Skip to main content

logicaffeine_compile/codegen_sva/
plasticine_report.rs

1//! The **Plasticine report** — P4 of the Plasticine lane: the artifact an RDU engineer reads, generated from
2//! the model so no number in it is typed by hand.
3//!
4//! One row per (workload, target): PCU/PMU used vs total, FU utilization, the initiation interval of every
5//! metapipeline stage with the critical stage flagged, the pipeline II, N-buffer depths and PMU counts, hop
6//! counts and static-routing occupancy against the Spade link budget, iterations, fill + steady-state
7//! cycles, the DRAM roofline (bytes moved, channel bandwidth, the bound), throughput at the II, and for
8//! GEMM GFLOPS and % of the published fp32 peak. A counted-loop row also carries its VM anchor (the VM's
9//! answer == the fabric's == the cycle-timed pipelined run at the modeled II, hazards one cycle below) and,
10//! for an ALU-only body, the re-checkable SAT certificate of its routed datapath.
11//!
12//! Provenance is `modeled` on every row: the numbers are the paper-parameterized descriptors' ([`PlasticineTarget`])
13//! under Spatial's II definition ([`super::metapipeline`]), the PMU banking model ([`super::pmu`]) and the
14//! checkerboard placer ([`super::plasticine_place`]). Nothing here ran on a Plasticine simulator or silicon;
15//! a Tungsten run lands beside these rows under its own provenance (P5).
16//!
17//! `benchmarks/silicon/plasticine_report.json` is written ONLY by the private place-and-route engine's report
18//! emitter (`emit_plasticine_report`), which installs the engine's certified negotiated router before placing
19//! anything, and pinned byte-equal by `plasticine_report_lock`.
20
21use std::panic::{catch_unwind, AssertUnwindSafe};
22
23use serde_json::{json, Value};
24
25use super::choreography::{synthesize, Choreography};
26use super::fabric_grid::route_loop_body;
27use super::fabric_rtl::{
28    eval_loop_fabric, loop_fabric_from_source, FabricOp, LoopCell, LoopFabric, Src,
29};
30use super::fusion::{share_operand, OperandStream, Sharing};
31use super::metapipeline::{eval_loop_fabric_at_ii, loop_stage, Metapipeline, Stage, UnitModel};
32use super::plasticine::PlasticineTarget;
33use super::plasticine_place::{
34    max_par, max_routable_par_within, place_unrolled, words_per_pmu, LinkBudget, Placement,
35};
36use super::pmu::{address_datapath_stages, memory_ii, strided_banking_for, AffineAccess, PmuSpec};
37use super::pnr_certificate::{certify_external_placement, PlacementCertificate};
38use super::spatial_emit::{
39    emit_spatial_counted_loop, emit_spatial_counted_loop_test, emit_spatial_gda_test, emit_spatial_gemm_test,
40    emit_spatial_kmeans_test, emit_spatial_outer_test, emit_spatial_zip_map_test, emit_spatial_zip_sum_test, GdaColumn,
41    GdaPar, KmeansPar, MapColumn, MapPar, OuterPar, ZipColumn, ZipPar,
42};
43use crate::tensor_recognize::{Axis, ZipOp, ZipSum, ZipTerm};
44use crate::compile::vm_outcome;
45
46/// The provenance every row carries: a paper-parameterized model, never a simulation or silicon.
47pub const PROVENANCE_MODELED: &str = "modeled";
48/// Where the generated report lives (repo-relative).
49pub const REPORT_PATH: &str = "benchmarks/silicon/plasticine_report.json";
50/// The bit width the routed-datapath certificate is proven at (the bounded fragment).
51pub const CERTIFICATE_WIDTH: u32 = 4;
52/// The public toolchain's ledger (written only by `scripts/accel/plasticine-gate-ledger.py`): Tungsten simulations.
53pub const TOOLCHAIN_LEDGER_PATH: &str = "benchmarks/silicon/plasticine_toolchain.json";
54/// The calibration file joining each toolchain row with our MODELED counterpart (generated with the report).
55pub const CALIBRATION_PATH: &str = "benchmarks/silicon/plasticine_calibration.json";
56/// The provenance of a toolchain-simulated row.
57pub const PROVENANCE_SIMULATED: &str = "simulated_tungsten";
58
59/// What a workload is.
60#[derive(Clone, Debug, PartialEq)]
61pub enum WorkloadKind {
62    /// A LOGOS counted-loop function `fn_name(params…) -> Int` in `source`, lowered through
63    /// [`loop_fabric_from_source`] and modeled as one metapipeline stage.
64    CountedLoop {
65        source: String,
66        fn_name: String,
67        params: Vec<i64>,
68    },
69    /// A tiled fp32 GEMM `C[m×n] = A[m×k] · B[k×n]`: `tile` (None = the largest square tile whose
70    /// double-buffered A+B panel fits one PMU) and `par` (None = the widest unroll that fits the mesh).
71    Gemm {
72        m: usize,
73        n: usize,
74        k: usize,
75        tile: Option<usize>,
76        par: Option<usize>,
77    },
78}
79
80#[derive(Clone, Debug, PartialEq)]
81pub struct Workload {
82    pub name: String,
83    pub kind: WorkloadKind,
84}
85
86#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
87pub struct StageRow {
88    pub name: String,
89    /// `pcu` (a PCU chain) or `ag` (a perimeter address generator: no PCU).
90    pub host: String,
91    pub resource_ii: usize,
92    pub recurrence_ii: usize,
93    pub memory_ii: usize,
94    pub ii: usize,
95    pub latency: usize,
96    pub cells: usize,
97    pub pcus: usize,
98    pub critical: bool,
99}
100
101#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
102pub struct BufferRow {
103    pub writer: usize,
104    pub reader: usize,
105    pub depth: usize,
106    pub words: usize,
107    pub pmus: usize,
108}
109
110/// One operand of a GEMM and how the compiler decided to feed the unrolled instances.
111#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
112pub struct OperandRow {
113    pub name: String,
114    /// `replicated` or `broadcast`.
115    pub sharing: String,
116    pub consumers: usize,
117    pub bytes_per_copy: usize,
118    /// Copies actually fetched from memory.
119    pub copies: usize,
120    pub bytes_moved: usize,
121    pub dram_cycles_saved: usize,
122    pub fabric_cycles_added: usize,
123    pub why: String,
124}
125
126/// One channel's control plane: the credit solve beside Spatial's N-buffer rule, and which one binds.
127#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
128pub struct ChannelRow {
129    pub writer: usize,
130    pub reader: usize,
131    pub hops: usize,
132    pub forward_latency: usize,
133    pub backward_latency: usize,
134    /// Spatial's published writer-to-reader rule.
135    pub n_buffer_depth: usize,
136    /// The bandwidth-delay solve: `ceil((forward + backward) / II)`.
137    pub credit_depth: usize,
138    /// What ships: the deeper requirement.
139    pub depth: usize,
140    /// `credits` when the rate solve binds, `n_buffer` when the liveness rule does.
141    pub binding: String,
142}
143
144/// The control plane as a whole: two nets per channel, budgeted on the scalar network.
145#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
146pub struct ControlRow {
147    pub nets: usize,
148    pub hops: usize,
149    pub max_scalar_occupancy: usize,
150    pub scalar_link_budget: usize,
151    pub routed_statically: bool,
152}
153
154#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
155pub struct VmAnchor {
156    pub params: Vec<i64>,
157    pub vm: i64,
158    pub fabric: i64,
159    pub pipelined_at_ii: i64,
160    pub hazards_at_ii: usize,
161    /// Hazards one cycle below the modeled II (`None` when the II is 1 — nothing below it).
162    pub hazards_below_ii: Option<usize>,
163}
164
165#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
166pub struct CertificateRow {
167    pub steps: usize,
168    pub vars: usize,
169    pub proves: String,
170    pub rechecked: bool,
171}
172
173#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
174pub struct GemmRow {
175    pub m: usize,
176    pub n: usize,
177    pub k: usize,
178    pub tile_m: usize,
179    pub tile_n: usize,
180    pub col_tiles: usize,
181    pub row_tiles: usize,
182    pub k_chunks: usize,
183    pub iters_per_tile: usize,
184    pub lanes: usize,
185    /// The reduction chunk the operands stream in: Spatial's `ts` along K, or the whole K for a tile whose
186    /// panels are streamed whole.
187    #[serde(default)]
188    pub k_tile: usize,
189    /// Reduction chunks run side by side (`loop_kk`, a dot product's `op`).
190    #[serde(default)]
191    pub kk: usize,
192    /// One chunk's operand load, at the reduce's lane width, and one chunk's MAC iterations.
193    #[serde(default)]
194    pub chunk_load_cycles: usize,
195    #[serde(default)]
196    pub chunk_compute_cycles: usize,
197    /// The chunks one instance runs one after another, over every tile it owns.
198    #[serde(default)]
199    pub chunks_in_sequence: usize,
200}
201
202/// One (workload, target) row of the report.
203#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
204pub struct WorkloadRow {
205    pub name: String,
206    /// `counted_loop` or `gemm`.
207    pub kind: String,
208    pub target: String,
209    pub provenance: String,
210    /// Unrolled instances placed (a counted loop's recurrence forbids unrolling: 1).
211    pub par: usize,
212    /// The widest unroll the mesh's PCU/PMU capacity allows — `par` is at most this; when it is less, the
213    /// static vector network, not capacity, bounded the unroll.
214    pub par_capacity: usize,
215    /// The private place-and-route engine's rung that produced the routes (`pnr-negotiated`, `pnr-exact`), or why
216    /// none did.
217    pub router: String,
218    pub stages: Vec<StageRow>,
219    pub pipeline_ii: usize,
220    pub critical_stage: String,
221    pub buffers: Vec<BufferRow>,
222    pub pcu_used: usize,
223    pub pcu_total: usize,
224    pub pcu_utilization_pct: f64,
225    pub pmu_used: usize,
226    pub pmu_total: usize,
227    pub pmu_utilization_pct: f64,
228    pub fu_utilization_pct: f64,
229    /// Address-generator stages across every unrolled instance.
230    pub ag_stages: usize,
231    pub total_hops: usize,
232    pub max_hops: usize,
233    pub max_link_occupancy: usize,
234    pub vector_links: usize,
235    pub routed_statically: bool,
236    /// Iterations one unrolled instance runs.
237    pub iterations: usize,
238    pub fill_cycles: usize,
239    pub compute_cycles: usize,
240    /// Cycles the MAC waits for operands on top of `compute_cycles`: a K-chunked reduction's first chunk loads
241    /// before any compute starts, and a chunk whose load outlasts its compute stalls for the difference.
242    #[serde(default)]
243    pub operand_stall_cycles: usize,
244    pub dram_bytes: usize,
245    pub dram_cycles: usize,
246    pub modeled_cycles: usize,
247    /// `compute` or `dram`.
248    pub bound: String,
249    pub throughput_iters_per_cycle: f64,
250    pub gflops: Option<f64>,
251    pub pct_of_peak_fp32: Option<f64>,
252    /// Per operand: how the compiler decided to feed the unrolled instances, and what it cost.
253    #[serde(default)]
254    pub operands: Option<Vec<OperandRow>>,
255    /// The synthesized control plane: one entry per inter-stage channel.
256    #[serde(default)]
257    pub channels: Vec<ChannelRow>,
258    /// The control nets those channels need, and whether they route.
259    #[serde(default)]
260    pub control: Option<ControlRow>,
261    pub certificate: Option<CertificateRow>,
262    pub certificate_omitted: Option<String>,
263    pub vm_anchor: Option<VmAnchor>,
264    pub gemm: Option<GemmRow>,
265}
266
267/// The placed, certified artifact of one counted loop on a Plasticine target — what `largo emit plasticine`
268/// writes and [`crate::compile::compile_to_plasticine`] returns.
269#[derive(Debug)]
270pub struct PlasticineArtifact {
271    pub fn_name: String,
272    pub target: &'static str,
273    pub loop_fabric: LoopFabric,
274    pub metapipeline: Metapipeline,
275    pub placement: Placement,
276    pub row: WorkloadRow,
277    pub certificate: Option<PlacementCertificate>,
278    /// Spatial source for the open toolchain.
279    pub spatial: String,
280    /// The same loop as a Spatial regression-harness app (`class Logos_<fn> extends SpatialTest`) at the
281    /// VM anchor's inputs, checking the VM's answer — what the toolchain gate runs.
282    pub spatial_test: String,
283}
284
285impl PlasticineArtifact {
286    /// The placement as pretty JSON (trailing newline).
287    pub fn placement_json(&self) -> String {
288        format!(
289            "{}\n",
290            serde_json::to_string_pretty(&self.placement).expect("a placement serializes")
291        )
292    }
293    /// The report row as pretty JSON (trailing newline).
294    pub fn row_json(&self) -> String {
295        format!(
296            "{}\n",
297            serde_json::to_string_pretty(&self.row).expect("a row serializes")
298        )
299    }
300}
301
302fn r3(x: f64) -> f64 {
303    (x * 1000.0).round() / 1000.0
304}
305
306fn pct(x: f64) -> f64 {
307    r3(x * 100.0)
308}
309
310fn ceil_div(a: usize, b: usize) -> usize {
311    (a + b.max(1) - 1) / b.max(1)
312}
313
314/// Run `f`, turning a panic into an `Err` carrying its message, with the default panic printer silenced for
315/// the call (the fabric lowerings panic on shapes outside their family — that is their honest boundary, and
316/// here it becomes a typed refusal instead of noise).
317fn quietly<T>(f: impl FnOnce() -> T) -> Result<T, String> {
318    let prev = std::panic::take_hook();
319    std::panic::set_hook(Box::new(|_| {}));
320    let r = catch_unwind(AssertUnwindSafe(f));
321    std::panic::set_hook(prev);
322    r.map_err(|p| {
323        p.downcast_ref::<String>()
324            .cloned()
325            .or_else(|| p.downcast_ref::<&str>().map(|s| s.to_string()))
326            .unwrap_or_else(|| "the lowering panicked".to_string())
327    })
328}
329
330/// `source` with its `## Main` section removed (up to the next `## ` header), so the VM anchor can append
331/// its own `Main` that shows the function under test.
332fn without_main(source: &str) -> String {
333    let mut out = String::new();
334    let mut skipping = false;
335    for line in source.lines() {
336        if line.trim() == "## Main" {
337            skipping = true;
338            continue;
339        }
340        if skipping && line.starts_with("## ") {
341            skipping = false;
342        }
343        if !skipping {
344            out.push_str(line);
345            out.push('\n');
346        }
347    }
348    out
349}
350
351fn vm_value(source: &str, fn_name: &str, params: &[i64]) -> Result<i64, String> {
352    let args = params
353        .iter()
354        .map(|p| {
355            if *p < 0 {
356                format!("(0 - {})", -p)
357            } else {
358                p.to_string()
359            }
360        })
361        .collect::<Vec<_>>()
362        .join(", ");
363    let prog = format!(
364        "{}## Main\n    Show {fn_name}({args}).\n",
365        without_main(source)
366    );
367    let out = vm_outcome(&prog);
368    if let Some(e) = out.error {
369        return Err(format!("the VM errored running `{fn_name}({args})`: {e:?}"));
370    }
371    out.output
372        .split_whitespace()
373        .next()
374        .and_then(|s| s.parse::<i64>().ok())
375        .ok_or_else(|| {
376            format!(
377                "the VM printed no integer for `{fn_name}({args})`: {:?}",
378                out.output
379            )
380        })
381}
382
383/// Certify the routed body datapath of `lf` (output 0 ≡ the next value of state register 0) at
384/// [`CERTIFICATE_WIDTH`] bits — only an ALU-only body whose next-state-0 is a body value lies in the bounded
385/// fragment; anything else is reported as omitted, with the reason.
386fn certify_body(
387    lf: &LoopFabric,
388) -> (
389    Option<PlacementCertificate>,
390    Option<CertificateRow>,
391    Option<String>,
392) {
393    let mut ops = Vec::with_capacity(lf.body.len());
394    for cell in &lf.body {
395        match cell {
396            LoopCell::Arith { op, a, b } => ops.push(FabricOp { op: *op, a: *a, b: *b }),
397            LoopCell::Select { .. } => return (None, None, Some("the body has a predicated select — outside the bounded ALU fragment the certificate covers".into())),
398            LoopCell::Func { unit, .. } => return (None, None, Some(format!("the body has a `{unit:?}` functional unit — outside the bounded ALU fragment the certificate covers"))),
399            _ => return (None, None, Some("the body has memory tiles — outside the bounded ALU fragment the certificate covers".into())),
400        }
401    }
402    let Some(Src::Val(result)) = lf.next.first().copied() else {
403        return (None, None, Some("next-state 0 is not a body value (the register holds or copies an input) — nothing to certify".into()));
404    };
405    let n_body_in = lf.n_state + 1 + lf.invariants.len();
406    let grid = route_loop_body(&lf.body, n_body_in, &lf.next);
407    match certify_external_placement(&grid, &ops, result, CERTIFICATE_WIDTH) {
408        Some(cert) => {
409            let rechecked = cert.recheck();
410            let row = CertificateRow {
411                steps: cert.len(),
412                vars: cert.num_vars(),
413                proves: format!(
414                    "the routed body grid's output 0 ≡ the next-state-0 datapath ({} ALU cells) for every {CERTIFICATE_WIDTH}-bit input: the negation is SAT-refuted (PR/RUP), re-checkable without the placer",
415                    ops.len()
416                ),
417                rechecked,
418            };
419            (Some(cert), Some(row), None)
420        }
421        None => (None, None, Some("the placement-equivalence obligation escaped the propositional fragment (fail-closed)".into())),
422    }
423}
424
425fn row_from_placement(
426    name: &str,
427    kind: &str,
428    t: &PlasticineTarget,
429    mp: &Metapipeline,
430    ag_stages: &[usize],
431    par: usize,
432    par_capacity: usize,
433    placement: &Placement,
434    iterations: usize,
435    dram_bytes: usize,
436    operand_stall_cycles: usize,
437) -> WorkloadRow {
438    let ii = mp.ii();
439    let fill: usize = mp.stages.iter().map(|s| s.latency).sum();
440    let compute_cycles = mp.cycles(iterations);
441    let dram_cycles = if dram_bytes == 0 {
442        0
443    } else {
444        (dram_bytes as f64 * t.clock_ghz / t.dram.gbps).ceil() as usize
445    };
446    let (modeled_cycles, bound) = if dram_cycles > compute_cycles + operand_stall_cycles {
447        (dram_cycles, "dram")
448    } else {
449        (compute_cycles + operand_stall_cycles, "compute")
450    };
451    let stages: Vec<StageRow> = mp
452        .stages
453        .iter()
454        .enumerate()
455        .map(|(i, s)| StageRow {
456            name: s.name.clone(),
457            host: if ag_stages.contains(&i) { "ag" } else { "pcu" }.to_string(),
458            resource_ii: s.resource_ii,
459            recurrence_ii: s.recurrence_ii,
460            memory_ii: s.memory_ii,
461            ii: s.ii(),
462            latency: s.latency,
463            cells: s.cells,
464            pcus: placement.alloc.stages[i].pcus,
465            critical: s.ii() == ii,
466        })
467        .collect();
468    let critical_stage = mp
469        .stages
470        .iter()
471        .find(|s| s.ii() == ii)
472        .map(|s| s.name.clone())
473        .unwrap_or_default();
474    let buffers: Vec<BufferRow> = placement
475        .alloc
476        .buffers
477        .iter()
478        .take(mp.edges.len())
479        .map(|b| BufferRow {
480            writer: b.edge.0,
481            reader: b.edge.1,
482            depth: b.depth,
483            words: b.words,
484            pmus: b.pmus,
485        })
486        .collect();
487    let fu = if placement.pcu_used() == 0 {
488        0.0
489    } else {
490        placement.fu_utilization(t.pcu_stages)
491    };
492    // The control plane the pipeline needs on this placement: credit depth from the routed distance.
493    let hops: Vec<usize> = mp
494        .edges
495        .iter()
496        // Instance 0's own route: `place_unrolled` offsets replicated edges by the stage count, so an
497        // unreplicated edge index names the first instance's route directly.
498        .map(|e| {
499            placement
500                .routes
501                .iter()
502                .find(|r| r.edge == *e)
503                .map(|r| r.hops)
504                .unwrap_or(0)
505        })
506        .collect();
507    let choreo: Choreography = synthesize(mp, &hops, t, placement.budget);
508    let channels: Vec<ChannelRow> = choreo
509        .channels
510        .iter()
511        .map(|ch| ChannelRow {
512            writer: ch.edge.0,
513            reader: ch.edge.1,
514            hops: ch.hops,
515            forward_latency: ch.forward_latency,
516            backward_latency: ch.backward_latency,
517            n_buffer_depth: ch.n_buffer_depth,
518            credit_depth: ch.credit_depth,
519            depth: ch.depth,
520            binding: if ch.credit_depth >= ch.n_buffer_depth {
521                "credits"
522            } else {
523                "n_buffer"
524            }
525            .to_string(),
526        })
527        .collect();
528    let control = ControlRow {
529        nets: choreo.nets.len(),
530        hops: choreo.control_hops,
531        max_scalar_occupancy: choreo.max_scalar_occupancy,
532        scalar_link_budget: choreo.scalar_link_budget,
533        routed_statically: choreo.control_routed_statically,
534    };
535    WorkloadRow {
536        name: name.to_string(),
537        kind: kind.to_string(),
538        target: t.name.to_string(),
539        provenance: PROVENANCE_MODELED.to_string(),
540        par,
541        par_capacity,
542        router: placement.router.to_string(),
543        stages,
544        pipeline_ii: ii,
545        critical_stage,
546        buffers,
547        pcu_used: placement.pcu_used(),
548        pcu_total: placement.pcu_total,
549        pcu_utilization_pct: pct(placement.pcu_utilization()),
550        pmu_used: placement.pmu_used(),
551        pmu_total: placement.pmu_total,
552        pmu_utilization_pct: pct(placement.pmu_utilization()),
553        fu_utilization_pct: pct(fu),
554        ag_stages: ag_stages.len() * par,
555        total_hops: placement.total_hops(),
556        max_hops: placement.max_hops(),
557        max_link_occupancy: placement.max_link_occupancy,
558        vector_links: placement.budget.vector_links,
559        routed_statically: placement.routed_statically,
560        iterations,
561        fill_cycles: fill,
562        compute_cycles,
563        operand_stall_cycles,
564        dram_bytes,
565        dram_cycles,
566        modeled_cycles,
567        bound: bound.to_string(),
568        throughput_iters_per_cycle: r3(mp.throughput()),
569        gflops: None,
570        pct_of_peak_fp32: None,
571        operands: None,
572        channels,
573        control: Some(control),
574        certificate: None,
575        certificate_omitted: None,
576        vm_anchor: None,
577        gemm: None,
578    }
579}
580
581/// The counted-loop shape the single-stage metapipeline model takes — the FIRST gate of
582/// [`lower_counted_loop`], on its own so a lane can ask the question without placing: `fn_name` of `source`
583/// as a [`LoopFabric`] (a numeric `Repeat for i from lo to hi` over scalar carried state), or the typed
584/// reason it is not one.
585pub fn counted_loop_fabric(source: &str, fn_name: &str) -> Result<LoopFabric, String> {
586    let lf = quietly(|| loop_fabric_from_source(source, fn_name))
587        .map_err(|why| format!("`{fn_name}` is not a counted range loop the metapipeline model takes (Spatial `Foreach` over `lo..=hi`): {why}"))?;
588    if lf.seq_input.is_some() || lf.seq_heap_ptr.is_some() {
589        return Err("sequence loops (`Repeat for x in xs`): the DRAM → PMU tile-load stage is modeled for GEMM only so far".into());
590    }
591    if !lf.epilogue.is_empty() || !lf.prologue_heap.is_empty() {
592        return Err(
593            "a post-loop epilogue / prologue heap island is outside the single-stage loop model"
594                .into(),
595        );
596    }
597    Ok(lf)
598}
599
600/// Lower the counted loop `fn_name` of `source` onto `t`: one metapipeline stage under the ideal pipelined
601/// PCU model, placed on the checkerboard, VM-anchored at `params`, certified when its body is ALU-only,
602/// with Spatial source — or a typed reason it cannot be.
603pub fn lower_counted_loop(
604    source: &str,
605    fn_name: &str,
606    params: &[i64],
607    t: &PlasticineTarget,
608) -> Result<PlasticineArtifact, String> {
609    let lf = counted_loop_fabric(source, fn_name)?;
610    if lf.n_in != params.len() {
611        return Err(format!(
612            "`{fn_name}` takes {} parameters; {} sample values were given",
613            lf.n_in,
614            params.len()
615        ));
616    }
617    // The PCU as Tungsten simulates it: a loop-carried register pays the pipeline depth per iteration.
618    let model = UnitModel::plasticine_pcu(t.pcu_stages);
619    let stage = loop_stage(&lf, &model, fn_name);
620    let mp = Metapipeline {
621        stages: vec![stage],
622        edges: vec![],
623    };
624    let placement = place_unrolled(&mp, &[], &[], 1, t, LinkBudget::spade_default())
625        .map_err(|e| format!("{e:?}"))?;
626    let empty: [i64; 0] = [];
627    let lo = lf.lo.eval(params, &empty);
628    let hi = lf.hi.eval(params, &empty);
629    let iterations = if hi < lo { 0 } else { (hi - lo + 1) as usize };
630    let ii = mp.ii();
631    let fabric = eval_loop_fabric(&lf, params);
632    let at_ii = eval_loop_fabric_at_ii(&lf, params, ii, &model);
633    let below = if ii > 1 {
634        Some(eval_loop_fabric_at_ii(&lf, params, ii - 1, &model).hazards)
635    } else {
636        None
637    };
638    let vm = vm_value(source, fn_name, params)?;
639    let (certificate, cert_row, omitted) = certify_body(&lf);
640    let spatial = emit_spatial_counted_loop(&lf, fn_name)?;
641    let spatial_test =
642        emit_spatial_counted_loop_test(&lf, &format!("Logos_{fn_name}"), params, vm)?;
643    let mut row = row_from_placement(
644        fn_name,
645        "counted_loop",
646        t,
647        &mp,
648        &[],
649        1,
650        1,
651        &placement,
652        iterations,
653        0,
654        0,
655    );
656    row.vm_anchor = Some(VmAnchor {
657        params: params.to_vec(),
658        vm,
659        fabric,
660        pipelined_at_ii: at_ii.value,
661        hazards_at_ii: at_ii.hazards,
662        hazards_below_ii: below,
663    });
664    row.certificate = cert_row;
665    row.certificate_omitted = omitted;
666    Ok(PlasticineArtifact {
667        fn_name: fn_name.to_string(),
668        target: t.name,
669        loop_fabric: lf,
670        metapipeline: mp,
671        placement,
672        row,
673        certificate,
674        spatial,
675        spatial_test,
676    })
677}
678
679fn pmu_spec(t: &PlasticineTarget) -> PmuSpec {
680    PmuSpec {
681        banks: t.pmu_banks,
682        words_per_bank: words_per_pmu(t) / t.pmu_banks.max(1),
683        n_buffers: 2,
684        stages: t.pmu_stages,
685    }
686}
687
688/// The largest square tile (a multiple of 8, at least 1) whose double-buffered A row-panel + B column-panel
689/// (`2 · tile · k` words) fits ONE PMU on `t`.
690pub fn gemm_tile_for(k: usize, t: &PlasticineTarget) -> usize {
691    let per_buffer = words_per_pmu(t) / 2;
692    let max_tile = per_buffer / (2 * k.max(1));
693    let by8 = max_tile / 8 * 8;
694    by8.max(1).min(max_tile.max(1))
695}
696
697/// The per-tile GEMM metapipeline on `t`: load (perimeter AGs: A row-panel + B column-panel, DRAM → PMU) →
698/// mac-reduce (a PCU: `lanes`-wide multiply, the `log2(lanes)+1`-stage reduction tree, the accumulator
699/// fused into its last stage — ISCA'17 §3.1) → store (AG: the C tile). Returns the metapipeline, the words
700/// on each edge (`[A+B panel, C tile]`) and the AG-hosted stage indices.
701pub fn gemm_metapipeline(
702    k: usize,
703    tile: usize,
704    t: &PlasticineTarget,
705) -> (Metapipeline, Vec<usize>, Vec<usize>) {
706    let lanes = t.pcu_lanes.max(1);
707    // The reduction tree is ⌈log2(lanes)⌉ adder levels deep plus the accumulator: a lane count that is not a
708    // power of two (Spatial's own apps run 5) still reduces pairwise, with one level for the odd lane.
709    let tree = (usize::BITS - (lanes - 1).leading_zeros()) as usize + 1;
710    let spec = pmu_spec(t);
711    let a_read = AffineAccess {
712        base: 0,
713        stride: 1,
714        lanes,
715    };
716    let b_read = AffineAccess {
717        base: 0,
718        stride: tile,
719        lanes,
720    };
721    let mem_ii = memory_ii(&[a_read], strided_banking_for(1), &spec).max(memory_ii(
722        &[b_read],
723        strided_banking_for(tile),
724        &spec,
725    ));
726    let agu = address_datapath_stages(2, true);
727    let load = Stage {
728        name: "load: A row-panel + B column-panel, DRAM → PMU (perimeter AGs)".into(),
729        resource_ii: 1,
730        recurrence_ii: 0,
731        latency: agu,
732        cells: 2,
733        memory_ii: 1,
734    };
735    let mac = Stage {
736        name: format!(
737            "mac-reduce: {lanes}-lane multiply → {tree}-stage reduction tree → accumulator"
738        ),
739        resource_ii: 1,
740        recurrence_ii: 1,
741        latency: 1 + tree,
742        cells: 1 + tree,
743        memory_ii: mem_ii,
744    };
745    let store = Stage {
746        name: "store: C tile, PMU → DRAM (perimeter AG)".into(),
747        resource_ii: 1,
748        recurrence_ii: 0,
749        latency: agu,
750        cells: 1,
751        memory_ii: 1,
752    };
753    let mp = Metapipeline {
754        stages: vec![load, mac, store],
755        edges: vec![(0, 1), (1, 2)],
756    };
757    (mp, vec![tile * k + k * tile, tile * tile], vec![0, 2])
758}
759
760/// The GEMM row: the tile (or the PMU-fitting default), the unroll (or the widest that fits AND routes
761/// statically; `par_capacity` records the capacity bound), the placement,
762/// and the roofline — compute cycles at the pipeline II vs the DRAM channel bandwidth over the bytes the
763/// tiling moves (B column-panels once, A re-streamed per column panel, C once).
764/// A GEMM row with mesh replication only — the intra-tile loops run one pass each.
765pub fn gemm_row(
766    name: &str,
767    m: usize,
768    n: usize,
769    k: usize,
770    tile: Option<usize>,
771    par: Option<usize>,
772    t: &PlasticineTarget,
773) -> Result<WorkloadRow, String> {
774    gemm_row_unrolled(name, m, n, k, tile, par, 1, 1, None, t)
775}
776
777/// `intra` is the product of the unrolls INSIDE one tile's chunk (the emitted nest's row and column loops) and
778/// `kk` the reduction chunks run side by side (`loop_kk`, a dot product's `op`): both divide the work per tile,
779/// where `par` divides the number of column panels — different axes, and the model needs each to reproduce an
780/// unrolled program. `k_tile` is the reduction chunk the operands stream in (Spatial's `ts` along K; `None` streams
781/// a tile's panels whole).
782#[allow(clippy::too_many_arguments)]
783pub fn gemm_row_unrolled(
784    name: &str,
785    m: usize,
786    n: usize,
787    k: usize,
788    tile: Option<usize>,
789    par: Option<usize>,
790    intra: usize,
791    kk: usize,
792    k_tile: Option<usize>,
793    t: &PlasticineTarget,
794) -> Result<WorkloadRow, String> {
795    let (intra, kk) = (intra.max(1), kk.max(1));
796    let k_tile = k_tile.unwrap_or(k).max(1).min(k.max(1));
797    // A tile never exceeds the matrix (a 1×1×K dot product tiles at 1 whatever `ts` the source app used).
798    let tile = tile
799        .unwrap_or_else(|| gemm_tile_for(k, t))
800        .max(1)
801        .min(m.max(1))
802        .min(n.max(1));
803    let (mp, words, ags) = gemm_metapipeline(k, tile, t);
804    let budget = LinkBudget::spade_default();
805    let capacity = max_par(&mp, &words, &ags, t).map_err(|e| format!("{e:?}"))?;
806    let lanes = t.pcu_lanes;
807    let col_tiles = ceil_div(n, tile);
808    let row_tiles = ceil_div(m, tile);
809    // The outer loop hands one column panel to each instance: unrolling wider than the panels leaves
810    // instances idle, so the automatic unroll is the routable maximum capped at the work available.
811    let par = match par {
812        Some(p) => p.max(1),
813        None => {
814            max_routable_par_within(&mp, &words, &ags, t, budget, col_tiles)
815                .map_err(|e| format!("{e:?}"))?
816                .0
817        }
818    };
819    let placement =
820        place_unrolled(&mp, &words, &ags, par, t, budget).map_err(|e| format!("{e:?}"))?;
821    let k_chunks = ceil_div(k, lanes);
822    // Three axes of parallelism, kept apart. `par` replicates the whole tile pipeline across the mesh and divides
823    // the column panels. `intra` widens the row and column loops inside a tile and divides a chunk's compute (a
824    // chunk is at least one pass). `kk` runs reduction chunks side by side, so a tile takes ⌈chunks / kk⌉ rounds of
825    // one chunk each — a whole chunk's time even when fewer than `kk` chunks are left for the round. The operands
826    // stream chunk by chunk, each load at the reduce's lane width (`par ip` on Spatial's loads): nothing computes
827    // until the first chunk is in, and a chunk whose load outlasts its compute stalls the MAC for the difference.
828    // Measured on Tungsten: a one-lane dot product of four 16,384-element chunks runs (4 + 1) x 16,384 cycles, and
829    // three five-lane chunk pipelines over four chunks run (2 + 1) rounds.
830    let chunks_in_sequence_per_tile = ceil_div(ceil_div(k, k_tile), kk);
831    let chunk_load_cycles = ceil_div(k_tile * tile, lanes);
832    let chunk_compute_cycles = ceil_div(tile * tile * ceil_div(k_tile, lanes), intra).max(1);
833    let iters_per_tile = chunks_in_sequence_per_tile * chunk_compute_cycles;
834    let tiles_in_sequence = ceil_div(col_tiles, par) * row_tiles;
835    let iterations = tiles_in_sequence * iters_per_tile;
836    let chunks_in_sequence = tiles_in_sequence * chunks_in_sequence_per_tile;
837    let operand_stall_cycles =
838        chunk_load_cycles + chunks_in_sequence * chunk_load_cycles.saturating_sub(chunk_compute_cycles);
839    // How each operand reaches the `par` instances is a COST DECISION, not an assumption: the B column-panel
840    // set and the A row-panel set are each either streamed per instance or streamed once and fanned out. The
841    // roofline then moves exactly the bytes the decision chose.
842    let hops = placement.routes.first().map(|r| r.hops).unwrap_or(1).max(1);
843    // B is PARTITIONED, not shared: instance `i` owns its own column panels, so every byte of B is fetched
844    // exactly once however the work is spread. There is no sharing question to ask about it.
845    let b_bytes = 4 * k * n;
846    // A is genuinely SHARED: every column panel sweeps all of A. Replicated, A is re-streamed once per column
847    // panel; broadcast to the `par` instances at once, once per ROUND of panels. That is the decision.
848    let a_bytes = 4 * m * k;
849    let a_stream = OperandStream {
850        name: "A".to_string(),
851        bytes: a_bytes,
852        consumers: par,
853        broadcast_hops: hops,
854    };
855    let a_dec = share_operand(&a_stream, t);
856    let a_copies = if a_dec.sharing == Sharing::Broadcast {
857        ceil_div(col_tiles, par)
858    } else {
859        col_tiles
860    };
861    let operands = vec![
862        OperandRow {
863            name: "B".to_string(),
864            sharing: "partitioned".to_string(),
865            consumers: par,
866            bytes_per_copy: b_bytes,
867            copies: 1,
868            bytes_moved: b_bytes,
869            dram_cycles_saved: 0,
870            fabric_cycles_added: 0,
871            why: "each instance owns disjoint column panels: every byte is fetched once, nothing to share".to_string(),
872        },
873        OperandRow {
874            name: "A".to_string(),
875            sharing: a_dec.sharing.label().to_string(),
876            consumers: par,
877            bytes_per_copy: a_bytes,
878            copies: a_copies,
879            bytes_moved: a_copies * a_bytes,
880            dram_cycles_saved: a_dec.dram_cycles_saved,
881            fabric_cycles_added: a_dec.fabric_cycles_added,
882            why: a_dec.why,
883        },
884    ];
885    let dram_bytes = b_bytes + a_copies * a_bytes + 4 * m * n;
886    let mut row = row_from_placement(
887        name,
888        "gemm",
889        t,
890        &mp,
891        &ags,
892        par,
893        capacity,
894        &placement,
895        iterations,
896        dram_bytes,
897        operand_stall_cycles,
898    );
899    row.operands = Some(operands);
900    let flops = 2.0 * m as f64 * n as f64 * k as f64;
901    let gflops = r3(flops * t.clock_ghz / row.modeled_cycles.max(1) as f64);
902    row.gflops = Some(gflops);
903    row.pct_of_peak_fp32 = Some(r3(gflops / (t.peak_fp32_tflops * 1000.0) * 100.0));
904    row.gemm = Some(GemmRow {
905        m,
906        n,
907        k,
908        tile_m: tile,
909        tile_n: tile,
910        col_tiles,
911        row_tiles,
912        k_chunks,
913        iters_per_tile,
914        lanes,
915        k_tile,
916        kk,
917        chunk_load_cycles,
918        chunk_compute_cycles,
919        chunks_in_sequence,
920    });
921    Ok(row)
922}
923
924/// The row for any workload on `t`.
925pub fn workload_row(w: &Workload, t: &PlasticineTarget) -> Result<WorkloadRow, String> {
926    match &w.kind {
927        WorkloadKind::CountedLoop {
928            source,
929            fn_name,
930            params,
931        } => {
932            let mut row = lower_counted_loop(source, fn_name, params, t)?.row;
933            row.name = w.name.clone();
934            Ok(row)
935        }
936        WorkloadKind::Gemm { m, n, k, tile, par } => gemm_row(&w.name, *m, *n, *k, *tile, *par, t),
937    }
938}
939
940const SELECT_SRC: &str = "## To f (n: Int) -> Int:\n    Let mutable s be 1.\n    Repeat for i from 1 to n:\n        If s is greater than 10:\n            Set s to s - i.\n        Else:\n            Set s to s + i * 2.\n    Return s.\n";
941
942/// One round of the MoE router's masked argmax over 128 synthetic expert scores (the inner loop of the
943/// `route128` routing proven ≡VM on the fabric): a counted loop with two carried registers and a
944/// data-dependent select.
945const ARGMAX_SRC: &str = "## To argmax128 (x: Int) -> Int:\n    Let mutable bv be 0.\n    Let mutable bi be 1.\n    Repeat for j from 1 to 128:\n        Let v be (((x * j * 7) + (j * 13) + 5) % 101).\n        If v is greater than bv:\n            Set bv to v.\n            Set bi to j.\n    Return bi.\n";
946
947/// The canonical workloads: the metapipeline corpus, the MoE argmax round, a dot product, and the two GEMM
948/// shapes the Tenstorrent ledger measures.
949pub fn canonical_workloads() -> Vec<Workload> {
950    fn loop_prog(body: &str) -> String {
951        format!("## To f (n: Int) -> Int:\n    Let mutable s be 1.\n    Repeat for i from 1 to n:\n        Set s to {body}.\n    Return s.\n")
952    }
953    // Trip counts keep every carried value inside i64: the VM promotes to BigInt on overflow while the
954    // fabric wraps, and the anchor must compare like with like (3^30, 6^20, 30^12 all fit).
955    let counted = |name: &str, source: String, n: i64| Workload {
956        name: name.to_string(),
957        kind: WorkloadKind::CountedLoop {
958            source,
959            fn_name: "f".to_string(),
960            params: vec![n],
961        },
962    };
963    vec![
964        counted("loop-sum", loop_prog("s + i"), 1000),
965        counted("loop-affine", loop_prog("s * 3 + i"), 30),
966        counted("loop-affine-deep", loop_prog("(s * 3 + i) * 2"), 20),
967        counted("loop-mul-chain", loop_prog("((s * 2) * 3) * 5 + i"), 12),
968        counted("loop-no-carry", loop_prog("i * 7 + 1"), 1000),
969        counted("loop-select", SELECT_SRC.to_string(), 1000),
970        Workload {
971            name: "moe-argmax-round-128".to_string(),
972            kind: WorkloadKind::CountedLoop {
973                source: ARGMAX_SRC.to_string(),
974                fn_name: "argmax128".to_string(),
975                params: vec![3],
976            },
977        },
978        Workload {
979            name: "dot-product-1024".to_string(),
980            kind: WorkloadKind::Gemm {
981                m: 1,
982                n: 1,
983                k: 1024,
984                tile: Some(1),
985                par: Some(1),
986            },
987        },
988        Workload {
989            name: "gemm-640x640x640".to_string(),
990            kind: WorkloadKind::Gemm {
991                m: 640,
992                n: 640,
993                k: 640,
994                tile: None,
995                par: None,
996            },
997        },
998        Workload {
999            name: "gemm-2048x1024x512".to_string(),
1000            kind: WorkloadKind::Gemm {
1001                m: 2048,
1002                n: 1024,
1003                k: 512,
1004                tile: None,
1005                par: None,
1006            },
1007        },
1008    ]
1009}
1010
1011/// The two published configurations.
1012pub fn canonical_targets() -> Vec<PlasticineTarget> {
1013    vec![PlasticineTarget::isca17(), PlasticineTarget::sara_20x20()]
1014}
1015
1016/// What the model does NOT cover yet, named with the reason — the honest boundary printed in the report.
1017pub fn not_modeled() -> Vec<(String, String)> {
1018    let s = |a: &str, b: &str| (a.to_string(), b.to_string());
1019    vec![
1020        s(
1021            "data-dependent `while` loops (Collatz, the data_dependent_control corpus)",
1022            "a while loop's trip count is a runtime predicate; the metapipeline model takes counted loops (Spatial `Foreach`). These programs are proven ≡VM on the fabric's decision / loop mechanisms; a stream-controller (Spatial `Stream`/FSM) II model is the P5 follow-on",
1023        ),
1024        s(
1025            "route128 top-8-of-128 MoE routing (whole function)",
1026            "three nested while loops over a heap-resident Seq — proven ≡VM on the fabric call-stack mechanism; the nested counter-chain controller model (Spatial `Foreach` nests with PMU-resident state) is P5. The argmax round it repeats eight times is modeled above",
1027        ),
1028        s(
1029            "tiny transformer block / Qwen3-30B MoE routing at real scale",
1030            "nested loops with heap-resident tensors — same nested-controller gap as route128",
1031        ),
1032        s(
1033            "scalar sequence loops (`Repeat for x in xs`)",
1034            "the DRAM → PMU tile-load stage is modeled for GEMM only; a parameter sequence's load stage comes with the nested-controller model",
1035        ),
1036    ]
1037}
1038
1039fn target_summary(t: &PlasticineTarget) -> Value {
1040    json!({
1041        "name": t.name,
1042        "mesh": format!("{}x{} checkerboard", t.pcu_rows, t.pcu_cols),
1043        "pcus": t.pcus(),
1044        "pmus": t.pmus(),
1045        "pcu_lanes": t.pcu_lanes,
1046        "pcu_stages": t.pcu_stages,
1047        "pcu_regs_per_stage": t.pcu_regs_per_stage,
1048        "pmu_banks": t.pmu_banks,
1049        "pmu_kib": t.pmu_kib,
1050        "pmu_stages": t.pmu_stages,
1051        "scratchpad_kib": t.scratchpad_kib(),
1052        "dram": { "channels": t.dram.channels, "kind": t.dram.kind, "gbps": t.dram.gbps },
1053        "clock_ghz": t.clock_ghz,
1054        "peak_fp32_tflops": r3(t.peak_fp32_tflops),
1055        "area_mm2": t.area_mm2,
1056        "power_w": t.power_w,
1057        "cite": t.cite,
1058    })
1059}
1060
1061/// The whole report as JSON — deterministic in the workloads' and targets' order. Every workload must lower
1062/// (a canonical workload the model cannot take is a bug, not a silent omission).
1063pub fn plasticine_report(workloads: &[Workload], targets: &[PlasticineTarget]) -> Value {
1064    let mut rows = Vec::with_capacity(workloads.len() * targets.len());
1065    for t in targets {
1066        for w in workloads {
1067            let row = workload_row(w, t)
1068                .unwrap_or_else(|e| panic!("canonical workload `{}` on {}: {e}", w.name, t.name));
1069            rows.push(serde_json::to_value(row).expect("a row serializes"));
1070        }
1071    }
1072    let not_modeled: Vec<Value> = not_modeled()
1073        .into_iter()
1074        .map(|(name, reason)| json!({ "name": name, "reason": reason }))
1075        .collect();
1076    json!({
1077        "_generated_by": "the private place-and-route engine's report emitter, emit_plasticine_report (codegen_sva::plasticine_report) — never hand-edited; plasticine_report_lock regenerates it byte-equal",
1078        "provenance": PROVENANCE_MODELED,
1079        "honesty": "Every number is the paper-parameterized model's (ISCA'17 / SARA descriptors, Spatial's II definition, the PMU banking model, the checkerboard placer). Nothing here ran on a Plasticine simulator or on silicon; Tungsten rows land beside these under provenance `simulated_tungsten`.",
1080        "model": {
1081            "ii": "Spatial (Koeplinger et al., PLDI 2018, §4): stage II = max(resource II, recurrence II, memory II); pipeline II = the slowest stage; N-buffer depth = reader − writer + 1",
1082            "pcu": "ISCA'17 §3.1: a stage of depth D chains ceil(D / 6) PCUs, each 16 lanes wide; the reduction tree is log2(lanes)+1 stages",
1083            "pmu": "ISCA'17 §3.2: 16 banks × 16 KB; strided banking at the access stride is conflict-free for 16 lanes; N-buffers partition the address space",
1084            "control": "every inter-stage channel carries a data-valid net forward and a space-available net backward (codegen_sva::choreography); its credit depth is the bandwidth-delay product ceil((forward + backward latency) / II) and the shipped depth is the deeper of that and Spatial's writer-to-reader N-buffer rule; control nets are budgeted on the SCALAR network against the PCU's control ports, separately from the vector network the data crosses. `plasticine_choreography_lock` proves the depth tight: at it the pipeline runs at exactly 1/II, and one credit less on any channel loses throughput",
1085            "placement": "row-balanced checkerboard placement; static routing by the private place-and-route engine over the mesh's routing-resource graph (one wire per channel of every DIRECTED link at Spade's vlink 2, unit vector ports, perimeter DRAM ports): the negotiated-congestion rung, the exact SAT rung on a stall, and the independent legality checker as the verdict; address-generator stages attach at the left/right perimeter switches (a port counts one hop)",
1086            "dram": "cycles = bytes × clock_GHz / (GB/s); a GEMM tiling moves B column-panels once, A once per column panel, C once",
1087            "cycles": "fill (Σ stage latency) + (iterations − 1) × II per unrolled instance; modeled cycles = max(compute, DRAM)",
1088        },
1089        "targets": targets.iter().map(target_summary).collect::<Vec<_>>(),
1090        "rows": rows,
1091        "not_modeled": not_modeled,
1092        "published_reference": {
1093            "note": "Spatial PLDI'18 Table 4 (Plasticine column) — the paper's numbers, not ours: the credibility target the Tungsten lane reproduces before our rows are compared to it",
1094            "spatial_pldi18_table4_plasticine": {
1095                "GEMM": { "pcu_pct": 96.8, "pmu_pct": 64.1, "ag_pct": 11.7, "time_ms": 15.98 },
1096                "BlackScholes": { "pcu_pct": 73.4, "pmu_pct": 10.9, "ag_pct": 20.6 },
1097                "GDA": { "pcu_pct": 95.3, "pmu_pct": 73.4, "ag_pct": 38.2 },
1098                "KMeans": { "pcu_pct": 89.1, "pmu_pct": 57.8, "ag_pct": 17.6 },
1099                "TQ6": { "pcu_pct": 29.7, "pmu_pct": 37.5, "ag_pct": 70.6 },
1100            },
1101        },
1102    })
1103}
1104
1105/// The descriptor a toolchain row's `rows × cols` mesh calibrates against: the 20x20 mesh is PIR's default in
1106/// front of Tungsten's DDR4 memory (`PlasticineTarget::tungsten_20x20_ddr4`, the system the chain simulates);
1107/// the 16x8 mesh is the ISCA'17 preset.
1108pub fn target_for_mesh(rows: usize, cols: usize) -> Option<PlasticineTarget> {
1109    if (rows, cols) == (20, 20) {
1110        return Some(PlasticineTarget::tungsten_20x20_ddr4());
1111    }
1112    canonical_targets()
1113        .into_iter()
1114        .find(|t| t.pcu_rows == rows && t.pcu_cols == cols)
1115}
1116
1117/// One toolchain row joined with our model: the same app shape, tile (`ts`) and a single instance (its loop
1118/// pars are 1), on the same mesh — modeled cycles vs Tungsten's, the per-cent difference, and the unit
1119/// counts side by side (PIR's PCU count includes the control/address units its partitioner splits off; our
1120/// row counts compute PCUs and perimeter address-generator stages separately — printed, not conflated).
1121/// A dot product is the `1 × 1 × N` GEMM: its `ts` the K-chunk, its `ip` the reduce's lanes, and its `op`
1122/// (how many tiles are reduced at once) the GEMM's `loop_kk`. Rows of `kind: "dot"` are modeled as that GEMM.
1123pub fn calibration_row(t: &PlasticineTarget, row: &Value) -> Result<Value, String> {
1124    if row["kind"].as_str() == Some("counted_loop") {
1125        return calibration_row_loop(t, row);
1126    }
1127    if let Some(why) = unmodeled(row) {
1128        return Err(format!("{}: {why}", row["key"]));
1129    }
1130    if let Some(why) = routed_failure(row) {
1131        return Err(format!("{}: {why}", row["key"]));
1132    }
1133    let dot = row["kind"].as_str() == Some("dot");
1134    let par = |p: &str| {
1135        let key = if dot && p == "loop_kk" { "op" } else { p };
1136        row["params"][key].as_u64().unwrap_or(1)
1137    };
1138    let shape = &row["shape"];
1139    let (m, n, k) = (
1140        shape["m"].as_u64().ok_or("shape.m")? as usize,
1141        shape["n"].as_u64().ok_or("shape.n")? as usize,
1142        shape["k"].as_u64().ok_or("shape.k")? as usize,
1143    );
1144    let ts = row["params"]["ts"].as_u64().ok_or("params.ts")? as usize;
1145    let ip = row["params"]["ip"].as_u64().ok_or("params.ip")? as usize;
1146    // The axes, kept apart: the outer tile loops replicate the pipeline across the mesh; the row and column loops
1147    // widen the work inside one chunk; `loop_kk` runs reduction chunks side by side.
1148    let mesh_par: u64 = ["loop_ii", "loop_jj"].iter().map(|p| par(p)).product();
1149    let intra: u64 = ["loop_i", "loop_j"].iter().map(|p| par(p)).product();
1150    let kk = par("loop_kk");
1151    // Model the app on the vector width the app actually runs. `ip` is the innermost `par` of the Spatial
1152    // source — the lanes the reduce issues across — and it is a property of the PROGRAM, not of the part: the
1153    // same mesh runs `ip=1` and `ip=16` apps. The model read the target's lane count instead, so an app
1154    // configured narrower than the descriptor was credited with the descriptor's parallelism. That is how a
1155    // 32^3 GEMM at `ip=1` came out at 1,035 modelled cycles against 9,128 measured — a 16-lane machine doing
1156    // one-lane work. Widths above the descriptor are not granted: the part cannot issue more than it has; widths
1157    // below it need not be powers of two (Spatial's own DotProduct_6 runs five).
1158    let mut app_t = t.clone();
1159    if ip >= 1 {
1160        app_t.pcu_lanes = ip.min(t.pcu_lanes);
1161    }
1162    let modeled = gemm_row_unrolled(
1163        row["app"].as_str().unwrap_or("app"),
1164        m,
1165        n,
1166        k,
1167        Some(ts),
1168        Some(mesh_par.max(1) as usize),
1169        intra.max(1) as usize,
1170        kk.max(1) as usize,
1171        Some(ts),
1172        &app_t,
1173    )?;
1174    let simulated = row["hybrid_network"]["cycles"]
1175        .as_u64()
1176        .ok_or("hybrid_network.cycles")? as usize;
1177    let ideal = row["ideal_network"]["cycles"]
1178        .as_u64()
1179        .ok_or("ideal_network.cycles")? as usize;
1180    let ratio = modeled.modeled_cycles as f64 / simulated as f64;
1181    Ok(json!({
1182        "key": row["key"],
1183        "app": row["app"],
1184        "kind": if dot { "dot" } else { "gemm" },
1185        "target": t.name,
1186        "shape": { "m": m, "n": n, "k": k, "tile": ts, "lanes": ip },
1187        "modeled": {
1188            "cycles": modeled.modeled_cycles,
1189            "bound": modeled.bound,
1190            "pipeline_ii": modeled.pipeline_ii,
1191            "lanes": modeled.gemm.as_ref().map(|g| g.lanes),
1192            "operand_stall_cycles": modeled.operand_stall_cycles,
1193            "compute_pcus": modeled.pcu_used,
1194            "pmus": modeled.pmu_used,
1195            "ag_stages": modeled.ag_stages,
1196            "provenance": PROVENANCE_MODELED,
1197        },
1198        "simulated": {
1199            "cycles_hybrid_network": simulated,
1200            "cycles_ideal_network": ideal,
1201            "pir_pcus": row["pir"]["PCU"]["used"],
1202            "pir_pmus": row["pir"]["PMU"]["used"],
1203            "pir_dags": row["pir"]["DAG"]["used"],
1204            "pir_mcs": row["pir"]["MC"]["used"],
1205            "plastiroute_best_score": row["plastiroute"]["best_score"],
1206            "pass": row["hybrid_network"]["pass"],
1207            "provenance": PROVENANCE_SIMULATED,
1208        },
1209        "modeled_over_simulated": r3(ratio),
1210        "difference_pct": r3((ratio - 1.0).abs() * 100.0),
1211        "lanes_agree": modeled.gemm.as_ref().map(|g| g.lanes == ip).unwrap_or(false),
1212    }))
1213}
1214
1215/// A toolchain row for one of OUR emitted counted loops (the ledger carries the LOGOS source, the function and
1216/// the runtime args), joined with the model's row for the same loop on the same mesh: modeled cycles (fill +
1217/// (iterations − 1) × II under the ideal pipelined PCU) vs Tungsten's.
1218fn calibration_row_loop(t: &PlasticineTarget, row: &Value) -> Result<Value, String> {
1219    let source = row["logos_source"].as_str().ok_or("logos_source")?;
1220    let fn_name = row["fn_name"].as_str().ok_or("fn_name")?;
1221    let params: Vec<i64> = row["params"]
1222        .as_array()
1223        .ok_or("params")?
1224        .iter()
1225        .map(|v| v.as_i64().ok_or("params: i64"))
1226        .collect::<Result<_, _>>()?;
1227    let art = lower_counted_loop(source, fn_name, &params, t)?;
1228    let modeled = &art.row;
1229    let simulated = row["hybrid_network"]["cycles"]
1230        .as_u64()
1231        .ok_or("hybrid_network.cycles")? as usize;
1232    let ideal = row["ideal_network"]["cycles"]
1233        .as_u64()
1234        .ok_or("ideal_network.cycles")? as usize;
1235    let ratio = modeled.modeled_cycles as f64 / simulated as f64;
1236    let anchor = modeled.vm_anchor.as_ref().ok_or("vm anchor")?;
1237    Ok(json!({
1238        "key": row["key"],
1239        "app": row["app"],
1240        "kind": "counted_loop",
1241        "target": t.name,
1242        "loop": { "fn": fn_name, "params": params, "iterations": modeled.iterations, "vm_answer": anchor.vm },
1243        "modeled": {
1244            "cycles": modeled.modeled_cycles,
1245            "pipeline_ii": modeled.pipeline_ii,
1246            "fill_cycles": modeled.fill_cycles,
1247            "compute_pcus": modeled.pcu_used,
1248            "provenance": PROVENANCE_MODELED,
1249        },
1250        "simulated": {
1251            "cycles_hybrid_network": simulated,
1252            "cycles_ideal_network": ideal,
1253            "pir_pcus": row["pir"]["PCU"]["used"],
1254            "pir_pmus": row["pir"]["PMU"]["used"],
1255            "pass": row["hybrid_network"]["pass"],
1256            "provenance": PROVENANCE_SIMULATED,
1257        },
1258        "modeled_over_simulated": r3(ratio),
1259        "difference_pct": r3((ratio - 1.0).abs() * 100.0),
1260        "simulated_cycles_per_iteration": r3(simulated as f64 / modeled.iterations.max(1) as f64),
1261    }))
1262}
1263
1264/// The kinds of toolchain row the model has no counterpart for, each with the reason — listed in the
1265/// calibration as uncalibrated, never dropped and never joined with a model that does not describe them.
1266pub const UNMODELED_KINDS: &[(&str, &str)] = &[
1267    (
1268        "outer",
1269        "the model has no outer-product row: its pipeline computes a ts1 x ts2 product tile and stores it whole, a \
1270         store-bound pipeline the GEMM metapipeline (a MemReduce over K) does not describe",
1271    ),
1272    (
1273        "tpchq6",
1274        "the model has no guarded-sum row: TPC-H Q6 streams four columns and selects per element, which the two-stream \
1275         1 x 1 x N GEMM a dot product is modeled as does not describe",
1276    ),
1277    (
1278        "gda",
1279        "the model has no scatter-accumulation row: GDA centres each row and adds its outer product to a C x C tile \
1280         under two nested MemReduces, a pipeline the GEMM metapipeline does not describe",
1281    ),
1282    (
1283        "blackscholes",
1284        "the model has no elementwise-map row: Black-Scholes streams six columns through FIFOs into a chain of \
1285         several hundred scalar operations per element, a dataflow graph the GEMM metapipeline does not describe",
1286    ),
1287    (
1288        "kmeans",
1289        "the model has no clustering row: k-means runs epochs of a distance reduce per point and centroid, a min and an \
1290         index reduce, and a one-hot MemReduce into the centroids, a pipeline the GEMM metapipeline does not describe",
1291    ),
1292    (
1293        "spatial_app",
1294        "one of Spatial's own apps whose twin the compiler does not print yet: the model describes the shapes the \
1295         compiler lowers (GEMMs, dot products, counted loops), not an arbitrary Spatial program",
1296    ),
1297];
1298
1299/// Why `row` has no modeled counterpart, if its kind is one the model does not describe.
1300/// Why a toolchain row's routed (hybrid) simulation is no calibration point: what it came to when it did not finish
1301/// clean (the recorder's `why`: deadlocked, threw, failed its gold check). The model is calibrated against routed
1302/// cycles, and a routed run that did not finish has none to compare.
1303pub fn routed_failure(row: &Value) -> Option<String> {
1304    row["hybrid_network"]["why"].as_str().map(|why| format!("its routed-network simulation {why}"))
1305}
1306
1307pub fn unmodeled(row: &Value) -> Option<&'static str> {
1308    let kind = row["kind"].as_str()?;
1309    UNMODELED_KINDS
1310        .iter()
1311        .find(|(k, _)| *k == kind)
1312        .map(|(_, why)| *why)
1313}
1314
1315/// The calibration document: every toolchain row on a mesh we model, joined with the model, and every row of
1316/// a kind the model does not describe, or whose routed run did not finish clean, listed as uncalibrated with the
1317/// reason.
1318pub fn calibration(toolchain_ledger: &Value) -> Result<Value, String> {
1319    let mut rows = Vec::new();
1320    let mut uncalibrated = Vec::new();
1321    for row in toolchain_ledger["rows"]
1322        .as_array()
1323        .ok_or("toolchain ledger has no rows array")?
1324    {
1325        if let Some(why) = unmodeled(row) {
1326            uncalibrated.push(json!({ "key": row["key"], "app": row["app"], "kind": row["kind"], "why": why }));
1327            continue;
1328        }
1329        if let Some(why) = routed_failure(row) {
1330            uncalibrated.push(json!({ "key": row["key"], "app": row["app"], "kind": row["kind"], "why": why }));
1331            continue;
1332        }
1333        let mesh = &row["mesh"];
1334        let (r, c) = (
1335            mesh["rows"].as_u64().ok_or("mesh.rows")? as usize,
1336            mesh["cols"].as_u64().ok_or("mesh.cols")? as usize,
1337        );
1338        let t = target_for_mesh(r, c).ok_or_else(|| {
1339            format!(
1340                "toolchain row {} ran on a {r}x{c} mesh we have no descriptor for",
1341                row["key"]
1342            )
1343        })?;
1344        rows.push(calibration_row(&t, row)?);
1345    }
1346    Ok(json!({
1347        "_generated_by": "the private place-and-route engine's report emitter, emit_plasticine_report (codegen_sva::plasticine_report::calibration) — joins benchmarks/silicon/plasticine_toolchain.json with the model; never hand-edited",
1348        "what": "For each app the public Plasticine toolchain simulated (Spatial → PIR → plastiroute → Tungsten), our MODELED row for the same shape, tile and unroll on the same mesh — the calibration an RDU engineer asks for before trusting a model. `simulated` numbers are the toolchain's (a cycle-accurate simulation of the paper's architecture, not silicon); `modeled` numbers are ours.",
1349        "tolerance_pct": CALIBRATION_TOLERANCE_PCT,
1350        "rows": rows,
1351        "uncalibrated": uncalibrated,
1352    }))
1353}
1354
1355/// The per-cent difference the calibration lock allows between modeled and simulated cycles.
1356pub const CALIBRATION_TOLERANCE_PCT: f64 = 15.0;
1357
1358/// Pretty JSON with a trailing newline — the exact bytes of [`REPORT_PATH`].
1359pub fn render_report(v: &Value) -> String {
1360    format!(
1361        "{}\n",
1362        serde_json::to_string_pretty(v).expect("the report serializes")
1363    )
1364}
1365
1366/// A canonical LOGOS matmul `C[m×n] = A[m×k] · B[k×n]` as the triple loop the tensor recognizer reads (the
1367/// same program the Tenstorrent lane lowers).
1368pub fn logos_gemm_program(m: usize, n: usize, k: usize) -> String {
1369    format!(
1370        "## Main\nLet rows be {m}.\nLet cols be {n}.\nLet mid be {k}.\nLet mutable a be a new Seq of Int.\nLet mutable b be a new Seq of Int.\nLet mutable c be a new Seq of Int.\nLet mutable p be 0.\nWhile p is less than rows * mid:\n    Push 1 to a.\n    Set p to p + 1.\nSet p to 0.\nWhile p is less than mid * cols:\n    Push 1 to b.\n    Set p to p + 1.\nSet p to 0.\nWhile p is less than rows * cols:\n    Push 0 to c.\n    Set p to p + 1.\nLet mutable i be 0.\nWhile i is less than rows:\n    Let mutable k be 0.\n    While k is less than mid:\n        Let mutable j be 0.\n        While j is less than cols:\n            Let idx be i * cols + j + 1.\n            Set item idx of c to item idx of c + item (i * mid + k + 1) of a * item (k * cols + j + 1) of b.\n            Set j to j + 1.\n        Set k to k + 1.\n    Set i to i + 1.\nShow item 1 of c.\n"
1371    )
1372}
1373
1374/// A LOGOS matmul, recognized (`tensor_recognize::recognize_matmul`) and emitted as Spatial's GEMM regression
1375/// app with tiles `ts`/`its` and an `ip`-lane reduce: `(m, n, k, class name, Spatial source)`, or a typed
1376/// reason (not a matmul; tiles that do not divide the shape).
1377pub fn lower_gemm_source(
1378    source: &str,
1379    ts: usize,
1380    its: usize,
1381    ip: usize,
1382) -> Result<(usize, usize, usize, String, String), String> {
1383    let (m, n, k) = crate::ui_bridge::with_parsed_program(source, |parsed, interner| {
1384        let (stmts, _types, _policies) = parsed.map_err(|e| format!("parse: {e:?}"))?;
1385        let spec = crate::tensor_recognize::recognize_matmul(stmts, interner)
1386            .map_err(|e| format!("not a matmul the tensor recognizer reads: {e:?}"))?;
1387        Ok::<_, String>((spec.m, spec.n, spec.k))
1388    })?;
1389    let class = format!("Logos_gemm_{m}x{n}x{k}");
1390    let spatial = emit_spatial_gemm_test(m, n, k, ts, its, ip, &class)?;
1391    Ok((m, n, k, class, spatial))
1392}
1393
1394/// A canonical LOGOS dot product `Σ_{t<n} a[t]·b[t]` as the counted loop the tensor recognizer reads.
1395pub fn logos_dot_program(n: usize) -> String {
1396    format!(
1397        "## Main\nLet n be {n}.\nLet mutable a be a new Seq of Int.\nLet mutable b be a new Seq of Int.\nLet mutable p be 0.\nWhile p is less than n:\n    Push p % 7 to a.\n    Push p % 5 to b.\n    Set p to p + 1.\nLet mutable s be 0.\nLet mutable i be 0.\nWhile i is less than n:\n    Set s to s + item (i + 1) of a * item (i + 1) of b.\n    Set i to i + 1.\nShow s.\n"
1398    )
1399}
1400
1401/// A canonical LOGOS outer product `c[i·cols + j] = a[i]·b[j]` (`rows × cols`) as the nest the tensor
1402/// recognizer reads; it shows `Σ (t + 1)·c[t]`, a checksum a transposed store does not reproduce.
1403pub fn logos_outer_program(rows: usize, cols: usize) -> String {
1404    format!(
1405        "## Main\nLet rows be {rows}.\nLet cols be {cols}.\nLet mutable a be a new Seq of Int.\nLet mutable b be a new Seq of Int.\nLet mutable c be a new Seq of Int.\nLet mutable p be 0.\nWhile p is less than rows:\n    Push p % 7 to a.\n    Set p to p + 1.\nSet p to 0.\nWhile p is less than cols:\n    Push p % 5 to b.\n    Set p to p + 1.\nSet p to 0.\nWhile p is less than rows * cols:\n    Push 0 to c.\n    Set p to p + 1.\nLet mutable i be 0.\nWhile i is less than rows:\n    Let mutable j be 0.\n    While j is less than cols:\n        Let idx be i * cols + j + 1.\n        Set item idx of c to item (i + 1) of a * item (j + 1) of b.\n        Set j to j + 1.\n    Set i to i + 1.\nLet mutable sum be 0.\nSet p to 0.\nWhile p is less than rows * cols:\n    Set sum to sum + item (p + 1) of c * (p + 1).\n    Set p to p + 1.\nShow sum.\n"
1406    )
1407}
1408
1409/// A recognized zip sum printed as Spatial's reduce-over-tiles app: each column named as the program names it
1410/// and filled as the program fills it. The class name carries the family, the length and every knob, in
1411/// Spatial's parameter order, so each variant's twin has its own generated directory.
1412fn emit_zip_twin(
1413    stmts: &[logicaffeine_language::ast::stmt::Stmt],
1414    interner: &crate::intern::Interner,
1415    zip: &ZipSum,
1416    family: &str,
1417    p: ZipPar,
1418) -> Result<(usize, String, String), String> {
1419    let columns: Vec<ZipColumn> = zip
1420        .operands
1421        .iter()
1422        .map(|o| ZipColumn {
1423            name: interner.resolve(*o).to_string(),
1424            fill: crate::tensor_recognize::recognize_fill(stmts, *o),
1425        })
1426        .collect();
1427    let class = format!("Logos_{family}_{}_ts{}_ip{}_op{}", zip.n, p.ts, p.ip, p.op);
1428    let spatial = emit_spatial_zip_sum_test(zip.n, &columns, &zip.term, p, &class)?;
1429    Ok((zip.n, class, spatial))
1430}
1431
1432/// A LOGOS dot product, recognized (`tensor_recognize::recognize_dot`) and emitted as Spatial's DotProduct
1433/// regression app at the knobs `p`: `(n, class name, Spatial source)`, or a typed reason (not a dot product; a
1434/// tile that does not divide the length; a sum the app's 32-bit `Int` would wrap).
1435pub fn lower_dot_source(source: &str, p: ZipPar) -> Result<(usize, String, String), String> {
1436    crate::ui_bridge::with_parsed_program(source, |parsed, interner| {
1437        let (stmts, _types, _policies) = parsed.map_err(|e| format!("parse: {e:?}"))?;
1438        let dot = crate::tensor_recognize::recognize_dot(stmts, interner)
1439            .map_err(|e| format!("not a dot product the tensor recognizer reads: {e:?}"))?;
1440        let operands = if dot.a == dot.b { vec![dot.a] } else { vec![dot.a, dot.b] };
1441        let read = |s| ZipTerm::Read(operands.iter().position(|o| *o == s).expect("an operand"));
1442        let zip = ZipSum {
1443            n: dot.n,
1444            term: ZipTerm::Op(ZipOp::Mul, Box::new(read(dot.a)), Box::new(read(dot.b))),
1445            operands,
1446            acc: dot.acc,
1447        };
1448        emit_zip_twin(stmts, interner, &zip, "dot", p)
1449    })
1450}
1451
1452/// The query of Spatial's `TPCHQ6` regression app (`pir/regression/TPCHQ6.scala`), transcribed: over the
1453/// columns `dates`, `discts`, `quants`, `prices` (read in that order), the revenue term
1454/// `date > MIN_DATE && date < MAX_DATE && disct >= MIN_DISC && disct <= MAX_DISC && quant < 24 ? price·disct : 0`
1455/// with its `MIN_DATE = 0`, `MAX_DATE = 9999`, `MIN_DISC = 0`, `MAX_DISC = 9999`.
1456pub fn tpchq6_query() -> ZipTerm {
1457    let (date, disct, quant, price) = (|| ZipTerm::Read(0), || ZipTerm::Read(1), || ZipTerm::Read(2), || ZipTerm::Read(3));
1458    let op = |o, l, r| ZipTerm::Op(o, Box::new(l), Box::new(r));
1459    let valid = [
1460        op(ZipOp::Gt, date(), ZipTerm::Const(0)),
1461        op(ZipOp::Lt, date(), ZipTerm::Const(9999)),
1462        op(ZipOp::Ge, disct(), ZipTerm::Const(0)),
1463        op(ZipOp::Le, disct(), ZipTerm::Const(9999)),
1464        op(ZipOp::Lt, quant(), ZipTerm::Const(24)),
1465    ]
1466    .into_iter()
1467    .reduce(|a, b| op(ZipOp::And, a, b))
1468    .expect("five conditions");
1469    ZipTerm::Select(Box::new(valid), Box::new(op(ZipOp::Mul, price(), disct())), Box::new(ZipTerm::Const(0)))
1470}
1471
1472/// TPC-H Q6 in LOGOS over `n` records — the four columns filled as Spatial's TPCHQ6 app fills them
1473/// (`i % 256`) and the revenue summed under the query's predicate.
1474pub fn logos_tpchq6_program(n: usize) -> String {
1475    format!(
1476        "## Main\nLet n be {n}.\nLet mutable dates be a new Seq of Int.\nLet mutable quants be a new Seq of Int.\nLet mutable discts be a new Seq of Int.\nLet mutable prices be a new Seq of Int.\nLet mutable p be 0.\nWhile p is less than n:\n    Push p % 256 to dates.\n    Push p % 256 to quants.\n    Push p % 256 to discts.\n    Push p % 256 to prices.\n    Set p to p + 1.\nLet mutable revenue be 0.\nLet mutable i be 0.\nWhile i is less than n:\n    Let date be item (i + 1) of dates.\n    Let disct be item (i + 1) of discts.\n    Let quant be item (i + 1) of quants.\n    If date is greater than 0 and date is less than 9999 and disct is at least 0 and disct is at most 9999 and quant is less than 24:\n        Set revenue to revenue + item (i + 1) of prices * disct.\n    Set i to i + 1.\nShow revenue.\n"
1477    )
1478}
1479
1480/// A LOGOS TPC-H Q6 — the zip sum whose term is [`tpchq6_query`] — emitted as Spatial's TPCHQ6 regression app
1481/// at the knobs `p`: `(n, class name, Spatial source)`, or a typed reason.
1482pub fn lower_tpchq6_source(source: &str, p: ZipPar) -> Result<(usize, String, String), String> {
1483    crate::ui_bridge::with_parsed_program(source, |parsed, interner| {
1484        let (stmts, _types, _policies) = parsed.map_err(|e| format!("parse: {e:?}"))?;
1485        let query = tpchq6_query();
1486        let zip = crate::tensor_recognize::recognize_zip_sum_where(stmts, interner, &|z| z.term == query)
1487            .map_err(|e| format!("not TPC-H Q6 as the tensor recognizer reads it: {e:?}"))?;
1488        emit_zip_twin(stmts, interner, &zip, "tpchq6", p)
1489    })
1490}
1491
1492/// The covariance step of Spatial's `GDA` regression app (`pir/regression/GDA.scala`), transcribed: over the arrays
1493/// `y` (at the row), `x` (row-major), `mu1` and `mu0` (at the column), read in that order, the row term
1494/// `x(rr, cc) - mux(y(rr) == 1, mu1(cc), mu0(cc))`, each row's outer product with itself summed into `sigma`.
1495pub fn gda_query() -> (Vec<Axis>, ZipTerm) {
1496    let op = |o, l, r| ZipTerm::Op(o, Box::new(l), Box::new(r));
1497    let label = op(ZipOp::Eq, ZipTerm::Read(0), ZipTerm::Const(1));
1498    let mean = ZipTerm::Select(Box::new(label), Box::new(ZipTerm::Read(2)), Box::new(ZipTerm::Read(3)));
1499    (vec![Axis::Row, Axis::RowCol, Axis::Col, Axis::Col], op(ZipOp::Sub, ZipTerm::Read(1), mean))
1500}
1501
1502/// Gaussian discriminant analysis's covariance in LOGOS over `rows` samples of `cols` features — the data filled as
1503/// Spatial's GDA app fills it (`x` row-major `i % 256`, `y` `i % 256`, both means `i % 2`), each row centred on the
1504/// mean its label picks and added to `sigma` as a rank-1 update. It shows `Σ (t + 1)·sigma[t]`, a checksum a
1505/// transposed or partial covariance does not reproduce.
1506pub fn logos_gda_program(rows: usize, cols: usize) -> String {
1507    format!(
1508        "## Main\nLet rows be {rows}.\nLet cols be {cols}.\nLet mutable x be a new Seq of Int.\nLet mutable y be a new Seq of Int.\nLet mutable mu0 be a new Seq of Int.\nLet mutable mu1 be a new Seq of Int.\nLet mutable sigma be a new Seq of Int.\nLet mutable sub be a new Seq of Int.\nLet mutable p be 0.\nWhile p is less than rows * cols:\n    Push p % 256 to x.\n    Set p to p + 1.\nSet p to 0.\nWhile p is less than rows:\n    Push p % 256 to y.\n    Set p to p + 1.\nSet p to 0.\nWhile p is less than cols:\n    Push p % 2 to mu0.\n    Push p % 2 to mu1.\n    Push 0 to sub.\n    Set p to p + 1.\nSet p to 0.\nWhile p is less than cols * cols:\n    Push 0 to sigma.\n    Set p to p + 1.\nLet mutable r be 0.\nWhile r is less than rows:\n    Let label be item (r + 1) of y.\n    Let mutable c be 0.\n    While c is less than cols:\n        If label is 1:\n            Set item (c + 1) of sub to item (r * cols + c + 1) of x - item (c + 1) of mu1.\n        Otherwise:\n            Set item (c + 1) of sub to item (r * cols + c + 1) of x - item (c + 1) of mu0.\n        Set c to c + 1.\n    Let mutable i be 0.\n    While i is less than cols:\n        Let mutable j be 0.\n        While j is less than cols:\n            Let idx be i * cols + j + 1.\n            Set item idx of sigma to item idx of sigma + item (i + 1) of sub * item (j + 1) of sub.\n            Set j to j + 1.\n        Set i to i + 1.\n    Set r to r + 1.\nLet mutable sum be 0.\nSet p to 0.\nWhile p is less than cols * cols:\n    Set sum to sum + item (p + 1) of sigma * (p + 1).\n    Set p to p + 1.\nShow sum.\n"
1509    )
1510}
1511
1512/// A LOGOS GDA covariance — the scatter accumulation whose row term and reading axes are [`gda_query`] — emitted as
1513/// Spatial's GDA regression app at the knobs `p`: `(rows, cols, class name, Spatial source)`, or a typed reason. The
1514/// class name carries the shape and every knob in Spatial's parameter order.
1515pub fn lower_gda_source(source: &str, p: GdaPar) -> Result<(usize, usize, String, String), String> {
1516    crate::ui_bridge::with_parsed_program(source, |parsed, interner| {
1517        let (stmts, _types, _policies) = parsed.map_err(|e| format!("parse: {e:?}"))?;
1518        let spec = crate::tensor_recognize::recognize_scatter(stmts, interner)
1519            .map_err(|e| format!("not a scatter accumulation the tensor recognizer reads: {e:?}"))?;
1520        let (axes, query) = gda_query();
1521        if spec.term != query || spec.operands.iter().map(|(_, a)| *a).collect::<Vec<_>>() != axes {
1522            return Err("a scatter accumulation, but not GDA's covariance step".to_string());
1523        }
1524        let columns: Vec<GdaColumn> = spec
1525            .operands
1526            .iter()
1527            .map(|(o, axis)| GdaColumn {
1528                name: interner.resolve(*o).to_string(),
1529                axis: *axis,
1530                fill: crate::tensor_recognize::recognize_fill(stmts, *o),
1531            })
1532            .collect();
1533        let class = format!(
1534            "Logos_gda_{}x{}_ts{}_op{}_mp{}x{}_ip{}",
1535            spec.rows, spec.cols, p.ts, p.op, p.mp1, p.mp2, p.ip
1536        );
1537        let spatial = emit_spatial_gda_test(spec.rows, spec.cols, &columns, &spec.term, p, &class)?;
1538        Ok((spec.rows, spec.cols, class, spatial))
1539    })
1540}
1541
1542/// The option price of Spatial's `BlackScholes` regression app (`pir/regression/BlackScholes.scala`,
1543/// `BlkSchlsEqEuroNoDiv`, with the Taylor approximations of `spatial.lang.api.MathAPI` its `CNDF` and the pricing
1544/// call), transcribed: over the columns `prices`, `strikes`, `rates`, `vols`, `times` (`Real`) and `types` (`Int`),
1545/// read in that order. Spatial's `-x` is LOGOS's `0 - x`.
1546pub fn blackscholes_query() -> ZipTerm {
1547    let r = ZipTerm::real;
1548    let op = |o, l: ZipTerm, r: ZipTerm| ZipTerm::Op(o, Box::new(l), Box::new(r));
1549    let (add, sub) = (|a, b| op(ZipOp::Add, a, b), |a, b| op(ZipOp::Sub, a, b));
1550    let (mul, div) = (|a, b| op(ZipOp::Mul, a, b), |a, b| op(ZipOp::Div, a, b));
1551    let lt = |a, b| op(ZipOp::Lt, a, b);
1552    let sq = |a| op(ZipOp::Pow, a, ZipTerm::Const(2));
1553    let neg = |a| sub(ZipTerm::Const(0), a);
1554    let sel = |c, a, b| ZipTerm::Select(Box::new(c), Box::new(a), Box::new(b));
1555    let exp_taylor = |x: ZipTerm| {
1556        let poly = add(
1557            add(
1558                add(add(add(r(1.0), x.clone()), div(mul(x.clone(), x.clone()), r(2.0))), div(mul(mul(x.clone(), x.clone()), x.clone()), r(6.0))),
1559                div(mul(mul(mul(x.clone(), x.clone()), x.clone()), x.clone()), r(24.0)),
1560            ),
1561            div(mul(mul(mul(mul(x.clone(), x.clone()), x.clone()), x.clone()), x.clone()), r(120.0)),
1562        );
1563        sel(lt(x.clone(), r(-3.5)), r(0.0), sel(lt(x.clone(), r(-1.2)), add(mul(x, r(0.1)), r(0.35)), poly))
1564    };
1565    let log_taylor = |x: ZipTerm| {
1566        let xx = sub(x, r(1.0));
1567        sub(
1568            add(sub(xx.clone(), div(mul(xx.clone(), xx.clone()), r(2.0))), div(mul(mul(xx.clone(), xx.clone()), xx.clone()), r(3.0))),
1569            div(mul(mul(mul(xx.clone(), xx.clone()), xx.clone()), xx), r(4.0)),
1570        )
1571    };
1572    let sqrt_approx = |x: ZipTerm| {
1573        let m1 = || sub(x.clone(), r(1.0));
1574        let taylor = add(sub(add(r(1.0), div(m1(), r(2.0))), div(mul(m1(), m1()), r(8.0))), div(mul(mul(m1(), m1()), m1()), r(16.0)));
1575        let line = |a: f64, b: f64| add(mul(x.clone(), r(a)), r(b));
1576        sel(
1577            lt(x.clone(), r(2.0)),
1578            taylor,
1579            sel(
1580                lt(x.clone(), r(10.0)),
1581                line(0.22, 1.0),
1582                sel(
1583                    lt(x.clone(), r(100.0)),
1584                    line(0.08, 2.5),
1585                    sel(lt(x.clone(), r(1000.0)), line(0.028, 8.0), sel(lt(x.clone(), r(10000.0)), line(0.008, 20.0), line(0.0002, 300.0))),
1586                ),
1587            ),
1588        )
1589    };
1590    let cndf = |x: ZipTerm| {
1591        let ax = ZipTerm::Abs(Box::new(x.clone()));
1592        let n_prime = mul(exp_taylor(mul(sq(ax.clone()), r(-0.05))), r(0.39894228040143270286));
1593        let k = div(r(1.0), add(mul(ax, r(0.2316419)), r(1.0)));
1594        let k2 = sq(k.clone());
1595        let k3 = mul(k2.clone(), k.clone());
1596        let k4 = mul(k3.clone(), k.clone());
1597        let k5 = mul(k4.clone(), k.clone());
1598        let l1 = add(
1599            add(add(add(mul(k2, r(-0.356563782)), mul(k3, r(1.781477937))), mul(k4, r(-1.821255978))), mul(k5, r(1.330274429))),
1600            mul(k, r(0.31938153)),
1601        );
1602        let l0 = mul(l1, n_prime);
1603        sel(lt(x, r(0.0)), l0.clone(), add(neg(l0), r(1.0)))
1604    };
1605    let (price, strike, rate, vol, time, otype) =
1606        (ZipTerm::Read(0), ZipTerm::Read(1), ZipTerm::Read(2), ZipTerm::Read(3), ZipTerm::Read(4), ZipTerm::Read(5));
1607    let log_term = log_taylor(div(price.clone(), strike.clone()));
1608    let power_term = mul(sq(vol.clone()), r(0.5));
1609    let num = add(mul(add(rate.clone(), power_term), time.clone()), log_term);
1610    let den = mul(vol, sqrt_approx(time.clone()));
1611    let d = div(num, sq(den.clone()));
1612    let (nd1, nd2) = (cndf(d.clone()), cndf(sub(d, den)));
1613    let future = mul(strike, exp_taylor(mul(neg(rate), time)));
1614    let (neg_nd1, neg_nd2) = (add(neg(nd1.clone()), r(1.0)), add(neg(nd2.clone()), r(1.0)));
1615    let price1 = sub(mul(price.clone(), nd1), mul(future.clone(), nd2));
1616    let price2 = sub(mul(future, neg_nd2), mul(price, neg_nd1));
1617    sel(op(ZipOp::Eq, otype, ZipTerm::Const(0)), price2, price1)
1618}
1619
1620/// Black-Scholes in LOGOS over `n` options, as Spatial's BlackScholes app prices them — its Taylor approximations
1621/// written as LOGOS functions, the six columns declared in the app's order and filled with residues (types `p % 2`,
1622/// the prices, strikes, rates, volatilities and times scaled into ranges the approximations are taken over), every
1623/// option priced by one counted loop. It shows `Σ (t + 1)·out[t]`, a checksum a permuted or partial result does not
1624/// reproduce.
1625pub fn logos_blackscholes_program(n: usize) -> String {
1626    format!(
1627        "## To expTaylor (x: Real) -> Real:\n    If x is less than -3.5:\n        Return 0.0.\n    If x is less than -1.2:\n        Return x * 0.1 + 0.35.\n    Return 1.0 + x + x * x / 2.0 + x * x * x / 6.0 + x * x * x * x / 24.0 + x * x * x * x * x / 120.0.\n\n\
1628## To logTaylor (x: Real) -> Real:\n    Let xx be x - 1.0.\n    Return xx - xx * xx / 2.0 + xx * xx * xx / 3.0 - xx * xx * xx * xx / 4.0.\n\n\
1629## To sqrtApprox (x: Real) -> Real:\n    If x is less than 2.0:\n        Return 1.0 + (x - 1.0) / 2.0 - (x - 1.0) * (x - 1.0) / 8.0 + (x - 1.0) * (x - 1.0) * (x - 1.0) / 16.0.\n    If x is less than 10.0:\n        Return x * 0.22 + 1.0.\n    If x is less than 100.0:\n        Return x * 0.08 + 2.5.\n    If x is less than 1000.0:\n        Return x * 0.028 + 8.0.\n    If x is less than 10000.0:\n        Return x * 0.008 + 20.0.\n    Return x * 0.0002 + 300.0.\n\n\
1630## To cndf (x: Real) -> Real:\n    Let ax be abs(x).\n    Let xNPrimeofX be expTaylor(ax ** 2 * -0.05) * 0.3989422804014327.\n    Let xK2 be 1.0 / (ax * 0.2316419 + 1.0).\n    Let xK2_2 be xK2 ** 2.\n    Let xK2_3 be xK2_2 * xK2.\n    Let xK2_4 be xK2_3 * xK2.\n    Let xK2_5 be xK2_4 * xK2.\n    Let xLocal_10 be xK2 * 0.31938153.\n    Let xLocal_20 be xK2_2 * -0.356563782.\n    Let xLocal_30 be xK2_3 * 1.781477937.\n    Let xLocal_31 be xK2_4 * -1.821255978.\n    Let xLocal_32 be xK2_5 * 1.330274429.\n    Let xLocal_21 be xLocal_20 + xLocal_30.\n    Let xLocal_22 be xLocal_21 + xLocal_31.\n    Let xLocal_23 be xLocal_22 + xLocal_32.\n    Let xLocal_1 be xLocal_23 + xLocal_10.\n    Let xLocal0 be xLocal_1 * xNPrimeofX.\n    Let xLocal be 0 - xLocal0 + 1.0.\n    If x is less than 0.0:\n        Return xLocal0.\n    Return xLocal.\n\n\
1631## To blackScholes (sptprice: Real, strike: Real, rate: Real, volatility: Real, time: Real, otype: Int) -> Real:\n    Let xLogTerm be logTaylor(sptprice / strike).\n    Let xPowerTerm be volatility ** 2 * 0.5.\n    Let xNum be (rate + xPowerTerm) * time + xLogTerm.\n    Let xDen be volatility * sqrtApprox(time).\n    Let xDiv be xNum / (xDen ** 2).\n    Let nofXd1 be cndf(xDiv).\n    Let nofXd2 be cndf(xDiv - xDen).\n    Let futureValueX be strike * expTaylor((0 - rate) * time).\n    Let negNofXd1 be 0 - nofXd1 + 1.0.\n    Let negNofXd2 be 0 - nofXd2 + 1.0.\n    Let optionPrice1 be sptprice * nofXd1 - futureValueX * nofXd2.\n    Let optionPrice2 be futureValueX * negNofXd2 - sptprice * negNofXd1.\n    If otype is 0:\n        Return optionPrice2.\n    Return optionPrice1.\n\n\
1632## Main\nLet n be {n}.\nLet mutable types be a new Seq of Int.\nLet mutable prices be a new Seq of Real.\nLet mutable strikes be a new Seq of Real.\nLet mutable rates be a new Seq of Real.\nLet mutable vols be a new Seq of Real.\nLet mutable times be a new Seq of Real.\nLet mutable out be a new Seq of Real.\nLet mutable p be 0.\nWhile p is less than n:\n    Push p % 2 to types.\n    Push (p % 97 + 50) / 100.0 to prices.\n    Push (p % 89 + 50) / 100.0 to strikes.\n    Push (p % 13 + 1) / 100.0 to rates.\n    Push (p % 41 + 10) / 100.0 to vols.\n    Push (p % 23 + 2) / 4.0 to times.\n    Push 0.0 to out.\n    Set p to p + 1.\nLet mutable i be 0.\nWhile i is less than n:\n    Set item (i + 1) of out to blackScholes(item (i + 1) of prices, item (i + 1) of strikes, item (i + 1) of rates, item (i + 1) of vols, item (i + 1) of times, item (i + 1) of types).\n    Set i to i + 1.\nLet mutable sum be 0.0.\nSet p to 0.\nWhile p is less than n:\n    Set sum to sum + item (p + 1) of out * (p + 1).\n    Set p to p + 1.\nShow sum.\n"
1633    )
1634}
1635
1636/// A LOGOS Black-Scholes — the zip map whose term is [`blackscholes_query`] — emitted as Spatial's BlackScholes
1637/// regression app at the knobs `p`, with the variant's PIR arguments (`pir_args`, what its `pirArgs` override
1638/// appends): `(n, class name, Spatial source)`, or a typed reason. The columns are loaded in the order the program
1639/// declares them, as the app loads its own. The class name carries the length and every knob in Spatial's parameter
1640/// order.
1641pub fn lower_blackscholes_source(source: &str, p: MapPar, pir_args: Option<&str>) -> Result<(usize, String, String), String> {
1642    crate::ui_bridge::with_parsed_program(source, |parsed, interner| {
1643        let (stmts, _types, _policies) = parsed.map_err(|e| format!("parse: {e:?}"))?;
1644        let query = blackscholes_query();
1645        let map = crate::tensor_recognize::recognize_zip_map_where(stmts, interner, &|m| m.term == query)
1646            .map_err(|e| format!("not Black-Scholes as the tensor recognizer reads it: {e:?}"))?;
1647        let columns: Vec<MapColumn> = map
1648            .operands
1649            .iter()
1650            .zip(&map.types)
1651            .map(|(o, ty)| MapColumn {
1652                name: interner.resolve(*o).to_string(),
1653                ty: *ty,
1654                fill: crate::tensor_recognize::recognize_fill(stmts, *o),
1655            })
1656            .collect();
1657        let declared = declaration_order(stmts, &map.operands)?;
1658        let class = format!("Logos_bs_{}_ts{}_op{}_ip{}", map.n, p.ts, p.op, p.ip);
1659        let out = interner.resolve(map.out).to_string();
1660        let spatial = emit_spatial_zip_map_test(map.n, &columns, &declared, &map.term, &out, p, pir_args, &class)?;
1661        Ok((map.n, class, spatial))
1662    })
1663}
1664
1665/// The arrays `arrays` in the order the program declares them (its top-level `Let`s): each one's position in
1666/// `arrays`.
1667fn declaration_order(stmts: &[logicaffeine_language::ast::stmt::Stmt], arrays: &[crate::intern::Symbol]) -> Result<Vec<usize>, String> {
1668    let at = |a: &crate::intern::Symbol| {
1669        stmts
1670            .iter()
1671            .position(|s| matches!(s, logicaffeine_language::ast::stmt::Stmt::Let { var, .. } if var == a))
1672            .ok_or_else(|| "a column the program reads is not declared at its top level".to_string())
1673    };
1674    let mut order: Vec<(usize, usize)> = arrays.iter().enumerate().map(|(k, a)| Ok((at(a)?, k))).collect::<Result<_, String>>()?;
1675    order.sort_unstable();
1676    Ok(order.into_iter().map(|(_, k)| k).collect())
1677}
1678
1679/// The distance a feature adds in Spatial's `Kmeans` regression app (`pir/regression/Kmeans.scala`), transcribed:
1680/// `(pts(pt,d) - cts(ct,d)) ** 2` over the point's feature and the centroid's, read in that order.
1681pub fn kmeans_distance_query() -> ZipTerm {
1682    let sub = ZipTerm::Op(ZipOp::Sub, Box::new(ZipTerm::Read(0)), Box::new(ZipTerm::Read(1)));
1683    ZipTerm::Op(ZipOp::Pow, Box::new(sub), Box::new(ZipTerm::Const(2)))
1684}
1685
1686/// K-means clustering in LOGOS — Lloyd's algorithm as Spatial's Kmeans app runs it: `n` points of `f` features
1687/// (row-major, filled `q % 97` over the flat index), `k` centroids starting as the first `k` points, `iters` epochs
1688/// each assigning every point to its nearest centroid by the squared distance (the last of equally near ones, as the
1689/// app's accelerator), summing and counting each centroid's points, and setting every centroid to the integer mean of
1690/// its points (0 for one with none). It shows `Σ (t + 1)·centroids[t]`, a checksum a permuted or partial result does
1691/// not reproduce.
1692pub fn logos_kmeans_program(n: usize, k: usize, f: usize, iters: usize) -> String {
1693    format!(
1694        "## Main\nLet n be {n}.\nLet k be {k}.\nLet f be {f}.\nLet iters be {iters}.\nLet mutable pts be a new Seq of Int.\nLet mutable cts be a new Seq of Int.\nLet mutable sums be a new Seq of Int.\nLet mutable counts be a new Seq of Int.\nLet mutable q be 0.\nWhile q is less than n * f:\n    Push q % 97 to pts.\n    Set q to q + 1.\nSet q to 0.\nWhile q is less than k * f:\n    Push item (q + 1) of pts to cts.\n    Push 0 to sums.\n    Set q to q + 1.\nSet q to 0.\nWhile q is less than k:\n    Push 0 to counts.\n    Set q to q + 1.\nLet mutable epoch be 0.\nWhile epoch is less than iters:\n    Set q to 0.\n    While q is less than k * f:\n        Set item (q + 1) of sums to 0.\n        Set q to q + 1.\n    Set q to 0.\n    While q is less than k:\n        Set item (q + 1) of counts to 0.\n        Set q to q + 1.\n    Let mutable p be 0.\n    While p is less than n:\n        Let mutable best be 0.\n        Let mutable bestDist be 0.\n        Let mutable c be 0.\n        While c is less than k:\n            Let mutable dist be 0.\n            Let mutable d be 0.\n            While d is less than f:\n                Set dist to dist + (item (p * f + d + 1) of pts - item (c * f + d + 1) of cts) ** 2.\n                Set d to d + 1.\n            If c is 0 or dist is at most bestDist:\n                Set best to c.\n                Set bestDist to dist.\n            Set c to c + 1.\n        Set q to 0.\n        While q is less than f:\n            Set item (best * f + q + 1) of sums to item (best * f + q + 1) of sums + item (p * f + q + 1) of pts.\n            Set q to q + 1.\n        Set item (best + 1) of counts to item (best + 1) of counts + 1.\n        Set p to p + 1.\n    Let mutable j be 0.\n    While j is less than k:\n        Let cnt be item (j + 1) of counts.\n        Set q to 0.\n        While q is less than f:\n            If cnt is 0:\n                Set item (j * f + q + 1) of cts to 0.\n            Otherwise:\n                Set item (j * f + q + 1) of cts to item (j * f + q + 1) of sums / cnt.\n            Set q to q + 1.\n        Set j to j + 1.\n    Set epoch to epoch + 1.\nLet mutable sum be 0.\nSet q to 0.\nWhile q is less than k * f:\n    Set sum to sum + item (q + 1) of cts * (q + 1).\n    Set q to q + 1.\nShow sum.\n"
1695    )
1696}
1697
1698/// A LOGOS k-means clustering — whose distance is [`kmeans_distance_query`] — emitted as Spatial's Kmeans regression
1699/// app at the knobs `p`: `(n, k, features + 1, epochs, class name, Spatial source)` (the app's `N`, `K`, `D` and
1700/// runtime `I`), or a typed reason. The class name carries the shape, the epochs and every knob in Spatial's parameter
1701/// order.
1702pub fn lower_kmeans_source(source: &str, p: KmeansPar) -> Result<(usize, usize, usize, usize, String, String), String> {
1703    crate::ui_bridge::with_parsed_program(source, |parsed, interner| {
1704        let (stmts, _types, _policies) = parsed.map_err(|e| format!("parse: {e:?}"))?;
1705        let spec = crate::tensor_recognize::recognize_kmeans(stmts, interner)
1706            .map_err(|e| format!("not k-means as the tensor recognizer reads it: {e:?}"))?;
1707        if spec.distance != kmeans_distance_query() {
1708            return Err("k-means, but not by the distance Spatial's Kmeans app sums".to_string());
1709        }
1710        let points = crate::tensor_recognize::recognize_fill(stmts, spec.points);
1711        let class = format!(
1712            "Logos_kmeans_{}x{}_k{}_i{}_ts{}_ip{}_op{}_mp{}x{}x{}",
1713            spec.n, spec.f + 1, spec.k, spec.iters, p.ts, p.ip, p.op, p.mp1, p.mp2, p.mp3
1714        );
1715        let spatial = emit_spatial_kmeans_test(&spec, &points, p, &class)?;
1716        Ok((spec.n, spec.k, spec.f + 1, spec.iters, class, spatial))
1717    })
1718}
1719
1720/// A LOGOS outer product, recognized (`tensor_recognize::recognize_outer`) and emitted as Spatial's
1721/// OuterProduct regression app at the knobs `p`: `(m, n, class name, Spatial source)`, or a typed reason. The
1722/// class name carries the shape and every knob in Spatial's parameter order (`ts1 x ts2`, `ip x ip1 x ip2`,
1723/// `op1 x op2`).
1724pub fn lower_outer_source(source: &str, p: OuterPar) -> Result<(usize, usize, String, String), String> {
1725    let (m, n) = crate::ui_bridge::with_parsed_program(source, |parsed, interner| {
1726        let (stmts, _types, _policies) = parsed.map_err(|e| format!("parse: {e:?}"))?;
1727        crate::tensor_recognize::recognize_outer(stmts, interner)
1728            .map(|spec| (spec.m, spec.n))
1729            .map_err(|e| format!("not an outer product the tensor recognizer reads: {e:?}"))
1730    })?;
1731    let class = format!(
1732        "Logos_outer_{m}x{n}_ts{}x{}_ip{}x{}x{}_op{}x{}",
1733        p.ts1, p.ts2, p.ip, p.ip1, p.ip2, p.op1, p.op2
1734    );
1735    let spatial = emit_spatial_outer_test(m, n, p, &class)?;
1736    Ok((m, n, class, spatial))
1737}