RustedSciThe 0.4.19

Rust framework for symbolic and numerical computing:BVP ( Newton-Raphson frozen/damped/with collocations ), IVP( BDF, Radau, Backward Euler, LSODE, LSODA, RK45, DoPri), nonlinear equations ( Levenberg, Gavin) and more
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# LSODE2 in RustedSciThe: user guide

This guide is written for a Rust developer who opens LSODE2 for the first time and wants to move quickly from “what is this solver?” to a practical, reproducible configuration. We will not only list options, but explain why those options exist, what trade-offs they encode, and when one route is better than another.

It helps to set the scope up front. LSODE2 in RustedSciThe combines two goals: it mirrors the ODEPACK family philosophy (especially LSODA-like Adams/BDF auto-switching), and it integrates with the modern symbolic/codegen/linear-algebra pipeline of the crate. So LSODE2 has an algorithmic side and an infrastructure side. If you mix them mentally, configuration feels chaotic; if you separate them, the system becomes straightforward.

## Where to start: two decision axes

Most LSODE2 setups can be described along two axes. The first axis is step mathematics: fixed BDF, fixed Adams, or LSODA-like automatic family switching. The second axis is backend execution: how residual/Jacobian are evaluated and how Newton linear systems are solved.

That second axis leads to the three familiar routes: pure numerical (`Numerical`), symbolic lambdify (`Lambdify`), and symbolic ahead-of-time generation (`AOT`). The key idea is that these are not three unrelated solvers; they are three ways to feed the same integration algorithm with high-quality function and matrix evaluators.

## What `Lsode2ProblemConfig::new` takes, and why it is the core syntax

`Lsode2ProblemConfig::new(...)` is the central entry point. Its signature is:

```rust
pub fn new(
    eq_system: Vec<Expr>,
    values: Vec<String>,
    arg: String,
    t0: f64,
    y0: DVector<f64>,
    t_bound: f64,
    max_step: f64,
    rtol: f64,
    atol: f64,
) -> Self
```

You can read it as: “here is my ODE system, my state-variable names, my independent variable, my initial state/time, my integration horizon, and my baseline step/accuracy policy.”

`eq_system` is the RHS in symbolic form (`Expr`).  
`values` are state-variable names in the same order as `y0`.  
`arg` is typically `"t"`.  
`t0`, `y0`, `t_bound` are the usual initial-value triple.  
`max_step`, `rtol`, `atol` control step ceiling and error tolerances.

Even if you later switch to fully analytical callbacks, this constructor remains the backbone: it defines the global geometry and tolerance policy of the solve.

## Dense, Sparse, Banded: not style, but cost model

Jacobian structure is a computational economics choice.

`Dense` is natural for small systems, debugging, and cases where simplicity matters most.  
`Sparse` is the practical default for large unstructured systems: less memory, cheaper factorization.  
`Banded` is ideal when nonzeros are concentrated near the diagonal, common in discretized transport/diffusion and many BVP/PDE-like systems.

If the structure is wrong, the solver may still run, but performance usually degrades and stability can suffer. In practice, structure is the first thing to get right, before any tolerance micro-tuning.

## LSODE vs LSODA: historical context and LSODE2 behavior

In classic Fortran ODEPACK, LSODE and LSODA are not the same thing. LSODE assumes method-family choice is user-fixed (Adams or BDF). LSODA adds automatic stiffness sensing and method switching.

LSODE2 follows that philosophy explicitly. A manual controller (`bdf_only` or `adams_only`) is LSODE-style behavior. `automatic_adams_bdf` is LSODA-style behavior under the same modern API.

## Core configuration types and their roles

### `Lsode2BackendConfig`

This is the low-level backend container. It groups Jacobian backend (`jacobian_backend`), linear backend (`linear_solver_backend`), and generated symbolic/AOT backend settings (`generated_backend`).

Most users should not build it from scratch every time. Presets like `native_sparse_faer()`, `native_banded_faithful()`, or `dense_aot_c_gcc(...)` are usually the right starting point, followed by minimal targeted overrides.

### `Lsode2JacobianBackend`

`SymbolicGenerated` means Jacobian comes from the symbolic pipeline (Lambdify or AOT).  
`AnalyticClosure` means user-provided analytical callbacks.  
`FiniteDifference` means built-in finite-difference Jacobian.

Important current constraint: `AnalyticClosure` and `FiniteDifference` are valid on native solve paths; bridge mode is not designed for those routes.

For the pure numerical route, prefer the explicit constructors:
`Lsode2ProblemConfig::new_numeric_fd_with_options(...)` when you provide only
the RHS/residual closure and want LSODE2 to approximate the Jacobian by finite
differences, or `Lsode2ProblemConfig::new_numeric_with_jacobian_options(...)`
when you also provide the analytical Jacobian closure. These constructors do
not ask user code for symbolic `Expr` equations; the shared internal
`Lsode2ProblemConfig` still stores placeholders, but that is now an
implementation detail rather than user-facing syntax.

### `Lsode2LinearSolverBackend`

`Dense` uses dense LU,  
`SparseFaer` uses sparse LU via `faer`,  
`BandedFaithful` uses faithful LAPACK-style banded LU.

At API level, it is often better to specify structure plus policy (`Auto`/`Force`) rather than pinning this backend directly.

## Linear-solver policy: Auto vs Force

`Lsode2LinearSolverPolicy::Auto` is deterministic, not heuristic guessing. It maps from `Lsode2LinearSystemStructure`: dense to `DenseLu`, sparse to `FaerSparseLu`, banded to `LapackFaithfulBandedLu`.

`Force(...)` is useful for targeted experiments, parity diagnostics, and backend-specific performance investigations.

In production, “set structure first, use Auto second” is usually the most robust and reproducible pattern.

## Backend route syntax: `with_backend` vs `with_linear_solver_policy`

LSODE2 examples may show two similar-looking styles:

```rust
.with_backend(
    Lsode2BackendConfig::native_banded_faithful()
        .with_generated_backend_target_chunks(4, 4),
)
```

and:

```rust
.with_linear_solver_policy(Lsode2LinearSolverPolicy::Auto)
```

They are not interchangeable. `with_backend(...)` is the high-level route
selector. It describes the complete LSODE2 backend preset: dense/sparse/banded
matrix structure, concrete linear algebra backend, symbolic/generated backend
lifecycle, AOT toolchain, and generated callback chunking knobs. This is the
recommended syntax for user-facing examples and production code because the
whole execution route is visible in one place.

```rust
let config = base.with_backend(
    Lsode2BackendConfig::native_sparse_faer()
        .with_generated_backend_target_chunks(4, 4),
);
```

`with_linear_solver_policy(...)` is lower-level. It only controls how the linear
solver is selected after the linear system structure is already known. `Auto`
maps dense systems to dense LU, sparse systems to faer sparse LU, and banded
systems to faithful LAPACK-style banded LU. `Force(...)` is useful in parity
tests, diagnostics, and legacy compatibility examples where you explicitly want
to prove that a particular linear solver was selected.

Practical rule: use `with_backend(...)` when you are choosing a real LSODE2
route; use `with_linear_solver_policy(...)` when a test or diagnostic is about
the linear-policy resolver itself.

## Symbolic assembly backends: `ExprLegacy` and `AtomView`

`Lsode2SymbolicAssemblyBackend` has two options: `ExprLegacy` and `AtomView`.

`ExprLegacy` is the conservative baseline.  
`AtomView` is a newer packed symbolic representation (an internal IR choice) that can improve preparation and/or runtime in some workloads.

IR should be stated plainly. IR (intermediate representation) is the internal format between mathematical expressions and executable code. In LSODE2, IR separation lets you change execution strategy without rewriting the model equations.

## Lambdify and AOT in practical terms

In LSODE2, Lambdify builds executable Rust closures from symbolic equations during solver preparation. It uses no external compiler, starts fast, and is easy to keep portable.

AOT (ahead-of-time) makes a different trade. You pay an upfront cost for code generation, compilation, and linking, then reduce per-call runtime cost for residual/Jacobian. That is why AOT shines for repeated solves of the same model or long heavy integrations.

In RustedSciThe, AOT supports several toolchains: C with `gcc`, C with `tcc`, `zig`, and Rust toolchain. The corresponding compilers must be available in your environment.

`Debug` vs `Release` profile keeps the standard meaning. Debug usually compiles faster but runs slower; Release compiles longer but lowers runtime cost inside the integration loop.

The practical AOT lifecycle has two common production modes. `BuildIfMissing`
or `BuildIfMissingRelease` builds an artifact when it is absent and returns a
resolver that can be reused. `RequirePrebuilt` is strict: it fails unless the
artifact is already known and built. This is the right route for production
runs where startup must not silently compile code.

In strict `RequirePrebuilt` mode, error messages are intentionally verbose.
They should report the route (`dense`, `sparse`, or `residual-only`), the
`problem_key`, build policy, generated backend, C compiler when relevant, and
the output parent directory. Treat `problem_key` as the artifact identity for
that symbolic problem plus frontend/matrix/toolchain/chunking options. If a
strict prebuilt run fails, run the same LSODE2 configuration once with
`BuildIfMissingRelease` or `RebuildAlways`, then reuse the returned resolver or
the same artifact directory for the strict run.

`RebuildAlways` is intentionally lock-safe on Windows-oriented workflows: it
materializes each rebuild into an isolated subdirectory under the configured
output parent instead of overwriting a DLL/cdylib that may already be loaded by
the current process. This avoids file-lock failures without pretending that a
loaded dynamic library can be safely deleted. If you use `RebuildAlways` in long
diagnostic sessions, old isolated rebuild directories can be cleaned later when
no process is using them.

## Parameter continuation: rebind values, reuse prepared callbacks

For a parameterized symbolic model, declare parameter names in expression
order and provide the corresponding initial values:

```rust
let config = Lsode2ProblemConfig::new(
    vec![Expr::parse_expression("-k*y")],
    vec!["y".to_string()],
    "t".to_string(),
    0.0,
    DVector::from_vec(vec![1.0]),
    1.0,
    0.05,
    1e-8,
    1e-10,
)
.with_equation_parameters(vec!["k".to_string()])
.with_equation_parameter_values(DVector::from_vec(vec![1.0]));
```

Construct one `Lsode2Solver`, then update only the numeric vector between
solves:

```rust
let mut solver = Lsode2Solver::new(config)?;
for k in [1.0, 2.0, 4.0] {
    solver.set_parameter_values(DVector::from_vec(vec![k]))?;
    solver.solve()?;
    let (_, states) = solver.get_result();
    println!("k={k}: y(1)={:.6e}", states[(states.nrows() - 1, 0)]);
}
```

This reuses the prepared symbolic callbacks; the AOT route also reuses its
prepared artifact instead of repeating symbolic preparation or compilation
for each value. Each `solve()` is nevertheless a new integration initialized
from the configured `t0` and `y0`: parameter continuation does not carry the
previous trajectory or integrator history forward. Parameter count and order
must match the declared schema. See the runnable examples
`lsode2_parameter_continuation_lambdify` and
`lsode2_parameter_continuation_aot`.

## AOT parallelism/chunking and performance

Generated backend settings include `aot_options`, where residual/Jacobian chunking strategies are defined. This influences function size, compile behavior, and runtime dispatch plan. Residual strategies include `Whole`, `ByTargetChunkCount`, `ByOutputCount`; dense Jacobian strategies include `Whole`, `ByTargetChunkCount`, `ByRowCount`.

At LSODE2 level, this is configured through `SymbolicIvpGeneratedBackendConfig`:

```rust
use RustedSciThe::numerical::LSODE2::{Lsode2BackendConfig, Lsode2ProblemConfig};
use RustedSciThe::symbolic::symbolic_ivp::SymbolicIvpAotOptions;
use RustedSciThe::symbolic::symbolic_ivp_generated::SymbolicIvpGeneratedBackendConfig;
use RustedSciThe::symbolic::codegen::codegen_runtime_api::{
    DenseJacobianChunkingStrategy, ResidualChunkingStrategy,
};

let aot_options = SymbolicIvpAotOptions {
    residual_strategy: ResidualChunkingStrategy::ByTargetChunkCount { target_chunks: 8 },
    jacobian_strategy: DenseJacobianChunkingStrategy::ByRowCount { rows_per_chunk: 32 },
};

let generated = SymbolicIvpGeneratedBackendConfig::build_if_missing_release("target/lsode2-aot")
    .with_c_gcc()
    .with_aot_options(aot_options);

let cfg = Lsode2ProblemConfig::new(/* ... */)
    .with_backend(
        Lsode2BackendConfig::native_sparse_faer_with_generated_backend(generated)
    );
```

This is usually advanced tuning. A good workflow is to establish a correct baseline first, then optimize chunking.

## Why there are three `with_stop_condition_*` variants

Early-stop control is valuable not only for convenience but for physical realism in models with a natural completion state.

`with_stop_condition(...)` is shorthand for `variable >= target`.  
`with_stop_condition_ge(...)` is explicit `>=`.  
`with_stop_condition_le(...)` is explicit `<=`.  
`with_stop_condition_abs(...)` stops when `|variable - target| <= tolerance`.

In combustion-like IVPs this is especially useful: you can stop at conversion 0.999 instead of integrating deep into a formally allowed but physically irrelevant tail.

## What a task document is, and how LSODE2 runs through the parser

RustedSciThe includes a command interpreter that parses a human-readable task document into a typed `IvpTaskSpec`, then builds a `UniversalODESolver`.

That is the task-document path: you describe the model in sections (`task`, `equations`, `initial_conditions`, `solver_options`, `postprocessing`), and the parser normalizes and validates fields. LSODE2-specific keys include `lsode2_symbolic_assembly`, `lsode2_symbolic_execution`, `lsode2_aot_toolchain`, `lsode2_aot_profile`, `lsode2_linear_structure`, `lsode2_linear_solver_policy`, `lsode2_native_execution`, and related options.

For outputs, parser-side postprocessing is intentionally conservative today: CSV export is built-in; `plot` is parsed and preserved for higher-level wrappers/frontends, but plotting is not auto-triggered in parser core.

## Complete Rust example (Lambdify, sparse, faithful BDF)

Below is a full minimal scenario from equation definition to status/statistics output.

```rust
use nalgebra::DVector;
use RustedSciThe::numerical::LSODE2::{
    Lsode2LinearSolverPolicy, Lsode2LinearSystemStructure, Lsode2ProblemConfig,
    Lsode2ResidualJacobianSource, Lsode2SymbolicAssemblyBackend, Lsode2SymbolicExecutionMode,
};
use RustedSciThe::numerical::ODE_api2::UniversalODESolver;
use RustedSciThe::symbolic::symbolic_engine::Expr;

fn main() {
    let config = Lsode2ProblemConfig::new(
        vec![
            Expr::parse_expression("-10.0*y1 + 9.0*y2"),
            Expr::parse_expression("y1 - y2"),
        ],
        vec!["y1".to_string(), "y2".to_string()],
        "t".to_string(),
        0.0,
        DVector::from_vec(vec![1.0, 0.0]),
        1.0,
        0.02,
        1e-6,
        1e-8,
    )
    .with_residual_jacobian_source(Lsode2ResidualJacobianSource::Symbolic {
        assembly: Lsode2SymbolicAssemblyBackend::ExprLegacy,
        execution: Lsode2SymbolicExecutionMode::LambdifyExpr,
    })
    .with_linear_system_structure(Lsode2LinearSystemStructure::Sparse)
    .with_linear_solver_policy(Lsode2LinearSolverPolicy::Auto)
    .with_faithful_bdf_solve(100_000, 100_000);

    let mut solver = UniversalODESolver::lsode2_with_problem_config(config);
    solver.solve();

    let status = solver.get_status().unwrap_or_else(|| "unknown".to_string());
    let (t, y) = solver.get_result();
    let final_t = t.as_ref().map(|tv| tv[tv.len() - 1]).unwrap_or(f64::NAN);
    let final_y1 = y.as_ref().map(|m| m[(m.nrows() - 1, 0)]).unwrap_or(f64::NAN);
    let final_y2 = y.as_ref().map(|m| m[(m.nrows() - 1, 1)]).unwrap_or(f64::NAN);

    println!("status  = {status}");
    println!("final_t = {final_t:.6}");
    println!("final_y = [{final_y1:.8e}, {final_y2:.8e}]");

    if let Some(stats) = solver.get_statistics() {
        println!("{}", stats.table_report());
    }
}
```

## Complete task document example

This format is useful when the model is provided from CLI, scripts, or external orchestration.

```text
task
solver: IVP
method: LSODE2

equations
arg: t
y1: -10.0*y1 + 9.0*y2
y2: y1 - y2

initial_conditions
t0: 0.0
t_end: 1.0
y0: 1.0, 0.0

solver_options
first_step: Some(1e-3)
rtol: 1e-6
atol: 1e-8
max_step: 0.05
lsode2_symbolic_assembly: ExprLegacy
lsode2_symbolic_execution: AOT
lsode2_aot_toolchain: c_gcc
lsode2_aot_profile: release
lsode2_linear_structure: sparse
lsode2_linear_solver_policy: auto
lsode2_native_execution: faithful_bdf_solve

postprocessing
save_csv: true
csv_path: lsode2_result.csv
plot: false
```

## Practical strategy for choosing a configuration

For small systems and early debugging, start with Dense + Lambdify. For large sparse systems, start with Sparse + Lambdify, then move to AOT and compare stage metrics (`prepare` vs `solve`) in multi-run story tests. For truly banded systems, use Banded + faithful backend with carefully validated `kl/ku`.

The sequence is deliberate: first lock mathematical correctness on the most transparent route, then transfer the same math to a more aggressive backend and check equivalence, then optimize compile/runtime balance.

## Parallelism knobs for AOT and Lambdify routes

LSODE2 exposes backend-level chunking controls at config level, so you can tune runtime work splitting without manually assembling low-level generated-backend objects.

For AOT, the highest-level entry point is:

```rust
let config = Lsode2ProblemConfig::new(/* ... */)
    // choose symbolic AOT route
    .with_residual_jacobian_source(Lsode2ResidualJacobianSource::Symbolic {
        assembly: Lsode2SymbolicAssemblyBackend::AtomView,
        execution: Lsode2SymbolicExecutionMode::Aot {
            toolchain: Lsode2AotToolchain::CTcc,
            profile: Lsode2AotProfile::Release,
        },
    })
    // choose matrix structure and solver policy
    .with_linear_system_structure(Lsode2LinearSystemStructure::Sparse)
    .with_linear_solver_policy(Lsode2LinearSolverPolicy::Auto)
    // split generated residual/Jacobian runtime work by available parallelism
    .with_aot_parallel_chunking(2);
```

If you need deterministic explicit chunk counts, use:

```rust
let config = config
    .with_aot_target_chunks(8, 8);
```

If you need sparse-Jacobian-specific control, use:

```rust
use RustedSciThe::symbolic::codegen::codegen_tasks::SparseChunkingStrategy;
let config = config
    .with_aot_sparse_chunking_strategy(SparseChunkingStrategy::ByTargetChunkCount {
        target_chunks: 8,
    });
```

On Lambdify route, generated runtime chunking can also be tuned through backend config (for heavy symbolic kernels this can matter on multi-core machines):

```rust
let config = Lsode2ProblemConfig::new(/* ... */)
    .with_backend(
        Lsode2BackendConfig::native_sparse_faer()
            .with_generated_backend_target_chunks(4, 4),
    )
    .with_residual_jacobian_source(Lsode2ResidualJacobianSource::Symbolic {
        assembly: Lsode2SymbolicAssemblyBackend::ExprLegacy,
        execution: Lsode2SymbolicExecutionMode::LambdifyExpr,
    });
```

The same logic applies to Dense and Banded. Structure selection changes linear algebra backend, while symbolic execution and chunking control callback-evaluation behavior.

## Where to continue in this repository

Hands-on examples are in `examples/lsode2_numerical_guide.rs`, `examples/lsode2_lambdify_guide.rs`, `examples/lsode2_aot_guide.rs`, `examples/lsode2_parameter_continuation_lambdify.rs`, `examples/lsode2_parameter_continuation_aot.rs`, `examples/lsode2_manual_bdf_guide.rs`, `examples/lsode2_manual_adams_guide.rs`, and `examples/lsode2_task_shell_guide.rs`.

Scenario-level correctness and performance tests are organized by topic under
`src/numerical/LSODE2/tests/` (for example, `correctness_story_tests.rs`,
`parameter_continuation_story_tests.rs`, and the `aot_*_story_tests.rs`
modules). For ODEPACK-style numerical parity, see `parity_micro.rs`,
`stiff_parity_tests.rs`, and `nonstiff_parity_tests.rs` in the LSODE2 module.

When these layers stay separated (guide for usage, story tests for end-to-end behavior, parity tests for mathematical equivalence), LSODE2 stops feeling complicated and starts behaving like a predictable engineering tool.