From 4b94fb16468a6cae48d6b32fb4b9d5c6518c86bd Mon Sep 17 00:00:00 2001 From: philluiz2323 Date: Tue, 7 Jul 2026 01:00:29 +0400 Subject: [PATCH 1/2] feat(miner-foundation): extract duplicate-winner adjudication into gittensory-engine (#2278) Ports the duplicate-cluster election logic (isDuplicateClusterWinner, isDuplicateClusterWinnerByClaim, resolveDuplicateClusterWinnerNumber) into packages/gittensory-engine/src/duplicate-winner.ts so the maintainer gate and the miner's own soft-claim adjudication share one versioned election rule instead of drifting apart. src/signals/duplicate-winner.ts is now a thin re-export shim; the existing 41-test suite passes unmodified against it. Adds a dedicated engine-package test file mirroring the package's own node:test convention. --- .../gittensory-engine/src/duplicate-winner.ts | 111 +++++++++++++++++ packages/gittensory-engine/src/index.ts | 6 + .../test/duplicate-winner.test.ts | 106 ++++++++++++++++ src/signals/duplicate-winner.ts | 115 ++---------------- 4 files changed, 234 insertions(+), 104 deletions(-) create mode 100644 packages/gittensory-engine/src/duplicate-winner.ts create mode 100644 packages/gittensory-engine/test/duplicate-winner.test.ts 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..02eeb80544 100644 --- a/src/signals/duplicate-winner.ts +++ b/src/signals/duplicate-winner.ts @@ -1,105 +1,12 @@ /** - * 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) — re-exported from `@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). + */ +export { + isDuplicateClusterWinner, + isDuplicateClusterWinnerByClaim, + resolveDuplicateClusterWinnerNumber, + type DuplicateClaimMember, +} from "@jsonbored/gittensory-engine"; From aa27bae90629ed6691fdb45287039215dda6b537 Mon Sep 17 00:00:00 2001 From: philluiz2323 Date: Tue, 7 Jul 2026 01:42:46 +0400 Subject: [PATCH 2/2] fix(miner-foundation): import duplicate-winner via relative engine source path CI's fresh npm ci does not link @jsonbored/gittensory-engine into root node_modules since root package.json never declares it as a dependency (matching the #2282 scoring-preview extraction, which deliberately avoids that edge by importing the engine module via its relative source path instead of the published package specifier). Switches the shim to the same pattern so typecheck and ui:build resolve the module on a clean checkout instead of only via a locally stale workspace symlink. --- src/signals/duplicate-winner.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/signals/duplicate-winner.ts b/src/signals/duplicate-winner.ts index 02eeb80544..c7980fe396 100644 --- a/src/signals/duplicate-winner.ts +++ b/src/signals/duplicate-winner.ts @@ -1,12 +1,15 @@ /** - * Duplicate-winner adjudication (#dup-winner) — re-exported from `@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). + * 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 "@jsonbored/gittensory-engine"; +} from "../../packages/gittensory-engine/src/duplicate-winner";