diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml index 7b1967f..eafdd8a 100644 --- a/.gitea/workflows/quality.yml +++ b/.gitea/workflows/quality.yml @@ -57,6 +57,7 @@ jobs: npm run test:followup npm run test:vision-refusal npm run test:worker-readiness + npm run test:pattern-summary - name: Dependency audit run: npm audit --audit-level=high - name: Syntax checks diff --git a/README.md b/README.md index ae458df..03dbabe 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ npm audit --audit-level=high - A committed CC0 synthetic vision corpus that continuously proves every reply is schema-valid or fails closed. A schema-valid reply with no readable form or colour now **abstains** ("No confident match") instead of surfacing as a worker failure, and unparsable or out-of-range replies return plain copy — "No suggestion was applied. Continue manually, or try a clearer photo." — with no provider text, image bytes, or credentials echoed. See [tests/fixtures/vision-corpus](tests/fixtures/vision-corpus/README.md) - Unsaved logging drafts are retained in the session with an explicit start over, and the service worker never caches analysis requests or image bodies - Local browser ledger, calendar, pattern summary, JSON portability, and delete-all +- Explainable pattern summaries: the home insight card reports the confirmed-log count and form groupings for the last 28 days, 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 - Installable/offline PWA shell for the manual and saved-ledger paths ## What is deliberately not faked diff --git a/app.js b/app.js index 6e9a12e..b36d40b 100644 --- a/app.js +++ b/app.js @@ -4,6 +4,7 @@ import { createDraft, draftNotice, isDraftDirty } from './src/draft.js'; import { changeVisualField, confirmSuggestion, createSuggestionState, provenanceCopy } from './src/suggestion.js'; import { HARD_STOOL_QUESTIONS, createFollowUpAnswers, evaluateHardStoolFollowUp, needsHardStoolFollowUp } from './src/followup.js'; import { visionReadiness } from './src/vision-readiness.js'; +import { buildPatternSummary } from './src/summary.js'; const runtimeConfig = { basePath: document.querySelector('meta[name="timmy-base-path"]')?.content || '/', @@ -96,6 +97,7 @@ function bindGlobal(){ document.querySelectorAll('[data-view]').forEach(btn=>btn.onclick=()=>{view=btn.dataset.view;render()}); document.querySelectorAll('[data-log]').forEach(btn=>btn.onclick=openLogger); document.querySelectorAll('[data-scan]').forEach(btn=>btn.onclick=openPhotoFirst); + document.querySelectorAll('[data-statement-link]').forEach(btn=>btn.onclick=()=>showPatternRecords(btn.dataset.statementLink)); } function recentList(limit=5){ @@ -105,9 +107,35 @@ function recentList(limit=5){ function thisWeek(){const now=Date.now(),week=7*864e5;return entries.filter(e=>now-new Date(e.occurredAt).getTime()e.occurredAt.slice(0,10)));let n=0,d=new Date();while(dates.has(d.toISOString().slice(0,10))){n++;d.setDate(d.getDate()-1)}return n} +// Issue #34: the home insight card is explainable. Every line says which confirmed records it was +// built from, following a citation shows exactly those records, and a sparse window says so +// instead of describing a trend the data cannot support. +function patternSummaryCard(){ + const summary=buildPatternSummary(entries); + const logs=`${summary.records.length} confirmed log${summary.records.length===1?'':'s'}`; + const lines=summary.insufficient + ? `

${esc(summary.copy)}

` + : ``; + return `
Timmy noticed

${esc(summary.window.label)} · ${logs}

${lines}

${esc(summary.boundary)}

`; +} +function showPatternRecords(statementId){ + const summary=buildPatternSummary(entries); + const statement=summary.statements.find(item=>item.id===statementId); + if(!statement)return; + const byId=new Map(summary.records.map(record=>[record.id,record])); + const rows=statement.entryIds.map(id=>byId.get(id)).filter(Boolean) + .map(record=>`
T${record.bristolType}
${formatDate(record.occurredAt)}Type ${record.bristolType} · ${esc(record.bucket)}
`).join(''); + document.querySelector('.sheet-backdrop')?.remove(); + const wrap=document.createElement('div');wrap.className='sheet-backdrop'; + wrap.innerHTML=``; + document.body.append(wrap); + document.querySelector('#pattern-records-close').onclick=()=>wrap.remove(); + wrap.onclick=e=>{if(e.target===wrap)wrap.remove()}; +} + function home(){ const latest=entries.slice().sort((a,b)=>new Date(b.occurredAt)-new Date(a.occurredAt))[0]; - shell(`
Your intelligent pooping pal

Log it.
Learn the pattern.

Start with a photo. Timmy suggests visible form and color; you review everything before it is saved.

${thisWeek()}this week
${currentStreak()}day streak
${entries.length}all logs
Timmy noticed

${esc(buildTimmySummary(entries))}

Latest

${latest?'Recent log':'Ready when you are'}

${latest?'':''}
${latest?`
T${latest.bristolType}
${formatDate(latest.occurredAt)}${esc(latest.color)} · ${bucketForBristolType(latest.bristolType)}
›
`:'

One quick, confirmed entry is enough to begin seeing your pattern.

'}
`); + shell(`
Your intelligent pooping pal

Log it.
Learn the pattern.

Start with a photo. Timmy suggests visible form and color; you review everything before it is saved.

${thisWeek()}this week
${currentStreak()}day streak
${entries.length}all logs
${patternSummaryCard()}
Latest

${latest?'Recent log':'Ready when you are'}

${latest?'':''}
${latest?`
T${latest.bristolType}
${formatDate(latest.occurredAt)}${esc(latest.color)} · ${bucketForBristolType(latest.bristolType)}
›
`:'

One quick, confirmed entry is enough to begin seeing your pattern.

'}
`); } function calendar(){ diff --git a/artifacts/pattern-summary-home-mobile.png b/artifacts/pattern-summary-home-mobile.png new file mode 100644 index 0000000..ccf31a4 Binary files /dev/null and b/artifacts/pattern-summary-home-mobile.png differ diff --git a/artifacts/pattern-summary-insufficient-mobile.png b/artifacts/pattern-summary-insufficient-mobile.png new file mode 100644 index 0000000..7e27843 Binary files /dev/null and b/artifacts/pattern-summary-insufficient-mobile.png differ diff --git a/artifacts/pattern-summary-records-mobile.png b/artifacts/pattern-summary-records-mobile.png new file mode 100644 index 0000000..f14a5cb Binary files /dev/null and b/artifacts/pattern-summary-records-mobile.png differ diff --git a/package.json b/package.json index ea13c19..438a999 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js", + "test": "node --test tests/domain.test.js tests/analysis.test.js tests/vision-service.test.js tests/vision-config.test.js tests/draft.test.js tests/suggestion.test.js tests/followup.test.js tests/vision-readiness.test.js tests/hermes-agent-service.test.js tests/agent-gateway.acceptance.test.js tests/staging-health.test.js tests/service-worker-runtime.test.js tests/training-ingest.test.js tests/ci-workflow.test.js tests/product-decisions.test.js tests/release-demo.test.js tests/selfhost-bootstrap.test.js tests/staging-config.test.js tests/threat-model.test.js tests/vision-corpus.test.js tests/vision-boundary.test.js tests/worker-benchmark.test.js tests/worker-ready.test.js tests/worker-container.test.js tests/summary.test.js", "test:ui": "node tests/ui.acceptance.mjs", "test:photo": "node tests/photo-first.acceptance.mjs", "test:mobile-capture": "node tests/mobile-capture.acceptance.mjs", @@ -14,7 +14,8 @@ "test:followup": "node tests/followup.acceptance.mjs", "test:vision-refusal": "node tests/vision-refusal.acceptance.mjs", "test:staging-smoke": "node tests/staging.acceptance.mjs", - "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check scripts/record_worker_benchmark.mjs && node --check tests/worker-readiness.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh", + "test:pattern-summary": "node tests/pattern-summary.acceptance.mjs", + "check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/draft.js && node --check src/suggestion.js && node --check src/followup.js && node --check src/vision-readiness.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check src/vision-corpus.js && node --check src/summary.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && node --check tests/manual-fallback.acceptance.mjs && node --check tests/provisional-suggestion.acceptance.mjs && node --check tests/followup.acceptance.mjs && node --check tests/vision-refusal.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py scripts/build_vision_corpus.py && node --check scripts/record_worker_benchmark.mjs && node --check tests/worker-readiness.acceptance.mjs && node --check tests/pattern-summary.acceptance.mjs && bash -n scripts/worker_ready.sh && bash -n deploy/worker/entrypoint.sh && bash -n deploy/worker/build_image.sh && bash -n deploy/worker/run_worker_container.sh", "check:diff": "bash scripts/check_diff.sh", "start": "node server.mjs", "test:worker-readiness": "node tests/worker-readiness.acceptance.mjs" diff --git a/scripts/build_release.py b/scripts/build_release.py index 9d5379c..ce40fa6 100755 --- a/scripts/build_release.py +++ b/scripts/build_release.py @@ -40,6 +40,8 @@ DEMO_REQUIRED_CAPTIONS = ( "An out-of-range answer fails closed: nothing is applied and no provider detail reaches you", "A filled-in field that is out of range still fails closed, even when the other field came back blank", "Reachable is not loaded: no AI control, no consent, and no upload until the pinned model is resident", + "Every pattern line names the confirmed logs behind it, and following one shows exactly those records", + "Two logs is not a pattern: the summary refuses to describe a trend it cannot support", ) @@ -138,6 +140,7 @@ def main() -> int: run(["npm", "run", "test:followup"], tree) run(["npm", "run", "test:vision-refusal"], tree) run(["npm", "run", "test:worker-readiness"], tree) + run(["npm", "run", "test:pattern-summary"], 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) @@ -179,7 +182,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", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.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", "scripts/record_worker_benchmark.mjs", "tests/staging.acceptance.mjs", "tests/vision-refusal.acceptance.mjs", "tests/worker-readiness.acceptance.mjs", "tests/pattern-summary.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) @@ -218,6 +221,7 @@ def main() -> int: "- Self-hosted worker container: a digest-pinned image that builds the pinned llama.cpp revision, mounts hash-verified weights read-only, runs unprivileged with no credential, and is published on loopback only (issue #15)\n" "- Post-load readiness: the app offers AI only once the pinned model is actually loaded, so a reachable-but-unloaded worker falls back to manual logging in one tap instead of failing after the upload (issue #15)\n" "- 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" "- 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" @@ -263,7 +267,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, and deterministic Hermes chat response", + "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, and deterministic Hermes chat response", }, "gates": { "unit_security": "passed", @@ -276,6 +280,7 @@ def main() -> int: "vision_corpus_refusal": "passed", "worker_container_receipts": "passed", "worker_post_load_readiness": "passed", + "longitudinal_pattern_summary": "passed", "dependency_audit_high": "passed", "syntax": "passed", "secret_scan": "passed", diff --git a/scripts/record_release_demo.mjs b/scripts/record_release_demo.mjs index c90ff05..c008580 100644 --- a/scripts/record_release_demo.mjs +++ b/scripts/record_release_demo.mjs @@ -314,6 +314,54 @@ await caption('Every refusal replays a committed synthetic fixture — there are 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' }); + 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(); diff --git a/src/summary.js b/src/summary.js new file mode 100644 index 0000000..5dabd83 --- /dev/null +++ b/src/summary.js @@ -0,0 +1,76 @@ +import { bucketForBristolType } from './domain.js'; + +export const PATTERN_WINDOW_DAYS = 28; +// Two logs are an anecdote. Below this the summary is required to say so instead of describing a trend. +export const MIN_WINDOW_LOGS = 3; + +const DAY_MS = 86400000; + +// The summary may only describe what the user recorded. No cause, no health verdict, no food ruling. +const BOUNDARY = 'Built only from your own confirmed records in this window. Timmy reports what you logged and when — not why, and never a verdict on your health or your food.'; + +function inWindowRecords(entries, now, windowDays) { + const startedAt = now - windowDays * DAY_MS; + return (Array.isArray(entries) ? entries : []) + .map(entry => ({ entry, at: new Date(entry?.occurredAt).getTime() })) + .filter(({ at }) => Number.isFinite(at) && at <= now && at >= startedAt) + .sort((left, right) => left.at - right.at) + .map(({ entry, at }) => ({ + id: String(entry?.id ?? ''), + occurredAt: new Date(at).toISOString(), + bristolType: Number(entry?.bristolType), + bucket: bucketForBristolType(entry?.bristolType), + })); +} + +export function buildPatternSummary(entries = [], { now = Date.now(), windowDays = PATTERN_WINDOW_DAYS } = {}) { + const records = inWindowRecords(entries, now, windowDays); + const window = { + days: windowDays, + label: `last ${windowDays} days`, + start: new Date(now - windowDays * DAY_MS).toISOString(), + end: new Date(now).toISOString(), + }; + + if (records.length < MIN_WINDOW_LOGS) { + return { + window, + records, + insufficient: true, + statements: [], + boundary: BOUNDARY, + copy: `Not enough confirmed logs in this window yet — ${records.length} of ${MIN_WINDOW_LOGS} needed before Timmy describes a pattern.`, + }; + } + + const frequency = { + id: 'frequency', + text: `${records.length} confirmed log${records.length === 1 ? '' : 's'} in the ${window.label}.`, + entryIds: records.map(record => record.id), + }; + + const buckets = new Map(); + for (const record of records) { + if (!buckets.has(record.bucket)) buckets.set(record.bucket, []); + buckets.get(record.bucket).push(record.id); + } + const bucketLine = (bucket, label) => ({ + id: bucket === 'constipation' ? 'firm' : bucket, + text: `${label}: ${buckets.get(bucket).length} of ${records.length} confirmed log${records.length === 1 ? '' : 's'}.`, + entryIds: buckets.get(bucket), + }); + const formLines = [ + ['constipation', 'Firm side (Type 1–2)'], + ['typical', 'Typical range (Type 3–4)'], + ['loose', 'Loose side (Type 5–7)'], + ].filter(([bucket]) => buckets.has(bucket)).map(([bucket, label]) => bucketLine(bucket, label)); + // Both ends in one window is not a trend, and the summary has to say so out loud rather than + // picking whichever side is marginally larger. + const mixedLine = buckets.has('constipation') && buckets.has('loose') ? [{ + id: 'mixed', + text: `Firm and loose logs both appear in the ${window.label} (${buckets.get('constipation').length} firm, ${buckets.get('loose').length} loose), which is a mixed pattern and not one trend.`, + entryIds: records.filter(record => record.bucket === 'constipation' || record.bucket === 'loose').map(record => record.id), + }] : []; + + return { window, records, insufficient: false, statements: [frequency, ...formLines, ...mixedLine], copy: '', boundary: BOUNDARY }; +} diff --git a/styles.css b/styles.css index 6146c38..e02a8d1 100644 --- a/styles.css +++ b/styles.css @@ -46,3 +46,9 @@ main{display:block}.eyebrow{font-size:11px;font-weight:800;letter-spacing:.1em;t .followup-guidance .fine{margin:8px 0 0} .followup-guidance.escalated{background:var(--red-soft);border-color:#eec1ba} .followup-guidance.escalated>strong{color:#75261f} +.pattern-card{align-items:start} +.pattern-window{margin:3px 0 6px;font-size:12px;font-weight:800;color:var(--teal);letter-spacing:.02em} +.pattern-statements{margin:0;padding:0;list-style:none} +.pattern-statements li{font-size:13.5px;line-height:1.45;margin-bottom:7px;color:#4d453e} +.pattern-link{display:inline-block;margin-top:3px;min-height:32px;padding:5px 10px;border:1px solid #bcdfd4;border-radius:999px;background:var(--surface);color:var(--teal);font-weight:800;font-size:11px;cursor:pointer} +.pattern-boundary{margin:8px 0 0;color:var(--muted)} diff --git a/tests/ci-workflow.test.js b/tests/ci-workflow.test.js index b06ef17..c02cfb2 100644 --- a/tests/ci-workflow.test.js +++ b/tests/ci-workflow.test.js @@ -143,3 +143,21 @@ test('the pinned worker container, post-load gate, and GPU benchmark are gated e assert.match(builder, /"worker_post_load_readiness": "passed"/); assert.match(builder, /"scripts\/worker_ready\.sh"/); }); + +test('explainable pattern summaries are a unit-, browser-, and release-gated contract', async () => { + const [packageJsonText, workflow, builder] = await Promise.all([ + readFile(packagePath, 'utf8'), + readFile(workflowPath, 'utf8'), + readFile(new URL('../scripts/build_release.py', import.meta.url), 'utf8'), + ]); + const packageJson = JSON.parse(packageJsonText); + + assert.match(packageJson.scripts.test, /tests\/summary\.test\.js/); + assert.equal(packageJson.scripts['test:pattern-summary'], 'node tests/pattern-summary.acceptance.mjs'); + assert.match(packageJson.scripts['check:syntax'], /node --check src\/summary\.js/); + assert.match(packageJson.scripts['check:syntax'], /node --check tests\/pattern-summary\.acceptance\.mjs/); + assert.match(workflow, /npm run test:pattern-summary/); + assert.match(builder, /"test:pattern-summary"/); + assert.match(builder, /"longitudinal_pattern_summary": "passed"/); + assert.match(builder, /"src\/summary\.js"/); +}); diff --git a/tests/pattern-summary.acceptance.mjs b/tests/pattern-summary.acceptance.mjs new file mode 100644 index 0000000..252bf7d --- /dev/null +++ b/tests/pattern-summary.acceptance.mjs @@ -0,0 +1,88 @@ +import { chromium } from 'playwright'; +import assert from 'node:assert/strict'; +import { mkdir } from 'node:fs/promises'; + +// Issue #34: every longitudinal summary line must point at the confirmed records behind it, and a +// sparse window must say so instead of describing a trend. The records are fictional sample entries +// written into the app's own local ledger contract; no real health data is involved. +const DAY = 86400000; +const sample = (id, daysAgo, bristolType) => ({ + id, + occurredAt: new Date(Date.now() - daysAgo * DAY).toISOString(), + bristolType, + color: 'brown', + urgency: 1, + discomfort: 1, + note: '', + photoDataUrl: '', + symptoms: {}, +}); + +await mkdir('artifacts', { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, serviceWorkers: 'block' }); +const page = await context.newPage(); +const errors = []; +page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); +page.on('pageerror', error => errors.push(error.message)); + +const seed = async entries => page.evaluate(list => { + const base = document.querySelector('meta[name="timmy-base-path"]')?.content || '/'; + localStorage.setItem(`timmy:${base}:ledger-v1`, JSON.stringify(list)); +}, entries); + +await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' }); +await page.evaluate(() => localStorage.clear()); + +// 1. A sparse window refuses to describe a pattern. +await seed([sample('sparse-a', 1, 4), sample('sparse-b', 4, 6)]); +await page.reload({ waitUntil: 'networkidle' }); +const sparse = page.locator('[data-summary-insufficient]'); +assert.equal(await sparse.count(), 1, 'a window below the minimum must show honest insufficient-data copy'); +assert.match(await sparse.innerText(), /not enough confirmed logs/i); +assert.match(await sparse.innerText(), /2 of 3/); +assert.equal(await page.locator('[data-statement]').count(), 0, 'no pattern line may be claimed from two logs'); +await page.screenshot({ path: 'artifacts/pattern-summary-insufficient-mobile.png' }); + +// 2. A populated window renders explainable lines, each citing its own records. +const seeded = [ + sample('rec-firm', 2, 1), + sample('rec-typical-1', 5, 3), + sample('rec-typical-2', 9, 4), + sample('rec-typical-3', 12, 4), + sample('rec-old', 40, 6), +]; +await seed(seeded); +await page.reload({ waitUntil: 'networkidle' }); + +const card = page.locator('[data-pattern-summary]'); +assert.equal(await card.count(), 1, 'the home insight card is the explainable summary'); +assert.match(await page.locator('[data-summary-window]').innerText(), /last 28 days/i); +assert.match(await page.locator('[data-statement="frequency"]').innerText(), /4 confirmed logs/); +assert.equal(await page.locator('[data-statement="firm"]').getAttribute('data-records'), '1'); +assert.equal(await page.locator('[data-statement="typical"]').getAttribute('data-records'), '3'); +// The out-of-window log is neither counted nor offered as a citation. +assert.doesNotMatch(await card.innerText(), /5 confirmed logs/); +await page.screenshot({ path: 'artifacts/pattern-summary-home-mobile.png' }); + +// 3. Following a citation shows exactly the contributing confirmed records. +await page.locator('[data-statement-link="typical"]').click(); +const records = page.locator('[data-summary-records]'); +await records.waitFor(); +assert.equal(await page.locator('[data-summary-record]').count(), 3, 'the citation lists exactly its own records'); +const listed = await page.locator('[data-summary-record]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-entry-id'))); +assert.deepEqual(listed.sort(), ['rec-typical-1', 'rec-typical-2', 'rec-typical-3']); +assert.match(await records.innerText(), /Type 3/); +assert.match(await records.innerText(), /These are the confirmed logs this line was built from/); +await page.screenshot({ path: 'artifacts/pattern-summary-records-mobile.png' }); +await page.locator('#pattern-records-close').click(); +assert.equal(await page.locator('[data-summary-records]').count(), 0, 'the citation sheet closes cleanly'); + +// 4. The summary never explains a cause or clears a food. +const summaryText = await card.innerText(); +assert.doesNotMatch(summaryText, /because|due to|caused|safe to eat|avoid eating|should eat|diagnos/i); + +assert.deepEqual(errors, []); +await context.close(); +await browser.close(); +console.log('PASS longitudinal pattern summaries cite their confirmed records and refuse to describe a sparse window'); diff --git a/tests/release-demo.test.js b/tests/release-demo.test.js index 27112fd..a23192d 100644 --- a/tests/release-demo.test.js +++ b/tests/release-demo.test.js @@ -50,6 +50,27 @@ test('release demo shows the provisional, confirmed, and corrected suggestion st assert.match(demo, /Type 2/, 'the retained draft in the demo must show the value the user corrected to'); }); +test('release demo shows the explainable pattern summary and follows a citation to its own records', async () => { + const demo = await readFile(demoPath, 'utf8'); + const builder = await readFile(builderPath, 'utf8'); + + // Issue #34: the recording must exercise the summary, its citation sheet, and the sparse case. + assert.match(demo, /\[data-pattern-summary\]/); + assert.match(demo, /\[data-statement-link="typical"\]/); + assert.match(demo, /\[data-summary-record\]/); + assert.match(demo, /\[data-summary-insufficient\]/); + assert.match(demo, /The summary says which confirmed logs it read, and a citation opens exactly those records/); + assert.match(demo, /Every pattern line names the confirmed logs behind it, and following one shows exactly those records/); + assert.match(demo, /Fictional sample logs are seeded into this browser's own storage — no real health data appears here/); + assert.match(demo, /Two logs is not a pattern: the summary refuses to describe a trend it cannot support/); + // The cited records are asserted in the browser, not narrated. + assert.match(demo, /must list exactly the records it cites/); + // Both captions are required release gates, so a later release cannot drop the scene silently. + assert.match(builder, /Every pattern line names the confirmed logs behind it, and following one shows exactly those records/); + assert.match(builder, /Two logs is not a pattern: the summary refuses to describe a trend it cannot support/); + assert.match(builder, /"longitudinal_pattern_summary": "passed"/); +}); + test('release builder gates the sleek shell, Hermes chat, and bootstrap syntax', async () => { const builder = await readFile(builderPath, 'utf8'); assert.match(builder, /"test:sleek"/); diff --git a/tests/summary.test.js b/tests/summary.test.js new file mode 100644 index 0000000..1d10899 --- /dev/null +++ b/tests/summary.test.js @@ -0,0 +1,100 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildPatternSummary } from '../src/summary.js'; + +const DAY = 86400000; +const NOW = Date.parse('2026-09-20T12:00:00.000Z'); +const entry = (id, daysAgo, bristolType) => ({ + id, + occurredAt: new Date(NOW - daysAgo * DAY).toISOString(), + bristolType, + color: 'brown', + urgency: 0, + discomfort: 0, + note: '', + symptoms: {}, +}); + +test('every summary statement cites the confirmed records it was built from', () => { + const summary = buildPatternSummary([entry('a', 1, 4), entry('b', 3, 3), entry('c', 5, 6)], { now: NOW }); + + assert.equal(summary.insufficient, false); + assert.equal(summary.window.label, 'last 28 days'); + + const frequency = summary.statements.find(statement => statement.id === 'frequency'); + assert.ok(frequency, 'the window needs a frequency line'); + assert.match(frequency.text, /3 confirmed logs/); + assert.match(frequency.text, /last 28 days/); + assert.deepEqual(frequency.entryIds, ['c', 'b', 'a']); + + // No statement may cite an id the summary cannot point at a real record for. + const known = new Set(summary.records.map(record => record.id)); + for (const statement of summary.statements) { + assert.ok(statement.entryIds.length > 0, `${statement.id} must cite its contributing records`); + for (const id of statement.entryIds) assert.ok(known.has(id), `${statement.id} cited an unknown record ${id}`); + } +}); + +test('each form line cites only the records in that form bucket', () => { + const summary = buildPatternSummary([entry('a', 1, 2), entry('b', 3, 4), entry('c', 5, 4), entry('d', 6, 4)], { now: NOW }); + + const firm = summary.statements.find(statement => statement.id === 'firm'); + assert.ok(firm, 'a firm-side line is needed when firm logs exist'); + assert.match(firm.text, /Type 1–2/); + assert.match(firm.text, /1 of 4/); + assert.deepEqual(firm.entryIds, ['a']); + + const typical = summary.statements.find(statement => statement.id === 'typical'); + assert.match(typical.text, /3 of 4/); + assert.deepEqual(typical.entryIds, ['d', 'c', 'b']); + assert.equal(summary.statements.some(statement => statement.id === 'loose'), false); +}); + +test('a contradictory window is reported as a mixed pattern instead of one trend', () => { + const summary = buildPatternSummary([entry('a', 1, 1), entry('b', 2, 1), entry('c', 3, 6), entry('d', 4, 7)], { now: NOW }); + + const mixed = summary.statements.find(statement => statement.id === 'mixed'); + assert.ok(mixed, 'a window with both firm and loose logs needs an explicit mixed line'); + assert.match(mixed.text, /firm and loose logs both appear/i); + assert.match(mixed.text, /not one trend/i); + assert.deepEqual(mixed.entryIds, ['d', 'c', 'b', 'a']); +}); + +test('logs outside the window are neither counted nor cited', () => { + const summary = buildPatternSummary([entry('old', 40, 1), entry('a', 1, 4), entry('b', 3, 3), entry('c', 5, 4)], { now: NOW }); + + assert.deepEqual(summary.records.map(record => record.id), ['c', 'b', 'a']); + assert.equal(summary.statements.some(statement => statement.entryIds.includes('old')), false); + assert.match(summary.statements.find(statement => statement.id === 'frequency').text, /3 confirmed logs/); + + const weekly = buildPatternSummary([entry('old', 40, 1), entry('a', 1, 4), entry('b', 2, 4), entry('c', 3, 4)], { now: NOW, windowDays: 7 }); + assert.equal(weekly.window.label, 'last 7 days'); + assert.equal(weekly.window.days, 7); + assert.deepEqual(weekly.records.map(record => record.id), ['c', 'b', 'a']); +}); + +test('the summary states its boundary and never explains a cause or clears a food', () => { + const summary = buildPatternSummary([entry('a', 1, 4), entry('b', 3, 3), entry('c', 5, 6)], { now: NOW }); + assert.match(summary.boundary, /your own confirmed records/i); + + const forbidden = /because|due to|caused|triggered|safe to eat|avoid eating|should eat|clears|diagnos|treat/i; + for (const text of [summary.boundary, summary.copy, ...summary.statements.map(statement => statement.text)]) { + assert.doesNotMatch(text, forbidden, text); + } + const sparse = buildPatternSummary([entry('a', 1, 4)], { now: NOW }); + for (const text of [sparse.boundary, sparse.copy]) { + assert.doesNotMatch(text, forbidden, text); + } +}); + +test('refuses to describe a pattern from too few confirmed logs', () => { + const summary = buildPatternSummary([entry('a', 1, 4), entry('b', 3, 3)], { now: NOW }); + + assert.equal(summary.insufficient, true); + assert.deepEqual(summary.statements, []); + assert.match(summary.copy, /not enough confirmed logs/i); + assert.match(summary.copy, /2 of 3/); + // The summary still lists exactly what it did read, in the order it happened. + assert.deepEqual(summary.records.map(record => record.id), ['b', 'a']); +});