feat(profiler/behave_shell): emit motor.motor_stability

BEHAVE-EXTRACTOR.md Phase B Step B.2. First principled
implementation — the prototype doesn't ship this primitive at all.

* _features/motor.py:motor_stability(ctx) emits one Observation
  in {steady, variable, tremor}. Reuses ctx.typing_bursts from B.1.
* Tremor proxy: fraction of within-burst IATs below
  TREMOR_FAST_FLOOR_S (30 ms — humans can't sustain sub-50 ms IATs).
  ≥ TREMOR_RATE_MIN (10%) sub-floor → tremor (double-press / motor
  twitch / stuck-key).
* Otherwise median burst CV decides: < CV_STEADY_MAX → steady,
  else → variable. Confidence 0.70 / 0.60 / 0.65.
* No typing bursts or fewer than 5 within-burst IATs → skip emit.
* Calibration grid widened to include motor.motor_stability; green
  across all five shards.

Tests cover all three buckets + skip paths.
This commit is contained in:
2026-05-03 21:25:54 -04:00
parent d90c8b70ce
commit 0737fcfe93
5 changed files with 118 additions and 0 deletions

View File

@@ -41,6 +41,7 @@ PHASE_AB_PRIMITIVES: frozenset[str] = frozenset({
"cognitive.inter_command_consistency",
# Phase B — motor.* completion (lands one primitive per commit)
"motor.keystroke_cadence",
"motor.motor_stability",
})

View File

@@ -0,0 +1,67 @@
"""Step B.2: ``motor.motor_stability``."""
from __future__ import annotations
from decnet.profiler.behave_shell import extract_session
from decnet.profiler.behave_shell._parse import AsciinemaEvent
def _of(observations: list, primitive: str):
obs = [o for o in observations if o.primitive == primitive]
assert len(obs) == 1, f"expected exactly one {primitive}, got {len(obs)}"
return obs[0]
def _typed_events(iats: list[float]) -> list[AsciinemaEvent]:
events: list[AsciinemaEvent] = [(0.0, "i", "a")]
t = 0.0
for x in iats:
t += x
events.append((t, "i", "b"))
return events
def test_no_typing_bursts_no_emission() -> None:
# All gaps above IKI_THINK_MAX_S → no bursts at all
events: list[AsciinemaEvent] = [(i * 5.0, "i", "x") for i in range(5)]
out = list(extract_session(events, sid="ms-no-bursts"))
assert [o for o in out if o.primitive == "motor.motor_stability"] == []
def test_uniform_iats_emit_steady() -> None:
iats = [0.15] * 12
out = list(extract_session(_typed_events(iats), sid="ms-steady"))
obs = _of(out, "motor.motor_stability")
assert obs.value == "steady"
assert obs.confidence == 0.70
def test_high_outlier_rate_emits_tremor() -> None:
# 50% of IATs below TREMOR_FAST_FLOOR_S (30 ms) — well above 10% rate
iats = [0.005, 0.150, 0.005, 0.150, 0.005, 0.150, 0.005, 0.150, 0.005, 0.150]
out = list(extract_session(_typed_events(iats), sid="ms-tremor"))
obs = _of(out, "motor.motor_stability")
assert obs.value == "tremor"
assert obs.confidence == 0.65
def test_moderate_variance_no_outliers_emits_variable() -> None:
# Moderate variance (CV around 0.7), no sub-30 ms IATs
iats = [0.10, 0.40, 0.10, 0.40, 0.10, 0.40, 0.10, 0.40, 0.10, 0.40, 0.10, 0.40]
out = list(extract_session(_typed_events(iats), sid="ms-variable"))
obs = _of(out, "motor.motor_stability")
assert obs.value == "variable"
def test_few_iats_no_emission() -> None:
# Below the 5-IAT minimum to claim stability
iats = [0.10, 0.10, 0.10]
out = list(extract_session(_typed_events(iats), sid="ms-low"))
# 4 inputs total → 3 IATs total, may or may not have a burst
# depending on threshold; either way the emit must skip when
# within-burst IATs total < 5.
obs = [o for o in out if o.primitive == "motor.motor_stability"]
if obs:
# If a burst formed, it's allowed — we only require no crash
assert obs[0].value in ("steady", "variable", "tremor")
else:
assert obs == []