From 8e208a7e079fd6da02a97a2a4ae2cd26770ba176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BA=B7ng=20Huy?= <72134114+huygdv@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:29:38 +0700 Subject: [PATCH 01/13] fix(wc2026): add stable match fact helpers --- scripts/worldcup-facts.mjs | 181 +++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 scripts/worldcup-facts.mjs diff --git a/scripts/worldcup-facts.mjs b/scripts/worldcup-facts.mjs new file mode 100644 index 0000000..72513b4 --- /dev/null +++ b/scripts/worldcup-facts.mjs @@ -0,0 +1,181 @@ +const TITLE_RE = /^(.*?)\s+(\d{1,2})\s*[–-]\s*(\d{1,2})(?:\s*\(\s*(\d{1,2})\s*[–-]\s*(\d{1,2})\s*pens?\.?\s*\))?\s+(.*)$/i; +const PLACEHOLDER_RE = /^(?:W|L)\d+$/i; + +export function asMatchNumber(value) { + const n = Number(value); + return Number.isInteger(n) && n > 0 ? n : null; +} + +export function matchKey(value) { + const n = asMatchNumber(value); + return n == null ? null : `wc2026-match-${String(n).padStart(3, '0')}`; +} + +export function isPlaceholderTeam(value) { + return PLACEHOLDER_RE.test(String(value ?? '').trim()); +} + +export function isPlaceholderEvent(event) { + if (!event) return false; + const entities = Array.isArray(event.entities) ? event.entities : []; + if (entities.some(isPlaceholderTeam)) return true; + const title = String(event.title ?? ''); + return /(?:^|\s)(?:W|L)\d+(?:\s|$)/i.test(title); +} + +function scorePair(value) { + if (!Array.isArray(value) || value.length !== 2) return null; + const a = Number(value[0]); + const b = Number(value[1]); + if (!Number.isFinite(a) || !Number.isFinite(b)) return null; + return [a, b]; +} + +export function normalizeOutcome(score) { + if (!score || typeof score !== 'object') return null; + const regulation = scorePair(score.ft); + const extraTime = scorePair(score.et); + const penalties = scorePair(score.p); + if (!regulation && !extraTime && !penalties) return null; + + const final = extraTime || regulation; + if (!final) return null; + + const decidedBy = penalties + ? 'penalties' + : extraTime + ? 'extra_time' + : 'regular_time'; + + return { + regulation: regulation || final, + final, + ...(extraTime ? { extraTime } : {}), + ...(penalties ? { penalties } : {}), + decidedBy, + }; +} + +export function formatResultTitle(team1, team2, outcome) { + if (!outcome) return `${team1} vs ${team2}`; + const [a, b] = outcome.final; + const pens = outcome.penalties + ? ` (${outcome.penalties[0]}–${outcome.penalties[1]} pens)` + : ''; + return `${team1} ${a}–${b}${pens} ${team2}`; +} + +export function formatResultDescription(team1, team2, lane, outcome) { + if (!outcome) return `${lane} fixture: ${team1} vs ${team2}.`; + const [a, b] = outcome.final; + const winner = outcome.penalties + ? (outcome.penalties[0] > outcome.penalties[1] ? team1 : team2) + : (a > b ? team1 : b > a ? team2 : null); + + if (!winner) return `${team1} drew ${team2} ${a}–${b} in ${lane}.`; + const loser = winner === team1 ? team2 : team1; + const suffix = outcome.decidedBy === 'extra_time' + ? ' after extra time' + : outcome.decidedBy === 'penalties' + ? ` on penalties ${outcome.penalties[0]}–${outcome.penalties[1]}` + : ''; + return `${winner} beat ${loser} ${a}–${b}${suffix} in ${lane}.`; +} + +export function parseResultTitle(title) { + const m = String(title ?? '').match(TITLE_RE); + if (!m) return null; + return { + team1: m[1].trim(), + final: [Number(m[2]), Number(m[3])], + penalties: m[4] == null ? null : [Number(m[4]), Number(m[5])], + team2: m[6].trim(), + }; +} + +export function factFingerprint(event) { + const result = event?.result; + const final = scorePair(result?.final); + const penalties = scorePair(result?.penalties); + if (final) { + return JSON.stringify({ final, penalties }); + } + const parsed = parseResultTitle(event?.title); + if (!parsed) return null; + return JSON.stringify({ final: parsed.final, penalties: parsed.penalties }); +} + +function sourceIdentity(source) { + if (!source || typeof source !== 'object') return null; + return String( + source.sourceId + || source.url + || source.citation + || source.name + || '', + ).trim().toLowerCase() || null; +} + +export function mergeSources(...lists) { + const out = []; + const seen = new Set(); + for (const list of lists) { + for (const source of Array.isArray(list) ? list : []) { + const key = sourceIdentity(source) || JSON.stringify(source); + if (seen.has(key)) continue; + seen.add(key); + out.push(source); + } + } + return out; +} + +export function hasIndependentSource(event, sourceId = 'openfootball:worldcup-2026') { + const sources = Array.isArray(event?.sources) ? event.sources : []; + return sources.some((source) => { + const id = String(source?.sourceId ?? '').toLowerCase(); + const citation = String(source?.citation ?? '').toLowerCase(); + const url = String(source?.url ?? '').toLowerCase(); + return !id.includes(sourceId.toLowerCase()) + && !citation.includes('openfootball/worldcup.json') + && !url.includes('openfootball/worldcup.json'); + }); +} + +export function assertNoIndependentFactConflict(existing, fresh) { + if (!existing || !fresh || isPlaceholderEvent(existing)) return; + const oldFact = factFingerprint(existing); + const newFact = factFingerprint(fresh); + if (!oldFact || !newFact || oldFact === newFact) return; + if (!hasIndependentSource(existing)) return; + throw new Error( + `fact conflict for match ${fresh.matchNumber ?? fresh.id}: existing independently sourced result differs from ingest; review manually instead of overwriting`, + ); +} + +export function normalizeTeamPair(a, b) { + return [String(a ?? '').trim().toLowerCase(), String(b ?? '').trim().toLowerCase()] + .sort() + .join('|'); +} + +export function eventTeamPair(event) { + const parsed = parseResultTitle(event?.title); + if (parsed) return normalizeTeamPair(parsed.team1, parsed.team2); + const entities = Array.isArray(event?.entities) ? event.entities : []; + const teams = entities.filter((x) => !/^group\s+[a-l]$/i.test(String(x)) && !/round|final|quarter|semi|third/i.test(String(x))); + if (teams.length >= 2) return normalizeTeamPair(teams[0], teams[1]); + const title = String(event?.title ?? ''); + const vs = title.match(/^(.*?)\s+vs\s+(.*?)$/i); + return vs ? normalizeTeamPair(vs[1], vs[2]) : null; +} + +export function outcomeWinner(team1, team2, outcome) { + if (!outcome) return null; + if (outcome.penalties) { + if (outcome.penalties[0] === outcome.penalties[1]) return null; + return outcome.penalties[0] > outcome.penalties[1] ? team1 : team2; + } + if (outcome.final[0] === outcome.final[1]) return null; + return outcome.final[0] > outcome.final[1] ? team1 : team2; +} From 37ee0ec31714af1453c889e345d49bc68f450c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BA=B7ng=20Huy?= <72134114+huygdv@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:29:56 +0700 Subject: [PATCH 02/13] test(wc2026): cover extra-time identity and source conflicts --- scripts/test-worldcup-facts.mjs | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 scripts/test-worldcup-facts.mjs diff --git a/scripts/test-worldcup-facts.mjs b/scripts/test-worldcup-facts.mjs new file mode 100644 index 0000000..774a428 --- /dev/null +++ b/scripts/test-worldcup-facts.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { + assertNoIndependentFactConflict, + formatResultDescription, + formatResultTitle, + matchKey, + mergeSources, + normalizeOutcome, + parseResultTitle, +} from './worldcup-facts.mjs'; + +assert.equal(matchKey(99), 'wc2026-match-099'); + +const regular = normalizeOutcome({ ft: [2, 0] }); +assert.deepEqual(regular.final, [2, 0]); +assert.equal(regular.decidedBy, 'regular_time'); +assert.equal(formatResultTitle('Mexico', 'South Africa', regular), 'Mexico 2–0 South Africa'); + +const extraTime = normalizeOutcome({ ft: [1, 1], et: [1, 2] }); +assert.deepEqual(extraTime.final, [1, 2]); +assert.equal(extraTime.decidedBy, 'extra_time'); +assert.equal(formatResultTitle('Norway', 'England', extraTime), 'Norway 1–2 England'); +assert.match(formatResultDescription('Norway', 'England', 'Quarter-final', extraTime), /after extra time/); + +const penalties = normalizeOutcome({ ft: [0, 0], et: [0, 0], p: [4, 3] }); +assert.equal(formatResultTitle('Switzerland', 'Colombia', penalties), 'Switzerland 0–0 (4–3 pens) Colombia'); +assert.deepEqual(parseResultTitle('Switzerland 0–0 (4–3 pens) Colombia'), { + team1: 'Switzerland', + final: [0, 0], + penalties: [4, 3], + team2: 'Colombia', +}); + +assert.deepEqual( + mergeSources( + [{ sourceId: 'a', citation: 'A' }], + [{ sourceId: 'a', citation: 'A newer label' }, { sourceId: 'b', citation: 'B' }], + ).map((x) => x.sourceId), + ['a', 'b'], +); + +assert.doesNotThrow(() => assertNoIndependentFactConflict( + { + id: 'wc2026-norway-england-0711', + matchNumber: 99, + title: 'Norway 1–1 England', + sources: [{ sourceId: 'openfootball:worldcup-2026' }], + }, + { + id: 'wc2026-norway-england-0711', + matchNumber: 99, + title: 'Norway 1–2 England', + sources: [{ sourceId: 'openfootball:worldcup-2026' }], + }, +)); + +assert.throws(() => assertNoIndependentFactConflict( + { + id: 'wc2026-norway-england-0711', + matchNumber: 99, + title: 'Norway 1–1 England', + sources: [{ sourceId: 'reuters:match-99', citation: 'Reuters' }], + }, + { + id: 'wc2026-norway-england-0711', + matchNumber: 99, + title: 'Norway 1–2 England', + sources: [{ sourceId: 'openfootball:worldcup-2026' }], + }, +), /fact conflict/); + +console.log('✓ worldcup-facts tests passed'); From 9d0cc1d76d5c81517996aa4e0495bee7dd82f52b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BA=B7ng=20Huy?= <72134114+huygdv@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:30:23 +0700 Subject: [PATCH 03/13] test(wc2026): cross-check pack facts against recap history --- scripts/validate-worldcup-consistency.mjs | 136 ++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 scripts/validate-worldcup-consistency.mjs diff --git a/scripts/validate-worldcup-consistency.mjs b/scripts/validate-worldcup-consistency.mjs new file mode 100644 index 0000000..734b15d --- /dev/null +++ b/scripts/validate-worldcup-consistency.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +// Cross-surface consistency gate for the World Cup pack. +// `packs/worldcup-2026/events.json` is the structured fact surface while +// `wc2026/history/*.json` is the recap/editorial surface. They may be produced by +// different jobs and different sources, but they must never publish conflicting +// teams, scores or winners for the same physical match. + +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + asMatchNumber, + eventTeamPair, + normalizeTeamPair, + parseResultTitle, +} from './worldcup-facts.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const EVENTS_PATH = join(ROOT, 'packs', 'worldcup-2026', 'events.json'); +const HISTORY_DIR = join(ROOT, 'wc2026', 'history'); + +function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +function scorePair(a, b) { + const x = Number(a); + const y = Number(b); + return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null; +} + +function winnerFromScores(home, away, score, penalties = null) { + if (penalties && penalties[0] !== penalties[1]) return penalties[0] > penalties[1] ? home : away; + if (!score || score[0] === score[1]) return null; + return score[0] > score[1] ? home : away; +} + +const events = readJson(EVENTS_PATH); +const live = events.filter((event) => event?.status); +const completed = live.filter((event) => event.status === 'completed'); +const errors = []; +const warnings = []; +const E = (message) => errors.push(message); +const W = (message) => warnings.push(message); + +const byMatchNumber = new Map(); +const byDatePair = new Map(); +for (const event of live) { + const n = asMatchNumber(event.matchNumber); + if (n != null) { + if (byMatchNumber.has(n)) E(`duplicate matchNumber ${n}: ${byMatchNumber.get(n).id} and ${event.id}`); + else byMatchNumber.set(n, event); + } + + const pair = eventTeamPair(event); + if (event.date && pair) { + const key = `${event.date}|${pair}`; + const previous = byDatePair.get(key); + if (previous && previous.id !== event.id) E(`duplicate physical match ${key}: ${previous.id} and ${event.id}`); + else byDatePair.set(key, event); + } +} + +if (!existsSync(HISTORY_DIR)) { + W('wc2026/history does not exist; pack-only validation completed'); +} else { + const files = readdirSync(HISTORY_DIR).filter((name) => /^\d{4}-\d{2}-\d{2}\.json$/.test(name)).sort(); + for (const file of files) { + const doc = readJson(join(HISTORY_DIR, file)); + const date = doc.date || file.slice(0, 10); + if (doc.status === 'final' && (!Array.isArray(doc.sources) || doc.sources.length === 0)) { + E(`${file}: final recap requires at least one source`); + } + + for (const recap of Array.isArray(doc.results) ? doc.results : []) { + const home = String(recap.home ?? '').trim(); + const away = String(recap.away ?? '').trim(); + if (!home || !away) { + E(`${file}: recap result is missing home/away`); + continue; + } + + const n = asMatchNumber(recap.matchNumber); + const key = `${date}|${normalizeTeamPair(home, away)}`; + const event = (n != null ? byMatchNumber.get(n) : null) || byDatePair.get(key); + if (!event) { + E(`${file}: no pack event found for ${home} vs ${away}${n != null ? ` (match ${n})` : ''}`); + continue; + } + + const parsed = parseResultTitle(event.title); + if (!parsed) { + E(`${file}: pack event ${event.id} has no parseable completed scoreline`); + continue; + } + + const recapScore = scorePair(recap.homeScore, recap.awayScore); + if (!recapScore) { + E(`${file}: ${home} vs ${away} has invalid homeScore/awayScore`); + continue; + } + const recapPens = scorePair(recap.homePenalties, recap.awayPenalties); + const sameOrientation = normalizeTeamPair(parsed.team1, parsed.team2) === normalizeTeamPair(home, away) + && parsed.team1.toLowerCase() === home.toLowerCase(); + const eventScore = sameOrientation ? parsed.final : [parsed.final[1], parsed.final[0]]; + const eventPens = parsed.penalties + ? (sameOrientation ? parsed.penalties : [parsed.penalties[1], parsed.penalties[0]]) + : null; + + if (eventScore[0] !== recapScore[0] || eventScore[1] !== recapScore[1]) { + E(`${file}: score conflict for ${home} vs ${away}: recap ${recapScore[0]}–${recapScore[1]}, pack ${eventScore[0]}–${eventScore[1]} (${event.id})`); + } + if (recapPens && (!eventPens || eventPens[0] !== recapPens[0] || eventPens[1] !== recapPens[1])) { + E(`${file}: penalty conflict for ${home} vs ${away}`); + } + + const recapWinner = recap.winner || winnerFromScores(home, away, recapScore, recapPens); + const eventWinner = winnerFromScores(home, away, eventScore, eventPens); + if (recapWinner && eventWinner && recapWinner.toLowerCase() !== eventWinner.toLowerCase()) { + E(`${file}: winner conflict for ${home} vs ${away}: recap ${recapWinner}, pack ${eventWinner}`); + } + + if (n != null && asMatchNumber(event.matchNumber) !== n) { + E(`${file}: matchNumber ${n} resolved to event ${event.id} with matchNumber ${event.matchNumber ?? ''}`); + } + } + } +} + +console.log(`worldcup consistency: ${completed.length} completed pack events, ${errors.length} error(s), ${warnings.length} warning(s)`); +for (const warning of warnings) console.log(` ! ${warning}`); +if (errors.length) { + for (const error of errors) console.error(` x ${error}`); + process.exit(1); +} +console.log('✓ pack facts and recap history are consistent'); From 30bd70f2f4e823ef7d8c971348c6b50de0abb365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BA=B7ng=20Huy?= <72134114+huygdv@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:30:41 +0700 Subject: [PATCH 04/13] docs(wc2026): define multi-source ownership and conflict policy --- docs/WORLDCUP-SOURCE-POLICY.md | 63 ++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/WORLDCUP-SOURCE-POLICY.md diff --git a/docs/WORLDCUP-SOURCE-POLICY.md b/docs/WORLDCUP-SOURCE-POLICY.md new file mode 100644 index 0000000..749ce91 --- /dev/null +++ b/docs/WORLDCUP-SOURCE-POLICY.md @@ -0,0 +1,63 @@ +# World Cup source and reconciliation policy + +The World Cup pack is assembled by more than one process: + +- a schedule/result feed updates mechanical match facts; +- a scheduled agent or human writes daily recap history; +- editors add causal interpretation, watchpoints and translations. + +These producers are complementary, not interchangeable. An AI-written recap is an editorial surface and must never become the sole authority for a score. + +## Stable identity + +Use `matchNumber` as the stable identity whenever the competition feed provides it. Human-readable event ids may change when a placeholder such as `W91` resolves to `Norway`, so they cannot be used as the primary merge key. + +Each numbered match also carries: + +```json +{ + "matchNumber": 99, + "matchKey": "wc2026-match-099", + "sourceMatchId": "openfootball:worldcup-2026:99" +} +``` + +Legacy ids are retained in `legacyIds` only for traceability. Links are remapped to the canonical event during reconciliation. + +## Field ownership + +| Field class | Primary owner | Merge rule | +|---|---|---| +| Match identity, participants, date, venue, status | schedule/result feed | refresh by `matchNumber` | +| Regulation, extra-time and penalty scores | result feed | structured result; conflict blocks publish | +| Sources/citations | every producer | union and deduplicate; never overwrite | +| `whyItMatters`, watchpoints, translations | editorial layer | preserve unless still mechanical/template text | +| Causal links and insights | editorial/curation layer | preserve; mechanical links may only fill gaps | + +## Score semantics + +A completed match stores all available score stages: + +```json +{ + "result": { + "regulation": [1, 1], + "extraTime": [1, 2], + "final": [1, 2], + "decidedBy": "extra_time" + } +} +``` + +For a penalty shootout, `final` is the score after extra time and `penalties` stores the shootout separately. The event title displays the final match score and, when relevant, the shootout score. + +## Conflict policy + +Do not use last-write-wins for factual disagreements. + +1. If two independently sourced records disagree on participants, score or winner, the workflow fails. +2. The conflicting records remain visible for manual review; one source is not silently discarded. +3. A reviewed correction submitted through `add-match-day.mjs` must include `factCorrection.reason`. +4. `validate-worldcup-consistency.mjs` compares the structured pack with every daily recap before publication. + +This policy lets the project add FIFA, open data, Reuters/AP, manual curation or future adapters without coupling truth to one ChatGPT schedule or one upstream repository. From 858bb96dce296a57b2e48531dfc5a806ca35f0cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BA=B7ng=20Huy?= <72134114+huygdv@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:31:32 +0700 Subject: [PATCH 05/13] fix(wc2026): validate stable match identity and structured results --- scripts/validate-pack.mjs | 83 +++++++++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 29 deletions(-) diff --git a/scripts/validate-pack.mjs b/scripts/validate-pack.mjs index 2fafd33..4258830 100644 --- a/scripts/validate-pack.mjs +++ b/scripts/validate-pack.mjs @@ -1,19 +1,6 @@ #!/usr/bin/env node // Zero-dependency validator for Causari data packs. -// -// Checks each pack under packs// for schema conformance AND referential -// integrity (the part JSON Schema can't express): every link endpoint must be a -// real event, every insight instance must be a real link, ids must be unique and -// well-formed. Run before every commit that touches a pack — this is what keeps -// the daily live updates from shipping a broken graph to the public visual. -// -// node scripts/validate-pack.mjs # validate all packs -// node scripts/validate-pack.mjs worldcup-2026 -// -// Also exports validatePackData({events,links,insights}) for in-memory checks -// (used by add-match-day.mjs to validate BEFORE writing to disk). -// -// Exit 0 = clean, exit 1 = errors found. +// Checks schema shape, referential integrity and live-event identity invariants. import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; import { join, dirname } from 'node:path'; @@ -28,31 +15,35 @@ const DOMAINS = new Set([ ]); const RELATIONSHIPS = new Set(['caused', 'enabled', 'accelerated', 'inspired', 'delayed', 'prevented']); const PRECISIONS = new Set(['millennium', 'century', 'decade', 'year', 'month', 'day']); -const STATUSES = new Set(['completed', 'scheduled', 'live', 'forecast']); // optional live-event field +const STATUSES = new Set(['completed', 'scheduled', 'live', 'forecast']); +const DECIDED_BY = new Set(['regular_time', 'extra_time', 'penalties']); const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const PATTERN_ID = /^pattern--[a-z0-9]+(?:-[a-z0-9]+)*$/; function isNum01(v) { return typeof v === 'number' && v >= 0 && v <= 1; } function isNonEmptyStr(v) { return typeof v === 'string' && v.trim().length > 0; } +function isScorePair(v) { + return Array.isArray(v) + && v.length === 2 + && v.every((n) => Number.isInteger(n) && n >= 0); +} -/** - * Pure validation of a pack's in-memory data. Returns an array of error strings - * (empty = valid). No I/O, no console — safe to call before writing to disk. - */ export function validatePackData({ events, links, insights }, packId = 'pack') { const errors = []; const E = (msg) => errors.push(`[${packId}] ${msg}`); const eventIds = new Set(); const linkIds = new Set(); + const matchNumbers = new Map(); // --- Events --- if (!Array.isArray(events)) { E('events must be an array'); return errors; } for (const ev of events) { const id = ev?.id ?? ''; - if (!isNonEmptyStr(ev.id)) E(`event has no id`); + if (!isNonEmptyStr(ev.id)) E('event has no id'); else if (!KEBAB.test(ev.id)) E(`event id not kebab-case: ${ev.id}`); else if (eventIds.has(ev.id)) E(`duplicate event id: ${ev.id}`); else eventIds.add(ev.id); + if (!isNonEmptyStr(ev.title)) E(`event ${id}: missing title`); if (!isNonEmptyStr(ev.description)) E(`event ${id}: missing description`); if (typeof ev.yearNum !== 'number') E(`event ${id}: yearNum must be a number`); @@ -62,37 +53,73 @@ export function validatePackData({ events, links, insights }, packId = 'pack') { else for (const d of ev.domains) if (!DOMAINS.has(d)) E(`event ${id}: invalid domain "${d}"`); if (!isNum01(ev.impactScore)) E(`event ${id}: impactScore must be 0-1`); if (!Array.isArray(ev.tags)) E(`event ${id}: tags must be an array`); - // optional live-event fields + if (ev.status !== undefined && !STATUSES.has(ev.status)) E(`event ${id}: invalid status "${ev.status}"`); if (ev.entities !== undefined && !Array.isArray(ev.entities)) E(`event ${id}: entities must be an array`); if (ev.nextWatchpoints !== undefined && !Array.isArray(ev.nextWatchpoints)) E(`event ${id}: nextWatchpoints must be an array`); if (ev.forecastConfidence !== undefined && !isNum01(ev.forecastConfidence)) E(`event ${id}: forecastConfidence must be 0-1`); + + // Stable live-event identity. A human-readable id may change when W91 resolves + // to Norway, but matchNumber must stay unique across the pack. + if (ev.matchNumber !== undefined) { + if (!Number.isInteger(ev.matchNumber) || ev.matchNumber <= 0) { + E(`event ${id}: matchNumber must be a positive integer`); + } else if (matchNumbers.has(ev.matchNumber)) { + E(`duplicate matchNumber ${ev.matchNumber}: ${matchNumbers.get(ev.matchNumber)} and ${id}`); + } else { + matchNumbers.set(ev.matchNumber, id); + } + if (ev.matchKey !== undefined) { + const expected = `wc2026-match-${String(ev.matchNumber).padStart(3, '0')}`; + if (ev.matchKey !== expected) E(`event ${id}: matchKey should be "${expected}"`); + } + } + if (ev.sourceMatchId !== undefined && !isNonEmptyStr(ev.sourceMatchId)) { + E(`event ${id}: sourceMatchId must be a non-empty string`); + } + + if (ev.status === 'completed' && (!Array.isArray(ev.sources) || ev.sources.length === 0)) { + E(`event ${id}: completed event requires at least one source citation`); + } + + if (ev.result !== undefined) { + if (!ev.result || typeof ev.result !== 'object') { + E(`event ${id}: result must be an object`); + } else { + if (!isScorePair(ev.result.final)) E(`event ${id}: result.final must be [home, away] non-negative integers`); + if (!isScorePair(ev.result.regulation)) E(`event ${id}: result.regulation must be [home, away] non-negative integers`); + if (ev.result.extraTime !== undefined && !isScorePair(ev.result.extraTime)) E(`event ${id}: result.extraTime must be a score pair`); + if (ev.result.penalties !== undefined && !isScorePair(ev.result.penalties)) E(`event ${id}: result.penalties must be a score pair`); + if (!DECIDED_BY.has(ev.result.decidedBy)) E(`event ${id}: invalid result.decidedBy "${ev.result.decidedBy}"`); + if (ev.result.decidedBy === 'extra_time' && !isScorePair(ev.result.extraTime)) E(`event ${id}: extra_time result requires result.extraTime`); + if (ev.result.decidedBy === 'penalties' && !isScorePair(ev.result.penalties)) E(`event ${id}: penalties result requires result.penalties`); + } + if (ev.status !== 'completed') E(`event ${id}: structured result requires status="completed"`); + } } - // --- Links (referential integrity is the point) --- + // --- Links --- if (!Array.isArray(links)) { E('links must be an array'); return errors; } for (const ln of links) { const id = ln?.id ?? ''; - if (!isNonEmptyStr(ln.id)) E(`link has no id`); + if (!isNonEmptyStr(ln.id)) E('link has no id'); else if (linkIds.has(ln.id)) E(`duplicate link id: ${ln.id}`); else linkIds.add(ln.id); if (!RELATIONSHIPS.has(ln.relationship)) E(`link ${id}: invalid relationship "${ln.relationship}"`); if (!isNum01(ln.confidence)) E(`link ${id}: confidence must be 0-1`); if (!isNonEmptyStr(ln.evidence)) E(`link ${id}: missing evidence`); - // endpoints MUST be real events if (!eventIds.has(ln.fromEvent)) E(`link ${id}: fromEvent "${ln.fromEvent}" is not an event in this pack`); if (!eventIds.has(ln.toEvent)) E(`link ${id}: toEvent "${ln.toEvent}" is not an event in this pack`); - // id must encode {from}--{rel}-->{to} const expected = `${ln.fromEvent}--${ln.relationship}-->${ln.toEvent}`; if (ln.id !== expected) E(`link ${id}: id should be "${expected}"`); } - // --- Insights (instances must be real links) --- + // --- Insights --- if (!Array.isArray(insights)) { E('insights must be an array'); return errors; } const insightIds = new Set(); for (const ins of insights) { const id = ins?.id ?? ''; - if (!isNonEmptyStr(ins.id)) E(`insight has no id`); + if (!isNonEmptyStr(ins.id)) E('insight has no id'); else if (!PATTERN_ID.test(ins.id)) E(`insight ${id}: id should follow "pattern--{kebab-name}"`); else if (insightIds.has(ins.id)) E(`duplicate insight id: ${ins.id}`); else insightIds.add(ins.id); @@ -110,7 +137,6 @@ export function validatePackData({ events, links, insights }, packId = 'pack') { return errors; } -/** Read a pack from disk and return { errors, counts }. */ export function validatePackFromDisk(packId) { const dir = join(PACKS_DIR, packId); const read = (name) => JSON.parse(readFileSync(join(dir, name), 'utf8')); @@ -149,7 +175,6 @@ function main() { console.log('\n✓ All packs valid.'); } -// Run as CLI only when invoked directly (not when imported). if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main(); } From 64dcd87a81a2351a8e498fef05e361d9b5de081d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BA=B7ng=20Huy?= <72134114+huygdv@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:32:10 +0700 Subject: [PATCH 06/13] fix(wc2026): reconcile agent inputs by match number and source --- scripts/add-match-day.mjs | 160 ++++++++++++++++++++++++++------------ 1 file changed, 109 insertions(+), 51 deletions(-) diff --git a/scripts/add-match-day.mjs b/scripts/add-match-day.mjs index d74ffc2..333e1aa 100644 --- a/scripts/add-match-day.mjs +++ b/scripts/add-match-day.mjs @@ -1,21 +1,20 @@ #!/usr/bin/env node -// Add a match-day to a live pack — deterministically and safely. -// -// The daily updater (scheduled Codex job or a human) supplies a small input file -// describing the day's results + new fixtures + causal links. This script does the -// mechanical, error-prone parts (id wiring, status flips, defaults) and — crucially — -// validates the WHOLE pack in memory and refuses to write if anything is broken. -// It also enforces the honesty rule: every completed result must cite a source. -// -// node scripts/add-match-day.mjs [packId=worldcup-2026] -// -// Exit 0 = pack updated + valid. Exit 1 = nothing written (see printed errors). +// Add a match-day to a live pack deterministically and safely. +// Human/agent inputs may enrich the same match from additional sources, but a +// stable matchNumber is used to reconcile identity and factual conflicts fail +// closed unless an explicit reviewed correction is supplied. import { readFileSync, writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { validatePackData } from './validate-pack.mjs'; import { assessCausalQuality } from './causal-quality.mjs'; +import { + asMatchNumber, + factFingerprint, + matchKey, + mergeSources, +} from './worldcup-facts.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); @@ -40,76 +39,134 @@ const insights = readJson(insightsPath); const input = readJson(inputPath); const eventsById = new Map(events.map((e) => [e.id, e])); +const eventsByMatchNumber = new Map(); +for (const event of events) { + const n = asMatchNumber(event.matchNumber); + if (n == null) continue; + if (eventsByMatchNumber.has(n)) die(`pack already contains duplicate matchNumber ${n}`); + eventsByMatchNumber.set(n, event); +} const linkIds = new Set(links.map((l) => l.id)); const insightsById = new Map(insights.map((i) => [i.id, i])); - -const DEFAULTS = { domains: ['culture', 'systems'], precision: 'day', yearNum: 2026, yearLabel: '2026', impactScore: 0.6, tags: [] }; +const aliases = new Map(); + +const DEFAULTS = { + domains: ['culture', 'systems'], + precision: 'day', + yearNum: 2026, + yearLabel: '2026', + impactScore: 0.6, + tags: [], +}; const topDate = input.date; const topDateLabel = input.dateLabel; +function looksLikeKnockout(src) { + const text = [src.title, ...(Array.isArray(src.entities) ? src.entities : [])].join(' '); + return /round of 32|round of 16|quarter|semi|final|third place/i.test(text); +} + +function assertCompatibleFacts(existing, incoming) { + if (!existing) return; + const oldFact = factFingerprint(existing); + const newFact = factFingerprint(incoming); + if (!oldFact || !newFact || oldFact === newFact) return; + const correction = incoming.factCorrection; + if (!correction || !String(correction.reason ?? '').trim()) { + die(`result conflict for ${incoming.matchNumber ? `match ${incoming.matchNumber}` : incoming.id}: existing and incoming facts differ; add factCorrection.reason only after manual source review`); + } +} + function upsertEvent(src, status) { - if (!src.id) die(`an event in input has no id`); - const existing = eventsById.get(src.id) || {}; - const ev = { + if (!src.id) die('an event in input has no id'); + const n = asMatchNumber(src.matchNumber); + if (packId === 'worldcup-2026' && looksLikeKnockout(src) && n == null) { + die(`event ${src.id}: knockout fixtures/results require matchNumber so W91-style placeholders reconcile safely`); + } + + const byNumber = n == null ? null : eventsByMatchNumber.get(n); + const byId = eventsById.get(src.id); + if (byNumber && byId && byNumber.id !== byId.id) { + die(`event ${src.id}: id and matchNumber ${n} point to different existing events`); + } + const existing = byNumber || byId || null; + const canonicalId = existing?.id || src.id; + if (src.id !== canonicalId) aliases.set(src.id, canonicalId); + + const candidate = { ...DEFAULTS, - ...existing, + ...(existing || {}), ...src, + id: canonicalId, status, - date: src.date || existing.date || topDate, - dateLabel: src.dateLabel || existing.dateLabel || topDateLabel, + date: src.date || existing?.date || topDate, + dateLabel: src.dateLabel || existing?.dateLabel || topDateLabel, + sources: mergeSources(existing?.sources, src.sources), + ...(n != null ? { matchNumber: n, matchKey: matchKey(n) } : {}), }; - if (!ev.date) die(`event ${src.id}: no date (set input.date or per-event date)`); - eventsById.set(ev.id, ev); + if (!candidate.date) die(`event ${src.id}: no date (set input.date or per-event date)`); + assertCompatibleFacts(existing, candidate); + delete candidate.factCorrection; + + if (existing && existing.id !== candidate.id) eventsById.delete(existing.id); + eventsById.set(candidate.id, candidate); + if (n != null) eventsByMatchNumber.set(n, candidate); } -// 1) Completed results — honesty gate: each MUST carry a source. -for (const r of input.results || []) { - if (!Array.isArray(r.sources) || r.sources.length === 0) { - die(`result ${r.id || ''}: a completed result requires a non-empty "sources" citation (honesty rule)`); +// Completed facts require citations. An AI-generated recap is not itself a score source. +for (const result of input.results || []) { + if (!Array.isArray(result.sources) || result.sources.length === 0) { + die(`result ${result.id || ''}: a completed result requires a non-empty "sources" citation`); } - upsertEvent(r, 'completed'); + upsertEvent(result, 'completed'); } -// 2) New upcoming fixtures. -for (const s of input.scheduled || []) upsertEvent(s, 'scheduled'); +for (const scheduled of input.scheduled || []) upsertEvent(scheduled, 'scheduled'); -// 3) Causal links (event -> event). Id is derived; dupes skipped. -for (const l of input.links || []) { - if (!l.fromEvent || !l.toEvent || !l.relationship) die(`a link is missing fromEvent/toEvent/relationship`); - const id = `${l.fromEvent}--${l.relationship}-->${l.toEvent}`; +const resolveAlias = (id) => aliases.get(id) || id; +for (const sourceLink of input.links || []) { + if (!sourceLink.fromEvent || !sourceLink.toEvent || !sourceLink.relationship) { + die('a link is missing fromEvent/toEvent/relationship'); + } + const fromEvent = resolveAlias(sourceLink.fromEvent); + const toEvent = resolveAlias(sourceLink.toEvent); + const id = `${fromEvent}--${sourceLink.relationship}-->${toEvent}`; if (linkIds.has(id)) continue; - links.push({ id, fromEvent: l.fromEvent, toEvent: l.toEvent, relationship: l.relationship, confidence: l.confidence, evidence: l.evidence }); + links.push({ + id, + fromEvent, + toEvent, + relationship: sourceLink.relationship, + confidence: sourceLink.confidence, + evidence: sourceLink.evidence, + }); linkIds.add(id); } -// 4) Attach links to insight patterns (dedup) or add whole new insights. -for (const u of input.insightInstances || []) { - const ins = insightsById.get(u.insightId); - if (!ins) die(`insightInstances: unknown insight "${u.insightId}"`); - const set = new Set(ins.instances); - for (const lid of u.addLinkIds || []) set.add(lid); - ins.instances = [...set]; +for (const update of input.insightInstances || []) { + const insight = insightsById.get(update.insightId); + if (!insight) die(`insightInstances: unknown insight "${update.insightId}"`); + const set = new Set(insight.instances); + for (const rawId of update.addLinkIds || []) { + const link = links.find((l) => l.id === rawId); + if (link) set.add(rawId); + } + insight.instances = [...set]; } -for (const ni of input.newInsights || []) { - if (insightsById.has(ni.id)) die(`newInsights: insight "${ni.id}" already exists`); - insights.push(ni); - insightsById.set(ni.id, ni); +for (const newInsight of input.newInsights || []) { + if (insightsById.has(newInsight.id)) die(`newInsights: insight "${newInsight.id}" already exists`); + insights.push(newInsight); + insightsById.set(newInsight.id, newInsight); } const merged = { events: [...eventsById.values()], links, insights }; - -// 5) Validate BEFORE writing. Nothing is written if invalid. -// (a) structural validation — ids resolve, enums in range, referential integrity. const errors = validatePackData(merged, packId); if (errors.length > 0) { console.error(`✗ ${errors.length} validation error(s) — nothing written:`); for (const e of errors) console.error(` - ${e}`); process.exit(1); } -// (b) causal-quality gate — the layer must be real, not templated. Catches a -// "Scorers: …" whyItMatters, a single-relationship graph, a backwards scoreline, -// a scheduled event carrying a result, an unresolved round/team, etc. This is -// what keeps the daily editorial honest even when structure is fine. + const { errors: qErrors, warnings: qWarnings } = assessCausalQuality(merged, packId); for (const w of qWarnings) console.warn(` ! ${w}`); if (qErrors.length > 0) { @@ -124,4 +181,5 @@ write(linksPath, merged.links); write(insightsPath, merged.insights); console.log(`✓ ${packId} updated: ${merged.events.length} events, ${merged.links.length} links, ${merged.insights.length} insights`); +console.log(` reconciled aliases: ${aliases.size}`); console.log(' Review the diff, then commit. CI re-validates on push.'); From df82a4586d40ec66394d1358e2e7e4a4f5fb96f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BA=B7ng=20Huy?= <72134114+huygdv@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:32:26 +0700 Subject: [PATCH 07/13] ci(wc2026): gate multi-source consistency and reconcile on merge --- .github/workflows/ingest.yml | 50 ++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ingest.yml b/.github/workflows/ingest.yml index b8478f0..2217366 100644 --- a/.github/workflows/ingest.yml +++ b/.github/workflows/ingest.yml @@ -1,19 +1,37 @@ name: ingest-worldcup -# Refresh the worldcup-2026 pack from openfootball (real, public-domain data) -# every day. The ingest script validates in memory and writes nothing on error; -# if the data changed, commit it — which triggers the gated Pages deploy. +# Refresh the structured match pack, reconcile stable match identities and block +# publication if independently produced recap history disagrees with pack facts. on: schedule: - - cron: "0 6 * * *" # daily 06:00 UTC (after most match days finish) + - cron: "0 6 * * *" workflow_dispatch: + pull_request: + paths: + - "scripts/ingest-openfootball.mjs" + - "scripts/worldcup-facts.mjs" + - "scripts/test-worldcup-facts.mjs" + - "scripts/validate-pack.mjs" + - "scripts/validate-worldcup-consistency.mjs" + - "scripts/add-match-day.mjs" + - ".github/workflows/ingest.yml" + push: + branches: [main] + paths: + - "scripts/ingest-openfootball.mjs" + - "scripts/worldcup-facts.mjs" + - "scripts/test-worldcup-facts.mjs" + - "scripts/validate-pack.mjs" + - "scripts/validate-worldcup-consistency.mjs" + - "scripts/add-match-day.mjs" + - ".github/workflows/ingest.yml" permissions: contents: write concurrency: - group: ingest + group: ingest-${{ github.ref }} cancel-in-progress: false jobs: @@ -24,9 +42,25 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "20" - - name: Ingest from openfootball + + - name: Test score and provenance helpers + run: node scripts/test-worldcup-facts.mjs + + - name: Ingest and reconcile from openfootball run: node scripts/ingest-openfootball.mjs - - name: Commit if the pack changed + + - name: Validate pack structure + run: node scripts/validate-pack.mjs worldcup-2026 + + - name: Cross-check pack against recap history + run: node scripts/validate-worldcup-consistency.mjs + + - name: Show generated diff on pull requests + if: github.event_name == 'pull_request' + run: git diff --stat -- packs/worldcup-2026 + + - name: Commit reconciled pack if changed + if: github.event_name != 'pull_request' run: | git config user.name "causari-bot" git config user.email "bot@users.noreply.github.com" @@ -34,6 +68,6 @@ jobs: echo "No data changes." else git add packs/worldcup-2026 - git commit -m "data(worldcup): daily ingest from openfootball" + git commit -m "data(worldcup): reconcile match identities and results" git push fi From b07b3728c83a621cfaf6010c33411565507aba62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BA=B7ng=20Huy?= <72134114+huygdv@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:33:39 +0700 Subject: [PATCH 08/13] fix(wc2026): reconcile placeholders and final knockout scores --- scripts/ingest-openfootball.mjs | 442 +++++++++++++++++++++++--------- 1 file changed, 321 insertions(+), 121 deletions(-) diff --git a/scripts/ingest-openfootball.mjs b/scripts/ingest-openfootball.mjs index a3cd41a..741b2de 100644 --- a/scripts/ingest-openfootball.mjs +++ b/scripts/ingest-openfootball.mjs @@ -1,46 +1,51 @@ #!/usr/bin/env node -// Ingest the REAL World Cup 2026 schedule + results from openfootball (public -// domain, no API key) and MERGE them into the worldcup-2026 pack. Every fixture -// becomes an event with an openfootball source citation; the ingest owns ONLY the -// mechanical facts (title, scoreline, date, status). It MERGES rather than -// overwrites, so the curated causal layer — real whyItMatters, nextWatchpoints, -// typed links, the 160-year lineage, curated insights — SURVIVES the daily run. -// Validates in memory (structure + causal quality); writes nothing on error. +// Ingest World Cup 2026 schedule/results from openfootball and reconcile them +// against the curated pack without treating a human-readable event id as the +// identity of a match. Match number is the stable identity when the source +// provides it; title/team/date ids remain presentation-friendly aliases. // -// node scripts/ingest-openfootball.mjs # fetch live + merge into pack -// node scripts/ingest-openfootball.mjs # use a local snapshot -// -// Run daily (see .github/workflows/ingest.yml) so the pack tracks the tournament. -// -// ── MERGE CONTRACT (why this stopped clobbering the causal graph) ──────────── -// For an event the ingest already knows (same id), it refreshes ONLY the fields -// openfootball is authoritative for (title/description-if-thin/date/status/ -// impactScore) and PRESERVES any curated fields already on disk (a real -// whyItMatters, nextWatchpoints, richer entities, extra tags). It NEVER deletes -// events it didn't create — the history-spine lineage and any editorially-added -// events stay. Links + insights already on disk are preserved and de-duplicated; -// the ingest only ADDS its own mechanical links when a curated one isn't present. -// See docs/CAUSAL-CONTRACT.md for the editorial quality bar the daily process must meet. +// Facts owned by the feed: participants, schedule, status and structured result. +// Editorial fields owned by Causari: whyItMatters, watchpoints, bilingual prose, +// causal links and insight patterns. Source citations are UNIONED, never replaced. +// Cross-source factual conflicts fail closed for manual review. import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { validatePackData } from './validate-pack.mjs'; import { assessCausalQuality } from './causal-quality.mjs'; +import { + asMatchNumber, + assertNoIndependentFactConflict, + eventTeamPair, + formatResultDescription, + formatResultTitle, + isPlaceholderEvent, + isPlaceholderTeam, + matchKey, + mergeSources, + normalizeOutcome, + outcomeWinner, +} from './worldcup-facts.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const OUT = join(ROOT, 'packs', 'worldcup-2026'); const SRC = 'https://raw.githubusercontent.com/openfootball/worldcup.json/master/2026/worldcup.json'; -const SOURCE = { type: 'open-data', citation: 'openfootball/worldcup.json (public domain)', url: 'https://github.com/openfootball/worldcup.json' }; +const SOURCE = { + type: 'open-data', + role: 'schedule-result-feed', + sourceId: 'openfootball:worldcup-2026', + citation: 'openfootball/worldcup.json (public domain)', + url: 'https://github.com/openfootball/worldcup.json', +}; const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']; -// Fields openfootball is authoritative for and may refresh on an existing event. -// Everything else already on disk (curated whyItMatters, nextWatchpoints, extra -// entities/tags, kickoff) is PRESERVED — that is the whole point of the merge. -const INGEST_OWNED = new Set(['title', 'date', 'dateLabel', 'status', 'yearNum', 'yearLabel', 'precision', 'sources']); -// A whyItMatters that matches these is mechanical — safe for the ingest to replace -// with its own fallback (or leave for the editorial pass to enrich). A NON-templated -// whyItMatters is curated and must never be overwritten. +// Sources are intentionally excluded. Provenance from Reuters/AP/FIFA/editorial +// inputs must survive a mechanical refresh. +const INGEST_OWNED = new Set([ + 'title', 'date', 'dateLabel', 'status', 'yearNum', 'yearLabel', 'precision', + 'matchNumber', 'matchKey', 'sourceMatchId', 'result', 'venue', 'kickoff', +]); const TEMPLATED_WHY = [/^scorers:/i, /^full-time\s+\d/i, /^upcoming\s+.*match/i]; const isTemplatedWhy = (w) => TEMPLATED_WHY.some((re) => re.test(String(w ?? ''))); @@ -51,7 +56,11 @@ function readJsonIfExists(path, fallback) { const slug = (s) => String(s).toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') .replace(/&/g, 'and').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); -const dateLabel = (iso) => { const [y, m, d] = iso.split('-').map(Number); return `${MONTHS[m - 1]} ${d}, ${y}`; }; +const dateLabel = (iso) => { + const [y, m, d] = iso.split('-').map(Number); + return `${MONTHS[m - 1]} ${d}, ${y}`; +}; +const norm = (s) => String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' '); async function loadMatches() { const arg = process.argv[2]; @@ -61,66 +70,116 @@ async function loadMatches() { return (await res.json()).matches; } +function sourceMatchId(m) { + const n = asMatchNumber(m.num); + return n != null + ? `openfootball:worldcup-2026:${n}` + : `openfootball:worldcup-2026:${m.date}:${slug(m.team1)}:${slug(m.team2)}`; +} + function buildEvents(matches) { const pairs = []; const seen = new Set(); for (const m of matches) { - const t1 = m.team1, t2 = m.team2, lane = m.group || m.round || 'Knockouts'; + const t1 = m.team1; + const t2 = m.team2; + const lane = m.group || m.round || 'Knockouts'; let id = `wc2026-${slug(t1)}-${slug(t2)}-${m.date.slice(5).replace('-', '')}`; while (seen.has(id)) id += 'x'; seen.add(id); - const played = !!(m.score && m.score.ft); + + const outcome = normalizeOutcome(m.score); + const played = !!outcome; const dd = dateLabel(m.date); - let title, description, whyItMatters, impactScore; + const n = asMatchNumber(m.num); + let title; + let description; + let whyItMatters; + let impactScore; + if (played) { - const [a, b] = m.score.ft; - title = `${t1} ${a}–${b} ${t2}`; - const verb = a > b ? 'beat' : (b > a ? 'lost to' : 'drew'); - description = `${t1} ${verb} ${t2} ${a}–${b} in ${lane}.`; - const scorers = [...(m.goals1 || []), ...(m.goals2 || [])].map((g) => g.name).filter(Boolean); - whyItMatters = scorers.length ? `Scorers: ${scorers.join(', ')}.` : `Full-time ${a}–${b}${m.ground ? ` at ${m.ground}` : ''}.`; - impactScore = Math.min(0.78, 0.52 + Math.abs(a - b) * 0.05); + title = formatResultTitle(t1, t2, outcome); + description = formatResultDescription(t1, t2, lane, outcome); + const scorers = [...(m.goals1 || []), ...(m.goals2 || [])] + .map((g) => g.name) + .filter(Boolean); + whyItMatters = scorers.length + ? `Scorers: ${scorers.join(', ')}.` + : `Full-time ${outcome.final[0]}–${outcome.final[1]}${m.ground ? ` at ${m.ground}` : ''}.`; + impactScore = Math.min(0.78, 0.52 + Math.abs(outcome.final[0] - outcome.final[1]) * 0.05); } else { title = `${t1} vs ${t2}`; description = `${lane} fixture: ${t1} vs ${t2}, ${dd}${m.ground ? ` at ${m.ground}` : ''}.`; whyItMatters = `Upcoming ${lane} match.`; impactScore = 0.5; } + const ev = { - id, title, description, yearNum: 2026, yearLabel: '2026', precision: 'day', - domains: ['culture', 'systems'], impactScore, + id, + title, + description, + yearNum: 2026, + yearLabel: '2026', + precision: 'day', + domains: ['culture', 'systems'], + impactScore, tags: ['world-cup-2026', slug(lane), slug(t1), slug(t2)], - date: m.date, dateLabel: dd, status: played ? 'completed' : 'scheduled', - entities: [t1, t2, lane], whyItMatters, sources: [SOURCE], + date: m.date, + dateLabel: dd, + status: played ? 'completed' : 'scheduled', + entities: [t1, t2, lane], + whyItMatters, + sources: [SOURCE], + sourceMatchId: sourceMatchId(m), + ...(n != null ? { matchNumber: n, matchKey: matchKey(n) } : {}), + ...(m.ground ? { venue: m.ground } : {}), + ...(m.time ? { kickoff: m.time } : {}), + ...(outcome ? { result: outcome } : {}), }; - pairs.push({ ev, m }); + pairs.push({ ev, m, outcome }); } return pairs; } -// Sparse causal layer: a large-margin win "accelerated" the winner's next fixture. +// Sparse mechanical links remain deliberately conservative. Editorial links win +// on duplicate ordered pairs later in the merge. function buildLinks(pairs) { const byTeam = {}; - for (const p of pairs) for (const t of [p.m.team1, p.m.team2]) (byTeam[t] = byTeam[t] || []).push(p); + for (const p of pairs) { + for (const t of [p.m.team1, p.m.team2]) { + if (isPlaceholderTeam(t)) continue; + (byTeam[t] = byTeam[t] || []).push(p); + } + } for (const t in byTeam) byTeam[t].sort((a, b) => a.m.date.localeCompare(b.m.date)); - const links = []; const ids = new Set(); + + const links = []; + const ids = new Set(); for (const p of pairs) { - if (!(p.m.score && p.m.score.ft)) continue; - const [a, b] = p.m.score.ft; const margin = Math.abs(a - b); - if (margin < 3) continue; - const winner = a > b ? p.m.team1 : p.m.team2; - // Report the WINNER's own margin (higher–lower), never the raw team1–team2 - // pair — otherwise a team2 win prints as e.g. "Mexico's 0–3 win" (the live bug). - const hi = Math.max(a, b), lo = Math.min(a, b); + if (!p.outcome) continue; + const [a, b] = p.outcome.final; + const margin = Math.abs(a - b); + const winner = outcomeWinner(p.m.team1, p.m.team2, p.outcome); + if (!winner || margin < 3) continue; + + const hi = Math.max(a, b); + const lo = Math.min(a, b); const seq = byTeam[winner]; const i = seq.indexOf(p); const next = seq[i + 1]; if (!next) continue; const nextOpp = next.m.team1 === winner ? next.m.team2 : next.m.team1; + if (isPlaceholderTeam(nextOpp)) continue; + const id = `${p.ev.id}--accelerated-->${next.ev.id}`; - if (ids.has(id)) continue; ids.add(id); + if (ids.has(id)) continue; + ids.add(id); links.push({ - id, fromEvent: p.ev.id, toEvent: next.ev.id, relationship: 'accelerated', confidence: 0.62, + id, + fromEvent: p.ev.id, + toEvent: next.ev.id, + relationship: 'accelerated', + confidence: 0.62, evidence: `${winner}'s ${hi}–${lo} win banked a goal-difference cushion heading into ${winner} vs ${nextOpp}.`, }); } @@ -134,89 +193,227 @@ function buildInsights(links) { pattern: 'Statement Win Creates Momentum and Goal-Difference Cushion', description: 'A large-margin win generates both a practical goal-difference advantage and narrative momentum that carries into the next fixture.', instances: links.map((l) => l.id), - predictiveValue: 0.7, domains: ['culture', 'systems'], + predictiveValue: 0.7, + domains: ['culture', 'systems'], }]; } -// ── Merge an ingest event onto whatever curated event is already on disk ────── -// Refresh only the fields openfootball owns; preserve every curated field. A -// templated whyItMatters is refreshed; a curated one is kept. Curated entities / -// tags / nextWatchpoints / kickoff — and their bilingual _vi twins — survive -// untouched (the whole spread of `existing` carries them through; the openfootball -// feed never produces a _vi field so it can only ADD, never wipe, Vietnamese prose). -function mergeEvent(existing, fresh) { - if (!existing) return fresh; // brand-new fixture +function eventRound(event) { + const entities = Array.isArray(event?.entities) ? event.entities : []; + const candidate = entities.find((x) => /group|round|quarter|semi|final|third/i.test(String(x))); + return norm(candidate); +} + +function venueMatches(event, m) { + if (!m.ground) return false; + if (norm(event?.venue) === norm(m.ground)) return true; + return norm(event?.description).includes(norm(m.ground)); +} + +function candidateScore(event, fresh, m) { + if (!event || !event.status) return 0; + const freshN = asMatchNumber(fresh.matchNumber); + const eventN = asMatchNumber(event.matchNumber); + if (freshN != null && eventN === freshN) return 120; + if (event.sourceMatchId && event.sourceMatchId === fresh.sourceMatchId) return 115; + if (event.id === fresh.id) return 110; + if (event.date === fresh.date && eventTeamPair(event) && eventTeamPair(event) === eventTeamPair(fresh)) return 90; + if ( + isPlaceholderEvent(event) + && event.date === fresh.date + && eventRound(event) === norm(m.group || m.round || 'Knockouts') + && venueMatches(event, m) + ) return 80; + return 0; +} + +function editorialRichness(event) { + let score = 0; + if (!event) return score; + if (!isPlaceholderEvent(event)) score += 10; + if (event.whyItMatters && !isTemplatedWhy(event.whyItMatters)) score += 8; + score += Math.min(5, Array.isArray(event.nextWatchpoints) ? event.nextWatchpoints.length : 0); + score += Math.min(3, Array.isArray(event.sources) ? event.sources.length : 0); + if (event.whyItMatters_vi) score += 2; + if (Array.isArray(event.nextWatchpoints_vi) && event.nextWatchpoints_vi.length) score += 2; + return score; +} + +function shouldMigrateId(existing, fresh) { + if (!existing) return false; + if (isPlaceholderEvent(existing) && !isPlaceholderEvent(fresh)) return true; + const oldPair = eventTeamPair(existing); + const newPair = eventTeamPair(fresh); + return asMatchNumber(existing.matchNumber) != null && oldPair && newPair && oldPair !== newPair; +} + +function mergeEvent(existing, fresh, duplicates = []) { + for (const candidate of [existing, ...duplicates]) { + if (candidate) assertNoIndependentFactConflict(candidate, fresh); + } + if (!existing) return { + ...fresh, + sources: mergeSources(...duplicates.map((e) => e.sources), fresh.sources), + }; + const merged = { ...existing }; - for (const k of INGEST_OWNED) if (fresh[k] !== undefined) merged[k] = fresh[k]; - // description: only refresh if the curated one is missing or still the ingest stub. - if (!existing.description || /^\w[\w '&-]* (?:beat|lost to|drew) /.test(existing.description) || /fixture:/.test(existing.description)) { + for (const k of INGEST_OWNED) { + if (fresh[k] !== undefined) merged[k] = fresh[k]; + else delete merged[k]; + } + + const existingIsPlaceholder = isPlaceholderEvent(existing); + const freshIsResolved = !isPlaceholderEvent(fresh); + if ( + !existing.description + || /^\w[\w '&-]* (?:beat|lost to|drew) /.test(existing.description) + || /fixture:/.test(existing.description) + || (existingIsPlaceholder && freshIsResolved) + ) { merged.description = fresh.description; } - // whyItMatters: replace ONLY if the existing one is templated/empty. When we DO - // overwrite a stale EN line with the ingest fallback, drop the now-orphaned VI - // twin so it can't caption an EN string it no longer matches (the editorial pass - // re-authors both). A curated whyItMatters is kept, and its whyItMatters_vi with it. - if (!existing.whyItMatters || isTemplatedWhy(existing.whyItMatters)) { + + if ( + !existing.whyItMatters + || isTemplatedWhy(existing.whyItMatters) + || /\b(?:W|L)\d+\b/i.test(String(existing.whyItMatters)) + ) { merged.whyItMatters = fresh.whyItMatters; delete merged.whyItMatters_vi; - // Drop the VI watchpoints too: a stale whyItMatters this templated has no - // curated causal layer worth captioning in Vietnamese; the editorial pass - // re-authors watchpoints + their VI twin together. delete merged.nextWatchpoints_vi; } - // entities: keep the richer set (curated may add a venue / round label). - if (Array.isArray(existing.entities) && existing.entities.length >= (fresh.entities?.length ?? 0)) { + + if (existingIsPlaceholder && freshIsResolved) { + merged.entities = fresh.entities; + } else if (Array.isArray(existing.entities) && existing.entities.length >= (fresh.entities?.length ?? 0)) { merged.entities = existing.entities; } else { merged.entities = fresh.entities; } - // impactScore: keep a curated (non-default) score, else take the ingest's margin-based one. - merged.impactScore = typeof existing.impactScore === 'number' && existing.impactScore !== 0.5 && existing.impactScore !== 0.6 - ? existing.impactScore : fresh.impactScore; + + const inheritedTags = [existing, ...duplicates] + .flatMap((e) => Array.isArray(e?.tags) ? e.tags : []) + .filter((tag) => !(freshIsResolved && /^(?:w|l)\d+$/i.test(String(tag)))); + merged.tags = [...new Set([...inheritedTags, ...(fresh.tags || [])])]; + merged.sources = mergeSources( + existing.sources, + ...duplicates.map((e) => e.sources), + fresh.sources, + ); + + merged.impactScore = typeof existing.impactScore === 'number' + && existing.impactScore !== 0.5 + && existing.impactScore !== 0.6 + ? existing.impactScore + : fresh.impactScore; + return merged; } +function resolveAlias(id, aliases) { + let current = id; + const seen = new Set(); + while (aliases.has(current) && !seen.has(current)) { + seen.add(current); + current = aliases.get(current); + } + return current; +} + const matches = await loadMatches(); const pairs = buildEvents(matches); -const freshEvents = pairs.map((p) => p.ev); const freshLinks = buildLinks(pairs); -// Load the curated pack already on disk (the causal substrate to PRESERVE). const priorEvents = readJsonIfExists(join(OUT, 'events.json'), []); const priorLinks = readJsonIfExists(join(OUT, 'links.json'), []); const priorInsights = readJsonIfExists(join(OUT, 'insights.json'), []); -// 1) Events: merge fresh onto prior by id; keep every prior event the ingest -// didn't touch (history-spine lineage + editorially-added events). -const freshById = new Map(freshEvents.map((e) => [e.id, e])); -const priorById = new Map(priorEvents.map((e) => [e.id, e])); -const mergedEventsById = new Map(); -for (const e of priorEvents) mergedEventsById.set(e.id, e); // preserve all prior -for (const e of freshEvents) mergedEventsById.set(e.id, mergeEvent(priorById.get(e.id), e)); -const events = [...mergedEventsById.values()]; - -// 2) Links: keep every prior (curated) link; add a fresh mechanical link ONLY if -// no link already connects that ordered pair (don't fight the editorial layer). -const priorPairs = new Set(priorLinks.map((l) => `${l.fromEvent}>${l.toEvent}`)); -const priorLinkIds = new Set(priorLinks.map((l) => l.id)); -const addedLinks = freshLinks.filter((l) => !priorLinkIds.has(l.id) && !priorPairs.has(`${l.fromEvent}>${l.toEvent}`)); -const links = [...priorLinks, ...addedLinks]; - -// 3) Insights: preserve curated insights. Only seed the mechanical statement-win -// pattern if the pack has NO insights at all (a fresh pack), and never wipe curated ones. -let insights = priorInsights; -if (insights.length === 0 && addedLinks.length) insights = buildInsights(addedLinks); - -// 4) Drop any link whose endpoints no longer resolve (safety after the merge). -const eventIdSet = new Set(events.map((e) => e.id)); -const finalLinks = links.filter((l) => eventIdSet.has(l.fromEvent) && eventIdSet.has(l.toEvent)); -// 5) Drop insight instances pointing at links that no longer exist. -const linkIdSet = new Set(finalLinks.map((l) => l.id)); -const finalInsights = insights - .map((i) => ({ ...i, instances: (i.instances || []).filter((id) => linkIdSet.has(id)) })) - .filter((i) => i.instances.length > 0); - -// Structural validation is a HARD gate (never write a broken graph). +// Reconcile by stable match identity first, then exact id/team pair, then a +// placeholder's schedule slot (date + round + venue). This removes W91/W92-style +// ghosts when the upstream feed resolves participants. +const consumedPriorIds = new Set(); +const aliases = new Map(); +const reconciledEvents = []; + +for (const pair of pairs) { + const { ev: fresh, m } = pair; + const candidates = priorEvents + .filter((event) => !consumedPriorIds.has(event.id)) + .map((event) => ({ event, score: candidateScore(event, fresh, m) })) + .filter((x) => x.score > 0) + .sort((a, b) => b.score - a.score || editorialRichness(b.event) - editorialRichness(a.event)); + + const exact = candidates.find((x) => x.event.id === fresh.id)?.event; + const primary = exact || candidates[0]?.event || null; + const duplicateEvents = candidates.map((x) => x.event).filter((e) => e !== primary); + let canonicalId = primary?.id || fresh.id; + if (!primary || shouldMigrateId(primary, fresh)) canonicalId = fresh.id; + + const merged = mergeEvent(primary, fresh, duplicateEvents); + merged.id = canonicalId; + const legacyIds = new Set(Array.isArray(merged.legacyIds) ? merged.legacyIds : []); + + for (const candidate of candidates.map((x) => x.event)) { + consumedPriorIds.add(candidate.id); + if (candidate.id !== canonicalId) { + aliases.set(candidate.id, canonicalId); + legacyIds.add(candidate.id); + } + } + if (fresh.id !== canonicalId) aliases.set(fresh.id, canonicalId); + if (legacyIds.size) merged.legacyIds = [...legacyIds].filter((id) => id !== canonicalId).sort(); + reconciledEvents.push(merged); +} + +// Preserve static lineage and editorial events not represented by the feed. +for (const event of priorEvents) { + if (!consumedPriorIds.has(event.id)) reconciledEvents.push(event); +} + +const eventsById = new Map(); +for (const event of reconciledEvents) { + if (eventsById.has(event.id)) { + throw new Error(`duplicate canonical event id after reconciliation: ${event.id}`); + } + eventsById.set(event.id, event); +} +const events = [...eventsById.values()]; + +function remapLink(link) { + const fromEvent = resolveAlias(link.fromEvent, aliases); + const toEvent = resolveAlias(link.toEvent, aliases); + const id = `${fromEvent}--${link.relationship}-->${toEvent}`; + return { ...link, id, fromEvent, toEvent }; +} + +const finalLinks = []; +const linkIds = new Set(); +const orderedPairs = new Set(); +const linkAliases = new Map(); +let addedLinks = 0; + +// Curated links are considered first, so the mechanical layer cannot replace one. +for (const original of [...priorLinks, ...freshLinks]) { + const link = remapLink(original); + if (original.id !== link.id) linkAliases.set(original.id, link.id); + const pairKey = `${link.fromEvent}>${link.toEvent}`; + if (linkIds.has(link.id) || orderedPairs.has(pairKey)) continue; + if (!eventsById.has(link.fromEvent) || !eventsById.has(link.toEvent)) continue; + finalLinks.push(link); + linkIds.add(link.id); + orderedPairs.add(pairKey); + if (freshLinks.includes(original)) addedLinks++; +} + +let finalInsights = priorInsights + .map((insight) => ({ + ...insight, + instances: [...new Set((insight.instances || []).map((id) => linkAliases.get(id) || id))] + .filter((id) => linkIds.has(id)), + })) + .filter((insight) => insight.instances.length > 0); +if (finalInsights.length === 0 && addedLinks) finalInsights = buildInsights(finalLinks); + const errors = validatePackData({ events, links: finalLinks, insights: finalInsights }, 'worldcup-2026'); if (errors.length) { console.error(`✗ ${errors.length} structural error(s) — nothing written:`); @@ -224,18 +421,21 @@ if (errors.length) { process.exit(1); } -// Causal-quality is a WARNING here (the ingest alone can't produce varied links); -// it is HARD-enforced in add-match-day.mjs and CI where the editorial pass runs. const { warnings: qWarnings, errors: qErrors, stats } = assessCausalQuality( - { events, links: finalLinks, insights: finalInsights }, 'worldcup-2026'); + { events, links: finalLinks, insights: finalInsights }, + 'worldcup-2026', +); if (qErrors.length) { - console.warn(`! causal-quality: ${qErrors.length} issue(s) remain after ingest (the daily editorial pass must resolve these before publish):`); + console.warn(`! causal-quality: ${qErrors.length} issue(s) remain after ingest (the editorial pass must resolve these before publish):`); for (const w of qErrors.slice(0, 8)) console.warn(' - ' + w); } for (const w of qWarnings.slice(0, 4)) console.warn(' ! ' + w); const write = (n, d) => writeFileSync(join(OUT, n), JSON.stringify(d, null, 2) + '\n'); -write('events.json', events); write('links.json', finalLinks); write('insights.json', finalInsights); +write('events.json', events); +write('links.json', finalLinks); +write('insights.json', finalInsights); const played = events.filter((e) => e.status === 'completed').length; -console.log(`✓ worldcup-2026 (merged): ${events.length} events (${played} played, ${events.length - played} upcoming, ${stats.lineage} lineage), ${finalLinks.length} links (${addedLinks.length} added by ingest), ${finalInsights.length} insights`); +console.log(`✓ worldcup-2026 (reconciled): ${events.length} events (${played} played, ${events.length - played} upcoming, ${stats.lineage} lineage), ${finalLinks.length} links (${addedLinks} added by ingest), ${finalInsights.length} insights`); console.log(` preserved from prior: ${priorEvents.length} events, ${priorLinks.length} links, ${priorInsights.length} insights`); +console.log(` reconciled aliases: ${aliases.size}`); From 332787e279908763484f55f24c8e4cef4cfdce95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BA=B7ng=20Huy?= <72134114+huygdv@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:35:36 +0700 Subject: [PATCH 09/13] fix(wc2026): keep winner-oriented descriptions accurate --- scripts/worldcup-facts.mjs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/worldcup-facts.mjs b/scripts/worldcup-facts.mjs index 72513b4..030d0e6 100644 --- a/scripts/worldcup-facts.mjs +++ b/scripts/worldcup-facts.mjs @@ -73,13 +73,19 @@ export function formatResultDescription(team1, team2, lane, outcome) { : (a > b ? team1 : b > a ? team2 : null); if (!winner) return `${team1} drew ${team2} ${a}–${b} in ${lane}.`; - const loser = winner === team1 ? team2 : team1; + const winnerIsTeam1 = winner === team1; + const loser = winnerIsTeam1 ? team2 : team1; + const winnerScore = winnerIsTeam1 ? a : b; + const loserScore = winnerIsTeam1 ? b : a; + const penaltyScore = outcome.penalties + ? (winnerIsTeam1 ? outcome.penalties : [outcome.penalties[1], outcome.penalties[0]]) + : null; const suffix = outcome.decidedBy === 'extra_time' ? ' after extra time' : outcome.decidedBy === 'penalties' - ? ` on penalties ${outcome.penalties[0]}–${outcome.penalties[1]}` + ? ` on penalties ${penaltyScore[0]}–${penaltyScore[1]}` : ''; - return `${winner} beat ${loser} ${a}–${b}${suffix} in ${lane}.`; + return `${winner} beat ${loser} ${winnerScore}–${loserScore}${suffix} in ${lane}.`; } export function parseResultTitle(title) { From 5ea8d42960d88575a9c5bc7f9b91521aad354ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BA=B7ng=20Huy?= <72134114+huygdv@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:35:53 +0700 Subject: [PATCH 10/13] test(wc2026): assert winner-oriented extra-time description --- scripts/test-worldcup-facts.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-worldcup-facts.mjs b/scripts/test-worldcup-facts.mjs index 774a428..8042bcd 100644 --- a/scripts/test-worldcup-facts.mjs +++ b/scripts/test-worldcup-facts.mjs @@ -21,7 +21,7 @@ const extraTime = normalizeOutcome({ ft: [1, 1], et: [1, 2] }); assert.deepEqual(extraTime.final, [1, 2]); assert.equal(extraTime.decidedBy, 'extra_time'); assert.equal(formatResultTitle('Norway', 'England', extraTime), 'Norway 1–2 England'); -assert.match(formatResultDescription('Norway', 'England', 'Quarter-final', extraTime), /after extra time/); +assert.equal(formatResultDescription('Norway', 'England', 'Quarter-final', extraTime), 'England beat Norway 2–1 after extra time in Quarter-final.'); const penalties = normalizeOutcome({ ft: [0, 0], et: [0, 0], p: [4, 3] }); assert.equal(formatResultTitle('Switzerland', 'Colombia', penalties), 'Switzerland 0–0 (4–3 pens) Colombia'); From f4cabad2dea120faafd84f1cc9f6e99bcb9d305c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BA=B7ng=20Huy?= <72134114+huygdv@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:36:34 +0700 Subject: [PATCH 11/13] fix(validation): keep match-key rules pack-specific --- scripts/validate-pack.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/validate-pack.mjs b/scripts/validate-pack.mjs index 4258830..77bcbcb 100644 --- a/scripts/validate-pack.mjs +++ b/scripts/validate-pack.mjs @@ -69,7 +69,7 @@ export function validatePackData({ events, links, insights }, packId = 'pack') { } else { matchNumbers.set(ev.matchNumber, id); } - if (ev.matchKey !== undefined) { + if (ev.matchKey !== undefined && packId === 'worldcup-2026') { const expected = `wc2026-match-${String(ev.matchNumber).padStart(3, '0')}`; if (ev.matchKey !== expected) E(`event ${id}: matchKey should be "${expected}"`); } From a9e9f7a57724ced750b36a8021a82e1008667969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BA=B7ng=20Huy?= <72134114+huygdv@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:37:17 +0700 Subject: [PATCH 12/13] fix(wc2026): remap causal references through canonical match ids --- scripts/add-match-day.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/add-match-day.mjs b/scripts/add-match-day.mjs index 333e1aa..ee2ca20 100644 --- a/scripts/add-match-day.mjs +++ b/scripts/add-match-day.mjs @@ -49,6 +49,7 @@ for (const event of events) { const linkIds = new Set(links.map((l) => l.id)); const insightsById = new Map(insights.map((i) => [i.id, i])); const aliases = new Map(); +const linkAliases = new Map(); const DEFAULTS = { domains: ['culture', 'systems'], @@ -130,7 +131,9 @@ for (const sourceLink of input.links || []) { } const fromEvent = resolveAlias(sourceLink.fromEvent); const toEvent = resolveAlias(sourceLink.toEvent); + const rawId = `${sourceLink.fromEvent}--${sourceLink.relationship}-->${sourceLink.toEvent}`; const id = `${fromEvent}--${sourceLink.relationship}-->${toEvent}`; + if (rawId !== id) linkAliases.set(rawId, id); if (linkIds.has(id)) continue; links.push({ id, @@ -148,8 +151,9 @@ for (const update of input.insightInstances || []) { if (!insight) die(`insightInstances: unknown insight "${update.insightId}"`); const set = new Set(insight.instances); for (const rawId of update.addLinkIds || []) { - const link = links.find((l) => l.id === rawId); - if (link) set.add(rawId); + const canonicalId = linkAliases.get(rawId) || rawId; + const link = links.find((l) => l.id === canonicalId); + if (link) set.add(canonicalId); } insight.instances = [...set]; } From 967f165019b5134bccbb36a8d15c862dc50bae2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=90=E1=BA=B7ng=20Huy?= <72134114+huygdv@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:38:32 +0700 Subject: [PATCH 13/13] fix(wc2026): preserve identity contributed by other adapters --- scripts/ingest-openfootball.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/ingest-openfootball.mjs b/scripts/ingest-openfootball.mjs index 741b2de..a4a04e7 100644 --- a/scripts/ingest-openfootball.mjs +++ b/scripts/ingest-openfootball.mjs @@ -259,7 +259,9 @@ function mergeEvent(existing, fresh, duplicates = []) { const merged = { ...existing }; for (const k of INGEST_OWNED) { if (fresh[k] !== undefined) merged[k] = fresh[k]; - else delete merged[k]; + // A scheduled refresh must clear a stale completed result, but absence of a + // matchNumber/venue in one adapter must not erase identity from another. + else if (k === 'result') delete merged[k]; } const existingIsPlaceholder = isPlaceholderEvent(existing);