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:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user