Skip to main content

openvm_benchmark_synthetic/
synthetic_air.rs

1//! Synthetic AIR generator.
2//!
3//! Reads a shape atlas (produced by `scripts/analyze_profile.py` from a
4//! profile JSONL captured by the `SHADOW_BENCH_PROFILE_PATH` probe) and
5//! provides [`SyntheticAir`], a parametric AIR matching captured shape
6//! statistics: trace height, common-main width, num_constraints,
7//! max_constraint_degree, num_interactions, num_distinct_buses.
8//!
9//! ## Trick
10//!
11//! Column 0 of the trace is treated as a "kill column": it is filled
12//! with zeros. Every constraint multiplies by it (so the constraint is
13//! trivially zero everywhere), and every interaction uses it as the
14//! count column (so multiplicities are zero, making the interactions
15//! trivially balanced regardless of send/receive distribution). The
16//! prover still iterates the same number of trace cells and constraint
17//! / interaction terms as the real AIR, so kernel timing is preserved.
18//!
19//! v1 limitations: ignores preprocessed columns, cached_main partitions,
20//! and after-challenge widths. The captured profile distribution is
21//! dominated by AIRs with these set to small or zero values, so this is
22//! sufficient to start. Extend if validation shows drift.
23
24use std::path::Path;
25
26use openvm_stark_backend::{
27    interaction::{BusIndex, InteractionBuilder},
28    p3_air::{Air, BaseAir, BaseAirWithPublicValues},
29    p3_field::{Field, PrimeCharacteristicRing},
30    p3_matrix::{dense::RowMajorMatrix, Matrix},
31    PartitionedBaseAir,
32};
33use serde::{Deserialize, Serialize};
34
35/// One entry in a shape atlas — shape statistics for a single AIR
36/// observed in the captured profile, deduplicated and counted by
37/// `scripts/analyze_profile.py`.
38#[derive(Clone, Debug, Serialize, Deserialize)]
39pub struct SyntheticShape {
40    pub air_name: String,
41    pub log_height: usize,
42    pub preprocessed_width: usize,
43    pub cached_main_widths: Vec<usize>,
44    pub common_main_width: usize,
45    pub after_challenge_widths: Vec<usize>,
46    pub num_constraints: usize,
47    pub num_interactions: usize,
48    pub num_distinct_buses: usize,
49    pub max_constraint_degree: usize,
50    /// Per-interaction message length. Schema v2 atlases populate this;
51    /// older atlases leave it empty and a heuristic is used.
52    #[serde(default)]
53    pub interaction_message_lens: Vec<usize>,
54    /// Per-interaction `count_weight` (logup soundness param).
55    /// Schema v2 atlases populate this; older atlases leave it empty.
56    #[serde(default)]
57    pub interaction_count_weights: Vec<u32>,
58    pub occurrences: usize,
59}
60
61#[derive(Clone, Debug, Serialize, Deserialize)]
62pub struct ShapeAtlas {
63    pub source: String,
64    pub shapes: Vec<SyntheticShape>,
65}
66
67impl ShapeAtlas {
68    pub fn read(path: impl AsRef<Path>) -> std::io::Result<Self> {
69        let f = std::fs::File::open(path)?;
70        Ok(serde_json::from_reader(f)?)
71    }
72}
73
74/// A parametric AIR that matches the prover-observable shape of a
75/// captured AIR record. See module docs for the construction trick.
76#[derive(Clone, Debug)]
77pub struct SyntheticAir {
78    width: usize,
79    num_constraints: usize,
80    max_constraint_degree: usize,
81    /// `(bus_index, is_send, message_len, count_weight)` per interaction.
82    interactions: Vec<(BusIndex, bool, usize, u32)>,
83}
84
85impl SyntheticAir {
86    pub fn from_shape(s: &SyntheticShape) -> Self {
87        // Width must accommodate (a) the kill column (col 0) and (b)
88        // the largest captured interaction message — every field in a
89        // message references a real column. If common_main_width is too
90        // narrow (notably width=1 AIRs like ProgramAir whose 9 columns
91        // live in cached_mains, which v1 ignores), widen to fit the
92        // largest message + the kill column. Otherwise message lengths
93        // would silently clamp to 0 and the prover's per-interaction
94        // work would diverge from the captured shape.
95        let max_captured_msg_len = s
96            .interaction_message_lens
97            .iter()
98            .copied()
99            .max()
100            .unwrap_or(0);
101        let width = s.common_main_width.max(max_captured_msg_len + 1).max(1);
102        let num_buses = s.num_distinct_buses.max(1);
103        // Each captured length is clamped to (width - 1). For v1 atlases
104        // (empty lens), fall back to min(width - 1, 4) — a typical real
105        // value, comfortably below the keygen's 128-element message cap.
106        let max_field_count = width - 1;
107        let fallback_len = max_field_count.min(4);
108        let interactions = (0..s.num_interactions)
109            .map(|i| {
110                let bus = (i % num_buses) as BusIndex;
111                let is_send = i % 2 == 0;
112                let captured_len = s
113                    .interaction_message_lens
114                    .get(i)
115                    .copied()
116                    .unwrap_or(fallback_len);
117                let msg_len = captured_len.min(max_field_count);
118                let cw = s.interaction_count_weights.get(i).copied().unwrap_or(0);
119                (bus, is_send, msg_len, cw)
120            })
121            .collect();
122        Self {
123            width,
124            num_constraints: s.num_constraints,
125            max_constraint_degree: s.max_constraint_degree.max(1),
126            interactions,
127        }
128    }
129
130    pub fn width(&self) -> usize {
131        self.width
132    }
133    pub fn num_constraints(&self) -> usize {
134        self.num_constraints
135    }
136    pub fn num_interactions(&self) -> usize {
137        self.interactions.len()
138    }
139
140    /// Generate an all-zeros trace at the given log_height. Width matches
141    /// `self.width()`. Trace is trivially valid (every constraint and
142    /// interaction count multiplies by column 0 = 0).
143    pub fn generate_trace<F: Field>(&self, log_height: usize) -> RowMajorMatrix<F> {
144        let h = 1usize << log_height;
145        RowMajorMatrix::new(vec![F::ZERO; h * self.width], self.width)
146    }
147}
148
149impl<F: Field> BaseAir<F> for SyntheticAir {
150    fn width(&self) -> usize {
151        self.width
152    }
153}
154impl<F: Field> BaseAirWithPublicValues<F> for SyntheticAir {}
155impl<F: Field> PartitionedBaseAir<F> for SyntheticAir {}
156
157impl<AB: InteractionBuilder> Air<AB> for SyntheticAir {
158    fn eval(&self, builder: &mut AB) {
159        let main = builder.main();
160        let local = main.row_slice(0).expect("trace has at least one row");
161        let next = main.row_slice(1).expect("trace has at least two rows");
162
163        // Build `num_constraints` STRUCTURALLY DISTINCT degree-D
164        // monomials over the column variables. Distinctness matters
165        // because `build_symbolic_constraints_dag` dedupes DAG nodes
166        // (dag.rs constraint_idx.dedup()), so duplicate symbolic
167        // expressions would collapse and the keygen-reported constraint
168        // count would underrun the captured shape's `num_constraints`.
169        //
170        // Encoding: the constraint index `c` is interpreted as a
171        // base-(2*width) integer of length max_constraint_degree. Each
172        // digit picks a column slot from the union of `local[0..width]`
173        // and `next[0..width]`, giving (2*width)^max_constraint_degree
174        // distinct monomials. For width >= 2 and degree >= 2 this
175        // comfortably exceeds any captured `num_constraints` (max
176        // observed in captured profiles: ~25 with much larger widths).
177        //
178        // Trace correctness: `generate_trace` fills every cell with 0,
179        // so any polynomial in the column variables evaluates to 0
180        // regardless of which columns the constraint references. Kernel
181        // workload (cells touched, monomial degree) still matches the
182        // captured shape.
183        let degree = self.max_constraint_degree;
184        let slot_count = 2 * self.width;
185        for c in 0..self.num_constraints {
186            let mut idx = c;
187            let mut term: Option<AB::Expr> = None;
188            for _ in 0..degree {
189                let slot = idx % slot_count;
190                idx /= slot_count;
191                let col = if slot < self.width {
192                    local[slot]
193                } else {
194                    next[slot - self.width]
195                };
196                term = Some(match term {
197                    None => col.into(),
198                    Some(t) => t * col.into(),
199                });
200            }
201            let term = term.unwrap_or_else(|| local[0].into());
202            builder.assert_zero(term);
203        }
204
205        // Interactions: each one reads `field_count` columns (all >=
206        // 1 in the captured data when width > 1) plus the count column
207        // (always col 0). Trace is all-zero so multiplicities are zero,
208        // making interactions trivially balanced regardless of how many
209        // are sends vs receives. Bus indices are renumbered to
210        // `0..num_distinct_buses` — actual identity doesn't affect the
211        // prover's per-bus batching cost.
212        for (bus, is_send, msg_len, count_weight) in &self.interactions {
213            let count = local[0];
214            let fields: Vec<AB::Expr> = (0..*msg_len)
215                .map(|i| local[(i + 1) % self.width].into())
216                .collect();
217            if *is_send {
218                builder.push_interaction(*bus, fields, count, *count_weight);
219            } else {
220                builder.push_interaction(
221                    *bus,
222                    fields,
223                    AB::Expr::NEG_ONE * count.into(),
224                    *count_weight,
225                );
226            }
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn shape_for_test() -> SyntheticShape {
236        // Arbitrary but representative numbers — not from a real
237        // capture; just exercises the constructor + AIR impl.
238        SyntheticShape {
239            air_name: "TestSynthetic".into(),
240            log_height: 4,
241            preprocessed_width: 0,
242            cached_main_widths: vec![],
243            common_main_width: 6,
244            after_challenge_widths: vec![],
245            num_constraints: 7,
246            num_interactions: 3,
247            num_distinct_buses: 2,
248            max_constraint_degree: 3,
249            interaction_message_lens: vec![3, 3, 3],
250            interaction_count_weights: vec![0, 0, 0],
251            occurrences: 1,
252        }
253    }
254
255    #[test]
256    fn synthetic_air_metadata_matches_shape() {
257        let s = shape_for_test();
258        let air = SyntheticAir::from_shape(&s);
259        assert_eq!(air.width(), s.common_main_width);
260        assert_eq!(air.num_constraints(), s.num_constraints);
261        assert_eq!(air.num_interactions(), s.num_interactions);
262    }
263
264    #[test]
265    fn width_widens_to_fit_captured_message_len() {
266        // Mirrors the ProgramAir-shaped records in the bundled profile:
267        // common_main_width=1 with a captured 9-field interaction. The
268        // synthetic AIR must widen to width=10 (kill col + 9 fields) so
269        // the interaction's per-field kernel cost survives.
270        let s = SyntheticShape {
271            air_name: "WidthOneWithMsg".into(),
272            log_height: 4,
273            preprocessed_width: 0,
274            cached_main_widths: vec![],
275            common_main_width: 1,
276            after_challenge_widths: vec![],
277            num_constraints: 0,
278            num_interactions: 1,
279            num_distinct_buses: 1,
280            max_constraint_degree: 1,
281            interaction_message_lens: vec![9],
282            interaction_count_weights: vec![0],
283            occurrences: 1,
284        };
285        let air = SyntheticAir::from_shape(&s);
286        assert_eq!(air.width(), 10, "kill col + 9 message fields");
287    }
288
289    #[test]
290    fn synthetic_air_keygen_prove_verify_round_trip() {
291        use std::sync::Arc;
292
293        use eyre::eyre;
294        use openvm_stark_backend::{
295            prover::{AirProvingContext, DeviceDataTransporter, ProvingContext},
296            StarkEngine,
297        };
298        use openvm_stark_sdk::config::{
299            app_params_with_100_bits_security, baby_bear_poseidon2::BabyBearPoseidon2CpuEngine,
300        };
301        use p3_baby_bear::BabyBear;
302
303        let s = shape_for_test();
304        let air = SyntheticAir::from_shape(&s);
305        let trace = air.generate_trace::<BabyBear>(s.log_height);
306
307        // Stacked height must be >= log_height of any AIR; use a
308        // value comfortably larger than the test shape's log_height.
309        let params = app_params_with_100_bits_security(15);
310        let engine: BabyBearPoseidon2CpuEngine = StarkEngine::new(params);
311
312        let air_arc = Arc::new(air);
313        let (pk, vk) = engine.keygen(&[air_arc]);
314
315        let inner_pk = &pk.per_air[0];
316        assert_eq!(
317            inner_pk
318                .vk
319                .symbolic_constraints
320                .constraints
321                .constraint_idx
322                .len(),
323            s.num_constraints,
324            "keygen-reported num_constraints must match the shape's num_constraints",
325        );
326        assert_eq!(
327            inner_pk.vk.symbolic_constraints.interactions.len(),
328            s.num_interactions,
329            "keygen-reported num_interactions must match the shape's num_interactions",
330        );
331
332        let trace_ctx = AirProvingContext::simple_no_pis(trace);
333        let d_pk = engine.device().transport_pk_to_device(&pk);
334        let proof = engine
335            .prove(&d_pk, ProvingContext::new(vec![(0, trace_ctx)]))
336            .map_err(|e| eyre!("Proving failed: {e:?}"))
337            .unwrap();
338        engine
339            .verify(&vk, &proof)
340            .expect("synthetic proof verifies");
341    }
342}