`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.
211 lines
7 KiB
Python
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()
|