Publish frozen LoopFormer pointer checkpoint and inference code
Browse files- .gitattributes +1 -0
- README.md +80 -0
- adapter_model.pt +3 -0
- chat_template.jinja +54 -0
- recurrent_config.json +241 -0
- requirements.txt +6 -0
- scripts/__init__.py +0 -0
- scripts/dataset/__init__.py +1 -0
- scripts/dataset/__main__.py +7 -0
- scripts/dataset/benchmark.py +57 -0
- scripts/dataset/cli.py +128 -0
- scripts/dataset/dataset.py +212 -0
- scripts/dataset/pointer.py +189 -0
- scripts/dataset/symbols.py +48 -0
- scripts/dataset/terminal.py +31 -0
- scripts/demo/__init__.py +1 -0
- scripts/demo/__main__.py +46 -0
- scripts/demo/gradio_app.py +21 -0
- scripts/demo/inference.py +151 -0
- scripts/recurrent_qwen/__init__.py +6 -0
- scripts/recurrent_qwen/checkpoint.py +124 -0
- scripts/recurrent_qwen/completion.py +26 -0
- scripts/recurrent_qwen/interfaces.py +139 -0
- scripts/recurrent_qwen/lora_utils.py +35 -0
- scripts/recurrent_qwen/memory.py +35 -0
- scripts/recurrent_qwen/model.py +374 -0
- scripts/recurrent_qwen/number_reader.py +61 -0
- scripts/recurrent_qwen/outputs.py +56 -0
- scripts/recurrent_qwen/partitioned.py +166 -0
- scripts/recurrent_qwen/prefix.py +67 -0
- scripts/recurrent_qwen/validation.py +185 -0
- tokenizer.json +3 -0
- tokenizer_config.json +30 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
tokenizer.json filter=lfs diff=lfs merge=lfs -text
|
README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
language: en
|
| 3 |
+
base_model: Qwen/Qwen2.5-0.5B-Instruct
|
| 4 |
+
tags:
|
| 5 |
+
- loopformer
|
| 6 |
+
- recurrent-transformer
|
| 7 |
+
- pointer-chasing
|
| 8 |
+
- custom-code
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# LoopFormer: recurrent pointer execution
|
| 12 |
+
|
| 13 |
+
Frozen final checkpoint: `controller-shared-number-precision-seed83`.
|
| 14 |
+
This is a custom recurrent Qwen model for following a supplied A–Z rule table.
|
| 15 |
+
It requires the included Python inference code; ordinary `AutoModelForCausalLM`
|
| 16 |
+
generation does not implement its recurrence.
|
| 17 |
+
|
| 18 |
+
## Download and run
|
| 19 |
+
|
| 20 |
+
Use Python 3.11 or 3.12. Install a PyTorch build suitable for your device.
|
| 21 |
+
The requirements record the validated research dependency versions; the Space
|
| 22 |
+
adds Gradio and `spaces` for hosting. Export a graph request from the playground,
|
| 23 |
+
or create JSON with `mapping` (26 ordered `[source, destination]` pairs), `start`
|
| 24 |
+
(one A–Z letter), and `steps` (a positive integer).
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
hf download mukunds/loopformer --local-dir loopformer-model
|
| 28 |
+
cd loopformer-model
|
| 29 |
+
python -m pip install -r requirements.txt
|
| 30 |
+
python -m scripts.demo --checkpoint . --request graph.json --device cuda --download
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
The explicit `--download` option permits downloading the pinned Qwen base
|
| 34 |
+
revision. It is needed on the first run. `--device cpu` and `--device mps` are
|
| 35 |
+
also available, but their latency and final-checkpoint fidelity must be measured
|
| 36 |
+
on the selected device. Each output line is a cumulative JSON snapshot of actual
|
| 37 |
+
model predictions; the final line includes separate correctness/stopping metrics.
|
| 38 |
+
|
| 39 |
+
The default requested-step ceiling is 256 and the safety cap is 272. To explore
|
| 40 |
+
longer rollouts, explicitly increase both `--max-steps` and `--loop-cap`. This
|
| 41 |
+
does not establish graph-execution accuracy beyond the evaluated 256 steps.
|
| 42 |
+
The symbol vocabulary remains exactly A–Z; 256 steps does not mean 256 nodes.
|
| 43 |
+
|
| 44 |
+
## Architecture and inference
|
| 45 |
+
|
| 46 |
+
The base is `Qwen/Qwen2.5-0.5B-Instruct`, revision
|
| 47 |
+
`7ae557604adf67be50417f59c2c2f167def9a775`. Six prelude layers prepare the prompt;
|
| 48 |
+
twelve shared middle layers update a recurrent working state; six coda layers
|
| 49 |
+
read out a distribution over the 26 letters after every loop. A trained bridge
|
| 50 |
+
re-enters the shared middle block. Fixed prefix memory is reused within a run.
|
| 51 |
+
The final executor adapts all R weights and freezes P/C.
|
| 52 |
+
|
| 53 |
+
The Steps field is routed away from the executor. A shared learned number reader
|
| 54 |
+
initializes a separate scalar timer, whose learned affine recurrence and stop
|
| 55 |
+
readout determine completion. Stop probability is a timing signal, not a
|
| 56 |
+
correctness estimate. Decoded predictions and reference states are never fed
|
| 57 |
+
back into recurrence. Default inference is FP32 with SDPA and threshold 0.5.
|
| 58 |
+
|
| 59 |
+
`adapter_model.pt` contains all adapted executor/controller tensors, not merely
|
| 60 |
+
LoRA weights. SHA-256:
|
| 61 |
+
`0f8a3f2572ec2caf4fafbcdd074b5283bd8c610602502763b3c04487ce1f123d`.
|
| 62 |
+
Optimizer state and training data are excluded from this inference release.
|
| 63 |
+
|
| 64 |
+
## Evidence and limitations
|
| 65 |
+
|
| 66 |
+
On the frozen final panel of 1,350 new graphs and requests 1–256:
|
| 67 |
+
|
| 68 |
+
- Final letter plus exact stopping: 97.81% over 345,600 graph/count queries.
|
| 69 |
+
- Complete trajectories through depth 256: 96.89% (1,308/1,350 graphs).
|
| 70 |
+
- Exact stopping: 100% across that finite requested-count panel.
|
| 71 |
+
|
| 72 |
+
Executor supervision used depths 1–6, 8, 10, 12. Controller fitting used
|
| 73 |
+
counts 1–63 excluding 9, 17, 29, 41, 53, with supervised unrolls capped at 12.
|
| 74 |
+
Controller-only numerical results at longer horizons are distinct from executing
|
| 75 |
+
graphs correctly. This is one task family, not general reasoning or transfer
|
| 76 |
+
evidence. Arbitrary hand-edited graphs can fail. Changes to precision, runtime,
|
| 77 |
+
hardware or kernels need validation against native intermediate readouts.
|
| 78 |
+
|
| 79 |
+
See the [source and full evidence](https://github.com/MukundShankar-dev/loopformer)
|
| 80 |
+
and the [research journal](https://mukundshankar-dev.github.io/projects/loopformer/).
|
adapter_model.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0f8a3f2572ec2caf4fafbcdd074b5283bd8c610602502763b3c04487ce1f123d
|
| 3 |
+
size 719156751
|
chat_template.jinja
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{%- if tools %}
|
| 2 |
+
{{- '<|im_start|>system\n' }}
|
| 3 |
+
{%- if messages[0]['role'] == 'system' %}
|
| 4 |
+
{{- messages[0]['content'] }}
|
| 5 |
+
{%- else %}
|
| 6 |
+
{{- 'You are Qwen, created by Alibaba Cloud. You are a helpful assistant.' }}
|
| 7 |
+
{%- endif %}
|
| 8 |
+
{{- "\n\n# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
|
| 9 |
+
{%- for tool in tools %}
|
| 10 |
+
{{- "\n" }}
|
| 11 |
+
{{- tool | tojson }}
|
| 12 |
+
{%- endfor %}
|
| 13 |
+
{{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call><|im_end|>\n" }}
|
| 14 |
+
{%- else %}
|
| 15 |
+
{%- if messages[0]['role'] == 'system' %}
|
| 16 |
+
{{- '<|im_start|>system\n' + messages[0]['content'] + '<|im_end|>\n' }}
|
| 17 |
+
{%- else %}
|
| 18 |
+
{{- '<|im_start|>system\nYou are Qwen, created by Alibaba Cloud. You are a helpful assistant.<|im_end|>\n' }}
|
| 19 |
+
{%- endif %}
|
| 20 |
+
{%- endif %}
|
| 21 |
+
{%- for message in messages %}
|
| 22 |
+
{%- if (message.role == "user") or (message.role == "system" and not loop.first) or (message.role == "assistant" and not message.tool_calls) %}
|
| 23 |
+
{{- '<|im_start|>' + message.role + '\n' + message.content + '<|im_end|>' + '\n' }}
|
| 24 |
+
{%- elif message.role == "assistant" %}
|
| 25 |
+
{{- '<|im_start|>' + message.role }}
|
| 26 |
+
{%- if message.content %}
|
| 27 |
+
{{- '\n' + message.content }}
|
| 28 |
+
{%- endif %}
|
| 29 |
+
{%- for tool_call in message.tool_calls %}
|
| 30 |
+
{%- if tool_call.function is defined %}
|
| 31 |
+
{%- set tool_call = tool_call.function %}
|
| 32 |
+
{%- endif %}
|
| 33 |
+
{{- '\n<tool_call>\n{"name": "' }}
|
| 34 |
+
{{- tool_call.name }}
|
| 35 |
+
{{- '", "arguments": ' }}
|
| 36 |
+
{{- tool_call.arguments | tojson }}
|
| 37 |
+
{{- '}\n</tool_call>' }}
|
| 38 |
+
{%- endfor %}
|
| 39 |
+
{{- '<|im_end|>\n' }}
|
| 40 |
+
{%- elif message.role == "tool" %}
|
| 41 |
+
{%- if (loop.index0 == 0) or (messages[loop.index0 - 1].role != "tool") %}
|
| 42 |
+
{{- '<|im_start|>user' }}
|
| 43 |
+
{%- endif %}
|
| 44 |
+
{{- '\n<tool_response>\n' }}
|
| 45 |
+
{{- message.content }}
|
| 46 |
+
{{- '\n</tool_response>' }}
|
| 47 |
+
{%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
|
| 48 |
+
{{- '<|im_end|>\n' }}
|
| 49 |
+
{%- endif %}
|
| 50 |
+
{%- endif %}
|
| 51 |
+
{%- endfor %}
|
| 52 |
+
{%- if add_generation_prompt %}
|
| 53 |
+
{{- '<|im_start|>assistant\n' }}
|
| 54 |
+
{%- endif %}
|
recurrent_config.json
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"base_model": "Qwen/Qwen2.5-0.5B-Instruct",
|
| 3 |
+
"revision": "7ae557604adf67be50417f59c2c2f167def9a775",
|
| 4 |
+
"recurrent_start": 6,
|
| 5 |
+
"recurrent_end": 18,
|
| 6 |
+
"lora_rank": 8,
|
| 7 |
+
"lora_alpha": 16,
|
| 8 |
+
"symbols": [
|
| 9 |
+
"A",
|
| 10 |
+
"B",
|
| 11 |
+
"C",
|
| 12 |
+
"D",
|
| 13 |
+
"E",
|
| 14 |
+
"F",
|
| 15 |
+
"G",
|
| 16 |
+
"H",
|
| 17 |
+
"I",
|
| 18 |
+
"J",
|
| 19 |
+
"K",
|
| 20 |
+
"L",
|
| 21 |
+
"M",
|
| 22 |
+
"N",
|
| 23 |
+
"O",
|
| 24 |
+
"P",
|
| 25 |
+
"Q",
|
| 26 |
+
"R",
|
| 27 |
+
"S",
|
| 28 |
+
"T",
|
| 29 |
+
"U",
|
| 30 |
+
"V",
|
| 31 |
+
"W",
|
| 32 |
+
"X",
|
| 33 |
+
"Y",
|
| 34 |
+
"Z"
|
| 35 |
+
],
|
| 36 |
+
"token_ids": [
|
| 37 |
+
362,
|
| 38 |
+
425,
|
| 39 |
+
356,
|
| 40 |
+
422,
|
| 41 |
+
468,
|
| 42 |
+
434,
|
| 43 |
+
479,
|
| 44 |
+
472,
|
| 45 |
+
358,
|
| 46 |
+
619,
|
| 47 |
+
730,
|
| 48 |
+
444,
|
| 49 |
+
386,
|
| 50 |
+
451,
|
| 51 |
+
506,
|
| 52 |
+
393,
|
| 53 |
+
1207,
|
| 54 |
+
431,
|
| 55 |
+
328,
|
| 56 |
+
350,
|
| 57 |
+
547,
|
| 58 |
+
647,
|
| 59 |
+
467,
|
| 60 |
+
1599,
|
| 61 |
+
809,
|
| 62 |
+
1863
|
| 63 |
+
],
|
| 64 |
+
"prompt_format": "dataset_raw",
|
| 65 |
+
"loss_vocabulary": "symbols",
|
| 66 |
+
"train_max_depth": 12,
|
| 67 |
+
"train_depths": [
|
| 68 |
+
1,
|
| 69 |
+
2,
|
| 70 |
+
3,
|
| 71 |
+
4,
|
| 72 |
+
5,
|
| 73 |
+
6,
|
| 74 |
+
8,
|
| 75 |
+
10,
|
| 76 |
+
12
|
| 77 |
+
],
|
| 78 |
+
"recurrence_mode": "fixed_prompt",
|
| 79 |
+
"completion_head": {
|
| 80 |
+
"intermediate": 1
|
| 81 |
+
},
|
| 82 |
+
"completion_loss_weight": 0.1,
|
| 83 |
+
"training_compute": {
|
| 84 |
+
"precision": "bf16",
|
| 85 |
+
"attention": "sdpa"
|
| 86 |
+
},
|
| 87 |
+
"executor": {
|
| 88 |
+
"train_scope": "recurrent",
|
| 89 |
+
"isolated": true,
|
| 90 |
+
"bridge": true,
|
| 91 |
+
"direct_readout": true,
|
| 92 |
+
"controller_size": 1,
|
| 93 |
+
"prefix_reuse": true,
|
| 94 |
+
"gradient_checkpointing": true,
|
| 95 |
+
"controller_kind": "shared_number"
|
| 96 |
+
},
|
| 97 |
+
"format": "loopformer-executor-v2",
|
| 98 |
+
"controller_training": {
|
| 99 |
+
"source": "models/stage1_pointer/controller-shared-number-seed83/best",
|
| 100 |
+
"source_checkpoint_sha256": "42059fd6fd4cc1ffe05a705a99a7aba76d4e7e0d87933bad18c7adc4cdee1110",
|
| 101 |
+
"trained_depths": [
|
| 102 |
+
1,
|
| 103 |
+
2,
|
| 104 |
+
3,
|
| 105 |
+
4,
|
| 106 |
+
5,
|
| 107 |
+
6,
|
| 108 |
+
7,
|
| 109 |
+
8,
|
| 110 |
+
10,
|
| 111 |
+
11,
|
| 112 |
+
12,
|
| 113 |
+
13,
|
| 114 |
+
14,
|
| 115 |
+
15,
|
| 116 |
+
16,
|
| 117 |
+
18,
|
| 118 |
+
19,
|
| 119 |
+
20,
|
| 120 |
+
21,
|
| 121 |
+
22,
|
| 122 |
+
23,
|
| 123 |
+
24,
|
| 124 |
+
25,
|
| 125 |
+
26,
|
| 126 |
+
27,
|
| 127 |
+
28,
|
| 128 |
+
30,
|
| 129 |
+
31,
|
| 130 |
+
32,
|
| 131 |
+
33,
|
| 132 |
+
34,
|
| 133 |
+
35,
|
| 134 |
+
36,
|
| 135 |
+
37,
|
| 136 |
+
38,
|
| 137 |
+
39,
|
| 138 |
+
40,
|
| 139 |
+
42,
|
| 140 |
+
43,
|
| 141 |
+
44,
|
| 142 |
+
45,
|
| 143 |
+
46,
|
| 144 |
+
47,
|
| 145 |
+
48,
|
| 146 |
+
49,
|
| 147 |
+
50,
|
| 148 |
+
51,
|
| 149 |
+
52,
|
| 150 |
+
54,
|
| 151 |
+
55,
|
| 152 |
+
56,
|
| 153 |
+
57,
|
| 154 |
+
58,
|
| 155 |
+
59,
|
| 156 |
+
60,
|
| 157 |
+
61,
|
| 158 |
+
62,
|
| 159 |
+
63
|
| 160 |
+
],
|
| 161 |
+
"training_loops": 12,
|
| 162 |
+
"optimization_scope": "countdown_cell_only",
|
| 163 |
+
"config": {
|
| 164 |
+
"source": "models/stage1_pointer/controller-shared-number-seed83/best",
|
| 165 |
+
"training_config": "configs/controller_affine.json",
|
| 166 |
+
"output": "models/stage1_pointer/controller-shared-number-precision-seed83"
|
| 167 |
+
},
|
| 168 |
+
"fit": {
|
| 169 |
+
"method": "float64 L-BFGS on original free-running numerical prefix labels; float32 deployment",
|
| 170 |
+
"training_counts": [
|
| 171 |
+
1,
|
| 172 |
+
2,
|
| 173 |
+
3,
|
| 174 |
+
4,
|
| 175 |
+
5,
|
| 176 |
+
6,
|
| 177 |
+
7,
|
| 178 |
+
8,
|
| 179 |
+
10,
|
| 180 |
+
11,
|
| 181 |
+
12,
|
| 182 |
+
13,
|
| 183 |
+
14,
|
| 184 |
+
15,
|
| 185 |
+
16,
|
| 186 |
+
18,
|
| 187 |
+
19,
|
| 188 |
+
20,
|
| 189 |
+
21,
|
| 190 |
+
22,
|
| 191 |
+
23,
|
| 192 |
+
24,
|
| 193 |
+
25,
|
| 194 |
+
26,
|
| 195 |
+
27,
|
| 196 |
+
28,
|
| 197 |
+
30,
|
| 198 |
+
31,
|
| 199 |
+
32,
|
| 200 |
+
33,
|
| 201 |
+
34,
|
| 202 |
+
35,
|
| 203 |
+
36,
|
| 204 |
+
37,
|
| 205 |
+
38,
|
| 206 |
+
39,
|
| 207 |
+
40,
|
| 208 |
+
42,
|
| 209 |
+
43,
|
| 210 |
+
44,
|
| 211 |
+
45,
|
| 212 |
+
46,
|
| 213 |
+
47,
|
| 214 |
+
48,
|
| 215 |
+
49,
|
| 216 |
+
50,
|
| 217 |
+
51,
|
| 218 |
+
52,
|
| 219 |
+
54,
|
| 220 |
+
55,
|
| 221 |
+
56,
|
| 222 |
+
57,
|
| 223 |
+
58,
|
| 224 |
+
59,
|
| 225 |
+
60,
|
| 226 |
+
61,
|
| 227 |
+
62,
|
| 228 |
+
63
|
| 229 |
+
],
|
| 230 |
+
"training_loops": 12,
|
| 231 |
+
"objective_evaluations": 10,
|
| 232 |
+
"before_mse": 4.1626904266916364e-09,
|
| 233 |
+
"fitted_mse": 2.002921115782509e-12,
|
| 234 |
+
"deployed_mse": 6.307695082326698e-12,
|
| 235 |
+
"learned_gain": 1.0,
|
| 236 |
+
"learned_offset": -0.9999999403953552,
|
| 237 |
+
"numeric_targets_are_inputs": false
|
| 238 |
+
},
|
| 239 |
+
"selection": "One predetermined training-label fit; no stress/graph benchmark selection"
|
| 240 |
+
}
|
| 241 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch==2.14.0
|
| 2 |
+
transformers==5.17.0
|
| 3 |
+
tokenizers==0.23.2
|
| 4 |
+
huggingface-hub==1.31.0
|
| 5 |
+
peft==0.20.0
|
| 6 |
+
numpy==2.4.6
|
scripts/__init__.py
ADDED
|
File without changes
|
scripts/dataset/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Synthetic tasks with exact reference execution; no model loading."""
|
scripts/dataset/__main__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run the dataset CLI with python -m scripts.dataset."""
|
| 2 |
+
|
| 3 |
+
from scripts.dataset.cli import main
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
if __name__ == "__main__":
|
| 7 |
+
main()
|
scripts/dataset/benchmark.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Independent, seed-replayable graph panels for frozen pointer benchmarks."""
|
| 2 |
+
from dataclasses import replace
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from .pointer import (PointerExample, example_seed, execute, generate_unconditioned_example,
|
| 7 |
+
orbit_structure, render_prompt, validate_example)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def count_variant(task: PointerExample, count: int) -> PointerExample:
|
| 11 |
+
"""Change requested horizon/labels only; never condition graph sampling on it."""
|
| 12 |
+
states = execute(dict(task.mapping), task.initial_state, count)
|
| 13 |
+
result = replace(task, example_id=f'{task.example_id}-steps-{count}', task_depth=count,
|
| 14 |
+
intermediate_states=states, final_state=states[-1],
|
| 15 |
+
prompt=render_prompt(task.mapping, task.initial_state, count))
|
| 16 |
+
validate_example(result)
|
| 17 |
+
return result
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def independent_graphs(config: dict, excluded: set[str]) -> tuple[list[PointerExample], list[dict]]:
|
| 21 |
+
"""Balanced seed/mode strata; reject only graph identity overlap, never difficulty."""
|
| 22 |
+
if config['graphs_per_seed_mode'] < 1 or not 1 <= config['max_depth'] <= 256:
|
| 23 |
+
raise ValueError('Need positive graph count and a maximum depth in 1..256')
|
| 24 |
+
if len(set(config['seeds'])) != len(config['seeds']) or len(set(config['graph_modes'])) != len(config['graph_modes']):
|
| 25 |
+
raise ValueError('Repeated seeds or graph modes')
|
| 26 |
+
used = set(excluded)
|
| 27 |
+
tasks, metadata = [], []
|
| 28 |
+
for seed in config['seeds']:
|
| 29 |
+
for mode in config['graph_modes']:
|
| 30 |
+
for index in range(config['graphs_per_seed_mode']):
|
| 31 |
+
for attempt in range(1000):
|
| 32 |
+
task = generate_unconditioned_example(example_seed(seed, f'benchmark-{mode}', index, attempt),
|
| 33 |
+
config['max_depth'], 'benchmark', index, mode)
|
| 34 |
+
if task.mapping_sha256 not in used:
|
| 35 |
+
break
|
| 36 |
+
else:
|
| 37 |
+
raise ValueError('Unable to sample a new rule table after 1000 attempts')
|
| 38 |
+
used.add(task.mapping_sha256)
|
| 39 |
+
task = replace(task, example_id=f'benchmark-{seed}-{mode}-{index:06d}-{task.seed:016x}')
|
| 40 |
+
validate_example(task)
|
| 41 |
+
tasks.append(task)
|
| 42 |
+
metadata.append({'graph_index': len(tasks) - 1, 'example_id': task.example_id,
|
| 43 |
+
'mapping_sha256': task.mapping_sha256, 'dataset_seed': seed, 'graph_mode': mode,
|
| 44 |
+
**orbit_structure(dict(task.mapping), task.initial_state)})
|
| 45 |
+
return tasks, metadata
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def exclusion_hashes(directory: Path) -> set[str]:
|
| 49 |
+
"""Read graph fingerprints, including reserved splits, solely to prevent overlap."""
|
| 50 |
+
hashes = set()
|
| 51 |
+
for path in sorted(directory.glob('*.jsonl')):
|
| 52 |
+
for line in path.open():
|
| 53 |
+
record = json.loads(line)
|
| 54 |
+
hashes.add(record['mapping_sha256'])
|
| 55 |
+
if not hashes:
|
| 56 |
+
raise ValueError('No old dataset graph fingerprints found')
|
| 57 |
+
return hashes
|
scripts/dataset/cli.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate or verify reproducible Stage 1 pointer data (no model training).
|
| 2 |
+
|
| 3 |
+
Run from the repository root: python -m scripts.dataset --help
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
from dataclasses import asdict
|
| 8 |
+
import hashlib
|
| 9 |
+
from importlib.metadata import version
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import platform
|
| 12 |
+
import subprocess
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
from rich.console import Console
|
| 16 |
+
from rich.panel import Panel
|
| 17 |
+
from rich.table import Table
|
| 18 |
+
from rich.text import Text
|
| 19 |
+
|
| 20 |
+
from scripts.dataset.dataset import (
|
| 21 |
+
DatasetConfig, SPLITS, generate_dataset, make_record, preview_indices,
|
| 22 |
+
verify_dataset, write_dataset,
|
| 23 |
+
)
|
| 24 |
+
from scripts.dataset.symbols import MODEL_ID, MODEL_REVISION, validate_symbols
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def show_summary(console: Console, manifest: dict) -> None:
|
| 28 |
+
table = Table(title="Validated pointer dataset")
|
| 29 |
+
for column in ("Split", "Examples", "Depths (count)", "Prompt tokens"):
|
| 30 |
+
table.add_column(column)
|
| 31 |
+
for split in SPLITS:
|
| 32 |
+
summary = manifest["splits"][split]
|
| 33 |
+
depths = ", ".join(f"{depth}: {count}" for depth, count in sorted(summary["depth_counts"].items(), key=lambda item: int(item[0])))
|
| 34 |
+
table.add_row(split, f"{summary['count']:,}", depths, f"{summary['prompt_tokens_min']}–{summary['prompt_tokens_max']}")
|
| 35 |
+
console.print(table)
|
| 36 |
+
console.print("[green]PASS[/green] Exact targets, tokenizer contexts, declared graph sharing, disjoint split boundaries, and seed replay")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main() -> None:
|
| 40 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 41 |
+
parser.add_argument("--graph-mode", choices=("unique_path", "mixture", "random_function", "permutation", "full_cycle"), default="unique_path")
|
| 42 |
+
parser.add_argument("--paired-horizons", action="store_true", help="Match each evaluation graph/start across depths; training graphs stay independent")
|
| 43 |
+
parser.add_argument("--seed", type=int, default=17)
|
| 44 |
+
parser.add_argument("--train-count", type=int, default=10_000)
|
| 45 |
+
parser.add_argument("--validation-count", type=int, default=1_000)
|
| 46 |
+
parser.add_argument("--test-count", type=int, default=1_000)
|
| 47 |
+
parser.add_argument("--depth-test-count", type=int, default=1_000)
|
| 48 |
+
parser.add_argument("--min-depth", type=int, default=1)
|
| 49 |
+
parser.add_argument("--max-train-depth", type=int, default=8)
|
| 50 |
+
parser.add_argument("--max-eval-depth", type=int, default=16)
|
| 51 |
+
parser.add_argument("--train-depths", type=int, nargs="+", help="Explicit training depths; validation/test retain every depth through max-train-depth")
|
| 52 |
+
parser.add_argument("--output", type=Path, help="New directory; default data/pointer/seed-<seed>")
|
| 53 |
+
mode = parser.add_mutually_exclusive_group()
|
| 54 |
+
mode.add_argument("--dry-run", nargs="?", const=5, type=int, choices=(5, 10), help="Preview up to 5 (default) or 10 examples spanning depths; write no dataset files")
|
| 55 |
+
mode.add_argument("--verify", type=Path, metavar="DIRECTORY", help="Read and independently verify an existing dataset")
|
| 56 |
+
args = parser.parse_args()
|
| 57 |
+
config = DatasetConfig(**{key: getattr(args, key) for key in asdict(DatasetConfig())})
|
| 58 |
+
try:
|
| 59 |
+
config.validate()
|
| 60 |
+
except ValueError as error:
|
| 61 |
+
parser.error(str(error))
|
| 62 |
+
output = args.output or Path("data/pointer") / f"seed-{args.seed}"
|
| 63 |
+
if args.verify is None and args.dry_run is None and output.exists():
|
| 64 |
+
parser.error(f"Output already exists: {output}; choose a new --output (never overwritten)")
|
| 65 |
+
|
| 66 |
+
from transformers import AutoTokenizer
|
| 67 |
+
|
| 68 |
+
console = Console()
|
| 69 |
+
console.print("[bold cyan]Stage 1 · pointer data[/bold cyan]")
|
| 70 |
+
console.print(f"Cached tokenizer only: {MODEL_ID} @ {MODEL_REVISION}")
|
| 71 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, revision=MODEL_REVISION, local_files_only=True)
|
| 72 |
+
if args.verify is not None:
|
| 73 |
+
with console.status("Verifying saved files and replaying seeds…"):
|
| 74 |
+
manifest = verify_dataset(args.verify, tokenizer)
|
| 75 |
+
show_summary(console, manifest)
|
| 76 |
+
console.print(f"Verified: {args.verify.resolve()}")
|
| 77 |
+
return
|
| 78 |
+
|
| 79 |
+
token_ids = validate_symbols(tokenizer)
|
| 80 |
+
console.print(f"[green]PASS[/green] 26 unique single-token symbols in rule, start, and answer contexts; seed {config.seed}")
|
| 81 |
+
console.print(f"Training depths: {list(config.depths('train'))}; validation/test: {config.min_depth}–{config.max_train_depth}; extrapolation depths: {config.max_train_depth + 1}–{config.max_eval_depth}")
|
| 82 |
+
if args.dry_run is not None:
|
| 83 |
+
distribution = Table(title="Configured dataset distribution (not preview frequencies)")
|
| 84 |
+
for column in ("Split", "Total", "Steps: example count"):
|
| 85 |
+
distribution.add_column(column)
|
| 86 |
+
for split in SPLITS:
|
| 87 |
+
depths = config.depths(split)
|
| 88 |
+
quotient, remainder = divmod(config.count(split), len(depths))
|
| 89 |
+
counts = ", ".join(f"{depth}: {quotient + (index < remainder)}" for index, depth in enumerate(depths))
|
| 90 |
+
distribution.add_row(split, f"{config.count(split):,}", counts)
|
| 91 |
+
console.print(distribution)
|
| 92 |
+
selected = preview_indices(config, args.dry_run)
|
| 93 |
+
console.print("[cyan]Preview spans shallow through deep tasks; it is not a random frequency sample.[/cyan]")
|
| 94 |
+
for split, index in selected:
|
| 95 |
+
record = make_record(config, split, index, tokenizer, token_ids)
|
| 96 |
+
console.print(Panel(Text(record["prompt"]), title=f"{record['example_id']} · depth {record['task_depth']}"))
|
| 97 |
+
table = Table(title=f"Start {record['initial_state']} · seed {record['seed']}")
|
| 98 |
+
for column in ("Step / loop", "Expected symbol", "Answer token ID"):
|
| 99 |
+
table.add_column(column)
|
| 100 |
+
for step, (symbol, token_id) in enumerate(zip(record["intermediate_states"], record["intermediate_token_ids"]), 1):
|
| 101 |
+
table.add_row(str(step), symbol, str(token_id))
|
| 102 |
+
console.print(table)
|
| 103 |
+
console.print(f"[bold green]Final: {record['final_state']}[/bold green] · answer position {record['answer_position']}")
|
| 104 |
+
console.print(f"[yellow]Dry run complete: {len(selected)} examples; no dataset files written.[/yellow]")
|
| 105 |
+
return
|
| 106 |
+
|
| 107 |
+
root = Path(__file__).resolve().parents[2]
|
| 108 |
+
sources = sorted((root / "scripts/dataset").glob("*.py"))
|
| 109 |
+
provenance = {
|
| 110 |
+
"command": [sys.executable, "-m", "scripts.dataset", *sys.argv[1:]],
|
| 111 |
+
"python": platform.python_version(),
|
| 112 |
+
"packages": {name: version(name) for name in ("transformers", "tokenizers", "huggingface-hub", "rich")},
|
| 113 |
+
"git_head": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip(),
|
| 114 |
+
"git_status": subprocess.check_output(["git", "status", "--short"], cwd=root, text=True),
|
| 115 |
+
"source_sha256": {str(path.relative_to(root)): hashlib.sha256(path.read_bytes()).hexdigest() for path in sources},
|
| 116 |
+
}
|
| 117 |
+
with console.status("Generating and checking every example…"):
|
| 118 |
+
dataset = generate_dataset(config, tokenizer, token_ids)
|
| 119 |
+
write_dataset(output, dataset, config, token_ids, provenance)
|
| 120 |
+
with console.status("Reading files back and independently replaying seeds…"):
|
| 121 |
+
manifest = verify_dataset(output, tokenizer)
|
| 122 |
+
show_summary(console, manifest)
|
| 123 |
+
console.print(f"Dataset and manifest: [bold]{output.resolve()}[/bold]")
|
| 124 |
+
console.print("Data milestone complete. No training performed.")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
main()
|
scripts/dataset/dataset.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Seeded splits, JSONL persistence, and independent pointer-data validation."""
|
| 2 |
+
|
| 3 |
+
from collections import Counter
|
| 4 |
+
from dataclasses import asdict, dataclass
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from scripts.dataset.pointer import (PointerExample, example_seed, generate_example, validate_example,
|
| 11 |
+
generate_unconditioned_example, orbit_structure)
|
| 12 |
+
from scripts.dataset.symbols import MODEL_ID, MODEL_REVISION, validate_prompt_tokens, validate_symbols
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
SPLITS = ("train", "validation", "test", "depth_test")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True)
|
| 19 |
+
class DatasetConfig:
|
| 20 |
+
graph_mode: str = "unique_path"
|
| 21 |
+
paired_horizons: bool = False
|
| 22 |
+
seed: int = 17
|
| 23 |
+
train_count: int = 10_000
|
| 24 |
+
validation_count: int = 1_000
|
| 25 |
+
test_count: int = 1_000
|
| 26 |
+
depth_test_count: int = 1_000
|
| 27 |
+
min_depth: int = 1
|
| 28 |
+
max_train_depth: int = 8
|
| 29 |
+
max_eval_depth: int = 16
|
| 30 |
+
train_depths: list[int] | None = None
|
| 31 |
+
|
| 32 |
+
def validate(self) -> None:
|
| 33 |
+
if any(type(value) is not int for name, value in asdict(self).items() if name not in ("train_depths", "graph_mode", "paired_horizons")):
|
| 34 |
+
raise ValueError("Dataset configuration values must be integers")
|
| 35 |
+
if self.graph_mode not in ("unique_path", "mixture", "random_function", "permutation", "full_cycle"):
|
| 36 |
+
raise ValueError("Unknown graph_mode")
|
| 37 |
+
if type(self.paired_horizons) is not bool or (self.paired_horizons and self.graph_mode == "unique_path"):
|
| 38 |
+
raise ValueError("paired_horizons requires depth-independent graph sampling")
|
| 39 |
+
if self.seed < 0 or min(self.count(split) for split in SPLITS) < 1:
|
| 40 |
+
raise ValueError("seed must be nonnegative and all split counts must be positive")
|
| 41 |
+
if not 1 <= self.min_depth <= self.max_train_depth < self.max_eval_depth <= (25 if self.graph_mode == "unique_path" else 256):
|
| 42 |
+
raise ValueError("Require ordered positive train/eval depths within the graph-mode limit")
|
| 43 |
+
|
| 44 |
+
if self.train_depths is not None:
|
| 45 |
+
if (not self.train_depths or any(type(d) is not int for d in self.train_depths)
|
| 46 |
+
or sorted(set(self.train_depths)) != self.train_depths
|
| 47 |
+
or min(self.train_depths) < self.min_depth or max(self.train_depths) != self.max_train_depth):
|
| 48 |
+
raise ValueError("train_depths must be sorted unique integers within bounds, ending at max_train_depth")
|
| 49 |
+
|
| 50 |
+
def paired(self, split: str) -> bool:
|
| 51 |
+
"""Match evaluation horizons; retain independent graphs in training."""
|
| 52 |
+
return self.paired_horizons and split != "train"
|
| 53 |
+
|
| 54 |
+
def count(self, split: str) -> int:
|
| 55 |
+
if split not in SPLITS:
|
| 56 |
+
raise ValueError(f"Unknown split: {split}")
|
| 57 |
+
return getattr(self, f"{split}_count")
|
| 58 |
+
|
| 59 |
+
def depths(self, split: str) -> range | list[int]:
|
| 60 |
+
if split not in SPLITS:
|
| 61 |
+
raise ValueError(f"Unknown split: {split}")
|
| 62 |
+
if split == "train" and self.train_depths is not None:
|
| 63 |
+
return self.train_depths
|
| 64 |
+
if split == "depth_test":
|
| 65 |
+
return range(self.max_train_depth + 1, self.max_eval_depth + 1)
|
| 66 |
+
return range(self.min_depth, self.max_train_depth + 1)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def preview_indices(config: DatasetConfig, count: int) -> list[tuple[str, int]]:
|
| 70 |
+
"""Select actual records spanning available depths, without sampling programs.
|
| 71 |
+
|
| 72 |
+
This is a depth showcase, not a random sample of split frequencies. Keep only
|
| 73 |
+
enough candidate indices to fill a preview, even for very large split counts.
|
| 74 |
+
"""
|
| 75 |
+
config.validate()
|
| 76 |
+
if count not in (5, 10):
|
| 77 |
+
raise ValueError("Preview count must be 5 or 10")
|
| 78 |
+
candidates = [
|
| 79 |
+
(split, index, config.depths(split)[index % len(config.depths(split))])
|
| 80 |
+
for split in SPLITS
|
| 81 |
+
for index in range(min(config.count(split), count * len(config.depths(split))))
|
| 82 |
+
]
|
| 83 |
+
depths = sorted({depth for _, _, depth in candidates})
|
| 84 |
+
count = min(count, len(candidates))
|
| 85 |
+
selected = []
|
| 86 |
+
for position in range(count):
|
| 87 |
+
target = depths[position * (len(depths) - 1) // (count - 1)]
|
| 88 |
+
candidate = min(candidates, key=lambda item: (
|
| 89 |
+
abs(item[2] - target), (SPLITS.index(item[0]) - position) % len(SPLITS), item[1],
|
| 90 |
+
))
|
| 91 |
+
candidates.remove(candidate)
|
| 92 |
+
selected.append(candidate[:2])
|
| 93 |
+
return selected
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def make_record(config: DatasetConfig, split: str, index: int, tokenizer: Any, token_ids: dict[str, int]) -> dict:
|
| 97 |
+
"""Index determines balanced depth and an independently derived random seed."""
|
| 98 |
+
depths = config.depths(split)
|
| 99 |
+
depth = depths[index % len(depths)]
|
| 100 |
+
graph_index = index // len(depths) if config.paired(split) else index
|
| 101 |
+
seed = example_seed(config.seed, split, graph_index)
|
| 102 |
+
example = (generate_example(seed, depth, split, index) if config.graph_mode == "unique_path" else
|
| 103 |
+
generate_unconditioned_example(seed, depth, split, index, config.graph_mode))
|
| 104 |
+
validate_example(example)
|
| 105 |
+
position = validate_prompt_tokens(tokenizer, example.prompt, token_ids)
|
| 106 |
+
return {
|
| 107 |
+
**example.to_dict(),
|
| 108 |
+
**({"graph_sampling": config.graph_mode, **orbit_structure(dict(example.mapping), example.initial_state)}
|
| 109 |
+
if config.graph_mode != "unique_path" else {}),
|
| 110 |
+
"answer_position": position,
|
| 111 |
+
"prompt_token_count": position + 1,
|
| 112 |
+
"intermediate_token_ids": [token_ids[symbol] for symbol in example.intermediate_states],
|
| 113 |
+
"final_token_id": token_ids[example.final_state],
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def generate_dataset(config: DatasetConfig, tokenizer: Any, token_ids: dict[str, int]) -> dict[str, list[dict]]:
|
| 118 |
+
config.validate()
|
| 119 |
+
dataset = {}
|
| 120 |
+
seen = {}
|
| 121 |
+
for split in SPLITS:
|
| 122 |
+
records = []
|
| 123 |
+
for index in range(config.count(split)):
|
| 124 |
+
record = make_record(config, split, index, tokenizer, token_ids)
|
| 125 |
+
fingerprint = record["mapping_sha256"]
|
| 126 |
+
if fingerprint in seen and (not config.paired(split) or seen[fingerprint] != split):
|
| 127 |
+
raise ValueError("Repeated rule table within/across splits; select a different master seed")
|
| 128 |
+
seen[fingerprint] = split
|
| 129 |
+
records.append(record)
|
| 130 |
+
dataset[split] = records
|
| 131 |
+
return dataset
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def encode_records(records: list[dict]) -> bytes:
|
| 135 |
+
return "".join(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n" for record in records).encode()
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def summarize(records: list[dict]) -> dict:
|
| 139 |
+
return {
|
| 140 |
+
"count": len(records),
|
| 141 |
+
"depth_counts": dict(sorted(Counter(str(record["task_depth"]) for record in records).items())),
|
| 142 |
+
"prompt_tokens_min": min(record["prompt_token_count"] for record in records),
|
| 143 |
+
"prompt_tokens_max": max(record["prompt_token_count"] for record in records),
|
| 144 |
+
"sha256": hashlib.sha256(encode_records(records)).hexdigest(),
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def write_dataset(
|
| 149 |
+
output: Path, dataset: dict[str, list[dict]], config: DatasetConfig,
|
| 150 |
+
token_ids: dict[str, int], provenance: dict,
|
| 151 |
+
) -> dict:
|
| 152 |
+
"""Write a new directory only; the manifest is the final completion marker."""
|
| 153 |
+
manifest = {
|
| 154 |
+
"schema_version": 1,
|
| 155 |
+
"generator": "pointer-v1" if config.graph_mode == "unique_path" else "pointer-v2",
|
| 156 |
+
"config": asdict(config),
|
| 157 |
+
"tokenizer": {
|
| 158 |
+
"model_id": MODEL_ID, "revision": MODEL_REVISION,
|
| 159 |
+
"add_special_tokens": False, "chat_template": False,
|
| 160 |
+
"answer_text_prefix": " ", "symbol_token_ids": token_ids,
|
| 161 |
+
},
|
| 162 |
+
"semantics": ("explicit_steps_unique_nominal_path_no_post_completion_targets" if config.graph_mode == "unique_path"
|
| 163 |
+
else "depth_independent_graphs_exact_steps_cycles_allowed"),
|
| 164 |
+
"splits": {split: summarize(dataset[split]) for split in SPLITS},
|
| 165 |
+
"validation": {"unique_mapping_tables": not config.paired_horizons, "exact_targets": True, "prompt_symbol_tokens": True},
|
| 166 |
+
"provenance": provenance,
|
| 167 |
+
}
|
| 168 |
+
output.mkdir(parents=True, exist_ok=False)
|
| 169 |
+
for split in SPLITS:
|
| 170 |
+
(output / f"{split}.jsonl").write_bytes(encode_records(dataset[split]))
|
| 171 |
+
(output / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
|
| 172 |
+
return manifest
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def verify_dataset(output: Path, tokenizer: Any) -> dict:
|
| 176 |
+
"""Check bytes, labels, split boundaries, tokenizer contexts, and seed replay.
|
| 177 |
+
|
| 178 |
+
Regeneration alone is insufficient: validate_example separately reparses the
|
| 179 |
+
rendered rules and executes them to check the actual stored trajectory.
|
| 180 |
+
"""
|
| 181 |
+
manifest = json.loads((output / "manifest.json").read_text())
|
| 182 |
+
if manifest["schema_version"] != 1 or manifest["generator"] not in ("pointer-v1", "pointer-v2"):
|
| 183 |
+
raise ValueError("Unsupported dataset version")
|
| 184 |
+
config = DatasetConfig(**manifest["config"])
|
| 185 |
+
config.validate()
|
| 186 |
+
expected_generator = "pointer-v1" if config.graph_mode == "unique_path" else "pointer-v2"
|
| 187 |
+
if manifest["generator"] != expected_generator:
|
| 188 |
+
raise ValueError("Generator version and graph sampling mode disagree")
|
| 189 |
+
token_ids = validate_symbols(tokenizer)
|
| 190 |
+
if manifest["tokenizer"] != {
|
| 191 |
+
"model_id": MODEL_ID, "revision": MODEL_REVISION,
|
| 192 |
+
"add_special_tokens": False, "chat_template": False,
|
| 193 |
+
"answer_text_prefix": " ", "symbol_token_ids": token_ids,
|
| 194 |
+
}:
|
| 195 |
+
raise ValueError("Tokenizer metadata mismatch")
|
| 196 |
+
seen = {}
|
| 197 |
+
for split in SPLITS:
|
| 198 |
+
payload = (output / f"{split}.jsonl").read_bytes()
|
| 199 |
+
if hashlib.sha256(payload).hexdigest() != manifest["splits"][split]["sha256"]:
|
| 200 |
+
raise ValueError(f"{split}: file checksum mismatch")
|
| 201 |
+
records = [json.loads(line) for line in payload.splitlines()]
|
| 202 |
+
if len(records) != config.count(split) or summarize(records) != manifest["splits"][split]:
|
| 203 |
+
raise ValueError(f"{split}: split count/summary mismatch")
|
| 204 |
+
for index, record in enumerate(records):
|
| 205 |
+
example = PointerExample(**{key: record[key] for key in PointerExample.__dataclass_fields__})
|
| 206 |
+
validate_example(example)
|
| 207 |
+
if record != make_record(config, split, index, tokenizer, token_ids):
|
| 208 |
+
raise ValueError(f"{split} record {index}: seed replay/token metadata mismatch")
|
| 209 |
+
if example.mapping_sha256 in seen and (not config.paired(split) or seen[example.mapping_sha256] != split):
|
| 210 |
+
raise ValueError("Repeated rule table within/across splits")
|
| 211 |
+
seen[example.mapping_sha256] = split
|
| 212 |
+
return manifest
|
scripts/dataset/pointer.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Explicit-step pointer programs and exact, one-based execution targets."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import asdict, dataclass
|
| 4 |
+
import hashlib
|
| 5 |
+
import json
|
| 6 |
+
import random
|
| 7 |
+
import re
|
| 8 |
+
from typing import Sequence
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
SYMBOLS = tuple("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
| 12 |
+
PAIR = re.compile(r"\(\s*([A-Z])\s*,\s*([A-Z])\s*\)")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def parse_mapping(text: str) -> dict[str, str]:
|
| 16 |
+
"""Parse pairs such as '(A,C) (C,D) (D, E)', rejecting ambiguous rules."""
|
| 17 |
+
matches = list(PAIR.finditer(text))
|
| 18 |
+
if not matches or PAIR.sub("", text).strip():
|
| 19 |
+
raise ValueError("Mapping must be whitespace-separated (A,C) pairs")
|
| 20 |
+
mapping = {}
|
| 21 |
+
for match in matches:
|
| 22 |
+
source, target = match.groups()
|
| 23 |
+
if source in mapping:
|
| 24 |
+
raise ValueError(f"Duplicate source symbol: {source}")
|
| 25 |
+
mapping[source] = target
|
| 26 |
+
return mapping
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def execute(mapping: dict[str, str], start: str, steps: int) -> list[str]:
|
| 30 |
+
"""Return states after transitions 1..steps, excluding the initial state.
|
| 31 |
+
|
| 32 |
+
This reference interpreter permits cycles and partial tables, provided every
|
| 33 |
+
requested transition exists. The dataset sampler applies stricter constraints.
|
| 34 |
+
"""
|
| 35 |
+
if type(steps) is not int or steps < 0:
|
| 36 |
+
raise ValueError("steps must be a nonnegative integer")
|
| 37 |
+
states = []
|
| 38 |
+
current = start
|
| 39 |
+
for step in range(1, steps + 1):
|
| 40 |
+
if current not in mapping:
|
| 41 |
+
raise ValueError(f"No rule for {current!r} at step {step}")
|
| 42 |
+
current = mapping[current]
|
| 43 |
+
states.append(current)
|
| 44 |
+
return states
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def check_predictions(
|
| 48 |
+
mapping: dict[str, str], start: str, predictions: Sequence[str], steps: int,
|
| 49 |
+
) -> list[bool]:
|
| 50 |
+
"""Score decoded symbols at steps 1..N against the exact nominal trajectory.
|
| 51 |
+
|
| 52 |
+
Predictions may be a prefix (too few model loops), but cannot extend beyond
|
| 53 |
+
the requested depth. Invalid/multi-symbol answers are incorrect; no lenient
|
| 54 |
+
extraction of a letter from prose is performed. Leading whitespace is allowed.
|
| 55 |
+
"""
|
| 56 |
+
targets = execute(mapping, start, steps)
|
| 57 |
+
if isinstance(predictions, str) or len(predictions) > steps:
|
| 58 |
+
raise ValueError("Provide a sequence of at most 'steps' decoded predictions")
|
| 59 |
+
return [prediction.strip() == target for prediction, target in zip(predictions, targets)]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def render_prompt(pairs: list[list[str]], start: str, steps: int) -> str:
|
| 63 |
+
# Spaces before symbols prevent Qwen punctuation/letter token merges.
|
| 64 |
+
rules = " ".join(f"( {source}, {target})" for source, target in pairs)
|
| 65 |
+
return f"Rules: {rules}\nStart: {start}\nSteps: {steps}\nAnswer:"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def mapping_fingerprint(mapping: dict[str, str]) -> str:
|
| 69 |
+
"""Ignore rule order, start, and depth when detecting reused rule tables."""
|
| 70 |
+
payload = json.dumps(sorted(mapping.items()), separators=(",", ":"))
|
| 71 |
+
return hashlib.sha256(payload.encode()).hexdigest()
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def example_seed(master_seed: int, split: str, index: int, attempt: int = 0) -> int:
|
| 75 |
+
payload = f"pointer-v1:{master_seed}:{split}:{index}:{attempt}"
|
| 76 |
+
return int.from_bytes(hashlib.sha256(payload.encode()).digest()[:8], "big")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@dataclass
|
| 80 |
+
class PointerExample:
|
| 81 |
+
schema_version: int
|
| 82 |
+
example_id: str
|
| 83 |
+
family: str
|
| 84 |
+
split: str
|
| 85 |
+
seed: int
|
| 86 |
+
task_depth: int
|
| 87 |
+
mapping: list[list[str]]
|
| 88 |
+
mapping_sha256: str
|
| 89 |
+
initial_state: str
|
| 90 |
+
intermediate_states: list[str]
|
| 91 |
+
final_state: str
|
| 92 |
+
prompt: str
|
| 93 |
+
|
| 94 |
+
def to_dict(self) -> dict:
|
| 95 |
+
return asdict(self)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def generate_example(seed: int, depth: int, split: str, index: int) -> PointerExample:
|
| 99 |
+
"""Sample a full table, conditioned on a non-repeating nominal path.
|
| 100 |
+
|
| 101 |
+
Every example uses all 26 symbols, keeping rule count independent of depth.
|
| 102 |
+
The d+1 distinct path states prevent an early/repeated final answer; remaining
|
| 103 |
+
edges are independent random choices and can cycle outside the nominal path.
|
| 104 |
+
Rule order is shuffled so the solution is not printed in execution order.
|
| 105 |
+
"""
|
| 106 |
+
if type(depth) is not int or not 1 <= depth < len(SYMBOLS):
|
| 107 |
+
raise ValueError("depth must be an integer between 1 and 25")
|
| 108 |
+
if type(seed) is not int or seed < 0:
|
| 109 |
+
raise ValueError("seed must be a nonnegative integer")
|
| 110 |
+
rng = random.Random(seed)
|
| 111 |
+
path = rng.sample(SYMBOLS, depth + 1)
|
| 112 |
+
mapping = dict(zip(path[:-1], path[1:]))
|
| 113 |
+
for symbol in SYMBOLS:
|
| 114 |
+
if symbol not in mapping:
|
| 115 |
+
mapping[symbol] = rng.choice(SYMBOLS)
|
| 116 |
+
pairs = [[source, mapping[source]] for source in SYMBOLS]
|
| 117 |
+
rng.shuffle(pairs)
|
| 118 |
+
states = execute(mapping, path[0], depth)
|
| 119 |
+
return PointerExample(
|
| 120 |
+
schema_version=1, example_id=f"{split}-{index:06d}-{seed:016x}",
|
| 121 |
+
family="pointer", split=split, seed=seed, task_depth=depth,
|
| 122 |
+
mapping=pairs, mapping_sha256=mapping_fingerprint(mapping),
|
| 123 |
+
initial_state=path[0], intermediate_states=states, final_state=states[-1],
|
| 124 |
+
prompt=render_prompt(pairs, path[0], depth),
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def validate_example(example: PointerExample) -> None:
|
| 129 |
+
"""Reparse the actual prompt and independently recompute every saved target."""
|
| 130 |
+
if example.schema_version not in (1, 2) or example.family != "pointer":
|
| 131 |
+
raise ValueError("Unsupported pointer record schema/family")
|
| 132 |
+
if type(example.task_depth) is not int or not 1 <= example.task_depth <= (25 if example.schema_version == 1 else 256):
|
| 133 |
+
raise ValueError("Invalid task depth")
|
| 134 |
+
if any(len(pair) != 2 for pair in example.mapping):
|
| 135 |
+
raise ValueError("Every mapping entry must have two symbols")
|
| 136 |
+
mapping = dict(example.mapping)
|
| 137 |
+
if len(example.mapping) != len(SYMBOLS) or set(mapping) != set(SYMBOLS):
|
| 138 |
+
raise ValueError("Each vocabulary symbol must have exactly one rule")
|
| 139 |
+
if not set(mapping.values()) <= set(SYMBOLS):
|
| 140 |
+
raise ValueError("Mapping target outside vocabulary")
|
| 141 |
+
if example.mapping_sha256 != mapping_fingerprint(mapping):
|
| 142 |
+
raise ValueError("Mapping fingerprint mismatch")
|
| 143 |
+
if example.prompt != render_prompt(example.mapping, example.initial_state, example.task_depth):
|
| 144 |
+
raise ValueError("Prompt disagrees with structured task")
|
| 145 |
+
parsed = parse_mapping(example.prompt.splitlines()[0].removeprefix("Rules: "))
|
| 146 |
+
states = execute(parsed, example.initial_state, example.task_depth)
|
| 147 |
+
if states != example.intermediate_states or example.final_state != states[-1]:
|
| 148 |
+
raise ValueError("Intermediate/final labels disagree with reference execution")
|
| 149 |
+
if example.schema_version == 1 and len(set([example.initial_state, *states])) != example.task_depth + 1:
|
| 150 |
+
raise ValueError("Nominal path must not repeat a state")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def generate_unconditioned_example(seed: int, depth: int, split: str, index: int,
|
| 154 |
+
graph_mode: str = "mixture") -> PointerExample:
|
| 155 |
+
"""Sample the graph/start/order before using depth; permit cyclic execution.
|
| 156 |
+
|
| 157 |
+
Equal-probability mixture: random function, random permutation, full cycle.
|
| 158 |
+
All three have 26 rules; graph selection never consults requested horizon.
|
| 159 |
+
"""
|
| 160 |
+
if type(depth) is not int or not 1 <= depth <= 256 or type(seed) is not int or seed < 0:
|
| 161 |
+
raise ValueError("Need nonnegative seed and depth in 1..256")
|
| 162 |
+
rng = random.Random(seed)
|
| 163 |
+
kinds = ("random_function", "permutation", "full_cycle")
|
| 164 |
+
kind = rng.choice(kinds) if graph_mode == "mixture" else graph_mode
|
| 165 |
+
if kind not in kinds:
|
| 166 |
+
raise ValueError("Unknown depth-independent graph mode")
|
| 167 |
+
if kind == "random_function":
|
| 168 |
+
mapping = {s: rng.choice(SYMBOLS) for s in SYMBOLS}
|
| 169 |
+
else:
|
| 170 |
+
order = rng.sample(SYMBOLS, len(SYMBOLS))
|
| 171 |
+
mapping = (dict(zip(SYMBOLS, order)) if kind == "permutation" else
|
| 172 |
+
{s: order[(i + 1) % len(order)] for i, s in enumerate(order)})
|
| 173 |
+
start = rng.choice(SYMBOLS)
|
| 174 |
+
pairs = [[s, mapping[s]] for s in SYMBOLS]
|
| 175 |
+
rng.shuffle(pairs)
|
| 176 |
+
states = execute(mapping, start, depth)
|
| 177 |
+
return PointerExample(2, f"{split}-{index:06d}-{seed:016x}", "pointer", split, seed,
|
| 178 |
+
depth, pairs, mapping_fingerprint(mapping), start, states, states[-1],
|
| 179 |
+
render_prompt(pairs, start, depth))
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def orbit_structure(mapping: dict[str, str], start: str) -> dict[str, int]:
|
| 183 |
+
"""Length before the first cycle and cycle period, independent of horizon."""
|
| 184 |
+
seen = {}
|
| 185 |
+
state = start
|
| 186 |
+
while state not in seen:
|
| 187 |
+
seen[state] = len(seen)
|
| 188 |
+
state = mapping[state]
|
| 189 |
+
return {"transient_length": seen[state], "cycle_period": len(seen) - seen[state]}
|
scripts/dataset/symbols.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Validate the actual tokenizer in the exact pointer prompt/answer contexts."""
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
import re
|
| 5 |
+
|
| 6 |
+
from scripts.dataset.pointer import SYMBOLS
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
|
| 10 |
+
MODEL_REVISION = "7ae557604adf67be50417f59c2c2f167def9a775"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def validate_prompt_tokens(tokenizer: Any, prompt: str, token_ids: dict[str, int]) -> int:
|
| 14 |
+
"""Check every space-prefixed symbol span; return the answer readout index.
|
| 15 |
+
|
| 16 |
+
Prompts are raw text, with no chat template or added special tokens. The
|
| 17 |
+
answer position is the final ':' token's index (a next-token readout).
|
| 18 |
+
"""
|
| 19 |
+
if not prompt.endswith("\nAnswer:"):
|
| 20 |
+
raise ValueError("Prompt must end with '\\nAnswer:' without a trailing space")
|
| 21 |
+
encoded = tokenizer(prompt, add_special_tokens=False, return_offsets_mapping=True)
|
| 22 |
+
spans = dict(zip(map(tuple, encoded["offset_mapping"]), encoded["input_ids"]))
|
| 23 |
+
for match in re.finditer(r" [A-Z](?=[,)\n])", prompt):
|
| 24 |
+
if spans.get(match.span()) != token_ids[match.group()[1:]]:
|
| 25 |
+
raise ValueError(f"Symbol does not retain its token at {match.span()}: {match.group()!r}")
|
| 26 |
+
return len(encoded["input_ids"]) - 1
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def validate_symbols(tokenizer: Any) -> dict[str, int]:
|
| 30 |
+
"""Require one unique token per ' '+symbol in rules, starts, and answers."""
|
| 31 |
+
token_ids = {}
|
| 32 |
+
for symbol in SYMBOLS:
|
| 33 |
+
ids = tokenizer.encode(" " + symbol, add_special_tokens=False)
|
| 34 |
+
if len(ids) != 1 or tokenizer.decode(ids) != " " + symbol:
|
| 35 |
+
raise ValueError(f"Symbol {symbol!r} is not one reversible space-prefixed token")
|
| 36 |
+
token_ids[symbol] = ids[0]
|
| 37 |
+
if len(set(token_ids.values())) != len(SYMBOLS):
|
| 38 |
+
raise ValueError("Answer tokens must be unique")
|
| 39 |
+
# Exhaustive pair contexts, and all 26 possible answer continuations.
|
| 40 |
+
for source in SYMBOLS:
|
| 41 |
+
prompt = "Rules: " + " ".join(f"( {source}, {target})" for target in SYMBOLS)
|
| 42 |
+
prompt += f"\nStart: {source}\nSteps: 1\nAnswer:"
|
| 43 |
+
validate_prompt_tokens(tokenizer, prompt, token_ids)
|
| 44 |
+
prefix = tokenizer.encode(prompt, add_special_tokens=False)
|
| 45 |
+
for target in SYMBOLS:
|
| 46 |
+
if tokenizer.encode(prompt + " " + target, add_special_tokens=False) != [*prefix, token_ids[target]]:
|
| 47 |
+
raise ValueError(f"Answer boundary merges for {target!r}")
|
| 48 |
+
return token_ids
|
scripts/dataset/terminal.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic, evaluation-only absorbing-terminal variants of pointer tasks."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import replace
|
| 4 |
+
|
| 5 |
+
from .pointer import PointerExample, execute, mapping_fingerprint, render_prompt, validate_example
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
TRANSFORM_VERSION = "absorbing-terminal-v1"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def absorbing_terminal(example: PointerExample) -> PointerExample:
|
| 12 |
+
"""Replace only the final state's outgoing edge, preserving nominal execution.
|
| 13 |
+
|
| 14 |
+
The original d+1 path states are distinct, so that edge is not followed until
|
| 15 |
+
loop d+1. The changed prompt is a new distribution, not a retention label
|
| 16 |
+
retroactively applied to the original task. Source IDs/seeds support pairing.
|
| 17 |
+
"""
|
| 18 |
+
validate_example(example)
|
| 19 |
+
if example.final_state in [example.initial_state, *example.intermediate_states[:-1]]:
|
| 20 |
+
raise ValueError("Terminal transform requires a final state not visited earlier; cyclic tasks need exact-step evaluation")
|
| 21 |
+
final = example.final_state
|
| 22 |
+
pairs = [[source, final if source == final else target] for source, target in example.mapping]
|
| 23 |
+
result = replace(
|
| 24 |
+
example, mapping=pairs, mapping_sha256=mapping_fingerprint(dict(pairs)),
|
| 25 |
+
prompt=render_prompt(pairs, example.initial_state, example.task_depth),
|
| 26 |
+
)
|
| 27 |
+
validate_example(result)
|
| 28 |
+
states = execute(dict(pairs), result.initial_state, result.task_depth + 2)
|
| 29 |
+
if states != [*example.intermediate_states, final, final]:
|
| 30 |
+
raise ValueError("Terminal transform changed the nominal trajectory")
|
| 31 |
+
return result
|
scripts/demo/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Public pointer demo: validated inputs and observations of native inference."""
|
scripts/demo/__main__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Download/run the final checkpoint, printing one JSON snapshot per readout."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
|
| 9 |
+
import torch
|
| 10 |
+
from huggingface_hub import snapshot_download
|
| 11 |
+
|
| 12 |
+
from scripts.demo.inference import DemoRequest, stream_inference
|
| 13 |
+
from scripts.recurrent_qwen.checkpoint import load_recurrent_checkpoint
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def main() -> None:
|
| 17 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 18 |
+
source = parser.add_mutually_exclusive_group(required=True)
|
| 19 |
+
source.add_argument("--checkpoint", type=Path)
|
| 20 |
+
source.add_argument("--model", help="Hugging Face model repository ID")
|
| 21 |
+
parser.add_argument("--revision", default="main")
|
| 22 |
+
parser.add_argument("--request", required=True, type=Path, help="JSON exported from the demo")
|
| 23 |
+
parser.add_argument("--device", choices=("cpu", "cuda", "mps"), default="cpu")
|
| 24 |
+
parser.add_argument("--download", action="store_true", help="Allow explicit Hub/base downloads")
|
| 25 |
+
parser.add_argument("--max-steps", type=int, default=256)
|
| 26 |
+
parser.add_argument("--loop-cap", type=int, default=272)
|
| 27 |
+
args = parser.parse_args()
|
| 28 |
+
request = DemoRequest.parse(json.loads(args.request.read_text()), max_steps=args.max_steps)
|
| 29 |
+
if args.loop_cap < request.steps:
|
| 30 |
+
parser.error("--loop-cap must cover the requested steps")
|
| 31 |
+
path = args.checkpoint or Path(snapshot_download(args.model, revision=args.revision,
|
| 32 |
+
local_files_only=not args.download))
|
| 33 |
+
torch.set_num_threads(4)
|
| 34 |
+
torch.use_deterministic_algorithms(True)
|
| 35 |
+
torch.backends.cuda.matmul.allow_tf32 = False
|
| 36 |
+
torch.backends.cudnn.allow_tf32 = False
|
| 37 |
+
model, tokenizer, spec = load_recurrent_checkpoint(path, device=args.device, download=args.download)
|
| 38 |
+
model.config._attn_implementation = "sdpa"
|
| 39 |
+
model.requires_grad_(False)
|
| 40 |
+
for frame in stream_inference(model, tokenizer, spec, request,
|
| 41 |
+
checkpoint_id=args.model or str(path), loop_cap=args.loop_cap):
|
| 42 |
+
print(json.dumps(frame, allow_nan=False), flush=True)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
if __name__ == "__main__":
|
| 46 |
+
main()
|
scripts/demo/gradio_app.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The same small queued streaming API for Hugging Face and local serving."""
|
| 2 |
+
|
| 3 |
+
from typing import Callable
|
| 4 |
+
|
| 5 |
+
import gradio as gr
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def create_app(predict: Callable, *, model_url: str | None = None) -> gr.Blocks:
|
| 9 |
+
with gr.Blocks(title="LoopFormer · live inference", analytics_enabled=False) as app:
|
| 10 |
+
gr.Markdown("# LoopFormer\nFollow a graph one recurrent loop at a time. "
|
| 11 |
+
"This endpoint streams real model predictions; it does not substitute reference answers.")
|
| 12 |
+
links = "[Open the interactive playground](https://mukundshankar-dev.github.io/projects/loopformer/demo/)"
|
| 13 |
+
if model_url:
|
| 14 |
+
links += f" · [Download the model]({model_url})"
|
| 15 |
+
gr.Markdown(links)
|
| 16 |
+
request_input = gr.JSON(label="Graph request: mapping, start, steps")
|
| 17 |
+
result = gr.JSON(label="Streamed trace")
|
| 18 |
+
run = gr.Button("Run model", variant="primary")
|
| 19 |
+
run.click(predict, inputs=request_input, outputs=result, api_name="predict",
|
| 20 |
+
concurrency_limit=1, stream_every=0.05)
|
| 21 |
+
return app.queue(max_size=16, default_concurrency_limit=1)
|
scripts/demo/inference.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stream native loop observations without reference-state feedback."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
import math
|
| 5 |
+
from queue import Queue
|
| 6 |
+
from threading import Event, Thread
|
| 7 |
+
from time import perf_counter
|
| 8 |
+
from typing import Any, Iterator
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
|
| 12 |
+
from scripts.dataset.pointer import SYMBOLS, execute, render_prompt
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
SAFETY_CAP = 272
|
| 16 |
+
STOP_THRESHOLD = 0.5
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass(frozen=True)
|
| 20 |
+
class DemoRequest:
|
| 21 |
+
"""Ordered full A–Z table; order is part of the model's actual prompt."""
|
| 22 |
+
|
| 23 |
+
pairs: tuple[tuple[str, str], ...]
|
| 24 |
+
start: str
|
| 25 |
+
steps: int
|
| 26 |
+
|
| 27 |
+
@classmethod
|
| 28 |
+
def parse(cls, value: Any, *, max_steps: int = 256) -> "DemoRequest":
|
| 29 |
+
if not isinstance(value, dict) or set(value) != {"mapping", "start", "steps"}:
|
| 30 |
+
raise ValueError("Supply exactly mapping, start, and steps")
|
| 31 |
+
pairs = value["mapping"]
|
| 32 |
+
if not isinstance(pairs, list) or len(pairs) != 26:
|
| 33 |
+
raise ValueError("Supply all 26 A–Z rules, with one destination per node")
|
| 34 |
+
if any(not isinstance(p, list) or len(p) != 2 or
|
| 35 |
+
any(not isinstance(s, str) or s not in SYMBOLS for s in p) for p in pairs):
|
| 36 |
+
raise ValueError("Each rule must be a pair of uppercase A–Z letters")
|
| 37 |
+
if {p[0] for p in pairs} != set(SYMBOLS):
|
| 38 |
+
raise ValueError("Each A–Z source must occur exactly once")
|
| 39 |
+
if not isinstance(value["start"], str) or value["start"] not in SYMBOLS:
|
| 40 |
+
raise ValueError("Start must be an uppercase A–Z letter")
|
| 41 |
+
if type(value["steps"]) is not int or not 1 <= value["steps"] <= max_steps:
|
| 42 |
+
raise ValueError(f"Steps must be an integer from 1 to {max_steps}")
|
| 43 |
+
return cls(tuple(tuple(p) for p in pairs), value["start"], value["steps"])
|
| 44 |
+
|
| 45 |
+
def payload(self) -> dict:
|
| 46 |
+
return {"mapping": [list(p) for p in self.pairs], "start": self.start, "steps": self.steps}
|
| 47 |
+
|
| 48 |
+
def prompt(self) -> str:
|
| 49 |
+
return render_prompt([list(p) for p in self.pairs], self.start, self.steps)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class InferenceCancelled(Exception):
|
| 53 |
+
"""The client closed its generator; stop at the next completed readout."""
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def score_trace(request: DemoRequest, rows: list[dict], stop_reason: str) -> dict:
|
| 57 |
+
"""Final letter, strict requested prefix and timing are separate metrics."""
|
| 58 |
+
expected = execute(dict(request.pairs), request.start, request.steps)
|
| 59 |
+
predictions = [row["prediction"] for row in rows]
|
| 60 |
+
exact_stop = stop_reason == "head" and len(rows) == request.steps
|
| 61 |
+
correct = bool(predictions) and predictions[-1] == expected[-1]
|
| 62 |
+
complete = len(predictions) >= request.steps and predictions[:request.steps] == expected
|
| 63 |
+
return {"answer_correct": correct, "complete_trajectory": complete,
|
| 64 |
+
"exact_stop": exact_stop, "joint_success": correct and exact_stop,
|
| 65 |
+
"stop_reason": stop_reason, "executed_loops": len(rows),
|
| 66 |
+
"first_error": next((r["loop"] for r in rows[:request.steps] if not r["correct"]), None)}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def stream_inference(model: Any, tokenizer: Any, spec: dict, request: DemoRequest,
|
| 70 |
+
*, checkpoint_id: str, cancel: Event | None = None,
|
| 71 |
+
loop_cap: int | None = None) -> Iterator[dict]:
|
| 72 |
+
"""Yield cumulative JSON snapshots as loops arrive, then a final summary.
|
| 73 |
+
|
| 74 |
+
One worker owns one native forward. The bounded 272-loop cap also bounds
|
| 75 |
+
the queue. A consumer must serialize calls to its model (Gradio concurrency
|
| 76 |
+
one). Closing the iterator cancels and joins the worker before releasing GPU
|
| 77 |
+
ownership. Targets are computed only in the consumer, never passed to R.
|
| 78 |
+
"""
|
| 79 |
+
if model.training or model.completion_head is None:
|
| 80 |
+
raise ValueError("Demo requires an eval-mode model with learned completion")
|
| 81 |
+
loop_cap = SAFETY_CAP if loop_cap is None else loop_cap
|
| 82 |
+
if type(loop_cap) is not int or loop_cap < 1:
|
| 83 |
+
raise ValueError("Loop cap must be a positive integer")
|
| 84 |
+
cancel = cancel if cancel is not None else Event()
|
| 85 |
+
queue: Queue = Queue()
|
| 86 |
+
rows: list[dict] = []
|
| 87 |
+
mapping = dict(request.pairs)
|
| 88 |
+
reference = request.start
|
| 89 |
+
previous_prediction = request.start
|
| 90 |
+
started = perf_counter()
|
| 91 |
+
metadata = {"schema_version": 1, "source": "live", "checkpoint": checkpoint_id,
|
| 92 |
+
"request": request.payload(), "threshold": STOP_THRESHOLD, "safety_cap": loop_cap}
|
| 93 |
+
|
| 94 |
+
def observe(loop, scores, stop_logits, state_logits):
|
| 95 |
+
if cancel.is_set():
|
| 96 |
+
raise InferenceCancelled()
|
| 97 |
+
scores = scores[0].float().cpu()
|
| 98 |
+
probability = float(stop_logits.sigmoid().item())
|
| 99 |
+
if not torch.isfinite(scores).all() or not math.isfinite(probability):
|
| 100 |
+
raise FloatingPointError("Nonfinite model output")
|
| 101 |
+
top = scores.topk(3)
|
| 102 |
+
probabilities = scores.softmax(-1)
|
| 103 |
+
queue.put(("loop", {"loop": loop, "prediction": SYMBOLS[int(scores.argmax())],
|
| 104 |
+
"direct_prediction": SYMBOLS[int(state_logits[0].argmax())] if state_logits is not None else None,
|
| 105 |
+
"stop_probability": probability,
|
| 106 |
+
"top3": [{"symbol": SYMBOLS[i], "probability": float(probabilities[i])}
|
| 107 |
+
for i in top.indices.tolist()],
|
| 108 |
+
"arrival_seconds": perf_counter() - started}))
|
| 109 |
+
|
| 110 |
+
def run():
|
| 111 |
+
try:
|
| 112 |
+
if cancel.is_set():
|
| 113 |
+
raise InferenceCancelled()
|
| 114 |
+
device = next(model.parameters()).device
|
| 115 |
+
encoded = tokenizer(request.prompt(), add_special_tokens=False, return_tensors="pt")
|
| 116 |
+
with torch.inference_mode():
|
| 117 |
+
result = model(input_ids=encoded["input_ids"].to(device),
|
| 118 |
+
attention_mask=encoded["attention_mask"].to(device),
|
| 119 |
+
num_loops=loop_cap, completion_threshold=STOP_THRESHOLD,
|
| 120 |
+
readout_token_ids=spec["token_ids"], loop_callback=observe)
|
| 121 |
+
signaled = result.stop_logits[0, -1].sigmoid().item() >= STOP_THRESHOLD
|
| 122 |
+
queue.put(("done", "head" if signaled else "cap"))
|
| 123 |
+
except InferenceCancelled:
|
| 124 |
+
queue.put(("cancelled", None))
|
| 125 |
+
except Exception as error:
|
| 126 |
+
# Transport the original failure to the owning generator, never hide it.
|
| 127 |
+
queue.put(("error", error))
|
| 128 |
+
|
| 129 |
+
worker = Thread(target=run, name="loopformer-inference", daemon=True)
|
| 130 |
+
worker.start()
|
| 131 |
+
try:
|
| 132 |
+
while True:
|
| 133 |
+
kind, value = queue.get()
|
| 134 |
+
if kind == "error":
|
| 135 |
+
raise value
|
| 136 |
+
if kind == "cancelled":
|
| 137 |
+
return
|
| 138 |
+
if kind == "done":
|
| 139 |
+
yield {**metadata, "status": "complete", "trace": list(rows),
|
| 140 |
+
"summary": score_trace(request, rows, value),
|
| 141 |
+
"inference_seconds": perf_counter() - started}
|
| 142 |
+
return
|
| 143 |
+
reference = mapping[reference]
|
| 144 |
+
value.update(expected=reference, correct=value["prediction"] == reference,
|
| 145 |
+
edge_valid=mapping[previous_prediction] == value["prediction"])
|
| 146 |
+
previous_prediction = value["prediction"]
|
| 147 |
+
rows.append(value)
|
| 148 |
+
yield {**metadata, "status": "streaming", "trace": list(rows)}
|
| 149 |
+
finally:
|
| 150 |
+
cancel.set()
|
| 151 |
+
worker.join()
|
scripts/recurrent_qwen/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared-depth Qwen model mechanics, without import-time loading."""
|
| 2 |
+
|
| 3 |
+
from .model import RecurrentQwen
|
| 4 |
+
from .outputs import RecurrentOutput
|
| 5 |
+
|
| 6 |
+
__all__ = ["RecurrentQwen", "RecurrentOutput"]
|
scripts/recurrent_qwen/checkpoint.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Portable trainable recurrent tensors plus explicit architecture/base identity."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
from transformers import AutoTokenizer, Qwen2ForCausalLM
|
| 9 |
+
|
| 10 |
+
from .lora_utils import attach_recurrent_lora
|
| 11 |
+
from .model import RecurrentQwen
|
| 12 |
+
from scripts.dataset.pointer import SYMBOLS
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
EXECUTOR_FORMAT = "loopformer-executor-v2"
|
| 16 |
+
FORMAT = "loopformer-stage1-v1"
|
| 17 |
+
COMPLETION_FORMAT = "loopformer-stage1-completion-v1"
|
| 18 |
+
FIXED_PROMPT_FORMAT = "loopformer-stage1-fixed-prompt-v1"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def checkpoint_mode(spec: dict) -> str:
|
| 22 |
+
"""Legacy checkpoints mean full-sequence recurrence; never infer new modes."""
|
| 23 |
+
mode = spec.get("recurrence_mode", "full_sequence")
|
| 24 |
+
if spec.get("format") not in (FORMAT, COMPLETION_FORMAT, FIXED_PROMPT_FORMAT, EXECUTOR_FORMAT):
|
| 25 |
+
raise ValueError("Unsupported recurrent checkpoint format")
|
| 26 |
+
if (spec["format"] == EXECUTOR_FORMAT) != ("executor" in spec):
|
| 27 |
+
raise ValueError("Executor architecture requires the versioned executor checkpoint format")
|
| 28 |
+
expected = "fixed_prompt" if spec["format"] in (FIXED_PROMPT_FORMAT, EXECUTOR_FORMAT) else "full_sequence"
|
| 29 |
+
if mode != expected:
|
| 30 |
+
raise ValueError("Checkpoint format and recurrence_mode disagree")
|
| 31 |
+
return mode
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def cpu_tree(value: Any) -> Any:
|
| 35 |
+
if isinstance(value, torch.Tensor):
|
| 36 |
+
return value.detach().cpu().clone()
|
| 37 |
+
if isinstance(value, dict):
|
| 38 |
+
return {key: cpu_tree(item) for key, item in value.items()}
|
| 39 |
+
if isinstance(value, list):
|
| 40 |
+
return [cpu_tree(item) for item in value]
|
| 41 |
+
if isinstance(value, tuple):
|
| 42 |
+
return tuple(cpu_tree(item) for item in value)
|
| 43 |
+
return value
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def save_checkpoint(path: Path, model: RecurrentQwen, tokenizer: Any, spec: dict, training_state: dict) -> None:
|
| 47 |
+
"""Write a new checkpoint directory; metadata is the final completion marker."""
|
| 48 |
+
if spec.get("recurrence_mode", "full_sequence") != model.recurrence_mode:
|
| 49 |
+
raise ValueError("Checkpoint spec and recurrence_mode disagree")
|
| 50 |
+
weights = {name: parameter for name, parameter in model.named_parameters() if parameter.requires_grad}
|
| 51 |
+
completion = model.completion_head is not None
|
| 52 |
+
expected_completion = spec.get("completion_head")
|
| 53 |
+
if completion != (expected_completion is not None):
|
| 54 |
+
raise ValueError("Checkpoint spec and completion head disagree")
|
| 55 |
+
if completion and expected_completion != {"intermediate": model.completion_head.intermediate}:
|
| 56 |
+
raise ValueError("Checkpoint completion-head architecture differs from spec")
|
| 57 |
+
new_architecture = "executor" in spec
|
| 58 |
+
if not new_architecture and (not weights or any(not ((name.startswith("recurrent.") and ".lora_" in name) or
|
| 59 |
+
(completion and name.startswith("completion_head."))) for name in weights)):
|
| 60 |
+
raise ValueError("Checkpoint expects recurrent LoRA and optional completion head only")
|
| 61 |
+
if new_architecture:
|
| 62 |
+
if spec["executor"]["train_scope"] != model.train_scope:
|
| 63 |
+
raise ValueError("Checkpoint training scope differs from model")
|
| 64 |
+
if not weights:
|
| 65 |
+
raise ValueError("Cannot save an empty executor")
|
| 66 |
+
path.mkdir(parents=True, exist_ok=False)
|
| 67 |
+
torch.save(cpu_tree(weights), path / "adapter_model.pt")
|
| 68 |
+
torch.save(cpu_tree(training_state), path / "training_state.pt")
|
| 69 |
+
tokenizer.save_pretrained(path)
|
| 70 |
+
version = FIXED_PROMPT_FORMAT if model.recurrence_mode == "fixed_prompt" else (COMPLETION_FORMAT if completion else FORMAT)
|
| 71 |
+
if new_architecture:
|
| 72 |
+
version = EXECUTOR_FORMAT
|
| 73 |
+
(path / "recurrent_config.json").write_text(json.dumps({**spec, "format": version}, indent=2) + "\n")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def restore_adapters(model: RecurrentQwen, path: Path) -> None:
|
| 77 |
+
if checkpoint_mode(json.loads((path / "recurrent_config.json").read_text())) != model.recurrence_mode:
|
| 78 |
+
raise ValueError("Adapter checkpoint recurrence_mode differs from model")
|
| 79 |
+
state = torch.load(path / "adapter_model.pt", map_location="cpu", weights_only=True)
|
| 80 |
+
expected = {name: value for name, value in model.named_parameters() if value.requires_grad}
|
| 81 |
+
if set(state) != set(expected):
|
| 82 |
+
raise ValueError("Adapter checkpoint names do not match the recurrent model")
|
| 83 |
+
with torch.no_grad():
|
| 84 |
+
for name, parameter in expected.items():
|
| 85 |
+
tensor = state[name]
|
| 86 |
+
if tensor.shape != parameter.shape or not torch.isfinite(tensor).all():
|
| 87 |
+
raise ValueError(f"Invalid adapter tensor: {name}")
|
| 88 |
+
parameter.copy_(tensor)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def load_recurrent_checkpoint(path: Path, *, device: str = "cpu", dtype: str = "float32",
|
| 92 |
+
download: bool = False) -> tuple[RecurrentQwen, Any, dict]:
|
| 93 |
+
spec = json.loads((path / "recurrent_config.json").read_text())
|
| 94 |
+
mode = checkpoint_mode(spec)
|
| 95 |
+
if spec.get("prompt_format") != "dataset_raw" or spec.get("loss_vocabulary") != "symbols":
|
| 96 |
+
raise ValueError("Unsupported recurrent checkpoint format/prompt/loss")
|
| 97 |
+
completion = spec["format"] == COMPLETION_FORMAT or (mode == "fixed_prompt" and "completion_head" in spec)
|
| 98 |
+
if completion and (not isinstance(spec.get("completion_head"), dict) or
|
| 99 |
+
type(spec["completion_head"].get("intermediate")) is not int or
|
| 100 |
+
spec["completion_head"]["intermediate"] < 1):
|
| 101 |
+
raise ValueError("Invalid saved completion-head architecture")
|
| 102 |
+
if not completion and "completion_head" in spec:
|
| 103 |
+
raise ValueError("Legacy checkpoint cannot contain a completion head")
|
| 104 |
+
if spec.get("symbols") != list(SYMBOLS) or len(set(spec.get("token_ids", []))) != len(SYMBOLS):
|
| 105 |
+
raise ValueError("Checkpoint requires the complete A–Z symbol vocabulary")
|
| 106 |
+
tokenizer = AutoTokenizer.from_pretrained(path, local_files_only=True, trust_remote_code=False)
|
| 107 |
+
for symbol, token in zip(spec["symbols"], spec["token_ids"], strict=True):
|
| 108 |
+
if tokenizer.encode(" " + symbol, add_special_tokens=False) != [token]:
|
| 109 |
+
raise ValueError("Checkpoint tokenizer and symbol IDs disagree")
|
| 110 |
+
base = Qwen2ForCausalLM.from_pretrained(
|
| 111 |
+
spec["base_model"], revision=spec["revision"], local_files_only=not download,
|
| 112 |
+
dtype=getattr(torch, dtype), attn_implementation="eager", trust_remote_code=False,
|
| 113 |
+
).to(device).eval()
|
| 114 |
+
model = RecurrentQwen(base, spec["recurrent_start"], spec["recurrent_end"], recurrence_mode=mode)
|
| 115 |
+
executor = spec.get("executor")
|
| 116 |
+
if executor is None or executor["train_scope"] == "lora":
|
| 117 |
+
attach_recurrent_lora(model, rank=spec["lora_rank"], alpha=spec["lora_alpha"])
|
| 118 |
+
if executor is not None:
|
| 119 |
+
model.configure_executor(tokenizer, **executor)
|
| 120 |
+
if completion and model.completion_head is None:
|
| 121 |
+
model.enable_completion(spec["completion_head"]["intermediate"])
|
| 122 |
+
restore_adapters(model, path)
|
| 123 |
+
model.eval()
|
| 124 |
+
return model, tokenizer, spec
|
scripts/recurrent_qwen/completion.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hidden-state-only completion head for explicit-step pointer recurrence."""
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
from torch import Tensor, nn
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class CompletionHead(nn.Module):
|
| 8 |
+
"""One stop logit per answer-position recurrent state [B,H].
|
| 9 |
+
|
| 10 |
+
No numeric loop index, parsed requested depth, decoded symbol, or target is
|
| 11 |
+
accepted by this interface. The prompt can encode its requested Steps.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
def __init__(self, width: int, intermediate: int = 128) -> None:
|
| 15 |
+
super().__init__()
|
| 16 |
+
if width < 1 or intermediate < 1:
|
| 17 |
+
raise ValueError("Completion-head widths must be positive")
|
| 18 |
+
self.width = width
|
| 19 |
+
self.intermediate = intermediate
|
| 20 |
+
self.network = nn.Sequential(nn.LayerNorm(width), nn.Linear(width, intermediate),
|
| 21 |
+
nn.GELU(), nn.Linear(intermediate, 1))
|
| 22 |
+
|
| 23 |
+
def forward(self, answer_state: Tensor) -> Tensor:
|
| 24 |
+
if answer_state.ndim != 2 or answer_state.shape[-1] != self.width:
|
| 25 |
+
raise ValueError("Completion head requires answer states [batch, hidden]")
|
| 26 |
+
return self.network(answer_state).squeeze(-1)
|
scripts/recurrent_qwen/interfaces.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Explicit text routing and learned interfaces for isolated pointer execution."""
|
| 2 |
+
from functools import lru_cache
|
| 3 |
+
import re
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
from torch import Tensor, nn
|
| 8 |
+
|
| 9 |
+
from .number_reader import SharedNumberReader
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class PromptRouter:
|
| 13 |
+
"""Remove the Steps line without parsing its value or changing public inputs.
|
| 14 |
+
|
| 15 |
+
Preserve the original answer index for existing diagnostic callers. Padding
|
| 16 |
+
inserted before that position is masked and RoPE positions are compacted, so
|
| 17 |
+
neither count digits nor their token length reach the executor. Cached CPU
|
| 18 |
+
tokenization is bounded; no prompt or activation survives in a checkpoint.
|
| 19 |
+
"""
|
| 20 |
+
def __init__(self, tokenizer: Any) -> None:
|
| 21 |
+
self.tokenizer = tokenizer
|
| 22 |
+
self.executor_ids = lru_cache(maxsize=4096)(self._executor_ids)
|
| 23 |
+
|
| 24 |
+
def _executor_ids(self, ids: tuple[int, ...]) -> tuple[int, ...]:
|
| 25 |
+
prompt = self.tokenizer.decode(list(ids), skip_special_tokens=False,
|
| 26 |
+
clean_up_tokenization_spaces=False)
|
| 27 |
+
if not re.fullmatch(r"Rules: [^\n]+\nStart: [A-Z]\nSteps: [1-9][0-9]*\nAnswer:", prompt):
|
| 28 |
+
raise ValueError("Isolated execution requires the raw Rules/Start/Steps/Answer prompt")
|
| 29 |
+
routed = re.sub(r"\nSteps: [1-9][0-9]*", "", prompt)
|
| 30 |
+
return tuple(self.tokenizer.encode(routed, add_special_tokens=False))
|
| 31 |
+
|
| 32 |
+
def __call__(self, ids: Tensor, mask: Tensor) -> tuple[Tensor, Tensor, Tensor]:
|
| 33 |
+
rows = ids.detach().cpu().tolist()
|
| 34 |
+
masks = mask.detach().cpu().tolist()
|
| 35 |
+
routed = torch.full_like(ids, self.tokenizer.pad_token_id)
|
| 36 |
+
attention = torch.zeros_like(mask)
|
| 37 |
+
for row, (tokens, valid) in enumerate(zip(rows, masks, strict=True)):
|
| 38 |
+
positions = [i for i, active in enumerate(valid) if active]
|
| 39 |
+
executor = self.executor_ids(tuple(tokens[i] for i in positions))
|
| 40 |
+
end = positions[-1]
|
| 41 |
+
if not executor or len(executor) > end + 1:
|
| 42 |
+
raise ValueError("Routed executor prompt does not fit original input positions")
|
| 43 |
+
routed[row, :len(executor) - 1] = torch.tensor(executor[:-1], device=ids.device)
|
| 44 |
+
routed[row, end] = executor[-1]
|
| 45 |
+
attention[row, :len(executor) - 1] = 1
|
| 46 |
+
attention[row, end] = 1
|
| 47 |
+
positions = (attention.cumsum(-1) - 1).clamp_min(0)
|
| 48 |
+
return routed, attention, positions
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class ReentryBridge(nn.Module):
|
| 52 |
+
"""Learned map from R output back to R input, applied only on re-entry.
|
| 53 |
+
|
| 54 |
+
Normalize direction, then restore the *initial* working-state RMS scale.
|
| 55 |
+
The scale contains rules/start information only and stays differentiable.
|
| 56 |
+
Identity initialization of the projection does not claim T>1 equivalence.
|
| 57 |
+
"""
|
| 58 |
+
def __init__(self, width: int) -> None:
|
| 59 |
+
super().__init__()
|
| 60 |
+
self.projection = nn.Linear(width, width, bias=False)
|
| 61 |
+
nn.init.eye_(self.projection.weight)
|
| 62 |
+
self.gain = nn.Parameter(torch.ones(width))
|
| 63 |
+
|
| 64 |
+
def forward(self, state: Tensor, initial: Tensor) -> Tensor:
|
| 65 |
+
rms = state.float().square().mean(-1, keepdim=True).add(1e-6).sqrt()
|
| 66 |
+
scale = initial.float().square().mean(-1, keepdim=True).add(1e-6).sqrt()
|
| 67 |
+
normalized = (state.float() / rms * scale).to(state.dtype)
|
| 68 |
+
return self.projection(normalized * self.gain)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class RecurrentController(nn.Module):
|
| 72 |
+
"""Own learned memory; detached prompt/executor features, no numeric clock."""
|
| 73 |
+
kind = 'gru'
|
| 74 |
+
def __init__(self, width: int, intermediate: int = 128) -> None:
|
| 75 |
+
super().__init__()
|
| 76 |
+
self.intermediate = intermediate
|
| 77 |
+
self.context = nn.Sequential(nn.LayerNorm(width), nn.Linear(width, intermediate), nn.Tanh())
|
| 78 |
+
self.observation = nn.Sequential(nn.LayerNorm(width), nn.Linear(width, intermediate), nn.Tanh())
|
| 79 |
+
self.cell = nn.GRUCell(intermediate, intermediate)
|
| 80 |
+
self.readout = nn.Linear(intermediate, 1)
|
| 81 |
+
|
| 82 |
+
def initialize(self, prompt_state: Tensor) -> Tensor:
|
| 83 |
+
return self.context(prompt_state.detach())
|
| 84 |
+
|
| 85 |
+
def advance(self, state: Tensor, memory: Tensor) -> tuple[Tensor, Tensor]:
|
| 86 |
+
memory = self.cell(self.observation(state.detach()), memory)
|
| 87 |
+
return self.readout(memory).squeeze(-1), memory
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class AffineSuffixController(RecurrentController):
|
| 91 |
+
"""Learned prompt reading and unbounded scalar recurrence; no parsed clock.
|
| 92 |
+
|
| 93 |
+
Initialization consumes flattened final-eight frozen token embeddings.
|
| 94 |
+
The trainable cell implements a*m+b, initialized to identity (b=0), never
|
| 95 |
+
a programmed decrement. Numerical labels train the cell through free-running
|
| 96 |
+
recurrence; stop BCE trains its readout without distorting counting dynamics.
|
| 97 |
+
Executor observations are intentionally ignored for this requested-count
|
| 98 |
+
controller: timing is independent of the pointer's identity or correctness.
|
| 99 |
+
"""
|
| 100 |
+
kind = 'affine_suffix'
|
| 101 |
+
suffix_tokens = 8
|
| 102 |
+
|
| 103 |
+
def __init__(self, width: int) -> None:
|
| 104 |
+
nn.Module.__init__(self)
|
| 105 |
+
self.width = width
|
| 106 |
+
self.intermediate = 1
|
| 107 |
+
self.context = nn.Linear(width * self.suffix_tokens, 1)
|
| 108 |
+
self.observation = nn.Identity()
|
| 109 |
+
self.cell = nn.Linear(1, 1)
|
| 110 |
+
self.readout = nn.Linear(1, 1)
|
| 111 |
+
nn.init.ones_(self.cell.weight)
|
| 112 |
+
nn.init.zeros_(self.cell.bias)
|
| 113 |
+
nn.init.zeros_(self.readout.weight)
|
| 114 |
+
nn.init.zeros_(self.readout.bias)
|
| 115 |
+
|
| 116 |
+
def advance(self, state: Tensor, memory: Tensor) -> tuple[Tensor, Tensor]:
|
| 117 |
+
self.observation(state.detach()) # Preserve observation hooks for cache/native checks.
|
| 118 |
+
memory = self.cell(memory)
|
| 119 |
+
return self.readout(memory.detach()).squeeze(-1), memory
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class SharedNumberController(AffineSuffixController):
|
| 123 |
+
"""Same learned countdown; share the number reader across positions/lengths."""
|
| 124 |
+
kind = 'shared_number'
|
| 125 |
+
|
| 126 |
+
def __init__(self, width: int) -> None:
|
| 127 |
+
super().__init__(width)
|
| 128 |
+
self.context = SharedNumberReader(width)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def controller_head(width: int, intermediate: int, kind: str = 'gru') -> RecurrentController:
|
| 132 |
+
"""Explicit architecture factory; historical checkpoints default to GRU."""
|
| 133 |
+
if kind == 'gru':
|
| 134 |
+
return RecurrentController(width, intermediate)
|
| 135 |
+
if kind == 'affine_suffix' and intermediate == 1:
|
| 136 |
+
return AffineSuffixController(width)
|
| 137 |
+
if kind == 'shared_number' and intermediate == 1:
|
| 138 |
+
return SharedNumberController(width)
|
| 139 |
+
raise ValueError('Unknown controller kind or incompatible memory width')
|
scripts/recurrent_qwen/lora_utils.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Attach PEFT LoRA only to the shared recurrent decoder group."""
|
| 2 |
+
|
| 3 |
+
from peft import LoraConfig, inject_adapter_in_model
|
| 4 |
+
|
| 5 |
+
from .model import RecurrentQwen
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def attach_recurrent_lora(
|
| 9 |
+
model: RecurrentQwen,
|
| 10 |
+
*,
|
| 11 |
+
rank: int = 8,
|
| 12 |
+
alpha: int = 16,
|
| 13 |
+
targets: tuple[str, ...] = ("q_proj", "v_proj"),
|
| 14 |
+
) -> None:
|
| 15 |
+
"""Mutate R in place; zero-initialized B preserves the original forward.
|
| 16 |
+
|
| 17 |
+
No bias training, dropout, bridge, or adapters outside R. Original weights
|
| 18 |
+
remain frozen. An initial zero gradient for A is expected when B is zero.
|
| 19 |
+
"""
|
| 20 |
+
if rank < 1 or alpha < 1:
|
| 21 |
+
raise ValueError("LoRA rank and alpha must be positive")
|
| 22 |
+
valid = {"q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"}
|
| 23 |
+
if not targets or len(set(targets)) != len(targets) or not set(targets) <= valid:
|
| 24 |
+
raise ValueError(f"Choose unique LoRA targets from {sorted(valid)}")
|
| 25 |
+
if getattr(model.recurrent, "peft_config", None):
|
| 26 |
+
raise ValueError("Recurrent LoRA is already attached")
|
| 27 |
+
config = LoraConfig(
|
| 28 |
+
r=rank,
|
| 29 |
+
lora_alpha=alpha,
|
| 30 |
+
target_modules=list(targets),
|
| 31 |
+
lora_dropout=0.0,
|
| 32 |
+
bias="none",
|
| 33 |
+
init_lora_weights=True,
|
| 34 |
+
)
|
| 35 |
+
inject_adapter_in_model(config, model.recurrent)
|
scripts/recurrent_qwen/memory.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-forward, differentiable prompt memory for a single writable position."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from torch import Tensor
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class PromptMemory:
|
| 11 |
+
"""Capture layer inputs on pass one and reuse their non-working positions.
|
| 12 |
+
|
| 13 |
+
write_mask is [B,S,1]; captured tensors are [B,S,H]. Never detach: later
|
| 14 |
+
losses must reach adapters that formed the prompt memory on the first pass.
|
| 15 |
+
Objects live for one model forward only, not across examples or updates.
|
| 16 |
+
This is an activation-memory reference path, not an attention KV cache.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
write_mask: Tensor
|
| 20 |
+
layers: list[Tensor] = field(default_factory=list)
|
| 21 |
+
output: Tensor | None = None
|
| 22 |
+
keys_values: list[tuple[Tensor, Tensor]] = field(default_factory=list)
|
| 23 |
+
layer_outputs: list[Tensor] = field(default_factory=list)
|
| 24 |
+
|
| 25 |
+
def layer_input(self, index: int, hidden: Tensor) -> Tensor:
|
| 26 |
+
if self.output is None:
|
| 27 |
+
self.layers.append(hidden)
|
| 28 |
+
return hidden
|
| 29 |
+
return torch.where(self.write_mask, hidden, self.layers[index])
|
| 30 |
+
|
| 31 |
+
def block_output(self, hidden: Tensor) -> Tensor:
|
| 32 |
+
if self.output is None:
|
| 33 |
+
self.output = hidden
|
| 34 |
+
return hidden
|
| 35 |
+
return torch.where(self.write_mask, hidden, self.output)
|
scripts/recurrent_qwen/model.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Qwen2 decoder surgery for the pinned Transformers implementation."""
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
from typing import Any, Callable, Literal
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
from torch import Tensor, nn
|
| 8 |
+
from transformers import Qwen2ForCausalLM
|
| 9 |
+
from transformers.masking_utils import create_causal_mask
|
| 10 |
+
|
| 11 |
+
from .outputs import RecurrentOutput, answer_margin
|
| 12 |
+
from .completion import CompletionHead
|
| 13 |
+
from .memory import PromptMemory
|
| 14 |
+
from .prefix import fixed_prefix_layer
|
| 15 |
+
from torch.utils.checkpoint import checkpoint
|
| 16 |
+
from .interfaces import PromptRouter, ReentryBridge, RecurrentController, AffineSuffixController, SharedNumberController, controller_head
|
| 17 |
+
from .number_reader import MAX_NUMBER_TOKENS, number_token_ids
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _answer_readout(hidden: Tensor, positions: list[int]) -> Tensor:
|
| 21 |
+
# Integer slices avoid MPS's nondeterministic scatter backward for advanced
|
| 22 |
+
# indexing/gather. Batches are deliberately tiny; positions sync once per forward.
|
| 23 |
+
return torch.stack([hidden[row, position] for row, position in enumerate(positions)])
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class DecoderBlock(nn.Module):
|
| 27 |
+
"""An ordered group of original decoder objects, with no copies."""
|
| 28 |
+
|
| 29 |
+
def __init__(self, layers: list[nn.Module]) -> None:
|
| 30 |
+
super().__init__()
|
| 31 |
+
self.layers = nn.ModuleList(layers)
|
| 32 |
+
self.prefix_reuse = False
|
| 33 |
+
self.gradient_checkpointing = False
|
| 34 |
+
|
| 35 |
+
def forward(
|
| 36 |
+
self,
|
| 37 |
+
hidden: Tensor,
|
| 38 |
+
attention_mask: Tensor | None,
|
| 39 |
+
position_ids: Tensor,
|
| 40 |
+
position_embeddings: tuple[Tensor, Tensor],
|
| 41 |
+
memory: PromptMemory | None = None,
|
| 42 |
+
) -> Tensor:
|
| 43 |
+
for index, layer in enumerate(self.layers):
|
| 44 |
+
if memory is not None:
|
| 45 |
+
hidden = memory.layer_input(index, hidden)
|
| 46 |
+
if self.prefix_reuse and memory is not None:
|
| 47 |
+
hidden = fixed_prefix_layer(layer, hidden, position_embeddings, attention_mask,
|
| 48 |
+
memory, index, self.gradient_checkpointing)
|
| 49 |
+
continue
|
| 50 |
+
call = layer
|
| 51 |
+
if self.gradient_checkpointing and torch.is_grad_enabled():
|
| 52 |
+
def call(x, _layer=layer, **kwargs):
|
| 53 |
+
return checkpoint(_layer, x, use_reentrant=False, **kwargs)
|
| 54 |
+
hidden = call(
|
| 55 |
+
hidden,
|
| 56 |
+
attention_mask=attention_mask,
|
| 57 |
+
position_ids=position_ids,
|
| 58 |
+
position_embeddings=position_embeddings,
|
| 59 |
+
past_key_values=None,
|
| 60 |
+
use_cache=False,
|
| 61 |
+
)
|
| 62 |
+
return memory.block_output(hidden) if memory is not None else hidden
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class RecurrentQwen(nn.Module):
|
| 66 |
+
"""P -> R repeated T times -> C, reusing the supplied model's weights.
|
| 67 |
+
|
| 68 |
+
Construction freezes and shares the supplied base model in place. Load a
|
| 69 |
+
fresh base for an independent reference after adapters have been attached.
|
| 70 |
+
Only full attention and default RoPE are supported in Stage 0, as used by
|
| 71 |
+
Qwen2.5-0.5B-Instruct. Device/dtype are inherited, never selected here.
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
def __init__(
|
| 75 |
+
self, base: Qwen2ForCausalLM, recurrent_start: int = 6, recurrent_end: int = 18,
|
| 76 |
+
*, recurrence_mode: str = "full_sequence",
|
| 77 |
+
) -> None:
|
| 78 |
+
super().__init__()
|
| 79 |
+
if not isinstance(base, Qwen2ForCausalLM):
|
| 80 |
+
raise TypeError("Stage 0 requires Qwen2ForCausalLM")
|
| 81 |
+
if recurrence_mode not in ("full_sequence", "fixed_prompt"):
|
| 82 |
+
raise ValueError("recurrence_mode must be full_sequence or fixed_prompt")
|
| 83 |
+
self.recurrence_mode = recurrence_mode
|
| 84 |
+
layers = list(base.model.layers)
|
| 85 |
+
if not 0 <= recurrent_start < recurrent_end <= len(layers):
|
| 86 |
+
raise ValueError("Require 0 <= recurrent_start < recurrent_end <= layer count")
|
| 87 |
+
if any(kind != "full_attention" for kind in base.config.layer_types):
|
| 88 |
+
raise ValueError("Stage 0 supports full-attention Qwen layers only")
|
| 89 |
+
if base.config.rope_parameters["rope_type"] != "default":
|
| 90 |
+
raise ValueError("Stage 0 supports default RoPE only")
|
| 91 |
+
if getattr(base, "peft_config", None):
|
| 92 |
+
raise ValueError("Supply an unadapted base; attach LoRA to recurrence afterwards")
|
| 93 |
+
base.requires_grad_(False)
|
| 94 |
+
base.zero_grad(set_to_none=True)
|
| 95 |
+
base.config.use_cache = False
|
| 96 |
+
self.config = base.config
|
| 97 |
+
self.recurrent_start = recurrent_start
|
| 98 |
+
self.recurrent_end = recurrent_end
|
| 99 |
+
self.embed_tokens = base.model.embed_tokens
|
| 100 |
+
self.rotary_emb = base.model.rotary_emb
|
| 101 |
+
self.prelude = DecoderBlock(layers[:recurrent_start])
|
| 102 |
+
self.recurrent = DecoderBlock(layers[recurrent_start:recurrent_end])
|
| 103 |
+
self.coda = DecoderBlock(layers[recurrent_end:])
|
| 104 |
+
self.norm = base.model.norm
|
| 105 |
+
self.lm_head = base.lm_head
|
| 106 |
+
self.completion_head: CompletionHead | RecurrentController | None = None
|
| 107 |
+
self.router = None
|
| 108 |
+
self.bridge = None
|
| 109 |
+
self.state_head = None
|
| 110 |
+
self.train_scope = "lora"
|
| 111 |
+
self.prefix_reuse = False
|
| 112 |
+
self.gradient_checkpointing = False
|
| 113 |
+
self.train(base.training)
|
| 114 |
+
|
| 115 |
+
def enable_completion(self, intermediate: int = 128) -> None:
|
| 116 |
+
"""Attach a trainable stop readout without changing recurrent inputs."""
|
| 117 |
+
if self.completion_head is not None:
|
| 118 |
+
raise ValueError("Completion head is already attached")
|
| 119 |
+
self.completion_head = CompletionHead(self.config.hidden_size, intermediate).to(
|
| 120 |
+
device=self.embed_tokens.weight.device, dtype=self.embed_tokens.weight.dtype)
|
| 121 |
+
|
| 122 |
+
def configure_executor(self, tokenizer: Any, *, train_scope: str = "recurrent",
|
| 123 |
+
bridge: bool = True, direct_readout: bool = True,
|
| 124 |
+
controller_size: int = 128, isolated: bool = True,
|
| 125 |
+
prefix_reuse: bool = False, gradient_checkpointing: bool = False,
|
| 126 |
+
controller_kind: str = 'gru') -> None:
|
| 127 |
+
"""Opt-in architecture; legacy forwards/checkpoints keep their semantics."""
|
| 128 |
+
if train_scope not in ("lora", "recurrent", "full"):
|
| 129 |
+
raise ValueError("train_scope must be lora, recurrent, or full")
|
| 130 |
+
if self.recurrence_mode != "fixed_prompt":
|
| 131 |
+
raise ValueError("Executor interfaces require fixed_prompt recurrence")
|
| 132 |
+
if train_scope != "lora" and any(".lora_" in name for name, _ in self.named_parameters()):
|
| 133 |
+
raise ValueError("Full-weight scopes require an unadapted base, not an attached LoRA model")
|
| 134 |
+
if prefix_reuse and self.config.attention_dropout != 0:
|
| 135 |
+
raise ValueError("Prefix reuse requires zero attention dropout for equivalent execution")
|
| 136 |
+
self.train_scope = train_scope
|
| 137 |
+
if train_scope == "recurrent":
|
| 138 |
+
self.recurrent.requires_grad_(True)
|
| 139 |
+
elif train_scope == "full":
|
| 140 |
+
self.requires_grad_(True)
|
| 141 |
+
device, dtype = self.embed_tokens.weight.device, self.embed_tokens.weight.dtype
|
| 142 |
+
if isolated:
|
| 143 |
+
self.router = PromptRouter(tokenizer)
|
| 144 |
+
if controller_size:
|
| 145 |
+
self.completion_head = controller_head(self.config.hidden_size, controller_size, controller_kind).to(device=device, dtype=dtype)
|
| 146 |
+
if bridge:
|
| 147 |
+
self.bridge = ReentryBridge(self.config.hidden_size).to(device=device, dtype=dtype)
|
| 148 |
+
if direct_readout:
|
| 149 |
+
self.state_head = nn.Linear(self.config.hidden_size, 26).to(device=device, dtype=dtype)
|
| 150 |
+
self.prefix_reuse = prefix_reuse
|
| 151 |
+
self.gradient_checkpointing = gradient_checkpointing
|
| 152 |
+
for block in (self.prelude, self.recurrent, self.coda):
|
| 153 |
+
block.prefix_reuse = prefix_reuse
|
| 154 |
+
block.gradient_checkpointing = gradient_checkpointing
|
| 155 |
+
|
| 156 |
+
def controller_prompt_state(self, input_ids: Tensor, attention_mask: Tensor,
|
| 157 |
+
answer_positions: Tensor | None = None) -> Tensor:
|
| 158 |
+
"""Frozen native initializer features [B,H_context], without R/C execution.
|
| 159 |
+
|
| 160 |
+
This interface runs neither R nor C and consumes only token IDs/mask.
|
| 161 |
+
GRU uses full-prompt P's answer position; affine_suffix uses the final
|
| 162 |
+
eight frozen token embeddings. Shared-number uses routed field-token
|
| 163 |
+
embeddings and validity masks. No branch interprets a numeric value.
|
| 164 |
+
"""
|
| 165 |
+
if input_ids.ndim != 2 or attention_mask.shape != input_ids.shape or not attention_mask.any(-1).all():
|
| 166 |
+
raise ValueError('Need aligned nonempty [B,S] prompt IDs and mask')
|
| 167 |
+
if answer_positions is None:
|
| 168 |
+
answer_positions = (attention_mask.long() * torch.arange(1, input_ids.shape[1] + 1,
|
| 169 |
+
device=input_ids.device)).argmax(-1)
|
| 170 |
+
if isinstance(self.completion_head, SharedNumberController):
|
| 171 |
+
rows = []
|
| 172 |
+
for ids, mask in zip(input_ids.detach().cpu().tolist(), attention_mask.detach().cpu().tolist(), strict=True):
|
| 173 |
+
valid = tuple(t for t, active in zip(ids, mask, strict=True) if active)
|
| 174 |
+
rows.append(number_token_ids(self.router.tokenizer, valid))
|
| 175 |
+
with torch.no_grad():
|
| 176 |
+
context = self.embed_tokens.weight.new_zeros(len(rows), MAX_NUMBER_TOKENS, self.config.hidden_size + 1)
|
| 177 |
+
for i, tokens in enumerate(rows):
|
| 178 |
+
context[i, :len(tokens), :-1] = self.embed_tokens(torch.tensor(tokens, device=input_ids.device))
|
| 179 |
+
context[i, :len(tokens), -1] = 1
|
| 180 |
+
return context.flatten(1)
|
| 181 |
+
if isinstance(self.completion_head, AffineSuffixController):
|
| 182 |
+
k = self.completion_head.suffix_tokens
|
| 183 |
+
if (answer_positions < k - 1).any():
|
| 184 |
+
raise ValueError('Affine suffix initialization requires eight final prompt tokens')
|
| 185 |
+
with torch.no_grad():
|
| 186 |
+
embeddings = self.embed_tokens(input_ids)
|
| 187 |
+
return torch.stack([embeddings[i, end - k + 1:end + 1].flatten()
|
| 188 |
+
for i, end in enumerate(answer_positions.tolist())])
|
| 189 |
+
with torch.no_grad():
|
| 190 |
+
context = self.embed_tokens(input_ids)
|
| 191 |
+
positions = torch.arange(input_ids.shape[1], device=input_ids.device).unsqueeze(0)
|
| 192 |
+
mask = create_causal_mask(config=self.config, inputs_embeds=context,
|
| 193 |
+
attention_mask=attention_mask, past_key_values=None, position_ids=positions)
|
| 194 |
+
context = self.prelude(context, mask, positions, self.rotary_emb(context, positions))
|
| 195 |
+
return _answer_readout(context, answer_positions.tolist())
|
| 196 |
+
|
| 197 |
+
def forward(
|
| 198 |
+
self,
|
| 199 |
+
input_ids: Tensor,
|
| 200 |
+
attention_mask: Tensor | None = None,
|
| 201 |
+
*,
|
| 202 |
+
num_loops: int = 1,
|
| 203 |
+
position_ids: Tensor | None = None,
|
| 204 |
+
answer_positions: Tensor | None = None,
|
| 205 |
+
labels: Tensor | None = None,
|
| 206 |
+
allowed_token_ids: Tensor | None = None,
|
| 207 |
+
return_hidden_states: bool = False,
|
| 208 |
+
logits_mode: Literal["answer", "all"] = "answer",
|
| 209 |
+
stop_policy: Callable[[int, Tensor, Tensor], bool] | None = None,
|
| 210 |
+
completion_threshold: float | None = None,
|
| 211 |
+
readout_token_ids: list[int] | None = None,
|
| 212 |
+
loop_callback: Callable[[int, Tensor, Tensor | None, Tensor | None], None] | None = None,
|
| 213 |
+
) -> RecurrentOutput:
|
| 214 |
+
"""Run complete prompt states through shared recurrence, without KV cache.
|
| 215 |
+
|
| 216 |
+
Inputs/mask: [B,S]; positions: [1,S] or [B,S], default arange(S),
|
| 217 |
+
matching ordinary Qwen forward (including padding). Answer positions:
|
| 218 |
+
[B], default last unmasked token. Labels: [B,T] *token IDs*, one
|
| 219 |
+
target per loop, with an explicit allowed answer set [A]. No CE loss
|
| 220 |
+
or causal label shifting is performed. Pass repeated final labels
|
| 221 |
+
explicitly if final-answer margins, rather than step margins, are wanted.
|
| 222 |
+
Optional stop_policy(t, h_t, answer_logits_t) is inference-only, batch 1,
|
| 223 |
+
and may return True to end after loop t. It never sees labels.
|
| 224 |
+
completion_threshold enables the checkpoint's hidden-state-only head,
|
| 225 |
+
independent of task depth, under the num_loops safety cap (batch 1).
|
| 226 |
+
fixed_prompt keeps only the final unmasked position writable; returned
|
| 227 |
+
full-shaped states contain fixed first-pass prefix outputs for diagnostics.
|
| 228 |
+
loop_callback receives one-based loop, C logits [1,A], optional stop
|
| 229 |
+
logits [1], and optional R logits [1,26] after each readout, including
|
| 230 |
+
the stopping loop. Detached copies protect inference from observer edits.
|
| 231 |
+
It is inference-only; its return value cannot choose when to stop.
|
| 232 |
+
"""
|
| 233 |
+
if type(num_loops) is not int or num_loops < 1:
|
| 234 |
+
raise ValueError("num_loops must be a positive integer")
|
| 235 |
+
if loop_callback is not None and (self.training or torch.is_grad_enabled() or
|
| 236 |
+
input_ids.shape[0] != 1 or labels is not None or logits_mode != "answer"):
|
| 237 |
+
raise ValueError("Loop observation requires eval/no_grad, batch 1, no labels, and answer logits")
|
| 238 |
+
if stop_policy is not None and (self.training or torch.is_grad_enabled() or input_ids.shape[0] != 1 or labels is not None or logits_mode != "answer"):
|
| 239 |
+
raise ValueError("Stopping requires eval/no_grad, batch 1, no labels, and answer logits")
|
| 240 |
+
if completion_threshold is not None:
|
| 241 |
+
if (not math.isfinite(completion_threshold) or not 0 < completion_threshold < 1 or
|
| 242 |
+
self.completion_head is None or stop_policy is not None):
|
| 243 |
+
raise ValueError("Completion stopping requires a head, threshold in (0,1), and no other stop policy")
|
| 244 |
+
if self.training or torch.is_grad_enabled() or input_ids.shape[0] != 1 or labels is not None or logits_mode != "answer":
|
| 245 |
+
raise ValueError("Completion stopping requires eval/no_grad, batch 1, no labels, and answer logits")
|
| 246 |
+
if logits_mode not in ("answer", "all"):
|
| 247 |
+
raise ValueError("logits_mode must be 'answer' or 'all'")
|
| 248 |
+
if input_ids.ndim != 2 or input_ids.dtype != torch.long or 0 in input_ids.shape:
|
| 249 |
+
raise ValueError("input_ids must be a nonempty torch.long [batch, sequence] tensor")
|
| 250 |
+
if input_ids.device != self.embed_tokens.weight.device:
|
| 251 |
+
raise ValueError("Input IDs and model must share a device")
|
| 252 |
+
if (input_ids < 0).any() or (input_ids >= self.config.vocab_size).any():
|
| 253 |
+
raise ValueError("Input IDs must be within the model vocabulary")
|
| 254 |
+
batch, sequence = input_ids.shape
|
| 255 |
+
device = input_ids.device
|
| 256 |
+
if attention_mask is None:
|
| 257 |
+
attention_mask = torch.ones_like(input_ids)
|
| 258 |
+
if attention_mask.shape != input_ids.shape or attention_mask.device != device:
|
| 259 |
+
raise ValueError("attention_mask must match input_ids shape and device")
|
| 260 |
+
if not ((attention_mask == 0) | (attention_mask == 1)).all():
|
| 261 |
+
raise ValueError("attention_mask must contain only 0 and 1")
|
| 262 |
+
if not attention_mask.bool().any(dim=-1).all():
|
| 263 |
+
raise ValueError("Each example must have at least one unmasked token")
|
| 264 |
+
if position_ids is None:
|
| 265 |
+
position_ids = torch.arange(sequence, device=device).unsqueeze(0)
|
| 266 |
+
if (
|
| 267 |
+
position_ids.shape not in ((1, sequence), (batch, sequence))
|
| 268 |
+
or position_ids.dtype != torch.long
|
| 269 |
+
or position_ids.device != device
|
| 270 |
+
or (position_ids < 0).any()
|
| 271 |
+
):
|
| 272 |
+
raise ValueError("position_ids must be nonnegative long [1,S] or [B,S] on the input device")
|
| 273 |
+
if answer_positions is None:
|
| 274 |
+
indices = torch.arange(sequence, device=device).expand(batch, -1)
|
| 275 |
+
answer_positions = indices.masked_fill(~attention_mask.bool(), -1).max(-1).values
|
| 276 |
+
if (
|
| 277 |
+
answer_positions.shape != (batch,)
|
| 278 |
+
or answer_positions.dtype != torch.long
|
| 279 |
+
or answer_positions.device != device
|
| 280 |
+
or (answer_positions < 0).any()
|
| 281 |
+
or (answer_positions >= sequence).any()
|
| 282 |
+
):
|
| 283 |
+
raise ValueError("answer_positions must be long [batch] valid sequence indices on the input device")
|
| 284 |
+
rows = torch.arange(batch, device=device)
|
| 285 |
+
if not attention_mask[rows, answer_positions].bool().all():
|
| 286 |
+
raise ValueError("Answer positions cannot point to padding")
|
| 287 |
+
if self.recurrence_mode == "fixed_prompt":
|
| 288 |
+
last = torch.arange(sequence, device=device).expand(batch, -1).masked_fill(~attention_mask.bool(), -1).max(-1).values
|
| 289 |
+
if not torch.equal(answer_positions, last):
|
| 290 |
+
raise ValueError("fixed_prompt requires the final unmasked position as its working state")
|
| 291 |
+
readout_positions = answer_positions.tolist()
|
| 292 |
+
if (labels is None) != (allowed_token_ids is None):
|
| 293 |
+
raise ValueError("Provide both labels and allowed_token_ids to compute margins")
|
| 294 |
+
if labels is not None and labels.shape != (batch, num_loops):
|
| 295 |
+
raise ValueError("labels must have shape [batch, num_loops]")
|
| 296 |
+
|
| 297 |
+
controller_initial = None
|
| 298 |
+
if self.router is not None:
|
| 299 |
+
if logits_mode != "answer":
|
| 300 |
+
raise ValueError("Isolated execution supports answer-position logits only")
|
| 301 |
+
# Controller prompt encoding is isolated even for full-model training.
|
| 302 |
+
controller_initial = self.controller_prompt_state(input_ids, attention_mask, answer_positions)
|
| 303 |
+
input_ids, attention_mask, position_ids = self.router(input_ids, attention_mask)
|
| 304 |
+
hidden = self.embed_tokens(input_ids)
|
| 305 |
+
causal_mask = create_causal_mask(
|
| 306 |
+
config=self.config,
|
| 307 |
+
inputs_embeds=hidden,
|
| 308 |
+
attention_mask=attention_mask,
|
| 309 |
+
past_key_values=None,
|
| 310 |
+
position_ids=position_ids,
|
| 311 |
+
)
|
| 312 |
+
position_embeddings = self.rotary_emb(hidden, position_ids)
|
| 313 |
+
block_args = (causal_mask, position_ids, position_embeddings)
|
| 314 |
+
hidden = self.prelude(hidden, *block_args)
|
| 315 |
+
initial_hidden = hidden if return_hidden_states else None
|
| 316 |
+
bridge_initial = _answer_readout(hidden, readout_positions)
|
| 317 |
+
controller_memory = (self.completion_head.initialize(controller_initial)
|
| 318 |
+
if isinstance(self.completion_head, RecurrentController) else None)
|
| 319 |
+
recurrent_memory = coda_memory = None
|
| 320 |
+
if self.recurrence_mode == "fixed_prompt":
|
| 321 |
+
write_mask = (torch.arange(sequence, device=device)[None, :] == answer_positions[:, None]).unsqueeze(-1)
|
| 322 |
+
recurrent_memory, coda_memory = PromptMemory(write_mask), PromptMemory(write_mask)
|
| 323 |
+
states, logits, margins, stop_logits, state_logits = [], [], [], [], []
|
| 324 |
+
for loop in range(num_loops):
|
| 325 |
+
if loop and self.bridge is not None:
|
| 326 |
+
working = self.bridge(_answer_readout(hidden, readout_positions), bridge_initial)
|
| 327 |
+
hidden = torch.where(write_mask, working[:, None, :], hidden)
|
| 328 |
+
hidden = (self.recurrent(hidden, *block_args, memory=recurrent_memory)
|
| 329 |
+
if recurrent_memory is not None else self.recurrent(hidden, *block_args))
|
| 330 |
+
working = _answer_readout(hidden, readout_positions)
|
| 331 |
+
if isinstance(self.completion_head, RecurrentController):
|
| 332 |
+
stop, controller_memory = self.completion_head.advance(working, controller_memory)
|
| 333 |
+
stop_logits.append(stop)
|
| 334 |
+
elif self.completion_head is not None:
|
| 335 |
+
stop_logits.append(self.completion_head(working))
|
| 336 |
+
if self.state_head is not None:
|
| 337 |
+
state_logits.append(self.state_head(working))
|
| 338 |
+
if return_hidden_states:
|
| 339 |
+
states.append(hidden)
|
| 340 |
+
# C stays differentiable even when frozen: readout loss must reach R.
|
| 341 |
+
decoded = self.norm(self.coda(hidden, *block_args, memory=coda_memory)
|
| 342 |
+
if coda_memory is not None else self.coda(hidden, *block_args))
|
| 343 |
+
readout = decoded if logits_mode == "all" else _answer_readout(decoded, readout_positions)
|
| 344 |
+
if readout_token_ids is None:
|
| 345 |
+
scores = self.lm_head(readout)
|
| 346 |
+
else:
|
| 347 |
+
if labels is not None:
|
| 348 |
+
raise ValueError("Use full-vocabulary readout when requesting token-ID margins")
|
| 349 |
+
ids = torch.tensor(readout_token_ids, device=device)
|
| 350 |
+
scores = nn.functional.linear(readout, self.lm_head.weight.index_select(0, ids))
|
| 351 |
+
logits.append(scores)
|
| 352 |
+
if loop_callback is not None:
|
| 353 |
+
loop_callback(loop + 1, scores.detach().clone(),
|
| 354 |
+
stop_logits[-1].detach().clone() if stop_logits else None,
|
| 355 |
+
state_logits[-1].detach().clone() if state_logits else None)
|
| 356 |
+
if labels is not None:
|
| 357 |
+
answer_scores = _answer_readout(scores, readout_positions) if logits_mode == "all" else scores
|
| 358 |
+
margins.append(answer_margin(answer_scores, labels[:, loop], allowed_token_ids))
|
| 359 |
+
if stop_policy is not None and stop_policy(loop + 1, hidden, scores):
|
| 360 |
+
break
|
| 361 |
+
if completion_threshold is not None:
|
| 362 |
+
probability = stop_logits[-1].sigmoid().item()
|
| 363 |
+
if not math.isfinite(probability):
|
| 364 |
+
raise FloatingPointError("Nonfinite completion probability")
|
| 365 |
+
if probability >= completion_threshold:
|
| 366 |
+
break
|
| 367 |
+
return RecurrentOutput(
|
| 368 |
+
loop_logits=tuple(logits),
|
| 369 |
+
state_logits=torch.stack(state_logits, dim=1) if state_logits else None,
|
| 370 |
+
hidden_states=tuple(states) if return_hidden_states else None,
|
| 371 |
+
initial_hidden_state=initial_hidden,
|
| 372 |
+
margins=torch.stack(margins, dim=1) if margins else None,
|
| 373 |
+
stop_logits=torch.stack(stop_logits, dim=1) if stop_logits else None,
|
| 374 |
+
)
|
scripts/recurrent_qwen/number_reader.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared learned token composition, with no numeric parser in inference."""
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
from torch import Tensor, nn
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
MAX_NUMBER_TOKENS = 8
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def number_token_ids(tokenizer, ids: tuple[int, ...]) -> tuple[int, ...]:
|
| 12 |
+
"""Route only the raw Steps text; never convert it to a number.
|
| 13 |
+
|
| 14 |
+
One token per character is a tokenizer contract, not a digit-value lookup.
|
| 15 |
+
The public prompt and executor routing remain unchanged.
|
| 16 |
+
"""
|
| 17 |
+
text = tokenizer.decode(list(ids), skip_special_tokens=False,
|
| 18 |
+
clean_up_tokenization_spaces=False)
|
| 19 |
+
match = re.fullmatch(r'Rules: [^\n]+\nStart: [A-Z]\nSteps: ([1-9][0-9]*)\nAnswer:', text)
|
| 20 |
+
if match is None:
|
| 21 |
+
raise ValueError('Number reader requires the raw Rules/Start/Steps/Answer prompt')
|
| 22 |
+
field = match.group(1)
|
| 23 |
+
tokens = tokenizer.encode(field, add_special_tokens=False)
|
| 24 |
+
if not 1 <= len(tokens) <= MAX_NUMBER_TOKENS or len(tokens) != len(field):
|
| 25 |
+
raise ValueError('Number reader requires one token per decimal character and at most eight characters')
|
| 26 |
+
# Length alone does not rule out a tokenizer that groups/reorders characters.
|
| 27 |
+
if any(tokenizer.decode([t], clean_up_tokenization_spaces=False) != c
|
| 28 |
+
for t, c in zip(tokens, field, strict=True)):
|
| 29 |
+
raise ValueError('Number reader tokenizer does not preserve individual decimal characters')
|
| 30 |
+
return tuple(tokens)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class SharedNumberReader(nn.Module):
|
| 34 |
+
"""Read padded frozen embeddings [B,8*(H+1)] using the same map at each slot.
|
| 35 |
+
|
| 36 |
+
Last channel of each slot is a validity mask. Valid tokens are left aligned.
|
| 37 |
+
The gain starts at identity, not a decimal radix. Training labels fit both
|
| 38 |
+
gain and token projection. Accumulate in float64 to reduce numerical error,
|
| 39 |
+
then return the controller's scalar-memory dtype [B,1].
|
| 40 |
+
"""
|
| 41 |
+
def __init__(self, width: int) -> None:
|
| 42 |
+
super().__init__()
|
| 43 |
+
self.width = width
|
| 44 |
+
self.in_features = MAX_NUMBER_TOKENS * (width + 1)
|
| 45 |
+
self.token_value = nn.Linear(width, 1)
|
| 46 |
+
self.gain = nn.Parameter(torch.ones(()))
|
| 47 |
+
|
| 48 |
+
def unpack(self, context: Tensor) -> tuple[Tensor, Tensor]:
|
| 49 |
+
if context.ndim != 2 or context.shape[1] != self.in_features:
|
| 50 |
+
raise ValueError('Need padded number embeddings with token-validity masks')
|
| 51 |
+
slots = context.reshape(-1, MAX_NUMBER_TOKENS, self.width + 1)
|
| 52 |
+
return slots[..., :self.width], slots[..., self.width:]
|
| 53 |
+
|
| 54 |
+
def forward(self, context: Tensor) -> Tensor:
|
| 55 |
+
embeddings, mask = self.unpack(context.detach())
|
| 56 |
+
values = torch.nn.functional.linear(embeddings.double(), self.token_value.weight.double(),
|
| 57 |
+
self.token_value.bias.double())
|
| 58 |
+
state = values.new_zeros(len(values), 1)
|
| 59 |
+
for value, valid in zip(values.unbind(1), mask.unbind(1), strict=True):
|
| 60 |
+
state = torch.where(valid.bool(), self.gain.double() * state + value, state)
|
| 61 |
+
return state.to(self.token_value.weight.dtype)
|
scripts/recurrent_qwen/outputs.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Loop outputs and symbolic readout semantics."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from torch import Tensor
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class RecurrentOutput:
|
| 11 |
+
"""Tuple index t-1 represents loop t; h_0 is not a decoded start symbol.
|
| 12 |
+
|
| 13 |
+
Logits are [batch, vocab] in answer mode or [batch, sequence, vocab]
|
| 14 |
+
in all-token mode. Optional hidden states are [batch, sequence, hidden].
|
| 15 |
+
Margins are [batch, loops]. Tensors retain autograd unless the caller
|
| 16 |
+
disables it; observations never feed back into the recurrent trajectory.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
loop_logits: tuple[Tensor, ...]
|
| 20 |
+
hidden_states: tuple[Tensor, ...] | None = None
|
| 21 |
+
initial_hidden_state: Tensor | None = None
|
| 22 |
+
margins: Tensor | None = None
|
| 23 |
+
state_logits: Tensor | None = None # Direct R readout [B,T,26], never fed back.
|
| 24 |
+
stop_logits: Tensor | None = None # [batch, executed_loops], when completion is enabled.
|
| 25 |
+
|
| 26 |
+
@property
|
| 27 |
+
def logits(self) -> Tensor:
|
| 28 |
+
"""The final loop's logits."""
|
| 29 |
+
return self.loop_logits[-1]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def answer_margin(logits: Tensor, labels: Tensor, allowed_token_ids: Tensor) -> Tensor:
|
| 33 |
+
"""Correct raw logit minus the best *other allowed* logit, per example.
|
| 34 |
+
|
| 35 |
+
Shapes: logits [batch, vocab], labels [batch] (token IDs), allowed IDs
|
| 36 |
+
[answers]. Labels must belong to a unique answer set of at least two IDs.
|
| 37 |
+
A tie has margin zero, so it is not margin-based correctness.
|
| 38 |
+
"""
|
| 39 |
+
if logits.ndim != 2 or labels.shape != logits.shape[:1]:
|
| 40 |
+
raise ValueError("Expected logits [batch, vocab] and labels [batch]")
|
| 41 |
+
if labels.dtype != torch.long or allowed_token_ids.dtype != torch.long:
|
| 42 |
+
raise ValueError("Labels and allowed_token_ids must be torch.long token IDs")
|
| 43 |
+
if labels.device != logits.device or allowed_token_ids.device != logits.device:
|
| 44 |
+
raise ValueError("Logits, labels, and allowed_token_ids must share a device")
|
| 45 |
+
if allowed_token_ids.ndim != 1 or allowed_token_ids.numel() < 2:
|
| 46 |
+
raise ValueError("Provide at least two allowed token IDs in a 1D tensor")
|
| 47 |
+
if allowed_token_ids.unique().numel() != allowed_token_ids.numel():
|
| 48 |
+
raise ValueError("Allowed token IDs must be unique")
|
| 49 |
+
if (allowed_token_ids < 0).any() or (allowed_token_ids >= logits.shape[-1]).any():
|
| 50 |
+
raise ValueError("Allowed token IDs must be within the model vocabulary")
|
| 51 |
+
correct = labels[:, None] == allowed_token_ids[None, :]
|
| 52 |
+
if not correct.any(dim=-1).all():
|
| 53 |
+
raise ValueError("Every label must be an allowed token ID")
|
| 54 |
+
scores = logits.index_select(-1, allowed_token_ids)
|
| 55 |
+
wrong = scores.masked_fill(correct, -torch.inf).max(dim=-1).values
|
| 56 |
+
return logits.gather(-1, labels[:, None]).squeeze(-1) - wrong
|
scripts/recurrent_qwen/partitioned.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inference-only causal factorization for count-dependent historical models.
|
| 2 |
+
|
| 3 |
+
Identical prompt prefixes are computed once per graph, at *each* loop for
|
| 4 |
+
full-sequence recurrence. Count-specific suffixes remain separate continuous
|
| 5 |
+
states. Fixed-prompt recurrence retains first-pass layer K/V, replacing only
|
| 6 |
+
the working position. No symbols, labels, depth values or stops enter R.
|
| 7 |
+
"""
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
from torch import Tensor, nn
|
| 12 |
+
from transformers.models.qwen2.modeling_qwen2 import apply_rotary_pos_emb, eager_attention_forward
|
| 13 |
+
from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
|
| 14 |
+
|
| 15 |
+
from .prefix import layer_compute
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def suffix_layer(layer: nn.Module, hidden: Tensor, embeddings: tuple[Tensor, Tensor],
|
| 19 |
+
mask: Tensor, prefix: tuple[Tensor, Tensor],
|
| 20 |
+
replace_positions: Tensor | None = None) -> tuple[Tensor, Tensor, Tensor]:
|
| 21 |
+
"""Queries [B,Q,H], rotary K/V prefix [B,KV,P,D]; full additive query mask."""
|
| 22 |
+
attention = layer.self_attn
|
| 23 |
+
x = layer.input_layernorm(hidden)
|
| 24 |
+
shape = (*x.shape[:-1], -1, attention.head_dim)
|
| 25 |
+
q = attention.q_proj(x).view(shape).transpose(1, 2)
|
| 26 |
+
k = attention.k_proj(x).view(shape).transpose(1, 2)
|
| 27 |
+
v = attention.v_proj(x).view(shape).transpose(1, 2)
|
| 28 |
+
q, k = apply_rotary_pos_emb(q, k, *embeddings)
|
| 29 |
+
if replace_positions is None:
|
| 30 |
+
k, v = torch.cat((prefix[0], k), -2), torch.cat((prefix[1], v), -2)
|
| 31 |
+
else:
|
| 32 |
+
# Work on fresh tensors: original fixed memory is immutable across loops.
|
| 33 |
+
k_full, v_full = prefix[0].clone(), prefix[1].clone()
|
| 34 |
+
rows = torch.arange(len(hidden), device=hidden.device)
|
| 35 |
+
k_full[rows, :, replace_positions, :] = k[:, :, 0, :]
|
| 36 |
+
v_full[rows, :, replace_positions, :] = v[:, :, 0, :]
|
| 37 |
+
k, v = k_full, v_full
|
| 38 |
+
interface = ALL_ATTENTION_FUNCTIONS.get_interface(attention.config._attn_implementation, eager_attention_forward)
|
| 39 |
+
output, _ = interface(attention, q, k, v, mask,
|
| 40 |
+
dropout=0.0, scaling=attention.scaling, sliding_window=None)
|
| 41 |
+
output = output.reshape(*x.shape[:-1], -1).contiguous()
|
| 42 |
+
result = hidden + attention.o_proj(output)
|
| 43 |
+
result = result + layer.mlp(layer.post_attention_layernorm(result))
|
| 44 |
+
return result, k, v
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class PartitionedResult:
|
| 49 |
+
"""Readouts [graph*count, loop, symbol], stop logits or None, final R working."""
|
| 50 |
+
scores: Tensor
|
| 51 |
+
stops: Tensor | None
|
| 52 |
+
working: Tensor
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@torch.inference_mode()
|
| 56 |
+
def partitioned_forward(model: nn.Module, prefix_ids: Tensor, suffix_ids: Tensor,
|
| 57 |
+
suffix_mask: Tensor, graph_indices: Tensor,
|
| 58 |
+
token_ids: list[int], loops: int, horizons: Tensor | None = None) -> PartitionedResult:
|
| 59 |
+
"""Exact causal factorization, modulo documented FP32 kernel roundoff.
|
| 60 |
+
|
| 61 |
+
Prefix [G,P] has no padding, suffix [B,S] is right padded. Each suffix maps
|
| 62 |
+
to one prefix via [B] graph_indices. Positions match the dense concatenation.
|
| 63 |
+
Supports historical full_sequence/fixed_prompt models, without routed inputs,
|
| 64 |
+
bridges, direct heads or private recurrent controllers. Never modifies model.
|
| 65 |
+
"""
|
| 66 |
+
if (model.training or model.router is not None or model.bridge is not None or
|
| 67 |
+
model.state_head is not None or loops < 1):
|
| 68 |
+
raise ValueError('Partitioned path requires an unmodified historical eval model')
|
| 69 |
+
if prefix_ids.ndim != 2 or suffix_ids.shape != suffix_mask.shape or suffix_ids.ndim != 2:
|
| 70 |
+
raise ValueError('Expected prefix and aligned suffix token matrices')
|
| 71 |
+
if (not suffix_mask.any(-1).all() or not torch.equal(suffix_mask.long().cumprod(-1), suffix_mask.long())
|
| 72 |
+
or graph_indices.shape != (len(suffix_ids),) or
|
| 73 |
+
(graph_indices < 0).any() or (graph_indices >= len(prefix_ids)).any()):
|
| 74 |
+
raise ValueError('Need right-padded suffixes and valid graph indices')
|
| 75 |
+
prefix = model.embed_tokens(prefix_ids)
|
| 76 |
+
suffix = model.embed_tokens(suffix_ids)
|
| 77 |
+
device, dtype = prefix.device, prefix.dtype
|
| 78 |
+
p, s = prefix.shape[1], suffix.shape[1]
|
| 79 |
+
pp = torch.arange(p, device=device)[None]
|
| 80 |
+
sp = torch.arange(p, p + s, device=device)[None]
|
| 81 |
+
pe, se = model.rotary_emb(prefix, pp), model.rotary_emb(suffix, sp)
|
| 82 |
+
negative = torch.finfo(dtype).min
|
| 83 |
+
pm = torch.zeros(p, p, device=device, dtype=dtype).masked_fill(
|
| 84 |
+
torch.arange(p, device=device)[None] > torch.arange(p, device=device)[:, None], negative)[None, None]
|
| 85 |
+
keep = torch.cat((torch.ones(len(suffix), p, device=device, dtype=torch.bool), suffix_mask.bool()), -1)
|
| 86 |
+
allowed = (torch.arange(p + s, device=device)[None] <= sp[0, :, None])[None] & keep[:, None, :]
|
| 87 |
+
sm = torch.zeros_like(allowed, dtype=dtype).masked_fill(~allowed, negative)[:, None]
|
| 88 |
+
ends = suffix_mask.sum(-1).long() - 1
|
| 89 |
+
rows = torch.arange(len(suffix), device=device)
|
| 90 |
+
am = sm[rows, :, ends, :].unsqueeze(2)
|
| 91 |
+
ae = tuple(e.expand(len(suffix), -1, -1)[rows, ends, :].unsqueeze(1) for e in se)
|
| 92 |
+
|
| 93 |
+
def block(layers, ph, sh, record=False):
|
| 94 |
+
memory = []
|
| 95 |
+
for layer in layers:
|
| 96 |
+
ph, k, v = layer_compute(layer, ph, pe, pm)
|
| 97 |
+
sh, keys, values = suffix_layer(layer, sh, se, sm,
|
| 98 |
+
(k.index_select(0, graph_indices), v.index_select(0, graph_indices)))
|
| 99 |
+
if record:
|
| 100 |
+
memory.append((keys, values))
|
| 101 |
+
return ph, sh, memory
|
| 102 |
+
|
| 103 |
+
def working_block(layers, state, memory):
|
| 104 |
+
for layer, kv in zip(layers, memory, strict=True):
|
| 105 |
+
state, _, _ = suffix_layer(layer, state, ae, am, kv, ends + p)
|
| 106 |
+
return state
|
| 107 |
+
|
| 108 |
+
prefix, suffix, _ = block(model.prelude.layers, prefix, suffix)
|
| 109 |
+
fixed = model.recurrence_mode == 'fixed_prompt'
|
| 110 |
+
scores, stops = [], []
|
| 111 |
+
original_batch = len(suffix_ids)
|
| 112 |
+
active_rows = torch.arange(original_batch, device=device)
|
| 113 |
+
crossed = torch.zeros(original_batch, device=device, dtype=torch.bool)
|
| 114 |
+
if horizons is not None and (horizons.shape != (original_batch,) or (horizons < 1).any() or (horizons > loops).any()):
|
| 115 |
+
raise ValueError('Requested readout horizons must fit the loop cap')
|
| 116 |
+
r_memory = c_memory = None
|
| 117 |
+
working = None
|
| 118 |
+
selected = model.lm_head.weight[token_ids]
|
| 119 |
+
for loop in range(loops):
|
| 120 |
+
if fixed and loop:
|
| 121 |
+
working = working_block(model.recurrent.layers, working, r_memory)
|
| 122 |
+
decoded = working_block(model.coda.layers, working, c_memory)
|
| 123 |
+
else:
|
| 124 |
+
prefix, suffix, r_memory = block(model.recurrent.layers, prefix, suffix, fixed)
|
| 125 |
+
working = suffix[rows, ends, :].unsqueeze(1)
|
| 126 |
+
_, decoded_suffix, c_memory = block(model.coda.layers, prefix, suffix, fixed)
|
| 127 |
+
decoded = decoded_suffix[rows, ends, :].unsqueeze(1)
|
| 128 |
+
score = nn.functional.linear(model.norm(decoded[:, 0]), selected).float()
|
| 129 |
+
if not torch.isfinite(score).all():
|
| 130 |
+
raise FloatingPointError('Nonfinite partitioned readout')
|
| 131 |
+
full_score = score.new_zeros(original_batch, score.shape[-1])
|
| 132 |
+
full_score[active_rows] = score
|
| 133 |
+
scores.append(full_score)
|
| 134 |
+
if model.completion_head is not None:
|
| 135 |
+
stop = model.completion_head(working[:, 0]).float()
|
| 136 |
+
if not torch.isfinite(stop).all():
|
| 137 |
+
raise FloatingPointError('Nonfinite partitioned completion head')
|
| 138 |
+
full_stop = stop.new_full((original_batch,), float('-inf'))
|
| 139 |
+
full_stop[active_rows] = stop
|
| 140 |
+
stops.append(full_stop)
|
| 141 |
+
crossed[active_rows] |= stop >= 0
|
| 142 |
+
if horizons is not None and loop + 1 < loops:
|
| 143 |
+
need = horizons[active_rows] > loop + 1
|
| 144 |
+
if model.completion_head is not None:
|
| 145 |
+
need |= ~crossed[active_rows]
|
| 146 |
+
# A CUDA boolean index discovers its output shape on the host.
|
| 147 |
+
# Discover the same ordered rows once, then reuse them for every
|
| 148 |
+
# state/cache tensor instead of repeating that synchronization.
|
| 149 |
+
kept = need.nonzero().flatten()
|
| 150 |
+
if not kept.numel():
|
| 151 |
+
for _ in range(loop + 1, loops):
|
| 152 |
+
scores.append(full_score.new_zeros(full_score.shape))
|
| 153 |
+
if stops: stops.append(full_stop.new_full(full_stop.shape, float('-inf')))
|
| 154 |
+
break
|
| 155 |
+
active_rows = active_rows.index_select(0, kept)
|
| 156 |
+
graph_indices = graph_indices.index_select(0, kept)
|
| 157 |
+
suffix = suffix.index_select(0, kept)
|
| 158 |
+
working = working.index_select(0, kept)
|
| 159 |
+
ends = ends.index_select(0, kept)
|
| 160 |
+
rows = torch.arange(len(active_rows), device=device)
|
| 161 |
+
sm = sm.index_select(0, kept); am = am.index_select(0, kept)
|
| 162 |
+
ae = tuple(e.index_select(0, kept) for e in ae)
|
| 163 |
+
if fixed:
|
| 164 |
+
r_memory = [(key.index_select(0, kept), value.index_select(0, kept)) for key, value in r_memory]
|
| 165 |
+
c_memory = [(key.index_select(0, kept), value.index_select(0, kept)) for key, value in c_memory]
|
| 166 |
+
return PartitionedResult(torch.stack(scores, 1), torch.stack(stops, 1) if stops else None, working[:, 0])
|
scripts/recurrent_qwen/prefix.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Differentiable fixed-prefix execution; one working query after the first pass."""
|
| 2 |
+
import torch
|
| 3 |
+
from torch import Tensor, nn
|
| 4 |
+
from .memory import PromptMemory
|
| 5 |
+
from torch.utils.checkpoint import checkpoint
|
| 6 |
+
from transformers.models.qwen2.modeling_qwen2 import apply_rotary_pos_emb, eager_attention_forward
|
| 7 |
+
from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def pick(x: Tensor, positions: list[int]) -> Tensor:
|
| 11 |
+
return torch.stack([x[row, position:position + 1] for row, position in enumerate(positions)])
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def layer_compute(layer: nn.Module, hidden: Tensor, embeddings: tuple[Tensor, Tensor],
|
| 15 |
+
mask: Tensor | None, positions: list[int] | None = None,
|
| 16 |
+
cached: tuple[Tensor, Tensor] | None = None) -> tuple[Tensor, Tensor, Tensor]:
|
| 17 |
+
"""Pure operation so non-reentrant checkpointing preserves prefix gradients."""
|
| 18 |
+
attention = layer.self_attn
|
| 19 |
+
residual = hidden
|
| 20 |
+
x = layer.input_layernorm(hidden)
|
| 21 |
+
shape = (*x.shape[:-1], -1, attention.head_dim)
|
| 22 |
+
q = attention.q_proj(x).view(shape).transpose(1, 2)
|
| 23 |
+
k = attention.k_proj(x).view(shape).transpose(1, 2)
|
| 24 |
+
v = attention.v_proj(x).view(shape).transpose(1, 2)
|
| 25 |
+
q, k = apply_rotary_pos_emb(q, k, *embeddings)
|
| 26 |
+
if cached is not None:
|
| 27 |
+
keys, values = cached
|
| 28 |
+
active = (torch.arange(keys.shape[-2], device=x.device)[None, :] ==
|
| 29 |
+
torch.tensor(positions, device=x.device)[:, None])[:, None, :, None]
|
| 30 |
+
k = torch.where(active, k, keys)
|
| 31 |
+
v = torch.where(active, v, values)
|
| 32 |
+
interface = ALL_ATTENTION_FUNCTIONS.get_interface(attention.config._attn_implementation, eager_attention_forward)
|
| 33 |
+
output, _ = interface(attention, q, k, v, mask,
|
| 34 |
+
dropout=attention.attention_dropout if layer.training else 0.0,
|
| 35 |
+
scaling=attention.scaling, sliding_window=None)
|
| 36 |
+
output = output.reshape(*x.shape[:-1], -1).contiguous()
|
| 37 |
+
hidden = residual + attention.o_proj(output)
|
| 38 |
+
hidden = hidden + layer.mlp(layer.post_attention_layernorm(hidden))
|
| 39 |
+
return hidden, k, v
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def fixed_prefix_layer(layer: nn.Module, hidden: Tensor, embeddings: tuple[Tensor, Tensor],
|
| 43 |
+
mask: Tensor | None, memory: PromptMemory, index: int,
|
| 44 |
+
use_checkpoint: bool) -> Tensor:
|
| 45 |
+
"""Reuse prefix K/V without detaching it or caching across model forwards."""
|
| 46 |
+
positions = memory.write_mask[..., 0].long().argmax(-1).tolist()
|
| 47 |
+
cached = None
|
| 48 |
+
first = memory.output is None
|
| 49 |
+
if not first:
|
| 50 |
+
hidden = pick(hidden, positions)
|
| 51 |
+
embeddings = tuple(pick(e.expand(memory.write_mask.shape[0], -1, -1), positions) for e in embeddings)
|
| 52 |
+
cached = memory.keys_values[index]
|
| 53 |
+
if mask is None:
|
| 54 |
+
keep = torch.arange(cached[0].shape[-2], device=hidden.device)[None, :] <= torch.tensor(positions, device=hidden.device)[:, None]
|
| 55 |
+
mask = torch.zeros_like(keep, dtype=hidden.dtype).masked_fill(~keep, torch.finfo(hidden.dtype).min)[:, None, None, :]
|
| 56 |
+
else:
|
| 57 |
+
mask = torch.stack([mask[row if mask.shape[0] > 1 else 0, :, pos:pos + 1, :] for row, pos in enumerate(positions)])
|
| 58 |
+
def compute(x, cos, sin, *kv):
|
| 59 |
+
return layer_compute(layer, x, (cos, sin), mask, positions, tuple(kv) if kv else None)
|
| 60 |
+
args = (hidden, *embeddings, *(cached or ()))
|
| 61 |
+
result, keys, values = (checkpoint(compute, *args, use_reentrant=False)
|
| 62 |
+
if use_checkpoint and torch.is_grad_enabled() else compute(*args))
|
| 63 |
+
if first:
|
| 64 |
+
memory.keys_values.append((keys, values))
|
| 65 |
+
memory.layer_outputs.append(result)
|
| 66 |
+
return result
|
| 67 |
+
return torch.where(memory.write_mask, result, memory.layer_outputs[index])
|
scripts/recurrent_qwen/validation.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage 0 checks shared by the pretrained CLI and a small integration test."""
|
| 2 |
+
|
| 3 |
+
from collections.abc import Callable
|
| 4 |
+
from time import perf_counter
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
from torch import Tensor
|
| 9 |
+
from torch.nn import functional as F
|
| 10 |
+
from transformers import Qwen2ForCausalLM
|
| 11 |
+
|
| 12 |
+
from .lora_utils import attach_recurrent_lora
|
| 13 |
+
from .model import RecurrentQwen
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def validate_stage0(
|
| 17 |
+
base: Qwen2ForCausalLM,
|
| 18 |
+
cases: dict[str, dict[str, Tensor]],
|
| 19 |
+
*,
|
| 20 |
+
recurrent_start: int = 6,
|
| 21 |
+
recurrent_end: int = 18,
|
| 22 |
+
rank: int = 8,
|
| 23 |
+
alpha: int = 16,
|
| 24 |
+
loop_counts: tuple[int, ...] = (1, 2, 4),
|
| 25 |
+
atol: float = 1e-5,
|
| 26 |
+
rtol: float = 1e-5,
|
| 27 |
+
progress: Callable[[str], None] = print,
|
| 28 |
+
) -> dict[str, Any]:
|
| 29 |
+
"""Assert architecture contracts; return serializable measured evidence.
|
| 30 |
+
|
| 31 |
+
Mutates/freezes base and attaches fresh LoRA. No optimizer step is taken.
|
| 32 |
+
The supplied cases must be short, nonempty input_ids/attention_mask pairs;
|
| 33 |
+
equivalence compares every vocabulary logit at every sequence position.
|
| 34 |
+
"""
|
| 35 |
+
if not cases or not loop_counts or min(loop_counts) < 1 or max(loop_counts) < 2:
|
| 36 |
+
raise ValueError("Provide input cases and positive loop counts including a depth > 1")
|
| 37 |
+
base.eval()
|
| 38 |
+
base.config.use_cache = False
|
| 39 |
+
originals = tuple(base.parameters())
|
| 40 |
+
original_ids = {id(p) for p in originals}
|
| 41 |
+
original_layers = tuple(base.model.layers)
|
| 42 |
+
progress("Computing ordinary-Qwen reference logits")
|
| 43 |
+
with torch.no_grad():
|
| 44 |
+
references = {name: base(**inputs, use_cache=False).logits for name, inputs in cases.items()}
|
| 45 |
+
model = RecurrentQwen(base, recurrent_start, recurrent_end).eval()
|
| 46 |
+
assert tuple(model.recurrent.layers) == original_layers[recurrent_start:recurrent_end]
|
| 47 |
+
errors = []
|
| 48 |
+
for adapted in (False, True):
|
| 49 |
+
if adapted:
|
| 50 |
+
attach_recurrent_lora(model, rank=rank, alpha=alpha)
|
| 51 |
+
progress(f"Checking T=1 equivalence ({'with' if adapted else 'without'} LoRA)")
|
| 52 |
+
with torch.no_grad():
|
| 53 |
+
for name, inputs in cases.items():
|
| 54 |
+
actual = model(**inputs, logits_mode="all").logits
|
| 55 |
+
expected = references[name]
|
| 56 |
+
torch.testing.assert_close(actual, expected, atol=atol, rtol=rtol)
|
| 57 |
+
difference = (actual - expected).abs()
|
| 58 |
+
errors.append({
|
| 59 |
+
"case": name,
|
| 60 |
+
"lora": adapted,
|
| 61 |
+
"max_absolute_error": difference.max().item(),
|
| 62 |
+
"mean_absolute_error": difference.mean().item(),
|
| 63 |
+
"logits_shape": list(actual.shape),
|
| 64 |
+
})
|
| 65 |
+
del references
|
| 66 |
+
|
| 67 |
+
progress("Checking shared weights, loop observability, and configurable depth")
|
| 68 |
+
parameters_before = tuple(id(p) for p in model.parameters())
|
| 69 |
+
calls: list[tuple[int, tuple[int, ...], Tensor, Tensor]] = []
|
| 70 |
+
block_calls: list[str] = []
|
| 71 |
+
layer_calls: list[int] = []
|
| 72 |
+
handles = [model.recurrent.register_forward_hook(
|
| 73 |
+
lambda module, args, output: calls.append(
|
| 74 |
+
(id(module), tuple(id(p) for p in module.parameters()), args[0], output)
|
| 75 |
+
)
|
| 76 |
+
)]
|
| 77 |
+
for name in ("prelude", "coda"):
|
| 78 |
+
handles.append(getattr(model, name).register_forward_hook(
|
| 79 |
+
lambda module, args, output, name=name: block_calls.append(name)
|
| 80 |
+
))
|
| 81 |
+
for layer in model.recurrent.layers:
|
| 82 |
+
handles.append(layer.register_forward_hook(
|
| 83 |
+
lambda module, args, output: layer_calls.append(id(module))
|
| 84 |
+
))
|
| 85 |
+
inputs = next(iter(cases.values()))
|
| 86 |
+
batch, sequence = inputs["input_ids"].shape
|
| 87 |
+
device = inputs["input_ids"].device
|
| 88 |
+
allowed = inputs["input_ids"].unique()
|
| 89 |
+
if allowed.numel() < 2:
|
| 90 |
+
raise ValueError("Validation inputs must contain at least two distinct token IDs")
|
| 91 |
+
recurrent_ids = tuple(id(p) for p in model.recurrent.parameters())
|
| 92 |
+
trajectories = []
|
| 93 |
+
try:
|
| 94 |
+
with torch.no_grad():
|
| 95 |
+
for depth in loop_counts:
|
| 96 |
+
calls.clear()
|
| 97 |
+
block_calls.clear()
|
| 98 |
+
layer_calls.clear()
|
| 99 |
+
labels = allowed[torch.arange(depth, device=device) % allowed.numel()].expand(batch, -1)
|
| 100 |
+
start = perf_counter()
|
| 101 |
+
output = model(**inputs, num_loops=depth, return_hidden_states=True,
|
| 102 |
+
labels=labels, allowed_token_ids=allowed)
|
| 103 |
+
if device.type == "mps":
|
| 104 |
+
torch.mps.synchronize()
|
| 105 |
+
seconds = perf_counter() - start
|
| 106 |
+
assert len(calls) == depth
|
| 107 |
+
assert block_calls.count("prelude") == 1 and block_calls.count("coda") == depth
|
| 108 |
+
assert layer_calls == [id(layer) for layer in model.recurrent.layers] * depth
|
| 109 |
+
assert all(call[0] == id(model.recurrent) and call[1] == recurrent_ids for call in calls)
|
| 110 |
+
assert tuple(id(p) for p in model.parameters()) == parameters_before
|
| 111 |
+
assert calls[0][2] is output.initial_hidden_state
|
| 112 |
+
assert all(calls[t][2] is calls[t - 1][3] for t in range(1, depth))
|
| 113 |
+
assert len(output.hidden_states) == len(output.loop_logits) == depth
|
| 114 |
+
assert output.margins.shape == (batch, depth)
|
| 115 |
+
for hidden, logits in zip(output.hidden_states, output.loop_logits):
|
| 116 |
+
assert hidden.shape == (batch, sequence, model.config.hidden_size)
|
| 117 |
+
assert logits.shape == (batch, model.config.vocab_size)
|
| 118 |
+
assert torch.isfinite(hidden).all() and torch.isfinite(logits).all()
|
| 119 |
+
assert torch.isfinite(output.margins).all()
|
| 120 |
+
trajectories.append({
|
| 121 |
+
"num_loops": depth,
|
| 122 |
+
"seconds": seconds,
|
| 123 |
+
"recurrent_calls": len(calls),
|
| 124 |
+
"recurrent_layer_calls": len(layer_calls),
|
| 125 |
+
"prelude_calls": block_calls.count("prelude"),
|
| 126 |
+
"coda_calls": block_calls.count("coda"),
|
| 127 |
+
"predicted_token_ids": [scores.argmax(-1).tolist() for scores in output.loop_logits],
|
| 128 |
+
"diagnostic_labels": labels.tolist(),
|
| 129 |
+
"allowed_token_ids": allowed.tolist(),
|
| 130 |
+
"margins": output.margins.tolist(),
|
| 131 |
+
})
|
| 132 |
+
finally:
|
| 133 |
+
for handle in handles:
|
| 134 |
+
handle.remove()
|
| 135 |
+
calls.clear()
|
| 136 |
+
del output
|
| 137 |
+
|
| 138 |
+
progress("Checking two-loop backward through the frozen coda (no optimizer step)")
|
| 139 |
+
model.train()
|
| 140 |
+
model.zero_grad(set_to_none=True)
|
| 141 |
+
start = perf_counter()
|
| 142 |
+
output = model(**inputs, num_loops=2, return_hidden_states=True)
|
| 143 |
+
for hidden in output.hidden_states:
|
| 144 |
+
hidden.retain_grad()
|
| 145 |
+
# Arbitrary token labels test graph connectivity, not task competence.
|
| 146 |
+
loss = F.cross_entropy(output.logits.float(), inputs["input_ids"][:, 0])
|
| 147 |
+
loss.backward()
|
| 148 |
+
if device.type == "mps":
|
| 149 |
+
torch.mps.synchronize()
|
| 150 |
+
backward_seconds = perf_counter() - start
|
| 151 |
+
assert all(not p.requires_grad and p.grad is None for p in originals)
|
| 152 |
+
trainable = [(name, p) for name, p in model.named_parameters() if p.requires_grad]
|
| 153 |
+
assert len(trainable) == (recurrent_end - recurrent_start) * 2 * 2
|
| 154 |
+
assert {id(p) for p in model.parameters()} - original_ids == {id(p) for _, p in trainable}
|
| 155 |
+
gradients = {}
|
| 156 |
+
for name, parameter in trainable:
|
| 157 |
+
assert name.startswith("recurrent.") and (".lora_A." in name or ".lora_B." in name)
|
| 158 |
+
assert parameter.grad is not None and torch.isfinite(parameter.grad).all(), name
|
| 159 |
+
norm = parameter.grad.float().norm().item()
|
| 160 |
+
if ".lora_B." in name:
|
| 161 |
+
assert norm > 0, name
|
| 162 |
+
gradients[name] = {"elements": parameter.numel(), "gradient_l2": norm}
|
| 163 |
+
hidden_gradients = [hidden.grad.float().norm().item() for hidden in output.hidden_states]
|
| 164 |
+
assert all(value > 0 and torch.isfinite(torch.tensor(value)) for value in hidden_gradients)
|
| 165 |
+
result = {
|
| 166 |
+
"gate_passed": True,
|
| 167 |
+
"checks": {name: "PASS" for name in (
|
| 168 |
+
"t1_equivalence", "shared_weights", "gradient_scope", "observability", "configurable_depth"
|
| 169 |
+
)},
|
| 170 |
+
"split": {"prelude": [0, recurrent_start], "recurrent": [recurrent_start, recurrent_end],
|
| 171 |
+
"coda": [recurrent_end, len(original_layers)], "end_indices_exclusive": True},
|
| 172 |
+
"lora": {"rank": rank, "alpha": alpha, "targets": ["q_proj", "v_proj"], "dropout": 0.0},
|
| 173 |
+
"bridge": None,
|
| 174 |
+
"tolerance": {"atol": atol, "rtol": rtol},
|
| 175 |
+
"equivalence": errors,
|
| 176 |
+
"trajectories": trajectories,
|
| 177 |
+
"parameters": {"original": sum(p.numel() for p in originals),
|
| 178 |
+
"trainable": sum(p.numel() for _, p in trainable),
|
| 179 |
+
"trainable_tensors": len(trainable)},
|
| 180 |
+
"gradient_check": {"loss": loss.item(), "forward_backward_seconds": backward_seconds,
|
| 181 |
+
"hidden_gradient_l2": hidden_gradients, "parameters": gradients,
|
| 182 |
+
"frozen_parameters_with_grad": 0, "optimizer_steps": 0},
|
| 183 |
+
}
|
| 184 |
+
model.zero_grad(set_to_none=True)
|
| 185 |
+
return result
|
tokenizer.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3fd169731d2cbde95e10bf356d66d5997fd885dd8dbb6fb4684da3f23b2585d8
|
| 3 |
+
size 11421892
|
tokenizer_config.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"add_prefix_space": false,
|
| 3 |
+
"backend": "tokenizers",
|
| 4 |
+
"bos_token": null,
|
| 5 |
+
"clean_up_tokenization_spaces": false,
|
| 6 |
+
"eos_token": "<|im_end|>",
|
| 7 |
+
"errors": "replace",
|
| 8 |
+
"extra_special_tokens": [
|
| 9 |
+
"<|im_start|>",
|
| 10 |
+
"<|im_end|>",
|
| 11 |
+
"<|object_ref_start|>",
|
| 12 |
+
"<|object_ref_end|>",
|
| 13 |
+
"<|box_start|>",
|
| 14 |
+
"<|box_end|>",
|
| 15 |
+
"<|quad_start|>",
|
| 16 |
+
"<|quad_end|>",
|
| 17 |
+
"<|vision_start|>",
|
| 18 |
+
"<|vision_end|>",
|
| 19 |
+
"<|vision_pad|>",
|
| 20 |
+
"<|image_pad|>",
|
| 21 |
+
"<|video_pad|>"
|
| 22 |
+
],
|
| 23 |
+
"is_local": false,
|
| 24 |
+
"local_files_only": true,
|
| 25 |
+
"model_max_length": 131072,
|
| 26 |
+
"pad_token": "<|endoftext|>",
|
| 27 |
+
"split_special_tokens": false,
|
| 28 |
+
"tokenizer_class": "Qwen2Tokenizer",
|
| 29 |
+
"unk_token": null
|
| 30 |
+
}
|