From c8166a60715982d080befa87fb27b888d60e9ed5 Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 4 May 2026 00:40:42 -0400 Subject: [PATCH] feat(profiler/behave_shell): emit environmental.numpad_usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sliding-window scan over single-char digit input events. A run of NUMPAD_RUN_MIN (4) consecutive digit events whose pairwise IATs are all ≤ NUMPAD_FAST_IAT_S (50ms) → detected. Otherwise → not_detected. Skips below NUMPAD_MIN_TYPED_CHARS (50) typed chars. Confidence cap 0.50 per the registry's weak-signal flag. --- .../behave_shell/_features/__init__.py | 2 + .../behave_shell/_features/environmental.py | 53 +++++++++++++++++ decnet/profiler/behave_shell/_thresholds.py | 9 +++ .../test_environmental_numpad_usage.py | 57 +++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 tests/profiler/behave_shell/test_environmental_numpad_usage.py diff --git a/decnet/profiler/behave_shell/_features/__init__.py b/decnet/profiler/behave_shell/_features/__init__.py index 4e7bb77f..f35d9f36 100644 --- a/decnet/profiler/behave_shell/_features/__init__.py +++ b/decnet/profiler/behave_shell/_features/__init__.py @@ -27,6 +27,7 @@ from decnet.profiler.behave_shell._features.cognitive import ( from decnet.profiler.behave_shell._features.environmental import ( keyboard_layout, locale, + numpad_usage, shell_type, terminal_multiplexer, ) @@ -77,4 +78,5 @@ FEATURES: tuple[FeatureFn, ...] = ( terminal_multiplexer, locale, keyboard_layout, + numpad_usage, ) diff --git a/decnet/profiler/behave_shell/_features/environmental.py b/decnet/profiler/behave_shell/_features/environmental.py index 1a9b2683..dcd3c147 100644 --- a/decnet/profiler/behave_shell/_features/environmental.py +++ b/decnet/profiler/behave_shell/_features/environmental.py @@ -9,6 +9,7 @@ Step F.1: ``environmental.shell_type``. Step F.2: ``environmental.terminal_multiplexer``. Step F.3: ``environmental.locale``. Step F.4: ``environmental.keyboard_layout``. +Step F.5: ``environmental.numpad_usage``. """ from __future__ import annotations @@ -30,6 +31,10 @@ from decnet.profiler.behave_shell._thresholds import ( LAYOUT_QWERTZ_Z_MIN, LAYOUT_TOP_ENG_BIGRAMS, LOCALE_MIN_VALUE_LENGTH, + NUMPAD_FAST_IAT_S, + NUMPAD_MIN_TYPED_CHARS, + NUMPAD_RUN_MIN, + PASTE_MIN_CHARS_PER_EVENT, SHELL_TYPE_MIN_PROMPTS, ) @@ -297,3 +302,51 @@ def keyboard_layout(ctx: SessionContext) -> Iterator[Observation]: value=value, confidence=confidence, ) + + +def numpad_usage(ctx: SessionContext) -> Iterator[Observation]: + """Emit ``environmental.numpad_usage`` ∈ {detected, not_detected}. + + A digit run is ``NUMPAD_RUN_MIN`` (4) consecutive single-character + digit input events whose pairwise IATs are all + ≤ ``NUMPAD_FAST_IAT_S`` (50ms). Numpad muscle memory produces + faster digit cadence than touch-typing the top row. + + Skip emission below ``NUMPAD_MIN_TYPED_CHARS`` (50) typed chars — + no honest signal in a tiny session. Confidence cap 0.50 (registry + flags as weak signal). + """ + if not ctx.commands: + return + + digit_events: list[float] = [] + typed_count = 0 + for t, _kind, data in ctx.input_events: + if len(data) >= PASTE_MIN_CHARS_PER_EVENT: + continue + typed_count += len(data) + if len(data) == 1 and data.isdigit(): + digit_events.append(t) + + if typed_count < NUMPAD_MIN_TYPED_CHARS: + return + + detected = False + if len(digit_events) >= NUMPAD_RUN_MIN: + # Sliding window: any contiguous run of NUMPAD_RUN_MIN digit + # events whose internal IATs are ALL fast → numpad. + for i in range(len(digit_events) - NUMPAD_RUN_MIN + 1): + window_iats = [ + digit_events[i + j + 1] - digit_events[i + j] + for j in range(NUMPAD_RUN_MIN - 1) + ] + if all(x <= NUMPAD_FAST_IAT_S for x in window_iats): + detected = True + break + + yield make_observation( + ctx, + primitive="environmental.numpad_usage", + value="detected" if detected else "not_detected", + confidence=0.50, + ) diff --git a/decnet/profiler/behave_shell/_thresholds.py b/decnet/profiler/behave_shell/_thresholds.py index f47e7639..0d88b046 100644 --- a/decnet/profiler/behave_shell/_thresholds.py +++ b/decnet/profiler/behave_shell/_thresholds.py @@ -263,6 +263,15 @@ LAYOUT_QWERTZ_Z_MIN: float = 0.030 # high `z` rate (German content / QWERTZ LAYOUT_QWERTZ_Y_MAX: float = 0.010 # AND `y` swap signature LAYOUT_QWERTY_ENG_MIN: float = 0.080 # English-bigram saturation floor +# ── environmental.numpad_usage (Step F.5) ────────────────────────────────── +# A digit run = NUMPAD_RUN_MIN consecutive single-char digit events +# whose pairwise IATs are all ≤ NUMPAD_FAST_IAT_S. Numpad muscle memory +# produces faster digit IATs than touch-typing on the top row. +NUMPAD_FAST_IAT_S: float = 0.050 +NUMPAD_RUN_MIN: int = 4 +# Below this many typed chars total, skip emission (no honest signal). +NUMPAD_MIN_TYPED_CHARS: int = 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 diff --git a/tests/profiler/behave_shell/test_environmental_numpad_usage.py b/tests/profiler/behave_shell/test_environmental_numpad_usage.py new file mode 100644 index 00000000..5843d071 --- /dev/null +++ b/tests/profiler/behave_shell/test_environmental_numpad_usage.py @@ -0,0 +1,57 @@ +"""Step F.5: ``environmental.numpad_usage``.""" +from __future__ import annotations + +from decnet.profiler.behave_shell import extract_session +from decnet.profiler.behave_shell._parse import AsciinemaEvent + + +PRIMITIVE = "environmental.numpad_usage" + + +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(text: str, t0: float = 0.0, dt: float = 0.10) -> list[AsciinemaEvent]: + return [(t0 + i * dt, "i", c) for i, c in enumerate(text)] + + +def test_below_min_typed_chars_no_emission() -> None: + out = list(extract_session(_typed("ls\r"), sid="np-tiny")) + assert [o for o in out if o.primitive == PRIMITIVE] == [] + + +def test_no_digit_runs_emits_not_detected() -> None: + """50+ typed chars, none of them digit runs → not_detected.""" + text = "the quick brown fox jumps over the lazy dog repeatedly\r" + out = list(extract_session(_typed(text, dt=0.10), sid="np-text")) + obs = _of(out, PRIMITIVE) + assert obs.value == "not_detected" + + +def test_slow_digit_typing_not_detected() -> None: + """Slow digit typing (typing-speed cadence) → not_detected.""" + # 100ms IAT between digits — too slow for numpad + text = "1234567890" * 6 + "\r" # 60 digits + return for command boundary + out = list(extract_session(_typed(text, dt=0.10), sid="np-slow")) + obs = _of(out, PRIMITIVE) + assert obs.value == "not_detected" + + +def test_fast_digit_run_emits_detected() -> None: + """Sub-50ms digit cadence over a 4+ run → detected.""" + # Build a session with 50+ chars first, then a fast digit burst + events: list[AsciinemaEvent] = [] + # Filler typing (slow) + for i, c in enumerate("the quick brown fox jumps over the lazy dog filler"): + events.append((i * 0.10, "i", c)) + # Fast digit run starting at t=10s — IAT=20ms + base = 10.0 + for i, d in enumerate("1234567890"): + events.append((base + i * 0.020, "i", d)) + events.append((20.0, "i", "\r")) + out = list(extract_session(events, sid="np-fast")) + obs = _of(out, PRIMITIVE) + assert obs.value == "detected"