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}