From d845b30e5aede73d2042132840d56ff1dd5f08cc Mon Sep 17 00:00:00 2001 From: StressTestor Date: Thu, 23 Jul 2026 01:37:26 -0600 Subject: [PATCH 1/2] feat(starmap): surface createdAt on starmap items --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 8 ++++++++ src/__tests__/starmap.test.ts | 30 ++++++++++++++++++++++++++++++ src/starmap.ts | 7 ++++++- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b403ae3..1fc5249 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -71,7 +71,7 @@ server/ # webhook server (GitHub App) - **incremental processing**: only re-embeds new/changed items. crash-recoverable via sqlite - **read-only default**: every GitHub mutation (labels, comments, closes, issue creation) funnels through one `write-gate.ts` gate that defaults to dry-run. CLI writes only under `--apply-labels`; the webhook server writes only when `PRISM_APPLY=1`. `--dry-run` always wins. (Fixes the prior leak where `ensureLabelsExist` created labels even under `--dry-run`, and the server writing unconditionally.) - **cross-repo**: config accepts multiple repos, dupe detection works across repo boundaries -- **canonical selection**: one `selectCanonical()` (src/canonical.ts) picks each cluster's source of truth for the report, the starmap payload, and the live triage bot alike. issue-majority clusters resolve to the earliest report (the original bug); PR-majority ranks by lifecycle state (merged > open > closed), then a CI veto (a known-red build never outranks a same-state green sibling, before score), then quality score. the veto stops a high-scored PR with failing checks from becoming bestPick over the green fix that actually landed; only `ciStatus === "failure"` demotes, so a not-yet-reported PR is never penalized. fully deterministic - every tie bottoms out at item number - so re-runs name the same canonical +- **canonical selection**: one `selectCanonical()` (src/canonical.ts) picks each cluster's source of truth for the report, the starmap payload, and the live triage bot alike. issue-majority clusters resolve to the earliest report (the original bug); PR-majority ranks by lifecycle state (merged > open > closed), then a CI veto (a known-red build never outranks a same-state green sibling, before score), then quality score. the veto stops a high-scored PR with failing checks from becoming bestPick over the green fix that actually landed; only `ciStatus === "failure"` demotes, so a not-yet-reported PR is never penalized. fully deterministic - every tie bottoms out at item number - so re-runs name the same canonical. confirmed (identity) clusters override to the earliest-created rule: byte-identical dupes resolve by which-was-first, never score (a copy can outscore its original) - **cluster confidence**: clustering is single-linkage (BFS over pairs >= threshold) with a centroid-refinement pass to break chained mega-clusters. because single-linkage can still chain in loosely-related members, each cluster reports both `avgSimilarity` and `minSimilarity` (lowest pairwise). the report/dupes output surfaces min as a confidence tier (high >= 90%, solid >= 80%, loose < 80%) so a low-min "loose" cluster gets eyeballed before anything is closed. avg and min are computed exactly over all pairs (no sampling), so the tier a maintainer sees is reproducible run to run ## database diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b29216..de47d8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ all notable changes to pr-prism are documented here. +## [unreleased] + +### changed +- confirmed (identity) duplicate clusters now pick canonical by earliest-created instead of quality score: byte-identical duplicates are a which-was-first question, and a copied PR can outscore the original it was lifted from. fuzzy clusters keep the state/CI/score rule. starmap `canonical`/`contested` for confirmed clusters shift accordingly (value change, schema stays v1) + +### added +- starmap items now carry `createdAt` alongside `updatedAt` (additive), so consumers can render and reason about which-was-first without re-fetching from github. NOTE for star-map: its importer rejects unknown fields, so it needs the coordinated importer patch before consuming a dataset with this field + ## [3.1.0] — 2026-07-20 ### added diff --git a/src/__tests__/starmap.test.ts b/src/__tests__/starmap.test.ts index 5332287..d4afe12 100644 --- a/src/__tests__/starmap.test.ts +++ b/src/__tests__/starmap.test.ts @@ -272,3 +272,33 @@ describe("buildStarmapPayload relational classification", () => { expect(p.schemaVersion).toBe(1); }); }); + +describe("confirmed clusters in the starmap payload", () => { + it("canonical follows earliest-created for identity clusters and items carry createdAt", () => { + const original = item(100, "pr", 0.2, { createdAt: "2026-01-01T00:00:00Z" }); + const copy = item(200, "pr", 0.9, { createdAt: "2026-03-01T00:00:00Z" }); + const confirmed: Cluster = { + ...cluster(1, [original, copy], 1, 1), + kind: "identity", + identity: { basis: "head-oid", key: "oid" }, + }; + const p = buildStarmapPayload([], META, { confirmed: [confirmed] }); + expect(p.clusters[0].canonical.number).toBe(100); + expect(p.clusters[0].items.every((i) => i.createdAt !== undefined)).toBe(true); + }); + + it("fuzzy PR clusters keep the quality-score canonical rule", () => { + const clusters = [ + cluster( + 1, + [ + item(10, "pr", 0.9, { createdAt: "2026-03-01T00:00:00Z" }), + item(11, "pr", 0.2, { createdAt: "2026-01-01T00:00:00Z" }), + ], + 0.9, + 0.88, + ), + ]; + expect(buildStarmapPayload(clusters, META).clusters[0].canonical.number).toBe(10); + }); +}); diff --git a/src/starmap.ts b/src/starmap.ts index 382ffac..315b76e 100644 --- a/src/starmap.ts +++ b/src/starmap.ts @@ -30,6 +30,7 @@ export interface StarmapItemRef { export interface StarmapItem extends StarmapItemRef { title: string; author: string; + createdAt: string; updatedAt: string; score: number; /** PRs only: same-repo issue numbers this PR closes. Omitted when the item @@ -148,7 +149,10 @@ export function buildStarmapPayload( // contested / runnerUp come from decideCanonical so they reflect the actual // canonical rule (earliest-report for issue clusters), not a score sort. const runnerUpMargin = ranked.length >= 2 ? ranked[0].score - ranked[1].score : null; - const decision = decideCanonical(c.items); + // Identity (confirmed byte-identical) clusters resolve canonical by + // which-was-first, matching identity.ts's bestPick — mode "issue" is the + // earliest-created rule. Fuzzy clusters keep the derived state/CI/score rule. + const decision = decideCanonical(c.items, c.kind === "identity" ? { mode: "issue" } : undefined); const trackerDecision = selectTracker(c.items); const tracker: StarmapTracker = { needsTracker: trackerDecision.needsTracker, @@ -160,6 +164,7 @@ export function buildStarmapPayload( ...ref(it), title: sanitizeTitle(it.title), author: it.author, + createdAt: it.createdAt, updatedAt: it.updatedAt, score: it.score, }; From f38589fae1d9de59b68ffa4017a834022a113915 Mon Sep 17 00:00:00 2001 From: StressTestor Date: Thu, 23 Jul 2026 01:37:26 -0600 Subject: [PATCH 2/2] fix(identity): confirmed dupes pick canonical by earliest-created, not score a byte-identical copy can outscore the original it was lifted from; naming the copy canonical rewards the theft. identity clusters now resolve by the earliest-created rule (mode issue, 24h near-tie window) in both the cluster bestPick and the starmap canonical. fuzzy clusters keep the state/CI/score rule. --- src/__tests__/identity.test.ts | 20 ++++++++++++++++++++ src/identity.ts | 6 +++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/__tests__/identity.test.ts b/src/__tests__/identity.test.ts index 98e28a1..50d0b6a 100644 --- a/src/__tests__/identity.test.ts +++ b/src/__tests__/identity.test.ts @@ -88,3 +88,23 @@ describe("diffFingerprint", () => { expect(diffFingerprint(a)).not.toBe(diffFingerprint(c)); }); }); + +describe("identity canonical is which-was-first", () => { + it("earliest-created member wins the bestPick even when a later copy outscores it", () => { + const original = pr(100, { + headRefOid: "same-oid", + createdAt: "2026-01-01T00:00:00Z", + ciStatus: "unknown", + }); + const copy = pr(200, { + headRefOid: "same-oid", + createdAt: "2026-03-01T00:00:00Z", + ciStatus: "success", + hasTests: true, + reviewCount: 3, + }); + const clusters = findConfirmedDuplicates([copy, original]); + expect(clusters).toHaveLength(1); + expect(clusters[0].bestPick.number).toBe(100); + }); +}); diff --git a/src/identity.ts b/src/identity.ts index 331c330..0b4630b 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -41,7 +41,11 @@ function pushGroup(map: Map, key: string, item: PRItem): void function makeIdentityCluster(members: PRItem[], identity: { basis: "head-oid" | "patch-id"; key: string }): Cluster { const items = members.map(scoreClusterItem).sort((a, b) => b.score - a.score); - const bestPick = selectCanonical(items); + // Confirmed byte-identical duplicates resolve by WHICH-WAS-FIRST, not quality: + // a copied PR can easily outscore the original it was lifted from, and naming + // the copy canonical rewards the theft. mode "issue" is the earliest-created + // rule (with its 24h near-tie window for genuinely simultaneous pushes). + const bestPick = selectCanonical(items, { mode: "issue" }); return { id: 0, // reassigned by the caller items,