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/// (#1063) The durable per-function hint-key contract, emitted in the sidecar so
334/// a consumer joins against the key `--wcet-hints` will actually accept instead
335/// of re-deriving it from mangled symbols (a by-hand copy of a shipped
336/// decision — the drift class this project removes, not adds).
337///
338/// `key` is chosen by [`assign_hint_keys`], in priority order: the export name;
339/// else the `name`-section name with its non-content-derived mangling components
340/// stripped ([`stable_name_key`]) when that stripped form is unique in the
341/// module; else the raw `name`-section name; else `func_<index>` as the last
342/// resort. `build_local` is scry#137's flag: `true` means the key is NOT
343/// expected to survive an unrelated rebuild (a raw mangled name still carrying
344/// its crate disambiguator, or a bare index), so a hints file keyed on it must
345/// be regenerated per build.
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
347pub struct WcetHintKey {
348    /// The canonical key a `--wcet-hints` entry addresses this function by.
349    pub key: String,
350    /// `true` when the key churns across rebuilds (raw disambiguated mangling,
351    /// or an index): usable only against the build that emitted this sidecar.
352    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
353    pub build_local: bool,
354}
355
356/// The per-function result: either a sound cycle bound or a loud decline.
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(tag = "status", rename_all = "kebab-case")]
359pub enum WcetFunction {
360    /// A sound upper bound on this function's execution in cycles.
361    Bounded {
362        /// Function name (WASM export or generated).
363        name: String,
364        /// The sound worst-case cycle bound. For a loop-free function this is the
365        /// SUM of each instruction's documented worst-case cycles (each executes
366        /// at most once). For a function whose loops ALL have proven trip counts
367        /// (#778 phase 2) each instruction's cost is multiplied by its proven
368        /// worst-case execution count. Always ≥ any real execution under the
369        /// stated precondition.
370        cycles: u64,
371        /// Number of ARM instructions summed (diagnostic).
372        instr_count: usize,
373        /// Proven loops (empty for a loop-free function), ascending head offset.
374        #[serde(default, skip_serializing_if = "Vec::is_empty")]
375        loops: Vec<WcetLoopBound>,
376        /// (#778 phase 4 / #49) Present iff this bound was composed via a verified
377        /// self-recursion certificate; states the derived depth + frame count.
378        #[serde(default, skip_serializing_if = "Option::is_none")]
379        recursion: Option<WcetRecursionBound>,
380        /// Hints that were rejected (the static proof stands independently).
381        #[serde(default, skip_serializing_if = "Vec::is_empty")]
382        hint_rejections: Vec<WcetHintRejection>,
383        /// (#1063) The key `--wcet-hints` matches this function on, plus its
384        /// build-locality — filled by the module driver, absent on sidecars
385        /// predating the field (additive).
386        #[serde(default, skip_serializing_if = "Option::is_none")]
387        hint_key: Option<WcetHintKey>,
388    },
389    /// No bound emitted — a loud decline with a machine-readable reason. A decline
390    /// is emitted (rather than the function omitted) so the map is COMPLETE: a
391    /// consumer sees every function is either bounded or explicitly unbounded,
392    /// never silently missing.
393    Declined {
394        /// Function name.
395        name: String,
396        /// Machine-readable reason.
397        reason: WcetDecline,
398        /// Human-readable note (`reason.note()`).
399        note: String,
400        /// (#921) The op that caused the decline, as its `ArmOp` variant name
401        /// (`I64Add`, `MveDivF32`, …). Emitted for `unmodeled-op`, where the
402        /// reason alone left a consumer nothing to act on but a hand-bisect.
403        ///
404        /// ADDITIVE and optional: absent for every other reason, and absent
405        /// when it cannot be determined, so existing consumers are unaffected.
406        #[serde(default, skip_serializing_if = "Option::is_none")]
407        op: Option<String>,
408        /// (#921) Byte offset of that op within the function, from the REAL
409        /// encoder — the same source of truth `WcetLoopBound::head_offset`
410        /// uses, so the two are cross-referenceable in one disassembly.
411        ///
412        /// `None` when any preceding op is one the encoder refuses: an offset
413        /// that cannot be computed is OMITTED, never approximated, because a
414        /// wrong offset sends a consumer to the wrong instruction.
415        #[serde(default, skip_serializing_if = "Option::is_none")]
416        offset: Option<u64>,
417        /// Hints that were offered for this function and rejected.
418        #[serde(default, skip_serializing_if = "Vec::is_empty")]
419        hint_rejections: Vec<WcetHintRejection>,
420        /// (#1063) The key `--wcet-hints` matches this function on, plus its
421        /// build-locality — filled by the module driver, absent on sidecars
422        /// predating the field (additive).
423        #[serde(default, skip_serializing_if = "Option::is_none")]
424        hint_key: Option<WcetHintKey>,
425    },
426}
427
428impl WcetFunction {
429    /// Construct a decline, filling in the note from the reason.
430    pub fn declined(name: impl Into<String>, reason: WcetDecline) -> Self {
431        let note = reason.note().to_string();
432        WcetFunction::Declined {
433            name: name.into(),
434            reason,
435            note,
436            op: None,
437            offset: None,
438            hint_rejections: Vec::new(),
439            hint_key: None,
440        }
441    }
442
443    /// (#921) Construct a decline that NAMES the offending op and its byte
444    /// offset. Used for `unmodeled-op`, whose reason string alone left a
445    /// consumer with nothing to act on but a hand-bisect of the whole object.
446    ///
447    /// `offset` is `None` when the byte position could not be computed from the
448    /// real encoder; the op name is still emitted, because "which instruction"
449    /// is the actionable half even without "where".
450    pub fn declined_at(
451        name: impl Into<String>,
452        reason: WcetDecline,
453        op: impl Into<String>,
454        offset: Option<u64>,
455    ) -> Self {
456        let note = reason.note().to_string();
457        WcetFunction::Declined {
458            name: name.into(),
459            reason,
460            note,
461            op: Some(op.into()),
462            offset,
463            hint_rejections: Vec::new(),
464            hint_key: None,
465        }
466    }
467
468    /// Construct a decline carrying rejected-hint records.
469    pub fn declined_with_rejections(
470        name: impl Into<String>,
471        reason: WcetDecline,
472        hint_rejections: Vec<WcetHintRejection>,
473    ) -> Self {
474        let note = reason.note().to_string();
475        WcetFunction::Declined {
476            name: name.into(),
477            reason,
478            note,
479            op: None,
480            offset: None,
481            hint_rejections,
482            hint_key: None,
483        }
484    }
485
486    /// The function name this entry is keyed by in the sidecar.
487    pub fn name(&self) -> &str {
488        match self {
489            WcetFunction::Bounded { name, .. } | WcetFunction::Declined { name, .. } => name,
490        }
491    }
492
493    /// (#1063) Rewrite this entry to its durable identity: the display name plus
494    /// the hint-key contract the driver assigned. Called by the module driver
495    /// after composition (composition works in compile names, `func_<idx>` for
496    /// internal functions).
497    pub fn set_identity(&mut self, display_name: &str, key: &WcetHintKey) {
498        match self {
499            WcetFunction::Bounded { name, hint_key, .. }
500            | WcetFunction::Declined { name, hint_key, .. } => {
501                *name = display_name.to_string();
502                *hint_key = Some(key.clone());
503            }
504        }
505    }
506}
507
508/// (#1063) Derive the STABLE form of a `name`-section name — the part of the
509/// name that IS content-derived, with the components that churn per build
510/// stripped. Measured motivation (gale #1063 / scry#123): Rust v0 mangling
511/// carries a crate disambiguator (`Cs942N1ctoMYm_`) hashed from crate metadata
512/// (compiler version, feature flags, …) — NOT from the function's content — and
513/// 43–45 % of function identities churn per build for exactly this reason. A
514/// hints key that churns every build trades an unaddressable decline for an
515/// unreliable one, so the key strips:
516///
517/// - every v0 crate-root disambiguator `C s <base62>+ _` → `C` (scry#137 tier 1;
518///   local disambiguators like closures' `s_0` are source-order-derived and are
519///   KEPT — only the crate-metadata hash is stripped);
520/// - a legacy-mangling content hash suffix `17h<16 hex>E` → `E`, and its
521///   demangled form `::h<16 hex>` at the end of the name.
522///
523/// The scan is textual, not a full mangling parse: a pathological identifier
524/// that CONTAINS the pattern strips too. That is deliberate — both sides of the
525/// join (this function emitting `hint_key` and the author copying it from the
526/// sidecar) use the same derivation, so consistency is what matters; a
527/// pathological merge of two distinct names is caught by [`assign_hint_keys`]'s
528/// uniqueness check and demoted to a build-local raw key, never silently
529/// mis-keyed.
530pub fn stable_name_key(raw: &str) -> String {
531    // v0 mangling: crate-root disambiguator `C s <base62>+ _` → `C`.
532    let b = raw.as_bytes();
533    let mut out: Vec<u8> = Vec::with_capacity(b.len());
534    let mut i = 0;
535    while i < b.len() {
536        if b[i] == b'C' && i + 1 < b.len() && b[i + 1] == b's' {
537            let mut j = i + 2;
538            while j < b.len() && b[j].is_ascii_alphanumeric() {
539                j += 1;
540            }
541            if j > i + 2 && j < b.len() && b[j] == b'_' {
542                out.push(b'C');
543                i = j + 1;
544                continue;
545            }
546        }
547        out.push(b[i]);
548        i += 1;
549    }
550    // Only removed ASCII substrings above, so this cannot fail; the fallback is
551    // pure defense.
552    let mut out = String::from_utf8(out).unwrap_or_else(|_| raw.to_string());
553    if !out.is_ascii() {
554        return out;
555    }
556    // Legacy mangling: `…17h<16 hex>E` → `…E`.
557    if out.len() >= 20 && out.ends_with('E') {
558        let tail = &out[out.len() - 20..out.len() - 1];
559        if let Some(hex) = tail.strip_prefix("17h")
560            && hex.bytes().all(|c| c.is_ascii_hexdigit())
561        {
562            out.truncate(out.len() - 20);
563            out.push('E');
564            return out;
565        }
566    }
567    // Demangled legacy hash: trailing `::h<16 hex>`.
568    if out.len() >= 19 {
569        let tail = &out[out.len() - 19..];
570        if let Some(hex) = tail.strip_prefix("::h")
571            && hex.bytes().all(|c| c.is_ascii_hexdigit())
572        {
573            out.truncate(out.len() - 19);
574        }
575    }
576    out
577}
578
579/// (#1063) A compiled function's identity inputs, as the module driver knows
580/// them: full-index-space index, export name (if exported), and `name`-section
581/// name (if the module carries one for it).
582#[derive(Debug, Clone, PartialEq, Eq)]
583pub struct WcetFnIdentity {
584    /// Full function index (imports first — the space `func_<index>` names).
585    pub index: u32,
586    /// The export name, when the function is exported.
587    pub export_name: Option<String>,
588    /// The `name`-section name, when present (debug metadata, untrusted-benign).
589    pub debug_name: Option<String>,
590}
591
592/// (#1063) The assigned identity for one function: what the backend compiled it
593/// as, what the sidecar displays, the canonical hint key, and every key a
594/// `--wcet-hints` entry may address it by.
595#[derive(Debug, Clone, PartialEq, Eq)]
596pub struct WcetKeyAssignment {
597    /// The name the backend compiled under (export name, else `func_<index>`) —
598    /// the key composition and the resolved hints map work in.
599    pub compile_name: String,
600    /// The sidecar display name: export name, else the RAW `name`-section name
601    /// (so a consumer can join against symbols), else `func_<index>`.
602    pub display_name: String,
603    /// The canonical hint key + build-locality (see [`WcetHintKey`]).
604    pub hint_key: WcetHintKey,
605    /// Every key a hints entry may address this function by (the canonical key,
606    /// plus the raw `name`-section name when it is unambiguous). `func_<index>`
607    /// is deliberately NOT accepted for a function that carries a real name: an
608    /// index silently retargets when an unrelated edit renumbers the space,
609    /// which is worse than no key (#1063).
610    pub accepted_keys: Vec<String>,
611}
612
613/// (#1063) Assign every function its durable WCET identity. Key priority:
614/// export name → stripped `name`-section name (when unique module-wide and not
615/// shadowing an export) → raw `name`-section name (unique, not shadowing;
616/// build-local) → `func_<index>` (build-local last resort). Uniqueness is
617/// checked over ALL functions' candidate names so two functions can never be
618/// assigned the same stable key; residual cross-tier collisions are additionally
619/// rejected as ambiguous at resolution time ([`resolve_hint_keys`]), so an
620/// ambiguous key is never silently applied to the wrong function.
621pub fn assign_hint_keys(fns: &[WcetFnIdentity]) -> Vec<WcetKeyAssignment> {
622    use std::collections::{HashMap, HashSet};
623    let exports: HashSet<&str> = fns
624        .iter()
625        .filter_map(|f| f.export_name.as_deref())
626        .collect();
627    let mut stripped_counts: HashMap<String, usize> = HashMap::new();
628    let mut raw_counts: HashMap<&str, usize> = HashMap::new();
629    for f in fns {
630        if let Some(d) = f.debug_name.as_deref() {
631            *stripped_counts.entry(stable_name_key(d)).or_default() += 1;
632            *raw_counts.entry(d).or_default() += 1;
633        }
634    }
635    fns.iter()
636        .map(|f| {
637            let fallback = format!("func_{}", f.index);
638            let raw_ok = |d: &str| raw_counts.get(d).copied() == Some(1) && !exports.contains(d);
639            let (compile_name, display_name, key, build_local) =
640                match (&f.export_name, &f.debug_name) {
641                    (Some(e), _) => (e.clone(), e.clone(), e.clone(), false),
642                    (None, Some(d)) => {
643                        let stripped = stable_name_key(d);
644                        if stripped_counts.get(&stripped).copied() == Some(1)
645                            && !exports.contains(stripped.as_str())
646                        {
647                            (fallback.clone(), d.clone(), stripped, false)
648                        } else if raw_ok(d) {
649                            (fallback.clone(), d.clone(), d.clone(), true)
650                        } else {
651                            (fallback.clone(), d.clone(), fallback.clone(), true)
652                        }
653                    }
654                    (None, None) => (fallback.clone(), fallback.clone(), fallback.clone(), true),
655                };
656            let mut accepted = vec![key.clone()];
657            // The raw name-section name is always an accepted alias when it is
658            // unambiguous — a hint keyed on the symbol the author sees in a
659            // disassembly must land (or be loudly rejected), never be ignored.
660            if let Some(d) = f.debug_name.as_deref()
661                && raw_ok(d)
662                && !accepted.iter().any(|k| k == d)
663            {
664                accepted.push(d.to_string());
665            }
666            WcetKeyAssignment {
667                compile_name,
668                display_name,
669                hint_key: WcetHintKey { key, build_local },
670                accepted_keys: accepted,
671            }
672        })
673        .collect()
674}
675
676/// (#1063) The outcome of resolving a `--wcet-hints` file against the module's
677/// key assignments: the re-keyed hints map (keyed by COMPILE name, the key the
678/// backend's per-function verifier looks up), which original keys resolved to
679/// which function, and a named diagnostic for every entry that was NOT consumed.
680#[derive(Debug, Clone, PartialEq, Eq)]
681pub struct WcetHintResolution {
682    /// The hints, re-keyed by compile name, ready for the backend.
683    pub hints: WcetHints,
684    /// `(original key, compile_name)` for every entry that resolved.
685    pub resolved: Vec<(String, String)>,
686    /// One STRUCTURED record per entry that was NOT consumed — the driver
687    /// prints each (`Display` = the human detail) AND carries them into the
688    /// sidecar's [`WcetHintsOutcome`]. An ignored-because-unmatched hint and a
689    /// rejected hint look identical to a `$?` check; the named reason is the
690    /// difference (#1063 increment 2: the reason must reach the MACHINE, not
691    /// only stderr).
692    pub diagnostics: Vec<WcetHintKeyDiagnostic>,
693}
694
695/// (#1063 increment 2) The machine reason tag for a `--wcet-hints` entry that
696/// never reached a per-loop/recursion verifier. Serialized as the SAME tag the
697/// stderr warning names, so the human and the machine read one vocabulary.
698/// (Distinct from [`WcetHintRejection`], which records a hint that DID reach a
699/// function's verifier and was rejected on the merits.)
700#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
701pub enum WcetHintKeyReason {
702    /// Two hints-file entries addressed the same function; the later one (in
703    /// key order) was not consumed.
704    #[serde(rename = "wcet-hint-key-duplicate")]
705    Duplicate,
706    /// The key is accepted by more than one function in this module.
707    #[serde(rename = "wcet-hint-key-ambiguous")]
708    Ambiguous,
709    /// An index key (`func_<idx>`) for a function that carries a real name —
710    /// refused by design; an index is not an identity (#1063).
711    #[serde(rename = "wcet-hint-key-index-refused")]
712    IndexRefused,
713    /// The key names no function in this module.
714    #[serde(rename = "wcet-hint-key-unknown")]
715    Unknown,
716    /// The key resolved, but the function was skipped by the backend and is
717    /// not in the output object, so the hint never reached a verifier.
718    #[serde(rename = "wcet-hint-key-skipped-function")]
719    SkippedFunction,
720}
721
722/// (#1063 increment 2) One `--wcet-hints` entry that was NOT consumed, as a
723/// structured sidecar record. `function` is present exactly when the key
724/// resolves to a single known function (duplicate, index-refused,
725/// skipped-function) and absent when it does not (ambiguous, unknown) — an
726/// unresolvable diagnostic is never forced into a per-function slot.
727#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
728pub struct WcetHintKeyDiagnostic {
729    /// The original hints-file key, verbatim.
730    pub key: String,
731    /// The machine reason (the same tag the stderr warning names).
732    pub reason: WcetHintKeyReason,
733    /// The function the key resolved to (sidecar display name), where
734    /// resolvable.
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub function: Option<String>,
737    /// The human-readable message (identical to the stderr warning text).
738    pub detail: String,
739}
740
741impl std::fmt::Display for WcetHintKeyDiagnostic {
742    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
743        f.write_str(&self.detail)
744    }
745}
746
747/// (#1063 increment 2) A hints-file key that resolved to a function, recorded
748/// in the sidecar so a consumer can verify WHICH function each hint landed on
749/// without re-deriving synth's key assignment. A resolved key was consumed by
750/// that function's verifier unless a [`WcetHintKeyReason::SkippedFunction`]
751/// diagnostic names it.
752#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
753pub struct WcetResolvedHint {
754    /// The original hints-file key, verbatim.
755    pub key: String,
756    /// The function it resolved to (sidecar display name).
757    pub function: String,
758}
759
760/// (#1063 increment 2) The top-level `hints` object of the sidecar: the
761/// machine-readable outcome of `--wcet-hints` resolution. Present in the
762/// sidecar IFF a hints file was passed, so a consumer can tell apart
763/// (a) no hints file (object absent), (b) hints consumed (`resolved`
764/// non-empty), and (c) hints supplied but ALL refused before reaching any
765/// function (`resolved` empty, `diagnostics` non-empty) — three states that
766/// were previously identical in the JSON (and to `$?`: the compile exits 0 in
767/// all three). Additive to `synth-wcet-v1`: absent for every compile without
768/// `--wcet-hints`, so existing sidecars are byte-identical and a consumer that
769/// ignores unknown fields is unaffected.
770#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
771pub struct WcetHintsOutcome {
772    /// One entry per hints-file key that resolved to a function.
773    pub resolved: Vec<WcetResolvedHint>,
774    /// One entry per hints-file key that was NOT consumed (never reached a
775    /// verifier). Merits-level rejections stay per-function
776    /// (`hint_rejections`); this array is only the keys that never got there.
777    pub diagnostics: Vec<WcetHintKeyDiagnostic>,
778}
779
780/// (#1063) Resolve every `--wcet-hints` entry against the module's accepted
781/// keys. Each entry either resolves to exactly one function (and is re-keyed to
782/// that function's compile name) or produces a NAMED diagnostic:
783/// ambiguous key, duplicate entry, refused index key (the function carries a
784/// real name — an index is not an identity), or unknown key. No entry is ever
785/// silently ignored.
786pub fn resolve_hint_keys(
787    hints: WcetHints,
788    assignments: &[WcetKeyAssignment],
789) -> WcetHintResolution {
790    use std::collections::HashMap;
791    let mut by_key: HashMap<&str, Vec<usize>> = HashMap::new();
792    for (i, a) in assignments.iter().enumerate() {
793        for k in &a.accepted_keys {
794            let v = by_key.entry(k.as_str()).or_default();
795            if !v.contains(&i) {
796                v.push(i);
797            }
798        }
799    }
800    let mut out = WcetHints {
801        schema: hints.schema,
802        functions: std::collections::BTreeMap::new(),
803    };
804    let mut resolved: Vec<(String, String)> = Vec::new();
805    let mut diagnostics: Vec<WcetHintKeyDiagnostic> = Vec::new();
806    for (k, entry) in hints.functions {
807        match by_key.get(k.as_str()).map(Vec::as_slice) {
808            Some([i]) => {
809                let a = &assignments[*i];
810                if out.functions.contains_key(&a.compile_name) {
811                    diagnostics.push(WcetHintKeyDiagnostic {
812                        reason: WcetHintKeyReason::Duplicate,
813                        function: Some(a.display_name.clone()),
814                        detail: format!(
815                            "--wcet-hints key '{k}' duplicates an earlier entry for function \
816                             '{}' — this entry was not consumed (wcet-hint-key-duplicate, #1063)",
817                            a.display_name
818                        ),
819                        key: k,
820                    });
821                } else {
822                    out.functions.insert(a.compile_name.clone(), entry);
823                    resolved.push((k, a.compile_name.clone()));
824                }
825            }
826            Some(many) => diagnostics.push(WcetHintKeyDiagnostic {
827                reason: WcetHintKeyReason::Ambiguous,
828                function: None,
829                detail: format!(
830                    "--wcet-hints key '{k}' is AMBIGUOUS in this module ({} functions accept \
831                     it) — the hint was not consumed (wcet-hint-key-ambiguous, #1063)",
832                    many.len()
833                ),
834                key: k,
835            }),
836            None => {
837                // An index key for a function that carries a real name is
838                // REFUSED by design, and the diagnostic names the key to use:
839                // an index silently retargets when an unrelated edit adds or
840                // removes an earlier function, converting a decline for a
841                // function whose shape nobody looked at.
842                if let Some(a) = assignments
843                    .iter()
844                    .find(|a| a.compile_name == k && !a.accepted_keys.contains(&k))
845                {
846                    diagnostics.push(WcetHintKeyDiagnostic {
847                        reason: WcetHintKeyReason::IndexRefused,
848                        function: Some(a.display_name.clone()),
849                        detail: format!(
850                            "--wcet-hints key '{k}' is an INDEX key, but that function carries \
851                             the name '{}' — an index is not an identity (it silently retargets \
852                             when the index space shifts), so it is refused; key the hint on \
853                             '{}' instead (wcet-hint-key-index-refused, #1063)",
854                            a.display_name, a.hint_key.key
855                        ),
856                        key: k,
857                    });
858                } else {
859                    diagnostics.push(WcetHintKeyDiagnostic {
860                        reason: WcetHintKeyReason::Unknown,
861                        function: None,
862                        detail: format!(
863                            "--wcet-hints names function '{k}' which is not in this module — \
864                             the hint was not consumed (wcet-hint-key-unknown)"
865                        ),
866                        key: k,
867                    });
868                }
869            }
870        }
871    }
872    WcetHintResolution {
873        hints: out,
874        resolved,
875        diagnostics,
876    }
877}
878
879/// (#778 phase 4 / #49) A proven SELF-recursion certificate: the function is a
880/// single-self-call chain whose controlling value is entry-independently bounded
881/// (a masked-slot counter decreasing by a const step toward a base guard on the
882/// SAME masked quantity), so its maximum recursion DEPTH is DERIVED (not
883/// hint-supplied) as an entry-independent ceiling. The composer folds the self-edge
884/// as `frame_count × frame_cost` (`frame_count = max_depth + 1`, counting the base
885/// frame) instead of declining `Recursion`.
886///
887/// A certificate is attached ONLY after the depth was cross-checked against a
888/// `--wcet-hints` `recursion_depth` entry (the untrusted oracle asserts intent;
889/// synth's derived ceiling is what is emitted). Without a hint the recursion still
890/// declines (a bound this consequential is opt-in, mirroring the equality-exit
891/// loop-hint gate). `self_label` is the function's own `func_<idx>` self-call label
892/// so the composer can identify and special-case exactly that edge.
893#[derive(Debug, Clone, PartialEq, Eq)]
894pub struct WcetRecursionCert {
895    /// The self-call `BL` label (`func_<idx>`) this certificate authorizes.
896    pub self_label: String,
897    /// The DERIVED maximum recursion depth (entry-independent ceiling). The base
898    /// frame is NOT included here — the composer uses `max_depth + 1` frames.
899    pub max_depth: u64,
900    /// The hint value that gated this certificate (recorded for the sidecar; the
901    /// emitted depth is always the derived `max_depth`, never the raw hint).
902    pub hint: u64,
903}
904
905/// One direct call site inside a composable function (#778 phase 3). Records the
906/// callee's `BL` label (`func_<idx>` for a local/relocatable-import call) and the
907/// per-instruction execution-count multiplier of the `BL` (1 outside any loop; the
908/// enclosing loop's proven trip product when the call sits inside a proven counted
909/// loop, so a call in a loop is counted `trip` times, never once).
910#[derive(Debug, Clone, PartialEq, Eq)]
911pub struct WcetCallSite {
912    /// The `BL` target label as emitted by the selector (`func_<wasm_index>` for a
913    /// direct local/import call; any other label is a runtime helper → external).
914    pub callee_label: String,
915    /// The call site's worst-case execution count (product of enclosing proven loop
916    /// trip factors; 1 outside any loop). `u128` to survive deep nesting without
917    /// wrapping, matching the loop-multiplier domain.
918    pub multiplier: u128,
919}
920
921/// The per-function INTERMEDIATE result of the WCET pass BEFORE inter-procedural
922/// composition (#778 phase 3). The backend produces one of these per function; the
923/// module-level composer ([`crate::wcet`] consumers call `synth_backend::wcet_compose`)
924/// resolves each function's direct call sites against the whole module and emits the
925/// final [`WcetFunction`] (a composed bound, or a propagated/recursion/indirect
926/// decline).
927///
928/// Splitting the pass in two keeps composition a PURE function over already-decided
929/// per-function facts: `own_cycles` already prices every non-call instruction
930/// (including each `BL`'s branch overhead) at its proven execution count, so the
931/// composed total is `own_cycles + Σ_site multiplier_site × callee_total` — the
932/// per-site multiplier makes a call inside a proven loop sound by construction.
933/// (#921) Where a decline happened: which op, and where in the function.
934/// Travels through the intermediate so composition can carry it to the sidecar.
935#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
936pub struct WcetDeclineSite {
937    /// `ArmOp` variant name — `I64Add`, `MveDivF32`, …
938    pub op: String,
939    /// Byte offset within the function; `None` when it could not be computed
940    /// from the real encoder (omitted rather than estimated).
941    #[serde(default, skip_serializing_if = "Option::is_none")]
942    pub offset: Option<u64>,
943}
944
945#[derive(Debug, Clone, PartialEq, Eq)]
946pub enum WcetIntermediate {
947    /// The function declines for a reason INDEPENDENT of composition (an unproven
948    /// loop, an internal looped expansion, an unsupported core, an unresolved label
949    /// branch, an indirect call, or an unmodeled op). Carried straight through to a
950    /// [`WcetFunction::Declined`]; composition never rescues these.
951    Declined {
952        /// (#921) The op that caused the decline, when it names one.
953        site: Option<WcetDeclineSite>,
954        name: String,
955        reason: WcetDecline,
956        hint_rejections: Vec<WcetHintRejection>,
957    },
958    /// The function's own body is bounded; its final bound depends only on resolving
959    /// the recorded direct call sites against the module's other functions.
960    Composable {
961        name: String,
962        /// The summed worst-case cost of every instruction in the final stream
963        /// (each priced at its documented worst case × its proven execution-count
964        /// multiplier), INCLUDING each direct `BL`'s branch overhead. The callee
965        /// bodies are added by the composer via `call_sites`.
966        own_cycles: u64,
967        /// Number of ARM instructions summed (diagnostic, carried to the bound).
968        instr_count: usize,
969        /// The direct call sites to resolve at compose time.
970        call_sites: Vec<WcetCallSite>,
971        /// Proven loops inside this function (carried to the bound unchanged).
972        loops: Vec<WcetLoopBound>,
973        /// (#778 phase 4 / #49) A proven self-recursion certificate, when this
974        /// function is a bounded single-self-call chain with a verified depth hint.
975        /// The composer folds the self-edge as `(max_depth+1) × frame_cost` instead
976        /// of declining `Recursion`. `None` for a non-recursive function or an
977        /// unverifiable/unhinted recursion (which still declines).
978        recursion_cert: Option<WcetRecursionCert>,
979        /// Hints rejected while analyzing this function (carried to the bound).
980        hint_rejections: Vec<WcetHintRejection>,
981    },
982}
983
984impl WcetIntermediate {
985    /// The compiled function name this intermediate is for.
986    pub fn name(&self) -> &str {
987        match self {
988            WcetIntermediate::Declined { name, .. } | WcetIntermediate::Composable { name, .. } => {
989                name
990            }
991        }
992    }
993}
994
995/// The parsed `--wcet-hints` file (`synth-wcet-hints-v1`) — an UNTRUSTED oracle
996/// input (#778 phase 2, the scry integration seam). Per function, an ordered
997/// array of claimed loop-trip-count upper bounds, matched to loops by ascending
998/// head offset (entry N = N-th loop head in the function; `null` skips a loop).
999/// Every entry is soundly CHECKED before use: synth re-derives the loop's trip
1000/// count from its own induction proof and consumes the hint only when the
1001/// derived count is ≤ the hint. A wrong or unverifiable hint is rejected with a
1002/// machine reason ([`WcetHintReject`]) — never trusted into a bound.
1003#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1004pub struct WcetHints {
1005    /// Must equal [`HINTS_SCHEMA`].
1006    pub schema: String,
1007    /// Per-function hint arrays, keyed by the compiled function name.
1008    #[serde(default)]
1009    pub functions: std::collections::BTreeMap<String, WcetFunctionHints>,
1010}
1011
1012/// Per-function loop-bound hints.
1013#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1014pub struct WcetFunctionHints {
1015    /// Claimed trip-count upper bounds, one per loop in ascending-head-offset
1016    /// order; `null` leaves that loop unhinted.
1017    #[serde(default)]
1018    pub loop_bounds: Vec<Option<u64>>,
1019    /// (#778 phase 4 / #49) An UNTRUSTED claimed maximum SELF-recursion depth for
1020    /// this function. Consulted only when synth has proven the function is a
1021    /// single-self-call chain whose controlling value is entry-independently bounded
1022    /// (a masked-slot counter): synth then DERIVES its own maximum depth from the
1023    /// mask+step+base induction and cross-checks this hint (`hint < derived` →
1024    /// `hint-below-derived-depth`). A hint on a function whose recursion synth cannot
1025    /// so verify is REJECTED (`hint-unverifiable-recursion`) and never trusted. The
1026    /// emitted bound always uses synth's DERIVED depth, never the raw hint.
1027    #[serde(default, skip_serializing_if = "Option::is_none")]
1028    pub recursion_depth: Option<u64>,
1029}
1030
1031/// The full `synth-wcet-v1` sidecar: schema header, precondition, and per-function
1032/// bounds/declines.
1033#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1034pub struct WcetReport {
1035    /// Schema version (`synth-wcet-v1`).
1036    pub schema: String,
1037    /// The compiled module name (for diagnostics).
1038    pub module: String,
1039    /// The core class the cycle table is written for (e.g. `"cortex-m4"`). The
1040    /// bound is CONDITIONAL on this core.
1041    pub core_class: String,
1042    /// Assumed instruction-memory wait states (0 for the sound zero-wait table).
1043    pub wait_states: u32,
1044    /// Human statement of the memory precondition the bound holds under.
1045    pub memory_assumption: String,
1046    /// (#1063 increment 2) The `--wcet-hints` resolution outcome. Present IFF
1047    /// a hints file was passed — its presence is the "hints were supplied"
1048    /// marker, so no-hints and all-hints-refused compiles are machine-
1049    /// distinguishable even when `diagnostics` is empty. Additive; absent for
1050    /// every compile without `--wcet-hints` (existing sidecars byte-identical).
1051    #[serde(default, skip_serializing_if = "Option::is_none")]
1052    pub hints: Option<WcetHintsOutcome>,
1053    /// Per-function bound or decline. Complete: one entry per compiled function.
1054    pub functions: Vec<WcetFunction>,
1055}
1056
1057impl WcetReport {
1058    /// Start an empty report for `module`, targeting `core_class` under the sound
1059    /// zero-wait precondition.
1060    pub fn new(module: impl Into<String>, core_class: impl Into<String>) -> Self {
1061        WcetReport {
1062            schema: SCHEMA.to_string(),
1063            module: module.into(),
1064            core_class: core_class.into(),
1065            wait_states: 0,
1066            memory_assumption:
1067                "zero-wait-state instruction memory (flash accelerator / I-cache hit); \
1068                 in-order single-issue pipeline; documented per-instruction worst-case cycles"
1069                    .to_string(),
1070            hints: None,
1071            functions: Vec::new(),
1072        }
1073    }
1074
1075    /// Serialize to pretty JSON.
1076    pub fn to_json(&self) -> serde_json::Result<String> {
1077        serde_json::to_string_pretty(self)
1078    }
1079
1080    /// Resolve the sidecar path (`<output>.wcet.json`) next to the ELF output.
1081    pub fn sidecar_path(output: &std::path::Path) -> std::path::PathBuf {
1082        let mut s = output.as_os_str().to_os_string();
1083        s.push(".wcet.json");
1084        std::path::PathBuf::from(s)
1085    }
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090    use super::*;
1091
1092    #[test]
1093    fn bounded_and_declined_roundtrip() {
1094        let mut r = WcetReport::new("m", "cortex-m4");
1095        r.functions.push(WcetFunction::Bounded {
1096            name: "leaf".into(),
1097            cycles: 42,
1098            instr_count: 7,
1099            loops: Vec::new(),
1100            recursion: None,
1101            hint_rejections: Vec::new(),
1102            hint_key: None,
1103        });
1104        r.functions
1105            .push(WcetFunction::declined("spins", WcetDecline::Loop));
1106        let json = r.to_json().unwrap();
1107        let back: WcetReport = serde_json::from_str(&json).unwrap();
1108        assert_eq!(r, back);
1109        // Decline reason is machine-readable and carries a note.
1110        assert!(json.contains("\"reason\": \"loop\""));
1111        assert!(json.contains("synth-wcet-v1"));
1112    }
1113
1114    #[test]
1115    fn sidecar_path_appends_suffix() {
1116        let p = WcetReport::sidecar_path(std::path::Path::new("out/app.elf"));
1117        assert_eq!(p, std::path::PathBuf::from("out/app.elf.wcet.json"));
1118    }
1119
1120    // ── #1063: durable hint keys ────────────────────────────────────────────
1121
1122    /// The v0 crate disambiguator (gale's measured churner, scry#123) strips;
1123    /// content-derived components survive.
1124    #[test]
1125    fn stable_key_strips_v0_crate_disambiguator() {
1126        assert_eq!(
1127            stable_name_key("_RNvCs942N1ctoMYm_4fixt12inner_eqexit"),
1128            "_RNvC4fixt12inner_eqexit"
1129        );
1130        // Multiple crate refs in one path all strip.
1131        assert_eq!(
1132            stable_name_key("_RNvNtCs942N1ctoMYm_4core3fmt3num__Cs1AbCd_5other"),
1133            "_RNvNtC4core3fmt3num__C5other"
1134        );
1135        // Local (closure) disambiguators like `s_0` are source-order-derived
1136        // and are KEPT — only the crate-metadata hash after `C` strips.
1137        assert_eq!(
1138            stable_name_key("_RNCNvCs942N1ctoMYm_4main4mains_0"),
1139            "_RNCNvC4main4mains_0"
1140        );
1141    }
1142
1143    /// Legacy mangling and demangled hash suffixes strip; a non-mangled name is
1144    /// unchanged.
1145    #[test]
1146    fn stable_key_strips_legacy_hashes_and_keeps_plain_names() {
1147        assert_eq!(
1148            stable_name_key("_ZN4core3fmt9Formatter3pad17h2b9e27d1f4d3ba32E"),
1149            "_ZN4core3fmt9Formatter3padE"
1150        );
1151        assert_eq!(
1152            stable_name_key("core::fmt::Formatter::pad::h2b9e27d1f4d3ba32"),
1153            "core::fmt::Formatter::pad"
1154        );
1155        assert_eq!(stable_name_key("memcpy"), "memcpy");
1156        assert_eq!(stable_name_key("entry"), "entry");
1157    }
1158
1159    fn idents() -> Vec<WcetFnIdentity> {
1160        vec![
1161            WcetFnIdentity {
1162                index: 0,
1163                export_name: None,
1164                debug_name: Some("_RNvCs942N1ctoMYm_4fixt12inner_eqexit".into()),
1165            },
1166            WcetFnIdentity {
1167                index: 1,
1168                export_name: Some("entry".into()),
1169                debug_name: Some("_RNvCs942N1ctoMYm_4fixt5entry".into()),
1170            },
1171            WcetFnIdentity {
1172                index: 2,
1173                export_name: None,
1174                debug_name: None,
1175            },
1176        ]
1177    }
1178
1179    /// Export name wins; a unique stripped name-section name is the stable key
1180    /// (raw name accepted as an alias); a nameless function keeps `func_<idx>`
1181    /// flagged build-local.
1182    #[test]
1183    fn assign_priority_export_then_stripped_then_index() {
1184        let a = assign_hint_keys(&idents());
1185        assert_eq!(a[0].compile_name, "func_0");
1186        assert_eq!(a[0].display_name, "_RNvCs942N1ctoMYm_4fixt12inner_eqexit");
1187        assert_eq!(a[0].hint_key.key, "_RNvC4fixt12inner_eqexit");
1188        assert!(!a[0].hint_key.build_local);
1189        assert!(
1190            a[0].accepted_keys
1191                .iter()
1192                .any(|k| k == "_RNvCs942N1ctoMYm_4fixt12inner_eqexit"),
1193            "raw name-section name must be an accepted alias"
1194        );
1195        assert!(
1196            !a[0].accepted_keys.iter().any(|k| k == "func_0"),
1197            "an index key is refused once the function carries a name"
1198        );
1199        assert_eq!(a[1].hint_key.key, "entry");
1200        assert!(!a[1].hint_key.build_local);
1201        assert_eq!(a[2].hint_key.key, "func_2");
1202        assert!(a[2].hint_key.build_local, "an index is not an identity");
1203    }
1204
1205    /// Two functions whose stripped keys collide demote to their RAW names
1206    /// (build-local) — a churning key is disclosed, never silently unstable.
1207    #[test]
1208    fn assign_demotes_stripped_collision_to_raw_build_local() {
1209        let fns = vec![
1210            WcetFnIdentity {
1211                index: 0,
1212                export_name: None,
1213                debug_name: Some("_RNvCsAAAA_4c3f".into()),
1214            },
1215            WcetFnIdentity {
1216                index: 1,
1217                export_name: None,
1218                debug_name: Some("_RNvCsBBBB_4c3f".into()),
1219            },
1220        ];
1221        let a = assign_hint_keys(&fns);
1222        assert_eq!(a[0].hint_key.key, "_RNvCsAAAA_4c3f");
1223        assert!(a[0].hint_key.build_local);
1224        assert_eq!(a[1].hint_key.key, "_RNvCsBBBB_4c3f");
1225        assert!(a[1].hint_key.build_local);
1226    }
1227
1228    /// Resolution re-keys to compile names, and every non-consumed entry gets a
1229    /// NAMED diagnostic — never a silent ignore.
1230    #[test]
1231    fn resolve_rekeys_and_names_every_refusal() {
1232        let a = assign_hint_keys(&idents());
1233        let mut h = WcetHints {
1234            schema: HINTS_SCHEMA.into(),
1235            functions: std::collections::BTreeMap::new(),
1236        };
1237        let entry = WcetFunctionHints {
1238            loop_bounds: vec![Some(8)],
1239            recursion_depth: None,
1240        };
1241        // stable key, raw alias (duplicate of the same function), refused index
1242        // key, and an unknown name.
1243        h.functions
1244            .insert("_RNvC4fixt12inner_eqexit".into(), entry.clone());
1245        h.functions.insert(
1246            "_RNvCs942N1ctoMYm_4fixt12inner_eqexit".into(),
1247            entry.clone(),
1248        );
1249        h.functions.insert("func_0".into(), entry.clone());
1250        h.functions.insert("nosuch".into(), entry);
1251        let res = resolve_hint_keys(h, &a);
1252        assert!(res.hints.functions.contains_key("func_0"));
1253        assert_eq!(res.resolved.len(), 1);
1254        assert_eq!(res.diagnostics.len(), 3);
1255        assert!(
1256            res.diagnostics
1257                .iter()
1258                .any(|d| d.reason == WcetHintKeyReason::Duplicate
1259                    && d.function.as_deref() == Some("_RNvCs942N1ctoMYm_4fixt12inner_eqexit"))
1260        );
1261        // The index-refused diagnostic RESOLVES to a known function and its
1262        // detail names the key to use instead (#1063 increment 2: structured,
1263        // sidecar-ready — not only a stderr string).
1264        assert!(
1265            res.diagnostics
1266                .iter()
1267                .any(|d| d.reason == WcetHintKeyReason::IndexRefused
1268                    && d.key == "func_0"
1269                    && d.function.as_deref() == Some("_RNvCs942N1ctoMYm_4fixt12inner_eqexit")
1270                    && d.detail.contains("_RNvC4fixt12inner_eqexit"))
1271        );
1272        assert!(
1273            res.diagnostics
1274                .iter()
1275                .any(|d| d.reason == WcetHintKeyReason::Unknown
1276                    && d.key == "nosuch"
1277                    && d.function.is_none()
1278                    && d.detail.contains("not in this module"))
1279        );
1280    }
1281
1282    /// (#1063 increment 2) The machine reason tags serialize as EXACTLY the
1283    /// tags the stderr warnings name — one vocabulary for human and machine —
1284    /// and the sidecar `hints` object roundtrips, with `function` absent (not
1285    /// null) for an unresolvable diagnostic.
1286    #[test]
1287    fn hint_key_diagnostics_serialize_with_stderr_tags() {
1288        for (r, tag) in [
1289            (WcetHintKeyReason::Duplicate, "wcet-hint-key-duplicate"),
1290            (WcetHintKeyReason::Ambiguous, "wcet-hint-key-ambiguous"),
1291            (
1292                WcetHintKeyReason::IndexRefused,
1293                "wcet-hint-key-index-refused",
1294            ),
1295            (WcetHintKeyReason::Unknown, "wcet-hint-key-unknown"),
1296            (
1297                WcetHintKeyReason::SkippedFunction,
1298                "wcet-hint-key-skipped-function",
1299            ),
1300        ] {
1301            assert_eq!(
1302                serde_json::to_value(r).unwrap(),
1303                serde_json::Value::String(tag.into())
1304            );
1305        }
1306        let mut rep = WcetReport::new("m", "cortex-m4");
1307        // No hints file => the `hints` key is ABSENT (its presence is the
1308        // "hints were supplied" marker).
1309        assert!(!rep.to_json().unwrap().contains("\"hints\""));
1310        rep.hints = Some(WcetHintsOutcome {
1311            resolved: vec![],
1312            diagnostics: vec![WcetHintKeyDiagnostic {
1313                key: "func_0".into(),
1314                reason: WcetHintKeyReason::IndexRefused,
1315                function: Some("real_name".into()),
1316                detail: "refused".into(),
1317            }],
1318        });
1319        let json = rep.to_json().unwrap();
1320        let back: WcetReport = serde_json::from_str(&json).unwrap();
1321        assert_eq!(rep, back);
1322        let v: serde_json::Value = serde_json::from_str(&json).unwrap();
1323        let d = &v["hints"]["diagnostics"][0];
1324        assert_eq!(d["reason"], "wcet-hint-key-index-refused");
1325        // An unresolvable diagnostic omits `function` entirely.
1326        rep.hints.as_mut().unwrap().diagnostics[0].function = None;
1327        assert!(!rep.to_json().unwrap().contains("\"function\""));
1328    }
1329}