Skip to content

Commit adfc33b

Browse files
committed
feat!(api): enforce fallible numeric invariants
- Introduce validated tolerance values for factorization and symmetry APIs. - Return typed errors for non-finite matrix, vector, determinant, and norm results. - Make determinant and vector operations propagate overflow/non-finite failures. - Update examples, docs, benchmarks, and property tests for the fallible API. - Document the v0.4.2 roadmap order for finite Matrix/Vector proof-type work. BREAKING CHANGE: tolerance arguments now use Tolerance instead of raw f64, Matrix::set returns Option<()>, determinant helpers return Result, Lu::det and Ldlt::det return Result<f64, LaError>, and Vector::dot / Vector::norm2_sq return Result<f64, LaError>. Closes #83
1 parent 6124948 commit adfc33b

23 files changed

Lines changed: 923 additions & 438 deletions

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ invariant over the convenient edit.
111111
`git --no-pager log`, `git --no-pager show`, `git --no-pager blame`) to inspect changes/history
112112
- **ALWAYS** use `git --no-pager` when reading git output
113113
- Suggest git commands that modify version control state for the user to run manually
114+
- When suggesting branch names, prefer `{type}/{issue}-descriptor-or-two`, e.g. `fix/307-topology-validation`,
115+
`perf/315-bench-profile`, or `doc/329-branch-guidance`. If an environment requires an owner/tool prefix,
116+
keep this structure after the prefix, e.g. `codex/fix/307-topology-validation`.
114117

115118
### Commit Messages
116119

@@ -240,6 +243,9 @@ just examples # Run all examples
240243
- Python setup: `uv sync --group dev` (or `just python-sync`)
241244
- Python tests: `just test-python`
242245
- Run a single test (by name filter): `cargo test solve_2x2_basic` (or the full path: `cargo test lu::tests::solve_2x2_basic`)
246+
Cargo accepts only one positional test filter. To run multiple focused
247+
filters, run separate `cargo test <filter>` commands rather than passing
248+
multiple filter arguments.
243249
- Run exact-feature tests: `cargo test --features exact --verbose` (or `just test-exact`)
244250
- Run examples: `just examples` (or `cargo run --example det_5x5` / `cargo run --example solve_5x5` /
245251
`cargo run --example ldlt_solve_3x3` / `cargo run --example const_det_4x4` /

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Feat!(matrix): enforce fallible matrix invariants [`e26c283`](https://github.com/acgetchell/la-stack/commit/e26c28358b2358100353b2895441b68892e92cd7)
13+
1014
### Changed
1115

1216
- Remove redundant cache restore keys for cargo-llvm-cov [`f75a01c`](https://github.com/acgetchell/la-stack/commit/f75a01c99c8dbcc8b6ffc36ae9f94ba968a2f111)
@@ -37,6 +41,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3741
### Documentation
3842

3943
- Document feature requirement for exact APIs [`19b10d5`](https://github.com/acgetchell/la-stack/commit/19b10d552e83b6a7f9e91695b4850b8fab3f4550)
44+
- Document scalar scope and release roadmap [`bfb0393`](https://github.com/acgetchell/la-stack/commit/bfb039386588f94b95561c610181ca6d486acd6e)
45+
46+
- Clarify that la-stack intentionally supports f64 floating-point APIs plus optional exact rationals, not alternate scalar families.
47+
- Add a roadmap covering the v0.4.x stable-Rust issue sequence and the v0.5.0 generic_const_exprs anchor.
48+
- Refresh generated changelog entries and archived changelog grouping.
4049

4150
### Maintenance
4251

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ proptest = "1.11.0"
2828

2929
[features]
3030
default = [ ]
31-
bench = [ "criterion", "faer", "nalgebra" ]
32-
exact = [ "num-bigint", "num-rational", "num-traits" ]
31+
bench = [ "dep:criterion", "dep:faer", "dep:nalgebra" ]
32+
exact = [ "dep:num-bigint", "dep:num-rational", "dep:num-traits" ]
3333

3434
[[example]]
3535
name = "exact_det_3x3"

README.md

Lines changed: 78 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -76,25 +76,29 @@ Solve a 5×5 system via LU:
7676
```rust
7777
use la_stack::prelude::*;
7878

79-
// This system requires pivoting (a[0][0] = 0), so it's a good LU demo.
80-
// A = J - I: zeros on diagonal, ones elsewhere.
81-
let a = Matrix::<5>::from_rows([
82-
[0.0, 1.0, 1.0, 1.0, 1.0],
83-
[1.0, 0.0, 1.0, 1.0, 1.0],
84-
[1.0, 1.0, 0.0, 1.0, 1.0],
85-
[1.0, 1.0, 1.0, 0.0, 1.0],
86-
[1.0, 1.0, 1.0, 1.0, 0.0],
87-
]);
88-
89-
let b = Vector::<5>::new([14.0, 13.0, 12.0, 11.0, 10.0]);
90-
91-
let lu = a.lu(DEFAULT_PIVOT_TOL).unwrap();
92-
let x = lu.solve_vec(b).unwrap().into_array();
93-
94-
// Floating-point rounding is expected; compare with a tolerance.
95-
let expected = [1.0, 2.0, 3.0, 4.0, 5.0];
96-
for (x_i, e_i) in x.iter().zip(expected.iter()) {
97-
assert!((*x_i - *e_i).abs() <= 1e-12);
79+
fn main() -> Result<(), LaError> {
80+
// This system requires pivoting (a[0][0] = 0), so it's a good LU demo.
81+
// A = J - I: zeros on diagonal, ones elsewhere.
82+
let a = Matrix::<5>::from_rows([
83+
[0.0, 1.0, 1.0, 1.0, 1.0],
84+
[1.0, 0.0, 1.0, 1.0, 1.0],
85+
[1.0, 1.0, 0.0, 1.0, 1.0],
86+
[1.0, 1.0, 1.0, 0.0, 1.0],
87+
[1.0, 1.0, 1.0, 1.0, 0.0],
88+
]);
89+
90+
let b = Vector::<5>::new([14.0, 13.0, 12.0, 11.0, 10.0]);
91+
92+
let lu = a.lu(DEFAULT_PIVOT_TOL)?;
93+
let x = lu.solve_vec(b)?.into_array();
94+
95+
// Floating-point rounding is expected; compare with a tolerance.
96+
let expected = [1.0, 2.0, 3.0, 4.0, 5.0];
97+
for (x_i, e_i) in x.iter().zip(expected.iter()) {
98+
assert!((*x_i - *e_i).abs() <= 1e-12);
99+
}
100+
101+
Ok(())
98102
}
99103
```
100104

@@ -106,17 +110,21 @@ For symmetric positive-definite matrices, `LDL^T` is essentially a square-root-f
106110
```rust
107111
use la_stack::prelude::*;
108112

109-
// This matrix is symmetric positive-definite (A = L*L^T) so LDLT works without pivoting.
110-
let a = Matrix::<5>::from_rows([
111-
[1.0, 1.0, 0.0, 0.0, 0.0],
112-
[1.0, 2.0, 1.0, 0.0, 0.0],
113-
[0.0, 1.0, 2.0, 1.0, 0.0],
114-
[0.0, 0.0, 1.0, 2.0, 1.0],
115-
[0.0, 0.0, 0.0, 1.0, 2.0],
116-
]);
117-
118-
let det = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap().det();
119-
assert!((det - 1.0).abs() <= 1e-12);
113+
fn main() -> Result<(), LaError> {
114+
// This matrix is symmetric positive-definite (A = L*L^T) so LDLT works without pivoting.
115+
let a = Matrix::<5>::from_rows([
116+
[1.0, 1.0, 0.0, 0.0, 0.0],
117+
[1.0, 2.0, 1.0, 0.0, 0.0],
118+
[0.0, 1.0, 2.0, 1.0, 0.0],
119+
[0.0, 0.0, 1.0, 2.0, 1.0],
120+
[0.0, 0.0, 0.0, 1.0, 2.0],
121+
]);
122+
123+
let det = a.ldlt(DEFAULT_SINGULAR_TOL)?.det()?;
124+
assert!((det - 1.0).abs() <= 1e-12);
125+
126+
Ok(())
127+
}
120128
```
121129

122130
> ⚠️ **LDLT invariant:** The input matrix must be **symmetric**. Asymmetric
@@ -133,23 +141,23 @@ assert!((det - 1.0).abs() <= 1e-12);
133141

134142
`det_direct()` is a `const fn` providing closed-form determinants for D=0–4,
135143
using fused multiply-add where applicable. `Matrix::<0>::zero().det_direct()`
136-
returns `Some(1.0)` (the empty-product convention). For D=1–4, cofactor
144+
returns `Ok(Some(1.0))` (the empty-product convention). For D=1–4, cofactor
137145
expansion bypasses LU factorization entirely. This enables compile-time
138146
evaluation when inputs are known:
139147

140148
```rust
141149
use la_stack::prelude::*;
142150

143151
// Evaluated entirely at compile time — no runtime cost.
144-
const DET: Option<f64> = {
152+
const DET: Result<Option<f64>, LaError> = {
145153
let m = Matrix::<3>::from_rows([
146154
[2.0, 0.0, 0.0],
147155
[0.0, 3.0, 0.0],
148156
[0.0, 0.0, 5.0],
149157
]);
150158
m.det_direct()
151159
};
152-
assert_eq!(DET, Some(30.0));
160+
assert_eq!(DET, Ok(Some(30.0)));
153161
```
154162

155163
The public `det()` method automatically dispatches through the closed-form path
@@ -181,23 +189,27 @@ la-stack = { version = "0.4.1", features = ["exact"] }
181189
```rust,ignore
182190
use la_stack::prelude::*;
183191
184-
// Exact determinant
185-
let m = Matrix::<3>::from_rows([
186-
[1.0, 2.0, 3.0],
187-
[4.0, 5.0, 6.0],
188-
[7.0, 8.0, 9.0],
189-
]);
190-
assert_eq!(m.det_sign_exact().unwrap(), 0); // exactly singular
191-
192-
let det = m.det_exact().unwrap();
193-
assert_eq!(det, BigRational::from_integer(0.into())); // exact zero
194-
195-
// Exact linear system solve
196-
let a = Matrix::<2>::from_rows([[1.0, 2.0], [3.0, 4.0]]);
197-
let b = Vector::<2>::new([5.0, 11.0]);
198-
let x = a.solve_exact_f64(b).unwrap().into_array();
199-
assert!((x[0] - 1.0).abs() <= f64::EPSILON);
200-
assert!((x[1] - 2.0).abs() <= f64::EPSILON);
192+
fn main() -> Result<(), LaError> {
193+
// Exact determinant
194+
let m = Matrix::<3>::from_rows([
195+
[1.0, 2.0, 3.0],
196+
[4.0, 5.0, 6.0],
197+
[7.0, 8.0, 9.0],
198+
]);
199+
assert_eq!(m.det_sign_exact()?, 0); // exactly singular
200+
201+
let det = m.det_exact()?;
202+
assert_eq!(det, BigRational::from_integer(0.into())); // exact zero
203+
204+
// Exact linear system solve
205+
let a = Matrix::<2>::from_rows([[1.0, 2.0], [3.0, 4.0]]);
206+
let b = Vector::<2>::new([5.0, 11.0]);
207+
let x = a.solve_exact_f64(b)?.into_array();
208+
assert!((x[0] - 1.0).abs() <= f64::EPSILON);
209+
assert!((x[1] - 2.0).abs() <= f64::EPSILON);
210+
211+
Ok(())
212+
}
201213
```
202214

203215
With the `exact` feature enabled, `BigInt` and `BigRational` are re-exported
@@ -222,19 +234,24 @@ adaptive-precision logic for geometric predicates:
222234
```rust,ignore
223235
use la_stack::prelude::*;
224236
225-
let m = Matrix::<3>::identity();
226-
if let Some(bound) = m.det_errbound() {
227-
let det = m.det_direct().unwrap();
228-
if det.abs() > bound {
229-
// f64 sign is guaranteed correct
230-
let sign = det.signum() as i8;
237+
fn main() -> Result<(), LaError> {
238+
let m = Matrix::<3>::identity();
239+
if let Some(bound) = m.det_errbound()? {
240+
if let Some(det) = m.det_direct()? {
241+
if det.abs() > bound {
242+
// f64 sign is guaranteed correct
243+
let sign = det.signum() as i8;
244+
} else {
245+
// Fall back to exact arithmetic (requires `exact` feature)
246+
let sign = m.det_sign_exact()?;
247+
}
248+
}
231249
} else {
232-
// Fall back to exact arithmetic (requires `exact` feature)
233-
let sign = m.det_sign_exact().unwrap();
250+
// D ≥ 5: no fast filter, use exact directly (requires `exact` feature)
251+
let sign = m.det_sign_exact()?;
234252
}
235-
} else {
236-
// D ≥ 5: no fast filter, use exact directly (requires `exact` feature)
237-
let sign = m.det_sign_exact().unwrap();
253+
254+
Ok(())
238255
}
239256
```
240257

benches/exact.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
//! empirical evidence for `docs/PERFORMANCE.md`.
1818
1919
use criterion::{BenchmarkGroup, Criterion, measurement::WallTime};
20-
use la_stack::{Matrix, Vector};
20+
use la_stack::{DEFAULT_PIVOT_TOL, Matrix, Vector};
2121
use pastey::paste;
2222
use std::hint::black_box;
2323

@@ -179,7 +179,7 @@ macro_rules! gen_exact_benches_for_dim {
179179
[<group_d $d>].bench_function("det", |bencher| {
180180
bencher.iter(|| {
181181
let det = black_box(a)
182-
.det(la_stack::DEFAULT_PIVOT_TOL)
182+
.det(DEFAULT_PIVOT_TOL)
183183
.expect("diagonally dominant matrix is non-singular");
184184
black_box(det);
185185
});

benches/vs_linalg.rs

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
//! - Matrix infinity norm is the maximum absolute row sum on all sides.
99
1010
use criterion::Criterion;
11-
use faer::linalg::solvers::Solve;
11+
use faer::linalg::solvers::{PartialPivLu, Solve};
1212
use faer::perm::PermRef;
13+
use la_stack::{DEFAULT_PIVOT_TOL, Matrix, Vector};
1314
use pastey::paste;
1415
use std::hint::black_box;
1516

@@ -43,7 +44,7 @@ fn faer_perm_sign(p: PermRef<'_, usize>) -> f64 {
4344
}
4445
}
4546

46-
fn faer_det_from_partial_piv_lu(lu: &faer::linalg::solvers::PartialPivLu<f64>) -> f64 {
47+
fn faer_det_from_partial_piv_lu(lu: &PartialPivLu<f64>) -> f64 {
4748
// For PA = LU with unit-lower L, det(A) = det(P) * det(U).
4849
let u = lu.U();
4950
let mut det = 1.0;
@@ -128,10 +129,10 @@ macro_rules! gen_vs_linalg_benches_for_dim {
128129
paste! {{
129130
// Isolate each dimension's inputs to keep types and captures clean.
130131
{
131-
let a = la_stack::Matrix::<$d>::from_rows(make_matrix_rows::<$d>());
132-
let rhs = la_stack::Vector::<$d>::new(make_vector_array::<$d>(0.0));
133-
let v1 = la_stack::Vector::<$d>::new(make_vector_array::<$d>(0.0));
134-
let v2 = la_stack::Vector::<$d>::new(make_vector_array::<$d>(1.0));
132+
let a = Matrix::<$d>::from_rows(make_matrix_rows::<$d>());
133+
let rhs = Vector::<$d>::new(make_vector_array::<$d>(0.0));
134+
let v1 = Vector::<$d>::new(make_vector_array::<$d>(0.0));
135+
let v2 = Vector::<$d>::new(make_vector_array::<$d>(1.0));
135136

136137
let na = nalgebra::SMatrix::<f64, $d, $d>::from_fn(|r, c| matrix_entry::<$d>(r, c));
137138
let nrhs = nalgebra::SVector::<f64, $d>::from_fn(|i, _| vector_entry(i, 0.0));
@@ -145,7 +146,7 @@ macro_rules! gen_vs_linalg_benches_for_dim {
145146

146147
// Precompute LU once for solve-only / det-only benchmarks.
147148
let a_lu = a
148-
.lu(la_stack::DEFAULT_PIVOT_TOL)
149+
.lu(DEFAULT_PIVOT_TOL)
149150
.expect("matrix should be non-singular");
150151
let na_lu = na.clone().lu();
151152
let fa_lu = fa.partial_piv_lu();
@@ -156,9 +157,12 @@ macro_rules! gen_vs_linalg_benches_for_dim {
156157
[<group_d $d>].bench_function("la_stack_det_via_lu", |bencher| {
157158
bencher.iter(|| {
158159
let lu = black_box(a)
159-
.lu(la_stack::DEFAULT_PIVOT_TOL)
160+
.lu(DEFAULT_PIVOT_TOL)
160161
.expect("matrix should be non-singular");
161-
let det = lu.det();
162+
let det = match lu.det() {
163+
Ok(det) => det,
164+
Err(err) => panic!("finite benchmark matrix determinant failed: {err}"),
165+
};
162166
black_box(det);
163167
});
164168
});
@@ -183,7 +187,7 @@ macro_rules! gen_vs_linalg_benches_for_dim {
183187
[<group_d $d>].bench_function("la_stack_det", |bencher| {
184188
bencher.iter(|| {
185189
let det = black_box(a)
186-
.det(la_stack::DEFAULT_PIVOT_TOL)
190+
.det(DEFAULT_PIVOT_TOL)
187191
.expect("matrix should be non-singular");
188192
black_box(det);
189193
});
@@ -193,7 +197,7 @@ macro_rules! gen_vs_linalg_benches_for_dim {
193197
[<group_d $d>].bench_function("la_stack_lu", |bencher| {
194198
bencher.iter(|| {
195199
let lu = black_box(a)
196-
.lu(la_stack::DEFAULT_PIVOT_TOL)
200+
.lu(DEFAULT_PIVOT_TOL)
197201
.expect("matrix should be non-singular");
198202
let _ = black_box(lu);
199203
});
@@ -217,7 +221,7 @@ macro_rules! gen_vs_linalg_benches_for_dim {
217221
[<group_d $d>].bench_function("la_stack_lu_solve", |bencher| {
218222
bencher.iter(|| {
219223
let lu = black_box(a)
220-
.lu(la_stack::DEFAULT_PIVOT_TOL)
224+
.lu(DEFAULT_PIVOT_TOL)
221225
.expect("matrix should be non-singular");
222226
let x = lu
223227
.solve_vec(black_box(rhs))
@@ -273,7 +277,10 @@ macro_rules! gen_vs_linalg_benches_for_dim {
273277
// === Determinant from a precomputed LU ===
274278
[<group_d $d>].bench_function("la_stack_det_from_lu", |bencher| {
275279
bencher.iter(|| {
276-
let det = a_lu.det();
280+
let det = match a_lu.det() {
281+
Ok(det) => det,
282+
Err(err) => panic!("finite benchmark matrix determinant failed: {err}"),
283+
};
277284
black_box(det);
278285
});
279286
});
@@ -295,7 +302,7 @@ macro_rules! gen_vs_linalg_benches_for_dim {
295302
// === Vector dot product ===
296303
[<group_d $d>].bench_function("la_stack_dot", |bencher| {
297304
bencher.iter(|| {
298-
let result = black_box(v1).dot(black_box(v2));
305+
let result = black_box(v1).dot(black_box(v2)).unwrap();
299306
black_box(result);
300307
});
301308
});
@@ -322,7 +329,7 @@ macro_rules! gen_vs_linalg_benches_for_dim {
322329
// === Vector norm squared ===
323330
[<group_d $d>].bench_function("la_stack_norm2_sq", |bencher| {
324331
bencher.iter(|| {
325-
let result = black_box(v1).norm2_sq();
332+
let result = black_box(v1).norm2_sq().unwrap();
326333
black_box(result);
327334
});
328335
});
@@ -349,7 +356,7 @@ macro_rules! gen_vs_linalg_benches_for_dim {
349356
// === Matrix infinity norm (max absolute row sum) ===
350357
[<group_d $d>].bench_function("la_stack_inf_norm", |bencher| {
351358
bencher.iter(|| {
352-
let result = black_box(a).inf_norm();
359+
let result = black_box(a).inf_norm().unwrap();
353360
black_box(result);
354361
});
355362
});

0 commit comments

Comments
 (0)