diff --git a/docs/INGESTION.md b/docs/INGESTION.md index 0028646..e4608e6 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -484,8 +484,13 @@ Only meaningful when a game has more than one source; a single-source game passe 1. **Same ID** → same event; keep the higher-confidence copy. Applies within a source as well as across sources — an identical id is the same row seen twice, whatever it is called. -2. **Near match** — **different sources**, same game, title similarity ≥ 0.80, starts within 24h — - → same event under different titles; keep the higher-confidence copy. +2. **Near match** — **different sources**, same game, starts within 24h, and either title similarity + ≥ 0.80 or one title is the other with words added at one end (`titleExtends`) — → same event + under different titles; keep the higher-confidence copy. The second test exists because a source + qualifier scores badly on overlap: `Bedazzling Dawnstar` / `… Sign-In` is 0.67. It reads both + ends since 2026-10-14 — a qualifier can be a category label in front, as Game8's + `Battle Pass - …` was — and both ends keep the two-word floor, because a one-word base would + fuse half of a patch day. **The survivor lists what it absorbed** in `mergedIds`, and the client moves readers' marks off those ids when the feed arrives — so a rule that starts fusing two published ids costs nobody a diff --git a/src/ingest/merge.ts b/src/ingest/merge.ts index c267340..f2d9c56 100644 --- a/src/ingest/merge.ts +++ b/src/ingest/merge.ts @@ -169,19 +169,26 @@ function isSameEvent( } /** - * True when one title is the other with words appended — the shape a source - * qualifier actually takes ("Bedazzling Dawnstar" → "… Sign-In"). + * True when one title is the other with words added at either end — the shape + * a source qualifier actually takes. Appended: "Bedazzling Dawnstar" → "… + * Sign-In". Prepended: Game8's "Battle Pass - Moontrace" beside Fandom's + * "Moontrace", where the other source files the category in a column instead. * - * Deliberately a prefix test, not a subset test. Subset matching would also + * Deliberately an edge test, not a subset test. Subset matching would also * fuse "Gold Clash" with "Gold Rush Clash Royale", which are different events. - * Two words is the floor: a one-word title would swallow half the calendar. + * Two words is the floor at both ends: a one-word title would swallow half the + * calendar. */ export function titleExtends(a: string, b: string): boolean { const ta = tokenList(a); const tb = tokenList(b); const [small, large] = ta.length <= tb.length ? [ta, tb] : [tb, ta]; if (small.length < 2 || small.length === large.length) return false; - return small.every((word, i) => large[i] === word); + const offset = large.length - small.length; + return ( + small.every((word, i) => large[i] === word) || + small.every((word, i) => large[offset + i] === word) + ); } function findConflict( diff --git a/test/merge.test.ts b/test/merge.test.ts index 69afe8e..cfac514 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -160,6 +160,22 @@ describe("titleExtends", () => { test("refuses two titles of equal length", () => { expect(titleExtends("Gold Clash", "Gold Rush")).toBe(false); }); + + test("matches a title with a category label in front", () => { + // Game8 heads Genshin's pass `Battle Pass - Moontrace` where Fandom says + // `Moontrace`: the same qualifier, written at the other end. + expect(titleExtends("Frozen Wilds", "Battle Pass - Frozen Wilds")).toBe(true); + }); + + test("refuses a one-word title under a label, as at the end", () => { + // The floor holds in both directions. `Moontrace` itself is made one ID by + // the Game8 parser dropping the label, not by loosening this. + expect(titleExtends("Moontrace", "Battle Pass - Moontrace")).toBe(false); + }); + + test("refuses shared words in the middle", () => { + expect(titleExtends("Gold Clash", "Royal Gold Clash Rush")).toBe(false); + }); }); describe("merging two sources for one game", () => { @@ -191,6 +207,29 @@ describe("merging two sources for one game", () => { expect(conflicts[0]?.field).toBe("endsAt"); }); + test("collapses a leading-label duplicate and keeps the first copy's id", () => { + const g8 = event({ + id: "genshin:battle-pass-frozen-wilds:2026-09-23", + title: "Battle Pass - Frozen Wilds", + startsAt: "2026-09-23T00:00:00.000Z", + endsAt: "2026-11-02T00:00:00.000Z", + sourceId: "genshin-game8-events", + confidence: 0.85, + }); + const fandom = event({ + id: "genshin:frozen-wilds:2026-09-23", + title: "Frozen Wilds", + startsAt: "2026-09-23T00:00:00.000Z", + endsAt: "2026-11-02T00:00:00.000Z", + sourceId: "genshin-fandom-events", + confidence: 0.85, + }); + const { events, conflicts } = mergeEvents([[g8], [fandom]]); + expect(events).toHaveLength(1); + expect(events[0]?.id).toBe(g8.id); + expect(conflicts).toHaveLength(0); + }); + test("the survivor lists every id it absorbed", () => { const a = event({ id: "genshin:stygian-onslaught:2026-08-12", title: "Stygian Onslaught", sourceId: "s-a" }); const b = event({ id: "genshin:stygian-onslaught-event:2026-08-12", title: "Stygian Onslaught Event", sourceId: "s-b" });