Skip to main content

synth_core/
wcet.rs

1//! #778 (v0.46 Wave-1 Lane 2) — the `synth-wcet-v1` static worst-case-cycle map.
2//!
3//! synth holds the EXACT final instruction sequence of every compiled function,
4//! so it is the natural owner of a SOUND static per-function worst-case execution
5//! time (WCET) bound. gale's schedulability track (spar T3/T4) computes a
6//! machine-checked response-time bound, but its per-task cost inputs (`C_i`) are
7//! only DWT high-water-marks — *observations*, not *bounds* — and a hard build
8//! gate forbids sizing budgets from DWT. This sidecar supplies the missing SOUND
9//! input: a cycle bound that is provably ≥ any real execution of the function.
10//!
11//! ## Soundness contract (the whole point)
12//!
13//! A bound that is EVER less than the real cycle count is a defect. This module
14//! is therefore deliberately conservative and DECLINES loudly rather than emit a
15//! number it cannot defend:
16//!
17//! - **Loop-free functions** get an EXACT-form bound: every instruction in the
18//!   final stream executes at most once, so the bound is the SUM of each
19//!   instruction's documented worst-case cycles. Summing every instruction
20//!   (including both arms of an `if/else`) is an over-estimate, hence sound; no
21//!   path enumeration is needed.
22//! - **Everything else** — any backward branch (a loop), any residual/external
23//!   label branch (unknown direction), any call (`Bl`/`Blx`, inter-procedural),
24//!   any op whose encoder expansion contains an internal runtime loop
25//!   (`i64` software div/rem), any unsupported core class — is DECLINED with a
26//!   machine-readable reason. gale cannot size a budget from an unsound number,
27//!   so a decline is strictly better than a guess.
28//!
29//! Loop-bound INFERENCE (recovering an unknown trip count) and inter-procedural
30//! composition are the named scry / spar follow-ups, explicitly OUT of scope.
31//!
32//! ## Precondition — a bound without its assumptions is not a safety input
33//!
34//! The per-instruction cycle numbers are documented worst cases for the
35//! **Cortex-M3 / Cortex-M4(F)** in-order pipeline under a **zero-wait-state**
36//! instruction memory (flash accelerator / I-cache hit). The bound is CONDITIONAL
37//! on that precondition, which is recorded in the JSON (`core_class`,
38//! `wait_states`, `memory_assumption`) so the T4 consumer knows exactly what it
39//! holds under. Cortex-M7 (dual-issue + caches with wait-states that can make
40//! actual cycles EXCEED a zero-wait straight sum) is DECLINED, not
41//! approximated — soundness over coverage.
42//!
43//! ## Schema (`synth-wcet-v1`)
44//!
45//! A JSON sidecar written next to the object (`<output>.wcet.json`). Purely
46//! additive metadata: it is derived from the already-decided instruction stream
47//! and never touches `.text`, so the emitted bytes are byte-identical whether or
48//! not the bound is emitted (frozen-safe).
49
50use serde::{Deserialize, Serialize};
51
52/// The schema version string embedded at the top of the sidecar.
53pub const SCHEMA: &str = "synth-wcet-v1";
54
55/// Why a function could not receive a sound static cycle bound. Each variant is a
56/// distinct, machine-readable decline reason so a consumer (spar T4) can tell an
57/// unbounded loop from an inter-procedural edge from an unsupported core.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum WcetDecline {
61    /// A backward branch in the final instruction stream — a loop. Bounding it
62    /// requires a trip count, which WASM does not carry; that is the scry
63    /// loop-bound-inference follow-up.
64    Loop,
65    /// A call (`Bl`/`Blx`) — the bound is per-function (intra-procedural). Summing
66    /// a callee's cost is the spar inter-procedural-composition follow-up.
67    Call,
68    /// A residual/external label branch (`B`/`Bcc`/… still carrying a label): its
69    /// direction is not statically known here, so it cannot be proven loop-free.
70    UnresolvedBranch,
71    /// An op whose encoder expansion contains an internal RUNTIME loop (the `i64`
72    /// software div/rem shift-subtract: emitted once but executed 64×). Its body
73    /// bytes appear once in the stream, so a straight sum would undercount — a
74    /// sound bound needs a per-op `trip × body` model, a named follow-up.
75    LoopedExpansion,
76    /// The target core class is not soundly summable with a zero-wait per-op table
77    /// (Cortex-M7/M7dp: dual-issue + cache wait-states). Declined, not
78    /// approximated.
79    UnsupportedCore,
80    /// An op the cycle model has not classified. Never emitted in a released
81    /// build (the classifier is exhaustive with no wildcard) — present so the
82    /// schema can carry a conservative decline if the table is ever incomplete.
83    UnmodeledOp,
84}
85
86impl WcetDecline {
87    /// A short human-readable explanation, embedded alongside the machine reason.
88    pub fn note(&self) -> &'static str {
89        match self {
90            WcetDecline::Loop => {
91                "backward branch (loop) — a sound bound needs a trip count \
92                 (scry loop-bound-inference follow-up)"
93            }
94            WcetDecline::Call => {
95                "call (Bl/Blx) — per-function bound is intra-procedural \
96                 (spar inter-procedural-composition follow-up)"
97            }
98            WcetDecline::UnresolvedBranch => {
99                "residual external/unresolved label branch — direction not \
100                 statically known, cannot prove loop-free"
101            }
102            WcetDecline::LoopedExpansion => {
103                "op expands to an internal runtime loop (i64 software div/rem, \
104                 executed 64×) — straight sum would undercount"
105            }
106            WcetDecline::UnsupportedCore => {
107                "core class not soundly summable with a zero-wait per-op table \
108                 (Cortex-M7 dual-issue + cache wait-states)"
109            }
110            WcetDecline::UnmodeledOp => "op not classified by the cycle model",
111        }
112    }
113}
114
115/// The per-function result: either a sound cycle bound or a loud decline.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(tag = "status", rename_all = "kebab-case")]
118pub enum WcetFunction {
119    /// A sound upper bound on this function's execution in cycles.
120    Bounded {
121        /// Function name (WASM export or generated).
122        name: String,
123        /// The sound worst-case cycle bound: for a loop-free function this is the
124        /// SUM of each instruction's documented worst-case cycles (each executes
125        /// at most once). Always ≥ any real execution under the stated
126        /// precondition.
127        cycles: u64,
128        /// Number of ARM instructions summed (diagnostic).
129        instr_count: usize,
130    },
131    /// No bound emitted — a loud decline with a machine-readable reason. A decline
132    /// is emitted (rather than the function omitted) so the map is COMPLETE: a
133    /// consumer sees every function is either bounded or explicitly unbounded,
134    /// never silently missing.
135    Declined {
136        /// Function name.
137        name: String,
138        /// Machine-readable reason.
139        reason: WcetDecline,
140        /// Human-readable note (`reason.note()`).
141        note: String,
142    },
143}
144
145impl WcetFunction {
146    /// Construct a decline, filling in the note from the reason.
147    pub fn declined(name: impl Into<String>, reason: WcetDecline) -> Self {
148        let note = reason.note().to_string();
149        WcetFunction::Declined {
150            name: name.into(),
151            reason,
152            note,
153        }
154    }
155}
156
157/// The full `synth-wcet-v1` sidecar: schema header, precondition, and per-function
158/// bounds/declines.
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct WcetReport {
161    /// Schema version (`synth-wcet-v1`).
162    pub schema: String,
163    /// The compiled module name (for diagnostics).
164    pub module: String,
165    /// The core class the cycle table is written for (e.g. `"cortex-m4"`). The
166    /// bound is CONDITIONAL on this core.
167    pub core_class: String,
168    /// Assumed instruction-memory wait states (0 for the sound zero-wait table).
169    pub wait_states: u32,
170    /// Human statement of the memory precondition the bound holds under.
171    pub memory_assumption: String,
172    /// Per-function bound or decline. Complete: one entry per compiled function.
173    pub functions: Vec<WcetFunction>,
174}
175
176impl WcetReport {
177    /// Start an empty report for `module`, targeting `core_class` under the sound
178    /// zero-wait precondition.
179    pub fn new(module: impl Into<String>, core_class: impl Into<String>) -> Self {
180        WcetReport {
181            schema: SCHEMA.to_string(),
182            module: module.into(),
183            core_class: core_class.into(),
184            wait_states: 0,
185            memory_assumption:
186                "zero-wait-state instruction memory (flash accelerator / I-cache hit); \
187                 in-order single-issue pipeline; documented per-instruction worst-case cycles"
188                    .to_string(),
189            functions: Vec::new(),
190        }
191    }
192
193    /// Serialize to pretty JSON.
194    pub fn to_json(&self) -> serde_json::Result<String> {
195        serde_json::to_string_pretty(self)
196    }
197
198    /// Resolve the sidecar path (`<output>.wcet.json`) next to the ELF output.
199    pub fn sidecar_path(output: &std::path::Path) -> std::path::PathBuf {
200        let mut s = output.as_os_str().to_os_string();
201        s.push(".wcet.json");
202        std::path::PathBuf::from(s)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn bounded_and_declined_roundtrip() {
212        let mut r = WcetReport::new("m", "cortex-m4");
213        r.functions.push(WcetFunction::Bounded {
214            name: "leaf".into(),
215            cycles: 42,
216            instr_count: 7,
217        });
218        r.functions
219            .push(WcetFunction::declined("spins", WcetDecline::Loop));
220        let json = r.to_json().unwrap();
221        let back: WcetReport = serde_json::from_str(&json).unwrap();
222        assert_eq!(r, back);
223        // Decline reason is machine-readable and carries a note.
224        assert!(json.contains("\"reason\": \"loop\""));
225        assert!(json.contains("synth-wcet-v1"));
226    }
227
228    #[test]
229    fn sidecar_path_appends_suffix() {
230        let p = WcetReport::sidecar_path(std::path::Path::new("out/app.elf"));
231        assert_eq!(p, std::path::PathBuf::from("out/app.elf.wcet.json"));
232    }
233}