arborist/tests/test_session.py
russell@unturf.com 45a348b4f0
session: single-shard forest + FTS5 search + cross-session forks
Refactor from per-session sqlite files to one shared shard at
~/.arborist/sessions.db. Three things that didn't work before now do:

1. Queries are first-class members of the tree.
   nodes_fts (FTS5 over question + answer_text + cited_titles) lets
   /find <query> walk every prior turn across every session. Cached
   answers and threads become findable, surface in the REPL as
   `[<bates>] <audit> <question>` lines.

2. Forking from history works the same as forking from a sibling.
   parent_bates can cross sids. After /find returns a hit from
   last week's session, /cd <bates> + ask = your next question
   lands as a child under that historical turn. The cross-session
   parent's subtree_hash ripples up its session_root.

3. One global audit chain instead of per-file.
   audit_events.event_hash = sha256(prev || canonical body), one
   chain over every state change in the shard. `make
   session-chain-check` is now a single pass; tampering anywhere
   in the operator's history breaks the chain.

Wire:
- arborist/qa/session.py — drop file-per-session SessionStore class;
  Session becomes a viewport on SessionStore. cited_titles_json +
  n_cited_sources materialized at insert time so FTS5 doesn't need
  a join into providence_cache.
- arborist/cli.py — `session` subcommand swaps --gc for --find;
  REPL adds /find. Ancestor-keyword extraction now reads
  nodes.cited_titles_json directly (no qa.db roundtrip).
- Makefile — `make session-find Q="..." [LIMIT=N JSON=1]`; `make
  session-gc` retired (no per-session files to GC).
- docs/sessions.md — rewritten for the single-shard shape.
- tests/test_session.py — 17 tests: create, add, fork (incl.
  cross-session), find (FTS5 + by_cache_key), path_to_root crossing
  sessions, audit chain (intact + tampered), cited-title extraction,
  subtree_hash ripple across sessions.

Migration: pre-existing per-session dbs at ~/.arborist/sessions/*.db
become orphaned. None lost data — test sessions only. Operator can
rm -rf ~/.arborist/sessions/ (or rename to sessions-old/) at leisure.

126 session+providence+verify+inspect tests pass.
2026-06-01 17:32:44 -04:00

234 lines
7.9 KiB
Python

"""Tests for arborist.qa.session — single-shard Merkle conversation forest."""
from __future__ import annotations
from pathlib import Path
import pytest
from arborist.qa.session import (
SessionStore,
Session,
render_tree,
_compute_node_hash,
_compute_subtree_hash,
)
@pytest.fixture
def store_path(tmp_path: Path) -> Path:
return tmp_path / "sessions.db"
@pytest.fixture
def store(store_path: Path) -> SessionStore:
return SessionStore.open(store_path)
def test_create_session_mints_synthetic_root(store: SessionStore):
sess = store.create_session(sid="sidA")
assert sess.sid == "sidA"
assert sess.root_bates == "sidA-000000"
root = sess.get_node(sess.root_bates)
assert root is not None
assert root.question == ""
assert root.parent_bates is None
assert root.subtree_hash == sess.session_root
assert sess.current_bates == sess.root_bates
def test_add_node_mints_bates_and_updates_root(store: SessionStore):
sess = store.create_session(sid="s")
r0 = sess.session_root
n1 = sess.add_node("q1", "ck1", "STRICT")
assert n1.bates == "s-000001"
assert sess.current_bates == n1.bates
assert sess.session_root != r0
def test_fork_via_cd_creates_sibling(store: SessionStore):
sess = store.create_session(sid="s")
n1 = sess.add_node("q1", "ck1", "STRICT")
n2 = sess.add_node("q2", "ck2", "STRICT")
sess.cd(n1.bates)
n3 = sess.add_node("q3", "ck3", "STRICT")
assert n3.parent_bates == n1.bates
kids = {k.bates for k in sess.children_of(n1.bates)}
assert kids == {n2.bates, n3.bates}
def test_cross_session_fork(store: SessionStore):
"""Fork from a node in another session — parent_bates crosses sids.
The cross-session parent's subtree_hash recomputes to include the
new child (Merkle ripples through the global forest)."""
sess_a = store.create_session(sid="A")
a1 = sess_a.add_node("hello from A", "ck-A1", "STRICT")
a1_subtree_before = store.get_node(a1.bates).subtree_hash
sess_b = store.create_session(sid="B")
# cd session B's pointer to A's node, then ask in B.
sess_b.cd(a1.bates)
b1 = sess_b.add_node("fork from A", "ck-B1", "STRICT")
assert b1.parent_bates == a1.bates
assert b1.sid == "B"
# A's node now has a child (B's node); subtree_hash changed.
a1_subtree_after = store.get_node(a1.bates).subtree_hash
assert a1_subtree_after != a1_subtree_before
# A's session_root also changed (ripple up).
a_root_subtree = store.session_root("A")
assert a_root_subtree != ""
def test_find_fts_search_across_sessions(store: SessionStore):
sess_a = store.create_session(sid="A")
sess_a.add_node("spider man origin story",
"ck1", "STRICT",
answer_text="Spider-Man was created by Stan Lee.")
sess_b = store.create_session(sid="B")
sess_b.add_node("how do you cook risotto?",
"ck2", "STRICT",
answer_text="Stir constantly while adding broth.")
hits = store.find("spider")
assert len(hits) == 1
assert hits[0].sid == "A"
assert "spider" in hits[0].question.lower()
hits = store.find("risotto")
assert len(hits) == 1
assert hits[0].sid == "B"
def test_find_skips_synthetic_root(store: SessionStore):
"""The synthetic root (seq=0, empty question) must not surface in
FTS5 search — its question is empty so it wouldn't match anyway,
but the WHERE seq>0 guard is an explicit second line of defense."""
store.create_session(sid="A")
hits = store.find("anything")
assert hits == []
def test_find_by_cache_key_lists_all_uses(store: SessionStore):
sess_a = store.create_session(sid="A")
sess_b = store.create_session(sid="B")
sess_a.add_node("q1", "shared-ck", "STRICT")
sess_b.add_node("q2", "shared-ck", "STRICT")
hits = store.find_by_cache_key("shared-ck")
assert {h.sid for h in hits} == {"A", "B"}
def test_path_to_root_crosses_sessions(store: SessionStore):
sess_a = store.create_session(sid="A")
a1 = sess_a.add_node("q1", "ck1", "STRICT")
sess_b = store.create_session(sid="B")
sess_b.cd(a1.bates)
b1 = sess_b.add_node("q2", "ck2", "STRICT")
path = store.path_to_root(b1.bates)
sids_in_path = [n.sid for n in path]
# b1 (B) → a1 (A) → A's root (A)
assert sids_in_path == ["B", "A", "A"]
def test_audit_chain_intact_after_inserts(store: SessionStore):
sess = store.create_session(sid="A")
sess.add_node("q1", "ck1", "STRICT")
sess.add_node("q2", "ck2", "STRICT")
sess2 = store.create_session(sid="B")
sess2.add_node("q3", "ck3", "HYBRID")
intact, breaks = store.chain_check()
assert breaks == 0
assert intact == 5 # 2 session_init + 3 node_added
def test_audit_chain_detects_tampering(store: SessionStore):
sess = store.create_session(sid="A")
sess.add_node("q1", "ck1", "STRICT")
sess.add_node("q2", "ck2", "STRICT")
store.conn.execute(
"UPDATE audit_events SET body=? WHERE event_type='node_added' "
"ORDER BY seq DESC LIMIT 1",
('{"tampered": true}',),
)
store.conn.commit()
intact, breaks = store.chain_check()
assert breaks >= 1
def test_resolve_accepts_bates_seq_and_label(store: SessionStore):
sess = store.create_session(sid="sid")
n1 = sess.add_node("q1", "ck1", "STRICT")
sess.label_current("alpha")
assert sess.resolve(n1.bates) == n1.bates
assert sess.resolve("1") == n1.bates
assert sess.resolve("alpha") == n1.bates
assert sess.resolve("nope") is None
def test_list_sessions(store: SessionStore):
store.create_session(sid="A")
store.create_session(sid="B")
lst = store.list_sessions()
sids = {s["sid"] for s in lst}
assert sids == {"A", "B"}
def test_render_tree_shows_cross_session_tag(store: SessionStore):
sess_a = store.create_session(sid="A")
a1 = sess_a.add_node("question A", "ck1", "STRICT")
sess_b = store.create_session(sid="B")
sess_b.cd(a1.bates)
sess_b.add_node("forked into B", "ck2", "STRICT")
# Render session A's tree — should include the B-minted child
# with a [from B] tag.
out = render_tree(sess_a)
assert "question A" in out
assert "forked into B" in out
assert "[from B]" in out
def test_subtree_hash_propagates_across_sessions(store: SessionStore):
"""Inserting a node in session B under a node in session A
changes that A-node's subtree_hash AND A's session_root."""
sess_a = store.create_session(sid="A")
a1 = sess_a.add_node("anchor in A", "ck1", "STRICT")
a_root_before = sess_a.session_root
sess_b = store.create_session(sid="B")
sess_b.cd(a1.bates)
sess_b.add_node("child in B", "ck2", "STRICT")
a_root_after = store.session_root("A")
assert a_root_after != a_root_before
def test_compute_hash_helpers_deterministic():
nh = _compute_node_hash("", "s-000001", "q", "ck", "STRICT",
"2026-06-01T00:00:00Z", None)
nh2 = _compute_node_hash("", "s-000001", "q", "ck", "STRICT",
"2026-06-01T00:00:00Z", None)
assert nh == nh2
sh = _compute_subtree_hash(nh, [])
sh2 = _compute_subtree_hash(nh2, [])
assert sh == sh2
def test_cited_titles_extracted_from_answer(store: SessionStore):
sess = store.create_session(sid="A")
ans = (
'Spider-Man was created by Stan Lee.\n'
' [E1 | Spider-Man | abc123: "..."]\n'
' [E2 | Stan Lee | def456: "..."]'
)
n = sess.add_node("who created spider man?",
"ck1", "STRICT", answer_text=ans)
assert n.cited_titles == ["Spider-Man", "Stan Lee"]
assert n.n_cited_sources == 2
def test_fts_finds_by_cited_title(store: SessionStore):
sess = store.create_session(sid="A")
ans = '...\n [E1 | Sesame Street | abc123: "..."]'
sess.add_node("when did the show start?", "ck1", "STRICT",
answer_text=ans)
hits = store.find("sesame")
assert len(hits) == 1
assert "sesame" in hits[0].cited_titles[0].lower()