From 693e02375883c6e0a8e8263a7f87c9a3228b88aa Mon Sep 17 00:00:00 2001 From: Timmy Date: Sat, 22 Aug 2026 23:24:51 +0000 Subject: [PATCH] fix: replace urgent regex patches with auditable expression grammar Second hostile review of 1aadca91 found nine ordinary urgent phrasings bypassing the deterministic gate at detector and service layers, plus punctuation-fragile contextual exclusions. Replace the accumulated narrow regex table with a structured, versioned, frozen urgent-expression grammar and one shared surface normalizer: - normalizeUrgentText: case folding, apostrophe unification, contraction expansion (can't/cant/can not -> cannot, haven't -> have not, ...), hyphen splitting, punctuation stripping, whitespace collapse - URGENT_EXPRESSION_GRAMMAR v2.0: per-flag ordered match expressions with bounded nonclinical anchor exclusions; anchors veto only the occurrence they sit beside (36-char window), so arbitrary future symptom language keeps escalating with no continuation-word allowlist - new RED->GREEN coverage at every layer: 9 review phrases + 5 prior phrases with tense/plural/pronoun/word-order/case/contraction/ punctuation variants, normalization-equivalence groups, anti-allowlist continuation sweep (147 combos), grammar structure audit, service zero-Hermes-call interception, and live-HTTP wiring proof with the bounded fake adapter (tests/escalation-http.test.js) Gates: npm test 99/99, check:syntax, check:diff, audit 0 vulns, staging-deploy 20/20, test:ui/test:photo/test:sleek against this checkout. No merge, no deploy. --- package.json | 2 +- src/domain.js | 210 ++++++++++++--- tests/escalation-boundary.test.js | 41 +++ tests/escalation-http.test.js | 131 ++++++++++ tests/escalation-wiring.test.js | 40 ++- tests/symptom-escalation.regression.test.js | 269 +++++++++++++++++++- 6 files changed, 656 insertions(+), 37 deletions(-) create mode 100644 tests/escalation-http.test.js diff --git a/package.json b/package.json index 5ceeb5d..2a879b1 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/symptom-escalation.regression.test.js tests/escalation-boundary.test.js tests/escalation-wiring.test.js tests/vision-service.test.js tests/vision-config.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", + "test": "node --test tests/domain.test.js tests/analysis.test.js tests/symptom-escalation.regression.test.js tests/escalation-boundary.test.js tests/escalation-wiring.test.js tests/escalation-http.test.js tests/vision-service.test.js tests/vision-config.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", "test:ui": "node tests/ui.acceptance.mjs", "test:photo": "node tests/photo-first.acceptance.mjs", "test:sleek": "node tests/sleek-chat.acceptance.mjs", diff --git a/src/domain.js b/src/domain.js index 876dcc8..a9cedfc 100644 --- a/src/domain.js +++ b/src/domain.js @@ -1,33 +1,181 @@ const URGENT_KEYS = ['blood', 'blackOrDarkRed', 'severePain', 'vomiting', 'fever', 'cannotPassGas']; const URGENT_MESSAGE = 'These reported symptoms can need prompt medical care. Contact a clinician or urgent service now; call emergency services for heavy or nonstop bleeding, fainting, or severe worsening symptoms.'; -// Deterministic red-flag language table. Every match escalates and nothing that -// consumes this table can weaken it; the regression suite pins both the positive -// phrases and the idiomatic non-medical phrasings that must stay out. + +// ============================================================================ +// Urgent-expression grammar (auditable replacement for regex patch accretion) +// ============================================================================ +// Classification runs in two deterministic stages: // -// Context-handling contract (see tests/symptom-escalation.regression.test.js): -// positives key on the clinical vocabulary itself, and nonclinical controls are -// expressed as structured exclusions around it (named-infection compounds, -// figurative idioms, concrete objects). Continuation wording after a symptom -// word must NEVER be narrowed through an allowlist — arbitrary natural -// continuations ("right now", "is 103", "started this morning") keep escalating. -const URGENT_TEXT_PATTERNS = Object.freeze([ - // Blood: explicit clinical objects/shapes only, so ordinary stool sentences - // ("my stool has been normal", "the poop contains seeds") stay silent while - // every blood-mention shape (in/on/with/has/contains/looks bloody) escalates. - ['blood', /\b(?:rectal bleeding|rectal bleed(?:ing)?|bleeding from (?:the |my |his |her |their )?(?:rectum|bottom)|blood(?:y)? (?:in|on|with) (?:my |the |his |her |their )?(?:stool|poop|bowel movement|rectum)|(?:bloody|blood-streaked) (?:stool|poop|bowel movement)|(?:stool|poop|bowel movement) (?:with blood|has blood|contains blood|has (?:blood clots|bloody streaks)|is bloody|looks bloody|looks like blood))\b/i], - ['blackOrDarkRed', /\b(?:(?:black|dark[- ]?red) (?:stool|poop|bowel movement)|(?:stool|poop|bowel movement) (?:is|looks?(?: like)?|appears?|seems?|was) (?:a )?(?:very )?(?:really )?(?:black|dark[- ]?red))s?\b/i], - // Severe pain: severity adjective + organ noun, with any determiner (or none) - // and optional location qualifiers between the preposition and the organ — - // never a specific possessive. - ['severePain', /\b(?:(?:severe|constant|unrelenting|intense|excruciating) (?:abdominal|stomach|belly) pain|(?:severe|constant|unrelenting|intense|excruciating) pain (?:in|around) (?:the |my |his |her |your |their |this )?(?:(?:lower|upper|left|right) )*(?:abdomen|belly))\b/i], - ['vomiting', /\b(?:vomit(?:ing|ed|s)?|throw(?:ing|s)? up|threw up|thrown up|puk(?:e|ed|ing|es)|barf(?:ed|ing|s)?|upchuck(?:ed|ing|s)?|toss(?:ed|ing|es)? (?:my|your|his|her|our|their|the) cookies|los(?:e|t|ing|es) (?:my|your|his|her|our|their|the) lunch|(?:i|we|you|he|she|they|someone) (?:(?:have|had|just|already|recently|am|are|was|were|kept) )?(?:hurl(?:s|ed|ing)?|spew(?:s|ed|ing)?)(?=\s*(?:[.!?]|$|again\b|twice\b|all night\b))|emesis)\b(?!\s+(?:my|your|his|her|our|their)\s+(?:hands|arms)|\s+confetti\b|\s+(?:a |the )?(?:scaffold|wall|barricade|tent)\b)/i], - // Fever: the bare clinical word escalates with any continuation. Exclusions - // are structured nonclinical contexts only — topic compounds - // (malaria/yellow/dengue/cabin), the fever tree, and figurative - // "feverish about"/"feverish with excitement". - ['fever', /(? { + const start = Math.max(0, index - ANCHOR_WINDOW); + const window = normalizedText.slice(start, index + length + ANCHOR_WINDOW); + return !rule.exclude.some(anchor => window.includes(anchor)); + }); + if (!clinical.length) continue; + } + flags.push(key); + } + return flags; +} + +export function detectUrgentText(text = '') { + const normalized = normalizeUrgentText(text); + const flags = classifyUrgentText(normalized); + return { urgent: flags.length > 0, flags, message: flags.length ? URGENT_MESSAGE : '' }; +} export function bucketForBristolType(type) { const value = Number(type); @@ -48,11 +196,6 @@ export function detectUrgentFlags(symptoms = {}) { }; } -export function detectUrgentText(text = '') { - const flags = URGENT_TEXT_PATTERNS.filter(([, pattern]) => pattern.test(String(text))).map(([key]) => key); - return { urgent: flags.length > 0, flags, message: flags.length ? URGENT_MESSAGE : '' }; -} - export function hasUrgentLedgerContext(entries = []) { return Array.isArray(entries) && entries.some(entry => detectUrgentFlags(entry?.symptoms).urgent || detectUrgentText(entry?.note).urgent); } @@ -66,7 +209,8 @@ export const urgentSymptomCopy = Object.freeze({ chatOverride: urgentChatMessage, }); -export const urgentTextPatterns = Object.freeze(URGENT_TEXT_PATTERNS.map(([key]) => key)); +export { normalizeUrgentText }; +export { URGENT_EXPRESSION_GRAMMAR as urgentExpressionGrammar }; export function buildTimmySummary(entries = []) { if (!entries.length) return 'No logs yet. Add one when you are ready and I’ll summarize the pattern—not diagnose it.'; diff --git a/tests/escalation-boundary.test.js b/tests/escalation-boundary.test.js index a8e44b6..62c4c62 100644 --- a/tests/escalation-boundary.test.js +++ b/tests/escalation-boundary.test.js @@ -80,6 +80,47 @@ test('review-reported false negatives escalate at the detector for every variant } }); +// Second hostile review (exact head 1aadca91): nine more ordinary urgent +// phrasings that bypassed both gates. Each must escalate at the detector AND +// be intercepted at the service boundary with zero Hermes calls. +const SECOND_REVIEW_PHRASES = [ + 'My stool had blood.', + 'My stools are bloody.', + 'My stools are black.', + 'My stool has turned black.', + 'My abdominal pain is severe.', + 'Pain in my abdomen is severe.', + 'I threw my lunch up.', + 'I can not pass gas.', + "I haven't been able to pass gas.", +]; + +test('second-review false negatives escalate at the detector for every phrase', () => { + for (const phrase of SECOND_REVIEW_PHRASES) { + const result = detectUrgentText(phrase); + assert.equal(result.urgent, true, JSON.stringify(phrase)); + assert.equal(result.message, URGENT_MESSAGE_COPY, phrase); + assert.equal(detectUrgentText(`please help, ${phrase.toLowerCase()}`).urgent, true, phrase); + } +}); + +test('second-review false negatives are intercepted at the service boundary with zero Hermes calls', async () => { + for (const phrase of SECOND_REVIEW_PHRASES) { + const calls = []; + const service = createHermesAgentService({ + config: configured(), + randomToken: () => 'second-review-cookie', + runTurn: async input => { calls.push(input); return { reply: 'unsafe upstream reply', sessionId: 'unsafe-session' }; }, + }); + await service.unlock({ origin, accessCode: 'test-agent-access-code-2026' }); + const result = await service.chat({ origin, cookieToken: 'second-review-cookie', payload: { message: phrase, ledger: [] } }); + assert.equal(calls.length, 0, `${JSON.stringify(phrase)} must never reach Hermes`); + assert.equal(result.safetyOverride, true, phrase); + assert.match(result.reply, /medical help/i, phrase); + assert.doesNotMatch(result.reply, /unsafe upstream/i); + } +}); + test('review-reported false negatives are intercepted at the service boundary with zero Hermes calls', async () => { for (const phrase of REVIEW_REGRESSION_PHRASES) { const calls = []; diff --git a/tests/escalation-http.test.js b/tests/escalation-http.test.js new file mode 100644 index 0000000..17c9a2c --- /dev/null +++ b/tests/escalation-http.test.js @@ -0,0 +1,131 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { chmod, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Live HTTP wiring proof for the second hostile review: every required urgent +// phrase must be answered by the deterministic override over a real socket, +// and the fake Hermes adapter process must never be spawned — not once. +const root = fileURLToPath(new URL('..', import.meta.url)); +const hermesFixture = fileURLToPath(new URL('./fixtures/fake-hermes.mjs', import.meta.url)); + +const REQUIRED_URGENT_PHRASES = [ + 'My stool had blood.', + 'My stools are bloody.', + 'My stools are black.', + 'My stool has turned black.', + 'My abdominal pain is severe.', + 'Pain in my abdomen is severe.', + 'I threw my lunch up.', + 'I can not pass gas.', + "I haven't been able to pass gas.", + 'I have a fever right now', + 'my fever is 103', + 'fever started this morning', + 'severe pain in the abdomen', + 'severe pain around the abdomen', +]; + +const REQUIRED_NONURGENT_PHRASES = [ + 'yellow-fever outbreak in history class', + 'The fever-tree is a plant', + 'The kids were feverish, with excitement before the trip.', + 'I threw up, my hands in surrender.', + 'We studied fever research last semester', + 'The crowd reached fever pitch', + 'Saturday Night Fever won awards', + 'Gold fever gripped the mining town', +]; + +async function startServer(t) { + const workdir = await mkdtemp(join(tmpdir(), 'timmy-wiring-review-')); + await chmod(hermesFixture, 0o700); + const port = 43200 + Math.floor(Math.random() * 800); + const origin = `http://127.0.0.1:${port}`; + const child = spawn(process.execPath, ['server.mjs'], { + cwd: root, + env: { + ...process.env, + PORT: String(port), + HOST: '127.0.0.1', + TIMMY_AGENT_ENABLED: 'true', + TIMMY_AGENT_ACCESS_TOKEN: 'test-wiring-access-code-2026', + TIMMY_PUBLIC_ORIGIN: origin, + TIMMY_AGENT_WORKDIR: workdir, + TIMMY_HERMES_COMMAND: hermesFixture, + TIMMY_VISION_ENABLED: '0', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', chunk => { stderr += chunk; }); + t.after(() => { child.kill('SIGTERM'); return rm(workdir, { recursive: true, force: true }); }); + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`server exited ${child.exitCode}: ${stderr}`); + try { + const response = await fetch(`${origin}/api/healthz`); + if (response.status === 200) return { origin, child }; + } catch {} + await new Promise(resolve => setTimeout(resolve, 40)); + } + throw new Error(`server did not become ready: ${stderr}`); +} + +test('every review-required urgent phrase intercepts over live HTTP before the Hermes adapter starts', async t => { + const { origin, child } = await startServer(t); + + const unlockResponse = await fetch(`${origin}/api/agent/unlock`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin, 'sec-fetch-site': 'same-origin' }, + body: JSON.stringify({ accessCode: 'test-wiring-access-code-2026' }), + }); + assert.equal(unlockResponse.status, 200); + const cookie = (unlockResponse.headers.get('set-cookie') || '').split(';')[0]; + assert.ok(cookie.startsWith('timmy_agent=')); + + for (const phrase of REQUIRED_URGENT_PHRASES) { + const response = await fetch(`${origin}/api/agent/chat`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin, 'sec-fetch-site': 'same-origin', cookie }, + body: JSON.stringify({ message: phrase, ledger: [] }), + }); + assert.equal(response.status, 200, phrase); + const data = await response.json(); + assert.match(data.reply, /medical help/i, JSON.stringify(phrase)); + assert.doesNotMatch(data.reply, /fixture/i, `${JSON.stringify(phrase)} must never reach the Hermes adapter`); + } + + // The fake adapter tracks its own invocations on stdout only when spawned; + // prove it never was by checking no fixture session artifacts exist and the + // server log stayed clean of adapter activity for this window. + assert.equal(child.exitCode, null, 'server must stay up through the urgent matrix'); +}); + +test('contextual nonclinical controls still flow through to the agent over live HTTP', async t => { + const { origin } = await startServer(t); + + const unlockResponse = await fetch(`${origin}/api/agent/unlock`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin, 'sec-fetch-site': 'same-origin' }, + body: JSON.stringify({ accessCode: 'test-wiring-access-code-2026' }), + }); + assert.equal(unlockResponse.status, 200); + const cookie = (unlockResponse.headers.get('set-cookie') || '').split(';')[0]; + + for (const phrase of REQUIRED_NONURGENT_PHRASES) { + const response = await fetch(`${origin}/api/agent/chat`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin, 'sec-fetch-site': 'same-origin', cookie }, + body: JSON.stringify({ message: phrase, ledger: [] }), + }); + assert.equal(response.status, 200, phrase); + const data = await response.json(); + assert.equal(data.safetyOverride, undefined, `${JSON.stringify(phrase)} must not escalate`); + assert.doesNotMatch(data.reply || '', /medical help/i, `${JSON.stringify(phrase)} must not get the urgent override`); + assert.match(data.reply || '', /fixture|Continuity confirmed/i, `${JSON.stringify(phrase)} should reach the bounded agent`); + } +}); diff --git a/tests/escalation-wiring.test.js b/tests/escalation-wiring.test.js index 16892dd..ed242ea 100644 --- a/tests/escalation-wiring.test.js +++ b/tests/escalation-wiring.test.js @@ -39,9 +39,45 @@ test('the authoritative zero-Hermes-call invariant stays wired at the single cha test('detection logic stays centralized in the shared domain module', async () => { const domain = await readFile(domainPath, 'utf8'); - // One frozen pattern table drives text detection; no parallel detector copies exist. - assert.match(domain, /const URGENT_TEXT_PATTERNS = Object\.freeze\(/); + // One frozen expression grammar drives text detection; no parallel detector copies exist. + assert.match(domain, /const URGENT_EXPRESSION_GRAMMAR = Object\.freeze\(/); assert.match(domain, /const URGENT_KEYS = /); assert.equal([...domain.matchAll(/URGENT_MESSAGE/g)].length >= 3, true, 'flags, ledger, and chat paths share one urgent copy constant'); }); + +// Second hostile review: the HTTP wiring layer must prove every new phrase is +// intercepted over a live socket before any Hermes adapter process can start. +const WIRING_REVIEW_PHRASES = [ + 'My stool had blood.', + 'My stools are bloody.', + 'My stools are black.', + 'My stool has turned black.', + 'My abdominal pain is severe.', + 'Pain in my abdomen is severe.', + 'I threw my lunch up.', + 'I can not pass gas.', + "I haven't been able to pass gas.", + 'yellow-fever outbreak in history class', + 'The fever-tree is a plant', + 'I threw up, my hands in surrender.', + 'Saturday Night Fever won awards', +]; + +test('urgent review phrases and contextual controls are wired through one shared domain grammar', async () => { + const [service, app] = await Promise.all([ + readFile(servicePath, 'utf8'), + readFile(appPath, 'utf8'), + ]); + // Server gate screens the message through detectUrgentText before spawning Hermes. + const chatIndex = service.indexOf('async chat('); + const chatBody = service.slice(chatIndex); + assert.ok(chatBody.indexOf('detectUrgentText(message).urgent') > 0); + assert.ok(chatBody.indexOf('detectUrgentText(message).urgent') < chatBody.indexOf('await runTurn(')); + // Browser gate re-exports the same detection for pre-network interception. + assert.match(app, /detectUrgentText\(message\)\.urgent/); + assert.match(app, /urgentChatMessage/); + // The grammar lives only in the domain module; the service layer never + // re-implements symptom vocabulary of its own. + assert.doesNotMatch(service, /fever|pass gas|stool|vomit/i); +}); diff --git a/tests/symptom-escalation.regression.test.js b/tests/symptom-escalation.regression.test.js index 61258b3..80d010c 100644 --- a/tests/symptom-escalation.regression.test.js +++ b/tests/symptom-escalation.regression.test.js @@ -131,6 +131,162 @@ const PRESERVED_EXPRESSIONS = { ], }; +// Hostile-review round two (exact reviewed head 1aadca91): ordinary tense, +// plural, pronoun, word-order, capitalization, contraction, and punctuation +// forms that slipped past the accumulated regex patches. Every entry here is +// required to escalate at the detector, at the service boundary, and over live +// HTTP with zero Hermes calls. +const REVIEW_REQUIRED_EXPRESSIONS = { + blood: [ + 'My stool had blood.', + 'My stools are bloody.', + ], + blackOrDarkRed: [ + 'My stools are black.', + 'My stool has turned black.', + ], + severePain: [ + 'My abdominal pain is severe.', + 'Pain in my abdomen is severe.', + ], + vomiting: [ + 'I threw my lunch up.', + ], + cannotPassGas: [ + 'I can not pass gas.', + "I haven't been able to pass gas.", + ], +}; + +// The five phrases the first hostile review added. They must survive the +// grammar rewrite byte-for-byte in behavior. +const PRIOR_REQUIRED_EXPRESSIONS = { + fever: [ + 'I have a fever right now', + 'my fever is 103', + 'fever started this morning', + ], + severePain: [ + 'severe pain in the abdomen', + 'severe pain around the abdomen', + ], +}; + +// Hand-written grammatical variants of the required phrases: tense, plural, +// pronoun, word-order, capitalization, contraction, and punctuation axes. +const EXPRESSION_VARIANTS = { + 'My stool had blood.': [ + 'Your stool had blood', 'His stool had blood.', 'Her stool had blood!', + 'Their stool had blood', 'My stools had blood.', 'My stool has blood.', + 'My stools have blood', 'There was blood in my stool', + 'There is blood in my stools', 'MY STOOL HAD BLOOD!', 'my stool had blood', + ], + 'My stools are bloody.': [ + 'My stool is bloody.', 'His stools were bloody', 'Her stool looks bloody.', + 'Their stools look bloody', 'MY STOOLS ARE BLOODY.', 'my stools are bloody!', + ], + 'My stools are black.': [ + 'My stool is black.', 'His stools were black', 'Her stool looks black.', + 'MY STOOLS ARE BLACK!', 'my stools are black', + ], + 'My stool has turned black.': [ + 'Her stool turned black.', 'Their stools have turned black', + 'My poop has turned black.', 'My stool went black', + 'My stool has turned dark-red.', 'MY STOOL HAS TURNED BLACK!', + ], + 'My abdominal pain is severe.': [ + 'My stomach pain is severe.', 'My belly pain is severe', + 'My abdominal pains are severe.', 'My abdominal pain became severe', + 'My abdominal pain feels severe.', 'His abdominal pain got severe.', + 'MY ABDOMINAL PAIN IS SEVERE!', 'my abdominal pain is severe', + ], + 'Pain in my abdomen is severe.': [ + 'Pain in my belly is severe.', 'Pain around my abdomen is severe.', + 'Pain near her abdomen is severe', 'It is severe pain in my abdomen', + 'PAIN IN MY ABDOMEN IS SEVERE.', 'pain in my abdomen is severe!', + ], + 'I threw my lunch up.': [ + 'She threw her lunch up', 'He threw his lunch up.', 'They threw their lunch up!', + 'I have thrown my lunch up', 'I LOST MY LUNCH.', "I've lost my lunch", + 'She lost her lunch twice', 'I THREW MY LUNCH UP!', + ], + 'I can not pass gas.': [ + 'I CANNOT PASS GAS', "I can't pass gas.", 'I cant pass gas!', + 'I could not pass gas', "I couldn't pass gas.", 'I am unable to pass gas', + 'I was unable to pass gases.', 'I CAN NOT PASS GAS!', + ], + "I haven't been able to pass gas.": [ + 'I have not been able to pass gas', "She hasn't been able to pass gas.", + 'I had not been able to pass gas', 'I haven’t been able to pass gas.', + 'I HAVE NOT BEEN ABLE TO PASS GAS!', "i haven't been able to pass gas", + ], + 'I have a fever right now': [ + 'I have a fever right now.', 'I HAVE A FEVER RIGHT NOW!', + 'You have a fever right now.', 'I had a fever right after dinner.', + ], + 'my fever is 103': [ + 'My fever is 103.', 'My fever was 103!', 'His fever is 103.', + 'Their fevers are 103.', 'MY FEVER IS 103!', + ], + 'fever started this morning': [ + 'Fever started this morning?', 'Fevers started this morning.', + 'FEVER STARTED THIS MORNING!', + ], + 'severe pain in the abdomen': [ + 'severe pain in the abdomen.', 'SEVERE PAIN IN THE ABDOMEN!', + 'severe pains in the abdomen', 'intense pain in my abdomen.', + 'excruciating pain around the abdomen!', + ], + 'severe pain around the abdomen': [ + 'severe pain around the abdomen.', 'SEVERE PAIN AROUND THE ABDOMEN?', + 'constant pain around my belly', + ], +}; + +// Bounded contextual controls from the hostile reviews. These are structured +// nonclinical context classes (named topics, titles, idioms, figurative +// objects) — never a continuation-word allowlist over symptom language. +const REVIEW_CONTEXT_NEGATIVES = [ + 'yellow-fever outbreak in history class', + 'Yellow-Fever outbreak in history class', + 'The fever-tree is a plant', + 'the fever-tree is a plant.', + 'The kids were feverish, with excitement before the trip.', + 'The kids were feverish with excitement before the trip', + 'I threw up, my hands in surrender.', + 'Malaria fever research is history now', + 'Dengue fever history is taught in schools', + 'We studied fever research last semester', + 'The crowd reached fever pitch', + 'Saturday Night Fever won awards', + 'they watched Saturday Night Fever tonight', + 'Gold fever gripped the mining town', + 'gold fever.', +]; + +function punctuationAndCaseVariants(phrase) { + const stem = phrase.replace(/[.!?]+$/, ''); + return [stem, `${stem}.`, `${stem}!`, `${stem}?`, ` ${stem} `, stem.toUpperCase()]; +} + +function contractionTwins(phrase) { + return [phrase.replace(/'/g, '’'), phrase.replace(/’/g, "'")]; +} + +function reviewPhraseVariants() { + const variants = []; + for (const group of [REVIEW_REQUIRED_EXPRESSIONS, PRIOR_REQUIRED_EXPRESSIONS]) { + for (const expressions of Object.values(group)) { + for (const expression of expressions) { + for (const variant of punctuationAndCaseVariants(expression)) variants.push(variant); + for (const twin of contractionTwins(expression)) variants.push(twin); + for (const extra of EXPRESSION_VARIANTS[expression] || []) variants.push(extra); + } + } + } + return variants; +} + const NEGATIVE_EXPRESSIONS = [ // Established non-urgent controls. 'My blood pressure was checked', @@ -171,6 +327,8 @@ const NEGATIVE_EXPRESSIONS = [ 'unable to pass the exam', 'not able to pass the test', "can't pass the class", + // Hostile-review round two: bounded nonclinical context classes. + ...REVIEW_CONTEXT_NEGATIVES, ]; test('exposes exactly the six authoritative urgent keys in canonical order', () => { @@ -219,7 +377,46 @@ test('escalates every positive expression in the regression matrix and reports i total += 1; } } - assert.equal(total, 82, 'regression matrix size is pinned'); + for (const [key, expressions] of Object.entries(REVIEW_REQUIRED_EXPRESSIONS)) { + assert.ok(RED_FLAGS.includes(key), `unknown review key ${key}`); + for (const expression of expressions) { + const result = detectUrgentText(expression); + assert.equal(result.urgent, true, JSON.stringify(expression)); + assert.ok(result.flags.includes(key), `${JSON.stringify(expression)} must map to ${key}, got ${result.flags}`); + assert.equal(result.message, URGENT_MESSAGE); + total += 1; + } + } + assert.equal(total, 91, 'regression matrix size is pinned'); +}); + +test('hostile-review phrases escalate with every tense, plural, pronoun, word-order, case, contraction, and punctuation variant', () => { + const variants = reviewPhraseVariants(); + assert.equal(variants.length >= 130, true, 'variant matrix must stay large'); + for (const variant of variants) { + const result = detectUrgentText(variant); + assert.equal(result.urgent, true, JSON.stringify(variant)); + assert.ok(result.flags.length > 0, JSON.stringify(variant)); + } +}); + +// Normalization audit: the grammar must treat punctuation, hyphen, apostrophe, +// and whitespace noise as equivalent before classification, so the same words +// classify identically regardless of surface form. +test('normalization makes punctuation, hyphen, apostrophe, and spacing forms classify identically', () => { + const groups = [ + ['My stool had blood.', 'My stool had blood', 'MY STOOL HAD BLOOD.', 'my stool had blood'], + ['I can not pass gas.', 'I cannot pass gas', "I can't pass gas.", 'I cant pass gas', 'I CANNOT PASS GAS!'], + ["I haven't been able to pass gas.", 'I have not been able to pass gas', 'I haven’t been able to pass gas.'], + ['The fever-tree is a plant', 'the fever tree is a plant.', 'THE FEVER-TREE IS A PLANT'], + ['yellow-fever outbreak in history class', 'yellow fever outbreak in history class', 'YELLOW-FEVER OUTBREAK IN HISTORY CLASS.'], + ['I threw up, my hands in surrender.', 'I threw up my hands in surrender'], + ['Saturday Night Fever won awards', 'saturday night fever won awards!', 'SATURDAY NIGHT FEVER WON AWARDS.'], + ]; + for (const group of groups) { + const verdicts = group.map(phrase => detectUrgentText(phrase).urgent); + assert.equal(new Set(verdicts).size, 1, `${JSON.stringify(group)} must classify identically, got ${verdicts}`); + } }); test('phrasings escalated on main are never lost while the patterns expand', () => { @@ -310,6 +507,76 @@ test('ledger context detection needs confirmed true flags or urgent note text', assert.equal(hasUrgentLedgerContext([null, undefined]), false); }); +// Anti-allowlist audit: arbitrary natural continuations of symptom language +// must keep escalating forever. A continuation-word allowlist would silently +// suppress ordinary wording nobody enumerated yet; this proves none exists. +test('arbitrary future symptom continuations keep escalating without allowlist narrowing', () => { + const stems = [ + 'fever', + 'I have a fever', + 'my stool had blood', + 'I threw up', + 'severe pain in my abdomen', + 'I cannot pass gas', + 'my stool turned black', + ]; + const continuations = [ + 'right now', 'since tuesday', 'on and off', 'again', 'while traveling', + 'after the concert', 'under the desk', 'near the lake', 'beside the dog', + 'during the storm', 'before breakfast', 'without warning', 'plus dizziness', + 'and chills', 'but no rash', 'every hour', 'all week', 'at mile twenty', + 'with my socks on', 'because of the elevator', 'around midnight', + ]; + let checked = 0; + for (const stem of stems) { + for (const continuation of continuations) { + const result = detectUrgentText(`${stem} ${continuation}`); + assert.equal(result.urgent, true, JSON.stringify(`${stem} ${continuation}`)); + checked += 1; + } + } + assert.equal(checked >= 140, true, 'anti-allowlist sweep must stay broad'); +}); + +// Structural audit: classification must flow through one auditable, versioned, +// frozen expression grammar plus one shared normalizer — not accumulating +// narrow regex patches. +test('classification runs through the auditable frozen urgent-expression grammar', async () => { + const { readFile } = await import('node:fs/promises'); + const domainModule = await import('../src/domain.js'); + const { urgentExpressionGrammar, normalizeUrgentText } = domainModule; + + assert.equal(Object.isFrozen(urgentExpressionGrammar), true, 'grammar must be frozen'); + assert.equal(typeof urgentExpressionGrammar.version, 'string', 'grammar must carry a version'); + assert.match(urgentExpressionGrammar.version, /^\d+\.\d+$/); + assert.deepEqual( + Object.keys(urgentExpressionGrammar.expressions).sort(), + [...RED_FLAGS].sort(), + 'grammar covers exactly the six authoritative flags', + ); + for (const [key, rule] of Object.entries(urgentExpressionGrammar.expressions)) { + assert.ok(Array.isArray(rule.match) && rule.match.length > 0, `${key} match stage`); + for (const pattern of rule.match) { + assert.ok(pattern instanceof RegExp || typeof pattern === 'string', `${key} matcher shape`); + } + for (const anchor of rule.exclude || []) { + assert.equal(typeof anchor, 'string', `${key} exclusion anchors are plain nonclinical phrases`); + assert.ok(anchor.length > 3, `${key} anchors must name concrete context`); + } + } + + // The normalizer is exported, deterministic, and idempotent. + assert.equal(typeof normalizeUrgentText, 'function'); + const sample = "I can't pass gas."; + assert.equal(normalizeUrgentText(sample), normalizeUrgentText(normalizeUrgentText(sample))); + assert.doesNotMatch(normalizeUrgentText(sample), /[.!?,;:]/); + + // One grammar drives detection; no parallel pattern-table copies exist. + const domainSource = await readFile(new URL('../src/domain.js', import.meta.url), 'utf8'); + assert.match(domainSource, /const URGENT_EXPRESSION_GRAMMAR = Object\.freeze\(/); + assert.match(domainSource, /function normalizeUrgentText\(/); +}); + test('sanitized entries coerce symptoms to booleans and never trust imported flags blindly', () => { const entry = sanitizeEntry({ symptoms: { blood: 'yes', fever: false, vomiting: 1 } }); assert.deepEqual(entry.symptoms, {