Skip to content

feat(cull): eye focus, eye state, smile and gaze per face - #471

Merged
bmachek merged 3 commits into
mainfrom
claude/optimistic-cray-nlbo6t
Nov 3, 2026
Merged

bmachek merged 3 commits into
mainfrom
claude/optimistic-cray-nlbo6t

Conversation

@bmachek

@bmachek bmachek commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Summary

Culling now judges faces by the criteria the commercial culling tools use. Lightroom's own Assisted Culling has Eye focus and Eyes open, including "can't tell". Narrative has per-face focus and eye traffic lights, Aftershoot key faces and expression, Excire eye sharpness and smile. Before this PR, face sharpness was a Laplacian variance over the whole face box on the ≤2048 px working copy, and blinks were guessed from an eye-openness gradient proxy.

This PR holds the first two of five planned packages (see Follow-ups):

  1. Eye and face focus at full resolution (84b0d3b), outstanding item 3 in Dev-Image-Culling-Implementation-Plan.md.
  2. Eye state, smile and gaze per face with MediaPipe (e13515d), outstanding item 1.

1. Eye and face focus at full resolution

  • New focus measure (lrg-imaging/src/focus.rs): a port of scikit-image's blur_effect (Crété-Roffet 2007). It compares a patch's gradient energy before and after a re-blur, so contrast barely moves it; that is what comparing the frames of one burst needs. A golden test pins it to scikit-image (testdata/focus_goldens.json, made by make_focus_goldens.py).
  • Measured on the full-size original. Detection stays on the working copy, so YuNet, the stored boxes and identities are unchanged. Only photos with faces get a second decode of the original, where the face step measures again (convert::decode_oriented shares its code with normalisation, so boxes map by one scale factor; faces::refine_focus). By-path, the file is read again, a raw through its embedded preview. Uploads are kept until the face step. At most two such decodes run at once.
  • Per face: the sharper eye (a square of 0.5 × interocular distance) and the whole face. Per photo: the least sharp prominent face. Faces too small to judge (eyes under 40 px apart) abstain. cull_focus_version tells "too small" apart from "indexed before this existed".
  • Ranking: eye focus takes a share of the face term, scored against the group. eyes_out_of_focus (or face_out_of_focus when the eyes are too small) becomes a defect only when the frame is clearly behind a sharper sibling; a single frame is never rejected for focus. All or nothing per group. Weights: portrait 0.55, default and event 0.35, sports 0.20, street off.
  • Explanation: eye focus N/10 within the group, relative to the sharpest frame, because per-pixel sharpness depends on camera, ISO and magnification as much as on focus.
  • Migration: /v1/index/unprocessed re-queues cull-prepared photos with faces but no focus version. The cull pass re-runs the face step for them, writing no identities. /v1/cull/grade warns about photos still without it. A failed full-size decode shows up in the index warnings.

2. Eye state, smile and gaze (MediaPipe)

  • Models: MediaPipe Face Mesh V2 (478 landmarks) and Blendshape V2 (52 expression coefficients), both from Google's face_landmarker.task, pinned by URL and SHA-256. Both model cards state Apache-2.0. The uses they exclude, life-critical decisions and identifying people, do not include culling. server-rs/scripts/export_face_state.py (standalone, PEP 723) converts them with tf2onnx and checks every output against TFLite before anything is written: at most 4·10⁻⁴ px off on landmarks, 1.3·10⁻⁶ on blendshapes. Credited in Credits.md.
  • Inference (lrg-ml/src/face_state.rs):
    • a crop 1.5× the face, square, rotated along the eye line, scaled to 256 px with a replicated border;
    • two passes: first from the detector box, then from the first pass's landmarks;
    • blendshapes from the 146-landmark subset MediaPipe uses.
  • Per face:
    • eye state open / half / closed / unknown ("can't tell": not a face, head turned more than 60°, or eyes under 24 px);
    • from 35° of head turn, only the near eye counts;
    • also smile, gaze away and head turn.
  • Indexing:
    • analyses up to 12 prominent faces, largest first, on the full-size original from the same decode the eye focus uses, otherwise on the working copy;
    • per face, FACE_TABLE gets face_eye_state, face_eye_closed, face_smile, face_gaze_away and face_yaw;
    • per photo: cull_eye_closed (worst prominent face), cull_eye_state, cull_smile (least smiling) and cull_gaze_away, plus cull_face_state_version and cull_face_state_model.
  • Ranking:
    • the model's reading replaces the proxy, again all or nothing per group, so one group never mixes two scales;
    • eyes_closed replaces possible_blink when measured and is a defect from 0.6;
    • new labels eyes_half_closed (a label, not a reject), looking_away (portrait) and best_smile, with smile weights portrait 0.15 and event 0.10;
    • a laugh with the eyes shut (smile ≥ 0.6) is not a blink;
    • street and sports use higher thresholds, and python_parity turns all of it off.
  • Model family, migration and warnings:
    • its own family face_state in /v1/models/assets and in /v1/server/health (face_state_model, face_state_error); Download AI models fetches it (~7 MB);
    • /v1/index/unprocessed re-queues cull-prepared photos with faces but no face-state version, only once the model is on disk;
    • /v1/cull/grade warns with the fix that applies: Prepare now when photos lack the reading, Download AI models when the model is missing;
    • indexing warns once per run when face detection ran without the model;
    • the plugin's model check asks for it before face and cull runs (Util.requiredModelFamilies).

Cost

Measured in release on a 4-core container:

  • Eye focus: about 0.24 s more per photo with faces for a 24 MP JPEG original (0.21 s decode + 0.03 s measuring, examples/bench_cull_metrics.rs).
  • Face state: about 26 ms per analysed face (two mesh passes and the blendshapes), plus about 40 ms once to load the models. It reuses that decode.
  • Photos without faces cost nothing extra.

Merging publishes a model release

The push to main runs model-assets-face-state.yml, since its paths include the new export script. It publishes face-state-assets-v1: face_mesh_v2.onnx and face_blendshapes_v2.onnx, about 7 MB, Apache-2.0. Nothing was dispatched from this branch.

Please check that run after merging. Until the release exists, Download AI models fails on every installation that is missing the eye and expression model. download_release_assets resolves every missing file before it fetches any, so the other families are blocked too. Culling itself keeps working on the old proxy and says so in its warning.

PR CI does not require the facestate goldens yet, since there is no release to fetch them from. The nightly golden-tests-full.yml fetches them from that release.

Test plan

  • cargo fmt --check, cargo clippy --workspace --all-targets (no warnings), cargo test --workspace
  • Goldens with the real models (LRG_REQUIRE_GOLDENS=face,auraface,facestate, face-state models exported locally):
    • face detection and the existing metrics are unchanged;
    • landmarks on the reference portrait are about 1 % of the interocular distance off MediaPipe's;
    • blink and smile are within ±0.01;
    • synthetically closed eyelids through the blendshape model match MediaPipe to 1e-4.
  • focus_goldens against scikit-image: synthetic cases exact to 1e-9; the astronaut's eyes and face to 1e-2 across JPEG decoders.
  • Unit tests:
    • focus: eye-only blur, abstaining eyes, full-size refine mapping;
    • face state: rotation, warp and border, projection, eye-state rules incl. near eye and unknown, smile and gaze;
    • aggregation: worst judged prominent face;
    • ranking (all-or-nothing focus as well): the model finds a blink the proxy missed; a laugh is not a blink; an unjudged face is never a blink; half-closed is labelled, not rejected; a group with an unanalysed frame keeps the proxy; the broader smile wins a portrait burst;
    • the warnings name the fix that applies.
  • Contract tests:
    • /index/unprocessed re-queue rules, for focus and for face state only with the model on disk;
    • face_state is its own downloadable family;
    • /cull/grade names a real blink, and its metrics, reasons, explanation and warnings.
  • cull_eval: winner top-1 100 %, NDCG 1.000, reject recall 100 %, reject precision 85.7 %. The one false reject predates this PR, in the event fixture, where focus is not active.
  • Live against the running binary, with curl and the access token:
    • with the model: three 3072 px portraits, by-path and multipart. Full-size focus and face state were measured, the sharp frame won, the eye-blurred frame got eyes_out_of_focus and a reject, the blurred one blurred. No false blinks, and best_smile went to the right frame.
    • without the model: the right warnings, the proxy as fallback, nothing re-queued.
    • migration: with the model, photos were re-queued, prepared again and then clean.
  • Plugin: busted 614 successes / 0 failures, luacheck 0 warnings, StyLua clean.
  • Not done:
    • There is no freely licensed real photo with closed eyes here. The blink path is pinned through synthetically closed eyelids instead, exactly against MediaPipe.
    • The thresholds (closed ≥ 0.6, half ≥ 0.35, laugh ≥ 0.6, eye-focus defect) stay documented hypotheses until a hand-culled real shoot runs through cull_eval.
    • Not tested inside Lightroom: no catalog was approved for this session.

Docs

  • Help-Cull-Photos.md: new sections Focus on the eyes and Eyes open, smile and gaze; reason codes and fields.
  • Help-People-Faces.md, Help-Analyze-and-Index.md, Troubleshooting.md.
  • Getting-Started.md: the download size is now 4.2 GB. The old 3.3 GB had not counted the quality model since AestheticSigLIP score + genre at index time, and a trimmed set of catalog fields (part 2 of 4) #425.
  • Dev-Backend-API.md: health, model families, /index/unprocessed, the index face fields, /cull/grade metrics and warnings, ranking.
  • Credits.md (MediaPipe).
  • server-rs/README.md (new Face state models section) and plugin/README.md, with the regenerated wiki copies.
  • Dev-Image-Culling-Implementation-Plan.md (items 1 and 3 done) and Dev-Image-Culling-Signal-Analysis.md.
  • CLAUDE.md, AGENTS.md and CONTRIBUTING.md (golden family facestate, the lrg-ml line), and doc-sources.toml.

Reviewed and still accurate (Docs-Reviewed trailers): Help-Find-Similar.md, Help-Choosing-AI-Model.md, Help-Other-AI-Server.md.

Follow-ups (planned packages)

  1. Subject detection (D-FINE-S, license check first): body focus for sports, a wildlife preset, subjects cut off at the frame edge
  2. Workflow: key people and a coverage report, an automatic preset by genre, strictness per criterion, a target number of picks, grouping sensitivity, accidental shots
  3. A browser review page with face and eye close-ups and traffic lights

🤖 Generated with Claude Code

https://claude.ai/code/session_01HaMMvqLxjmxBq24sQiYQmn

Culling could not tell a frame focused on the eyes from one focused on the
nose. Face sharpness was a Laplacian variance over the whole face box on the
≤2048 px working copy, 35% of the composite face score and never a defect of
its own, and eye focus was not measured at all — the commercial culling tools
(Lightroom's Assisted Culling, Excire, Narrative) all treat both as separate
criteria. This was outstanding item 3 of the culling plan.

Measure. `lrg_imaging::focus` ports scikit-image's blur effect (Crété-Roffet
2007): re-blur a patch and see how much gradient energy survives. Being a
ratio of the patch's own energies it barely moves with contrast, which is
what comparing frames of a burst needs. Pinned against skimage
(testdata/focus_goldens.json, make_focus_goldens.py).

Where. Faces are still detected on the working copy, so YuNet, stored boxes
and identities do not change. The face step then decodes the original once
more, only for a photo with faces (`convert::decode_oriented`, sharing its
decode and orientation code with normalisation so boxes map by one scale), and
re-measures there (`faces::refine_focus`): the sharper eye in a square of half
the interocular distance, and the face box. By-path the file is read again; an
upload is kept until the face step. At most two such decodes at once. About
0.24 s more per photo with faces for a 24 MP JPEG in release; nothing for a
photo without.

Rank. Per photo `cull_eye_focus`/`cull_face_focus` are the least sharp
prominent face's, absent when too small to judge, with `cull_focus_version`
telling that apart from "indexed before this". Eye focus takes a share of the
face term against the group; `eyes_out_of_focus` (or `face_out_of_focus` where
the eyes are too small) is a label and, clearly behind a sharper sibling, a
clear defect. Never for a single. All or nothing per group. Portrait weights it
most, sports least, street not at all; python_parity switches it off.

The explanation's "eye focus N/10 within the group" is relative to the
sharpest frame: per-pixel sharpness depends on camera, ISO and magnification
as much as on focus (a 6x enlargement of a sharp portrait read 0.39 on its
sharp eye in the live test), while frames of one burst share all of that.

Migration: /index/unprocessed re-queues cull-prepared photos with faces and no
focus version, the cull pass re-runs the face step for them without writing
identities, and /cull/grade warns about photos still without it. The plugin's
prepare prompt no longer says such photos have no culling data.

Verified: cargo fmt/clippy/test (workspace), face goldens with the real models
(LRG_REQUIRE_GOLDENS=face), cull_eval (portrait fixture gains a nose-vs-eyes
group: winner top-1 85.7% -> 100% overall with eye focus), and live against
the running binary on by-path and multipart uploads.

Docs-Reviewed: Help-Find-Similar.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaMMvqLxjmxBq24sQiYQmn
Culling used to guess blinks from an eye-openness gradient proxy. It now
reads each prominent face with MediaPipe Face Mesh V2 (478 landmarks) and
Blendshape V2 (52 expression coefficients), the signals commercial cullers
rank on: eyes open / half closed / closed / can't tell, smile, gaze and
head turn.

- export_face_state.py converts Google's face_landmarker.task (pinned by
  SHA-256, both model cards Apache-2.0) to ONNX with tf2onnx and checks
  every output against TFLite; model-assets-face-state.yml publishes the
  pair (~7 MB) to its own face-state-assets-v1 tag.
- lrg-ml face_state.rs: rotated 1.5x ROI along the eye line, two passes
  (detector box, then landmarks), blendshapes from the 146-landmark subset,
  eye state with a near-eye rule for turned heads and "unknown" when the
  face is too small, too turned or not a face. Goldens pin it to MediaPipe
  (landmarks within ~1 % of the eye distance, closed eyelids exact).
- Indexing analyses up to 12 prominent faces on the full-size original
  (the same decode the eye focus uses), stores the readings per face and
  per photo, and marks them with cull_face_state_version so photos
  prepared before the model was downloaded are prepared again.
- Ranking: the model reading replaces the proxy, all or nothing per group;
  new reasons eyes_closed, eyes_half_closed, looking_away and best_smile;
  a laugh with closed eyes is not a blink; smile weight in the portrait
  and event presets.
- The model is its own family in /v1/models/assets and /health; the plugin
  asks for it before face and cull runs. Indexing and grading warn with the
  fix ("Download AI models" or "Prepare now") when it is missing.

Docs-Reviewed: Help-Find-Similar.md
Docs-Reviewed: Help-Choosing-AI-Model.md
Docs-Reviewed: Help-Other-AI-Server.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaMMvqLxjmxBq24sQiYQmn
@bmachek bmachek changed the title feat(cull): eye and face focus at full resolution feat(cull): eye focus, eye state, smile and gaze per face Nov 3, 2026
Rust 1.99's clippy adds chunks_exact_to_as_chunks, which the
format-lint-rust job (stable toolchain) turns into an error. Same
pattern the BioCLIP and Chroma readers already use; no change in
behaviour, the mesh output is always a whole number of points.

Docs-Reviewed: Help-Cull-Photos.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaMMvqLxjmxBq24sQiYQmn
@bmachek
bmachek merged commit 922c993 into main Nov 3, 2026
5 checks passed
@bmachek
bmachek deleted the claude/optimistic-cray-nlbo6t branch November 3, 2026 17:55
bmachek pushed a commit that referenced this pull request Nov 4, 2026
…wnload

#471 added the `face_state` family ("Eye and expression analysis",
`face-state-assets-v1`) as one more `need_*` flag in the combined
download, which this branch replaced with per-family `Family` entries.
It becomes `Family::FaceState` here, with #471's size estimate, release tag
and assets, so a family that is not published yet (the case that started
this branch) is skipped and named while the others still download.

- routes/assets.rs: `Family::FaceState` with id `face_state`, name "Eye
  and expression analysis", `FACE_STATE_APPROX_BYTES`,
  `state.face_state.is_cached()` and `face_state_assets`.
- tests/contract.rs: the /v1/models/assets id list includes `face_state`.
- Getting-Started.md: main's text (about 4.2 GB, with the eye and
  expression model).
- Troubleshooting.md: the §3 intro names the fifth model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaMMvtwbAwMWB9jcpdCc3T
bmachek added a commit that referenced this pull request Nov 4, 2026
…e, search and AI Edit (#474)

* feat(cull): read eyes and gaze the way real photographs show them

The face state thresholds were pinned on one portrait and on lids moved shut
on the mesh. Measured on photographs (examples/face_state_probe.rs, 285 faces
from Wikimedia Commons to choose by, 292 from a disjoint sample to check by,
not committed), neither reading meant what the ranking took it to mean.

Eyes. eyeBlink tops out low: eyes shut read 0.54-0.78 on the more closed eye
and 0.41-0.72 as a mean, so the mean at 0.6 caught 6 of 22 shut or lowered
pairs. The more closed eye now speaks, with thresholds where the readings
are: 19 of 22, about 17 of 24 on the second sample (was about 8), and 1 of
178 open-eyed portraits over the label.

Gaze. eyeLook* is the eye inside the head, so the label had it backwards:
people turned 25-30 degrees looking straight at the camera read 0.85-0.99, a
head turned 39 degrees with the eyes following it 0.14. It is now degrees off
the lens, head and eyes together, from a head pose read off a frame of bone
landmarks (within 1 degree of a full Procrustes fit on yaw and roll, 2 on
pitch, at no linear algebra dependency). looking_away in portraits drops from
25-31% to 10-12% and rises from 53% to 67% in People looking away. Shut eyes
abstain; their eyeLook* is noise.

Squint is read at last, only on a face that is not smiling. All 52 blendshapes
are stored per face, so a later signal need not decode every original again.

The version check read the key's presence, so a bump would have changed
nothing; its value counts now. #471 is in no release, so only development
databases are analysed again.

Also from the live test: shut eyes were reported as the best open eyes of
their group, a single always being its own best.

Limits, in the wiki: a slit overlaps eyes shut, sunglasses read closed (the
relative rules hold inside a burst), a head on its side reads open. The sports
and street thresholds are scaled by judgement, not measured. A hand-culled
shoot still has to confirm all of it.

Verified: cargo fmt/clippy/test (workspace) with face and facestate goldens on
the real models, check-docs, and live against the release binary by path.

Docs-Reviewed: Help-Analyze-and-Index.md
Docs-Reviewed: Help-Find-Similar.md
Docs-Reviewed: Credits.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaMMvsyz1bjNzJoq8hSGXy

* feat(people,search,cull,edit): ask the face state what people ask of a photo

People and search can now ask what a face shows, culling names the mouth and the
light on the face, and AI Edit reads the faces it is about to edit. All of it
measured on photographs from Wikimedia Commons first (not committed), the same
way as the eyes and gaze before it.

People and search. lrg_analysis::face_expression answers eyes open, smiling
(mouthSmile from 0.5) and looking at the camera (within 20 degrees), per person
on GET /v1/faces/persons/<id>/photos and for everyone in the frame on /v1/search,
both pages and the MCP tools. A reading the model did not make is unknown, not a
no: it is counted and the fix named. A person's card shows their best portrait,
no longer the sharpest blink, and "best N" takes each person's best photos.

A catalog indexed before the model was on disk has rows with no readings, and a
cull pass never touched them. It now brings its readings to the rows a full pass
wrote, matched by box, leaving identities alone, and stamps the photo current
only once they are saved. /index/unprocessed queues a face pass for readings that
are out of date or rows the cull pass could not match (face_rows_behind).
Checked live: indexed without the model, a person named, cull pass with it: the
person keeps the name and the filters answer.

Culling gains labels, which move no score until a hand-culled shoot says what they
are worth: mouth_open (portrait, event), face_darker, face_hard_light, face_blown.
What the measuring changed:
- mouthPucker reads up to 0.99 on full lips at rest and mouthFunnel barely moves,
  so the mouth is jawOpen and mouthLowerDown (from 0.2: 10% of people speaking,
  0.6% of portraits).
- Blown is the middle channel, not the top: warm light clips red on skin that holds
  its detail, and that flagged 16% of faces. It is now 2.6% over 0.05.
- Luminance is only ever compared with the same people in a sibling frame, since
  skin ranges over several stops.

AI Edit: face_hard_light takes clarity and most contrast, face_blown holds the white
point, face_below_surroundings holds contrast. That last reading is lowest on people
with dark skin in front of something bright, correctly exposed, so it never calls a
face underexposed or backlit: it can only hold contrast back, and says what it
measured. A failed read reaches the user; missing models skip the rules, as the
comment there explains.

Left out on the evidence: keeping turned heads out of cluster formation scores the
same at every limit from 60 to 30 degrees on 370 faces of 18 public figures, since
the occlusion gate already holds them back, and raising that gate lowers the score.
Clustering is unchanged; examples/face_cluster_eval.rs is what measured it.

Verified: cargo fmt/clippy/test (workspace) with face and facestate goldens on the
real models, check-docs, live against the release binary. busted has one failure,
aesthetic_refine_spec.lua:76, which fails the same without this change. Not yet run
inside Lightroom.

Docs-Reviewed: Credits.md
Docs-Reviewed: Dev-Agent-Bridge.md
Docs-Reviewed: Help-Find-Similar.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaMMvsyz1bjNzJoq8hSGXy

* fix(cull): a gaze going down is not a blink

The first run on a real shoot, a test catalog of 197 photos, showed what the
Commons samples did not: 36 faces read closed, nearly all a child looking down at
candles with the lids over the eyes. That made a false "eyes closed" and could make
a reject candidate of a frame a photographer may well want.

Lids down on a head lowered 10 degrees or more now read `down`. Such a face is left
out of the blink reading and the frame says "looking down", never as a defect. Eyes
going down count as neither open nor on the camera in the People and search filters,
and cost a portrait what half-closed eyes do.

Measured: 30 of 39 faces reading closed in that catalog become `down`, and all 18 of
the highest, checked by eye, really do look down. 10 of 34 faces with the eyes really
shut, mostly people asleep, get the note too, which costs a warning where a reject
would have been, the right way round. eyeLookDown does not separate them. Faces
reading closed fall from 36 to 7. The rest are mostly gazes on a barely lowered head
and lids on a raised head behind glasses, which the relative rules hold back inside
a burst. FACE_STATE_VERSION goes to 3, so those photos are analysed again.

Also says what a guardrail did: "no clarity was added", not "left alone", for a
value it had set to 0.

Verified: cargo fmt/clippy/test (workspace) with face and facestate goldens on models
downloaded from the published release, check-docs, and in Lightroom Classic 15.6 on
the test catalog: 123 photos with faces queued again by the version, names kept.

Docs-Reviewed: Help-Analyze-and-Index.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaMMvsyz1bjNzJoq8hSGXy

* fix(cull): sharp eyes overrule a soft frame; a lowered mouth abstains

Found in Lightroom, on the test catalog. Ten frames of a child behind birthday candles
all read `blurred`, on a frame-wide sharpness of 0.03-0.05, while the eyes read 0.54-0.60
and are visibly sharp. With every frame a defect the group had no pick and all ten were
reject candidates, so deleting the candidates would have lost the whole series, next to
explanations reading "noticeably blurred" and "eye focus 10/10".

A frame whose eyes reach reason_eye_focus_threshold is no longer a blur defect, on the
top frame's own check as well. In portrait every group now has its pick, and candidates
fall from 74 to 69, the one really smeared frame still among them. Older than this PR,
fixed here because these signals are what make it possible. It is one catalog, and the
reading depends on magnification, so a slightly soft frame shot close can pass.

Still false: frames with no measurable eyes, a sharp cat or a boy's shirt at 0.09, which
default and event still reject as a whole group. The eyes cannot help there; the frame-wide
threshold needs a hand-culled shoot.

Also: a lowered head foreshortens the lips, so a closed mouth read open and got
mouth_open. Such a face now abstains.

Verified: cargo fmt/clippy/test (workspace) with goldens, cull_eval unchanged (winner top-1
7 of 7), check-docs, and re-graded live on the test catalog.

Docs-Reviewed: Help-Analyze-and-Index.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HaMMvsyz1bjNzJoq8hSGXy

---------

Co-authored-by: Claude Opus 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

Development

Successfully merging this pull request may close these issues.

2 participants