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.
This commit is contained in:
parent
21d2d2774a
commit
508fec6975
6 changed files with 1541 additions and 9 deletions
211
tests/test_session.py
Normal file
211
tests/test_session.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue