Skip to main content

synth_core/
backend.rs

1//! Backend trait and registry for multi-backend compilation
2//!
3//! Every compiler backend (ARM, aWsm, wasker, w2c2) implements the `Backend`
4//! trait, allowing the CLI and verification framework to treat them uniformly.
5
6use crate::target::TargetSpec;
7use crate::wasm_decoder::DecodedModule;
8use crate::wasm_op::WasmOp;
9use crate::wsc_facts::WscFact;
10use std::collections::HashMap;
11use thiserror::Error;
12
13/// Errors from backend compilation
14#[derive(Debug, Error)]
15pub enum BackendError {
16    #[error("compilation failed: {0}")]
17    CompilationFailed(String),
18
19    #[error("backend not available: {0}")]
20    NotAvailable(String),
21
22    #[error("unsupported configuration: {0}")]
23    UnsupportedConfig(String),
24
25    #[error("external tool error: {0}")]
26    ExternalToolError(String),
27}
28
29/// Memory-bounds safety strategy. Phase 1 of `docs/binary-safety-design.md` §3.1.
30///
31/// - `Mpu`/PMP: rely on hardware (ARM MPU or RV32 PMP) — no inline check.
32/// - `Software`: emit a `CMP/BHS Trap_Handler` (ARM) or `bgeu addr, mem_size, ebreak` (RV32)
33///   before every load/store.
34/// - `Mask`: emit `AND addr, addr, #(mem_size - 1)` — only valid when memory size
35///   is a power of two. Wraps on OOB rather than trapping (fuzz-profile semantics).
36/// - `None`: no bounds enforcement.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum SafetyBounds {
39    /// No bounds check (caller assumes the WASM module is trusted)
40    #[default]
41    None,
42    /// ARM MPU / RV32 PMP — hardware enforcement, no inline guard
43    Mpu,
44    /// Software CMP/BHS (ARM) or BGEU+EBREAK (RV32) per access
45    Software,
46    /// AND-mask, requires power-of-two memory size
47    Mask,
48}
49
50impl SafetyBounds {
51    /// Parse the `--safety-bounds` argument value.
52    pub fn parse(s: &str) -> std::result::Result<Self, String> {
53        match s {
54            "none" => Ok(SafetyBounds::None),
55            "mpu" | "pmp" => Ok(SafetyBounds::Mpu),
56            "software" | "soft" => Ok(SafetyBounds::Software),
57            "mask" | "masking" => Ok(SafetyBounds::Mask),
58            other => Err(format!(
59                "unknown --safety-bounds value '{}'; expected one of: none, mpu, software, mask",
60                other
61            )),
62        }
63    }
64
65    /// String form used in the safety manifest.
66    pub fn as_str(self) -> &'static str {
67        match self {
68            SafetyBounds::None => "none",
69            SafetyBounds::Mpu => "mpu",
70            SafetyBounds::Software => "software",
71            SafetyBounds::Mask => "mask",
72        }
73    }
74}
75
76/// Configuration for a compilation run
77#[derive(Debug, Clone)]
78pub struct CompileConfig {
79    /// Optimization level (0 = none, 1 = fast, 2 = default, 3 = aggressive)
80    pub opt_level: u8,
81    /// Target specification
82    pub target: TargetSpec,
83    /// Legacy: enable software bounds checking for memory operations.
84    /// Deprecated in favor of `safety_bounds`. When set, equivalent to
85    /// `SafetyBounds::Software`. Kept for backwards compatibility with
86    /// callers that haven't migrated yet.
87    pub bounds_check: bool,
88    /// Phase-1 unified safety-bounds knob. If `bounds_check` is `true` and
89    /// this is `None`, the legacy field wins (back-compat). If both are set,
90    /// `safety_bounds` wins.
91    pub safety_bounds: SafetyBounds,
92    /// Hardware profile name (e.g. "nrf52840", "stm32f407")
93    pub hardware: String,
94    /// Skip optimization passes (direct instruction selection)
95    pub no_optimize: bool,
96    /// Use Loom-compatible optimization preset
97    pub loom_compat: bool,
98    /// Number of imported functions (calls to indices below this use Meld dispatch)
99    pub num_imports: u32,
100    /// AAPCS integer-argument count per function, indexed by full WASM function
101    /// index (imports first, then locals). Lets `Call` marshal the right number
102    /// of operand-stack values into R0–R3 (issue #195). Empty = pass no args
103    /// (pre-#195 behaviour).
104    pub func_arg_counts: Vec<u32>,
105    /// AAPCS integer-argument count per function type, indexed by type index.
106    /// Used by `call_indirect` (issue #195).
107    pub type_arg_counts: Vec<u32>,
108    /// Produce relocatable (ET_REL) host-link output. When set, the backend
109    /// uses the direct instruction selector (`select_with_stack`) rather than
110    /// the optimized path: the optimizer materializes an *absolute* linear-
111    /// memory base (0x20000100) and does not preserve caller-saved registers
112    /// across calls, both wrong for a host-linked object where the linmem base
113    /// is supplied via `fp` at runtime and callees follow AAPCS. Imports are
114    /// also emitted as direct `func_N` BLs (resolved to the wasm field name)
115    /// instead of `__meld_dispatch_import`. (#197 — follow-up to #188/#171.)
116    pub relocatable: bool,
117
118    /// #237: emit wasm function-static data as a base-independent `.data`
119    /// section (`__synth_wasm_data`) addressed via MOVW/MOVT symbol relocations,
120    /// so a host-pointer drop-in (linmem base = 0 for native `*ptr` derefs)
121    /// doesn't mis-resolve the statics. Off by default — only the leaves'
122    /// base-relative `[R11+const]` path is used unless explicitly requested.
123    pub native_pointer_abi: bool,
124
125    /// #237: wasm linear-memory minimum size in bytes — the full static-data
126    /// extent (initialized `(data)` segments plus the zero-init/BSS region).
127    /// Under `native_pointer_abi`, a const memory address below this is a wasm
128    /// static → symbol-relative; any address beyond it is a runtime host pointer
129    /// → `[R11=0 + addr]`.
130    pub linear_memory_bytes: u32,
131
132    /// #237: the wasm stack-pointer global as `(index, init_value)`, if the
133    /// module has one. Under `native_pointer_abi` the backend register-promotes
134    /// it: `global.get` materializes `__synth_wasm_data + init` (the real stack
135    /// top) and the init value doubles as the static-data base that separates
136    /// pointer consts (`>= init`) from frame-size scalars (`< init`).
137    pub stack_pointer_global: Option<(u32, i32)>,
138    /// #311: per-function (full index) / per-type "returns i64" — the call
139    /// lowering must tag i64 results as a register pair or the hi half is
140    /// invisible to liveness.
141    pub func_ret_i64: Vec<bool>,
142    pub type_ret_i64: Vec<bool>,
143    /// #359: declared parameter widths per *function* (full index, imports
144    /// first): `func_params_i64[f][k]` is true when param `k` of function `f` is
145    /// i64/f64. The AAPCS stack-argument path needs the *declared* widths
146    /// (op-stream inference can't see an unused i64 param that still shifts the
147    /// incoming-stack layout). The source of truth — a per-function driver loop
148    /// (`compile_module` / the CLI loop) indexes it by `func.index` and copies
149    /// the slice into [`current_func_params_i64`] before each `compile_function`.
150    /// Empty → every param assumed i32 (the legacy path; keeps every function
151    /// with <=4 params, or all-i32 params, byte-identical).
152    pub func_params_i64: Vec<Vec<bool>>,
153    /// #359: declared parameter widths of the function CURRENTLY being compiled
154    /// — `current_func_params_i64[k]` is true when param `k` is i64/f64. Set per
155    /// function (a cheap clone of the config) from [`func_params_i64`] by the
156    /// driver loop, because `compile_function` is shared across backends and
157    /// carries no function index. Empty → assume i32.
158    pub current_func_params_i64: Vec<bool>,
159    /// #457: DECLARED parameter count of the function CURRENTLY being compiled,
160    /// from the module's type section (`func_arg_counts[func.index]`). Set per
161    /// function by the driver loops like [`current_func_params_i64`].
162    ///
163    /// The backends otherwise INFER the param count from local-access patterns
164    /// (`count_params`: a local whose first access is a read is assumed to be a
165    /// param) — which cannot distinguish a param from a read-before-write
166    /// non-param local. WASM zero-initializes non-param locals, so such a local
167    /// must read 0; the inference instead homed it in a parameter register and
168    /// read caller garbage (#457). The backends cap the inferred count at this
169    /// declared count when it is present, which reclassifies exactly the
170    /// read-before-write locals (an inferred count can only exceed the declared
171    /// one via a read-first index >= the declared count) and leaves every other
172    /// function's codegen byte-identical.
173    ///
174    /// `None` → declared signature unknown (hand-built op streams, direct
175    /// `compile_function` callers) → pure inference, the legacy behaviour.
176    pub current_func_param_count: Option<u32>,
177    /// #509: blocktype-arity side-table of the function CURRENTLY being compiled
178    /// — `(param_count, result_count)` of the k-th `Block`/`Loop`/`If` in its op
179    /// stream (ordinal-keyed; see [`FunctionOps::block_arity`]). Set per function
180    /// by the driver loop (like [`current_func_params_i64`]). The direct selector
181    /// uses it to land a value carried by `br`/`br_if`/`br_table` in the target
182    /// block's designated result register instead of dropping it. Empty → every
183    /// block treated as void (the legacy lowering; hand-built op streams).
184    ///
185    /// [`FunctionOps::block_arity`]: crate::wasm_decoder::FunctionOps::block_arity
186    pub current_func_block_arity: Vec<(u8, u8)>,
187
188    /// #543 Phase 1 — integrator-marked volatile linear-memory segments (the DMA
189    /// transfer window). Each range `[base, base+len)` names a region of the fused
190    /// linear memory that an EXTERNAL agent (the DMA engine, modelled by gale as a
191    /// Component-Model `own<buffer>` handoff — gale decision `DD-DMA-REGION-001`,
192    /// gale#124) rewrites out-of-band. Loads and stores whose address falls inside
193    /// a marked range must eventually be treated as VOLATILE: not cached, hoisted,
194    /// or reordered across the transfer boundary.
195    ///
196    /// PHASE-2 CONTRACT (implemented — issue #543): the optimizer's
197    /// address-caching passes HONOR these ranges. Consumption points:
198    ///  - the #468 base-CSE / const-address-fold
199    ///    (`optimizer_bridge::plan_base_cse`, DEFAULT-ON, opt-out
200    ///    `SYNTH_BASE_CSE=0`): a const-address access whose 4-byte window
201    ///    intersects a marked range is EXCLUDED from the fold set — it keeps
202    ///    its verbatim per-access materialize-and-access codegen, while
203    ///    accesses outside the range still fold;
204    ///  - const-CSE (`liveness::apply_const_cse` wired in `arm_backend.rs`,
205    ///    DEFAULT-ON, opt-out `SYNTH_CONST_CSE=0`; the former bridge-level
206    ///    inline cache is retired, #242): declines WHOLESALE while any range is
207    ///    marked — a cached constant cannot be classified address-vs-data at
208    ///    that level, so the conservative stance for statically-unknown
209    ///    addressing is to re-materialize every constant at each occurrence.
210    ///
211    /// Passes that only touch SP-relative frame slots (stack-reload forwarding,
212    /// frame-slot DCE, spill re-choice) are unaffected by design: these ranges
213    /// are LINEAR-MEMORY addresses, and frame slots are never linmem. Nothing on
214    /// the pipeline deletes, forwards, or reorders a linear-memory access (IR CSE
215    /// deliberately never CSEs `MemLoad`s; DCE removes only unreachable blocks),
216    /// so every marked access is issued verbatim, in program order.
217    ///
218    /// Empty (the default): zero behavior change by construction — every gate
219    /// reduces to the pre-#543 path, so the emitted `.text` is byte-identical
220    /// with or without this code (the frozen-codegen gate holds). See rivet
221    /// `VCR-DMA-001`.
222    pub volatile_segments: Vec<VolatileRange>,
223
224    /// VCR-PERF-002 Phase 1 (#494) — proven invariants forwarded by loom in
225    /// the `wsc.facts` custom section (encoding:
226    /// `docs/design/wsc-facts-encoding.md`; program:
227    /// `docs/design/proof-carrying-specialization.md`), whole-module table
228    /// keyed by `(func_index, value_id)`. The compile driver copies the
229    /// current function's slice into [`current_func_facts`] (the
230    /// `func_params_i64` → `current_func_params_i64` pattern), because
231    /// `compile_function` carries no function index.
232    ///
233    /// PHASE-1 CONTRACT: threaded but NOT consumed — no codegen path reads
234    /// facts, so emitted bytes are unchanged whether or not the module
235    /// carries the section (locked by `wsc_facts_ingestion_494.rs`). Phase 2
236    /// turns each fact into a premise for a flag-gated (`SYNTH_FACT_SPEC`),
237    /// per-elision ordeal-validated specialization; the facts-absent compile
238    /// stays byte-identical by construction (empty ⇒ every gate vacuous).
239    ///
240    /// [`current_func_facts`]: CompileConfig::current_func_facts
241    pub wsc_facts: Vec<WscFact>,
242    /// VCR-PERF-002 Phase 1 (#494): the `wsc.facts` invariants of the function
243    /// CURRENTLY being compiled (`fact.func_index == func.index`), set per
244    /// function by the driver loops like [`current_func_params_i64`]. This is
245    /// the field a Phase-2 selector pass will read its premises from. Empty →
246    /// no facts → no specialization may ever fire (the fail-safe default).
247    ///
248    /// [`current_func_params_i64`]: CompileConfig::current_func_params_i64
249    pub current_func_facts: Vec<WscFact>,
250    /// VCR-PERF-002 Phase 2b (#494, divisor-nonzero): op indices (into the op
251    /// stream passed to `compile_function`) of `div`/`rem` ops whose
252    /// DIVIDE-BY-ZERO trap guard is proven dead — the fact-spec pass
253    /// discharged `UNSAT(P ∧ divisor == 0)` per site through the
254    /// certificate-checked ordeal solver BEFORE the driver set this field.
255    /// Consumed by the ARM direct selector (`select_with_stack`); every other
256    /// path ignores it (guards stay — sound). Empty (the default) ⇒ every
257    /// guard is emitted, byte-identical to today.
258    pub fact_div_zero_elide: Vec<usize>,
259    /// VCR-PERF-002 Phase 2b (#494): op indices of `div_s` ops whose
260    /// `INT_MIN / -1` OVERFLOW trap guard is proven dead — a SEPARATE
261    /// obligation (`UNSAT(P ∧ dividend == INT_MIN ∧ divisor == -1)`). A
262    /// divisor-nonzero fact alone NEVER lands here: divisor ≠ 0 does not
263    /// exclude -1 (#633/#634 two-guard distinction). Empty ⇒ guard emitted.
264    pub fact_div_ovf_elide: Vec<usize>,
265}
266
267/// #543 — an integrator-marked volatile linear-memory segment (the DMA transfer
268/// window): the half-open byte range `[base, base + len)` of the fused linear
269/// memory that an external agent rewrites out-of-band. Parsed from the CLI
270/// `--volatile-segment <base>:<len>` flag. See [`CompileConfig::volatile_segments`]
271/// for the Phase-1/Phase-2 split.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub struct VolatileRange {
274    /// Start address of the volatile region, in linear-memory bytes.
275    pub base: u32,
276    /// Length of the volatile region, in bytes. The region is `[base, base+len)`.
277    pub len: u32,
278}
279
280impl CompileConfig {
281    /// Resolve the effective safety-bounds setting, honouring the legacy
282    /// `bounds_check` field as a fallback. Used by backends to pick the
283    /// inline-check shape.
284    pub fn effective_safety_bounds(&self) -> SafetyBounds {
285        match (self.safety_bounds, self.bounds_check) {
286            (SafetyBounds::None, true) => SafetyBounds::Software,
287            (s, _) => s,
288        }
289    }
290}
291
292impl Default for CompileConfig {
293    fn default() -> Self {
294        Self {
295            opt_level: 2,
296            target: TargetSpec::cortex_m4(),
297            bounds_check: false,
298            safety_bounds: SafetyBounds::None,
299            hardware: String::new(),
300            no_optimize: false,
301            loom_compat: false,
302            num_imports: 0,
303            func_arg_counts: Vec::new(),
304            type_arg_counts: Vec::new(),
305            relocatable: false,
306            native_pointer_abi: false,
307            linear_memory_bytes: 0,
308            stack_pointer_global: None,
309            func_ret_i64: Vec::new(),
310            type_ret_i64: Vec::new(),
311            func_params_i64: Vec::new(),
312            current_func_params_i64: Vec::new(),
313            // #457: None ⇒ declared signature unknown ⇒ param-count inference
314            // only (unit tests / hand-built op streams); driver loops fill it.
315            current_func_param_count: None,
316            // #509: empty ⇒ legacy void-block lowering (unit tests / hand-built
317            // op streams); the driver loops fill it per function.
318            current_func_block_arity: Vec::new(),
319            // #543 Phase 1: no volatile segments unless the CLI flag names them.
320            // Empty ⇒ inert ⇒ emitted bytes unchanged.
321            volatile_segments: Vec::new(),
322            // VCR-PERF-002 Phase 1 (#494): no facts unless the module carries
323            // a parseable `wsc.facts` section. Empty ⇒ inert (and Phase 1 has
324            // no consumer anyway) ⇒ emitted bytes unchanged.
325            wsc_facts: Vec::new(),
326            current_func_facts: Vec::new(),
327            // VCR-PERF-002 Phase 2b (#494): no guard-elision marks unless the
328            // fact-spec pass discharged the per-site obligations. Empty ⇒
329            // every div/rem trap guard is emitted, byte-identical.
330            fact_div_zero_elide: Vec::new(),
331            fact_div_ovf_elide: Vec::new(),
332        }
333    }
334}
335
336/// A relocation entry produced during compilation
337///
338/// Records that a BL instruction at `offset` bytes into the function's code
339/// targets an external symbol (e.g., `__meld_dispatch_import`). The linker
340/// resolves these when combining the Synth object with the Kiln bridge.
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
342pub enum RelocKind {
343    /// R_ARM_THM_CALL — a Thumb BL call site (the default; #167).
344    ThmCall,
345    /// R_ARM_MOVW_ABS_NC — the MOVW half of a symbol-relative address (#237).
346    MovwAbs,
347    /// R_ARM_MOVT_ABS — the MOVT half of a symbol-relative address (#237).
348    MovtAbs,
349    /// R_ARM_ABS32 — a 32-bit absolute address held in a `.text` literal-pool
350    /// word, loaded via `LDR rX, [pc, #off]` (#345). The link-survivable
351    /// replacement for the inline-immediate MOVW/MOVT-ABS pair: `ld`/bfd patches
352    /// the data word at link time (`S + A`, the addend living in the word, REL
353    /// semantics), which survives placement into a large multi-object image —
354    /// whereas an inline-instruction MOVW_ABS immediate can be mangled.
355    Abs32,
356}
357
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct CodeRelocation {
360    /// Byte offset within the function's machine code where the reloc applies
361    pub offset: u32,
362    /// Target symbol name (e.g., "__meld_dispatch_import", "__synth_wasm_data")
363    pub symbol: String,
364    /// Which ARM relocation type to emit for this site.
365    pub kind: RelocKind,
366}
367
368/// VCR-DBG-001: a per-instruction source map — `(machine_offset_within_code,
369/// wasm_op_index)` pairs, one per emitted machine instruction. A `None` op-index
370/// marks an instruction with no originating wasm op (prologue/epilogue, literal
371/// pool). Consumed by the DWARF `.debug_line` emitter; empty when no source map
372/// was produced.
373pub type LineMap = Vec<(u32, Option<usize>)>;
374
375/// A single compiled function
376#[derive(Debug, Clone)]
377pub struct CompiledFunction {
378    /// Function name (from WASM export or generated)
379    pub name: String,
380    /// Raw machine code bytes
381    pub code: Vec<u8>,
382    /// Original WASM ops (retained for verification)
383    pub wasm_ops: Vec<WasmOp>,
384    /// Relocations for external symbol references (BL to bridge functions)
385    pub relocations: Vec<CodeRelocation>,
386    /// VCR-DBG-001: per-instruction source map for DWARF `.debug_line` emission —
387    /// `(machine_offset_within_code, wasm_op_index)` captured at encode time, one
388    /// entry per emitted machine instruction. A `None` op-index marks an
389    /// instruction with no originating wasm op (prologue/epilogue, literal-pool
390    /// word). This is purely additive metadata: it is never serialized unless
391    /// `.debug_line` emission is requested, so the emitted `.text` is
392    /// byte-identical with or without it. Empty for backends/paths that do not
393    /// yet produce a source map (RISC-V, the optimized ARM path).
394    pub line_map: LineMap,
395}
396
397/// Result of compiling a full module
398#[derive(Debug)]
399pub struct CompilationResult {
400    /// Compiled functions
401    pub functions: Vec<CompiledFunction>,
402    /// Complete ELF binary (if backend produces one directly)
403    pub elf: Option<Vec<u8>>,
404    /// Name of the backend that produced this result
405    pub backend_name: String,
406}
407
408/// What a backend can and cannot do
409#[derive(Debug, Clone)]
410pub struct BackendCapabilities {
411    /// Backend produces complete ELF files (external backends like aWsm)
412    pub produces_elf: bool,
413    /// Backend supports per-rule verification (only our custom ARM backend)
414    pub supports_rule_verification: bool,
415    /// Backend supports binary-level verification (all backends via disassembly)
416    pub supports_binary_verification: bool,
417    /// Backend is an external tool (not a library)
418    pub is_external: bool,
419}
420
421/// Trait that every compilation backend implements
422pub trait Backend: Send + Sync {
423    /// Human-readable backend name
424    fn name(&self) -> &str;
425
426    /// What this backend can do
427    fn capabilities(&self) -> BackendCapabilities;
428
429    /// Which targets this backend supports
430    fn supported_targets(&self) -> Vec<TargetSpec>;
431
432    /// Compile an entire decoded WASM module
433    fn compile_module(
434        &self,
435        module: &DecodedModule,
436        config: &CompileConfig,
437    ) -> std::result::Result<CompilationResult, BackendError>;
438
439    /// Compile a single function from WASM ops to machine code
440    fn compile_function(
441        &self,
442        name: &str,
443        ops: &[WasmOp],
444        config: &CompileConfig,
445    ) -> std::result::Result<CompiledFunction, BackendError>;
446
447    /// Check if this backend is available (external tools installed, etc.)
448    fn is_available(&self) -> bool;
449}
450
451/// Registry of available backends
452pub struct BackendRegistry {
453    backends: HashMap<String, Box<dyn Backend>>,
454}
455
456impl BackendRegistry {
457    pub fn new() -> Self {
458        Self {
459            backends: HashMap::new(),
460        }
461    }
462
463    /// Register a backend under its name
464    pub fn register(&mut self, backend: Box<dyn Backend>) {
465        let name = backend.name().to_string();
466        self.backends.insert(name, backend);
467    }
468
469    /// Get a backend by name
470    pub fn get(&self, name: &str) -> Option<&dyn Backend> {
471        self.backends.get(name).map(|b| b.as_ref())
472    }
473
474    /// List all registered backends
475    pub fn list(&self) -> Vec<&dyn Backend> {
476        self.backends.values().map(|b| b.as_ref()).collect()
477    }
478
479    /// List backends that are actually available (installed and working)
480    pub fn available(&self) -> Vec<&dyn Backend> {
481        self.backends
482            .values()
483            .filter(|b| b.is_available())
484            .map(|b| b.as_ref())
485            .collect()
486    }
487}
488
489impl Default for BackendRegistry {
490    fn default() -> Self {
491        Self::new()
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn test_registry_empty() {
501        let reg = BackendRegistry::new();
502        assert!(reg.list().is_empty());
503        assert!(reg.available().is_empty());
504        assert!(reg.get("arm").is_none());
505    }
506
507    #[test]
508    fn test_compile_config_default() {
509        let config = CompileConfig::default();
510        assert_eq!(config.opt_level, 2);
511        assert!(!config.bounds_check);
512        assert_eq!(config.safety_bounds, SafetyBounds::None);
513        assert!(!config.no_optimize);
514    }
515
516    #[test]
517    fn safety_bounds_parse_round_trip() {
518        for s in ["none", "mpu", "software", "mask"] {
519            let sb = SafetyBounds::parse(s).unwrap();
520            assert_eq!(sb.as_str(), s);
521        }
522        assert_eq!(SafetyBounds::parse("pmp").unwrap(), SafetyBounds::Mpu);
523        assert_eq!(SafetyBounds::parse("soft").unwrap(), SafetyBounds::Software);
524        assert!(SafetyBounds::parse("nonsense").is_err());
525    }
526
527    #[test]
528    fn effective_safety_bounds_legacy_promotes_to_software() {
529        let cfg = CompileConfig {
530            bounds_check: true,
531            ..Default::default()
532        };
533        assert_eq!(cfg.effective_safety_bounds(), SafetyBounds::Software);
534    }
535
536    #[test]
537    fn effective_safety_bounds_new_field_wins() {
538        let cfg = CompileConfig {
539            bounds_check: true,
540            safety_bounds: SafetyBounds::Mpu,
541            ..Default::default()
542        };
543        assert_eq!(cfg.effective_safety_bounds(), SafetyBounds::Mpu);
544    }
545}