vitnify 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vitnify/__init__.py +0 -0
- vitnify/_vendor/__init__.py +0 -0
- vitnify/_vendor/pck/__init__.py +0 -0
- vitnify/_vendor/pck/cas.py +181 -0
- vitnify/capability.py +38 -0
- vitnify/certificate.py +139 -0
- vitnify/deterministic_llm.py +64 -0
- vitnify/engine.py +46 -0
- vitnify/events.py +95 -0
- vitnify/langgraph_adapter.py +101 -0
- vitnify/mcp_adapter.py +80 -0
- vitnify/react.py +93 -0
- vitnify/replayer.py +44 -0
- vitnify/viewer.py +230 -0
- vitnify-0.1.0.dist-info/METADATA +99 -0
- vitnify-0.1.0.dist-info/RECORD +20 -0
- vitnify-0.1.0.dist-info/WHEEL +5 -0
- vitnify-0.1.0.dist-info/licenses/LICENSE +202 -0
- vitnify-0.1.0.dist-info/licenses/NOTICE +14 -0
- vitnify-0.1.0.dist-info/top_level.txt +1 -0
vitnify/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Content-addressed store + Merkle ledger — the provenance backbone of PCK.
|
|
2
|
+
|
|
3
|
+
Every source chunk is addressed by its cryptographic hash. A Merkle tree over those
|
|
4
|
+
hashes yields a single ``root`` that attests the whole corpus. Any single chunk can
|
|
5
|
+
then carry an *inclusion proof*: a short path of sibling hashes that recomputes the
|
|
6
|
+
root. The guarantees this buys, and why they matter for AI answers:
|
|
7
|
+
|
|
8
|
+
* **Tamper-evidence** — change one byte of one chunk and the root changes.
|
|
9
|
+
* **Independent verifiability** — anyone holding the root can check a chunk's proof
|
|
10
|
+
without trusting the party that produced it (no model, no server, no secret key).
|
|
11
|
+
* **Fabrication is uncredentialed** — a made-up "source" has no leaf in the tree, so
|
|
12
|
+
it cannot produce a valid proof. This is the cryptographic half of catching a
|
|
13
|
+
hallucination: a grounded claim points at a chunk with a valid proof; an invented
|
|
14
|
+
one cannot.
|
|
15
|
+
|
|
16
|
+
This mirrors the BLAKE3 + Merkle design of a native runtime's ``cas_store`` (whose real
|
|
17
|
+
Rust code is exercised in ``research/pck_provenance/``); here we keep a dependency-light
|
|
18
|
+
pure-Python implementation that uses ``blake3`` when available and falls back to
|
|
19
|
+
``hashlib.blake2b`` otherwise. The wire format of a proof is identical either way.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
try: # BLAKE3 if the user has it (matches the substrate); else a stdlib fallback.
|
|
28
|
+
import blake3 as _blake3 # type: ignore
|
|
29
|
+
|
|
30
|
+
_HASH_NAME = "blake3"
|
|
31
|
+
|
|
32
|
+
def _digest(b: bytes) -> str:
|
|
33
|
+
return _blake3.blake3(b).hexdigest()
|
|
34
|
+
except Exception: # pragma: no cover - depends on environment
|
|
35
|
+
_HASH_NAME = "blake2b-256"
|
|
36
|
+
|
|
37
|
+
def _digest(b: bytes) -> str:
|
|
38
|
+
return hashlib.blake2b(b, digest_size=32).hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
HASH_NAME = _HASH_NAME
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def hash_bytes(b: bytes) -> str:
|
|
45
|
+
"""Content address of raw bytes (hex)."""
|
|
46
|
+
return _digest(b)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def hash_text(s: str) -> str:
|
|
50
|
+
"""Content address of a text chunk (UTF-8, hex)."""
|
|
51
|
+
return _digest(s.encode("utf-8"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _hash_pair(left: str, right: str) -> str:
|
|
55
|
+
"""Internal Merkle node = hash of the concatenated child hex digests."""
|
|
56
|
+
return _digest((left + right).encode("utf-8"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --- inclusion proof -------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class ProofStep:
|
|
63
|
+
"""One level of a Merkle inclusion path."""
|
|
64
|
+
|
|
65
|
+
sibling: str # hex digest of the sibling node
|
|
66
|
+
on_right: bool # True if the *current* node is the right child at this level
|
|
67
|
+
|
|
68
|
+
def to_json(self) -> dict:
|
|
69
|
+
return {"sibling": self.sibling, "on_right": self.on_right}
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def from_json(d: dict) -> "ProofStep":
|
|
73
|
+
return ProofStep(sibling=d["sibling"], on_right=bool(d["on_right"]))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class InclusionProof:
|
|
78
|
+
"""A self-contained proof that ``leaf`` is committed under ``root``.
|
|
79
|
+
|
|
80
|
+
Carries everything a third party needs to re-verify against a *published* root:
|
|
81
|
+
the leaf hash, the sibling path, and the index. No tree, server, or key required.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
leaf: str
|
|
85
|
+
index: int
|
|
86
|
+
path: list[ProofStep]
|
|
87
|
+
hash_name: str = HASH_NAME
|
|
88
|
+
|
|
89
|
+
def verify(self, root: str) -> bool:
|
|
90
|
+
return verify_proof(self.leaf, self.path, root)
|
|
91
|
+
|
|
92
|
+
def to_json(self) -> dict:
|
|
93
|
+
return {
|
|
94
|
+
"leaf": self.leaf,
|
|
95
|
+
"index": self.index,
|
|
96
|
+
"hash": self.hash_name,
|
|
97
|
+
"path": [s.to_json() for s in self.path],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def from_json(d: dict) -> "InclusionProof":
|
|
102
|
+
return InclusionProof(
|
|
103
|
+
leaf=d["leaf"],
|
|
104
|
+
index=int(d["index"]),
|
|
105
|
+
path=[ProofStep.from_json(s) for s in d["path"]],
|
|
106
|
+
hash_name=d.get("hash", HASH_NAME),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def verify_proof(leaf: str, path: list[ProofStep], root: str) -> bool:
|
|
111
|
+
"""Recompute the root from ``leaf`` and ``path``; True iff it equals ``root``."""
|
|
112
|
+
h = leaf
|
|
113
|
+
for step in path:
|
|
114
|
+
h = _hash_pair(step.sibling, h) if step.on_right else _hash_pair(h, step.sibling)
|
|
115
|
+
return h == root
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --- the tree / store ------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class MerkleCAS:
|
|
122
|
+
"""Content-addressed store with a Merkle commitment over its chunks.
|
|
123
|
+
|
|
124
|
+
Odd layers duplicate the final node (Bitcoin-style), so a proof always exists for
|
|
125
|
+
every leaf. Construction is deterministic in the chunk order.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
chunks: list[str]
|
|
129
|
+
leaves: list[str] = field(default_factory=list)
|
|
130
|
+
layers: list[list[str]] = field(default_factory=list)
|
|
131
|
+
_index_of_hash: dict[str, int] = field(default_factory=dict)
|
|
132
|
+
|
|
133
|
+
def __post_init__(self) -> None:
|
|
134
|
+
if not self.chunks:
|
|
135
|
+
raise ValueError("MerkleCAS requires at least one chunk")
|
|
136
|
+
self.leaves = [hash_text(c) for c in self.chunks]
|
|
137
|
+
# first occurrence wins if a chunk repeats
|
|
138
|
+
for i, h in enumerate(self.leaves):
|
|
139
|
+
self._index_of_hash.setdefault(h, i)
|
|
140
|
+
self.layers = self._build(self.leaves)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _build(leaves: list[str]) -> list[list[str]]:
|
|
144
|
+
layers = [leaves[:]]
|
|
145
|
+
while len(layers[-1]) > 1:
|
|
146
|
+
cur = layers[-1]
|
|
147
|
+
nxt = [
|
|
148
|
+
_hash_pair(cur[i], cur[i + 1] if i + 1 < len(cur) else cur[i])
|
|
149
|
+
for i in range(0, len(cur), 2)
|
|
150
|
+
]
|
|
151
|
+
layers.append(nxt)
|
|
152
|
+
return layers
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def root(self) -> str:
|
|
156
|
+
return self.layers[-1][0]
|
|
157
|
+
|
|
158
|
+
def index_of(self, chunk_or_hash: str) -> Optional[int]:
|
|
159
|
+
"""Index of a chunk by its raw text or by its hash; None if absent."""
|
|
160
|
+
if chunk_or_hash in self._index_of_hash:
|
|
161
|
+
return self._index_of_hash[chunk_or_hash]
|
|
162
|
+
return self._index_of_hash.get(hash_text(chunk_or_hash))
|
|
163
|
+
|
|
164
|
+
def contains(self, chunk_or_hash: str) -> bool:
|
|
165
|
+
return self.index_of(chunk_or_hash) is not None
|
|
166
|
+
|
|
167
|
+
def prove_index(self, idx: int) -> InclusionProof:
|
|
168
|
+
if not 0 <= idx < len(self.leaves):
|
|
169
|
+
raise IndexError(idx)
|
|
170
|
+
path: list[ProofStep] = []
|
|
171
|
+
i = idx
|
|
172
|
+
for layer in self.layers[:-1]:
|
|
173
|
+
sib = i ^ 1 if (i ^ 1) < len(layer) else i # duplicated node is its own sibling
|
|
174
|
+
path.append(ProofStep(sibling=layer[sib], on_right=bool(i & 1)))
|
|
175
|
+
i //= 2
|
|
176
|
+
return InclusionProof(leaf=self.leaves[idx], index=idx, path=path)
|
|
177
|
+
|
|
178
|
+
def prove(self, chunk_or_hash: str) -> Optional[InclusionProof]:
|
|
179
|
+
"""Inclusion proof for a chunk (by text or hash); None if not in the corpus."""
|
|
180
|
+
idx = self.index_of(chunk_or_hash)
|
|
181
|
+
return None if idx is None else self.prove_index(idx)
|
vitnify/capability.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Userspace, kernel-free capability broker (adapted from the PoC).
|
|
2
|
+
Containment by construction: every side-effecting tool call routes through here,
|
|
3
|
+
and a tool the run wasn't granted is simply unreachable -- a jailbreak that reaches
|
|
4
|
+
an ungranted tool hits a wall, not a classifier. Each call is recorded to the event
|
|
5
|
+
log (with its result, so replay can re-inject it). No ML, no score, no bypass.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import hashlib
|
|
9
|
+
from .events import EventLog, Kind
|
|
10
|
+
|
|
11
|
+
def _rh(x) -> str:
|
|
12
|
+
return hashlib.blake2b(repr(x).encode(), digest_size=16).hexdigest()
|
|
13
|
+
|
|
14
|
+
class Broker:
|
|
15
|
+
def __init__(self, capabilities, tools: dict, log: EventLog, replay=None):
|
|
16
|
+
self.caps = set(capabilities)
|
|
17
|
+
self.tools = tools
|
|
18
|
+
self.log = log
|
|
19
|
+
self.replay = replay # if set: list of recorded tool results to re-inject
|
|
20
|
+
|
|
21
|
+
def call(self, tool: str, *args):
|
|
22
|
+
allowed = tool in self.caps
|
|
23
|
+
if not allowed:
|
|
24
|
+
self.log.append(Kind.TOOL_CALL, {"tool": tool, "args": list(args), "decision": "DENY"})
|
|
25
|
+
return False, None
|
|
26
|
+
if self.replay is not None:
|
|
27
|
+
# external effect: DO NOT re-run the live tool; serve the recorded result
|
|
28
|
+
result = self.replay.pop(0)
|
|
29
|
+
else:
|
|
30
|
+
result = self.tools[tool](*args)
|
|
31
|
+
self.log.append(Kind.TOOL_CALL, {"tool": tool, "args": list(args),
|
|
32
|
+
"decision": "ALLOW", "result": result, "result_hash": _rh(result)})
|
|
33
|
+
return True, result
|
|
34
|
+
|
|
35
|
+
def recorded_tool_results(log: EventLog):
|
|
36
|
+
"""Extract the ordered list of ALLOW tool results, to feed a replay Broker."""
|
|
37
|
+
return [e.payload["result"] for e in log.events
|
|
38
|
+
if e.kind == Kind.TOOL_CALL.value and e.payload.get("decision") == "ALLOW"]
|
vitnify/certificate.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""vitnify-receipt v1 -- the signed, self-verifying execution receipt.
|
|
2
|
+
|
|
3
|
+
One receipt binds an agent run into a single object:
|
|
4
|
+
|
|
5
|
+
program hash | granted capabilities | hash-chained event log (Merkle root + head)
|
|
6
|
+
| the engine's per-step model-computation digests
|
|
7
|
+
|
|
8
|
+
so an action can never be detached from the computation and context that produced
|
|
9
|
+
it. The receipt digest is BLAKE3 over a canonical body (matching the vitni-tensor
|
|
10
|
+
engine and the event log); the receipt is signed with ed25519 and embeds its own
|
|
11
|
+
public key, so ANYONE can verify it offline with no model, no network, and no secret.
|
|
12
|
+
|
|
13
|
+
Two verification levels:
|
|
14
|
+
* level 1 (integrity): recompute the Merkle root, chain head, and event count from
|
|
15
|
+
the raw events, and check the ed25519 signature. No model required.
|
|
16
|
+
* level 2 (recomputation): re-run each bound model step through vitni-tensor and
|
|
17
|
+
confirm every model_digest reproduces bit-for-bit.
|
|
18
|
+
|
|
19
|
+
verify_certificate() implements level 1 and is fully independent. HMAC signing is
|
|
20
|
+
retained only as a fallback for environments without an asymmetric key.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
import os, sys, json, hmac
|
|
24
|
+
from dataclasses import dataclass, field, asdict
|
|
25
|
+
from .events import EventLog
|
|
26
|
+
|
|
27
|
+
from ._vendor.pck.cas import MerkleCAS # noqa: E402
|
|
28
|
+
|
|
29
|
+
FORMAT = "vitnify-receipt v1"
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import blake3 as _blake3
|
|
33
|
+
def _digest32(b: bytes) -> str:
|
|
34
|
+
return _blake3.blake3(b).hexdigest()
|
|
35
|
+
except ImportError:
|
|
36
|
+
import hashlib
|
|
37
|
+
def _digest32(b: bytes) -> str:
|
|
38
|
+
return hashlib.blake2b(b, digest_size=32).hexdigest()
|
|
39
|
+
|
|
40
|
+
# cryptography is imported lazily inside the ed25519 helpers so the fallback path
|
|
41
|
+
# still runs on a stock Python without the dependency.
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _canon(o) -> str:
|
|
45
|
+
return json.dumps(o, sort_keys=True, separators=(",", ":"))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def gen_ed25519():
|
|
49
|
+
"""Return (private_key, public_key_hex). Persist the private key to sign future runs."""
|
|
50
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
51
|
+
priv = Ed25519PrivateKey.generate()
|
|
52
|
+
return priv, priv.public_key().public_bytes_raw().hex()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ExecutionCertificate:
|
|
57
|
+
program_hash: str
|
|
58
|
+
capabilities: list
|
|
59
|
+
event_root: str
|
|
60
|
+
n_events: int
|
|
61
|
+
head_hash: str
|
|
62
|
+
model_digests: list = field(default_factory=list) # engine per-step digests, bound by the log
|
|
63
|
+
v: str = FORMAT
|
|
64
|
+
sig: str | None = None
|
|
65
|
+
sig_alg: str = "none"
|
|
66
|
+
pubkey: str | None = None # ed25519 public key (hex) -- verifier needs no secret
|
|
67
|
+
|
|
68
|
+
def body(self) -> dict:
|
|
69
|
+
return {"v": self.v, "program_hash": self.program_hash,
|
|
70
|
+
"capabilities": sorted(self.capabilities), "event_root": self.event_root,
|
|
71
|
+
"n_events": self.n_events, "head_hash": self.head_hash,
|
|
72
|
+
"model_digests": list(self.model_digests)}
|
|
73
|
+
|
|
74
|
+
def digest(self) -> str:
|
|
75
|
+
return _digest32(_canon(self.body()).encode())
|
|
76
|
+
|
|
77
|
+
def sign_ed25519(self, priv) -> "ExecutionCertificate":
|
|
78
|
+
self.sig = priv.sign(bytes.fromhex(self.digest())).hex()
|
|
79
|
+
self.pubkey = priv.public_key().public_bytes_raw().hex()
|
|
80
|
+
self.sig_alg = "ed25519"
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def sign_hmac(self, key: bytes) -> "ExecutionCertificate":
|
|
84
|
+
import hashlib
|
|
85
|
+
self.sig = hmac.new(key, self.digest().encode(), hashlib.blake2b).hexdigest()
|
|
86
|
+
self.sig_alg = "hmac-blake2b"
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def to_json(self) -> str:
|
|
90
|
+
return _canon(asdict(self))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def issue_certificate(program_hash, capabilities, log: EventLog, priv=None, key: bytes | None = None):
|
|
94
|
+
"""Build and sign a vitnify-receipt for a completed run.
|
|
95
|
+
|
|
96
|
+
The model-computation digests are read from the log's llm_call events, so the
|
|
97
|
+
receipt binds exactly the model steps that were recorded. ed25519 (self-verifying)
|
|
98
|
+
is the canonical signing path; pass `priv`.
|
|
99
|
+
"""
|
|
100
|
+
cas = MerkleCAS(log.chunks())
|
|
101
|
+
cert = ExecutionCertificate(
|
|
102
|
+
program_hash, sorted(set(capabilities)), cas.root, len(log), log.head(),
|
|
103
|
+
model_digests=log.model_digests(),
|
|
104
|
+
)
|
|
105
|
+
if priv is not None:
|
|
106
|
+
cert.sign_ed25519(priv)
|
|
107
|
+
elif key is not None:
|
|
108
|
+
cert.sign_hmac(key)
|
|
109
|
+
return cert, cas
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def verify_certificate(cert: ExecutionCertificate, log: EventLog, key: bytes | None = None) -> dict:
|
|
113
|
+
"""Level-1 verification: recompute everything from the raw events; trust nothing.
|
|
114
|
+
|
|
115
|
+
ed25519 receipts verify against their OWN embedded public key -- no secret required.
|
|
116
|
+
(Level 2 -- recomputing each model_digest through vitni-tensor -- is a separate step
|
|
117
|
+
that additionally needs the model weights.)
|
|
118
|
+
"""
|
|
119
|
+
cas = MerkleCAS(log.chunks())
|
|
120
|
+
checks = {
|
|
121
|
+
"format": cert.v == FORMAT,
|
|
122
|
+
"root_matches": cas.root == cert.event_root,
|
|
123
|
+
"head_matches": log.head() == cert.head_hash,
|
|
124
|
+
"count_matches": len(log) == cert.n_events,
|
|
125
|
+
"model_digests_match": log.model_digests() == list(cert.model_digests),
|
|
126
|
+
}
|
|
127
|
+
if cert.sig_alg == "ed25519":
|
|
128
|
+
try:
|
|
129
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
130
|
+
Ed25519PublicKey.from_public_bytes(bytes.fromhex(cert.pubkey)).verify(
|
|
131
|
+
bytes.fromhex(cert.sig), bytes.fromhex(cert.digest()))
|
|
132
|
+
checks["sig_valid"] = True
|
|
133
|
+
except Exception:
|
|
134
|
+
checks["sig_valid"] = False
|
|
135
|
+
elif cert.sig_alg == "hmac-blake2b" and key is not None:
|
|
136
|
+
import hashlib
|
|
137
|
+
checks["sig_valid"] = cert.sig == hmac.new(key, cert.digest().encode(), hashlib.blake2b).hexdigest()
|
|
138
|
+
checks["ok"] = all(checks.values())
|
|
139
|
+
return checks
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Deterministic local inference -- the piece that makes a replay CERTIFIABLE.
|
|
2
|
+
Greedy decode over TinyLlama, with an optional batch-invariant linear (your bi_linear
|
|
3
|
+
work). The certificate commits to a hash of the LOGITS at each step, not just the token:
|
|
4
|
+
tokens rarely flip under batch load, but logits always shift -- so a bit-identical
|
|
5
|
+
certificate can only be reproduced when inference is batch-invariant.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
import hashlib
|
|
9
|
+
from contextlib import nullcontext
|
|
10
|
+
import torch, torch.nn.functional as F
|
|
11
|
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
12
|
+
|
|
13
|
+
_ORIG_LINEAR = F.linear
|
|
14
|
+
|
|
15
|
+
def _bi_linear(x, w, b=None):
|
|
16
|
+
"""Fixed-chunk fp32 reduction -> output independent of batch size (bit-identical)."""
|
|
17
|
+
d, CH = x.shape[-1], 128
|
|
18
|
+
xf, wf, acc = x.float(), w.float(), None
|
|
19
|
+
for c in range(0, d, CH):
|
|
20
|
+
p = xf[..., c:c+CH] @ wf[:, c:c+CH].t()
|
|
21
|
+
acc = p if acc is None else acc + p
|
|
22
|
+
out = (acc + b.float()) if b is not None else acc
|
|
23
|
+
return out.to(x.dtype)
|
|
24
|
+
|
|
25
|
+
class batch_invariant:
|
|
26
|
+
def __enter__(self): F.linear = _bi_linear
|
|
27
|
+
def __exit__(self, *a): F.linear = _ORIG_LINEAR
|
|
28
|
+
|
|
29
|
+
def hash_tensor(t: torch.Tensor) -> str:
|
|
30
|
+
return hashlib.blake2b(t.detach().to("cpu").contiguous().numpy().tobytes(), digest_size=16).hexdigest()
|
|
31
|
+
|
|
32
|
+
class DeterministicLM:
|
|
33
|
+
def __init__(self, name="TinyLlama/TinyLlama-1.1B-Chat-v1.0", device=None):
|
|
34
|
+
self.dev = device or ("mps" if torch.backends.mps.is_available() else "cpu")
|
|
35
|
+
self.tok = AutoTokenizer.from_pretrained(name, local_files_only=True)
|
|
36
|
+
self.model = AutoModelForCausalLM.from_pretrained(
|
|
37
|
+
name, torch_dtype=torch.float16, local_files_only=True).eval().to(self.dev)
|
|
38
|
+
self.V = self.model.config.vocab_size
|
|
39
|
+
|
|
40
|
+
def chat_prompt(self, messages) -> str:
|
|
41
|
+
"""Render messages with the model's own chat template (Qwen, Llama, etc.)."""
|
|
42
|
+
return self.tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
|
43
|
+
|
|
44
|
+
@torch.no_grad()
|
|
45
|
+
def generate(self, prompt: str, n_new=8, batch_load=0, invariant=False):
|
|
46
|
+
"""Greedy decode. batch_load simulates production co-batching (padded neighbors).
|
|
47
|
+
Returns (tokens, per_step_logit_hashes). Our request is always row 0."""
|
|
48
|
+
ids = self.tok(prompt, return_tensors="pt").input_ids.to(self.dev)
|
|
49
|
+
ctx = batch_invariant() if invariant else nullcontext()
|
|
50
|
+
toks, hashes = [], []
|
|
51
|
+
with ctx:
|
|
52
|
+
cur = ids
|
|
53
|
+
for _ in range(n_new):
|
|
54
|
+
if batch_load:
|
|
55
|
+
pad = torch.randint(0, self.V, (batch_load, cur.shape[1]), device=self.dev)
|
|
56
|
+
batch = torch.cat([cur, pad], 0)
|
|
57
|
+
else:
|
|
58
|
+
batch = cur
|
|
59
|
+
logits = self.model(batch).logits[0, -1]
|
|
60
|
+
hashes.append(hash_tensor(logits))
|
|
61
|
+
nxt = int(logits.argmax())
|
|
62
|
+
toks.append(nxt)
|
|
63
|
+
cur = torch.cat([cur, torch.tensor([[nxt]], device=self.dev)], 1)
|
|
64
|
+
return toks, hashes
|
vitnify/engine.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Bridge to the vitni-tensor engine.
|
|
2
|
+
|
|
3
|
+
Runs a model step through the deterministic engine and returns its
|
|
4
|
+
`vitnify-receipt v1` model-computation digest, so the SDK can bind what the model
|
|
5
|
+
actually computed into the signed execution receipt (`EventLog.append_llm_call`).
|
|
6
|
+
|
|
7
|
+
The engine is the `vitni-receipt` binary (built from the vitni-tensor crate). Point
|
|
8
|
+
`VITNI_RECEIPT_BIN` at it, or put it on PATH.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
import os, json, subprocess
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import blake3 as _blake3
|
|
15
|
+
def _h32(b: bytes) -> str:
|
|
16
|
+
return _blake3.blake3(b).hexdigest()
|
|
17
|
+
except ImportError:
|
|
18
|
+
import hashlib
|
|
19
|
+
def _h32(b: bytes) -> str:
|
|
20
|
+
return hashlib.blake2b(b, digest_size=32).hexdigest()
|
|
21
|
+
|
|
22
|
+
DEFAULT_BIN = os.environ.get("VITNI_RECEIPT_BIN", "vitni-receipt")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def prompt_hash(tokens: list[int]) -> str:
|
|
26
|
+
return _h32(json.dumps(list(tokens), separators=(",", ":")).encode())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Engine:
|
|
30
|
+
"""Deterministic model backend. Every `run` is reproducible bit-for-bit and yields
|
|
31
|
+
a cross-vendor model-computation digest to bind into the receipt."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, gguf: str, model_id: str, binary: str = DEFAULT_BIN):
|
|
34
|
+
self.gguf = gguf
|
|
35
|
+
self.model_id = model_id
|
|
36
|
+
self.binary = binary
|
|
37
|
+
|
|
38
|
+
def run(self, prompt_tokens: list[int], n_new: int = 16) -> dict:
|
|
39
|
+
"""Return {"tokens": [...], "model_digest": hex, "weights_hash": hex}."""
|
|
40
|
+
proc = subprocess.run(
|
|
41
|
+
[self.binary, "--gguf", self.gguf,
|
|
42
|
+
"--prompt", ",".join(str(t) for t in prompt_tokens),
|
|
43
|
+
"--n", str(n_new), "--model-id", self.model_id],
|
|
44
|
+
capture_output=True, text=True, check=True,
|
|
45
|
+
)
|
|
46
|
+
return json.loads(proc.stdout)
|
vitnify/events.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Canonical, hash-chained agent event log.
|
|
2
|
+
|
|
3
|
+
Every nondeterministic input/output of an agent run becomes one canonical,
|
|
4
|
+
hash-linked Event. The chain gives append-only tamper-evidence during recording;
|
|
5
|
+
certificate.py Merkle-commits the same chunks for inclusion proofs and binds the
|
|
6
|
+
root into the signed vitnify-receipt.
|
|
7
|
+
|
|
8
|
+
Hashing is BLAKE3 throughout (matching the vitni-tensor engine and the
|
|
9
|
+
`vitnify-receipt v1` format), so the whole receipt is one hash family end to end.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass, asdict
|
|
14
|
+
from enum import Enum
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import blake3 as _blake3
|
|
18
|
+
def _h32(b: bytes) -> str:
|
|
19
|
+
return _blake3.blake3(b).hexdigest()
|
|
20
|
+
except ImportError: # stdlib fallback; wire format is identical either way
|
|
21
|
+
import hashlib
|
|
22
|
+
def _h32(b: bytes) -> str:
|
|
23
|
+
return hashlib.blake2b(b, digest_size=32).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def canon(obj) -> str:
|
|
27
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def h(s: str) -> str:
|
|
31
|
+
return _h32(s.encode())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Kind(str, Enum):
|
|
35
|
+
LLM_CALL = "llm_call" # (prompt_hash, tokens, seed, model_digest) -- the model step
|
|
36
|
+
TOOL_CALL = "tool_call" # (tool, args, decision, result) -- external, re-injected on replay
|
|
37
|
+
ENTROPY = "entropy" # (source, value) -- RNG/clock draw, from-log on replay
|
|
38
|
+
AGENT_STEP = "agent_step" # (state) -- orchestration checkpoint
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Event:
|
|
43
|
+
i: int
|
|
44
|
+
kind: str
|
|
45
|
+
payload: dict
|
|
46
|
+
prev: str
|
|
47
|
+
def canonical(self) -> str:
|
|
48
|
+
return canon({"i": self.i, "kind": self.kind, "payload": self.payload, "prev": self.prev})
|
|
49
|
+
@property
|
|
50
|
+
def hash(self) -> str:
|
|
51
|
+
return h(self.canonical())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class EventLog:
|
|
55
|
+
def __init__(self):
|
|
56
|
+
self.events: list[Event] = []
|
|
57
|
+
|
|
58
|
+
def append(self, kind, payload: dict) -> Event:
|
|
59
|
+
prev = self.events[-1].hash if self.events else "genesis"
|
|
60
|
+
ev = Event(len(self.events), kind.value if isinstance(kind, Kind) else str(kind), payload, prev)
|
|
61
|
+
self.events.append(ev)
|
|
62
|
+
return ev
|
|
63
|
+
|
|
64
|
+
def append_llm_call(self, prompt_hash: str, tokens: list, seed, model_digest: str) -> Event:
|
|
65
|
+
"""Record a model step and bind its vitni-tensor model-computation digest.
|
|
66
|
+
|
|
67
|
+
`model_digest` is the engine's `vitnify-receipt v1` digest for this forward
|
|
68
|
+
pass. Committing it here (and thus in the receipt's Merkle root) is what binds
|
|
69
|
+
the deterministic model recomputation to the agent run.
|
|
70
|
+
"""
|
|
71
|
+
return self.append(Kind.LLM_CALL, {
|
|
72
|
+
"prompt_hash": prompt_hash, "tokens": list(tokens),
|
|
73
|
+
"seed": seed, "model_digest": model_digest,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
def model_digests(self) -> list[str]:
|
|
77
|
+
"""Ordered engine model-computation digests bound by this log (for L2 recompute)."""
|
|
78
|
+
return [e.payload["model_digest"] for e in self.events
|
|
79
|
+
if e.kind == Kind.LLM_CALL.value and "model_digest" in e.payload]
|
|
80
|
+
|
|
81
|
+
def chunks(self) -> list[str]:
|
|
82
|
+
return [e.canonical() for e in self.events]
|
|
83
|
+
|
|
84
|
+
def head(self) -> str:
|
|
85
|
+
return self.events[-1].hash if self.events else "genesis"
|
|
86
|
+
|
|
87
|
+
def to_json(self) -> str:
|
|
88
|
+
return canon([asdict(e) for e in self.events])
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_events(cls, events: list[Event]) -> "EventLog":
|
|
92
|
+
log = cls(); log.events = events; return log
|
|
93
|
+
|
|
94
|
+
def __len__(self):
|
|
95
|
+
return len(self.events)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""LangGraph adapter — make a real LangGraph agent recordable, replayable, certifiable,
|
|
2
|
+
and contained, with no change to the agent's logic.
|
|
3
|
+
|
|
4
|
+
Two integration points:
|
|
5
|
+
1. build_react_graph(session, ...) -> a compiled LangGraph StateGraph whose *agent*
|
|
6
|
+
node calls session.llm() and whose *tool* node calls session.tool() (the capability
|
|
7
|
+
wall). Because everything flows through the VitniReplay Session, the whole graph is
|
|
8
|
+
recorded, deterministically replayable, and certified — for free.
|
|
9
|
+
2. VitniReplayCallback(session) -> a LangChain BaseCallbackHandler that records LLM/tool
|
|
10
|
+
events from ANY LangChain/LangGraph agent (observability + certificate). This path is
|
|
11
|
+
record-only: callbacks observe but cannot re-inject, so it doesn't drive replay.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
from typing import TypedDict, Optional
|
|
15
|
+
from .react import parse_action, REACT_SYS
|
|
16
|
+
from .events import Kind
|
|
17
|
+
|
|
18
|
+
try: # optional dependency: only needed to actually build/run the graph
|
|
19
|
+
from langgraph.graph import StateGraph, START, END
|
|
20
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
21
|
+
_IMPORT_ERROR: Exception | None = None
|
|
22
|
+
except ImportError as exc: # keep the module importable without the extra installed
|
|
23
|
+
StateGraph = START = END = None # type: ignore[assignment]
|
|
24
|
+
BaseCallbackHandler = object # type: ignore[assignment,misc]
|
|
25
|
+
_IMPORT_ERROR = exc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _require_langgraph() -> None:
|
|
29
|
+
"""Raise a clear, actionable error if the LangGraph extra is not installed."""
|
|
30
|
+
if _IMPORT_ERROR is not None:
|
|
31
|
+
raise ImportError(
|
|
32
|
+
"vitnify's LangGraph adapter requires 'langgraph' and 'langchain-core'. "
|
|
33
|
+
'Install them with: pip install "vitnify[langgraph]"'
|
|
34
|
+
) from _IMPORT_ERROR
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class VRState(TypedDict, total=False):
|
|
38
|
+
prompt: str
|
|
39
|
+
steps: int
|
|
40
|
+
last_text: str
|
|
41
|
+
last_act: Optional[dict]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def initial_prompt(task: str, tools_desc: str) -> str:
|
|
45
|
+
return (f"<|system|>\n{REACT_SYS.format(tools=tools_desc)}</s>\n"
|
|
46
|
+
f"<|user|>\n{task}</s>\n<|assistant|>\n")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_react_graph(session, tools_desc: str, tool_names, max_steps: int = 3, n_new: int = 40):
|
|
50
|
+
"""Compile a LangGraph StateGraph wired to a VitniReplay Session."""
|
|
51
|
+
_require_langgraph()
|
|
52
|
+
|
|
53
|
+
def agent_node(state: VRState) -> VRState:
|
|
54
|
+
text = session.llm(state["prompt"], n_new=n_new) # recorded + recomputable
|
|
55
|
+
act = parse_action(text, tool_names)
|
|
56
|
+
return {"last_text": text, "last_act": act, "steps": state.get("steps", 0) + 1}
|
|
57
|
+
|
|
58
|
+
def route(state: VRState) -> str:
|
|
59
|
+
act = state.get("last_act")
|
|
60
|
+
if act is None or "final" in act or state.get("steps", 0) >= max_steps:
|
|
61
|
+
return "end"
|
|
62
|
+
return "tools"
|
|
63
|
+
|
|
64
|
+
def tool_node(state: VRState) -> VRState:
|
|
65
|
+
act = state["last_act"]
|
|
66
|
+
ok, obs = session.tool(act["tool"], *act["args"]) # capability wall
|
|
67
|
+
observation = obs if ok else f"DENIED: agent has no capability '{act['tool']}'"
|
|
68
|
+
return {"prompt": state["prompt"] + state["last_text"] + f"\nObservation: {observation}\n"}
|
|
69
|
+
|
|
70
|
+
g = StateGraph(VRState)
|
|
71
|
+
g.add_node("agent", agent_node)
|
|
72
|
+
g.add_node("tools", tool_node)
|
|
73
|
+
g.add_edge(START, "agent")
|
|
74
|
+
g.add_conditional_edges("agent", route, {"tools": "tools", "end": END})
|
|
75
|
+
g.add_edge("tools", "agent")
|
|
76
|
+
return g.compile()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class VitniReplayCallback(BaseCallbackHandler):
|
|
80
|
+
"""Observability adapter for ANY LangChain/LangGraph agent: records LLM + tool events
|
|
81
|
+
into the session's event log. Record-only (callbacks can't re-inject for replay)."""
|
|
82
|
+
def __init__(self, session):
|
|
83
|
+
_require_langgraph()
|
|
84
|
+
self.session = session
|
|
85
|
+
self._pending = ("?", "")
|
|
86
|
+
|
|
87
|
+
def on_llm_end(self, response, **kwargs):
|
|
88
|
+
try:
|
|
89
|
+
text = response.generations[0][0].text
|
|
90
|
+
except Exception:
|
|
91
|
+
text = str(response)[:200]
|
|
92
|
+
self.session.log.append(Kind.LLM_CALL, {"text": text, "via": "callback"})
|
|
93
|
+
|
|
94
|
+
def on_tool_start(self, serialized, input_str, **kwargs):
|
|
95
|
+
name = serialized.get("name", "?") if isinstance(serialized, dict) else "?"
|
|
96
|
+
self._pending = (name, str(input_str))
|
|
97
|
+
|
|
98
|
+
def on_tool_end(self, output, **kwargs):
|
|
99
|
+
name, inp = self._pending
|
|
100
|
+
self.session.log.append(Kind.TOOL_CALL,
|
|
101
|
+
{"tool": name, "args": [inp], "decision": "OBSERVED", "result": str(output)[:200]})
|