diff --git a/decnet/profiler/behave_shell/_features/__init__.py b/decnet/profiler/behave_shell/_features/__init__.py index 5cccca4b..4e28b33d 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.emotional_valence import ( + valence, +) from decnet.profiler.behave_shell._features.environmental import ( keyboard_layout, locale, @@ -91,4 +94,5 @@ FEATURES: tuple[FeatureFn, ...] = ( opsec_discipline, cleanup_behavior, multi_actor_indicators, + valence, ) diff --git a/decnet/profiler/behave_shell/_features/emotional_valence.py b/decnet/profiler/behave_shell/_features/emotional_valence.py new file mode 100644 index 00000000..0c22928b --- /dev/null +++ b/decnet/profiler/behave_shell/_features/emotional_valence.py @@ -0,0 +1,65 @@ +"""``emotional_valence.*`` feature functions (Phase G, soft block). + +All four primitives in this module ride a hard 0.5 confidence cap +(:data:`EMOTIONAL_VALENCE_CONFIDENCE_CAP`). Cap is enforced inside +the feature functions, *not* via :func:`make_observation` — sample-size +honesty may still pull confidence below 0.5. + +Step G.5: ``emotional_valence.valence``. +Step G.6: ``emotional_valence.arousal`` (lands later). +Step G.7: ``emotional_valence.stress_response`` (lands later). +Step G.8: ``emotional_valence.frustration_venting`` (lands later). +""" +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 ( + EMOTIONAL_VALENCE_CONFIDENCE_CAP, + VALENCE_FULL_CONFIDENCE_MIN, + VALENCE_MIN_HITS, + VALENCE_MIN_TYPED_CHARS, +) + + +def _cap_soft(c: float) -> float: + """Clamp confidence to the soft-primitive ceiling.""" + return min(c, EMOTIONAL_VALENCE_CONFIDENCE_CAP) + + +def valence(ctx: SessionContext) -> Iterator[Observation]: + """Emit ``emotional_valence.valence`` ∈ {positive, neutral, negative}. + + Pure ratio over the lexical counters built in G.0: + + * ``positive`` — ``positive_lex_hits > negative_lex_hits + + obscenity_hits`` AND ``positive_lex_hits ≥ VALENCE_MIN_HITS`` (2). + * ``negative`` — ``negative_lex_hits + obscenity_hits > + positive_lex_hits`` AND that sum ≥ ``VALENCE_MIN_HITS``. + * ``neutral`` — fall-through. + + Skip emission below ``VALENCE_MIN_TYPED_CHARS`` (80) typed letters. + Confidence hard-capped at 0.50 (registry convention); 0.30 below + ``VALENCE_FULL_CONFIDENCE_MIN`` (200). + """ + if ctx.typed_letter_count < VALENCE_MIN_TYPED_CHARS: + return + pos = ctx.positive_lex_hits + neg_total = ctx.negative_lex_hits + ctx.obscenity_hits + if pos > neg_total and pos >= VALENCE_MIN_HITS: + value = "positive" + elif neg_total > pos and neg_total >= VALENCE_MIN_HITS: + value = "negative" + else: + value = "neutral" + raw = 0.50 if ctx.typed_letter_count >= VALENCE_FULL_CONFIDENCE_MIN else 0.30 + yield make_observation( + ctx, + primitive="emotional_valence.valence", + value=value, + confidence=_cap_soft(raw), + ) diff --git a/tests/profiler/behave_shell/test_emotional_valence_valence.py b/tests/profiler/behave_shell/test_emotional_valence_valence.py new file mode 100644 index 00000000..ea8fcdc7 --- /dev/null +++ b/tests/profiler/behave_shell/test_emotional_valence_valence.py @@ -0,0 +1,68 @@ +"""Step G.5: ``emotional_valence.valence`` ∈ {positive, neutral, negative}. + +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.valence" + + +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_too_little_text_no_emission() -> None: + out = list(extract_session(_typed("hi"), sid="g5-thin")) + assert [o for o in out if o.primitive == PRIMITIVE] == [] + + +def test_positive_valence() -> None: + text = ( + "thanks great nice perfect awesome love thanks great nice perfect " + "this is going perfectly well today thanks " + ) + obs = _of(list(extract_session(_typed(text), sid="g5-pos")), PRIMITIVE) + assert obs.value == "positive" + assert obs.confidence <= 0.50 + + +def test_negative_valence_via_obscenity_and_negatives() -> None: + text = ( + "fuck this is broken damn it stuck here wtf fuck shit " + "everything is broken and stupid today again broken again " + "wrong wrong wrong total disaster here and now " + ) + obs = _of(list(extract_session(_typed(text), sid="g5-neg")), PRIMITIVE) + assert obs.value == "negative" + assert obs.confidence <= 0.50 + + +def test_neutral_valence_when_no_lexicon_hits() -> None: + text = ( + "running command for inspection of remote system today " + "checking files and verifying things look correct overall " + ) + obs = _of(list(extract_session(_typed(text), sid="g5-neutral")), PRIMITIVE) + assert obs.value == "neutral" + + +def test_confidence_hard_capped_at_05() -> None: + text = "thanks " * 50 # plenty positive, plenty long + obs = _of(list(extract_session(_typed(text), sid="g5-cap")), PRIMITIVE) + assert obs.confidence <= 0.50 + + +def test_low_text_count_lower_confidence() -> None: + text = "thanks great nice perfect awesome love " * 3 + obs = _of(list(extract_session(_typed(text), sid="g5-lowconf")), PRIMITIVE) + assert obs.confidence == 0.30