diff --git a/packages/gittensory-engine/src/duplicate-winner.ts b/packages/gittensory-engine/src/duplicate-winner.ts new file mode 100644 index 0000000000..48bfaa689e --- /dev/null +++ b/packages/gittensory-engine/src/duplicate-winner.ts @@ -0,0 +1,111 @@ +/** + * Duplicate-winner adjudication (#dup-winner). Flag-gated by GITTENSORY_DUPLICATE_WINNER. + * + * When several OPEN PRs link the same issue (a duplicate cluster), the legacy behavior gate-blocks + + * auto-closes EVERY sibling as a duplicate — no winner survives. With the flag ON, exactly ONE winner is + * spared: the earliest claimant. Sparse legacy rows that do not yet have claim timing fail closed so unknown + * ordering cannot arbitrarily suppress duplicate evidence. Only the LOSERS are blocked/closed; the winner + * still must pass CI / conflict / gate / linked-issue / slop on its OWN merits. + * + * This module is PURE — no IO, no Date, no random — so the same inputs always yield the same verdict and the + * caller can compute the winner ONCE per review run and thread the result boolean consistently into every + * surface (advisory finding, close reason, slop, panels), so they agree by construction. + * + * ELECTION ORDER (#dup-winner true-creation-time): prefer each PR's true GitHub `pull_request.created_at` — + * the real order contributors opened their PRs in — over `linkedIssueClaimedAt` (gittensory's own sync-time, + * i.e. whenever a webhook/sweep/backfill pass happened to OBSERVE the linked issue). Sync order and creation + * order diverge whenever processing isn't strictly FIFO (a stalled sweep catching up on a backlog, backfill + * reordering, webhook delivery delay), under the old claim-time-only rule, that divergence could crown a + * LATER contributor the winner and close the PR of whoever actually opened first. `createdAt` is compared + * only when BOTH sides of a given comparison have a valid one; otherwise this falls back to the legacy + * claim-time comparison unchanged, so sparse/legacy rows keep their existing fail-closed behavior exactly. + * + * INVARIANT (the caller MUST honor it): {@link openSiblingNumbers} carries OPEN-only sibling PR numbers. The + * existing sources already exclude closed/merged PRs. Once the winner closes (e.g. red CI), it leaves the open + * set and the next-earliest OPEN claimant becomes the winner on re-eval — no permanently-orphaned cluster. + * + * SECOND CONSUMER (#2278): this module is intentionally engine-hosted (not `src/`-only) because its election + * logic is reusable for the miner's own soft-claim adjudication — deciding which of several miners claiming + * the same issue proceeds. A future contributor wiring the miner's local claim ledger should import this + * module rather than reimplementing the election rule, so both the maintainer gate and the miner agree on + * exactly one winner by construction. + */ + +export type DuplicateClaimMember = { + number: number; + linkedIssueClaimedAt?: string | null | undefined; + /** GitHub's true PR creation time. See the module doc's "ELECTION ORDER" note. */ + createdAt?: string | null | undefined; +}; + +/** + * True iff `prNumber` is the cluster winner: the minimum of `{prNumber} ∪ openSiblingNumbers`. An empty + * sibling list ⇒ the PR is alone in (or out of) the cluster ⇒ winner. A sibling list that happens to contain + * `prNumber` itself is harmless — the comparison is still min-based. + * + * @deprecated Use {@link isDuplicateClusterWinnerByClaim}. PR-number election is retained only for legacy + * compatibility callers that do not have claim timestamps. + */ +export function isDuplicateClusterWinner(prNumber: number, openSiblingNumbers: number[]): boolean { + for (const sibling of openSiblingNumbers) { + if (sibling < prNumber) return false; + } + return true; +} + +/** + * True iff `pr` is the earliest-elected claimant in the open duplicate cluster (see the module doc's + * "ELECTION ORDER" note for the createdAt-vs-claim-time precedence). Sparse legacy rows fail closed; ties + * between equally-ordered members use PR number. + */ +export function isDuplicateClusterWinnerByClaim(pr: DuplicateClaimMember, openSiblings: DuplicateClaimMember[]): boolean { + if (openSiblings.length === 0) return true; + for (const sibling of openSiblings) { + if (!prPrecedesSibling(pr, sibling)) return false; + } + return true; +} + +/** + * True iff `pr` is ordered at or ahead of `sibling` for cluster-winner purposes. Prefers `createdAt` when BOTH + * sides have a valid one (the true creation-time order); otherwise falls back to the legacy `linkedIssueClaimedAt` + * comparison unchanged (including its fail-closed-on-missing/invalid-timestamp behavior), so a mixed + * legacy/modern cluster never silently guesses using two different clocks for the two sides of one comparison. + */ +function prPrecedesSibling(pr: DuplicateClaimMember, sibling: DuplicateClaimMember): boolean { + const prCreated = claimTimeMs(pr.createdAt); + const siblingCreated = claimTimeMs(sibling.createdAt); + if (prCreated !== null && siblingCreated !== null) { + if (prCreated !== siblingCreated) return prCreated < siblingCreated; + return pr.number <= sibling.number; + } + const prClaim = claimTimeMs(pr.linkedIssueClaimedAt); + if (prClaim === null) return false; + const siblingClaim = claimTimeMs(sibling.linkedIssueClaimedAt); + if (siblingClaim === null) return false; + if (siblingClaim < prClaim) return false; + if (siblingClaim === prClaim && sibling.number < pr.number) return false; + return true; +} + +/** + * The winning PR number among `pr` and its open duplicate siblings, or `null` when the election is not + * determinable (mirrors {@link isDuplicateClusterWinnerByClaim}'s fail-closed semantics — this never guesses a + * specific winner when the ordering data is too sparse/ambiguous to be sure). Used only for DISPLAY (naming the + * winner in a loser's close comment, #dup-winner-credit) — the close/hold decision for any given PR is still + * driven directly by {@link isDuplicateClusterWinnerByClaim}, not by this function's return value. + */ +export function resolveDuplicateClusterWinnerNumber(pr: DuplicateClaimMember, openSiblings: DuplicateClaimMember[]): number | null { + if (isDuplicateClusterWinnerByClaim(pr, openSiblings)) return pr.number; + for (const sibling of openSiblings) { + const rest = openSiblings.filter((other) => other.number !== sibling.number); + if (isDuplicateClusterWinnerByClaim(sibling, [pr, ...rest])) return sibling.number; + } + return null; +} + +function claimTimeMs(value: string | null | undefined): number | null { + if (!value) return null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 53513e7b95..47a63faf05 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -225,3 +225,9 @@ export { bestMetadataOpportunityAtOrAboveScore } from "./metadata-best-min-score export * as scoringModel from "./scoring/model.js"; export * as scoringPreview from "./scoring/preview.js"; export * as scoringPendingPrScenarios from "./scoring/pending-pr-scenarios.js"; +export { + isDuplicateClusterWinner, + isDuplicateClusterWinnerByClaim, + resolveDuplicateClusterWinnerNumber, + type DuplicateClaimMember, +} from "./duplicate-winner.js"; diff --git a/packages/gittensory-engine/test/duplicate-winner.test.ts b/packages/gittensory-engine/test/duplicate-winner.test.ts new file mode 100644 index 0000000000..522cc38d21 --- /dev/null +++ b/packages/gittensory-engine/test/duplicate-winner.test.ts @@ -0,0 +1,106 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + isDuplicateClusterWinner, + isDuplicateClusterWinnerByClaim, + resolveDuplicateClusterWinnerNumber, +} from "../dist/index.js"; + +test("barrel: the public entrypoint re-exports the duplicate-winner adjudication API", () => { + assert.equal(typeof isDuplicateClusterWinner, "function"); + assert.equal(typeof isDuplicateClusterWinnerByClaim, "function"); + assert.equal(typeof resolveDuplicateClusterWinnerNumber, "function"); +}); + +test("isDuplicateClusterWinner: the lowest open sibling number wins", () => { + assert.equal(isDuplicateClusterWinner(5, [7, 9]), true); +}); + +test("isDuplicateClusterWinner: a lower open sibling beats this PR (loser)", () => { + assert.equal(isDuplicateClusterWinner(5, [3, 9]), false); +}); + +test("isDuplicateClusterWinner: an empty sibling list is always a winner", () => { + assert.equal(isDuplicateClusterWinner(5, []), true); +}); + +test("isDuplicateClusterWinnerByClaim: an empty sibling list is always a winner", () => { + assert.equal(isDuplicateClusterWinnerByClaim({ number: 5 }, []), true); +}); + +test("isDuplicateClusterWinnerByClaim: elects the earliest observed linked-issue claimant, not the lowest PR number", () => { + const earlier = { number: 9, linkedIssueClaimedAt: "2026-01-01T00:00:00Z" }; + const later = { number: 3, linkedIssueClaimedAt: "2026-01-02T00:00:00Z" }; + assert.equal(isDuplicateClusterWinnerByClaim(earlier, [later]), true); + assert.equal(isDuplicateClusterWinnerByClaim(later, [earlier]), false); +}); + +test("isDuplicateClusterWinnerByClaim: falls back to PR number for an equal known claim timestamp", () => { + const a = { number: 3, linkedIssueClaimedAt: "2026-01-01T00:00:00Z" }; + const b = { number: 9, linkedIssueClaimedAt: "2026-01-01T00:00:00Z" }; + assert.equal(isDuplicateClusterWinnerByClaim(a, [b]), true); + assert.equal(isDuplicateClusterWinnerByClaim(b, [a]), false); +}); + +test("isDuplicateClusterWinnerByClaim: fails closed when sparse legacy rows lack claim timestamps", () => { + assert.equal(isDuplicateClusterWinnerByClaim({ number: 5 }, [{ number: 9 }]), false); +}); + +test("isDuplicateClusterWinnerByClaim: fails closed on an invalid claim timestamp", () => { + assert.equal( + isDuplicateClusterWinnerByClaim({ number: 5, linkedIssueClaimedAt: "not-a-date" }, [{ number: 9, linkedIssueClaimedAt: "2026-01-01T00:00:00Z" }]), + false, + ); +}); + +test("isDuplicateClusterWinnerByClaim createdAt precedence: elects the PR GitHub says opened first, even when observed later", () => { + const openedFirstButClaimedLater = { + number: 9, + createdAt: "2026-01-01T00:00:00Z", + linkedIssueClaimedAt: "2026-01-05T00:00:00Z", + }; + const openedSecondButClaimedFirst = { + number: 3, + createdAt: "2026-01-02T00:00:00Z", + linkedIssueClaimedAt: "2026-01-01T00:00:00Z", + }; + assert.equal(isDuplicateClusterWinnerByClaim(openedFirstButClaimedLater, [openedSecondButClaimedFirst]), true); + assert.equal(isDuplicateClusterWinnerByClaim(openedSecondButClaimedFirst, [openedFirstButClaimedLater]), false); +}); + +test("isDuplicateClusterWinnerByClaim createdAt precedence: falls back to claim-time when only one side has a valid createdAt", () => { + const modern = { number: 9, createdAt: "2026-01-01T00:00:00Z", linkedIssueClaimedAt: "2026-01-05T00:00:00Z" }; + const legacy = { number: 3, linkedIssueClaimedAt: "2026-01-02T00:00:00Z" }; + // Neither side has BOTH createdAt values, so this falls back to claim-time comparison: modern claimed later, so legacy wins. + assert.equal(isDuplicateClusterWinnerByClaim(legacy, [modern]), true); + assert.equal(isDuplicateClusterWinnerByClaim(modern, [legacy]), false); +}); + +test("isDuplicateClusterWinnerByClaim createdAt precedence: ties break by PR number", () => { + const a = { number: 3, createdAt: "2026-01-01T00:00:00Z" }; + const b = { number: 9, createdAt: "2026-01-01T00:00:00Z" }; + assert.equal(isDuplicateClusterWinnerByClaim(a, [b]), true); + assert.equal(isDuplicateClusterWinnerByClaim(b, [a]), false); +}); + +test("resolveDuplicateClusterWinnerNumber: returns this PR's own number when it is the winner", () => { + const pr = { number: 5, linkedIssueClaimedAt: "2026-01-01T00:00:00Z" }; + const sibling = { number: 9, linkedIssueClaimedAt: "2026-01-02T00:00:00Z" }; + assert.equal(resolveDuplicateClusterWinnerNumber(pr, [sibling]), 5); +}); + +test("resolveDuplicateClusterWinnerNumber: returns the actual winning sibling's number when this PR is a loser", () => { + const pr = { number: 9, linkedIssueClaimedAt: "2026-01-02T00:00:00Z" }; + const sibling = { number: 5, linkedIssueClaimedAt: "2026-01-01T00:00:00Z" }; + assert.equal(resolveDuplicateClusterWinnerNumber(pr, [sibling]), 5); +}); + +test("resolveDuplicateClusterWinnerNumber: an empty sibling list means this PR wins by default", () => { + assert.equal(resolveDuplicateClusterWinnerNumber({ number: 5 }, []), 5); +}); + +test("resolveDuplicateClusterWinnerNumber: returns null when the election is too ambiguous to name a specific winner", () => { + // Every member lacks a claim timestamp, so no one can be proven the winner (fails closed). + assert.equal(resolveDuplicateClusterWinnerNumber({ number: 5 }, [{ number: 9 }, { number: 3 }]), null); +}); diff --git a/src/signals/duplicate-winner.ts b/src/signals/duplicate-winner.ts index 9de8f9f496..c7980fe396 100644 --- a/src/signals/duplicate-winner.ts +++ b/src/signals/duplicate-winner.ts @@ -1,105 +1,15 @@ /** - * Duplicate-winner adjudication (#dup-winner). Flag-gated by GITTENSORY_DUPLICATE_WINNER. - * - * When several OPEN PRs link the same issue (a duplicate cluster), the legacy behavior gate-blocks + - * auto-closes EVERY sibling as a duplicate — no winner survives. With the flag ON, exactly ONE winner is - * spared: the earliest claimant. Sparse legacy rows that do not yet have claim timing fail closed so unknown - * ordering cannot arbitrarily suppress duplicate evidence. Only the LOSERS are blocked/closed; the winner - * still must pass CI / conflict / gate / linked-issue / slop on its OWN merits. - * - * This module is PURE — no IO, no Date, no random — so the same inputs always yield the same verdict and the - * caller can compute the winner ONCE per review run and thread the result boolean consistently into every - * surface (advisory finding, close reason, slop, panels), so they agree by construction. - * - * ELECTION ORDER (#dup-winner true-creation-time): prefer each PR's true GitHub `pull_request.created_at` — - * the real order contributors opened their PRs in — over `linkedIssueClaimedAt` (gittensory's own sync-time, - * i.e. whenever a webhook/sweep/backfill pass happened to OBSERVE the linked issue). Sync order and creation - * order diverge whenever processing isn't strictly FIFO (a stalled sweep catching up on a backlog, backfill - * reordering, webhook delivery delay), under the old claim-time-only rule, that divergence could crown a - * LATER contributor the winner and close the PR of whoever actually opened first. `createdAt` is compared - * only when BOTH sides of a given comparison have a valid one; otherwise this falls back to the legacy - * claim-time comparison unchanged, so sparse/legacy rows keep their existing fail-closed behavior exactly. - * - * INVARIANT (the caller MUST honor it): {@link openSiblingNumbers} carries OPEN-only sibling PR numbers. The - * existing sources already exclude closed/merged PRs. Once the winner closes (e.g. red CI), it leaves the open - * set and the next-earliest OPEN claimant becomes the winner on re-eval — no permanently-orphaned cluster. - */ - -export type DuplicateClaimMember = { - number: number; - linkedIssueClaimedAt?: string | null | undefined; - /** GitHub's true PR creation time. See the module doc's "ELECTION ORDER" note. */ - createdAt?: string | null | undefined; -}; - -/** - * True iff `prNumber` is the cluster winner: the minimum of `{prNumber} ∪ openSiblingNumbers`. An empty - * sibling list ⇒ the PR is alone in (or out of) the cluster ⇒ winner. A sibling list that happens to contain - * `prNumber` itself is harmless — the comparison is still min-based. - * - * @deprecated Use {@link isDuplicateClusterWinnerByClaim}. PR-number election is retained only for legacy - * compatibility callers that do not have claim timestamps. - */ -export function isDuplicateClusterWinner(prNumber: number, openSiblingNumbers: number[]): boolean { - for (const sibling of openSiblingNumbers) { - if (sibling < prNumber) return false; - } - return true; -} - -/** - * True iff `pr` is the earliest-elected claimant in the open duplicate cluster (see the module doc's - * "ELECTION ORDER" note for the createdAt-vs-claim-time precedence). Sparse legacy rows fail closed; ties - * between equally-ordered members use PR number. - */ -export function isDuplicateClusterWinnerByClaim(pr: DuplicateClaimMember, openSiblings: DuplicateClaimMember[]): boolean { - if (openSiblings.length === 0) return true; - for (const sibling of openSiblings) { - if (!prPrecedesSibling(pr, sibling)) return false; - } - return true; -} - -/** - * True iff `pr` is ordered at or ahead of `sibling` for cluster-winner purposes. Prefers `createdAt` when BOTH - * sides have a valid one (the true creation-time order); otherwise falls back to the legacy `linkedIssueClaimedAt` - * comparison unchanged (including its fail-closed-on-missing/invalid-timestamp behavior), so a mixed - * legacy/modern cluster never silently guesses using two different clocks for the two sides of one comparison. - */ -function prPrecedesSibling(pr: DuplicateClaimMember, sibling: DuplicateClaimMember): boolean { - const prCreated = claimTimeMs(pr.createdAt); - const siblingCreated = claimTimeMs(sibling.createdAt); - if (prCreated !== null && siblingCreated !== null) { - if (prCreated !== siblingCreated) return prCreated < siblingCreated; - return pr.number <= sibling.number; - } - const prClaim = claimTimeMs(pr.linkedIssueClaimedAt); - if (prClaim === null) return false; - const siblingClaim = claimTimeMs(sibling.linkedIssueClaimedAt); - if (siblingClaim === null) return false; - if (siblingClaim < prClaim) return false; - if (siblingClaim === prClaim && sibling.number < pr.number) return false; - return true; -} - -/** - * The winning PR number among `pr` and its open duplicate siblings, or `null` when the election is not - * determinable (mirrors {@link isDuplicateClusterWinnerByClaim}'s fail-closed semantics — this never guesses a - * specific winner when the ordering data is too sparse/ambiguous to be sure). Used only for DISPLAY (naming the - * winner in a loser's close comment, #dup-winner-credit) — the close/hold decision for any given PR is still - * driven directly by {@link isDuplicateClusterWinnerByClaim}, not by this function's return value. - */ -export function resolveDuplicateClusterWinnerNumber(pr: DuplicateClaimMember, openSiblings: DuplicateClaimMember[]): number | null { - if (isDuplicateClusterWinnerByClaim(pr, openSiblings)) return pr.number; - for (const sibling of openSiblings) { - const rest = openSiblings.filter((other) => other.number !== sibling.number); - if (isDuplicateClusterWinnerByClaim(sibling, [pr, ...rest])) return sibling.number; - } - return null; -} - -function claimTimeMs(value: string | null | undefined): number | null { - if (!value) return null; - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : null; -} + * Duplicate-winner adjudication (#dup-winner), extracted to `@jsonbored/gittensory-engine` (#2278) so the + * maintainer gate and the miner's own soft-claim adjudication (a later Phase-0 issue) import the identical, + * versioned election logic instead of drifting apart. See the engine module's doc comment for the full + * election-order rationale (createdAt-vs-claim-time precedence, fail-closed semantics). + * + * packages/gittensory-engine/src/duplicate-winner.ts (imported via relative source path, not the published + * module, matching the #2282 scoring-preview extraction) is the source of truth. + */ +export { + isDuplicateClusterWinner, + isDuplicateClusterWinnerByClaim, + resolveDuplicateClusterWinnerNumber, + type DuplicateClaimMember, +} from "../../packages/gittensory-engine/src/duplicate-winner";