Quantitative biomarker substrate: Layers 0-2 - #26
Merged
Conversation
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>
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.
Builds the three-tier biomarker substrate from
docs/superpowers/plans/2026-07-24-biomarker-substrate.md. Scope was agreed asLayers 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
d10bc80d5733648a7f2cd4ab5855f762967c872c2ff976a7bThe provenance fix is the load-bearing one. Eleven of the pack's declared
requirements resolved to
undefinedand were skipped in silence, so the reportclaimed 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.eventCountwas 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
c50752ec7cad209c43694outputFaceBlendshapesis nowfalse— a measurement decision. The rig istrained 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
processorRefmove to 1.1 because decimation isnow 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
f24935cad9f8aa4605f73Blink 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
riseMsunderstates the real rise, because onset is the threshold crossingrather than the start of movement. A relative measure, not an absolute one.
decayTauMsruns high — the resting baseline is estimated from frames thatinclude the expression. Only the ordering is asserted.
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 —
cadenceHznow makes the question answerable from real sessions),formants (need vowel identification, which crosses into transcription), and the
export artifact.
Verification
269 unit tests,
tsc --noEmitclean 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 waswrong and was reverted. MediaPipe iris points trace the limbus, not the pupil,
so there is no autonomic channel there.
🤖 Generated with Claude Code