Skip to main content

logicaffeine_compile/tensor_graph/
vjp.rs

1//! Reverse-mode differentiation of a tensor program, as a program.
2//!
3//! [`backward`] reads a forward program's tensor statements (the typed specs the front end lifts,
4//! [`crate::spatial::passes::recognize_stages`]) and writes the statements that compute the gradient of a scalar
5//! loss with respect to any tensor the forward reads. The backward pass is ORDINARY LOGOS — tensor sugar where the
6//! language has the form (`matmul`, `transpose`, `plus`, `hadamard`, `scaled by`), row-major loops over declared
7//! tensors where it does not — so the same front end, fusion and every backend take it, and the VM runs it in f64 as
8//! the oracle a gradient is checked against.
9//!
10//! The loss is `Σ out ⊙ cotangent` for a declared `cotangent` tensor of the output's shape: the seed of reverse
11//! mode is that tensor, and every gradient follows from it. A tensor read by several stages sums its gradients.
12//!
13//! Every op kind has a rule or a refusal that names it (`vjp_stage`, an exhaustive match over
14//! [`TensorOpSpec`]). A refusal is either [`VjpRefusal::NoGradient`] — the op is piecewise constant in its
15//! inputs (`argmax`) — or [`VjpRefusal::NotDerived`], an op whose rule is not written yet: a training program
16//! that reaches it is refused by name, never differentiated as if the op were absent.
17
18use std::collections::BTreeMap;
19use std::fmt::Write as _;
20
21use crate::spatial::passes::recognize_stages::{RecognizedProgram, RecognizedStage, TensorOpSpec};
22use crate::tensor_recognize::{ElementwiseOp, Epsilon, RowReduceKind};
23
24/// Why a stage has no backward rule.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum VjpRefusal {
27    /// The op is piecewise constant in its inputs: its gradient is zero almost everywhere and undefined at
28    /// the jumps, so no training signal flows through it.
29    NoGradient { op: &'static str, why: &'static str },
30    /// The op's rule is not written yet.
31    NotDerived { op: &'static str },
32    /// The loss depends on `tensor` through `op`, whose rule does not differentiate that operand (a table of
33    /// rotation angles, an integer index): asking for its gradient is refused rather than answered with zero.
34    NotDifferentiated { tensor: String, op: &'static str },
35    /// A tensor the backward pass needs has no shape the front end recorded.
36    Shapeless { tensor: String },
37    /// The forward program does not lift whole, so its backward cannot be derived from it.
38    Unrecognized { statements: Vec<(usize, String)> },
39    /// The output the loss is taken over is not a tensor any stage computes or the program declares.
40    UnknownOutput { tensor: String },
41    /// The parser refused the program.
42    Parse(String),
43}
44
45/// A derived backward pass.
46#[derive(Clone, Debug, PartialEq)]
47pub struct Backward {
48    /// The statements, one per line, to append inside `## Main` after the forward.
49    pub source: String,
50    /// For every tensor the forward reads that the loss depends on: the tensor holding its gradient.
51    pub grads: BTreeMap<String, String>,
52}
53
54/// Fresh names, so every tensor the backward binds is bound once — the front end refuses a second binding.
55struct Names {
56    next: usize,
57}
58
59impl Names {
60    fn fresh(&mut self, stem: &str) -> String {
61        self.next += 1;
62        format!("{stem}_{}", self.next)
63    }
64}
65
66/// The statements a backward pass is accumulating, and its gradient table.
67struct Out {
68    text: String,
69    names: Names,
70    grads: BTreeMap<String, String>,
71    shapes: BTreeMap<String, (usize, usize)>,
72    /// Operands the loss reaches through a rule that does not differentiate them, and that rule's op.
73    undiff: BTreeMap<String, &'static str>,
74}
75
76impl Out {
77    fn line(&mut self, depth: usize, s: impl AsRef<str>) {
78        for _ in 0..depth {
79            self.text.push_str("    ");
80        }
81        self.text.push_str(s.as_ref());
82        self.text.push('\n');
83    }
84
85    /// Declare a zero-filled `rows × cols` tensor of Real.
86    fn tensor(&mut self, stem: &str, (rows, cols): (usize, usize)) -> String {
87        let name = self.names.fresh(stem);
88        self.line(0, format!("Let {name} be a {rows} by {cols} tensor of Real."));
89        self.shapes.insert(name.clone(), (rows, cols));
90        name
91    }
92
93    /// Add `contribution` to the gradient of `tensor`: the first contribution is the gradient, a later one is
94    /// summed into a fresh tensor.
95    fn accumulate(&mut self, tensor: &str, contribution: String) {
96        match self.grads.get(tensor).cloned() {
97            None => {
98                self.grads.insert(tensor.to_string(), contribution);
99            }
100            Some(previous) => {
101                let sum = self.names.fresh(&format!("g_{tensor}"));
102                self.line(0, format!("Let {sum} be {previous} plus {contribution}."));
103                let shape = self.shapes[&previous];
104                self.shapes.insert(sum.clone(), shape);
105                self.grads.insert(tensor.to_string(), sum);
106            }
107        }
108    }
109
110    fn shape(&self, tensor: &str) -> Result<(usize, usize), VjpRefusal> {
111        self.shapes.get(tensor).copied().ok_or_else(|| VjpRefusal::Shapeless { tensor: tensor.to_string() })
112    }
113
114    /// A loop over every element of a `rows × cols` tensor, binding the row `r`, the column `c` and the
115    /// 1-based flat index `k`; `body` writes the inner statements at the given depth.
116    fn each(&mut self, (rows, cols): (usize, usize), body: impl FnOnce(&mut Out, usize, &str, &str, &str)) {
117        let r = self.names.fresh("r");
118        let c = self.names.fresh("c");
119        let k = self.names.fresh("k");
120        self.line(0, format!("Repeat for {r} from 1 to {rows}:"));
121        self.line(1, format!("Repeat for {c} from 1 to {cols}:"));
122        self.line(2, format!("Let {k} be ({r} - 1) * {cols} + {c}."));
123        body(self, 2, &r, &c, &k);
124    }
125}
126
127/// The backward pass of `forward` (a program whose `## Main` holds tensor statements) for the loss
128/// `Σ output ⊙ cotangent`, giving the gradient of every tensor in `wrt`. `cotangent` must be a declared tensor of
129/// `output`'s shape. A tensor in `wrt` the loss does not depend on gets a zero gradient.
130pub fn backward(forward: &str, output: &str, cotangent: &str, wrt: &[&str]) -> Result<Backward, VjpRefusal> {
131    let rp = crate::spatial::passes::recognize_stages::recognize_stages(forward)
132        .map_err(|e| VjpRefusal::Parse(format!("{e:?}")))?;
133    backward_of(&rp, output, cotangent, wrt)
134}
135
136/// [`backward`] over a program the front end has already lifted.
137pub fn backward_of(rp: &RecognizedProgram, output: &str, cotangent: &str, wrt: &[&str]) -> Result<Backward, VjpRefusal> {
138    if !rp.unrecognized.is_empty() {
139        return Err(VjpRefusal::Unrecognized { statements: rp.unrecognized.clone() });
140    }
141    let produced = rp.stages.iter().any(|s| s.out == output);
142    let declared = rp.declared.iter().any(|d| d.name == output);
143    if !produced && !declared {
144        return Err(VjpRefusal::UnknownOutput { tensor: output.to_string() });
145    }
146    let mut out = Out {
147        text: String::new(),
148        names: Names { next: 0 },
149        grads: BTreeMap::new(),
150        shapes: rp.shapes.iter().map(|(k, v)| (k.clone(), *v)).collect(),
151        undiff: BTreeMap::new(),
152    };
153    out.grads.insert(output.to_string(), cotangent.to_string());
154    for st in rp.stages.iter().rev() {
155        let Some(d_out) = out.grads.get(&st.out).cloned() else { continue };
156        vjp_stage(rp, st, &d_out, &mut out)?;
157    }
158    for &t in wrt {
159        if let Some(op) = out.undiff.get(t) {
160            return Err(VjpRefusal::NotDifferentiated { tensor: t.to_string(), op });
161        }
162        if !out.grads.contains_key(t) {
163            let shape = out.shape(t)?;
164            let zero = out.tensor(&format!("g_{t}"), shape);
165            out.grads.insert(t.to_string(), zero);
166        }
167    }
168    let grads = wrt.iter().map(|&t| (t.to_string(), out.grads[t].clone())).collect();
169    Ok(Backward { source: out.text, grads })
170}
171
172/// The rule of one stage: given `d_out`, the tensor holding the loss's gradient with respect to the stage's
173/// output, add each of its inputs' gradients.
174fn vjp_stage(rp: &RecognizedProgram, st: &RecognizedStage, d_out: &str, out: &mut Out) -> Result<(), VjpRefusal> {
175    match &st.op {
176        TensorOpSpec::Matmul(m) => {
177            // C = A·B with A m×k, B k×n: dA = dC·Bᵀ, dB = Aᵀ·dC.
178            let (a, b) = (rp.name(m.a).to_string(), rp.name(m.b).to_string());
179            let bt = out.names.fresh(&format!("t_{b}"));
180            out.line(0, format!("Let {bt} be transpose of {b}."));
181            out.shapes.insert(bt.clone(), (m.n, m.k));
182            let da = out.names.fresh(&format!("g_{a}"));
183            out.line(0, format!("Let {da} be {d_out} matmul {bt}."));
184            out.shapes.insert(da.clone(), (m.m, m.k));
185            let at = out.names.fresh(&format!("t_{a}"));
186            out.line(0, format!("Let {at} be transpose of {a}."));
187            out.shapes.insert(at.clone(), (m.k, m.m));
188            let db = out.names.fresh(&format!("g_{b}"));
189            out.line(0, format!("Let {db} be {at} matmul {d_out}."));
190            out.shapes.insert(db.clone(), (m.k, m.n));
191            out.accumulate(&a, da);
192            out.accumulate(&b, db);
193            Ok(())
194        }
195        TensorOpSpec::Elementwise(e) => {
196            let (a, b) = (rp.name(e.a).to_string(), rp.name(e.b).to_string());
197            match e.op {
198                ElementwiseOp::Add => {
199                    out.accumulate(&a, d_out.to_string());
200                    out.accumulate(&b, d_out.to_string());
201                }
202                ElementwiseOp::Mul => {
203                    let shape = out.shape(&a)?;
204                    let da = out.names.fresh(&format!("g_{a}"));
205                    out.line(0, format!("Let {da} be {d_out} hadamard {b}."));
206                    out.shapes.insert(da.clone(), shape);
207                    let db = out.names.fresh(&format!("g_{b}"));
208                    out.line(0, format!("Let {db} be {d_out} hadamard {a}."));
209                    out.shapes.insert(db.clone(), shape);
210                    out.accumulate(&a, da);
211                    out.accumulate(&b, db);
212                }
213            }
214            Ok(())
215        }
216        TensorOpSpec::Scale(c) => {
217            // y = x·c: dx = dy·c.
218            let x = rp.name(c.x).to_string();
219            let shape = out.shape(&x)?;
220            let dx = out.names.fresh(&format!("g_{x}"));
221            out.line(0, format!("Let {dx} be {d_out} scaled by {:?}.", c.factor.get()));
222            out.shapes.insert(dx.clone(), shape);
223            out.accumulate(&x, dx);
224            Ok(())
225        }
226        TensorOpSpec::Transpose(t) => {
227            let x = rp.name(t.x).to_string();
228            let shape = out.shape(&x)?;
229            let dx = out.names.fresh(&format!("g_{x}"));
230            out.line(0, format!("Let {dx} be transpose of {d_out}."));
231            out.shapes.insert(dx.clone(), shape);
232            out.accumulate(&x, dx);
233            Ok(())
234        }
235        TensorOpSpec::FusedRow(r) => {
236            let x = rp.name(r.x).to_string();
237            let y = rp.name(r.y).to_string();
238            let shape = (r.rows, r.cols);
239            match r.reduce {
240                RowReduceKind::Softmax => softmax_rule(out, &y, d_out, &x, shape),
241                RowReduceKind::RmsNorm => rmsnorm_rule(rp, r, out, d_out, shape),
242                RowReduceKind::LayerNorm => layernorm_rule(rp, r, out, d_out, shape),
243            }
244        }
245        TensorOpSpec::Sigmoid(s) => {
246            // y = σ(x): dx = dy · y · (1 − y).
247            let (x, y) = (rp.name(s.x).to_string(), rp.name(s.out).to_string());
248            pointwise(out, &x, d_out, &[&y], |v| format!("{0} * {1} * (1.0 - {1})", v[0], v[1]))
249        }
250        TensorOpSpec::Silu(s) => {
251            // y = x·σ(x): dx = dy · (σ + x·σ·(1 − σ)).
252            let x = rp.name(s.x).to_string();
253            pointwise_with_sigma(out, &x, d_out, |dy, x, sg| format!("{dy} * ({sg} + {x} * {sg} * (1.0 - {sg}))"))
254        }
255        TensorOpSpec::Gelu(g) => {
256            // y = x·s with s = σ(2z), z = √(2/π)·(x + 0.044715·x³): dy/dx = s + x·s·(1 − s)·2·√(2/π)·(1 + 3·0.044715·x²).
257            let x = rp.name(g.x).to_string();
258            gelu_rule(out, &x, d_out)
259        }
260        TensorOpSpec::SwiGlu(s) => {
261            // y = silu(g)·u: dg = dy·u·silu'(g), du = dy·silu(g).
262            let (g, u) = (rp.name(s.gate).to_string(), rp.name(s.up).to_string());
263            swiglu_rule(out, &g, &u, d_out)
264        }
265        TensorOpSpec::L2Norm(l) => l2norm_rule(out, rp.name(l.x), l.eps, d_out, (l.rows, l.cols)),
266        TensorOpSpec::RowGather(g) => {
267            let table = rp.name(g.table).to_string();
268            row_scatter_rule(rp, out, &table, d_out, &RowIndexText::of(rp, &g.index))
269        }
270        TensorOpSpec::RowsGather(g) => {
271            let table = rp.name(g.table).to_string();
272            let indices = rp.name(g.indices).to_string();
273            out.undiff.insert(indices.clone(), "RowsGather");
274            rows_scatter_rule(out, &table, &indices, d_out)
275        }
276        TensorOpSpec::ArgMax(_) => Err(VjpRefusal::NoGradient {
277            op: "ArgMax",
278            why: "the index of the largest element is piecewise constant in every input",
279        }),
280        TensorOpSpec::Rope(r) => rope_rule(rp, r, out, d_out),
281        TensorOpSpec::Decay(_) => Err(VjpRefusal::NotDerived { op: "Decay" }),
282        TensorOpSpec::CausalConv(_) => Err(VjpRefusal::NotDerived { op: "CausalConv" }),
283        TensorOpSpec::GatedDelta(_) => Err(VjpRefusal::NotDerived { op: "GatedDelta" }),
284        TensorOpSpec::Softcap(c) => {
285            // y = C·tanh(x/C): dx = dy · (1 − (y/C)²).
286            let (x, y, cap) = (rp.name(c.x).to_string(), rp.name(c.out).to_string(), c.cap.get());
287            pointwise(out, &x, d_out, &[&y], |v| format!("{0} * (1.0 - ({1} / {cap:?}) * ({1} / {cap:?}))", v[0], v[1]))
288        }
289        TensorOpSpec::Attention(a) => {
290            let attended = match &a.pos {
291                None => Attended::All(a.l),
292                Some(p) => Attended::From(RowIndexText::of(rp, p).0),
293            };
294            let shape = AttentionShape {
295                q: rp.name(a.q),
296                k: rp.name(a.kc),
297                v: rp.name(a.vc),
298                rows: a.rows,
299                h: a.h,
300                kv: a.kv,
301                hd: a.hd,
302                vd: a.vd,
303                l: a.l,
304                attended,
305                scale: a.scale.map_or(1.0 / (a.hd as f64).sqrt(), |s| s.get()),
306                cap: a.softcap.map(|c| c.get()),
307                window: a.window,
308                sinks: a.sinks.map(|s| rp.name(s)),
309            };
310            attention_rule(out, shape, d_out)
311        }
312        TensorOpSpec::FullAttention(a) => attention_rule(
313            out,
314            AttentionShape {
315                q: rp.name(a.q),
316                k: rp.name(a.k),
317                v: rp.name(a.v),
318                rows: a.t,
319                h: a.h,
320                kv: a.h,
321                hd: a.hd,
322                vd: a.hd,
323                l: a.t,
324                attended: Attended::All(a.t),
325                scale: 1.0 / (a.hd as f64).sqrt(),
326                cap: None,
327                window: None,
328                sinks: None,
329            },
330            d_out,
331        ),
332        TensorOpSpec::Moe(_) => Err(VjpRefusal::NotDerived { op: "Moe" }),
333        TensorOpSpec::SetRow(w) => set_row_rule(rp, w, out, d_out),
334        TensorOpSpec::HeadSlice(h) => head_slice_rule(out, rp.name(h.x), h.every, h.from, h.width, h.n, d_out),
335        TensorOpSpec::Join(j) => join_rule(out, (rp.name(j.a), rp.name(j.b)), (j.rows, j.heads, j.wa, j.wb), d_out),
336        TensorOpSpec::HeadMatmul(m) => head_matmul_rule(out, (rp.name(m.x), rp.name(m.w)), (m.m, m.heads, m.a, m.b), d_out),
337        TensorOpSpec::IntRequant(_) => Err(VjpRefusal::NoGradient {
338            op: "IntRequant",
339            why: "integer arithmetic: a floored shift of an integer is a step function of it",
340        }),
341        TensorOpSpec::IntClamp(_) => Err(VjpRefusal::NoGradient {
342            op: "IntClamp",
343            why: "integer arithmetic: a saturation of integers is a step function of them",
344        }),
345        TensorOpSpec::Relu(r) if r.int => Err(VjpRefusal::NoGradient {
346            op: "Relu",
347            why: "integer arithmetic: a ReLU of integers is a step function of them",
348        }),
349        TensorOpSpec::Relu(_) => Err(VjpRefusal::NotDerived { op: "Relu" }),
350        TensorOpSpec::LutAttention(_) => Err(VjpRefusal::NoGradient {
351            op: "LutAttention",
352            why: "integer arithmetic: a table-weighted, floored mean of integers is a step function of them",
353        }),
354    }
355}
356
357/// `P[g·width + i] = X[g·every + from + i]` over the `n / every` groups of `X`: each slot it read takes the gradient of
358/// the element it became, every other slot none.
359fn head_slice_rule(out: &mut Out, x: &str, every: usize, from: usize, width: usize, n: usize, d_out: &str) -> Result<(), VjpRefusal> {
360    let shape = out.shape(x)?;
361    let dx = out.tensor(&format!("g_{x}"), shape);
362    let (g, i, src, dst) = (out.names.fresh("g"), out.names.fresh("i"), out.names.fresh("k"), out.names.fresh("k"));
363    out.line(0, format!("Repeat for {g} from 1 to {}:", n / every.max(1)));
364    out.line(1, format!("Repeat for {i} from 1 to {width}:"));
365    out.line(2, format!("Let {dst} be ({g} - 1) * {every} + {from} + {i}."));
366    out.line(2, format!("Let {src} be ({g} - 1) * {width} + {i}."));
367    out.line(2, format!("Set item {dst} of {dx} to item {src} of {d_out}."));
368    out.accumulate(x, dx);
369    Ok(())
370}
371
372/// `J` holds, per row and head, the head's part of `A` then its part of `B`: each part's gradient is the slice of `dJ`
373/// it became.
374fn join_rule(out: &mut Out, (a, b): (&str, &str), (rows, heads, wa, wb): (usize, usize, usize, usize), d_out: &str) -> Result<(), VjpRefusal> {
375    let (sa, sb) = (out.shape(a)?, out.shape(b)?);
376    let (da, db) = (out.tensor(&format!("g_{a}"), sa), out.tensor(&format!("g_{b}"), sb));
377    let (ha, hb) = (wa / heads.max(1), wb / heads.max(1));
378    let (r, h, i, j) = (out.names.fresh("r"), out.names.fresh("h"), out.names.fresh("i"), out.names.fresh("j"));
379    let (at, ka, kj, kb, kk) = (out.names.fresh("k"), out.names.fresh("k"), out.names.fresh("k"), out.names.fresh("k"), out.names.fresh("k"));
380    out.line(0, format!("Repeat for {r} from 1 to {rows}:"));
381    out.line(1, format!("Repeat for {h} from 1 to {heads}:"));
382    out.line(2, format!("Let {at} be ({r} - 1) * {} + ({h} - 1) * {}.", wa + wb, ha + hb));
383    out.line(2, format!("Repeat for {i} from 1 to {ha}:"));
384    out.line(3, format!("Let {ka} be ({r} - 1) * {wa} + ({h} - 1) * {ha} + {i}."));
385    out.line(3, format!("Let {kj} be {at} + {i}."));
386    out.line(3, format!("Set item {ka} of {da} to item {kj} of {d_out}."));
387    out.line(2, format!("Repeat for {j} from 1 to {hb}:"));
388    out.line(3, format!("Let {kb} be ({r} - 1) * {wb} + ({h} - 1) * {hb} + {j}."));
389    out.line(3, format!("Let {kk} be {at} + {ha} + {j}."));
390    out.line(3, format!("Set item {kb} of {db} to item {kk} of {d_out}."));
391    out.accumulate(a, da);
392    out.accumulate(b, db);
393    Ok(())
394}
395
396/// `Y[t][h·b + r] = Σ_e X[t][h·a + e]·W[h·a + e][r]`, one `a × b` block of `W` per head: `dX[t][h·a + e] =
397/// Σ_r dY[t][h·b + r]·W[h·a + e][r]` and `dW[h·a + e][r] = Σ_t X[t][h·a + e]·dY[t][h·b + r]`.
398fn head_matmul_rule(out: &mut Out, (x, w): (&str, &str), (m, heads, a, b): (usize, usize, usize, usize), d_out: &str) -> Result<(), VjpRefusal> {
399    let (sx, sw) = (out.shape(x)?, out.shape(w)?);
400    let (dx, dw) = (out.tensor(&format!("g_{x}"), sx), out.tensor(&format!("g_{w}"), sw));
401    let (t, h, e, r) = (out.names.fresh("t"), out.names.fresh("h"), out.names.fresh("e"), out.names.fresh("r"));
402    let (xk, wk, yk, dy) = (out.names.fresh("k"), out.names.fresh("k"), out.names.fresh("k"), out.names.fresh("y"));
403    out.line(0, format!("Repeat for {t} from 1 to {m}:"));
404    out.line(1, format!("Repeat for {h} from 1 to {heads}:"));
405    out.line(2, format!("Repeat for {e} from 1 to {a}:"));
406    out.line(3, format!("Let {xk} be ({t} - 1) * {} + ({h} - 1) * {a} + {e}.", heads * a));
407    out.line(3, format!("Repeat for {r} from 1 to {b}:"));
408    out.line(4, format!("Let {wk} be (({h} - 1) * {a} + {e} - 1) * {b} + {r}."));
409    out.line(4, format!("Let {yk} be ({t} - 1) * {} + ({h} - 1) * {b} + {r}.", heads * b));
410    out.line(4, format!("Let {dy} be item {yk} of {d_out}."));
411    out.line(4, format!("Set item {xk} of {dx} to (item {xk} of {dx}) + {dy} * (item {wk} of {w})."));
412    out.line(4, format!("Set item {wk} of {dw} to (item {wk} of {dw}) + {dy} * (item {xk} of {x})."));
413    out.accumulate(x, dx);
414    out.accumulate(w, dw);
415    Ok(())
416}
417
418/// `dx = combine(dy, operands…)` element by element, every operand of `x`'s shape.
419fn pointwise(out: &mut Out, x: &str, d_out: &str, operands: &[&str], combine: impl Fn(&[String]) -> String) -> Result<(), VjpRefusal> {
420    let shape = out.shape(x)?;
421    let dx = out.tensor(&format!("g_{x}"), shape);
422    let operands: Vec<String> = operands.iter().map(|s| s.to_string()).collect();
423    let d_out = d_out.to_string();
424    out.each(shape, |o, depth, _, _, k| {
425        let mut vals = vec![format!("(item {k} of {d_out})")];
426        vals.extend(operands.iter().map(|t| format!("(item {k} of {t})")));
427        o.line(depth, format!("Set item {k} of {dx} to {}.", combine(&vals)));
428    });
429    out.accumulate(x, dx);
430    Ok(())
431}
432
433/// `σ(x)` as LOGOS: `1 / (1 + e^-x)` with `e` raised by `**`, the form the forward sugar uses.
434fn sigma(x: &str) -> String {
435    format!("(1.0 / (1.0 + {} ** (0.0 - {x})))", std::f64::consts::E)
436}
437
438fn pointwise_with_sigma(out: &mut Out, x: &str, d_out: &str, rule: impl Fn(&str, &str, &str) -> String) -> Result<(), VjpRefusal> {
439    let shape = out.shape(x)?;
440    let dx = out.tensor(&format!("g_{x}"), shape);
441    let d_out = d_out.to_string();
442    let x_name = x.to_string();
443    out.each(shape, |o, depth, _, _, k| {
444        let xv = o.names.fresh("x");
445        let sg = o.names.fresh("s");
446        o.line(depth, format!("Let {xv} be item {k} of {x_name}."));
447        o.line(depth, format!("Let {sg} be {}.", sigma(&xv)));
448        o.line(depth, format!("Set item {k} of {dx} to {}.", rule(&format!("(item {k} of {d_out})"), &xv, &sg)));
449    });
450    out.accumulate(x, dx);
451    Ok(())
452}
453
454fn gelu_rule(out: &mut Out, x: &str, d_out: &str) -> Result<(), VjpRefusal> {
455    const SQRT_2_OVER_PI: f64 = 0.797_884_560_802_865_4;
456    const K: f64 = 0.044_715;
457    let shape = out.shape(x)?;
458    let dx = out.tensor(&format!("g_{x}"), shape);
459    let d_out = d_out.to_string();
460    let x_name = x.to_string();
461    out.each(shape, |o, depth, _, _, k| {
462        let xv = o.names.fresh("x");
463        let z2 = o.names.fresh("z");
464        let s = o.names.fresh("s");
465        o.line(depth, format!("Let {xv} be item {k} of {x_name}."));
466        o.line(depth, format!("Let {z2} be 2.0 * {SQRT_2_OVER_PI:?} * ({xv} + {K:?} * {xv} * {xv} * {xv})."));
467        o.line(depth, format!("Let {s} be {}.", sigma(&z2)));
468        o.line(
469            depth,
470            format!(
471                "Set item {k} of {dx} to (item {k} of {d_out}) * ({s} + {xv} * {s} * (1.0 - {s}) * 2.0 * {SQRT_2_OVER_PI:?} * (1.0 + 3.0 * {K:?} * {xv} * {xv}))."
472            ),
473        );
474    });
475    out.accumulate(x, dx);
476    Ok(())
477}
478
479fn swiglu_rule(out: &mut Out, g: &str, u: &str, d_out: &str) -> Result<(), VjpRefusal> {
480    let shape = out.shape(g)?;
481    let dg = out.tensor(&format!("g_{g}"), shape);
482    let du = out.tensor(&format!("g_{u}"), shape);
483    let (g_name, u_name, d_out) = (g.to_string(), u.to_string(), d_out.to_string());
484    out.each(shape, |o, depth, _, _, k| {
485        let gv = o.names.fresh("x");
486        let sg = o.names.fresh("s");
487        o.line(depth, format!("Let {gv} be item {k} of {g_name}."));
488        o.line(depth, format!("Let {sg} be {}.", sigma(&gv)));
489        o.line(
490            depth,
491            format!("Set item {k} of {dg} to (item {k} of {d_out}) * (item {k} of {u_name}) * ({sg} + {gv} * {sg} * (1.0 - {sg}))."),
492        );
493        o.line(depth, format!("Set item {k} of {du} to (item {k} of {d_out}) * {gv} * {sg}."));
494    });
495    out.accumulate(g, dg);
496    out.accumulate(u, du);
497    Ok(())
498}
499
500/// A loop over `rows` rows of `cols` elements in row-major storage — a tensor's own rows, or the heads of a per-head
501/// norm's view of each row — binding the row `r` and the 1-based flat index `base` of the element before its first, so
502/// element `c` of the row is `base + c`.
503fn each_row(out: &mut Out, (rows, cols): (usize, usize)) -> (String, String) {
504    let r = out.names.fresh("r");
505    let base = out.names.fresh("b");
506    out.line(0, format!("Repeat for {r} from 1 to {rows}:"));
507    out.line(1, format!("Let {base} be ({r} - 1) * {cols}."));
508    (r, base)
509}
510
511/// `Let mutable acc be 0.0.` then `Set acc to acc + term(k)` over the row's columns, at depth 1.
512fn row_sum(out: &mut Out, cols: usize, base: &str, term: impl Fn(&str) -> String) -> String {
513    let acc = out.names.fresh("a");
514    let c = out.names.fresh("c");
515    let k = out.names.fresh("k");
516    out.line(1, format!("Let mutable {acc} be 0.0."));
517    out.line(1, format!("Repeat for {c} from 1 to {cols}:"));
518    out.line(2, format!("Let {k} be {base} + {c}."));
519    out.line(2, format!("Set {acc} to {acc} + {}.", term(&k)));
520    acc
521}
522
523/// Over the row's columns at depth 1: `Set item k of target to value(k)`.
524fn row_write(out: &mut Out, cols: usize, base: &str, target: &str, value: impl Fn(&str) -> String) {
525    let c = out.names.fresh("c");
526    let k = out.names.fresh("k");
527    out.line(1, format!("Repeat for {c} from 1 to {cols}:"));
528    out.line(2, format!("Let {k} be {base} + {c}."));
529    out.line(2, format!("Set item {k} of {target} to {}.", value(&k)));
530}
531
532fn softmax_rule(out: &mut Out, y: &str, d_out: &str, x: &str, shape: (usize, usize)) -> Result<(), VjpRefusal> {
533    // Per row: dx = y ⊙ (dy − Σ dy⊙y).
534    let dx = out.tensor(&format!("g_{x}"), out.shape(x)?);
535    let (_, base) = each_row(out, shape);
536    let (y, d_out) = (y.to_string(), d_out.to_string());
537    let dot = row_sum(out, shape.1, &base, |k| format!("(item {k} of {d_out}) * (item {k} of {y})"));
538    row_write(out, shape.1, &base, &dx, |k| format!("(item {k} of {y}) * ((item {k} of {d_out}) - {dot})"));
539    out.accumulate(x, dx);
540    Ok(())
541}
542
543fn l2norm_rule(out: &mut Out, x: &str, eps: Epsilon, d_out: &str, shape: (usize, usize)) -> Result<(), VjpRefusal> {
544    // Per row, n = √(Σx² + ε): dx = dy/n − x·(Σ dy⊙x)/n³.
545    let dx = out.tensor(&format!("g_{x}"), out.shape(x)?);
546    let (_, base) = each_row(out, shape);
547    let (x, d_out) = (x.to_string(), d_out.to_string());
548    let ss = row_sum(out, shape.1, &base, |k| format!("(item {k} of {x}) * (item {k} of {x})"));
549    let dot = row_sum(out, shape.1, &base, |k| format!("(item {k} of {d_out}) * (item {k} of {x})"));
550    let n = out.names.fresh("n");
551    out.line(1, format!("Let {n} be sqrt({ss} + {:?}).", eps.get()));
552    row_write(out, shape.1, &base, &dx, |k| {
553        format!("(item {k} of {d_out}) / {n} - (item {k} of {x}) * {dot} / ({n} * {n} * {n})")
554    });
555    out.accumulate(&x, dx);
556    Ok(())
557}
558
559fn rmsnorm_rule(
560    rp: &RecognizedProgram,
561    r: &crate::tensor_recognize::FusedRowSpec,
562    out: &mut Out,
563    d_out: &str,
564    shape: (usize, usize),
565) -> Result<(), VjpRefusal> {
566    // u = x (+ residual); per row, q = 1/√(mean u² + ε); y = u·q·w.
567    // du_i = q·(w_i dy_i) − q³/C · u_i · Σ_j w_j dy_j u_j;  dw_i = Σ_rows dy_i · u_i · q.
568    let x = rp.name(r.x).to_string();
569    let residual = r.residual.map(|s| rp.name(s).to_string());
570    let weight = r.weight.map(|s| rp.name(s).to_string());
571    let cols = shape.1;
572    let du = out.tensor(&format!("g_{x}"), out.shape(&x)?);
573    let dw = weight.as_ref().map(|w| {
574        let s = out.shape(w).unwrap_or((1, cols));
575        out.tensor(&format!("g_{w}"), s)
576    });
577    let d_out = d_out.to_string();
578    let u_of = |k: &str| match &residual {
579        Some(res) => format!("((item {k} of {x}) + (item {k} of {res}))"),
580        None => format!("(item {k} of {x})"),
581    };
582    let w_of = |c_index: &str| match &weight {
583        Some(w) => format!("(item {c_index} of {w})"),
584        None => "1.0".to_string(),
585    };
586    let (_, base) = each_row(out, shape);
587    let ss = row_sum(out, cols, &base, |k| format!("{} * {}", u_of(k), u_of(k)));
588    let q = out.names.fresh("q");
589    out.line(1, format!("Let {q} be 1.0 / sqrt({ss} / {cols}.0 + {:?}).", r.eps.get()));
590    // Σ_j w_j dy_j u_j, with the column index recovered as k - base.
591    let acc = out.names.fresh("a");
592    let c = out.names.fresh("c");
593    let k = out.names.fresh("k");
594    out.line(1, format!("Let mutable {acc} be 0.0."));
595    out.line(1, format!("Repeat for {c} from 1 to {cols}:"));
596    out.line(2, format!("Let {k} be {base} + {c}."));
597    out.line(2, format!("Set {acc} to {acc} + {} * (item {k} of {d_out}) * {}.", w_of(&c), u_of(&k)));
598    let c2 = out.names.fresh("c");
599    let k2 = out.names.fresh("k");
600    out.line(1, format!("Repeat for {c2} from 1 to {cols}:"));
601    out.line(2, format!("Let {k2} be {base} + {c2}."));
602    out.line(
603        2,
604        format!(
605            "Set item {k2} of {du} to {q} * {} * (item {k2} of {d_out}) - {q} * {q} * {q} / {cols}.0 * {} * {acc}.",
606            w_of(&c2),
607            u_of(&k2)
608        ),
609    );
610    if let Some(dw) = &dw {
611        out.line(2, format!("Set item {c2} of {dw} to (item {c2} of {dw}) + (item {k2} of {d_out}) * {} * {q}.", u_of(&k2)));
612    }
613    out.accumulate(&x, du.clone());
614    if let Some(res) = residual {
615        out.accumulate(&res, du);
616    }
617    if let (Some(w), Some(dw)) = (weight, dw) {
618        out.accumulate(&w, dw);
619    }
620    Ok(())
621}
622
623fn layernorm_rule(
624    rp: &RecognizedProgram,
625    r: &crate::tensor_recognize::FusedRowSpec,
626    out: &mut Out,
627    d_out: &str,
628    shape: (usize, usize),
629) -> Result<(), VjpRefusal> {
630    // Per row: μ = mean x, v = mean (x−μ)², q = 1/√(v + ε), x̂ = (x−μ)·q, y = x̂·w + b.
631    // g = dy·w;  dx = q·(g − mean g − x̂·mean(g·x̂));  dw = Σ_rows dy·x̂;  db = Σ_rows dy.
632    let x = rp.name(r.x).to_string();
633    let weight = r.weight.map(|s| rp.name(s).to_string());
634    let bias = r.bias.map(|s| rp.name(s).to_string());
635    let cols = shape.1;
636    let dx = out.tensor(&format!("g_{x}"), out.shape(&x)?);
637    let dw = weight.as_ref().map(|w| {
638        let s = out.shape(w).unwrap_or((1, cols));
639        out.tensor(&format!("g_{w}"), s)
640    });
641    let db = bias.as_ref().map(|b| {
642        let s = out.shape(b).unwrap_or((1, cols));
643        out.tensor(&format!("g_{b}"), s)
644    });
645    let d_out = d_out.to_string();
646    let w_of = |c_index: &str| match &weight {
647        Some(w) => format!("(item {c_index} of {w})"),
648        None => "1.0".to_string(),
649    };
650    let (_, base) = each_row(out, shape);
651    let sum = row_sum(out, cols, &base, |k| format!("(item {k} of {x})"));
652    let mu = out.names.fresh("m");
653    out.line(1, format!("Let {mu} be {sum} / {cols}.0."));
654    let var = row_sum(out, cols, &base, |k| format!("((item {k} of {x}) - {mu}) * ((item {k} of {x}) - {mu})"));
655    let q = out.names.fresh("q");
656    out.line(1, format!("Let {q} be 1.0 / sqrt({var} / {cols}.0 + {:?}).", r.eps.get()));
657    let (g_sum, gx_sum) = (out.names.fresh("a"), out.names.fresh("a"));
658    let c = out.names.fresh("c");
659    let k = out.names.fresh("k");
660    out.line(1, format!("Let mutable {g_sum} be 0.0."));
661    out.line(1, format!("Let mutable {gx_sum} be 0.0."));
662    out.line(1, format!("Repeat for {c} from 1 to {cols}:"));
663    out.line(2, format!("Let {k} be {base} + {c}."));
664    out.line(2, format!("Set {g_sum} to {g_sum} + (item {k} of {d_out}) * {}.", w_of(&c)));
665    out.line(2, format!("Set {gx_sum} to {gx_sum} + (item {k} of {d_out}) * {} * ((item {k} of {x}) - {mu}) * {q}.", w_of(&c)));
666    let c2 = out.names.fresh("c");
667    let k2 = out.names.fresh("k");
668    out.line(1, format!("Repeat for {c2} from 1 to {cols}:"));
669    out.line(2, format!("Let {k2} be {base} + {c2}."));
670    out.line(
671        2,
672        format!(
673            "Set item {k2} of {dx} to {q} * ((item {k2} of {d_out}) * {} - {g_sum} / {cols}.0 - ((item {k2} of {x}) - {mu}) * {q} * {gx_sum} / {cols}.0).",
674            w_of(&c2)
675        ),
676    );
677    if let Some(dw) = &dw {
678        out.line(2, format!("Set item {c2} of {dw} to (item {c2} of {dw}) + (item {k2} of {d_out}) * ((item {k2} of {x}) - {mu}) * {q}."));
679    }
680    if let Some(db) = &db {
681        out.line(2, format!("Set item {c2} of {db} to (item {c2} of {db}) + (item {k2} of {d_out})."));
682    }
683    out.accumulate(&x, dx);
684    if let (Some(w), Some(dw)) = (weight, dw) {
685        out.accumulate(&w, dw);
686    }
687    if let (Some(b), Some(db)) = (bias, db) {
688        out.accumulate(&b, db);
689    }
690    Ok(())
691}
692
693/// A row index as LOGOS text: a literal, or the name of the variable holding it.
694struct RowIndexText(String);
695
696impl RowIndexText {
697    fn of(rp: &RecognizedProgram, index: &crate::tensor_recognize::qwen::RowIndex) -> RowIndexText {
698        use crate::tensor_recognize::qwen::RowIndex;
699        RowIndexText(match index {
700            RowIndex::Literal(n) => n.to_string(),
701            RowIndex::Named(s) => rp.name(*s).to_string(),
702        })
703    }
704}
705
706fn row_scatter_rule(_rp: &RecognizedProgram, out: &mut Out, table: &str, d_out: &str, index: &RowIndexText) -> Result<(), VjpRefusal> {
707    // x = row i of T: dT[i, :] += dx.
708    let shape = out.shape(table)?;
709    let dt = out.tensor(&format!("g_{table}"), shape);
710    let c = out.names.fresh("c");
711    let k = out.names.fresh("k");
712    out.line(0, format!("Repeat for {c} from 1 to {}:", shape.1));
713    out.line(1, format!("Let {k} be ({} - 1) * {} + {c}.", index.0, shape.1));
714    out.line(1, format!("Set item {k} of {dt} to (item {k} of {dt}) + (item {c} of {d_out})."));
715    out.accumulate(table, dt);
716    Ok(())
717}
718
719fn rows_scatter_rule(out: &mut Out, table: &str, indices: &str, d_out: &str) -> Result<(), VjpRefusal> {
720    // X = rows I of T: dT[I[r], :] += dX[r, :] for every r.
721    let shape = out.shape(table)?;
722    let (rows, _) = out.shape(d_out).unwrap_or((1, shape.1));
723    let dt = out.tensor(&format!("g_{table}"), shape);
724    let r = out.names.fresh("r");
725    let c = out.names.fresh("c");
726    let k = out.names.fresh("k");
727    let src = out.names.fresh("k");
728    let mut header = String::new();
729    let _ = write!(header, "Repeat for {r} from 1 to {rows}:");
730    out.line(0, header);
731    out.line(1, format!("Repeat for {c} from 1 to {}:", shape.1));
732    out.line(2, format!("Let {k} be ((item {r} of {indices}) - 1) * {} + {c}.", shape.1));
733    out.line(2, format!("Let {src} be ({r} - 1) * {} + {c}.", shape.1));
734    out.line(2, format!("Set item {k} of {dt} to (item {k} of {dt}) + (item {src} of {d_out})."));
735    out.accumulate(table, dt);
736    Ok(())
737}
738
739fn rope_rule(rp: &RecognizedProgram, r: &crate::tensor_recognize::qwen::RopeSpec, out: &mut Out, d_out: &str) -> Result<(), VjpRefusal> {
740    // A rotation is orthogonal and R(θ)ᵀ = R(−θ): the gradient is the same rope with the sine table negated, so
741    // every option — a partial slice, a row of angles per row, angles from a cache position — carries over as is.
742    let (q, rc, rs) = (rp.name(r.q).to_string(), rp.name(r.rc).to_string(), rp.name(r.rs).to_string());
743    let table = out.shape(&rs)?;
744    let neg = out.tensor(&format!("n_{rs}"), table);
745    let rs_name = rs.clone();
746    out.each(table, |o, depth, _, _, k| {
747        o.line(depth, format!("Set item {k} of {neg} to 0.0 - (item {k} of {rs_name})."));
748    });
749    let shape = out.shape(&q)?;
750    let dq = out.names.fresh(&format!("g_{q}"));
751    let mut stmt = format!("Let {dq} be rope of {d_out} with cos {rc} and sin {neg}");
752    if let Some((index, _)) = &r.from {
753        stmt.push_str(&format!(" from position {}", RowIndexText::of(rp, index).0));
754    }
755    if r.hd != 2 * r.half {
756        stmt.push_str(&format!(" across {} heads", r.h));
757    }
758    stmt.push('.');
759    out.line(0, stmt);
760    out.shapes.insert(dq.clone(), shape);
761    out.accumulate(&q, dq);
762    out.undiff.insert(rc, "Rope");
763    out.undiff.insert(rs, "Rope");
764    Ok(())
765}
766
767/// How many cached positions a query row attends.
768enum Attended {
769    /// Every one of the `l` positions.
770    All(usize),
771    /// The first `P` (a literal or a variable) — and, in a block of query rows, row `r` (1-based) the first
772    /// `P + r − 1`.
773    From(String),
774}
775
776struct AttentionShape<'n> {
777    q: &'n str,
778    k: &'n str,
779    v: &'n str,
780    rows: usize,
781    h: usize,
782    kv: usize,
783    hd: usize,
784    /// The value head width: `hd`, or other.
785    vd: usize,
786    l: usize,
787    attended: Attended,
788    /// The factor every score takes: `1/√hd` unless the program states one.
789    scale: f64,
790    /// The soft cap every scaled score takes, `C·tanh(s/C)`.
791    cap: Option<f64>,
792    window: Option<crate::tensor_recognize::qwen::Window>,
793    /// The tensor of each head's sink logit (1 by `h`), which joins every row's softmax.
794    sinks: Option<&'n str>,
795}
796
797fn attention_rule(out: &mut Out, a: AttentionShape, d_out: &str) -> Result<(), VjpRefusal> {
798    // Per query row r and head g over its attended positions j (from the window's floor), with kv head
799    // g' = (g − 1)/(H/KV) + 1, the scale α, the cap C and the head's sink σ_g:
800    //   u_j = α·q·k_j,  s_j = C·tanh(u_j/C) (u_j uncapped),  p = softmax(s, σ_g),  c = Σ p_j v_j
801    //   dv_j += p_j·dc,  dp_j = dc·v_j,  ds_j = p_j·(dp_j − Σ_i p_i dp_i),  du_j = ds_j·(1 − (s_j/C)²),
802    //   dq += α Σ_j du_j k_j,  dk_j += α du_j q,  dσ_g += −p_σ·Σ_i p_i dp_i.
803    // The forward keeps no intermediates, so the scores and the softmax are recomputed here.
804    let (q, k, v) = (a.q.to_string(), a.k.to_string(), a.v.to_string());
805    let (width, owidth) = (a.h * a.hd, a.h * a.vd);
806    let (kwidth, vwidth) = (a.kv * a.hd, a.kv * a.vd);
807    let group = a.h / a.kv;
808    let alpha = a.scale;
809    let e = std::f64::consts::E;
810    let dq = out.tensor(&format!("g_{q}"), (a.rows, width));
811    let dk = out.tensor(&format!("g_{k}"), (a.l, kwidth));
812    let dv = out.tensor(&format!("g_{v}"), (a.l, vwidth));
813    let dsink = a.sinks.map(|s| out.tensor(&format!("g_{s}"), (1, a.h)));
814    let n = |o: &mut Names| o.fresh("n");
815    let (r, g, kvh, cnt) = (out.names.fresh("r"), out.names.fresh("h"), out.names.fresh("kh"), n(&mut out.names));
816    let (qo, oo, j, ko, vo, d) =
817        (out.names.fresh("qo"), out.names.fresh("oo"), out.names.fresh("j"), out.names.fresh("ko"), out.names.fresh("vo"), out.names.fresh("d"));
818    let (lo, sc, ps, dps) = (out.names.fresh("lo"), out.names.fresh("sc"), out.names.fresh("ps"), out.names.fresh("dp"));
819    let (acc, mx, den, dot) = (out.names.fresh("a"), out.names.fresh("m"), out.names.fresh("z"), out.names.fresh("t"));
820    out.line(0, format!("Repeat for {r} from 1 to {}:", a.rows));
821    out.line(1, format!("Repeat for {g} from 1 to {}:", a.h));
822    out.line(2, format!("Let {kvh} be ({g} - 1) / {group} + 1."));
823    match &a.attended {
824        Attended::All(l) => out.line(2, format!("Let {cnt} be {l}.")),
825        Attended::From(p) => out.line(2, format!("Let {cnt} be {p} + {r} - 1.")),
826    }
827    // The window's floor: the last W positions, or the query's own chunk of C.
828    use crate::tensor_recognize::qwen::Window;
829    match a.window {
830        None => out.line(2, format!("Let {lo} be 1.")),
831        Some(Window::Sliding(w)) => {
832            out.line(2, format!("Let mutable {lo} be {cnt} - {w} + 1."));
833            out.line(2, format!("If {lo} < 1:"));
834            out.line(3, format!("Set {lo} to 1."));
835        }
836        Some(Window::Chunked(c)) => out.line(2, format!("Let {lo} be (({cnt} - 1) / {c}) * {c} + 1.")),
837    }
838    out.line(2, format!("Let {qo} be ({r} - 1) * {width} + ({g} - 1) * {}.", a.hd));
839    out.line(2, format!("Let {oo} be ({r} - 1) * {owidth} + ({g} - 1) * {}.", a.vd));
840    // scores and their max, the head's sink in the max where it has one
841    out.line(2, format!("Let mutable {sc} be a new Seq of Real."));
842    match a.sinks {
843        Some(sinks) => out.line(2, format!("Let mutable {mx} be item {g} of {sinks}.")),
844        None => out.line(2, format!("Let mutable {mx} be 0.0.")),
845    }
846    out.line(2, format!("Repeat for {j} from {lo} to {cnt}:"));
847    out.line(3, format!("Let {ko} be ({j} - 1) * {kwidth} + ({kvh} - 1) * {}.", a.hd));
848    out.line(3, format!("Let mutable {acc} be 0.0."));
849    out.line(3, format!("Repeat for {d} from 1 to {}:", a.hd));
850    out.line(4, format!("Set {acc} to {acc} + (item ({qo} + {d}) of {q}) * (item ({ko} + {d}) of {k})."));
851    out.line(3, format!("Set {acc} to {acc} * {alpha:?}."));
852    if let Some(c) = a.cap {
853        out.line(3, format!("Set {acc} to {c:?} * (1.0 - 2.0 / ({e:?} ** ({acc} * {:?}) + 1.0)).", 2.0 / c));
854    }
855    let first = if a.sinks.is_some() { String::new() } else { format!("{j} == {lo} or ") };
856    out.line(3, format!("If {first}{acc} > {mx}:"));
857    out.line(4, format!("Set {mx} to {acc}."));
858    out.line(3, format!("Push {acc} to {sc}."));
859    // softmax, the sink's term in the denominator
860    match a.sinks {
861        Some(sinks) => out.line(2, format!("Let mutable {den} be {e:?} ** ((item {g} of {sinks}) - {mx}).")),
862        None => out.line(2, format!("Let mutable {den} be 0.0.")),
863    }
864    out.line(2, format!("Let mutable {ps} be a new Seq of Real."));
865    out.line(2, format!("Repeat for {j} from {lo} to {cnt}:"));
866    out.line(3, format!("Let {acc}_e be {e:?} ** ((item ({j} - {lo} + 1) of {sc}) - {mx})."));
867    out.line(3, format!("Set {den} to {den} + {acc}_e."));
868    out.line(3, format!("Push {acc}_e to {ps}."));
869    // dp, dv, and Σ p·dp
870    out.line(2, format!("Let mutable {dps} be a new Seq of Real."));
871    out.line(2, format!("Let mutable {dot} be 0.0."));
872    out.line(2, format!("Repeat for {j} from {lo} to {cnt}:"));
873    out.line(3, format!("Let {vo} be ({j} - 1) * {vwidth} + ({kvh} - 1) * {}.", a.vd));
874    out.line(3, format!("Let {acc}_p be (item ({j} - {lo} + 1) of {ps}) / {den}."));
875    out.line(3, format!("Let mutable {acc} be 0.0."));
876    out.line(3, format!("Repeat for {d} from 1 to {}:", a.vd));
877    out.line(4, format!("Set {acc} to {acc} + (item ({oo} + {d}) of {d_out}) * (item ({vo} + {d}) of {v})."));
878    out.line(4, format!("Set item ({vo} + {d}) of {dv} to (item ({vo} + {d}) of {dv}) + {acc}_p * (item ({oo} + {d}) of {d_out})."));
879    out.line(3, format!("Push {acc} to {dps}."));
880    out.line(3, format!("Set {dot} to {dot} + {acc}_p * {acc}."));
881    // dσ: the sink's weight against the row's expected dp
882    if let (Some(sinks), Some(dsink)) = (a.sinks, &dsink) {
883        out.line(2, format!("Let {acc}_sink be {e:?} ** ((item {g} of {sinks}) - {mx}) / {den}."));
884        out.line(2, format!("Set item {g} of {dsink} to (item {g} of {dsink}) - {acc}_sink * {dot}."));
885    }
886    // ds through the cap, dq, dk
887    out.line(2, format!("Repeat for {j} from {lo} to {cnt}:"));
888    out.line(3, format!("Let {ko} be ({j} - 1) * {kwidth} + ({kvh} - 1) * {}.", a.hd));
889    let through_cap = match a.cap {
890        Some(c) => format!(" * (1.0 - ((item ({j} - {lo} + 1) of {sc}) / {c:?}) * ((item ({j} - {lo} + 1) of {sc}) / {c:?}))"),
891        None => String::new(),
892    };
893    out.line(
894        3,
895        format!("Let {acc}_s be (item ({j} - {lo} + 1) of {ps}) / {den} * ((item ({j} - {lo} + 1) of {dps}) - {dot}){through_cap} * {alpha:?}."),
896    );
897    out.line(3, format!("Repeat for {d} from 1 to {}:", a.hd));
898    out.line(4, format!("Set item ({qo} + {d}) of {dq} to (item ({qo} + {d}) of {dq}) + {acc}_s * (item ({ko} + {d}) of {k})."));
899    out.line(4, format!("Set item ({ko} + {d}) of {dk} to (item ({ko} + {d}) of {dk}) + {acc}_s * (item ({qo} + {d}) of {q})."));
900    out.accumulate(&q, dq);
901    out.accumulate(&k, dk);
902    out.accumulate(&v, dv);
903    if let (Some(sinks), Some(dsink)) = (a.sinks, dsink) {
904        out.accumulate(sinks, dsink);
905    }
906    Ok(())
907}
908
909fn set_row_rule(rp: &RecognizedProgram, w: &crate::tensor_recognize::qwen::SetRowSpec, out: &mut Out, d_out: &str) -> Result<(), VjpRefusal> {
910    // `Set rows from P of T to X` replaces rows P … P + count − 1 of T: X's gradient is those rows of T's gradient
911    // after the write, and T's gradient before the write is the same with those rows zeroed — the values they held
912    // never reach the loss.
913    let (table, source) = (rp.name(w.table).to_string(), rp.name(w.source).to_string());
914    let p = RowIndexText::of(rp, &w.index).0;
915    let cols = w.cols;
916    let dx = out.tensor(&format!("g_{source}"), (w.count, cols));
917    let before = out.tensor(&format!("g_{table}"), (w.rows, cols));
918    let (r, c, k) = (out.names.fresh("r"), out.names.fresh("c"), out.names.fresh("k"));
919    out.line(0, format!("Repeat for {r} from 1 to {}:", w.rows));
920    out.line(1, format!("Repeat for {c} from 1 to {cols}:"));
921    out.line(2, format!("Let {k} be ({r} - 1) * {cols} + {c}."));
922    out.line(2, format!("If {r} >= {p} and {r} < {p} + {}:", w.count));
923    out.line(3, format!("Set item (({r} - {p}) * {cols} + {c}) of {dx} to item {k} of {d_out}."));
924    out.line(2, "Otherwise:".to_string());
925    out.line(3, format!("Set item {k} of {before} to item {k} of {d_out}."));
926    out.grads.insert(table.clone(), before);
927    out.accumulate(&source, dx);
928    if let crate::tensor_recognize::qwen::RowIndex::Named(s) = &w.index {
929        out.undiff.insert(rp.name(*s).to_string(), "SetRow");
930    }
931    Ok(())
932}