From 627fa59c152ea95de96668b564fd71458053b263 Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 4 May 2026 00:10:57 -0400 Subject: [PATCH] feat(profiler/behave_shell): emit temporal.session_duration Bucket ctx.duration_s against SESSION_DURATION_SHORT_MAX (60s) / MEDIUM_MAX (600s) / LONG_MAX (3600s); else marathon. Direct measurement, confidence 0.85. Skip emission only when no commands and zero duration. New _features/temporal.py module opens Phase E. --- .../behave_shell/_features/__init__.py | 4 ++ .../behave_shell/_features/temporal.py | 50 +++++++++++++++++++ decnet/profiler/behave_shell/_thresholds.py | 14 ++++++ .../test_temporal_session_duration.py | 50 +++++++++++++++++++ 4 files changed, 118 insertions(+) create mode 100644 decnet/profiler/behave_shell/_features/temporal.py create mode 100644 tests/profiler/behave_shell/test_temporal_session_duration.py diff --git a/decnet/profiler/behave_shell/_features/__init__.py b/decnet/profiler/behave_shell/_features/__init__.py index 73e847b6..565a361c 100644 --- a/decnet/profiler/behave_shell/_features/__init__.py +++ b/decnet/profiler/behave_shell/_features/__init__.py @@ -24,6 +24,9 @@ from decnet.profiler.behave_shell._features.cognitive import ( inter_command_consistency, inter_command_latency_class, ) +from decnet.profiler.behave_shell._features.temporal import ( + session_duration, +) from decnet.profiler.behave_shell._features.motor import ( command_chunking, error_correction, @@ -59,4 +62,5 @@ FEATURES: tuple[FeatureFn, ...] = ( error_resilience_retry_tactic, error_resilience_frustration_typing, error_resilience_fallback_to_man, + session_duration, ) diff --git a/decnet/profiler/behave_shell/_features/temporal.py b/decnet/profiler/behave_shell/_features/temporal.py new file mode 100644 index 00000000..81e0b14d --- /dev/null +++ b/decnet/profiler/behave_shell/_features/temporal.py @@ -0,0 +1,50 @@ +"""``temporal.*`` feature functions — per-session subset. + +Phase E ships the four ``temporal.*`` primitives that don't need +observation history. The other three (``session_timing``, +``persistence``, ``lifecycle_markers.idle_periodicity``) are Tier B +and computed by the attribution engine, not the extractor. + +Step E.1: ``temporal.session_duration``. +""" +from __future__ import annotations + +from typing import Iterator + +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 ( + SESSION_DURATION_LONG_MAX, + SESSION_DURATION_MEDIUM_MAX, + SESSION_DURATION_SHORT_MAX, +) + + +def session_duration(ctx: SessionContext) -> Iterator[Observation]: + """Emit ``temporal.session_duration`` ∈ {short, medium, long, marathon}. + + Direct measurement off ``ctx.duration_s``. Skip emission only when + the session has neither commands nor any duration to speak of — + a one-event session with ``duration_s == 0`` and no commands has + nothing honest to bucket. Confidence is high — duration is a fact, + not an inference. + """ + if ctx.duration_s <= 0.0 and not ctx.commands: + return + d = ctx.duration_s + if d < SESSION_DURATION_SHORT_MAX: + value = "short" + elif d < SESSION_DURATION_MEDIUM_MAX: + value = "medium" + elif d < SESSION_DURATION_LONG_MAX: + value = "long" + else: + value = "marathon" + yield make_observation( + ctx, + primitive="temporal.session_duration", + value=value, + confidence=0.85, + ) diff --git a/decnet/profiler/behave_shell/_thresholds.py b/decnet/profiler/behave_shell/_thresholds.py index 6249e296..b6d1536f 100644 --- a/decnet/profiler/behave_shell/_thresholds.py +++ b/decnet/profiler/behave_shell/_thresholds.py @@ -170,6 +170,20 @@ TOOL_VOCAB_BROAD_MIN: int = 10 FRUSTRATION_LOW_MAX: float = 0.10 FRUSTRATION_MODERATE_MAX: float = 0.30 +# ── temporal.session_duration (Step E.1) ─────────────────────────────────── +# Bucket edges (seconds) for ``ctx.duration_s``: +# +# duration_s < SESSION_DURATION_SHORT_MAX → short +# duration_s < SESSION_DURATION_MEDIUM_MAX → medium +# duration_s < SESSION_DURATION_LONG_MAX → long +# else → marathon +# +# 60s / 600s / 3600s are the BEHAVE-EXTRACTOR.md defaults; D.8-equivalent +# re-tune for E lands when calibration corpus is run. +SESSION_DURATION_SHORT_MAX: float = 60.0 +SESSION_DURATION_MEDIUM_MAX: float = 600.0 +SESSION_DURATION_LONG_MAX: float = 3600.0 + # ── 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_temporal_session_duration.py b/tests/profiler/behave_shell/test_temporal_session_duration.py new file mode 100644 index 00000000..702c57d0 --- /dev/null +++ b/tests/profiler/behave_shell/test_temporal_session_duration.py @@ -0,0 +1,50 @@ +"""Step E.1: ``temporal.session_duration``.""" +from __future__ import annotations + +from decnet.profiler.behave_shell import extract_session +from decnet.profiler.behave_shell._parse import AsciinemaEvent + + +PRIMITIVE = "temporal.session_duration" + + +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 test_empty_session_no_emission() -> None: + out = list(extract_session([], sid="dur-empty")) + assert [o for o in out if o.primitive == PRIMITIVE] == [] + + +def test_under_60s_emits_short() -> None: + events: list[AsciinemaEvent] = [(0.0, "i", "a"), (30.0, "i", "b")] + obs = _of(list(extract_session(events, sid="dur-short")), PRIMITIVE) + assert obs.value == "short" + + +def test_under_600s_emits_medium() -> None: + events: list[AsciinemaEvent] = [(0.0, "i", "a"), (300.0, "i", "b")] + obs = _of(list(extract_session(events, sid="dur-med")), PRIMITIVE) + assert obs.value == "medium" + + +def test_under_3600s_emits_long() -> None: + events: list[AsciinemaEvent] = [(0.0, "i", "a"), (1800.0, "i", "b")] + obs = _of(list(extract_session(events, sid="dur-long")), PRIMITIVE) + assert obs.value == "long" + + +def test_over_3600s_emits_marathon() -> None: + events: list[AsciinemaEvent] = [(0.0, "i", "a"), (7200.0, "i", "b")] + obs = _of(list(extract_session(events, sid="dur-marathon")), PRIMITIVE) + assert obs.value == "marathon" + + +def test_high_confidence() -> None: + """Duration is a fact, not an inference — confidence stays high.""" + events: list[AsciinemaEvent] = [(0.0, "i", "a"), (30.0, "i", "b")] + obs = _of(list(extract_session(events, sid="dur-conf")), PRIMITIVE) + assert obs.confidence >= 0.80