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//! - **Loops with statically-evident trip counts** (#778 phase 2): a canonical
23//!   counted loop — const-initialized counter, const step, const bound, single
24//!   backward branch — whose trip count synth PROVES from the final instruction
25//!   stream gets `trip × body-worst + overhead` as an upper bound; every
26//!   instruction's cost is multiplied by its proven worst-case execution count.
27//!   Nested loops multiply only when EVERY level proves.
28//! - **Everything else** — any loop synth cannot prove a trip count for
29//!   (data-dependent bounds, non-canonical shapes), any residual/external
30//!   label branch (unknown direction), any call (`Bl`/`Blx`, inter-procedural),
31//!   any op whose encoder expansion contains an internal runtime loop
32//!   (`i64` software div/rem), any unsupported core class — is DECLINED with a
33//!   machine-readable reason. gale cannot size a budget from an unsound number,
34//!   so a decline is strictly better than a guess.
35//!
36//! `--wcet-hints` (#778 phase 2, the scry seam) supplies UNTRUSTED per-loop
37//! trip-count hints; each is soundly CHECKED against synth's own induction
38//! proof before use and REJECTED with a machine reason otherwise (see
39//! [`WcetHints`] / [`WcetHintReject`]). Richer hint certificates (data-dependent
40//! bounds) and inter-procedural composition remain the named scry / spar
41//! follow-ups, explicitly OUT of scope.
42//!
43//! ## Precondition — a bound without its assumptions is not a safety input
44//!
45//! The per-instruction cycle numbers are documented worst cases for the
46//! **Cortex-M3 / Cortex-M4(F)** in-order pipeline under a **zero-wait-state**
47//! instruction memory (flash accelerator / I-cache hit). The bound is CONDITIONAL
48//! on that precondition, which is recorded in the JSON (`core_class`,
49//! `wait_states`, `memory_assumption`) so the T4 consumer knows exactly what it
50//! holds under. Cortex-M7 (dual-issue + caches with wait-states that can make
51//! actual cycles EXCEED a zero-wait straight sum) is DECLINED, not
52//! approximated — soundness over coverage.
53//!
54//! ## Schema (`synth-wcet-v1`)
55//!
56//! A JSON sidecar written next to the object (`<output>.wcet.json`). Purely
57//! additive metadata: it is derived from the already-decided instruction stream
58//! and never touches `.text`, so the emitted bytes are byte-identical whether or
59//! not the bound is emitted (frozen-safe).
60
61use serde::{Deserialize, Serialize};
62
63/// The schema version string embedded at the top of the sidecar.
64pub const SCHEMA: &str = "synth-wcet-v1";
65
66/// The schema string a `--wcet-hints` file must carry (#778 phase 2).
67pub const HINTS_SCHEMA: &str = "synth-wcet-hints-v1";
68
69/// Why a function could not receive a sound static cycle bound. Each variant is a
70/// distinct, machine-readable decline reason so a consumer (spar T4) can tell an
71/// unbounded loop from an inter-procedural edge from an unsupported core.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(rename_all = "kebab-case")]
74pub enum WcetDecline {
75    /// A backward branch in the final instruction stream — a loop — whose trip
76    /// count synth could NOT statically prove (#778 phase 2 proves canonical
77    /// const-init/const-step/const-bound counted loops; equality-exit shapes
78    /// additionally need a verified `--wcet-hints` entry). Data-dependent
79    /// bounds remain the scry loop-bound-inference follow-up.
80    Loop,
81    /// A call (`Bl`/`Blx`) — the bound is per-function (intra-procedural). Summing
82    /// a callee's cost is the spar inter-procedural-composition follow-up.
83    Call,
84    /// A residual/external label branch (`B`/`Bcc`/… still carrying a label): its
85    /// direction is not statically known here, so it cannot be proven loop-free.
86    UnresolvedBranch,
87    /// An op whose encoder expansion contains an internal RUNTIME loop (the `i64`
88    /// software div/rem shift-subtract: emitted once but executed 64×). Its body
89    /// bytes appear once in the stream, so a straight sum would undercount — a
90    /// sound bound needs a per-op `trip × body` model, a named follow-up.
91    LoopedExpansion,
92    /// The target core class is not soundly summable with a zero-wait per-op table
93    /// (Cortex-M7/M7dp: dual-issue + cache wait-states). Declined, not
94    /// approximated.
95    UnsupportedCore,
96    /// An op the cycle model has not classified. Never emitted in a released
97    /// build (the classifier is exhaustive with no wildcard) — present so the
98    /// schema can carry a conservative decline if the table is ever incomplete.
99    UnmodeledOp,
100}
101
102impl WcetDecline {
103    /// A short human-readable explanation, embedded alongside the machine reason.
104    pub fn note(&self) -> &'static str {
105        match self {
106            WcetDecline::Loop => {
107                "backward branch (loop) without a statically-proven trip count — \
108                 canonical const-bound counted loops are proven automatically; \
109                 equality-exit shapes need a verified --wcet-hints entry; \
110                 data-dependent bounds are the scry loop-bound-inference follow-up"
111            }
112            WcetDecline::Call => {
113                "call (Bl/Blx) — per-function bound is intra-procedural \
114                 (spar inter-procedural-composition follow-up)"
115            }
116            WcetDecline::UnresolvedBranch => {
117                "residual external/unresolved label branch — direction not \
118                 statically known, cannot prove loop-free"
119            }
120            WcetDecline::LoopedExpansion => {
121                "op expands to an internal runtime loop (i64 software div/rem, \
122                 executed 64×) — straight sum would undercount"
123            }
124            WcetDecline::UnsupportedCore => {
125                "core class not soundly summable with a zero-wait per-op table \
126                 (Cortex-M7 dual-issue + cache wait-states)"
127            }
128            WcetDecline::UnmodeledOp => "op not classified by the cycle model",
129        }
130    }
131}
132
133/// How a loop's trip count was established (#778 phase 2).
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "kebab-case")]
136pub enum WcetLoopBoundSource {
137    /// Fully static proof: const-initialized counter, const step, const bound,
138    /// exit-guaranteeing comparison — the trip count is derived by synth alone.
139    Static,
140    /// The loop is an equality-exit shape synth only bounds under an explicit
141    /// `--wcet-hints` assertion; the hint was CHECKED against synth's own derived
142    /// trip count (divisibility + monotonicity + derived ≤ hint) before use. The
143    /// emitted trip count is still synth's DERIVED value, never the raw hint.
144    HintVerified,
145}
146
147/// One proven-bounded loop inside a bounded function (#778 phase 2). Loops are
148/// listed in ascending `head_offset` order — the SAME order `--wcet-hints`
149/// `loop_bounds` entries are matched by.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct WcetLoopBound {
152    /// Byte offset of the loop head (backward-branch target) within the function.
153    pub head_offset: u64,
154    /// The PROVEN maximum number of body executions (full iterations).
155    pub trip_count: u64,
156    /// Number of instructions inside the loop region (head..=backward branch),
157    /// so a consumer can cross-check `cycles ≥ trip_count × region_instr_count`
158    /// (every instruction costs ≥ 1 cycle).
159    pub region_instr_count: usize,
160    /// How the trip count was established.
161    pub source: WcetLoopBoundSource,
162    /// The hint value consumed (present iff `source == HintVerified` or a
163    /// redundant hint was cross-checked against a static proof).
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub hint: Option<u64>,
166}
167
168/// Machine-readable reason a `--wcet-hints` entry was REJECTED (#778 phase 2).
169/// The hint file is UNTRUSTED input: a hint is only ever consumed after synth
170/// verifies the loop's induction against it; everything else lands here.
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "kebab-case")]
173pub enum WcetHintReject {
174    /// The hint is SMALLER than synth's own derived trip count — a wrong hint.
175    /// Trusting it would emit a bound < a real execution (the fatal class).
176    HintBelowDerivedTrip,
177    /// synth could not verify the loop's induction against the hint (counter not
178    /// provably monotonic toward a statically-known bound ≤ hint — e.g. a
179    /// data-dependent bound register, a non-canonical shape, or an equality exit
180    /// whose step does not divide the distance). An unverifiable hint is never
181    /// trusted into a bound.
182    HintUnverifiableInduction,
183    /// The hint indexes a loop that does not exist in this function's final
184    /// instruction stream.
185    HintUnknownLoop,
186}
187
188impl WcetHintReject {
189    /// A short human-readable explanation, embedded alongside the machine reason.
190    pub fn note(&self) -> &'static str {
191        match self {
192            WcetHintReject::HintBelowDerivedTrip => {
193                "hint is below synth's derived trip count — a wrong hint; \
194                 trusting it would emit an unsound bound"
195            }
196            WcetHintReject::HintUnverifiableInduction => {
197                "loop induction not verifiable against the hint (counter not \
198                 provably monotonic toward a statically-known bound ≤ hint) — \
199                 an unverifiable hint is never trusted into a bound"
200            }
201            WcetHintReject::HintUnknownLoop => {
202                "hint indexes a loop that does not exist in the final \
203                 instruction stream"
204            }
205        }
206    }
207}
208
209/// One rejected hint, recorded in the sidecar so the oracle (scry) sees exactly
210/// which of its claims synth refused and why.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct WcetHintRejection {
213    /// Index into the function's `loop_bounds` hint array (== loop order by
214    /// ascending head offset).
215    pub loop_index: usize,
216    /// Byte offset of the loop head this hint addressed, when the loop exists.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub head_offset: Option<u64>,
219    /// The rejected hint value.
220    pub hint: u64,
221    /// Machine-readable rejection reason.
222    pub reason: WcetHintReject,
223    /// Human-readable note (`reason.note()`).
224    pub note: String,
225}
226
227/// The per-function result: either a sound cycle bound or a loud decline.
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(tag = "status", rename_all = "kebab-case")]
230pub enum WcetFunction {
231    /// A sound upper bound on this function's execution in cycles.
232    Bounded {
233        /// Function name (WASM export or generated).
234        name: String,
235        /// The sound worst-case cycle bound. For a loop-free function this is the
236        /// SUM of each instruction's documented worst-case cycles (each executes
237        /// at most once). For a function whose loops ALL have proven trip counts
238        /// (#778 phase 2) each instruction's cost is multiplied by its proven
239        /// worst-case execution count. Always ≥ any real execution under the
240        /// stated precondition.
241        cycles: u64,
242        /// Number of ARM instructions summed (diagnostic).
243        instr_count: usize,
244        /// Proven loops (empty for a loop-free function), ascending head offset.
245        #[serde(default, skip_serializing_if = "Vec::is_empty")]
246        loops: Vec<WcetLoopBound>,
247        /// Hints that were rejected (the static proof stands independently).
248        #[serde(default, skip_serializing_if = "Vec::is_empty")]
249        hint_rejections: Vec<WcetHintRejection>,
250    },
251    /// No bound emitted — a loud decline with a machine-readable reason. A decline
252    /// is emitted (rather than the function omitted) so the map is COMPLETE: a
253    /// consumer sees every function is either bounded or explicitly unbounded,
254    /// never silently missing.
255    Declined {
256        /// Function name.
257        name: String,
258        /// Machine-readable reason.
259        reason: WcetDecline,
260        /// Human-readable note (`reason.note()`).
261        note: String,
262        /// Hints that were offered for this function and rejected.
263        #[serde(default, skip_serializing_if = "Vec::is_empty")]
264        hint_rejections: Vec<WcetHintRejection>,
265    },
266}
267
268impl WcetFunction {
269    /// Construct a decline, filling in the note from the reason.
270    pub fn declined(name: impl Into<String>, reason: WcetDecline) -> Self {
271        let note = reason.note().to_string();
272        WcetFunction::Declined {
273            name: name.into(),
274            reason,
275            note,
276            hint_rejections: Vec::new(),
277        }
278    }
279
280    /// Construct a decline carrying rejected-hint records.
281    pub fn declined_with_rejections(
282        name: impl Into<String>,
283        reason: WcetDecline,
284        hint_rejections: Vec<WcetHintRejection>,
285    ) -> Self {
286        let note = reason.note().to_string();
287        WcetFunction::Declined {
288            name: name.into(),
289            reason,
290            note,
291            hint_rejections,
292        }
293    }
294}
295
296/// The parsed `--wcet-hints` file (`synth-wcet-hints-v1`) — an UNTRUSTED oracle
297/// input (#778 phase 2, the scry integration seam). Per function, an ordered
298/// array of claimed loop-trip-count upper bounds, matched to loops by ascending
299/// head offset (entry N = N-th loop head in the function; `null` skips a loop).
300/// Every entry is soundly CHECKED before use: synth re-derives the loop's trip
301/// count from its own induction proof and consumes the hint only when the
302/// derived count is ≤ the hint. A wrong or unverifiable hint is rejected with a
303/// machine reason ([`WcetHintReject`]) — never trusted into a bound.
304#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
305pub struct WcetHints {
306    /// Must equal [`HINTS_SCHEMA`].
307    pub schema: String,
308    /// Per-function hint arrays, keyed by the compiled function name.
309    #[serde(default)]
310    pub functions: std::collections::BTreeMap<String, WcetFunctionHints>,
311}
312
313/// Per-function loop-bound hints.
314#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
315pub struct WcetFunctionHints {
316    /// Claimed trip-count upper bounds, one per loop in ascending-head-offset
317    /// order; `null` leaves that loop unhinted.
318    #[serde(default)]
319    pub loop_bounds: Vec<Option<u64>>,
320}
321
322/// The full `synth-wcet-v1` sidecar: schema header, precondition, and per-function
323/// bounds/declines.
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325pub struct WcetReport {
326    /// Schema version (`synth-wcet-v1`).
327    pub schema: String,
328    /// The compiled module name (for diagnostics).
329    pub module: String,
330    /// The core class the cycle table is written for (e.g. `"cortex-m4"`). The
331    /// bound is CONDITIONAL on this core.
332    pub core_class: String,
333    /// Assumed instruction-memory wait states (0 for the sound zero-wait table).
334    pub wait_states: u32,
335    /// Human statement of the memory precondition the bound holds under.
336    pub memory_assumption: String,
337    /// Per-function bound or decline. Complete: one entry per compiled function.
338    pub functions: Vec<WcetFunction>,
339}
340
341impl WcetReport {
342    /// Start an empty report for `module`, targeting `core_class` under the sound
343    /// zero-wait precondition.
344    pub fn new(module: impl Into<String>, core_class: impl Into<String>) -> Self {
345        WcetReport {
346            schema: SCHEMA.to_string(),
347            module: module.into(),
348            core_class: core_class.into(),
349            wait_states: 0,
350            memory_assumption:
351                "zero-wait-state instruction memory (flash accelerator / I-cache hit); \
352                 in-order single-issue pipeline; documented per-instruction worst-case cycles"
353                    .to_string(),
354            functions: Vec::new(),
355        }
356    }
357
358    /// Serialize to pretty JSON.
359    pub fn to_json(&self) -> serde_json::Result<String> {
360        serde_json::to_string_pretty(self)
361    }
362
363    /// Resolve the sidecar path (`<output>.wcet.json`) next to the ELF output.
364    pub fn sidecar_path(output: &std::path::Path) -> std::path::PathBuf {
365        let mut s = output.as_os_str().to_os_string();
366        s.push(".wcet.json");
367        std::path::PathBuf::from(s)
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn bounded_and_declined_roundtrip() {
377        let mut r = WcetReport::new("m", "cortex-m4");
378        r.functions.push(WcetFunction::Bounded {
379            name: "leaf".into(),
380            cycles: 42,
381            instr_count: 7,
382            loops: Vec::new(),
383            hint_rejections: Vec::new(),
384        });
385        r.functions
386            .push(WcetFunction::declined("spins", WcetDecline::Loop));
387        let json = r.to_json().unwrap();
388        let back: WcetReport = serde_json::from_str(&json).unwrap();
389        assert_eq!(r, back);
390        // Decline reason is machine-readable and carries a note.
391        assert!(json.contains("\"reason\": \"loop\""));
392        assert!(json.contains("synth-wcet-v1"));
393    }
394
395    #[test]
396    fn sidecar_path_appends_suffix() {
397        let p = WcetReport::sidecar_path(std::path::Path::new("out/app.elf"));
398        assert_eq!(p, std::path::PathBuf::from("out/app.elf.wcet.json"));
399    }
400}