synth_core/provenance.rs
1//! VCR-DEC-003 (#396, witness#130) — the `synth-provenance-v1` branch-transformation map.
2//!
3//! synth's lowering changes the branch structure between the WASM source and the
4//! ARM object: cmp→select fusion collapses a `select` into a predicated IT-block
5//! move (no branch), `br_table` splits one WASM branch into a cascade of object
6//! branches, constant-condition guards get elided. witness measures MC/DC on the
7//! WASM component; to certify the OBJECT it must reconcile each object-level
8//! branch/condition back to its source condition. This module emits the map that
9//! makes that reconciliation possible.
10//!
11//! ## Schema (`synth-provenance-v1`)
12//!
13//! A JSON sidecar. The witness-facing join key is `(func_index,
14//! instruction_offset)` where `instruction_offset` is the ABSOLUTE wasm byte
15//! offset of the source op (synth's `op_offsets`, same origin as walrus
16//! `InstrLocId` — see VCR-DEC-003). Each entry additionally carries the OBJECT
17//! realization (`object_pcs`) so a consumer can map a source condition to the
18//! machine code it became — the piece the roadmap's terse schema left implicit
19//! but the reconciliation gate needs.
20//!
21//! `kind ∈`
22//! - `preserved` — a 1:1 `br_if`/`br` that stayed a real object branch.
23//! - `folded-predication` — a `select` fused to predicated (IT-block) moves; a
24//! decision with NO object branch. `object_pcs` point at the predicated moves.
25//! - `split-into-object-branches` — a `br_table` that became N object branches;
26//! `count` = N.
27//! - `eliminated-constant` — a source branch/condition dropped before codegen
28//! (constant-fold / fact-spec guard elision). `object_pcs` is empty; the
29//! omission is RECORDED, not silently missing.
30//!
31//! ## What the map lets a consumer prove (the non-vacuous gate)
32//!
33//! (a) every object-level conditional branch resolves to a source WASM condition
34//! — carried in `object_cond_branches`, derived from the real object-branch
35//! side-table ([`crate::backend::BranchMap`]), NOT re-walked from the wasm
36//! branch ops (which would be vacuous);
37//! (b) a folded/eliminated source condition is explicitly recorded with its
38//! object realization (or its absence), NOT dropped.
39//!
40//! ## Bounded v1 — covered vs uncovered
41//!
42//! GATE-EXERCISED: `br_if`, `br` (preserved), `select` (folded-predication),
43//! `br_table` (split). `eliminated-constant` is WIRED (schema + emitter, correct
44//! byte-offset join key) but not yet gate-exercised — a fixture that drops a
45//! covered branch op is a v1 follow-up. Every object conditional branch that does
46//! NOT resolve to one of the covered source ops is surfaced in
47//! `object_cond_branches` with `resolved: false` and a `note` (e.g. an `i64`
48//! expansion branch or a div/mem trap guard) — an "only-in-synth"-style
49//! divergence the consumer SEES rather than a silent gap. Naming the uncovered
50//! ones is the deliverable's explicit follow-up boundary.
51
52use serde::{Deserialize, Serialize};
53
54use crate::backend::{BranchClass, BranchMap, LineMap};
55use crate::wasm_op::WasmOp;
56
57/// The schema version string embedded at the top of the sidecar.
58pub const SCHEMA: &str = "synth-provenance-v1";
59
60/// The transformation a source branch/condition underwent on the way to object code.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "kebab-case")]
63pub enum ProvKind {
64 /// 1:1 `br_if`/`br` that stayed a real object branch.
65 Preserved,
66 /// `select` fused to predicated moves (no object branch).
67 FoldedPredication,
68 /// `br_table` split into N object branches (`count` = N).
69 SplitIntoObjectBranches,
70 /// Source branch/condition dropped before codegen (constant / fact-spec).
71 EliminatedConstant,
72}
73
74/// One source-level branch/condition and what it became in the object.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct ProvEntry {
77 /// Witness join key: ABSOLUTE wasm byte offset of the source op.
78 pub instruction_offset: u32,
79 /// Index of the source op within the (compiled) op stream — diagnostic.
80 pub wasm_op_index: usize,
81 /// The source WASM op mnemonic (e.g. `"BrIf"`, `"Select"`, `"BrTable"`).
82 pub op: String,
83 /// How synth transformed it.
84 pub kind: ProvKind,
85 /// Object PCs (function-relative machine offsets) that realize this source
86 /// op's control flow. Empty for `eliminated-constant`.
87 pub object_pcs: Vec<u32>,
88 /// For `split-into-object-branches`: the object-branch count. Omitted otherwise.
89 #[serde(skip_serializing_if = "Option::is_none")]
90 pub count: Option<usize>,
91 /// Optional scry#51 reachability evidence for an `eliminated-constant` entry
92 /// (justified-infeasible). Reserved for a later increment; `None` in v1.
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub scry_evidence: Option<String>,
95}
96
97/// One object-level conditional branch, and whether it reconciled to a covered
98/// source condition. This is the (a)-clause carrier: derived from the REAL
99/// object-branch side-table, so a branch synth emitted that no covered source op
100/// explains shows up here with `resolved: false` (surfaced, not hidden).
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct ObjectCondBranch {
103 /// Function-relative machine offset of the conditional branch.
104 pub pc: u32,
105 /// The wasm op index this branch traces back to (via `line_map`), if any.
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub wasm_op_index: Option<usize>,
108 /// Absolute wasm byte offset of that source op, if resolvable.
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub instruction_offset: Option<u32>,
111 /// True iff this branch resolves to a covered source condition (`br_if` /
112 /// `br_table`). False = an uncovered/only-in-synth object branch (e.g. an
113 /// i64-expansion or trap-guard branch) — a v1 follow-up, surfaced not hidden.
114 pub resolved: bool,
115 /// Human note when `resolved` is false.
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pub note: Option<String>,
118}
119
120/// Provenance for one compiled function.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct FunctionProvenance {
123 pub func_index: u32,
124 pub name: String,
125 pub entries: Vec<ProvEntry>,
126 pub object_cond_branches: Vec<ObjectCondBranch>,
127}
128
129/// The whole-module `synth-provenance-v1` map.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct ProvenanceMap {
132 pub schema: String,
133 pub module: String,
134 pub functions: Vec<FunctionProvenance>,
135}
136
137impl ProvenanceMap {
138 pub fn new(module: impl Into<String>) -> Self {
139 ProvenanceMap {
140 schema: SCHEMA.to_string(),
141 module: module.into(),
142 functions: Vec::new(),
143 }
144 }
145
146 /// Serialize to pretty JSON.
147 pub fn to_json(&self) -> String {
148 serde_json::to_string_pretty(self).expect("ProvenanceMap serializes")
149 }
150}
151
152/// Is this a source op the map covers as a branch/condition? Returns its
153/// mnemonic if so. Public so the CLI can classify eliminated (fact-spec-dropped)
154/// ops with the SAME coverage predicate the emitter uses.
155pub fn covered_source_op_name(op: &WasmOp) -> Option<&'static str> {
156 source_op_name(op)
157}
158
159/// Is this a source op the map covers as a branch/condition?
160fn source_op_name(op: &WasmOp) -> Option<&'static str> {
161 match op {
162 WasmOp::BrIf(_) => Some("BrIf"),
163 WasmOp::Br(_) => Some("Br"),
164 WasmOp::BrTable { .. } => Some("BrTable"),
165 WasmOp::Select => Some("Select"),
166 _ => None,
167 }
168}
169
170/// Derive provenance for one function from the CLI-available data.
171///
172/// - `ops` / `op_offsets` are index-aligned (the stream the backend compiled and
173/// its per-op absolute wasm byte offsets, already fact-spec-filtered upstream).
174/// - `line_map` / `branch_map` are index-aligned (one entry per emitted machine
175/// instruction: `(pc, wasm_op_index)` and `(pc, class)`).
176/// - `eliminated`: `(wasm_op_index_in_original_stream, op_name,
177/// absolute_wasm_byte_offset)` for branch/condition ops that constant-folding
178/// / fact-spec dropped before codegen. The offset is the ORIGINAL-stream byte
179/// offset (the witness join key), NOT derivable from `op_offsets` here (which
180/// is the filtered/kept table) — the caller looks it up in the unfiltered
181/// side-table.
182pub fn derive_function_provenance(
183 func_index: u32,
184 name: &str,
185 ops: &[WasmOp],
186 op_offsets: &[u32],
187 line_map: &LineMap,
188 branch_map: &BranchMap,
189 eliminated: &[(usize, String, u32)],
190) -> FunctionProvenance {
191 // For each op index, collect the object PCs whose branch_map class matters.
192 // line_map and branch_map are parallel; zip them.
193 let mut entries: Vec<ProvEntry> = Vec::new();
194
195 for (op_idx, op) in ops.iter().enumerate() {
196 let Some(op_name) = source_op_name(op) else {
197 continue;
198 };
199 let instruction_offset = op_offsets.get(op_idx).copied().unwrap_or(0);
200
201 // Object realizations of this op: the machine instructions whose
202 // line_map op-index == op_idx AND whose branch class is a branch or a
203 // predicated move (skip the data-processing setup instructions).
204 let mut cond_pcs = Vec::new();
205 let mut uncond_pcs = Vec::new();
206 let mut pred_pcs = Vec::new();
207 for ((pc, oi), (_pc2, class)) in line_map.iter().zip(branch_map.iter()) {
208 if *oi != Some(op_idx) {
209 continue;
210 }
211 match class {
212 BranchClass::CondBranch => cond_pcs.push(*pc),
213 BranchClass::UncondBranch => uncond_pcs.push(*pc),
214 BranchClass::Predicated => pred_pcs.push(*pc),
215 BranchClass::Other => {}
216 }
217 }
218
219 let (kind, object_pcs, count) = match op {
220 WasmOp::BrIf(_) => (ProvKind::Preserved, cond_pcs.clone(), None),
221 WasmOp::Br(_) => (ProvKind::Preserved, uncond_pcs.clone(), None),
222 WasmOp::BrTable { .. } => {
223 let n = cond_pcs.len();
224 let mut all = cond_pcs.clone();
225 all.extend(uncond_pcs.iter().copied());
226 (ProvKind::SplitIntoObjectBranches, all, Some(n))
227 }
228 WasmOp::Select => (ProvKind::FoldedPredication, pred_pcs.clone(), None),
229 _ => unreachable!("source_op_name gated the match"),
230 };
231
232 entries.push(ProvEntry {
233 instruction_offset,
234 wasm_op_index: op_idx,
235 op: op_name.to_string(),
236 kind,
237 object_pcs,
238 count,
239 scry_evidence: None,
240 });
241 }
242
243 // Eliminated-constant entries: branch/condition ops dropped before codegen.
244 for (orig_idx, op_name, byte_offset) in eliminated {
245 entries.push(ProvEntry {
246 instruction_offset: *byte_offset,
247 wasm_op_index: *orig_idx,
248 op: op_name.clone(),
249 kind: ProvKind::EliminatedConstant,
250 object_pcs: Vec::new(),
251 count: None,
252 scry_evidence: None,
253 });
254 }
255
256 // (a)-clause carrier: enumerate the REAL object conditional branches and
257 // reconcile each back to its source op via line_map. A branch that traces to
258 // a covered condition (BrIf / BrTable) is resolved; anything else is an
259 // uncovered/only-in-synth branch, surfaced with a note.
260 let mut object_cond_branches: Vec<ObjectCondBranch> = Vec::new();
261 for ((pc, oi), (_pc2, class)) in line_map.iter().zip(branch_map.iter()) {
262 if *class != BranchClass::CondBranch {
263 continue;
264 }
265 let (resolved, note, instruction_offset) = match oi {
266 Some(idx) => match ops.get(*idx) {
267 Some(WasmOp::BrIf(_)) | Some(WasmOp::BrTable { .. }) => {
268 (true, None, op_offsets.get(*idx).copied())
269 }
270 Some(other) => (
271 false,
272 Some(format!(
273 "object conditional branch from non-branch source op {other:?} \
274 (uncovered in v1: i64-expansion / trap-guard / bounds-check branch)"
275 )),
276 op_offsets.get(*idx).copied(),
277 ),
278 None => (
279 false,
280 Some(
281 "object conditional branch traces to an out-of-range op index".to_string(),
282 ),
283 None,
284 ),
285 },
286 None => (
287 false,
288 Some(
289 "object conditional branch with no source op (prologue/epilogue synth branch)"
290 .to_string(),
291 ),
292 None,
293 ),
294 };
295 object_cond_branches.push(ObjectCondBranch {
296 pc: *pc,
297 wasm_op_index: *oi,
298 instruction_offset,
299 resolved,
300 note,
301 });
302 }
303
304 FunctionProvenance {
305 func_index,
306 name: name.to_string(),
307 entries,
308 object_cond_branches,
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn preserved_brif_and_folded_select() {
318 // op[0] BrIf, op[1] Select.
319 let ops = vec![WasmOp::BrIf(0), WasmOp::Select];
320 let op_offsets = vec![10u32, 20u32];
321 // Object: BrIf → cmp(other)@0x00 + bcc(cond)@0x04; Select → predicated mov@0x08.
322 let line_map: LineMap = vec![(0x00, Some(0)), (0x04, Some(0)), (0x08, Some(1))];
323 let branch_map: BranchMap = vec![
324 (0x00, BranchClass::Other),
325 (0x04, BranchClass::CondBranch),
326 (0x08, BranchClass::Predicated),
327 ];
328 let fp = derive_function_provenance(0, "f", &ops, &op_offsets, &line_map, &branch_map, &[]);
329
330 let brif = &fp.entries[0];
331 assert_eq!(brif.kind, ProvKind::Preserved);
332 assert_eq!(brif.object_pcs, vec![0x04]);
333 assert_eq!(brif.instruction_offset, 10);
334
335 let sel = &fp.entries[1];
336 assert_eq!(sel.kind, ProvKind::FoldedPredication);
337 assert_eq!(sel.object_pcs, vec![0x08]);
338
339 // (a): the one object cond branch resolves to the BrIf.
340 assert_eq!(fp.object_cond_branches.len(), 1);
341 assert!(fp.object_cond_branches[0].resolved);
342 assert_eq!(fp.object_cond_branches[0].instruction_offset, Some(10));
343 }
344
345 #[test]
346 fn unresolved_object_branch_is_surfaced_not_hidden() {
347 // An I32DivU whose object lowering emits a trap-guard conditional branch.
348 let ops = vec![WasmOp::I32DivU];
349 let op_offsets = vec![30u32];
350 let line_map: LineMap = vec![(0x00, Some(0))];
351 let branch_map: BranchMap = vec![(0x00, BranchClass::CondBranch)];
352 let fp = derive_function_provenance(0, "g", &ops, &op_offsets, &line_map, &branch_map, &[]);
353 // No covered source entries (I32DivU isn't a covered source branch)...
354 assert!(fp.entries.is_empty());
355 // ...but the object branch is NOT missing: it's surfaced unresolved.
356 assert_eq!(fp.object_cond_branches.len(), 1);
357 assert!(!fp.object_cond_branches[0].resolved);
358 assert!(fp.object_cond_branches[0].note.is_some());
359 }
360
361 #[test]
362 fn eliminated_constant_is_recorded() {
363 let ops: Vec<WasmOp> = vec![];
364 let op_offsets: Vec<u32> = vec![];
365 let line_map: LineMap = vec![];
366 let branch_map: BranchMap = vec![];
367 let fp = derive_function_provenance(
368 0,
369 "h",
370 &ops,
371 &op_offsets,
372 &line_map,
373 &branch_map,
374 &[(3, "BrIf".to_string(), 42)],
375 );
376 assert_eq!(fp.entries.len(), 1);
377 assert_eq!(fp.entries[0].kind, ProvKind::EliminatedConstant);
378 assert!(fp.entries[0].object_pcs.is_empty());
379 // The witness join key is the real byte offset, not a hardcoded 0.
380 assert_eq!(fp.entries[0].instruction_offset, 42);
381 }
382}