arborist/tests/test_session.py
russell@unturf.com 508fec6975
session: Merkle-rooted multi-turn Q&A REPL with Bates ledger
`arborist session` is an interactive multi-turn Q&A REPL where every
turn (or fork) mints one node in a per-session SQLite-backed tree.
Each node carries a stable Bates id (`<sid>-<6-digit>`) and folds into
a Merkle subtree-hash chain; the root node's subtree_hash is the
session_root.

Tree shape lets:

- **Forks** happen implicitly: `/cd <bates>` to a prior node, ask
  again → sibling under that parent. Branch points (≥2 children)
  surfaced by `/branches`.
- **Page-refresh caching** stay cheap: a client tracking
  (bates → subtree_hash, body) only refetches subtrees whose hash
  changed. Sibling subtrees that didn't change are byte-identical
  → cache-equivalent. Same property git pack-protocol and IPFS MFS
  use.
- **Audit-chain verification** be per-session and independent: each
  session db has its own session_audit_events with event_hash =
  sha256(prev_hash || canonical_body). `make session-chain-check`
  walks all sessions; 0 breaks each = intact.

Wire:

- arborist/qa/session.py — Session class, Bates minting, Merkle
  recompute on O(depth) insert, audit chain, helpers (list, render,
  resolve <bates|seq|label>).
- arborist/cli.py — `session` subcommand: REPL + --list / --tree
  / --chain-check / --gc / --json flags. Ancestor-titles → retrieval
  keywords (parsed from cited-pointer lines in answer_text) flow down
  the branch via policy["retrieval_keywords"].
- arborist/qa/providence_query.py — honor policy["retrieval_keywords"]:
  augment FTS5 retrieval query without touching cache_key (mirrors
  legacy --retrieval-keywords discipline, #000001).
- Makefile — `make session [SID=...]`, `make session-list`,
  `make session-tree SID=...`, `make session-chain-check`,
  `make session-gc SESSION_KEEP=N`.
- docs/sessions.md — schema, Merkle conventions (portability for
  non-Python consumers), REPL command reference.
- tests/test_session.py — 15 tests: create, resume, add_node, fork
  via cd, branches, root determinism, audit chain (intact + tampered),
  resolve, list, render, sibling-invariance of subtree_hash.

Storage: ~/.arborist/sessions/<sid>.db (self-contained — no FK into
main store). Answers live in providence_cache keyed by cache_key;
session only carries conversation shape. Cache hits stay live across
sessions. Bounded growth via --gc.

Phase 1 scope: tree + Merkle + Bates + retrieval-keyword flow.
NOT in Phase 1: LLM-side conversation_history (threading prior Q&A
into the LLM prompt + conversation_hash). A bare-pronoun follow-up
("who created him?") gets the right retrieval today but the LLM may
still UNGROUNDED because it sees only the new question as user
message. Folding conversation_history into the prompt + cache_key's
conversation_hash dimension is the natural Phase 2.

197 tests pass.
2026-06-01 16:58:08 -04:00

211 lines
7 KiB
Python

"""Tests for arborist.qa.session — Merkle-rooted conversation tree."""
from __future__ import annotations
import sqlite3
from pathlib import Path
import pytest
from arborist.qa.session import (
Session,
list_sessions,
render_tree,
session_path,
)
@pytest.fixture
def session_db(tmp_path: Path) -> Path:
return tmp_path / "test-sid.db"
def test_open_creates_synthetic_root(session_db: Path):
sess = Session.open(session_db, sid="sid001")
assert sess.sid == "sid001"
assert sess.root_bates == "sid001-000000"
root = sess.get_node("sid001-000000")
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
sess.close()
def test_open_resumes_existing_session(session_db: Path):
s1 = Session.open(session_db, sid="sid002")
s1.add_node("first question", "cache-key-1", "STRICT")
root_after_1 = s1.session_root
s1.close()
s2 = Session.open(session_db) # no sid → resume
assert s2.sid == "sid002"
assert s2.session_root == root_after_1
assert s2.current_bates == "sid002-000001"
s2.close()
def test_add_node_mints_bates_and_recomputes_root(session_db: Path):
sess = Session.open(session_db, sid="s")
root_root = sess.session_root
n1 = sess.add_node("q1", "ck1", "STRICT")
assert n1.bates == "s-000001"
assert sess.current_bates == "s-000001"
assert sess.session_root != root_root # tree changed → root changed
n2 = sess.add_node("q2", "ck2", "HYBRID")
assert n2.bates == "s-000002"
assert n2.parent_bates == "s-000001"
sess.close()
def test_fork_via_cd_creates_sibling(session_db: Path):
sess = Session.open(session_db, sid="s")
n1 = sess.add_node("q1", "ck1", "STRICT")
n2 = sess.add_node("q2", "ck2", "STRICT")
# Fork: cd back to n1, ask q3 — q3 is a sibling of q2 under n1.
sess.cd(n1.bates)
n3 = sess.add_node("q3", "ck3", "STRICT")
assert n3.parent_bates == n1.bates
assert sess.current_bates == n3.bates
kids = sess.children_of(n1.bates)
bates_set = {k.bates for k in kids}
assert n2.bates in bates_set
assert n3.bates in bates_set
sess.close()
def test_branches_lists_branch_points(session_db: Path):
sess = Session.open(session_db, sid="s")
n1 = sess.add_node("q1", "ck1", "STRICT")
sess.add_node("q2", "ck2", "STRICT") # child of n1
sess.cd(n1.bates)
sess.add_node("q3", "ck3", "STRICT") # second child of n1 → branch
bps = sess.branches()
assert n1.bates in bps
assert sess.root_bates not in bps # root only has 1 child so far
sess.close()
def test_session_root_changes_per_insert(session_db: Path):
sess = Session.open(session_db, sid="s")
roots = [sess.session_root]
sess.add_node("q1", "ck1", "STRICT")
roots.append(sess.session_root)
sess.add_node("q2", "ck2", "STRICT")
roots.append(sess.session_root)
assert len(set(roots)) == 3 # all distinct
sess.close()
def test_session_root_deterministic(tmp_path: Path):
"""Two sessions with the same insert sequence produce the same root.
Note: timestamps differ in real wall-clock — we fix them by
passing the same created_at via mocking. Here we just confirm the
HASH formula is deterministic when inputs match.
"""
from arborist.qa.session import _compute_node_hash, _compute_subtree_hash
nh = _compute_node_hash("", "s-000001", "q", "ck", "STRICT",
"2026-06-01T00:00:00Z", None)
sh = _compute_subtree_hash(nh, [])
# Same inputs → same hash.
nh2 = _compute_node_hash("", "s-000001", "q", "ck", "STRICT",
"2026-06-01T00:00:00Z", None)
sh2 = _compute_subtree_hash(nh2, [])
assert nh == nh2
assert sh == sh2
def test_path_to_root_walks_ancestors(session_db: Path):
sess = Session.open(session_db, sid="s")
n1 = sess.add_node("q1", "ck1", "STRICT")
n2 = sess.add_node("q2", "ck2", "STRICT")
path = sess.path_to_root(n2.bates)
assert [n.bates for n in path] == [n2.bates, n1.bates, sess.root_bates]
sess.close()
def test_audit_chain_intact_after_inserts(session_db: Path):
sess = Session.open(session_db, sid="s")
sess.add_node("q1", "ck1", "STRICT")
sess.add_node("q2", "ck2", "STRICT")
sess.add_node("q3", "ck3", "HYBRID")
intact, breaks = sess.chain_check()
assert breaks == 0
assert intact >= 4 # session_init + 3 node_added
sess.close()
def test_audit_chain_detects_tampered_event(session_db: Path):
sess = Session.open(session_db, sid="s")
sess.add_node("q1", "ck1", "STRICT")
sess.add_node("q2", "ck2", "STRICT")
# Tamper with one event's body.
sess.conn.execute(
"UPDATE session_audit_events SET body=? "
"WHERE event_type='node_added' LIMIT 1",
('{"tampered": true}',),
)
sess.conn.commit()
intact, breaks = sess.chain_check()
assert breaks >= 1
sess.close()
def test_resolve_accepts_bates_seq_and_label(session_db: Path):
sess = Session.open(session_db, sid="sid")
n1 = sess.add_node("q1", "ck1", "STRICT")
sess.label(n1.bates, "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
sess.close()
def test_list_sessions_finds_db_in_dir(tmp_path: Path):
p1 = tmp_path / "s1.db"
p2 = tmp_path / "s2.db"
Session.open(p1, sid="s1").close()
Session.open(p2, sid="s2").close()
listing = list_sessions(tmp_path)
sids = {s["sid"] for s in listing}
assert sids == {"s1", "s2"}
def test_render_tree_includes_current_marker(session_db: Path):
sess = Session.open(session_db, sid="s")
sess.add_node("first", "ck1", "STRICT")
out = render_tree(sess)
assert "first" in out
assert "← current" in out
sess.close()
def test_subtree_hash_propagates_up(session_db: Path):
"""Inserting a deep node changes the root's subtree_hash."""
sess = Session.open(session_db, sid="s")
n1 = sess.add_node("q1", "ck1", "STRICT")
root_after_1 = sess.session_root
# Add a deep child of n1.
sess.cd(n1.bates)
sess.add_node("q1.child", "ck2", "STRICT")
root_after_2 = sess.session_root
assert root_after_1 != root_after_2
sess.close()
def test_sibling_insert_changes_only_changed_paths(session_db: Path):
"""When a node is added under parent P, P's subtree_hash AND root
change, but unrelated siblings' subtree_hash stays the same."""
sess = Session.open(session_db, sid="s")
a = sess.add_node("a", "ck-a", "STRICT")
sess.cd(sess.root_bates)
b = sess.add_node("b", "ck-b", "STRICT")
a_hash_before = sess.get_node(a.bates).subtree_hash
b_hash_before = sess.get_node(b.bates).subtree_hash
# Add child under a.
sess.cd(a.bates)
sess.add_node("a.child", "ck-ac", "STRICT")
# a's subtree_hash changed; b's didn't.
assert sess.get_node(a.bates).subtree_hash != a_hash_before
assert sess.get_node(b.bates).subtree_hash == b_hash_before
sess.close()