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.
266 lines
9.7 KiB
Python
266 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate the deterministic synthetic vision corpus for issue #18.
|
|
|
|
Every fixture is drawn from fixed integer math: no camera, no human subject, no
|
|
third-party content, and no randomness. That keeps the corpus redistributable and
|
|
keeps the recorded SHA-256 hashes meaningful as an integrity receipt.
|
|
|
|
Usage:
|
|
python3 scripts/build_vision_corpus.py # (re)write fixtures and manifest.json
|
|
python3 scripts/build_vision_corpus.py --verify # verify committed fixtures against the manifest
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
from PIL import Image, ImageDraw, ImageFilter
|
|
|
|
LICENSE = "CC0-1.0"
|
|
CORPUS_DIR = Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "vision-corpus"
|
|
WIDTH, HEIGHT = 640, 480
|
|
PROVENANCE = "Synthetic fixture drawn deterministically by scripts/build_vision_corpus.py; no camera, no human subject, no third-party content."
|
|
|
|
OBSERVATION_STRINGS = {
|
|
"smooth": "Smooth, formed appearance.",
|
|
"flatware": "Flatware on a tray.",
|
|
"unclear": "Form is unclear.",
|
|
"highlight": "Large specular highlight hides the surface.",
|
|
"container": "Empty container.",
|
|
"confident": "Confident.",
|
|
}
|
|
|
|
|
|
def background(color: tuple[int, int, int]) -> Image.Image:
|
|
"""A flat canvas with a coarse, deterministic shade band."""
|
|
image = Image.new("RGB", (WIDTH, HEIGHT), color)
|
|
draw = ImageDraw.Draw(image)
|
|
for index in range(4):
|
|
shade = tuple(min(255, channel + 6 * index) for channel in color)
|
|
draw.rectangle([0, index * 120, WIDTH, index * 120 + 119], fill=shade)
|
|
return image
|
|
|
|
|
|
def blob(image: Image.Image, box: tuple[int, int, int, int], tone: tuple[int, int, int]) -> None:
|
|
draw = ImageDraw.Draw(image)
|
|
draw.ellipse(box, fill=tone)
|
|
left, top, right, bottom = box
|
|
inset = max(4, (right - left) // 6)
|
|
draw.ellipse([left + inset, top + inset, right - inset, bottom - inset],
|
|
fill=tuple(min(255, channel + 10) for channel in tone))
|
|
|
|
|
|
def stool_like() -> Image.Image:
|
|
image = background((226, 222, 212))
|
|
blob(image, (170, 150, 470, 330), (108, 76, 48))
|
|
return image
|
|
|
|
|
|
def non_stool_scene() -> Image.Image:
|
|
image = background((198, 202, 208))
|
|
draw = ImageDraw.Draw(image)
|
|
draw.ellipse([120, 90, 520, 390], fill=(244, 244, 240))
|
|
draw.rectangle([60, 200, 300, 224], fill=(120, 126, 134))
|
|
draw.rectangle([340, 200, 580, 224], fill=(120, 126, 134))
|
|
return image
|
|
|
|
|
|
def blur_low_detail() -> Image.Image:
|
|
return stool_like().filter(ImageFilter.GaussianBlur(5))
|
|
|
|
|
|
def glare_overexposed() -> Image.Image:
|
|
image = background((222, 218, 208))
|
|
blob(image, (150, 140, 430, 300), (104, 74, 46))
|
|
draw = ImageDraw.Draw(image)
|
|
draw.polygon([(380, 200), (640, 120), (640, 360), (420, 330)], fill=(252, 252, 250))
|
|
draw.ellipse([470, 210, 620, 320], fill=(255, 255, 255))
|
|
return image
|
|
|
|
|
|
def empty_bowl() -> Image.Image:
|
|
image = background((214, 210, 202))
|
|
draw = ImageDraw.Draw(image)
|
|
draw.ellipse([130, 90, 510, 400], fill=(238, 236, 230))
|
|
draw.ellipse([175, 130, 465, 360], fill=(206, 204, 198))
|
|
draw.ellipse([220, 170, 420, 320], fill=(232, 230, 224))
|
|
return image
|
|
|
|
|
|
def unusable_tone() -> Image.Image:
|
|
image = background((202, 206, 198))
|
|
blob(image, (190, 160, 450, 320), (128, 118, 92))
|
|
return image
|
|
|
|
|
|
def shaded_tone() -> Image.Image:
|
|
image = background((216, 214, 206))
|
|
blob(image, (180, 155, 460, 325), (96, 84, 52))
|
|
draw = ImageDraw.Draw(image)
|
|
draw.rectangle([0, 300, WIDTH, 306], fill=(180, 178, 170))
|
|
return image
|
|
|
|
|
|
def content(payload: dict[str, object] | str) -> str:
|
|
return payload if isinstance(payload, str) else json.dumps(payload, separators=(",", ":"))
|
|
|
|
|
|
def cases() -> list[dict[str, Any]]:
|
|
return [
|
|
{
|
|
"id": "stool-like-formed",
|
|
"caseClass": "stool",
|
|
"image": stool_like,
|
|
"expectedOutcome": "suggestion",
|
|
"expectedReason": "schema_valid_suggestion",
|
|
"providerContent": content({
|
|
"isStool": True, "bristolType": 4, "color": "brown", "confidence": 0.83,
|
|
"imageQuality": "good", "observations": OBSERVATION_STRINGS["smooth"],
|
|
}),
|
|
},
|
|
{
|
|
"id": "non-stool-scene",
|
|
"caseClass": "non-stool",
|
|
"image": non_stool_scene,
|
|
"expectedOutcome": "abstained",
|
|
"expectedReason": "not_stool",
|
|
"providerContent": content({
|
|
"isStool": False, "bristolType": None, "color": None, "confidence": 0.97,
|
|
"imageQuality": "good", "observations": OBSERVATION_STRINGS["flatware"],
|
|
}),
|
|
},
|
|
{
|
|
"id": "blur-low-detail",
|
|
"caseClass": "blur",
|
|
"image": blur_low_detail,
|
|
"expectedOutcome": "abstained",
|
|
"expectedReason": "low_confidence",
|
|
"providerContent": content({
|
|
"isStool": True, "bristolType": 4, "color": "brown", "confidence": 0.41,
|
|
"imageQuality": "poor", "observations": OBSERVATION_STRINGS["unclear"],
|
|
}),
|
|
},
|
|
{
|
|
"id": "glare-overexposed",
|
|
"caseClass": "glare",
|
|
"image": glare_overexposed,
|
|
"expectedOutcome": "abstained",
|
|
"expectedReason": "unusable_visual_fields",
|
|
"providerContent": content({
|
|
"isStool": True, "bristolType": None, "color": None, "confidence": 0.66,
|
|
"imageQuality": "poor", "observations": OBSERVATION_STRINGS["highlight"],
|
|
}),
|
|
},
|
|
{
|
|
"id": "empty-bowl",
|
|
"caseClass": "empty-bowl",
|
|
"image": empty_bowl,
|
|
"expectedOutcome": "abstained",
|
|
"expectedReason": "not_stool",
|
|
"providerContent": content({
|
|
"isStool": False, "bristolType": None, "color": None, "confidence": 0.93,
|
|
"imageQuality": "good", "observations": OBSERVATION_STRINGS["container"],
|
|
}),
|
|
},
|
|
{
|
|
"id": "malformed-response",
|
|
"caseClass": "malformed-response",
|
|
"image": unusable_tone,
|
|
"expectedOutcome": "fail_closed",
|
|
"expectedReason": "unparsable_content",
|
|
"providerContent": "Sure! It looks like a Type 4 to me. Hope that helps.",
|
|
},
|
|
{
|
|
"id": "out-of-range-fields",
|
|
"caseClass": "malformed-response",
|
|
"image": shaded_tone,
|
|
"expectedOutcome": "fail_closed",
|
|
"expectedReason": "out_of_range_or_missing_fields",
|
|
"providerContent": content({
|
|
"isStool": True, "bristolType": 9, "color": "purple", "confidence": 1.4,
|
|
"imageQuality": "excellent", "observations": OBSERVATION_STRINGS["confident"],
|
|
}),
|
|
},
|
|
]
|
|
|
|
|
|
def write_fixture(definition: dict[str, Any], directory: Path) -> dict[str, Any]:
|
|
name = f"{definition['id']}.png"
|
|
target = directory / name
|
|
factory: Callable[[], Image.Image] = definition["image"]
|
|
factory().save(target, "PNG", optimize=True)
|
|
data = target.read_bytes()
|
|
return {
|
|
"id": definition["id"],
|
|
"caseClass": definition["caseClass"],
|
|
"file": name,
|
|
"bytes": len(data),
|
|
"sha256": hashlib.sha256(data).hexdigest(),
|
|
"width": WIDTH,
|
|
"height": HEIGHT,
|
|
"format": "png",
|
|
"synthetic": True,
|
|
"license": LICENSE,
|
|
"provenance": PROVENANCE,
|
|
"expectedOutcome": definition["expectedOutcome"],
|
|
"expectedReason": definition["expectedReason"],
|
|
"providerContent": definition["providerContent"],
|
|
}
|
|
|
|
|
|
def manifest(entries: list[dict[str, object]]) -> dict[str, object]:
|
|
return {
|
|
"schemaVersion": 1,
|
|
"generator": "scripts/build_vision_corpus.py",
|
|
"license": LICENSE,
|
|
"note": "Synthetic fixtures only. No real medical imagery, no personal data, and no third-party content.",
|
|
"cases": entries,
|
|
}
|
|
|
|
|
|
def write(directory: Path) -> int:
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
entries = [write_fixture(definition, directory) for definition in cases()]
|
|
(directory / "manifest.json").write_text(json.dumps(manifest(entries), indent=2) + "\n", encoding="utf-8")
|
|
print(f"wrote {len(entries)} fixtures to {directory}")
|
|
return 0
|
|
|
|
|
|
def verify(directory: Path) -> int:
|
|
manifest_path = directory / "manifest.json"
|
|
recorded = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
problems: list[str] = []
|
|
for entry in recorded.get("cases", []):
|
|
path = directory / str(entry.get("file"))
|
|
if not path.is_file():
|
|
problems.append(f"{entry.get('id')}: missing fixture {entry.get('file')}")
|
|
continue
|
|
data = path.read_bytes()
|
|
if hashlib.sha256(data).hexdigest() != entry.get("sha256"):
|
|
problems.append(f"{entry.get('id')}: bytes do not match the recorded sha256")
|
|
if len(data) != entry.get("bytes"):
|
|
problems.append(f"{entry.get('id')}: byte length does not match the manifest")
|
|
if problems:
|
|
for problem in problems:
|
|
print(problem, file=sys.stderr)
|
|
return 1
|
|
print(f"verified {len(recorded.get('cases', []))} corpus fixtures against manifest.json")
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--verify", action="store_true", help="check committed fixtures against the manifest instead of rewriting")
|
|
parser.add_argument("--directory", default=str(CORPUS_DIR))
|
|
args = parser.parse_args()
|
|
directory = Path(args.directory)
|
|
return verify(directory) if args.verify else write(directory)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|