feat(training): separate opt-in contribution receipts with withdrawable tombstones
Quality gates / quality (pull_request) Successful in 3m44s
Quality gates / quality (pull_request) Successful in 3m44s
A journal save now records no training contribution. A contribution is a second consent: the privacy page states consent version training-consent-v1 and the receipt (contribution id, included fields, never-included fields) before the confirm control is enabled, the receipt is queued on this device only and copies no photo bytes, note text, or symptom answers, and withdrawal keeps a tombstone naming every derived artifact by contribution id. Delete-all clears the receipts with the ledger. Closes #24
This commit is contained in:
1 parent
d0b3c10aea
commit
ef22ea8970
11 files changed
+559
-6
No files matched your search
@@ -51,6 +51,9 @@ DEMO_REQUIRED_CAPTIONS = (
|
||||
"The answer states the count and cites the four confirmed logs it counted",
|
||||
"Following a citation opens exactly the confirmed log behind that answer",
|
||||
"Ask something the journal cannot answer and Timmy refuses instead of reassuring",
|
||||
"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",
|
||||
)
|
||||
|
||||
|
||||
@@ -152,6 +155,7 @@ def main() -> int:
|
||||
run(["npm", "run", "test:pattern-summary"], tree)
|
||||
run(["npm", "run", "test:grounded-chat"], tree)
|
||||
run(["npm", "run", "test:photo-lifecycle"], tree)
|
||||
run(["npm", "run", "test:training-contribution"], tree)
|
||||
demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm"
|
||||
caption_receipt = release_dir / f"timmy-talking-turd-{version}-demo-captions.json"
|
||||
demo_env = dict(server_env)
|
||||
@@ -199,7 +203,7 @@ def main() -> int:
|
||||
if not contact_sheet.exists() or contact_sheet.stat().st_size == 0:
|
||||
raise SystemExit("Release demo contact sheet was not produced")
|
||||
run(["npm", "audit", "--audit-level=high"], tree)
|
||||
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/suggestion.js", "src/followup.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "src/vision-corpus.js", "src/summary.js", "src/photo-lifecycle.js", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs", "tests/pattern-summary.acceptance.mjs", "tests/photo-lifecycle.acceptance.mjs"):
|
||||
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/draft.js", "src/suggestion.js", "src/followup.js", "src/vision-readiness.js", "src/hermes-agent-service.js", "src/vision-config.js", "src/vision-service.js", "src/vision-corpus.js", "src/summary.js", "src/photo-lifecycle.js", "src/training-contribution.js", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs", "tests/pattern-summary.acceptance.mjs", "tests/photo-lifecycle.acceptance.mjs", "tests/training-contribution.acceptance.mjs"):
|
||||
run(["node", "--check", file], tree)
|
||||
run(["bash", "-n", "scripts/bootstrap_selfhost_smolvlm.sh"], tree)
|
||||
run(["bash", "-n", "scripts/run_selfhost_smolvlm.sh"], tree)
|
||||
@@ -240,6 +244,7 @@ def main() -> int:
|
||||
"- Target-GPU benchmark: scripts/record_worker_benchmark.mjs records cold start, warm latency, and VRAM on the target GPU and refuses to write a receipt it did not measure; no GPU measurement is bundled with this release\n"
|
||||
"- 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"
|
||||
"- 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"
|
||||
@@ -285,7 +290,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",
|
||||
"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",
|
||||
},
|
||||
"gates": {
|
||||
"unit_security": "passed",
|
||||
@@ -300,6 +305,7 @@ def main() -> int:
|
||||
"worker_post_load_readiness": "passed",
|
||||
"longitudinal_pattern_summary": "passed",
|
||||
"grounded_chat_citations": "passed",
|
||||
"training_contribution_consent": "passed",
|
||||
"dependency_audit_high": "passed",
|
||||
"syntax": "passed",
|
||||
"secret_scan": "passed",
|
||||
|
||||
@@ -477,6 +477,49 @@ 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]');
|
||||
await indicate('#delete-all');
|
||||
await tap('#delete-all', 650);
|
||||
await page.getByText(/Local ledger and photos deleted/).waitFor();
|
||||
@@ -485,6 +528,9 @@ if (wiped.raw !== null) throw new Error('delete-all must remove the stored ledge
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user