feat: merge events across multiple sources per game

Collapses the same event seen by two sources, matching on ID or on title
similarity plus start-date proximity. Proximity is the real guard against
false positives, since a rerun reuses its name months later.

Agreement from an independent source raises confidence; the same row seen
twice in one document does not. Disagreement on an end date is recorded as
a conflict for the review gate rather than averaged — splitting the
difference would publish a date neither source asserts.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-15 00:11:08 +02:00
co-authored by Claude Opus 5
parent 489ce92ef5
commit 493fc9f22a
2 changed files with 310 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
import type { GachaEvent } from "../shared/schema.ts";
/**
* Combine events for one game from several sources.
*
* Two sources covering the same game will disagree: different titles for the
* same event, dates that differ by a day, one listing something the other
* misses. This decides what the feed shows.
*
* The rules, in order:
* 1. Same event ID → same event. Keep the higher-confidence copy.
* 2. Near match → same event under different titles. Keep the
* higher-confidence copy and record corroboration.
* 3. Otherwise → distinct events; keep both.
*
* Corroboration is the point of running multiple sources: two independent
* sources agreeing on a date is much stronger evidence than one asserting it,
* and that shows up as a confidence bump. Two sources *disagreeing* on an end
* date is flagged rather than silently resolved — see `conflicts`.
*/
export interface MergeResult {
events: GachaEvent[];
/**
* Pairs that look like the same event but disagree on an end date by more
* than the tolerance. These are the cases a human should look at; the
* pipeline routes them to quarantine.
*/
conflicts: Array<{
kept: GachaEvent;
rejected: GachaEvent;
field: "endsAt" | "startsAt";
deltaHours: number;
}>;
}
export interface MergeOptions {
/** How far two boundaries may differ and still count as agreement. */
toleranceHours?: number;
/** Title similarity above which two events are considered the same. */
titleThreshold?: number;
/** Confidence added when an independent source agrees. */
corroborationBonus?: number;
}
const DEFAULTS = {
toleranceHours: 24,
/**
* 0.8, not higher: a two-word title with one extra decorative token
* ("Stygian Onslaught" vs "Stygian Onslaught Event") scores exactly 0.8, and
* failing to merge those puts duplicate rows in front of the user. The real
* guard against false positives is start-date proximity, not this number — a
* rerun reuses the name but starts months later.
*/
titleThreshold: 0.8,
corroborationBonus: 0.1,
} as const;
export function mergeEvents(
groups: GachaEvent[][],
options: MergeOptions = {},
): MergeResult {
const toleranceHours = options.toleranceHours ?? DEFAULTS.toleranceHours;
const titleThreshold = options.titleThreshold ?? DEFAULTS.titleThreshold;
const bonus = options.corroborationBonus ?? DEFAULTS.corroborationBonus;
const kept: GachaEvent[] = [];
const conflicts: MergeResult["conflicts"] = [];
for (const incoming of groups.flat()) {
const matchIndex = kept.findIndex(
(existing) =>
existing.game === incoming.game &&
isSameEvent(existing, incoming, titleThreshold, toleranceHours),
);
if (matchIndex === -1) {
kept.push(incoming);
continue;
}
const existing = kept[matchIndex];
if (existing === undefined) continue;
const conflict = findConflict(existing, incoming, toleranceHours);
const [winner, loser] =
incoming.confidence > existing.confidence
? ([incoming, existing] as const)
: ([existing, incoming] as const);
if (conflict !== null) {
// Independent sources disagree on when this ends. Do not average, do not
// silently prefer one — surface it. A wrong end date is the failure this
// product exists to prevent.
conflicts.push({ kept: winner, rejected: loser, ...conflict });
kept[matchIndex] = winner;
continue;
}
// Agreement from a different source is real evidence; from the same source
// it is just the same row seen twice.
const corroborated =
winner.sourceId !== loser.sourceId
? { ...winner, confidence: Math.min(1, winner.confidence + bonus) }
: winner;
kept[matchIndex] = corroborated;
}
kept.sort((a, b) =>
a.startsAt === b.startsAt
? a.id.localeCompare(b.id)
: a.startsAt.localeCompare(b.startsAt),
);
return { events: kept, conflicts };
}
function isSameEvent(
a: GachaEvent,
b: GachaEvent,
titleThreshold: number,
toleranceHours: number,
): boolean {
if (a.id === b.id) return true;
if (titleSimilarity(a.title, b.title) < titleThreshold) return false;
// Similar titles are not enough — reruns reuse names. Require the start dates
// to be close before treating two entries as one event.
return hoursBetween(a.startsAt, b.startsAt) <= toleranceHours;
}
function findConflict(
a: GachaEvent,
b: GachaEvent,
toleranceHours: number,
): { field: "endsAt" | "startsAt"; deltaHours: number } | null {
if (a.endsAt !== null && b.endsAt !== null) {
const delta = hoursBetween(a.endsAt, b.endsAt);
if (delta > toleranceHours) return { field: "endsAt", deltaHours: delta };
}
const startDelta = hoursBetween(a.startsAt, b.startsAt);
if (startDelta > toleranceHours) {
return { field: "startsAt", deltaHours: startDelta };
}
return null;
}
function hoursBetween(a: string, b: string): number {
return Math.abs(Date.parse(a) - Date.parse(b)) / 3_600_000;
}
/**
* Token-overlap (Dice) similarity on normalised titles. Deliberately simple:
* it only needs to catch "Stygian Onslaught" vs "Stygian Onslaught (Event)",
* not to do fuzzy natural-language matching.
*/
export function titleSimilarity(a: string, b: string): number {
const ta = tokens(a);
const tb = tokens(b);
if (ta.size === 0 || tb.size === 0) return 0;
let shared = 0;
for (const t of ta) if (tb.has(t)) shared += 1;
return (2 * shared) / (ta.size + tb.size);
}
function tokens(title: string): Set<string> {
return new Set(
title
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.split(/\s+/)
.filter((w) => w.length > 0),
);
}