fix: fail closed when a filled-in visual field is out of range (#73)
Quality gates / quality (pull_request) Failing after 14s
Quality gates / quality (pull_request) Failing after 14s
The blank-first ordering let one blank field short-circuit the whole reply, so a response carrying an impossible value came back as `unusable_visual_fields`: the user was told the photo was unreadable when the model had in fact returned something Timmy must never apply. Blank fields still abstain and still prefill nothing. A filled-in field is a claim, and a claim outside the contract now fails closed even when the other field came back blank. The corpus earns its label too. `out-of-range-fields` drops from confidence 1.4 to 0.9 so the case reaches the field-range check instead of dying at the confidence gate, and a new `blank-plus-out-of-range` case covers the class the old ordering let through. The release demo replays that case on camera and the fail-closed 503 budget moves from one to two, so the scene cannot be dropped silently. Closes #73
This commit is contained in:
1 parent
a1777a72e8
commit
5c759078c4
13 files changed
+189
-19
No files matched your search
@@ -38,6 +38,7 @@ DEMO_REQUIRED_CAPTIONS = (
|
||||
"Hermes Agent connected — free-text, contextual, and server-side",
|
||||
"Urgent language is intercepted deterministically before Hermes",
|
||||
"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",
|
||||
)
|
||||
|
||||
|
||||
@@ -207,7 +208,7 @@ def main() -> int:
|
||||
"- Vision profiles: hosted and self-hosted SmolVLM2 bootstrap\n"
|
||||
"- AI suggestions stay provisional: suggested, confirmed, and corrected states are distinct and screen-reader described; correcting one clears the AI-confirmed state without losing user context\n"
|
||||
"- Hard-stool follow-up: a Bristol 1-2 entry asks user-reported questions (difficulty passing, soreness, visible blood, truly black/tarry versus dark brown, hydration); mild reports get non-diagnostic self-care, and a confirmed red-flag answer lands on the same deterministic escalation path as manual logging\n"
|
||||
"- Vision corpus: a committed CC0 synthetic corpus (stool, non-stool, blur, glare, empty bowl, malformed response) proves every reply is schema-valid or fails closed; a schema-valid reply with unreadable fields abstains instead of erroring, and unparsable or out-of-range replies return plain copy with nothing applied\n"
|
||||
"- Vision corpus: a committed CC0 synthetic corpus (stool, non-stool, blur, glare, empty bowl, malformed response) proves every reply is schema-valid or fails closed; a schema-valid reply with unreadable fields abstains instead of erroring, and unparsable or out-of-range replies return plain copy with nothing applied — including a reply that fills in one field and leaves the other blank, where an out-of-range value still fails closed instead of being reported as an unreadable photo (issue #73)\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"
|
||||
@@ -253,7 +254,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, and an 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, and deterministic Hermes chat response",
|
||||
},
|
||||
"gates": {
|
||||
"unit_security": "passed",
|
||||
|
||||
@@ -32,6 +32,7 @@ OBSERVATION_STRINGS = {
|
||||
"highlight": "Large specular highlight hides the surface.",
|
||||
"container": "Empty container.",
|
||||
"confident": "Confident.",
|
||||
"partial": "Form read, colour not.",
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +106,16 @@ def shaded_tone() -> Image.Image:
|
||||
return image
|
||||
|
||||
|
||||
def half_read_tone() -> Image.Image:
|
||||
"""A fixture for the reply shape that fills one field and blanks the other (issue #73)."""
|
||||
image = background((208, 212, 204))
|
||||
blob(image, (175, 150, 455, 322), (112, 96, 66))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle([0, 200, WIDTH // 2, 206], fill=(196, 194, 186))
|
||||
draw.rectangle([WIDTH // 2, 260, WIDTH, 266], fill=(170, 168, 160))
|
||||
return image
|
||||
|
||||
|
||||
def content(payload: dict[str, object] | str) -> str:
|
||||
return payload if isinstance(payload, str) else json.dumps(payload, separators=(",", ":"))
|
||||
|
||||
@@ -180,11 +191,28 @@ def cases() -> list[dict[str, Any]]:
|
||||
"image": shaded_tone,
|
||||
"expectedOutcome": "fail_closed",
|
||||
"expectedReason": "out_of_range_or_missing_fields",
|
||||
# Confidence stays inside the contract so this reply reaches the field-range check. At 1.4 it
|
||||
# died at the confidence gate instead, which credited the `out_of_range_or_missing_fields`
|
||||
# label to a guard the case never exercised (issue #73).
|
||||
"providerContent": content({
|
||||
"isStool": True, "bristolType": 9, "color": "purple", "confidence": 1.4,
|
||||
"isStool": True, "bristolType": 9, "color": "purple", "confidence": 0.9,
|
||||
"imageQuality": "excellent", "observations": OBSERVATION_STRINGS["confident"],
|
||||
}),
|
||||
},
|
||||
{
|
||||
"id": "blank-plus-out-of-range",
|
||||
"caseClass": "malformed-response",
|
||||
"image": half_read_tone,
|
||||
"expectedOutcome": "fail_closed",
|
||||
"expectedReason": "out_of_range_or_missing_fields",
|
||||
# The class the blank-first ordering used to let through (issue #73): one field is blank and
|
||||
# the other is a filled-in, impossible value. A filled-in field is a claim, so this must fail
|
||||
# closed rather than abstain as `unusable_visual_fields`.
|
||||
"providerContent": content({
|
||||
"isStool": True, "bristolType": 9, "color": None, "confidence": 0.9,
|
||||
"imageQuality": "good", "observations": OBSERVATION_STRINGS["partial"],
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -259,6 +259,22 @@ await tap('#analyze-photo', 600);
|
||||
await page.getByText(/couldn’t analyze that safely/i).waitFor();
|
||||
await caption('An out-of-range answer fails closed: nothing is applied and no provider detail reaches you', 2700);
|
||||
await indicate('.scan-result');
|
||||
// Issue #73's escape class: one field blank, the other filled in and impossible. The old blank-first
|
||||
// ordering abstained here, which told the user the photo was unreadable while the model had returned a
|
||||
// value Timmy must never apply. The scene is asserted in the browser, not just captioned.
|
||||
analyzeOverride = await corpusAnalyzeResult(corpusCase('blank-plus-out-of-range'));
|
||||
await tap('#retake-photo', 400);
|
||||
await page.locator('#gallery-photo').setInputFiles('tests/fixtures/vision-corpus/blank-plus-out-of-range.png');
|
||||
await page.locator('#ai-consent').check();
|
||||
await tap('#analyze-photo', 600);
|
||||
await page.getByText(/couldn’t analyze that safely/i).waitFor();
|
||||
if (await page.getByText(/No confident match/i).count() !== 0) {
|
||||
throw new Error('A filled-in out-of-range field must not be shown as an unreadable-photo abstention');
|
||||
}
|
||||
await caption('A filled-in field that is out of range still fails closed, even when the other field came back blank', 2900);
|
||||
await indicate('.scan-result');
|
||||
await caption('You are no longer told the photo was unreadable when the model returned an impossible value', 2400);
|
||||
await indicate('.scan-result');
|
||||
await caption('Every refusal replays a committed synthetic fixture — there are no real photos in the corpus', 2500);
|
||||
analyzeOverride = null;
|
||||
await tap('#close-sheet', 450);
|
||||
@@ -301,11 +317,12 @@ await page.evaluate(() => {
|
||||
});
|
||||
await sleep(2200);
|
||||
|
||||
// The refusal pass deliberately produces one fail-closed 503, which the browser logs as a failed
|
||||
// resource. Requiring exactly that one keeps the scene honest: if the boundary stops refusing, this
|
||||
// fails instead of quietly recording a happy path.
|
||||
// The refusal pass deliberately produces two fail-closed 503s (an out-of-range reply, and one blank field
|
||||
// beside an out-of-range one), which the browser logs as failed resources. Requiring exactly those two
|
||||
// keeps the scene honest: if the boundary stops refusing, this fails instead of quietly recording a happy
|
||||
// path, and a dropped scene is caught rather than silently shrinking the evidence.
|
||||
const expectedFailClosed = errors.filter(message => /Failed to load resource: the server responded with a status of 503 \(Service Unavailable\)/.test(message));
|
||||
if (expectedFailClosed.length !== 1 || errors.length !== expectedFailClosed.length) {
|
||||
if (expectedFailClosed.length !== 2 || errors.length !== expectedFailClosed.length) {
|
||||
throw new Error(`Browser errors: ${errors.join(' | ') || 'unexpected error count'}`);
|
||||
}
|
||||
// Machine-readable receipt of exactly what was rendered on camera, so the release gate can prove
|
||||
|
||||
Reference in new issue
Block a user