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
train.py ADDED
@@ -0,0 +1,885 @@
1
+ """quantum-cortex — N1 training scaffold (single-file trainer, feature F2).
2
+
3
+ Vanilla GPT-2-style control model (the ONLY legitimate baseline per hub decision 019),
4
+ byte-level tokenizer with reserved oracle token ids (register NOW-1),
5
+ device-agnostic per ADR-002 (CUDA bf16/fp16, CPU fp32 for smoke tests only),
6
+ checkpoint/resume (EuroHPC plan §4), and first-class metrics per ADR-001:
7
+ every completed run appends one schema-v1 record to metrics/runs.jsonl and
8
+ regenerates metrics/LATEST.md. A run without its committed record does not exist.
9
+
10
+ Usage:
11
+ python train.py --config configs/smoke_cpu.json
12
+ python train.py --config configs/kaggle_t4.json [--resume]
13
+ python train.py --regen-latest
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import dataclasses
20
+ import hashlib
21
+ import json
22
+ import math
23
+ import os
24
+ import platform
25
+ import subprocess
26
+ import time
27
+ from dataclasses import dataclass
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+
31
+ import numpy as np
32
+ import torch
33
+ import torch.nn as nn
34
+ import torch.nn.functional as F
35
+
36
+ REPO_ROOT = Path(__file__).resolve().parent
37
+ SCHEMA_PATH = REPO_ROOT / "metrics" / "schema" / "run-v1.schema.json"
38
+ LEDGER_PATH = REPO_ROOT / "metrics" / "runs.jsonl"
39
+ LATEST_PATH = REPO_ROOT / "metrics" / "LATEST.md"
40
+
41
+ # ----------------------------------------------------------------------------- config
42
+
43
+
44
+ @dataclass
45
+ class Config:
46
+ # run
47
+ kind: str = "control" # control | ablation | combo
48
+ component_under_test: str | None = None # null for control; C1..C4 otherwise
49
+ notes: str | None = None
50
+ provider: str = "other" # kaggle | colab | local | eurohpc | trc | rented | other
51
+ # model (vanilla GPT-2-style control: LayerNorm, GELU, learned pos emb, tied embeddings)
52
+ n_layer: int = 8
53
+ n_head: int = 8
54
+ n_embd: int = 512
55
+ block_size: int = 1024
56
+ vocab_bytes: int = 256
57
+ reserved_oracle_tokens: int = 8 # NOW-1: token space reserved from day one
58
+ # data
59
+ data_mode: str = "synthetic" # synthetic | bin
60
+ bin_path: str = "data/train_bytes.bin"
61
+ dataset_id: str = "synthetic-v0"
62
+ data_slice: str = "seeded pattern, 5% noise"
63
+ val_fraction: float = 0.01
64
+ # training
65
+ max_tokens: int = 200_000
66
+ batch_size: int = 8
67
+ lr: float = 1e-3
68
+ min_lr_ratio: float = 0.1
69
+ warmup_steps: int = 20
70
+ weight_decay: float = 0.1
71
+ grad_clip: float = 1.0
72
+ seed: int = 1337
73
+ optimizer: str = "adamw" # F1 (NorMuon-class) arrives only via its own ablation
74
+ c2_variant: str = "none" # none | hopfield | delta (ADR-006: C2 associative layer, default off)
75
+ attn_local_conv: bool = False # the ladder's L3 convolution (depthwise, kernel 3, causal) on the attention's
76
+ # normalised input in every block: the control plus a local convolution, a declared
77
+ # reference (register Rev60, 2026-09-17); default off, transparent to the hash
78
+ attn_local_conv_init: str = "random" # "random" (the ladder's reference, row 32) | "identity": the centre tap at one,
79
+ # the others and the bias at zero, so a fine tune starts as its parent exactly
80
+ # (v8, the reader with a local convolution); transparent to the hash at its default
81
+ journal_familiarity_mark: bool = False # RES-23 (v9): every read window line carries the organ's own similarity for that
82
+ # line, `A(0.97):<payload>`, in training and at evaluation; default off, transparent
83
+ c2_mem_slots: int = 64 # hopfield: number of stored key/value patterns
84
+ c2_heads: int = 4 # delta: number of recurrent heads
85
+ c2_delta_l2_keys: bool = True # delta: L2-normalise q,k before the write (KDA-style stability)
86
+ c2_delta_state_clip: float = 0.0 # delta: optional |S| clip (0 = off); belt-and-braces guard
87
+ # journal in the decode loop (ADR-008), default off -- the model starts as N1 and learns to read
88
+ journal: str = "none" # none | read
89
+ journal_block: int = 5 # the block that carries the reader (one hippocampal port)
90
+ journal_k: int = 4 # episodes retrieved per read
91
+ journal_read_bytes: int = 256 # byte budget of the read window
92
+ journal_cue_dim: int = 64 # the organ's cue dimension (CUE_DIM)
93
+ journal_heads: int = 4 # heads of the reader's cross attention
94
+ journal_route: str = "pinned_on" # pinned_on | pinned_off | router (the H.M. arms pin; the router is measured apart)
95
+ journal_asym_weight: float = 2.0 # loss weight on the decision token of an abstention target (answering instead of abstaining costs more)
96
+ journal_neg_frac: float = 0.25 # share of curriculum examples on never planted entities (target <UNKNOWN>); v4 lever (run ce008745375a)
97
+ journal_paired_negatives: bool = False # v5 lever (run 6f10e8cbfebf): negatives are minimal pairs, the same planted fact with its
98
+ # episode withheld from the window, so the decision must depend on the read, not on a class prior
99
+ journal_paired_keep_shape: bool = False # v6 lever (run db028e6a4262, INVALID): the pair keeps k lines (k + 1 retrieved, the own
100
+ # dropped); as run in v5 the withheld window had k - 1 lines, a shape cue the protocol never shows
101
+ journal_match_head: bool = False # v7 lever, RES-21 (run 81cb13a684aa, INVALID: six data recipes left the discrimination at 20 to 25
102
+ # points): a head supervised on "the queried entity is in the window", coupled to the decision token
103
+ journal_match_loss_weight: float = 1.0 # weight of the head's own loss (binary cross entropy) in the curriculum steps
104
+ journal_match_coupling: float = 2.0 # logit bias on the decision token: + c * m on <CITE>, - c * m on <UNKNOWN>
105
+ journal_forced_frac: float = 0.5 # bootstrap: share of missed retrievals where the right episode is forced into the window, decays to 0
106
+ journal_curriculum_ratio: float = 0.5 # share of training steps spent on organ use examples (the rest: ordinary language modelling)
107
+ journal_contrastive_weight: float = 1.0
108
+ journal_pool_facts: int = 200 # planted facts per curriculum epoch
109
+ journal_pool_refresh: int = 100 # steps between two replantings under the current encoder
110
+ journal_train_seed: int = 100_000 # the training facts' generator seed (disjoint from the frozen protocol's 0 and 10_000)
111
+ journal_fresh_pool: bool = False # v2 lever (run dc34fcf000aa, INVALID): a NEW set of facts at every replant, so the only way
112
+ # down the loss is to read the window -- a fixed pool let the model memorise entity -> attribute
113
+ journal_lm_window: bool = False # v2 lever: language modelling steps also carry a retrieved window (gate on), so the model
114
+ # learns to ignore an irrelevant read on ordinary text (the skill arm measures exactly that)
115
+ journal_path: str | None = None # the sealed on-disk journal of the run (key from QUANTUM_CORTEX_JOURNAL_KEY); None = a memory scope
116
+ journal_hm_arm: bool = True # run the frozen protocol's LM arm at the end of the run
117
+ journal_hm_facts: int = 200 # the protocol's sizes (200 / 50 are the frozen defaults; smaller only for a CPU smoke)
118
+ journal_hm_negctrl: int = 50
119
+ parent_run_id: str | None = None # the checkpoint this run resumes from (a fine tune is a new run)
120
+ parent_ckpt: str | None = None # path of that checkpoint
121
+ # circuit breaker (ADR-006 D7): aggressive early-abort, thresholds declared pre-run
122
+ cb_enabled: bool = True # NaN/Inf abort is always on; divergence check needs a baseline
123
+ cb_divergence_mult: float = 2.0 # abort if loss > mult × baseline-at-step over a window
124
+ cb_window: int = 3 # consecutive divergent evals before abort
125
+ cb_baseline_losses: str | None = None # optional path to control's per-eval losses (jsonl or json list)
126
+ # io
127
+ out_dir: str = "runs/smoke"
128
+ log_every: int = 10
129
+ eval_every: int = 50
130
+ eval_batches: int = 8
131
+ ckpt_every: int = 200
132
+
133
+ @property
134
+ def vocab_size(self) -> int:
135
+ return self.vocab_bytes + self.reserved_oracle_tokens
136
+
137
+
138
+ CONFIG_HASH_EXCLUDE = {"provider", "notes", "out_dir", "log_every", "parent_ckpt"}
139
+
140
+
141
+ def load_config(path: str) -> Config:
142
+ cfg = Config()
143
+ overrides = json.loads(Path(path).read_text())
144
+ valid = {f.name for f in dataclasses.fields(Config)}
145
+ for k, v in overrides.items():
146
+ if k not in valid:
147
+ raise SystemExit(f"unknown config key: {k!r} (typo safety — honest data)")
148
+ setattr(cfg, k, v)
149
+ return cfg
150
+
151
+
152
+ # Levers added after runs were recorded: absent from the hash while at their default, so a
153
+ # configuration written before the lever existed keeps its hash (run dc34fcf000aa stays
154
+ # 6785ba1f8e213dce, 5f0a6d3ff4e8 stays b9e10b3e0a9e5938); moved, the lever enters the hash
155
+ # like any other field.
156
+ HASH_TRANSPARENT_AT_DEFAULT = {"journal_fresh_pool": False, "journal_lm_window": False, "journal_neg_frac": 0.25,
157
+ "journal_paired_negatives": False, "journal_paired_keep_shape": False,
158
+ "journal_match_head": False, "journal_match_loss_weight": 1.0, "journal_match_coupling": 2.0,
159
+ "attn_local_conv": False, "attn_local_conv_init": "random", "journal_familiarity_mark": False}
160
+
161
+
162
+ def config_hash(cfg: Config) -> str:
163
+ d = {k: v for k, v in dataclasses.asdict(cfg).items()
164
+ if k not in CONFIG_HASH_EXCLUDE and not (k in HASH_TRANSPARENT_AT_DEFAULT and v == HASH_TRANSPARENT_AT_DEFAULT[k])}
165
+ return hashlib.sha256(json.dumps(d, sort_keys=True).encode()).hexdigest()[:16]
166
+
167
+
168
+ # ----------------------------------------------------------------------------- data
169
+
170
+
171
+ def make_synthetic(cfg: Config, n_tokens: int) -> np.ndarray:
172
+ """Deterministic learnable byte stream: repeated seeded pattern + 5% noise."""
173
+ rng = np.random.default_rng(cfg.seed)
174
+ pattern = rng.integers(0, cfg.vocab_bytes, size=4096, dtype=np.uint16)
175
+ reps = int(np.ceil(n_tokens / pattern.size)) + 1
176
+ arr = np.tile(pattern, reps)[: n_tokens]
177
+ noise = rng.random(arr.size) < 0.05
178
+ arr[noise] = rng.integers(0, cfg.vocab_bytes, size=int(noise.sum()), dtype=np.uint16)
179
+ return arr
180
+
181
+
182
+ def load_data(cfg: Config) -> tuple[np.ndarray, np.ndarray]:
183
+ if cfg.data_mode == "synthetic":
184
+ total = int(cfg.max_tokens * 1.05) + cfg.block_size + 1
185
+ arr = make_synthetic(cfg, total)
186
+ elif cfg.data_mode == "bin":
187
+ p = (REPO_ROOT / cfg.bin_path).resolve()
188
+ if not p.exists():
189
+ raise SystemExit(f"bin dataset missing: {p} (build it — see notebooks/n1_kaggle.ipynb)")
190
+ arr = np.memmap(p, dtype=np.uint16, mode="r")
191
+ else:
192
+ raise SystemExit(f"unknown data_mode: {cfg.data_mode}")
193
+ split = int(len(arr) * (1.0 - cfg.val_fraction))
194
+ return arr[:split], arr[split:]
195
+
196
+
197
+ def get_batch(arr: np.ndarray, cfg: Config, gen: torch.Generator, device: torch.device):
198
+ hi = len(arr) - cfg.block_size - 1
199
+ ix = torch.randint(0, hi, (cfg.batch_size,), generator=gen)
200
+ x = torch.stack([torch.from_numpy(np.asarray(arr[i : i + cfg.block_size], dtype=np.int64)) for i in ix])
201
+ y = torch.stack([torch.from_numpy(np.asarray(arr[i + 1 : i + 1 + cfg.block_size], dtype=np.int64)) for i in ix])
202
+ return x.to(device, non_blocking=True), y.to(device, non_blocking=True)
203
+
204
+
205
+ # ----------------------------------------------------------------------------- model
206
+
207
+
208
+ class HopfieldMemory(nn.Module):
209
+ """Variant H (RES-1): modern-Hopfield content-addressable retrieval.
210
+ A learned bank of key/value patterns read by softmax attention (energy
211
+ descent). Zero-initialised output projection ⇒ at init this is a no-op,
212
+ so a fresh model with the flag on starts identical to control and learns
213
+ the memory from there. Third residual sub-block; default off (ADR-006).
214
+ """
215
+ def __init__(self, cfg: "Config"):
216
+ super().__init__()
217
+ self.ln = nn.LayerNorm(cfg.n_embd)
218
+ self.q = nn.Linear(cfg.n_embd, cfg.n_embd, bias=False)
219
+ self.keys = nn.Parameter(torch.randn(cfg.c2_mem_slots, cfg.n_embd) * 0.02)
220
+ self.vals = nn.Parameter(torch.randn(cfg.c2_mem_slots, cfg.n_embd) * 0.02)
221
+ self.out = nn.Linear(cfg.n_embd, cfg.n_embd, bias=False)
222
+ nn.init.zeros_(self.out.weight) # no-op at init
223
+ self.scale = cfg.n_embd ** -0.5
224
+
225
+ def forward(self, x):
226
+ h = self.ln(x)
227
+ q = self.q(h) # (b,t,c)
228
+ att = torch.softmax((q @ self.keys.t()) * self.scale, dim=-1) # (b,t,slots)
229
+ read = att @ self.vals # (b,t,c)
230
+ return self.out(read)
231
+
232
+
233
+ class DeltaMemory(nn.Module):
234
+ """Variant D (K1, KDA-class): a per-head recurrent state S updated by an
235
+ error-correcting rank-one write S += beta * k (v - S^T k)^T with a
236
+ per-channel forget gate. The Widrow-Hoff / delta-rule family. Chunk-free
237
+ sequential scan (correctness-first; a parallel scan is a later slice).
238
+ Zero-initialised output ⇒ no-op at init. Default off (ADR-006).
239
+ """
240
+ def __init__(self, cfg: "Config"):
241
+ super().__init__()
242
+ self.h = cfg.c2_heads
243
+ self.dk = cfg.n_embd // cfg.c2_heads
244
+ self.ln = nn.LayerNorm(cfg.n_embd)
245
+ self.to_qkv = nn.Linear(cfg.n_embd, 3 * cfg.n_embd, bias=False)
246
+ self.to_beta = nn.Linear(cfg.n_embd, cfg.c2_heads, bias=True)
247
+ self.to_gate = nn.Linear(cfg.n_embd, cfg.c2_heads * self.dk, bias=True)
248
+ self.out = nn.Linear(cfg.n_embd, cfg.n_embd, bias=False)
249
+ nn.init.zeros_(self.out.weight) # no-op at init
250
+ self.l2_keys = cfg.c2_delta_l2_keys
251
+ self.state_clip = cfg.c2_delta_state_clip
252
+
253
+ def forward(self, x):
254
+ b, t, c = x.shape
255
+ z = self.ln(x)
256
+ q, k, v = self.to_qkv(z).split(c, dim=2)
257
+ q = q.view(b, t, self.h, self.dk)
258
+ k = k.view(b, t, self.h, self.dk)
259
+ v = v.view(b, t, self.h, self.dk)
260
+ # KDA-style stability: L2-normalise q,k so the rank-one write stays bounded
261
+ # (unnormalised keys let |S| grow unboundedly across the scan → the v1 divergence).
262
+ if getattr(self, "l2_keys", True):
263
+ q = torch.nn.functional.normalize(q, dim=-1, eps=1e-6)
264
+ k = torch.nn.functional.normalize(k, dim=-1, eps=1e-6)
265
+ beta = torch.sigmoid(self.to_beta(z)).view(b, t, self.h, 1) # write rate
266
+ gate = torch.sigmoid(self.to_gate(z)).view(b, t, self.h, self.dk) # per-channel forget
267
+ S = torch.zeros(b, self.h, self.dk, self.dk, device=x.device, dtype=x.dtype)
268
+ clip = getattr(self, "state_clip", 0.0)
269
+ outs = []
270
+ for i in range(t):
271
+ ki = k[:, i] # (b,h,dk)
272
+ vi = v[:, i]
273
+ qi = q[:, i]
274
+ gi = gate[:, i]
275
+ bi = beta[:, i]
276
+ # channel-wise forget on the key dimension (columns of S)
277
+ S = S * gi.unsqueeze(2)
278
+ u = torch.einsum('bhij,bhi->bhj', S, ki) # read S^T k
279
+ err = vi - u # prediction error
280
+ S = S + bi.unsqueeze(-1) * torch.einsum('bhi,bhj->bhij', ki, err)
281
+ if clip > 0.0: # optional belt-and-braces guard
282
+ S = torch.clamp(S, -clip, clip)
283
+ oi = torch.einsum('bhij,bhi->bhj', S, qi) # read with query
284
+ outs.append(oi.reshape(b, c))
285
+ o = torch.stack(outs, dim=1) # (b,t,c)
286
+ return self.out(o)
287
+
288
+
289
+ class Block(nn.Module):
290
+ def __init__(self, cfg: Config, layer_idx: int = -1):
291
+ super().__init__()
292
+ self.ln1 = nn.LayerNorm(cfg.n_embd)
293
+ self.attn = nn.Linear(cfg.n_embd, 3 * cfg.n_embd)
294
+ self.proj = nn.Linear(cfg.n_embd, cfg.n_embd)
295
+ self.ln2 = nn.LayerNorm(cfg.n_embd)
296
+ self.mlp_up = nn.Linear(cfg.n_embd, 4 * cfg.n_embd)
297
+ self.mlp_down = nn.Linear(4 * cfg.n_embd, cfg.n_embd)
298
+ self.n_head = cfg.n_head
299
+ # the control plus a local convolution (Rev60): L3's depthwise causal conv, kernel 3, on ln1(x), the residual untouched
300
+ self.conv = (nn.Conv1d(cfg.n_embd, cfg.n_embd, kernel_size=3, padding=2, groups=cfg.n_embd)
301
+ if cfg.attn_local_conv else None)
302
+ if self.conv is not None and getattr(cfg, "attn_local_conv_init", "random") == "identity":
303
+ with torch.no_grad(): # v8: the model starts as its parent exactly
304
+ self.conv.weight.zero_(); self.conv.weight[:, 0, 2] = 1.0 # the centre tap (causal crop keeps taps 0..2)
305
+ self.conv.bias.zero_()
306
+ # C2 associative layer (ADR-006), default off — a third residual sub-block
307
+ if cfg.c2_variant == "hopfield":
308
+ self.c2 = HopfieldMemory(cfg)
309
+ elif cfg.c2_variant == "delta":
310
+ self.c2 = DeltaMemory(cfg)
311
+ else:
312
+ self.c2 = None
313
+ # the journal reader (ADR-008), one block only, default off
314
+ self.reader = None
315
+ if cfg.journal == "read" and layer_idx == cfg.journal_block:
316
+ from cortex_c2b.lm_bridge import JournalReader
317
+ self.reader = JournalReader(cfg.n_embd, cfg.journal_heads, cfg.journal_read_bytes)
318
+
319
+ def forward(self, x, window=None, window_mask=None, gate: float = 1.0):
320
+ b, t, c = x.shape
321
+ h = self.ln1(x)
322
+ if self.conv is not None: # Rev60: causal crop, as L3 does it
323
+ h = self.conv(h.transpose(1, 2))[:, :, :t].transpose(1, 2)
324
+ q, k, v = self.attn(h).split(c, dim=2)
325
+ q = q.view(b, t, self.n_head, c // self.n_head).transpose(1, 2)
326
+ k = k.view(b, t, self.n_head, c // self.n_head).transpose(1, 2)
327
+ v = v.view(b, t, self.n_head, c // self.n_head).transpose(1, 2)
328
+ a = F.scaled_dot_product_attention(q, k, v, is_causal=True)
329
+ a = a.transpose(1, 2).contiguous().view(b, t, c)
330
+ x = x + self.proj(a)
331
+ if self.c2 is not None:
332
+ x = x + self.c2(x) # associative memory residual (no-op at init)
333
+ self.last_read_mass = None
334
+ self.last_pre = self.last_attended = None
335
+ if self.reader is not None and window is not None and gate != 0.0:
336
+ read, mass = self.reader(x, window, window_mask)
337
+ self.last_pre, self.last_attended = x, self.reader.last_attended # RES-21: what the head compares
338
+ x = x + gate * read # the journal as content, on the router's gate (no-op at init)
339
+ self.last_read_mass = mass
340
+ x = x + self.mlp_down(F.gelu(self.mlp_up(self.ln2(x))))
341
+ return x
342
+
343
+
344
+ class VanillaGPT(nn.Module):
345
+ def __init__(self, cfg: Config):
346
+ super().__init__()
347
+ self.cfg = cfg
348
+ self.wte = nn.Embedding(cfg.vocab_size, cfg.n_embd)
349
+ self.wpe = nn.Embedding(cfg.block_size, cfg.n_embd)
350
+ self.blocks = nn.ModuleList([Block(cfg, i) for i in range(cfg.n_layer)])
351
+ self.ln_f = nn.LayerNorm(cfg.n_embd)
352
+ self.cue_encoder = None
353
+ if cfg.journal == "read":
354
+ from cortex_c2b.lm_bridge import CueEncoder
355
+ self.cue_encoder = CueEncoder(cfg.n_embd, cfg.journal_cue_dim)
356
+ self.match_head = None
357
+ if cfg.journal == "read" and cfg.journal_match_head:
358
+ from cortex_c2b.lm_bridge import MatchHead
359
+ self.match_head = MatchHead(cfg.n_embd)
360
+ self.head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False)
361
+ self.head.weight = self.wte.weight # tied embeddings (vanilla)
362
+ self.apply(self._init)
363
+ for name, p in self.named_parameters(): # scaled residual init
364
+ if name.endswith("proj.weight") or name.endswith("mlp_down.weight"):
365
+ nn.init.normal_(p, mean=0.0, std=0.02 / math.sqrt(2 * cfg.n_layer))
366
+ # ADR-006: restore the C2 no-op AFTER global init overwrote it — the
367
+ # associative residual must start at ~zero so the model begins as the
368
+ # transformer and learns the memory from there. (A test asserts this.)
369
+ for blk in self.blocks:
370
+ if getattr(blk, "c2", None) is not None:
371
+ nn.init.zeros_(blk.c2.out.weight)
372
+ if getattr(blk, "reader", None) is not None: # ADR-008: the reader starts as a no-op too
373
+ nn.init.zeros_(blk.reader.out.weight)
374
+ nn.init.zeros_(blk.reader.null)
375
+ if self.match_head is not None: # RES-21: the head starts silent (m = 0, no bias)
376
+ nn.init.zeros_(self.match_head.out.weight); nn.init.zeros_(self.match_head.out.bias)
377
+
378
+ @staticmethod
379
+ def _init(m):
380
+ if isinstance(m, nn.Linear):
381
+ nn.init.normal_(m.weight, mean=0.0, std=0.02)
382
+ if m.bias is not None:
383
+ nn.init.zeros_(m.bias)
384
+ elif isinstance(m, nn.Embedding):
385
+ nn.init.normal_(m.weight, mean=0.0, std=0.02)
386
+
387
+ def journal_hidden(self, idx):
388
+ """The residual stream at the journal block, before the reader: what the
389
+ cue encoder pools. Without a journal, the stream at the last block."""
390
+ b, t = idx.shape
391
+ pos = torch.arange(t, device=idx.device)
392
+ x = self.wte(idx) + self.wpe(pos)
393
+ stop = self.cfg.journal_block if self.cfg.journal == "read" else len(self.blocks)
394
+ for blk in self.blocks[:stop]:
395
+ x = blk(x)
396
+ return x
397
+
398
+ def read_mass(self):
399
+ """The reader's attention mass on the retrieved bytes at the last forward (invariant 8)."""
400
+ for blk in self.blocks:
401
+ if getattr(blk, "reader", None) is not None and blk.last_read_mass is not None:
402
+ return float(blk.last_read_mass.detach())
403
+ return None
404
+
405
+ def forward(self, idx, targets=None, window_tokens=None, window_mask=None, gate: float = 1.0,
406
+ loss_weights=None, decision_pos=None, match_targets=None):
407
+ """`window_tokens` (b, r): the read window's bytes, embedded with the model's
408
+ own `wte`; `gate`: the router's path 4 weight; `loss_weights` (b, t): per
409
+ token weights (the curriculum's answer-only, asymmetric loss). RES-21:
410
+ `decision_pos` (b,) the position predicting the decision token; the matching
411
+ head fires there only, biases the decision logits, and `match_targets` (b,)
412
+ adds its own loss."""
413
+ b, t = idx.shape
414
+ pos = torch.arange(t, device=idx.device)
415
+ x = self.wte(idx) + self.wpe(pos)
416
+ window = self.wte(window_tokens) if window_tokens is not None else None
417
+ for blk in self.blocks:
418
+ x = blk(x, window, window_mask, gate) if blk.reader is not None else blk(x)
419
+ logits = self.head(self.ln_f(x))
420
+ self.last_match_logit = self.last_match_loss = self.last_match_acc = None
421
+ if self.match_head is not None and decision_pos is not None and window is not None:
422
+ blk = self.blocks[self.cfg.journal_block]
423
+ if blk.last_attended is not None:
424
+ from cortex_c2b.lm_bridge import TOK_CITE, TOK_UNKNOWN
425
+ ar = torch.arange(b, device=idx.device)
426
+ m = self.match_head(blk.last_pre[ar, decision_pos], blk.last_attended[ar, decision_pos])
427
+ self.last_match_logit = m.detach()
428
+ if self.cfg.journal_match_coupling:
429
+ add = torch.zeros_like(logits)
430
+ add[ar, decision_pos, TOK_CITE] = self.cfg.journal_match_coupling * m.to(logits.dtype)
431
+ add[ar, decision_pos, TOK_UNKNOWN] = -self.cfg.journal_match_coupling * m.to(logits.dtype)
432
+ logits = logits + add
433
+ if match_targets is not None:
434
+ self.last_match_loss = F.binary_cross_entropy_with_logits(m.float(), match_targets.float())
435
+ self.last_match_acc = ((m > 0).float() == match_targets.float()).float().mean().detach()
436
+ loss = None
437
+ if targets is not None:
438
+ ce = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.reshape(-1), reduction="none")
439
+ if loss_weights is None:
440
+ loss = ce.mean()
441
+ else:
442
+ w = loss_weights.reshape(-1).to(ce.dtype)
443
+ loss = (ce * w).sum() / w.sum().clamp(min=1.0)
444
+ if self.last_match_loss is not None:
445
+ loss = loss + self.cfg.journal_match_loss_weight * self.last_match_loss
446
+ return logits, loss
447
+
448
+
449
+ def load_parent(model: "VanillaGPT", ckpt_path: str | Path, device) -> tuple[str, list[str]]:
450
+ """A fine tune starts from a parent checkpoint: every parent weight loads
451
+ unchanged (strict on the parent's keys); only the journal's own modules may
452
+ be new. Returns (parent run id, the keys that were initialised fresh)."""
453
+ ck = torch.load(ckpt_path, map_location=device, weights_only=False)
454
+ result = model.load_state_dict(ck["model"], strict=False)
455
+ if result.unexpected_keys:
456
+ raise SystemExit(f"parent checkpoint has keys this model lacks: {result.unexpected_keys[:5]}")
457
+ allowed = ("cue_encoder.", ".reader.", "match_head.")
458
+ if getattr(model.cfg, "attn_local_conv", False):
459
+ allowed = allowed + (".conv.",) # v8: the convolution is new next to the journal's modules
460
+ bad = [k for k in result.missing_keys if not any(a in k for a in allowed)]
461
+ if bad:
462
+ raise SystemExit(f"parent checkpoint lacks non-journal weights: {bad[:5]}")
463
+ return ck.get("run_id", "unknown"), list(result.missing_keys)
464
+
465
+
466
+ # ----------------------------------------------------------------------------- ledger (ADR-001)
467
+
468
+
469
+ def git_commit() -> str:
470
+ try:
471
+ return subprocess.check_output(
472
+ ["git", "rev-parse", "HEAD"], cwd=REPO_ROOT, stderr=subprocess.DEVNULL
473
+ ).decode().strip()
474
+ except Exception:
475
+ return "uncommitted"
476
+
477
+
478
+ def build_record(cfg: Config, chash: str, run_id: str, started_iso: str, params: int,
479
+ tokens_seen: int, steps: int, wall_s: float, final_loss: float | None,
480
+ val_ppl: float | None, status: str, anomalies: str | None, device: torch.device) -> dict:
481
+ lr_schedule = f"warmup{cfg.warmup_steps}+cosine(max={cfg.lr},min={cfg.min_lr_ratio}x)"
482
+ if device.type == "cuda":
483
+ hardware = torch.cuda.get_device_name(0)
484
+ precision = "bf16" if torch.cuda.is_bf16_supported() else "fp16"
485
+ gpu_hours = round(wall_s / 3600.0, 4)
486
+ else:
487
+ hardware = platform.processor() or platform.machine() or "cpu"
488
+ precision = "fp32"
489
+ gpu_hours = None
490
+ return {
491
+ "schema_version": "run-v1",
492
+ "run_id": run_id,
493
+ "timestamp_utc": started_iso,
494
+ "git_commit": git_commit(),
495
+ "config_hash": chash,
496
+ "run": {"kind": cfg.kind, "component_under_test": cfg.component_under_test,
497
+ "status": status, "anomalies": anomalies, "notes": cfg.notes},
498
+ "model": {"params_total": params,
499
+ "architecture_id": (("journal-" if cfg.journal == "read" else "")
500
+ + (f"vanilla-{max(1, round(params / 1e6))}m-byte" if cfg.c2_variant == "none"
501
+ else f"c2{cfg.c2_variant}-{max(1, round(params / 1e6))}m-byte")),
502
+ "tokenizer_id": f"byte-v0+{cfg.reserved_oracle_tokens}oracle"},
503
+ "training": {"tokens_seen": tokens_seen, "dataset_id": cfg.dataset_id,
504
+ "data_slice": cfg.data_slice, "seed": cfg.seed, "steps": steps,
505
+ "batch_size": cfg.batch_size, "lr_schedule": lr_schedule,
506
+ "precision": precision, "parent_run_id": cfg.parent_run_id},
507
+ "compute": {"provider": cfg.provider, "hardware": hardware, "gpu_hours": gpu_hours},
508
+ "results": {"final_train_loss": final_loss, "val_perplexity": val_ppl,
509
+ "benchmarks": {"routing_specialization_mi": None, "mqar_accuracy": None,
510
+ "oracle_shift_recovery": None,
511
+ "invariant_compliance_rate": None, "standard_suite": None}},
512
+ "comparison": None,
513
+ }
514
+
515
+
516
+ def validate_record(record: dict) -> None:
517
+ try:
518
+ import jsonschema
519
+ except ImportError:
520
+ print("[ledger] jsonschema not installed — CI will validate this record")
521
+ return
522
+ schema = json.loads(SCHEMA_PATH.read_text())
523
+ jsonschema.validate(record, schema)
524
+ print("[ledger] record valid against run-v1")
525
+
526
+
527
+ def append_record(record: dict) -> None:
528
+ LEDGER_PATH.parent.mkdir(parents=True, exist_ok=True)
529
+ with open(LEDGER_PATH, "a", encoding="utf-8", newline="\n") as f:
530
+ f.write(json.dumps(record, sort_keys=True) + "\n")
531
+ print(f"[ledger] appended run {record['run_id']} -> {LEDGER_PATH.relative_to(REPO_ROOT)}")
532
+
533
+
534
+ def regen_latest() -> None:
535
+ records = []
536
+ if LEDGER_PATH.exists():
537
+ for line in LEDGER_PATH.read_text().splitlines():
538
+ if line.strip():
539
+ records.append(json.loads(line))
540
+ lines = ["# LATEST — run ledger state (generated by train.py — never hand-edit)", "",
541
+ f"Generated: {datetime.now(timezone.utc).isoformat(timespec='seconds')} · records: {len(records)}", ""]
542
+ if not records:
543
+ lines.append("No completed runs yet (expected pre-N1 execution).")
544
+ else:
545
+ lines += ["| run_id | date (UTC) | kind | comp | arch | tokens | steps | val_ppl | status | provider |",
546
+ "|---|---|---|---|---|---|---|---|---|---|"]
547
+ for r in records:
548
+ ppl = r["results"]["val_perplexity"]
549
+ lines.append("| {} | {} | {} | {} | {} | {:,} | {} | {} | {} | {} |".format(
550
+ r["run_id"], r["timestamp_utc"][:10], r["run"]["kind"],
551
+ r["run"]["component_under_test"] or "-", r["model"]["architecture_id"],
552
+ r["training"]["tokens_seen"], r["training"]["steps"],
553
+ f"{ppl:.3f}" if isinstance(ppl, (int, float)) else "null",
554
+ r["run"]["status"], r["compute"]["provider"]))
555
+ done = [r for r in records if r["run"]["status"] == "completed"
556
+ and isinstance(r["results"]["val_perplexity"], (int, float))]
557
+ if done:
558
+ best = min(done, key=lambda r: r["results"]["val_perplexity"])
559
+ lines += ["", f"Best val_perplexity: **{best['results']['val_perplexity']:.3f}** (run {best['run_id']})"]
560
+ LATEST_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
561
+ print(f"[ledger] regenerated {LATEST_PATH.relative_to(REPO_ROOT)}")
562
+
563
+
564
+ # ----------------------------------------------------------------------------- train
565
+
566
+
567
+ def lr_at(step: int, total: int, cfg: Config) -> float:
568
+ if step < cfg.warmup_steps:
569
+ return cfg.lr * (step + 1) / cfg.warmup_steps
570
+ p = (step - cfg.warmup_steps) / max(1, total - cfg.warmup_steps)
571
+ return cfg.lr * (cfg.min_lr_ratio + (1 - cfg.min_lr_ratio) * 0.5 * (1 + math.cos(math.pi * p)))
572
+
573
+
574
+ def main() -> None:
575
+ ap = argparse.ArgumentParser()
576
+ ap.add_argument("--config", type=str, help="path to config json")
577
+ ap.add_argument("--resume", action="store_true")
578
+ ap.add_argument("--regen-latest", action="store_true")
579
+ ap.add_argument("--time-budget-min", type=float, default=None,
580
+ help="stop cleanly after N minutes of training: checkpoint + exit 0, no record; rerun with --resume (session-wall / preemption survival)")
581
+ ap.add_argument("--relay", default=None,
582
+ help="checkpoint relay (cortex_data/relay.py): push out_dir to this private Kaggle dataset slug after every checkpoint; "
583
+ "needs KAGGLE_USERNAME and KAGGLE_KEY in the environment, silently off without them")
584
+ args = ap.parse_args()
585
+ if args.regen_latest:
586
+ regen_latest()
587
+ return
588
+ if not args.config:
589
+ raise SystemExit("--config required (or --regen-latest)")
590
+
591
+ cfg = load_config(args.config)
592
+ chash = config_hash(cfg)
593
+ torch.manual_seed(cfg.seed)
594
+ np.random.seed(cfg.seed)
595
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
596
+ use_bf16 = device.type == "cuda" and torch.cuda.is_bf16_supported()
597
+ use_fp16 = device.type == "cuda" and not use_bf16
598
+ amp_dtype = torch.bfloat16 if use_bf16 else torch.float16
599
+ scaler = torch.amp.GradScaler("cuda", enabled=use_fp16)
600
+
601
+ train_arr, val_arr = load_data(cfg)
602
+ model = VanillaGPT(cfg).to(device)
603
+ params = sum(p.numel() for p in model.parameters())
604
+ # ---- ADR-008: a fine tune starts from a parent checkpoint (strict on the parent's keys)
605
+ if cfg.parent_ckpt:
606
+ parent_id, fresh = load_parent(model, REPO_ROOT / cfg.parent_ckpt, device)
607
+ if cfg.parent_run_id and cfg.parent_run_id != parent_id:
608
+ raise SystemExit(f"parent_run_id {cfg.parent_run_id} does not match the checkpoint's {parent_id}")
609
+ cfg.parent_run_id = parent_id
610
+ print(f"[parent] {parent_id}: parent weights loaded unchanged; {len(fresh)} journal tensors fresh (zero output)")
611
+ # ---- ADR-008: the journal in the decode loop (default off)
612
+ journal_ctx = None
613
+ if cfg.journal == "read":
614
+ from cortex_c2b import Journal, POLICY_STOP
615
+ from cortex_c2b.crypto import key_from_env
616
+ from cortex_c2b.lm_bridge import JournalBridge, contrastive_loss, journal_gate
617
+ from cortex_c2b.organ_use import training_facts, plant_pool, make_batch, batch_stats, collate, match_tensors
618
+ if cfg.journal_path and key_from_env() is None:
619
+ raise SystemExit("journal_path is set but QUANTUM_CORTEX_JOURNAL_KEY is not in the environment: the run's "
620
+ "journal is sealed by default (ADR-007 D8); set the key before training, not after")
621
+ facts, negatives, rep_facts = training_facts(cfg.journal_pool_facts, cfg.journal_train_seed)
622
+ print(f"[journal] curriculum facts {rep_facts['n_facts']} negatives {rep_facts['n_negatives']} "
623
+ f"generator {rep_facts['generator_hash']} collisions removed {len(rep_facts['removed_collisions'])}"
624
+ f" | fresh pool per replant: {cfg.journal_fresh_pool} | window on LM steps: {cfg.journal_lm_window}"
625
+ f" | negatives {cfg.journal_neg_frac} paired: {cfg.journal_paired_negatives}"
626
+ f" shape kept: {cfg.journal_paired_keep_shape}"
627
+ f" | match head: {cfg.journal_match_head}"
628
+ + (f" (loss weight {cfg.journal_match_loss_weight}, coupling {cfg.journal_match_coupling})" if cfg.journal_match_head else ""))
629
+ def run_journal():
630
+ """The run's own scope: sealed on disk (key from the environment), policy stop --
631
+ a measurement run never continues in memory unnoticed (ADR-007 D9)."""
632
+ if cfg.journal_path:
633
+ return Journal(REPO_ROOT / cfg.journal_path, key=key_from_env(), policy=POLICY_STOP)
634
+ return Journal()
635
+ journal_ctx = {"facts": facts, "negatives": negatives, "rng": np.random.default_rng(cfg.seed + 17),
636
+ "bridge": None, "planted_at": -1, "run_journal": run_journal, "refresh": 0}
637
+ if cfg.optimizer != "adamw":
638
+ raise SystemExit("only adamw in the control scaffold — F1 (NorMuon-class) arrives via its own ablation")
639
+ decay = [p for n, p in model.named_parameters() if p.dim() >= 2]
640
+ nodecay = [p for n, p in model.named_parameters() if p.dim() < 2]
641
+ opt = torch.optim.AdamW([{"params": decay, "weight_decay": cfg.weight_decay},
642
+ {"params": nodecay, "weight_decay": 0.0}],
643
+ lr=cfg.lr, betas=(0.9, 0.95))
644
+
645
+ tokens_per_step = cfg.batch_size * cfg.block_size
646
+ total_steps = max(1, cfg.max_tokens // tokens_per_step)
647
+ out_dir = REPO_ROOT / cfg.out_dir
648
+ out_dir.mkdir(parents=True, exist_ok=True)
649
+ ckpt_path = out_dir / "ckpt.pt"
650
+
651
+ start_step, tokens_seen = 0, 0
652
+ started_iso = datetime.now(timezone.utc).isoformat(timespec="seconds")
653
+ run_id = hashlib.sha256(f"{chash}{started_iso}".encode()).hexdigest()[:12]
654
+ if args.resume and ckpt_path.exists():
655
+ ck = torch.load(ckpt_path, map_location=device, weights_only=False)
656
+ if ck["config_hash"] != chash:
657
+ raise SystemExit("resume refused: config_hash mismatch (honest data — change config, change run)")
658
+ model.load_state_dict(ck["model"]); opt.load_state_dict(ck["opt"])
659
+ start_step, tokens_seen = ck["step"] + 1, ck["tokens_seen"]
660
+ started_iso, run_id = ck["started_iso"], ck["run_id"]
661
+ print(f"[resume] step {start_step}/{total_steps} run {run_id}")
662
+ if start_step >= total_steps:
663
+ print(f"[resume] run {run_id} already complete — nothing to do (its record is in the ledger)")
664
+ return
665
+ elif args.resume:
666
+ print("[resume] no checkpoint found — starting fresh")
667
+
668
+ print(f"[run {run_id}] device={device.type} params={params:,} steps={total_steps} "
669
+ f"tokens/step={tokens_per_step:,} config_hash={chash}")
670
+
671
+ gen = torch.Generator().manual_seed(cfg.seed + start_step)
672
+
673
+ def evaluate() -> float:
674
+ model.eval()
675
+ with torch.no_grad():
676
+ losses = []
677
+ for _ in range(cfg.eval_batches):
678
+ x, y = get_batch(val_arr, cfg, gen, device)
679
+ with torch.autocast(device.type, dtype=amp_dtype, enabled=device.type == "cuda"):
680
+ _, l = model(x, y)
681
+ losses.append(l.item())
682
+ model.train()
683
+ return float(np.mean(losses))
684
+
685
+ t0, loss_val, first_loss = time.time(), None, None
686
+ val_loss = None
687
+ anomalies = None
688
+ status = "completed"
689
+ cb_divergent = 0
690
+ # build a baseline lookup for the divergence check (ADR-006 D7), if provided
691
+ baseline_at = None
692
+ if cfg.cb_baseline_losses:
693
+ try:
694
+ bp = (REPO_ROOT / cfg.cb_baseline_losses).resolve()
695
+ raw = bp.read_text().strip()
696
+ if raw.startswith("["):
697
+ _bvals = json.loads(raw) # [{"step":s,"val_loss":v}, ...] or [v, ...]
698
+ else:
699
+ _bvals = [json.loads(l) for l in raw.splitlines() if l.strip()]
700
+ _bmap = {}
701
+ for i, item in enumerate(_bvals):
702
+ if isinstance(item, dict):
703
+ _bmap[int(item["step"])] = float(item["val_loss"])
704
+ else:
705
+ _bmap[(i + 1) * cfg.eval_every] = float(item)
706
+ _bsteps = sorted(_bmap)
707
+ def baseline_at(s, _m=_bmap, _ss=_bsteps):
708
+ # nearest recorded baseline step <= s
709
+ cand = [k for k in _ss if k <= s]
710
+ return _m[cand[-1]] if cand else (_m[_ss[0]] if _ss else None)
711
+ print(f"[circuit-breaker] divergence check armed against {len(_bmap)} baseline points")
712
+ except Exception as e:
713
+ print(f"[circuit-breaker] baseline load failed ({e}); NaN/Inf abort still active")
714
+ baseline_at = None
715
+ try:
716
+ cur_step = start_step
717
+ for step in range(start_step, total_steps):
718
+ cur_step = step
719
+ for g in opt.param_groups:
720
+ g["lr"] = lr_at(step, total_steps, cfg)
721
+ curriculum_step = (journal_ctx is not None
722
+ and journal_ctx["rng"].random() < cfg.journal_curriculum_ratio)
723
+ if curriculum_step:
724
+ # ADR-008 invariant 6: organ use is taught; the pool is replanted under the
725
+ # current encoder every `journal_pool_refresh` steps (a memory scope)
726
+ if journal_ctx["bridge"] is None or step - journal_ctx["planted_at"] >= cfg.journal_pool_refresh:
727
+ if cfg.journal_fresh_pool and journal_ctx["refresh"] > 0:
728
+ # v2: never the same facts twice -- a new seed per replant, decontaminated like the first
729
+ journal_ctx["facts"], journal_ctx["negatives"], _ = training_facts(
730
+ cfg.journal_pool_facts, cfg.journal_train_seed + 2 * journal_ctx["refresh"])
731
+ journal_ctx["refresh"] += 1
732
+ jb = JournalBridge(model, Journal(), k=cfg.journal_k, budget_bytes=cfg.journal_read_bytes,
733
+ seed=cfg.seed, shuffle_seed=cfg.seed + step, device=device,
734
+ mark=cfg.journal_familiarity_mark)
735
+ model.eval(); plant_pool(jb, journal_ctx["facts"], now=float(step)); model.train()
736
+ journal_ctx["bridge"], journal_ctx["planted_at"] = jb, step
737
+ jb = journal_ctx["bridge"]
738
+ progress = step / max(1, total_steps)
739
+ forced = cfg.journal_forced_frac * max(0.0, 1.0 - progress) # the bootstrap decays to zero
740
+ model.eval()
741
+ examples = make_batch(jb, journal_ctx["facts"], journal_ctx["negatives"], cfg.batch_size,
742
+ journal_ctx["rng"], negatives_frac=cfg.journal_neg_frac, forced_frac=forced,
743
+ paired=cfg.journal_paired_negatives, keep_shape=cfg.journal_paired_keep_shape)
744
+ model.train()
745
+ x, y, w, wt, wm, qseq, sseq = collate(jb, examples, cfg.journal_asym_weight, cfg.journal_read_bytes)
746
+ mt, dpos = match_tensors(examples, x.device) if cfg.journal_match_head else (None, None)
747
+ gate = journal_gate(cfg.journal_route) if cfg.journal_route != "router" else 1.0 # a learned router is measured apart (D9 held)
748
+ with torch.autocast(device.type, dtype=amp_dtype, enabled=device.type == "cuda"):
749
+ _, loss_ce = model(x, y, window_tokens=wt, window_mask=wm, gate=gate, loss_weights=w,
750
+ decision_pos=dpos, match_targets=mt)
751
+ loss_c = contrastive_loss(jb.cue_tensor(qseq), jb.cue_tensor(sseq))
752
+ loss = loss_ce + cfg.journal_contrastive_weight * loss_c
753
+ journal_ctx["last_stats"] = {**batch_stats(examples), "loss_ce": float(loss_ce.detach()), "loss_contrastive": float(loss_c.detach()),
754
+ "match_bce": (float(model.last_match_loss.detach()) if model.last_match_loss is not None else None),
755
+ "match_acc": (float(model.last_match_acc) if model.last_match_acc is not None else None)}
756
+ else:
757
+ x, y = get_batch(train_arr, cfg, gen, device)
758
+ wt = wm = None
759
+ if journal_ctx is not None and cfg.journal_lm_window and journal_ctx["bridge"] is not None:
760
+ # v2: the reader stays on during language modelling, fed with whatever the journal returns
761
+ # for each span, so an irrelevant read costs nothing after training (the e_S arm)
762
+ jb = journal_ctx["bridge"]
763
+ model.eval()
764
+ cues = jb.cues([row.tolist() for row in x])
765
+ windows = [jb.read(c).window for c in cues]
766
+ model.train()
767
+ wt, wm = jb.window_tensors(windows, cfg.journal_read_bytes)
768
+ with torch.autocast(device.type, dtype=amp_dtype, enabled=device.type == "cuda"):
769
+ _, loss = model(x, y, window_tokens=wt, window_mask=wm, gate=1.0)
770
+ opt.zero_grad(set_to_none=True)
771
+ scaler.scale(loss).backward()
772
+ scaler.unscale_(opt)
773
+ torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.grad_clip)
774
+ scaler.step(opt)
775
+ scaler.update()
776
+ loss_val = loss.item()
777
+ # circuit breaker — hard criterion: NaN/Inf is instant death (ADR-006 D7)
778
+ if cfg.cb_enabled and (math.isnan(loss_val) or math.isinf(loss_val)):
779
+ torch.save({"model": model.state_dict(), "opt": opt.state_dict(), "step": step,
780
+ "tokens_seen": tokens_seen, "config_hash": chash,
781
+ "started_iso": started_iso, "run_id": run_id}, ckpt_path)
782
+ anomalies = f"NaN/Inf loss at step {step} — circuit breaker (hard abort)"
783
+ print(f"[circuit-breaker] {anomalies}")
784
+ status = "aborted"
785
+ cur_step = step
786
+ break
787
+ if first_loss is None:
788
+ first_loss = loss_val
789
+ tokens_seen += tokens_per_step
790
+ if step % cfg.log_every == 0 or step == total_steps - 1:
791
+ tps = tokens_seen / max(1e-9, time.time() - t0)
792
+ print(f"step {step}/{total_steps} loss {loss_val:.4f} lr {opt.param_groups[0]['lr']:.2e} tok/s {tps:,.0f}")
793
+ if journal_ctx is not None and journal_ctx.get("last_stats"):
794
+ st = journal_ctx["last_stats"]
795
+ print(f" journal: hit {st['retrieval_hit']:.2f} abstain-target {st['abstain_target_rate']:.2f} "
796
+ f"forced {st['n_forced']} neg {st['n_negative']} ce {st['loss_ce']:.3f} contrastive {st['loss_contrastive']:.3f}"
797
+ + (f" match bce {st['match_bce']:.3f} acc {st['match_acc']:.2f}" if st.get("match_bce") is not None else ""))
798
+ if step and step % cfg.eval_every == 0:
799
+ val_loss = evaluate()
800
+ print(f" eval: val_loss {val_loss:.4f} ppl {math.exp(val_loss):.3f}")
801
+ # circuit breaker — divergence criterion vs a control baseline (ADR-006 D7)
802
+ if cfg.cb_enabled and baseline_at is not None:
803
+ b = baseline_at(step)
804
+ if b is not None and val_loss > cfg.cb_divergence_mult * b:
805
+ cb_divergent += 1
806
+ print(f"[circuit-breaker] divergent eval {cb_divergent}/{cfg.cb_window} "
807
+ f"(val {val_loss:.4f} > {cfg.cb_divergence_mult}× baseline {b:.4f} at step {step})")
808
+ else:
809
+ cb_divergent = 0
810
+ if cb_divergent >= cfg.cb_window:
811
+ torch.save({"model": model.state_dict(), "opt": opt.state_dict(), "step": step,
812
+ "tokens_seen": tokens_seen, "config_hash": chash,
813
+ "started_iso": started_iso, "run_id": run_id}, ckpt_path)
814
+ anomalies = (f"diverged at step {step}: val {val_loss:.4f} > "
815
+ f"{cfg.cb_divergence_mult}× baseline {b:.4f} over {cfg.cb_window} evals "
816
+ f"— circuit breaker")
817
+ print(f"[circuit-breaker] {anomalies}")
818
+ status = "aborted"
819
+ cur_step = step
820
+ break
821
+ if step and step % cfg.ckpt_every == 0:
822
+ torch.save({"model": model.state_dict(), "opt": opt.state_dict(), "step": step,
823
+ "tokens_seen": tokens_seen, "config_hash": chash,
824
+ "started_iso": started_iso, "run_id": run_id}, ckpt_path)
825
+ if args.relay:
826
+ from cortex_data import relay
827
+ if relay.push_async(out_dir, args.relay, f"step {step}"):
828
+ print(f"[relay] pushing {out_dir} -> {args.relay} (step {step})")
829
+ if args.time_budget_min is not None and (time.time() - t0) / 60.0 >= args.time_budget_min:
830
+ torch.save({"model": model.state_dict(), "opt": opt.state_dict(), "step": step,
831
+ "tokens_seen": tokens_seen, "config_hash": chash,
832
+ "started_iso": started_iso, "run_id": run_id}, ckpt_path)
833
+ if args.relay:
834
+ from cortex_data import relay
835
+ relay.push(out_dir, args.relay, f"budget stop at step {step}")
836
+ print(f"[budget] {args.time_budget_min:.0f} min reached at step {step}/{total_steps} — "
837
+ f"checkpoint saved, exiting cleanly (no record); rerun with --resume to continue")
838
+ return
839
+ except KeyboardInterrupt:
840
+ status, anomalies = "aborted", "KeyboardInterrupt — checkpoint holds last saved step"
841
+ val_loss = evaluate() if status == "completed" else val_loss
842
+ wall = time.time() - t0
843
+ if status == "completed" and first_loss is not None and loss_val is not None and loss_val >= first_loss:
844
+ anomalies = f"train loss did not decrease ({first_loss:.4f} -> {loss_val:.4f})"
845
+ val_ppl = round(math.exp(val_loss), 4) if isinstance(val_loss, float) else None
846
+ torch.save({"model": model.state_dict(), "opt": opt.state_dict(),
847
+ "step": (total_steps - 1) if status == "completed" else cur_step,
848
+ "tokens_seen": tokens_seen, "config_hash": chash,
849
+ "started_iso": started_iso, "run_id": run_id}, ckpt_path)
850
+ record = build_record(cfg, chash, run_id, started_iso, params, tokens_seen,
851
+ total_steps if status == "completed" else start_step,
852
+ wall, round(loss_val, 4) if loss_val is not None else None,
853
+ val_ppl, status, anomalies, device)
854
+ if journal_ctx is not None and cfg.journal_hm_arm and status == "completed":
855
+ # ADR-008 invariant 9: the frozen protocol's LM arm, its result in the record and as a file
856
+ from cortex_c2b.hm_lm import run_hm_lm
857
+ vb = [get_batch(val_arr, cfg, gen, device) for _ in range(cfg.eval_batches)]
858
+ hm = run_hm_lm(model, journal_ctx["run_journal"], n_facts=cfg.journal_hm_facts, n_negctrl=cfg.journal_hm_negctrl,
859
+ k=cfg.journal_k, budget=cfg.journal_read_bytes, window_len=cfg.journal_read_bytes,
860
+ val_batches=vb, on_disk=bool(cfg.journal_path), mark=cfg.journal_familiarity_mark)
861
+ (out_dir / "hm-lm.json").write_text(json.dumps(hm, indent=2) + "\n", encoding="utf-8")
862
+ kept = REPO_ROOT / "metrics" / "mqar" / f"hm-lm-{run_id}.json" # runs/ is never committed; this is
863
+ kept.parent.mkdir(parents=True, exist_ok=True) # the artefact a RESULTS row cites
864
+ kept.write_text(json.dumps(hm, indent=2) + "\n", encoding="utf-8")
865
+ suite = {k: (float(v) if isinstance(v, bool) else v) for k, v in hm.items()
866
+ if k.startswith("hm_") and isinstance(v, (int, float)) and v is not None}
867
+ suite["hm_persistent"] = float(hm["persistent"]); suite["hm_claimable"] = float(hm["claimable"])
868
+ record["results"]["benchmarks"]["standard_suite"] = suite
869
+ print(f"[hm-lm] {hm['verdict']} | recall on {hm['hm_recall_on']:.3f} off {hm['hm_recall_off']:.3f} "
870
+ f"skill delta {hm['hm_skill_delta']:.4f} invalid citation {hm['hm_invalid_citation_on']:.3f} "
871
+ f"| retained: {kept}")
872
+ validate_record(record)
873
+ append_record(record)
874
+ if args.relay:
875
+ from cortex_data import relay
876
+ relay.wait()
877
+ if relay.push(out_dir, args.relay, "final"):
878
+ print(f"[relay] final push of {out_dir} -> {args.relay}")
879
+ regen_latest()
880
+ print("\n[record — copy this line into metrics/runs.jsonl on the machine that commits]")
881
+ print(json.dumps(record, sort_keys=True))
882
+
883
+
884
+ if __name__ == "__main__":
885
+ main()