metrale_circuit/ir.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-28: The circuit IR: a closed op vocabulary, edges with a format and a shape, nodes
4//! bound to checkpoint modules, and the instantiated graph of one model.
5//!
6//! Owner: metrale-circuit.
7//! Invariants:
8//! - [`OpKind`] is closed. A template naming anything else is a load error, never an
9//! opaque pass-through.
10//! - Heavy ops ([`OpKind::is_heavy`]) are opaque: the fuser fuses at their edges or through a
11//! pre-written kernel, and never generates them.
12//! - Nodes are stored in execution order, which is a topological order: every input of a
13//! node is produced by an earlier node or is a circuit input.
14//! - An edge's fused or materialised state belongs to a plan (`FusionPlan::edge_states`),
15//! not to the circuit, because one circuit has one plan per mode and row count.
16
17use std::collections::BTreeMap;
18
19use crate::dims::DimExpr;
20use crate::format::Format;
21
22/// 2026-09-28: What a linear (GEMV/GEMM) node projects. Closed, like [`OpKind`].
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub enum LinearRole {
25 /// 2026-09-28: Attention Q with its output gate, interleaved per head.
26 Q,
27 /// 2026-09-28: Attention K.
28 K,
29 /// 2026-09-28: Attention V.
30 V,
31 /// 2026-09-28: Attention output projection.
32 O,
33 /// 2026-09-28: GatedDeltaNet `in_proj_qkv` and `in_proj_z` in one projection.
34 Qkvz,
35 /// 2026-09-28: GatedDeltaNet `in_proj_b` and `in_proj_a` (the beta and decay inputs).
36 Ba,
37 /// 2026-09-28: GatedDeltaNet `out_proj`.
38 GdnOut,
39 /// 2026-09-28: MLP gate and up projections.
40 GateUp,
41 /// 2026-09-28: MLP down projection.
42 Down,
43 /// 2026-09-28: MoE shared-expert gate and up projections.
44 SharedGateUp,
45 /// 2026-09-28: MoE shared-expert down projection.
46 SharedDown,
47 /// 2026-09-28: MoE shared-expert scalar gate (`shared_expert_gate`).
48 SharedGate,
49 /// 2026-09-28: MTP head input projection (`fc`, embedding and hidden concatenated).
50 MtpFc,
51 /// 2026-09-29: Mamba2 `in_proj`: z, x, B, C and dt in one projection.
52 MambaIn,
53 /// 2026-09-29: Mamba2 `out_proj`.
54 MambaOut,
55 /// 2026-09-29: An ungated MoE shared expert's up projection.
56 SharedUp,
57 /// 2026-09-30: A latent MoE's projection from the hidden width into the experts' latent
58 /// width (`fc1_latent_proj`, Nemotron-3 Super).
59 MoeLatentIn,
60 /// 2026-09-30: A latent MoE's projection of the routed sum back to the hidden width
61 /// (`fc2_latent_proj`).
62 MoeLatentOut,
63}
64
65const ROLES: [(LinearRole, &str); 18] = [
66 (LinearRole::Q, "q"),
67 (LinearRole::K, "k"),
68 (LinearRole::V, "v"),
69 (LinearRole::O, "o"),
70 (LinearRole::Qkvz, "qkvz"),
71 (LinearRole::Ba, "ba"),
72 (LinearRole::GdnOut, "gdn_out"),
73 (LinearRole::GateUp, "gate_up"),
74 (LinearRole::Down, "down"),
75 (LinearRole::SharedGateUp, "shared_gate_up"),
76 (LinearRole::SharedDown, "shared_down"),
77 (LinearRole::SharedGate, "shared_gate"),
78 (LinearRole::MtpFc, "mtp_fc"),
79 (LinearRole::MambaIn, "mamba_in"),
80 (LinearRole::MambaOut, "mamba_out"),
81 (LinearRole::SharedUp, "shared_up"),
82 (LinearRole::MoeLatentIn, "moe_latent_in"),
83 (LinearRole::MoeLatentOut, "moe_latent_out"),
84];
85
86impl LinearRole {
87 /// 2026-09-28: The role spelled in templates and rules.
88 pub fn parse(s: &str) -> Option<Self> {
89 ROLES.iter().find(|(_, n)| *n == s).map(|(r, _)| *r)
90 }
91
92 /// 2026-09-28: The canonical spelling.
93 pub fn name(self) -> &'static str {
94 ROLES
95 .iter()
96 .find(|(r, _)| *r == self)
97 .map(|(_, n)| *n)
98 .unwrap_or("?")
99 }
100}
101
102/// 2026-09-28: The closed op vocabulary.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
104pub enum OpKind {
105 /// 2026-09-28: Token embedding lookup; a source node.
106 Embed,
107 /// 2026-09-28: RMSNorm with a learned weight.
108 RmsNorm,
109 /// 2026-09-28: RMSNorm of one input gated by SiLU of another (GDN output norm).
110 GatedRmsNorm,
111 /// 2026-09-28: Elementwise sum of two streams.
112 ResidualAdd,
113 /// 2026-09-28: A copy between buffers.
114 Copy,
115 /// 2026-09-28: Concatenate inputs along the feature dim.
116 Concat,
117 /// 2026-09-28: Split or deinterleave a packed projection output.
118 Split,
119 /// 2026-09-28: A weight-bearing projection.
120 Linear(LinearRole),
121 /// 2026-09-28: `silu(gate) * up` over a packed gate|up input.
122 SiluMul,
123 /// 2026-09-29: `relu(x)^2` of an ungated up projection.
124 Relu2,
125 /// 2026-09-28: Quantize activations to the given format.
126 ActQuant(Format),
127 /// 2026-09-28: Per-head RMSNorm of Q or K.
128 QkNorm,
129 /// 2026-09-28: Rotary position embedding.
130 Rope,
131 /// 2026-09-28: Append K and V to the paged cache.
132 KvWrite,
133 /// 2026-09-28: Paged softmax attention over the cache.
134 PagedAttention,
135 /// 2026-09-28: `x * sigmoid(gate)` (attention output gate).
136 SigmoidGateMul,
137 /// 2026-09-28: GDN beta and decay from the `ba` projection.
138 GdnGates,
139 /// 2026-09-28: Causal depthwise conv1d step with its rolling state.
140 Conv1dUpdate,
141 /// 2026-09-28: L2 normalisation of Q and K heads.
142 L2Norm,
143 /// 2026-09-28: The gated delta rule recurrence over the recurrent state.
144 GdnRecurrence,
145 /// 2026-09-29: The Mamba2 selective state-space update: one step of the SSM state per row
146 /// (`dt` softplus, `A`, the grouped `B`/`C` projections and the `D` skip).
147 SsmUpdate,
148 /// 2026-09-28: MoE router logits.
149 Router,
150 /// 2026-09-28: Top-k expert selection and weights.
151 TopK,
152 /// 2026-09-28: Routed experts' gate and up projections.
153 ExpertGateUp,
154 /// 2026-09-28: Routed experts' down projections.
155 ExpertDown,
156 /// 2026-09-28: Weighted sum of the routed experts and the gated shared expert.
157 Blend,
158 /// 2026-09-28: Cross-rank reduction of an expert-parallel FFN output.
159 EpReduce,
160 /// 2026-09-28: The model's final RMSNorm.
161 FinalNorm,
162 /// 2026-09-28: Vocabulary projection.
163 LmHead,
164 /// 2026-09-28: Greedy token selection over the logits.
165 Argmax,
166 /// 2026-09-29: A snapshot of the recurrent state its input's producer updates, taken after
167 /// each row but the last into the step's checkpoint slots (the verify's rollback points).
168 /// It has no output.
169 StateSnapshot,
170}
171
172const PLAIN_OPS: [(OpKind, &str); 29] = [
173 (OpKind::Embed, "embed"),
174 (OpKind::RmsNorm, "rms_norm"),
175 (OpKind::GatedRmsNorm, "gated_rms_norm"),
176 (OpKind::ResidualAdd, "residual_add"),
177 (OpKind::Copy, "copy"),
178 (OpKind::Concat, "concat"),
179 (OpKind::Split, "split"),
180 (OpKind::SiluMul, "silu_mul"),
181 (OpKind::Relu2, "relu2"),
182 (OpKind::QkNorm, "qk_norm"),
183 (OpKind::Rope, "rope"),
184 (OpKind::KvWrite, "kv_write"),
185 (OpKind::PagedAttention, "paged_attention"),
186 (OpKind::SigmoidGateMul, "sigmoid_gate_mul"),
187 (OpKind::GdnGates, "gdn_gates"),
188 (OpKind::Conv1dUpdate, "conv1d_update"),
189 (OpKind::L2Norm, "l2_norm"),
190 (OpKind::GdnRecurrence, "gdn_recurrence"),
191 (OpKind::SsmUpdate, "ssm_update"),
192 (OpKind::Router, "router"),
193 (OpKind::TopK, "top_k"),
194 (OpKind::ExpertGateUp, "expert_gate_up"),
195 (OpKind::ExpertDown, "expert_down"),
196 (OpKind::Blend, "blend"),
197 (OpKind::EpReduce, "ep_reduce"),
198 (OpKind::FinalNorm, "final_norm"),
199 (OpKind::LmHead, "lm_head"),
200 (OpKind::Argmax, "argmax"),
201 (OpKind::StateSnapshot, "state_snapshot"),
202];
203
204impl OpKind {
205 /// 2026-09-28: Resolve an op name. `linear` needs `role`, `act_quant` needs `format`;
206 /// every other op refuses both.
207 pub fn parse(
208 name: &str,
209 role: Option<&str>,
210 format: Option<Format>,
211 ) -> Result<Self, OpParseError> {
212 match (name, role, format) {
213 ("linear", Some(r), None) => LinearRole::parse(r)
214 .map(OpKind::Linear)
215 .ok_or_else(|| OpParseError::UnknownRole(r.to_string())),
216 ("linear", None, _) => Err(OpParseError::MissingRole),
217 ("act_quant", None, Some(f)) => Ok(OpKind::ActQuant(f)),
218 ("act_quant", _, None) => Err(OpParseError::MissingFormat),
219 _ => {
220 let op = PLAIN_OPS
221 .iter()
222 .find(|(_, n)| *n == name)
223 .map(|(k, _)| *k)
224 .ok_or_else(|| OpParseError::UnknownOp(name.to_string()))?;
225 if role.is_some() || format.is_some() {
226 return Err(OpParseError::StrayQualifier(name.to_string()));
227 }
228 Ok(op)
229 }
230 }
231 }
232
233 /// 2026-09-28: The op's base name (`linear`, `act_quant`, `rms_norm`, ...).
234 pub fn base_name(&self) -> &'static str {
235 match self {
236 OpKind::Linear(_) => "linear",
237 OpKind::ActQuant(_) => "act_quant",
238 other => PLAIN_OPS
239 .iter()
240 .find(|(k, _)| k == other)
241 .map(|(_, n)| *n)
242 .unwrap_or("?"),
243 }
244 }
245
246 /// 2026-09-28: The canonical spelling with its qualifier, e.g. `linear:down`,
247 /// `act_quant:fp8/token`.
248 pub fn name(&self) -> String {
249 match self {
250 OpKind::Linear(r) => format!("linear:{}", r.name()),
251 OpKind::ActQuant(f) => format!("act_quant:{}", f.name()),
252 _ => self.base_name().to_string(),
253 }
254 }
255
256 /// 2026-09-28: An op that is never generated: projections, attention, the recurrence,
257 /// the experts and the vocabulary projection. Fusion happens at its edges.
258 pub fn is_heavy(&self) -> bool {
259 matches!(
260 self,
261 OpKind::Linear(_)
262 | OpKind::PagedAttention
263 | OpKind::GdnRecurrence
264 | OpKind::SsmUpdate
265 | OpKind::Router
266 | OpKind::ExpertGateUp
267 | OpKind::ExpertDown
268 | OpKind::LmHead
269 )
270 }
271
272 /// 2026-09-28: Ops that read a weight whose format the precision resolver answers.
273 pub fn reads_linear_weight(&self) -> bool {
274 matches!(
275 self,
276 OpKind::Linear(_)
277 | OpKind::ExpertGateUp
278 | OpKind::ExpertDown
279 | OpKind::LmHead
280 | OpKind::Router
281 )
282 }
283}
284
285/// 2026-09-28: Why an op spelling was refused.
286#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
287pub enum OpParseError {
288 /// 2026-09-28: Not in the vocabulary.
289 #[error("unknown op `{0}`")]
290 UnknownOp(String),
291 /// 2026-09-28: `linear` with a role outside [`LinearRole`].
292 #[error("unknown linear role `{0}`")]
293 UnknownRole(String),
294 /// 2026-09-28: `linear` without `role`.
295 #[error("`linear` needs a `role`")]
296 MissingRole,
297 /// 2026-09-28: `act_quant` without `format`.
298 #[error("`act_quant` needs a `format`")]
299 MissingFormat,
300 /// 2026-09-28: `role` or `format` on an op that takes neither.
301 #[error("op `{0}` takes no `role` or `format`")]
302 StrayQualifier(String),
303}
304
305/// 2026-09-28: Index of an edge in [`Circuit::edges`].
306pub type EdgeIdx = usize;
307/// 2026-09-28: Index of a node in [`Circuit::nodes`].
308pub type NodeIdx = usize;
309
310/// 2026-09-28: One tensor between nodes: `rows x dim` in `format`. `rows` is an expression
311/// over `n`, the step's row count, and the arch dims (e.g. `n*top_k`).
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct Edge {
314 /// 2026-09-28: Unique id, e.g. `l3.attn.q` or `stream.4`.
315 pub id: String,
316 /// 2026-09-28: Element format.
317 pub format: Format,
318 /// 2026-09-28: Row expression; reads `n`.
319 pub rows: DimExpr,
320 /// 2026-09-28: Feature dimension expression.
321 pub dim: DimExpr,
322 /// 2026-09-28: `dim` evaluated under the arch dims.
323 pub dim_value: u64,
324 /// 2026-09-28: The node that writes it; `None` for none (refused by the loader).
325 pub producer: Option<NodeIdx>,
326 /// 2026-09-28: The nodes that read it, in node order.
327 pub consumers: Vec<NodeIdx>,
328 /// 2026-09-28: Read outside the circuit (the logits); never fused away.
329 pub is_output: bool,
330 /// 2026-09-30: The model buffer a declared output lands in; `None` for an output declared
331 /// without one (the executor refuses such a program) and for every other edge.
332 pub binds: Option<crate::model_buffer::ModelBuffer>,
333}
334
335/// 2026-09-28: One op instance.
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub struct Node {
338 /// 2026-09-28: Unique id, e.g. `l3.attn.o_proj`.
339 pub id: String,
340 /// 2026-09-28: The id inside its block template, e.g. `o_proj`.
341 pub local: String,
342 /// 2026-09-28: The op.
343 pub op: OpKind,
344 /// 2026-09-28: Input edges, in the op's argument order.
345 pub inputs: Vec<EdgeIdx>,
346 /// 2026-09-28: Output edges.
347 pub outputs: Vec<EdgeIdx>,
348 /// 2026-09-28: Weight format, for ops that read a linear weight.
349 pub weight: Option<Format>,
350 /// 2026-09-28: Checkpoint modules this node reads (`layers.3.mlp.down_proj`), in order.
351 pub binding: Vec<String>,
352 /// 2026-09-28: Op parameters from the template, e.g. `top_k = "8"`.
353 pub params: BTreeMap<String, String>,
354 /// 2026-09-28: The layer this node belongs to; `None` in the prologue and epilogue.
355 pub layer: Option<usize>,
356 /// 2026-09-28: The block template it was instantiated from.
357 pub block: String,
358 /// 2026-09-30: The states it touches: indices into [`Circuit::states`], and how.
359 pub state: Vec<(usize, crate::state::StateAccess)>,
360}
361
362/// 2026-09-28: A decoder layer's kind, as `ModelConfig::layer_type` reports it.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
364pub enum LayerKind {
365 /// 2026-09-28: A GatedDeltaNet layer.
366 LinearAttention,
367 /// 2026-09-28: A softmax attention layer.
368 FullAttention,
369 /// 2026-09-29: A Mamba2 layer (Nemotron-H `mamba`).
370 Mamba,
371 /// 2026-09-29: A layer whose only mixer is a MoE FFN (Nemotron-H `moe`).
372 Moe,
373}
374
375impl LayerKind {
376 /// 2026-09-28: The spelling in HF configs and circuit layouts.
377 pub fn name(self) -> &'static str {
378 match self {
379 LayerKind::LinearAttention => "linear_attention",
380 LayerKind::FullAttention => "full_attention",
381 LayerKind::Mamba => "mamba",
382 LayerKind::Moe => "moe",
383 }
384 }
385
386 /// 2026-09-28: Parse the HF spelling.
387 pub fn parse(s: &str) -> Option<Self> {
388 match s {
389 "linear_attention" => Some(LayerKind::LinearAttention),
390 "full_attention" => Some(LayerKind::FullAttention),
391 "mamba" => Some(LayerKind::Mamba),
392 "moe" => Some(LayerKind::Moe),
393 _ => None,
394 }
395 }
396}
397
398/// 2026-09-28: What instantiation needs from a model config: its layer kinds in order and
399/// its named dims. The caller fills it from `ModelConfig`, so this crate does not depend on
400/// the config tree.
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct ArchShape {
403 /// 2026-09-28: Kind of each text-stack layer, index = layer.
404 pub layer_kinds: Vec<LayerKind>,
405 /// 2026-09-28: Named dims (`hidden`, `head_dim`, ...), the names the templates read.
406 pub dims: BTreeMap<String, u64>,
407}
408
409/// 2026-09-28: Which forward a block belongs to.
410#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
411pub enum Section {
412 /// 2026-09-28: The target model's forward.
413 Main,
414 /// 2026-09-28: The speculative draft head (MTP), which reads the main stream.
415 Draft,
416}
417
418/// 2026-09-28: One instantiated block: its template and the node range it produced.
419#[derive(Debug, Clone, PartialEq, Eq)]
420pub struct BlockInstance {
421 /// 2026-09-28: Template name.
422 pub template: String,
423 /// 2026-09-28: Layer index; `None` for the prologue, epilogue and draft.
424 pub layer: Option<usize>,
425 /// 2026-09-28: Main or draft.
426 pub section: Section,
427 /// 2026-09-28: First node.
428 pub first: NodeIdx,
429 /// 2026-09-28: One past the last node.
430 pub end: NodeIdx,
431 /// 2026-09-28: The stream edge entering the block, if it reads one.
432 pub stream_in: Option<EdgeIdx>,
433 /// 2026-09-28: The stream edge leaving it, if it writes one.
434 pub stream_out: Option<EdgeIdx>,
435}
436
437/// 2026-09-28: An instantiated circuit.
438#[derive(Debug, Clone, PartialEq, Eq)]
439pub struct Circuit {
440 /// 2026-09-28: The arch id of the TOML it came from (`qwen3_5`).
441 pub arch: String,
442 /// 2026-09-28: The TOML's one-line description.
443 pub description: String,
444 /// 2026-09-28: Nodes in execution order.
445 pub nodes: Vec<Node>,
446 /// 2026-09-28: All edges.
447 pub edges: Vec<Edge>,
448 /// 2026-09-28: Blocks in execution order.
449 pub blocks: Vec<BlockInstance>,
450 /// 2026-09-28: Layer kinds, from the arch shape.
451 pub layer_kinds: Vec<LayerKind>,
452 /// 2026-09-28: The dims every expression was evaluated under.
453 pub dims: BTreeMap<String, u64>,
454 /// 2026-09-30: The state every block keeps between steps, in block order.
455 pub states: Vec<crate::state::StateDecl>,
456}
457
458impl Circuit {
459 /// 2026-09-28: Find an edge by id.
460 pub fn edge(&self, id: &str) -> Option<EdgeIdx> {
461 self.edges.iter().position(|e| e.id == id)
462 }
463
464 /// 2026-09-28: Find a node by id.
465 pub fn node(&self, id: &str) -> Option<NodeIdx> {
466 self.nodes.iter().position(|n| n.id == id)
467 }
468}