Content engine: posts that structurally cannot lie
PHASE 0 — contentEngine makes Truth Law structural, not careful. Copy is
token-substituted and an unbacked {token} REFUSES to render -- there is no
code path that produces a plausible default. The fact contract is asserted
before any string is built. Card and copy render from ONE fact object, so
a caption and a card cannot disagree. No live model writes factual claims:
the voice is in the template, the facts are pulled, and the voice-polish
port is deliberately unwired, because an LLM that can rewrite a sentence
can rewrite a number.
18 tests carry the proof. The one that matters most: ZERO IS PRESENT.
"0 cleared B+" is our most honest possible post, and treating 0 as missing
would be the Number(null)===0 breach wearing its opposite coat -- it would
silently delete exactly the post the brand is built on.
PHASE 1 — three templates, generating real posts from tonight's data:
hot hitters off the repaired full-season log, the honesty flex off the
real servedGrade distribution (2,140 graded / 70 cleared B+ / 42% not
separable / A unissuable), and streaks verified from settled outcomes only.
THE ENGINE CAUGHT A BUG IN ITSELF, and it is the sharpest lesson here. The
first run published "No hitter is meaningfully hot tonight -- we could
dress up a middling week as a streak. We don't." That was FALSE: the
box-score cache spans only the settled window, every player had under 20
games, and the pool was empty. A broken pull was publishing as considered
editorial judgement -- the fourth appearance of this class tonight and the
first where our OWN HONESTY COPY was the disguise.
Fixed structurally rather than by patching the number: an absent() variant
may now DECLINE to speak, and the template separates "no candidates at
all" (SKIP with a reason) from "candidates judged, none hot" (honest
absence). Both locked by test. Source corrected to mlbStatsAdapter.fullLog,
the same log the repaired champion reads.
PHASE 2 — cardRenderer emits SVG rather than canvas: it is text, so it
diffs in review and its numbers are greppable, which matters when the
whole claim is that the numbers are real. VYND white + R green, slashed-Y,
scanlines, mono. The card never formats its own facts -- every string
arrives pre-rendered and gate-checked.
PHASE 3 — scripts/generate-content.js writes copy + card per template to
.content-out/<date>/. Template N+1 is a registry entry: requires, pull,
copy, card, absent. Queued as stubs, not built: hot takes, daily reads,
"grades we DIDN'T give", cross-sport streak variants (the streak template
is already sport-agnostic -- settled outcomes and a noun).
FULLY ISOLATED: read-only on every source, zero writes to serving, model
or ledger tables. Serving fingerprint verified unchanged. The accrual clock
is untouched at 0 eligible dates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
This commit is contained in:
@@ -29,3 +29,5 @@ out/
|
|||||||
.vercel/
|
.vercel/
|
||||||
|
|
||||||
.seq-cache/
|
.seq-cache/
|
||||||
|
|
||||||
|
.content-out/
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* generate-content — tonight's posts, from tonight's real data.
|
||||||
|
*
|
||||||
|
* READ-ONLY on every source. This writes nothing to any serving, model or
|
||||||
|
* ledger table, so it has zero effect on the repaired-champion accrual clock.
|
||||||
|
*
|
||||||
|
* SUPABASE_URL=... node scripts/generate-content.js
|
||||||
|
* -> .content-out/<date>/<template>.txt and .svg
|
||||||
|
*/
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { createClient } = require('@supabase/supabase-js');
|
||||||
|
const engine = require('../src/services/content/contentEngine');
|
||||||
|
const { toSvg } = require('../src/services/content/cardRenderer');
|
||||||
|
const sg = require('../src/services/model/servedGrade');
|
||||||
|
const { knownNumber } = require('../src/utils/known');
|
||||||
|
|
||||||
|
for (const t of ['hotHitters', 'honestyFlex', 'streakList']) {
|
||||||
|
engine.registerTemplate(require(`../src/services/content/templates/${t}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOX = path.join(process.cwd(), '.seq-cache', 'batting-lines.json');
|
||||||
|
const OUT = path.join(process.cwd(), '.content-out');
|
||||||
|
|
||||||
|
async function page(sb, t, sel, ob, f) {
|
||||||
|
const o = [];
|
||||||
|
for (let i = 0; ; i += 1000) {
|
||||||
|
const { data, error } = await f(sb.from(t).select(sel)).order(ob, { ascending: true }).range(i, i + 999);
|
||||||
|
if (error) throw new Error(`${t}: ${error.message}`);
|
||||||
|
if (!data || !data.length) break;
|
||||||
|
o.push(...data);
|
||||||
|
if (data.length < 1000) break;
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const sb = createClient(process.env.SUPABASE_URL,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY, { auth: { persistSession: false } });
|
||||||
|
const date = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date());
|
||||||
|
|
||||||
|
// ── SOURCE 1: full-season hitter form (the REPAIRED window, not last10) ──
|
||||||
|
const lines = fs.existsSync(BOX) ? JSON.parse(fs.readFileSync(BOX, 'utf8')).lines : {};
|
||||||
|
const byPlayer = new Map();
|
||||||
|
for (const [k, b] of Object.entries(lines)) {
|
||||||
|
const [d, key] = k.split('|');
|
||||||
|
if (!byPlayer.has(key)) byPlayer.set(key, []);
|
||||||
|
byPlayer.get(key).push({ d, hits: b.hits, name: b.name });
|
||||||
|
}
|
||||||
|
// The box-score cache spans only the settled snapshot window, so it holds far
|
||||||
|
// fewer than a season per player. Season form comes from the SAME full log the
|
||||||
|
// repaired champion reads.
|
||||||
|
const mlb = require('../src/services/adapters/mlbStatsAdapter');
|
||||||
|
const hitterFormFull = async (names) => {
|
||||||
|
const out = [];
|
||||||
|
for (const n of names.slice(0, 60)) {
|
||||||
|
try {
|
||||||
|
const res = await mlb.getPlayerStats(n);
|
||||||
|
const log = (res && res.found && Array.isArray(res.fullLog)) ? res.fullLog : [];
|
||||||
|
const vals = log.map((g) => knownNumber(g && g.stat && g.stat.hits)).filter((v) => v !== null);
|
||||||
|
if (vals.length < 20) continue;
|
||||||
|
const rate = (a) => a.filter((v) => v > 0).length / a.length;
|
||||||
|
out.push({ name: n, season_games: vals.length, season_rate: rate(vals), recent_rate: rate(vals.slice(-10)) });
|
||||||
|
} catch { /* absent player -> absent row */ }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
const cacheForm = async () => [...byPlayer.entries()].map(([key, games]) => {
|
||||||
|
games.sort((a, b) => a.d.localeCompare(b.d));
|
||||||
|
const vals = games.map((g) => knownNumber(g.hits)).filter((v) => v !== null);
|
||||||
|
if (vals.length < 20) return null;
|
||||||
|
const rate = (arr) => arr.filter((v) => v > 0).length / arr.length;
|
||||||
|
return {
|
||||||
|
name: games[games.length - 1].name || key,
|
||||||
|
season_games: vals.length,
|
||||||
|
season_rate: rate(vals),
|
||||||
|
recent_rate: rate(vals.slice(-10)),
|
||||||
|
};
|
||||||
|
}).filter(Boolean);
|
||||||
|
const hitterForm = async () => {
|
||||||
|
const names = [...byPlayer.values()].map((g) => g[g.length - 1].name).filter(Boolean);
|
||||||
|
const full = await hitterFormFull([...new Set(names)]);
|
||||||
|
return full.length ? full : await cacheForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── SOURCE 2: the real served-grade distribution ──
|
||||||
|
const snaps = await page(sb, 'model_snapshots', 'p_win, refused, stat, game_date', 'id',
|
||||||
|
(q) => q.eq('sport', 'mlb').eq('game_date', date));
|
||||||
|
const gradeDistribution = async () => {
|
||||||
|
const usable = snaps.filter((r) => !r.refused && knownNumber(r.p_win) !== null);
|
||||||
|
const by = {}; let flat = 0;
|
||||||
|
for (const r of usable) {
|
||||||
|
const g = sg.gradeFor({ p_win: knownNumber(r.p_win) });
|
||||||
|
by[g.letter] = (by[g.letter] || 0) + 1;
|
||||||
|
if (g.separates_from_base_rate === false) flat += 1;
|
||||||
|
}
|
||||||
|
return { total: usable.length || null, by_letter: by, not_separable: flat };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── SOURCE 3: streaks verified from SETTLED ledger outcomes only ──
|
||||||
|
const led = await page(sb, 'ledger_entries', 'player_name, player_key, game_date, outcome, stat', 'id',
|
||||||
|
(q) => q.eq('sport', 'mlb').is('user_id', null).eq('stat', 'hits').in('outcome', ['hit', 'miss']));
|
||||||
|
const settledStreaks = async () => {
|
||||||
|
const by = new Map();
|
||||||
|
for (const r of led) {
|
||||||
|
if (!by.has(r.player_key)) by.set(r.player_key, []);
|
||||||
|
by.get(r.player_key).push(r);
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (const [, rows] of by) {
|
||||||
|
rows.sort((a, b) => String(b.game_date).localeCompare(String(a.game_date)));
|
||||||
|
let n = 0;
|
||||||
|
for (const r of rows) { if (r.outcome === 'hit') n += 1; else break; }
|
||||||
|
if (n >= 3) out.push({ name: rows[0].player_name, streak: n, verified_from_settled: true });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deps = { date, hitterForm, gradeDistribution, settledStreaks, servedGrade: sg };
|
||||||
|
const results = await engine.generateAll(deps);
|
||||||
|
|
||||||
|
const dir = path.join(OUT, date);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
for (const r of results) {
|
||||||
|
if (!r.ok) { console.log(`\n[SKIP] ${r.id} — ${r.reason}`); continue; }
|
||||||
|
fs.writeFileSync(path.join(dir, `${r.id}.txt`), r.copy);
|
||||||
|
fs.writeFileSync(path.join(dir, `${r.id}.svg`), toSvg(r.card));
|
||||||
|
console.log(`\n${'='.repeat(64)}\n${r.id.toUpperCase()}${r.honest_absence ? ' [HONEST ABSENCE]' : ''}\n${'='.repeat(64)}\n${r.copy}`);
|
||||||
|
}
|
||||||
|
console.log(`\n\noutput: ${dir}`);
|
||||||
|
process.exit(0);
|
||||||
|
})().catch((e) => { console.error('FAILED:', e.message); process.exit(1); });
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# The content engine — posts that structurally cannot lie
|
||||||
|
|
||||||
|
## Phase 0 — architecture
|
||||||
|
|
||||||
|
`src/services/content/contentEngine.js`. Three mechanisms make Truth Law
|
||||||
|
structural rather than careful:
|
||||||
|
|
||||||
|
1. **Copy is token-substituted.** Every factual claim is a `{token}` resolved
|
||||||
|
against pulled facts. An unbacked token **refuses to render** — there is no
|
||||||
|
code path producing a plausible default.
|
||||||
|
2. **The fact contract is asserted first.** A template declares required fields;
|
||||||
|
they are checked *before any string is built*.
|
||||||
|
3. **Card and copy share one fact object.** They cannot diverge.
|
||||||
|
|
||||||
|
**No live model writes factual claims.** The voice is in the template, the facts
|
||||||
|
are pulled. A voice-polish port is reserved and deliberately unwired — an LLM
|
||||||
|
that can rewrite a sentence can rewrite a number.
|
||||||
|
|
||||||
|
**Read-only on every source.** Zero writes to serving, model or ledger tables, so
|
||||||
|
zero effect on the accrual clock.
|
||||||
|
|
||||||
|
### The Truth-Law proof — 18 tests
|
||||||
|
|
||||||
|
| the guard | what it prevents |
|
||||||
|
|---|---|
|
||||||
|
| unbacked token refuses | `{edge}` rendering as `undefined` or an empty hole |
|
||||||
|
| card tokens gated too | a caption that's honest beside a card that isn't |
|
||||||
|
| `render()` throws directly | a caller bypassing the gate |
|
||||||
|
| `null` never renders as `"null"` | absence dressed as data |
|
||||||
|
| **`0` IS present** | *"0 cleared B+"* is our most honest post — deleting it would be `Number(null)===0` in reverse |
|
||||||
|
| `NaN`/`Infinity` absent | arithmetic failures are not facts |
|
||||||
|
| contract gap names the field | a silent half-post |
|
||||||
|
| pull failure skips | a post built on a dead source |
|
||||||
|
|
||||||
|
## Phase 1 — three templates, real output
|
||||||
|
|
||||||
|
**HOT HITTERS** (from the repaired full-season log, not a ten-game slice):
|
||||||
|
> Jahmai Jones is hitting 60% over his last 10. His season number is 26%.
|
||||||
|
> That gap is the whole point. Everybody else is guessing at it.
|
||||||
|
|
||||||
|
**THE HONESTY FLEX** — the differentiator, and every number is ours:
|
||||||
|
> WE GRADED 2140 PROPS TONIGHT. 70 CLEARED B+.
|
||||||
|
> That's 3%. The other 42% we can't separate from the baseline, and we say so on the card instead of calling them leans.
|
||||||
|
> We do not issue A+, A, A-. No band of this model has ever hit at a rate that would justify one.
|
||||||
|
> Everybody else's card is all A's. Ask them what their A actually hits.
|
||||||
|
|
||||||
|
**STREAK LIST** — verified from settled outcomes only:
|
||||||
|
> Nathan Church has a 7-game hit streak. Live, verified off settled results only.
|
||||||
|
> Every game in these ran to a final. We don't count a pending night to make a number look better.
|
||||||
|
|
||||||
|
### The bug the engine caught in itself
|
||||||
|
|
||||||
|
The first run emitted *"No hitter is meaningfully hot tonight — we could dress up
|
||||||
|
a middling week as a streak. We don't."*
|
||||||
|
|
||||||
|
**That was false.** The box-score cache spans only the settled snapshot window,
|
||||||
|
so **every** player had fewer than 20 games and the pool was empty. A broken pull
|
||||||
|
was publishing as considered editorial judgement — **the fourth appearance of
|
||||||
|
this class tonight, and the first where our own honesty copy was the disguise.**
|
||||||
|
|
||||||
|
Fixed structurally: an `absent()` variant may now **decline to speak**. The
|
||||||
|
template separates *no candidates at all* (SKIP with a reason) from *candidates
|
||||||
|
judged, none hot* (honest absence). Both cases are locked by test.
|
||||||
|
|
||||||
|
Source corrected to `mlbStatsAdapter.fullLog` — the same log the repaired
|
||||||
|
champion reads.
|
||||||
|
|
||||||
|
## Phase 2 — the card
|
||||||
|
|
||||||
|
`cardRenderer.js`, SVG rather than canvas: it is text, so it diffs in review and
|
||||||
|
its numbers are **greppable** — which matters when the entire claim is that the
|
||||||
|
numbers are real. A card whose contents can't be inspected without opening an
|
||||||
|
image is a poor fit for a Truth-Law product.
|
||||||
|
|
||||||
|
Brand: VYND white + R green `#00D4A0`, slashed-Y, scanline field, mono
|
||||||
|
throughout. The card never formats its own facts — every string arrives already
|
||||||
|
rendered and gate-checked, so caption and card cannot disagree. A test asserts
|
||||||
|
the pulled number appears in the emitted SVG.
|
||||||
|
|
||||||
|
## Phase 3 — posting-ready, and extending it
|
||||||
|
|
||||||
|
```
|
||||||
|
SUPABASE_URL=... node scripts/generate-content.js
|
||||||
|
-> .content-out/2026-08-07/hot_hitters.txt + .svg
|
||||||
|
-> .content-out/2026-08-07/honesty_flex.txt + .svg
|
||||||
|
-> .content-out/2026-08-07/streak_list.txt + .svg
|
||||||
|
```
|
||||||
|
|
||||||
|
Kev posts; the engine generates.
|
||||||
|
|
||||||
|
### Adding template N+1 — registry entry only, no engine change
|
||||||
|
|
||||||
|
```js
|
||||||
|
registerTemplate({
|
||||||
|
id, sport, requires: ['dotted.paths'],
|
||||||
|
pull: async (deps) => facts, // the ONLY place data enters
|
||||||
|
copy: () => 'text with {tokens}',
|
||||||
|
card: () => ({ title, subtitle, lines }),
|
||||||
|
absent: (gaps, facts) => ({ copy, card }) // or { skip: 'reason' }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Queued (stubs, not built):** hot takes · daily honest reads · *"grades we
|
||||||
|
DIDN'T give"* · cross-sport streak variants (the streak template is already
|
||||||
|
sport-agnostic — it takes settled outcomes and a noun, so NFL TD streaks or NBA
|
||||||
|
made-three streaks need only that sport's settled data).
|
||||||
|
|
||||||
|
## Isolation
|
||||||
|
|
||||||
|
Read-only throughout. `p_win`, the model and the serving path are untouched;
|
||||||
|
the eligible-date clock is unaffected. **0 eligible dates today, unchanged.**
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cardRenderer — one card engine, per-template layouts.
|
||||||
|
*
|
||||||
|
* SVG rather than canvas: it is text, so it has no native dependency, it
|
||||||
|
* diffs in review, and the numbers inside it are greppable — which matters when
|
||||||
|
* the whole claim is that the numbers are real. A card whose contents cannot be
|
||||||
|
* inspected without opening an image is a bad fit for a Truth-Law product.
|
||||||
|
*
|
||||||
|
* The card never formats its own facts. Every string arrives already rendered
|
||||||
|
* and already gate-checked by contentEngine, so a caption and a card physically
|
||||||
|
* cannot disagree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BRAND = Object.freeze({
|
||||||
|
bg: '#05070A',
|
||||||
|
panel: '#0A0E14',
|
||||||
|
line: '#1A222E',
|
||||||
|
green: '#00D4A0', // the R
|
||||||
|
white: '#FFFFFF', // VYND
|
||||||
|
dim: '#6B7A8D',
|
||||||
|
amber: '#FFB347',
|
||||||
|
mono: "ui-monospace, 'JetBrains Mono', 'SFMono-Regular', Menlo, monospace",
|
||||||
|
});
|
||||||
|
|
||||||
|
const W = 1080;
|
||||||
|
const H = 1350;
|
||||||
|
|
||||||
|
const esc = (s) => String(s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
|
||||||
|
/** The slashed-Y wordmark: VYND white, R green. */
|
||||||
|
function wordmark(x, y, size = 34) {
|
||||||
|
return `
|
||||||
|
<g font-family="${BRAND.mono}" font-size="${size}" font-weight="800" letter-spacing="${size * 0.09}">
|
||||||
|
<text x="${x}" y="${y}" fill="${BRAND.white}">VYND</text>
|
||||||
|
<text x="${x + size * 2.92}" y="${y}" fill="${BRAND.green}">R</text>
|
||||||
|
<line x1="${x + size * 1.02}" y1="${y - size * 0.78}" x2="${x + size * 1.42}" y2="${y + size * 0.22}"
|
||||||
|
stroke="${BRAND.green}" stroke-width="${Math.max(2, size * 0.07)}" opacity=".85"/>
|
||||||
|
</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a card model to SVG.
|
||||||
|
* @param {object} card { kind, title, subtitle, lines[], footer }
|
||||||
|
*/
|
||||||
|
function toSvg(card = {}) {
|
||||||
|
const lines = card.lines || [];
|
||||||
|
let y = 300;
|
||||||
|
const body = lines.map((l) => {
|
||||||
|
const text = typeof l === 'string' ? l : l.text;
|
||||||
|
const style = (typeof l === 'object' && l.style) || 'body';
|
||||||
|
let out = '';
|
||||||
|
if (style === 'rule') {
|
||||||
|
out = `<line x1="72" y1="${y - 18}" x2="${W - 72}" y2="${y - 18}" stroke="${BRAND.line}" stroke-width="1"/>`;
|
||||||
|
y += 26;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const size = style === 'lead' ? 46 : style === 'stat' ? 40 : 30;
|
||||||
|
const fill = style === 'stat' ? BRAND.green : style === 'dim' ? BRAND.dim : BRAND.white;
|
||||||
|
const weight = style === 'body' ? 500 : 800;
|
||||||
|
out = `<text x="72" y="${y}" font-family="${BRAND.mono}" font-size="${size}" font-weight="${weight}" fill="${fill}">${esc(text)}</text>`;
|
||||||
|
y += size + (style === 'lead' ? 26 : 18);
|
||||||
|
return out;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
||||||
|
<rect width="${W}" height="${H}" fill="${BRAND.bg}"/>
|
||||||
|
<rect x="40" y="40" width="${W - 80}" height="${H - 80}" fill="${BRAND.panel}" stroke="${BRAND.line}"/>
|
||||||
|
${Array.from({ length: 26 }, (_, i) => `<line x1="40" y1="${52 * i + 40}" x2="${W - 40}" y2="${52 * i + 40}" stroke="${BRAND.white}" stroke-width="1" opacity=".02"/>`).join('')}
|
||||||
|
${wordmark(72, 128)}
|
||||||
|
${card.title ? `<text x="72" y="212" font-family="${BRAND.mono}" font-size="54" font-weight="800" fill="${BRAND.white}" letter-spacing="1">${esc(card.title)}</text>` : ''}
|
||||||
|
${card.subtitle ? `<text x="72" y="256" font-family="${BRAND.mono}" font-size="26" font-weight="600" fill="${BRAND.dim}" letter-spacing="2">${esc(card.subtitle)}</text>` : ''}
|
||||||
|
<line x1="72" y1="272" x2="${W - 72}" y2="272" stroke="${BRAND.green}" stroke-width="2" opacity=".55"/>
|
||||||
|
${body}
|
||||||
|
<text x="72" y="${H - 96}" font-family="${BRAND.mono}" font-size="22" font-weight="600" fill="${BRAND.dim}">${esc(card.footer || 'Every number here is measured. Nothing is projected.')}</text>
|
||||||
|
<text x="72" y="${H - 62}" font-family="${BRAND.mono}" font-size="20" font-weight="500" fill="${BRAND.line}">vyndr · built by Kevon Butler · Detroit</text>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { toSvg, BRAND, W, H };
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* contentEngine — posts that structurally cannot lie.
|
||||||
|
*
|
||||||
|
* The top-of-funnel is a content flywheel, and the thing that makes it VYNDR's
|
||||||
|
* rather than anyone's is that the numbers in it are real. That is easy to
|
||||||
|
* promise and hard to keep, because the failure mode is not a person choosing to
|
||||||
|
* fabricate — it is a template with a hardcoded adjective, or a field that came
|
||||||
|
* back null and rendered as "0", or a caption drifting from the card beside it.
|
||||||
|
*
|
||||||
|
* So the honesty is enforced by construction rather than by care:
|
||||||
|
*
|
||||||
|
* 1. COPY IS TOKEN-SUBSTITUTED. Every factual claim in a template is a
|
||||||
|
* `{token}` resolved against pulled facts. A token with no backing fact
|
||||||
|
* REFUSES to render — it cannot fall back to a plausible default, because
|
||||||
|
* there is no code path that produces one.
|
||||||
|
* 2. THE FACT CONTRACT IS ASSERTED FIRST. A template declares the fields it
|
||||||
|
* needs; the engine checks them before any string is built. A missing field
|
||||||
|
* means SKIP or an honest-absence variant, never invention.
|
||||||
|
* 3. CARD AND COPY SHARE ONE FACT OBJECT. They cannot diverge, because there
|
||||||
|
* is only one set of numbers and both read it.
|
||||||
|
*
|
||||||
|
* NO LIVE MODEL WRITES FACTUAL CLAIMS. The voice lives in the template; the
|
||||||
|
* facts are pulled. A voice-polish port is reserved for later and is not wired
|
||||||
|
* here — an LLM that can rewrite a sentence can rewrite a number.
|
||||||
|
*
|
||||||
|
* READ-ONLY. This engine touches no serving or forecast table. It has zero
|
||||||
|
* effect on the repaired-champion accrual clock.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { knownNumber } = require('../../utils/known');
|
||||||
|
|
||||||
|
/** Resolve `a.b.c` against an object; undefined when any hop is missing. */
|
||||||
|
function pathValue(obj, path) {
|
||||||
|
return String(path).split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A value that may stand as a fact.
|
||||||
|
*
|
||||||
|
* `null` and `undefined` are absent. Empty string is absent — it renders as a
|
||||||
|
* hole in a sentence. Zero and false are PRESENT: "0 props cleared B+" is a real
|
||||||
|
* and important claim, and treating 0 as missing is the `Number(null) === 0`
|
||||||
|
* breach wearing its opposite coat.
|
||||||
|
*/
|
||||||
|
function isPresent(v) {
|
||||||
|
if (v === null || v === undefined) return false;
|
||||||
|
if (typeof v === 'string' && v.trim() === '') return false;
|
||||||
|
if (Array.isArray(v) && v.length === 0) return false;
|
||||||
|
if (typeof v === 'number' && !Number.isFinite(v)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN = /\{([a-zA-Z0-9_.]+)\}/g;
|
||||||
|
|
||||||
|
/** Every `{token}` in a string. */
|
||||||
|
function tokensIn(text) {
|
||||||
|
const out = [];
|
||||||
|
let m;
|
||||||
|
const re = new RegExp(TOKEN);
|
||||||
|
while ((m = re.exec(String(text))) !== null) out.push(m[1]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Substitute tokens, refusing on any that is unbacked.
|
||||||
|
* @throws when a token has no present fact — the whole point.
|
||||||
|
*/
|
||||||
|
function render(text, facts) {
|
||||||
|
const missing = tokensIn(text).filter((t) => !isPresent(pathValue(facts, t)));
|
||||||
|
if (missing.length) {
|
||||||
|
throw new Error(`TRUTH LAW: unbacked token(s) ${missing.join(', ')} — refusing to render`);
|
||||||
|
}
|
||||||
|
return String(text).replace(TOKEN, (_, t) => String(pathValue(facts, t)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which contract fields are absent from a pulled fact set. */
|
||||||
|
function contractGaps(template, facts) {
|
||||||
|
return (template.requires || []).filter((f) => !isPresent(pathValue(facts, f)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a template.
|
||||||
|
*
|
||||||
|
* @param {object} t
|
||||||
|
* id stable key
|
||||||
|
* sport which sport it speaks for
|
||||||
|
* requires fact-contract: dotted paths that MUST be present
|
||||||
|
* pull async (deps) => facts — the ONLY place data enters
|
||||||
|
* copy (facts) => string with {tokens}
|
||||||
|
* card (facts) => card model (layout + token strings)
|
||||||
|
* absent optional (gaps) => honest "nothing tonight" post
|
||||||
|
*/
|
||||||
|
function registerTemplate(t) {
|
||||||
|
if (!t || !t.id) throw new Error('a template needs an id');
|
||||||
|
for (const k of ['requires', 'pull', 'copy', 'card']) {
|
||||||
|
if (!t[k]) throw new Error(`template ${t.id} is missing ${k}`);
|
||||||
|
}
|
||||||
|
registry.set(t.id, t);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTemplates = () => [...registry.values()];
|
||||||
|
const getTemplate = (id) => registry.get(id) || null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate one post.
|
||||||
|
*
|
||||||
|
* @returns {object} { ok, id, copy, card, facts, skipped, reason }
|
||||||
|
* Never throws on absent data — absence is an outcome, not an error.
|
||||||
|
*/
|
||||||
|
async function generate(id, deps = {}) {
|
||||||
|
const t = registry.get(id);
|
||||||
|
if (!t) return { ok: false, id, skipped: true, reason: 'no such template' };
|
||||||
|
|
||||||
|
let facts;
|
||||||
|
try {
|
||||||
|
facts = await t.pull(deps);
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, id, skipped: true, reason: `pull failed: ${e.message}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── THE CONTRACT, BEFORE ANY STRING IS BUILT ──
|
||||||
|
const gaps = contractGaps(t, facts || {});
|
||||||
|
if (gaps.length) {
|
||||||
|
if (typeof t.absent === 'function') {
|
||||||
|
const alt = t.absent(gaps, facts || {}) || {};
|
||||||
|
// An absence variant may DECLINE to speak. A template that cannot tell
|
||||||
|
// "nothing happened" from "nothing was fetched" must not publish the
|
||||||
|
// first sentence when the second is true -- honest-absence copy is a
|
||||||
|
// perfect hiding place for a broken pull.
|
||||||
|
if (alt.skip) return { ok: false, id, skipped: true, reason: alt.skip, gaps };
|
||||||
|
return { ok: true, id, honest_absence: true, copy: alt.copy, card: alt.card, facts: facts || {}, gaps };
|
||||||
|
}
|
||||||
|
return { ok: false, id, skipped: true, reason: `fact-contract gap: ${gaps.join(', ')}`, gaps };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const copyRaw = t.copy(facts);
|
||||||
|
const copy = render(copyRaw, facts);
|
||||||
|
const cardModel = t.card(facts);
|
||||||
|
// The card's own strings pass the same gate -- a caption and a card cannot
|
||||||
|
// disagree if both are rendered from one fact object under one rule.
|
||||||
|
const card = {
|
||||||
|
...cardModel,
|
||||||
|
lines: (cardModel.lines || []).map((l) => (typeof l === 'string'
|
||||||
|
? render(l, facts)
|
||||||
|
: { ...l, text: render(l.text, facts) })),
|
||||||
|
title: cardModel.title ? render(cardModel.title, facts) : null,
|
||||||
|
subtitle: cardModel.subtitle ? render(cardModel.subtitle, facts) : null,
|
||||||
|
};
|
||||||
|
return { ok: true, id, copy, card, facts };
|
||||||
|
} catch (e) {
|
||||||
|
// A refusal is a skip, never a degraded post.
|
||||||
|
return { ok: false, id, skipped: true, reason: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate every registered template; skips are reported, never hidden. */
|
||||||
|
async function generateAll(deps = {}) {
|
||||||
|
const out = [];
|
||||||
|
for (const t of listTemplates()) out.push(await generate(t.id, deps));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
registerTemplate, listTemplates, getTemplate, generate, generateAll,
|
||||||
|
render, tokensIn, contractGaps, isPresent, pathValue,
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TEMPLATE 2 — THE HONESTY FLEX.
|
||||||
|
*
|
||||||
|
* The differentiator, and the one that would be worthless if it were padded.
|
||||||
|
* It publishes the REAL grade distribution: how many props were graded, how few
|
||||||
|
* cleared the ceiling, and the fact that A is unissuable because no band of this
|
||||||
|
* model has ever earned one.
|
||||||
|
*
|
||||||
|
* Every competitor's card is all A's. Ours says most of tonight is a base-rate
|
||||||
|
* read — and that claim is only impressive if it is exactly true, so the numbers
|
||||||
|
* come from servedGrade's own bands rather than from a marketing sentence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { knownNumber } = require('../../../utils/known');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: 'honesty_flex',
|
||||||
|
sport: 'mlb',
|
||||||
|
label: 'The Honesty Flex',
|
||||||
|
requires: ['graded', 'ceiling_letter', 'ceiling_realized', 'base_rate', 'date'],
|
||||||
|
|
||||||
|
async pull(deps) {
|
||||||
|
const g = await deps.gradeDistribution(); // injected, read-only
|
||||||
|
const sg = deps.servedGrade || require('../../model/servedGrade');
|
||||||
|
const total = knownNumber(g && g.total);
|
||||||
|
const top = (g && g.by_letter && (g.by_letter['B+'] || 0)) || 0;
|
||||||
|
const flat = (g && g.not_separable) || 0;
|
||||||
|
const ceiling = sg.BANDS[0];
|
||||||
|
return {
|
||||||
|
date: deps.date,
|
||||||
|
graded: total,
|
||||||
|
top_count: top,
|
||||||
|
top_pct: total ? Math.round((top / total) * 100) : null,
|
||||||
|
flat_count: flat,
|
||||||
|
flat_pct: total ? Math.round((flat / total) * 100) : null,
|
||||||
|
ceiling_letter: ceiling.letter,
|
||||||
|
ceiling_realized: Math.round(ceiling.realized * 100),
|
||||||
|
base_rate: Math.round(sg.BASE_RATE * 100),
|
||||||
|
unissuable: sg.UNISSUABLE.join(', '),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
copy: () => `WE GRADED {graded} PROPS TONIGHT. {top_count} CLEARED {ceiling_letter}.
|
||||||
|
|
||||||
|
That's {top_pct}%. The other {flat_pct}% we can't separate from the baseline, and we say so on the card instead of calling them leans.
|
||||||
|
|
||||||
|
Our ceiling is {ceiling_letter} — those reads land about {ceiling_realized}% against a {base_rate}% baseline. We do not issue {unissuable}. No band of this model has ever hit at a rate that would justify one.
|
||||||
|
|
||||||
|
Everybody else's card is all A's. Ask them what their A actually hits.`,
|
||||||
|
|
||||||
|
card: () => ({
|
||||||
|
kind: 'flex',
|
||||||
|
title: 'TONIGHT, HONESTLY',
|
||||||
|
subtitle: '{date}',
|
||||||
|
lines: [
|
||||||
|
{ text: '{graded} graded', style: 'lead' },
|
||||||
|
{ text: '{top_count} cleared {ceiling_letter} — {top_pct}%', style: 'stat' },
|
||||||
|
{ text: '{flat_pct}% we cannot separate from baseline', style: 'dim' },
|
||||||
|
{ text: '', style: 'rule' },
|
||||||
|
{ text: 'Ceiling: {ceiling_letter} · lands {ceiling_realized}%', style: 'body' },
|
||||||
|
{ text: 'Baseline: {base_rate}%', style: 'body' },
|
||||||
|
{ text: 'We do not issue {unissuable}.', style: 'body' },
|
||||||
|
],
|
||||||
|
footer: 'Grades are earned from realized outcomes, not issued on confidence.',
|
||||||
|
}),
|
||||||
|
|
||||||
|
absent: () => ({
|
||||||
|
copy: 'No slate graded tonight. Nothing to show, so nothing shown.',
|
||||||
|
card: { kind: 'absence', title: 'NO SLATE TONIGHT', subtitle: 'NOTHING TO PAD', lines: [{ text: 'No props graded. An empty board is an honest board.', style: 'body' }], footer: 'We post the count even when the count is zero.' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TEMPLATE 1 — HOT HITTERS.
|
||||||
|
*
|
||||||
|
* Reads the REPAIRED full-season log, not a ten-game slice. "Hot" here means a
|
||||||
|
* recent rate measured against that hitter's own season rate — which is only a
|
||||||
|
* meaningful comparison now that the season rate is a season.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { knownNumber } = require('../../../utils/known');
|
||||||
|
|
||||||
|
/** A hitter must have this much history before we call him anything. */
|
||||||
|
const MIN_SEASON_GAMES = 20;
|
||||||
|
const RECENT = 10;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: 'hot_hitters',
|
||||||
|
sport: 'mlb',
|
||||||
|
label: 'Hot Hitters',
|
||||||
|
requires: ['count', 'hitters', 'window', 'date'],
|
||||||
|
|
||||||
|
async pull(deps) {
|
||||||
|
const rows = await deps.hitterForm(); // injected, read-only
|
||||||
|
// ── EMPTY POOL IS NOT AN HONEST ABSENCE ────────────────────────────────
|
||||||
|
// The first run of this template emitted "no hitter is meaningfully hot"
|
||||||
|
// while the real cause was a source holding fewer than 20 games for EVERY
|
||||||
|
// player. That reads as a considered editorial judgement and is actually a
|
||||||
|
// broken pull -- the exact failure class that has bitten this codebase four
|
||||||
|
// times tonight, here wearing the costume of our own honesty copy.
|
||||||
|
//
|
||||||
|
// So the two are separated: no candidates at all is a SKIP with a reason;
|
||||||
|
// candidates present but none hot is the honest absence.
|
||||||
|
const candidates = (rows || []).length;
|
||||||
|
const usable = (rows || []).filter((r) =>
|
||||||
|
knownNumber(r.season_games) !== null && r.season_games >= MIN_SEASON_GAMES
|
||||||
|
&& knownNumber(r.recent_rate) !== null && knownNumber(r.season_rate) !== null);
|
||||||
|
const hot = usable
|
||||||
|
.map((r) => ({ ...r, lift: r.recent_rate - r.season_rate }))
|
||||||
|
.filter((r) => r.lift > 0)
|
||||||
|
.sort((a, b) => b.lift - a.lift)
|
||||||
|
.slice(0, 5);
|
||||||
|
return {
|
||||||
|
date: deps.date,
|
||||||
|
window: RECENT,
|
||||||
|
candidates,
|
||||||
|
qualified: usable.length,
|
||||||
|
count: hot.length || null, // zero hot hitters is an ABSENT list, not "0 hot hitters"
|
||||||
|
hitters: hot.length ? hot : null,
|
||||||
|
list: hot.map((h, i) =>
|
||||||
|
`${i + 1}. ${h.name} — ${Math.round(h.recent_rate * 100)}% last ${RECENT}, ${Math.round(h.season_rate * 100)}% season`).join('\n'),
|
||||||
|
top_name: hot[0] ? hot[0].name : null,
|
||||||
|
top_recent: hot[0] ? Math.round(hot[0].recent_rate * 100) : null,
|
||||||
|
top_season: hot[0] ? Math.round(hot[0].season_rate * 100) : null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
copy: () => `WHO'S ACTUALLY HOT — {date}
|
||||||
|
|
||||||
|
{top_name} is hitting {top_recent}% over his last {window}. His season number is {top_season}%.
|
||||||
|
That gap is the whole point. Everybody else is guessing at it.
|
||||||
|
|
||||||
|
{list}
|
||||||
|
|
||||||
|
Measured off full season logs, not a ten-game window that flatters whoever ran hot last week.`,
|
||||||
|
|
||||||
|
card: () => ({
|
||||||
|
kind: 'list',
|
||||||
|
title: "WHO'S ACTUALLY HOT",
|
||||||
|
subtitle: 'LAST {window} vs SEASON · {date}',
|
||||||
|
lines: [
|
||||||
|
{ text: '{top_name}', style: 'lead' },
|
||||||
|
{ text: '{top_recent}% last {window} · {top_season}% season', style: 'stat' },
|
||||||
|
{ text: '', style: 'rule' },
|
||||||
|
{ text: '{list}', style: 'body' },
|
||||||
|
],
|
||||||
|
footer: 'Rates measured from full season game logs.',
|
||||||
|
}),
|
||||||
|
|
||||||
|
absent: (gaps, facts) => {
|
||||||
|
// Only speak if there was actually a pool to judge.
|
||||||
|
if (!facts || !facts.qualified) {
|
||||||
|
return {
|
||||||
|
copy: null,
|
||||||
|
card: null,
|
||||||
|
skip: `no qualified hitters in the pool (candidates=${(facts && facts.candidates) || 0}, qualified=${(facts && facts.qualified) || 0}) — source problem, not a quiet night`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
copy: "No hitter is meaningfully hot tonight.\n\nWe could dress up a middling week as a streak. We don't.",
|
||||||
|
card: { kind: 'absence', title: 'NOTHING HOT TONIGHT', subtitle: 'AND WE WILL SAY SO', lines: [{ text: 'No hitter cleared his own season rate by enough to name.', style: 'body' }], footer: 'An empty list is a real answer.' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TEMPLATE 3 — STREAK LIST.
|
||||||
|
*
|
||||||
|
* A streak is only a streak if every game in it settled. This reads the ledger's
|
||||||
|
* SETTLED outcomes, so a run cannot be extended by a game that is still pending
|
||||||
|
* or was voided for a DNP — the two ways a streak list quietly inflates.
|
||||||
|
*
|
||||||
|
* The structure is sport-agnostic: it takes settled outcome rows and a noun.
|
||||||
|
* MLB hit streaks today; the same shape carries TD streaks or made-three streaks
|
||||||
|
* the moment those sports have settled outcomes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MIN_STREAK = 3;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: 'streak_list',
|
||||||
|
sport: 'mlb',
|
||||||
|
label: 'Active Streaks',
|
||||||
|
requires: ['streaks', 'count', 'noun', 'date'],
|
||||||
|
|
||||||
|
async pull(deps) {
|
||||||
|
const rows = await deps.settledStreaks(); // injected, read-only
|
||||||
|
const live = (rows || [])
|
||||||
|
.filter((r) => Number(r.streak) >= MIN_STREAK && r.verified_from_settled === true)
|
||||||
|
.sort((a, b) => b.streak - a.streak)
|
||||||
|
.slice(0, 6);
|
||||||
|
return {
|
||||||
|
date: deps.date,
|
||||||
|
noun: deps.noun || 'game hit streak',
|
||||||
|
count: live.length || null,
|
||||||
|
streaks: live.length ? live : null,
|
||||||
|
list: live.map((s) => `${s.name} — ${s.streak} straight`).join('\n'),
|
||||||
|
top_name: live[0] ? live[0].name : null,
|
||||||
|
top_streak: live[0] ? live[0].streak : null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
copy: () => `ACTIVE STREAKS — {date}
|
||||||
|
|
||||||
|
{top_name} has a {top_streak}-{noun}. Live, verified off settled results only.
|
||||||
|
|
||||||
|
{list}
|
||||||
|
|
||||||
|
Every game in these ran to a final. We don't count a pending night to make a number look better.`,
|
||||||
|
|
||||||
|
card: () => ({
|
||||||
|
kind: 'list',
|
||||||
|
title: 'ACTIVE STREAKS',
|
||||||
|
subtitle: 'VERIFIED FROM SETTLED RESULTS · {date}',
|
||||||
|
lines: [
|
||||||
|
{ text: '{top_name}', style: 'lead' },
|
||||||
|
{ text: '{top_streak} straight', style: 'stat' },
|
||||||
|
{ text: '', style: 'rule' },
|
||||||
|
{ text: '{list}', style: 'body' },
|
||||||
|
],
|
||||||
|
footer: 'Settled games only. Pending and voided nights do not count.',
|
||||||
|
}),
|
||||||
|
|
||||||
|
absent: () => ({
|
||||||
|
copy: 'No live streaks worth naming tonight.\n\nWe could lower the bar to three-of-four. We keep the bar.',
|
||||||
|
card: { kind: 'absence', title: 'NO LIVE STREAKS', subtitle: 'THE BAR STAYS WHERE IT IS', lines: [{ text: 'Nothing running long enough to name.', style: 'body' }], footer: 'We do not lower a threshold to fill a card.' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE TRUTH-LAW PROOF.
|
||||||
|
*
|
||||||
|
* The claim this engine makes is that a post cannot contain a number that was
|
||||||
|
* not pulled. These tests are that claim, made falsifiable. If any of them can
|
||||||
|
* be made to pass while a fabricated string escapes, the moat is decorative.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const engine = require('../../src/services/content/contentEngine');
|
||||||
|
const { toSvg } = require('../../src/services/content/cardRenderer');
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
id: 'test_tpl', sport: 'mlb', requires: ['n'],
|
||||||
|
pull: async () => ({ n: 3, name: 'Real Player' }),
|
||||||
|
copy: () => 'we graded {n} props',
|
||||||
|
card: () => ({ title: 'T', lines: [{ text: '{n} props', style: 'stat' }] }),
|
||||||
|
};
|
||||||
|
const reg = (over = {}) => engine.registerTemplate({ ...base, ...over, id: over.id || `t_${Math.random()}` });
|
||||||
|
|
||||||
|
describe('a template CANNOT render an unbacked claim', () => {
|
||||||
|
it('refuses a token with no pulled fact', async () => {
|
||||||
|
// The failure this prevents: a template author writes {edge} and the engine
|
||||||
|
// helpfully renders "undefined" or, worse, an empty string that reads fine.
|
||||||
|
const t = reg({ copy: () => 'edge is {edge_that_was_never_pulled}%' });
|
||||||
|
const out = await engine.generate(t.id, {});
|
||||||
|
expect(out.ok).toBe(false);
|
||||||
|
expect(out.skipped).toBe(true);
|
||||||
|
expect(out.reason).toMatch(/TRUTH LAW: unbacked token/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an unbacked token on the CARD too, not just the copy', async () => {
|
||||||
|
const t = reg({ card: () => ({ title: 'T', lines: [{ text: '{ghost_stat}', style: 'stat' }] }) });
|
||||||
|
const out = await engine.generate(t.id, {});
|
||||||
|
expect(out.ok).toBe(false);
|
||||||
|
expect(out.reason).toMatch(/unbacked token/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('render() throws directly — the gate is not bypassable by a caller', () => {
|
||||||
|
expect(() => engine.render('{nope}', { yes: 1 })).toThrow(/TRUTH LAW/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a null fact is ABSENT, not rendered as "null"', async () => {
|
||||||
|
const t = reg({ pull: async () => ({ n: null }), copy: () => '{n} props' });
|
||||||
|
const out = await engine.generate(t.id, {});
|
||||||
|
expect(out.ok).toBe(false);
|
||||||
|
expect(String(out.copy || '')).not.toMatch(/null/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an empty string is absent — a hole in a sentence is a lie by omission', () => {
|
||||||
|
expect(engine.isPresent('')).toBe(false);
|
||||||
|
expect(engine.isPresent(' ')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ZERO is PRESENT — "0 cleared B+" is a real and important claim', () => {
|
||||||
|
// Treating 0 as missing is the Number(null) === 0 breach wearing its
|
||||||
|
// opposite coat, and it would silently delete our most honest post.
|
||||||
|
expect(engine.isPresent(0)).toBe(true);
|
||||||
|
expect(engine.render('{n} cleared', { n: 0 })).toBe('0 cleared');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NaN and Infinity are absent — they are arithmetic failures, not facts', () => {
|
||||||
|
expect(engine.isPresent(NaN)).toBe(false);
|
||||||
|
expect(engine.isPresent(Infinity)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the fact contract is checked BEFORE any string is built', () => {
|
||||||
|
it('a contract gap skips with the missing field named', async () => {
|
||||||
|
const t = reg({ requires: ['n', 'missing_field'] });
|
||||||
|
const out = await engine.generate(t.id, {});
|
||||||
|
expect(out.ok).toBe(false);
|
||||||
|
expect(out.reason).toMatch(/fact-contract gap: missing_field/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a gap can emit an HONEST ABSENCE instead of nothing', async () => {
|
||||||
|
const t = reg({
|
||||||
|
requires: ['n', 'absent_thing'],
|
||||||
|
absent: () => ({ copy: 'nothing tonight, and we say so', card: { title: 'NONE' } }),
|
||||||
|
});
|
||||||
|
const out = await engine.generate(t.id, {});
|
||||||
|
expect(out.ok).toBe(true);
|
||||||
|
expect(out.honest_absence).toBe(true);
|
||||||
|
expect(out.copy).toMatch(/nothing tonight/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a failing pull skips rather than rendering a half-post', async () => {
|
||||||
|
const t = reg({ pull: async () => { throw new Error('source down'); } });
|
||||||
|
const out = await engine.generate(t.id, {});
|
||||||
|
expect(out.ok).toBe(false);
|
||||||
|
expect(out.reason).toMatch(/pull failed: source down/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('copy and card cannot disagree', () => {
|
||||||
|
it('both render from ONE fact object', async () => {
|
||||||
|
const t = reg({
|
||||||
|
pull: async () => ({ n: 7 }),
|
||||||
|
copy: () => 'we graded {n}',
|
||||||
|
card: () => ({ title: 'T', lines: [{ text: '{n} graded', style: 'stat' }] }),
|
||||||
|
});
|
||||||
|
const out = await engine.generate(t.id, {});
|
||||||
|
expect(out.copy).toMatch(/7/);
|
||||||
|
expect(out.card.lines[0].text).toMatch(/7/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the card SVG contains the same pulled number', async () => {
|
||||||
|
const t = reg({ pull: async () => ({ n: 42 }), copy: () => '{n}', card: () => ({ title: 'T', lines: [{ text: '{n} graded', style: 'stat' }] }) });
|
||||||
|
const out = await engine.generate(t.id, {});
|
||||||
|
expect(toSvg(out.card)).toMatch(/42 graded/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the real templates obey the contract', () => {
|
||||||
|
const hot = require('../../src/services/content/templates/hotHitters');
|
||||||
|
const flex = require('../../src/services/content/templates/honestyFlex');
|
||||||
|
const streak = require('../../src/services/content/templates/streakList');
|
||||||
|
|
||||||
|
it.each([[hot], [flex], [streak]])('every token in %s is declarable', (t) => {
|
||||||
|
const tokens = new Set([
|
||||||
|
...engine.tokensIn(t.copy({})),
|
||||||
|
...engine.tokensIn(JSON.stringify(t.card({}))),
|
||||||
|
]);
|
||||||
|
expect(tokens.size).toBeGreaterThan(0);
|
||||||
|
// Each template must ship an absent-variant, or a thin night silently
|
||||||
|
// produces nothing and the flywheel stops without anyone noticing.
|
||||||
|
expect(typeof t.absent).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hot hitters refuses a hitter with too little history', async () => {
|
||||||
|
engine.registerTemplate(hot);
|
||||||
|
const out = await engine.generate('hot_hitters', {
|
||||||
|
date: '2026-08-07',
|
||||||
|
hitterForm: async () => ([{ name: 'Rookie', season_games: 4, recent_rate: 0.9, season_rate: 0.2 }]),
|
||||||
|
});
|
||||||
|
// 4 games is not a season, so nobody QUALIFIES -- which is a source problem,
|
||||||
|
// not a quiet night. It must SKIP rather than publish honest-absence copy.
|
||||||
|
expect(out.ok).toBe(false);
|
||||||
|
expect(out.reason).toMatch(/source problem, not a quiet night/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hot hitters DOES emit honest absence when a real pool has nobody hot', async () => {
|
||||||
|
// The distinction that matters: candidates existed, were judged, none hot.
|
||||||
|
engine.registerTemplate(hot);
|
||||||
|
const out = await engine.generate('hot_hitters', {
|
||||||
|
date: '2026-08-07',
|
||||||
|
hitterForm: async () => Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
name: `P${i}`, season_games: 90, recent_rate: 0.30, season_rate: 0.40,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
expect(out.honest_absence).toBe(true);
|
||||||
|
expect(out.copy).toMatch(/No hitter is meaningfully hot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streaks refuse a run not verified from settled results', async () => {
|
||||||
|
engine.registerTemplate(streak);
|
||||||
|
const out = await engine.generate('streak_list', {
|
||||||
|
date: '2026-08-07',
|
||||||
|
settledStreaks: async () => ([{ name: 'X', streak: 9, verified_from_settled: false }]),
|
||||||
|
});
|
||||||
|
expect(out.honest_absence).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user