kerzgrr commited on
Commit
d5df530
·
verified ·
1 Parent(s): c6d352d

Upload Senryu (apo_zero apo-v2 EMA @ step 704, from SFT step 12800)

Browse files
README.md ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ tags:
6
+ - text-generation
7
+ - causal-lm
8
+ - pytorch
9
+ - sft
10
+ - preference-optimization
11
+ - apo
12
+ - on-policy
13
+ - instruction-tuned
14
+ - chat
15
+ - reasoning
16
+ - tool-use
17
+ - hybrid
18
+ - gated-deltanet-2
19
+ - gated-mla
20
+ - senryu
21
+ pipeline_tag: text-generation
22
+ library_name: tiny_gdn
23
+ datasets:
24
+ - HuggingFaceFW/fineweb-edu
25
+ - HuggingFaceTB/smoltalk2
26
+ - HuggingFaceTB/smoltalk
27
+ - nvidia/Nemotron-Post-Training-Dataset-v1
28
+ - nvidia/Nemotron-Agentic-v1
29
+ - nvidia/Nemotron-SFT-Agentic-v2
30
+ - nvidia/Nemotron-SFT-Instruction-Following-Chat-v2
31
+ - nvidia/Nemotron-Instruction-Following-Chat-v1
32
+ - nvidia/OpenMathReasoning
33
+ - nvidia/OpenCodeReasoning
34
+ - nvidia/When2Call
35
+ - Agent-Ark/Toucan-1.5M
36
+ - interstellarninja/hermes_reasoning_tool_use
37
+ - interstellarninja/tool-use-multiturn-reasoning
38
+ - NousResearch/Hermes-3-Dataset
39
+ - glaiveai/glaive-function-calling-v2
40
+ - allenai/WildChat-4.8M
41
+ - QuixiAI/SystemChat-2.0
42
+ - Open-Orca/SlimOrca
43
+ - HuggingFaceH4/ultrachat_200k
44
+ - ise-uiuc/Magicoder-Evol-Instruct-110K
45
+ - bigcode/self-oss-instruct-sc2-exec-filter-50k
46
+ - OpenAssistant/oasst2
47
+ - allenai/tulu-3-sft-personas-instruction-following
48
+ - HuggingFaceH4/no_robots
49
+ ---
50
+
51
+ <div align="center">
52
+
53
+ # Senryu
54
+
55
+ ### Reasoning + tool-call chat model (803M), preference-tuned on its own samples
56
+
57
+ [![Model](https://img.shields.io/badge/Model-803M_params-blue)](.)
58
+ [![Stage](https://img.shields.io/badge/Stage-SFT_+_on--policy_APO-green.svg)](.)
59
+ [![License](https://img.shields.io/badge/License-Apache_2.0-green.svg)](LICENSE)
60
+ [![Architecture](https://img.shields.io/badge/Arch-Gated_DeltaNet--2_+_Gated_MLA-purple.svg)](.)
61
+ [![Demo](https://img.shields.io/badge/Space-senryu--demo-indigo.svg)](https://huggingface.co/spaces/kerzgrr/senryu-demo)
62
+
63
+ *An 803M hybrid Gated DeltaNet-2 + gated MLA model: 32k-context SFT on reasoning, tool and chat data, then APO-zero on pairs sampled from the model itself*
64
+
65
+ </div>
66
+
67
+ ---
68
+
69
+ ## What this is
70
+
71
+ **Senryu** is the 803M successor to [Haiku](https://huggingface.co/kerzgrr/Haiku): eight more layers, Gated DeltaNet-2 in place of Kimi Delta Attention, and a full-rank MLA query.
72
+
73
+ - Pretraining on **1.15B** FineWeb-Edu tokens
74
+ - Supervised fine-tuning at a **32,768-token** sequence length on 6.7B tokens (5.39B assistant tokens) of reasoning, tool-call, instruction-following and chat data
75
+ - Then **APO-zero** on **22,517 on-policy preference pairs**: four samples per prompt from the SFT model itself, judged by [Skywork-Reward-V2-Llama-3.1-8B](https://huggingface.co/Skywork/Skywork-Reward-V2-Llama-3.1-8B) and rule checks for tool calls, answers and loops
76
+ - Live demo: [`kerzgrr/senryu-demo`](https://huggingface.co/spaces/kerzgrr/senryu-demo)
77
+ - Hub weights are the **EMA** snapshot in **bfloat16**
78
+
79
+ This upload is the final checkpoint of the preference run (optimizer step **704**, one full epoch of 22,517 pairs). It is the only released checkpoint of the Senryu generation; the pretrain and SFT checkpoints behind it are not published.
80
+
81
+ ---
82
+
83
+ ## Chat contract
84
+
85
+ ### Thinking
86
+
87
+ Each assistant turn is prefixed with a **zero-loss control token**:
88
+
89
+ | Mode | Prefix | Typical body |
90
+ |------|--------|----------------|
91
+ | think | `<\|think\|>\n` | `<think>…</think>` then the answer |
92
+ | no-think | `<\|no_think\|>\n` | answer only |
93
+
94
+ `inference.py` streams the `<think>` region live (dim yellow) and hides the control tokens.
95
+
96
+ ### Tool calls (Hermes JSON)
97
+
98
+ Tool schemas go in a Hermes-style `<tools>` block in the system turn. The model calls them with:
99
+
100
+ ```
101
+ <tool_call>
102
+ {"name": "web-search", "arguments": {"query": "…"}}
103
+ </tool_call>
104
+ ```
105
+
106
+ Senryu learned schema-following calls from Glaive function calling v2, When2Call, Hermes reasoning tool-use, SmolTalk and the stage-3 tool mix (Toucan 1.5M, Nemotron PTD v1, Nemotron Agentic v1/v2). `web-search` is the only built-in it saw by name, in the Nemotron Agentic conversations; to Senryu, `stateful_python_code_exec` and `calculator` are ordinary schemas it reads from the `<tools>` block. On prompts that offer tools, the preference pairs reward calling and answering directly equally often (6,794 pairs each). `inference.py` auto-runs:
107
+
108
+ | Built-in | Tool name | Observation |
109
+ |----------|-----------|-------------|
110
+ | `--tools web-search` | `web-search` | Tavily-shaped JSON (Tavily if `TAVILY_API_KEY` is set, else DuckDuckGo + Wikipedia) |
111
+ | `--tools python` | `stateful_python_code_exec` | Jupyter-style stdout / last value from a restricted interpreter |
112
+ | `--tools calculator` | `calculator` | Numeric result of a math expression |
113
+
114
+ ### Tool results
115
+
116
+ Each observation is a `tool` (or `user`) turn prefixed with:
117
+
118
+ ```
119
+ <|tool_response|>
120
+ {observation}
121
+ ```
122
+
123
+ ---
124
+
125
+ ## Install & run
126
+
127
+ ```bash
128
+ pip install torch safetensors tokenizers huggingface_hub
129
+ hf download kerzgrr/Senryu inference.py --local-dir .
130
+ python inference.py
131
+ ```
132
+
133
+ ```bash
134
+ python inference.py --prompt "What is the capital of France?"
135
+ python inference.py --tools web-search,python,calculator
136
+ python inference.py --no-think --prompt "Reply in one sentence."
137
+ ```
138
+
139
+ `inference.py` auto-downloads weights / tokenizer / `tiny_gdn/` and auto-installs pinned `flash-linear-attention` (plus `transformers`, which its decode cache imports). Git is required on `PATH`. A CUDA GPU is required: the Gated DeltaNet-2 layers run on Triton kernels.
140
+
141
+ | Flag | Default | Description |
142
+ |------|---------|-------------|
143
+ | `--prompt` | — | One-shot user message |
144
+ | `--system` | — | System prompt, used verbatim |
145
+ | `--think` / `--no-think` | think | Assistant control prefix |
146
+ | `--tools` | — | Built-ins: `web-search`, `python`, `calculator` (comma-separated) |
147
+ | `--temperature` | `0.6` | Sampling temperature (0.3 suits tool-calling turns) |
148
+ | `--top-p` | `0.95` | Nucleus sampling |
149
+ | `--repetition-penalty` | `1.08` | Over the last 256 tokens |
150
+ | `--max-new-tokens` | `4096` | Max generation length |
151
+ | `--context-length` | `32768` | Conversation tokens kept (oldest dropped first) |
152
+ | `--device` | `cuda` | CUDA device, e.g. `cuda:1` |
153
+
154
+ Interactive commands: `/think` `/no_think` `/system …` `/reset` `/exit`. Ctrl+C stops the current reply and keeps the session.
155
+
156
+ ---
157
+
158
+ ## Model architecture
159
+
160
+ 803,000,968 parameters:
161
+
162
+ | | |
163
+ |--|--|
164
+ | **Layers** | 44 (Gated DeltaNet-2 ×3 + gated MLA every 4th) |
165
+ | **Hidden** | 1,024 |
166
+ | **MLP** | SiTU-GLU 3,392 |
167
+ | **Attention** | Gated MLA (NoPE), 8 heads, full-rank query, KV LoRA rank 512 |
168
+ | **Linear** | Gated DeltaNet-2, 8 heads × 128, channel-wise erase and write gates |
169
+ | **Residuals** | Block attention residuals |
170
+ | **Vocab** | 65,536 BPE (the Haiku tokenizer) |
171
+ | **Context** | 32,768 (SFT sequence length) |
172
+
173
+ ---
174
+
175
+ ## Training
176
+
177
+ | Stage | Details |
178
+ |-------|---------|
179
+ | **Pretrain** | [FineWeb-Edu](https://huggingface.co/datasets/HuggingFaceFW/fineweb-edu), seq 2,048, 2,200 steps = 1.15B tokens, hybrid Muon + AdamW at peak 5×10⁻⁴, 10.4 hours. Val loss (EMA) 3.06 |
180
+ | **SFT** | Natural-proportion mixture with a full shuffle: [SmolTalk2](https://huggingface.co/datasets/HuggingFaceTB/smoltalk2) Mid + SFT, the stage-3 tool/agentic mix (Nemotron PTD v1, Agentic v1/v2, IF-Chat v1/v2, Toucan 1.5M), OpenMathReasoning, OpenCodeReasoning, WildChat-4.8M, SmolTalk, Hermes-3, Hermes reasoning tool-use, SystemChat 2.0, SlimOrca, UltraChat 200k, Magicoder Evol-Instruct, Self-OSS-Instruct, Glaive function calling v2, When2Call, OASST2, Tulu 3 IF personas, No Robots. Seq 32,768, hybrid Muon + AdamW 4×10⁻⁴, 12,800 steps = 6.7B tokens (5.39B assistant tokens), 11.8 hours. Val loss (EMA) 0.827 (ppl 2.29) |
181
+ | **Preference (APO)** | APO-zero, β = 0.05, on on-policy pairs. 20,000 prompts drawn from the SFT mixture, four samples each from the SFT EMA (temperature 0.8, top-p 0.95). The chosen reply is the best-judged sample, or the dataset's reference reply when no sample passed; pairs need a reward margin of at least 3. Prompts that offer tools have 6,794 call pairs and 6,794 answer-directly pairs. 3,697 loop-stop pairs prefer ending the turn exactly where a repeat begins over one more repeat. 22,517 pairs, one epoch, 32 pairs per step, 704 steps, AdamW 1×10⁻⁶ cosine to 1×10⁻⁷, 10% warmup, clip 0.2, max length 16,512. Reference model: the SFT EMA. 1.8 hours |
182
+ | **Weights** | EMA (this repo's `model.safetensors`) |
183
+ | **Preference val (EMA)** | 903 held-out pairs: reward accuracy 79.0%, reward margin 1.33, chosen NLL 0.56 per token |
184
+
185
+ `preference_config.json`, `validation.json`, `sft_config.json` and `sft_validation.json` hold the exact settings and held-out results. `preference_data.json` describes the pair pack: prompt selection, sampling, judging, and the composition by source and label.
186
+
187
+ ---
188
+
189
+ ## Limitations
190
+
191
+ - **Scale**: 803M is a research / edge model, not a frontier system
192
+ ---
193
+
194
+ ## Model family
195
+
196
+ | Model | Stage | Hub |
197
+ |-------|-------|-----|
198
+ | **Senryu** | **SFT + on-policy APO (chat, reasoning, tools)** | **this repo** |
199
+ | Demo | ZeroGPU Space (chat) | [`kerzgrr/senryu-demo`](https://huggingface.co/spaces/kerzgrr/senryu-demo) |
200
+ | Haiku | Previous generation (~655M) | [`kerzgrr/Haiku`](https://huggingface.co/kerzgrr/Haiku) |
201
+ | Haiku-base | Previous generation, pretrain | [`kerzgrr/Haiku-base`](https://huggingface.co/kerzgrr/Haiku-base) |
202
+
203
+ ---
204
+
205
+ ## Citation
206
+
207
+ ```bibtex
208
+ @misc{senryu2026,
209
+ title={Senryu: An 803M Hybrid Gated DeltaNet-2 + Gated-MLA Reasoning Model},
210
+ author={kerzgrr},
211
+ year={2026},
212
+ url={https://huggingface.co/kerzgrr/Senryu}
213
+ }
214
+ ```
215
+
216
+ ---
217
+
218
+ <div align="center">
219
+
220
+ *Five, seven, five — about people this time.*
221
+
222
+ </div>
chat_template.jinja ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {%- set ns = namespace(xml_tools=none, tools_emitted=false) -%}
2
+ {%- if xml_tools is defined and xml_tools -%}
3
+ {%- set ns.xml_tools = xml_tools -%}
4
+ {%- elif tools is defined and tools -%}
5
+ {%- set ns.xml_tools = tools -%}
6
+ {%- endif -%}
7
+ {%- set tools_preamble = 'You may call one or more functions to assist with the user query.\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>\n' -%}
8
+ {%- set tools_epilogue = '</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>' -%}
9
+ {%- for message in messages -%}
10
+ {%- if loop.first -%}{{- bos_token -}}{%- endif -%}
11
+ {%- if loop.first and ns.xml_tools and message['role'] != 'system' -%}
12
+ {{- '<|im_start|>system\n' + tools_preamble -}}
13
+ {%- for tool in ns.xml_tools -%}
14
+ {%- if tool is string -%}{{- (tool | replace('<tools>', '') | replace('</tools>', '') | trim) + '\n' -}}
15
+ {%- else -%}{{- tool | tojson + '\n' -}}
16
+ {%- endif -%}
17
+ {%- endfor -%}
18
+ {{- tools_epilogue + '<|im_end|>\n' -}}
19
+ {%- set ns.tools_emitted = true -%}
20
+ {%- endif -%}
21
+ {%- set raw_role = message['role'] -%}
22
+ {%- set role = 'user' if raw_role == 'tool' or raw_role == 'function' else raw_role -%}
23
+ {%- set content_text = namespace(value='') -%}
24
+ {%- if message['content'] is string -%}
25
+ {%- set content_text.value = message['content'] -%}
26
+ {%- elif message['content'] is iterable -%}
27
+ {%- for item in message['content'] -%}
28
+ {%- if item['type'] == 'text' -%}{%- set content_text.value = content_text.value + item['text'] -%}{%- endif -%}
29
+ {%- endfor -%}
30
+ {%- endif -%}
31
+ {%- set is_tool = raw_role == 'tool' or raw_role == 'function' -%}
32
+ {%- set prev_is_tool = loop.previtem is defined and (loop.previtem['role'] == 'tool' or loop.previtem['role'] == 'function') -%}
33
+ {%- set next_is_tool = loop.nextitem is defined and (loop.nextitem['role'] == 'tool' or loop.nextitem['role'] == 'function') -%}
34
+ {%- if raw_role == 'system' and not (content_text.value | trim) and not (ns.xml_tools and not ns.tools_emitted) -%}
35
+ {%- else -%}
36
+ {%- if is_tool and prev_is_tool -%}
37
+ {{- '\n' -}}
38
+ {%- else -%}
39
+ {{- '<|im_start|>' + role + '\n' -}}
40
+ {%- endif -%}
41
+ {%- if raw_role == 'assistant' and enable_thinking is defined -%}
42
+ {{- ('<|think|>\n' if enable_thinking else '<|no_think|>\n') -}}
43
+ {%- endif -%}
44
+ {%- if is_tool -%}
45
+ {{- '<|tool_response|>\n' + (content_text.value | replace('<tool_response>', '') | replace('</tool_response>', '') | trim) -}}
46
+ {%- elif raw_role == 'system' -%}
47
+ {{- content_text.value | replace('/system_override', '') | replace('/no_think', '') | replace('/think', '') | trim -}}
48
+ {%- else -%}
49
+ {{- content_text.value -}}
50
+ {%- endif -%}
51
+ {%- if raw_role == 'system' and ns.xml_tools and not ns.tools_emitted and '<tools>' not in content_text.value -%}
52
+ {%- if content_text.value | trim -%}{{- '\n\n' -}}{%- endif -%}
53
+ {{- tools_preamble -}}
54
+ {%- for tool in ns.xml_tools -%}
55
+ {%- if tool is string -%}{{- (tool | replace('<tools>', '') | replace('</tools>', '') | trim) + '\n' -}}
56
+ {%- else -%}{{- tool | tojson + '\n' -}}
57
+ {%- endif -%}
58
+ {%- endfor -%}
59
+ {{- tools_epilogue -}}
60
+ {%- set ns.tools_emitted = true -%}
61
+ {%- endif -%}
62
+ {%- if raw_role == 'assistant' and message['tool_calls'] is defined and message['tool_calls'] -%}
63
+ {%- if '<tool_call>' not in content_text.value -%}
64
+ {%- for tool_call in message['tool_calls'] -%}
65
+ {%- set fn = tool_call['function'] if tool_call['function'] is defined else tool_call -%}
66
+ {%- if loop.first and not (content_text.value | trim) -%}
67
+ {{- '<tool_call>\n{"name": "' + fn['name'] + '", "arguments": ' -}}
68
+ {%- else -%}
69
+ {{- '\n<tool_call>\n{"name": "' + fn['name'] + '", "arguments": ' -}}
70
+ {%- endif -%}
71
+ {%- if fn['arguments'] is string -%}{{- fn['arguments'] -}}
72
+ {%- else -%}{{- fn['arguments'] | tojson -}}
73
+ {%- endif -%}
74
+ {{- '}\n</tool_call>' -}}
75
+ {%- endfor -%}
76
+ {%- endif -%}
77
+ {%- endif -%}
78
+ {%- if not (is_tool and next_is_tool) -%}
79
+ {{- '<|im_end|>\n' -}}
80
+ {%- endif -%}
81
+ {%- endif -%}
82
+ {%- endfor -%}
83
+ {%- if add_generation_prompt -%}
84
+ {{- '<|im_start|>assistant\n' -}}
85
+ {%- if enable_thinking is defined -%}{{- ('<|think|>\n' if enable_thinking else '<|no_think|>\n') -}}{%- endif -%}
86
+ {%- endif -%}
config.json ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "allow_negative_eigenvalues": false,
3
+ "architecture": "SenryuForCausalLM",
4
+ "architectures": [
5
+ "SenryuForCausalLM"
6
+ ],
7
+ "attention_dropout": 0.0,
8
+ "attention_head_dim": 128,
9
+ "attention_layer_type": "gated_mla",
10
+ "bos_token_id": 0,
11
+ "chat_template": "smollm3",
12
+ "checkpoint_step": 704,
13
+ "eos_token_id": 1,
14
+ "full_attention_interval": 4,
15
+ "hidden_size": 1024,
16
+ "initializer_range": 0.02,
17
+ "intermediate_size": 3392,
18
+ "kda_lower_bound": -5.0,
19
+ "kda_safe_gate": true,
20
+ "layer_types": [
21
+ "gdn2",
22
+ "gdn2",
23
+ "gdn2",
24
+ "gated_mla",
25
+ "gdn2",
26
+ "gdn2",
27
+ "gdn2",
28
+ "gated_mla",
29
+ "gdn2",
30
+ "gdn2",
31
+ "gdn2",
32
+ "gated_mla",
33
+ "gdn2",
34
+ "gdn2",
35
+ "gdn2",
36
+ "gated_mla",
37
+ "gdn2",
38
+ "gdn2",
39
+ "gdn2",
40
+ "gated_mla",
41
+ "gdn2",
42
+ "gdn2",
43
+ "gdn2",
44
+ "gated_mla",
45
+ "gdn2",
46
+ "gdn2",
47
+ "gdn2",
48
+ "gated_mla",
49
+ "gdn2",
50
+ "gdn2",
51
+ "gdn2",
52
+ "gated_mla",
53
+ "gdn2",
54
+ "gdn2",
55
+ "gdn2",
56
+ "gated_mla",
57
+ "gdn2",
58
+ "gdn2",
59
+ "gdn2",
60
+ "gated_mla",
61
+ "gdn2",
62
+ "gdn2",
63
+ "gdn2",
64
+ "gated_mla"
65
+ ],
66
+ "linear_conv_kernel_dim": 4,
67
+ "linear_expand_v": 1.0,
68
+ "linear_head_dim": 128,
69
+ "linear_layer_type": "gdn2",
70
+ "linear_num_heads": 8,
71
+ "linear_num_value_heads": 8,
72
+ "max_position_embeddings": 32768,
73
+ "mla_kv_lora_rank": 512,
74
+ "mla_q_lora_rank": null,
75
+ "mla_qk_nope_head_dim": 128,
76
+ "mla_v_head_dim": 128,
77
+ "mlp_activation": "situ_glu",
78
+ "model_family": "Senryu",
79
+ "model_name": "Senryu",
80
+ "model_type": "senryu",
81
+ "mtp_adapter_rank": 128,
82
+ "mtp_loss_weight": 0.0,
83
+ "mtp_num_heads": 0,
84
+ "num_attention_heads": 8,
85
+ "num_hidden_layers": 44,
86
+ "num_key_value_heads": 8,
87
+ "pad_token_id": 2,
88
+ "partial_rotary_factor": 0.5,
89
+ "preference_beta": 0.05,
90
+ "preference_hours": 1.7632968792019852,
91
+ "preference_loss": "apo_zero",
92
+ "preference_reward_model": "Skywork/Skywork-Reward-V2-Llama-3.1-8B",
93
+ "preference_run": "apo-v2",
94
+ "preference_train_pairs": 22517,
95
+ "preference_val_loss_ema": 0.8922630808546166,
96
+ "preference_val_pairs": 903,
97
+ "preference_val_reward_accuracy_ema": 0.7895902547065338,
98
+ "preference_val_reward_margin_ema": 1.3302929308987403,
99
+ "pretrain_checkpoint_step": 2200,
100
+ "pretrain_hours": 10.380536459482197,
101
+ "pretrain_run": "pretrain-v1",
102
+ "pretrain_tokens": 1153433600,
103
+ "pretrain_val_loss_ema": 3.06275936961174,
104
+ "reasoning": true,
105
+ "rms_norm_eps": 1e-06,
106
+ "rope_theta": 1000000.0,
107
+ "sft_assistant_tokens": 5388440922,
108
+ "sft_checkpoint_step": 12800,
109
+ "sft_hours": 11.803919963316359,
110
+ "sft_run": "sft-v1",
111
+ "sft_val_loss_ema": 0.8266990829440273,
112
+ "sft_val_ppl_ema": 2.2857611656454533,
113
+ "shared_layer_indices": [],
114
+ "situ_gate_cap": 4.0,
115
+ "situ_up_cap": 25.0,
116
+ "stage": "preference",
117
+ "tie_word_embeddings": true,
118
+ "tool_calls": "hermes_json",
119
+ "torch_dtype": "bfloat16",
120
+ "training_sequence_length": 32768,
121
+ "transformers_version": "4.45.0",
122
+ "unk_token_id": 3,
123
+ "use_block_attn_res": true,
124
+ "vocab_size": 65536,
125
+ "weights": "ema"
126
+ }
inference.py ADDED
@@ -0,0 +1,593 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Standalone chat inference for kerzgrr/Senryu.
3
+
4
+ Streams a live reasoning trace (`<think>` / `<|think|>`) and runs Hermes JSON
5
+ tool-call rounds. Built-in `web-search`, `python`, and `calculator` tools
6
+ execute automatically. Other observations are sent as `<|tool_response|>`.
7
+
8
+ Examples:
9
+ python inference.py --prompt "What is the capital of France?"
10
+ python inference.py --tools web-search,python,calculator
11
+ python inference.py --no-think --prompt "Reply in one sentence."
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import importlib
18
+ import json
19
+ import os
20
+ import platform
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+ import time
25
+ import warnings
26
+ from dataclasses import fields
27
+ from pathlib import Path
28
+ from types import SimpleNamespace
29
+ from typing import Any
30
+
31
+ import torch
32
+ from huggingface_hub import hf_hub_download
33
+ from safetensors.torch import load_file
34
+ from tokenizers import Tokenizer
35
+
36
+
37
+ def _silence_runtime_warnings() -> None:
38
+ patterns = (
39
+ r"tl\.make_block_ptr is deprecated",
40
+ r"Memory efficient kernel not used because",
41
+ r"Memory Efficient attention has been runtime disabled",
42
+ r"Flash attention kernel not used because",
43
+ r"Torch was not compiled with flash attention",
44
+ r"cuDNN attention kernel not used because",
45
+ r"cuDNN attention has been runtime disabled",
46
+ )
47
+ for pattern in patterns:
48
+ warnings.filterwarnings("ignore", message=pattern)
49
+
50
+
51
+ REPO_ID = "kerzgrr/Senryu"
52
+ FLA_COMMIT = "cbb0a72efb55c18ca0ef4f298298317573ad2cb3"
53
+ FLA_REPO = "https://github.com/fla-org/flash-linear-attention.git"
54
+ FLA_RUNTIME_REQUIREMENT = "transformers>=4.45.0"
55
+ PATCH_FILES = (
56
+ "fla/__init__.py",
57
+ "fla/ops/__init__.py",
58
+ "fla/layers/__init__.py",
59
+ "fla/ops/simple_gla/__init__.py",
60
+ )
61
+ TINY_GDN_FILES = (
62
+ "tiny_gdn/__init__.py",
63
+ "tiny_gdn/config.py",
64
+ "tiny_gdn/model.py",
65
+ "tiny_gdn/haiku_layers.py",
66
+ "tiny_gdn/nn_common.py",
67
+ "tiny_gdn/fused_ops.py",
68
+ "tiny_gdn/tools.py",
69
+ "tiny_gdn/smoltalk_chat.py",
70
+ "tiny_gdn/chatml.py",
71
+ "tiny_gdn/cli_chat.py",
72
+ "tiny_gdn/detokenize.py",
73
+ "tiny_gdn/code_exec.py",
74
+ "tiny_gdn/web_search.py",
75
+ )
76
+
77
+
78
+ def _download(filename: str, local_dir: Path | None) -> Path:
79
+ return Path(
80
+ hf_hub_download(
81
+ repo_id=REPO_ID,
82
+ filename=filename,
83
+ local_dir=str(local_dir) if local_dir else None,
84
+ )
85
+ )
86
+
87
+
88
+ def _run(cmd: list[str], *, cwd: Path | None = None, env: dict | None = None) -> None:
89
+ print("+", " ".join(cmd), flush=True)
90
+ merged = os.environ.copy()
91
+ if env:
92
+ merged.update(env)
93
+ merged.setdefault("PYTHONUTF8", "1")
94
+ merged.setdefault("PYTHONIOENCODING", "utf-8")
95
+ subprocess.check_call(cmd, cwd=str(cwd) if cwd else None, env=merged)
96
+
97
+
98
+ def _pip_install(*args: str) -> None:
99
+ _run([sys.executable, "-m", "pip", "install", *args])
100
+
101
+
102
+ def _fla_importable() -> tuple[bool, str]:
103
+ # fla.models.utils holds the decode cache and is the module that needs
104
+ # transformers at runtime; the GDN-2 layer alone imports without it.
105
+ try:
106
+ importlib.import_module("fla.layers.gdn2")
107
+ importlib.import_module("fla.models.utils")
108
+ except Exception as error: # noqa: BLE001
109
+ return False, str(error)
110
+ return True, ""
111
+
112
+
113
+ def _cache_root() -> Path:
114
+ override = os.environ.get("SENRYU_CACHE") or os.environ.get("MONOSTICH_CACHE")
115
+ if override:
116
+ path = Path(override).expanduser().resolve()
117
+ else:
118
+ path = Path.home() / ".cache" / "senryu"
119
+ path.mkdir(parents=True, exist_ok=True)
120
+ return path
121
+
122
+
123
+ def _ensure_git() -> None:
124
+ if shutil.which("git") is None:
125
+ raise RuntimeError(
126
+ "git is required to auto-install flash-linear-attention. "
127
+ "Install Git and ensure it is on PATH."
128
+ )
129
+
130
+
131
+ def _apply_windows_fla_patches(fla_root: Path, local_dir: Path | None) -> None:
132
+ print("Applying Windows FLA import patches from the Hub …", flush=True)
133
+ for relative in PATCH_FILES:
134
+ source = _download(f"windows_fla_patches/{relative}", local_dir)
135
+ target = fla_root / relative
136
+ target.parent.mkdir(parents=True, exist_ok=True)
137
+ shutil.copy2(source, target)
138
+ print(f" patched {relative}", flush=True)
139
+
140
+
141
+ def _install_fla(local_dir: Path | None) -> None:
142
+ print("flash-linear-attention missing/broken — installing automatically …", flush=True)
143
+ _pip_install("einops", "numpy", FLA_RUNTIME_REQUIREMENT)
144
+ if platform.system() != "Windows":
145
+ _pip_install("--no-deps", f"git+{FLA_REPO}@{FLA_COMMIT}")
146
+ return
147
+
148
+ _ensure_git()
149
+ fla_root = _cache_root() / "flash-linear-attention"
150
+ if (fla_root / ".git").is_dir():
151
+ _run(["git", "fetch", "--depth", "1", "origin", FLA_COMMIT], cwd=fla_root)
152
+ _run(["git", "checkout", "--force", FLA_COMMIT], cwd=fla_root)
153
+ else:
154
+ if fla_root.exists():
155
+ shutil.rmtree(fla_root)
156
+ _run(["git", "clone", "--filter=blob:none", FLA_REPO, str(fla_root)])
157
+ _run(["git", "fetch", "--depth", "1", "origin", FLA_COMMIT], cwd=fla_root)
158
+ _run(["git", "checkout", "--force", FLA_COMMIT], cwd=fla_root)
159
+
160
+ _apply_windows_fla_patches(fla_root, local_dir)
161
+ _pip_install("--no-build-isolation", "--no-deps", "-e", str(fla_root))
162
+
163
+
164
+ def _ensure_fla(local_dir: Path | None) -> None:
165
+ ok, error = _fla_importable()
166
+ if ok:
167
+ return
168
+ print(f"FLA not ready ({error})", flush=True)
169
+ try:
170
+ _install_fla(local_dir)
171
+ except Exception as install_error: # noqa: BLE001
172
+ raise RuntimeError(
173
+ "Automatic flash-linear-attention install failed.\n"
174
+ f"Original import error: {error}\n"
175
+ f"Install error: {install_error}"
176
+ ) from install_error
177
+
178
+ for name in list(sys.modules):
179
+ if name == "fla" or name.startswith("fla."):
180
+ del sys.modules[name]
181
+ importlib.invalidate_caches()
182
+
183
+ ok, error = _fla_importable()
184
+ if not ok:
185
+ raise RuntimeError(
186
+ "flash-linear-attention installed but still failed to import "
187
+ f"GatedDeltaNet2 and its decode cache: {error}"
188
+ )
189
+ print("flash-linear-attention ready.", flush=True)
190
+
191
+
192
+ def _has_tiny_gdn(root: Path) -> bool:
193
+ return all((root / name).is_file() for name in TINY_GDN_FILES)
194
+
195
+
196
+ def _ensure_tiny_gdn(local_dir: Path | None) -> Path:
197
+ here = Path(__file__).resolve().parent
198
+ if _has_tiny_gdn(here):
199
+ return here
200
+ if local_dir and _has_tiny_gdn(local_dir):
201
+ return local_dir
202
+ for name in TINY_GDN_FILES:
203
+ _download(name, local_dir)
204
+ return _download("tiny_gdn/__init__.py", local_dir).parent.parent
205
+
206
+
207
+ def _import_chat_stack() -> SimpleNamespace:
208
+ # tiny_gdn is fetched from the Hub and FLA may be installed at runtime, so
209
+ # these modules only become importable after _ensure_tiny_gdn/_ensure_fla.
210
+ tiny_gdn = importlib.import_module("tiny_gdn")
211
+ chatml = importlib.import_module("tiny_gdn.chatml")
212
+ cli_chat = importlib.import_module("tiny_gdn.cli_chat")
213
+ detokenize = importlib.import_module("tiny_gdn.detokenize")
214
+ tools = importlib.import_module("tiny_gdn.tools")
215
+ return SimpleNamespace(
216
+ TinyGDNConfig=tiny_gdn.TinyGDNConfig,
217
+ TinyGDNForCausalLM=tiny_gdn.TinyGDNForCausalLM,
218
+ encode_chatml_messages=chatml.encode_chatml_messages,
219
+ ChatLoopState=cli_chat.ChatLoopState,
220
+ LiveReasoningStreamer=cli_chat.LiveReasoningStreamer,
221
+ append_tool_round=cli_chat.append_tool_round,
222
+ apply_slash_command=cli_chat.apply_slash_command,
223
+ prompt_tool_results=cli_chat.prompt_tool_results,
224
+ resolve_cli_tools=cli_chat.resolve_cli_tools,
225
+ IncrementalUtf8Decoder=detokenize.IncrementalUtf8Decoder,
226
+ parse_tool_calls=tools.parse_tool_calls,
227
+ )
228
+
229
+
230
+ def _sample(
231
+ logits: torch.Tensor,
232
+ *,
233
+ temperature: float,
234
+ top_p: float,
235
+ top_k: int,
236
+ generator: torch.Generator,
237
+ ) -> int:
238
+ logits = logits.float()
239
+ if temperature <= 1e-5:
240
+ return int(torch.argmax(logits).item())
241
+ logits = logits / temperature
242
+ if 0 < top_k < logits.shape[-1]:
243
+ threshold = torch.topk(logits, top_k).values[-1]
244
+ logits = logits.masked_fill(logits < threshold, -torch.inf)
245
+ if top_p < 1.0:
246
+ sorted_logits, sorted_indices = torch.sort(logits, descending=True)
247
+ probs = torch.softmax(sorted_logits, dim=-1)
248
+ remove = torch.cumsum(probs, dim=-1) > top_p
249
+ remove[1:] = remove[:-1].clone()
250
+ remove[0] = False
251
+ sorted_logits = sorted_logits.masked_fill(remove, -torch.inf)
252
+ logits = torch.full_like(logits, -torch.inf)
253
+ logits.scatter_(0, sorted_indices, sorted_logits)
254
+ probs = torch.softmax(logits, dim=-1).cpu()
255
+ return int(torch.multinomial(probs, 1, generator=generator).item())
256
+
257
+
258
+ def _apply_repetition_penalty(
259
+ logits: torch.Tensor,
260
+ token_ids: list[int],
261
+ penalty: float,
262
+ window: int,
263
+ ) -> torch.Tensor:
264
+ if penalty == 1.0 or not token_ids:
265
+ return logits
266
+ recent = token_ids[-window:] if window > 0 else token_ids
267
+ unique = torch.tensor(list(set(recent)), dtype=torch.long, device=logits.device)
268
+ score = logits[unique]
269
+ logits[unique] = torch.where(score > 0, score / penalty, score * penalty)
270
+ return logits
271
+
272
+
273
+ @torch.inference_mode()
274
+ def generate_turn(
275
+ model,
276
+ tokenizer: Tokenizer,
277
+ stack: SimpleNamespace,
278
+ streamer,
279
+ messages: list[dict[str, Any]],
280
+ *,
281
+ tools: Any | None,
282
+ enable_thinking: bool,
283
+ max_new_tokens: int,
284
+ context_length: int,
285
+ temperature: float,
286
+ top_p: float,
287
+ top_k: int,
288
+ repetition_penalty: float,
289
+ repetition_window: int,
290
+ seed: int,
291
+ device: torch.device,
292
+ ) -> tuple[str, tuple]:
293
+ prompt_ids = stack.encode_chatml_messages(
294
+ tokenizer,
295
+ messages,
296
+ tools=tools,
297
+ enable_thinking=enable_thinking,
298
+ )
299
+ if len(prompt_ids) > context_length:
300
+ print(
301
+ f"[warn] conversation is {len(prompt_ids)} tokens; "
302
+ f"keeping the last {context_length} (--context-length)",
303
+ file=sys.stderr,
304
+ flush=True,
305
+ )
306
+ eos_id = int(model.config.eos_token_id)
307
+ im_end = tokenizer.token_to_id("<|im_end|>")
308
+ stop = {eos_id}
309
+ if im_end is not None:
310
+ stop.add(im_end)
311
+
312
+ token_ids = list(prompt_ids[-context_length:])
313
+ generated: list[int] = []
314
+ decoder = stack.IncrementalUtf8Decoder(tokenizer)
315
+ stop_reason = "max_new_tokens"
316
+ generator = torch.Generator(device="cpu")
317
+ generator.manual_seed(seed)
318
+ started = time.perf_counter()
319
+
320
+ try:
321
+ input_ids = torch.tensor([token_ids], dtype=torch.long, device=device)
322
+ output = model(
323
+ input_ids,
324
+ return_logits=True,
325
+ logits_to_keep=1,
326
+ use_cache=True,
327
+ )
328
+
329
+ for step in range(max_new_tokens):
330
+ if output.logits is None:
331
+ raise RuntimeError("Model returned no logits")
332
+ next_logits = _apply_repetition_penalty(
333
+ output.logits[0, -1],
334
+ token_ids,
335
+ repetition_penalty,
336
+ repetition_window,
337
+ )
338
+ next_id = _sample(
339
+ next_logits,
340
+ temperature=temperature,
341
+ top_p=top_p,
342
+ top_k=top_k,
343
+ generator=generator,
344
+ )
345
+ if next_id in stop:
346
+ stop_reason = "stop"
347
+ break
348
+ token_ids.append(next_id)
349
+ generated.append(next_id)
350
+ decoder.push(next_id)
351
+ if streamer is not None:
352
+ streamer.update(decoder.text)
353
+ if step + 1 < max_new_tokens:
354
+ if output.past_key_values is None:
355
+ raise RuntimeError("Model returned no decode cache")
356
+ input_ids = torch.tensor([[next_id]], dtype=torch.long, device=device)
357
+ output = model(
358
+ input_ids,
359
+ return_logits=True,
360
+ logits_to_keep=1,
361
+ past_key_values=output.past_key_values,
362
+ use_cache=True,
363
+ )
364
+ except KeyboardInterrupt:
365
+ stop_reason = "interrupted"
366
+
367
+ decoded = decoder.finalize()
368
+ if streamer is not None:
369
+ streamer.finish()
370
+ elapsed = time.perf_counter() - started
371
+ tps = len(generated) / max(elapsed, 1e-9)
372
+ print(
373
+ f"[done] tokens={len(generated)} stop={stop_reason} {tps:.1f} tok/s",
374
+ file=sys.stderr,
375
+ flush=True,
376
+ )
377
+ if stop_reason == "interrupted":
378
+ return decoded, ()
379
+ return decoded, tuple(stack.parse_tool_calls(decoded))
380
+
381
+
382
+ def parse_args() -> argparse.Namespace:
383
+ parser = argparse.ArgumentParser(description="Senryu chat inference")
384
+ parser.add_argument("--prompt", default=None, help="Single user prompt")
385
+ parser.add_argument("--system", default="", help="Optional system prompt")
386
+ parser.add_argument(
387
+ "--think",
388
+ dest="enable_thinking",
389
+ action="store_true",
390
+ default=True,
391
+ )
392
+ parser.add_argument(
393
+ "--no-think",
394
+ dest="enable_thinking",
395
+ action="store_false",
396
+ )
397
+ parser.add_argument(
398
+ "--tools",
399
+ default="",
400
+ help="Built-in tools: web-search, python, calculator (comma-separated)",
401
+ )
402
+ parser.add_argument("--tools-json", default=None)
403
+ parser.add_argument("--max-tool-rounds", type=int, default=8)
404
+ parser.add_argument("--max-new-tokens", type=int, default=4096)
405
+ parser.add_argument("--temperature", type=float, default=0.6)
406
+ parser.add_argument("--top-p", type=float, default=0.95)
407
+ parser.add_argument("--top-k", type=int, default=50)
408
+ parser.add_argument("--repetition-penalty", type=float, default=1.08)
409
+ parser.add_argument("--repetition-window", type=int, default=256)
410
+ parser.add_argument("--context-length", type=int, default=32768)
411
+ parser.add_argument("--seed", type=int, default=42)
412
+ parser.add_argument(
413
+ "--device",
414
+ default="cuda",
415
+ help="CUDA device (cuda, cuda:1, …); Senryu needs a GPU",
416
+ )
417
+ parser.add_argument("--no-stream", action="store_true")
418
+ parser.add_argument("--no-color", action="store_true")
419
+ parser.add_argument("--local-dir", default=None)
420
+ parser.add_argument("--repo-id", default=REPO_ID)
421
+ return parser.parse_args()
422
+
423
+
424
+ def main() -> int:
425
+ _silence_runtime_warnings()
426
+ args = parse_args()
427
+ global REPO_ID
428
+ REPO_ID = args.repo_id
429
+ local_dir = Path(args.local_dir).resolve() if args.local_dir else None
430
+
431
+ try:
432
+ device = torch.device(args.device)
433
+ except RuntimeError as error:
434
+ print(f"Invalid --device {args.device!r}: {error}", file=sys.stderr)
435
+ return 1
436
+ if device.type != "cuda" or not torch.cuda.is_available():
437
+ print(
438
+ "Senryu needs a CUDA GPU: its Gated DeltaNet-2 layers run on "
439
+ "flash-linear-attention Triton kernels. "
440
+ f"torch {torch.__version__} reports CUDA available={torch.cuda.is_available()}.",
441
+ file=sys.stderr,
442
+ )
443
+ return 1
444
+
445
+ print(f"Loading Senryu from huggingface.co/{REPO_ID} …", flush=True)
446
+ try:
447
+ package_root = _ensure_tiny_gdn(local_dir)
448
+ except Exception as error: # noqa: BLE001
449
+ print(f"Failed to resolve tiny_gdn package: {error}", file=sys.stderr)
450
+ return 1
451
+
452
+ if str(package_root) not in sys.path:
453
+ sys.path.insert(0, str(package_root))
454
+
455
+ try:
456
+ _ensure_fla(local_dir)
457
+ except Exception as error: # noqa: BLE001
458
+ print(str(error), file=sys.stderr)
459
+ return 1
460
+
461
+ try:
462
+ stack = _import_chat_stack()
463
+ except ImportError as error:
464
+ print(f"Could not import tiny_gdn chat stack: {error}", file=sys.stderr)
465
+ return 1
466
+
467
+ try:
468
+ raw_json = (
469
+ Path(args.tools_json).read_text(encoding="utf-8")
470
+ if args.tools_json
471
+ else None
472
+ )
473
+ tools = stack.resolve_cli_tools(args.tools, raw_json)
474
+ except (OSError, ValueError, json.JSONDecodeError) as error:
475
+ print(error, file=sys.stderr)
476
+ return 1
477
+
478
+ weights_path = _download("model.safetensors", local_dir)
479
+ tok_path = _download("tokenizer.json", local_dir)
480
+ cfg_path = _download("config.json", local_dir)
481
+
482
+ raw = json.loads(cfg_path.read_text(encoding="utf-8"))
483
+ allowed = {item.name for item in fields(stack.TinyGDNConfig)}
484
+ payload = {key: value for key, value in raw.items() if key in allowed}
485
+ if "shared_layer_indices" in payload:
486
+ payload["shared_layer_indices"] = tuple(payload["shared_layer_indices"])
487
+ config = stack.TinyGDNConfig(**payload)
488
+
489
+ print(
490
+ f"Building Senryu ({config.num_hidden_layers}L / {config.hidden_size}d) "
491
+ f"on {device} …",
492
+ flush=True,
493
+ )
494
+ model = stack.TinyGDNForCausalLM(config)
495
+ state = load_file(str(weights_path), device="cpu")
496
+ model.load_state_dict(state, strict=True)
497
+ del state
498
+ model = model.to(device=device, dtype=torch.bfloat16)
499
+ model.eval()
500
+ model.requires_grad_(False)
501
+
502
+ tokenizer = Tokenizer.from_file(str(tok_path))
503
+ context_length = min(args.context_length, config.max_position_embeddings)
504
+ color = not args.no_color and sys.stdout.isatty()
505
+
506
+ def read_line(prompt: str) -> str:
507
+ return input(prompt)
508
+
509
+ def run_turn(state) -> None:
510
+ for round_index in range(max(1, args.max_tool_rounds + 1)):
511
+ streamer = (
512
+ None
513
+ if args.no_stream
514
+ else stack.LiveReasoningStreamer(sys.stdout, color=color)
515
+ )
516
+ if streamer is not None:
517
+ print("assistant> ", end="", flush=True)
518
+ text, calls = generate_turn(
519
+ model,
520
+ tokenizer,
521
+ stack,
522
+ streamer,
523
+ state.messages,
524
+ tools=state.tools,
525
+ enable_thinking=state.enable_thinking,
526
+ max_new_tokens=args.max_new_tokens,
527
+ context_length=context_length,
528
+ temperature=args.temperature,
529
+ top_p=args.top_p,
530
+ top_k=args.top_k,
531
+ repetition_penalty=args.repetition_penalty,
532
+ repetition_window=args.repetition_window,
533
+ seed=args.seed,
534
+ device=device,
535
+ )
536
+ if args.no_stream:
537
+ print(f"assistant> {text}")
538
+ if not calls or round_index >= args.max_tool_rounds:
539
+ state.messages.append({"role": "assistant", "content": text})
540
+ return
541
+ results = stack.prompt_tool_results(calls, read_line=read_line)
542
+ stack.append_tool_round(state.messages, text, results)
543
+
544
+ state = stack.ChatLoopState(
545
+ system=args.system,
546
+ enable_thinking=args.enable_thinking,
547
+ tools=tools,
548
+ )
549
+ state.reset()
550
+
551
+ if args.prompt is not None:
552
+ state.add_user(args.prompt)
553
+ try:
554
+ run_turn(state)
555
+ except KeyboardInterrupt:
556
+ print("\n(interrupted)", file=sys.stderr)
557
+ return 130
558
+ return 0
559
+
560
+ print(
561
+ "Interactive Senryu chat. "
562
+ "Commands: /think /no_think /system … /reset /exit\n"
563
+ "web-search, python, and calculator run automatically when enabled. "
564
+ "Other <tool_call> observations can be pasted at the prompt. "
565
+ "Ctrl+C stops a reply.",
566
+ flush=True,
567
+ )
568
+ while True:
569
+ try:
570
+ user_input = input("user> ")
571
+ except (EOFError, KeyboardInterrupt):
572
+ print()
573
+ return 0
574
+ text = user_input.strip()
575
+ if not text:
576
+ continue
577
+ status = stack.apply_slash_command(state, text)
578
+ if status == "exit":
579
+ return 0
580
+ if status is not None:
581
+ print(f"({status})", flush=True)
582
+ continue
583
+ state.add_user(text)
584
+ try:
585
+ run_turn(state)
586
+ except KeyboardInterrupt:
587
+ print("\n(interrupted)", flush=True)
588
+ print(flush=True)
589
+ return 0
590
+
591
+
592
+ if __name__ == "__main__":
593
+ raise SystemExit(main())
merges.txt ADDED
The diff for this file is too large to render. See raw diff
 
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:57f7960df1a6e4b9cee141de3a909de43790afd51d2ea8258f34e67847936668
3
+ size 1606092704
preference_config.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "adam_beta1": 0.9,
3
+ "adam_beta2": 0.999,
4
+ "adam_epsilon": 1e-08,
5
+ "beta": 0.05,
6
+ "dataset_manifest": "/media/nvidia/4TB2/llm-data/senryu/rl-v1/pack-v2/manifest.json",
7
+ "ema_inv_gamma": 1.0,
8
+ "ema_max_decay": 0.9999,
9
+ "ema_power": 0.75,
10
+ "enable_gradient_checkpointing": true,
11
+ "epochs": 1,
12
+ "forward_overhead_tokens": 1024,
13
+ "fp32_master_weights": true,
14
+ "gradient_checkpointing_layers": 22,
15
+ "gradient_clip_norm": 0.2,
16
+ "initial_checkpoint": "checkpoint-00012800",
17
+ "initial_run_dir": "/media/nvidia/4TB2/llm-runs/senryu/sft-v1",
18
+ "initial_weights": "ema",
19
+ "label_smoothing": 0.0,
20
+ "learning_rate": 1e-06,
21
+ "log_every_optimizer_steps": 1,
22
+ "loss_type": "apo_zero",
23
+ "max_length": 16512,
24
+ "maximum_checkpoints": 5,
25
+ "maximum_optimizer_steps": null,
26
+ "micro_batch_tokens": 32768,
27
+ "minimum_learning_rate_ratio": 0.1,
28
+ "muon_learning_rate": null,
29
+ "muon_lr_adjustment": "match_rms_adamw",
30
+ "muon_momentum": 0.95,
31
+ "optimizer": "adamw",
32
+ "output_dir": "/media/nvidia/4TB2/llm-runs/senryu/apo-v2",
33
+ "pairs_per_step": 32,
34
+ "reference_batch_tokens": 32768,
35
+ "save_every_optimizer_steps": 100,
36
+ "seed": 42,
37
+ "sft_weight": 0.0,
38
+ "sources": null,
39
+ "validation_pairs": 1000,
40
+ "warmup_ratio": 0.1,
41
+ "warmup_steps": null,
42
+ "weight_decay": 0.0
43
+ }
preference_data.json ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "kind": "on-policy preference pairs from SFT-mixture prompts",
3
+ "prompt_selection": {
4
+ "excluded_components": [
5
+ "nemotron-cascade-2"
6
+ ],
7
+ "settings": {
8
+ "prompts": 20000,
9
+ "offered_share": 0.3333333333333333,
10
+ "floor": 40,
11
+ "min_weight": 1000,
12
+ "max_prompt_tokens": 8192,
13
+ "max_reference_tokens": 8192,
14
+ "validation_permyriad": 400,
15
+ "max_scan": 600000,
16
+ "seed": 20261018
17
+ }
18
+ },
19
+ "policy": {
20
+ "run": "sft-v1",
21
+ "checkpoint": "checkpoint-00012800",
22
+ "weights": "ema"
23
+ },
24
+ "samples_per_prompt": 4,
25
+ "sampling": {
26
+ "temperature": 0.8,
27
+ "top_p": 0.95,
28
+ "top_k": 0,
29
+ "think_tokens": 8192,
30
+ "direct_tokens": 3072,
31
+ "loop_ngram": 16,
32
+ "loop_window": 1536,
33
+ "loop_threshold": 0.5,
34
+ "seed": 20261018
35
+ },
36
+ "reward_model": "Skywork/Skywork-Reward-V2-Llama-3.1-8B",
37
+ "pairing": {
38
+ "margin": 3.0,
39
+ "max_length_ratio": 2.0,
40
+ "max_group_ratio": 1.25,
41
+ "seed": 20261018,
42
+ "tool_abstention": {
43
+ "train": {
44
+ "tool_call_pairs": 6794,
45
+ "tool_abstain_pairs": 6794,
46
+ "abstentions_added": 4328
47
+ },
48
+ "validation": {
49
+ "tool_call_pairs": 277,
50
+ "tool_abstain_pairs": 277,
51
+ "abstentions_added": 182
52
+ }
53
+ },
54
+ "loop_stops": true
55
+ },
56
+ "sources": [
57
+ "glaive-fc/direct-answer",
58
+ "glaive-fc/direct-answer+tools",
59
+ "glaive-fc/direct-call",
60
+ "hermes-3/direct-answer",
61
+ "hermes-tool-use/reason-answer+tools",
62
+ "hermes-tool-use/reason-call",
63
+ "magicoder/direct-answer",
64
+ "no-robots/direct-answer",
65
+ "oasst2/direct-answer",
66
+ "opencode/reason-answer",
67
+ "openmath/reason-answer",
68
+ "self-oss-instruct/direct-answer",
69
+ "slimorca/direct-answer",
70
+ "smoltalk/direct-answer",
71
+ "smoltalk/direct-answer+tools",
72
+ "smoltalk/direct-call",
73
+ "smoltalk2-mid/reason-answer",
74
+ "smoltalk2-sft/direct-answer",
75
+ "smoltalk2-sft/direct-answer+tools",
76
+ "smoltalk2-sft/direct-call",
77
+ "smoltalk2-sft/reason-answer",
78
+ "smoltalk2-sft/reason-call",
79
+ "stage3-v2/direct-answer",
80
+ "stage3-v2/direct-answer+tools",
81
+ "stage3-v2/direct-call",
82
+ "stage3-v2/reason-answer",
83
+ "stage3-v2/reason-answer+tools",
84
+ "stage3-v2/reason-call",
85
+ "systemchat-2/direct-answer",
86
+ "tulu3-if/direct-answer",
87
+ "ultrachat-200k/direct-answer",
88
+ "when2call/direct-answer",
89
+ "when2call/direct-answer+tools",
90
+ "wildchat/direct-answer",
91
+ "wildchat/reason-answer"
92
+ ],
93
+ "chat_template": {
94
+ "prompt": "BOS through the assistant header and its zero-loss <|think|> or <|no_think|> line, as SFT packed it",
95
+ "response": "reply, <|im_end|>, newline, EOS",
96
+ "truncated_rejected": "replies cut at their token budget, in a loop, or ending at EOS keep no turn end"
97
+ },
98
+ "composition": {
99
+ "train": {
100
+ "sources": {
101
+ "glaive-fc/direct-answer": 79,
102
+ "glaive-fc/direct-answer+tools": 306,
103
+ "glaive-fc/direct-call": 154,
104
+ "hermes-3/direct-answer": 358,
105
+ "hermes-tool-use/reason-answer+tools": 512,
106
+ "hermes-tool-use/reason-call": 264,
107
+ "magicoder/direct-answer": 168,
108
+ "no-robots/direct-answer": 70,
109
+ "oasst2/direct-answer": 147,
110
+ "opencode/reason-answer": 821,
111
+ "openmath/reason-answer": 1025,
112
+ "self-oss-instruct/direct-answer": 85,
113
+ "slimorca/direct-answer": 185,
114
+ "smoltalk/direct-answer": 303,
115
+ "smoltalk/direct-answer+tools": 680,
116
+ "smoltalk/direct-call": 447,
117
+ "smoltalk2-mid/reason-answer": 1207,
118
+ "smoltalk2-sft/direct-answer": 343,
119
+ "smoltalk2-sft/direct-answer+tools": 263,
120
+ "smoltalk2-sft/direct-call": 426,
121
+ "smoltalk2-sft/reason-answer": 340,
122
+ "smoltalk2-sft/reason-call": 403,
123
+ "stage3-v2/direct-answer": 711,
124
+ "stage3-v2/direct-answer+tools": 1899,
125
+ "stage3-v2/direct-call": 2226,
126
+ "stage3-v2/reason-answer": 777,
127
+ "stage3-v2/reason-answer+tools": 3098,
128
+ "stage3-v2/reason-call": 3579,
129
+ "systemchat-2/direct-answer": 127,
130
+ "tulu3-if/direct-answer": 116,
131
+ "ultrachat-200k/direct-answer": 165,
132
+ "when2call/direct-answer": 57,
133
+ "when2call/direct-answer+tools": 310,
134
+ "wildchat/direct-answer": 661,
135
+ "wildchat/reason-answer": 205
136
+ },
137
+ "basis": {
138
+ "reward": 4971,
139
+ "rules": 6282,
140
+ "reference": 7490,
141
+ "partial": 77,
142
+ "loop_stop": 3697
143
+ },
144
+ "chosen": {
145
+ "ok": 8403,
146
+ "reference:correct_call": 4145,
147
+ "loop_stop": 3697,
148
+ "correct_call": 2572,
149
+ "reference:ok": 2180,
150
+ "reference:correct_answer": 1165,
151
+ "correct_answer": 278,
152
+ "wrong_function": 48,
153
+ "missed_call": 15,
154
+ "wrong_arguments": 14
155
+ },
156
+ "rejected": {
157
+ "loop": 5930,
158
+ "ok": 4971,
159
+ "unneeded_call": 2492,
160
+ "wrong_arguments": 2099,
161
+ "wrong_function": 2079,
162
+ "unfinished": 1640,
163
+ "missed_call": 1209,
164
+ "unknown_function": 964,
165
+ "malformed_call": 504,
166
+ "missing_arguments": 255,
167
+ "wrong_answer": 154,
168
+ "repetitive": 92,
169
+ "no_final_answer": 48,
170
+ "unclosed_think": 44,
171
+ "call_without_tools": 19,
172
+ "think_in_direct": 10,
173
+ "malformed_think": 7
174
+ },
175
+ "balance": {
176
+ "available": {
177
+ "reason-call": 3715,
178
+ "reason-answer": 4522,
179
+ "direct-call": 3079,
180
+ "direct-answer": 4066
181
+ },
182
+ "cap": 3849
183
+ },
184
+ "loop_stops": 3697
185
+ },
186
+ "validation": {
187
+ "sources": {
188
+ "glaive-fc/direct-answer": 3,
189
+ "glaive-fc/direct-answer+tools": 8,
190
+ "glaive-fc/direct-call": 5,
191
+ "hermes-3/direct-answer": 11,
192
+ "hermes-tool-use/reason-answer+tools": 28,
193
+ "hermes-tool-use/reason-call": 10,
194
+ "magicoder/direct-answer": 2,
195
+ "no-robots/direct-answer": 3,
196
+ "oasst2/direct-answer": 1,
197
+ "opencode/reason-answer": 39,
198
+ "openmath/reason-answer": 59,
199
+ "self-oss-instruct/direct-answer": 7,
200
+ "slimorca/direct-answer": 6,
201
+ "smoltalk/direct-answer": 12,
202
+ "smoltalk/direct-answer+tools": 23,
203
+ "smoltalk/direct-call": 25,
204
+ "smoltalk2-mid/reason-answer": 30,
205
+ "smoltalk2-sft/direct-answer": 14,
206
+ "smoltalk2-sft/direct-answer+tools": 18,
207
+ "smoltalk2-sft/direct-call": 19,
208
+ "smoltalk2-sft/reason-answer": 19,
209
+ "smoltalk2-sft/reason-call": 26,
210
+ "stage3-v2/direct-answer": 32,
211
+ "stage3-v2/direct-answer+tools": 78,
212
+ "stage3-v2/direct-call": 79,
213
+ "stage3-v2/reason-answer": 31,
214
+ "stage3-v2/reason-answer+tools": 121,
215
+ "stage3-v2/reason-call": 141,
216
+ "systemchat-2/direct-answer": 5,
217
+ "tulu3-if/direct-answer": 1,
218
+ "ultrachat-200k/direct-answer": 8,
219
+ "when2call/direct-answer": 1,
220
+ "when2call/direct-answer+tools": 9,
221
+ "wildchat/direct-answer": 23,
222
+ "wildchat/reason-answer": 6
223
+ },
224
+ "basis": {
225
+ "rules": 281,
226
+ "reward": 205,
227
+ "reference": 278,
228
+ "partial": 3,
229
+ "loop_stop": 136
230
+ },
231
+ "chosen": {
232
+ "ok": 363,
233
+ "reference:correct_call": 163,
234
+ "loop_stop": 136,
235
+ "correct_call": 111,
236
+ "reference:ok": 69,
237
+ "reference:correct_answer": 46,
238
+ "correct_answer": 12,
239
+ "wrong_function": 2,
240
+ "missed_call": 1
241
+ },
242
+ "rejected": {
243
+ "loop": 219,
244
+ "ok": 205,
245
+ "wrong_arguments": 100,
246
+ "unneeded_call": 84,
247
+ "wrong_function": 65,
248
+ "unfinished": 63,
249
+ "missed_call": 52,
250
+ "malformed_call": 42,
251
+ "unknown_function": 38,
252
+ "missing_arguments": 15,
253
+ "wrong_answer": 9,
254
+ "unclosed_think": 5,
255
+ "no_final_answer": 4,
256
+ "call_without_tools": 1,
257
+ "malformed_think": 1
258
+ },
259
+ "balance": {
260
+ "available": {
261
+ "reason-call": 189,
262
+ "reason-answer": 203,
263
+ "direct-call": 123,
264
+ "direct-answer": 179
265
+ },
266
+ "cap": 154
267
+ },
268
+ "loop_stops": 136
269
+ }
270
+ },
271
+ "splits": {
272
+ "train": {
273
+ "pairs": 22517,
274
+ "tokens": 86450384,
275
+ "prompt_tokens": 29781563,
276
+ "chosen_tokens": 23379623,
277
+ "rejected_tokens": 33289198
278
+ },
279
+ "validation": {
280
+ "pairs": 903,
281
+ "tokens": 3556933,
282
+ "prompt_tokens": 1193370,
283
+ "chosen_tokens": 972868,
284
+ "rejected_tokens": 1390695
285
+ }
286
+ }
287
+ }
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ torch>=2.4.0
2
+ safetensors>=0.4.0
3
+ tokenizers>=0.20.0
4
+ huggingface_hub>=0.26.0
5
+ transformers>=4.45.0
6
+ einops>=0.8.0
7
+ numpy>=1.26.0
8
+ sympy>=1.13.0
9
+
10
+ # flash-linear-attention is auto-installed by inference.py on first run.
sft_config.json ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "adam_beta1": 0.9,
3
+ "adam_beta2": 0.95,
4
+ "adam_epsilon": 1e-08,
5
+ "cpu_prefetch_factor": 2,
6
+ "cpu_workers": 2,
7
+ "dataset_manifest": "/home/nvidia/llm-data/haiku/tokenized/sft-all-v3/manifest.json",
8
+ "ema_inv_gamma": 1.0,
9
+ "ema_max_decay": 0.9999,
10
+ "ema_power": 0.75,
11
+ "enable_gradient_checkpointing": true,
12
+ "exclude_components": [
13
+ "nemotron-cascade-2"
14
+ ],
15
+ "fp32_master_weights": true,
16
+ "full_shuffle": true,
17
+ "gradient_accumulation_steps": 16,
18
+ "gradient_checkpointing_layers": 22,
19
+ "gradient_clip_norm": 1.0,
20
+ "initial_run_dir": "/media/nvidia/4TB2/llm-runs/senryu/pretrain-v1",
21
+ "initial_weights": "ema",
22
+ "learning_rate": 0.0004,
23
+ "log_every_optimizer_steps": 1,
24
+ "logit_z_loss_coefficient": 0.0001,
25
+ "maximum_checkpoints": 5,
26
+ "maximum_optimizer_steps": null,
27
+ "micro_batch_size": 1,
28
+ "minimum_learning_rate_ratio": 0.1,
29
+ "muon_learning_rate": null,
30
+ "muon_lr_adjustment": "match_rms_adamw",
31
+ "muon_momentum": 0.95,
32
+ "optimizer": "hybrid_muon",
33
+ "output_dir": "/media/nvidia/4TB2/llm-runs/senryu/sft-v1",
34
+ "require_complete_pretraining": false,
35
+ "require_complete_sft_source": false,
36
+ "rewrite_empty_think": true,
37
+ "save_every_optimizer_steps": 100,
38
+ "seed": 20261008,
39
+ "sequence_length": 32768,
40
+ "shuffle_block_sequences": 2048,
41
+ "stream_download_threads": 8,
42
+ "stream_english_share": null,
43
+ "stream_open_languages": null,
44
+ "stream_pairs_per_row": 1,
45
+ "tokenizer": null,
46
+ "unclosed_think": "mask_end",
47
+ "validation_batches": 16,
48
+ "warmup_ratio": 0.0,
49
+ "warmup_steps": 200,
50
+ "weight_decay": 0.1
51
+ }
sft_validation.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "checkpoint": "checkpoint-00012800",
3
+ "ema": {
4
+ "assistant_targets": 393188,
5
+ "batches": 16,
6
+ "elapsed_seconds": 6.345391824841499,
7
+ "loss": 0.8266990829440273,
8
+ "perplexity": 2.2857611656454533,
9
+ "targets_per_second": 61964.33740477821
10
+ },
11
+ "excluded_components": [
12
+ "nemotron-cascade-2"
13
+ ],
14
+ "normal": {
15
+ "assistant_targets": 393188,
16
+ "batches": 16,
17
+ "elapsed_seconds": 6.353875299915671,
18
+ "loss": 0.8744940579562701,
19
+ "perplexity": 2.3976619090753144,
20
+ "targets_per_second": 61881.60475941641
21
+ },
22
+ "optimizer_step": 12800,
23
+ "type": "sft_validation"
24
+ }
special_token_ids.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "bos_token": "<|begin_of_text|>",
3
+ "eos_token": "<|end_of_text|>",
4
+ "pad_token": "<|padding|>",
5
+ "unk_token": "<|unknown|>",
6
+ "bos_token_id": 0,
7
+ "eos_token_id": 1,
8
+ "pad_token_id": 2,
9
+ "unk_token_id": 3,
10
+ "im_start": "<|im_start|>",
11
+ "im_start_id": 4,
12
+ "im_end": "<|im_end|>",
13
+ "im_end_id": 5,
14
+ "think_control": "<|think|>",
15
+ "think_control_id": 15,
16
+ "no_think_control": "<|no_think|>",
17
+ "no_think_control_id": 14,
18
+ "tool_response": "<|tool_response|>",
19
+ "tool_response_id": 7,
20
+ "think_open": "<think>",
21
+ "think_open_id": 8,
22
+ "think_close": "</think>",
23
+ "think_close_id": 9
24
+ }
special_tokens_map.json ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "bos_token": "<|begin_of_text|>",
3
+ "eos_token": "<|end_of_text|>",
4
+ "pad_token": "<|padding|>",
5
+ "unk_token": "<|unknown|>",
6
+ "additional_special_tokens": [
7
+ "<|im_start|>",
8
+ "<|im_end|>",
9
+ "<|tool_call|>",
10
+ "<|tool_response|>",
11
+ "<think>",
12
+ "</think>",
13
+ "<|fim_prefix|>",
14
+ "<|fim_middle|>",
15
+ "<|fim_suffix|>",
16
+ "<|fim_pad|>",
17
+ "<|no_think|>",
18
+ "<|think|>",
19
+ "<|reserved_002|>",
20
+ "<|reserved_003|>",
21
+ "<|reserved_004|>",
22
+ "<|reserved_005|>",
23
+ "<|reserved_006|>",
24
+ "<|reserved_007|>",
25
+ "<|reserved_008|>",
26
+ "<|reserved_009|>",
27
+ "<|reserved_010|>",
28
+ "<|reserved_011|>",
29
+ "<|reserved_012|>",
30
+ "<|reserved_013|>",
31
+ "<|reserved_014|>",
32
+ "<|reserved_015|>",
33
+ "<|reserved_016|>",
34
+ "<|reserved_017|>",
35
+ "<|reserved_018|>",
36
+ "<|reserved_019|>",
37
+ "<|reserved_020|>",
38
+ "<|reserved_021|>",
39
+ "<|reserved_022|>",
40
+ "<|reserved_023|>",
41
+ "<|reserved_024|>",
42
+ "<|reserved_025|>",
43
+ "<|reserved_026|>",
44
+ "<|reserved_027|>",
45
+ "<|reserved_028|>",
46
+ "<|reserved_029|>",
47
+ "<|reserved_030|>",
48
+ "<|reserved_031|>",
49
+ "<|reserved_032|>",
50
+ "<|reserved_033|>",
51
+ "<|reserved_034|>",
52
+ "<|reserved_035|>",
53
+ "<|reserved_036|>",
54
+ "<|reserved_037|>",
55
+ "<|reserved_038|>",
56
+ "<|reserved_039|>",
57
+ "<|reserved_040|>",
58
+ "<|reserved_041|>",
59
+ "<|reserved_042|>",
60
+ "<|reserved_043|>",
61
+ "<|reserved_044|>",
62
+ "<|reserved_045|>",
63
+ "<|reserved_046|>",
64
+ "<|reserved_047|>",
65
+ "<|reserved_048|>",
66
+ "<|reserved_049|>"
67
+ ]
68
+ }
tiny_gdn/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from tiny_gdn.config import TinyGDNConfig, haiku_config, haiku_kdn_config, senryu_config
2
+ from tiny_gdn.model import TinyGDNForCausalLM, TinyGDNOutput
3
+
4
+ __all__ = [
5
+ "TinyGDNConfig",
6
+ "TinyGDNForCausalLM",
7
+ "TinyGDNOutput",
8
+ "haiku_config",
9
+ "haiku_kdn_config",
10
+ "senryu_config",
11
+ ]
tiny_gdn/chatml.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from tiny_gdn.smoltalk_chat import (
6
+ NO_THINK_PREFIX,
7
+ THINK_CONTROL_PREFIX,
8
+ ToolFormatChoice,
9
+ kwargs_from_inference_request,
10
+ materialize_smoltalk_messages,
11
+ merge_kwargs_with_system_message,
12
+ resolve_reasoning_mode,
13
+ )
14
+ from tiny_gdn.tools import prepare_inference_messages
15
+
16
+ THINK_START = "<think>"
17
+ THINK_END = "</think>"
18
+
19
+
20
+ def decode_chat_completion(tokenizer: Any, token_ids: list[int]) -> str:
21
+ return tokenizer.decode(token_ids, skip_special_tokens=False)
22
+
23
+
24
+ def append_assistant_generation_prompt(
25
+ tokenizer: Any,
26
+ token_ids: list[int],
27
+ *,
28
+ enable_thinking: bool | None = None,
29
+ ) -> None:
30
+ im_start = tokenizer.token_to_id("<|im_start|>")
31
+ if im_start is None:
32
+ raise RuntimeError("Tokenizer is missing <|im_start|>")
33
+ token_ids.append(im_start)
34
+ token_ids.extend(
35
+ tokenizer.encode("assistant\n", add_special_tokens=False).ids
36
+ )
37
+ if enable_thinking is True:
38
+ token_ids.extend(
39
+ tokenizer.encode(THINK_CONTROL_PREFIX, add_special_tokens=False).ids
40
+ )
41
+ elif enable_thinking is False:
42
+ token_ids.extend(
43
+ tokenizer.encode(NO_THINK_PREFIX, add_special_tokens=False).ids
44
+ )
45
+
46
+
47
+ def encode_chatml_messages(
48
+ tokenizer: Any,
49
+ messages: list[dict[str, Any]],
50
+ *,
51
+ tools: Any | None = None,
52
+ enable_thinking: bool | None = None,
53
+ xml_tools: Any | None = None,
54
+ python_tools: Any | None = None,
55
+ tool_format: ToolFormatChoice = "json",
56
+ ) -> list[int]:
57
+ """``tool_format`` picks the SmolTalk tool layout; models without the thinking toggle only know XML."""
58
+
59
+ if enable_thinking is None:
60
+ prepared = prepare_inference_messages(messages, tools=tools)
61
+ assistant_thinking_control = None
62
+ else:
63
+ kwargs = kwargs_from_inference_request(
64
+ enable_thinking=enable_thinking,
65
+ xml_tools=xml_tools,
66
+ python_tools=python_tools,
67
+ tools=tools,
68
+ tool_format=tool_format,
69
+ )
70
+ prepared = materialize_smoltalk_messages(messages, kwargs)
71
+ _, resolved = merge_kwargs_with_system_message(messages, kwargs)
72
+ assistant_thinking_control = (
73
+ resolve_reasoning_mode(
74
+ resolved.enable_thinking,
75
+ resolved.custom_instructions,
76
+ )
77
+ == "/think"
78
+ )
79
+ required_tokens = {
80
+ token: tokenizer.token_to_id(token)
81
+ for token in (
82
+ "<|begin_of_text|>",
83
+ "<|im_start|>",
84
+ "<|im_end|>",
85
+ )
86
+ }
87
+ if any(token_id is None for token_id in required_tokens.values()):
88
+ raise RuntimeError("Tokenizer is missing ChatML special tokens")
89
+ bos_id = required_tokens["<|begin_of_text|>"]
90
+ im_start_id = required_tokens["<|im_start|>"]
91
+ im_end_id = required_tokens["<|im_end|>"]
92
+ if bos_id is None or im_start_id is None or im_end_id is None:
93
+ raise RuntimeError("Tokenizer ChatML IDs could not be resolved")
94
+
95
+ token_ids = [bos_id]
96
+ newline_ids = tokenizer.encode("\n", add_special_tokens=False).ids
97
+ allowed_roles = {"system", "user", "assistant"}
98
+ for message_index, message in enumerate(prepared):
99
+ role = message["role"]
100
+ content = message["content"]
101
+ masked_prefix = message.get("masked_prefix", "")
102
+ if role not in allowed_roles:
103
+ raise ValueError(
104
+ f"Unsupported chat role at index {message_index}: {role!r}"
105
+ )
106
+ if not isinstance(masked_prefix, str):
107
+ raise ValueError(
108
+ f"Masked prefix at index {message_index} must be a string"
109
+ )
110
+ if not content.strip():
111
+ raise ValueError(
112
+ f"Chat content at index {message_index} must be non-empty"
113
+ )
114
+ token_ids.append(im_start_id)
115
+ token_ids.extend(
116
+ tokenizer.encode(f"{role}\n", add_special_tokens=False).ids
117
+ )
118
+ if masked_prefix:
119
+ token_ids.extend(
120
+ tokenizer.encode(masked_prefix, add_special_tokens=False).ids
121
+ )
122
+ token_ids.extend(
123
+ tokenizer.encode(content, add_special_tokens=False).ids
124
+ )
125
+ token_ids.append(im_end_id)
126
+ token_ids.extend(newline_ids)
127
+ if prepared[-1]["role"] != "user":
128
+ raise ValueError("The final chat message must have role 'user'")
129
+ append_assistant_generation_prompt(
130
+ tokenizer,
131
+ token_ids,
132
+ enable_thinking=assistant_thinking_control,
133
+ )
134
+ return token_ids
tiny_gdn/cli_chat.py ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Terminal chat: live reasoning stream plus SmolTalk tool-call rounds.
2
+
3
+ Tercet-R assistant turns may contain:
4
+
5
+ - a zero-loss `<|think|>` / `<|no_think|>` control prefix
6
+ - a `<think>…</think>` reasoning block
7
+ - one or more SmolTalk JSON `<tool_call>` blocks (NVIDIA XML is also parsed)
8
+
9
+ This module colours those regions as tokens arrive and, after a completed
10
+ turn, collects tool observations (prefixed with `<|tool_response|>`) so the
11
+ model can continue the same conversation.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import sys
18
+ from collections.abc import Callable, Sequence
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Literal, TextIO
21
+
22
+ from tiny_gdn.smoltalk_chat import (
23
+ TOOL_RESPONSE_TOKEN,
24
+ format_smoltalk_tool_call,
25
+ wrap_smoltalk_tool_result,
26
+ )
27
+ from tiny_gdn.code_exec import (
28
+ CALCULATOR_TOOL,
29
+ NEMOTRON_PYTHON_EXEC_TOOL,
30
+ execute_math_tool,
31
+ is_auto_math_tool_name,
32
+ reset_default_python_session,
33
+ )
34
+ from tiny_gdn.tools import ParsedToolCall, parse_tool_calls
35
+ from tiny_gdn.web_search import (
36
+ NEMOTRON_WEB_SEARCH_TOOL,
37
+ is_web_search_tool_name,
38
+ query_from_arguments,
39
+ search_web,
40
+ )
41
+
42
+
43
+ SegmentKind = Literal["answer", "think", "tool_call"]
44
+ MarkerKind = Literal[
45
+ "think_open",
46
+ "think_close",
47
+ "think_control",
48
+ "no_think",
49
+ "tool_open",
50
+ "tool_close",
51
+ ]
52
+
53
+ THINK_OPEN = "<think>"
54
+ THINK_CLOSE = "</think>"
55
+ TOOL_CALL_OPEN = "<tool_call>"
56
+ TOOL_CALL_CLOSE = "</tool_call>"
57
+ THINK_CONTROL = "<|think|>"
58
+ NO_THINK_CONTROL = "<|no_think|>"
59
+
60
+ MARKERS: tuple[tuple[str, MarkerKind], ...] = (
61
+ (THINK_CLOSE, "think_close"),
62
+ (TOOL_CALL_CLOSE, "tool_close"),
63
+ (THINK_OPEN, "think_open"),
64
+ (TOOL_CALL_OPEN, "tool_open"),
65
+ (NO_THINK_CONTROL, "no_think"),
66
+ (THINK_CONTROL, "think_control"),
67
+ )
68
+
69
+ ANSI = {
70
+ "think": "\033[2;33m",
71
+ "tool_call": "\033[36m",
72
+ "answer": "\033[0m",
73
+ "reset": "\033[0m",
74
+ }
75
+
76
+ DEFAULT_MAX_TOOL_ROUNDS = 8
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class ContentSegment:
81
+ kind: SegmentKind
82
+ text: str
83
+ open: bool = False
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class GeneratedTurn:
88
+ text: str
89
+ token_count: int
90
+ stop_reason: str
91
+ tool_calls: tuple[ParsedToolCall, ...]
92
+
93
+
94
+ def first_marker(text: str) -> tuple[int, str, MarkerKind] | None:
95
+ best: tuple[int, str, MarkerKind] | None = None
96
+ for marker, kind in MARKERS:
97
+ at = text.find(marker)
98
+ if at < 0:
99
+ continue
100
+ if (
101
+ best is None
102
+ or at < best[0]
103
+ or (at == best[0] and len(marker) > len(best[1]))
104
+ ):
105
+ best = (at, marker, kind)
106
+ return best
107
+
108
+
109
+ def holdback_prefix_length(text: str) -> int:
110
+ if not text:
111
+ return 0
112
+ keep = 0
113
+ for marker, _kind in MARKERS:
114
+ limit = min(len(marker) - 1, len(text))
115
+ for size in range(1, limit + 1):
116
+ if marker.startswith(text[-size:]):
117
+ keep = max(keep, size)
118
+ return keep
119
+
120
+
121
+ def mode_after_marker(kind: MarkerKind, current: SegmentKind) -> SegmentKind:
122
+ if kind in {"think_open", "think_control"}:
123
+ return "think"
124
+ if kind in {"think_close", "no_think", "tool_close"}:
125
+ return "answer"
126
+ if kind == "tool_open":
127
+ return "tool_call"
128
+ return current
129
+
130
+
131
+ def _segment_text(segment: ContentSegment) -> str:
132
+ return segment.text.strip()
133
+
134
+
135
+ def format_assistant_markdown(source: str) -> str:
136
+ """Render a streamed assistant turn as a single markdown block."""
137
+
138
+ think_parts: list[str] = []
139
+ answer_parts: list[str] = []
140
+ tool_parts: list[str] = []
141
+ for segment in split_assistant_segments(source):
142
+ text = _segment_text(segment)
143
+ if not text:
144
+ continue
145
+ if segment.kind == "think":
146
+ think_parts.append(text)
147
+ elif segment.kind == "tool_call":
148
+ tool_parts.append(text)
149
+ else:
150
+ answer_parts.append(text)
151
+ blocks: list[str] = []
152
+ if think_parts:
153
+ blocks.append("**Reasoning**\n\n" + "\n\n".join(think_parts))
154
+ if answer_parts:
155
+ blocks.append("\n\n".join(answer_parts))
156
+ for tool in tool_parts:
157
+ blocks.append(f"```tool_call\n{tool}\n```")
158
+ return "\n\n".join(blocks).strip()
159
+
160
+
161
+ def format_assistant_chat_messages(source: str) -> list[dict[str, Any]]:
162
+ """Split a turn into Gradio thoughts plus a normal assistant chat message.
163
+
164
+ Messages with ``metadata.title`` render as collapsible thoughts. The reply
165
+ has no metadata so Gradio shows it as the chat bubble.
166
+ """
167
+
168
+ think_parts: list[str] = []
169
+ answer_parts: list[str] = []
170
+ tool_parts: list[str] = []
171
+ think_pending = False
172
+ for segment in split_assistant_segments(source):
173
+ text = _segment_text(segment)
174
+ if segment.kind == "think":
175
+ think_pending = segment.open
176
+ if text:
177
+ think_parts.append(text)
178
+ continue
179
+ think_pending = False
180
+ if not text:
181
+ continue
182
+ if segment.kind == "tool_call":
183
+ tool_parts.append(text)
184
+ else:
185
+ answer_parts.append(text)
186
+
187
+ messages: list[dict[str, Any]] = []
188
+ if think_parts:
189
+ messages.append(
190
+ {
191
+ "role": "assistant",
192
+ "content": "\n\n".join(think_parts),
193
+ "metadata": {
194
+ "title": "Reasoning",
195
+ "status": "pending" if think_pending else "done",
196
+ },
197
+ }
198
+ )
199
+ for tool in tool_parts:
200
+ messages.append(
201
+ {
202
+ "role": "assistant",
203
+ "content": f"```json\n{tool}\n```",
204
+ "metadata": {"title": "Tool call", "status": "done"},
205
+ }
206
+ )
207
+ answer = "\n\n".join(answer_parts)
208
+ if answer or not messages:
209
+ messages.append({"role": "assistant", "content": answer})
210
+ return messages
211
+
212
+
213
+ def split_assistant_segments(source: str) -> list[ContentSegment]:
214
+ """Split a completed (or in-progress) assistant turn for tests / replay."""
215
+
216
+ segments: list[ContentSegment] = []
217
+ mode: SegmentKind = "answer"
218
+ cursor = 0
219
+ while cursor < len(source):
220
+ found = first_marker(source[cursor:])
221
+ if found is None:
222
+ tail = source[cursor:]
223
+ if tail:
224
+ segments.append(ContentSegment(kind=mode, text=tail, open=True))
225
+ break
226
+ at, marker, kind = found
227
+ at += cursor
228
+ if at > cursor:
229
+ segments.append(
230
+ ContentSegment(kind=mode, text=source[cursor:at], open=False)
231
+ )
232
+ mode = mode_after_marker(kind, mode)
233
+ cursor = at + len(marker)
234
+ return [segment for segment in segments if segment.text]
235
+
236
+
237
+ class LiveReasoningStreamer:
238
+ """Colour reasoning and tool-call regions as decoded text grows."""
239
+
240
+ def __init__(
241
+ self,
242
+ writer: TextIO | None = None,
243
+ *,
244
+ color: bool | None = None,
245
+ ) -> None:
246
+ self.writer = writer if writer is not None else sys.stdout
247
+ if color is None:
248
+ color = bool(getattr(self.writer, "isatty", lambda: False)())
249
+ self.color = color
250
+ self._seen = ""
251
+ self._hold = ""
252
+ self._mode: SegmentKind = "answer"
253
+ self._style: SegmentKind | None = None
254
+ self._emitted_think_label = False
255
+ self._emitted_tool_label = False
256
+
257
+ def update(self, decoded: str) -> None:
258
+ if decoded.startswith(self._seen):
259
+ delta = decoded[len(self._seen) :]
260
+ else:
261
+ delta = decoded
262
+ self._seen = decoded
263
+ if delta:
264
+ self._consume(delta, final=False)
265
+
266
+ def finish(self) -> str:
267
+ if self._hold:
268
+ self._emit(self._hold)
269
+ self._hold = ""
270
+ self._set_style(None)
271
+ self.writer.write("\n")
272
+ self.writer.flush()
273
+ return self._seen
274
+
275
+ def _consume(self, delta: str, *, final: bool) -> None:
276
+ buffer = self._hold + delta
277
+ self._hold = ""
278
+ while buffer:
279
+ found = first_marker(buffer)
280
+ if found is None:
281
+ keep = 0 if final else holdback_prefix_length(buffer)
282
+ if keep:
283
+ self._emit(buffer[:-keep])
284
+ self._hold = buffer[-keep:]
285
+ else:
286
+ self._emit(buffer)
287
+ return
288
+ at, marker, kind = found
289
+ if at:
290
+ self._emit(buffer[:at])
291
+ self._switch(kind)
292
+ buffer = buffer[at + len(marker) :]
293
+ if final:
294
+ return
295
+
296
+ def _switch(self, kind: MarkerKind) -> None:
297
+ nxt = mode_after_marker(kind, self._mode)
298
+ if nxt != self._mode and nxt == "answer":
299
+ self._emit_plain("\n")
300
+ self._mode = nxt
301
+ if nxt == "think" and not self._emitted_think_label:
302
+ self._emit_plain("\n")
303
+ self._set_style("think")
304
+ self._emit_plain("reasoning ")
305
+ self._emitted_think_label = True
306
+ elif nxt == "tool_call" and not self._emitted_tool_label:
307
+ self._emit_plain("\n")
308
+ self._set_style("tool_call")
309
+ self._emit_plain("tool_call ")
310
+ self._emitted_tool_label = True
311
+ elif nxt == "answer":
312
+ self._set_style("answer")
313
+
314
+ def _emit(self, text: str) -> None:
315
+ if not text:
316
+ return
317
+ self._set_style(self._mode)
318
+ self.writer.write(text)
319
+ self.writer.flush()
320
+
321
+ def _emit_plain(self, text: str) -> None:
322
+ if not text:
323
+ return
324
+ self._set_style(None)
325
+ self.writer.write(text)
326
+ self.writer.flush()
327
+
328
+ def _set_style(self, kind: SegmentKind | None) -> None:
329
+ if not self.color:
330
+ self._style = kind
331
+ return
332
+ if kind == self._style:
333
+ return
334
+ self.writer.write(ANSI["reset"])
335
+ if kind in {"think", "tool_call"}:
336
+ self.writer.write(ANSI[kind])
337
+ self._style = kind
338
+
339
+
340
+ def prompt_tool_results(
341
+ calls: Sequence[ParsedToolCall],
342
+ *,
343
+ read_line: Callable[[str], str],
344
+ writer: TextIO | None = None,
345
+ execute_web_search: bool = True,
346
+ ) -> list[str]:
347
+ out = writer if writer is not None else sys.stdout
348
+ results: list[str] = []
349
+ auto_search = sum(
350
+ 1
351
+ for call in calls
352
+ if execute_web_search and is_web_search_tool_name(call.name)
353
+ )
354
+ auto_math = sum(1 for call in calls if is_auto_math_tool_name(call.name))
355
+ if auto_search:
356
+ out.write(
357
+ f"\n{auto_search} web-search call(s) will run automatically "
358
+ f"(Tavily JSON, sent as {TOOL_RESPONSE_TOKEN}).\n"
359
+ )
360
+ out.flush()
361
+ if auto_math:
362
+ out.write(
363
+ f"\n{auto_math} python/calculator call(s) will run automatically "
364
+ f"(sent as {TOOL_RESPONSE_TOKEN}).\n"
365
+ )
366
+ out.flush()
367
+ manual = len(calls) - auto_search - auto_math
368
+ if manual:
369
+ out.write(
370
+ f"\n{manual} tool call(s). Paste each observation; "
371
+ f"it is sent as {TOOL_RESPONSE_TOKEN}.\n"
372
+ )
373
+ out.flush()
374
+ for index, call in enumerate(calls, start=1):
375
+ out.write(
376
+ f"\n[{index}/{len(calls)}] "
377
+ f"{format_smoltalk_tool_call(call.name, call.arguments)}\n"
378
+ )
379
+ out.flush()
380
+ if execute_web_search and is_web_search_tool_name(call.name):
381
+ query = query_from_arguments(call.arguments)
382
+ out.write(f"searching {query!r}…\n")
383
+ out.flush()
384
+ results.append(search_web(query))
385
+ continue
386
+ if is_auto_math_tool_name(call.name):
387
+ out.write("running python…\n")
388
+ out.flush()
389
+ try:
390
+ results.append(execute_math_tool(call.name, call.arguments))
391
+ except Exception as error:
392
+ results.append(f"Error: {error}")
393
+ continue
394
+ results.append(read_line(f"result[{call.name}]> "))
395
+ return results
396
+
397
+
398
+ def append_tool_round(
399
+ messages: list[dict[str, Any]],
400
+ assistant_text: str,
401
+ raw_results: Sequence[str],
402
+ ) -> None:
403
+ messages.append({"role": "assistant", "content": assistant_text})
404
+ for raw in raw_results:
405
+ wrapped = wrap_smoltalk_tool_result(raw)
406
+ if not wrapped:
407
+ raise ValueError("Tool result cannot be empty")
408
+ messages.append({"role": "tool", "content": raw})
409
+
410
+
411
+ def resolve_cli_tools(spec: str | None, tools_json: str | None) -> list[dict[str, Any]] | None:
412
+ tools: list[dict[str, Any]] = []
413
+ if spec:
414
+ for name in spec.split(","):
415
+ key = name.strip().lower()
416
+ if not key or key in {"none", "off"}:
417
+ continue
418
+ if is_web_search_tool_name(key):
419
+ tools.append(NEMOTRON_WEB_SEARCH_TOOL)
420
+ continue
421
+ if key in {"python", "python-exec", "code-interpreter"}:
422
+ tools.append(NEMOTRON_PYTHON_EXEC_TOOL)
423
+ continue
424
+ if key in {"calculator", "calc"}:
425
+ tools.append(CALCULATOR_TOOL)
426
+ continue
427
+ raise ValueError(
428
+ f"Unknown built-in tool {name!r}. "
429
+ "Use web-search, python, calculator, or --tools-json."
430
+ )
431
+ if tools_json:
432
+ payload = json.loads(tools_json)
433
+ if isinstance(payload, dict):
434
+ tools.append(payload)
435
+ elif isinstance(payload, list):
436
+ tools.extend(payload)
437
+ else:
438
+ raise ValueError("tools JSON must be an object or array")
439
+ return tools or None
440
+
441
+
442
+ @dataclass
443
+ class ChatLoopState:
444
+ messages: list[dict[str, Any]] = field(default_factory=list)
445
+ system: str = ""
446
+ enable_thinking: bool = True
447
+ tools: list[dict[str, Any]] | None = None
448
+
449
+ def reset(self) -> None:
450
+ self.messages = []
451
+ reset_default_python_session()
452
+ if self.system.strip():
453
+ self.messages.append({"role": "system", "content": self.system.strip()})
454
+
455
+ def add_user(self, text: str) -> None:
456
+ self.messages.append({"role": "user", "content": text})
457
+
458
+
459
+ def apply_slash_command(state: ChatLoopState, text: str) -> str | None:
460
+ """Return a status string if `text` is a slash command, else None."""
461
+
462
+ command = text.strip()
463
+ lowered = command.lower()
464
+ if lowered in {"/exit", "/quit"}:
465
+ return "exit"
466
+ if lowered == "/reset":
467
+ state.reset()
468
+ return "history cleared"
469
+ if lowered == "/think":
470
+ if state.messages:
471
+ return "thinking can only be changed on a fresh conversation (/reset first)"
472
+ state.enable_thinking = True
473
+ return "thinking on — next assistant turn is prefixed with <|think|>"
474
+ if lowered in {"/no_think", "/nothink"}:
475
+ if state.messages:
476
+ return "thinking can only be changed on a fresh conversation (/reset first)"
477
+ state.enable_thinking = False
478
+ return "thinking off — next assistant turn is prefixed with <|no_think|>"
479
+ if lowered.startswith("/system"):
480
+ rest = command[len("/system") :].strip()
481
+ state.system = rest
482
+ state.reset()
483
+ return "system prompt updated" if rest else "system prompt cleared"
484
+ return None
tiny_gdn/code_exec.py ADDED
@@ -0,0 +1,498 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Restricted stateful Python for Nemotron Cascade-2 math tool calls.
2
+
3
+ Tercet-R saw `stateful_python_code_exec` on `math/math_tool.jsonl` during
4
+ the Cascade-2 SFT stage. Observations are the Jupyter-style stdout / last
5
+ expression value, not a JSON envelope.
6
+
7
+ The worker process only allows a math-oriented import whitelist and rejects
8
+ dunder access, so a tool call cannot read the filesystem or start a shell.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import ast
14
+ import io
15
+ import json
16
+ import math
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ import threading
21
+ from collections.abc import Mapping
22
+ from pathlib import Path
23
+ from queue import Empty, Queue
24
+ from typing import Any
25
+
26
+
27
+ NEMOTRON_PYTHON_EXEC_TOOL: dict[str, Any] = {
28
+ "type": "function",
29
+ "function": {
30
+ "name": "stateful_python_code_exec",
31
+ "description": (
32
+ "Call this function to execute Python code in a stateful Jupyter "
33
+ "notebook environment. Python will respond with the output of the "
34
+ "execution or time out after 120.0 seconds."
35
+ ),
36
+ "parameters": {
37
+ "type": "object",
38
+ "properties": {
39
+ "code": {
40
+ "type": "string",
41
+ "description": "Code to execute",
42
+ }
43
+ },
44
+ "required": ["code"],
45
+ },
46
+ },
47
+ }
48
+
49
+ CALCULATOR_TOOL: dict[str, Any] = {
50
+ "type": "function",
51
+ "function": {
52
+ "name": "calculator",
53
+ "description": "Evaluate a math expression and return the numeric result.",
54
+ "parameters": {
55
+ "type": "object",
56
+ "properties": {
57
+ "expression": {
58
+ "type": "string",
59
+ "description": "Math expression, for example 12.5 * (3 + 4)",
60
+ }
61
+ },
62
+ "required": ["expression"],
63
+ },
64
+ },
65
+ }
66
+
67
+ PYTHON_EXEC_TOOL_NAMES = frozenset(
68
+ {
69
+ "stateful_python_code_exec",
70
+ "python_code_exec",
71
+ "code_interpreter",
72
+ "python_exec",
73
+ "python",
74
+ }
75
+ )
76
+ CALCULATOR_TOOL_NAMES = frozenset({"calculator", "calc"})
77
+ DEFAULT_EXEC_TIMEOUT_SECONDS = 30.0
78
+ _SRC_ROOT = Path(__file__).resolve().parents[1]
79
+
80
+ _session_lock = threading.Lock()
81
+ _default_session: StatefulPythonSession | None = None
82
+
83
+
84
+ def _normalize_tool_name(name: str) -> str:
85
+ return name.strip().lower().replace("-", "_")
86
+
87
+
88
+ def is_python_exec_tool_name(name: str) -> bool:
89
+ return _normalize_tool_name(name) in PYTHON_EXEC_TOOL_NAMES
90
+
91
+
92
+ def is_calculator_tool_name(name: str) -> bool:
93
+ return _normalize_tool_name(name) in CALCULATOR_TOOL_NAMES
94
+
95
+
96
+ def is_auto_math_tool_name(name: str) -> bool:
97
+ return is_python_exec_tool_name(name) or is_calculator_tool_name(name)
98
+
99
+
100
+ def code_from_arguments(arguments: Mapping[str, Any] | None) -> str:
101
+ if not arguments:
102
+ return ""
103
+ for key in ("code", "expression", "expr", "source"):
104
+ raw = arguments.get(key)
105
+ if isinstance(raw, str) and raw.strip():
106
+ return raw
107
+ if raw is not None and key != "code":
108
+ return str(raw)
109
+ return ""
110
+
111
+
112
+ class CodeExecError(ValueError):
113
+ """Rejected or failed tool code."""
114
+
115
+
116
+ class StatefulPythonSession:
117
+ """One long-lived restricted interpreter, matching the training tool."""
118
+
119
+ def __init__(self, *, timeout_seconds: float = DEFAULT_EXEC_TIMEOUT_SECONDS) -> None:
120
+ self.timeout_seconds = timeout_seconds
121
+ self._lock = threading.Lock()
122
+ self._process: subprocess.Popen[str] | None = None
123
+
124
+ def close(self) -> None:
125
+ with self._lock:
126
+ self._kill_locked()
127
+
128
+ def reset(self) -> None:
129
+ with self._lock:
130
+ self._kill_locked()
131
+
132
+ def run(self, code: str) -> str:
133
+ text = code.strip()
134
+ if not text:
135
+ raise CodeExecError("code is empty")
136
+ with self._lock:
137
+ return self._run_locked(text)
138
+
139
+ def _run_locked(self, code: str) -> str:
140
+ process = self._ensure_process_locked()
141
+ try:
142
+ process.stdin.write(json.dumps({"code": code}, ensure_ascii=False) + "\n")
143
+ process.stdin.flush()
144
+ line = self._readline_locked(process, self.timeout_seconds)
145
+ except (BrokenPipeError, OSError) as error:
146
+ self._kill_locked()
147
+ raise CodeExecError(f"python worker died: {error}") from error
148
+ if not line:
149
+ stderr = ""
150
+ if process.stderr is not None:
151
+ try:
152
+ stderr = process.stderr.read()
153
+ except OSError:
154
+ stderr = ""
155
+ self._kill_locked()
156
+ detail = stderr.strip() or "python worker closed stdout"
157
+ raise CodeExecError(detail)
158
+ try:
159
+ payload = json.loads(line)
160
+ except json.JSONDecodeError as error:
161
+ self._kill_locked()
162
+ raise CodeExecError(f"python worker returned invalid JSON: {line!r}") from error
163
+ if not isinstance(payload, dict):
164
+ raise CodeExecError("python worker returned a non-object")
165
+ output = str(payload.get("output") or "")
166
+ if payload.get("ok") is True:
167
+ return output if output else "None"
168
+ raise CodeExecError(output or "execution failed")
169
+
170
+ def _readline_locked(self, process: subprocess.Popen[str], timeout: float) -> str:
171
+ if process.stdout is None:
172
+ raise CodeExecError("python worker has no stdout")
173
+ lines: Queue[str] = Queue()
174
+
175
+ def _read() -> None:
176
+ lines.put(process.stdout.readline() if process.stdout is not None else "")
177
+
178
+ reader = threading.Thread(target=_read, daemon=True)
179
+ reader.start()
180
+ reader.join(timeout)
181
+ if reader.is_alive():
182
+ self._kill_locked()
183
+ raise CodeExecError(f"timed out after {timeout:g}s")
184
+ try:
185
+ return lines.get_nowait()
186
+ except Empty:
187
+ return ""
188
+
189
+ def _ensure_process_locked(self) -> subprocess.Popen[str]:
190
+ process = self._process
191
+ if process is not None and process.poll() is None:
192
+ return process
193
+ env = os.environ.copy()
194
+ pythonpath = env.get("PYTHONPATH", "")
195
+ env["PYTHONPATH"] = (
196
+ str(_SRC_ROOT) if not pythonpath else f"{_SRC_ROOT}{os.pathsep}{pythonpath}"
197
+ )
198
+ self._process = subprocess.Popen(
199
+ [sys.executable, "-m", "tiny_gdn.code_exec"],
200
+ stdin=subprocess.PIPE,
201
+ stdout=subprocess.PIPE,
202
+ stderr=subprocess.PIPE,
203
+ text=True,
204
+ bufsize=1,
205
+ env=env,
206
+ )
207
+ if self._process.stdin is None or self._process.stdout is None:
208
+ self._kill_locked()
209
+ raise CodeExecError("failed to start python worker")
210
+ return self._process
211
+
212
+ def _kill_locked(self) -> None:
213
+ process = self._process
214
+ self._process = None
215
+ if process is None:
216
+ return
217
+ try:
218
+ process.kill()
219
+ process.wait(timeout=2)
220
+ except (OSError, subprocess.TimeoutExpired):
221
+ pass
222
+
223
+
224
+ def default_python_session() -> StatefulPythonSession:
225
+ global _default_session
226
+ with _session_lock:
227
+ if _default_session is None:
228
+ _default_session = StatefulPythonSession()
229
+ return _default_session
230
+
231
+
232
+ def reset_default_python_session() -> None:
233
+ global _default_session
234
+ with _session_lock:
235
+ session = _default_session
236
+ _default_session = None
237
+ if session is not None:
238
+ session.close()
239
+
240
+
241
+ def execute_calculator(expression: str) -> str:
242
+ tree = ast.parse(expression, mode="eval")
243
+ _reject_unsafe_ast(tree)
244
+ value = eval( # noqa: S307 — AST already rejected unsafe nodes
245
+ compile(tree, "<calculator>", "eval"),
246
+ {"__builtins__": {}},
247
+ _calculator_namespace(),
248
+ )
249
+ return _format_result(value)
250
+
251
+
252
+ def execute_math_tool(name: str, arguments: Mapping[str, Any] | None) -> str:
253
+ source = code_from_arguments(arguments)
254
+ if is_calculator_tool_name(name) and "\n" not in source and ";" not in source:
255
+ try:
256
+ return execute_calculator(source)
257
+ except (SyntaxError, CodeExecError, TypeError, ValueError, ZeroDivisionError) as error:
258
+ raise CodeExecError(str(error)) from error
259
+ return default_python_session().run(source)
260
+
261
+
262
+ def _calculator_namespace() -> dict[str, Any]:
263
+ names = {
264
+ key: getattr(math, key)
265
+ for key in dir(math)
266
+ if not key.startswith("_")
267
+ }
268
+ names.update(
269
+ {
270
+ "abs": abs,
271
+ "min": min,
272
+ "max": max,
273
+ "round": round,
274
+ "pow": pow,
275
+ "pi": math.pi,
276
+ "e": math.e,
277
+ "tau": math.tau,
278
+ "inf": math.inf,
279
+ }
280
+ )
281
+ return names
282
+
283
+
284
+ def _reject_unsafe_ast(tree: ast.AST) -> None:
285
+ for node in ast.walk(tree):
286
+ if isinstance(node, (ast.ClassDef, ast.AsyncFunctionDef)):
287
+ raise CodeExecError("class definitions are blocked")
288
+ if isinstance(node, ast.Attribute) and node.attr.startswith("_"):
289
+ raise CodeExecError("dunder attribute access is blocked")
290
+ if isinstance(node, ast.Name) and node.id.startswith("_"):
291
+ raise CodeExecError("dunder names are blocked")
292
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
293
+ if node.func.id in _BANNED_CALLS:
294
+ raise CodeExecError(f"{node.func.id}() is blocked")
295
+
296
+
297
+ _BANNED_CALLS = frozenset(
298
+ {
299
+ "eval",
300
+ "exec",
301
+ "compile",
302
+ "open",
303
+ "input",
304
+ "breakpoint",
305
+ "getattr",
306
+ "setattr",
307
+ "delattr",
308
+ "globals",
309
+ "locals",
310
+ "vars",
311
+ "dir",
312
+ "help",
313
+ "__import__",
314
+ "memoryview",
315
+ "exit",
316
+ "quit",
317
+ }
318
+ )
319
+
320
+
321
+ def _format_result(value: Any) -> str:
322
+ if value is None:
323
+ return "None"
324
+ if isinstance(value, bool):
325
+ return str(value)
326
+ if isinstance(value, float):
327
+ if value.is_integer() and abs(value) < 1e15:
328
+ return str(int(value))
329
+ return format(value, ".12g")
330
+ return str(value)
331
+
332
+
333
+ _ALLOWED_IMPORT_ROOTS = frozenset(
334
+ {
335
+ "cmath",
336
+ "collections",
337
+ "copy",
338
+ "decimal",
339
+ "fractions",
340
+ "functools",
341
+ "itertools",
342
+ "json",
343
+ "math",
344
+ "mpmath",
345
+ "numbers",
346
+ "numpy",
347
+ "operator",
348
+ "re",
349
+ "statistics",
350
+ "string",
351
+ "sympy",
352
+ "textwrap",
353
+ "unicodedata",
354
+ }
355
+ )
356
+
357
+ _WORKER_NAMESPACE: dict[str, Any] | None = None
358
+
359
+
360
+ def _allowed_import(
361
+ name: str,
362
+ globals: dict[str, Any] | None = None,
363
+ locals: dict[str, Any] | None = None,
364
+ fromlist: tuple[str, ...] = (),
365
+ level: int = 0,
366
+ ) -> Any:
367
+ root = name.split(".")[0]
368
+ if root not in _ALLOWED_IMPORT_ROOTS:
369
+ raise ImportError(f"import of {name!r} is blocked")
370
+ return __import__(name, globals, locals, fromlist, level)
371
+
372
+
373
+ def _worker_namespace() -> dict[str, Any]:
374
+ global _WORKER_NAMESPACE
375
+ if _WORKER_NAMESPACE is None:
376
+ builtins = {
377
+ "abs": abs,
378
+ "all": all,
379
+ "any": any,
380
+ "bin": bin,
381
+ "bool": bool,
382
+ "bytes": bytes,
383
+ "chr": chr,
384
+ "complex": complex,
385
+ "dict": dict,
386
+ "divmod": divmod,
387
+ "enumerate": enumerate,
388
+ "filter": filter,
389
+ "float": float,
390
+ "format": format,
391
+ "frozenset": frozenset,
392
+ "hex": hex,
393
+ "int": int,
394
+ "isinstance": isinstance,
395
+ "issubclass": issubclass,
396
+ "iter": iter,
397
+ "len": len,
398
+ "list": list,
399
+ "map": map,
400
+ "max": max,
401
+ "min": min,
402
+ "next": next,
403
+ "oct": oct,
404
+ "ord": ord,
405
+ "pow": pow,
406
+ "print": print,
407
+ "range": range,
408
+ "repr": repr,
409
+ "reversed": reversed,
410
+ "round": round,
411
+ "set": set,
412
+ "slice": slice,
413
+ "sorted": sorted,
414
+ "str": str,
415
+ "sum": sum,
416
+ "tuple": tuple,
417
+ "zip": zip,
418
+ "True": True,
419
+ "False": False,
420
+ "None": None,
421
+ "__import__": _allowed_import,
422
+ }
423
+ _WORKER_NAMESPACE = {
424
+ "__builtins__": builtins,
425
+ "__name__": "__tool__",
426
+ "math": math,
427
+ }
428
+ return _WORKER_NAMESPACE
429
+
430
+
431
+ def run_cell(code: str) -> str:
432
+ tree = ast.parse(code)
433
+ _reject_unsafe_import_roots(tree)
434
+ _reject_unsafe_ast(tree)
435
+ namespace = _worker_namespace()
436
+ buffer = io.StringIO()
437
+ previous = sys.stdout
438
+ sys.stdout = buffer
439
+ try:
440
+ if tree.body and isinstance(tree.body[-1], ast.Expr):
441
+ body = tree.body[:-1]
442
+ last = tree.body[-1]
443
+ if body:
444
+ exec( # noqa: S102
445
+ compile(ast.Module(body, type_ignores=[]), "<tool>", "exec"),
446
+ namespace,
447
+ namespace,
448
+ )
449
+ value = eval( # noqa: S307
450
+ compile(ast.Expression(last.value), "<tool>", "eval"),
451
+ namespace,
452
+ namespace,
453
+ )
454
+ printed = buffer.getvalue()
455
+ if value is None:
456
+ return printed if printed else "None"
457
+ rendered = _format_result(value)
458
+ return f"{printed}{rendered}" if printed else rendered
459
+ exec(compile(tree, "<tool>", "exec"), namespace, namespace) # noqa: S102
460
+ printed = buffer.getvalue()
461
+ return printed if printed else "None"
462
+ finally:
463
+ sys.stdout = previous
464
+
465
+
466
+ def _reject_unsafe_import_roots(tree: ast.AST) -> None:
467
+ for node in ast.walk(tree):
468
+ if isinstance(node, ast.Import):
469
+ for alias in node.names:
470
+ root = alias.name.split(".")[0]
471
+ if root not in _ALLOWED_IMPORT_ROOTS:
472
+ raise CodeExecError(f"import of {alias.name!r} is blocked")
473
+ elif isinstance(node, ast.ImportFrom):
474
+ root = (node.module or "").split(".")[0]
475
+ if root not in _ALLOWED_IMPORT_ROOTS:
476
+ raise CodeExecError(f"import of {node.module!r} is blocked")
477
+
478
+
479
+ def _worker_loop() -> None:
480
+ for line in sys.stdin:
481
+ line = line.strip()
482
+ if not line:
483
+ continue
484
+ try:
485
+ request = json.loads(line)
486
+ code = str(request.get("code") or "")
487
+ output = run_cell(code)
488
+ sys.stdout.write(json.dumps({"ok": True, "output": output}, ensure_ascii=False) + "\n")
489
+ except Exception as error:
490
+ detail = str(error) or type(error).__name__
491
+ sys.stdout.write(
492
+ json.dumps({"ok": False, "output": detail}, ensure_ascii=False) + "\n"
493
+ )
494
+ sys.stdout.flush()
495
+
496
+
497
+ if __name__ == "__main__":
498
+ _worker_loop()
tiny_gdn/config.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass, fields
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ LINEAR_LAYER_TYPES = frozenset({"gdn2", "kda", "diag_kdn"})
9
+ DIAG_KDN_HEAD_DIMS = (64, 128)
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class TinyGDNConfig:
14
+ architecture: str = "TinyGDNForCausalLM"
15
+ model_type: str = "tiny_gdn"
16
+
17
+ vocab_size: int = 49_152
18
+ # Deep-thin sizing is deliberate: controlled sub-billion studies find
19
+ # depth materially more valuable than width around the 125M-150M scale.
20
+ hidden_size: int = 512
21
+ intermediate_size: int = 1_472
22
+ num_hidden_layers: int = 32
23
+
24
+ num_attention_heads: int = 4
25
+ num_key_value_heads: int = 1
26
+ attention_head_dim: int = 128
27
+ full_attention_interval: int = 4
28
+ attention_dropout: float = 0.0
29
+ partial_rotary_factor: float = 0.5
30
+ rope_theta: float = 1_000_000.0
31
+
32
+ linear_num_heads: int = 4
33
+ linear_num_value_heads: int = 4
34
+ linear_head_dim: int = 128
35
+ linear_expand_v: float = 1.0
36
+ linear_conv_kernel_dim: int = 4
37
+ allow_negative_eigenvalues: bool = False
38
+ linear_layer_type: str = "gdn2"
39
+ attention_layer_type: str = "full_attention"
40
+ mlp_activation: str = "swiglu"
41
+ use_block_attn_res: bool = False
42
+ kda_safe_gate: bool = True
43
+ kda_lower_bound: float = -5.0
44
+ mla_q_lora_rank: int | None = 512
45
+ mla_kv_lora_rank: int = 256
46
+ mla_qk_nope_head_dim: int = 128
47
+ mla_v_head_dim: int = 128
48
+ situ_gate_cap: float = 4.0
49
+ situ_up_cap: float = 25.0
50
+
51
+ max_position_embeddings: int = 32_768
52
+ training_sequence_length: int = 2_048
53
+ rms_norm_eps: float = 1e-6
54
+ initializer_range: float = 0.02
55
+ tie_word_embeddings: bool = True
56
+ shared_layer_indices: tuple[int, ...] = ()
57
+
58
+ # MTP is an opt-in ablation at this scale; static MTP is not assumed to
59
+ # improve a 150M model without a controlled pilot.
60
+ mtp_num_heads: int = 0
61
+ mtp_adapter_rank: int = 128
62
+ mtp_loss_weight: float = 0.0
63
+
64
+ bos_token_id: int = 0
65
+ eos_token_id: int = 1
66
+ pad_token_id: int = 2
67
+ unk_token_id: int = 3
68
+
69
+ def __post_init__(self) -> None:
70
+ if self.vocab_size <= 0 or self.vocab_size > 65_536:
71
+ raise ValueError("vocab_size must fit the uint16 token dataset")
72
+ if self.hidden_size != self.num_attention_heads * self.attention_head_dim:
73
+ raise ValueError("hidden_size must equal num_attention_heads * attention_head_dim")
74
+ if self.hidden_size != self.linear_num_heads * self.linear_head_dim:
75
+ raise ValueError("hidden_size must equal linear_num_heads * linear_head_dim")
76
+ if self.linear_num_value_heads < self.linear_num_heads:
77
+ raise ValueError("linear_num_value_heads must be at least linear_num_heads")
78
+ if self.linear_num_value_heads % self.linear_num_heads != 0:
79
+ raise ValueError("linear_num_value_heads must be divisible by linear_num_heads")
80
+ if self.num_attention_heads % self.num_key_value_heads != 0:
81
+ raise ValueError("num_attention_heads must be divisible by num_key_value_heads")
82
+ if self.num_hidden_layers % self.full_attention_interval != 0:
83
+ raise ValueError("num_hidden_layers must be divisible by full_attention_interval")
84
+ if not 0.0 < self.partial_rotary_factor <= 1.0:
85
+ raise ValueError("partial_rotary_factor must be in (0, 1]")
86
+ rotary_dim = int(self.attention_head_dim * self.partial_rotary_factor)
87
+ if rotary_dim <= 0 or rotary_dim % 2:
88
+ raise ValueError("The partial rotary dimension must be positive and even")
89
+ if self.training_sequence_length > self.max_position_embeddings:
90
+ raise ValueError("training_sequence_length exceeds max_position_embeddings")
91
+ if len(set(self.shared_layer_indices)) != len(self.shared_layer_indices):
92
+ raise ValueError("shared_layer_indices must be unique")
93
+ if any(
94
+ index < 0 or index >= self.num_hidden_layers
95
+ for index in self.shared_layer_indices
96
+ ):
97
+ raise ValueError("shared_layer_indices contains an invalid layer")
98
+ if self.mtp_num_heads < 0:
99
+ raise ValueError("mtp_num_heads cannot be negative")
100
+ if self.mtp_num_heads and self.mtp_adapter_rank <= 0:
101
+ raise ValueError("mtp_adapter_rank must be positive when MTP is enabled")
102
+ if not 0.0 <= self.mtp_loss_weight <= 1.0:
103
+ raise ValueError("mtp_loss_weight must be between zero and one")
104
+ for token_id in (
105
+ self.bos_token_id,
106
+ self.eos_token_id,
107
+ self.pad_token_id,
108
+ self.unk_token_id,
109
+ ):
110
+ if not 0 <= token_id < self.vocab_size:
111
+ raise ValueError(f"Special token ID {token_id} is outside the vocabulary")
112
+ if self.linear_layer_type not in LINEAR_LAYER_TYPES:
113
+ raise ValueError(
114
+ f"linear_layer_type must be one of {sorted(LINEAR_LAYER_TYPES)}"
115
+ )
116
+ if self.linear_layer_type == "diag_kdn":
117
+ if self.linear_head_dim not in DIAG_KDN_HEAD_DIMS:
118
+ raise ValueError(
119
+ f"diag_kdn needs linear_head_dim in {DIAG_KDN_HEAD_DIMS}"
120
+ )
121
+ if self.linear_num_value_heads != self.linear_num_heads:
122
+ raise ValueError("diag_kdn needs linear_num_value_heads == linear_num_heads")
123
+ if self.linear_expand_v != 1.0:
124
+ raise ValueError("diag_kdn needs linear_expand_v == 1.0")
125
+ if self.allow_negative_eigenvalues:
126
+ raise ValueError("diag_kdn derives its gain from the covariance; negative eigenvalues are unsupported")
127
+ if self.attention_layer_type not in {"full_attention", "gated_mla"}:
128
+ raise ValueError("attention_layer_type must be 'full_attention' or 'gated_mla'")
129
+ if self.mlp_activation not in {"swiglu", "situ_glu"}:
130
+ raise ValueError("mlp_activation must be 'swiglu' or 'situ_glu'")
131
+ if self.mla_kv_lora_rank <= 0:
132
+ raise ValueError("mla_kv_lora_rank must be positive")
133
+ if self.mla_q_lora_rank is not None and self.mla_q_lora_rank <= 0:
134
+ raise ValueError("mla_q_lora_rank must be positive or None")
135
+ if self.mla_qk_nope_head_dim <= 0 or self.mla_v_head_dim <= 0:
136
+ raise ValueError("MLA head dimensions must be positive")
137
+ if self.kda_lower_bound >= 0.0:
138
+ raise ValueError("kda_lower_bound must be negative log-decay")
139
+ if self.situ_gate_cap <= 0.0 or self.situ_up_cap <= 0.0:
140
+ raise ValueError("SiTU caps must be positive")
141
+
142
+ @property
143
+ def layer_types(self) -> tuple[str, ...]:
144
+ return tuple(
145
+ self.attention_layer_type
146
+ if (index + 1) % self.full_attention_interval == 0
147
+ else self.linear_layer_type
148
+ for index in range(self.num_hidden_layers)
149
+ )
150
+
151
+ @property
152
+ def rotary_dim(self) -> int:
153
+ return int(self.attention_head_dim * self.partial_rotary_factor)
154
+
155
+ @property
156
+ def effective_num_layers(self) -> int:
157
+ return self.num_hidden_layers + len(self.shared_layer_indices)
158
+
159
+ def to_dict(self) -> dict[str, Any]:
160
+ payload = asdict(self)
161
+ payload["layer_types"] = list(self.layer_types)
162
+ return payload
163
+
164
+ def save_json(self, path: Path) -> None:
165
+ path.parent.mkdir(parents=True, exist_ok=True)
166
+ path.write_text(
167
+ json.dumps(self.to_dict(), indent=2, sort_keys=True) + "\n",
168
+ encoding="utf-8",
169
+ )
170
+
171
+ @classmethod
172
+ def from_json(cls, path: Path) -> TinyGDNConfig:
173
+ payload = json.loads(path.read_text(encoding="utf-8"))
174
+ payload.pop("layer_types", None)
175
+ if "shared_layer_indices" in payload:
176
+ payload["shared_layer_indices"] = tuple(payload["shared_layer_indices"])
177
+ allowed = {item.name for item in fields(cls)}
178
+ return cls(**{key: value for key, value in payload.items() if key in allowed})
179
+
180
+
181
+ def haiku_config(**overrides: Any) -> TinyGDNConfig:
182
+ """650M Haiku: 3×KDA + 1×Gated-MLA, AttnRes, SiTU-GLU, MTP."""
183
+
184
+ payload = {
185
+ "architecture": "HaikuForCausalLM",
186
+ "model_type": "haiku",
187
+ "vocab_size": 65_536,
188
+ "hidden_size": 1024,
189
+ "intermediate_size": 3840,
190
+ "num_hidden_layers": 36,
191
+ "num_attention_heads": 8,
192
+ "num_key_value_heads": 8,
193
+ "attention_head_dim": 128,
194
+ "full_attention_interval": 4,
195
+ "linear_num_heads": 8,
196
+ "linear_num_value_heads": 8,
197
+ "linear_head_dim": 128,
198
+ "linear_layer_type": "kda",
199
+ "attention_layer_type": "gated_mla",
200
+ "mlp_activation": "situ_glu",
201
+ "use_block_attn_res": True,
202
+ "mtp_num_heads": 2,
203
+ "mtp_adapter_rank": 128,
204
+ "mtp_loss_weight": 0.2,
205
+ "max_position_embeddings": 32_768,
206
+ "training_sequence_length": 2048,
207
+ }
208
+ payload.update(overrides)
209
+ return TinyGDNConfig(**payload)
210
+
211
+
212
+ def haiku_kdn_config(**overrides: Any) -> TinyGDNConfig:
213
+ """Haiku with Diagonal KDN in place of KDA in the 27 linear layers.
214
+
215
+ Opt-in: at equal tokens it trailed KDA in Haiku pilots and trains ~23%
216
+ slower at full depth (docs/architecture.md, "Haiku v2").
217
+ """
218
+
219
+ return haiku_config(**{"linear_layer_type": "diag_kdn", **overrides})
220
+
221
+
222
+ def senryu_config(**overrides: Any) -> TinyGDNConfig:
223
+ """800M Senryu: 3×Gated DeltaNet-2 + 1×Gated-MLA over 44 layers, AttnRes, SiTU-GLU.
224
+
225
+ Haiku's width, heads, tokenizer, and residual design with GDN-2's
226
+ channel-wise erase and write gates in the 33 linear layers. A rank-512
227
+ query LoRA costs as many parameters as a full 1024×1024 query, so queries
228
+ are full rank, and the KV latent doubles to 512. The feed-forward narrows
229
+ to 3392 to pay for the depth. Next-token only: MTP has no pilot win at
230
+ this scale (docs/architecture.md, "Senryu").
231
+ """
232
+
233
+ return haiku_config(
234
+ **{
235
+ "architecture": "SenryuForCausalLM",
236
+ "model_type": "senryu",
237
+ "intermediate_size": 3392,
238
+ "num_hidden_layers": 44,
239
+ "linear_layer_type": "gdn2",
240
+ "mla_q_lora_rank": None,
241
+ "mla_kv_lora_rank": 512,
242
+ "mtp_num_heads": 0,
243
+ "mtp_loss_weight": 0.0,
244
+ **overrides,
245
+ }
246
+ )
tiny_gdn/detokenize.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Byte-accurate incremental decode for GPT-2-style byte-level BPE.
2
+
3
+ `tokenizer.decode(ids)` UTF-8-replaces incomplete sequences. Streaming that
4
+ as a string prefix then breaks on the next byte: decode([20492]) is a
5
+ replacement character and decode([20492, 294]) is `` ≈``, so the
6
+ accumulated text is no longer a prefix and the UI re-emits the whole
7
+ think block.
8
+
9
+ This decoder concatenates raw BPE bytes and only emits complete UTF-8
10
+ characters. The completing byte of ``≈`` yields `` ≈``; nothing is rewound.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Iterable, Mapping, Sequence
16
+ from typing import Any
17
+
18
+
19
+ def gpt2_bytes_to_unicode() -> dict[int, str]:
20
+ raw = (
21
+ list(range(ord("!"), ord("~") + 1))
22
+ + list(range(ord("¡"), ord("¬") + 1))
23
+ + list(range(ord("®"), ord("ÿ") + 1))
24
+ )
25
+ mapped = raw[:]
26
+ extra = 0
27
+ for byte in range(256):
28
+ if byte not in raw:
29
+ raw.append(byte)
30
+ mapped.append(256 + extra)
31
+ extra += 1
32
+ return dict(zip(raw, [chr(code) for code in mapped], strict=True))
33
+
34
+
35
+ def unicode_to_gpt2_bytes() -> dict[str, int]:
36
+ return {char: byte for byte, char in gpt2_bytes_to_unicode().items()}
37
+
38
+
39
+ def utf8_char_length(lead: int) -> int | None:
40
+ if lead < 0x80:
41
+ return 1
42
+ if 0xC2 <= lead <= 0xDF:
43
+ return 2
44
+ if 0xE0 <= lead <= 0xEF:
45
+ return 3
46
+ if 0xF0 <= lead <= 0xF4:
47
+ return 4
48
+ return None
49
+
50
+
51
+ def split_complete_utf8(buffer: bytes) -> tuple[str, bytes]:
52
+ """Return (decoded prefix, leftover incomplete or invalid-lead bytes)."""
53
+ index = 0
54
+ end = len(buffer)
55
+ while index < end:
56
+ size = utf8_char_length(buffer[index])
57
+ if size is None:
58
+ break
59
+ if index + size > end:
60
+ break
61
+ chunk = buffer[index : index + size]
62
+ try:
63
+ chunk.decode("utf-8")
64
+ except UnicodeDecodeError:
65
+ break
66
+ index += size
67
+ if index == 0:
68
+ return "", buffer
69
+ return buffer[:index].decode("utf-8"), buffer[index:]
70
+
71
+
72
+ def piece_to_bytes(piece: str, unicode_to_byte: Mapping[str, int]) -> bytes:
73
+ raw = bytearray()
74
+ for char in piece:
75
+ mapped = unicode_to_byte.get(char)
76
+ if mapped is None:
77
+ raw.extend(char.encode("utf-8"))
78
+ else:
79
+ raw.append(mapped)
80
+ return bytes(raw)
81
+
82
+
83
+ class IncrementalUtf8Decoder:
84
+ """Push token ids; read only complete Unicode as it becomes available."""
85
+
86
+ def __init__(
87
+ self,
88
+ tokenizer: Any,
89
+ *,
90
+ skip_special_ids: Iterable[int] | None = None,
91
+ ) -> None:
92
+ vocab = tokenizer.get_vocab()
93
+ self._id_to_piece = {tid: piece for piece, tid in vocab.items()}
94
+ self._unicode_to_byte = unicode_to_gpt2_bytes()
95
+ self._skip_special_ids = set(skip_special_ids or ())
96
+ self._pending = b""
97
+ self._text = ""
98
+
99
+ @property
100
+ def text(self) -> str:
101
+ return self._text
102
+
103
+ def reset(self) -> None:
104
+ self._pending = b""
105
+ self._text = ""
106
+
107
+ def token_bytes(self, token_id: int) -> bytes:
108
+ piece = self._id_to_piece.get(token_id, "")
109
+ return piece_to_bytes(piece, self._unicode_to_byte)
110
+
111
+ def push(self, token_id: int) -> str:
112
+ if token_id in self._skip_special_ids:
113
+ return ""
114
+ self._pending += self.token_bytes(token_id)
115
+ complete, self._pending = split_complete_utf8(self._pending)
116
+ self._text += complete
117
+ return complete
118
+
119
+ def push_many(self, token_ids: Sequence[int]) -> str:
120
+ delta = []
121
+ for token_id in token_ids:
122
+ piece = self.push(token_id)
123
+ if piece:
124
+ delta.append(piece)
125
+ return "".join(delta)
126
+
127
+ def finalize(self) -> str:
128
+ if self._pending:
129
+ self._text += self._pending.decode("utf-8", errors="replace")
130
+ self._pending = b""
131
+ return self._text
tiny_gdn/fused_ops.py ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fused CUDA kernels for memory-bound blocks: SiTU-GLU, the LM loss, token log-probs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ import triton
7
+ import triton.language as tl
8
+ from triton.language.extra import libdevice
9
+
10
+ from tiny_gdn.nn_common import (
11
+ linear_cross_entropy_reference,
12
+ linear_token_logprobs_reference,
13
+ situ_glu_reference,
14
+ )
15
+
16
+ BLOCK_SIZE = 1024
17
+ VOCAB_BLOCK_SIZE = 4096
18
+ LOGITS_CHUNK_BYTES = 1 << 30
19
+
20
+
21
+ @triton.jit
22
+ def _situ_glu_forward_kernel(
23
+ gate_up,
24
+ output,
25
+ total,
26
+ width,
27
+ gate_cap,
28
+ up_cap,
29
+ BLOCK: tl.constexpr,
30
+ ):
31
+ offsets = tl.program_id(0).to(tl.int64) * BLOCK + tl.arange(0, BLOCK)
32
+ mask = offsets < total
33
+ row = offsets // width
34
+ gate_offsets = offsets + row * width
35
+ gate = tl.load(gate_up + gate_offsets, mask=mask, other=0.0).to(tl.float32)
36
+ up = tl.load(gate_up + gate_offsets + width, mask=mask, other=0.0).to(tl.float32)
37
+ gated = gate_cap * libdevice.tanh(gate / gate_cap) * tl.sigmoid(gate)
38
+ result = gated * up_cap * libdevice.tanh(up / up_cap)
39
+ tl.store(output + offsets, result.to(output.dtype.element_ty), mask=mask)
40
+
41
+
42
+ @triton.jit
43
+ def _situ_glu_backward_kernel(
44
+ gate_up,
45
+ grad_output,
46
+ grad_gate_up,
47
+ total,
48
+ width,
49
+ gate_cap,
50
+ up_cap,
51
+ BLOCK: tl.constexpr,
52
+ ):
53
+ offsets = tl.program_id(0).to(tl.int64) * BLOCK + tl.arange(0, BLOCK)
54
+ mask = offsets < total
55
+ row = offsets // width
56
+ gate_offsets = offsets + row * width
57
+ gate = tl.load(gate_up + gate_offsets, mask=mask, other=0.0).to(tl.float32)
58
+ up = tl.load(gate_up + gate_offsets + width, mask=mask, other=0.0).to(tl.float32)
59
+ grad = tl.load(grad_output + offsets, mask=mask, other=0.0).to(tl.float32)
60
+ gate_tanh = libdevice.tanh(gate / gate_cap)
61
+ up_tanh = libdevice.tanh(up / up_cap)
62
+ sigmoid = tl.sigmoid(gate)
63
+ gated = gate_cap * gate_tanh * sigmoid
64
+ capped_up = up_cap * up_tanh
65
+ gate_slope = sigmoid * ((1.0 - gate_tanh * gate_tanh) + gate_cap * gate_tanh * (1.0 - sigmoid))
66
+ grad_gate = grad * capped_up * gate_slope
67
+ grad_up = grad * gated * (1.0 - up_tanh * up_tanh)
68
+ dtype = grad_gate_up.dtype.element_ty
69
+ tl.store(grad_gate_up + gate_offsets, grad_gate.to(dtype), mask=mask)
70
+ tl.store(grad_gate_up + gate_offsets + width, grad_up.to(dtype), mask=mask)
71
+
72
+
73
+ def _launch_grid(total: int) -> tuple[int]:
74
+ return (triton.cdiv(total, BLOCK_SIZE),)
75
+
76
+
77
+ class _SiTUGLUFunction(torch.autograd.Function):
78
+ @staticmethod
79
+ def forward(ctx, gate_up: torch.Tensor, gate_cap: float, up_cap: float) -> torch.Tensor:
80
+ gate_up = gate_up.contiguous()
81
+ width = gate_up.shape[-1] // 2
82
+ output = gate_up.new_empty((*gate_up.shape[:-1], width))
83
+ total = output.numel()
84
+ if total:
85
+ _situ_glu_forward_kernel[_launch_grid(total)](
86
+ gate_up, output, total, width, gate_cap, up_cap, BLOCK=BLOCK_SIZE
87
+ )
88
+ ctx.save_for_backward(gate_up)
89
+ ctx.caps = (gate_cap, up_cap)
90
+ return output
91
+
92
+ @staticmethod
93
+ def backward(ctx, grad_output: torch.Tensor) -> tuple[torch.Tensor, None, None]:
94
+ (gate_up,) = ctx.saved_tensors
95
+ gate_cap, up_cap = ctx.caps
96
+ grad_output = grad_output.contiguous()
97
+ grad_gate_up = torch.empty_like(gate_up)
98
+ width = gate_up.shape[-1] // 2
99
+ total = grad_output.numel()
100
+ if total:
101
+ _situ_glu_backward_kernel[_launch_grid(total)](
102
+ gate_up,
103
+ grad_output,
104
+ grad_gate_up,
105
+ total,
106
+ width,
107
+ gate_cap,
108
+ up_cap,
109
+ BLOCK=BLOCK_SIZE,
110
+ )
111
+ return grad_gate_up, None, None
112
+
113
+
114
+ def situ_glu(gate_up: torch.Tensor, gate_cap: float, up_cap: float) -> torch.Tensor:
115
+ """``softcap(gate) * sigmoid(gate) * softcap(up)`` over ``[..., gate | up]``.
116
+
117
+ The CUDA path evaluates in FP32 and rounds once; backward recomputes the
118
+ activations from ``gate_up`` instead of saving intermediates.
119
+ """
120
+
121
+ if gate_up.shape[-1] % 2:
122
+ raise ValueError("gate_up must have an even last dimension")
123
+ if not gate_up.is_cuda:
124
+ return situ_glu_reference(gate_up, gate_cap, up_cap)
125
+ return _SiTUGLUFunction.apply(gate_up, gate_cap, up_cap)
126
+
127
+
128
+ @triton.jit
129
+ def _cross_entropy_rows_kernel(
130
+ logits,
131
+ targets,
132
+ losses,
133
+ log_partitions,
134
+ valid_count,
135
+ row_stride,
136
+ vocab_size,
137
+ z_loss_coefficient,
138
+ ignore_index,
139
+ COMPUTE_GRAD: tl.constexpr,
140
+ BLOCK: tl.constexpr,
141
+ ):
142
+ """Per row: logsumexp and NLL; optionally overwrite the row with d(mean loss)/d(logits)."""
143
+
144
+ row = tl.program_id(0).to(tl.int64)
145
+ row_logits = logits + row * row_stride
146
+ target = tl.load(targets + row)
147
+ columns = tl.arange(0, BLOCK)
148
+ if target == ignore_index:
149
+ tl.store(losses + row, 0.0)
150
+ tl.store(log_partitions + row, 0.0)
151
+ if COMPUTE_GRAD:
152
+ for start in range(0, vocab_size, BLOCK):
153
+ offsets = start + columns
154
+ zeros = tl.zeros((BLOCK,), dtype=logits.dtype.element_ty)
155
+ tl.store(row_logits + offsets, zeros, mask=offsets < vocab_size)
156
+ else:
157
+ running_max = tl.full((), float("-inf"), tl.float32)
158
+ running_sum = tl.zeros((), dtype=tl.float32)
159
+ for start in range(0, vocab_size, BLOCK):
160
+ offsets = start + columns
161
+ values = tl.load(
162
+ row_logits + offsets, mask=offsets < vocab_size, other=float("-inf")
163
+ ).to(tl.float32)
164
+ new_max = tl.maximum(running_max, tl.max(values, axis=0))
165
+ running_sum = running_sum * tl.exp(running_max - new_max) + tl.sum(
166
+ tl.exp(values - new_max), axis=0
167
+ )
168
+ running_max = new_max
169
+ log_partition = running_max + tl.log(running_sum)
170
+ target_logit = tl.load(row_logits + target).to(tl.float32)
171
+ tl.store(losses + row, log_partition - target_logit)
172
+ tl.store(log_partitions + row, log_partition)
173
+ if COMPUTE_GRAD:
174
+ grad_scale = 1.0 / tl.load(valid_count).to(tl.float32)
175
+ softmax_scale = grad_scale * (1.0 + 2.0 * z_loss_coefficient * log_partition)
176
+ for start in range(0, vocab_size, BLOCK):
177
+ offsets = start + columns
178
+ in_vocab = offsets < vocab_size
179
+ values = tl.load(row_logits + offsets, mask=in_vocab, other=0.0).to(tl.float32)
180
+ grad = tl.exp(values - log_partition) * softmax_scale
181
+ grad = tl.where(offsets == target, grad - grad_scale, grad)
182
+ tl.store(row_logits + offsets, grad.to(logits.dtype.element_ty), mask=in_vocab)
183
+
184
+
185
+ class _LinearCrossEntropyFunction(torch.autograd.Function):
186
+ """Chunked ``states @ weight.T`` + cross-entropy + z-loss with gradients precomputed.
187
+
188
+ Each logits chunk is consumed in place, so peak extra memory is one chunk
189
+ instead of ``[tokens, vocab]`` logits plus their softmax for backward.
190
+ """
191
+
192
+ @staticmethod
193
+ def forward(
194
+ ctx,
195
+ states: torch.Tensor,
196
+ weight: torch.Tensor,
197
+ targets: torch.Tensor,
198
+ z_loss_coefficient: float,
199
+ ignore_index: int,
200
+ compute_grad: bool,
201
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
202
+ states = states.contiguous()
203
+ targets = targets.contiguous()
204
+ rows, vocab_size = states.shape[0], weight.shape[0]
205
+ valid = targets.ne(ignore_index)
206
+ valid_count = valid.sum(dtype=torch.float32)
207
+ losses = states.new_empty(rows, dtype=torch.float32)
208
+ log_partitions = states.new_empty(rows, dtype=torch.float32)
209
+ grad_states = torch.empty_like(states) if compute_grad else None
210
+ grad_weight = (
211
+ torch.zeros(weight.shape, dtype=torch.float32, device=weight.device)
212
+ if compute_grad
213
+ else None
214
+ )
215
+ chunk_rows = max(1, LOGITS_CHUNK_BYTES // (vocab_size * states.element_size()))
216
+ for start in range(0, rows, chunk_rows):
217
+ end = min(start + chunk_rows, rows)
218
+ chunk = states[start:end]
219
+ logits = chunk @ weight.t()
220
+ _cross_entropy_rows_kernel[(end - start,)](
221
+ logits,
222
+ targets[start:end],
223
+ losses[start:end],
224
+ log_partitions[start:end],
225
+ valid_count,
226
+ logits.stride(0),
227
+ vocab_size,
228
+ z_loss_coefficient,
229
+ ignore_index,
230
+ COMPUTE_GRAD=compute_grad,
231
+ BLOCK=VOCAB_BLOCK_SIZE,
232
+ num_warps=8,
233
+ )
234
+ if compute_grad:
235
+ torch.mm(logits, weight, out=grad_states[start:end])
236
+ if chunk.dtype == torch.float32:
237
+ grad_weight.addmm_(logits.t(), chunk)
238
+ else:
239
+ torch.addmm(
240
+ grad_weight, logits.t(), chunk, out_dtype=torch.float32, out=grad_weight
241
+ )
242
+ cross_entropy = losses.sum() / valid_count
243
+ z_loss = log_partitions.square().sum() / valid_count
244
+ objective = cross_entropy + z_loss_coefficient * z_loss
245
+ if compute_grad:
246
+ ctx.save_for_backward(grad_states, grad_weight)
247
+ ctx.weight_dtype = weight.dtype
248
+ ctx.mark_non_differentiable(cross_entropy, z_loss)
249
+ return objective, cross_entropy, z_loss
250
+
251
+ @staticmethod
252
+ def backward(
253
+ ctx, grad_objective: torch.Tensor, *_: torch.Tensor
254
+ ) -> tuple[torch.Tensor, torch.Tensor, None, None, None, None]:
255
+ grad_states, grad_weight = ctx.saved_tensors
256
+ scale = grad_objective.to(torch.float32)
257
+ grad_states = (grad_states * scale).to(grad_states.dtype)
258
+ grad_weight = (grad_weight * scale).to(ctx.weight_dtype)
259
+ return grad_states, grad_weight, None, None, None, None
260
+
261
+
262
+ def linear_cross_entropy(
263
+ states: torch.Tensor,
264
+ weight: torch.Tensor,
265
+ targets: torch.Tensor,
266
+ *,
267
+ z_loss_coefficient: float = 0.0,
268
+ ignore_index: int = -100,
269
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
270
+ """Mean ``cross_entropy(states @ weight.T, targets)`` without full logits.
271
+
272
+ Returns ``(objective, cross_entropy, z_loss)``. Only ``objective =
273
+ cross_entropy + z_loss_coefficient * z_loss`` carries gradients; ``z_loss``
274
+ is the mean squared log-partition over non-ignored targets.
275
+ """
276
+
277
+ if states.ndim != 2 or targets.shape != states.shape[:1]:
278
+ raise ValueError("states must be [tokens, hidden] and targets [tokens]")
279
+ if not states.is_cuda:
280
+ return linear_cross_entropy_reference(
281
+ states,
282
+ weight,
283
+ targets,
284
+ z_loss_coefficient=z_loss_coefficient,
285
+ ignore_index=ignore_index,
286
+ )
287
+ compute_grad = torch.is_grad_enabled() and (states.requires_grad or weight.requires_grad)
288
+ return _LinearCrossEntropyFunction.apply(
289
+ states, weight, targets, z_loss_coefficient, ignore_index, compute_grad
290
+ )
291
+
292
+
293
+ @triton.jit
294
+ def _token_logprob_grad_rows_kernel(
295
+ logits,
296
+ targets,
297
+ log_partitions,
298
+ row_grads,
299
+ row_stride,
300
+ vocab_size,
301
+ ignore_index,
302
+ BLOCK: tl.constexpr,
303
+ ):
304
+ """Overwrite each row with ``g * (onehot(target) - softmax)``, the gradient of ``g * log p``."""
305
+
306
+ row = tl.program_id(0).to(tl.int64)
307
+ row_logits = logits + row * row_stride
308
+ target = tl.load(targets + row)
309
+ columns = tl.arange(0, BLOCK)
310
+ if target == ignore_index:
311
+ for start in range(0, vocab_size, BLOCK):
312
+ offsets = start + columns
313
+ zeros = tl.zeros((BLOCK,), dtype=logits.dtype.element_ty)
314
+ tl.store(row_logits + offsets, zeros, mask=offsets < vocab_size)
315
+ else:
316
+ scale = tl.load(row_grads + row).to(tl.float32)
317
+ log_partition = tl.load(log_partitions + row)
318
+ for start in range(0, vocab_size, BLOCK):
319
+ offsets = start + columns
320
+ in_vocab = offsets < vocab_size
321
+ values = tl.load(row_logits + offsets, mask=in_vocab, other=0.0).to(tl.float32)
322
+ grad = -scale * tl.exp(values - log_partition)
323
+ grad = tl.where(offsets == target, grad + scale, grad)
324
+ tl.store(row_logits + offsets, grad.to(logits.dtype.element_ty), mask=in_vocab)
325
+
326
+
327
+ class _LinearTokenLogprobFunction(torch.autograd.Function):
328
+ """Per-row ``log_softmax(states @ weight.T)[target]`` without keeping logits.
329
+
330
+ Preference losses weight every sequence differently, so the gradient
331
+ cannot be precomputed in forward like the mean LM loss. Forward keeps one
332
+ log-partition per row; backward recomputes each logits chunk and turns it
333
+ into the scaled softmax gradient in place.
334
+ """
335
+
336
+ @staticmethod
337
+ def forward(
338
+ ctx,
339
+ states: torch.Tensor,
340
+ weight: torch.Tensor,
341
+ targets: torch.Tensor,
342
+ ignore_index: int,
343
+ ) -> torch.Tensor:
344
+ states = states.contiguous()
345
+ targets = targets.contiguous()
346
+ rows, vocab_size = states.shape[0], weight.shape[0]
347
+ negative_logprobs = states.new_empty(rows, dtype=torch.float32)
348
+ log_partitions = states.new_empty(rows, dtype=torch.float32)
349
+ unused_count = states.new_ones((), dtype=torch.float32)
350
+ chunk_rows = max(1, LOGITS_CHUNK_BYTES // (vocab_size * states.element_size()))
351
+ for start in range(0, rows, chunk_rows):
352
+ end = min(start + chunk_rows, rows)
353
+ logits = states[start:end] @ weight.t()
354
+ _cross_entropy_rows_kernel[(end - start,)](
355
+ logits,
356
+ targets[start:end],
357
+ negative_logprobs[start:end],
358
+ log_partitions[start:end],
359
+ unused_count,
360
+ logits.stride(0),
361
+ vocab_size,
362
+ 0.0,
363
+ ignore_index,
364
+ COMPUTE_GRAD=False,
365
+ BLOCK=VOCAB_BLOCK_SIZE,
366
+ num_warps=8,
367
+ )
368
+ ctx.save_for_backward(states, weight, targets, log_partitions)
369
+ ctx.ignore_index = ignore_index
370
+ ctx.chunk_rows = chunk_rows
371
+ return negative_logprobs.neg_()
372
+
373
+ @staticmethod
374
+ def backward(
375
+ ctx, grad_logprobs: torch.Tensor
376
+ ) -> tuple[torch.Tensor | None, torch.Tensor | None, None, None]:
377
+ states, weight, targets, log_partitions = ctx.saved_tensors
378
+ row_grads = grad_logprobs.to(torch.float32).contiguous()
379
+ rows, vocab_size = states.shape[0], weight.shape[0]
380
+ grad_states = torch.empty_like(states) if ctx.needs_input_grad[0] else None
381
+ grad_weight = (
382
+ torch.zeros(weight.shape, dtype=torch.float32, device=weight.device)
383
+ if ctx.needs_input_grad[1]
384
+ else None
385
+ )
386
+ for start in range(0, rows, ctx.chunk_rows):
387
+ end = min(start + ctx.chunk_rows, rows)
388
+ chunk = states[start:end]
389
+ logits = chunk @ weight.t()
390
+ _token_logprob_grad_rows_kernel[(end - start,)](
391
+ logits,
392
+ targets[start:end],
393
+ log_partitions[start:end],
394
+ row_grads[start:end],
395
+ logits.stride(0),
396
+ vocab_size,
397
+ ctx.ignore_index,
398
+ BLOCK=VOCAB_BLOCK_SIZE,
399
+ num_warps=8,
400
+ )
401
+ if grad_states is not None:
402
+ torch.mm(logits, weight, out=grad_states[start:end])
403
+ if grad_weight is not None:
404
+ if chunk.dtype == torch.float32:
405
+ grad_weight.addmm_(logits.t(), chunk)
406
+ else:
407
+ torch.addmm(
408
+ grad_weight, logits.t(), chunk, out_dtype=torch.float32, out=grad_weight
409
+ )
410
+ return (
411
+ grad_states,
412
+ None if grad_weight is None else grad_weight.to(weight.dtype),
413
+ None,
414
+ None,
415
+ )
416
+
417
+
418
+ def linear_token_logprobs(
419
+ states: torch.Tensor,
420
+ weight: torch.Tensor,
421
+ targets: torch.Tensor,
422
+ *,
423
+ ignore_index: int = -100,
424
+ ) -> torch.Tensor:
425
+ """fp32 ``log_softmax(states @ weight.T)[row, target]`` per row without full logits.
426
+
427
+ Rows whose target is ``ignore_index`` return 0 and receive no gradient.
428
+ """
429
+
430
+ if states.ndim != 2 or targets.shape != states.shape[:1]:
431
+ raise ValueError("states must be [tokens, hidden] and targets [tokens]")
432
+ if not states.is_cuda:
433
+ return linear_token_logprobs_reference(
434
+ states, weight, targets, ignore_index=ignore_index
435
+ )
436
+ return _LinearTokenLogprobFunction.apply(states, weight, targets, ignore_index)
tiny_gdn/haiku_layers.py ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Haiku mixers: Gated MLA (NoPE), SiTU-GLU, and block Attention Residuals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import torch
8
+ import torch.nn.functional as F
9
+ from torch import nn
10
+ from torch.nn.attention import sdpa_kernel
11
+
12
+ from tiny_gdn.config import TinyGDNConfig
13
+ from tiny_gdn.nn_common import RMSNorm, attention_sdpa_backends, situ_glu_reference
14
+
15
+ try:
16
+ from tiny_gdn.fused_ops import situ_glu
17
+ except ImportError:
18
+ situ_glu = situ_glu_reference
19
+
20
+
21
+ class GatedMultiheadLatentAttention(nn.Module):
22
+ """DeepSeek-style MLA with Kimi K3 NoPE and a full-rank output gate.
23
+
24
+ Queries and keys are content-only. Position is left to the KDA layers.
25
+ KV is compressed through a latent bottleneck, then expanded per head.
26
+ """
27
+
28
+ def __init__(self, config: TinyGDNConfig) -> None:
29
+ super().__init__()
30
+ self.num_heads = config.num_attention_heads
31
+ self.qk_dim = config.mla_qk_nope_head_dim
32
+ self.v_dim = config.mla_v_head_dim
33
+ self.dropout = config.attention_dropout
34
+ q_out = self.num_heads * self.qk_dim
35
+ v_out = self.num_heads * self.v_dim
36
+ kv_out = self.num_heads * (self.qk_dim + self.v_dim)
37
+
38
+ if config.mla_q_lora_rank is None:
39
+ self.q_proj: nn.Module = nn.Linear(config.hidden_size, q_out, bias=False)
40
+ else:
41
+ self.q_proj = nn.Sequential(
42
+ nn.Linear(config.hidden_size, config.mla_q_lora_rank, bias=False),
43
+ RMSNorm(config.mla_q_lora_rank, config.rms_norm_eps),
44
+ nn.Linear(config.mla_q_lora_rank, q_out, bias=False),
45
+ )
46
+ self.kv_down = nn.Linear(config.hidden_size, config.mla_kv_lora_rank, bias=False)
47
+ self.kv_norm = RMSNorm(config.mla_kv_lora_rank, config.rms_norm_eps)
48
+ self.kv_up = nn.Linear(config.mla_kv_lora_rank, kv_out, bias=False)
49
+ self.q_norm = RMSNorm(self.qk_dim, config.rms_norm_eps)
50
+ self.k_norm = RMSNorm(self.qk_dim, config.rms_norm_eps)
51
+ self.output_gate_proj = nn.Linear(config.hidden_size, v_out, bias=False)
52
+ self.o_proj = nn.Linear(v_out, config.hidden_size, bias=False)
53
+
54
+ def _attention_mask(
55
+ self,
56
+ attention_mask: torch.Tensor | None,
57
+ sequence_length: int,
58
+ device: torch.device,
59
+ ) -> torch.Tensor | None:
60
+ if attention_mask is None:
61
+ return None
62
+ if attention_mask.ndim != 2:
63
+ raise ValueError("attention_mask must have shape [batch, sequence]")
64
+ if attention_mask.shape[1] != sequence_length:
65
+ raise ValueError("attention_mask sequence length does not match input")
66
+ causal = torch.ones(
67
+ sequence_length,
68
+ sequence_length,
69
+ dtype=torch.bool,
70
+ device=device,
71
+ ).tril()
72
+ valid_keys = attention_mask[:, None, None, :].to(dtype=torch.bool, device=device)
73
+ return causal[None, None, :, :] & valid_keys
74
+
75
+ def _project_kv(self, hidden_states: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
76
+ batch_size, sequence_length, _ = hidden_states.shape
77
+ compressed = self.kv_norm(self.kv_down(hidden_states))
78
+ key_value = self.kv_up(compressed)
79
+ key, value = key_value.split(
80
+ [self.num_heads * self.qk_dim, self.num_heads * self.v_dim],
81
+ dim=-1,
82
+ )
83
+ key = self.k_norm(
84
+ key.view(batch_size, sequence_length, self.num_heads, self.qk_dim)
85
+ ).transpose(1, 2)
86
+ value = value.view(
87
+ batch_size, sequence_length, self.num_heads, self.v_dim
88
+ ).transpose(1, 2)
89
+ return key, value
90
+
91
+ def forward(
92
+ self,
93
+ hidden_states: torch.Tensor,
94
+ attention_mask: torch.Tensor | None = None,
95
+ past_key_value: tuple[torch.Tensor, torch.Tensor] | None = None,
96
+ ) -> tuple[torch.Tensor, tuple[torch.Tensor, torch.Tensor] | None]:
97
+ batch_size, sequence_length, _ = hidden_states.shape
98
+ query = self.q_proj(hidden_states)
99
+ query = self.q_norm(
100
+ query.view(batch_size, sequence_length, self.num_heads, self.qk_dim)
101
+ ).transpose(1, 2)
102
+ key, value = self._project_kv(hidden_states)
103
+ if past_key_value is not None:
104
+ key = torch.cat([past_key_value[0], key], dim=2)
105
+ value = torch.cat([past_key_value[1], value], dim=2)
106
+ present = (key, value)
107
+ past_len = 0 if past_key_value is None else past_key_value[0].shape[2]
108
+ kv_len = key.shape[2]
109
+
110
+ if attention_mask is not None and past_key_value is None:
111
+ sdpa_mask = self._attention_mask(
112
+ attention_mask, sequence_length, hidden_states.device
113
+ )
114
+ is_causal = False
115
+ elif past_key_value is not None and sequence_length == 1:
116
+ sdpa_mask = (
117
+ None
118
+ if attention_mask is None
119
+ else attention_mask[:, None, None, :].to(
120
+ dtype=torch.bool, device=hidden_states.device
121
+ )
122
+ )
123
+ is_causal = False
124
+ elif past_key_value is not None:
125
+ q_idx = torch.arange(
126
+ past_len, past_len + sequence_length, device=hidden_states.device
127
+ )[:, None]
128
+ k_idx = torch.arange(kv_len, device=hidden_states.device)[None, :]
129
+ sdpa_mask = (k_idx <= q_idx)[None, None, :, :]
130
+ is_causal = False
131
+ else:
132
+ sdpa_mask = None
133
+ is_causal = True
134
+
135
+ with sdpa_kernel(attention_sdpa_backends(query.device)):
136
+ attention_output = F.scaled_dot_product_attention(
137
+ query,
138
+ key,
139
+ value,
140
+ attn_mask=sdpa_mask,
141
+ dropout_p=self.dropout if self.training else 0.0,
142
+ is_causal=is_causal,
143
+ )
144
+ attention_output = attention_output.transpose(1, 2).reshape(
145
+ batch_size, sequence_length, -1
146
+ )
147
+ attention_output = attention_output * torch.sigmoid(
148
+ self.output_gate_proj(hidden_states)
149
+ )
150
+ return self.o_proj(attention_output), present
151
+
152
+
153
+ class SiTUGLU(nn.Module):
154
+ """Sigmoid-Tanh Unit GLU from Kimi K3.
155
+
156
+ Caps the Swish linear factor and the up-projection so the routed / deep
157
+ stack cannot explode, while matching SwiGLU near the origin.
158
+ """
159
+
160
+ def __init__(self, config: TinyGDNConfig) -> None:
161
+ super().__init__()
162
+ self.gate_up_proj = nn.Linear(
163
+ config.hidden_size,
164
+ config.intermediate_size * 2,
165
+ bias=False,
166
+ )
167
+ self.down_proj = nn.Linear(
168
+ config.intermediate_size,
169
+ config.hidden_size,
170
+ bias=False,
171
+ )
172
+ self.gate_cap = config.situ_gate_cap
173
+ self.up_cap = config.situ_up_cap
174
+
175
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
176
+ gate_up = self.gate_up_proj(hidden_states)
177
+ return self.down_proj(situ_glu(gate_up, self.gate_cap, self.up_cap))
178
+
179
+
180
+ class BlockAttentionResidual(nn.Module):
181
+ """Selective depth skip over block outputs (Kimi K3 AttnRes, block-level)."""
182
+
183
+ def __init__(self, hidden_size: int) -> None:
184
+ super().__init__()
185
+ self.query = nn.Parameter(torch.zeros(hidden_size))
186
+ self.scale = 1.0 / math.sqrt(hidden_size)
187
+
188
+ def forward(
189
+ self,
190
+ hidden_states: torch.Tensor,
191
+ memories: list[torch.Tensor],
192
+ ) -> torch.Tensor:
193
+ if not memories:
194
+ return hidden_states
195
+ stacked = torch.stack(memories, dim=2)
196
+ scores = torch.einsum("d,bsnd->bsn", self.query, stacked) * self.scale
197
+ weights = torch.softmax(scores, dim=-1)
198
+ mixed = torch.einsum("bsn,bsnd->bsd", weights, stacked)
199
+ return hidden_states + mixed
tiny_gdn/model.py ADDED
@@ -0,0 +1,782 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import torch
9
+ import torch.nn.functional as F
10
+ from safetensors.torch import load_model, save_model
11
+ from torch import nn
12
+ from torch.nn.attention import sdpa_kernel
13
+ from torch.utils.checkpoint import checkpoint
14
+
15
+ from tiny_gdn.config import LINEAR_LAYER_TYPES, TinyGDNConfig
16
+ from tiny_gdn.haiku_layers import (
17
+ BlockAttentionResidual,
18
+ GatedMultiheadLatentAttention,
19
+ SiTUGLU,
20
+ )
21
+ from tiny_gdn.nn_common import (
22
+ RMSNorm,
23
+ attention_sdpa_backends,
24
+ linear_cross_entropy_reference,
25
+ )
26
+
27
+ try:
28
+ from tiny_gdn.fused_ops import linear_cross_entropy
29
+ except ImportError:
30
+ linear_cross_entropy = linear_cross_entropy_reference
31
+
32
+ try:
33
+ # Import the module directly — `from fla.layers import GatedDeltaNet2`
34
+ # executes layers/__init__.py and eagerly loads every attention kernel.
35
+ from fla.layers.gdn2 import GatedDeltaNet2
36
+ except ImportError as import_error:
37
+ GatedDeltaNet2 = None
38
+ FLA_IMPORT_ERROR: ImportError | None = import_error
39
+ else:
40
+ FLA_IMPORT_ERROR = None
41
+
42
+ try:
43
+ from fla.layers.kda import KimiDeltaAttention
44
+ except ImportError as import_error:
45
+ KimiDeltaAttention = None
46
+ KDA_IMPORT_ERROR: ImportError | None = import_error
47
+ else:
48
+ KDA_IMPORT_ERROR = None
49
+
50
+ try:
51
+ from tiny_gdn.kdn import DiagonalKalmanDeltaNet
52
+ except ImportError as import_error:
53
+ DiagonalKalmanDeltaNet = None
54
+ KDN_IMPORT_ERROR: ImportError | None = import_error
55
+ else:
56
+ KDN_IMPORT_ERROR = None
57
+
58
+
59
+ @dataclass
60
+ class TinyGDNOutput:
61
+ loss: torch.Tensor | None
62
+ logits: torch.Tensor | None
63
+ main_loss: torch.Tensor | None
64
+ mtp_loss: torch.Tensor | None
65
+ z_loss: torch.Tensor | None
66
+ hidden_states: torch.Tensor | None = None
67
+ past_key_values: Any | None = None
68
+
69
+
70
+ class RotaryEmbedding(nn.Module):
71
+ def __init__(self, rotary_dim: int, rope_theta: float) -> None:
72
+ super().__init__()
73
+ inverse_frequency = 1.0 / (
74
+ rope_theta
75
+ ** (
76
+ torch.arange(0, rotary_dim, 2, dtype=torch.float32)
77
+ / rotary_dim
78
+ )
79
+ )
80
+ self.rotary_dim = rotary_dim
81
+ self.register_buffer("inverse_frequency", inverse_frequency, persistent=False)
82
+
83
+ def forward(
84
+ self,
85
+ sequence_length: int,
86
+ device: torch.device,
87
+ dtype: torch.dtype,
88
+ position_offset: int = 0,
89
+ ) -> tuple[torch.Tensor, torch.Tensor]:
90
+ positions = torch.arange(
91
+ position_offset,
92
+ position_offset + sequence_length,
93
+ device=device,
94
+ dtype=torch.float32,
95
+ )
96
+ frequencies = torch.outer(positions, self.inverse_frequency.float())
97
+ embeddings = torch.cat((frequencies, frequencies), dim=-1)
98
+ return embeddings.cos().to(dtype=dtype), embeddings.sin().to(dtype=dtype)
99
+
100
+
101
+ def rotate_half(hidden_states: torch.Tensor) -> torch.Tensor:
102
+ first, second = hidden_states.chunk(2, dim=-1)
103
+ return torch.cat((-second, first), dim=-1)
104
+
105
+
106
+ def apply_rotary_embedding(
107
+ query: torch.Tensor,
108
+ key: torch.Tensor,
109
+ cosine: torch.Tensor,
110
+ sine: torch.Tensor,
111
+ rotary_dim: int,
112
+ ) -> tuple[torch.Tensor, torch.Tensor]:
113
+ cosine = cosine[None, None, :, :]
114
+ sine = sine[None, None, :, :]
115
+ query_rotary, query_pass = query[..., :rotary_dim], query[..., rotary_dim:]
116
+ key_rotary, key_pass = key[..., :rotary_dim], key[..., rotary_dim:]
117
+ query_rotary = query_rotary * cosine + rotate_half(query_rotary) * sine
118
+ key_rotary = key_rotary * cosine + rotate_half(key_rotary) * sine
119
+ return (
120
+ torch.cat((query_rotary, query_pass), dim=-1),
121
+ torch.cat((key_rotary, key_pass), dim=-1),
122
+ )
123
+
124
+
125
+ class GatedGroupedQueryAttention(nn.Module):
126
+ """QK-normalized, partially rotary GQA with a learned sigmoid output gate."""
127
+
128
+ def __init__(self, config: TinyGDNConfig) -> None:
129
+ super().__init__()
130
+ self.num_heads = config.num_attention_heads
131
+ self.num_key_value_heads = config.num_key_value_heads
132
+ self.head_dim = config.attention_head_dim
133
+ self.rotary_dim = config.rotary_dim
134
+ self.dropout = config.attention_dropout
135
+
136
+ query_size = self.num_heads * self.head_dim
137
+ key_value_size = self.num_key_value_heads * self.head_dim
138
+ self.q_gate_proj = nn.Linear(config.hidden_size, query_size * 2, bias=False)
139
+ self.k_proj = nn.Linear(config.hidden_size, key_value_size, bias=False)
140
+ self.v_proj = nn.Linear(config.hidden_size, key_value_size, bias=False)
141
+ self.o_proj = nn.Linear(query_size, config.hidden_size, bias=False)
142
+ self.q_norm = RMSNorm(self.head_dim, config.rms_norm_eps)
143
+ self.k_norm = RMSNorm(self.head_dim, config.rms_norm_eps)
144
+ self.rotary = RotaryEmbedding(self.rotary_dim, config.rope_theta)
145
+
146
+ def _attention_mask(
147
+ self,
148
+ attention_mask: torch.Tensor | None,
149
+ sequence_length: int,
150
+ device: torch.device,
151
+ ) -> torch.Tensor | None:
152
+ if attention_mask is None:
153
+ return None
154
+ if attention_mask.ndim != 2:
155
+ raise ValueError("attention_mask must have shape [batch, sequence]")
156
+ if attention_mask.shape[1] != sequence_length:
157
+ raise ValueError("attention_mask sequence length does not match input")
158
+
159
+ causal = torch.ones(
160
+ sequence_length,
161
+ sequence_length,
162
+ dtype=torch.bool,
163
+ device=device,
164
+ ).tril()
165
+ valid_keys = attention_mask[:, None, None, :].to(dtype=torch.bool, device=device)
166
+ return causal[None, None, :, :] & valid_keys
167
+
168
+ def forward(
169
+ self,
170
+ hidden_states: torch.Tensor,
171
+ attention_mask: torch.Tensor | None = None,
172
+ past_key_value: tuple[torch.Tensor, torch.Tensor] | None = None,
173
+ ) -> tuple[torch.Tensor, tuple[torch.Tensor, torch.Tensor] | None]:
174
+ batch_size, sequence_length, _ = hidden_states.shape
175
+ past_len = 0 if past_key_value is None else past_key_value[0].shape[2]
176
+ query_and_gate = self.q_gate_proj(hidden_states)
177
+ query, output_gate = query_and_gate.chunk(2, dim=-1)
178
+
179
+ query = query.view(batch_size, sequence_length, self.num_heads, self.head_dim)
180
+ key = self.k_proj(hidden_states).view(
181
+ batch_size,
182
+ sequence_length,
183
+ self.num_key_value_heads,
184
+ self.head_dim,
185
+ )
186
+ value = self.v_proj(hidden_states).view(
187
+ batch_size,
188
+ sequence_length,
189
+ self.num_key_value_heads,
190
+ self.head_dim,
191
+ )
192
+
193
+ query = self.q_norm(query).transpose(1, 2)
194
+ key = self.k_norm(key).transpose(1, 2)
195
+ value = value.transpose(1, 2)
196
+
197
+ cosine, sine = self.rotary(
198
+ sequence_length,
199
+ device=hidden_states.device,
200
+ dtype=query.dtype,
201
+ position_offset=past_len,
202
+ )
203
+ query, key = apply_rotary_embedding(
204
+ query,
205
+ key,
206
+ cosine,
207
+ sine,
208
+ rotary_dim=self.rotary_dim,
209
+ )
210
+ if past_key_value is not None:
211
+ key = torch.cat([past_key_value[0], key], dim=2)
212
+ value = torch.cat([past_key_value[1], value], dim=2)
213
+ present = (key, value)
214
+
215
+ kv_len = key.shape[2]
216
+ if attention_mask is not None and past_key_value is None:
217
+ sdpa_mask = self._attention_mask(
218
+ attention_mask,
219
+ sequence_length,
220
+ hidden_states.device,
221
+ )
222
+ is_causal = False
223
+ elif past_key_value is not None and sequence_length == 1:
224
+ # Decode step: query attends to the cached key/value prefix. Preserve
225
+ # the prefill padding mask when decoding a left-padded prompt batch.
226
+ sdpa_mask = (
227
+ None
228
+ if attention_mask is None
229
+ else attention_mask[:, None, None, :].to(
230
+ dtype=torch.bool,
231
+ device=hidden_states.device,
232
+ )
233
+ )
234
+ is_causal = False
235
+ elif past_key_value is not None:
236
+ # Prefill chunk with cache — build causal mask over kv_len.
237
+ q_idx = torch.arange(
238
+ past_len, past_len + sequence_length, device=hidden_states.device
239
+ )[:, None]
240
+ k_idx = torch.arange(kv_len, device=hidden_states.device)[None, :]
241
+ sdpa_mask = (k_idx <= q_idx)[None, None, :, :]
242
+ is_causal = False
243
+ else:
244
+ sdpa_mask = None
245
+ is_causal = True
246
+ sdpa_options = {
247
+ "attn_mask": sdpa_mask,
248
+ "dropout_p": self.dropout if self.training else 0.0,
249
+ "is_causal": is_causal,
250
+ "enable_gqa": True,
251
+ }
252
+ with sdpa_kernel(attention_sdpa_backends(query.device)):
253
+ attention_output = F.scaled_dot_product_attention(
254
+ query,
255
+ key,
256
+ value,
257
+ **sdpa_options,
258
+ )
259
+ attention_output = attention_output.transpose(1, 2).reshape(
260
+ batch_size,
261
+ sequence_length,
262
+ -1,
263
+ )
264
+ attention_output = attention_output * torch.sigmoid(output_gate)
265
+ return self.o_proj(attention_output), present
266
+
267
+
268
+ class SwiGLU(nn.Module):
269
+ def __init__(self, config: TinyGDNConfig) -> None:
270
+ super().__init__()
271
+ self.gate_up_proj = nn.Linear(
272
+ config.hidden_size,
273
+ config.intermediate_size * 2,
274
+ bias=False,
275
+ )
276
+ self.down_proj = nn.Linear(
277
+ config.intermediate_size,
278
+ config.hidden_size,
279
+ bias=False,
280
+ )
281
+
282
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
283
+ gate, up = self.gate_up_proj(hidden_states).chunk(2, dim=-1)
284
+ return self.down_proj(F.silu(gate) * up)
285
+
286
+
287
+ class TinyGDNBlock(nn.Module):
288
+ def __init__(self, config: TinyGDNConfig, layer_index: int) -> None:
289
+ super().__init__()
290
+ layer_type = config.layer_types[layer_index]
291
+ self.layer_type = layer_type
292
+ self.token_mixer_norm = RMSNorm(config.hidden_size, config.rms_norm_eps)
293
+ self.mlp_norm = RMSNorm(config.hidden_size, config.rms_norm_eps)
294
+
295
+ if layer_type == "gdn2":
296
+ if GatedDeltaNet2 is None:
297
+ raise ImportError(
298
+ "Gated DeltaNet-2 requires the pinned flash-linear-attention dependency"
299
+ ) from FLA_IMPORT_ERROR
300
+ self.token_mixer = GatedDeltaNet2(
301
+ hidden_size=config.hidden_size,
302
+ expand_v=config.linear_expand_v,
303
+ head_dim=config.linear_head_dim,
304
+ num_heads=config.linear_num_heads,
305
+ num_v_heads=config.linear_num_value_heads,
306
+ mode="chunk",
307
+ use_short_conv=True,
308
+ allow_neg_eigval=config.allow_negative_eigenvalues,
309
+ conv_size=config.linear_conv_kernel_dim,
310
+ conv_bias=False,
311
+ layer_idx=layer_index,
312
+ norm_eps=config.rms_norm_eps,
313
+ )
314
+ elif layer_type == "kda":
315
+ if KimiDeltaAttention is None:
316
+ raise ImportError(
317
+ "Kimi Delta Attention requires the pinned flash-linear-attention dependency"
318
+ ) from KDA_IMPORT_ERROR
319
+ self.token_mixer = KimiDeltaAttention(
320
+ hidden_size=config.hidden_size,
321
+ expand_v=config.linear_expand_v,
322
+ head_dim=config.linear_head_dim,
323
+ num_heads=config.linear_num_heads,
324
+ num_v_heads=config.linear_num_value_heads,
325
+ mode="chunk",
326
+ use_short_conv=True,
327
+ allow_neg_eigval=config.allow_negative_eigenvalues,
328
+ safe_gate=config.kda_safe_gate,
329
+ lower_bound=config.kda_lower_bound,
330
+ conv_size=config.linear_conv_kernel_dim,
331
+ conv_bias=False,
332
+ layer_idx=layer_index,
333
+ norm_eps=config.rms_norm_eps,
334
+ )
335
+ elif layer_type == "diag_kdn":
336
+ if DiagonalKalmanDeltaNet is None:
337
+ raise ImportError(
338
+ "Diagonal KDN requires Triton and the pinned flash-linear-attention dependency"
339
+ ) from KDN_IMPORT_ERROR
340
+ self.token_mixer = DiagonalKalmanDeltaNet(
341
+ hidden_size=config.hidden_size,
342
+ head_dim=config.linear_head_dim,
343
+ num_heads=config.linear_num_heads,
344
+ conv_size=config.linear_conv_kernel_dim,
345
+ norm_eps=config.rms_norm_eps,
346
+ layer_idx=layer_index,
347
+ )
348
+ elif layer_type == "full_attention":
349
+ self.token_mixer = GatedGroupedQueryAttention(config)
350
+ elif layer_type == "gated_mla":
351
+ self.token_mixer = GatedMultiheadLatentAttention(config)
352
+ else:
353
+ raise ValueError(f"Unsupported layer type: {layer_type}")
354
+
355
+ if config.mlp_activation == "swiglu":
356
+ self.mlp: nn.Module = SwiGLU(config)
357
+ elif config.mlp_activation == "situ_glu":
358
+ self.mlp = SiTUGLU(config)
359
+ else:
360
+ raise ValueError(f"Unsupported mlp_activation: {config.mlp_activation}")
361
+
362
+ def forward(
363
+ self,
364
+ hidden_states: torch.Tensor,
365
+ attention_mask: torch.Tensor | None = None,
366
+ *,
367
+ past_key_values: Any | None = None,
368
+ past_key_value: tuple[torch.Tensor, torch.Tensor] | None = None,
369
+ use_cache: bool = False,
370
+ ) -> tuple[torch.Tensor, Any]:
371
+ residual = hidden_states
372
+ normalized = self.token_mixer_norm(hidden_states)
373
+ present: Any = None
374
+ if self.layer_type in LINEAR_LAYER_TYPES:
375
+ mixed, _, past_key_values = self.token_mixer(
376
+ normalized,
377
+ attention_mask=attention_mask,
378
+ past_key_values=past_key_values,
379
+ use_cache=use_cache,
380
+ )
381
+ present = past_key_values
382
+ elif self.layer_type in {"full_attention", "gated_mla"}:
383
+ mixed, present = self.token_mixer(
384
+ normalized,
385
+ attention_mask=attention_mask,
386
+ past_key_value=past_key_value,
387
+ )
388
+ if not use_cache:
389
+ present = None
390
+ else:
391
+ raise ValueError(f"Unsupported layer type: {self.layer_type}")
392
+ hidden_states = residual + mixed
393
+ hidden_states = hidden_states + self.mlp(self.mlp_norm(hidden_states))
394
+ return hidden_states, present
395
+
396
+
397
+ class MultiTokenPredictionAdapter(nn.Module):
398
+ """A lightweight residual adapter for one additional prediction horizon."""
399
+
400
+ def __init__(self, config: TinyGDNConfig) -> None:
401
+ super().__init__()
402
+ self.norm = RMSNorm(config.hidden_size, config.rms_norm_eps)
403
+ self.down_proj = nn.Linear(
404
+ config.hidden_size,
405
+ config.mtp_adapter_rank,
406
+ bias=False,
407
+ )
408
+ self.up_proj = nn.Linear(
409
+ config.mtp_adapter_rank,
410
+ config.hidden_size,
411
+ bias=False,
412
+ )
413
+
414
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
415
+ adapted = self.up_proj(F.silu(self.down_proj(self.norm(hidden_states))))
416
+ return hidden_states + adapted
417
+
418
+
419
+ class TinyGDNForCausalLM(nn.Module):
420
+ def __init__(self, config: TinyGDNConfig) -> None:
421
+ super().__init__()
422
+ self.config = config
423
+ self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size)
424
+ self.layers = nn.ModuleList(
425
+ TinyGDNBlock(config, layer_index)
426
+ for layer_index in range(config.num_hidden_layers)
427
+ )
428
+ self.final_norm = RMSNorm(config.hidden_size, config.rms_norm_eps)
429
+ self.mtp_adapters = nn.ModuleList(
430
+ MultiTokenPredictionAdapter(config)
431
+ for _ in range(config.mtp_num_heads)
432
+ )
433
+ self.block_attn_res = (
434
+ BlockAttentionResidual(config.hidden_size)
435
+ if config.use_block_attn_res
436
+ else None
437
+ )
438
+ self.checkpointed_layers: frozenset[int] = frozenset()
439
+
440
+ self.apply(self._initialize_module)
441
+ self._initialize_residual_projections()
442
+
443
+ def _initialize_module(self, module: nn.Module) -> None:
444
+ if isinstance(module, nn.Linear):
445
+ nn.init.normal_(
446
+ module.weight,
447
+ mean=0.0,
448
+ std=self.config.initializer_range,
449
+ )
450
+ if module.bias is not None:
451
+ nn.init.zeros_(module.bias)
452
+ elif isinstance(module, nn.Embedding):
453
+ nn.init.normal_(
454
+ module.weight,
455
+ mean=0.0,
456
+ std=self.config.initializer_range,
457
+ )
458
+ elif DiagonalKalmanDeltaNet is not None and isinstance(
459
+ module, DiagonalKalmanDeltaNet
460
+ ):
461
+ # apply() visits children first, so this runs after the Linear re-init.
462
+ module.reset_noise_parameters()
463
+
464
+ def _initialize_residual_projections(self) -> None:
465
+ residual_std = self.config.initializer_range / math.sqrt(
466
+ 2 * self.config.num_hidden_layers
467
+ )
468
+ for layer in self.layers:
469
+ nn.init.normal_(
470
+ layer.token_mixer.o_proj.weight,
471
+ mean=0.0,
472
+ std=residual_std,
473
+ )
474
+ nn.init.normal_(
475
+ layer.mlp.down_proj.weight,
476
+ mean=0.0,
477
+ std=residual_std,
478
+ )
479
+ for adapter in self.mtp_adapters:
480
+ nn.init.normal_(adapter.up_proj.weight, mean=0.0, std=residual_std)
481
+
482
+ def enable_gradient_checkpointing(
483
+ self,
484
+ enabled: bool = True,
485
+ *,
486
+ layers: int | None = None,
487
+ ) -> None:
488
+ """Recompute block activations during backward instead of storing them.
489
+
490
+ ``layers`` checkpoints only that many blocks, linear-attention blocks
491
+ first: replaying softmax attention costs quadratically in sequence
492
+ length, replaying a linear-attention block costs linearly. ``None``
493
+ checkpoints every block.
494
+ """
495
+
496
+ if not enabled:
497
+ if layers is not None:
498
+ raise ValueError("layers needs gradient checkpointing enabled")
499
+ self.checkpointed_layers = frozenset()
500
+ return
501
+ count = len(self.layers) if layers is None else layers
502
+ if not 1 <= count <= len(self.layers):
503
+ raise ValueError(f"layers must be between 1 and {len(self.layers)}")
504
+ cheapest_replay_first = sorted(
505
+ range(len(self.layers)),
506
+ key=lambda index: self.layers[index].layer_type not in LINEAR_LAYER_TYPES,
507
+ )
508
+ self.checkpointed_layers = frozenset(cheapest_replay_first[:count])
509
+
510
+ def describe_gradient_checkpointing(self) -> str:
511
+ if not self.checkpointed_layers:
512
+ return "off"
513
+ layer_types = sorted(
514
+ {self.layers[index].layer_type for index in self.checkpointed_layers}
515
+ )
516
+ return (
517
+ f"{len(self.checkpointed_layers)}/{len(self.layers)} blocks "
518
+ f"({', '.join(layer_types)})"
519
+ )
520
+
521
+ def project_to_vocabulary(self, hidden_states: torch.Tensor) -> torch.Tensor:
522
+ return F.linear(hidden_states, self.embed_tokens.weight)
523
+
524
+ def _run_layer(
525
+ self,
526
+ layer_index: int,
527
+ hidden_states: torch.Tensor,
528
+ attention_mask: torch.Tensor | None,
529
+ *,
530
+ past_key_values: Any | None = None,
531
+ past_key_value: tuple[torch.Tensor, torch.Tensor] | None = None,
532
+ use_cache: bool = False,
533
+ ) -> tuple[torch.Tensor, Any]:
534
+ layer = self.layers[layer_index]
535
+ if self.training and layer_index in self.checkpointed_layers:
536
+ hidden_states, present = checkpoint(
537
+ layer,
538
+ hidden_states,
539
+ attention_mask,
540
+ use_reentrant=False,
541
+ )
542
+ return hidden_states, present
543
+ return layer(
544
+ hidden_states,
545
+ attention_mask,
546
+ past_key_values=past_key_values,
547
+ past_key_value=past_key_value,
548
+ use_cache=use_cache,
549
+ )
550
+
551
+ def _causal_loss(
552
+ self,
553
+ hidden_states: torch.Tensor,
554
+ labels: torch.Tensor,
555
+ target_offset: int,
556
+ adapter: nn.Module | None = None,
557
+ z_loss_coefficient: float = 0.0,
558
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
559
+ """Return ``(objective, cross_entropy, z_loss)``.
560
+
561
+ Only ``objective = cross_entropy + z_loss_coefficient * z_loss`` carries
562
+ gradients; the other two are detached values for logging.
563
+ """
564
+
565
+ if target_offset < 0:
566
+ raise ValueError("target_offset cannot be negative")
567
+ if target_offset and hidden_states.shape[1] <= target_offset:
568
+ raise ValueError(
569
+ f"Sequence length must exceed target offset {target_offset}"
570
+ )
571
+ if target_offset:
572
+ prediction_states = hidden_states[:, :-target_offset, :]
573
+ targets = labels[:, target_offset:].contiguous()
574
+ else:
575
+ prediction_states = hidden_states
576
+ targets = labels.contiguous()
577
+ if adapter is not None:
578
+ prediction_states = adapter(prediction_states)
579
+ return linear_cross_entropy(
580
+ prediction_states.reshape(-1, prediction_states.shape[-1]),
581
+ self.embed_tokens.weight,
582
+ targets.reshape(-1),
583
+ z_loss_coefficient=z_loss_coefficient,
584
+ )
585
+
586
+ def forward(
587
+ self,
588
+ input_ids: torch.Tensor,
589
+ labels: torch.Tensor | None = None,
590
+ attention_mask: torch.Tensor | None = None,
591
+ past_key_values: Any | None = None,
592
+ *,
593
+ use_cache: bool = False,
594
+ return_logits: bool = True,
595
+ return_hidden_states: bool = False,
596
+ labels_are_shifted: bool = False,
597
+ include_mtp_loss: bool = True,
598
+ mtp_loss_weight: float | None = None,
599
+ z_loss_coefficient: float = 0.0,
600
+ logits_to_keep: int | None = None,
601
+ ) -> TinyGDNOutput:
602
+ if input_ids.ndim != 2:
603
+ raise ValueError("input_ids must have shape [batch, sequence]")
604
+ if input_ids.shape[1] > self.config.max_position_embeddings:
605
+ raise ValueError("Input exceeds max_position_embeddings")
606
+ if labels is not None and labels.shape != input_ids.shape:
607
+ raise ValueError("labels must have the same shape as input_ids")
608
+ if z_loss_coefficient < 0.0:
609
+ raise ValueError("z_loss_coefficient cannot be negative")
610
+ if logits_to_keep is not None and logits_to_keep <= 0:
611
+ raise ValueError("logits_to_keep must be positive")
612
+ if use_cache and labels is not None:
613
+ raise ValueError("use_cache is not supported with labels")
614
+ effective_mtp_weight = (
615
+ self.config.mtp_loss_weight
616
+ if mtp_loss_weight is None
617
+ else mtp_loss_weight
618
+ )
619
+ if not 0.0 <= effective_mtp_weight <= 1.0:
620
+ raise ValueError("mtp_loss_weight must be between zero and one")
621
+
622
+ if use_cache and past_key_values is None:
623
+ try:
624
+ from fla.models.utils import Cache as FlaCache
625
+ except ImportError as import_error:
626
+ raise ImportError(
627
+ "Cached decode requires flash-linear-attention Cache"
628
+ ) from import_error
629
+ past_key_values = {
630
+ "fla": FlaCache(),
631
+ "gqa": [None] * len(self.layers),
632
+ }
633
+ elif past_key_values is not None and not isinstance(past_key_values, dict):
634
+ raise TypeError("past_key_values must be a TinyGDN cache dict or None")
635
+
636
+ fla_cache = None if past_key_values is None else past_key_values["fla"]
637
+ gqa_cache = None if past_key_values is None else past_key_values["gqa"]
638
+
639
+ hidden_states = self.embed_tokens(input_ids)
640
+ shared_layer_indices = set(self.config.shared_layer_indices)
641
+ softmax_types = {"full_attention", "gated_mla"}
642
+ attn_memories: list[torch.Tensor] = [hidden_states]
643
+ interval = self.config.full_attention_interval
644
+ for layer_index, layer in enumerate(self.layers):
645
+ layer_gqa = None if gqa_cache is None else gqa_cache[layer_index]
646
+ hidden_states, present = self._run_layer(
647
+ layer_index,
648
+ hidden_states,
649
+ attention_mask,
650
+ past_key_values=fla_cache,
651
+ past_key_value=layer_gqa,
652
+ use_cache=use_cache,
653
+ )
654
+ if use_cache and layer.layer_type in softmax_types and gqa_cache is not None:
655
+ gqa_cache[layer_index] = present
656
+ if layer_index in shared_layer_indices:
657
+ layer_gqa = None if gqa_cache is None else gqa_cache[layer_index]
658
+ hidden_states, present = self._run_layer(
659
+ layer_index,
660
+ hidden_states,
661
+ attention_mask,
662
+ past_key_values=fla_cache,
663
+ past_key_value=layer_gqa,
664
+ use_cache=use_cache,
665
+ )
666
+ if use_cache and layer.layer_type in softmax_types and gqa_cache is not None:
667
+ gqa_cache[layer_index] = present
668
+ if (
669
+ self.block_attn_res is not None
670
+ and (layer_index + 1) % interval == 0
671
+ ):
672
+ hidden_states = self.block_attn_res(
673
+ hidden_states, [*attn_memories, hidden_states]
674
+ )
675
+ attn_memories.append(hidden_states)
676
+ hidden_states = self.final_norm(hidden_states)
677
+
678
+ main_loss = None
679
+ mtp_loss = None
680
+ z_loss = None
681
+ total_loss = None
682
+ if labels is not None:
683
+ main_target_offset = 0 if labels_are_shifted else 1
684
+ main_objective, main_loss, z_loss = self._causal_loss(
685
+ hidden_states,
686
+ labels,
687
+ target_offset=main_target_offset,
688
+ z_loss_coefficient=z_loss_coefficient,
689
+ )
690
+ if z_loss_coefficient == 0.0:
691
+ z_loss = None
692
+ if self.mtp_adapters and include_mtp_loss:
693
+ auxiliary_losses = [
694
+ self._causal_loss(
695
+ hidden_states,
696
+ labels,
697
+ target_offset=(
698
+ head_index + 1
699
+ if labels_are_shifted
700
+ else head_index + 2
701
+ ),
702
+ adapter=adapter,
703
+ )[0]
704
+ for head_index, adapter in enumerate(self.mtp_adapters)
705
+ ]
706
+ mtp_loss = torch.stack(auxiliary_losses).mean()
707
+ total_loss = main_objective + effective_mtp_weight * mtp_loss
708
+ else:
709
+ total_loss = main_objective
710
+
711
+ output_states = (
712
+ hidden_states
713
+ if logits_to_keep is None
714
+ else hidden_states[:, -logits_to_keep:, :]
715
+ )
716
+ logits = self.project_to_vocabulary(output_states) if return_logits else None
717
+ return TinyGDNOutput(
718
+ loss=total_loss,
719
+ logits=logits,
720
+ main_loss=main_loss,
721
+ mtp_loss=mtp_loss,
722
+ z_loss=z_loss,
723
+ hidden_states=hidden_states if return_hidden_states else None,
724
+ past_key_values=past_key_values if use_cache else None,
725
+ )
726
+
727
+ def parameter_report(self) -> dict[str, int]:
728
+ total = sum(parameter.numel() for parameter in self.parameters())
729
+ mtp = sum(parameter.numel() for parameter in self.mtp_adapters.parameters())
730
+ embeddings = self.embed_tokens.weight.numel()
731
+ return {
732
+ "deployable_core": total - mtp,
733
+ "training_total": total,
734
+ "embedding": embeddings,
735
+ "mtp_auxiliary": mtp,
736
+ "non_embedding_core": total - mtp - embeddings,
737
+ }
738
+
739
+ def save_checkpoint(self, output_dir: Path) -> None:
740
+ output_dir.mkdir(parents=True, exist_ok=True)
741
+ self.config.save_json(output_dir / "config.json")
742
+ save_model(self, output_dir / "model.safetensors")
743
+
744
+ @classmethod
745
+ def from_checkpoint(
746
+ cls,
747
+ checkpoint_dir: Path,
748
+ *,
749
+ device: str | torch.device = "cpu",
750
+ dtype: torch.dtype | None = None,
751
+ ) -> TinyGDNForCausalLM:
752
+ config = TinyGDNConfig.from_json(checkpoint_dir / "config.json")
753
+ model = cls(config).to(device=device, dtype=dtype)
754
+ load_model(model, checkpoint_dir / "model.safetensors", device=str(device))
755
+ return model
756
+
757
+ def extra_repr(self) -> str:
758
+ report = self.parameter_report()
759
+ return (
760
+ f"core_parameters={report['deployable_core']:,}, "
761
+ f"training_parameters={report['training_total']:,}"
762
+ )
763
+
764
+ def get_architecture_metadata(self) -> dict[str, Any]:
765
+ return {
766
+ "architecture": self.config.architecture,
767
+ "layer_types": list(self.config.layer_types),
768
+ "effective_num_layers": self.config.effective_num_layers,
769
+ "shared_layer_indices": list(self.config.shared_layer_indices),
770
+ "parameter_report": self.parameter_report(),
771
+ "features": [
772
+ "hybrid linear + softmax token mixing",
773
+ "Kimi Delta Attention or Gated DeltaNet-2",
774
+ "gated MLA or gated GQA",
775
+ "optional block Attention Residuals",
776
+ "SwiGLU or SiTU-GLU",
777
+ "QK normalization",
778
+ "zero-centered RMSNorm",
779
+ "tied input-output embeddings",
780
+ "optional multi-token prediction auxiliaries",
781
+ ],
782
+ }
tiny_gdn/nn_common.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ import torch.nn.functional as F
5
+ from torch import nn
6
+ from torch.nn.attention import SDPBackend
7
+
8
+ try:
9
+ from fla.modules.layernorm import rms_norm as fused_rms_norm
10
+ except ImportError:
11
+ fused_rms_norm = None
12
+
13
+
14
+ class RMSNorm(nn.Module):
15
+ """Zero-centered RMSNorm as used by Qwen3-Next.
16
+
17
+ On CUDA this runs FLA's single-pass Triton kernel with the ``1 + weight``
18
+ scale kept in FP32, which matches the eager FP32 math to rounding.
19
+ """
20
+
21
+ def __init__(self, hidden_size: int, eps: float) -> None:
22
+ super().__init__()
23
+ self.weight = nn.Parameter(torch.zeros(hidden_size))
24
+ self.eps = eps
25
+
26
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
27
+ scale = 1.0 + self.weight.float()
28
+ if fused_rms_norm is not None and hidden_states.is_cuda:
29
+ return fused_rms_norm(hidden_states, scale, None, eps=self.eps)
30
+ input_dtype = hidden_states.dtype
31
+ normalized = hidden_states.float()
32
+ normalized = normalized * torch.rsqrt(
33
+ normalized.square().mean(dim=-1, keepdim=True) + self.eps
34
+ )
35
+ return (normalized * scale).to(dtype=input_dtype)
36
+
37
+
38
+ def linear_cross_entropy_reference(
39
+ states: torch.Tensor,
40
+ weight: torch.Tensor,
41
+ targets: torch.Tensor,
42
+ *,
43
+ z_loss_coefficient: float = 0.0,
44
+ ignore_index: int = -100,
45
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
46
+ """Eager ``(objective, cross_entropy, z_loss)`` over materialized logits."""
47
+
48
+ logits = F.linear(states, weight)
49
+ cross_entropy = F.cross_entropy(logits, targets, ignore_index=ignore_index)
50
+ reduced_dtype = torch.promote_types(logits.dtype, torch.float32)
51
+ if z_loss_coefficient > 0.0:
52
+ # Keep logits in their training dtype for logsumexp. Casting the full
53
+ # [tokens, vocab] tensor to fp32 materializes it again in the autograd
54
+ # graph; upcast only the reduced [tokens] partition.
55
+ log_partition = torch.logsumexp(logits, dim=-1).to(reduced_dtype)
56
+ z_loss = log_partition.square()[targets.ne(ignore_index)].mean()
57
+ else:
58
+ z_loss = cross_entropy.new_zeros((), dtype=reduced_dtype)
59
+ objective = cross_entropy + z_loss_coefficient * z_loss
60
+ return objective, cross_entropy.detach(), z_loss.detach()
61
+
62
+
63
+ def linear_token_logprobs_reference(
64
+ states: torch.Tensor,
65
+ weight: torch.Tensor,
66
+ targets: torch.Tensor,
67
+ *,
68
+ ignore_index: int = -100,
69
+ ) -> torch.Tensor:
70
+ """Eager fp32 ``log_softmax(states @ weight.T)[row, target]``; ignored rows are 0."""
71
+
72
+ valid = targets.ne(ignore_index)
73
+ logprobs = F.linear(states, weight).float().log_softmax(dim=-1)
74
+ picked = logprobs.gather(1, targets.masked_fill(~valid, 0)[:, None]).squeeze(1)
75
+ return picked.masked_fill(~valid, 0.0)
76
+
77
+
78
+ def softcap(values: torch.Tensor, cap: float) -> torch.Tensor:
79
+ return cap * torch.tanh(values / cap)
80
+
81
+
82
+ def situ_glu_reference(gate_up: torch.Tensor, gate_cap: float, up_cap: float) -> torch.Tensor:
83
+ """``softcap(gate) * sigmoid(gate) * softcap(up)`` over ``[..., gate | up]``."""
84
+
85
+ gate, up = gate_up.chunk(2, dim=-1)
86
+ return softcap(gate, gate_cap) * torch.sigmoid(gate) * softcap(up, up_cap)
87
+
88
+
89
+ def attention_sdpa_backends(device: torch.device) -> list[SDPBackend]:
90
+ if device.type != "cuda":
91
+ return [SDPBackend.MATH]
92
+ return [
93
+ SDPBackend.CUDNN_ATTENTION,
94
+ SDPBackend.FLASH_ATTENTION,
95
+ SDPBackend.EFFICIENT_ATTENTION,
96
+ ]
tiny_gdn/smoltalk_chat.py ADDED
@@ -0,0 +1,676 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SmolTalk2 / SmolLM3 chat-template contract for Tercet-R.
2
+
3
+ The SFT subset stores the toggle and tools in `chat_template_kwargs`:
4
+
5
+ - `enable_thinking`: True → assistant turns receive a zero-loss
6
+ `<|think|>\\n` control prefix; assistant targets may emit `<think>` naturally
7
+ - `enable_thinking`: False → assistant turns receive a zero-loss
8
+ `<|no_think|>\\n` control prefix
9
+ - `custom_instructions`: the system prompt, used verbatim. Only the SmolLM3
10
+ template directives `/think`, `/no_think`, and `/system_override` are
11
+ removed because they are control markers, not prompt text
12
+ - `xml_tools` / `python_tools`: appended to the system prompt as a Hermes
13
+ `<tools>` block only when tools are present and the prompt does not already
14
+ carry one
15
+ - `tool_format` (inference only): "auto" and "json" render Hermes JSON
16
+ `<tool_call>` blocks, the layout the served models were trained on. "xml"
17
+ is Cascade's OpenHands layout and is used only when a request asks for it
18
+
19
+ There is no default identity, metadata header, or wrapper: a conversation
20
+ without a system prompt and without tools has no system turn at all.
21
+
22
+ Tool results (`tool` / `function` roles, or text still wrapped in Nemotron
23
+ `<tool_response>` XML) are rendered as a user turn where every result is
24
+ prefixed with the `<|tool_response|>` special token.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import re
31
+ from dataclasses import dataclass, replace
32
+ from typing import Any, Literal
33
+
34
+ from tiny_gdn.tools import (
35
+ json_ready,
36
+ TOOL_RESPONSE_CLOSE,
37
+ TOOL_RESPONSE_OPEN,
38
+ TOOLS_CLOSE,
39
+ TOOLS_OPEN,
40
+ assistant_message_content,
41
+ coerce_tools,
42
+ format_tool_calls_xml,
43
+ loads_json,
44
+ message_text,
45
+ parse_arguments_payload,
46
+ render_available_tools,
47
+ strip_leaked_chatml,
48
+ )
49
+
50
+
51
+ ReasoningMode = Literal["/think", "/no_think"]
52
+ ToolFormat = Literal["json", "xml"]
53
+ ToolFormatChoice = Literal["auto", "json", "xml"]
54
+
55
+ NO_THINK_TOKEN = "<|no_think|>"
56
+ NO_THINK_PREFIX = f"{NO_THINK_TOKEN}\n"
57
+ THINK_CONTROL_TOKEN = "<|think|>"
58
+ THINK_CONTROL_PREFIX = f"{THINK_CONTROL_TOKEN}\n"
59
+ TOOL_RESPONSE_TOKEN = "<|tool_response|>"
60
+ TOOL_RESPONSE_PREFIX = f"{TOOL_RESPONSE_TOKEN}\n"
61
+ THINK_OPEN = "<think>"
62
+ THINK_CLOSE = "</think>"
63
+ TOOL_CALL_OPEN = "<tool_call>"
64
+ TOOL_CALL_CLOSE = "</tool_call>"
65
+ SYSTEM_OVERRIDE_MARK = "/system_override"
66
+ THINK_MARK = "/think"
67
+ NO_THINK_MARK = "/no_think"
68
+
69
+ # SmolTalk2 SFT used this markdown web_search schema. Tercet-R stage-3
70
+ # packs Nemotron Agentic-v2 search instead (`web-search` + Tavily JSON);
71
+ # see tiny_gdn.web_search.NEMOTRON_WEB_SEARCH_TOOL.
72
+ SMOLTALK_WEB_SEARCH_TOOL = {
73
+ "type": "function",
74
+ "function": {
75
+ "name": "web_search",
76
+ "description": (
77
+ "Performs a web search for a query and returns a string of the "
78
+ "top search results formatted as markdown with titles, links, "
79
+ "and descriptions."
80
+ ),
81
+ "parameters": {
82
+ "type": "object",
83
+ "properties": {
84
+ "query": {
85
+ "type": "string",
86
+ "description": "The search query to perform.",
87
+ }
88
+ },
89
+ "required": ["query"],
90
+ },
91
+ },
92
+ }
93
+ XML_TOOLS_PREAMBLE = (
94
+ "You may call one or more functions to assist with the user query.\n"
95
+ "You are provided with function signatures within <tools></tools> XML "
96
+ "tags:\n<tools>\n"
97
+ )
98
+ XML_TOOLS_EPILOGUE = (
99
+ "</tools>\n\n"
100
+ "For each function call, return a json object with function name and "
101
+ "arguments within <tool_call></tool_call> XML tags:\n"
102
+ "<tool_call>\n"
103
+ '{"name": <function-name>, "arguments": <args-json-object>}\n'
104
+ "</tool_call>"
105
+ )
106
+ PYTHON_TOOLS_PREAMBLE = (
107
+ "When you send a message containing Python code between '<code>' and "
108
+ "'</code>' tags, it will be executed in a stateful Jupyter notebook "
109
+ "environment, and you will then be given the output to continued "
110
+ "reasoning in an agentic loop.\n\n"
111
+ "You can use the following tools in your python code like regular "
112
+ "functions:\n<tools>\n"
113
+ )
114
+ PYTHON_TOOLS_EPILOGUE = (
115
+ "</tools>\n\n"
116
+ "The state persists between code executions: so variables that you "
117
+ "define in one step are still available thereafter."
118
+ )
119
+ _EMPTY_THINK_BLOCK = re.compile(
120
+ re.escape(THINK_OPEN) + r"\s*" + re.escape(THINK_CLOSE)
121
+ )
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class ChatTemplateKwargs:
126
+ custom_instructions: str = ""
127
+ enable_thinking: bool = True
128
+ xml_tools: tuple[str, ...] = ()
129
+ python_tools: tuple[str, ...] = ()
130
+ tool_format: ToolFormat = "json"
131
+
132
+
133
+ def coerce_tool_strings(raw_tools: Any) -> tuple[str, ...]:
134
+ if raw_tools is None:
135
+ return ()
136
+ if isinstance(raw_tools, str):
137
+ text = raw_tools.strip()
138
+ return (text,) if text else ()
139
+ if isinstance(raw_tools, dict):
140
+ raw_tools = [raw_tools]
141
+ if not isinstance(raw_tools, list):
142
+ return ()
143
+ rendered: list[str] = []
144
+ for item in raw_tools:
145
+ if isinstance(item, str):
146
+ text = item.strip()
147
+ if text:
148
+ rendered.append(text)
149
+ continue
150
+ if isinstance(item, dict):
151
+ rendered.append(json.dumps(item, ensure_ascii=False))
152
+ return tuple(rendered)
153
+
154
+
155
+ def parse_chat_template_kwargs(raw: Any) -> ChatTemplateKwargs:
156
+ if raw is None:
157
+ return ChatTemplateKwargs()
158
+ if isinstance(raw, str):
159
+ text = raw.strip()
160
+ if not text:
161
+ return ChatTemplateKwargs()
162
+ raw = json.loads(text)
163
+ if not isinstance(raw, dict):
164
+ return ChatTemplateKwargs()
165
+ enable = raw.get("enable_thinking")
166
+ if enable is None:
167
+ enable_thinking = True
168
+ elif isinstance(enable, bool):
169
+ enable_thinking = enable
170
+ else:
171
+ enable_thinking = str(enable).strip().lower() in {"1", "true", "yes", "on"}
172
+ custom = raw.get("custom_instructions")
173
+ return ChatTemplateKwargs(
174
+ custom_instructions=custom if isinstance(custom, str) else "",
175
+ enable_thinking=enable_thinking,
176
+ xml_tools=coerce_tool_strings(raw.get("xml_tools")),
177
+ python_tools=coerce_tool_strings(raw.get("python_tools")),
178
+ )
179
+
180
+
181
+ def resolve_reasoning_mode(
182
+ enable_thinking: bool,
183
+ custom_instructions: str,
184
+ ) -> ReasoningMode:
185
+ if NO_THINK_MARK in custom_instructions:
186
+ return "/no_think"
187
+ if THINK_MARK in custom_instructions:
188
+ return "/think"
189
+ return "/think" if enable_thinking else "/no_think"
190
+
191
+
192
+ def strip_template_markers(custom_instructions: str) -> str:
193
+ return (
194
+ custom_instructions.replace(NO_THINK_MARK, "")
195
+ .replace(THINK_MARK, "")
196
+ .replace(SYSTEM_OVERRIDE_MARK, "")
197
+ .strip()
198
+ )
199
+
200
+
201
+ def strip_tools_wrapper(item: str) -> str:
202
+ """Drop a `<tools>` / `</tools>` wrapper some sources bake into each entry."""
203
+
204
+ text = item.strip()
205
+ if text.startswith(TOOLS_OPEN):
206
+ text = text[len(TOOLS_OPEN) :]
207
+ if text.endswith(TOOLS_CLOSE):
208
+ text = text[: -len(TOOLS_CLOSE)]
209
+ return text.strip()
210
+
211
+
212
+ def render_xml_tools_block(xml_tools: tuple[str, ...]) -> str:
213
+ entries = [strip_tools_wrapper(item) for item in xml_tools]
214
+ entries = [item for item in entries if item]
215
+ if not entries:
216
+ return ""
217
+ body = "".join(f"{item}\n" for item in entries)
218
+ return f"{XML_TOOLS_PREAMBLE}{body}{XML_TOOLS_EPILOGUE}"
219
+
220
+
221
+ def render_cascade_tools_block(xml_tools: tuple[str, ...]) -> str:
222
+ """The same tools in Nemotron Cascade-2's layout: XML schemas, `<function=…>` calls."""
223
+
224
+ entries = [strip_tools_wrapper(item) for item in xml_tools]
225
+ return render_available_tools([loads_json(item) for item in entries if item])
226
+
227
+
228
+ def render_python_tools_block(python_tools: tuple[str, ...]) -> str:
229
+ entries = [strip_tools_wrapper(item) for item in python_tools]
230
+ entries = [item for item in entries if item]
231
+ if not entries:
232
+ return ""
233
+ body = "".join(f"{item}\n" for item in entries)
234
+ return f"{PYTHON_TOOLS_PREAMBLE}{body}{PYTHON_TOOLS_EPILOGUE}"
235
+
236
+
237
+ def unwrap_tool_result(content: str) -> str:
238
+ """Return the bare tool output, without ChatML leaks or `<tool_response>` XML."""
239
+
240
+ text = strip_leaked_chatml(content).strip()
241
+ if text.startswith(TOOL_RESPONSE_TOKEN):
242
+ text = text[len(TOOL_RESPONSE_TOKEN) :].strip()
243
+ if TOOL_RESPONSE_OPEN in text:
244
+ chunks: list[str] = []
245
+ cursor = 0
246
+ while True:
247
+ start = text.find(TOOL_RESPONSE_OPEN, cursor)
248
+ if start < 0:
249
+ tail = text[cursor:].strip()
250
+ if tail:
251
+ chunks.append(tail)
252
+ break
253
+ head = text[cursor:start].strip()
254
+ if head:
255
+ chunks.append(head)
256
+ start += len(TOOL_RESPONSE_OPEN)
257
+ end = text.find(TOOL_RESPONSE_CLOSE, start)
258
+ if end < 0:
259
+ inner = text[start:].strip()
260
+ cursor = len(text)
261
+ else:
262
+ inner = text[start:end].strip()
263
+ cursor = end + len(TOOL_RESPONSE_CLOSE)
264
+ if inner:
265
+ chunks.append(inner)
266
+ if cursor >= len(text):
267
+ break
268
+ text = "\n".join(chunks)
269
+ return text
270
+
271
+
272
+ def wrap_smoltalk_tool_result(content: str) -> str:
273
+ text = unwrap_tool_result(content)
274
+ if not text:
275
+ return ""
276
+ return f"{TOOL_RESPONSE_PREFIX}{text}"
277
+
278
+
279
+ def strip_empty_think_blocks(content: str) -> str:
280
+ """Drop `<think>` spans whose body is empty or whitespace-only.
281
+
282
+ Nemotron and similar traces sometimes emit `<think></think>` or
283
+ `<think>\\n</think>` with no reasoning. Those are not think turns.
284
+ """
285
+
286
+ stripped = _EMPTY_THINK_BLOCK.sub("", content)
287
+ if stripped == content:
288
+ return content
289
+ return stripped.lstrip("\r\n")
290
+
291
+
292
+ def rewrite_tool_result_content(content: str) -> str:
293
+ stripped = content.lstrip()
294
+ if stripped.startswith(TOOL_RESPONSE_TOKEN) and TOOL_RESPONSE_OPEN not in stripped:
295
+ return content
296
+ text = strip_leaked_chatml(content).strip()
297
+ if TOOL_RESPONSE_OPEN in text:
298
+ chunks: list[str] = []
299
+ cursor = 0
300
+ while True:
301
+ start = text.find(TOOL_RESPONSE_OPEN, cursor)
302
+ if start < 0:
303
+ break
304
+ start += len(TOOL_RESPONSE_OPEN)
305
+ end = text.find(TOOL_RESPONSE_CLOSE, start)
306
+ inner = (text[start:end] if end >= 0 else text[start:]).strip()
307
+ if inner:
308
+ chunks.append(wrap_smoltalk_tool_result(inner))
309
+ if end < 0:
310
+ break
311
+ cursor = end + len(TOOL_RESPONSE_CLOSE)
312
+ if chunks:
313
+ return "\n".join(chunks)
314
+ return wrap_smoltalk_tool_result(text)
315
+
316
+
317
+ def apply_reasoning_and_tool_controls(messages: list[dict[str, str]]) -> list[dict[str, str]]:
318
+ """Attach Haiku/Tercet-R control tokens to any ChatML conversation.
319
+
320
+ Assistant turns without a `masked_prefix` get `<|think|>` when the
321
+ content already contains a `<think>` block, otherwise `<|no_think|>`.
322
+ Empty or whitespace-only `<think>` spans are stripped and treated as
323
+ no-think, including when a think prefix was already attached.
324
+ `tool` roles and Nemotron `<tool_response>` user turns become user
325
+ turns prefixed with the `<|tool_response|>` special.
326
+ Existing prefixes and already-rewritten tool results are left alone
327
+ unless an empty think block was removed and no real think remains.
328
+ """
329
+
330
+ prepared: list[dict[str, str]] = []
331
+ pending: list[str] = []
332
+
333
+ def flush_tools() -> None:
334
+ if pending:
335
+ prepared.append({"role": "user", "content": "\n".join(pending)})
336
+ pending.clear()
337
+
338
+ for message in messages:
339
+ role = message["role"]
340
+ content = message["content"]
341
+ if role == "tool":
342
+ rewritten = rewrite_tool_result_content(content)
343
+ if rewritten:
344
+ pending.append(rewritten)
345
+ continue
346
+ if role == "user" and (
347
+ content.lstrip().startswith(TOOL_RESPONSE_TOKEN)
348
+ or TOOL_RESPONSE_OPEN in content
349
+ ):
350
+ rewritten = rewrite_tool_result_content(content)
351
+ if rewritten:
352
+ pending.append(rewritten)
353
+ continue
354
+ flush_tools()
355
+ if role == "assistant":
356
+ rendered = dict(message)
357
+ original = content
358
+ content = strip_empty_think_blocks(content)
359
+ rendered["content"] = content
360
+ has_think = THINK_OPEN in content
361
+ existing_prefix = rendered.get("masked_prefix")
362
+ if not existing_prefix:
363
+ rendered["masked_prefix"] = (
364
+ THINK_CONTROL_PREFIX if has_think else NO_THINK_PREFIX
365
+ )
366
+ elif content != original and not has_think:
367
+ rendered["masked_prefix"] = NO_THINK_PREFIX
368
+ prepared.append(rendered)
369
+ continue
370
+ prepared.append(dict(message))
371
+ flush_tools()
372
+ return prepared
373
+
374
+
375
+ def openai_tools_as_xml_strings(raw_tools: Any) -> tuple[str, ...]:
376
+ if raw_tools is None:
377
+ return ()
378
+ rendered: list[str] = []
379
+ for tool in coerce_tools(raw_tools):
380
+ rendered.append(
381
+ json.dumps(
382
+ json_ready({"type": "function", "function": tool}),
383
+ ensure_ascii=False,
384
+ default=str,
385
+ )
386
+ )
387
+ return tuple(rendered)
388
+
389
+
390
+ def format_smoltalk_tool_call(name: str, arguments: dict[str, Any]) -> str:
391
+ payload = {"name": name, "arguments": json_ready(arguments)}
392
+ return (
393
+ f"{TOOL_CALL_OPEN}\n"
394
+ f"{json.dumps(payload, ensure_ascii=False, default=str)}\n"
395
+ f"{TOOL_CALL_CLOSE}"
396
+ )
397
+
398
+
399
+ def format_smoltalk_tool_calls(raw_tool_calls: Any) -> str:
400
+ if not raw_tool_calls:
401
+ return ""
402
+ if not isinstance(raw_tool_calls, list):
403
+ raise ValueError("tool_calls must be a list")
404
+ chunks: list[str] = []
405
+ for raw_call in raw_tool_calls:
406
+ if not isinstance(raw_call, dict):
407
+ raise ValueError("Each tool_call must be an object")
408
+ payload = (
409
+ raw_call.get("function")
410
+ if isinstance(raw_call.get("function"), dict)
411
+ else raw_call
412
+ )
413
+ if not isinstance(payload, dict):
414
+ raise ValueError("tool_call is missing a function object")
415
+ name = payload.get("name")
416
+ if not isinstance(name, str) or not name.strip():
417
+ raise ValueError("tool_call is missing a function name")
418
+ arguments = parse_arguments_payload(payload.get("arguments"))
419
+ chunks.append(format_smoltalk_tool_call(name.strip(), arguments))
420
+ return "\n".join(chunks)
421
+
422
+
423
+ def assistant_smoltalk_content(message: dict[str, Any], *, tool_format: ToolFormat = "json") -> str:
424
+ content = assistant_message_content(
425
+ {**message, "tool_calls": None}
426
+ )
427
+ if TOOL_CALL_OPEN in content:
428
+ return content
429
+ if tool_format == "xml":
430
+ tool_xml = format_tool_calls_xml(message.get("tool_calls")).rstrip("\n")
431
+ else:
432
+ tool_xml = format_smoltalk_tool_calls(message.get("tool_calls"))
433
+ if not tool_xml:
434
+ return content
435
+ if content.strip():
436
+ return f"{content.rstrip()}\n{tool_xml}"
437
+ return tool_xml
438
+
439
+
440
+ def render_system_prompt(kwargs: ChatTemplateKwargs) -> str:
441
+ """Render the system turn verbatim; empty string means no system turn."""
442
+
443
+ custom = strip_leaked_chatml(strip_template_markers(kwargs.custom_instructions))
444
+ tool_blocks: list[str] = []
445
+ if TOOLS_OPEN not in custom:
446
+ if kwargs.tool_format == "xml":
447
+ xml_block = render_cascade_tools_block(kwargs.xml_tools)
448
+ else:
449
+ xml_block = render_xml_tools_block(kwargs.xml_tools)
450
+ if xml_block:
451
+ tool_blocks.append(xml_block)
452
+ python_block = render_python_tools_block(kwargs.python_tools)
453
+ if python_block:
454
+ tool_blocks.append(python_block)
455
+ sections = [custom] if custom else []
456
+ sections.extend(tool_blocks)
457
+ return "\n\n".join(sections).strip()
458
+
459
+
460
+ def merge_kwargs_with_system_message(
461
+ messages: list[dict[str, Any]],
462
+ kwargs: ChatTemplateKwargs,
463
+ ) -> tuple[list[dict[str, Any]], ChatTemplateKwargs]:
464
+ if not messages:
465
+ return messages, kwargs
466
+ first = messages[0]
467
+ if first.get("role") != "system":
468
+ return messages, kwargs
469
+ system_text = message_text(first.get("content"))
470
+ if system_text.strip():
471
+ merged = (
472
+ system_text
473
+ if not kwargs.custom_instructions.strip()
474
+ else f"{kwargs.custom_instructions.rstrip()}\n\n{system_text.strip()}"
475
+ )
476
+ kwargs = replace(kwargs, custom_instructions=merged)
477
+ return messages[1:], kwargs
478
+
479
+
480
+ def kwargs_from_inference_request(
481
+ *,
482
+ enable_thinking: bool,
483
+ custom_instructions: str = "",
484
+ xml_tools: Any = None,
485
+ python_tools: Any = None,
486
+ tools: Any = None,
487
+ tool_format: ToolFormatChoice = "json",
488
+ ) -> ChatTemplateKwargs:
489
+ """``tool_format="auto"`` is the Hermes JSON layout every served model was trained on.
490
+
491
+ The Cascade XML layout is only used when a request asks for ``xml`` explicitly.
492
+ """
493
+
494
+ xml = coerce_tool_strings(xml_tools)
495
+ if not xml:
496
+ xml = openai_tools_as_xml_strings(tools)
497
+ if tool_format == "auto":
498
+ tool_format = "json"
499
+ return ChatTemplateKwargs(
500
+ custom_instructions=custom_instructions,
501
+ enable_thinking=enable_thinking,
502
+ xml_tools=xml,
503
+ python_tools=coerce_tool_strings(python_tools),
504
+ tool_format=tool_format,
505
+ )
506
+
507
+
508
+ def _normalize_role(raw_role: Any, index: int) -> str:
509
+ if not isinstance(raw_role, str):
510
+ raise ValueError(f"Unsupported chat role at index {index}: {raw_role!r}")
511
+ role = raw_role.strip().lower()
512
+ if role == "human":
513
+ return "user"
514
+ if role == "gpt":
515
+ return "assistant"
516
+ if role == "function":
517
+ return "tool"
518
+ if role not in {"system", "user", "assistant", "tool"}:
519
+ raise ValueError(f"Unsupported chat role at index {index}: {raw_role!r}")
520
+ return role
521
+
522
+
523
+ def materialize_smoltalk_messages(
524
+ raw_messages: list[dict[str, Any]],
525
+ kwargs: ChatTemplateKwargs,
526
+ ) -> list[dict[str, str]]:
527
+ if not raw_messages:
528
+ raise ValueError("Chat history cannot be empty")
529
+ remaining, resolved = merge_kwargs_with_system_message(raw_messages, kwargs)
530
+ for index, raw_message in enumerate(remaining):
531
+ if not isinstance(raw_message, dict):
532
+ raise ValueError(f"Unsupported chat message at index {index}")
533
+ if _normalize_role(raw_message.get("role"), index) != "system":
534
+ continue
535
+ extra = strip_leaked_chatml(message_text(raw_message.get("content")))
536
+ if extra:
537
+ resolved = replace(
538
+ resolved,
539
+ custom_instructions=(
540
+ f"{resolved.custom_instructions.rstrip()}\n\n{extra}".strip()
541
+ ),
542
+ )
543
+ mode = resolve_reasoning_mode(
544
+ resolved.enable_thinking,
545
+ resolved.custom_instructions,
546
+ )
547
+ masked_prefix = THINK_CONTROL_PREFIX if mode == "/think" else NO_THINK_PREFIX
548
+ prepared: list[dict[str, str]] = []
549
+ system_prompt = render_system_prompt(resolved)
550
+ if system_prompt:
551
+ prepared.append({"role": "system", "content": system_prompt})
552
+ pending_tool_results: list[str] = []
553
+ for index, raw_message in enumerate(remaining):
554
+ role = _normalize_role(raw_message.get("role"), index)
555
+ if role == "system":
556
+ continue
557
+ if role == "assistant":
558
+ content = assistant_smoltalk_content(raw_message, tool_format=resolved.tool_format)
559
+ else:
560
+ content = message_text(raw_message.get("content"))
561
+ if role == "tool":
562
+ content = wrap_smoltalk_tool_result(content)
563
+ if not content:
564
+ raise ValueError(f"Chat content at index {index} must be non-empty")
565
+ pending_tool_results.append(content)
566
+ continue
567
+ if pending_tool_results:
568
+ prepared.append(
569
+ {"role": "user", "content": "\n".join(pending_tool_results)}
570
+ )
571
+ pending_tool_results = []
572
+ if not content.strip():
573
+ raise ValueError(f"Chat content at index {index} must be non-empty")
574
+ prepared_message = {"role": role, "content": content}
575
+ if role == "assistant":
576
+ prepared_message["masked_prefix"] = masked_prefix
577
+ prepared.append(prepared_message)
578
+ if pending_tool_results:
579
+ prepared.append({"role": "user", "content": "\n".join(pending_tool_results)})
580
+ if not any(message["role"] != "system" for message in prepared):
581
+ raise ValueError("Chat history cannot be empty")
582
+ return prepared
583
+
584
+
585
+ def format_think_block(reasoning: str, content: str) -> str:
586
+ """SmolTalk2 layout: `<think>\\n…\\n</think>\\n\\nanswer`."""
587
+
588
+ body = reasoning.strip()
589
+ if content:
590
+ return f"{THINK_OPEN}\n{body}\n{THINK_CLOSE}\n\n{content}"
591
+ return f"{THINK_OPEN}\n{body}\n{THINK_CLOSE}\n\n"
592
+
593
+
594
+ def render_assistant_turn(
595
+ content: str,
596
+ *,
597
+ reasoning: str | None,
598
+ tool_calls: list[dict[str, Any]] | None,
599
+ ) -> dict[str, str]:
600
+ """Render one assistant turn with a per-turn thinking control prefix.
601
+
602
+ A turn with reasoning gets `<|think|>` and a `<think>` block; a turn
603
+ without reasoning gets `<|no_think|>`. Tool calls become SmolTalk JSON
604
+ `<tool_call>` blocks unless the content already inlines them.
605
+ """
606
+
607
+ text = strip_empty_think_blocks(content.strip())
608
+ if tool_calls and TOOL_CALL_OPEN not in text:
609
+ calls = "\n".join(
610
+ format_smoltalk_tool_call(call["name"], call["arguments"]) for call in tool_calls
611
+ )
612
+ text = f"{text}\n{calls}" if text else calls
613
+ if reasoning and reasoning.strip():
614
+ return {
615
+ "role": "assistant",
616
+ "content": format_think_block(reasoning, text),
617
+ "masked_prefix": THINK_CONTROL_PREFIX,
618
+ }
619
+ return {"role": "assistant", "content": text, "masked_prefix": NO_THINK_PREFIX}
620
+
621
+
622
+ def materialize_canonical_conversation(
623
+ *,
624
+ system: str | None,
625
+ tools: Any,
626
+ messages: list[dict[str, Any]],
627
+ ) -> list[dict[str, str]]:
628
+ """Render a stage-3 canonical conversation into ChatML-ready turns.
629
+
630
+ `messages` entries are `{"role", "content", "reasoning"?, "tool_calls"?}`
631
+ with roles user / assistant / tool. The system prompt is used verbatim;
632
+ tools are appended only when the prompt lacks a `<tools>` block.
633
+ """
634
+
635
+ kwargs = ChatTemplateKwargs(
636
+ custom_instructions=system or "",
637
+ xml_tools=openai_tools_as_xml_strings(tools) if tools else (),
638
+ )
639
+ prepared: list[dict[str, str]] = []
640
+ system_prompt = render_system_prompt(kwargs)
641
+ if system_prompt:
642
+ prepared.append({"role": "system", "content": system_prompt})
643
+ pending_tool_results: list[str] = []
644
+ for index, message in enumerate(messages):
645
+ role = message["role"]
646
+ if role == "tool":
647
+ wrapped = wrap_smoltalk_tool_result(message_text(message.get("content")))
648
+ if not wrapped:
649
+ raise ValueError(f"Tool result at index {index} is empty")
650
+ pending_tool_results.append(wrapped)
651
+ continue
652
+ if pending_tool_results:
653
+ prepared.append({"role": "user", "content": "\n".join(pending_tool_results)})
654
+ pending_tool_results = []
655
+ if role == "user":
656
+ content = message_text(message.get("content"))
657
+ if not content.strip():
658
+ raise ValueError(f"User turn at index {index} is empty")
659
+ prepared.append({"role": "user", "content": content})
660
+ continue
661
+ if role == "assistant":
662
+ rendered = render_assistant_turn(
663
+ message_text(message.get("content")),
664
+ reasoning=message.get("reasoning"),
665
+ tool_calls=message.get("tool_calls"),
666
+ )
667
+ if not rendered["content"].strip():
668
+ raise ValueError(f"Assistant turn at index {index} is empty")
669
+ prepared.append(rendered)
670
+ continue
671
+ raise ValueError(f"Unsupported role at index {index}: {role!r}")
672
+ if pending_tool_results:
673
+ raise ValueError("Conversation ends with an unanswered tool result")
674
+ if not prepared or prepared[-1]["role"] != "assistant":
675
+ raise ValueError("Conversation must end with an assistant turn")
676
+ return prepared
tiny_gdn/tools.py ADDED
@@ -0,0 +1,624 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Nemotron Cascade-2 tool protocol (XML in ChatML content).
2
+
3
+ Tercet-R is trained on `nvidia/Nemotron-Cascade-2-SFT-Data`, which inlines
4
+ available tools, calls, and results as `<tools>` / `<tool_call>` /
5
+ `<tool_response>` in message text. That is not OpenAI `tool_calls` JSON and
6
+ not this tokenizer's unused `<|tool_call|>` / `<|tool_response|>` specials.
7
+
8
+ This module matches NVIDIA's Cascade-2 chat template:
9
+ https://huggingface.co/nvidia/Nemotron-Cascade-2-30B-A3B/blob/main/chat_template.jinja
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ import json
16
+ import re
17
+ import sys
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+
22
+ TOOL_CALL_OPEN = "<tool_call>"
23
+ TOOL_CALL_CLOSE = "</tool_call>"
24
+ TOOL_RESPONSE_OPEN = "<tool_response>"
25
+ TOOL_RESPONSE_CLOSE = "</tool_response>"
26
+ TOOLS_OPEN = "<tools>"
27
+ TOOLS_CLOSE = "</tools>"
28
+ THINK_OPEN = "<think>"
29
+ THINK_CLOSE = "</think>"
30
+
31
+ CHATML_START_RE = re.compile(r"^<\|im_start\|>[A-Za-z]+\n")
32
+ CHATML_END_RE = re.compile(r"\n?<\|im_end\|>\s*$")
33
+ TOOL_CALL_BLOCK_RE = re.compile(
34
+ rf"{re.escape(TOOL_CALL_OPEN)}(.*?){re.escape(TOOL_CALL_CLOSE)}",
35
+ re.DOTALL,
36
+ )
37
+ FUNCTION_BLOCK_RE = re.compile(
38
+ r"<function=([^>\s]+)>(.*?)</function>",
39
+ re.DOTALL,
40
+ )
41
+ PARAMETER_BLOCK_RE = re.compile(
42
+ r"<parameter=([^>\s]+)>\n?(.*?)\n?</parameter>",
43
+ re.DOTALL,
44
+ )
45
+ FUNCTION_NAME_RE = re.compile(r"<function=([^>\s]+)")
46
+
47
+ INFERENCE_ROLE_MAP = {
48
+ "system": "system",
49
+ "user": "user",
50
+ "human": "user",
51
+ "assistant": "assistant",
52
+ "gpt": "assistant",
53
+ "tool": "tool",
54
+ "function": "tool",
55
+ }
56
+
57
+ TOOLS_PREAMBLE = "# Tools\n\nYou have access to the following functions:\n\n"
58
+ TOOL_CALL_INSTRUCTIONS = (
59
+ "\n\nIf you choose to call a function ONLY reply in the following format "
60
+ "with NO suffix:\n\n"
61
+ "<tool_call>\n"
62
+ "<function=example_function_name>\n"
63
+ "<parameter=example_parameter_1>\n"
64
+ "value_1\n"
65
+ "</parameter>\n"
66
+ "<parameter=example_parameter_2>\n"
67
+ "This is the value for the second parameter\n"
68
+ "that can span\n"
69
+ "multiple lines\n"
70
+ "</parameter>\n"
71
+ "</function>\n"
72
+ "</tool_call>\n\n"
73
+ "<IMPORTANT>\n"
74
+ "Reminder:\n"
75
+ "- Function calls MUST follow the specified format: an inner "
76
+ "<function=...></function> block must be nested within "
77
+ "<tool_call></tool_call> XML tags\n"
78
+ "- Required parameters MUST be specified\n"
79
+ "- You may provide optional reasoning for your function call in natural "
80
+ "language BEFORE the function call, but NOT after\n"
81
+ "- If there is no function call available, answer the question like "
82
+ "normal with your current knowledge and do not tell the user about "
83
+ "function calls\n"
84
+ "</IMPORTANT>"
85
+ )
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class ParsedToolCall:
90
+ name: str
91
+ arguments: dict[str, Any]
92
+ raw: str
93
+
94
+
95
+ def message_text(raw_content: Any) -> str:
96
+ if raw_content is None:
97
+ return ""
98
+ if isinstance(raw_content, str):
99
+ return raw_content
100
+ if isinstance(raw_content, list):
101
+ parts: list[str] = []
102
+ for item in raw_content:
103
+ if isinstance(item, str):
104
+ parts.append(item)
105
+ continue
106
+ if not isinstance(item, dict):
107
+ continue
108
+ part_type = item.get("type")
109
+ if part_type in {None, "text", "input_text", "output_text"}:
110
+ text = item.get("text")
111
+ if isinstance(text, str):
112
+ parts.append(text)
113
+ return "".join(parts)
114
+ return str(raw_content)
115
+
116
+
117
+ def strip_leaked_chatml(content: str) -> str:
118
+ text = content.strip()
119
+ while True:
120
+ match = CHATML_START_RE.match(text)
121
+ if match is None:
122
+ break
123
+ text = text[match.end() :]
124
+ text = CHATML_END_RE.sub("", text)
125
+ return text.strip()
126
+
127
+
128
+ def has_tools_block(content: str) -> bool:
129
+ return TOOLS_OPEN in content
130
+
131
+
132
+ def wrap_tool_response(content: str) -> str:
133
+ text = strip_leaked_chatml(content)
134
+ if TOOL_RESPONSE_OPEN in text:
135
+ return text
136
+ return f"{TOOL_RESPONSE_OPEN}\n\n\n{text}\n{TOOL_RESPONSE_CLOSE}"
137
+
138
+
139
+ def _xml_value(value: Any) -> str:
140
+ if isinstance(value, dict) or (
141
+ isinstance(value, (list, tuple)) and not isinstance(value, (str, bytes))
142
+ ):
143
+ return json.dumps(value, ensure_ascii=False)
144
+ if value is True or value is False or value is None:
145
+ return str(value)
146
+ return str(value)
147
+
148
+
149
+ def _render_extra_keys(payload: dict[str, Any], handled: set[str]) -> str:
150
+ chunks: list[str] = []
151
+ for key, value in payload.items():
152
+ if key in handled:
153
+ continue
154
+ chunks.append(f"\n<{key}>{_xml_value(value)}</{key}>")
155
+ return "".join(chunks)
156
+
157
+
158
+ def _unwrap_tool(raw_tool: Any) -> dict[str, Any]:
159
+ if not isinstance(raw_tool, dict):
160
+ raise ValueError("Each tool must be an object")
161
+ if isinstance(raw_tool.get("function"), dict):
162
+ tool = dict(raw_tool["function"])
163
+ else:
164
+ tool = dict(raw_tool)
165
+ name = tool.get("name")
166
+ if not isinstance(name, str) or not name.strip():
167
+ raise ValueError("Tool is missing a function name")
168
+ tool["name"] = name.strip()
169
+ return tool
170
+
171
+
172
+ def coerce_tools(raw_tools: Any) -> list[dict[str, Any]]:
173
+ if raw_tools is None:
174
+ return []
175
+ if isinstance(raw_tools, str):
176
+ text = raw_tools.strip()
177
+ if not text:
178
+ return []
179
+ raw_tools = loads_json(text)
180
+ if isinstance(raw_tools, dict):
181
+ raw_tools = [raw_tools]
182
+ if not isinstance(raw_tools, list):
183
+ raise ValueError("tools must be a list of function specs")
184
+ return [_unwrap_tool(item) for item in raw_tools]
185
+
186
+
187
+ def render_function_schema(tool: dict[str, Any]) -> str:
188
+ chunks = [f"\n<function>\n<name>{tool['name']}</name>"]
189
+ description = tool.get("description")
190
+ if isinstance(description, str) and description.strip():
191
+ chunks.append(f"\n<description>{description.strip()}</description>")
192
+ chunks.append("\n<parameters>")
193
+ parameters = tool.get("parameters")
194
+ properties: dict[str, Any] = {}
195
+ if isinstance(parameters, dict):
196
+ raw_properties = parameters.get("properties")
197
+ if isinstance(raw_properties, dict):
198
+ properties = raw_properties
199
+ for param_name, raw_fields in properties.items():
200
+ fields = raw_fields if isinstance(raw_fields, dict) else {}
201
+ chunks.append("\n<parameter>")
202
+ chunks.append(f"\n<name>{param_name}</name>")
203
+ if "type" in fields:
204
+ chunks.append(f"\n<type>{_xml_value(fields['type'])}</type>")
205
+ if isinstance(fields.get("description"), str) and fields["description"].strip():
206
+ chunks.append(
207
+ f"\n<description>{fields['description'].strip()}</description>"
208
+ )
209
+ if "enum" in fields:
210
+ chunks.append(f"\n<enum>{_xml_value(fields['enum'])}</enum>")
211
+ chunks.append(
212
+ _render_extra_keys(
213
+ fields,
214
+ {"name", "type", "description", "enum"},
215
+ )
216
+ )
217
+ chunks.append("\n</parameter>")
218
+ chunks.append(
219
+ _render_extra_keys(parameters, {"type", "properties", "required"})
220
+ )
221
+ if "required" in parameters:
222
+ chunks.append(f"\n<required>{_xml_value(parameters['required'])}</required>")
223
+ chunks.append("\n</parameters>")
224
+ chunks.append(
225
+ _render_extra_keys(
226
+ tool,
227
+ {"type", "name", "description", "parameters"},
228
+ )
229
+ )
230
+ chunks.append("\n</function>")
231
+ return "".join(chunks)
232
+
233
+
234
+ def render_available_tools(raw_tools: Any) -> str:
235
+ tools = coerce_tools(raw_tools)
236
+ if not tools:
237
+ return ""
238
+ body = "".join(render_function_schema(tool) for tool in tools)
239
+ return (
240
+ f"{TOOLS_PREAMBLE}{TOOLS_OPEN}{body}\n{TOOLS_CLOSE}"
241
+ f"{TOOL_CALL_INSTRUCTIONS}"
242
+ )
243
+
244
+
245
+ # Cascade's OpenHands conversations used XML schemas and `<function=…>` calls.
246
+ # Those conversations are not in the served models' training data; `auto` is JSON.
247
+ XML_LAYOUT_TOOL_NAMES = frozenset({"execute_bash", "str_replace_editor"})
248
+
249
+
250
+ def prefers_xml_tool_calls(raw_tools: Any) -> bool:
251
+ """Whether a tool list holds names Cascade rendered only in the XML layout.
252
+
253
+ Served models were not trained on that data, so inference ``auto`` does not
254
+ use this. It remains for callers that still want to detect the old tool set.
255
+ """
256
+
257
+ try:
258
+ tools = coerce_tools(raw_tools)
259
+ except ValueError:
260
+ return False
261
+ return any(tool["name"].lower().replace("-", "_") in XML_LAYOUT_TOOL_NAMES for tool in tools)
262
+
263
+
264
+ def inject_available_tools(system_content: str, raw_tools: Any) -> str:
265
+ block = render_available_tools(raw_tools)
266
+ if not block:
267
+ return system_content
268
+ if has_tools_block(system_content):
269
+ return system_content
270
+ if not system_content.strip():
271
+ return block
272
+ return f"{system_content.rstrip()}\n\n{block}"
273
+
274
+
275
+ def parse_json_int(text: str) -> int | str:
276
+ """Keep oversized JSON integers as strings.
277
+
278
+ Python 3.12 refuses to convert integers longer than
279
+ ``sys.get_int_max_str_digits()`` (default 4300). Tool-call payloads
280
+ sometimes embed hashes or blobs as bare JSON numbers; those must not
281
+ abort packing.
282
+ """
283
+
284
+ digits = text.lstrip("+-")
285
+ limit = sys.get_int_max_str_digits()
286
+ if limit and len(digits) > limit:
287
+ return text
288
+ return int(text)
289
+
290
+
291
+ def loads_json(text: str) -> Any:
292
+ return json.loads(text, parse_int=parse_json_int)
293
+
294
+
295
+ # Models sometimes close a JSON string with `".}` instead of `"}`.
296
+ _TRAILING_STRING_PERIOD_RE = re.compile(r'"\s*\.(?=\s*[}\],])')
297
+ _TRAILING_COMMA_RE = re.compile(r",\s*(?=[}\]])")
298
+ _JSON_NAME_RE = re.compile(r'"name"\s*:\s*"([^"]+)"')
299
+ _JSON_QUERY_RE = re.compile(r'"query"\s*:\s*"([^"]*)"')
300
+
301
+
302
+ def repair_jsonish(text: str) -> str:
303
+ repaired = _TRAILING_STRING_PERIOD_RE.sub('"', text.strip())
304
+ return _TRAILING_COMMA_RE.sub("", repaired)
305
+
306
+
307
+ def loads_jsonish(text: str) -> Any:
308
+ """JSON with common model slips repaired; Python literals (single quotes, True) also parse."""
309
+
310
+ stripped = text.strip()
311
+ if not stripped:
312
+ raise json.JSONDecodeError("Empty JSON", text, 0)
313
+ last_error: Exception | None = None
314
+ for candidate in dict.fromkeys((stripped, repair_jsonish(stripped))):
315
+ try:
316
+ return loads_json(candidate)
317
+ except (json.JSONDecodeError, ValueError, RecursionError) as error:
318
+ last_error = error
319
+ try:
320
+ return json_ready(ast.literal_eval(stripped))
321
+ except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError):
322
+ pass
323
+ try:
324
+ return loads_json(stripped.replace("'", '"'))
325
+ except (json.JSONDecodeError, ValueError, RecursionError):
326
+ if last_error is not None:
327
+ raise last_error from None
328
+ raise
329
+
330
+
331
+ def extract_json_tool_fields(text: str) -> tuple[str, dict[str, Any]] | None:
332
+ name_match = _JSON_NAME_RE.search(text)
333
+ if name_match is None:
334
+ return None
335
+ arguments: dict[str, Any] = {}
336
+ query_match = _JSON_QUERY_RE.search(text)
337
+ if query_match is not None:
338
+ arguments["query"] = query_match.group(1)
339
+ return name_match.group(1).strip(), arguments
340
+
341
+
342
+ def _json_ready_int(value: int) -> int | str:
343
+ try:
344
+ json.dumps(value)
345
+ except ValueError:
346
+ previous = sys.get_int_max_str_digits()
347
+ sys.set_int_max_str_digits(0)
348
+ try:
349
+ return str(value)
350
+ finally:
351
+ sys.set_int_max_str_digits(previous)
352
+ return value
353
+
354
+
355
+ def json_ready(value: Any) -> Any:
356
+ """Coerce Python literals into JSON-serialisable values.
357
+
358
+ Some SFT sources store tool arguments as Python literals. `ast.literal_eval`
359
+ turns `{1, 2}` into a `set` and `...` into `Ellipsis`, which later
360
+ `json.dumps` calls reject. Oversized ints are stored as decimal strings.
361
+ """
362
+
363
+ if value is None or isinstance(value, (bool, float, str)):
364
+ return value
365
+ if isinstance(value, int):
366
+ return _json_ready_int(value)
367
+ if value is Ellipsis:
368
+ return None
369
+ if isinstance(value, dict):
370
+ return {str(key): json_ready(item) for key, item in value.items()}
371
+ if isinstance(value, (set, frozenset)):
372
+ items = [json_ready(item) for item in value]
373
+ try:
374
+ return sorted(
375
+ items,
376
+ key=lambda item: json.dumps(item, sort_keys=True, default=str),
377
+ )
378
+ except TypeError:
379
+ return items
380
+ if isinstance(value, tuple):
381
+ return [json_ready(item) for item in value]
382
+ if isinstance(value, list):
383
+ return [json_ready(item) for item in value]
384
+ if isinstance(value, bytes):
385
+ try:
386
+ return value.decode("utf-8")
387
+ except UnicodeDecodeError:
388
+ return list(value)
389
+ try:
390
+ json.dumps(value)
391
+ return value
392
+ except (TypeError, ValueError):
393
+ return str(value)
394
+
395
+
396
+ def parse_argument_value(raw: str) -> Any:
397
+ text = raw.strip()
398
+ if not text:
399
+ return ""
400
+ try:
401
+ return loads_json(text)
402
+ except (json.JSONDecodeError, ValueError, RecursionError):
403
+ return text
404
+
405
+
406
+ def parse_arguments_payload(raw: Any) -> dict[str, Any]:
407
+ if raw is None:
408
+ return {}
409
+ ready = json_ready(raw)
410
+ if isinstance(ready, dict):
411
+ return ready
412
+ if isinstance(ready, str):
413
+ text = ready.strip()
414
+ if not text:
415
+ return {}
416
+ try:
417
+ loaded = loads_json(text)
418
+ except (json.JSONDecodeError, ValueError, RecursionError):
419
+ return {"value": ready}
420
+ loaded = json_ready(loaded)
421
+ if isinstance(loaded, dict):
422
+ return loaded
423
+ return {"value": loaded}
424
+ return {"value": ready}
425
+
426
+
427
+ def format_tool_call_xml(name: str, arguments: dict[str, Any]) -> str:
428
+ chunks = [f"{TOOL_CALL_OPEN}\n<function={name}>\n"]
429
+ for key, value in arguments.items():
430
+ chunks.append(f"<parameter={key}>\n{_xml_value(value)}\n</parameter>\n")
431
+ chunks.append(f"</function>\n{TOOL_CALL_CLOSE}\n")
432
+ return "".join(chunks)
433
+
434
+
435
+ def format_tool_calls_xml(raw_tool_calls: Any) -> str:
436
+ if not raw_tool_calls:
437
+ return ""
438
+ if not isinstance(raw_tool_calls, list):
439
+ raise ValueError("tool_calls must be a list")
440
+ chunks: list[str] = []
441
+ for raw_call in raw_tool_calls:
442
+ if not isinstance(raw_call, dict):
443
+ raise ValueError("Each tool_call must be an object")
444
+ payload = raw_call.get("function") if isinstance(raw_call.get("function"), dict) else raw_call
445
+ if not isinstance(payload, dict):
446
+ raise ValueError("tool_call is missing a function object")
447
+ name = payload.get("name")
448
+ if not isinstance(name, str) or not name.strip():
449
+ raise ValueError("tool_call is missing a function name")
450
+ arguments = parse_arguments_payload(payload.get("arguments"))
451
+ chunks.append(format_tool_call_xml(name.strip(), arguments))
452
+ return "".join(chunks)
453
+
454
+
455
+ def _json_tool_call(payload: Any, raw: str) -> ParsedToolCall | None:
456
+ if not isinstance(payload, dict):
457
+ return None
458
+ nested = payload.get("function")
459
+ source = nested if isinstance(nested, dict) else payload
460
+ name = source.get("name")
461
+ if not isinstance(name, str) or not name.strip():
462
+ name = payload.get("name")
463
+ if not isinstance(name, str) or not name.strip():
464
+ return None
465
+ arguments = parse_arguments_payload(
466
+ source.get("arguments", source.get("parameters", payload.get("arguments")))
467
+ )
468
+ return ParsedToolCall(name=name.strip(), arguments=arguments, raw=raw)
469
+
470
+
471
+ def parse_json_tool_calls(
472
+ inner: str, raw: str = "", *, strict: bool = False
473
+ ) -> list[ParsedToolCall] | None:
474
+ """Calls in the JSON body of one `<tool_call>` block, or None when it holds none.
475
+
476
+ The body is one call object or a list of them (``[]`` is an explicit "no call").
477
+ Unless ``strict``, a body that does not parse still yields the call name and
478
+ query it mentions.
479
+ """
480
+
481
+ text = inner.strip()
482
+ if not text:
483
+ return None
484
+ try:
485
+ payload = loads_jsonish(text)
486
+ except (json.JSONDecodeError, ValueError, RecursionError):
487
+ payload = None
488
+ if payload == []:
489
+ return []
490
+ items = payload if isinstance(payload, list) else [payload]
491
+ calls = [call for item in items if (call := _json_tool_call(item, raw)) is not None]
492
+ if strict:
493
+ return calls if calls and len(calls) == len(items) else None
494
+ if calls:
495
+ return calls
496
+ extracted = extract_json_tool_fields(repair_jsonish(text))
497
+ if extracted is None:
498
+ return None
499
+ name, arguments = extracted
500
+ return [ParsedToolCall(name=name, arguments=arguments, raw=raw)]
501
+
502
+
503
+ def parse_tool_call_blocks(
504
+ text: str, *, strict: bool = False
505
+ ) -> list[list[ParsedToolCall] | None]:
506
+ """The calls of each `<tool_call>` block in ``text``; None for a block that does not parse."""
507
+
508
+ blocks: list[list[ParsedToolCall] | None] = []
509
+ for block in TOOL_CALL_BLOCK_RE.finditer(text):
510
+ inner = block.group(1)
511
+ raw = block.group(0).strip()
512
+ calls: list[ParsedToolCall] = []
513
+ for function in FUNCTION_BLOCK_RE.finditer(inner):
514
+ arguments: dict[str, Any] = {}
515
+ for parameter in PARAMETER_BLOCK_RE.finditer(function.group(2)):
516
+ arguments[parameter.group(1).strip()] = parse_argument_value(
517
+ parameter.group(2)
518
+ )
519
+ calls.append(
520
+ ParsedToolCall(
521
+ name=function.group(1).strip(),
522
+ arguments=json_ready(arguments),
523
+ raw=raw,
524
+ )
525
+ )
526
+ blocks.append(calls if calls else parse_json_tool_calls(inner, raw, strict=strict))
527
+ return blocks
528
+
529
+
530
+ def parse_tool_calls(text: str) -> list[ParsedToolCall]:
531
+ return [call for calls in parse_tool_call_blocks(text) if calls for call in calls]
532
+
533
+
534
+ def openai_tool_calls_from_text(text: str) -> list[dict[str, Any]]:
535
+ encoded: list[dict[str, Any]] = []
536
+ for index, call in enumerate(parse_tool_calls(text)):
537
+ encoded.append(
538
+ {
539
+ "id": f"call_{index}_{call.name}",
540
+ "type": "function",
541
+ "function": {
542
+ "name": call.name,
543
+ "arguments": json.dumps(call.arguments, ensure_ascii=False),
544
+ },
545
+ }
546
+ )
547
+ return encoded
548
+
549
+
550
+ def assistant_message_content(message: dict[str, Any]) -> str:
551
+ reasoning = message.get("reasoning_content")
552
+ content = message_text(message.get("content"))
553
+ if isinstance(reasoning, str) and reasoning.strip():
554
+ content = f"{THINK_OPEN}\n{reasoning.strip()}\n{THINK_CLOSE}\n{content}"
555
+ tool_xml = format_tool_calls_xml(message.get("tool_calls"))
556
+ if tool_xml:
557
+ if content.strip():
558
+ return f"{content.rstrip()}\n{tool_xml}"
559
+ return tool_xml
560
+ return content
561
+
562
+
563
+ def _flush_tool_group(
564
+ group: list[str],
565
+ messages: list[dict[str, str]],
566
+ ) -> None:
567
+ if not group:
568
+ return
569
+ messages.append({"role": "user", "content": "\n".join(group)})
570
+ group.clear()
571
+
572
+
573
+ def prepare_inference_messages(
574
+ raw_messages: list[dict[str, Any]],
575
+ *,
576
+ tools: Any | None = None,
577
+ ) -> list[dict[str, str]]:
578
+ if not raw_messages:
579
+ raise ValueError("Chat history cannot be empty")
580
+
581
+ prepared: list[dict[str, str]] = []
582
+ pending_tool_results: list[str] = []
583
+ for index, raw_message in enumerate(raw_messages):
584
+ if not isinstance(raw_message, dict):
585
+ raise ValueError(f"Unsupported chat message at index {index}")
586
+ raw_role = raw_message.get("role")
587
+ if not isinstance(raw_role, str):
588
+ raise ValueError(f"Unsupported chat role at index {index}: {raw_role!r}")
589
+ role = INFERENCE_ROLE_MAP.get(raw_role.strip().lower())
590
+ if role is None:
591
+ raise ValueError(f"Unsupported chat role at index {index}: {raw_role!r}")
592
+ if role == "assistant":
593
+ content = assistant_message_content(raw_message)
594
+ else:
595
+ content = message_text(raw_message.get("content"))
596
+ if role == "tool":
597
+ content = wrap_tool_response(content)
598
+ if not content.strip():
599
+ raise ValueError(f"Chat content at index {index} must be non-empty")
600
+ pending_tool_results.append(content)
601
+ continue
602
+ _flush_tool_group(pending_tool_results, prepared)
603
+ if not content.strip():
604
+ if role == "system":
605
+ continue
606
+ raise ValueError(f"Chat content at index {index} must be non-empty")
607
+ if role == "system":
608
+ content = strip_leaked_chatml(content)
609
+ prepared.append({"role": role, "content": content})
610
+ _flush_tool_group(pending_tool_results, prepared)
611
+
612
+ tools_block = render_available_tools(tools)
613
+ if tools_block:
614
+ if prepared and prepared[0]["role"] == "system":
615
+ prepared[0] = {
616
+ "role": "system",
617
+ "content": inject_available_tools(prepared[0]["content"], tools),
618
+ }
619
+ else:
620
+ prepared.insert(0, {"role": "system", "content": tools_block})
621
+
622
+ if not prepared:
623
+ raise ValueError("Chat history cannot be empty")
624
+ return prepared
tiny_gdn/web_search.py ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Execute Nemotron Agentic-v2 `web-search` the way Tercet-R stage-3 SFT saw it.
2
+
3
+ `nvidia/Nemotron-SFT-Agentic-v2` `data/search.jsonl` (packed as
4
+ `agentic_v2_search`) declares a single tool named `web-search` whose
5
+ observations are compact Tavily Search JSON:
6
+
7
+ {"query", "follow_up_questions", "answer", "images", "results",
8
+ "response_time", "request_id"}
9
+
10
+ Each `results[]` item is `{url, title, content, score, raw_content}`
11
+ (about ten hits, with an LLM `answer` when Tavily supplies one). The chat
12
+ template then prefixes that JSON with `<|tool_response|>`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import time
20
+ from collections.abc import Mapping, Sequence
21
+ from html import unescape
22
+ from html.parser import HTMLParser
23
+ from typing import Any
24
+ from urllib.error import HTTPError, URLError
25
+ from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
26
+ from urllib.request import Request, urlopen
27
+
28
+
29
+ NEMOTRON_WEB_SEARCH_TOOL: dict[str, Any] = {
30
+ "type": "function",
31
+ "function": {
32
+ "name": "web-search",
33
+ "description": "Search the web for a query.",
34
+ "parameters": {
35
+ "type": "object",
36
+ "properties": {
37
+ "query": {
38
+ "type": "string",
39
+ "description": "Search query.",
40
+ }
41
+ },
42
+ "required": ["query"],
43
+ },
44
+ },
45
+ }
46
+
47
+ WEB_SEARCH_TOOL_NAME = "web-search"
48
+ DEFAULT_MAX_RESULTS = 10
49
+ TAVILY_SEARCH_URL = "https://api.tavily.com/search"
50
+ DUCKDUCKGO_HTML_URL = "https://html.duckduckgo.com/html/"
51
+ WIKIPEDIA_API_URL = "https://en.wikipedia.org/w/api.php"
52
+ USER_AGENT = (
53
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
54
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
55
+ )
56
+ REQUEST_TIMEOUT_SECONDS = 30.0
57
+
58
+
59
+ def is_web_search_tool_name(name: str) -> bool:
60
+ return name.strip().lower().replace("_", "-") == WEB_SEARCH_TOOL_NAME
61
+
62
+
63
+ def query_from_arguments(arguments: Mapping[str, Any] | None) -> str:
64
+ if not arguments:
65
+ return ""
66
+ raw = arguments.get("query")
67
+ if isinstance(raw, str):
68
+ return raw.strip()
69
+ if raw is None:
70
+ return ""
71
+ return str(raw).strip()
72
+
73
+
74
+ def dumps_search_payload(payload: Mapping[str, Any]) -> str:
75
+ return json.dumps(canonical_search_payload(payload), ensure_ascii=False)
76
+
77
+
78
+ def canonical_search_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
79
+ results_raw = payload.get("results")
80
+ results: list[dict[str, Any]] = []
81
+ if isinstance(results_raw, Sequence) and not isinstance(results_raw, (str, bytes)):
82
+ for item in results_raw:
83
+ if isinstance(item, Mapping):
84
+ results.append(_canonical_result(item))
85
+ images_raw = payload.get("images")
86
+ images: list[Any] = []
87
+ if isinstance(images_raw, Sequence) and not isinstance(images_raw, (str, bytes)):
88
+ images = list(images_raw)
89
+ return {
90
+ "query": str(payload.get("query") or ""),
91
+ "follow_up_questions": payload.get("follow_up_questions"),
92
+ "answer": payload.get("answer"),
93
+ "images": images,
94
+ "results": results,
95
+ "response_time": payload.get("response_time"),
96
+ "request_id": payload.get("request_id"),
97
+ }
98
+
99
+
100
+ def _canonical_result(item: Mapping[str, Any]) -> dict[str, Any]:
101
+ if item.get("error") and not item.get("url") and not item.get("title"):
102
+ return {"error": str(item["error"])}
103
+ result: dict[str, Any] = {
104
+ "url": str(item.get("url") or ""),
105
+ "title": str(item.get("title") or ""),
106
+ "content": str(item.get("content") or ""),
107
+ "score": _as_score(item.get("score")),
108
+ "raw_content": item.get("raw_content"),
109
+ }
110
+ if item.get("error"):
111
+ result["error"] = str(item["error"])
112
+ return result
113
+
114
+
115
+ def _as_score(value: Any) -> float | None:
116
+ if value is None or value == "":
117
+ return None
118
+ try:
119
+ return float(value)
120
+ except (TypeError, ValueError):
121
+ return None
122
+
123
+
124
+ def search_web(
125
+ query: str,
126
+ *,
127
+ max_results: int = DEFAULT_MAX_RESULTS,
128
+ api_key: str | None = None,
129
+ ) -> str:
130
+ """Return a Tavily-shaped JSON string for one `web-search` observation."""
131
+
132
+ cleaned = query.strip()
133
+ started = time.perf_counter()
134
+ if not cleaned:
135
+ return dumps_search_payload(
136
+ _empty_payload(
137
+ query,
138
+ error="Search query was empty",
139
+ response_time=time.perf_counter() - started,
140
+ )
141
+ )
142
+ key = (api_key if api_key is not None else os.environ.get("TAVILY_API_KEY", "")).strip()
143
+ if key:
144
+ try:
145
+ return dumps_search_payload(
146
+ _search_tavily(cleaned, api_key=key, max_results=max_results)
147
+ )
148
+ except Exception as error:
149
+ fallback_error = f"Tavily search failed: {error}"
150
+ try:
151
+ return dumps_search_payload(
152
+ _search_without_tavily(
153
+ cleaned,
154
+ max_results=max_results,
155
+ fallback_error=fallback_error,
156
+ )
157
+ )
158
+ except Exception:
159
+ return dumps_search_payload(
160
+ _empty_payload(
161
+ cleaned,
162
+ error=fallback_error,
163
+ response_time=time.perf_counter() - started,
164
+ )
165
+ )
166
+ try:
167
+ return dumps_search_payload(
168
+ _search_without_tavily(cleaned, max_results=max_results)
169
+ )
170
+ except Exception as error:
171
+ return dumps_search_payload(
172
+ _empty_payload(
173
+ cleaned,
174
+ error=str(error),
175
+ response_time=time.perf_counter() - started,
176
+ )
177
+ )
178
+
179
+
180
+ def _empty_payload(
181
+ query: str,
182
+ *,
183
+ error: str,
184
+ response_time: float | None,
185
+ ) -> dict[str, Any]:
186
+ return {
187
+ "query": query,
188
+ "follow_up_questions": None,
189
+ "answer": None,
190
+ "images": [],
191
+ "results": [{"error": error}],
192
+ "response_time": response_time,
193
+ "request_id": None,
194
+ }
195
+
196
+
197
+ def _search_tavily(
198
+ query: str,
199
+ *,
200
+ api_key: str,
201
+ max_results: int,
202
+ ) -> dict[str, Any]:
203
+ body = json.dumps(
204
+ {
205
+ "query": query,
206
+ "max_results": max(1, min(int(max_results), 20)),
207
+ "include_answer": True,
208
+ "include_raw_content": False,
209
+ "include_images": False,
210
+ "search_depth": "basic",
211
+ }
212
+ ).encode("utf-8")
213
+ payload = _read_json(
214
+ TAVILY_SEARCH_URL,
215
+ data=body,
216
+ headers={
217
+ "Authorization": f"Bearer {api_key}",
218
+ "Content-Type": "application/json",
219
+ "Accept": "application/json",
220
+ },
221
+ method="POST",
222
+ )
223
+ if not isinstance(payload, dict):
224
+ raise RuntimeError("Tavily returned a non-object payload")
225
+ payload.setdefault("query", query)
226
+ return payload
227
+
228
+
229
+ def _search_without_tavily(
230
+ query: str,
231
+ *,
232
+ max_results: int,
233
+ fallback_error: str | None = None,
234
+ ) -> dict[str, Any]:
235
+ started = time.perf_counter()
236
+ results: list[dict[str, Any]] = []
237
+ errors: list[str] = []
238
+ if fallback_error:
239
+ errors.append(fallback_error)
240
+ try:
241
+ results.extend(_search_duckduckgo_html(query, max_results=max_results))
242
+ except Exception as error:
243
+ errors.append(f"DuckDuckGo search failed: {error}")
244
+ if len(results) < max_results:
245
+ try:
246
+ wiki = _search_wikipedia(query, max_results=max_results)
247
+ seen = {item["url"] for item in results}
248
+ for item in wiki:
249
+ if item["url"] in seen:
250
+ continue
251
+ results.append(item)
252
+ if len(results) >= max_results:
253
+ break
254
+ except Exception as error:
255
+ errors.append(f"Wikipedia search failed: {error}")
256
+ elapsed = time.perf_counter() - started
257
+ if not results:
258
+ message = "; ".join(errors) if errors else "Search returned no results"
259
+ return _empty_payload(query, error=message, response_time=elapsed)
260
+ clipped = results[: max(1, min(int(max_results), 20))]
261
+ for index, item in enumerate(clipped):
262
+ if item.get("score") is None:
263
+ item["score"] = round(max(0.05, 1.0 - (index * 0.07)), 8)
264
+ item.setdefault("raw_content", None)
265
+ answer = clipped[0].get("content") if clipped else None
266
+ if isinstance(answer, str) and not answer.strip():
267
+ answer = None
268
+ return {
269
+ "query": query,
270
+ "follow_up_questions": None,
271
+ "answer": answer,
272
+ "images": [],
273
+ "results": clipped,
274
+ "response_time": elapsed,
275
+ "request_id": None,
276
+ }
277
+
278
+
279
+ def _search_duckduckgo_html(query: str, *, max_results: int) -> list[dict[str, Any]]:
280
+ encoded = urlencode({"q": query, "kl": "us-en"})
281
+ html = _read_text(
282
+ DUCKDUCKGO_HTML_URL,
283
+ data=encoded.encode("utf-8"),
284
+ headers={
285
+ "Content-Type": "application/x-www-form-urlencoded",
286
+ "Accept": "text/html",
287
+ },
288
+ method="POST",
289
+ )
290
+ parser = _DuckDuckGoParser()
291
+ parser.feed(html)
292
+ parser.close()
293
+ results: list[dict[str, Any]] = []
294
+ seen: set[str] = set()
295
+ for item in parser.results:
296
+ url = item["url"]
297
+ if not url or url in seen:
298
+ continue
299
+ seen.add(url)
300
+ results.append(
301
+ {
302
+ "url": url,
303
+ "title": item["title"],
304
+ "content": item["content"],
305
+ "score": None,
306
+ "raw_content": None,
307
+ }
308
+ )
309
+ if len(results) >= max_results:
310
+ break
311
+ return results
312
+
313
+
314
+ def _search_wikipedia(query: str, *, max_results: int) -> list[dict[str, Any]]:
315
+ params = urlencode(
316
+ {
317
+ "action": "query",
318
+ "list": "search",
319
+ "srsearch": query,
320
+ "srlimit": max(1, min(int(max_results), 10)),
321
+ "srprop": "snippet",
322
+ "format": "json",
323
+ "utf8": "1",
324
+ }
325
+ )
326
+ payload = _read_json(
327
+ f"{WIKIPEDIA_API_URL}?{params}",
328
+ headers={"Accept": "application/json"},
329
+ method="GET",
330
+ )
331
+ if not isinstance(payload, dict):
332
+ return []
333
+ query_block = payload.get("query")
334
+ hits = query_block.get("search") if isinstance(query_block, dict) else None
335
+ if not isinstance(hits, list):
336
+ return []
337
+ results: list[dict[str, Any]] = []
338
+ for hit in hits:
339
+ if not isinstance(hit, dict):
340
+ continue
341
+ title = str(hit.get("title") or "").strip()
342
+ if not title:
343
+ continue
344
+ snippet = unescape(_strip_tags(str(hit.get("snippet") or ""))).strip()
345
+ results.append(
346
+ {
347
+ "url": f"https://en.wikipedia.org/wiki/{quote(title.replace(' ', '_'))}",
348
+ "title": title,
349
+ "content": snippet,
350
+ "score": None,
351
+ "raw_content": None,
352
+ }
353
+ )
354
+ return results
355
+
356
+
357
+ def unwrap_ddg_url(href: str) -> str:
358
+ raw = href.strip()
359
+ if raw.startswith("//"):
360
+ raw = f"https:{raw}"
361
+ parsed = urlparse(raw)
362
+ host = parsed.netloc.lower()
363
+ if "duckduckgo.com" in host:
364
+ values = parse_qs(parsed.query).get("uddg") or []
365
+ if values:
366
+ return unquote(values[0])
367
+ return raw
368
+
369
+
370
+ def _strip_tags(value: str) -> str:
371
+ return _TagStripper.strip(value)
372
+
373
+
374
+ class _TagStripper(HTMLParser):
375
+ def __init__(self) -> None:
376
+ super().__init__()
377
+ self.chunks: list[str] = []
378
+
379
+ def handle_data(self, data: str) -> None:
380
+ self.chunks.append(data)
381
+
382
+ @classmethod
383
+ def strip(cls, value: str) -> str:
384
+ parser = cls()
385
+ parser.feed(value)
386
+ parser.close()
387
+ return "".join(parser.chunks)
388
+
389
+
390
+ class _DuckDuckGoParser(HTMLParser):
391
+ def __init__(self) -> None:
392
+ super().__init__()
393
+ self.results: list[dict[str, str]] = []
394
+ self._in_title = False
395
+ self._in_snippet = False
396
+ self._href = ""
397
+ self._buffer: list[str] = []
398
+ self._pending: dict[str, str] | None = None
399
+
400
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
401
+ classes = {item for item in (dict(attrs).get("class") or "").split() if item}
402
+ if tag == "a" and "result__a" in classes:
403
+ self._flush_pending()
404
+ self._in_title = True
405
+ self._href = dict(attrs).get("href") or ""
406
+ self._buffer = []
407
+ return
408
+ if tag in {"a", "div"} and "result__snippet" in classes:
409
+ self._in_snippet = True
410
+ self._buffer = []
411
+
412
+ def handle_endtag(self, tag: str) -> None:
413
+ if tag == "a" and self._in_title:
414
+ title = unescape("".join(self._buffer)).strip()
415
+ url = unwrap_ddg_url(self._href)
416
+ self._pending = {"title": title, "url": url, "content": ""}
417
+ self._in_title = False
418
+ self._buffer = []
419
+ return
420
+ if self._in_snippet and tag in {"a", "div"}:
421
+ snippet = unescape("".join(self._buffer)).strip()
422
+ if self._pending is not None:
423
+ self._pending["content"] = snippet
424
+ self.results.append(self._pending)
425
+ self._pending = None
426
+ self._in_snippet = False
427
+ self._buffer = []
428
+
429
+ def handle_data(self, data: str) -> None:
430
+ if self._in_title or self._in_snippet:
431
+ self._buffer.append(data)
432
+
433
+ def close(self) -> None:
434
+ self._flush_pending()
435
+ super().close()
436
+
437
+ def _flush_pending(self) -> None:
438
+ if self._pending is None:
439
+ return
440
+ self.results.append(self._pending)
441
+ self._pending = None
442
+
443
+
444
+ def _read_json(
445
+ url: str,
446
+ *,
447
+ data: bytes | None = None,
448
+ headers: Mapping[str, str],
449
+ method: str,
450
+ ) -> Any:
451
+ text = _read_text(url, data=data, headers=headers, method=method)
452
+ return json.loads(text)
453
+
454
+
455
+ def _read_text(
456
+ url: str,
457
+ *,
458
+ data: bytes | None = None,
459
+ headers: Mapping[str, str],
460
+ method: str,
461
+ ) -> str:
462
+ merged = {"User-Agent": USER_AGENT, **dict(headers)}
463
+ request = Request(url, data=data, headers=merged, method=method)
464
+ try:
465
+ with urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response:
466
+ raw = response.read()
467
+ charset = response.headers.get_content_charset() or "utf-8"
468
+ except HTTPError as error:
469
+ detail = error.read().decode("utf-8", errors="replace")
470
+ raise RuntimeError(
471
+ f"{error.code} {error.reason} from {url}: {detail[:500]}"
472
+ ) from error
473
+ except URLError as error:
474
+ raise RuntimeError(f"Could not reach {url}: {error.reason}") from error
475
+ return raw.decode(charset, errors="replace")
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_bos_token": false,
3
+ "add_eos_token": false,
4
+ "bos_token": "<|begin_of_text|>",
5
+ "eos_token": "<|end_of_text|>",
6
+ "pad_token": "<|padding|>",
7
+ "unk_token": "<|unknown|>",
8
+ "model_max_length": 2048,
9
+ "clean_up_tokenization_spaces": false,
10
+ "tokenizer_class": "PreTrainedTokenizerFast",
11
+ "chat_template": "{%- set ns = namespace(xml_tools=none, tools_emitted=false) -%}\n{%- if xml_tools is defined and xml_tools -%}\n{%- set ns.xml_tools = xml_tools -%}\n{%- elif tools is defined and tools -%}\n{%- set ns.xml_tools = tools -%}\n{%- endif -%}\n{%- set tools_preamble = 'You may call one or more functions to assist with the user query.\\nYou are provided with function signatures within <tools></tools> XML tags:\\n<tools>\\n' -%}\n{%- set tools_epilogue = '</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>' -%}\n{%- for message in messages -%}\n{%- if loop.first -%}{{- bos_token -}}{%- endif -%}\n{%- if loop.first and ns.xml_tools and message['role'] != 'system' -%}\n{{- '<|im_start|>system\\n' + tools_preamble -}}\n{%- for tool in ns.xml_tools -%}\n{%- if tool is string -%}{{- (tool | replace('<tools>', '') | replace('</tools>', '') | trim) + '\\n' -}}\n{%- else -%}{{- tool | tojson + '\\n' -}}\n{%- endif -%}\n{%- endfor -%}\n{{- tools_epilogue + '<|im_end|>\\n' -}}\n{%- set ns.tools_emitted = true -%}\n{%- endif -%}\n{%- set raw_role = message['role'] -%}\n{%- set role = 'user' if raw_role == 'tool' or raw_role == 'function' else raw_role -%}\n{%- set content_text = namespace(value='') -%}\n{%- if message['content'] is string -%}\n{%- set content_text.value = message['content'] -%}\n{%- elif message['content'] is iterable -%}\n{%- for item in message['content'] -%}\n{%- if item['type'] == 'text' -%}{%- set content_text.value = content_text.value + item['text'] -%}{%- endif -%}\n{%- endfor -%}\n{%- endif -%}\n{%- set is_tool = raw_role == 'tool' or raw_role == 'function' -%}\n{%- set prev_is_tool = loop.previtem is defined and (loop.previtem['role'] == 'tool' or loop.previtem['role'] == 'function') -%}\n{%- set next_is_tool = loop.nextitem is defined and (loop.nextitem['role'] == 'tool' or loop.nextitem['role'] == 'function') -%}\n{%- if raw_role == 'system' and not (content_text.value | trim) and not (ns.xml_tools and not ns.tools_emitted) -%}\n{%- else -%}\n{%- if is_tool and prev_is_tool -%}\n{{- '\\n' -}}\n{%- else -%}\n{{- '<|im_start|>' + role + '\\n' -}}\n{%- endif -%}\n{%- if raw_role == 'assistant' and enable_thinking is defined -%}\n{{- ('<|think|>\\n' if enable_thinking else '<|no_think|>\\n') -}}\n{%- endif -%}\n{%- if is_tool -%}\n{{- '<|tool_response|>\\n' + (content_text.value | replace('<tool_response>', '') | replace('</tool_response>', '') | trim) -}}\n{%- elif raw_role == 'system' -%}\n{{- content_text.value | replace('/system_override', '') | replace('/no_think', '') | replace('/think', '') | trim -}}\n{%- else -%}\n{{- content_text.value -}}\n{%- endif -%}\n{%- if raw_role == 'system' and ns.xml_tools and not ns.tools_emitted and '<tools>' not in content_text.value -%}\n{%- if content_text.value | trim -%}{{- '\\n\\n' -}}{%- endif -%}\n{{- tools_preamble -}}\n{%- for tool in ns.xml_tools -%}\n{%- if tool is string -%}{{- (tool | replace('<tools>', '') | replace('</tools>', '') | trim) + '\\n' -}}\n{%- else -%}{{- tool | tojson + '\\n' -}}\n{%- endif -%}\n{%- endfor -%}\n{{- tools_epilogue -}}\n{%- set ns.tools_emitted = true -%}\n{%- endif -%}\n{%- if raw_role == 'assistant' and message['tool_calls'] is defined and message['tool_calls'] -%}\n{%- if '<tool_call>' not in content_text.value -%}\n{%- for tool_call in message['tool_calls'] -%}\n{%- set fn = tool_call['function'] if tool_call['function'] is defined else tool_call -%}\n{%- if loop.first and not (content_text.value | trim) -%}\n{{- '<tool_call>\\n{\"name\": \"' + fn['name'] + '\", \"arguments\": ' -}}\n{%- else -%}\n{{- '\\n<tool_call>\\n{\"name\": \"' + fn['name'] + '\", \"arguments\": ' -}}\n{%- endif -%}\n{%- if fn['arguments'] is string -%}{{- fn['arguments'] -}}\n{%- else -%}{{- fn['arguments'] | tojson -}}\n{%- endif -%}\n{{- '}\\n</tool_call>' -}}\n{%- endfor -%}\n{%- endif -%}\n{%- endif -%}\n{%- if not (is_tool and next_is_tool) -%}\n{{- '<|im_end|>\\n' -}}\n{%- endif -%}\n{%- endif -%}\n{%- endfor -%}\n{%- if add_generation_prompt -%}\n{{- '<|im_start|>assistant\\n' -}}\n{%- if enable_thinking is defined -%}{{- ('<|think|>\\n' if enable_thinking else '<|no_think|>\\n') -}}{%- endif -%}\n{%- endif -%}",
12
+ "extra_special_tokens": [
13
+ "<|im_start|>",
14
+ "<|im_end|>",
15
+ "<|tool_call|>",
16
+ "<|tool_response|>",
17
+ "<think>",
18
+ "</think>",
19
+ "<|fim_prefix|>",
20
+ "<|fim_middle|>",
21
+ "<|fim_suffix|>",
22
+ "<|fim_pad|>",
23
+ "<|no_think|>",
24
+ "<|think|>",
25
+ "<|reserved_002|>",
26
+ "<|reserved_003|>",
27
+ "<|reserved_004|>",
28
+ "<|reserved_005|>",
29
+ "<|reserved_006|>",
30
+ "<|reserved_007|>",
31
+ "<|reserved_008|>",
32
+ "<|reserved_009|>",
33
+ "<|reserved_010|>",
34
+ "<|reserved_011|>",
35
+ "<|reserved_012|>",
36
+ "<|reserved_013|>",
37
+ "<|reserved_014|>",
38
+ "<|reserved_015|>",
39
+ "<|reserved_016|>",
40
+ "<|reserved_017|>",
41
+ "<|reserved_018|>",
42
+ "<|reserved_019|>",
43
+ "<|reserved_020|>",
44
+ "<|reserved_021|>",
45
+ "<|reserved_022|>",
46
+ "<|reserved_023|>",
47
+ "<|reserved_024|>",
48
+ "<|reserved_025|>",
49
+ "<|reserved_026|>",
50
+ "<|reserved_027|>",
51
+ "<|reserved_028|>",
52
+ "<|reserved_029|>",
53
+ "<|reserved_030|>",
54
+ "<|reserved_031|>",
55
+ "<|reserved_032|>",
56
+ "<|reserved_033|>",
57
+ "<|reserved_034|>",
58
+ "<|reserved_035|>",
59
+ "<|reserved_036|>",
60
+ "<|reserved_037|>",
61
+ "<|reserved_038|>",
62
+ "<|reserved_039|>",
63
+ "<|reserved_040|>",
64
+ "<|reserved_041|>",
65
+ "<|reserved_042|>",
66
+ "<|reserved_043|>",
67
+ "<|reserved_044|>",
68
+ "<|reserved_045|>",
69
+ "<|reserved_046|>",
70
+ "<|reserved_047|>",
71
+ "<|reserved_048|>",
72
+ "<|reserved_049|>"
73
+ ]
74
+ }
validation.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "checkpoint": "checkpoint-00000704",
3
+ "ema": {
4
+ "elapsed_seconds": 54.355851609958336,
5
+ "logps_chosen": -601.3653792912514,
6
+ "logps_rejected": -652.0577934662236,
7
+ "loss": 0.8922630808546166,
8
+ "nll_chosen_per_token": 0.5581774069041227,
9
+ "pairs": 903,
10
+ "response_tokens": 2363563,
11
+ "rewards_accuracy": 0.7895902547065338,
12
+ "rewards_chosen": -1.6784689598041251,
13
+ "rewards_margin": 1.3302929308987403,
14
+ "rewards_rejected": -3.008761755519795
15
+ },
16
+ "normal": {
17
+ "elapsed_seconds": 53.85813916684128,
18
+ "logps_chosen": -601.1099114064231,
19
+ "logps_rejected": -651.828419158361,
20
+ "loss": 0.8913962870077173,
21
+ "nll_chosen_per_token": 0.5579402858352829,
22
+ "pairs": 903,
23
+ "response_tokens": 2363563,
24
+ "rewards_accuracy": 0.7918050941306756,
25
+ "rewards_chosen": -1.6656929430111433,
26
+ "rewards_margin": 1.331598123442691,
27
+ "rewards_rejected": -2.997290931270764
28
+ },
29
+ "optimizer_step": 704,
30
+ "type": "preference_validation"
31
+ }
vocab.json ADDED
The diff for this file is too large to render. See raw diff
 
windows_fla_patches/fla/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2023-2026, Songlin Yang, Yu Zhang, Zhiyuan Li
2
+ #
3
+ # Keep package import light. Eagerly importing fla.layers pulls every Triton
4
+ # kernel and breaks on Windows + Triton 3.7.
5
+
6
+ from pkgutil import extend_path
7
+
8
+ __path__ = extend_path(__path__, __name__)
9
+ __version__ = "0.5.2"
10
+ __all__: list[str] = []
windows_fla_patches/fla/layers/__init__.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2023-2026, Songlin Yang, Yu Zhang, Zhiyuan Li
2
+ #
3
+ # Lazy layer exports — avoid compiling every Triton kernel at import time.
4
+
5
+ from __future__ import annotations
6
+
7
+ import importlib
8
+ from typing import Any
9
+
10
+ _EXPORTS: dict[str, tuple[str, str]] = {
11
+ "ABCAttention": (".abc", "ABCAttention"),
12
+ "Attention": (".attn", "Attention"),
13
+ "BasedLinearAttention": (".based", "BasedLinearAttention"),
14
+ "BitAttention": (".bitattn", "BitAttention"),
15
+ "Comba": (".comba", "Comba"),
16
+ "DeltaNet": (".delta_net", "DeltaNet"),
17
+ "DeltaFormerAttention": (".deltaformer", "DeltaFormerAttention"),
18
+ "ForgettingAttention": (".forgetting_attn", "ForgettingAttention"),
19
+ "GatedDeltaNet": (".gated_deltanet", "GatedDeltaNet"),
20
+ "GatedDeltaProduct": (".gated_deltaproduct", "GatedDeltaProduct"),
21
+ "GatedDeltaNet2": (".gdn2", "GatedDeltaNet2"),
22
+ "GatedLinearAttention": (".gla", "GatedLinearAttention"),
23
+ "GatedSlotAttention": (".gsa", "GatedSlotAttention"),
24
+ "HGRNAttention": (".hgrn", "HGRNAttention"),
25
+ "HGRN2Attention": (".hgrn2", "HGRN2Attention"),
26
+ "KimiDeltaAttention": (".kda", "KimiDeltaAttention"),
27
+ "LightNetAttention": (".lightnet", "LightNetAttention"),
28
+ "LinearAttention": (".linear_attn", "LinearAttention"),
29
+ "LogLinearMamba2": (".log_linear_mamba2", "LogLinearMamba2"),
30
+ "Mamba": (".mamba", "Mamba"),
31
+ "Mamba2": (".mamba2", "Mamba2"),
32
+ "Mamba3": (".mamba3", "Mamba3"),
33
+ "MesaNet": (".mesa_net", "MesaNet"),
34
+ "MultiheadLatentAttention": (".mla", "MultiheadLatentAttention"),
35
+ "MoBA": (".moba", "MoBA"),
36
+ "MomAttention": (".mom", "MomAttention"),
37
+ "MultiScaleRetention": (".multiscale_retention", "MultiScaleRetention"),
38
+ "NativeSparseAttention": (".nsa", "NativeSparseAttention"),
39
+ "Parallax": (".parallax", "Parallax"),
40
+ "PaTHAttention": (".path_attn", "PaTHAttention"),
41
+ "Raven": (".raven", "Raven"),
42
+ "ReBasedLinearAttention": (".rebased", "ReBasedLinearAttention"),
43
+ "RodimusAttention": (".rodimus", "RodimusAttention"),
44
+ "SlidingWindowSharedKeyAttention": (".rodimus", "SlidingWindowSharedKeyAttention"),
45
+ "RWKV6Attention": (".rwkv6", "RWKV6Attention"),
46
+ "RWKV7Attention": (".rwkv7", "RWKV7Attention"),
47
+ "WallAttention": (".wall_attn", "WallAttention"),
48
+ "YOCOCrossAttention": (".yoco", "YOCOCrossAttention"),
49
+ "YOCOGatedRetention": (".yoco", "YOCOGatedRetention"),
50
+ "YOCOSharedKVBuilder": (".yoco", "YOCOSharedKVBuilder"),
51
+ }
52
+
53
+ __all__ = list(_EXPORTS)
54
+
55
+
56
+ def __getattr__(name: str) -> Any:
57
+ spec = _EXPORTS.get(name)
58
+ if spec is None:
59
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
60
+ module_name, attr = spec
61
+ value = getattr(importlib.import_module(module_name, __name__), attr)
62
+ globals()[name] = value
63
+ return value
windows_fla_patches/fla/ops/__init__.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2023-2026, Songlin Yang, Yu Zhang, Zhiyuan Li
2
+ #
3
+ # Lazy public exports so importing fla.ops.utils / fla.ops.gdn2 does not
4
+ # eagerly compile every Triton kernel (needed on Windows + Triton 3.7).
5
+
6
+ from __future__ import annotations
7
+
8
+ import importlib
9
+ from typing import Any
10
+
11
+ _EXPORTS: dict[str, str] = {
12
+ "chunk_abc": "fla.ops.abc",
13
+ "parallel_attn": "fla.ops.attn",
14
+ "fused_attnres": "fla.ops.attnres",
15
+ "fused_chunk_based": "fla.ops.based",
16
+ "parallel_based": "fla.ops.based",
17
+ "chunk_comba": "fla.ops.comba",
18
+ "fused_recurrent_comba": "fla.ops.comba",
19
+ "chunk_delta_rule": "fla.ops.delta_rule",
20
+ "fused_chunk_delta_rule": "fla.ops.delta_rule",
21
+ "fused_recurrent_delta_rule": "fla.ops.delta_rule",
22
+ "parallel_forgetting_attn": "fla.ops.forgetting_attn",
23
+ "chunk_gated_delta_rule": "fla.ops.gated_delta_rule",
24
+ "chunk_gdn": "fla.ops.gated_delta_rule",
25
+ "fused_recurrent_gated_delta_rule": "fla.ops.gated_delta_rule",
26
+ "fused_recurrent_gdn": "fla.ops.gated_delta_rule",
27
+ "chunk_dplr_delta_rule": "fla.ops.generalized_delta_rule",
28
+ "chunk_iplr_delta_rule": "fla.ops.generalized_delta_rule",
29
+ "fused_recurrent_dplr_delta_rule": "fla.ops.generalized_delta_rule",
30
+ "fused_recurrent_iplr_delta_rule": "fla.ops.generalized_delta_rule",
31
+ "chunk_gla": "fla.ops.gla",
32
+ "fused_chunk_gla": "fla.ops.gla",
33
+ "fused_recurrent_gla": "fla.ops.gla",
34
+ "chunk_gsa": "fla.ops.gsa",
35
+ "fused_recurrent_gsa": "fla.ops.gsa",
36
+ "fused_recurrent_hgrn": "fla.ops.hgrn",
37
+ "chunk_kda": "fla.ops.kda",
38
+ "fused_recurrent_kda": "fla.ops.kda",
39
+ "chunk_lightning_attn": "fla.ops.lightning_attn",
40
+ "fused_recurrent_lightning_attn": "fla.ops.lightning_attn",
41
+ "chunk_linear_attn": "fla.ops.linear_attn",
42
+ "fused_chunk_linear_attn": "fla.ops.linear_attn",
43
+ "fused_recurrent_linear_attn": "fla.ops.linear_attn",
44
+ "chunk_log_linear_attn": "fla.ops.log_linear_attn",
45
+ "chunk_mesa_net": "fla.ops.mesa_net",
46
+ "parallel_nsa": "fla.ops.nsa",
47
+ "parallel_parallax": "fla.ops.parallax",
48
+ "parallel_path_attn": "fla.ops.path_attn",
49
+ "chunk_retention": "fla.ops.retention",
50
+ "fused_chunk_retention": "fla.ops.retention",
51
+ "fused_recurrent_retention": "fla.ops.retention",
52
+ "parallel_retention": "fla.ops.retention",
53
+ "chunk_rwkv6": "fla.ops.rwkv6",
54
+ "fused_recurrent_rwkv6": "fla.ops.rwkv6",
55
+ "chunk_rwkv7": "fla.ops.rwkv7",
56
+ "fused_recurrent_rwkv7": "fla.ops.rwkv7",
57
+ "chunk_simple_gla": "fla.ops.simple_gla",
58
+ "fused_chunk_simple_gla": "fla.ops.simple_gla",
59
+ "fused_recurrent_simple_gla": "fla.ops.simple_gla",
60
+ "parallel_simple_gla": "fla.ops.simple_gla",
61
+ "parallel_wall_attn": "fla.ops.wall_attn",
62
+ "parallel_wall_attn_decode": "fla.ops.wall_attn",
63
+ "chunk_gdn2": "fla.ops.gdn2",
64
+ "fused_recurrent_gdn2": "fla.ops.gdn2",
65
+ }
66
+
67
+ __all__ = list(_EXPORTS)
68
+
69
+
70
+ def __getattr__(name: str) -> Any:
71
+ module_name = _EXPORTS.get(name)
72
+ if module_name is None:
73
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
74
+ value = getattr(importlib.import_module(module_name), name)
75
+ globals()[name] = value
76
+ return value
windows_fla_patches/fla/ops/simple_gla/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) 2023-2026, Songlin Yang, Yu Zhang, Zhiyuan Li
2
+ #
3
+ # This source code is licensed under the MIT license found in the
4
+ # LICENSE file in the root directory of this source tree.
5
+ # For a list of all contributors, visit:
6
+ # https://github.com/fla-org/flash-linear-attention/graphs/contributors
7
+
8
+ from .chunk import chunk_simple_gla
9
+ from .fused_chunk import fused_chunk_simple_gla
10
+ from .fused_recurrent import fused_recurrent_simple_gla
11
+
12
+ # Triton 3.7 on Windows can fail while decorating parallel kernels at import time.
13
+ try:
14
+ from .parallel import parallel_simple_gla
15
+ except Exception: # noqa: BLE001
16
+ parallel_simple_gla = None
17
+
18
+ __all__ = [
19
+ 'chunk_simple_gla',
20
+ 'fused_chunk_simple_gla',
21
+ 'fused_recurrent_simple_gla',
22
+ 'parallel_simple_gla',
23
+ ]