feat(profiler/behave_shell): emit motor.keystroke_cadence

BEHAVE-EXTRACTOR.md Phase B Step B.1.

* SessionContext gains typing_bursts: tuple[tuple[float, ...], ...]
  built by _split_typing_bursts(iats) — splits at gaps > IKI_THINK_MAX_S
  (1.5s) and drops bursts of fewer than 3 IATs. Mirrors prototype's
  _split_into_bursts at BEHAVE/prototype_extractors/shell/extract.py:275.
* _features/motor.py:keystroke_cadence(ctx) emits one Observation
  in {steady, bursty, hunt_and_peck, machine}. Median CV across
  typing bursts; mean IKI < IKI_MACHINE_MAX_S paired with CV <
  CV_MACHINE_MAX → machine. Confidence 0.85/0.70/0.65/0.60 per the
  prototype's calibration history.
* < MIN_INPUTS_FOR_CADENCE inputs or zero typing bursts → skip
  emission. v0.1 emits only the burst-CV variant; the prototype's
  NAIVE session-CV variant is parked for v0.2.
* Calibration grid widened (PHASE_A_PRIMITIVES → PHASE_AB_PRIMITIVES)
  to include motor.keystroke_cadence. Grid green across all five
  shards.

Tests: too-few-inputs → no emit, all-think-pauses → no burst → no
emit, uniform IATs → steady, sub-5ms → machine, mixed-pace → bursty,
extreme bimodal → hunt_and_peck.
This commit is contained in:
2026-05-03 21:24:13 -04:00
parent 0510cde073
commit d90c8b70ce
6 changed files with 180 additions and 3 deletions

View File

@@ -31,13 +31,16 @@ from decnet.profiler.behave_shell import extract_session
from decnet.profiler.behave_shell._parse import parse_shard_line
PHASE_A_PRIMITIVES: frozenset[str] = frozenset({
PHASE_AB_PRIMITIVES: frozenset[str] = frozenset({
# Phase A — calibration floor
"motor.input_modality",
"motor.paste_burst_rate",
"cognitive.inter_command_latency_class",
"cognitive.command_branch_diversity",
"cognitive.feedback_loop_engagement",
"cognitive.inter_command_consistency",
# Phase B — motor.* completion (lands one primitive per commit)
"motor.keystroke_cadence",
})
@@ -105,7 +108,7 @@ def test_shard_emits_all_phase_a_primitives(
obs = _all_observations(path)
assert obs, f"{class_label}: extractor produced zero observations"
seen = {o.primitive for o in obs}
missing = PHASE_A_PRIMITIVES - seen
missing = PHASE_AB_PRIMITIVES - seen
assert not missing, (
f"{class_label} ({shard_file}) missing primitives: "
f"{sorted(missing)}"
@@ -142,7 +145,7 @@ def test_shards_are_discriminative_across_classes(
# At least one primitive should produce different majority values
# across the present classes.
discriminative_primitives: list[str] = []
for prim in PHASE_A_PRIMITIVES:
for prim in PHASE_AB_PRIMITIVES:
values = {by_class[c].get(prim) for c in by_class if prim in by_class[c]}
if len(values) >= 2:
discriminative_primitives.append(prim)