feat(merge): pair a title with a qualifier in front of it

titleExtends caught a source appending words ("Bedazzling Dawnstar Sign-In") but not prepending them, which is the shape Game8's "Battle Pass - …" label took. The same qualifier at the other end now pairs too. The two-word floor holds at both ends, because a one-word base would fuse half of a patch day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-14 17:48:53 +02:00
1 parent 2924605bd4
commit 1f6b8fd1d6
3 files changed
+58 -7

No files matched your search

+7 -2
View File
@@ -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
+12 -5
View File
@@ -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(
+39
View File
@@ -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" });