Skip to content

Foundation: enforce the Images API's 280 px minimum, hash image bytes in replay keys, bound tiles (#306) - #321

Merged
CompleteDotTech merged 3 commits into
mainfrom
foundation/vision-limits
Oct 21, 2026
Merged

CompleteDotTech merged 3 commits into
mainfrom
foundation/vision-limits

Conversation

@CompleteDotTech

Copy link
Copy Markdown
Contributor

Closes #306

What this adds

Fixes vision.py accepting 256-279 px image sides that the Images API rejects, and closes #297's carried-over review items S1, S3, S5 and S7. Foundation work; no recipe or notebook changes. This defines contracts every image fixture binds to (the size limits and the image replay-key envelope), so it needs two independent reviews.

Upstream limits, quoted from https://docs.typesafe.ai/images.md (Limits table, fetched 2026-10-20 via llms.txt -> Images):

| Width and height | 280 to 1568 pixels each. An image with either side under 280 pixels is rejected. |
| Total pixels (width × height) | 78,400 to 2,458,624, the area of a 280 × 280 to a 1568 × 1568 square. |
| File size | 16 MiB per image file. For base64, this is the size after decoding. |
All limits include both ends of the range. An image outside them is rejected, never resized to fit.

Behaviour changes (each tested)

  1. Minimum side 256 -> 280 (vision.py). Area bounds and 16 MiB are unchanged and now live with the sides in one table, vision.IMAGE_LIMITS, with a comment citing the upstream page and fetch date. Tests: 279x400 and 400x279 rejected (via image_asset, validate_image_asset and replay_key), 280x280, 280x1568, 1568x280, 1568x1568 accepted, 256x307 now rejected. Existing tests that used 256-px fixtures were moved to 280 (320x256 -> 320x280); no assertion was loosened. test_image_limits_match_the_upstream_table_and_the_docs pins IMAGE_LIMITS to a hand-copied upstream table and checks docs/vision.md and the validate_image_asset docstring say 280-1568.
  2. S1: box_iou raises ValueError ("union underflows to zero") when the union is 0, rather than ZeroDivisionError. I chose raising over returning 0.0 because two identical 1e-200 boxes would otherwise score 0 (disjoint) silently. Parametrized test through box_iou and localization_metrics; one underflowing box against a normal box still returns 0.0.
  3. S3: replay key hashes decoded bytes. payload["assets"][ref]["sha256"] is now the SHA-256 of the decoded file bytes (the digest already inside image_reference), not of the base64 text. QQ==/QR==-style non-canonical base64 of identical bytes now yields one key (tested with a real PNG, plus a replay hit across the two spellings). The envelope is otherwise unchanged ("v": 1, type, canonical format, sha256). Confirmed with git grep and git ls-files that no image fixture exists on main (the only .png files are the banner and the notebook-style docs images), so no recorded key changes; test_text_only_replay_keys_are_pinned_to_the_published_envelope is untouched and green. A test rebuilds the documented envelope independently and compares the key. Documented in docs/backends.md (and cross-referenced in docs/vision.md).
  4. S5: generate_tiles(..., max_tiles=256) (DEFAULT_MAX_TILES). The tile count is computed from the start positions before any crop; above the cap it raises ValueError. Tested including the overlap-511 case on 1568x1568 (~1.1M tiles) with crop patched to fail if reached, inclusive boundary, raised cap, and invalid max_tiles.
  5. S7: box_confidence docstring and docs/vision.md now say a reported 0.0/1.0 is returned as reported and only absence is never coerced. Docstring test.

Rechecked docs examples

docs/vision.md: "384 x 320 fits" stays true; "a 256 x 256 fixture does not" stays true (now also 279 x 400 / 400 x 279 named); tile_size=(320, 280) is valid because both sides are >= 280 (a note says tiles/sources under 280 must be scaled or padded before image_asset). Other docs mention no 256 limit.

Recipe checklist

Skipped: foundation work.

How it was verified

Fresh venv, pip install -e ".[dev]", Windows:

  • ruff check . -> All checks passed
  • ruff format --check . -> 395 files already formatted
  • python tools/render_catalog.py --check -> passes (no output)
  • python tools/check_hygiene.py -> "Hygiene check passed: 604 file(s) scanned." (an earlier run caught a 64-char alphabet literal in my new test; replaced by string constants)
  • python -m jev_cookbook.fixtures validate --all -> every recipe valid
  • pytest (full): 4950 passed, 58 skipped, 31 xfailed; the only failures were the two known Windows-only tests/test_notebook_ci_sandbox.py signal.SIGKILL tests (Foundation: two notebook-CI sandbox tests use signal.SIGKILL and fail on Windows #302) and test_hygiene.py::test_the_repository_itself_is_clean, which failed on the alphabet literal above and passes after the fix (re-run: 494 passed on the affected files).
  • Notebooks: no recipe or notebook imports jev_cookbook.vision (git grep), and text-only replay keys are pinned unchanged, so no notebook output can change; I did not re-execute notebooks.

No live call, no API key.

… in replay keys, bound tiles (#306)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@CompleteDotTech

Copy link
Copy Markdown
Contributor Author

Opus reviewer (independent review 1 of 2): Claude Opus 5.5 (claude-opus-5-5)

  • Head reviewed: 424790b5e58774798f45821d220856f7212e84fb (gh pr view 321 --json headRefOid, checked again before posting)
  • Base: 9a7f152202f13a1b1e4fc004d978390fc7c3232f (origin/main, which is also the merge-base)
  • Scope: 6 files, all 100644 -> 100644 modifications (git diff --raw -M origin/main...HEAD), no new files, no binaries, no mode or type changes, nothing outside the files the issue lists.
  • I did not read the other Opus review. I made no live call and did not look for a key.

Summary

The code is correct and well tested. I checked the limits against the live upstream page and they match exactly. The boundary tests cover both axes, and a mutation run kills every enforced rule. S1, S3, S5 and S7 are all handled. I am asking for one small docs change, because this PR freezes the image replay-key contract and leaves a promise in docs/backends.md that its own change breaks.

Blocking

B1. docs/backends.md:294-295 promises that v is bumped whenever the key rule changes. This PR changes the rule and keeps v: 1 without saying so.

The text reads: "The key version v is bumped if this rule ever changes." Since #297, image keys hashed the base64 text. Under this PR they hash the decoded bytes, and the field is still called sha256 with "v": 1 (backends.py:181, :186-192). So the doc every image fixture binds to now makes a claim the code does not keep.

On whether v should be bumped (I was asked to decide): no, and the PR is right to keep v: 1.

  • KEY_VERSION sits in every payload (backends.py:181). Bumping it would change all 2398 committed text-only keys. That breaks the acceptance criterion "text keys are unchanged" and would force every recipe 01-50 to re-record.
  • No image key has ever been committed. git ls-files shows only the banner and the docs PNGs. Nothing under recipes/ has an assets member, data_base64 or an image:sha256 reference, and no recipe or tool imports jev_cookbook.vision.
  • A key from the Foundation: image inputs and the Box primitive across backends, with local vision helpers #297 rule cannot be mistaken for a key from the new rule. The two payloads put different sha256 values in the hashed JSON, so an old-rule recording would raise ReplayMiss. It would never replay a wrong answer. A version marker adds no safety here.

What to change: make the doc say what actually happened and what happens next. For example, change lines 294-295 to: "The key version v is bumped if this rule changes after a fixture has been recorded under it. The image assets member was redefined once before any image fixture existed (#306: the digest went from the base64 text to the decoded file bytes), so v stayed 1." It would help to also say how a later change to the assets member would be versioned without disturbing text keys. One way: a marker inside the assets member only, added when that need arises. If you prefer, mirror this in the replay_key docstring (backends.py:160-167).

Suggestions (not blocking)

  1. The tile-growth wording is inaccurate. docs/vision.md:196-197 says the count grows "roughly with its square", and the generate_tiles docstring (vision.py:387) says it "grows quadratically as overlap approaches the tile size". The count is about (W/(t-o))·(H/(t-o)), so it grows with the inverse square of the stride tile - overlap, not with the square of the overlap. Suggested wording: "the count grows with the inverse square of the stride (tile size minus overlap)".
  2. The area check in _image_format (vision.py:88-89) can no longer be reached. min_area == min_side² and max_area == max_side², so the side check always fires first. My mutation M9, which removed the area check, survived. Keeping it as a guard in case upstream separates the two limits is reasonable. The test already asserts that relationship, so this is only for the record.
  3. backends.py:166 is a docstring line of about 130 characters. Ruff does not enforce a line length here, but every neighbouring line wraps at 100.
  4. Consider pinning one literal image key, built from fixed hand-made bytes or a fixed digest, next to PINNED_TEXT_KEY. test_image_key_hashes_decoded_bytes_in_the_documented_envelope rebuilds the envelope independently with json.dumps, which is good. A literal would also catch drift that changed both sides at once.

Upstream limits, checked independently

I followed https://docs.typesafe.ai/llms.txt to the Images page and fetched https://docs.typesafe.ai/images.md on 2026-10-20 with curl. The Limits table says:

| Width and height | 280 to 1568 pixels each. An image with either side under 280 pixels is rejected. |
| Total pixels (width × height) | 78,400 to 2,458,624, the area of a 280 × 280 to a 1568 × 1568 square. |
| File size | 16 MiB per image file. For base64, this is the size after decoding. |
All limits include both ends of the range. An image outside them is rejected, never resized to fit.

vision.IMAGE_LIMITS matches exactly: 280, 1568, 78,400, 2,458,624 and 16 * 1024 * 1024. The Errors table lists below-minimum width, height or total pixels as a 400, so the change from 256 to 280 is the right fix. The new docs/vision.md text also matches upstream. It says the API rejects an image and never resizes it, and that a smaller image must be scaled or padded first.

Boundary tests on both axes

The tests cover both axes. test_image_asset_accepts_boundary_dimensions accepts (280, 280), (280, 1568), (1568, 280), (1568, 1568) and (320, 280). test_image_asset_rejects_invalid_dimensions rejects (279, 400), (400, 279), (279, 279), (256, 307), (255, 400), (400, 255), (1569, 280) and (280, 1569), and each rejection is matched on the message, so a different ValueError would not pass. The same 279/280 boundary is exercised on both axes through validate_image_asset and through replay_key.

I also probed replay_key myself with fresh PNGs:

  • Accepted: 280x280, 1568x280, 280x1568, 1568x1568.
  • Rejected with "280 to 1568": 279x400, 400x279, 1569x280.

Mutation evidence

I made one change at a time to the source, ran tests/test_vision.py and tests/test_multimodal_backends.py, then restored the file byte for byte. git status was clean afterwards.

Mutation Result
M1 min_side back to 256 killed
M2 key hashes the base64 text again (S3 reverted) killed
M3 height minimum unchecked killed
M4 width minimum unchecked killed
M10 height maximum unchecked killed
M8 max_side 1569 killed
M5 tile cap removed killed
M6 tile cap made exclusive (>=) killed
M7 box_iou returns 0.0 instead of raising killed
M9 area check removed survived (unreachable, see suggestion 2)

Checks run (fresh venv, Python 3.14.3, pip install -e ".[dev]", Windows, no TYPESAFE_API_KEY)

  • ruff check .: All checks passed.
  • ruff format --check .: 395 files already formatted.
  • Full pytest: 4951 passed, 58 skipped, 50 deselected, 31 xfailed, 2 failed, in 542.66 s. The two failures are tests/test_notebook_ci_sandbox.py::test_kills_every_non_zombie_process_and_returns_once_none_remain and ::test_a_process_that_will_not_die_fails_closed, the known Windows-only signal.SIGKILL cases (Foundation: two notebook-CI sandbox tests use signal.SIGKILL and fail on Windows #302). This PR does not touch that code. Nothing else failed, including test_hygiene.py::test_the_repository_itself_is_clean, which the builder had only re-run partially.
  • python tools/render_catalog.py --check: exit 0.
  • python tools/check_hygiene.py: Hygiene check passed, 604 files scanned.
  • python -m jev_cookbook.fixtures validate --all: all 50 recipes and _template valid (mode replay).
  • Notebooks: no recipe, template or tool imports jev_cookbook.vision. The backends.py change only affects the non-empty-assets branch. Text-only keys are pinned by test_text_only_replay_keys_are_pinned_to_the_published_envelope, and each recipe's byte-identical-responses test passed in the full run. So no notebook can be affected, and I did not re-execute them locally. In CI, every Notebook (...) job passed on this head, executed offline in a scratch copy and checked for freshness.
  • gh pr checks 321: 60 of 60 pass, covering lint, Tests py3.10 and py3.14, catalog, hygiene, fixtures, scope and every notebook.

Other things I checked and found correct

  • The extra sha256 field that _check_assets now returns is consumed only by replay_key. live.py:696-704 reads only data_base64, so nothing new reaches the SDK, a recording or the ledger. A caller cannot inject its own sha256, because validate_image_asset requires exactly {type, format, data_base64}.
  • The key digest is the same one image_reference embeds (tested). The non-canonical twin helper really produces different base64 for identical bytes, the replay hit works across the two spellings, and reverting S3 is caught (M2).
  • box_iou raises when the union is 0. One tiny box against a normal box is still defined (0.0), and localization_metrics propagates the ValueError. Raising is a defensible choice: returning 0.0 would score two identical boxes as disjoint.
  • generate_tiles checks the tile count before any crop. The test patches Image.crop to fail if it is reached, and covers the 1568² overlap-511 case. The default of 256 still allows any sensible grid: the docs example (320, 280) with overlap 32 on 1568² makes 42 tiles. max_tiles rejects 0, -1, True, 2.5 and "9".
  • The box_confidence docstring is corrected, and its test asserts that a reported 0.0 and 1.0 come back unchanged and that absence stays None.
  • No test was deleted or loosened. Changing the 256-pixel fixtures to 280 keeps every assertion, and the rejection tests are stricter because they now match the message.

Acceptance criteria (#306)

  • vision.py enforces 280-1568 per side. docs/vision.md and the validate_image_asset docstring say 280, and the example sizes, including tile_size=(320, 280) and "256 × 256 does not", were rechecked.
  • Boundary cases (280, 280) and (1568, 1568) are accepted, and (279, 400), (400, 279) and (256, 307) are rejected, plus both axes at the maximum.
  • A docs-drift test pins the limit numbers in one constant table (IMAGE_LIMITS), with a comment citing the upstream page and the 2026-10-20 fetch date.
  • box_iou raises ValueError when the union is 0, with a parametrized 1e-200 test through box_iou and localization_metrics.
  • Decision recorded on S3: partly met. The key now hashes the decoded bytes, the QQ==/QR== test is present and the text-key pin is green. However, docs/backends.md:294-295 still says v is bumped on any rule change and was not updated to record that the image member was redefined at v: 1 (B1).
  • generate_tiles takes max_tiles= (default 256) and has tests.
  • The box_confidence docstring is corrected.

OPUS-REVIEW: CHANGES-NEEDED 424790b

@CompleteDotTech

Copy link
Copy Markdown
Contributor Author

Opus reviewer (independent review 2 of 2). Model: Claude Opus 5.5 (claude-opus-5-5). I wrote this without reading the other review.

  • Head reviewed: 424790b5e58774798f45821d220856f7212e84fb (checked out detached at this SHA; git rev-parse HEAD matches gh pr view 321 --json headRefOid)
  • Base: 9a7f152202f13a1b1e4fc004d978390fc7c3232f
  • Scope: git diff --raw -M origin/main...HEAD shows 6 files, all 100644 -> 100644 M (docs/backends.md, docs/vision.md, src/jev_cookbook/backends.py, src/jev_cookbook/vision.py, tests/test_multimodal_backends.py, tests/test_vision.py). No symlinks, submodules, mode changes, keys, personal data or live calls.

Blocking findings

None.

Did the three contracts work for the recipe builders who will use them?

I went through each one as the builder of a Box recipe (77-84) and of the tiled-search recipe (83).

box_iou raising ValueError when the union is zero (S1): right contract. The union is never smaller than either box's area. So it can only be zero when both boxes have areas that underflow to zero, and that includes the gold box. The model cannot crash an evaluation as long as the gold label is sane. I checked this: localization_metrics([normal gold], [1e-200 prediction]) returns mean_iou=0.0 and does not raise. The only way to hit the raise is a degenerate gold label, which is a fixture defect. Raising is the right response to that, and returning 0.0 would quietly call two identical boxes disjoint. Subnormal (not fully underflowed) areas still behave: identical 1e-160 boxes give 1.0 and a partial overlap gives 0.25. The contract is documented where a Box builder will look, in docs/vision.md:120-124 and the box_iou docstring (vision.py:301).

max_tiles (default 256, inclusive) (S5): right contract, and the default holds up on realistic inputs. I ran generate_tiles on configurations a tiled-search builder would try:

source tile overlap result
1568x1568 280x280 0 36 tiles
1568x1568 280x280 140 121 tiles
1568x1568 512x512 256 36 tiles
3840x2160 512x512 256 112 tiles
3840x2160 280x280 140 raises: "would make 405 tiles, above max_tiles=256; use a larger tile, less overlap or a larger max_tiles"
4000x3000 1568x1568 200 9 tiles

The only configuration refused is one that would cost more than 400 API calls per image. That is a reasonable thing to stop by default, and the error message says what to change. The tile count is computed from start positions before any crop, and the test proves it by patching crop to fail. The 280 px note in the tiling section (docs/vision.md:193-198) is the warning a tiled-search builder needs: a tile smaller than 280 px is geometrically valid but image_asset will reject it.

Replay key hashes decoded bytes (S3): right contract, applied consistently. Every place a key is built goes through replay_key: LiveBackend.decide, record() deduplication and the ledger (live.py:969), and CallAudit (accounting.py:68). None of them hashes data_base64 separately, so recording and replay cannot drift apart. The sha256 field _check_assets now adds never reaches the SDK (live.py:703 reads only data_base64) and never reaches validate_image_asset again, which requires exactly three keys. The key's digest is now the same one image_reference puts in the state, so a builder can tell which bytes a key binds just by reading the reference. git ls-files shows no image fixtures under recipes/, so no recorded key moves. test_text_only_replay_keys_are_pinned_to_the_published_envelope (tests/test_multimodal_backends.py:197) is unchanged and passes. The envelope is documented at docs/backends.md:401-411, and docs/fixtures.md already points builders to replay_key there.

Mutation evidence. I applied each mutation to the source, ran tests/test_vision.py and tests/test_multimodal_backends.py, then restored the file. All six were caught:

mutation result
min_side back to 256 caught
key hashes base64 text again caught
tile-cap check removed caught
cap made exclusive (>=) caught
box_iou returns 0.0 instead of raising caught
box_iou raises whenever either area is 0 caught (the one-tiny-box case stays defined)

Suggestions (not blocking)

  1. vision.py:477-484: the localization_metrics docstring doesn't mention the new ValueError. Add one sentence saying it raises only when a gold box's area underflows to zero, i.e. on a degenerate gold label. Put the same sentence in docs/vision.md:122-124. That tells a Box builder the raise means "fix the fixture", not "the model broke evaluation". Rejecting such gold boxes when labels are validated could follow later.
  2. docs/vision.md:196-198: add that each tile is usually one API call, so max_tiles also caps cost. That gives builders a reason to keep the default other than memory.
  3. backends.py:166: the edited replay_key docstring line is about 130 characters. Ruff accepts it, but wrapping it would match the rest of the file.
  4. docs/backends.md:407: "no image fixture existed before it was fixed, so no recorded key changed" is history. It belongs in the PR or changelog, not in the reference doc builders read. Consider dropping it.

Checks run (fresh venv, Python 3.14, Windows, no TYPESAFE_API_KEY)

  • ruff check .: All checks passed!
  • ruff format --check .: 395 files already formatted
  • python tools/render_catalog.py --check: exit 0
  • python tools/check_hygiene.py: Hygiene check passed: 604 file(s) scanned.
  • python -m jev_cookbook.fixtures validate --all: every recipe valid (mode replay)
  • pytest (full): 4951 passed, 2 failed, 58 skipped, 50 deselected, 31 xfailed. The 2 failures are the known Windows-only tests/test_notebook_ci_sandbox.py signal.SIGKILL tests (Foundation: two notebook-CI sandbox tests use signal.SIGKILL and fail on Windows #302). This PR does not touch that code.
  • Notebooks: no notebook or recipe helper imports jev_cookbook.vision or passes assets=, and the text keys are pinned, so no notebook output can change. As a sanity check I executed _template and 01-sentiment-classification offline through tools/execute_notebook.py with netguard. Both ran, and check_notebook_fresh.py reports "matches a fresh offline run" for each.
  • Mutation testing: 6 of 6 caught (table above).
  • Ad hoc probes: the tile-count table, subnormal and underflow IoU cases, and normal gold against a tiny prediction (above).
  • gh pr checks 321: 60 of 60 pass, including Tests py3.10 and py3.14 and every notebook job.

Acceptance criteria (#306)

  • vision.py enforces 280-1568 per side. docs/vision.md and the validate_image_asset docstring both say 280. The doc examples were rechecked: 384x320 and 280x280 fit; 256x256, 279x400 and 400x279 do not; and the (320, 280) tile example is valid.
  • Boundary tests: (280,280), (1568,1568), (280,1568) and (1568,280) are accepted. (279,400), (400,279) and (256,307) are rejected through image_asset, validate_image_asset and replay_key.
  • A docs-drift test pins IMAGE_LIMITS in one table, with a comment citing https://docs.typesafe.ai/images (fetched 2026-10-20). I did not re-fetch upstream, since that would be a network call. The numbers agree with the quote in the PR body and with each other: min area is 280², max area is 1568².
  • box_iou raises ValueError on a zero union. A parametrized 1e-200 test covers both box_iou and localization_metrics.
  • S3 decision: the key now hashes decoded bytes. There is a QQ==/QR== test with a real PNG and a test that replay hits across the two spellings. The pinned text-key test is unchanged and passes.
  • generate_tiles(max_tiles=256) is documented and tested: the cap is checked before cropping, it is inclusive, it can be raised, and invalid values are rejected.
  • box_confidence docstring corrected, with a test.

OPUS-REVIEW: APPROVE 424790b

CompleteDotTech added a commit that referenced this pull request Oct 21, 2026
…ge policy (#307) (#322)

Closes #307

## What this adds

The image-file contract for multimodal recipes, a checker for it, and
the fixes to the scope, freshness and attribute handling of binary
files. Contract in `docs/fixtures.md` (Image fixtures); notebook rules
in `docs/notebook-ci.md` (staleness check rules 2-5).

- `tools/check_recipe_assets.py` (stdlib only), second step of the
`Hygiene (secrets and notebook outputs)` job and a pre-commit hook:
location (`recipes/<slug>/assets/` only), PNG/baseline JPEG with
lowercase suffix and matching bytes, 280-1568 px / 78,400-2,458,624
area, 1 MiB per file, 8 MiB per folder, no metadata, no symlinks.
Parsing is allowlist-based for both formats (see fix round 1).
- `tools/check_recipe_scope.py`: an image suffix added/modified/renamed
anywhere but `recipes/<slug>/assets/` is rejected (deletion allowed).
- `tools/check_notebook_fresh.py`: `image/jpeg` is normalised like
`image/png` (Pillow displays every image as both); output cap 2 MiB;
embedded images are metadata-checked in both files.
- `.gitattributes` `*.jpeg binary`; `docs/recipe-template.md` `assets/`
row; `CONTRIBUTING.md`, `docs/development.md`, `.pre-commit-config.yaml`
updated.

## Fix round 1 (both Opus reviews on 367a1e3 were CHANGES-NEEDED)

Review 1:
- **B1** metadata smuggling: JPEG is now a marker allowlist (SOI, one
exactly-16-byte JFIF APP0 with no thumbnail, whole DQT/DHT tables only,
DRI of length 4, one SOF0 8-bit with 1 or 3 components, SOS with
consistent length, RSTn, EOI); everything else is `jpeg-metadata` or
`jpeg-structure`. PNG: exact length for
gAMA/cHRM/sRGB/pHYs/sBIT/bKGD/tRNS, PLTE only in palette images, IHDR
validated, IDAT consecutive and one zlib stream with nothing after it.
One negative test per carrier from the review (text after JFIF, second
APP0, thumbnail, DNL, JPG0, 0xFF02, DRI, PLTE in RGB, pHYs/bKGD with
text, IDAT trailing text, ...).
- **S5** CRC test now corrupts byte 20 and asserts `bad CRC in chunk
IHDR` (mutation of the CRC check is killed; I checked).
- **B2** notebook outputs: every committed `image/png` and `image/jpeg`
output (and every `data:image` URI in an output) is checked with the
asset rules in both files; matplotlib's short `tEXt Software` is the
only text allowed. Test: EXIF JPEG committed vs clean fresh JPEG now
fails. Markdown/raw cells may not embed an image (attachment or data
URI); `image/gif` etc. outputs are problems. All notebooks on main still
pass (tested).
- **B3** `.pre-commit-config.yaml` hook, `docs/development.md` Hygiene
row and section, `CONTRIBUTING.md` `assets/` entry; each pinned by a
test.
- **B4** `sniff` now also detects BMP, ICO, HEIF/AVIF, JPEG 2000 and SVG
by content; docs list exactly the detected set and state that
base64/data: images inside text files are not detected.
- **S2** documented: 8/16-bit and interlaced PNGs accepted; JPEG
precision 8, 1 or 3 components enforced. **S3** noted the cap runs in
the Notebook job. **S1** test pins the checker limits to the documented
280-1568 and, if `jev_cookbook.vision.IMAGE_LIMITS` exists (PR #321), to
it; `vision.py` untouched.

Review 2:
- **Blocking 1** the "twice its file" claim is replaced in
`docs/notebook-ci.md` and the `tools/check_notebook_fresh.py` comment
with the measured 8.6x to 22x factors; both recommend
`IPython.display.Image(filename=path)` and `Image.thumbnail((640,
640))`; `docs/fixtures.md` carries the same guidance.
- **Suggestions** taken: `savefig(metadata={"Software": None})`
documented; `file-too-large` message and doc suggest JPEG for
photographic content; ICC warning corrected to PNG only; CMYK/Adobe
note. Not taken: #321 ordering (vision.md still says 256 until #321
merges).

## Verification (fresh venv, Windows, after merging origin/main 7fa806e
with a signed merge commit)

- `ruff check .` pass; `ruff format --check .` 397 files formatted.
- `pytest`: the full run before the last test-only fix had 4 failures:
the 2 known Windows `SIGKILL` ones (#302),
`test_a_scripted_scaffold_runs_from_the_scaffold_to_a_byte_identical_notebook`
(kernel start timeout under load; passes on rerun), and
`test_style_notebook_display.py::test_apply_style_does_nothing_outside_ipython`
(caused by a new test of mine creating a global IPython shell; fixed to
use `DisplayFormatter()`; rerun of the three affected files: 143
passed). The final head was not re-run in full; it differs from the full
run only by that test fix.
- `render_catalog.py --check` exit 0; `check_hygiene.py` 606 files pass;
`check_recipe_assets.py` 461 files pass; `notebook_ci.py fixtures` 51
folders, 0 failed.

Two independent Opus reviews requested by the issue; this is a new head,
so earlier approvals do not apply.

🤖 Generated with [Claude Code](https://claude.com/claude-code)



## Fix round 2 (head 171240f; re-review
1 CHANGES-NEEDED, re-review 2 APPROVE on 3456211)

- **R1 (blocking)**: the text-cell rule now matches `data:\s*image/`
case-insensitively with any parameters, base64 or not; cell metadata and
notebook metadata (values, nested values and keys such as `image/jpeg`)
are scanned too; the output data-URI regex is case-insensitive and
accepts parameters. One test per spelling (upper-case scheme, mixed-case
type, one and two parameters, whitespace, non-base64 SVG, markdown
syntax; markdown and raw cells) and per metadata placement.
- **S7 (cheap PNG hardening)**: PNG chunks the spec allows once (gAMA,
cHRM, sRGB, sBIT, pHYs, bKGD, tRNS) are rejected when repeated; the
inflated IDAT length must equal the length implied by width, height,
depth, colour type and interlace (including the Adam7 passes; the test
computes the expected size independently). Tests include text appended
inside the zlib stream.
- **S8**: an embedded PNG output may hold at most one `tEXt` chunk, and
it must match matplotlib's own wording (`Software\0Matplotlib
version<N>, https://matplotlib.org/`).
- **S6**: tests for a second exact JFIF APP0, a late APP0, and the
marker allowlist (unknown markers with a valid table body are refused
with the allowlist message). I confirmed by mutation that removing
either guard now fails a test.
- **Docs**: `docs/fixtures.md` says the rules remove metadata ordinary
tools write and are not a detector for deliberately hidden bytes; the
output-cap failure message recommends
`IPython.display.Image(filename=path)`.
- Not added, per the scope decision: table-content and pre-EOI-data
rules.
- **Verification on this head (origin/main had not moved; no merge
needed)**: ruff check and format clean; `render_catalog --check` 0;
hygiene 606 files; `check_recipe_assets` 461 files; fixtures 51/0; full
`pytest`: 2 failed, 5284 passed, 58 skipped, 50 deselected, 31 xfailed.
The 2 failures are the known Windows `SIGKILL` tests (#302).

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
CompleteDotTech and others added 2 commits October 20, 2026 21:14
…an image replay key (#306)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@CompleteDotTech

Copy link
Copy Markdown
Contributor Author

Opus reviewer (independent review 1 of 2, re-review): Claude Opus 5.5 (claude-opus-5-5)

Is my earlier review resolved?

B1 (blocking) is resolved. docs/backends.md:294-298 now says v is: "bumped if this rule changes after a fixture has been recorded under it". It also states that the assets member was redefined once, before any image fixture existed, and that v therefore stayed 1. It adds: "Any later change that would alter an existing key, text-only or image, bumps v." That is accurate and matches what the code does. The duplicate claim lower on the page (around line 410) was removed, so the page no longer says the same thing twice in different words.

Suggestions:

  1. Tile-growth wording: resolved. Both docs/vision.md:198-201 and the generate_tiles docstring now say the count grows with "the inverse square of the stride (tile size minus overlap)". That is correct. The new remark that the cap also bounds cost is a fair addition.
  2. Unreachable area check: left as is. That was my suggestion too. M9 still survives, and that is expected (see the mutation table).
  3. Overlong docstring line: resolved. backends.py:166 is now wrapped.
  4. Literal image key: resolved. test_image_replay_key_is_pinned_to_the_published_envelope patches validate_image_asset to return fixed bytes, so the pinned value does not depend on the Pillow or zlib version. I recomputed the key myself from the documented envelope with plain json.dumps and hashlib, with no project code involved.
    • I got 00c4d3b2fcb92ce23200e54bed0dcc9c91a7bc134b7862838a1facaddf9acba8, which is exactly the pinned literal.
    • Under the old rule (hashing the base64 text), the same request gives bb2774fa…0732586. So the pin also catches a regression back to the old rule.

Other new text in fd39eb8: it adds a note that a box_iou ValueError in localization_metrics "means a degenerate gold label" (docs/vision.md:125-126, vision.py:486-487). This is correct. The union is at least as large as either area, so the union can only reach zero when the gold area has also underflowed.

The merge, and the asset checker against vision.IMAGE_LIMITS

  • The merge adds nothing of its own. git show --remerge-diff f486636 is empty, so no conflict was resolved by hand. The PR's own diff against the old base (9a7f152..fd39eb8) is line-for-line identical to its diff against the new base (327b0a0..f486636): 584 lines each once index lines are dropped, and diff exits 0.
  • The limits agree. tools/check_recipe_assets.py:48-51 sets MIN_SIDE = 280, MAX_SIDE = 1568, MIN_PIXELS = 78_400 and MAX_PIXELS = 2_458_624, which equal vision.IMAGE_LIMITS min_side, max_side, min_area and max_area.
  • File size uses two different caps, on purpose. The checker's 1 MiB per-file cap is a stricter repository rule and is documented as such. It sits beside the API's 16 MiB limit, which vision.py enforces.
  • The cross-pin test is now live, and it bites. tests/test_check_recipe_assets.py::test_the_checker_limits_match_the_shared_image_limits_when_the_package_exports_them now really compares the two sets of values. Mutation M11 (max_area changed to 2,458,625 in vision.py) is killed with 2 failures.
  • No document still states a 256 px minimum. I searched docs, AGENTS.md, CONTRIBUTING.md, README.md, catalog, src, tools, the template and every *.md for 256 (excluding SHA-256, 256 * 1024 and max_tiles), and for any dimension pair with a side of 279 or less.
    • The only hit is docs/vision.md:45, "a 256 × 256 … fixture does not [fit]", which is a correct counterexample.
    • Every statement of the limits says 280 to 1568 and 78,400 to 2,458,624: docs/vision.md:43, docs/fixtures.md:307, the checker's docstring and the validate_image_asset docstring.

Blocking

None.

Suggestions (not blocking)

  1. Make the cross-pin unconditional. tests/test_check_recipe_assets.py:1060 uses getattr(vision, "IMAGE_LIMITS", None) and skips the comparison when the name is missing. That made sense while Foundation: image-file contract, checker, scope rule and notebook image policy (#307) #322 landed before this PR. Now that IMAGE_LIMITS exists, a later rename would quietly turn this test into a pass that checks nothing. test_vision.py would still catch the rename, but not the checker side of it. The file comes from main, so this belongs in a follow-up rather than here: replace the getattr with a direct attribute access and delete the comment that says "until it does".

Checks run

Same fresh venv (Python 3.14.3, pip install -e ".[dev]" re-run at the new head), on Windows, with no TYPESAFE_API_KEY.

  • ruff check .: All checks passed!
  • ruff format --check .: 397 files already formatted.
  • Full pytest: 5310 passed, 58 skipped, 50 deselected, 31 xfailed, 2 failed, in 437.40 s.
  • python -m jev_cookbook.fixtures validate --all: exit 0. All 51 folders are valid (mode replay): the 50 recipes plus _template. The builder had not run this after the merge.
  • python tools/check_recipe_assets.py: "Image-asset check passed: 461 file(s) under recipes/ checked."
  • python tools/check_hygiene.py: "Hygiene check passed: 606 file(s) scanned."
  • python tools/render_catalog.py --check: exit 0.
  • Notebooks: I did not execute any locally, because no notebook can change. No recipe imports jev_cookbook.vision; tools/check_recipe_assets.py only names it in a docstring. Text-only keys are pinned, and each recipe's test that its responses are byte-identical passed in the full run. In CI, every Notebook (...) job passed on this head.
  • gh pr checks 321: 60 of 60 pass.

Mutation re-run at this head

Each change was applied on its own and tested with tests/test_vision.py, tests/test_multimodal_backends.py and tests/test_check_recipe_assets.py. Each file was restored byte for byte afterwards, and git status is clean.

Mutation Result
M1 min_side 256 killed (15 failures)
M2 hash the base64 text killed (4, now including the literal pin)
M3 / M4 height or width minimum unchecked killed (4 / 6)
M10 height maximum unchecked killed (1)
M8 max_side 1569 killed (14)
M5 tile cap removed killed (2)
M6 tile cap exclusive killed (1)
M7 box_iou returns 0.0 killed (3)
M11 max_area 2,458,625 killed (2)
M9 area check removed survived (the check cannot be reached: min_area = min_side² and max_area = max_side²)

I also re-checked the upstream page directly: docs.typesafe.ai/images.md, fetched on 2026-10-20, gives 280 to 1568 px per side, 78,400 to 2,458,624 px in all, and 16 MiB per file, with both ends of each range included. These match.

Acceptance criteria (#306)

  • vision.py enforces 280 to 1568 per side, and the docs and the docstring say 280. The worked examples were rechecked.
  • The boundary cases are tested on both axes: (280, 280) and (1568, 1568) are accepted; (279, 400), (400, 279) and (256, 307) are rejected; the maxima are covered too.
  • A docs-drift test pins IMAGE_LIMITS, and its comment cites the upstream page and the fetch date. The asset checker is now cross-pinned to the same values.
  • box_iou raises ValueError when the union is zero. A parametrized 1e-200 case covers box_iou and localization_metrics.
  • The S3 decision is recorded. Keys now hash the decoded bytes. The QQ==/QR== test, the literal image-key pin and the text-key pin all pass, and the key-version rule is documented truthfully with v kept at 1 (B1).
  • generate_tiles takes max_tiles= (default 256), with tests.
  • The box_confidence docstring is corrected.

OPUS-REVIEW: APPROVE f486636

@CompleteDotTech

Copy link
Copy Markdown
Contributor Author

Opus reviewer (independent review 2 of 2, re-review). Model: Claude Opus 5.5 (claude-opus-5-5). As before, I wrote this without reading the other reviewer's comments.

  • Head reviewed: f4866361e49c85c268079616acccbd4005a18adb. I checked it out detached; git rev-parse HEAD and gh pr view 321 --json headRefOid agree.
  • Base: 327b0a0ed5da928b898fb27d886316c728d266a9 (origin/main).
  • Changes since my approval of 424790b5:
  • Scope: git diff --raw -M origin/main...HEAD lists the same 6 files, all 100644 -> 100644 M. No symlinks, submodules, mode changes, keys, personal data or live calls.

Blocking findings

None.

My earlier notes

  1. localization_metrics docstring, and the docs on the raise: resolved. vision.py:485-487 and docs/vision.md:125-126 now explain that the union is at least as large as each area. A ValueError therefore means a degenerate gold label: fix the fixture.
  2. max_tiles also caps cost: resolved. This is now in docs/vision.md:198-201 and in the generate_tiles docstring. The growth wording is corrected to "inverse square of the stride (tile size minus overlap)". That is accurate: the count is roughly (side / stride)².
  3. Long replay_key docstring line: resolved. It is wrapped at backends.py:166-167.
  4. PR-history sentence in docs/backends.md: resolved. It is gone from the envelope paragraph. It has become a key-version rule at docs/backends.md:294-298: bump v when a change would alter a key some fixture was already recorded under. The paragraph records that the one assets redefinition happened before any image fixture existed. That is the right rule to state now that Foundation: image-file contract, checker, scope rule and notebook image policy (#307) #322 allows image files into recipes/<slug>/assets/.

Since 424790b

The literal image key pin (tests/test_multimodal_backends.py:463-477) runs; it is not skipped.

  • pytest -v -rs reports PASSED.
  • It patches vision.validate_image_asset. That patch takes effect because backends._check_assets imports the function lazily, inside the function (backends.py:120). The patch keeps Pillow and zlib versions out of the literal.
  • I rebuilt 00c4d3b2…acba8 independently, from the documented envelope with json.dumps and hashlib and without replay_key, and got the same digest.
  • Mutation results:
Mutation Result
Key hashes the base64 text again pin fails
sha256 field renamed in the envelope pin fails

The asset checker's limits match vision.IMAGE_LIMITS.

  • tools/check_recipe_assets.py:48-51 has MIN_SIDE=280, MAX_SIDE=1568, MIN_PIXELS=78_400 and MAX_PIXELS=2_458_624. These equal min_side, max_side, min_area and max_area.
  • The 1 MiB per-file cap is the repository's own, deliberately stricter than the API's 16 MiB (docs/fixtures.md:308).
  • test_the_checker_limits_match_the_shared_image_limits_when_the_package_exports_them runs, passes, and compares all four values on this head.
Mutation Result
Checker MAX_PIXELS off by one caught
IMAGE_LIMITS["min_area"] off by one caught
IMAGE_LIMITS renamed survives (see suggestion 1)

The rename case is still caught elsewhere: tests/test_vision.py::test_image_limits_match_the_upstream_table_and_the_docs reads vision.IMAGE_LIMITS directly. So there is no real gap today.

Docs after the merge. docs/fixtures.md (Image fixtures, from #322) and docs/vision.md give the same 280-1568 and 78,400-2,458,624 limits. Nothing in #322 conflicts with the decoded-bytes envelope.

Suggestions (not blocking)

  1. tests/test_check_recipe_assets.py:1055-1064: IMAGE_LIMITS now exists, so the getattr(vision, "IMAGE_LIMITS", None) / if limits is not None guard only lets the test pass silently if the table is renamed. Make the comparison unconditional and drop "when the package exports them" from the test name. This could be a follow-up, since Foundation: image-file contract, checker, scope rule and notebook image policy (#307) #322 owns that test.
  2. docs/vision.md:42: "at most 16 MiB" is correct for the loader. But a builder reading only this page could save a 3 MiB or EXIF-tagged PNG that image_asset accepts and check_recipe_assets.py rejects. One clause would prevent that, for example: "committed fixtures are further capped at 1 MiB per file with no metadata; see fixtures.md". It fails at pre-commit or CI rather than in a paid call, so it is cheap either way.

Checks run (fresh venv venv2, Python 3.14, Windows, no TYPESAFE_API_KEY, at f4866361)

  • ruff check .: All checks passed!
  • ruff format --check .: 397 files already formatted
  • python tools/render_catalog.py --check: exit 0
  • python tools/check_hygiene.py: Hygiene check passed: 606 file(s) scanned.
  • python tools/check_recipe_assets.py: Image-asset check passed: 461 file(s) under recipes/ checked.
  • python -m jev_cookbook.fixtures validate --all: every recipe valid (mode replay)
  • pytest -v -rs on the two key pins and the two checker-limit tests: 4 passed, 0 skipped.
  • pytest (full): 5310 passed, 2 failed, 58 skipped, 50 deselected, 31 xfailed.
  • Mutations: 5 run, 4 caught; the survivor is the rename case above.
  • Notebooks: the fix round and merge change no notebook, helper or text key path (the text-key pin passes). CI ran every notebook offline. In round 1 I executed _template and 01 locally and both matched; I did not repeat that.
  • gh pr checks 321: 60 of 60 pass.

Acceptance criteria (#306)

All still met at this head:

  • 280-1568 enforced and documented, with the doc examples rechecked
  • Boundary tests
  • Drift test pinning IMAGE_LIMITS, with its upstream citation
  • box_iou raises ValueError on a zero union, with a parametrized test
  • S3: the key hashes decoded bytes (QQ==/QR== test; text pin unchanged and passing; an image key literal is now pinned too)
  • max_tiles cap, with tests
  • box_confidence docstring

OPUS-REVIEW: APPROVE f486636

@CompleteDotTech
CompleteDotTech merged commit e998eb5 into main Oct 21, 2026
60 checks passed
@CompleteDotTech
CompleteDotTech deleted the foundation/vision-limits branch October 21, 2026 01:34
CompleteDotTech added a commit that referenced this pull request Oct 21, 2026
)

Closes #311

## What this adds

- `vision.gold_box(label)`: decodes and validates the gold Box label
`{"present": true, "box": {top, bottom, left, right}}` / `{"present":
false}` (strict keys, boolean `present`, no `box` on an absent label,
edges in [0, 1], positive size, and a gold area that does not underflow
to zero, so `box_iou` can never hit #321's zero-union error against a
gold box).
- `vision.localization_report(gold, outcomes, *, iou_threshold=0.5)` and
`LocalizationReport`: takes `("located", box)` / `("absent", None)` /
`("review", None)` per example; a present table and an absent table
count every example exactly once; mean IoU over located-present and over
all present (missed and review count 0), hit rate at the threshold,
false positives, correct absents; `str(report)` and
`report.denominators` name every denominator. A box returned for an
absent target is a false positive, never an IoU; a review is never an
absent prediction.
- Fixture validator: a top-level label object with a `present` key
declares Box labels; each must pass `gold_box`, and a non-Box label
among them is an error. `schema.json` description points to the format.
- `localization_metrics` kept; its docstring and `docs/vision.md` point
to the report.
- Docs: `docs/fixtures.md` ("Box labels"), `docs/vision.md` (gold
labels, the report, the worked example), `docs/evaluation.md` (Box
localization table of denominators).

Design note for the reviewers: a Box label nested under another key
(rank 98 mixed types, `{"target": {"present": ..., "box": ...},
"rubric": 3}`) is not seen by the validator; such recipes call
`gold_box(label["target"])` themselves. A tile choice is not stored
beside a gold box: the box is stored once, full-image, and Python
derives tiles from it (`docs/vision.md`, "Tiled and hierarchical
recipes"; `docs/fixtures.md`, "Box labels"). "Declared" is by the
reserved top-level `present` key because no per-recipe declaration
mechanism exists. Raise it if you prefer an explicit marker.

## Recipe checklist

Foundation work; section skipped.

## How it was verified

Merged head 23182da (signed merge of origin/main c316712 into 865dfcc),
Linux, fresh Python 3.10 venv:

- `ruff check .`: All checks passed. `ruff format --check .`: 397 files
already formatted.
- `pytest`: 5409 passed, 49 skipped, 50 deselected, 31 xfailed. (865dfcc
also passed on Python 3.14: 5408 passed.)
- `python tools/render_catalog.py --check`: exit 0. `python
tools/check_hygiene.py`: passed, 606 files. `python
tools/check_recipe_assets.py`: passed. `python tools/notebook_ci.py
fixtures`: 51 recipe folders, 0 failed.
- No notebook touches `vision.localization_*`, so no notebook execution
is affected.
- New tests (`tests/test_vision.py`, `tests/test_fixtures.py`) use
hand-computed values on dyadic rectangles: mixed, all-absent,
all-review, a reviewed present target changing the denominator, a box
returned for an absent target, a present target called absent, a
degenerate (underflowing) gold box, threshold inclusivity, malformed
inputs; the vision.md worked example is executed and its printed text
compared. Two docs-structure tests pin the Box-labels examples and the
tiled-recipe statements.

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
CompleteDotTech added a commit that referenced this pull request Oct 21, 2026
…309) (#323)

Closes #309

## What this adds

- `src/jev_cookbook/frames.py` (new, pure Python, no new dependency):
`sample_frames`, `frame_timestamps`, `select_frames` (copies carry
`frame_index`), `frame_intervals`, `relative_frames`,
`window_timestamps`, `frame_at`, `frame_windows`, `transition_time`,
`earliest_transition`.
- `evaluation.transition_metrics` + `TransitionMetrics`: exact and
within-tolerance accuracy, review count with stated denominators
(resolved and all), label-matched earliest-transition error in seconds
(plus separately named any-label fields), per-example `outcomes` and
`timing_errors_seconds`, `max_sampling_interval_seconds` /
`tolerance_below_sampling_interval`.
- `docs/vision.md` (sampling rules, tie-breaking, no video decoding,
elapsed-seconds convention, index spaces, recording the selection in
`fields`) and `docs/evaluation.md` (the transition-time convention,
tolerance versus sampling gap, review labels, multi-transition
sequences, result type row).
- Tests: `tests/test_frames.py` (new), additions to
`tests/test_evaluation.py`; `tests/test_docs_examples.py` bumped for the
one new python block in `docs/evaluation.md` (8 to 9 blocks, empty
preamble).

Deliberate deviation from the issue text: the helpers live in
`jev_cookbook.frames`, not `jev_cookbook.vision` (#321 edits
`vision.py`; it is untouched). `jev_cookbook.frames` is the single
import path; no re-export is planned.

## Review round 1 resolutions (head 8ff6588)

- Timing error counted wrong-label predictions (both reviews): the
headline `n_timed` and means now cover only label-matched examples with
times on both sides; the all-labels view is `n_timed_any_label` and
`*_any_label_*`. Tests and docs updated (hand-computed: n_timed 2, MAE
0.2).
- Tie rule with frames closer than the slack (review 1 B2): the nearest
search now considers the neighbouring frames and takes the earliest
within the slack of the closest. Tests include `[0, 1, 1+5e-10, 2]`
giving `[0, 1, 3]`.
- No transition-time convention (review 2 B1): one convention, the
`timestamp_seconds` of the first frame in which the new state is
visible, for gold and predictions. `frames.transition_time` and
`frames.earliest_transition` implement it; docs say tolerance should be
at least the largest sampled gap; `max_sampling_interval_seconds` is
recorded and `tolerance_below_sampling_interval` flags the opposite. A
hand-computed end-to-end test pins it.
- Suggestions taken: JSON two-element lists accepted; `uncertain_labels`
takes several review labels; `ValueError` (not `OverflowError`) for huge
times, one shared validator; per-example outcomes; `frame_at` and
`frame_windows`; `frame_index` on selected copies; docs on
position-based `count`, elapsed seconds, multi-transition sequences, and
mapping the model's uncertain option and Python's gate to the review
labels.

Merged `origin/main` (7fa806e) with a signed merge commit; no conflicts
(#318's `docs/evaluation.md` edits merged cleanly alongside this
section).

## Review round 2 resolutions (head 2cef5c7)

- `earliest_transition` accepts `None` for an undetermined pair (and
NumPy booleans). An undetermined pair before the first `True`, or with
no `True`, returns `{"review": True, "first_undetermined_index": i,
...}` with no time, never a later transition. Confirmed results now
carry `"review": False`. Docs say to score it with an `uncertain_labels`
label. Tested.
- `frame_windows` appends a final window aligned to `end` when the last
stride-spaced window stops short. Every frame lies in some window when
`stride_seconds <= width_seconds`; with a larger stride the windows are
deliberately sparse and frames between them are in no window (documented
with an example, tested). Tested with frames every 0.5 s to 8 s, width
3, stride 2 (windows start 0, 2, 4, 5) and with a small case.
- `docs/vision.md` states the actual tie guarantee (up to two frames
within the slack around a target); "can only be late" now says a correct
detection; docs say to drop `frame_index` too; `select_frames` validates
a pre-existing `frame_index`; `uncertain_labels=None` (or a
non-collection) raises `ValueError`.

## Recipe checklist

Skipped (foundation work).

## How it was verified

Final head 42dc36a (signed merge of origin/main 2601074 into a9c7922,
after two fix rounds), Linux, fresh Python 3.14 venv:
- `ruff check .`: All checks passed. `ruff format --check .`: 399 files
already formatted.
- `pytest`: 5547 passed, 49 skipped, 50 deselected, 31 xfailed, 0
failed.
- `python tools/check_hygiene.py`: passed, 608 files. `python
tools/check_recipe_assets.py`: passed, 461 files. `python
tools/render_catalog.py --check`: exit 0. `python tools/notebook_ci.py
fixtures`: 51 recipe folders, 0 failed.
- No notebook is affected (no recipe imports the new code).
`tests/test_docs_examples.py` executes the `docs/evaluation.md` blocks
and the `docs/vision.md` frame walkthrough.

No live call, no key; nothing here claims anything about Jev.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant