Skip to content

Measure spontaneous facial expression for the facial palsy pack - #24

Merged
logannye merged 1 commit into
mainfrom
feat/facial-palsy-expression-events
Jul 24, 2026
Merged

Measure spontaneous facial expression for the facial palsy pack#24
logannye merged 1 commit into
mainfrom
feat/facial-palsy-expression-events

Conversation

@logannye

Copy link
Copy Markdown
Owner

Implements docs/superpowers/specs/2026-07-24-facial-palsy-protocol-pack-design.md (PR #23).

Adds six face metrics (16 → 22) and an expression-dynamics report section:

Metric Role
rest_mouth_corner_asymmetry.signed signed resting geometry — establishes the affected side
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

Why 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, and pose are derived scalars already on FacialKinematicsFrameV1, so the entire 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. 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 let minimumTimingCoverage read 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 carries clinicalValidation: "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

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
logannye merged commit 2f439d1 into main Jul 24, 2026
6 checks passed
@logannye
logannye deleted the feat/facial-palsy-expression-events branch July 24, 2026 21:31
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>
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.

1 participant