Skip to main content

logicaffeine_compile/codegen_sva/
spatial_emit.rs

1//! **Spatial source emission** for a counted loop — the bridge from the certified fabric IR to the open
2//! Plasticine toolchain (Spatial → PIR → plastiroute → Tungsten; Koeplinger et al., PLDI 2018; the chain is
3//! mirrored at gitlab.com/dataplane-ai). P4 of the Plasticine lane.
4//!
5//! A [`LoopFabric`] is one `Foreach` controller: its carried state registers become `Reg`s, the induction
6//! variable the counter, the body cells `val`s in SSA order, a predicated select a `mux`, and the next-state
7//! sources the register writes at the end of the iteration (computed first, assigned after — the parallel
8//! register-update semantics the fabric has). Every LOGOS `Int` is a 64-bit `Long`. What this emitter does
9//! not spell (sequence tiles, heap islands, the wider `FuncUnit` family) is refused TYPED so a program never
10//! reaches the toolchain with silently different meaning.
11
12use super::fabric_rtl::{AluOp, CmpKind, FuncUnit, LoopCell, LoopFabric, Src};
13use crate::tensor_recognize::{Axis, DagNode, Fill, Stop, TermDag, Ty, Scalar, ZipOp, ZipTerm};
14use std::collections::HashMap;
15
16/// A LOGOS `Int` constant as a Spatial `Long`: an `Int`-sized literal is cast, a wider one is a Scala `Long`
17/// literal lifted by Spatial's `FixFromLong` (a bare `123456789012.to[Long]` is "integer number too large").
18fn lit(c: i64) -> String {
19    if i32::try_from(c).is_ok() {
20        if c < 0 {
21            format!("({c}).to[Long]")
22        } else {
23            format!("{c}.to[Long]")
24        }
25    } else {
26        format!("({c}L: Long)")
27    }
28}
29
30fn alu(op: AluOp, a: &str, b: &str) -> String {
31    match op {
32        AluOp::Add => format!("{a} + {b}"),
33        AluOp::Sub => format!("{a} - {b}"),
34        AluOp::Mul => format!("{a} * {b}"),
35        AluOp::And => format!("{a} & {b}"),
36        AluOp::Or => format!("{a} | {b}"),
37        AluOp::Xor => format!("{a} ^ {b}"),
38        AluOp::Shl => format!("{a} << ({b} & 63.to[Long]).to[Int]"),
39        AluOp::Shr => format!("{a} >> ({b} & 63.to[Long]).to[Int]"),
40        AluOp::UShr => format!("{a} >>> ({b} & 63.to[Long]).to[Int]"),
41    }
42}
43
44fn cmp(c: CmpKind) -> &'static str {
45    match c {
46        CmpKind::Lt => "<",
47        CmpKind::Gt => ">",
48        CmpKind::Le => "<=",
49        CmpKind::Ge => ">=",
50        CmpKind::Eq => "===",
51        CmpKind::Ne => "=!=",
52    }
53}
54
55/// The `Accel { … }` block for the counted loop `lf` (shared by the app and the test flavours), indented by
56/// `pad`, or a typed reason it cannot be spelled. `arg_in` names circuit input `k` as the host reads it.
57fn accel_block(
58    lf: &LoopFabric,
59    pad: &str,
60    arg_in: &dyn Fn(usize) -> String,
61    const_params: Option<&[i64]>,
62) -> Result<String, String> {
63    if lf.seq_input.is_some() || lf.seq_heap_ptr.is_some() {
64        return Err("sequence loops (`Repeat for x in xs`) need the DRAM → PMU tile-load stage, which this emitter does not spell yet".into());
65    }
66    if !lf.epilogue.is_empty() || !lf.prologue_heap.is_empty() {
67        return Err(
68            "a post-loop epilogue / prologue heap island is not spelled by this emitter yet".into(),
69        );
70    }
71    let body_in = |k: usize| -> String {
72        if k < lf.n_state {
73            format!("c{k}")
74        } else if k == lf.n_state {
75            "iv".to_string()
76        } else {
77            format!("inv{}", k - lf.n_state - 1)
78        }
79    };
80    let over_circuit = |s: &Src| match s {
81        Src::In(k) => arg_in(*k),
82        Src::Val(v) => format!("v{v}"),
83        Src::Const(c) => lit(*c),
84    };
85    let over_body = |s: &Src| match s {
86        Src::In(k) => body_in(*k),
87        Src::Val(v) => format!("v{v}"),
88        Src::Const(c) => lit(*c),
89    };
90    let mut body = Vec::new();
91    for (i, cell) in lf.body.iter().enumerate() {
92        let rhs = match cell {
93            LoopCell::Arith { op, a, b } => alu(*op, &over_body(a), &over_body(b)),
94            LoopCell::Select {
95                cmp: c,
96                ca,
97                cb,
98                t,
99                e,
100            } => {
101                format!(
102                    "mux({} {} {}, {}, {})",
103                    over_body(ca),
104                    cmp(*c),
105                    over_body(cb),
106                    over_body(t),
107                    over_body(e)
108                )
109            }
110            LoopCell::Func {
111                unit: FuncUnit::DivS,
112                a,
113                b,
114            } => format!("{} / {}", over_body(a), over_body(b)),
115            LoopCell::Func {
116                unit: FuncUnit::ModS,
117                a,
118                b,
119            } => format!("{} % {}", over_body(a), over_body(b)),
120            LoopCell::Func { unit, .. } => {
121                return Err(format!(
122                    "functional unit `{unit:?}` has no Spatial spelling in this emitter yet"
123                ));
124            }
125            LoopCell::MemRead { .. }
126            | LoopCell::HeapRead { .. }
127            | LoopCell::HeapWrite { .. }
128            | LoopCell::HeapAlloc { .. } => {
129                return Err("memory tiles (sequence / heap) need a PMU-resident buffer this emitter does not spell yet".into());
130            }
131            LoopCell::VectorArith { .. } => {
132                return Err("packed-SIMD tiles are not spelled by this emitter yet".into());
133            }
134        };
135        body.push(format!("{pad}    val v{i} = {rhs}"));
136    }
137    // Loop bounds: a constant is a bare `Int` literal; an input bound is the baked parameter when the block is
138    // specialized (the regression flavour — Spatial then proves every iteration whole and emits no
139    // counter-valid enable on the carried write, which PIR's Tungsten codegen cannot type), else narrowed.
140    let bound = |s: &Src| match s {
141        Src::Const(c) => c.to_string(),
142        Src::In(k) if const_params.is_some() => const_params.unwrap()[*k].to_string(),
143        other => format!("({}).to[Int]", over_circuit(other)),
144    };
145    // Carried registers take the DEFAULT init (zero); the LOGOS init is PEELED into the first iteration —
146    // each state read is `mux(iv == lo, init, s.value)` — so the loop is one self-contained controller with
147    // uniform read counts. PIR refuses the alternatives: a register constructed with a non-zero init
148    // ("inits already has value Some(0), but reupdate to 1" in its RewriteTransformer), a bare init write
149    // before the loop ("Don't know how to infer vec of MemWrite…_en" in its Tungsten codegen), and an init
150    // written from a one-trip loop (its RuntimeAnalyzer asserts every read in a context has one trip count).
151    let lo = over_circuit(&lf.lo);
152    let mut out = String::new();
153    out.push_str(&format!("{pad}Accel {{\n"));
154    for (j, inv) in lf.invariants.iter().enumerate() {
155        out.push_str(&format!("{pad}  val inv{j} = {}\n", over_circuit(inv)));
156    }
157    for k in 0..lf.n_state {
158        out.push_str(&format!("{pad}  val s{k} = Reg[Long]\n"));
159    }
160    out.push_str(&format!(
161        "{pad}  Foreach({} until {} + 1) {{ i =>\n{pad}    val iv = i.to[Long]\n",
162        bound(&lf.lo),
163        bound(&lf.hi)
164    ));
165    for (k, init) in lf.init.iter().enumerate() {
166        out.push_str(&format!(
167            "{pad}    val c{k} = mux(iv === {lo}, {}, s{k}.value)\n",
168            over_circuit(init)
169        ));
170    }
171    for line in &body {
172        out.push_str(line);
173        out.push('\n');
174    }
175    for (k, next) in lf.next.iter().enumerate() {
176        out.push_str(&format!("{pad}    val n{k} = {}\n", over_body(next)));
177    }
178    for k in 0..lf.n_state {
179        out.push_str(&format!("{pad}    s{k} := n{k}\n"));
180    }
181    out.push_str(&format!("{pad}  }}\n"));
182    out.push_str(&format!("{pad}  out := s{}.value\n", lf.result_state));
183    out.push_str(&format!("{pad}}}\n"));
184    Ok(out)
185}
186
187/// Emit a Spatial REGRESSION-HARNESS app (`class <class_name> extends SpatialTest`) for the counted loop: the
188/// sample `params` are its `runtimeArgs`, the VM's answer `gold` is checked on the host, and the harness's
189/// `PASS: true (<class_name>)` line is what the toolchain ledger parses. Values are 64-bit `Long`: PIR's
190/// Tungsten codegen lowers `Fix(true,64,0)` to C `long`, so the simulation computes what the VM computes
191/// (the ISCA'17 FU is 32-bit; a silicon run would need the narrower type).
192pub fn emit_spatial_counted_loop_test(
193    lf: &LoopFabric,
194    class_name: &str,
195    params: &[i64],
196    gold: i64,
197) -> Result<String, String> {
198    if lf.n_in != params.len() {
199        return Err(format!(
200            "{} parameters but {} runtime args",
201            lf.n_in,
202            params.len()
203        ));
204    }
205    let accel = accel_block(lf, "    ", &|k| format!("in{k}.value"), Some(params))?;
206    let mut out = String::new();
207    out.push_str(&format!(
208        "// Generated by LOGOS (logicaffeine_compile::codegen_sva::spatial_emit) — Spatial regression app for the\n// counted loop `{class_name}`: runtime args {params:?} (baked into the loop bounds), expected {gold} (the LOGOS VM's answer).\n"
209    ));
210    out.push_str("import spatial.dsl._\n\n");
211    out.push_str(&format!(
212        "@spatial class {class_name} extends SpatialTest {{\n"
213    ));
214    out.push_str(&format!(
215        "  override def runtimeArgs: Args = \"{}\"\n\n",
216        params
217            .iter()
218            .map(|p| p.to_string())
219            .collect::<Vec<_>>()
220            .join(" ")
221    ));
222    out.push_str("  def main(args: Array[String]): Unit = {\n");
223    for k in 0..lf.n_in {
224        out.push_str(&format!(
225            "    val in{k} = ArgIn[Long]\n    setArg(in{k}, args({k}).to[Long])\n"
226        ));
227    }
228    out.push_str("    val out = ArgOut[Long]\n");
229    out.push_str(&accel);
230    out.push_str(&format!("    val gold = {}\n", lit(gold)));
231    out.push_str("    val result = getArg(out)\n");
232    out.push_str("    val cksum = result == gold\n");
233    out.push_str(&format!("    println(r\"result $result gold $gold\")\n    println(\"PASS: \" + cksum + \" ({class_name})\")\n    assert(cksum)\n"));
234    out.push_str("  }\n}\n");
235    Ok(out)
236}
237
238/// Emit Spatial source for the counted loop `lf` of LOGOS function `fn_name` as a standalone `SpatialApp`
239/// (arguments from the command line, the result printed), or a typed reason it cannot.
240pub fn emit_spatial_counted_loop(lf: &LoopFabric, fn_name: &str) -> Result<String, String> {
241    let accel = accel_block(lf, "    ", &|k| format!("in{k}.value"), None)?;
242    let mut out = String::new();
243    out.push_str(&format!(
244        "// Generated by LOGOS (logicaffeine_compile::codegen_sva::spatial_emit) from `{fn_name}` — Spatial source\n// (Koeplinger et al., PLDI 2018) for the open Plasticine toolchain. LOGOS `Int` is a 64-bit `Long`.\n"
245    ));
246    out.push_str("import spatial.dsl._\n\n");
247    out.push_str(&format!(
248        "@spatial object Logos_{fn_name} extends SpatialApp {{\n"
249    ));
250    out.push_str("  def main(args: Array[String]): Unit = {\n");
251    for k in 0..lf.n_in {
252        out.push_str(&format!(
253            "    val in{k} = ArgIn[Long]\n    setArg(in{k}, args({k}).to[Long])\n"
254        ));
255    }
256    out.push_str("    val out = ArgOut[Long]\n");
257    out.push_str(&accel);
258    out.push_str(&format!("    println(r\"{fn_name} = ${{getArg(out)}}\")\n"));
259    out.push_str("  }\n}\n");
260    Ok(out)
261}
262
263/// Emit a Spatial REGRESSION-HARNESS GEMM app for `C[m×n] = A[m×k] · B[k×n]` — the structure of Spatial's own
264/// `regression/GEMM.scala` (an output tile `its × ts` accumulated over `k` in `ts`-chunks by a `MemReduce`, an
265/// `ip`-lane `Reduce` per dot product) with OUR shape and tiles, so a LOGOS matmul runs through the public
266/// chain as the toolchain's authors would write it. Host data are small residues so the 32-bit sums stay
267/// exact and the harness's `PASS` line means equality. Typed refusal when the tiles do not divide the shape
268/// (the template streams whole tiles).
269/// How wide each loop of the GEMM nest runs. Spatial's own regression variants set exactly these, and a
270/// factor left at 1 is a loop we run serially where the hand-written app runs it wide.
271#[derive(Clone, Copy, Debug, PartialEq, Eq)]
272pub struct GemmUnroll {
273    /// Outer tile loops over the output — rows and columns of tiles.
274    pub ii: usize,
275    pub jj: usize,
276    /// The reduction over K-chunks.
277    pub kk: usize,
278    /// Inner element loops within a tile — rows and columns.
279    pub i: usize,
280    pub j: usize,
281}
282
283impl Default for GemmUnroll {
284    /// Serial everywhere. The innermost dot is always `par ip`, which is a separate argument.
285    fn default() -> Self {
286        GemmUnroll {
287            ii: 1,
288            jj: 1,
289            kk: 1,
290            i: 1,
291            j: 1,
292        }
293    }
294}
295
296impl GemmUnroll {
297    /// The widest any SINGLE loop may be unrolled, and the largest TOTAL unroll across all of them.
298    ///
299    /// Both are measured frontiers, not resource derivations, and the distinction between them matters. A
300    /// 16-wide column loop at ts=32 does not merely stop paying: PIR emitted over eighteen thousand Tungsten
301    /// contexts and made no progress in forty minutes on a program that otherwise compiles in two. But that
302    /// is a PER-LOOP effect — widening one loop 16 ways instantiates sixteen reduces fed by one operand
303    /// buffer — and it does not say a product of 16 spread across four loops is unplaceable. Spatial's own
304    /// variants confirm the split: they never widen a single loop past 3, and they freely combine three
305    /// loops at 2 for a product of 8.
306    ///
307    /// So the search is bounded per loop by what stalled, and in total by what has been demonstrated to
308    /// COMPLETE on the chain — which is 4, measured from both sides:
309    ///
310    ///   * their `GEMM_10` (loop_i=2 loop_j=2, product 4) simulates in minutes at 9,065 cycles;
311    ///   * a product of 8 does not. Our own emission at kk=2 i=2 j=2 made no progress in sixteen minutes,
312    ///     the same signature as the 16-wide single loop, and their `GEMM_13` (loop_ii=2 loop_jj=2, also
313    ///     product 4 but on the OUTER tile loops) stalled at twenty-five minutes — which is why the outer
314    ///     loops are ranked last: they replicate the whole tile pipeline rather than one reduce.
315    ///
316    /// Raise it when the parity corpus shows a wider configuration completing and winning. Both numbers are
317    /// evidence, not intuition, and neither is tuned to a shape.
318    pub const MAX_PER_LOOP: usize = 3;
319    pub const MAX_TOTAL_UNROLL: usize = 4;
320
321    /// Kept as the single knob the older call sites pass; the total is what bounds a whole configuration.
322    pub const PLACEABLE_UNROLL: usize = Self::MAX_TOTAL_UNROLL;
323
324    /// Every divisor of `extent` that is at most `cap`, largest first.
325    fn divisors(extent: usize, cap: usize) -> Vec<usize> {
326        (1..=extent.min(cap).max(1))
327            .rev()
328            .filter(|f| extent % f == 0)
329            .collect()
330    }
331
332    /// Search the unroll space for the configuration that does the most parallel work per cycle within what
333    /// the toolchain can place.
334    ///
335    /// The space is small — each factor divides its own loop extent — so this enumerates rather than
336    /// guesses. Two facts order the search, both measured on the vendor's own simulator at 128^3, ts 64,
337    /// its 64, ip 16, where the serial program costs 132,068 cycles:
338    ///
339    ///   * widening ANY single loop by 2 roughly halves the cycles — `loop_j` 66,587, `loop_i` 66,713,
340    ///     `loop_kk` 67,700 — so total parallel work is what the objective should maximise;
341    ///   * among equal products the column loop wins, because `j` iterations are independent dot products
342    ///     over the SAME `a_sram`, while `i` reloads that buffer per iteration and `kk` adds a partial-sum
343    ///     buffer per parallel chunk. The measured ordering j < i < kk is exactly that reasoning.
344    ///
345    /// So: maximise the product subject to the placement frontier, break ties by that ordering. No shape is
346    /// named anywhere — the same rule runs for every tiling, and a shape it has never seen gets the same
347    /// treatment as one in the corpus.
348    pub fn search(ts: usize, its: usize, k_chunks: usize, budget: usize) -> GemmUnroll {
349        Self::search_nest(ts, its, k_chunks, 1, 1, budget)
350    }
351
352    /// The full five-loop search. `m_tiles` and `n_tiles` are how many output tiles the shape has in each
353    /// direction, which is what the outer `ii` / `jj` loops range over.
354    pub fn search_nest(
355        ts: usize,
356        its: usize,
357        k_chunks: usize,
358        m_tiles: usize,
359        n_tiles: usize,
360        budget: usize,
361    ) -> GemmUnroll {
362        let budget = budget.max(1);
363        let per = Self::MAX_PER_LOOP;
364        let mut best = GemmUnroll::default();
365        let mut best_key = (1usize, 0i32);
366        for &j in &Self::divisors(ts, per) {
367            for &i in &Self::divisors(its, per) {
368                for &kk in &Self::divisors(k_chunks, per) {
369                    for &jj in &Self::divisors(n_tiles, per) {
370                        for &ii in &Self::divisors(m_tiles, per) {
371                            let product = j * i * kk * jj * ii;
372                            if product > budget {
373                                continue;
374                            }
375                            // Tie-break among equal parallel work, by what each loop costs beyond the
376                            // arithmetic. `j` iterations are independent dots over the SAME operand buffer.
377                            // `i` reloads that buffer per iteration. `kk` adds a partial-sum buffer per
378                            // parallel chunk. The outer tile loops replicate the whole tile pipeline and are
379                            // the most expensive, so they are taken last. Measured at 128^3: j 66,587,
380                            // i 66,713, kk 67,700 against 132,068 serial.
381                            let pref = 4 * (j > 1) as i32
382                                + 3 * (i > 1) as i32
383                                + 2 * (kk > 1) as i32
384                                + (jj > 1) as i32;
385                            if (product, pref) > best_key {
386                                best_key = (product, pref);
387                                best = GemmUnroll { ii, jj, kk, i, j };
388                            }
389                        }
390                    }
391                }
392            }
393        }
394        best
395    }
396
397    /// The compiler's choice for a tiling when the tile counts are not known to the caller.
398    pub fn choose(ts: usize, its: usize, ip: usize) -> GemmUnroll {
399        let _ = ip;
400        Self::search(ts, its, 1, Self::MAX_TOTAL_UNROLL)
401    }
402}
403
404pub fn emit_spatial_gemm_test(
405    m: usize,
406    n: usize,
407    k: usize,
408    ts: usize,
409    its: usize,
410    ip: usize,
411    class_name: &str,
412) -> Result<String, String> {
413    // The tile counts the outer loops range over, and the K-chunk count the reduction ranges over, are all
414    // known here — so the search sees the whole nest rather than the inner two loops.
415    let u = GemmUnroll::search_nest(
416        ts,
417        its,
418        k / ts.max(1),
419        m / its.max(1),
420        n / ts.max(1),
421        GemmUnroll::MAX_TOTAL_UNROLL,
422    );
423    emit_spatial_gemm_test_with(m, n, k, ts, its, ip, u, class_name)
424}
425
426/// The same emission with the loop widths given explicitly — the form the parity corpus uses to reproduce a
427/// Spatial variant's exact configuration.
428pub fn emit_spatial_gemm_test_with(
429    m: usize,
430    n: usize,
431    k: usize,
432    ts: usize,
433    its: usize,
434    ip: usize,
435    u: GemmUnroll,
436    class_name: &str,
437) -> Result<String, String> {
438    if ts == 0 || its == 0 || ip == 0 {
439        return Err("tiles and lanes are at least 1".into());
440    }
441    if m % its != 0 || n % ts != 0 || k % ts != 0 {
442        return Err(format!("the Spatial GEMM template streams whole tiles: its={its} must divide m={m}, ts={ts} must divide n={n} and k={k}"));
443    }
444    if ts % ip != 0 {
445        return Err(format!(
446            "the {ip}-lane reduce must divide the k-chunk ts={ts}"
447        ));
448    }
449    let mut o = String::new();
450    o.push_str(&format!(
451        "// Generated by LOGOS (logicaffeine_compile::codegen_sva::spatial_emit) from a LOGOS matmul recognized as\n// C[{m}x{n}] = A[{m}x{k}] · B[{k}x{n}]: Spatial's GEMM structure (regression/GEMM.scala) with our shape, tiles\n// its={its} ts={ts} and {ip}-lane reduce. Gold on the host; the harness's PASS line is what the ledger parses.\n"
452    ));
453    o.push_str("import spatial.dsl._\n\n");
454    o.push_str(&format!(
455        "@spatial class {class_name} extends SpatialTest {{\n"
456    ));
457    o.push_str("  type T = Int\n");
458    o.push_str(&format!("  val M = {m}\n  val N = {n}\n  val K = {k}\n  val ts = {ts}\n  val its = {its}\n  val ip = {ip}\n\n"));
459    o.push_str("  def gemm(a_data: Matrix[T], b_data: Matrix[T], c_init: Matrix[T]) = {\n");
460    o.push_str("    val a_dram = DRAM[T](M, K)\n    val b_dram = DRAM[T](K, N)\n    val c_dram = DRAM[T](M, N)\n");
461    o.push_str(
462        "    setMem(a_dram, a_data)\n    setMem(b_dram, b_data)\n    setMem(c_dram, c_init)\n",
463    );
464    o.push_str("    Accel {\n");
465    // Every loop of the nest carries its own parallelisation factor. Emitting `par 1` everywhere but the
466    // innermost dot is what made a compiled GEMM lose to the same GEMM written by hand: Spatial's own
467    // variants raise `loop_ii`/`loop_jj`/`loop_kk`/`loop_i`/`loop_j`, and a factor left at 1 is strictly less
468    // work per cycle. `par 1` is emitted as no annotation at all, so a fully-serial nest is byte-identical to
469    // what this emitted before.
470    let par = |f: usize| {
471        if f > 1 {
472            format!(" par {f}")
473        } else {
474            String::new()
475        }
476    };
477    o.push_str(&format!(
478        "      Foreach(M by its{}, N by ts{}) {{ (ii, jj) =>\n",
479        par(u.ii),
480        par(u.jj)
481    ));
482    o.push_str("        val c_col = SRAM[T](its, ts)\n");
483    o.push_str(&format!(
484        "        MemReduce(c_col par ip)(K by ts{}) {{ kk =>\n",
485        par(u.kk)
486    ));
487    o.push_str("          val c_col_partial = SRAM[T](its, ts)\n");
488    o.push_str("          val b_sram = SRAM[T](ts, ts)\n");
489    o.push_str("          b_sram load b_dram(kk::kk+ts, jj::jj+ts par ip)\n");
490    o.push_str(&format!(
491        "          Foreach(its by 1{}) {{ i =>\n",
492        par(u.i)
493    ));
494    o.push_str("            val a_sram = SRAM[T](ts)\n");
495    o.push_str("            a_sram load a_dram(ii+i, kk::kk+ts par ip)\n");
496    o.push_str(&format!(
497        "            Foreach(ts by 1{}) {{ j =>\n",
498        par(u.j)
499    ));
500    o.push_str("              val dot = Reduce(Reg[T])(ts by 1 par ip) { k =>\n");
501    o.push_str("                b_sram(k, j) * a_sram(k)\n");
502    o.push_str("              } { _ + _ }\n");
503    o.push_str("              Pipe { c_col_partial(i, j) = dot }\n");
504    o.push_str("            }\n");
505    o.push_str("          }\n");
506    o.push_str("          c_col_partial\n");
507    o.push_str("        } { _ + _ }\n");
508    o.push_str("        c_dram(ii::ii+its, jj::jj+ts par ip) store c_col\n");
509    o.push_str("      }\n");
510    o.push_str("    }\n");
511    o.push_str("    getMatrix(c_dram)\n");
512    o.push_str("  }\n\n");
513    o.push_str("  def main(args: Array[String]): Unit = {\n");
514    o.push_str("    val a_data = (0::M, 0::K) { (i, j) => ((i * K + j) % 7).to[T] }\n");
515    o.push_str("    val b_data = (0::K, 0::N) { (i, j) => ((i * N + j) % 5).to[T] }\n");
516    o.push_str("    val c_init = (0::M, 0::N) { (i, j) => 0.to[T] }\n");
517    o.push_str("    val c_gold = (0::M, 0::N) { (i, j) => Array.tabulate(K) { k => a_data(i, k) * b_data(k, j) }.reduce { _ + _ } }\n");
518    o.push_str("    val c_result = gemm(a_data, b_data, c_init)\n");
519    o.push_str("    val cksum = approxEql(c_gold, c_result, margin = 0.05)\n");
520    o.push_str(&format!(
521        "    println(\"PASS: \" + cksum + \" ({class_name})\")\n    assert(cksum)\n"
522    ));
523    o.push_str("  }\n}\n");
524    Ok(o)
525}
526
527/// The knobs of Spatial's reduce-over-tiles regression apps (`pir/regression/DotProduct.scala`,
528/// `TPCHQ6.scala`): the tile `ts` each outer iteration loads of every column, how many tiles run at once
529/// (`op`), and the lanes of the loads and of the inner reduce (`ip`).
530#[derive(Clone, Copy, Debug, PartialEq, Eq)]
531pub struct ZipPar {
532    pub ts: usize,
533    pub ip: usize,
534    pub op: usize,
535}
536
537/// The knobs of Spatial's `OuterProduct` regression app (`pir/regression/OuterProduct.scala`): the tiles of
538/// the two vectors (`ts1`, `ts2`), the lanes of the loads and the store (`ip`), the lanes of the tile's
539/// column and row loops (`ip1`, `ip2`), and how many tiles of each vector run at once (`op1`, `op2`).
540#[derive(Clone, Copy, Debug, PartialEq, Eq)]
541pub struct OuterPar {
542    pub ts1: usize,
543    pub ts2: usize,
544    pub ip: usize,
545    pub ip1: usize,
546    pub ip2: usize,
547    pub op1: usize,
548    pub op2: usize,
549}
550
551fn at_least_one(knobs: &[(&str, usize)]) -> Result<(), String> {
552    match knobs.iter().find(|(_, v)| *v == 0) {
553        Some((k, _)) => Err(format!("`{k}` is at least 1")),
554        None => Ok(()),
555    }
556}
557
558/// One column of a zip sum as the app declares it: its name (a Scala identifier the app does not otherwise use)
559/// and the host data the program fills it with.
560#[derive(Clone, Debug, PartialEq, Eq)]
561pub struct ZipColumn {
562    pub name: String,
563    pub fill: Fill,
564}
565
566/// The identifiers the zip-sum app declares itself, which a column may not take.
567const ZIP_RESERVED: &[&str] = &[
568    "N", "ts", "ip", "op", "T", "out", "accO", "part", "gold", "cksum", "args", "i", "ii", "zipsum", "main",
569];
570
571/// A column's host data as Scala, `t` the element index.
572fn host_fill(f: &Fill) -> Result<String, String> {
573    match f {
574        Fill::Const(c) if i32::try_from(*c).is_ok() => Ok(format!("{c}.to[T]")),
575        Fill::Pattern { modulus, shift: 0 } => Ok(format!("(i % {modulus}).to[T]")),
576        Fill::Pattern { modulus, shift } => Ok(format!("((i % {modulus}) + {shift}).to[T]")),
577        other => Err(format!("a column filled as {other:?} has no host data the app can print")),
578    }
579}
580
581/// A column's element `t` under its fill.
582fn fill_at(f: &Fill, t: i64) -> i64 {
583    match f {
584        Fill::Const(c) => *c,
585        Fill::Pattern { modulus, shift } => t % modulus + shift,
586        _ => unreachable!("refused by host_fill"),
587    }
588}
589
590/// An operator as Spatial spells it, inside the accelerator and on the host alike.
591fn op_symbol(op: ZipOp) -> &'static str {
592    match op {
593        ZipOp::Add => "+",
594        ZipOp::Sub => "-",
595        ZipOp::Mul => "*",
596        ZipOp::Div => "/",
597        ZipOp::Pow => "**",
598        ZipOp::Lt => "<",
599        ZipOp::Gt => ">",
600        ZipOp::Le => "<=",
601        ZipOp::Ge => ">=",
602        ZipOp::Eq => "===",
603        ZipOp::Ne => "=!=",
604        ZipOp::And => "&&",
605        ZipOp::Or => "||",
606    }
607}
608
609/// The exponent of a power printed as Spatial's own apps write a whole one — a Scala `Int`, `x ** 2`, which Spatial
610/// stages as multiplication — when it is a non-negative whole-number constant; `None` for any other exponent, printed
611/// at the operand's type, a power the accelerator computes as such.
612fn whole_exponent(x: f64) -> Option<i64> {
613    (x >= 0.0 && x.fract() == 0.0 && x <= f64::from(i32::MAX)).then_some(x as i64)
614}
615
616/// Why a term has no value in the app's 32-bit arithmetic at one element, as a refusal names it.
617fn stop_reason(stop: Stop) -> &'static str {
618    match stop {
619        Stop::IntRange => "leaves the 32-bit Int the Spatial app computes in (the VM's Int is 64-bit)",
620        Stop::RealRange => "leaves the range of the 32-bit Float the Spatial app computes in (the VM's Real is a double)",
621        Stop::ZeroDivisor => "divides by zero",
622        Stop::NegativeExponent => "raises to a negative power",
623        Stop::Mistyped => "is not a value the app computes",
624    }
625}
626
627/// The term printed with column `k`'s element as `read(k)`: `===`/`=!=` and `mux` (Spatial's staged equality and
628/// select) inside the accelerator, the host's `if` for a select when `host`.
629fn zip_term(t: &ZipTerm, read: &dyn Fn(usize) -> String, host: bool, top: bool) -> String {
630    let wrap = |s: String| if top { s } else { format!("({s})") };
631    match t {
632        ZipTerm::Read(k) => read(*k),
633        ZipTerm::Const(c) => format!("{c}.to[T]"),
634        ZipTerm::Real(b) => format!("{:?}.to[T]", f64::from_bits(*b)),
635        ZipTerm::Abs(x) => format!("abs({})", zip_term(x, read, host, true)),
636        ZipTerm::Op(op, l, r) => {
637            let whole = match (op, &**r) {
638                (ZipOp::Pow, ZipTerm::Const(c)) => whole_exponent(*c as f64),
639                (ZipOp::Pow, ZipTerm::Real(b)) => whole_exponent(f64::from_bits(*b)),
640                _ => None,
641            };
642            let rhs = whole.map_or_else(|| zip_term(r, read, host, false), |n| n.to_string());
643            wrap(format!("{} {} {rhs}", zip_term(l, read, host, false), op_symbol(*op)))
644        }
645        ZipTerm::Select(c, a, b) => {
646            let (c, a, b) = (zip_term(c, read, host, true), zip_term(a, read, host, true), zip_term(b, read, host, true));
647            if host {
648                wrap(format!("if ({c}) {a} else {b}"))
649            } else {
650                format!("mux({c}, {a}, {b})")
651            }
652        }
653    }
654}
655
656/// Emit a Spatial REGRESSION-HARNESS zip sum `Σ_{t<n} f(c₀[t], c₁[t], …)` with the structure of Spatial's own
657/// reduce-over-tiles apps (`DotProduct`, `TPCHQ6`): a `ts`-element tile of every column loaded in parallel,
658/// `op` tiles reduced at once, each tile's sum an `ip`-lane `Reduce` of the term (the body of
659/// `spatial.lib.ML.sum_flat`) — at our length, over the program's own column data, so the app's gold is the
660/// LOGOS program's answer. The app computes in Spatial's 32-bit `Int`, as the regression apps do; a sum that
661/// would wrap there (where the VM's 64-bit `Int` does not) is refused, as is a tile that does not divide the
662/// length or a reduce wider than its tile.
663pub fn emit_spatial_zip_sum_test(
664    n: usize,
665    columns: &[ZipColumn],
666    term: &ZipTerm,
667    p: ZipPar,
668    class_name: &str,
669) -> Result<String, String> {
670    at_least_one(&[("ts", p.ts), ("ip", p.ip), ("op", p.op)])?;
671    if n % p.ts != 0 {
672        return Err(format!("the Spatial reduce-over-tiles template loads whole tiles: ts={} must divide N={n}", p.ts));
673    }
674    if p.ip > p.ts {
675        return Err(format!("the reduce is {} lanes wide over a tile of {}", p.ip, p.ts));
676    }
677    for (k, c) in columns.iter().enumerate() {
678        let ident = c.name.chars().next().map_or(false, |h| h.is_ascii_alphabetic())
679            && c.name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_');
680        if !ident || ZIP_RESERVED.contains(&c.name.as_str()) || columns[..k].iter().any(|o| o.name == c.name) {
681            return Err(format!("the column `{}` is not a name the app can declare", c.name));
682        }
683    }
684    if term.ty(&vec![Ty::Int; columns.len()]) != Some(Ty::Int) {
685        return Err("the reduce-over-tiles app sums an Int term of Int columns".to_string());
686    }
687    let fills = columns.iter().map(|c| host_fill(&c.fill)).collect::<Result<Vec<_>, _>>()?;
688    let (lo, hi) = (i32::MIN as i128, i32::MAX as i128);
689    let mut magnitude: i128 = 0;
690    for t in 0..n as i64 {
691        let row: Vec<Scalar> = columns.iter().map(|c| Scalar::Int(fill_at(&c.fill, t))).collect();
692        let v = match term.value(&row, (lo, hi), f64::MAX) {
693            Ok(Scalar::Int(v)) => v,
694            Ok(_) => return Err(format!("element {t} {}", stop_reason(Stop::Mistyped))),
695            Err(stop) => return Err(format!("element {t} {}", stop_reason(stop))),
696        };
697        magnitude += (v as i128).abs();
698        if magnitude > hi {
699            return Err(format!("the sum leaves the 32-bit Int the Spatial app computes in by element {t} (the VM's Int is 64-bit)"));
700        }
701    }
702    let ZipPar { ts, ip, op } = p;
703    let accel = zip_term(term, &|k| format!("{}Blk(ii)", columns[k].name), false, true);
704    let host = zip_term(term, &|k| format!("{}(i)", columns[k].name), true, true);
705    let mut o = String::new();
706    o.push_str(&format!(
707        "// Generated by LOGOS (logicaffeine_compile::codegen_sva::spatial_emit) from a LOGOS loop recognized as the zip\n// sum Σ_t {host} over N={n}: Spatial's reduce-over-tiles structure (regression/DotProduct.scala,\n// regression/TPCHQ6.scala) at ts={ts} ip={ip} op={op}, over the program's own data. The PASS line is what the ledger parses.\n"
708    ));
709    o.push_str("import spatial.dsl._\n\n");
710    o.push_str(&format!("@spatial class {class_name} extends SpatialTest {{\n"));
711    o.push_str("  type T = Int\n");
712    o.push_str(&format!("  val N = {n}\n  val ts = {ts}\n  val ip = {ip}\n  val op = {op}\n\n"));
713    let params: Vec<String> = columns.iter().map(|c| format!("{}In: Array[T]", c.name)).collect();
714    o.push_str(&format!("  def zipsum({}) = {{\n", params.join(", ")));
715    for c in columns {
716        o.push_str(&format!("    val {} = DRAM[T](N)\n", c.name));
717    }
718    o.push_str("    val out = ArgOut[T]\n");
719    for c in columns {
720        o.push_str(&format!("    setMem({0}, {0}In)\n", c.name));
721    }
722    o.push_str("    Accel {\n");
723    o.push_str("      val accO = Reg[T](0.to[T])\n");
724    o.push_str("      out := Reduce(accO)(N by ts par op) { i =>\n");
725    for c in columns {
726        o.push_str(&format!("        val {}Blk = SRAM[T](ts)\n", c.name));
727    }
728    o.push_str("        Parallel {\n");
729    for c in columns {
730        o.push_str(&format!("          {0}Blk load {0}(i::i+ts par ip)\n", c.name));
731    }
732    o.push_str("        }\n");
733    o.push_str("        val part = Reg[T]\n");
734    o.push_str(&format!("        Reduce(part)(ts par ip) {{ ii => {accel} }} {{ _ + _ }}\n"));
735    o.push_str("        part.value\n");
736    o.push_str("      } { _ + _ }\n");
737    o.push_str("    }\n");
738    o.push_str("    out\n");
739    o.push_str("  }\n\n");
740    o.push_str("  def main(args: Array[String]): Unit = {\n");
741    for (c, f) in columns.iter().zip(&fills) {
742        o.push_str(&format!("    val {} = Array.tabulate(N) {{ i => {f} }}\n", c.name));
743    }
744    let names: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect();
745    o.push_str(&format!("    val out = zipsum({})\n", names.join(", ")));
746    o.push_str(&format!("    val gold = Array.tabulate(N) {{ i => {host} }}.reduce {{ _ + _ }}\n"));
747    o.push_str("    val cksum = checkGold(out, gold)\n");
748    o.push_str(&format!("    println(\"PASS: \" + cksum + \" ({class_name})\")\n    assert(cksum)\n"));
749    o.push_str("  }\n}\n");
750    Ok(o)
751}
752
753/// Emit a Spatial REGRESSION-HARNESS outer product `out[i][j] = a[i]·b[j]` (`m × n`) with the structure of
754/// Spatial's own `OuterProduct` app — a `ts1`-tile of `a` per outer iteration, a `ts2`-tile of `b` per inner
755/// one, the `ts1 × ts2` product tile computed in SRAM and stored whole — at our shape. Host data are small
756/// residues; the harness's `PASS` line means equality. Typed refusal when a tile does not divide its vector
757/// or a tile loop is wider than its tile.
758pub fn emit_spatial_outer_test(m: usize, n: usize, p: OuterPar, class_name: &str) -> Result<String, String> {
759    let OuterPar { ts1, ts2, ip, ip1, ip2, op1, op2 } = p;
760    at_least_one(&[("ts1", ts1), ("ts2", ts2), ("ip", ip), ("ip1", ip1), ("ip2", ip2), ("op1", op1), ("op2", op2)])?;
761    if m % ts1 != 0 || n % ts2 != 0 {
762        return Err(format!(
763            "the Spatial OuterProduct template streams whole tiles: ts1={ts1} must divide M={m} and ts2={ts2} must divide N={n}"
764        ));
765    }
766    if ip2 > ts1 || ip1 > ts2 {
767        return Err(format!("the tile loops run {ip2} x {ip1} lanes over a {ts1} x {ts2} tile"));
768    }
769    let mut o = String::new();
770    o.push_str(&format!(
771        "// Generated by LOGOS (logicaffeine_compile::codegen_sva::spatial_emit) from a LOGOS nest recognized as the outer\n// product out[{m}x{n}] = a[i]·b[j]: Spatial's OuterProduct structure (regression/OuterProduct.scala) with tiles\n// ts1={ts1} ts2={ts2}, ip={ip} ip1={ip1} ip2={ip2} op1={op1} op2={op2}. Gold on the host; the PASS line is what the ledger parses.\n"
772    ));
773    o.push_str("import spatial.dsl._\n\n");
774    o.push_str(&format!("@spatial class {class_name} extends SpatialTest {{\n"));
775    o.push_str("  type T = Int\n");
776    o.push_str(&format!(
777        "  val M = {m}\n  val N = {n}\n  val ts1 = {ts1}\n  val ts2 = {ts2}\n  val ip = {ip}\n  val ip1 = {ip1}\n  val ip2 = {ip2}\n  val op1 = {op1}\n  val op2 = {op2}\n\n"
778    ));
779    o.push_str("  def outerproduct(a: Array[T], b: Array[T]) = {\n");
780    o.push_str("    val vec1 = DRAM[T](M)\n    val vec2 = DRAM[T](N)\n    val out = DRAM[T](M, N)\n");
781    o.push_str("    setMem(vec1, a)\n    setMem(vec2, b)\n");
782    o.push_str("    Accel {\n");
783    o.push_str("      Foreach(M by ts1 par op1) { i =>\n");
784    o.push_str("        val b1 = SRAM[T](ts1)\n");
785    o.push_str("        b1 load vec1(i::i+ts1 par ip)\n");
786    o.push_str("        Foreach(N by ts2 par op2) { j =>\n");
787    o.push_str("          val b2 = SRAM[T](ts2)\n");
788    o.push_str("          b2 load vec2(j::j+ts2 par ip)\n");
789    o.push_str("          val outTile = SRAM[T](ts1, ts2)\n");
790    o.push_str("          Foreach(ts1 par ip2, ts2 par ip1) { (ii, jj) => outTile(ii, jj) = b1(ii) * b2(jj) }\n");
791    o.push_str("          out(i::i+ts1, j::j+ts2 par ip) store outTile\n");
792    o.push_str("        }\n");
793    o.push_str("      }\n");
794    o.push_str("    }\n");
795    o.push_str("    out\n");
796    o.push_str("  }\n\n");
797    o.push_str("  def main(args: Array[String]): Unit = {\n");
798    o.push_str("    val a = Array.tabulate(M) { i => (i % 7).to[T] }\n");
799    o.push_str("    val b = Array.tabulate(N) { i => (i % 5).to[T] }\n");
800    o.push_str("    val out = outerproduct(a, b)\n");
801    o.push_str("    val gold = (0::M, 0::N) { (i, j) => a(i) * b(j) }\n");
802    o.push_str("    val cksum = checkGold(out, gold, 0)\n");
803    o.push_str(&format!("    println(\"PASS: \" + cksum + \" ({class_name})\")\n    assert(cksum)\n"));
804    o.push_str("  }\n}\n");
805    Ok(o)
806}
807
808/// The knobs of Spatial's `GDA` regression app (`pir/regression/GDA.scala`): the rows a tile of the data loads
809/// (`ts`), how many such tiles run at once (`op`), the rows of a tile reduced at once (`mp1`), the covariance rows
810/// one rank-1 update writes at once (`mp2`), and the lanes of the loads, the store and the column loops (`ip`).
811#[derive(Clone, Copy, Debug, PartialEq, Eq)]
812pub struct GdaPar {
813    pub ts: usize,
814    pub op: usize,
815    pub mp1: usize,
816    pub mp2: usize,
817    pub ip: usize,
818}
819
820/// One array a scatter row term reads, as the GDA app declares it: its name, its axis and its host data.
821#[derive(Clone, Debug, PartialEq, Eq)]
822pub struct GdaColumn {
823    pub name: String,
824    pub axis: Axis,
825    pub fill: Fill,
826}
827
828/// Emit a Spatial REGRESSION-HARNESS scatter accumulation `S = Σ_{r<R} d_r d_rᵀ` (`C × C`) with the structure of
829/// Spatial's own `GDA` app — the column-axis arrays loaded once, a `MemReduce` over `ts`-row tiles of the row-axis
830/// arrays and of the row-major data, inside it a `MemReduce` over the tile's rows that computes each row's `d` into
831/// `subTile` and its outer product into `sigmaTile` — at our shape, over the program's own data. The app computes in
832/// Spatial's 32-bit `Int`; a sum that would wrap there (where the VM's 64-bit `Int` does not) is refused, as is a
833/// tile that does not divide the rows.
834pub fn emit_spatial_gda_test(
835    rows: usize,
836    cols: usize,
837    columns: &[GdaColumn],
838    term: &ZipTerm,
839    p: GdaPar,
840    class_name: &str,
841) -> Result<String, String> {
842    let GdaPar { ts, op, mp1, mp2, ip } = p;
843    at_least_one(&[("ts", ts), ("op", op), ("mp1", mp1), ("mp2", mp2), ("ip", ip)])?;
844    if rows % ts != 0 {
845        return Err(format!("the Spatial GDA template loads whole tiles: ts={ts} must divide R={rows}"));
846    }
847    for (k, c) in columns.iter().enumerate() {
848        let ident = c.name.chars().next().map_or(false, |h| h.is_ascii_alphabetic())
849            && c.name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_');
850        let reserved = ["R", "C", "ts", "op", "mp1", "mp2", "ip", "T", "sigma", "sigmaOut", "sigmaBlk", "sigmaTile",
851                        "subTile", "gold", "result", "cksum", "args", "r", "rr", "cc", "ii", "jj", "i", "j", "gda", "main"];
852        if !ident || reserved.contains(&c.name.as_str()) || columns[..k].iter().any(|o| o.name == c.name) {
853            return Err(format!("the array `{}` is not a name the app can declare", c.name));
854        }
855    }
856    if term.ty(&vec![Ty::Int; columns.len()]) != Some(Ty::Int) {
857        return Err("the GDA app centres Int rows on Int means".to_string());
858    }
859    let fills = columns.iter().map(|c| host_fill(&c.fill)).collect::<Result<Vec<_>, _>>()?;
860    let (lo, hi) = (i32::MIN as i128, i32::MAX as i128);
861    let d = |r: usize, c: usize| -> Result<i64, String> {
862        let row: Vec<Scalar> = columns
863            .iter()
864            .map(|col| {
865                let t = match col.axis {
866                    Axis::Row => r,
867                    Axis::Col => c,
868                    Axis::RowCol => r * cols + c,
869                };
870                Scalar::Int(fill_at(&col.fill, t as i64))
871            })
872            .collect::<Vec<_>>();
873        match term.value(&row, (lo, hi), f64::MAX) {
874            Ok(Scalar::Int(v)) => Ok(v),
875            Ok(_) => Err(format!("row {r} column {c} {}", stop_reason(Stop::Mistyped))),
876            Err(stop) => Err(format!("row {r} column {c} {}", stop_reason(stop))),
877        }
878    };
879    let dr: Vec<Vec<i64>> = (0..rows).map(|r| (0..cols).map(|c| d(r, c)).collect::<Result<_, _>>()).collect::<Result<_, _>>()?;
880    for i in 0..cols {
881        for j in 0..cols {
882            let magnitude: i128 = dr.iter().map(|v| (v[i] as i128 * v[j] as i128).abs()).sum();
883            if magnitude > hi {
884                return Err(format!("covariance [{i}][{j}] leaves the 32-bit Int the Spatial app computes in (the VM's Int is 64-bit)"));
885            }
886        }
887    }
888    let tile = |c: &GdaColumn| format!("{}Tile", c.name);
889    let accel = zip_term(term, &|k| match columns[k].axis {
890        Axis::Row => format!("{}(rr)", tile(&columns[k])),
891        Axis::Col => format!("{}(cc)", tile(&columns[k])),
892        Axis::RowCol => format!("{}(rr, cc)", tile(&columns[k])),
893    }, false, true);
894    let host = |at: &str| zip_term(term, &|k| match columns[k].axis {
895        Axis::Row => format!("{}(r)", columns[k].name),
896        Axis::Col => format!("{}({at})", columns[k].name),
897        Axis::RowCol => format!("{}(r * C + {at})", columns[k].name),
898    }, true, false);
899    let loads = |lines: Vec<String>, pad: &str, o: &mut String| {
900        if lines.len() > 1 {
901            o.push_str(&format!("{pad}Parallel {{\n"));
902            for l in lines {
903                o.push_str(&format!("{pad}  {l}\n"));
904            }
905            o.push_str(&format!("{pad}}}\n"));
906        } else {
907            for l in lines {
908                o.push_str(&format!("{pad}{l}\n"));
909            }
910        }
911    };
912    let mut o = String::new();
913    o.push_str(&format!(
914        "// Generated by LOGOS (logicaffeine_compile::codegen_sva::spatial_emit) from a LOGOS nest recognized as the scatter\n// accumulation S = Σ_r d_r d_rᵀ over R={rows} rows of C={cols}, d_r[c] = {}: Spatial's GDA structure\n// (regression/GDA.scala) at ts={ts} op={op} mp1={mp1} mp2={mp2} ip={ip}, over the program's own data. The PASS line is what the ledger parses.\n",
915        host("c")
916    ));
917    o.push_str("import spatial.dsl._\n\n");
918    o.push_str(&format!("@spatial class {class_name} extends SpatialTest {{\n"));
919    o.push_str("  type T = Int\n");
920    o.push_str(&format!("  val R = {rows}\n  val C = {cols}\n  val ts = {ts}\n  val op = {op}\n  val mp1 = {mp1}\n  val mp2 = {mp2}\n  val ip = {ip}\n\n"));
921    let params: Vec<String> = columns.iter().map(|c| format!("{}In: Array[T]", c.name)).collect();
922    o.push_str(&format!("  def gda({}) = {{\n", params.join(", ")));
923    for c in columns {
924        let dims = match c.axis {
925            Axis::Row => "R",
926            Axis::Col => "C",
927            Axis::RowCol => "R, C",
928        };
929        o.push_str(&format!("    val {} = DRAM[T]({dims})\n", c.name));
930    }
931    o.push_str("    val sigma = DRAM[T](C, C)\n");
932    for c in columns {
933        o.push_str(&format!("    setMem({0}, {0}In)\n", c.name));
934    }
935    o.push_str("    Accel {\n");
936    let by_axis = |a: Axis| columns.iter().filter(move |c| c.axis == a);
937    for c in by_axis(Axis::Col) {
938        o.push_str(&format!("      val {} = SRAM[T](C)\n", tile(c)));
939    }
940    loads(by_axis(Axis::Col).map(|c| format!("{} load {}(0 :: C par ip)", tile(c), c.name)).collect(), "      ", &mut o);
941    o.push_str("      val sigmaOut = SRAM[T](C, C)\n");
942    o.push_str("      MemReduce(sigmaOut par ip)(R by ts par op) { r =>\n");
943    for c in columns.iter().filter(|c| c.axis != Axis::Col) {
944        let dims = if c.axis == Axis::Row { "ts" } else { "ts, C" };
945        o.push_str(&format!("        val {} = SRAM[T]({dims})\n", tile(c)));
946    }
947    loads(
948        columns
949            .iter()
950            .filter(|c| c.axis != Axis::Col)
951            .map(|c| match c.axis {
952                Axis::Row => format!("{} load {}(r :: r + ts par ip)", tile(c), c.name),
953                _ => format!("{} load {}(r :: r + ts, 0 :: C par ip)", tile(c), c.name),
954            })
955            .collect(),
956        "        ",
957        &mut o,
958    );
959    o.push_str("        val sigmaBlk = SRAM[T](C, C)\n");
960    o.push_str("        MemReduce(sigmaBlk par ip)(ts par mp1) { rr =>\n");
961    o.push_str("          val subTile = SRAM[T](C)\n          val sigmaTile = SRAM[T](C, C)\n");
962    o.push_str(&format!("          Foreach(C par ip) {{ cc => subTile(cc) = {accel} }}\n"));
963    o.push_str("          Foreach(C by 1 par mp2) { ii =>\n");
964    o.push_str("            Foreach(C par ip) { jj => sigmaTile(ii, jj) = subTile(ii) * subTile(jj) }\n");
965    o.push_str("          }\n          sigmaTile\n        } { _ + _ }\n      } { _ + _ }\n");
966    o.push_str("      sigma(0 :: C, 0 :: C par ip) store sigmaOut\n    }\n    getMem(sigma)\n  }\n\n");
967    o.push_str("  def main(args: Array[String]): Unit = {\n");
968    for (c, f) in columns.iter().zip(&fills) {
969        let n = match c.axis {
970            Axis::Row => "R",
971            Axis::Col => "C",
972            Axis::RowCol => "R * C",
973        };
974        o.push_str(&format!("    val {} = Array.tabulate({n}) {{ i => {f} }}\n", c.name));
975    }
976    let names: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect();
977    o.push_str(&format!("    val result = gda({})\n", names.join(", ")));
978    o.push_str(&format!(
979        "    val gold = Array.tabulate(C) {{ i => Array.tabulate(C) {{ j => Array.tabulate(R) {{ r => {} * {} }}.reduce {{ _ + _ }} }} }}.flatten\n",
980        host("i"),
981        host("j")
982    ));
983    o.push_str("    val cksum = approxEql(gold, result)\n");
984    o.push_str(&format!("    println(\"PASS: \" + cksum + \" ({class_name})\")\n    assert(cksum)\n  }}\n}}\n"));
985    Ok(o)
986}
987
988/// The knobs of Spatial's `BlackScholes` regression app (`pir/regression/BlackScholes.scala`): the tile `ts` of every
989/// column each outer iteration streams through FIFOs, how many tiles run at once (`op`), and the lanes of the loads,
990/// the map and the store (`ip`).
991#[derive(Clone, Copy, Debug, PartialEq, Eq)]
992pub struct MapPar {
993    pub ts: usize,
994    pub op: usize,
995    pub ip: usize,
996}
997
998/// One column of a zip map as the app declares it: its name, its element type and the host data the program fills
999/// it with.
1000#[derive(Clone, Debug, PartialEq, Eq)]
1001pub struct MapColumn {
1002    pub name: String,
1003    pub ty: Ty,
1004    pub fill: Fill,
1005}
1006
1007/// Spatial's type for a LOGOS element type, as the regression apps declare it: their 32-bit `Int` and `Float`.
1008fn spatial_ty(t: Ty) -> &'static str {
1009    match t {
1010        Ty::Int => "Int",
1011        Ty::Real => "Float",
1012        Ty::Bool => "Bit",
1013    }
1014}
1015
1016/// Names an app's column may not take: Scala's keywords and the Spatial names a zip-map app uses.
1017const MAP_RESERVED: &[&str] = &[
1018    "N", "ts", "op", "ip", "i", "j", "r", "g", "gold", "result", "cksum", "zipmap", "main", "args", "abs", "mux", "min",
1019    "max", "pow", "sqrt", "exp", "ln", "Int", "Float", "Bit", "Array", "DRAM", "FIFO", "SRAM", "Reg", "Foreach",
1020    "Reduce", "Accel", "val", "var", "def", "type", "if", "else", "for", "while", "do", "new", "class", "object",
1021    "trait", "match", "case", "return", "true", "false", "null", "this", "super", "import", "package", "with",
1022    "extends", "override", "lazy", "yield", "try", "catch", "finally", "throw",
1023];
1024
1025/// A constant as the app spells it at type `at`: `3.to[Int]`, `0.25.to[Float]` (a Scala `Double` literal, so the
1026/// `Float` is the VM's double rounded once), a negative one parenthesized so it is a literal, not a negation.
1027fn literal(v: Scalar, at: Ty) -> String {
1028    let real = |x: f64| if x.is_sign_negative() { format!("({x:?}).to[Float]") } else { format!("{x:?}.to[Float]") };
1029    match (v, at) {
1030        (Scalar::Int(c), Ty::Real) => real(c as f64),
1031        (Scalar::Int(c), _) if c < 0 => format!("({c}).to[Int]"),
1032        (Scalar::Int(c), _) => format!("{c}.to[Int]"),
1033        (Scalar::Real(x), _) => real(x),
1034        (Scalar::Bool(b), _) => format!("{b}.to[Bit]"),
1035    }
1036}
1037
1038/// A column's host data as Scala, at its element type, the element's index the Scala expression `at`.
1039fn host_data(f: &Fill, ty: Ty, at: &str) -> Result<String, String> {
1040    let int = |c: i64| i32::try_from(c).is_ok();
1041    let at = if at.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { at.to_string() } else { format!("({at})") };
1042    match (f, ty) {
1043        (Fill::Const(c), Ty::Int) if int(*c) => Ok(literal(Scalar::Int(*c), Ty::Int)),
1044        (Fill::Pattern { modulus, shift: 0 }, Ty::Int) if int(*modulus) => Ok(format!("({at} % {modulus}).to[Int]")),
1045        (Fill::Pattern { modulus, shift }, Ty::Int) if int(*modulus) && int(*shift) && int(modulus + shift) => {
1046            Ok(format!("(({at} % {modulus}) + {shift}).to[Int]"))
1047        }
1048        (Fill::ConstF(b), Ty::Real) => Ok(literal(Scalar::Real(f64::from_bits(*b)), Ty::Real)),
1049        (Fill::RealPattern { modulus, shift, divisor }, Ty::Real) if int(*modulus) && int(*shift) && int(modulus + shift) => {
1050            Ok(format!(
1051                "((({at} % {modulus}) + {shift}).to[Float] / {})",
1052                literal(Scalar::Real(f64::from_bits(*divisor)), Ty::Real)
1053            ))
1054        }
1055        (other, ty) => Err(format!("a column of {ty:?} filled as {other:?} has no host data the app can print")),
1056    }
1057}
1058
1059/// A term printed from its DAG for a Spatial app. Each operand of an operator is printed at the operator's operand
1060/// type (an `Int` beside a `Real` converted with `.to[Float]`), a constant at the type it is used at; a subterm used
1061/// more than once is bound once, as a `val`, in the order first needed; `mux` and Spatial's `===`/`=!=` inside the
1062/// accelerator, the host's `if` for a select when `host`.
1063struct DagPrinter<'d> {
1064    dag: &'d TermDag,
1065    tys: &'d [Ty],
1066    read: &'d dyn Fn(usize) -> String,
1067    host: bool,
1068    names: HashMap<usize, String>,
1069    lines: Vec<String>,
1070}
1071
1072impl<'d> DagPrinter<'d> {
1073    fn print(dag: &'d TermDag, tys: &'d [Ty], read: &'d dyn Fn(usize) -> String, host: bool) -> (Vec<String>, String) {
1074        let mut p = DagPrinter { dag, tys, read, host, names: HashMap::new(), lines: Vec::new() };
1075        let root = p.at(dag.root, tys[dag.root]);
1076        (p.lines, root)
1077    }
1078
1079    /// Node `i` as an expression of type `want`.
1080    fn at(&mut self, i: usize, want: Ty) -> String {
1081        let own = self.tys[i];
1082        let text = match self.dag.nodes[i] {
1083            DagNode::Const(c) => return literal(Scalar::Int(c), want),
1084            DagNode::Real(b) => return literal(Scalar::Real(f64::from_bits(b)), want),
1085            DagNode::Read(k) => (self.read)(k),
1086            _ if self.names.contains_key(&i) => self.names[&i].clone(),
1087            node => {
1088                let expr = self.node(node, own);
1089                if self.dag.uses[i] > 1 {
1090                    let name = format!("t{}", self.names.len());
1091                    self.lines.push(format!("val {name} = {expr}"));
1092                    self.names.insert(i, name.clone());
1093                    name
1094                } else {
1095                    expr
1096                }
1097            }
1098        };
1099        if own == Ty::Int && want == Ty::Real {
1100            format!("{text}.to[Float]")
1101        } else {
1102            text
1103        }
1104    }
1105
1106    fn node(&mut self, node: DagNode, own: Ty) -> String {
1107        match node {
1108            DagNode::Abs(x) => format!("abs({})", self.at(x, own)),
1109            DagNode::Op(op, l, r) => {
1110                let operand = if op.connects() {
1111                    Ty::Bool
1112                } else if !op.compares() {
1113                    own
1114                } else if self.tys[l] == Ty::Real || self.tys[r] == Ty::Real {
1115                    Ty::Real
1116                } else {
1117                    Ty::Int
1118                };
1119                let whole = match (op, self.dag.nodes[r]) {
1120                    (ZipOp::Pow, DagNode::Const(c)) => whole_exponent(c as f64),
1121                    (ZipOp::Pow, DagNode::Real(b)) => whole_exponent(f64::from_bits(b)),
1122                    _ => None,
1123                };
1124                let a = self.at(l, operand);
1125                let b = whole.map_or_else(|| self.at(r, operand), |n| n.to_string());
1126                format!("({a} {} {b})", op_symbol(op))
1127            }
1128            DagNode::Select(c, a, b) => {
1129                let c = self.at(c, Ty::Bool);
1130                let (a, b) = (self.at(a, own), self.at(b, own));
1131                if self.host {
1132                    format!("(if ({c}) {a} else {b})")
1133                } else {
1134                    format!("mux({c}, {a}, {b})")
1135                }
1136            }
1137            DagNode::Read(_) | DagNode::Const(_) | DagNode::Real(_) => unreachable!("leaves are printed in place"),
1138        }
1139    }
1140}
1141
1142/// Emit a Spatial REGRESSION-HARNESS zip map `out[t] = f(c₀[t], c₁[t], …)` with the structure of Spatial's own
1143/// `BlackScholes` app: per `ts`-element tile, a FIFO of every column loaded in the order the program declares its
1144/// columns (`declared`, a permutation of the columns, which are in the order the term reads them), an `ip`-lane
1145/// `Foreach` dequeuing one element of every column and enqueuing the term's value, the output tile stored, `op`
1146/// tiles at once — at our length, over the program's own data, with the variant's PIR arguments (`pir_args`, the
1147/// suffix its `pirArgs` override appends). The term is printed as its DAG: a function the program calls twice is two
1148/// copies, as Spatial stages it, and a subterm used twice is computed once. The app computes in Spatial's 32-bit
1149/// `Int` and `Float`; a term whose `Int` would wrap there or whose `Real` would leave the `Float`'s range is refused,
1150/// as is a division by zero on any path (the host's gold would trap), a tile that does not divide the length, or a
1151/// map wider than its tile. The gold is the host's own evaluation of the term: a `Float` result passes within 0.1%
1152/// (and 0.001) of it, an `Int` one exactly.
1153#[allow(clippy::too_many_arguments)]
1154pub fn emit_spatial_zip_map_test(
1155    n: usize,
1156    columns: &[MapColumn],
1157    declared: &[usize],
1158    term: &ZipTerm,
1159    out: &str,
1160    p: MapPar,
1161    pir_args: Option<&str>,
1162    class_name: &str,
1163) -> Result<String, String> {
1164    let MapPar { ts, op, ip } = p;
1165    at_least_one(&[("ts", ts), ("op", op), ("ip", ip)])?;
1166    if n % ts != 0 {
1167        return Err(format!("the Spatial BlackScholes template streams whole tiles: ts={ts} must divide N={n}"));
1168    }
1169    if ip > ts {
1170        return Err(format!("the map is {ip} lanes wide over a tile of {ts}"));
1171    }
1172    let mut order = declared.to_vec();
1173    order.sort_unstable();
1174    if order != (0..columns.len()).collect::<Vec<_>>() {
1175        return Err("the declaration order is not an order of the columns".to_string());
1176    }
1177    let reads: Vec<Ty> = columns.iter().map(|c| c.ty).collect();
1178    let dag = TermDag::of(term);
1179    let tys = dag.types(&reads).ok_or("the term is ill-typed over its columns")?;
1180    let out_ty = tys[dag.root];
1181    if out_ty == Ty::Bool || reads.contains(&Ty::Bool) {
1182        return Err("the app maps Int and Float columns to an Int or Float column".to_string());
1183    }
1184    let mut taken: Vec<String> = Vec::new();
1185    for name in columns.iter().map(|c| c.name.as_str()).chain([out]) {
1186        let ident = name.chars().next().map_or(false, |h| h.is_ascii_lowercase())
1187            && name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_');
1188        if !ident || MAP_RESERVED.contains(&name) {
1189            return Err(format!("`{name}` is not a name the app can declare"));
1190        }
1191        taken.extend([name.to_string(), format!("{name}In"), format!("{name}Blk"), format!("{name}V")]);
1192    }
1193    let shared = (0..dag.nodes.len()).filter(|i| dag.uses[*i] > 1).count();
1194    taken.extend((0..shared).map(|k| format!("t{k}")));
1195    let mut seen = std::collections::HashSet::new();
1196    if let Some(clash) = taken.iter().find(|t| !seen.insert(t.as_str()) || MAP_RESERVED.contains(&t.as_str())) {
1197        return Err(format!("the app would declare `{clash}` twice"));
1198    }
1199    for node in &dag.nodes {
1200        if let DagNode::Const(c) = node {
1201            if i32::try_from(*c).is_err() {
1202                return Err(format!("the constant {c} {}", stop_reason(Stop::IntRange)));
1203            }
1204        }
1205    }
1206    let fills = columns.iter().map(|c| host_data(&c.fill, c.ty, "i")).collect::<Result<Vec<_>, _>>()?;
1207    let int_divisors: Vec<usize> = dag
1208        .nodes
1209        .iter()
1210        .filter_map(|node| match *node {
1211            DagNode::Op(ZipOp::Div, l, r) if tys[l] == Ty::Int && tys[r] == Ty::Int => Some(r),
1212            _ => None,
1213        })
1214        .collect();
1215    let (ints, reals) = ((i32::MIN as i128, i32::MAX as i128), f32::MAX as f64);
1216    for t in 0..n as i64 {
1217        let row: Vec<Scalar> = columns.iter().map(|c| c.fill.element(t).expect("host data has elements")).collect();
1218        dag.value(&row, ints, reals).map_err(|stop| format!("element {t} {}", stop_reason(stop)))?;
1219        for d in &int_divisors {
1220            if dag.value_of(*d, &row, ints, reals) == Ok(Scalar::Int(0)) {
1221                return Err(format!("element {t} {} on a path the program does not take, which the app's host would still evaluate", stop_reason(Stop::ZeroDivisor)));
1222            }
1223        }
1224    }
1225    let accel_read = |k: usize| format!("{}V", columns[k].name);
1226    let (accel_lines, accel_root) = DagPrinter::print(&dag, &tys, &accel_read, false);
1227    let host_read = |k: usize| format!("{}(i)", columns[k].name);
1228    let (host_lines, host_root) = DagPrinter::print(&dag, &tys, &host_read, true);
1229    let (oty, decl) = (spatial_ty(out_ty), declared.iter().map(|k| &columns[*k]).collect::<Vec<_>>());
1230    let mut o = String::new();
1231    o.push_str(&format!(
1232        "// Generated by LOGOS (logicaffeine_compile::codegen_sva::spatial_emit) from a LOGOS loop recognized as the zip\n// map {out}[t] = f({}) over N={n}: Spatial's BlackScholes structure (regression/BlackScholes.scala) at ts={ts} op={op}\n// ip={ip}, over the program's own data. The PASS line is what the ledger parses.\n",
1233        columns.iter().map(|c| format!("{}[t]", c.name)).collect::<Vec<_>>().join(", ")
1234    ));
1235    o.push_str("import spatial.dsl._\n\n");
1236    o.push_str(&format!("@spatial class {class_name} extends SpatialTest {{\n"));
1237    if let Some(args) = pir_args {
1238        o.push_str(&format!("  override def pirArgs = super.pirArgs + {args:?}\n"));
1239    }
1240    o.push_str(&format!("  val N = {n}\n  val ts = {ts}\n  val op = {op}\n  val ip = {ip}\n\n"));
1241    let params: Vec<String> = decl.iter().map(|c| format!("{}In: Array[{}]", c.name, spatial_ty(c.ty))).collect();
1242    o.push_str(&format!("  def zipmap({}) = {{\n", params.join(", ")));
1243    for c in &decl {
1244        o.push_str(&format!("    val {} = DRAM[{}](N)\n", c.name, spatial_ty(c.ty)));
1245    }
1246    o.push_str(&format!("    val {out} = DRAM[{oty}](N)\n"));
1247    for c in &decl {
1248        o.push_str(&format!("    setMem({0}, {0}In)\n", c.name));
1249    }
1250    o.push_str("    Accel {\n");
1251    o.push_str("      Foreach(N by ts par op) { i =>\n");
1252    for c in &decl {
1253        o.push_str(&format!("        val {}Blk = FIFO[{}](ts)\n", c.name, spatial_ty(c.ty)));
1254    }
1255    o.push_str(&format!("        val {out}Blk = FIFO[{oty}](ts)\n"));
1256    for c in &decl {
1257        o.push_str(&format!("        {0}Blk load {0}(i::i+ts par ip)\n", c.name));
1258    }
1259    o.push_str("        Foreach(ts par ip) { j =>\n");
1260    for c in columns {
1261        o.push_str(&format!("          val {0}V = {0}Blk.deq\n", c.name));
1262    }
1263    for l in &accel_lines {
1264        o.push_str(&format!("          {l}\n"));
1265    }
1266    o.push_str(&format!("          {out}Blk.enq({accel_root})\n"));
1267    o.push_str("        }\n");
1268    o.push_str(&format!("        {out}(i::i+ts par ip) store {out}Blk\n"));
1269    o.push_str("      }\n    }\n");
1270    o.push_str(&format!("    getMem({out})\n  }}\n\n"));
1271    o.push_str("  def main(args: Array[String]): Unit = {\n");
1272    for k in declared {
1273        o.push_str(&format!("    val {} = Array.tabulate(N) {{ i => {} }}\n", columns[*k].name, fills[*k]));
1274    }
1275    o.push_str(&format!(
1276        "    val result = zipmap({})\n",
1277        decl.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join(", ")
1278    ));
1279    o.push_str("    val gold = Array.tabulate(N) { i =>\n");
1280    for l in &host_lines {
1281        o.push_str(&format!("      {l}\n"));
1282    }
1283    o.push_str(&format!("      {host_root}\n    }}\n"));
1284    if out_ty == Ty::Real {
1285        o.push_str("    val cksum = result.zip(gold) { (r, g) => abs(r - g) <= 0.001.to[Float] * abs(g) + 0.001.to[Float] }.reduce { _ & _ }\n");
1286    } else {
1287        o.push_str("    val cksum = result.zip(gold) { (r, g) => r === g }.reduce { _ & _ }\n");
1288    }
1289    o.push_str(&format!("    println(\"PASS: \" + cksum + \" ({class_name})\")\n    assert(cksum)\n"));
1290    o.push_str("  }\n}\n");
1291    Ok(o)
1292}
1293
1294/// The knobs of Spatial's `Kmeans` regression app (`pir/regression/Kmeans.scala`): the points a tile loads (`ts`), the
1295/// lanes of the loads, the store and the feature loops (`ip`), how many tiles run at once (`op`), and how many points,
1296/// centroids and centroid updates run at once (`mp1`, `mp2`, `mp3`).
1297#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1298pub struct KmeansPar {
1299    pub ts: usize,
1300    pub ip: usize,
1301    pub op: usize,
1302    pub mp1: usize,
1303    pub mp2: usize,
1304    pub mp3: usize,
1305}
1306
1307/// Emit a Spatial REGRESSION-HARNESS k-means clustering with the structure of Spatial's own `Kmeans` app — the
1308/// centroids loaded from the first `K` points, `iters` epochs each a `MemReduce` over `ts`-point tiles of a
1309/// `MemReduce` over the tile's points (each point's distance to every centroid a `Reduce` over the features, the
1310/// nearest found by a min and an index reduce, the point added to its centroid's row as a one-hot tile), then every
1311/// centroid set to its row over its count — at our shape and the app's runtime arguments, over the program's own
1312/// data, written at the app's `X = Int` (the app's `kmeans[T:Num]` instantiated). The app keeps each centroid's count
1313/// as a last feature of every point fixed at 1 (`D = f + 1` columns), where the program keeps counts; equally near
1314/// centroids go to the last (the app's `max` of the matching indices) or, for a program keeping the first, to the
1315/// first (a `min`). The gold is the clustering the program computes, evaluated on the host the way the accelerator
1316/// computes it, the nearest centroid chosen by distance and then by index whichever way the host folds. The app
1317/// computes in Spatial's 32-bit `Int`; a distance or a sum that would wrap there is refused, as is a tile that does
1318/// not divide the points.
1319pub fn emit_spatial_kmeans_test(
1320    spec: &crate::tensor_recognize::KmeansSpec,
1321    points: &Fill,
1322    p: KmeansPar,
1323    class_name: &str,
1324) -> Result<String, String> {
1325    use crate::tensor_recognize::Ties;
1326    let KmeansPar { ts, ip, op, mp1, mp2, mp3 } = p;
1327    at_least_one(&[("ts", ts), ("ip", ip), ("op", op), ("mp1", mp1), ("mp2", mp2), ("mp3", mp3)])?;
1328    let (n, k, f, iters) = (spec.n, spec.k, spec.f, spec.iters);
1329    if n % ts != 0 {
1330        return Err(format!("the Spatial Kmeans template loads whole tiles: ts={ts} must divide N={n}"));
1331    }
1332    if n < k || k == 0 || f == 0 {
1333        return Err(format!("{n} points cannot seed {k} centroids of {f} features"));
1334    }
1335    let data = host_data(points, Ty::Int, "i * DM1 + d")?;
1336    let values: Vec<i64> = (0..(n * f) as i64)
1337        .map(|t| match points.element(t) {
1338            Some(Scalar::Int(v)) => Ok(v),
1339            _ => Err("the points have no Int data the app can print".to_string()),
1340        })
1341        .collect::<Result<_, _>>()?;
1342    spec.run(&values, (i32::MIN as i128, i32::MAX as i128))
1343        .map_err(|stop| format!("the clustering {}", stop_reason(stop)))?;
1344    let dag = TermDag::of(&spec.distance);
1345    let tys = dag.types(&[Ty::Int, Ty::Int]).ok_or("the distance is ill-typed")?;
1346    let (accel_lines, accel_term) = DagPrinter::print(&dag, &tys, &|r| ["pts(pt,d)", "cts(ct,d)"][r].to_string(), false);
1347    let (host_lines, host_term) = DagPrinter::print(&dag, &tys, &|r| ["p1(i)", "p2(i)"][r].to_string(), true);
1348    let block = |lines: &[String], pad: &str, root: &str| -> String {
1349        if lines.is_empty() {
1350            root.to_string()
1351        } else {
1352            let body: String = lines.iter().map(|l| format!("{pad}  {l}\n")).collect();
1353            format!("{{\n{body}{pad}  {root}\n{pad}}}")
1354        }
1355    };
1356    // The host's gold keeps the nearest by distance and then by index, so it does not depend on which way the host
1357    // folds: Spatial's C++ host reduces `rfunc(next, accumulated)`, its Scala host `rfunc(accumulated, next)`.
1358    let (sentinel, reduce, prefer) = match spec.ties {
1359        Ties::Last => ("-1", "max(a,b)", "if (a._1 < b._1 || (a._1 == b._1 && a._2 > b._2)) a else b"),
1360        Ties::First => ("K", "min(a,b)", "if (a._1 < b._1 || (a._1 == b._1 && a._2 < b._2)) a else b"),
1361    };
1362    let ties = if spec.ties == Ties::Last { "last" } else { "first" };
1363    let mut o = format!(
1364        "// Generated by LOGOS (logicaffeine_compile::codegen_sva::spatial_emit) from a LOGOS loop nest recognized as k-means\n// clustering ({n} points of {f} features, {k} centroids, {iters} epochs, the {ties} of equally near centroids):\n// Spatial's Kmeans structure (regression/Kmeans.scala) at ts={ts} ip={ip} op={op} mp1={mp1} mp2={mp2} mp3={mp3}, over the\n// program's own data. The PASS line is what the ledger parses.\nimport spatial.dsl._\n\n"
1365    );
1366    o.push_str(&format!("@spatial class {class_name} extends SpatialTest {{ self =>\n  type X = Int\n\n"));
1367    o.push_str(&format!(
1368        "  val K = {k}\n  val D = {}\n  val ts = {ts}\n  val ip = {ip}\n  val op = {op}\n  val mp1 = {mp1}\n  val mp2 = {mp2}\n  val mp3 = {mp3}\n\n",
1369        f + 1
1370    ));
1371    o.push_str(&format!("  override def runtimeArgs = \"{iters} {n}\"\n\n  def DM1 = D - 1\n\n"));
1372    o.push_str(&format!(
1373        r#"  def kmeans(points_in: Array[X], I:Int, N:Int) = {{
1374
1375    val iters = ArgIn[Int]
1376    val numPoints     = ArgIn[Int]
1377
1378    setArg(iters, I)
1379    setArg(numPoints, N)
1380
1381    val points = DRAM[X](numPoints, D)
1382    val centroids = DRAM[X](K, D)
1383    setMem(points, points_in)
1384
1385    Accel {{
1386      val cts = SRAM[X](K, D)
1387
1388      cts load points(0::K, 0::D par ip)
1389
1390      Sequential.Foreach(iters by 1){{ epoch =>
1391        val newCents = SRAM[X](K,D)
1392        if (ip == 1) {{
1393          newCents.onlyblockcyclic.blockcyclic_Bs(Seq(63))
1394          ()
1395        }}
1396        MemReduce(newCents par ip)(numPoints by ts par op){{i =>
1397          val pts = SRAM[X](ts, D)
1398          pts load points(i::i+ts, 0::D par ip)
1399
1400          MemReduce(SRAM[X](K,D) par ip)(ts par mp1){{pt =>
1401            val dists = SRAM[X](K)
1402            Foreach(K by 1 par mp2) {{ ct =>
1403              val dist = Reduce(Reg[X])(D par ip){{d =>
1404                mux(d==DM1, 0, {accel})
1405              }}{{_+_}}
1406              dists(ct) = dist.value
1407            }}
1408            val minDist = Reduce(Reg[X])(K by 1 par ip) {{ ct => dists(ct) }} {{ (a,b) => min(a, b) }}
1409            val minCent = Reduce(Reg[I32])(K by 1 par ip) {{ ct =>
1410              mux(dists(ct) == minDist.value, ct, {sentinel})
1411            }} {{ (a,b) => {reduce} }}
1412
1413            val localCent = SRAM[X](K,D)
1414            Foreach(K by 1, D par ip){{(ct,d) =>
1415              localCent(ct, d) = mux(ct == minCent.value, pts(pt,d), 0.to[X])
1416            }}
1417            localCent
1418          }}{{_+_}}
1419        }}{{_+_}}
1420
1421        Foreach(K by 1 par mp3){{ ct =>
1422          Foreach(D-1 par ip){{ d =>
1423            val centCount = newCents(ct,DM1)
1424            val cond = centCount == 0.to[X]
1425            cts(ct, d) = mux(cond, 0.to[X], newCents(ct,d) / mux(cond,1.to[X], centCount))
1426          }}
1427        }}
1428      }}
1429
1430      centroids(0::K, D par ip) store cts
1431    }}
1432
1433    getMem(centroids)
1434  }}
1435
1436  def main(args: Array[String]): Unit = {{
1437    val I = args(0).to[Int]
1438    val N = args(1).to[Int]
1439
1440    val pts = Array.tabulate(N){{i => Array.tabulate(D){{d => if (d == DM1) 1.to[X] else {data} }}}}
1441
1442    val result = kmeans(pts.flatten, I, N)
1443
1444    val cts = Array.tabulate(K) {{ i => Array.empty[X](D) }}
1445    for (k <- 0 until K) {{
1446      for (d <- 0 until D) {{
1447        val cent = cts(k)
1448        val point = pts(k)
1449        cent(d) = point(d)
1450      }}
1451    }}
1452
1453    def dist(p1: Array[X], p2: Array[X]) = {{
1454      Array.tabulate(D) {{ i =>
1455        if (i==DM1) 0.to[X] else {host}
1456      }}.reduce(_+_)
1457    }}
1458
1459    val ii = Array.tabulate(K){{i => i}}
1460    for(epoch <- 0 until I) {{
1461      val accum = Array.tabulate(K) {{ i => Array.empty[X](D) }}
1462      for (k <- 0 until K) {{
1463        for (d <- 0 until D) {{
1464          val acc = accum(k)
1465          acc(d) = 0.to[X]
1466        }}
1467      }}
1468      pts.foreach {{ pt =>
1469        val dists = cts.map{{ct => dist(pt, ct) }}
1470        val idx = dists.zip(ii){{(a,b) => pack(a,b) }}.reduce{{(a,b) => {prefer}}}._2
1471        for (d <- 0 until D) {{
1472          val acc = accum(idx)
1473          acc(d) = acc(d) + pt(d)
1474        }}
1475      }}
1476      for (k <- 0 until K) {{
1477        val acc = accum(k)
1478        val n = acc(DM1)
1479        for (d <- 0 until DM1) {{
1480          val cent = cts(k)
1481          cent(d) = if (n == 0) 0.to[X] else acc(d)/n
1482        }}
1483      }}
1484    }}
1485
1486    val gold = cts.flatten
1487    val cksum = gold.zip(result) {{ (g, r) => g === r }}.reduce {{ _ & _ }}
1488    println("PASS: " + cksum + " ({class_name})")
1489    assert(cksum)
1490  }}
1491}}
1492"#,
1493        accel = block(&accel_lines, "                ", &accel_term),
1494        host = block(&host_lines, "        ", &host_term),
1495    ));
1496    Ok(o)
1497}