Skip to main content

earthsci_ast/simulate_array/tape/
mod.rs

1//! Tape compilation and execution.
2//!
3//! Compiles a model's observed + RHS rule set into a flat instruction program
4//! ([`TapeProgram`]) with build-time value numbering (subsuming the runtime
5//! CSE overlay for taped rules), cadence-sectioned instruction streams
6//! (CONST / SEGMENT / CONTINUOUS), liveness-based slab coloring, `dy` scatter
7//! descriptors and observed exports (Step 3a), and executes it as the
8//! DEFAULT production RHS hot path through the fast slab executor in
9//! [`exec`] (Step 3b): a model builds the program once, keeps it
10//! ([`TapeCache`]), and each segment's RHS scratch runs it. Which evaluator runs is the caller's
11//! choice of compiler (API_SPEC §5.8) and nothing else: `native` is this
12//! tape, `interpreter` is the per-cell oracle. Under `native` the tape also
13//! serves the finite-difference Jacobian closure and the observed passes
14//! (the build-time materialization of static observeds, the per-segment seed
15//! and the observeds reported at output times; see `tape_serves_passes`).
16//! `debug_eval_rhs` is the oracle and never runs the tape.
17//!
18//! ## Instruction set
19//!
20//! [`ir`] defines it and fixes each instruction's element semantics; the
21//! short version is that everything is straight-line and shape-static.
22//! Elementwise work is [`Instr::Bin`] / [`Instr::Un`] / [`Instr::Neg`] /
23//! [`Instr::Select`] (and, after the Step 4 fusion pass, [`Instr::Fused`]);
24//! data movement is [`Instr::Gather`] / [`Instr::LoadElem`] /
25//! [`Instr::Copy`] / [`Instr::Region`] / [`Instr::Fill`] / [`Instr::Ramp`];
26//! control flow is the one structured [`Instr::JmpIfZero`]; and the
27//! boundaries with the rest of the runtime are [`Instr::Export`],
28//! [`Instr::DyWrite`] and [`Instr::LoadForcing`] (the forcing buffer, read in
29//! the CONST or SEGMENT section).
30//!
31//! There is no instruction that hands a rule to the per-cell oracle. A
32//! program exists only when every rule lowered; a build with a rule the tape
33//! cannot express yields a [`DeclinedTape`] instead, which names each
34//! declined rule and its reason and holds nothing to execute. So `native`,
35//! which runs nothing but this tape, cannot reach per-cell evaluation: the
36//! per-cell code lives only in the interpreter (`evaluate_rhs_legacy`, the
37//! observed passes), which the historical routing runs for a model whose tape
38//! declined.
39//!
40//! Two instructions carry data or a fold rather than a per-element map, and
41//! both exist so a whole downstream cone of rules stops declining:
42//!
43//! * [`Instr::ConstArray`] materializes an inline array literal into a
44//!   CONST-section slot — one instruction and one store per solve, whatever
45//!   the literal's size, with the payload on
46//!   [`TapeProgram::const_data`]. Without it an array-valued `const` had no
47//!   representation at all (`Operand::Lit` is one `f64`; `Fill` broadcasts
48//!   one scalar), so every reader of such a constant bailed.
49//! * [`Instr::Reduce`] folds a source box over a set of axes with a binary
50//!   kernel, visiting the source in ROW-MAJOR order — which is the per-cell
51//!   oracle's contraction odometer, so a rank-0 `faq` (every index
52//!   contracted, scalar result) folds the same terms in the same
53//!   association the interpreter would. It is the one construct the tape
54//!   lowers that the whole-array overlay declines outright.
55//!
56//! A rule that reads an observed whose own rule declined declines too, at
57//! the read: there is no operand for a value that is not on the tape, so the
58//! decline list names the producer and every rule that reads it.
59//!
60//! ## Closed functions
61//!
62//! A fourth piece is likewise not an instruction. The esm-spec §9.2
63//! `datetime.*` family — `year`, `month`, `day`, `hour`, `minute`, `second`,
64//! `day_of_year`, `julian_day`, `is_leap_year` — is expanded at LOWERING time
65//! into the arithmetic above (one floored divmod by 86400, then floors,
66//! remainders, comparisons and selects on integer-valued `f64`s), so all
67//! three executors gained it at once and none of them can drift from
68//! another. The `interp.*` entries of the same registry are not lowered: they
69//! read a table, and a rule that calls one declines, naming the function.
70
71#[cfg(test)]
72mod array_tests;
73#[cfg(test)]
74mod data_gather_tests;
75mod exec;
76#[cfg(test)]
77mod forcing_tests;
78mod fuse;
79mod geom;
80mod ir;
81mod layout;
82mod lower;
83#[cfg(test)]
84mod lowering_limit_tests;
85mod prune;
86#[cfg(test)]
87mod refexec;
88mod reroll;
89mod scan_shift;
90mod sink;
91#[cfg(test)]
92mod tests;
93#[cfg(test)]
94mod tuple_tests;
95// Phase 2: the XLA emitter over this IR (feature `xla`, OFF by default). Last
96// in the list because it is the only optional one.
97#[cfg(feature = "xla")]
98pub mod xla_emit;
99
100#[cfg(test)]
101pub(crate) use exec::section_primes;
102pub(in crate::simulate_array) use exec::{
103    CallInputs, ForcingView, TapeCtx, run_tape_call, run_tape_inputs,
104};
105pub(crate) use ir::*;
106#[cfg(not(target_arch = "wasm32"))]
107pub(crate) use lower::StandaloneError;
108use lower::build_tape_program;
109pub(crate) use lower::{TileAxisDom, TileLowering, TileObserved, TilePiece};
110#[cfg(not(target_arch = "wasm32"))]
111pub(in crate::simulate_array) use lower::{collect_variable_names, eval_standalone_rule};
112
113use super::ArrayCompiled;
114use std::cell::RefCell;
115use std::collections::HashSet;
116use std::fmt;
117use std::rc::Rc;
118
119/// Human-readable summary of one tape build. Public so external diagnostics
120/// (the `tape_report` example) can print it; the program itself stays
121/// crate-internal.
122#[derive(Debug, Clone, Default)]
123pub struct TapeBuildReport {
124    /// Total rules considered (observed + RHS).
125    pub n_rules: usize,
126    /// Rules fully lowered onto the tape.
127    pub n_taped: usize,
128    /// `(rule name, deepest bail reason)` for every rule that did not lower.
129    /// Non-empty means the build produced no program at all (the instruction
130    /// counts below are then zero): the model runs on the interpreter under
131    /// the historical routing, and a strict compiler refuses it.
132    pub fallbacks: Vec<(String, String)>,
133    pub n_instr_const: usize,
134    pub n_instr_segment: usize,
135    pub n_instr_continuous: usize,
136    /// Instruction counts per opcode, whole program, descending.
137    pub opcode_counts: Vec<(String, usize)>,
138    pub n_slots: usize,
139    pub n_storages: usize,
140    pub n_gather_plans: usize,
141    pub n_exports: usize,
142    pub n_dy_writes: usize,
143    /// Total slab size in bytes (all storages, `f64`).
144    pub slab_bytes: usize,
145    /// Dedicated (cross-section-live) CONST storage bytes.
146    pub slab_bytes_const: usize,
147    /// Dedicated SEGMENT storage bytes.
148    pub slab_bytes_segment: usize,
149    /// Recycled (within-section) storage bytes.
150    pub slab_bytes_recycled: usize,
151    /// Build-time value-numbering hits served from the scope-local memo.
152    pub vn_scope_hits: usize,
153    /// Hits served from the cross-rule (box-pure) hoist map.
154    pub vn_hoist_hits: usize,
155    /// Step 4: kernel-fusion diagnostics (all-zero when fusion is disabled).
156    pub fuse: FuseStats,
157}
158
159impl fmt::Display for TapeBuildReport {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        writeln!(f, "tape build report")?;
162        writeln!(
163            f,
164            "  rules: {} taped / {} declined (of {})",
165            self.n_taped,
166            self.fallbacks.len(),
167            self.n_rules
168        )?;
169        for (name, reason) in &self.fallbacks {
170            writeln!(f, "    declined {name}: {reason}")?;
171        }
172        if !self.fallbacks.is_empty() {
173            return writeln!(f, "  no program: a declined rule leaves no tape");
174        }
175        writeln!(
176            f,
177            "  instructions: {} CONST + {} SEGMENT + {} CONTINUOUS = {}",
178            self.n_instr_const,
179            self.n_instr_segment,
180            self.n_instr_continuous,
181            self.n_instr_const + self.n_instr_segment + self.n_instr_continuous
182        )?;
183        write!(f, "  opcodes:")?;
184        for (op, n) in &self.opcode_counts {
185            write!(f, " {op}={n}")?;
186        }
187        writeln!(f)?;
188        writeln!(
189            f,
190            "  slots: {} ({} storages, {} gather plans, {} dy writes, {} exports)",
191            self.n_slots, self.n_storages, self.n_gather_plans, self.n_dy_writes, self.n_exports
192        )?;
193        writeln!(
194            f,
195            "  slab: {:.2} MiB total ({:.2} MiB CONST + {:.2} MiB SEGMENT dedicated, {:.2} MiB recycled)",
196            self.slab_bytes as f64 / (1024.0 * 1024.0),
197            self.slab_bytes_const as f64 / (1024.0 * 1024.0),
198            self.slab_bytes_segment as f64 / (1024.0 * 1024.0),
199            self.slab_bytes_recycled as f64 / (1024.0 * 1024.0),
200        )?;
201        writeln!(
202            f,
203            "  value numbering: {} scope hits, {} hoist hits",
204            self.vn_scope_hits, self.vn_hoist_hits
205        )?;
206        if self.fuse.n_groups > 0 {
207            writeln!(
208                f,
209                "  fusion: {} -> {} instructions; {} groups absorbing {} \
210                 ({} gathers folded, {} kept, {} reductions folded); group sizes \
211                 [2-3]={} [4-7]={} [8-15]={} [16-31]={} [32-63]={} [64+]={}",
212                self.fuse.instrs_before,
213                self.fuse.instrs_after,
214                self.fuse.n_groups,
215                self.fuse.n_member_instrs,
216                self.fuse.n_gathers_folded,
217                self.fuse.n_gathers_kept,
218                self.fuse.n_reduces_folded,
219                self.fuse.group_size_hist[0],
220                self.fuse.group_size_hist[1],
221                self.fuse.group_size_hist[2],
222                self.fuse.group_size_hist[3],
223                self.fuse.group_size_hist[4],
224                self.fuse.group_size_hist[5],
225            )?;
226        }
227        Ok(())
228    }
229}
230
231/// Where ONE rule of a model landed, for `compiler_report` (API_SPEC §5.8).
232///
233/// Deliberately plain data rather than a borrow of the program: the record
234/// lives on the Problem, apart from the program the model keeps.
235#[derive(Clone, Debug)]
236pub(crate) struct TapeRuleRecord {
237    /// The rule's variable name, as the compiled model spells it (the caller
238    /// qualifies it with the component).
239    pub name: String,
240    /// `"observed"` or `"state derivative"`.
241    pub kind: &'static str,
242    /// The cadence tier the rule runs at: `"const"` (once per solve, at
243    /// setup), `"segment"` (once per forcing-refresh segment) or
244    /// `"continuous"` (every right-hand-side call).
245    pub cadence: &'static str,
246    /// `None` when the rule lowered onto the tape; otherwise the DEEPEST
247    /// decline reason reached while trying (and the model has no tape).
248    pub fallback_reason: Option<String>,
249}
250
251/// Assemble the report from a finished program.
252pub(crate) fn make_report(prog: &TapeProgram, vn_hits: (usize, usize)) -> TapeBuildReport {
253    let mut opcode_counts: std::collections::HashMap<&'static str, usize> =
254        std::collections::HashMap::new();
255    for i in &prog.instrs {
256        *opcode_counts.entry(i.opcode()).or_insert(0) += 1;
257    }
258    let mut opcode_counts: Vec<(String, usize)> = opcode_counts
259        .into_iter()
260        .map(|(k, v)| (k.to_string(), v))
261        .collect();
262    opcode_counts.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
263    let n_cont = prog.instrs.len() - (prog.n_const + prog.n_segment) as usize;
264    TapeBuildReport {
265        n_rules: prog.rules.len(),
266        n_taped: prog.rules.len(),
267        fallbacks: Vec::new(),
268        n_instr_const: prog.n_const as usize,
269        n_instr_segment: prog.n_segment as usize,
270        n_instr_continuous: n_cont,
271        opcode_counts,
272        n_slots: prog.slots.len(),
273        n_storages: prog.slab.storages.len(),
274        n_gather_plans: prog.plans.len(),
275        n_exports: prog.exports.len(),
276        n_dy_writes: prog.dy_writes.len(),
277        slab_bytes: prog.slab.total_elems * 8,
278        slab_bytes_const: prog.slab.const_elems * 8,
279        slab_bytes_segment: prog.slab.segment_elems * 8,
280        slab_bytes_recycled: prog.slab.recycled_elems * 8,
281        vn_scope_hits: vn_hits.0,
282        vn_hoist_hits: vn_hits.1,
283        fuse: prog.fuse_stats.clone(),
284    }
285}
286
287/// The report of a build that declined: the rule counts and the declined
288/// rules, and nothing about a program, because there is none.
289pub(crate) fn make_declined_report(
290    declined: &DeclinedTape,
291    vn_hits: (usize, usize),
292) -> TapeBuildReport {
293    let fallbacks = declined.declined_rules();
294    TapeBuildReport {
295        n_rules: declined.rules.len(),
296        n_taped: declined.rules.len() - fallbacks.len(),
297        fallbacks,
298        vn_scope_hits: vn_hits.0,
299        vn_hoist_hits: vn_hits.1,
300        ..TapeBuildReport::default()
301    }
302}
303
304impl DeclinedTape {
305    /// `(rule name, reason)` for every rule that did not lower, in rule order.
306    pub(crate) fn declined_rules(&self) -> Vec<(String, String)> {
307        self.rules
308            .iter()
309            .zip(&self.reasons)
310            .filter_map(|(r, why)| why.as_ref().map(|why| (r.name.clone(), why.clone())))
311            .collect()
312    }
313}
314
315impl RuleInfo {
316    /// `"observed"` or `"state derivative"`, as the compiler report spells it.
317    pub(crate) fn kind_name(&self) -> &'static str {
318        match self.kind {
319            RuleKind::Observed(_) => "observed",
320            RuleKind::Rhs(_) => "state derivative",
321        }
322    }
323
324    /// The cadence tier's name in the compiler report.
325    pub(crate) fn cadence_name(&self) -> &'static str {
326        match self.cadence {
327            Cadence::Const => "const",
328            Cadence::Segment => "segment",
329            Cadence::Continuous => "continuous",
330        }
331    }
332}
333
334/// Test seam: the program of a build every rule of which lowered, panicking
335/// with the build report when one declined.
336#[cfg(test)]
337pub(crate) fn taped(
338    (prog, report): (Result<TapeProgram, DeclinedTape>, TapeBuildReport),
339) -> (TapeProgram, TapeBuildReport) {
340    match prog {
341        Ok(prog) => (prog, report),
342        Err(_) => panic!("the tape declined a rule:\n{report}"),
343    }
344}
345
346/// What a model's tape build produced: the program, or — when any rule did
347/// not lower — the [`DeclinedTape`] in its place. Either way the build's
348/// report rides beside it.
349pub(crate) type TapeBuild = Result<std::sync::Arc<TapeProgram>, Rc<DeclinedTape>>;
350
351/// A tape program this model built, with its report, kept so that every pass
352/// that runs the tape — the construction-time gate, a state-free evaluation,
353/// each solve, the diagnostic seams — runs the one program instead of
354/// building its own.
355///
356/// What a build reads, and so what invalidates a kept program:
357///
358/// * the compiled model itself — its rules, state layout, parameter NAMES and
359///   const-array registry. These are fixed when the model is constructed and
360///   nothing mutates them through `&self`; a changed document is a new model,
361///   with an empty cache.
362/// * the DISCRETE-forcing set, which decides each observed's cadence tier and
363///   the section each forcing load runs in;
364/// * the NAMES and SHAPES the forcing buffer holds, which decide which reads
365///   are forcing loads and the box each one compiles against (a name the
366///   buffer does not hold compiles against its declared shape);
367/// * the precision in force (`crate::precision::active`), which rounds every
368///   literal the build folds;
369/// * whether the tape serves the observed passes (the runtime mode), which
370///   decides what it exports.
371///
372/// The last four are the key an entry is looked up by. Parameter VALUES and
373/// the forcing buffer's VALUES are not inputs: the program reads parameters
374/// on every call and loads the forcing buffer in its CONST and SEGMENT
375/// sections. So a new parameter vector, or new forcing data, needs no rebuild:
376/// those sections are per-scratch state, re-run when the parameter vector
377/// changes, and the SEGMENT section again when the driver bumps the forcing
378/// epoch after a refresh (`TapeCtx::bump_forcing_epoch`).
379pub(crate) struct TapeCache {
380    entries: RefCell<Vec<CachedTape>>,
381}
382
383struct CachedTape {
384    discrete_forcing: Vec<String>,
385    forcing_buffer: Vec<(String, Vec<usize>)>,
386    precision: crate::precision::Precision,
387    serves_passes: bool,
388    prog: TapeBuild,
389    report: Rc<TapeBuildReport>,
390}
391
392impl TapeCache {
393    pub(crate) fn new() -> Self {
394        TapeCache {
395            entries: RefCell::new(Vec::new()),
396        }
397    }
398}
399
400impl ArrayCompiled {
401    /// The production tape for `discrete_forcing`, built on first use and then
402    /// served from [`TapeCache`]: see there for what a kept program depends on.
403    /// `Err` when a rule did not lower: there is then no program to run.
404    pub(crate) fn tape(
405        &self,
406        discrete_forcing: &HashSet<String>,
407    ) -> (TapeBuild, Rc<TapeBuildReport>) {
408        let mut forcing: Vec<String> = discrete_forcing.iter().cloned().collect();
409        forcing.sort();
410        let forcing_buffer = self.forcing_buffer_signature();
411        let precision = crate::precision::active();
412        let serves_passes = self.tape_serves_passes();
413        if let Some(e) = self.tape_cache.entries.borrow().iter().find(|e| {
414            e.discrete_forcing == forcing
415                && e.forcing_buffer == forcing_buffer
416                && e.precision == precision
417                && e.serves_passes == serves_passes
418        }) {
419            return (e.prog.clone(), Rc::clone(&e.report));
420        }
421        let (prog, report) = self.build_tape(discrete_forcing);
422        let (prog, report) = (
423            prog.map(std::sync::Arc::new).map_err(Rc::new),
424            Rc::new(report),
425        );
426        self.tape_cache.entries.borrow_mut().push(CachedTape {
427            discrete_forcing: forcing,
428            forcing_buffer,
429            precision,
430            serves_passes,
431            prog: prog.clone(),
432            report: Rc::clone(&report),
433        });
434        (prog, report)
435    }
436
437    /// The tape a STRICT compiler (`native`, `xla`) runs for
438    /// `discrete_forcing`. A build that declined a rule has no program, so
439    /// this is the strict compilers' refusal (`compiler_refused_rule`, naming
440    /// the first declined rule and its reason) — there is nothing else for
441    /// them to run. `esm_problem` raises the same refusal at construction,
442    /// with the component-qualified rule name, before any pass gets here.
443    pub(crate) fn strict_tape(
444        &self,
445        discrete_forcing: &HashSet<String>,
446    ) -> Result<std::sync::Arc<TapeProgram>, crate::simulate::SimulateError> {
447        let declined = match self.tape(discrete_forcing).0 {
448            Ok(prog) => return Ok(prog),
449            Err(declined) => declined,
450        };
451        let (k, reason) = declined
452            .reasons
453            .iter()
454            .enumerate()
455            .find_map(|(k, why)| why.clone().map(|why| (k, why)))
456            .expect("a declined tape names at least one rule");
457        let rule = &declined.rules[k];
458        Err(crate::simulate::SimulateError::Compile(
459            crate::compile_error::CompileError::CompilerRefusedRule {
460                compiler: if self.is_xla() { "xla" } else { "native" },
461                kind: rule.kind_name(),
462                rule: rule.name.clone(),
463                tier: rule.cadence_name(),
464                reason,
465            },
466        ))
467    }
468
469    /// Every entry the forcing buffer holds, as `(name, shape)` sorted by name:
470    /// the part of the buffer a tape build reads (see [`TapeCache`]).
471    fn forcing_buffer_signature(&self) -> Vec<(String, Vec<usize>)> {
472        let mut sig: Vec<(String, Vec<usize>)> = self
473            .forcing
474            .borrow()
475            .iter()
476            .map(|(name, a)| (name.clone(), a.shape().to_vec()))
477            .collect();
478        sig.sort();
479        sig
480    }
481
482    /// The forcing half of a build's inputs: every name the forcing buffer can
483    /// serve, with the box a read of it compiles against, and the refreshed
484    /// parameters it cannot.
485    ///
486    /// That is every entry the buffer holds now, at its own shape (the
487    /// interpreter's lookup resolves any name the buffer holds that nothing
488    /// else binds), then every externally refreshed parameter the buffer does
489    /// not hold yet, at its declared shape. A parameter whose declared shape
490    /// the registry cannot size, and which the buffer does not hold, is not
491    /// served: a read of it stays unresolved.
492    fn forcing_inputs<'a>(&self, discrete: &'a HashSet<String>) -> lower::ForcingInputs<'a> {
493        let mut shapes: rustc_hash::FxHashMap<String, super::DimU> = self
494            .forcing
495            .borrow()
496            .iter()
497            .map(|(name, a)| (name.clone(), a.shape().iter().copied().collect()))
498            .collect();
499        let mut unsized_ = HashSet::new();
500        for (name, decl) in &self.forcing_decls {
501            if shapes.contains_key(name) {
502                continue;
503            }
504            match decl {
505                Some(shape) => {
506                    shapes.insert(name.clone(), shape.iter().copied().collect());
507                }
508                None => {
509                    unsized_.insert(name.clone());
510                }
511            }
512        }
513        lower::ForcingInputs {
514            shapes,
515            unsized_,
516            discrete,
517        }
518    }
519
520    /// Build the tape program for this model, uncached. Production passes go
521    /// through [`Self::tape`]; this is the build itself, for the tests that
522    /// take a program apart.
523    pub(crate) fn build_tape(
524        &self,
525        discrete_forcing: &HashSet<String>,
526    ) -> (Result<TapeProgram, DeclinedTape>, TapeBuildReport) {
527        self.build_tape_opts(discrete_forcing, Some(fuse::SuperopCfg::from_env()))
528    }
529
530    /// [`Self::build_tape`] with the Step 4 fusion pass explicitly on/off
531    /// (the entry the fused-vs-unfused A/B tests drive).
532    pub(crate) fn build_tape_opts(
533        &self,
534        discrete_forcing: &HashSet<String>,
535        fuse: Option<fuse::SuperopCfg>,
536    ) -> (Result<TapeProgram, DeclinedTape>, TapeBuildReport) {
537        self.build_tape_layout(discrete_forcing, fuse, true)
538    }
539
540    /// [`Self::build_tape_opts`] with the state-layout alignment
541    /// ([`layout`]) explicitly on/off. The XLA emitter builds with it off: it
542    /// lowers logical boxes.
543    pub(crate) fn build_tape_layout(
544        &self,
545        discrete_forcing: &HashSet<String>,
546        fuse: Option<fuse::SuperopCfg>,
547        align_layout: bool,
548    ) -> (Result<TapeProgram, DeclinedTape>, TapeBuildReport) {
549        let const_names = self.classify_static_observeds(discrete_forcing);
550        let seg_names = self.classify_segment_invariant_observeds(discrete_forcing, true);
551        let forcing = self.forcing_inputs(discrete_forcing);
552        let (prog, vn_hits) = build_tape_program(
553            self,
554            &const_names,
555            &seg_names,
556            &forcing,
557            fuse,
558            align_layout,
559            None,
560        );
561        let report = match &prog {
562            Ok(prog) => make_report(prog, vn_hits),
563            Err(declined) => make_declined_report(declined, vn_hits),
564        };
565        (prog, report)
566    }
567
568    /// The tape of a range-restricted program (API_SPEC §5.8): `self` holds
569    /// the tile's clipped rules and storage-box state layout, `tile` the
570    /// domains its reads are classified against. Built with the same sections
571    /// and fusion as the whole-domain tape; `Err` names the declined rules.
572    ///
573    /// Every forcing name is discrete: a program reads its forcing from the
574    /// set each call names, so every forcing load, and every observed that
575    /// reads one, is in the SEGMENT section, re-run when the set changes.
576    pub(crate) fn build_tile_tape(&self, tile: &TileLowering) -> Result<TapeProgram, DeclinedTape> {
577        let discrete: HashSet<String> = self.forcing_decls.keys().cloned().collect();
578        let const_names = self.classify_static_observeds(&discrete);
579        let seg_names = self.classify_segment_invariant_observeds(&discrete, true);
580        let forcing = self.forcing_inputs(&discrete);
581        build_tape_program(
582            self,
583            &const_names,
584            &seg_names,
585            &forcing,
586            Some(fuse::SuperopCfg::from_env()),
587            true,
588            Some(tile),
589        )
590        .0
591    }
592
593    /// Build the tape and return the diagnostic report (see
594    /// [`TapeBuildReport`]). Evaluation is unchanged: this is a build-and-
595    /// discard entry for inspection tooling (`examples/tape_report.rs`).
596    pub fn debug_build_tape_report(&self) -> TapeBuildReport {
597        (*self.tape(&HashSet::new()).1).clone()
598    }
599
600    /// The program as text, one instruction per line with its section and
601    /// the box it defines, then the fused groups (`TAPE_LISTING=1` in
602    /// `examples/tape_report.rs`). Diagnostics only.
603    #[doc(hidden)]
604    pub fn debug_tape_listing(&self) -> String {
605        use std::fmt::Write;
606        let (prog, report) = self.tape(&HashSet::new());
607        let Ok(prog) = prog else {
608            return report.to_string();
609        };
610        let mut out = String::new();
611        for (pc, ins) in prog.instrs.iter().enumerate() {
612            let boxed = ins
613                .out()
614                .map(|o| {
615                    let d = &prog.slots[o as usize];
616                    format!(" -> s{o}{:?}", &d.shape[..])
617                })
618                .unwrap_or_default();
619            let _ = writeln!(out, "{pc:4} {:?} {ins:?}{boxed}", prog.section_of(pc));
620        }
621        for (i, fs) in prog.fused.iter().enumerate() {
622            let _ = writeln!(
623                out,
624                "fused {i}: box {:?}, {} micro-ops, {} inputs ({} shifted), {} scalars, {} outputs, {} runs ({} schedule nodes), reduce {:?}",
625                &fs.shape[..],
626                fs.micro.len(),
627                fs.inputs.len(),
628                fs.inputs.iter().filter(|x| x.shifted_ix.is_some()).count(),
629                fs.scalars.len(),
630                fs.outputs.len(),
631                fs.schedule.n_runs,
632                fs.schedule.nodes.len(),
633                fs.reduce
634            );
635            for (k, inp) in fs.inputs.iter().enumerate() {
636                let _ = writeln!(out, "  in {k}: {inp:?}");
637            }
638            for op in &fs.micro {
639                let _ = writeln!(out, "  {op:?}");
640            }
641        }
642        for (i, ls) in prog.lanes.iter().enumerate() {
643            let _ = writeln!(
644                out,
645                "lanes {i}: {} lanes, {} micro-ops, {} inputs, {} scalars, {} registers, {} writes",
646                ls.lanes,
647                ls.micro.len(),
648                ls.inputs.len(),
649                ls.scalars.len(),
650                ls.n_regs,
651                ls.writes.len()
652            );
653            for (k, inp) in ls.inputs.iter().enumerate() {
654                let _ = writeln!(out, "  in {k}: {:?} {:?}", inp.kind, inp.ix);
655            }
656            for op in &ls.micro {
657                let _ = writeln!(out, "  {op:?}");
658            }
659            for w in &ls.writes {
660                let _ = writeln!(out, "  write {:?} -> {:?}", w.src, w.dst);
661            }
662        }
663        out
664    }
665
666    /// Where every rule of this model LANDS, in program order — the per-rule
667    /// half of `compiler_report` (API_SPEC §5.8).
668    ///
669    /// [`TapeBuildReport::fallbacks`] answers only "which rules did not lower",
670    /// which is the wrong half for a caller asking what a compiler did: a
671    /// document with no fallbacks reports an empty list and says nothing about
672    /// the rules that DID lower, nor at which cadence they run. This walks the
673    /// program's whole rule table instead, so a taped rule is named too.
674    ///
675    /// Read off the same kept program the solve runs ([`Self::tape`]).
676    pub(crate) fn tape_rule_records(
677        &self,
678        discrete_forcing: &HashSet<String>,
679    ) -> (Vec<TapeRuleRecord>, TapeBuildReport) {
680        let (prog, report) = self.tape(discrete_forcing);
681        let report = (*report).clone();
682        let (rules, reasons): (&[RuleInfo], &[Option<String>]) = match &prog {
683            Ok(prog) => (&prog.rules, &[]),
684            Err(declined) => (&declined.rules, &declined.reasons),
685        };
686        let records = rules
687            .iter()
688            .enumerate()
689            .map(|(k, r)| TapeRuleRecord {
690                name: r.name.clone(),
691                kind: r.kind_name(),
692                cadence: r.cadence_name(),
693                fallback_reason: reasons.get(k).cloned().flatten(),
694            })
695            .collect();
696        (records, report)
697    }
698
699    /// Step 4 diagnostic: dump per-group shape statistics of the fused
700    /// program to stderr (`examples/fuse_stats.rs`).
701    pub fn debug_dump_fuse_stats(&self) {
702        let (prog, report) = self.tape(&HashSet::new());
703        eprintln!("{report}");
704        let Ok(prog) = prog else {
705            return;
706        };
707        let mut by_regs: Vec<&FusedSpec> = prog.fused.iter().collect();
708        by_regs.sort_by_key(|f| std::cmp::Reverse(f.n_regs));
709        let max_regs = by_regs.first().map(|f| f.n_regs).unwrap_or(0);
710        let tot_runs: usize = prog.fused.iter().map(|f| f.schedule.n_runs).sum();
711        let tot_micro: usize = prog.fused.iter().map(|f| f.micro.len()).sum();
712        let tot_inputs: usize = prog.fused.iter().map(|f| f.inputs.len()).sum();
713        let tot_outputs: usize = prog.fused.iter().map(|f| f.outputs.len()).sum();
714        let tot_elem_ops: usize = prog.fused.iter().map(|f| f.micro.len() * f.n_elems()).sum();
715        let tot_out_elems: usize = prog
716            .fused
717            .iter()
718            .map(|f| f.outputs.len() * f.n_elems())
719            .sum();
720        eprintln!(
721            "element-ops per call: {} micro x elems | output elems stored {}",
722            tot_elem_ops, tot_out_elems
723        );
724        eprintln!(
725            "fused groups: {} | micro ops {} | inputs {} | outputs {} | runs {} | max n_regs {}",
726            prog.fused.len(),
727            tot_micro,
728            tot_inputs,
729            tot_outputs,
730            tot_runs,
731            max_regs
732        );
733        eprintln!("micro-op histogram (element-weighted, post-superop):");
734        for (k, n) in prog.fuse_stats.micro_hist.iter().take(24) {
735            eprintln!("  {n:>10}  {k}");
736        }
737        eprintln!("single-use adjacency (element-weighted, pre-superop):");
738        for (k, n) in prog.fuse_stats.adj_hist.iter().take(24) {
739            eprintln!("  {n:>10}  {k}");
740        }
741        eprintln!("arith chain lengths (element-weighted, pre-superop):");
742        for (len, n) in prog.fuse_stats.chain_hist.iter() {
743            eprintln!("  {n:>10}  len={len}");
744        }
745        for f in by_regs.iter().take(12) {
746            eprintln!(
747                "  group: shape={:?} micro={} regs={} inputs={} (shifted {}) scalars={} \
748                 outputs={} runs={} folded_gathers={}",
749                &f.shape[..],
750                f.micro.len(),
751                f.n_regs,
752                f.inputs.len(),
753                f.inputs.iter().filter(|i| i.shifted_ix.is_some()).count(),
754                f.scalars.len(),
755                f.outputs.len(),
756                f.schedule.n_runs,
757                f.n_folded_gathers
758            );
759        }
760    }
761}