From bc97d425ef267311162788c67a34f18006cc39b7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 11:51:15 -0400 Subject: [PATCH] feat: directed edges with per-turn delta projector --- chat/db/migrations/0005_edges.sql | 13 +++ chat/state/edges.py | 84 ++++++++++++++++ tests/test_edges.py | 162 ++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 chat/db/migrations/0005_edges.sql create mode 100644 chat/state/edges.py create mode 100644 tests/test_edges.py diff --git a/chat/db/migrations/0005_edges.sql b/chat/db/migrations/0005_edges.sql new file mode 100644 index 0000000..4a0969e --- /dev/null +++ b/chat/db/migrations/0005_edges.sql @@ -0,0 +1,13 @@ +CREATE TABLE edges ( + id INTEGER PRIMARY KEY, + chat_id TEXT, + source_id TEXT NOT NULL, + target_id TEXT NOT NULL, + affinity INTEGER NOT NULL DEFAULT 50, + trust INTEGER NOT NULL DEFAULT 50, + summary TEXT NOT NULL DEFAULT '', + knowledge_json TEXT NOT NULL DEFAULT '[]', + last_interaction_chat_id TEXT, + last_interaction_at TEXT, + UNIQUE (source_id, target_id) +); diff --git a/chat/state/edges.py b/chat/state/edges.py new file mode 100644 index 0000000..03be66b --- /dev/null +++ b/chat/state/edges.py @@ -0,0 +1,84 @@ +from __future__ import annotations +import json +from sqlite3 import Connection +from chat.eventlog.projector import on +from chat.eventlog.log import Event + + +def _clamp(value: int, lo: int = 0, hi: int = 100) -> int: + return max(lo, min(hi, value)) + + +@on("edge_update") +def _apply_edge_update(conn: Connection, e: Event) -> None: + p = e.payload + source_id = p["source_id"] + target_id = p["target_id"] + chat_id = p.get("chat_id") + + # Upsert: ensure a row exists with defaults, then apply deltas. + conn.execute( + "INSERT OR IGNORE INTO edges (chat_id, source_id, target_id) VALUES (?, ?, ?)", + (chat_id, source_id, target_id), + ) + + row = conn.execute( + "SELECT affinity, trust, knowledge_json, last_interaction_chat_id, last_interaction_at " + "FROM edges WHERE source_id = ? AND target_id = ?", + (source_id, target_id), + ).fetchone() + affinity, trust, knowledge_json, last_chat_id, last_at = row + + affinity_delta = int(p.get("affinity_delta", 0)) + trust_delta = int(p.get("trust_delta", 0)) + new_affinity = _clamp(affinity + affinity_delta) + new_trust = _clamp(trust + trust_delta) + + new_facts = p.get("knowledge_facts") or [] + if new_facts: + knowledge = json.loads(knowledge_json) + knowledge.extend(new_facts) + knowledge_json = json.dumps(knowledge) + + payload_at = p.get("last_interaction_at") + payload_chat_id = p.get("last_interaction_chat_id") + if payload_at is not None: + last_at = payload_at + if payload_chat_id is not None: + last_chat_id = payload_chat_id + + conn.execute( + "UPDATE edges SET affinity = ?, trust = ?, knowledge_json = ?, " + "last_interaction_chat_id = ?, last_interaction_at = ? " + "WHERE source_id = ? AND target_id = ?", + (new_affinity, new_trust, knowledge_json, last_chat_id, last_at, + source_id, target_id), + ) + + +def get_edge(conn: Connection, source_id: str, target_id: str) -> dict | None: + row = conn.execute( + "SELECT * FROM edges WHERE source_id = ? AND target_id = ?", + (source_id, target_id), + ).fetchone() + if not row: + return None + cols = [c[1] for c in conn.execute("PRAGMA table_info(edges)").fetchall()] + d = dict(zip(cols, row)) + d["knowledge"] = json.loads(d.pop("knowledge_json")) + return d + + +def list_edges_for(conn: Connection, source_id: str) -> list[dict]: + cur = conn.execute( + "SELECT * FROM edges WHERE source_id = ? ORDER BY target_id", + (source_id,), + ) + rows = cur.fetchall() + cols = [c[1] for c in conn.execute("PRAGMA table_info(edges)").fetchall()] + out: list[dict] = [] + for row in rows: + d = dict(zip(cols, row)) + d["knowledge"] = json.loads(d.pop("knowledge_json")) + out.append(d) + return out diff --git a/tests/test_edges.py b/tests/test_edges.py new file mode 100644 index 0000000..22746fc --- /dev/null +++ b/tests/test_edges.py @@ -0,0 +1,162 @@ +from chat.db.migrate import apply_migrations +from chat.db.connection import open_db +from chat.eventlog.log import append_event +from chat.eventlog.projector import project +from chat.state.edges import get_edge, list_edges_for +import chat.state.entities # registers bot/you handlers +import chat.state.edges # registers edge_update handler + + +def _seed_entities(conn) -> None: + append_event(conn, kind="bot_authored", payload={ + "id": "bot_a", "name": "BotA", "persona": "p", + "voice_samples": [], "traits": [], + "backstory": "", "initial_relationship_to_you": "", + "kickoff_prose": "", + }) + append_event(conn, kind="you_authored", payload={ + "name": "Me", "pronouns": "", "persona": "", + }) + + +def test_edge_update_upsert_applies_first_delta(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_entities(conn) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "affinity_delta": 5, + }) + project(conn) + edge = get_edge(conn, "bot_a", "you") + assert edge is not None + assert edge["affinity"] == 55 + assert edge["trust"] == 50 + assert edge["knowledge"] == [] + assert edge["summary"] == "" + + +def test_edge_update_multiple_deltas_accumulate(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_entities(conn) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "affinity_delta": 5, + }) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "affinity_delta": -3, + "trust_delta": 2, + "knowledge_facts": ["she has a sister"], + }) + project(conn) + edge = get_edge(conn, "bot_a", "you") + assert edge is not None + assert edge["affinity"] == 52 + assert edge["trust"] == 52 + assert edge["knowledge"] == ["she has a sister"] + + +def test_edge_update_clamps_affinity_at_max(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_entities(conn) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "affinity_delta": 5, + }) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "affinity_delta": 100, + }) + project(conn) + edge = get_edge(conn, "bot_a", "you") + assert edge is not None + assert edge["affinity"] == 100 + + +def test_edge_update_clamps_trust_at_min(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_entities(conn) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "trust_delta": -200, + }) + project(conn) + edge = get_edge(conn, "bot_a", "you") + assert edge is not None + assert edge["trust"] == 0 + + +def test_edges_are_directed_and_asymmetric(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_entities(conn) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "affinity_delta": 5, + }) + append_event(conn, kind="edge_update", payload={ + "source_id": "you", "target_id": "bot_a", + "affinity_delta": 10, + }) + project(conn) + forward = get_edge(conn, "bot_a", "you") + reverse = get_edge(conn, "you", "bot_a") + assert forward is not None and reverse is not None + assert forward["affinity"] == 55 + assert reverse["affinity"] == 60 + # Independent rows + assert forward["affinity"] != reverse["affinity"] + + +def test_edge_update_bumps_last_interaction(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_entities(conn) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", + "affinity_delta": 1, + "last_interaction_at": "2026-04-26T10:00:00", + "last_interaction_chat_id": "chat_bot_a", + }) + project(conn) + edge = get_edge(conn, "bot_a", "you") + assert edge is not None + assert edge["last_interaction_at"] == "2026-04-26T10:00:00" + assert edge["last_interaction_chat_id"] == "chat_bot_a" + + +def test_list_edges_for_returns_outgoing_only(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_entities(conn) + append_event(conn, kind="bot_authored", payload={ + "id": "bot_b", "name": "BotB", "persona": "p", + "voice_samples": [], "traits": [], + "backstory": "", "initial_relationship_to_you": "", + "kickoff_prose": "", + }) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "you", "affinity_delta": 1, + }) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_a", "target_id": "bot_b", "affinity_delta": 2, + }) + append_event(conn, kind="edge_update", payload={ + "source_id": "bot_b", "target_id": "bot_a", "affinity_delta": 3, + }) + project(conn) + outgoing = list_edges_for(conn, "bot_a") + targets = [e["target_id"] for e in outgoing] + assert targets == sorted(targets) + assert set(targets) == {"you", "bot_b"}