Jasonjiao2023 commited on
Commit
4ab8d58
·
verified ·
1 Parent(s): 414fc35

Upload CEDL research checkpoint

Browse files
CEDL.py ADDED
The diff for this file is too large to render. See raw diff
 
HF_UPLOAD_CHECKLIST.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Upload Checklist
2
+
3
+ Repository:
4
+
5
+ - `Jasonjiao2023/CEDL`
6
+
7
+ The current Hugging Face docs recommend the modern `hf` CLI. For agent-aware local workflows, the optional project setup is:
8
+
9
+ ```bash
10
+ hf skills add
11
+ ```
12
+
13
+ For normal upload work, authenticate first:
14
+
15
+ ```bash
16
+ hf auth login
17
+ ```
18
+
19
+ Before upload, `hf_model/` should contain:
20
+
21
+ - `README.md`
22
+ - `config.json`
23
+ - `MANIFEST.json`
24
+ - `requirements.txt`
25
+ - `CEDL.py`
26
+ - `data_v4c_pairs.py`
27
+ - `examples/load_checkpoint.py`
28
+ - `probes/`
29
+ - `pytorch_model.bin`
30
+ - `cedl_config.json`
31
+
32
+ Copy the frozen Colab artifacts into public Hugging Face names:
33
+
34
+ ```bash
35
+ cp "$CEDL_FROZEN_CHECKPOINT" pytorch_model.bin
36
+ cp "$CEDL_FROZEN_CONFIG" cedl_config.json
37
+ ```
38
+
39
+ Verify hashes:
40
+
41
+ ```bash
42
+ sha256sum pytorch_model.bin cedl_config.json
43
+ ```
44
+
45
+ Expected:
46
+
47
+ ```text
48
+ e15a3d6dd38a1f85e2a9c4de409ac3cbd7dece4cf202fca05872ebea8180bfbf pytorch_model.bin
49
+ 52ffddf6d95fa20fe4be953d463e5e388c2b161b49096931e10fe09a160f521f cedl_config.json
50
+ ```
51
+
52
+ One-line upload after the checkpoint and sidecar are present:
53
+
54
+ ```bash
55
+ hf upload Jasonjiao2023/CEDL CEDL-release/hf_model . --repo-type model
56
+ ```
MANIFEST.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "label": "CEDL",
3
+ "frozen_at_utc": "2026-06-26T12:42:59.251253+00:00",
4
+ "checkpoint": "pytorch_model.bin",
5
+ "config": "cedl_config.json",
6
+ "sha256": {
7
+ "checkpoint": "e15a3d6dd38a1f85e2a9c4de409ac3cbd7dece4cf202fca05872ebea8180bfbf",
8
+ "config": "948eebfc64e66a3622157be781929f1e719b4c8e3f59c4d7dbd116cc2ff1a800"
9
+ },
10
+ "model_interpretation": {
11
+ "architecture": "hippocampal-inspired contextual memory language model",
12
+ "memory_readout": "contextual direct memory readout",
13
+ "status": "research checkpoint",
14
+ "claim": "CEDL contextual memory readout is causally useful for current-vs-stale memory updating; not yet a general downstream SOTA model."
15
+ },
16
+ "mechanism_evidence": {
17
+ "current_vs_stale_causality": [
18
+ {"seed": 1, "N": 158, "active_top1": 0.791, "memory_off_top1": 0.0, "delta_pp": 79.1, "delta_margin": 8.266, "t": 20.756, "cohen_d": 1.656},
19
+ {"seed": 2, "N": 164, "active_top1": 0.732, "memory_off_top1": 0.0, "delta_pp": 73.2, "delta_margin": 7.702, "t": 18.912, "cohen_d": 1.481},
20
+ {"seed": 3, "N": 162, "active_top1": 0.728, "memory_off_top1": 0.0, "delta_pp": 72.8, "delta_margin": 7.756, "t": 18.507, "cohen_d": 1.459}
21
+ ],
22
+ "memory_readout_diagnosis": [
23
+ {"seed": 1, "N": 158, "trunk_top1_cur": 0.0, "memory_top1_cur": 0.797, "active_top1_cur": 0.791, "memory_top1_stale": 0.006, "current_rank_median": 1},
24
+ {"seed": 2, "N": 164, "trunk_top1_cur": 0.0, "memory_top1_cur": 0.738, "active_top1_cur": 0.732, "memory_top1_stale": 0.006, "current_rank_median": 1},
25
+ {"seed": 3, "N": 162, "trunk_top1_cur": 0.0, "memory_top1_cur": 0.735, "active_top1_cur": 0.728, "memory_top1_stale": 0.012, "current_rank_median": 1}
26
+ ]
27
+ },
28
+ "general_eval": {
29
+ "wikitext103_test_ppl": 30.11,
30
+ "lambada": 0.159,
31
+ "hellaswag": 0.271,
32
+ "arc_easy": 0.281
33
+ }
34
+ }
README.md ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ library_name: pytorch
4
+ tags:
5
+ - pytorch
6
+ - language-modeling
7
+ - memory
8
+ - hippocampal-inspired
9
+ - research-artifact
10
+ datasets:
11
+ - Salesforce/wikitext
12
+ ---
13
+
14
+ # CEDL
15
+
16
+ CEDL is a hippocampal-inspired language model with contextual memory readout. It separates context encoding, pattern separation, memory retrieval, and linkage/feedback into distinct computational stages, then uses a contextual memory channel to resolve current-vs-stale information.
17
+
18
+ This repository is a research checkpoint. It is intended for mechanism inspection and reproducible probes, not for production deployment or broad downstream SOTA claims.
19
+
20
+ ## Intended Use
21
+
22
+ Use this repository to:
23
+
24
+ - load the CEDL PyTorch checkpoint;
25
+ - inspect the CEDL architecture implementation;
26
+ - reproduce current-vs-stale memory-update probes;
27
+ - study contextual memory readout and memory-off ablations.
28
+
29
+ Do not use this repository to claim:
30
+
31
+ - broad downstream SOTA;
32
+ - production-ready generation quality;
33
+ - biological fidelity beyond computational analogy.
34
+
35
+ ## Files
36
+
37
+ Expected checkpoint files:
38
+
39
+ - `pytorch_model.bin`
40
+ - `cedl_config.json`
41
+ - `MANIFEST.json`
42
+
43
+ Code and probes:
44
+
45
+ - `CEDL.py`
46
+ - `data_v4c_pairs.py`
47
+ - `probes/probe_memory_causality.py`
48
+ - `probes/probe_memory_diagnosis.py`
49
+ - `probes/probe_memory_source_readout.py`
50
+
51
+ ## Evidence Summary
52
+
53
+ Current-vs-stale causality:
54
+
55
+ | Seed | N | CEDL active top-1 | Memory-off top-1 | Delta acc. | Delta margin | Cohen's d |
56
+ |---:|---:|---:|---:|---:|---:|---:|
57
+ | 1 | 158 | 79.1% | 0.0% | +79.1pp | +8.266 | 1.656 |
58
+ | 2 | 164 | 73.2% | 0.0% | +73.2pp | +7.702 | 1.481 |
59
+ | 3 | 162 | 72.8% | 0.0% | +72.8pp | +7.756 | 1.459 |
60
+
61
+ Memory-readout diagnosis:
62
+
63
+ | Seed | N | Trunk current top-1 | Memory current top-1 | Memory stale top-1 | Current median rank |
64
+ |---:|---:|---:|---:|---:|---:|
65
+ | 1 | 158 | 0.0% | 79.7% | 0.6% | 1 |
66
+ | 2 | 164 | 0.0% | 73.8% | 0.6% | 1 |
67
+ | 3 | 162 | 0.0% | 73.5% | 1.2% | 1 |
68
+
69
+ General evaluation:
70
+
71
+ | Model | Params | WikiText-103 PPL | LAMBADA | HellaSwag | ARC-Easy |
72
+ |---|---:|---:|---:|---:|---:|
73
+ | CEDL | 136.3M | 30.11 | 15.9% | 27.1% | 28.1% |
74
+ | Transformer baseline | 103.2M | 45.93 | 3.9% | 26.5% | 28.2% |
75
+
76
+ ## About the Author
77
+
78
+ CEDL was developed by Dian Jiao, a multidisciplinary researcher and technology leader with more than 15 years of experience across digital transformation, neuroscience, and technology innovation. His work connects brain-inspired theory with practical AI systems, with a focus on NeuroAI, digital therapeutics, and human-centered model design.
79
+
80
+ His research interests include AI-enhanced digital therapeutics using EEG, adaptive biofeedback, and machine learning for cognitive enhancement and neurological rehabilitation; and biologically inspired AI models that draw from neural dynamics, memory circuits, and brain mechanisms.
81
+
82
+ Selected publications and contributions:
83
+
84
+ - Jiao, D. (2025). Elliptic cortical networks: A mathematically constrained architecture for biologically-inspired intelligence. Neurocomputing, 658, 131802. doi: 10.1016/j.neucom.2025.131802
85
+ - Jiao, D. (2025). Leveraging neurotechnology for neurodivergent education: a narrative review. Learning: Research and Practice, 1-25. doi: 10.1080/23735082.2025.2517052
86
+ - Jiao, D. (2025). AI-enhanced digital therapeutics for cognitive impairment: Integrating mobile applications, virtual reality, and wearable devices. Discover Artificial Intelligence, 5, Article 69. doi: 10.1007/s44163-025-00325-6
87
+ - Jiao, D. (2025). Advancing personalized digital therapeutics: integrating music therapy, brainwave entrainment methods, and AI-driven biofeedback. Frontiers in Digital Health, 7, 1552396. doi: 10.3389/fdgth.2025.1552396
88
+ - Jiao, D. (2025). From hypoxic pockets to daily routines: linking brain oxygenation and cognitive resilience. Frontiers in Aging Neuroscience, 17, 1534198. doi: 10.3389/fnagi.2025.1534198
89
+ - Cognition and Brain Teaching Technology, a book on brain-based teaching and cognitive technology.
90
+ - Chinese translations of How Breakthroughs Happen (2020), Mastering the Dynamics of Innovation (2022), and Neuroscience of You (2025).
91
+
92
+ LinkedIn: https://www.linkedin.com/in/jason-jiao-2972141a4
93
+
94
+ ## Load Example
95
+
96
+ ```bash
97
+ pip install -r requirements.txt
98
+ python examples/load_checkpoint.py --checkpoint pytorch_model.bin
99
+ ```
100
+
101
+ ## Probe Example
102
+
103
+ ```bash
104
+ python probes/probe_memory_causality.py \
105
+ --checkpoint pytorch_model.bin \
106
+ --sidecar cedl_config.json \
107
+ --n-items 200 \
108
+ --seed 1
109
+ ```
110
+
111
+ ## Citation
112
+
113
+ If you use this checkpoint before the manuscript is published, cite it as:
114
+
115
+ ```bibtex
116
+ @misc{jiao2026cedl,
117
+ title = {CEDL: A Hippocampal-Inspired Language Model with Contextual Memory Readout},
118
+ author = {Jiao, Dian},
119
+ year = {2026},
120
+ note = {Research checkpoint for contextual memory-readout probes}
121
+ }
122
+ ```
cedl_config.json ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "CEDL",
3
+ "model_type": "cedl",
4
+ "architecture": "hippocampal-inspired contextual memory language model",
5
+ "vocab_size": 50257,
6
+ "max_position_embeddings": 1024,
7
+ "parameters": 136254705,
8
+ "checkpoint_file": "pytorch_model.bin",
9
+ "implementation_file": "CEDL.py",
10
+ "memory_readout": {
11
+ "enabled": true,
12
+ "source": "contextual_memory_state",
13
+ "mode": "contextual_direct",
14
+ "lambda_head": true,
15
+ "lambda_head_hidden": 160,
16
+ "lambda_head_bias_init": -7.0,
17
+ "lambda_head_w_init_std": 0.05,
18
+ "selection_objective": "binary_answer_background",
19
+ "background_target": 0.01,
20
+ "sparsity_weight": 0.05,
21
+ "sparsity_target": 0.05,
22
+ "memory_ce_weight": 1.0,
23
+ "pair_ce_weight": 5.0,
24
+ "source_adapter": true,
25
+ "context_adapter": true,
26
+ "no_injection": true
27
+ },
28
+ "mechanism_evidence": {
29
+ "current_vs_stale_causality": [
30
+ {
31
+ "seed": 1,
32
+ "N": 158,
33
+ "active_top1": 0.791,
34
+ "memory_off_top1": 0.0,
35
+ "delta_pp": 79.1,
36
+ "delta_margin": 8.266,
37
+ "t": 20.756,
38
+ "cohen_d": 1.656
39
+ },
40
+ {
41
+ "seed": 2,
42
+ "N": 164,
43
+ "active_top1": 0.732,
44
+ "memory_off_top1": 0.0,
45
+ "delta_pp": 73.2,
46
+ "delta_margin": 7.702,
47
+ "t": 18.912,
48
+ "cohen_d": 1.481
49
+ },
50
+ {
51
+ "seed": 3,
52
+ "N": 162,
53
+ "active_top1": 0.728,
54
+ "memory_off_top1": 0.0,
55
+ "delta_pp": 72.8,
56
+ "delta_margin": 7.756,
57
+ "t": 18.507,
58
+ "cohen_d": 1.459
59
+ }
60
+ ],
61
+ "memory_readout_diagnosis": [
62
+ {
63
+ "seed": 1,
64
+ "N": 158,
65
+ "trunk_top1_cur": 0.0,
66
+ "memory_top1_cur": 0.797,
67
+ "active_top1_cur": 0.791,
68
+ "memory_top1_stale": 0.006,
69
+ "current_rank_median": 1
70
+ },
71
+ {
72
+ "seed": 2,
73
+ "N": 164,
74
+ "trunk_top1_cur": 0.0,
75
+ "memory_top1_cur": 0.738,
76
+ "active_top1_cur": 0.732,
77
+ "memory_top1_stale": 0.006,
78
+ "current_rank_median": 1
79
+ },
80
+ {
81
+ "seed": 3,
82
+ "N": 162,
83
+ "trunk_top1_cur": 0.0,
84
+ "memory_top1_cur": 0.735,
85
+ "active_top1_cur": 0.728,
86
+ "memory_top1_stale": 0.012,
87
+ "current_rank_median": 1
88
+ }
89
+ ]
90
+ },
91
+ "general_eval": {
92
+ "wikitext103_test_ppl": 30.11,
93
+ "lambada": 0.159,
94
+ "hellaswag": 0.271,
95
+ "arc_easy": 0.281
96
+ },
97
+ "claim_boundary": "Research checkpoint for contextual memory readout; not a general downstream SOTA model."
98
+ }
config.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_type": "cedl",
3
+ "architectures": ["CEDLTwoLoop100M"],
4
+ "model_name": "CEDL",
5
+ "vocab_size": 50257,
6
+ "max_position_embeddings": 1024,
7
+ "parameters": 136254705,
8
+ "checkpoint_file": "pytorch_model.bin",
9
+ "sidecar_config_file": "cedl_config.json",
10
+ "implementation_file": "CEDL.py",
11
+ "public_description": "Hippocampal-inspired language model with contextual memory readout.",
12
+ "claim_boundary": "Research checkpoint for contextual memory readout; not a general downstream SOTA model."
13
+ }
14
+
data_v4c_pairs.py ADDED
@@ -0,0 +1,615 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """V4c synthetic-pair generator.
3
+
4
+ Generates short belief-revision / contrast / update prompts with EXPLICIT
5
+ token-position spans for query / current / stale / distractor / neutral.
6
+ Resolved against GPT-2 BPE at generation time so spans are guaranteed correct
7
+ when consumed by the V4c contrastive loss.
8
+
9
+ Five item families:
10
+ - but-update : "Alice owns hat. Bob owns pen. But Alice now owns car. What does Alice own now? Answer:"
11
+ - however-revision : "The capital was Bonn until 1990. However the capital is now Berlin. What is the capital? Answer:"
12
+ - temporal-update : "Previously the gate was A12. Currently it is C7. Which gate is current? Answer:"
13
+ - paraphrased-equiv. : update twice (different paraphrases) → multiple positives for SupCon
14
+ - neutral-control : "Carol owns book. Frank owns desk. What does Carol own? Answer:" (no update; supplies negatives only)
15
+
16
+ Critical span rule (causal LM constraint): `query` MUST be the question-side
17
+ entity occurrence (the "Alice" in "What does Alice own now?"), NOT the first
18
+ factual mention. Otherwise the model would be asked to pull an early Alice
19
+ token toward a future "car" token, which is causally unreachable.
20
+
21
+ Returns (ids, span_dict) where span_dict has keys:
22
+ family : str — one of the 5 family names
23
+ query : list[(start, end)] — token-position spans (end-exclusive)
24
+ current : list[(start, end)]
25
+ stale : list[(start, end)]
26
+ distractor : list[(start, end)] | empty
27
+ neutral : list[(start, end)] | empty
28
+ paraphrase_positives: list[(start, end)] | empty
29
+ For neutral-control: query/current/stale/distractor/paraphrase_positives all
30
+ empty; only `neutral` is populated.
31
+ """
32
+
33
+ from __future__ import annotations
34
+ from dataclasses import dataclass, field
35
+ from typing import Dict, List, Optional, Tuple
36
+ import random
37
+
38
+
39
+ NAMES = ["Alice", "Bob", "Carol", "Dave", "Eve", "Frank", "Grace",
40
+ "Hank", "Iris", "Jack", "Kate", "Leo", "Mia", "Nick",
41
+ "Olivia", "Paul", "Quinn", "Rosa", "Sam", "Tina",
42
+ "Uma", "Victor", "Wendy", "Xander", "Yara", "Zane",
43
+ "Adam", "Beth", "Clara", "Drew", "Ella", "Felix",
44
+ "Greta", "Henry", "Ivy", "Julian", "Kira", "Liam",
45
+ "Mona", "Noah"]
46
+ OBJECTS = ["car", "hat", "bag", "pen", "cup", "box", "ring", "lamp",
47
+ "book", "ball", "shoe", "coat", "desk", "bell", "fork",
48
+ "drum", "fish", "kite", "coin", "vase",
49
+ "rope", "horn", "tent", "boot", "scarf", "glove",
50
+ "torch", "mask", "wand", "shield", "spear", "axe",
51
+ "brush", "comb", "flute", "kettle", "knife", "ladle",
52
+ "mirror", "needle"]
53
+ CAPITALS_OLD = [
54
+ ("Bonn", "Berlin", "1990"),
55
+ ("Karachi", "Islamabad", "1960"),
56
+ ("Lagos", "Abuja", "1991"),
57
+ ("Rio de Janeiro", "Brasilia", "1960"),
58
+ ("Almaty", "Astana", "1997"),
59
+ ("Yangon", "Naypyidaw", "2006"),
60
+ ("Saint Petersburg", "Moscow", "1918"),
61
+ ("Philadelphia", "Washington", "1800"),
62
+ ("Kyoto", "Tokyo", "1868"),
63
+ ("Calcutta", "Delhi", "1911"),
64
+ ("Auckland", "Wellington", "1865"),
65
+ ("Melbourne", "Canberra", "1927"),
66
+ ("Salvador", "Rio de Janeiro", "1763"),
67
+ ("Toronto", "Ottawa", "1857"),
68
+ ("Bogota", "Lima", "1542"),
69
+ ("Quito", "Cuenca", "1830"),
70
+ ("Caracas", "Maracay", "1917"),
71
+ ("Asuncion", "Concepcion", "1811"),
72
+ ("Sucre", "La Paz", "1899"),
73
+ ("Cuzco", "Lima", "1535"),
74
+ ("Tunis", "Carthage", "1881"),
75
+ ("Algiers", "Oran", "1962"),
76
+ ("Tripoli", "Benghazi", "1969"),
77
+ ("Aden", "Sanaa", "1990"),
78
+ ("Beirut", "Damascus", "1944"),
79
+ ("Vienna", "Salzburg", "1918"),
80
+ ("Krakow", "Warsaw", "1596"),
81
+ ("Helsinki", "Turku", "1812"),
82
+ ("Stockholm", "Uppsala", "1523"),
83
+ ("Trondheim", "Oslo", "1814"),
84
+ ]
85
+ def _build_gate_pairs():
86
+ letters = list("ABCDEFGHJKLMNPQRSTUVWXYZ")
87
+ numbers = list(range(1, 21))
88
+ rng = random.Random(20260526)
89
+ pairs = []
90
+ for _ in range(260):
91
+ L_old, L_new = rng.sample(letters, 2)
92
+ n_old, n_new = rng.sample(numbers, 2)
93
+ pairs.append((f"{L_old}{n_old}", f"{L_new}{n_new}"))
94
+ return pairs
95
+ GATES = _build_gate_pairs()
96
+ PARAPHRASE_OLD_CITIES = [
97
+ "Paris", "London", "Madrid", "Rome", "Vienna",
98
+ "Athens", "Lisbon", "Prague", "Warsaw", "Helsinki",
99
+ "Dublin", "Brussels", "Amsterdam", "Stockholm", "Copenhagen",
100
+ "Budapest", "Sofia", "Bucharest", "Zagreb", "Belgrade",
101
+ ]
102
+ PARAPHRASE_NEW_CITIES = [
103
+ "Berlin", "Tokyo", "Seoul", "Cairo", "Oslo",
104
+ "Manila", "Bangkok", "Hanoi", "Jakarta", "Singapore",
105
+ "Mumbai", "Lagos", "Nairobi", "Dakar", "Accra",
106
+ "Lima", "Bogota", "Quito", "Santiago", "Montevideo",
107
+ ]
108
+ HOWEVER_SUBJECTS = [
109
+ "capital", "headquarters", "embassy",
110
+ "consulate", "annex", "secretariat",
111
+ "garrison", "outpost", "depot",
112
+ "treasury", "archive", "registry",
113
+ "tribunal", "synod", "academy",
114
+ "observatory", "arena", "fortress",
115
+ "monastery", "citadel",
116
+ ]
117
+ GATE_SUBJECTS = [
118
+ "gate", "code", "room", "key", "slot",
119
+ "platform", "channel", "pin", "lock", "terminal",
120
+ "passage", "station", "dock", "port",
121
+ "panel", "module", "bay", "console",
122
+ ]
123
+
124
+
125
+ VOCAB_SPLIT_SEED = 20260528
126
+ _TRAIN_FRAC = 0.70
127
+
128
+
129
+ def _partition(pool, offset):
130
+ """Split a pool of ATOMIC SURFACE STRINGS ~70/30. Dedups + sorts first so
131
+ the partition is over unique surfaces (review finding: tuple-level splitting
132
+ of CAPITALS_OLD/GATES leaked the old/new strings shared across tuples — the
133
+ surfaces, not the tuples, are the entities under the novel-entity claim)."""
134
+ uniq = sorted(set(pool))
135
+ idx = list(range(len(uniq)))
136
+ random.Random(VOCAB_SPLIT_SEED + offset).shuffle(idx)
137
+ n_tr = max(1, int(round(_TRAIN_FRAC * len(uniq))))
138
+ tr = set(idx[:n_tr])
139
+ train = [uniq[i] for i in range(len(uniq)) if i in tr]
140
+ test = [uniq[i] for i in range(len(uniq)) if i not in tr]
141
+ return train, test
142
+
143
+
144
+ _CITIES = ([c for (_o, _n, _y) in CAPITALS_OLD for c in (_o, _n)]
145
+ + PARAPHRASE_OLD_CITIES + PARAPHRASE_NEW_CITIES)
146
+ _GATE_CODES = [c for (_o, _n) in GATES for c in (_o, _n)]
147
+ YEARS = sorted({_y for (_o, _n, _y) in CAPITALS_OLD})
148
+
149
+ _POOL_REGISTRY = {
150
+ "NAMES": NAMES, "OBJECTS": OBJECTS,
151
+ "CITIES": _CITIES, "GATE_CODES": _GATE_CODES,
152
+ "HOWEVER_SUBJECTS": HOWEVER_SUBJECTS, "GATE_SUBJECTS": GATE_SUBJECTS,
153
+ }
154
+ _SPLITS = {}
155
+ for _off, (_nm, _pool) in enumerate(_POOL_REGISTRY.items()):
156
+ _tr, _te = _partition(_pool, _off)
157
+ _SPLITS[_nm] = {"train": _tr, "test": _te, "all": sorted(set(_pool))}
158
+
159
+
160
+ def _P(name, split):
161
+ """Pool `name` restricted to vocabulary `split`. Raises on an unknown split
162
+ (review finding: a silent fallback to 'all' would leak train/test vocab)."""
163
+ if split not in ("train", "test", "all"):
164
+ raise ValueError(
165
+ f"unknown vocab split {split!r}; expected 'train'/'test'/'all'")
166
+ return _SPLITS[name][split]
167
+
168
+
169
+ def vocab_split_summary():
170
+ """Sizes of each pool per split — for sanity logging."""
171
+ return {nm: {s: len(_SPLITS[nm][s]) for s in ("train", "test", "all")}
172
+ for nm in _SPLITS}
173
+
174
+
175
+ def vocab_split_overlap():
176
+ """Global train∩test surface-string overlap across ALL pools. Should be
177
+ empty — validates the 'novel entity' claim end to end."""
178
+ tr, te = set(), set()
179
+ for nm in _SPLITS:
180
+ tr |= set(_SPLITS[nm]["train"])
181
+ te |= set(_SPLITS[nm]["test"])
182
+ return sorted(tr & te)
183
+
184
+
185
+ SpanT = Tuple[int, int]
186
+
187
+
188
+ @dataclass
189
+ class V4cItem:
190
+ """One synthetic contrastive pair with token-position spans."""
191
+ family: str
192
+ ids: List[int]
193
+ query: List[SpanT] = field(default_factory=list)
194
+ current: List[SpanT] = field(default_factory=list)
195
+ stale: List[SpanT] = field(default_factory=list)
196
+ distractor: List[SpanT] = field(default_factory=list)
197
+ neutral: List[SpanT] = field(default_factory=list)
198
+ paraphrase_positives: List[SpanT] = field(default_factory=list)
199
+ original_length: int = 0
200
+
201
+
202
+ def _tok_span(tokenizer, full_ids: List[int], substring: str,
203
+ search_from: int = 0) -> Optional[SpanT]:
204
+ """Locate `substring` in the decoded text and return its token-position
205
+ span. Returns None if not found. `search_from` lets the caller bias the
206
+ search past earlier occurrences (e.g., the question-side query token must
207
+ come AFTER both current and stale mentions)."""
208
+ encoded = tokenizer.encode(" " + substring, add_special_tokens=False)
209
+ if not encoded:
210
+ encoded = tokenizer.encode(substring, add_special_tokens=False)
211
+ if not encoded:
212
+ return None
213
+ target_len = len(encoded)
214
+ for start in range(search_from, len(full_ids) - target_len + 1):
215
+ if full_ids[start:start + target_len] == encoded:
216
+ return (start, start + target_len)
217
+ encoded_bare = tokenizer.encode(substring, add_special_tokens=False)
218
+ if encoded_bare:
219
+ L = len(encoded_bare)
220
+ for start in range(search_from, len(full_ids) - L + 1):
221
+ if full_ids[start:start + L] == encoded_bare:
222
+ return (start, start + L)
223
+ return None
224
+
225
+
226
+ def _last_tok_span(tokenizer, full_ids: List[int], substring: str) -> Optional[SpanT]:
227
+ """Find the LAST occurrence of `substring` in token-position space."""
228
+ encoded = tokenizer.encode(" " + substring, add_special_tokens=False)
229
+ if not encoded:
230
+ return None
231
+ L = len(encoded)
232
+ found = None
233
+ for start in range(0, len(full_ids) - L + 1):
234
+ if full_ids[start:start + L] == encoded:
235
+ found = (start, start + L)
236
+ if found is None:
237
+ encoded_bare = tokenizer.encode(substring, add_special_tokens=False)
238
+ if encoded_bare:
239
+ L = len(encoded_bare)
240
+ for start in range(0, len(full_ids) - L + 1):
241
+ if full_ids[start:start + L] == encoded_bare:
242
+ found = (start, start + L)
243
+ return found
244
+
245
+
246
+ def gen_but_update(tokenizer, rng: random.Random, split: str = "all",
247
+ force_stale_obj: str = None) -> V4cItem:
248
+ """Alice owns hat. Bob owns pen. Carol owns cup. But Alice now owns car.
249
+ What does Alice own now? Answer:
250
+
251
+ `force_stale_obj` (hard-collision support): pin the stale object to a given
252
+ token (e.g. another item's CURRENT object) so the same token appears in
253
+ opposite roles across the batch — forcing role-by-position, not token id."""
254
+ n_distract = 2
255
+ names = rng.sample(_P("NAMES", split), n_distract + 1)
256
+ if force_stale_obj is not None:
257
+ others = rng.sample(
258
+ [o for o in _P("OBJECTS", split) if o != force_stale_obj],
259
+ n_distract + 1)
260
+ objs = [force_stale_obj] + others
261
+ else:
262
+ objs = rng.sample(_P("OBJECTS", split), n_distract + 2)
263
+ subject = names[0]
264
+ stale_obj = objs[0]
265
+ new_obj = objs[-1]
266
+ distractor_obj = objs[1] if n_distract > 0 else objs[0]
267
+ facts = [f"{names[i]} owns a {objs[i]}" for i in range(n_distract + 1)]
268
+ rng.shuffle(facts)
269
+ text = (". ".join(facts) + ". "
270
+ + f"But {subject} now owns a {new_obj}. "
271
+ + f"What does {subject} own now? Answer:")
272
+ ids = tokenizer.encode(text, add_special_tokens=False)
273
+
274
+ current_span = _tok_span(tokenizer, ids, new_obj)
275
+ stale_span = _tok_span(tokenizer, ids, stale_obj)
276
+ distractor_span = _tok_span(tokenizer, ids, distractor_obj)
277
+ query_span = _last_tok_span(tokenizer, ids, subject)
278
+ return V4cItem(
279
+ family="but_update",
280
+ ids=ids,
281
+ query=[query_span] if query_span else [],
282
+ current=[current_span] if current_span else [],
283
+ stale=[stale_span] if stale_span else [],
284
+ distractor=[distractor_span] if distractor_span else [],
285
+ original_length=len(ids),
286
+ )
287
+
288
+
289
+ def gen_however_revision(tokenizer, rng: random.Random,
290
+ split: str = "all",
291
+ force_subject: str = None) -> V4cItem:
292
+ """The headquarters was Bonn until 1990. However the headquarters is now
293
+ Berlin. What is the headquarters? Answer:
294
+
295
+ Subject noun varies across items to avoid (query=`capital`, current=X)
296
+ collisions when the same `current` city is reused. With ~14 subjects ×
297
+ 30 (old, new, year) triples, paired-collision rate stays under birthday-
298
+ paradox bounds for 100-150 items."""
299
+ old, new = rng.sample(_P("CITIES", split), 2)
300
+ year = rng.choice(YEARS)
301
+ subject = (force_subject if force_subject is not None
302
+ else rng.choice(_P("HOWEVER_SUBJECTS", split)))
303
+ text = (f"The {subject} was {old} until {year}. "
304
+ f"However the {subject} is now {new}. "
305
+ f"What is the {subject}? Answer:")
306
+ ids = tokenizer.encode(text, add_special_tokens=False)
307
+ current_span = _tok_span(tokenizer, ids, new)
308
+ stale_span = _tok_span(tokenizer, ids, old)
309
+ query_span = _last_tok_span(tokenizer, ids, subject)
310
+ return V4cItem(
311
+ family="however_revision",
312
+ ids=ids,
313
+ query=[query_span] if query_span else [],
314
+ current=[current_span] if current_span else [],
315
+ stale=[stale_span] if stale_span else [],
316
+ original_length=len(ids),
317
+ )
318
+
319
+
320
+ def gen_temporal_update(tokenizer, rng: random.Random,
321
+ split: str = "all",
322
+ force_subject: str = None) -> V4cItem:
323
+ """Previously the platform was A12. Currently the platform is C7. Which
324
+ platform is current now? Answer:
325
+
326
+ Subject noun varies; combined with 260-pair GATES pool gives
327
+ ~18 × 260 = 4680 unique (subject, current) combinations."""
328
+ old, new = rng.sample(_P("GATE_CODES", split), 2)
329
+ subject = (force_subject if force_subject is not None
330
+ else rng.choice(_P("GATE_SUBJECTS", split)))
331
+ text = (f"Previously the {subject} was {old}. "
332
+ f"Currently the {subject} is {new}. "
333
+ f"Which {subject} is current now? Answer:")
334
+ ids = tokenizer.encode(text, add_special_tokens=False)
335
+ current_span = _tok_span(tokenizer, ids, new)
336
+ stale_span = _tok_span(tokenizer, ids, old)
337
+ query_span = _last_tok_span(tokenizer, ids, subject)
338
+ return V4cItem(
339
+ family="temporal_update",
340
+ ids=ids,
341
+ query=[query_span] if query_span else [],
342
+ current=[current_span] if current_span else [],
343
+ stale=[stale_span] if stale_span else [],
344
+ original_length=len(ids),
345
+ )
346
+
347
+
348
+ def gen_paraphrased_equiv(tokenizer, rng: random.Random,
349
+ split: str = "all") -> V4cItem:
350
+ """Same entity, two paraphrased questions → two query positives.
351
+
352
+ Carol used to live in Paris. She moved to Berlin. Where does Carol live now?
353
+ Carol resides in Berlin currently. What city is Carol's home? Answer:
354
+ """
355
+ person = rng.choice(_P("NAMES", split))
356
+ old_city, new_city = rng.sample(_P("CITIES", split), 2)
357
+ text = (f"{person} used to live in {old_city}. "
358
+ f"{person} moved to {new_city}. "
359
+ f"Where does {person} live now? "
360
+ f"{person} resides in {new_city} currently. "
361
+ f"What city is {person}'s home? Answer:")
362
+ ids = tokenizer.encode(text, add_special_tokens=False)
363
+ current_first = _tok_span(tokenizer, ids, new_city)
364
+ stale_span = _tok_span(tokenizer, ids, old_city)
365
+ current_second = None
366
+ if current_first is not None:
367
+ current_second = _tok_span(tokenizer, ids, new_city,
368
+ search_from=current_first[1])
369
+ query_span = _last_tok_span(tokenizer, ids, person)
370
+ item = V4cItem(
371
+ family="paraphrased_equiv",
372
+ ids=ids,
373
+ query=[query_span] if query_span else [],
374
+ current=[current_first] if current_first else [],
375
+ stale=[stale_span] if stale_span else [],
376
+ original_length=len(ids),
377
+ )
378
+ if current_second is not None:
379
+ item.paraphrase_positives.append(current_second)
380
+ return item
381
+
382
+
383
+ def gen_neutral_control(tokenizer, rng: random.Random,
384
+ split: str = "all") -> V4cItem:
385
+ """No update; supplies negatives only. The whole text serves as a single
386
+ `neutral` span pool — anchor + positive roles are skipped by the loss."""
387
+ n = 3
388
+ names = rng.sample(_P("NAMES", split), n)
389
+ objs = rng.sample(_P("OBJECTS", split), n)
390
+ facts = [f"{names[i]} owns a {objs[i]}" for i in range(n)]
391
+ text = ". ".join(facts) + ". "
392
+ text += f"What does {names[0]} own? Answer:"
393
+ ids = tokenizer.encode(text, add_special_tokens=False)
394
+ return V4cItem(
395
+ family="neutral_control",
396
+ ids=ids,
397
+ neutral=[(0, len(ids))],
398
+ original_length=len(ids),
399
+ )
400
+
401
+
402
+ _FAMILIES = [
403
+ ("but_update", gen_but_update, 0.30),
404
+ ("however_revision", gen_however_revision, 0.20),
405
+ ("temporal_update", gen_temporal_update, 0.20),
406
+ ("paraphrased_equiv", gen_paraphrased_equiv, 0.10),
407
+ ("neutral_control", gen_neutral_control, 0.20),
408
+ ]
409
+
410
+
411
+ _FAMILY_FNS = {name: fn for name, fn, _ in _FAMILIES}
412
+
413
+
414
+ def make_entity_swap(item: "V4cItem", tokenizer, rng: random.Random,
415
+ split: str = "train") -> "V4cItem":
416
+ """Structurally-identical twin of `item`: SAME family AND template subtype,
417
+ only the entity FILLERS swapped, with its OWN recomputed spans (review
418
+ Blocker 3 — preserve role structure, NOT span offsets). For however/temporal
419
+ the template subtype is the query subject-noun (capital/gate/…), so it is
420
+ PRESERVED and only the cities/codes are freshly sampled (review finding 2:
421
+ a same-family resample that also changed the subject is broader than
422
+ 'same template, fresh entities'). Used by the V4e swap-consistency loss:
423
+ g at the current/stale role spans must MATCH across this swap, forcing
424
+ v_vec PRODUCTION to key on STRUCTURE not entity id."""
425
+ fam = item.family
426
+ if fam in ("however_revision", "temporal_update") and item.query:
427
+ qs, qe = item.query[0]
428
+ subj = tokenizer.decode(item.ids[qs:qe]).strip()
429
+ fn = (gen_however_revision if fam == "however_revision"
430
+ else gen_temporal_update)
431
+ return fn(tokenizer, rng, split, force_subject=subj)
432
+ fn = _FAMILY_FNS.get(fam, gen_but_update)
433
+ return fn(tokenizer, rng, split)
434
+
435
+
436
+ def generate(tokenizer, n: int, seed: int = 42,
437
+ neutral_cap: float = 0.25, split: str = "all",
438
+ hard_collision_frac: float = 0.0,
439
+ family_weights: Optional[Dict[str, float]] = None
440
+ ) -> List[V4cItem]:
441
+ """Generate `n` V4c items by family with the weights above. neutral_cap
442
+ bounds the neutral_control fraction (since neutrals can't be anchors and
443
+ >25% neutrals starves SupCon supervision).
444
+
445
+ split: 'train'/'test'/'all' vocabulary partition (Correction 4 — novel
446
+ entity generalization is tested by training on 'train', probing 'test').
447
+ hard_collision_frac: fraction of but_update items whose STALE object is
448
+ pinned to a recently-seen CURRENT object (Correction 5 — same token in
449
+ opposite roles across the batch, forcing role-by-position not token id).
450
+ within-item current==stale is never created (audit hard-fails it)."""
451
+ rng = random.Random(seed)
452
+ _resolved = []
453
+ for name, fn, w in _FAMILIES:
454
+ if family_weights is not None and name in family_weights:
455
+ w = float(family_weights[name])
456
+ _resolved.append((name, fn, w))
457
+ families_cumweights = []
458
+ cum = 0.0
459
+ for name, fn, w in _resolved:
460
+ cum += w
461
+ families_cumweights.append((name, fn, cum))
462
+ items: List[V4cItem] = []
463
+ n_neutral = 0
464
+ n_neutral_cap = int(n * neutral_cap)
465
+ recent_currents: List[str] = []
466
+ while len(items) < n:
467
+ r = rng.random() * cum
468
+ for name, fn, cwt in families_cumweights:
469
+ if r <= cwt:
470
+ if name == "neutral_control" and n_neutral >= n_neutral_cap:
471
+ fn = gen_but_update
472
+ name = "but_update"
473
+ if (name == "but_update" and recent_currents
474
+ and rng.random() < hard_collision_frac):
475
+ forced = rng.choice(recent_currents)
476
+ item = gen_but_update(tokenizer, rng, split,
477
+ force_stale_obj=forced)
478
+ else:
479
+ item = fn(tokenizer, rng, split)
480
+ items.append(item)
481
+ if item.family == "neutral_control":
482
+ n_neutral += 1
483
+ if item.family == "but_update" and item.current:
484
+ cs, ce = item.current[0]
485
+ recent_currents.append(
486
+ tokenizer.decode(item.ids[cs:ce]).strip())
487
+ recent_currents = recent_currents[-32:]
488
+ break
489
+ return items
490
+
491
+
492
+ def audit(items: List[V4cItem], tokenizer, *, verbose: bool = False
493
+ ) -> Dict[str, object]:
494
+ """Family-aware audit. Returns a report dict; `verbose` prints failures."""
495
+ report = {
496
+ "total": len(items),
497
+ "by_family": {},
498
+ "hard_fails": [],
499
+ "duplicate_collisions": 0,
500
+ "n_anchorable": 0,
501
+ "duplicate_collisions_by_family": {},
502
+ "top_repeated_pairs": [],
503
+ }
504
+ for it in items:
505
+ report["by_family"][it.family] = report["by_family"].get(it.family, 0) + 1
506
+
507
+ seen_keys = {}
508
+ pair_counter = {}
509
+ for i, it in enumerate(items):
510
+ ids_set = set(it.ids)
511
+ for span_list in [it.query, it.current, it.stale, it.distractor,
512
+ it.neutral, it.paraphrase_positives]:
513
+ for s, e in span_list:
514
+ if not (0 <= s < e <= len(it.ids)):
515
+ report["hard_fails"].append((i, f"span out of bounds: ({s},{e}) len={len(it.ids)}"))
516
+
517
+ if it.family == "neutral_control":
518
+ if not it.neutral:
519
+ report["hard_fails"].append((i, "neutral_control with empty neutral"))
520
+ for fname in ("query", "current", "stale", "distractor",
521
+ "paraphrase_positives"):
522
+ if getattr(it, fname):
523
+ report["hard_fails"].append((i, f"neutral_control has non-empty {fname}"))
524
+ else:
525
+ if not it.query:
526
+ report["hard_fails"].append((i, f"{it.family}: empty query"))
527
+ continue
528
+ if not it.current:
529
+ report["hard_fails"].append((i, f"{it.family}: empty current"))
530
+ continue
531
+ if not it.stale:
532
+ report["hard_fails"].append((i, f"{it.family}: empty stale"))
533
+ continue
534
+ current_text = tokenizer.decode(
535
+ [t for s, e in it.current for t in it.ids[s:e]]).strip()
536
+ stale_text = tokenizer.decode(
537
+ [t for s, e in it.stale for t in it.ids[s:e]]).strip()
538
+ if current_text == stale_text:
539
+ report["hard_fails"].append(
540
+ (i, f"{it.family}: current surface == stale surface "
541
+ f"({current_text!r})"))
542
+ for s, e in it.distractor:
543
+ d_text = tokenizer.decode(it.ids[s:e]).strip()
544
+ if d_text == current_text or d_text == stale_text:
545
+ report["hard_fails"].append(
546
+ (i, f"{it.family}: distractor surface matches "
547
+ f"current/stale ({d_text!r})"))
548
+ break
549
+ q_start = it.query[0][0]
550
+ c_max = max(e for s, e in it.current)
551
+ s_max = max(e for s, e in it.stale)
552
+ if not (q_start >= c_max and q_start >= s_max):
553
+ report["hard_fails"].append((
554
+ i, f"{it.family}: query_pos={q_start} not > "
555
+ f"current_end={c_max} / stale_end={s_max}"))
556
+ else:
557
+ report["n_anchorable"] += 1
558
+
559
+ q_tokens = tuple(it.ids[it.query[0][0]:it.query[0][1]])
560
+ c_tokens = tuple(it.ids[it.current[0][0]:it.current[0][1]])
561
+ key = (q_tokens, c_tokens)
562
+ if key in seen_keys:
563
+ report["duplicate_collisions"] += 1
564
+ report["duplicate_collisions_by_family"][it.family] = (
565
+ report["duplicate_collisions_by_family"].get(it.family, 0) + 1)
566
+ seen_keys[key] = True
567
+ q_decoded = tokenizer.decode(list(q_tokens)).strip()
568
+ c_decoded = tokenizer.decode(list(c_tokens)).strip()
569
+ pair_key = (it.family, q_decoded, c_decoded)
570
+ pair_counter[pair_key] = pair_counter.get(pair_key, 0) + 1
571
+
572
+ sorted_pairs = sorted(pair_counter.items(), key=lambda kv: -kv[1])
573
+ report["top_repeated_pairs"] = [
574
+ {"family": k[0], "query": k[1], "current": k[2], "count": v}
575
+ for k, v in sorted_pairs[:10] if v > 1
576
+ ]
577
+
578
+ current_surfaces = set()
579
+ for it in items:
580
+ if it.current:
581
+ s, e = it.current[0]
582
+ current_surfaces.add(tokenizer.decode(it.ids[s:e]).strip())
583
+ n_anchor = 0
584
+ n_role_collision = 0
585
+ for it in items:
586
+ if it.family == "neutral_control" or not it.current or not it.stale:
587
+ continue
588
+ n_anchor += 1
589
+ neg_surfaces = []
590
+ s, e = it.stale[0]
591
+ neg_surfaces.append(tokenizer.decode(it.ids[s:e]).strip())
592
+ for s, e in it.distractor:
593
+ neg_surfaces.append(tokenizer.decode(it.ids[s:e]).strip())
594
+ if any(ns in current_surfaces for ns in neg_surfaces):
595
+ n_role_collision += 1
596
+ report["role_collision_rate"] = (n_role_collision / n_anchor
597
+ if n_anchor else 0.0)
598
+ report["role_collision_count"] = n_role_collision
599
+
600
+ if verbose and report["hard_fails"]:
601
+ for i, reason in report["hard_fails"][:20]:
602
+ print(f" HARD-FAIL item {i}: {reason}")
603
+ if len(report["hard_fails"]) > 20:
604
+ print(f" ... ({len(report['hard_fails']) - 20} more)")
605
+ return report
606
+
607
+
608
+ def decode_spans(item: V4cItem, tokenizer) -> Dict[str, List[str]]:
609
+ """Decode each span family back into surface text for visual audit."""
610
+ out = {}
611
+ for fname in ("query", "current", "stale", "distractor", "neutral",
612
+ "paraphrase_positives"):
613
+ out[fname] = [tokenizer.decode(item.ids[s:e]).strip()
614
+ for s, e in getattr(item, fname)]
615
+ return out
examples/load_checkpoint.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import torch
7
+
8
+ ROOT = Path(__file__).resolve().parents[1]
9
+ if str(ROOT) not in sys.path:
10
+ sys.path.insert(0, str(ROOT))
11
+
12
+ from CEDL import build_model
13
+
14
+
15
+ def constructor_kwargs(config_path):
16
+ with open(config_path) as f:
17
+ cfg = json.load(f)
18
+ mem = cfg.get("memory_readout", {})
19
+ source_name = str(mem.get("source", "contextual_memory_state"))
20
+ source_map = {
21
+ "contextual_memory_state": "q_mem",
22
+ "decoder_state": "h_d",
23
+ "expanded_state": "h_e",
24
+ "attractor_state": "q_attractor",
25
+ "q_mem": "q_mem",
26
+ }
27
+ return dict(
28
+ lambda_head=bool(mem.get("lambda_head", True)),
29
+ lambda_head_hidden=int(mem.get("lambda_head_hidden", 160)),
30
+ lambda_head_bias_init=float(mem.get("lambda_head_bias_init", -7.0)),
31
+ lambda_head_w_init_std=float(
32
+ mem.get("lambda_head_w_init_std", 0.05)),
33
+ bce_objective=(
34
+ mem.get("selection_objective") == "binary_answer_background"),
35
+ sel_weight=1.0,
36
+ bg_weight=1.0,
37
+ bg_target=float(mem.get("background_target", 0.01)),
38
+ wt_sparsity_weight=float(mem.get("sparsity_weight", 0.05)),
39
+ wt_sparsity_target=float(mem.get("sparsity_target", 0.05)),
40
+ memory_head_enabled=bool(mem.get("enabled", True)),
41
+ memory_ce_weight=float(mem.get("memory_ce_weight", 1.0)),
42
+ memory_pair_ce_weight=float(mem.get("pair_ce_weight", 5.0)),
43
+ memory_query_source=source_map.get(source_name, source_name),
44
+ memory_readout_mode="direct",
45
+ source_adapter=bool(mem.get("source_adapter", True)),
46
+ context_adapter=bool(mem.get("context_adapter", True)),
47
+ specialist_noinject=bool(mem.get("no_injection", True)),
48
+ )
49
+
50
+
51
+ def unwrap_state_dict(obj):
52
+ if isinstance(obj, dict) and "model" in obj and isinstance(obj["model"], dict):
53
+ return obj["model"]
54
+ return obj
55
+
56
+
57
+ def main():
58
+ parser = argparse.ArgumentParser()
59
+ parser.add_argument("--checkpoint", default="pytorch_model.bin")
60
+ parser.add_argument("--config", default="cedl_config.json")
61
+ parser.add_argument("--device", default="cpu")
62
+ args = parser.parse_args()
63
+
64
+ ckpt_path = Path(args.checkpoint)
65
+ if not ckpt_path.exists():
66
+ raise FileNotFoundError(f"Checkpoint not found: {ckpt_path}")
67
+ cfg_path = Path(args.config)
68
+ if not cfg_path.exists():
69
+ raise FileNotFoundError(f"Config not found: {cfg_path}")
70
+
71
+ model = build_model(
72
+ "CEDL",
73
+ vocab=50257,
74
+ max_seq=1024,
75
+ **constructor_kwargs(cfg_path),
76
+ )
77
+
78
+ state = unwrap_state_dict(torch.load(ckpt_path, map_location="cpu"))
79
+ result = model.load_state_dict(state, strict=True)
80
+ model.to(args.device)
81
+ model.eval()
82
+
83
+ n_params = sum(p.numel() for p in model.parameters())
84
+ print(f"Loaded {ckpt_path}")
85
+ print(f"Parameters: {n_params:,}")
86
+ print(f"Missing keys: {len(result.missing_keys)}")
87
+ print(f"Unexpected keys: {len(result.unexpected_keys)}")
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
probes/probe_memory_causality.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Memory-readout causality probe.
2
+
3
+ Runs the same current-vs-stale items through the live CEDL memory path and a
4
+ memory-off ablation. The paired comparison estimates whether contextual memory
5
+ readout causally improves current-token selection.
6
+
7
+ Usage:
8
+ python probes/probe_memory_causality.py \
9
+ --checkpoint pytorch_model.bin \
10
+ --sidecar cedl_config.json \
11
+ --n-items 50
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import sys
18
+
19
+ import numpy as np
20
+ import torch
21
+ import torch.nn as nn
22
+
23
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
24
+ sys.path.insert(0, "/content")
25
+
26
+ import CEDL
27
+ import data_v4c_pairs as v4c
28
+
29
+
30
+ MEMORY_GATE_ATTR = "v" + "6_lambda_head"
31
+
32
+
33
+ class ZeroLambdaHead(nn.Module):
34
+ """Forces the memory mixture gate near zero."""
35
+ def forward(self, h):
36
+ return torch.full(
37
+ h.shape[:-1] + (1,), -100.0,
38
+ device=h.device, dtype=h.dtype,
39
+ )
40
+
41
+
42
+ def load_sidecar_constructor_kwargs(sidecar_path):
43
+ """Read public CEDL config and return constructor keyword arguments."""
44
+ with open(sidecar_path) as f:
45
+ sc = json.load(f)
46
+ mem = sc.get("memory_readout")
47
+ if not isinstance(mem, dict):
48
+ raise ValueError("Expected cedl_config.json with a memory_readout block.")
49
+ source_name = str(mem.get("source", "contextual_memory_state"))
50
+ source_map = {
51
+ "contextual_memory_state": "q_mem",
52
+ "decoder_state": "h_d",
53
+ "expanded_state": "h_e",
54
+ "attractor_state": "q_attractor",
55
+ "q_mem": "q_mem",
56
+ }
57
+ return dict(
58
+ lambda_head=bool(mem.get("lambda_head", True)),
59
+ lambda_head_hidden=int(mem.get("lambda_head_hidden", 160)),
60
+ lambda_head_bias_init=float(mem.get("lambda_head_bias_init", -7.0)),
61
+ lambda_head_w_init_std=float(
62
+ mem.get("lambda_head_w_init_std", 0.05)),
63
+ bce_objective=(
64
+ mem.get("selection_objective") == "binary_answer_background"),
65
+ sel_weight=1.0,
66
+ bg_weight=1.0,
67
+ bg_target=float(mem.get("background_target", 0.01)),
68
+ wt_sparsity_weight=float(mem.get("sparsity_weight", 0.05)),
69
+ wt_sparsity_target=float(mem.get("sparsity_target", 0.05)),
70
+ memory_head_enabled=bool(mem.get("enabled", True)),
71
+ memory_ce_weight=float(mem.get("memory_ce_weight", 1.0)),
72
+ memory_pair_ce_weight=float(mem.get("pair_ce_weight", 5.0)),
73
+ memory_query_source=source_map.get(source_name, source_name),
74
+ memory_readout_mode="direct",
75
+ source_adapter=bool(mem.get("source_adapter", True)),
76
+ context_adapter=bool(mem.get("context_adapter", True)),
77
+ specialist_noinject=bool(mem.get("no_injection", True)),
78
+ )
79
+
80
+
81
+ def model_forward_logits(m, ids_b):
82
+ """Call m(ids_b) and return the logits tensor [B, T, V] regardless of
83
+ whether forward returns logits-only or (logits, aux_loss)."""
84
+ out = m(ids_b)
85
+ if isinstance(out, tuple):
86
+ logits = out[0]
87
+ else:
88
+ logits = out
89
+ return logits
90
+
91
+
92
+ def main():
93
+ p = argparse.ArgumentParser()
94
+ p.add_argument("--checkpoint", type=str, default="pytorch_model.bin")
95
+ p.add_argument("--sidecar", type=str, default="cedl_config.json")
96
+ p.add_argument("--n-items", type=int, default=50)
97
+ p.add_argument("--seed", type=int, default=0)
98
+ args = p.parse_args()
99
+
100
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
101
+ print(f"[setup] device={device}")
102
+ print(f"[setup] checkpoint={args.checkpoint}")
103
+ print(f"[setup] sidecar={args.sidecar}")
104
+ print(f"[setup] n_items={args.n_items}")
105
+
106
+ model_kwargs = load_sidecar_constructor_kwargs(args.sidecar)
107
+ print(f"[setup] memory_head={model_kwargs['lambda_head']} "
108
+ f"hidden={model_kwargs['lambda_head_hidden']} "
109
+ f"bias_init={model_kwargs['lambda_head_bias_init']} "
110
+ f"sparsity_weight={model_kwargs['wt_sparsity_weight']} "
111
+ f"sparsity_target={model_kwargs['wt_sparsity_target']} "
112
+ f"memory_source={model_kwargs['memory_query_source']} "
113
+ f"readout_mode={model_kwargs['memory_readout_mode']}")
114
+
115
+ m = CEDL.build_model("CEDL", vocab=50257, max_seq=1024, **model_kwargs)
116
+ m = m.to(device).eval()
117
+
118
+ state = torch.load(args.checkpoint, map_location="cpu", weights_only=True)
119
+ msd = state["model"] if isinstance(state, dict) and "model" in state else state
120
+ if any(k.startswith("_orig_mod.") for k in msd):
121
+ msd = {k.replace("_orig_mod.", ""): v for k, v in msd.items()}
122
+ res = m.load_state_dict(msd, strict=True)
123
+ print(f"[load] strict OK (missing={len(res.missing_keys)} unexpected={len(res.unexpected_keys)})")
124
+
125
+ m.feedback_alpha.fill_(1.0)
126
+ if hasattr(m, "sl_alpha"):
127
+ m.sl_alpha.fill_(1.0)
128
+
129
+ from transformers import GPT2TokenizerFast
130
+ tok = GPT2TokenizerFast.from_pretrained("gpt2")
131
+ print(f"\n[v4c] generating {args.n_items} items (seed={args.seed})...")
132
+ items = v4c.generate(tok, n=args.n_items, seed=args.seed)
133
+ print(f"[v4c] generated {len(items)} raw items")
134
+
135
+ saved_head = getattr(m, MEMORY_GATE_ATTR)
136
+ zero_head = ZeroLambdaHead().to(device)
137
+
138
+ margin_active = []
139
+ margin_off = []
140
+ pred_correct_active = []
141
+ pred_correct_off = []
142
+ lam_at_ans_active = []
143
+ n_skipped = 0
144
+ skip_reasons = {"neutral_control": 0, "no_cur_stale": 0,
145
+ "bad_length": 0, "same_token": 0}
146
+
147
+ for it_idx, it in enumerate(items):
148
+ if it.family == "neutral_control":
149
+ n_skipped += 1; skip_reasons["neutral_control"] += 1; continue
150
+ if not it.current or not it.stale:
151
+ n_skipped += 1; skip_reasons["no_cur_stale"] += 1; continue
152
+ ol = getattr(it, "original_length", 0)
153
+ if ol <= 1 or ol > 1024:
154
+ n_skipped += 1; skip_reasons["bad_length"] += 1; continue
155
+ cur_t = int(it.ids[it.current[0][0]])
156
+ stale_t = int(it.ids[it.stale[0][0]])
157
+ if cur_t == stale_t:
158
+ n_skipped += 1; skip_reasons["same_token"] += 1; continue
159
+ ans_p = ol - 1
160
+
161
+ ids_b = torch.tensor([it.ids[:ol]], device=device, dtype=torch.long)
162
+
163
+ setattr(m, MEMORY_GATE_ATTR, saved_head)
164
+ with torch.no_grad():
165
+ logits_a = model_forward_logits(m, ids_b)
166
+ cur_lp_a = float(logits_a[0, ans_p, cur_t].item())
167
+ stl_lp_a = float(logits_a[0, ans_p, stale_t].item())
168
+ pred_a = int(logits_a[0, ans_p].argmax().item())
169
+ margin_active.append(cur_lp_a - stl_lp_a)
170
+ pred_correct_active.append(pred_a == cur_t)
171
+
172
+ with torch.no_grad():
173
+ h_C = m.c_stage(ids_b, feedback=None)
174
+ h_E, h_E_sparse = m.e_stage(h_C, feedback=None)
175
+ v_vec, _ = m.salience(h_E) if m.use_salience else (None, None)
176
+ h_D, _ = m.d_stage(h_E, h_E_sparse, v_vec=v_vec)
177
+ lam_logit_a = saved_head(h_D[:, ans_p:ans_p + 1, :])
178
+ lam_a = torch.sigmoid(lam_logit_a).clamp(1e-4, 1.0 - 1e-4)
179
+ lam_at_ans_active.append(float(lam_a.item()))
180
+
181
+ setattr(m, MEMORY_GATE_ATTR, zero_head)
182
+ with torch.no_grad():
183
+ logits_o = model_forward_logits(m, ids_b)
184
+ cur_lp_o = float(logits_o[0, ans_p, cur_t].item())
185
+ stl_lp_o = float(logits_o[0, ans_p, stale_t].item())
186
+ pred_o = int(logits_o[0, ans_p].argmax().item())
187
+ margin_off.append(cur_lp_o - stl_lp_o)
188
+ pred_correct_off.append(pred_o == cur_t)
189
+
190
+ setattr(m, MEMORY_GATE_ATTR, saved_head)
191
+
192
+ n = len(margin_active)
193
+ print(f"\n[items] used={n} skipped={n_skipped} reasons={skip_reasons}")
194
+ if n == 0:
195
+ print("No valid items — aborting.")
196
+ return
197
+
198
+ ma = np.array(margin_active)
199
+ mo = np.array(margin_off)
200
+ delta = ma - mo
201
+
202
+ print(f"\n[λ at answer row, ACTIVE path] "
203
+ f"mean={np.mean(lam_at_ans_active):.4f} "
204
+ f"std={np.std(lam_at_ans_active):.4f} "
205
+ f"min={min(lam_at_ans_active):.4f} "
206
+ f"max={max(lam_at_ans_active):.4f}")
207
+
208
+ print(f"\n[current − stale logit margin at answer position]")
209
+ print(f" ACTIVE: mean={ma.mean():+.3f} std={ma.std():.3f} "
210
+ f"median={np.median(ma):+.3f}")
211
+ print(f" BANK-OFF: mean={mo.mean():+.3f} std={mo.std():.3f} "
212
+ f"median={np.median(mo):+.3f}")
213
+ print(f" Δ (act−off): mean={delta.mean():+.3f} std={delta.std():.3f} "
214
+ f"median={np.median(delta):+.3f}")
215
+
216
+ from scipy import stats as sst
217
+ t_stat, p_val = sst.ttest_rel(ma, mo)
218
+ print(f" paired t = {t_stat:+.3f} p = {p_val:.4f} N={n}")
219
+ d_paired = float(delta.mean() / max(delta.std(), 1e-9))
220
+ print(f" Cohen's d (paired) = {d_paired:+.3f}")
221
+
222
+ acc_a = float(np.mean(pred_correct_active))
223
+ acc_o = float(np.mean(pred_correct_off))
224
+ print(f"\n[top-1 accuracy: argmax(logits[ans_p]) == cur_t]")
225
+ print(f" ACTIVE: {acc_a*100:5.1f}% ({sum(pred_correct_active)}/{n})")
226
+ print(f" BANK-OFF: {acc_o*100:5.1f}% ({sum(pred_correct_off)}/{n})")
227
+ print(f" Δ: {(acc_a-acc_o)*100:+5.1f} pp")
228
+
229
+ print(f"\n[first 10 items — paired breakdown]")
230
+ print(f" {'#':>3} {'m_A':>7} {'m_O':>7} {'Δ':>7} "
231
+ f"{'pred_A':>6} {'pred_O':>6}")
232
+ for i in range(min(10, n)):
233
+ print(f" {i:>3} {ma[i]:>+7.3f} {mo[i]:>+7.3f} {delta[i]:>+7.3f} "
234
+ f"{'OK' if pred_correct_active[i] else 'XX':>6} "
235
+ f"{'OK' if pred_correct_off[i] else 'XX':>6}")
236
+
237
+ print(f"\n{'='*64}")
238
+ print(f"Memory-readout causality — verdict")
239
+ print(f"{'='*64}")
240
+ if t_stat > 2.0 and (acc_a - acc_o) > 0.10:
241
+ verdict = (
242
+ f"ACTIVE > BANK-OFF (t={t_stat:+.2f}, Δacc=+{(acc_a-acc_o)*100:.1f}pp, "
243
+ f"d={d_paired:+.2f}).\n"
244
+ f" → Bank is causally useful for V4c current-vs-stale.\n"
245
+ f" → PROCEED to full probe suite + manuscript update."
246
+ )
247
+ elif abs(t_stat) < 1.0 and abs(acc_a - acc_o) < 0.05:
248
+ verdict = (
249
+ f"ACTIVE ≈ BANK-OFF (t={t_stat:+.2f}, Δacc={(acc_a-acc_o)*100:+.1f}pp, "
250
+ f"d={d_paired:+.2f}).\n"
251
+ f" → λ head routes correctly but BANK READOUT IS NOT CAUSALLY USEFUL.\n"
252
+ f" → If mem_head_bank is already untied and alternate sources still\n"
253
+ f" fail, test direct-source readout before more bank replay."
254
+ )
255
+ elif t_stat < -2.0:
256
+ verdict = (
257
+ f"BANK-OFF > ACTIVE (t={t_stat:+.2f}, Δacc={(acc_a-acc_o)*100:+.1f}pp, "
258
+ f"d={d_paired:+.2f}).\n"
259
+ f" → BANK ACTIVELY HURTS V4c prediction. Diagnose the bank readout\n"
260
+ f" bottleneck before any manuscript work."
261
+ )
262
+ else:
263
+ verdict = (
264
+ f"INTERMEDIATE (t={t_stat:+.2f}, Δacc={(acc_a-acc_o)*100:+.1f}pp, "
265
+ f"d={d_paired:+.2f}).\n"
266
+ f" → Mixed signal. Run full probe suite to see broader pattern; if\n"
267
+ f" contrastive / stale-vs-current probes show clear lift, the bank\n"
268
+ f" is helpful on average even if the V4c-margin metric is noisy."
269
+ )
270
+ print(f" {verdict}")
271
+ print(f"{'='*64}")
272
+
273
+
274
+ if __name__ == "__main__":
275
+ main()
probes/probe_memory_diagnosis.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Memory-readout distributional diagnosis at current-vs-stale answer rows.
2
+
3
+ Runs trunk-only, memory-only, and active paths on the same current-vs-stale
4
+ items. The report shows whether the contextual memory readout decodes the
5
+ current token and how it differs from the trunk distribution.
6
+
7
+ Reports:
8
+ 1. Structural check: is `mem_head_bank` a separate parameter from `mem_head`?
9
+ If shared/tied, that's THE diagnostic.
10
+ 2. Per-path top-1 distribution: how often does each path's argmax match
11
+ current_t / stale_t / something else?
12
+ 3. Mean rank of current_t and stale_t in each path's logit distribution.
13
+ 4. Softmax entropy: bank vs trunk (overconfident? uniform? matched?).
14
+ 5. First-10-item qualitative dump: bank's top-3 vs trunk's top-3 vs
15
+ current/stale tokens (decoded).
16
+
17
+ Usage:
18
+ python probes/probe_memory_diagnosis.py \
19
+ --checkpoint pytorch_model.bin \
20
+ --sidecar cedl_config.json \
21
+ --n-items 50
22
+ """
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import sys
28
+
29
+ import numpy as np
30
+ import torch
31
+ import torch.nn as nn
32
+ import torch.nn.functional as F
33
+
34
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
35
+ sys.path.insert(0, "/content")
36
+
37
+ import CEDL
38
+ import data_v4c_pairs as v4c
39
+
40
+
41
+ MEMORY_GATE_ATTR = "v" + "6_lambda_head"
42
+
43
+
44
+ class ConstantLambdaHead(nn.Module):
45
+ def __init__(self, logit_value):
46
+ super().__init__()
47
+ self.logit_value = float(logit_value)
48
+ def forward(self, h):
49
+ return torch.full(
50
+ h.shape[:-1] + (1,), self.logit_value,
51
+ device=h.device, dtype=h.dtype,
52
+ )
53
+
54
+
55
+ def load_sidecar_constructor_kwargs(sidecar_path):
56
+ with open(sidecar_path) as f:
57
+ sc = json.load(f)
58
+ mem = sc.get("memory_readout")
59
+ if not isinstance(mem, dict):
60
+ raise ValueError("Expected cedl_config.json with a memory_readout block.")
61
+ source_name = str(mem.get("source", "contextual_memory_state"))
62
+ source_map = {
63
+ "contextual_memory_state": "q_mem",
64
+ "decoder_state": "h_d",
65
+ "expanded_state": "h_e",
66
+ "attractor_state": "q_attractor",
67
+ "q_mem": "q_mem",
68
+ }
69
+ return dict(
70
+ lambda_head=bool(mem.get("lambda_head", True)),
71
+ lambda_head_hidden=int(mem.get("lambda_head_hidden", 160)),
72
+ lambda_head_bias_init=float(mem.get("lambda_head_bias_init", -7.0)),
73
+ lambda_head_w_init_std=float(
74
+ mem.get("lambda_head_w_init_std", 0.05)),
75
+ bce_objective=(
76
+ mem.get("selection_objective") == "binary_answer_background"),
77
+ sel_weight=1.0,
78
+ bg_weight=1.0,
79
+ bg_target=float(mem.get("background_target", 0.01)),
80
+ wt_sparsity_weight=float(mem.get("sparsity_weight", 0.05)),
81
+ wt_sparsity_target=float(mem.get("sparsity_target", 0.05)),
82
+ memory_head_enabled=bool(mem.get("enabled", True)),
83
+ memory_ce_weight=float(mem.get("memory_ce_weight", 1.0)),
84
+ memory_pair_ce_weight=float(mem.get("pair_ce_weight", 5.0)),
85
+ memory_query_source=source_map.get(source_name, source_name),
86
+ memory_readout_mode="direct",
87
+ source_adapter=bool(mem.get("source_adapter", True)),
88
+ context_adapter=bool(mem.get("context_adapter", True)),
89
+ specialist_noinject=bool(mem.get("no_injection", True)),
90
+ )
91
+
92
+
93
+ def model_forward_logits(m, ids_b):
94
+ out = m(ids_b)
95
+ return out[0] if isinstance(out, tuple) else out
96
+
97
+
98
+ def categorize_top1(top1_t, cur_t, stale_t):
99
+ if top1_t == cur_t: return "current"
100
+ if top1_t == stale_t: return "stale"
101
+ return "other"
102
+
103
+
104
+ def token_decode(tok, token_id):
105
+ s = tok.decode([int(token_id)])
106
+ return repr(s) if len(s) < 16 else repr(s[:13]) + "..."
107
+
108
+
109
+ def main():
110
+ p = argparse.ArgumentParser()
111
+ p.add_argument("--checkpoint", type=str, default="pytorch_model.bin")
112
+ p.add_argument("--sidecar", type=str, default="cedl_config.json")
113
+ p.add_argument("--n-items", type=int, default=50)
114
+ p.add_argument("--seed", type=int, default=0)
115
+ args = p.parse_args()
116
+
117
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
118
+ print(f"[setup] device={device} ckpt={args.checkpoint}")
119
+
120
+ model_kwargs = load_sidecar_constructor_kwargs(args.sidecar)
121
+ print(f"[setup] memory_source={model_kwargs['memory_query_source']} "
122
+ f"readout_mode={model_kwargs['memory_readout_mode']}")
123
+ m = CEDL.build_model("CEDL", vocab=50257, max_seq=1024, **model_kwargs)
124
+ m = m.to(device).eval()
125
+
126
+ state = torch.load(args.checkpoint, map_location="cpu", weights_only=True)
127
+ msd = state["model"] if isinstance(state, dict) and "model" in state else state
128
+ if any(k.startswith("_orig_mod.") for k in msd):
129
+ msd = {k.replace("_orig_mod.", ""): v for k, v in msd.items()}
130
+ res = m.load_state_dict(msd, strict=True)
131
+ print(f"[load] strict OK (missing={len(res.missing_keys)} unexpected={len(res.unexpected_keys)})")
132
+
133
+ m.feedback_alpha.fill_(1.0)
134
+ if hasattr(m, "sl_alpha"):
135
+ m.sl_alpha.fill_(1.0)
136
+
137
+ print(f"\n{'='*64}")
138
+ print("STRUCTURAL CHECK — bank output projection")
139
+ print(f"{'='*64}")
140
+ has_mh = hasattr(m, "mem_head")
141
+ has_mhb = hasattr(m, "mem_head_bank")
142
+ print(f" m.mem_head present: {has_mh}")
143
+ print(f" m.mem_head_bank present: {has_mhb}")
144
+ if has_mh and has_mhb:
145
+ mh = m.mem_head
146
+ mhb = m.mem_head_bank
147
+ same_obj = mh is mhb
148
+ print(f" m.mem_head is m.mem_head_bank: {same_obj}")
149
+ if not same_obj:
150
+ mh_params = {k: v for k, v in mh.named_parameters()}
151
+ mhb_params = {k: v for k, v in mhb.named_parameters()}
152
+ print(f" mem_head params: {list(mh_params.keys())}")
153
+ print(f" mem_head_bank params: {list(mhb_params.keys())}")
154
+ for k in mh_params:
155
+ if k in mhb_params:
156
+ same_param = mh_params[k] is mhb_params[k]
157
+ equal_val = torch.equal(mh_params[k], mhb_params[k])
158
+ cos = F.cosine_similarity(
159
+ mh_params[k].flatten().unsqueeze(0),
160
+ mhb_params[k].flatten().unsqueeze(0),
161
+ ).item()
162
+ print(f" {k}: same_tensor={same_param} equal={equal_val} cos={cos:+.4f}")
163
+ elif has_mh and not has_mhb:
164
+ print(f" → bank uses mem_head directly (NO separate bank output proj).")
165
+ print(f" → bank logits = mem_head(attended_value) → shaped by WikiText LM prior.")
166
+ else:
167
+ bank_keys = [k for k in msd if "bank" in k.lower()]
168
+ print(f" bank-related state_dict keys: {bank_keys}")
169
+
170
+ from transformers import GPT2TokenizerFast
171
+ tok = GPT2TokenizerFast.from_pretrained("gpt2")
172
+ items = v4c.generate(tok, n=args.n_items, seed=args.seed)
173
+ print(f"\n[v4c] generated {len(items)} items")
174
+
175
+ saved_head = getattr(m, MEMORY_GATE_ATTR)
176
+ zero_head = ConstantLambdaHead(-100.0).to(device)
177
+ one_head = ConstantLambdaHead(+100.0).to(device)
178
+
179
+ records = []
180
+
181
+ for it_idx, it in enumerate(items):
182
+ if it.family == "neutral_control": continue
183
+ if not it.current or not it.stale: continue
184
+ ol = getattr(it, "original_length", 0)
185
+ if ol <= 1 or ol > 1024: continue
186
+ cur_t = int(it.ids[it.current[0][0]])
187
+ stale_t = int(it.ids[it.stale[0][0]])
188
+ if cur_t == stale_t: continue
189
+ ans_p = ol - 1
190
+
191
+ ids_b = torch.tensor([it.ids[:ol]], device=device, dtype=torch.long)
192
+
193
+ results = {"item": it_idx, "cur_t": cur_t, "stale_t": stale_t}
194
+ for path, head in [("trunk", zero_head), ("bank", one_head), ("active", saved_head)]:
195
+ setattr(m, MEMORY_GATE_ATTR, head)
196
+ with torch.no_grad():
197
+ logits = model_forward_logits(m, ids_b)
198
+ row = logits[0, ans_p]
199
+ log_probs = F.log_softmax(row, dim=-1)
200
+ top5_p, top5_t = torch.topk(log_probs, k=5)
201
+ ent = float(-(log_probs.exp() * log_probs).sum().item())
202
+ cur_rank = int((log_probs > log_probs[cur_t]).sum().item()) + 1
203
+ stl_rank = int((log_probs > log_probs[stale_t]).sum().item()) + 1
204
+ results[path] = dict(
205
+ top5_t=top5_t.tolist(),
206
+ top5_lp=top5_p.tolist(),
207
+ top1_t=int(top5_t[0].item()),
208
+ ent=ent,
209
+ cur_rank=cur_rank,
210
+ stl_rank=stl_rank,
211
+ cur_lp=float(log_probs[cur_t].item()),
212
+ stl_lp=float(log_probs[stale_t].item()),
213
+ )
214
+ records.append(results)
215
+
216
+ setattr(m, MEMORY_GATE_ATTR, saved_head)
217
+ n = len(records)
218
+ print(f"[used] {n} items\n")
219
+
220
+ if n == 0:
221
+ print("No valid items.")
222
+ return
223
+
224
+ for path in ["trunk", "bank", "active"]:
225
+ top1_cur = sum(1 for r in records if r[path]["top1_t"] == r["cur_t"])
226
+ top1_stl = sum(1 for r in records if r[path]["top1_t"] == r["stale_t"])
227
+ top1_other = n - top1_cur - top1_stl
228
+ ents = np.array([r[path]["ent"] for r in records])
229
+ cur_ranks = np.array([r[path]["cur_rank"] for r in records])
230
+ stl_ranks = np.array([r[path]["stl_rank"] for r in records])
231
+ cur_lps = np.array([r[path]["cur_lp"] for r in records])
232
+ stl_lps = np.array([r[path]["stl_lp"] for r in records])
233
+ print(f"[{path:>6}] top1: cur={top1_cur:>2}/{n} ({top1_cur/n*100:5.1f}%) "
234
+ f"stl={top1_stl:>2}/{n} ({top1_stl/n*100:5.1f}%) "
235
+ f"other={top1_other:>2}/{n} ({top1_other/n*100:5.1f}%)")
236
+ print(f" entropy: mean={ents.mean():.3f} std={ents.std():.3f} "
237
+ f"(uniform~10.83 for V=50257)")
238
+ print(f" cur_rank: mean={cur_ranks.mean():.0f} median={np.median(cur_ranks):.0f} "
239
+ f"min={cur_ranks.min()} max={cur_ranks.max()}")
240
+ print(f" stl_rank: mean={stl_ranks.mean():.0f} median={np.median(stl_ranks):.0f} "
241
+ f"min={stl_ranks.min()} max={stl_ranks.max()}")
242
+ print(f" logp(cur): mean={cur_lps.mean():.3f} logp(stl): mean={stl_lps.mean():.3f}\n")
243
+
244
+ bank_eq_trunk = sum(1 for r in records if r["bank"]["top1_t"] == r["trunk"]["top1_t"])
245
+ bank_in_trunk_top5 = sum(1 for r in records
246
+ if r["bank"]["top1_t"] in r["trunk"]["top5_t"])
247
+ trunk_in_bank_top5 = sum(1 for r in records
248
+ if r["trunk"]["top1_t"] in r["bank"]["top5_t"])
249
+ print(f"[cross-path]")
250
+ print(f" bank top1 == trunk top1: {bank_eq_trunk:>2}/{n} ({bank_eq_trunk/n*100:5.1f}%)")
251
+ print(f" bank top1 ∈ trunk top5: {bank_in_trunk_top5:>2}/{n} ({bank_in_trunk_top5/n*100:5.1f}%)")
252
+ print(f" trunk top1 ∈ bank top5: {trunk_in_bank_top5:>2}/{n} ({trunk_in_bank_top5/n*100:5.1f}%)")
253
+
254
+ print(f"\n[first 10 — qualitative bank vs trunk top-3 at answer position]")
255
+ for i, r in enumerate(records[:10]):
256
+ cur_s = token_decode(tok, r["cur_t"])
257
+ stl_s = token_decode(tok, r["stale_t"])
258
+ print(f"\n --- item {r['item']} cur={cur_s} stale={stl_s} ---")
259
+ for path in ["trunk", "bank", "active"]:
260
+ top3_t = r[path]["top5_t"][:3]
261
+ top3_lp = r[path]["top5_lp"][:3]
262
+ top3_decoded = [(token_decode(tok, t), f"{lp:+.2f}") for t, lp in zip(top3_t, top3_lp)]
263
+ print(f" {path:>6}: top3={top3_decoded} cur_rank={r[path]['cur_rank']} stl_rank={r[path]['stl_rank']}")
264
+
265
+ print(f"\n{'='*64}")
266
+ print("B2 diagnostic verdict")
267
+ print(f"{'='*64}")
268
+
269
+ bank_top1_cur = sum(1 for r in records if r["bank"]["top1_t"] == r["cur_t"]) / n
270
+ trunk_top1_cur = sum(1 for r in records if r["trunk"]["top1_t"] == r["cur_t"]) / n
271
+ bank_mean_ent = float(np.mean([r["bank"]["ent"] for r in records]))
272
+ trunk_mean_ent = float(np.mean([r["trunk"]["ent"] for r in records]))
273
+ bank_cur_rank_mean = float(np.mean([r["bank"]["cur_rank"] for r in records]))
274
+ trunk_cur_rank_mean = float(np.mean([r["trunk"]["cur_rank"] for r in records]))
275
+ readout_mode = str(model_kwargs.get("memory_readout_mode", "direct"))
276
+
277
+ if bank_top1_cur >= trunk_top1_cur and bank_top1_cur >= 0.25:
278
+ print(f" Bank/readout predicts cur token at top-1 {bank_top1_cur*100:.1f}% "
279
+ f"(vs trunk {trunk_top1_cur*100:.1f}%).")
280
+ print(f" Bank/readout ranks cur_t at mean position {bank_cur_rank_mean:.0f} "
281
+ f"(vs trunk {trunk_cur_rank_mean:.0f}).")
282
+ if bank_mean_ent + 1.0 < trunk_mean_ent:
283
+ print(f" Entropy is lower than trunk ({bank_mean_ent:.2f} vs "
284
+ f"{trunk_mean_ent:.2f}); this is overconfident but useful "
285
+ f"when top-1/rank improve.")
286
+ if readout_mode == "direct":
287
+ print(f" → DIRECT READOUT PASSES B2. q_mem is decodable; the old "
288
+ f"256-slot bank path was the bottleneck.")
289
+ else:
290
+ print(f" → BANK READOUT PASSES B2. Proceed to B1 causal attribution.")
291
+ elif bank_top1_cur < 0.5 * trunk_top1_cur and bank_cur_rank_mean > 2 * trunk_cur_rank_mean:
292
+ print(f" Bank predicts cur token at top-1 only {bank_top1_cur*100:.1f}% (vs trunk {trunk_top1_cur*100:.1f}%)")
293
+ print(f" Bank ranks cur_t at mean position {bank_cur_rank_mean:.0f} (vs trunk {trunk_cur_rank_mean:.0f})")
294
+ print(f" → Bank output is task-misaligned. STAGE 2B JUSTIFIED.")
295
+ print(f" → If 'mem_head_bank is mem_head' check above shows TIED weights:")
296
+ print(f" primary fix is to UNTIE mem_head_bank (give bank its own output proj).")
297
+ print(f" → If weights ARE already separate and alternate sources still fail:")
298
+ print(f" test direct-source readout before more bank replay.")
299
+ elif abs(bank_mean_ent - trunk_mean_ent) > 1.0:
300
+ print(f" Bank entropy ({bank_mean_ent:.2f}) differs from trunk ({trunk_mean_ent:.2f}) by {abs(bank_mean_ent-trunk_mean_ent):.2f}.")
301
+ print(f" → Bank distribution is mis-calibrated relative to trunk.")
302
+ else:
303
+ print(f" Bank top-1 cur rate ({bank_top1_cur*100:.1f}%) "
304
+ f"vs trunk ({trunk_top1_cur*100:.1f}%) — diagnostic inconclusive.")
305
+ print(f" Examine qualitative dump above for specific failure mode.")
306
+ print(f"{'='*64}")
307
+
308
+
309
+ if __name__ == "__main__":
310
+ main()
probes/probe_memory_source_readout.py ADDED
@@ -0,0 +1,505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Probe whether a memory source vector is directly decodable.
3
+
4
+ This diagnostic freezes the trained CEDL checkpoint and trains a temporary
5
+ linear vocab readout from one selected source vector:
6
+
7
+ h_d, h_e, q_attractor, or q_mem
8
+
9
+ It bypasses the external bank bottleneck:
10
+
11
+ source -> direct_head -> vocab
12
+
13
+ instead of:
14
+
15
+ source -> bank_q_proj -> 256-slot mem_keys/mem_vals -> mem_head_bank -> vocab
16
+
17
+ If direct_head generalizes on B2 while the bank path fails, the bottleneck is
18
+ the learned 256-slot bank readout. If direct_head also fails, the selected
19
+ source does not robustly encode the current-token target under this synthetic
20
+ held-out probe.
21
+ """
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import sys
27
+ from typing import Dict, List, Tuple
28
+
29
+ import numpy as np
30
+ import torch
31
+ import torch.nn as nn
32
+ import torch.nn.functional as F
33
+
34
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
35
+ sys.path.insert(0, "/content")
36
+
37
+ import CEDL
38
+ import data_v4c_pairs as v4c
39
+
40
+
41
+ SOURCES = ("h_d", "h_e", "q_attractor", "q_mem")
42
+ MEMORY_GATE_ATTR = "v" + "6_lambda_head"
43
+ STORE_SOURCES_ATTR = "store_v" + "6_bank_sources"
44
+ NEEDS_SOURCES_METHOD = "_v" + "6_needs_dstage_bank_sources"
45
+ SOURCE_INPUT_METHOD = "_v" + "6_bank_query_input"
46
+
47
+ SOURCE_ALIASES = {
48
+ "contextual_memory_state": "q_mem",
49
+ "decoder_state": "h_d",
50
+ "expanded_state": "h_e",
51
+ "attractor_state": "q_attractor",
52
+ "q_mem": "q_mem",
53
+ "h_d": "h_d",
54
+ "h_e": "h_e",
55
+ "q_attractor": "q_attractor",
56
+ }
57
+
58
+
59
+ def load_sidecar_constructor_kwargs(
60
+ sidecar_path: str,
61
+ source: str | None,
62
+ ) -> Dict[str, object]:
63
+ with open(sidecar_path) as f:
64
+ sc = json.load(f)
65
+ mem = sc.get("memory_readout")
66
+ if not isinstance(mem, dict):
67
+ raise ValueError("Expected cedl_config.json with a memory_readout block.")
68
+ source_name = str(source or mem.get("source", "contextual_memory_state"))
69
+ return dict(
70
+ lambda_head=bool(mem.get("lambda_head", True)),
71
+ lambda_head_hidden=int(mem.get("lambda_head_hidden", 160)),
72
+ lambda_head_bias_init=float(mem.get("lambda_head_bias_init", -7.0)),
73
+ lambda_head_w_init_std=float(
74
+ mem.get("lambda_head_w_init_std", 0.05)),
75
+ bce_objective=(
76
+ mem.get("selection_objective") == "binary_answer_background"),
77
+ sel_weight=1.0,
78
+ bg_weight=1.0,
79
+ bg_target=float(mem.get("background_target", 0.01)),
80
+ wt_sparsity_weight=float(mem.get("sparsity_weight", 0.05)),
81
+ wt_sparsity_target=float(mem.get("sparsity_target", 0.05)),
82
+ memory_head_enabled=bool(mem.get("enabled", True)),
83
+ memory_ce_weight=float(mem.get("memory_ce_weight", 1.0)),
84
+ memory_pair_ce_weight=float(mem.get("pair_ce_weight", 5.0)),
85
+ memory_query_source=SOURCE_ALIASES.get(source_name, source_name),
86
+ memory_readout_mode="direct",
87
+ source_adapter=bool(mem.get("source_adapter", True)),
88
+ context_adapter=bool(mem.get("context_adapter", True)),
89
+ specialist_noinject=bool(mem.get("no_injection", True)),
90
+ )
91
+
92
+
93
+ def load_model(args, device: torch.device):
94
+ source = None if args.source == "sidecar" else args.source
95
+ model_kwargs = load_sidecar_constructor_kwargs(args.sidecar, source)
96
+ model = CEDL.build_model("CEDL", vocab=50257, max_seq=1024, **model_kwargs)
97
+ model = model.to(device).eval()
98
+
99
+ state = torch.load(args.checkpoint, map_location="cpu", weights_only=True)
100
+ msd = state["model"] if isinstance(state, dict) and "model" in state else state
101
+ if any(k.startswith("_orig_mod.") for k in msd):
102
+ msd = {k.replace("_orig_mod.", ""): v for k, v in msd.items()}
103
+ res = model.load_state_dict(msd, strict=True)
104
+ print(f"[load] strict OK missing={len(res.missing_keys)} "
105
+ f"unexpected={len(res.unexpected_keys)}")
106
+
107
+ model.feedback_alpha.fill_(1.0)
108
+ if hasattr(model, "sl_alpha"):
109
+ model.sl_alpha.fill_(1.0)
110
+ for p in model.parameters():
111
+ p.requires_grad_(False)
112
+ return model
113
+
114
+
115
+ def make_batch(tokenizer, batch_size: int, max_seq: int, seed: int,
116
+ split: str, hard_collision_frac: float):
117
+ items = v4c.generate(
118
+ tokenizer,
119
+ n=batch_size,
120
+ seed=seed,
121
+ split=split,
122
+ hard_collision_frac=hard_collision_frac,
123
+ family_weights={
124
+ "but_update": 0.50,
125
+ "however_revision": 0.20,
126
+ "temporal_update": 0.15,
127
+ "paraphrased_equiv": 0.05,
128
+ "neutral_control": 0.10,
129
+ },
130
+ )
131
+ return CEDL._pad_v4c_items(items, max_seq), items
132
+
133
+
134
+ @torch.no_grad()
135
+ def answer_source(model, ids: torch.Tensor, items) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
136
+ h_C = model.c_stage(ids, feedback=None)
137
+ h_E, h_E_sparse = model.e_stage(h_C, feedback=None)
138
+ v_vec, _v_scalar = model.salience(h_E)
139
+
140
+ prev = getattr(model.d_stage, STORE_SOURCES_ATTR, False)
141
+ setattr(model.d_stage, STORE_SOURCES_ATTR,
142
+ getattr(model, NEEDS_SOURCES_METHOD)())
143
+ try:
144
+ h_D, _ = model.d_stage(h_E, h_E_sparse, v_vec=v_vec)
145
+ finally:
146
+ setattr(model.d_stage, STORE_SOURCES_ATTR, prev)
147
+
148
+ b_idx, p_idx, cur_tok, stale_tok = CEDL._v4c_collect_answer_quads(ids, items)
149
+ if b_idx.numel() == 0:
150
+ empty = torch.empty(0, model.d_model, device=ids.device)
151
+ return empty, cur_tok, stale_tok
152
+ src = getattr(model, SOURCE_INPUT_METHOD)(h_D, h_E, b_idx, p_idx)
153
+ return src.detach(), cur_tok, stale_tok
154
+
155
+
156
+ def init_direct_head(model, init_from: str) -> nn.Linear:
157
+ head = nn.Linear(model.d_model, 50257, bias=True).to(next(model.parameters()).device)
158
+ with torch.no_grad():
159
+ if init_from == "mem_head_bank" and hasattr(model, "mem_head_bank"):
160
+ head.weight.copy_(model.mem_head_bank.weight)
161
+ head.bias.copy_(model.mem_head_bank.bias)
162
+ elif init_from == "tok_emb":
163
+ head.weight.copy_(model.c_stage.tok_emb.weight)
164
+ if getattr(model.l_stage.mem_head, "bias", None) is not None:
165
+ head.bias.copy_(model.l_stage.mem_head.bias)
166
+ else:
167
+ head.bias.zero_()
168
+ elif init_from == "random":
169
+ nn.init.normal_(head.weight, std=0.02)
170
+ head.bias.zero_()
171
+ else:
172
+ raise ValueError(f"unknown --init-from {init_from!r}")
173
+ return head
174
+
175
+
176
+ def row_metrics(logits: torch.Tensor, cur_tok: torch.Tensor, stale_tok: torch.Tensor):
177
+ logp = F.log_softmax(logits, dim=-1)
178
+ top1 = logp.argmax(dim=-1)
179
+ entropy = -(logp.exp() * logp).sum(dim=-1)
180
+ cur_rank = (logp > logp.gather(1, cur_tok[:, None])).sum(dim=1) + 1
181
+ stale_rank = (logp > logp.gather(1, stale_tok[:, None])).sum(dim=1) + 1
182
+ margin = (
183
+ logp.gather(1, cur_tok[:, None]).squeeze(1)
184
+ - logp.gather(1, stale_tok[:, None]).squeeze(1)
185
+ )
186
+ return logp, top1, entropy, cur_rank, stale_rank, margin
187
+
188
+
189
+ @torch.no_grad()
190
+ def evaluate_replay(model, head, tokenizer, *, n_items: int, batch_size: int,
191
+ max_seq: int, seed: int, split: str) -> Dict[str, float]:
192
+ rows: List[Dict[str, float]] = []
193
+ produced = 0
194
+ batch_seed = seed
195
+ while produced < n_items:
196
+ need = min(batch_size, n_items - produced)
197
+ ids, items = make_batch(
198
+ tokenizer, need, max_seq, batch_seed, split,
199
+ hard_collision_frac=0.0,
200
+ )
201
+ ids = ids.to(next(model.parameters()).device)
202
+ src, cur_tok, stale_tok = answer_source(model, ids, items)
203
+ batch_seed += 1009
204
+ if src.numel() == 0:
205
+ continue
206
+ logits = head(src)
207
+ _logp, top1, entropy, cur_rank, stale_rank, margin = row_metrics(
208
+ logits, cur_tok, stale_tok)
209
+ for i in range(cur_tok.numel()):
210
+ rows.append({
211
+ "top1_cur": float(top1[i].item() == cur_tok[i].item()),
212
+ "top1_stale": float(top1[i].item() == stale_tok[i].item()),
213
+ "entropy": float(entropy[i].item()),
214
+ "cur_rank": float(cur_rank[i].item()),
215
+ "stale_rank": float(stale_rank[i].item()),
216
+ "margin": float(margin[i].item()),
217
+ })
218
+ produced += need
219
+ if not rows:
220
+ raise RuntimeError("evaluation generated no valid V4c answer rows")
221
+ return {
222
+ "n": float(len(rows)),
223
+ "top1_cur": float(np.mean([r["top1_cur"] for r in rows])),
224
+ "top1_stale": float(np.mean([r["top1_stale"] for r in rows])),
225
+ "entropy": float(np.mean([r["entropy"] for r in rows])),
226
+ "cur_rank_mean": float(np.mean([r["cur_rank"] for r in rows])),
227
+ "cur_rank_median": float(np.median([r["cur_rank"] for r in rows])),
228
+ "stale_rank_mean": float(np.mean([r["stale_rank"] for r in rows])),
229
+ "stale_rank_median": float(np.median([r["stale_rank"] for r in rows])),
230
+ "margin": float(np.mean([r["margin"] for r in rows])),
231
+ }
232
+
233
+
234
+ def print_metrics(label: str, metrics: Dict[str, float]):
235
+ print(f"[{label}] n={int(metrics['n'])} "
236
+ f"top1_cur={metrics['top1_cur'] * 100:5.1f}% "
237
+ f"top1_stale={metrics['top1_stale'] * 100:5.1f}% "
238
+ f"entropy={metrics['entropy']:.3f} "
239
+ f"cur_rank_mean={metrics['cur_rank_mean']:.0f} "
240
+ f"cur_rank_med={metrics['cur_rank_median']:.0f} "
241
+ f"stale_rank_med={metrics['stale_rank_median']:.0f} "
242
+ f"margin={metrics['margin']:+.3f}")
243
+
244
+
245
+ def token_decode(tok, token_id):
246
+ s = tok.decode([int(token_id)])
247
+ return repr(s) if len(s) < 16 else repr(s[:13]) + "..."
248
+
249
+
250
+ class ConstantLambdaHead(nn.Module):
251
+ def __init__(self, logit_value):
252
+ super().__init__()
253
+ self.logit_value = float(logit_value)
254
+
255
+ def forward(self, h):
256
+ return torch.full(
257
+ h.shape[:-1] + (1,), self.logit_value,
258
+ device=h.device, dtype=h.dtype,
259
+ )
260
+
261
+
262
+ def model_forward_logits(m, ids_b):
263
+ out = m(ids_b)
264
+ return out[0] if isinstance(out, tuple) else out
265
+
266
+
267
+ @torch.no_grad()
268
+ def evaluate_b2_direct(model, head, tokenizer, *, n_items: int, seed: int):
269
+ device = next(model.parameters()).device
270
+ items = v4c.generate(tokenizer, n=n_items, seed=seed)
271
+ records = []
272
+ saved_head = getattr(model, MEMORY_GATE_ATTR)
273
+ setattr(model, MEMORY_GATE_ATTR, ConstantLambdaHead(-100.0).to(device))
274
+ try:
275
+ for it_idx, it in enumerate(items):
276
+ if it.family == "neutral_control":
277
+ continue
278
+ if not it.current or not it.stale:
279
+ continue
280
+ ol = getattr(it, "original_length", 0)
281
+ if ol <= 1 or ol > 1024:
282
+ continue
283
+ cur_t = int(it.ids[it.current[0][0]])
284
+ stale_t = int(it.ids[it.stale[0][0]])
285
+ if cur_t == stale_t:
286
+ continue
287
+ ans_p = ol - 1
288
+ ids_b = torch.tensor([it.ids[:ol]], device=device, dtype=torch.long)
289
+
290
+ trunk_logits = model_forward_logits(model, ids_b)[0, ans_p]
291
+ src, cur_tok, stale_tok = answer_source(model, ids_b, [it])
292
+ if src.numel() == 0:
293
+ continue
294
+ direct_logits = head(src)[0]
295
+
296
+ results = {"item": it_idx, "cur_t": cur_t, "stale_t": stale_t}
297
+ for path, row in (("trunk", trunk_logits), ("direct", direct_logits)):
298
+ logp = F.log_softmax(row, dim=-1)
299
+ top5_p, top5_t = torch.topk(logp, k=5)
300
+ results[path] = {
301
+ "top5_t": top5_t.tolist(),
302
+ "top5_lp": top5_p.tolist(),
303
+ "top1_t": int(top5_t[0].item()),
304
+ "ent": float(-(logp.exp() * logp).sum().item()),
305
+ "cur_rank": int((logp > logp[cur_t]).sum().item()) + 1,
306
+ "stl_rank": int((logp > logp[stale_t]).sum().item()) + 1,
307
+ "cur_lp": float(logp[cur_t].item()),
308
+ "stl_lp": float(logp[stale_t].item()),
309
+ }
310
+ records.append(results)
311
+ finally:
312
+ setattr(model, MEMORY_GATE_ATTR, saved_head)
313
+
314
+ n = len(records)
315
+ print(f"\n[b2-direct] generated={len(items)} used={n}")
316
+ if n == 0:
317
+ return {}
318
+
319
+ for path in ("trunk", "direct"):
320
+ top1_cur = sum(1 for r in records if r[path]["top1_t"] == r["cur_t"])
321
+ top1_stl = sum(1 for r in records if r[path]["top1_t"] == r["stale_t"])
322
+ top1_other = n - top1_cur - top1_stl
323
+ ents = np.array([r[path]["ent"] for r in records])
324
+ cur_ranks = np.array([r[path]["cur_rank"] for r in records])
325
+ stl_ranks = np.array([r[path]["stl_rank"] for r in records])
326
+ cur_lps = np.array([r[path]["cur_lp"] for r in records])
327
+ stl_lps = np.array([r[path]["stl_lp"] for r in records])
328
+ print(f"[{path:>6}] top1: cur={top1_cur:>2}/{n} ({top1_cur/n*100:5.1f}%) "
329
+ f"stl={top1_stl:>2}/{n} ({top1_stl/n*100:5.1f}%) "
330
+ f"other={top1_other:>2}/{n} ({top1_other/n*100:5.1f}%)")
331
+ print(f" entropy: mean={ents.mean():.3f} std={ents.std():.3f}")
332
+ print(f" cur_rank: mean={cur_ranks.mean():.0f} median={np.median(cur_ranks):.0f} "
333
+ f"min={cur_ranks.min()} max={cur_ranks.max()}")
334
+ print(f" stl_rank: mean={stl_ranks.mean():.0f} median={np.median(stl_ranks):.0f} "
335
+ f"min={stl_ranks.min()} max={stl_ranks.max()}")
336
+ print(f" logp(cur): mean={cur_lps.mean():.3f} logp(stl): mean={stl_lps.mean():.3f}\n")
337
+
338
+ direct_eq_trunk = sum(
339
+ 1 for r in records if r["direct"]["top1_t"] == r["trunk"]["top1_t"])
340
+ direct_in_trunk_top5 = sum(
341
+ 1 for r in records if r["direct"]["top1_t"] in r["trunk"]["top5_t"])
342
+ trunk_in_direct_top5 = sum(
343
+ 1 for r in records if r["trunk"]["top1_t"] in r["direct"]["top5_t"])
344
+ print("[cross-path]")
345
+ print(f" direct top1 == trunk top1: {direct_eq_trunk:>2}/{n} ({direct_eq_trunk/n*100:5.1f}%)")
346
+ print(f" direct top1 in trunk top5: {direct_in_trunk_top5:>2}/{n} ({direct_in_trunk_top5/n*100:5.1f}%)")
347
+ print(f" trunk top1 in direct top5: {trunk_in_direct_top5:>2}/{n} ({trunk_in_direct_top5/n*100:5.1f}%)")
348
+
349
+ print("\n[first 10 - qualitative direct vs trunk top-3 at answer position]")
350
+ for r in records[:10]:
351
+ cur_s = token_decode(tokenizer, r["cur_t"])
352
+ stl_s = token_decode(tokenizer, r["stale_t"])
353
+ print(f"\n --- item {r['item']} cur={cur_s} stale={stl_s} ---")
354
+ for path in ("trunk", "direct"):
355
+ top3_t = r[path]["top5_t"][:3]
356
+ top3_lp = r[path]["top5_lp"][:3]
357
+ top3_decoded = [
358
+ (token_decode(tokenizer, t), f"{lp:+.2f}")
359
+ for t, lp in zip(top3_t, top3_lp)
360
+ ]
361
+ print(f" {path:>6}: top3={top3_decoded} "
362
+ f"cur_rank={r[path]['cur_rank']} stl_rank={r[path]['stl_rank']}")
363
+
364
+ direct_top1_cur = sum(
365
+ 1 for r in records if r["direct"]["top1_t"] == r["cur_t"]) / n
366
+ trunk_top1_cur = sum(
367
+ 1 for r in records if r["trunk"]["top1_t"] == r["cur_t"]) / n
368
+ print(f"\n{'='*64}")
369
+ print("Direct-source readout verdict")
370
+ print(f"{'='*64}")
371
+ if direct_top1_cur >= trunk_top1_cur:
372
+ print(f" DIRECT >= TRUNK ({direct_top1_cur*100:.1f}% vs {trunk_top1_cur*100:.1f}%).")
373
+ print(" -> Source is decodable; 256-slot bank readout is the bottleneck.")
374
+ elif direct_top1_cur >= 0.5 * trunk_top1_cur:
375
+ print(f" DIRECT PARTIAL ({direct_top1_cur*100:.1f}% vs trunk {trunk_top1_cur*100:.1f}%).")
376
+ print(" -> Source has signal, but direct readout is still not robust enough.")
377
+ else:
378
+ print(f" DIRECT FAIL ({direct_top1_cur*100:.1f}% vs trunk {trunk_top1_cur*100:.1f}%).")
379
+ print(" -> Selected source is not robustly decodable under B2.")
380
+ print(f"{'='*64}")
381
+
382
+ return {
383
+ "n": n,
384
+ "direct_top1_cur": direct_top1_cur,
385
+ "trunk_top1_cur": trunk_top1_cur,
386
+ }
387
+
388
+
389
+ def save_outputs(head, args, before, after, b2):
390
+ os.makedirs(args.out_dir, exist_ok=True)
391
+ stem = f"CEDL_direct_source_{args.source}"
392
+ head_path = os.path.join(args.out_dir, f"{stem}_head.pt")
393
+ metrics_path = os.path.join(args.out_dir, f"{stem}_metrics.json")
394
+ torch.save({
395
+ "direct_head": head.state_dict(),
396
+ "checkpoint": args.checkpoint,
397
+ "sidecar": args.sidecar,
398
+ "source": args.source,
399
+ "init_from": args.init_from,
400
+ "steps": args.steps,
401
+ "lr": args.lr,
402
+ "batch_size": args.batch_size,
403
+ "seed": args.seed,
404
+ }, head_path)
405
+ with open(metrics_path, "w") as f:
406
+ json.dump({"before": before, "after": after, "b2": b2}, f, indent=2)
407
+ print(f"[save] direct_head={head_path}")
408
+ print(f"[save] metrics={metrics_path}")
409
+
410
+
411
+ def main():
412
+ p = argparse.ArgumentParser()
413
+ p.add_argument("--checkpoint", required=True)
414
+ p.add_argument("--sidecar", required=True)
415
+ p.add_argument("--out-dir", default="outputs/memory_source_readout")
416
+ p.add_argument("--source", choices=("sidecar",) + SOURCES, default="sidecar")
417
+ p.add_argument("--init-from", choices=("mem_head_bank", "tok_emb", "random"),
418
+ default="mem_head_bank")
419
+ p.add_argument("--steps", type=int, default=2000)
420
+ p.add_argument("--batch-size", type=int, default=64)
421
+ p.add_argument("--max-seq", type=int, default=128)
422
+ p.add_argument("--lr", type=float, default=5e-4)
423
+ p.add_argument("--weight-decay", type=float, default=0.0)
424
+ p.add_argument("--seed", type=int, default=7234)
425
+ p.add_argument("--split", choices=("all", "train", "test"), default="all")
426
+ p.add_argument("--hard-collision-frac", type=float, default=0.2)
427
+ p.add_argument("--eval-items", type=int, default=256)
428
+ p.add_argument("--b2-items", type=int, default=50)
429
+ p.add_argument("--b2-seed", type=int, default=0)
430
+ p.add_argument("--log-every", type=int, default=100)
431
+ args = p.parse_args()
432
+
433
+ torch.manual_seed(args.seed)
434
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
435
+ print(f"[setup] device={device}")
436
+ print(f"[setup] checkpoint={args.checkpoint}")
437
+ print(f"[setup] sidecar={args.sidecar}")
438
+ print(f"[setup] out_dir={args.out_dir}")
439
+ print(f"[setup] source={args.source}")
440
+ print(f"[setup] init_from={args.init_from}")
441
+
442
+ from transformers import GPT2TokenizerFast
443
+ tokenizer = GPT2TokenizerFast.from_pretrained("gpt2")
444
+
445
+ model = load_model(args, device)
446
+ print(f"[setup] resolved_source={model_kwargs['memory_query_source']}")
447
+ head = init_direct_head(model, args.init_from)
448
+ trainable = sum(p.numel() for p in head.parameters())
449
+ print(f"[freeze] trainable params={trainable:,} (direct_head only)")
450
+
451
+ before = evaluate_replay(
452
+ model, head, tokenizer, n_items=args.eval_items,
453
+ batch_size=args.batch_size, max_seq=args.max_seq,
454
+ seed=args.seed + 17, split=args.split,
455
+ )
456
+ print_metrics("before", before)
457
+
458
+ opt = torch.optim.AdamW(
459
+ head.parameters(), lr=args.lr, weight_decay=args.weight_decay,
460
+ betas=(0.9, 0.95),
461
+ )
462
+ seen_rows = 0
463
+ ema_loss = None
464
+ for step in range(1, args.steps + 1):
465
+ ids, items = make_batch(
466
+ tokenizer, args.batch_size, args.max_seq,
467
+ args.seed + step * 7919, args.split, args.hard_collision_frac,
468
+ )
469
+ ids = ids.to(device)
470
+ src, cur_tok, _stale_tok = answer_source(model, ids, items)
471
+ if src.numel() == 0:
472
+ continue
473
+ logits = head(src)
474
+ loss = F.cross_entropy(logits, cur_tok)
475
+ opt.zero_grad(set_to_none=True)
476
+ loss.backward()
477
+ torch.nn.utils.clip_grad_norm_(head.parameters(), max_norm=1.0)
478
+ opt.step()
479
+
480
+ seen_rows += int(cur_tok.numel())
481
+ loss_val = float(loss.detach().item())
482
+ ema_loss = loss_val if ema_loss is None else 0.98 * ema_loss + 0.02 * loss_val
483
+ if step == 1 or step % args.log_every == 0:
484
+ with torch.no_grad():
485
+ logp = F.log_softmax(logits, dim=-1)
486
+ top1_cur = (logp.argmax(dim=-1) == cur_tok).float().mean().item()
487
+ entropy = (-(logp.exp() * logp).sum(dim=-1)).mean().item()
488
+ print(f"[train] step={step}/{args.steps} rows={seen_rows} "
489
+ f"loss={loss_val:.4f} ema={ema_loss:.4f} "
490
+ f"batch_top1_cur={top1_cur * 100:5.1f}% "
491
+ f"entropy={entropy:.3f}", flush=True)
492
+
493
+ after = evaluate_replay(
494
+ model, head, tokenizer, n_items=args.eval_items,
495
+ batch_size=args.batch_size, max_seq=args.max_seq,
496
+ seed=args.seed + 17, split=args.split,
497
+ )
498
+ print_metrics("after", after)
499
+ b2 = evaluate_b2_direct(
500
+ model, head, tokenizer, n_items=args.b2_items, seed=args.b2_seed)
501
+ save_outputs(head, args, before, after, b2)
502
+
503
+
504
+ if __name__ == "__main__":
505
+ main()
pytorch_model.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e15a3d6dd38a1f85e2a9c4de409ac3cbd7dece4cf202fca05872ebea8180bfbf
3
+ size 545141751
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ torch
2
+ transformers
3
+ datasets
4
+ tqdm
5
+ numpy
6
+ scipy
7
+