diff --git a/scripts/build_release.py b/scripts/build_release.py index b07006f..0eab81f 100755 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -55,6 +55,10 @@ DEMO_REQUIRED_CAPTIONS = ( "Training contribution is off by default: a saved log is never training data", "The consent version and the receipt are on screen before the contribute button works", "Withdrawing keeps a tombstone naming every derived artifact by contribution ID", + "The corpus pipeline is a command-line tool, not a screen in the app — this runs the real ingest against the committed synthetic fixture", + "Every ingested sample records consent, source digest, both hashes, derivation, review, and lifecycle — the caller file path is never stored", + "Withdrawing deletes the derived image and the raw copy, keeps the original record, and appends a tombstone — the trainer sees zero active samples", + "Revoking the same contribution twice is refused and appends nothing: the manifest only ever grows", "A backup is versioned, and it carries the withdrawn receipt as a tombstone rather than silence", "An older backup is upgraded on import, and the notice says which version the file came from", "A file from a newer Timmy is refused with an instruction, and the ledger it would have replaced is untouched", @@ -155,6 +159,8 @@ def main() -> int: run(["npm", "test"], tree) # The committed corpus hashes are an integrity receipt: verify them before anything is built. run(["python3", "scripts/build_vision_corpus.py", "--verify"], tree) + # Issue #25's corpus pipeline is a CLI, so its own suite is a release gate, not just CI. + run(["python3", "tests/corpus-manifest.test.py", "-v"], tree) server_env = dict(os.environ) server_env.update({"HOST": "127.0.0.1", "PORT": "4173", "TIMMY_VISION_ENABLED": "false"}) # A leftover server from another checkout answers on this port and the readiness probe cannot @@ -247,7 +253,7 @@ def main() -> int: run(["bash", "-n", "deploy/worker/entrypoint.sh"], tree) run(["bash", "-n", "deploy/worker/build_image.sh"], tree) run(["bash", "-n", "deploy/worker/run_worker_container.sh"], tree) - run(["python3", "-m", "py_compile", "scripts/ingest_training_photo.py", "scripts/build_release.py", "scripts/build_vision_corpus.py"], tree) + run(["python3", "-m", "py_compile", "scripts/ingest_training_photo.py", "scripts/build_release.py", "scripts/build_vision_corpus.py", "scripts/corpus_manifest.py"], tree) run(["git", "diff", "--check", commit], tree) names = tracked_files(tree) @@ -281,7 +287,8 @@ def main() -> int: "- Explainable pattern summaries (issue #34): the home insight card reports confirmed-log counts and form groupings per window, every line names the exact confirmed records behind it, following a citation opens those records, and a window below the minimum says it is too sparse instead of describing a trend; no line explains a cause, rules on food, or offers a health verdict\n" "- Private photo lifecycle (issue #36): the journal marks a log that holds a saved photo without rendering a thumbnail, showing the image is an explicit request, removing it keeps every confirmed field, deleting a log is a separate two-step decision that takes its photo, and delete-all removes the ledger and every stored photo together\n" "- Training contribution is a separate opt-in (issue #24): a journal save records no contribution, the consent version and the receipt are on screen before the control can be pressed, the receipt is queued on this device only (this build transmits nothing) and carries no note text, symptom answers, or photo bytes, and a withdrawal keeps a tombstone naming every derived artifact by contribution id\n" - "- Safety: suggestions are observable-field assistance, never diagnosis; user confirmation is required\n" + "- Auditable corpus pipeline (issue #25): every ingested training sample now records its consent version and timestamp, source kind and the raw-input digest (the caller's file path is never stored), both the raw and derived hashes, the derivation pipeline, the human review status, the split, and its lifecycle state in an append-only manifest; withdrawing a contribution deletes the derived image and any raw pre-derivation copy from the active dataset, keeps the original record as its own history, and appends a tombstone that names every removed artifact by contribution id and digest while dropping labels and the subject key; illegal lifecycle moves (re-ingesting a contribution, re-labelling a derived image, revoking twice, revoking an unknown contribution, reviving a revoked one, revoking without a reason) are refused before any byte is written\n" + "- Scope note: the corpus pipeline is a command-line tool. There is no corpus screen in the app, and this release does not imply one — the recorded demo runs the real ingest and revoke commands against the committed synthetic fixture and shows their actual output\n" "- Known limitation: open-weight VLM acceptance is proven, Bristol accuracy is not clinically validated\n" "- Package excludes model weights, medical-image corpora, raw research pages, generated screenshots, and video production assets\n" ) @@ -326,7 +333,7 @@ def main() -> int: "codec": "h264", "pixel_format": "yuv420p", "recorded_from_working_app": True, - "fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, user-reported hard-stool follow-up with non-diagnostic self-care and a confirmed black/tarry red flag, the CC0 synthetic vision corpus replaying a non-stool refusal, an unreadable-fields abstention, an out-of-range fail-closed reply, and a blank-plus-out-of-range fail-closed reply, offline vision status, fictional multi-day sample logs driving the explainable pattern summary and its citation sheet, a sparse fictional window showing the insufficient-data copy, a grounded local chat answer citing each counted log and opening the cited record, a refusal that borrows no journal evidence, and a deterministic Hermes chat reply whose positional refs render as sources, plus the opt-in training contribution path where nothing is queued after ordinary saves, the consent version and receipt precede the confirm control, one receipt is queued on the device only, and withdrawal leaves a tombstone naming each derived artifact, plus a versioned backup that carries the logs and the withdrawn receipt, an older version 1 file upgraded with the version named on screen, a file from a newer Timmy refused without changing the ledger, and a restored log still showing it began as a corrected AI suggestion", + "fixture_data": "synthetic-Type-4, deterministic vision suggestion reviewed, confirmed, and corrected to Type 2, user-reported hard-stool follow-up with non-diagnostic self-care and a confirmed black/tarry red flag, the CC0 synthetic vision corpus replaying a non-stool refusal, an unreadable-fields abstention, an out-of-range fail-closed reply, and a blank-plus-out-of-range fail-closed reply, offline vision status, fictional multi-day sample logs driving the explainable pattern summary and its citation sheet, a sparse fictional window showing the insufficient-data copy, a grounded local chat answer citing each counted log and opening the cited record, a refusal that borrows no journal evidence, and a deterministic Hermes chat reply whose positional refs render as sources, plus the opt-in training contribution path where nothing is queued after ordinary saves, the consent version and receipt precede the confirm control, one receipt is queued on the device only, and withdrawal leaves a tombstone naming each derived artifact, plus a versioned backup that carries the logs and the withdrawn receipt, an older version 1 file upgraded with the version named on screen, a file from a newer Timmy refused without changing the ledger, and a restored log still showing it began as a corrected AI suggestion, plus the corpus audit card, where the real ingest and revoke commands run against the committed synthetic fixture and the card shows their actual output: the provenance record with consent, source digest, both hashes, derivation, review and lifecycle; the derived image deleted from disk; the original record kept as history beside a tombstone that drops labels and the subject key; zero active samples for a trainer; and a second revocation refused without appending anything", }, "gates": { "unit_security": "passed", @@ -342,6 +349,7 @@ def main() -> int: "longitudinal_pattern_summary": "passed", "grounded_chat_citations": "passed", "training_contribution_consent": "passed", + "corpus_manifest_provenance": "passed", "portable_backup_restore": "passed", "dependency_audit_high": "passed", "syntax": "passed", diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs index b8d2742..ac9fb6f 100644 --- a/scripts/record_release_demo.mjs +++ b/scripts/record_release_demo.mjs @@ -1,10 +1,16 @@ import { chromium } from 'playwright'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +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 @@ -521,6 +527,188 @@ for (const artifact of tombstone.artifacts) { 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 '], + ['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: 'rgba(24,20,18,.97)', 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)); + return { + ok: 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, 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 the 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()); +// 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.