Skip to main content

logicaffeine_compile/spatial/
certificate.rs

1//! `ChoreographyCertificate` — what a verified spatial program PROVES about itself, re-checkable by a checker
2//! that trusts only a ~100-line PR/RUP refutation checker, a ~40-line topological-order verifier, and integer
3//! arithmetic — never the passes that produced the schedule.
4//!
5//! * **Liveness** — the schedule is unrolled into its event graph: one transition per executed op, one place
6//!   per token (`Data{buf,k}` = the k-th page pushed, `Space{buf,k}` = the k-th reservation's slot, returned by
7//!   the pop `depth` pages earlier, `Ready`/`Valid` = the k-th round of a multicast semaphore pair, `McastData`
8//!   = the k-th block landing at a receiver, `Pc` = program order). Every place has one producer and one
9//!   consumer (a marked graph); by Commoner's theorem the net is live iff every directed cycle carries a
10//!   token, i.e. iff the token-free arcs form a DAG — the witness is a topological order of the transitions.
11//!   A consumed place with neither a producer nor an initial token is a STARVED transition (a dropped
12//!   increment, flag or pop) and earns no certificate.
13//! * **Bounds** — per-buffer counting balance (reserve == push == pop over the run) and the evaluated
14//!   high-water occupancy against the depth.
15//! * **PE datapaths** — a SAT [`PlacementCertificate`] for every integer compute stage (`psum + a·w` at the
16//!   8-bit bound the fabric's placer certification uses) and for ALU/select scalar bodies; float tensor
17//!   datapaths are honestly `Uncertified` with the reason.
18//! * **Numerics** — the exactness claim carried from the front end.
19
20use rustc_hash::FxHashMap;
21use std::collections::{BTreeMap, BTreeSet};
22
23use serde::{Deserialize, Serialize};
24
25use super::eval::{eval_choreography_dry, eval_choreography_traced, ChoreoFault, Inputs, Value};
26use super::ir::*;
27use crate::codegen_sva::bitblast::{word_size, BlastRefusal};
28use crate::codegen_sva::fabric_grid::{
29    cells_datapath, cells_to_gates, grid_output_to_bounded, grid_output_to_gates, route_loop_body,
30    unroutable_cell, BoundedBuild, TILE_OPERAND_CHANNELS,
31};
32use crate::codegen_sva::fabric_rtl::{AluOp, FabricOp, LoopCell, Src};
33use crate::codegen_sva::fabric_target::Precision;
34use crate::codegen_sva::pnr_certificate::{
35    certify_gates_within, certify_mapping, PlacementCertificate, GATE_BUDGET_NODES,
36};
37use crate::codegen_sva::sva_to_verify::BoundedExpr;
38use crate::spatial::target::precision_is_int;
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
41pub enum PlaceKind {
42    Space {
43        buf: BufferId,
44        k: usize,
45    },
46    Data {
47        buf: BufferId,
48        k: usize,
49    },
50    Ready {
51        sem: SemId,
52        receiver: Site,
53        k: usize,
54    },
55    Valid {
56        sem: SemId,
57        receiver: Site,
58        k: usize,
59    },
60    McastData {
61        group: GroupId,
62        receiver: Site,
63        k: usize,
64    },
65    Pc {
66        site: Site,
67        engine: Engine,
68        k: usize,
69    },
70    /// The `k`-th post landed at `at` on a RENDEZVOUS semaphore. A rendezvous is not a hand-off: a post is
71    /// not consumed by one reader, because every engine program at the site that waits reads the same
72    /// count. So this place has one producer (the post) and as many consumers as the site has waiters —
73    /// one for a gather (its root), every program at the site for a release — and a wait for `c` consumes
74    /// the first `c` posts at its own site. The liveness proof is the same DAG argument either way.
75    ///
76    /// ★ WHY NOT `Ready`. A ready counter is modelled per RECEIVER SITE and paired one round at a time
77    /// with the sender's wait; a rendezvous is waited on by several engines of one site at once, and that
78    /// pairing does not describe it. Posts are keyed by the site they LAND on, which is exactly the
79    /// counter a wait there reads.
80    Arrival {
81        sem: SemId,
82        at: Site,
83        k: usize,
84    },
85}
86
87#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
88pub struct Place {
89    pub kind: PlaceKind,
90    pub marking: usize,
91}
92
93#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
94pub struct Transition {
95    pub site: Site,
96    pub engine: Engine,
97    pub op_index: usize,
98    pub consumes: Vec<usize>,
99    pub produces: Vec<usize>,
100}
101
102#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
103pub struct MarkedGraph {
104    pub places: Vec<Place>,
105    pub transitions: Vec<Transition>,
106}
107
108#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
109pub struct LivenessWitness {
110    pub topo_order: Vec<usize>,
111}
112
113#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
114pub struct BufferBalance {
115    pub buf: BufferId,
116    pub depth_pages: usize,
117    pub reserved: u64,
118    pub pushed: u64,
119    pub popped: u64,
120    pub high_water: usize,
121}
122
123#[derive(Clone, Debug)]
124pub enum ComputeStageCert {
125    Sat(PlacementCertificate),
126    Uncertified { reason: String },
127}
128
129impl PartialEq for ComputeStageCert {
130    fn eq(&self, other: &Self) -> bool {
131        match (self, other) {
132            (ComputeStageCert::Sat(a), ComputeStageCert::Sat(b)) => {
133                a.len() == b.len() && a.num_vars() == b.num_vars()
134            }
135            (
136                ComputeStageCert::Uncertified { reason: a },
137                ComputeStageCert::Uncertified { reason: b },
138            ) => a == b,
139            _ => false,
140        }
141    }
142}
143
144/// The liveness proof of ONE section: its graph's shape, a digest that binds the witness to that exact
145/// graph, and the topological order over the section's own transition numbering.
146#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
147pub struct SectionProof {
148    pub section: usize,
149    pub places: usize,
150    pub transitions: usize,
151    /// FNV-1a over the section graph's serialization — the stand-in for carrying the graph itself.
152    pub digest: u64,
153    /// Which entry of the proof's witness table orders this section's transitions.
154    pub witness: usize,
155    /// The section's graph at every count of its trips' windows ([`trip_window`]); empty when it runs to no trip.
156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157    pub cases: Vec<TripCase>,
158}
159
160/// ★ ONE COUNT OF A TRIP, PROVED. The graph a part of the program unrolls to when each trip it runs to counts
161/// `counts[i]` (its trips in id order), carried like a section's: its shape, its digest, and the witness-table entry
162/// that orders it. `recheck` rebuilds every one.
163#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
164pub struct TripCase {
165    pub counts: Vec<usize>,
166    pub places: usize,
167    pub transitions: usize,
168    pub digest: u64,
169    pub witness: usize,
170}
171
172/// HOW THE LIVENESS PROOF IS CARRIED.
173///
174/// ★ WHY THERE ARE TWO FORMS. The unrolled event graph of a four-layer Qwen3.8 forward is 196,211,828
175/// places and 102,936,753 transitions — 35 GB, two thirds of peak, and the reason the sixty-four-layer
176/// model did not fit on a 125 GB box. The graph is not irreducibly that big: it is a DISJOINT UNION of
177/// one subgraph per section joined only by the program-counter chain, because on the composed forward
178/// NO buffer, semaphore or multicast group is touched by two sections. Proving one section at a time
179/// and keeping only the witnesses turns a 35 GB artifact into per-section orders, and peak from the sum
180/// of the sections into the largest of them.
181///
182/// ★ WHY THE CUT IS SOUND, AND WHERE IT REFUSES. Commoner's condition asks that every directed cycle
183/// carry a token. Cutting the program-counter arcs between sections only REMOVES arcs, so it can invent
184/// no cycle; it could only HIDE one that runs backwards from a later section to an earlier one — and a
185/// backwards arc is exactly a place produced in one section and consumed in another. `section_ranges`
186/// checks for that and hands back `None` when it finds one, so a program that does not decompose is
187/// proved whole, at the old cost, rather than proved wrongly.
188#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
189pub enum GraphProof {
190    /// One graph for the whole choreography. The form every single-section program takes, and the
191    /// fallback whenever a place crosses a section boundary. The graph itself is carried, so `recheck`
192    /// compares it structurally.
193    Whole {
194        graph: MarkedGraph,
195        liveness: LivenessWitness,
196        /// The graph at every count of the program's trips' windows, ordered by `case_witnesses`.
197        #[serde(default, skip_serializing_if = "Vec::is_empty")]
198        cases: Vec<TripCase>,
199        #[serde(default, skip_serializing_if = "Vec::is_empty")]
200        case_witnesses: Vec<LivenessWitness>,
201    },
202    /// One proof per section, plus the totals. The graphs are rebuilt by `recheck` from the schedule and
203    /// matched against the carried digests.
204    ///
205    /// ★ THE WITNESSES ARE A TABLE, BECAUSE A DECODER REPEATS ITSELF. Sections with the same dataflow
206    /// build the same graph up to resource names, and so the same topological order. Measured on the
207    /// composed Qwen3.8 forward: 117 sections carry 11 DISTINCT witnesses at 4 layers, and 232 sections
208    /// carry the SAME 11 at 8 — the table is constant in depth, while one witness per section grows with
209    /// it (3,923 entries at 4 layers, 7,759 at 8; the real-width certificate was 810 MB at 2 layers).
210    /// Sharing is safe by construction: `recheck` still rebuilds EVERY section's own graph and verifies
211    /// the witness it references against that graph, so a shared witness that does not fit a section is
212    /// a refusal, never an acceptance.
213    PerSection {
214        sections: Vec<SectionProof>,
215        witnesses: Vec<LivenessWitness>,
216        places: usize,
217        transitions: usize,
218    },
219}
220
221impl GraphProof {
222    pub fn places(&self) -> usize {
223        match self {
224            GraphProof::Whole { graph, .. } => graph.places.len(),
225            GraphProof::PerSection { places, .. } => *places,
226        }
227    }
228    pub fn transitions(&self) -> usize {
229        match self {
230            GraphProof::Whole { graph, .. } => graph.transitions.len(),
231            GraphProof::PerSection { transitions, .. } => *transitions,
232        }
233    }
234    /// The whole graph, when one is carried. `None` is not "no proof": it is the per-section form, whose
235    /// graphs are rebuilt on demand by [`ChoreographyCertificate::recheck`].
236    pub fn whole_graph(&self) -> Option<&MarkedGraph> {
237        match self {
238            GraphProof::Whole { graph, .. } => Some(graph),
239            GraphProof::PerSection { .. } => None,
240        }
241    }
242    /// The whole graph, mutably — the seam a mutation probe needs to corrupt a carried graph.
243    pub fn whole_graph_mut(&mut self) -> Option<&mut MarkedGraph> {
244        match self {
245            GraphProof::Whole { graph, .. } => Some(graph),
246            GraphProof::PerSection { .. } => None,
247        }
248    }
249    /// Every witness this proof carries, in section order (one entry for the whole form).
250    pub fn witnesses(&self) -> Vec<&LivenessWitness> {
251        match self {
252            GraphProof::Whole { liveness, .. } => vec![liveness],
253            GraphProof::PerSection { witnesses, .. } => witnesses.iter().collect(),
254        }
255    }
256    /// For the per-section form, the witness-table index each section uses, in section order.
257    pub fn witness_of_section(&self) -> Option<Vec<usize>> {
258        match self {
259            GraphProof::Whole { .. } => None,
260            GraphProof::PerSection { sections, .. } => {
261                Some(sections.iter().map(|s| s.witness).collect())
262            }
263        }
264    }
265}
266
267#[derive(Clone, Debug, PartialEq)]
268pub struct ChoreographyCertificate {
269    pub schedule_digest: u64,
270    pub proof: GraphProof,
271    pub balances: Vec<BufferBalance>,
272    /// `(semaphore, increments or flag sets, wait target)`.
273    pub sem_counts: Vec<(SemId, u64, u64)>,
274    pub compute: Vec<(StageId, ComputeStageCert)>,
275    pub numerics: NumericsClaim,
276}
277
278impl ChoreographyCertificate {
279    /// Total places across the proof, whichever form it takes.
280    pub fn places(&self) -> usize {
281        self.proof.places()
282    }
283    /// Total transitions across the proof, whichever form it takes.
284    pub fn transitions(&self) -> usize {
285        self.proof.transitions()
286    }
287}
288
289#[derive(Clone, Debug, PartialEq)]
290pub enum CertifyFault {
291    NotScheduled,
292    Invalid(IrViolation),
293    /// A transition consumes a place nobody produces and that carries no initial token.
294    Starved {
295        transition: usize,
296        place: PlaceKind,
297    },
298    /// A token-free cycle: the ring of waits that could never all fire, each named by the op that waits
299    /// and the place it waits on. A deadlock is only actionable if you can see WHICH hand-off closed the
300    /// ring, so the fault carries the ring rather than an index list.
301    Deadlock {
302        cycle: Vec<DeadlockNode>,
303    },
304    Bounds {
305        buffer: BufferId,
306    },
307    /// An ACTIVATION is not ordered: the section that writes it is not strictly before every section
308    /// that reads it, or a barrier between them is missing. Without that order the reader takes
309    /// whatever is in the tensor, which on a hundred-section forward is the whole answer.
310    ActivationOrder {
311        tensor: TensorId,
312        writer: Option<usize>,
313        reader: usize,
314        reason: &'static str,
315    },
316    /// STATE is not ordered: more than one section writes it, one section both reads and writes it, or
317    /// a barrier between the reader and the writer is missing. Unlike an activation a state tensor is
318    /// FULL at the start of a run, so an unordered read yields last step's value instead of faulting —
319    /// which is exactly the silent wrong answer this obligation exists to make impossible.
320    StateOrder {
321        tensor: TensorId,
322        reader: Option<usize>,
323        writer: Option<usize>,
324        reason: &'static str,
325    },
326    /// A TENSOR-PARALLEL SHARD REACHES AN OUTPUT WITHOUT BEING SUMMED. Each chip of a row-parallel shard
327    /// computes a partial over its slice of the contraction — correctly shaped, fully written, and
328    /// missing every term the other chips hold. Nothing about that partial looks wrong: the program runs,
329    /// the store lands, the run reports success, and the number is `1/chips` of the answer. This is the
330    /// one obligation that cannot be recovered by inspecting the output, which is why it is checked here.
331    UnreducedShard {
332        tensor: TensorId,
333        chips: usize,
334    },
335    /// A part of the program runs to trips whose windows multiply past what one proof may hold.
336    TripCasesExceed {
337        limit: usize,
338    },
339    Eval(ChoreoFault),
340}
341
342/// FNV-1a over the schedule's serialization — binds a certificate to one choreography.
343pub fn schedule_digest(p: &SpatialProgram) -> u64 {
344    fnv_json(&p.schedule)
345}
346
347/// FNV-1a over a value's JSON serialization, hashed as it streams: the digest of a composed program's
348/// schedule never materializes the text.
349fn fnv_json<T: Serialize>(v: &T) -> u64 {
350    struct Fnv(u64);
351    impl std::io::Write for Fnv {
352        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
353            for x in buf {
354                self.0 ^= u64::from(*x);
355                self.0 = self.0.wrapping_mul(0x100000001b3);
356            }
357            Ok(buf.len())
358        }
359        fn flush(&mut self) -> std::io::Result<()> {
360            Ok(())
361        }
362    }
363    let mut f = Fnv(0xcbf29ce484222325);
364    let _ = serde_json::to_writer(&mut f, v);
365    f.0
366}
367
368/// An executed op with the loop environment it ran under.
369///
370/// ★ IT BORROWS. The graph only READS the choreography, and a `ChoreoOp::Compute` carries a whole
371/// `ComputeOp` — its iteration space, one access map per operand, and a body tree. Copying that per op,
372/// and again per unrolled round, made the CERTIFICATE cost more memory than the program it certifies:
373/// two thirds of peak on a four-layer Qwen3.8 (52.6 GB, against 18.0 GB for the composed program), which
374/// is what put the 64-layer model out of reach of a 125 GB box. The schedule outlives the graph, so a
375/// reference is all this ever needed.
376#[derive(Clone, Debug)]
377struct Event<'a> {
378    op: &'a ChoreoOp,
379}
380
381/// The item-loop window the event graph unrolls. Work items are rounds of one choreography whose state
382/// (FIFO occupancy, semaphore rounds) carries over with a dependence distance bounded by the deepest FIFO;
383/// the unrolled graph is periodic beyond that, so a token-free cycle exists in the full unrolling iff it
384/// exists within `deepest FIFO + 2` consecutive rounds. Every engine of a site (and every member of a
385/// multicast group) loops the same count, so the cap keeps producer/consumer pairings consistent.
386pub fn item_window(p: &SpatialProgram) -> usize {
387    p.buffers
388        .iter()
389        .map(|b| b.kind.depth_pages())
390        .max()
391        .unwrap_or(1)
392        + 2
393}
394
395/// Unroll `ops` into events, each loop run to a trip counted `counts[trip]`.
396fn unroll<'a>(ops: &'a [TaggedOp], out: &mut Vec<Event<'a>>, window: usize, counts: &[usize]) {
397    for t in ops {
398        match &t.op {
399            ChoreoOp::Loop { var, count, body } => {
400                // ★ ONLY THE ITEM LOOP MAY BE CAPPED, AND THIS WAS MEASURED THE HARD WAY.
401                //
402                // Fully unrolling counted loops is what makes the graph enormous — 196,211,828 places
403                // and 102,936,753 transitions for a FOUR-layer Qwen3.8, 35 GB and two thirds of peak,
404                // which is why the 64-layer model does not fit. Capping every loop at the same window
405                // looks like the same periodicity argument, and it is NOT: with the K-block loop
406                // truncated, 39 of 100 statements that certify today stop certifying. The item loop is
407                // special because every engine of a site loops the SAME count, so truncation keeps
408                // producer/consumer pairings consistent; a K-block loop's balance lives INSIDE the loop
409                // and truncation breaks it. A smaller graph has to come from somewhere else.
410                let n = if *var == crate::spatial::passes::LV_ITEM {
411                    count.at(counts).min(window)
412                } else {
413                    count.at(counts)
414                };
415                // The body is walked in place; `body_ops` would copy it once per round.
416                for _ in 0..n {
417                    unroll(body, out, window, counts);
418                }
419            }
420            other => out.push(Event { op: other }),
421        }
422    }
423}
424
425/// The graph under construction. A place is interned by its kind — the first request creates it, every later one
426/// finds it — and the lookup is hashed: a layer's graphs hold millions of places, each requested by its producer and
427/// again by its consumer.
428struct Builder {
429    places: Vec<Place>,
430    index: FxHashMap<PlaceKind, usize>,
431    transitions: Vec<Transition>,
432}
433
434impl Builder {
435    fn place(&mut self, kind: PlaceKind, marking: usize) -> usize {
436        if let Some(i) = self.index.get(&kind) {
437            return *i;
438        }
439        let i = self.places.len();
440        self.places.push(Place { kind, marking });
441        self.index.insert(kind, i);
442        i
443    }
444}
445
446/// Build the unrolled event graph of the whole schedule, every trip at its greatest count.
447pub fn build_marked_graph(p: &SpatialProgram) -> Result<MarkedGraph, CertifyFault> {
448    build_graph_over(p, &GraphContext::of(p), None, &p.bound_counts())
449}
450
451/// A section's share of the schedule: each program that holds ops of the section, with the half-open range of its
452/// top-level ops that do, in program order.
453type Selection = Vec<(usize, std::ops::Range<usize>)>;
454
455/// What every graph of one program reads, derived once rather than once per section graph — each is linear in the
456/// whole program, and a composed layer builds thousands of section graphs.
457struct GraphContext {
458    /// The item-loop cap, [`item_window`].
459    window: usize,
460    /// Each buffer's depth in pages.
461    depth: Vec<usize>,
462    /// The multicast group feeding each receive buffer.
463    recv_group: BTreeMap<BufferId, GroupId>,
464    /// Each trip's lead bound, [`trip_spreads`].
465    spreads: Vec<usize>,
466}
467
468impl GraphContext {
469    fn of(p: &SpatialProgram) -> Self {
470        let recv_group = p
471            .stages
472            .iter()
473            .filter_map(|s| match (&s.kind, s.outs.first()) {
474                (StageKind::Recv { group }, Some(o)) => Some((*o, *group)),
475                _ => None,
476            })
477            .collect();
478        GraphContext {
479            window: item_window(p),
480            depth: p.buffers.iter().map(|bf| bf.kind.depth_pages()).collect(),
481            recv_group,
482            spreads: trip_spreads(p),
483        }
484    }
485}
486
487/// Build the unrolled event graph of the schedule, or of ONE section of it.
488///
489/// `sel` names the programs holding the section's ops and each one's half-open range of top-level ops; `None`
490/// takes every op of every program. Places are keyed by resource and round, and a section that owns its
491/// buffers, semaphores and groups outright (which is what [`section_ranges`] checks before handing any
492/// selection back) restarts those rounds at one — so the section's subgraph is exactly the part of the
493/// whole graph its ops induce, program-counter arcs across the cut aside. The work is linear in the ops
494/// selected: a program with no op in the section contributes nothing and is not visited.
495///
496/// Each loop run to a trip runs `counts[trip]` times.
497fn build_graph_over(
498    p: &SpatialProgram,
499    cx: &GraphContext,
500    sel: Option<&[(usize, std::ops::Range<usize>)]>,
501    counts: &[usize],
502) -> Result<MarkedGraph, CertifyFault> {
503    let sched = p.schedule.as_ref().ok_or(CertifyFault::NotScheduled)?;
504    let pl = p.placement.as_ref().ok_or(CertifyFault::NotScheduled)?;
505    let mut b = Builder {
506        places: Vec::new(),
507        index: FxHashMap::default(),
508        transitions: Vec::new(),
509    };
510    let chosen: Vec<(&EngineProgram, &[TaggedOp])> = match sel {
511        Some(r) => r
512            .iter()
513            .map(|(pi, ops)| (&sched.programs[*pi], &sched.programs[*pi].ops[ops.clone()]))
514            .collect(),
515        None => sched
516            .programs
517            .iter()
518            .map(|prog| (prog, prog.ops.as_slice()))
519            .collect(),
520    };
521    // Per-program unrolled events (item loops within the provable window).
522    let mut events: Vec<Vec<Event>> = Vec::new();
523    for (_, ops) in &chosen {
524        let mut out = Vec::new();
525        unroll(ops, &mut out, cx.window, counts);
526        events.push(out);
527    }
528    // Global transition ids: (program, event) → id.
529    let mut base = Vec::new();
530    let mut total = 0;
531    for ev in &events {
532        base.push(total);
533        total += ev.len();
534    }
535    for (ci, (prog, _)) in chosen.iter().enumerate() {
536        for (ei, _) in events[ci].iter().enumerate() {
537            b.transitions.push(Transition {
538                site: prog.site,
539                engine: prog.engine,
540                op_index: ei,
541                consumes: Vec::new(),
542                produces: Vec::new(),
543            });
544        }
545    }
546    // Program order through Pc places.
547    for (ci, (prog, _)) in chosen.iter().enumerate() {
548        for ei in 0..events[ci].len() {
549            let t = base[ci] + ei;
550            let pc_in = b.place(
551                PlaceKind::Pc {
552                    site: prog.site,
553                    engine: prog.engine,
554                    k: ei,
555                },
556                if ei == 0 { 1 } else { 0 },
557            );
558            b.transitions[t].consumes.push(pc_in);
559            let pc_out = b.place(
560                PlaceKind::Pc {
561                    site: prog.site,
562                    engine: prog.engine,
563                    k: ei + 1,
564                },
565                0,
566            );
567            b.transitions[t].produces.push(pc_out);
568        }
569    }
570    // Buffer tokens: per buffer, walk producer and consumer programs in order. The counters are sparse — a
571    // section's graph touches only its own buffers.
572    let depth = &cx.depth;
573    let (mut reserved, mut pushed, mut waited_hi, mut popped): (
574        FxHashMap<usize, usize>,
575        FxHashMap<usize, usize>,
576        FxHashMap<usize, usize>,
577        FxHashMap<usize, usize>,
578    ) = Default::default();
579    // Semaphore rounds: per (sem, site) counters of incs / waits / sets.
580    let mut inc_round: FxHashMap<(SemId, Site), usize> = FxHashMap::default();
581    let mut wait_round: FxHashMap<(SemId, Site), usize> = FxHashMap::default();
582    // Rendezvous posts, keyed by the site they land on (see `PlaceKind::Arrival`).
583    let mut arrival_round: FxHashMap<(SemId, Site), usize> = FxHashMap::default();
584    // A shared valid flag is set per GROUP: each sender's flag multicasts count their own rounds.
585    let mut valid_set_round: FxHashMap<(SemId, GroupId), usize> = FxHashMap::default();
586    let mut mcast_round: FxHashMap<GroupId, usize> = FxHashMap::default();
587    let mut recv_push_round: FxHashMap<(GroupId, Site), usize> = FxHashMap::default();
588    let recv_group = &cx.recv_group;
589    // Events must be processed in an order where producers precede consumers per buffer — program order
590    // within a program suffices for pairing indices, so we walk programs sequentially.
591    for (ci, (prog, _)) in chosen.iter().enumerate() {
592        for (ei, ev) in events[ci].iter().enumerate() {
593            let t = base[ci] + ei;
594            match ev.op {
595                ChoreoOp::Reserve { buf, pages } => {
596                    for _ in 0..*pages {
597                        let k = bump(&mut reserved, buf.idx());
598                        let initial = k <= depth[buf.idx()];
599                        let pl_id = b.place(
600                            PlaceKind::Space { buf: *buf, k },
601                            if initial { 1 } else { 0 },
602                        );
603                        b.transitions[t].consumes.push(pl_id);
604                    }
605                }
606                ChoreoOp::Push { buf, pages } => {
607                    for _ in 0..*pages {
608                        let k = bump(&mut pushed, buf.idx());
609                        let pl_id = b.place(PlaceKind::Data { buf: *buf, k }, 0);
610                        b.transitions[t].produces.push(pl_id);
611                    }
612                    if let Some(g) = recv_group.get(buf) {
613                        let r = *recv_push_round.entry((*g, prog.site)).or_insert(0);
614                        recv_push_round.insert((*g, prog.site), r + 1);
615                        let pl_id = b.place(
616                            PlaceKind::McastData {
617                                group: *g,
618                                receiver: prog.site,
619                                k: r + 1,
620                            },
621                            0,
622                        );
623                        b.transitions[t].consumes.push(pl_id);
624                    }
625                }
626                ChoreoOp::Wait { buf, pages } => {
627                    let need = popped.get(&buf.idx()).copied().unwrap_or(0) + *pages;
628                    while waited_hi.get(&buf.idx()).copied().unwrap_or(0) < need {
629                        let k = bump(&mut waited_hi, buf.idx());
630                        let pl_id = b.place(PlaceKind::Data { buf: *buf, k }, 0);
631                        b.transitions[t].consumes.push(pl_id);
632                    }
633                }
634                ChoreoOp::Pop { buf, pages } => {
635                    for _ in 0..*pages {
636                        let k = bump(&mut popped, buf.idx()) + depth[buf.idx()];
637                        let pl_id = b.place(PlaceKind::Space { buf: *buf, k }, 0);
638                        b.transitions[t].produces.push(pl_id);
639                    }
640                    let done = popped.get(&buf.idx()).copied().unwrap_or(0);
641                    let hi = waited_hi.entry(buf.idx()).or_insert(0);
642                    *hi = (*hi).max(done);
643                }
644                ChoreoOp::SemInc { sem, at, by: _ }
645                    if matches!(p.semaphores[sem.idx()].role, SemRole::Barrier { .. }) =>
646                {
647                    let r = *arrival_round.entry((*sem, *at)).or_insert(0);
648                    arrival_round.insert((*sem, *at), r + 1);
649                    let pl_id = b.place(
650                        PlaceKind::Arrival {
651                            sem: *sem,
652                            at: *at,
653                            k: r + 1,
654                        },
655                        0,
656                    );
657                    b.transitions[t].produces.push(pl_id);
658                }
659                ChoreoOp::SemInc { sem, at, by: _ } => {
660                    let r = *inc_round.entry((*sem, prog.site)).or_insert(0);
661                    inc_round.insert((*sem, prog.site), r + 1);
662                    let _ = at;
663                    let pl_id = b.place(
664                        PlaceKind::Ready {
665                            sem: *sem,
666                            receiver: prog.site,
667                            k: r + 1,
668                        },
669                        0,
670                    );
671                    b.transitions[t].produces.push(pl_id);
672                }
673                // A rendezvous wait for `c` consumes the first `c` posts AT THIS SITE — the gather's root
674                // every arrival, a release waiter its site's one release — and it is what a dropped post
675                // starves.
676                ChoreoOp::SemWait { sem, until }
677                    if matches!(p.semaphores[sem.idx()].role, SemRole::Barrier { .. }) =>
678                {
679                    let (SemCond::AtLeast(c) | SemCond::Equals(c)) = *until;
680                    for k in 1..=c {
681                        let pl_id = b.place(
682                            PlaceKind::Arrival {
683                                sem: *sem,
684                                at: prog.site,
685                                k,
686                            },
687                            0,
688                        );
689                        b.transitions[t].consumes.push(pl_id);
690                    }
691                }
692                ChoreoOp::SemWait { sem, .. } => {
693                    let r = *wait_round.entry((*sem, prog.site)).or_insert(0);
694                    wait_round.insert((*sem, prog.site), r + 1);
695                    match p.semaphores[sem.idx()].role {
696                        SemRole::Barrier { .. } => {
697                            unreachable!("a rendezvous wait is matched above")
698                        }
699                        SemRole::ReadyCounter { .. } => {
700                            // The sender waits for every receiver's (r+1)-th increment.
701                            let sem_ref = &p.semaphores[sem.idx()];
702                            let own_group = sem_ref.operand.and_then(|op| {
703                                pl.groups
704                                    .iter()
705                                    .find(|g| g.operand == op && g.sender == prog.site)
706                            });
707                            let receivers: Vec<Site> = match own_group {
708                                Some(gg) => gg
709                                    .rect
710                                    .sites()
711                                    .into_iter()
712                                    .filter(|s| *s != gg.sender)
713                                    .collect(),
714                                None => sem_ref
715                                    .sites
716                                    .iter()
717                                    .copied()
718                                    .filter(|s| *s != prog.site)
719                                    .collect(),
720                            };
721                            for rcv in receivers {
722                                let pl_id = b.place(
723                                    PlaceKind::Ready {
724                                        sem: *sem,
725                                        receiver: rcv,
726                                        k: r + 1,
727                                    },
728                                    0,
729                                );
730                                b.transitions[t].consumes.push(pl_id);
731                            }
732                        }
733                        SemRole::ValidFlag => {
734                            let pl_id = b.place(
735                                PlaceKind::Valid {
736                                    sem: *sem,
737                                    receiver: prog.site,
738                                    k: r + 1,
739                                },
740                                0,
741                            );
742                            b.transitions[t].consumes.push(pl_id);
743                        }
744                    }
745                }
746                ChoreoOp::McastSetValid { sem, group } => {
747                    let r = *valid_set_round.entry((*sem, *group)).or_insert(0);
748                    valid_set_round.insert((*sem, *group), r + 1);
749                    let g = &pl.groups[group.idx()];
750                    for rcv in g.rect.sites().into_iter().filter(|s| *s != g.sender) {
751                        let pl_id = b.place(
752                            PlaceKind::Valid {
753                                sem: *sem,
754                                receiver: rcv,
755                                k: r + 1,
756                            },
757                            0,
758                        );
759                        b.transitions[t].produces.push(pl_id);
760                    }
761                }
762                ChoreoOp::McastSend { group, .. } => {
763                    let r = *mcast_round.entry(*group).or_insert(0);
764                    mcast_round.insert(*group, r + 1);
765                    let g = &pl.groups[group.idx()];
766                    for rcv in g.rect.sites().into_iter().filter(|s| *s != g.sender) {
767                        let pl_id = b.place(
768                            PlaceKind::McastData {
769                                group: *group,
770                                receiver: rcv,
771                                k: r + 1,
772                            },
773                            0,
774                        );
775                        b.transitions[t].produces.push(pl_id);
776                    }
777                }
778                _ => {}
779            }
780        }
781    }
782    Ok(MarkedGraph {
783        places: b.places,
784        transitions: b.transitions,
785    })
786}
787
788/// Advance a sparse counter and return its new value.
789fn bump(counts: &mut FxHashMap<usize, usize>, key: usize) -> usize {
790    let c = counts.entry(key).or_insert(0);
791    *c += 1;
792    *c
793}
794
795/// One transition in a token-free cycle: the op that waits, and the empty place it waits on.
796#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
797pub struct DeadlockNode {
798    pub site: Site,
799    pub engine: Engine,
800    pub op_index: usize,
801    pub waiting_on: PlaceKind,
802}
803
804/// Find a real back edge in the stuck sub-graph and return the ring it closes.
805///
806/// A greedy walk is not enough here: program order chains every op of an engine to the next, so following
807/// successors marches to the end of a program without ever revisiting. Only a depth-first search that keeps
808/// its recursion stack can tell a forward edge from the back edge that actually closes a cycle, and the back
809/// edge is the whole diagnostic — it names the two hand-offs waiting on each other.
810fn token_free_cycle(
811    g: &MarkedGraph,
812    indeg: &[usize],
813    succ: &[Vec<usize>],
814    producer: &[Option<usize>],
815) -> Vec<DeadlockNode> {
816    let n = g.transitions.len();
817    let stuck = |i: usize| indeg[i] > 0;
818    // 0 = unvisited, 1 = on the stack, 2 = done.
819    let mut color = vec![0u8; n];
820    let mut stack: Vec<usize> = Vec::new();
821    let mut ring: Vec<usize> = Vec::new();
822    for root in (0..n).filter(|i| stuck(*i)) {
823        if color[root] != 0 {
824            continue;
825        }
826        // Iterative DFS: (node, index of the next successor to try).
827        let mut work: Vec<(usize, usize)> = vec![(root, 0)];
828        color[root] = 1;
829        stack.push(root);
830        while !work.is_empty() {
831            let (node, idx) = work.last().map(|t| (t.0, t.1)).expect("non-empty");
832            match succ[node].get(idx).copied() {
833                Some(next) => {
834                    work.last_mut().expect("non-empty").1 += 1;
835                    if !stuck(next) {
836                        continue;
837                    }
838                    if color[next] == 1 {
839                        let at = stack.iter().position(|&x| x == next).unwrap_or(0);
840                        ring = stack[at..].to_vec();
841                        break;
842                    }
843                    if color[next] == 0 {
844                        color[next] = 1;
845                        stack.push(next);
846                        work.push((next, 0));
847                    }
848                }
849                None => {
850                    color[node] = 2;
851                    stack.pop();
852                    work.pop();
853                }
854            }
855        }
856        if !ring.is_empty() {
857            break;
858        }
859    }
860    // No back edge means the sweep stalled on a starved arc rather than a cycle; report the stuck head.
861    if ring.is_empty() {
862        ring = (0..n).filter(|i| stuck(*i)).take(1).collect();
863    }
864    ring.iter()
865        .take(12)
866        .map(|&ti| {
867            let t = &g.transitions[ti];
868            // The arc that holds this op is the empty place whose producer is also stuck.
869            let waiting_on = t
870                .consumes
871                .iter()
872                .find(|pl| {
873                    g.places[**pl].marking == 0 && producer[**pl].map(stuck).unwrap_or(false)
874                })
875                .or_else(|| t.consumes.iter().find(|pl| g.places[**pl].marking == 0))
876                .map(|pl| g.places[*pl].kind)
877                .unwrap_or(g.places[0].kind);
878            DeadlockNode {
879                site: t.site,
880                engine: t.engine,
881                op_index: t.op_index,
882                waiting_on,
883            }
884        })
885        .collect()
886}
887
888/// Commoner's condition on the unrolled net: a starved transition or a token-free cycle is a fault; else the
889/// topological order over token-free arcs is the witness.
890pub fn liveness_witness(g: &MarkedGraph) -> Result<LivenessWitness, CertifyFault> {
891    let n = g.transitions.len();
892    let mut producer = vec![None; g.places.len()];
893    for (ti, t) in g.transitions.iter().enumerate() {
894        for pl in &t.produces {
895            producer[*pl] = Some(ti);
896        }
897    }
898    let mut indeg = vec![0usize; n];
899    let mut succ: Vec<Vec<usize>> = vec![Vec::new(); n];
900    for (ti, t) in g.transitions.iter().enumerate() {
901        for pl in &t.consumes {
902            if g.places[*pl].marking > 0 {
903                continue;
904            }
905            match producer[*pl] {
906                Some(pr) => {
907                    succ[pr].push(ti);
908                    indeg[ti] += 1;
909                }
910                None => {
911                    return Err(CertifyFault::Starved {
912                        transition: ti,
913                        place: g.places[*pl].kind,
914                    })
915                }
916            }
917        }
918    }
919    // The lowest-numbered ready transition first, so the witness is a function of the graph alone.
920    let mut order = Vec::with_capacity(n);
921    let mut ready: std::collections::BinaryHeap<std::cmp::Reverse<usize>> = (0..n)
922        .filter(|i| indeg[*i] == 0)
923        .map(std::cmp::Reverse)
924        .collect();
925    while let Some(std::cmp::Reverse(t)) = ready.pop() {
926        order.push(t);
927        for s in &succ[t] {
928            indeg[*s] -= 1;
929            if indeg[*s] == 0 {
930                ready.push(std::cmp::Reverse(*s));
931            }
932        }
933    }
934    if order.len() != n {
935        return Err(CertifyFault::Deadlock {
936            cycle: token_free_cycle(g, &indeg, &succ, &producer),
937        });
938    }
939    Ok(LivenessWitness { topo_order: order })
940}
941
942fn verify_witness(g: &MarkedGraph, w: &LivenessWitness) -> bool {
943    let n = g.transitions.len();
944    if w.topo_order.len() != n {
945        return false;
946    }
947    let mut pos = vec![usize::MAX; n];
948    for (i, t) in w.topo_order.iter().enumerate() {
949        if *t >= n || pos[*t] != usize::MAX {
950            return false;
951        }
952        pos[*t] = i;
953    }
954    let mut producer = vec![None; g.places.len()];
955    for (ti, t) in g.transitions.iter().enumerate() {
956        for pl in &t.produces {
957            producer[*pl] = Some(ti);
958        }
959    }
960    for (ti, t) in g.transitions.iter().enumerate() {
961        for pl in &t.consumes {
962            if g.places[*pl].marking > 0 {
963                continue;
964            }
965            match producer[*pl] {
966                Some(pr) => {
967                    if pos[pr] >= pos[ti] {
968                        return false;
969                    }
970                }
971                None => return false,
972            }
973        }
974    }
975    true
976}
977
978/// Counting balances from the schedule (loop multiplicities), high-water from the dry run.
979fn balances(p: &SpatialProgram, high_water: &BTreeMap<BufferId, usize>) -> Vec<BufferBalance> {
980    let sched = p.schedule.as_ref().unwrap();
981    let mut reserved = vec![0u64; p.buffers.len()];
982    let mut pushed = vec![0u64; p.buffers.len()];
983    let mut popped = vec![0u64; p.buffers.len()];
984    for prog in &sched.programs {
985        crate::spatial::ir::walk_tagged_ops(&prog.ops, 1, &mut |op, m| match op {
986            ChoreoOp::Reserve { buf, pages } => reserved[buf.idx()] += m * *pages as u64,
987            ChoreoOp::Push { buf, pages } => pushed[buf.idx()] += m * *pages as u64,
988            ChoreoOp::Pop { buf, pages } => popped[buf.idx()] += m * *pages as u64,
989            _ => {}
990        });
991    }
992    p.buffers
993        .iter()
994        .map(|b| BufferBalance {
995            buf: b.id,
996            depth_pages: b.kind.depth_pages(),
997            reserved: reserved[b.id.idx()],
998            pushed: pushed[b.id.idx()],
999            popped: popped[b.id.idx()],
1000            high_water: high_water.get(&b.id).copied().unwrap_or(0),
1001        })
1002        .collect()
1003}
1004
1005fn sem_counts(p: &SpatialProgram) -> Vec<(SemId, u64, u64)> {
1006    let sched = p.schedule.as_ref().unwrap();
1007    let mut incs = vec![0u64; p.semaphores.len()];
1008    let mut waits = vec![0u64; p.semaphores.len()];
1009    for prog in &sched.programs {
1010        crate::spatial::ir::walk_tagged_ops(&prog.ops, 1, &mut |op, m| match op {
1011            ChoreoOp::SemInc { sem, by, .. } => incs[sem.idx()] += m * *by as u64,
1012            ChoreoOp::McastSetValid { sem, .. } => incs[sem.idx()] += m,
1013            ChoreoOp::SemWait { sem, until } => {
1014                let n = match (p.semaphores[sem.idx()].role, until) {
1015                    (SemRole::ValidFlag, _) => 1,
1016                    (_, SemCond::Equals(n)) | (_, SemCond::AtLeast(n)) => *n as u64,
1017                };
1018                waits[sem.idx()] += m * n;
1019            }
1020            _ => {}
1021        });
1022    }
1023    p.semaphores
1024        .iter()
1025        .map(|s| (s.id, incs[s.id.idx()], waits[s.id.idx()]))
1026        .collect()
1027}
1028
1029/// Lower an ALU/select cell body to the bounded fragment (the placer's own lowering vocabulary).
1030///
1031/// This is the TREE form: a cell read by two later cells is copied into both, and their readers
1032/// copy those, so the body's `BoundedExpr` is the cell DAG UNFOLDED — `fib.lg`'s 75 cells unfold
1033/// to 1 657 763 nodes. Callers that speak the tree (printers, SMT surfaces) want exactly that;
1034/// the certifier does not, and uses [`cells_to_gates`](crate::codegen_sva::fabric_grid::cells_to_gates)
1035/// to build the same datapath into the shared gate DAG instead, through the SAME walk, so the two
1036/// forms cannot drift.
1037pub fn cells_to_bounded(cells: &[LoopCell], result: Src, width: u32) -> Option<BoundedExpr> {
1038    cells_datapath(
1039        &mut BoundedBuild::new(width, &|k| format!("in{k}")),
1040        cells,
1041        result,
1042    )
1043}
1044
1045/// The PE datapath of an integer tile matmul: `psum + a·w`, certified at the 8-bit operand bound.
1046fn pe_certificate(bits: u32) -> ComputeStageCert {
1047    let width = bits.min(8);
1048    let pe = vec![
1049        FabricOp {
1050            op: AluOp::Mul,
1051            a: Src::In(0),
1052            b: Src::In(2),
1053        },
1054        FabricOp {
1055            op: AluOp::Add,
1056            a: Src::In(1),
1057            b: Src::Val(0),
1058        },
1059    ];
1060    let cells: Vec<LoopCell> = pe
1061        .iter()
1062        .map(|o| LoopCell::Arith {
1063            op: o.op,
1064            a: o.a,
1065            b: o.b,
1066        })
1067        .collect();
1068    let g = route_loop_body(&cells, 3, &[Src::Val(1)]);
1069    let placed = grid_output_to_bounded(&g, 0, width, &|k| format!("in{k}"));
1070    let spec = crate::codegen_sva::fabric_rtl::fabric_to_bounded(&pe, 1, width);
1071    match certify_mapping(placed, spec) {
1072        Some(c) => ComputeStageCert::Sat(c),
1073        None => ComputeStageCert::Uncertified {
1074            reason: "the PE obligation escaped the propositional fragment".into(),
1075        },
1076    }
1077}
1078
1079/// The per-PE proof of a scalar ALU/select stage: the ROUTED grid proven equal to the body's own
1080/// datapath, for every 8-bit input, with the refutation kept.
1081pub fn scalar_certificate(body: &ScalarBody) -> ComputeStageCert {
1082    scalar_certificate_within(body, GATE_BUDGET_NODES)
1083}
1084
1085/// [`scalar_certificate`] against an EXPLICIT gate ceiling — the seam a lock shrinks to prove the
1086/// budget is live code and not a constant nobody reads.
1087pub fn scalar_certificate_within(body: &ScalarBody, gate_budget: u64) -> ComputeStageCert {
1088    let combinational = body
1089        .cells
1090        .iter()
1091        .all(|c| matches!(c, LoopCell::Arith { .. } | LoopCell::Select { .. }));
1092    if !combinational {
1093        return ComputeStageCert::Uncertified {
1094            reason: "a functional-unit or memory cell has no bit-blast lowering".into(),
1095        };
1096    }
1097    let Src::Val(_) = body.result else {
1098        return ComputeStageCert::Uncertified {
1099            reason: "the body's result is not a cell value".into(),
1100        };
1101    };
1102    // Every tile on the array has the same two routed operand channels (its north and its west).
1103    // A computed PREDICATE is an ordinary dataflow edge and takes one of them like any other
1104    // operand — but a mux whose comparison AND both arms read three different cells wants a third
1105    // channel the interconnect does not have. That is the one shape still declined, and it is
1106    // declined by name, from the cells, before the placer is asked to route it.
1107    if let Some((cell, demand)) = unroutable_cell(&body.cells) {
1108        return ComputeStageCert::Uncertified { reason: format!(
1109            "cell {cell} needs {demand} distinct computed values routed to one tile, and a tile has \
1110             {TILE_OPERAND_CHANNELS} operand channels (north and west) — a predicated select that \
1111             compares a computed value AND selects between two more has no placed form"
1112        ) };
1113    }
1114    let width = 8;
1115    // Both sides of the obligation are built STRAIGHT INTO THE SHARED GATE DAG. The tree form of
1116    // this same datapath is the cell DAG UNFOLDED (`fib.lg`: 75 cells → 1 657 763 `BoundedExpr`
1117    // nodes → a 10^14-node propositional formula, which is not a thing any machine holds); the
1118    // circuit is linear in the cells, and the Tseitin encoding of the circuit is linear in the
1119    // circuit. So the certificate is priced in GATES, and the tree is never built at all.
1120    //
1121    // The SPEC is built and priced FIRST because it is the cheap side: it costs one pass over the
1122    // cells, where the routed grid is a (2m+2)×m tile array. A body past the ceiling is therefore
1123    // refused without ever allocating the array that would place it.
1124    let Some(spec) = cells_to_gates(&body.cells, body.result, width) else {
1125        return ComputeStageCert::Uncertified {
1126            reason: "the body escaped the bounded fragment".into(),
1127        };
1128    };
1129    let size = word_size(&spec);
1130    if size.dag_nodes > gate_budget {
1131        let refusal = BlastRefusal::GateBudget {
1132            dag_nodes: size.dag_nodes,
1133            budget: gate_budget,
1134        };
1135        return ComputeStageCert::Uncertified {
1136            reason: refusal.reason(),
1137        };
1138    }
1139    let g = route_loop_body(&body.cells, body.n_in, &[body.result]);
1140    let Some(placed) = grid_output_to_gates(&g, 0, width, &|k| format!("in{k}")) else {
1141        return ComputeStageCert::Uncertified {
1142            reason: "the placed grid escaped the bounded fragment".into(),
1143        };
1144    };
1145    match certify_gates_within(&placed, &spec, gate_budget) {
1146        Ok(c) => ComputeStageCert::Sat(c),
1147        Err(r) => ComputeStageCert::Uncertified { reason: r.reason() },
1148    }
1149}
1150
1151/// How large a datapath tree the scalar certificate will pay to build.
1152///
1153/// The unit is `BoundedExpr` nodes in the UNFOLDED body spec — the cheapest quantity that is both
1154/// exact (it is what `cells_to_bounded` allocates) and knowable without allocating. Every ALU body
1155/// that certifies today prices in the tens (`mix` 12, one fib doubling block 29, two blocks 277),
1156/// so 50 000 is over two orders of magnitude of headroom; the six-block fib doubling chain prices
1157/// at 1 657 763 — refused before a single node is allocated, in microseconds. The second,
1158/// exact ceiling is [`crate::codegen_sva::bitblast::BLAST_BUDGET_NODES`] on the materialised
1159/// formula: this one stops the runaway, that one stops the merely-enormous.
1160pub const SCALAR_DATAPATH_BUDGET_NODES: u128 = 50_000;
1161
1162/// The `BoundedExpr` node count `cells_to_bounded` will materialise for `body`, computed from the
1163/// cell DAG alone — one pass, no allocation, saturating so an astronomical body is priced rather
1164/// than overflowing. Mirrors `cells_to_bounded`'s shapes exactly: an `Arith` cell is
1165/// `1 + |a| + |b|`, a `Select` is the comparison (`1 + |ca| + |cb|`) plus both arms plus the ite
1166/// node, and a `Val(k)` operand costs a full copy of cell `k`.
1167pub fn bounded_datapath_nodes(body: &ScalarBody) -> u128 {
1168    fn src(s: &Src, vals: &[u128]) -> u128 {
1169        match s {
1170            Src::In(_) | Src::Const(_) => 1,
1171            Src::Val(k) => vals.get(*k).copied().unwrap_or(1),
1172        }
1173    }
1174    let mut vals: Vec<u128> = Vec::with_capacity(body.cells.len());
1175    for c in &body.cells {
1176        let v = match c {
1177            LoopCell::Arith { a, b, .. } => src(a, &vals)
1178                .saturating_add(src(b, &vals))
1179                .saturating_add(1),
1180            LoopCell::Select { ca, cb, t, e, .. } => src(ca, &vals)
1181                .saturating_add(src(cb, &vals))
1182                .saturating_add(1)
1183                .saturating_add(src(t, &vals))
1184                .saturating_add(src(e, &vals))
1185                .saturating_add(1),
1186            // Not combinational — `scalar_certificate` has already declined by name above; price it
1187            // as a leaf so this function is total.
1188            _ => 1,
1189        };
1190        vals.push(v);
1191    }
1192    src(&body.result, &vals)
1193}
1194
1195/// The stages a certificate carries a compute certificate for: every tile matmul and every scalar body.
1196fn has_compute_certificate(s: &Stage) -> bool {
1197    matches!(
1198        s.kind,
1199        StageKind::Compute(ComputeOp::MatmulTiles { .. }) | StageKind::Scalar(_)
1200    )
1201}
1202
1203fn compute_certificates(p: &SpatialProgram) -> Vec<(StageId, ComputeStageCert)> {
1204    let mut by_origin: BTreeMap<String, ComputeStageCert> = BTreeMap::new();
1205    let mut out = Vec::new();
1206    for s in p.stages.iter().filter(|s| has_compute_certificate(s)) {
1207        let key_cert: Option<(String, Box<dyn Fn() -> ComputeStageCert>)> = match &s.kind {
1208            StageKind::Compute(ComputeOp::MatmulTiles { operand, .. }) => {
1209                let operand = *operand;
1210                Some((
1211                    format!("mm:{operand:?}"),
1212                    Box::new(move || {
1213                        if precision_is_int(operand) {
1214                            pe_certificate(
1215                                crate::spatial::target::precision_int_bits(operand).unwrap_or(64),
1216                            )
1217                        } else {
1218                            ComputeStageCert::Uncertified { reason: format!("float tensor datapath ({operand:?} operands, fp32 accumulate) cannot be bit-blasted; equivalence is by evaluation") }
1219                        }
1220                    }),
1221                ))
1222            }
1223            StageKind::Scalar(body) => {
1224                let body = body.clone();
1225                Some((
1226                    format!(
1227                        "scalar:{}",
1228                        serde_json::to_string(&body).unwrap_or_default()
1229                    ),
1230                    Box::new(move || scalar_certificate(&body)),
1231                ))
1232            }
1233            _ => None,
1234        };
1235        if let Some((key, make)) = key_cert {
1236            let cert = by_origin.entry(key).or_insert_with(|| make()).clone();
1237            out.push((s.id, cert));
1238        }
1239    }
1240    out
1241}
1242
1243/// Inputs for a dry run: deterministic fills where recorded, zeros for external data.
1244pub fn dry_inputs(p: &SpatialProgram) -> Inputs {
1245    let mut inputs = Inputs::default();
1246    for t in &p.tensors {
1247        if t.role == TensorRole::Output {
1248            continue;
1249        }
1250        let n = t.elems.rows * t.elems.cols;
1251        let vals: Vec<i64> = (0..n).map(|i| t.init.element(i).unwrap_or(0)).collect();
1252        inputs.tensors.insert(t.id, Value::Int(vals));
1253    }
1254    inputs
1255}
1256
1257/// ★ THE ORDERING OBLIGATIONS READ ONE INDEX OF THE PROGRAM, built in one pass: each stage's section, each tensor's
1258/// loads and stores, each stage's dataflow successors, and a prefix count of the barriers. The obligations ask their
1259/// questions — which sections load and store a tensor, whether a barrier stands between two sections, whether a store
1260/// depends on a load — per carried tensor and per pair of sections, and a batched tensor-parallel layer has thousands
1261/// of each; from the index every answer costs the size of what it asks about, not the size of the program.
1262struct OrderIndex<'a> {
1263    p: &'a SpatialProgram,
1264    /// The section of each stage (0 for a program without sections).
1265    sec_of: Vec<usize>,
1266    loads: BTreeMap<TensorId, Vec<StageId>>,
1267    stores: BTreeMap<TensorId, Vec<StageId>>,
1268    /// Each stage's dataflow successors.
1269    succ: Vec<Vec<StageId>>,
1270    /// `barriers_before[k]`: how many of sections `0..k` end in a barrier.
1271    barriers_before: Vec<usize>,
1272}
1273
1274impl<'a> OrderIndex<'a> {
1275    fn new(p: &'a SpatialProgram) -> Self {
1276        let mut sec_of = vec![0usize; p.stages.len()];
1277        for (k, sp) in p.sections.iter().enumerate() {
1278            for slot in sec_of
1279                .iter_mut()
1280                .take(sp.stages.1.min(p.stages.len()))
1281                .skip(sp.stages.0)
1282            {
1283                *slot = k;
1284            }
1285        }
1286        let (mut loads, mut stores): (
1287            BTreeMap<TensorId, Vec<StageId>>,
1288            BTreeMap<TensorId, Vec<StageId>>,
1289        ) = (BTreeMap::new(), BTreeMap::new());
1290        for s in &p.stages {
1291            match &s.kind {
1292                StageKind::Load { tensor, .. } => loads.entry(*tensor).or_default().push(s.id),
1293                StageKind::Store { tensor, .. } => stores.entry(*tensor).or_default().push(s.id),
1294                _ => {}
1295            }
1296        }
1297        let mut succ = vec![Vec::new(); p.stages.len()];
1298        for e in &p.edges {
1299            if let Some(out) = succ.get_mut(e.from.idx()) {
1300                out.extend(e.to.iter().copied());
1301            }
1302        }
1303        let mut barriers_before = Vec::with_capacity(p.sections.len() + 1);
1304        barriers_before.push(0);
1305        for sp in &p.sections {
1306            barriers_before.push(
1307                barriers_before.last().copied().unwrap_or(0)
1308                    + usize::from(sp.barrier_after.is_some()),
1309            );
1310        }
1311        OrderIndex {
1312            p,
1313            sec_of,
1314            loads,
1315            stores,
1316            succ,
1317            barriers_before,
1318        }
1319    }
1320
1321    /// The sections a tensor is loaded in, and the sections it is stored in.
1322    fn touch_sections(&self, t: TensorId) -> (Vec<usize>, Vec<usize>) {
1323        let secs = |stages: Option<&Vec<StageId>>| -> Vec<usize> {
1324            let mut v: Vec<usize> = stages
1325                .map(|s| s.iter().map(|id| self.sec_of[id.idx()]).collect())
1326                .unwrap_or_default();
1327            v.sort_unstable();
1328            v.dedup();
1329            v
1330        };
1331        (secs(self.loads.get(&t)), secs(self.stores.get(&t)))
1332    }
1333
1334    /// Whether some section in `[from, to)` ends in a barrier — the rendezvous that makes "section `from` happens
1335    /// before section `to`" a fact about the schedule rather than a convention. ONE suffices: `validate_rendezvous`
1336    /// places every op of a section after the last barrier before it and before the first barrier at or after it, so
1337    /// a barrier after section `b` orders every section up to `b` before every section after it. Sections with no
1338    /// barrier between them run concurrently, and must share no data.
1339    fn barriers_between(&self, from: usize, to: usize) -> bool {
1340        let at = |k: usize| {
1341            self.barriers_before
1342                .get(k.min(self.barriers_before.len() - 1))
1343                .copied()
1344                .unwrap_or(0)
1345        };
1346        from < to && at(to) > at(from)
1347    }
1348
1349    /// Whether `to` is DOWNSTREAM of `from` in the buffer dataflow — the order a section already has
1350    /// inside itself, because a stage cannot run before whatever fills its operands.
1351    ///
1352    /// ★ THE SAME-SECTION READ-THEN-WRITE IS REAL, NOT A LOOPHOLE. A conv ring is `CS' = shift(CS) ← x`:
1353    /// one section that reads the memory and writes it, and the read is ordered before the write by the
1354    /// FIFO between the load and the store — the same hand-off every operand in the IR travels on. Demanding
1355    /// a barrier there would force a pointless extra section; accepting it WITHOUT this check would let a
1356    /// store and a load of the same state sit side by side on two engines with nothing between them. So the
1357    /// exception is exactly "the write is downstream of the read", walked here rather than assumed.
1358    fn reaches(&self, from: StageId, to: StageId) -> bool {
1359        let mut seen = vec![false; self.p.stages.len()];
1360        let mut queue = vec![from];
1361        seen[from.idx()] = true;
1362        while let Some(s) = queue.pop() {
1363            if s == to {
1364                return true;
1365            }
1366            for t in &self.succ[s.idx()] {
1367                if !seen[t.idx()] {
1368                    seen[t.idx()] = true;
1369                    queue.push(*t);
1370                }
1371            }
1372        }
1373        false
1374    }
1375
1376    /// A section that reads and writes `t` must make every write DEPEND on a read — PER SITE.
1377    ///
1378    /// ★ THE DEPENDENCE IS PER SITE, AND THE ALL-PAIRS FORM WAS AN ARTIFACT. This once demanded that every
1379    /// `Load` of `t` reach every `Store` of `t`, which is right while a section has ONE work item and exactly
1380    /// one copy of each stage. `place` clones a section's stages per site, so a section with 16 items has 16
1381    /// loads and 16 stores on 16 different sites — and site 0's load can never reach site 1's store, because
1382    /// there is no edge between them and no reason there should be: they touch DISJOINT TILES. Under the old
1383    /// form any blocked row op that carried state was unsatisfiable by construction. (Measured: a 512-wide
1384    /// blocked map lowers to `items=16 loads=16 stores=16 sites=16`.)
1385    ///
1386    /// What the obligation actually protects is that a write is never unordered with respect to the read it
1387    /// supersedes — otherwise the step could clobber the state before reading it. That is a statement about
1388    /// one site's dataflow, so it is checked there: every store must be downstream of a load ON ITS OWN SITE.
1389    /// An unplaced section (no sites yet) keeps the whole-section form, which is the same thing when there is
1390    /// one copy.
1391    fn read_before_write_in_section(&self, t: TensorId, section: usize) -> bool {
1392        let in_section = |ids: Option<&Vec<StageId>>| -> Vec<&Stage> {
1393            ids.map(|v| {
1394                v.iter()
1395                    .filter(|id| self.sec_of[id.idx()] == section)
1396                    .map(|id| self.p.stage(*id))
1397                    .collect()
1398            })
1399            .unwrap_or_default()
1400        };
1401        let (loads, stores) = (
1402            in_section(self.loads.get(&t)),
1403            in_section(self.stores.get(&t)),
1404        );
1405        if loads.is_empty() || stores.is_empty() {
1406            return false;
1407        }
1408        // Every store is downstream of a load that shares its site (or, unplaced, of some load).
1409        stores.iter().all(|w| {
1410            loads
1411                .iter()
1412                .filter(|l| w.site.is_none() || l.site == w.site)
1413                .any(|l| self.reaches(l.id, w.id))
1414        })
1415    }
1416}
1417
1418/// ★ THE TWO OBLIGATIONS A COMPOSITION ADDS, AND WHY THEY ARE NOT THE LIVENESS PROOF.
1419///
1420/// Liveness says the choreography never wedges. It says nothing about WHICH section wrote the bytes a
1421/// later section reads: a program whose sections all ran in the wrong order is perfectly live. These
1422/// two say the data dependences the composer recorded are realized by barriers in the schedule.
1423fn ordering_obligations(p: &SpatialProgram) -> Result<(), CertifyFault> {
1424    let index = std::cell::OnceCell::new();
1425    let ix = || index.get_or_init(|| OrderIndex::new(p));
1426    for t in &p.tensors {
1427        match t.role {
1428            // ★ AN ACTIVATION MAY BE WRITTEN IN PARTS — a matrix a tile row at a time, a chunk's rows by the chunk's section
1429            // — each tile by ONE section (the program's coverage proof counts every tile's stores across all sections).
1430            // What a reader needs is the whole of it: every reader comes after EVERY writer, separated from each by a
1431            // barrier chain.
1432            TensorRole::Activation => {
1433                let (readers, writers) = ix().touch_sections(t.id);
1434                if writers.is_empty() {
1435                    return Err(CertifyFault::ActivationOrder {
1436                        tensor: t.id,
1437                        writer: None,
1438                        reader: 0,
1439                        reason: "no section writes it",
1440                    });
1441                }
1442                for &w in &writers {
1443                    for &r in &readers {
1444                        if r <= w {
1445                            return Err(CertifyFault::ActivationOrder { tensor: t.id, writer: Some(w), reader: r, reason: "a section reads the activation at or before a section that writes it" });
1446                        }
1447                        if !ix().barriers_between(w, r) {
1448                            return Err(CertifyFault::ActivationOrder { tensor: t.id, writer: Some(w), reader: r, reason: "no barrier chain separates a writing section from the reading one" });
1449                        }
1450                    }
1451                }
1452            }
1453            // ★ A CARRY IS WHAT THE STEP UPDATES IN PLACE. It must be written — a state nothing stores is an input —
1454            // and every access to it is ORDERED against every write: a read against each write (it sees the writes
1455            // before it and none after), and a write against each other write (the later one's value is the one
1456            // that stands). One writer is the common case — a cache append, a memory update; several are a table
1457            // written row by row, a stacked batch, each row by its own section — and the rule is the same pairwise
1458            // one. A state the step writes but does not read (a row appended to a cache the NEXT step reads) is a
1459            // carry all the same.
1460            TensorRole::State => {
1461                let (readers, writers) = ix().touch_sections(t.id);
1462                if writers.is_empty() {
1463                    return Err(CertifyFault::StateOrder {
1464                        tensor: t.id,
1465                        reader: readers.first().copied(),
1466                        writer: None,
1467                        reason: "a carried tensor the step never writes is not carried",
1468                    });
1469                }
1470                for &w in &writers {
1471                    for &r in &readers {
1472                        if r == w {
1473                            if !ix().read_before_write_in_section(t.id, r) {
1474                                return Err(CertifyFault::StateOrder {
1475                                    tensor: t.id,
1476                                    reader: Some(r),
1477                                    writer: Some(w),
1478                                    reason: "one section reads and writes the carried state with nothing ordering the two — the write must be downstream of the read in the section's own dataflow, or the halves must be separate sections",
1479                                });
1480                            }
1481                            continue;
1482                        }
1483                        let (a, b) = if r < w { (r, w) } else { (w, r) };
1484                        if !ix().barriers_between(a, b) {
1485                            return Err(CertifyFault::StateOrder { tensor: t.id, reader: Some(r), writer: Some(w), reason: "no barrier chain separates the reading section from the writing one" });
1486                        }
1487                    }
1488                    for &other in writers.iter().filter(|o| **o > w) {
1489                        if !ix().barriers_between(w, other) {
1490                            return Err(CertifyFault::StateOrder { tensor: t.id, reader: None, writer: Some(other), reason: "no barrier chain separates two sections that write the same carried state" });
1491                        }
1492                    }
1493                }
1494            }
1495            _ => {}
1496        }
1497    }
1498    shard_obligation(p)?;
1499    Ok(())
1500}
1501
1502/// ★ THE OBLIGATION TENSOR PARALLELISM ADDS.
1503///
1504/// A program is SHARDED when its sites do not all sit on one chip. A row-parallel chip holds a PARTIAL of the
1505/// contraction, and the partials are the answer only once summed. Unlike a missing barrier — which shows up as a
1506/// wedge or a stale read — a missing reduce produces a complete, correctly shaped, entirely plausible wrong number,
1507/// so nothing downstream can catch it.
1508///
1509/// ★ THE RULE IS ABOUT DATAFLOW, NOT ABOUT WHICH TENSOR IS THE OUTPUT. A tensor-parallel LAYER reduces its partials
1510/// in the middle and computes on after — the residual, the norm, the next layer — so "every output is an all-reduce's
1511/// tensor" is false of every correct layer. What is true of every correct program is this: a value derived from a
1512/// chip's own tensors (a `chip: Some` tensor — a weight slice, a slice of the carried state, a chip's activation)
1513/// reaches the REPLICATED spine (`chip: None`) only through an all-reduce that folds every operand it is handed and
1514/// draws them from EVERY chip of the program. So chip-local-ness flows forward through every stage and buffer; a
1515/// complete all-reduce is the one stage that ends it; and a chip-local value stored into a replicated tensor is an
1516/// unreduced shard. What the rule cannot follow — a multicast receive — counts as chip-local, so it refuses rather
1517/// than passes.
1518fn shard_obligation(p: &SpatialProgram) -> Result<(), CertifyFault> {
1519    use std::collections::BTreeSet;
1520    let chips: BTreeSet<u16> = p
1521        .stages
1522        .iter()
1523        .filter_map(|s| s.site.map(|x| x.chip))
1524        .collect();
1525    if chips.len() <= 1 {
1526        return Ok(());
1527    }
1528    // ★ PRESENCE IS NOT ENOUGH. A collective that is THERE but folds only some of its operands, or only some chips'
1529    // partials, is the same wrong answer as one that is missing — `Add(op0, op1)` over four partials returns half the
1530    // contraction, and nothing about the program's shape looks off.
1531    let complete = |s: &Stage| -> bool {
1532        let StageKind::AllReduce { combine, .. } = &s.kind else {
1533            return false;
1534        };
1535        let folds_all = match combine.as_ref() {
1536            ComputeOp::Generic { body, .. } => body.max_operand() == s.ins.len().checked_sub(1),
1537            _ => false,
1538        };
1539        let from: BTreeSet<u16> = s
1540            .ins
1541            .iter()
1542            .filter_map(|b| p.buffer(*b).site.map(|x| x.chip))
1543            .collect();
1544        folds_all && from == chips
1545    };
1546    let chip_local_tensor = |t: TensorId| p.tensor(t).chip.is_some();
1547    let mut local: BTreeSet<BufferId> = BTreeSet::new();
1548    loop {
1549        let before = local.len();
1550        for s in &p.stages {
1551            let derived = match &s.kind {
1552                StageKind::Load { tensor, .. } => {
1553                    chip_local_tensor(*tensor) || s.ins.iter().any(|b| local.contains(b))
1554                }
1555                StageKind::Recv { .. } => true,
1556                StageKind::AllReduce { .. } => !complete(s),
1557                StageKind::Store { .. } => false,
1558                _ => s.ins.iter().any(|b| local.contains(b)),
1559            };
1560            if derived {
1561                local.extend(s.outs.iter().copied());
1562            }
1563        }
1564        if local.len() == before {
1565            break;
1566        }
1567    }
1568    for s in &p.stages {
1569        let StageKind::Store { tensor, .. } = &s.kind else {
1570            continue;
1571        };
1572        if !chip_local_tensor(*tensor) && s.ins.first().is_some_and(|b| local.contains(b)) {
1573            return Err(CertifyFault::UnreducedShard {
1574                tensor: *tensor,
1575                chips: chips.len(),
1576            });
1577        }
1578    }
1579    Ok(())
1580}
1581
1582/// Build the certificate for a scheduled program, or refuse.
1583/// FNV-1a over a graph's serialization — what a per-section proof carries INSTEAD of the graph, so that
1584/// `recheck` still binds a witness to one exact graph without the certificate having to hold it.
1585fn graph_digest(g: &MarkedGraph) -> u64 {
1586    fnv_json(g)
1587}
1588
1589/// The places a single op puts a claim on. Only buffers, semaphores and multicast groups become places;
1590/// a TENSOR does not, which is why activations may cross a section boundary and these may not.
1591fn op_resources(op: &ChoreoOp, out: &mut Vec<(u8, usize)>) {
1592    const BUF: u8 = 0;
1593    const SEM: u8 = 1;
1594    const GRP: u8 = 2;
1595    match op {
1596        ChoreoOp::Reserve { buf, .. }
1597        | ChoreoOp::Push { buf, .. }
1598        | ChoreoOp::Wait { buf, .. }
1599        | ChoreoOp::Pop { buf, .. } => out.push((BUF, buf.idx())),
1600        ChoreoOp::ReadTiles { into, .. } => out.push((BUF, into.idx())),
1601        ChoreoOp::WriteTiles { from, .. } => out.push((BUF, from.idx())),
1602        ChoreoOp::McastSend { buf, group, .. } => {
1603            out.push((BUF, buf.idx()));
1604            out.push((GRP, group.idx()));
1605        }
1606        ChoreoOp::McastSetValid { sem, group } => {
1607            out.push((SEM, sem.idx()));
1608            out.push((GRP, group.idx()));
1609        }
1610        ChoreoOp::SemSet { sem, .. }
1611        | ChoreoOp::SemInc { sem, .. }
1612        | ChoreoOp::SemWait { sem, .. } => out.push((SEM, sem.idx())),
1613        ChoreoOp::Compute {
1614            ins, acc_reload, ..
1615        } => {
1616            for b in ins {
1617                out.push((BUF, b.idx()));
1618            }
1619            if let Some(b) = acc_reload {
1620                out.push((BUF, b.idx()));
1621            }
1622        }
1623        ChoreoOp::PackAcc { to, .. } => out.push((BUF, to.idx())),
1624        ChoreoOp::Barrier { group } => out.push((GRP, group.idx())),
1625        _ => {}
1626    }
1627}
1628
1629/// Does this choreography's liveness proof decompose into one proof per section?
1630///
1631/// The precondition `section_ranges` checks, exposed so a caller — and the lock that probes it — can
1632/// see which form a certificate will take and why, instead of inferring it from the certificate's size.
1633pub fn decomposes_by_section(p: &SpatialProgram) -> bool {
1634    section_ranges(p).is_some()
1635}
1636
1637/// The per-section slice of every engine program, or `None` when the choreography does not decompose.
1638///
1639/// Four things must hold, and every one is CHECKED rather than assumed — a wrong `Some` here would prove
1640/// a program live that deadlocks across a section boundary, which is the one failure this whole structure
1641/// exists to make impossible:
1642///   1. every stage belongs to exactly one section, and each top-level op's stages — loop bodies included
1643///      — all fall in the SAME one; an op straddling a boundary has no section to be proved in;
1644///   2. each program's ops run in non-decreasing section order, so a section is a contiguous slice;
1645///   3. no buffer, semaphore or multicast group is touched from two sections;
1646///   4. a receive buffer and the group that feeds it live in the same section, since the multicast token
1647///      is produced by the sender's `McastSend` and consumed by the receiver's `Push`.
1648///
1649/// Under (3) and (4) the only arcs the cut removes are program-counter arcs, which run forward in section
1650/// order and can close no cycle.
1651fn section_ranges(p: &SpatialProgram) -> Option<Vec<Selection>> {
1652    macro_rules! refuse {
1653        ($($a:tt)*) => {{
1654            if std::env::var_os("LOGOS_SECTION_DEBUG").is_some() {
1655                eprintln!("section_ranges refuses: {}", format!($($a)*));
1656            }
1657            return None;
1658        }};
1659    }
1660    let sched = p.schedule.as_ref()?;
1661    if p.sections.len() < 2 {
1662        refuse!("only {} section(s)", p.sections.len());
1663    }
1664    let mut sec_of_stage = vec![usize::MAX; p.stages.len()];
1665    for (si, sp) in p.sections.iter().enumerate() {
1666        let Some(slots) = sec_of_stage.get_mut(sp.stages.0..sp.stages.1.min(p.stages.len())) else {
1667            refuse!(
1668                "section {si} span {:?} is out of range of {} stages",
1669                sp.stages,
1670                p.stages.len()
1671            );
1672        };
1673        for slot in slots {
1674            if *slot != usize::MAX {
1675                refuse!("section {si} overlaps section {} on a stage", *slot);
1676            }
1677            *slot = si;
1678        }
1679    }
1680    let mut ranges: Vec<Selection> = vec![Vec::new(); p.sections.len()];
1681    let mut owner: BTreeMap<(u8, usize), usize> = BTreeMap::new();
1682    let mut res: Vec<(u8, usize)> = Vec::new();
1683    for (pi, prog) in sched.programs.iter().enumerate() {
1684        let mut cur: Option<(usize, usize)> = None;
1685        for (oi, t) in prog.ops.iter().enumerate() {
1686            let mut sec: Option<usize> = None;
1687            let mut straddles = false;
1688            res.clear();
1689            walk_tagged(std::slice::from_ref(t), 1, &mut |tt, _| {
1690                op_resources(&tt.op, &mut res);
1691                match sec_of_stage.get(tt.stage.idx()).copied() {
1692                    None | Some(usize::MAX) => straddles = true,
1693                    Some(x) => match sec {
1694                        None => sec = Some(x),
1695                        Some(a) if a != x => straddles = true,
1696                        Some(_) => {}
1697                    },
1698                }
1699            });
1700            let Some(sec) = sec else {
1701                refuse!("program {pi} op {oi} tags no stage")
1702            };
1703            if straddles {
1704                refuse!("program {pi} op {oi} straddles sections (or names a sectionless stage)");
1705            }
1706            for r in &res {
1707                match owner.entry(*r) {
1708                    std::collections::btree_map::Entry::Vacant(v) => {
1709                        v.insert(sec);
1710                    }
1711                    std::collections::btree_map::Entry::Occupied(o) => {
1712                        if *o.get() != sec {
1713                            refuse!("program {pi} op {oi}: resource {:?} is touched from sections {} and {sec}", r, *o.get());
1714                        }
1715                    }
1716                }
1717            }
1718            match cur {
1719                Some((s, _)) if s == sec => {}
1720                Some((s, st)) => {
1721                    if sec < s {
1722                        refuse!(
1723                            "program {pi} op {oi} (stage {}): section {sec} follows section {s}",
1724                            t.stage.idx()
1725                        );
1726                    }
1727                    ranges[s].push((pi, st..oi));
1728                    cur = Some((sec, oi));
1729                }
1730                None => cur = Some((sec, oi)),
1731            }
1732        }
1733        if let Some((s, st)) = cur {
1734            ranges[s].push((pi, st..prog.ops.len()));
1735        }
1736    }
1737    for st in &p.stages {
1738        if let (StageKind::Recv { group }, Some(o)) = (&st.kind, st.outs.first()) {
1739            match (owner.get(&(0, o.idx())), owner.get(&(2, group.idx()))) {
1740                (Some(a), Some(b)) if a != b => {
1741                    refuse!("recv buffer in section {a} fed by a group in section {b}")
1742                }
1743                _ => {}
1744            }
1745        }
1746    }
1747    Some(ranges)
1748}
1749
1750pub fn certify_choreography(p: &SpatialProgram) -> Result<ChoreographyCertificate, CertifyFault> {
1751    if p.schedule.is_none() {
1752        return Err(CertifyFault::NotScheduled);
1753    }
1754    p.validate().map_err(CertifyFault::Invalid)?;
1755    ordering_obligations(p)?;
1756    let proof = build_proof(p)?;
1757    let trace = eval_choreography_dry(p).map_err(CertifyFault::Eval)?;
1758    let balances = balances(p, &trace.high_water);
1759    for bl in &balances {
1760        if bl.high_water > bl.depth_pages || bl.reserved != bl.pushed || bl.pushed != bl.popped {
1761            return Err(CertifyFault::Bounds { buffer: bl.buf });
1762        }
1763    }
1764    Ok(ChoreographyCertificate {
1765        schedule_digest: schedule_digest(p),
1766        proof,
1767        balances,
1768        sem_counts: sem_counts(p),
1769        compute: compute_certificates(p),
1770        numerics: p.claim.clone(),
1771    })
1772}
1773
1774/// ★ THE TRIP WINDOW — A FEW COUNTS PROVE EVERY COUNT. A loop run to a trip unrolls to a different graph at each
1775/// count, and a decode fold at a long context admits thousands of them. The certificate proves the counts
1776/// `lo ..= min(hi, max(lo, s + 1))`, where `s` bounds how far, in rounds of the trip's loops, one engine can run ahead
1777/// of another it trades pages with — and that proves them all, by the shape the IR holds a trip loop to (a program
1778/// input for its count, no semaphore or multicast in its body, no trip loop in another, every buffer it touches
1779/// balanced per round — `validate_trips` and the per-round counting check):
1780///   • per-round balance means advancing EVERY engine of a coupled set by one round leaves every buffer's occupancy
1781///     where it was;
1782///   • so a stall with every engine of a set strictly inside its rounds cannot be the run's final state — the same
1783///     stall one round earlier is a smaller final state, and a marked graph has one;
1784///   • a stall with an engine still at the start of its rounds has every engine of its set within `s` rounds of
1785///     the start, and is the same stall at every count past `s + 1`: proved at `s + 1`, it is refused there;
1786///   • a stall with an engine past its rounds has every engine within `s` rounds of the end, and the same stall one
1787///     round shorter is a stall of the next count down — down to `s + 1`, which is proved.
1788/// `s` is the most any path of per-round page traffic lets one engine lead another: over each set of engines that
1789/// trade pages inside the trip's loops, the sum of each such buffer's depth, the pages it moves outside them, and one.
1790pub fn trip_window(p: &SpatialProgram, trip: TripId) -> std::ops::RangeInclusive<usize> {
1791    window_of(p.trip(trip), trip_spreads(p)[trip.idx()])
1792}
1793
1794/// The lead bound `s` of [`trip_window`] for every trip, in trip order, from ONE walk of the schedule. Engines are
1795/// COUPLED by a buffer one of them pushes and another pops inside a trip's loops — a page that moves every round; a
1796/// page the rounds only wait on moves once per item and lets no engine run a round ahead. Each coupled set's bound is
1797/// the sum over its buffers of depth, pages moved outside the loops, and one; a trip's `s` is its largest set's.
1798fn trip_spreads(p: &SpatialProgram) -> Vec<usize> {
1799    let mut out = vec![0usize; p.trips.len()];
1800    let Some(sched) = p.schedule.as_ref() else {
1801        return out;
1802    };
1803    let n = p.buffers.len();
1804    let (mut producer, mut consumer) = (vec![None; n], vec![None; n]);
1805    let mut outside = vec![0usize; n];
1806    let mut moved_in: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); n];
1807    struct Walk<'a> {
1808        prog: usize,
1809        producer: &'a mut [Option<usize>],
1810        consumer: &'a mut [Option<usize>],
1811        moved_in: &'a mut [BTreeSet<usize>],
1812        outside: &'a mut [usize],
1813    }
1814    fn walk(w: &mut Walk, ops: &[TaggedOp], trip: Option<usize>, mult: usize) {
1815        for t in ops {
1816            let (buf, pages, produces) = match &t.op {
1817                ChoreoOp::Loop { var, count, body } => {
1818                    match count.trip() {
1819                        Some(id) => walk(w, body, Some(id.idx()), mult),
1820                        None if *var == crate::spatial::passes::LV_ITEM => {
1821                            walk(w, body, trip, mult)
1822                        }
1823                        None => walk(w, body, trip, mult * count.bound()),
1824                    }
1825                    continue;
1826                }
1827                ChoreoOp::Push { buf, pages } => (*buf, *pages, true),
1828                ChoreoOp::Pop { buf, pages } => (*buf, *pages, false),
1829                _ => continue,
1830            };
1831            let i = buf.idx();
1832            if produces {
1833                w.producer[i] = Some(w.prog);
1834            } else {
1835                w.consumer[i] = Some(w.prog);
1836            }
1837            match trip {
1838                Some(id) => {
1839                    w.moved_in[i].insert(id);
1840                }
1841                None => w.outside[i] += mult * pages,
1842            }
1843        }
1844    }
1845    for (prog, q) in sched.programs.iter().enumerate() {
1846        let mut w = Walk {
1847            prog,
1848            producer: &mut producer,
1849            consumer: &mut consumer,
1850            moved_in: &mut moved_in,
1851            outside: &mut outside,
1852        };
1853        walk(&mut w, &q.ops, None, 1);
1854    }
1855    // Per trip, the coupling buffers and a union-find over just the engine programs they join.
1856    let mut coupling: Vec<Vec<(usize, usize, usize)>> = vec![Vec::new(); p.trips.len()];
1857    for i in 0..n {
1858        let (Some(a), Some(b)) = (producer[i], consumer[i]) else {
1859            continue;
1860        };
1861        if a == b {
1862            continue;
1863        }
1864        for &trip in &moved_in[i] {
1865            coupling[trip].push((a, b, p.buffers[i].kind.depth_pages() + outside[i] + 1));
1866        }
1867    }
1868    fn root(parent: &mut rustc_hash::FxHashMap<usize, usize>, mut x: usize) -> usize {
1869        while let Some(&up) = parent.get(&x).filter(|up| **up != x) {
1870            let grand = parent.get(&up).copied().unwrap_or(up);
1871            parent.insert(x, grand);
1872            x = up;
1873        }
1874        x
1875    }
1876    for (trip, edges) in coupling.iter().enumerate() {
1877        let mut parent: rustc_hash::FxHashMap<usize, usize> = rustc_hash::FxHashMap::default();
1878        for (a, b, _) in edges {
1879            let (ra, rb) = (root(&mut parent, *a), root(&mut parent, *b));
1880            if ra != rb {
1881                parent.insert(ra, rb);
1882            }
1883        }
1884        let mut per_set: rustc_hash::FxHashMap<usize, usize> = rustc_hash::FxHashMap::default();
1885        for (a, _, lead) in edges {
1886            *per_set.entry(root(&mut parent, *a)).or_default() += lead;
1887        }
1888        out[trip] = per_set.values().copied().max().unwrap_or(0);
1889    }
1890    out
1891}
1892
1893/// The window of a trip whose lead bound is `spread`.
1894fn window_of(t: &Trip, spread: usize) -> std::ops::RangeInclusive<usize> {
1895    t.lo()..=t.hi().min(t.lo().max(spread + 1))
1896}
1897
1898/// The trips the loops of a part of the schedule run to — `sel`'s ops, or every op — in id order.
1899fn part_trips(p: &SpatialProgram, sel: Option<&[(usize, std::ops::Range<usize>)]>) -> Vec<TripId> {
1900    let Some(sched) = p.schedule.as_ref() else {
1901        return Vec::new();
1902    };
1903    let mut found = std::collections::BTreeSet::new();
1904    let mut visit = |ops: &[TaggedOp]| {
1905        walk_tagged(ops, 1, &mut |t, _| {
1906            if let ChoreoOp::Loop { count, .. } = &t.op {
1907                found.extend(count.trip());
1908            }
1909        })
1910    };
1911    match sel {
1912        Some(r) => r
1913            .iter()
1914            .for_each(|(pi, ops)| visit(&sched.programs[*pi].ops[ops.clone()])),
1915        None => sched.programs.iter().for_each(|q| visit(&q.ops)),
1916    }
1917    found.into_iter().collect()
1918}
1919
1920/// The most cases one part's proof may hold: the windows of the trips it runs to, multiplied.
1921const TRIP_CASES_LIMIT: usize = 4096;
1922
1923/// Every count a part must be proved at: each trip it runs to at every count of its window, the rest at their
1924/// greatest — the part's counts (its trips in id order) beside the whole program's.
1925fn part_cases(
1926    p: &SpatialProgram,
1927    cx: &GraphContext,
1928    sel: Option<&[(usize, std::ops::Range<usize>)]>,
1929) -> Result<Vec<(Vec<usize>, Vec<usize>)>, CertifyFault> {
1930    let trips = part_trips(p, sel);
1931    if trips.is_empty() {
1932        return Ok(Vec::new());
1933    }
1934    let windows: Vec<Vec<usize>> = trips
1935        .iter()
1936        .map(|t| window_of(p.trip(*t), cx.spreads[t.idx()]).collect())
1937        .collect();
1938    let cases = windows
1939        .iter()
1940        .try_fold(1usize, |n, w| n.checked_mul(w.len()))
1941        .filter(|n| *n <= TRIP_CASES_LIMIT);
1942    let Some(cases) = cases else {
1943        return Err(CertifyFault::TripCasesExceed {
1944            limit: TRIP_CASES_LIMIT,
1945        });
1946    };
1947    let bound = p.bound_counts();
1948    Ok((0..cases)
1949        .map(|mut k| {
1950            let mut full = bound.clone();
1951            let mut own = vec![0; trips.len()];
1952            for (i, w) in windows.iter().enumerate().rev() {
1953                own[i] = w[k % w.len()];
1954                k /= w.len();
1955                full[trips[i].idx()] = own[i];
1956            }
1957            (own, full)
1958        })
1959        .collect())
1960}
1961
1962/// Prove a part at every count of its trips' windows, adding each witness to the table.
1963fn prove_cases(
1964    p: &SpatialProgram,
1965    cx: &GraphContext,
1966    sel: Option<&[(usize, std::ops::Range<usize>)]>,
1967    witnesses: &mut Vec<LivenessWitness>,
1968    index_of: &mut BTreeMap<Vec<usize>, usize>,
1969) -> Result<Vec<TripCase>, CertifyFault> {
1970    let mut out = Vec::new();
1971    for (counts, full) in part_cases(p, cx, sel)? {
1972        let g = build_graph_over(p, cx, sel, &full)?;
1973        let liveness = liveness_witness(&g)?;
1974        let witness = *index_of
1975            .entry(liveness.topo_order.clone())
1976            .or_insert_with(|| {
1977                witnesses.push(liveness);
1978                witnesses.len() - 1
1979            });
1980        out.push(TripCase {
1981            counts,
1982            places: g.places.len(),
1983            transitions: g.transitions.len(),
1984            digest: graph_digest(&g),
1985            witness,
1986        });
1987    }
1988    Ok(out)
1989}
1990
1991/// The carried cases of a part are exactly the counts its trips' windows require, in order, and each one's rebuilt
1992/// graph has its shape, its digest, and an order in the table that the graph verifies.
1993fn recheck_cases(
1994    p: &SpatialProgram,
1995    cx: &GraphContext,
1996    sel: Option<&[(usize, std::ops::Range<usize>)]>,
1997    cases: &[TripCase],
1998    witnesses: &[LivenessWitness],
1999) -> bool {
2000    let Ok(want) = part_cases(p, cx, sel) else {
2001        return false;
2002    };
2003    if want.len() != cases.len() {
2004        return false;
2005    }
2006    want.iter().zip(cases).all(|((counts, full), case)| {
2007        if *counts != case.counts {
2008            return false;
2009        }
2010        let Ok(g) = build_graph_over(p, cx, sel, full) else {
2011            return false;
2012        };
2013        let Some(w) = witnesses.get(case.witness) else {
2014            return false;
2015        };
2016        g.places.len() == case.places
2017            && g.transitions.len() == case.transitions
2018            && graph_digest(&g) == case.digest
2019            && verify_witness(&g, w)
2020    })
2021}
2022
2023/// Prove liveness in whichever form the choreography admits: one section at a time when it decomposes,
2024/// whole otherwise. Each section's graph is dropped the moment its witness exists — holding one section
2025/// instead of the sum of them is the entire point.
2026fn build_proof(p: &SpatialProgram) -> Result<GraphProof, CertifyFault> {
2027    let cx = GraphContext::of(p);
2028    let bound = p.bound_counts();
2029    let Some(ranges) = section_ranges(p) else {
2030        let graph = build_graph_over(p, &cx, None, &bound)?;
2031        let liveness = liveness_witness(&graph)?;
2032        let (mut case_witnesses, mut index_of) = (Vec::new(), BTreeMap::new());
2033        let cases = prove_cases(p, &cx, None, &mut case_witnesses, &mut index_of)?;
2034        return Ok(GraphProof::Whole {
2035            graph,
2036            liveness,
2037            cases,
2038            case_witnesses,
2039        });
2040    };
2041    let mut sections = Vec::with_capacity(ranges.len());
2042    let mut witnesses: Vec<LivenessWitness> = Vec::new();
2043    let mut index_of: BTreeMap<Vec<usize>, usize> = BTreeMap::new();
2044    let (mut places, mut transitions) = (0usize, 0usize);
2045    for (si, r) in ranges.iter().enumerate() {
2046        let g = build_graph_over(p, &cx, Some(r), &bound)?;
2047        let liveness = liveness_witness(&g)?;
2048        places += g.places.len();
2049        transitions += g.transitions.len();
2050        let witness = *index_of
2051            .entry(liveness.topo_order.clone())
2052            .or_insert_with(|| {
2053                witnesses.push(liveness);
2054                witnesses.len() - 1
2055            });
2056        let (digest, section_places, section_transitions) =
2057            (graph_digest(&g), g.places.len(), g.transitions.len());
2058        drop(g);
2059        let cases = prove_cases(p, &cx, Some(r), &mut witnesses, &mut index_of)?;
2060        sections.push(SectionProof {
2061            section: si,
2062            places: section_places,
2063            transitions: section_transitions,
2064            digest,
2065            witness,
2066            cases,
2067        });
2068    }
2069    Ok(GraphProof::PerSection {
2070        sections,
2071        witnesses,
2072        places,
2073        transitions,
2074    })
2075}
2076
2077impl ChoreographyCertificate {
2078    /// Independent re-check: the digest binds the certificate to `p`; the graph is rebuilt from `p`'s schedule
2079    /// and compared; the witness is verified against the token-free arcs; balances and semaphore counts are
2080    /// recomputed; every SAT certificate is re-run through the PR/RUP checker.
2081    pub fn recheck(&self, p: &SpatialProgram) -> bool {
2082        if p.schedule.is_none() || self.schedule_digest != schedule_digest(p) {
2083            return false;
2084        }
2085        if p.validate().is_err() || ordering_obligations(p).is_err() {
2086            return false;
2087        }
2088        if !self.recheck_proof(p) {
2089            return false;
2090        }
2091        let hw: BTreeMap<BufferId, usize> = self
2092            .balances
2093            .iter()
2094            .map(|b| (b.buf, b.high_water))
2095            .collect();
2096        if balances(p, &hw) != self.balances {
2097            return false;
2098        }
2099        if self
2100            .balances
2101            .iter()
2102            .any(|b| b.high_water > b.depth_pages || b.reserved != b.pushed || b.pushed != b.popped)
2103        {
2104            return false;
2105        }
2106        if sem_counts(p) != self.sem_counts {
2107            return false;
2108        }
2109        // ★ A CERTIFICATE STATES WHAT IT CERTIFIES. Its numerics claim is the program's, and its compute certificates
2110        // name exactly the program's compute stages, in order — a `Sat` one rechecked below, an `Uncertified` one a claim
2111        // of less than a derivation might make, never of more.
2112        if self.numerics != p.claim
2113            || self.compute.iter().map(|(s, _)| *s).ne(p
2114                .stages
2115                .iter()
2116                .filter(|s| has_compute_certificate(s))
2117                .map(|s| s.id))
2118        {
2119            return false;
2120        }
2121        for (_, c) in &self.compute {
2122            if let ComputeStageCert::Sat(pc) = c {
2123                if !pc.recheck() {
2124                    return false;
2125                }
2126            }
2127        }
2128        true
2129    }
2130
2131    /// Re-derive the liveness proof from `p` and check the carried one against it.
2132    ///
2133    /// The whole form is compared structurally, graph and all, exactly as it always was. The per-section
2134    /// form rebuilds each section's graph, matches its shape and its digest, and verifies that section's
2135    /// witness against it — the digest standing in for the graph comparison, and the witness check being
2136    /// the same Commoner argument restated once per section.
2137    fn recheck_proof(&self, p: &SpatialProgram) -> bool {
2138        match &self.proof {
2139            GraphProof::Whole {
2140                graph,
2141                liveness,
2142                cases,
2143                case_witnesses,
2144            } => {
2145                let Ok(rebuilt) = build_marked_graph(p) else {
2146                    return false;
2147                };
2148                rebuilt == *graph
2149                    && verify_witness(graph, liveness)
2150                    && recheck_cases(p, &GraphContext::of(p), None, cases, case_witnesses)
2151            }
2152            GraphProof::PerSection {
2153                sections,
2154                witnesses,
2155                places,
2156                transitions,
2157            } => {
2158                let Some(ranges) = section_ranges(p) else {
2159                    return false;
2160                };
2161                if ranges.len() != sections.len() {
2162                    return false;
2163                }
2164                let cx = GraphContext::of(p);
2165                let (mut tp, mut tt) = (0usize, 0usize);
2166                for (si, sp) in sections.iter().enumerate() {
2167                    if sp.section != si {
2168                        return false;
2169                    }
2170                    let Ok(g) = build_graph_over(p, &cx, Some(&ranges[si]), &p.bound_counts())
2171                    else {
2172                        return false;
2173                    };
2174                    if g.places.len() != sp.places || g.transitions.len() != sp.transitions {
2175                        return false;
2176                    }
2177                    let Some(w) = witnesses.get(sp.witness) else {
2178                        return false;
2179                    };
2180                    if graph_digest(&g) != sp.digest || !verify_witness(&g, w) {
2181                        return false;
2182                    }
2183                    if !recheck_cases(p, &cx, Some(&ranges[si]), &sp.cases, witnesses) {
2184                        return false;
2185                    }
2186                    tp += sp.places;
2187                    tt += sp.transitions;
2188                }
2189                tp == *places && tt == *transitions
2190            }
2191        }
2192    }
2193
2194    /// A tampered copy: the liveness witness permuted so it is no longer a topological order — must fail
2195    /// `recheck`. This is the probe that proves the witness is load-bearing rather than decorative, so it
2196    /// has to bite in BOTH proof forms.
2197    ///
2198    /// Whole form: swap one real producer/consumer pair, the most surgical break there is. Per-section
2199    /// form: reverse the first section long enough to have an arc — the graph is not carried there, so a
2200    /// pair cannot be picked out, and reversing violates EVERY token-free arc at once instead of one.
2201    #[must_use]
2202    pub fn with_witness_tampered(&self) -> Self {
2203        let mut c = self.clone();
2204        match &mut c.proof {
2205            GraphProof::Whole {
2206                graph, liveness, ..
2207            } => {
2208                let n = liveness.topo_order.len();
2209                if n >= 2 {
2210                    let g: &MarkedGraph = graph;
2211                    let mut swapped = false;
2212                    for t in 0..n {
2213                        for pl in &g.transitions[t].consumes {
2214                            if g.places[*pl].marking == 0 {
2215                                if let Some(pr) =
2216                                    g.transitions.iter().position(|x| x.produces.contains(pl))
2217                                {
2218                                    let (ip, ic) = (
2219                                        liveness.topo_order.iter().position(|x| *x == pr).unwrap(),
2220                                        liveness.topo_order.iter().position(|x| *x == t).unwrap(),
2221                                    );
2222                                    liveness.topo_order.swap(ip, ic);
2223                                    swapped = true;
2224                                    break;
2225                                }
2226                            }
2227                        }
2228                        if swapped {
2229                            break;
2230                        }
2231                    }
2232                    if !swapped {
2233                        liveness.topo_order.swap(0, 1);
2234                    }
2235                }
2236            }
2237            GraphProof::PerSection { witnesses, .. } => {
2238                if let Some(w) = witnesses.iter_mut().find(|w| w.topo_order.len() >= 2) {
2239                    w.topo_order.reverse();
2240                }
2241            }
2242        }
2243        c
2244    }
2245}
2246
2247/// Import-and-certify at the spatial level: an EXTERNAL placement+schedule of the SAME computation as
2248/// `source` earns a certificate iff it computes the same thing (structure and evaluation) and its
2249/// choreography certifies. `None` is fail-closed.
2250pub fn certify_external_spatial(
2251    external: &SpatialProgram,
2252    source: &SpatialProgram,
2253) -> Option<ChoreographyCertificate> {
2254    if external.tensors != source.tensors
2255        || external.work.k_tiles != source.work.k_tiles
2256        || external.numerics != source.numerics
2257    {
2258        return None;
2259    }
2260    let kinds = |p: &SpatialProgram| -> Vec<String> {
2261        let mut v: Vec<String> = p
2262            .stages
2263            .iter()
2264            .map(|s| match &s.kind {
2265                StageKind::Load { tensor, .. } | StageKind::Store { tensor, .. } => {
2266                    format!("{:?}:{}", std::mem::discriminant(&s.kind), tensor.idx())
2267                }
2268                StageKind::Recv { .. } => "load".into(),
2269                StageKind::AllReduce { pool, tensor, .. } => {
2270                    format!("allreduce:{pool:?}:{}", tensor.idx())
2271                }
2272                StageKind::Compute(op) => format!("{:?}", std::mem::discriminant(op)),
2273                StageKind::Scalar(b) => {
2274                    format!("scalar:{}", serde_json::to_string(b).unwrap_or_default())
2275                }
2276            })
2277            .collect();
2278        v.sort();
2279        v.dedup();
2280        v
2281    };
2282    if kinds(external) != kinds(source) {
2283        return None;
2284    }
2285    let inputs = dry_inputs(source);
2286    let (a, _) = eval_choreography_traced(external, &inputs).ok()?;
2287    let (b, _) = eval_choreography_traced(source, &inputs).ok()?;
2288    if a.tensors != b.tensors {
2289        return None;
2290    }
2291    let mut ext = external.clone();
2292    ext.certificate = None;
2293    certify_choreography(&ext).ok()
2294}
2295
2296/// A precision's role in a PE certificate (kept for lowerings that report it).
2297pub fn certifiable_operand(p: Precision) -> bool {
2298    precision_is_int(p)
2299}