Skip to content

Commit 79f05cd

Browse files
liplus-lin-laysmileygamesclaude
authored
feat(search): add recency marker to same_entity rows (#268)
* feat(search): mark same_entity rows with recency (latest/superseded) 同じ実体の代表行と same_entity.others の updated_at を比較し、より新しい行がある行に superseded、最新行に latest を付ける加算的フィールドを追加した。順位・score・rerank は変えない。プール内に版が一つしか無い場合は印を付けない。#267 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XiAGywnJ6R9ibkNrxg9fry * docs(search): document recency in search tool description 検索ツールの説明文に recency(superseded / latest)の説明を追加した。前回のコミットで説明文の置換が漏れていたため。#267 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XiAGywnJ6R9ibkNrxg9fry --------- Co-authored-by: Yoshiharu Uematsu <smileygames2021@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3b9e29b commit 79f05cd

3 files changed

Lines changed: 73 additions & 3 deletions

File tree

‎src/aggregate.test.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { entityKey, groupByEntity, type EntityRow } from "./aggregate.js";
2+
import { entityKey, groupByEntity, markRecency, type EntityRow } from "./aggregate.js";
33

44
/**
55
* Binding-independent unit tests for entity aggregation (node pool).
@@ -243,3 +243,32 @@ describe("duplicate rate regression (issue #189 measurements, 2026-08-01)", () =
243243
expect(entityKey(pool[2])).not.toBe(entityKey(pool[3])); // issue #1317 vs PR #1318
244244
});
245245
});
246+
247+
describe("markRecency (issue #267)", () => {
248+
it("marks older rows superseded and newest latest", () => {
249+
const m = markRecency([
250+
{ id: "old", updatedAt: "2026-09-15T00:00:00Z" },
251+
{ id: "new", updatedAt: "2026-09-29T00:00:00Z" },
252+
{ id: "mid", updatedAt: "2026-09-23T00:00:00Z" },
253+
]);
254+
expect(m.get("old")).toBe("superseded");
255+
expect(m.get("mid")).toBe("superseded");
256+
expect(m.get("new")).toBe("latest");
257+
});
258+
it("marks nothing for a single row or equal timestamps", () => {
259+
expect(markRecency([{ id: "a", updatedAt: "2026-09-15T00:00:00Z" }]).size).toBe(0);
260+
expect(markRecency([
261+
{ id: "a", updatedAt: "2026-09-15T00:00:00Z" },
262+
{ id: "b", updatedAt: "2026-09-15T00:00:00Z" },
263+
]).size).toBe(0);
264+
});
265+
it("leaves unparsable timestamps unmarked", () => {
266+
const m = markRecency([
267+
{ id: "a", updatedAt: "" },
268+
{ id: "b", updatedAt: "2026-09-15T00:00:00Z" },
269+
{ id: "c", updatedAt: "2026-09-16T00:00:00Z" },
270+
]);
271+
expect(m.has("a")).toBe(false);
272+
expect(m.get("c")).toBe("latest");
273+
});
274+
});

‎src/aggregate.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,24 @@ export function groupByEntity<T>(
127127
}
128128
return ordered;
129129
}
130+
131+
/** Recency marker for one row of an entity group (issue #267). */
132+
export type Recency = "latest" | "superseded";
133+
134+
/**
135+
* Compare `updated_at` across the representative and its folded rows and
136+
* mark each one: "superseded" when another row in the group is newer,
137+
* "latest" for the newest. Returns an empty map when the group holds fewer
138+
* than two distinct timestamps — the pool proves nothing about recency then.
139+
* Rows with an unparsable timestamp are left unmarked.
140+
*/
141+
export function markRecency(rows: Array<{ id: string; updatedAt: string | null | undefined }>): Map<string, Recency> {
142+
const out = new Map<string, Recency>();
143+
const times = rows
144+
.map((r) => ({ id: r.id, t: r.updatedAt ? Date.parse(r.updatedAt) : NaN }))
145+
.filter((r) => !Number.isNaN(r.t));
146+
if (new Set(times.map((r) => r.t)).size < 2) return out;
147+
const max = Math.max(...times.map((r) => r.t));
148+
for (const r of times) out.set(r.id, r.t === max ? "latest" : "superseded");
149+
return out;
150+
}

‎src/mcp.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ import {
5959
FETCH_CONTENT_MAX_CHARS,
6060
FETCH_MAX_VECTOR_IDS,
6161
} from "./fetch.js";
62-
import { entityKey, groupByEntity } from "./aggregate.js";
62+
import { entityKey, groupByEntity, markRecency } from "./aggregate.js";
6363
import { pathPrefixRange, validateDocPathPrefix } from "./path-prefix.js";
6464

6565
const GITHUB_API = "https://api.github.com";
@@ -337,7 +337,10 @@ export function createRagMcpServer(env: Env): McpServer {
337337
"(Master's feedback, AI responses, self-review now/later/accepted classifications).\n" +
338338
"Results are aggregated per underlying entity: a file's doc row and its commit diffs are one result, " +
339339
"an issue or PR and its comments / reviews are one result. top_k therefore counts distinct entities, " +
340-
"and a result that absorbed others carries same_entity { count, others[] } with links to them.\n" +
340+
"and a result that absorbed others carries same_entity { count, others[] } with links to them. " +
341+
"When those rows hold more than one distinct updated_at, the representative and each others entry carry recency: " +
342+
"\"superseded\" = a newer row of the same entity is in the pool, \"latest\" = the newest row in the pool. " +
343+
"Absent recency is not a claim of being latest, and recency never affects ranking or scores.\n" +
341344
"Every result row — and every same_entity.others entry — carries vector_id, the handle mode 4 takes. " +
342345
"It is a handle for reaching a row you just found, not a durable identifier: the id scheme has been " +
343346
"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 {
917920
updated_at: string;
918921
score: number;
919922
commit_sha?: string;
923+
/** See ResultItem.recency (issue #267). */
924+
recency?: "latest" | "superseded";
920925
}>;
921926
};
927+
/**
928+
* Additive recency marker (issue #267), set only when the
929+
* representative and its same_entity.others hold more than one
930+
* distinct updated_at: "superseded" = a newer row of the same entity
931+
* is in the pool, "latest" = newest row in the pool. Never affects
932+
* ranking or scores. Absent = no newer version in the pool, which is
933+
* not a claim of being latest.
934+
*/
935+
recency?: "latest" | "superseded";
922936
};
923937

924938
const items: ResultItem[] = filtered.map((f) => {
@@ -928,6 +942,10 @@ export function createRagMcpServer(env: Env): McpServer {
928942
// Rows folded into this representative. Kept as references (never
929943
// dropped) so the caller can still reach every version / comment.
930944
const folded = collapsedInto.get(f.vectorId) ?? [];
945+
const recency = markRecency([
946+
{ id: f.vectorId, updatedAt: r.updatedAt },
947+
...folded.map((o) => ({ id: o.vectorId, updatedAt: resolveRow(payload.get(o.vectorId)).updatedAt })),
948+
]);
931949
const sameEntity =
932950
folded.length > 0
933951
? {
@@ -942,6 +960,7 @@ export function createRagMcpServer(env: Env): McpServer {
942960
updated_at: or.updatedAt,
943961
score: o.fusedScore,
944962
...(or.type === "diff" ? { commit_sha: or.commitSha } : {}),
963+
...(recency.has(o.vectorId) ? { recency: recency.get(o.vectorId) } : {}),
945964
};
946965
}),
947966
}
@@ -1008,6 +1027,7 @@ export function createRagMcpServer(env: Env): McpServer {
10081027
}
10091028
: {}),
10101029
...(sameEntity ? { same_entity: sameEntity } : {}),
1030+
...(recency.has(f.vectorId) ? { recency: recency.get(f.vectorId) } : {}),
10111031
};
10121032
});
10131033

0 commit comments

Comments
 (0)