diff --git a/src/aggregate.test.ts b/src/aggregate.test.ts index 3c6153b..1158f52 100644 --- a/src/aggregate.test.ts +++ b/src/aggregate.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { entityKey, groupByEntity, type EntityRow } from "./aggregate.js"; +import { entityKey, groupByEntity, markRecency, type EntityRow } from "./aggregate.js"; /** * Binding-independent unit tests for entity aggregation (node pool). @@ -243,3 +243,32 @@ describe("duplicate rate regression (issue #189 measurements, 2026-08-01)", () = expect(entityKey(pool[2])).not.toBe(entityKey(pool[3])); // issue #1317 vs PR #1318 }); }); + +describe("markRecency (issue #267)", () => { + it("marks older rows superseded and newest latest", () => { + const m = markRecency([ + { id: "old", updatedAt: "2026-09-15T00:00:00Z" }, + { id: "new", updatedAt: "2026-09-29T00:00:00Z" }, + { id: "mid", updatedAt: "2026-09-23T00:00:00Z" }, + ]); + expect(m.get("old")).toBe("superseded"); + expect(m.get("mid")).toBe("superseded"); + expect(m.get("new")).toBe("latest"); + }); + it("marks nothing for a single row or equal timestamps", () => { + expect(markRecency([{ id: "a", updatedAt: "2026-09-15T00:00:00Z" }]).size).toBe(0); + expect(markRecency([ + { id: "a", updatedAt: "2026-09-15T00:00:00Z" }, + { id: "b", updatedAt: "2026-09-15T00:00:00Z" }, + ]).size).toBe(0); + }); + it("leaves unparsable timestamps unmarked", () => { + const m = markRecency([ + { id: "a", updatedAt: "" }, + { id: "b", updatedAt: "2026-09-15T00:00:00Z" }, + { id: "c", updatedAt: "2026-09-16T00:00:00Z" }, + ]); + expect(m.has("a")).toBe(false); + expect(m.get("c")).toBe("latest"); + }); +}); diff --git a/src/aggregate.ts b/src/aggregate.ts index 17cdec9..24e749f 100644 --- a/src/aggregate.ts +++ b/src/aggregate.ts @@ -127,3 +127,24 @@ export function groupByEntity( } return ordered; } + +/** Recency marker for one row of an entity group (issue #267). */ +export type Recency = "latest" | "superseded"; + +/** + * Compare `updated_at` across the representative and its folded rows and + * mark each one: "superseded" when another row in the group is newer, + * "latest" for the newest. Returns an empty map when the group holds fewer + * than two distinct timestamps — the pool proves nothing about recency then. + * Rows with an unparsable timestamp are left unmarked. + */ +export function markRecency(rows: Array<{ id: string; updatedAt: string | null | undefined }>): Map { + const out = new Map(); + const times = rows + .map((r) => ({ id: r.id, t: r.updatedAt ? Date.parse(r.updatedAt) : NaN })) + .filter((r) => !Number.isNaN(r.t)); + if (new Set(times.map((r) => r.t)).size < 2) return out; + const max = Math.max(...times.map((r) => r.t)); + for (const r of times) out.set(r.id, r.t === max ? "latest" : "superseded"); + return out; +} diff --git a/src/mcp.ts b/src/mcp.ts index 7cc62e3..dfbe2fb 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -59,7 +59,7 @@ import { FETCH_CONTENT_MAX_CHARS, FETCH_MAX_VECTOR_IDS, } from "./fetch.js"; -import { entityKey, groupByEntity } from "./aggregate.js"; +import { entityKey, groupByEntity, markRecency } from "./aggregate.js"; import { pathPrefixRange, validateDocPathPrefix } from "./path-prefix.js"; const GITHUB_API = "https://api.github.com"; @@ -337,7 +337,10 @@ export function createRagMcpServer(env: Env): McpServer { "(Master's feedback, AI responses, self-review now/later/accepted classifications).\n" + "Results are aggregated per underlying entity: a file's doc row and its commit diffs are one result, " + "an issue or PR and its comments / reviews are one result. top_k therefore counts distinct entities, " + - "and a result that absorbed others carries same_entity { count, others[] } with links to them.\n" + + "and a result that absorbed others carries same_entity { count, others[] } with links to them. " + + "When those rows hold more than one distinct updated_at, the representative and each others entry carry recency: " + + "\"superseded\" = a newer row of the same entity is in the pool, \"latest\" = the newest row in the pool. " + + "Absent recency is not a claim of being latest, and recency never affects ranking or scores.\n" + "Every result row — and every same_entity.others entry — carries vector_id, the handle mode 4 takes. " + "It is a handle for reaching a row you just found, not a durable identifier: the id scheme has been " + "migrated before and may be again, so do not store one for later use.\n" + @@ -917,8 +920,19 @@ export function createRagMcpServer(env: Env): McpServer { updated_at: string; score: number; commit_sha?: string; + /** See ResultItem.recency (issue #267). */ + recency?: "latest" | "superseded"; }>; }; + /** + * Additive recency marker (issue #267), set only when the + * representative and its same_entity.others hold more than one + * distinct updated_at: "superseded" = a newer row of the same entity + * is in the pool, "latest" = newest row in the pool. Never affects + * ranking or scores. Absent = no newer version in the pool, which is + * not a claim of being latest. + */ + recency?: "latest" | "superseded"; }; const items: ResultItem[] = filtered.map((f) => { @@ -928,6 +942,10 @@ export function createRagMcpServer(env: Env): McpServer { // Rows folded into this representative. Kept as references (never // dropped) so the caller can still reach every version / comment. const folded = collapsedInto.get(f.vectorId) ?? []; + const recency = markRecency([ + { id: f.vectorId, updatedAt: r.updatedAt }, + ...folded.map((o) => ({ id: o.vectorId, updatedAt: resolveRow(payload.get(o.vectorId)).updatedAt })), + ]); const sameEntity = folded.length > 0 ? { @@ -942,6 +960,7 @@ export function createRagMcpServer(env: Env): McpServer { updated_at: or.updatedAt, score: o.fusedScore, ...(or.type === "diff" ? { commit_sha: or.commitSha } : {}), + ...(recency.has(o.vectorId) ? { recency: recency.get(o.vectorId) } : {}), }; }), } @@ -1008,6 +1027,7 @@ export function createRagMcpServer(env: Env): McpServer { } : {}), ...(sameEntity ? { same_entity: sameEntity } : {}), + ...(recency.has(f.vectorId) ? { recency: recency.get(f.vectorId) } : {}), }; });