Measure spontaneous facial expression for the facial palsy pack - #24
Merged
Conversation
Implements the design in docs/superpowers/specs/2026-07-24-facial-palsy-protocol-pack-design.md. Adds six face metrics (16 -> 22) and an expression-dynamics report section: rest_mouth_corner_asymmetry.signed signed resting geometry rest_eye_aperture_asymmetry.signed spontaneous_event_rate events/minute spontaneous_excursion.p90 expression magnitude spontaneous_excursion_asymmetry.median HEADLINE oculo_oral_synkinesis_index HEADLINE The two headline measures are within-face, within-event contrasts. Expression amplitude depends on how warm the conversation was, but that confound acts on both hemifaces equally and cancels in a left/right ratio — the same property that makes the resting metrics robust across visits. No new data crosses any boundary. mouthCorners, eyeAperture and pose are derived scalars already on FacialKinematicsFrameV1, so the whole measurement lives in ambient-core as pure functions. No landmark reaches this code, the worker is untouched, and detection is geometric — no affective state is inferred, labelled, or emitted anywhere. Two decisions worth stating: Detection keys on the MAXIMUM of the two sides, never a bilateral AND. In unilateral palsy the affected side barely moves, so any both-sides rule would find fewer events the worse the palsy — the instrument would go blind exactly where it is needed. There is a test asserting event count is independent of which side is affected, and of whether one side moves at all. Coupling abstains rather than dividing by a side that did not move. Synkinesis is undefined on a flaccid side, and inventing a number there is the imputation this system refuses everywhere else. A rate of zero, by contrast, IS measured: the session was observed and contained no expressions. Contract surgery: MetricCodeSchema, metrics .length(16 -> 22), report sections 8 -> 9 tuple and enum, PostEncounterReportV1 sections .length, pack version 1.0.0 -> 2.0.0 and its SHA-256 recomputed. The digest is what makes sessions measured under different packs structurally non-comparable, so it is pinned and verified rather than derived at load. The threshold parity test added with the timing-coverage fix caught the three new evidence-requirement keys immediately, exactly as designed. They are now either resolved from an emitted quality fact (minimumExpressionEvents, minimumCoupledExpressionEvents) or declared an algorithm parameter (minimumCouplingElevation), with a regression test asserting the adapter emits the facts — the failure mode that let minimumTimingCoverage read pitch coverage for months. Detection thresholds are engineering defaults chosen from the geometry, not from data. No calibration set exists. They are named constants and must be revisited against real recordings before anything derived from them is interpreted. Every new metric carries clinicalValidation: "none". Verification: structure gate + 222 unit tests (+16) + typecheck x6 + build; 6/6 Playwright; 4/4 pytest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
logannye
added a commit
that referenced
this pull request
Jul 24, 2026
…ack (#25) Completes the two measurement items left unbuilt by #24. Adds five face metrics (22 -> 27) and a brow-geometry report section. brow_height.left / .right frontalis, per side brow_height_asymmetry.signed lid_closure_completeness.left / .right unilateral lagophthalmos Brow is the safety-relevant one. Forehead sparing is what separates upper- from lower-motor-neuron facial weakness, and until now it was not measurable at all — docs/safety.md cites that absence as one of two reasons acute stroke screening is refused. It is graded as its own zone rather than folded into eye geometry, matching how Sunnybrook and eFACE score the face. This needs the visual pipeline, so it is the first change to cross into the worker's feature extraction: three landmarks per brow arc (mirrored index pairs 70/300, 63/293, 105/334, matching the eye and mouth convention already in FACE_LANDMARK_INDICES), averaged for stability, measured against the inter-eye axis. Both eye centres sit at y = 0 by construction, so negating the normalized y gives a height that is already scale-invariant and roll-corrected — there is a test asserting the value is unchanged across frame sizes. Per-eye lid closure needs no pipeline change; eyeAperture already carries both sides. Completeness is referenced to that eye's OWN open state, so it is a within-eye ratio independent of face scale. Two estimator decisions the fixtures forced, both worth keeping: The closed reference is a per-bin MINIMUM, not a low percentile of frames. At normal blink rates only a few percent of a bin's frames are mid-blink, so even P05 sits above the closed state and would report an eye that never closes. Across bins it is P25 of those minima, not the median. Closure is intermittent: a bin containing no blink reports its open value as its minimum, and a median over a mix of blink and no-blink bins lands between the two — reporting an eye that half-closes when it in fact closes fully. That is exactly what the 10s blink fixture produced against 5s bins, and the test caught it. The guided-metric guard rejected "eye_closure_completeness" by matching /eye.closure/, which is the vocabulary of the retired "close your eyes" exercise. Renamed to lid closure rather than weakening the guard — and "lid closure" is the correct term anyway, since lagophthalmos is specifically incomplete lid closure. Pack version 2.0.0 -> 3.0.0 with its digest recomputed; report sections 9 -> 10. Verification: structure gate + 233 unit tests (+11) + typecheck x6 + build; 6/6 Playwright; 4/4 pytest. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements
docs/superpowers/specs/2026-07-24-facial-palsy-protocol-pack-design.md(PR #23).Adds six face metrics (16 → 22) and an
expression-dynamicsreport section:rest_mouth_corner_asymmetry.signedrest_eye_aperture_asymmetry.signedspontaneous_event_ratespontaneous_excursion.p90spontaneous_excursion_asymmetry.medianoculo_oral_synkinesis_indexWhy this shape
Both headline measures are within-face, within-event contrasts. Expression amplitude depends on how warm the conversation was — but that confound acts on both hemifaces equally and cancels in a left/right ratio, the same property that makes the resting metrics robust across visits.
No new data crosses any boundary
mouthCorners,eyeAperture, andposeare derived scalars already onFacialKinematicsFrameV1, so the entire measurement lives inambient-coreas pure functions. No landmark reaches this code, the worker is untouched, and detection is geometric — no affective state is inferred, labelled, or emitted anywhere. Pose is already a strict gate (yaw ≤7°, pitch ≤10°, roll ≤5°), so the expression measures inherit it.Two decisions worth reviewing
Detection keys on the MAXIMUM of the two sides, never a bilateral AND. In unilateral palsy the affected side barely moves, so any both-sides rule would find fewer events the worse the palsy — the instrument would go blind exactly where it's needed. There's a test asserting event count is independent of which side is affected, and of whether one side moves at all.
Coupling abstains rather than dividing by a side that didn't move. Synkinesis is undefined on a flaccid side, and inventing a number there is the imputation this system refuses everywhere else. A rate of zero, by contrast, is measured — the session was observed and contained no expressions.
Contract surgery
MetricCodeSchema, metrics.length(16 → 22), report sections 8 → 9 (tuple + enum + labels),PostEncounterReportV1.sections.length, pack version 1.0.0 → 2.0.0 with its SHA-256 recomputed and verified. The digest is what makes sessions measured under different packs structurally non-comparable, so it stays pinned rather than derived at load.The parity test earned its keep
The threshold parity guard added with the timing-coverage fix (#21) caught all three new evidence-requirement keys immediately, exactly as designed. They're now either resolved from an emitted quality fact (
minimumExpressionEvents,minimumCoupledExpressionEvents) or declared an algorithm parameter (minimumCouplingElevation) — with a regression test asserting the adapter actually emits the facts. That's the failure mode that letminimumTimingCoverageread pitch coverage.Honest limitation
Detection thresholds are engineering defaults chosen from the geometry, not from data. No calibration set exists. They're named constants (
EXPRESSION_ONSET_ELEVATION,SYNKINESIS_MIN_ELEVATION, …) and must be revisited against real recordings before anything derived from them is interpreted. Every new metric carriesclinicalValidation: "none".Still unbuilt from the spec: brow/frontalis landmarks (needs a worker change and a frame-contract field) and per-eye closure completeness.
Verification
structure gate + 222 unit tests (+16) + typecheck ×6 + build · 6/6 Playwright · 4/4 pytest
🤖 Generated with Claude Code