arcaeon-compact 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.
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""arcaeon_compact — tamper-evident receipts for context compaction.
|
|
2
|
+
|
|
3
|
+
When an agent compacts context — summarizes a conversation, prunes memory,
|
|
4
|
+
truncates history — nothing, today, proves what was dropped. The summary says
|
|
5
|
+
"nothing important was lost" and you take its word. A CompactionReceipt fixes
|
|
6
|
+
the provable part: a hash-chained record binding
|
|
7
|
+
|
|
8
|
+
- a digest of the FULL pre-compaction content,
|
|
9
|
+
- a digest of the post-compaction SURVIVOR,
|
|
10
|
+
- a drop-manifest: per-dropped-item digests + count + byte totals
|
|
11
|
+
(digests only, NEVER content — privacy by construction),
|
|
12
|
+
- the compactor's identity string + method label,
|
|
13
|
+
- a timestamp,
|
|
14
|
+
|
|
15
|
+
appended as one row to an `arcaeon-ledger` chain, so the receipt itself is
|
|
16
|
+
tamper-evident and ordered against every other receipt.
|
|
17
|
+
|
|
18
|
+
Three calls:
|
|
19
|
+
|
|
20
|
+
from arcaeon_compact import CompactionReceipt, verify_receipt
|
|
21
|
+
|
|
22
|
+
receipt = CompactionReceipt.open(pre_content) # list[str | bytes | dict]
|
|
23
|
+
receipt.record_kept(post_content) # dropped inferred by digest
|
|
24
|
+
row = receipt.seal("receipts.jsonl", compactor="summarizer-v2",
|
|
25
|
+
method="llm-summary")
|
|
26
|
+
|
|
27
|
+
verify_receipt(row) # self-consistency, always
|
|
28
|
+
verify_receipt(row, pre_content, post_content) # recompute + compare
|
|
29
|
+
|
|
30
|
+
WHAT IT PROVES, AND WHAT IT DOESN'T — the boundary is the product:
|
|
31
|
+
|
|
32
|
+
1. It proves WHAT was dropped, never that dropping was WISE. The receipt has
|
|
33
|
+
no opinion on salience. A compactor that keeps the pleasantries and drops
|
|
34
|
+
the wire-transfer instructions gets a perfectly valid receipt saying
|
|
35
|
+
exactly that.
|
|
36
|
+
2. It proves the compactor's CLAIM about its inputs, not that the inputs were
|
|
37
|
+
complete (the gateway problem). If content was withheld before `open()`
|
|
38
|
+
ever saw it, the receipt faithfully notarizes the partial view. The
|
|
39
|
+
receipt binds what crossed the gate, not what existed behind it.
|
|
40
|
+
3. Digests only means content is NOT recoverable from the receipt. Feature:
|
|
41
|
+
dropped secrets don't leak into the audit trail. Limitation: you can
|
|
42
|
+
prove an item you still HOLD was dropped (hash it, find it in the
|
|
43
|
+
manifest); you cannot resurrect an item you lost.
|
|
44
|
+
|
|
45
|
+
Stdlib + arcaeon-ledger only. MIT.
|
|
46
|
+
"""
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import json
|
|
50
|
+
from collections import Counter
|
|
51
|
+
from datetime import datetime, timezone
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import Any, Iterable, Sequence
|
|
54
|
+
|
|
55
|
+
from arcaeon_ledger import Ledger, digest_bytes, digest_json
|
|
56
|
+
|
|
57
|
+
__version__ = "0.1.0"
|
|
58
|
+
__all__ = ["CompactionReceipt", "verify_receipt", "SCHEMA"]
|
|
59
|
+
|
|
60
|
+
# The receipt schema label, frozen at v1. A future shape change mints v2;
|
|
61
|
+
# old rows keep v1 forever, so a schema drift never reads history as tampered.
|
|
62
|
+
SCHEMA = "arcaeon-compact:receipt:v1"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _now_iso() -> str:
|
|
66
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _digest_item(item: Any) -> tuple[str, int]:
|
|
70
|
+
"""(self-describing digest, byte length) for one content item.
|
|
71
|
+
|
|
72
|
+
The type rule is part of the v1 schema — frozen, so a stranger can
|
|
73
|
+
reproduce any manifest entry from content they hold:
|
|
74
|
+
|
|
75
|
+
bytes / bytearray -> raw-bytes digest of the bytes, as-is
|
|
76
|
+
str -> raw-bytes digest of the UTF-8 encoding
|
|
77
|
+
anything else -> json-c14n digest of the canonicalized value
|
|
78
|
+
(arcaeon-ledger's pinned recipe: sorted keys,
|
|
79
|
+
compact separators, UTF-8, NaN rejected)
|
|
80
|
+
|
|
81
|
+
Digests are always self-describing (`sha256:<recipe>:<ver>:<hex>`) —
|
|
82
|
+
never a bare hex hash — so each manifest entry carries its own recipe.
|
|
83
|
+
"""
|
|
84
|
+
if isinstance(item, (bytes, bytearray)):
|
|
85
|
+
b = bytes(item)
|
|
86
|
+
return digest_bytes(b), len(b)
|
|
87
|
+
if isinstance(item, str):
|
|
88
|
+
b = item.encode("utf-8")
|
|
89
|
+
return digest_bytes(b), len(b)
|
|
90
|
+
canon = json.dumps(item, sort_keys=True, separators=(",", ":"),
|
|
91
|
+
ensure_ascii=False, allow_nan=False).encode("utf-8")
|
|
92
|
+
return digest_json(item), len(canon)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _digest_content(items: Iterable[Any]) -> tuple[list[str], list[int], str, int]:
|
|
96
|
+
"""Digest a content list: (per-item digests, per-item bytes, whole, total).
|
|
97
|
+
|
|
98
|
+
The WHOLE digest is `digest_json` over the ordered list of per-item digest
|
|
99
|
+
strings — a digest of digests. Deterministic, order-sensitive, and
|
|
100
|
+
recomputable from content alone, without ever storing content.
|
|
101
|
+
"""
|
|
102
|
+
digests: list[str] = []
|
|
103
|
+
sizes: list[int] = []
|
|
104
|
+
for it in items:
|
|
105
|
+
d, n = _digest_item(it)
|
|
106
|
+
digests.append(d)
|
|
107
|
+
sizes.append(n)
|
|
108
|
+
return digests, sizes, digest_json(digests), sum(sizes)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _well_formed(digest: str) -> bool:
|
|
112
|
+
"""A self-describing digest has exactly 4 colon-parts with a hex tail."""
|
|
113
|
+
parts = digest.split(":") if isinstance(digest, str) else []
|
|
114
|
+
if len(parts) != 4 or not all(parts):
|
|
115
|
+
return False
|
|
116
|
+
try:
|
|
117
|
+
int(parts[3], 16)
|
|
118
|
+
except ValueError:
|
|
119
|
+
return False
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class CompactionReceipt:
|
|
124
|
+
"""One compaction event, opened on the full content, sealed to a ledger.
|
|
125
|
+
|
|
126
|
+
Lifecycle: `open(pre)` -> `record_kept(post)` [-> `record_dropped(items)`]
|
|
127
|
+
-> `seal(...)`. After seal the receipt is immutable; recording or sealing
|
|
128
|
+
again raises. `record_dropped` is optional — the drop set is inferred as
|
|
129
|
+
pre-minus-kept by digest (multiset, duplicates counted); if you do record
|
|
130
|
+
it explicitly, seal() reconciles your claim against the inference and
|
|
131
|
+
refuses to notarize a receipt that disagrees with itself.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, *_a: Any, **_k: Any):
|
|
135
|
+
raise TypeError("use CompactionReceipt.open(pre_content)")
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def open(cls, pre_content: Sequence[Any]) -> "CompactionReceipt":
|
|
139
|
+
"""Digest every pre-compaction item + the whole; start the receipt."""
|
|
140
|
+
self = object.__new__(cls)
|
|
141
|
+
self._pre_digests, self._pre_sizes, self._pre_whole, self._pre_bytes = \
|
|
142
|
+
_digest_content(pre_content)
|
|
143
|
+
self._opened_at = _now_iso()
|
|
144
|
+
self._post: tuple[list[str], list[int], str, int] | None = None
|
|
145
|
+
self._dropped_explicit: list[str] | None = None
|
|
146
|
+
self._sealed_row: dict | None = None
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def _require_unsealed(self) -> None:
|
|
150
|
+
if self._sealed_row is not None:
|
|
151
|
+
raise RuntimeError("receipt already sealed; open a new one")
|
|
152
|
+
|
|
153
|
+
def record_kept(self, post_content: Sequence[Any]) -> None:
|
|
154
|
+
"""Digest the post-compaction survivor content."""
|
|
155
|
+
self._require_unsealed()
|
|
156
|
+
self._post = _digest_content(post_content)
|
|
157
|
+
|
|
158
|
+
def record_dropped(self, items: Sequence[Any]) -> None:
|
|
159
|
+
"""Explicitly claim the dropped items (optional; else inferred).
|
|
160
|
+
|
|
161
|
+
seal() checks this claim against pre-minus-kept and raises on any
|
|
162
|
+
disagreement — the receipt never notarizes an inconsistent claim.
|
|
163
|
+
"""
|
|
164
|
+
self._require_unsealed()
|
|
165
|
+
self._dropped_explicit = [_digest_item(it)[0] for it in items]
|
|
166
|
+
|
|
167
|
+
def seal(self, ledger_path: str | Path, *, compactor: str,
|
|
168
|
+
method: str) -> dict:
|
|
169
|
+
"""Append the receipt row to an arcaeon-ledger chain; return the row.
|
|
170
|
+
|
|
171
|
+
`compactor` is the identity string of whatever did the compacting
|
|
172
|
+
(an agent id, a model name, a service); `method` labels how
|
|
173
|
+
("llm-summary", "fifo-truncate", "salience-prune", ...). Both are
|
|
174
|
+
claims, bound tamper-evidently — the chain proves they weren't edited
|
|
175
|
+
after the fact, not that they were true when written.
|
|
176
|
+
"""
|
|
177
|
+
self._require_unsealed()
|
|
178
|
+
if self._post is None:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
"seal() before record_kept() — record the survivor first "
|
|
181
|
+
"(an empty list is a valid survivor: everything was dropped)")
|
|
182
|
+
post_digests, _post_sizes, post_whole, post_bytes = self._post
|
|
183
|
+
|
|
184
|
+
# Inferred drop set: pre minus kept, as a multiset over digests, in
|
|
185
|
+
# pre order. Duplicates count: keeping one copy of a twice-seen item
|
|
186
|
+
# still drops the other.
|
|
187
|
+
remaining = Counter(post_digests)
|
|
188
|
+
dropped: list[str] = []
|
|
189
|
+
dropped_bytes = 0
|
|
190
|
+
for d, n in zip(self._pre_digests, self._pre_sizes):
|
|
191
|
+
if remaining[d] > 0:
|
|
192
|
+
remaining[d] -= 1
|
|
193
|
+
else:
|
|
194
|
+
dropped.append(d)
|
|
195
|
+
dropped_bytes += n
|
|
196
|
+
# Whatever the survivor holds that pre never did was INTRODUCED by the
|
|
197
|
+
# compactor (the summary text itself, typically).
|
|
198
|
+
introduced = sum(remaining.values())
|
|
199
|
+
|
|
200
|
+
if self._dropped_explicit is not None and \
|
|
201
|
+
Counter(self._dropped_explicit) != Counter(dropped):
|
|
202
|
+
raise ValueError(
|
|
203
|
+
"record_dropped() claim disagrees with pre-minus-kept "
|
|
204
|
+
f"(claimed {len(self._dropped_explicit)} item(s), inferred "
|
|
205
|
+
f"{len(dropped)}); refusing to seal an inconsistent receipt")
|
|
206
|
+
|
|
207
|
+
body = {
|
|
208
|
+
"schema": SCHEMA,
|
|
209
|
+
"pre": {"count": len(self._pre_digests), "bytes": self._pre_bytes,
|
|
210
|
+
"digest": self._pre_whole},
|
|
211
|
+
"post": {"count": len(post_digests), "bytes": post_bytes,
|
|
212
|
+
"digest": post_whole},
|
|
213
|
+
"dropped": {"count": len(dropped), "bytes": dropped_bytes,
|
|
214
|
+
"items": dropped},
|
|
215
|
+
"introduced": {"count": introduced},
|
|
216
|
+
"compactor": compactor,
|
|
217
|
+
"method": method,
|
|
218
|
+
}
|
|
219
|
+
row = dict(body)
|
|
220
|
+
row["kind"] = "compaction_receipt"
|
|
221
|
+
row["opened_at"] = self._opened_at
|
|
222
|
+
# The receipt digest covers the deterministic core (body) only — not
|
|
223
|
+
# timestamps or the chain — so it is reproducible from content alone
|
|
224
|
+
# and a receipt row stays checkable even copied OUT of its ledger.
|
|
225
|
+
row["receipt_digest"] = digest_json(body)
|
|
226
|
+
row["ts"] = _now_iso()
|
|
227
|
+
row["chain"] = Ledger(ledger_path).append(row)
|
|
228
|
+
self._sealed_row = row
|
|
229
|
+
return row
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _core_body(row: dict) -> dict:
|
|
233
|
+
"""The deterministic core the receipt_digest covers."""
|
|
234
|
+
return {k: row.get(k) for k in
|
|
235
|
+
("schema", "pre", "post", "dropped", "introduced",
|
|
236
|
+
"compactor", "method")}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def verify_receipt(row: dict, pre_content: Sequence[Any] | None = None,
|
|
240
|
+
post_content: Sequence[Any] | None = None) -> dict:
|
|
241
|
+
"""Verify a receipt row. Self-consistency always; recompute if given content.
|
|
242
|
+
|
|
243
|
+
Self-consistency (no content needed): schema known, every digest
|
|
244
|
+
well-formed, the counts add up (pre = kept + dropped, post = kept +
|
|
245
|
+
introduced), the manifest length matches its count, and the
|
|
246
|
+
receipt_digest reproduces from the row's own core — so an edited row is
|
|
247
|
+
caught even when it's been copied out of its ledger. (Inside a ledger the
|
|
248
|
+
chain catches the same edit; this check travels with the row.)
|
|
249
|
+
|
|
250
|
+
With `pre_content` and/or `post_content`: recompute the digests from the
|
|
251
|
+
content you hold and compare. With BOTH, the drop set itself is
|
|
252
|
+
recomputed (pre minus post, by digest) and compared against the manifest
|
|
253
|
+
— a receipt that claims nothing was dropped while something was, fails
|
|
254
|
+
here by construction.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
{"ok": bool, # self_consistent AND content didn't mismatch
|
|
258
|
+
"self_consistent": bool,
|
|
259
|
+
"content": "skipped" | "match" | "mismatch",
|
|
260
|
+
"notes": [<str>, ...]}
|
|
261
|
+
|
|
262
|
+
What a pass MEANS, exactly: the row is internally coherent and, if you
|
|
263
|
+
provided content, that content reproduces the claim. It does not mean the
|
|
264
|
+
drop was wise, and it does not mean the pre-content the compactor showed
|
|
265
|
+
the receipt was everything that existed (the gateway problem).
|
|
266
|
+
"""
|
|
267
|
+
notes: list[str] = []
|
|
268
|
+
out = {"ok": False, "self_consistent": False, "content": "skipped",
|
|
269
|
+
"notes": notes}
|
|
270
|
+
|
|
271
|
+
# --- self-consistency ---------------------------------------------------
|
|
272
|
+
sc = True
|
|
273
|
+
if row.get("schema") != SCHEMA:
|
|
274
|
+
notes.append(f"unknown schema {row.get('schema')!r} — cannot verify")
|
|
275
|
+
return out
|
|
276
|
+
try:
|
|
277
|
+
pre, post, dropped = row["pre"], row["post"], row["dropped"]
|
|
278
|
+
introduced = row["introduced"]
|
|
279
|
+
counts = (pre["count"], post["count"], dropped["count"],
|
|
280
|
+
introduced["count"], pre["bytes"], post["bytes"],
|
|
281
|
+
dropped["bytes"])
|
|
282
|
+
manifest = dropped["items"]
|
|
283
|
+
except (KeyError, TypeError) as e:
|
|
284
|
+
notes.append(f"malformed receipt row: missing {e}")
|
|
285
|
+
return out
|
|
286
|
+
if not all(isinstance(c, int) and c >= 0 for c in counts):
|
|
287
|
+
sc = False
|
|
288
|
+
notes.append("counts/bytes must be non-negative integers")
|
|
289
|
+
for label, d in [("pre.digest", pre.get("digest")),
|
|
290
|
+
("post.digest", post.get("digest")),
|
|
291
|
+
("receipt_digest", row.get("receipt_digest"))] + \
|
|
292
|
+
[(f"dropped.items[{i}]", d) for i, d in enumerate(manifest)]:
|
|
293
|
+
if not _well_formed(d):
|
|
294
|
+
sc = False
|
|
295
|
+
notes.append(f"{label} is not a well-formed self-describing digest")
|
|
296
|
+
if len(manifest) != dropped["count"]:
|
|
297
|
+
sc = False
|
|
298
|
+
notes.append(f"manifest length {len(manifest)} != dropped.count "
|
|
299
|
+
f"{dropped['count']}")
|
|
300
|
+
kept = pre["count"] - dropped["count"]
|
|
301
|
+
if kept < 0:
|
|
302
|
+
sc = False
|
|
303
|
+
notes.append("dropped.count exceeds pre.count")
|
|
304
|
+
elif post["count"] - introduced["count"] != kept:
|
|
305
|
+
sc = False
|
|
306
|
+
notes.append("counts do not reconcile: post - introduced != pre - dropped")
|
|
307
|
+
if row.get("receipt_digest") != digest_json(_core_body(row)):
|
|
308
|
+
sc = False
|
|
309
|
+
notes.append("receipt_digest does not reproduce from the row's core "
|
|
310
|
+
"— the row was altered")
|
|
311
|
+
out["self_consistent"] = sc
|
|
312
|
+
|
|
313
|
+
# --- content recomputation ----------------------------------------------
|
|
314
|
+
mismatch = False
|
|
315
|
+
checked = False
|
|
316
|
+
pre_digests = pre_sizes = None
|
|
317
|
+
if pre_content is not None:
|
|
318
|
+
checked = True
|
|
319
|
+
pre_digests, pre_sizes, whole, total = _digest_content(pre_content)
|
|
320
|
+
for label, got, want in [("pre.digest", whole, pre.get("digest")),
|
|
321
|
+
("pre.count", len(pre_digests), pre["count"]),
|
|
322
|
+
("pre.bytes", total, pre["bytes"])]:
|
|
323
|
+
if got != want:
|
|
324
|
+
mismatch = True
|
|
325
|
+
notes.append(f"{label}: recomputed {got!r} != claimed {want!r}")
|
|
326
|
+
# every manifest entry must exist in pre (multiset)
|
|
327
|
+
avail = Counter(pre_digests)
|
|
328
|
+
for d in manifest:
|
|
329
|
+
if avail[d] > 0:
|
|
330
|
+
avail[d] -= 1
|
|
331
|
+
else:
|
|
332
|
+
mismatch = True
|
|
333
|
+
notes.append(f"manifest digest {d[:40]}… not found in "
|
|
334
|
+
"pre_content — receipt claims a drop of an item "
|
|
335
|
+
"the pre-content never contained")
|
|
336
|
+
post_digests = None
|
|
337
|
+
if post_content is not None:
|
|
338
|
+
checked = True
|
|
339
|
+
post_digests, _sizes, whole, total = _digest_content(post_content)
|
|
340
|
+
for label, got, want in [("post.digest", whole, post.get("digest")),
|
|
341
|
+
("post.count", len(post_digests), post["count"]),
|
|
342
|
+
("post.bytes", total, post["bytes"])]:
|
|
343
|
+
if got != want:
|
|
344
|
+
mismatch = True
|
|
345
|
+
notes.append(f"{label}: recomputed {got!r} != claimed {want!r}")
|
|
346
|
+
if pre_digests is not None and post_digests is not None:
|
|
347
|
+
# Recompute the drop set from BOTH sides and hold it against the
|
|
348
|
+
# manifest — the planted-drop catch. A compactor that dropped item X
|
|
349
|
+
# while claiming it dropped nothing fails exactly here.
|
|
350
|
+
remaining = Counter(post_digests)
|
|
351
|
+
actual: list[str] = []
|
|
352
|
+
actual_bytes = 0
|
|
353
|
+
for d, n in zip(pre_digests, pre_sizes):
|
|
354
|
+
if remaining[d] > 0:
|
|
355
|
+
remaining[d] -= 1
|
|
356
|
+
else:
|
|
357
|
+
actual.append(d)
|
|
358
|
+
actual_bytes += n
|
|
359
|
+
if Counter(actual) != Counter(manifest):
|
|
360
|
+
mismatch = True
|
|
361
|
+
notes.append(f"drop-manifest disagrees with content: recomputed "
|
|
362
|
+
f"{len(actual)} dropped item(s), manifest claims "
|
|
363
|
+
f"{len(manifest)}")
|
|
364
|
+
elif actual_bytes != dropped["bytes"]:
|
|
365
|
+
mismatch = True
|
|
366
|
+
notes.append(f"dropped.bytes: recomputed {actual_bytes} != "
|
|
367
|
+
f"claimed {dropped['bytes']}")
|
|
368
|
+
if sum(remaining.values()) != introduced["count"]:
|
|
369
|
+
mismatch = True
|
|
370
|
+
notes.append(f"introduced.count: recomputed "
|
|
371
|
+
f"{sum(remaining.values())} != claimed "
|
|
372
|
+
f"{introduced['count']}")
|
|
373
|
+
|
|
374
|
+
out["content"] = "mismatch" if mismatch else ("match" if checked else "skipped")
|
|
375
|
+
out["ok"] = sc and not mismatch
|
|
376
|
+
return out
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Self-test: golden receipt-digest vectors + the planted-drop fixture.
|
|
2
|
+
|
|
3
|
+
python -m arcaeon_compact.selftest
|
|
4
|
+
|
|
5
|
+
Ships in the package rather than living in CI, so a stranger runs it on THEIR
|
|
6
|
+
machine and trusts their own output, not ours:
|
|
7
|
+
|
|
8
|
+
1. Golden vectors. The receipt digest is only a reliable signal if every
|
|
9
|
+
environment computes the same one from the same content. These constants
|
|
10
|
+
were frozen when the v1 schema was frozen (0.1.0, 2026-08-14). If your
|
|
11
|
+
Python, your platform, or a future release computes anything else, this
|
|
12
|
+
command fails loudly — do not trust receipts it produces.
|
|
13
|
+
|
|
14
|
+
2. The planted drop. The whole product claim is "a compactor cannot claim
|
|
15
|
+
nothing was dropped while dropping something." So the load-bearing test
|
|
16
|
+
plants exactly that lie — record_kept(everything) while the shipped
|
|
17
|
+
survivor is missing an item — and verify_receipt MUST catch it. The honest
|
|
18
|
+
receipt over the same drop MUST pass. Both run here, every time.
|
|
19
|
+
|
|
20
|
+
Exit code 0 = every check passed.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from . import CompactionReceipt, verify_receipt, _digest_item
|
|
29
|
+
|
|
30
|
+
# The fixture content. Covers all three item types (str, dict, bytes) so the
|
|
31
|
+
# frozen type rule (str -> UTF-8 raw-bytes, dict -> json-c14n, bytes -> raw)
|
|
32
|
+
# is enforced, not just documented.
|
|
33
|
+
PRE = ["the quick brown fox",
|
|
34
|
+
{"role": "user", "text": "hello"},
|
|
35
|
+
b"\x00\x01binary"]
|
|
36
|
+
POST = ["the quick brown fox", "summary: user said hello"]
|
|
37
|
+
|
|
38
|
+
# Frozen at schema freeze (arcaeon-compact:receipt:v1, 0.1.0, 2026-08-14).
|
|
39
|
+
# These never change. A v2 schema gets NEW vectors alongside these.
|
|
40
|
+
GOLDEN_RECEIPT_DIGEST = ("sha256:json-c14n:v1:"
|
|
41
|
+
"9a0f4bb37b78f8433f52ae14603d20f1fc2413265e434beb9ad15613fc5947a4")
|
|
42
|
+
GOLDEN_PRE_DIGEST = ("sha256:json-c14n:v1:"
|
|
43
|
+
"0fa1adc5dd156a66d78b1e32a52632ce8b98fabc7dfe9d25813375e75ddc82a7")
|
|
44
|
+
GOLDEN_ITEMS = [
|
|
45
|
+
("str item (raw-bytes of UTF-8)", PRE[0], "sha256:raw-bytes:v1:"
|
|
46
|
+
"9ecb36561341d18eb65484e833efea61edc74b84cf5e6ae1b81c63533e25fc8f"),
|
|
47
|
+
("dict item (json-c14n)", PRE[1], "sha256:json-c14n:v1:"
|
|
48
|
+
"09a32d6caf7737200a34ee38c65c82446831be896aa5d5aa6b858729ee13beb1"),
|
|
49
|
+
("bytes item (raw-bytes)", PRE[2], "sha256:raw-bytes:v1:"
|
|
50
|
+
"f8c1ccc7df7243da4740c5b7875f88b70e3f935d99c7b37a5f7239d152d994e6"),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run() -> int:
|
|
55
|
+
failures = 0
|
|
56
|
+
|
|
57
|
+
print("== golden vectors (schema enforcement) ==")
|
|
58
|
+
for name, item, want in GOLDEN_ITEMS:
|
|
59
|
+
got = _digest_item(item)[0]
|
|
60
|
+
ok = got == want
|
|
61
|
+
failures += 0 if ok else 1
|
|
62
|
+
print(f" {'PASS' if ok else 'FAIL'} {name}")
|
|
63
|
+
if not ok:
|
|
64
|
+
print(f" want {want}\n got {got}")
|
|
65
|
+
|
|
66
|
+
with tempfile.TemporaryDirectory() as td:
|
|
67
|
+
led = Path(td) / "receipts.jsonl"
|
|
68
|
+
r = CompactionReceipt.open(PRE)
|
|
69
|
+
r.record_kept(POST)
|
|
70
|
+
row = r.seal(led, compactor="golden-fixture", method="test:v1")
|
|
71
|
+
for name, got, want in [
|
|
72
|
+
("pre.digest vector", row["pre"]["digest"], GOLDEN_PRE_DIGEST),
|
|
73
|
+
("receipt_digest vector", row["receipt_digest"],
|
|
74
|
+
GOLDEN_RECEIPT_DIGEST)]:
|
|
75
|
+
ok = got == want
|
|
76
|
+
failures += 0 if ok else 1
|
|
77
|
+
print(f" {'PASS' if ok else 'FAIL'} {name}")
|
|
78
|
+
if not ok:
|
|
79
|
+
print(f" want {want}\n got {got}")
|
|
80
|
+
|
|
81
|
+
print("== the planted drop (the load-bearing lie) ==")
|
|
82
|
+
# Honest branch: the same drop, receipted truthfully, must pass fully.
|
|
83
|
+
v = verify_receipt(row, PRE, POST)
|
|
84
|
+
ok = v["ok"] and v["content"] == "match"
|
|
85
|
+
failures += 0 if ok else 1
|
|
86
|
+
print(f" {'PASS' if ok else 'FAIL'} honest receipt -> "
|
|
87
|
+
f"ok={v['ok']} content={v['content']!r} (must be True/'match')")
|
|
88
|
+
|
|
89
|
+
# The lie: claim the survivor is EVERYTHING (nothing dropped), while
|
|
90
|
+
# the actually-shipped survivor is missing the binary item.
|
|
91
|
+
liar = CompactionReceipt.open(PRE)
|
|
92
|
+
liar.record_kept(PRE) # "nothing was dropped"
|
|
93
|
+
lied_row = liar.seal(led, compactor="liar", method="test:v1")
|
|
94
|
+
shipped = PRE[:2] # ...something was.
|
|
95
|
+
v = verify_receipt(lied_row, PRE, shipped)
|
|
96
|
+
ok = (not v["ok"]) and v["content"] == "mismatch"
|
|
97
|
+
failures += 0 if ok else 1
|
|
98
|
+
print(f" {'PASS' if ok else 'FAIL'} planted drop -> "
|
|
99
|
+
f"ok={v['ok']} content={v['content']!r} (must be False/'mismatch')")
|
|
100
|
+
|
|
101
|
+
# And the lying row alone (no content) must still be SELF-consistent:
|
|
102
|
+
# the receipt binds the claim; only content exposes the lie. Stating
|
|
103
|
+
# this precisely is part of the product, so it is asserted, not hoped.
|
|
104
|
+
v = verify_receipt(lied_row)
|
|
105
|
+
ok = v["ok"] and v["content"] == "skipped"
|
|
106
|
+
failures += 0 if ok else 1
|
|
107
|
+
print(f" {'PASS' if ok else 'FAIL'} lie w/o content -> "
|
|
108
|
+
f"ok={v['ok']} (self-consistency cannot expose it: by design, "
|
|
109
|
+
f"and said out loud)")
|
|
110
|
+
|
|
111
|
+
print(f"\n{'ALL CHECKS PASSED' if failures == 0 else f'{failures} CHECK(S) FAILED'}")
|
|
112
|
+
return 0 if failures == 0 else 1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
sys.exit(run())
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: arcaeon-compact
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tamper-evident receipts for context compaction. Prove what your summarizer dropped.
|
|
5
|
+
Project-URL: Homepage, https://arcaeon.io
|
|
6
|
+
Author: Arcaeon
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: agents,ai,audit,compaction,context,memory,provenance,summarization,tamper-evident
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Requires-Dist: arcaeon-ledger>=0.5.1
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# arcaeon-compact
|
|
19
|
+
|
|
20
|
+
**Your summarizer says it kept what mattered. `arcaeon-compact` makes it prove
|
|
21
|
+
what it dropped.**
|
|
22
|
+
|
|
23
|
+
Every agent compacts context — summarizes the conversation, prunes memory,
|
|
24
|
+
truncates history — and today that step is a black hole: content goes in,
|
|
25
|
+
a survivor comes out, and nothing attests to the difference. A
|
|
26
|
+
**CompactionReceipt** is a tamper-evident record of exactly that difference:
|
|
27
|
+
digests of the full pre-compaction content and the post-compaction survivor,
|
|
28
|
+
plus a drop-manifest naming every dropped item by digest, chained onto an
|
|
29
|
+
[`arcaeon-ledger`](https://pypi.org/project/arcaeon-ledger/) log so the receipt
|
|
30
|
+
itself can't be quietly edited later.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
pip install arcaeon-compact # brings arcaeon-ledger; nothing else
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from arcaeon_compact import CompactionReceipt, verify_receipt
|
|
38
|
+
|
|
39
|
+
pre = conversation_turns # list of str | bytes | dict
|
|
40
|
+
post = summarize(pre) # your compactor, any compactor
|
|
41
|
+
|
|
42
|
+
receipt = CompactionReceipt.open(pre) # digest every item + the whole
|
|
43
|
+
receipt.record_kept(post) # dropped = pre minus kept, by digest
|
|
44
|
+
row = receipt.seal("receipts.jsonl", # one chained row on an arcaeon-ledger
|
|
45
|
+
compactor="summarizer-v2", method="llm-summary")
|
|
46
|
+
|
|
47
|
+
verify_receipt(row) # self-consistency, always
|
|
48
|
+
verify_receipt(row, pre, post) # recompute from content and compare
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Three calls in, one call out. That's the whole API.
|
|
52
|
+
|
|
53
|
+
## What it does NOT prove — read this before the features
|
|
54
|
+
|
|
55
|
+
Being precise about the boundary is the product, not a disclaimer.
|
|
56
|
+
|
|
57
|
+
**1. It proves WHAT was dropped, never that dropping was wise.** The receipt
|
|
58
|
+
has no opinion on salience. A compactor that keeps the small talk and drops the
|
|
59
|
+
wire-transfer instructions gets a perfectly valid receipt saying exactly that.
|
|
60
|
+
The receipt turns "trust me, nothing important was lost" into a checkable
|
|
61
|
+
claim — judging the loss is still your job.
|
|
62
|
+
|
|
63
|
+
**2. It proves the compactor's claim about its inputs, not that the inputs
|
|
64
|
+
were complete.** The gateway problem: if content was withheld before `open()`
|
|
65
|
+
ever saw it, the receipt faithfully notarizes the partial view. The receipt
|
|
66
|
+
binds what crossed the gate, not what existed behind it. Closing that gap means
|
|
67
|
+
receipting the *producing* side too (the ledger's artefact-binding is the tool
|
|
68
|
+
for that) — a layer you add, stated here, not implied away.
|
|
69
|
+
|
|
70
|
+
**3. Digests only means dropped content is NOT recoverable from the receipt.**
|
|
71
|
+
This is privacy by construction — a receipt can be published, shipped to an
|
|
72
|
+
auditor, or held by a counterparty without leaking one byte of the
|
|
73
|
+
conversation. It is also a real limitation, stated plainly: you can prove an
|
|
74
|
+
item you still *hold* was dropped (hash it, find it in the manifest); you
|
|
75
|
+
cannot resurrect an item you lost. The receipt is a witness, not a backup.
|
|
76
|
+
|
|
77
|
+
And, inherited honestly from the chain underneath: the ledger proves the
|
|
78
|
+
receipt row wasn't altered *in place* — for truncation-resistance you pin the
|
|
79
|
+
ledger head externally, exactly as `arcaeon-ledger`'s docs describe.
|
|
80
|
+
|
|
81
|
+
## The receipt, anatomically
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"schema": "arcaeon-compact:receipt:v1",
|
|
86
|
+
"pre": {"count": 4, "bytes": 121, "digest": "sha256:json-c14n:v1:0fa1…"},
|
|
87
|
+
"post": {"count": 2, "bytes": 49, "digest": "sha256:json-c14n:v1:2623…"},
|
|
88
|
+
"dropped": {"count": 3, "bytes": 84, "items": ["sha256:raw-bytes:v1:9ecb…", "…"]},
|
|
89
|
+
"introduced": {"count": 1},
|
|
90
|
+
"compactor": "summarizer-v2",
|
|
91
|
+
"method": "llm-summary",
|
|
92
|
+
"opened_at": "2026-08-14T17:40:00Z",
|
|
93
|
+
"receipt_digest": "sha256:json-c14n:v1:9a0f…",
|
|
94
|
+
"ts": "…", "chain": "…"
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- Every digest is **self-describing** (`sha256:<recipe>:<ver>:<hex>`), carrying
|
|
99
|
+
its own pinned canonicalization recipe from `arcaeon-ledger` — never a bare
|
|
100
|
+
hex hash a stranger can't reproduce. The per-item type rule is frozen into
|
|
101
|
+
the v1 schema: `bytes` are hashed raw, `str` as UTF-8, everything else
|
|
102
|
+
through the pinned `json-c14n` recipe.
|
|
103
|
+
- The **whole-content digests** are a digest over the ordered per-item digests,
|
|
104
|
+
so they recompute from content alone — content is never stored.
|
|
105
|
+
- **`introduced`** counts survivor items that were never in the pre-content:
|
|
106
|
+
the summary text itself, typically. It closes the arithmetic
|
|
107
|
+
(`pre = kept + dropped`, `post = kept + introduced`) so the counts can't be
|
|
108
|
+
fudged independently.
|
|
109
|
+
- **`receipt_digest`** covers the deterministic core, so an edited row is
|
|
110
|
+
caught even when it's been copied *out* of its ledger. Inside the ledger,
|
|
111
|
+
the chain catches the same edit; this check travels with the row.
|
|
112
|
+
- Duplicates are counted as a **multiset**: keeping one copy of a twice-seen
|
|
113
|
+
item still drops the other, and the manifest says so.
|
|
114
|
+
|
|
115
|
+
## Verification, honestly scoped
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
verify_receipt(row)
|
|
119
|
+
# {"ok": True, "self_consistent": True, "content": "skipped", "notes": []}
|
|
120
|
+
|
|
121
|
+
verify_receipt(row, pre_content=pre, post_content=post)
|
|
122
|
+
# {"ok": True, "self_consistent": True, "content": "match", "notes": []}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Self-consistency (no content needed) checks the schema, every digest's shape,
|
|
126
|
+
the count arithmetic, and the `receipt_digest`. With content provided, every
|
|
127
|
+
digest is recomputed and compared — and with **both** sides provided, the drop
|
|
128
|
+
set itself is recomputed (pre minus post, by digest) and held against the
|
|
129
|
+
manifest. That last comparison is the point of the whole library:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
# the compactor claims nothing was dropped...
|
|
133
|
+
receipt = CompactionReceipt.open(pre)
|
|
134
|
+
receipt.record_kept(pre) # "kept everything"
|
|
135
|
+
row = receipt.seal("receipts.jsonl", compactor="liar", method="llm-summary")
|
|
136
|
+
|
|
137
|
+
# ...but what it actually shipped is missing an item
|
|
138
|
+
verify_receipt(row, pre, shipped_post)
|
|
139
|
+
# {"ok": False, "content": "mismatch",
|
|
140
|
+
# "notes": ["post.digest: recomputed … != claimed …",
|
|
141
|
+
# "drop-manifest disagrees with content: recomputed 1 dropped
|
|
142
|
+
# item(s), manifest claims 0"]}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Stated with equal honesty: the lying row **alone** is self-consistent — a
|
|
146
|
+
receipt binds the claim; only content exposes the lie. The self-test asserts
|
|
147
|
+
this out loud rather than letting you discover it. What the receipt guarantees
|
|
148
|
+
is that the claim is *frozen*: the compactor committed to specific digests at
|
|
149
|
+
seal time, and anyone who ever holds the content can check that commitment.
|
|
150
|
+
|
|
151
|
+
`record_dropped(items)` is optional — the drop set is inferred as
|
|
152
|
+
pre-minus-kept by digest. If you do record it explicitly, `seal()` reconciles
|
|
153
|
+
the claim against the inference and **refuses to seal a receipt that disagrees
|
|
154
|
+
with itself**, so an internally inconsistent receipt never exists to be
|
|
155
|
+
believed.
|
|
156
|
+
|
|
157
|
+
## Prove your own install
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
python -m arcaeon_compact.selftest
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Golden digest vectors frozen at the v1 schema freeze — if your environment
|
|
164
|
+
computes anything else, the command fails loudly and you should not trust
|
|
165
|
+
receipts it produces — plus the planted-drop fixture above, run for real in a
|
|
166
|
+
temp dir every time. The negative test ships in the package because "trust our
|
|
167
|
+
CI" is exactly the posture this library exists to replace.
|
|
168
|
+
|
|
169
|
+
## Built on arcaeon-ledger
|
|
170
|
+
|
|
171
|
+
Receipts append to a standard [`arcaeon-ledger`](https://pypi.org/project/arcaeon-ledger/)
|
|
172
|
+
chain, so everything the ledger gives you composes for free: `verify` names
|
|
173
|
+
the exact tampered line, `head()` pins close the truncation gap, and a
|
|
174
|
+
`WitnessStore` gives you an external record a re-minter cannot advance. A
|
|
175
|
+
receipts file is just a ledger file; the receipt is just a row with a schema.
|
|
176
|
+
|
|
177
|
+
## Status
|
|
178
|
+
|
|
179
|
+
v0.1.0. Library + packaged self-test, tested against the planted-drop lie,
|
|
180
|
+
in-row edits, multiset duplicates, byte totals, and lifecycle misuse
|
|
181
|
+
(`test_compact.py`). Extracted from the context-compaction flow of a
|
|
182
|
+
long-running agent that wanted receipts for its own memory pruning before
|
|
183
|
+
selling them to anyone else.
|
|
184
|
+
|
|
185
|
+
MIT.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
arcaeon_compact/__init__.py,sha256=ub0K8LtKHswfo4onI0RMNl1Qa3cseALuz2pZhzvdOMw,16837
|
|
2
|
+
arcaeon_compact/selftest.py,sha256=Po6RIgL0mqYsEH8z3YI-rB6oCmNILTRiFOBHqhUIh2A,5184
|
|
3
|
+
arcaeon_compact-0.1.0.dist-info/METADATA,sha256=pQzqUrGAwg2mkLAOKcq-t5bqVDf1S7A1Om6mgJM1CLE,8478
|
|
4
|
+
arcaeon_compact-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
5
|
+
arcaeon_compact-0.1.0.dist-info/licenses/LICENSE,sha256=X891VWc5gAsgzxxpN3G8LJ2dvQDKca9SUivd0m2IftQ,1064
|
|
6
|
+
arcaeon_compact-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arcaeon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|