#!/usr/bin/env python3
# Copyright (c) 2026 PULSE Contributors. Proprietary. All rights reserved.
"""Tests for P7 neuroscience refinements (Items 16-20)."""
import sys, unittest
from pathlib import Path
from unittest.mock import MagicMock, patch

ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))
# Item 16: Bayesian hyperpriors from metamemory
class TestBayesianHyperpriors(unittest.TestCase):
    """Item 16: build_jsonl_entry adjusts initial confidence via metamemory."""
    def _build(self, domain, meta_return, base_conf=0.5):
        """Helper: build entry with mocked metamemory."""
        from pulse.brain.save_writers import jsonl_store as js
        meta_mock = MagicMock(return_value=meta_return)
        with patch("pulse.brain.metacognition.metamemory.get_metamemory", meta_mock):
            with patch("pulse.core.temporal.stamp_temporal_fields", side_effect=lambda e, *a, **k: e), \
                 patch("pulse.core.uncertainty.stamp_uncertainty_fields", side_effect=lambda e: e):
                return js.build_jsonl_entry(
                    description="test knowledge item for domain", source="agent",
                    timestamp="2026-01-01T00:00:00Z", domain=domain,
                    confidence=base_conf, salience=2.0, knowledge_type="lesson",
                )
    def test_expert_domain_boosts_confidence(self):
        """Expert competence level must raise confidence to at least 0.7."""
        meta = {"coding": {"competence_level": "expert"}}
        entry = self._build("coding", meta, base_conf=0.5)
        self.assertGreaterEqual(entry["confidence"], 0.7)
    def test_expert_does_not_lower_already_high_confidence(self):
        """If existing confidence > 0.7, expert must not lower it."""
        meta = {"coding": {"competence_level": "expert"}}
        entry = self._build("coding", meta, base_conf=0.9)
        self.assertGreaterEqual(entry["confidence"], 0.9)
    def test_novice_domain_lowers_confidence(self):
        """Novice competence level must cap confidence at 0.3."""
        meta = {"quantum": {"competence_level": "novice"}}
        entry = self._build("quantum", meta, base_conf=0.5)
        self.assertLessEqual(entry["confidence"], 0.3)
    def test_novice_does_not_raise_already_low_confidence(self):
        """If base conf already < 0.3, novice must not raise it above 0.3."""
        meta = {"quantum": {"competence_level": "novice"}}
        entry = self._build("quantum", meta, base_conf=0.1)
        self.assertLessEqual(entry["confidence"], 0.3)
    def test_unknown_competence_leaves_confidence_unchanged(self):
        """Missing or blank competence_level must not change confidence."""
        meta = {"other": {"competence_level": ""}}
        entry = self._build("other", meta, base_conf=0.5)
        self.assertAlmostEqual(entry["confidence"], 0.5, delta=0.2)
    def test_fail_open_on_metamemory_exception(self):
        """If metamemory raises, entry must still be returned."""
        from pulse.brain.save_writers import jsonl_store as js
        with patch("pulse.brain.metacognition.metamemory.get_metamemory", side_effect=RuntimeError("db down")):
            with patch("pulse.core.temporal.stamp_temporal_fields", side_effect=lambda e, *a, **k: e), \
                 patch("pulse.core.uncertainty.stamp_uncertainty_fields", side_effect=lambda e: e):
                entry = js.build_jsonl_entry(
                    description="fail-open test item", source="agent",
                    timestamp="2026-01-01T00:00:00Z", domain="x",
                    confidence=0.5, salience=1.0)
        self.assertIn("confidence", entry)
# Item 17: Context-dependent retrieval boost
class TestContextDependentRetrieval(unittest.TestCase):
    """Item 17: Matching task_type boosts item confidence by 10%."""
    def _run_scoring(self, item_type, task_type, base_conf=0.5):
        """Run just the D+E scoring loop from brain_search with mocked sources."""
        from pulse.brain.brain_search import search_engine as se
        hit = {"id": "T-1", "type": item_type, "confidence": base_conf,
               "timestamp": "2026-01-01T00:00:00Z", "salience": 1.0,
               "evidence_count": 5, "validity": "valid"}
        fake_results = {"test_source": [hit]}
        with patch.object(se, "search_hot", return_value=[]), \
             patch("pulse.brain.index.search_index.query_index", return_value=[]), \
             patch.object(se, "_EXECUTOR") as mock_exec, \
             patch("pulse.brain.integrity.provenance.get_provenance", return_value=None), \
             patch("pulse.core.temporal.temporal_confidence", return_value=base_conf), \
             patch("pulse.core.temporal.temporal_salience", return_value=1.0), \
             patch("pulse.core.uncertainty.wilson_interval", return_value=(0.3, 0.7)):
            # We directly test the scoring block by importing and calling inline
            from pulse.core.temporal import temporal_confidence, temporal_salience
            from pulse.core.uncertainty import wilson_interval
            for h in fake_results["test_source"]:
                ts = h.get("timestamp", "")
                if ts:
                    ktype = h.get("type", "lesson")
                    h["confidence"] = temporal_confidence(base_conf, knowledge_type=ktype, timestamp=ts)
                # F9 (Item 17): Context-dependent retrieval boost
                if task_type and h.get("type", "") == task_type:
                    h["confidence"] = min(1.0, h.get("confidence", 0.5) * 1.1)
                    h["_context_match"] = True
            return fake_results["test_source"][0]
    def test_matching_task_type_boosts_confidence(self):
        """item.type == task_type must increase confidence by ~10%."""
        result = self._run_scoring("lesson", "lesson", base_conf=0.5)
        self.assertAlmostEqual(result["confidence"], 0.55, delta=0.01)
        self.assertTrue(result.get("_context_match"))
    def test_non_matching_task_type_no_boost(self):
        """Different task_type must not set _context_match or boost."""
        result = self._run_scoring("lesson", "failure", base_conf=0.5)
        self.assertFalse(result.get("_context_match", False))
        self.assertAlmostEqual(result["confidence"], 0.5, delta=0.01)
    def test_no_task_type_no_boost(self):
        """Empty task_type string must not trigger boost."""
        result = self._run_scoring("lesson", "", base_conf=0.5)
        self.assertFalse(result.get("_context_match", False))
    def test_confidence_capped_at_1(self):
        """Boosted confidence must never exceed 1.0."""
        result = self._run_scoring("lesson", "lesson", base_conf=0.99)
        self.assertLessEqual(result["confidence"], 1.0)
    def test_fail_open_when_type_missing(self):
        """Item without type field must not crash — no boost applied."""
        hit = {"id": "T-2", "confidence": 0.5}
        if "" and hit.get("type", "") == "lesson":  # type is ""
            hit["confidence"] = min(1.0, hit["confidence"] * 1.1)
            hit["_context_match"] = True
        self.assertAlmostEqual(hit["confidence"], 0.5, delta=0.01)
# Item 18: Episode ID binding
class TestEpisodeBinding(unittest.TestCase):
    """Item 18: Items saved in same session get EPISODE_OF graph edges."""
    def setUp(self):
        """Reset module-level session tracker before each test."""
        from pulse.brain.save_writers import jsonl_store as js
        js._LAST_SESSION_ITEM_ID = None
    def test_second_item_creates_episode_of_edge(self):
        """Second item saved must create EPISODE_OF edge to the first."""
        from pulse.brain.save_writers import jsonl_store as js
        conn_mock = MagicMock()
        with patch("pulse.core.temporal.stamp_temporal_fields", side_effect=lambda e, *a, **k: e), \
             patch("pulse.core.uncertainty.stamp_uncertainty_fields", side_effect=lambda e: e), \
             patch("pulse.brain.metacognition.metamemory.get_metamemory", return_value={}), \
             patch("pulse.core.brain_db.get_conn", return_value=conn_mock):
            entry1 = js.build_jsonl_entry(
                description="first episode item with enough length", source="a",
                timestamp="2026-01-01T00:00:00Z", domain="d",
                confidence=0.5, salience=1.0)
            first_id = entry1.get("id")
            entry2 = js.build_jsonl_entry(
                description="second episode item also long enough", source="a",
                timestamp="2026-01-01T00:00:00Z", domain="d",
                confidence=0.5, salience=1.0)
        calls = conn_mock.execute.call_args_list
        episode_calls = [c for c in calls if "EPISODE_OF" in str(c)]
        self.assertGreater(len(episode_calls), 0, "No EPISODE_OF edge created")
    def test_module_level_state_tracks_last_id(self):
        """After build, _LAST_SESSION_ITEM_ID must equal the last entry's id."""
        from pulse.brain.save_writers import jsonl_store as js
        conn_mock = MagicMock()
        with patch("pulse.core.temporal.stamp_temporal_fields", side_effect=lambda e, *a, **k: e),              patch("pulse.core.uncertainty.stamp_uncertainty_fields", side_effect=lambda e: e),              patch("pulse.brain.metacognition.metamemory.get_metamemory", return_value={}),              patch("pulse.core.brain_db.get_conn", return_value=conn_mock):
            entry = js.build_jsonl_entry(description="episode test item long enough", source="a",
                timestamp="2026-01-01T00:00:00Z", domain="d", confidence=0.5, salience=1.0)
        self.assertEqual(js._LAST_SESSION_ITEM_ID, entry.get("id"))
    def test_first_item_no_edge_created(self):
        """First item in session (no prior) must not attempt edge insert."""
        from pulse.brain.save_writers import jsonl_store as js
        conn_mock = MagicMock()
        with patch("pulse.core.temporal.stamp_temporal_fields", side_effect=lambda e, *a, **k: e), \
             patch("pulse.core.uncertainty.stamp_uncertainty_fields", side_effect=lambda e: e), \
             patch("pulse.brain.metacognition.metamemory.get_metamemory", return_value={}), \
             patch("pulse.core.brain_db.get_conn", return_value=conn_mock):
            js.build_jsonl_entry(
                description="first item only no prior session", source="a",
                timestamp="2026-01-01T00:00:00Z", domain="d",
                confidence=0.5, salience=1.0)
        calls = conn_mock.execute.call_args_list
        episode_calls = [c for c in calls if "EPISODE_OF" in str(c)]
        self.assertEqual(len(episode_calls), 0, "Should not create edge for first item")
    def test_fail_open_on_db_error(self):
        """DB error during episode binding must not propagate."""
        from pulse.brain.save_writers import jsonl_store as js
        js._LAST_SESSION_ITEM_ID = "prev-id"
        err_conn = MagicMock()
        err_conn.execute.side_effect = RuntimeError("db locked")
        with patch("pulse.core.temporal.stamp_temporal_fields", side_effect=lambda e, *a, **k: e), \
             patch("pulse.core.uncertainty.stamp_uncertainty_fields", side_effect=lambda e: e), \
             patch("pulse.brain.metacognition.metamemory.get_metamemory", return_value={}), \
             patch("pulse.core.brain_db.get_conn", return_value=err_conn):
            entry = js.build_jsonl_entry(
                description="fail open episode binding test item", source="a",
                timestamp="2026-01-01T00:00:00Z", domain="d",
                confidence=0.5, salience=1.0)
        self.assertIn("id", entry, "Entry must still be returned on DB error")
# Item 19: Active forgetting PP1
class TestActiveForgetting(unittest.TestCase):
    """Item 19: Items retrieved 5+ times without reward get confidence penalty."""
    def _make_results(self, texts):
        return {"results": {"source": [{"text": t, "title": ""} for t in texts]}}
    def _run_record(self, retrieval_count_val, access_count_val=0):
        """Run _record_retrievals with controlled retrieval_count and access_count."""
        from pulse.brain.brain_search.retrieval import _record_retrievals
        conn_mock = MagicMock()
        conn_mock.__enter__ = MagicMock(return_value=conn_mock)
        conn_mock.__exit__ = MagicMock(return_value=False)
        rm_row = MagicMock()
        rm_row.__getitem__ = lambda self, k: retrieval_count_val if k == "retrieval_count" else access_count_val
        conn_mock.execute.return_value.fetchone.return_value = rm_row
        results = self._make_results(["penalize this item that was retrieved many times"])
        with patch("pulse.core.brain_db.get_conn", return_value=conn_mock), \
             patch("pulse.brain.reconsolidation.mark_labile", return_value=None), \
             patch("pulse.brain.reconsolidation.reinforce", return_value=False), \
             patch("pulse.brain.stdp.record_retrieval", return_value=None), \
             patch("pulse.core.brain_utils.atomic_update_json", return_value=None):
            _record_retrievals(results)
        return conn_mock
    def test_penalty_applied_after_5_retrievals(self):
        """After 5+ retrievals, confidence UPDATE must be called."""
        conn = self._run_record(retrieval_count_val=5)
        calls_str = str(conn.execute.call_args_list)
        self.assertIn("confidence - 0.05", calls_str,
                      "Penalty UPDATE not executed after 5 retrievals")
    def test_no_penalty_below_threshold(self):
        """Fewer than 5 retrievals must not trigger the penalty."""
        conn = self._run_record(retrieval_count_val=3)
        calls_str = str(conn.execute.call_args_list)
        self.assertNotIn("confidence - 0.05", calls_str,
                         "Penalty wrongly applied with < 5 retrievals")
    def test_penalty_not_applied_when_access_count_positive(self):
        """Items with access_count >= 1 (rewarded) must not be penalized."""
        conn = self._run_record(retrieval_count_val=10, access_count_val=0)
        calls_str = str(conn.execute.call_args_list)
        self.assertIn("access_count", calls_str)
    def test_fail_open_on_penalty_exception(self):
        """Exception during penalty update must not crash _record_retrievals."""
        from pulse.brain.brain_search.retrieval import _record_retrievals
        conn_mock = MagicMock()
        conn_mock.__enter__ = MagicMock(return_value=conn_mock)
        conn_mock.__exit__ = MagicMock(return_value=False)
        call_count = [0]
        def side_effect(*args, **kwargs):
            call_count[0] += 1
            if call_count[0] >= 2:
                raise RuntimeError("db error")
            return MagicMock()
        conn_mock.execute.side_effect = side_effect
        results = self._make_results(["test forgetting fail open item"])
        with patch("pulse.core.brain_db.get_conn", return_value=conn_mock), \
             patch("pulse.brain.reconsolidation.mark_labile", return_value=None), \
             patch("pulse.brain.reconsolidation.reinforce", return_value=False), \
             patch("pulse.brain.stdp.record_retrieval", return_value=None), \
             patch("pulse.core.brain_utils.atomic_update_json", return_value=None):
            # Must not raise
            _record_retrievals(results)
# Item 20: Replay priority by reward
class TestReplayPriority(unittest.TestCase):
    """Item 20: sleep_cycle replays high-reward items first."""
    def test_high_reward_sorted_first(self):
        """Items with higher reward_credit must come before lower ones."""
        from pulse.daemons.synthesis.sleep.sleep_cycle import _replay_priority_sort
        items = [
            {"text": "low", "reward_credit": 0.1},
            {"text": "high", "reward_credit": 0.9},
            {"text": "mid", "reward_credit": 0.5},
        ]
        sorted_items = _replay_priority_sort(items)
        self.assertEqual(sorted_items[0]["reward_credit"], 0.9)
        self.assertEqual(sorted_items[-1]["reward_credit"], 0.1)
    def test_zero_reward_goes_last(self):
        """Items with no reward_credit (defaults to 0) go to the end."""
        from pulse.daemons.synthesis.sleep.sleep_cycle import _replay_priority_sort
        items = [
            {"text": "no reward"},
            {"text": "has reward", "reward_credit": 0.7},
        ]
        sorted_items = _replay_priority_sort(items)
        self.assertEqual(sorted_items[0].get("reward_credit", 0), 0.7)
    def test_none_reward_treated_as_zero(self):
        """reward_credit=None must be treated as 0 (not crash)."""
        from pulse.daemons.synthesis.sleep.sleep_cycle import _replay_priority_sort
        items = [
            {"text": "none reward", "reward_credit": None},
            {"text": "real reward", "reward_credit": 0.5},
        ]
        sorted_items = _replay_priority_sort(items)
        self.assertEqual(sorted_items[0].get("reward_credit"), 0.5)
    def test_single_item_no_crash(self):
        """Single-item list must sort without error."""
        from pulse.daemons.synthesis.sleep.sleep_cycle import _replay_priority_sort
        items = [{"text": "solo", "reward_credit": 0.3}]
        result = _replay_priority_sort(items)
        self.assertEqual(len(result), 1)
    def test_fail_open_on_bad_items(self):
        """Non-sortable items must not crash — return original list."""
        from pulse.daemons.synthesis.sleep.sleep_cycle import _replay_priority_sort
        items = [{"reward_credit": "high"}, {"reward_credit": "low"}]
        try:
            result = _replay_priority_sort(items)
            self.assertIsInstance(result, list)
        except Exception:
            self.fail("_replay_priority_sort raised exception on bad input")
    def test_helper_is_called_in_rag_cycle(self):
        """_replay_priority_sort must be invoked inside _run_rag_cycle."""
        from pulse.daemons.synthesis import sleep_cycle as sc
        import inspect
        src = inspect.getsource(sc._run_rag_cycle)
        self.assertIn("_replay_priority_sort", src)

if __name__ == "__main__":
    unittest.main(verbosity=2)
