diff --git a/decnet/profiler/behave_shell/_features/__init__.py b/decnet/profiler/behave_shell/_features/__init__.py index 4e28b33d..13cd111e 100644 --- a/decnet/profiler/behave_shell/_features/__init__.py +++ b/decnet/profiler/behave_shell/_features/__init__.py @@ -25,6 +25,7 @@ from decnet.profiler.behave_shell._features.cognitive import ( inter_command_latency_class, ) from decnet.profiler.behave_shell._features.emotional_valence import ( + arousal, valence, ) from decnet.profiler.behave_shell._features.environmental import ( @@ -95,4 +96,5 @@ FEATURES: tuple[FeatureFn, ...] = ( cleanup_behavior, multi_actor_indicators, valence, + arousal, ) diff --git a/decnet/profiler/behave_shell/_features/emotional_valence.py b/decnet/profiler/behave_shell/_features/emotional_valence.py index 0c22928b..b255c88a 100644 --- a/decnet/profiler/behave_shell/_features/emotional_valence.py +++ b/decnet/profiler/behave_shell/_features/emotional_valence.py @@ -12,6 +12,7 @@ Step G.8: ``emotional_valence.frustration_venting`` (lands later). """ from __future__ import annotations +import statistics from typing import Iterator from decnet_behave_core.spec.envelope import Observation @@ -19,6 +20,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 ( + AROUSAL_BANG_RUN_MIN, + AROUSAL_CALM_IAT_S, + AROUSAL_CAPS_RUN_MIN, + AROUSAL_FAST_IAT_S, + AROUSAL_MIN_IATS, EMOTIONAL_VALENCE_CONFIDENCE_CAP, VALENCE_FULL_CONFIDENCE_MIN, VALENCE_MIN_HITS, @@ -63,3 +69,51 @@ def valence(ctx: SessionContext) -> Iterator[Observation]: value=value, confidence=_cap_soft(raw), ) + + +def arousal(ctx: SessionContext) -> Iterator[Observation]: + """Emit ``emotional_valence.arousal`` ∈ {low_calm, medium_engaged, + high_agitated}. + + Three signals (any of which fires ``high_agitated``): + + * ``ctx.caps_run_max ≥ AROUSAL_CAPS_RUN_MIN`` (5) — capslock rant. + * ``ctx.bang_run_max ≥ AROUSAL_BANG_RUN_MIN`` (3) — repeated bangs. + * The fastest typing burst's median IAT < ``AROUSAL_FAST_IAT_S`` + (0.06) over a burst of ≥ ``AROUSAL_MIN_IATS`` (30) IATs. + + ``low_calm`` — slowest qualifying burst's median IAT > + ``AROUSAL_CALM_IAT_S`` (0.30). + + ``medium_engaged`` — fall-through. + + Skip emission when no qualifying typing bursts. Confidence hard- + capped at 0.50; 0.30 below ``AROUSAL_MIN_IATS`` total typed IATs. + """ + qualifying = [b for b in ctx.typing_bursts if len(b) >= 3] + if not qualifying: + return + fastest_med = min(statistics.median(b) for b in qualifying) + slowest_med = max(statistics.median(b) for b in qualifying) + total_iats = sum(len(b) for b in qualifying) + + if ( + ctx.caps_run_max >= AROUSAL_CAPS_RUN_MIN + or ctx.bang_run_max >= AROUSAL_BANG_RUN_MIN + or ( + total_iats >= AROUSAL_MIN_IATS + and fastest_med < AROUSAL_FAST_IAT_S + ) + ): + value = "high_agitated" + elif total_iats >= AROUSAL_MIN_IATS and slowest_med > AROUSAL_CALM_IAT_S: + value = "low_calm" + else: + value = "medium_engaged" + raw = 0.50 if total_iats >= AROUSAL_MIN_IATS else 0.30 + yield make_observation( + ctx, + primitive="emotional_valence.arousal", + value=value, + confidence=_cap_soft(raw), + ) diff --git a/tests/profiler/behave_shell/test_emotional_valence_arousal.py b/tests/profiler/behave_shell/test_emotional_valence_arousal.py new file mode 100644 index 00000000..50ab1f20 --- /dev/null +++ b/tests/profiler/behave_shell/test_emotional_valence_arousal.py @@ -0,0 +1,67 @@ +"""Step G.6: ``emotional_valence.arousal`` ∈ {low_calm, medium_engaged, +high_agitated}. + +Hard 0.5 confidence cap. +""" +from __future__ import annotations + +from decnet.profiler.behave_shell import extract_session +from decnet.profiler.behave_shell._parse import AsciinemaEvent + + +PRIMITIVE = "emotional_valence.arousal" + + +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.05) -> list[AsciinemaEvent]: + return [(t0 + i * dt, "i", c) for i, c in enumerate(text)] + + +def test_no_typing_bursts_no_emission() -> None: + out = list(extract_session([(0.0, "i", "x")], sid="g6-empty")) + assert [o for o in out if o.primitive == PRIMITIVE] == [] + + +def test_high_agitated_via_caps_run() -> None: + """Long capslock streak fires high_agitated regardless of pace.""" + text = "ls\rWHAT IS GOING ON HERE\rls\r" + obs = _of(list(extract_session(_typed(text), sid="g6-caps")), PRIMITIVE) + assert obs.value == "high_agitated" + assert obs.confidence <= 0.50 + + +def test_high_agitated_via_bangs() -> None: + text = "ls\rno!!!! something\rls\r" + obs = _of(list(extract_session(_typed(text), sid="g6-bang")), PRIMITIVE) + assert obs.value == "high_agitated" + + +def test_high_agitated_via_fast_typing() -> None: + """Long fast burst (dt=0.04) fires high_agitated.""" + text = "thequickbrownfoxjumpsoverthelazydog" * 2 + obs = _of( + list(extract_session(_typed(text, dt=0.04), sid="g6-fast")), PRIMITIVE + ) + assert obs.value == "high_agitated" + + +def test_low_calm_slow_typing() -> None: + """Long slow burst (dt=0.40) fires low_calm.""" + text = "thequickbrownfoxjumpsoverthelazydog" * 2 + obs = _of( + list(extract_session(_typed(text, dt=0.40), sid="g6-calm")), PRIMITIVE + ) + assert obs.value == "low_calm" + + +def test_confidence_capped_at_05() -> None: + text = "thequickbrownfoxjumpsoverthelazydog" * 2 + obs = _of( + list(extract_session(_typed(text, dt=0.10), sid="g6-cap")), PRIMITIVE + ) + assert obs.confidence <= 0.50