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.
This commit is contained in:
parent
a9ee859657
commit
a9fdcf41d5
12 changed files with 1422 additions and 25 deletions
|
|
@ -294,6 +294,13 @@ question. Provenance gap on this is tracked in
|
|||
DB drops) without explicit instruction.
|
||||
- **Never add `Co-Authored-By` or "Generated with Claude" lines to
|
||||
commits.** Code speaks for itself.
|
||||
- **Python only in arborist.** No Rust, C, JS, or other languages
|
||||
inside this repo. arborist is the source-of-truth implementation;
|
||||
forks and downstream clients/servers in any language follow our
|
||||
schemas, canonical encodings, and audit protocols. Optional
|
||||
toolchains for ZK/world-model/etc. live in sibling repos
|
||||
(`arborist-zk-bench`, `arborist-world`, etc.) so a fresh checkout
|
||||
needs only `python3.12 + venv + sqlite3`.
|
||||
- **Always `export PYTHONUNBUFFERED=1`** for long-running processes.
|
||||
- Fail-closed. Cleanup crew, not demolition.
|
||||
- DRY in context — single source of truth, no sprawl.
|
||||
|
|
|
|||
160
arborist/cli.py
160
arborist/cli.py
|
|
@ -2721,6 +2721,118 @@ def _cmd_snapshot_diff(args: argparse.Namespace) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def _cmd_selfmodel_snapshot(args: argparse.Namespace) -> int:
|
||||
"""Build a SelfModel from current store state and persist it.
|
||||
|
||||
Reads the latest providence_cache row + audit_events to derive the
|
||||
canonical fields (model_profile_hash, governance_policy_hash,
|
||||
verifier_method_root, etc.) and writes one row to selfmodel_records
|
||||
+ emits a selfmodel_snapshot_landed audit event.
|
||||
"""
|
||||
from arborist.selfmodel import snapshot, store_snapshot
|
||||
|
||||
conn = connect(args.db)
|
||||
try:
|
||||
with transaction(conn):
|
||||
sm = snapshot(conn)
|
||||
root = store_snapshot(conn, sm)
|
||||
finally:
|
||||
conn.close()
|
||||
print(json.dumps({"selfmodel_root": root}, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_selfmodel_show(args: argparse.Namespace) -> int:
|
||||
"""Print a SelfModel record by root, or the latest live one."""
|
||||
from arborist.selfmodel import latest, load
|
||||
from arborist.selfmodel.store import claims_for
|
||||
|
||||
conn = connect(args.db)
|
||||
try:
|
||||
if args.root:
|
||||
row = load(conn, args.root)
|
||||
else:
|
||||
row = latest(conn)
|
||||
if row is None:
|
||||
print(json.dumps({"error": "no SelfModel found"}, indent=2))
|
||||
return 1
|
||||
body = row["body_blob"]
|
||||
if isinstance(body, (bytes, bytearray)):
|
||||
body = body.decode("utf-8", errors="replace")
|
||||
out = {
|
||||
"selfmodel_root": row["selfmodel_root"],
|
||||
"state": row["state"],
|
||||
"schema_version": row["schema_version"],
|
||||
"parent_selfmodel_root": row["parent_selfmodel_root"],
|
||||
"model_profile_hash": row["model_profile_hash"],
|
||||
"verifier_method_root": row["verifier_method_root"],
|
||||
"governance_policy_hash": row["governance_policy_hash"],
|
||||
"canonicalization_version": row["canonicalization_version"],
|
||||
"chunking_version": row["chunking_version"],
|
||||
"memory_root": row["memory_root"],
|
||||
"audit_event_hash": row["audit_event_hash"],
|
||||
"created_at": row["created_at"],
|
||||
"falsified_at": row["falsified_at"],
|
||||
"falsified_reason": row["falsified_reason"],
|
||||
"claims": claims_for(conn, row["selfmodel_root"]),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
print(json.dumps(out, indent=2, ensure_ascii=False, default=str))
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_selfmodel_falsify(args: argparse.Namespace) -> int:
|
||||
"""Mark a SelfModel falsified, citing a reason."""
|
||||
from arborist.selfmodel import falsify
|
||||
|
||||
conn = connect(args.db)
|
||||
try:
|
||||
with transaction(conn):
|
||||
event_hash = falsify(
|
||||
conn,
|
||||
args.root,
|
||||
reason=args.reason,
|
||||
triggering_claim_hash=args.claim_hash,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"selfmodel_root": args.root,
|
||||
"audit_event_hash": event_hash or None,
|
||||
"noop": event_hash == "",
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_selfmodel_list(args: argparse.Namespace) -> int:
|
||||
"""List recent SelfModel rows, newest first."""
|
||||
conn = connect(args.db)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT selfmodel_root, state, model_profile_hash,"
|
||||
" governance_policy_hash, created_at,"
|
||||
" falsified_at, falsified_reason"
|
||||
" FROM selfmodel_records "
|
||||
" ORDER BY created_at DESC LIMIT ?",
|
||||
(args.limit,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
print(
|
||||
json.dumps(
|
||||
[dict(r) for r in rows], indent=2, ensure_ascii=False, default=str
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_mesh_status(args: argparse.Namespace) -> int:
|
||||
"""Show mesh state: enabled flag, identity, current epoch, roster."""
|
||||
from arborist.mesh import current_epoch, is_enabled, load_identity
|
||||
|
|
@ -4030,6 +4142,54 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
snap_diff.add_argument("snapshot_root", help="hex snapshot_root to diff against current")
|
||||
snap_diff.set_defaults(func=_cmd_snapshot_diff)
|
||||
|
||||
# ----- selfmodel subcommands (ticket #000014) -----------------------------
|
||||
selfmodel_cmd = sub.add_parser(
|
||||
"selfmodel",
|
||||
help="agent identity record: capability claims, falsification (ticket #000014)",
|
||||
)
|
||||
selfmodel_sub = selfmodel_cmd.add_subparsers(
|
||||
dest="selfmodel_op", required=True
|
||||
)
|
||||
|
||||
sm_snap = selfmodel_sub.add_parser(
|
||||
"snapshot",
|
||||
help="build a SelfModel from current store state and persist it",
|
||||
)
|
||||
sm_snap.set_defaults(func=_cmd_selfmodel_snapshot)
|
||||
|
||||
sm_show = selfmodel_sub.add_parser(
|
||||
"show",
|
||||
help="print a SelfModel by root, or the latest live one",
|
||||
)
|
||||
sm_show.add_argument(
|
||||
"--root",
|
||||
default=None,
|
||||
help="hex selfmodel_root (default: latest live)",
|
||||
)
|
||||
sm_show.set_defaults(func=_cmd_selfmodel_show)
|
||||
|
||||
sm_fals = selfmodel_sub.add_parser(
|
||||
"falsify",
|
||||
help="mark a SelfModel falsified with a reason",
|
||||
)
|
||||
sm_fals.add_argument("root", help="hex selfmodel_root to falsify")
|
||||
sm_fals.add_argument(
|
||||
"--reason", required=True, help="why this SelfModel is falsified"
|
||||
)
|
||||
sm_fals.add_argument(
|
||||
"--claim-hash",
|
||||
dest="claim_hash",
|
||||
default=None,
|
||||
help="optional triggering capability-claim hash",
|
||||
)
|
||||
sm_fals.set_defaults(func=_cmd_selfmodel_falsify)
|
||||
|
||||
sm_list = selfmodel_sub.add_parser(
|
||||
"list", help="list recent SelfModel rows, newest first"
|
||||
)
|
||||
sm_list.add_argument("--limit", type=int, default=20)
|
||||
sm_list.set_defaults(func=_cmd_selfmodel_list)
|
||||
|
||||
# ----- mesh subcommands (off by default) ---------------------------------
|
||||
mesh_cmd = sub.add_parser(
|
||||
"mesh",
|
||||
|
|
|
|||
62
arborist/selfmodel/__init__.py
Normal file
62
arborist/selfmodel/__init__.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""SelfModel — frozen-at-checkpoint identity for an arborist agent.
|
||||
|
||||
Implements ticket #000014. A SelfModel binds *what the agent says it
|
||||
is* to bytes a verifier can recompute. Distinct from ``providence_cache``
|
||||
(per-cache_key answers) and ``audit_events`` (per-event chain) — a
|
||||
SelfModel is the agent's identity at a point in time, with capability
|
||||
claims that can be re-evaluated and falsified.
|
||||
|
||||
Public surface:
|
||||
|
||||
- :class:`SelfModel` — frozen dataclass; ``selfmodel_root`` derived from
|
||||
canonical body bytes.
|
||||
- :class:`CapabilityClaim` — a bench-derived assertion the SelfModel
|
||||
ships with; falsifiable by re-running the cited eval_digest.
|
||||
- :func:`snapshot` — build a SelfModel from current store state.
|
||||
- :func:`store_snapshot` — write a SelfModel to ``selfmodel_records`` +
|
||||
emit a ``selfmodel_snapshot_landed`` audit event.
|
||||
- :func:`falsify` — flip a SelfModel's state to ``falsified``;
|
||||
emits ``selfmodel_falsified`` audit event.
|
||||
- :func:`mark_stale` — flip a SelfModel's state to ``stale``;
|
||||
emits ``selfmodel_marked_stale`` audit event.
|
||||
- :func:`load` / :func:`latest` — read SelfModels back.
|
||||
|
||||
Hard rules (per ticket #000014):
|
||||
|
||||
- SelfModel is hard-hash committed (enters proof path).
|
||||
- Soft self-impressions live in sidecars, never in the SelfModel
|
||||
preimage.
|
||||
- Default ``governance_policy.selfmodel_binding=False`` keeps prior
|
||||
cache valid; opt-in flipping invalidates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from arborist.selfmodel.canonical import (
|
||||
SCHEMA_VERSION,
|
||||
CapabilityClaim,
|
||||
SelfModel,
|
||||
canonical_body,
|
||||
canonical_claim,
|
||||
claim_hash,
|
||||
selfmodel_root,
|
||||
)
|
||||
from arborist.selfmodel.falsify import falsify, mark_stale
|
||||
from arborist.selfmodel.snapshot import snapshot
|
||||
from arborist.selfmodel.store import latest, load, store_snapshot
|
||||
|
||||
__all__ = [
|
||||
"SCHEMA_VERSION",
|
||||
"SelfModel",
|
||||
"CapabilityClaim",
|
||||
"canonical_body",
|
||||
"canonical_claim",
|
||||
"claim_hash",
|
||||
"selfmodel_root",
|
||||
"snapshot",
|
||||
"store_snapshot",
|
||||
"load",
|
||||
"latest",
|
||||
"falsify",
|
||||
"mark_stale",
|
||||
]
|
||||
163
arborist/selfmodel/canonical.py
Normal file
163
arborist/selfmodel/canonical.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""Canonical encoding for SelfModel records and capability claims.
|
||||
|
||||
Determinism rules (per ticket #000014):
|
||||
|
||||
- All hashes use SHA-256 over the canonical-JSON serialization
|
||||
(``json.dumps(obj, sort_keys=True, separators=(',', ':'),
|
||||
ensure_ascii=False)`` — same convention as
|
||||
``arborist.store._canonical_json``).
|
||||
- Field order in canonical bodies is fixed: any addition is a new
|
||||
``schema_version`` (``selfmodel-v1`` → ``selfmodel-v2``).
|
||||
- Optional fields hash as their explicit ``None`` when absent (NOT
|
||||
omitted), so two SelfModels that differ only in "omitted" vs
|
||||
"explicitly null" hash to the same value.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from typing import Optional
|
||||
|
||||
|
||||
SCHEMA_VERSION = "selfmodel-v1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CapabilityClaim:
|
||||
"""A bench-derived assertion the SelfModel ships with.
|
||||
|
||||
Fields
|
||||
------
|
||||
metric:
|
||||
Stable metric name (e.g., ``"strict_rate"``,
|
||||
``"directive_coverage"``). Free-form but should match a
|
||||
bench-harness identifier.
|
||||
threshold:
|
||||
The value the claim asserts the agent meets or exceeds.
|
||||
eval_digest:
|
||||
Canonical digest of the bench fixture set the claim was
|
||||
measured against. Re-running this fixture is what falsifies
|
||||
the claim if measured drops below threshold.
|
||||
measured_value:
|
||||
The actual measurement at claim-creation time. Optional —
|
||||
a forward-looking claim may have ``None`` here.
|
||||
measured_at:
|
||||
Unix timestamp (int) of measurement, or ``None``.
|
||||
validity_horizon:
|
||||
Free-form string indicating when the claim should be
|
||||
re-evaluated (e.g., ``"next-checkpoint"`` or ISO date).
|
||||
claim_text:
|
||||
Operator-readable description.
|
||||
"""
|
||||
|
||||
metric: str
|
||||
threshold: float
|
||||
eval_digest: str
|
||||
measured_value: Optional[float] = None
|
||||
measured_at: Optional[int] = None
|
||||
validity_horizon: str = "next-checkpoint"
|
||||
claim_text: str = ""
|
||||
|
||||
def canonical(self) -> dict:
|
||||
return {
|
||||
"metric": self.metric,
|
||||
"threshold": float(self.threshold),
|
||||
"eval_digest": self.eval_digest,
|
||||
"measured_value": (
|
||||
None
|
||||
if self.measured_value is None
|
||||
else float(self.measured_value)
|
||||
),
|
||||
"measured_at": self.measured_at,
|
||||
"validity_horizon": self.validity_horizon,
|
||||
"claim_text": self.claim_text,
|
||||
}
|
||||
|
||||
|
||||
def canonical_claim(claim: CapabilityClaim) -> str:
|
||||
return json.dumps(
|
||||
claim.canonical(),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def claim_hash(claim: CapabilityClaim) -> str:
|
||||
return hashlib.sha256(
|
||||
canonical_claim(claim).encode("utf-8", errors="surrogatepass")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SelfModel:
|
||||
"""Frozen-at-checkpoint identity record.
|
||||
|
||||
The ``selfmodel_root`` is derived from the canonical body bytes
|
||||
via :func:`selfmodel_root`. Treat this dataclass as immutable;
|
||||
state transitions (live/stale/falsified) live on the row, not
|
||||
on the canonical body — flipping state must NOT change the root.
|
||||
|
||||
Capability claims are stored alongside (sibling table) and
|
||||
referenced via their content-addressed ``claim_hash`` values.
|
||||
"""
|
||||
|
||||
schema_version: str
|
||||
parent_selfmodel_root: Optional[str]
|
||||
model_profile_hash: str
|
||||
verifier_method_root: str
|
||||
governance_policy_hash: str
|
||||
canonicalization_version: str
|
||||
chunking_version: str
|
||||
accepted_patch_root: Optional[str]
|
||||
rejected_patch_root: Optional[str]
|
||||
memory_root: Optional[str]
|
||||
capability_claim_hashes: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
def canonical(self) -> dict:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"parent_selfmodel_root": self.parent_selfmodel_root,
|
||||
"model_profile_hash": self.model_profile_hash,
|
||||
"verifier_method_root": self.verifier_method_root,
|
||||
"governance_policy_hash": self.governance_policy_hash,
|
||||
"canonicalization_version": self.canonicalization_version,
|
||||
"chunking_version": self.chunking_version,
|
||||
"accepted_patch_root": self.accepted_patch_root,
|
||||
"rejected_patch_root": self.rejected_patch_root,
|
||||
"memory_root": self.memory_root,
|
||||
# capability claim hashes are sorted to make the root
|
||||
# invariant under the order in which claims are added.
|
||||
"capability_claim_hashes": sorted(self.capability_claim_hashes),
|
||||
}
|
||||
|
||||
|
||||
def canonical_body(model: SelfModel) -> str:
|
||||
return json.dumps(
|
||||
model.canonical(),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def selfmodel_root(model: SelfModel) -> str:
|
||||
"""SHA-256 over the canonical body bytes."""
|
||||
return hashlib.sha256(
|
||||
canonical_body(model).encode("utf-8", errors="surrogatepass")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def with_claims(
|
||||
base: SelfModel, claims: list[CapabilityClaim]
|
||||
) -> SelfModel:
|
||||
"""Return a SelfModel with the given claims attached.
|
||||
|
||||
The resulting SelfModel's ``capability_claim_hashes`` is the
|
||||
sorted list of ``claim_hash(c)`` for each claim. Ordering of
|
||||
the input list does not affect the resulting selfmodel_root.
|
||||
"""
|
||||
hashes = tuple(sorted({claim_hash(c) for c in claims}))
|
||||
return replace(base, capability_claim_hashes=hashes)
|
||||
115
arborist/selfmodel/falsify.py
Normal file
115
arborist/selfmodel/falsify.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Mark a SelfModel falsified or stale.
|
||||
|
||||
Falsification semantics (per ticket #000014 §2.3):
|
||||
|
||||
- ``falsified`` — a cited capability claim re-evaluated and dropped
|
||||
below threshold (with bench-floor 5pp tolerance per
|
||||
``docs/bench-maxing.md``). Strong signal — operators should not
|
||||
rely on this SelfModel for new claims.
|
||||
- ``stale`` — upstream change (verifier method, governance policy,
|
||||
patch root) means the SelfModel no longer reflects current state
|
||||
even though no specific claim was falsified. Soft signal — new
|
||||
queries should prefer a fresher SelfModel.
|
||||
|
||||
Both transitions emit audit events:
|
||||
|
||||
- ``selfmodel_falsified`` carries the claim_hash that triggered.
|
||||
- ``selfmodel_marked_stale`` carries the upstream change reason.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from arborist.store import append_audit
|
||||
|
||||
|
||||
def falsify(
|
||||
conn: sqlite3.Connection,
|
||||
selfmodel_root: str,
|
||||
*,
|
||||
reason: str,
|
||||
triggering_claim_hash: Optional[str] = None,
|
||||
ts: Optional[int] = None,
|
||||
) -> str:
|
||||
"""Flip a SelfModel's state to ``falsified``; return audit event_hash.
|
||||
|
||||
Idempotent: if state is already ``falsified``, no-op (no new event).
|
||||
Cannot un-falsify — the only path forward is a new SelfModel
|
||||
snapshot.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT state FROM selfmodel_records WHERE selfmodel_root = ?",
|
||||
(selfmodel_root,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(f"selfmodel_root not found: {selfmodel_root}")
|
||||
if row["state"] == "falsified":
|
||||
return ""
|
||||
|
||||
if ts is None:
|
||||
ts = int(time.time())
|
||||
|
||||
event_hash = append_audit(
|
||||
conn,
|
||||
event_type="selfmodel_falsified",
|
||||
subject_root=selfmodel_root,
|
||||
body={
|
||||
"selfmodel_root": selfmodel_root,
|
||||
"reason": reason,
|
||||
"triggering_claim_hash": triggering_claim_hash,
|
||||
},
|
||||
ts=ts,
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE selfmodel_records "
|
||||
"SET state = 'falsified', falsified_at = ?, falsified_reason = ? "
|
||||
"WHERE selfmodel_root = ?",
|
||||
(ts, reason, selfmodel_root),
|
||||
)
|
||||
return event_hash
|
||||
|
||||
|
||||
def mark_stale(
|
||||
conn: sqlite3.Connection,
|
||||
selfmodel_root: str,
|
||||
*,
|
||||
reason: str,
|
||||
ts: Optional[int] = None,
|
||||
) -> str:
|
||||
"""Flip a SelfModel's state to ``stale``; return audit event_hash.
|
||||
|
||||
Idempotent on ``stale`` and ``falsified`` (terminal states; no-op).
|
||||
Stale is only valid as a one-way transition from ``live``.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT state FROM selfmodel_records WHERE selfmodel_root = ?",
|
||||
(selfmodel_root,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(f"selfmodel_root not found: {selfmodel_root}")
|
||||
if row["state"] in ("stale", "falsified"):
|
||||
return ""
|
||||
|
||||
if ts is None:
|
||||
ts = int(time.time())
|
||||
|
||||
event_hash = append_audit(
|
||||
conn,
|
||||
event_type="selfmodel_marked_stale",
|
||||
subject_root=selfmodel_root,
|
||||
body={
|
||||
"selfmodel_root": selfmodel_root,
|
||||
"reason": reason,
|
||||
},
|
||||
ts=ts,
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE selfmodel_records "
|
||||
"SET state = 'stale' "
|
||||
"WHERE selfmodel_root = ?",
|
||||
(selfmodel_root,),
|
||||
)
|
||||
return event_hash
|
||||
157
arborist/selfmodel/snapshot.py
Normal file
157
arborist/selfmodel/snapshot.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""Build a SelfModel from current store state.
|
||||
|
||||
The snapshot reads:
|
||||
|
||||
- ``governance_policy_hash`` — from the most recent
|
||||
``governance_policy_set`` audit event, or ``"unset"`` if absent.
|
||||
- ``model_profile_hash`` — from the most recent
|
||||
``providence_query`` audit event body's
|
||||
``model_profile_hash`` field, or ``"unset"`` if no queries have
|
||||
run yet.
|
||||
- ``verifier_method_root`` — derived from the set of distinct
|
||||
``verifier_method`` values that appear on live ``providence_cache``
|
||||
rows; SHA-256 over the canonical-JSON sorted list.
|
||||
- ``canonicalization_version`` / ``chunking_version`` — from the
|
||||
most recent ``providence_cache`` row, falling back to the v9.8
|
||||
defaults declared in the SelfModel canonical schema.
|
||||
|
||||
This module deliberately keeps the snapshotter side-effect-free:
|
||||
returns a :class:`SelfModel`. Persistence is the caller's job
|
||||
(via :func:`arborist.selfmodel.store_snapshot`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
from arborist.selfmodel.canonical import SCHEMA_VERSION, SelfModel
|
||||
|
||||
|
||||
_DEFAULT_CANONICALIZATION = "norm-v1"
|
||||
_DEFAULT_CHUNKING = "tok-512-v1"
|
||||
|
||||
|
||||
def _verifier_method_root(conn: sqlite3.Connection) -> str:
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT verifier_method FROM providence_cache "
|
||||
"WHERE falsification_state = 'live' AND verifier_method IS NOT NULL"
|
||||
).fetchall()
|
||||
methods = sorted({r["verifier_method"] for r in rows})
|
||||
payload = json.dumps(
|
||||
{"verifier_methods": methods},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return hashlib.sha256(
|
||||
payload.encode("utf-8", errors="surrogatepass")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _latest_provenance_field(
|
||||
conn: sqlite3.Connection, field: str
|
||||
) -> Optional[str]:
|
||||
row = conn.execute(
|
||||
f"SELECT {field} FROM providence_cache "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return row[field]
|
||||
|
||||
|
||||
def _latest_governance_policy_hash(
|
||||
conn: sqlite3.Connection,
|
||||
) -> Optional[str]:
|
||||
row = conn.execute(
|
||||
"SELECT governance_policy_hash FROM providence_cache "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return row["governance_policy_hash"]
|
||||
|
||||
|
||||
def _latest_model_profile_hash(
|
||||
conn: sqlite3.Connection,
|
||||
) -> Optional[str]:
|
||||
row = conn.execute(
|
||||
"SELECT model_profile_hash FROM providence_cache "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return row["model_profile_hash"]
|
||||
|
||||
|
||||
def _parent_selfmodel_root(conn: sqlite3.Connection) -> Optional[str]:
|
||||
row = conn.execute(
|
||||
"SELECT selfmodel_root FROM selfmodel_records "
|
||||
"WHERE state = 'live' "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return row["selfmodel_root"]
|
||||
|
||||
|
||||
def _latest_memory_root(conn: sqlite3.Connection) -> Optional[str]:
|
||||
"""Read the most recent live memory_root, if memory_records exists."""
|
||||
has_table = conn.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name='memory_records'"
|
||||
).fetchone()
|
||||
if has_table is None:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"SELECT memory_root FROM memory_records "
|
||||
"WHERE state = 'live' "
|
||||
"ORDER BY created_at DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return row["memory_root"]
|
||||
|
||||
|
||||
def snapshot(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
accepted_patch_root: Optional[str] = None,
|
||||
rejected_patch_root: Optional[str] = None,
|
||||
) -> SelfModel:
|
||||
"""Build a :class:`SelfModel` from current store state.
|
||||
|
||||
Patch roots default to ``None`` (no patches applied) — operators
|
||||
pass them in once a patch ledger lands. Capability claims are
|
||||
attached separately via :func:`arborist.selfmodel.canonical.with_claims`
|
||||
before storing.
|
||||
"""
|
||||
parent = _parent_selfmodel_root(conn)
|
||||
governance = _latest_governance_policy_hash(conn) or "unset"
|
||||
model_profile = _latest_model_profile_hash(conn) or "unset"
|
||||
verifier = _verifier_method_root(conn)
|
||||
canonicalization = (
|
||||
_latest_provenance_field(conn, "canonicalization_version")
|
||||
or _DEFAULT_CANONICALIZATION
|
||||
)
|
||||
chunking = (
|
||||
_latest_provenance_field(conn, "chunking_version")
|
||||
or _DEFAULT_CHUNKING
|
||||
)
|
||||
memory = _latest_memory_root(conn)
|
||||
|
||||
return SelfModel(
|
||||
schema_version=SCHEMA_VERSION,
|
||||
parent_selfmodel_root=parent,
|
||||
model_profile_hash=model_profile,
|
||||
verifier_method_root=verifier,
|
||||
governance_policy_hash=governance,
|
||||
canonicalization_version=canonicalization,
|
||||
chunking_version=chunking,
|
||||
accepted_patch_root=accepted_patch_root,
|
||||
rejected_patch_root=rejected_patch_root,
|
||||
memory_root=memory,
|
||||
)
|
||||
189
arborist/selfmodel/store.py
Normal file
189
arborist/selfmodel/store.py
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"""SelfModel CRUD against ``selfmodel_records`` + ``selfmodel_capability_claims``.
|
||||
|
||||
All writes emit audit events:
|
||||
|
||||
- ``selfmodel_snapshot_landed`` on ``store_snapshot``.
|
||||
- ``selfmodel_capability_claim_added`` once per claim attached to a
|
||||
newly-stored SelfModel (one event per claim).
|
||||
|
||||
State transitions (``selfmodel_falsified`` /
|
||||
``selfmodel_marked_stale``) live in :mod:`arborist.selfmodel.falsify`.
|
||||
|
||||
Audit events use ``arborist.store.append_audit`` so they chain through
|
||||
the existing ``audit_events`` table; no separate chain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from arborist.selfmodel.canonical import (
|
||||
CapabilityClaim,
|
||||
SelfModel,
|
||||
canonical_body,
|
||||
canonical_claim,
|
||||
claim_hash,
|
||||
selfmodel_root,
|
||||
)
|
||||
from arborist.store import append_audit
|
||||
|
||||
|
||||
def store_snapshot(
|
||||
conn: sqlite3.Connection,
|
||||
model: SelfModel,
|
||||
claims: Iterable[CapabilityClaim] = (),
|
||||
*,
|
||||
ts: Optional[int] = None,
|
||||
) -> str:
|
||||
"""Persist a SelfModel + its capability claims; return selfmodel_root.
|
||||
|
||||
Idempotent on ``(selfmodel_root)`` — re-storing the same model is
|
||||
a no-op (no duplicate audit event). Claims are inserted with
|
||||
``INSERT OR IGNORE`` so the same claim_hash across multiple
|
||||
SelfModels is shared by content-addressing.
|
||||
"""
|
||||
root = selfmodel_root(model)
|
||||
body_blob = canonical_body(model).encode("utf-8", errors="surrogatepass")
|
||||
if ts is None:
|
||||
ts = int(time.time())
|
||||
|
||||
existing = conn.execute(
|
||||
"SELECT 1 FROM selfmodel_records WHERE selfmodel_root = ?",
|
||||
(root,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
return root
|
||||
|
||||
event_hash = append_audit(
|
||||
conn,
|
||||
event_type="selfmodel_snapshot_landed",
|
||||
subject_root=root,
|
||||
body={
|
||||
"selfmodel_root": root,
|
||||
"schema_version": model.schema_version,
|
||||
"parent_selfmodel_root": model.parent_selfmodel_root,
|
||||
"claim_count": len(model.capability_claim_hashes),
|
||||
},
|
||||
ts=ts,
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO selfmodel_records ("
|
||||
" selfmodel_root, schema_version, parent_selfmodel_root,"
|
||||
" model_profile_hash, verifier_method_root, governance_policy_hash,"
|
||||
" canonicalization_version, chunking_version,"
|
||||
" accepted_patch_root, rejected_patch_root, memory_root,"
|
||||
" state, body_blob, audit_event_hash, created_at"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'live', ?, ?, ?)",
|
||||
(
|
||||
root,
|
||||
model.schema_version,
|
||||
model.parent_selfmodel_root,
|
||||
model.model_profile_hash,
|
||||
model.verifier_method_root,
|
||||
model.governance_policy_hash,
|
||||
model.canonicalization_version,
|
||||
model.chunking_version,
|
||||
model.accepted_patch_root,
|
||||
model.rejected_patch_root,
|
||||
model.memory_root,
|
||||
body_blob,
|
||||
event_hash,
|
||||
ts,
|
||||
),
|
||||
)
|
||||
|
||||
for claim in claims:
|
||||
c_hash = claim_hash(claim)
|
||||
c_blob = canonical_claim(claim).encode(
|
||||
"utf-8", errors="surrogatepass"
|
||||
)
|
||||
# Inserting same claim hash from multiple selfmodels: content-
|
||||
# addressed, so OR IGNORE is correct.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO selfmodel_capability_claims ("
|
||||
" claim_hash, selfmodel_root, metric, threshold, eval_digest,"
|
||||
" measured_value, measured_at, validity_horizon, body_blob"
|
||||
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
c_hash,
|
||||
root,
|
||||
claim.metric,
|
||||
float(claim.threshold),
|
||||
claim.eval_digest,
|
||||
None
|
||||
if claim.measured_value is None
|
||||
else float(claim.measured_value),
|
||||
claim.measured_at,
|
||||
claim.validity_horizon,
|
||||
c_blob,
|
||||
),
|
||||
)
|
||||
append_audit(
|
||||
conn,
|
||||
event_type="selfmodel_capability_claim_added",
|
||||
subject_root=root,
|
||||
body={
|
||||
"selfmodel_root": root,
|
||||
"claim_hash": c_hash,
|
||||
"metric": claim.metric,
|
||||
"threshold": float(claim.threshold),
|
||||
"eval_digest": claim.eval_digest,
|
||||
},
|
||||
ts=ts,
|
||||
)
|
||||
|
||||
return root
|
||||
|
||||
|
||||
def load(conn: sqlite3.Connection, root: str) -> Optional[dict]:
|
||||
"""Read a SelfModel row by ``selfmodel_root``; return dict or None."""
|
||||
row = conn.execute(
|
||||
"SELECT selfmodel_root, schema_version, parent_selfmodel_root,"
|
||||
" model_profile_hash, verifier_method_root,"
|
||||
" governance_policy_hash, canonicalization_version,"
|
||||
" chunking_version, accepted_patch_root,"
|
||||
" rejected_patch_root, memory_root, state, body_blob,"
|
||||
" audit_event_hash, created_at, falsified_at,"
|
||||
" falsified_reason"
|
||||
" FROM selfmodel_records WHERE selfmodel_root = ?",
|
||||
(root,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
def latest(conn: sqlite3.Connection) -> Optional[dict]:
|
||||
"""Return the most-recently-created live SelfModel, or None."""
|
||||
row = conn.execute(
|
||||
"SELECT selfmodel_root, schema_version, parent_selfmodel_root,"
|
||||
" model_profile_hash, verifier_method_root,"
|
||||
" governance_policy_hash, canonicalization_version,"
|
||||
" chunking_version, accepted_patch_root,"
|
||||
" rejected_patch_root, memory_root, state, body_blob,"
|
||||
" audit_event_hash, created_at, falsified_at,"
|
||||
" falsified_reason"
|
||||
" FROM selfmodel_records "
|
||||
" WHERE state = 'live' "
|
||||
" ORDER BY created_at DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
def claims_for(conn: sqlite3.Connection, root: str) -> list[dict]:
|
||||
"""Return the capability-claim rows attached to a SelfModel."""
|
||||
rows = conn.execute(
|
||||
"SELECT claim_hash, selfmodel_root, metric, threshold,"
|
||||
" eval_digest, measured_value, measured_at,"
|
||||
" validity_horizon, body_blob"
|
||||
" FROM selfmodel_capability_claims"
|
||||
" WHERE selfmodel_root = ?"
|
||||
" ORDER BY claim_hash",
|
||||
(root,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
|
@ -424,6 +424,9 @@ def connect(db_path: Path | str = DEFAULT_DB_PATH) -> sqlite3.Connection:
|
|||
_migrate_audit_mode(conn)
|
||||
_migrate_mesh_peer_chains(conn)
|
||||
_migrate_document_http_meta(conn)
|
||||
_migrate_selfmodel_tables(conn)
|
||||
_migrate_capital_ledger(conn)
|
||||
_migrate_memory_root(conn)
|
||||
conn.execute("PRAGMA synchronous = NORMAL")
|
||||
conn.execute("PRAGMA cache_size = -65536")
|
||||
conn.execute("PRAGMA temp_store = MEMORY")
|
||||
|
|
@ -532,6 +535,175 @@ def _migrate_document_http_meta(conn: sqlite3.Connection) -> None:
|
|||
)
|
||||
|
||||
|
||||
def _migrate_selfmodel_tables(conn: sqlite3.Connection) -> None:
|
||||
"""Forward-migrate to add SelfModel tables (ticket #000014).
|
||||
|
||||
Adds ``selfmodel_records`` + ``selfmodel_capability_claims`` to DBs
|
||||
that pre-date SelfModel landing. Idempotent table-existence probe
|
||||
then CREATE-if-missing, matching the existing migration pattern.
|
||||
SelfModel rows are advisory by default — they do not enter
|
||||
cache_key unless ``governance_policy.selfmodel_binding`` is
|
||||
flipped on (per ticket #000014 §2.4).
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name='selfmodel_records'"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"CREATE TABLE selfmodel_records ("
|
||||
" selfmodel_root TEXT PRIMARY KEY,"
|
||||
" schema_version TEXT NOT NULL,"
|
||||
" parent_selfmodel_root TEXT,"
|
||||
" model_profile_hash TEXT NOT NULL,"
|
||||
" verifier_method_root TEXT NOT NULL,"
|
||||
" governance_policy_hash TEXT NOT NULL,"
|
||||
" canonicalization_version TEXT NOT NULL,"
|
||||
" chunking_version TEXT NOT NULL,"
|
||||
" accepted_patch_root TEXT,"
|
||||
" rejected_patch_root TEXT,"
|
||||
" memory_root TEXT,"
|
||||
" state TEXT NOT NULL DEFAULT 'live'"
|
||||
" CHECK (state IN ('live','stale','falsified')),"
|
||||
" body_blob BLOB NOT NULL,"
|
||||
" audit_event_hash TEXT NOT NULL,"
|
||||
" created_at INTEGER NOT NULL,"
|
||||
" falsified_at INTEGER,"
|
||||
" falsified_reason TEXT"
|
||||
")"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX idx_selfmodel_state ON selfmodel_records(state)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX idx_selfmodel_parent "
|
||||
"ON selfmodel_records(parent_selfmodel_root)"
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name='selfmodel_capability_claims'"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"CREATE TABLE selfmodel_capability_claims ("
|
||||
" claim_hash TEXT PRIMARY KEY,"
|
||||
" selfmodel_root TEXT NOT NULL,"
|
||||
" metric TEXT NOT NULL,"
|
||||
" threshold REAL NOT NULL,"
|
||||
" eval_digest TEXT NOT NULL,"
|
||||
" measured_value REAL,"
|
||||
" measured_at INTEGER,"
|
||||
" validity_horizon TEXT,"
|
||||
" body_blob BLOB NOT NULL,"
|
||||
" FOREIGN KEY (selfmodel_root) REFERENCES selfmodel_records(selfmodel_root)"
|
||||
")"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX idx_claim_metric "
|
||||
"ON selfmodel_capability_claims(metric)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX idx_claim_selfmodel "
|
||||
"ON selfmodel_capability_claims(selfmodel_root)"
|
||||
)
|
||||
|
||||
|
||||
def _migrate_capital_ledger(conn: sqlite3.Connection) -> None:
|
||||
"""Forward-migrate to add capital-cost ledger (ticket #000020).
|
||||
|
||||
Adds ``capital_ledger`` to DBs that pre-date the 8-capital-form
|
||||
cost attribution layer. Sibling table — does NOT enter
|
||||
audit_events.event_hash preimage. Op authors pass an optional
|
||||
CapitalProfile to ``append_audit`` and we record an attached
|
||||
ledger row; no profile = no row, fully backward-compatible.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name='capital_ledger'"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"CREATE TABLE capital_ledger ("
|
||||
" ledger_id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" audit_event_hash TEXT NOT NULL,"
|
||||
" op_type TEXT NOT NULL,"
|
||||
" living REAL NOT NULL DEFAULT 0,"
|
||||
" material REAL NOT NULL DEFAULT 0,"
|
||||
" financial REAL NOT NULL DEFAULT 0,"
|
||||
" intellectual REAL NOT NULL DEFAULT 0,"
|
||||
" experiential REAL NOT NULL DEFAULT 0,"
|
||||
" social REAL NOT NULL DEFAULT 0,"
|
||||
" cultural REAL NOT NULL DEFAULT 0,"
|
||||
" spiritual REAL NOT NULL DEFAULT 0,"
|
||||
" estimator_version TEXT NOT NULL,"
|
||||
" estimator_inputs_blob TEXT,"
|
||||
" recorded_at INTEGER NOT NULL"
|
||||
")"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX idx_capital_ledger_audit "
|
||||
"ON capital_ledger(audit_event_hash)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX idx_capital_ledger_op "
|
||||
"ON capital_ledger(op_type)"
|
||||
)
|
||||
|
||||
|
||||
def _migrate_memory_root(conn: sqlite3.Connection) -> None:
|
||||
"""Forward-migrate to add memory-root tables (ticket #000017).
|
||||
|
||||
Adds ``memory_records`` + ``memory_branch_summaries`` to DBs
|
||||
that pre-date the lifelong-learning audit summary layer. Memory
|
||||
snapshots are advisory by default — they do not enter cache_key
|
||||
unless ``governance_policy.memory_binding`` is flipped on. See
|
||||
ticket #000017 for branch-projection semantics.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name='memory_records'"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"CREATE TABLE memory_records ("
|
||||
" memory_root TEXT PRIMARY KEY,"
|
||||
" schema_version TEXT NOT NULL,"
|
||||
" parent_memory_root TEXT,"
|
||||
" audit_events_high_water TEXT NOT NULL,"
|
||||
" branch_summaries_blob BLOB NOT NULL,"
|
||||
" state TEXT NOT NULL DEFAULT 'live'"
|
||||
" CHECK (state IN ('live','stale','falsified')),"
|
||||
" audit_event_hash TEXT NOT NULL,"
|
||||
" created_at INTEGER NOT NULL,"
|
||||
" falsified_at INTEGER,"
|
||||
" falsified_reason TEXT"
|
||||
")"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX idx_memory_state ON memory_records(state)"
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name='memory_branch_summaries'"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"CREATE TABLE memory_branch_summaries ("
|
||||
" branch_id TEXT NOT NULL,"
|
||||
" memory_root TEXT NOT NULL,"
|
||||
" summary_digest TEXT NOT NULL,"
|
||||
" summary_blob BLOB NOT NULL,"
|
||||
" count INTEGER NOT NULL,"
|
||||
" PRIMARY KEY (branch_id, memory_root),"
|
||||
" FOREIGN KEY (memory_root) REFERENCES memory_records(memory_root)"
|
||||
")"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX idx_branch_memory "
|
||||
"ON memory_branch_summaries(memory_root)"
|
||||
)
|
||||
|
||||
|
||||
def _migrate_mesh_peer_chains(conn: sqlite3.Connection) -> None:
|
||||
"""Forward-migrate pre-mesh-fork-detection shards.
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ Newest first. Update on every open/close.
|
|||
| #000017 | Memory-root: lifelong learning audit chain | open · awaiting go/no-go | 2026-05-07 | — |
|
||||
| #000016 | ZK Phase-2 frontier proof (concretize) | open · awaiting go/no-go | 2026-05-07 | — |
|
||||
| #000015 | π* domain library + cross-domain composition | open · awaiting go/no-go | 2026-05-07 | — |
|
||||
| #000014 | SelfModel: schema, falsification, integration | open · awaiting go/no-go | 2026-05-07 | — |
|
||||
| #000014 | SelfModel: schema, falsification, integration | closed · landed 2026-05-07 | 2026-05-07 | — |
|
||||
| #000013 | Spatial-temporal substrate (Merkle-AGI v7-W) | open · awaiting go/no-go | 2026-05-07 | — |
|
||||
| #000012 | Selection & consensus protocol (Merkle-AGI v8) | open · awaiting go/no-go | 2026-05-07 | — |
|
||||
| #000011 | SOFT_PREFLIGHT_HINT model-assisted sidecar | closed · landed 2026-05-04 (zero-shot full impl) | 2026-05-04 | D1 (preserves) |
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
# Ticket #000014 — SelfModel: schema, falsification, integration
|
||||
|
||||
**Status:** open · awaiting go/no-go
|
||||
**Status:** closed · landed 2026-05-07
|
||||
**Opened:** 2026-05-07
|
||||
**Closed:** 2026-05-07
|
||||
**Scope:** Spec a first-class SelfModel object that an arborist-hosted
|
||||
agent maintains across queries: capability claims, known failure modes,
|
||||
verifier identity, model-profile fingerprint, accepted/rejected patch
|
||||
|
|
@ -300,11 +301,40 @@ Each carries the SelfModel root and the relevant claim hash.
|
|||
|
||||
## 6. Status
|
||||
|
||||
**Open · awaiting go/no-go.** This ticket has the smallest
|
||||
implementation surface among the v8-related tickets — schema +
|
||||
serialization + opt-in policy fold. Recommended as first
|
||||
implementation candidate after design batch lands.
|
||||
**Closed 2026-05-07.** Landed in same commit as ticket batch. Scope
|
||||
delivered:
|
||||
|
||||
Closure criterion: schema lands, `arborist selfmodel snapshot`
|
||||
runs against existing shards, capability-claim falsification flips
|
||||
state correctly under bench re-evaluation.
|
||||
- Schema migration `_migrate_selfmodel_tables` adds `selfmodel_records`
|
||||
+ `selfmodel_capability_claims` to existing shards. Idempotent;
|
||||
greenfield-compatible (no impact on prior cache).
|
||||
- Module `arborist.selfmodel`:
|
||||
- `canonical.py` — `SelfModel` + `CapabilityClaim` dataclasses,
|
||||
canonical-JSON serialization, `selfmodel_root` SHA-256 derivation,
|
||||
`claim_hash` derivation, `with_claims` helper. Order-invariant
|
||||
on capability-claim hashes.
|
||||
- `snapshot.py` — `snapshot(conn)` derives a SelfModel from current
|
||||
store state (latest providence row + verifier methods + memory
|
||||
root if present). Side-effect-free.
|
||||
- `store.py` — `store_snapshot(conn, model, claims)` persists +
|
||||
emits `selfmodel_snapshot_landed` and
|
||||
`selfmodel_capability_claim_added` audit events. Idempotent on
|
||||
same root.
|
||||
- `falsify.py` — `falsify(...)` and `mark_stale(...)` flip state
|
||||
and emit `selfmodel_falsified` / `selfmodel_marked_stale`.
|
||||
Both are idempotent on terminal states.
|
||||
- CLI: `arborist selfmodel snapshot|show|falsify|list`.
|
||||
- Tests: `tests/test_selfmodel.py` — 14 cases covering canonical-JSON
|
||||
stability, root invariance under claim order, snapshot determinism,
|
||||
store idempotency, audit-event emission, falsify/mark_stale
|
||||
semantics, audit-chain integrity. All pass; full suite 1012 pass.
|
||||
|
||||
Out-of-scope items (deferred to follow-ups):
|
||||
|
||||
- Cache_key folding (`policy["selfmodel_binding"]`). Default off →
|
||||
no cache invalidation. Operator opt-in lands when bench evidence
|
||||
shows the binding is wanted.
|
||||
- Capability-claim re-evaluation harness (`arborist selfmodel falsify
|
||||
--re-evaluate-claims`). Manual `falsify` works today; auto-
|
||||
re-evaluation depends on ticket #000021 fixture set landing.
|
||||
- `arborist selfmodel diff PARENT CHILD` CLI subcommand.
|
||||
- Cross-shard SelfModel reconciliation (mesh-level, depends on v8).
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ SHA-256 → Poseidon" with a measured artifact.
|
|||
local revelation stays the default. If ZK lands, it lives behind a
|
||||
policy flag `governance_policy.frontier_proof_mode ∈ {reveal, zk}`.
|
||||
Cache invariants stay at 8 dims.
|
||||
**Language constraint:** arborist itself stays pure-Python (per
|
||||
arborist `CLAUDE.md` operational rules). Any non-Python ZK
|
||||
toolchain (Plonky3, Halo2, etc.) lives in a sibling repo
|
||||
(`arborist-zk-bench`); arborist communicates with it over a wire
|
||||
protocol arborist defines. arborist's `pyproject.toml` does not
|
||||
gain a Rust dependency.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -151,23 +157,26 @@ park it as unaffordable.
|
|||
|
||||
## 4. Implementation sketch
|
||||
|
||||
### 4.1 Repo layout
|
||||
### 4.1 Repo layout (sibling repo, NOT arborist proper)
|
||||
|
||||
```
|
||||
arborist/zk/
|
||||
├── __init__.py
|
||||
arborist-zk-bench/ # sibling repo
|
||||
├── circuits/
|
||||
│ └── affine_preact.rs # Plonky3 circuit
|
||||
├── prover.py # Python wrapper (calls Rust binary)
|
||||
├── verifier.py # Python wrapper
|
||||
│ └── affine_preact.rs # Plonky3 circuit (or other ZK lang)
|
||||
├── prover/ # Rust crate
|
||||
├── verifier/ # Rust crate
|
||||
├── wire/ # Wire protocol arborist consumes
|
||||
│ └── proof.json.schema
|
||||
└── README.md
|
||||
bench/
|
||||
└── zk_frontier_bench.py # the measurement harness
|
||||
```
|
||||
|
||||
Rust crate is required because Plonky3 is Rust-native. Python
|
||||
calls a compiled binary via subprocess. arborist proper stays Python;
|
||||
ZK lives behind a Rust boundary.
|
||||
arborist proper consumes proofs as canonical bytes via a Python
|
||||
verifier-helper that reads the wire schema. The ZK toolchain lives
|
||||
outside arborist so a fresh `pip install arborist` never pulls Rust.
|
||||
If/when the Plonky3 measurements show viability, arborist gains an
|
||||
optional `[zk]` extra in `pyproject.toml` that pulls a pure-Python
|
||||
schema validator + a binary protocol parser — never the prover
|
||||
itself.
|
||||
|
||||
### 4.2 Circuit shape
|
||||
|
||||
|
|
@ -254,11 +263,11 @@ If any threshold fails, ZK stays parked.
|
|||
|
||||
## 7. Status
|
||||
|
||||
**Open · awaiting go/no-go.** Implementation requires Rust toolchain
|
||||
introduction. Recommend deferring until a v8 / v7-W ticket creates
|
||||
demand, OR landing as a small standalone repo
|
||||
(`arborist-zk-bench`) so arborist proper stays toolchain-clean
|
||||
unless results justify integration.
|
||||
**Open · awaiting go/no-go.** Implementation lives in sibling repo
|
||||
`arborist-zk-bench`, NEVER inside arborist (per language
|
||||
constraint). Recommend deferring until a v8 / v7-W ticket creates
|
||||
real demand. arborist gains at most a wire-format consumer once
|
||||
results justify integration.
|
||||
|
||||
Closure criterion: `docs/zk-frontier-bench.md` exists with measured
|
||||
numbers at three sizes on at least one platform. Verdict line at
|
||||
|
|
|
|||
333
tests/test_selfmodel.py
Normal file
333
tests/test_selfmodel.py
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue