from __future__ import annotations import unittest from contextlib import asynccontextmanager from types import ModuleType, SimpleNamespace from unittest.mock import AsyncMock, patch from uuid import uuid4 from .services import rupture_runtime def _window( *, dimension: str | None = None, severity: str | None = "moderate", techniques: tuple[str, ...] = (), states: tuple[str, ...] = ("defensive",), appropriateness: float = 1.0, rapport: float | None = -0.4, evaluation_error: bool = False, safety_event_ids: tuple[int, ...] = (), ) -> rupture_runtime.DurableTurnWindow: return rupture_runtime.DurableTurnWindow( counselor_turn_id=uuid4(), client_turn_id=uuid4(), counselor_seq=1, client_seq=2, appropriateness_score=appropriateness, rapport_signal=rapport, techniques=techniques, client_states=states, intent_dimension=dimension, intent_severity=severity, evaluation_error=evaluation_error, safety_event_ids=safety_event_ids, ) class RuptureRuntimeDetectionTests(unittest.TestCase): def test_sessions_install_one_shared_finalize_hook_for_voice(self) -> None: from .routes import sessions as session_routes from .routes import voice as voice_routes self.assertIs( session_routes.turn_runtime.finalize_completed_turn, voice_routes.turn_runtime.finalize_completed_turn, ) self.assertTrue( getattr( voice_routes.turn_runtime.finalize_completed_turn, "__rupture_runtime_hook__", False, ) ) def test_all_nine_rupture_types_have_conservative_structured_rules(self) -> None: cases = { "withdrawal": _window(dimension=None), "confrontation": _window(dimension=None, techniques=("confrontation",)), "goal_mismatch": _window(dimension="goal"), "task_mismatch": _window(dimension="pacing"), "empathic_miss": _window(dimension="empathy"), "cultural_miss": _window(dimension="cultural_context"), "boundary_tension": _window(dimension="confidentiality"), "premature_advice": _window( dimension="advice", techniques=("psychoeducation",) ), "over_disclosure": _window( dimension="self_disclosure", techniques=("self_disclosure",) ), } for expected, window in cases.items(): with self.subTest(expected=expected): result = rupture_runtime.detect_rupture(window) self.assertIsNotNone(result) assert result is not None self.assertEqual(result.rupture_type, expected) def test_insufficient_fast_evidence_fails_closed_without_normal_rupture( self, ) -> None: self.assertIsNone( rupture_runtime.detect_rupture( _window( dimension=None, states=(), appropriateness=3.0, rapport=None, ) ) ) self.assertIsNone( rupture_runtime.detect_rupture(_window(dimension="goal", severity="minor")) ) self.assertIsNone( rupture_runtime.detect_rupture( _window(dimension="goal", evaluation_error=True) ) ) def test_advice_and_disclosure_require_matching_technique_evidence(self) -> None: self.assertIsNone(rupture_runtime.detect_rupture(_window(dimension="advice"))) self.assertIsNone( rupture_runtime.detect_rupture(_window(dimension="self_disclosure")) ) def test_safety_references_do_not_change_classifier_output(self) -> None: without_safety = _window(dimension="goal") with_safety = rupture_runtime.DurableTurnWindow( counselor_turn_id=without_safety.counselor_turn_id, client_turn_id=without_safety.client_turn_id, counselor_seq=without_safety.counselor_seq, client_seq=without_safety.client_seq, appropriateness_score=without_safety.appropriateness_score, rapport_signal=without_safety.rapport_signal, techniques=without_safety.techniques, client_states=without_safety.client_states, intent_dimension=without_safety.intent_dimension, intent_severity=without_safety.intent_severity, safety_event_ids=(41, 42), ) self.assertEqual( rupture_runtime.detect_rupture(without_safety), rupture_runtime.detect_rupture(with_safety), ) def test_follow_up_requires_behavior_and_client_response_for_resolution( self, ) -> None: original = rupture_runtime.DetectionCandidate( rupture_type="empathic_miss", confidence=0.9, uncertainty=0.1 ) resolved = rupture_runtime.assess_follow_up( original, _window( dimension=None, techniques=("empathy", "facilitative_question"), states=("defense_loosening",), appropriateness=5.0, rapport=0.5, ), ) missed = rupture_runtime.assess_follow_up( original, _window( dimension=None, techniques=("empathy",), states=("defensive",), appropriateness=2.0, rapport=-0.2, ), ) self.assertIsNotNone(resolved) self.assertEqual(resolved.status, "resolved") # type: ignore[union-attr] self.assertIsNotNone(missed) self.assertEqual(missed.status, "missed") # type: ignore[union-attr] def test_uuid5_keys_are_stable_for_reprocessing(self) -> None: first = rupture_runtime._stable_uuid("observation", "episode", "resolved") second = rupture_runtime._stable_uuid("observation", "episode", "resolved") different = rupture_runtime._stable_uuid("observation", "episode", "partial") self.assertEqual(first, second) self.assertNotEqual(first, different) class RuptureRuntimeAsyncTests(unittest.IsolatedAsyncioTestCase): async def test_scan_uses_same_process_evaluator_context_and_real_provenance( self, ) -> None: session_id = uuid4() window = _window(dimension="goal") snapshot = rupture_runtime.RuntimeSnapshot( session_id=session_id, ended=False, windows=(window,), ) conn = AsyncMock() acquired: list[dict[str, object]] = [] @asynccontextmanager async def fake_acquire(**kwargs: object): acquired.append(kwargs) yield conn observation_id = uuid4() episode_id = uuid4() append = AsyncMock( return_value={ "episode_id": episode_id, "observation_id": observation_id, } ) with ( patch.object(rupture_runtime.db, "acquire", fake_acquire), patch.object( rupture_runtime, "_load_snapshot", AsyncMock(return_value=snapshot), ), patch.object( rupture_runtime, "_load_runtime_episodes", AsyncMock(return_value={}), ), patch.object( rupture_runtime.rupture_repair_store, "append_evaluator_observation", append, ), ): result = await rupture_runtime.run_session_scan( str(session_id), trigger="turn_persisted" ) self.assertEqual(result.status, "recorded") self.assertEqual( acquired, [{"ai_view": "evaluator", "ai_context": True}], ) insert_sql = str(conn.execute.await_args.args[0]) self.assertIn("INSERT INTO audit.model_run", insert_sql) kwargs = append.await_args.kwargs self.assertEqual(kwargs["source_kind"], "model_inferred") self.assertEqual(kwargs["perspective"], "independent_observer") self.assertIsNotNone(kwargs["model_run_id"]) self.assertEqual(kwargs["safety_event_ids"], window.safety_event_ids) async def test_reprocessing_reuses_the_same_observation_idempotency_key( self, ) -> None: session_id = uuid4() window = _window(dimension="goal") snapshot = rupture_runtime.RuntimeSnapshot( session_id=session_id, ended=False, windows=(window,), ) conn = AsyncMock() @asynccontextmanager async def fake_acquire(**kwargs: object): yield conn append = AsyncMock( return_value={"episode_id": uuid4(), "observation_id": uuid4()} ) with ( patch.object(rupture_runtime.db, "acquire", fake_acquire), patch.object( rupture_runtime, "_load_snapshot", AsyncMock(return_value=snapshot), ), patch.object( rupture_runtime, "_load_runtime_episodes", AsyncMock(side_effect=[{}, {}]), ), patch.object( rupture_runtime.rupture_repair_store, "append_evaluator_observation", append, ), ): await rupture_runtime.run_session_scan(str(session_id), trigger="one") await rupture_runtime.run_session_scan(str(session_id), trigger="two") first = append.await_args_list[0].kwargs["idempotency_key"] second = append.await_args_list[1].kwargs["idempotency_key"] self.assertEqual(first, second) async def test_failure_is_metadata_only_and_does_not_escape(self) -> None: session_id = str(uuid4()) with patch.object( rupture_runtime, "_process_session_scan", AsyncMock(side_effect=RuntimeError("masked transcript must not leak")), ): result = await rupture_runtime.run_session_scan( session_id, trigger="turn_persisted" ) self.assertEqual(result.status, "error") self.assertEqual(result.error_code, "runtime_runtimeerror") self.assertNotIn("masked transcript", repr(result)) self.assertEqual(rupture_runtime.last_runtime_result(session_id), result) async def test_finalize_hook_schedules_after_original_for_any_caller(self) -> None: module = ModuleType("fake_turn_runtime") order: list[str] = [] async def finalize(session: object, *_args: object, **_kwargs: object) -> str: order.append("persisted") return "learner-turn" module.finalize_completed_turn = finalize # type: ignore[attr-defined] rupture_runtime.install_turn_finalize_hook(module) installed = module.finalize_completed_turn # type: ignore[attr-defined] with patch.object( rupture_runtime, "schedule_session_scan", side_effect=lambda *_args, **_kwargs: order.append("scheduled"), ): result = await installed(SimpleNamespace(session_id=str(uuid4()))) self.assertEqual(result, "learner-turn") self.assertEqual(order, ["persisted", "scheduled"]) before = module.finalize_completed_turn # type: ignore[attr-defined] rupture_runtime.install_turn_finalize_hook(module) self.assertIs(before, module.finalize_completed_turn) # type: ignore[attr-defined] if __name__ == "__main__": unittest.main()