Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions .github/workflows/ingest.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -24,16 +42,32 @@ 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"
if git diff --quiet -- packs/worldcup-2026; then
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
63 changes: 63 additions & 0 deletions docs/WORLDCUP-SOURCE-POLICY.md
Original file line number Diff line number Diff line change
@@ -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.
164 changes: 113 additions & 51 deletions scripts/add-match-day.mjs
Original file line number Diff line number Diff line change
@@ -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 <input.json> [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)), '..');

Expand All @@ -40,76 +39,138 @@ 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 linkAliases = 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 || '<no 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 || '<no 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 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, 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 canonicalId = linkAliases.get(rawId) || rawId;
const link = links.find((l) => l.id === canonicalId);
if (link) set.add(canonicalId);
}
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) {
Expand All @@ -124,4 +185,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.');
Loading
Loading