quantum-cortex 1.0.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.
Files changed (41) hide show
  1. cortex_c2/__init__.py +161 -0
  2. cortex_c2/consolidation.py +146 -0
  3. cortex_c2/router_ablation.py +254 -0
  4. cortex_c2/router_v2.py +109 -0
  5. cortex_c2b/__init__.py +724 -0
  6. cortex_c2b/companion_demo.py +203 -0
  7. cortex_c2b/crypto.py +102 -0
  8. cortex_c2b/external_arm.py +591 -0
  9. cortex_c2b/hm_lm.py +983 -0
  10. cortex_c2b/hm_protocol.py +308 -0
  11. cortex_c2b/lifecycle.py +454 -0
  12. cortex_c2b/lm_bridge.py +338 -0
  13. cortex_c2b/organ_use.py +254 -0
  14. cortex_c2b/read_path.py +218 -0
  15. cortex_c2b/reconstruction_probe.py +471 -0
  16. cortex_c2b/write_path.py +176 -0
  17. cortex_data/__init__.py +439 -0
  18. cortex_data/relay.py +174 -0
  19. cortex_eval/__init__.py +20 -0
  20. cortex_eval/ablation_mqar.py +187 -0
  21. cortex_eval/confirmation_run.py +133 -0
  22. cortex_eval/domain_map.py +127 -0
  23. cortex_eval/external_baseline.py +148 -0
  24. cortex_eval/ingest_confirmation.py +194 -0
  25. cortex_eval/mqar.py +122 -0
  26. cortex_eval/multiseed.py +148 -0
  27. cortex_eval/recurrent_base.py +197 -0
  28. cortex_eval/recurrent_ladder.py +159 -0
  29. cortex_eval/resumable_ladder.py +231 -0
  30. cortex_eval/resumable_rb.py +207 -0
  31. cortex_eval/run_mqar.py +127 -0
  32. cortex_eval/serial_position.py +116 -0
  33. quantum_cortex-1.0.0.dist-info/METADATA +209 -0
  34. quantum_cortex-1.0.0.dist-info/RECORD +41 -0
  35. quantum_cortex-1.0.0.dist-info/WHEEL +5 -0
  36. quantum_cortex-1.0.0.dist-info/licenses/AUTHORS.md +30 -0
  37. quantum_cortex-1.0.0.dist-info/licenses/LICENSE +73 -0
  38. quantum_cortex-1.0.0.dist-info/licenses/LICENSE-DOCS.md +34 -0
  39. quantum_cortex-1.0.0.dist-info/licenses/NOTICE +50 -0
  40. quantum_cortex-1.0.0.dist-info/top_level.txt +5 -0
  41. train.py +885 -0
cortex_c2b/__init__.py ADDED
@@ -0,0 +1,724 @@
1
+ """cortex_c2b — the persistent tier (ADR-007), Slice A: the store.
2
+
3
+ The journal is the episodic organ: weights = semantic and procedural; journal =
4
+ episodic. It lives OUTSIDE the weights and survives the session boundary — the
5
+ thing the H.M. protocol measures. Slice A ships the store and its three laws:
6
+
7
+ Size law — an entry is ≤ 1 KB: (cue, pointer, salience, schema_id, timestamps,
8
+ state). Content is NEVER duplicated into the journal: the payload
9
+ lives in a content-addressed payload store (cortex_data hashing
10
+ reused) and the entry carries only its hash.
11
+ Read law — retrieval is never a scan. Reads go through an index keyed by cue
12
+ (a sub-linear structure); an O(N) scan over entries is prohibited
13
+ BY TEST (test_no_scan fails if a read touches every entry).
14
+ Lifecycle — every entry carries its RES-17 (type,state) cell:
15
+ live → consolidated → demoted → evicted. Slice A stores and
16
+ transitions the state; the scheduling (RES-9) is Slice E.
17
+
18
+ Deliberately NOT in Slice A: the write path (DG separation, CA1 surprise, the
19
+ two-stage noise gate — Slice B), the router path (Slice C), the H.M. protocol
20
+ (Slice D), consolidation/eviction scheduling (Slice E). The store is the
21
+ substrate they all need; it is built first so each can be tested against it.
22
+
23
+ Persistence is a property PER COMPONENT (ADR-007 Decision 8, founder,
24
+ 2026-09-12): "survives the session boundary" means after a full process stop
25
+ and relaunch, not within one process. Each component declares its lifecycle
26
+ (`lifecycle_declaration()`):
27
+ structure -- the append-only log (`journal.jsonl`): entries, states,
28
+ saliences, links, references, phases;
29
+ bytes -- the content-addressed payload directory next to the log
30
+ (`journal.payloads/<pointer>`), one file per payload,
31
+ released at the last reference;
32
+ index -- recomputed at replay from the log with a declared seed
33
+ (`JournalPath(journal, seed)`), aliases from the links;
34
+ associative memory -- CA3 (write path) and the persistent memory (lifecycle)
35
+ rebuilt from their own events in the log today; checkpointed
36
+ with the weights once the C2 layer takes over.
37
+ At rest, log lines and payload files are encrypted per scope with the hub's
38
+ QJE1 framing (AES-256-GCM; `cortex_c2b.crypto`) when a key is given; the key is
39
+ an explicit argument, never read implicitly (key management deferred, hub 010).
40
+
41
+ Written from scratch (our filon). ANN over cues is Slice C's job; Slice A uses
42
+ an exact keyed index so the read law is enforced without a heavy dependency.
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import errno
47
+ import json
48
+ import shutil
49
+ import time
50
+ from collections import deque
51
+ from dataclasses import dataclass, field, asdict
52
+ from pathlib import Path
53
+
54
+ import numpy as np
55
+
56
+ from cortex_data import _hash_obj # content hashing reused (ADR-005)
57
+ from cortex_c2b.crypto import (seal, open_sealed, is_sealed, sealed_overhead, seal_line, open_line,
58
+ MAGIC)
59
+
60
+ # --- lifecycle states (RES-17 cell) -----------------------------------------
61
+ STATE_LIVE = "live"
62
+ STATE_CONSOLIDATED = "consolidated"
63
+ STATE_DEMOTED = "demoted"
64
+ STATE_EVICTED = "evicted"
65
+ _TRANSITIONS = {
66
+ STATE_LIVE: {STATE_CONSOLIDATED, STATE_EVICTED},
67
+ STATE_CONSOLIDATED: {STATE_DEMOTED, STATE_EVICTED},
68
+ STATE_DEMOTED: {STATE_EVICTED},
69
+ STATE_EVICTED: set(),
70
+ }
71
+
72
+ SCHEMA_SUMMARY = "summary" # the schema_id of a demotion summary (Slice E)
73
+
74
+ # --- storage policy (ADR-007 Decision 9) --------------------------------------
75
+ POLICY_STOP = "stop" # a storage failure raises StorageExhausted; the caller decides (measurement runs)
76
+ POLICY_READ_ONLY = "read_only" # writes are refused (JournalReadOnly), reads continue, the state stays durable
77
+ POLICY_MEMORY = "memory" # writes continue in a bounded spill buffer, flushed when the disk returns; declared not durable
78
+ POLICIES = (POLICY_STOP, POLICY_READ_ONLY, POLICY_MEMORY)
79
+ MODE_DURABLE, MODE_READ_ONLY, MODE_MEMORY = "durable", "read_only", "memory"
80
+
81
+
82
+ class StorageDegraded(OSError):
83
+ """Base: the journal could not persist. Never silent (invariant 1)."""
84
+
85
+
86
+ class StorageExhausted(StorageDegraded):
87
+ """Policy `stop`: the journal is consistent, nothing half written, the caller decides."""
88
+
89
+
90
+ class JournalReadOnly(StorageDegraded):
91
+ """Policy `read_only` (or a full spill buffer): this write is refused; reads continue."""
92
+
93
+ ENTRY_MAX_BYTES = 1024 # the size law
94
+ CUE_DIM = 64 # fixed-size cue (matryoshka-truncatable later)
95
+
96
+
97
+ def content_hash(payload: bytes) -> str:
98
+ """Pointer = content hash of the payload (never the payload itself)."""
99
+ import hashlib
100
+ return hashlib.sha256(payload).hexdigest()[:16]
101
+
102
+
103
+ @dataclass
104
+ class Entry:
105
+ """One journal entry — the ≤1 KB retention-law object."""
106
+ cue: list[float] # fixed-size address embedding (CUE_DIM floats)
107
+ pointer: str # content hash of the payload (in the payload store)
108
+ salience: float
109
+ schema_id: str # span-contract tag (RES-8)
110
+ t_written: float
111
+ t_last_read: float
112
+ state: str = STATE_LIVE
113
+ entry_id: str = ""
114
+
115
+ def to_json(self) -> str:
116
+ return json.dumps(asdict(self), separators=(",", ":"))
117
+
118
+ def size_bytes(self) -> int:
119
+ return len(self.to_json().encode("utf-8"))
120
+
121
+
122
+ class PayloadStore:
123
+ """Content-addressed payload storage. Same bytes → same hash → stored once.
124
+ The journal never holds content; it holds pointers here.
125
+
126
+ `directory` (optional): one file per payload, named by its pointer, next to
127
+ the journal log -- the bytes' own lifecycle (Decision 8). With a `key` each
128
+ file is sealed (QJE1 binary framing, the pointer as associated data, so a
129
+ file cannot be swapped under another pointer). Without a directory the
130
+ store is memory only -- and says so in `lifecycle_declaration()`."""
131
+
132
+ def __init__(self, directory: str | Path | None = None, key: bytes | None = None):
133
+ self._blobs: dict[str, bytes] = {} # plaintext cache
134
+ self._sizes: dict[str, int] = {} # pointer -> plaintext size, known without reading
135
+ self._pending: set[str] = set() # registered in memory, not yet on disk (memory mode)
136
+ self._dir = Path(directory) if directory else None
137
+ self._key = key
138
+ if self._dir:
139
+ self._dir.mkdir(parents=True, exist_ok=True) # may raise: the journal applies its policy
140
+ for f in self._dir.iterdir():
141
+ if f.is_file() and len(f.name) == 16:
142
+ with open(f, "rb") as fh:
143
+ head = fh.read(len(MAGIC))
144
+ self._sizes[f.name] = f.stat().st_size - (sealed_overhead() if head == MAGIC else 0)
145
+
146
+ def put(self, payload: bytes, durable: bool = True) -> str:
147
+ """Durable first: the file reaches the disk before the pointer is registered,
148
+ so a failure leaves nothing half registered (Decision 9, invariant 4).
149
+ `durable=False` registers in memory only and marks the pointer pending
150
+ (memory mode); `flush_pending()` writes it later."""
151
+ h = content_hash(payload)
152
+ if h in self._sizes:
153
+ self._blobs.setdefault(h, payload)
154
+ return h
155
+ if self._dir and durable:
156
+ self._write_file(h, payload) # may raise OSError
157
+ elif self._dir:
158
+ self._pending.add(h)
159
+ self._sizes[h] = len(payload)
160
+ self._blobs[h] = payload
161
+ return h
162
+
163
+ def _write_file(self, h: str, payload: bytes) -> None:
164
+ blob = seal(self._key, payload, h.encode()) if self._key else payload
165
+ tmp = self._dir / (h + ".tmp")
166
+ try:
167
+ tmp.write_bytes(blob); tmp.replace(self._dir / h) # never a half-written file
168
+ except OSError:
169
+ tmp.unlink(missing_ok=True); raise
170
+
171
+ def flush_pending(self) -> int:
172
+ """Write every pending payload (memory mode recovery). Raises on the first failure."""
173
+ n = 0
174
+ for h in sorted(self._pending):
175
+ self._write_file(h, self._blobs[h]); self._pending.discard(h); n += 1
176
+ return n
177
+
178
+ @property
179
+ def pending(self) -> int:
180
+ return len(self._pending)
181
+
182
+ def get(self, pointer: str) -> bytes:
183
+ if pointer in self._blobs:
184
+ return self._blobs[pointer]
185
+ if pointer not in self._sizes or not self._dir:
186
+ raise KeyError(pointer)
187
+ blob = (self._dir / pointer).read_bytes()
188
+ if is_sealed(blob):
189
+ if self._key is None:
190
+ raise ValueError(f"payload {pointer} is sealed and no key was given")
191
+ payload = open_sealed(self._key, blob, pointer.encode()) # InvalidTag on tampering / wrong key
192
+ else:
193
+ payload = blob # legacy clear file, coexists
194
+ if content_hash(payload) != pointer:
195
+ raise ValueError(f"integrity: payload {pointer} does not match its pointer")
196
+ self._blobs[pointer] = payload
197
+ return payload
198
+
199
+ def release(self, pointer: str) -> bool:
200
+ """Drop the bytes behind a pointer. Called by the journal only when the
201
+ last non-evicted reference is gone (Slice E, reference counting)."""
202
+ existed = pointer in self._sizes
203
+ self._blobs.pop(pointer, None); self._sizes.pop(pointer, None); self._pending.discard(pointer)
204
+ if self._dir:
205
+ try:
206
+ (self._dir / pointer).unlink(missing_ok=True)
207
+ except OSError:
208
+ pass # a release that cannot delete is retried at the next open
209
+ return existed
210
+
211
+ def __contains__(self, pointer: str) -> bool:
212
+ return pointer in self._sizes
213
+
214
+ def __len__(self):
215
+ return len(self._sizes)
216
+
217
+ def total_bytes(self) -> int:
218
+ return sum(self._sizes.values())
219
+
220
+ @property
221
+ def persistent(self) -> bool:
222
+ return self._dir is not None
223
+
224
+ @property
225
+ def sealed(self) -> bool:
226
+ return self._key is not None
227
+
228
+
229
+ class Journal:
230
+ """Append-only journal with a keyed index. Reads never scan.
231
+
232
+ `path` (optional): an append-only JSONL file; every write appends one line,
233
+ nothing is rewritten (the ledger discipline). State transitions are appended
234
+ as events, so the file is a full provenance log (RES-17: the object's history).
235
+
236
+ Decision 8 -- sealed by default: an on-disk journal needs a `key` (32 bytes);
237
+ `plaintext=True` declares a test scope explicitly; a memory-only journal
238
+ (no path) needs neither. Opening without either fails loudly.
239
+
240
+ Decision 9 -- durable first, and a declared storage policy per scope:
241
+ every mutation reaches the log (and the payload its file) BEFORE memory
242
+ changes, so a failure leaves no half state. When the disk fails (no space,
243
+ no permission, or free space under the declared floor), `policy` decides:
244
+ `stop` raises StorageExhausted; `read_only` refuses the write and keeps
245
+ reading; `memory` keeps writing into a bounded spill buffer, declared not
246
+ durable, flushed with a `storage_restored` event when the disk returns.
247
+ The policy, the floor, the sealing and the spill bound are the scope's
248
+ configuration (`scope_config_hash()`).
249
+ """
250
+
251
+ def __init__(self, path: str | Path | None = None, cue_dim: int = CUE_DIM,
252
+ key: bytes | None = None, payload_dir: str | Path | None = None,
253
+ plaintext: bool = False, policy: str = POLICY_READ_ONLY,
254
+ disk_free_floor_bytes: int | None = None, spill_max_events: int = 10_000,
255
+ disk_check_every: int = 16):
256
+ if policy not in POLICIES:
257
+ raise ValueError(f"policy must be one of {POLICIES}, got {policy!r}")
258
+ self.cue_dim = cue_dim
259
+ self._path = Path(path) if path else None
260
+ if self._path is not None and key is None and not plaintext:
261
+ raise ValueError("an on-disk journal is sealed by default (ADR-007 D8): pass key=<32 bytes>, "
262
+ "or plaintext=True to declare a test scope explicitly")
263
+ self._key = key
264
+ self.plaintext = bool(plaintext) if self._path is not None else False
265
+ self.policy = policy
266
+ self.disk_free_floor_bytes = disk_free_floor_bytes
267
+ self.spill_max_events = int(spill_max_events)
268
+ self._disk_check_every = max(1, int(disk_check_every))
269
+ self._persist_count = 0
270
+ # ---- storage state (Decision 9) ----
271
+ self.mode = MODE_DURABLE if self._path is None else MODE_DURABLE
272
+ self.degraded_reason: str | None = None
273
+ self.spill: deque[str] = deque() # encoded lines not yet on disk (memory mode); a deque: O(1) at both ends
274
+ self.refused_writes = 0 # read_only mode: mutations refused since the failure
275
+ self.gap_since: float | None = None # logical time of the first failure of the current gap
276
+ self._last_now: float | None = None
277
+ self.restorations: list[dict] = [] # storage_restored records (this object and the log)
278
+ # ---- the store ----
279
+ if payload_dir is None and self._path is not None:
280
+ payload_dir = self._path.with_name(self._path.stem + ".payloads") # the bytes, next to the log
281
+ self._entries: dict[str, Entry] = {} # entry_id -> Entry
282
+ self._by_cue_key: dict[str, list[str]] = {} # cue key -> entry ids (keyed index)
283
+ self._reads_touched = 0 # instrumentation for the no-scan test
284
+ # ---- what the associative memories and the scheduler rebuild from (Decision 8) ----
285
+ self.ca3_order: list[tuple[str, dict]] = [] # (entry_id, meta) in write-path admission order
286
+ self.consolidated_order: list[str] = [] # entry ids in consolidation order
287
+ self.sentinels_recorded: list[tuple[int, str]] = [] # (fact index, entry_id)
288
+ self.phase_count = 0
289
+ self.refused_count = 0
290
+ self.writes_since_last_phase: list[str] = []
291
+ # ---- Slice E state, every item rebuilt from the log on replay ----
292
+ self._refs: dict[str, set[str]] = {} # pointer -> non-evicted entry ids referencing it
293
+ self.summary_sources: dict[str, list[str]] = {} # summary id -> its K source ids
294
+ self.summary_of: dict[str, str] = {} # source id -> summary id
295
+ self.summary_level: dict[str, int] = {} # summary id -> hierarchy level (1 = of entries)
296
+ self.contracted: set[str] = set() # ids credited under a contract (never decayed)
297
+ # ---- open: the payload directory, the log, the writability probe ----
298
+ try:
299
+ self.payloads = PayloadStore(payload_dir, key)
300
+ except OSError as e:
301
+ self.payloads = PayloadStore(None, key) # memory only until the disk answers
302
+ self._on_open_failure(e)
303
+ else:
304
+ if self._path is not None:
305
+ try:
306
+ self._probe_writable()
307
+ except OSError as e:
308
+ self._on_open_failure(e) # the files stay readable
309
+ if self._path and self._path.exists():
310
+ self._replay()
311
+ self._collect_orphans()
312
+
313
+ # ---- scope configuration -----------------------------------------------------
314
+ def scope_config_hash(self) -> str:
315
+ """Whatever changes the journal's behaviour on disk is in the hash (Decision 9)."""
316
+ return _hash_obj({"policy": self.policy, "sealed": self._key is not None, "plaintext": self.plaintext,
317
+ "disk_free_floor_bytes": self.disk_free_floor_bytes,
318
+ "spill_max_events": self.spill_max_events, "cue_dim": self.cue_dim,
319
+ "on_disk": self._path is not None})
320
+
321
+ # ---- keyed index: an exact bucket over a quantised cue (sub-linear reads) ----
322
+ @staticmethod
323
+ def _cue_key(cue) -> str:
324
+ c = np.asarray(cue, dtype=np.float32)
325
+ # coarse quantisation of the cue -> a bucket key; exact, cheap, sub-linear.
326
+ # Slice C replaces this with ANN over cues; the contract (never scan) holds.
327
+ return _hash_obj(np.round(c[:8], 1).tolist())
328
+
329
+ # ---- write ----------------------------------------------------------------
330
+ def write(self, cue, payload: bytes, salience: float, schema_id: str,
331
+ now: float | None = None, ca3: dict | None = None) -> Entry:
332
+ """Durable first: payload file, then the log line, then memory. `ca3` (the
333
+ write path's separation meta) rides on the write event so CA3 is rebuilt
334
+ from one line, never from two that could be split by a failure."""
335
+ cue = np.asarray(cue, dtype=np.float32)
336
+ assert cue.shape == (self.cue_dim,), f"cue must be ({self.cue_dim},)"
337
+ now = time.time() if now is None else now
338
+ self._last_now = now
339
+ if self.mode == MODE_READ_ONLY:
340
+ self._retry_or_refuse() # the disk may be back
341
+ pointer = self._put_payload(payload) # content stored once, by hash
342
+ entry = Entry(cue=[round(float(x), 4) for x in cue], pointer=pointer,
343
+ salience=float(salience), schema_id=schema_id,
344
+ t_written=now, t_last_read=now, state=STATE_LIVE)
345
+ entry.entry_id = _hash_obj({"p": pointer, "t": now, "s": schema_id})
346
+ sz = entry.size_bytes()
347
+ if sz > ENTRY_MAX_BYTES:
348
+ raise ValueError(f"entry violates the size law: {sz} B > {ENTRY_MAX_BYTES} B")
349
+ ev = {"ev": "write", "entry": asdict(entry)}
350
+ if ca3 is not None:
351
+ ev["ca3"] = dict(ca3)
352
+ self._persist(ev) # may raise; memory untouched then
353
+ self._entries[entry.entry_id] = entry
354
+ self._by_cue_key.setdefault(self._cue_key(cue), []).append(entry.entry_id)
355
+ self._refs.setdefault(pointer, set()).add(entry.entry_id)
356
+ self.writes_since_last_phase.append(entry.entry_id)
357
+ if ca3 is not None:
358
+ self.ca3_order.append((entry.entry_id, dict(ca3)))
359
+ return entry
360
+
361
+ def _put_payload(self, payload: bytes) -> str:
362
+ if self.mode == MODE_MEMORY:
363
+ return self.payloads.put(payload, durable=False)
364
+ try:
365
+ self._check_disk_floor()
366
+ return self.payloads.put(payload, durable=True)
367
+ except OSError as e:
368
+ self._on_storage_failure(e) # raises for stop / read_only
369
+ return self.payloads.put(payload, durable=False) # memory policy: pending
370
+
371
+ # ---- read (never a scan) --------------------------------------------------
372
+ def read_by_cue(self, cue, now: float | None = None) -> list[Entry]:
373
+ """Retrieve entries whose cue falls in the same bucket. Touches only that
374
+ bucket -- never the whole journal. `_reads_touched` counts entries visited
375
+ so the no-scan test can assert sub-linear behaviour."""
376
+ key = self._cue_key(np.asarray(cue, dtype=np.float32))
377
+ ids = self._by_cue_key.get(key, [])
378
+ now = time.time() if now is None else now
379
+ out = []
380
+ for i in ids:
381
+ e = self._entries[i]
382
+ self._reads_touched += 1
383
+ if e.state != STATE_EVICTED:
384
+ e.t_last_read = now
385
+ out.append(e)
386
+ return out
387
+
388
+ def get(self, entry_id: str) -> Entry:
389
+ self._reads_touched += 1
390
+ return self._entries[entry_id]
391
+
392
+ # ---- lifecycle (RES-17 transitions) --------------------------------------
393
+ def transition(self, entry_id: str, new_state: str) -> Entry:
394
+ e = self._entries[entry_id]
395
+ if new_state not in _TRANSITIONS[e.state]:
396
+ raise ValueError(f"illegal transition {e.state} -> {new_state}")
397
+ self._persist({"ev": "transition", "entry_id": entry_id, "to": new_state})
398
+ e.state = new_state
399
+ if new_state == STATE_CONSOLIDATED:
400
+ self.consolidated_order.append(entry_id)
401
+ if new_state == STATE_EVICTED:
402
+ self._release_refs(entry_id)
403
+ for src in self.summary_sources.get(entry_id, []): # cascade: a summary takes its
404
+ if self._entries[src].state != STATE_EVICTED: # sources with it -- a demoted
405
+ self.transition(src, STATE_EVICTED) # source is never left unreachable
406
+ return e
407
+
408
+ # ---- Slice E primitives: each one an event, so the log replays them ------
409
+ def set_salience(self, entry_id: str, value: float, why: str = "credit",
410
+ now: float | None = None) -> Entry:
411
+ """Salience is lifecycle state: every change is an event (invariant 1).
412
+ `why == "contract"` marks the entry as contracted: scheduled decay never
413
+ touches it."""
414
+ e = self._entries[entry_id]
415
+ value = float(min(1.0, max(0.0, value)))
416
+ self._persist({"ev": "salience", "entry_id": entry_id, "to": value, "why": why})
417
+ e.salience = value
418
+ if why == "contract":
419
+ self.contracted.add(entry_id)
420
+ return e
421
+
422
+ def decay(self, factor: float, states=(STATE_CONSOLIDATED, STATE_DEMOTED)) -> int:
423
+ """Scheduled decay (ADR-007 D1) as ONE event; contracted entries exempt."""
424
+ self._persist({"ev": "decay", "factor": float(factor), "states": list(states)})
425
+ return self._apply_decay(factor, tuple(states))
426
+
427
+ def _apply_decay(self, factor: float, states) -> int:
428
+ n = 0
429
+ for eid, e in self._entries.items():
430
+ if e.state in states and eid not in self.contracted:
431
+ e.salience = round(e.salience * factor, 6); n += 1
432
+ return n
433
+
434
+ def demote(self, summary_id: str, source_ids: list[str], level: int = 1) -> None:
435
+ """Record the link summary <- sources (ADR-007 D4: keeping pointers). The
436
+ summary holds a reference to every source payload, so those bytes live
437
+ as long as the summary does. The state transitions of the sources are
438
+ separate `transition` events (invariant 1)."""
439
+ if summary_id not in self._entries:
440
+ raise KeyError(summary_id)
441
+ for sid in source_ids:
442
+ if sid in self.summary_of:
443
+ raise ValueError(f"{sid} already belongs to summary {self.summary_of[sid]}")
444
+ self._persist({"ev": "demote", "summary": summary_id, "sources": list(source_ids),
445
+ "level": int(level)})
446
+ self._link_summary(summary_id, list(source_ids), level)
447
+
448
+ def _link_summary(self, summary_id: str, source_ids: list[str], level: int) -> None:
449
+ self.summary_sources[summary_id] = source_ids
450
+ self.summary_level[summary_id] = level
451
+ for sid in source_ids:
452
+ self.summary_of[sid] = summary_id
453
+ self._refs.setdefault(self._entries[sid].pointer, set()).add(summary_id)
454
+
455
+ def _release_refs(self, entry_id: str) -> None:
456
+ """Reference counting on identity (the pointer), never on similarity.
457
+ A payload is released when no non-evicted entry (or summary) points to it."""
458
+ e = self._entries[entry_id]
459
+ ptrs = [e.pointer] + [self._entries[s].pointer for s in self.summary_sources.get(entry_id, [])]
460
+ for p in ptrs:
461
+ holders = self._refs.get(p)
462
+ if holders is None:
463
+ continue
464
+ holders.discard(entry_id)
465
+ if not holders:
466
+ del self._refs[p]
467
+ self.payloads.release(p)
468
+
469
+ def phase_event(self, record: dict) -> None:
470
+ """Telemetry snapshot of a lifecycle phase, appended to the log. Replay only
471
+ counts it (phases, refusals, writes since the last phase): it describes
472
+ state, it does not change it."""
473
+ self._persist({"ev": "phase", **record})
474
+ self._count_phase(record)
475
+
476
+ def _count_phase(self, record: dict) -> None:
477
+ self.phase_count += 1
478
+ if record.get("refused"):
479
+ self.refused_count += 1
480
+ self.writes_since_last_phase = []
481
+
482
+ def mark_ca3(self, entry_id: str, meta: dict) -> None:
483
+ """Kept for logs written before 2026-09-12; the write path now puts the
484
+ meta on the write event itself (one line, never split by a failure)."""
485
+ self._persist({"ev": "ca3", "entry_id": entry_id, **meta})
486
+ self.ca3_order.append((entry_id, dict(meta)))
487
+
488
+ def mark_sentinel(self, index: int, entry_id: str) -> None:
489
+ self._persist({"ev": "sentinel", "i": int(index), "entry_id": entry_id})
490
+ self.sentinels_recorded.append((int(index), entry_id))
491
+
492
+ def references(self, pointer: str) -> set[str]:
493
+ return set(self._refs.get(pointer, ()))
494
+
495
+ def regulated_bytes(self) -> int:
496
+ """The bytes the budget regulates: non-evicted entries + the payloads alive.
497
+ Evicted entries are tombstones in memory; the append-only log is
498
+ provenance and grows by design (its retention is a separate decision)."""
499
+ return (sum(e.size_bytes() for e in self._entries.values() if e.state != STATE_EVICTED)
500
+ + self.payloads.total_bytes())
501
+
502
+ # ---- introspection (RES-17 snapshot) -------------------------------------
503
+ def snapshot(self) -> dict:
504
+ counts = {s: 0 for s in _TRANSITIONS}
505
+ for e in self._entries.values():
506
+ counts[e.state] += 1
507
+ return {"entries": len(self._entries), "payloads": len(self.payloads),
508
+ "payload_bytes": self.payloads.total_bytes(),
509
+ "journal_bytes": sum(e.size_bytes() for e in self._entries.values()),
510
+ "regulated_bytes": self.regulated_bytes(),
511
+ "summaries": len(self.summary_sources), "contracted": len(self.contracted),
512
+ "storage_mode": self.mode, "unpersisted_events": len(self.spill),
513
+ **counts}
514
+
515
+ def __len__(self):
516
+ return len(self._entries)
517
+
518
+ # ---- storage: durable first, then the declared policy (Decision 9) ----------
519
+ def disk_free(self) -> int | None:
520
+ if self._path is None:
521
+ return None
522
+ try:
523
+ return int(shutil.disk_usage(self._path.parent).free)
524
+ except OSError:
525
+ return None
526
+
527
+ def _check_disk_floor(self) -> None:
528
+ """The proactive wall: under the declared floor, act as if the disk were full."""
529
+ if self.disk_free_floor_bytes is None or self._path is None:
530
+ return
531
+ self._persist_count += 1
532
+ if self._persist_count % self._disk_check_every != 1 and self._disk_check_every > 1:
533
+ return
534
+ free = self.disk_free()
535
+ if free is not None and free < self.disk_free_floor_bytes:
536
+ raise OSError(errno.ENOSPC, f"free space {free} B under the declared floor {self.disk_free_floor_bytes} B")
537
+
538
+ def _probe_writable(self) -> None:
539
+ self._path.parent.mkdir(parents=True, exist_ok=True)
540
+ with open(self._path, "a", encoding="utf-8"):
541
+ pass
542
+
543
+ def _encode(self, ev: dict) -> str:
544
+ line = json.dumps(ev, separators=(",", ":"))
545
+ return seal_line(self._key, line) if self._key is not None else line # QJE1, per line, as on the hub
546
+
547
+ def _write_line(self, line: str) -> None:
548
+ with open(self._path, "a", encoding="utf-8") as fh:
549
+ fh.write(line + "\n")
550
+
551
+ def _persist(self, ev: dict) -> None:
552
+ """The line reaches the disk before memory changes. On failure the policy
553
+ applies: stop raises, read_only refuses, memory spills (bounded)."""
554
+ if self._path is None:
555
+ return
556
+ line = self._encode(ev)
557
+ if self.mode == MODE_MEMORY:
558
+ try:
559
+ self._flush_spill() # the disk may be back
560
+ except OSError:
561
+ self._spill(line)
562
+ return
563
+ try:
564
+ self._check_disk_floor()
565
+ self._write_line(line)
566
+ except OSError as e:
567
+ self._on_storage_failure(e) # raises for stop / read_only
568
+ self._spill(line) # memory policy
569
+ return
570
+ if self.mode == MODE_READ_ONLY:
571
+ self._restored(MODE_READ_ONLY)
572
+
573
+ def _on_storage_failure(self, e: OSError) -> None:
574
+ reason = f"{e.strerror or e}"
575
+ if self.policy == POLICY_STOP:
576
+ raise StorageExhausted(e.errno or errno.EIO, f"storage failed, policy stop: {reason}") from e
577
+ if self.gap_since is None:
578
+ self.gap_since = self._last_now
579
+ self.degraded_reason = reason
580
+ if self.policy == POLICY_READ_ONLY:
581
+ self.mode = MODE_READ_ONLY
582
+ self.refused_writes += 1
583
+ raise JournalReadOnly(e.errno or errno.EIO, f"journal read only ({reason}); this write is refused") from e
584
+ self.mode = MODE_MEMORY # policy memory: the caller spills
585
+
586
+ def _spill(self, line: str) -> None:
587
+ if len(self.spill) >= self.spill_max_events:
588
+ self.mode = MODE_READ_ONLY
589
+ self.refused_writes += 1
590
+ raise JournalReadOnly(errno.ENOSPC, f"spill buffer full ({self.spill_max_events} events); this write is refused")
591
+ self.spill.append(line)
592
+
593
+ def _retry_or_refuse(self) -> None:
594
+ """read_only mode, a new write: probe the disk once; if it answers, resume."""
595
+ try:
596
+ self._check_disk_floor()
597
+ self._probe_writable()
598
+ except OSError as e:
599
+ self.refused_writes += 1
600
+ raise JournalReadOnly(e.errno or errno.EIO, f"journal read only ({self.degraded_reason}); this write is refused") from e
601
+
602
+ def _flush_spill(self) -> None:
603
+ """Memory mode recovery: pending payloads, then the spilled lines in order, then
604
+ the restoration record. Raises on the first failure, nothing lost."""
605
+ self._check_disk_floor()
606
+ self.payloads.flush_pending()
607
+ while self.spill:
608
+ self._write_line(self.spill[0]); self.spill.popleft() # O(1); a list.pop(0) here was O(n) per event
609
+ self._restored(MODE_MEMORY)
610
+
611
+ def _restored(self, from_mode: str) -> None:
612
+ rec = {"ev": "storage_restored", "from_mode": from_mode, "reason": self.degraded_reason,
613
+ "since": self.gap_since, "until": self._last_now,
614
+ "refused_writes": self.refused_writes if from_mode == MODE_READ_ONLY else 0}
615
+ self.mode = MODE_DURABLE
616
+ self.degraded_reason, self.gap_since, self.refused_writes = None, None, 0
617
+ try:
618
+ self._write_line(self._encode(rec))
619
+ except OSError:
620
+ pass # the disk went again; the next write will see it
621
+ self.restorations.append(rec)
622
+
623
+ def _on_open_failure(self, e: OSError) -> None:
624
+ reason = f"{e.strerror or e}"
625
+ if self.policy == POLICY_STOP:
626
+ raise StorageExhausted(e.errno or errno.EACCES, f"cannot open the journal on disk, policy stop: {reason}") from e
627
+ self.degraded_reason = reason
628
+ self.gap_since = None
629
+ if self.policy == POLICY_READ_ONLY:
630
+ if not (self._path.exists() and os_readable(self._path)):
631
+ raise JournalReadOnly(e.errno or errno.EACCES, f"cannot open the journal and nothing to read: {reason}") from e
632
+ self.mode = MODE_READ_ONLY
633
+ else:
634
+ self.mode = MODE_MEMORY
635
+
636
+ def _collect_orphans(self) -> None:
637
+ """A payload file no entry references (a write interrupted between the file
638
+ and the line) is garbage, released at open."""
639
+ for p in [p for p in list(self.payloads._sizes) if p not in self._refs]:
640
+ self.payloads.release(p)
641
+
642
+ # ---- append-only persistence ---------------------------------------------
643
+ def _replay(self):
644
+ """Rebuild in-memory state from the append-only log: entries, states,
645
+ saliences, summary links, payload references, the CA3 and sentinel
646
+ events, the phase counters. Payload BYTES are not in the log (the size
647
+ law keeps content out of it): they live in the payload directory next
648
+ to it, opened by `PayloadStore` (Decision 8). Before 2026-09-12 this
649
+ docstring claimed the payloads were re-registered from the log; they
650
+ were not -- recorded here so the correction stays visible."""
651
+ for raw in self._path.read_text(encoding="utf-8").splitlines():
652
+ if not raw.strip():
653
+ continue
654
+ ev = json.loads(open_line(self._key, raw)) # a clear line passes, a sealed one needs the key
655
+ if ev["ev"] == "write":
656
+ d = ev["entry"]
657
+ e = Entry(**d)
658
+ self._entries[e.entry_id] = e
659
+ self._by_cue_key.setdefault(self._cue_key(e.cue), []).append(e.entry_id)
660
+ self._refs.setdefault(e.pointer, set()).add(e.entry_id)
661
+ self.writes_since_last_phase.append(e.entry_id)
662
+ if "ca3" in ev:
663
+ self.ca3_order.append((e.entry_id, dict(ev["ca3"])))
664
+ elif ev["ev"] == "transition":
665
+ self._entries[ev["entry_id"]].state = ev["to"]
666
+ if ev["to"] == STATE_CONSOLIDATED:
667
+ self.consolidated_order.append(ev["entry_id"])
668
+ if ev["to"] == STATE_EVICTED:
669
+ self._release_refs(ev["entry_id"])
670
+ elif ev["ev"] == "salience":
671
+ self._entries[ev["entry_id"]].salience = float(ev["to"])
672
+ if ev.get("why") == "contract":
673
+ self.contracted.add(ev["entry_id"])
674
+ elif ev["ev"] == "decay":
675
+ self._apply_decay(float(ev["factor"]), tuple(ev["states"]))
676
+ elif ev["ev"] == "demote":
677
+ self._link_summary(ev["summary"], list(ev["sources"]), int(ev.get("level", 1)))
678
+ elif ev["ev"] == "phase":
679
+ self._count_phase(ev) # telemetry: counted, not applied
680
+ elif ev["ev"] == "ca3":
681
+ self.ca3_order.append((ev["entry_id"], {k: v for k, v in ev.items() if k not in ("ev", "entry_id")}))
682
+ elif ev["ev"] == "sentinel":
683
+ self.sentinels_recorded.append((int(ev["i"]), ev["entry_id"]))
684
+ elif ev["ev"] == "storage_restored":
685
+ self.restorations.append({k: v for k, v in ev.items() if k != "ev"})
686
+
687
+
688
+ def os_readable(path: Path) -> bool:
689
+ try:
690
+ with open(path, "rb"):
691
+ return True
692
+ except OSError:
693
+ return False
694
+
695
+
696
+ def lifecycle_declaration(journal: "Journal | None" = None) -> dict:
697
+ """Decision 8: persistence is a property per component; each declares its own.
698
+ With a journal, the declaration reflects that journal's actual setup."""
699
+ has = journal is not None # never `if journal`: an empty journal is falsy
700
+ on_disk = has and journal._path is not None
701
+ sealed = has and journal._key is not None
702
+ durable = on_disk and journal.mode != MODE_MEMORY # a memory-mode journal never claims (invariant 1)
703
+ storage = {"mode": journal.mode if has else None, "policy": journal.policy if has else None,
704
+ "degraded_reason": journal.degraded_reason if has else None,
705
+ "unpersisted_events": len(journal.spill) if has else 0,
706
+ "plaintext_scope": has and journal.plaintext}
707
+ return {
708
+ "storage": storage,
709
+ "structure": {"what": "entries, states, saliences, links, references, phases",
710
+ "lifecycle": "append-only log, replayed at open",
711
+ "survives_restart": durable, "encrypted_at_rest": sealed},
712
+ "bytes": {"what": "the payloads (content), one file per pointer",
713
+ "lifecycle": "content-addressed directory next to the log, released at the last reference",
714
+ "survives_restart": has and journal.payloads.persistent and durable,
715
+ "encrypted_at_rest": has and journal.payloads.sealed},
716
+ "index": {"what": "the cue index (buckets) and the demotion aliases",
717
+ "lifecycle": "recomputed at open from the log, with a declared seed",
718
+ "survives_restart": durable, "encrypted_at_rest": None},
719
+ "associative_memory": {"what": "CA3 codes (write path) and the persistent memory (lifecycle)",
720
+ "lifecycle": "rebuilt at open from their own events (ca3, consolidation order); "
721
+ "checkpointed with the weights once the C2 layer takes over -- the "
722
+ "language model arm re declares this component (ADR-007 D8, named boundary)",
723
+ "survives_restart": durable, "encrypted_at_rest": None},
724
+ }