diff --git a/Cargo.toml b/Cargo.toml index 9533966..1096b86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,6 +81,11 @@ name = "exact" harness = false required-features = [ "bench", "exact" ] +[[bench]] +name = "interval" +harness = false +required-features = [ "bench" ] + [profile.release] lto = "fat" codegen-units = 1 diff --git a/README.md b/README.md index 3786703..4592779 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ while keeping the API intentionally small and explicit. - `Vector` for fixed-length `f64` vectors backed by `[f64; D]` - `Matrix` for fixed-size square `f64` matrices backed by `[[f64; D]; D]` +- `Interval` and `IntervalMatrix` for outward-rounded, + proof-bearing determinant filters through D=7 - `RationalVector` and `RationalMatrix` for exact rational inputs behind the optional `"exact"` feature - `Lu` for LU factorization with partial pivoting (solve + det) @@ -40,6 +42,13 @@ factorization tolerances are rejection thresholds, not accuracy guarantees. For D≤4, direct determinants can be paired with a conservative absolute roundoff bound when its range preconditions hold. +Derived binary64 expressions can instead be assembled with `Interval` +subtraction, addition, multiplication, negation, and square. The resulting +`IntervalMatrix` determinant sign is certified through D=7 when its enclosure +separates zero; the singleton `[0, 0]` also certifies exact zero. Every other +overlap with zero is explicitly inconclusive. This default-feature surface is +distinct from arbitrary-precision exact arithmetic. + With `features = ["exact"]`, callers can either lift stored binary64 inputs losslessly or supply already-exact rational inputs for exact determinant signs, determinant values, and solves. Exactness over binary64 input starts at the @@ -61,6 +70,8 @@ for the algorithms, validity boundaries, and supporting references. semantics - ✅ Error-bounded f64 determinant filtering plus optional exact signs (`det_errbound`, `det_sign_exact`) +- ✅ Outward-rounded interval expressions and division-free determinant signs + through D=7, with explicit inconclusive evidence - ✅ Exact determinant values and linear solves via optional arbitrary-precision arithmetic (`det_exact`, `solve_exact`, strict/rounded f64 conversions) - ✅ Explicit algorithms (LU, solve, determinant) @@ -94,17 +105,24 @@ for current release planning. - You want explicit LU / LDLT / determinant APIs rather than a broad algebra toolkit - You need exact determinants, exact determinant signs, or exact linear solves for fixed-size systems +- You need a cheap, sound interval filter for determinant expressions assembled + from rounded binary64 operations - Robust predicates matter for geometry-style workloads near degeneracy - You prefer a default build with no runtime dependencies -## 🔢 Scalar types +## 🔢 Scalar and bounded-value types -The public scalar model deliberately has exactly two input domains: +The public point-value scalar model deliberately has two input domains: - finite `f64` through `Matrix` and `Vector` for floating-point work; - arbitrary-precision `BigRational` through `RationalMatrix` and `RationalVector` behind the optional `"exact"` feature. +`Interval` is a separate bounded-value layer over finite `f64` endpoints. It +encloses exact-real values during a small set of outward-rounded operations and +feeds `IntervalMatrix` determinant proofs; it does not make `Matrix` generic +over alternate scalars or provide a general interval package. + This is not a generic scalar-parameterized API. Exact support intentionally covers the robustness-sensitive operations that require it: determinant sign, determinant value, and linear solve, followed by explicit strict or rounded @@ -128,7 +146,8 @@ la-stack = "0.4.5" ### Feature flags -- `default`: no runtime dependencies +- `default`: no runtime dependencies; includes outward-rounded `Interval` and + `IntervalMatrix` APIs - `exact`: exact determinant signs, determinant values, and solves over stored `f64` values or caller-supplied `BigRational` inputs - `bench`: repository-development gate used only by benchmark targets and @@ -269,6 +288,57 @@ non-zero pivot; it does not misreport that numerical failure as an exact zero. Use `lu()` directly when you need a different tolerance policy, and use the exact determinant APIs when exact singularity classification matters. +## 📦 Outward-rounded interval determinants + +`Interval` encloses expression construction that has not yet been reduced to a +single stored `f64`. Point intervals preserve finite binary64 values exactly; +`try_from_subtraction`, `try_add`, `try_mul`, `negate`, and `try_square` enclose +the corresponding exact-real operations. `IntervalMatrix::det_sign()` then +uses a division-free subset expansion through D=7, returning positive, +negative, zero, or inconclusive evidence. + +```rust +use la_stack::prelude::*; + +fn main() -> Result<(), LaError> { + // Relative coordinates and the lifted norm retain their construction error. + let x = Interval::try_from_subtraction(0.1, 0.0)?; + let y = Interval::try_from_subtraction(0.1, 0.0)?; + let z = Interval::try_from_subtraction(0.1, 0.0)?; + let lifted = x + .try_square()? + .try_add(&y.try_square()?)? + .try_add(&z.try_square()?)?; + + let matrix = IntervalMatrix::<4>::from_rows([ + [Interval::ONE, Interval::ZERO, Interval::ZERO, Interval::ONE], + [Interval::ZERO, Interval::ONE, Interval::ZERO, Interval::ONE], + [Interval::ZERO, Interval::ZERO, Interval::ONE, Interval::ONE], + [x, y, z, lifted], + ]); + assert_eq!( + matrix.det_sign()?, + IntervalDeterminantSign::Negative, + ); + Ok(()) +} +``` + +Every successful interval keeps finite ordered endpoints. Subnormal bounds are +preserved, both signed zeros are treated as real zero and canonicalized to +`+0.0`, and underflowed nonzero products widen toward the least subnormal value. +If an exact result range cannot fit between finite binary64 endpoints, the +operation returns `LaError::IntervalRangeExhausted` with its interval operation +recorded in `ArithmeticOperation`. + +`Positive`, `Negative`, and `Zero` are proofs. `Inconclusive` only means that +the determinant enclosure overlaps zero; it must not be converted to equality +or singularity. A filtered-exact caller should rebuild the same derived +expression with `RationalMatrix` and call `det_sign()` when the interval result +is inconclusive or reports range failure. Lifting a finished `Matrix` with +`IntervalMatrix::from_matrix` encloses its stored entries, but cannot recover +rounding that occurred while those entries were assembled. + ## 🔬 Exact arithmetic (`"exact"` feature) The default build has **zero runtime dependencies**. Enable the optional @@ -567,6 +637,9 @@ out of the common prelude. |---|---|---|---| | `Vector` | `[f64; D]` | Finite fixed-length vector for input and computation | `try_new`, `as_array`, `into_array`, `dot`, `norm2_sq` | | `Matrix` | `[[f64; D]; D]` | Finite square matrix for input and computation | See below | +| `Interval` | Two finite ordered `f64` bounds | Outward-rounded exact-real enclosure | `try_new`, `point`, `try_from_subtraction`, `try_add`, `try_mul`, `negate`, `try_square` | +| `IntervalMatrix` | `[[Interval; D]; D]` | Division-free determinant enclosure and sign proof through D=7 | `from_rows`, `try_from_point_rows`, `from_matrix`, `det`, `det_sign` | +| `IntervalDeterminantSign` | enum | Positive, negative, zero, or inconclusive determinant evidence | — | | `RationalVector`¹ | `[BigRational; D]` | Exact rational right-hand side and solution | `try_new`, `try_from_fn`, `as_array`, `into_array`, `get` | | `RationalMatrix`¹ | `[[BigRational; D]; D]` | Exact rational matrix for determinant and solve operations | `try_from_rows`, `try_from_fn`, `as_rows`, `det_sign`, `det`, `solve` | | `DeterminantWithErrorBound` | Opaque validated pair | Paired direct determinant and certified absolute bound | `determinant`, `absolute_error_bound` | @@ -578,8 +651,15 @@ out of the common prelude. | `ExactF64Conversion`¹ | trait | Strict or explicitly rounded conversion of exact results to `f64` | `try_to_f64`, `to_rounded_f64` | `Matrix` and `Vector` use the intentional inline `f64` scalar model. -The exact-feature rational types retain fixed-size outer arrays while their -`BigRational` scalars use arbitrary-precision integer storage. +`IntervalMatrix` retains inline fixed-size storage and uses a fixed 128-entry +stack workspace for its supported determinant dimensions. The exact-feature +rational types retain fixed-size outer arrays while their `BigRational` scalars +use arbitrary-precision integer storage. + +For a runtime-selected interval dimension from 0 through +`MAX_INTERVAL_MATRIX_DIM` (7), `try_with_interval_matrix!` dispatches to a +concrete `IntervalMatrix`. This is useful when stable Rust cannot express a +derived const dimension such as `D + 1`. For a runtime dimension from 0 through `MAX_STACK_MATRIX_DISPATCH_DIM` (7), `try_with_stack_matrix!` dispatches to a concrete `Matrix` while preserving @@ -620,6 +700,9 @@ observed pivot magnitude, and tolerance, while exact-arithmetic singularity is identified separately. `LaError::NonFinite` retains the crate-wide non-finite contract but uses `NonFiniteOrigin`, `NonFiniteLocation`, and `ArithmeticOperation` to distinguish invalid inputs from computed overflow. +`LaError::InvertedInterval` preserves rejected finite bounds when the lower +endpoint exceeds the upper endpoint, and `LaError::IntervalRangeExhausted` +distinguishes finite-input interval range loss from a non-finite value. `InvalidToleranceReason` distinguishes negative from non-finite tolerances, and `PositiveSemidefiniteViolation` distinguishes negative LDLT pivots from a zero pivot with nonzero coupling. Match these public enums with a wildcard and use @@ -665,6 +748,11 @@ references. Releases produced with the rational-input harness include Criterion point estimates and confidence intervals for these rows; comparisons against a pre-API baseline retain them as explicit current-only measurements. +The focused `interval` Criterion suite covers conclusive and inconclusive +relative-coordinate lifted determinant signs at D=4 and the maximum supported +D=7 workload. Run it with `just bench-interval`; fixture validation stays +outside the timed closures. + | D | la-stack median (ns) | nalgebra median (ns) | faer median (ns) | reduction vs nalgebra (point est.) | reduction vs faer (point est.) | diff --git a/REFERENCES.md b/REFERENCES.md index f2cc252..8cffdee 100644 --- a/REFERENCES.md +++ b/REFERENCES.md @@ -23,6 +23,27 @@ No generated content was used without human oversight. ## Linear algebra algorithms +### Outward-rounded interval determinant sign + +`Interval` uses IEEE-754 round-to-nearest binary64 operations plus adjacent +representable values to enclose exact-real addition, subtraction, +multiplication, and square results (references 9–11). Addition and subtraction +use an error-free `TwoSum` residual [8]; multiplication independently compares +the exact integer-significand product with the rounded binary64 result, +including gradual underflow to zero. Results whose exact range cannot fit +between finite binary64 endpoints return a typed range failure rather than +storing infinity. For the broader standardized interval arithmetic model, see +[14]; this crate does not claim IEEE 1788 conformance. + +`IntervalMatrix::det()` evaluates the Leibniz expansion with a division-free +column-subset dynamic program. It uses `2^D` inline interval states and +`D × 2^(D-1)` coefficient products through D=7. A determinant interval strictly +separated from zero certifies its sign; `[0, 0]` certifies zero; every other +overlap is explicitly inconclusive. The determinant identity is standard +linear algebra (reference 12); the interval evaluation and subset-DP +organization are implemented specifically for this crate's small +fixed-dimension scope. + ### Absolute error bound for closed-form determinants `Matrix::det_errbound()` returns a conservative Shewchuk-style absolute error bound [8] @@ -150,3 +171,7 @@ algorithmic background. 13. Kalibera, Tomas, and Richard Jones. "Rigorous Benchmarking in Reasonable Time." *Proceedings of the 2013 International Symposium on Memory Management* (ISMM '13), 2013: 63–74. [DOI](https://doi.org/10.1145/2464157.2464160) +14. IEEE Computer Society. "IEEE Standard for Interval Arithmetic." + *IEEE Std 1788-2015*, 2015: 1–97. + [DOI](https://doi.org/10.1109/IEEESTD.2015.7140721) · + [IEEE record](https://standards.ieee.org/ieee/1788/4431/) diff --git a/benches/interval.rs b/benches/interval.rs new file mode 100644 index 0000000..72d3021 --- /dev/null +++ b/benches/interval.rs @@ -0,0 +1,127 @@ +#![forbid(unsafe_code)] + +//! Criterion coverage for conclusive and inconclusive interval determinants. + +use std::hint::black_box; + +use criterion::Criterion; + +use la_stack::{Interval, IntervalDeterminantSign, IntervalMatrix, LaError}; + +#[path = "common/bench_utils.rs"] +mod bench_utils; +use bench_utils::OrAbort; + +/// Assemble the relative-coordinate lifted matrix for a tetrahedral in-sphere +/// predicate whose interval determinant is conclusively negative. +fn conclusive_lifted_4x4() -> Result, LaError> { + let x = Interval::try_from_subtraction(0.1, 0.0)?; + let y = Interval::try_from_subtraction(0.1, 0.0)?; + let z = Interval::try_from_subtraction(0.1, 0.0)?; + let lifted = x + .try_square()? + .try_add(&y.try_square()?)? + .try_add(&z.try_square()?)?; + + Ok(IntervalMatrix::from_rows([ + [Interval::ONE, Interval::ZERO, Interval::ZERO, Interval::ONE], + [Interval::ZERO, Interval::ONE, Interval::ZERO, Interval::ONE], + [Interval::ZERO, Interval::ZERO, Interval::ONE, Interval::ONE], + [x, y, z, lifted], + ])) +} + +/// Assemble a lifted boundary case whose final coefficient retains one ULP of +/// expression uncertainty, forcing an inconclusive interval sign. +fn inconclusive_lifted_4x4() -> Result, LaError> { + Ok(IntervalMatrix::from_rows([ + [Interval::ONE, Interval::ZERO, Interval::ZERO, Interval::ONE], + [Interval::ZERO, Interval::ONE, Interval::ZERO, Interval::ONE], + [Interval::ZERO, Interval::ZERO, Interval::ONE, Interval::ONE], + [ + Interval::ONE, + Interval::ONE, + Interval::ONE, + Interval::try_new(3.0_f64.next_down(), 3.0_f64.next_up())?, + ], + ])) +} + +/// Assemble a six-coordinate lifted predicate matrix to exercise the maximum +/// supported D=7 subset-DP workload. +fn conclusive_lifted_7x7() -> Result, LaError> { + let mut matrix = IntervalMatrix::zero(); + for index in 0..6 { + matrix.set(index, index, Interval::ONE)?; + matrix.set(index, 6, Interval::ONE)?; + } + + let relative = Interval::try_from_subtraction(0.125, 0.0)?; + let square = relative.try_square()?; + let mut lifted = Interval::ZERO; + for column in 0..6 { + matrix.set(6, column, relative)?; + lifted = lifted.try_add(&square)?; + } + matrix.set(6, 6, lifted)?; + Ok(matrix) +} + +fn main() { + let conclusive_4 = + conclusive_lifted_4x4().or_abort("conclusive D=4 interval fixture construction"); + let inconclusive_4 = + inconclusive_lifted_4x4().or_abort("inconclusive D=4 interval fixture construction"); + let conclusive_7 = + conclusive_lifted_7x7().or_abort("conclusive D=7 interval fixture construction"); + + assert_eq!( + conclusive_4 + .det_sign() + .or_abort("conclusive D=4 interval fixture validation"), + IntervalDeterminantSign::Negative, + ); + assert_eq!( + inconclusive_4 + .det_sign() + .or_abort("inconclusive D=4 interval fixture validation"), + IntervalDeterminantSign::Inconclusive, + ); + assert_eq!( + conclusive_7 + .det_sign() + .or_abort("conclusive D=7 interval fixture validation"), + IntervalDeterminantSign::Negative, + ); + + let mut criterion = Criterion::default().configure_from_args(); + { + let mut group = criterion.benchmark_group("interval_det_sign"); + group.bench_function("d4_conclusive_lifted", |bencher| { + bencher.iter(|| { + let sign = black_box(&conclusive_4) + .det_sign() + .or_abort("D=4 conclusive interval determinant"); + let _ = black_box(sign); + }); + }); + group.bench_function("d4_inconclusive_lifted", |bencher| { + bencher.iter(|| { + let sign = black_box(&inconclusive_4) + .det_sign() + .or_abort("D=4 inconclusive interval determinant"); + let _ = black_box(sign); + }); + }); + group.bench_function("d7_conclusive_lifted", |bencher| { + bencher.iter(|| { + let sign = black_box(&conclusive_7) + .det_sign() + .or_abort("D=7 conclusive interval determinant"); + let _ = black_box(sign); + }); + }); + group.finish(); + } + criterion.final_summary(); +} diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index 94a7ee4..b1abaa2 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -28,6 +28,7 @@ the commands measure and where their outputs go. | Non-exact release-signal check against tags | `just performance-local-non-exact v0.4.5 v0.4.4` | | Fast saved-baseline loop | `just bench-save-baseline ` then `just bench-compare all-benches` | | Full crate comparison | `just bench-vs-linalg` | +| Interval determinant filter | `just bench-interval` | | README table and plot | `just performance-release` then `just performance-readme` | | Release report | `just performance-release v0.4.5 v0.4.4` | | Build docs from retained release inputs | `just performance-doc` | @@ -63,7 +64,7 @@ promotion in one command. ## Benchmark Suites -`la-stack` has two Criterion benchmark suites. +`la-stack` has three Criterion benchmark suites. Newly rendered reports use one table per selected suite. Dimension and adversarial-input group appear in a `Case` column instead of creating a separate @@ -88,6 +89,14 @@ suite compares row-cleared Bareiss operations with direct `BigRational` Gaussian operations over already-exact rational inputs across D=2-8. Use it to understand exact-arithmetic cost and track optimization progress. +**`interval`** (`benches/interval.rs`) measures the default-feature, +division-free interval determinant sign filter. Its fixtures cover a conclusive +4×4 relative-coordinate lifted predicate, the corresponding inconclusive +boundary regime, and a conclusive 7×7 lifted workload at the supported dimension +limit. Fixture construction and expected-sign validation occur outside the +timed closures. This suite is a focused kernel signal; it is not part of the +release-to-release `vs_linalg` or `exact` report schema. + ## Common Workflows ### Compare Current Code With The Latest Release diff --git a/docs/mathematical_basis.md b/docs/mathematical_basis.md index 0f0c4b1..c20259a 100644 --- a/docs/mathematical_basis.md +++ b/docs/mathematical_basis.md @@ -1,13 +1,15 @@ # Mathematical basis `la-stack` provides fixed-dimension numerical linear algebra over two deliberate -input domains. `Matrix` and `Vector` store finite IEEE 754 binary64 values; -their default algorithms operate in binary64 and are therefore approximate. The -optional `exact` feature can either lift each of those stored values to the exact -rational number it represents or accept caller-supplied `BigRational` values -through `RationalMatrix` and `RationalVector`. For either domain, -`Matrix` and `RationalMatrix` are dense `D × D` square matrices and the -corresponding vector type has length `D`. +point-value input domains plus one bounded layer. `Matrix` and `Vector` +store finite IEEE 754 binary64 values; their default algorithms operate in +binary64 and are therefore approximate. `Interval` and `IntervalMatrix` +enclose exact-real expression values between outward-rounded finite binary64 +bounds when interval operations are used throughout expression assembly. +Lifting an already-rounded `Matrix` encloses only its stored values. The +optional `exact` feature can either lift stored binary64 values to +the exact rational numbers they represent or accept caller-supplied +`BigRational` values through `RationalMatrix` and `RationalVector`. This document separates three questions that are easy to conflate: @@ -39,7 +41,7 @@ computation was rounded to `f64`. Caller-supplied `RationalMatrix` and `RationalVector` values instead enter the exact domain directly and do not pass through binary64. -`Matrix`, `Vector`, `Lu`, and `Ldlt` use inline fixed-size storage. +`Matrix`, `Vector`, `IntervalMatrix`, `Lu`, and `Ldlt` use inline fixed-size storage. Arbitrary-precision `BigInt` and `BigRational` values allocate when the `exact` feature is used. Const-generic `D` is not itself a mathematical dimension limit; “small, fixed dimension” is the intended performance scope. `try_with_stack_matrix!` is @@ -51,6 +53,50 @@ not provide certified forward, backward, or absolute error bounds. This includes dot products, squared norms, matrix norms, factorizations, and solves. Some kernels use FMA to reduce rounding steps, but that does not make them exact. +## Outward-rounded interval expressions + +`Interval` owns the invariant `-∞ < lower ≤ upper < +∞`. Its public constructor +rejects non-finite or inverted bounds, its fields are private, and every +arithmetic operation either returns another valid enclosure or a typed range +failure. Both signed-zero inputs represent exact real zero and are canonicalized +to `+0.0`; finite subnormal endpoints remain valid. + +Point construction introduces no width. Exact-real subtraction and interval +addition use an error-free `TwoSum` residual to determine whether the rounded +result is exact or which adjacent binary64 value is required for the outward +endpoint [8]. Multiplication decomposes each nonzero binary64 operand into its exact +integer significand and power of two, compares the exact 106-bit significand +product with the rounded result, and widens only in the required direction. +This comparison also handles products that underflow to zero: a positive result +is enclosed by `[0, f64::from_bits(1)]`, and a negative result by the mirrored +interval. Squaring uses multiplication bounds but gives every interval spanning +zero the exact lower bound zero. These guarantees rely on IEEE-754 binary64 +round-to-nearest, ties-to-even, and gradual underflow \[9-11\]. IEEE 1788 +provides the broader standardized interval arithmetic model \[14\]; this crate's +deliberately smaller, undecorated surface does not claim conformance. + +If the exact result lies outside `[-f64::MAX, f64::MAX]`, no interval with finite +binary64 endpoints can contain it. The operation then returns +`LaError::IntervalRangeExhausted` with the responsible interval +`ArithmeticOperation`; it never stores infinity as an interval bound. + +For D≤7, `IntervalMatrix::det()` evaluates the Leibniz determinant with subset +dynamic programming. A state for each column subset stores the determinant +enclosure for the corresponding leading-row minor, requiring 128 inline states +at D=7 and `D × 2^(D-1)` interval products. The expansion performs no division, +so a pivot interval containing zero cannot make the algorithm unsound. +`det_sign()` classifies a strictly positive or negative enclosure accordingly, +returns `Zero` only for the singleton `[0, 0]`, and returns `Inconclusive` for +every other overlap with zero. Inconclusive evidence is not a singularity +classification. + +Lifting a completed `Matrix` creates point intervals for its stored values. It +does not recover rounding from earlier subtraction, dot products, or lifted-norm +construction. Robust callers instead assemble those derived coefficients with +interval operations, use `IntervalMatrix::det_sign()` as a fast proof, and +rebuild the expression in `RationalMatrix` or another exact representation when +the filter is inconclusive or loses range. + ## Floating-point factorizations ### LU with partial pivoting @@ -236,6 +282,7 @@ required by `Matrix::ldlt`. | Positive-definite floating solve | `ldlt(tol)` then `solve` | Exact symmetry; computed pivots must exceed tolerance; success is not a certificate | | Floating determinant, any `D` | `det` | No certified bound; zero is not exact singularity | | `D ≤ 4` error-bounded determinant/sign test | `det_direct_with_errbound` | Sign is certified when estimate magnitude exceeds bound; otherwise inconclusive | +| Derived-expression determinant sign through `D ≤ 7` | `IntervalMatrix::det_sign` | Outward-rounded proof; overlap with zero is explicitly inconclusive | | Exact determinant sign | `det_sign_exact` | Exact for stored binary64 entries | | Exact determinant value or solve | `det_exact`, `solve_exact` | Exact for represented inputs | | Exact operations over preassembled rationals | `RationalMatrix::det_sign`, `det`, `solve` | No intermediate binary64 reconstruction | @@ -245,9 +292,9 @@ required by `Matrix::ldlt`. Orientation, in-sphere, and related geometric predicates can be reduced to determinant signs, which is why an adaptive exact sign is useful near degeneracy -\[8\]. `la-stack` supplies the determinant primitive; callers construct the -problem-specific predicate matrix and remain responsible for any rounding that -occurs during that construction. The crate originated to support +\[8\]. `la-stack` supplies both point-matrix and bounded-expression determinant +primitives; callers still own problem-specific matrix assembly and semantic +classification. The crate originated to support [`delaunay`](https://crates.io/crates/delaunay), but its matrix, factorization, and exact-arithmetic APIs are general numerical infrastructure. diff --git a/justfile b/justfile index 6cbb15b..60bc919 100644 --- a/justfile +++ b/justfile @@ -23,10 +23,10 @@ cargo_machete_version := "0.9.2" cargo_nextest_version := "0.9.143" cargo_update_version := "22.1.1" clippy_sarif_version := "0.8.0" -dprint_version := "0.57.0" +dprint_version := "0.57.1" git_cliff_version := "2.14.1" just_version := "1.58.0" -rumdl_version := "0.2.63" +rumdl_version := "0.2.64" sarif_fmt_version := "0.8.0" taplo_version := "0.10.0" typos_version := "1.50.1" @@ -270,6 +270,10 @@ bench-compile: bench-exact: cargo bench --locked --features bench,exact --bench exact +# Run the outward-rounded interval determinant benchmark suite. +bench-interval: + cargo bench --locked --features bench --bench interval + # Run the cheaper latest measurements used for latest-vs-last reports. bench-latest: bench-vs-linalg-la-stack bench-exact @@ -500,6 +504,8 @@ help-workflows: @echo " just bench-compile # Compile benches with warnings-as-errors" @echo " just bench-latest # Run cheap latest measurements" @echo " just bench-latest-vs-last # Run latest and compare against last" + @echo " just bench-exact # Run exact-arithmetic benchmarks" + @echo " just bench-interval # Run interval determinant benchmarks" @echo " just bench-save-last # Save full baseline as 'last'" @echo " just bench-vs-linalg # Run vs_linalg bench (optional filter)" @echo " just bench-vs-linalg-la-stack # Run la-stack rows from vs_linalg" diff --git a/pyproject.toml b/pyproject.toml index 7abef77..aa5295f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,10 +154,10 @@ package = true dev = [ "actionlint-py==1.7.12.24", "pytest==9.1.1", - "ruff==0.16.5", + "ruff==0.16.6", "semgrep==1.176.0", "shellcheck-py==0.11.0.1", "shfmt-py==4.1.0", - "ty==0.0.77", + "ty==0.0.78", "yamllint==1.38.0", ] diff --git a/src/error.rs b/src/error.rs index f0d0e5d..803c030 100644 --- a/src/error.rs +++ b/src/error.rs @@ -4,7 +4,11 @@ use core::fmt; -/// Floating-point operation that produced a non-finite intermediate or result. +/// Arithmetic operation associated with a computation failure. +/// +/// This identifies both non-finite intermediates/results through +/// [`NonFiniteOrigin::Computation`] and finite-endpoint range exhaustion through +/// [`LaError::IntervalRangeExhausted`]. /// /// # Examples /// ``` @@ -31,6 +35,16 @@ pub enum ArithmeticOperation { Determinant, /// Determinant error-bound calculation. DeterminantErrorBound, + /// Outward-rounded interval addition. + IntervalAddition, + /// Exact-real subtraction enclosed by an outward-rounded interval. + IntervalSubtraction, + /// Outward-rounded interval multiplication. + IntervalMultiplication, + /// Outward-rounded interval square. + IntervalSquare, + /// Division-free interval determinant calculation. + IntervalDeterminant, /// Vector dot-product calculation. VectorDotProduct, /// Vector squared-norm calculation. @@ -48,6 +62,11 @@ impl fmt::Display for ArithmeticOperation { Self::LdltSolve => "LDLT solve", Self::Determinant => "determinant", Self::DeterminantErrorBound => "determinant error bound", + Self::IntervalAddition => "interval addition", + Self::IntervalSubtraction => "interval subtraction", + Self::IntervalMultiplication => "interval multiplication", + Self::IntervalSquare => "interval square", + Self::IntervalDeterminant => "interval determinant", Self::VectorDotProduct => "vector dot product", Self::VectorSquaredNorm => "vector squared norm", }) @@ -103,6 +122,26 @@ pub enum InvalidToleranceReason { NotFinite, } +/// Endpoint of an interval constructor input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum IntervalBound { + /// Lower endpoint. + Lower, + /// Upper endpoint. + Upper, +} + +/// Operand of a binary scalar operation used to construct an interval. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum IntervalOperand { + /// Left operand. + Left, + /// Right operand. + Right, +} + /// Location at which a non-finite value was observed. /// /// # Examples @@ -140,6 +179,18 @@ pub enum NonFiniteLocation { /// Step index. index: usize, }, + /// Endpoint supplied to an interval constructor. + #[non_exhaustive] + IntervalBound { + /// Bound at which the non-finite value was observed. + bound: IntervalBound, + }, + /// Operand supplied to a binary scalar interval constructor. + #[non_exhaustive] + IntervalOperand { + /// Operand at which the non-finite value was observed. + operand: IntervalOperand, + }, /// Scalar value without a meaningful matrix or vector coordinate. Scalar, } @@ -297,7 +348,7 @@ pub enum LaError { /// Typed reason for the singularity classification. reason: SingularityReason, }, - /// A caller input or arithmetic result is NaN or infinite. + /// A caller input or arithmetic intermediate/result is NaN or infinite. #[non_exhaustive] NonFinite { /// Typed location of the value. @@ -305,6 +356,14 @@ pub enum LaError { /// Whether the value came from input or a particular computation. origin: NonFiniteOrigin, }, + /// An exact-real interval intermediate or result cannot be enclosed by + /// finite binary64 endpoints. + #[non_exhaustive] + IntervalRangeExhausted { + /// Operation whose mathematical intermediate or result exceeded the + /// finite interval endpoint domain. + operation: ArithmeticOperation, + }, /// An exact result cannot satisfy the requested finite-`f64` conversion. #[non_exhaustive] Unrepresentable { @@ -313,6 +372,14 @@ pub enum LaError { /// Reason the conversion contract cannot be satisfied. reason: UnrepresentableReason, }, + /// A finite interval's lower bound is greater than its upper bound. + #[non_exhaustive] + InvertedInterval { + /// Rejected lower bound. + lower: f64, + /// Rejected upper bound. + upper: f64, + }, /// Exact determinant scaling overflowed the internal exponent representation. #[non_exhaustive] DeterminantScaleOverflow { @@ -321,7 +388,7 @@ pub enum LaError { /// Minimum decomposed binary64 exponent among non-zero entries. min_exponent: i32, }, - /// A runtime matrix dimension has no stack-dispatch arm. + /// A matrix algorithm or runtime dispatch helper does not support a dimension. #[non_exhaustive] UnsupportedDimension { /// Runtime dimension requested by the caller. @@ -443,6 +510,28 @@ impl LaError { } } + /// Construct a [`LaError::NonFinite`] input error for a specific interval + /// endpoint. + #[inline] + #[must_use] + pub const fn non_finite_input_interval_bound(bound: IntervalBound) -> Self { + Self::NonFinite { + location: NonFiniteLocation::IntervalBound { bound }, + origin: NonFiniteOrigin::Input, + } + } + + /// Construct a [`LaError::NonFinite`] input error for a specific binary + /// scalar operand. + #[inline] + #[must_use] + pub const fn non_finite_input_interval_operand(operand: IntervalOperand) -> Self { + Self::NonFinite { + location: NonFiniteLocation::IntervalOperand { operand }, + origin: NonFiniteOrigin::Input, + } + } + /// Construct a [`LaError::NonFinite`] computation error at matrix cell /// `(row, col)`, retaining the originating `operation`. #[inline] @@ -480,6 +569,14 @@ impl LaError { } } + /// Construct an [`LaError::IntervalRangeExhausted`] failure retaining the + /// responsible interval operation. + #[inline] + #[must_use] + pub const fn interval_range_exhausted(operation: ArithmeticOperation) -> Self { + Self::IntervalRangeExhausted { operation } + } + /// Construct a [`LaError::Unrepresentable`] conversion failure for a scalar /// (`index = None`) or vector component (`index = Some(_)`). #[inline] @@ -488,6 +585,14 @@ impl LaError { Self::Unrepresentable { index, reason } } + /// Construct a [`LaError::InvertedInterval`] error preserving both rejected + /// finite bounds. + #[inline] + #[must_use] + pub const fn inverted_interval(lower: f64, upper: f64) -> Self { + Self::InvertedInterval { lower, upper } + } + /// Return the typed exact-to-`f64` conversion reason, or `None` for every /// other error variant. #[inline] @@ -619,6 +724,18 @@ fn write_non_finite_location( } NonFiniteLocation::VectorEntry { index } => write!(f, "vector entry {index}"), NonFiniteLocation::Step { index } => write!(f, "step {index}"), + NonFiniteLocation::IntervalBound { + bound: IntervalBound::Lower, + } => f.write_str("interval lower bound"), + NonFiniteLocation::IntervalBound { + bound: IntervalBound::Upper, + } => f.write_str("interval upper bound"), + NonFiniteLocation::IntervalOperand { + operand: IntervalOperand::Left, + } => f.write_str("left interval operand"), + NonFiniteLocation::IntervalOperand { + operand: IntervalOperand::Right, + } => f.write_str("right interval operand"), NonFiniteLocation::Scalar => f.write_str("scalar value"), } } @@ -668,6 +785,10 @@ impl fmt::Display for LaError { "matrix is numerically singular during {factorization} factorization at pivot column {pivot_col}: pivot magnitude {pivot_magnitude} <= tolerance {tolerance}" ), Self::NonFinite { location, origin } => write_non_finite(f, location, origin), + Self::IntervalRangeExhausted { operation } => write!( + f, + "exact-real intermediate or result of {operation} has no enclosure with finite binary64 endpoints" + ), Self::Unrepresentable { index: Some(index), reason: UnrepresentableReason::RequiresRounding, @@ -690,13 +811,17 @@ impl fmt::Display for LaError { index: None, reason: UnrepresentableReason::NotFinite, } => f.write_str("exact result has no finite f64 representation after rounding"), + Self::InvertedInterval { lower, upper } => write!( + f, + "invalid interval bounds [{lower}, {upper}]; expected lower <= upper" + ), Self::DeterminantScaleOverflow { dim, min_exponent } => write!( f, "exact determinant scale exponent overflows for dimension {dim} with minimum entry exponent {min_exponent}" ), Self::UnsupportedDimension { requested, max } => write!( f, - "unsupported matrix dimension {requested}; maximum stack-dispatch dimension is {max}" + "unsupported matrix dimension {requested}; maximum supported dimension is {max}" ), Self::IndexOutOfBounds { row, col, dim } => write!( f, @@ -783,6 +908,26 @@ mod tests { ArithmeticOperation::VectorSquaredNorm.to_string(), "vector squared norm" ); + assert_eq!( + ArithmeticOperation::IntervalAddition.to_string(), + "interval addition" + ); + assert_eq!( + ArithmeticOperation::IntervalSubtraction.to_string(), + "interval subtraction" + ); + assert_eq!( + ArithmeticOperation::IntervalMultiplication.to_string(), + "interval multiplication" + ); + assert_eq!( + ArithmeticOperation::IntervalSquare.to_string(), + "interval square" + ); + assert_eq!( + ArithmeticOperation::IntervalDeterminant.to_string(), + "interval determinant" + ); } #[test] @@ -835,6 +980,22 @@ mod tests { LaError::non_finite_input_scalar().to_string(), "non-finite scalar input" ); + assert_eq!( + LaError::non_finite_input_interval_bound(IntervalBound::Upper).to_string(), + "non-finite input value at interval upper bound" + ); + assert_eq!( + LaError::non_finite_input_interval_bound(IntervalBound::Lower).to_string(), + "non-finite input value at interval lower bound" + ); + assert_eq!( + LaError::non_finite_input_interval_operand(IntervalOperand::Left).to_string(), + "non-finite input value at left interval operand" + ); + assert_eq!( + LaError::non_finite_input_interval_operand(IntervalOperand::Right).to_string(), + "non-finite input value at right interval operand" + ); assert_eq!( LaError::non_finite_computation_matrix(ArithmeticOperation::LuFactorization, 2, 1) .to_string(), @@ -909,6 +1070,37 @@ mod tests { ); } + #[test] + fn inverted_interval_error_preserves_both_bounds() { + let error = LaError::inverted_interval(2.0, 1.0); + assert_eq!( + error, + LaError::InvertedInterval { + lower: 2.0, + upper: 1.0, + } + ); + assert_eq!( + error.to_string(), + "invalid interval bounds [2, 1]; expected lower <= upper" + ); + } + + #[test] + fn interval_range_error_preserves_operation() { + let error = LaError::interval_range_exhausted(ArithmeticOperation::IntervalSquare); + assert_eq!( + error, + LaError::IntervalRangeExhausted { + operation: ArithmeticOperation::IntervalSquare, + } + ); + assert_eq!( + error.to_string(), + "exact-real intermediate or result of interval square has no enclosure with finite binary64 endpoints" + ); + } + #[test] fn asymmetric_error_retains_observed_values_and_bound() { let err = LaError::asymmetric(0, 2, 3, 1.0, 1.5, 1e-12); @@ -949,7 +1141,7 @@ mod tests { ); assert_eq!( LaError::unsupported_dimension(8, MAX_STACK_MATRIX_DISPATCH_DIM).to_string(), - "unsupported matrix dimension 8; maximum stack-dispatch dimension is 7" + "unsupported matrix dimension 8; maximum supported dimension is 7" ); assert_eq!( LaError::index_out_of_bounds(3, 0, 3).to_string(), diff --git a/src/interval.rs b/src/interval.rs new file mode 100644 index 0000000..302e2cb --- /dev/null +++ b/src/interval.rs @@ -0,0 +1,1269 @@ +#![forbid(unsafe_code)] + +//! Outward-rounded intervals and fixed-size interval determinant signs. + +use crate::{ArithmeticOperation, IntervalBound, IntervalOperand, LaError, Matrix}; + +/// Largest dimension supported by [`IntervalMatrix::det`] and +/// [`IntervalMatrix::det_sign`]. +/// +/// A subset-DP determinant needs `2^D` partial intervals. The implementation +/// reserves 128 entries inline, covering the geometry-oriented D ≤ 7 scope +/// without heap allocation. +pub const MAX_INTERVAL_MATRIX_DIM: usize = 7; + +/// A closed finite binary64 interval `[lower, upper]`. +/// +/// Construction keeps both endpoints finite and ordered. Arithmetic rounds +/// outward, so every successful result contains the exact-real result of the +/// corresponding operation on all represented inputs. Both IEEE-754 signed +/// zeros are accepted and canonicalized to `+0.0`; subnormal bounds are +/// retained. +/// +/// This is a deliberately small proof-bearing surface, not a general-purpose +/// interval arithmetic package. Division is intentionally absent. +/// +/// # Examples +/// ``` +/// use la_stack::{Interval, LaError}; +/// +/// # fn main() -> Result<(), LaError> { +/// let difference = Interval::try_from_subtraction(1.0, 0.1)?; +/// let square = difference.try_square()?; +/// assert!(difference.lower() < difference.upper()); +/// assert!(square.contains((1.0_f64 - 0.1).powi(2))); +/// # Ok(()) +/// # } +/// ``` +#[must_use] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Interval { + lower: f64, + upper: f64, +} + +/// Sign evidence from an outward-rounded interval determinant. +/// +/// `Positive`, `Negative`, and `Zero` are proofs about every determinant +/// represented by the interval matrix. `Inconclusive` means the computed +/// enclosure overlaps zero and must not be interpreted as exact singularity. +#[must_use] +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum IntervalDeterminantSign { + /// The determinant interval is strictly greater than zero. + Positive, + /// The determinant interval is strictly less than zero. + Negative, + /// The determinant interval is exactly `[0, 0]`. + Zero, + /// The determinant interval contains zero and at least one nonzero value. + Inconclusive, +} + +/// Fixed-size square matrix of outward-rounded [`Interval`] entries. +/// +/// Storage is the inline array `[[Interval; D]; D]`. Determinants use a +/// division-free Leibniz subset DP through D=7, so zero-containing pivot +/// intervals never require a special case and no heap allocation occurs. +/// +/// # Examples +/// ``` +/// use la_stack::{IntervalDeterminantSign, IntervalMatrix, LaError}; +/// +/// # fn main() -> Result<(), LaError> { +/// let matrix = IntervalMatrix::<3>::try_from_point_rows([ +/// [0.0, 1.0, 0.0], +/// [1.0, 0.0, 0.0], +/// [0.0, 0.0, 1.0], +/// ])?; +/// assert_eq!(matrix.det_sign()?, IntervalDeterminantSign::Negative); +/// # Ok(()) +/// # } +/// ``` +#[must_use] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct IntervalMatrix { + rows: [[Interval; D]; D], +} + +/// Canonicalize either signed representation of real zero to `+0.0`. +#[inline] +const fn canonical_zero(value: f64) -> f64 { + if value == 0.0 { 0.0 } else { value } +} + +/// Return the exact error in a rounded binary64 sum. +/// +/// This is Knuth's `TwoSum` transform. With IEEE-754 round-to-nearest and +/// gradual underflow, `rounded + error` equals the exact-real sum whenever the +/// rounded sum is finite. +#[inline] +const fn two_sum_error(left: f64, right: f64, rounded: f64) -> f64 { + let virtual_right = rounded - left; + let virtual_left = rounded - virtual_right; + let right_error = right - virtual_right; + let left_error = left - virtual_left; + left_error + right_error +} + +/// Decompose a nonzero finite binary64 magnitude as `significand × 2^exponent`. +#[inline] +const fn decompose_magnitude(value: f64) -> (u128, i64) { + let magnitude_bits = value.to_bits() & 0x7fff_ffff_ffff_ffff; + let biased_exponent = ((magnitude_bits >> 52) & 0x7ff).cast_signed(); + let fraction = magnitude_bits & 0x000f_ffff_ffff_ffff; + + if biased_exponent == 0 { + (fraction as u128, -1074) + } else { + ( + (fraction | (1_u64 << 52)) as u128, + biased_exponent - 1023 - 52, + ) + } +} + +/// Compare two positive values represented as `significand × 2^exponent`. +#[inline] +const fn compare_binary_magnitudes( + left_significand: u128, + left_exponent: i64, + right_significand: u128, + right_exponent: i64, +) -> i8 { + let left_zeros = left_significand.trailing_zeros() as i64; + let right_zeros = right_significand.trailing_zeros() as i64; + let normalized_left = left_significand >> left_zeros.cast_unsigned(); + let normalized_right = right_significand >> right_zeros.cast_unsigned(); + let normalized_left_exponent = left_exponent + left_zeros; + let normalized_right_exponent = right_exponent + right_zeros; + + let left_top = + normalized_left_exponent + (u128::BITS - normalized_left.leading_zeros() - 1) as i64; + let right_top = + normalized_right_exponent + (u128::BITS - normalized_right.leading_zeros() - 1) as i64; + if left_top < right_top { + return -1; + } + if left_top > right_top { + return 1; + } + + let common_exponent = if normalized_left_exponent < normalized_right_exponent { + normalized_left_exponent + } else { + normalized_right_exponent + }; + let aligned_left = + normalized_left << (normalized_left_exponent - common_exponent).cast_unsigned(); + let aligned_right = + normalized_right << (normalized_right_exponent - common_exponent).cast_unsigned(); + if aligned_left < aligned_right { + -1 + } else if aligned_left > aligned_right { + 1 + } else { + 0 + } +} + +/// Compare the exact-real product `left × right` with its rounded result. +#[inline] +const fn compare_product_with_rounded(left: f64, right: f64, rounded: f64) -> i8 { + let negative = left.is_sign_negative() != right.is_sign_negative(); + if rounded == 0.0 { + return if negative { -1 } else { 1 }; + } + + let (left_significand, left_exponent) = decompose_magnitude(left); + let (right_significand, right_exponent) = decompose_magnitude(right); + let exact_significand = left_significand * right_significand; + let exact_exponent = left_exponent + right_exponent; + let (rounded_significand, rounded_exponent) = decompose_magnitude(rounded); + let magnitude_relation = compare_binary_magnitudes( + exact_significand, + exact_exponent, + rounded_significand, + rounded_exponent, + ); + + if negative { + -magnitude_relation + } else { + magnitude_relation + } +} + +/// Turn a finite rounded sum into the tight adjacent-float enclosure implied by +/// its exact `TwoSum` residual. +#[inline] +const fn rounded_add_bounds( + left: f64, + right: f64, + operation: ArithmeticOperation, +) -> Result<(f64, f64), LaError> { + let rounded = left + right; + if !rounded.is_finite() { + return Err(LaError::interval_range_exhausted(operation)); + } + + let error = two_sum_error(left, right, rounded); + if !error.is_finite() { + return Err(LaError::non_finite_computation_scalar(operation)); + } + let (lower, upper) = if error < 0.0 { + (rounded.next_down(), rounded) + } else if error > 0.0 { + (rounded, rounded.next_up()) + } else { + (rounded, rounded) + }; + if !lower.is_finite() || !upper.is_finite() { + return Err(LaError::interval_range_exhausted(operation)); + } + + Ok((canonical_zero(lower), canonical_zero(upper))) +} + +/// Turn a finite rounded product into the tight adjacent-float enclosure of the +/// exact binary64-input product. +#[inline] +const fn rounded_product_bounds( + left: f64, + right: f64, + operation: ArithmeticOperation, +) -> Result<(f64, f64), LaError> { + if left == 0.0 || right == 0.0 { + return Ok((0.0, 0.0)); + } + + let rounded = left * right; + if !rounded.is_finite() { + return Err(LaError::interval_range_exhausted(operation)); + } + + let relation = compare_product_with_rounded(left, right, rounded); + let (lower, upper) = if relation < 0 { + (rounded.next_down(), rounded) + } else if relation > 0 { + (rounded, rounded.next_up()) + } else { + (rounded, rounded) + }; + if !lower.is_finite() || !upper.is_finite() { + return Err(LaError::interval_range_exhausted(operation)); + } + + Ok((canonical_zero(lower), canonical_zero(upper))) +} + +impl Interval { + /// Exact real zero. + pub const ZERO: Self = Self { + lower: 0.0, + upper: 0.0, + }; + + /// Exact real one. + pub const ONE: Self = Self { + lower: 1.0, + upper: 1.0, + }; + + /// Construct a closed interval from finite ordered bounds. + /// + /// Signed zero endpoints are canonicalized to `+0.0`. + /// + /// # Errors + /// Returns [`LaError::NonFinite`] when either endpoint is NaN or infinity. + /// Returns [`LaError::InvertedInterval`] when `lower > upper`. + #[inline] + pub const fn try_new(lower: f64, upper: f64) -> Result { + if !lower.is_finite() { + return Err(LaError::non_finite_input_interval_bound( + IntervalBound::Lower, + )); + } + if !upper.is_finite() { + return Err(LaError::non_finite_input_interval_bound( + IntervalBound::Upper, + )); + } + if lower > upper { + return Err(LaError::inverted_interval(lower, upper)); + } + Ok(Self::new_unchecked(lower, upper)) + } + + /// Construct a point interval from a finite binary64 value. + /// + /// # Errors + /// Returns [`LaError::NonFinite`] when `value` is NaN or infinity. + #[inline] + pub const fn point(value: f64) -> Result { + match Self::try_new(value, value) { + Ok(interval) => Ok(interval), + Err(LaError::NonFinite { .. }) => Err(LaError::non_finite_input_scalar()), + Err(error) => Err(error), + } + } + + /// Enclose the exact-real subtraction of two finite binary64 inputs. + /// + /// Unlike subtracting first and then calling [`point`](Self::point), this + /// method preserves the rounding uncertainty introduced by the subtraction. + /// + /// # Errors + /// Returns [`LaError::NonFinite`] for a non-finite input, preserving whether + /// it was the left or right operand. Returns + /// [`LaError::IntervalRangeExhausted`] when the exact difference has no + /// finite binary64 enclosure. + #[inline] + pub const fn try_from_subtraction(left: f64, right: f64) -> Result { + if !left.is_finite() { + return Err(LaError::non_finite_input_interval_operand( + IntervalOperand::Left, + )); + } + if !right.is_finite() { + return Err(LaError::non_finite_input_interval_operand( + IntervalOperand::Right, + )); + } + match rounded_add_bounds(left, -right, ArithmeticOperation::IntervalSubtraction) { + Ok((lower, upper)) => Ok(Self::new_unchecked(lower, upper)), + Err(error) => Err(error), + } + } + + /// Return the finite lower bound. + #[inline] + #[must_use] + pub const fn lower(self) -> f64 { + self.lower + } + + /// Return the finite upper bound. + #[inline] + #[must_use] + pub const fn upper(self) -> f64 { + self.upper + } + + /// Return whether this interval contains the finite `value`. + #[inline] + #[must_use] + pub const fn contains(self, value: f64) -> bool { + value.is_finite() && self.lower <= value && value <= self.upper + } + + /// Add two intervals with outward rounding. + /// + /// # Errors + /// Returns [`LaError::IntervalRangeExhausted`] when the exact result range + /// has no finite binary64 enclosure. + #[inline] + pub const fn try_add(&self, other: &Self) -> Result { + self.try_add_for(other, ArithmeticOperation::IntervalAddition) + } + + /// Multiply two intervals with outward rounding. + /// + /// # Errors + /// Returns [`LaError::IntervalRangeExhausted`] when the exact result range + /// has no finite binary64 enclosure. + #[inline] + pub const fn try_mul(&self, other: &Self) -> Result { + self.try_mul_for(other, ArithmeticOperation::IntervalMultiplication) + } + + /// Negate an interval exactly by swapping and negating its endpoints. + #[inline] + pub const fn negate(&self) -> Self { + Self::new_unchecked(-self.upper, -self.lower) + } + + /// Square an interval with outward rounding. + /// + /// An interval spanning zero has exact lower bound zero. The upper bound is + /// the outward-rounded square of the endpoint with greatest magnitude. + /// + /// # Errors + /// Returns [`LaError::IntervalRangeExhausted`] when the exact square range + /// has no finite binary64 enclosure. + #[inline] + pub const fn try_square(&self) -> Result { + let operation = ArithmeticOperation::IntervalSquare; + let left_square = match rounded_product_bounds(self.lower, self.lower, operation) { + Ok(bounds) => bounds, + Err(error) => return Err(error), + }; + let right_square = match rounded_product_bounds(self.upper, self.upper, operation) { + Ok(bounds) => bounds, + Err(error) => return Err(error), + }; + let lower = if self.lower <= 0.0 && self.upper >= 0.0 { + 0.0 + } else if left_square.0 < right_square.0 { + left_square.0 + } else { + right_square.0 + }; + let upper = if left_square.1 > right_square.1 { + left_square.1 + } else { + right_square.1 + }; + Ok(Self::new_unchecked(lower, upper)) + } + + /// Construct an interval after its finite ordered-bound invariant is known. + #[inline] + const fn new_unchecked(lower: f64, upper: f64) -> Self { + Self { + lower: canonical_zero(lower), + upper: canonical_zero(upper), + } + } + + /// Add while attributing range failure to the owning public operation. + #[inline] + const fn try_add_for( + &self, + other: &Self, + operation: ArithmeticOperation, + ) -> Result { + if self.is_zero() { + return Ok(*other); + } + if other.is_zero() { + return Ok(*self); + } + + let lower = match rounded_add_bounds(self.lower, other.lower, operation) { + Ok((lower, _)) => lower, + Err(error) => return Err(error), + }; + let upper = match rounded_add_bounds(self.upper, other.upper, operation) { + Ok((_, upper)) => upper, + Err(error) => return Err(error), + }; + Ok(Self::new_unchecked(lower, upper)) + } + + /// Multiply while attributing range failure to the owning public operation. + #[inline] + const fn try_mul_for( + &self, + other: &Self, + operation: ArithmeticOperation, + ) -> Result { + if self.is_zero() || other.is_zero() { + return Ok(Self::ZERO); + } + if self.is_one() { + return Ok(*other); + } + if other.is_one() { + return Ok(*self); + } + if self.is_point() && other.is_point() { + return match rounded_product_bounds(self.lower, other.lower, operation) { + Ok((lower, upper)) => Ok(Self::new_unchecked(lower, upper)), + Err(error) => Err(error), + }; + } + + self.try_mul_by_sign(other, operation) + } + + /// Select only the endpoint products that can attain each range extremum. + #[inline] + const fn try_mul_by_sign( + &self, + other: &Self, + operation: ArithmeticOperation, + ) -> Result { + let self_nonnegative = self.lower >= 0.0; + let self_nonpositive = self.upper <= 0.0; + let other_nonnegative = other.lower >= 0.0; + let other_nonpositive = other.upper <= 0.0; + + if self_nonnegative { + if other_nonnegative { + return Self::try_product_extrema( + (self.lower, other.lower), + (self.upper, other.upper), + operation, + ); + } + if other_nonpositive { + return Self::try_product_extrema( + (self.upper, other.lower), + (self.lower, other.upper), + operation, + ); + } + return Self::try_product_extrema( + (self.upper, other.lower), + (self.upper, other.upper), + operation, + ); + } + if self_nonpositive { + if other_nonnegative { + return Self::try_product_extrema( + (self.lower, other.upper), + (self.upper, other.lower), + operation, + ); + } + if other_nonpositive { + return Self::try_product_extrema( + (self.upper, other.upper), + (self.lower, other.lower), + operation, + ); + } + return Self::try_product_extrema( + (self.lower, other.upper), + (self.lower, other.lower), + operation, + ); + } + if other_nonnegative { + return Self::try_product_extrema( + (self.lower, other.upper), + (self.upper, other.upper), + operation, + ); + } + if other_nonpositive { + return Self::try_product_extrema( + (self.upper, other.lower), + (self.lower, other.lower), + operation, + ); + } + + let lower_left = match rounded_product_bounds(self.lower, other.upper, operation) { + Ok(bounds) => bounds, + Err(error) => return Err(error), + }; + let lower_right = match rounded_product_bounds(self.upper, other.lower, operation) { + Ok(bounds) => bounds, + Err(error) => return Err(error), + }; + let upper_left = match rounded_product_bounds(self.lower, other.lower, operation) { + Ok(bounds) => bounds, + Err(error) => return Err(error), + }; + let upper_right = match rounded_product_bounds(self.upper, other.upper, operation) { + Ok(bounds) => bounds, + Err(error) => return Err(error), + }; + let lower = if lower_left.0 < lower_right.0 { + lower_left.0 + } else { + lower_right.0 + }; + let upper = if upper_left.1 > upper_right.1 { + upper_left.1 + } else { + upper_right.1 + }; + Ok(Self::new_unchecked(lower, upper)) + } + + /// Enclose the selected exact lower and upper product extrema. + #[inline] + const fn try_product_extrema( + lower_factors: (f64, f64), + upper_factors: (f64, f64), + operation: ArithmeticOperation, + ) -> Result { + let lower = match rounded_product_bounds(lower_factors.0, lower_factors.1, operation) { + Ok((lower, _)) => lower, + Err(error) => return Err(error), + }; + let upper = match rounded_product_bounds(upper_factors.0, upper_factors.1, operation) { + Ok((_, upper)) => upper, + Err(error) => return Err(error), + }; + Ok(Self::new_unchecked(lower, upper)) + } + + /// Return whether this interval is exactly real zero. + #[inline] + const fn is_zero(&self) -> bool { + self.lower == 0.0 && self.upper == 0.0 + } + + /// Return whether this interval is exactly real one. + #[inline] + const fn is_one(&self) -> bool { + self.lower.to_bits() == 1.0_f64.to_bits() && self.upper.to_bits() == 1.0_f64.to_bits() + } + + /// Return whether this interval contains one binary64 point. + #[inline] + const fn is_point(&self) -> bool { + self.lower.to_bits() == self.upper.to_bits() + } +} + +impl Default for Interval { + #[inline] + fn default() -> Self { + Self::ZERO + } +} + +impl IntervalMatrix { + /// Construct an interval matrix from already-validated interval rows. + #[inline] + pub const fn from_rows(rows: [[Interval; D]; D]) -> Self { + Self { rows } + } + + /// Lift finite binary64 rows into point intervals. + /// + /// This preserves the stored binary64 values exactly; it does not recover + /// uncertainty from arithmetic performed before this call. + /// + /// # Errors + /// Returns [`LaError::NonFinite`] with matrix coordinates for the first NaN + /// or infinity in row-major order. + #[inline] + pub const fn try_from_point_rows(rows: [[f64; D]; D]) -> Result { + let mut intervals = [[Interval::ZERO; D]; D]; + let mut row = 0; + while row < D { + let mut column = 0; + while column < D { + let value = rows[row][column]; + if !value.is_finite() { + return Err(LaError::non_finite_input_matrix(row, column)); + } + intervals[row][column] = Interval::new_unchecked(value, value); + column += 1; + } + row += 1; + } + Ok(Self::from_rows(intervals)) + } + + /// Lift a finite [`Matrix`] into point intervals. + /// + /// Earlier rounded expression construction is not enclosed; use interval + /// operations while constructing derived coefficients when that uncertainty + /// belongs in the proof. + #[inline] + pub const fn from_matrix(matrix: &Matrix) -> Self { + let matrix_rows = matrix.as_rows(); + let mut intervals = [[Interval::ZERO; D]; D]; + let mut row = 0; + while row < D { + let mut column = 0; + while column < D { + let value = matrix_rows[row][column]; + intervals[row][column] = Interval::new_unchecked(value, value); + column += 1; + } + row += 1; + } + Self::from_rows(intervals) + } + + /// All-zero interval matrix. + #[inline] + pub const fn zero() -> Self { + Self::from_rows([[Interval::ZERO; D]; D]) + } + + /// Identity interval matrix. + #[inline] + pub const fn identity() -> Self { + let mut matrix = Self::zero(); + let mut index = 0; + while index < D { + matrix.rows[index][index] = Interval::ONE; + index += 1; + } + matrix + } + + /// Borrow the row-major interval storage. + #[inline] + pub const fn as_rows(&self) -> &[[Interval; D]; D] { + &self.rows + } + + /// Consume this matrix and return its row-major interval storage. + #[inline] + pub const fn into_rows(self) -> [[Interval; D]; D] { + self.rows + } + + /// Get an interval entry with bounds checking. + #[inline] + #[must_use] + pub const fn get(&self, row: usize, column: usize) -> Option { + if row < D && column < D { + Some(self.rows[row][column]) + } else { + None + } + } + + /// Get an interval entry while preserving index context on failure. + /// + /// # Errors + /// Returns [`LaError::IndexOutOfBounds`] when either index is not `< D`. + #[inline] + pub const fn try_get(&self, row: usize, column: usize) -> Result { + if row < D && column < D { + Ok(self.rows[row][column]) + } else { + Err(LaError::index_out_of_bounds(row, column, D)) + } + } + + /// Set an interval entry with bounds checking. + /// + /// Validation is unnecessary for the value because [`Interval`] already + /// carries the finite ordered-bound proof. + /// + /// # Errors + /// Returns [`LaError::IndexOutOfBounds`] when either index is not `< D`. + #[inline] + pub const fn set(&mut self, row: usize, column: usize, value: Interval) -> Result<(), LaError> { + if row >= D || column >= D { + return Err(LaError::index_out_of_bounds(row, column, D)); + } + self.rows[row][column] = value; + Ok(()) + } + + /// Enclose the determinant with division-free subset dynamic programming. + /// + /// For each column subset, the DP stores the determinant interval of the + /// leading rows and those columns. This evaluates the Leibniz expansion in + /// `D × 2^(D-1)` products and additions without choosing or dividing by a + /// pivot. The returned interval therefore encloses every exact-real + /// determinant represented by the input intervals, subject only to an + /// explicit range failure. + /// + /// The D=0 determinant follows the empty-product convention and is `[1, 1]`. + /// + /// # Errors + /// Returns [`LaError::UnsupportedDimension`] for D>7. Returns + /// [`LaError::IntervalRangeExhausted`] with interval-determinant provenance + /// when an exact intermediate has no finite binary64 enclosure; callers can + /// then proceed to an exact or higher-range fallback. + #[inline] + pub const fn det(&self) -> Result { + if D > MAX_INTERVAL_MATRIX_DIM { + return Err(LaError::unsupported_dimension(D, MAX_INTERVAL_MATRIX_DIM)); + } + + let state_count = 1_usize << D; + let mut partials = [Interval::ZERO; 1 << MAX_INTERVAL_MATRIX_DIM]; + partials[0] = Interval::ONE; + let operation = ArithmeticOperation::IntervalDeterminant; + + let mut subset = 1; + while subset < state_count { + let row = subset.count_ones() as usize - 1; + let mut sum = Interval::ZERO; + let mut column = 0; + while column < D { + let column_bit = 1_usize << column; + if subset & column_bit != 0 { + let previous = subset ^ column_bit; + let mut term = + match partials[previous].try_mul_for(&self.rows[row][column], operation) { + Ok(term) => term, + Err(error) => return Err(error), + }; + let columns_after = (subset >> (column + 1)).count_ones(); + if !columns_after.is_multiple_of(2) { + term = term.negate(); + } + sum = match sum.try_add_for(&term, operation) { + Ok(next_sum) => next_sum, + Err(error) => return Err(error), + }; + } + column += 1; + } + partials[subset] = sum; + subset += 1; + } + + Ok(partials[state_count - 1]) + } + + /// Return proof-bearing determinant sign evidence. + /// + /// An interval strictly on one side of zero proves that sign. Only the + /// singleton interval `[0, 0]` proves `Zero`; every other overlap with zero + /// is [`IntervalDeterminantSign::Inconclusive`]. + /// + /// # Errors + /// Propagates the dimension and arithmetic range failures from + /// [`det`](Self::det). + #[inline] + pub const fn det_sign(&self) -> Result { + let determinant = match self.det() { + Ok(determinant) => determinant, + Err(error) => return Err(error), + }; + if determinant.lower > 0.0 { + Ok(IntervalDeterminantSign::Positive) + } else if determinant.upper < 0.0 { + Ok(IntervalDeterminantSign::Negative) + } else if determinant.lower == 0.0 && determinant.upper == 0.0 { + Ok(IntervalDeterminantSign::Zero) + } else { + Ok(IntervalDeterminantSign::Inconclusive) + } + } +} + +impl Default for IntervalMatrix { + #[inline] + fn default() -> Self { + Self::zero() + } +} + +#[cfg(test)] +mod tests { + use core::assert_matches; + + use pastey::paste; + + use super::*; + use crate::{IntervalBound, IntervalOperand, NonFiniteLocation, NonFiniteOrigin}; + + #[test] + fn point_and_bounds_enforce_interval_invariants() { + assert_eq!(Interval::point(-0.0).unwrap().lower().to_bits(), 0); + assert_eq!(Interval::try_new(-0.0, 0.0).unwrap(), Interval::ZERO); + assert_matches!( + Interval::point(f64::NAN), + Err(LaError::NonFinite { + location: NonFiniteLocation::Scalar, + origin: NonFiniteOrigin::Input, + .. + }) + ); + assert_matches!( + Interval::try_new(2.0, 1.0), + Err(LaError::InvertedInterval { + lower: 2.0, + upper: 1.0, + .. + }) + ); + } + + #[test] + fn constructors_preserve_non_finite_input_locations() { + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!( + Interval::try_new(value, 0.0), + Err(LaError::NonFinite { + location: NonFiniteLocation::IntervalBound { + bound: IntervalBound::Lower, + }, + origin: NonFiniteOrigin::Input, + }) + ); + assert_eq!( + Interval::try_new(0.0, value), + Err(LaError::NonFinite { + location: NonFiniteLocation::IntervalBound { + bound: IntervalBound::Upper, + }, + origin: NonFiniteOrigin::Input, + }) + ); + assert_eq!( + Interval::try_from_subtraction(value, 0.0), + Err(LaError::NonFinite { + location: NonFiniteLocation::IntervalOperand { + operand: IntervalOperand::Left, + }, + origin: NonFiniteOrigin::Input, + }) + ); + assert_eq!( + Interval::try_from_subtraction(0.0, value), + Err(LaError::NonFinite { + location: NonFiniteLocation::IntervalOperand { + operand: IntervalOperand::Right, + }, + origin: NonFiniteOrigin::Input, + }) + ); + } + + assert_eq!( + Interval::try_new(f64::NAN, f64::INFINITY), + Err(LaError::NonFinite { + location: NonFiniteLocation::IntervalBound { + bound: IntervalBound::Lower, + }, + origin: NonFiniteOrigin::Input, + }) + ); + assert_eq!( + Interval::try_from_subtraction(f64::NAN, f64::INFINITY), + Err(LaError::NonFinite { + location: NonFiniteLocation::IntervalOperand { + operand: IntervalOperand::Left, + }, + origin: NonFiniteOrigin::Input, + }) + ); + + let rows = [[0.0, f64::NAN], [f64::INFINITY, 0.0]]; + assert_eq!( + IntervalMatrix::<2>::try_from_point_rows(rows), + Err(LaError::NonFinite { + location: NonFiniteLocation::MatrixCell { row: 0, col: 1 }, + origin: NonFiniteOrigin::Input, + }) + ); + } + + #[test] + fn exact_operations_remain_point_intervals() -> Result<(), LaError> { + let one = Interval::point(1.0)?; + let two = Interval::point(2.0)?; + assert_eq!(one.try_add(&two)?, Interval::point(3.0)?); + assert_eq!(two.try_mul(&two)?, Interval::point(4.0)?); + assert_eq!(Interval::try_from_subtraction(3.0, 2.0)?, one); + assert_eq!( + Interval::try_new(-2.0, -1.0)?.negate(), + Interval::try_new(1.0, 2.0)? + ); + Ok(()) + } + + #[test] + fn inexact_operations_expand_only_in_the_required_direction() -> Result<(), LaError> { + let subtraction = Interval::try_from_subtraction(1.0, 0.1)?; + let rounded_subtraction = 1.0 - 0.1; + assert!(subtraction.contains(rounded_subtraction)); + assert!(subtraction.lower() < subtraction.upper()); + + let product = Interval::point(0.1)?.try_mul(&Interval::point(0.2)?)?; + assert!(product.contains(0.1 * 0.2)); + assert!(product.lower() < product.upper()); + + let below_one = 1.0 - f64::EPSILON; + let above_one = 1.0 + f64::EPSILON; + let binade_boundary = Interval::point(below_one)?.try_mul(&Interval::point(above_one)?)?; + assert_eq!( + binade_boundary, + Interval::try_new(1.0_f64.next_down(), 1.0)? + ); + Ok(()) + } + + #[test] + fn cancellation_preserves_an_exact_ulp_difference() -> Result<(), LaError> { + let next = 1.0_f64.next_up(); + let difference = Interval::try_from_subtraction(next, 1.0)?; + assert_eq!(difference, Interval::point(f64::EPSILON)?); + Ok(()) + } + + #[test] + fn underflowed_product_still_encloses_the_positive_exact_result() -> Result<(), LaError> { + let least_subnormal = f64::from_bits(1); + let product = Interval::point(least_subnormal)?.try_mul(&Interval::point(0.5)?)?; + assert_eq!(product, Interval::try_new(0.0, least_subnormal)?); + Ok(()) + } + + #[test] + fn range_failure_preserves_interval_operation() -> Result<(), LaError> { + let error = Interval::point(f64::MAX)? + .try_mul(&Interval::point(2.0)?) + .unwrap_err(); + assert_eq!( + error, + LaError::IntervalRangeExhausted { + operation: ArithmeticOperation::IntervalMultiplication, + } + ); + Ok(()) + } + + #[test] + fn rounded_maximum_detects_exact_sum_beyond_finite_range() -> Result<(), LaError> { + let maximum = Interval::point(f64::MAX)?; + let tiny = Interval::point(f64::MIN_POSITIVE)?; + assert_eq!( + maximum.try_add(&maximum), + Err(LaError::IntervalRangeExhausted { + operation: ArithmeticOperation::IntervalAddition, + }) + ); + assert_eq!( + maximum.try_add(&tiny), + Err(LaError::IntervalRangeExhausted { + operation: ArithmeticOperation::IntervalAddition, + }) + ); + let nonnegative = Interval::try_new(0.0, f64::MAX)?; + assert_eq!( + nonnegative.try_add(&nonnegative), + Err(LaError::IntervalRangeExhausted { + operation: ArithmeticOperation::IntervalAddition, + }) + ); + + let finite_difference = maximum.try_add(&tiny.negate())?; + assert_eq!(finite_difference.upper().to_bits(), f64::MAX.to_bits()); + assert!(finite_difference.lower() < finite_difference.upper()); + Ok(()) + } + + #[test] + fn subtraction_and_square_preserve_distinct_range_operations() -> Result<(), LaError> { + assert_eq!( + Interval::try_from_subtraction(f64::MAX, -f64::MIN_POSITIVE), + Err(LaError::IntervalRangeExhausted { + operation: ArithmeticOperation::IntervalSubtraction, + }) + ); + assert_eq!( + Interval::point(f64::MAX)?.try_square(), + Err(LaError::IntervalRangeExhausted { + operation: ArithmeticOperation::IntervalSquare, + }) + ); + assert_eq!( + Interval::try_new(-1.0, f64::MAX)?.try_square(), + Err(LaError::IntervalRangeExhausted { + operation: ArithmeticOperation::IntervalSquare, + }) + ); + Ok(()) + } + + #[test] + fn determinant_overflow_reports_interval_determinant_range_failure() -> Result<(), LaError> { + let matrix = IntervalMatrix::<2>::try_from_point_rows([[f64::MAX, 0.0], [0.0, 2.0]])?; + assert_eq!( + matrix.det(), + Err(LaError::IntervalRangeExhausted { + operation: ArithmeticOperation::IntervalDeterminant, + }) + ); + + let accumulating = + IntervalMatrix::<2>::try_from_point_rows([[f64::MAX, f64::MAX], [-1.0, 1.0]])?; + assert_eq!( + accumulating.det(), + Err(LaError::IntervalRangeExhausted { + operation: ArithmeticOperation::IntervalDeterminant, + }) + ); + assert_eq!( + accumulating.det_sign(), + Err(LaError::IntervalRangeExhausted { + operation: ArithmeticOperation::IntervalDeterminant, + }) + ); + Ok(()) + } + + #[test] + fn determinant_reports_intermediate_exhaustion_before_exact_cancellation() -> Result<(), LaError> + { + let matrix = IntervalMatrix::<2>::try_from_point_rows([[f64::MAX, f64::MAX], [2.0, 2.0]])?; + assert_eq!( + matrix.det(), + Err(LaError::IntervalRangeExhausted { + operation: ArithmeticOperation::IntervalDeterminant, + }) + ); + Ok(()) + } + + #[test] + fn square_spanning_zero_has_exact_zero_lower_bound() -> Result<(), LaError> { + let square = Interval::try_new(-2.0, 3.0)?.try_square()?; + assert_eq!(square, Interval::try_new(0.0, 9.0)?); + Ok(()) + } + + #[test] + fn multiplication_selects_correct_extrema_in_every_sign_quadrant() -> Result<(), LaError> { + for (left, right, expected) in [ + ((2.0, 3.0), (4.0, 5.0), (8.0, 15.0)), + ((2.0, 3.0), (-5.0, -4.0), (-15.0, -8.0)), + ((2.0, 3.0), (-5.0, 4.0), (-15.0, 12.0)), + ((-3.0, -2.0), (4.0, 5.0), (-15.0, -8.0)), + ((-3.0, -2.0), (-5.0, -4.0), (8.0, 15.0)), + ((-3.0, -2.0), (-5.0, 4.0), (-12.0, 15.0)), + ((-3.0, 2.0), (4.0, 5.0), (-15.0, 10.0)), + ((-3.0, 2.0), (-5.0, -4.0), (-10.0, 15.0)), + ((-3.0, 2.0), (-5.0, 4.0), (-12.0, 15.0)), + ] { + let product = Interval::try_new(left.0, left.1)? + .try_mul(&Interval::try_new(right.0, right.1)?)?; + assert_eq!(product, Interval::try_new(expected.0, expected.1)?); + } + + assert_eq!( + Interval::ZERO.try_mul(&Interval::try_new(-f64::MAX, f64::MAX)?)?, + Interval::ZERO + ); + Ok(()) + } + + #[test] + fn multiplication_rejects_unrepresentable_selected_extrema() -> Result<(), LaError> { + let half_maximum = f64::MAX / 2.0; + for (left, right) in [ + ((half_maximum, f64::MAX), (-2.0, -1.0)), + ((half_maximum, f64::MAX), (1.0, 2.0)), + ((-f64::MAX, 1.0), (-1.0, 2.0)), + ((-1.0, f64::MAX), (-2.0, 1.0)), + ((-f64::MAX, 1.0), (-2.0, 1.0)), + ((-1.0, f64::MAX), (-1.0, 2.0)), + ] { + let result = + Interval::try_new(left.0, left.1)?.try_mul(&Interval::try_new(right.0, right.1)?); + assert_eq!( + result, + Err(LaError::IntervalRangeExhausted { + operation: ArithmeticOperation::IntervalMultiplication, + }), + "left={left:?}, right={right:?}" + ); + } + Ok(()) + } + + macro_rules! gen_interval_identity_tests { + ($d:literal) => { + paste! { + #[test] + fn []() { + let matrix = IntervalMatrix::<$d>::identity(); + assert_eq!(matrix.det(), Ok(Interval::ONE)); + assert_eq!( + matrix.det_sign(), + Ok(IntervalDeterminantSign::Positive) + ); + } + } + }; + } + + gen_interval_identity_tests!(2); + gen_interval_identity_tests!(3); + gen_interval_identity_tests!(4); + gen_interval_identity_tests!(5); + gen_interval_identity_tests!(6); + gen_interval_identity_tests!(7); + + #[test] + fn determinant_sign_handles_row_swap_and_exact_singularity() -> Result<(), LaError> { + let swapped = IntervalMatrix::<3>::try_from_point_rows([ + [0.0, 1.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + ])?; + assert_eq!(swapped.det_sign()?, IntervalDeterminantSign::Negative); + + let singular = IntervalMatrix::<3>::try_from_point_rows([ + [1.0, 2.0, 3.0], + [1.0, 2.0, 3.0], + [0.0, 0.0, 1.0], + ])?; + assert_eq!(singular.det_sign()?, IntervalDeterminantSign::Zero); + Ok(()) + } + + #[test] + fn wide_determinant_interval_is_inconclusive() -> Result<(), LaError> { + let matrix = IntervalMatrix::<2>::from_rows([ + [Interval::ONE, Interval::ZERO], + [Interval::ZERO, Interval::try_new(-1.0, 1.0)?], + ]); + assert_eq!(matrix.det_sign()?, IntervalDeterminantSign::Inconclusive); + Ok(()) + } + + #[test] + fn determinant_rejects_dimensions_above_supported_stack_dp() { + assert_matches!( + IntervalMatrix::<8>::identity().det(), + Err(LaError::UnsupportedDimension { + requested: 8, + max: MAX_INTERVAL_MATRIX_DIM, + .. + }) + ); + } + + #[test] + fn matrix_accessors_preserve_validated_storage() -> Result<(), LaError> { + let source = Matrix::<2>::identity(); + let mut intervals = IntervalMatrix::from_matrix(&source); + let value = Interval::try_new(2.0, 3.0)?; + intervals.set(0, 1, value)?; + assert_eq!(intervals.get(0, 1), Some(value)); + assert_eq!(intervals.get(2, 0), None); + assert_eq!(intervals.try_get(0, 1)?, value); + assert_matches!( + intervals.try_get(2, 0), + Err(LaError::IndexOutOfBounds { + row: 2, + col: 0, + dim: 2, + .. + }) + ); + assert_eq!(intervals.as_rows()[0][1], value); + assert_eq!(intervals.into_rows()[0][1], value); + Ok(()) + } + + #[test] + fn rejected_matrix_set_is_failure_atomic() -> Result<(), LaError> { + let mut matrix = IntervalMatrix::<2>::identity(); + let before = matrix; + let value = Interval::try_new(2.0, 3.0)?; + + assert_eq!( + matrix.set(2, 0, value), + Err(LaError::IndexOutOfBounds { + row: 2, + col: 0, + dim: 2, + }) + ); + assert_eq!(matrix, before); + assert_eq!( + matrix.set(0, 2, value), + Err(LaError::IndexOutOfBounds { + row: 0, + col: 2, + dim: 2, + }) + ); + assert_eq!(matrix, before); + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 9dceb44..7bd3398 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,6 +84,33 @@ mod readme_doctests { /// ``` fn det_direct_4x4_const_example() {} + /// ```rust + /// use la_stack::prelude::*; + /// + /// # fn main() -> Result<(), LaError> { + /// let x = Interval::try_from_subtraction(0.1, 0.0)?; + /// let y = Interval::try_from_subtraction(0.1, 0.0)?; + /// let z = Interval::try_from_subtraction(0.1, 0.0)?; + /// let lifted = x + /// .try_square()? + /// .try_add(&y.try_square()?)? + /// .try_add(&z.try_square()?)?; + /// + /// let matrix = IntervalMatrix::<4>::from_rows([ + /// [Interval::ONE, Interval::ZERO, Interval::ZERO, Interval::ONE], + /// [Interval::ZERO, Interval::ONE, Interval::ZERO, Interval::ONE], + /// [Interval::ZERO, Interval::ZERO, Interval::ONE, Interval::ONE], + /// [x, y, z, lifted], + /// ]); + /// assert_eq!( + /// matrix.det_sign()?, + /// IntervalDeterminantSign::Negative, + /// ); + /// # Ok(()) + /// # } + /// ``` + fn interval_determinant_example() {} + #[cfg(feature = "exact")] /// ```rust /// use la_stack::prelude::*; @@ -259,6 +286,7 @@ mod readme_doctests { mod error; #[cfg(feature = "exact")] mod exact; +mod interval; mod ldlt; mod lu; mod matrix; @@ -442,9 +470,11 @@ pub const MAX_STACK_MATRIX_DISPATCH_DIM: usize = 7; pub const MAX_RATIONAL_MATRIX_DISPATCH_DIM: usize = 8; pub use error::{ - ArithmeticOperation, FactorizationKind, InvalidToleranceReason, LaError, NonFiniteLocation, - NonFiniteOrigin, PositiveSemidefiniteViolation, SingularityReason, UnrepresentableReason, + ArithmeticOperation, FactorizationKind, IntervalBound, IntervalOperand, InvalidToleranceReason, + LaError, NonFiniteLocation, NonFiniteOrigin, PositiveSemidefiniteViolation, SingularityReason, + UnrepresentableReason, }; +pub use interval::{Interval, IntervalDeterminantSign, IntervalMatrix, MAX_INTERVAL_MATRIX_DIM}; pub use ldlt::Ldlt; pub use lu::Lu; pub use matrix::{DeterminantWithErrorBound, Matrix}; @@ -532,6 +562,88 @@ macro_rules! try_with_stack_matrix { }}; } +/// Fallibly dispatch a runtime dimension to a concrete interval matrix. +/// +/// The macro creates a zero [`IntervalMatrix`] with the selected const-generic +/// dimension, then evaluates the closure body. Supported dimensions run from +/// `0` through [`MAX_INTERVAL_MATRIX_DIM`]. Unsupported dimensions return +/// [`LaError::UnsupportedDimension`] converted through `From`. +/// +/// # Errors +/// Returns [`LaError::UnsupportedDimension`] (converted through +/// `From`) when the requested dimension is greater than +/// [`MAX_INTERVAL_MATRIX_DIM`]. The closure body may return any other error +/// representable by its declared `Result` type. +/// +/// # Examples +/// ``` +/// use la_stack::prelude::*; +/// +/// # fn main() -> Result<(), LaError> { +/// let requested = 3usize; +/// let sign = try_with_interval_matrix!(requested, |mut matrix| -> Result< +/// IntervalDeterminantSign, +/// LaError, +/// > { +/// for index in 0..requested { +/// matrix.set(index, index, Interval::ONE)?; +/// } +/// matrix.det_sign() +/// })?; +/// assert_eq!(sign, IntervalDeterminantSign::Positive); +/// # Ok(()) +/// # } +/// ``` +#[macro_export] +macro_rules! try_with_interval_matrix { + ($dim:expr, |$matrix:ident| -> $ret:ty $body:block $(,)?) => {{ + let __la_stack_requested_dim: usize = $dim; + match __la_stack_requested_dim { + 0 => $crate::try_with_interval_matrix!(@arm 0, $matrix, $ret, $body), + 1 => $crate::try_with_interval_matrix!(@arm 1, $matrix, $ret, $body), + 2 => $crate::try_with_interval_matrix!(@arm 2, $matrix, $ret, $body), + 3 => $crate::try_with_interval_matrix!(@arm 3, $matrix, $ret, $body), + 4 => $crate::try_with_interval_matrix!(@arm 4, $matrix, $ret, $body), + 5 => $crate::try_with_interval_matrix!(@arm 5, $matrix, $ret, $body), + 6 => $crate::try_with_interval_matrix!(@arm 6, $matrix, $ret, $body), + 7 => $crate::try_with_interval_matrix!(@arm 7, $matrix, $ret, $body), + requested => Err(::core::convert::From::from( + $crate::LaError::unsupported_dimension( + requested, + $crate::MAX_INTERVAL_MATRIX_DIM, + ), + )), + } + }}; + ($dim:expr, |mut $matrix:ident| -> $ret:ty $body:block $(,)?) => {{ + let __la_stack_requested_dim: usize = $dim; + match __la_stack_requested_dim { + 0 => $crate::try_with_interval_matrix!(@arm_mut 0, $matrix, $ret, $body), + 1 => $crate::try_with_interval_matrix!(@arm_mut 1, $matrix, $ret, $body), + 2 => $crate::try_with_interval_matrix!(@arm_mut 2, $matrix, $ret, $body), + 3 => $crate::try_with_interval_matrix!(@arm_mut 3, $matrix, $ret, $body), + 4 => $crate::try_with_interval_matrix!(@arm_mut 4, $matrix, $ret, $body), + 5 => $crate::try_with_interval_matrix!(@arm_mut 5, $matrix, $ret, $body), + 6 => $crate::try_with_interval_matrix!(@arm_mut 6, $matrix, $ret, $body), + 7 => $crate::try_with_interval_matrix!(@arm_mut 7, $matrix, $ret, $body), + requested => Err(::core::convert::From::from( + $crate::LaError::unsupported_dimension( + requested, + $crate::MAX_INTERVAL_MATRIX_DIM, + ), + )), + } + }}; + (@arm $d:literal, $matrix:ident, $ret:ty, $body:block) => {{ + let __la_stack_body = |$matrix: $crate::IntervalMatrix<$d>| -> $ret { $body }; + __la_stack_body($crate::IntervalMatrix::<$d>::zero()) + }}; + (@arm_mut $d:literal, $matrix:ident, $ret:ty, $body:block) => {{ + let __la_stack_body = |mut $matrix: $crate::IntervalMatrix<$d>| -> $ret { $body }; + __la_stack_body($crate::IntervalMatrix::<$d>::zero()) + }}; +} + /// Fallibly dispatch a runtime dimension to a concrete exact rational matrix. /// /// The macro creates a zero [`RationalMatrix`] with the selected const-generic @@ -622,13 +734,16 @@ macro_rules! try_with_rational_matrix { /// Common imports for ergonomic usage. /// /// This prelude re-exports the primary types and common constants: [`Matrix`], -/// [`DeterminantWithErrorBound`], [`Vector`], [`Lu`], [`Ldlt`], [`Tolerance`], +/// [`DeterminantWithErrorBound`], [`Interval`], [`IntervalMatrix`], +/// [`IntervalDeterminantSign`], [`Vector`], [`Lu`], [`Ldlt`], [`Tolerance`], /// and [`LaError`]. Its typed /// error categories include [`ArithmeticOperation`], [`FactorizationKind`], -/// [`InvalidToleranceReason`], [`NonFiniteLocation`], [`NonFiniteOrigin`], -/// [`PositiveSemidefiniteViolation`], [`SingularityReason`], and -/// [`UnrepresentableReason`]. It also re-exports [`DEFAULT_SINGULAR_TOL`], -/// [`MAX_STACK_MATRIX_DISPATCH_DIM`], and [`try_with_stack_matrix!`] for +/// [`IntervalBound`], [`IntervalOperand`], [`InvalidToleranceReason`], +/// [`NonFiniteLocation`], [`NonFiniteOrigin`], [`PositiveSemidefiniteViolation`], +/// [`SingularityReason`], and [`UnrepresentableReason`]. It also re-exports +/// [`DEFAULT_SINGULAR_TOL`], +/// [`MAX_STACK_MATRIX_DISPATCH_DIM`], [`MAX_INTERVAL_MATRIX_DIM`], +/// [`try_with_stack_matrix!`], and [`try_with_interval_matrix!`] for /// runtime-to-const matrix dispatch. Advanced custom-filter code should import /// [`ERR_COEFF_2`], [`ERR_COEFF_3`], and [`ERR_COEFF_4`] explicitly from the /// crate root; those raw coefficients intentionally stay out of the prelude. @@ -681,9 +796,11 @@ macro_rules! try_with_rational_matrix { pub mod prelude { pub use crate::{ ArithmeticOperation, DEFAULT_SINGULAR_TOL, DeterminantWithErrorBound, FactorizationKind, - InvalidToleranceReason, LaError, Ldlt, Lu, MAX_STACK_MATRIX_DISPATCH_DIM, Matrix, - NonFiniteLocation, NonFiniteOrigin, PositiveSemidefiniteViolation, SingularityReason, - Tolerance, UnrepresentableReason, Vector, try_with_stack_matrix, + Interval, IntervalBound, IntervalDeterminantSign, IntervalMatrix, IntervalOperand, + InvalidToleranceReason, LaError, Ldlt, Lu, MAX_INTERVAL_MATRIX_DIM, + MAX_STACK_MATRIX_DISPATCH_DIM, Matrix, NonFiniteLocation, NonFiniteOrigin, + PositiveSemidefiniteViolation, SingularityReason, Tolerance, UnrepresentableReason, Vector, + try_with_interval_matrix, try_with_stack_matrix, }; #[cfg(feature = "exact")] @@ -734,6 +851,38 @@ mod tests { gen_stack_matrix_dispatch_tests!(6); gen_stack_matrix_dispatch_tests!(7); + macro_rules! gen_interval_matrix_dispatch_tests { + ($d:literal) => { + paste! { + #[test] + fn []() { + let requested = $d; + let got = try_with_interval_matrix!( + requested, + |mut matrix| -> Result { + let mut index = 0; + while index < $d { + matrix.set(index, index, Interval::ONE)?; + index += 1; + } + matrix.det_sign() + }, + ); + + assert_eq!(got, Ok(IntervalDeterminantSign::Positive)); + } + } + }; + } + + gen_interval_matrix_dispatch_tests!(1); + gen_interval_matrix_dispatch_tests!(2); + gen_interval_matrix_dispatch_tests!(3); + gen_interval_matrix_dispatch_tests!(4); + gen_interval_matrix_dispatch_tests!(5); + gen_interval_matrix_dispatch_tests!(6); + gen_interval_matrix_dispatch_tests!(7); + #[cfg(feature = "exact")] macro_rules! gen_rational_matrix_dispatch_tests { ($d:literal) => { @@ -799,6 +948,16 @@ mod tests { assert_eq!(got, Ok(Some(1.0))); } + #[test] + fn try_with_interval_matrix_supports_zero_dimension() { + let got = try_with_interval_matrix!(0usize, |matrix| -> Result< + IntervalDeterminantSign, + LaError, + > { matrix.det_sign() },); + + assert_eq!(got, Ok(IntervalDeterminantSign::Positive)); + } + #[test] fn try_with_stack_matrix_evaluates_dimension_once() { let mut evaluations = 0; @@ -814,6 +973,21 @@ mod tests { assert_eq!(got, Ok(0.0)); } + #[test] + fn try_with_interval_matrix_evaluates_dimension_once() { + let mut evaluations = 0; + let got = try_with_interval_matrix!( + { + evaluations += 1; + 2usize + }, + |matrix| -> Result { matrix.try_get(1, 1) }, + ); + + assert_eq!(evaluations, 1); + assert_eq!(got, Ok(Interval::ZERO)); + } + #[test] fn try_with_stack_matrix_reports_unsupported_dimension() { let got = try_with_stack_matrix!(8usize, |m| -> Result { m.det() }); @@ -827,6 +1001,22 @@ mod tests { ); } + #[test] + fn try_with_interval_matrix_reports_unsupported_dimension() { + let got = try_with_interval_matrix!(8usize, |matrix| -> Result< + IntervalDeterminantSign, + LaError, + > { matrix.det_sign() },); + + assert_eq!( + got, + Err(LaError::UnsupportedDimension { + requested: 8, + max: MAX_INTERVAL_MATRIX_DIM, + }) + ); + } + #[derive(Debug, PartialEq)] struct DownstreamError(LaError); @@ -852,6 +1042,22 @@ mod tests { ); } + #[test] + fn try_with_interval_matrix_converts_unsupported_dimension_error() { + let got = try_with_interval_matrix!(8usize, |matrix| -> Result< + IntervalDeterminantSign, + DownstreamError, + > { Ok(matrix.det_sign()?) },); + + assert_eq!( + got, + Err(DownstreamError(LaError::UnsupportedDimension { + requested: 8, + max: MAX_INTERVAL_MATRIX_DIM, + })) + ); + } + #[cfg(feature = "exact")] #[test] fn try_with_rational_matrix_reports_unsupported_dimension() { diff --git a/tests/prelude_exports.rs b/tests/prelude_exports.rs index 459be77..35b4b8c 100644 --- a/tests/prelude_exports.rs +++ b/tests/prelude_exports.rs @@ -79,9 +79,62 @@ fn common_prelude_supports_downstream_composition() -> Result<(), LaError> { assert_abs_diff_eq!(dispatched, 1.0, epsilon = 0.0); assert_eq!(MAX_STACK_MATRIX_DISPATCH_DIM, 7); + let interval = Interval::try_from_subtraction(1.0, 0.1)?; + assert!(interval.lower() <= 1.0 - 0.1); + assert!(interval.upper() >= 1.0 - 0.1); + let interval_matrix = IntervalMatrix::<2>::identity(); + assert_eq!( + interval_matrix.det_sign()?, + IntervalDeterminantSign::Positive + ); + let interval_sign = + try_with_interval_matrix!(MAX_INTERVAL_MATRIX_DIM, |matrix| -> Result< + IntervalDeterminantSign, + LaError, + > { matrix.det_sign() },)?; + assert_eq!(interval_sign, IntervalDeterminantSign::Zero); + assert_matches!( + LaError::inverted_interval(2.0, 1.0), + LaError::InvertedInterval { + lower: 2.0, + upper: 1.0, + .. + } + ); Ok(()) } +#[test] +fn interval_error_categories_are_available_from_the_prelude() { + assert_matches!( + LaError::non_finite_input_interval_bound(IntervalBound::Lower), + LaError::NonFinite { + location: NonFiniteLocation::IntervalBound { + bound: IntervalBound::Lower, + .. + }, + .. + } + ); + assert_matches!( + LaError::non_finite_input_interval_operand(IntervalOperand::Right), + LaError::NonFinite { + location: NonFiniteLocation::IntervalOperand { + operand: IntervalOperand::Right, + .. + }, + .. + } + ); + assert_matches!( + LaError::interval_range_exhausted(ArithmeticOperation::IntervalAddition), + LaError::IntervalRangeExhausted { + operation: ArithmeticOperation::IntervalAddition, + .. + } + ); +} + #[cfg(feature = "exact")] #[test] fn exact_prelude_supports_downstream_composition() { diff --git a/tests/proptest_interval.rs b/tests/proptest_interval.rs new file mode 100644 index 0000000..171cb03 --- /dev/null +++ b/tests/proptest_interval.rs @@ -0,0 +1,360 @@ +#![forbid(unsafe_code)] + +//! Property tests for outward-rounded interval arithmetic and determinant signs. +//! +//! The oracle converts binary64 inputs independently to `BigRational` and uses +//! rational Gaussian elimination. Production interval determinants instead use +//! division-free subset dynamic programming. + +#![cfg(feature = "exact")] + +use std::array::from_fn; + +use pastey::paste; +use proptest::prelude::*; + +use la_stack::prelude::*; + +#[path = "common/proptest_config.rs"] +mod proptest_config; +use proptest_config::with_default_cases; + +fn exact_f64(value: f64) -> BigRational { + BigRational::from_f64(value).expect("the generated binary64 value is finite") +} + +fn interval_contains_exact(interval: Interval, value: &BigRational) -> bool { + exact_f64(interval.lower()) <= *value && *value <= exact_f64(interval.upper()) +} + +fn finite_f64() -> impl Strategy { + any::() + .prop_map(f64::from_bits) + .prop_filter("binary64 value must be finite", |value| value.is_finite()) +} + +fn exact_fits_finite_interval(value: &BigRational) -> bool { + let maximum = exact_f64(f64::MAX); + -&maximum <= *value && *value <= maximum +} + +fn assert_outward_result( + result: Result, + exact: &BigRational, + expected_operation: ArithmeticOperation, +) -> Result<(), TestCaseError> { + match result { + Ok(interval) => { + prop_assert!(interval_contains_exact(interval, exact)); + prop_assert!(exact_fits_finite_interval(exact)); + } + Err(LaError::IntervalRangeExhausted { operation, .. }) => { + prop_assert_eq!(operation, expected_operation); + prop_assert!(!exact_fits_finite_interval(exact)); + } + Err(error) => { + return Err(TestCaseError::fail(format!( + "unexpected interval error: {error}" + ))); + } + } + Ok(()) +} + +fn assert_conclusive_sign_matches( + sign: IntervalDeterminantSign, + exact: &BigRational, +) -> Result<(), TestCaseError> { + match sign { + IntervalDeterminantSign::Positive => prop_assert!(exact.is_positive()), + IntervalDeterminantSign::Negative => prop_assert!(exact.is_negative()), + IntervalDeterminantSign::Zero => { + prop_assert_eq!(exact, &BigRational::from_integer(0.into())); + } + IntervalDeterminantSign::Inconclusive => {} + _ => prop_assert!(false, "unknown interval determinant sign"), + } + Ok(()) +} + +/// Independent determinant oracle using rational Gaussian elimination. +fn rational_det(rows: &[[f64; D]; D]) -> BigRational { + let mut work: [[BigRational; D]; D] = + from_fn(|row| from_fn(|column| exact_f64(rows[row][column]))); + let mut negative = false; + + for column in 0..D { + let mut pivot_row = column; + while pivot_row < D && work[pivot_row][column] == BigRational::from_integer(0.into()) { + pivot_row += 1; + } + if pivot_row == D { + return BigRational::from_integer(0.into()); + } + if pivot_row != column { + work.swap(pivot_row, column); + negative = !negative; + } + + let pivot = work[column][column].clone(); + let pivot_row = work[column].clone(); + for row in work.iter_mut().skip(column + 1) { + let factor = &row[column] / &pivot; + for (entry, pivot_entry) in row.iter_mut().zip(pivot_row.iter()).skip(column) { + let reduction = &factor * pivot_entry; + *entry -= reduction; + } + } + } + + let mut determinant = BigRational::from_integer(1.into()); + for (index, row) in work.iter().enumerate() { + determinant *= &row[index]; + } + if negative { -determinant } else { determinant } +} + +fn f64_rows(rows: [[i8; D]; D]) -> [[f64; D]; D] { + rows.map(|row| row.map(f64::from)) +} + +proptest! { + #![proptest_config(with_default_cases(128))] + + #[test] + fn scalar_interval_operations_contain_exact_rational_results( + left in -10_000i16..=10_000, + right in -10_000i16..=10_000, + ) { + let left = f64::from(left) / 10.0; + let right = f64::from(right) / 10.0; + let exact_left = exact_f64(left); + let exact_right = exact_f64(right); + let left_interval = Interval::point(left)?; + let right_interval = Interval::point(right)?; + + let subtraction = Interval::try_from_subtraction(left, right)?; + prop_assert!(interval_contains_exact( + subtraction, + &(&exact_left - &exact_right), + )); + + let addition = left_interval.try_add(&right_interval)?; + prop_assert!(interval_contains_exact( + addition, + &(&exact_left + &exact_right), + )); + + let product = left_interval.try_mul(&right_interval)?; + prop_assert!(interval_contains_exact( + product, + &(&exact_left * &exact_right), + )); + + let square = left_interval.try_square()?; + prop_assert!(interval_contains_exact( + square, + &(&exact_left * &exact_left), + )); + } + + #[test] + fn wide_interval_operations_enclose_all_endpoint_extrema( + left_center in -100i16..=100, + left_radius in 0u8..=10, + right_center in -100i16..=100, + right_radius in 0u8..=10, + ) { + let left_lower = f64::from(left_center - i16::from(left_radius)); + let left_upper = f64::from(left_center + i16::from(left_radius)); + let right_lower = f64::from(right_center - i16::from(right_radius)); + let right_upper = f64::from(right_center + i16::from(right_radius)); + let left = Interval::try_new(left_lower, left_upper)?; + let right = Interval::try_new(right_lower, right_upper)?; + + let sum = left.try_add(&right)?; + prop_assert!(interval_contains_exact( + sum, + &(exact_f64(left_lower) + exact_f64(right_lower)), + )); + prop_assert!(interval_contains_exact( + sum, + &(exact_f64(left_upper) + exact_f64(right_upper)), + )); + + let product = left.try_mul(&right)?; + for (left_endpoint, right_endpoint) in [ + (left_lower, right_lower), + (left_lower, right_upper), + (left_upper, right_lower), + (left_upper, right_upper), + ] { + prop_assert!(interval_contains_exact( + product, + &(exact_f64(left_endpoint) * exact_f64(right_endpoint)), + )); + } + + let square = left.try_square()?; + prop_assert!(interval_contains_exact( + square, + &(exact_f64(left_lower) * exact_f64(left_lower)), + )); + prop_assert!(interval_contains_exact( + square, + &(exact_f64(left_upper) * exact_f64(left_upper)), + )); + if left.contains(0.0) { + prop_assert!(square.contains(0.0)); + } + } +} + +proptest! { + #![proptest_config(with_default_cases(512))] + + #[test] + fn arbitrary_finite_point_operations_are_outward_or_report_true_range_loss( + left in finite_f64(), + right in finite_f64(), + ) { + let exact_left = exact_f64(left); + let exact_right = exact_f64(right); + let left_interval = Interval::point(left)?; + let right_interval = Interval::point(right)?; + + assert_outward_result( + Interval::try_from_subtraction(left, right), + &(&exact_left - &exact_right), + ArithmeticOperation::IntervalSubtraction, + )?; + assert_outward_result( + left_interval.try_add(&right_interval), + &(&exact_left + &exact_right), + ArithmeticOperation::IntervalAddition, + )?; + assert_outward_result( + left_interval.try_mul(&right_interval), + &(&exact_left * &exact_right), + ArithmeticOperation::IntervalMultiplication, + )?; + assert_outward_result( + left_interval.try_square(), + &(&exact_left * &exact_left), + ArithmeticOperation::IntervalSquare, + )?; + } +} + +macro_rules! gen_interval_determinant_proptests { + ($d:literal) => { + paste! { + proptest! { + #![proptest_config(with_default_cases(24))] + + #[test] + fn []( + raw_rows in any::<[[i8; $d]; $d]>(), + ) { + let rows = f64_rows(raw_rows); + let matrix = IntervalMatrix::<$d>::try_from_point_rows(rows)?; + let determinant = matrix.det()?; + let exact = rational_det(&rows); + prop_assert!(interval_contains_exact(determinant, &exact)); + + match matrix.det_sign()? { + IntervalDeterminantSign::Positive => prop_assert!(exact.is_positive()), + IntervalDeterminantSign::Negative => prop_assert!(exact.is_negative()), + IntervalDeterminantSign::Zero => prop_assert_eq!( + exact, + BigRational::from_integer(0.into()), + ), + IntervalDeterminantSign::Inconclusive => { + prop_assert!(determinant.contains(0.0)); + } + _ => prop_assert!(false, "unknown interval determinant sign"), + } + } + + #[test] + fn []( + raw_rows in any::<[[i8; $d]; $d]>(), + raw_radii in any::<[[u8; $d]; $d]>(), + ) { + let center_rows = f64_rows(raw_rows); + let lower_rows = from_fn(|row| from_fn(|column| { + let center = i16::from(raw_rows[row][column]); + let radius = i16::from(raw_radii[row][column] % 3); + f64::from(center - radius) + })); + let upper_rows = from_fn(|row| from_fn(|column| { + let center = i16::from(raw_rows[row][column]); + let radius = i16::from(raw_radii[row][column] % 3); + f64::from(center + radius) + })); + let interval_rows = from_fn(|row| from_fn(|column| { + Interval::try_new(lower_rows[row][column], upper_rows[row][column]) + .expect("integer endpoints are finite and ordered") + })); + let matrix = IntervalMatrix::<$d>::from_rows(interval_rows); + let determinant = matrix.det()?; + let sign = matrix.det_sign()?; + + for selected_rows in [&lower_rows, ¢er_rows, &upper_rows] { + let exact = rational_det(selected_rows); + prop_assert!(interval_contains_exact(determinant, &exact)); + assert_conclusive_sign_matches(sign, &exact)?; + } + } + } + } + }; +} + +gen_interval_determinant_proptests!(2); +gen_interval_determinant_proptests!(3); +gen_interval_determinant_proptests!(4); +gen_interval_determinant_proptests!(5); +gen_interval_determinant_proptests!(6); +gen_interval_determinant_proptests!(7); + +proptest! { + #![proptest_config(with_default_cases(48))] + + #[test] + fn wide_2d_determinant_contains_every_endpoint_matrix( + raw_rows in any::<[[i8; 2]; 2]>(), + raw_radii in any::<[[u8; 2]; 2]>(), + ) { + let lower_rows: [[f64; 2]; 2] = from_fn(|row| from_fn(|column| { + let center = i16::from(raw_rows[row][column]); + let radius = i16::from(raw_radii[row][column] % 3); + f64::from(center - radius) + })); + let upper_rows: [[f64; 2]; 2] = from_fn(|row| from_fn(|column| { + let center = i16::from(raw_rows[row][column]); + let radius = i16::from(raw_radii[row][column] % 3); + f64::from(center + radius) + })); + let matrix = IntervalMatrix::<2>::from_rows(from_fn(|row| from_fn(|column| { + Interval::try_new(lower_rows[row][column], upper_rows[row][column]) + .expect("integer endpoints are finite and ordered") + }))); + let determinant = matrix.det()?; + let sign = matrix.det_sign()?; + + for endpoint_mask in 0_u8..16 { + let selected_rows: [[f64; 2]; 2] = from_fn(|row| from_fn(|column| { + let bit = 1_u8 << (row * 2 + column); + if endpoint_mask & bit == 0 { + lower_rows[row][column] + } else { + upper_rows[row][column] + } + })); + let exact = rational_det(&selected_rows); + prop_assert!(interval_contains_exact(determinant, &exact)); + assert_conclusive_sign_matches(sign, &exact)?; + } + } +} diff --git a/uv.lock b/uv.lock index badb3f5..09bd9e8 100644 --- a/uv.lock +++ b/uv.lock @@ -19,14 +19,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.14.2" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/9a/c15a60547004a3f3cea20296c934f827ddd7bdba225a2e7e9fcb5ec48c80/anyio-4.15.0.tar.gz", hash = "sha256:b5c620ed540725e2579c31b17bb995b3bf02c9281c9cace04c7d186380bab85e", size = 276504, upload-time = "2026-09-02T21:46:36.957Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/2b21ce5ebe4d8938a247c9b0dbb7271566ae559b01795c83ea4bb2660ed7/anyio-4.15.0-py3-none-any.whl", hash = "sha256:7ecd9937369ffce8bba0b5ccb9b3a9507b101b0ed50256aecfbab27e6c2acb99", size = 131908, upload-time = "2026-09-02T21:46:35.485Z" }, ] [[package]] @@ -338,14 +339,14 @@ wheels = [ [[package]] name = "googleapis-common-protos" -version = "1.75.2" +version = "1.75.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/90/fb8f1c84537fbf210c1f53a53ae473a805f6599c5a40b93c1bbadd211f7a/googleapis_common_protos-1.75.2.tar.gz", hash = "sha256:8829a3d1e4508c5b7b9a6b9525f7fccff611f8531644579a76466c29295d4bb2", size = 154083, upload-time = "2026-08-25T19:19:13.028Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/c5/4353a188e2c335aee33269e8b654af228278cca8e5f0b4b5f11e5d0e9adb/googleapis_common_protos-1.75.3.tar.gz", hash = "sha256:57c435ac2c68b108999b6db075d9053e4d7a936ba57b4a3d45667b1346f1738a", size = 153905, upload-time = "2026-09-03T22:31:21.869Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/5b/1c9e55363c3b1890a98cae813de5b4ea327845756cd8fb7ee690140c7eac/googleapis_common_protos-1.75.2-py3-none-any.whl", hash = "sha256:6b83302f554ea93a0f48409c7fc2050f954bcbcddb7e3a9c76d4a823cb22920e", size = 307002, upload-time = "2026-08-25T19:18:08.927Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7a/7d79170c6ce6f12e109df2b3879d6b934010cf4f99aea8de8b7e5408c174/googleapis_common_protos-1.75.3-py3-none-any.whl", hash = "sha256:a018d2bf098ca9fb6faa08d5bb780e2a2c2f73c566f069761331386c9596d3f2", size = 306984, upload-time = "2026-09-03T22:30:45.133Z" }, ] [[package]] @@ -474,11 +475,11 @@ dev = [ dev = [ { name = "actionlint-py", specifier = "==1.7.12.24" }, { name = "pytest", specifier = "==9.1.1" }, - { name = "ruff", specifier = "==0.16.5" }, + { name = "ruff", specifier = "==0.16.6" }, { name = "semgrep", specifier = "==1.176.0" }, { name = "shellcheck-py", specifier = "==0.11.0.1" }, { name = "shfmt-py", specifier = "==4.1.0" }, - { name = "ty", specifier = "==0.0.77" }, + { name = "ty", specifier = "==0.0.78" }, { name = "yamllint", specifier = "==1.38.0" }, ] @@ -1022,27 +1023,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, - { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, - { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, - { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, - { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, - { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, - { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, - { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, - { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, - { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, ] [[package]] @@ -1125,15 +1126,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.4.8" +version = "3.4.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/00/b42a44342a054d58cb1115d7c8aa9cb4290dd9442f9c1b91a4b8173dba22/sse_starlette-3.4.8.tar.gz", hash = "sha256:ed89ffbb75cbf78a5fe2f2109cd584792ee7f9dfac96f791db546df8f15f3f9c", size = 32548, upload-time = "2026-08-05T11:19:49.982Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/e1/8a41e88e825ea26c44333897c7ffe35fe60153a2cfc097a5bd1d209ad281/sse_starlette-3.4.10.tar.gz", hash = "sha256:c6c87280d8feb4e55a8d79633782766b9cac6a26da5c79a145d00aa404117a86", size = 33720, upload-time = "2026-09-03T09:36:24.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/3a/764912c58293d95b6dcdf4cc255f9d10de310580ced547b082eb9d72018c/sse_starlette-3.4.8-py3-none-any.whl", hash = "sha256:6e82314c786709a3cd9520f2285cf9fff90e181e598e8a357b0cf80f66afba0d", size = 16516, upload-time = "2026-08-05T11:19:48.748Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3c/96018a51c7301a64f7b0579d9ce8f9b69dd39ca8ed5aa100ba3feadee503/sse_starlette-3.4.10-py3-none-any.whl", hash = "sha256:710f5f5b0527409903a22a91699db02f76f4c2eb9204e882e4ee7cada76bdf75", size = 17120, upload-time = "2026-09-03T09:36:22.56Z" }, ] [[package]] @@ -1177,27 +1178,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.77" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/ef/a2024d1d33d5ba8436677a6fd734f87a137150aba99906fc009f7c5de956/ty-0.0.77.tar.gz", hash = "sha256:8898f3097610f4a772ead6bfad7b204c7d76e8eb3a010c7285b9186278e0fb82", size = 7005734, upload-time = "2026-09-01T00:26:26.901Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/03/930f7454a6d797abc09aebbf537017825ebb08d9f8c2e2d905e74341dbb1/ty-0.0.77-py3-none-linux_armv6l.whl", hash = "sha256:ea76012f1ed387b6fc6500894fb8a7654b724c38713e263a23f12756f00e2469", size = 13241626, upload-time = "2026-09-01T00:25:51.02Z" }, - { url = "https://files.pythonhosted.org/packages/65/41/7e064045775718673851f6c0e1cfde70d6e380b0db9d91d4484a362f2d67/ty-0.0.77-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:237a58c389e01d65c4693e0394feb0f0adea3fba0345c3b932d209084372f3d3", size = 12830037, upload-time = "2026-09-01T00:25:53.172Z" }, - { url = "https://files.pythonhosted.org/packages/5b/64/66f0b0dc89bd239922bdf12fa3e6d066aa7a425e8b70414368c4eb44a6e6/ty-0.0.77-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bbb8ae7a753b9a6af25725c314d0eca967e5fde5eebb7a35b04b0d763dfb9d7c", size = 12612071, upload-time = "2026-09-01T00:25:55.116Z" }, - { url = "https://files.pythonhosted.org/packages/fd/5c/bef8b1f471931a9395792be023613b6cee0ef1449815bf97b18be07f883c/ty-0.0.77-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:603620c063b718ddbe302f83fb0d7c01a13f1941c3b9763512a89c9bbfabba96", size = 12641403, upload-time = "2026-09-01T00:25:57.305Z" }, - { url = "https://files.pythonhosted.org/packages/43/66/e4d32a0145162f79bc3dd6fca4770f88966c3b5206121ffeecd5f7b05e8b/ty-0.0.77-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03f128cb8b204e22a18f4b01ef0194aa445f4edb080cb7d77621cb2ab353885c", size = 13013526, upload-time = "2026-09-01T00:25:59.278Z" }, - { url = "https://files.pythonhosted.org/packages/aa/be/33493ee43d4db7d402ceaee55c5d6c22e2b1105f10b1ac5928c220f79650/ty-0.0.77-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97d1600ef69fc8e3b2310de1915cd34ba3b712fdc730cb02b713520956baec0a", size = 13828076, upload-time = "2026-09-01T00:26:01.343Z" }, - { url = "https://files.pythonhosted.org/packages/cf/d5/b6335fdc8c09aaaaf6541322076c5a7205fa16c29dbeddc1f24dd89b3894/ty-0.0.77-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dced0924a83b6d945fd48a29aa85e131fb98dc574f4c9a0e7c43d03eff373402", size = 14247770, upload-time = "2026-09-01T00:26:03.388Z" }, - { url = "https://files.pythonhosted.org/packages/11/64/bbe96c1da26585919ac10f33df1d337d9b498013fc60c5178e76955c2a53/ty-0.0.77-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b14b002233a954115a04932b3a93d75a387569848276cf7bb0b5dc6b040001c4", size = 13926528, upload-time = "2026-09-01T00:26:05.587Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e2/cdc94e9a1b69e9b7dd0ec3ad3ca75741245b8b5b2e3ffe9b365ca31d8052/ty-0.0.77-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:581adf1ae1e48e00e89ec162913d559ddf75a7805646a13ffd68943c1b5e8daa", size = 13290834, upload-time = "2026-09-01T00:26:07.912Z" }, - { url = "https://files.pythonhosted.org/packages/92/8c/4929dd8e924ddbd6284dcb3cfbda488bd6cb091e5384f8b7fb5dd3de9122/ty-0.0.77-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9bda523b40e06c643feb6d5d5bcbacc56a2bd9eed3d7cae1119452502bcd69b0", size = 13829048, upload-time = "2026-09-01T00:26:10.093Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f4/441a74ddc5e2db2690264c1ee0e478d46b8e42bd529472fdb1656b63bff2/ty-0.0.77-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4b8b04d8c3af8148e963e950094bd53c8cc4d3a11f5f8764ff8d39627bd6e64a", size = 12803755, upload-time = "2026-09-01T00:26:12.055Z" }, - { url = "https://files.pythonhosted.org/packages/85/39/875bb7092ee1edb090b62eca74b7311f6416dc3aa7da442b4a1f95408251/ty-0.0.77-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:33a9ff9be488edf4d6fac46c456198cc848d9641f418f1a83aa123825e93c6ec", size = 13028244, upload-time = "2026-09-01T00:26:14.212Z" }, - { url = "https://files.pythonhosted.org/packages/e3/76/4b0a48f118dca6a32e1609f2d0bea2c3f7be9051af645fe5044ca35cbbb4/ty-0.0.77-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61ad5467206e5ea6531c8aebebf1276c6150989a94a1d5974a84d9e0eeed5c38", size = 13321068, upload-time = "2026-09-01T00:26:16.418Z" }, - { url = "https://files.pythonhosted.org/packages/8d/46/cd21bc6441df4b221d9e131551bab2a5f9c0b724e60e1c960622ee175128/ty-0.0.77-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1293b94f26d1f330424e3b97c20b434f7e2ac1938287b8588466c75ece6c0b10", size = 13609112, upload-time = "2026-09-01T00:26:18.424Z" }, - { url = "https://files.pythonhosted.org/packages/21/7b/0da4537a7eca4eb3ca72a2cb4539cfbb6fb3138d1dc4ae7a66299b941fb3/ty-0.0.77-py3-none-win32.whl", hash = "sha256:cb71152bdf56860375c790682cc3efc120aaf8e36f1cd6367773312905e82b50", size = 12573821, upload-time = "2026-09-01T00:26:20.404Z" }, - { url = "https://files.pythonhosted.org/packages/96/91/aec75b11bfaa5a358f5a505afa6307fc4bee1c5f4c6444e18fa82c25f3be/ty-0.0.77-py3-none-win_amd64.whl", hash = "sha256:86f01d4af8b006c9442a2de0ab949cc24462f8f9431bebc0b73f6166fbbca19f", size = 13154851, upload-time = "2026-09-01T00:26:22.389Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e5/77772f95ff81bf7c817595cd08b52d007acab90dac0f2339faa486e6a525/ty-0.0.77-py3-none-win_arm64.whl", hash = "sha256:942a3bb2a4786c80bd8ef4503e5024046617cdfcc550b56c1acc4817ce7ac144", size = 12992392, upload-time = "2026-09-01T00:26:24.44Z" }, +version = "0.0.78" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/c7/2ba0861384c5b5097ac354383abb98188112cb330208c21d5197e98a29e5/ty-0.0.78.tar.gz", hash = "sha256:770b45854f85fa11595208f08c0f28df80943164d10a2832d86be6ac29f135b2", size = 7050609, upload-time = "2026-09-02T22:41:33.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/ed/f34cfc06a9ba72979df219e48dae1a0b6a6c74a340763ccd30a547edf913/ty-0.0.78-py3-none-linux_armv6l.whl", hash = "sha256:122700b98f9d45785c1ce91a9418154562e7f64f4bf34679f6f7b38c5b97453f", size = 13304624, upload-time = "2026-09-02T22:40:56.649Z" }, + { url = "https://files.pythonhosted.org/packages/1c/28/5576e2a08b57676d9b2a736d528f077a6c6e32c3d1de2d75dbbe28346966/ty-0.0.78-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cbe7e3709ccf29ef3d9f58f5914cc30ec36fb647008a5a4ff8483abe401bfe8d", size = 12928383, upload-time = "2026-09-02T22:40:59.162Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/027d49c3da5235e634da3d3fc52c4874ec89f50830388c9c259f8fb71e0e/ty-0.0.78-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c3528897b3ab9d3589561bc2b5e61a4d5d686de527a4400bb09a439b922a6c70", size = 12730058, upload-time = "2026-09-02T22:41:01.235Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bc/6bf8ffefd8063730a70ae889cf85def72bc155fd1453135ebf8a09c2f1c2/ty-0.0.78-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8bfba4c44a06484093f527b8ef43a317abcf91395b49396170266629eedf74aa", size = 12813321, upload-time = "2026-09-02T22:41:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c0/b3cdf26f82108908d92fdb7ddb741019c446ceb666cf204d4dbf611bde33/ty-0.0.78-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f903c06fdee17baf8373173039ab28f1bce28e66b1c734929a5f50b37eb54d9", size = 13072219, upload-time = "2026-09-02T22:41:05.225Z" }, + { url = "https://files.pythonhosted.org/packages/4d/36/16bfb0abdd178dae11dbc4b57b9a86c2b62642a18c1103fd2c2930aa5879/ty-0.0.78-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd83e5fe3f07291d1bd4e591f8d2607c204f3eab53bcb1b8060c40e876e1aa61", size = 13903958, upload-time = "2026-09-02T22:41:07.333Z" }, + { url = "https://files.pythonhosted.org/packages/0f/0c/74d3b1f0344b13156c719dd68a7edf7e48c2051718c9f326ba3cbf45ea53/ty-0.0.78-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:325285377319cf7168a2b8b771ae5027f411530e3c7eab1faf4583cdd72fd7c9", size = 14355581, upload-time = "2026-09-02T22:41:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d7/7ca0359e1e61b15b8b02328c8d120db3c507876b9b7c6bd9f26935353e0e/ty-0.0.78-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b7846a404da6492697b524a769755ee9bee157d67674063ecd9c5f69dc52ff", size = 14044749, upload-time = "2026-09-02T22:41:11.678Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6a/731f16ff42c5fc96e742c2f4e0a6915356bae114ae02ae4af6f33502175f/ty-0.0.78-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:169d3b9d134c0b48fe1af8a844142d374655552054a9d6c15a4b6e51bb0382ea", size = 13391647, upload-time = "2026-09-02T22:41:13.928Z" }, + { url = "https://files.pythonhosted.org/packages/0c/af/250cc29daf310e837188509ea4d78460c495d8a74c4fb84b4152d9ef2d4f/ty-0.0.78-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6108cb3b2d28dac5981d4e25008a0a5547d8c877a67f6da9e8c38e7e946be44f", size = 13945020, upload-time = "2026-09-02T22:41:15.968Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/96aab1dee4535e66554e8dc7657c69f6c61c181d8e70b9c3527908e93ef4/ty-0.0.78-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:589ad03608d9d2975ef4b23c41c4f847e325bcc7686f51d4c66066b8dbc18a2a", size = 12851149, upload-time = "2026-09-02T22:41:18.06Z" }, + { url = "https://files.pythonhosted.org/packages/85/b0/53d8fe9a847534ef5fe2165c7d5292cb853b29dfd7011cbfb1cdb90a8012/ty-0.0.78-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b3fa0786edc1af06030f872d83499c0cea7c261dbbb651e0aee8306bf2c8a869", size = 13091464, upload-time = "2026-09-02T22:41:20.227Z" }, + { url = "https://files.pythonhosted.org/packages/21/65/94a4e5a02de559f6c6c0ee7a14b88660b4eee058ec6fec5c0ff6ecfbf5cc/ty-0.0.78-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92c5639befc577578c8abd4e5a7fccf98d828db98b09e8d4dd81605dc0e54aef", size = 13389389, upload-time = "2026-09-02T22:41:22.27Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/d0c7fe6be64ca5a15100c4b1f0e39c19660d53bc544f609fa117b346e661/ty-0.0.78-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0dce70bc51652b2775debd1d1e422fb0179f5207c73deb4d5d0aca37e5f61695", size = 13691928, upload-time = "2026-09-02T22:41:24.292Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5d/36081205611ba3fa802efc4ad63e4b1e949bda2f7bb37b34ac9bb6c00db0/ty-0.0.78-py3-none-win32.whl", hash = "sha256:1c80976ca9185d7a9d1baab1fb57240331b8dac58d3b11e46200284086a6de8d", size = 12647346, upload-time = "2026-09-02T22:41:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/f1/89/b925fe1ea1bc56fc7f11d2496e29072fdd2ceb7b389884fc7b2d07cf0c41/ty-0.0.78-py3-none-win_amd64.whl", hash = "sha256:32e82b704471eab34f67b51c151660ca8a00815977b28278905d76fba54f7415", size = 13241810, upload-time = "2026-09-02T22:41:28.857Z" }, + { url = "https://files.pythonhosted.org/packages/91/f1/090ef7b52355bcedfbfbff6ce70fa81ba5bd5f6ed3f8d99ae05e6f2fe75b/ty-0.0.78-py3-none-win_arm64.whl", hash = "sha256:3a14d641a3c04fa9a80f2a46be1531d915f60d4fb79d4b894627bbe46bb35d64", size = 13077433, upload-time = "2026-09-02T22:41:31.525Z" }, ] [[package]]