diff --git a/decnet/profiler/behave_shell/_features/__init__.py b/decnet/profiler/behave_shell/_features/__init__.py index 94cec4ab..4df7e40d 100644 --- a/decnet/profiler/behave_shell/_features/__init__.py +++ b/decnet/profiler/behave_shell/_features/__init__.py @@ -20,6 +20,7 @@ from decnet.profiler.behave_shell._features.cognitive import ( from decnet.profiler.behave_shell._features.motor import ( input_modality, keystroke_cadence, + motor_stability, paste_burst_rate, ) @@ -29,6 +30,7 @@ FEATURES: tuple[FeatureFn, ...] = ( input_modality, paste_burst_rate, keystroke_cadence, + motor_stability, inter_command_latency_class, command_branch_diversity, feedback_loop_engagement, diff --git a/decnet/profiler/behave_shell/_features/motor.py b/decnet/profiler/behave_shell/_features/motor.py index 36cc55e2..48890d03 100644 --- a/decnet/profiler/behave_shell/_features/motor.py +++ b/decnet/profiler/behave_shell/_features/motor.py @@ -24,6 +24,8 @@ from decnet.profiler.behave_shell._thresholds import ( MODALITY_TYPED_MAX, PASTE_RATE_HABITUAL_MIN, PASTE_RATE_OCCASIONAL_MIN, + TREMOR_FAST_FLOOR_S, + TREMOR_RATE_MIN, ) @@ -126,3 +128,41 @@ def keystroke_cadence(ctx: SessionContext) -> Iterator[Observation]: value=value, confidence=confidence, ) + + +def motor_stability(ctx: SessionContext) -> Iterator[Observation]: + """Emit ``motor.motor_stability`` ∈ {steady, variable, tremor}. + + First-pass tremor signal: fraction of within-typing-burst IATs + below ``TREMOR_FAST_FLOOR_S`` (30 ms — humans can't reliably + produce sustained sub-50 ms IATs). High sub-floor rate flags + double-press / motor twitch / stuck-key. Otherwise the same + median burst-CV used by ``keystroke_cadence`` decides + steady-vs-variable, with the cadence's CV_STEADY_MAX as the + boundary. + """ + if not ctx.typing_bursts: + return + flat = list(chain.from_iterable(ctx.typing_bursts)) + if len(flat) < 5: + return + fast_rate = sum(1 for x in flat if x < TREMOR_FAST_FLOOR_S) / len(flat) + if fast_rate >= TREMOR_RATE_MIN: + value, confidence = "tremor", 0.65 + else: + burst_cvs: list[float] = [] + for b in ctx.typing_bursts: + m = statistics.fmean(b) + if m > 0: + burst_cvs.append(statistics.pstdev(b) / m) + cv = statistics.median(burst_cvs) if burst_cvs else 0.0 + if cv < CV_STEADY_MAX: + value, confidence = "steady", 0.70 + else: + value, confidence = "variable", 0.60 + yield make_observation( + ctx, + primitive="motor.motor_stability", + value=value, + confidence=confidence, + ) diff --git a/decnet/profiler/behave_shell/_thresholds.py b/decnet/profiler/behave_shell/_thresholds.py index 9a5a7e2f..7f13e94a 100644 --- a/decnet/profiler/behave_shell/_thresholds.py +++ b/decnet/profiler/behave_shell/_thresholds.py @@ -90,3 +90,11 @@ CV_STEADY_MAX: float = 0.50 CV_BURSTY_MAX: float = 1.50 # Need this many input events before we'll claim a cadence at all. MIN_INPUTS_FOR_CADENCE: int = 5 + +# ── motor.motor_stability (Step B.2) ──────────────────────────────────────── +# Tremor proxy: fraction of within-burst IATs below TREMOR_FAST_FLOOR_S +# (30 ms — physiologically implausible double-press floor; humans can't +# reliably produce IATs below ~50 ms in sustained typing). High rate +# of sub-floor IATs flags double-press / motor twitch / stuck-key. +TREMOR_FAST_FLOOR_S: float = 0.030 +TREMOR_RATE_MIN: float = 0.10 # ≥10% sub-floor → tremor diff --git a/tests/profiler/behave_shell/test_calibration_grid.py b/tests/profiler/behave_shell/test_calibration_grid.py index 5355e991..d6579c58 100644 --- a/tests/profiler/behave_shell/test_calibration_grid.py +++ b/tests/profiler/behave_shell/test_calibration_grid.py @@ -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", }) diff --git a/tests/profiler/behave_shell/test_motor_motor_stability.py b/tests/profiler/behave_shell/test_motor_motor_stability.py new file mode 100644 index 00000000..c6bfe649 --- /dev/null +++ b/tests/profiler/behave_shell/test_motor_motor_stability.py @@ -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 == []