Files
timmy-talking-turd/tests/analysis.test.js
T
timmy 209e21a951
Quality gates / quality (pull_request) Successful in 13m29s
Gate visual prefill on a versioned abstention policy (#32)
Confidence was the only thing standing between a model answer and a
prefilled journal entry, so a confident reading of a blurry photo could
still be offered as fact. Quality is now part of the policy: `poor`
abstains however confident the reply looked, with its own fixed copy.

The confidence floor, accepted quality levels, and refusal copy move into
src/abstention-policy.js so the parser, the corpus, and the browser cannot
drift into three explanations of the same refusal. parseVisionResponse
takes an optional policy, which is how the tests prove the parser carries
no hidden limits of its own.

evaluatePrefillGate turns the #29 calibration report into one release
decision: GO plus a validation-selected threshold scored on the held-out
test split, or prefill is blocked with each reason named. A missing report
is a block, never a default allow. scripts/abstention_policy_receipt.mjs
exposes that as a receipt (exit 0 / 3 / 2).

The committed synthetic predictions currently report BLOCKED and the
receipt says so. No candidate exists until #31, and the builder does not
hard-gate on the receipt yet; the on-camera quality gate is the evidence
this release carries.

The corpus gains a case that replays a 0.94-confidence answer about the
synthetic motion-blur fixture, and the release demo asserts in the browser
that nothing is prefilled before captioning it.

Verified: 226 unit tests, calibration harness (19), corpus manifest (8),
vision-refusal, photo-first, and provisional acceptance, check:syntax,
check:diff, and a full real-browser demo recording whose caption receipt
confirms the new scene painted on screen.
2026-10-26 10:58:38 +00:00

171 lines
8.2 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { ABSTENTION_COPY, ABSTENTION_POLICY } from '../src/abstention-policy.js';
import {
buildVisionRequest,
mergeVisualSuggestion,
parseVisionResponse,
validatePhotoPayload,
} from '../src/analysis.js';
test('validates a confident visual suggestion without inventing nonvisual fields', () => {
const result = parseVisionResponse({
isStool: true,
bristolType: 4,
color: 'brown',
confidence: 0.82,
imageQuality: 'good',
observations: 'Smooth, formed appearance.',
urgency: 4,
discomfort: 3,
symptoms: { blood: true },
diagnosis: 'anything',
});
assert.deepEqual(result, {
status: 'suggestion',
isStool: true,
bristolType: 4,
color: 'brown',
confidence: 0.82,
imageQuality: 'good',
observations: 'Smooth, formed appearance.',
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
});
assert.equal(result.urgency, undefined);
assert.equal(result.symptoms, undefined);
assert.equal(result.diagnosis, undefined);
});
test('fails closed on a non-stool or low-confidence image', () => {
// abstainedFor names which abstention happened, so callers never have to parse prose.
assert.deepEqual(parseVisionResponse({ isStool: false, confidence: 0.9 }), {
status: 'needs_user_input',
isStool: false,
abstainedFor: 'not_stool',
reason: 'The image does not clearly show stool.',
});
assert.deepEqual(parseVisionResponse({ isStool: true, bristolType: 4, color: 'brown', confidence: 0.31 }), {
status: 'needs_user_input',
isStool: true,
abstainedFor: 'low_confidence',
reason: 'The image is too uncertain to prefill safely.',
});
});
test('rejects malformed model output instead of guessing defaults', () => {
assert.throws(() => parseVisionResponse({ isStool: true, bristolType: 9, color: 'purple', confidence: 0.8 }), /invalid/i);
assert.throws(() => parseVisionResponse('not json'), /invalid/i);
});
test('merges only visual fields and preserves user-reported context', () => {
const form = { bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true } };
const merged = mergeVisualSuggestion(form, { status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.8 });
assert.deepEqual(merged, { bristolType: 4, color: 'brown', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true } });
});
test('accepts bounded JPEG/PNG/WebP data URLs and rejects oversized or unsupported input', () => {
const payload = validatePhotoPayload({ imageDataUrl: 'data:image/jpeg;base64,' + 'YQ==', consent: true });
assert.equal(payload.mime, 'image/jpeg');
assert.equal(payload.bytes, 1);
assert.throws(() => validatePhotoPayload({ imageDataUrl: 'data:image/svg+xml;base64,PHN2Zz4=', consent: true }), /JPEG, PNG, or WebP/i);
assert.throws(() => validatePhotoPayload({ imageDataUrl: 'data:image/jpeg;base64,YQ==', consent: false }), /consent/i);
assert.throws(() => validatePhotoPayload({ imageDataUrl: 'data:image/jpeg;base64,' + 'A'.repeat(6_000_000), consent: true }), /too large/i);
});
test('builds a structured multimodal request that forbids diagnosis and nonvisual inference', () => {
const body = buildVisionRequest({ imageDataUrl: 'data:image/jpeg;base64,YQ==', model: 'vision-model' });
assert.equal(body.model, 'vision-model');
assert.equal(body.response_format.type, 'json_schema');
const prompt = body.messages[0].content.find(part => part.type === 'text').text;
assert.match(prompt, /do not infer urgency/i);
assert.match(prompt, /not a diagnosis/i);
assert.equal(body.messages[0].content.find(part => part.type === 'image_url').image_url.url, 'data:image/jpeg;base64,YQ==');
});
test('fails closed when a field the model did fill in is out of range while another field is blank', () => {
// Issue #73: the blank-first ordering let a blank field short-circuit the whole response, so a reply
// that carried an impossible value came back as `unusable_visual_fields` — telling the user the photo
// was unreadable when the model had in fact returned something Timmy must never apply. A blank field
// means "could not read it"; a filled-in field is a claim, and a claim outside the contract fails closed.
assert.throws(
() => parseVisionResponse({ isStool: true, bristolType: 9, color: null, confidence: 0.9, imageQuality: 'good', observations: 'Form read, colour not.' }),
(error) => {
assert.equal(error.code, 'invalid_fields', 'an out-of-range form next to a blank colour must fail closed');
return true;
},
);
assert.throws(
() => parseVisionResponse({ isStool: true, bristolType: null, color: 'purple', confidence: 0.9, imageQuality: 'good', observations: 'Colour read, form not.' }),
(error) => {
assert.equal(error.code, 'invalid_fields', 'an out-of-range colour next to a blank form must fail closed');
return true;
},
);
});
test('still abstains when the only filled-in visual field is inside the contract', () => {
// The complement of the rule above: a partial read that is entirely valid is an abstention, not a
// failure, so the glare path keeps returning 200 with nothing prefilled.
assert.deepEqual(parseVisionResponse({ isStool: true, bristolType: 4, color: null, confidence: 0.9, imageQuality: 'good', observations: 'Form read, colour not.' }), {
status: 'needs_user_input',
isStool: true,
abstainedFor: 'unusable_visual_fields',
reason: 'The photo does not show a form Timmy can read safely.',
});
});
test('abstains instead of failing when a schema-valid response carries no readable visual fields', () => {
// The declared JSON schema allows a null form and color whenever the model cannot read them,
// so a schema-valid response must come back as an abstention — never as a provider failure.
assert.deepEqual(parseVisionResponse({
isStool: true,
bristolType: null,
color: null,
confidence: 0.66,
imageQuality: 'poor',
observations: 'Large specular highlight hides the surface.',
}), {
status: 'needs_user_input',
isStool: true,
abstainedFor: 'unusable_visual_fields',
reason: 'The photo does not show a form Timmy can read safely.',
});
});
test('a confident answer about a low-quality photo still abstains', () => {
// Issue #32: confidence is not the only gate. A blurry or badly lit photo can produce a
// confident-looking form and colour, and prefilling it would show the user a claim the photo
// cannot support. Quality is part of the policy, so this abstains instead of prefilling.
assert.deepEqual(parseVisionResponse({
isStool: true,
bristolType: 4,
color: 'brown',
confidence: 0.94,
imageQuality: 'poor',
observations: 'Form barely visible through motion blur.',
}), {
status: 'needs_user_input',
isStool: true,
abstainedFor: 'low_quality',
reason: 'The photo is too blurry or poorly lit to suggest a form safely.',
});
});
test('the parse path reads the shipped policy instead of hardcoding its own limits', () => {
// Issue #32: policy numbers and copy live in one module. A caller may tighten the policy for a
// release without editing the parser, and a photo that the default policy would accept has to
// abstain under the stricter one — otherwise the parser is carrying its own hidden policy.
const fields = { isStool: true, bristolType: 4, color: 'brown', confidence: 0.8, imageQuality: 'fair', observations: 'Form visible but soft.' };
assert.equal(parseVisionResponse(fields).status, 'suggestion', 'fair quality may prefill under the shipped policy');
const strict = { ...ABSTENTION_POLICY, minConfidence: 0.9, acceptedImageQuality: ['good'] };
assert.equal(parseVisionResponse(fields, strict).abstainedFor, 'low_confidence');
assert.equal(parseVisionResponse({ ...fields, confidence: 0.95 }, strict).abstainedFor, 'low_quality');
assert.equal(parseVisionResponse({ ...fields, confidence: 0.95, imageQuality: 'good' }, strict).status, 'suggestion');
assert.equal(
parseVisionResponse({ ...fields, imageQuality: 'poor' }).reason,
ABSTENTION_COPY.low_quality,
'the refusal copy must come from the policy module, not a second copy in the parser',
);
});