Skip to content

Quantitative biomarker substrate: Layers 0-2 - #26

Merged
logannye merged 14 commits into
mainfrom
feat/biomarker-substrate
Jul 24, 2026
Merged

Quantitative biomarker substrate: Layers 0-2#26
logannye merged 14 commits into
mainfrom
feat/biomarker-substrate

Conversation

@logannye

Copy link
Copy Markdown
Owner

Builds the three-tier biomarker substrate from
docs/superpowers/plans/2026-07-24-biomarker-substrate.md. Scope was agreed as
Layers 0–2: no new Tier-3 metric codes, so the protocol pack, its digest,
and every pinned count are untouched.

Design principle

Store physical quantities, not clinical constructs. A construct is
terminal; a quantity recombines. Every clinical scale is a function of
quantities, so an archive of quantities can produce any scale on demand —
including scales published after the data was collected.

The operational test applied throughout: if we learn in two years that the
right hypomimia measure is AU12 onset velocity rather than event rate, can we
recompute it on sessions already captured?

Layer 0 — foundations and fixes

Commit
d10bc80 Remove legacy v2 trajectory, observation, and event contracts
d573364 Verify every published evidence gate instead of skipping eleven
8a7f2cd Stop expression events stitching across rejected bins
4ab5855 Make the pack digest regenerable and enforce it in the gate
f762967 DSP primitives: FFT, windowing, anti-aliased decimation
c872c2f Derive palpebral fissure, mouth midline offset, iris geometry
f976a7b Declare Tier-2 kinematic event record types

The provenance fix is the load-bearing one. Eleven of the pack's declared
requirements resolved to undefined and were skipped in silence, so the report
claimed provenance over gates nothing checked. A measured outcome that cannot
produce the evidence its own pack entry demands has not satisfied that gate —
it has failed to report on it. That is now an error.

Closing it required the facts to exist, so each extractor emits the worst
value across its accepted units: a minimum where the pack states a minimum*
threshold, a maximum where it states a maximum* one.

Separately, evidence.eventCount was a maximum over the pause, run, nucleus,
and blink counters — a voice pause gate could be cleared by a syllable count.
Each metric now counts only its own events.

Layer 1 — frame schemas

Commit
c50752e Wire spectral DSP into the voice lane; add cepstral peak prominence
c7cad20 Record derived face geometry; stop computing blendshapes
9c43694 Record the tremor feasibility outcome as not-shipped

outputFaceBlendshapes is now false — a measurement decision. The rig is
trained for avatar retargeting and carries a symmetry prior that suppresses
exactly the left-right difference this system exists to measure. Action Units
will be derived geometrically.

Voice algorithm version and processorRef move to 1.1 because decimation is
now anti-aliased and pitch values shift slightly. The frame schema version
stays v1: the new fields are optional additions, so the shape is compatible.
The values changed, not the shape.

Layer 2 — event extraction

Commit
f24935c Detect blinks per eye and keep the waveform
ad9f8aa Keep the trajectory shape of a spontaneous expression
4605f73 Record ordered speech runs and classified pauses

Blink detection required both eyes below threshold on the same frame, so a
unilateral closure produced nothing at all — the case a facial palsy actually
presents. The published bilateral rate is provably unchanged: 0 for a
unilateral fixture, 6 for the conjugate one, matching the previous detector
exactly.

Limits stated rather than tuned away

  • riseMs understates the real rise, because onset is the threshold crossing
    rather than the start of movement. A relative measure, not an absolute one.
  • decayTauMs runs high — the resting baseline is estimated from frames that
    include the expression. Only the ordering is asserted.
  • The breath/hesitation split is an acoustic proxy, unvalidated against any
    respiratory reference. A throat clear or a second speaker would satisfy it.
    The caveat sits on the constant itself.

Deliberately not included

Nasolabial fold (alar-base indices cannot be read off a library constant and
need real-face validation), tremor bands (unmeasurable at the 16 Hz accepted
floor — cadenceHz now makes the question answerable from real sessions),
formants (need vowel identification, which crosses into transcription), and the
export artifact.

Verification

269 unit tests, tsc --noEmit clean across all packages, structure check,
protocol-digest check, production build, 6 browser e2e. Every commit was gated
before landing.

Corrections made during the work: the consent SHA does hash a real source
(AMBIENT_LOCAL_CONSENT_TEXT) — an earlier claim that it hashed nothing was
wrong and was reverted. MediaPipe iris points trace the limbus, not the pupil,
so there is no autonomic channel there.

🤖 Generated with Claude Code

logannye and others added 14 commits July 24, 2026 15:39
packages/trajectory-core compared one session scalar against an unordered bag
of prior sessions -- occurredAt was an admissibility filter, never an axis. It
was built on superseded v2 contracts, emitted workflow-event v0.2 envelopes the
live journal cannot accept, and had zero importers.

observation.ts and measurement.ts turned out to be mixed rather than legacy:
they carry the live capture-provenance and audio-quality types the workers and
primitives.ts depend on. Those are kept; only the v2 tail is removed
(EncounterObservation, BiomarkerAggregate, EncounterQualitySummary,
FoundationProtocolId, CaptureAdapter, VoiceModelProvenance, and the confound,
window, and abstention interfaces). Each removed symbol was verified to have
zero live uses first.

ambient-core/src/events.ts goes with it -- trajectory-core was its only
consumer.

The AudioPipelineProvenance window/hop/ring literals survive and now document
why: they pin the pitch and VAD path, so a future formant or cepstral path
declares its own provenance rather than widening them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eleven of the pack's declared evidence requirements resolved to undefined and
were skipped in silence, so the report claimed provenance over gates nothing
checked. A measured outcome that cannot produce the evidence its own pack entry
demands has not satisfied that gate -- it has failed to report on it, which is
the one result this system must never read as a pass. That path is now an error.

Closing it required the facts to exist, so each extractor now emits the WORST
value across its accepted units: a minimum where the pack states a minimum
threshold, a maximum where it states a maximum. Every accepted unit already
cleared the gate, so if the worst one satisfies the threshold, all of them did.
This extends the rule the timingCoverage fix already established.

  voice  estimatorQuality, estimatorAgreement, segmentSpanMs,
         activeSpeechPerSegmentMs, validBinsPerSegment
  face   cadenceHz, p95FrameGapMs, maximumFrameGapMs, dataPerBinMs,
         samplesPerBin, binSpanMs

cadenceHz and p95FrameGapMs were already computed for the blink withhold
decision and simply never written down.

Separately, evidence.eventCount was a maximum over the pause, run, nucleus, and
blink counters. A voice pause gate could be cleared by a syllable count, and
once face events joined that maximum a blink count could clear a voice gate.
Each metric now counts only its own events, and minimumEventsForMedian and
minimumNuclei resolve from the specific counter rather than the conflated one.

Tests: a dual-lane session drives 24 of 27 metrics to measured and asserts
provenance still passes -- the gates are only checked on measured outcomes, so
nothing short of the success path proves the facts are emitted. Adds the
pack -> evidence-core exhaustiveness guard that mirrors the one already on the
ambient-core side, in both directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
detectExpressionEvents receives the concatenated frames of the accepted bins
and sorted them by tMs with no gap check, so a bin the screener rejected left
a hole the state machine walked straight through. An event opening before the
hole closed after it, fabricating one movement out of two unrelated moments.

The duration filter only caught holes wider than EXPRESSION_MAX_DURATION_MS. A
single rejected 5 s bin -- the ordinary case, one head turn -- sits inside the
accepted 300 ms to 10 s window and passed.

A hole now discards whatever was open rather than closing it: the movement's
real extent was never observed, so its duration and peak are unknown and
reporting either would be an assertion the session does not support. This is
how detectBlinks already guards its own state machine.

The regression test fails without the fix, fabricating an event at 6000 ms
across the hole, and a contiguous control of identical geometry still detects
its one real event.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e gate

contentSha256 was hand-pasted hex. The only enforcement was one vitest
assertion, and the runtime verify at report.ts is self-referential because the
sole live caller passes the pack it just loaded -- so a wrong digest reaches the
browser and stamps every observation with nothing objecting.

The script recomputes it from the parsed, frozen pack rather than the raw
literal, so a pack that fails Zod fails here too. It reuses the existing
recursive key-sort canonicalizer byte for byte; swapping in a standard
canonical form would silently change the digest. `pnpm check` now runs --check,
so drift fails the gate.

Verified by mutating a threshold: check reports stale, --write repairs it,
check passes, and the original digest is unchanged after restore.

The consent digest is deliberately left out of the tool. It hashes
AMBIENT_LOCAL_CONSENT_TEXT -- the exact string rendered into the consent
checkbox at main.ts:125 -- and contracts-v3.test.ts already asserts they match.
Teaching the script to re-derive it would make editing the terms a participant
agreed to self-certifying, when what that check needs is to break loudly and
force a deliberate version bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cepstral peak prominence, formant estimates, and intensity dynamics all need
things the pipeline could not produce: a windowed spectrum, a transform that is
not quadratic in window length, and a decimation path that removes the band it
is about to fold.

  hannWindow / hannWindowSymmetric  periodic for analysis, symmetric for
                                    filter design -- no window function existed
                                    anywhere before this
  preEmphasis                       first-order, for the formant path only
  fftInPlace / powerSpectrum        radix-2 with cached twiddles
  designLowPass                     linear-phase windowed-sinc
  decimate                          anti-aliased, group-delay compensated

The FFT is cheaper than what it will replace, not an added cost: the existing
16-band direct evaluation calls sin and cos inside its inner loop, about a
million times per second of audio at the current window and hop.

Decimation is the substantive change. The old path averaged each output sample
over a block of inputs; a boxcar is a low-pass filter but a poor one, so
content above the new Nyquist folded back into the retained band instead of
being removed. Tolerable for a 50-700 Hz pitch estimate, not for anything
spectral. A test drives a 6 kHz tone through both: it aliases to 2 kHz, and the
boxcar leaves more than ten times as much of it behind.

Nothing imports these yet. Wiring them into analyzeVoiceWindow changes its
numeric output and so belongs with the frame schema and processorRef bumps in
Layer 1, not here.

Writing the tests first caught a real defect: designLowPass tapered with the
periodic window, which makes the kernel asymmetric and costs the filter the
linear phase that group-delay compensation depends on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three derivations on landmarks the pipeline already receives but never read.
None is wired into the frame yet; that is Layer 1.

Palpebral fissure width and height, in shared inter-eye units. The existing
eyeAperture divides each eye's lid gap by that same eye's canthal width, so a
fissure that is uniformly smaller on one side -- the shape ptosis and
orbicularis weakness produce -- still yields a normal-looking ratio. A test
shrinks one eye 20% about its own centre: the fissure measures catch it and the
aperture ratio does not move at all.

Mouth midline offset. The coordinate origin is already the midpoint of the eye
centres, so the x of the mouth-corner midpoint is the deviation, signed toward
the subject's left. Distinct from corner asymmetry: a mouth pulled bodily
off-centre moves this without changing the height difference between corners,
and a dropped corner moves that without changing this.

Iris geometry. Indices were read from the library's own FACE_LANDMARKS_*_IRIS
connection sets rather than assumed -- ring 474-477 (centre 473) for subject
left, 469-472 (centre 468) for subject right, with the base tesselation ending
at 467. Gaze offset is the iris centre against the canthal midpoint, so it
describes where the eye points independently of the head.

Correcting an earlier claim: these points trace the limbus, not the pupil.
Pupil diameter is not recoverable from this model and there is no autonomic
channel here. Iris diameter is near-constant within a person, making it a scale
reference rather than a signal.

A 468-landmark result abstains rather than returning zeros, since a zeroed gaze
would read as "looking straight ahead" -- a measurement the session never made.

NOT included: nasolabial fold angle, which the plan lists. It needs alar-base
landmark indices, and unlike the iris set those cannot be read off a library
constant. Choosing them without checking against a real face would be a guess
dressed as geometry, so it waits for a validation pass that can.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Types only. Layer 2 fills them; nothing constructs one yet.

The session aggregates this pipeline publishes -- a blink rate, a pause rate, a
median run duration -- are projections of events that were fully observed and
then discarded. A blink is not a count: it is a closing edge, a closed
interval, and a reopening edge, and which of the three is abnormal is what
separates one condition from another. Reduced rate is hypomimia, a shallow
depth is orbicularis weakness, a delayed reopening is fatigable. One waveform,
three findings, none of them recoverable from a count.

Blinks are recorded per eye. The existing detector requires both eyes below
threshold at once, so a unilateral closure is not counted at all -- exactly the
case a facial palsy produces.

Pauses carry a kind. Splitting breath from hesitation turns one detector into
two clinical channels, respiratory and cognitive, from the same event stream.
The third kind, truncated, exists because the current path silently filters
window-bounded pauses out, which makes an unobserved pause indistinguishable
from a pause that did not happen.

These stay provider-side and are deliberately not in @phenometric/contracts:
nothing here crosses a boundary, so nothing needs a Zod schema, an identity, or
a place in the pack.

The test checks field names in the source, because type declarations leave no
runtime value to inspect and the failure mode is a name. Three privacy
assertions elsewhere reject serialised structures carrying `eyeAperture`,
`landmarks`, and similar; catching a bad name at the declaration is cheaper
than catching it at the boundary and renaming through every consumer. Lid
fields are `lidAperture*` for that reason, and it is the more precise term for
what is measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Layer 1, voice half. The frame now carries two measures the pipeline either
computed and discarded or could not compute at all.

spectralFlux already drove voice-activity and syllable detection but was never
written down, so nothing downstream could reuse the decision it had already
made.

cepstralPeakProminenceDb is new. It is the best-validated acoustic correlate of
dysphonia and needs no cycle marking, unlike jitter and shimmer, which require
a reliable cycle boundary and fall apart on exactly the disordered voices they
are meant to describe. Computed on the full-rate signal: the upper harmonics
that set the cepstral background sit above the 4 kHz the pitch path keeps.

Two internals changed underneath:

  decimation   anti-aliased filter instead of a boxcar average, so content
               above the new Nyquist is removed rather than folded back
  bandEnergies one windowed FFT instead of a rectangular-window direct
               evaluation at 16 frequencies

Pitch estimates shift slightly as a result, so the algorithm version and
processorRef move to 1.1 and sessions are not interchangeable across them. The
frame schema version stays v1: the new fields are optional additions, so the
shape is still compatible. The values changed, not the shape, and the two
versions are separate to be able to say that.

Null on cepstral prominence means no harmonic structure was measurable --
silence, or too short a window. Zero means the window was analysed and found to
have none. Collapsing those would assert a measurement that was never made.

Writing the tests surfaced a real error in the quefrency mapping. The cepstrum
is taken over the spectrum array, so an index is a period in frequency BINS,
and converting it needs both transform sizes. Reading it as a lag in signal
samples -- the intuitive reading -- misplaces the peak by the ratio between
them, and the result still looks like a plausible frequency. A test that pins
the peak to the fundamental that produced it now holds that down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…endshapes

Layer 1, face half. The derivations added in the previous commit now reach the
frame: palpebral fissure width and height, mouth midline offset, and iris gaze
offset and limbus diameter. Geometry version moves to v2. Existing values are
unchanged, so this is additive.

outputFaceBlendshapes is now false, and that is a measurement decision rather
than a performance one. The 52 coefficients are the obvious shortcut to Action
Unit intensities and the wrong instrument twice over: the rig is trained for
avatar retargeting, so it carries a symmetry prior that suppresses exactly the
left-right difference this system exists to measure, and a 52-dimensional
expression vector is identity-adjacent in a way per-side geometry is not --
which is why the frame privacy assertion names `blendshapes` outright. They
were computed every frame and discarded at the worker boundary, so nothing is
lost by not asking for them. Action Units will be derived geometrically.

Each field resolves independently, so a model without the iris head still
produces fissure and midline values instead of dropping the whole group.

Added a degeneracy guard while testing: a ring collapsed onto its centre is
what an absent iris head looks like when the landmark array is padded rather
than truncated, and it would otherwise have yielded a zero-diameter iris with a
confident gaze of exactly zero -- indistinguishable from someone looking
straight ahead. It now abstains.

The face-mesh e2e still passes, confirming the overlay draws from landmarks and
never depended on the blendshape output.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolved by not shipping it, and by making the question answerable instead.

AMBIENT_FACE_MIN_SAMPLES_PER_BIN accepts 16 Hz, which is an 8 Hz Nyquist, so
the planned 8-12 Hz band is unmeasurable at the floor and 5-8 Hz sits on the
edge. Accepted inter-frame gaps reach 200 ms, so the sampling is non-uniform
and a naive periodogram is invalid on top of that.

Raising the capture request to 60 fps doubles decode and inference load, and
whether that raises or lowers the ANALYZED cadence -- the quantity tremor
depends on -- is a property of the target device. Changing it without measuring
would be the unvalidated assertion this whole section exists to warn about, so
the request stays at ideal 30.

cadenceHz and p95FrameGapMs now ride on every face metric's evidence, so the
real delivered distribution becomes visible from ordinary sessions. That is the
measurement this decision was waiting on, and it can be made on data once
enough sessions exist.

No tremor band is emitted meanwhile. An aliased value in the 8-12 Hz band would
be indistinguishable from a real one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Layer 2a. The detector required BOTH eyes below threshold on the same frame, so
a unilateral closure produced nothing at all -- the case a facial palsy
actually presents, and the one the instrument most needs to see. Running the
same state machine per eye removes that blind spot as a consequence of the
shape rather than as a special case.

Each closure now yields onset, peak, offset, open reference, minimum aperture,
depth, closing and opening velocity, and closed dwell. Those parameters
dissociate clinically: reduced rate is hypomimia, shallow depth is orbicularis
weakness and the form lagophthalmos takes, slow reopening is fatigable. One
waveform, several findings, none recoverable from a count. `elapsed` was
already computed and thrown away.

Onset is the last frame above the recovery threshold rather than the first
frame below the closure threshold, so the closing phase covers the actual
movement instead of only its tail. When the window opens mid-blink there is no
such frame, and it falls back to the closure frame -- conservative phases
rather than absent ones.

The published bilateral rate is unchanged. It now counts per-eye events whose
closures overlap in time, which is what "both eyes closed together" already
meant, so a unilateral closure becomes an event without inflating it. Tests pin
both directions: 0 for a unilateral fixture, 6 for the conjugate one, matching
the previous detector exactly.

Events are returned alongside the outcomes rather than inside them. An outcome
carries one value and one dispersion by construction, so a series cannot travel
in it. An absent array means the detector did not run; an empty one means it
ran and found nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Layer 2b. The detector kept only the peak frame, so rise, dwell, and decay --
the temporal signature separating a flaccid movement from a synkinetic one --
were observed and discarded. Two movements reaching the same height by
different paths were indistinguishable.

Each event now carries riseMs, dwellMs, and decayTauMs, and ExpressionEvent is
the Tier-2 record type rather than a parallel shape. The eye-delta fields are
renamed to lidAperture* to match, which is also the more precise term.

Two honest limits, both pinned by tests rather than tuned away:

riseMs is onset-to-peak where onset is the THRESHOLD CROSSING, not the true
start of movement, so it understates the real rise -- a 400 ms ramp crossing at
a third of its height leaves about 267 ms visible. That is inherent to
detecting movement by threshold, and it makes rise a relative measure between
events rather than an absolute duration.

decayTauMs runs high. The resting baseline is estimated from frames that
include the expression, so the decay asymptotes slightly above true rest and
biases the log fit. The test asserts only that a sustained relaxation comes out
slower than a brisk one, which is the clinical distinction; the absolute
constant is not calibrated and is not claimed to be.

Null decay when the tail does not support a fit -- fewer than three usable
points, or a slope that is flat or rising, which is what a movement cut off by
the end of the window looks like. Fitting a constant to a truncated decay would
produce a number with no relaxation behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Layer 2c, completing Tier-2 extraction. internalRuns collapsed straight to two
unordered duration arrays, which discarded the alternation -- the rhythm of the
speech -- and dropped every pause outside [200, 1999] ms plus the leading and
trailing quiet without recording that any of it happened. An unobserved pause
was indistinguishable from a pause that did not occur.

Runs now carry phonated time separately from duration, because a run can be
long while little of it is voiced and the ratio is a different quantity from
either. Also nucleus count and mean and peak intensity, from the per-frame rms
that was already on the frame and read by nothing.

Pauses carry a kind. Splitting breath from hesitation turns one detector into
two clinical channels out of the same event stream: respiratory load and
cognitive retrieval. Window-bounded quiet is retained as `truncated` rather
than dropped, so an unknown extent is stated instead of hidden.

The breath test is an ACOUSTIC PROXY and the comment says so at the constant. An
audible inspiration raises energy without periodicity, so a silence containing
one is not flat. It has not been validated against any respiratory reference,
and a throat clear, a chair creak, or a second speaker would satisfy it
equally. It is a starting point for a measure, not a measure.

Every published metric is unchanged: the filtered durations are computed
exactly as before, alongside the unfiltered records, and a test pins pause_rate
to prove the rewrite moved no existing number.

Fixed while testing: summing identical intensities accumulated enough
floating-point error to put the run mean a few ulps above its own maximum. Now
clamped -- a mean above its maximum is not a number worth emitting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dual-lane e2e failed on CI while passing locally, and the same commit
passed one run and failed the other -- flaky, not a regression. The e2e fixture
replaces the voice worker outright and emits frames directly, so none of the
DSP work on this branch executes in that test at all.

The fixture emitted ONE unvoiced frame at 400 ms and nothing further until 800.
The live dashboard commits a state only after stateDwellMs of consistent input,
so the gate was driven by a single sample across a 400 ms gap: it commits on an
unloaded machine and races on a loaded runner. CI observed the state go
straight from Quiet/background to Voiced speech, never showing Speech/noise.

Now emitted as a run of frames every 50 ms across the same window. The phase
boundaries are unchanged deliberately -- later assertions depend on the voiced
burst still arriving at 1000 ms -- and the three-state assertion is kept rather
than weakened, because it is the one that proves the dashboard reflects the
state machine.

Also aligns the fixture's processorRef with the 1.1 bump so it does not claim a
version the pipeline no longer emits.

Verified by running the browser suite three times: 6 passed each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@logannye
logannye merged commit 4cb3184 into main Jul 24, 2026
6 checks passed
@logannye
logannye deleted the feat/biomarker-substrate branch July 24, 2026 23:38
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