Skip to main content

logicaffeine_compile/spatial/
eval.rs

1//! `eval_choreography` — the executable MEANING of a placed + scheduled spatial program.
2//!
3//! Every engine program runs as a coroutine over bounded FIFOs, semaphores and accumulators with tt-metal's
4//! enabling rules (a reserve needs free capacity, a push needs a reservation carrying data, a wait needs
5//! pages, a multicast needs every receiver's reservation, a semaphore wait needs its value). A round in
6//! which no program can step while some are unfinished is a `Deadlock`; every protocol slip is a typed
7//! `ChoreoFault`. Five schedulers must agree (Kahn determinacy, executed). The numerics per tile are the
8//! systolic references: integer operands accumulate in wrapping i64 (`I32` truncates at pack), float
9//! operands accumulate in f32 K-ascending exactly as `eval_systolic_bf16`, and a spill to a narrower
10//! intermediate rounds exactly where the schedule spills. This is the golden oracle every lowering is
11//! proven against.
12
13use std::collections::{BTreeMap, BTreeSet, VecDeque};
14
15use serde::{Deserialize, Serialize};
16
17use super::ir::*;
18use super::passes::{LV_ITEM, LV_ITEM_X, LV_ITEM_Y, LV_SUB_H, LV_SUB_W};
19use super::target::{precision_int_bits, precision_is_int};
20use crate::codegen_sva::fabric_rtl::{eval_decision_fabric_heap, DecisionFabric};
21use crate::codegen_sva::fabric_target::Precision;
22use crate::codegen_sva::simd::{f16_to_f32, f32_to_f16};
23use crate::codegen_sva::systolic::{round_bf16, trunc_signed};
24
25/// The refusal an element-level SCATTER earns: an indirect term on a compute stage's OUTPUT map. Gathers (an
26/// indirect term on an operand map, an `IndirectTile` on a Load) and tile scatters (an `IndirectTile` on a
27/// Store) have semantics; an element scatter does not, and nothing lowers one.
28pub const INDIRECT_MAP_DEFERRED: &str = "indirect access (B-P5): an element-level scatter (an indirect term on a compute's output map) has no evaluation semantics";
29
30/// A tensor's elements, row-major over its element shape.
31#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
32pub enum Value {
33    Int(Vec<i64>),
34    Float(Vec<f64>),
35}
36
37#[derive(Clone, Debug, Default)]
38pub struct Inputs {
39    pub tensors: BTreeMap<TensorId, Value>,
40}
41
42impl Inputs {
43    /// Every tensor of `p` but its outputs, materialized from its recorded fill: a float fill as floats, every other
44    /// as integers. Refuses, naming it, a tensor whose fill is not deterministic (data the compiler cannot see).
45    pub fn from_fills(p: &SpatialProgram) -> Result<Inputs, String> {
46        let mut inputs = Inputs::default();
47        for t in p.tensors.iter().filter(|t| t.role != TensorRole::Output) {
48            let n = t.elems.rows * t.elems.cols;
49            let value = if t.init.is_float() {
50                (0..n).map(|i| t.init.element_f64(i)).collect::<Option<Vec<f64>>>().map(Value::Float)
51            } else {
52                (0..n).map(|i| t.init.element(i)).collect::<Option<Vec<i64>>>().map(Value::Int)
53            };
54            inputs.tensors.insert(t.id, value.ok_or_else(|| format!("`{}` has no deterministic fill", t.name))?);
55        }
56        Ok(inputs)
57    }
58}
59
60#[derive(Clone, Debug, PartialEq)]
61pub struct Outputs {
62    pub tensors: BTreeMap<TensorId, Value>,
63    pub steps: u64,
64}
65
66#[derive(Clone, Debug, Default)]
67pub struct Trace {
68    /// Peak pages resident (queued + filled-pending) per buffer.
69    pub high_water: BTreeMap<BufferId, usize>,
70    pub steps: u64,
71    /// Tiles each tensor had read, per section (`0` for a program of one workload): what the run actually moved.
72    pub tiles_read: BTreeMap<(TensorId, usize), u64>,
73    /// The least and the greatest integer each buffer's pages held.
74    pub held: BTreeMap<BufferId, (i64, i64)>,
75    /// For each stage running an integer Generic body, the least and the greatest value each of its nodes computed —
76    /// in [`TensorBody::post_order`], both arms of a select at every point, `None` for a node that never computed one.
77    pub nodes: BTreeMap<StageId, Vec<Option<(i64, i64)>>>,
78    /// The least and the greatest integer each compute stage kept in its accumulator: a contraction's and a fold's
79    /// partial results, a matmul's partial sums, the values it wrote.
80    pub stage_held: BTreeMap<StageId, (i64, i64)>,
81}
82
83fn widen(slot: Option<(i64, i64)>, v: i64) -> (i64, i64) {
84    slot.map_or((v, v), |(lo, hi)| (lo.min(v), hi.max(v)))
85}
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
88pub enum Scheduler {
89    RoundRobin,
90    Reverse,
91    Seeded(u64),
92    MovementFirst,
93    ComputeFirst,
94}
95
96#[derive(Clone, Debug, PartialEq)]
97pub enum ChoreoFault {
98    NotScheduled,
99    MissingInput(TensorId),
100    /// A trip's input holds a value the program does not admit ([`Trip::count_of`]) — refused before any op runs,
101    /// against the admitted range it names, so no loop ever runs a count its bound was not proved for.
102    TripOutOfRange {
103        trip: TripId,
104        value: f64,
105        admitted: (i64, i64),
106    },
107    Deadlock {
108        runnable: Vec<(Site, Engine)>,
109        blocked_at: Vec<(Site, Engine, usize, ChoreoOp)>,
110    },
111    BufferOverflow {
112        buf: BufferId,
113        at: (Site, Engine, usize),
114    },
115    Underflow {
116        buf: BufferId,
117        at: (Site, Engine, usize),
118    },
119    PushWithoutData {
120        buf: BufferId,
121        at: (Site, Engine, usize),
122    },
123    /// A consumer read a page it never waited for (hardware would read stale local memory).
124    ReadBeforeWait {
125        buf: BufferId,
126        at: (Site, Engine, usize),
127    },
128    /// A consumer popped more pages than it had waited for.
129    PopBeforeWait {
130        buf: BufferId,
131        at: (Site, Engine, usize),
132    },
133    Unconsumed {
134        buf: BufferId,
135        pages_left: usize,
136    },
137    OutstandingReservation {
138        buf: BufferId,
139        pages: usize,
140    },
141    McastIntoUnreserved {
142        group: GroupId,
143        receiver: Site,
144    },
145    SemaphoreProtocol {
146        sem: SemId,
147        expected: SemCond,
148        found: usize,
149    },
150    AccOverflow {
151        requested: usize,
152        available: usize,
153    },
154    /// A page was written into local memory an ALIAS of the buffer still holds: a page the alias pushed and its
155    /// consumer has not popped. Two buffers sharing one region (`Buffer::alias_of`) keep separate counters, so
156    /// nothing else stops a producer of one from overwriting what the consumer of the other is about to read.
157    AliasClobber {
158        buf: BufferId,
159        alias: BufferId,
160        at: (Site, Engine, usize),
161    },
162    AccProtocol {
163        at: (Site, Engine, usize),
164    },
165    StoreConflict {
166        tensor: TensorId,
167        tile: usize,
168        first: Site,
169        second: Site,
170    },
171    OutputTileNeverStored {
172        tensor: TensorId,
173        tile: usize,
174    },
175    ReadOutOfRange {
176        tensor: TensorId,
177        tile: usize,
178    },
179    /// A CARRIED tensor was read out of step with its writer: a section after the writing section read
180    /// a tile the writer has not produced yet (last step's value), or a section before it read a tile
181    /// the writer had already overwritten (this step's value).
182    ///
183    /// ★ THIS FAULT EXISTS BECAUSE STATE CANNOT FAULT ANY OTHER WAY. An activation starts a run EMPTY,
184    /// so reading it early is `ReadOutOfRange` and the evaluator notices for free. A state tensor
185    /// starts a run FULL — that is what carrying means — so an unordered read returns a perfectly
186    /// well-formed number from the previous token, and every check downstream passes. Dropping a
187    /// barrier would be silently, plausibly wrong. So the read is checked against the section the
188    /// writer lives in, which is a fact about the program, not about this scheduler's luck.
189    StaleStateRead {
190        tensor: TensorId,
191        tile: usize,
192        read_in: usize,
193        written_in: usize,
194        already_written: bool,
195    },
196    NonDeterministic {
197        scheduler_a: &'static str,
198        scheduler_b: &'static str,
199        tensor: TensorId,
200    },
201    /// A GATHER read an index outside its declared extent — `[0, modulus)` of its `IndirectTerm` — or one
202    /// that is not a whole number. ★ AN OUT-OF-RANGE INDEX IS A FAULT, NEVER A WRAP. A token id past the
203    /// vocabulary, a cache position past the cache: a wrapped or clamped index reads SOMEBODY ELSE'S row
204    /// and every check downstream passes. So the data run stops, naming the value and the extent.
205    GatherOutOfRange {
206        at: (Site, Engine, usize),
207        index: f64,
208        extent: usize,
209    },
210    /// An integer stage's arithmetic left `i64` — a product, a sum or a shifted value past ±2⁶³ — or divided by zero.
211    /// The VM carries such a value exactly, as a big integer, or refuses the division; an evaluator that wrapped or
212    /// rounded would report another number as the answer, so the run stops here, naming the operation.
213    IntegerArithmetic {
214        at: (Site, Engine, usize),
215        what: &'static str,
216    },
217    /// A TILE gather or scatter (`IndirectTile`) read an index outside its declared `bound`, or one that is not
218    /// a whole number. The same promise as [`ChoreoFault::GatherOutOfRange`] at tile granularity: a row that
219    /// is not the table's is never read, and a write that has nowhere legal to land is never silently dropped.
220    TileGatherOutOfBound {
221        at: (Site, Engine, usize),
222        index: f64,
223        bound: (usize, usize),
224    },
225    Unsupported(String),
226}
227
228#[derive(Clone, Debug)]
229enum TileData {
230    Int(Vec<i64>),
231    Float(Vec<f64>),
232    /// A token without data — the dry run carries choreography, never arithmetic.
233    Empty,
234}
235
236type Page = Vec<TileData>;
237
238#[derive(Clone, Debug)]
239enum AccData {
240    Int(Vec<i64>),
241    F32(Vec<f32>),
242}
243
244struct FifoState {
245    capacity: usize,
246    queue: VecDeque<Page>,
247    pending: VecDeque<Page>,
248    reserved: usize,
249    high_water: usize,
250    /// Pages at the front the consumer has waited for and not yet popped — the only pages it may read.
251    visible: usize,
252    /// Pages popped since the program began. With the front page being the only one a compute may read,
253    /// `(buffer, popped)` names exactly WHICH page an accumulation folded in — the identity the accumulator
254    /// protocol needs and a boolean cannot carry.
255    popped: usize,
256    /// The backing store the pages live in, addressed by the write pointer: a page the producer overwrites
257    /// or ACCUMULATES INTO is the one at the slot it is about to fill, whether or not the consumer has
258    /// already drained it. Only an accumulating packer reads this, and it is what makes the partial sum stay
259    /// in local memory across K blocks.
260    slots: Vec<Page>,
261    wr: usize,
262}
263
264impl FifoState {
265    fn resident(&self) -> usize {
266        self.queue.len() + self.pending.len()
267    }
268}
269
270struct TensorStore {
271    dtype: Precision,
272    cols_t: usize,
273    tiles: Vec<Option<TileData>>,
274    /// The site and section of each tile's latest store.
275    stored_by: Vec<Option<(Site, usize)>>,
276    /// For a CARRIED tensor: the sections whose `Store`s produce this step's value, in order. `None` for every
277    /// other role — nothing else is read out of step.
278    writer_sections: Option<Vec<usize>>,
279    /// For a carried tensor: the section that last wrote each tile this step, `None` while it holds last step's.
280    written_in: Vec<Option<usize>>,
281    /// The dry run's item window cut short an item loop that writes this tensor, so a tile it has not
282    /// written may be one the elided items write: an unwritten in-range tile is not provably absent.
283    elided: bool,
284}
285
286#[derive(Clone, Debug)]
287enum Instr {
288    /// An op, TAGGED with the stage it belongs to. The tag is what makes `item_coord` section-scoped:
289    /// a composed program's item list is every section's concatenated, and an item loop must index its
290    /// OWN section's items or every section after the first addresses the wrong tiles.
291    Op(ChoreoOp, StageId),
292    LoopStart {
293        var: LoopVar,
294        count: usize,
295        end: usize,
296    },
297    LoopEnd {
298        start: usize,
299    },
300}
301
302struct Coroutine {
303    site: Site,
304    engine: Engine,
305    code: std::rc::Rc<Vec<Instr>>,
306    pc: usize,
307    loops: Vec<(LoopVar, usize, usize)>,
308    env: BTreeMap<LoopVar, usize>,
309    acc: Option<Vec<AccData>>,
310    /// The held accumulator has been computed into since it was acquired (packing a fresh one is a fault).
311    acc_computed: bool,
312    /// Operand pages already folded into the held accumulator, as `(buffer, pop cursor)`.
313    ///
314    /// A reduction legitimately computes many times inside ONE accumulator window — that is how the K loop
315    /// accumulates — so "compute at most once per window" would reject a correct program. What separates a
316    /// real repeat from a duplicated op is that the real one consumes FRESH pages: a pop and a new wait sit
317    /// between them, so the operands advance. Folding a page that is already in this set is the mutation
318    /// that silently doubles a partial sum.
319    acc_folded: std::collections::BTreeSet<(usize, usize)>,
320    done: bool,
321}
322
323/// Add each tensor's `WriteTiles` executions in flattened `code`, per section — every op counted once per iteration
324/// of the loops around it — to `out`.
325fn count_writes(p: &SpatialProgram, code: &[Instr], out: &mut [BTreeMap<usize, u64>]) {
326    let mut mult: Vec<u64> = vec![1];
327    for i in code {
328        match i {
329            Instr::LoopStart { count, .. } => {
330                let m = *mult.last().expect("an outer multiplicity");
331                mult.push(m * *count as u64);
332            }
333            Instr::LoopEnd { .. } => {
334                mult.pop();
335            }
336            Instr::Op(ChoreoOp::WriteTiles { tensor, .. }, stage) => {
337                *out[tensor.idx()]
338                    .entry(p.section_of_stage(*stage).unwrap_or(0))
339                    .or_default() += *mult.last().expect("an outer multiplicity")
340            }
341            _ => {}
342        }
343    }
344}
345
346/// Flatten a tagged choreography into straight-line code, each loop run to a trip counted `counts[trip]`. Under
347/// `item_cap`, every item loop runs at most `cap` rounds, and `elided[t]` is set for each tensor `t` a cut-short loop
348/// writes.
349fn flatten(
350    ops: &[TaggedOp],
351    out: &mut Vec<Instr>,
352    counts: &[usize],
353    item_cap: Option<usize>,
354    elided: &mut [bool],
355) {
356    for t in ops {
357        match &t.op {
358            ChoreoOp::Loop { var, count, body } => {
359                let start = out.len();
360                let full = count.at(counts);
361                // A token-only run walks the item loop within its provable window (see `certificate::item_window`);
362                // every engine and every group member caps the same way, so the choreography stays balanced. The
363                // window is a bound on the TOKEN protocol, which is periodic past it; the data the elided rounds
364                // write is not modelled, so the tensors they write are recorded.
365                let n = match item_cap {
366                    Some(cap) if *var == LV_ITEM => full.min(cap),
367                    _ => full,
368                };
369                if n < full {
370                    walk_tagged_ops(body, 1, &mut |op, _| {
371                        if let ChoreoOp::WriteTiles { tensor, .. } = op {
372                            elided[tensor.idx()] = true;
373                        }
374                    });
375                }
376                out.push(Instr::LoopStart {
377                    var: *var,
378                    count: n,
379                    end: 0,
380                });
381                flatten(body, out, counts, item_cap, elided);
382                let end = out.len();
383                out.push(Instr::LoopEnd { start });
384                if let Instr::LoopStart { end: e, .. } = &mut out[start] {
385                    *e = end;
386                }
387            }
388            other => out.push(Instr::Op(other.clone(), t.stage)),
389        }
390    }
391}
392
393fn scheduler_name(s: Scheduler) -> &'static str {
394    match s {
395        Scheduler::RoundRobin => "round-robin",
396        Scheduler::Reverse => "reverse",
397        Scheduler::Seeded(_) => "seeded",
398        Scheduler::MovementFirst => "movement-first",
399        Scheduler::ComputeFirst => "compute-first",
400    }
401}
402
403/// Element-wise sum of two pages — the packer's L1 accumulate. A missing or empty base is zero.
404fn add_pages(base: &Page, add: &Page) -> Page {
405    if base.len() != add.len() {
406        return add.clone();
407    }
408    base.iter()
409        .zip(add)
410        .map(|(b, a)| match (b, a) {
411            (TileData::Int(x), TileData::Int(y)) => {
412                TileData::Int(x.iter().zip(y).map(|(u, v)| u.wrapping_add(*v)).collect())
413            }
414            (TileData::Float(x), TileData::Float(y)) => {
415                TileData::Float(x.iter().zip(y).map(|(u, v)| u + v).collect())
416            }
417            (TileData::Empty, other) | (other, TileData::Empty) => other.clone(),
418            (_, y) => y.clone(),
419        })
420        .collect()
421}
422
423/// One tile as a buffer of precision `p` holds it — the rounding a load performs on the way in.
424fn narrow(tile: TileData, p: Precision) -> TileData {
425    match tile {
426        TileData::Float(v) => TileData::Float(v.into_iter().map(|x| round_to(p, x)).collect()),
427        other => other,
428    }
429}
430
431/// A value as the datapath of precision `p` stores it (bf16 / fp16 / fp32 rounding; integers untouched).
432pub fn round_to(p: Precision, x: f64) -> f64 {
433    match p {
434        Precision::Bf16 => round_bf16(x),
435        Precision::Fp16 => f16_to_f32(f32_to_f16(x as f32)) as f64,
436        Precision::Fp32 => x as f32 as f64,
437        _ => x,
438    }
439}
440
441struct Eval<'a> {
442    p: &'a SpatialProgram,
443    t: usize,
444    fifos: Vec<FifoState>,
445    tensors: Vec<TensorStore>,
446    sems: BTreeMap<(SemId, Site), usize>,
447    recv_buffer: BTreeMap<(GroupId, Site), BufferId>,
448    programs: Vec<Coroutine>,
449    /// The work items of each site within each section, which every tile address of an item loop indexes.
450    site_items: SiteItems<'a>,
451    /// Per tensor, the `WriteTiles` each section's schedule runs, and how many have run.
452    writes_expected: Vec<BTreeMap<usize, u64>>,
453    writes_done: Vec<BTreeMap<usize, u64>>,
454    steps: u64,
455    /// Token-only: pages carry no data and compute ops do no arithmetic (every protocol check still runs).
456    dry: bool,
457    /// Tiles read per `(tensor, section)`.
458    tiles_read: BTreeMap<(TensorId, usize), u64>,
459    /// Per buffer, the other buffers whose pages live in the same local memory (`Buffer::alias_of`).
460    partners: Vec<Vec<BufferId>>,
461    held: BTreeMap<BufferId, (i64, i64)>,
462    nodes: BTreeMap<StageId, Vec<Option<(i64, i64)>>>,
463    stage_held: BTreeMap<StageId, (i64, i64)>,
464}
465
466impl<'a> Eval<'a> {
467    fn new(p: &'a SpatialProgram, inputs: &Inputs) -> Result<Eval<'a>, ChoreoFault> {
468        let counts = trip_counts(p, inputs)?;
469        Self::build(p, inputs, false, &counts)
470    }
471
472    fn new_dry(p: &'a SpatialProgram, counts: &[usize]) -> Result<Eval<'a>, ChoreoFault> {
473        Self::build(p, &Inputs::default(), true, counts)
474    }
475
476    fn build(
477        p: &'a SpatialProgram,
478        inputs: &Inputs,
479        dry: bool,
480        counts: &[usize],
481    ) -> Result<Eval<'a>, ChoreoFault> {
482        let sched = p.schedule.as_ref().ok_or(ChoreoFault::NotScheduled)?;
483        let t = p.tile.rows;
484        assert_eq!(p.tile.rows, p.tile.cols, "square native tiles");
485        let mut store_sections: Vec<Vec<usize>> = vec![Vec::new(); p.tensors.len()];
486        for s in &p.stages {
487            if let StageKind::Store { tensor, .. } = &s.kind {
488                if let Some(v) = store_sections.get_mut(tensor.idx()) {
489                    v.push(p.section_of_stage(s.id).unwrap_or(0));
490                }
491            }
492        }
493        let mut tensors = Vec::new();
494        for td in &p.tensors {
495            let n_tiles = td.tiles.count();
496            // ★ A ROLE DECIDES WHETHER A RUN STARTS WITH THE DATA. An `Input`, a `Param` and the
497            // carried `State` do; an `Output` and an `Activation` do not, which is why a section that
498            // reads an activation before its producer ran faults instead of reading zeros.
499            let writer_sections = (td.role == TensorRole::State).then(|| {
500                let mut w = store_sections.get(td.id.idx()).cloned().unwrap_or_default();
501                w.sort_unstable();
502                w.dedup();
503                w
504            });
505            let mut store = TensorStore {
506                dtype: td.dtype,
507                cols_t: td.tiles.cols_t,
508                tiles: vec![None; n_tiles],
509                stored_by: vec![None; n_tiles],
510                writer_sections,
511                written_in: vec![None; n_tiles],
512                elided: false,
513            };
514            if td.role.starts_filled() && dry {
515                store.tiles = vec![Some(TileData::Empty); n_tiles];
516            } else if td.role.starts_filled() {
517                let value = match inputs.tensors.get(&td.id) {
518                    Some(v) => v.clone(),
519                    None => {
520                        let n = td.elems.rows * td.elems.cols;
521                        if td.init.is_float() {
522                            let vals: Option<Vec<f64>> =
523                                (0..n).map(|i| td.init.element_f64(i)).collect();
524                            Value::Float(vals.ok_or(ChoreoFault::MissingInput(td.id))?)
525                        } else {
526                            let vals: Option<Vec<i64>> =
527                                (0..n).map(|i| td.init.element(i)).collect();
528                            Value::Int(vals.ok_or(ChoreoFault::MissingInput(td.id))?)
529                        }
530                    }
531                };
532                let (rows, cols) = (td.elems.rows, td.elems.cols);
533                let is_int = precision_is_int(td.dtype);
534                let bits = precision_int_bits(td.dtype).unwrap_or(64);
535                for rt in 0..td.tiles.rows_t {
536                    for ct in 0..td.tiles.cols_t {
537                        let mut ints = Vec::new();
538                        let mut floats = Vec::new();
539                        for r in 0..t {
540                            for c in 0..t {
541                                let (er, ec) = (rt * t + r, ct * t + c);
542                                let inside = er < rows && ec < cols;
543                                let idx = er * cols + ec;
544                                match (&value, is_int) {
545                                    (Value::Int(v), true) => ints.push(if inside {
546                                        trunc_signed(v[idx], bits)
547                                    } else {
548                                        0
549                                    }),
550                                    (Value::Int(v), false) => floats.push(if inside {
551                                        round_to(td.dtype, v[idx] as f64)
552                                    } else {
553                                        0.0
554                                    }),
555                                    (Value::Float(v), false) => floats.push(if inside {
556                                        round_to(td.dtype, v[idx])
557                                    } else {
558                                        0.0
559                                    }),
560                                    (Value::Float(v), true) => ints.push(if inside {
561                                        trunc_signed(v[idx] as i64, bits)
562                                    } else {
563                                        0
564                                    }),
565                                }
566                            }
567                        }
568                        let tile = if is_int {
569                            TileData::Int(ints)
570                        } else {
571                            TileData::Float(floats)
572                        };
573                        store.tiles[rt * td.tiles.cols_t + ct] = Some(tile);
574                    }
575                }
576            }
577            tensors.push(store);
578        }
579        let fifos = p
580            .buffers
581            .iter()
582            .map(|b| FifoState {
583                capacity: b.kind.depth_pages(),
584                slots: vec![Vec::new(); b.kind.depth_pages().max(1)],
585                wr: 0,
586                popped: 0,
587                queue: VecDeque::new(),
588                pending: VecDeque::new(),
589                reserved: 0,
590                high_water: 0,
591                visible: 0,
592            })
593            .collect();
594        let mut recv_buffer = BTreeMap::new();
595        for s in &p.stages {
596            if let (StageKind::Recv { group }, Some(site)) = (&s.kind, s.site) {
597                if let Some(out) = s.outs.first() {
598                    recv_buffer.insert((*group, site), *out);
599                }
600            }
601        }
602        let mut programs = Vec::new();
603        let mut writes_expected = vec![BTreeMap::new(); p.tensors.len()];
604        let mut elided = vec![false; p.tensors.len()];
605        for prog in &sched.programs {
606            let mut code = Vec::new();
607            flatten(
608                &prog.ops,
609                &mut code,
610                counts,
611                if dry {
612                    Some(crate::spatial::certificate::item_window(p))
613                } else {
614                    None
615                },
616                &mut elided,
617            );
618            count_writes(p, &code, &mut writes_expected);
619            programs.push(Coroutine {
620                site: prog.site,
621                engine: prog.engine,
622                code: std::rc::Rc::new(code),
623                pc: 0,
624                loops: Vec::new(),
625                env: BTreeMap::new(),
626                acc: None,
627                acc_computed: false,
628                acc_folded: Default::default(),
629                done: false,
630            });
631        }
632        for (store, cut) in tensors.iter_mut().zip(elided) {
633            store.elided = cut;
634        }
635        let writes_done = vec![BTreeMap::new(); p.tensors.len()];
636        let mut regions: BTreeMap<BufferId, Vec<BufferId>> = BTreeMap::new();
637        for b in &p.buffers {
638            if let Some(owner) = b.alias_of {
639                regions
640                    .entry(owner)
641                    .or_insert_with(|| vec![owner])
642                    .push(b.id);
643            }
644        }
645        let mut partners = vec![Vec::new(); p.buffers.len()];
646        for sharing in regions.values() {
647            for &b in sharing {
648                partners[b.idx()] = sharing.iter().copied().filter(|o| *o != b).collect();
649            }
650        }
651        Ok(Eval {
652            p,
653            t,
654            fifos,
655            tensors,
656            sems: BTreeMap::new(),
657            recv_buffer,
658            programs,
659            site_items: p.site_items(),
660            writes_expected,
661            writes_done,
662            steps: 0,
663            dry,
664            tiles_read: BTreeMap::new(),
665            partners,
666            held: BTreeMap::new(),
667            nodes: BTreeMap::new(),
668            stage_held: BTreeMap::new(),
669        })
670    }
671
672    fn item_coord(
673        &self,
674        site: Site,
675        env: &BTreeMap<LoopVar, usize>,
676        stage: StageId,
677    ) -> (usize, usize) {
678        let items = self.site_items.at(self.p.section_of_stage(stage), site);
679        let idx = env.get(&LV_ITEM).copied().unwrap_or(0);
680        match items.get(idx) {
681            Some(id) => self.p.work.items[id.idx()].coord,
682            None => (0, 0),
683        }
684    }
685
686    /// Resolve a tile address under the program's loop environment plus the current item's coordinates,
687    /// without materializing an extended environment. An `IndirectTile` adds `(v div elems_per_tile) ·
688    /// tile_stride` for the index `v` at the front of its buffer — see [`Self::indirect_offset`].
689    fn resolve_addr(
690        &self,
691        site: Site,
692        env: &BTreeMap<LoopVar, usize>,
693        addr: &TileAddr,
694        stage: StageId,
695        at: (Site, Engine, usize),
696    ) -> Result<usize, ChoreoFault> {
697        let (oy, ox) = self.item_coord(site, env, stage);
698        let mut a = addr.base;
699        for (v, stride) in &addr.steps {
700            let idx = if *v == LV_ITEM_Y {
701                oy
702            } else if *v == LV_ITEM_X {
703                ox
704            } else {
705                env.get(v).copied().unwrap_or(0)
706            };
707            a += idx * stride;
708        }
709        if let Some(ind) = &addr.indirect {
710            a += self.indirect_offset(ind, at)?;
711        }
712        Ok(a)
713    }
714
715    /// The tile offset an `IndirectTile` contributes: `(v div elems_per_tile) · tile_stride`, `v` the integer at
716    /// element 0 of the index buffer's front page (which the schedule waited on).
717    ///
718    /// ★ THE INDEX IS CHECKED, NEVER TRUSTED. A `v` outside `bound`, or not a whole number, is
719    /// `TileGatherOutOfBound` — never a wrapped or clamped row. The token-only dry run has no index to read, so
720    /// it takes the LOW end of the bound here and [`Self::check_indirect_span`] walks the high end: a
721    /// certificate is issued only when every index the bound admits addresses tiles the tensor has.
722    fn indirect_offset(
723        &self,
724        ind: &IndirectTile,
725        at: (Site, Engine, usize),
726    ) -> Result<usize, ChoreoFault> {
727        let v = if self.dry {
728            ind.bound.0 as f64
729        } else {
730            let page = self.front_page(ind.index, at)?;
731            match page.first() {
732                Some(TileData::Int(v)) => v.first().copied().unwrap_or(0) as f64,
733                Some(TileData::Float(v)) => v.first().copied().unwrap_or(0.0),
734                _ => {
735                    return Err(ChoreoFault::Unsupported(
736                        "an indirect tile index page holds no data".into(),
737                    ))
738                }
739            }
740        };
741        if v.fract() != 0.0 || v < ind.bound.0 as f64 || v > ind.bound.1 as f64 {
742            return Err(ChoreoFault::TileGatherOutOfBound {
743                at,
744                index: v,
745                bound: ind.bound,
746            });
747        }
748        Ok((v as usize / ind.elems_per_tile) * ind.tile_stride)
749    }
750
751    /// The dry run's other half of an indirect address: the block at the HIGH end of the bound must lie inside
752    /// the tensor too. `base` is the address resolved at the low end.
753    fn check_indirect_span(
754        &self,
755        addr: &TileAddr,
756        base: usize,
757        block: (usize, usize, usize, usize),
758        tensor: TensorId,
759    ) -> Result<(), ChoreoFault> {
760        let Some(ind) = &addr.indirect else {
761            return Ok(());
762        };
763        let (block_h, block_w, stride_h, stride_w) = (block.0, block.1, block.2, block.3);
764        let low = (ind.bound.0 / ind.elems_per_tile) * ind.tile_stride;
765        let high = (ind.bound.1 / ind.elems_per_tile) * ind.tile_stride;
766        let top = base - low + high;
767        let last =
768            top + block_h.saturating_sub(1) * stride_h + block_w.saturating_sub(1) * stride_w;
769        let tiles = self.tensors[tensor.idx()].tiles.len();
770        if last >= tiles {
771            return Err(ChoreoFault::ReadOutOfRange { tensor, tile: last });
772        }
773        Ok(())
774    }
775
776    /// The buffer whose local memory `buf`'s pages live in: its own, or the one it aliases.
777    fn storage(&self, buf: BufferId) -> BufferId {
778        self.p.buffer(buf).alias_of.unwrap_or(buf)
779    }
780
781    /// A page written into `slot` of `buf`'s memory must not overwrite a page an alias of that memory still holds:
782    /// one it pushed and its consumer has not yet popped.
783    fn check_alias_clobber(
784        &self,
785        buf: BufferId,
786        slot: usize,
787        at: (Site, Engine, usize),
788    ) -> Result<(), ChoreoFault> {
789        let len = self.fifos[self.storage(buf).idx()].slots.len().max(1);
790        for &alias in &self.partners[buf.idx()] {
791            let f = &self.fifos[alias.idx()];
792            if (f.popped..f.wr).any(|n| n % len == slot) {
793                return Err(ChoreoFault::AliasClobber { buf, alias, at });
794            }
795        }
796        Ok(())
797    }
798
799    fn touch(&mut self, buf: BufferId) {
800        let f = &mut self.fifos[buf.idx()];
801        f.high_water = f.high_water.max(f.resident());
802        // Every page enters a buffer as a pending page, each followed by this call.
803        let mut held = self.held.get(&buf).copied();
804        for tile in f.pending.iter().flatten() {
805            if let TileData::Int(v) = tile {
806                for x in v {
807                    held = Some(widen(held, *x));
808                }
809            }
810        }
811        if let Some(h) = held {
812            self.held.insert(buf, h);
813        }
814    }
815
816    /// Notes every integer the program's accumulator holds against `stage`.
817    fn note_acc(&mut self, pi: usize, stage: StageId) {
818        let Some(acc) = self.programs[pi].acc.as_ref() else { return };
819        let mut held = self.stage_held.get(&stage).copied();
820        for a in acc {
821            if let AccData::Int(v) = a {
822                for x in v {
823                    held = Some(widen(held, *x));
824                }
825            }
826        }
827        if let Some(h) = held {
828            self.stage_held.insert(stage, h);
829        }
830    }
831
832    /// Try to execute one op; `Ok(true)` stepped, `Ok(false)` blocked.
833    fn step(&mut self, pi: usize) -> Result<bool, ChoreoFault> {
834        let (site, engine, pc) = {
835            let c = &self.programs[pi];
836            if c.done {
837                return Ok(false);
838            }
839            (c.site, c.engine, c.pc)
840        };
841        let at = (site, engine, pc);
842        let code = std::rc::Rc::clone(&self.programs[pi].code);
843        let Some(instr) = code.get(pc) else {
844            self.programs[pi].done = true;
845            return Ok(true);
846        };
847        match instr {
848            Instr::LoopStart { var, count, end } => {
849                let (var, count, end) = (*var, *count, *end);
850                let c = &mut self.programs[pi];
851                if count == 0 {
852                    c.pc = end + 1;
853                } else {
854                    c.loops.push((var, 0, count));
855                    c.env.insert(var, 0);
856                    c.pc += 1;
857                }
858                Ok(true)
859            }
860            Instr::LoopEnd { start } => {
861                let start = *start;
862                let c = &mut self.programs[pi];
863                let (var, iter, count) = c.loops.pop().expect("loop frame");
864                if iter + 1 < count {
865                    c.loops.push((var, iter + 1, count));
866                    c.env.insert(var, iter + 1);
867                    c.pc = start + 1;
868                } else {
869                    c.env.remove(&var);
870                    c.pc += 1;
871                }
872                Ok(true)
873            }
874            Instr::Op(op, stage) => {
875                let stage = *stage;
876                let stepped = self.exec(pi, site, engine, at, &op, stage)?;
877                if stepped {
878                    self.programs[pi].pc += 1;
879                }
880                Ok(stepped)
881            }
882        }
883    }
884
885    fn exec(
886        &mut self,
887        pi: usize,
888        site: Site,
889        engine: Engine,
890        at: (Site, Engine, usize),
891        op: &ChoreoOp,
892        stage: StageId,
893    ) -> Result<bool, ChoreoFault> {
894        match op {
895            ChoreoOp::Reserve { buf, pages } => {
896                let f = &mut self.fifos[buf.idx()];
897                if f.queue.len() + f.reserved + *pages > f.capacity {
898                    return Ok(false);
899                }
900                f.reserved += *pages;
901                Ok(true)
902            }
903            ChoreoOp::Push { buf, pages } => {
904                {
905                    let f = &self.fifos[buf.idx()];
906                    if f.reserved < *pages {
907                        return Err(ChoreoFault::BufferOverflow { buf: *buf, at });
908                    }
909                    if f.pending.len() < *pages {
910                        return Err(ChoreoFault::PushWithoutData { buf: *buf, at });
911                    }
912                }
913                let owner = self.storage(*buf);
914                for _ in 0..*pages {
915                    let slot =
916                        self.fifos[buf.idx()].wr % self.fifos[owner.idx()].slots.len().max(1);
917                    self.check_alias_clobber(*buf, slot, at)?;
918                    let f = &mut self.fifos[buf.idx()];
919                    let page = f.pending.pop_front().unwrap();
920                    f.wr = f.wr.wrapping_add(1);
921                    f.queue.push_back(page.clone());
922                    let o = &mut self.fifos[owner.idx()];
923                    if !o.slots.is_empty() {
924                        o.slots[slot] = page;
925                    }
926                }
927                self.fifos[buf.idx()].reserved -= *pages;
928                self.touch(*buf);
929                Ok(true)
930            }
931            ChoreoOp::Wait { buf, pages } => {
932                let f = &mut self.fifos[buf.idx()];
933                if f.queue.len() < *pages {
934                    return Ok(false);
935                }
936                f.visible = f.visible.max(*pages);
937                Ok(true)
938            }
939            ChoreoOp::Pop { buf, pages } => {
940                let f = &mut self.fifos[buf.idx()];
941                if f.queue.len() < *pages {
942                    return Err(ChoreoFault::Underflow { buf: *buf, at });
943                }
944                if f.visible < *pages {
945                    return Err(ChoreoFault::PopBeforeWait { buf: *buf, at });
946                }
947                for _ in 0..*pages {
948                    f.queue.pop_front();
949                }
950                f.visible -= *pages;
951                f.popped += *pages;
952                Ok(true)
953            }
954            ChoreoOp::ReadTiles {
955                tensor,
956                at: addr,
957                block_h,
958                block_w,
959                stride_w,
960                stride_h,
961                into,
962            } => {
963                let base = self.resolve_addr(site, &self.programs[pi].env, addr, stage, at)?;
964                if self.dry {
965                    self.check_indirect_span(
966                        addr,
967                        base,
968                        (*block_h, *block_w, *stride_h, *stride_w),
969                        *tensor,
970                    )?;
971                }
972                let read_in = self.p.section_of_stage(stage).unwrap_or(0);
973                *self.tiles_read.entry((*tensor, read_in)).or_default() +=
974                    (*block_h * *block_w) as u64;
975                let dst_dtype = self.p.buffer(*into).dtype;
976                let store = &self.tensors[tensor.idx()];
977                let mut page = Vec::with_capacity(block_h * block_w);
978                for h in 0..*block_h {
979                    for w in 0..*block_w {
980                        let idx = base + h * stride_h + w * stride_w;
981                        // ★ A CARRIED TILE IS READ IN STEP OR NOT AT ALL. The writer's section is a
982                        // fact about the PROGRAM; whether the tile is written yet is a fact about this
983                        // RUN. They must agree, and when a barrier is missing they do not — which is
984                        // the only way a state tensor can announce that it was read out of order,
985                        // because unlike an activation it always holds a plausible number.
986                        //
987                        // ★ AND A CARRY MAY BE UPDATED IN PART. A row appended to a cache writes that row's tiles
988                        // and no others, so a later section reading an untouched tile reads this step's value —
989                        // PROVIDED every write the schedule holds for the tensor has run: then "not written" means
990                        // "not written this step", while before the last write it means a read racing the writer.
991                        //
992                        // ★ AND BY MORE THAN ONE SECTION. A table written row by row has a writer per row, so the
993                        // rule is per writer: no section at or after the reader has written the tile yet, and every
994                        // writer before the reader has either written it or run all of its writes.
995                        if let Some(writers) = &store.writer_sections {
996                            let last = store.written_in.get(idx).copied().flatten();
997                            let done = |w: usize| {
998                                let t = tensor.idx();
999                                self.writes_done[t].get(&w).copied().unwrap_or(0)
1000                                    >= self.writes_expected[t].get(&w).copied().unwrap_or(0)
1001                            };
1002                            let late = last.filter(|l| *l >= read_in);
1003                            let early = writers
1004                                .iter()
1005                                .copied()
1006                                .find(|w| *w < read_in && !done(*w) && last.is_none_or(|l| l < *w));
1007                            if let Some(w_sec) = late.or(early) {
1008                                return Err(ChoreoFault::StaleStateRead {
1009                                    tensor: *tensor,
1010                                    tile: idx,
1011                                    read_in,
1012                                    written_in: w_sec,
1013                                    already_written: last.is_some(),
1014                                });
1015                            }
1016                        }
1017                        // An unwritten tile is a read before its writer — unless the dry run's window elided
1018                        // writes to this tensor, when it may be a tile the elided rounds write. Whether a later
1019                        // section reads after an earlier one wrote is the ordering the IR proves, not the window.
1020                        let tile = match store.tiles.get(idx) {
1021                            Some(Some(tile)) => tile.clone(),
1022                            Some(None) if store.elided => TileData::Empty,
1023                            _ => {
1024                                return Err(ChoreoFault::ReadOutOfRange {
1025                                    tensor: *tensor,
1026                                    tile: idx,
1027                                })
1028                            }
1029                        };
1030                        // ★ A LOAD NARROWS TO THE BUFFER IT LANDS IN. Memory holds what the producer
1031                        // wrote; the operand register holds what its own width can represent. A
1032                        // composed program stores an fp32 matmul result and reads it into a bf16
1033                        // datapath one section later, and modelling that copy as lossless would make
1034                        // the oracle disagree with the part in the one place it is supposed to be
1035                        // exact about. No single-op program changes: every one of them declares its
1036                        // buffers at the tensor's own precision.
1037                        page.push(if self.dry {
1038                            TileData::Empty
1039                        } else {
1040                            narrow(tile, dst_dtype)
1041                        });
1042                    }
1043                }
1044                let f = &mut self.fifos[into.idx()];
1045                if f.pending.len() >= f.reserved {
1046                    return Err(ChoreoFault::BufferOverflow { buf: *into, at });
1047                }
1048                f.pending.push_back(page);
1049                self.touch(*into);
1050                Ok(true)
1051            }
1052            ChoreoOp::ReadBarrier | ChoreoOp::WriteBarrier | ChoreoOp::Flush { .. } => Ok(true),
1053            ChoreoOp::WriteTiles {
1054                from,
1055                tensor,
1056                at: addr,
1057                block_h,
1058                block_w,
1059                stride_w,
1060                stride_h,
1061            } => {
1062                let base = self.resolve_addr(site, &self.programs[pi].env, addr, stage, at)?;
1063                if self.dry {
1064                    self.check_indirect_span(
1065                        addr,
1066                        base,
1067                        (*block_h, *block_w, *stride_h, *stride_w),
1068                        *tensor,
1069                    )?;
1070                }
1071                let page = self.front_page(*from, at)?;
1072                if page.len() != block_h * block_w {
1073                    return Err(ChoreoFault::Unsupported(format!(
1074                        "write page of {} tiles for a {block_h}x{block_w} block",
1075                        page.len()
1076                    )));
1077                }
1078                let section = self.p.section_of_stage(stage).unwrap_or(0);
1079                let store = &mut self.tensors[tensor.idx()];
1080                for h in 0..*block_h {
1081                    for w in 0..*block_w {
1082                        let idx = base + h * stride_h + w * stride_w;
1083                        if idx >= store.tiles.len() {
1084                            return Err(ChoreoFault::ReadOutOfRange {
1085                                tensor: *tensor,
1086                                tile: idx,
1087                            });
1088                        }
1089                        // ★ A SECOND STORE OF A TILE IS A RACE — within one section. A carried tensor's later
1090                        // section rewriting a tile an earlier one wrote is the next update, ordered by the barrier
1091                        // between them (the certificate's `StateOrder`); anything else storing a tile twice is two
1092                        // writers of one value.
1093                        if let Some((first, first_section)) = store.stored_by[idx] {
1094                            if store.writer_sections.is_none() || first_section == section {
1095                                return Err(ChoreoFault::StoreConflict {
1096                                    tensor: *tensor,
1097                                    tile: idx,
1098                                    first,
1099                                    second: site,
1100                                });
1101                            }
1102                        }
1103                        store.tiles[idx] = Some(page[h * block_w + w].clone());
1104                        store.stored_by[idx] = Some((site, section));
1105                        store.written_in[idx] = Some(section);
1106                    }
1107                }
1108                *self.writes_done[tensor.idx()].entry(section).or_default() += 1;
1109                Ok(true)
1110            }
1111            ChoreoOp::McastSend {
1112                buf, pages, group, ..
1113            } => {
1114                let pl = self.p.placement.as_ref().expect("placed");
1115                let g = &pl.groups[group.idx()];
1116                let sender_pending: Vec<Page> = {
1117                    let f = &self.fifos[buf.idx()];
1118                    if f.pending.len() < *pages {
1119                        return Err(ChoreoFault::PushWithoutData { buf: *buf, at });
1120                    }
1121                    f.pending.iter().rev().take(*pages).rev().cloned().collect()
1122                };
1123                let receivers: Vec<Site> = g
1124                    .rect
1125                    .sites()
1126                    .into_iter()
1127                    .filter(|s| *s != g.sender)
1128                    .collect();
1129                for r in &receivers {
1130                    let rb = *self.recv_buffer.get(&(*group, *r)).ok_or(
1131                        ChoreoFault::McastIntoUnreserved {
1132                            group: *group,
1133                            receiver: *r,
1134                        },
1135                    )?;
1136                    let f = &self.fifos[rb.idx()];
1137                    if f.pending.len() + pages > f.reserved {
1138                        return Err(ChoreoFault::McastIntoUnreserved {
1139                            group: *group,
1140                            receiver: *r,
1141                        });
1142                    }
1143                }
1144                for r in &receivers {
1145                    let rb = self.recv_buffer[&(*group, *r)];
1146                    let f = &mut self.fifos[rb.idx()];
1147                    for page in &sender_pending {
1148                        f.pending.push_back(page.clone());
1149                    }
1150                    self.touch(rb);
1151                }
1152                Ok(true)
1153            }
1154            ChoreoOp::McastSetValid { sem, group } => {
1155                let pl = self.p.placement.as_ref().expect("placed");
1156                let g = &pl.groups[group.idx()];
1157                for r in g.rect.sites().into_iter().filter(|s| *s != g.sender) {
1158                    self.sems
1159                        .insert((*sem, r), SemValue::Valid.as_u32() as usize);
1160                }
1161                Ok(true)
1162            }
1163            ChoreoOp::SemSet { sem, value } => {
1164                self.sems.insert((*sem, site), value.as_u32() as usize);
1165                Ok(true)
1166            }
1167            ChoreoOp::SemInc {
1168                sem,
1169                at: target,
1170                by,
1171            } => {
1172                *self.sems.entry((*sem, *target)).or_insert(0) += by;
1173                Ok(true)
1174            }
1175            ChoreoOp::SemWait { sem, until } => {
1176                let v = self.sems.get(&(*sem, site)).copied().unwrap_or(0);
1177                Ok(match until {
1178                    SemCond::Equals(n) => v == *n,
1179                    SemCond::AtLeast(n) => v >= *n,
1180                })
1181            }
1182            ChoreoOp::AcquireAcc { tiles } => {
1183                if *tiles > self.p.acc_tiles_used.max(1) {
1184                    return Err(ChoreoFault::AccOverflow {
1185                        requested: *tiles,
1186                        available: self.p.acc_tiles_used,
1187                    });
1188                }
1189                let c = &mut self.programs[pi];
1190                if c.acc.is_some() {
1191                    return Err(ChoreoFault::AccProtocol { at });
1192                }
1193                let n = self.t * self.t;
1194                // The accumulator is the one this op's SECTION was lowered for; a composed program's own plan
1195                // is only its last section's.
1196                let acc_float = self
1197                    .p
1198                    .plan_of_stage(stage)
1199                    .map(|pl| pl.acc == AccPolicy::F32)
1200                    .unwrap_or(false);
1201                c.acc = Some(
1202                    (0..*tiles)
1203                        .map(|_| {
1204                            if acc_float {
1205                                AccData::F32(vec![0.0; n])
1206                            } else {
1207                                AccData::Int(vec![0; n])
1208                            }
1209                        })
1210                        .collect(),
1211                );
1212                c.acc_computed = false;
1213                c.acc_folded.clear();
1214                Ok(true)
1215            }
1216            ChoreoOp::ReleaseAcc => {
1217                let c = &mut self.programs[pi];
1218                if c.acc.is_none() {
1219                    return Err(ChoreoFault::AccProtocol { at });
1220                }
1221                c.acc = None;
1222                Ok(true)
1223            }
1224            ChoreoOp::Compute {
1225                op,
1226                ins,
1227                acc_reload,
1228            } => {
1229                let stepped = self.compute(pi, site, engine, at, stage, op, ins, *acc_reload)?;
1230                if stepped && !self.dry {
1231                    self.note_acc(pi, stage);
1232                }
1233                Ok(stepped)
1234            }
1235            ChoreoOp::PackAcc {
1236                to,
1237                tiles,
1238                convert,
1239                accumulate,
1240            } => {
1241                let acc = match self.programs[pi].acc.as_ref() {
1242                    Some(a) => a.clone(),
1243                    None => return Err(ChoreoFault::AccProtocol { at }),
1244                };
1245                if !self.programs[pi].acc_computed {
1246                    return Err(ChoreoFault::AccProtocol { at });
1247                }
1248                if acc.len() < *tiles {
1249                    return Err(ChoreoFault::AccOverflow {
1250                        requested: *tiles,
1251                        available: acc.len(),
1252                    });
1253                }
1254                let plan_acc = self
1255                    .p
1256                    .plan_of_stage(stage)
1257                    .map(|pl| pl.acc)
1258                    .unwrap_or(AccPolicy::I64);
1259                let page: Page = if self.dry {
1260                    vec![TileData::Empty; *tiles]
1261                } else {
1262                    acc.iter()
1263                        .take(*tiles)
1264                        .map(|a| match a {
1265                            AccData::Int(v) => {
1266                                let vals: Vec<i64> = v
1267                                    .iter()
1268                                    .map(|x| {
1269                                        if plan_acc == AccPolicy::I32 {
1270                                            trunc_signed(*x, 32)
1271                                        } else {
1272                                            *x
1273                                        }
1274                                    })
1275                                    .collect();
1276                                if precision_is_int(*convert) {
1277                                    TileData::Int(vals)
1278                                } else {
1279                                    TileData::Float(
1280                                        vals.iter()
1281                                            .map(|x| round_to(*convert, *x as f64))
1282                                            .collect(),
1283                                    )
1284                                }
1285                            }
1286                            AccData::F32(v) => {
1287                                if precision_is_int(*convert) {
1288                                    TileData::Int(v.iter().map(|x| *x as i64).collect())
1289                                } else {
1290                                    TileData::Float(
1291                                        v.iter().map(|x| round_to(*convert, *x as f64)).collect(),
1292                                    )
1293                                }
1294                            }
1295                        })
1296                        .collect()
1297                };
1298                let f = &self.fifos[to.idx()];
1299                if f.pending.len() >= f.reserved {
1300                    return Err(ChoreoFault::BufferOverflow { buf: *to, at });
1301                }
1302                let page = if *accumulate && !self.dry {
1303                    // The page accumulated into is the one at the slot about to be filled, in whichever buffer's
1304                    // memory this one's pages live.
1305                    let store = &self.fifos[self.storage(*to).idx()].slots;
1306                    let slot = (f.wr + f.pending.len()) % store.len().max(1);
1307                    let base = store.get(slot).cloned().unwrap_or_default();
1308                    add_pages(&base, &page)
1309                } else {
1310                    page
1311                };
1312                self.fifos[to.idx()].pending.push_back(page);
1313                self.touch(*to);
1314                Ok(true)
1315            }
1316            ChoreoOp::Barrier { .. } => Err(ChoreoFault::Unsupported(
1317                "barrier groups are not lowered by any pass yet".into(),
1318            )),
1319            ChoreoOp::Loop { .. } => unreachable!("loops are flattened"),
1320        }
1321    }
1322
1323    fn front_page(&self, buf: BufferId, at: (Site, Engine, usize)) -> Result<Page, ChoreoFault> {
1324        let f = &self.fifos[buf.idx()];
1325        if f.visible == 0 {
1326            return Err(if f.queue.is_empty() {
1327                ChoreoFault::Underflow { buf, at }
1328            } else {
1329                ChoreoFault::ReadBeforeWait { buf, at }
1330            });
1331        }
1332        f.queue
1333            .front()
1334            .cloned()
1335            .ok_or(ChoreoFault::Underflow { buf, at })
1336    }
1337
1338    #[allow(clippy::too_many_arguments)]
1339    fn compute(
1340        &mut self,
1341        pi: usize,
1342        site: Site,
1343        _engine: Engine,
1344        at: (Site, Engine, usize),
1345        stage: StageId,
1346        op: &ComputeOp,
1347        ins: &[BufferId],
1348        acc_reload: Option<BufferId>,
1349    ) -> Result<bool, ChoreoFault> {
1350        let t = self.t;
1351        let n = t * t;
1352        if self.programs[pi].acc.is_none() {
1353            return Err(ChoreoFault::AccProtocol { at });
1354        }
1355        // ★ THE GUARD IS AGAINST FOLDING A PAGE TWICE, NOT AGAINST USING AN OPERAND TWICE. It exists
1356        // so two separate compute ops cannot accumulate the same page of the same buffer into the
1357        // register file — the double-count a K loop makes easy. An op that names ONE buffer on both
1358        // of its ports (`x · x`, the square a sum-of-squares starts with) reads a single page once
1359        // and is not that mistake, so the fold is keyed per distinct operand within an op.
1360        let mut folded: Vec<_> = Vec::new();
1361        for b in ins {
1362            let key = (b.idx(), self.fifos[b.idx()].popped);
1363            if folded.contains(&key) {
1364                continue;
1365            }
1366            folded.push(key);
1367            if !self.programs[pi].acc_folded.insert(key) {
1368                return Err(ChoreoFault::AccProtocol { at });
1369            }
1370        }
1371        self.programs[pi].acc_computed = true;
1372        // ★ A GATHER IS REFUSED BEFORE THE DRY RUN DECLINES TO LOOK. The token-only run skips the
1373        // arithmetic, which is exactly where an indirect map would otherwise slip past — and a program
1374        // the certificate blesses must be one the data run can evaluate. So a map carrying an
1375        // `IndirectTerm` is refused here, in both modes, naming the phase that gives it meaning.
1376        // A GATHER (an indirect term on an OPERAND map) is evaluated below; a SCATTER (one on the output
1377        // map) is not — nothing lowers one — and it is refused in both modes, naming the phase it waits on.
1378        if let ComputeOp::Generic { out, .. } = op {
1379            if out.indirect.is_some() {
1380                return Err(ChoreoFault::Unsupported(INDIRECT_MAP_DEFERRED.into()));
1381            }
1382        }
1383        if self.dry {
1384            // Token-only: the protocol (accumulator held, operand pages present, reload page present) is checked;
1385            // the arithmetic is not performed.
1386            for b in ins {
1387                self.front_page(*b, at)?;
1388            }
1389            if let Some(interm) = acc_reload {
1390                let page = self.front_page(interm, at)?;
1391                let acc = self.programs[pi].acc.as_ref().unwrap();
1392                if page.len() < acc.len() {
1393                    return Err(ChoreoFault::Unsupported(format!(
1394                        "reload page of {} tiles for {} accumulators",
1395                        page.len(),
1396                        acc.len()
1397                    )));
1398                }
1399            }
1400            return Ok(true);
1401        }
1402        if let Some(interm) = acc_reload {
1403            let page = self.front_page(interm, at)?;
1404            let acc = self.programs[pi].acc.as_mut().unwrap();
1405            for (a, tile) in acc.iter_mut().zip(page.iter()) {
1406                *a = match (tile, &*a) {
1407                    (TileData::Int(v), AccData::Int(_)) => AccData::Int(v.clone()),
1408                    (TileData::Float(v), AccData::F32(_)) => {
1409                        AccData::F32(v.iter().map(|x| *x as f32).collect())
1410                    }
1411                    (TileData::Int(v), AccData::F32(_)) => {
1412                        AccData::F32(v.iter().map(|x| *x as f32).collect())
1413                    }
1414                    (TileData::Float(v), AccData::Int(_)) => {
1415                        AccData::Int(v.iter().map(|x| *x as i64).collect())
1416                    }
1417                    (TileData::Empty, a) => a.clone(),
1418                };
1419            }
1420        }
1421        match op {
1422            ComputeOp::MatmulTiles {
1423                block,
1424                sub_blocks,
1425                operand,
1426                acc,
1427                ..
1428            } => {
1429                let a_page = self.front_page(ins[0], at)?;
1430                let b_page = self.front_page(ins[1], at)?;
1431                let env = &self.programs[pi].env;
1432                let sh = env.get(&LV_SUB_H).copied().unwrap_or(0);
1433                let sw = env.get(&LV_SUB_W).copied().unwrap_or(0);
1434                let (osh, osw, ibw) = (block.osh, block.osw, block.ibw);
1435                let per_site_n = sub_blocks.1 * osw;
1436                let bits = precision_int_bits(*operand).unwrap_or(64);
1437                let accs = self.programs[pi].acc.as_mut().unwrap();
1438                if accs.len() < osh * osw {
1439                    return Err(ChoreoFault::AccOverflow {
1440                        requested: osh * osw,
1441                        available: accs.len(),
1442                    });
1443                }
1444                for i in 0..osh {
1445                    for j in 0..osw {
1446                        for kk in 0..ibw {
1447                            let dst = &mut accs[i * osw + j];
1448                            let a_tile = a_page
1449                                .get((sh * osh + i) * ibw + kk)
1450                                .ok_or(ChoreoFault::Unsupported("A page shape".into()))?;
1451                            let b_tile = b_page
1452                                .get(kk * per_site_n + (sw * osw + j))
1453                                .ok_or(ChoreoFault::Unsupported("B page shape".into()))?;
1454                            match (dst, a_tile, b_tile) {
1455                                (AccData::Int(d), TileData::Int(a), TileData::Int(b)) => {
1456                                    for r in 0..t {
1457                                        for c in 0..t {
1458                                            let mut s = d[r * t + c];
1459                                            for x in 0..t {
1460                                                s = s.wrapping_add(
1461                                                    trunc_signed(a[r * t + x], bits).wrapping_mul(
1462                                                        trunc_signed(b[x * t + c], bits),
1463                                                    ),
1464                                                );
1465                                            }
1466                                            d[r * t + c] = s;
1467                                        }
1468                                    }
1469                                }
1470                                (AccData::F32(d), TileData::Float(a), TileData::Float(b)) => {
1471                                    for r in 0..t {
1472                                        for c in 0..t {
1473                                            let mut s = d[r * t + c];
1474                                            for x in 0..t {
1475                                                s += a[r * t + x] as f32 * b[x * t + c] as f32;
1476                                            }
1477                                            d[r * t + c] = s;
1478                                        }
1479                                    }
1480                                }
1481                                _ => {
1482                                    return Err(ChoreoFault::Unsupported(format!(
1483                                        "operand/accumulator mix for {acc:?}"
1484                                    )))
1485                                }
1486                            }
1487                        }
1488                    }
1489                }
1490                Ok(true)
1491            }
1492            ComputeOp::Requant { m, s } => {
1493                let page = self.front_page(ins[0], at)?;
1494                let accs = self.programs[pi].acc.as_mut().unwrap();
1495                for (k, dst) in accs.iter_mut().enumerate() {
1496                    let Some(TileData::Int(v)) = page.get(k) else {
1497                        return Err(ChoreoFault::Unsupported(
1498                            "requant needs integer tiles".into(),
1499                        ));
1500                    };
1501                    *dst = AccData::Int(v.iter().map(|x| m.wrapping_mul(*x) >> s).collect());
1502                }
1503                Ok(true)
1504            }
1505            ComputeOp::Bias { .. } => {
1506                let page = self.front_page(ins[0], at)?;
1507                let bias = self.front_page(ins[1], at)?;
1508                let accs = self.programs[pi].acc.as_mut().unwrap();
1509                for (k, dst) in accs.iter_mut().enumerate() {
1510                    let (Some(TileData::Int(v)), Some(TileData::Int(b))) =
1511                        (page.get(k), bias.get(k % bias.len().max(1)))
1512                    else {
1513                        return Err(ChoreoFault::Unsupported("bias needs integer tiles".into()));
1514                    };
1515                    let mut out = vec![0i64; n];
1516                    for r in 0..t {
1517                        for c in 0..t {
1518                            out[r * t + c] = v[r * t + c].wrapping_add(b[c]);
1519                        }
1520                    }
1521                    *dst = AccData::Int(out);
1522                }
1523                Ok(true)
1524            }
1525            ComputeOp::Relu => {
1526                let page = self.front_page(ins[0], at)?;
1527                let accs = self.programs[pi].acc.as_mut().unwrap();
1528                for (k, dst) in accs.iter_mut().enumerate() {
1529                    match page.get(k) {
1530                        Some(TileData::Int(v)) => {
1531                            *dst = AccData::Int(v.iter().map(|x| (*x).max(0)).collect())
1532                        }
1533                        Some(TileData::Float(v)) => {
1534                            *dst = AccData::F32(v.iter().map(|x| x.max(0.0) as f32).collect())
1535                        }
1536                        Some(TileData::Empty) | None => {
1537                            return Err(ChoreoFault::Unsupported("relu page shape".into()))
1538                        }
1539                    }
1540                }
1541                Ok(true)
1542            }
1543            // ── THE GENERAL FORM ───────────────────────────────────────────────────────────────
1544            //
1545            // ★ THIS ARM IS THE CONTRACT. Every legalization the compiler performs must agree with
1546            // what is written here, and `spatial_rewrite_lock` is what checks that it does. The
1547            // arithmetic is deliberately plain: walk the iteration space, read each operand through
1548            // its own access map, apply the body, fold the reduce axes.
1549            //
1550            // The two facts the algebra rests on are visible in `coord` alone: an operand whose map
1551            // omits an axis reads coordinate 0 along it — which IS a broadcast — and an axis marked
1552            // `Reduce` never reaches the output's map, which IS a reduction.
1553            ComputeOp::Generic {
1554                iters,
1555                ins: maps,
1556                out,
1557                body,
1558            } => {
1559                let (row_ax, col_ax, k_ax) = match iters.as_slice() {
1560                    [r, c] => (*r, *c, None),
1561                    [r, c, k] => (*r, *c, Some(*k)),
1562                    _ => return Err(ChoreoFault::Unsupported("a Generic iteration space that is not (rows, cols) or (rows, cols, contraction)".into())),
1563                };
1564                let k_var = k_ax
1565                    .map(|k| k.var)
1566                    .unwrap_or(crate::spatial::passes::LV_GEN_K);
1567                if maps.len() != ins.len() {
1568                    return Err(ChoreoFault::Unsupported(
1569                        "a Generic whose access maps do not match its operands".into(),
1570                    ));
1571                }
1572                for it in [row_ax, col_ax] {
1573                    if matches!(it.kind, IterKind::Reduce { .. }) && out.varies_along(it.var) {
1574                        return Err(ChoreoFault::Unsupported(
1575                            "a Generic whose output varies along a reduced axis".into(),
1576                        ));
1577                    }
1578                }
1579                if body.max_operand().is_some_and(|i| i >= ins.len()) {
1580                    return Err(ChoreoFault::Unsupported(
1581                        "a Generic body naming an absent operand".into(),
1582                    ));
1583                }
1584                let pages: Vec<Page> = ins
1585                    .iter()
1586                    .map(|b| self.front_page(*b, at))
1587                    .collect::<Result<_, _>>()?;
1588                // ★ THE PAGE IS A FLAT ROW-MAJOR PLANE — `t` rows by `page_tiles · t` columns — and a
1589                // map is a SIGNED offset into it. `(off / width, off % width)` recovers the element;
1590                // an offset outside the plane reads 0.0, never faults, so an unselected `Select`
1591                // branch may point past the page. The old per-axis read (`coord` took only the axis'
1592                // own term) could never let a row depend on the column iterator; this is what makes
1593                // a transpose, a head slice and a rope pair read all MAPS.
1594                let read_flat = |page: &Page, off: i64| -> f64 {
1595                    let width = (page.len() * t) as i64;
1596                    if off < 0 || width == 0 || off >= t as i64 * width {
1597                        return 0.0;
1598                    }
1599                    let (r, c) = ((off / width) as usize, (off % width) as usize);
1600                    let (tile, e) = (c / t, r * t + c % t);
1601                    match page.get(tile) {
1602                        Some(TileData::Float(v)) => v.get(e).copied().unwrap_or(0.0),
1603                        Some(TileData::Int(v)) => v.get(e).copied().unwrap_or(0) as f64,
1604                        _ => 0.0,
1605                    }
1606                };
1607                fn holds(
1608                    p: &Pred,
1609                    operand: &dyn Fn(usize) -> f64,
1610                    axis: &dyn Fn(LoopVar) -> usize,
1611                ) -> bool {
1612                    let bound = match p.rhs() {
1613                        PredRhs::Const(k) => k as f64,
1614                        PredRhs::Operand(i) => operand(i),
1615                    };
1616                    let x = axis(p.var()) as f64;
1617                    match p {
1618                        Pred::AxisLt(..) => x < bound,
1619                        Pred::AxisGe(..) => x >= bound,
1620                    }
1621                }
1622                fn apply(
1623                    b: &TensorBody,
1624                    operand: &dyn Fn(usize) -> f64,
1625                    axis: &dyn Fn(LoopVar) -> usize,
1626                ) -> f64 {
1627                    match b {
1628                        TensorBody::Operand(i) => operand(*i),
1629                        TensorBody::Const(bits) => f64::from_bits(*bits),
1630                        TensorBody::Add(x, y) => apply(x, operand, axis) + apply(y, operand, axis),
1631                        TensorBody::Sub(x, y) => apply(x, operand, axis) - apply(y, operand, axis),
1632                        TensorBody::Mul(x, y) => apply(x, operand, axis) * apply(y, operand, axis),
1633                        TensorBody::Min(x, y) => apply(x, operand, axis).min(apply(y, operand, axis)),
1634                        TensorBody::Max(x, y) => apply(x, operand, axis).max(apply(y, operand, axis)),
1635                        TensorBody::FloorDiv(x, y) => (apply(x, operand, axis) / apply(y, operand, axis)).floor(),
1636                        TensorBody::Shr { arg, by } => (apply(arg, operand, axis) / 2f64.powi(*by as i32)).floor(),
1637                        TensorBody::Unary { kind, arg } => {
1638                            let v = apply(arg, operand, axis);
1639                            match kind {
1640                                SfpuKind::Exp => v.exp(),
1641                                SfpuKind::Recip => 1.0 / v,
1642                                SfpuKind::Rsqrt { eps_nano, over } => {
1643                                    1.0 / (v / (*over).max(1) as f64 + *eps_nano as f64 * 1e-9)
1644                                        .sqrt()
1645                                }
1646                                SfpuKind::Silu => v / (1.0 + (-v).exp()),
1647                                SfpuKind::Relu => v.max(0.0),
1648                                SfpuKind::Ln => v.ln(),
1649                            }
1650                        }
1651                        // Branch-free: decided by POSITION, both branches meaningful at every point.
1652                        TensorBody::Select {
1653                            cond,
1654                            then,
1655                            otherwise,
1656                        } => {
1657                            if holds(cond, operand, axis) {
1658                                apply(then, operand, axis)
1659                            } else {
1660                                apply(otherwise, operand, axis)
1661                            }
1662                        }
1663                    }
1664                }
1665                // ★ A GATHER IS AN OFFSET WHOSE LAST TERM IS DATA. An operand map with an `IndirectTerm` adds
1666                // `v · stride`, where `v` is the INDEX operand read through ITS OWN map at the same point —
1667                // so a per-row index broadcasts along the row, a per-element index moves with it. The index
1668                // must be a whole number in `[0, modulus)`; anything else is recorded as the first fault and
1669                // the run stops (`GatherOutOfRange`), because a wrapped or clamped index is a silently wrong
1670                // row, not an error anybody would see.
1671                let fault: std::cell::Cell<Option<(f64, usize)>> = std::cell::Cell::new(None);
1672                let offset = |i: usize, r: usize, c: usize, x: usize| -> i64 {
1673                    let affine = maps[i].offset_at_k(row_ax.var, r, col_ax.var, c, k_var, x);
1674                    let Some(ind) = maps[i].indirect else {
1675                        return affine;
1676                    };
1677                    let v = read_flat(
1678                        &pages[ind.operand],
1679                        maps[ind.operand].offset_at_k(row_ax.var, r, col_ax.var, c, k_var, x),
1680                    );
1681                    if v.fract() != 0.0 || v < 0.0 || v >= ind.modulus as f64 {
1682                        if fault.get().is_none() {
1683                            fault.set(Some((v, ind.modulus)));
1684                        }
1685                        return affine;
1686                    }
1687                    affine + v as i64 * ind.stride
1688                };
1689                // The body at one point of the iteration space — summed over the contraction axis when the space
1690                // has one, innermost, so a row or column fold consumes each point's whole product.
1691                let value_at = |r: usize, c: usize, x: usize| -> f64 {
1692                    let operand = |i: usize| read_flat(&pages[i], offset(i, r, c, x));
1693                    let axis = |v: LoopVar| {
1694                        if v == row_ax.var {
1695                            r
1696                        } else if v == col_ax.var {
1697                            c
1698                        } else if k_ax.is_some() && v == k_var {
1699                            x
1700                        } else {
1701                            0
1702                        }
1703                    };
1704                    apply(body, &operand, &axis)
1705                };
1706                let value = |r: usize, c: usize| -> f64 {
1707                    match k_ax {
1708                        None => value_at(r, c, 0),
1709                        Some(k) => (0..k.extent).map(|x| value_at(r, c, x)).sum(),
1710                    }
1711                };
1712                // ★ A PAGE HOLDS `t` ROWS. A point's row lands at tile row `r % t`, so a parallel row axis longer than
1713                // the tile writes two rows into one place and keeps whichever came last. Refused, never overwritten:
1714                // on a scalar-tile part (t = 1) a fold over `h > 1` heads as rows is exactly that.
1715                if matches!(row_ax.kind, IterKind::Parallel) && row_ax.extent > t {
1716                    return Err(ChoreoFault::Unsupported(format!(
1717                        "a Generic with {} parallel rows on a {t}-row tile — a page holds {t} rows, so rows past it would overwrite earlier ones",
1718                        row_ax.extent
1719                    )));
1720                }
1721                let out_tiles = if matches!(col_ax.kind, IterKind::Reduce { .. }) {
1722                    1
1723                } else {
1724                    col_ax.extent.div_ceil(t)
1725                };
1726                // ★ INTEGER ARITHMETIC STAYS INTEGER. Over integer operands a body with no transcendental is
1727                // evaluated in `i64`, every operation checked: a product, sum or fold past ±2⁶³, or a zero
1728                // divisor, stops the run (`IntegerArithmetic`) where the VM would carry a big integer or refuse
1729                // — never a wrapped or rounded number. Through `f64` the same body is exact only while every
1730                // intermediate stays below 2⁵³, and a requant's `x · M` can pass that while its shifted result
1731                // does not. The folds and the output convention are the float arm's, below, in `i64`.
1732                if ins
1733                    .iter()
1734                    .all(|b| precision_int_bits(self.p.buffer(*b).dtype).is_some())
1735                    && body.integral()
1736                {
1737                    fn apply_int(
1738                        b: &TensorBody,
1739                        operand: &dyn Fn(usize) -> i64,
1740                        axis: &dyn Fn(LoopVar) -> usize,
1741                    ) -> Result<i64, &'static str> {
1742                        let two = |x: &TensorBody, y: &TensorBody| -> Result<(i64, i64), &'static str> {
1743                            Ok((apply_int(x, operand, axis)?, apply_int(y, operand, axis)?))
1744                        };
1745                        Ok(match b {
1746                            TensorBody::Operand(i) => operand(*i),
1747                            TensorBody::Const(bits) => f64::from_bits(*bits) as i64,
1748                            TensorBody::Add(x, y) => {
1749                                let (a, b) = two(x, y)?;
1750                                a.checked_add(b).ok_or("an integer sum past ±2⁶³")?
1751                            }
1752                            TensorBody::Sub(x, y) => {
1753                                let (a, b) = two(x, y)?;
1754                                a.checked_sub(b).ok_or("an integer difference past ±2⁶³")?
1755                            }
1756                            TensorBody::Mul(x, y) => {
1757                                let (a, b) = two(x, y)?;
1758                                a.checked_mul(b).ok_or("an integer product past ±2⁶³")?
1759                            }
1760                            TensorBody::Min(x, y) => {
1761                                let (a, b) = two(x, y)?;
1762                                a.min(b)
1763                            }
1764                            TensorBody::Max(x, y) => {
1765                                let (a, b) = two(x, y)?;
1766                                a.max(b)
1767                            }
1768                            TensorBody::FloorDiv(x, y) => {
1769                                let (a, d) = two(x, y)?;
1770                                if d == 0 {
1771                                    return Err("an integer division by zero");
1772                                }
1773                                let q = a.checked_div(d).ok_or("an integer quotient past ±2⁶³")?;
1774                                if a % d != 0 && ((a < 0) != (d < 0)) {
1775                                    q - 1
1776                                } else {
1777                                    q
1778                                }
1779                            }
1780                            TensorBody::Shr { arg, by } => apply_int(arg, operand, axis)? >> by,
1781                            TensorBody::Unary { .. } => return Err("a transcendental in an integer body"),
1782                            TensorBody::Select {
1783                                cond,
1784                                then,
1785                                otherwise,
1786                            } => {
1787                                let as_f64 = |i: usize| operand(i) as f64;
1788                                if holds(cond, &as_f64, axis) {
1789                                    apply_int(then, operand, axis)?
1790                                } else {
1791                                    apply_int(otherwise, operand, axis)?
1792                                }
1793                            }
1794                        })
1795                    }
1796                    let read_int = |page: &Page, off: i64| -> i64 {
1797                        let width = (page.len() * t) as i64;
1798                        if off < 0 || width == 0 || off >= t as i64 * width {
1799                            return 0;
1800                        }
1801                        let (r, c) = ((off / width) as usize, (off % width) as usize);
1802                        let (tile, e) = (c / t, r * t + c % t);
1803                        match page.get(tile) {
1804                            Some(TileData::Int(v)) => v.get(e).copied().unwrap_or(0),
1805                            Some(TileData::Float(v)) => v.get(e).copied().unwrap_or(0.0) as i64,
1806                            _ => 0,
1807                        }
1808                    };
1809                    let value_at = |r: usize, c: usize, x: usize| -> Result<i64, &'static str> {
1810                        let operand = |i: usize| read_int(&pages[i], offset(i, r, c, x));
1811                        let axis = |v: LoopVar| {
1812                            if v == row_ax.var {
1813                                r
1814                            } else if v == col_ax.var {
1815                                c
1816                            } else if k_ax.is_some() && v == k_var {
1817                                x
1818                            } else {
1819                                0
1820                            }
1821                        };
1822                        apply_int(body, &operand, &axis)
1823                    };
1824                    // Every partial result the contraction and the folds hold, for the trace.
1825                    let kept: std::cell::Cell<Option<(i64, i64)>> = std::cell::Cell::new(None);
1826                    let keep = |v: i64| kept.set(Some(widen(kept.get(), v)));
1827                    let value = |r: usize, c: usize| -> Result<i64, &'static str> {
1828                        match k_ax {
1829                            None => value_at(r, c, 0),
1830                            Some(k) => (0..k.extent).try_fold(0i64, |sum, x| {
1831                                let sum = sum
1832                                    .checked_add(value_at(r, c, x)?)
1833                                    .ok_or("an integer contraction past ±2⁶³")?;
1834                                keep(sum);
1835                                Ok(sum)
1836                            }),
1837                        }
1838                    };
1839                    struct IntFold {
1840                        pool: ReducePool,
1841                        acc: i64,
1842                        idx: usize,
1843                        seeded: bool,
1844                        held: Option<(i64, i64)>,
1845                    }
1846                    impl IntFold {
1847                        fn new(pool: ReducePool) -> IntFold {
1848                            IntFold {
1849                                pool,
1850                                acc: 0,
1851                                idx: 0,
1852                                seeded: false,
1853                                held: None,
1854                            }
1855                        }
1856                        fn step(&mut self, i: usize, v: i64) -> Result<(), &'static str> {
1857                            match self.pool {
1858                                ReducePool::Sum => {
1859                                    self.acc = self
1860                                        .acc
1861                                        .checked_add(v)
1862                                        .ok_or("an integer row sum past ±2⁶³")?
1863                                }
1864                                ReducePool::Max => {
1865                                    self.acc = if self.seeded { self.acc.max(v) } else { v }
1866                                }
1867                                ReducePool::ArgMax => {
1868                                    if !self.seeded || v > self.acc {
1869                                        self.acc = v;
1870                                        self.idx = i;
1871                                    }
1872                                }
1873                            }
1874                            self.seeded = true;
1875                            self.held = Some(widen(self.held, self.acc));
1876                            Ok(())
1877                        }
1878                        fn result(&self) -> i64 {
1879                            match self.pool {
1880                                ReducePool::Sum | ReducePool::Max => self.acc,
1881                                ReducePool::ArgMax => self.idx as i64,
1882                            }
1883                        }
1884                    }
1885                    let kept_fold = |f: &IntFold| {
1886                        if let Some((lo, hi)) = f.held {
1887                            keep(lo);
1888                            keep(hi);
1889                        }
1890                    };
1891                    let fail = |what: &'static str| ChoreoFault::IntegerArithmetic { at, what };
1892                    let mut plane = vec![vec![0i64; n]; out_tiles.max(1)];
1893                    match (row_ax.kind, col_ax.kind) {
1894                        (IterKind::Parallel, IterKind::Parallel) => {
1895                            for r in 0..row_ax.extent {
1896                                for c in 0..col_ax.extent {
1897                                    plane[c / t][(r % t) * t + (c % t)] = value(r, c).map_err(fail)?;
1898                                }
1899                            }
1900                        }
1901                        (IterKind::Parallel, IterKind::Reduce { pool }) => {
1902                            for r in 0..row_ax.extent {
1903                                let mut fold = IntFold::new(pool);
1904                                for c in 0..col_ax.extent {
1905                                    fold.step(c, value(r, c).map_err(fail)?).map_err(fail)?;
1906                                }
1907                                kept_fold(&fold);
1908                                let v = fold.result();
1909                                for cc in 0..t {
1910                                    plane[0][(r % t) * t + cc] = v;
1911                                }
1912                            }
1913                        }
1914                        (IterKind::Reduce { pool }, IterKind::Parallel) => {
1915                            for c in 0..col_ax.extent {
1916                                let mut fold = IntFold::new(pool);
1917                                for r in 0..row_ax.extent {
1918                                    fold.step(r, value(r, c).map_err(fail)?).map_err(fail)?;
1919                                }
1920                                kept_fold(&fold);
1921                                let v = fold.result();
1922                                for rr in 0..t {
1923                                    plane[c / t][rr * t + (c % t)] = v;
1924                                }
1925                            }
1926                        }
1927                        (IterKind::Reduce { pool: row_pool }, IterKind::Reduce { pool: col_pool }) => {
1928                            let mut outer = IntFold::new(row_pool);
1929                            for r in 0..row_ax.extent {
1930                                let mut inner = IntFold::new(col_pool);
1931                                for c in 0..col_ax.extent {
1932                                    inner.step(c, value(r, c).map_err(fail)?).map_err(fail)?;
1933                                }
1934                                kept_fold(&inner);
1935                                outer.step(r, inner.result()).map_err(fail)?;
1936                            }
1937                            kept_fold(&outer);
1938                            let v = outer.result();
1939                            for e in plane[0].iter_mut() {
1940                                *e = v;
1941                            }
1942                        }
1943                    }
1944                    if let Some((index, extent)) = fault.get() {
1945                        return Err(ChoreoFault::GatherOutOfRange { at, index, extent });
1946                    }
1947                    // Every node at every point, both arms of a select as hardware evaluates them — a node with no
1948                    // value (a division by zero in the arm not taken) is not one.
1949                    fn note_nodes(
1950                        b: &TensorBody,
1951                        operand: &dyn Fn(usize) -> i64,
1952                        axis: &dyn Fn(LoopVar) -> usize,
1953                        next: &mut usize,
1954                        seen: &mut [Option<(i64, i64)>],
1955                    ) -> Option<i64> {
1956                        let two = |x: &TensorBody, y: &TensorBody, next: &mut usize, seen: &mut [Option<(i64, i64)>]| {
1957                            let a = note_nodes(x, operand, axis, next, seen);
1958                            let c = note_nodes(y, operand, axis, next, seen);
1959                            a.zip(c)
1960                        };
1961                        let v = match b {
1962                            TensorBody::Operand(i) => Some(operand(*i)),
1963                            TensorBody::Const(bits) => Some(f64::from_bits(*bits) as i64),
1964                            TensorBody::Add(x, y) => two(x, y, next, seen).and_then(|(a, c)| a.checked_add(c)),
1965                            TensorBody::Sub(x, y) => two(x, y, next, seen).and_then(|(a, c)| a.checked_sub(c)),
1966                            TensorBody::Mul(x, y) => two(x, y, next, seen).and_then(|(a, c)| a.checked_mul(c)),
1967                            TensorBody::Min(x, y) => two(x, y, next, seen).map(|(a, c)| a.min(c)),
1968                            TensorBody::Max(x, y) => two(x, y, next, seen).map(|(a, c)| a.max(c)),
1969                            TensorBody::FloorDiv(x, y) => two(x, y, next, seen).and_then(|(a, d)| {
1970                                let q = a.checked_div(d)?;
1971                                Some(if a % d != 0 && ((a < 0) != (d < 0)) { q - 1 } else { q })
1972                            }),
1973                            TensorBody::Shr { arg, by } => note_nodes(arg, operand, axis, next, seen).map(|a| a >> by),
1974                            TensorBody::Unary { arg, .. } => {
1975                                note_nodes(arg, operand, axis, next, seen);
1976                                None
1977                            }
1978                            TensorBody::Select { cond, then, otherwise } => {
1979                                let t = note_nodes(then, operand, axis, next, seen);
1980                                let o = note_nodes(otherwise, operand, axis, next, seen);
1981                                let as_f64 = |i: usize| operand(i) as f64;
1982                                if holds(cond, &as_f64, axis) {
1983                                    t
1984                                } else {
1985                                    o
1986                                }
1987                            }
1988                        };
1989                        let i = *next;
1990                        *next += 1;
1991                        if let Some(v) = v {
1992                            seen[i] = Some(widen(seen[i], v));
1993                        }
1994                        v
1995                    }
1996                    let mut seen = self.nodes.get(&stage).cloned().unwrap_or_else(|| vec![None; body.post_order().len()]);
1997                    for r in 0..row_ax.extent {
1998                        for c in 0..col_ax.extent {
1999                            for x in 0..k_ax.map_or(1, |k| k.extent) {
2000                                let operand = |i: usize| read_int(&pages[i], offset(i, r, c, x));
2001                                let axis = |v: LoopVar| {
2002                                    if v == row_ax.var {
2003                                        r
2004                                    } else if v == col_ax.var {
2005                                        c
2006                                    } else if k_ax.is_some() && v == k_var {
2007                                        x
2008                                    } else {
2009                                        0
2010                                    }
2011                                };
2012                                let mut next = 0;
2013                                note_nodes(body, &operand, &axis, &mut next, &mut seen);
2014                            }
2015                        }
2016                    }
2017                    self.nodes.insert(stage, seen);
2018                    if let Some((lo, hi)) = kept.get() {
2019                        let h = widen(Some(widen(self.stage_held.get(&stage).copied(), lo)), hi);
2020                        self.stage_held.insert(stage, h);
2021                    }
2022                    let accs = self.programs[pi].acc.as_mut().unwrap();
2023                    for (k, dst) in accs.iter_mut().enumerate().take(plane.len()) {
2024                        *dst = AccData::Int(plane[k].clone());
2025                    }
2026                    return Ok(true);
2027                }
2028                // ★ A FOLD IS A PROPERTY OF THE ITERATOR. One accumulator per pool: `Sum` adds, `Max`
2029                // keeps the larger, `ArgMax` is the VM's `>` scan — SEEDED by the first element, replaced
2030                // only by a strictly greater one — and yields the index it holds. Seeding is what makes a
2031                // NaN first the answer, as it is on the VM; an accumulator starting at `−∞` would skip it.
2032                struct Fold {
2033                    pool: ReducePool,
2034                    acc: f64,
2035                    idx: usize,
2036                    seeded: bool,
2037                }
2038                impl Fold {
2039                    fn new(pool: ReducePool) -> Fold {
2040                        Fold {
2041                            pool,
2042                            acc: match pool {
2043                                ReducePool::Sum => 0.0,
2044                                ReducePool::Max | ReducePool::ArgMax => f64::NEG_INFINITY,
2045                            },
2046                            idx: 0,
2047                            seeded: false,
2048                        }
2049                    }
2050                    fn step(&mut self, i: usize, v: f64) {
2051                        match self.pool {
2052                            ReducePool::Sum => self.acc += v,
2053                            ReducePool::Max => self.acc = self.acc.max(v),
2054                            ReducePool::ArgMax => {
2055                                if !self.seeded || v > self.acc {
2056                                    self.acc = v;
2057                                    self.idx = i;
2058                                    self.seeded = true;
2059                                }
2060                            }
2061                        }
2062                    }
2063                    fn result(&self) -> f64 {
2064                        match self.pool {
2065                            ReducePool::Sum | ReducePool::Max => self.acc,
2066                            ReducePool::ArgMax => self.idx as f64,
2067                        }
2068                    }
2069                }
2070                // ★ THE SYMMETRIC OUTPUT CONVENTION. Folding the COLUMN axis leaves one scalar per row,
2071                // written ACROSS the whole output tile (a per-row broadcast reads it back from column
2072                // 0). Folding the ROW axis leaves one scalar per column, written DOWN the output tile
2073                // (a per-column broadcast reads it back from row 0). Folding both leaves one scalar,
2074                // on the whole tile.
2075                let mut plane = vec![vec![0f64; n]; out_tiles.max(1)];
2076                match (row_ax.kind, col_ax.kind) {
2077                    (IterKind::Parallel, IterKind::Parallel) => {
2078                        for r in 0..row_ax.extent {
2079                            for c in 0..col_ax.extent {
2080                                plane[c / t][(r % t) * t + (c % t)] = value(r, c);
2081                            }
2082                        }
2083                    }
2084                    (IterKind::Parallel, IterKind::Reduce { pool }) => {
2085                        for r in 0..row_ax.extent {
2086                            let mut fold = Fold::new(pool);
2087                            for c in 0..col_ax.extent {
2088                                fold.step(c, value(r, c));
2089                            }
2090                            let v = fold.result();
2091                            for cc in 0..t {
2092                                plane[0][(r % t) * t + cc] = v;
2093                            }
2094                        }
2095                    }
2096                    (IterKind::Reduce { pool }, IterKind::Parallel) => {
2097                        for c in 0..col_ax.extent {
2098                            let mut fold = Fold::new(pool);
2099                            for r in 0..row_ax.extent {
2100                                fold.step(r, value(r, c));
2101                            }
2102                            let v = fold.result();
2103                            for rr in 0..t {
2104                                plane[c / t][rr * t + (c % t)] = v;
2105                            }
2106                        }
2107                    }
2108                    (IterKind::Reduce { pool: row_pool }, IterKind::Reduce { pool: col_pool }) => {
2109                        // Rows outer, columns inner: the row fold consumes each row's column fold.
2110                        let mut outer = Fold::new(row_pool);
2111                        for r in 0..row_ax.extent {
2112                            let mut inner = Fold::new(col_pool);
2113                            for c in 0..col_ax.extent {
2114                                inner.step(c, value(r, c));
2115                            }
2116                            outer.step(r, inner.result());
2117                        }
2118                        let v = outer.result();
2119                        for e in plane[0].iter_mut() {
2120                            *e = v;
2121                        }
2122                    }
2123                }
2124                if let Some((index, extent)) = fault.get() {
2125                    return Err(ChoreoFault::GatherOutOfRange { at, index, extent });
2126                }
2127                // ★ INDEX ARITHMETIC RUNS ON THE INTEGER DATAPATH. An arg-max, a gather offset, a token id is
2128                // computed here too, and `f32` holds integers exactly only below 2²⁴ — a Qwen3.8 embedding
2129                // offset (token · 5120) reaches 1.27·10⁹. A stage whose operands are ALL integer buffers, or
2130                // whose fold is an `ArgMax` (an index, which the IR already requires to pack into an integer
2131                // buffer), yields the exact `f64` plane (exact to 2⁵³) as integers; any other stage is the
2132                // float datapath and accumulates in f32, whatever the program-wide accumulator policy says.
2133                let integer = ins
2134                    .iter()
2135                    .all(|b| precision_int_bits(self.p.buffer(*b).dtype).is_some())
2136                    || [row_ax, col_ax].iter().any(|a| {
2137                        matches!(
2138                            a.kind,
2139                            IterKind::Reduce {
2140                                pool: ReducePool::ArgMax
2141                            }
2142                        )
2143                    });
2144                let accs = self.programs[pi].acc.as_mut().unwrap();
2145                for (k, dst) in accs.iter_mut().enumerate().take(plane.len()) {
2146                    *dst = if integer {
2147                        AccData::Int(plane[k].iter().map(|v| *v as i64).collect())
2148                    } else {
2149                        AccData::F32(plane[k].iter().map(|v| *v as f32).collect())
2150                    };
2151                }
2152                Ok(true)
2153            }
2154            ComputeOp::Scalar(body) => {
2155                let pages: Vec<Page> = ins
2156                    .iter()
2157                    .map(|b| self.front_page(*b, at))
2158                    .collect::<Result<_, _>>()?;
2159                let df = DecisionFabric {
2160                    n_in: body.n_in,
2161                    cells: body.cells.clone(),
2162                    result: body.result,
2163                    escaped_first_return: false,
2164                };
2165                let accs = self.programs[pi].acc.as_mut().unwrap();
2166                let mut heap: Vec<i64> = Vec::new();
2167                for (k, dst) in accs.iter_mut().enumerate() {
2168                    let mut out = vec![0i64; n];
2169                    for e in 0..n {
2170                        let inputs: Vec<i64> = pages
2171                            .iter()
2172                            .map(|p| match p.get(k) {
2173                                Some(TileData::Int(v)) => v[e],
2174                                Some(TileData::Float(v)) => v[e] as i64,
2175                                Some(TileData::Empty) | None => 0,
2176                            })
2177                            .collect();
2178                        out[e] = eval_decision_fabric_heap(&df, &inputs, &mut heap);
2179                    }
2180                    *dst = AccData::Int(out);
2181                }
2182                let _ = site;
2183                Ok(true)
2184            }
2185        }
2186    }
2187
2188    fn order(&self, sched: Scheduler, round: u64) -> Vec<usize> {
2189        let n = self.programs.len();
2190        let mut idx: Vec<usize> = (0..n).collect();
2191        match sched {
2192            Scheduler::RoundRobin => {}
2193            Scheduler::Reverse => idx.reverse(),
2194            Scheduler::Seeded(seed) => {
2195                let mut x = seed ^ round.wrapping_mul(0x9E3779B97F4A7C15);
2196                for i in (1..n).rev() {
2197                    x ^= x << 13;
2198                    x ^= x >> 7;
2199                    x ^= x << 17;
2200                    let j = (x % (i as u64 + 1)) as usize;
2201                    idx.swap(i, j);
2202                }
2203            }
2204            Scheduler::MovementFirst => {
2205                idx.sort_by_key(|i| matches!(self.programs[*i].engine, Engine::Compute))
2206            }
2207            Scheduler::ComputeFirst => {
2208                idx.sort_by_key(|i| !matches!(self.programs[*i].engine, Engine::Compute))
2209            }
2210        }
2211        idx
2212    }
2213
2214    fn run(&mut self, sched: Scheduler) -> Result<(), ChoreoFault> {
2215        let mut round = 0u64;
2216        loop {
2217            let order = self.order(sched, round);
2218            let mut progressed = false;
2219            let mut all_done = true;
2220            for pi in order {
2221                if self.programs[pi].done {
2222                    continue;
2223                }
2224                all_done = false;
2225                // Let a program run ahead while it can: bounded by its blocking op.
2226                let mut budget = 64;
2227                while budget > 0 && !self.programs[pi].done {
2228                    if self.step(pi)? {
2229                        progressed = true;
2230                        self.steps += 1;
2231                    } else {
2232                        break;
2233                    }
2234                    budget -= 1;
2235                }
2236            }
2237            if all_done {
2238                return Ok(());
2239            }
2240            if !progressed {
2241                let blocked_at = self
2242                    .programs
2243                    .iter()
2244                    .filter(|c| !c.done)
2245                    .map(|c| {
2246                        let op = match c.code.get(c.pc) {
2247                            Some(Instr::Op(op, _)) => op.clone(),
2248                            _ => ChoreoOp::ReadBarrier,
2249                        };
2250                        (c.site, c.engine, c.pc, op)
2251                    })
2252                    .collect();
2253                return Err(ChoreoFault::Deadlock {
2254                    runnable: Vec::new(),
2255                    blocked_at,
2256                });
2257            }
2258            round += 1;
2259        }
2260    }
2261
2262    fn finish(self) -> Result<(Outputs, Trace), ChoreoFault> {
2263        self.finish_with(false)
2264    }
2265
2266    /// `every`: collect every tensor the program holds at the end — its activations and parameters too — not only
2267    /// its results (`Output` and `State`).
2268    fn finish_with(mut self, every: bool) -> Result<(Outputs, Trace), ChoreoFault> {
2269        for (i, f) in self.fifos.iter().enumerate() {
2270            if !f.queue.is_empty() || !f.pending.is_empty() {
2271                return Err(ChoreoFault::Unconsumed {
2272                    buf: BufferId::new(i),
2273                    pages_left: f.queue.len() + f.pending.len(),
2274                });
2275            }
2276            if f.reserved != 0 {
2277                return Err(ChoreoFault::OutstandingReservation {
2278                    buf: BufferId::new(i),
2279                    pages: f.reserved,
2280                });
2281            }
2282        }
2283        let t = self.t;
2284        let mut tensors = BTreeMap::new();
2285        for (td, store) in self.p.tensors.iter().zip(self.tensors.iter()) {
2286            // ★ THE CARRY LEAVES WITH THE ANSWER. A step's state is as much a result as its logits —
2287            // it is what the NEXT token reads — so it rides in the outputs and a differential can
2288            // compare it. Without this a wrong GDN memory would be invisible until the second token.
2289            if !every && !matches!(td.role, TensorRole::Output | TensorRole::State) {
2290                continue;
2291            }
2292            let (rows, cols) = (td.elems.rows, td.elems.cols);
2293            let is_int = precision_is_int(store.dtype);
2294            let mut ints = vec![0i64; rows * cols];
2295            let mut floats = vec![0f64; rows * cols];
2296            for (idx, tile) in store.tiles.iter().enumerate() {
2297                // ★ OUTPUT COVERAGE IS NOT PROVABLE HERE, AND THE OLD COMMENT CLAIMED OTHERWISE.
2298                //
2299                // What stood here said coverage "is the structural invariant `validate` proves (every
2300                // output tile stored exactly once by the work list)". `validate` proves no such thing: it
2301                // checks that a Store writes an Output tensor and a Load does not read one. Nothing
2302                // connected the WORK LIST (over which coverage is stated) to the SCHEDULE (which is what
2303                // actually runs) — so `certify_choreography`, which reaches coverage only through
2304                // `eval_choreography_dry`, could issue a certificate for a program that never stores part
2305                // of its result. `spatial_certificate_lock` found it at case 105 by deleting a scheduled op.
2306                //
2307                // ★ AND IT CANNOT BE FIXED BY CHECKING `stored_by` HERE. A dry run is deliberately bounded:
2308                // `Eval::new` flattens with `Some(item_window(p))` and `unroll` caps the ITEM loop at that
2309                // window, so a program with more items per site than the window leaves later tiles unwritten
2310                // AS DESIGNED. Faulting on those rejects correct GEMMs — measured, five locks across
2311                // handshake/buffers/work-model — and because `verify` is the last pass in `PASS_ORDER` that
2312                // is the compiler refusing to compile, not a test going red.
2313                //
2314                // The real guarantee is structural and lives in `validate_schedule`
2315                // (`IrViolation::StageNotScheduled`): a store stage must issue at least one `WriteTiles` in
2316                // the schedule. That runs after every pass, so a pass that drops a store fails immediately
2317                // with a precise violation instead of surviving to certification. The substitution below is
2318                // therefore correct AS A DATA FALLBACK ONLY — it says nothing about coverage, and must not
2319                // be read as saying anything about it.
2320                let Some(tile) = tile.as_ref().or(if self.dry {
2321                    Some(&TileData::Empty)
2322                } else {
2323                    None
2324                }) else {
2325                    return Err(ChoreoFault::OutputTileNeverStored {
2326                        tensor: td.id,
2327                        tile: idx,
2328                    });
2329                };
2330                let (rt, ct) = (idx / store.cols_t, idx % store.cols_t);
2331                for r in 0..t {
2332                    for c in 0..t {
2333                        let (er, ec) = (rt * t + r, ct * t + c);
2334                        if er < rows && ec < cols {
2335                            match tile {
2336                                TileData::Int(v) => {
2337                                    ints[er * cols + ec] = v[r * t + c];
2338                                    floats[er * cols + ec] = v[r * t + c] as f64;
2339                                }
2340                                TileData::Float(v) => {
2341                                    floats[er * cols + ec] = v[r * t + c];
2342                                    ints[er * cols + ec] = v[r * t + c] as i64;
2343                                }
2344                                TileData::Empty => {}
2345                            }
2346                        }
2347                    }
2348                }
2349            }
2350            tensors.insert(
2351                td.id,
2352                if is_int {
2353                    Value::Int(ints)
2354                } else {
2355                    Value::Float(floats)
2356                },
2357            );
2358        }
2359        let trace = Trace {
2360            high_water: self
2361                .fifos
2362                .iter()
2363                .enumerate()
2364                .map(|(i, f)| (BufferId::new(i), f.high_water))
2365                .collect(),
2366            steps: self.steps,
2367            tiles_read: std::mem::take(&mut self.tiles_read),
2368            held: std::mem::take(&mut self.held),
2369            nodes: std::mem::take(&mut self.nodes),
2370            stage_held: std::mem::take(&mut self.stage_held),
2371        };
2372        Ok((
2373            Outputs {
2374                tensors,
2375                steps: self.steps,
2376            },
2377            trace,
2378        ))
2379    }
2380}
2381
2382pub fn eval_choreography_with(
2383    p: &SpatialProgram,
2384    inputs: &Inputs,
2385    sched: Scheduler,
2386) -> Result<Outputs, ChoreoFault> {
2387    eval_choreography_traced_with(p, inputs, sched).map(|(o, _)| o)
2388}
2389
2390pub fn eval_choreography_traced_with(
2391    p: &SpatialProgram,
2392    inputs: &Inputs,
2393    sched: Scheduler,
2394) -> Result<(Outputs, Trace), ChoreoFault> {
2395    let mut e = Eval::new(p, inputs)?;
2396    e.run(sched)?;
2397    e.finish()
2398}
2399
2400/// A token-only run: every choreography check (reservations, pushes, waits, semaphore protocol, multicast
2401/// landing, accumulator protocol, store coverage) with no arithmetic — the certificate's dry run, every trip at its
2402/// greatest count.
2403pub fn eval_choreography_dry(p: &SpatialProgram) -> Result<Trace, ChoreoFault> {
2404    eval_choreography_dry_at(p, &p.bound_counts())
2405}
2406
2407/// [`eval_choreography_dry`] with each trip `t` counting `counts[t]`.
2408pub fn eval_choreography_dry_at(
2409    p: &SpatialProgram,
2410    counts: &[usize],
2411) -> Result<Trace, ChoreoFault> {
2412    let mut e = Eval::new_dry(p, counts)?;
2413    e.run(Scheduler::RoundRobin)?;
2414    e.finish().map(|(_, t)| t)
2415}
2416
2417/// The count of each of `p`'s trips on `inputs`, in trip order: element `elem` of the trip's input — as the tensor
2418/// holds it, at its own precision — through [`Trip::count_of`]. An input `inputs` does not carry reads the tensor's
2419/// declared fill.
2420pub fn trip_counts(p: &SpatialProgram, inputs: &Inputs) -> Result<Vec<usize>, ChoreoFault> {
2421    p.trips
2422        .iter()
2423        .enumerate()
2424        .map(|(i, trip)| {
2425            let td = p.tensor(trip.tensor);
2426            let raw = match inputs.tensors.get(&td.id) {
2427                Some(Value::Int(v)) => v.get(trip.elem).map(|x| *x as f64),
2428                Some(Value::Float(v)) => v.get(trip.elem).copied(),
2429                None if td.init.is_float() => td.init.element_f64(trip.elem),
2430                None => td.init.element(trip.elem).map(|x| x as f64),
2431            }
2432            .ok_or(ChoreoFault::MissingInput(td.id))?;
2433            let held = match precision_int_bits(td.dtype) {
2434                Some(bits) => trunc_signed(raw as i64, bits) as f64,
2435                None => round_to(td.dtype, raw),
2436            };
2437            trip.count_of(held).ok_or(ChoreoFault::TripOutOfRange {
2438                trip: TripId::new(i),
2439                value: held,
2440                admitted: trip.admit,
2441            })
2442        })
2443        .collect()
2444}
2445
2446/// [`eval_choreography`] with EVERY tensor the program holds at the end in the outputs — its activations and
2447/// parameters as well as its results: the values a segment of a composed program starts from, and must reach.
2448pub fn eval_choreography_every_tensor(
2449    p: &SpatialProgram,
2450    inputs: &Inputs,
2451) -> Result<Outputs, ChoreoFault> {
2452    let mut e = Eval::new(p, inputs)?;
2453    e.run(Scheduler::RoundRobin)?;
2454    e.finish_with(true).map(|(o, _)| o)
2455}
2456
2457/// The meaning of a scheduled program under the round-robin scheduler.
2458pub fn eval_choreography(p: &SpatialProgram, inputs: &Inputs) -> Result<Outputs, ChoreoFault> {
2459    eval_choreography_with(p, inputs, Scheduler::RoundRobin)
2460}
2461
2462pub fn eval_choreography_traced(
2463    p: &SpatialProgram,
2464    inputs: &Inputs,
2465) -> Result<(Outputs, Trace), ChoreoFault> {
2466    eval_choreography_traced_with(p, inputs, Scheduler::RoundRobin)
2467}
2468
2469/// Run five schedulers and demand bit-equal outputs — Kahn determinacy, executed.
2470pub fn eval_choreography_confluent(
2471    p: &SpatialProgram,
2472    inputs: &Inputs,
2473) -> Result<Outputs, ChoreoFault> {
2474    let scheds = [
2475        Scheduler::RoundRobin,
2476        Scheduler::Reverse,
2477        Scheduler::Seeded(0x5EED),
2478        Scheduler::MovementFirst,
2479        Scheduler::ComputeFirst,
2480    ];
2481    let first = eval_choreography_with(p, inputs, scheds[0])?;
2482    for s in &scheds[1..] {
2483        let o = eval_choreography_with(p, inputs, *s)?;
2484        if o.tensors != first.tensors {
2485            let tensor = first
2486                .tensors
2487                .keys()
2488                .find(|k| o.tensors.get(k) != first.tensors.get(k))
2489                .copied()
2490                .unwrap_or(TensorId(0));
2491            return Err(ChoreoFault::NonDeterministic {
2492                scheduler_a: scheduler_name(scheds[0]),
2493                scheduler_b: scheduler_name(*s),
2494                tensor,
2495            });
2496        }
2497    }
2498    Ok(first)
2499}
2500
2501/// Every site any program runs on (a placement view for the lowerings).
2502pub fn program_sites(p: &SpatialProgram) -> BTreeSet<Site> {
2503    p.schedule
2504        .as_ref()
2505        .map(|s| s.programs.iter().map(|q| q.site).collect())
2506        .unwrap_or_default()
2507}