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 DIRECT call (`Bl func_N`) that could not be composed into a bound because
82    /// the callee is unbounded or unresolvable in THIS module — an external/imported
83    /// callee (a WASM import, `__meld_dispatch_import`, or an `__aeabi_*` runtime
84    /// helper: it has no per-function body in this module to sum). #778 phase 3
85    /// composes direct calls to LOCAL, bounded callees over the direct call graph;
86    /// this reason remains for the direct edges that cannot be composed.
87    Call,
88    /// A cycle in the direct call graph (self-recursion or mutual recursion). An
89    /// upper cycle bound cannot be composed from a call graph that revisits a frame
90    /// an unbounded number of times, so every function on the cycle DECLINES. This
91    /// is the #778 phase-3 decline-honesty guard: composition only bounds an acyclic
92    /// direct call graph.
93    Recursion,
94    /// An INDIRECT call (`Blx <reg>` / `call_indirect` / a function-pointer
95    /// dispatch such as `__meld_dispatch_import`): the callee is not statically
96    /// known, so its bound cannot be composed. Declined, not guessed. (#778 phase 3.)
97    IndirectCall,
98    /// A caller whose own body is bounded but that DIRECTLY calls a callee which
99    /// itself declined (transitively): a decline must PROPAGATE up the call graph —
100    /// a caller cannot be bounded while a callee it invokes is unbounded. (#778
101    /// phase 3.) The `note()` names the first unbounded callee for diagnosis.
102    CalleeUnbounded,
103    /// A residual/external label branch (`B`/`Bcc`/… still carrying a label): its
104    /// direction is not statically known here, so it cannot be proven loop-free.
105    UnresolvedBranch,
106    /// An op whose encoder expansion contains an internal RUNTIME loop (the `i64`
107    /// software div/rem shift-subtract: emitted once but executed 64×). Its body
108    /// bytes appear once in the stream, so a straight sum would undercount — a
109    /// sound bound needs a per-op `trip × body` model, a named follow-up.
110    LoopedExpansion,
111    /// The target core class is not soundly summable with a zero-wait per-op table
112    /// (Cortex-M7/M7dp: dual-issue + cache wait-states). Declined, not
113    /// approximated.
114    UnsupportedCore,
115    /// An op the cycle model has not classified.
116    ///
117    /// This comment used to claim the variant was "never emitted in a released
118    /// build (the classifier is exhaustive with no wildcard)". That conflated
119    /// two different things and was FALSE: `op_cost` has no wildcard arm, so it
120    /// is exhaustive in the *compiler's* sense, but a large number of its arms
121    /// return `Unmodeled` deliberately — every `i64` pseudo-op (`I64Add`,
122    /// `I64Const`, `I64Ldr`, `I64Str`, `I64ExtendI32S/U`, `I32WrapI64`, the i64
123    /// compares) and the whole MVE/Helium f32 vector family. Exhaustive over
124    /// variants is not the same as costed for every variant.
125    ///
126    /// gale hit it immediately (#921): 9 of 31 functions on a real object, the
127    /// second-largest decline category, clustered in time/timer code.
128    ///
129    /// WHICH op that is, we could not say from here — reproducing gale's object
130    /// needs meld + loom + the composite. That inability IS the issue. Locally
131    /// `i64.load` reproduces the decline (`I64Ldr`), while `i64.add`,
132    /// `i64.ge_s` and `i64.extend_i32_u` all come out BOUNDED because the
133    /// selector expands them before the WCET pass sees them — so "it will be
134    /// the i64 family" was a guess worth not shipping. The `op` field is what
135    /// answers it, on gale's object rather than by inference from ours.
136    ///
137    /// The decline now names the OP and its BYTE OFFSET (see the `op`/`offset`
138    /// fields on [`WcetFunction::Declined`]) so a consumer gets a bounded
139    /// request against the cycle model instead of a 31-function bisect.
140    UnmodeledOp,
141}
142
143impl WcetDecline {
144    /// A short human-readable explanation, embedded alongside the machine reason.
145    pub fn note(&self) -> &'static str {
146        match self {
147            WcetDecline::Loop => {
148                "backward branch (loop) without a statically-proven trip count — \
149                 canonical const-bound counted loops are proven automatically; \
150                 equality-exit shapes need a verified --wcet-hints entry; \
151                 data-dependent bounds are the scry loop-bound-inference follow-up"
152            }
153            WcetDecline::Call => {
154                "direct call to an external/imported/unresolvable callee with no \
155                 per-function bound in this module — cannot compose an \
156                 inter-procedural bound (local direct calls ARE composed, #778 phase 3)"
157            }
158            WcetDecline::Recursion => {
159                "cycle in the direct call graph (self- or mutual recursion) — an \
160                 upper cycle bound cannot be composed from a recursive call graph"
161            }
162            WcetDecline::IndirectCall => {
163                "indirect call (Blx <reg> / call_indirect / function-pointer \
164                 dispatch) — the callee is not statically known, cannot compose"
165            }
166            WcetDecline::CalleeUnbounded => {
167                "a directly-called callee is itself unbounded — the decline \
168                 propagates up the call graph (a caller cannot be bounded while a \
169                 callee it invokes is unbounded)"
170            }
171            WcetDecline::UnresolvedBranch => {
172                "residual external/unresolved label branch — direction not \
173                 statically known, cannot prove loop-free"
174            }
175            WcetDecline::LoopedExpansion => {
176                "op expands to an internal runtime loop (i64 software div/rem, \
177                 executed 64×) — straight sum would undercount"
178            }
179            WcetDecline::UnsupportedCore => {
180                "core class not soundly summable with a zero-wait per-op table \
181                 (Cortex-M7 dual-issue + cache wait-states)"
182            }
183            WcetDecline::UnmodeledOp => "op not classified by the cycle model",
184        }
185    }
186}
187
188/// How a loop's trip count was established (#778 phase 2).
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
190#[serde(rename_all = "kebab-case")]
191pub enum WcetLoopBoundSource {
192    /// Fully static proof: const-initialized counter, const step, const bound,
193    /// exit-guaranteeing comparison — the trip count is derived by synth alone.
194    Static,
195    /// The loop is an equality-exit shape synth only bounds under an explicit
196    /// `--wcet-hints` assertion; the hint was CHECKED against synth's own derived
197    /// trip count (divisibility + monotonicity + derived ≤ hint) before use. The
198    /// emitted trip count is still synth's DERIVED value, never the raw hint.
199    HintVerified,
200    /// (#778 phase 5) The loop's exit bound is a DATA-DEPENDENT masked ceiling
201    /// (`i REL (x & K)` for a runtime `x`): the real per-iteration bound lies in
202    /// `[0, K]` for ANY input (`x & K ∈ [0,K]`), so synth DERIVES the worst-case
203    /// trip as the MAX over both endpoints of that interval (`rhs = K` and
204    /// `rhs = 0`, both required to terminate) — an entry-independent ceiling.
205    /// Like [`HintVerified`] this is HINT-GATED: the derived trip is consumed
206    /// only under an explicit `--wcet-hints` entry the derived count respects
207    /// (`derived ≤ hint`); the emitted trip is synth's DERIVED value, never the
208    /// raw hint. A distinct source (not `HintVerified`) so the sidecar states the
209    /// extra data-dependent-ceiling assumption the bound rests on.
210    MaskCeiling,
211}
212
213/// One proven-bounded loop inside a bounded function (#778 phase 2). Loops are
214/// listed in ascending `head_offset` order — the SAME order `--wcet-hints`
215/// `loop_bounds` entries are matched by.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct WcetLoopBound {
218    /// Byte offset of the loop head (backward-branch target) within the function.
219    pub head_offset: u64,
220    /// The PROVEN maximum number of body executions (full iterations).
221    pub trip_count: u64,
222    /// Number of instructions inside the loop region (head..=backward branch),
223    /// so a consumer can cross-check `cycles ≥ trip_count × region_instr_count`
224    /// (every instruction costs ≥ 1 cycle).
225    pub region_instr_count: usize,
226    /// How the trip count was established.
227    pub source: WcetLoopBoundSource,
228    /// The hint value consumed (present iff `source == HintVerified` or a
229    /// redundant hint was cross-checked against a static proof).
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub hint: Option<u64>,
232}
233
234/// Machine-readable reason a `--wcet-hints` entry was REJECTED (#778 phase 2).
235/// The hint file is UNTRUSTED input: a hint is only ever consumed after synth
236/// verifies the loop's induction against it; everything else lands here.
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(rename_all = "kebab-case")]
239pub enum WcetHintReject {
240    /// The hint is SMALLER than synth's own derived trip count — a wrong hint.
241    /// Trusting it would emit a bound < a real execution (the fatal class).
242    HintBelowDerivedTrip,
243    /// synth could not verify the loop's induction against the hint (counter not
244    /// provably monotonic toward a statically-known bound ≤ hint — e.g. a
245    /// data-dependent bound register, a non-canonical shape, or an equality exit
246    /// whose step does not divide the distance). An unverifiable hint is never
247    /// trusted into a bound.
248    HintUnverifiableInduction,
249    /// The hint indexes a loop that does not exist in this function's final
250    /// instruction stream.
251    HintUnknownLoop,
252    /// A recursion-depth hint (`recursion_depth`) was offered but synth could NOT
253    /// verify the self-recursion is a single-self-call chain whose controlling
254    /// value is entry-independently bounded (a masked-slot counter decreasing by a
255    /// const step toward a base guard on the SAME masked quantity). Without an
256    /// entry-independent ceiling the true depth is runtime-unbounded, so the hint
257    /// is never trusted into a bound. (#778 phase 4 / #49.)
258    HintUnverifiableRecursion,
259    /// A recursion-depth hint is SMALLER than synth's own DERIVED maximum depth
260    /// (the entry-independent ceiling proven from the masked-slot induction). A
261    /// hint below the derived depth is a wrong oracle claim — trusting it would
262    /// emit a bound < a real execution (the fatal class). (#778 phase 4 / #49.)
263    HintBelowDerivedDepth,
264}
265
266impl WcetHintReject {
267    /// A short human-readable explanation, embedded alongside the machine reason.
268    pub fn note(&self) -> &'static str {
269        match self {
270            WcetHintReject::HintBelowDerivedTrip => {
271                "hint is below synth's derived trip count — a wrong hint; \
272                 trusting it would emit an unsound bound"
273            }
274            WcetHintReject::HintUnverifiableInduction => {
275                "loop induction not verifiable against the hint (counter not \
276                 provably monotonic toward a statically-known bound ≤ hint) — \
277                 an unverifiable hint is never trusted into a bound"
278            }
279            WcetHintReject::HintUnknownLoop => {
280                "hint indexes a loop that does not exist in the final \
281                 instruction stream"
282            }
283            WcetHintReject::HintUnverifiableRecursion => {
284                "recursion-depth hint not verifiable — the self-recursion is not a \
285                 single-self-call chain whose controlling value is entry-independently \
286                 bounded (masked-slot counter decreasing by a const step toward a base \
287                 guard on the same masked quantity); depth is runtime-unbounded, so \
288                 the hint is never trusted into a bound"
289            }
290            WcetHintReject::HintBelowDerivedDepth => {
291                "recursion-depth hint is below synth's derived maximum depth (the \
292                 entry-independent ceiling proven from the masked-slot induction) — \
293                 a wrong hint; trusting it would emit an unsound bound"
294            }
295        }
296    }
297}
298
299/// One rejected hint, recorded in the sidecar so the oracle (scry) sees exactly
300/// which of its claims synth refused and why.
301#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
302pub struct WcetHintRejection {
303    /// Index into the function's `loop_bounds` hint array (== loop order by
304    /// ascending head offset).
305    pub loop_index: usize,
306    /// Byte offset of the loop head this hint addressed, when the loop exists.
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub head_offset: Option<u64>,
309    /// The rejected hint value.
310    pub hint: u64,
311    /// Machine-readable rejection reason.
312    pub reason: WcetHintReject,
313    /// Human-readable note (`reason.note()`).
314    pub note: String,
315}
316
317/// (#778 phase 4 / #49) The self-recursion record carried on a bounded function
318/// whose bound was composed via a verified recursion-depth certificate, so the
319/// sidecar states exactly how the frame count was established (and that a hint gated
320/// it — the derived depth is still synth's own).
321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
322pub struct WcetRecursionBound {
323    /// The DERIVED maximum recursion depth (entry-independent ceiling).
324    pub max_depth: u64,
325    /// The number of frames folded into the bound (`max_depth + 1`, counting the
326    /// base frame). Diagnostic — lets a consumer cross-check `cycles ≥ frames`.
327    pub frame_count: u64,
328    /// The `--wcet-hints` `recursion_depth` value that gated the certificate (the
329    /// emitted `max_depth` is synth's DERIVED value, never this raw hint).
330    pub hint: u64,
331}
332
333/// The per-function result: either a sound cycle bound or a loud decline.
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(tag = "status", rename_all = "kebab-case")]
336pub enum WcetFunction {
337    /// A sound upper bound on this function's execution in cycles.
338    Bounded {
339        /// Function name (WASM export or generated).
340        name: String,
341        /// The sound worst-case cycle bound. For a loop-free function this is the
342        /// SUM of each instruction's documented worst-case cycles (each executes
343        /// at most once). For a function whose loops ALL have proven trip counts
344        /// (#778 phase 2) each instruction's cost is multiplied by its proven
345        /// worst-case execution count. Always ≥ any real execution under the
346        /// stated precondition.
347        cycles: u64,
348        /// Number of ARM instructions summed (diagnostic).
349        instr_count: usize,
350        /// Proven loops (empty for a loop-free function), ascending head offset.
351        #[serde(default, skip_serializing_if = "Vec::is_empty")]
352        loops: Vec<WcetLoopBound>,
353        /// (#778 phase 4 / #49) Present iff this bound was composed via a verified
354        /// self-recursion certificate; states the derived depth + frame count.
355        #[serde(default, skip_serializing_if = "Option::is_none")]
356        recursion: Option<WcetRecursionBound>,
357        /// Hints that were rejected (the static proof stands independently).
358        #[serde(default, skip_serializing_if = "Vec::is_empty")]
359        hint_rejections: Vec<WcetHintRejection>,
360    },
361    /// No bound emitted — a loud decline with a machine-readable reason. A decline
362    /// is emitted (rather than the function omitted) so the map is COMPLETE: a
363    /// consumer sees every function is either bounded or explicitly unbounded,
364    /// never silently missing.
365    Declined {
366        /// Function name.
367        name: String,
368        /// Machine-readable reason.
369        reason: WcetDecline,
370        /// Human-readable note (`reason.note()`).
371        note: String,
372        /// (#921) The op that caused the decline, as its `ArmOp` variant name
373        /// (`I64Add`, `MveDivF32`, …). Emitted for `unmodeled-op`, where the
374        /// reason alone left a consumer nothing to act on but a hand-bisect.
375        ///
376        /// ADDITIVE and optional: absent for every other reason, and absent
377        /// when it cannot be determined, so existing consumers are unaffected.
378        #[serde(default, skip_serializing_if = "Option::is_none")]
379        op: Option<String>,
380        /// (#921) Byte offset of that op within the function, from the REAL
381        /// encoder — the same source of truth `WcetLoopBound::head_offset`
382        /// uses, so the two are cross-referenceable in one disassembly.
383        ///
384        /// `None` when any preceding op is one the encoder refuses: an offset
385        /// that cannot be computed is OMITTED, never approximated, because a
386        /// wrong offset sends a consumer to the wrong instruction.
387        #[serde(default, skip_serializing_if = "Option::is_none")]
388        offset: Option<u64>,
389        /// Hints that were offered for this function and rejected.
390        #[serde(default, skip_serializing_if = "Vec::is_empty")]
391        hint_rejections: Vec<WcetHintRejection>,
392    },
393}
394
395impl WcetFunction {
396    /// Construct a decline, filling in the note from the reason.
397    pub fn declined(name: impl Into<String>, reason: WcetDecline) -> Self {
398        let note = reason.note().to_string();
399        WcetFunction::Declined {
400            name: name.into(),
401            reason,
402            note,
403            op: None,
404            offset: None,
405            hint_rejections: Vec::new(),
406        }
407    }
408
409    /// (#921) Construct a decline that NAMES the offending op and its byte
410    /// offset. Used for `unmodeled-op`, whose reason string alone left a
411    /// consumer with nothing to act on but a hand-bisect of the whole object.
412    ///
413    /// `offset` is `None` when the byte position could not be computed from the
414    /// real encoder; the op name is still emitted, because "which instruction"
415    /// is the actionable half even without "where".
416    pub fn declined_at(
417        name: impl Into<String>,
418        reason: WcetDecline,
419        op: impl Into<String>,
420        offset: Option<u64>,
421    ) -> Self {
422        let note = reason.note().to_string();
423        WcetFunction::Declined {
424            name: name.into(),
425            reason,
426            note,
427            op: Some(op.into()),
428            offset,
429            hint_rejections: Vec::new(),
430        }
431    }
432
433    /// Construct a decline carrying rejected-hint records.
434    pub fn declined_with_rejections(
435        name: impl Into<String>,
436        reason: WcetDecline,
437        hint_rejections: Vec<WcetHintRejection>,
438    ) -> Self {
439        let note = reason.note().to_string();
440        WcetFunction::Declined {
441            name: name.into(),
442            reason,
443            note,
444            op: None,
445            offset: None,
446            hint_rejections,
447        }
448    }
449}
450
451/// (#778 phase 4 / #49) A proven SELF-recursion certificate: the function is a
452/// single-self-call chain whose controlling value is entry-independently bounded
453/// (a masked-slot counter decreasing by a const step toward a base guard on the
454/// SAME masked quantity), so its maximum recursion DEPTH is DERIVED (not
455/// hint-supplied) as an entry-independent ceiling. The composer folds the self-edge
456/// as `frame_count × frame_cost` (`frame_count = max_depth + 1`, counting the base
457/// frame) instead of declining `Recursion`.
458///
459/// A certificate is attached ONLY after the depth was cross-checked against a
460/// `--wcet-hints` `recursion_depth` entry (the untrusted oracle asserts intent;
461/// synth's derived ceiling is what is emitted). Without a hint the recursion still
462/// declines (a bound this consequential is opt-in, mirroring the equality-exit
463/// loop-hint gate). `self_label` is the function's own `func_<idx>` self-call label
464/// so the composer can identify and special-case exactly that edge.
465#[derive(Debug, Clone, PartialEq, Eq)]
466pub struct WcetRecursionCert {
467    /// The self-call `BL` label (`func_<idx>`) this certificate authorizes.
468    pub self_label: String,
469    /// The DERIVED maximum recursion depth (entry-independent ceiling). The base
470    /// frame is NOT included here — the composer uses `max_depth + 1` frames.
471    pub max_depth: u64,
472    /// The hint value that gated this certificate (recorded for the sidecar; the
473    /// emitted depth is always the derived `max_depth`, never the raw hint).
474    pub hint: u64,
475}
476
477/// One direct call site inside a composable function (#778 phase 3). Records the
478/// callee's `BL` label (`func_<idx>` for a local/relocatable-import call) and the
479/// per-instruction execution-count multiplier of the `BL` (1 outside any loop; the
480/// enclosing loop's proven trip product when the call sits inside a proven counted
481/// loop, so a call in a loop is counted `trip` times, never once).
482#[derive(Debug, Clone, PartialEq, Eq)]
483pub struct WcetCallSite {
484    /// The `BL` target label as emitted by the selector (`func_<wasm_index>` for a
485    /// direct local/import call; any other label is a runtime helper → external).
486    pub callee_label: String,
487    /// The call site's worst-case execution count (product of enclosing proven loop
488    /// trip factors; 1 outside any loop). `u128` to survive deep nesting without
489    /// wrapping, matching the loop-multiplier domain.
490    pub multiplier: u128,
491}
492
493/// The per-function INTERMEDIATE result of the WCET pass BEFORE inter-procedural
494/// composition (#778 phase 3). The backend produces one of these per function; the
495/// module-level composer ([`crate::wcet`] consumers call `synth_backend::wcet_compose`)
496/// resolves each function's direct call sites against the whole module and emits the
497/// final [`WcetFunction`] (a composed bound, or a propagated/recursion/indirect
498/// decline).
499///
500/// Splitting the pass in two keeps composition a PURE function over already-decided
501/// per-function facts: `own_cycles` already prices every non-call instruction
502/// (including each `BL`'s branch overhead) at its proven execution count, so the
503/// composed total is `own_cycles + Σ_site multiplier_site × callee_total` — the
504/// per-site multiplier makes a call inside a proven loop sound by construction.
505/// (#921) Where a decline happened: which op, and where in the function.
506/// Travels through the intermediate so composition can carry it to the sidecar.
507#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
508pub struct WcetDeclineSite {
509    /// `ArmOp` variant name — `I64Add`, `MveDivF32`, …
510    pub op: String,
511    /// Byte offset within the function; `None` when it could not be computed
512    /// from the real encoder (omitted rather than estimated).
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub offset: Option<u64>,
515}
516
517#[derive(Debug, Clone, PartialEq, Eq)]
518pub enum WcetIntermediate {
519    /// The function declines for a reason INDEPENDENT of composition (an unproven
520    /// loop, an internal looped expansion, an unsupported core, an unresolved label
521    /// branch, an indirect call, or an unmodeled op). Carried straight through to a
522    /// [`WcetFunction::Declined`]; composition never rescues these.
523    Declined {
524        /// (#921) The op that caused the decline, when it names one.
525        site: Option<WcetDeclineSite>,
526        name: String,
527        reason: WcetDecline,
528        hint_rejections: Vec<WcetHintRejection>,
529    },
530    /// The function's own body is bounded; its final bound depends only on resolving
531    /// the recorded direct call sites against the module's other functions.
532    Composable {
533        name: String,
534        /// The summed worst-case cost of every instruction in the final stream
535        /// (each priced at its documented worst case × its proven execution-count
536        /// multiplier), INCLUDING each direct `BL`'s branch overhead. The callee
537        /// bodies are added by the composer via `call_sites`.
538        own_cycles: u64,
539        /// Number of ARM instructions summed (diagnostic, carried to the bound).
540        instr_count: usize,
541        /// The direct call sites to resolve at compose time.
542        call_sites: Vec<WcetCallSite>,
543        /// Proven loops inside this function (carried to the bound unchanged).
544        loops: Vec<WcetLoopBound>,
545        /// (#778 phase 4 / #49) A proven self-recursion certificate, when this
546        /// function is a bounded single-self-call chain with a verified depth hint.
547        /// The composer folds the self-edge as `(max_depth+1) × frame_cost` instead
548        /// of declining `Recursion`. `None` for a non-recursive function or an
549        /// unverifiable/unhinted recursion (which still declines).
550        recursion_cert: Option<WcetRecursionCert>,
551        /// Hints rejected while analyzing this function (carried to the bound).
552        hint_rejections: Vec<WcetHintRejection>,
553    },
554}
555
556impl WcetIntermediate {
557    /// The compiled function name this intermediate is for.
558    pub fn name(&self) -> &str {
559        match self {
560            WcetIntermediate::Declined { name, .. } | WcetIntermediate::Composable { name, .. } => {
561                name
562            }
563        }
564    }
565}
566
567/// The parsed `--wcet-hints` file (`synth-wcet-hints-v1`) — an UNTRUSTED oracle
568/// input (#778 phase 2, the scry integration seam). Per function, an ordered
569/// array of claimed loop-trip-count upper bounds, matched to loops by ascending
570/// head offset (entry N = N-th loop head in the function; `null` skips a loop).
571/// Every entry is soundly CHECKED before use: synth re-derives the loop's trip
572/// count from its own induction proof and consumes the hint only when the
573/// derived count is ≤ the hint. A wrong or unverifiable hint is rejected with a
574/// machine reason ([`WcetHintReject`]) — never trusted into a bound.
575#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
576pub struct WcetHints {
577    /// Must equal [`HINTS_SCHEMA`].
578    pub schema: String,
579    /// Per-function hint arrays, keyed by the compiled function name.
580    #[serde(default)]
581    pub functions: std::collections::BTreeMap<String, WcetFunctionHints>,
582}
583
584/// Per-function loop-bound hints.
585#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
586pub struct WcetFunctionHints {
587    /// Claimed trip-count upper bounds, one per loop in ascending-head-offset
588    /// order; `null` leaves that loop unhinted.
589    #[serde(default)]
590    pub loop_bounds: Vec<Option<u64>>,
591    /// (#778 phase 4 / #49) An UNTRUSTED claimed maximum SELF-recursion depth for
592    /// this function. Consulted only when synth has proven the function is a
593    /// single-self-call chain whose controlling value is entry-independently bounded
594    /// (a masked-slot counter): synth then DERIVES its own maximum depth from the
595    /// mask+step+base induction and cross-checks this hint (`hint < derived` →
596    /// `hint-below-derived-depth`). A hint on a function whose recursion synth cannot
597    /// so verify is REJECTED (`hint-unverifiable-recursion`) and never trusted. The
598    /// emitted bound always uses synth's DERIVED depth, never the raw hint.
599    #[serde(default, skip_serializing_if = "Option::is_none")]
600    pub recursion_depth: Option<u64>,
601}
602
603/// The full `synth-wcet-v1` sidecar: schema header, precondition, and per-function
604/// bounds/declines.
605#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
606pub struct WcetReport {
607    /// Schema version (`synth-wcet-v1`).
608    pub schema: String,
609    /// The compiled module name (for diagnostics).
610    pub module: String,
611    /// The core class the cycle table is written for (e.g. `"cortex-m4"`). The
612    /// bound is CONDITIONAL on this core.
613    pub core_class: String,
614    /// Assumed instruction-memory wait states (0 for the sound zero-wait table).
615    pub wait_states: u32,
616    /// Human statement of the memory precondition the bound holds under.
617    pub memory_assumption: String,
618    /// Per-function bound or decline. Complete: one entry per compiled function.
619    pub functions: Vec<WcetFunction>,
620}
621
622impl WcetReport {
623    /// Start an empty report for `module`, targeting `core_class` under the sound
624    /// zero-wait precondition.
625    pub fn new(module: impl Into<String>, core_class: impl Into<String>) -> Self {
626        WcetReport {
627            schema: SCHEMA.to_string(),
628            module: module.into(),
629            core_class: core_class.into(),
630            wait_states: 0,
631            memory_assumption:
632                "zero-wait-state instruction memory (flash accelerator / I-cache hit); \
633                 in-order single-issue pipeline; documented per-instruction worst-case cycles"
634                    .to_string(),
635            functions: Vec::new(),
636        }
637    }
638
639    /// Serialize to pretty JSON.
640    pub fn to_json(&self) -> serde_json::Result<String> {
641        serde_json::to_string_pretty(self)
642    }
643
644    /// Resolve the sidecar path (`<output>.wcet.json`) next to the ELF output.
645    pub fn sidecar_path(output: &std::path::Path) -> std::path::PathBuf {
646        let mut s = output.as_os_str().to_os_string();
647        s.push(".wcet.json");
648        std::path::PathBuf::from(s)
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    #[test]
657    fn bounded_and_declined_roundtrip() {
658        let mut r = WcetReport::new("m", "cortex-m4");
659        r.functions.push(WcetFunction::Bounded {
660            name: "leaf".into(),
661            cycles: 42,
662            instr_count: 7,
663            loops: Vec::new(),
664            recursion: None,
665            hint_rejections: Vec::new(),
666        });
667        r.functions
668            .push(WcetFunction::declined("spins", WcetDecline::Loop));
669        let json = r.to_json().unwrap();
670        let back: WcetReport = serde_json::from_str(&json).unwrap();
671        assert_eq!(r, back);
672        // Decline reason is machine-readable and carries a note.
673        assert!(json.contains("\"reason\": \"loop\""));
674        assert!(json.contains("synth-wcet-v1"));
675    }
676
677    #[test]
678    fn sidecar_path_appends_suffix() {
679        let p = WcetReport::sidecar_path(std::path::Path::new("out/app.elf"));
680        assert_eq!(p, std::path::PathBuf::from("out/app.elf.wcet.json"));
681    }
682}