Skip to main content

logicaffeine_compile/spatial/passes/
from_tensor_op.rs

1//! `from_tensor_op` — a recognized tensor op becomes a TEMPLATE spatial program (phase `Template`): the
2//! tensors, the four stages, three double-buffered FIFOs, and one work item per output tile. The numerics are
3//! chosen HERE from the target's matrix engine and the recognized fills, and the exactness claim is derived,
4//! never assumed: an `Int` source on a float-primary engine rides as bf16 operands with an fp32 accumulator and
5//! is claimed exact only while every partial sum fits fp32's mantissa.
6
7use super::{SpatialReject, LV_GEN_COL, LV_GEN_ROW, LV_ITEM_X, LV_ITEM_Y, LV_KBLOCK};
8use crate::codegen_sva::fabric_target::Precision;
9use crate::spatial::ir::*;
10use crate::spatial::target::{precision_int_bits, SpatialTarget};
11use crate::tensor_recognize::{ElementwiseOp, Fill, MatmulSpec, RowReduceKind};
12
13/// The source-level names of a matmul's tensors.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct MatmulNames {
16    pub a: String,
17    pub b: String,
18    pub c: String,
19}
20
21pub fn init_of(fill: &Fill, n: usize) -> TensorInit {
22    match fill {
23        Fill::Const(0) => TensorInit::Zero,
24        Fill::Const(c) => TensorInit::Pattern {
25            modulus: 1,
26            shift: *c,
27        },
28        Fill::Pattern { modulus, shift } => TensorInit::Pattern {
29            modulus: *modulus,
30            shift: *shift,
31        },
32        Fill::Overrides { base, sets } => {
33            let base = init_of(base, n);
34            let mut vals: Vec<i64> = (0..n).map(|i| base.element(i).unwrap_or(0)).collect();
35            for (i, v) in sets {
36                if *i < n {
37                    vals[*i] = *v;
38                }
39            }
40            TensorInit::Values(vals)
41        }
42        Fill::ConstF(b) => TensorInit::ConstF(*b),
43        Fill::FloatOverrides { base, sets } => {
44            let mut vals = vec![*base; n];
45            for (i, v) in sets {
46                if *i < n {
47                    vals[*i] = *v;
48                }
49            }
50            TensorInit::FloatValues(vals)
51        }
52        // A real pattern is supplied at run time here, as data the compiler cannot see is: the device inits hold
53        // a float fill as one constant or as every value, and a pattern's every value is the materialization the
54        // `ConstF` init exists to avoid.
55        Fill::RealPattern { .. } | Fill::Unknown => TensorInit::External,
56    }
57}
58
59// ── The other tensor ops: one template shape, one builder ─────────────────────────────────────────
60
61/// Names of an elementwise op's tensors (from the source).
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct ElementwiseNames {
64    pub a: String,
65    pub b: String,
66    pub c: String,
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct SwiGluNames {
71    pub gate: String,
72    pub up: String,
73    pub y: String,
74}
75
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct FusedRowNames {
78    pub x: String,
79    pub y: String,
80    pub weight: Option<String>,
81    pub residual: Option<String>,
82    pub bias: Option<String>,
83}
84
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct FusedRowFills {
87    pub x: Fill,
88    pub weight: Option<Fill>,
89    pub residual: Option<Fill>,
90    pub bias: Option<Fill>,
91}
92
93/// A template under construction: tensors, stages, buffers and local hand-off edges, in id order.
94///
95/// ★ THE ONE BUILDER EVERY TEMPLATE COMES FROM — the recognized workloads below, the B-P4 Qwen
96/// compositions, and the hand-built probes of `spatial_algebra_expansion_lock`. Building a stage any
97/// other way is how two programs end up disagreeing about what a stage IS.
98pub struct Template {
99    tile: crate::spatial::target::TileShape,
100    local: Handshake,
101    tensors: Vec<TensorDecl>,
102    stages: Vec<Stage>,
103    buffers: Vec<Buffer>,
104    edges: Vec<Edge>,
105    trips: Vec<Trip>,
106}
107
108impl Template {
109    pub fn new(target: &SpatialTarget) -> Result<Template, SpatialReject> {
110        Ok(Template {
111            tile: target.tile,
112            local: Handshake::Fifo,
113            tensors: Vec::new(),
114            stages: Vec::new(),
115            buffers: Vec::new(),
116            edges: Vec::new(),
117            trips: Vec::new(),
118        })
119    }
120    /// Declare a loop count the program reads from its data ([`Trip`]).
121    pub fn trip(&mut self, trip: Trip) -> TripId {
122        self.trips.push(trip);
123        TripId::new(self.trips.len() - 1)
124    }
125    pub fn tensor(
126        &mut self,
127        name: &str,
128        role: TensorRole,
129        rows: usize,
130        cols: usize,
131        dtype: Precision,
132        init: TensorInit,
133    ) -> TensorId {
134        let id = TensorId::new(self.tensors.len());
135        self.tensors.push(TensorDecl {
136            id,
137            name: name.to_string(),
138            role,
139            elems: ElemShape { rows, cols },
140            tiles: TileGrid {
141                rows_t: rows.div_ceil(self.tile.rows),
142                cols_t: cols.div_ceil(self.tile.cols),
143            },
144            dtype,
145            layout: Layout::RowMajorTiles,
146            space: TensorSpace::Dram,
147            init,
148            range: None,
149            chip: None,
150        });
151        id
152    }
153    pub fn buffer(&mut self, name: &str, page_tiles: usize, dtype: Precision) -> BufferId {
154        let id = BufferId::new(self.buffers.len());
155        self.buffers.push(Buffer {
156            id,
157            name: name.into(),
158            kind: BufferKind::Fifo {
159                depth_pages: 2,
160                page_tiles,
161            },
162            dtype,
163            space: MemSpace::Local,
164            site: None,
165            origin: None,
166            alias_of: None,
167        });
168        id
169    }
170    /// A buffer that lives only between two compute stages. It is neither filled by a reader nor
171    /// drained by a writer, so it takes the intermediate circular-buffer class rather than an
172    /// operand or output slot.
173    /// A hand-off of an explicit DEPTH. Double buffering is the right default — it lets a producer fill
174    /// page `n+1` while the consumer drains page `n` — but it costs twice the local memory, and a stage
175    /// whose page is consumed by the very next stage in its own item never overlaps anything. A section
176    /// that fans into many sub-blocks pays that doubling once per operand per sub-block, which is how a
177    /// 426 KB site budget disappears.
178    pub fn buffer_deep(
179        &mut self,
180        name: &str,
181        depth_pages: usize,
182        page_tiles: usize,
183        dtype: Precision,
184    ) -> BufferId {
185        let id = BufferId::new(self.buffers.len());
186        self.buffers.push(Buffer {
187            id,
188            name: name.into(),
189            kind: BufferKind::Fifo {
190                depth_pages,
191                page_tiles,
192            },
193            dtype,
194            space: MemSpace::Local,
195            site: None,
196            origin: None,
197            alias_of: None,
198        });
199        id
200    }
201    /// [`Template::scratch`] with an explicit depth.
202    pub fn scratch_deep(
203        &mut self,
204        name: &str,
205        depth_pages: usize,
206        page_tiles: usize,
207        dtype: Precision,
208    ) -> BufferId {
209        let id = BufferId::new(self.buffers.len());
210        self.buffers.push(Buffer {
211            id,
212            name: name.into(),
213            kind: BufferKind::Interm {
214                depth_pages,
215                page_tiles,
216            },
217            dtype,
218            space: MemSpace::Local,
219            site: None,
220            origin: None,
221            alias_of: None,
222        });
223        id
224    }
225    pub fn scratch(&mut self, name: &str, page_tiles: usize, dtype: Precision) -> BufferId {
226        let id = BufferId::new(self.buffers.len());
227        self.buffers.push(Buffer {
228            id,
229            name: name.into(),
230            kind: BufferKind::Interm {
231                depth_pages: 2,
232                page_tiles,
233            },
234            dtype,
235            space: MemSpace::Local,
236            site: None,
237            origin: None,
238            alias_of: None,
239        });
240        id
241    }
242    pub fn stage(
243        &mut self,
244        name: &str,
245        kind: StageKind,
246        ins: Vec<BufferId>,
247        outs: Vec<BufferId>,
248    ) -> StageId {
249        let id = StageId::new(self.stages.len());
250        let role = kind.role();
251        self.stages.push(Stage {
252            id,
253            name: name.into(),
254            kind,
255            role,
256            ins,
257            outs,
258            site: None,
259            item: None,
260            origin: None,
261        });
262        id
263    }
264    /// A load of `block_w` tiles of one tile-row of `tensor`, addressed by the item's coordinates.
265    pub fn load(
266        &mut self,
267        name: &str,
268        tensor: TensorId,
269        steps: Vec<(LoopVar, usize)>,
270        block_w: usize,
271        out: BufferId,
272    ) -> StageId {
273        let cols_t = self.tensors[tensor.idx()].tiles.cols_t;
274        self.stage(
275            name,
276            StageKind::Load {
277                tensor,
278                pattern: AccessPattern {
279                    origin: TileAddr {
280                        base: 0,
281                        steps,
282                        indirect: None,
283                    },
284                    block_h: 1,
285                    block_w,
286                    stride_w: 1,
287                    stride_h: cols_t,
288                },
289                num_blocks: 1,
290                multicast: None,
291            },
292            vec![],
293            vec![out],
294        )
295    }
296    /// A load of a `block_h x block_w` TILE BLOCK whose rows are `stride_h` tiles apart — the shape a
297    /// strided view needs when the rows it wants are not adjacent in the tensor. A causal conv's ring is
298    /// `taps x chan` flattened, so one channel block's taps sit `chan/tile` tiles apart.
299    pub fn load_block(
300        &mut self,
301        name: &str,
302        tensor: TensorId,
303        steps: Vec<(LoopVar, usize)>,
304        block_h: usize,
305        block_w: usize,
306        stride_h: usize,
307        out: BufferId,
308    ) -> StageId {
309        self.stage(
310            name,
311            StageKind::Load {
312                tensor,
313                pattern: AccessPattern {
314                    origin: TileAddr {
315                        base: 0,
316                        steps,
317                        indirect: None,
318                    },
319                    block_h,
320                    block_w,
321                    stride_w: 1,
322                    stride_h,
323                },
324                num_blocks: 1,
325                multicast: None,
326            },
327            vec![],
328            vec![out],
329        )
330    }
331    /// The store twin of [`Template::load_block`], with an explicit tile `base` — the constant part of
332    /// an address whose variable part is the item's coordinate.
333    pub fn store_block(
334        &mut self,
335        name: &str,
336        tensor: TensorId,
337        base: usize,
338        steps: Vec<(LoopVar, usize)>,
339        block_h: usize,
340        block_w: usize,
341        stride_h: usize,
342        input: BufferId,
343    ) -> StageId {
344        self.stage(
345            name,
346            StageKind::Store {
347                tensor,
348                pattern: AccessPattern {
349                    origin: TileAddr {
350                        base,
351                        steps,
352                        indirect: None,
353                    },
354                    block_h,
355                    block_w,
356                    stride_w: 1,
357                    stride_h,
358                },
359                sub_blocks: (1, 1),
360            },
361            vec![input],
362            vec![],
363        )
364    }
365    /// [`Template::store_block`] over the item's `sub_blocks` — the extent the pattern's `LV_SUB_H` / `LV_SUB_W` steps
366    /// walk. ★ A TEMPLATE STATES WHAT ITS PLAN SAYS: placement reshapes only the store of the section's result to a
367    /// site's block, so every other store's sub-blocks are the template's, and the coverage check reads them.
368    #[allow(clippy::too_many_arguments)]
369    pub fn store_block_over(
370        &mut self,
371        name: &str,
372        tensor: TensorId,
373        base: usize,
374        steps: Vec<(LoopVar, usize)>,
375        block_h: usize,
376        block_w: usize,
377        stride_h: usize,
378        sub_blocks: (usize, usize),
379        input: BufferId,
380    ) -> StageId {
381        self.stage(
382            name,
383            StageKind::Store {
384                tensor,
385                pattern: AccessPattern {
386                    origin: TileAddr {
387                        base,
388                        steps,
389                        indirect: None,
390                    },
391                    block_h,
392                    block_w,
393                    stride_w: 1,
394                    stride_h,
395                },
396                sub_blocks,
397            },
398            vec![input],
399            vec![],
400        )
401    }
402    /// [`Template::load_block`] with an explicit tile `base`.
403    pub fn load_block_at(
404        &mut self,
405        name: &str,
406        tensor: TensorId,
407        base: usize,
408        steps: Vec<(LoopVar, usize)>,
409        block_h: usize,
410        block_w: usize,
411        stride_h: usize,
412        out: BufferId,
413    ) -> StageId {
414        self.stage(
415            name,
416            StageKind::Load {
417                tensor,
418                pattern: AccessPattern {
419                    origin: TileAddr {
420                        base,
421                        steps,
422                        indirect: None,
423                    },
424                    block_h,
425                    block_w,
426                    stride_w: 1,
427                    stride_h,
428                },
429                num_blocks: 1,
430                multicast: None,
431            },
432            vec![],
433            vec![out],
434        )
435    }
436    pub fn store(
437        &mut self,
438        name: &str,
439        tensor: TensorId,
440        steps: Vec<(LoopVar, usize)>,
441        block_w: usize,
442        input: BufferId,
443    ) -> StageId {
444        let cols_t = self.tensors[tensor.idx()].tiles.cols_t;
445        self.stage(
446            name,
447            StageKind::Store {
448                tensor,
449                pattern: AccessPattern {
450                    origin: TileAddr {
451                        base: 0,
452                        steps,
453                        indirect: None,
454                    },
455                    block_h: 1,
456                    block_w,
457                    stride_w: 1,
458                    stride_h: cols_t,
459                },
460                sub_blocks: (1, 1),
461            },
462            vec![input],
463            vec![],
464        )
465    }
466    /// Whether a stage names a buffer among its operands (a chain wires by this, not by position).
467    pub fn stage_reads(&self, s: StageId, b: BufferId) -> bool {
468        self.stages[s.idx()].ins.contains(&b)
469    }
470    /// The buffer a chain stage writes.
471    pub fn stage_output(&self, s: StageId) -> BufferId {
472        self.stages[s.idx()].outs[0]
473    }
474    /// The element precision buffer `b` holds.
475    pub fn dtype(&self, b: BufferId) -> Precision {
476        self.buffers[b.idx()].dtype
477    }
478    /// Tiles per page of a buffer — the page WIDTH every flat access map is taken over.
479    pub fn page_tiles(&self, b: BufferId) -> usize {
480        self.buffers[b.idx()].kind.page_tiles()
481    }
482    /// One hand-off read by SEVERAL stages. A buffer has exactly one producing EDGE, not one per
483    /// consumer — a fan-out is a single edge with a list of readers, which is also what the choreography
484    /// needs in order to pop the page once rather than once per reader.
485    pub fn edge_many(&mut self, from: StageId, buffer: BufferId, to: Vec<StageId>) {
486        let id = EdgeId::new(self.edges.len());
487        self.edges.push(Edge {
488            id,
489            from,
490            buffer,
491            to,
492            handshake: self.local.clone(),
493        });
494    }
495    pub fn edge(&mut self, from: StageId, buffer: BufferId, to: StageId) {
496        let id = EdgeId::new(self.edges.len());
497        self.edges.push(Edge {
498            id,
499            from,
500            buffer,
501            to: vec![to],
502            handshake: self.local.clone(),
503        });
504    }
505    pub fn finish(
506        self,
507        name: String,
508        items: Vec<WorkItem>,
509        acc: AccPolicy,
510        claim: NumericsClaim,
511        target: &SpatialTarget,
512    ) -> Result<SpatialProgram, SpatialReject> {
513        let mut requires = Requirements::default();
514        requires.handshakes.insert(self.local.kind());
515        let p = SpatialProgram {
516            name,
517            tile: self.tile,
518            tensors: self.tensors,
519            stages: self.stages,
520            buffers: self.buffers,
521            edges: self.edges,
522            semaphores: Vec::new(),
523            sections: Vec::new(),
524            trips: self.trips,
525            links: Vec::new(),
526            work: WorkList {
527                items,
528                k_tiles: 1,
529                plan: None,
530                k_trip: None,
531            },
532            numerics: NumericsPolicy::Exact,
533            claim,
534            acc_tiles_used: target.effective_acc_tiles(acc, true),
535            mem_banks: target.site.mem_banks,
536            requires,
537            placement: None,
538            memory: None,
539            schedule: None,
540            certificate: None,
541        };
542        p.validate()?;
543        Ok(p)
544    }
545}
546
547fn elementwise_precisions(
548    target: &SpatialTarget,
549) -> Option<&std::collections::BTreeSet<Precision>> {
550    target.compute.iter().find_map(|c| match c {
551        crate::spatial::target::ComputeCap::Elementwise { precisions } => Some(precisions),
552        _ => None,
553    })
554}
555
556fn row_reduce_precisions(target: &SpatialTarget) -> Option<&std::collections::BTreeSet<Precision>> {
557    target.compute.iter().find_map(|c| match c {
558        crate::spatial::target::ComputeCap::RowReduce { precisions } => Some(precisions),
559        _ => None,
560    })
561}
562
563/// The float precision the target's ELEMENTWISE datapath offers, widest first — the one a composition
564/// carries its activations in (bf16 on the RDU's witnessed envelope).
565pub fn elementwise_float(target: &SpatialTarget) -> Option<Precision> {
566    elementwise_precisions(target).and_then(float_precision)
567}
568
569/// The integer precision the target's ELEMENTWISE datapath offers, widest first — the one an integer model's
570/// tensors are carried in: an `Int` is the VM's 64-bit integer, so the widest the part has is the closest to it.
571pub fn elementwise_int(target: &SpatialTarget) -> Option<Precision> {
572    let ps = elementwise_precisions(target)?;
573    [Precision::Int64, Precision::Int32, Precision::Int16, Precision::Int8]
574        .into_iter()
575        .find(|p| ps.contains(p))
576}
577
578/// The widest float precision a capability offers (fp32 first: exact for integer data below 2²⁴).
579fn float_precision(ps: &std::collections::BTreeSet<Precision>) -> Option<Precision> {
580    [Precision::Fp32, Precision::Bf16, Precision::Fp16]
581        .into_iter()
582        .find(|p| ps.contains(p))
583}
584
585/// One work item per output tile along a single tile-row (elementwise / SwiGLU: 1×n tensors).
586pub(crate) fn items_per_tile(cols_t: usize) -> Vec<WorkItem> {
587    items_per_tile_grid(1, cols_t)
588}
589
590/// One work item per tile of a `rows_t × cols_t` tile grid, row-major — the tiling of a pointwise map over a
591/// matrix. Item `(ry, cx)` owns tile `(ry, cx)`.
592pub(crate) fn items_per_tile_grid(rows_t: usize, cols_t: usize) -> Vec<WorkItem> {
593    (0..rows_t)
594        .flat_map(|ry| (0..cols_t).map(move |cx| (ry, cx)))
595        .enumerate()
596        .map(|(id, (ry, cx))| WorkItem {
597            id: WorkItemId::new(id),
598            coord: (ry, cx),
599            out: TileRect {
600                row0_t: ry,
601                col0_t: cx,
602                h_t: 1,
603                w_t: 1,
604            },
605        })
606        .collect()
607}
608
609/// The tile address of an item of [`items_per_tile_grid`]: tile `ry · cols_t + cx`. A single tile row needs no
610/// row term, and a `1 × n` row keeps exactly the address it has always had.
611pub(crate) fn tile_grid_steps(rows_t: usize, cols_t: usize) -> Vec<(LoopVar, usize)> {
612    if rows_t > 1 {
613        vec![(LV_ITEM_Y, cols_t), (LV_ITEM_X, 1)]
614    } else {
615        vec![(LV_ITEM_X, 1)]
616    }
617}
618
619/// ★ A RECOGNIZED PRODUCER/CONSUMER PIPE, AS SPATIAL IR — the template that retires
620/// `codegen_tt::concurrency_mesh`'s hand-written two-core program.
621///
622/// **The shape was already in the IR's vocabulary.** A task sending N values into a pipe while main
623/// receives and folds them is a LOAD on one engine feeding a FIFO edge, and a REDUCTION on the other
624/// draining it — which is precisely what the retired emitter spelled out by hand as two baby-RISC-V
625/// kernels around a circular buffer. Nothing new was needed: `ChoreoOp::{Reserve,Push,Wait,Pop}` are
626/// the circular-buffer discipline, and a fold is not an operator but an `IterKind::Reduce` axis.
627///
628/// The sends are constants OF THE RECOGNIZED PROGRAM, so they ride as the input tensor's `Values`
629/// init rather than as a `const uint32_t vals[N]` baked into a kernel string. That is the whole
630/// difference between a template and a lowering: the same numbers, but reachable by every pass,
631/// every census and the evaluator instead of being sealed inside C++ text.
632///
633/// **Numerics — why fp32 and not an integer datapath.** A pipe of integers has an integer sum, so
634/// the fold must be exact. Asking for `Precision::Int64` would make `trivial_plan` choose
635/// `AccPolicy::I64`, which the Tensix printer refuses: the matrix engine is float-primary, the same
636/// hardware fact that retired the int8 GEMM path (measured 0/4096 correct on Wormhole AND Blackhole).
637/// Fp32 represents integers exactly below 2^23, so a bounded pipe keeps `NumericsClaim::Exact` on the
638/// validated datapath. A pipe whose fold could leave that range is REFUSED rather than silently
639/// rounded — the claim has to be true, not convenient.
640pub fn from_pipe(sends: &[i64], target: &SpatialTarget) -> Result<SpatialProgram, SpatialReject> {
641    let n = sends.len();
642    if n == 0 {
643        return Err(SpatialReject::NotLowered("a pipe with no sends"));
644    }
645    // The fold's worst case, not the individual values: summing many small integers is what leaves
646    // the range, and it leaves it silently.
647    const EXACT_FP32: i64 = 1 << 23;
648    let bound: i64 = sends
649        .iter()
650        .map(|v| v.saturating_abs())
651        .try_fold(0i64, |a: i64, v| a.checked_add(v))
652        .unwrap_or(i64::MAX);
653    if bound >= EXACT_FP32 {
654        return Err(SpatialReject::NotLowered(
655            "a pipe whose fold leaves the exactly-representable fp32 range",
656        ));
657    }
658    let caps = elementwise_precisions(target).ok_or(SpatialReject::MissingCompute {
659        needed: "elementwise",
660        target: target.name,
661    })?;
662    let precision = float_precision(caps).ok_or(SpatialReject::MissingCompute {
663        needed: "a float elementwise datapath",
664        target: target.name,
665    })?;
666
667    let mut t = Template::new(target)?;
668    let ch = t.tensor(
669        "ch",
670        TensorRole::Input,
671        1,
672        n,
673        precision,
674        TensorInit::Values(sends.to_vec()),
675    );
676    let total = t.tensor(
677        "total",
678        TensorRole::Output,
679        1,
680        1,
681        precision,
682        TensorInit::Zero,
683    );
684    let cols_t = t.tensors[ch.idx()].tiles.cols_t;
685    let pipe = t.buffer("pipe", cols_t, precision);
686    let out = t.buffer("out", 1, precision);
687    // ★ THE REDUCE ENGINE'S SCALER OPERAND, AT 1.0. Tensix's `reduce_tile` multiplies every term by a
688    // constant scaler tile and REQUIRES one — a fold with a single operand is not expressible on the
689    // part. At 1.0 it is the identity, so the sum is the sum; the operand exists because the hardware
690    // has it, not because the specification wanted a coefficient.
691    let scaler_elems = target.tile.rows * target.tile.cols;
692    let ones = t.tensor(
693        "pipe_scaler",
694        TensorRole::Param,
695        target.tile.rows,
696        target.tile.cols,
697        precision,
698        TensorInit::FloatValues(vec![1.0f64.to_bits(); scaler_elems]),
699    );
700    let ones_in = t.buffer("in_scaler", 1, precision);
701    let l_ones = t.load("load_scaler", ones, vec![], 1, ones_in);
702    // The producer: one load of the whole pipe, the send side of the circular buffer.
703    let send = t.load("send", ch, vec![(LV_ITEM_X, 1)], cols_t, pipe);
704    // The consumer: receive and fold. The column axis is REDUCED away, so `out` is the scalar total.
705    let fold = t.stage(
706        "receive_fold",
707        StageKind::Compute(ComputeOp::Generic {
708            iters: space(target, target.tile.cols, Some(ReducePool::Sum)),
709            ins: vec![ident_map(cols_t * target.tile.cols), const_map()],
710            out: const_map(),
711            body: TensorBody::Operand(0),
712        }),
713        vec![pipe, ones_in],
714        vec![out],
715    );
716    let show = t.store("show", total, vec![(LV_ITEM_X, 1)], 1, out);
717    t.edge(send, pipe, fold);
718    t.edge(l_ones, ones_in, fold);
719    t.edge(fold, out, show);
720    t.finish(
721        format!("pipe_{n}"),
722        items_per_tile(1),
723        AccPolicy::F32,
724        NumericsClaim::Exact,
725        target,
726    )
727}
728
729/// `C[p] = A[p] ⊕ B[p]` over a flat length: integer datapath where the target has one, fp32 otherwise.
730pub fn from_elementwise(
731    spec: &crate::tensor_recognize::ElementwiseSpec,
732    names: &ElementwiseNames,
733    fills: (&Fill, &Fill),
734    target: &SpatialTarget,
735) -> Result<SpatialProgram, SpatialReject> {
736    from_elementwise_with(spec, names, fills, false, (1, spec.n), target)
737}
738
739/// The same, told whether the operands are float even when neither carries a recognized fill — which is
740/// every activation inside a composition. Without it a target that offers an integer elementwise
741/// datapath would take it for a pair of tensors that hold bf16 model activations.
742pub fn from_elementwise_with(
743    spec: &crate::tensor_recognize::ElementwiseSpec,
744    names: &ElementwiseNames,
745    fills: (&Fill, &Fill),
746    float_hint: bool,
747    shape: (usize, usize),
748    target: &SpatialTarget,
749) -> Result<SpatialProgram, SpatialReject> {
750    let caps = elementwise_precisions(target).ok_or(SpatialReject::MissingCompute {
751        needed: "elementwise",
752        target: target.name,
753    })?;
754    let (rows, cols) = if shape.0 * shape.1 == spec.n {
755        shape
756    } else {
757        (1, spec.n)
758    };
759    let init_a = init_of(fills.0, spec.n);
760    let init_b = init_of(fills.1, spec.n);
761    let float_data = float_hint || init_a.is_float() || init_b.is_float();
762    let precision = if !float_data && caps.contains(&Precision::Int64) {
763        Precision::Int64
764    } else {
765        float_precision(caps).ok_or(SpatialReject::MissingCompute {
766            needed: "a float elementwise datapath",
767            target: target.name,
768        })?
769    };
770    let bound = init_a
771        .abs_bound()
772        .unwrap_or(i64::MAX)
773        .max(init_b.abs_bound().unwrap_or(i64::MAX));
774    let claim = match precision {
775        Precision::Int64 => NumericsClaim::Exact,
776        Precision::Fp32 if !float_data && bound < (1 << 23) => NumericsClaim::Exact,
777        _ => NumericsClaim::Approx {
778            reason: format!("elementwise in {precision:?}"),
779        },
780    };
781    let body = match spec.op {
782        ElementwiseOp::Add => TensorBody::Add(Box::new(TensorBody::Operand(0)), Box::new(TensorBody::Operand(1))),
783        ElementwiseOp::Mul => TensorBody::Mul(Box::new(TensorBody::Operand(0)), Box::new(TensorBody::Operand(1))),
784    };
785    // ★ ONE ROW COMBINED WITH EVERY ROW (a bias over a block of rows): `C[r, c] = A[r, c] ⊕ B[c]`.
786    if let Some(w) = spec.row {
787        if w == 0 || spec.n % w != 0 {
788            return Err(SpatialReject::NotLowered("an elementwise op whose row does not divide its operand"));
789        }
790        return row_broadcast(spec, names, fills, (rows, cols), w, precision, claim, body, target);
791    }
792    // ★ A ROW IS ONE ITEM WHILE A SITE HOLDS IT — the rule a row map follows (`from_qwen_ops::unary_map`), for two
793    // operands: an item a tile reads, adds and writes the row an element at a time where the tile is an element. A row
794    // of one tile is one item already, over its tile.
795    if rows == 1 && cols.div_ceil(target.tile.cols) > 1 && !super::from_qwen_ops::row_needs_blocking(cols, target) {
796        return super::from_qwen_ops::row_binary_map(
797            format!("elementwise_{}_{}", spec.op.c_op(), spec.n),
798            "elementwise",
799            body,
800            (&names.a, fills.0),
801            (&names.b, fills.1),
802            &names.c,
803            cols,
804            precision,
805            claim,
806            target,
807        );
808    }
809    let mut t = Template::new(target)?;
810    let a = t.tensor(&names.a, TensorRole::Input, rows, cols, precision, init_a);
811    let b = t.tensor(&names.b, TensorRole::Input, rows, cols, precision, init_b);
812    let c = t.tensor(
813        &names.c,
814        TensorRole::Output,
815        rows,
816        cols,
817        precision,
818        TensorInit::Zero,
819    );
820    let (rows_t, cols_t) = (
821        t.tensors[c.idx()].tiles.rows_t,
822        t.tensors[c.idx()].tiles.cols_t,
823    );
824    let in0 = t.buffer("in0", 1, precision);
825    let in1 = t.buffer("in1", 1, precision);
826    let out = t.buffer("out", 1, precision);
827    let la = t.load("load_a", a, tile_grid_steps(rows_t, cols_t), 1, in0);
828    let lb = t.load("load_b", b, tile_grid_steps(rows_t, cols_t), 1, in1);
829    let ew = t.stage(
830        "elementwise",
831        StageKind::Compute(ComputeOp::Generic {
832            iters: space(target, target.tile.cols, None),
833            ins: vec![ident_map(target.tile.cols), ident_map(target.tile.cols)],
834            out: ident_map(target.tile.cols),
835            // ★ AN OPERATOR BECAME A COMPOSITION. `A plus B` is no longer a named op the printer
836            // recognizes — it is the general form with two identity maps and a two-operand body, and the
837            // Tensix arm derives `add_tiles` from exactly that. A new pointwise operation is a new
838            // `TensorBody`, and costs no print arm anywhere.
839            body,
840        }),
841        vec![in0, in1],
842        vec![out],
843    );
844    let st = t.store("store_c", c, tile_grid_steps(rows_t, cols_t), 1, out);
845    t.edge(la, in0, ew);
846    t.edge(lb, in1, ew);
847    t.edge(ew, out, st);
848    let acc = if precision_int_bits(precision).is_some() {
849        AccPolicy::I64
850    } else {
851        AccPolicy::F32
852    };
853    let name = if rows > 1 {
854        format!("elementwise_{}_{rows}x{cols}", spec.op.c_op())
855    } else {
856        format!("elementwise_{}_{}", spec.op.c_op(), spec.n)
857    };
858    t.finish(
859        name,
860        items_per_tile_grid(rows_t, cols_t),
861        acc,
862        claim,
863        target,
864    )
865}
866
867/// `C = A ⊕ B` where `B` is one row of `w` combined with every row of `A`. A dense block of rows is a tile grid, each
868/// output tile reading its tile of `A` and the tile of `B` in its column, `B` constant down the tile's rows; a flat
869/// block is one row a band per row, `B` read at the band's row start ([`super::from_qwen_ops::flat_row_broadcast`]).
870#[allow(clippy::too_many_arguments)]
871fn row_broadcast(
872    spec: &crate::tensor_recognize::ElementwiseSpec,
873    names: &ElementwiseNames,
874    fills: (&Fill, &Fill),
875    (rows, cols): (usize, usize),
876    w: usize,
877    precision: Precision,
878    claim: NumericsClaim,
879    body: TensorBody,
880    target: &SpatialTarget,
881) -> Result<SpatialProgram, SpatialReject> {
882    if rows == 1 || cols != w {
883        return super::from_qwen_ops::flat_row_broadcast(spec.op, names, fills, spec.n, w, precision, claim, target);
884    }
885    let mut t = Template::new(target)?;
886    let a = t.tensor(&names.a, TensorRole::Input, rows, cols, precision, init_of(fills.0, spec.n));
887    let b = t.tensor(&names.b, TensorRole::Input, 1, w, precision, init_of(fills.1, w));
888    let c = t.tensor(&names.c, TensorRole::Output, rows, cols, precision, TensorInit::Zero);
889    let (rows_t, cols_t) = (t.tensors[c.idx()].tiles.rows_t, t.tensors[c.idx()].tiles.cols_t);
890    let in0 = t.buffer("in0", 1, precision);
891    let in1 = t.buffer("in1", 1, precision);
892    let out = t.buffer("out", 1, precision);
893    let la = t.load("load_a", a, tile_grid_steps(rows_t, cols_t), 1, in0);
894    let lb = t.load("load_row", b, vec![(LV_ITEM_X, 1)], 1, in1);
895    let tw = target.tile.cols;
896    let ew = t.stage(
897        "elementwise_row",
898        StageKind::Compute(ComputeOp::Generic {
899            iters: space(target, tw, None),
900            ins: vec![ident_map(tw), per_col_map()],
901            out: ident_map(tw),
902            body,
903        }),
904        vec![in0, in1],
905        vec![out],
906    );
907    let st = t.store("store_c", c, tile_grid_steps(rows_t, cols_t), 1, out);
908    t.edge(la, in0, ew);
909    t.edge(lb, in1, ew);
910    t.edge(ew, out, st);
911    let acc = if precision_int_bits(precision).is_some() { AccPolicy::I64 } else { AccPolicy::F32 };
912    t.finish(format!("elementwise_{}_row_{rows}x{cols}", spec.op.c_op()), items_per_tile_grid(rows_t, cols_t), acc, claim, target)
913}
914
915/// The page WIDTH in elements a stage's operand is read through — the row stride of every flat map
916/// over that buffer. A `Generic` page is a strip of `page_tiles` tiles, so the width is fixed the
917/// moment the buffer is; the passes that re-page (`apply_plan`) touch matmul chains only.
918pub fn page_width(page_tiles: usize, target: &SpatialTarget) -> usize {
919    page_tiles * target.tile.cols
920}
921
922/// The identity map over a page `width` elements wide: the operand is walked along both axes,
923/// `[(row, width), (col, 1)]` — a row step is a page width because the offset is FLAT.
924pub fn ident_map(width: usize) -> ElemAccess {
925    ElemAccess {
926        base: 0,
927        terms: vec![(LV_GEN_ROW, width as i64), (LV_GEN_COL, 1)],
928        indirect: None,
929    }
930}
931
932/// A map that OMITS the column axis — one scalar per row, constant along the row. This is a
933/// broadcast, and it is spelled as a missing term rather than as an operator.
934pub fn per_row_map(width: usize) -> ElemAccess {
935    ElemAccess {
936        base: 0,
937        terms: vec![(LV_GEN_ROW, width as i64)],
938        indirect: None,
939    }
940}
941
942/// A map that OMITS the row axis — one value per column, constant down the rows (a weight, a bias).
943pub fn per_col_map() -> ElemAccess {
944    ElemAccess {
945        base: 0,
946        terms: vec![(LV_GEN_COL, 1)],
947        indirect: None,
948    }
949}
950
951/// A map constant in both axes — a scalar tile, which is what Tensix's `reduce_tile` wants as its
952/// scaler operand.
953pub fn const_map() -> ElemAccess {
954    ElemAccess {
955        base: 0,
956        terms: Vec::new(),
957        indirect: None,
958    }
959}
960
961/// The `(rows, cols)` iteration space of a compute stage covering `cols` ELEMENTS.
962///
963/// ★ THE TRUE COLUMN COUNT, NEVER THE PADDED WIDTH. A tensor is stored in whole 32-wide tiles, so a
964/// 1×4 row occupies a tile whose other 28 columns are zero. Walking the padded width is harmless for
965/// a parallel axis — those results are cropped on the way out — and WRONG for a reduced one: a `max`
966/// then folds in 28 zeros (which wins outright when every real element is negative) and a `sum` picks
967/// up 28 spurious terms. `spatial_algebra_semantics_lock` caught exactly this, comparing against the
968/// mathematics rather than against the compiler.
969pub(crate) fn space(
970    target: &SpatialTarget,
971    cols: usize,
972    reduce: Option<ReducePool>,
973) -> Vec<IterSpec> {
974    vec![
975        IterSpec {
976            var: LV_GEN_ROW,
977            extent: target.tile.rows,
978            kind: IterKind::Parallel,
979        },
980        IterSpec {
981            var: LV_GEN_COL,
982            extent: cols.max(1),
983            kind: match reduce {
984                Some(pool) => IterKind::Reduce { pool },
985                None => IterKind::Parallel,
986            },
987        },
988    ]
989}
990
991pub fn from_swiglu(
992    spec: &crate::tensor_recognize::SwiGluSpec,
993    names: &SwiGluNames,
994    fills: (&Fill, &Fill),
995    target: &SpatialTarget,
996) -> Result<SpatialProgram, SpatialReject> {
997    from_swiglu_shaped(spec, names, fills, (1, spec.total), target)
998}
999
1000/// [`from_swiglu`] over the program's `(rows, cols)` — SwiGLU is pointwise, so a matrix is mapped tile by tile in
1001/// the tiling the matmuls on either side of it use.
1002pub fn from_swiglu_shaped(
1003    spec: &crate::tensor_recognize::SwiGluSpec,
1004    names: &SwiGluNames,
1005    fills: (&Fill, &Fill),
1006    shape: (usize, usize),
1007    target: &SpatialTarget,
1008) -> Result<SpatialProgram, SpatialReject> {
1009    let caps = elementwise_precisions(target).ok_or(SpatialReject::MissingCompute {
1010        needed: "elementwise",
1011        target: target.name,
1012    })?;
1013    let precision = float_precision(caps).ok_or(SpatialReject::MissingCompute {
1014        needed: "a float elementwise datapath with exp (swiglu)",
1015        target: target.name,
1016    })?;
1017    let (rows, cols) = if shape.0 * shape.1 == spec.total {
1018        shape
1019    } else {
1020        (1, spec.total)
1021    };
1022    let mut t = Template::new(target)?;
1023    let g = t.tensor(
1024        &names.gate,
1025        TensorRole::Input,
1026        rows,
1027        cols,
1028        precision,
1029        init_of(fills.0, spec.total),
1030    );
1031    let u = t.tensor(
1032        &names.up,
1033        TensorRole::Input,
1034        rows,
1035        cols,
1036        precision,
1037        init_of(fills.1, spec.total),
1038    );
1039    let y = t.tensor(
1040        &names.y,
1041        TensorRole::Output,
1042        rows,
1043        cols,
1044        precision,
1045        TensorInit::Zero,
1046    );
1047    let (rows_t, cols_t) = (
1048        t.tensors[y.idx()].tiles.rows_t,
1049        t.tensors[y.idx()].tiles.cols_t,
1050    );
1051    let in0 = t.buffer("in0", 1, precision);
1052    let in1 = t.buffer("in1", 1, precision);
1053    let out = t.buffer("out", 1, precision);
1054    let lg = t.load("load_gate", g, tile_grid_steps(rows_t, cols_t), 1, in0);
1055    let lu = t.load("load_up", u, tile_grid_steps(rows_t, cols_t), 1, in1);
1056    // silu(gate) · up, as two compositions. S4's fusion pass folds them back into one register
1057    // window; expressing them apart first is what lets the fusion be CHECKED rather than assumed.
1058    let act = t.scratch("silu_g", 1, AccPolicy::F32.interm_precision());
1059    let s1 = t.stage(
1060        "silu",
1061        StageKind::Compute(ComputeOp::Generic {
1062            iters: space(target, target.tile.cols, None),
1063            ins: vec![ident_map(target.tile.cols)],
1064            out: ident_map(target.tile.cols),
1065            body: TensorBody::Unary {
1066                kind: SfpuKind::Silu,
1067                arg: Box::new(TensorBody::Operand(0)),
1068            },
1069        }),
1070        vec![in0],
1071        vec![act],
1072    );
1073    let sw = t.stage(
1074        "swiglu",
1075        StageKind::Compute(ComputeOp::Generic {
1076            iters: space(target, target.tile.cols, None),
1077            ins: vec![ident_map(target.tile.cols), ident_map(target.tile.cols)],
1078            out: ident_map(target.tile.cols),
1079            body: TensorBody::Mul(
1080                Box::new(TensorBody::Operand(0)),
1081                Box::new(TensorBody::Operand(1)),
1082            ),
1083        }),
1084        vec![act, in1],
1085        vec![out],
1086    );
1087    let st = t.store("store_y", y, tile_grid_steps(rows_t, cols_t), 1, out);
1088    t.edge(lg, in0, s1);
1089    t.edge(s1, act, sw);
1090    t.edge(lu, in1, sw);
1091    t.edge(sw, out, st);
1092    let name = if rows > 1 {
1093        format!("swiglu_{rows}x{cols}")
1094    } else {
1095        format!("swiglu_{}", spec.total)
1096    };
1097    t.finish(
1098        name,
1099        items_per_tile_grid(rows_t, cols_t),
1100        AccPolicy::F32,
1101        NumericsClaim::Approx {
1102            reason: format!("sigmoid in {precision:?}"),
1103        },
1104        target,
1105    )
1106}
1107
1108/// A fused row op (RMSNorm / softmax / LayerNorm with optional residual, weight and bias): one work item per
1109/// tile-row, the whole row of tiles in one page, reduced in the register file.
1110/// A norm's epsilon in the `Rsqrt` transform's nano units, refused by name when it is not a whole number of them.
1111pub(crate) fn eps_nano(eps: crate::tensor_recognize::Epsilon) -> Result<u64, SpatialReject> {
1112    let nano = eps.get() * 1e9;
1113    let whole = nano.round();
1114    if whole < 1.0 || (nano - whole).abs() > 1e-6 * whole {
1115        return Err(SpatialReject::NotLowered("a norm epsilon that is not a whole number of nano"));
1116    }
1117    Ok(whole as u64)
1118}
1119
1120pub fn from_fused_row(
1121    spec: &crate::tensor_recognize::FusedRowSpec,
1122    names: &FusedRowNames,
1123    fills: &FusedRowFills,
1124    target: &SpatialTarget,
1125) -> Result<SpatialProgram, SpatialReject> {
1126    let caps = row_reduce_precisions(target).ok_or(SpatialReject::MissingCompute {
1127        needed: "a row-reduce engine (rmsnorm / softmax / layernorm)",
1128        target: target.name,
1129    })?;
1130    let precision = float_precision(caps).ok_or(SpatialReject::MissingCompute {
1131        needed: "a float row-reduce datapath",
1132        target: target.name,
1133    })?;
1134    let (rows, cols) = (spec.rows, spec.cols);
1135    let kind = spec.reduce;
1136    let mut t = Template::new(target)?;
1137    let x = t.tensor(
1138        &names.x,
1139        TensorRole::Input,
1140        rows,
1141        cols,
1142        precision,
1143        init_of(&fills.x, rows * cols),
1144    );
1145    let residual = match (&names.residual, &fills.residual) {
1146        (Some(n), Some(f)) => Some(t.tensor(
1147            n,
1148            TensorRole::Input,
1149            rows,
1150            cols,
1151            precision,
1152            init_of(f, rows * cols),
1153        )),
1154        _ => None,
1155    };
1156    let weight = match (&names.weight, &fills.weight) {
1157        (Some(n), Some(f)) => {
1158            Some(t.tensor(n, TensorRole::Param, 1, cols, precision, init_of(f, cols)))
1159        }
1160        _ => None,
1161    };
1162    let bias = match (&names.bias, &fills.bias) {
1163        (Some(n), Some(f)) => {
1164            Some(t.tensor(n, TensorRole::Param, 1, cols, precision, init_of(f, cols)))
1165        }
1166        _ => None,
1167    };
1168    let y = t.tensor(
1169        &names.y,
1170        TensorRole::Output,
1171        rows,
1172        cols,
1173        precision,
1174        TensorInit::Zero,
1175    );
1176    let (rows_t, cols_t) = (
1177        t.tensors[y.idx()].tiles.rows_t,
1178        t.tensors[y.idx()].tiles.cols_t,
1179    );
1180    if cols_t > target.effective_acc_tiles(AccPolicy::F32, true) {
1181        return Err(SpatialReject::AccTilesExceeded {
1182            needed: cols_t,
1183            available: target.effective_acc_tiles(AccPolicy::F32, true),
1184            acc: AccPolicy::F32,
1185        });
1186    }
1187    let row_steps = vec![(LV_ITEM_Y, cols_t)];
1188    let xin = t.buffer("in_x", cols_t, precision);
1189    let lx = t.load("load_x", x, row_steps.clone(), cols_t, xin);
1190    let mut loads = vec![(lx, xin)];
1191    let res_in = residual.map(|r| {
1192        let b = t.buffer("in_res", cols_t, precision);
1193        let l = t.load("load_res", r, row_steps.clone(), cols_t, b);
1194        loads.push((l, b));
1195        b
1196    });
1197    let w_in = weight.map(|w| {
1198        let b = t.buffer("in_w", cols_t, precision);
1199        let l = t.load("load_w", w, vec![], cols_t, b);
1200        loads.push((l, b));
1201        b
1202    });
1203    let b_in = bias.map(|bt| {
1204        let b = t.buffer("in_b", cols_t, precision);
1205        let l = t.load("load_b", bt, vec![], cols_t, b);
1206        loads.push((l, b));
1207        b
1208    });
1209    let out = t.buffer("out", cols_t, precision);
1210    let wide = page_width(cols_t, target);
1211    let one = page_width(1, target);
1212    // ── The normalization, as the pipeline it actually is ──
1213    //
1214    // ★ NOT ONE OP. A reduce engine reads circular buffers, never the destination registers, so the
1215    // row's sum has to be PACKED OUT before it can be broadcast back across the row — and a pack
1216    // closes the register window. A single fused op would therefore have to smuggle three
1217    // acquire/pack cycles inside one, fighting the brackets the choreography puts around it. Split
1218    // into stages, each one fits its window honestly and the choreography synthesizes the handshakes
1219    // it already knows how to synthesize. The square and the residual add are not new primitives
1220    // either: they are the elementwise op that already exists.
1221    // ★ SOFTMAX AND LAYERNORM ARE COMPOSITIONS, NOT OPERATORS — AND ADDING THEM TOUCHED NO BACKEND.
1222    //
1223    // This is the property the whole campaign exists to demonstrate, and
1224    // `spatial_algebra_lock::a_new_workload_touches_no_backend` is what holds it: both workloads
1225    // below are new sequences of `ComputeOp::Generic`, and not one file under `spatial/lower/` gained
1226    // an arm for either of them. A reduction is an ITERATOR KIND, a broadcast is an ABSENT TERM in an
1227    // access map, and a transform is an `SfpuKind` — so the printers already knew how to emit every
1228    // instruction these need, without knowing the words "softmax" or "layernorm".
1229    if kind != RowReduceKind::RmsNorm {
1230        let acc_path = AccPolicy::F32.interm_precision();
1231        let mut chain: Vec<StageId> = Vec::new();
1232        let scaler_tile = target.tile.rows * target.tile.cols;
1233        // The reduce engine's scaler operand, at 1.0 — every divide in these chains happens ONCE,
1234        // explicitly, rather than being smeared across the reduction's terms. See
1235        // `SfpuKind::Rsqrt { over }` for what that mistake cost the first time.
1236        let ones = t.tensor(
1237            "row_scaler",
1238            TensorRole::Param,
1239            target.tile.rows,
1240            target.tile.cols,
1241            precision,
1242            TensorInit::FloatValues(vec![1.0f64.to_bits(); scaler_tile]),
1243        );
1244        let ones_in = t.buffer("in_scaler", 1, precision);
1245        let l_ones = t.load("load_scaler", ones, vec![], 1, ones_in);
1246        loads.push((l_ones, ones_in));
1247
1248        let mk = |t: &mut Template,
1249                      name: &str,
1250                      iters: Vec<IterSpec>,
1251                      ins: Vec<BufferId>,
1252                      maps: Vec<ElemAccess>,
1253                      omap: ElemAccess,
1254                      body: TensorBody,
1255                      dst: BufferId|
1256         -> StageId {
1257            t.stage(
1258                name,
1259                StageKind::Compute(ComputeOp::Generic {
1260                    iters,
1261                    ins: maps,
1262                    out: omap,
1263                    body,
1264                }),
1265                ins,
1266                vec![dst],
1267            )
1268        };
1269        let out_stage;
1270        if kind == RowReduceKind::Softmax {
1271            // exp(x - max) / Σ exp(x - max)
1272            let rmax = t.scratch("row_max", 1, acc_path);
1273            chain.push(mk(
1274                &mut t,
1275                "row_max",
1276                space(target, cols, Some(ReducePool::Max)),
1277                vec![xin, ones_in],
1278                vec![ident_map(wide), const_map()],
1279                per_row_map(one),
1280                TensorBody::Operand(0),
1281                rmax,
1282            ));
1283            let cx = t.scratch("centred", cols_t, acc_path);
1284            chain.push(mk(
1285                &mut t,
1286                "centre",
1287                space(target, cols, None),
1288                vec![xin, rmax],
1289                vec![ident_map(wide), per_row_map(one)],
1290                ident_map(wide),
1291                TensorBody::Sub(
1292                    Box::new(TensorBody::Operand(0)),
1293                    Box::new(TensorBody::Operand(1)),
1294                ),
1295                cx,
1296            ));
1297            let ex = t.scratch("expx", cols_t, acc_path);
1298            chain.push(mk(
1299                &mut t,
1300                "exp",
1301                space(target, cols, None),
1302                vec![cx],
1303                vec![ident_map(wide)],
1304                ident_map(wide),
1305                TensorBody::Unary {
1306                    kind: SfpuKind::Exp,
1307                    arg: Box::new(TensorBody::Operand(0)),
1308                },
1309                ex,
1310            ));
1311            let rsum = t.scratch("row_sum", 1, acc_path);
1312            chain.push(mk(
1313                &mut t,
1314                "row_sum",
1315                space(target, cols, Some(ReducePool::Sum)),
1316                vec![ex, ones_in],
1317                vec![ident_map(wide), const_map()],
1318                per_row_map(one),
1319                TensorBody::Operand(0),
1320                rsum,
1321            ));
1322            let inv = t.scratch("row_inv", 1, acc_path);
1323            chain.push(mk(
1324                &mut t,
1325                "recip",
1326                space(target, target.tile.cols, None),
1327                vec![rsum],
1328                vec![per_row_map(one)],
1329                per_row_map(one),
1330                TensorBody::Unary {
1331                    kind: SfpuKind::Recip,
1332                    arg: Box::new(TensorBody::Operand(0)),
1333                },
1334                inv,
1335            ));
1336            out_stage = mk(
1337                &mut t,
1338                "scale",
1339                space(target, cols, None),
1340                vec![ex, inv],
1341                vec![ident_map(wide), per_row_map(one)],
1342                ident_map(wide),
1343                TensorBody::Mul(
1344                    Box::new(TensorBody::Operand(0)),
1345                    Box::new(TensorBody::Operand(1)),
1346                ),
1347                out,
1348            );
1349            chain.push(out_stage);
1350        } else {
1351            // (x - mean)·rsqrt(var + eps), then the per-column weight and bias.
1352            let inv_cols = (1.0f64 / cols as f64).to_bits();
1353            let sum = t.scratch("row_sum", 1, acc_path);
1354            chain.push(mk(
1355                &mut t,
1356                "row_sum",
1357                space(target, cols, Some(ReducePool::Sum)),
1358                vec![xin, ones_in],
1359                vec![ident_map(wide), const_map()],
1360                per_row_map(one),
1361                TensorBody::Operand(0),
1362                sum,
1363            ));
1364            let mean = t.scratch("row_mean", 1, acc_path);
1365            chain.push(mk(
1366                &mut t,
1367                "mean",
1368                space(target, target.tile.cols, None),
1369                vec![sum],
1370                vec![per_row_map(one)],
1371                per_row_map(one),
1372                TensorBody::Mul(
1373                    Box::new(TensorBody::Operand(0)),
1374                    Box::new(TensorBody::Const(inv_cols)),
1375                ),
1376                mean,
1377            ));
1378            let dev = t.scratch("dev", cols_t, acc_path);
1379            chain.push(mk(
1380                &mut t,
1381                "centre",
1382                space(target, cols, None),
1383                vec![xin, mean],
1384                vec![ident_map(wide), per_row_map(one)],
1385                ident_map(wide),
1386                TensorBody::Sub(
1387                    Box::new(TensorBody::Operand(0)),
1388                    Box::new(TensorBody::Operand(1)),
1389                ),
1390                dev,
1391            ));
1392            let dsq = t.scratch("dev_sq", cols_t, acc_path);
1393            chain.push(mk(
1394                &mut t,
1395                "square",
1396                space(target, cols, None),
1397                vec![dev, dev],
1398                vec![ident_map(wide), ident_map(wide)],
1399                ident_map(wide),
1400                TensorBody::Mul(
1401                    Box::new(TensorBody::Operand(0)),
1402                    Box::new(TensorBody::Operand(1)),
1403                ),
1404                dsq,
1405            ));
1406            let vsum = t.scratch("var_sum", 1, acc_path);
1407            chain.push(mk(
1408                &mut t,
1409                "var",
1410                space(target, cols, Some(ReducePool::Sum)),
1411                vec![dsq, ones_in],
1412                vec![ident_map(wide), const_map()],
1413                per_row_map(one),
1414                TensorBody::Operand(0),
1415                vsum,
1416            ));
1417            let inv = t.scratch("row_inv", 1, acc_path);
1418            chain.push(mk(
1419                &mut t,
1420                "rsqrt",
1421                space(target, target.tile.cols, None),
1422                vec![vsum],
1423                vec![per_row_map(one)],
1424                per_row_map(one),
1425                TensorBody::Unary {
1426                    kind: SfpuKind::Rsqrt {
1427                        eps_nano: eps_nano(spec.eps)?,
1428                        over: cols,
1429                    },
1430                    arg: Box::new(TensorBody::Operand(0)),
1431                },
1432                inv,
1433            ));
1434            // Normalize, then the per-column weight and bias — each a row-broadcast, each an access
1435            // map with the ROW axis omitted.
1436            let mut prev = dev;
1437            let mut steps: Vec<(&str, BufferId, ElemAccess, TensorBody)> = vec![(
1438                "normalize",
1439                inv,
1440                per_row_map(one),
1441                TensorBody::Mul(
1442                    Box::new(TensorBody::Operand(0)),
1443                    Box::new(TensorBody::Operand(1)),
1444                ),
1445            )];
1446            if let Some(w) = w_in {
1447                steps.push((
1448                    "weight",
1449                    w,
1450                    per_col_map(),
1451                    TensorBody::Mul(
1452                        Box::new(TensorBody::Operand(0)),
1453                        Box::new(TensorBody::Operand(1)),
1454                    ),
1455                ));
1456            }
1457            if let Some(b) = b_in {
1458                steps.push((
1459                    "bias",
1460                    b,
1461                    per_col_map(),
1462                    TensorBody::Add(
1463                        Box::new(TensorBody::Operand(0)),
1464                        Box::new(TensorBody::Operand(1)),
1465                    ),
1466                ));
1467            }
1468            let last = steps.len() - 1;
1469            let mut tail = None;
1470            for (i, (name, second, m, body)) in steps.into_iter().enumerate() {
1471                let dst = if i == last {
1472                    out
1473                } else {
1474                    t.scratch(&format!("{name}_out"), cols_t, acc_path)
1475                };
1476                let sid = mk(
1477                    &mut t,
1478                    name,
1479                    space(target, cols, None),
1480                    vec![prev, second],
1481                    vec![ident_map(wide), m],
1482                    ident_map(wide),
1483                    body,
1484                    dst,
1485                );
1486                chain.push(sid);
1487                prev = dst;
1488                tail = Some(sid);
1489            }
1490            out_stage = tail.expect("the layernorm chain ends in the stage that writes the output");
1491        }
1492        let st = t.store("store_y", y, row_steps, cols_t, out);
1493        for (l, b) in loads {
1494            let consumer = *chain
1495                .iter()
1496                .find(|c| t.stages[c.idx()].ins.contains(&b))
1497                .expect("every load feeds a chain stage");
1498            t.edge(l, b, consumer);
1499        }
1500        for pair in chain.windows(2) {
1501            let b = t.stages[pair[0].idx()].outs[0];
1502            if t.stages[pair[1].idx()].ins.contains(&b) {
1503                t.edge(pair[0], b, pair[1]);
1504            } else {
1505                let later = *chain
1506                    .iter()
1507                    .find(|c| t.stages[c.idx()].ins.contains(&b))
1508                    .expect("every chain output is read");
1509                t.edge(pair[0], b, later);
1510            }
1511        }
1512        t.edge(out_stage, out, st);
1513        let items: Vec<WorkItem> = (0..rows_t)
1514            .map(|oy| WorkItem {
1515                id: WorkItemId::new(oy),
1516                coord: (oy, 0),
1517                out: TileRect {
1518                    row0_t: oy,
1519                    col0_t: 0,
1520                    h_t: 1,
1521                    w_t: cols_t,
1522                },
1523            })
1524            .collect();
1525        return t.finish(
1526            format!("{}_{}x{}", kind.name(), rows, cols),
1527            items,
1528            AccPolicy::F32,
1529            NumericsClaim::Approx {
1530                reason: format!("row reduction in {precision:?}"),
1531            },
1532            target,
1533        );
1534    }
1535    // ★ THE INTERMEDIATES CARRY THE ACCUMULATOR'S PRECISION, NOT THE OPERAND'S.
1536    //
1537    // A fused op rounds once, at its output. The same arithmetic split into a chain rounds at EVERY
1538    // stage boundary, because each stage packs its result into a buffer and the next unpacks it. Left
1539    // at the operand precision that is a real numeric change: on the RDU, whose witnessed datapath is
1540    // bf16, the legalized rmsnorm drifted a full bf16 ulp from the VM and
1541    // `spatial_front_end_lock::the_row_reduce_and_swiglu_sugars_compile_where_provided…` caught it
1542    // (230469 vs 231445). Legalization must not be able to change what a program computes — that is
1543    // the whole premise the campaign rests on — so the chain keeps fp32 between stages and rounds
1544    // only where the fused form would have, at the output.
1545    //
1546    // This is the same rule `validate_compute` already enforces for a matmul's spill buffer
1547    // (`IrViolation::IntermNarrowerThanAcc`): an intermediate is part of the accumulation, not part
1548    // of the operand set.
1549    // ★ WIDTH ON THE ACCUMULATION PATH ONLY — AND BOTH HALVES ARE LOAD-BEARING, TESTED SEPARATELY.
1550    //
1551    // rmsnorm drifted a full bf16 ulp from the VM on the RDU once it became a chain. Two things were
1552    // wrong and NEITHER fix works alone — removing either brings the failure straight back:
1553    //   1. scale-per-term vs scale-once (now carried by `SfpuKind::Rsqrt { over }`), and
1554    //   2. the reduction's own path narrowing at each stage boundary.
1555    //
1556    // But (2) is only needed where the ACCUMULATION lives: the squares that feed the reduce, its
1557    // result, and the transform of that result. The elementwise-shaped links — a fused residual add,
1558    // the output of a broadcast rescale — round exactly where the fused form would have rounded
1559    // anyway, so widening them buys nothing and doubles the bytes crossing every one of those
1560    // boundaries. This is the same rule `validate_compute` already applies to a matmul's spill
1561    // (`IrViolation::IntermNarrowerThanAcc`): an intermediate is part of the accumulation, or it is
1562    // not, and only the former earns the accumulator's width.
1563    let acc_path = AccPolicy::F32.interm_precision();
1564    let interm = precision;
1565    let mut chain: Vec<StageId> = Vec::new();
1566    let mut src = xin;
1567    if let Some(r) = res_in {
1568        let xr = t.scratch("xr", cols_t, interm);
1569        chain.push(t.stage(
1570            "add_residual",
1571            StageKind::Compute(ComputeOp::Generic {
1572                iters: space(target, cols, None),
1573                ins: vec![ident_map(wide), ident_map(wide)],
1574                out: ident_map(wide),
1575                body: TensorBody::Add(
1576                    Box::new(TensorBody::Operand(0)),
1577                    Box::new(TensorBody::Operand(1)),
1578                ),
1579            }),
1580            vec![xin, r],
1581            vec![xr],
1582        ));
1583        src = xr;
1584    }
1585    let xsq = t.scratch("xsq", cols_t, acc_path);
1586    chain.push(t.stage(
1587        "square",
1588        StageKind::Compute(ComputeOp::Generic {
1589            iters: space(target, cols, None),
1590            ins: vec![ident_map(wide), ident_map(wide)],
1591            out: ident_map(wide),
1592            body: TensorBody::Mul(
1593                Box::new(TensorBody::Operand(0)),
1594                Box::new(TensorBody::Operand(1)),
1595            ),
1596        }),
1597        vec![src, src],
1598        vec![xsq],
1599    ));
1600    // The reduction's scaler carries `1/cols`, so the reduce YIELDS the mean and no later divide can
1601    // fall out of step with the column count. It is one whole tile of the same constant, which is
1602    // both what the engine broadcasts and what the evaluator reads at (0,0).
1603    let tile_elems = target.tile.rows * target.tile.cols;
1604    // 1.0, not 1/cols: the reduce engine multiplies EVERY term by its scaler, and the specification
1605    // scales once at the end. `RowXformKind::Rsqrt` now carries the divide. See its doc comment.
1606    let recip = 1.0f64;
1607    let ones = t.tensor(
1608        "rms_scaler",
1609        TensorRole::Param,
1610        target.tile.rows,
1611        target.tile.cols,
1612        precision,
1613        TensorInit::FloatValues(vec![recip.to_bits(); tile_elems]),
1614    );
1615    let ones_in = t.buffer("in_scaler", 1, precision);
1616    let l_ones = t.load("load_scaler", ones, vec![], 1, ones_in);
1617    loads.push((l_ones, ones_in));
1618    let ssum = t.scratch("row_mean", 1, acc_path);
1619    // The scaler is named twice on purpose: the op carries it so the printer knows WHICH operand is
1620    // the scaler, and the stage lists it so it is a real port with a real edge. Carrying it only
1621    // inside the op leaves the load's edge pointing at a buffer the stage does not declare.
1622    let reduce = t.stage(
1623        "reduce_rows",
1624        StageKind::Compute(ComputeOp::Generic {
1625            // The column axis is FOLDED — that is the whole of "this is a reduction". The scaler rides
1626            // as the last operand because Tensix's `reduce_tile` takes one; its map is constant in both
1627            // axes, which is how the printer recognizes it as the scaler rather than as data.
1628            iters: space(target, cols, Some(ReducePool::Sum)),
1629            ins: vec![ident_map(wide), const_map()],
1630            out: per_row_map(one),
1631            body: TensorBody::Operand(0),
1632        }),
1633        vec![xsq, ones_in],
1634        vec![ssum],
1635    );
1636    chain.push(reduce);
1637    let scale = t.scratch("row_scale", 1, acc_path);
1638    chain.push(t.stage(
1639        "rsqrt",
1640        StageKind::Compute(ComputeOp::Generic {
1641            iters: space(target, target.tile.cols, None),
1642            ins: vec![per_row_map(one)],
1643            out: per_row_map(one),
1644            body: TensorBody::Unary {
1645                kind: SfpuKind::Rsqrt {
1646                    eps_nano: eps_nano(spec.eps)?,
1647                    over: cols,
1648                },
1649                arg: Box::new(TensorBody::Operand(0)),
1650            },
1651        }),
1652        vec![ssum],
1653        vec![scale],
1654    ));
1655    // Normalize, then the per-column weight, then the per-column bias. Each is its own stage for the
1656    // same reason the reduce is: its second operand is read from a circular buffer, so the previous
1657    // result has to be packed out before it can be used. Only the last one writes the output buffer.
1658    let mut steps: Vec<(&str, ComputeOp, BufferId)> = vec![(
1659        "rescale",
1660        ComputeOp::Generic {
1661            // The scale is one value per ROW: its map omits the column axis. That absence IS the
1662            // broadcast, and it is what the Tensix arm turns into `mul_tiles_bcast_cols`.
1663            iters: space(target, cols, None),
1664            ins: vec![ident_map(wide), per_row_map(one)],
1665            out: ident_map(wide),
1666            body: TensorBody::Mul(
1667                Box::new(TensorBody::Operand(0)),
1668                Box::new(TensorBody::Operand(1)),
1669            ),
1670        },
1671        scale,
1672    )];
1673    if let Some(w) = w_in {
1674        steps.push((
1675            "weight",
1676            ComputeOp::Generic {
1677                // A per-COLUMN weight: its map omits the ROW axis — `mul_tiles_bcast_rows`.
1678                iters: space(target, cols, None),
1679                ins: vec![ident_map(wide), per_col_map()],
1680                out: ident_map(wide),
1681                body: TensorBody::Mul(
1682                    Box::new(TensorBody::Operand(0)),
1683                    Box::new(TensorBody::Operand(1)),
1684                ),
1685            },
1686            w,
1687        ));
1688    }
1689    if let Some(b) = b_in {
1690        steps.push((
1691            "bias",
1692            ComputeOp::Generic {
1693                iters: space(target, cols, None),
1694                ins: vec![ident_map(wide), per_col_map()],
1695                out: ident_map(wide),
1696                body: TensorBody::Add(
1697                    Box::new(TensorBody::Operand(0)),
1698                    Box::new(TensorBody::Operand(1)),
1699                ),
1700            },
1701            b,
1702        ));
1703    }
1704    let last = steps.len() - 1;
1705    let mut prev = src;
1706    for (i, (name, op, second)) in steps.into_iter().enumerate() {
1707        let dst = if i == last {
1708            out
1709        } else {
1710            t.scratch(&format!("{name}_out"), cols_t, interm)
1711        };
1712        chain.push(t.stage(name, StageKind::Compute(op), vec![prev, second], vec![dst]));
1713        prev = dst;
1714    }
1715    let rescale = *chain
1716        .last()
1717        .expect("the chain ends in the stage that writes the output");
1718    let st = t.store("store_y", y, row_steps, cols_t, out);
1719    // Every load feeds the first stage that reads it; the chain's own edges carry the rest.
1720    for (l, b) in loads {
1721        let consumer = *chain
1722            .iter()
1723            .find(|c| t.stages[c.idx()].ins.contains(&b))
1724            .expect("every load feeds a chain stage");
1725        t.edge(l, b, consumer);
1726    }
1727    t.edge(chain[0], t.stages[chain[0].idx()].outs[0], chain[1]);
1728    for pair in chain.windows(2).skip(1) {
1729        let b = t.stages[pair[0].idx()].outs[0];
1730        t.edge(pair[0], b, pair[1]);
1731    }
1732    t.edge(rescale, out, st);
1733    let items: Vec<WorkItem> = (0..rows_t)
1734        .map(|oy| WorkItem {
1735            id: WorkItemId::new(oy),
1736            coord: (oy, 0),
1737            out: TileRect {
1738                row0_t: oy,
1739                col0_t: 0,
1740                h_t: 1,
1741                w_t: cols_t,
1742            },
1743        })
1744        .collect();
1745    t.finish(
1746        format!("{}_{}x{}", kind.name(), rows, cols),
1747        items,
1748        AccPolicy::F32,
1749        NumericsClaim::Approx {
1750            reason: format!("row reduction in {precision:?}"),
1751        },
1752        target,
1753    )
1754}
1755
1756/// The largest integer magnitude a precision represents exactly.
1757fn exact_int_bound(p: Precision) -> i64 {
1758    match p {
1759        Precision::Int8 => 127,
1760        Precision::Int16 => 32767,
1761        Precision::Int32 => i32::MAX as i64,
1762        Precision::Int64 => i64::MAX,
1763        Precision::Fp8 => 15,
1764        Precision::Bf16 => 256,
1765        Precision::Fp16 => 2048,
1766        Precision::Fp32 => 1 << 24,
1767    }
1768}
1769
1770/// The chosen numerics of a matmul on a target, from each operand's largest magnitude: `(operand, accumulator, output,
1771/// claim)`. An operand bounded by `b` lies in `[−b, b]` ([`choose_matmul_numerics_ranged`]).
1772pub fn choose_matmul_numerics(
1773    target: &SpatialTarget,
1774    bound_a: Option<i64>,
1775    bound_b: Option<i64>,
1776    k: usize,
1777) -> Result<(Precision, AccPolicy, Precision, NumericsClaim), SpatialReject> {
1778    let symmetric = |b: Option<i64>| b.map(|b| (-b, b));
1779    choose_matmul_numerics_ranged(target, symmetric(bound_a), symmetric(bound_b), k)
1780}
1781
1782/// The narrowest integer precision whose two's-complement range holds `[lo, hi]`.
1783fn int_holds(p: Precision, (lo, hi): (i64, i64)) -> bool {
1784    match crate::spatial::target::precision_int_bits(p) {
1785        Some(bits) if bits >= 64 => true,
1786        Some(bits) => lo >= -(1i64 << (bits - 1)) && hi < (1i64 << (bits - 1)),
1787        None => false,
1788    }
1789}
1790
1791/// The chosen numerics of a matmul on a target, from each operand's range `[lo, hi]`: `(operand, accumulator, output,
1792/// claim)`. An integer operand is the narrowest two's-complement width that holds both ranges — an int8 activation
1793/// clamped to `[−128, 127]` is int8 — and the claim is exact while `k` products of the operands' extremes fit the
1794/// accumulator.
1795pub fn choose_matmul_numerics_ranged(
1796    target: &SpatialTarget,
1797    range_a: Option<(i64, i64)>,
1798    range_b: Option<(i64, i64)>,
1799    k: usize,
1800) -> Result<(Precision, AccPolicy, Precision, NumericsClaim), SpatialReject> {
1801    let magnitude = |(lo, hi): (i64, i64)| (lo as i128).abs().max((hi as i128).abs());
1802    // The largest magnitude of a partial sum, from the fact every chip's accumulator is judged by; a sum past i128 is no
1803    // bound.
1804    let product_bound: Option<i128> = match (range_a, range_b) {
1805        (Some(a), Some(b)) => {
1806            let (lo, hi) = crate::tensor_graph::facts::product_sums(a, b, k);
1807            (lo > i128::MIN && hi < i128::MAX).then(|| lo.unsigned_abs().max(hi.unsigned_abs()) as i128)
1808        }
1809        _ => None,
1810    };
1811    // Integer path first: the narrowest integer operand that holds the ranges exactly.
1812    for p in [
1813        Precision::Int8,
1814        Precision::Int16,
1815        Precision::Int32,
1816        Precision::Int64,
1817    ] {
1818        if !target.matmul_supports(p) {
1819            continue;
1820        }
1821        let fits = match (range_a, range_b) {
1822            (Some(a), Some(b)) => int_holds(p, a) && int_holds(p, b),
1823            _ => p == Precision::Int64,
1824        };
1825        if !fits {
1826            continue;
1827        }
1828        let acc = if target.matmul_acc_int_bits() >= 64 {
1829            AccPolicy::I64
1830        } else {
1831            AccPolicy::I32
1832        };
1833        let limit: i128 = 1i128 << (acc.bits() - 1);
1834        let claim = match product_bound {
1835            Some(pb) if pb < limit => NumericsClaim::Exact,
1836            Some(_) => NumericsClaim::Approx {
1837                reason: format!("partial sums may exceed the {}-bit accumulator", acc.bits()),
1838            },
1839            None => NumericsClaim::Approx {
1840                reason: "operand range unknown (external data)".into(),
1841            },
1842        };
1843        // The result is what the accumulator holds: an INT32 accumulator packs INT32, never a widened copy the
1844        // part has no type for.
1845        let out =
1846            crate::spatial::target::int_precision_of_bits(acc.bits()).unwrap_or(Precision::Int64);
1847        return Ok((p, acc, out, claim));
1848    }
1849    // Float engine: the narrowest float operand the datapath takes — bf16, then fp16, then fp32 on a datapath with
1850    // nothing narrower — with an fp32 accumulator; exact only inside the mantissa.
1851    for p in [Precision::Bf16, Precision::Fp16, Precision::Fp32] {
1852        if !target.matmul_supports(p) {
1853            continue;
1854        }
1855        if !target.matmul_fp32_acc() {
1856            return Err(SpatialReject::MissingCompute {
1857                needed: "fp32 accumulate",
1858                target: target.name,
1859            });
1860        }
1861        let exact = exact_int_bound(p) as i128;
1862        let claim = match (range_a.map(magnitude), range_b.map(magnitude), product_bound) {
1863            (Some(a), Some(b), Some(pb)) if a <= exact && b <= exact && pb < (1i128 << 24) => NumericsClaim::Exact,
1864            (Some(a), Some(b), _) if a > exact || b > exact => NumericsClaim::Approx {
1865                reason: format!("operands exceed the exact integer range of {p:?}"),
1866            },
1867            (Some(_), Some(_), _) => NumericsClaim::Approx {
1868                reason: "partial sums exceed fp32's 24-bit mantissa".into(),
1869            },
1870            _ => NumericsClaim::Approx {
1871                reason: "operand range unknown (external data)".into(),
1872            },
1873        };
1874        return Ok((p, AccPolicy::F32, Precision::Fp32, claim));
1875    }
1876    Err(SpatialReject::MissingCompute {
1877        needed: "tile matmul",
1878        target: target.name,
1879    })
1880}
1881
1882/// Lift a recognized matmul to a template spatial program on `target`.
1883pub fn from_matmul(
1884    spec: &MatmulSpec,
1885    names: &MatmulNames,
1886    fills: (&Fill, &Fill),
1887    ranges: (Option<(i64, i64)>, Option<(i64, i64)>),
1888    numerics: NumericsPolicy,
1889    target: &SpatialTarget,
1890) -> Result<SpatialProgram, SpatialReject> {
1891    let tile = target.tile;
1892    let init_a = init_of(fills.0, spec.m * spec.k);
1893    let init_b = init_of(fills.1, spec.k * spec.n);
1894    let (operand, acc, mut out_dtype, mut claim) = choose_matmul_numerics_ranged(
1895        target,
1896        ranges.0.or(init_a.range()),
1897        ranges.1.or(init_b.range()),
1898        spec.k,
1899    )?;
1900    // A program that accepts spill rounding packs its partials and its output in the operand's width.
1901    if numerics == NumericsPolicy::AllowSpillRounding && acc == AccPolicy::F32 {
1902        out_dtype = operand;
1903        claim = NumericsClaim::Approx {
1904            reason: "spill and output rounding to the operand width accepted".into(),
1905        };
1906    }
1907    let dataflow =
1908        target
1909            .matmul_dataflows()
1910            .first()
1911            .copied()
1912            .ok_or(SpatialReject::MissingCompute {
1913                needed: "tile matmul",
1914                target: target.name,
1915            })?;
1916    let local = Handshake::Fifo;
1917
1918    let grid = |rows: usize, cols: usize| TileGrid {
1919        rows_t: rows.div_ceil(tile.rows),
1920        cols_t: cols.div_ceil(tile.cols),
1921    };
1922    let tensors = vec![
1923        TensorDecl {
1924            id: TensorId(0),
1925            name: names.a.clone(),
1926            role: TensorRole::Input,
1927            elems: ElemShape {
1928                rows: spec.m,
1929                cols: spec.k,
1930            },
1931            tiles: grid(spec.m, spec.k),
1932            dtype: operand,
1933            layout: Layout::RowMajorTiles,
1934            space: TensorSpace::Dram,
1935            init: init_a,
1936            range: ranges.0,
1937            chip: None,
1938        },
1939        TensorDecl {
1940            id: TensorId(1),
1941            name: names.b.clone(),
1942            role: TensorRole::Param,
1943            elems: ElemShape {
1944                rows: spec.k,
1945                cols: spec.n,
1946            },
1947            tiles: grid(spec.k, spec.n),
1948            dtype: operand,
1949            layout: Layout::RowMajorTiles,
1950            space: TensorSpace::Dram,
1951            init: init_b,
1952            range: ranges.1,
1953            chip: None,
1954        },
1955        TensorDecl {
1956            id: TensorId(2),
1957            name: names.c.clone(),
1958            role: TensorRole::Output,
1959            elems: ElemShape {
1960                rows: spec.m,
1961                cols: spec.n,
1962            },
1963            tiles: grid(spec.m, spec.n),
1964            dtype: out_dtype,
1965            layout: Layout::RowMajorTiles,
1966            space: TensorSpace::Dram,
1967            init: TensorInit::Zero,
1968            range: None,
1969            chip: None,
1970        },
1971    ];
1972    let (mt, nt, kt) = (
1973        tensors[2].tiles.rows_t,
1974        tensors[2].tiles.cols_t,
1975        tensors[0].tiles.cols_t,
1976    );
1977
1978    let buffers = vec![
1979        Buffer {
1980            id: BufferId(0),
1981            name: "in0".into(),
1982            kind: BufferKind::Fifo {
1983                depth_pages: 2,
1984                page_tiles: 1,
1985            },
1986            dtype: operand,
1987            space: MemSpace::Local,
1988            site: None,
1989            origin: None,
1990            alias_of: None,
1991        },
1992        Buffer {
1993            id: BufferId(1),
1994            name: "in1".into(),
1995            kind: BufferKind::Fifo {
1996                depth_pages: 2,
1997                page_tiles: 1,
1998            },
1999            dtype: operand,
2000            space: MemSpace::Local,
2001            site: None,
2002            origin: None,
2003            alias_of: None,
2004        },
2005        Buffer {
2006            id: BufferId(2),
2007            name: "out".into(),
2008            kind: BufferKind::Fifo {
2009                depth_pages: 2,
2010                page_tiles: 1,
2011            },
2012            dtype: out_dtype,
2013            space: MemSpace::Local,
2014            site: None,
2015            origin: None,
2016            alias_of: None,
2017        },
2018    ];
2019
2020    let stages = vec![
2021        Stage {
2022            id: StageId(0),
2023            name: "load_a".into(),
2024            kind: StageKind::Load {
2025                tensor: TensorId(0),
2026                pattern: AccessPattern {
2027                    origin: TileAddr {
2028                        base: 0,
2029                        steps: vec![(LV_ITEM_Y, kt), (LV_KBLOCK, 1)],
2030                        indirect: None,
2031                    },
2032                    block_h: 1,
2033                    block_w: 1,
2034                    stride_w: 1,
2035                    stride_h: kt,
2036                },
2037                num_blocks: kt,
2038                multicast: None,
2039            },
2040            role: SiteRole::Movement,
2041            ins: vec![],
2042            outs: vec![BufferId(0)],
2043            site: None,
2044            item: None,
2045            origin: None,
2046        },
2047        Stage {
2048            id: StageId(1),
2049            name: "load_b".into(),
2050            kind: StageKind::Load {
2051                tensor: TensorId(1),
2052                pattern: AccessPattern {
2053                    origin: TileAddr {
2054                        base: 0,
2055                        steps: vec![(LV_ITEM_X, 1), (LV_KBLOCK, nt)],
2056                        indirect: None,
2057                    },
2058                    block_h: 1,
2059                    block_w: 1,
2060                    stride_w: 1,
2061                    stride_h: nt,
2062                },
2063                num_blocks: kt,
2064                multicast: None,
2065            },
2066            role: SiteRole::Movement,
2067            ins: vec![],
2068            outs: vec![BufferId(1)],
2069            site: None,
2070            item: None,
2071            origin: None,
2072        },
2073        Stage {
2074            id: StageId(2),
2075            name: "mm".into(),
2076            kind: StageKind::Compute(ComputeOp::MatmulTiles {
2077                block: BlockShape {
2078                    osh: 1,
2079                    osw: 1,
2080                    ibw: 1,
2081                },
2082                sub_blocks: (1, 1),
2083                num_blocks: kt,
2084                operand,
2085                acc,
2086                dataflow,
2087                interm: None,
2088                granularity: Granularity::PerTile,
2089            }),
2090            role: SiteRole::Compute,
2091            ins: vec![BufferId(0), BufferId(1)],
2092            outs: vec![BufferId(2)],
2093            site: None,
2094            item: None,
2095            origin: None,
2096        },
2097        Stage {
2098            id: StageId(3),
2099            name: "store_c".into(),
2100            kind: StageKind::Store {
2101                tensor: TensorId(2),
2102                pattern: AccessPattern {
2103                    origin: TileAddr {
2104                        base: 0,
2105                        steps: vec![(LV_ITEM_Y, nt), (LV_ITEM_X, 1)],
2106                        indirect: None,
2107                    },
2108                    block_h: 1,
2109                    block_w: 1,
2110                    stride_w: 1,
2111                    stride_h: nt,
2112                },
2113                sub_blocks: (1, 1),
2114            },
2115            role: SiteRole::Movement,
2116            ins: vec![BufferId(2)],
2117            outs: vec![],
2118            site: None,
2119            item: None,
2120            origin: None,
2121        },
2122    ];
2123    let edges = vec![
2124        Edge {
2125            id: EdgeId(0),
2126            from: StageId(0),
2127            buffer: BufferId(0),
2128            to: vec![StageId(2)],
2129            handshake: local.clone(),
2130        },
2131        Edge {
2132            id: EdgeId(1),
2133            from: StageId(1),
2134            buffer: BufferId(1),
2135            to: vec![StageId(2)],
2136            handshake: local.clone(),
2137        },
2138        Edge {
2139            id: EdgeId(2),
2140            from: StageId(2),
2141            buffer: BufferId(2),
2142            to: vec![StageId(3)],
2143            handshake: local.clone(),
2144        },
2145    ];
2146    let mut items = Vec::with_capacity(mt * nt);
2147    for oy in 0..mt {
2148        for ox in 0..nt {
2149            items.push(WorkItem {
2150                id: WorkItemId::new(items.len()),
2151                coord: (oy, ox),
2152                out: TileRect {
2153                    row0_t: oy,
2154                    col0_t: ox,
2155                    h_t: 1,
2156                    w_t: 1,
2157                },
2158            });
2159        }
2160    }
2161    let mut requires = Requirements::default();
2162    requires.handshakes.insert(local.kind());
2163    let p = SpatialProgram {
2164        name: format!("matmul_{}x{}x{}", spec.m, spec.n, spec.k),
2165        tile,
2166        tensors,
2167        stages,
2168        buffers,
2169        edges,
2170        semaphores: Vec::new(),
2171        sections: Vec::new(),
2172        trips: Vec::new(),
2173        links: Vec::new(),
2174        work: WorkList {
2175            items,
2176            k_tiles: kt,
2177            plan: None,
2178            k_trip: None,
2179        },
2180        numerics,
2181        claim,
2182        acc_tiles_used: target.effective_acc_tiles(acc, numerics == NumericsPolicy::Exact),
2183        mem_banks: target.site.mem_banks,
2184        requires,
2185        placement: None,
2186        memory: None,
2187        schedule: None,
2188        certificate: None,
2189    };
2190    p.validate()?;
2191    Ok(p)
2192}
2193
2194/// Operand bit width for the integer reference (`None` for float operands).
2195pub fn operand_bits(p: Precision) -> Option<u32> {
2196    precision_int_bits(p)
2197}
2198
2199/// The same-site hand-off primitive a target offers: a FIFO where it has one, else valid/ready.
2200/// The handshake a target provides for a same-site bounded hand-off. A template always says `Fifo` (the
2201/// abstract bounded buffer between a producer and its consumer); `synthesize_handshakes` retypes those edges
2202/// to this kind, so the template is the same value on every target and the target enters only through passes.
2203pub fn local_handshake(target: &SpatialTarget) -> Result<Handshake, SpatialReject> {
2204    use crate::spatial::target::HandshakeKind;
2205    if target.provides_handshake(HandshakeKind::Fifo) {
2206        Ok(Handshake::Fifo)
2207    } else if target.provides_handshake(HandshakeKind::ValidReady) {
2208        Ok(Handshake::ValidReady)
2209    } else if target.provides_handshake(HandshakeKind::Token) {
2210        Ok(Handshake::Token)
2211    } else {
2212        Err(SpatialReject::MissingHandshake {
2213            needed: HandshakeKind::Fifo,
2214            target: target.name,
2215        })
2216    }
2217}
2218
2219/// A weight-stationary systolic tile as a spatial program: `O(m×n) = A(m×k)·W(k×n)`, the weights baked into
2220/// a `Param` tensor, the compute stage carrying the weight-stationary policy.
2221pub fn from_systolic(
2222    sys: &crate::codegen_sva::systolic::Systolic,
2223    m: usize,
2224    target: &SpatialTarget,
2225) -> Result<SpatialProgram, SpatialReject> {
2226    let spec = MatmulSpec {
2227        m,
2228        n: sys.n,
2229        k: sys.k,
2230        a: Default::default(),
2231        b: Default::default(),
2232        c: Default::default(),
2233    };
2234    let names = MatmulNames {
2235        a: "a".into(),
2236        b: "w".into(),
2237        c: "o".into(),
2238    };
2239    let weights = sys.weights.iter().fold(None, |h: Option<(i64, i64)>, w| Some(h.map_or((*w, *w), |(lo, hi)| (lo.min(*w), hi.max(*w)))));
2240    let mut p = from_matmul(
2241        &spec,
2242        &names,
2243        (&Fill::Unknown, &Fill::Unknown),
2244        (Some((-128, 127)), Some(weights.unwrap_or((0, 0)))),
2245        NumericsPolicy::Exact,
2246        target,
2247    )?;
2248    p.tensors[1].init = TensorInit::Values(sys.weights.clone());
2249    use crate::spatial::target::DataflowPolicy;
2250    let offered = target.matmul_dataflows();
2251    let policy = if offered.contains(&DataflowPolicy::WeightStationary) {
2252        DataflowPolicy::WeightStationary
2253    } else if offered.contains(&DataflowPolicy::OutputStationary) {
2254        DataflowPolicy::OutputStationary
2255    } else {
2256        return Err(SpatialReject::MissingCompute {
2257            needed: "a systolic dataflow (weight- or output-stationary)",
2258            target: target.name,
2259        });
2260    };
2261    for s in p.stages.iter_mut() {
2262        if let StageKind::Compute(ComputeOp::MatmulTiles { dataflow, .. }) = &mut s.kind {
2263            *dataflow = policy;
2264        }
2265    }
2266    p.name = format!("systolic_{}x{}_m{m}", sys.k, sys.n);
2267    p.validate()?;
2268    Ok(p)
2269}
2270
2271/// A fused transformer block (matmul → requant → bias → relu) as ONE fused compute chain inside the
2272/// scalar datapath's fusion window, intermediates streaming through local hand-offs.
2273pub fn from_fused_block(
2274    fb: &crate::codegen_sva::systolic::FusedBlock,
2275    m: usize,
2276    target: &SpatialTarget,
2277) -> Result<SpatialProgram, SpatialReject> {
2278    let window = target.scalar_target().map(|t| t.fusion_window).unwrap_or(1);
2279    if window < 4 {
2280        return Err(SpatialReject::MissingCompute {
2281            needed: "fusion window of 4 (matmul→requant→bias→relu)",
2282            target: target.name,
2283        });
2284    }
2285    let mut p = from_systolic(&fb.sys, m, target)?;
2286    let local = Handshake::Fifo;
2287    let out_dtype = p.tensors[2].dtype;
2288    // Bias tensor: one row of n, a Param.
2289    let bias_id = TensorId::new(p.tensors.len());
2290    let n = fb.sys.n;
2291    p.tensors.push(TensorDecl {
2292        id: bias_id,
2293        name: "bias".into(),
2294        role: TensorRole::Param,
2295        elems: ElemShape { rows: 1, cols: n },
2296        tiles: TileGrid {
2297            rows_t: 1,
2298            cols_t: n.div_ceil(p.tile.cols),
2299        },
2300        dtype: out_dtype,
2301        layout: Layout::RowMajorTiles,
2302        space: TensorSpace::Dram,
2303        init: TensorInit::Values(fb.bias.clone()),
2304        range: fb.bias.iter().fold(None, |h: Option<(i64, i64)>, b| Some(h.map_or((*b, *b), |(lo, hi)| (lo.min(*b), hi.max(*b))))),
2305        chip: None,
2306    });
2307    // Rewire: mm → rq → bias(+load) → relu → store.
2308    let store = p
2309        .stages
2310        .iter()
2311        .position(|s| matches!(s.kind, StageKind::Store { .. }))
2312        .unwrap();
2313    let mm_out = p
2314        .stages
2315        .iter()
2316        .find(|s| matches!(s.kind, StageKind::Compute(ComputeOp::MatmulTiles { .. })))
2317        .unwrap()
2318        .outs[0];
2319    let new_buf = |p: &mut SpatialProgram, name: &str| {
2320        let id = BufferId::new(p.buffers.len());
2321        p.buffers.push(Buffer {
2322            id,
2323            name: name.into(),
2324            kind: BufferKind::Fifo {
2325                depth_pages: 2,
2326                page_tiles: 1,
2327            },
2328            dtype: out_dtype,
2329            space: MemSpace::Local,
2330            site: None,
2331            origin: None,
2332            alias_of: None,
2333        });
2334        id
2335    };
2336    let rq_out = new_buf(&mut p, "rq");
2337    let bias_in = new_buf(&mut p, "bias_in");
2338    let bias_out = new_buf(&mut p, "biased");
2339    let relu_out = new_buf(&mut p, "relu");
2340    let mm_id = p
2341        .stages
2342        .iter()
2343        .find(|s| matches!(s.kind, StageKind::Compute(ComputeOp::MatmulTiles { .. })))
2344        .unwrap()
2345        .id;
2346    let nt = p.tensors[2].tiles.cols_t;
2347    let push_stage = |p: &mut SpatialProgram,
2348                      name: &str,
2349                      kind: StageKind,
2350                      role: SiteRole,
2351                      ins: Vec<BufferId>,
2352                      outs: Vec<BufferId>|
2353     -> StageId {
2354        let id = StageId::new(p.stages.len());
2355        p.stages.push(Stage {
2356            id,
2357            name: name.into(),
2358            kind,
2359            role,
2360            ins,
2361            outs,
2362            site: None,
2363            item: None,
2364            origin: None,
2365        });
2366        id
2367    };
2368    let rq = push_stage(
2369        &mut p,
2370        "requant",
2371        StageKind::Compute(ComputeOp::Requant {
2372            m: fb.requant_m,
2373            s: fb.requant_s,
2374        }),
2375        SiteRole::Compute,
2376        vec![mm_out],
2377        vec![rq_out],
2378    );
2379    let load_bias = push_stage(
2380        &mut p,
2381        "load_bias",
2382        StageKind::Load {
2383            tensor: bias_id,
2384            pattern: AccessPattern {
2385                origin: TileAddr {
2386                    base: 0,
2387                    steps: vec![(LV_ITEM_X, 1)],
2388                    indirect: None,
2389                },
2390                block_h: 1,
2391                block_w: 1,
2392                stride_w: 1,
2393                stride_h: nt,
2394            },
2395            num_blocks: 1,
2396            multicast: None,
2397        },
2398        SiteRole::Movement,
2399        vec![],
2400        vec![bias_in],
2401    );
2402    let bias = push_stage(
2403        &mut p,
2404        "bias",
2405        StageKind::Compute(ComputeOp::Bias { bias: bias_in }),
2406        SiteRole::Compute,
2407        vec![rq_out, bias_in],
2408        vec![bias_out],
2409    );
2410    let relu = push_stage(
2411        &mut p,
2412        "relu",
2413        StageKind::Compute(ComputeOp::Relu),
2414        SiteRole::Compute,
2415        vec![bias_out],
2416        vec![relu_out],
2417    );
2418    // The store now consumes the relu output.
2419    let store_id = p.stages[store].id;
2420    p.stages[store].ins = vec![relu_out];
2421    for e in p.edges.iter_mut() {
2422        if e.from == mm_id {
2423            e.to = vec![rq];
2424        }
2425    }
2426    let add_edge = |p: &mut SpatialProgram, from: StageId, buffer: BufferId, to: StageId| {
2427        let id = EdgeId::new(p.edges.len());
2428        p.edges.push(Edge {
2429            id,
2430            from,
2431            buffer,
2432            to: vec![to],
2433            handshake: local.clone(),
2434        });
2435    };
2436    add_edge(&mut p, rq, rq_out, bias);
2437    add_edge(&mut p, load_bias, bias_in, bias);
2438    add_edge(&mut p, bias, bias_out, relu);
2439    add_edge(&mut p, relu, relu_out, store_id);
2440    p.name = format!("fused_block_{}x{}_m{m}", fb.sys.k, fb.sys.n);
2441    p.validate()?;
2442    Ok(p)
2443}
2444
2445/// A decision fabric (an if-converted scalar body) as a `Scalar` stage applied element-wise over an
2446/// `n`-element input vector: `y[i] = f(x[i])`.
2447pub fn from_scalar_body(
2448    df: &crate::codegen_sva::fabric_rtl::DecisionFabric,
2449    n: usize,
2450    target: &SpatialTarget,
2451) -> Result<SpatialProgram, SpatialReject> {
2452    if target.scalar_target().is_none() {
2453        return Err(SpatialReject::MissingCompute {
2454            needed: "scalar datapath",
2455            target: target.name,
2456        });
2457    }
2458    if df.n_in != 1 {
2459        return Err(SpatialReject::NotLowered("multi-input scalar bodies"));
2460    }
2461    let local = Handshake::Fifo;
2462    let tile = target.tile;
2463    let grid = |rows: usize, cols: usize| TileGrid {
2464        rows_t: rows.div_ceil(tile.rows),
2465        cols_t: cols.div_ceil(tile.cols),
2466    };
2467    let tensors = vec![
2468        TensorDecl {
2469            id: TensorId(0),
2470            name: "x".into(),
2471            role: TensorRole::Input,
2472            elems: ElemShape { rows: 1, cols: n },
2473            tiles: grid(1, n),
2474            dtype: Precision::Int64,
2475            layout: Layout::RowMajorTiles,
2476            space: TensorSpace::Dram,
2477            init: TensorInit::External,
2478            range: None,
2479            chip: None,
2480        },
2481        TensorDecl {
2482            id: TensorId(1),
2483            name: "y".into(),
2484            role: TensorRole::Output,
2485            elems: ElemShape { rows: 1, cols: n },
2486            tiles: grid(1, n),
2487            dtype: Precision::Int64,
2488            layout: Layout::RowMajorTiles,
2489            space: TensorSpace::Dram,
2490            init: TensorInit::Zero,
2491            range: None,
2492            chip: None,
2493        },
2494    ];
2495    let nt = tensors[1].tiles.cols_t;
2496    let buffers = vec![
2497        Buffer {
2498            id: BufferId(0),
2499            name: "in0".into(),
2500            kind: BufferKind::Fifo {
2501                depth_pages: 2,
2502                page_tiles: 1,
2503            },
2504            dtype: Precision::Int64,
2505            space: MemSpace::Local,
2506            site: None,
2507            origin: None,
2508            alias_of: None,
2509        },
2510        Buffer {
2511            id: BufferId(1),
2512            name: "out".into(),
2513            kind: BufferKind::Fifo {
2514                depth_pages: 2,
2515                page_tiles: 1,
2516            },
2517            dtype: Precision::Int64,
2518            space: MemSpace::Local,
2519            site: None,
2520            origin: None,
2521            alias_of: None,
2522        },
2523    ];
2524    let stages = vec![
2525        Stage {
2526            id: StageId(0),
2527            name: "load_x".into(),
2528            kind: StageKind::Load {
2529                tensor: TensorId(0),
2530                pattern: AccessPattern {
2531                    origin: TileAddr {
2532                        base: 0,
2533                        steps: vec![(LV_ITEM_Y, nt), (LV_ITEM_X, 1)],
2534                        indirect: None,
2535                    },
2536                    block_h: 1,
2537                    block_w: 1,
2538                    stride_w: 1,
2539                    stride_h: nt,
2540                },
2541                num_blocks: 1,
2542                multicast: None,
2543            },
2544            role: SiteRole::Movement,
2545            ins: vec![],
2546            outs: vec![BufferId(0)],
2547            site: None,
2548            item: None,
2549            origin: None,
2550        },
2551        Stage {
2552            id: StageId(1),
2553            name: "body".into(),
2554            kind: StageKind::Scalar(ScalarBody {
2555                n_in: df.n_in,
2556                cells: df.cells.clone(),
2557                result: df.result,
2558            }),
2559            role: SiteRole::Compute,
2560            ins: vec![BufferId(0)],
2561            outs: vec![BufferId(1)],
2562            site: None,
2563            item: None,
2564            origin: None,
2565        },
2566        Stage {
2567            id: StageId(2),
2568            name: "store_y".into(),
2569            kind: StageKind::Store {
2570                tensor: TensorId(1),
2571                pattern: AccessPattern {
2572                    origin: TileAddr {
2573                        base: 0,
2574                        steps: vec![(LV_ITEM_Y, nt), (LV_ITEM_X, 1)],
2575                        indirect: None,
2576                    },
2577                    block_h: 1,
2578                    block_w: 1,
2579                    stride_w: 1,
2580                    stride_h: nt,
2581                },
2582                sub_blocks: (1, 1),
2583            },
2584            role: SiteRole::Movement,
2585            ins: vec![BufferId(1)],
2586            outs: vec![],
2587            site: None,
2588            item: None,
2589            origin: None,
2590        },
2591    ];
2592    let edges = vec![
2593        Edge {
2594            id: EdgeId(0),
2595            from: StageId(0),
2596            buffer: BufferId(0),
2597            to: vec![StageId(1)],
2598            handshake: local.clone(),
2599        },
2600        Edge {
2601            id: EdgeId(1),
2602            from: StageId(1),
2603            buffer: BufferId(1),
2604            to: vec![StageId(2)],
2605            handshake: local.clone(),
2606        },
2607    ];
2608    let mut items = Vec::new();
2609    for ox in 0..nt {
2610        items.push(WorkItem {
2611            id: WorkItemId::new(items.len()),
2612            coord: (0, ox),
2613            out: TileRect {
2614                row0_t: 0,
2615                col0_t: ox,
2616                h_t: 1,
2617                w_t: 1,
2618            },
2619        });
2620    }
2621    let mut requires = Requirements::default();
2622    requires.handshakes.insert(local.kind());
2623    let p = SpatialProgram {
2624        name: "scalar_body".into(),
2625        tile,
2626        tensors,
2627        stages,
2628        buffers,
2629        edges,
2630        semaphores: Vec::new(),
2631        sections: Vec::new(),
2632        trips: Vec::new(),
2633        links: Vec::new(),
2634        work: WorkList {
2635            items,
2636            k_tiles: 1,
2637            plan: None,
2638            k_trip: None,
2639        },
2640        numerics: NumericsPolicy::Exact,
2641        claim: NumericsClaim::Exact,
2642        acc_tiles_used: target.site.acc_tiles.max(1),
2643        mem_banks: target.site.mem_banks,
2644        requires,
2645        placement: None,
2646        memory: None,
2647        schedule: None,
2648        certificate: None,
2649    };
2650    p.validate()?;
2651    Ok(p)
2652}