#!/usr/bin/env python3
"""Tests for Phase 4 Architectural Shifts.

Covers all 6 implemented architectures:
1. RPE Prompt Adaptation (learned boot prompt)
2. Hierarchical Predictive Search (source-relevance predictions)
3. Procedural Cache (model-free habit system)
4. Critical Periods (domain-age plasticity)
5. Evidence Accumulation (DDM for agent-task assignment)
6. Multi-level Abstraction (meta-schemas + principles)
Plus: Graph SQLite Unification + Dedup Fix
"""
import unittest
import json
import math
import sys
import uuid
from pathlib import Path
from datetime import datetime, timezone

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


# ---------------------------------------------------------------------------
# Architecture 1: RPE Prompt Adaptation
# ---------------------------------------------------------------------------
class TestRPEPromptAdaptation(unittest.TestCase):
    """Boot prompt adapts from accumulated reward credit."""

    def test_learned_prompt_imports(self):
        from pulse.brain.metacognition.learned_prompt import (
            prioritize_by_credit, get_learned_weights,
            update_learned_weights, update_from_credits,
            MIN_DATA_POINTS, BLEND_RATIO
        )
        self.assertGreater(MIN_DATA_POINTS, 0)
        self.assertGreater(BLEND_RATIO, 0)
        self.assertLess(BLEND_RATIO, 1.0)

    def test_cold_start_returns_none(self):
        from pulse.brain.metacognition.learned_prompt import get_learned_weights
        result = get_learned_weights("nonexistent_task_type")
        self.assertIsNone(result, "Cold start should return None")

    def test_cold_start_corrupt_json_returns_none(self):
        """Corrupt JSON must return None, not raise."""
        import tempfile
        from unittest.mock import patch
        from pulse.brain import learned_prompt
        tmp = Path(tempfile.mktemp(suffix=".json"))
        tmp.write_bytes(b"{ not valid json }")
        try:
            with patch.object(learned_prompt, "LEARNED_WEIGHTS_FILE", tmp):
                result = learned_prompt.get_learned_weights("debug")
            self.assertIsNone(result, "Corrupt JSON should return None gracefully")
        finally:
            tmp.unlink(missing_ok=True)

    def test_cold_start_below_min_data_points_returns_none(self):
        """File with data below MIN_DATA_POINTS must return None."""
        import tempfile
        from unittest.mock import patch
        from pulse.brain import learned_prompt
        data = {"debug": {"_data_points": 1, "_last_updated": None, "wins": 1.2}}
        tmp = Path(tempfile.mktemp(suffix=".json"))
        tmp.write_text(json.dumps(data), encoding="utf-8")
        try:
            with patch.object(learned_prompt, "LEARNED_WEIGHTS_FILE", tmp):
                result = learned_prompt.get_learned_weights("debug")
            self.assertIsNone(result, "Below MIN_DATA_POINTS should return None")
        finally:
            tmp.unlink(missing_ok=True)

    def test_cold_start_passthrough(self):
        from pulse.brain.metacognition.learned_prompt import prioritize_by_credit
        items = [{"id": "a"}, {"id": "b"}, {"id": "c"}]
        result = prioritize_by_credit(items, 10)
        self.assertEqual(len(result), 3)

    def test_update_learned_weights_creates_file(self):
        from pulse.brain.metacognition.learned_prompt import (
            update_learned_weights, LEARNED_WEIGHTS_FILE
        )
        update_learned_weights("test_type", {"wins": 0.5, "fails": -0.3})
        self.assertTrue(LEARNED_WEIGHTS_FILE.exists())
        data = json.loads(LEARNED_WEIGHTS_FILE.read_text(encoding="utf-8"))
        self.assertIn("test_type", data)
        self.assertIn("_data_points", data["test_type"])

    def test_attention_blends_learned_weights(self):
        """Attention should blend static + learned when data exists."""
        import inspect
        from pulse.brain.workspace.attention import get_attention_weights
        src = inspect.getsource(get_attention_weights)
        self.assertIn("learned_prompt", src,
                       "Attention must import learned_prompt for blending")
        self.assertIn("BLEND_RATIO", src)

    def test_reward_signal_wires_update(self):
        import inspect
        from pulse.core.reward.reward_signal import record_task_outcome
        src = inspect.getsource(record_task_outcome)
        self.assertIn("update_from_credits", src,
                       "Reward signal must call update_from_credits")

    def test_static_weights_still_work(self):
        """On cold start, static weights should be unchanged."""
        from pulse.brain.workspace.attention import get_attention_weights
        w = get_attention_weights("debug")
        self.assertGreaterEqual(w["fails"], 1.5, "Debug should boost fails")


# ---------------------------------------------------------------------------
# Architecture 4: Procedural Cache
# ---------------------------------------------------------------------------
class TestProceduralCache(unittest.TestCase):
    """Model-free habit system for familiar tasks."""

    def test_procedural_cache_imports(self):
        from pulse.brain.memory.procedural_cache import (
            compute_signature, record_procedure, lookup_procedure,
            record_failure, extract_tool_sequence,
            MIN_SUCCESSES, MAX_CACHE_ENTRIES
        )
        self.assertGreater(MIN_SUCCESSES, 0)
        self.assertGreater(MAX_CACHE_ENTRIES, 0)

    def test_signature_is_deterministic(self):
        from pulse.brain.memory.procedural_cache import compute_signature
        s1 = compute_signature("arch", "Split file into subfolder")
        s2 = compute_signature("arch", "Split file into subfolder")
        self.assertEqual(s1, s2, "Same input must produce same signature")

    def test_signature_differs_by_domain(self):
        from pulse.brain.memory.procedural_cache import compute_signature
        s1 = compute_signature("arch", "Fix the bug")
        s2 = compute_signature("testing", "Fix the bug")
        self.assertNotEqual(s1, s2, "Different domains should produce different signatures")

    def test_lookup_requires_min_successes(self):
        from pulse.brain.memory.procedural_cache import (
            compute_signature, record_procedure, lookup_procedure, MIN_SUCCESSES
        )
        uid = uuid.uuid4().hex[:8]
        seq = [{"tool": "read", "args_pattern": {}}, {"tool": "write", "args_pattern": {}}]
        # Record once — should not be enough
        record_procedure(f"test_{uid}", f"task_{uid}", "", seq)
        result = lookup_procedure(f"test_{uid}", f"task_{uid}")
        self.assertIsNone(result, "Should not return procedure with < MIN_SUCCESSES hits")

    def test_extract_tool_sequence_filters_non_tools(self):
        from pulse.brain.memory.procedural_cache import extract_tool_sequence
        events = [
            {"type": "tool_call", "tool": "read_file", "arguments": {"path": "test.py"}},
            {"type": "reasoning", "content": "thinking..."},
            {"type": "tool_call", "tool": "edit_file", "arguments": {"path": "test.py", "content": "code"}},
        ]
        seq = extract_tool_sequence(events)
        self.assertEqual(len(seq), 2, "Should only extract tool_call events")

    def test_arg_generalization(self):
        from pulse.brain.memory.procedural_cache import extract_tool_sequence
        events = [
            {"type": "tool_call", "tool": "read_file",
             "arguments": {"path": "pulse/core/paths.py"}},
        ]
        seq = extract_tool_sequence(events)
        self.assertEqual(seq[0]["args_pattern"]["path"], "{file_path}",
                         "File paths should be generalized to {file_path}")

    def test_extract_tool_sequence_worker_args_format(self):
        """Worker events use 'args' key (not 'arguments') — must be handled correctly."""
        from pulse.brain.memory.procedural_cache import extract_tool_sequence
        events = [
            {"type": "tool_call", "tool": "read_file", "args": {"path": "pulse/brain/brain_query.py"}},
            {"type": "reasoning", "content": "thinking..."},
            {"type": "tool_call", "tool": "edit_file", "args": {"path": "pulse/brain/attention.py", "old_text": "x", "new_text": "y"}},
        ]
        seq = extract_tool_sequence(events)
        self.assertEqual(len(seq), 2, "Worker 'args' format must extract 2 tool_call events")
        self.assertEqual(seq[0]["args_pattern"]["path"], "{file_path}",
                         "Worker 'args' paths should be generalized to {file_path}")

    def test_save_session_calls_procedural_cache(self):
        """save_session_json must invoke procedural_cache on both success and failure."""
        import inspect
        from pulse.workers.worker_tools import save_session_json
        src = inspect.getsource(save_session_json)
        self.assertIn("procedural_cache", src,
                      "save_session_json must import from procedural_cache")
        self.assertIn("record_procedure", src,
                      "save_session_json must call record_procedure on success")
        self.assertIn("record_failure", src,
                      "save_session_json must call record_failure on failure")
        self.assertIn("extract_tool_sequence", src,
                      "save_session_json must extract tool sequence from events")

    def test_failure_recording(self):
        from pulse.brain.memory.procedural_cache import record_failure
        # Should not crash
        record_failure("test_domain", "test_title")

    def test_too_short_sequence_rejected(self):
        from pulse.brain.memory.procedural_cache import record_procedure
        result = record_procedure("d", "t", "", [{"tool": "x"}])
        self.assertFalse(result, "Single-tool sequences should be rejected")


# ---------------------------------------------------------------------------
# Architecture 5: Critical Periods
# ---------------------------------------------------------------------------
class TestCriticalPeriods(unittest.TestCase):
    """Domain-age sigmoid plasticity on reconsolidation."""

    def test_plasticity_constants(self):
        from pulse.brain.plasticity.reconsolidation import (
            CRITICAL_PERIOD_MIDPOINT, CRITICAL_PERIOD_STEEPNESS, MIN_PLASTICITY
        )
        self.assertGreater(CRITICAL_PERIOD_MIDPOINT, 0)
        self.assertGreater(CRITICAL_PERIOD_STEEPNESS, 0)
        self.assertGreater(MIN_PLASTICITY, 0)
        self.assertLess(MIN_PLASTICITY, 1.0)

    def test_unknown_domain_fully_plastic(self):
        from pulse.brain.plasticity.reconsolidation import get_domain_plasticity
        self.assertEqual(get_domain_plasticity(None), 1.0)
        self.assertEqual(get_domain_plasticity(""), 1.0)

    def test_new_domain_fully_plastic(self):
        from pulse.brain.plasticity.reconsolidation import get_domain_plasticity
        p = get_domain_plasticity(f"brand_new_{uuid.uuid4().hex[:6]}")
        self.assertEqual(p, 1.0)

    def test_sigmoid_math_correct(self):
        from pulse.brain.plasticity.reconsolidation import (
            CRITICAL_PERIOD_MIDPOINT as M, CRITICAL_PERIOD_STEEPNESS as S, MIN_PLASTICITY
        )
        # At midpoint, plasticity should be ~0.5
        p_mid = 1.0 / (1.0 + math.exp(S * (M - M)))
        self.assertAlmostEqual(p_mid, 0.5, places=1)
        # At age 0, plasticity should be high
        p_new = 1.0 / (1.0 + math.exp(S * (0 - M)))
        self.assertGreater(p_new, 0.8)
        # At age 365, plasticity should be at floor
        p_old = max(MIN_PLASTICITY, 1.0 / (1.0 + math.exp(S * (365 - M))))
        self.assertEqual(p_old, MIN_PLASTICITY)

    def test_plasticity_wired_into_reinforce(self):
        import inspect
        from pulse.brain.plasticity.reconsolidation import reinforce
        src = inspect.getsource(reinforce)
        self.assertIn("get_domain_plasticity", src)
        self.assertIn("plasticity", src)

    def test_plasticity_wired_into_contradict(self):
        import inspect
        from pulse.brain.plasticity.reconsolidation import contradict
        src = inspect.getsource(contradict)
        self.assertIn("get_domain_plasticity", src)
        self.assertIn("plasticity", src)


# ---------------------------------------------------------------------------
# Architecture 6: Evidence Accumulation (DDM)
# ---------------------------------------------------------------------------
class TestEvidenceAccumulation(unittest.TestCase):
    """Drift-diffusion model for agent-task assignment."""

    def test_ddm_imports(self):
        from pulse.tasks.evidence_accumulator import (
            score_agent_for_task, select_best_agent,
            DECISION_THRESHOLD, MAX_DELIBERATION_S
        )
        self.assertGreater(DECISION_THRESHOLD, 0)
        self.assertGreater(MAX_DELIBERATION_S, 0)

    def test_score_returns_all_streams(self):
        from pulse.tasks.evidence_accumulator import score_agent_for_task
        task = {"domain": "testing", "required_capabilities": ["python"]}
        scores = score_agent_for_task("test_agent", task)
        expected_streams = ["hebbian", "competence", "success_rate", "availability", "capability"]
        for stream in expected_streams:
            self.assertIn(stream, scores, f"Missing evidence stream: {stream}")
        self.assertIn("total", scores)

    def test_total_is_weighted_sum(self):
        from pulse.tasks.evidence_accumulator import score_agent_for_task
        task = {"domain": "general"}
        scores = score_agent_for_task("agent_x", task)
        # Total should be between 0 and 1 (weighted average of [0,1] scores)
        self.assertGreaterEqual(scores["total"], 0)
        self.assertLessEqual(scores["total"], 1.0)

    def test_single_candidate_instant(self):
        from pulse.tasks.evidence_accumulator import select_best_agent
        best, scores = select_best_agent(["only_one"], {"domain": "x"})
        self.assertEqual(best, "only_one")

    def test_empty_candidates_handled(self):
        from pulse.tasks.evidence_accumulator import select_best_agent
        best, scores = select_best_agent([], {"domain": "x"})
        self.assertEqual(best, "")
        self.assertEqual(scores, {})

    def test_multiple_candidates_returns_best(self):
        from pulse.tasks.evidence_accumulator import select_best_agent
        best, scores = select_best_agent(
            ["agent_a", "agent_b", "agent_c"], {"domain": "general"})
        self.assertIn(best, ["agent_a", "agent_b", "agent_c"])
        self.assertIn("total", scores)

    def test_success_rate_reads_registry_schema(self):
        """_get_agent_success_rate must use 'success'/'failure' keys (real registry schema)."""
        import json, tempfile
        from pathlib import Path
        from unittest.mock import patch
        from pulse.tasks.evidence_accumulator import _get_agent_success_rate

        registry = {
            "agent_alpha": {
                "status": "idle",
                "capabilities": ["python"],
                "history": {
                    "tasks_completed": 7,
                    "domains": {
                        "architecture": {"success": 3, "failure": 4},
                        "testing": {"success": 5, "failure": 1},
                    },
                },
            }
        }
        with tempfile.TemporaryDirectory() as tmp:
            reg_file = Path(tmp) / "agent_registry.json"
            reg_file.write_text(json.dumps(registry), encoding="utf-8")
            with patch("pulse.tasks.evidence_accumulator.get_state_dir", return_value=Path(tmp)):
                rate = _get_agent_success_rate("agent_alpha")
        # 8 successes / (8 + 5) total = 8/13
        self.assertAlmostEqual(rate, 8 / 13, places=2,
                               msg="success_rate must read 'success'/'failure' keys from registry")

    def test_success_rate_cold_start_returns_half(self):
        """Agent with < 3 completed tasks returns 0.5 (cold start)."""
        import json, tempfile
        from pathlib import Path
        from unittest.mock import patch
        from pulse.tasks.evidence_accumulator import _get_agent_success_rate

        registry = {
            "new_agent": {
                "status": "idle",
                "capabilities": [],
                "history": {"tasks_completed": 2, "domains": {}},
            }
        }
        with tempfile.TemporaryDirectory() as tmp:
            reg_file = Path(tmp) / "agent_registry.json"
            reg_file.write_text(json.dumps(registry), encoding="utf-8")
            with patch("pulse.tasks.evidence_accumulator.get_state_dir", return_value=Path(tmp)):
                rate = _get_agent_success_rate("new_agent")
        self.assertEqual(rate, 0.5, "Cold start (< 3 tasks) must return 0.5")

    def test_capability_overlap_uses_registry(self):
        """_get_capability_overlap must match against 'capabilities' list in registry."""
        import json, tempfile
        from pathlib import Path
        from unittest.mock import patch
        from pulse.tasks.evidence_accumulator import _get_capability_overlap

        registry = {
            "skilled_agent": {
                "capabilities": ["python", "testing", "refactoring"],
                "history": {"tasks_completed": 5, "domains": {}},
            }
        }
        with tempfile.TemporaryDirectory() as tmp:
            reg_file = Path(tmp) / "agent_registry.json"
            reg_file.write_text(json.dumps(registry), encoding="utf-8")
            with patch("pulse.tasks.evidence_accumulator.get_state_dir", return_value=Path(tmp)):
                score_full = _get_capability_overlap("skilled_agent", {"python", "testing"})
                score_partial = _get_capability_overlap("skilled_agent", {"python", "security"})
                score_none = _get_capability_overlap("skilled_agent", {"java", "kubernetes"})
        self.assertEqual(score_full, 1.0, "Full overlap must score 1.0")
        self.assertAlmostEqual(score_partial, 0.5, places=2,
                               msg="Half overlap must score 0.5")
        self.assertEqual(score_none, 0.0, "Zero overlap must score 0.0")

    def test_availability_booting_returns_half(self):
        """Agents with status 'booting' (common in real registry) return 0.5 availability."""
        import json, tempfile
        from pathlib import Path
        from unittest.mock import patch
        from pulse.tasks.evidence_accumulator import _get_availability_score

        registry = {
            "booting_agent": {
                "status": "booting",
                "capabilities": [],
                "history": {"tasks_completed": 0, "domains": {}},
            }
        }
        with tempfile.TemporaryDirectory() as tmp:
            reg_file = Path(tmp) / "agent_registry.json"
            reg_file.write_text(json.dumps(registry), encoding="utf-8")
            with patch("pulse.tasks.evidence_accumulator.get_state_dir", return_value=Path(tmp)):
                score = _get_availability_score("booting_agent")
        self.assertAlmostEqual(score, 0.5, places=2,
                               msg="Booting status must return 0.5 availability")

    def test_failure_agent_scores_below_successful_agent(self):
        """Agent with 100% failure history must score lower than agent with win history."""
        import json, tempfile
        from pathlib import Path
        from unittest.mock import patch
        from pulse.tasks.evidence_accumulator import _get_agent_success_rate

        # Mirrors real claude_daemon: 0 wins / 4 failures
        all_fail = {
            "failed_agent": {
                "status": "idle",
                "capabilities": [],
                "history": {
                    "tasks_completed": 4,
                    "domains": {"general": {"success": 0, "failure": 4}},
                },
            },
            # Mirrors real gemini_interactive: 3 wins / 4 failures
            "mixed_agent": {
                "status": "idle",
                "capabilities": [],
                "history": {
                    "tasks_completed": 7,
                    "domains": {"architecture": {"success": 3, "failure": 4}},
                },
            },
        }
        with tempfile.TemporaryDirectory() as tmp:
            reg_file = Path(tmp) / "agent_registry.json"
            reg_file.write_text(json.dumps(all_fail), encoding="utf-8")
            with patch("pulse.tasks.evidence_accumulator.get_state_dir", return_value=Path(tmp)):
                fail_rate = _get_agent_success_rate("failed_agent")
                mixed_rate = _get_agent_success_rate("mixed_agent")
        self.assertEqual(fail_rate, 0.0,
                         "All-failure agent must have success_rate=0.0")
        self.assertAlmostEqual(mixed_rate, 3 / 7, places=2,
                               msg="Mixed agent must reflect actual win ratio")
        self.assertLess(fail_rate, mixed_rate,
                        "Failed agent must score lower than partially successful agent")

    def test_ddm_selects_experienced_over_failed_agent(self):
        """DDM must prefer an agent with wins over one with all failures."""
        import json, tempfile
        from pathlib import Path
        from unittest.mock import patch
        from pulse.tasks.evidence_accumulator import select_best_agent

        registry = {
            "fail_agent": {
                "status": "idle",
                "capabilities": ["python"],
                "history": {
                    "tasks_completed": 4,
                    "domains": {"general": {"success": 0, "failure": 4}},
                },
            },
            "win_agent": {
                "status": "idle",
                "capabilities": ["python"],
                "history": {
                    "tasks_completed": 5,
                    "domains": {"general": {"success": 4, "failure": 1}},
                },
            },
        }
        task = {"domain": "general", "required_capabilities": ["python"]}
        with tempfile.TemporaryDirectory() as tmp:
            reg_file = Path(tmp) / "agent_registry.json"
            reg_file.write_text(json.dumps(registry), encoding="utf-8")
            with patch("pulse.tasks.evidence_accumulator.get_state_dir", return_value=Path(tmp)):
                best, _ = select_best_agent(["fail_agent", "win_agent"], task)
        self.assertEqual(best, "win_agent",
                         "DDM must select agent with wins over agent with all failures")


# ---------------------------------------------------------------------------
# Architecture 7: Multi-Level Abstraction
# ---------------------------------------------------------------------------
class TestMultiLevelAbstraction(unittest.TestCase):
    """Three-level knowledge hierarchy."""

    def test_meta_schemas_imports(self):
        from pulse.brain.metacognition.meta_schemas import (
            build_meta_schemas, get_meta_schemas, get_principles,
            MIN_SCHEMAS_PER_CLUSTER, MIN_DOMAINS_FOR_PRINCIPLE
        )
        self.assertGreater(MIN_SCHEMAS_PER_CLUSTER, 0)
        self.assertGreater(MIN_DOMAINS_FOR_PRINCIPLE, 0)

    def test_build_returns_dict(self):
        from pulse.brain.metacognition.meta_schemas import build_meta_schemas
        result = build_meta_schemas(verbose=False)
        self.assertIsInstance(result, dict)
        self.assertIn("meta_schemas", result)
        self.assertIn("principles", result)
        self.assertIn("status", result)

    def test_get_meta_schemas_returns_list(self):
        from pulse.brain.metacognition.meta_schemas import get_meta_schemas
        ms = get_meta_schemas()
        self.assertIsInstance(ms, list)

    def test_get_principles_returns_list(self):
        from pulse.brain.metacognition.meta_schemas import get_principles
        ps = get_principles()
        self.assertIsInstance(ps, list)

    def test_quality_gate(self):
        from pulse.brain.metacognition.meta_schemas import QUALITY_GATE
        self.assertGreater(QUALITY_GATE, 0)
        self.assertLess(QUALITY_GATE, 1.0)

    def test_build_produces_clusters_from_real_schemas(self):
        """build_meta_schemas must produce ≥1 cluster when schemas.json has data."""
        from pulse.brain.metacognition.meta_schemas import build_meta_schemas, get_meta_schemas
        result = build_meta_schemas(verbose=False)
        self.assertEqual(result["status"], "ok")
        self.assertGreater(result["meta_schemas"], 0,
                           "Expected ≥1 meta-schema cluster from schemas.json")
        clusters = get_meta_schemas()
        self.assertGreater(len(clusters), 0, "get_meta_schemas() must return clusters after build")

    def test_cluster_required_fields(self):
        """Each cluster must have all required structural fields."""
        from pulse.brain.metacognition.meta_schemas import build_meta_schemas, get_meta_schemas
        build_meta_schemas(verbose=False)
        clusters = get_meta_schemas()
        required = {"id", "type", "description", "schema_count",
                    "constituent_ids", "domains_covered", "avg_confidence", "computed_at"}
        for c in clusters:
            missing = required - set(c.keys())
            self.assertFalse(missing, f"Cluster {c.get('id')} missing fields: {missing}")

    def test_cluster_schema_count_matches_constituent_ids(self):
        """schema_count must equal len(constituent_ids) for every cluster."""
        from pulse.brain.metacognition.meta_schemas import build_meta_schemas, get_meta_schemas
        build_meta_schemas(verbose=False)
        for c in get_meta_schemas():
            self.assertEqual(
                c["schema_count"], len(c["constituent_ids"]),
                f"Cluster {c['id']}: schema_count={c['schema_count']} "
                f"!= len(constituent_ids)={len(c['constituent_ids'])}"
            )

    def test_cluster_avg_confidence_above_quality_gate(self):
        """Every cluster's avg_confidence must be ≥ QUALITY_GATE."""
        from pulse.brain.metacognition.meta_schemas import build_meta_schemas, get_meta_schemas, QUALITY_GATE
        build_meta_schemas(verbose=False)
        for c in get_meta_schemas():
            self.assertGreaterEqual(
                c["avg_confidence"], QUALITY_GATE,
                f"Cluster {c['id']} avg_confidence={c['avg_confidence']} below QUALITY_GATE={QUALITY_GATE}"
            )

    def test_small_type_below_threshold_excluded(self):
        """Types with fewer than MIN_SCHEMAS_PER_CLUSTER schemas must not form a cluster."""
        from pulse.brain.metacognition.meta_schemas import build_meta_schemas, get_meta_schemas, MIN_SCHEMAS_PER_CLUSTER
        build_meta_schemas(verbose=False)
        # 'linguistic' type has only 2 schemas in schemas.json — below threshold
        cluster_types = {c["type"] for c in get_meta_schemas()}
        self.assertNotIn("linguistic", cluster_types,
                         f"'linguistic' has <{MIN_SCHEMAS_PER_CLUSTER} schemas and must be excluded")

    def test_cluster_constituent_ids_are_non_empty_list(self):
        """constituent_ids must be a non-empty list of strings."""
        from pulse.brain.metacognition.meta_schemas import build_meta_schemas, get_meta_schemas
        build_meta_schemas(verbose=False)
        for c in get_meta_schemas():
            self.assertIsInstance(c["constituent_ids"], list)
            self.assertGreater(len(c["constituent_ids"]), 0,
                               f"Cluster {c['id']} has empty constituent_ids")
            for cid in c["constituent_ids"]:
                self.assertIsInstance(cid, str)

    def test_cluster_domains_covered_is_list(self):
        """domains_covered must be a list."""
        from pulse.brain.metacognition.meta_schemas import build_meta_schemas, get_meta_schemas
        build_meta_schemas(verbose=False)
        for c in get_meta_schemas():
            self.assertIsInstance(c["domains_covered"], list,
                                  f"Cluster {c['id']} domains_covered is not a list")


# ---------------------------------------------------------------------------
# Graph SQLite Unification
# ---------------------------------------------------------------------------
class TestGraphSQLiteUnification(unittest.TestCase):
    """SQLite is primary source of truth for knowledge graph."""

    def test_sqlite_has_graph_data(self):
        from pulse.core.brain_db import get_conn
        conn = get_conn()
        nodes = conn.execute("SELECT COUNT(*) as c FROM graph_nodes").fetchone()["c"]
        edges = conn.execute("SELECT COUNT(*) as c FROM graph_edges").fetchone()["c"]
        self.assertGreater(nodes, 30_000,
                           f"Knowledge graph must have >30,000 nodes (got {nodes})")
        self.assertGreater(edges, 0, "SQLite graph_edges should have data")

    def test_sqlite_graph_integrity(self):
        """Audit: minimal dangling edges, no nodes with NULL text.

        Graph sync from JSON may leave a small number of transient dangling
        edges when nodes are pruned between consolidation cycles. Allow up
        to 5 (< 0.01% of 35k+ nodes) as acceptable noise.
        """
        from pulse.core.brain_db import get_conn
        conn = get_conn()
        dangling = conn.execute("""
            SELECT COUNT(*) as c FROM graph_edges
            WHERE from_node NOT IN (SELECT id FROM graph_nodes)
               OR to_node NOT IN (SELECT id FROM graph_nodes)
        """).fetchone()["c"]
        self.assertLessEqual(dangling, 5,
                             f"Found {dangling} dangling edges (>5 indicates sync bug)")
        empty_text = conn.execute(
            "SELECT COUNT(*) as c FROM graph_nodes WHERE text IS NULL OR text = ''"
        ).fetchone()["c"]
        self.assertEqual(empty_text, 0,
                         f"Found {empty_text} nodes with empty/NULL text")

    def test_build_calls_sqlite_sync(self):
        import inspect
        from pulse.graph.knowledge_graph_build import build_graph
        src = inspect.getsource(build_graph)
        self.assertIn("sync_to_sqlite", src,
                       "build_graph must call sync_to_sqlite")

    def test_sync_module_exists(self):
        from pulse.graph.graph_sqlite_sync import sync_to_sqlite
        self.assertTrue(callable(sync_to_sqlite))

    def test_api_uses_sqlite_not_json(self):
        import inspect
        from pulse.api.pulse_api_brain import get_graph
        src = inspect.getsource(get_graph)
        self.assertIn("brain_db", src, "API should use SQLite, not JSON")
        self.assertNotIn("knowledge_graph.json", src,
                         "API should NOT parse JSON anymore")

    def test_query_module_sqlite_first(self):
        import inspect
        from pulse.graph.knowledge_graph_query import query_related
        # The module docstring says "SQLite-first"
        import pulse.graph.knowledge_graph_query as mod
        self.assertIn("SQLite-first", mod.__doc__ or "")

    def test_sync_preserves_temporal_and_coactivation_edges(self):
        """sync_to_sqlite must not delete TEMPORAL or COACTIVATION edges."""
        import sqlite3
        import tempfile, os
        from unittest.mock import patch

        # Build an in-memory DB with the same schema as brain_db
        mem_conn = sqlite3.connect(":memory:")
        mem_conn.row_factory = sqlite3.Row
        mem_conn.execute("""
            CREATE TABLE graph_nodes (
                id TEXT PRIMARY KEY, type TEXT NOT NULL DEFAULT 'CONCEPT',
                text TEXT NOT NULL, source TEXT DEFAULT '', confidence REAL DEFAULT 0.5,
                domain TEXT NOT NULL DEFAULT 'general',
                date TEXT DEFAULT '', degree INTEGER DEFAULT 0,
                embedding BLOB DEFAULT NULL, metadata TEXT DEFAULT '{}'
            )
        """)
        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)
            )
        """)

        # Seed: one TEMPORAL edge, one COACTIVATION edge, one CAUSES edge
        mem_conn.execute("INSERT INTO graph_nodes VALUES ('A','CONCEPT','Alpha','',0.8,'test','',0,NULL,'{}')")
        mem_conn.execute("INSERT INTO graph_nodes VALUES ('B','CONCEPT','Beta','',0.8,'test','',0,NULL,'{}')")
        mem_conn.execute("INSERT INTO graph_edges(from_node,to_node,edge_type,confidence,source_node) VALUES ('A','B','TEMPORAL',0.9,'seed')")
        mem_conn.execute("INSERT INTO graph_edges(from_node,to_node,edge_type,confidence,source_node) VALUES ('B','A','COACTIVATION',0.8,'seed')")
        mem_conn.execute("INSERT INTO graph_edges(from_node,to_node,edge_type,confidence,source_node) VALUES ('A','B','CAUSES',0.7,'seed')")
        mem_conn.commit()

        from pulse.graph import graph_sqlite_sync

        with patch("pulse.core.brain_db.get_conn", return_value=mem_conn):
            graph_sqlite_sync.sync_to_sqlite(
                nodes={"C": {"type": "CONCEPT", "text": "Gamma", "confidence": 0.5, "source": "test"}},
                edges=[{"from": "C", "to": "A", "type": "RELATED", "confidence": 0.5, "source_node": "new"}],
            )

        edges = mem_conn.execute("SELECT edge_type FROM graph_edges").fetchall()
        edge_types = {r["edge_type"] for r in edges}

        self.assertIn("TEMPORAL", edge_types,
                      "TEMPORAL edges must survive sync_to_sqlite")
        self.assertIn("COACTIVATION", edge_types,
                      "COACTIVATION edges must survive sync_to_sqlite")
        self.assertNotIn("CAUSES", edge_types,
                         "Non-learned edges (CAUSES) must be replaced on sync")
        self.assertIn("RELATED", edge_types,
                      "Newly synced edges must be present after sync")

        mem_conn.close()

    def test_sync_preserves_stdp_edge_confidence_and_metadata(self):
        """STDP TEMPORAL edges must survive sync_to_sqlite with confidence + metadata intact."""
        import sqlite3
        from unittest.mock import patch
        from pulse.brain.plasticity.stdp import _upsert_temporal_edge, LTP_DELTA
        from pulse.graph import graph_sqlite_sync

        # In-memory DB with full graph schema (mirrors brain_db.py)
        mem_conn = sqlite3.connect(":memory:")
        mem_conn.row_factory = sqlite3.Row
        mem_conn.execute("""
            CREATE TABLE graph_nodes (
                id TEXT PRIMARY KEY, type TEXT NOT NULL DEFAULT 'CONCEPT',
                text TEXT NOT NULL, source TEXT DEFAULT '', confidence REAL DEFAULT 0.5,
                domain TEXT NOT NULL DEFAULT 'general',
                date TEXT DEFAULT '', degree INTEGER DEFAULT 0,
                embedding BLOB DEFAULT NULL, metadata TEXT DEFAULT '{}'
            )
        """)
        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)
            )
        """)

        # Create STDP TEMPORAL edge via the real upsert function
        _upsert_temporal_edge(mem_conn, "A", "B", LTP_DELTA)
        mem_conn.commit()

        # Record state before rebuild
        before = mem_conn.execute(
            "SELECT confidence, metadata FROM graph_edges "
            "WHERE from_node='A' AND to_node='B' AND edge_type='TEMPORAL'"
        ).fetchone()
        self.assertIsNotNone(before, "STDP edge must exist before sync")
        pre_confidence = before["confidence"]
        pre_meta = json.loads(before["metadata"])
        self.assertEqual(pre_meta.get("source"), "stdp",
                         "Pre-sync edge must have stdp source in metadata")

        # Simulate a full graph rebuild with entirely different nodes/edges
        with patch("pulse.core.brain_db.get_conn", return_value=mem_conn):
            graph_sqlite_sync.sync_to_sqlite(
                nodes={"X": {"type": "CONCEPT", "text": "NewNode", "confidence": 0.6, "source": "rebuild"}},
                edges=[{"from": "X", "to": "Y", "type": "RELATED", "confidence": 0.5, "source_node": "rebuild"}],
            )

        # STDP TEMPORAL edge must survive with exact confidence and metadata
        after = mem_conn.execute(
            "SELECT confidence, metadata FROM graph_edges "
            "WHERE from_node='A' AND to_node='B' AND edge_type='TEMPORAL'"
        ).fetchone()
        self.assertIsNotNone(after,
                              "STDP TEMPORAL edge must survive sync_to_sqlite rebuild")
        self.assertAlmostEqual(after["confidence"], pre_confidence, places=4,
                               msg="STDP edge confidence must be preserved exactly after rebuild")
        after_meta = json.loads(after["metadata"])
        self.assertEqual(after_meta.get("source"), "stdp",
                         "STDP edge metadata.source must be preserved after rebuild")

        mem_conn.close()


# ---------------------------------------------------------------------------
# STDP Temporal Edge Validation
# ---------------------------------------------------------------------------
class TestSTDPTemporalEdges(unittest.TestCase):
    """All STDP temporal edges in SQLite graph_edges must be structurally valid."""

    def _make_mem_conn(self):
        """In-memory SQLite connection with graph_edges schema."""
        import sqlite3
        conn = sqlite3.connect(":memory:")
        conn.row_factory = sqlite3.Row
        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)
            )
        """)
        return conn

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

    def test_upsert_creates_temporal_edge_with_valid_structure(self):
        """New TEMPORAL edge must have confidence in (0, 1] and valid JSON metadata."""
        from pulse.brain.plasticity.stdp import _upsert_temporal_edge, LTP_DELTA
        conn = self._make_mem_conn()
        _upsert_temporal_edge(conn, "A", "B", LTP_DELTA)
        row = conn.execute(
            "SELECT confidence, metadata FROM graph_edges "
            "WHERE from_node='A' AND to_node='B' AND edge_type='TEMPORAL'"
        ).fetchone()
        self.assertIsNotNone(row, "TEMPORAL edge must be created on positive delta")
        self.assertGreater(row["confidence"], 0.0)
        self.assertLessEqual(row["confidence"], 1.0)
        meta = json.loads(row["metadata"])
        self.assertEqual(meta.get("source"), "stdp", "metadata.source must be 'stdp'")
        self.assertIn("created", meta, "metadata must include creation timestamp")

    def test_upsert_confidence_clamped_at_one(self):
        """Repeated LTP updates must never push confidence above 1.0."""
        from pulse.brain.plasticity.stdp import _upsert_temporal_edge, LTP_DELTA
        conn = self._make_mem_conn()
        for _ in range(25):
            _upsert_temporal_edge(conn, "A", "B", LTP_DELTA)
        row = conn.execute(
            "SELECT confidence FROM graph_edges "
            "WHERE from_node='A' AND to_node='B' AND edge_type='TEMPORAL'"
        ).fetchone()
        self.assertIsNotNone(row)
        self.assertLessEqual(row["confidence"], 1.0, "Confidence must never exceed 1.0")

    def test_upsert_deletes_sub_threshold_edges(self):
        """Edges whose confidence drops below 0.01 must be deleted."""
        from pulse.brain.plasticity.stdp import _upsert_temporal_edge, LTD_DELTA
        conn = self._make_mem_conn()
        conn.execute(
            "INSERT INTO graph_edges(from_node,to_node,edge_type,confidence,metadata) "
            "VALUES ('X','Y','TEMPORAL',0.005,'{}')"
        )
        conn.commit()
        _upsert_temporal_edge(conn, "X", "Y", LTD_DELTA)
        row = conn.execute(
            "SELECT id FROM graph_edges "
            "WHERE from_node='X' AND to_node='Y' AND edge_type='TEMPORAL'"
        ).fetchone()
        self.assertIsNone(row, "Sub-threshold TEMPORAL edges must be pruned by LTD")

    def test_upsert_negative_delta_creates_no_new_edge(self):
        """Negative delta (LTD) must NOT create a new edge where none exists."""
        from pulse.brain.plasticity.stdp import _upsert_temporal_edge, LTD_DELTA
        conn = self._make_mem_conn()
        _upsert_temporal_edge(conn, "P", "Q", LTD_DELTA)
        row = conn.execute(
            "SELECT id FROM graph_edges WHERE from_node='P' AND to_node='Q' AND edge_type='TEMPORAL'"
        ).fetchone()
        self.assertIsNone(row, "Negative delta must not create new TEMPORAL edges")

    def test_apply_stdp_returns_zero_for_single_item_session(self):
        """Sessions with only one retrieved item cannot form pairs — must return 0."""
        import tempfile
        from unittest.mock import patch
        from pulse.brain.plasticity.stdp import apply_stdp

        fake_sessions = Path(tempfile.mkdtemp()) / "session_retrievals.json"
        now = datetime.now(timezone.utc).isoformat()
        fake_sessions.write_text(
            json.dumps({"solo": [{"item_id": "only_one", "ts": now}]}),
            encoding="utf-8"
        )
        with patch("pulse.brain.stdp.SESSIONS_FILE", fake_sessions):
            result = apply_stdp("solo", True)
        self.assertEqual(result, 0, "Single-item session must yield 0 edge updates")

    def test_existing_db_temporal_edges_confidence_in_range(self):
        """Every TEMPORAL edge in the real DB must have confidence in [0.0, 1.0]."""
        try:
            from pulse.core.brain_db import get_conn
            conn = get_conn()
            rows = conn.execute(
                "SELECT id, from_node, to_node, confidence FROM graph_edges "
                "WHERE edge_type='TEMPORAL'"
            ).fetchall()
        except Exception:
            self.skipTest("SQLite DB not available")
        for row in rows:
            self.assertGreaterEqual(
                row["confidence"], 0.0,
                f"TEMPORAL edge {row['id']} ({row['from_node']}->{row['to_node']}) "
                f"has confidence {row['confidence']} < 0"
            )
            self.assertLessEqual(
                row["confidence"], 1.0,
                f"TEMPORAL edge {row['id']} ({row['from_node']}->{row['to_node']}) "
                f"has confidence {row['confidence']} > 1"
            )

    def test_existing_db_temporal_edges_have_valid_json_metadata(self):
        """Every TEMPORAL edge in the real DB must have parseable JSON metadata."""
        try:
            from pulse.core.brain_db import get_conn
            conn = get_conn()
            rows = conn.execute(
                "SELECT id, from_node, to_node, metadata FROM graph_edges "
                "WHERE edge_type='TEMPORAL'"
            ).fetchall()
        except Exception:
            self.skipTest("SQLite DB not available")
        for row in rows:
            try:
                json.loads(row["metadata"] or "{}")
            except (json.JSONDecodeError, TypeError):
                self.fail(
                    f"TEMPORAL edge {row['id']} ({row['from_node']}->{row['to_node']}) "
                    f"has invalid JSON metadata: {row['metadata']!r}"
                )

    def test_existing_db_no_self_referential_temporal_edges(self):
        """STDP must not create self-referential TEMPORAL edges (from_node == to_node)."""
        try:
            from pulse.core.brain_db import get_conn
            conn = get_conn()
            count = conn.execute(
                "SELECT COUNT(*) as c FROM graph_edges "
                "WHERE edge_type='TEMPORAL' AND from_node = to_node"
            ).fetchone()["c"]
        except Exception:
            self.skipTest("SQLite DB not available")
        self.assertEqual(count, 0,
                         f"Found {count} self-referential TEMPORAL edges — STDP must skip pre_id == post_id")


# ---------------------------------------------------------------------------
# Dedup Fix
# ---------------------------------------------------------------------------
class TestDedupFix(unittest.TestCase):
    """Fingerprint dedup prevents duplicate brain entries."""

    def test_cache_max_covers_brain(self):
        from pulse.brain.save_writers import _CACHE_MAX
        self.assertGreaterEqual(_CACHE_MAX, 10000,
                                "Cache must cover full brain to prevent eviction dupes")

    def test_fingerprint_strips_agent_prefix(self):
        from pulse.brain.save_writers import _make_fingerprint
        fp1 = _make_fingerprint("[claude]: Always use os.replace() on Windows")
        fp2 = _make_fingerprint("gemini: Always use os.replace() on Windows")
        fp3 = _make_fingerprint("Always use os.replace() on Windows")
        self.assertEqual(fp1, fp2, "Agent prefix should not affect fingerprint")
        self.assertEqual(fp2, fp3, "Bare text should match prefixed text")

    def test_fingerprint_strips_timestamps(self):
        from pulse.brain.save_writers import _make_fingerprint
        fp1 = _make_fingerprint("2026-03-17T13:24:41 Use filelock for concurrent writes")
        fp2 = _make_fingerprint("Use filelock for concurrent writes")
        self.assertEqual(fp1, fp2, "Timestamps should not affect fingerprint")

    def test_fingerprint_deterministic(self):
        from pulse.brain.save_writers import _make_fingerprint
        fp1 = _make_fingerprint("Same text produces same hash every time")
        fp2 = _make_fingerprint("Same text produces same hash every time")
        self.assertEqual(fp1, fp2)

    def test_fingerprint_different_for_different_content(self):
        from pulse.brain.save_writers import _make_fingerprint
        fp1 = _make_fingerprint("Use os.replace() for atomic file operations")
        fp2 = _make_fingerprint("Never use shutil.move() on Windows systems")
        self.assertNotEqual(fp1, fp2, "Different content must produce different fingerprints")


# ---------------------------------------------------------------------------
# Cross-Architecture Integration
# ---------------------------------------------------------------------------
class TestPhase4Integration(unittest.TestCase):
    """Cross-architecture wiring verification."""

    def test_all_phase4_modules_importable(self):
        modules = [
            "pulse.brain.metacognition.learned_prompt",
            "pulse.brain.procedural_cache",
            "pulse.brain.reconsolidation",
            "pulse.tasks.evidence_accumulator",
            "pulse.brain.metacognition.meta_schemas",
            "pulse.graph.graph_sqlite_sync",
        ]
        for mod in modules:
            try:
                __import__(mod)
            except Exception as e:
                self.fail(f"Failed to import {mod}: {e}")

    def test_reward_signal_full_pipeline(self):
        """Reward signal should wire: credit → propagate → STDP → Hebbian → learned weights."""
        import inspect
        from pulse.core.reward.reward_signal import record_task_outcome
        src = inspect.getsource(record_task_outcome)
        self.assertIn("propagate_reward", src)
        self.assertIn("apply_stdp", src)
        self.assertIn("apply_hebbian", src)
        self.assertIn("update_from_credits", src)

    def test_reconsolidation_has_bcm_and_critical_periods(self):
        """Reconsolidation should have both Phase 3 (BCM) and Phase 4 (critical periods)."""
        import inspect
        from pulse.brain.plasticity.reconsolidation import reinforce
        src = inspect.getsource(reinforce)
        self.assertIn("bcm_delta", src, "Phase 3 BCM must be present")
        self.assertIn("plasticity", src, "Phase 4 critical periods must be present")


# ---------------------------------------------------------------------------
# Edge Cases
# ---------------------------------------------------------------------------
class TestPhase4EdgeCases(unittest.TestCase):

    def test_plasticity_floor_prevents_zero(self):
        from pulse.brain.plasticity.reconsolidation import MIN_PLASTICITY
        self.assertGreater(MIN_PLASTICITY, 0, "Plasticity must never reach zero")

    def test_ddm_weights_sum_to_one(self):
        """Evidence stream weights should sum to 1.0."""
        # Weights: 0.30 + 0.20 + 0.20 + 0.15 + 0.15 = 1.0
        total = 0.30 + 0.20 + 0.20 + 0.15 + 0.15
        self.assertAlmostEqual(total, 1.0, places=2)

    def test_procedural_cache_handles_empty_events(self):
        from pulse.brain.memory.procedural_cache import extract_tool_sequence
        self.assertEqual(extract_tool_sequence([]), [])

    def test_meta_schemas_handles_no_schemas(self):
        """Should not crash if schema file is empty."""
        from pulse.brain.metacognition.meta_schemas import build_meta_schemas
        # Just verify it doesn't crash
        result = build_meta_schemas(verbose=False)
        self.assertIsInstance(result, dict)

    def test_learned_weights_bounded(self):
        """Learned weights should be bounded [0.3, 2.5]."""
        from pulse.brain.metacognition.learned_prompt import update_learned_weights, LEARNED_WEIGHTS_FILE
        # Force extreme values
        for _ in range(50):
            update_learned_weights("extreme_test", {"wins": 10.0})
        if LEARNED_WEIGHTS_FILE.exists():
            data = json.loads(LEARNED_WEIGHTS_FILE.read_text(encoding="utf-8"))
            if "extreme_test" in data:
                for k, v in data["extreme_test"].items():
                    if not k.startswith("_"):
                        self.assertLessEqual(v, 2.5, f"Weight {k}={v} exceeds upper bound")
                        self.assertGreaterEqual(v, 0.3, f"Weight {k}={v} below lower bound")

    def test_fingerprint_handles_empty_string(self):
        from pulse.brain.save_writers import _make_fingerprint
        fp = _make_fingerprint("")
        self.assertIsInstance(fp, str)
        self.assertEqual(len(fp), 16)

    def test_signature_handles_empty_title(self):
        from pulse.brain.memory.procedural_cache import compute_signature
        sig = compute_signature("domain", "")
        self.assertIsInstance(sig, str)
        self.assertEqual(len(sig), 16)


# ---------------------------------------------------------------------------
# Volatility State After 10 RPE Recordings
# ---------------------------------------------------------------------------
class TestVolatilityStateAfterRPE(unittest.TestCase):
    """Volatility state file is populated correctly after 10 RPE recordings."""

    def test_state_file_created_with_valid_structure(self):
        """compute_volatility() must write a valid state file when 10+ RPEs are present."""
        import tempfile
        from unittest.mock import patch

        with tempfile.TemporaryDirectory() as tmp:
            tmp_path = Path(tmp)
            reward_file = tmp_path / "reward_signals.json"
            vol_file = tmp_path / "volatility_state.json"

            # 10 RPE recordings with alternating high/low values (ensures variance > 0)
            signals = [
                {
                    "rpe_delta": 0.8 if i % 2 == 0 else -0.6,
                    "success": i % 2 == 0,
                    "agent": "test_agent",
                    "task_id": f"t_{i:03d}",
                    "domain": "testing",
                    "items_credited": {},
                    "timestamp": datetime.now(timezone.utc).isoformat(),
                }
                for i in range(10)
            ]
            reward_file.write_text(json.dumps(signals, indent=2), encoding="utf-8")

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

            self.assertTrue(
                vol_file.exists(),
                "Volatility state file must be created after computing volatility",
            )
            data = json.loads(vol_file.read_text(encoding="utf-8"))
            self.assertIn("multiplier", data, "State file must contain 'multiplier'")
            self.assertIn("signals", data, "State file must contain 'signals'")
            self.assertIn("computed_at", data, "State file must contain 'computed_at'")

            from pulse.core.reward.volatility import MIN_MULTIPLIER, MAX_MULTIPLIER
            self.assertGreaterEqual(data["multiplier"], MIN_MULTIPLIER)
            self.assertLessEqual(data["multiplier"], MAX_MULTIPLIER)
            self.assertIsInstance(data["signals"], list)
            self.assertGreater(
                len(data["signals"]), 0,
                "RPE variance signal must be computed and stored from 10 recordings",
            )

    def test_rpe_variance_drives_high_multiplier(self):
        """High RPE variance (volatile env) must produce multiplier > DEFAULT."""
        import tempfile
        from unittest.mock import patch

        with tempfile.TemporaryDirectory() as tmp:
            tmp_path = Path(tmp)
            reward_file = tmp_path / "reward_signals.json"
            vol_file = tmp_path / "volatility_state.json"

            # Extreme alternating RPEs → very high variance
            signals = [
                {"rpe_delta": 1.0 if i % 2 == 0 else -1.0,
                 "success": i % 2 == 0,
                 "agent": "a", "task_id": f"t{i}", "domain": "general",
                 "items_credited": {},
                 "timestamp": datetime.now(timezone.utc).isoformat()}
                for i in range(10)
            ]
            reward_file.write_text(json.dumps(signals), encoding="utf-8")

            with patch("pulse.core.reward.reward_signal.REWARD_SIGNALS_FILE", reward_file), \
                 patch("pulse.core.reward.volatility.VOLATILITY_FILE", vol_file):
                from pulse.core.reward.volatility import compute_volatility, DEFAULT_MULTIPLIER
                mult = compute_volatility()

            self.assertGreater(mult, DEFAULT_MULTIPLIER,
                               "High RPE variance must produce multiplier > 1.0")

    def test_multiplier_clamped_to_0_5_and_2_0(self):
        """compute_volatility() must never return < 0.5 or > 2.0 regardless of signal extremes."""
        import tempfile
        from unittest.mock import patch

        with tempfile.TemporaryDirectory() as tmp:
            tmp_path = Path(tmp)
            reward_file = tmp_path / "reward_signals.json"
            vol_file = tmp_path / "volatility_state.json"

            # 20 extreme alternating RPEs — maximises variance signal toward 1.5
            signals = [
                {"rpe_delta": 1.0 if i % 2 == 0 else -1.0,
                 "success": i % 2 == 0,
                 "agent": "a", "task_id": f"t{i}", "domain": "general",
                 "items_credited": {},
                 "timestamp": datetime.now(timezone.utc).isoformat()}
                for i in range(20)
            ]
            reward_file.write_text(json.dumps(signals), encoding="utf-8")

            with patch("pulse.core.reward.reward_signal.REWARD_SIGNALS_FILE", reward_file), \
                 patch("pulse.core.reward.volatility.VOLATILITY_FILE", vol_file):
                from pulse.core.reward.volatility import (
                    compute_volatility, MIN_MULTIPLIER, MAX_MULTIPLIER
                )
                mult = compute_volatility()

            self.assertGreaterEqual(mult, MIN_MULTIPLIER,
                                    f"Multiplier {mult} must be >= {MIN_MULTIPLIER}")
            self.assertLessEqual(mult, MAX_MULTIPLIER,
                                 f"Multiplier {mult} must be <= {MAX_MULTIPLIER}")
            self.assertEqual(MIN_MULTIPLIER, 0.5, "MIN_MULTIPLIER must be 0.5")
            self.assertEqual(MAX_MULTIPLIER, 2.0, "MAX_MULTIPLIER must be 2.0")


# ---------------------------------------------------------------------------
# DDM Scoring with Real Agent Registry
# ---------------------------------------------------------------------------
class TestDDMWithRealRegistry(unittest.TestCase):
    """Verify evidence_accumulator scoring against actual agent_registry.json entries.

    Tests load the real registry so assertions are grounded in live data.
    Each test skips gracefully when the expected agent is absent.
    """

    _REG_FILE = Path(__file__).resolve().parent.parent / "state" / "agent_registry.json"

    def _registry(self):
        if not self._REG_FILE.exists():
            self.skipTest("agent_registry.json not found")
        return json.loads(self._REG_FILE.read_text(encoding="utf-8"))

    def _expect_agent(self, agent_id: str):
        reg = self._registry()
        if agent_id not in reg:
            self.skipTest(f"{agent_id} not in registry")
        return reg[agent_id]

    # ------------------------------------------------------------------
    # Stream 3: success_rate from registry history
    # ------------------------------------------------------------------
    def test_claude_daemon_success_rate_is_zero(self):
        """claude_daemon: 0 successes / 4 failures → success_rate=0.0."""
        agent = self._expect_agent("claude_daemon")
        domains = agent.get("history", {}).get("domains", {})
        wins = sum(d.get("success", 0) for d in domains.values() if isinstance(d, dict))
        fails = sum(d.get("failure", 0) for d in domains.values() if isinstance(d, dict))
        if wins + fails < 3:
            self.skipTest("Not enough history for rate calculation")
        from pulse.tasks.evidence_accumulator import score_agent_for_task
        scores = score_agent_for_task("claude_daemon", {"domain": "general"})
        expected = round(wins / (wins + fails), 3)
        self.assertAlmostEqual(scores["success_rate"], expected, places=3,
                               msg="success_rate must reflect 0/4 registry history")

    def test_gemini_19156_success_rate_reflects_partial_history(self):
        """gemini_interactive_19156_daemon: 3/7 → success_rate≈0.429."""
        agent = self._expect_agent("gemini_interactive_19156_daemon")
        domains = agent.get("history", {}).get("domains", {})
        wins = sum(d.get("success", 0) for d in domains.values() if isinstance(d, dict))
        fails = sum(d.get("failure", 0) for d in domains.values() if isinstance(d, dict))
        total = wins + fails
        if total < 3:
            self.skipTest("Not enough history")
        from pulse.tasks.evidence_accumulator import score_agent_for_task
        scores = score_agent_for_task("gemini_interactive_19156_daemon",
                                      {"domain": "architecture"})
        expected = round(wins / total, 3)
        self.assertAlmostEqual(scores["success_rate"], expected, places=3)
        self.assertGreater(scores["success_rate"], 0.0,
                           "Partial-history agent must have positive success_rate")

    def test_zero_tasks_agent_defaults_to_neutral_success_rate(self):
        """Agents with < 3 tasks_completed must receive the 0.5 neutral default."""
        self._expect_agent("gemini")
        from pulse.tasks.evidence_accumulator import score_agent_for_task
        scores = score_agent_for_task("gemini", {"domain": "general"})
        self.assertAlmostEqual(scores["success_rate"], 0.5, places=3,
                               msg="gemini has 0 completed tasks; must default to 0.5")

    # ------------------------------------------------------------------
    # Stream 4: availability from registry status
    # ------------------------------------------------------------------
    def test_idle_agent_scores_full_availability(self):
        """status=idle → availability=1.0."""
        agent = self._expect_agent("gemini")
        self.assertEqual(agent.get("status"), "idle",
                         "Registry sanity: gemini must be idle")
        from pulse.tasks.evidence_accumulator import score_agent_for_task
        scores = score_agent_for_task("gemini", {"domain": "general"})
        self.assertAlmostEqual(scores["availability"], 1.0, places=3)

    def test_booting_agent_scores_half_availability(self):
        """status=booting falls through to the 0.5 default."""
        agent = self._expect_agent("test_fast_daemon")
        self.assertEqual(agent.get("status"), "booting",
                         "Registry sanity: test_fast_daemon must be booting")
        from pulse.tasks.evidence_accumulator import score_agent_for_task
        scores = score_agent_for_task("test_fast_daemon", {"domain": "general"})
        self.assertAlmostEqual(scores["availability"], 0.5, places=3)

    # ------------------------------------------------------------------
    # Stream 5: capability overlap from registry capabilities
    # ------------------------------------------------------------------
    def test_gemini_full_capability_overlap_for_matching_task(self):
        """gemini has ['code','refactoring','docs']; requiring 'code' → overlap=1.0."""
        agent = self._expect_agent("gemini")
        self.assertIn("code", agent.get("capabilities", []),
                      "Registry sanity: gemini must list 'code' capability")
        from pulse.tasks.evidence_accumulator import score_agent_for_task
        scores = score_agent_for_task("gemini",
                                      {"domain": "general",
                                       "required_capabilities": ["code"]})
        self.assertAlmostEqual(scores["capability"], 1.0, places=3)

    def test_empty_capabilities_agent_scores_half_for_required_caps(self):
        """claude_daemon has no capabilities; any required cap → 0.5 fallback."""
        agent = self._expect_agent("claude_daemon")
        self.assertEqual(agent.get("capabilities", []), [],
                         "Registry sanity: claude_daemon must have no capabilities")
        from pulse.tasks.evidence_accumulator import score_agent_for_task
        scores = score_agent_for_task("claude_daemon",
                                      {"domain": "general",
                                       "required_capabilities": ["python"]})
        self.assertAlmostEqual(scores["capability"], 0.5, places=3)

    def test_no_required_caps_gives_full_capability_score(self):
        """When task has no required_capabilities, all agents score capability=1.0."""
        from pulse.tasks.evidence_accumulator import score_agent_for_task
        reg = self._registry()
        for agent_id in ("gemini", "claude_daemon", "gemini_interactive_19156_daemon"):
            if agent_id not in reg:
                continue
            scores = score_agent_for_task(agent_id, {"domain": "general"})
            self.assertAlmostEqual(
                scores["capability"], 1.0, places=3,
                msg=f"{agent_id}: no-req task must score capability=1.0"
            )

    # ------------------------------------------------------------------
    # select_best_agent with real registry candidates
    # ------------------------------------------------------------------
    def test_select_best_from_real_agents_returns_valid_candidate(self):
        """select_best_agent must return one of the known real candidates."""
        from pulse.tasks.evidence_accumulator import select_best_agent
        reg = self._registry()
        candidates = [c for c in
                      ("gemini", "claude_daemon", "gemini_interactive_19156_daemon")
                      if c in reg]
        if len(candidates) < 2:
            self.skipTest("Need ≥2 real registry agents for this test")
        task = {"domain": "architecture", "required_capabilities": []}
        best, scores = select_best_agent(candidates, task)
        self.assertIn(best, candidates)
        self.assertIn("total", scores)
        self.assertGreaterEqual(scores["total"], 0.0)
        self.assertLessEqual(scores["total"], 1.0)

    def test_partial_history_agent_beats_zero_history_on_success_rate(self):
        """gemini_19156 (3/7=0.429) must outscore claude_daemon (0/4=0.0) on success_rate stream."""
        from pulse.tasks.evidence_accumulator import score_agent_for_task
        reg = self._registry()
        if "gemini_interactive_19156_daemon" not in reg or "claude_daemon" not in reg:
            self.skipTest("Required agents not in registry")
        scores_exp = score_agent_for_task("gemini_interactive_19156_daemon",
                                          {"domain": "architecture"})
        scores_zero = score_agent_for_task("claude_daemon", {"domain": "general"})
        self.assertGreater(
            scores_exp["success_rate"], scores_zero["success_rate"],
            "3/7 success must outscore 0/4 success on success_rate stream"
        )

    def test_all_streams_in_unit_range_for_real_registry_agents(self):
        """Every evidence stream must be in [0.0, 1.0] for all real registry agents."""
        from pulse.tasks.evidence_accumulator import score_agent_for_task
        reg = self._registry()
        task = {"domain": "general", "required_capabilities": []}
        streams = ["hebbian", "competence", "success_rate",
                   "availability", "capability", "total"]
        for agent_id in list(reg.keys())[:10]:  # Sample to keep test fast
            scores = score_agent_for_task(agent_id, task)
            for stream in streams:
                self.assertIn(stream, scores,
                              f"{agent_id}: missing evidence stream '{stream}'")
                val = scores[stream]
                self.assertGreaterEqual(val, 0.0,
                                        f"{agent_id}.{stream}={val} below 0.0")
                self.assertLessEqual(val, 1.0,
                                     f"{agent_id}.{stream}={val} above 1.0")


# ---------------------------------------------------------------------------
# Audit: Meta-Schema Quality Gate — Low Confidence Filtering
# ---------------------------------------------------------------------------
class TestMetaSchemaQualityGateFilters(unittest.TestCase):
    """Audit: quality gate rejects low-confidence clusters and passes high-confidence ones.

    Uses mock injection so results are deterministic regardless of schemas.json state.
    """

    def _make_schemas(self, type_, confidence, count, domain="testing"):
        return [
            {"name": f"s_{type_}_{i}", "type": type_, "confidence": confidence, "domain": domain}
            for i in range(count)
        ]

    def _build_captured(self, schemas):
        """Run build_meta_schemas with injected schemas; return (clusters, principles)."""
        from pulse.brain.metacognition.meta_schemas import build_meta_schemas
        from unittest.mock import patch
        captured = {}

        def _cap_meta(ms):
            captured["meta"] = ms

        def _cap_prin(ps):
            captured["prin"] = ps

        with patch("pulse.brain.metacognition.meta_schemas._load_schemas", return_value=schemas), \
             patch("pulse.brain.metacognition.meta_schemas._save_meta_schemas", side_effect=_cap_meta), \
             patch("pulse.brain.metacognition.meta_schemas._save_principles", side_effect=_cap_prin):
            build_meta_schemas(verbose=False)
        return captured.get("meta", []), captured.get("prin", [])

    def test_low_confidence_cluster_rejected(self):
        """Cluster with avg_confidence below QUALITY_GATE must not appear in output."""
        from pulse.brain.metacognition.meta_schemas import QUALITY_GATE, MIN_SCHEMAS_PER_CLUSTER
        low = round(QUALITY_GATE - 0.1, 3)
        schemas = self._make_schemas("rule", low, MIN_SCHEMAS_PER_CLUSTER)
        clusters, _ = self._build_captured(schemas)
        types = {c["type"] for c in clusters}
        self.assertNotIn(
            "rule", types,
            f"Low-confidence cluster (avg={low} < QUALITY_GATE={QUALITY_GATE}) must be rejected",
        )

    def test_high_confidence_cluster_passes(self):
        """Cluster with avg_confidence above QUALITY_GATE must appear in output."""
        from pulse.brain.metacognition.meta_schemas import QUALITY_GATE, MIN_SCHEMAS_PER_CLUSTER
        high = round(QUALITY_GATE + 0.1, 3)
        schemas = self._make_schemas("rule", high, MIN_SCHEMAS_PER_CLUSTER)
        clusters, _ = self._build_captured(schemas)
        types = {c["type"] for c in clusters}
        self.assertIn(
            "rule", types,
            f"High-confidence cluster (avg={high} >= QUALITY_GATE={QUALITY_GATE}) must pass",
        )

    def test_boundary_at_quality_gate_passes(self):
        """avg_confidence == QUALITY_GATE must pass: gate uses strict < (not <=)."""
        from pulse.brain.metacognition.meta_schemas import QUALITY_GATE, MIN_SCHEMAS_PER_CLUSTER
        schemas = self._make_schemas("causal", QUALITY_GATE, MIN_SCHEMAS_PER_CLUSTER)
        clusters, _ = self._build_captured(schemas)
        types = {c["type"] for c in clusters}
        self.assertIn(
            "causal", types,
            f"avg_confidence == QUALITY_GATE ({QUALITY_GATE}) must pass (strict < gate, not <=)",
        )

    def test_missing_confidence_key_defaults_to_zero(self):
        """Schemas without 'confidence' key default to 0 and sink cluster below gate."""
        from pulse.brain.metacognition.meta_schemas import MIN_SCHEMAS_PER_CLUSTER
        schemas = [{"name": f"s{i}", "type": "unknown"} for i in range(MIN_SCHEMAS_PER_CLUSTER)]
        clusters, _ = self._build_captured(schemas)
        types = {c["type"] for c in clusters}
        self.assertNotIn(
            "unknown", types,
            "Schemas with no 'confidence' key default to 0.0 and must be rejected by gate",
        )

    def test_avg_confidence_field_accurate(self):
        """The stored avg_confidence field must equal the actual mean of constituent confs."""
        from pulse.brain.metacognition.meta_schemas import MIN_SCHEMAS_PER_CLUSTER
        confs = [0.6, 0.8, 0.7]
        assert len(confs) >= MIN_SCHEMAS_PER_CLUSTER
        schemas = [
            {"name": f"s_rule_{i}", "type": "rule", "confidence": c, "domain": "testing"}
            for i, c in enumerate(confs)
        ]
        clusters, _ = self._build_captured(schemas)
        rule_clusters = [c for c in clusters if c["type"] == "rule"]
        self.assertTrue(rule_clusters, "Expected 'rule' cluster to be created")
        expected = round(sum(confs) / len(confs), 3)
        self.assertAlmostEqual(
            rule_clusters[0]["avg_confidence"], expected, places=3,
            msg=f"avg_confidence must be {expected} (mean of {confs})",
        )

    def test_mixed_confidence_cluster_uses_average(self):
        """Gate applies to cluster average, not individual schema confidence."""
        from pulse.brain.metacognition.meta_schemas import QUALITY_GATE, MIN_SCHEMAS_PER_CLUSTER
        # One schema at 0.0, rest at 0.7 → avg([0.0, 0.7, 0.7]) = 0.467 > 0.4 → passes
        schemas = (
            [{"name": "s_temporal_0", "type": "temporal", "confidence": 0.0, "domain": "testing"}]
            + [
                {"name": f"s_temporal_{i}", "type": "temporal", "confidence": 0.7, "domain": "testing"}
                for i in range(1, MIN_SCHEMAS_PER_CLUSTER)
            ]
        )
        avg = (0.0 + 0.7 * (MIN_SCHEMAS_PER_CLUSTER - 1)) / MIN_SCHEMAS_PER_CLUSTER
        clusters, _ = self._build_captured(schemas)
        types = {c["type"] for c in clusters}
        if avg >= QUALITY_GATE:
            self.assertIn(
                "temporal", types,
                f"Mixed cluster avg={avg:.3f} >= QUALITY_GATE={QUALITY_GATE} must pass",
            )
        else:
            self.assertNotIn(
                "temporal", types,
                f"Mixed cluster avg={avg:.3f} < QUALITY_GATE={QUALITY_GATE} must fail",
            )


# ---------------------------------------------------------------------------
# Audit: Procedural Cache Signature Stability Across Identical Tasks
# ---------------------------------------------------------------------------
class TestProceduralCacheSignatureStabilityAudit(unittest.TestCase):
    """Audit: compute_signature produces stable hashes for identical task patterns.

    Verifies every normalization axis:
    - Numeric suffixes stripped (batch 1 == batch 2)
    - Whitespace collapsed
    - Punctuation stripped
    - Case folded
    - Word order irrelevant (terms sorted before hashing)
    - Stop-word-only descriptions treated as empty
    - Signature is exactly 16 hex chars, always
    - SIGNATURE_TERMS boundary: >8 unique terms changes the hash
    - True determinism: 10 repeated calls, same result
    """

    def _sig(self, domain, title, description=""):
        from pulse.brain.memory.procedural_cache import compute_signature
        return compute_signature(domain, title, description)

    # ------------------------------------------------------------------
    # Core invariant: length always 16
    # ------------------------------------------------------------------
    def test_signature_length_always_16(self):
        """Signature must always be a 16-char hex string regardless of input."""
        cases = [
            ("d", "", ""),
            ("d", "a", ""),
            ("d", "the a an is", ""),
            ("d", "Audit cache signature stability", ""),
            ("d", "alpha beta gamma delta epsilon zeta eta theta iota kappa", ""),
        ]
        for domain, title, desc in cases:
            sig = self._sig(domain, title, desc)
            self.assertEqual(len(sig), 16,
                             f"Sig for ({domain!r},{title!r}) must be 16 chars, got {len(sig)}")
            self.assertRegex(sig, r'^[0-9a-f]{16}$',
                             f"Sig must be lowercase hex, got {sig!r}")

    # ------------------------------------------------------------------
    # True determinism: same input → same output across repeated calls
    # ------------------------------------------------------------------
    def test_deterministic_across_10_calls(self):
        """10 repeated calls with same args must produce the same signature."""
        from pulse.brain.memory.procedural_cache import compute_signature
        expected = compute_signature("testing", "Audit procedural cache stability", "across identical tasks")
        for i in range(9):
            result = compute_signature("testing", "Audit procedural cache stability", "across identical tasks")
            self.assertEqual(result, expected,
                             f"Call #{i+2} returned different sig: {result!r} vs {expected!r}")

    # ------------------------------------------------------------------
    # Numeric suffix stripping (critical for task batches)
    # ------------------------------------------------------------------
    def test_numeric_suffixes_stripped(self):
        """Batch 1, 2, and 99 must produce the same signature — numbers are not terms."""
        s1 = self._sig("testing", "Extract knowledge claude batch 1")
        s2 = self._sig("testing", "Extract knowledge claude batch 2")
        s99 = self._sig("testing", "Extract knowledge claude batch 99")
        self.assertEqual(s1, s2,
                         "Batch 1 and batch 2 must have the same signature (numbers stripped)")
        self.assertEqual(s1, s99,
                         "Batch 99 must match batch 1 (numbers stripped)")

    def test_pure_numeric_title_same_as_empty(self):
        """A title with only numbers collapses to the same sig as an empty title."""
        s_numeric = self._sig("testing", "123 456 789")
        s_empty = self._sig("testing", "")
        self.assertEqual(s_numeric, s_empty,
                         "Numeric-only title must produce same sig as empty title")

    # ------------------------------------------------------------------
    # Whitespace normalization
    # ------------------------------------------------------------------
    def test_extra_whitespace_ignored(self):
        """Multiple spaces between words must not change the signature."""
        s_normal = self._sig("testing", "Audit cache signature")
        s_extra = self._sig("testing", "Audit  cache   signature")
        self.assertEqual(s_normal, s_extra,
                         "Extra whitespace must not affect signature")

    def test_leading_trailing_whitespace_ignored(self):
        """Leading and trailing spaces must not change the signature."""
        s_clean = self._sig("testing", "Fix the bug")
        s_padded = self._sig("testing", "  Fix the bug  ")
        self.assertEqual(s_clean, s_padded,
                         "Leading/trailing whitespace must not affect signature")

    # ------------------------------------------------------------------
    # Punctuation stripping
    # ------------------------------------------------------------------
    def test_punctuation_stripped_from_title(self):
        """Punctuation in title must not affect the signature."""
        s_clean = self._sig("testing", "Fix the bug")
        s_punct = self._sig("testing", "Fix the bug!")
        s_comma = self._sig("testing", "Fix, the bug.")
        self.assertEqual(s_clean, s_punct,
                         "Trailing punctuation must not affect signature")
        self.assertEqual(s_clean, s_comma,
                         "Inline punctuation must not affect signature")

    # ------------------------------------------------------------------
    # Case folding
    # ------------------------------------------------------------------
    def test_case_folded_to_lowercase(self):
        """Upper, mixed, and lower case titles must produce the same signature."""
        s_lower = self._sig("testing", "fix the bug in module")
        s_upper = self._sig("testing", "FIX THE BUG IN MODULE")
        s_mixed = self._sig("testing", "Fix The Bug In Module")
        self.assertEqual(s_lower, s_upper, "UPPER case must match lower case")
        self.assertEqual(s_lower, s_mixed, "Mixed case must match lower case")

    # ------------------------------------------------------------------
    # Word order invariance (sorted before hashing)
    # ------------------------------------------------------------------
    def test_word_order_invariant(self):
        """Reordering significant words must not change the signature."""
        s1 = self._sig("testing", "cache audit signature")
        s2 = self._sig("testing", "signature audit cache")
        s3 = self._sig("testing", "audit signature cache")
        self.assertEqual(s1, s2, "Word order must not affect signature (sorted)")
        self.assertEqual(s1, s3, "All permutations must produce same signature")

    # ------------------------------------------------------------------
    # Stop-word-only description treated as empty
    # ------------------------------------------------------------------
    def test_stop_word_only_description_equals_empty(self):
        """A description containing only stop words must not change the signature."""
        s_no_desc = self._sig("testing", "Audit cache")
        s_stop_desc = self._sig("testing", "Audit cache", "the a an is are was were be")
        self.assertEqual(s_no_desc, s_stop_desc,
                         "Stop-word-only description must not change signature")

    def test_default_description_equals_empty_string(self):
        """Omitting description must be equivalent to passing empty string."""
        from pulse.brain.memory.procedural_cache import compute_signature
        s_omitted = compute_signature("testing", "Audit cache")
        s_explicit_empty = compute_signature("testing", "Audit cache", "")
        self.assertEqual(s_omitted, s_explicit_empty,
                         "Omitted description must equal empty-string description")

    # ------------------------------------------------------------------
    # Description with significant terms → different from no-description
    # ------------------------------------------------------------------
    def test_description_with_significant_terms_changes_signature(self):
        """Adding significant (non-stop) words via description must change the signature."""
        s_no_desc = self._sig("testing", "Audit cache")
        s_with_desc = self._sig("testing", "Audit cache",
                                "signature stability validation reliability")
        self.assertNotEqual(s_no_desc, s_with_desc,
                            "Significant description terms must change the signature")

    # ------------------------------------------------------------------
    # SIGNATURE_TERMS boundary: 8 terms vs 9 terms
    # ------------------------------------------------------------------
    def test_exactly_8_significant_terms_used(self):
        """With 8 unique significant terms the 9th (alphabetically later) is dropped."""
        from pulse.brain.memory.procedural_cache import SIGNATURE_TERMS
        self.assertEqual(SIGNATURE_TERMS, 8,
                         "SIGNATURE_TERMS constant must be 8 — change this test if it changes")
        # 8 terms: alpha..theta — signature A
        s8 = self._sig("testing", "alpha beta gamma delta epsilon zeta eta theta")
        # 9 terms — alphabetically 'iota' comes after 'eta' so it replaces nothing at :8
        # sorted: alpha, beta, delta, epsilon, eta, gamma, iota, theta, zeta → :8 = alpha..theta(no zeta)
        s9 = self._sig("testing", "alpha beta gamma delta epsilon zeta eta theta iota")
        self.assertNotEqual(s8, s9,
                            "Adding a 9th significant term must change the signature (SIGNATURE_TERMS cap)")

    def test_8_duplicate_significant_terms_same_as_8_unique(self):
        """Repeating the same terms (duplicates deduped) must not change the signature."""
        s_once = self._sig("testing", "cache audit signature stable")
        # Same 4 terms repeated twice
        s_doubled = self._sig("testing", "cache audit signature stable cache audit signature stable")
        self.assertEqual(s_once, s_doubled,
                         "Duplicate terms deduped — repeated input must not change signature")

    # ------------------------------------------------------------------
    # All-stop-words title collapses to same sig as empty title
    # ------------------------------------------------------------------
    def test_all_stop_words_same_as_empty(self):
        """A title containing only stop words must produce the same sig as an empty title."""
        s_empty = self._sig("testing", "")
        s_stops = self._sig("testing", "the a an is are was were be been have has")
        self.assertEqual(s_empty, s_stops,
                         "All-stop-word title must collapse to same sig as empty title")

    # ------------------------------------------------------------------
    # Domain prefix enforces cross-domain uniqueness
    # ------------------------------------------------------------------
    def test_domain_prefix_enforces_uniqueness_multiple_domains(self):
        """Three different domains with identical titles must all produce unique signatures."""
        domains = ["arch", "testing", "security"]
        sigs = [self._sig(d, "Fix the authentication bug") for d in domains]
        self.assertEqual(len(set(sigs)), len(domains),
                         f"All {len(domains)} domains must produce distinct signatures: {sigs}")

    # ------------------------------------------------------------------
    # Non-ASCII / Unicode stripped, sig still valid
    # ------------------------------------------------------------------
    def test_non_ascii_chars_stripped_sig_valid(self):
        """Non-ASCII characters are not captured by regex — sig must still be valid 16-char hex."""
        sig = self._sig("testing", "Ré-audit tâche système")
        self.assertIsInstance(sig, str)
        self.assertEqual(len(sig), 16)
        self.assertRegex(sig, r'^[0-9a-f]{16}$')

    # ------------------------------------------------------------------
    # Underscored compound words handled consistently
    # ------------------------------------------------------------------
    def test_underscored_compound_words_stable(self):
        """task_id, file_path etc. are matched as single tokens and produce stable sigs."""
        s1 = self._sig("testing", "Fix task_id collision in cache")
        s2 = self._sig("testing", "Fix task_id collision in cache")
        self.assertEqual(s1, s2, "Underscore compound words must produce stable signatures")

    def test_short_words_under_3_chars_filtered(self):
        """Words shorter than 3 characters are filtered by regex — must not affect sig."""
        s_with = self._sig("testing", "Fix it up by go")
        s_without = self._sig("testing", "")
        # "fix" is 3 chars and passes, "it/up/by/go" are 2 chars and are filtered
        # "fix" is not a stop word so s_with != s_without
        sig_fix_only = self._sig("testing", "fix")
        self.assertEqual(s_with, sig_fix_only,
                         "2-char words must be filtered; only 'fix' survives from the title")


# ---------------------------------------------------------------------------
# STDP Confidence Updates on Task Success (Audit)
# ---------------------------------------------------------------------------
class TestSTDPConfidenceOnTaskSuccess(unittest.TestCase):
    """Audit: apply_stdp correctly updates temporal edge confidence on task success/failure.

    Covers:
    - Causal edge created with confidence floor on first success
    - Anti-causal edge created at same floor (both start at max(0.1, delta))
    - Return count matches expected pairs (2 items → 2, 3 items → 6)
    - LTD weakens existing edges on failure
    - Negative delta never creates new edges (failure on unseen pair)
    - Session cleaned up from SESSIONS_FILE after processing
    - Items beyond TEMPORAL_WINDOW_S (120s) do not form pairs
    - Repeated success accumulates confidence above floor
    - Anti-causal increment is weaker than causal on accumulation
    """

    def _make_mem_conn(self):
        import sqlite3
        conn = sqlite3.connect(":memory:")
        conn.row_factory = sqlite3.Row
        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)
            )
        """)
        return conn

    def _make_sessions_file(self, tmpdir, session_id, items_and_offsets_s):
        """Write a SESSIONS_FILE with item timestamps spaced by given offsets (in seconds)."""
        import tempfile
        from datetime import timedelta
        path = Path(tmpdir) / "session_retrievals.json"
        base = datetime(2026, 3, 18, 10, 0, 0, tzinfo=timezone.utc)
        seq = []
        t = base
        for item_id, offset_s in items_and_offsets_s:
            t = base + timedelta(seconds=offset_s)
            seq.append({"item_id": item_id, "ts": t.isoformat()})
        path.write_text(json.dumps({session_id: seq}), encoding="utf-8")
        return path

    def test_apply_stdp_success_creates_causal_edge(self):
        """Task success: A retrieved before B → causal A→B TEMPORAL edge must be created."""
        import tempfile
        from unittest.mock import patch
        from pulse.brain.plasticity.stdp import apply_stdp, LTP_DELTA

        conn = self._make_mem_conn()
        with tempfile.TemporaryDirectory() as tmp:
            sf = self._make_sessions_file(tmp, "s_causal",
                                          [("A", 0), ("B", 5)])
            with patch("pulse.brain.stdp.SESSIONS_FILE", sf), \
                 patch("pulse.core.brain_db.get_conn", return_value=conn):
                apply_stdp("s_causal", True)

        edge = conn.execute(
            "SELECT confidence FROM graph_edges "
            "WHERE from_node='A' AND to_node='B' AND edge_type='TEMPORAL'"
        ).fetchone()
        self.assertIsNotNone(edge, "Causal A→B edge must be created on task success")
        self.assertAlmostEqual(
            edge["confidence"], max(0.1, LTP_DELTA), places=4,
            msg="New causal edge confidence must equal max(0.1, LTP_DELTA)"
        )

    def test_apply_stdp_success_creates_anti_causal_edge(self):
        """Task success: B→A anti-causal edge must also be created (30% of causal signal)."""
        import tempfile
        from unittest.mock import patch
        from pulse.brain.plasticity.stdp import apply_stdp, LTP_DELTA, ANTI_CAUSAL_RATIO

        conn = self._make_mem_conn()
        with tempfile.TemporaryDirectory() as tmp:
            sf = self._make_sessions_file(tmp, "s_anti",
                                          [("A", 0), ("B", 5)])
            with patch("pulse.brain.stdp.SESSIONS_FILE", sf), \
                 patch("pulse.core.brain_db.get_conn", return_value=conn):
                apply_stdp("s_anti", True)

        edge = conn.execute(
            "SELECT confidence FROM graph_edges "
            "WHERE from_node='B' AND to_node='A' AND edge_type='TEMPORAL'"
        ).fetchone()
        self.assertIsNotNone(edge, "Anti-causal B→A edge must be created on task success")
        expected = max(0.1, LTP_DELTA * ANTI_CAUSAL_RATIO)
        self.assertAlmostEqual(
            edge["confidence"], expected, places=4,
            msg="Anti-causal edge confidence must equal max(0.1, LTP*ANTI_CAUSAL_RATIO)"
        )

    def test_apply_stdp_two_items_returns_two(self):
        """2-item session → exactly 2 edge updates (1 causal + 1 anti-causal)."""
        import tempfile
        from unittest.mock import patch
        from pulse.brain.plasticity.stdp import apply_stdp

        conn = self._make_mem_conn()
        with tempfile.TemporaryDirectory() as tmp:
            sf = self._make_sessions_file(tmp, "s_two",
                                          [("X", 0), ("Y", 10)])
            with patch("pulse.brain.stdp.SESSIONS_FILE", sf), \
                 patch("pulse.core.brain_db.get_conn", return_value=conn):
                count = apply_stdp("s_two", True)

        self.assertEqual(count, 2,
                         "2-item session must produce exactly 2 edge updates "
                         "(1 causal X→Y + 1 anti-causal Y→X)")

    def test_apply_stdp_three_items_returns_six(self):
        """3-item session (A,B,C all within window) → 6 edge updates.

        Pairs: (A,B), (A,C), (B,C) → 3 causal + 3 anti-causal = 6 total.
        """
        import tempfile
        from unittest.mock import patch
        from pulse.brain.plasticity.stdp import apply_stdp

        conn = self._make_mem_conn()
        with tempfile.TemporaryDirectory() as tmp:
            sf = self._make_sessions_file(tmp, "s_three",
                                          [("A", 0), ("B", 10), ("C", 20)])
            with patch("pulse.brain.stdp.SESSIONS_FILE", sf), \
                 patch("pulse.core.brain_db.get_conn", return_value=conn):
                count = apply_stdp("s_three", True)

        self.assertEqual(count, 6,
                         "3-item session must produce 6 edge updates "
                         "(3 causal pairs + 3 anti-causal pairs)")

    def test_apply_stdp_failure_weakens_existing_edge(self):
        """Task failure: LTD must reduce confidence of an existing TEMPORAL edge."""
        import tempfile
        from unittest.mock import patch
        from pulse.brain.plasticity.stdp import apply_stdp, LTD_DELTA

        conn = self._make_mem_conn()
        initial_conf = 0.5
        conn.execute(
            "INSERT INTO graph_edges(from_node,to_node,edge_type,confidence,metadata) "
            "VALUES ('P','Q','TEMPORAL',?,'{}')", (initial_conf,)
        )
        conn.commit()

        with tempfile.TemporaryDirectory() as tmp:
            sf = self._make_sessions_file(tmp, "s_fail",
                                          [("P", 0), ("Q", 5)])
            with patch("pulse.brain.stdp.SESSIONS_FILE", sf), \
                 patch("pulse.core.brain_db.get_conn", return_value=conn):
                apply_stdp("s_fail", False)

        row = conn.execute(
            "SELECT confidence FROM graph_edges "
            "WHERE from_node='P' AND to_node='Q' AND edge_type='TEMPORAL'"
        ).fetchone()
        self.assertIsNotNone(row, "Edge should still exist after LTD unless below threshold")
        expected = round(max(0.0, initial_conf + LTD_DELTA), 4)
        self.assertAlmostEqual(
            row["confidence"], expected, places=4,
            msg=f"Existing edge confidence must decrease by LTD_DELTA on failure: "
                f"expected {expected}, got {row['confidence']}"
        )

    def test_apply_stdp_failure_does_not_create_new_edge(self):
        """Failure signal (negative delta) must NOT create new TEMPORAL edges."""
        import tempfile
        from unittest.mock import patch
        from pulse.brain.plasticity.stdp import apply_stdp

        conn = self._make_mem_conn()
        with tempfile.TemporaryDirectory() as tmp:
            sf = self._make_sessions_file(tmp, "s_fail_new",
                                          [("N1", 0), ("N2", 5)])
            with patch("pulse.brain.stdp.SESSIONS_FILE", sf), \
                 patch("pulse.core.brain_db.get_conn", return_value=conn):
                apply_stdp("s_fail_new", False)

        rows = conn.execute(
            "SELECT id FROM graph_edges WHERE edge_type='TEMPORAL'"
        ).fetchall()
        self.assertEqual(len(rows), 0,
                         "Failure-only session must not create any new TEMPORAL edges")

    def test_apply_stdp_cleans_up_session_after_processing(self):
        """Session key must be removed from SESSIONS_FILE after apply_stdp runs."""
        import tempfile
        from unittest.mock import patch
        from pulse.brain.plasticity.stdp import apply_stdp

        conn = self._make_mem_conn()
        with tempfile.TemporaryDirectory() as tmp:
            sf = self._make_sessions_file(tmp, "s_cleanup",
                                          [("R", 0), ("S", 5)])
            with patch("pulse.brain.stdp.SESSIONS_FILE", sf), \
                 patch("pulse.core.brain_db.get_conn", return_value=conn):
                apply_stdp("s_cleanup", True)

            remaining = json.loads(sf.read_text(encoding="utf-8"))
            self.assertNotIn("s_cleanup", remaining,
                             "apply_stdp must remove the session from SESSIONS_FILE "
                             "after processing to prevent double-application")

    def test_apply_stdp_items_beyond_window_do_not_pair(self):
        """Items separated by more than TEMPORAL_WINDOW_S must NOT form a pair."""
        import tempfile
        from unittest.mock import patch
        from pulse.brain.plasticity.stdp import apply_stdp, TEMPORAL_WINDOW_S

        conn = self._make_mem_conn()
        gap = TEMPORAL_WINDOW_S + 10  # Clearly outside window
        with tempfile.TemporaryDirectory() as tmp:
            sf = self._make_sessions_file(tmp, "s_window",
                                          [("W1", 0), ("W2", gap)])
            with patch("pulse.brain.stdp.SESSIONS_FILE", sf), \
                 patch("pulse.core.brain_db.get_conn", return_value=conn):
                count = apply_stdp("s_window", True)

        self.assertEqual(count, 0,
                         f"Items >{TEMPORAL_WINDOW_S}s apart must not form STDP pairs "
                         f"(gap={gap}s)")

    def test_apply_stdp_items_at_window_boundary_do_pair(self):
        """Items separated by exactly TEMPORAL_WINDOW_S must still form a pair (inclusive boundary)."""
        import tempfile
        from unittest.mock import patch
        from pulse.brain.plasticity.stdp import apply_stdp, TEMPORAL_WINDOW_S

        conn = self._make_mem_conn()
        with tempfile.TemporaryDirectory() as tmp:
            sf = self._make_sessions_file(tmp, "s_boundary",
                                          [("B1", 0), ("B2", TEMPORAL_WINDOW_S)])
            with patch("pulse.brain.stdp.SESSIONS_FILE", sf), \
                 patch("pulse.core.brain_db.get_conn", return_value=conn):
                count = apply_stdp("s_boundary", True)

        self.assertEqual(count, 2,
                         f"Items at exactly TEMPORAL_WINDOW_S={TEMPORAL_WINDOW_S}s must "
                         f"still form a pair (boundary is inclusive via <=)")

    def test_apply_stdp_repeated_success_accumulates_causal_confidence(self):
        """Two success passes must push causal A→B confidence above the initial floor."""
        import tempfile
        from unittest.mock import patch
        from pulse.brain.plasticity.stdp import apply_stdp, LTP_DELTA

        conn = self._make_mem_conn()
        with tempfile.TemporaryDirectory() as tmp:
            # First pass
            sf = self._make_sessions_file(tmp, "s_acc1", [("A", 0), ("B", 5)])
            with patch("pulse.brain.stdp.SESSIONS_FILE", sf), \
                 patch("pulse.core.brain_db.get_conn", return_value=conn):
                apply_stdp("s_acc1", True)

            after_first = conn.execute(
                "SELECT confidence FROM graph_edges "
                "WHERE from_node='A' AND to_node='B' AND edge_type='TEMPORAL'"
            ).fetchone()["confidence"]

            # Second pass
            sf2 = self._make_sessions_file(tmp, "s_acc2", [("A", 0), ("B", 5)])
            with patch("pulse.brain.stdp.SESSIONS_FILE", sf2), \
                 patch("pulse.core.brain_db.get_conn", return_value=conn):
                apply_stdp("s_acc2", True)

        after_second = conn.execute(
            "SELECT confidence FROM graph_edges "
            "WHERE from_node='A' AND to_node='B' AND edge_type='TEMPORAL'"
        ).fetchone()["confidence"]

        self.assertAlmostEqual(
            after_second, round(min(1.0, after_first + LTP_DELTA), 4), places=4,
            msg="Repeated success must accumulate causal edge confidence by LTP_DELTA each time"
        )

    def test_apply_stdp_causal_accumulates_faster_than_anti_causal(self):
        """After 2 successes, A→B (causal) must have higher confidence than B→A (anti-causal)."""
        import tempfile
        from unittest.mock import patch
        from pulse.brain.plasticity.stdp import apply_stdp

        conn = self._make_mem_conn()
        with tempfile.TemporaryDirectory() as tmp:
            for i in range(2):
                sf = self._make_sessions_file(tmp, f"s_dir_{i}", [("A", 0), ("B", 5)])
                with patch("pulse.brain.stdp.SESSIONS_FILE", sf), \
                     patch("pulse.core.brain_db.get_conn", return_value=conn):
                    apply_stdp(f"s_dir_{i}", True)

        causal = conn.execute(
            "SELECT confidence FROM graph_edges "
            "WHERE from_node='A' AND to_node='B' AND edge_type='TEMPORAL'"
        ).fetchone()["confidence"]
        anti = conn.execute(
            "SELECT confidence FROM graph_edges "
            "WHERE from_node='B' AND to_node='A' AND edge_type='TEMPORAL'"
        ).fetchone()["confidence"]

        self.assertGreater(causal, anti,
                           f"After 2 successes, causal confidence ({causal:.4f}) must exceed "
                           f"anti-causal ({anti:.4f}) — ANTI_CAUSAL_RATIO < 1.0 must differentiate")


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