From 467c5ec1f7077d1000a3cce8a9c9f15de621f4e7 Mon Sep 17 00:00:00 2001 From: R4vager Date: Wed, 20 May 2026 01:08:56 -0400 Subject: [PATCH] Connectome Graph Phase 1 (research Avenue 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-class representation of the inter-subsystem communication graph. Walked existing code + proposals for seed edges. Operationalizes Avenue 5 from research/autonomous-research-avenues-2026-05-20.md. - Migration 073: connectome_nodes (22 seeded) + connectome_edges (18 seeded). Top-degree hub bg_modulators with 5 connections — matches biological intuition. - mcp_tools_connectome: 5 tools (status, node_get, register_node, register_edge, neighbors with BFS). - 11 tests. Phase 2 adds path-finding + cycle detection. Phase 3 auto-updates from runtime observations. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 27 ++ db/migrations/073_connectome.sql | 158 ++++++++++ src/agentmemory/mcp_server.py | 2 + src/agentmemory/mcp_tools_connectome.py | 378 ++++++++++++++++++++++++ tests/test_mcp_tools_connectome.py | 167 +++++++++++ 5 files changed, 732 insertions(+) create mode 100644 db/migrations/073_connectome.sql create mode 100644 src/agentmemory/mcp_tools_connectome.py create mode 100644 tests/test_mcp_tools_connectome.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c17766f..361bf3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added — Connectome Graph Phase 1 (research Avenue 5) + +First-class representation of the inter-subsystem communication graph. +Walked the existing code base + design proposals for the seed edges. +Operationalizes Avenue 5 from `research/autonomous-research-avenues-2026-05-20.md`. + +- **Migration 073** — `connectome_nodes` (22 seeded: 16 subsystems + + 3 tables + 1 dial + 2 event_buses) + `connectome_edges` (18 seeded + edges across writes_to / reads_from / modulates / gates / depends_on + / broadcasts_to). +- **Top-degree hub:** `bg_modulators` with 5 connections — matches + biological intuition that the neuromod dial cluster is the central + integrator. +- **`agentmemory.mcp_tools_connectome`** — 5 MCP tools: + - `connectome_status` — counts + breakdowns + top-10 by degree + - `connectome_node_get` — node + all incoming/outgoing edges + - `connectome_register_node` — idempotent UPSERT + - `connectome_register_edge` — idempotent UPSERT keyed by (source, target, edge_type) + - `connectome_neighbors` — BFS-walk in 'in'/'out'/'both' direction up to depth 6 +- **11 tests** covering seed topology, status, node-get, registration + idempotency, validation, BFS neighbor traversal. + +Phase 2 adds path-finding + cycle detection + reachability tools. +Phase 3 auto-updates the connectome from runtime observations (which +subsystem actually called which, with last_observed_at timestamps +moving as evidence accumulates). + ### Added — issue #116 Phase 1-A: retrieval pathway log External architecture memo (issue #116, "Thalamus, Basal Ganglia, and diff --git a/db/migrations/073_connectome.sql b/db/migrations/073_connectome.sql new file mode 100644 index 0000000..039b040 --- /dev/null +++ b/db/migrations/073_connectome.sql @@ -0,0 +1,158 @@ +-- Migration 073: connectome graph — Phase 1 schema +-- +-- Operationalizes Avenue 5 from research/autonomous-research-avenues-2026-05-20.md: +-- "Connectome as a first-class graph." A first-class representation of +-- which subsystems talk to which, with edge types and weights. Enables +-- cycle detection, "what writes to this dial" queries, and impact +-- analysis when changing or disabling a subsystem. +-- +-- Phase 1 ships the schema + seed catalog of known edges (walked from +-- the existing code base). Phase 2 adds query tools for graph +-- traversal and impact analysis. Phase 3 auto-updates the connectome +-- from runtime observations (which subsystem actually called which). +-- +-- Edge types: +-- writes_to — source mutates a column owned by target +-- reads_from — source reads target's state but doesn't mutate +-- modulates — source adjusts target's gain / threshold / weight +-- gates — source decides whether target's output passes +-- depends_on — source requires target's schema/tables to exist +-- broadcasts_to — source fires events target subscribes to +-- +-- Rollback: +-- DROP TABLE IF EXISTS connectome_edges; +-- DROP TABLE IF EXISTS connectome_nodes; +-- DELETE FROM schema_version WHERE version = 73; +-- +-- IDEMPOTENT. + +CREATE TABLE IF NOT EXISTS connectome_nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + category TEXT NOT NULL CHECK(category IN ( + 'subsystem', 'table', 'dial', 'event_bus', 'external' + )), + description TEXT, + schema_version_introduced INTEGER, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')) +); +CREATE INDEX IF NOT EXISTS idx_cn_category ON connectome_nodes(category); + +CREATE TABLE IF NOT EXISTS connectome_edges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL, + target_id INTEGER NOT NULL, + edge_type TEXT NOT NULL CHECK(edge_type IN ( + 'writes_to', 'reads_from', 'modulates', 'gates', + 'depends_on', 'broadcasts_to' + )), + weight REAL NOT NULL DEFAULT 1.0 CHECK(weight BETWEEN 0.0 AND 1.0), + description TEXT, + evidence_source TEXT, -- e.g. 'code:bg_shadow.py:broadcast_td_error' + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), + last_observed_at TEXT, + FOREIGN KEY (source_id) REFERENCES connectome_nodes(id) ON DELETE CASCADE, + FOREIGN KEY (target_id) REFERENCES connectome_nodes(id) ON DELETE CASCADE, + UNIQUE (source_id, target_id, edge_type) +); +CREATE INDEX IF NOT EXISTS idx_ce_source ON connectome_edges(source_id); +CREATE INDEX IF NOT EXISTS idx_ce_target ON connectome_edges(target_id); +CREATE INDEX IF NOT EXISTS idx_ce_type ON connectome_edges(edge_type); + +-- Seed the known subsystem nodes (walked from db/migrations + src/agentmemory). +INSERT OR IGNORE INTO connectome_nodes (name, category, description, schema_version_introduced) VALUES + -- Brain subsystems + ('thalamus', 'subsystem', 'typed routing layer + salience + gate', 50), + ('basal_ganglia', 'subsystem', 'five-loop action selection + Go/NoGo learning', 54), + ('cerebellum', 'subsystem', 'forward-model layer with predict/observe per partner', 56), + ('amygdala', 'subsystem', 'rapid valence/threat tagging', 58), + ('hippocampus_dg_ca3', 'subsystem', 'DG pattern separation + CA3 pattern completion', 59), + ('hippocampus_ca1', 'subsystem', 'CA1 match/mismatch + Subiculum output bridge', 71), + ('acc', 'subsystem', 'in-flight conflict / surprise / EVC monitor', 60), + ('dmn', 'subsystem', 'default mode network — offline simulation', 61), + ('drives', 'subsystem', 'hypothalamic-analog homeostatic drives', 62), + ('insula', 'subsystem', 'self-state interoception', 63), + ('pfc', 'subsystem', 'named PFC slots (dlPFC/vmPFC/OFC/frontopolar)', 64), + ('entorhinal_grid', 'subsystem', '48 grid cells across 3 scales', 65), + ('lc', 'subsystem', 'locus coeruleus — NE on surprise', 67), + ('nb', 'subsystem', 'nucleus basalis — ACh on attention', 68), + ('aras', 'subsystem', 'ascending reticular activating system — global arousal', 69), + ('habenula', 'subsystem', 'lateral habenula — anti-reward / negative-PE', 70), + ('workspace', 'subsystem', 'global neuronal workspace broadcasts', NULL), + ('workspace_bandwidth', 'subsystem', 'top-K-per-epoch bandwidth limit on workspace', 72), + -- Buses / shared dials + ('bg_td_events', 'event_bus', 'TD-error broadcast bus (δ from outcome_annotate)', 54), + ('bg_modulators', 'dial', 'global neuromod dials (tonic_da, lc_ne, serotonin, acetylcholine)', 54), + ('workspace_broadcasts', 'table', 'global workspace broadcast event log', NULL), + ('cerebellum_boundaries', 'table', 'cerebellum-fired boundary markers above threshold', 56); + +-- Seed the known edges (walked from code as of 2026-05-20). +-- Subsystem → bus / dial edges first. +INSERT OR IGNORE INTO connectome_edges (source_id, target_id, edge_type, weight, description, evidence_source) VALUES + -- BG closes the actor-critic loop through bg_td_events + bg_modulators + ((SELECT id FROM connectome_nodes WHERE name='basal_ganglia'), + (SELECT id FROM connectome_nodes WHERE name='bg_td_events'), + 'writes_to', 1.0, 'broadcast_td_error inserts TD events', 'code:bg_shadow.py:broadcast_td_error'), + ((SELECT id FROM connectome_nodes WHERE name='basal_ganglia'), + (SELECT id FROM connectome_nodes WHERE name='bg_modulators'), + 'writes_to', 1.0, 'bg_modulator_set + cascade', 'code:mcp_tools_basal_ganglia.py'), + -- Cerebellum fires boundaries + feeds bg_td_events + ((SELECT id FROM connectome_nodes WHERE name='cerebellum'), + (SELECT id FROM connectome_nodes WHERE name='cerebellum_boundaries'), + 'writes_to', 1.0, 'high |delta_forward| → boundary marker', 'code:cerebellum_shadow.py'), + ((SELECT id FROM connectome_nodes WHERE name='cerebellum'), + (SELECT id FROM connectome_nodes WHERE name='bg_td_events'), + 'broadcasts_to', 0.8, 'cerebellum delta supplements BG TD signal', 'code:cerebellum_shadow.py'), + ((SELECT id FROM connectome_nodes WHERE name='cerebellum_boundaries'), + (SELECT id FROM connectome_nodes WHERE name='workspace_broadcasts'), + 'broadcasts_to', 1.0, 'high-PE events fire workspace broadcasts', 'migration:057_cerebellum_workspace_bridge.sql'), + -- Thalamus reads modulators (cascade source) + ((SELECT id FROM connectome_nodes WHERE name='thalamus'), + (SELECT id FROM connectome_nodes WHERE name='bg_modulators'), + 'reads_from', 1.0, 'tonic_da → wake_focused vs wake_exploratory cascade', 'commit:32c466e'), + ((SELECT id FROM connectome_nodes WHERE name='basal_ganglia'), + (SELECT id FROM connectome_nodes WHERE name='thalamus'), + 'modulates', 1.0, 'BG modulator cascade to thalamus mode', 'commit:32c466e'), + -- LC + NB + ARAS + Habenula (tonight's shipping) all write/read bg_modulators + ((SELECT id FROM connectome_nodes WHERE name='lc'), + (SELECT id FROM connectome_nodes WHERE name='bg_modulators'), + 'reads_from', 1.0, 'reads lc_ne dial in lc_status (Phase 1); will write in Phase 2', 'code:mcp_tools_locus_coeruleus.py'), + ((SELECT id FROM connectome_nodes WHERE name='nb'), + (SELECT id FROM connectome_nodes WHERE name='bg_modulators'), + 'depends_on', 1.0, 'migration 068 adds acetylcholine column to bg_modulators', 'migration:068_nucleus_basalis.sql'), + ((SELECT id FROM connectome_nodes WHERE name='aras'), + (SELECT id FROM connectome_nodes WHERE name='lc'), + 'modulates', 0.5, 'Phase 3: low arousal damps LC phasic firings (planned)', 'docs/proposals/aras.md'), + ((SELECT id FROM connectome_nodes WHERE name='aras'), + (SELECT id FROM connectome_nodes WHERE name='nb'), + 'modulates', 0.5, 'Phase 3: high arousal amplifies NB attention bursts (planned)', 'docs/proposals/aras.md'), + ((SELECT id FROM connectome_nodes WHERE name='habenula'), + (SELECT id FROM connectome_nodes WHERE name='bg_modulators'), + 'modulates', 0.5, 'Phase 3: suggested_da_damp subtracts from tonic_da (planned)', 'docs/proposals/habenula.md'), + -- Hippocampal chain + ((SELECT id FROM connectome_nodes WHERE name='hippocampus_dg_ca3'), + (SELECT id FROM connectome_nodes WHERE name='hippocampus_ca1'), + 'broadcasts_to', 1.0, 'CA3 pattern completion feeds CA1 comparison (Phase 2 will auto-wire)', 'docs/proposals/hippocampus_ca1_subiculum.md'), + ((SELECT id FROM connectome_nodes WHERE name='hippocampus_ca1'), + (SELECT id FROM connectome_nodes WHERE name='workspace_broadcasts'), + 'broadcasts_to', 0.5, 'Phase 3: subiculum output fires workspace broadcasts (planned)', 'docs/proposals/hippocampus_ca1_subiculum.md'), + -- Workspace bandwidth gates workspace_broadcasts + ((SELECT id FROM connectome_nodes WHERE name='workspace_bandwidth'), + (SELECT id FROM connectome_nodes WHERE name='workspace_broadcasts'), + 'gates', 1.0, 'Phase 2: every workspace broadcast checked against bandwidth limit (planned)', 'docs/proposals (workspace_bandwidth)'), + -- ACC fires BG holds + ((SELECT id FROM connectome_nodes WHERE name='acc'), + (SELECT id FROM connectome_nodes WHERE name='basal_ganglia'), + 'gates', 0.8, 'high EVC fires BG holds', 'code:mcp_tools_acc.py'), + -- DMN reads insula state + ((SELECT id FROM connectome_nodes WHERE name='dmn'), + (SELECT id FROM connectome_nodes WHERE name='insula'), + 'reads_from', 0.7, 'DMN simulation conditions on self-state vector', 'code:mcp_tools_dmn.py'), + -- Insula subscribers + ((SELECT id FROM connectome_nodes WHERE name='insula'), + (SELECT id FROM connectome_nodes WHERE name='drives'), + 'broadcasts_to', 0.7, 'self-state changes notify drive monitors', 'code:mcp_tools_insula.py'); + +INSERT OR IGNORE INTO schema_version (version, description, applied_at) +VALUES (73, 'connectome Phase 1: 2 tables (nodes + edges) + seed catalog', + strftime('%Y-%m-%dT%H:%M:%S', 'now')); diff --git a/src/agentmemory/mcp_server.py b/src/agentmemory/mcp_server.py index 95e5f45..d3ed185 100755 --- a/src/agentmemory/mcp_server.py +++ b/src/agentmemory/mcp_server.py @@ -48,6 +48,7 @@ mcp_tools_belief_merge, mcp_tools_beliefs, mcp_tools_cerebellum, + mcp_tools_connectome, mcp_tools_consolidation, mcp_tools_dmn, mcp_tools_drives, @@ -91,6 +92,7 @@ mcp_tools_belief_merge, mcp_tools_beliefs, mcp_tools_cerebellum, + mcp_tools_connectome, mcp_tools_consolidation, mcp_tools_dmn, mcp_tools_drives, diff --git a/src/agentmemory/mcp_tools_connectome.py b/src/agentmemory/mcp_tools_connectome.py new file mode 100644 index 0000000..f6674cc --- /dev/null +++ b/src/agentmemory/mcp_tools_connectome.py @@ -0,0 +1,378 @@ +"""brainctl MCP tools — connectome graph (Avenue 5 from research memo). + +Phase 1 first-class representation of the inter-subsystem +communication graph. Walks the existing code base + design proposals +for the seed edges; Phase 2 will provide query tools for path-finding, +cycle detection, and impact analysis; Phase 3 auto-updates from +runtime observations. + +See research/autonomous-research-avenues-2026-05-20.md §Avenue 5 for +the design rationale. +""" +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_NODE_CATEGORIES = {"subsystem", "table", "dial", "event_bus", "external"} +VALID_EDGE_TYPES = { + "writes_to", "reads_from", "modulates", "gates", + "depends_on", "broadcasts_to", +} + + +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 _require_schema(conn: sqlite3.Connection) -> str | None: + for t in ("connectome_nodes", "connectome_edges"): + if not conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (t,) + ).fetchone(): + return (f"connectome schema missing: {t} not found. " + "Run `brainctl migrate` (migration 073).") + return None + + +def tool_connectome_status(**_kw: Any) -> dict[str, Any]: + """Connectome graph health summary.""" + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + node_count = conn.execute("SELECT COUNT(*) FROM connectome_nodes").fetchone()[0] + edge_count = conn.execute("SELECT COUNT(*) FROM connectome_edges").fetchone()[0] + by_category = _rows(conn.execute( + "SELECT category, COUNT(*) AS n FROM connectome_nodes GROUP BY category" + ).fetchall()) + by_edge_type = _rows(conn.execute( + "SELECT edge_type, COUNT(*) AS n FROM connectome_edges GROUP BY edge_type" + ).fetchall()) + top_degree = _rows(conn.execute( + """ + SELECT n.name, n.category, + (SELECT COUNT(*) FROM connectome_edges WHERE source_id = n.id) AS out_degree, + (SELECT COUNT(*) FROM connectome_edges WHERE target_id = n.id) AS in_degree, + (SELECT COUNT(*) FROM connectome_edges + WHERE source_id = n.id OR target_id = n.id) AS total_degree + FROM connectome_nodes n + ORDER BY total_degree DESC LIMIT 10 + """ + ).fetchall()) + return { + "ok": True, + "node_count": node_count, + "edge_count": edge_count, + "nodes_by_category": by_category, + "edges_by_type": by_edge_type, + "top_degree": top_degree, + } + + +def tool_connectome_node_get(name: str, **_kw: Any) -> dict[str, Any]: + """Get a node by name with its incoming + outgoing edges.""" + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + node = conn.execute( + "SELECT * FROM connectome_nodes WHERE name = ?", (name,) + ).fetchone() + if not node: + return {"error": f"node {name!r} not found"} + outgoing = _rows(conn.execute( + """ + SELECT e.id, e.edge_type, e.weight, e.description, e.evidence_source, + t.name AS target, t.category AS target_category + FROM connectome_edges e + JOIN connectome_nodes t ON t.id = e.target_id + WHERE e.source_id = ? + ORDER BY e.weight DESC, t.name + """, (node["id"],), + ).fetchall()) + incoming = _rows(conn.execute( + """ + SELECT e.id, e.edge_type, e.weight, e.description, e.evidence_source, + s.name AS source, s.category AS source_category + FROM connectome_edges e + JOIN connectome_nodes s ON s.id = e.source_id + WHERE e.target_id = ? + ORDER BY e.weight DESC, s.name + """, (node["id"],), + ).fetchall()) + return { + "ok": True, + "node": dict(node), + "outgoing": outgoing, + "incoming": incoming, + "out_degree": len(outgoing), + "in_degree": len(incoming), + } + + +def tool_connectome_register_node( + name: str, category: str, description: str | None = None, + schema_version_introduced: int | None = None, + **_kw: Any, +) -> dict[str, Any]: + if category not in VALID_NODE_CATEGORIES: + return {"error": f"invalid category {category!r}; expected one of {sorted(VALID_NODE_CATEGORIES)}"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + conn.execute( + """ + INSERT INTO connectome_nodes (name, category, description, schema_version_introduced) + VALUES (?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + category = excluded.category, + description = COALESCE(excluded.description, connectome_nodes.description), + schema_version_introduced = COALESCE(excluded.schema_version_introduced, connectome_nodes.schema_version_introduced) + """, + (name, category, description, schema_version_introduced), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM connectome_nodes WHERE name = ?", (name,) + ).fetchone() + return {"ok": True, "node": dict(row) if row else None} + + +def tool_connectome_register_edge( + source: str, target: str, edge_type: str, + weight: float = 1.0, description: str | None = None, + evidence_source: str | None = None, + **_kw: Any, +) -> dict[str, Any]: + if edge_type not in VALID_EDGE_TYPES: + return {"error": f"invalid edge_type {edge_type!r}; expected one of {sorted(VALID_EDGE_TYPES)}"} + if not 0.0 <= weight <= 1.0: + return {"error": "weight must be in [0, 1]"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + src = conn.execute("SELECT id FROM connectome_nodes WHERE name = ?", (source,)).fetchone() + tgt = conn.execute("SELECT id FROM connectome_nodes WHERE name = ?", (target,)).fetchone() + if not src: + return {"error": f"source node {source!r} not registered"} + if not tgt: + return {"error": f"target node {target!r} not registered"} + conn.execute( + """ + INSERT INTO connectome_edges + (source_id, target_id, edge_type, weight, description, evidence_source) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(source_id, target_id, edge_type) DO UPDATE SET + weight = excluded.weight, + description = COALESCE(excluded.description, connectome_edges.description), + evidence_source = COALESCE(excluded.evidence_source, connectome_edges.evidence_source), + last_observed_at = strftime('%Y-%m-%dT%H:%M:%S', 'now') + """, + (src["id"], tgt["id"], edge_type, float(weight), description, evidence_source), + ) + conn.commit() + return { + "ok": True, "source": source, "target": target, "edge_type": edge_type, + "weight": float(weight), + } + + +def tool_connectome_neighbors( + name: str, direction: str = "out", + edge_type: str | None = None, depth: int = 1, + **_kw: Any, +) -> dict[str, Any]: + """BFS-walk the connectome from `name` up to `depth` hops in + `direction` ('in' | 'out' | 'both').""" + if direction not in {"in", "out", "both"}: + return {"error": "direction must be in {'in', 'out', 'both'}"} + if depth < 1 or depth > 6: + return {"error": "depth must be in [1, 6]"} + if edge_type is not None and edge_type not in VALID_EDGE_TYPES: + return {"error": f"invalid edge_type {edge_type!r}"} + with _db() as conn: + conn.row_factory = sqlite3.Row + err = _require_schema(conn) + if err: + return {"error": err} + root = conn.execute( + "SELECT id FROM connectome_nodes WHERE name = ?", (name,) + ).fetchone() + if not root: + return {"error": f"node {name!r} not found"} + et_clause = ("AND edge_type = ?",) if edge_type else ("",) + et_params = (edge_type,) if edge_type else () + visited = {root["id"]: 0} + frontier = {root["id"]} + paths: list[dict[str, Any]] = [] + for hop in range(1, depth + 1): + next_frontier: set[int] = set() + for nid in frontier: + if direction in {"out", "both"}: + rows = conn.execute( + f""" + SELECT target_id AS other, edge_type, weight + FROM connectome_edges WHERE source_id = ? {et_clause[0]} + """, + (nid, *et_params), + ).fetchall() + for r in rows: + if r["other"] not in visited: + visited[r["other"]] = hop + next_frontier.add(r["other"]) + paths.append({"from_id": nid, "to_id": r["other"], + "edge_type": r["edge_type"], + "weight": r["weight"], "hop": hop, + "direction": "out"}) + if direction in {"in", "both"}: + rows = conn.execute( + f""" + SELECT source_id AS other, edge_type, weight + FROM connectome_edges WHERE target_id = ? {et_clause[0]} + """, + (nid, *et_params), + ).fetchall() + for r in rows: + if r["other"] not in visited: + visited[r["other"]] = hop + next_frontier.add(r["other"]) + paths.append({"from_id": r["other"], "to_id": nid, + "edge_type": r["edge_type"], + "weight": r["weight"], "hop": hop, + "direction": "in"}) + frontier = next_frontier + if not frontier: + break + # Resolve IDs to names for the output. + id_to_name = { + r["id"]: r["name"] for r in conn.execute( + f"SELECT id, name FROM connectome_nodes WHERE id IN ({','.join('?' * len(visited))})", + tuple(visited.keys()), + ).fetchall() + } + nodes_out = [{"name": id_to_name[nid], "hop": hop} for nid, hop in visited.items()] + paths_out = [ + {**p, "from": id_to_name.get(p["from_id"]), "to": id_to_name.get(p["to_id"])} + for p in paths + ] + return { + "ok": True, "root": name, "direction": direction, "depth": depth, + "edge_type_filter": edge_type, "nodes": nodes_out, "edges": paths_out, + } + + +TOOLS: list[Tool] = [ + Tool( + name="connectome_status", + description=( + "Connectome graph health: node/edge counts, breakdown by category + edge_type, " + "and top-10 by total degree." + ), + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="connectome_node_get", + description=( + "Get one connectome node by name + all its incoming and outgoing edges, " + "sorted by weight." + ), + inputSchema={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + ), + Tool( + name="connectome_register_node", + description=( + "Idempotent UPSERT on connectome_nodes. category ∈ {subsystem, table, dial, " + "event_bus, external}. Use when a new subsystem ships." + ), + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "category": {"type": "string", "enum": sorted(VALID_NODE_CATEGORIES)}, + "description": {"type": "string"}, + "schema_version_introduced": {"type": "integer"}, + }, + "required": ["name", "category"], + }, + ), + Tool( + name="connectome_register_edge", + description=( + "Idempotent UPSERT on connectome_edges. edge_type ∈ {writes_to, reads_from, " + "modulates, gates, depends_on, broadcasts_to}. weight in [0, 1]. UPSERT key is " + "(source, target, edge_type)." + ), + inputSchema={ + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "edge_type": {"type": "string", "enum": sorted(VALID_EDGE_TYPES)}, + "weight": {"type": "number", "default": 1.0}, + "description": {"type": "string"}, + "evidence_source": {"type": "string"}, + }, + "required": ["source", "target", "edge_type"], + }, + ), + Tool( + name="connectome_neighbors", + description=( + "BFS-walk from a node up to `depth` hops in `direction` ('in', 'out', 'both'). " + "Optional edge_type filter. Returns nodes (with hop count) + edges. Use for " + "impact analysis ('what depends on X') and reachability queries." + ), + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string"}, + "direction": {"type": "string", "enum": ["in", "out", "both"], "default": "out"}, + "edge_type": {"type": "string", "enum": sorted(VALID_EDGE_TYPES)}, + "depth": {"type": "integer", "default": 1, "minimum": 1, "maximum": 6}, + }, + "required": ["name"], + }, + ), +] + + +_CONNECTOME_TOOLS = { + "connectome_status": tool_connectome_status, + "connectome_node_get": tool_connectome_node_get, + "connectome_register_node": tool_connectome_register_node, + "connectome_register_edge": tool_connectome_register_edge, + "connectome_neighbors": tool_connectome_neighbors, +} + +DISPATCH: dict[str, Any] = { + name: (lambda _func=func, **kw: _func(**kw)) + for name, func in _CONNECTOME_TOOLS.items() +} + + +def register_tools() -> tuple[list[Tool], dict[str, Any]]: + return TOOLS, DISPATCH diff --git a/tests/test_mcp_tools_connectome.py b/tests/test_mcp_tools_connectome.py new file mode 100644 index 0000000..2be795a --- /dev/null +++ b/tests/test_mcp_tools_connectome.py @@ -0,0 +1,167 @@ +"""Tests for mcp_tools_connectome — Phase 1.""" +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +MIGRATION_073 = REPO_ROOT / "db" / "migrations" / "073_connectome.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_073.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_connectome as mod + monkeypatch.setattr(mod, "DB_PATH", db) + return mod + + +def test_migration_seeds_known_topology(tmp_path): + db = tmp_path / "brain.db" + _apply(db) + conn = sqlite3.connect(str(db)) + try: + n = conn.execute("SELECT COUNT(*) FROM connectome_nodes").fetchone()[0] + e = conn.execute("SELECT COUNT(*) FROM connectome_edges").fetchone()[0] + assert n >= 20 # 22 seeded + assert e >= 15 # 18 seeded + # bg_modulators should be a high-degree hub (matches biology) + hub = conn.execute( + """ + SELECT n.name, + (SELECT COUNT(*) FROM connectome_edges + WHERE source_id = n.id OR target_id = n.id) AS deg + FROM connectome_nodes n WHERE n.name = 'bg_modulators' + """ + ).fetchone() + assert hub[1] >= 3 + finally: + conn.close() + + +def test_status_returns_summary(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_connectome_status() + assert out["ok"] is True + assert out["node_count"] >= 20 + assert out["edge_count"] >= 15 + assert any(t["edge_type"] == "writes_to" for t in out["edges_by_type"]) + assert any(c["category"] == "subsystem" for c in out["nodes_by_category"]) + # top_degree[0] should be a real hub + assert out["top_degree"][0]["total_degree"] >= 3 + + +def test_node_get_returns_neighbors(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_connectome_node_get(name="basal_ganglia") + assert out["ok"] is True + assert out["node"]["name"] == "basal_ganglia" + assert out["out_degree"] >= 1 + # Outgoing edges should include a write to bg_td_events + target_names = {e["target"] for e in out["outgoing"]} + assert "bg_td_events" in target_names + + +def test_node_get_unknown(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_connectome_node_get(name="not-a-node") + assert "error" in out + + +def test_register_node_idempotent(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + first = mod.tool_connectome_register_node( + name="vta_snc", category="subsystem", + description="dopamine source (research avenue 7)", + ) + # Second call without description → COALESCE preserves first description + second = mod.tool_connectome_register_node( + name="vta_snc", category="subsystem", + ) + assert first["ok"] and second["ok"] + assert first["node"]["id"] == second["node"]["id"] + assert "research avenue 7" in second["node"]["description"] + + +def test_register_node_validates_category(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_connectome_register_node(name="x", category="bad-cat") + assert "error" in out + + +def test_register_edge_idempotent(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + # Register nodes if not present (they may already be seeded) + mod.tool_connectome_register_node(name="vta_snc", category="subsystem") + first = mod.tool_connectome_register_edge( + source="vta_snc", target="bg_modulators", edge_type="writes_to", + weight=0.9, evidence_source="docs:avenue 7", + ) + second = mod.tool_connectome_register_edge( + source="vta_snc", target="bg_modulators", edge_type="writes_to", + weight=1.0, + ) + assert first["ok"] and second["ok"] + # Verify the second call updated the weight + out = mod.tool_connectome_node_get(name="vta_snc") + bgmod_edges = [e for e in out["outgoing"] if e["target"] == "bg_modulators"] + assert len(bgmod_edges) == 1 + assert bgmod_edges[0]["weight"] == 1.0 + + +def test_register_edge_rejects_unknown_node(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_connectome_register_edge( + source="nonexistent", target="bg_modulators", edge_type="writes_to", + ) + assert "error" in out + + +def test_register_edge_validates(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + assert "error" in mod.tool_connectome_register_edge( + source="basal_ganglia", target="bg_modulators", edge_type="bogus", + ) + assert "error" in mod.tool_connectome_register_edge( + source="basal_ganglia", target="bg_modulators", edge_type="writes_to", + weight=1.5, + ) + + +def test_neighbors_bfs_depth_1(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + out = mod.tool_connectome_neighbors(name="basal_ganglia", direction="out", depth=1) + assert out["ok"] is True + names = {n["name"] for n in out["nodes"]} + assert "bg_td_events" in names or "bg_modulators" in names + # depth-1 should not include 2-hop reachable nodes from BG + # (unless they happen to be direct outgoing — varies by seed) + + +def test_neighbors_validates_direction_and_depth(tmp_path, monkeypatch): + mod = _make_db(tmp_path, monkeypatch) + assert "error" in mod.tool_connectome_neighbors(name="basal_ganglia", direction="invalid") + assert "error" in mod.tool_connectome_neighbors(name="basal_ganglia", depth=0) + assert "error" in mod.tool_connectome_neighbors(name="basal_ganglia", depth=99)