From 58870103e58871642f14b119035edc1a812a5b29 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 6 Feb 2026 15:48:37 -0500 Subject: [PATCH] Implement escalation tie-breaker using severity ordering (#263) - Add getMostSevereResolution helper to modResponse.ts - Update escalationResolver to use tiebreaker instead of defaulting to track - Add comprehensive unit tests for getMostSevereResolution - Add integration tests for tie scenarios in voting.test.ts Co-Authored-By: Claude Sonnet 4.5 --- app/commands/escalate/escalationResolver.ts | 11 +++- app/commands/escalate/voting.test.ts | 31 ++++++++++ app/helpers/modResponse.test.ts | 64 +++++++++++++++++++++ app/helpers/modResponse.ts | 30 ++++++++++ 4 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 app/helpers/modResponse.test.ts diff --git a/app/commands/escalate/escalationResolver.ts b/app/commands/escalate/escalationResolver.ts index 94292b10..bb377300 100644 --- a/app/commands/escalate/escalationResolver.ts +++ b/app/commands/escalate/escalationResolver.ts @@ -19,6 +19,7 @@ import { } from "#~/effects/discordSdk"; import { logEffect } from "#~/effects/observability"; import { + getMostSevereResolution, humanReadableResolutions, resolutions, type Resolution, @@ -78,13 +79,17 @@ export const processEscalationEffect = ( if (tally.totalVotes === 0) { resolution = resolutions.track; } else if (tally.isTied) { + resolution = getMostSevereResolution(tally.tiedResolutions); yield* logEffect( "warn", "EscalationResolver", - "Auto-resolve defaulting to track due to tie", - { tiedResolutions: tally.tiedResolutions, votingStrategy }, + "Auto-resolve tie broken by severity", + { + tiedResolutions: tally.tiedResolutions, + selectedResolution: resolution, + votingStrategy, + }, ); - resolution = resolutions.track; } else if (tally.leader) { resolution = tally.leader; } else { diff --git a/app/commands/escalate/voting.test.ts b/app/commands/escalate/voting.test.ts index 70fe1d18..6cc1ba7c 100644 --- a/app/commands/escalate/voting.test.ts +++ b/app/commands/escalate/voting.test.ts @@ -68,6 +68,37 @@ describe("tallyVotes", () => { expect(result.tiedResolutions).toHaveLength(3); }); + it("provides tied resolutions with different severities for tiebreaker", () => { + const tally = tallyVotes([ + { vote: resolutions.timeout, voter_id: "user1" }, + { vote: resolutions.restrict, voter_id: "user2" }, + { vote: resolutions.timeout, voter_id: "user3" }, + { vote: resolutions.restrict, voter_id: "user4" }, + ]); + + expect(tally.isTied).toBe(true); + expect(tally.tiedResolutions).toHaveLength(2); + expect(tally.tiedResolutions).toContain(resolutions.timeout); + expect(tally.tiedResolutions).toContain(resolutions.restrict); + }); + + it("provides all tied resolutions in three-way tie for tiebreaker", () => { + const tally = tallyVotes([ + { vote: resolutions.track, voter_id: "user1" }, + { vote: resolutions.kick, voter_id: "user2" }, + { vote: resolutions.ban, voter_id: "user3" }, + { vote: resolutions.track, voter_id: "user4" }, + { vote: resolutions.kick, voter_id: "user5" }, + { vote: resolutions.ban, voter_id: "user6" }, + ]); + + expect(tally.isTied).toBe(true); + expect(tally.tiedResolutions).toHaveLength(3); + expect(tally.tiedResolutions).toContain(resolutions.track); + expect(tally.tiedResolutions).toContain(resolutions.kick); + expect(tally.tiedResolutions).toContain(resolutions.ban); + }); + it("breaks tie when one option gets more votes", () => { const votes = [ { vote: resolutions.ban, voter_id: "user1" }, diff --git a/app/helpers/modResponse.test.ts b/app/helpers/modResponse.test.ts new file mode 100644 index 00000000..afee445d --- /dev/null +++ b/app/helpers/modResponse.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { getMostSevereResolution, resolutions } from "./modResponse.js"; + +describe("getMostSevereResolution", () => { + it("returns most severe when given multiple resolutions", () => { + expect(getMostSevereResolution([resolutions.track, resolutions.ban])).toBe( + resolutions.ban, + ); + + expect( + getMostSevereResolution([resolutions.timeout, resolutions.restrict]), + ).toBe(resolutions.restrict); + + expect( + getMostSevereResolution([ + resolutions.track, + resolutions.kick, + resolutions.timeout, + ]), + ).toBe(resolutions.kick); + }); + + it("returns the resolution when given a single resolution", () => { + expect(getMostSevereResolution([resolutions.track])).toBe( + resolutions.track, + ); + expect(getMostSevereResolution([resolutions.ban])).toBe(resolutions.ban); + }); + + it("handles all resolutions being tied", () => { + expect( + getMostSevereResolution([ + resolutions.track, + resolutions.timeout, + resolutions.restrict, + resolutions.kick, + resolutions.ban, + ]), + ).toBe(resolutions.ban); + }); + + it("returns track for empty array", () => { + expect(getMostSevereResolution([])).toBe(resolutions.track); + }); + + it("correctly orders all severity levels", () => { + expect(getMostSevereResolution([resolutions.ban, resolutions.track])).toBe( + resolutions.ban, + ); + expect( + getMostSevereResolution([resolutions.track, resolutions.timeout]), + ).toBe(resolutions.timeout); + expect( + getMostSevereResolution([resolutions.timeout, resolutions.restrict]), + ).toBe(resolutions.restrict); + expect( + getMostSevereResolution([resolutions.restrict, resolutions.kick]), + ).toBe(resolutions.kick); + expect(getMostSevereResolution([resolutions.kick, resolutions.ban])).toBe( + resolutions.ban, + ); + }); +}); diff --git a/app/helpers/modResponse.ts b/app/helpers/modResponse.ts index 6e58c940..240819f7 100644 --- a/app/helpers/modResponse.ts +++ b/app/helpers/modResponse.ts @@ -16,6 +16,36 @@ export const humanReadableResolutions = { } as const; export type Resolution = (typeof resolutions)[keyof typeof resolutions]; +const severityOrder: Resolution[] = [ + resolutions.track, + resolutions.timeout, + resolutions.restrict, + resolutions.kick, + resolutions.ban, +]; + +export function getMostSevereResolution( + resolutionList: Resolution[], +): Resolution { + // Defensive: return track if empty (shouldn't happen in practice) + if (resolutionList.length === 0) { + return resolutions.track; + } + + let mostSevere = resolutionList[0]; + let highestIndex = severityOrder.indexOf(mostSevere); + + for (const resolution of resolutionList) { + const index = severityOrder.indexOf(resolution); + if (index > highestIndex) { + highestIndex = index; + mostSevere = resolution; + } + } + + return mostSevere; +} + export const votingStrategies = { simple: "simple", majority: "majority",