Files
timmy-talking-turd/scripts/record_release_demo.mjs
timmy 179f8fe6e9
Quality gates / quality (pull_request) Successful in 13m25s
feat(eval): score candidates against the release gates on held-out contributors (#29)
The ROADMAP gates (macro-F1 >= 0.80, precision@coverage >= 0.90, ECE <= 0.05) were prose with no
tool behind them. scripts/calibration_report.py now computes them from prediction records and
refuses input it cannot honestly score:

- a split is re-derived from the pseudonymous subject key with the ingestion rule, so a corpus
  cannot re-label its own splits; a contributor spanning two splits is refused before scoring
- the threshold is selected on validation only, and selecting on test or train is refused
- coverage, precision-at-coverage, macro-F1, per-class precision/recall, confusion, and expected
  calibration error are computed on accepted suggestions, with classes that abstained away named
  in excludedClasses instead of being scored as a silent zero
- exit codes carry the verdict: 0 GO, 3 BLOCKED, 2 refused

The release demo records the real harness against committed synthetic fixtures: a blocked
three-candidate comparison, a fixture engineered to clear every gate, and a leaked corpus refused.
Every number on the card is read back from the report the run wrote, and the captions state that
no model was measured, because a fixture is not evidence about accuracy.

The harness suite is wired into CI, check:syntax, and the release builder's own gates.
2026-10-25 11:05:28 +00:00

1091 lines
66 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { chromium } from 'playwright';
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import os from 'node:os';
import { promisify } from 'node:util';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { analyzePhoto, visionFailureResponse } from '../src/vision-service.js';
import { loadVisionCorpus } from '../src/vision-corpus.js';
const execFileAsync = promisify(execFile);
const output = path.resolve(process.argv[2] || 'release-demo.webm');
const version = process.env.TIMMY_RELEASE_VERSION || 'review build';
// The release delta is supplied by the builder so the recording states, on camera, what actually
// changed in this release instead of implying a feature that did not ship.
const releaseDelta = process.env.TIMMY_RELEASE_DEMO_DELTA
|| 'Verified rebuild of the current user-visible feature path — no new feature is claimed.';
const captionsOut = process.env.TIMMY_DEMO_CAPTIONS_OUT || '';
const captionsShown = [];
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
await mkdir(path.dirname(output), { recursive: true });
// The refusal scene replays committed synthetic corpus cases through the real analysis code path,
// so the recording shows the status and copy the server would actually send rather than copy
// written for the video. No real photo is used anywhere in the corpus.
const corpus = await loadVisionCorpus(fileURLToPath(new URL('../tests/fixtures/vision-corpus/', import.meta.url)));
const corpusCase = id => corpus.cases.find(entry => entry.id === id);
async function corpusAnalyzeResult(entry) {
const fetchImpl = async () => ({ ok: true, json: async () => ({ choices: [{ message: { content: entry.providerContent } }] }) });
try {
const result = await analyzePhoto({
payload: { imageDataUrl: 'data:image/png;base64,YQ==', consent: true },
fetchImpl,
config: { baseUrl: 'http://127.0.0.1:9/v1', apiKey: 'corpus-replay', model: 'corpus-model' },
});
return { status: 200, body: result };
} catch (error) {
const failure = visionFailureResponse(error);
return { status: failure.status, body: failure.body };
}
}
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 405, height: 720 },
deviceScaleFactor: 1,
serviceWorkers: 'block',
recordVideo: { dir: path.dirname(output), size: { width: 405, height: 720 } },
colorScheme: 'light',
});
const page = await context.newPage();
const errors = [];
// Delete-all asks for confirmation; the recording accepts it so the resulting state is shown, not implied.
page.on('dialog', dialog => dialog.accept());
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
page.on('pageerror', error => errors.push(error.message));
let visionOffline = false;
// Reachable is not loaded: the worker can answer /v1/models before its pinned model is resident,
// and the recording shows the app withholding AI in exactly that state (issue #15).
let modelLoaded = true;
await page.route('**/api/vision-status', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
enabled: true,
profile: 'selfhost',
processor: 'self-hosted',
model: 'SmolVLM2-2.2B-Instruct',
// The second pass flips the app's own readiness contract so no AI control is offered at all.
providerReady: !visionOffline,
modelSeen: !visionOffline && modelLoaded,
}),
}));
// The happy path keeps one deterministic suggestion. The refusal scene swaps this for a real
// corpus replay, so both the usable and the refused answers come from the same code path.
const suggestionBody = {
status: 'suggestion',
isStool: true,
bristolType: 4,
color: 'brown',
confidence: 0.83,
imageQuality: 'good',
observations: 'Smooth, formed appearance.',
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
};
let analyzeOverride = null;
await page.route('**/api/analyze', route => {
const response = analyzeOverride || { status: 200, body: suggestionBody };
return route.fulfill({ status: response.status, contentType: 'application/json', body: JSON.stringify(response.body) });
});
let hermesCalls = 0;
// Issue #37 turns the agent on and off on camera: local Timmy mode is a real app state, and the
// grounded local answer has to be shown as its own surface rather than implied.
let agentConnected = true;
await page.route('**/api/agent/status', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
enabled: true,
// Local Timmy mode is the no-backend state the app really uses: nothing configured, nothing sent.
configured: agentConnected,
authenticated: agentConnected,
mode: agentConnected ? 'hermes-agent' : 'local-fallback',
}),
}));
await page.route('**/api/agent/chat', route => {
hermesCalls += 1;
return route.fulfill({
status: 200,
contentType: 'application/json',
// The refs are the positional handles the server gives the agent; the browser resolves them
// against the same ledger it sent, so this reply is a real citation contract, not a prop.
body: JSON.stringify({ connected: true, reply: 'You have 4 confirmed logs in the last 28 days [entry:L1] [entry:L2] [entry:L3] [entry:L4]. Your confirmed logs are mostly Type 4. I can explain that pattern, but I cannot diagnose a cause.' }),
});
});
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
const captionEvidence = [];
// Pixel proof that a caption actually painted: if the caption band looks identical with and without
// the node, the caption did not reach the screen even though the string was pushed.
async function captionBand() {
return page.screenshot({ clip: { x: 0, y: 0, width: 405, height: 68 } });
}
async function caption(text, duration = 1400) {
captionsShown.push(text);
const blankBand = await captionBand().catch(() => null);
await page.evaluate(text => {
document.querySelector('#demo-caption')?.remove();
const node = document.createElement('div');
node.id = 'demo-caption';
node.textContent = text;
// Inline styles, not a stylesheet: a stylesheet belongs to one document and the demo reloads the
// page, which silently stripped caption styling and left every later caption clipped body text.
node.style.cssText = 'position:fixed;left:16px;right:16px;top:14px;z-index:20000;'
+ 'background:rgba(40,33,29,.94);color:#fff;padding:11px 14px;border-radius:14px;'
+ 'font:800 12px/1.25 system-ui;letter-spacing:.02em;text-align:center;'
+ 'box-shadow:0 8px 24px rgba(0,0,0,.2)';
document.body.append(node);
node.animate([{ opacity: 0, transform: 'translateY(-8px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 260, fill: 'forwards' });
}, text);
await sleep(320);
// Structural proof: a fixed bar at the top of the viewport, on top of whatever is beneath it, fully
// inside the frame. A caption that lost its stylesheet sits unstyled at the end of the body instead.
const painted = await page.evaluate(() => {
const node = document.querySelector('#demo-caption');
if (!node) return { ok: false, reason: 'caption node missing' };
const rect = node.getBoundingClientRect();
const style = getComputedStyle(node);
const topmost = document.elementFromPoint(Math.round(rect.left + rect.width / 2), Math.round(rect.top + rect.height / 2));
return {
ok: style.position === 'fixed' && style.zIndex === '20000' && rect.top <= 40 && rect.height >= 24
&& rect.width > 200 && rect.bottom <= window.innerHeight && (topmost === node || (topmost && node.contains(topmost))),
top: Math.round(rect.top), height: Math.round(rect.height), position: style.position,
zIndex: style.zIndex, background: style.backgroundColor, topmost: topmost === node,
};
});
const shownBand = await captionBand().catch(() => null);
const bandChanged = Boolean(blankBand && shownBand && !blankBand.equals(shownBand));
if (!painted.ok) throw new Error(`Caption is not an on-screen bar: ${text} ${JSON.stringify(painted)}`);
if (!bandChanged) throw new Error(`Caption painted nothing into the caption band: ${text}`);
captionEvidence.push({ text, visible: true, position: painted.position, top: painted.top, height: painted.height, bandChanged });
await sleep(duration);
// A caption must not outlive its stated duration. It used to stay on screen until the next one
// replaced it, so a view change left the previous screen's claim hanging over a different screen —
// e.g. a delete-data caption sitting on top of the home view.
await page.evaluate(() => {
const node = document.querySelector('#demo-caption');
if (!node) return;
node.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 180, fill: 'forwards' });
setTimeout(() => node.remove(), 220);
});
await sleep(220);
}
async function tap(selector, after = 650) {
const target = page.locator(selector).first();
await target.scrollIntoViewIfNeeded();
const box = await target.boundingBox();
if (!box) throw new Error(`Missing demo target: ${selector}`);
await page.evaluate(({ x, y }) => {
document.querySelector('#demo-touch')?.remove();
const ring = document.createElement('div');
ring.id = 'demo-touch';
ring.style.cssText = 'position:fixed;z-index:20001;width:48px;height:48px;border:4px solid #fff;'
+ 'background:rgba(245,201,91,.55);box-shadow:0 0 0 7px rgba(21,125,120,.28);border-radius:50%;'
+ 'pointer-events:none;transform:translate(-50%,-50%)';
ring.style.left = `${x}px`;
ring.style.top = `${y}px`;
document.body.append(ring);
ring.animate([{ opacity: .2, transform: 'translate(-50%,-50%) scale(.55)' }, { opacity: 1, transform: 'translate(-50%,-50%) scale(1)' }], { duration: 400 });
setTimeout(() => ring.remove(), 550);
}, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
await sleep(220);
await target.click();
await sleep(after);
}
async function indicate(selector) {
const target = page.locator(selector).first();
await target.scrollIntoViewIfNeeded();
const box = await target.boundingBox();
if (!box) throw new Error(`Missing demo target: ${selector}`);
await page.evaluate(({ x, y }) => {
document.querySelector('#demo-touch')?.remove();
const ring = document.createElement('div');
ring.id = 'demo-touch';
ring.style.cssText = 'position:fixed;z-index:20001;width:48px;height:48px;border:4px solid #fff;'
+ 'background:rgba(245,201,91,.55);box-shadow:0 0 0 7px rgba(21,125,120,.28);border-radius:50%;'
+ 'pointer-events:none;transform:translate(-50%,-50%)';
ring.style.left = `${x}px`;
ring.style.top = `${y}px`;
document.body.append(ring);
ring.animate([{ opacity: .2, transform: 'translate(-50%,-50%) scale(.55)' }, { opacity: 1, transform: 'translate(-50%,-50%) scale(1)' }], { duration: 400 });
setTimeout(() => ring.remove(), 550);
}, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
await sleep(650);
}
await caption(`TIMMY ${version} • RELEASE DEMO`, 1200);
await caption(`This release: ${releaseDelta}`, 2600);
await caption('Automated checks replay this synthetic path before review', 1200);
await caption('One clear photo action. Manual logging stays one tap away.', 1500);
await tap('[data-scan]', 450);
await page.getByText(/Self-hosted model ready/i).waitFor();
await caption('The pinned bootstrap verifies both model files before starting on private loopback', 1500);
await indicate('label[for="camera-photo"]');
await page.locator('#camera-photo').dispatchEvent('cancel');
await page.getByText(/Camera or photo picker closed/i).waitFor();
await caption('Camera closed cleanly — gallery and manual logging are still available', 1600);
await indicate('label[for="gallery-photo"]');
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await caption('Nothing uploads until explicit consent', 1100);
await page.locator('#ai-consent').check();
await tap('#analyze-photo', 450);
await page.getByText(/83% confidence/i).waitFor();
await caption('AI may suggest visible form, broad color, and image quality — never symptoms or diagnosis', 1700);
await indicate('.scan-result[data-provenance="pending"]');
await caption('The suggestion stays provisional — nothing is applied or saved until you confirm it', 1700);
await tap('#use-suggestion', 450);
await page.locator('.ai-provenance[data-provenance="confirmed"]').waitFor();
await caption('Confirming is explicit, and every suggested value is still yours to overrule', 1600);
await tap('[data-type="2"]', 500);
await page.locator('[data-provenance="corrected"]').waitFor();
await caption('Change a suggested value and Timmy clears the AI-confirmed state', 1700);
await indicate('.ai-provenance[data-provenance="corrected"] .provenance-note');
await tap('#next', 450);
await page.getByText(/You changed it to Type 2/).waitFor();
await caption('Only visible form and color were ever suggested — urgency, discomfort, and symptoms stay yours', 1900);
await tap('#next', 450);
await page.locator('[data-followup-block]').waitFor();
await caption('Type 2 is hard or lumpy, so Timmy asks about soreness, hydration, blood, and black-or-tarry — never guessed from the photo', 2600);
await page.locator('[data-question="bloodVisible"]').scrollIntoViewIfNeeded();
await indicate('[data-question="bloodVisible"]');
await tap('[data-followup="bloodVisible"][data-answer="no"]', 400);
await caption('Every answer is yours to give, and a skipped question stays unanswered', 1900);
await tap('[data-followup="soreness"][data-answer="yes"]', 350);
await tap('[data-followup="dehydration"][data-answer="yes"]', 350);
await page.locator('#followup-guidance').waitFor();
await caption('Mild report: steady water, no straining, gentle care — general self-care, not a diagnosis', 2600);
await indicate('#followup-guidance');
await caption('And it never claims the photo ruled out bleeding', 1900);
await tap('[data-followup="tarryBlack"][data-answer="yes"]', 600);
await page.locator('#urgent-box .alert').waitFor();
await caption('Say it was truly black and tarry and the same deterministic red-flag path fires instead', 2600);
await tap('[data-followup="tarryBlack"][data-answer="no"]', 500);
await page.locator('#urgent-box .alert').waitFor({ state: 'detached' });
await caption('Dark brown is not silently escalated, and the flag clears the moment the answer changes', 2200);
await tap('#close-sheet', 450);
await caption('Closing the log keeps the draft in this session instead of throwing it away', 1600);
await caption('Now the same photo flow while the vision worker is offline', 1500);
visionOffline = true;
await tap('[data-scan]', 550);
await page.getByText(/Vision worker offline/i).waitFor();
await caption('Vision worker offline — the AI and consent steps disappear instead of failing after upload', 1900);
await indicate('label[for="gallery-photo"]');
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await page.getByText(/Ready for your review/i).waitFor();
await caption('No consent prompt and no upload when analysis cannot run', 1600);
await tap('#manual-from-scan', 500);
const keptDraft = await page.locator('.draft-resume').innerText({ timeout: 5000 });
if (!/Type 2/.test(keptDraft)) throw new Error('The retained draft must show the value the user corrected to');
await caption('One tap still reaches manual Bristol entry, and the draft keeps your corrected value', 1800);
await tap('#discard-draft', 500);
await caption('Start over clears the kept draft — nothing lingers silently', 1500);
await tap('#close-sheet', 450);
// Post-load readiness pass (issue #15): the worker answers, but its pinned model is not resident
// yet. The app must withhold AI instead of offering a control that fails after the upload. Every
// claim below is asserted in the browser, so the recording cannot show a state the app does not have.
modelLoaded = false;
visionOffline = false;
await caption('Now the same worker while its pinned model is still loading', 1700);
await tap('[data-scan]', 550);
await page.getByText(/model is not loaded yet/i).first().waitFor();
if (await page.getByText(/Self-hosted model ready/i).count() !== 0) {
throw new Error('A reachable worker without its loaded model must never be reported ready');
}
const loadingAmber = await page.locator('.model-status.loading').first().evaluate(el => getComputedStyle(el).backgroundColor);
if (loadingAmber !== 'rgb(253, 243, 216)') throw new Error('The not-loaded state must be visually distinct from ready and offline');
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await page.getByText(/Ready for your review/i).waitFor();
if (await page.locator('#analyze-photo').count() !== 0) throw new Error('No AI control may exist before the model is loaded');
if (await page.locator('.consent-card').count() !== 0) throw new Error('No consent may be requested before the model is loaded');
await caption('Reachable is not loaded: no AI control, no consent, and no upload until the pinned model is resident', 2900);
await tap('#manual-from-scan', 500);
await page.getByText('Pick the closest form').waitFor();
await caption('One tap still reaches manual Bristol entry while the model finishes loading', 2000);
await tap('#close-sheet', 450);
modelLoaded = true;
await caption('Once the pinned model is loaded, the same screen offers the AI analysis again', 2200);
await tap('[data-scan]', 550);
await page.getByText(/Self-hosted model ready/i).waitFor();
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
await page.getByText(/Ready for your review/i).waitFor();
if (await page.locator('.consent-card').isVisible() !== true) throw new Error('The AI path must return once the model is loaded');
if (await page.locator('#analyze-photo').count() !== 1) throw new Error('The analyze control must return once the model is loaded');
await tap('#close-sheet', 450);
// Corpus refusal pass (issue #18): the same photo flow, fed the answers Timmy has to refuse.
visionOffline = false;
await caption('The corpus also replays the answers Timmy has to refuse', 1700);
analyzeOverride = await corpusAnalyzeResult(corpusCase('non-stool-scene'));
await tap('[data-scan]', 500);
await page.getByText(/Self-hosted model ready/i).waitFor();
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/vision-corpus/non-stool-scene.png');
await page.locator('#ai-consent').check();
await tap('#analyze-photo', 600);
await page.getByText(/No confident match/i).waitFor();
await caption('A non-stool photo gets a refusal, never a guess — manual entry stays one tap away', 2600);
await indicate('.scan-result');
analyzeOverride = await corpusAnalyzeResult(corpusCase('glare-overexposed'));
await tap('#retake-photo', 400);
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/vision-corpus/glare-overexposed.png');
await page.locator('#ai-consent').check();
await tap('#analyze-photo', 600);
await page.getByText(/does not show a form Timmy can read safely/i).waitFor();
await caption('Unreadable visual fields abstain as well: a valid reply with nothing to prefill is not an error', 2700);
await indicate('.scan-result');
analyzeOverride = await corpusAnalyzeResult(corpusCase('out-of-range-fields'));
await tap('#retake-photo', 400);
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/vision-corpus/out-of-range-fields.png');
await page.locator('#ai-consent').check();
await tap('#analyze-photo', 600);
await page.getByText(/couldn’t analyze that safely/i).waitFor();
await caption('An out-of-range answer fails closed: nothing is applied and no provider detail reaches you', 2700);
await indicate('.scan-result');
// Issue #73's escape class: one field blank, the other filled in and impossible. The old blank-first
// ordering abstained here, which told the user the photo was unreadable while the model had returned a
// value Timmy must never apply. The scene is asserted in the browser, not just captioned.
analyzeOverride = await corpusAnalyzeResult(corpusCase('blank-plus-out-of-range'));
await tap('#retake-photo', 400);
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/vision-corpus/blank-plus-out-of-range.png');
await page.locator('#ai-consent').check();
await tap('#analyze-photo', 600);
await page.getByText(/couldn’t analyze that safely/i).waitFor();
if (await page.getByText(/No confident match/i).count() !== 0) {
throw new Error('A filled-in out-of-range field must not be shown as an unreadable-photo abstention');
}
await caption('A filled-in field that is out of range still fails closed, even when the other field came back blank', 2900);
await indicate('.scan-result');
await caption('You are no longer told the photo was unreadable when the model returned an impossible value', 2400);
await indicate('.scan-result');
await caption('Every refusal replays a committed synthetic fixture — there are no real photos in the corpus', 2500);
analyzeOverride = null;
await tap('#close-sheet', 450);
// Explainable pattern summary (issue #34). The recording seeds fictional multi-day sample logs into
// the app's own local ledger contract, then follows one summary line to the exact records behind it.
// Every claim below is checked in the browser, and no real health data is involved anywhere.
const seedSyntheticLedger = async entries => {
const key = await page.evaluate(() => `timmy:${document.querySelector('meta[name="timmy-base-path"]')?.content || '/'}:ledger-v1`);
await page.evaluate(({ storeKey, list }) => localStorage.setItem(storeKey, JSON.stringify(list)), { storeKey: key, list: entries });
await page.reload({ waitUntil: 'networkidle' });
};
const sampleDay = (id, daysAgo, bristolType) => ({
id,
occurredAt: new Date(Date.now() - daysAgo * 86400000).toISOString(),
bristolType,
color: 'brown',
urgency: 1,
discomfort: 1,
note: '',
photoDataUrl: '',
symptoms: {},
});
await caption('The journal explains its own pattern lines instead of just asserting a trend', 2200);
await seedSyntheticLedger([
sampleDay('demo-firm', 2, 2),
sampleDay('demo-typical-1', 5, 3),
sampleDay('demo-typical-2', 9, 4),
sampleDay('demo-typical-3', 12, 4),
sampleDay('demo-old', 40, 6),
]);
await caption("Fictional sample logs are seeded into this browser's own storage — no real health data appears here", 2600);
await page.locator('[data-pattern-summary]').waitFor();
await indicate('[data-pattern-summary]');
await caption('The summary says which confirmed logs it read, and a citation opens exactly those records', 2400);
await indicate('[data-statement="typical"]');
await tap('[data-statement-link="typical"]', 650);
const citedRecords = await page.locator('[data-summary-record]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-entry-id')).sort());
if (JSON.stringify(citedRecords) !== JSON.stringify(['demo-typical-1', 'demo-typical-2', 'demo-typical-3'])) {
throw new Error('A summary citation must list exactly the records it cites');
}
await caption('Every pattern line names the confirmed logs behind it, and following one shows exactly those records', 2900);
await indicate('[data-summary-records]');
await tap('#pattern-records-close', 450);
await seedSyntheticLedger([sampleDay('demo-sparse-1', 1, 4), sampleDay('demo-sparse-2', 4, 6)]);
await page.locator('[data-summary-insufficient]').waitFor();
await indicate('[data-summary-insufficient]');
await caption('Two logs is not a pattern: the summary refuses to describe a trend it cannot support', 2900);
// Restore the empty ledger the earlier scenes started from, so the rest of the recording is unchanged.
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
// Issue #36 — a saved photo and its confirmed log are separate things a person owns. The journal
// states that a photo exists without rendering it, the image appears only when asked for, removing
// it keeps every confirmed field, and delete-all takes the ledger and every photo together. The
// only image here is a synthetic 1x1 PNG constant written by this script; no real photo is used.
// A 160x120 synthetic test card (checkerboard + 'SYNTHETIC DEMO PHOTO'), so the reveal is legibly
// an image on camera. It carries no photograph of any person or specimen.
const SYNTHETIC_PHOTO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAAG0ElEQVR4nO2deVATZxTAXxKSIldVZqyiTEWucMgpyCGggEK96gFYgSKVenCJoYAy1DpaBYpBnEqt1WoVb2071fHCqi2IiFo70gJaT7COWKdYBZExCW7/2HFJm8OFok5f3m/yx+6X9739Jr98m+zb+RKB1MYaCLwIX/UAiBcLCUaOEbfVEh/33OjGtAT+qYeWlvEPpsy9m9ly+w52g2YwckgwckgwckgwckgwckgwckgwckgwckgwckgwckgwcgTc7cILNeXPjUZTqkWf2dsvgt2gGYwcEowcEowcEowcEowcEowcEowcEowcEowcEowcEowcEowcEowcEoycrqUrfG5Robmbhj6z5bMNmsHIIcHIIcHIIcHIIcHIIcHIIcHIIcHIIcHIIcHIIcHIIcHIIcHI6Vp8xv0Q2r3lBa9uPMR/YsBHuewG90NoWlYXmgxwevkjI3qFx/cusRu0utBQIMHIIcHIIcHIIcHIIcHIMXp+CAAANNTVFRcVqFQqkUiUXyRvf9QuS0/57nC5UCgEgKjJEwrkq6Pfnuju6bV15x62i6+7y7na+uPlR8u2bAaAn3867zXCBwDiEhKXLM4+V1vPJWcjAcDT2WG4uwfbGBo+dsgQaz199+3euWvHNlNTMxMT02Ur8wcOsuqdlwQZUhtr9tH+RwP7YLQxZXxEc/MdhmHKjxySpSUzDJO5IPXQgf0Mw/x48gTb4uPmHBc97eyZaraLj5uzegb1XV1P/atdT9/Tpypnxc7o6OhgGKbih5OJsTO0djQ0OImcVr6n6JaWPxVPngBAaPi4uIREAEhJX7j+s7VPnz7dsK40dYGMDUuTfbB2zeoX81b8B5s3fiHLyjE2NgaA4NFjrN8cqlKpXsJx/3fwFSzLWhQXM/3DRdkXzp/z9vEFAFs7O0ep09K83EFWg23t7dkwP/8AADh7pvoFDZfj2tUrTi6u3O7y/EIjI74fNwYF3xdlalR02Nhxx78vL/h4WXhEZFqGDABSFmRMHBd28NgJ9cj0hZmflhSP9A/Qk02pVCbMjFHf1WzPzF7k4eWtK0NnZyfPkRs4vATfv9/S1Njo6eU9LSpmdGjY5MixrGCbYbampqY2w2zVg339/EUiUY3eSSwWi8t27e3q4u6itV0PQ22GXW6od/PwBACGYXKzMwvlJXw6Ghq8TtECgUCWlny3+Q4APPjrgZXVYP3x6QszS0uKe2F0uomNT1hTvEqhUADA4YMH2A1CE14zuF+//svzCzNSk42NjYVC4coiuf74Eb4jxWJxD1509VO0h6dXZs5iXZFvTZzU2Hhz+qTx/S0tLS0tlyxf0d1jGQh0PxgVmveDqZKFHBKMHBKMHL7XwWyVWCAQKBWK6Hdip0ZFg0bpODFpjoeT/Ziw8JLSz9nGHFnGsaOHL166CgDffr13R9kWsViiVCrenTV7yvQozeQqpXLxkqXD3dy56jQLt6uZRGutu739ka5jGRp8BXNXqB2PH6fMmd3HxCRy/ATNy1aJRHLzxo3Ozk6RSMQwzK1bTRKJBACqKiu+2bN7y/bd5hYWba2t85MS3xg40D9w1L+SX/ntcl5O1r79B7WOQWuS8IjI8IhIAPB1d2GTVFVWlG3+UtexDI1un6L7mJhk5eZt+2qTrgBnV9e6X2oB4HJDg6NUyjZu2rA+OzfP3MICAMwtLLJy8zauX6fZ18FRevv277oy80zCM8xA6MlnsKPUqampUdezo4JCqiorAKDqVMWo4BC28cb1a+qlY2cX1+vXrmr2rak+LXVy1pWZZxKeYQZCTwr0KpVKbCQGHaXjwKDgndu2pmbIaqpPz4zX/msxDMMIBAJul83DMIy5ufmKwlWgu1itJ4kueIZhpSeCf6296CCVgo7S8et9+wqEQrauaWZmxjba2tk31Nd5Prt50FBfZ2fvwHXRzKO1WK0/CQfPMAOh26fo1ocP5Z8UJM2drycmKGR0ibwoQO17TdLc+fKClW1tbQDQ1tpaXJj//rzk7h6aZ5JeORYa+M5g9pzJXsnMmZfs6+cPukvHIWNC18iL9h85xnUPDAq+e7d5VmyMRPKaUqmIT3jPLyCwu2PlmaRXjoUGqkWjgmrRBgcJRg6tLkQFrS40OLR8i+beBZqg+bVdw8lMMxg5JBg5JBg5JBg5JBg5JBg5JBg5JBg5JBg5JBg5JBg5JBg59N+FODPTfxcaCiQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOSQYOVpWF+oBzd009JlpdaGhQIKRQ4KRQ4KRQ4KRQ4KRQ4KRQ4KRQ4KRQ4KRQ4KR01WLJlBCMxg5JBg5fwMX/GJFpYaDtAAAAABJRU5ErkJggg==';
const photoDay = (id, daysAgo, bristolType = 4) => ({ ...sampleDay(id, daysAgo, bristolType), photoDataUrl: SYNTHETIC_PHOTO });
const storedLedger = async () => {
const key = await page.evaluate(() => `timmy:${document.querySelector('meta[name="timmy-base-path"]')?.content || '/'}:ledger-v1`);
const raw = await page.evaluate(storeKey => localStorage.getItem(storeKey), key);
return { key, raw };
};
await caption('A confirmed log can hold a private photo — the journal says so without showing it', 2300);
await seedSyntheticLedger([photoDay('demo-photo', 1), sampleDay('demo-nophoto', 4, 6)]);
await tap('[data-view="calendar"]', 550);
await page.locator('.entries-card .entry').first().waitFor();
if (await page.locator('.entries-card [data-photo-indicator="present"]').count() !== 1) {
throw new Error('exactly one log must be marked as holding a photo');
}
if (await page.locator('.entries-card img').count() !== 0) {
throw new Error('the journal must not show a thumbnail by default');
}
await caption('Photo presence is a label, not a thumbnail — nothing sensitive is on screen until you ask', 2700);
await indicate('[data-photo-indicator="present"]');
await tap('[data-entry-open="demo-photo"]', 550);
await page.locator('[data-photo-lifecycle="present"]').waitFor();
if (await page.locator('#entry-photo').count() !== 0) {
throw new Error('opening a log must not reveal its photo');
}
await caption('Opening the log states the photo is saved here — and still does not render it', 2500);
await indicate('[data-photo-lifecycle="present"]');
await tap('#show-entry-photo', 600);
await page.locator('#entry-photo').waitFor();
const revealedPhoto = await page.locator('#entry-photo').getAttribute('src');
if (!/^data:image\/png;base64,/.test(revealedPhoto || '')) throw new Error('the revealed image must be the stored bytes');
await caption('Showing the photo is your explicit request, from this browser’s own storage', 2300);
await indicate('#entry-photo');
await tap('#remove-entry-photo', 650);
await page.locator('[data-photo-lifecycle="absent"]').waitFor();
const afterRemoval = JSON.parse((await storedLedger()).raw || '[]');
const keptLog = afterRemoval.find(entry => entry.id === 'demo-photo');
if (afterRemoval.length !== 2 || !keptLog || keptLog.photoDataUrl !== '') {
throw new Error('removing a photo must keep the confirmed log and clear only its image bytes');
}
if (keptLog.bristolType !== 4 || keptLog.color !== 'brown') throw new Error('removing a photo must not alter the confirmed fields');
await caption('Remove just the photo: the confirmed log, form, and note are all still here', 2900);
await indicate('[data-photo-lifecycle="absent"]');
await tap('#entry-sheet-close', 450);
await page.locator('.entries-card .entry').first().waitFor();
if (await page.locator('.entries-card [data-photo-indicator="present"]').count() !== 0) {
throw new Error('the journal must drop the photo label once the bytes are gone');
}
await caption('The label follows the bytes: the log stays, the photo is gone', 2300);
// One photo-bearing log remains for the deletion pass below, so the wipe shows photos leaving with
// the ledger rather than an empty promise.
await seedSyntheticLedger([photoDay('demo-wipe', 2)]);
await caption('The threat model documents this surface, so the recording shows it live rather than describing it', 2200);
await tap('[data-view="calendar"]', 550);
await page.getByText(/Your private journal/i).waitFor();
await tap('[data-view="privacy"]', 550);
await page.getByText(/Your poop\. Your phone\./i).waitFor();
await caption('Every byte-bearing control the model names, in the running app: local ledger, consent-gated upload, portable export, full deletion', 2800);
await indicate('#export');
await caption('Export writes a plain JSON file you hold — the model records it as a data-lifetime boundary, not a server sync', 2600);
// Issue #24 — training contribution is a second, separate consent. The recording shows the section
// reporting nothing queued after ordinary saves, the version and receipt on screen before the
// button can be pressed, one receipt queued on the device only, and a withdrawal tombstone that
// names each derived artifact by contribution id.
await caption('Training contribution is off by default: a saved log is never training data', 2600);
await page.locator('[data-training-consent]').waitFor();
const contributionKey = await page.evaluate(() => `timmy:${document.querySelector('meta[name="timmy-base-path"]')?.content || '/'}:training-contributions-v1`);
const storedContributions = async () => JSON.parse(await page.evaluate(key => localStorage.getItem(key) || '[]', contributionKey));
if ((await page.locator('[data-contribution-queued-count]').innerText()).trim() !== '0') {
throw new Error('a journal save must not queue a training contribution');
}
await indicate('[data-contribute-entry="demo-wipe"]');
await tap('[data-contribute-entry="demo-wipe"]', 600);
await page.locator('[data-contribution-sheet]').waitFor();
const receiptId = (await page.locator('[data-receipt-id]').innerText()).trim();
if (!receiptId) throw new Error('the consent sheet must name the receipt before consent');
const sheetCopy = await page.locator('[data-contribution-sheet]').innerText();
if (!sheetCopy.includes('training-consent-v1')) throw new Error('the consent sheet must state the consent version');
if (!(await page.locator('#contribute-confirm').isDisabled())) throw new Error('the contribute control must stay disabled until the terms are accepted');
if ((await storedContributions()).length !== 0) throw new Error('no receipt may exist before explicit consent');
await caption('The consent version and the receipt are on screen before the contribute button works', 3000);
await page.locator('#training-consent-check').check();
await tap('#contribute-confirm', 700);
await page.locator('[data-contribution-state="queued-local"]').waitFor();
const queuedReceipts = await storedContributions();
if (queuedReceipts.length !== 1 || queuedReceipts[0].consentVersion !== 'training-consent-v1') {
throw new Error('one queued receipt must carry the consent version it was accepted under');
}
if (JSON.stringify(queuedReceipts).includes('data:image')) throw new Error('a receipt must never copy the photo bytes');
await caption('One confirmed log is queued on this device with a receipt you can withdraw — nothing is uploaded', 3200);
await indicate('[data-contribution-state="queued-local"]');
await tap(`[data-withdraw="${receiptId}"]`, 700);
await page.locator('[data-contribution-state="withdrawn"]').waitFor();
const withdrawnReceipts = await storedContributions();
const tombstone = withdrawnReceipts[0];
if (!tombstone || tombstone.state !== 'withdrawn') throw new Error('withdrawal must keep the receipt as a tombstone');
if ((tombstone.artifacts || []).length !== 2) throw new Error('the tombstone must name every derived artifact');
for (const artifact of tombstone.artifacts) {
if (artifact.contributionId !== receiptId) throw new Error('every derived artifact must be addressed by contribution id');
if (artifact.state !== 'withdrawn') throw new Error('every derived artifact must be marked withdrawn');
}
await caption('Withdrawing keeps a tombstone naming every derived artifact by contribution ID', 3000);
await indicate('[data-withdrawn-artifacts]');
// Issue #25 — the corpus pipeline is a command-line tool, not a screen in the app. Rather than
// inventing a UI for it, this scene runs the real ingest and revoke commands against the committed
// synthetic fixture in a throwaway dataset and shows their actual output. Every number, hash prefix,
// and refusal line below is read back from those commands at record time.
async function corpusCli(args) {
return execFileAsync('python3', args, {
cwd: process.cwd(),
env: { ...process.env, TIMMY_DATASET_SALT: 'demo-only-salt-not-a-secret' },
});
}
async function corpusAudit() {
const dataset = await mkdtemp(path.join(os.tmpdir(), 'timmy-corpus-demo-'));
const fixture = 'tests/fixtures/synthetic-type4.jpg';
const contributionId = 'demo-contrib-0001-abcd';
const manifestPath = path.join(dataset, 'manifest.jsonl');
const lines = async () => (await readFile(manifestPath, 'utf8'))
.split('\n').filter(line => line.trim()).map(line => JSON.parse(line));
const images = async () => (await readdir(path.join(dataset, 'images')).catch(() => [])).sort();
try {
const ingest = await corpusCli([
'scripts/ingest_training_photo.py',
'--input', fixture,
'--output-dir', dataset,
'--subject-id', 'demo-subject',
'--consent-version', 'training-consent-v1',
'--contribution-id', contributionId,
'--bristol-type', '4', '--color', 'brown', '--quality', 'good',
'--reviewer', 'user-confirmed',
]);
const record = JSON.parse(ingest.stdout);
const fixtureDigest = createHash('sha256').update(await readFile(fixture)).digest('hex');
if (record.recordType !== 'sample' || record.lifecycle?.state !== 'active') {
throw new Error('ingest must write one active sample record');
}
// The record names the source by digest only: the caller's path must never be stored.
if (record.source?.inputSha256 !== fixtureDigest || record.hashes?.rawInput !== fixtureDigest) {
throw new Error('the record must carry the raw input digest');
}
if (JSON.stringify(record).includes(fixture) || 'inputPath' in record) {
throw new Error('the record must never store the caller path');
}
const afterIngestImages = await images();
if (afterIngestImages.length !== 1 || (await lines()).length !== 1) {
throw new Error('ingest must write exactly one derived image and one manifest record');
}
const revoke = await corpusCli([
'scripts/ingest_training_photo.py', '--output-dir', dataset,
'--revoke', contributionId, '--reason', 'participant withdrew consent',
]);
const tombstone = JSON.parse(revoke.stdout);
const afterRevokeImages = await images();
const history = await lines();
if (tombstone.recordType !== 'tombstone' || history.length !== 2) {
throw new Error('revocation must append exactly one tombstone to the existing history');
}
if (afterRevokeImages.length !== 0) throw new Error('revocation must delete the derived bytes');
if (history[0].recordType !== 'sample' || history[0].lifecycle?.state !== 'active') {
throw new Error('the original record must survive its own withdrawal');
}
if ('labels' in tombstone || 'subjectKey' in tombstone) {
throw new Error('a tombstone must not carry the labels or subject key');
}
const deleted = tombstone.deletedArtifacts || [];
if (!deleted.some(item => item.relativePath === `images/${record.imageId}.jpg` && item.deleted === true)) {
throw new Error('the tombstone must name the derived artifact it removed');
}
const active = await corpusCli([
'-c',
'import sys;sys.path.insert(0,"scripts");import corpus_manifest as c;from pathlib import Path;'
+ 'print(len(c.active_records(c.load_manifest(Path(sys.argv[1])/"manifest.jsonl"))))',
dataset,
]);
if (active.stdout.trim() !== '0') throw new Error('a trainer reading the manifest must see zero active samples');
// A second revocation of the same contribution has to be refused, and refuse without writing.
let refusal = '';
try {
await corpusCli([
'scripts/ingest_training_photo.py', '--output-dir', dataset,
'--revoke', contributionId, '--reason', 'participant withdrew consent again',
]);
throw new Error('a second revocation must be refused');
} catch (error) {
if (error.message === 'a second revocation must be refused') throw error;
refusal = String(error.stderr || error.stdout || '').trim().split('\n').filter(Boolean).pop() || '';
}
if (!/already revoked/.test(refusal)) throw new Error(`second revocation refusal was not legible: ${refusal}`);
if ((await lines()).length !== 2) throw new Error('a refused revocation must append nothing');
return { record, tombstone, deleted, refusal, derived: record.imageId, fixtureDigest };
} finally {
await rm(dataset, { recursive: true, force: true });
}
}
const audit = await corpusAudit();
const short = value => String(value).slice(0, 12);
const deletedName = audit.deleted[0].relativePath.split('/').pop().replace(/\.jpg$/, '');
await tap('[data-view="home"]', 500);
// The card is drawn from the values above, so a run that stops writing provenance or stops deleting
// bytes fails here instead of recording a caption that claims it.
const auditRows = [
['command', 'python3 scripts/ingest_training_photo.py --input <synthetic fixture>'],
['record', `sample · schemaVersion ${audit.record.schemaVersion}`],
['consent', audit.record.consent.version],
['source', `sha256 ${short(audit.record.source.inputSha256)}… (path never stored)`],
['hashes', `raw ${short(audit.record.hashes.rawInput)}… · derived ${short(audit.record.hashes.derived)}…`],
['derived', audit.record.derivation.pipeline + ' · jpeg · ≤1200px · metadata stripped'],
['review', audit.record.review.status],
['lifecycle', audit.record.lifecycle.state],
['—', ''],
['command', `--revoke ${audit.record.contributionId} --reason "participant withdrew consent"`],
['deleted', `images/${short(deletedName)}….jpg → gone from disk`],
['tombstone', 'appended · keeps id + digests · drops labels + subject key'],
['history', '2 lines: the original record is still there'],
['trainer', 'active samples: 0'],
['—', ''],
['command', '--revoke again'],
['refused', audit.refusal],
['manifest', 'unchanged at 2 lines'],
];
const panelOk = await page.evaluate(rows => {
document.querySelector('#corpus-audit')?.remove();
const panel = document.createElement('div');
panel.id = 'corpus-audit';
Object.assign(panel.style, {
position: 'fixed', inset: '96px 14px 40px 14px', zIndex: '15000',
background: '#181412', color: '#f6f1e6', borderRadius: '18px',
border: '1px solid rgba(245,201,91,.45)', padding: '13px 13px 11px',
font: '600 10.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace',
display: 'flex', flexDirection: 'column', gap: '1px', overflow: 'hidden',
boxShadow: '0 18px 40px rgba(0,0,0,.42)',
});
const head = document.createElement('div');
head.textContent = 'CORPUS AUDIT';
Object.assign(head.style, { font: '800 14px/1.2 system-ui', letterSpacing: '.14em', color: '#f5c95b', marginBottom: '2px' });
const sub = document.createElement('div');
sub.textContent = 'command line, not app UI — real run, synthetic fixture';
Object.assign(sub.style, { font: '700 9.5px/1.3 system-ui', color: '#c9bfae', marginBottom: '9px' });
panel.append(head, sub);
for (const [key, value] of rows) {
const row = document.createElement('div');
if (key === '—') { row.style.height = '7px'; panel.append(row); continue; }
Object.assign(row.style, { display: 'flex', gap: '7px', alignItems: 'baseline' });
const k = document.createElement('span');
k.textContent = key;
k.style.cssText = 'flex:0 0 62px;color:#8fd7c8;text-transform:uppercase;font-size:9px;letter-spacing:.08em';
const v = document.createElement('span');
v.textContent = value;
v.style.cssText = 'flex:1 1 auto;word-break:break-word;color:#f6f1e6';
row.append(k, v);
panel.append(row);
}
document.body.append(panel);
panel.animate([{ opacity: 0, transform: 'translateY(12px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 320, fill: 'forwards' });
return { height: panel.clientHeight, scroll: panel.scrollHeight, text: panel.innerText };
}, auditRows);
await sleep(420);
const panelProof = await page.evaluate(() => {
const panel = document.querySelector('#corpus-audit');
if (!panel) return { ok: false, reason: 'panel missing' };
const rect = panel.getBoundingClientRect();
const topmost = document.elementFromPoint(Math.round(rect.left + rect.width / 2), Math.round(rect.top + rect.height / 2));
const background = getComputedStyle(panel).backgroundColor;
// A translucent card lets the nav bar and body copy ghost through its empty rows, which reads as a
// sloppy overlay on the thinnest part of the frame. The card must be fully opaque.
const opaque = !/^rgba\(.*,\s*(0?\.\d+|0)\)$/.test(background);
return {
ok: opaque && rect.top >= 60 && rect.bottom <= window.innerHeight && rect.width > 300 && rect.height > 300
&& (topmost === panel || panel.contains(topmost)) && panel.scrollHeight <= panel.clientHeight + 1,
top: Math.round(rect.top), height: Math.round(rect.height), width: Math.round(rect.width),
scroll: panel.scrollHeight, client: panel.clientHeight, background, opaque,
covered: topmost === panel || panel.contains(topmost),
};
});
if (!panelProof.ok) throw new Error(`Corpus audit card is not fully readable on screen: ${JSON.stringify(panelProof)}`);
// The card is drawn from live command output; prove the values on it are the ones the run produced.
if (!panelOk.text.includes(audit.record.imageId.slice(0, 12))) throw new Error('the card must show the derived digest it recorded');
if (!panelOk.text.includes('active samples: 0')) throw new Error('the card must show the post-revocation active count');
await caption('The corpus pipeline is a command-line tool, not a screen in the app — this runs the real ingest against the committed synthetic fixture', 3400);
await caption('Every ingested sample records consent, source digest, both hashes, derivation, review, and lifecycle — the caller file path is never stored', 3600);
await caption('Withdrawing deletes the derived image and any raw copy, keeps the original record, and appends a tombstone — the trainer sees zero active samples', 3800);
await caption('Revoking the same contribution twice is refused and appends nothing: the manifest only ever grows', 3200);
await page.evaluate(() => document.querySelector('#corpus-audit')?.remove());
// Issue #29 — the release gate is a command-line harness, not a screen in the app. This scene runs
// the real harness over the committed synthetic prediction fixtures and shows the receipt it wrote:
// a blocked comparison, a passing fixture, and a refused leaked corpus. Every number and every
// refusal line on the card is read back from those runs; the scores are invented fixtures, and the
// captions say so, because a fixture is not a measurement of any model.
async function calibrationCli(args) {
try {
const result = await execFileAsync('python3', ['scripts/calibration_report.py', ...args], { cwd: process.cwd() });
return { code: 0, stdout: result.stdout, stderr: result.stderr };
} catch (error) {
return {
code: typeof error.code === 'number' ? error.code : 1,
stdout: error.stdout || '',
stderr: error.stderr || '',
};
}
}
async function calibrationGate() {
const directory = await mkdtemp(path.join(os.tmpdir(), 'timmy-calibration-demo-'));
const reportPath = path.join(directory, 'report.json');
const source = 'invented scores, not measurements of any model';
try {
const blocked = await calibrationCli([
'--predictions', 'tests/fixtures/calibration/predictions.jsonl',
'--json', reportPath, '--source', source,
]);
if (blocked.code !== 3) throw new Error('the synthetic candidate fixtures must block the release');
const report = JSON.parse(await readFile(reportPath, 'utf8'));
if (report.verdict !== 'BLOCKED' || report.recommended !== null) {
throw new Error('a blocked gate must recommend nobody');
}
if (report.models.length !== 3) throw new Error('the comparison must score all three candidate fixtures');
for (const row of report.models) {
if (!row.gate.failed.length) throw new Error(`${row.model}: a fixture candidate must fail at least one gate`);
if (row.selectionSplit !== 'validation' || row.evaluatedSplit !== 'test') {
throw new Error(`${row.model}: the threshold must come from validation and the score from test`);
}
}
const go = await calibrationCli([
'--predictions', 'tests/fixtures/calibration/predictions-go.jsonl',
'--source', 'engineered fixture — proves the passing branch, not a result',
]);
if (go.code !== 0 || !/verdict: GO/.test(go.stdout)) throw new Error('the engineered fixture must prove the passing branch');
const leak = await calibrationCli(['--predictions', 'tests/fixtures/calibration/predictions-leaky.jsonl']);
if (leak.code !== 2 || !/leaks across/.test(leak.stderr)) throw new Error('a leaked corpus must be refused before scoring');
return { report, source, blocked, go, leak };
} finally {
await rm(directory, { recursive: true, force: true });
}
}
const gate = await calibrationGate();
await tap('[data-view="home"]', 450);
const gateRows = [
['command', 'python3 scripts/calibration_report.py --predictions <fixtures>'],
['split', 'threshold chosen on validation · scored on test'],
['floor', `coverage floor ${Math.round(gate.report.models[0].minCoverage * 100)}%`],
['—', ''],
];
for (const row of gate.report.models) {
gateRows.push([
row.model,
`thr ${row.threshold.toFixed(2)} · F1 ${row.macroF1.toFixed(3)} · P@C ${row.precisionAtCoverage.toFixed(3)} · cov ${row.coverage.toFixed(2)}`,
]);
gateRows.push(['gate', `FAIL: ${row.gate.failed.join(', ')} · ECE ${row.ece.toFixed(3)}`]);
}
gateRows.push(['—', '']);
gateRows.push(['verdict', `${gate.report.verdict} · no candidate clears every gate`]);
gateRows.push(['gates', 'macroF1 ≥ 0.80 · precision@coverage ≥ 0.90 · ECE ≤ 0.05']);
gateRows.push(['—', '']);
gateRows.push(['engineered', 'GO — gate-plumbing-fixture clears every gate (fixture, not a result)']);
gateRows.push(['leaked', gate.leak.stderr.trim().replace(/^calibration harness refused:\s*/, '')]);
gateRows.push(['data', 'synthetic predictions · no model was measured']);
const gateCard = await page.evaluate(rows => {
document.querySelector('#calibration-report')?.remove();
const panel = document.createElement('div');
panel.id = 'calibration-report';
Object.assign(panel.style, {
position: 'fixed', inset: '96px 14px 40px 14px', zIndex: '15000',
background: '#181412', color: '#f6f1e6', borderRadius: '18px',
border: '1px solid rgba(245,201,91,.45)', padding: '13px 13px 11px',
font: '600 9.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace',
display: 'flex', flexDirection: 'column', gap: '1px', overflow: 'hidden',
boxShadow: '0 18px 40px rgba(0,0,0,.42)',
});
const head = document.createElement('div');
head.textContent = 'CALIBRATION HARNESS';
Object.assign(head.style, { font: '800 13.5px/1.2 system-ui', letterSpacing: '.12em', color: '#f5c95b', marginBottom: '2px' });
const sub = document.createElement('div');
sub.textContent = 'command line, not app UI — real run, synthetic predictions';
Object.assign(sub.style, { font: '700 9px/1.3 system-ui', color: '#c9bfae', marginBottom: '8px' });
panel.append(head, sub);
for (const [key, value] of rows) {
const row = document.createElement('div');
if (key === '—') { row.style.height = '6px'; panel.append(row); continue; }
Object.assign(row.style, { display: 'flex', gap: '6px', alignItems: 'baseline' });
const k = document.createElement('span');
k.textContent = key;
k.style.cssText = 'flex:0 0 74px;color:#8fd7c8;text-transform:uppercase;font-size:8.5px;letter-spacing:.06em';
const v = document.createElement('span');
v.textContent = value;
v.style.cssText = 'flex:1 1 auto;word-break:break-word;color:#f6f1e6';
row.append(k, v);
panel.append(row);
}
document.body.append(panel);
panel.animate([{ opacity: 0, transform: 'translateY(12px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 320, fill: 'forwards' });
return { height: panel.clientHeight, scroll: panel.scrollHeight, text: panel.innerText };
}, gateRows);
await sleep(420);
const gateProof = await page.evaluate(() => {
const panel = document.querySelector('#calibration-report');
if (!panel) return { ok: false, reason: 'panel missing' };
const rect = panel.getBoundingClientRect();
const topmost = document.elementFromPoint(Math.round(rect.left + rect.width / 2), Math.round(rect.top + rect.height / 2));
// Same rule as the corpus card: a translucent card lets the screen behind it ghost through.
const opaque = !/^rgba\(.*,\s*(0?\.\d+|0)\)$/.test(getComputedStyle(panel).backgroundColor);
return {
ok: opaque && rect.top >= 60 && rect.bottom <= window.innerHeight && rect.width > 300 && rect.height > 300
&& (topmost === panel || panel.contains(topmost)) && panel.scrollHeight <= panel.clientHeight + 1,
top: Math.round(rect.top), height: Math.round(rect.height), width: Math.round(rect.width),
scroll: panel.scrollHeight, client: panel.clientHeight, opaque,
covered: topmost === panel || panel.contains(topmost),
};
});
if (!gateProof.ok) throw new Error(`Calibration card is not fully readable on screen: ${JSON.stringify(gateProof)}`);
// The card is drawn from the report; prove the values on it are the ones the harness measured.
for (const row of gate.report.models) {
if (!gateCard.text.includes(row.macroF1.toFixed(3))) {
throw new Error(`the card must show the macro-F1 the harness measured for ${row.model}`);
}
}
if (!gateCard.text.includes('leaks across')) throw new Error('the card must show the refusal the leaked corpus earned');
if (!gateCard.text.includes('GO — gate-plumbing-fixture')) throw new Error('the card must show the passing branch it proved');
await caption('The calibration harness is a command-line release gate, not a screen in the app — this runs it against committed synthetic predictions', 3400);
await caption('Every score on this card is invented to exercise the arithmetic: no model produced them and none of them describes the real classifier', 3600);
await caption('Each candidate is scored on test at a threshold chosen on validation only; picking a threshold on the split you report is refused', 3600);
await caption('One contributor spread across two splits is refused before anything is scored, and a passing verdict is proven only by a fixture engineered to clear it', 3800);
await page.evaluate(() => document.querySelector('#calibration-report')?.remove());
// Back to the privacy view for the portability scene: the privacy entry lives on the journal view.
await tap('[data-view="calendar"]', 550);
await tap('[data-view="privacy"]', 600);
// Issue #35 — a backup is the person's own record, so it carries a version and everything it is
// claimed to hold. The recording exports the real file, imports an older version and a newer one,
// and reopens a restored log. Fictional sample logs are used throughout; nothing here is real data.
const exportDownload = page.waitForEvent('download');
await tap('#export', 600);
const exportedBackup = JSON.parse(await readFile(await (await exportDownload).path(), 'utf8'));
if (exportedBackup.schemaVersion !== 2) throw new Error('the exported backup must declare the current schema version');
if (!Array.isArray(exportedBackup.entries) || !Array.isArray(exportedBackup.contributions)) {
throw new Error('a backup must carry both the confirmed logs and the contribution receipts');
}
const exportedReceipt = exportedBackup.contributions.find(receipt => receipt.contributionId === receiptId);
if (!exportedReceipt || exportedReceipt.state !== 'withdrawn') {
throw new Error('the backup must carry the withdrawn receipt as a tombstone');
}
if (!(exportedReceipt.artifacts || []).length) throw new Error('the backup tombstone must still name its derived artifacts');
await page.locator('[data-portability-notice][data-portability-version="2"]').waitFor();
await caption('A backup is versioned, and it carries the withdrawn receipt as a tombstone rather than silence', 3200);
const fictionalLegacyBackup = JSON.stringify({
product: 'Timmy the Talking Turd',
schemaVersion: 1,
exportedAt: '2026-08-01T09:00:00.000Z',
entries: [{
id: 'demo-legacy-backup',
occurredAt: new Date(Date.now() - 3 * 86400000).toISOString(),
bristolType: 3,
color: 'brown',
urgency: 1,
discomfort: 0,
note: 'fictional version 1 backup',
photoDataUrl: '',
symptoms: {},
}],
});
await page.locator('#import').setInputFiles({ name: 'timmy-backup-v1.json', mimeType: 'application/json', buffer: Buffer.from(fictionalLegacyBackup) });
await page.locator('[data-portability-notice][data-portability-version="1"]').waitFor();
const upgradeNotice = (await page.locator('[data-portability-notice]').innerText()).trim();
if (!/upgraded/i.test(upgradeNotice)) throw new Error('an older backup must be reported as upgraded, not silently rewritten');
await caption('An older backup is upgraded on import, and the notice says which version the file came from', 3400);
const ledgerBeforeFuture = await storedLedger();
await page.locator('#import').setInputFiles({
name: 'timmy-backup-v3.json', mimeType: 'application/json',
buffer: Buffer.from(JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 3, entries: [{ id: 'demo-future', bristolType: 7 }] })),
});
await page.locator('[data-portability-error]').waitFor();
if (!/newer version of Timmy/i.test(await page.locator('[data-portability-error]').innerText())) {
throw new Error('a backup from a newer Timmy must be refused with an instruction to update');
}
if ((await storedLedger()).raw !== ledgerBeforeFuture.raw) {
throw new Error('a refused import must not change the ledger it would have replaced');
}
await caption('A file from a newer Timmy is refused with an instruction, and the ledger it would have replaced is untouched', 3400);
const fictionalAssistedBackup = JSON.stringify({
product: 'Timmy the Talking Turd',
schemaVersion: 2,
exportedAt: new Date().toISOString(),
entries: [{
id: 'demo-restored-provenance',
occurredAt: new Date(Date.now() - 86400000).toISOString(),
bristolType: 2,
color: 'brown',
urgency: 1,
discomfort: 0,
note: 'fictional restored log',
photoDataUrl: '',
symptoms: {},
provenance: {
source: 'ai-corrected',
model: 'SmolVLM2-2.2B-Instruct',
suggestedBristolType: 4,
suggestedColor: 'brown',
confirmedAt: new Date(Date.now() - 86400000).toISOString(),
},
}],
contributions: [],
});
await page.locator('#import').setInputFiles({ name: 'timmy-backup-v2.json', mimeType: 'application/json', buffer: Buffer.from(fictionalAssistedBackup) });
await page.locator('[data-portability-notice][data-portability-version="2"]').waitFor();
await tap('[data-view="calendar"]', 550);
await page.locator('[data-entry-open="demo-restored-provenance"]').waitFor();
await tap('[data-entry-open="demo-restored-provenance"]', 550);
await page.locator('[data-entry-provenance="ai-corrected"]').waitFor();
const provenanceLine = (await page.locator('[data-entry-provenance="ai-corrected"]').innerText()).trim();
if (!/corrected/i.test(provenanceLine) || !/SmolVLM2/.test(provenanceLine)) {
throw new Error('a restored log must still show that it began as a corrected AI suggestion');
}
await caption('A restored log still says it began as an AI suggestion you corrected — provenance is part of the record', 3400);
await indicate('[data-entry-provenance="ai-corrected"]');
await tap('#entry-sheet-close', 450);
await tap('[data-view="privacy"]', 550);
await page.locator('#delete-all').waitFor();
await indicate('#delete-all');
await tap('#delete-all', 650);
await page.getByText(/Local ledger and photos deleted/).waitFor();
const wiped = await storedLedger();
if (wiped.raw !== null) throw new Error('delete-all must remove the stored ledger');
if (await page.evaluate(() => JSON.stringify(localStorage).includes('data:image'))) {
throw new Error('no photo bytes may survive delete-all');
}
if (await page.evaluate(key => localStorage.getItem(key), contributionKey) !== null) {
throw new Error('delete-all must clear the contribution receipts as well');
}
await caption('Delete all local data clears the ledger and every saved photo in this browser; nothing is kept on a server', 2900);
await tap('[data-view="home"]', 500);
// Issue #37 — a chat answer about the journal must point at the confirmed records behind it. The
// scenes below run against the app's own local ledger contract with fictional logs, and every claim
// is asserted in the browser before the caption is shown.
agentConnected = false;
await seedSyntheticLedger([
sampleDay('demo-chat-1', 2, 4),
sampleDay('demo-chat-2', 5, 3),
sampleDay('demo-chat-3', 9, 4),
sampleDay('demo-chat-4', 12, 4),
]);
await tap('[data-view="timmy"]', 550);
await page.getByText(/Local Timmy mode/i).waitFor();
await caption('Fictional logs are seeded into this browser only — the chat answers from them, not from model memory', 2600);
await page.locator('#chat-message').fill('How many times have I logged?');
await tap('#send-chat', 500);
const groundedAnswer = page.locator('.bubble.timmy', { hasText: '4 confirmed logs' }).last();
await groundedAnswer.locator('[data-chat-citation]').first().waitFor();
if (await groundedAnswer.locator('[data-chat-citation]').count() !== 4) {
throw new Error('A grounded chat answer must cite every record it counted');
}
await caption('The answer states the count and cites the four confirmed logs it counted', 2600);
await indicate('[data-chat-citations]');
await tap('[data-chat-citation]', 700);
await page.locator('[data-chat-record]').waitFor();
if (await page.locator('[data-chat-record]').count() !== 1) {
throw new Error('A chat citation must open exactly the one record it names');
}
await caption('Following a citation opens exactly the confirmed log behind that answer', 2600);
await indicate('[data-chat-record-sheet]');
await tap('#chat-record-close', 450);
await page.locator('#chat-message').fill('Is it safe to eat tacos?');
await tap('#send-chat', 500);
await page.getByText(/cannot clear a food/i).waitFor();
if (await page.locator('.bubble.timmy').last().locator('[data-chat-citation]').count() !== 0) {
throw new Error('A refusal must not borrow journal citations');
}
await caption('Ask something the journal cannot answer and Timmy refuses instead of reassuring', 2800);
agentConnected = true;
await page.reload({ waitUntil: 'networkidle' });
await tap('[data-view="timmy"]', 550);
await page.getByText(/Hermes Agent connected/i).waitFor();
await caption('Hermes Agent connected — free-text, contextual, and server-side', 1400);
await page.locator('#chat-message').fill('What pattern do you see?');
await tap('#send-chat', 500);
await page.getByText(/mostly Type 4/i).waitFor();
if (await page.locator('.bubble.timmy').last().locator('[data-chat-citation]').count() !== 4) {
throw new Error('A Hermes answer must render the records it cites as sources');
}
await caption('Hermes answers carry the same ledger citations, rendered as sources in the transcript', 2400);
await caption('Hermes keeps credentials, tools, and session continuity server-side. Photos stay out of chat.', 1600);
await page.locator('#chat-message').fill('I barfed');
await tap('#send-chat', 450);
await page.getByText(/Pause and get medical help/i).waitFor();
if (hermesCalls !== 1) throw new Error('Urgent chat must be intercepted before Hermes');
await caption('Urgent language is intercepted deterministically before Hermes', 1800);
// Issue #21 — the escalation has to fire for the phrasing people actually use, not only for the one
// word the previous scene typed. Each line below is asserted in the browser: exactly one new Timmy
// bubble, carrying the app's own fixed copy, with hermesCalls unmoved.
const escalationBubbles = () => page.locator('.bubble.timmy', { hasText: 'Pause and get medical help' }).count();
const expectEscalation = async (text, label) => {
const before = await escalationBubbles();
await page.locator('#chat-message').fill(text);
await tap('#send-chat', 450);
const bubble = page.locator('.bubble.timmy', { hasText: 'Pause and get medical help' }).last();
await bubble.waitFor();
if (await escalationBubbles() !== before + 1) throw new Error(`${label}: expected exactly one escalation bubble`);
if (!/^Pause and get medical help\. These reported symptoms can need prompt medical care\./.test(await bubble.innerText())) {
throw new Error(`${label}: the escalation copy must be the app's own fixed string`);
}
if (hermesCalls !== 1) throw new Error(`${label}: an escalated turn must not reach Hermes`);
};
await expectEscalation('There is blood when I wipe', 'bleeding reported as wiping');
await caption('Bleeding reported as wiping still escalates, in the app’s own fixed words', 2600);
await expectEscalation('I have tarry stools', 'tarry stool');
await caption('Tarry and black stool wording escalates too: one red flag, one unchanging message', 2600);
// The same detector runs over the confirmed ledger, so a symptom already written down as a note
// escalates even when the question itself is innocuous. The seeded entry is fictional and stays in
// this browser, exactly like the summary scene above.
await page.evaluate(() => localStorage.clear());
await seedSyntheticLedger([{ ...sampleDay('demo-tarry-note', 1, 4), note: 'stool was tarry yesterday' }]);
await tap('[data-view="timmy"]', 550);
const ledgerEscalations = await escalationBubbles();
await page.locator('#chat-message').fill('What do my recent logs show?');
await tap('#send-chat', 450);
await page.locator('.bubble.timmy', { hasText: 'Pause and get medical help' }).last().waitFor();
if (await escalationBubbles() !== ledgerEscalations + 1) throw new Error('a tarry note in the journal must escalate the next answer');
if (hermesCalls !== 1) throw new Error('a ledger red flag must be intercepted before Hermes');
await caption('A tarry note already saved in the journal escalates the same way, before any provider call', 2800);
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
await tap('[data-view="timmy"]', 500);
await sleep(400);
await page.evaluate(() => {
document.querySelector('#demo-caption')?.remove();
const outro = document.createElement('div');
outro.id = 'release-outro';
outro.innerHTML = '<img src="/assets/timmy.svg"><strong>SLEEK. SIMPLE.<br>HERMES-POWERED.</strong><span>User-confirmed guidance — never a diagnosis.</span>';
Object.assign(outro.style, { position:'fixed', inset:'0', zIndex:'30000', background:'linear-gradient(145deg,#f7f3ea,#f5c95b)', display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', textAlign:'center', color:'#28211d', fontFamily:'system-ui', opacity:'0' });
outro.querySelector('img').style.cssText = 'width:150px;height:150px;filter:drop-shadow(0 14px 16px rgba(63,37,30,.18))';
outro.querySelector('strong').style.cssText = 'font-size:31px;line-height:1.05;margin:18px 0 12px;letter-spacing:-.04em';
outro.querySelector('span').style.cssText = 'font-size:13px;font-weight:800;color:#176957';
document.body.append(outro);
outro.animate([{opacity:0},{opacity:1}], {duration:500,fill:'forwards'});
});
await sleep(2200);
// The refusal pass deliberately produces two fail-closed 503s (an out-of-range reply, and one blank field
// beside an out-of-range one), which the browser logs as failed resources. Requiring exactly those two
// keeps the scene honest: if the boundary stops refusing, this fails instead of quietly recording a happy
// path, and a dropped scene is caught rather than silently shrinking the evidence.
const expectedFailClosed = errors.filter(message => /Failed to load resource: the server responded with a status of 503 \(Service Unavailable\)/.test(message));
if (expectedFailClosed.length !== 2 || errors.length !== expectedFailClosed.length) {
throw new Error(`Browser errors: ${errors.join(' | ') || 'unexpected error count'}`);
}
// Machine-readable receipt of exactly what was rendered on camera, so the release gate can prove
// the recorded delta statement reached the screen instead of trusting the script source.
if (captionsOut) {
await writeFile(captionsOut, JSON.stringify({ version, releaseDelta, captions: captionsShown,
captionsVerified: captionEvidence }, null, 2) + '\n', 'utf8');
}
const recording = page.video();
await page.close();
await recording.saveAs(output);
await context.close();
await browser.close();
console.log(output);