synth_core/wsc_facts.rs
1//! `wsc.facts` custom-section ingestion — VCR-PERF-002 / #494 **Phase 1**.
2//!
3//! loom (the PROVER, loom#231/loom#240) forwards invariants its Z3 translation
4//! validator already discharges in a custom section named `wsc.facts` (sibling
5//! of `wsc.transformation.attestation`), keyed by `(function index, value id)`.
6//! synth is a CONDITIONAL optimizer: a later phase consumes each fact as a
7//! premise behind a per-elision ordeal obligation (see
8//! `docs/design/proof-carrying-specialization.md`). THIS module is ingestion
9//! only — parse and represent; **no codegen path reads the result yet**, so
10//! emitted bytes are unchanged by construction (frozen-safe).
11//!
12//! Binary contract: `docs/design/wsc-facts-encoding.md` (schema v1). The
13//! normative fail-safe skew rule (loom#231 Q4) is implemented here:
14//!
15//! - missing section / unknown version / structurally unparseable section →
16//! NO facts (the whole section is ignored, never an error);
17//! - unknown fact kind → that record is skipped via its length prefix;
18//! - known kind whose body doesn't decode exactly → that record is dropped.
19//!
20//! Facts are optional accelerators: there is no error path out of this module.
21//! [`parse_wsc_facts`] is total — it returns a (possibly empty) fact list for
22//! every input.
23
24/// Name of the custom section loom emits (loom#231).
25pub const WSC_FACTS_SECTION_NAME: &str = "wsc.facts";
26
27/// Schema version this parser understands. The version byte is FIRST in the
28/// payload so any incompatible future layout is detected before a single
29/// record is read (fail-safe skew: unknown version ⇒ whole section ignored).
30pub const WSC_FACTS_SCHEMA_VERSION: u8 = 1;
31
32/// One proven invariant forwarded by loom, keyed by `(function index, value
33/// id)`. `value_id` is the 0-based index of the producing operator within the
34/// function body's operator sequence — the same index space as
35/// [`FunctionOps::ops`]/[`FunctionOps::op_offsets`] (loom#231 Q1; see the
36/// encoding doc's "Value identification"). Phase 1 stores facts verbatim; an
37/// out-of-range `value_id` is vacuous and a Phase-2 consumer must ignore it.
38///
39/// [`FunctionOps::ops`]: crate::wasm_decoder::FunctionOps::ops
40/// [`FunctionOps::op_offsets`]: crate::wasm_decoder::FunctionOps::op_offsets
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct WscFact {
43 /// Full wasm function index (imported functions first, then local ones) —
44 /// the same space as [`FunctionOps::index`].
45 ///
46 /// [`FunctionOps::index`]: crate::wasm_decoder::FunctionOps::index
47 pub func_index: u32,
48 /// 0-based producing-operator index within the function body (see above).
49 pub value_id: u32,
50 /// The invariant itself.
51 pub kind: FactKind,
52}
53
54/// The fact kinds of encoding schema v1, in silicon-payoff order (the numbers
55/// are the on-wire `kind` bytes; `0x00` is deliberately unassigned so a zeroed
56/// buffer never parses as a fact).
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum FactKind {
59 /// `0x01` — the value is within `[lo, hi]`, signed, inclusive both ends
60 /// (drives the dead-clamp elision, the `gust_mix` shape).
61 ValueRange { lo: i64, hi: i64 },
62 /// `0x02` — the shift amount is `< bound` (bare `lsl/lsr`, no mask).
63 ShiftBound { bound: u32 },
64 /// `0x03` — the divisor is never zero (the `div/rem` trap guard is dead).
65 DivisorNonZero,
66 /// `0x04` — the address plus an `access_width`-byte access stays within
67 /// linear-memory bounds (the bounds check is dead).
68 InBoundsAccess { access_width: u32 },
69 /// `0x05` — both `select` arms are safe to evaluate: branchless `IT`
70 /// lowering is admissible (pairs with VCR-SEL-004).
71 SelectTotality,
72 /// `0x06` — this address and the one produced by `other_value_id` (same
73 /// function) never alias: values may stay in registers across the store.
74 MemoryDisjointness { other_value_id: u32 },
75}
76
77/// Result of parsing a `wsc.facts` payload: the facts plus the diagnostic
78/// counters the decoder surfaces as WARNINGS (never errors — the rivet
79/// `VCR-PERF-002` phase-1 criterion is "ignored with a diagnostic, never an
80/// error"). `Default` is the missing-section value: no facts, nothing to
81/// report.
82#[derive(Debug, Clone, PartialEq, Eq, Default)]
83pub struct WscFactsParse {
84 /// The facts that decoded cleanly. Empty whenever `section_ignored` is set.
85 pub facts: Vec<WscFact>,
86 /// `Some(reason)` when the WHOLE section was ignored: unknown schema
87 /// version or structural unparseability (truncated/overrun framing,
88 /// trailing bytes). Facts already decoded are dropped too — an
89 /// unparseable section is not trusted piecemeal.
90 pub section_ignored: Option<String>,
91 /// Records dropped individually while the section itself parsed: unknown
92 /// fact kinds (a newer loom) and known kinds whose length-prefixed body
93 /// was not exactly the v1 fields (an extended body). Emitter/consumer
94 /// skew signal — worth a warning, never an error.
95 pub records_skipped: u32,
96}
97
98/// Parse a `wsc.facts` custom-section payload (schema v1). TOTAL by design —
99/// this is the fail-safe skew rule (loom#231 Q4) and there is deliberately no
100/// `Result` in the signature: facts are optional accelerators, so no input can
101/// produce an error that changes a compilation outcome.
102///
103/// Whole-section ignore (reported via [`WscFactsParse::section_ignored`]):
104/// unknown schema version, truncated/overrun framing, count mismatch, or
105/// trailing bytes after the last record. Per-record skip (counted in
106/// [`WscFactsParse::records_skipped`], parsing continues): unknown fact kinds
107/// and known kinds whose length-prefixed body doesn't decode to exactly the
108/// expected fields.
109pub fn parse_wsc_facts(payload: &[u8]) -> WscFactsParse {
110 let mut out = WscFactsParse::default();
111 if parse_wsc_facts_inner(payload, &mut out).is_none() {
112 return WscFactsParse {
113 facts: Vec::new(),
114 section_ignored: Some(
115 "unknown schema version or truncated/overrun record framing".to_string(),
116 ),
117 records_skipped: 0,
118 };
119 }
120 out
121}
122
123/// Structural parse into `out`: `None` ⇒ the whole section is unparseable and
124/// must be ignored entirely (including records already decoded into `out`).
125fn parse_wsc_facts_inner(payload: &[u8], out: &mut WscFactsParse) -> Option<()> {
126 let mut r = Reader::new(payload);
127 if r.byte()? != WSC_FACTS_SCHEMA_VERSION {
128 return None;
129 }
130 let count = r.leb_u32()?;
131 for _ in 0..count {
132 let kind = r.byte()?;
133 let func_index = r.leb_u32()?;
134 let value_id = r.leb_u32()?;
135 let body_len = r.leb_u32()?;
136 let body = r.bytes(body_len as usize)?;
137 // Per-record tolerance: an unknown kind byte, or a known kind whose
138 // body isn't exactly the expected fields (a newer emitter may have
139 // extended it), drops THIS record only — the framing above already
140 // consumed it, so the rest of the section still parses.
141 match decode_body(kind, body) {
142 Some(kind) => out.facts.push(WscFact {
143 func_index,
144 value_id,
145 kind,
146 }),
147 None => out.records_skipped += 1,
148 }
149 }
150 // Trailing bytes after the declared records ⇒ structurally unparseable.
151 if !r.is_empty() {
152 return None;
153 }
154 Some(())
155}
156
157/// Decode one record body. `None` ⇒ drop the record (unknown kind, or a body
158/// that is not exactly the v1 fields for its kind).
159fn decode_body(kind: u8, body: &[u8]) -> Option<FactKind> {
160 let mut r = Reader::new(body);
161 let fact = match kind {
162 0x01 => FactKind::ValueRange {
163 lo: r.leb_s64()?,
164 hi: r.leb_s64()?,
165 },
166 0x02 => FactKind::ShiftBound {
167 bound: r.leb_u32()?,
168 },
169 0x03 => FactKind::DivisorNonZero,
170 0x04 => FactKind::InBoundsAccess {
171 access_width: r.leb_u32()?,
172 },
173 0x05 => FactKind::SelectTotality,
174 0x06 => FactKind::MemoryDisjointness {
175 other_value_id: r.leb_u32()?,
176 },
177 _ => return None,
178 };
179 // Exact-body rule: trailing bytes mean an extended (newer) body — treat
180 // like an unknown kind rather than half-reading it.
181 if !r.is_empty() {
182 return None;
183 }
184 Some(fact)
185}
186
187/// Minimal bounds-checked cursor with wasm-spec LEB128 readers. Hand-rolled
188/// (~30 lines) rather than borrowing `wasmparser::BinaryReader` so this
189/// contract cannot drift under a wasmparser major bump — the encoding doc is
190/// the single source of truth.
191struct Reader<'a> {
192 data: &'a [u8],
193 pos: usize,
194}
195
196impl<'a> Reader<'a> {
197 fn new(data: &'a [u8]) -> Self {
198 Self { data, pos: 0 }
199 }
200
201 fn is_empty(&self) -> bool {
202 self.pos >= self.data.len()
203 }
204
205 fn byte(&mut self) -> Option<u8> {
206 let b = *self.data.get(self.pos)?;
207 self.pos += 1;
208 Some(b)
209 }
210
211 fn bytes(&mut self, n: usize) -> Option<&'a [u8]> {
212 let end = self.pos.checked_add(n)?;
213 let s = self.data.get(self.pos..end)?;
214 self.pos = end;
215 Some(s)
216 }
217
218 /// Unsigned LEB128, max 5 bytes (wasm-spec `u32`).
219 fn leb_u32(&mut self) -> Option<u32> {
220 let mut result: u32 = 0;
221 for shift in [0u32, 7, 14, 21, 28] {
222 let b = self.byte()?;
223 let low = u32::from(b & 0x7f);
224 // Bits that would fall off the top make the encoding invalid.
225 if low.checked_shl(shift)? >> shift != low {
226 return None;
227 }
228 result |= low << shift;
229 if b & 0x80 == 0 {
230 return Some(result);
231 }
232 }
233 None // continuation bit set on the 5th byte
234 }
235
236 /// Signed LEB128, max 10 bytes (wasm-spec `s64`).
237 fn leb_s64(&mut self) -> Option<i64> {
238 let mut result: i64 = 0;
239 let mut shift: u32 = 0;
240 loop {
241 let b = self.byte()?;
242 if shift >= 63 {
243 // 10th byte: only 1 payload bit left. It must terminate and
244 // must be a pure sign-extension byte (0x00 or 0x7f).
245 if b & 0x80 != 0 || (b != 0x00 && b != 0x7f) {
246 return None;
247 }
248 if b == 0x7f {
249 result |= -1i64 << shift;
250 }
251 return Some(result);
252 }
253 result |= i64::from(b & 0x7f) << shift;
254 shift += 7;
255 if b & 0x80 == 0 {
256 // Sign-extend from the last payload bit.
257 if shift < 64 && b & 0x40 != 0 {
258 result |= -1i64 << shift;
259 }
260 return Some(result);
261 }
262 }
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 /// Facts-only view for the happy-path assertions; the skew tests assert
271 /// the diagnostic fields explicitly.
272 fn facts(payload: &[u8]) -> Vec<WscFact> {
273 parse_wsc_facts(payload).facts
274 }
275
276 // ---- test-side encoder (mirrors docs/design/wsc-facts-encoding.md) ----
277
278 fn leb_u32(mut v: u32, out: &mut Vec<u8>) {
279 loop {
280 let mut b = (v & 0x7f) as u8;
281 v >>= 7;
282 if v != 0 {
283 b |= 0x80;
284 }
285 out.push(b);
286 if v == 0 {
287 return;
288 }
289 }
290 }
291
292 fn leb_s64(mut v: i64, out: &mut Vec<u8>) {
293 loop {
294 let mut b = (v & 0x7f) as u8;
295 v >>= 7; // arithmetic shift
296 let done = (v == 0 && b & 0x40 == 0) || (v == -1 && b & 0x40 != 0);
297 if !done {
298 b |= 0x80;
299 }
300 out.push(b);
301 if done {
302 return;
303 }
304 }
305 }
306
307 fn record(kind: u8, func_index: u32, value_id: u32, body: &[u8]) -> Vec<u8> {
308 let mut out = vec![kind];
309 leb_u32(func_index, &mut out);
310 leb_u32(value_id, &mut out);
311 leb_u32(body.len() as u32, &mut out);
312 out.extend_from_slice(body);
313 out
314 }
315
316 fn section(version: u8, records: &[Vec<u8>]) -> Vec<u8> {
317 let mut out = vec![version];
318 leb_u32(records.len() as u32, &mut out);
319 for r in records {
320 out.extend_from_slice(r);
321 }
322 out
323 }
324
325 fn value_range_body(lo: i64, hi: i64) -> Vec<u8> {
326 let mut b = Vec::new();
327 leb_s64(lo, &mut b);
328 leb_s64(hi, &mut b);
329 b
330 }
331
332 // ---- well-formed sections: every v1 fact kind round-trips ----
333
334 #[test]
335 fn parses_value_range_494() {
336 // The gust_mix premise: v ∈ [524, 1524] on (func 3, value 7).
337 let s = section(1, &[record(0x01, 3, 7, &value_range_body(524, 1524))]);
338 assert_eq!(
339 facts(&s),
340 vec![WscFact {
341 func_index: 3,
342 value_id: 7,
343 kind: FactKind::ValueRange { lo: 524, hi: 1524 },
344 }]
345 );
346 }
347
348 #[test]
349 fn parses_negative_and_extreme_range_bounds_494() {
350 let s = section(
351 1,
352 &[record(0x01, 0, 0, &value_range_body(i64::MIN, i64::MAX))],
353 );
354 assert_eq!(
355 facts(&s),
356 vec![WscFact {
357 func_index: 0,
358 value_id: 0,
359 kind: FactKind::ValueRange {
360 lo: i64::MIN,
361 hi: i64::MAX,
362 },
363 }]
364 );
365 }
366
367 #[test]
368 fn parses_shift_bound_494() {
369 let mut body = Vec::new();
370 leb_u32(32, &mut body);
371 let s = section(1, &[record(0x02, 1, 4, &body)]);
372 assert_eq!(
373 facts(&s),
374 vec![WscFact {
375 func_index: 1,
376 value_id: 4,
377 kind: FactKind::ShiftBound { bound: 32 },
378 }]
379 );
380 }
381
382 #[test]
383 fn parses_divisor_nonzero_494() {
384 let s = section(1, &[record(0x03, 2, 9, &[])]);
385 assert_eq!(
386 facts(&s),
387 vec![WscFact {
388 func_index: 2,
389 value_id: 9,
390 kind: FactKind::DivisorNonZero,
391 }]
392 );
393 }
394
395 #[test]
396 fn parses_in_bounds_access_494() {
397 let mut body = Vec::new();
398 leb_u32(4, &mut body);
399 let s = section(1, &[record(0x04, 5, 11, &body)]);
400 assert_eq!(
401 facts(&s),
402 vec![WscFact {
403 func_index: 5,
404 value_id: 11,
405 kind: FactKind::InBoundsAccess { access_width: 4 },
406 }]
407 );
408 }
409
410 #[test]
411 fn parses_select_totality_494() {
412 let s = section(1, &[record(0x05, 6, 13, &[])]);
413 assert_eq!(
414 facts(&s),
415 vec![WscFact {
416 func_index: 6,
417 value_id: 13,
418 kind: FactKind::SelectTotality,
419 }]
420 );
421 }
422
423 #[test]
424 fn parses_memory_disjointness_494() {
425 let mut body = Vec::new();
426 leb_u32(21, &mut body);
427 let s = section(1, &[record(0x06, 7, 17, &body)]);
428 assert_eq!(
429 facts(&s),
430 vec![WscFact {
431 func_index: 7,
432 value_id: 17,
433 kind: FactKind::MemoryDisjointness { other_value_id: 21 },
434 }]
435 );
436 }
437
438 #[test]
439 fn parses_multiple_facts_in_order_494() {
440 let s = section(
441 1,
442 &[
443 record(0x01, 0, 1, &value_range_body(-8, 8)),
444 record(0x03, 0, 2, &[]),
445 ],
446 );
447 let facts = facts(&s);
448 assert_eq!(facts.len(), 2);
449 assert_eq!(facts[0].kind, FactKind::ValueRange { lo: -8, hi: 8 });
450 assert_eq!(facts[1].kind, FactKind::DivisorNonZero);
451 }
452
453 #[test]
454 fn parses_empty_fact_list_494() {
455 // A well-formed zero-fact section is NOT a skew condition: no facts,
456 // no diagnostics.
457 let parsed = parse_wsc_facts(§ion(1, &[]));
458 assert_eq!(parsed, WscFactsParse::default());
459 }
460
461 // ---- fail-safe skew rule: tolerance without any error path ----
462
463 #[test]
464 fn unknown_kind_is_skipped_others_kept_494() {
465 // kind 0x2A from a future loom, length-prefixed body — skipped via the
466 // prefix; the known records around it still parse.
467 let s = section(
468 1,
469 &[
470 record(0x03, 0, 1, &[]),
471 record(0x2a, 0, 2, &[0xde, 0xad, 0xbe]),
472 record(0x05, 0, 3, &[]),
473 ],
474 );
475 let parsed = parse_wsc_facts(&s);
476 assert_eq!(parsed.facts.len(), 2);
477 assert_eq!(parsed.facts[0].kind, FactKind::DivisorNonZero);
478 assert_eq!(parsed.facts[1].kind, FactKind::SelectTotality);
479 // The skip is DIAGNOSED (skew signal), not a whole-section ignore.
480 assert_eq!(parsed.records_skipped, 1);
481 assert_eq!(parsed.section_ignored, None);
482 }
483
484 #[test]
485 fn kind_zero_is_not_a_fact_494() {
486 // 0x00 is deliberately unassigned (zeroed buffers must not parse).
487 let s = section(1, &[record(0x00, 0, 0, &[])]);
488 assert_eq!(facts(&s), vec![]);
489 }
490
491 #[test]
492 fn extended_body_on_known_kind_drops_that_record_only_494() {
493 // divisor-nonzero with a 2-byte body: a newer emitter extended it —
494 // exact-body rule drops it, the neighbor survives.
495 let s = section(
496 1,
497 &[record(0x03, 0, 1, &[0x01, 0x02]), record(0x05, 0, 2, &[])],
498 );
499 let facts = facts(&s);
500 assert_eq!(facts.len(), 1);
501 assert_eq!(facts[0].kind, FactKind::SelectTotality);
502 }
503
504 #[test]
505 fn short_body_on_known_kind_drops_that_record_only_494() {
506 // value-range with only `lo` present — the body under-decodes.
507 let mut body = Vec::new();
508 leb_s64(524, &mut body);
509 let s = section(1, &[record(0x01, 0, 1, &body), record(0x03, 0, 2, &[])]);
510 let facts = facts(&s);
511 assert_eq!(facts.len(), 1);
512 assert_eq!(facts[0].kind, FactKind::DivisorNonZero);
513 }
514
515 #[test]
516 fn version_mismatch_ignores_whole_section_494() {
517 for version in [0, 2, 0xff] {
518 let parsed = parse_wsc_facts(§ion(version, &[record(0x03, 0, 1, &[])]));
519 assert_eq!(parsed.facts, vec![]);
520 // Whole-section ignore is DIAGNOSED, never an error.
521 assert!(parsed.section_ignored.is_some());
522 }
523 }
524
525 #[test]
526 fn empty_and_truncated_sections_ignored_494() {
527 assert_eq!(facts(&[]), vec![]); // no version byte
528 assert_eq!(facts(&[0x01]), vec![]); // no count
529 // count says 1 record, no record bytes follow
530 assert_eq!(facts(&[0x01, 0x01]), vec![]);
531 // record framing truncated mid-header (kind present, indices missing)
532 assert_eq!(facts(&[0x01, 0x01, 0x01]), vec![]);
533 }
534
535 #[test]
536 fn body_len_overrun_ignores_whole_section_494() {
537 // body_len = 200 but only 2 body bytes present — framing overrun, and
538 // the earlier GOOD record is dropped too (unparseable ⇒ ignore whole).
539 let good = record(0x03, 0, 1, &[]);
540 let mut bad = vec![0x01];
541 leb_u32(0, &mut bad);
542 leb_u32(0, &mut bad);
543 leb_u32(200, &mut bad);
544 bad.extend_from_slice(&[0xaa, 0xbb]);
545 let s = section(1, &[good, bad]);
546 assert_eq!(facts(&s), vec![]);
547 }
548
549 #[test]
550 fn trailing_bytes_after_records_ignore_whole_section_494() {
551 let mut s = section(1, &[record(0x03, 0, 1, &[])]);
552 s.push(0xff);
553 assert_eq!(facts(&s), vec![]);
554 }
555
556 #[test]
557 fn overlong_leb_ignores_whole_section_494() {
558 // func_index as a 6-byte LEB (continuation on the 5th byte) — invalid
559 // wasm-spec u32.
560 let mut s = vec![0x01, 0x01, 0x03];
561 s.extend_from_slice(&[0x80, 0x80, 0x80, 0x80, 0x80, 0x00]); // func_index
562 s.push(0x00); // value_id
563 s.push(0x00); // body_len
564 assert_eq!(facts(&s), vec![]);
565 }
566
567 #[test]
568 fn garbage_is_ignored_never_panics_494() {
569 // A few adversarial byte soups: total function, empty result.
570 for garbage in [
571 &[0xff_u8, 0xff, 0xff, 0xff][..],
572 &[0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff][..],
573 &[0x00, 0x00, 0x00][..],
574 ] {
575 assert_eq!(facts(garbage), vec![]);
576 }
577 }
578}