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:
@@ -20,6 +20,7 @@ from decnet.profiler.behave_shell._features.cognitive import (
|
|||||||
from decnet.profiler.behave_shell._features.motor import (
|
from decnet.profiler.behave_shell._features.motor import (
|
||||||
input_modality,
|
input_modality,
|
||||||
keystroke_cadence,
|
keystroke_cadence,
|
||||||
|
motor_stability,
|
||||||
paste_burst_rate,
|
paste_burst_rate,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ FEATURES: tuple[FeatureFn, ...] = (
|
|||||||
input_modality,
|
input_modality,
|
||||||
paste_burst_rate,
|
paste_burst_rate,
|
||||||
keystroke_cadence,
|
keystroke_cadence,
|
||||||
|
motor_stability,
|
||||||
inter_command_latency_class,
|
inter_command_latency_class,
|
||||||
command_branch_diversity,
|
command_branch_diversity,
|
||||||
feedback_loop_engagement,
|
feedback_loop_engagement,
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ from decnet.profiler.behave_shell._thresholds import (
|
|||||||
MODALITY_TYPED_MAX,
|
MODALITY_TYPED_MAX,
|
||||||
PASTE_RATE_HABITUAL_MIN,
|
PASTE_RATE_HABITUAL_MIN,
|
||||||
PASTE_RATE_OCCASIONAL_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,
|
value=value,
|
||||||
confidence=confidence,
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -90,3 +90,11 @@ CV_STEADY_MAX: float = 0.50
|
|||||||
CV_BURSTY_MAX: float = 1.50
|
CV_BURSTY_MAX: float = 1.50
|
||||||
# Need this many input events before we'll claim a cadence at all.
|
# Need this many input events before we'll claim a cadence at all.
|
||||||
MIN_INPUTS_FOR_CADENCE: int = 5
|
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
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ PHASE_AB_PRIMITIVES: frozenset[str] = frozenset({
|
|||||||
"cognitive.inter_command_consistency",
|
"cognitive.inter_command_consistency",
|
||||||
# Phase B — motor.* completion (lands one primitive per commit)
|
# Phase B — motor.* completion (lands one primitive per commit)
|
||||||
"motor.keystroke_cadence",
|
"motor.keystroke_cadence",
|
||||||
|
"motor.motor_stability",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
67
tests/profiler/behave_shell/test_motor_motor_stability.py
Normal file
67
tests/profiler/behave_shell/test_motor_motor_stability.py
Normal 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 == []
|
||||||
Reference in New Issue
Block a user