Skip to main content

logicaffeine_compile/tensor_graph/
graph.rs

1//! The one IR: a cut of a model's one program as the tensor ops it runs and the tensors they touch.
2//!
3//! A graph is lifted from the front end ([`walk`](super::front::walk)): every top-level tensor statement is one op,
4//! and a statement the front end cannot lift — or a write it cannot account for — is refused by name. Each tensor
5//! carries the facts every backend reads instead of re-deriving them:
6//!
7//!   • its **role** — a *parameter* the program reads as a position or row indices (the token, a chunk's tokens, the
8//!     position), *state* an op writes in place (a KV cache by `Set row`, a convolution ring, a delta-rule memory),
9//!     a *constant* any other declared tensor (weights, norm vectors, rotary tables), an *activation* what an op
10//!     computes;
11//!   • its **element** type, Real or Int, as the program declares or the op that computes it makes it;
12//!   • its **fill**, the values the program gives a declared tensor, and its **storage**, where a constant's values
13//!     live (computed, values, or rows of a checkpoint tensor in a block format) — storage comes from the caller,
14//!     since the IR reads no checkpoint;
15//!   • whether a statement other than an op **observes** it (a `Show`, a scalar read, foreign code).
16//!
17//! An op is a symbol over operands: a primitive the front end lifted (`Sym::Prim`), a fused symbol whose definition is
18//! its parts (`Sym::Fused`), or a view into another tensor. Each operand is read whole or as a part of every
19//! unit of a wider tensor (`Access`), and each result lands in its own buffer or in rows or units of another (`Dst`).
20//! `covers` names the lifted ops an op computes, so every op of the program is accounted for exactly once however the
21//! graph is rewritten.
22
23use std::collections::{BTreeMap, BTreeSet};
24use std::fmt;
25
26use crate::intern::{Interner, Symbol};
27use crate::model::program::{Cut, Identity};
28use crate::model::spec::ModelSpec;
29use crate::spatial::value_range::{Interval, UNBOUNDED};
30use crate::model::quant::GgmlType;
31use crate::tensor_recognize::qwen::RowIndex;
32use crate::tensor_recognize::Fill;
33
34use super::front::{RecognizedProgram, TensorOpSpec};
35
36/// How a step treats a tensor.
37#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
38pub enum Role {
39    Param,
40    State,
41    Constant,
42    Activation,
43}
44
45/// A tensor's element type.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
47pub enum Elem {
48    Float,
49    Int,
50}
51
52/// Where a tensor's values live.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub enum Storage {
55    /// An op computes it, or the program fills it.
56    Computed,
57    /// Values a backend holds as numbers, in the layout the program declares.
58    Values,
59    /// Rows of a checkpoint tensor read in place in a block format; `transposed` when the checkpoint's rows are the
60    /// program's columns.
61    Rows { format: GgmlType, transposed: bool },
62}
63
64/// One tensor of the graph: its shape as the program gives it (`rows × cols`), and its facts.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct Tensor {
67    pub name: String,
68    pub rows: usize,
69    pub cols: usize,
70    pub role: Role,
71    pub elem: Elem,
72    /// The values the program declares it with; `None` for a tensor an op computes.
73    pub fill: Option<Fill>,
74    pub storage: Storage,
75    /// The interval an integer tensor is proven to hold over the step; `None` for a float, and for an integer no bound
76    /// is proven for.
77    pub range: Option<Interval>,
78    /// A statement other than an op reads or writes it.
79    pub observed: bool,
80    /// A state the step carries to the next ([`super::front::carried_state`]): written in place and read beyond what
81    /// the step writes — a cache, a ring, a memory — where a table the step assembles whole before reading is not.
82    pub carried: bool,
83}
84
85impl Tensor {
86    pub fn len(&self) -> usize {
87        self.rows * self.cols
88    }
89
90    pub fn is_empty(&self) -> bool {
91        self.len() == 0
92    }
93}
94
95/// Which row of a table a write starts at: the row a parameter names (1-based), or a fixed row.
96#[derive(Clone, Debug, PartialEq, Eq, Hash)]
97pub enum RowSel {
98    Param(String),
99    Fixed(usize),
100}
101
102/// A part of every unit of a wider tensor: unit `u`'s values start `u·pitch + offset` in.
103#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
104pub struct Part {
105    pub pitch: usize,
106    pub offset: usize,
107}
108
109/// A part of every unit of a wider tensor read in place: unit `u`'s `width` values from `u·pitch + offset`, for every
110/// whole unit of the tensor.
111#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
112pub struct Strided {
113    pub pitch: usize,
114    pub offset: usize,
115    pub width: usize,
116}
117
118/// How an op reads an operand: the whole of it, or a part of every unit of it.
119#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
120pub enum Access {
121    Whole,
122    Strided(Strided),
123}
124
125/// Where an op writes its result: a buffer of its own, or the rows of a table of rows `pitch` wide from a starting row
126/// — the whole of it, or (`unit`) a part of every unit of it, each unit's share of the result `width` values.
127#[derive(Clone, Debug, PartialEq, Eq, Hash)]
128pub struct Dst {
129    pub buf: String,
130    pub row: Option<(RowSel, usize)>,
131    pub unit: Option<Strided>,
132}
133
134impl Dst {
135    pub fn to(buf: &str) -> Dst {
136        Dst { buf: buf.to_string(), row: None, unit: None }
137    }
138
139    /// The tensor the result is the whole of, in its own buffer.
140    pub fn own(&self) -> Option<&str> {
141        (self.row.is_none() && self.unit.is_none()).then_some(self.buf.as_str())
142    }
143}
144
145/// The names a step reads and writes across steps: its token(s), its position, its logits and its next token — its first
146/// sequence's, and a batch's every other sequence's in `more`, sequence `b`'s next token written to item `b` of the
147/// token tensor.
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct StepIo {
150    pub token: String,
151    pub position: String,
152    pub logits: String,
153    pub next: String,
154    pub more: Vec<SeqIo>,
155}
156
157impl StepIo {
158    /// Every sequence's position, the first sequence's first.
159    pub fn positions(&self) -> Vec<&str> {
160        std::iter::once(self.position.as_str()).chain(self.more.iter().map(|q| q.position.as_str())).collect()
161    }
162
163    /// Every name the step reads or writes across steps: its tokens and every sequence's position, logits and next token.
164    pub fn names(&self) -> Vec<&str> {
165        let firsts = [self.token.as_str(), self.position.as_str(), self.logits.as_str(), self.next.as_str()];
166        firsts.into_iter().chain(self.more.iter().flat_map(|q| [q.position.as_str(), q.logits.as_str(), q.next.as_str()])).collect()
167    }
168}
169
170/// A batch's sequence past its first: the position it reads, the logits it computes and the next token it picks.
171#[derive(Clone, Debug, PartialEq, Eq)]
172pub struct SeqIo {
173    pub position: String,
174    pub logits: String,
175    pub next: String,
176}
177
178/// A fused symbol: its class and its definition, the ops it computes, in order.
179#[derive(Clone, Debug)]
180pub struct Fused {
181    pub class: super::symbols::ClassId,
182    pub parts: Vec<Op>,
183}
184
185/// How an activation is quantized for the products that read it: int8 words a 32-value block shares one scale of —
186/// one word a value, or a high and a low word.
187#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
188pub enum QuantFormat {
189    OneWord,
190    TwoWords,
191}
192
193/// What an op computes.
194#[derive(Clone, Debug)]
195pub enum Sym {
196    /// An op the front end lifted whole.
197    Prim(TensorOpSpec),
198    /// A fused symbol, defined by its parts.
199    Fused(Fused),
200    /// The values of its one operand from `offset` on, read in place.
201    View { offset: usize },
202    /// Its one operand's quantized form for the products that read it: the operand's own values, under the meaning.
203    Quantize(QuantFormat),
204}
205
206/// One op of the graph: its symbol, its operands and how it reads each, its result and where that lands, the further
207/// results it writes whole, and the lifted ops it computes.
208#[derive(Clone, Debug)]
209pub struct Op {
210    pub sym: Sym,
211    pub ins: Vec<String>,
212    pub access: Vec<Access>,
213    pub out: String,
214    pub dst: Dst,
215    pub keeps: Vec<String>,
216    pub covers: Vec<usize>,
217}
218
219impl Op {
220    /// The primitive a lifted op is.
221    pub fn prim(&self) -> Option<&TensorOpSpec> {
222        match &self.sym {
223            Sym::Prim(spec) => Some(spec),
224            _ => None,
225        }
226    }
227
228    /// The op's kind: a primitive's, a fused symbol's class, `View` or `Quantize`.
229    pub fn kind(&self) -> &str {
230        match &self.sym {
231            Sym::Prim(spec) => spec.kind(),
232            Sym::Fused(f) => f.class.id(),
233            Sym::View { .. } => "View",
234            Sym::Quantize(_) => "Quantize",
235        }
236    }
237
238    /// The tensor the op computes whole into a buffer of its own: not one it lands rows or units of another in, nor one
239    /// it updates in place (a row written into a table, an activation's quantized form).
240    pub fn result(&self) -> Option<&str> {
241        let own = self.dst.own()?;
242        let in_place = matches!(self.sym, Sym::Quantize(_)) || self.state_inputs().into_iter().any(|k| self.ins[k] == own);
243        (!in_place).then_some(own)
244    }
245
246    /// The positions in `ins` of the operands the op writes in place: a primitive's state, or any part's.
247    pub fn state_inputs(&self) -> Vec<usize> {
248        match &self.sym {
249            Sym::Prim(spec) => spec.state_inputs(),
250            Sym::Fused(f) => {
251                let written: BTreeSet<&str> = f.parts.iter().flat_map(|p| p.state_inputs().into_iter().map(|k| p.ins[k].as_str())).collect();
252                self.ins.iter().enumerate().filter(|(_, n)| written.contains(n.as_str())).map(|(k, _)| k).collect()
253            }
254            Sym::View { .. } | Sym::Quantize(_) => Vec::new(),
255        }
256    }
257
258    /// Every tensor the op writes: its destination's buffer, what it writes in place, what it keeps, and — for a fused
259    /// op — every table or wider tensor a part lands its result in.
260    pub fn writes(&self) -> Vec<&str> {
261        let mut w: Vec<&str> = vec![self.dst.buf.as_str()];
262        w.extend(self.state_inputs().into_iter().map(|k| self.ins[k].as_str()));
263        w.extend(self.keeps.iter().map(String::as_str));
264        if let Sym::Fused(f) = &self.sym {
265            w.extend(f.parts.iter().flat_map(|p| p.writes()).filter(|t| p_lands_outside(f, t)));
266        }
267        w.sort_unstable();
268        w.dedup();
269        w
270    }
271
272    /// The parameters that name the rows the op's result, or any part's, lands in.
273    pub fn row_params(&self) -> Vec<&str> {
274        let mut r: Vec<&str> = match &self.dst.row {
275            Some((RowSel::Param(p), _)) => vec![p.as_str()],
276            _ => Vec::new(),
277        };
278        if let Sym::Fused(f) = &self.sym {
279            r.extend(f.parts.iter().flat_map(|p| p.row_params()));
280        }
281        r
282    }
283}
284
285/// Whether a tensor a part of `f` writes is one the fused op writes outside itself: a table or wider tensor a part lands
286/// rows or units in, or state a part updates — not an intermediate one part computes for another.
287fn p_lands_outside(f: &Fused, t: &str) -> bool {
288    f.parts.iter().any(|p| p.dst.buf == t && p.dst.own().is_none()) || f.parts.iter().any(|p| p.state_inputs().iter().any(|&k| p.ins[k] == t))
289}
290
291/// The ops that touch each tensor of a graph ([`TensorGraph::touches`]).
292pub struct Touches<'g> {
293    graph: &'g TensorGraph,
294    by: BTreeMap<&'g str, BTreeSet<usize>>,
295}
296
297impl Touches<'_> {
298    /// Whether ops `writer` and `reader` are the only ones that touch `tensor`: an activation no statement but an op
299    /// sees, which no other op reads or writes.
300    pub fn read_only_by(&self, tensor: &str, writer: usize, reader: usize) -> bool {
301        self.graph.tensors.get(tensor).is_some_and(|t| t.role == Role::Activation && !t.observed)
302            && self.by.get(tensor).is_none_or(|at| at.iter().all(|&i| i == writer || i == reader))
303    }
304}
305
306/// A source walked by the front end, and the graph of every statement the walk lifts.
307#[derive(Clone, Debug)]
308pub struct Lifted {
309    pub program: RecognizedProgram,
310    pub graph: TensorGraph,
311}
312
313/// A cut of a model's one program as a graph.
314#[derive(Clone, Debug)]
315pub struct TensorGraph {
316    /// The cut it was lifted from.
317    pub identity: Option<Identity>,
318    /// The names it reads and writes across steps.
319    pub io: Option<StepIo>,
320    pub tensors: BTreeMap<String, Tensor>,
321    /// The ops in program order, the schedule order every backend starts from.
322    pub ops: Vec<Op>,
323}
324
325/// Why a program is not a graph, by name.
326#[derive(Clone, Debug, PartialEq, Eq)]
327pub enum GraphError {
328    /// A tensor statement the front end lifts no op from.
329    Unrecognized { stmt: usize, what: String },
330    /// A write to a declared tensor that no op accounts for.
331    UnliftedWrite { stmt: usize, tensor: String, why: &'static str },
332    /// An op reads a tensor neither declared nor computed before it.
333    Undefined { op: usize, tensor: String },
334    /// The program computes nothing.
335    Empty,
336    /// The source does not parse, or the front end's walk refuses it.
337    Unwalked { why: String },
338}
339
340impl fmt::Display for GraphError {
341    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342        match self {
343            GraphError::Unrecognized { stmt, what } => write!(f, "statement {stmt} (`{what}`) is no op a lane compiles"),
344            GraphError::UnliftedWrite { stmt, tensor, why } => {
345                write!(f, "statement {stmt} writes `{tensor}` outside any op: {why}")
346            }
347            GraphError::Undefined { op, tensor } => write!(f, "op {op} reads `{tensor}` before anything defines it"),
348            GraphError::Empty => write!(f, "the program computes nothing"),
349            GraphError::Unwalked { why } => write!(f, "the front end does not walk the program: {why}"),
350        }
351    }
352}
353
354impl std::error::Error for GraphError {}
355
356/// The names an op reads as a position or as row indices.
357fn index_operands(op: &TensorOpSpec, name: &dyn Fn(crate::intern::Symbol) -> String) -> Vec<String> {
358    let named = |i: &RowIndex| match i {
359        RowIndex::Named(s) => Some(name(*s)),
360        RowIndex::Literal(_) => None,
361    };
362    match op {
363        TensorOpSpec::RowGather(r) => named(&r.index).into_iter().collect(),
364        TensorOpSpec::RowsGather(r) => vec![name(r.indices)],
365        TensorOpSpec::SetRow(r) => named(&r.index).into_iter().collect(),
366        TensorOpSpec::Attention(a) => a.pos.as_ref().and_then(named).into_iter().collect(),
367        TensorOpSpec::LutAttention(a) => a.pos.as_ref().and_then(named).into_iter().collect(),
368        TensorOpSpec::Rope(r) => r.from.as_ref().and_then(|(p, _)| named(p)).into_iter().collect(),
369        _ => Vec::new(),
370    }
371}
372
373/// The element count of an op's result.
374pub fn out_len(op: &TensorOpSpec) -> usize {
375    match op {
376        TensorOpSpec::Matmul(m) => m.m * m.n,
377        TensorOpSpec::Elementwise(e) => e.n,
378        TensorOpSpec::SwiGlu(s) => s.total,
379        TensorOpSpec::FusedRow(r) => r.rows * r.cols,
380        TensorOpSpec::Rope(r) => r.h * r.hd,
381        TensorOpSpec::Silu(s) => s.n,
382        TensorOpSpec::Sigmoid(s) => s.n,
383        TensorOpSpec::Gelu(g) => g.n,
384        TensorOpSpec::Scale(c) => c.n,
385        TensorOpSpec::Softcap(c) => c.n,
386        TensorOpSpec::Decay(d) => d.n,
387        TensorOpSpec::L2Norm(l) => l.rows * l.cols,
388        TensorOpSpec::Transpose(t) => t.rows * t.cols,
389        TensorOpSpec::HeadSlice(s) => s.n / s.every * s.width,
390        TensorOpSpec::Join(j) => j.rows * (j.wa + j.wb),
391        TensorOpSpec::HeadMatmul(m) => m.m * m.heads * m.b,
392        TensorOpSpec::CausalConv(c) => c.rows * c.chan,
393        TensorOpSpec::GatedDelta(g) => g.rows * g.hv * g.d,
394        TensorOpSpec::Attention(a) => a.rows * a.h * a.vd,
395        TensorOpSpec::FullAttention(a) => a.t * a.h * a.hd,
396        TensorOpSpec::Moe(m) => m.d,
397        TensorOpSpec::RowGather(r) => r.cols,
398        TensorOpSpec::RowsGather(r) => r.count * r.cols,
399        TensorOpSpec::ArgMax(_) => 1,
400        TensorOpSpec::SetRow(r) => r.rows * r.cols,
401        TensorOpSpec::IntRequant(r) => r.n,
402        TensorOpSpec::IntClamp(c) => c.n,
403        TensorOpSpec::Relu(r) => r.n,
404        TensorOpSpec::LutAttention(a) => a.h * a.hd,
405    }
406}
407
408impl TensorGraph {
409    /// The graph of a walked program: `storage` says where each constant's values live (the IR reads no checkpoint), and
410    /// `identity` and `io` are the cut it was lifted from and the names it carries across steps.
411    pub fn lift(
412        rp: &RecognizedProgram,
413        identity: Option<Identity>,
414        io: Option<StepIo>,
415        storage: &dyn Fn(&str) -> Storage,
416    ) -> Result<TensorGraph, GraphError> {
417        if let Some((stmt, what)) = rp.unrecognized.first() {
418            return Err(GraphError::Unrecognized { stmt: *stmt, what: what.clone() });
419        }
420        if let Some((stmt, tensor, why)) = rp.unlifted_writes.first() {
421            return Err(GraphError::UnliftedWrite { stmt: *stmt, tensor: tensor.clone(), why });
422        }
423        if rp.stages.is_empty() {
424            return Err(GraphError::Empty);
425        }
426        TensorGraph::build(rp, identity, io, storage, false)
427    }
428
429    /// The graph with each constant's values where `storage` says and `io` the names it carries across steps, which
430    /// its host reads: a graph lifted before its constants were placed, placed as [`TensorGraph::lift`] places them.
431    pub fn placed(&self, storage: &dyn Fn(&str) -> Storage, io: Option<StepIo>) -> TensorGraph {
432        let mut g = self.clone();
433        let carried: BTreeSet<String> = io.iter().flat_map(|io| io.names()).map(String::from).collect();
434        for t in g.tensors.values_mut() {
435            if t.role == Role::Constant {
436                t.storage = storage(&t.name);
437            }
438            t.observed |= carried.contains(&t.name);
439        }
440        g.io = io;
441        g
442    }
443
444    /// The graph of the statements the front end lifted, whatever else the program holds: a backend that reports the
445    /// statements it could not lift on its own lowers the rest. A tensor a statement the front end passed over computes
446    /// is an activation of the shape the program gives it.
447    pub fn lift_stages(rp: &RecognizedProgram, identity: Option<Identity>, io: Option<StepIo>, storage: &dyn Fn(&str) -> Storage) -> TensorGraph {
448        TensorGraph::build(rp, identity, io, storage, true).expect("a lenient lift refuses nothing")
449    }
450
451    /// The front end's walk of `source` and the graph of what it lifts ([`TensorGraph::lift_stages`]): the one way a
452    /// backend reaches the ops of a source.
453    pub fn lift_source(source: &str, storage: &dyn Fn(&str) -> Storage) -> Result<Lifted, crate::spatial::passes::SpatialReject> {
454        let program = super::front::recognize_stages(source)?;
455        let graph = TensorGraph::lift_stages(&program, None, None, storage);
456        Ok(Lifted { program, graph })
457    }
458
459    /// The graph of the cut `cut` of `spec`'s program, recording the cut's identity: `io` the names it carries across
460    /// steps, `storage` where each constant's values live.
461    pub fn of_cut(spec: &ModelSpec, cut: Cut, io: Option<StepIo>, storage: &dyn Fn(&str) -> Storage) -> Result<TensorGraph, GraphError> {
462        let (authored, identity) = crate::model::program::written(spec, cut);
463        let rp = super::front::recognize_stages(&authored.0).map_err(|e| GraphError::Unwalked { why: format!("{e:?}") })?;
464        TensorGraph::lift(&rp, Some(identity), io, storage)
465    }
466
467    /// The graph of one op over operands the source declares, each `(symbol, length, fill)`, and its result `out`: a
468    /// statement a backend recognizes on its own, outside any walk of a program. Every symbol the op's spec names is
469    /// one of its operands or its result.
470    pub fn of_op(op: TensorOpSpec, ins: Vec<(Symbol, usize, Fill)>, out: Symbol, interner: &Interner) -> TensorGraph {
471        let name = |s: Symbol| interner.resolve(s).to_string();
472        let rp = RecognizedProgram {
473            stages: vec![super::front::RecognizedStage { op, stmt_index: 0, out: name(out), ins: ins.iter().map(|(s, _, _)| name(*s)).collect() }],
474            declared: ins.iter().map(|(s, len, fill)| super::front::DeclaredTensor { name: name(*s), len: *len, fill: fill.clone() }).collect(),
475            symbols: ins.iter().map(|(s, _, _)| *s).chain([out]).map(|s| (s, name(s))).collect(),
476            ..RecognizedProgram::default()
477        };
478        TensorGraph::lift_stages(&rp, None, None, &|_| Storage::Values)
479    }
480
481    fn build(
482        rp: &RecognizedProgram,
483        identity: Option<Identity>,
484        io: Option<StepIo>,
485        storage: &dyn Fn(&str) -> Storage,
486        lenient: bool,
487    ) -> Result<TensorGraph, GraphError> {
488        let name = |s| rp.name(s).to_string();
489        let mut params = BTreeSet::new();
490        let mut state = BTreeSet::new();
491        for st in &rp.stages {
492            params.extend(index_operands(&st.op, &name));
493            for k in st.op.state_inputs() {
494                state.insert(st.ins[k].clone());
495            }
496        }
497        // A tensor some statement other than an op touches: a `Show`, a scalar read, foreign code.
498        let stage_stmts: BTreeSet<usize> = rp.stages.iter().map(|s| s.stmt_index).collect();
499        let observed = |t: &str| {
500            !rp.opaque.is_empty() || rp.mentions.get(t).is_some_and(|at| at.iter().any(|i| !stage_stmts.contains(i) && !rp.quiet.contains(i)))
501        };
502        // Each name's element kind and, an integer's, its proven interval: the front end's own analyses.
503        let floats = super::front::float_tensors(rp);
504        let ranges = crate::spatial::value_range::value_ranges(rp, &floats);
505        let elem = |t: &str| if floats.contains(t) { Elem::Float } else { Elem::Int };
506        let range = |t: &str| (!floats.contains(t)).then(|| ranges.get(t).copied()).flatten().filter(|r| *r != UNBOUNDED);
507        let carried: BTreeSet<String> = super::front::carried_state(rp).into_iter().collect();
508        let mut tensors = BTreeMap::new();
509        for d in &rp.declared {
510            let role = if params.contains(&d.name) {
511                Role::Param
512            } else if state.contains(&d.name) {
513                Role::State
514            } else {
515                Role::Constant
516            };
517            let (rows, cols) = rp.shape_of(&d.name, d.len);
518            let storage = if role == Role::Constant { storage(&d.name) } else { Storage::Computed };
519            tensors.insert(
520                d.name.clone(),
521                Tensor {
522                    name: d.name.clone(),
523                    rows,
524                    cols,
525                    role,
526                    elem: elem(&d.name),
527                    fill: Some(d.fill.clone()),
528                    storage,
529                    range: range(&d.name),
530                    observed: false,
531                    carried: role == Role::State && carried.contains(&d.name),
532                },
533            );
534        }
535        let mut ops = Vec::with_capacity(rp.stages.len());
536        for (i, st) in rp.stages.iter().enumerate() {
537            for x in &st.ins {
538                if tensors.contains_key(x) {
539                    continue;
540                }
541                if !lenient {
542                    return Err(GraphError::Undefined { op: i, tensor: x.clone() });
543                }
544                let (rows, cols) = rp.shapes.get(x).copied().unwrap_or((1, 0));
545                tensors.insert(
546                    x.clone(),
547                    Tensor {
548                        name: x.clone(),
549                        rows,
550                        cols,
551                        role: Role::Activation,
552                        elem: elem(x),
553                        fill: None,
554                        storage: Storage::Computed,
555                        range: range(x),
556                        observed: false,
557                        carried: false,
558                    },
559                );
560            }
561            if !tensors.contains_key(&st.out) {
562                let n = out_len(&st.op);
563                let (rows, cols) = rp.shape_of(&st.out, n);
564                tensors.insert(
565                    st.out.clone(),
566                    Tensor {
567                        name: st.out.clone(),
568                        rows,
569                        cols,
570                        role: Role::Activation,
571                        elem: elem(&st.out),
572                        fill: None,
573                        storage: Storage::Computed,
574                        range: range(&st.out),
575                        observed: false,
576                        carried: false,
577                    },
578                );
579            }
580            ops.push(Op {
581                sym: Sym::Prim(st.op.clone()),
582                access: vec![Access::Whole; st.ins.len()],
583                ins: st.ins.clone(),
584                dst: Dst::to(&st.out),
585                out: st.out.clone(),
586                keeps: Vec::new(),
587                covers: vec![i],
588            });
589        }
590        // The names a step carries across steps are read by its host: they are observed.
591        let carried: BTreeSet<&str> = io.iter().flat_map(|io| io.names()).collect();
592        let names: Vec<String> = tensors.keys().cloned().collect();
593        for n in names {
594            let seen = observed(&n) || carried.contains(n.as_str());
595            tensors.get_mut(&n).expect("a tensor").observed = seen;
596        }
597        Ok(TensorGraph { identity, io, tensors, ops })
598    }
599
600    /// The `(rows, cols)` the program gives `tensor`, where that accounts for the `n` elements an op counts — the
601    /// `1 × n` row otherwise.
602    pub fn shape_of(&self, tensor: &str, n: usize) -> (usize, usize) {
603        match self.tensors.get(tensor) {
604            Some(t) if t.len() == n => (t.rows, t.cols),
605            _ => (1, n),
606        }
607    }
608
609    /// Which ops touch each tensor — read it, write it, or name the rows it lands in — taken once, for questions asked
610    /// of many tensors.
611    pub fn touches(&self) -> Touches<'_> {
612        use super::facts::{reads, writes};
613        let mut by: BTreeMap<&str, BTreeSet<usize>> = BTreeMap::new();
614        for (i, op) in self.ops.iter().enumerate() {
615            for t in reads(op).into_iter().chain(writes(op)) {
616                by.entry(t).or_default().insert(i);
617            }
618        }
619        Touches { graph: self, by }
620    }
621
622    /// The tensors of one role, by name.
623    pub fn of_role(&self, role: Role) -> impl Iterator<Item = &Tensor> {
624        self.tensors.values().filter(move |t| t.role == role)
625    }
626
627    /// The ops that read `tensor`, by index.
628    pub fn readers(&self, tensor: &str) -> Vec<usize> {
629        self.ops.iter().enumerate().filter(|(_, o)| o.ins.iter().any(|x| x == tensor)).map(|(i, _)| i).collect()
630    }
631
632    /// The op that computes `tensor` into its own buffer.
633    pub fn producer(&self, tensor: &str) -> Option<usize> {
634        self.ops.iter().position(|o| o.result() == Some(tensor) || o.keeps.iter().any(|k| k == tensor))
635    }
636
637    /// The ops of the graph and every member of each fused one, at every depth: each op as it computes.
638    fn members(&self) -> Vec<&Op> {
639        fn walk<'a>(op: &'a Op, out: &mut Vec<&'a Op>) {
640            out.push(op);
641            if let Sym::Fused(f) = &op.sym {
642                f.parts.iter().for_each(|p| walk(p, out));
643            }
644        }
645        let mut out = Vec::new();
646        self.ops.iter().for_each(|op| walk(op, &mut out));
647        out
648    }
649
650    /// Every tensor an op writes in place, however deep in a fused op it is written: what it updates as state, and every
651    /// table or wider tensor a destination lands rows or units in.
652    pub fn writes_in_place(&self) -> BTreeSet<String> {
653        let mut out = BTreeSet::new();
654        for op in self.members() {
655            for k in op.state_inputs() {
656                out.insert(op.ins[k].clone());
657            }
658            if op.dst.row.is_some() || op.dst.unit.is_some() {
659                out.insert(op.dst.buf.clone());
660            }
661        }
662        out
663    }
664
665    /// The attention caches: every state tensor an attention reads its keys or values from, itself or through the
666    /// head slices cut from it (a latent cache's values are its rows' first part).
667    pub fn caches(&self) -> BTreeSet<String> {
668        let members = self.members();
669        let producer: BTreeMap<&str, &Op> = members.iter().map(|o| (o.out.as_str(), *o)).collect();
670        let mut out = BTreeSet::new();
671        for op in members.iter().filter(|o| matches!(o.sym, Sym::Prim(TensorOpSpec::Attention(_)))) {
672            for read in &op.ins[1..3] {
673                let mut t = read.as_str();
674                loop {
675                    if self.tensors.get(t).is_some_and(|x| x.role == Role::State) {
676                        out.insert(t.to_string());
677                        break;
678                    }
679                    match producer.get(t) {
680                        Some(p) if matches!(p.sym, Sym::Prim(TensorOpSpec::HeadSlice(_))) => t = &p.ins[0],
681                        _ => break,
682                    }
683                }
684            }
685        }
686        out
687    }
688}