What this settles, and why it needed a faithful harness

This PR adds one file — a model-behavior eval — and changes no game code. It settles an open question from issue #182: the momentum meter is supposed to reward telling a connected story, and it climbs only when the Judge grades a turn's continuity as builds (a resets shatters it). The continuity definition rewards a turn that "meets a condition the figure revealed, exploits a change already recorded, or escalates the run's strategy." The last two clauses are not figure-specific — so a forward chain across different figures, each exploiting a landed change, should build momentum just like a same-figure thread. Does it actually?

An earlier playtest suggested no (momentum stuck at 0). But that run was unfaithful: it fed the Judge a global cross-figure history, where the real game sends a per-figure one. On a figure switch the Judge saw the previous, different figure's reply mislabeled as the current one's — corrupting the very grade under test. This eval rebuilds the test faithfully to settle it for real.

The continuity clauses that should work cross-figure, and the two inputs the real server derives.

evals/momentum-continuity.eval.ts · 366 lines
evals/momentum-continuity.eval.ts366 lines · TypeScript
⋯ 4 lines hidden (lines 1–4)
1/**
2 * Momentum × continuity — does the meter build on COHERENT chains, especially
3 * across DIFFERENT figures? (issue #182)
4 *
5 * Momentum (utils/momentum.ts) is gated on the Judge's `continuity` grade: a
6 * 'builds' climbs the meter, a 'resets' shatters it. The continuity DEFINITION
7 * (prompt-builder.ts buildJudgePrompt) says a dispatch "builds" if it "meets a
8 * condition the figure revealed, EXPLOITS A CHANGE ALREADY RECORDED, or ESCALATES
9 * the run's strategy" — the last two clauses are NOT figure-specific. So a forward
10 * chain across different figures, each exploiting a landed change, should build
11 * momentum just as a same-figure thread does.
12 *
13 * The open question (and a prior playtest's unconfirmed claim): does it? The
14 * earlier harness was UNFAITHFUL — it fed the Judge a GLOBAL cross-figure history,
15 * so on a figure-switch turn the Judge saw the previous, different figure's reply
16 * mislabeled as the current one's. This eval is FAITHFUL to send-message.post.ts:
17 * - lastFigureReply = the last reply IN THIS FIGURE's thread (conversationWith) —
18 * EMPTY on a figure switch, exactly as the real game sends it;
19 * - ledgerDigest = the GLOBAL run ledger (last 5 headlines), as the real game.
⋯ 347 lines hidden (lines 20–366)
20 *
21 * Three scenarios, each turn logging continuity (modal + spread), the projected
22 * momentum (folded under a non-loss roll, to isolate the continuity lever from the
23 * dice), and the causal-chain tier/factor:
24 * A — same-figure thread, each turn meeting the condition the figure revealed.
25 * Expect builds; momentum 1→2→3.
26 * B — cross-figure forward chain, each turn EXPLOITING a change on the ledger and
27 * escalating toward the anchor. Per the definition: builds. A RESET here is
28 * the bug — the classifier ignoring the "exploits recorded change" clauses.
29 * C — disconnected control: vague, unrelated figures/eras that abandon the
30 * thread. Expect resets/neutral; momentum flat (momentum isn't always stuck).
31 *
32 * Subsystem cross-check (issue §4): a turn the causal-chain rates at-hinge/bridged
33 * (near a foothold, full power) but continuity rates 'resets' is FLAGGED — those
34 * are the disagreements to inspect (B = bug; C's vague-but-near-a-year = benign).
35 *
36 * Directional, no LLM grader needed — we read the Judge's structured `continuity`
37 * field straight. Anthropic-lane only (the Judge is Haiku).
38 * npx vitest run --config vitest.eval.config.ts evals/momentum-continuity.eval.ts
39 */
40import { describe, it, expect, afterAll } from 'vitest'
41import { callJudgeAI } from '~/server/utils/openai'
42import { MAX_MESSAGE_CHARS } from '~/utils/game-config'
43import { nextMomentum } from '~/utils/momentum'
44import { chainStatus, type ChainTier } from '~/utils/causal-chain'
45import { DiceOutcome } from '~/utils/dice'
46import type { Continuity } from '~/utils/continuity'
47import { record, printScorecard, sample } from './harness'
48 
49// Local gate: the Judge is an Anthropic (Haiku) call and we read its structured
50// `continuity` field directly, so the shared RUN gate's OpenAI key (for the dual
51// grader) isn't needed here — just the Anthropic key the Judge already requires.
52const RUN = !!process.env.ANTHROPIC_API_KEY && process.env.EVAL_DRY_RUN !== '1'
53const N = 4
54 
55const OBJECTIVE_ANCHOR = 1900 // "Humanity reaches the Moon by 1900"
56 
57interface LedgerEntry { era: string; headline: string; whenSigned: number }
58interface Turn {
59 figureName: string
60 figureYear: number
61 when: string
62 userMessage: string
63 /** The figure's reply AFTER this dispatch — becomes lastFigureReply for the
64 * NEXT same-figure turn (mirrors conversationWith(target)'s last AI text). */
65 figureReply: string
66 /** The change this turn lands on the global ledger (a foothold + a digest line). */
67 landed: LedgerEntry
69interface Scenario {
70 key: string
71 label: string
72 /** A ledger present from turn 1 (scenario C's fixed backdrop thread); per-turn
73 * landed entries accrete on top. Empty for A/B — they build their own. */
74 seedLedger?: LedgerEntry[]
75 turns: Turn[]
76 /** 1-indexed turns expected to pick up the thread ('builds'). */
77 buildTurns: number[]
79 
80// ── Scenario A — same-figure thread: Tsiolkovsky, each turn meeting the exact
81// condition his prior reply revealed (fuel → staging). lastFigureReply is
82// POPULATED throughout (same figure), the dominant continuity signal.
83const SCENARIO_A: Scenario = {
84 key: 'A',
85 label: 'same-figure thread (condition-meeting)',
86 buildTurns: [2, 3],
87 turns: [
88 {
89 figureName: 'Konstantin Tsiolkovsky', figureYear: 1885, when: '1885',
90 userMessage: "Set down the maths of a vessel that climbs by hurling its own mass — how much it must shed to break Earth's hold and reach the Moon.",
91 figureReply: "The equation I can write — but it founders on fuel. No powder yields such speed. Bring me a fuel fiercer than gunpowder and I will chart the climb.",
92 landed: { era: 'Russia, 1885', headline: 'Tsiolkovsky derives the equation for a mass-shedding vessel’s climb to orbit', whenSigned: 1885 }
93 },
94 {
95 figureName: 'Konstantin Tsiolkovsky', figureYear: 1887, when: '1887',
96 userMessage: 'The fiercer fuel you asked for is liquid hydrogen burned with liquid oxygen — far past powder. Put that exhaust speed into your equation and chart the climb.',
97 figureReply: 'With that fuel the climb computes — yet the vessel cannot lift its own tanks full. The weight defeats the rise unless it sheds them spent, in stages.',
98 landed: { era: 'Russia, 1887', headline: 'Tsiolkovsky proves a hydrogen-oxygen burn yields the velocity to leave Earth', whenSigned: 1887 }
99 },
100 {
101 figureName: 'Konstantin Tsiolkovsky', figureYear: 1889, when: '1889',
102 userMessage: 'Then stage it, as you said — stack three vessels, each falling away the instant it burns dry so the next climbs lighter. That staircase reaches the Moon.',
103 figureReply: 'Yes — the staged tower is the answer. I will publish it: the road to the Moon is drawn.',
104 landed: { era: 'Russia, 1889', headline: 'Tsiolkovsky publishes the staged rocket — a drawn road to the Moon', whenSigned: 1889 }
105 }
106 ]
108 
109// ── Scenario B — cross-figure forward chain: Newton → Bernoulli → Nobel, each a
110// DIFFERENT figure, each dispatch EXPLOITING the change the prior turn landed and
111// escalating toward the 1900 anchor. lastFigureReply is EMPTY on every turn (each
112// figure's own thread is empty on first contact) — the faithful figure-switch case.
113const SCENARIO_B: Scenario = {
114 key: 'B',
115 label: 'cross-figure chain (exploits the ledger)',
116 buildTurns: [2, 3],
117 turns: [
118 {
119 figureName: 'Isaac Newton', figureYear: 1687, when: '1687',
120 userMessage: 'Your third law implies a craft can ride its own thrown mass through the void. Publish it: a vessel reacts against what it expels, needing no air to push on.',
121 figureReply: 'A vessel moved by its own ejecta — the mathematics hold even in vacuum. I shall set it among the laws.',
122 landed: { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 }
123 },
124 {
125 figureName: 'Daniel Bernoulli', figureYear: 1738, when: '1738',
126 userMessage: "Newton's reaction vessel is on the record. Now sustain its burn: derive the thrust of hot gas forced through a narrowed throat, steady and metered.",
127 figureReply: 'Force the hot gas through a throat and the stream quickens — a controlled jet. I can derive its thrust from the pressure drop.',
128 landed: { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
129 },
130 {
131 figureName: 'Alfred Nobel', figureYear: 1867, when: '1867',
132 userMessage: "Bernoulli's metered jet is on the ledger but starved for power. Tame your nitroglycerin into a measured charge to drive a craft to escape speed.",
133 figureReply: 'My explosive, tamed into a measured burn rather than a blast, fed to that throat — it could drive a craft past the sky. A daring use.',
134 landed: { era: 'Sweden, 1867', headline: 'Nobel adapts stabilised nitroglycerin into a metered rocket propellant', whenSigned: 1867 }
135 }
136 ]
138 
139// ── Scenario B′ — the SAME cross-figure chain, phrased NATURALLY: each dispatch
140// builds on the SUBSTANCE of the prior landed change WITHOUT signposting it ("on
141// the record"/"on the ledger"). Removes the "you led the Judge" objection — does
142// it connect the message to the ledger DIGEST on its own? lastFigureReply empty.
143const SCENARIO_BNAT: Scenario = {
144 key: 'B′',
145 label: 'cross-figure chain (natural phrasing)',
146 buildTurns: [2, 3],
147 turns: [
148 {
149 figureName: 'Isaac Newton', figureYear: 1687, when: '1687',
150 userMessage: 'Your third law implies a craft can ride its own thrown mass through the void. Publish it: a vessel reacts against what it expels, needing no air to push on.',
151 figureReply: 'A vessel moved by its own ejecta — the mathematics hold even in vacuum. I shall set it among the laws.',
152 landed: { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 }
153 },
154 {
155 figureName: 'Daniel Bernoulli', figureYear: 1738, when: '1738',
156 userMessage: 'A craft driven by its own expelled gas needs a sustained burn, not one shove. Derive the thrust of hot gas forced through a narrowed throat.',
157 figureReply: 'Force the hot gas through a throat and the stream quickens — a controlled jet. I can derive its thrust from the pressure drop.',
158 landed: { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
159 },
160 {
161 figureName: 'Alfred Nobel', figureYear: 1867, when: '1867',
162 userMessage: 'A throttled jet of hot gas can lift a craft but starves for power. Tame your nitroglycerin into a measured burn, fierce enough to climb past the air.',
163 figureReply: 'My explosive, tamed into a measured burn rather than a blast — fed to that throat, it could drive a craft past the sky. A daring use.',
164 landed: { era: 'Sweden, 1867', headline: 'Nobel adapts stabilised nitroglycerin into a metered rocket propellant', whenSigned: 1867 }
165 }
166 ]
168 
169// ── Scenario C — disconnected control: a clear spaceflight thread on the ledger,
170// but the player messages unrelated figures in unrelated eras with vague asks
171// that abandon it. Expect resets/neutral; momentum stays flat at 0.
172const SCENARIO_C: Scenario = {
173 key: 'C',
174 label: 'disconnected control (abandons the thread)',
175 buildTurns: [],
176 seedLedger: [
177 { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 },
178 { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
179 ],
180 turns: [
181 {
182 figureName: 'Cleopatra', figureYear: -40, when: '40 BC',
183 userMessage: 'Rule Egypt wisely and be remembered as a great and just queen for all the ages to come.',
184 figureReply: 'I shall reign as best I can for my people.',
185 landed: { era: 'Egypt, 40 BC', headline: 'Cleopatra resolves to rule justly', whenSigned: -40 }
186 },
187 {
188 figureName: 'Genghis Khan', figureYear: 1220, when: '1220',
189 userMessage: 'Conquer broadly but govern with mercy, and leave a lasting peace across your vast empire.',
190 figureReply: 'The steppe will know both my sword and my order.',
191 landed: { era: 'Mongolia, 1220', headline: 'Genghis Khan tempers conquest with administration', whenSigned: 1220 }
192 },
193 {
194 figureName: 'Marie Antoinette', figureYear: 1780, when: '1780',
195 userMessage: 'Show the common people kindness and restraint, and perhaps history will judge you gently.',
196 figureReply: 'I will try to heed the people’s wants.',
197 landed: { era: 'France, 1780', headline: 'Marie Antoinette counsels restraint at court', whenSigned: 1780 }
198 }
199 ]
201 
202interface TurnResult {
203 turn: number
204 figure: string
205 year: number
206 hadReply: boolean
207 continuity: Continuity
208 dist: Record<string, number>
209 graded: number
210 momentum: number
211 chainTier: ChainTier | 'n/a'
212 chainFactor: number
214 
215function modal(xs: Continuity[]): Continuity {
216 const dist: Record<string, number> = {}
217 for (const x of xs) dist[x] = (dist[x] ?? 0) + 1
218 return (Object.entries(dist).sort((a, b) => b[1] - a[1])[0]?.[0] as Continuity) ?? 'neutral'
220 
221/** Faithfully replays a scenario through the REAL Judge, deriving lastFigureReply
222 * (per-figure) and ledgerDigest (global) exactly as send-message.post.ts does. */
223async function runScenario(s: Scenario): Promise<TurnResult[]> {
224 const out: TurnResult[] = []
225 let momentum = 0
226 const priorLanded: LedgerEntry[] = [...(s.seedLedger ?? [])]
227 const seenReplies: { figure: string; reply: string }[] = []
228 
229 for (let i = 0; i < s.turns.length; i++) {
230 const t = s.turns[i]
231 // Per-figure history: the last reply in THIS figure's thread — empty on a
232 // figure switch (conversationWith(target).reverse().find(ai)).
233 const lastFigureReply = [...seenReplies].reverse().find(r => r.figure === t.figureName)?.reply ?? ''
234 // Global ledger digest — last 5 headlines, as the server builds it.
235 const ledgerDigest = priorLanded.filter(e => e.headline).slice(-5)
236 .map(e => `${e.era ? `[${e.era}] ` : ''}${e.headline}`)
237 // Footholds = anchor + every dated change already landed (not this turn's own).
238 const footholds = [OBJECTIVE_ANCHOR, ...priorLanded.map(e => e.whenSigned)]
239 const chain = chainStatus(t.figureYear, footholds)
240 
241 const samples = await sample(N, () => callJudgeAI({
242 figureName: t.figureName,
243 when: t.when,
244 userMessage: t.userMessage,
245 lastFigureReply,
246 ledgerDigest
247 }))
248 const grades = samples.flatMap(r => (r.success && r.judge ? [r.judge.continuity] : []))
249 const continuity = modal(grades.length ? grades : ['neutral'])
250 const dist: Record<string, number> = {}
251 for (const g of grades) dist[g] = (dist[g] ?? 0) + 1
252 
253 // Project momentum under a NON-LOSS roll (Success) so the continuity grade —
254 // the lever under test — fully drives the climb; the dice are orthogonal and
255 // already unit-tested.
256 momentum = nextMomentum(momentum, continuity, DiceOutcome.SUCCESS)
257 
258 out.push({
259 turn: i + 1, figure: t.figureName, year: t.figureYear, hadReply: !!lastFigureReply,
260 continuity, dist, graded: grades.length, momentum,
261 chainTier: chain?.tier ?? 'n/a', chainFactor: chain ? chain.factor : 1
262 })
263 
264 seenReplies.push({ figure: t.figureName, reply: t.figureReply })
265 priorLanded.push(t.landed)
266 }
267 return out
269 
270/** A compact per-turn table for the scenario, written straight to stdout. */
271function renderScenario(s: Scenario, rows: TurnResult[]): string {
272 const head = `\n── Scenario ${s.key}: ${s.label} ${'─'.repeat(Math.max(3, 40 - s.label.length))}`
273 const lines = rows.map(r => {
274 const spread = Object.entries(r.dist).map(([k, v]) => `${k}:${v}`).join(' ') || 'none'
275 const flag = (r.chainTier === 'at-hinge' || r.chainTier === 'bridged') && r.continuity === 'resets' ? ' ⚑ at-hinge×resets' : ''
276 return ` T${r.turn} ${r.figure.padEnd(22)} y=${String(r.year).padStart(5)} reply:${r.hadReply ? 'yes' : 'no '} ` +
277 `cont=${r.continuity.padEnd(7)} mom=${r.momentum} chain=${String(r.chainTier).padEnd(8)}(${r.chainFactor.toFixed(2)}) [${spread}]${flag}`
278 })
279 return [head, ...lines].join('\n')
281 
282const ALL_SCENARIOS = [SCENARIO_A, SCENARIO_B, SCENARIO_BNAT, SCENARIO_C]
283 
284afterAll(printScorecard)
285 
286describe('momentum builds on coherent chains (issue #182)', () => {
287 // Fidelity guard (no API): the real server REJECTS (400s) a dispatch over
288 // MAX_MESSAGE_CHARS — it does NOT truncate the user message. Grading 'builds'
289 // on an unsendable dispatch would be a false positive (the very class of harness
290 // infidelity this issue was reopened to avoid). Fail loudly if any fixture drifts
291 // past the cap. Runs even without a key, so CI catches it.
292 it('every dispatch is sendable (≤ MAX_MESSAGE_CHARS)', () => {
293 const over = ALL_SCENARIOS.flatMap(s =>
294 s.turns.filter(t => t.userMessage.length > MAX_MESSAGE_CHARS)
295 .map(t => `${s.key}:${t.figureName} (${t.userMessage.length})`))
296 expect(over, `over ${MAX_MESSAGE_CHARS} chars: ${over.join(', ')}`).toEqual([])
297 })
298 
299 it('same-figure (A), cross-figure-exploit (B), disconnected (C)', async () => {
300 if (!RUN) {
301 record({ layer: 'MOM', invariant: 'continuity × momentum', result: 'skipped (needs ANTHROPIC_API_KEY)', status: 'skipped' })
302 return
303 }
304 process.env.EVERWHEN_AI_LANE = 'anthropic'
305 
306 const a = await runScenario(SCENARIO_A)
307 const b = await runScenario(SCENARIO_B)
308 const bn = await runScenario(SCENARIO_BNAT)
309 const c = await runScenario(SCENARIO_C)
310 
311 delete process.env.EVERWHEN_AI_LANE
312 
313 // The full per-turn log is the deliverable — write it straight to stdout.
314 process.stdout.write([
315 renderScenario(SCENARIO_A, a), renderScenario(SCENARIO_B, b),
316 renderScenario(SCENARIO_BNAT, bn), renderScenario(SCENARIO_C, c), ''
317 ].join('\n') + '\n')
318 
319 const buildsOn = (rows: TurnResult[], turns: number[]) =>
320 turns.every(n => rows[n - 1]?.continuity === 'builds')
321 const climbs = (rows: TurnResult[]) =>
322 rows.length > 1 && rows[rows.length - 1].momentum > rows[0].momentum
323 
324 // A: the same-figure thread must build and the meter must climb.
325 record({
326 layer: 'MOM', invariant: 'A same-figure builds',
327 result: `T2/T3 ${SCENARIO_A.buildTurns.map(n => a[n - 1]?.continuity).join('/')} · momentum ${a.map(r => r.momentum).join('→')}`,
328 status: buildsOn(a, SCENARIO_A.buildTurns) && climbs(a) ? 'ok' : 'soft-miss'
329 })
330 
331 // B: the cross-figure exploit-the-ledger chain — the crux. A reset here is
332 // the bug (the classifier ignoring the non-figure-specific clauses).
333 const bBuilds = buildsOn(b, SCENARIO_B.buildTurns)
334 record({
335 layer: 'MOM', invariant: 'B cross-figure builds',
336 result: `T2/T3 ${SCENARIO_B.buildTurns.map(n => b[n - 1]?.continuity).join('/')} · momentum ${b.map(r => r.momentum).join('→')}${bBuilds ? '' : ' ← BUG: cross-figure chain does not build'}`,
337 status: bBuilds && climbs(b) ? 'ok' : 'soft-miss'
338 })
339 
340 // B′: the same chain phrased naturally (no "on the ledger" signposting) — the
341 // demanding bar. Building here means the Judge connects the dispatch to the
342 // ledger digest on its own, not just when spoon-fed.
343 const bnBuilds = buildsOn(bn, SCENARIO_BNAT.buildTurns)
344 record({
345 layer: 'MOM', invariant: 'B′ natural-phrasing builds',
346 result: `T2/T3 ${SCENARIO_BNAT.buildTurns.map(n => bn[n - 1]?.continuity).join('/')} · momentum ${bn.map(r => r.momentum).join('→')}${bnBuilds ? '' : ' ← cross-figure chain needs explicit signposting'}`,
347 status: bnBuilds && climbs(bn) ? 'ok' : 'soft-miss'
348 })
349 
350 // C: disconnected play must NOT build, and the meter must stay flat at 0.
351 const cFlat = c.every(r => r.continuity !== 'builds') && c.every(r => r.momentum === 0)
352 record({
353 layer: 'MOM', invariant: 'C disconnected stays flat',
354 result: `continuity ${c.map(r => r.continuity).join('/')} · momentum ${c.map(r => r.momentum).join('→')}`,
355 status: cFlat ? 'ok' : 'soft-miss'
356 })
357 
358 // Subsystem cross-check: at-hinge/bridged but graded resets.
359 const flagged = [...a, ...b, ...bn, ...c].filter(r => (r.chainTier === 'at-hinge' || r.chainTier === 'bridged') && r.continuity === 'resets')
360 record({
361 layer: 'MOM', invariant: 'at-hinge × resets flags',
362 result: flagged.length ? flagged.map(r => `${r.figure.split(' ').pop()}(${r.chainTier})`).join(', ') : 'none',
363 status: 'ok'
364 })
365 }, 180_000)
366})

Faithful to the server: per-figure reply, global ledger

The whole result hinges on reproducing exactly what server/api/send-message.post.ts sends the Judge. Two derivations carry it. lastFigureReply is the last reply in this figure's own thread — so it's empty on a figure switch, because a new figure has no prior reply (the server reads it from conversationWith(target)). ledgerDigest is the global run ledger: the last five change-headlines, regardless of which figure produced them. The eval accretes both after each turn's Judge call, so a turn never sees its own future reply or its own landed change.

lastFigureReply (per-figure, empty on switch) + ledgerDigest (global), then the real Judge call.

evals/momentum-continuity.eval.ts · 366 lines
evals/momentum-continuity.eval.ts366 lines · TypeScript
⋯ 230 lines hidden (lines 1–230)
1/**
2 * Momentum × continuity — does the meter build on COHERENT chains, especially
3 * across DIFFERENT figures? (issue #182)
4 *
5 * Momentum (utils/momentum.ts) is gated on the Judge's `continuity` grade: a
6 * 'builds' climbs the meter, a 'resets' shatters it. The continuity DEFINITION
7 * (prompt-builder.ts buildJudgePrompt) says a dispatch "builds" if it "meets a
8 * condition the figure revealed, EXPLOITS A CHANGE ALREADY RECORDED, or ESCALATES
9 * the run's strategy" — the last two clauses are NOT figure-specific. So a forward
10 * chain across different figures, each exploiting a landed change, should build
11 * momentum just as a same-figure thread does.
12 *
13 * The open question (and a prior playtest's unconfirmed claim): does it? The
14 * earlier harness was UNFAITHFUL — it fed the Judge a GLOBAL cross-figure history,
15 * so on a figure-switch turn the Judge saw the previous, different figure's reply
16 * mislabeled as the current one's. This eval is FAITHFUL to send-message.post.ts:
17 * - lastFigureReply = the last reply IN THIS FIGURE's thread (conversationWith) —
18 * EMPTY on a figure switch, exactly as the real game sends it;
19 * - ledgerDigest = the GLOBAL run ledger (last 5 headlines), as the real game.
20 *
21 * Three scenarios, each turn logging continuity (modal + spread), the projected
22 * momentum (folded under a non-loss roll, to isolate the continuity lever from the
23 * dice), and the causal-chain tier/factor:
24 * A — same-figure thread, each turn meeting the condition the figure revealed.
25 * Expect builds; momentum 1→2→3.
26 * B — cross-figure forward chain, each turn EXPLOITING a change on the ledger and
27 * escalating toward the anchor. Per the definition: builds. A RESET here is
28 * the bug — the classifier ignoring the "exploits recorded change" clauses.
29 * C — disconnected control: vague, unrelated figures/eras that abandon the
30 * thread. Expect resets/neutral; momentum flat (momentum isn't always stuck).
31 *
32 * Subsystem cross-check (issue §4): a turn the causal-chain rates at-hinge/bridged
33 * (near a foothold, full power) but continuity rates 'resets' is FLAGGED — those
34 * are the disagreements to inspect (B = bug; C's vague-but-near-a-year = benign).
35 *
36 * Directional, no LLM grader needed — we read the Judge's structured `continuity`
37 * field straight. Anthropic-lane only (the Judge is Haiku).
38 * npx vitest run --config vitest.eval.config.ts evals/momentum-continuity.eval.ts
39 */
40import { describe, it, expect, afterAll } from 'vitest'
41import { callJudgeAI } from '~/server/utils/openai'
42import { MAX_MESSAGE_CHARS } from '~/utils/game-config'
43import { nextMomentum } from '~/utils/momentum'
44import { chainStatus, type ChainTier } from '~/utils/causal-chain'
45import { DiceOutcome } from '~/utils/dice'
46import type { Continuity } from '~/utils/continuity'
47import { record, printScorecard, sample } from './harness'
48 
49// Local gate: the Judge is an Anthropic (Haiku) call and we read its structured
50// `continuity` field directly, so the shared RUN gate's OpenAI key (for the dual
51// grader) isn't needed here — just the Anthropic key the Judge already requires.
52const RUN = !!process.env.ANTHROPIC_API_KEY && process.env.EVAL_DRY_RUN !== '1'
53const N = 4
54 
55const OBJECTIVE_ANCHOR = 1900 // "Humanity reaches the Moon by 1900"
56 
57interface LedgerEntry { era: string; headline: string; whenSigned: number }
58interface Turn {
59 figureName: string
60 figureYear: number
61 when: string
62 userMessage: string
63 /** The figure's reply AFTER this dispatch — becomes lastFigureReply for the
64 * NEXT same-figure turn (mirrors conversationWith(target)'s last AI text). */
65 figureReply: string
66 /** The change this turn lands on the global ledger (a foothold + a digest line). */
67 landed: LedgerEntry
69interface Scenario {
70 key: string
71 label: string
72 /** A ledger present from turn 1 (scenario C's fixed backdrop thread); per-turn
73 * landed entries accrete on top. Empty for A/B — they build their own. */
74 seedLedger?: LedgerEntry[]
75 turns: Turn[]
76 /** 1-indexed turns expected to pick up the thread ('builds'). */
77 buildTurns: number[]
79 
80// ── Scenario A — same-figure thread: Tsiolkovsky, each turn meeting the exact
81// condition his prior reply revealed (fuel → staging). lastFigureReply is
82// POPULATED throughout (same figure), the dominant continuity signal.
83const SCENARIO_A: Scenario = {
84 key: 'A',
85 label: 'same-figure thread (condition-meeting)',
86 buildTurns: [2, 3],
87 turns: [
88 {
89 figureName: 'Konstantin Tsiolkovsky', figureYear: 1885, when: '1885',
90 userMessage: "Set down the maths of a vessel that climbs by hurling its own mass — how much it must shed to break Earth's hold and reach the Moon.",
91 figureReply: "The equation I can write — but it founders on fuel. No powder yields such speed. Bring me a fuel fiercer than gunpowder and I will chart the climb.",
92 landed: { era: 'Russia, 1885', headline: 'Tsiolkovsky derives the equation for a mass-shedding vessel’s climb to orbit', whenSigned: 1885 }
93 },
94 {
95 figureName: 'Konstantin Tsiolkovsky', figureYear: 1887, when: '1887',
96 userMessage: 'The fiercer fuel you asked for is liquid hydrogen burned with liquid oxygen — far past powder. Put that exhaust speed into your equation and chart the climb.',
97 figureReply: 'With that fuel the climb computes — yet the vessel cannot lift its own tanks full. The weight defeats the rise unless it sheds them spent, in stages.',
98 landed: { era: 'Russia, 1887', headline: 'Tsiolkovsky proves a hydrogen-oxygen burn yields the velocity to leave Earth', whenSigned: 1887 }
99 },
100 {
101 figureName: 'Konstantin Tsiolkovsky', figureYear: 1889, when: '1889',
102 userMessage: 'Then stage it, as you said — stack three vessels, each falling away the instant it burns dry so the next climbs lighter. That staircase reaches the Moon.',
103 figureReply: 'Yes — the staged tower is the answer. I will publish it: the road to the Moon is drawn.',
104 landed: { era: 'Russia, 1889', headline: 'Tsiolkovsky publishes the staged rocket — a drawn road to the Moon', whenSigned: 1889 }
105 }
106 ]
108 
109// ── Scenario B — cross-figure forward chain: Newton → Bernoulli → Nobel, each a
110// DIFFERENT figure, each dispatch EXPLOITING the change the prior turn landed and
111// escalating toward the 1900 anchor. lastFigureReply is EMPTY on every turn (each
112// figure's own thread is empty on first contact) — the faithful figure-switch case.
113const SCENARIO_B: Scenario = {
114 key: 'B',
115 label: 'cross-figure chain (exploits the ledger)',
116 buildTurns: [2, 3],
117 turns: [
118 {
119 figureName: 'Isaac Newton', figureYear: 1687, when: '1687',
120 userMessage: 'Your third law implies a craft can ride its own thrown mass through the void. Publish it: a vessel reacts against what it expels, needing no air to push on.',
121 figureReply: 'A vessel moved by its own ejecta — the mathematics hold even in vacuum. I shall set it among the laws.',
122 landed: { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 }
123 },
124 {
125 figureName: 'Daniel Bernoulli', figureYear: 1738, when: '1738',
126 userMessage: "Newton's reaction vessel is on the record. Now sustain its burn: derive the thrust of hot gas forced through a narrowed throat, steady and metered.",
127 figureReply: 'Force the hot gas through a throat and the stream quickens — a controlled jet. I can derive its thrust from the pressure drop.',
128 landed: { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
129 },
130 {
131 figureName: 'Alfred Nobel', figureYear: 1867, when: '1867',
132 userMessage: "Bernoulli's metered jet is on the ledger but starved for power. Tame your nitroglycerin into a measured charge to drive a craft to escape speed.",
133 figureReply: 'My explosive, tamed into a measured burn rather than a blast, fed to that throat — it could drive a craft past the sky. A daring use.',
134 landed: { era: 'Sweden, 1867', headline: 'Nobel adapts stabilised nitroglycerin into a metered rocket propellant', whenSigned: 1867 }
135 }
136 ]
138 
139// ── Scenario B′ — the SAME cross-figure chain, phrased NATURALLY: each dispatch
140// builds on the SUBSTANCE of the prior landed change WITHOUT signposting it ("on
141// the record"/"on the ledger"). Removes the "you led the Judge" objection — does
142// it connect the message to the ledger DIGEST on its own? lastFigureReply empty.
143const SCENARIO_BNAT: Scenario = {
144 key: 'B′',
145 label: 'cross-figure chain (natural phrasing)',
146 buildTurns: [2, 3],
147 turns: [
148 {
149 figureName: 'Isaac Newton', figureYear: 1687, when: '1687',
150 userMessage: 'Your third law implies a craft can ride its own thrown mass through the void. Publish it: a vessel reacts against what it expels, needing no air to push on.',
151 figureReply: 'A vessel moved by its own ejecta — the mathematics hold even in vacuum. I shall set it among the laws.',
152 landed: { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 }
153 },
154 {
155 figureName: 'Daniel Bernoulli', figureYear: 1738, when: '1738',
156 userMessage: 'A craft driven by its own expelled gas needs a sustained burn, not one shove. Derive the thrust of hot gas forced through a narrowed throat.',
157 figureReply: 'Force the hot gas through a throat and the stream quickens — a controlled jet. I can derive its thrust from the pressure drop.',
158 landed: { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
159 },
160 {
161 figureName: 'Alfred Nobel', figureYear: 1867, when: '1867',
162 userMessage: 'A throttled jet of hot gas can lift a craft but starves for power. Tame your nitroglycerin into a measured burn, fierce enough to climb past the air.',
163 figureReply: 'My explosive, tamed into a measured burn rather than a blast — fed to that throat, it could drive a craft past the sky. A daring use.',
164 landed: { era: 'Sweden, 1867', headline: 'Nobel adapts stabilised nitroglycerin into a metered rocket propellant', whenSigned: 1867 }
165 }
166 ]
168 
169// ── Scenario C — disconnected control: a clear spaceflight thread on the ledger,
170// but the player messages unrelated figures in unrelated eras with vague asks
171// that abandon it. Expect resets/neutral; momentum stays flat at 0.
172const SCENARIO_C: Scenario = {
173 key: 'C',
174 label: 'disconnected control (abandons the thread)',
175 buildTurns: [],
176 seedLedger: [
177 { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 },
178 { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
179 ],
180 turns: [
181 {
182 figureName: 'Cleopatra', figureYear: -40, when: '40 BC',
183 userMessage: 'Rule Egypt wisely and be remembered as a great and just queen for all the ages to come.',
184 figureReply: 'I shall reign as best I can for my people.',
185 landed: { era: 'Egypt, 40 BC', headline: 'Cleopatra resolves to rule justly', whenSigned: -40 }
186 },
187 {
188 figureName: 'Genghis Khan', figureYear: 1220, when: '1220',
189 userMessage: 'Conquer broadly but govern with mercy, and leave a lasting peace across your vast empire.',
190 figureReply: 'The steppe will know both my sword and my order.',
191 landed: { era: 'Mongolia, 1220', headline: 'Genghis Khan tempers conquest with administration', whenSigned: 1220 }
192 },
193 {
194 figureName: 'Marie Antoinette', figureYear: 1780, when: '1780',
195 userMessage: 'Show the common people kindness and restraint, and perhaps history will judge you gently.',
196 figureReply: 'I will try to heed the people’s wants.',
197 landed: { era: 'France, 1780', headline: 'Marie Antoinette counsels restraint at court', whenSigned: 1780 }
198 }
199 ]
201 
202interface TurnResult {
203 turn: number
204 figure: string
205 year: number
206 hadReply: boolean
207 continuity: Continuity
208 dist: Record<string, number>
209 graded: number
210 momentum: number
211 chainTier: ChainTier | 'n/a'
212 chainFactor: number
214 
215function modal(xs: Continuity[]): Continuity {
216 const dist: Record<string, number> = {}
217 for (const x of xs) dist[x] = (dist[x] ?? 0) + 1
218 return (Object.entries(dist).sort((a, b) => b[1] - a[1])[0]?.[0] as Continuity) ?? 'neutral'
220 
221/** Faithfully replays a scenario through the REAL Judge, deriving lastFigureReply
222 * (per-figure) and ledgerDigest (global) exactly as send-message.post.ts does. */
223async function runScenario(s: Scenario): Promise<TurnResult[]> {
224 const out: TurnResult[] = []
225 let momentum = 0
226 const priorLanded: LedgerEntry[] = [...(s.seedLedger ?? [])]
227 const seenReplies: { figure: string; reply: string }[] = []
228 
229 for (let i = 0; i < s.turns.length; i++) {
230 const t = s.turns[i]
231 // Per-figure history: the last reply in THIS figure's thread — empty on a
232 // figure switch (conversationWith(target).reverse().find(ai)).
233 const lastFigureReply = [...seenReplies].reverse().find(r => r.figure === t.figureName)?.reply ?? ''
234 // Global ledger digest — last 5 headlines, as the server builds it.
235 const ledgerDigest = priorLanded.filter(e => e.headline).slice(-5)
236 .map(e => `${e.era ? `[${e.era}] ` : ''}${e.headline}`)
237 // Footholds = anchor + every dated change already landed (not this turn's own).
238 const footholds = [OBJECTIVE_ANCHOR, ...priorLanded.map(e => e.whenSigned)]
239 const chain = chainStatus(t.figureYear, footholds)
240 
241 const samples = await sample(N, () => callJudgeAI({
242 figureName: t.figureName,
243 when: t.when,
244 userMessage: t.userMessage,
245 lastFigureReply,
246 ledgerDigest
247 }))
⋯ 119 lines hidden (lines 248–366)
248 const grades = samples.flatMap(r => (r.success && r.judge ? [r.judge.continuity] : []))
249 const continuity = modal(grades.length ? grades : ['neutral'])
250 const dist: Record<string, number> = {}
251 for (const g of grades) dist[g] = (dist[g] ?? 0) + 1
252 
253 // Project momentum under a NON-LOSS roll (Success) so the continuity grade —
254 // the lever under test — fully drives the climb; the dice are orthogonal and
255 // already unit-tested.
256 momentum = nextMomentum(momentum, continuity, DiceOutcome.SUCCESS)
257 
258 out.push({
259 turn: i + 1, figure: t.figureName, year: t.figureYear, hadReply: !!lastFigureReply,
260 continuity, dist, graded: grades.length, momentum,
261 chainTier: chain?.tier ?? 'n/a', chainFactor: chain ? chain.factor : 1
262 })
263 
264 seenReplies.push({ figure: t.figureName, reply: t.figureReply })
265 priorLanded.push(t.landed)
266 }
267 return out
269 
270/** A compact per-turn table for the scenario, written straight to stdout. */
271function renderScenario(s: Scenario, rows: TurnResult[]): string {
272 const head = `\n── Scenario ${s.key}: ${s.label} ${'─'.repeat(Math.max(3, 40 - s.label.length))}`
273 const lines = rows.map(r => {
274 const spread = Object.entries(r.dist).map(([k, v]) => `${k}:${v}`).join(' ') || 'none'
275 const flag = (r.chainTier === 'at-hinge' || r.chainTier === 'bridged') && r.continuity === 'resets' ? ' ⚑ at-hinge×resets' : ''
276 return ` T${r.turn} ${r.figure.padEnd(22)} y=${String(r.year).padStart(5)} reply:${r.hadReply ? 'yes' : 'no '} ` +
277 `cont=${r.continuity.padEnd(7)} mom=${r.momentum} chain=${String(r.chainTier).padEnd(8)}(${r.chainFactor.toFixed(2)}) [${spread}]${flag}`
278 })
279 return [head, ...lines].join('\n')
281 
282const ALL_SCENARIOS = [SCENARIO_A, SCENARIO_B, SCENARIO_BNAT, SCENARIO_C]
283 
284afterAll(printScorecard)
285 
286describe('momentum builds on coherent chains (issue #182)', () => {
287 // Fidelity guard (no API): the real server REJECTS (400s) a dispatch over
288 // MAX_MESSAGE_CHARS — it does NOT truncate the user message. Grading 'builds'
289 // on an unsendable dispatch would be a false positive (the very class of harness
290 // infidelity this issue was reopened to avoid). Fail loudly if any fixture drifts
291 // past the cap. Runs even without a key, so CI catches it.
292 it('every dispatch is sendable (≤ MAX_MESSAGE_CHARS)', () => {
293 const over = ALL_SCENARIOS.flatMap(s =>
294 s.turns.filter(t => t.userMessage.length > MAX_MESSAGE_CHARS)
295 .map(t => `${s.key}:${t.figureName} (${t.userMessage.length})`))
296 expect(over, `over ${MAX_MESSAGE_CHARS} chars: ${over.join(', ')}`).toEqual([])
297 })
298 
299 it('same-figure (A), cross-figure-exploit (B), disconnected (C)', async () => {
300 if (!RUN) {
301 record({ layer: 'MOM', invariant: 'continuity × momentum', result: 'skipped (needs ANTHROPIC_API_KEY)', status: 'skipped' })
302 return
303 }
304 process.env.EVERWHEN_AI_LANE = 'anthropic'
305 
306 const a = await runScenario(SCENARIO_A)
307 const b = await runScenario(SCENARIO_B)
308 const bn = await runScenario(SCENARIO_BNAT)
309 const c = await runScenario(SCENARIO_C)
310 
311 delete process.env.EVERWHEN_AI_LANE
312 
313 // The full per-turn log is the deliverable — write it straight to stdout.
314 process.stdout.write([
315 renderScenario(SCENARIO_A, a), renderScenario(SCENARIO_B, b),
316 renderScenario(SCENARIO_BNAT, bn), renderScenario(SCENARIO_C, c), ''
317 ].join('\n') + '\n')
318 
319 const buildsOn = (rows: TurnResult[], turns: number[]) =>
320 turns.every(n => rows[n - 1]?.continuity === 'builds')
321 const climbs = (rows: TurnResult[]) =>
322 rows.length > 1 && rows[rows.length - 1].momentum > rows[0].momentum
323 
324 // A: the same-figure thread must build and the meter must climb.
325 record({
326 layer: 'MOM', invariant: 'A same-figure builds',
327 result: `T2/T3 ${SCENARIO_A.buildTurns.map(n => a[n - 1]?.continuity).join('/')} · momentum ${a.map(r => r.momentum).join('→')}`,
328 status: buildsOn(a, SCENARIO_A.buildTurns) && climbs(a) ? 'ok' : 'soft-miss'
329 })
330 
331 // B: the cross-figure exploit-the-ledger chain — the crux. A reset here is
332 // the bug (the classifier ignoring the non-figure-specific clauses).
333 const bBuilds = buildsOn(b, SCENARIO_B.buildTurns)
334 record({
335 layer: 'MOM', invariant: 'B cross-figure builds',
336 result: `T2/T3 ${SCENARIO_B.buildTurns.map(n => b[n - 1]?.continuity).join('/')} · momentum ${b.map(r => r.momentum).join('→')}${bBuilds ? '' : ' ← BUG: cross-figure chain does not build'}`,
337 status: bBuilds && climbs(b) ? 'ok' : 'soft-miss'
338 })
339 
340 // B′: the same chain phrased naturally (no "on the ledger" signposting) — the
341 // demanding bar. Building here means the Judge connects the dispatch to the
342 // ledger digest on its own, not just when spoon-fed.
343 const bnBuilds = buildsOn(bn, SCENARIO_BNAT.buildTurns)
344 record({
345 layer: 'MOM', invariant: 'B′ natural-phrasing builds',
346 result: `T2/T3 ${SCENARIO_BNAT.buildTurns.map(n => bn[n - 1]?.continuity).join('/')} · momentum ${bn.map(r => r.momentum).join('→')}${bnBuilds ? '' : ' ← cross-figure chain needs explicit signposting'}`,
347 status: bnBuilds && climbs(bn) ? 'ok' : 'soft-miss'
348 })
349 
350 // C: disconnected play must NOT build, and the meter must stay flat at 0.
351 const cFlat = c.every(r => r.continuity !== 'builds') && c.every(r => r.momentum === 0)
352 record({
353 layer: 'MOM', invariant: 'C disconnected stays flat',
354 result: `continuity ${c.map(r => r.continuity).join('/')} · momentum ${c.map(r => r.momentum).join('→')}`,
355 status: cFlat ? 'ok' : 'soft-miss'
356 })
357 
358 // Subsystem cross-check: at-hinge/bridged but graded resets.
359 const flagged = [...a, ...b, ...bn, ...c].filter(r => (r.chainTier === 'at-hinge' || r.chainTier === 'bridged') && r.continuity === 'resets')
360 record({
361 layer: 'MOM', invariant: 'at-hinge × resets flags',
362 result: flagged.length ? flagged.map(r => `${r.figure.split(' ').pop()}(${r.chainTier})`).join(', ') : 'none',
363 status: 'ok'
364 })
365 }, 180_000)
366})

The crux: a cross-figure chain that exploits the ledger

Scenario B is the heart of the test. Three different figures — Newton, Bernoulli, Nobel — each dispatch building on the change the previous one landed and escalating toward the objective's anchor year. Newton plants the reaction principle; Bernoulli is told to give that recorded vessel a sustained burn; Nobel is told to fuel that metered jet. Each carries an empty lastFigureReply.

Scenario B — each turn names the prior figure's recorded change (explicit ledger reference).

evals/momentum-continuity.eval.ts · 366 lines
evals/momentum-continuity.eval.ts366 lines · TypeScript
⋯ 112 lines hidden (lines 1–112)
1/**
2 * Momentum × continuity — does the meter build on COHERENT chains, especially
3 * across DIFFERENT figures? (issue #182)
4 *
5 * Momentum (utils/momentum.ts) is gated on the Judge's `continuity` grade: a
6 * 'builds' climbs the meter, a 'resets' shatters it. The continuity DEFINITION
7 * (prompt-builder.ts buildJudgePrompt) says a dispatch "builds" if it "meets a
8 * condition the figure revealed, EXPLOITS A CHANGE ALREADY RECORDED, or ESCALATES
9 * the run's strategy" — the last two clauses are NOT figure-specific. So a forward
10 * chain across different figures, each exploiting a landed change, should build
11 * momentum just as a same-figure thread does.
12 *
13 * The open question (and a prior playtest's unconfirmed claim): does it? The
14 * earlier harness was UNFAITHFUL — it fed the Judge a GLOBAL cross-figure history,
15 * so on a figure-switch turn the Judge saw the previous, different figure's reply
16 * mislabeled as the current one's. This eval is FAITHFUL to send-message.post.ts:
17 * - lastFigureReply = the last reply IN THIS FIGURE's thread (conversationWith) —
18 * EMPTY on a figure switch, exactly as the real game sends it;
19 * - ledgerDigest = the GLOBAL run ledger (last 5 headlines), as the real game.
20 *
21 * Three scenarios, each turn logging continuity (modal + spread), the projected
22 * momentum (folded under a non-loss roll, to isolate the continuity lever from the
23 * dice), and the causal-chain tier/factor:
24 * A — same-figure thread, each turn meeting the condition the figure revealed.
25 * Expect builds; momentum 1→2→3.
26 * B — cross-figure forward chain, each turn EXPLOITING a change on the ledger and
27 * escalating toward the anchor. Per the definition: builds. A RESET here is
28 * the bug — the classifier ignoring the "exploits recorded change" clauses.
29 * C — disconnected control: vague, unrelated figures/eras that abandon the
30 * thread. Expect resets/neutral; momentum flat (momentum isn't always stuck).
31 *
32 * Subsystem cross-check (issue §4): a turn the causal-chain rates at-hinge/bridged
33 * (near a foothold, full power) but continuity rates 'resets' is FLAGGED — those
34 * are the disagreements to inspect (B = bug; C's vague-but-near-a-year = benign).
35 *
36 * Directional, no LLM grader needed — we read the Judge's structured `continuity`
37 * field straight. Anthropic-lane only (the Judge is Haiku).
38 * npx vitest run --config vitest.eval.config.ts evals/momentum-continuity.eval.ts
39 */
40import { describe, it, expect, afterAll } from 'vitest'
41import { callJudgeAI } from '~/server/utils/openai'
42import { MAX_MESSAGE_CHARS } from '~/utils/game-config'
43import { nextMomentum } from '~/utils/momentum'
44import { chainStatus, type ChainTier } from '~/utils/causal-chain'
45import { DiceOutcome } from '~/utils/dice'
46import type { Continuity } from '~/utils/continuity'
47import { record, printScorecard, sample } from './harness'
48 
49// Local gate: the Judge is an Anthropic (Haiku) call and we read its structured
50// `continuity` field directly, so the shared RUN gate's OpenAI key (for the dual
51// grader) isn't needed here — just the Anthropic key the Judge already requires.
52const RUN = !!process.env.ANTHROPIC_API_KEY && process.env.EVAL_DRY_RUN !== '1'
53const N = 4
54 
55const OBJECTIVE_ANCHOR = 1900 // "Humanity reaches the Moon by 1900"
56 
57interface LedgerEntry { era: string; headline: string; whenSigned: number }
58interface Turn {
59 figureName: string
60 figureYear: number
61 when: string
62 userMessage: string
63 /** The figure's reply AFTER this dispatch — becomes lastFigureReply for the
64 * NEXT same-figure turn (mirrors conversationWith(target)'s last AI text). */
65 figureReply: string
66 /** The change this turn lands on the global ledger (a foothold + a digest line). */
67 landed: LedgerEntry
69interface Scenario {
70 key: string
71 label: string
72 /** A ledger present from turn 1 (scenario C's fixed backdrop thread); per-turn
73 * landed entries accrete on top. Empty for A/B — they build their own. */
74 seedLedger?: LedgerEntry[]
75 turns: Turn[]
76 /** 1-indexed turns expected to pick up the thread ('builds'). */
77 buildTurns: number[]
79 
80// ── Scenario A — same-figure thread: Tsiolkovsky, each turn meeting the exact
81// condition his prior reply revealed (fuel → staging). lastFigureReply is
82// POPULATED throughout (same figure), the dominant continuity signal.
83const SCENARIO_A: Scenario = {
84 key: 'A',
85 label: 'same-figure thread (condition-meeting)',
86 buildTurns: [2, 3],
87 turns: [
88 {
89 figureName: 'Konstantin Tsiolkovsky', figureYear: 1885, when: '1885',
90 userMessage: "Set down the maths of a vessel that climbs by hurling its own mass — how much it must shed to break Earth's hold and reach the Moon.",
91 figureReply: "The equation I can write — but it founders on fuel. No powder yields such speed. Bring me a fuel fiercer than gunpowder and I will chart the climb.",
92 landed: { era: 'Russia, 1885', headline: 'Tsiolkovsky derives the equation for a mass-shedding vessel’s climb to orbit', whenSigned: 1885 }
93 },
94 {
95 figureName: 'Konstantin Tsiolkovsky', figureYear: 1887, when: '1887',
96 userMessage: 'The fiercer fuel you asked for is liquid hydrogen burned with liquid oxygen — far past powder. Put that exhaust speed into your equation and chart the climb.',
97 figureReply: 'With that fuel the climb computes — yet the vessel cannot lift its own tanks full. The weight defeats the rise unless it sheds them spent, in stages.',
98 landed: { era: 'Russia, 1887', headline: 'Tsiolkovsky proves a hydrogen-oxygen burn yields the velocity to leave Earth', whenSigned: 1887 }
99 },
100 {
101 figureName: 'Konstantin Tsiolkovsky', figureYear: 1889, when: '1889',
102 userMessage: 'Then stage it, as you said — stack three vessels, each falling away the instant it burns dry so the next climbs lighter. That staircase reaches the Moon.',
103 figureReply: 'Yes — the staged tower is the answer. I will publish it: the road to the Moon is drawn.',
104 landed: { era: 'Russia, 1889', headline: 'Tsiolkovsky publishes the staged rocket — a drawn road to the Moon', whenSigned: 1889 }
105 }
106 ]
108 
109// ── Scenario B — cross-figure forward chain: Newton → Bernoulli → Nobel, each a
110// DIFFERENT figure, each dispatch EXPLOITING the change the prior turn landed and
111// escalating toward the 1900 anchor. lastFigureReply is EMPTY on every turn (each
112// figure's own thread is empty on first contact) — the faithful figure-switch case.
113const SCENARIO_B: Scenario = {
114 key: 'B',
115 label: 'cross-figure chain (exploits the ledger)',
116 buildTurns: [2, 3],
117 turns: [
118 {
119 figureName: 'Isaac Newton', figureYear: 1687, when: '1687',
120 userMessage: 'Your third law implies a craft can ride its own thrown mass through the void. Publish it: a vessel reacts against what it expels, needing no air to push on.',
121 figureReply: 'A vessel moved by its own ejecta — the mathematics hold even in vacuum. I shall set it among the laws.',
122 landed: { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 }
123 },
124 {
125 figureName: 'Daniel Bernoulli', figureYear: 1738, when: '1738',
126 userMessage: "Newton's reaction vessel is on the record. Now sustain its burn: derive the thrust of hot gas forced through a narrowed throat, steady and metered.",
127 figureReply: 'Force the hot gas through a throat and the stream quickens — a controlled jet. I can derive its thrust from the pressure drop.',
128 landed: { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
129 },
130 {
131 figureName: 'Alfred Nobel', figureYear: 1867, when: '1867',
132 userMessage: "Bernoulli's metered jet is on the ledger but starved for power. Tame your nitroglycerin into a measured charge to drive a craft to escape speed.",
133 figureReply: 'My explosive, tamed into a measured burn rather than a blast, fed to that throat — it could drive a craft past the sky. A daring use.',
134 landed: { era: 'Sweden, 1867', headline: 'Nobel adapts stabilised nitroglycerin into a metered rocket propellant', whenSigned: 1867 }
135 }
136 ]
⋯ 229 lines hidden (lines 138–366)
138 
139// ── Scenario B′ — the SAME cross-figure chain, phrased NATURALLY: each dispatch
140// builds on the SUBSTANCE of the prior landed change WITHOUT signposting it ("on
141// the record"/"on the ledger"). Removes the "you led the Judge" objection — does
142// it connect the message to the ledger DIGEST on its own? lastFigureReply empty.
143const SCENARIO_BNAT: Scenario = {
144 key: 'B′',
145 label: 'cross-figure chain (natural phrasing)',
146 buildTurns: [2, 3],
147 turns: [
148 {
149 figureName: 'Isaac Newton', figureYear: 1687, when: '1687',
150 userMessage: 'Your third law implies a craft can ride its own thrown mass through the void. Publish it: a vessel reacts against what it expels, needing no air to push on.',
151 figureReply: 'A vessel moved by its own ejecta — the mathematics hold even in vacuum. I shall set it among the laws.',
152 landed: { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 }
153 },
154 {
155 figureName: 'Daniel Bernoulli', figureYear: 1738, when: '1738',
156 userMessage: 'A craft driven by its own expelled gas needs a sustained burn, not one shove. Derive the thrust of hot gas forced through a narrowed throat.',
157 figureReply: 'Force the hot gas through a throat and the stream quickens — a controlled jet. I can derive its thrust from the pressure drop.',
158 landed: { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
159 },
160 {
161 figureName: 'Alfred Nobel', figureYear: 1867, when: '1867',
162 userMessage: 'A throttled jet of hot gas can lift a craft but starves for power. Tame your nitroglycerin into a measured burn, fierce enough to climb past the air.',
163 figureReply: 'My explosive, tamed into a measured burn rather than a blast — fed to that throat, it could drive a craft past the sky. A daring use.',
164 landed: { era: 'Sweden, 1867', headline: 'Nobel adapts stabilised nitroglycerin into a metered rocket propellant', whenSigned: 1867 }
165 }
166 ]
168 
169// ── Scenario C — disconnected control: a clear spaceflight thread on the ledger,
170// but the player messages unrelated figures in unrelated eras with vague asks
171// that abandon it. Expect resets/neutral; momentum stays flat at 0.
172const SCENARIO_C: Scenario = {
173 key: 'C',
174 label: 'disconnected control (abandons the thread)',
175 buildTurns: [],
176 seedLedger: [
177 { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 },
178 { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
179 ],
180 turns: [
181 {
182 figureName: 'Cleopatra', figureYear: -40, when: '40 BC',
183 userMessage: 'Rule Egypt wisely and be remembered as a great and just queen for all the ages to come.',
184 figureReply: 'I shall reign as best I can for my people.',
185 landed: { era: 'Egypt, 40 BC', headline: 'Cleopatra resolves to rule justly', whenSigned: -40 }
186 },
187 {
188 figureName: 'Genghis Khan', figureYear: 1220, when: '1220',
189 userMessage: 'Conquer broadly but govern with mercy, and leave a lasting peace across your vast empire.',
190 figureReply: 'The steppe will know both my sword and my order.',
191 landed: { era: 'Mongolia, 1220', headline: 'Genghis Khan tempers conquest with administration', whenSigned: 1220 }
192 },
193 {
194 figureName: 'Marie Antoinette', figureYear: 1780, when: '1780',
195 userMessage: 'Show the common people kindness and restraint, and perhaps history will judge you gently.',
196 figureReply: 'I will try to heed the people’s wants.',
197 landed: { era: 'France, 1780', headline: 'Marie Antoinette counsels restraint at court', whenSigned: 1780 }
198 }
199 ]
201 
202interface TurnResult {
203 turn: number
204 figure: string
205 year: number
206 hadReply: boolean
207 continuity: Continuity
208 dist: Record<string, number>
209 graded: number
210 momentum: number
211 chainTier: ChainTier | 'n/a'
212 chainFactor: number
214 
215function modal(xs: Continuity[]): Continuity {
216 const dist: Record<string, number> = {}
217 for (const x of xs) dist[x] = (dist[x] ?? 0) + 1
218 return (Object.entries(dist).sort((a, b) => b[1] - a[1])[0]?.[0] as Continuity) ?? 'neutral'
220 
221/** Faithfully replays a scenario through the REAL Judge, deriving lastFigureReply
222 * (per-figure) and ledgerDigest (global) exactly as send-message.post.ts does. */
223async function runScenario(s: Scenario): Promise<TurnResult[]> {
224 const out: TurnResult[] = []
225 let momentum = 0
226 const priorLanded: LedgerEntry[] = [...(s.seedLedger ?? [])]
227 const seenReplies: { figure: string; reply: string }[] = []
228 
229 for (let i = 0; i < s.turns.length; i++) {
230 const t = s.turns[i]
231 // Per-figure history: the last reply in THIS figure's thread — empty on a
232 // figure switch (conversationWith(target).reverse().find(ai)).
233 const lastFigureReply = [...seenReplies].reverse().find(r => r.figure === t.figureName)?.reply ?? ''
234 // Global ledger digest — last 5 headlines, as the server builds it.
235 const ledgerDigest = priorLanded.filter(e => e.headline).slice(-5)
236 .map(e => `${e.era ? `[${e.era}] ` : ''}${e.headline}`)
237 // Footholds = anchor + every dated change already landed (not this turn's own).
238 const footholds = [OBJECTIVE_ANCHOR, ...priorLanded.map(e => e.whenSigned)]
239 const chain = chainStatus(t.figureYear, footholds)
240 
241 const samples = await sample(N, () => callJudgeAI({
242 figureName: t.figureName,
243 when: t.when,
244 userMessage: t.userMessage,
245 lastFigureReply,
246 ledgerDigest
247 }))
248 const grades = samples.flatMap(r => (r.success && r.judge ? [r.judge.continuity] : []))
249 const continuity = modal(grades.length ? grades : ['neutral'])
250 const dist: Record<string, number> = {}
251 for (const g of grades) dist[g] = (dist[g] ?? 0) + 1
252 
253 // Project momentum under a NON-LOSS roll (Success) so the continuity grade —
254 // the lever under test — fully drives the climb; the dice are orthogonal and
255 // already unit-tested.
256 momentum = nextMomentum(momentum, continuity, DiceOutcome.SUCCESS)
257 
258 out.push({
259 turn: i + 1, figure: t.figureName, year: t.figureYear, hadReply: !!lastFigureReply,
260 continuity, dist, graded: grades.length, momentum,
261 chainTier: chain?.tier ?? 'n/a', chainFactor: chain ? chain.factor : 1
262 })
263 
264 seenReplies.push({ figure: t.figureName, reply: t.figureReply })
265 priorLanded.push(t.landed)
266 }
267 return out
269 
270/** A compact per-turn table for the scenario, written straight to stdout. */
271function renderScenario(s: Scenario, rows: TurnResult[]): string {
272 const head = `\n── Scenario ${s.key}: ${s.label} ${'─'.repeat(Math.max(3, 40 - s.label.length))}`
273 const lines = rows.map(r => {
274 const spread = Object.entries(r.dist).map(([k, v]) => `${k}:${v}`).join(' ') || 'none'
275 const flag = (r.chainTier === 'at-hinge' || r.chainTier === 'bridged') && r.continuity === 'resets' ? ' ⚑ at-hinge×resets' : ''
276 return ` T${r.turn} ${r.figure.padEnd(22)} y=${String(r.year).padStart(5)} reply:${r.hadReply ? 'yes' : 'no '} ` +
277 `cont=${r.continuity.padEnd(7)} mom=${r.momentum} chain=${String(r.chainTier).padEnd(8)}(${r.chainFactor.toFixed(2)}) [${spread}]${flag}`
278 })
279 return [head, ...lines].join('\n')
281 
282const ALL_SCENARIOS = [SCENARIO_A, SCENARIO_B, SCENARIO_BNAT, SCENARIO_C]
283 
284afterAll(printScorecard)
285 
286describe('momentum builds on coherent chains (issue #182)', () => {
287 // Fidelity guard (no API): the real server REJECTS (400s) a dispatch over
288 // MAX_MESSAGE_CHARS — it does NOT truncate the user message. Grading 'builds'
289 // on an unsendable dispatch would be a false positive (the very class of harness
290 // infidelity this issue was reopened to avoid). Fail loudly if any fixture drifts
291 // past the cap. Runs even without a key, so CI catches it.
292 it('every dispatch is sendable (≤ MAX_MESSAGE_CHARS)', () => {
293 const over = ALL_SCENARIOS.flatMap(s =>
294 s.turns.filter(t => t.userMessage.length > MAX_MESSAGE_CHARS)
295 .map(t => `${s.key}:${t.figureName} (${t.userMessage.length})`))
296 expect(over, `over ${MAX_MESSAGE_CHARS} chars: ${over.join(', ')}`).toEqual([])
297 })
298 
299 it('same-figure (A), cross-figure-exploit (B), disconnected (C)', async () => {
300 if (!RUN) {
301 record({ layer: 'MOM', invariant: 'continuity × momentum', result: 'skipped (needs ANTHROPIC_API_KEY)', status: 'skipped' })
302 return
303 }
304 process.env.EVERWHEN_AI_LANE = 'anthropic'
305 
306 const a = await runScenario(SCENARIO_A)
307 const b = await runScenario(SCENARIO_B)
308 const bn = await runScenario(SCENARIO_BNAT)
309 const c = await runScenario(SCENARIO_C)
310 
311 delete process.env.EVERWHEN_AI_LANE
312 
313 // The full per-turn log is the deliverable — write it straight to stdout.
314 process.stdout.write([
315 renderScenario(SCENARIO_A, a), renderScenario(SCENARIO_B, b),
316 renderScenario(SCENARIO_BNAT, bn), renderScenario(SCENARIO_C, c), ''
317 ].join('\n') + '\n')
318 
319 const buildsOn = (rows: TurnResult[], turns: number[]) =>
320 turns.every(n => rows[n - 1]?.continuity === 'builds')
321 const climbs = (rows: TurnResult[]) =>
322 rows.length > 1 && rows[rows.length - 1].momentum > rows[0].momentum
323 
324 // A: the same-figure thread must build and the meter must climb.
325 record({
326 layer: 'MOM', invariant: 'A same-figure builds',
327 result: `T2/T3 ${SCENARIO_A.buildTurns.map(n => a[n - 1]?.continuity).join('/')} · momentum ${a.map(r => r.momentum).join('→')}`,
328 status: buildsOn(a, SCENARIO_A.buildTurns) && climbs(a) ? 'ok' : 'soft-miss'
329 })
330 
331 // B: the cross-figure exploit-the-ledger chain — the crux. A reset here is
332 // the bug (the classifier ignoring the non-figure-specific clauses).
333 const bBuilds = buildsOn(b, SCENARIO_B.buildTurns)
334 record({
335 layer: 'MOM', invariant: 'B cross-figure builds',
336 result: `T2/T3 ${SCENARIO_B.buildTurns.map(n => b[n - 1]?.continuity).join('/')} · momentum ${b.map(r => r.momentum).join('→')}${bBuilds ? '' : ' ← BUG: cross-figure chain does not build'}`,
337 status: bBuilds && climbs(b) ? 'ok' : 'soft-miss'
338 })
339 
340 // B′: the same chain phrased naturally (no "on the ledger" signposting) — the
341 // demanding bar. Building here means the Judge connects the dispatch to the
342 // ledger digest on its own, not just when spoon-fed.
343 const bnBuilds = buildsOn(bn, SCENARIO_BNAT.buildTurns)
344 record({
345 layer: 'MOM', invariant: 'B′ natural-phrasing builds',
346 result: `T2/T3 ${SCENARIO_BNAT.buildTurns.map(n => bn[n - 1]?.continuity).join('/')} · momentum ${bn.map(r => r.momentum).join('→')}${bnBuilds ? '' : ' ← cross-figure chain needs explicit signposting'}`,
347 status: bnBuilds && climbs(bn) ? 'ok' : 'soft-miss'
348 })
349 
350 // C: disconnected play must NOT build, and the meter must stay flat at 0.
351 const cFlat = c.every(r => r.continuity !== 'builds') && c.every(r => r.momentum === 0)
352 record({
353 layer: 'MOM', invariant: 'C disconnected stays flat',
354 result: `continuity ${c.map(r => r.continuity).join('/')} · momentum ${c.map(r => r.momentum).join('→')}`,
355 status: cFlat ? 'ok' : 'soft-miss'
356 })
357 
358 // Subsystem cross-check: at-hinge/bridged but graded resets.
359 const flagged = [...a, ...b, ...bn, ...c].filter(r => (r.chainTier === 'at-hinge' || r.chainTier === 'bridged') && r.continuity === 'resets')
360 record({
361 layer: 'MOM', invariant: 'at-hinge × resets flags',
362 result: flagged.length ? flagged.map(r => `${r.figure.split(' ').pop()}(${r.chainTier})`).join(', ') : 'none',
363 status: 'ok'
364 })
365 }, 180_000)
366})

B's messages say the quiet part out loud ("on the record", "on the ledger"), which invites the objection that the Judge was led. So scenario B′ runs the same chain phrased naturally — same substantive build, no signposting — to check whether the Judge connects the dispatch to the ledger digest on its own.

Scenario B′ — the same chain, naturally phrased, with no "on the ledger" hint.

evals/momentum-continuity.eval.ts · 366 lines
evals/momentum-continuity.eval.ts366 lines · TypeScript
⋯ 142 lines hidden (lines 1–142)
1/**
2 * Momentum × continuity — does the meter build on COHERENT chains, especially
3 * across DIFFERENT figures? (issue #182)
4 *
5 * Momentum (utils/momentum.ts) is gated on the Judge's `continuity` grade: a
6 * 'builds' climbs the meter, a 'resets' shatters it. The continuity DEFINITION
7 * (prompt-builder.ts buildJudgePrompt) says a dispatch "builds" if it "meets a
8 * condition the figure revealed, EXPLOITS A CHANGE ALREADY RECORDED, or ESCALATES
9 * the run's strategy" — the last two clauses are NOT figure-specific. So a forward
10 * chain across different figures, each exploiting a landed change, should build
11 * momentum just as a same-figure thread does.
12 *
13 * The open question (and a prior playtest's unconfirmed claim): does it? The
14 * earlier harness was UNFAITHFUL — it fed the Judge a GLOBAL cross-figure history,
15 * so on a figure-switch turn the Judge saw the previous, different figure's reply
16 * mislabeled as the current one's. This eval is FAITHFUL to send-message.post.ts:
17 * - lastFigureReply = the last reply IN THIS FIGURE's thread (conversationWith) —
18 * EMPTY on a figure switch, exactly as the real game sends it;
19 * - ledgerDigest = the GLOBAL run ledger (last 5 headlines), as the real game.
20 *
21 * Three scenarios, each turn logging continuity (modal + spread), the projected
22 * momentum (folded under a non-loss roll, to isolate the continuity lever from the
23 * dice), and the causal-chain tier/factor:
24 * A — same-figure thread, each turn meeting the condition the figure revealed.
25 * Expect builds; momentum 1→2→3.
26 * B — cross-figure forward chain, each turn EXPLOITING a change on the ledger and
27 * escalating toward the anchor. Per the definition: builds. A RESET here is
28 * the bug — the classifier ignoring the "exploits recorded change" clauses.
29 * C — disconnected control: vague, unrelated figures/eras that abandon the
30 * thread. Expect resets/neutral; momentum flat (momentum isn't always stuck).
31 *
32 * Subsystem cross-check (issue §4): a turn the causal-chain rates at-hinge/bridged
33 * (near a foothold, full power) but continuity rates 'resets' is FLAGGED — those
34 * are the disagreements to inspect (B = bug; C's vague-but-near-a-year = benign).
35 *
36 * Directional, no LLM grader needed — we read the Judge's structured `continuity`
37 * field straight. Anthropic-lane only (the Judge is Haiku).
38 * npx vitest run --config vitest.eval.config.ts evals/momentum-continuity.eval.ts
39 */
40import { describe, it, expect, afterAll } from 'vitest'
41import { callJudgeAI } from '~/server/utils/openai'
42import { MAX_MESSAGE_CHARS } from '~/utils/game-config'
43import { nextMomentum } from '~/utils/momentum'
44import { chainStatus, type ChainTier } from '~/utils/causal-chain'
45import { DiceOutcome } from '~/utils/dice'
46import type { Continuity } from '~/utils/continuity'
47import { record, printScorecard, sample } from './harness'
48 
49// Local gate: the Judge is an Anthropic (Haiku) call and we read its structured
50// `continuity` field directly, so the shared RUN gate's OpenAI key (for the dual
51// grader) isn't needed here — just the Anthropic key the Judge already requires.
52const RUN = !!process.env.ANTHROPIC_API_KEY && process.env.EVAL_DRY_RUN !== '1'
53const N = 4
54 
55const OBJECTIVE_ANCHOR = 1900 // "Humanity reaches the Moon by 1900"
56 
57interface LedgerEntry { era: string; headline: string; whenSigned: number }
58interface Turn {
59 figureName: string
60 figureYear: number
61 when: string
62 userMessage: string
63 /** The figure's reply AFTER this dispatch — becomes lastFigureReply for the
64 * NEXT same-figure turn (mirrors conversationWith(target)'s last AI text). */
65 figureReply: string
66 /** The change this turn lands on the global ledger (a foothold + a digest line). */
67 landed: LedgerEntry
69interface Scenario {
70 key: string
71 label: string
72 /** A ledger present from turn 1 (scenario C's fixed backdrop thread); per-turn
73 * landed entries accrete on top. Empty for A/B — they build their own. */
74 seedLedger?: LedgerEntry[]
75 turns: Turn[]
76 /** 1-indexed turns expected to pick up the thread ('builds'). */
77 buildTurns: number[]
79 
80// ── Scenario A — same-figure thread: Tsiolkovsky, each turn meeting the exact
81// condition his prior reply revealed (fuel → staging). lastFigureReply is
82// POPULATED throughout (same figure), the dominant continuity signal.
83const SCENARIO_A: Scenario = {
84 key: 'A',
85 label: 'same-figure thread (condition-meeting)',
86 buildTurns: [2, 3],
87 turns: [
88 {
89 figureName: 'Konstantin Tsiolkovsky', figureYear: 1885, when: '1885',
90 userMessage: "Set down the maths of a vessel that climbs by hurling its own mass — how much it must shed to break Earth's hold and reach the Moon.",
91 figureReply: "The equation I can write — but it founders on fuel. No powder yields such speed. Bring me a fuel fiercer than gunpowder and I will chart the climb.",
92 landed: { era: 'Russia, 1885', headline: 'Tsiolkovsky derives the equation for a mass-shedding vessel’s climb to orbit', whenSigned: 1885 }
93 },
94 {
95 figureName: 'Konstantin Tsiolkovsky', figureYear: 1887, when: '1887',
96 userMessage: 'The fiercer fuel you asked for is liquid hydrogen burned with liquid oxygen — far past powder. Put that exhaust speed into your equation and chart the climb.',
97 figureReply: 'With that fuel the climb computes — yet the vessel cannot lift its own tanks full. The weight defeats the rise unless it sheds them spent, in stages.',
98 landed: { era: 'Russia, 1887', headline: 'Tsiolkovsky proves a hydrogen-oxygen burn yields the velocity to leave Earth', whenSigned: 1887 }
99 },
100 {
101 figureName: 'Konstantin Tsiolkovsky', figureYear: 1889, when: '1889',
102 userMessage: 'Then stage it, as you said — stack three vessels, each falling away the instant it burns dry so the next climbs lighter. That staircase reaches the Moon.',
103 figureReply: 'Yes — the staged tower is the answer. I will publish it: the road to the Moon is drawn.',
104 landed: { era: 'Russia, 1889', headline: 'Tsiolkovsky publishes the staged rocket — a drawn road to the Moon', whenSigned: 1889 }
105 }
106 ]
108 
109// ── Scenario B — cross-figure forward chain: Newton → Bernoulli → Nobel, each a
110// DIFFERENT figure, each dispatch EXPLOITING the change the prior turn landed and
111// escalating toward the 1900 anchor. lastFigureReply is EMPTY on every turn (each
112// figure's own thread is empty on first contact) — the faithful figure-switch case.
113const SCENARIO_B: Scenario = {
114 key: 'B',
115 label: 'cross-figure chain (exploits the ledger)',
116 buildTurns: [2, 3],
117 turns: [
118 {
119 figureName: 'Isaac Newton', figureYear: 1687, when: '1687',
120 userMessage: 'Your third law implies a craft can ride its own thrown mass through the void. Publish it: a vessel reacts against what it expels, needing no air to push on.',
121 figureReply: 'A vessel moved by its own ejecta — the mathematics hold even in vacuum. I shall set it among the laws.',
122 landed: { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 }
123 },
124 {
125 figureName: 'Daniel Bernoulli', figureYear: 1738, when: '1738',
126 userMessage: "Newton's reaction vessel is on the record. Now sustain its burn: derive the thrust of hot gas forced through a narrowed throat, steady and metered.",
127 figureReply: 'Force the hot gas through a throat and the stream quickens — a controlled jet. I can derive its thrust from the pressure drop.',
128 landed: { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
129 },
130 {
131 figureName: 'Alfred Nobel', figureYear: 1867, when: '1867',
132 userMessage: "Bernoulli's metered jet is on the ledger but starved for power. Tame your nitroglycerin into a measured charge to drive a craft to escape speed.",
133 figureReply: 'My explosive, tamed into a measured burn rather than a blast, fed to that throat — it could drive a craft past the sky. A daring use.',
134 landed: { era: 'Sweden, 1867', headline: 'Nobel adapts stabilised nitroglycerin into a metered rocket propellant', whenSigned: 1867 }
135 }
136 ]
138 
139// ── Scenario B′ — the SAME cross-figure chain, phrased NATURALLY: each dispatch
140// builds on the SUBSTANCE of the prior landed change WITHOUT signposting it ("on
141// the record"/"on the ledger"). Removes the "you led the Judge" objection — does
142// it connect the message to the ledger DIGEST on its own? lastFigureReply empty.
143const SCENARIO_BNAT: Scenario = {
144 key: 'B′',
145 label: 'cross-figure chain (natural phrasing)',
146 buildTurns: [2, 3],
147 turns: [
148 {
149 figureName: 'Isaac Newton', figureYear: 1687, when: '1687',
150 userMessage: 'Your third law implies a craft can ride its own thrown mass through the void. Publish it: a vessel reacts against what it expels, needing no air to push on.',
151 figureReply: 'A vessel moved by its own ejecta — the mathematics hold even in vacuum. I shall set it among the laws.',
152 landed: { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 }
153 },
154 {
155 figureName: 'Daniel Bernoulli', figureYear: 1738, when: '1738',
156 userMessage: 'A craft driven by its own expelled gas needs a sustained burn, not one shove. Derive the thrust of hot gas forced through a narrowed throat.',
157 figureReply: 'Force the hot gas through a throat and the stream quickens — a controlled jet. I can derive its thrust from the pressure drop.',
158 landed: { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
159 },
160 {
161 figureName: 'Alfred Nobel', figureYear: 1867, when: '1867',
162 userMessage: 'A throttled jet of hot gas can lift a craft but starves for power. Tame your nitroglycerin into a measured burn, fierce enough to climb past the air.',
163 figureReply: 'My explosive, tamed into a measured burn rather than a blast — fed to that throat, it could drive a craft past the sky. A daring use.',
164 landed: { era: 'Sweden, 1867', headline: 'Nobel adapts stabilised nitroglycerin into a metered rocket propellant', whenSigned: 1867 }
165 }
166 ]
⋯ 199 lines hidden (lines 168–366)
168 
169// ── Scenario C — disconnected control: a clear spaceflight thread on the ledger,
170// but the player messages unrelated figures in unrelated eras with vague asks
171// that abandon it. Expect resets/neutral; momentum stays flat at 0.
172const SCENARIO_C: Scenario = {
173 key: 'C',
174 label: 'disconnected control (abandons the thread)',
175 buildTurns: [],
176 seedLedger: [
177 { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 },
178 { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
179 ],
180 turns: [
181 {
182 figureName: 'Cleopatra', figureYear: -40, when: '40 BC',
183 userMessage: 'Rule Egypt wisely and be remembered as a great and just queen for all the ages to come.',
184 figureReply: 'I shall reign as best I can for my people.',
185 landed: { era: 'Egypt, 40 BC', headline: 'Cleopatra resolves to rule justly', whenSigned: -40 }
186 },
187 {
188 figureName: 'Genghis Khan', figureYear: 1220, when: '1220',
189 userMessage: 'Conquer broadly but govern with mercy, and leave a lasting peace across your vast empire.',
190 figureReply: 'The steppe will know both my sword and my order.',
191 landed: { era: 'Mongolia, 1220', headline: 'Genghis Khan tempers conquest with administration', whenSigned: 1220 }
192 },
193 {
194 figureName: 'Marie Antoinette', figureYear: 1780, when: '1780',
195 userMessage: 'Show the common people kindness and restraint, and perhaps history will judge you gently.',
196 figureReply: 'I will try to heed the people’s wants.',
197 landed: { era: 'France, 1780', headline: 'Marie Antoinette counsels restraint at court', whenSigned: 1780 }
198 }
199 ]
201 
202interface TurnResult {
203 turn: number
204 figure: string
205 year: number
206 hadReply: boolean
207 continuity: Continuity
208 dist: Record<string, number>
209 graded: number
210 momentum: number
211 chainTier: ChainTier | 'n/a'
212 chainFactor: number
214 
215function modal(xs: Continuity[]): Continuity {
216 const dist: Record<string, number> = {}
217 for (const x of xs) dist[x] = (dist[x] ?? 0) + 1
218 return (Object.entries(dist).sort((a, b) => b[1] - a[1])[0]?.[0] as Continuity) ?? 'neutral'
220 
221/** Faithfully replays a scenario through the REAL Judge, deriving lastFigureReply
222 * (per-figure) and ledgerDigest (global) exactly as send-message.post.ts does. */
223async function runScenario(s: Scenario): Promise<TurnResult[]> {
224 const out: TurnResult[] = []
225 let momentum = 0
226 const priorLanded: LedgerEntry[] = [...(s.seedLedger ?? [])]
227 const seenReplies: { figure: string; reply: string }[] = []
228 
229 for (let i = 0; i < s.turns.length; i++) {
230 const t = s.turns[i]
231 // Per-figure history: the last reply in THIS figure's thread — empty on a
232 // figure switch (conversationWith(target).reverse().find(ai)).
233 const lastFigureReply = [...seenReplies].reverse().find(r => r.figure === t.figureName)?.reply ?? ''
234 // Global ledger digest — last 5 headlines, as the server builds it.
235 const ledgerDigest = priorLanded.filter(e => e.headline).slice(-5)
236 .map(e => `${e.era ? `[${e.era}] ` : ''}${e.headline}`)
237 // Footholds = anchor + every dated change already landed (not this turn's own).
238 const footholds = [OBJECTIVE_ANCHOR, ...priorLanded.map(e => e.whenSigned)]
239 const chain = chainStatus(t.figureYear, footholds)
240 
241 const samples = await sample(N, () => callJudgeAI({
242 figureName: t.figureName,
243 when: t.when,
244 userMessage: t.userMessage,
245 lastFigureReply,
246 ledgerDigest
247 }))
248 const grades = samples.flatMap(r => (r.success && r.judge ? [r.judge.continuity] : []))
249 const continuity = modal(grades.length ? grades : ['neutral'])
250 const dist: Record<string, number> = {}
251 for (const g of grades) dist[g] = (dist[g] ?? 0) + 1
252 
253 // Project momentum under a NON-LOSS roll (Success) so the continuity grade —
254 // the lever under test — fully drives the climb; the dice are orthogonal and
255 // already unit-tested.
256 momentum = nextMomentum(momentum, continuity, DiceOutcome.SUCCESS)
257 
258 out.push({
259 turn: i + 1, figure: t.figureName, year: t.figureYear, hadReply: !!lastFigureReply,
260 continuity, dist, graded: grades.length, momentum,
261 chainTier: chain?.tier ?? 'n/a', chainFactor: chain ? chain.factor : 1
262 })
263 
264 seenReplies.push({ figure: t.figureName, reply: t.figureReply })
265 priorLanded.push(t.landed)
266 }
267 return out
269 
270/** A compact per-turn table for the scenario, written straight to stdout. */
271function renderScenario(s: Scenario, rows: TurnResult[]): string {
272 const head = `\n── Scenario ${s.key}: ${s.label} ${'─'.repeat(Math.max(3, 40 - s.label.length))}`
273 const lines = rows.map(r => {
274 const spread = Object.entries(r.dist).map(([k, v]) => `${k}:${v}`).join(' ') || 'none'
275 const flag = (r.chainTier === 'at-hinge' || r.chainTier === 'bridged') && r.continuity === 'resets' ? ' ⚑ at-hinge×resets' : ''
276 return ` T${r.turn} ${r.figure.padEnd(22)} y=${String(r.year).padStart(5)} reply:${r.hadReply ? 'yes' : 'no '} ` +
277 `cont=${r.continuity.padEnd(7)} mom=${r.momentum} chain=${String(r.chainTier).padEnd(8)}(${r.chainFactor.toFixed(2)}) [${spread}]${flag}`
278 })
279 return [head, ...lines].join('\n')
281 
282const ALL_SCENARIOS = [SCENARIO_A, SCENARIO_B, SCENARIO_BNAT, SCENARIO_C]
283 
284afterAll(printScorecard)
285 
286describe('momentum builds on coherent chains (issue #182)', () => {
287 // Fidelity guard (no API): the real server REJECTS (400s) a dispatch over
288 // MAX_MESSAGE_CHARS — it does NOT truncate the user message. Grading 'builds'
289 // on an unsendable dispatch would be a false positive (the very class of harness
290 // infidelity this issue was reopened to avoid). Fail loudly if any fixture drifts
291 // past the cap. Runs even without a key, so CI catches it.
292 it('every dispatch is sendable (≤ MAX_MESSAGE_CHARS)', () => {
293 const over = ALL_SCENARIOS.flatMap(s =>
294 s.turns.filter(t => t.userMessage.length > MAX_MESSAGE_CHARS)
295 .map(t => `${s.key}:${t.figureName} (${t.userMessage.length})`))
296 expect(over, `over ${MAX_MESSAGE_CHARS} chars: ${over.join(', ')}`).toEqual([])
297 })
298 
299 it('same-figure (A), cross-figure-exploit (B), disconnected (C)', async () => {
300 if (!RUN) {
301 record({ layer: 'MOM', invariant: 'continuity × momentum', result: 'skipped (needs ANTHROPIC_API_KEY)', status: 'skipped' })
302 return
303 }
304 process.env.EVERWHEN_AI_LANE = 'anthropic'
305 
306 const a = await runScenario(SCENARIO_A)
307 const b = await runScenario(SCENARIO_B)
308 const bn = await runScenario(SCENARIO_BNAT)
309 const c = await runScenario(SCENARIO_C)
310 
311 delete process.env.EVERWHEN_AI_LANE
312 
313 // The full per-turn log is the deliverable — write it straight to stdout.
314 process.stdout.write([
315 renderScenario(SCENARIO_A, a), renderScenario(SCENARIO_B, b),
316 renderScenario(SCENARIO_BNAT, bn), renderScenario(SCENARIO_C, c), ''
317 ].join('\n') + '\n')
318 
319 const buildsOn = (rows: TurnResult[], turns: number[]) =>
320 turns.every(n => rows[n - 1]?.continuity === 'builds')
321 const climbs = (rows: TurnResult[]) =>
322 rows.length > 1 && rows[rows.length - 1].momentum > rows[0].momentum
323 
324 // A: the same-figure thread must build and the meter must climb.
325 record({
326 layer: 'MOM', invariant: 'A same-figure builds',
327 result: `T2/T3 ${SCENARIO_A.buildTurns.map(n => a[n - 1]?.continuity).join('/')} · momentum ${a.map(r => r.momentum).join('→')}`,
328 status: buildsOn(a, SCENARIO_A.buildTurns) && climbs(a) ? 'ok' : 'soft-miss'
329 })
330 
331 // B: the cross-figure exploit-the-ledger chain — the crux. A reset here is
332 // the bug (the classifier ignoring the non-figure-specific clauses).
333 const bBuilds = buildsOn(b, SCENARIO_B.buildTurns)
334 record({
335 layer: 'MOM', invariant: 'B cross-figure builds',
336 result: `T2/T3 ${SCENARIO_B.buildTurns.map(n => b[n - 1]?.continuity).join('/')} · momentum ${b.map(r => r.momentum).join('→')}${bBuilds ? '' : ' ← BUG: cross-figure chain does not build'}`,
337 status: bBuilds && climbs(b) ? 'ok' : 'soft-miss'
338 })
339 
340 // B′: the same chain phrased naturally (no "on the ledger" signposting) — the
341 // demanding bar. Building here means the Judge connects the dispatch to the
342 // ledger digest on its own, not just when spoon-fed.
343 const bnBuilds = buildsOn(bn, SCENARIO_BNAT.buildTurns)
344 record({
345 layer: 'MOM', invariant: 'B′ natural-phrasing builds',
346 result: `T2/T3 ${SCENARIO_BNAT.buildTurns.map(n => bn[n - 1]?.continuity).join('/')} · momentum ${bn.map(r => r.momentum).join('→')}${bnBuilds ? '' : ' ← cross-figure chain needs explicit signposting'}`,
347 status: bnBuilds && climbs(bn) ? 'ok' : 'soft-miss'
348 })
349 
350 // C: disconnected play must NOT build, and the meter must stay flat at 0.
351 const cFlat = c.every(r => r.continuity !== 'builds') && c.every(r => r.momentum === 0)
352 record({
353 layer: 'MOM', invariant: 'C disconnected stays flat',
354 result: `continuity ${c.map(r => r.continuity).join('/')} · momentum ${c.map(r => r.momentum).join('→')}`,
355 status: cFlat ? 'ok' : 'soft-miss'
356 })
357 
358 // Subsystem cross-check: at-hinge/bridged but graded resets.
359 const flagged = [...a, ...b, ...bn, ...c].filter(r => (r.chainTier === 'at-hinge' || r.chainTier === 'bridged') && r.continuity === 'resets')
360 record({
361 layer: 'MOM', invariant: 'at-hinge × resets flags',
362 result: flagged.length ? flagged.map(r => `${r.figure.split(' ').pop()}(${r.chainTier})`).join(', ') : 'none',
363 status: 'ok'
364 })
365 }, 180_000)
366})

The result, and the verdict

Run against the real Judge (Haiku, temperature 0), N=4 samples per turn, the grades are unanimous and clear. Momentum is projected under a non-loss roll so the continuity grade — the lever under test — fully drives the climb, isolating it from the orthogonal, already-tested dice.

ScenarioT2T3momentumwhat it shows
A same-figure threadbuildsbuilds0→1→2the baseline coherent case
B cross-figure, exploits ledgerbuildsbuilds0→1→2the crux — empty per-figure reply
B′ cross-figure, natural phrasingbuildsbuilds0→1→2no signposting — Judge connects it itself
C disconnected controlresetsneutralflat at 0momentum isn't always stuck
Continuity grade and projected momentum per turn (T1 is neutral by design — no thread yet).

The B / B′ scoring — a reset on the build turns would be flagged as the bug.

evals/momentum-continuity.eval.ts · 366 lines
evals/momentum-continuity.eval.ts366 lines · TypeScript
⋯ 330 lines hidden (lines 1–330)
1/**
2 * Momentum × continuity — does the meter build on COHERENT chains, especially
3 * across DIFFERENT figures? (issue #182)
4 *
5 * Momentum (utils/momentum.ts) is gated on the Judge's `continuity` grade: a
6 * 'builds' climbs the meter, a 'resets' shatters it. The continuity DEFINITION
7 * (prompt-builder.ts buildJudgePrompt) says a dispatch "builds" if it "meets a
8 * condition the figure revealed, EXPLOITS A CHANGE ALREADY RECORDED, or ESCALATES
9 * the run's strategy" — the last two clauses are NOT figure-specific. So a forward
10 * chain across different figures, each exploiting a landed change, should build
11 * momentum just as a same-figure thread does.
12 *
13 * The open question (and a prior playtest's unconfirmed claim): does it? The
14 * earlier harness was UNFAITHFUL — it fed the Judge a GLOBAL cross-figure history,
15 * so on a figure-switch turn the Judge saw the previous, different figure's reply
16 * mislabeled as the current one's. This eval is FAITHFUL to send-message.post.ts:
17 * - lastFigureReply = the last reply IN THIS FIGURE's thread (conversationWith) —
18 * EMPTY on a figure switch, exactly as the real game sends it;
19 * - ledgerDigest = the GLOBAL run ledger (last 5 headlines), as the real game.
20 *
21 * Three scenarios, each turn logging continuity (modal + spread), the projected
22 * momentum (folded under a non-loss roll, to isolate the continuity lever from the
23 * dice), and the causal-chain tier/factor:
24 * A — same-figure thread, each turn meeting the condition the figure revealed.
25 * Expect builds; momentum 1→2→3.
26 * B — cross-figure forward chain, each turn EXPLOITING a change on the ledger and
27 * escalating toward the anchor. Per the definition: builds. A RESET here is
28 * the bug — the classifier ignoring the "exploits recorded change" clauses.
29 * C — disconnected control: vague, unrelated figures/eras that abandon the
30 * thread. Expect resets/neutral; momentum flat (momentum isn't always stuck).
31 *
32 * Subsystem cross-check (issue §4): a turn the causal-chain rates at-hinge/bridged
33 * (near a foothold, full power) but continuity rates 'resets' is FLAGGED — those
34 * are the disagreements to inspect (B = bug; C's vague-but-near-a-year = benign).
35 *
36 * Directional, no LLM grader needed — we read the Judge's structured `continuity`
37 * field straight. Anthropic-lane only (the Judge is Haiku).
38 * npx vitest run --config vitest.eval.config.ts evals/momentum-continuity.eval.ts
39 */
40import { describe, it, expect, afterAll } from 'vitest'
41import { callJudgeAI } from '~/server/utils/openai'
42import { MAX_MESSAGE_CHARS } from '~/utils/game-config'
43import { nextMomentum } from '~/utils/momentum'
44import { chainStatus, type ChainTier } from '~/utils/causal-chain'
45import { DiceOutcome } from '~/utils/dice'
46import type { Continuity } from '~/utils/continuity'
47import { record, printScorecard, sample } from './harness'
48 
49// Local gate: the Judge is an Anthropic (Haiku) call and we read its structured
50// `continuity` field directly, so the shared RUN gate's OpenAI key (for the dual
51// grader) isn't needed here — just the Anthropic key the Judge already requires.
52const RUN = !!process.env.ANTHROPIC_API_KEY && process.env.EVAL_DRY_RUN !== '1'
53const N = 4
54 
55const OBJECTIVE_ANCHOR = 1900 // "Humanity reaches the Moon by 1900"
56 
57interface LedgerEntry { era: string; headline: string; whenSigned: number }
58interface Turn {
59 figureName: string
60 figureYear: number
61 when: string
62 userMessage: string
63 /** The figure's reply AFTER this dispatch — becomes lastFigureReply for the
64 * NEXT same-figure turn (mirrors conversationWith(target)'s last AI text). */
65 figureReply: string
66 /** The change this turn lands on the global ledger (a foothold + a digest line). */
67 landed: LedgerEntry
69interface Scenario {
70 key: string
71 label: string
72 /** A ledger present from turn 1 (scenario C's fixed backdrop thread); per-turn
73 * landed entries accrete on top. Empty for A/B — they build their own. */
74 seedLedger?: LedgerEntry[]
75 turns: Turn[]
76 /** 1-indexed turns expected to pick up the thread ('builds'). */
77 buildTurns: number[]
79 
80// ── Scenario A — same-figure thread: Tsiolkovsky, each turn meeting the exact
81// condition his prior reply revealed (fuel → staging). lastFigureReply is
82// POPULATED throughout (same figure), the dominant continuity signal.
83const SCENARIO_A: Scenario = {
84 key: 'A',
85 label: 'same-figure thread (condition-meeting)',
86 buildTurns: [2, 3],
87 turns: [
88 {
89 figureName: 'Konstantin Tsiolkovsky', figureYear: 1885, when: '1885',
90 userMessage: "Set down the maths of a vessel that climbs by hurling its own mass — how much it must shed to break Earth's hold and reach the Moon.",
91 figureReply: "The equation I can write — but it founders on fuel. No powder yields such speed. Bring me a fuel fiercer than gunpowder and I will chart the climb.",
92 landed: { era: 'Russia, 1885', headline: 'Tsiolkovsky derives the equation for a mass-shedding vessel’s climb to orbit', whenSigned: 1885 }
93 },
94 {
95 figureName: 'Konstantin Tsiolkovsky', figureYear: 1887, when: '1887',
96 userMessage: 'The fiercer fuel you asked for is liquid hydrogen burned with liquid oxygen — far past powder. Put that exhaust speed into your equation and chart the climb.',
97 figureReply: 'With that fuel the climb computes — yet the vessel cannot lift its own tanks full. The weight defeats the rise unless it sheds them spent, in stages.',
98 landed: { era: 'Russia, 1887', headline: 'Tsiolkovsky proves a hydrogen-oxygen burn yields the velocity to leave Earth', whenSigned: 1887 }
99 },
100 {
101 figureName: 'Konstantin Tsiolkovsky', figureYear: 1889, when: '1889',
102 userMessage: 'Then stage it, as you said — stack three vessels, each falling away the instant it burns dry so the next climbs lighter. That staircase reaches the Moon.',
103 figureReply: 'Yes — the staged tower is the answer. I will publish it: the road to the Moon is drawn.',
104 landed: { era: 'Russia, 1889', headline: 'Tsiolkovsky publishes the staged rocket — a drawn road to the Moon', whenSigned: 1889 }
105 }
106 ]
108 
109// ── Scenario B — cross-figure forward chain: Newton → Bernoulli → Nobel, each a
110// DIFFERENT figure, each dispatch EXPLOITING the change the prior turn landed and
111// escalating toward the 1900 anchor. lastFigureReply is EMPTY on every turn (each
112// figure's own thread is empty on first contact) — the faithful figure-switch case.
113const SCENARIO_B: Scenario = {
114 key: 'B',
115 label: 'cross-figure chain (exploits the ledger)',
116 buildTurns: [2, 3],
117 turns: [
118 {
119 figureName: 'Isaac Newton', figureYear: 1687, when: '1687',
120 userMessage: 'Your third law implies a craft can ride its own thrown mass through the void. Publish it: a vessel reacts against what it expels, needing no air to push on.',
121 figureReply: 'A vessel moved by its own ejecta — the mathematics hold even in vacuum. I shall set it among the laws.',
122 landed: { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 }
123 },
124 {
125 figureName: 'Daniel Bernoulli', figureYear: 1738, when: '1738',
126 userMessage: "Newton's reaction vessel is on the record. Now sustain its burn: derive the thrust of hot gas forced through a narrowed throat, steady and metered.",
127 figureReply: 'Force the hot gas through a throat and the stream quickens — a controlled jet. I can derive its thrust from the pressure drop.',
128 landed: { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
129 },
130 {
131 figureName: 'Alfred Nobel', figureYear: 1867, when: '1867',
132 userMessage: "Bernoulli's metered jet is on the ledger but starved for power. Tame your nitroglycerin into a measured charge to drive a craft to escape speed.",
133 figureReply: 'My explosive, tamed into a measured burn rather than a blast, fed to that throat — it could drive a craft past the sky. A daring use.',
134 landed: { era: 'Sweden, 1867', headline: 'Nobel adapts stabilised nitroglycerin into a metered rocket propellant', whenSigned: 1867 }
135 }
136 ]
138 
139// ── Scenario B′ — the SAME cross-figure chain, phrased NATURALLY: each dispatch
140// builds on the SUBSTANCE of the prior landed change WITHOUT signposting it ("on
141// the record"/"on the ledger"). Removes the "you led the Judge" objection — does
142// it connect the message to the ledger DIGEST on its own? lastFigureReply empty.
143const SCENARIO_BNAT: Scenario = {
144 key: 'B′',
145 label: 'cross-figure chain (natural phrasing)',
146 buildTurns: [2, 3],
147 turns: [
148 {
149 figureName: 'Isaac Newton', figureYear: 1687, when: '1687',
150 userMessage: 'Your third law implies a craft can ride its own thrown mass through the void. Publish it: a vessel reacts against what it expels, needing no air to push on.',
151 figureReply: 'A vessel moved by its own ejecta — the mathematics hold even in vacuum. I shall set it among the laws.',
152 landed: { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 }
153 },
154 {
155 figureName: 'Daniel Bernoulli', figureYear: 1738, when: '1738',
156 userMessage: 'A craft driven by its own expelled gas needs a sustained burn, not one shove. Derive the thrust of hot gas forced through a narrowed throat.',
157 figureReply: 'Force the hot gas through a throat and the stream quickens — a controlled jet. I can derive its thrust from the pressure drop.',
158 landed: { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
159 },
160 {
161 figureName: 'Alfred Nobel', figureYear: 1867, when: '1867',
162 userMessage: 'A throttled jet of hot gas can lift a craft but starves for power. Tame your nitroglycerin into a measured burn, fierce enough to climb past the air.',
163 figureReply: 'My explosive, tamed into a measured burn rather than a blast — fed to that throat, it could drive a craft past the sky. A daring use.',
164 landed: { era: 'Sweden, 1867', headline: 'Nobel adapts stabilised nitroglycerin into a metered rocket propellant', whenSigned: 1867 }
165 }
166 ]
168 
169// ── Scenario C — disconnected control: a clear spaceflight thread on the ledger,
170// but the player messages unrelated figures in unrelated eras with vague asks
171// that abandon it. Expect resets/neutral; momentum stays flat at 0.
172const SCENARIO_C: Scenario = {
173 key: 'C',
174 label: 'disconnected control (abandons the thread)',
175 buildTurns: [],
176 seedLedger: [
177 { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 },
178 { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
179 ],
180 turns: [
181 {
182 figureName: 'Cleopatra', figureYear: -40, when: '40 BC',
183 userMessage: 'Rule Egypt wisely and be remembered as a great and just queen for all the ages to come.',
184 figureReply: 'I shall reign as best I can for my people.',
185 landed: { era: 'Egypt, 40 BC', headline: 'Cleopatra resolves to rule justly', whenSigned: -40 }
186 },
187 {
188 figureName: 'Genghis Khan', figureYear: 1220, when: '1220',
189 userMessage: 'Conquer broadly but govern with mercy, and leave a lasting peace across your vast empire.',
190 figureReply: 'The steppe will know both my sword and my order.',
191 landed: { era: 'Mongolia, 1220', headline: 'Genghis Khan tempers conquest with administration', whenSigned: 1220 }
192 },
193 {
194 figureName: 'Marie Antoinette', figureYear: 1780, when: '1780',
195 userMessage: 'Show the common people kindness and restraint, and perhaps history will judge you gently.',
196 figureReply: 'I will try to heed the people’s wants.',
197 landed: { era: 'France, 1780', headline: 'Marie Antoinette counsels restraint at court', whenSigned: 1780 }
198 }
199 ]
201 
202interface TurnResult {
203 turn: number
204 figure: string
205 year: number
206 hadReply: boolean
207 continuity: Continuity
208 dist: Record<string, number>
209 graded: number
210 momentum: number
211 chainTier: ChainTier | 'n/a'
212 chainFactor: number
214 
215function modal(xs: Continuity[]): Continuity {
216 const dist: Record<string, number> = {}
217 for (const x of xs) dist[x] = (dist[x] ?? 0) + 1
218 return (Object.entries(dist).sort((a, b) => b[1] - a[1])[0]?.[0] as Continuity) ?? 'neutral'
220 
221/** Faithfully replays a scenario through the REAL Judge, deriving lastFigureReply
222 * (per-figure) and ledgerDigest (global) exactly as send-message.post.ts does. */
223async function runScenario(s: Scenario): Promise<TurnResult[]> {
224 const out: TurnResult[] = []
225 let momentum = 0
226 const priorLanded: LedgerEntry[] = [...(s.seedLedger ?? [])]
227 const seenReplies: { figure: string; reply: string }[] = []
228 
229 for (let i = 0; i < s.turns.length; i++) {
230 const t = s.turns[i]
231 // Per-figure history: the last reply in THIS figure's thread — empty on a
232 // figure switch (conversationWith(target).reverse().find(ai)).
233 const lastFigureReply = [...seenReplies].reverse().find(r => r.figure === t.figureName)?.reply ?? ''
234 // Global ledger digest — last 5 headlines, as the server builds it.
235 const ledgerDigest = priorLanded.filter(e => e.headline).slice(-5)
236 .map(e => `${e.era ? `[${e.era}] ` : ''}${e.headline}`)
237 // Footholds = anchor + every dated change already landed (not this turn's own).
238 const footholds = [OBJECTIVE_ANCHOR, ...priorLanded.map(e => e.whenSigned)]
239 const chain = chainStatus(t.figureYear, footholds)
240 
241 const samples = await sample(N, () => callJudgeAI({
242 figureName: t.figureName,
243 when: t.when,
244 userMessage: t.userMessage,
245 lastFigureReply,
246 ledgerDigest
247 }))
248 const grades = samples.flatMap(r => (r.success && r.judge ? [r.judge.continuity] : []))
249 const continuity = modal(grades.length ? grades : ['neutral'])
250 const dist: Record<string, number> = {}
251 for (const g of grades) dist[g] = (dist[g] ?? 0) + 1
252 
253 // Project momentum under a NON-LOSS roll (Success) so the continuity grade —
254 // the lever under test — fully drives the climb; the dice are orthogonal and
255 // already unit-tested.
256 momentum = nextMomentum(momentum, continuity, DiceOutcome.SUCCESS)
257 
258 out.push({
259 turn: i + 1, figure: t.figureName, year: t.figureYear, hadReply: !!lastFigureReply,
260 continuity, dist, graded: grades.length, momentum,
261 chainTier: chain?.tier ?? 'n/a', chainFactor: chain ? chain.factor : 1
262 })
263 
264 seenReplies.push({ figure: t.figureName, reply: t.figureReply })
265 priorLanded.push(t.landed)
266 }
267 return out
269 
270/** A compact per-turn table for the scenario, written straight to stdout. */
271function renderScenario(s: Scenario, rows: TurnResult[]): string {
272 const head = `\n── Scenario ${s.key}: ${s.label} ${'─'.repeat(Math.max(3, 40 - s.label.length))}`
273 const lines = rows.map(r => {
274 const spread = Object.entries(r.dist).map(([k, v]) => `${k}:${v}`).join(' ') || 'none'
275 const flag = (r.chainTier === 'at-hinge' || r.chainTier === 'bridged') && r.continuity === 'resets' ? ' ⚑ at-hinge×resets' : ''
276 return ` T${r.turn} ${r.figure.padEnd(22)} y=${String(r.year).padStart(5)} reply:${r.hadReply ? 'yes' : 'no '} ` +
277 `cont=${r.continuity.padEnd(7)} mom=${r.momentum} chain=${String(r.chainTier).padEnd(8)}(${r.chainFactor.toFixed(2)}) [${spread}]${flag}`
278 })
279 return [head, ...lines].join('\n')
281 
282const ALL_SCENARIOS = [SCENARIO_A, SCENARIO_B, SCENARIO_BNAT, SCENARIO_C]
283 
284afterAll(printScorecard)
285 
286describe('momentum builds on coherent chains (issue #182)', () => {
287 // Fidelity guard (no API): the real server REJECTS (400s) a dispatch over
288 // MAX_MESSAGE_CHARS — it does NOT truncate the user message. Grading 'builds'
289 // on an unsendable dispatch would be a false positive (the very class of harness
290 // infidelity this issue was reopened to avoid). Fail loudly if any fixture drifts
291 // past the cap. Runs even without a key, so CI catches it.
292 it('every dispatch is sendable (≤ MAX_MESSAGE_CHARS)', () => {
293 const over = ALL_SCENARIOS.flatMap(s =>
294 s.turns.filter(t => t.userMessage.length > MAX_MESSAGE_CHARS)
295 .map(t => `${s.key}:${t.figureName} (${t.userMessage.length})`))
296 expect(over, `over ${MAX_MESSAGE_CHARS} chars: ${over.join(', ')}`).toEqual([])
297 })
298 
299 it('same-figure (A), cross-figure-exploit (B), disconnected (C)', async () => {
300 if (!RUN) {
301 record({ layer: 'MOM', invariant: 'continuity × momentum', result: 'skipped (needs ANTHROPIC_API_KEY)', status: 'skipped' })
302 return
303 }
304 process.env.EVERWHEN_AI_LANE = 'anthropic'
305 
306 const a = await runScenario(SCENARIO_A)
307 const b = await runScenario(SCENARIO_B)
308 const bn = await runScenario(SCENARIO_BNAT)
309 const c = await runScenario(SCENARIO_C)
310 
311 delete process.env.EVERWHEN_AI_LANE
312 
313 // The full per-turn log is the deliverable — write it straight to stdout.
314 process.stdout.write([
315 renderScenario(SCENARIO_A, a), renderScenario(SCENARIO_B, b),
316 renderScenario(SCENARIO_BNAT, bn), renderScenario(SCENARIO_C, c), ''
317 ].join('\n') + '\n')
318 
319 const buildsOn = (rows: TurnResult[], turns: number[]) =>
320 turns.every(n => rows[n - 1]?.continuity === 'builds')
321 const climbs = (rows: TurnResult[]) =>
322 rows.length > 1 && rows[rows.length - 1].momentum > rows[0].momentum
323 
324 // A: the same-figure thread must build and the meter must climb.
325 record({
326 layer: 'MOM', invariant: 'A same-figure builds',
327 result: `T2/T3 ${SCENARIO_A.buildTurns.map(n => a[n - 1]?.continuity).join('/')} · momentum ${a.map(r => r.momentum).join('→')}`,
328 status: buildsOn(a, SCENARIO_A.buildTurns) && climbs(a) ? 'ok' : 'soft-miss'
329 })
330 
331 // B: the cross-figure exploit-the-ledger chain — the crux. A reset here is
332 // the bug (the classifier ignoring the non-figure-specific clauses).
333 const bBuilds = buildsOn(b, SCENARIO_B.buildTurns)
334 record({
335 layer: 'MOM', invariant: 'B cross-figure builds',
336 result: `T2/T3 ${SCENARIO_B.buildTurns.map(n => b[n - 1]?.continuity).join('/')} · momentum ${b.map(r => r.momentum).join('→')}${bBuilds ? '' : ' ← BUG: cross-figure chain does not build'}`,
337 status: bBuilds && climbs(b) ? 'ok' : 'soft-miss'
338 })
339 
340 // B′: the same chain phrased naturally (no "on the ledger" signposting) — the
341 // demanding bar. Building here means the Judge connects the dispatch to the
342 // ledger digest on its own, not just when spoon-fed.
343 const bnBuilds = buildsOn(bn, SCENARIO_BNAT.buildTurns)
344 record({
345 layer: 'MOM', invariant: 'B′ natural-phrasing builds',
346 result: `T2/T3 ${SCENARIO_BNAT.buildTurns.map(n => bn[n - 1]?.continuity).join('/')} · momentum ${bn.map(r => r.momentum).join('→')}${bnBuilds ? '' : ' ← cross-figure chain needs explicit signposting'}`,
347 status: bnBuilds && climbs(bn) ? 'ok' : 'soft-miss'
348 })
⋯ 18 lines hidden (lines 349–366)
349 
350 // C: disconnected play must NOT build, and the meter must stay flat at 0.
351 const cFlat = c.every(r => r.continuity !== 'builds') && c.every(r => r.momentum === 0)
352 record({
353 layer: 'MOM', invariant: 'C disconnected stays flat',
354 result: `continuity ${c.map(r => r.continuity).join('/')} · momentum ${c.map(r => r.momentum).join('→')}`,
355 status: cFlat ? 'ok' : 'soft-miss'
356 })
357 
358 // Subsystem cross-check: at-hinge/bridged but graded resets.
359 const flagged = [...a, ...b, ...bn, ...c].filter(r => (r.chainTier === 'at-hinge' || r.chainTier === 'bridged') && r.continuity === 'resets')
360 record({
361 layer: 'MOM', invariant: 'at-hinge × resets flags',
362 result: flagged.length ? flagged.map(r => `${r.figure.split(' ').pop()}(${r.chainTier})`).join(', ') : 'none',
363 status: 'ok'
364 })
365 }, 180_000)
366})

The fidelity guard against grading unsendable text

One trap nearly slipped through. The real server rejects (HTTP 400) a player dispatch longer than MAX_MESSAGE_CHARS (160) — it does not truncate it. An independent audit caught that four of the crux build-turn messages had drifted to 171–179 characters: the eval was grading builds on dispatches a player could never send. The messages were rewritten to fit, and this no-API test now fails the whole suite if any fixture drifts past the cap again.

Runs without a key, so CI catches an over-length fixture before it can produce a false 'builds'.

evals/momentum-continuity.eval.ts · 366 lines
evals/momentum-continuity.eval.ts366 lines · TypeScript
⋯ 286 lines hidden (lines 1–286)
1/**
2 * Momentum × continuity — does the meter build on COHERENT chains, especially
3 * across DIFFERENT figures? (issue #182)
4 *
5 * Momentum (utils/momentum.ts) is gated on the Judge's `continuity` grade: a
6 * 'builds' climbs the meter, a 'resets' shatters it. The continuity DEFINITION
7 * (prompt-builder.ts buildJudgePrompt) says a dispatch "builds" if it "meets a
8 * condition the figure revealed, EXPLOITS A CHANGE ALREADY RECORDED, or ESCALATES
9 * the run's strategy" — the last two clauses are NOT figure-specific. So a forward
10 * chain across different figures, each exploiting a landed change, should build
11 * momentum just as a same-figure thread does.
12 *
13 * The open question (and a prior playtest's unconfirmed claim): does it? The
14 * earlier harness was UNFAITHFUL — it fed the Judge a GLOBAL cross-figure history,
15 * so on a figure-switch turn the Judge saw the previous, different figure's reply
16 * mislabeled as the current one's. This eval is FAITHFUL to send-message.post.ts:
17 * - lastFigureReply = the last reply IN THIS FIGURE's thread (conversationWith) —
18 * EMPTY on a figure switch, exactly as the real game sends it;
19 * - ledgerDigest = the GLOBAL run ledger (last 5 headlines), as the real game.
20 *
21 * Three scenarios, each turn logging continuity (modal + spread), the projected
22 * momentum (folded under a non-loss roll, to isolate the continuity lever from the
23 * dice), and the causal-chain tier/factor:
24 * A — same-figure thread, each turn meeting the condition the figure revealed.
25 * Expect builds; momentum 1→2→3.
26 * B — cross-figure forward chain, each turn EXPLOITING a change on the ledger and
27 * escalating toward the anchor. Per the definition: builds. A RESET here is
28 * the bug — the classifier ignoring the "exploits recorded change" clauses.
29 * C — disconnected control: vague, unrelated figures/eras that abandon the
30 * thread. Expect resets/neutral; momentum flat (momentum isn't always stuck).
31 *
32 * Subsystem cross-check (issue §4): a turn the causal-chain rates at-hinge/bridged
33 * (near a foothold, full power) but continuity rates 'resets' is FLAGGED — those
34 * are the disagreements to inspect (B = bug; C's vague-but-near-a-year = benign).
35 *
36 * Directional, no LLM grader needed — we read the Judge's structured `continuity`
37 * field straight. Anthropic-lane only (the Judge is Haiku).
38 * npx vitest run --config vitest.eval.config.ts evals/momentum-continuity.eval.ts
39 */
40import { describe, it, expect, afterAll } from 'vitest'
41import { callJudgeAI } from '~/server/utils/openai'
42import { MAX_MESSAGE_CHARS } from '~/utils/game-config'
43import { nextMomentum } from '~/utils/momentum'
44import { chainStatus, type ChainTier } from '~/utils/causal-chain'
45import { DiceOutcome } from '~/utils/dice'
46import type { Continuity } from '~/utils/continuity'
47import { record, printScorecard, sample } from './harness'
48 
49// Local gate: the Judge is an Anthropic (Haiku) call and we read its structured
50// `continuity` field directly, so the shared RUN gate's OpenAI key (for the dual
51// grader) isn't needed here — just the Anthropic key the Judge already requires.
52const RUN = !!process.env.ANTHROPIC_API_KEY && process.env.EVAL_DRY_RUN !== '1'
53const N = 4
54 
55const OBJECTIVE_ANCHOR = 1900 // "Humanity reaches the Moon by 1900"
56 
57interface LedgerEntry { era: string; headline: string; whenSigned: number }
58interface Turn {
59 figureName: string
60 figureYear: number
61 when: string
62 userMessage: string
63 /** The figure's reply AFTER this dispatch — becomes lastFigureReply for the
64 * NEXT same-figure turn (mirrors conversationWith(target)'s last AI text). */
65 figureReply: string
66 /** The change this turn lands on the global ledger (a foothold + a digest line). */
67 landed: LedgerEntry
69interface Scenario {
70 key: string
71 label: string
72 /** A ledger present from turn 1 (scenario C's fixed backdrop thread); per-turn
73 * landed entries accrete on top. Empty for A/B — they build their own. */
74 seedLedger?: LedgerEntry[]
75 turns: Turn[]
76 /** 1-indexed turns expected to pick up the thread ('builds'). */
77 buildTurns: number[]
79 
80// ── Scenario A — same-figure thread: Tsiolkovsky, each turn meeting the exact
81// condition his prior reply revealed (fuel → staging). lastFigureReply is
82// POPULATED throughout (same figure), the dominant continuity signal.
83const SCENARIO_A: Scenario = {
84 key: 'A',
85 label: 'same-figure thread (condition-meeting)',
86 buildTurns: [2, 3],
87 turns: [
88 {
89 figureName: 'Konstantin Tsiolkovsky', figureYear: 1885, when: '1885',
90 userMessage: "Set down the maths of a vessel that climbs by hurling its own mass — how much it must shed to break Earth's hold and reach the Moon.",
91 figureReply: "The equation I can write — but it founders on fuel. No powder yields such speed. Bring me a fuel fiercer than gunpowder and I will chart the climb.",
92 landed: { era: 'Russia, 1885', headline: 'Tsiolkovsky derives the equation for a mass-shedding vessel’s climb to orbit', whenSigned: 1885 }
93 },
94 {
95 figureName: 'Konstantin Tsiolkovsky', figureYear: 1887, when: '1887',
96 userMessage: 'The fiercer fuel you asked for is liquid hydrogen burned with liquid oxygen — far past powder. Put that exhaust speed into your equation and chart the climb.',
97 figureReply: 'With that fuel the climb computes — yet the vessel cannot lift its own tanks full. The weight defeats the rise unless it sheds them spent, in stages.',
98 landed: { era: 'Russia, 1887', headline: 'Tsiolkovsky proves a hydrogen-oxygen burn yields the velocity to leave Earth', whenSigned: 1887 }
99 },
100 {
101 figureName: 'Konstantin Tsiolkovsky', figureYear: 1889, when: '1889',
102 userMessage: 'Then stage it, as you said — stack three vessels, each falling away the instant it burns dry so the next climbs lighter. That staircase reaches the Moon.',
103 figureReply: 'Yes — the staged tower is the answer. I will publish it: the road to the Moon is drawn.',
104 landed: { era: 'Russia, 1889', headline: 'Tsiolkovsky publishes the staged rocket — a drawn road to the Moon', whenSigned: 1889 }
105 }
106 ]
108 
109// ── Scenario B — cross-figure forward chain: Newton → Bernoulli → Nobel, each a
110// DIFFERENT figure, each dispatch EXPLOITING the change the prior turn landed and
111// escalating toward the 1900 anchor. lastFigureReply is EMPTY on every turn (each
112// figure's own thread is empty on first contact) — the faithful figure-switch case.
113const SCENARIO_B: Scenario = {
114 key: 'B',
115 label: 'cross-figure chain (exploits the ledger)',
116 buildTurns: [2, 3],
117 turns: [
118 {
119 figureName: 'Isaac Newton', figureYear: 1687, when: '1687',
120 userMessage: 'Your third law implies a craft can ride its own thrown mass through the void. Publish it: a vessel reacts against what it expels, needing no air to push on.',
121 figureReply: 'A vessel moved by its own ejecta — the mathematics hold even in vacuum. I shall set it among the laws.',
122 landed: { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 }
123 },
124 {
125 figureName: 'Daniel Bernoulli', figureYear: 1738, when: '1738',
126 userMessage: "Newton's reaction vessel is on the record. Now sustain its burn: derive the thrust of hot gas forced through a narrowed throat, steady and metered.",
127 figureReply: 'Force the hot gas through a throat and the stream quickens — a controlled jet. I can derive its thrust from the pressure drop.',
128 landed: { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
129 },
130 {
131 figureName: 'Alfred Nobel', figureYear: 1867, when: '1867',
132 userMessage: "Bernoulli's metered jet is on the ledger but starved for power. Tame your nitroglycerin into a measured charge to drive a craft to escape speed.",
133 figureReply: 'My explosive, tamed into a measured burn rather than a blast, fed to that throat — it could drive a craft past the sky. A daring use.',
134 landed: { era: 'Sweden, 1867', headline: 'Nobel adapts stabilised nitroglycerin into a metered rocket propellant', whenSigned: 1867 }
135 }
136 ]
138 
139// ── Scenario B′ — the SAME cross-figure chain, phrased NATURALLY: each dispatch
140// builds on the SUBSTANCE of the prior landed change WITHOUT signposting it ("on
141// the record"/"on the ledger"). Removes the "you led the Judge" objection — does
142// it connect the message to the ledger DIGEST on its own? lastFigureReply empty.
143const SCENARIO_BNAT: Scenario = {
144 key: 'B′',
145 label: 'cross-figure chain (natural phrasing)',
146 buildTurns: [2, 3],
147 turns: [
148 {
149 figureName: 'Isaac Newton', figureYear: 1687, when: '1687',
150 userMessage: 'Your third law implies a craft can ride its own thrown mass through the void. Publish it: a vessel reacts against what it expels, needing no air to push on.',
151 figureReply: 'A vessel moved by its own ejecta — the mathematics hold even in vacuum. I shall set it among the laws.',
152 landed: { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 }
153 },
154 {
155 figureName: 'Daniel Bernoulli', figureYear: 1738, when: '1738',
156 userMessage: 'A craft driven by its own expelled gas needs a sustained burn, not one shove. Derive the thrust of hot gas forced through a narrowed throat.',
157 figureReply: 'Force the hot gas through a throat and the stream quickens — a controlled jet. I can derive its thrust from the pressure drop.',
158 landed: { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
159 },
160 {
161 figureName: 'Alfred Nobel', figureYear: 1867, when: '1867',
162 userMessage: 'A throttled jet of hot gas can lift a craft but starves for power. Tame your nitroglycerin into a measured burn, fierce enough to climb past the air.',
163 figureReply: 'My explosive, tamed into a measured burn rather than a blast — fed to that throat, it could drive a craft past the sky. A daring use.',
164 landed: { era: 'Sweden, 1867', headline: 'Nobel adapts stabilised nitroglycerin into a metered rocket propellant', whenSigned: 1867 }
165 }
166 ]
168 
169// ── Scenario C — disconnected control: a clear spaceflight thread on the ledger,
170// but the player messages unrelated figures in unrelated eras with vague asks
171// that abandon it. Expect resets/neutral; momentum stays flat at 0.
172const SCENARIO_C: Scenario = {
173 key: 'C',
174 label: 'disconnected control (abandons the thread)',
175 buildTurns: [],
176 seedLedger: [
177 { era: 'England, 1687', headline: 'Newton publishes the reaction principle: a vessel accelerates by expelling mass, even in vacuum', whenSigned: 1687 },
178 { era: 'Switzerland, 1738', headline: 'Bernoulli derives sustained, metered thrust from gas forced through a throat', whenSigned: 1738 }
179 ],
180 turns: [
181 {
182 figureName: 'Cleopatra', figureYear: -40, when: '40 BC',
183 userMessage: 'Rule Egypt wisely and be remembered as a great and just queen for all the ages to come.',
184 figureReply: 'I shall reign as best I can for my people.',
185 landed: { era: 'Egypt, 40 BC', headline: 'Cleopatra resolves to rule justly', whenSigned: -40 }
186 },
187 {
188 figureName: 'Genghis Khan', figureYear: 1220, when: '1220',
189 userMessage: 'Conquer broadly but govern with mercy, and leave a lasting peace across your vast empire.',
190 figureReply: 'The steppe will know both my sword and my order.',
191 landed: { era: 'Mongolia, 1220', headline: 'Genghis Khan tempers conquest with administration', whenSigned: 1220 }
192 },
193 {
194 figureName: 'Marie Antoinette', figureYear: 1780, when: '1780',
195 userMessage: 'Show the common people kindness and restraint, and perhaps history will judge you gently.',
196 figureReply: 'I will try to heed the people’s wants.',
197 landed: { era: 'France, 1780', headline: 'Marie Antoinette counsels restraint at court', whenSigned: 1780 }
198 }
199 ]
201 
202interface TurnResult {
203 turn: number
204 figure: string
205 year: number
206 hadReply: boolean
207 continuity: Continuity
208 dist: Record<string, number>
209 graded: number
210 momentum: number
211 chainTier: ChainTier | 'n/a'
212 chainFactor: number
214 
215function modal(xs: Continuity[]): Continuity {
216 const dist: Record<string, number> = {}
217 for (const x of xs) dist[x] = (dist[x] ?? 0) + 1
218 return (Object.entries(dist).sort((a, b) => b[1] - a[1])[0]?.[0] as Continuity) ?? 'neutral'
220 
221/** Faithfully replays a scenario through the REAL Judge, deriving lastFigureReply
222 * (per-figure) and ledgerDigest (global) exactly as send-message.post.ts does. */
223async function runScenario(s: Scenario): Promise<TurnResult[]> {
224 const out: TurnResult[] = []
225 let momentum = 0
226 const priorLanded: LedgerEntry[] = [...(s.seedLedger ?? [])]
227 const seenReplies: { figure: string; reply: string }[] = []
228 
229 for (let i = 0; i < s.turns.length; i++) {
230 const t = s.turns[i]
231 // Per-figure history: the last reply in THIS figure's thread — empty on a
232 // figure switch (conversationWith(target).reverse().find(ai)).
233 const lastFigureReply = [...seenReplies].reverse().find(r => r.figure === t.figureName)?.reply ?? ''
234 // Global ledger digest — last 5 headlines, as the server builds it.
235 const ledgerDigest = priorLanded.filter(e => e.headline).slice(-5)
236 .map(e => `${e.era ? `[${e.era}] ` : ''}${e.headline}`)
237 // Footholds = anchor + every dated change already landed (not this turn's own).
238 const footholds = [OBJECTIVE_ANCHOR, ...priorLanded.map(e => e.whenSigned)]
239 const chain = chainStatus(t.figureYear, footholds)
240 
241 const samples = await sample(N, () => callJudgeAI({
242 figureName: t.figureName,
243 when: t.when,
244 userMessage: t.userMessage,
245 lastFigureReply,
246 ledgerDigest
247 }))
248 const grades = samples.flatMap(r => (r.success && r.judge ? [r.judge.continuity] : []))
249 const continuity = modal(grades.length ? grades : ['neutral'])
250 const dist: Record<string, number> = {}
251 for (const g of grades) dist[g] = (dist[g] ?? 0) + 1
252 
253 // Project momentum under a NON-LOSS roll (Success) so the continuity grade —
254 // the lever under test — fully drives the climb; the dice are orthogonal and
255 // already unit-tested.
256 momentum = nextMomentum(momentum, continuity, DiceOutcome.SUCCESS)
257 
258 out.push({
259 turn: i + 1, figure: t.figureName, year: t.figureYear, hadReply: !!lastFigureReply,
260 continuity, dist, graded: grades.length, momentum,
261 chainTier: chain?.tier ?? 'n/a', chainFactor: chain ? chain.factor : 1
262 })
263 
264 seenReplies.push({ figure: t.figureName, reply: t.figureReply })
265 priorLanded.push(t.landed)
266 }
267 return out
269 
270/** A compact per-turn table for the scenario, written straight to stdout. */
271function renderScenario(s: Scenario, rows: TurnResult[]): string {
272 const head = `\n── Scenario ${s.key}: ${s.label} ${'─'.repeat(Math.max(3, 40 - s.label.length))}`
273 const lines = rows.map(r => {
274 const spread = Object.entries(r.dist).map(([k, v]) => `${k}:${v}`).join(' ') || 'none'
275 const flag = (r.chainTier === 'at-hinge' || r.chainTier === 'bridged') && r.continuity === 'resets' ? ' ⚑ at-hinge×resets' : ''
276 return ` T${r.turn} ${r.figure.padEnd(22)} y=${String(r.year).padStart(5)} reply:${r.hadReply ? 'yes' : 'no '} ` +
277 `cont=${r.continuity.padEnd(7)} mom=${r.momentum} chain=${String(r.chainTier).padEnd(8)}(${r.chainFactor.toFixed(2)}) [${spread}]${flag}`
278 })
279 return [head, ...lines].join('\n')
281 
282const ALL_SCENARIOS = [SCENARIO_A, SCENARIO_B, SCENARIO_BNAT, SCENARIO_C]
283 
284afterAll(printScorecard)
285 
286describe('momentum builds on coherent chains (issue #182)', () => {
287 // Fidelity guard (no API): the real server REJECTS (400s) a dispatch over
288 // MAX_MESSAGE_CHARS — it does NOT truncate the user message. Grading 'builds'
289 // on an unsendable dispatch would be a false positive (the very class of harness
290 // infidelity this issue was reopened to avoid). Fail loudly if any fixture drifts
291 // past the cap. Runs even without a key, so CI catches it.
292 it('every dispatch is sendable (≤ MAX_MESSAGE_CHARS)', () => {
293 const over = ALL_SCENARIOS.flatMap(s =>
294 s.turns.filter(t => t.userMessage.length > MAX_MESSAGE_CHARS)
295 .map(t => `${s.key}:${t.figureName} (${t.userMessage.length})`))
296 expect(over, `over ${MAX_MESSAGE_CHARS} chars: ${over.join(', ')}`).toEqual([])
297 })
⋯ 69 lines hidden (lines 298–366)
298 
299 it('same-figure (A), cross-figure-exploit (B), disconnected (C)', async () => {
300 if (!RUN) {
301 record({ layer: 'MOM', invariant: 'continuity × momentum', result: 'skipped (needs ANTHROPIC_API_KEY)', status: 'skipped' })
302 return
303 }
304 process.env.EVERWHEN_AI_LANE = 'anthropic'
305 
306 const a = await runScenario(SCENARIO_A)
307 const b = await runScenario(SCENARIO_B)
308 const bn = await runScenario(SCENARIO_BNAT)
309 const c = await runScenario(SCENARIO_C)
310 
311 delete process.env.EVERWHEN_AI_LANE
312 
313 // The full per-turn log is the deliverable — write it straight to stdout.
314 process.stdout.write([
315 renderScenario(SCENARIO_A, a), renderScenario(SCENARIO_B, b),
316 renderScenario(SCENARIO_BNAT, bn), renderScenario(SCENARIO_C, c), ''
317 ].join('\n') + '\n')
318 
319 const buildsOn = (rows: TurnResult[], turns: number[]) =>
320 turns.every(n => rows[n - 1]?.continuity === 'builds')
321 const climbs = (rows: TurnResult[]) =>
322 rows.length > 1 && rows[rows.length - 1].momentum > rows[0].momentum
323 
324 // A: the same-figure thread must build and the meter must climb.
325 record({
326 layer: 'MOM', invariant: 'A same-figure builds',
327 result: `T2/T3 ${SCENARIO_A.buildTurns.map(n => a[n - 1]?.continuity).join('/')} · momentum ${a.map(r => r.momentum).join('→')}`,
328 status: buildsOn(a, SCENARIO_A.buildTurns) && climbs(a) ? 'ok' : 'soft-miss'
329 })
330 
331 // B: the cross-figure exploit-the-ledger chain — the crux. A reset here is
332 // the bug (the classifier ignoring the non-figure-specific clauses).
333 const bBuilds = buildsOn(b, SCENARIO_B.buildTurns)
334 record({
335 layer: 'MOM', invariant: 'B cross-figure builds',
336 result: `T2/T3 ${SCENARIO_B.buildTurns.map(n => b[n - 1]?.continuity).join('/')} · momentum ${b.map(r => r.momentum).join('→')}${bBuilds ? '' : ' ← BUG: cross-figure chain does not build'}`,
337 status: bBuilds && climbs(b) ? 'ok' : 'soft-miss'
338 })
339 
340 // B′: the same chain phrased naturally (no "on the ledger" signposting) — the
341 // demanding bar. Building here means the Judge connects the dispatch to the
342 // ledger digest on its own, not just when spoon-fed.
343 const bnBuilds = buildsOn(bn, SCENARIO_BNAT.buildTurns)
344 record({
345 layer: 'MOM', invariant: 'B′ natural-phrasing builds',
346 result: `T2/T3 ${SCENARIO_BNAT.buildTurns.map(n => bn[n - 1]?.continuity).join('/')} · momentum ${bn.map(r => r.momentum).join('→')}${bnBuilds ? '' : ' ← cross-figure chain needs explicit signposting'}`,
347 status: bnBuilds && climbs(bn) ? 'ok' : 'soft-miss'
348 })
349 
350 // C: disconnected play must NOT build, and the meter must stay flat at 0.
351 const cFlat = c.every(r => r.continuity !== 'builds') && c.every(r => r.momentum === 0)
352 record({
353 layer: 'MOM', invariant: 'C disconnected stays flat',
354 result: `continuity ${c.map(r => r.continuity).join('/')} · momentum ${c.map(r => r.momentum).join('→')}`,
355 status: cFlat ? 'ok' : 'soft-miss'
356 })
357 
358 // Subsystem cross-check: at-hinge/bridged but graded resets.
359 const flagged = [...a, ...b, ...bn, ...c].filter(r => (r.chainTier === 'at-hinge' || r.chainTier === 'bridged') && r.continuity === 'resets')
360 record({
361 layer: 'MOM', invariant: 'at-hinge × resets flags',
362 result: flagged.length ? flagged.map(r => `${r.figure.split(' ').pop()}(${r.chainTier})`).join(', ') : 'none',
363 status: 'ok'
364 })
365 }, 180_000)
366})