diff --git a/CHANGELOG.md b/CHANGELOG.md index c17766f..464f571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added — Hippocampus CA1 + Subiculum Phase 1 (trisynaptic loop completion) + +Completes the hippocampal trisynaptic loop. Migration 059 shipped DG + +CA3; this adds **CA1** (match/mismatch detector) + **Subiculum** +(hippocampal output bridge to cortex). + +- **Migration 071** — 3 tables (`hippocampus_ca1_comparisons`, + `hippocampus_ca1_state` single row, `hippocampus_subiculum_outputs`). +- **`agentmemory.mcp_tools_hippocampus_ca1`** — 4 MCP tools. +- **9 tests**. +- **Design proposal** at `docs/proposals/hippocampus_ca1_subiculum.md`. + ### Added — issue #116 Phase 1-A: retrieval pathway log External architecture memo (issue #116, "Thalamus, Basal Ganglia, and diff --git a/db/migrations/071_hippocampus_ca1_subiculum.sql b/db/migrations/071_hippocampus_ca1_subiculum.sql new file mode 100644 index 0000000..e778e5d --- /dev/null +++ b/db/migrations/071_hippocampus_ca1_subiculum.sql @@ -0,0 +1,61 @@ +-- Migration 071: hippocampus CA1 + Subiculum — Phase 1 schema +-- +-- Completes the hippocampal trisynaptic loop. Migration 059 shipped +-- DG (pattern separation) + CA3 (pattern completion); this migration +-- adds CA1 (match/mismatch detector) + Subiculum (cortical bridge). +-- +-- Phase 1 is inspection-only / additive. Tables + tools only. Phase 2 +-- hooks CA1 into the existing hippocampus_* pipeline. Phase 3 wires +-- Subiculum into workspace_broadcasts. Phase 4 enforces. +-- +-- Rollback: +-- DROP TABLE IF EXISTS hippocampus_subiculum_outputs; +-- DROP TABLE IF EXISTS hippocampus_ca1_state; +-- DROP TABLE IF EXISTS hippocampus_ca1_comparisons; +-- DELETE FROM schema_version WHERE version = 71; +-- +-- IDEMPOTENT. + +CREATE TABLE IF NOT EXISTS hippocampus_ca1_comparisons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + compared_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + agent_id TEXT, + memory_id INTEGER, + ec_input_hash TEXT, + ca3_output_hash TEXT, + match_score REAL NOT NULL CHECK(match_score BETWEEN 0.0 AND 1.0), + novelty_score REAL NOT NULL CHECK(novelty_score BETWEEN 0.0 AND 1.0), + classification TEXT NOT NULL CHECK(classification IN ('match', 'mismatch', 'partial', 'ambiguous')), + notes TEXT +); +CREATE INDEX IF NOT EXISTS idx_ca1_cmp_recent ON hippocampus_ca1_comparisons(compared_at); +CREATE INDEX IF NOT EXISTS idx_ca1_cmp_agent ON hippocampus_ca1_comparisons(agent_id, compared_at); +CREATE INDEX IF NOT EXISTS idx_ca1_cmp_class ON hippocampus_ca1_comparisons(classification, compared_at); + +CREATE TABLE IF NOT EXISTS hippocampus_ca1_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + recent_match_rate REAL NOT NULL DEFAULT 0.5 CHECK(recent_match_rate BETWEEN 0.0 AND 1.0), + recent_novelty_rate REAL NOT NULL DEFAULT 0.5 CHECK(recent_novelty_rate BETWEEN 0.0 AND 1.0), + total_comparisons INTEGER NOT NULL DEFAULT 0, + last_comparison_at TEXT, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) +); +INSERT OR IGNORE INTO hippocampus_ca1_state (id) VALUES (1); + +CREATE TABLE IF NOT EXISTS hippocampus_subiculum_outputs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + output_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + agent_id TEXT, + memory_id INTEGER, + ca1_comparison_id INTEGER, + target_channel TEXT NOT NULL CHECK(target_channel IN ('cortex_general', 'workspace_broadcast', 'thalamus_relay', 'other')), + output_strength REAL NOT NULL DEFAULT 0.5 CHECK(output_strength BETWEEN 0.0 AND 1.0), + notes TEXT, + FOREIGN KEY (ca1_comparison_id) REFERENCES hippocampus_ca1_comparisons(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_sub_outputs_recent ON hippocampus_subiculum_outputs(output_at); +CREATE INDEX IF NOT EXISTS idx_sub_outputs_target ON hippocampus_subiculum_outputs(target_channel, output_at); + +INSERT OR IGNORE INTO schema_version (version, description, applied_at) +VALUES (71, 'hippocampus CA1 + Subiculum Phase 1: 3 tables completing the trisynaptic loop', + strftime('%Y-%m-%dT%H:%M:%S', 'now')); diff --git a/docs/proposals/hippocampus_ca1_subiculum.md b/docs/proposals/hippocampus_ca1_subiculum.md new file mode 100644 index 0000000..209896d --- /dev/null +++ b/docs/proposals/hippocampus_ca1_subiculum.md @@ -0,0 +1,100 @@ +# Proposal: Hippocampal CA1 + Subiculum (completion of the trisynaptic loop) + +**Status:** Phase 1 design + implementation, branch `brain-regions-ca1-phase-1`. +**Author:** Claude Opus 4.7 (overnight chain — 5th region tonight) +**Date:** 2026-05-20 +**Scope:** Extension of migration 059 (DG+CA3). Completes the hippocampal trisynaptic loop. Phase 1 inspection-only. + +--- + +## TL;DR + +Migration 059 shipped Dentate Gyrus (pattern separation) + CA3 (pattern completion). The hippocampal trisynaptic loop is: + +``` +Entorhinal Cortex L2 → DG → CA3 → CA1 → Subiculum → Entorhinal Cortex L5 → out + ↑ + missing +``` + +CA1 is **computationally key** — it sits between CA3's pattern-completion output and Subiculum's cortical-bridge output. Its function: **compare incoming entorhinal input against CA3's recall output**. Match = familiarity confirmation. Mismatch = novelty detection / prediction error. + +Subiculum is the hippocampal output structure. Without it, the hippocampal "memory trace" has no clean way to influence the rest of the brain — it's bottled up in CA3. + +In brainctl terms, missing CA1+Subiculum means: +- No first-class **match/mismatch detector** for memory writes (would CA3's completion match what the entorhinal grid currently sees?) +- No first-class **hippocampal output channel** (writes just land in `memories` and get picked up by full-text search; biology has the hippocampus actively pushing certain content into cortex via Subiculum→EC deep layers) + +Phase 1 ships schema for both subfields + 4 MCP tools. **No behavior change.** Phase 2 hooks CA1 into the existing hippocampus_dg_separate / hippocampus_ca3_complete pipeline. Phase 3 wires Subiculum into workspace_broadcasts. Phase 4 enforces. + +## Architectural placement + +``` + ┌──────────── existing ────────────┐ + │ │ + EC L2 ──→ DG ──→ CA3 (pattern completion) │ + │ │ │ + │ └──→ CA1 ◀── EC L3 ─────────┤ + │ ▼ (this PR) │ + │ compare → match/mismatch │ + │ │ │ + │ ▼ │ + │ Subiculum │ + │ (this PR) │ + │ │ │ + │ ▼ │ + │ EC L5/L6 → cortex │ + └──────────────────────────────────┘ +``` + +## Phase 1 schema (migration 071) + +```sql +CREATE TABLE hippocampus_ca1_comparisons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + compared_at TEXT NOT NULL DEFAULT (...), + agent_id TEXT, + memory_id INTEGER, -- the memory under consideration + ec_input_hash TEXT, -- hash of the entorhinal-layer input (what's coming in now) + ca3_output_hash TEXT, -- hash of the CA3 pattern-completion output (what we'd recall) + match_score REAL NOT NULL CHECK(match_score BETWEEN 0.0 AND 1.0), + novelty_score REAL NOT NULL CHECK(novelty_score BETWEEN 0.0 AND 1.0), + classification TEXT NOT NULL CHECK(classification IN ('match', 'mismatch', 'partial', 'ambiguous')), + notes TEXT +); + +CREATE TABLE hippocampus_ca1_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + recent_match_rate REAL DEFAULT 0.5, -- EWMA of recent match_score + recent_novelty_rate REAL DEFAULT 0.5, -- EWMA of recent novelty_score + total_comparisons INTEGER DEFAULT 0, + last_comparison_at TEXT, + updated_at TEXT NOT NULL DEFAULT (...) +); + +CREATE TABLE hippocampus_subiculum_outputs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + output_at TEXT NOT NULL DEFAULT (...), + agent_id TEXT, + memory_id INTEGER, + ca1_comparison_id INTEGER REFERENCES hippocampus_ca1_comparisons(id), + target_channel TEXT NOT NULL CHECK(target_channel IN ('cortex_general', 'workspace_broadcast', 'thalamus_relay', 'other')), + output_strength REAL NOT NULL DEFAULT 0.5 CHECK(output_strength BETWEEN 0.0 AND 1.0), + notes TEXT +); +``` + +## Phase 1 MCP tools + +- `ca1_compare(memory_id, ec_input_hash, ca3_output_hash, classification)` — record one comparison; computes match_score/novelty_score from hash similarity (Phase 1 uses naive Hamming-distance hash compare; Phase 2 swaps to embedding-cosine) +- `ca1_status` — current state + last 5 comparisons + 24h aggregate +- `subiculum_output` — manually record a subiculum output event with target_channel +- `ca1_subiculum_history(limit, since, agent_id, classification)` — paginated comparison + output history + +## DoD + +- Migration 071 applies cleanly to /tmp + live (with backup) +- Single state row + tables exist +- 4 MCP tools registered + discoverable +- ≥6 tests passing +- Branch pushed, PR open, NOT merged to main diff --git a/src/agentmemory/mcp_server.py b/src/agentmemory/mcp_server.py index 95e5f45..a282be6 100755 --- a/src/agentmemory/mcp_server.py +++ b/src/agentmemory/mcp_server.py @@ -56,6 +56,7 @@ mcp_tools_federation, mcp_tools_health, mcp_tools_hippocampal_subfields, + mcp_tools_hippocampus_ca1, mcp_tools_immunity, mcp_tools_insula, mcp_tools_knowledge, @@ -99,6 +100,7 @@ mcp_tools_federation, mcp_tools_health, mcp_tools_hippocampal_subfields, + mcp_tools_hippocampus_ca1, mcp_tools_immunity, mcp_tools_insula, mcp_tools_knowledge, diff --git a/src/agentmemory/mcp_tools_hippocampus_ca1.py b/src/agentmemory/mcp_tools_hippocampus_ca1.py new file mode 100644 index 0000000..f6fd856 --- /dev/null +++ b/src/agentmemory/mcp_tools_hippocampus_ca1.py @@ -0,0 +1,332 @@ +"""brainctl MCP tools — hippocampus CA1 + Subiculum. + +Phase 1 per docs/proposals/hippocampus_ca1_subiculum.md. Completes +the trisynaptic loop after migration 059 shipped DG + CA3. + +CA1 = match/mismatch detector (compare entorhinal input vs CA3 output). +Subiculum = hippocampal output bridge to cortex. + +Phase 1 is inspection + manual writes; Phase 2 auto-wires into the +hippocampus_dg/ca3 pipeline. +""" +from __future__ import annotations + +import sqlite3 +from collections.abc import Iterable +from pathlib import Path +from typing import Any + +from mcp.types import Tool + +from agentmemory.lib.mcp_helpers import open_db +from agentmemory.paths import get_db_path + +DB_PATH: Path = get_db_path() + +VALID_CLASSIFICATIONS = {"match", "mismatch", "partial", "ambiguous"} +VALID_TARGET_CHANNELS = {"cortex_general", "workspace_broadcast", "thalamus_relay", "other"} + + +def _db() -> sqlite3.Connection: + return open_db(str(DB_PATH)) + + +def _rows(rows: Iterable[sqlite3.Row]) -> list[dict[str, Any]]: + return [dict(r) for r in rows] + + +def _table_exists(conn: sqlite3.Connection, name: str) -> bool: + return bool( + conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,) + ).fetchone() + ) + + +def _require_schema(conn: sqlite3.Connection) -> str | None: + missing = [ + t for t in ("hippocampus_ca1_comparisons", "hippocampus_ca1_state", + "hippocampus_subiculum_outputs") + if not _table_exists(conn, t) + ] + if missing: + return ("CA1/subiculum schema missing: " + ", ".join(missing) + + ". Run `brainctl migrate` (migration 071).") + return None + + +def _hash_similarity(h1: str | None, h2: str | None) -> float: + """Naive bit-string similarity over two hex hashes of equal length. + + Phase 1 stand-in for proper embedding cosine. Returns 0.0 if either + hash is None or lengths differ. + """ + if not h1 or not h2 or len(h1) != len(h2): + return 0.0 + matches = sum(1 for a, b in zip(h1, h2) if a == b) + return matches / len(h1) + + +def tool_ca1_compare( + memory_id: int | None = None, + ec_input_hash: str | None = None, + ca3_output_hash: str | None = None, + classification: str | None = None, + agent_id: str | None = None, + notes: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + """Record one CA1 comparison. + + If classification is not passed explicitly, it's derived from the + computed match_score: ≥0.85 = match, ≤0.15 = mismatch, 0.40-0.60 = + ambiguous, else partial. + """ + match_score = _hash_similarity(ec_input_hash, ca3_output_hash) + novelty_score = 1.0 - match_score + if classification is None: + if match_score >= 0.85: + classification = "match" + elif match_score <= 0.15: + classification = "mismatch" + elif 0.40 <= match_score <= 0.60: + classification = "ambiguous" + else: + classification = "partial" + if classification not in VALID_CLASSIFICATIONS: + return {"error": f"invalid classification {classification!r}"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + cur = conn.execute( + """ + INSERT INTO hippocampus_ca1_comparisons + (agent_id, memory_id, ec_input_hash, ca3_output_hash, + match_score, novelty_score, classification, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (agent_id, memory_id, ec_input_hash, ca3_output_hash, + match_score, novelty_score, classification, notes), + ) + comparison_id = cur.lastrowid + state = conn.execute("SELECT * FROM hippocampus_ca1_state WHERE id = 1").fetchone() + old_match = float(state["recent_match_rate"]) if state else 0.5 + old_nov = float(state["recent_novelty_rate"]) if state else 0.5 + new_match = 0.9 * old_match + 0.1 * match_score + new_nov = 0.9 * old_nov + 0.1 * novelty_score + conn.execute( + """ + UPDATE hippocampus_ca1_state SET + recent_match_rate = ?, recent_novelty_rate = ?, + total_comparisons = total_comparisons + 1, + last_comparison_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), + updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + WHERE id = 1 + """, + (new_match, new_nov), + ) + conn.commit() + return { + "ok": True, "comparison_id": comparison_id, + "match_score": match_score, "novelty_score": novelty_score, + "classification": classification, + "new_recent_match_rate": new_match, + "new_recent_novelty_rate": new_nov, + } + + +def tool_ca1_status(agent_id: str | None = None, **_kw: Any) -> dict[str, Any]: + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + state = conn.execute("SELECT * FROM hippocampus_ca1_state WHERE id = 1").fetchone() + last_5 = _rows(conn.execute( + """ + SELECT id, compared_at, agent_id, memory_id, classification, + match_score, novelty_score + FROM hippocampus_ca1_comparisons + WHERE (? IS NULL OR agent_id = ?) + ORDER BY id DESC LIMIT 5 + """, (agent_id, agent_id) + ).fetchall()) + agg = conn.execute( + """ + SELECT COUNT(*) AS n, + COALESCE(AVG(match_score), 0.0) AS mean_match, + COALESCE(AVG(novelty_score), 0.0) AS mean_novelty, + SUM(CASE WHEN classification='match' THEN 1 ELSE 0 END) AS n_match, + SUM(CASE WHEN classification='mismatch' THEN 1 ELSE 0 END) AS n_mismatch, + SUM(CASE WHEN classification='partial' THEN 1 ELSE 0 END) AS n_partial, + SUM(CASE WHEN classification='ambiguous' THEN 1 ELSE 0 END) AS n_ambiguous + FROM hippocampus_ca1_comparisons + WHERE compared_at >= datetime('now', '-24 hours') + AND (? IS NULL OR agent_id = ?) + """, (agent_id, agent_id) + ).fetchone() + return { + "ok": True, + "state": dict(state) if state else None, + "last_5_comparisons": last_5, + "aggregate_24h": dict(agg) if agg else {}, + } + + +def tool_subiculum_output( + target_channel: str, + memory_id: int | None = None, + ca1_comparison_id: int | None = None, + output_strength: float = 0.5, + agent_id: str | None = None, + notes: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + if target_channel not in VALID_TARGET_CHANNELS: + return {"error": f"invalid target_channel {target_channel!r}; expected one of {sorted(VALID_TARGET_CHANNELS)}"} + if not 0.0 <= output_strength <= 1.0: + return {"error": "output_strength must be in [0, 1]"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + cur = conn.execute( + """ + INSERT INTO hippocampus_subiculum_outputs + (agent_id, memory_id, ca1_comparison_id, target_channel, output_strength, notes) + VALUES (?, ?, ?, ?, ?, ?) + """, + (agent_id, memory_id, ca1_comparison_id, target_channel, + float(output_strength), notes), + ) + conn.commit() + return {"ok": True, "output_id": cur.lastrowid, "target_channel": target_channel} + + +def tool_ca1_subiculum_history( + limit: int = 20, since: str | None = None, + agent_id: str | None = None, classification: str | None = None, + target_channel: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + """Paginated combined history of CA1 comparisons + Subiculum outputs. + + Filters apply per-bucket: classification → comparisons; target_channel + → outputs. Returns two lists. + """ + limit = max(1, min(int(limit), 200)) + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + cmp_clauses, cmp_params = [], [] + out_clauses, out_params = [], [] + if since: + cmp_clauses.append("compared_at >= ?"); cmp_params.append(since) + out_clauses.append("output_at >= ?"); out_params.append(since) + if agent_id: + cmp_clauses.append("agent_id = ?"); cmp_params.append(agent_id) + out_clauses.append("agent_id = ?"); out_params.append(agent_id) + if classification: + cmp_clauses.append("classification = ?"); cmp_params.append(classification) + if target_channel: + out_clauses.append("target_channel = ?"); out_params.append(target_channel) + cmp_where = "WHERE " + " AND ".join(cmp_clauses) if cmp_clauses else "" + out_where = "WHERE " + " AND ".join(out_clauses) if out_clauses else "" + comparisons = _rows(conn.execute( + f"SELECT * FROM hippocampus_ca1_comparisons {cmp_where} ORDER BY id DESC LIMIT ?", + (*cmp_params, limit), + ).fetchall()) + outputs = _rows(conn.execute( + f"SELECT * FROM hippocampus_subiculum_outputs {out_where} ORDER BY id DESC LIMIT ?", + (*out_params, limit), + ).fetchall()) + return {"ok": True, "comparisons": comparisons, "outputs": outputs} + + +TOOLS: list[Tool] = [ + Tool( + name="ca1_compare", + description=( + "Record one CA1 match/mismatch comparison (entorhinal input vs CA3 output). " + "match_score auto-computed from hash similarity. classification auto-derived " + "if not passed (≥0.85=match, ≤0.15=mismatch, [0.4,0.6]=ambiguous, else partial)." + ), + inputSchema={ + "type": "object", + "properties": { + "memory_id": {"type": "integer"}, + "ec_input_hash": {"type": "string"}, + "ca3_output_hash": {"type": "string"}, + "classification": {"type": "string", "enum": sorted(VALID_CLASSIFICATIONS)}, + "agent_id": {"type": "string"}, + "notes": {"type": "string"}, + }, + }, + ), + Tool( + name="ca1_status", + description="CA1 Phase 1 inspection. State (EWMA match/novelty rates) + last 5 comparisons + 24h aggregate.", + inputSchema={"type": "object", "properties": {"agent_id": {"type": "string"}}}, + ), + Tool( + name="subiculum_output", + description=( + "Record one Subiculum output event. target_channel ∈ {cortex_general, " + "workspace_broadcast, thalamus_relay, other}. output_strength in [0,1]. " + "Optional ca1_comparison_id links the output back to the comparison that " + "drove it." + ), + inputSchema={ + "type": "object", + "properties": { + "target_channel": {"type": "string", "enum": sorted(VALID_TARGET_CHANNELS)}, + "memory_id": {"type": "integer"}, + "ca1_comparison_id": {"type": "integer"}, + "output_strength": {"type": "number", "default": 0.5}, + "agent_id": {"type": "string"}, + "notes": {"type": "string"}, + }, + "required": ["target_channel"], + }, + ), + Tool( + name="ca1_subiculum_history", + description=( + "Combined paginated history of CA1 comparisons + Subiculum outputs. " + "Filters: since, agent_id, classification (cmp-side), target_channel " + "(out-side). limit clamped to [1, 200]." + ), + inputSchema={ + "type": "object", + "properties": { + "limit": {"type": "integer", "default": 20}, + "since": {"type": "string"}, + "agent_id": {"type": "string"}, + "classification": {"type": "string", "enum": sorted(VALID_CLASSIFICATIONS)}, + "target_channel": {"type": "string", "enum": sorted(VALID_TARGET_CHANNELS)}, + }, + }, + ), +] + + +_CA1_TOOLS = { + "ca1_compare": tool_ca1_compare, + "ca1_status": tool_ca1_status, + "subiculum_output": tool_subiculum_output, + "ca1_subiculum_history": tool_ca1_subiculum_history, +} + +DISPATCH: dict[str, Any] = { + name: (lambda _func=func, **kw: _func(**kw)) + for name, func in _CA1_TOOLS.items() +} + + +def register_tools() -> tuple[list[Tool], dict[str, Any]]: + return TOOLS, DISPATCH diff --git a/tests/test_mcp_tools_hippocampus_ca1.py b/tests/test_mcp_tools_hippocampus_ca1.py new file mode 100644 index 0000000..713123e --- /dev/null +++ b/tests/test_mcp_tools_hippocampus_ca1.py @@ -0,0 +1,146 @@ +"""Tests for mcp_tools_hippocampus_ca1 — Phase 1.""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +MIGRATION_071 = REPO_ROOT / "db" / "migrations" / "071_hippocampus_ca1_subiculum.sql" + + +def _bootstrap(conn): + conn.executescript( + """ + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + description TEXT, + applied_at TEXT + ); + """ + ) + + +def _apply(db_path): + conn = sqlite3.connect(str(db_path)) + try: + _bootstrap(conn) + conn.executescript(MIGRATION_071.read_text()) + conn.commit() + finally: + conn.close() + + +def _make_db(tmp_path, monkeypatch): + db = tmp_path / "brain.db" + _apply(db) + from agentmemory import mcp_tools_hippocampus_ca1 as mod + monkeypatch.setattr(mod, "DB_PATH", db) + return mod + + +def test_migration_applies_with_seed_state(tmp_path): + db = tmp_path / "brain.db" + _apply(db) + conn = sqlite3.connect(str(db)) + try: + state = conn.execute( + "SELECT recent_match_rate, recent_novelty_rate, total_comparisons FROM hippocampus_ca1_state" + ).fetchone() + assert state == (0.5, 0.5, 0) + sv = conn.execute("SELECT version FROM schema_version WHERE version=71").fetchone() + assert sv == (71,) + finally: + conn.close() + + +def test_ca1_compare_match_classification(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + # Identical hashes → match_score=1.0 → 'match' + out = mod.tool_ca1_compare( + ec_input_hash="abc123def456", + ca3_output_hash="abc123def456", + agent_id="a1", + ) + assert out["ok"] is True + assert out["match_score"] == 1.0 + assert out["novelty_score"] == 0.0 + assert out["classification"] == "match" + + +def test_ca1_compare_mismatch_classification(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + # Fully disjoint hashes → match_score < 0.15 → 'mismatch' + out = mod.tool_ca1_compare( + ec_input_hash="aaaaaaaaaaaa", + ca3_output_hash="bbbbbbbbbbbb", + agent_id="a1", + ) + assert out["ok"] is True + assert out["match_score"] == 0.0 + assert out["classification"] == "mismatch" + + +def test_ca1_compare_partial_classification(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + # 6/12 chars match → 0.5 → 'ambiguous' + out = mod.tool_ca1_compare( + ec_input_hash="aaaaaa______", + ca3_output_hash="aaaaaa000000", + ) + assert out["ok"] is True + assert abs(out["match_score"] - 0.5) < 1e-9 + assert out["classification"] == "ambiguous" + + +def test_ca1_status_reflects_comparisons(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + mod.tool_ca1_compare(ec_input_hash="aaa", ca3_output_hash="aaa", agent_id="a1") + mod.tool_ca1_compare(ec_input_hash="bbb", ca3_output_hash="ccc", agent_id="a1") + status = mod.tool_ca1_status(agent_id="a1") + assert status["ok"] is True + assert status["aggregate_24h"]["n"] == 2 + assert status["aggregate_24h"]["n_match"] == 1 + assert status["aggregate_24h"]["n_mismatch"] == 1 + assert status["state"]["total_comparisons"] == 2 + + +def test_subiculum_output_writes(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_subiculum_output( + target_channel="workspace_broadcast", memory_id=42, + output_strength=0.8, agent_id="a1", + ) + assert out["ok"] is True + assert out["target_channel"] == "workspace_broadcast" + + +def test_subiculum_output_validates_target(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_subiculum_output(target_channel="nope") + assert "error" in out + + +def test_subiculum_output_validates_strength(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_subiculum_output( + target_channel="cortex_general", output_strength=1.5, + ) + assert "error" in out + + +def test_history_filters(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + mod.tool_ca1_compare(ec_input_hash="aaa", ca3_output_hash="aaa", agent_id="a1") + mod.tool_ca1_compare(ec_input_hash="aaa", ca3_output_hash="bbb", agent_id="a2") + mod.tool_subiculum_output(target_channel="workspace_broadcast", agent_id="a1") + mod.tool_subiculum_output(target_channel="thalamus_relay", agent_id="a1") + all_h = mod.tool_ca1_subiculum_history(limit=10) + assert len(all_h["comparisons"]) == 2 + assert len(all_h["outputs"]) == 2 + a1 = mod.tool_ca1_subiculum_history(limit=10, agent_id="a1") + assert len(a1["comparisons"]) == 1 + assert len(a1["outputs"]) == 2 + matches = mod.tool_ca1_subiculum_history(limit=10, classification="match") + assert len(matches["comparisons"]) == 1 + workspace = mod.tool_ca1_subiculum_history(limit=10, target_channel="workspace_broadcast") + assert len(workspace["outputs"]) == 1