Quality gates / quality (pull_request) Successful in 2m26s
The worker contract was only proven by one happy-path fixture and a handful of inline assertions, so nothing continuously checked that a reply Timmy cannot use is refused instead of guessed at. Corpus: a CC0 synthetic corpus (stool, non-stool, blur, glare, empty bowl, malformed response) generated deterministically by scripts/build_vision_corpus.py. Every fixture records its sha256, provenance, licence, case class, the provider reply it replays, and the outcome that reply must produce. loadVisionCorpus refuses a fixture whose bytes no longer match the manifest, so the corpus cannot be edited into a different test. No real medical imagery, camera, or third-party content is involved. Contract fixes the corpus exposed: - A schema-valid reply with a null form and colour is now an abstention (abstainedFor: unusable_visual_fields) instead of a hard provider failure. The declared response schema permits those nulls, so treating the reply as an error was wrong; it now abstains calmly and still prefill nothing. - Fail-closed replies carry a machine-readable code (invalid_json, invalid_fields) instead of prose matching, and visionFailureResponse() maps them to plain, user-safe copy with no provider text, image bytes, or credentials echoed. A running server now returns that copy for unparsable and out-of-range replies. Evidence: unit corpus contract and hash integrity, corpus-wide conformance through the real analysis path, the same corpus through a running server.mjs with a stub worker (200 abstain vs 503 fail-closed, no leaked bytes), and a real browser acceptance run for the refusal UI. The release demo replays three corpus cases through the real code path so the video shows the status the server would actually send, with the on-camera caption gated by the release builder.
112 lines
4.7 KiB
JavaScript
112 lines
4.7 KiB
JavaScript
// Contract for the deterministic vision corpus under tests/fixtures/vision-corpus.
|
|
//
|
|
// The corpus exists to prove one claim continuously: every provider response Timmy
|
|
// receives is either a schema-valid visible-field suggestion or it fails closed.
|
|
// Fixtures are synthetic images generated by scripts/build_vision_corpus.py; no real
|
|
// medical imagery, personal data, or third-party content is redistributable here.
|
|
import { readFile } from 'node:fs/promises';
|
|
import { createHash } from 'node:crypto';
|
|
import { join } from 'node:path';
|
|
import { parseVisionResponse, VISION_FAILURE_CODES } from './analysis.js';
|
|
|
|
export const VISION_CORPUS_LICENSE = 'CC0-1.0';
|
|
|
|
// Case classes the corpus must always cover, from issue #18.
|
|
export const VISION_CORPUS_CLASSES = Object.freeze([
|
|
'stool',
|
|
'non-stool',
|
|
'blur',
|
|
'glare',
|
|
'empty-bowl',
|
|
'malformed-response',
|
|
]);
|
|
|
|
export const VISION_CORPUS_OUTCOMES = Object.freeze(['suggestion', 'abstained', 'fail_closed']);
|
|
|
|
const ALLOWED_CLASSES = new Set(VISION_CORPUS_CLASSES);
|
|
|
|
export function validateCorpusManifest(manifest) {
|
|
if (!manifest || typeof manifest !== 'object') throw new Error('vision corpus manifest must be an object');
|
|
if (manifest.schemaVersion !== 1) throw new Error('vision corpus manifest must declare schemaVersion 1');
|
|
if (manifest.license !== VISION_CORPUS_LICENSE) {
|
|
throw new Error(`vision corpus must be redistributable under ${VISION_CORPUS_LICENSE}`);
|
|
}
|
|
if (!Array.isArray(manifest.cases) || manifest.cases.length === 0) throw new Error('vision corpus must list cases');
|
|
const ids = new Set();
|
|
for (const entry of manifest.cases) {
|
|
if (!entry?.id) throw new Error('every corpus case needs an id');
|
|
if (ids.has(entry.id)) throw new Error(`duplicate corpus case: ${entry.id}`);
|
|
ids.add(entry.id);
|
|
if (!ALLOWED_CLASSES.has(entry.caseClass)) throw new Error(`${entry.id} has an unsupported caseClass: ${entry.caseClass}`);
|
|
if (entry.synthetic !== true) throw new Error(`${entry.id} must be a synthetic fixture`);
|
|
if (entry.license !== VISION_CORPUS_LICENSE) throw new Error(`${entry.id} must record its ${VISION_CORPUS_LICENSE} license`);
|
|
if (typeof entry.provenance !== 'string' || !entry.provenance.trim()) throw new Error(`${entry.id} must record provenance`);
|
|
if (!/^[a-f0-9]{64}$/.test(String(entry.sha256 || ''))) throw new Error(`${entry.id} must record a sha256`);
|
|
if (!VISION_CORPUS_OUTCOMES.includes(entry.expectedOutcome)) {
|
|
throw new Error(`${entry.id} must declare an expectedOutcome from ${VISION_CORPUS_OUTCOMES.join(', ')}`);
|
|
}
|
|
if (!('providerContent' in entry)) throw new Error(`${entry.id} must record the provider response it replays`);
|
|
}
|
|
for (const required of VISION_CORPUS_CLASSES) {
|
|
if (!manifest.cases.some(entry => entry.caseClass === required)) {
|
|
throw new Error(`vision corpus is missing the required ${required} case`);
|
|
}
|
|
}
|
|
return manifest;
|
|
}
|
|
|
|
export function classifyVisionOutcome(providerContent) {
|
|
let parsed;
|
|
try {
|
|
parsed = parseVisionResponse(providerContent);
|
|
} catch (error) {
|
|
const reason = error?.code === VISION_FAILURE_CODES.invalidJson
|
|
? 'unparsable_content'
|
|
: error?.code === VISION_FAILURE_CODES.invalidFields
|
|
? 'out_of_range_or_missing_fields'
|
|
: 'unexpected_error';
|
|
return { outcome: 'fail_closed', reason, detail: error.message };
|
|
}
|
|
if (parsed.status === 'suggestion') {
|
|
return {
|
|
outcome: 'suggestion',
|
|
reason: 'schema_valid_suggestion',
|
|
bristolType: parsed.bristolType,
|
|
color: parsed.color,
|
|
confidence: parsed.confidence,
|
|
};
|
|
}
|
|
if (parsed.status === 'needs_user_input') {
|
|
return {
|
|
outcome: 'abstained',
|
|
reason: parsed.abstainedFor,
|
|
detail: parsed.reason,
|
|
};
|
|
}
|
|
return { outcome: 'fail_closed', reason: 'unparsable_content', detail: 'The response could not be used.' };
|
|
}
|
|
|
|
export async function loadVisionCorpus(directory) {
|
|
const manifestPath = join(directory, 'manifest.json');
|
|
let manifest;
|
|
try {
|
|
manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
} catch (error) {
|
|
throw new Error(`vision corpus manifest could not be read at ${manifestPath}: ${error.message}`);
|
|
}
|
|
validateCorpusManifest(manifest);
|
|
for (const entry of manifest.cases) {
|
|
let bytes;
|
|
try {
|
|
bytes = await readFile(join(directory, entry.file));
|
|
} catch {
|
|
throw new Error(`${entry.id} is missing its fixture ${entry.file}`);
|
|
}
|
|
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
if (digest !== entry.sha256 || bytes.length !== entry.bytes) {
|
|
throw new Error(`${entry.id} fixture ${entry.file} does not match its recorded sha256; regenerate the corpus instead of editing it`);
|
|
}
|
|
}
|
|
return { ...manifest, directory };
|
|
}
|