Skip to main content

aver/ir/
interval.rs

1//! Per-module interval analysis over refinement-type carriers.
2//!
3//! This is a **pure, read-only diagnostic**. It derives, for each
4//! refinement-via-opaque type the proof side already lifted into
5//! [`crate::ir::proof_ir::RefinedTypeDecl`], a constant integer
6//! interval `[lo, hi]` that over-approximates the type's invariant,
7//! and then classifies every arithmetic operation the defining module
8//! exposes on that type as one of [`OpClass::OverflowFree`],
9//! [`OpClass::NeedsWiderScratch`], or [`OpClass::Unbounded`].
10//!
11//! Nothing here changes codegen, the runtime, or proof output. The
12//! result is surfaced only through `aver compile --explain-passes`
13//! and stored on [`crate::ir::pipeline::PipelineResult`] for future
14//! opt-in consumers (the carrier-lowering recognizer is a later
15//! slice). See `prompts/int-semantics-refinement-perf-optin.md` for
16//! the why.
17//!
18//! ## Soundness direction
19//!
20//! The dangerous mistake is wrongly certifying an operation as
21//! [`OpClass::OverflowFree`] — that would let a future codegen lower
22//! an intermediate to a raw `i64` that can actually wrap. So the
23//! analysis is conservative in exactly one direction: when any input
24//! shape is unrecognized, or any operand is unbounded, it **declines**
25//! (`Unbounded` / `interval_known = false`). It never over-claims a
26//! bound it cannot derive.
27//!
28//! The interval carrier is `i128` internally so the analysis can
29//! represent and reason about values *outside* `i64` without itself
30//! wrapping — this is what lets it prove `a + b` for `a, b ∈ [0, 100]`
31//! stays `≤ 200 < i64::MAX`. All `i128` arithmetic **saturates** to
32//! ±infinity on overflow; it never wraps.
33
34use std::collections::HashMap;
35
36use crate::ast::{BinOp, Expr, FnBody, FnDef, Literal, Spanned, Stmt};
37use crate::codegen::proof_lower::ProofLowerInputs;
38use crate::ir::TypeId;
39use crate::ir::proof_ir::{Predicate, RefinedTypeDecl};
40
41/// One endpoint of an [`Interval`]. `Finite(k)` is an exact `i128`
42/// bound; `NegInf` / `PosInf` are the open ends the saturating
43/// arithmetic produces when a value escapes the `i128` range or when
44/// the source invariant is one-sided (e.g. `Natural`'s `n >= 0`).
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Bound {
47    NegInf,
48    Finite(i128),
49    PosInf,
50}
51
52impl Bound {
53    /// `true` for a `Finite` bound that fits the `i64` range.
54    fn fits_i64(self) -> bool {
55        match self {
56            Bound::Finite(k) => k >= i64::MIN as i128 && k <= i64::MAX as i128,
57            Bound::NegInf | Bound::PosInf => false,
58        }
59    }
60
61    /// Saturating addition over the extended integers. Any `inf`
62    /// dominates; two `Finite` bounds add in `i128` and saturate to
63    /// the matching infinity on overflow (never wrap).
64    fn add(self, other: Bound) -> Bound {
65        match (self, other) {
66            // ∞ + (-∞) cannot arise for a well-formed interval (a
67            // `lo` is never `PosInf` and a `hi` is never `NegInf`),
68            // but guard it anyway: collapse to the conservative open
69            // end so we never fabricate a finite bound.
70            (Bound::PosInf, Bound::NegInf) | (Bound::NegInf, Bound::PosInf) => Bound::NegInf,
71            (Bound::PosInf, _) | (_, Bound::PosInf) => Bound::PosInf,
72            (Bound::NegInf, _) | (_, Bound::NegInf) => Bound::NegInf,
73            (Bound::Finite(a), Bound::Finite(b)) => match a.checked_add(b) {
74                Some(s) => Bound::Finite(s),
75                None if a > 0 => Bound::PosInf,
76                None => Bound::NegInf,
77            },
78        }
79    }
80
81    /// Negate an endpoint. `Finite(i128::MIN)` cannot be negated in
82    /// `i128`, so it saturates to `PosInf` rather than wrapping.
83    fn neg(self) -> Bound {
84        match self {
85            Bound::NegInf => Bound::PosInf,
86            Bound::PosInf => Bound::NegInf,
87            Bound::Finite(k) => match k.checked_neg() {
88                Some(n) => Bound::Finite(n),
89                None => Bound::PosInf,
90            },
91        }
92    }
93
94    /// Saturating multiplication of two endpoints. The sign of an
95    /// infinity is determined by the sign of the finite factor.
96    fn mul(self, other: Bound) -> Bound {
97        // Resolve the sign and magnitude of each factor.
98        let sign = |b: Bound| -> i32 {
99            match b {
100                Bound::NegInf => -1,
101                Bound::PosInf => 1,
102                Bound::Finite(0) => 0,
103                Bound::Finite(k) => {
104                    if k > 0 {
105                        1
106                    } else {
107                        -1
108                    }
109                }
110            }
111        };
112        // 0 * ∞ is treated as 0: a `Finite(0)` factor pins the
113        // product to 0 regardless of the other endpoint. This is the
114        // correct interval-arithmetic answer because a 0 endpoint
115        // only arises from a `Finite(0)` operand, never from an open
116        // end (an open end has no finite witness to multiply).
117        if matches!(self, Bound::Finite(0)) || matches!(other, Bound::Finite(0)) {
118            return Bound::Finite(0);
119        }
120        match (self, other) {
121            (Bound::Finite(a), Bound::Finite(b)) => match a.checked_mul(b) {
122                Some(p) => Bound::Finite(p),
123                None => {
124                    if sign(self) * sign(other) >= 0 {
125                        Bound::PosInf
126                    } else {
127                        Bound::NegInf
128                    }
129                }
130            },
131            _ => {
132                if sign(self) * sign(other) >= 0 {
133                    Bound::PosInf
134                } else {
135                    Bound::NegInf
136                }
137            }
138        }
139    }
140
141    /// Order for picking a `min` lower bound: `NegInf < Finite < PosInf`.
142    pub fn min(self, other: Bound) -> Bound {
143        if self.le(other) { self } else { other }
144    }
145
146    /// Order for picking a `max` upper bound.
147    pub fn max(self, other: Bound) -> Bound {
148        if self.le(other) { other } else { self }
149    }
150
151    /// `self <= other` over the extended integers.
152    fn le(self, other: Bound) -> bool {
153        match (self, other) {
154            (Bound::NegInf, _) => true,
155            (_, Bound::NegInf) => false,
156            (_, Bound::PosInf) => true,
157            (Bound::PosInf, _) => false,
158            (Bound::Finite(a), Bound::Finite(b)) => a <= b,
159        }
160    }
161}
162
163/// A constant integer interval `[lo, hi]` over the extended integers.
164/// Produced by [`interval_of_invariant`] from a refinement predicate
165/// and propagated bottom-up through an operation body by the abstract
166/// interpreter in [`classify_op`].
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub struct Interval {
169    pub lo: Bound,
170    pub hi: Bound,
171}
172
173impl Interval {
174    /// The whole extended integer line — the conservative "I know
175    /// nothing" answer the analysis returns whenever it declines.
176    pub fn unbounded() -> Interval {
177        Interval {
178            lo: Bound::NegInf,
179            hi: Bound::PosInf,
180        }
181    }
182
183    /// A single point `[k, k]`.
184    pub fn point(k: i128) -> Interval {
185        Interval {
186            lo: Bound::Finite(k),
187            hi: Bound::Finite(k),
188        }
189    }
190
191    /// `[k, +inf]` — the over-approximation of `n >= k`.
192    pub fn ge(k: i128) -> Interval {
193        Interval {
194            lo: Bound::Finite(k),
195            hi: Bound::PosInf,
196        }
197    }
198
199    /// `[-inf, k]` — the over-approximation of `n <= k`.
200    pub fn le(k: i128) -> Interval {
201        Interval {
202            lo: Bound::NegInf,
203            hi: Bound::Finite(k),
204        }
205    }
206
207    /// `[lo, hi]` from two literal bounds.
208    pub fn between(lo: i128, hi: i128) -> Interval {
209        Interval {
210            lo: Bound::Finite(lo),
211            hi: Bound::Finite(hi),
212        }
213    }
214
215    /// Intersection — used to combine the conjuncts of a compound
216    /// `Bool.and` guard into a single two-sided interval.
217    pub fn intersect(self, other: Interval) -> Interval {
218        Interval {
219            lo: self.lo.max(other.lo),
220            hi: self.hi.min(other.hi),
221        }
222    }
223
224    /// Convex hull (worst-case widening join) — the merge operator for
225    /// control-flow joins and the per-value `worst` accumulator the
226    /// general range pass uses. If either operand escapes `i64`, so does
227    /// the hull, keeping the headroom verdict sound. Public mirror of the
228    /// crate-private `join` free fn so the bare-`i64` consumer (outside
229    /// this module) can widen without re-deriving the rule.
230    pub fn hull(self, other: Interval) -> Interval {
231        Interval {
232            lo: self.lo.min(other.lo),
233            hi: self.hi.max(other.hi),
234        }
235    }
236
237    /// `true` when both bounds are finite and within the `i64` range.
238    /// This is the headroom test: an intermediate whose interval
239    /// `fits_i64` cannot overflow a raw `i64` before the smart
240    /// constructor's guard re-validates it.
241    pub fn fits_i64(self) -> bool {
242        self.lo.fits_i64() && self.hi.fits_i64()
243    }
244
245    /// `true` when the constant `k` provably lies within `[lo, hi]`. Used by
246    /// the carrier-`i64` eligibility tightening: a BARE record constructor
247    /// outside the smart-ctor whose carrier-field argument is a literal in
248    /// the proven interval is SAFE (it cannot smuggle an out-of-bound /
249    /// i64-overflowing value past the gate), so it does not demote the
250    /// carrier. A non-literal argument (or a literal outside the interval)
251    /// is ungated and DOES demote. `Bound::le` is module-private, so this
252    /// containment test must live here.
253    pub fn contains_point(self, k: i128) -> bool {
254        self.lo.le(Bound::Finite(k)) && Bound::Finite(k).le(self.hi)
255    }
256
257    /// Standard interval widening ∇. An endpoint that moved OUTWARD between
258    /// the previous iterate `self` and the next iterate `next` jumps to the
259    /// matching infinity; a stable or inward-moving endpoint is kept (as the
260    /// enclosing bound of the two). ENLARGING-ONLY: the result is always a
261    /// superset of `next`, so widening can never make an interval too narrow
262    /// (soundness preserved — only precision is lost). It is the termination
263    /// operator for the bare-`i64` interval fixpoint: a genuinely-unbounded
264    /// endpoint reaches ±inf in one widen step, capping the chain height.
265    ///
266    /// `Bound::le` is module-private, so this MUST live in `interval.rs`.
267    pub fn widen(self, next: Interval) -> Interval {
268        // `lo` moved outward when `next.lo < self.lo` (descended) → -inf.
269        let lo = if next.lo.le(self.lo) && next.lo != self.lo {
270            Bound::NegInf
271        } else {
272            self.lo.min(next.lo)
273        };
274        // `hi` moved outward when `next.hi > self.hi` (ascended) → +inf.
275        let hi = if self.hi.le(next.hi) && self.hi != next.hi {
276            Bound::PosInf
277        } else {
278            self.hi.max(next.hi)
279        };
280        Interval { lo, hi }
281    }
282
283    /// `true` when neither bound is infinite (the interval is a real
284    /// finite range, even if wider than `i64`).
285    fn is_finite(self) -> bool {
286        matches!(self.lo, Bound::Finite(_)) && matches!(self.hi, Bound::Finite(_))
287    }
288
289    /// Saturating interval addition. (Inherent, not `std::ops::Add`:
290    /// the operation saturates rather than panicking, so the std trait
291    /// would carry the wrong contract.)
292    #[allow(clippy::should_implement_trait)]
293    pub fn add(self, other: Interval) -> Interval {
294        Interval {
295            lo: self.lo.add(other.lo),
296            hi: self.hi.add(other.hi),
297        }
298    }
299
300    /// Saturating interval subtraction (`a - b = a + (-b)`, with the
301    /// endpoints flipped so `lo` stays the lower bound).
302    #[allow(clippy::should_implement_trait)]
303    pub fn sub(self, other: Interval) -> Interval {
304        Interval {
305            lo: self.lo.add(other.hi.neg()),
306            hi: self.hi.add(other.lo.neg()),
307        }
308    }
309
310    /// Saturating interval multiplication. The product range is the
311    /// min/max over the four endpoint products, which is the standard
312    /// sound interval-arithmetic rule and handles negative operands.
313    #[allow(clippy::should_implement_trait)]
314    pub fn mul(self, other: Interval) -> Interval {
315        let products = [
316            self.lo.mul(other.lo),
317            self.lo.mul(other.hi),
318            self.hi.mul(other.lo),
319            self.hi.mul(other.hi),
320        ];
321        let mut lo = products[0];
322        let mut hi = products[0];
323        for p in &products[1..] {
324            lo = lo.min(*p);
325            hi = hi.max(*p);
326        }
327        Interval { lo, hi }
328    }
329}
330
331/// Classification of an operation's worst-case `i64` intermediate.
332///
333/// **`OverflowFree` means EVERY `i64` intermediate across the WHOLE op
334/// body provably fits `i64` — the smart-constructor guard (`fromInt` /
335/// `fromX`) is STILL REQUIRED.** The verdict is the join over the final
336/// tail interval, every intermediate subexpression interval, and every
337/// earlier binding's interval (see [`classify_op`]): a single
338/// out-of-`i64` intermediate anywhere — even one whose value never
339/// reaches the tail — pulls the class out of `OverflowFree`. It does
340/// NOT mean "the result is in range without the guard": the result of
341/// e.g. `IntRange.add([0,100], [0,100]) = [0,200]` fits `i64` but
342/// exceeds the type's `[0,100]` bound, so `fromInt` must still run to
343/// re-validate the invariant. A future codegen recognizer that lowers
344/// the arithmetic to raw `i64` on the strength of this class must keep
345/// the `fromInt` call. Dropping it reintroduces the model-vs-runtime
346/// gap this whole mechanism exists to close.
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum OpClass {
349    /// The arithmetic intermediate provably fits `i64`, so it can run
350    /// on raw `i64` without wrapping before the guard re-validates.
351    OverflowFree,
352    /// The intermediate exceeds `i64` but is a proven-finite range, so
353    /// a future codegen could compute it in a wider scratch type
354    /// (`i128` or bignum) and then narrow through the guard. None of
355    /// the current example types reach this band; it exists so the
356    /// classifier is honest about the middle case rather than
357    /// collapsing it into `Unbounded`.
358    NeedsWiderScratch,
359    /// The intermediate has no derivable finite bound — typically
360    /// because an operand is a one-sided refinement (`[0, +inf]`) or a
361    /// plain `Int` (unbounded by construction). The honest decline:
362    /// the analysis cannot certify the operation as native-`i64`-safe.
363    Unbounded,
364}
365
366impl OpClass {
367    /// Classify an arithmetic intermediate interval. Conservative:
368    /// only a fully-`i64`-fitting interval earns `OverflowFree`.
369    pub fn of_interval(i: Interval) -> OpClass {
370        if i.fits_i64() {
371            OpClass::OverflowFree
372        } else if i.is_finite() {
373            OpClass::NeedsWiderScratch
374        } else {
375            OpClass::Unbounded
376        }
377    }
378
379    /// Stable lowercase label for diagnostics / JSON.
380    pub fn label(self) -> &'static str {
381        match self {
382            OpClass::OverflowFree => "overflow_free",
383            OpClass::NeedsWiderScratch => "needs_wider_scratch",
384            OpClass::Unbounded => "unbounded",
385        }
386    }
387}
388
389/// The raw-`i64`-carrier recognizer, factored to a single place so the
390/// `--explain-passes` diagnostic ([`RefinedTypeInterval::raw_i64_eligible`])
391/// and the persisted-fact gate
392/// ([`crate::ir::proof_ir::RefinedTypeDecl::raw_i64_eligible`]) can never
393/// disagree about which types are eligible.
394///
395/// Returns `true` IFF a refined type may have a raw `i64` carrier:
396///
397/// - `interval` is `Some` — a recognized enclosure (`None` is the
398///   analysis's conservative decline); AND
399/// - that interval [`Interval::fits_i64`] — both bounds finite AND within
400///   `[i64::MIN, i64::MAX]`, which is exactly "two-sided and machine-word
401///   sized" (a one-sided / open bound is `±inf`, which never fits); AND
402/// - every op in `ops` is [`OpClass::OverflowFree`] — a single
403///   `NeedsWiderScratch` / `Unbounded` op means some carrier arithmetic
404///   can wrap a raw `i64` before the guard re-validates.
405///
406/// Conservative in the soundness-critical direction: a wrongly-`true`
407/// answer would license a later codegen to lower a carrier whose ops can
408/// wrap. An **empty** `ops` slice with a finite-`i64` interval is
409/// eligible — storage fits `i64` and the `all(...)` is vacuously true, so
410/// nothing can overflow. See the `RefinedTypeDecl` method doc for the
411/// full empty-op reasoning.
412///
413/// WHITELIST SEMANTICS — load-bearing for any consumer that lowers a
414/// carrier to `i64`. `ops` is the set of carrier-*arithmetic* ops the
415/// analysis examined (a fn taking the refined type whose own body computes
416/// an arithmetic intermediate over the carrier). It is NOT every op the
417/// type exposes: an op whose overflow risk lives inside a *called helper*
418/// — e.g. `fromInt(widen(r.value))` with no arithmetic operator in its own
419/// body — is intentionally not enumerated. So `true` does NOT assert
420/// "every operation on the type is overflow-free". It asserts only: the
421/// carrier may be *stored* as `i64`, and the enumerated `OverflowFree` ops
422/// may compute on it with direct `i64` arithmetic. Any consumer that
423/// lowers the carrier to `i64` MUST therefore convert every OTHER read of
424/// the carrier (helper calls, projections, unenumerated ops) to a bignum
425/// `Int` — never raw-`i64` arithmetic outside the enumerated ops. That is
426/// the carrier-lowering contract the bignum runtime must honour; it is
427/// what keeps an unenumerated helper-call op sound (its `r.value` is read
428/// as a bignum `Int`, so the helper's arithmetic cannot wrap).
429pub fn raw_i64_eligible<'a>(
430    interval: Option<Interval>,
431    ops: impl IntoIterator<Item = &'a OpClass>,
432) -> bool {
433    let Some(interval) = interval else {
434        return false;
435    };
436    if !interval.fits_i64() {
437        return false;
438    }
439    ops.into_iter().all(|c| *c == OpClass::OverflowFree)
440}
441
442/// Per-refined-type interval analysis result.
443#[derive(Debug, Clone)]
444pub struct RefinedTypeInterval {
445    /// Opaque type identity — the same key the type carries in
446    /// `ProofIR.refined_types`, so two same-named types in different
447    /// modules stay distinct.
448    pub type_id: TypeId,
449    /// Source-level type name (for diagnostics only; not a key).
450    pub name: String,
451    /// The derived constant interval over-approximating the invariant.
452    /// `Interval::unbounded()` when the shape was unrecognized.
453    pub interval: Interval,
454    /// `true` when [`interval_of_invariant`] recognized the invariant
455    /// shape and the interval is a real (non-trivial) enclosure;
456    /// `false` when it declined (the interval is `unbounded()`).
457    pub interval_known: bool,
458    /// Per-op classification, in module-walk order. Each entry pairs
459    /// the operation's source name with its [`OpClass`].
460    pub ops: Vec<(String, OpClass)>,
461}
462
463impl RefinedTypeInterval {
464    /// Whether this type may have a raw `i64` carrier — the diagnostic
465    /// mirror of the persisted-fact gate on `RefinedTypeDecl`. Delegates
466    /// to the shared [`raw_i64_eligible`] so the two paths agree by
467    /// construction. The persisted decl stores `interval: None` for a
468    /// declined invariant, so this passes `Some(self.interval)` only when
469    /// `interval_known` to match that exact representation.
470    pub fn raw_i64_eligible(&self) -> bool {
471        let interval = self.interval_known.then_some(self.interval);
472        raw_i64_eligible(interval, self.ops.iter().map(|(_, c)| c))
473    }
474}
475
476/// Whole-analysis result, keyed for cheap programmatic lookup.
477#[derive(Debug, Clone, Default)]
478pub struct IntervalAnalysisResult {
479    /// One entry per refined type the analysis saw, keyed by opaque
480    /// `TypeId`.
481    pub types: HashMap<TypeId, RefinedTypeInterval>,
482}
483
484impl IntervalAnalysisResult {
485    /// Number of types analyzed.
486    pub fn types_analyzed(&self) -> usize {
487        self.types.len()
488    }
489
490    /// Types whose invariant yielded a two-sided constant interval
491    /// (both bounds finite) — the carrier-lowering candidates.
492    pub fn two_sided_bounded(&self) -> usize {
493        self.types
494            .values()
495            .filter(|t| t.interval_known && t.interval.is_finite())
496            .count()
497    }
498
499    /// Total ops across all types classified `OverflowFree`.
500    pub fn ops_overflow_free(&self) -> usize {
501        self.count_ops(OpClass::OverflowFree)
502    }
503
504    /// Total ops across all types classified `NeedsWiderScratch`.
505    pub fn ops_needs_wider(&self) -> usize {
506        self.count_ops(OpClass::NeedsWiderScratch)
507    }
508
509    /// Total ops across all types classified `Unbounded`.
510    pub fn ops_unbounded(&self) -> usize {
511        self.count_ops(OpClass::Unbounded)
512    }
513
514    /// Refined types whose carrier may lower to a raw `i64`
515    /// ([`RefinedTypeInterval::raw_i64_eligible`]) — the
516    /// recognizer's headline count, surfaced by `--explain-passes`. This
517    /// is the "proof the recognizer fires on the right types" metric;
518    /// nothing in codegen / runtime / proof consumes it.
519    pub fn raw_i64_eligible(&self) -> usize {
520        self.types.values().filter(|t| t.raw_i64_eligible()).count()
521    }
522
523    fn count_ops(&self, class: OpClass) -> usize {
524        self.types
525            .values()
526            .flat_map(|t| t.ops.iter())
527            .filter(|(_, c)| *c == class)
528            .count()
529    }
530}
531
532/// Derive a constant interval from a refinement invariant.
533///
534/// Returns `(interval, interval_known)`. Recognizes exactly the
535/// comparison / `Bool.and` shapes the refinement examples produce; any
536/// other shape (`Bool.or`, a bare identifier, a structural carrier,
537/// non-literal bounds) yields `(Interval::unbounded(), false)` — the
538/// conservative decline.
539///
540/// The free variable matched against the bound is taken from
541/// `pred.free_vars[0]` (the smart constructor's parameter). Operand-
542/// flipped comparisons (`k <= n` as well as `n >= k`) are normalized.
543pub fn interval_of_invariant(pred: &Predicate) -> (Interval, bool) {
544    let Some((var, _)) = pred.free_vars.first() else {
545        return (Interval::unbounded(), false);
546    };
547    match interval_of_resolved(&pred.expr, var) {
548        Some(i) => (i, true),
549        None => (Interval::unbounded(), false),
550    }
551}
552
553/// Recognize a single resolved predicate expression as an interval
554/// over `var`. `None` = unrecognized shape (caller declines).
555fn interval_of_resolved(
556    expr: &Spanned<crate::ir::hir::ResolvedExpr>,
557    var: &str,
558) -> Option<Interval> {
559    use crate::ir::hir::{ResolvedCallee, ResolvedExpr};
560    match &expr.node {
561        // Compound guard `Bool.and(l, r)` → intersect both sides.
562        // Recurses, so deeply-nested conjunctions also collapse.
563        ResolvedExpr::Call(ResolvedCallee::Builtin(name), args)
564            if name == "Bool.and" && args.len() == 2 =>
565        {
566            let l = interval_of_resolved(&args[0], var)?;
567            let r = interval_of_resolved(&args[1], var)?;
568            Some(l.intersect(r))
569        }
570        // A comparison between the predicate variable and a literal.
571        ResolvedExpr::BinOp(op, lhs, rhs) => interval_of_comparison(*op, lhs, rhs, var),
572        // Anything else (Bool.or, Bool.not, arbitrary predicate, the
573        // bare variable) is not a recognized interval shape.
574        _ => None,
575    }
576}
577
578/// Recognize `var <op> k` (or the operand-flipped `k <op> var`) as an
579/// interval. Only `>`, `>=`, `<`, `<=` against an integer literal
580/// produce a bound; `==` / `!=` and non-literal operands decline.
581fn interval_of_comparison(
582    op: BinOp,
583    lhs: &Spanned<crate::ir::hir::ResolvedExpr>,
584    rhs: &Spanned<crate::ir::hir::ResolvedExpr>,
585    var: &str,
586) -> Option<Interval> {
587    // Identify which side is the variable and which is the literal,
588    // normalizing the operator if the operands are flipped.
589    let (op, k) = if is_var(lhs, var) {
590        (op, int_literal(rhs)?)
591    } else if is_var(rhs, var) {
592        (flip_comparison(op)?, int_literal(lhs)?)
593    } else {
594        return None;
595    };
596    let k = k as i128;
597    match op {
598        BinOp::Gte => Some(Interval::ge(k)),    // n >= k  → [k, +inf]
599        BinOp::Gt => Some(Interval::ge(k + 1)), // n >  k  → [k+1, +inf]
600        BinOp::Lte => Some(Interval::le(k)),    // n <= k  → [-inf, k]
601        BinOp::Lt => Some(Interval::le(k - 1)), // n <  k  → [-inf, k-1]
602        _ => None,
603    }
604}
605
606/// `true` when the resolved expression is a reference to `var`
607/// (whether it survived as a bare `Ident` or carries a resolved slot).
608fn is_var(expr: &Spanned<crate::ir::hir::ResolvedExpr>, var: &str) -> bool {
609    use crate::ir::hir::ResolvedExpr;
610    match &expr.node {
611        ResolvedExpr::Ident(name) => name == var,
612        ResolvedExpr::Resolved { name, .. } => name == var,
613        _ => false,
614    }
615}
616
617/// Extract an integer literal from a resolved leaf, if it is one.
618fn int_literal(expr: &Spanned<crate::ir::hir::ResolvedExpr>) -> Option<i64> {
619    use crate::ir::hir::ResolvedExpr;
620    match &expr.node {
621        ResolvedExpr::Literal(Literal::Int(k)) => Some(*k),
622        ResolvedExpr::Neg(inner) => match &inner.node {
623            ResolvedExpr::Literal(Literal::Int(k)) => Some(-*k),
624            _ => None,
625        },
626        _ => None,
627    }
628}
629
630/// Operator with operands swapped: `a < b` ⇔ `b > a`, etc.
631fn flip_comparison(op: BinOp) -> Option<BinOp> {
632    match op {
633        BinOp::Lt => Some(BinOp::Gt),
634        BinOp::Gt => Some(BinOp::Lt),
635        BinOp::Lte => Some(BinOp::Gte),
636        BinOp::Gte => Some(BinOp::Lte),
637        _ => None,
638    }
639}
640
641/// Classify one operation function over a refined type.
642///
643/// `op_fn` is the raw-AST fn def the defining module exposes (e.g.
644/// `IntRange.add`). `carrier_interval` is the interval of the refined
645/// type's carrier. `refined_type_name` is the source name of that
646/// type so the analyzer can tell a refined-typed parameter apart from
647/// a plain `Int` one.
648///
649/// The body is abstractly interpreted bottom-up over [`Interval`]: a
650/// `param.value` access on a refined-typed parameter contributes
651/// `carrier_interval`; an integer literal contributes a point; `+` /
652/// `-` / `*` combine sub-intervals via saturating interval arithmetic;
653/// the smart-constructor call (`fromInt(inner)`, where the callee is
654/// the type's actual constructor `constructor_fn`) is transparent — the
655/// classified intermediate is the argument feeding the guard. ANY OTHER
656/// call (a user helper, a builtin, an unknown callee), a plain-`Int`
657/// operand entering the arithmetic, or any unrecognized shape evaluates
658/// to `Interval::unbounded()` — the conservative decline.
659///
660/// The verdict is NOT the tail interval alone. Every binding in the
661/// op body and every intermediate subexpression node within an
662/// expression joins its own interval into a worst-case accumulator;
663/// the returned [`OpClass`] is `of_interval` over the JOIN of the tail
664/// interval and every intermediate/binding interval. So an out-of-`i64`
665/// (or unbounded) value computed in an earlier statement, or buried in
666/// a tail subexpression that later cancels out, still demotes the op
667/// below `OverflowFree`.
668pub fn classify_op(
669    op_fn: &FnDef,
670    carrier_interval: Interval,
671    refined_type_name: &str,
672    carrier_field: &str,
673    constructor_fn: &str,
674) -> OpClass {
675    // Map each parameter name to whether it carries the refined type.
676    let mut refined_params: HashMap<&str, bool> = HashMap::new();
677    for (pname, ptype) in &op_fn.params {
678        refined_params.insert(pname.as_str(), ptype == refined_type_name);
679    }
680    let ctx = OpCtx {
681        carrier_interval,
682        carrier_field,
683        refined_params: &refined_params,
684        constructor_fn,
685    };
686
687    // Worst-case (least-headroom) interval seen anywhere in the body.
688    // Starts at a point so that a body with no recognized arithmetic
689    // intermediate stays neutral; every binding and the tail join into
690    // it. `worst` is the running join the evaluator widens at each
691    // arithmetic / call / negation node.
692    let mut worst = Interval::point(0);
693
694    // Walk every statement in execution order. Each binding's value
695    // expression and the tail expression are evaluated; both their
696    // final intervals and every intermediate node they contain land in
697    // `worst`.
698    let FnBody::Block(stmts) = op_fn.body.as_ref();
699    for stmt in stmts {
700        let value = match stmt {
701            Stmt::Expr(e) | Stmt::Binding(_, _, e) => e,
702        };
703        let i = eval_expr(value, &ctx, &mut worst);
704        worst = join(worst, i);
705    }
706
707    OpClass::of_interval(worst)
708}
709
710/// Read-only context threaded through the op-body abstract evaluator.
711struct OpCtx<'a> {
712    carrier_interval: Interval,
713    carrier_field: &'a str,
714    refined_params: &'a HashMap<&'a str, bool>,
715    /// The refined type's actual smart constructor name (`"fromInt"`).
716    /// ONLY a one-arg call to this exact callee is peeled transparently;
717    /// every other call evaluates to `Interval::unbounded()`.
718    constructor_fn: &'a str,
719}
720
721/// Convex hull of two intervals — the "worst case includes both"
722/// join. Widening to the union keeps the headroom verdict sound: if
723/// either operand escapes `i64`, so does the join.
724fn join(a: Interval, b: Interval) -> Interval {
725    Interval {
726        lo: a.lo.min(b.lo),
727        hi: a.hi.max(b.hi),
728    }
729}
730
731/// Abstractly evaluate a raw-AST expression to its interval, joining
732/// the result of every arithmetic / negation / constructor-call node
733/// into `worst` so the caller can classify over the whole body rather
734/// than just the final value. Unknown or unbounded shapes evaluate to
735/// `Interval::unbounded()`, which propagates through the arithmetic and
736/// forces an `Unbounded` class — the conservative direction.
737fn eval_expr(expr: &Spanned<Expr>, ctx: &OpCtx<'_>, worst: &mut Interval) -> Interval {
738    match &expr.node {
739        Expr::Literal(Literal::Int(k)) => Interval::point(*k as i128),
740        // `param.value` where `param` is a refined-typed parameter and
741        // the field is the carrier field → the carrier interval. Any
742        // other field access (or access on a non-refined param) is an
743        // unknown integer. The object leaf may be a bare `Ident` (proof
744        // mode, resolve off) or a `Resolved` slot (resolve on) — both
745        // carry the param name.
746        Expr::Attr(obj, field) => {
747            if field == ctx.carrier_field
748                && let Some(pname) = param_name(obj)
749                && ctx.refined_params.get(pname).copied() == Some(true)
750            {
751                ctx.carrier_interval
752            } else {
753                Interval::unbounded()
754            }
755        }
756        Expr::BinOp(op, lhs, rhs) => {
757            let l = eval_expr(lhs, ctx, worst);
758            let r = eval_expr(rhs, ctx, worst);
759            let result = match op {
760                BinOp::Add => l.add(r),
761                BinOp::Sub => l.sub(r),
762                BinOp::Mul => l.mul(r),
763                // Division and comparisons don't feed the headroom
764                // question we model; decline rather than guess.
765                _ => Interval::unbounded(),
766            };
767            // This arithmetic node is itself an `i64` intermediate —
768            // record it before it is folded into an enclosing op (which
769            // may cancel it back into range, as in `(a + MAX) - MAX`).
770            *worst = join(*worst, result);
771            result
772        }
773        Expr::Neg(inner) => {
774            let result = Interval::point(0).sub(eval_expr(inner, ctx, worst));
775            *worst = join(*worst, result);
776            result
777        }
778        // The smart constructor is transparent: the intermediate we
779        // care about is the value handed to the guard, i.e. its
780        // argument. ONLY the type's real constructor (`constructor_fn`)
781        // is peeled — a one-arg helper like `widen(x)` must NOT be
782        // treated as identity, or its widened return value would be
783        // hidden. Every non-constructor call is opaque → unbounded.
784        Expr::FnCall(callee, args)
785            if args.len() == 1 && is_constructor_call(callee, ctx.constructor_fn) =>
786        {
787            eval_expr(&args[0], ctx, worst)
788        }
789        // A bare identifier of a refined param read without `.value`,
790        // a plain-`Int` param, any non-constructor call, or any other
791        // shape: unbounded.
792        _ => Interval::unbounded(),
793    }
794}
795
796/// `true` for a `FnCall` whose callee is a bare identifier naming the
797/// refined type's actual smart constructor. A top-level fn name like
798/// `fromInt` stays an `Ident` through the resolver (it has no local
799/// slot), so checking `Ident` + name equality covers both proof mode
800/// (resolve off) and the resolved pipeline. Module-qualified or
801/// computed callees, and any other helper, are not the constructor.
802fn is_constructor_call(callee: &Spanned<Expr>, constructor_fn: &str) -> bool {
803    matches!(&callee.node, Expr::Ident(name) if name == constructor_fn)
804}
805
806/// Extract the parameter name a leaf refers to, whether it survived as
807/// a bare `Ident` (resolve off) or was rewritten to a `Resolved` slot
808/// (resolve on). `None` for any other expression.
809fn param_name(expr: &Spanned<Expr>) -> Option<&str> {
810    match &expr.node {
811        Expr::Ident(name) => Some(name.as_str()),
812        Expr::Resolved { name, .. } => Some(name.as_str()),
813        _ => None,
814    }
815}
816
817/// Run the interval analysis over every refined type in `refined`,
818/// classifying each one's module-exposed arithmetic ops.
819///
820/// `refined` is `ProofIR.refined_types`, already keyed by opaque
821/// `TypeId` and scoped per module by `populate_refined_types`. `inputs`
822/// is the same [`ProofLowerInputs`] the proof-lower stage consumed, so
823/// the op fns are looked up with the identical module-scoped discipline
824/// (never bare-name matching).
825pub fn analyze(
826    refined: &HashMap<TypeId, RefinedTypeDecl>,
827    inputs: &ProofLowerInputs<'_>,
828) -> IntervalAnalysisResult {
829    let mut result = IntervalAnalysisResult::default();
830    let symbols = inputs.symbol_table;
831
832    for (type_id, decl) in refined {
833        let (interval, interval_known) = interval_of_invariant(&decl.invariant);
834
835        // Find the module scope this type lives in by resolving its
836        // opaque `TypeId` back through the symbol table, then walk that
837        // scope's fns for arithmetic ops over the type. Same scoping
838        // discipline as `populate_refined_types` — two same-named
839        // types in different modules never share an op set.
840        let scope = scope_of_type(*type_id, decl, inputs, symbols);
841
842        // Recover this type's actual smart constructor name in the SAME
843        // module scope, so the op-body evaluator only peels the real
844        // `fromInt`-style wrapper transparently (never an arbitrary
845        // one-arg helper). If the refinement shape can't be re-resolved
846        // (it always can here — the type was lifted from it), there is
847        // no trustworthy constructor to gate on, so every op declines.
848        let constructor_fn = crate::codegen::common::refinement_info_for_in_scope(
849            &decl.name,
850            inputs,
851            scope.as_deref(),
852        )
853        .map(|info| info.constructor_fn.to_string());
854
855        let ops = classify_ops_in_scope(
856            decl,
857            interval,
858            interval_known,
859            constructor_fn.as_deref(),
860            inputs.pure_fns_in_scope(scope.as_deref()),
861        );
862
863        result.types.insert(
864            *type_id,
865            RefinedTypeInterval {
866                type_id: *type_id,
867                name: decl.name.clone(),
868                interval,
869                interval_known,
870                ops,
871            },
872        );
873    }
874    result
875}
876
877/// Resolve the module scope (`None` = entry, `Some(prefix)` = a dep
878/// module) that declares the refined type with `type_id`. Matches the
879/// `TypeKey` the symbol table holds for that id against the candidate
880/// scopes, so the answer is keyed by opaque identity, never bare name.
881fn scope_of_type(
882    type_id: TypeId,
883    decl: &RefinedTypeDecl,
884    inputs: &ProofLowerInputs<'_>,
885    symbols: &crate::ir::SymbolTable,
886) -> Option<String> {
887    for scope in inputs.scopes() {
888        let key = match &scope {
889            Some(prefix) => crate::ir::TypeKey::in_module(prefix.clone(), &decl.name),
890            None => crate::ir::TypeKey::entry(&decl.name),
891        };
892        if symbols.type_id_of(&key) == Some(type_id) {
893            return scope;
894        }
895    }
896    None
897}
898
899/// Classify every arithmetic op a module exposes over the refined
900/// type. An op qualifies when it takes at least one parameter of the
901/// refined type; ops with no refined-typed parameter (the smart
902/// constructor `fromInt(n: Int)`, the unwrapper `toInt(n: T) -> Int`)
903/// are skipped — they aren't arithmetic over two carriers.
904fn classify_ops_in_scope(
905    decl: &RefinedTypeDecl,
906    interval: Interval,
907    interval_known: bool,
908    constructor_fn: Option<&str>,
909    fns: Vec<&FnDef>,
910) -> Vec<(String, OpClass)> {
911    let mut ops = Vec::new();
912    for fd in fns {
913        // The op must take the refined type as a parameter AND its
914        // body must do carrier arithmetic (a `param.value` access).
915        // `toInt` takes the refined type but just projects the
916        // carrier — no arithmetic intermediate — so it never needs an
917        // overflow verdict.
918        let takes_refined = fd.params.iter().any(|(_, t)| t == &decl.name);
919        if !takes_refined || !body_does_carrier_arithmetic(fd, &decl.carrier_field) {
920            continue;
921        }
922        // When the carrier interval is unknown (declined invariant), or
923        // we couldn't recover the smart constructor to gate the
924        // transparent peel on, the op is necessarily `Unbounded` —
925        // there's no derived bound to reason from, and without the
926        // constructor name no call can be safely treated as identity.
927        let class = match (interval_known, constructor_fn) {
928            (true, Some(ctor)) => classify_op(fd, interval, &decl.name, &decl.carrier_field, ctor),
929            _ => OpClass::Unbounded,
930        };
931        ops.push((fd.name.clone(), class));
932    }
933    ops
934}
935
936/// `true` when the fn body's tail expression contains a `BinOp`
937/// (Add/Sub/Mul) anywhere — i.e. it computes an arithmetic
938/// intermediate over the carrier rather than just projecting it.
939fn body_does_carrier_arithmetic(fd: &FnDef, _carrier_field: &str) -> bool {
940    let FnBody::Block(stmts) = fd.body.as_ref();
941    stmts.iter().any(|s| match s {
942        Stmt::Expr(e) | Stmt::Binding(_, _, e) => expr_has_arithmetic(e),
943    })
944}
945
946/// Recursively scan for an arithmetic `BinOp` node.
947fn expr_has_arithmetic(expr: &Spanned<Expr>) -> bool {
948    match &expr.node {
949        Expr::BinOp(BinOp::Add | BinOp::Sub | BinOp::Mul, _, _) => true,
950        Expr::BinOp(_, l, r) => expr_has_arithmetic(l) || expr_has_arithmetic(r),
951        Expr::FnCall(_, args) => args.iter().any(expr_has_arithmetic),
952        Expr::Attr(o, _) => expr_has_arithmetic(o),
953        Expr::Neg(i) | Expr::ErrorProp(i) => expr_has_arithmetic(i),
954        _ => false,
955    }
956}
957
958#[cfg(test)]
959mod tests {
960    use super::*;
961    use crate::ast::SourceLine;
962    use crate::ir::hir::ResolvedExpr;
963    use crate::ir::proof_ir::QuantifierType;
964
965    const LINE: SourceLine = 0;
966
967    fn sp_r(node: ResolvedExpr) -> Spanned<ResolvedExpr> {
968        Spanned::new(node, LINE)
969    }
970
971    fn ident_r(name: &str) -> Spanned<ResolvedExpr> {
972        sp_r(ResolvedExpr::Ident(name.to_string()))
973    }
974
975    fn int_r(k: i64) -> Spanned<ResolvedExpr> {
976        sp_r(ResolvedExpr::Literal(Literal::Int(k)))
977    }
978
979    fn cmp_r(
980        op: BinOp,
981        l: Spanned<ResolvedExpr>,
982        r: Spanned<ResolvedExpr>,
983    ) -> Spanned<ResolvedExpr> {
984        sp_r(ResolvedExpr::BinOp(op, Box::new(l), Box::new(r)))
985    }
986
987    fn pred(expr: Spanned<ResolvedExpr>) -> Predicate {
988        Predicate {
989            free_vars: vec![("n".to_string(), QuantifierType::Plain("Int".to_string()))],
990            expr,
991        }
992    }
993
994    // ── Bound saturating arithmetic ─────────────────────────────────
995
996    #[test]
997    fn bound_posinf_plus_finite_is_posinf() {
998        assert_eq!(Bound::PosInf.add(Bound::Finite(5)), Bound::PosInf);
999        assert_eq!(Bound::Finite(5).add(Bound::PosInf), Bound::PosInf);
1000    }
1001
1002    #[test]
1003    fn bound_finite_mul_exact_in_i128() {
1004        // 100 * 100 = 10_000 — exact, no saturation.
1005        assert_eq!(
1006            Bound::Finite(100).mul(Bound::Finite(100)),
1007            Bound::Finite(10_000)
1008        );
1009    }
1010
1011    #[test]
1012    fn bound_i64_max_squared_is_exact_finite_not_i64() {
1013        // `(i64::MAX)^2 ≈ 2^126` fits i128 (max ≈ 2^127), so the
1014        // product is an EXACT finite i128 value — it does NOT wrap and
1015        // does NOT need to saturate. The soundness consequence: such a
1016        // product is finite-but-outside-i64, so the op that produced
1017        // it classifies `NeedsWiderScratch`, never `OverflowFree`.
1018        let m = Bound::Finite(i64::MAX as i128);
1019        let expected = (i64::MAX as i128) * (i64::MAX as i128);
1020        assert_eq!(m.mul(m), Bound::Finite(expected));
1021        assert!(!Bound::Finite(expected).fits_i64());
1022    }
1023
1024    #[test]
1025    fn bound_i128_overflow_saturates_not_wraps() {
1026        // The keystone soundness unit test: a product that overflows
1027        // i128 itself MUST saturate to ±inf, never wrap to a small (or
1028        // negative) finite value that would fake headroom. `i128::MAX *
1029        // i128::MAX` is the canonical case.
1030        let big = Bound::Finite(i128::MAX);
1031        assert_eq!(big.mul(big), Bound::PosInf);
1032        // Opposite signs saturate to -inf.
1033        let neg = Bound::Finite(i128::MIN + 1);
1034        assert_eq!(big.mul(neg), Bound::NegInf);
1035        // Addition overflow saturates too.
1036        assert_eq!(big.add(Bound::Finite(1)), Bound::PosInf);
1037        assert_eq!(
1038            Bound::Finite(i128::MIN).add(Bound::Finite(-1)),
1039            Bound::NegInf
1040        );
1041    }
1042
1043    #[test]
1044    fn bound_neg_min_saturates() {
1045        // i128::MIN cannot be negated in i128 → saturates to +inf.
1046        assert_eq!(Bound::Finite(i128::MIN).neg(), Bound::PosInf);
1047    }
1048
1049    #[test]
1050    fn interval_add_keeps_finite_in_band() {
1051        let a = Interval::between(0, 100);
1052        let sum = a.add(a);
1053        assert_eq!(sum, Interval::between(0, 200));
1054        assert!(sum.fits_i64());
1055    }
1056
1057    #[test]
1058    fn interval_mul_handles_negatives() {
1059        // [-2, 3] * [-2, 3] → min of {4,-6,-6,9} = -6, max = 9.
1060        let a = Interval::between(-2, 3);
1061        assert_eq!(a.mul(a), Interval::between(-6, 9));
1062    }
1063
1064    #[test]
1065    fn contains_point_respects_both_bounds_and_infinities() {
1066        let band = Interval::between(0, 100);
1067        // Endpoints inclusive, interior in, outside out.
1068        assert!(band.contains_point(0));
1069        assert!(band.contains_point(100));
1070        assert!(band.contains_point(50));
1071        assert!(!band.contains_point(-1));
1072        assert!(!band.contains_point(101));
1073        // One-sided bands: `n >= 0` contains every non-negative, no negative.
1074        let ge0 = Interval::ge(0);
1075        assert!(ge0.contains_point(0));
1076        assert!(ge0.contains_point(i64::MAX as i128));
1077        assert!(!ge0.contains_point(-1));
1078        // The fully-unbounded interval contains everything.
1079        assert!(Interval::unbounded().contains_point(i128::MIN));
1080        assert!(Interval::unbounded().contains_point(i128::MAX));
1081    }
1082
1083    // ── interval_of_invariant: 5 recognized shapes ─────────────────
1084
1085    #[test]
1086    fn invariant_ge_natural() {
1087        // n >= 0  → [0, +inf]
1088        let (i, known) = interval_of_invariant(&pred(cmp_r(BinOp::Gte, ident_r("n"), int_r(0))));
1089        assert!(known);
1090        assert_eq!(i, Interval::ge(0));
1091    }
1092
1093    #[test]
1094    fn invariant_gt_positive() {
1095        // n > 0  → [1, +inf]
1096        let (i, known) = interval_of_invariant(&pred(cmp_r(BinOp::Gt, ident_r("n"), int_r(0))));
1097        assert!(known);
1098        assert_eq!(i, Interval::ge(1));
1099    }
1100
1101    #[test]
1102    fn invariant_lte() {
1103        // n <= 100  → [-inf, 100]
1104        let (i, known) = interval_of_invariant(&pred(cmp_r(BinOp::Lte, ident_r("n"), int_r(100))));
1105        assert!(known);
1106        assert_eq!(i, Interval::le(100));
1107    }
1108
1109    #[test]
1110    fn invariant_lt() {
1111        // n < 10  → [-inf, 9]
1112        let (i, known) = interval_of_invariant(&pred(cmp_r(BinOp::Lt, ident_r("n"), int_r(10))));
1113        assert!(known);
1114        assert_eq!(i, Interval::le(9));
1115    }
1116
1117    #[test]
1118    fn invariant_bool_and_intrange() {
1119        // Bool.and(n >= 0, n <= 100)  → [0, 100]
1120        use crate::ir::hir::ResolvedCallee;
1121        let and = sp_r(ResolvedExpr::Call(
1122            ResolvedCallee::Builtin("Bool.and".to_string()),
1123            vec![
1124                cmp_r(BinOp::Gte, ident_r("n"), int_r(0)),
1125                cmp_r(BinOp::Lte, ident_r("n"), int_r(100)),
1126            ],
1127        ));
1128        let (i, known) = interval_of_invariant(&pred(and));
1129        assert!(known);
1130        assert_eq!(i, Interval::between(0, 100));
1131    }
1132
1133    #[test]
1134    fn invariant_operand_flipped() {
1135        // 0 <= n  (literal on the left) normalizes to n >= 0 → [0, +inf]
1136        let (i, known) = interval_of_invariant(&pred(cmp_r(BinOp::Lte, int_r(0), ident_r("n"))));
1137        assert!(known);
1138        assert_eq!(i, Interval::ge(0));
1139    }
1140
1141    // ── interval_of_invariant: declined shapes ─────────────────────
1142
1143    #[test]
1144    fn invariant_bool_or_declines() {
1145        use crate::ir::hir::ResolvedCallee;
1146        let or = sp_r(ResolvedExpr::Call(
1147            ResolvedCallee::Builtin("Bool.or".to_string()),
1148            vec![
1149                cmp_r(BinOp::Gte, ident_r("n"), int_r(0)),
1150                cmp_r(BinOp::Lte, ident_r("n"), int_r(100)),
1151            ],
1152        ));
1153        let (i, known) = interval_of_invariant(&pred(or));
1154        assert!(!known);
1155        assert_eq!(i, Interval::unbounded());
1156    }
1157
1158    #[test]
1159    fn invariant_bare_ident_declines() {
1160        let (i, known) = interval_of_invariant(&pred(ident_r("n")));
1161        assert!(!known);
1162        assert_eq!(i, Interval::unbounded());
1163    }
1164
1165    #[test]
1166    fn invariant_non_literal_bound_declines() {
1167        // n >= m  (m is another variable, not a literal) → decline.
1168        let (i, known) =
1169            interval_of_invariant(&pred(cmp_r(BinOp::Gte, ident_r("n"), ident_r("m"))));
1170        assert!(!known);
1171        assert_eq!(i, Interval::unbounded());
1172    }
1173
1174    // ── fits_i64 boundary ──────────────────────────────────────────
1175
1176    #[test]
1177    fn fits_i64_at_boundary() {
1178        assert!(Interval::between(0, i64::MAX as i128).fits_i64());
1179        // One past i64::MAX no longer fits → NeedsWiderScratch band.
1180        let over = Interval::between(0, i64::MAX as i128 + 1);
1181        assert!(!over.fits_i64());
1182        assert!(over.is_finite());
1183        assert_eq!(OpClass::of_interval(over), OpClass::NeedsWiderScratch);
1184    }
1185
1186    #[test]
1187    fn opclass_overflow_free_vs_unbounded() {
1188        assert_eq!(
1189            OpClass::of_interval(Interval::between(0, 200)),
1190            OpClass::OverflowFree
1191        );
1192        // [0, +inf] (Natural) is NOT overflow-free.
1193        assert_eq!(OpClass::of_interval(Interval::ge(0)), OpClass::Unbounded);
1194    }
1195
1196    // ── widen ───────────────────────────────────────────────────────
1197
1198    #[test]
1199    fn widen_stable_endpoint_kept() {
1200        // No endpoint moved outward → the result is just the enclosing hull
1201        // (here identical to both, which equal each other).
1202        let a = Interval::between(0, 10);
1203        assert_eq!(a.widen(a), a);
1204    }
1205
1206    #[test]
1207    fn widen_descending_lo_jumps_to_neg_inf() {
1208        // `lo` descended (10 → 5) → -inf; `hi` stable.
1209        let prev = Interval::between(10, 20);
1210        let next = Interval::between(5, 20);
1211        assert_eq!(
1212            prev.widen(next),
1213            Interval {
1214                lo: Bound::NegInf,
1215                hi: Bound::Finite(20),
1216            }
1217        );
1218    }
1219
1220    #[test]
1221    fn widen_ascending_hi_jumps_to_pos_inf() {
1222        // `hi` ascended (20 → 30) → +inf; `lo` stable.
1223        let prev = Interval::between(0, 20);
1224        let next = Interval::between(0, 30);
1225        assert_eq!(
1226            prev.widen(next),
1227            Interval {
1228                lo: Bound::Finite(0),
1229                hi: Bound::PosInf,
1230            }
1231        );
1232    }
1233
1234    #[test]
1235    fn widen_is_enlarging_only() {
1236        // Result is always a superset of `next`: an inward-moving endpoint
1237        // is NOT narrowed past `next` (widen only ever enlarges).
1238        let prev = Interval::between(0, 100);
1239        let next = Interval::between(5, 90); // both endpoints moved INWARD
1240        let w = prev.widen(next);
1241        // Superset of next on both sides.
1242        assert!(w.lo.le(next.lo), "lo must not rise above next.lo");
1243        assert!(next.hi.le(w.hi), "hi must not fall below next.hi");
1244        // No outward move, so it stays the enclosing hull [0, 100].
1245        assert_eq!(w, Interval::between(0, 100));
1246    }
1247}