earthsci-ast 0.6.0

Rust implementation of EarthSciML Abstract Syntax Tree Format
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
//! Tape compilation and execution.
//!
//! Compiles a model's observed + RHS rule set into a flat instruction program
//! ([`TapeProgram`]) with build-time value numbering (subsuming the runtime
//! CSE overlay for taped rules), cadence-sectioned instruction streams
//! (CONST / SEGMENT / CONTINUOUS), liveness-based slab coloring, `dy` scatter
//! descriptors and observed exports (Step 3a), and executes it as the
//! DEFAULT production RHS hot path through the fast slab executor in
//! [`exec`] (Step 3b): a model builds the program once, keeps it
//! ([`TapeCache`]), and each segment's RHS scratch runs it. Which evaluator runs is the caller's
//! choice of compiler (API_SPEC §5.8) and nothing else: `native` is this
//! tape, `interpreter` is the per-cell oracle. Under `native` the tape also
//! serves the finite-difference Jacobian closure and the observed passes
//! (the build-time materialization of static observeds, the per-segment seed
//! and the observeds reported at output times; see `tape_serves_passes`).
//! `debug_eval_rhs` is the oracle and never runs the tape.
//!
//! ## Instruction set
//!
//! [`ir`] defines it and fixes each instruction's element semantics; the
//! short version is that everything is straight-line and shape-static.
//! Elementwise work is [`Instr::Bin`] / [`Instr::Un`] / [`Instr::Neg`] /
//! [`Instr::Select`] (and, after the Step 4 fusion pass, [`Instr::Fused`]);
//! data movement is [`Instr::Gather`] / [`Instr::LoadElem`] /
//! [`Instr::Copy`] / [`Instr::Region`] / [`Instr::Fill`] / [`Instr::Ramp`];
//! control flow is the one structured [`Instr::JmpIfZero`]; and the
//! boundaries with the rest of the runtime are [`Instr::Export`],
//! [`Instr::DyWrite`] and [`Instr::LoadForcing`] (the forcing buffer, read in
//! the CONST or SEGMENT section).
//!
//! There is no instruction that hands a rule to the per-cell oracle. A
//! program exists only when every rule lowered; a build with a rule the tape
//! cannot express yields a [`DeclinedTape`] instead, which names each
//! declined rule and its reason and holds nothing to execute. So `native`,
//! which runs nothing but this tape, cannot reach per-cell evaluation: the
//! per-cell code lives only in the interpreter (`evaluate_rhs_legacy`, the
//! observed passes), which the historical routing runs for a model whose tape
//! declined.
//!
//! Two instructions carry data or a fold rather than a per-element map, and
//! both exist so a whole downstream cone of rules stops declining:
//!
//! * [`Instr::ConstArray`] materializes an inline array literal into a
//!   CONST-section slot — one instruction and one store per solve, whatever
//!   the literal's size, with the payload on
//!   [`TapeProgram::const_data`]. Without it an array-valued `const` had no
//!   representation at all (`Operand::Lit` is one `f64`; `Fill` broadcasts
//!   one scalar), so every reader of such a constant bailed.
//! * [`Instr::Reduce`] folds a source box over a set of axes with a binary
//!   kernel, visiting the source in ROW-MAJOR order — which is the per-cell
//!   oracle's contraction odometer, so a rank-0 `faq` (every index
//!   contracted, scalar result) folds the same terms in the same
//!   association the interpreter would. It is the one construct the tape
//!   lowers that the whole-array overlay declines outright.
//!
//! A rule that reads an observed whose own rule declined declines too, at
//! the read: there is no operand for a value that is not on the tape, so the
//! decline list names the producer and every rule that reads it.
//!
//! ## Closed functions
//!
//! A fourth piece is likewise not an instruction. The esm-spec §9.2
//! `datetime.*` family — `year`, `month`, `day`, `hour`, `minute`, `second`,
//! `day_of_year`, `julian_day`, `is_leap_year` — is expanded at LOWERING time
//! into the arithmetic above (one floored divmod by 86400, then floors,
//! remainders, comparisons and selects on integer-valued `f64`s), so all
//! three executors gained it at once and none of them can drift from
//! another. The `interp.*` entries of the same registry are not lowered: they
//! read a table, and a rule that calls one declines, naming the function.

#[cfg(test)]
mod array_tests;
#[cfg(test)]
mod data_gather_tests;
mod exec;
#[cfg(test)]
mod forcing_tests;
mod fuse;
mod geom;
mod ir;
mod layout;
mod lower;
#[cfg(test)]
mod lowering_limit_tests;
mod prune;
#[cfg(test)]
mod refexec;
mod reroll;
mod scan_shift;
mod sink;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tuple_tests;
// Phase 2: the XLA emitter over this IR (feature `xla`, OFF by default). Last
// in the list because it is the only optional one.
#[cfg(feature = "xla")]
pub mod xla_emit;

#[cfg(test)]
pub(crate) use exec::section_primes;
pub(in crate::simulate_array) use exec::{
    CallInputs, ForcingView, TapeCtx, run_tape_call, run_tape_inputs,
};
pub(crate) use ir::*;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use lower::StandaloneError;
use lower::build_tape_program;
pub(crate) use lower::{TileAxisDom, TileLowering, TileObserved, TilePiece};
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::simulate_array) use lower::{collect_variable_names, eval_standalone_rule};

use super::ArrayCompiled;
use std::cell::RefCell;
use std::collections::HashSet;
use std::fmt;
use std::rc::Rc;

/// Human-readable summary of one tape build. Public so external diagnostics
/// (the `tape_report` example) can print it; the program itself stays
/// crate-internal.
#[derive(Debug, Clone, Default)]
pub struct TapeBuildReport {
    /// Total rules considered (observed + RHS).
    pub n_rules: usize,
    /// Rules fully lowered onto the tape.
    pub n_taped: usize,
    /// `(rule name, deepest bail reason)` for every rule that did not lower.
    /// Non-empty means the build produced no program at all (the instruction
    /// counts below are then zero): the model runs on the interpreter under
    /// the historical routing, and a strict compiler refuses it.
    pub fallbacks: Vec<(String, String)>,
    pub n_instr_const: usize,
    pub n_instr_segment: usize,
    pub n_instr_continuous: usize,
    /// Instruction counts per opcode, whole program, descending.
    pub opcode_counts: Vec<(String, usize)>,
    pub n_slots: usize,
    pub n_storages: usize,
    pub n_gather_plans: usize,
    pub n_exports: usize,
    pub n_dy_writes: usize,
    /// Total slab size in bytes (all storages, `f64`).
    pub slab_bytes: usize,
    /// Dedicated (cross-section-live) CONST storage bytes.
    pub slab_bytes_const: usize,
    /// Dedicated SEGMENT storage bytes.
    pub slab_bytes_segment: usize,
    /// Recycled (within-section) storage bytes.
    pub slab_bytes_recycled: usize,
    /// Build-time value-numbering hits served from the scope-local memo.
    pub vn_scope_hits: usize,
    /// Hits served from the cross-rule (box-pure) hoist map.
    pub vn_hoist_hits: usize,
    /// Step 4: kernel-fusion diagnostics (all-zero when fusion is disabled).
    pub fuse: FuseStats,
}

impl fmt::Display for TapeBuildReport {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        writeln!(f, "tape build report")?;
        writeln!(
            f,
            "  rules: {} taped / {} declined (of {})",
            self.n_taped,
            self.fallbacks.len(),
            self.n_rules
        )?;
        for (name, reason) in &self.fallbacks {
            writeln!(f, "    declined {name}: {reason}")?;
        }
        if !self.fallbacks.is_empty() {
            return writeln!(f, "  no program: a declined rule leaves no tape");
        }
        writeln!(
            f,
            "  instructions: {} CONST + {} SEGMENT + {} CONTINUOUS = {}",
            self.n_instr_const,
            self.n_instr_segment,
            self.n_instr_continuous,
            self.n_instr_const + self.n_instr_segment + self.n_instr_continuous
        )?;
        write!(f, "  opcodes:")?;
        for (op, n) in &self.opcode_counts {
            write!(f, " {op}={n}")?;
        }
        writeln!(f)?;
        writeln!(
            f,
            "  slots: {} ({} storages, {} gather plans, {} dy writes, {} exports)",
            self.n_slots, self.n_storages, self.n_gather_plans, self.n_dy_writes, self.n_exports
        )?;
        writeln!(
            f,
            "  slab: {:.2} MiB total ({:.2} MiB CONST + {:.2} MiB SEGMENT dedicated, {:.2} MiB recycled)",
            self.slab_bytes as f64 / (1024.0 * 1024.0),
            self.slab_bytes_const as f64 / (1024.0 * 1024.0),
            self.slab_bytes_segment as f64 / (1024.0 * 1024.0),
            self.slab_bytes_recycled as f64 / (1024.0 * 1024.0),
        )?;
        writeln!(
            f,
            "  value numbering: {} scope hits, {} hoist hits",
            self.vn_scope_hits, self.vn_hoist_hits
        )?;
        if self.fuse.n_groups > 0 {
            writeln!(
                f,
                "  fusion: {} -> {} instructions; {} groups absorbing {} \
                 ({} gathers folded, {} kept, {} reductions folded); group sizes \
                 [2-3]={} [4-7]={} [8-15]={} [16-31]={} [32-63]={} [64+]={}",
                self.fuse.instrs_before,
                self.fuse.instrs_after,
                self.fuse.n_groups,
                self.fuse.n_member_instrs,
                self.fuse.n_gathers_folded,
                self.fuse.n_gathers_kept,
                self.fuse.n_reduces_folded,
                self.fuse.group_size_hist[0],
                self.fuse.group_size_hist[1],
                self.fuse.group_size_hist[2],
                self.fuse.group_size_hist[3],
                self.fuse.group_size_hist[4],
                self.fuse.group_size_hist[5],
            )?;
        }
        Ok(())
    }
}

/// Where ONE rule of a model landed, for `compiler_report` (API_SPEC §5.8).
///
/// Deliberately plain data rather than a borrow of the program: the record
/// lives on the Problem, apart from the program the model keeps.
#[derive(Clone, Debug)]
pub(crate) struct TapeRuleRecord {
    /// The rule's variable name, as the compiled model spells it (the caller
    /// qualifies it with the component).
    pub name: String,
    /// `"observed"` or `"state derivative"`.
    pub kind: &'static str,
    /// The cadence tier the rule runs at: `"const"` (once per solve, at
    /// setup), `"segment"` (once per forcing-refresh segment) or
    /// `"continuous"` (every right-hand-side call).
    pub cadence: &'static str,
    /// `None` when the rule lowered onto the tape; otherwise the DEEPEST
    /// decline reason reached while trying (and the model has no tape).
    pub fallback_reason: Option<String>,
}

/// Assemble the report from a finished program.
pub(crate) fn make_report(prog: &TapeProgram, vn_hits: (usize, usize)) -> TapeBuildReport {
    let mut opcode_counts: std::collections::HashMap<&'static str, usize> =
        std::collections::HashMap::new();
    for i in &prog.instrs {
        *opcode_counts.entry(i.opcode()).or_insert(0) += 1;
    }
    let mut opcode_counts: Vec<(String, usize)> = opcode_counts
        .into_iter()
        .map(|(k, v)| (k.to_string(), v))
        .collect();
    opcode_counts.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
    let n_cont = prog.instrs.len() - (prog.n_const + prog.n_segment) as usize;
    TapeBuildReport {
        n_rules: prog.rules.len(),
        n_taped: prog.rules.len(),
        fallbacks: Vec::new(),
        n_instr_const: prog.n_const as usize,
        n_instr_segment: prog.n_segment as usize,
        n_instr_continuous: n_cont,
        opcode_counts,
        n_slots: prog.slots.len(),
        n_storages: prog.slab.storages.len(),
        n_gather_plans: prog.plans.len(),
        n_exports: prog.exports.len(),
        n_dy_writes: prog.dy_writes.len(),
        slab_bytes: prog.slab.total_elems * 8,
        slab_bytes_const: prog.slab.const_elems * 8,
        slab_bytes_segment: prog.slab.segment_elems * 8,
        slab_bytes_recycled: prog.slab.recycled_elems * 8,
        vn_scope_hits: vn_hits.0,
        vn_hoist_hits: vn_hits.1,
        fuse: prog.fuse_stats.clone(),
    }
}

/// The report of a build that declined: the rule counts and the declined
/// rules, and nothing about a program, because there is none.
pub(crate) fn make_declined_report(
    declined: &DeclinedTape,
    vn_hits: (usize, usize),
) -> TapeBuildReport {
    let fallbacks = declined.declined_rules();
    TapeBuildReport {
        n_rules: declined.rules.len(),
        n_taped: declined.rules.len() - fallbacks.len(),
        fallbacks,
        vn_scope_hits: vn_hits.0,
        vn_hoist_hits: vn_hits.1,
        ..TapeBuildReport::default()
    }
}

impl DeclinedTape {
    /// `(rule name, reason)` for every rule that did not lower, in rule order.
    pub(crate) fn declined_rules(&self) -> Vec<(String, String)> {
        self.rules
            .iter()
            .zip(&self.reasons)
            .filter_map(|(r, why)| why.as_ref().map(|why| (r.name.clone(), why.clone())))
            .collect()
    }
}

impl RuleInfo {
    /// `"observed"` or `"state derivative"`, as the compiler report spells it.
    pub(crate) fn kind_name(&self) -> &'static str {
        match self.kind {
            RuleKind::Observed(_) => "observed",
            RuleKind::Rhs(_) => "state derivative",
        }
    }

    /// The cadence tier's name in the compiler report.
    pub(crate) fn cadence_name(&self) -> &'static str {
        match self.cadence {
            Cadence::Const => "const",
            Cadence::Segment => "segment",
            Cadence::Continuous => "continuous",
        }
    }
}

/// Test seam: the program of a build every rule of which lowered, panicking
/// with the build report when one declined.
#[cfg(test)]
pub(crate) fn taped(
    (prog, report): (Result<TapeProgram, DeclinedTape>, TapeBuildReport),
) -> (TapeProgram, TapeBuildReport) {
    match prog {
        Ok(prog) => (prog, report),
        Err(_) => panic!("the tape declined a rule:\n{report}"),
    }
}

/// What a model's tape build produced: the program, or — when any rule did
/// not lower — the [`DeclinedTape`] in its place. Either way the build's
/// report rides beside it.
pub(crate) type TapeBuild = Result<std::sync::Arc<TapeProgram>, Rc<DeclinedTape>>;

/// A tape program this model built, with its report, kept so that every pass
/// that runs the tape — the construction-time gate, a state-free evaluation,
/// each solve, the diagnostic seams — runs the one program instead of
/// building its own.
///
/// What a build reads, and so what invalidates a kept program:
///
/// * the compiled model itself — its rules, state layout, parameter NAMES and
///   const-array registry. These are fixed when the model is constructed and
///   nothing mutates them through `&self`; a changed document is a new model,
///   with an empty cache.
/// * the DISCRETE-forcing set, which decides each observed's cadence tier and
///   the section each forcing load runs in;
/// * the NAMES and SHAPES the forcing buffer holds, which decide which reads
///   are forcing loads and the box each one compiles against (a name the
///   buffer does not hold compiles against its declared shape);
/// * the precision in force (`crate::precision::active`), which rounds every
///   literal the build folds;
/// * whether the tape serves the observed passes (the runtime mode), which
///   decides what it exports.
///
/// The last four are the key an entry is looked up by. Parameter VALUES and
/// the forcing buffer's VALUES are not inputs: the program reads parameters
/// on every call and loads the forcing buffer in its CONST and SEGMENT
/// sections. So a new parameter vector, or new forcing data, needs no rebuild:
/// those sections are per-scratch state, re-run when the parameter vector
/// changes, and the SEGMENT section again when the driver bumps the forcing
/// epoch after a refresh (`TapeCtx::bump_forcing_epoch`).
pub(crate) struct TapeCache {
    entries: RefCell<Vec<CachedTape>>,
}

struct CachedTape {
    discrete_forcing: Vec<String>,
    forcing_buffer: Vec<(String, Vec<usize>)>,
    precision: crate::precision::Precision,
    serves_passes: bool,
    prog: TapeBuild,
    report: Rc<TapeBuildReport>,
}

impl TapeCache {
    pub(crate) fn new() -> Self {
        TapeCache {
            entries: RefCell::new(Vec::new()),
        }
    }
}

impl ArrayCompiled {
    /// The production tape for `discrete_forcing`, built on first use and then
    /// served from [`TapeCache`]: see there for what a kept program depends on.
    /// `Err` when a rule did not lower: there is then no program to run.
    pub(crate) fn tape(
        &self,
        discrete_forcing: &HashSet<String>,
    ) -> (TapeBuild, Rc<TapeBuildReport>) {
        let mut forcing: Vec<String> = discrete_forcing.iter().cloned().collect();
        forcing.sort();
        let forcing_buffer = self.forcing_buffer_signature();
        let precision = crate::precision::active();
        let serves_passes = self.tape_serves_passes();
        if let Some(e) = self.tape_cache.entries.borrow().iter().find(|e| {
            e.discrete_forcing == forcing
                && e.forcing_buffer == forcing_buffer
                && e.precision == precision
                && e.serves_passes == serves_passes
        }) {
            return (e.prog.clone(), Rc::clone(&e.report));
        }
        let (prog, report) = self.build_tape(discrete_forcing);
        let (prog, report) = (
            prog.map(std::sync::Arc::new).map_err(Rc::new),
            Rc::new(report),
        );
        self.tape_cache.entries.borrow_mut().push(CachedTape {
            discrete_forcing: forcing,
            forcing_buffer,
            precision,
            serves_passes,
            prog: prog.clone(),
            report: Rc::clone(&report),
        });
        (prog, report)
    }

    /// The tape a STRICT compiler (`native`, `xla`) runs for
    /// `discrete_forcing`. A build that declined a rule has no program, so
    /// this is the strict compilers' refusal (`compiler_refused_rule`, naming
    /// the first declined rule and its reason) — there is nothing else for
    /// them to run. `esm_problem` raises the same refusal at construction,
    /// with the component-qualified rule name, before any pass gets here.
    pub(crate) fn strict_tape(
        &self,
        discrete_forcing: &HashSet<String>,
    ) -> Result<std::sync::Arc<TapeProgram>, crate::simulate::SimulateError> {
        let declined = match self.tape(discrete_forcing).0 {
            Ok(prog) => return Ok(prog),
            Err(declined) => declined,
        };
        let (k, reason) = declined
            .reasons
            .iter()
            .enumerate()
            .find_map(|(k, why)| why.clone().map(|why| (k, why)))
            .expect("a declined tape names at least one rule");
        let rule = &declined.rules[k];
        Err(crate::simulate::SimulateError::Compile(
            crate::compile_error::CompileError::CompilerRefusedRule {
                compiler: if self.is_xla() { "xla" } else { "native" },
                kind: rule.kind_name(),
                rule: rule.name.clone(),
                tier: rule.cadence_name(),
                reason,
            },
        ))
    }

    /// Every entry the forcing buffer holds, as `(name, shape)` sorted by name:
    /// the part of the buffer a tape build reads (see [`TapeCache`]).
    fn forcing_buffer_signature(&self) -> Vec<(String, Vec<usize>)> {
        let mut sig: Vec<(String, Vec<usize>)> = self
            .forcing
            .borrow()
            .iter()
            .map(|(name, a)| (name.clone(), a.shape().to_vec()))
            .collect();
        sig.sort();
        sig
    }

    /// The forcing half of a build's inputs: every name the forcing buffer can
    /// serve, with the box a read of it compiles against, and the refreshed
    /// parameters it cannot.
    ///
    /// That is every entry the buffer holds now, at its own shape (the
    /// interpreter's lookup resolves any name the buffer holds that nothing
    /// else binds), then every externally refreshed parameter the buffer does
    /// not hold yet, at its declared shape. A parameter whose declared shape
    /// the registry cannot size, and which the buffer does not hold, is not
    /// served: a read of it stays unresolved.
    fn forcing_inputs<'a>(&self, discrete: &'a HashSet<String>) -> lower::ForcingInputs<'a> {
        let mut shapes: rustc_hash::FxHashMap<String, super::DimU> = self
            .forcing
            .borrow()
            .iter()
            .map(|(name, a)| (name.clone(), a.shape().iter().copied().collect()))
            .collect();
        let mut unsized_ = HashSet::new();
        for (name, decl) in &self.forcing_decls {
            if shapes.contains_key(name) {
                continue;
            }
            match decl {
                Some(shape) => {
                    shapes.insert(name.clone(), shape.iter().copied().collect());
                }
                None => {
                    unsized_.insert(name.clone());
                }
            }
        }
        lower::ForcingInputs {
            shapes,
            unsized_,
            discrete,
        }
    }

    /// Build the tape program for this model, uncached. Production passes go
    /// through [`Self::tape`]; this is the build itself, for the tests that
    /// take a program apart.
    pub(crate) fn build_tape(
        &self,
        discrete_forcing: &HashSet<String>,
    ) -> (Result<TapeProgram, DeclinedTape>, TapeBuildReport) {
        self.build_tape_opts(discrete_forcing, Some(fuse::SuperopCfg::from_env()))
    }

    /// [`Self::build_tape`] with the Step 4 fusion pass explicitly on/off
    /// (the entry the fused-vs-unfused A/B tests drive).
    pub(crate) fn build_tape_opts(
        &self,
        discrete_forcing: &HashSet<String>,
        fuse: Option<fuse::SuperopCfg>,
    ) -> (Result<TapeProgram, DeclinedTape>, TapeBuildReport) {
        self.build_tape_layout(discrete_forcing, fuse, true)
    }

    /// [`Self::build_tape_opts`] with the state-layout alignment
    /// ([`layout`]) explicitly on/off. The XLA emitter builds with it off: it
    /// lowers logical boxes.
    pub(crate) fn build_tape_layout(
        &self,
        discrete_forcing: &HashSet<String>,
        fuse: Option<fuse::SuperopCfg>,
        align_layout: bool,
    ) -> (Result<TapeProgram, DeclinedTape>, TapeBuildReport) {
        let const_names = self.classify_static_observeds(discrete_forcing);
        let seg_names = self.classify_segment_invariant_observeds(discrete_forcing, true);
        let forcing = self.forcing_inputs(discrete_forcing);
        let (prog, vn_hits) = build_tape_program(
            self,
            &const_names,
            &seg_names,
            &forcing,
            fuse,
            align_layout,
            None,
        );
        let report = match &prog {
            Ok(prog) => make_report(prog, vn_hits),
            Err(declined) => make_declined_report(declined, vn_hits),
        };
        (prog, report)
    }

    /// The tape of a range-restricted program (API_SPEC §5.8): `self` holds
    /// the tile's clipped rules and storage-box state layout, `tile` the
    /// domains its reads are classified against. Built with the same sections
    /// and fusion as the whole-domain tape; `Err` names the declined rules.
    ///
    /// Every forcing name is discrete: a program reads its forcing from the
    /// set each call names, so every forcing load, and every observed that
    /// reads one, is in the SEGMENT section, re-run when the set changes.
    pub(crate) fn build_tile_tape(&self, tile: &TileLowering) -> Result<TapeProgram, DeclinedTape> {
        let discrete: HashSet<String> = self.forcing_decls.keys().cloned().collect();
        let const_names = self.classify_static_observeds(&discrete);
        let seg_names = self.classify_segment_invariant_observeds(&discrete, true);
        let forcing = self.forcing_inputs(&discrete);
        build_tape_program(
            self,
            &const_names,
            &seg_names,
            &forcing,
            Some(fuse::SuperopCfg::from_env()),
            true,
            Some(tile),
        )
        .0
    }

    /// Build the tape and return the diagnostic report (see
    /// [`TapeBuildReport`]). Evaluation is unchanged: this is a build-and-
    /// discard entry for inspection tooling (`examples/tape_report.rs`).
    pub fn debug_build_tape_report(&self) -> TapeBuildReport {
        (*self.tape(&HashSet::new()).1).clone()
    }

    /// The program as text, one instruction per line with its section and
    /// the box it defines, then the fused groups (`TAPE_LISTING=1` in
    /// `examples/tape_report.rs`). Diagnostics only.
    #[doc(hidden)]
    pub fn debug_tape_listing(&self) -> String {
        use std::fmt::Write;
        let (prog, report) = self.tape(&HashSet::new());
        let Ok(prog) = prog else {
            return report.to_string();
        };
        let mut out = String::new();
        for (pc, ins) in prog.instrs.iter().enumerate() {
            let boxed = ins
                .out()
                .map(|o| {
                    let d = &prog.slots[o as usize];
                    format!(" -> s{o}{:?}", &d.shape[..])
                })
                .unwrap_or_default();
            let _ = writeln!(out, "{pc:4} {:?} {ins:?}{boxed}", prog.section_of(pc));
        }
        for (i, fs) in prog.fused.iter().enumerate() {
            let _ = writeln!(
                out,
                "fused {i}: box {:?}, {} micro-ops, {} inputs ({} shifted), {} scalars, {} outputs, {} runs ({} schedule nodes), reduce {:?}",
                &fs.shape[..],
                fs.micro.len(),
                fs.inputs.len(),
                fs.inputs.iter().filter(|x| x.shifted_ix.is_some()).count(),
                fs.scalars.len(),
                fs.outputs.len(),
                fs.schedule.n_runs,
                fs.schedule.nodes.len(),
                fs.reduce
            );
            for (k, inp) in fs.inputs.iter().enumerate() {
                let _ = writeln!(out, "  in {k}: {inp:?}");
            }
            for op in &fs.micro {
                let _ = writeln!(out, "  {op:?}");
            }
        }
        for (i, ls) in prog.lanes.iter().enumerate() {
            let _ = writeln!(
                out,
                "lanes {i}: {} lanes, {} micro-ops, {} inputs, {} scalars, {} registers, {} writes",
                ls.lanes,
                ls.micro.len(),
                ls.inputs.len(),
                ls.scalars.len(),
                ls.n_regs,
                ls.writes.len()
            );
            for (k, inp) in ls.inputs.iter().enumerate() {
                let _ = writeln!(out, "  in {k}: {:?} {:?}", inp.kind, inp.ix);
            }
            for op in &ls.micro {
                let _ = writeln!(out, "  {op:?}");
            }
            for w in &ls.writes {
                let _ = writeln!(out, "  write {:?} -> {:?}", w.src, w.dst);
            }
        }
        out
    }

    /// Where every rule of this model LANDS, in program order — the per-rule
    /// half of `compiler_report` (API_SPEC §5.8).
    ///
    /// [`TapeBuildReport::fallbacks`] answers only "which rules did not lower",
    /// which is the wrong half for a caller asking what a compiler did: a
    /// document with no fallbacks reports an empty list and says nothing about
    /// the rules that DID lower, nor at which cadence they run. This walks the
    /// program's whole rule table instead, so a taped rule is named too.
    ///
    /// Read off the same kept program the solve runs ([`Self::tape`]).
    pub(crate) fn tape_rule_records(
        &self,
        discrete_forcing: &HashSet<String>,
    ) -> (Vec<TapeRuleRecord>, TapeBuildReport) {
        let (prog, report) = self.tape(discrete_forcing);
        let report = (*report).clone();
        let (rules, reasons): (&[RuleInfo], &[Option<String>]) = match &prog {
            Ok(prog) => (&prog.rules, &[]),
            Err(declined) => (&declined.rules, &declined.reasons),
        };
        let records = rules
            .iter()
            .enumerate()
            .map(|(k, r)| TapeRuleRecord {
                name: r.name.clone(),
                kind: r.kind_name(),
                cadence: r.cadence_name(),
                fallback_reason: reasons.get(k).cloned().flatten(),
            })
            .collect();
        (records, report)
    }

    /// Step 4 diagnostic: dump per-group shape statistics of the fused
    /// program to stderr (`examples/fuse_stats.rs`).
    pub fn debug_dump_fuse_stats(&self) {
        let (prog, report) = self.tape(&HashSet::new());
        eprintln!("{report}");
        let Ok(prog) = prog else {
            return;
        };
        let mut by_regs: Vec<&FusedSpec> = prog.fused.iter().collect();
        by_regs.sort_by_key(|f| std::cmp::Reverse(f.n_regs));
        let max_regs = by_regs.first().map(|f| f.n_regs).unwrap_or(0);
        let tot_runs: usize = prog.fused.iter().map(|f| f.schedule.n_runs).sum();
        let tot_micro: usize = prog.fused.iter().map(|f| f.micro.len()).sum();
        let tot_inputs: usize = prog.fused.iter().map(|f| f.inputs.len()).sum();
        let tot_outputs: usize = prog.fused.iter().map(|f| f.outputs.len()).sum();
        let tot_elem_ops: usize = prog.fused.iter().map(|f| f.micro.len() * f.n_elems()).sum();
        let tot_out_elems: usize = prog
            .fused
            .iter()
            .map(|f| f.outputs.len() * f.n_elems())
            .sum();
        eprintln!(
            "element-ops per call: {} micro x elems | output elems stored {}",
            tot_elem_ops, tot_out_elems
        );
        eprintln!(
            "fused groups: {} | micro ops {} | inputs {} | outputs {} | runs {} | max n_regs {}",
            prog.fused.len(),
            tot_micro,
            tot_inputs,
            tot_outputs,
            tot_runs,
            max_regs
        );
        eprintln!("micro-op histogram (element-weighted, post-superop):");
        for (k, n) in prog.fuse_stats.micro_hist.iter().take(24) {
            eprintln!("  {n:>10}  {k}");
        }
        eprintln!("single-use adjacency (element-weighted, pre-superop):");
        for (k, n) in prog.fuse_stats.adj_hist.iter().take(24) {
            eprintln!("  {n:>10}  {k}");
        }
        eprintln!("arith chain lengths (element-weighted, pre-superop):");
        for (len, n) in prog.fuse_stats.chain_hist.iter() {
            eprintln!("  {n:>10}  len={len}");
        }
        for f in by_regs.iter().take(12) {
            eprintln!(
                "  group: shape={:?} micro={} regs={} inputs={} (shifted {}) scalars={} \
                 outputs={} runs={} folded_gathers={}",
                &f.shape[..],
                f.micro.len(),
                f.n_regs,
                f.inputs.len(),
                f.inputs.iter().filter(|i| i.shifted_ix.is_some()).count(),
                f.scalars.len(),
                f.outputs.len(),
                f.schedule.n_runs,
                f.n_folded_gathers
            );
        }
    }
}