arborist/tests/test_selfmodel.py
russell@unturf.com a9fdcf41d5
selfmodel: land ticket #000014 (identity record + falsification)
SelfModel binds an arborist agent's identity to bytes a verifier can
recompute: model_profile_hash, verifier_method_root, governance hash,
canonicalization/chunking versions, optional patch + memory roots,
sorted capability-claim hashes. Hard-hash committed; no soft state in
preimage. State transitions live on the row, not the body, so the
selfmodel_root stays stable across live → stale → falsified.

Surface:

- arborist.selfmodel.{canonical,snapshot,store,falsify}
- CLI: arborist selfmodel snapshot|show|falsify|list
- Schema: selfmodel_records + selfmodel_capability_claims (additive)
- Audit events: selfmodel_snapshot_landed,
  selfmodel_capability_claim_added, selfmodel_falsified,
  selfmodel_marked_stale (all chain via existing append_audit)

Also folds in:
- CLAUDE.md operational rule: arborist stays Python-only; non-Python
  toolchains live in sibling repos. Forks/clients/servers in any
  language follow our schemas + canonical encodings.
- Ticket #000016 update: ZK lives in sibling repo arborist-zk-bench;
  arborist gains at most a wire-format consumer, never a Rust dep.
- Schema migrations also stub capital_ledger and memory_records
  tables for tickets #000020 and #000017 respectively (additive,
  empty until those modules land).

Tests: tests/test_selfmodel.py (14 cases; canonical-JSON stability,
root order-invariance, snapshot determinism, store idempotency,
audit events, falsify/mark_stale semantics, audit-chain integrity).
Full suite: 1012 passed, 36 skipped.
2026-05-07 16:36:34 -04:00

333 lines
10 KiB
Python

"""SelfModel — schema, snapshot, falsification (ticket #000014).
Covers:
- canonical-JSON round-trip stability
- selfmodel_root invariance under capability-claim ordering
- snapshot determinism given fixed store state
- store_snapshot idempotency on the same root
- falsify and mark_stale audit events
- chain-check stays clean after SelfModel landing
"""
from __future__ import annotations
import time
import pytest
from arborist.selfmodel import (
CapabilityClaim,
SelfModel,
canonical_body,
claim_hash,
falsify,
latest,
load,
mark_stale,
selfmodel_root,
snapshot,
store_snapshot,
)
from arborist.selfmodel.canonical import SCHEMA_VERSION, with_claims
from arborist.selfmodel.store import claims_for
from arborist.store import connect, latest_event_hash, transaction
# --- canonical-JSON ---------------------------------------------------
def _bare_model() -> SelfModel:
return SelfModel(
schema_version=SCHEMA_VERSION,
parent_selfmodel_root=None,
model_profile_hash="model-profile-aaaa",
verifier_method_root="verifier-root-bbbb",
governance_policy_hash="gov-cccc",
canonicalization_version="norm-v1",
chunking_version="tok-512-v1",
accepted_patch_root=None,
rejected_patch_root=None,
memory_root=None,
)
def test_canonical_body_stable_across_field_reorder():
a = _bare_model()
# Build "different" object with same logical content via dict round-trip.
b = SelfModel(
chunking_version="tok-512-v1",
accepted_patch_root=None,
canonicalization_version="norm-v1",
governance_policy_hash="gov-cccc",
memory_root=None,
model_profile_hash="model-profile-aaaa",
parent_selfmodel_root=None,
rejected_patch_root=None,
schema_version=SCHEMA_VERSION,
verifier_method_root="verifier-root-bbbb",
)
assert canonical_body(a) == canonical_body(b)
assert selfmodel_root(a) == selfmodel_root(b)
def test_selfmodel_root_invariant_under_claim_order():
base = _bare_model()
c1 = CapabilityClaim(
metric="strict_rate", threshold=0.50, eval_digest="d1"
)
c2 = CapabilityClaim(
metric="directive_coverage", threshold=0.99, eval_digest="d2"
)
a = with_claims(base, [c1, c2])
b = with_claims(base, [c2, c1])
assert selfmodel_root(a) == selfmodel_root(b)
def test_canonical_body_changes_when_field_changes():
a = _bare_model()
b = SelfModel(**{**a.__dict__, "model_profile_hash": "different"})
assert selfmodel_root(a) != selfmodel_root(b)
# --- snapshot ---------------------------------------------------------
def test_snapshot_returns_selfmodel_with_defaults_on_empty_db(tmp_path):
db = tmp_path / "shard.db"
conn = connect(db)
try:
with transaction(conn):
sm = snapshot(conn)
assert sm.schema_version == SCHEMA_VERSION
assert sm.governance_policy_hash == "unset"
assert sm.model_profile_hash == "unset"
assert sm.canonicalization_version == "norm-v1"
assert sm.chunking_version == "tok-512-v1"
assert sm.parent_selfmodel_root is None
# verifier_method_root is SHA-256 over an empty list; always 64 hex.
assert len(sm.verifier_method_root) == 64
finally:
conn.close()
def test_snapshot_deterministic_on_same_state(tmp_path):
db = tmp_path / "shard.db"
conn = connect(db)
try:
with transaction(conn):
a = snapshot(conn)
b = snapshot(conn)
assert selfmodel_root(a) == selfmodel_root(b)
finally:
conn.close()
# --- store_snapshot ---------------------------------------------------
def test_store_snapshot_persists_and_emits_audit(tmp_path):
db = tmp_path / "shard.db"
conn = connect(db)
try:
with transaction(conn):
sm = snapshot(conn)
root = store_snapshot(conn, sm)
loaded = load(conn, root)
assert loaded is not None
assert loaded["selfmodel_root"] == root
assert loaded["state"] == "live"
assert loaded["audit_event_hash"] is not None
# An audit event of type selfmodel_snapshot_landed exists.
row = conn.execute(
"SELECT event_type FROM audit_events "
"WHERE event_type='selfmodel_snapshot_landed' "
"AND subject_root = ?",
(root,),
).fetchone()
assert row is not None
finally:
conn.close()
def test_store_snapshot_idempotent(tmp_path):
db = tmp_path / "shard.db"
conn = connect(db)
try:
with transaction(conn):
sm = snapshot(conn)
r1 = store_snapshot(conn, sm)
r2 = store_snapshot(conn, sm)
assert r1 == r2
# Only one selfmodel_snapshot_landed event.
n = conn.execute(
"SELECT COUNT(*) FROM audit_events "
"WHERE event_type='selfmodel_snapshot_landed' "
"AND subject_root = ?",
(r1,),
).fetchone()[0]
assert n == 1
finally:
conn.close()
def test_store_snapshot_with_claims(tmp_path):
db = tmp_path / "shard.db"
conn = connect(db)
try:
c = CapabilityClaim(
metric="strict_rate",
threshold=0.50,
eval_digest="bench-fixture-aabb",
measured_value=0.54,
measured_at=1700000000,
)
with transaction(conn):
sm = with_claims(snapshot(conn), [c])
root = store_snapshot(conn, sm, claims=[c])
rows = claims_for(conn, root)
assert len(rows) == 1
assert rows[0]["metric"] == "strict_rate"
assert rows[0]["threshold"] == pytest.approx(0.50)
# Audit event for claim emission.
ev = conn.execute(
"SELECT event_type FROM audit_events "
"WHERE event_type='selfmodel_capability_claim_added' "
"AND subject_root = ?",
(root,),
).fetchone()
assert ev is not None
finally:
conn.close()
def test_latest_returns_most_recent_live(tmp_path):
db = tmp_path / "shard.db"
conn = connect(db)
try:
with transaction(conn):
sm1 = snapshot(conn)
r1 = store_snapshot(conn, sm1, ts=1700000000)
# Force a different model-profile-hash on the second snapshot.
sm2_dict = {**sm1.__dict__, "model_profile_hash": "shifted"}
sm2 = SelfModel(**sm2_dict)
r2 = store_snapshot(conn, sm2, ts=1700000100)
live = latest(conn)
assert live is not None
assert live["selfmodel_root"] == r2
assert r1 != r2
finally:
conn.close()
# --- falsify / mark_stale ---------------------------------------------
def test_falsify_flips_state_and_emits_event(tmp_path):
db = tmp_path / "shard.db"
conn = connect(db)
try:
with transaction(conn):
sm = snapshot(conn)
root = store_snapshot(conn, sm)
event_hash = falsify(
conn,
root,
reason="strict_rate dropped to 0.40 under bench",
triggering_claim_hash="claim-aaaa",
)
loaded = load(conn, root)
assert loaded["state"] == "falsified"
assert loaded["falsified_reason"].startswith("strict_rate")
assert event_hash != ""
ev = conn.execute(
"SELECT body FROM audit_events WHERE event_hash = ?",
(event_hash,),
).fetchone()
assert ev is not None
finally:
conn.close()
def test_falsify_idempotent(tmp_path):
db = tmp_path / "shard.db"
conn = connect(db)
try:
with transaction(conn):
sm = snapshot(conn)
root = store_snapshot(conn, sm)
falsify(conn, root, reason="r1")
second = falsify(conn, root, reason="r2")
assert second == "" # no-op on already-falsified
finally:
conn.close()
def test_mark_stale_flips_state_and_emits_event(tmp_path):
db = tmp_path / "shard.db"
conn = connect(db)
try:
with transaction(conn):
sm = snapshot(conn)
root = store_snapshot(conn, sm)
event_hash = mark_stale(conn, root, reason="verifier method shifted")
loaded = load(conn, root)
assert loaded["state"] == "stale"
assert event_hash != ""
finally:
conn.close()
def test_mark_stale_does_not_overwrite_falsified(tmp_path):
db = tmp_path / "shard.db"
conn = connect(db)
try:
with transaction(conn):
sm = snapshot(conn)
root = store_snapshot(conn, sm)
falsify(conn, root, reason="hard regression")
second = mark_stale(conn, root, reason="just a soft drift")
assert second == "" # no-op, falsified is terminal
loaded = load(conn, root)
assert loaded["state"] == "falsified"
finally:
conn.close()
# --- audit chain integrity --------------------------------------------
def test_audit_chain_stays_clean_after_selfmodel_ops(tmp_path):
"""All SelfModel ops use append_audit, which chains via prev_event_hash.
Recompute the chain from scratch and assert no breaks.
"""
import hashlib
db = tmp_path / "shard.db"
conn = connect(db)
try:
with transaction(conn):
sm = snapshot(conn)
root = store_snapshot(conn, sm)
falsify(conn, root, reason="r")
rows = conn.execute(
"SELECT event_hash, prev_event_hash, body, ts "
"FROM audit_events ORDER BY seq"
).fetchall()
prev = None
for row in rows:
h = hashlib.sha256()
if prev is not None:
h.update(bytes.fromhex(prev))
h.update(row["body"].encode("utf-8", errors="surrogatepass"))
assert h.hexdigest() == row["event_hash"], (
"audit chain break detected"
)
assert row["prev_event_hash"] == prev
prev = row["event_hash"]
assert prev == latest_event_hash(conn)
finally:
conn.close()