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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.