#!/usr/bin/env python3
"""Tests for Phase 3 AGI Capabilities — New Brain Mechanisms.

Covers all 10 capabilities:
1. Global Workspace competition (winner-take-all, lateral inhibition)
2. Biased competition in attention (source suppression)
3. STDP temporal sequence learning (retrieval order → edges)
4. Hebbian co-activation learning (co-retrieval → edges)
5. Spaced retrieval scheduler (Leitner boxes, testing effect)
6. BCM metaplasticity (diminishing reinforce for popular items)
7. Loss aversion (Kahneman-Tversky 2x asymmetry)
8. Volatility-adaptive learning rates (Behrens 2007)
9. Session-based replay (hippocampal offline replay)
10. Conflict monitor (ACC contradiction detection)
"""
import unittest
import json
import sys
import os
import tempfile
import time
from pathlib import Path
from datetime import datetime, timezone, timedelta
from unittest.mock import patch, MagicMock

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))


# ---------------------------------------------------------------------------
# Capability 1: Global Workspace Competition
# ---------------------------------------------------------------------------
class TestGlobalWorkspaceCompetition(unittest.TestCase):
    """Winner-take-all dynamics with lateral inhibition."""

    def test_ignition_constants_exist(self):
        from pulse.brain.workspace.global_workspace import (
            IGNITION_GAP, MAX_BROADCAST_ITEMS,
            RECURRENT_CYCLES, INHIBITION_FACTOR
        )
        self.assertGreater(IGNITION_GAP, 0)
        self.assertGreater(MAX_BROADCAST_ITEMS, 0)
        self.assertGreater(RECURRENT_CYCLES, 0)
        self.assertGreater(INHIBITION_FACTOR, 0)
        self.assertLess(INHIBITION_FACTOR, 1.0)  # Must suppress, not amplify

    def test_inhibition_suppresses_losers(self):
        """After recurrent cycles, winner should dominate over losers."""
        from pulse.brain.workspace.global_workspace import INHIBITION_FACTOR, RECURRENT_CYCLES
        winner_activation = 0.8
        loser_activation = 0.6
        # Simulate recurrent competition
        for _ in range(RECURRENT_CYCLES):
            winner_activation = min(1.0, winner_activation + 0.1)
            loser_activation *= INHIBITION_FACTOR
        gap = winner_activation - loser_activation
        self.assertGreater(gap, 0.3, "Winner should dominate after recurrent cycles")

    def test_workspace_ignition_callable(self):
        from pulse.brain.workspace.global_workspace import run_workspace_ignition
        self.assertTrue(callable(run_workspace_ignition))

    def test_memory_wins_with_high_confidence_results(self):
        """Memory coalition dominates when search results carry high confidence/salience."""
        from pulse.brain.workspace import global_workspace as gw
        from pulse.brain.workspace.specialists import Coalition

        search_results = {
            "results": {
                "wins": [
                    {"text": "use filelock for atomic writes to avoid concurrent corruption",
                     "confidence": 0.9, "salience": 6.0},
                    {"text": "brain first is mandatory before any implementation action",
                     "confidence": 0.85, "salience": 5.5},
                ],
                "fails": [
                    {"text": "shutil.move fails on windows when target file already exists",
                     "confidence": 0.8, "salience": 4.5},
                ],
            }
        }

        with patch("pulse.brain.workspace.global_workspace.event_bus"), \
             patch("pulse.brain.workspace.global_workspace.compute_dynamic_threshold", return_value=0.3), \
             patch("pulse.brain.workspace.global_workspace.get_prediction_coalition",
                   return_value=Coalition("prediction", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_dmn_coalition",
                   return_value=Coalition("dmn", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_reflex_coalition",
                   return_value=Coalition("reflex", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_neural_state_coalition",
                   return_value=Coalition("neural_state", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_working_memory_coalition",
                   return_value=Coalition("working_memory", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_governance_coalition",
                   return_value=Coalition("governance", [], 0.0)):
            result = gw.run_workspace_ignition(
                "test_agent", "filelock atomic writes", search_results)

        self.assertIn("memory", result["winning_sources"],
                      "High-confidence search results must elect memory coalition")
        self.assertGreater(len(result["items"]), 0, "Broadcast must contain items")

    def test_broadcast_items_have_gw_annotations(self):
        """Every item in the broadcast must carry _gw_source and _gw_activation fields."""
        from pulse.brain.workspace import global_workspace as gw
        from pulse.brain.workspace.specialists import Coalition

        search_results = {
            "results": {
                "wins": [
                    {"text": "always verify changes with actual tool calls not assumptions",
                     "confidence": 0.9, "salience": 5.0},
                ],
            }
        }

        with patch("pulse.brain.workspace.global_workspace.event_bus"), \
             patch("pulse.brain.workspace.global_workspace.compute_dynamic_threshold", return_value=0.3), \
             patch("pulse.brain.workspace.global_workspace.get_prediction_coalition",
                   return_value=Coalition("prediction", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_dmn_coalition",
                   return_value=Coalition("dmn", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_reflex_coalition",
                   return_value=Coalition("reflex", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_neural_state_coalition",
                   return_value=Coalition("neural_state", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_working_memory_coalition",
                   return_value=Coalition("working_memory", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_governance_coalition",
                   return_value=Coalition("governance", [], 0.0)):
            result = gw.run_workspace_ignition(
                "test_agent", "verify tool calls", search_results)

        self.assertGreater(len(result["items"]), 0, "Must produce broadcast items")
        for item in result["items"]:
            self.assertIn("_gw_source", item,
                          "Each broadcast item must carry _gw_source annotation")
            self.assertIn("_gw_activation", item,
                          "Each broadcast item must carry _gw_activation annotation")
            self.assertIsInstance(item["_gw_activation"], float)
            self.assertGreater(item["_gw_activation"], 0.0)

    def test_max_broadcast_cap_enforced_with_many_results(self):
        """Broadcast is capped at MAX_BROADCAST_ITEMS even when search returns many hits."""
        from pulse.brain.workspace import global_workspace as gw
        from pulse.brain.workspace.specialists import Coalition
        from pulse.brain.workspace.global_workspace import MAX_BROADCAST_ITEMS

        # 20 items — well above the cap of 12
        many_items = [
            {"text": f"lesson {i}: important pattern about system behavior number {i}",
             "confidence": 0.8, "salience": 4.0 + (i % 3)}
            for i in range(20)
        ]
        search_results = {"results": {"wins": many_items}}

        with patch("pulse.brain.workspace.global_workspace.event_bus"), \
             patch("pulse.brain.workspace.global_workspace.compute_dynamic_threshold", return_value=0.3), \
             patch("pulse.brain.workspace.global_workspace.get_prediction_coalition",
                   return_value=Coalition("prediction", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_dmn_coalition",
                   return_value=Coalition("dmn", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_reflex_coalition",
                   return_value=Coalition("reflex", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_neural_state_coalition",
                   return_value=Coalition("neural_state", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_working_memory_coalition",
                   return_value=Coalition("working_memory", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_governance_coalition",
                   return_value=Coalition("governance", [], 0.0)):
            result = gw.run_workspace_ignition(
                "test_agent", "system behavior patterns", search_results)

        self.assertLessEqual(
            len(result["items"]), MAX_BROADCAST_ITEMS,
            f"Broadcast must be capped at MAX_BROADCAST_ITEMS={MAX_BROADCAST_ITEMS}, "
            f"got {len(result['items'])}")

    def test_governance_protected_from_full_suppression(self):
        """Governance coalition survives lateral inhibition from a dominant competitor."""
        from pulse.brain.workspace import global_workspace as gw
        from pulse.brain.workspace.specialists import Coalition

        # Governance has items and activation just above threshold
        governance = Coalition(
            "governance",
            [{"type": "violation", "rule": "brain_first_skipped", "severity": "tier0"}],
            0.31,
        )
        # Memory dominates at near-max activation
        dominant_memory = Coalition(
            "memory",
            [{"text": "dominant memory item from high-confidence brain search", "confidence": 0.99}],
            0.99,
        )

        with patch("pulse.brain.workspace.global_workspace.event_bus"), \
             patch("pulse.brain.workspace.global_workspace.compute_dynamic_threshold", return_value=0.3), \
             patch("pulse.brain.workspace.global_workspace.get_memory_coalition",
                   return_value=dominant_memory), \
             patch("pulse.brain.workspace.global_workspace.get_prediction_coalition",
                   return_value=Coalition("prediction", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_dmn_coalition",
                   return_value=Coalition("dmn", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_reflex_coalition",
                   return_value=Coalition("reflex", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_neural_state_coalition",
                   return_value=Coalition("neural_state", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_working_memory_coalition",
                   return_value=Coalition("working_memory", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_governance_coalition",
                   return_value=governance):
            result = gw.run_workspace_ignition(
                "test_agent", "brain first enforcement", {})

        self.assertIn("governance", result["winning_sources"],
                      "Governance must survive lateral inhibition and appear in broadcast")


# ---------------------------------------------------------------------------
# Capability 1 — Real Search Results: Competition Dynamics
# ---------------------------------------------------------------------------
class TestGlobalWorkspaceWithRealSearch(unittest.TestCase):
    """Verify competition dynamics using actual brain search results.

    Unlike TestGlobalWorkspaceCompetition (which uses hardcoded dicts),
    these tests call brain_search to get real results from Hippocampus, then
    pass them through run_workspace_ignition and verify the competition
    dynamics (lateral inhibition, ignition gap, broadcast annotations, etc.)
    hold on live data.
    """

    @classmethod
    def setUpClass(cls):
        """Run a real brain search once and cache results for all tests."""
        try:
            from pulse.brain.brain_search import brain_search
            cls.search_results = brain_search("brain first atomic writes filelock", max_per_source=10)
            cls.has_results = bool(
                cls.search_results and
                cls.search_results.get("results") and
                any(cls.search_results["results"].values())
            )
        except Exception as e:
            cls.search_results = {"results": {}}
            cls.has_results = False
            cls._search_error = str(e)

    def _run_ignition(self, search_results, query="brain first enforcement"):
        """Run workspace ignition with real search results, mocking only I/O sinks."""
        from pulse.brain.workspace import global_workspace as gw
        with patch("pulse.brain.workspace.global_workspace.event_bus"), \
             patch("pulse.brain.workspace.global_workspace.compute_dynamic_threshold", return_value=0.3):
            return gw.run_workspace_ignition("test_agent", query, search_results)

    def test_real_search_returns_results(self):
        """Brain search must return a non-empty results dict for standard queries."""
        self.assertIsInstance(self.search_results, dict,
                              "brain_search must return a dict")
        self.assertIn("results", self.search_results,
                      "brain_search result must contain 'results' key")

    def test_ignition_with_real_results_returns_valid_structure(self):
        """run_workspace_ignition must return required keys when fed real search results."""
        result = self._run_ignition(self.search_results)
        for key in ("agent", "query", "task_type", "winning_sources", "items"):
            self.assertIn(key, result,
                          f"Broadcast must contain key '{key}'")
        self.assertIsInstance(result["winning_sources"], list)
        self.assertIsInstance(result["items"], list)

    def test_broadcast_items_sorted_by_activation(self):
        """Broadcast items must be sorted descending by _gw_activation."""
        if not self.has_results:
            self.skipTest("No real brain data available — skipping activation order check")
        result = self._run_ignition(self.search_results)
        items = result["items"]
        if len(items) < 2:
            return  # Nothing to compare
        activations = [i.get("_gw_activation", 0) for i in items]
        for i in range(1, len(activations)):
            self.assertGreaterEqual(
                activations[i - 1], activations[i],
                f"Broadcast items must be sorted by _gw_activation desc: "
                f"index {i-1} ({activations[i-1]:.3f}) < index {i} ({activations[i]:.3f})"
            )

    def test_all_broadcast_items_carry_gw_annotations(self):
        """Every item in a real-search broadcast must have _gw_source and _gw_activation."""
        if not self.has_results:
            self.skipTest("No real brain data — skipping annotation check")
        result = self._run_ignition(self.search_results)
        for idx, item in enumerate(result["items"]):
            self.assertIn("_gw_source", item,
                          f"Item {idx} missing _gw_source annotation")
            self.assertIn("_gw_activation", item,
                          f"Item {idx} missing _gw_activation annotation")
            self.assertIsInstance(item["_gw_activation"], float)
            self.assertGreater(item["_gw_activation"], 0.0,
                               f"Item {idx} _gw_activation must be positive")

    def test_broadcast_capped_at_max_items(self):
        """Broadcast must never exceed MAX_BROADCAST_ITEMS regardless of search size."""
        from pulse.brain.workspace.global_workspace import MAX_BROADCAST_ITEMS
        # Build large synthetic results to stress the cap
        large_results = {
            "results": {
                "wins": [
                    {"text": f"lesson {i}: important fact about brain architecture",
                     "confidence": 0.85, "salience": 4.0 + (i % 4)}
                    for i in range(50)
                ],
                "fails": [
                    {"text": f"failure {i}: known anti-pattern in swarm execution",
                     "confidence": 0.78, "salience": 3.5}
                    for i in range(30)
                ],
            }
        }
        result = self._run_ignition(large_results)
        self.assertLessEqual(
            len(result["items"]), MAX_BROADCAST_ITEMS,
            f"Broadcast capped at {MAX_BROADCAST_ITEMS}, got {len(result['items'])}"
        )

    def test_recurrent_inhibition_widens_activation_gap(self):
        """After RECURRENT_CYCLES, winner must dominate runner-up by > IGNITION_GAP."""
        from pulse.brain.workspace.global_workspace import (
            RECURRENT_CYCLES, INHIBITION_FACTOR, IGNITION_GAP
        )
        # Simulate with a clear winner vs runner-up
        winner = 0.75
        runner_up = 0.55
        for _ in range(RECURRENT_CYCLES):
            winner = min(1.0, winner + 0.1)    # Self-excitation
            runner_up *= INHIBITION_FACTOR       # Lateral inhibition
        gap = winner - runner_up
        self.assertGreater(
            gap, IGNITION_GAP,
            f"After {RECURRENT_CYCLES} cycles, activation gap {gap:.3f} must exceed "
            f"IGNITION_GAP={IGNITION_GAP}. Winner={winner:.3f}, runner_up={runner_up:.3f}"
        )

    def test_weak_ignition_blends_top2_when_gap_small(self):
        """When winner barely leads runner-up (gap < IGNITION_GAP), top-2 are both winners."""
        from pulse.brain.workspace import global_workspace as gw
        from pulse.brain.workspace.specialists import Coalition

        # Two nearly-equal coalitions → gap < IGNITION_GAP after cycles
        mem = Coalition("memory", [{"id": "m1", "text": "a", "confidence": 0.8}], 0.42)
        pred = Coalition("prediction", [{"id": "p1", "text": "b", "confidence": 0.75}], 0.40)

        with patch("pulse.brain.workspace.global_workspace.event_bus"), \
             patch("pulse.brain.workspace.global_workspace.compute_dynamic_threshold", return_value=0.3), \
             patch("pulse.brain.workspace.global_workspace.get_memory_coalition", return_value=mem), \
             patch("pulse.brain.workspace.global_workspace.get_prediction_coalition", return_value=pred), \
             patch("pulse.brain.workspace.global_workspace.get_dmn_coalition",
                   return_value=Coalition("dmn", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_reflex_coalition",
                   return_value=Coalition("reflex", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_neural_state_coalition",
                   return_value=Coalition("neural_state", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_working_memory_coalition",
                   return_value=Coalition("working_memory", [], 0.0)), \
             patch("pulse.brain.workspace.global_workspace.get_governance_coalition",
                   return_value=Coalition("governance", [], 0.0)):
            result = gw.run_workspace_ignition("test_agent", "blended query", {})

        # With near-equal activations after recurrent cycles the gap may still
        # exceed IGNITION_GAP; just verify both sources produced valid broadcast
        self.assertGreaterEqual(len(result["winning_sources"]), 1,
                                "At least one winner must emerge even from close competition")
        self.assertGreater(len(result["items"]), 0,
                           "Weak ignition must still produce broadcast items")

    def test_memory_coalition_activation_derived_from_real_hits(self):
        """Memory coalition activation must correlate with actual hit quality."""
        if not self.has_results:
            self.skipTest("No real brain data — skipping activation derivation check")
        from pulse.brain.workspace.specialists import get_memory_coalition
        coalition = get_memory_coalition(
            "brain first atomic writes filelock", self.search_results, "general"
        )
        # If search returned real hits, coalition should have positive activation
        real_hit_count = sum(
            len(v) for v in self.search_results["results"].values()
        )
        if real_hit_count > 0:
            self.assertGreater(coalition.activation, 0.0,
                               "Memory coalition activation must be > 0 when real hits exist")
        self.assertLessEqual(coalition.activation, 1.0,
                             "Coalition activation must be clamped to [0, 1]")

    def test_suppression_log_written_on_real_ignition(self):
        """Sacred Boundary #3: suppression audit log must be written when coalitions are rejected."""
        import tempfile
        from pathlib import Path
        from pulse.brain.workspace import global_workspace as gw
        from pulse.brain.workspace.specialists import Coalition

        # Two strong coalitions (one will be winner, other suppressed)
        mem = Coalition("memory", [{"id": "m", "text": "lesson", "confidence": 0.9}], 0.85)
        pred = Coalition("prediction", [{"id": "p", "text": "prediction"}], 0.35)

        with tempfile.TemporaryDirectory() as tmpdir:
            log_path = Path(tmpdir) / "workspace_suppression.jsonl"
            with patch("pulse.brain.workspace.global_workspace.event_bus"), \
                 patch("pulse.brain.workspace.global_workspace.compute_dynamic_threshold", return_value=0.3), \
                 patch("pulse.brain.workspace.global_workspace._SUPPRESSION_LOG", log_path), \
                 patch("pulse.brain.workspace.global_workspace.get_memory_coalition", return_value=mem), \
                 patch("pulse.brain.workspace.global_workspace.get_prediction_coalition", return_value=pred), \
                 patch("pulse.brain.workspace.global_workspace.get_dmn_coalition",
                       return_value=Coalition("dmn", [], 0.0)), \
                 patch("pulse.brain.workspace.global_workspace.get_reflex_coalition",
                       return_value=Coalition("reflex", [], 0.0)), \
                 patch("pulse.brain.workspace.global_workspace.get_neural_state_coalition",
                       return_value=Coalition("neural_state", [], 0.0)), \
                 patch("pulse.brain.workspace.global_workspace.get_working_memory_coalition",
                       return_value=Coalition("working_memory", [], 0.0)), \
                 patch("pulse.brain.workspace.global_workspace.get_governance_coalition",
                       return_value=Coalition("governance", [], 0.0)):
                gw.run_workspace_ignition("test_agent", "suppression test", {})

            if log_path.exists():
                lines = [l for l in log_path.read_text(encoding="utf-8").splitlines() if l.strip()]
                self.assertGreater(len(lines), 0,
                                   "Suppression log must record at least one entry")
                entry = json.loads(lines[0])
                self.assertIn("winners", entry)
                self.assertIn("threshold", entry)
                self.assertIn("ts", entry)


# ---------------------------------------------------------------------------
# Capability 2: Biased Competition in Attention
# ---------------------------------------------------------------------------
class TestBiasedCompetition(unittest.TestCase):
    """Boosted sources should suppress competitors."""

    def test_general_queries_skip_bias(self):
        from pulse.brain.workspace.attention import apply_attention_to_results
        results = {"results": {
            "wins": [{"text": "x", "salience": 5.0, "confidence": 0.8}],
        }}
        out = apply_attention_to_results(results, "general")
        # General should not add attention fields
        self.assertNotIn("_attention_salience", results["results"]["wins"][0])

    def test_debug_boosts_failures(self):
        from pulse.brain.workspace.attention import get_attention_weights
        weights = get_attention_weights("debug")
        self.assertGreater(weights.get("fails", 1.0), 1.0,
                           "Debug tasks should boost failure source")

    def test_non_boosted_sources_get_suppressed(self):
        from pulse.brain.workspace.attention import apply_attention_to_results, get_attention_weights
        weights = get_attention_weights("debug")
        # Find a source that is NOT boosted
        non_boosted = [s for s, w in weights.items() if w <= 1.0]
        if not non_boosted:
            self.skipTest("All sources boosted for debug — can't test suppression")
        source = non_boosted[0]
        results = {"results": {
            source: [{"text": "test", "salience": 5.0, "confidence": 0.8}],
            "fails": [{"text": "fail", "salience": 3.0, "confidence": 0.7}],
        }}
        apply_attention_to_results(results, "debug")
        hit = results["results"][source][0]
        if "_attention_salience" in hit:
            self.assertLess(hit["_attention_salience"], 5.0,
                            "Non-boosted source should have reduced salience")

    def test_inhibited_by_tag_present(self):
        from pulse.brain.workspace.attention import apply_attention_to_results, get_attention_weights
        weights = get_attention_weights("debug")
        non_boosted = [s for s, w in weights.items() if w <= 1.0]
        if not non_boosted:
            self.skipTest("No non-boosted sources for debug")
        source = non_boosted[0]
        results = {"results": {
            source: [{"text": "test", "salience": 5.0, "confidence": 0.8}],
            "fails": [{"text": "fail", "salience": 3.0, "confidence": 0.7}],
        }}
        apply_attention_to_results(results, "debug")
        hit = results["results"][source][0]
        self.assertIn("_inhibited_by", hit, "Suppressed items should tag who inhibited them")


# ---------------------------------------------------------------------------
# Capability 3: STDP Temporal Sequence Learning
# ---------------------------------------------------------------------------
class TestSTDP(unittest.TestCase):
    """Retrieval order → temporal edges in knowledge graph."""

    def test_stdp_imports(self):
        from pulse.brain.plasticity.stdp import (
            record_retrieval, apply_stdp,
            TEMPORAL_WINDOW_S, LTP_DELTA, LTD_DELTA, ANTI_CAUSAL_RATIO
        )
        self.assertEqual(TEMPORAL_WINDOW_S, 120)
        self.assertGreater(LTP_DELTA, 0)
        self.assertLess(LTD_DELTA, 0)
        self.assertGreater(ANTI_CAUSAL_RATIO, 0)

    def test_stdp_asymmetry(self):
        """Causal (A→B) should be stronger than anti-causal (B→A)."""
        from pulse.brain.plasticity.stdp import LTP_DELTA, ANTI_CAUSAL_RATIO
        # Anti-causal is LTP * ANTI_CAUSAL_RATIO, which should be weaker
        anti_causal_strength = abs(LTP_DELTA * ANTI_CAUSAL_RATIO)
        self.assertGreater(abs(LTP_DELTA), anti_causal_strength,
                           "Causal direction must be stronger than anti-causal")

    def test_record_retrieval_creates_file(self):
        from pulse.brain.plasticity.stdp import record_retrieval, SESSIONS_FILE
        # Use session ID that sorts LAST lexicographically so the 24-entry
        # cap in record_retrieval (keeps last 24 by sorted key) won't prune it.
        sid = f"zzz_test_stdp_{int(time.time())}"
        record_retrieval(sid, "item_A")
        record_retrieval(sid, "item_B")
        self.assertTrue(SESSIONS_FILE.exists())
        data = json.loads(SESSIONS_FILE.read_text(encoding="utf-8"))
        self.assertIn(sid, data)
        self.assertEqual(len(data[sid]), 2)

    def test_record_and_apply_stdp(self):
        from pulse.brain.plasticity.stdp import record_retrieval, apply_stdp
        sid = "test_apply_phase3"
        record_retrieval(sid, "X")
        record_retrieval(sid, "Y")
        record_retrieval(sid, "Z")
        # apply_stdp should run without error (may return 0 if no DB edges exist)
        result = apply_stdp(sid, True)
        self.assertIsInstance(result, int)
        self.assertGreaterEqual(result, 0)

    def test_empty_session_apply_returns_zero(self):
        from pulse.brain.plasticity.stdp import apply_stdp
        result = apply_stdp("nonexistent_session_xyz", True)
        self.assertEqual(result, 0)


# ---------------------------------------------------------------------------
# Capability 4: Hebbian Co-activation Learning
# ---------------------------------------------------------------------------
class TestHebbianKnowledge(unittest.TestCase):
    """Co-retrieved items that succeed → COACTIVATION edges."""

    def test_hebbian_constants(self):
        from pulse.brain.plasticity.hebbian_knowledge import HEBBIAN_LTP, HEBBIAN_LTD, MIN_COACT_ITEMS
        self.assertGreater(HEBBIAN_LTP, 0, "LTP must be positive")
        self.assertLess(HEBBIAN_LTD, 0, "LTD must be negative")
        self.assertGreaterEqual(MIN_COACT_ITEMS, 2, "Need at least 2 items for pairs")

    def test_record_coactivation_requires_minimum_items(self):
        from pulse.brain.plasticity.hebbian_knowledge import record_coactivation, COACT_FILE
        # Single item should be ignored
        record_coactivation(["only_one"])
        if COACT_FILE.exists():
            data = json.loads(COACT_FILE.read_text(encoding="utf-8"))
            # Should not have a session with just 1 item
            for items in data.values():
                self.assertGreaterEqual(len(items), 0)  # May exist from prior tests

    def test_record_coactivation_buffers_items(self):
        from pulse.brain.plasticity.hebbian_knowledge import record_coactivation, COACT_FILE
        record_coactivation(["item_A", "item_B", "item_C"])
        self.assertTrue(COACT_FILE.exists())
        data = json.loads(COACT_FILE.read_text(encoding="utf-8"))
        # Should have at least one session with our items
        found = False
        for session_items in data.values():
            if "item_A" in session_items and "item_B" in session_items:
                found = True
                break
        self.assertTrue(found, "Coactivation buffer should contain our items")

    def test_apply_hebbian_empty_session_returns_zero(self):
        from pulse.brain.plasticity.hebbian_knowledge import apply_hebbian
        result = apply_hebbian("nonexistent_session_key", True)
        self.assertEqual(result, 0)

    def test_coactivation_edges_accumulate_in_graph(self):
        """apply_hebbian creates COACTIVATION edges and accumulates confidence over multiple calls."""
        import sqlite3
        import tempfile
        from pathlib import Path
        from datetime import datetime, timezone

        session_key = datetime.now(timezone.utc).strftime("%Y%m%d_%H")

        # In-memory SQLite with graph_edges schema (mirrors brain_db.py)
        mem_conn = sqlite3.connect(":memory:")
        mem_conn.row_factory = sqlite3.Row
        mem_conn.execute("""
            CREATE TABLE graph_edges (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                from_node TEXT NOT NULL,
                to_node TEXT NOT NULL,
                edge_type TEXT NOT NULL,
                confidence REAL DEFAULT 0.5,
                source_node TEXT DEFAULT '',
                metadata TEXT DEFAULT '{}',
                UNIQUE(from_node, edge_type, to_node)
            )
        """)
        mem_conn.commit()

        with tempfile.TemporaryDirectory() as tmpdir:
            coact_file = Path(tmpdir) / "coactivation_buffer.json"

            with patch("pulse.brain.hebbian_knowledge.COACT_FILE", coact_file), \
                 patch("pulse.core.brain_db.get_conn", return_value=mem_conn):

                from pulse.brain.plasticity.hebbian_knowledge import (
                    record_coactivation, apply_hebbian, HEBBIAN_LTP
                )

                # Round 1: record 3 items and apply Hebbian learning
                record_coactivation(["node_alpha", "node_beta", "node_gamma"])
                count_1 = apply_hebbian(session_key, True)

                rows_r1 = mem_conn.execute(
                    "SELECT from_node, to_node, confidence FROM graph_edges "
                    "WHERE edge_type='COACTIVATION' ORDER BY from_node, to_node"
                ).fetchall()

                self.assertGreater(count_1, 0,
                                   "First Hebbian pass must report at least 1 updated edge")
                self.assertGreater(len(rows_r1), 0,
                                   "COACTIVATION edges must exist in graph_edges after apply_hebbian")

                first_row = dict(rows_r1[0])
                first_conf = first_row["confidence"]
                self.assertGreater(first_conf, 0.0,
                                   "Edge confidence must be positive after LTP (success=True)")
                self.assertAlmostEqual(first_conf, HEBBIAN_LTP, places=4,
                                       msg="Initial confidence must equal HEBBIAN_LTP for a new edge")

                # Round 2: re-record same items — buffer was cleared after round 1
                record_coactivation(["node_alpha", "node_beta", "node_gamma"])
                count_2 = apply_hebbian(session_key, True)

                row_r2 = mem_conn.execute(
                    "SELECT confidence FROM graph_edges "
                    "WHERE edge_type='COACTIVATION' AND from_node=? AND to_node=?",
                    (first_row["from_node"], first_row["to_node"])
                ).fetchone()

                self.assertIsNotNone(row_r2, "Same COACTIVATION edge must persist after second apply")
                second_conf = dict(row_r2)["confidence"]
                self.assertGreater(second_conf, first_conf,
                                   "COACTIVATION edge confidence must accumulate across "
                                   "repeated co-retrievals (Hebbian strengthening)")
                self.assertAlmostEqual(second_conf, HEBBIAN_LTP * 2, places=4,
                                       msg="Confidence after 2 rounds must equal 2 × HEBBIAN_LTP")


# ---------------------------------------------------------------------------
# Capability 5: Spaced Retrieval Scheduler
# ---------------------------------------------------------------------------
class TestSpacedRetrieval(unittest.TestCase):
    """Leitner box system for brain self-testing."""

    def test_intervals_are_expanding(self):
        from pulse.daemons.consolidation.retrieval_scheduler import INTERVALS_DAYS
        for i in range(1, len(INTERVALS_DAYS)):
            self.assertGreater(INTERVALS_DAYS[i], INTERVALS_DAYS[i - 1],
                               f"Interval {i} must be > interval {i-1}")

    def test_max_reviews_per_cycle_is_bounded(self):
        from pulse.daemons.consolidation.retrieval_scheduler import MAX_REVIEWS_PER_CYCLE
        self.assertLessEqual(MAX_REVIEWS_PER_CYCLE, 50,
                             "Review budget must be bounded to avoid consolidation slowdown")

    def test_schedule_reviews_callable(self):
        from pulse.daemons.consolidation.retrieval_scheduler import schedule_reviews
        self.assertTrue(callable(schedule_reviews))

    def test_schedule_reviews_returns_dict(self):
        from pulse.daemons.consolidation.retrieval_scheduler import schedule_reviews
        result = schedule_reviews(verbose=False)
        self.assertIsInstance(result, dict)
        self.assertIn("reviewed", result)
        self.assertIn("passed", result)
        self.assertIn("failed", result)

    def test_box_progression_logic(self):
        """Verify Leitner box intervals make sense."""
        from pulse.daemons.consolidation.retrieval_scheduler import INTERVALS_DAYS, MAX_BOX
        # Box 0 should be shortest (1 day)
        self.assertEqual(INTERVALS_DAYS[0], 1)
        # Last box should be longest
        self.assertGreaterEqual(INTERVALS_DAYS[MAX_BOX], 30)


# ---------------------------------------------------------------------------
# Capability 6: BCM Metaplasticity
# ---------------------------------------------------------------------------
class TestBCMMetaplasticity(unittest.TestCase):
    """Reinforce boost should diminish with access count."""

    def test_reconsolidation_has_bcm_formula(self):
        """reinforce() should use access_count-dependent boost."""
        import inspect
        from pulse.brain.plasticity.reconsolidation import reinforce
        source = inspect.getsource(reinforce)
        self.assertIn("access_count", source, "BCM should use access_count")
        self.assertIn("bcm_delta", source, "BCM delta variable should exist")

    def test_bcm_diminishing_returns_math(self):
        """High access count → smaller boost."""
        # BCM formula: delta = 0.3 / (1 + ac/10)
        def bcm(ac):
            return round(0.3 / (1 + ac / 10), 3)

        self.assertAlmostEqual(bcm(0), 0.3, places=2)     # New item: full boost
        self.assertAlmostEqual(bcm(10), 0.15, places=2)   # Moderate: half boost
        self.assertAlmostEqual(bcm(100), 0.027, places=2)  # Very popular: tiny boost
        # Verify monotonic decrease
        prev = bcm(0)
        for ac in [1, 5, 10, 20, 50, 100]:
            curr = bcm(ac)
            self.assertLess(curr, prev, f"BCM must decrease: ac={ac}")
            prev = curr


# ---------------------------------------------------------------------------
# Capability 7: Loss Aversion
# ---------------------------------------------------------------------------
class TestLossAversion(unittest.TestCase):
    """Failures weighted 2x successes (Kahneman-Tversky)."""

    def test_loss_aversion_ratio_constant(self):
        from pulse.core.reward.reward_signal import LOSS_AVERSION_RATIO
        self.assertEqual(LOSS_AVERSION_RATIO, 2.0)

    def test_delta_asymmetry(self):
        from pulse.core.reward.reward_signal import REINFORCE_DELTA, CONTRADICT_DELTA
        self.assertAlmostEqual(CONTRADICT_DELTA / REINFORCE_DELTA, 2.0, places=1)

    def test_assign_credit_amplifies_negative_rpe(self):
        from pulse.core.reward.reward_signal import assign_credit
        pos = assign_credit(["a", "b", "c"], 0.5)
        neg = assign_credit(["a", "b", "c"], -0.5)
        # Negative credits should be 2x magnitude of positive
        self.assertAlmostEqual(abs(neg["a"]) / abs(pos["a"]), 2.0, places=1,
                               msg="Loss aversion: negative credit should be 2x positive")

    def test_assign_credit_preserves_sign(self):
        from pulse.core.reward.reward_signal import assign_credit
        pos = assign_credit(["x"], 0.8)
        neg = assign_credit(["x"], -0.8)
        self.assertGreater(pos["x"], 0, "Positive RPE → positive credit")
        self.assertLess(neg["x"], 0, "Negative RPE → negative credit")

    def test_assign_credit_zero_rpe_returns_empty(self):
        from pulse.core.reward.reward_signal import assign_credit
        self.assertEqual(assign_credit(["a"], 0.0), {})

    def test_assign_credit_empty_items_returns_empty(self):
        from pulse.core.reward.reward_signal import assign_credit
        self.assertEqual(assign_credit([], 0.5), {})

    def test_credit_positional_decay_with_loss_aversion(self):
        from pulse.core.reward.reward_signal import assign_credit, CREDIT_DECAY
        neg = assign_credit(["a", "b", "c"], -0.5)
        # First item gets most credit, decays for subsequent
        self.assertGreater(abs(neg["a"]), abs(neg["b"]))
        self.assertGreater(abs(neg["b"]), abs(neg["c"]))


# ---------------------------------------------------------------------------
# Capability 8: Volatility-Adaptive Learning Rates
# ---------------------------------------------------------------------------
class TestVolatility(unittest.TestCase):
    """Environment volatility → learning rate modulation."""

    def test_volatility_constants(self):
        from pulse.core.reward.volatility import (
            DEFAULT_MULTIPLIER, MIN_MULTIPLIER, MAX_MULTIPLIER
        )
        self.assertEqual(DEFAULT_MULTIPLIER, 1.0)
        self.assertEqual(MIN_MULTIPLIER, 0.5)
        self.assertEqual(MAX_MULTIPLIER, 2.0)

    def test_volatility_bounds(self):
        """Min multiplier < Max multiplier."""
        from pulse.core.reward.volatility import MIN_MULTIPLIER, MAX_MULTIPLIER
        self.assertLess(MIN_MULTIPLIER, MAX_MULTIPLIER)

    def test_learning_rate_multiplier_range(self):
        """Multiplier should be bounded."""
        from pulse.core.reward.volatility import get_volatility_multiplier, MIN_MULTIPLIER, MAX_MULTIPLIER
        mult = get_volatility_multiplier()
        self.assertGreaterEqual(mult, MIN_MULTIPLIER)
        self.assertLessEqual(mult, MAX_MULTIPLIER)

    def test_compute_volatility_returns_float(self):
        from pulse.core.reward.volatility import compute_volatility
        vol = compute_volatility()
        self.assertIsInstance(vol, float)
        self.assertGreater(vol, 0)

    def test_get_volatility_multiplier_callable(self):
        from pulse.core.reward.volatility import get_volatility_multiplier
        mult = get_volatility_multiplier()
        self.assertIsInstance(mult, float)


# ---------------------------------------------------------------------------
# Capability 9: Session-Based Replay
# ---------------------------------------------------------------------------
class TestSessionReplay(unittest.TestCase):
    """Hippocampal replay of actual retrieval sequences."""

    def test_session_replay_imports(self):
        from pulse.daemons.consolidation.session_replay import (
            run_session_replay, MAX_SESSIONS_PER_CYCLE, MAX_ITEMS_PER_REPLAY,
            NEGATIVE_SAL_DELTA
        )
        self.assertGreater(MAX_SESSIONS_PER_CYCLE, 0)
        self.assertGreater(MAX_ITEMS_PER_REPLAY, 0)
        self.assertGreater(NEGATIVE_SAL_DELTA, 0)

    def test_run_session_replay_returns_dict(self):
        from pulse.daemons.consolidation.session_replay import run_session_replay
        result = run_session_replay(verbose=False)
        self.assertIsInstance(result, dict)
        self.assertIn("replayed", result)

    def test_empty_completed_dir_returns_zero(self):
        from pulse.daemons.consolidation.session_replay import run_session_replay
        result = run_session_replay(verbose=False)
        # If no sessions exist, should gracefully return 0
        self.assertGreaterEqual(result["replayed"], 0)

    def test_negative_sal_delta_is_gentle(self):
        """Negative replay should not be catastrophic."""
        from pulse.daemons.consolidation.session_replay import NEGATIVE_SAL_DELTA
        self.assertLessEqual(NEGATIVE_SAL_DELTA, 0.3,
                             "Negative replay delta must be gentle (<=0.3)")

    def test_was_session_successful_default(self):
        """No task_id → assume success (neutral replay)."""
        from pulse.daemons.consolidation.session_replay import _was_session_successful
        self.assertTrue(_was_session_successful(None))
        self.assertTrue(_was_session_successful(""))

    def test_was_session_successful_unknown_task_id_defaults_true(self):
        """Task ID not found on board → defaults True (archived/evicted tasks replay as success)."""
        from pulse.daemons.consolidation.session_replay import _was_session_successful
        result = _was_session_successful("nonexistent_task_id_xyz_never_exists_abc123")
        self.assertTrue(result,
                        "Unknown task_id must default to True — archived failed tasks "
                        "will be replayed as successes; caller must be aware of this bias")

    def test_stdp_wired_into_session_replay(self):
        """Session replay must call STDP for temporal edge learning."""
        import inspect
        from pulse.daemons.consolidation.session_replay import run_session_replay
        src = inspect.getsource(run_session_replay)
        self.assertIn("record_retrieval", src,
                      "run_session_replay must call record_retrieval (STDP)")
        self.assertIn("apply_stdp", src,
                      "run_session_replay must call apply_stdp for temporal edges")

    def test_hebbian_wired_into_session_replay(self):
        """Session replay must call Hebbian for co-activation edge learning."""
        import inspect
        from pulse.daemons.consolidation.session_replay import run_session_replay
        src = inspect.getsource(run_session_replay)
        self.assertIn("record_coactivation", src,
                      "run_session_replay must call record_coactivation (Hebbian)")
        self.assertIn("apply_hebbian", src,
                      "run_session_replay must call apply_hebbian for co-activation edges")

    def test_stdp_hebbian_calls_are_fault_tolerant(self):
        """STDP and Hebbian failures must not crash the replay engine."""
        import inspect
        from pulse.daemons.consolidation import session_replay as sr_mod
        src = inspect.getsource(sr_mod)
        # Both calls must be inside try/except blocks
        self.assertIn("STDP replay failed", src,
                      "STDP block must catch exceptions to keep replay alive")
        self.assertIn("Hebbian replay failed", src,
                      "Hebbian block must catch exceptions to keep replay alive")

    def test_stdp_temporal_window_covers_replay_speed(self):
        """STDP temporal window must be large enough to catch all replay items
        (replay timestamps are current-time, so all items fall within ~1 second)."""
        from pulse.brain.plasticity.stdp import TEMPORAL_WINDOW_S
        self.assertGreaterEqual(TEMPORAL_WINDOW_S, 10,
                                "TEMPORAL_WINDOW_S must be >= 10s to capture replay sequences "
                                "recorded with current timestamps (replay is fast, all ~0s apart)")

    def test_hebbian_min_items_compatible_with_replay(self):
        """MAX_ITEMS_PER_REPLAY must satisfy Hebbian MIN_COACT_ITEMS threshold."""
        from pulse.daemons.consolidation.session_replay import MAX_ITEMS_PER_REPLAY
        from pulse.brain.plasticity.hebbian_knowledge import MIN_COACT_ITEMS
        self.assertGreater(MAX_ITEMS_PER_REPLAY, MIN_COACT_ITEMS,
                           "MAX_ITEMS_PER_REPLAY must exceed MIN_COACT_ITEMS "
                           "or Hebbian learning will never trigger from replay")

    def test_replay_items_sorted_before_stdp(self):
        """Items must be sorted by first_accessed before STDP recording
        so causal direction A→B reflects original retrieval order."""
        import inspect
        from pulse.daemons.consolidation import session_replay as sr_mod
        src = inspect.getsource(sr_mod.run_session_replay)
        self.assertIn("first_accessed", src,
                      "run_session_replay must sort items by first_accessed "
                      "to preserve temporal ordering for STDP causal direction")

    def test_stdp_ltp_stronger_than_ltd(self):
        """LTP must be stronger than LTD for net positive learning signal."""
        from pulse.brain.plasticity.stdp import LTP_DELTA, LTD_DELTA
        self.assertGreater(abs(LTP_DELTA), abs(LTD_DELTA),
                           "LTP_DELTA magnitude must exceed LTD_DELTA — "
                           "successful replay should reinforce more than failure weakens")

    def test_hebbian_ltp_stronger_than_ltd(self):
        """Hebbian LTP must be stronger than LTD for net positive signal."""
        from pulse.brain.plasticity.hebbian_knowledge import HEBBIAN_LTP, HEBBIAN_LTD
        self.assertGreater(abs(HEBBIAN_LTP), abs(HEBBIAN_LTD),
                           "HEBBIAN_LTP magnitude must exceed HEBBIAN_LTD")

    def test_stdp_anti_causal_ratio_bounded(self):
        """Anti-causal signal must be weaker than causal (asymmetry preserved)."""
        from pulse.brain.plasticity.stdp import ANTI_CAUSAL_RATIO
        self.assertGreater(ANTI_CAUSAL_RATIO, 0,
                           "Anti-causal ratio must be positive")
        self.assertLess(ANTI_CAUSAL_RATIO, 1.0,
                        "Anti-causal ratio must be < 1.0 — B→A weaker than A→B")


# ---------------------------------------------------------------------------
# Capability 10: Conflict Monitor
# ---------------------------------------------------------------------------
class TestConflictMonitor(unittest.TestCase):
    """ACC-style contradiction detection."""

    def test_conflict_monitor_imports(self):
        from pulse.daemons.synthesis.core.conflict_monitor import (
            scan_for_conflicts, NEGATION_WORDS, AFFIRMATION_WORDS
        )
        self.assertGreater(len(NEGATION_WORDS), 5)
        self.assertGreater(len(AFFIRMATION_WORDS), 5)

    def test_negation_affirmation_no_overlap(self):
        from pulse.daemons.synthesis.core.conflict_monitor import NEGATION_WORDS, AFFIRMATION_WORDS
        overlap = NEGATION_WORDS & AFFIRMATION_WORDS
        self.assertEqual(len(overlap), 0,
                         f"Negation and affirmation sets must not overlap: {overlap}")

    def test_scan_returns_dict(self):
        from pulse.daemons.synthesis.core.conflict_monitor import scan_for_conflicts
        result = scan_for_conflicts(verbose=False)
        self.assertIsInstance(result, dict)
        self.assertIn("status", result)

    def test_conflict_detection_logic(self):
        """Verify negation/affirmation signal detection."""
        from pulse.daemons.synthesis.core.conflict_monitor import NEGATION_WORDS, AFFIRMATION_WORDS
        # Negation text
        neg_text = "never use shutil.move on windows it is wrong and broken"
        neg_words = set(neg_text.split())
        self.assertTrue(bool(neg_words & NEGATION_WORDS))
        # Affirmation text
        aff_text = "always use os.replace it works correct and proper"
        aff_words = set(aff_text.split())
        self.assertTrue(bool(aff_words & AFFIRMATION_WORDS))
        # Contradiction: one negates, other affirms
        is_contradiction = (bool(neg_words & NEGATION_WORDS) and
                            bool(aff_words & AFFIRMATION_WORDS))
        self.assertTrue(is_contradiction)

    def test_conflict_detection_on_real_db_data(self):
        """End-to-end: inject a known contradiction into the real SQLite DB,
        run scan_for_conflicts with a wide enough window, confirm it is found,
        then clean up. Validates detection on real brain infrastructure."""
        from datetime import datetime, timezone, timedelta
        from pulse.core.brain_db import get_conn, init_db
        from pulse.core.brain_utils import text_similarity
        from pulse.daemons.synthesis.core.conflict_monitor import (
            NEGATION_WORDS, AFFIRMATION_WORDS, scan_for_conflicts
        )

        init_db()
        conn = get_conn()

        # Inject a controlled pair: old affirming item + new negating item for same domain
        old_ts = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
        new_ts = (datetime.now(timezone.utc) - timedelta(minutes=30)).isoformat()
        old_id = "test_conflict_OLD_aff_001"
        new_id = "test_conflict_NEW_neg_001"
        old_text = "always use filelock for task board writes to prevent race conditions"
        new_text = "never use filelock for task board writes it causes deadlocks"

        try:
            with conn:
                conn.execute("""
                    INSERT OR REPLACE INTO lessons
                    (id, fingerprint, type, content, title, domain, agent, timestamp,
                     salience, confidence, validity, tags, cross_refs, provenance, metadata)
                    VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
                """, (old_id, f"fp_{old_id}", "lesson", old_text, old_text[:60],
                      "testing", "test_agent", old_ts, 1.5, 0.8, "valid",
                      "[]", "[]", "{}", "{}"))
                conn.execute("""
                    INSERT OR REPLACE INTO lessons
                    (id, fingerprint, type, content, title, domain, agent, timestamp,
                     salience, confidence, validity, tags, cross_refs, provenance, metadata)
                    VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
                """, (new_id, f"fp_{new_id}", "lesson", new_text, new_text[:60],
                      "testing", "test_agent", new_ts, 1.5, 0.8, "valid",
                      "[]", "[]", "{}", "{}"))

            # Verify text_similarity is above threshold between the two
            sim = text_similarity(old_text[:80], new_text[:80])
            self.assertGreaterEqual(sim, 0.4, f"Test pair similarity too low: {sim:.3f}")

            # Verify signal polarity
            old_words = set(old_text.lower().split())
            new_words = set(new_text.lower().split())
            self.assertTrue(bool(old_words & AFFIRMATION_WORDS), "Old item must have affirmation signal")
            self.assertTrue(bool(new_words & NEGATION_WORDS), "New item must have negation signal")

            # Run scan — should detect the injected contradiction
            result = scan_for_conflicts(verbose=False)
            self.assertIsInstance(result, dict)
            self.assertEqual(result["status"], "ok")
            self.assertGreaterEqual(result["conflicts"], 1,
                                    "Conflict monitor failed to detect known contradiction on real DB data")

        finally:
            with conn:
                conn.execute("DELETE FROM lessons WHERE id IN (?, ?)", (old_id, new_id))


# ---------------------------------------------------------------------------
# Cross-Capability Integration Tests
# ---------------------------------------------------------------------------
class TestPhase3Integration(unittest.TestCase):
    """Cross-capability wiring verification."""

    def test_consolidation_has_all_new_stages(self):
        """Consolidation daemon should include all Phase 3 stages."""
        import inspect
        from pulse.daemons.consolidation.consolidation_daemon import run_consolidation
        source = inspect.getsource(run_consolidation)
        self.assertIn("session_replay", source, "Stage 19 session replay missing")
        self.assertIn("conflict_monitor", source, "Stage 20 conflict monitor missing")
        self.assertIn("schedule_reviews", source, "Stage 21 spaced retrieval missing")

    def test_brain_search_records_coactivation(self):
        """brain_search should wire into hebbian recording."""
        import inspect
        from pulse.brain.brain_search import _record_retrievals
        source = inspect.getsource(_record_retrievals)
        self.assertIn("hebbian_knowledge", source,
                       "brain_search must wire hebbian co-activation recording")
        self.assertIn("record_coactivation", source)

    def test_reward_signal_wires_hebbian(self):
        """reward_signal should trigger hebbian learning on task outcome."""
        import inspect
        from pulse.core.reward.reward_signal import record_task_outcome
        source = inspect.getsource(record_task_outcome)
        self.assertIn("hebbian_knowledge", source,
                       "reward_signal must wire hebbian learning")
        self.assertIn("apply_hebbian", source)

    def test_reward_signal_wires_stdp(self):
        """reward_signal should trigger STDP on task outcome."""
        import inspect
        from pulse.core.reward.reward_signal import record_task_outcome
        source = inspect.getsource(record_task_outcome)
        self.assertIn("apply_stdp", source,
                       "reward_signal must wire STDP temporal learning")

    def test_reward_signal_wires_volatility(self):
        """reward_signal should use volatility-adaptive LR."""
        import inspect
        from pulse.core.reward.reward_signal import propagate_reward
        source = inspect.getsource(propagate_reward)
        self.assertIn("volatility", source,
                       "propagate_reward must use volatility multiplier")

    def test_all_10_capabilities_importable(self):
        """Every Phase 3 capability module must import cleanly."""
        modules = [
            "pulse.brain.workspace.global_workspace",
            "pulse.brain.workspace.attention",
            "pulse.brain.stdp",
            "pulse.brain.hebbian_knowledge",
            "pulse.daemons.consolidation.retrieval_scheduler",
            "pulse.brain.reconsolidation",
            "pulse.core.reward.reward_signal",
            "pulse.core.reward.volatility",
            "pulse.daemons.consolidation.session_replay",
            "pulse.daemons.synthesis.core.conflict_monitor",
        ]
        for mod in modules:
            try:
                __import__(mod)
            except Exception as e:
                self.fail(f"Failed to import {mod}: {e}")

    def test_session_replay_hebbian_key_matches_record_coactivation_format(self):
        """session_replay's session_key passed to apply_hebbian must use the same
        strftime format as record_coactivation's internal bucket key ('%Y%m%d_%H').
        A prefix mismatch (e.g. 'search_20260317_22' vs '20260317_22') causes
        apply_hebbian to look up a non-existent key — Hebbian never fires."""
        import inspect, re
        from pulse.daemons.consolidation.session_replay import run_session_replay
        from pulse.brain.plasticity.hebbian_knowledge import record_coactivation

        src_replay = inspect.getsource(run_session_replay)
        src_record = inspect.getsource(record_coactivation)

        FORMAT = "%Y%m%d_%H"
        self.assertIn(FORMAT, src_replay,
                      "session_replay must use '%Y%m%d_%H' format for Hebbian session_key "
                      "to match record_coactivation's internal bucket key")
        self.assertIn(FORMAT, src_record,
                      "record_coactivation must use '%Y%m%d_%H' hourly bucket key")

    def test_stdp_key_in_session_replay_uses_replay_prefix(self):
        """session_replay must use 'replay_' prefix for STDP session IDs.
        This prevents cross-contamination with live search sessions which use
        the 'search_' prefix from brain_search.py."""
        import inspect
        from pulse.daemons.consolidation.session_replay import run_session_replay
        src = inspect.getsource(run_session_replay)
        self.assertIn("replay_", src,
                      "STDP session key in session_replay must use 'replay_' prefix "
                      "— live searches use 'search_' prefix, replay must be separate")

    def test_reward_signal_hebbian_key_no_search_prefix(self):
        """reward_signal's Hebbian session key must NOT use the same 'search_' prefixed
        variable as STDP. record_coactivation stores under '{%Y%m%d_%H}' (no prefix),
        so apply_hebbian must receive the unprefixed hourly key.
        Bug fixed: reward_signal now uses separate stdp_session_id and hebb_session_key."""
        import inspect
        from pulse.core.reward import reward_signal as rs_mod

        src = inspect.getsource(rs_mod.record_task_outcome)

        self.assertIn("stdp_session_id", src,
                      "reward_signal must use a separate stdp_session_id variable "
                      "with 'search_' prefix for STDP (matches brain_search STDP key)")
        self.assertIn("hebb_session_key", src,
                      "reward_signal must use a separate hebb_session_key variable "
                      "without 'search_' prefix (matches record_coactivation bucket key)")

    def test_session_replay_end_to_end_with_mock_items(self):
        """session_replay must call both STDP record_retrieval and record_coactivation
        when a session with resolved items is replayed. Uses patched _resolve_item_ids
        to bypass DB dependency."""
        import json, tempfile
        from pathlib import Path
        from unittest.mock import patch

        session_data = {
            "session_id": "s_test_e2e_001",
            "task_id": None,
            "items": [
                {"text": "use filelock for atomic brain writes",
                 "first_accessed": "2026-03-17T10:00:00+00:00"},
                {"text": "brain first before every implementation",
                 "first_accessed": "2026-03-17T10:00:30+00:00"},
                {"text": "silent compliance agents do not print laws",
                 "first_accessed": "2026-03-17T10:01:00+00:00"},
            ]
        }

        stdp_calls = []
        coact_calls = []

        with tempfile.TemporaryDirectory() as tmpdir:
            completed_dir = Path(tmpdir) / "completed"
            completed_dir.mkdir()
            (completed_dir / "session_e2e_001.json").write_text(
                json.dumps(session_data), encoding="utf-8")

            with patch("pulse.daemons.consolidation.session_replay.COMPLETED_DIR", completed_dir), \
                 patch("pulse.daemons.consolidation.session_replay._resolve_item_ids",
                       return_value=["item_X", "item_Y", "item_Z"]), \
                 patch("pulse.daemons.consolidation.session_replay._positive_replay",
                       return_value=3), \
                 patch("pulse.brain.stdp.record_retrieval",
                       side_effect=lambda sid, iid: stdp_calls.append((sid, iid))), \
                 patch("pulse.brain.stdp.apply_stdp", return_value=2), \
                 patch("pulse.brain.hebbian_knowledge.record_coactivation",
                       side_effect=lambda ids: coact_calls.append(list(ids))), \
                 patch("pulse.brain.hebbian_knowledge.apply_hebbian", return_value=1):

                from pulse.daemons.consolidation.session_replay import run_session_replay
                result = run_session_replay(verbose=False)

        self.assertGreater(result["replayed"], 0, "Must have replayed at least 1 session")
        self.assertGreater(len(stdp_calls), 0, "STDP record_retrieval must be called per item")
        self.assertGreater(len(coact_calls), 0, "Hebbian record_coactivation must be called")

        # All STDP calls must use replay_ prefix (not search_)
        for sid, _ in stdp_calls:
            self.assertTrue(sid.startswith("replay_"),
                            f"STDP session ID must start with 'replay_', got: '{sid}'")

        # Coactivation must include all resolved item IDs
        all_coact_ids = [iid for batch in coact_calls for iid in batch]
        for expected_id in ["item_X", "item_Y", "item_Z"]:
            self.assertIn(expected_id, all_coact_ids,
                          f"item '{expected_id}' must appear in Hebbian coactivation call")


# ---------------------------------------------------------------------------
# Edge Case Tests
# ---------------------------------------------------------------------------
class TestPhase3EdgeCases(unittest.TestCase):
    """Edge cases that could break the new mechanisms."""

    def test_stdp_self_loop_prevention(self):
        """STDP should not create A→A edges when applied."""
        from pulse.brain.plasticity.stdp import record_retrieval, apply_stdp
        sid = "selfloop_test_p3"
        record_retrieval(sid, "same_item")
        record_retrieval(sid, "same_item")
        # apply_stdp should handle duplicates gracefully
        result = apply_stdp(sid, True)
        self.assertIsInstance(result, int)

    def test_hebbian_canonical_order(self):
        """Hebbian edges always store with lexicographically smaller ID as from_node."""
        import sqlite3
        from pulse.brain.plasticity.hebbian_knowledge import _upsert_coact_edge, HEBBIAN_LTP

        # Build minimal in-memory graph_edges table (mirrors brain_db.py schema)
        mem_conn = sqlite3.connect(":memory:")
        mem_conn.row_factory = sqlite3.Row
        mem_conn.execute("""
            CREATE TABLE graph_edges (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                from_node TEXT NOT NULL,
                to_node TEXT NOT NULL,
                edge_type TEXT NOT NULL,
                confidence REAL DEFAULT 0.5,
                source_node TEXT DEFAULT '',
                metadata TEXT DEFAULT '{}',
                UNIQUE(from_node, edge_type, to_node)
            )
        """)
        mem_conn.commit()

        # Call with REVERSED order: larger ID first
        _upsert_coact_edge(mem_conn, "zzz_node", "aaa_node", HEBBIAN_LTP)
        mem_conn.commit()

        rows = mem_conn.execute(
            "SELECT from_node, to_node FROM graph_edges WHERE edge_type='COACTIVATION'"
        ).fetchall()
        self.assertEqual(len(rows), 1, "Exactly one edge must be created")
        row = dict(rows[0])
        self.assertEqual(row["from_node"], "aaa_node",
                         "from_node must be the lexicographically smaller ID regardless of call order")
        self.assertEqual(row["to_node"], "zzz_node",
                         "to_node must be the lexicographically larger ID regardless of call order")

        # Call again with NORMAL order — must hit the same edge, not create a duplicate
        _upsert_coact_edge(mem_conn, "aaa_node", "zzz_node", HEBBIAN_LTP)
        mem_conn.commit()

        rows2 = mem_conn.execute(
            "SELECT from_node, to_node, confidence FROM graph_edges WHERE edge_type='COACTIVATION'"
        ).fetchall()
        self.assertEqual(len(rows2), 1,
                         "Canonical ordering must deduplicate: reversed + normal calls = 1 edge")
        self.assertAlmostEqual(dict(rows2[0])["confidence"], HEBBIAN_LTP * 2, places=4,
                               msg="Confidence must accumulate: two LTP calls = 2 × HEBBIAN_LTP")

    def test_volatility_cold_start(self):
        """With no data files, volatility must default to exactly 1.0 (DEFAULT_MULTIPLIER)."""
        import tempfile
        from pathlib import Path
        from unittest.mock import patch, MagicMock
        import pulse.core.reward.volatility as vol_mod

        with tempfile.TemporaryDirectory() as tmpdir:
            tmp = Path(tmpdir)
            empty_vol_file = tmp / "volatility_state.json"
            empty_reward_file = tmp / "reward_signals.json"
            empty_lessons_file = tmp / "auto_lessons.jsonl"

            # Mock DB conn that raises so signal 3 (domain novelty) is skipped
            mock_conn = MagicMock()
            mock_conn.execute.side_effect = Exception("no db on cold start")

            # Patch all three signal data sources to empty paths
            with patch("pulse.core.reward.volatility.VOLATILITY_FILE", empty_vol_file), \
                 patch("pulse.core.reward.reward_signal.REWARD_SIGNALS_FILE", empty_reward_file), \
                 patch("pulse.core.brain_utils.LESSONS_JSONL", empty_lessons_file), \
                 patch("pulse.core.brain_db.get_conn", return_value=mock_conn):
                mult = vol_mod.compute_volatility()

        self.assertEqual(mult, vol_mod.DEFAULT_MULTIPLIER,
                         f"Cold start must return DEFAULT_MULTIPLIER=1.0, got {mult}")

    def test_volatility_state_after_10_rpe_recordings(self):
        """After 10 RPE recordings, state file must include RPE variance signal."""
        import json
        import tempfile
        from pathlib import Path
        from unittest.mock import patch

        # 10 alternating success/failure recordings → high variance RPE
        recordings = []
        for i in range(10):
            success = bool(i % 2)
            recordings.append({
                "task_id": f"t_test_{i}",
                "agent": "test_agent",
                "success": success,
                "domain": "testing",
                "rpe_delta": 0.5 if success else -0.5,
                "items_credited": {},
                "timestamp": f"2026-03-17T10:{i:02d}:00+00:00",
            })

        with tempfile.TemporaryDirectory() as tmpdir:
            tmp = Path(tmpdir)
            reward_file = tmp / "reward_signals.json"
            vol_file = tmp / "volatility_state.json"
            reward_file.write_text(json.dumps(recordings), encoding="utf-8")

            import pulse.core.reward.volatility as vol_mod
            with patch("pulse.core.reward.reward_signal.REWARD_SIGNALS_FILE", reward_file), \
                 patch("pulse.core.reward.volatility.VOLATILITY_FILE", vol_file):
                vol_mod.compute_volatility()

            self.assertTrue(vol_file.exists(),
                            "volatility_state.json must be written after 10 RPE recordings")
            state = json.loads(vol_file.read_text(encoding="utf-8"))

            self.assertIn("multiplier", state)
            self.assertIn("signals", state)
            self.assertIn("computed_at", state)
            # RPE variance signal fires when >= 5 entries — must appear
            self.assertGreater(len(state["signals"]), 0,
                               "RPE variance signal must fire with 10 recordings (threshold=5)")
            self.assertGreaterEqual(state["multiplier"], 0.5)
            self.assertLessEqual(state["multiplier"], 2.0)

    def test_loss_aversion_does_not_overflow(self):
        """Large RPE with loss aversion should not produce unbounded credits."""
        from pulse.core.reward.reward_signal import assign_credit
        credits = assign_credit(["a"], -1.0)  # Max negative RPE
        # With LOSS_AVERSION_RATIO=2.0, magnitude=2.0, credit=-2.0
        self.assertLessEqual(abs(credits["a"]), 3.0,
                             "Credit should not be unbounded even with max RPE + loss aversion")

    def test_biased_competition_empty_results(self):
        """Biased competition should handle empty result sets."""
        from pulse.brain.workspace.attention import apply_attention_to_results
        empty = {"results": {}}
        result = apply_attention_to_results(empty, "debug")
        self.assertEqual(result["results"], {})

    def test_conflict_monitor_short_text_skipped(self):
        """Items with very short text should be skipped (< 20 chars)."""
        from pulse.daemons.synthesis.core.conflict_monitor import NEGATION_WORDS
        short_text = "hi"
        # Verify our 20-char threshold would skip this
        self.assertLess(len(short_text), 20)

    def test_session_replay_max_bounds(self):
        """Session replay constants should be reasonable."""
        from pulse.daemons.consolidation.session_replay import (
            MAX_SESSIONS_PER_CYCLE, MAX_ITEMS_PER_REPLAY
        )
        self.assertLessEqual(MAX_SESSIONS_PER_CYCLE, 20)
        self.assertLessEqual(MAX_ITEMS_PER_REPLAY, 30)

    def test_spaced_retrieval_box_bounds(self):
        """Box index should never go below 0 or above MAX_BOX."""
        from pulse.daemons.consolidation.retrieval_scheduler import INTERVALS_DAYS, MAX_BOX
        self.assertEqual(MAX_BOX, len(INTERVALS_DAYS) - 1)
        self.assertGreater(MAX_BOX, 0)


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