Research-Stack/5-Applications/tools-scripts/verifier/run_dag.py

210 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""
run_dag.py — Merkle DAG provenance for verifier and audit runs.
Every run that uses RunDAG emits a JSON file describing its computation as a
directed acyclic graph of typed nodes:
input — a parameter or initial value, hashed by repr
compute — a deterministic function call on parent nodes, hashed by
(function name, parent hashes, output summary)
gate — a verifier gate's result on parent compute outputs, hashed by
(gate name, parent hashes, result)
verdict — terminal aggregation of all gates, hashed by gate results
Each node's hash includes its parents' hashes, so the DAG is a Merkle DAG: a
single byte changed anywhere upstream perturbs every downstream hash. This
gives the AVMR/metaprobe receipt principle a *structural* guarantee, not just
an asserted one.
Schema is intentionally simple JSON so the artifact is consumable by any tool
that reads JSON (and any graph viewer that accepts node/edge lists).
"""
from __future__ import annotations
import datetime
import hashlib
import json
import uuid
from pathlib import Path
from typing import Any
def _utcnow() -> str:
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
def _sha256(s: str | bytes) -> str:
if isinstance(s, str):
s = s.encode("utf-8")
return "sha256:" + hashlib.sha256(s).hexdigest()
def _hash_file(path: Path) -> str:
if not path.exists():
return "sha256:MISSING"
return _sha256(path.read_bytes())
def _stable_repr(value: Any) -> str:
"""Deterministic serialization for hashing.
Falls back to repr() for objects json can't handle.
"""
try:
return json.dumps(value, sort_keys=True, default=repr)
except (TypeError, ValueError):
return repr(value)
class RunDAG:
"""Build up a Merkle DAG of a verifier or audit run as it executes."""
SCHEMA_VERSION = "1.0"
def __init__(self, run_type: str, code_paths: list[Path] | None = None,
run_id: str | None = None):
self.run_type = run_type
self.run_id = run_id or f"{run_type}-{_utcnow()}-{uuid.uuid4().hex[:8]}"
self.started_at = _utcnow()
self.completed_at: str | None = None
self.code_provenance = {
str(p): _hash_file(p) for p in (code_paths or [])
}
self.nodes: dict[str, dict] = {}
self.edges: list[dict] = []
# ----- node primitives -----
def _record_edges(self, node_id: str, parents: list[str]) -> None:
for p in parents:
if p not in self.nodes:
raise KeyError(f"DAG edge {p}{node_id}: parent not yet in DAG")
self.edges.append({"from": p, "to": node_id})
def add_input(self, node_id: str, value: Any) -> str:
if node_id in self.nodes:
raise KeyError(f"node already exists: {node_id}")
value_repr = _stable_repr(value)
h = _sha256(value_repr)
self.nodes[node_id] = {
"id": node_id, "type": "input",
"value_repr": value_repr, "hash": h,
}
return h
def add_compute(self, node_id: str, function: str, parents: list[str],
output_summary: dict) -> str:
if node_id in self.nodes:
raise KeyError(f"node already exists: {node_id}")
parent_hashes = [self.nodes[p]["hash"] for p in parents]
payload = _stable_repr({
"function": function,
"parent_hashes": parent_hashes,
"output": output_summary,
})
h = _sha256(payload)
self.nodes[node_id] = {
"id": node_id, "type": "compute",
"function": function, "parent_hashes": parent_hashes,
"output_summary": output_summary, "hash": h,
}
self._record_edges(node_id, parents)
return h
def add_gate(self, node_id: str, function: str, parents: list[str],
result: dict) -> str:
if node_id in self.nodes:
raise KeyError(f"node already exists: {node_id}")
parent_hashes = [self.nodes[p]["hash"] for p in parents]
payload = _stable_repr({
"function": function,
"parent_hashes": parent_hashes,
"result": result,
})
h = _sha256(payload)
self.nodes[node_id] = {
"id": node_id, "type": "gate",
"function": function, "parent_hashes": parent_hashes,
"result": result, "hash": h,
}
self._record_edges(node_id, parents)
return h
def add_verdict(self, node_id: str, gate_ids: list[str],
pass_predicate=lambda g: g["result"].get("passes", False)) -> str:
if node_id in self.nodes:
raise KeyError(f"node already exists: {node_id}")
parent_hashes = [self.nodes[gid]["hash"] for gid in gate_ids]
results = {gid: pass_predicate(self.nodes[gid]) for gid in gate_ids}
# Hash MUST include parent hashes to preserve the Merkle property —
# otherwise two runs with the same pass/fail pattern but different
# underlying gate-metric values produce identical verdict hashes.
payload = _stable_repr({
"gate_ids": gate_ids,
"parent_hashes": parent_hashes,
"results": results,
})
h = _sha256(payload)
self.nodes[node_id] = {
"id": node_id, "type": "verdict",
"gate_ids": gate_ids, "parent_hashes": parent_hashes,
"results": results, "all_passed": all(results.values()),
"hash": h,
}
self._record_edges(node_id, gate_ids)
return h
# ----- emit -----
def merkle_root(self) -> str:
verdict_nodes = [n for n in self.nodes.values() if n["type"] == "verdict"]
if not verdict_nodes:
# Aggregate hash over all leaf nodes if no explicit verdict
leaf_hashes = sorted(n["hash"] for n in self.nodes.values())
return _sha256(_stable_repr(leaf_hashes))
# Single verdict: its hash IS the merkle root
if len(verdict_nodes) == 1:
return verdict_nodes[0]["hash"]
# Multiple verdicts: hash of sorted verdict hashes
return _sha256(_stable_repr(sorted(v["hash"] for v in verdict_nodes)))
def emit(self, path: Path) -> Path:
self.completed_at = _utcnow()
path.parent.mkdir(parents=True, exist_ok=True)
doc = {
"schema_version": self.SCHEMA_VERSION,
"run_id": self.run_id,
"run_type": self.run_type,
"started_at": self.started_at,
"completed_at": self.completed_at,
"code_provenance": self.code_provenance,
"merkle_root": self.merkle_root(),
"n_nodes": len(self.nodes),
"n_edges": len(self.edges),
"nodes": list(self.nodes.values()),
"edges": self.edges,
}
path.write_text(json.dumps(doc, indent=2, default=str))
return path
# ----- helpers -----
def to_dot(self) -> str:
"""Emit Graphviz DOT for visual inspection."""
lines = [f'digraph "{self.run_id}" {{', ' rankdir=LR;', ' node [shape=box];']
type_color = {"input": "lightblue", "compute": "white",
"gate": "lightyellow", "verdict": "lightgreen"}
for n in self.nodes.values():
label = n["id"]
if n["type"] == "gate":
label += f"\\n{'PASS' if n['result'].get('passes') else 'FAIL'}"
if "metric" in n["result"]:
label += f"\\nm={n['result']['metric']:.4g}"
color = type_color.get(n["type"], "white")
lines.append(f' "{n["id"]}" [label="{label}", style=filled, fillcolor={color}];')
for e in self.edges:
lines.append(f' "{e["from"]}" -> "{e["to"]}";')
lines.append("}")
return "\n".join(lines)