initium-deeplearning 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.
Files changed (71) hide show
  1. initium/LNN_controller/cfc_backbone_parallel.py +265 -0
  2. initium/LNN_controller/cfc_controller.py +325 -0
  3. initium/LNN_controller/chained_controller.py +83 -0
  4. initium/LNN_controller/hybrid_controller.py +90 -0
  5. initium/MoE/README.md +1 -0
  6. initium/MoE/moe_layer.py +552 -0
  7. initium/__init__.py +1 -0
  8. initium/config/controller_config.py +86 -0
  9. initium/config/train_config.py +64 -0
  10. initium/core_training.py +2182 -0
  11. initium/data/__init__.py +0 -0
  12. initium/data/audio/__init__.py +0 -0
  13. initium/data/audio/audio_dataset.py +96 -0
  14. initium/data/base_dataset.py +75 -0
  15. initium/data/common/__init__.py +0 -0
  16. initium/data/common/chain_task.py +542 -0
  17. initium/data/common/digit_codec.py +85 -0
  18. initium/data/common/graph_io.py +123 -0
  19. initium/data/common/real_data.py +747 -0
  20. initium/data/dataset_registry.py +59 -0
  21. initium/data/graph_traversal/__init__.py +0 -0
  22. initium/data/graph_traversal/graph_traversal.py +746 -0
  23. initium/data/multimodal/__init__.py +0 -0
  24. initium/data/multimodal/multimodal_dataset.py +401 -0
  25. initium/data/text/__init__.py +0 -0
  26. initium/data/text/text_dataset.py +79 -0
  27. initium/data/video/__init__.py +0 -0
  28. initium/data/video/video_dataset.py +85 -0
  29. initium/inference/__init__.py +1 -0
  30. initium/inference/cache/__init__.py +1 -0
  31. initium/inference/cache/base_cache.py +112 -0
  32. initium/inference/cache/cache_config.py +21 -0
  33. initium/inference/cache/cache_registry.py +99 -0
  34. initium/inference/cache/cached_engine.py +264 -0
  35. initium/inference/cache/keys.py +77 -0
  36. initium/inference/cache/prefix_cache.py +59 -0
  37. initium/inference/cache/result_cache.py +57 -0
  38. initium/inference/cache/state_utils.py +74 -0
  39. initium/inference/cache/stores.py +167 -0
  40. initium/inference/capabilities.py +86 -0
  41. initium/inference/checkpoint_io.py +63 -0
  42. initium/inference/engine.py +88 -0
  43. initium/inference/inference_config.py +22 -0
  44. initium/inference/metrics.py +103 -0
  45. initium/inference/model_loader.py +270 -0
  46. initium/inference/run_inference.py +526 -0
  47. initium/inference/run_logging.py +173 -0
  48. initium/inference/tasks/__init__.py +1 -0
  49. initium/inference/tasks/audio_task.py +114 -0
  50. initium/inference/tasks/base_task.py +60 -0
  51. initium/inference/tasks/graph_traversal_task.py +235 -0
  52. initium/inference/tasks/multimodal_task.py +74 -0
  53. initium/inference/tasks/task_registry.py +68 -0
  54. initium/inference/tasks/text_task.py +114 -0
  55. initium/inference/tasks/video_task.py +114 -0
  56. initium/mamba_controller/mamba2_controller.py +433 -0
  57. initium/mamba_controller/mamba3_controller.py +242 -0
  58. initium/mamba_controller/mamba_backbone_parallel.py +291 -0
  59. initium/mamba_controller/mamba_controller.py +983 -0
  60. initium/mamba_controller/split_graph_dnc.py +623 -0
  61. initium/memory_manipulation/dynamic_memory_resize.py +157 -0
  62. initium/memory_manipulation/dynamic_n_controller.py +152 -0
  63. initium/memory_manipulation/link_matrix_ablation.py +130 -0
  64. initium/memory_manipulation/nvrtc_compat.py +143 -0
  65. initium/memory_manipulation/stochastic_write_head_v2.py +545 -0
  66. initium/setup_wheels.py +175 -0
  67. initium_deeplearning-0.1.0.dist-info/METADATA +253 -0
  68. initium_deeplearning-0.1.0.dist-info/RECORD +71 -0
  69. initium_deeplearning-0.1.0.dist-info/WHEEL +4 -0
  70. initium_deeplearning-0.1.0.dist-info/entry_points.txt +3 -0
  71. initium_deeplearning-0.1.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,746 @@
1
+ """
2
+ file: data/graph_traversal/graph_traversal.py
3
+
4
+ Graph-traversal dataset (Graves 2016-style triple encoding, curriculum over
5
+ synthetic graphs, London Underground as held-out OOD and controller test). Everything
6
+ graph-specific lives here; core_training.py only sees GraphTraversalDataset.
7
+
8
+ History of graph-specific changes (moved from core_training.py):
9
+ - v2: build_london_underground_eval() uses a local random.Random(1234), never
10
+ the global `random.seed` (a global reseed would repeat training batches after
11
+ every periodic OOD eval).
12
+ - v4 gate fix: evaluate_traversal() resamples num_nodes per episode over
13
+ nodes_range (same as training) instead of pinning to the hardest size, and
14
+ the curriculum advances on triple_acc >= ADVANCE_THRESHOLD instead of
15
+ perfect_frac (perfect_frac compounds ~acc**hops and made deep lessons
16
+ unreachable). perfect_frac is still computed/logged/returned.
17
+ hop_breakdown=True buckets episodes by walk length and is printed/logged at
18
+ every advance-check.
19
+ - v5: build_traversal_episode[_from_graph] and evaluate_traversal take an
20
+ optional `rng` (random.Random); None -> global `random` (byte-identical to
21
+ v4). OOD calls pass a dedicated rng so OOD walk sampling is decoupled from
22
+ the training curriculum's RNG stream.
23
+ - v16: per-field / per-hop-position accuracy (field_log, write_field_log).
24
+ - Static Option 2: per-lesson memory size LESSON_NR_CELLS, now held on the
25
+ curriculum instance (`curriculum.lesson_nr_cells`) so core can override it
26
+ per run (isolate_link_ablation / dynamic_n_mode) without touching module
27
+ globals.
28
+ """
29
+
30
+ import random
31
+
32
+ import numpy as np
33
+ import torch
34
+
35
+ from initium.data.base_dataset import BaseDataset
36
+ from initium.data.common.graph_io import load_graph
37
+ from initium.memory_manipulation.dynamic_memory_resize import resize_memory
38
+
39
+ # ==========================================
40
+ # CONFIGURATION
41
+ # ==========================================
42
+ LABEL_RANGE = 1000
43
+ LABEL_DIGITS = 3 # each label is a 3-digit number, 0-999
44
+ DIGIT_BASE = 10 # one-hot over digits 0-9
45
+ LABEL_DIM = LABEL_DIGITS * DIGIT_BASE # 30
46
+ TRIPLE_DIM = 3 * LABEL_DIM # 90 (source + edge + destination)
47
+ NUM_PHASE_CHANNELS = 2 # [phase-transition, prediction-required]
48
+ INPUT_DIM = TRIPLE_DIM + NUM_PHASE_CHANNELS # 92, matches Methods
49
+
50
+ TRAVERSAL_CURRICULUM = [
51
+ ((3, 10), (2, 4), (1, 1)),
52
+ ((3, 10), (2, 4), (1, 2)),
53
+ ((5, 10), (2, 4), (1, 3)),
54
+ ((5, 10), (2, 4), (1, 4)),
55
+ ((10, 15), (2, 4), (1, 4)),
56
+ ((10, 15), (2, 4), (1, 5)),
57
+ ((10, 20), (2, 4), (1, 5)),
58
+ ((10, 20), (2, 4), (1, 6)),
59
+ ((10, 30), (2, 4), (1, 6)),
60
+ ((10, 30), (2, 4), (1, 7)),
61
+ ((10, 30), (2, 4), (1, 8)),
62
+ ((10, 30), (2, 4), (1, 9)),
63
+ ((10, 40), (2, 6), (1, 10)),
64
+ ((10, 40), (2, 6), (1, 20)),
65
+ ]
66
+
67
+ # Static Option 2: per-lesson memory size N, indexed like TRAVERSAL_CURRICULUM.
68
+ LESSON_NR_CELLS = [
69
+ 128,
70
+ 128,
71
+ 128,
72
+ 128, # lessons 1-4 (<=10 nodes)
73
+ 160,
74
+ 160,
75
+ 160,
76
+ 160, # lessons 5-8 (<=20 nodes)
77
+ 192,
78
+ 192,
79
+ 192,
80
+ 192, # lessons 9-12 (<=30 nodes)
81
+ 256,
82
+ 256, # lessons 13-14 (<=40 nodes, path_length up to 20)
83
+ ]
84
+ assert len(LESSON_NR_CELLS) == len(TRAVERSAL_CURRICULUM)
85
+
86
+ ADVANCE_THRESHOLD = 0.85 # 85% per-triple accuracy
87
+ OLD_LESSON_MIX_RATE = 0.10 # 10% of exemplars drawn from earlier lessons
88
+ EVAL_BATCH_SIZE = 100 # episodes per lesson-completion check
89
+ OOD_PATH_LENGTH_RANGE = (3, 5)
90
+
91
+ LONDON_UNDERGROUND_EDGES_RAW = [
92
+ ("OxfordCircus", "TottenhamCtRd", "Central"),
93
+ ("TottenhamCtRd", "OxfordCircus", "Central"),
94
+ ("OxfordCircus", "PiccadillyCircus", "Bakerloo"),
95
+ ("PiccadillyCircus", "OxfordCircus", "Bakerloo"),
96
+ ("OxfordCircus", "NottingHillGate", "Central"),
97
+ ("OxfordCircus", "Euston", "Victoria"),
98
+ ("BakerSt", "Marylebone", "Circle"),
99
+ ("BakerSt", "Marylebone", "Bakerloo"),
100
+ ("BakerSt", "OxfordCircus", "Bakerloo"),
101
+ ("LeicesterSq", "CharingCross", "Northern"),
102
+ ("TottenhamCtRd", "LeicesterSq", "Northern"),
103
+ ("LeicesterSq", "PiccadillyCircus", "Piccadilly"),
104
+ ("PiccadillyCircus", "LeicesterSq", "Piccadilly"),
105
+ ("PiccadillyCircus", "GreenPark", "Piccadilly"),
106
+ ("GreenPark", "PiccadillyCircus", "Piccadilly"),
107
+ ("GreenPark", "OxfordCircus", "Victoria"),
108
+ ("GreenPark", "Victoria", "Victoria"),
109
+ ("Victoria", "GreenPark", "Victoria"),
110
+ ("CharingCross", "PiccadillyCircus", "Bakerloo"),
111
+ ("PiccadillyCircus", "CharingCross", "Bakerloo"),
112
+ ("LeicesterSq", "TottenhamCtRd", "Northern"),
113
+ ("CharingCross", "LeicesterSq", "Northern"),
114
+ ]
115
+
116
+ # Module-level indirection so evaluate_traversal can be reused without
117
+ # threading output_proj through every call. Set via set_output_proj().
118
+ output_proj_current = None
119
+
120
+
121
+ def set_output_proj(proj):
122
+ global output_proj_current
123
+ output_proj_current = proj
124
+
125
+
126
+ # ==========================================
127
+ # GRAPH GENERATOR (fresh random graph on every call, nothing cached)
128
+ # ==========================================
129
+ def generate_graph(num_nodes, k_range, label_range=LABEL_RANGE):
130
+ """Returns (edges [(src_label, edge_label, dst_label)], node_labels,
131
+ adjacency {node_idx: [(dest_idx, edge_label)]})."""
132
+ points = np.random.uniform(0.0, 1.0, size=(num_nodes, 2))
133
+ node_labels = random.sample(range(label_range), num_nodes)
134
+
135
+ dists = np.linalg.norm(points[:, None, :] - points[None, :, :], axis=-1)
136
+ np.fill_diagonal(dists, np.inf)
137
+
138
+ k_min, k_max = k_range
139
+ adjacency = {i: [] for i in range(num_nodes)}
140
+ edges = []
141
+
142
+ for i in range(num_nodes):
143
+ k = random.randint(k_min, min(k_max, num_nodes - 1))
144
+ nearest_idx = np.argsort(dists[i])[:k]
145
+
146
+ edge_labels = random.sample(node_labels, k)
147
+
148
+ for dest_idx, edge_label in zip(nearest_idx, edge_labels):
149
+ src_label = node_labels[i]
150
+ dst_label = node_labels[int(dest_idx)]
151
+ edges.append((src_label, edge_label, dst_label))
152
+ adjacency[i].append((int(dest_idx), edge_label))
153
+
154
+ return edges, node_labels, adjacency
155
+
156
+
157
+ # ==========================================
158
+ # TRIPLE ENCODER
159
+ # ==========================================
160
+ def encode_label(label):
161
+ vec = torch.zeros(LABEL_DIM)
162
+ if label is None:
163
+ return vec
164
+ digits = f"{label:03d}"
165
+ for pos, ch in enumerate(digits):
166
+ vec[pos * DIGIT_BASE + int(ch)] = 1.0
167
+ return vec
168
+
169
+
170
+ def encode_triple(source, edge, dest, phase_transition=0.0, prediction_required=0.0):
171
+ src_vec = encode_label(source)
172
+ edge_vec = encode_label(edge)
173
+ dst_vec = encode_label(dest)
174
+ phase_vec = torch.tensor([phase_transition, prediction_required], dtype=torch.float32)
175
+ return torch.cat([src_vec, edge_vec, dst_vec, phase_vec])
176
+
177
+
178
+ def collate_fn(batch):
179
+ max_len = max(len(x[0]) for x in batch)
180
+ B = len(batch)
181
+
182
+ padded_input = torch.zeros(B, max_len, INPUT_DIM)
183
+ padded_targets = torch.zeros(B, max_len, 9, dtype=torch.long)
184
+ padded_mask = torch.zeros(B, max_len)
185
+
186
+ for i, (inp, tgt, mask) in enumerate(batch):
187
+ T = inp.size(0)
188
+ padded_input[i, :T] = inp
189
+ padded_targets[i, :T] = tgt
190
+ padded_mask[i, :T] = mask
191
+
192
+ return padded_input, padded_targets, padded_mask
193
+
194
+
195
+ # ==========================================
196
+ # LOSS + DIAGNOSTICS
197
+ # ==========================================
198
+ def digit_loss(output, target_digits, answer_mask):
199
+ B, T, _ = output.shape
200
+ logits = output.view(B, T, 9, DIGIT_BASE)
201
+ log_probs = torch.log_softmax(logits, dim=-1)
202
+
203
+ gathered = torch.gather(log_probs, -1, target_digits.unsqueeze(-1)).squeeze(-1)
204
+ per_step_loss = -gathered.sum(dim=-1)
205
+
206
+ mask = answer_mask.float()
207
+ total = (per_step_loss * mask).sum()
208
+ denom = mask.sum().clamp(min=1.0)
209
+ return total / denom
210
+
211
+
212
+ def prediction_diversity(output, target_digits, answer_mask):
213
+ mask = answer_mask.bool()
214
+ if mask.sum() == 0:
215
+ return 0.0
216
+ logits = output.view(*output.shape[:2], 9, DIGIT_BASE)
217
+ preds = logits.argmax(dim=-1) # (B,T,9)
218
+ preds_masked = preds[mask] # (num_answer_steps, 9)
219
+ if preds_masked.numel() == 0:
220
+ return 0.0
221
+ diversities = [preds_masked[:, d].unique().numel() for d in range(9)]
222
+ return sum(diversities) / 9.0
223
+
224
+
225
+ # ==========================================
226
+ # EPISODE CONSTRUCTION
227
+ # ==========================================
228
+ def label_to_digits(label):
229
+ s = f"{label:03d}"
230
+ return [int(c) for c in s]
231
+
232
+
233
+ def triple_to_digit_targets(source, edge, dest):
234
+ return label_to_digits(source) + label_to_digits(edge) + label_to_digits(dest)
235
+
236
+
237
+ def build_traversal_episode_from_graph(
238
+ edges, node_labels, adjacency, num_nodes, path_length_range, rng=None
239
+ ):
240
+ """rng: optional random.Random; None -> global `random` (see header, v5)."""
241
+ rng = rng if rng is not None else random
242
+ inputs, target_digits, answer_mask = [], [], []
243
+
244
+ def add_step(src, edge, dst, phase_transition, prediction_required, target_triple=None):
245
+ inputs.append(encode_triple(src, edge, dst, phase_transition, prediction_required))
246
+ if target_triple is not None:
247
+ target_digits.append(triple_to_digit_targets(*target_triple))
248
+ answer_mask.append(1)
249
+ else:
250
+ target_digits.append([0] * 9)
251
+ answer_mask.append(0)
252
+
253
+ shuffled_edges = edges[:]
254
+ rng.shuffle(shuffled_edges)
255
+ for i, (s, e, d) in enumerate(shuffled_edges):
256
+ add_step(s, e, d, 1.0 if i == 0 else 0.0, 0.0)
257
+
258
+ path_length = rng.randint(*path_length_range)
259
+ start_idx = rng.randrange(num_nodes)
260
+ cur = start_idx
261
+ walk = []
262
+ for _ in range(path_length):
263
+ if not adjacency[cur]:
264
+ break
265
+ dst_idx, edge_label = rng.choice(adjacency[cur])
266
+ walk.append((cur, edge_label, dst_idx))
267
+ cur = dst_idx
268
+ if not walk:
269
+ return None
270
+
271
+ for i, (src_idx, edge_label, _dst_idx) in enumerate(walk):
272
+ src = node_labels[src_idx] if i == 0 else None
273
+ add_step(src, edge_label, None, 1.0 if i == 0 else 0.0, 0.0)
274
+
275
+ for i, (src_idx, edge_label, dst_idx) in enumerate(walk):
276
+ target = (node_labels[src_idx], edge_label, node_labels[dst_idx])
277
+ add_step(None, None, None, 1.0 if i == 0 else 0.0, 1.0, target_triple=target)
278
+
279
+ input_seq = torch.stack(inputs)
280
+ target_digits_t = torch.tensor(target_digits, dtype=torch.long)
281
+ answer_mask_t = torch.tensor(answer_mask, dtype=torch.float32)
282
+ return input_seq, target_digits_t, answer_mask_t
283
+
284
+
285
+ def build_traversal_episode(num_nodes, k_range, path_length_range, rng=None):
286
+ # generate_graph() always uses the global random/numpy streams; it is only
287
+ # called for ID episodes, never for the fixed-graph OOD path.
288
+ edges, node_labels, adjacency = generate_graph(num_nodes, k_range)
289
+ return build_traversal_episode_from_graph(
290
+ edges, node_labels, adjacency, num_nodes, path_length_range, rng=rng
291
+ )
292
+
293
+
294
+ # ==========================================
295
+ # CURRICULUM
296
+ # ==========================================
297
+ class TraversalCurriculum:
298
+ def __init__(self, table=TRAVERSAL_CURRICULUM, custom_graph=None):
299
+ self.table = table
300
+ self.lesson = 0
301
+ # per-instance so core can override per run (isolate_link_ablation /
302
+ # dynamic_n_mode) without mutating module globals
303
+ self.lesson_nr_cells = list(LESSON_NR_CELLS)
304
+ # custom_graph: (edges, node_labels, adjacency) from a user-supplied
305
+ # graph file (GraphTraversalDataset's dataset_link), or None for the
306
+ # default per-episode synthetic generate_graph() behavior. When set,
307
+ # sample_episode() walks THIS fixed graph instead of generating a
308
+ # fresh random one -- node count is then whatever the supplied graph
309
+ # has, not the per-lesson nodes_range; only path_length_range still
310
+ # scales difficulty across lessons.
311
+ self.custom_graph = custom_graph
312
+
313
+ def _sample_lesson_params(self):
314
+ if self.lesson > 0 and random.random() < OLD_LESSON_MIX_RATE:
315
+ idx = random.randint(0, self.lesson - 1)
316
+ else:
317
+ idx = self.lesson
318
+ return self.table[idx]
319
+
320
+ def sample_episode(self):
321
+ nodes_range, out_degree_range, path_len_range = self._sample_lesson_params()
322
+ if self.custom_graph is not None:
323
+ edges, node_labels, adjacency = self.custom_graph
324
+ ep = None
325
+ while ep is None:
326
+ ep = build_traversal_episode_from_graph(
327
+ edges, node_labels, adjacency, len(node_labels), path_len_range
328
+ )
329
+ return ep
330
+ ep = None
331
+ while ep is None:
332
+ num_nodes = random.randint(*nodes_range)
333
+ ep = build_traversal_episode(num_nodes, out_degree_range, path_len_range)
334
+ return ep
335
+
336
+ def maybe_advance(self, model, device, step=None, optimizer=None):
337
+ """Returns (lesson, id_triple_acc, id_perfect_frac). Eval samples
338
+ num_nodes over the lesson's nodes_range (as trained), or walks the
339
+ fixed custom_graph when one was supplied; the gate advances on
340
+ triple_acc >= ADVANCE_THRESHOLD (see header, v4)."""
341
+ nodes_range, out_degree_range, path_len_range = self.table[self.lesson]
342
+ if self.custom_graph is not None:
343
+ edges, node_labels, adjacency = self.custom_graph
344
+ graph_kwargs = dict(
345
+ fixed_graph=(edges, node_labels, adjacency, len(node_labels)),
346
+ path_length_range=path_len_range,
347
+ )
348
+ else:
349
+ graph_kwargs = dict(
350
+ nodes_range=nodes_range, k_range=out_degree_range, path_length_range=path_len_range
351
+ )
352
+ triple_acc, perfect_frac, hop_breakdown = evaluate_traversal(
353
+ model,
354
+ device,
355
+ num_episodes=EVAL_BATCH_SIZE,
356
+ verbose_n=0,
357
+ field_log=(field_log := {}),
358
+ hop_breakdown=True,
359
+ **graph_kwargs,
360
+ )
361
+ if hop_breakdown:
362
+ breakdown_str = ", ".join(
363
+ f"{hops}-hop: acc {acc:.1f}% perfect {pf:.1f}% (n={n})"
364
+ for hops, (acc, pf, n) in hop_breakdown.items()
365
+ )
366
+ print(f" [lesson {self.lesson + 1} eval by hop count] {breakdown_str}")
367
+ hop_writer = getattr(self, "hop_log_writer", None)
368
+ if hop_writer is not None:
369
+ for hops, (acc, pf, n) in sorted(hop_breakdown.items()):
370
+ hop_writer.writerow([step, self.lesson + 1, "id", hops, acc, pf, n])
371
+ self.hop_log_file.flush()
372
+
373
+ field_writer = getattr(self, "field_log_writer", None)
374
+ if field_writer is not None and field_log:
375
+ write_field_log(
376
+ field_writer, self.field_log_file, step, self.lesson + 1, "id", field_log
377
+ )
378
+
379
+ if triple_acc / 100.0 >= ADVANCE_THRESHOLD and self.lesson < len(self.table) - 1:
380
+ self.lesson += 1
381
+ new_n = self.lesson_nr_cells[self.lesson]
382
+ if new_n != self.lesson_nr_cells[self.lesson - 1]:
383
+ # optimizer=optimizer: without it a live resize orphans the
384
+ # rebuilt Memory sublayers from Adam's param_groups.
385
+ resize_memory(model, new_n, device=device, optimizer=optimizer)
386
+ print(f">>> Memory resized to nr_cells={new_n} for lesson {self.lesson + 1}")
387
+ print(f">>> Curriculum advanced to lesson {self.lesson + 1}/{len(self.table)}")
388
+ writer = getattr(self, "advance_log_writer", None)
389
+ if writer is not None:
390
+ writer.writerow([step, self.lesson + 1, len(self.table)])
391
+ self.advance_log_file.flush()
392
+ return self.lesson, triple_acc, perfect_frac
393
+
394
+
395
+ def sample_batch(curriculum, batch_size):
396
+ episodes = [curriculum.sample_episode() for _ in range(batch_size)]
397
+ return collate_fn(episodes)
398
+
399
+
400
+ # ==========================================
401
+ # EVALUATION
402
+ # ==========================================
403
+ def decode_prediction(output_step):
404
+ logits = output_step.view(9, DIGIT_BASE)
405
+ digit_preds = logits.argmax(dim=-1).tolist()
406
+ src = int("".join(str(d) for d in digit_preds[0:3]))
407
+ edge = int("".join(str(d) for d in digit_preds[3:6]))
408
+ dst = int("".join(str(d) for d in digit_preds[6:9]))
409
+ return src, edge, dst
410
+
411
+
412
+ def evaluate_traversal(
413
+ model,
414
+ device,
415
+ num_episodes=100,
416
+ verbose_n=3,
417
+ num_nodes=None,
418
+ nodes_range=None,
419
+ k_range=None,
420
+ path_length_range=None,
421
+ fixed_graph=None,
422
+ hop_breakdown=False,
423
+ rng=None,
424
+ ablate_memory=False,
425
+ field_log=None,
426
+ model_kwargs=None,
427
+ ):
428
+ """
429
+ num_nodes: fixed graph size for every episode. nodes_range: (lo, hi),
430
+ num_nodes resampled per episode (same distribution as training).
431
+ Passing both is an error; neither is only valid with fixed_graph.
432
+ hop_breakdown: also return {path_length: (triple_acc, perfect_frac, n)}.
433
+ rng: optional random.Random for this call's sampling (None -> global).
434
+ ablate_memory: passes pass_through_memory=False to the model (memory
435
+ read AND write skipped) -- functional-usage check (LB-9/Concept 6).
436
+ field_log: optional dict filled with per-field / per-hop-position counts
437
+ (see write_field_log).
438
+ """
439
+ if num_nodes is not None and nodes_range is not None:
440
+ raise ValueError("evaluate_traversal: pass num_nodes or nodes_range, not both")
441
+
442
+ model.eval()
443
+ total_triples, correct_triples = 0, 0
444
+ perfect_episodes = 0
445
+ tested = 0
446
+ by_hops = {} # path_length -> [triples_total, triples_correct, episodes, episodes_perfect]
447
+
448
+ with torch.no_grad():
449
+ while tested < num_episodes:
450
+ if fixed_graph is not None:
451
+ edges, node_labels, adjacency, n = fixed_graph
452
+ ep = build_traversal_episode_from_graph(
453
+ edges, node_labels, adjacency, n, path_length_range, rng=rng
454
+ )
455
+ else:
456
+ n = (rng or random).randint(*nodes_range) if nodes_range is not None else num_nodes
457
+ ep = build_traversal_episode(n, k_range, path_length_range, rng=rng)
458
+ if ep is None:
459
+ continue
460
+ input_seq, target_digits, answer_mask = ep
461
+ input_seq = input_seq.unsqueeze(0).to(device)
462
+
463
+ hidden = (None, None, None)
464
+ output, _ = model(
465
+ input_seq,
466
+ hidden,
467
+ reset_experience=True,
468
+ pass_through_memory=not ablate_memory,
469
+ **(model_kwargs or {}),
470
+ )
471
+ output = output.transpose(0, 1).contiguous().squeeze(0) # (T, 92)
472
+ output = output_proj_current(output) # (T, 90)
473
+
474
+ answer_idx = (answer_mask == 1).nonzero(as_tuple=True)[0]
475
+ episode_perfect = True
476
+ ep_total = ep_correct = 0
477
+ _prev_dst_ok = None # was the previous hop's dst correct (chain-propagation diagnostic)
478
+ for hop_pos, idx in enumerate(answer_idx, start=1):
479
+ pred = decode_prediction(output[idx])
480
+ tgt_digits = target_digits[idx].tolist()
481
+ tgt = (
482
+ int("".join(map(str, tgt_digits[0:3]))),
483
+ int("".join(map(str, tgt_digits[3:6]))),
484
+ int("".join(map(str, tgt_digits[6:9]))),
485
+ )
486
+ is_correct = pred == tgt
487
+ if field_log is not None: # keyed by (episode path length, hop position)
488
+ _s, _e, _d = pred[0] == tgt[0], pred[1] == tgt[1], pred[2] == tgt[2]
489
+ _fl = field_log.setdefault((len(answer_idx), hop_pos), [0] * 9)
490
+ _fl[0] += 1 # n triples
491
+ _fl[1] += int(_s)
492
+ _fl[2] += int(_e)
493
+ _fl[3] += int(_d)
494
+ _fl[4] += int(is_correct)
495
+ if _s and _e: # lookup check: dst given src+edge right
496
+ _fl[5] += 1
497
+ _fl[6] += int(_d)
498
+ if _prev_dst_ok: # chain check: src given previous dst right
499
+ _fl[7] += 1
500
+ _fl[8] += int(_s)
501
+ _prev_dst_ok = _d
502
+ correct_triples += int(is_correct)
503
+ total_triples += 1
504
+ ep_correct += int(is_correct)
505
+ ep_total += 1
506
+ if not is_correct:
507
+ episode_perfect = False
508
+ if tested < verbose_n:
509
+ print(f" Pred: {pred} | Target: {tgt} | Correct: {is_correct}")
510
+
511
+ perfect_episodes += int(episode_perfect)
512
+ tested += 1
513
+
514
+ if hop_breakdown:
515
+ # ep_total == walk length (one answer step per hop)
516
+ acc = by_hops.setdefault(ep_total, [0, 0, 0, 0])
517
+ acc[0] += ep_total
518
+ acc[1] += ep_correct
519
+ acc[2] += 1
520
+ acc[3] += int(episode_perfect)
521
+
522
+ triple_acc = correct_triples / max(total_triples, 1) * 100
523
+ perfect_frac = perfect_episodes / tested * 100
524
+ print(
525
+ f"Eval: triple-level acc {triple_acc:.2f}% | "
526
+ f"perfect-traversal fraction {perfect_frac:.2f}% ({tested} episodes)"
527
+ )
528
+ model.train()
529
+
530
+ if hop_breakdown:
531
+ breakdown = {
532
+ hops: (
533
+ correct / max(total, 1) * 100,
534
+ n_perfect / max(n_eps, 1) * 100,
535
+ n_eps,
536
+ )
537
+ for hops, (total, correct, n_eps, n_perfect) in sorted(by_hops.items())
538
+ }
539
+ return triple_acc, perfect_frac, breakdown
540
+ return triple_acc, perfect_frac
541
+
542
+
543
+ def write_field_log(writer, file, step, lesson, eval_type, field_log):
544
+ """Dump one evaluate_traversal(field_log=...) dict to the field-breakdown
545
+ CSV and print a compact console summary (hop positions 1-4).
546
+ field_log[(path_length, hop_position)] = [n, src_ok, edge_ok, dst_ok, triple_ok,
547
+ n_src_edge_ok, dst_ok_given_src_edge, n_prev_dst_ok, src_ok_given_prev_dst]."""
548
+ nan = float("nan")
549
+ for (path_len, hop_pos), (n, s, e, d, t, n_se, d_se, n_pd, s_pd) in sorted(field_log.items()):
550
+ writer.writerow(
551
+ [
552
+ step,
553
+ lesson,
554
+ eval_type,
555
+ path_len,
556
+ hop_pos,
557
+ n,
558
+ s / n * 100,
559
+ e / n * 100,
560
+ d / n * 100,
561
+ t / n * 100,
562
+ (d_se / n_se * 100) if n_se else "",
563
+ n_se,
564
+ (s_pd / n_pd * 100) if n_pd else "",
565
+ n_pd,
566
+ ]
567
+ )
568
+ file.flush()
569
+ agg = {}
570
+ for (_path_len, hop_pos), v in field_log.items():
571
+ a = agg.setdefault(hop_pos, [0] * 9)
572
+ for i in range(9):
573
+ a[i] += v[i]
574
+ parts = []
575
+ for hop_pos in sorted(agg)[:4]:
576
+ n, s, e, d, t, n_se, d_se, n_pd, s_pd = agg[hop_pos]
577
+ part = (
578
+ f"hop{hop_pos}: src {s / n * 100:.0f} edge {e / n * 100:.0f} dst {d / n * 100:.0f} "
579
+ f"dst|src+edge {(d_se / n_se * 100) if n_se else nan:.0f}"
580
+ )
581
+ if hop_pos > 1:
582
+ part += f" src|prev_dst_ok {(s_pd / n_pd * 100) if n_pd else nan:.0f}"
583
+ parts.append(part + f" (n={n})")
584
+ print(f" [field acc {eval_type} step {step}] " + " | ".join(parts))
585
+
586
+
587
+ def build_london_underground_eval():
588
+ """Fixed, reproducible OOD graph/label mapping. Uses a local
589
+ random.Random(1234) so it never touches the global RNG stream."""
590
+ stations = sorted(
591
+ {s for s, d, _ in LONDON_UNDERGROUND_EDGES_RAW}
592
+ | {d for s, d, _ in LONDON_UNDERGROUND_EDGES_RAW}
593
+ )
594
+ lines = sorted({l for _, _, l in LONDON_UNDERGROUND_EDGES_RAW})
595
+
596
+ rng = random.Random(1234)
597
+ all_labels = rng.sample(range(1000), len(stations) + len(lines))
598
+ station_to_label = dict(zip(stations, all_labels[: len(stations)]))
599
+ line_to_label = dict(zip(lines, all_labels[len(stations) :]))
600
+
601
+ edges = [
602
+ (station_to_label[s], line_to_label[l], station_to_label[d])
603
+ for s, d, l in LONDON_UNDERGROUND_EDGES_RAW
604
+ ]
605
+
606
+ adjacency = {i: [] for i in range(len(stations))}
607
+ station_idx = {s: i for i, s in enumerate(stations)}
608
+ for s, d, l in LONDON_UNDERGROUND_EDGES_RAW:
609
+ adjacency[station_idx[s]].append((station_idx[d], line_to_label[l]))
610
+
611
+ node_labels = [station_to_label[s] for s in stations]
612
+ return edges, node_labels, adjacency
613
+
614
+
615
+ # ==========================================
616
+ # DATASET ADAPTER (what core_training.py uses)
617
+ # ==========================================
618
+ class GraphTraversalDataset(BaseDataset):
619
+ name = "graph-traversal"
620
+ input_dim = INPUT_DIM
621
+ output_dim = TRIPLE_DIM
622
+ advance_threshold = ADVANCE_THRESHOLD
623
+ _custom_graph: (
624
+ tuple[list[tuple[int, int, int]], list[int], dict[int, list[tuple[int, int]]]] | None
625
+ )
626
+ _custom_test_graph: (
627
+ tuple[list[tuple[int, int, int]], list[int], dict[int, list[tuple[int, int]]]] | None
628
+ )
629
+
630
+ def __init__(self, dataset_link: str | None = None, test_dataset_link: str | None = None):
631
+ # dataset_link: path to a real edge file (see data/common/graph_io.py
632
+ # for the accepted formats) to TRAIN on, instead of the default
633
+ # per-episode synthetic generate_graph(). None (default) -> unchanged
634
+ # synthetic behavior.
635
+ # test_dataset_link: path to a real edge file to use as the fixed
636
+ # OOD TEST graph, instead of the built-in London Underground -- the
637
+ # graph analogue of text/audio/video's test_dataset_link. None
638
+ # (default) -> the built-in London Underground, unchanged.
639
+ self._custom_graph = load_graph(dataset_link) if dataset_link is not None else None
640
+ self._custom_test_graph = (
641
+ load_graph(test_dataset_link) if test_dataset_link is not None else None
642
+ )
643
+ if self._custom_graph is not None:
644
+ print(
645
+ f"[graph-traversal] training graph loaded from {dataset_link}: "
646
+ f"{len(self._custom_graph[1])} nodes, {len(self._custom_graph[0])} edges"
647
+ )
648
+ if self._custom_test_graph is not None:
649
+ print(
650
+ f"[graph-traversal] test graph loaded from {test_dataset_link}: "
651
+ f"{len(self._custom_test_graph[1])} nodes, {len(self._custom_test_graph[0])} edges"
652
+ )
653
+
654
+ def make_curriculum(self):
655
+ return TraversalCurriculum(custom_graph=self._custom_graph)
656
+
657
+ def sample_batch(self, curriculum, batch_size):
658
+ return sample_batch(curriculum, batch_size)
659
+
660
+ def loss(self, output, target, mask):
661
+ return digit_loss(output, target, mask)
662
+
663
+ def diversity(self, output, target, mask):
664
+ return prediction_diversity(output, target, mask)
665
+
666
+ def set_output_proj(self, proj):
667
+ set_output_proj(proj)
668
+
669
+ def evaluate_ood(self, model, device, num_episodes, rng, verbose_n=0, field_log=None):
670
+ if self._custom_test_graph is not None:
671
+ edges, node_labels, adjacency = self._custom_test_graph
672
+ else:
673
+ edges, node_labels, adjacency = build_london_underground_eval()
674
+ return evaluate_traversal(
675
+ model,
676
+ device,
677
+ num_episodes=num_episodes,
678
+ verbose_n=verbose_n,
679
+ field_log=field_log,
680
+ fixed_graph=(edges, node_labels, adjacency, len(node_labels)),
681
+ path_length_range=OOD_PATH_LENGTH_RANGE,
682
+ rng=rng,
683
+ hop_breakdown=True,
684
+ )
685
+
686
+ def _lesson_eval_graph_kwargs(self, curriculum, lesson_idx):
687
+ """-> kwargs for evaluate_traversal() covering the 'which graph(s)'
688
+ half of a lesson-distribution eval: nodes_range/k_range (synthetic,
689
+ default) or a fixed_graph (when dataset_link supplied a custom
690
+ training graph -- see __init__ above). Either way path_length_range
691
+ still comes from the lesson table."""
692
+ nodes_range, out_degree_range, path_len_range = curriculum.table[lesson_idx]
693
+ if self._custom_graph is not None:
694
+ edges, node_labels, adjacency = self._custom_graph
695
+ return dict(
696
+ fixed_graph=(edges, node_labels, adjacency, len(node_labels)),
697
+ path_length_range=path_len_range,
698
+ )
699
+ return dict(
700
+ nodes_range=nodes_range, k_range=out_degree_range, path_length_range=path_len_range
701
+ )
702
+
703
+ def evaluate_id_ablated(self, model, device, curriculum, lesson_idx):
704
+ return evaluate_traversal(
705
+ model,
706
+ device,
707
+ num_episodes=EVAL_BATCH_SIZE,
708
+ verbose_n=0,
709
+ ablate_memory=True,
710
+ **self._lesson_eval_graph_kwargs(curriculum, lesson_idx),
711
+ )
712
+
713
+ def evaluate_id_combiner_stage_ablated(
714
+ self, model, device, curriculum, lesson_idx, skip_stages
715
+ ):
716
+ """Same lesson-distribution eval as evaluate_id_ablated, but bypasses
717
+ one stage of a hybrid split-graph combiner (see
718
+ ChainedControllerWrapper.forward's skip_stages / SplitGraphDNC's
719
+ combiner_skip_stages) instead of Memory. Only meaningful when
720
+ model.combiner_wrapper is a ChainedControllerWrapper."""
721
+ return evaluate_traversal(
722
+ model,
723
+ device,
724
+ num_episodes=EVAL_BATCH_SIZE,
725
+ verbose_n=0,
726
+ model_kwargs={"combiner_skip_stages": skip_stages},
727
+ **self._lesson_eval_graph_kwargs(curriculum, lesson_idx),
728
+ )
729
+
730
+ def field_log_header(self):
731
+ return [
732
+ "path_length",
733
+ "hop_position",
734
+ "n_triples",
735
+ "src_acc",
736
+ "edge_acc",
737
+ "dst_acc",
738
+ "triple_acc",
739
+ "dst_acc_given_src_edge",
740
+ "n_src_edge_correct",
741
+ "src_acc_given_prev_dst_correct",
742
+ "n_prev_dst_correct",
743
+ ]
744
+
745
+ def write_field_log(self, writer, file, step, lesson, eval_type, field_log):
746
+ write_field_log(writer, file, step, lesson, eval_type, field_log)