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

@@ -20,6 +20,7 @@ from decnet.profiler.behave_shell._parse import (
hash_token,
)
from decnet.profiler.behave_shell._thresholds import (
IKI_THINK_MAX_S,
PASTE_BURST_MAX_IAT_S,
PASTE_MIN_CHARS_PER_EVENT,
)
@@ -47,6 +48,9 @@ class SessionContext:
inter_cmd_iats: tuple[float, ...] = field(default_factory=tuple)
output_per_cmd: tuple[int, ...] = field(default_factory=tuple)
# Step B.1 derivations — typing bursts (IATs split at think-pauses)
typing_bursts: tuple[tuple[float, ...], ...] = field(default_factory=tuple)
def _detect_paste_bursts(
inputs: list[AsciinemaEvent],
@@ -102,6 +106,22 @@ def _detect_paste_bursts(
return tuple(bursts), paste_count
def _split_typing_bursts(iats: tuple[float, ...]) -> tuple[tuple[float, ...], ...]:
"""Split a flat IAT sequence at gaps > IKI_THINK_MAX_S.
Drops bursts of fewer than 3 IATs — too short to compute a stable
CV. Mirrors BEHAVE prototype's ``_split_into_bursts``.
"""
bursts: list[list[float]] = [[]]
for x in iats:
if x > IKI_THINK_MAX_S:
if bursts[-1]:
bursts.append([])
else:
bursts[-1].append(x)
return tuple(tuple(b) for b in bursts if len(b) >= 3)
def _segment_commands(inputs: list[AsciinemaEvent]) -> tuple[Command, ...]:
"""Walk input events, splitting on ``\\r`` / ``\\n`` into commands.
@@ -179,6 +199,7 @@ def build_session_context(
max(0.0, inputs[i][0] - inputs[i - 1][0]) for i in range(1, len(inputs))
)
paste_bursts, paste_count = _detect_paste_bursts(inputs)
typing_bursts = _split_typing_bursts(iats)
commands = _segment_commands(inputs)
inter_cmd_iats = tuple(
max(0.0, commands[i + 1].start_ts - commands[i].end_ts)
@@ -204,4 +225,5 @@ def build_session_context(
commands=commands,
inter_cmd_iats=inter_cmd_iats,
output_per_cmd=output_per_cmd,
typing_bursts=typing_bursts,
)

View File

@@ -19,6 +19,7 @@ from decnet.profiler.behave_shell._features.cognitive import (
)
from decnet.profiler.behave_shell._features.motor import (
input_modality,
keystroke_cadence,
paste_burst_rate,
)
@@ -27,6 +28,7 @@ FeatureFn = Callable[[SessionContext], Iterable[Observation]]
FEATURES: tuple[FeatureFn, ...] = (
input_modality,
paste_burst_rate,
keystroke_cadence,
inter_command_latency_class,
command_branch_diversity,
feedback_loop_engagement,

View File

@@ -2,9 +2,12 @@
Step 2: ``motor.input_modality`` — typed / pasted / mixed.
Step 3: ``motor.paste_burst_rate`` — none / occasional / habitual.
Step B.1: ``motor.keystroke_cadence`` — steady / bursty / hunt_and_peck / machine.
"""
from __future__ import annotations
import statistics
from itertools import chain
from typing import Iterator
from decnet_behave_core.spec.envelope import Observation
@@ -12,6 +15,11 @@ from decnet_behave_core.spec.envelope import Observation
from decnet.profiler.behave_shell._ctx import SessionContext
from decnet.profiler.behave_shell._features._emit import make_observation
from decnet.profiler.behave_shell._thresholds import (
CV_BURSTY_MAX,
CV_MACHINE_MAX,
CV_STEADY_MAX,
IKI_MACHINE_MAX_S,
MIN_INPUTS_FOR_CADENCE,
MODALITY_PASTED_MIN,
MODALITY_TYPED_MAX,
PASTE_RATE_HABITUAL_MIN,
@@ -76,3 +84,45 @@ def paste_burst_rate(ctx: SessionContext) -> Iterator[Observation]:
value=level,
confidence=confidence,
)
def keystroke_cadence(ctx: SessionContext) -> Iterator[Observation]:
"""Emit ``motor.keystroke_cadence`` ∈ {steady, bursty, hunt_and_peck, machine}.
Median CV of within-typing-burst IATs (bursts split at gaps >
``IKI_THINK_MAX_S`` so think-pauses between commands don't
inflate the variance). Pasted-only sessions and sessions below
``MIN_INPUTS_FOR_CADENCE`` skip emission — no honest cadence
available.
v0.1 emits only the burst-CV variant. The prototype's NAIVE
session-CV variant (lower confidence, second emission per
primitive) is parked for v0.2.
"""
if len(ctx.input_events) < MIN_INPUTS_FOR_CADENCE:
return
if not ctx.typing_bursts:
return
burst_cvs: list[float] = []
for b in ctx.typing_bursts:
m = statistics.fmean(b)
if m > 0:
burst_cvs.append(statistics.pstdev(b) / m)
if not burst_cvs:
return
cv = statistics.median(burst_cvs)
mean_iki = statistics.fmean(chain.from_iterable(ctx.typing_bursts))
if mean_iki < IKI_MACHINE_MAX_S and cv < CV_MACHINE_MAX:
value, confidence = "machine", 0.85
elif cv < CV_STEADY_MAX:
value, confidence = "steady", 0.70
elif cv < CV_BURSTY_MAX:
value, confidence = "bursty", 0.65
else:
value, confidence = "hunt_and_peck", 0.60
yield make_observation(
ctx,
primitive="motor.keystroke_cadence",
value=value,
confidence=confidence,
)

View File

@@ -75,3 +75,18 @@ FEEDBACK_MIN_PAIRS: int = 5
# via Hartigan dip is filed for v0.2).
PAUSE_CV_METRONOMIC_MAX: float = 0.40
PAUSE_CV_BIMODAL_MIN: float = 1.50
# ── motor.keystroke_cadence (Step B.1) ──────────────────────────────────────
# Typing bursts split at gaps > IKI_THINK_MAX_S so think-pauses between
# commands don't inflate the within-burst CV. Mirrors the prototype's
# _split_into_bursts (BEHAVE/prototype_extractors/shell/extract.py:275-286).
IKI_THINK_MAX_S: float = 1.50
# Sub-human floor for the "machine" classification — only paired with a
# pathologically uniform CV, since real humans never produce sub-5ms IATs
# in a sustained burst.
IKI_MACHINE_MAX_S: float = 0.005
CV_MACHINE_MAX: float = 0.05
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