# Решаем задачу с начальным условием с RustedSciThe
Продолжая разговор о FFI, стоит отметить: существует и противоположный путь. Один из пунктов «RustedSciThe Zen» — коллекции полушуточных афоризмов, суммирующих опыт создания и эксплуатации одноимённой численной библиотеки, — гласит: “FFI is a sin. Try to keep it Rusty.” Другие строки из того же набора звучат так: “Borrow from Fortran. Return with Jacobians and AOT.” и “Ancient libraries deserve reincarnation. A good numerical method outlives its language.” В афористической манере здесь высказывается вполне очевидная инженерная идея. Вместо построения всё более сложных слоёв поверх C API иногда оказывается разумнее взять проверенный временем алгоритм и переписать его непосредственно на Rust, сохранив математическую логику оригинала, но встроив в современную экосистему типов, тестов и бэкэндов.
Именно по этой философии развивается LSODE2 в RustedSciThe. В основе лежит подход “отзеркаливания”: максимально буквальное воспроизведение структуры оригинальных алгоритмов семейства ODEPACK/LSODE с последующей адаптацией под современный стек Rust. Речь не о «вдохновении идеями» старого Fortran-кода, а о сознательном стремлении сохранить алгоритмическое поведение, порядок вычислений и даже отдельные ветви логики там, где это важно для численной эквивалентности. Такой подход позволяет проводить паритетные проверки между реализациями и постепенно усовершенствовать инфраструктурный слой, не меняя математическое ядро. В LSODE2 этому посвящены отдельные тесты воспроизводимости относительно оригинальной реализации (parity tests) и чек-листы проверки.
Первое, с чем сталкивается пользователь LSODE2, — необходимость сделать три выбора. Первый касается самой схемы интегрирования: использовать семейство Adams, использовать BDF (также известный как метод Гира) или разрешить автоматическое переключение между ними в духе классической LSODA. В таком режиме солвер сам оценивает поведение задачи и меняет семейство метода, если это выгодно. Для пользователя это самый «ленивый» и часто самый удобный путь, но в серьёзных расчётах он не отменяет необходимости понимать структуру системы. Автоматический выбор семейства не заменяет выбор структуры Якобиана, бэкэнда и политики линейного солвера.
Второй выбор связан с тем, как будет храниться якобиан и каким образом решается возникающая на каждом шаге Ньютона система линейных уравнений. Иначе говоря, пользователь фактически определяет вычислительную геометрию задачи. Для небольших систем естественным оказывается плотное представление— в этом случае LSODE2 использует бэкенд для плотной линейной (Dense) алгебры, опирающийся на возможности nalgebra. Если якобиан разрежён, разумнее выбрать разреженное (Sparse) представление: тогда в работу вступают sparse-решатели на основе крейта faer, которому были посвящены почти апологетические пассажи в предыдущих главах. Наконец, для задач с локальными взаимодействиями — например, после дискретизации PDE, где переменная зависит главным образом от соседних узлов сетки, — якобиан часто имеет ленточную структуру (Banded). Для такого случая в RustedSciThe реализован специализированный бэкенд LU разложения ленточных матриц и решения СЛАУ под названием LapackFaithfulLU (“Lapack” потому что алгоритм был скопирован из знаменитой одноименной библиотеки на Fortran, а “faithful” потому что был скопирован со всем тщанием).
Ошибиться здесь — значит не обязательно получить неверное решение, но почти гарантированно заплатить производительностью
Третий выбор связан с тем, как вычисляются функции невязок и якобиан и каким образом решается линейная система Ньютона. Именно здесь, пользователь подобно сказочному богатырю оказывается у развилки трех дорог.
а) Numerical backend — наиболее прямолинейный путь. Пользователь предоставляет функции невязок в виде коллбэков или замыканий Rust, а якобиан при необходимости вычисляется численно, обычно конечно-разностной аппроксимацией. Такой подход прост, универсален и требует минимальной подготовки модели, но может оказаться дорогим для больших систем: численное дифференцирование увеличивает число вычислений и иногда ухудшает устойчивость.
б) Lambdify backend — символический путь исполнения. Напомним, что под lambdify (“лямбдификация”) мы понимаем преобразование символьного выражения в исполняемое Rust-замыкание без предварительной компиляции отдельного артефакта (то есть объектного модуля). Пользователь задаёт систему как набор выражений (символьный тип RustedSciThe называется Expr от expression), после чего библиотека строит функции невязок и якобиан автоматически. По духу это напоминает переход от формулы на бумаге к обычной функции:
y' = -k y
↓
|t, y| -> -k * y
Lambdify обычно даёт хороший компромисс между удобством и производительностью и особенно удобен на этапе разработки модели.
в) Ahead-of-Time generation (AOT) — наиболее тяжёлый, но потенциально наиболее быстрый маршрут. Ahead-of-Time пайплайн, коротко говоря, превращает символьный вектор функций невязок и якобиан в исполняемый модуль. Более подробно: здесь символьные выражения сначала превращаются во внутреннее промежуточное представление (IR, Intermediate Representation), затем генерируется код для функции невязок и якобиана. При этом общие подвыражения, встречающиеся в нескольких выражениях, выделяются в отдельные переменные (common subexpression elimination), что уменьшает число повторных вычислений и способно дать заметный выигрыш в производительности. После этого код компилируется в отдельный динамически подключаемый артефакт (как правило, библиотеку .dll/.so), который солвер затем использует как готовый вычислительный бэкенд (RustedSciThe как заправский замок чародея из фэнтези имеет собственную фабрику артефактов). Иначе говоря, часть вычислительной работы переносится из рантайма непосредственно в этап подготовки модели.
Схематично этот процесс выглядит так:
Expr (символьный тип RustedSciThe)
↓
IR (внутреннее представление)
↓
генерация кода
↓
компиляция
↓
динамическое подключение
↓
исполнение внутри солвер
Такой подход увеличивает стоимость первого запуска, но способен существенно сократить время длительных расчётов или серий параметрических исследований.
AOT-фабрика RustedSciThe поддерживает несколько языков и инструментальных цепочек компиляции, причем выбор тулчейна напрямую влияет на компромисс между временем подготовки артефакта и скоростью его последующего исполнения.
Rust.
Наш любимый язык, к сожалению, не славится скоростью компиляции. Генерация и сборка артефактов через Rust тулчейн обычно занимает заметно больше времени по сравнению с более лёгкими вариантами. Для небольших задач стоимость компиляции может оказаться выше потенциального выигрыша от AOT, поэтому такой путь чаще интересен как средство экспериментов, унификации инфраструктуры или глубокого контроля над сгенерированными модулями.
Zig.
Молодой системный язык, ещё не достигший версии 1.0 на момент написания книги. Zig заслужил репутацию очень производительного инструмента, однако скорость компиляции сгенерированных модулей не всегда оказывается достаточно высокой, чтобы оправдать использование на коротких расчётах. Для тяжёлых повторяющихся задач ситуация может меняться, но в качестве универсального варианта Zig обычно не рассматривается.
C + gcc.
Классический маршрут через язык C и компилятор gcc. Один из наиболее надёжных и предсказуемых вариантов. Во многих случаях генерация артефактов происходит существенно быстрее тяжёлых тулчейнов общего назначения, при этом итоговый код остаётся достаточно эффективным.
C + tcc (Tiny C Compiler).
Практический фаворит AOT-маршрута и передовик производства артефактов AOT-фабрики. **[исправлено: в API LSODE2 технический `Default` для `Lsode2AotToolchain` сейчас остаётся `CGcc`; `CTcc` — не enum-default, а практическая рекомендация для быстрых cold-build/warm-prebuilt сценариев, если `tcc` установлен и доступен через `PATH`.]** tcc ориентирован прежде всего на чрезвычайно быструю компиляцию, благодаря чему стоимость подготовки артефактов оказывается минимальной. На тяжёлых задачах этот подход способен конкурировать не только с другими AOT-тулчейнами, но иногда и с Lambdify-бэкендом. Особенно заметно преимущество там, где сгенерированный модуль используется многократно.
В порядке исторического отступления стоит упомянуть, что tcc (Tiny C Compiler) был создан Фабрисом Белларом (Fabrice Bellard) — разработчиком, также стоявшим у истоков проектов QEMU и FFmpeg. Сегодня tcc развивается сообществом open-source.
Разумеется, использование AOT предполагает, что соответствующий компилятор установлен в системе и доступен через PATH.
Кроме выбора тулчейна пользователь может управлять профилем компиляции сгенерированных модулей. Обычно доступны режимы:
Debug → быстрая компиляция, меньшая оптимизация
Release → более долгая компиляция, агрессивная оптимизация
Интуитивно хочется всегда выбирать Release, однако на практике это не всегда оправдано. Если задача решается один раз или модель часто изменяется, дополнительное время компиляции может не окупиться. **[исправлено: `Debug` здесь следует понимать как удобный экспериментальный выбор, а не как дефолт API; `Lsode2AotProfile::default()` сейчас равен `Release`.]** Напротив, при длительных расчётах или сериях параметрических исследований более дорогая релизная компиляция способна компенсировать себя за счёт ускорения рантайма.
Иначе говоря, выбор AOT-тулчейна напоминает выбор метода интегрирования: универсального победителя нет. Быстрая подготовка артефактов, высокая скорость исполнения и удобство разработки образуют треугольник компромиссов, и улучшение одной вершины почти всегда требует уступок в другой.
Кроме того, AOT поддерживает настройку политики распараллеливания и разбиения вычислений (chunking). Пользователь может управлять количеством целевых чанков для функции невязок и якобиана, а также стратегией их распределения между потоками исполнения. Идея проста: вместо генерации одного большого вычислительного ядра выражения разбиваются на несколько относительно независимых фрагментов, которые затем могут исполняться параллельно.
Для небольших систем это почти незаметно. Однако для крупных систем уравнений — например, задач химической кинетики с сотнями компонентов, больших разрежённых якобианов или сеточных моделей с ленточной структурой матрицы — подобные настройки начинают влиять на общую стоимость вычислений не меньше, чем выбор самого метода интегрирования. В таких случаях вопрос «как эффективно организовать вычислительную работу между потоками?» перестает быть праздным.
Важно понимать: перечисленные варианты — не разные солверы, а разные способы снабдить один и тот же алгоритм качественными функциями невязок и производными. Численный метод остаётся тем же; меняется лишь то, насколько эффективно и каким способом он получает информацию о модели.
Центральной точкой входа выступает конфигурация задачи, задаваемая структурой Lsode2ProblemConfig, чей метод new отвечает на вопрос “каков базовый набор математических объектов нужно задать для постановки задачи с начальным условием?”: вот система уравнений, вот имена переменных, вот независимая переменная, начальные условия, горизонт интегрирования и требования к точности. Даже если позднее использовать аналитические коллбэк-функции или AOT-компиляцию, именно этот метод задаёт “игровое поле” задачи: где начинается интегрирование, где заканчивается и насколько аккуратно следует считать. **[исправлено: сигнатура ниже оформлена как Rust-сниппет; это именно API-конструктор конфигурации, а не отдельный solver.]**
```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
```
LSODE2 полезно воспринимать не как один «чёрный ящик», а как конфигурируемый вычислительный маршрут, определяемый методами структуры Lsode2ProblemConfig.
Как было сказано ранее, в классическом стиле LSODE пользователь может явно зафиксировать семейство метода (Adams или BDF) либо, в духе LSODA, предоставить выбор автоматическому определителю жёсткости. Во всех трёх случаях используется метод:
```rust
.with_controller(Lsode2ControllerConfig::...)
```
Для очевидно нежёстких задач можно явно выбрать семейство Adams:
```rust
.with_controller(Lsode2ControllerConfig::adams_only())
```
или воспользоваться менее многословным шорткатом:
```rust
.with_adams_only_controller()
```
Для жёстких или потенциально жёстких систем можно, наоборот, явно закрепить использование BDF:
```rust
.with_controller(Lsode2ControllerConfig::bdf_only())
```
либо использовать, аналогично предыдущему, сокращённую форму:
```rust
.with_bdf_only_controller()
```
Если же априорной информации о жёсткости нет и хочется предоставить выбор самому солверу, используется автоматический контроллер:
```rust
.with_controller(Lsode2ControllerConfig::automatic_adams_bdf())
```
или соответствующий шорткат:
```rust
.with_automatic_adams_bdf_controller()
```
В последнем случае поведение алгоритмически копировать классическую LSODA:
Adams ←→ BDF
с автоматическим переключением между семействами по мере изменения характера задачи.
Итак, предположим теперь, что выбран один из двух последних вариантов — явный BDF или автоматический режим, где BDF потенциально может понадобиться. Возникает следующий вопрос: какой именно BDF-солвер использовать?
В LSODE2 фактически существуют два BDF-маршрута.
Первый — нативный LSODE-подобный путь, максимально близкий к оригинальному Fortran-коду ODEPACK: .with_faithful_bdf_solve(..). Именно поэтому используется слово faithful: алгоритм стремится воспроизводить поведение оригинального решателя настолько буквально, насколько это разумно при переносе на Rust. В LSODE2 этому посвящены отдельные тесты согласованности с оригинальным алгоритмом (parity tests) и проверки воспроизводимости.
Второй путь использует BDF-реализацию, архитектурно более близкую к варианту из SciPy. Такой маршрут полезен как инструмент сравнения, дополнительный бэкенд или способ проверить различия поведения солверов на одной и той же задаче.
По умолчанию предпочтение обычно отдаётся «верному» LSODE-подобному варианту, который включается методом:
```rust
.with_faithful_bdf_solve(
max_step_attempts,
max_accepted_steps
)
```
где аргументы означают всего лишь ограничения на работу интегратора: max_step_attempts — максимально допустимое число попыток шага, включая отклонённые; max_accepted_steps — максимально допустимое число успешно принятых шагов. Эти параметры играют роль предохранителей: если солвер застрял, слишком часто уменьшает шаг или долго не может продвинуться по времени, расчёт завершается вместо бесконечной работы.
Если хочется использовать альтернативный SciPy-подобный маршрут:
```rust
.with_bridge_solve()
```
Иначе говоря:
контроллер
(Adams / BDF / Auto)
↓
выбирает семейство метода
маршрут решения
(faithful LSODE / bridge SciPy ...)
↓
определяет внутреннюю реализацию
Это разные уровни настройки.
Выбор семейства интегрирования — лишь первый слой конфигурации. Следующий связан с тем, как хранится якобиан и каким способом решается возникающая на каждом шаге Ньютона система линейных уравнений.
За это отвечает метод:
```rust
.with_linear_system_structure(
Lsode2LinearSystemStructure::{…}
)
```
Перечисление Lsode2LinearSystemStructure определяет структуру матрицы и, косвенно, тип используемого бэкенда линейной алгебры:
```rust
pub enum Lsode2LinearSystemStructure {
Dense,
Sparse,
Banded { kl: usize, ku: usize },
}
```
Вариант
Dense
предполагает использование плотной матрицы. Такой путь естественен для небольших систем и отладки. Вариант
Sparse
используется для разрежённых якобианов, где число ненулевых элементов существенно меньше полного размера матрицы. В этом случае активируется соответствующий sparse-бэкенд; в LSODE2 это означает использование возможностей крейта faer. Наконец,
Banded { kl, ku }
предназначен для полосчатых/ленточных матриц. Здесь необходимо явно указать число поддиагоналей (kl) и наддиагоналей (ku). Такие структуры часто возникают после дискретизации PDE, в задачах переноса или моделях с локальным взаимодействием соседних узлов сетки.
Дальше вступает в силу политика выбора решателя СЛАУ. Обычно используется метод:
```rust
.with_linear_solver_policy(
Lsode2LinearSolverPolicy::Auto
)
```
Несмотря на название, Auto здесь не означает попытку эвристически «угадать лучший солвер». Логика гораздо проще и полностью детерминирована:
Dense → dense-бэкенд
Sparse → sparse-бэкенд
Banded → banded-бэкенд
То есть пользователь задаёт структуру матрицы, а библиотека автоматически выбирает естественный бэкенд для этой структуры.
Если же нужен исследовательский прогон или намеренная проверка конкретного бэкенда, можно использовать Lsode2LinearSolverPolicy::Force(...), где в качестве аргумента нужно подать один из вариантов перечисления Lsode2LinearSolverChoice:
DenseLu, FaerSparseLu, LapackFaithfulBandedLu.
Ошибиться в выборе солвера СЛАУ — не обязательно получить неверное решение, но почти гарантированно потерять производительность. Использовать плотный бэкенд для большой полосчатой системы примерно так же расточительно, как хранить диагональную матрицу целиком: информация останется той же, но цена вычислений возрастёт многократно.
Таким образом, к этому моменту пользователь LSODE2 уже сделал два важных выбора:
1. Как интегрировать?
Adams / BDF / Auto
2. Как хранить якобиан и решать СЛАУ?
Dense / Sparse / Banded
Только после этого имеет смысл переходить к третьему уровню настройки — происхождению функций невязок и якобиана: Numerical, Lambdify или AOT. Здесь начинается уже не выбор метода, а выбор вычислительной философии.
а) Чисто численный путь. Пользователь предоставляет функции непосредственно в Rust-коде. Это минимальный путь для тех случаев, когда модель уже записана как обычные функции, а не как символические выражения. Есть два варианта одной и той же задачи: аналитическая функция невязки вместе с аналитическим якобианом и та же аналитическая функция невязки, но с конечно-разностным якобианом. **[исправлено: для этого маршрута теперь есть явные numerical constructors; пользователь больше не обязан передавать `Vec<Expr>` или dummy Jacobian closure. Внутренние placeholders остаются implementation detail общей `Lsode2ProblemConfig`.]**
Схематически numerical route с пользовательским якобианом выглядит так:
```rust
let options = Lsode2NumericProblemOptions::new(
vec!["y".to_string()],
"t".to_string(),
0.0,
DVector::from_vec(vec![1.0]),
1.0,
0.05,
1e-6,
1e-8,
)
.with_linear_system_structure(Lsode2LinearSystemStructure::Sparse)
.with_faithful_bdf_solve(100_000, 100_000);
let analytical = Lsode2ProblemConfig::new_numeric_with_jacobian_options(
options.clone(),
|_t, y: &DVector<f64>| DVector::from_vec(vec![-2.0 * y[0]]),
|_t, _y: &DVector<f64>| DMatrix::from_row_slice(1, 1, &[-2.0]),
);
```
Если якобиан не хочется или невозможно писать вручную, можно оставить точную функцию невязки, но переключить якобиан на конечно-разностное выполнение:
```rust
let finite_difference = Lsode2ProblemConfig::new_numeric_fd_with_options(
options,
|_t, y: &DVector<f64>| DVector::from_vec(vec![-2.0 * y[0]]),
);
```
б) Путь “ламбдификации”. Здесь пользователь задаёт систему как набор символьных выражений, а LSODE2 строит исполняемые замыкания во время подготовки солвера.
Вначале записать уравнения либо через Expr::parse_expression(…) либо просто через нативный символьный синтаксис, вводя символьные переменные и константы, и операции между ними
```rust
let x = Expr::Var("x".to_string());
let c = Expr::Const(5.0);
let y = x + c;
```
Потом выбрать структуру Jacobian, позволить LSODE2 построить функции невязок и Jacobian с помощью символьной машинерии, затем запустить задачу через UniversalODESolver.
Типичная конфигурация выглядит так:
```rust
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_backend(
Lsode2BackendConfig::native_sparse_faer()
.with_generated_backend_target_chunks(4, 4),
);
```
Перечисление Lsode2SymbolicAssemblyBackend допускает два варианта:
- ExprLegacy - стабильный бэкенд сборки символики;
- AtomView – более новый и, как правило, более быстрый бэкенд символьной машинерии.
А перечисление Lsode2SymbolicExecutionMode отвечающее за бэкенд сборки коллбэков невязок и якобиана имеет два варианта: Aot {… }, который мы обсудим в следующем разделе и LambdifyExpr, последний и означает исполнение через генерируемые в рантайме Rust замыкания, без внешнего компилятора.
**[исправлено]** `.with_generated_backend_target_chunks(4, 4)` относится к настройкам generated/AOT runtime. На Lambdify-маршруте этот вызов допустим как часть общей backend-конфигурации, но практически важен именно тогда, когда выбран generated/AOT backend; для обычного `LambdifyExpr` главным knob остаётся выбор `ExprLegacy`/`AtomView` и структура линейной системы.
в) AOT путь. Ahead-of-Time генерация нужна тогда, когда стоимость вычисления функций невязок и якобиана становится заметной. Это более тяжёлый путь: первый запуск может потратить время на кодогенерацию и компиляцию, зато повторные запуски могут переиспользовать артефакты. Исполняемый компилируется один раз, затем используется повторно.
Пример AOT-конфигурации:
```rust
let config = base_symbolic_problem()
.with_backend(
Lsode2BackendConfig::native_sparse_faer_aot_c_tcc(
"target/lsode2-aot-guide",
)
)
.with_residual_jacobian_source(
Lsode2ResidualJacobianSource::Symbolic {
assembly: Lsode2SymbolicAssemblyBackend::AtomView,
execution: Lsode2SymbolicExecutionMode::Aot {
toolchain: Lsode2AotToolchain::CTcc,
profile: Lsode2AotProfile::Release,
},
},
)
.with_aot_parallel_chunking(2);
```
Здесь, как мы уже выяснили раннее, AtomView выступает как более современный symbolic assembly backend, а Aot { toolchain, profile } задаёт не профиль сборки Rust crate, а способ компиляции сгенерированных модулей коллбэков.
**[исправлено]** В примере используется C с компилятором tcc. Путь `"target/lsode2-aot-guide"` задаёт каталог сгенерированного артефакта; при повторных запусках та же папка позволяет использовать «тёплое» поведение — не начинать подготовку артефактов полностью с нуля, особенно если build policy настроена как `BuildIfMissing` или строгий `RequirePrebuilt`.
AOT-маршрут естественно связан с разбиением на чанки и многопоточным исполнением. Функцию можно разбивать на части, чтобы не получить одну огромную функцию, которую неприятно компилировать и трудно эффективно исполнять. В простейшем случае это задаётся так:
```rust
let config = config
.with_aot_parallel_chunking(2)
.with_aot_target_chunks(8, 8);
```
Можно точечно настроить чанкинг на разреженной матрице:
```rust
use RustedSciThe::symbolic::codegen::codegen_tasks::SparseChunkingStrategy;
let config = config.with_aot_sparse_chunking_strategy(
SparseChunkingStrategy::ByTargetChunkCount {
target_chunks: 8,
},
);
```
Обычно это уже не первая настройка, которую стоит трогать. Иначе легко получить ситуацию, где knobs крутятся, статистика меняется, а понять, стало ли решение действительно лучше, трудно.
В итоге конфигурация LSODE2 читается как последовательность инженерных решений, а не как случайная цепочка билдер-вызовов:
```rust
let config = Lsode2ProblemConfig::new(...)
// 1. Откуда берём функции невязок и Jacobian?
.with_residual_jacobian_source(...)
// 2. Какова структура линейной системы?
.with_linear_system_structure(...)
// 3. Как выбираем линейный backend?
.with_linear_solver_policy(...)
// 4. Какое семейство метода или какой controller используем?
.with_controller(...)
// или
.with_adams_only_controller()
// 5. Каким native solve path запускаем задачу?
.with_faithful_bdf_solve(...);
```
Для ориентира можно свести основные решения в короткую таблицу.
| Что выбираем | Типичный API | Когда использовать |
| --- | --- | --- |
| Adams-only | `.with_adams_only_controller()` | Явно нежёсткие IVP, когда не нужно автоматическое переключение |
| BDF-only | `.with_bdf_only_controller()` | Заведомо жёсткие задачи или “подозреваемые” |
| Numerical | `new_numeric_with_jacobian_options(...)`, `new_numeric_fd_with_options(...)` | Модель уже написана на Rust, нужна “нулевая линия” без символьной машинерии |
| Lambdify | `Symbolic + LambdifyExpr` | Первый удобный маршрут для символьных моделей |
| AOT | `Symbolic + Aot { toolchain, profile }` | Дорогие невязки/якобианы, длинные расчёты, повторные запуски |
| Dense | `Lsode2LinearSystemStructure::Dense` | Малые системы, отладка |
| Sparse | `Lsode2LinearSystemStructure::Sparse` | Большие разреженные якобианы, production-маршрут |
| Banded | `Lsode2LinearSystemStructure::Banded { kl, ku }` | Сеточные задачи, локальные взаимодействия, транспорт/диффузия |
| Auto solver policy | `Lsode2LinearSolverPolicy::Auto` | Стандартный режим: бэкэнд выводится из структуры системы |
| Forced backend | `Lsode2LinearSolverPolicy::Force(...)` | Исследовательские сравнения, parity, диагностика бэкэндов |
Так настройка LSODE2 перестаёт выглядеть как длинная гирлянда методов. Это скорее маршрутная карта: сначала выбрать математическое семейство, затем источник вычислительных ядер, затем геометрию Jacobian, затем линейную алгебру, а уже в конце — крутим оптимизационные ручки вроде AOT-профиля и разбиения на чанки.
Некоторая перегруженность записи – цена за возможность тонко настроить солвер и получить, потенциально, выигрыш в производительности. При проектировании была заложена правильная философия постановки задачи: сначала описывается математика задачи, затем структура линейной системы, потом политика выбора решателя и лишь затем детали исполнения. Этот порядок отражает общую философию численного моделирования: сначала корректность, потом оптимизация.
Ниже приведён законченный пример от начала до конца: от задания системы до запуска через UniversalODESolver, получения результата и печати статистики.
```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() {
// Описываем систему ОДУ в символическом виде.
//
// y1' = -10 y1 + 9 y2
// y2' = y1 - y2
//
// Это небольшая система, но она уже демонстрирует
// основной стиль LSODE2: формулы задаются как Expr,
// а дальнейший pipeline сам строит функции невязок
// и Jacobian выбранным способом.
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,
)
// Просим LSODE2 получать residual/Jacobian из символики.
// Assembly отвечает за внутреннее представление выражений,
// execution — за способ исполнения.
.with_residual_jacobian_source(
Lsode2ResidualJacobianSource::Symbolic {
assembly: Lsode2SymbolicAssemblyBackend::ExprLegacy,
execution: Lsode2SymbolicExecutionMode::LambdifyExpr,
},
)
// Указываем ожидаемую структуру линейной системы.
// Для маленькой задачи Dense тоже был бы разумен,
// но Sparse показывает общий production-маршрут.
.with_linear_system_structure(Lsode2LinearSystemStructure::Sparse)
// Auto означает: выбрать backend детерминированно
// из указанной структуры системы.
.with_linear_solver_policy(Lsode2LinearSolverPolicy::Auto)
// Faithful BDF solve — режим, ориентированный на воспроизведение
// LSODE/ODEPACK-подобного поведения.
.with_faithful_bdf_solve(100_000, 100_000);
// UniversalODESolver — единая точка входа в solver API.
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();
// печатаем или сохраняем результат
// Статистика важна не меньше самого результата.
// Она позволяет понять, сколько шагов сделал solver,
// где была потрачена работа и не деградировал ли backend.
if let Some(stats) = solver.get_statistics() {
println!("{}", stats.table_report());
}
}
```
Этот пример показывает одну из сильных сторон LSODE2: пользователь задаёт математическую модель довольно компактно, но при этом не теряет доступ к низкоуровневым решениям — структуре матрицы, политике линейного решателя, символьной машинерии и статистике.
Если свести весь этот “зоопарк” опций к простым рецептам, то будет следующее. Если модель уже написана как Rust-функции, начинайте с numerical route и аналитического якобиана; если якобиан писать дорого, используйте finite-difference как диагностический или стартовый путь. Если модель удобнее задавать формулами, начинайте с `AtomView + LambdifyExpr`. Если задача крупная, повторяется много раз или подготовленный артефакт можно переиспользовать, переходите к `AtomView + AOT`, чаще всего начиная с `CTcc` и затем сравнивая warm/prebuilt AOT с Lambdify на своей машине. **[исправлено: удалён оборванный пункт списка и заменён на законченную практическую рекомендацию.]**
Интересной особенностью LSODE2 являются и условия остановки интегрирования (и это, конечно, пришло из SciPy):
```rust
.with_stop_condition_ge(...)
.with_stop_condition_le(...)
.with_stop_condition_abs(...)
```
Для учебного примера это выглядит второстепенной деталью, но не для реальных задач. Горение, популяционные модели, химическая кинетика: во многих системах существует естественный физический конец процесса. Стоит остановиться при достижении некоторого физического критерия, чем продолжать интегрировать область, где решение формально существует, но уже потеряло смысл.
RustedSciThe снабжен постпроцессором, поэтому можно “заказать” выдачу графика или таблицы.
Кроме Rust API, LSODE2 поддерживает путь постановки задачи интегрирования ОДУ через документ с заданием (task document) — человекочитаемое описание задачи, который затем разбирается парсером и превращается в типизированный IvpTaskSpec, а дальше в UniversalODESolver – универсальный API задач с начальным условием. Это особенно удобно для CLI, GUI, внешних сервисов и воспроизводимых расчётных сценариев: вместо того чтобы менять Rust-код, пользователь меняет документ задачи.
Пример task document:
```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
```
Такой формат полезен там, где численная задача становится не просто фрагментом программы, а объектом обмена: её можно сохранить, отправить коллеге, положить в репозиторий или генерировать из интерфейса. При этом ядро остаётся типизированным: документ задания (task document) — это не замена API, а внешний слой над ним.
//========================================================================================================================================================================================
# LSODE2 в RustedSciThe: руководство пользователя
Этот текст написан для разработчика на Rust, который открывает LSODE2 впервые и хочет быстро перейти от «что это вообще такое» к рабочей, осмысленной конфигурации. Мы будем говорить не только о том, какие флаги существуют, но и о том, почему они вообще появились, какие решения стоят за ними и в какой момент выбирать один маршрут вместо другого.
Важно сразу договориться о фокусе. LSODE2 в текущей архитектуре RustedSciThe одновременно решает две задачи: воспроизводит математическую философию семейства ODEPACK (в особенности LSODA-подобный автопереход Adams/BDF), и встраивается в современный pipeline символики, codegen и линейной алгебры RustedSciThe. Поэтому у него есть «алгоритмическая часть» и «инфраструктурная часть». Если смешивать их в голове, конфиг быстро превращается в хаос; если разделять, всё становится логичным.
## С чего начать: две оси выбора
Практически любую постановку в LSODE2 удобно описывать по двум осям. Первая ось отвечает за математику шага: фиксированный BDF, фиксированный Adams или автоматическое переключение семейства, как в LSODA. Вторая ось отвечает за то, как считаются residual/Jacobian и как решается линейная система Ньютона.
Именно вторая ось порождает три знакомых маршрута: чисто численный (`Numerical`), символический с lambdify (`Lambdify`) и символический с ahead-of-time генерацией (`AOT`). На этом месте полезно держать простую мысль: это не «три разных солвера», это три способа кормить один и тот же алгоритм качественными функциями и матрицами.
## Что принимает `Lsode2ProblemConfig::new` и почему это «ядро синтаксиса»
Конструктор `Lsode2ProblemConfig::new(...)` — действительно центральная точка входа. Его сигнатура:
```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
```
По смыслу это читается как: «вот система уравнений, вот имена переменных, вот независимая переменная, вот старт, стартовое состояние, конечное время и базовые настройки точности/шага».
`eq_system` — правая часть ОДУ в символическом виде (`Expr`).
`values` — имена компонент вектора состояния в том же порядке, в каком компоненты лежат в `y0`.
`arg` — имя независимой переменной, обычно `"t"`.
`t0`, `y0`, `t_bound` — стандартная тройка начальные условия + горизонт интегрирования.
`max_step`, `rtol`, `atol` — верхний потолок шага и относительная/абсолютная точности.
Даже если вы потом пойдёте в чисто аналитические callbacks, этот конструктор остаётся основой: он задаёт общую геометрию задачи и политику интегратора.
## Dense, Sparse, Banded: не «вкусовщина», а модель стоимости
Выбор структуры Якобиана — это выбор вычислительной экономики.
`Dense` оправдан для маленьких систем, для отладки и для случаев, где главная ценность — простота трассировки.
`Sparse` — обычный рабочий default для больших неструктурированных систем: меньше память, дешевле факторизация.
`Banded` — лучший путь там, где матрица действительно ленточная, например в дискретизированных задачах переноса/диффузии и многих BVP/PDE-подобных постановках.
Если структура выбрана неверно, солвер не обязательно «упадёт», но почти гарантированно потеряет производительность и иногда устойчивость. Поэтому сначала стоит смотреть на структуру Якобиана, а уже потом на тюнинг толерансов.
## LSODE и LSODA: исторический контекст и как это отражено в LSODE2
В классическом Fortran-мире LSODE и LSODA — не синонимы. LSODE предполагает, что семейство метода выбирается пользователем (Adams или BDF) и дальше не прыгает само по себе. LSODA добавляет автоматику: оценивает «жёсткость» и стоимость, и при необходимости переключает семью.
LSODE2 в RustedSciThe использует эту философию буквально. Если вы задаёте ручной контроллер (`bdf_only` или `adams_only`), это LSODE-стиль. Если выбираете `automatic_adams_bdf`, это LSODA-стиль поведения в рамках единого API.
## Ключевые типы конфигурации: что за что отвечает
### `Lsode2BackendConfig`
Это «контейнер» низкоуровневых backend-настроек. Внутри три важные составляющие: какой backend Якобиана (`jacobian_backend`), какой линейный backend (`linear_solver_backend`) и как организован generated backend для символики/AOT (`generated_backend`).
В обычной практике вы редко собираете его «с нуля руками»: чаще используете готовые конструкторы вроде `native_sparse_faer()`, `native_banded_faithful()`, `dense_aot_c_gcc(...)` и затем при необходимости аккуратно донастраиваете.
### `Lsode2JacobianBackend`
`SymbolicGenerated` означает, что Якобиан идёт через символический pipeline (Lambdify или AOT).
`AnalyticClosure` означает пользовательский аналитический callback.
`FiniteDifference` — встроенный конечно-разностный Якобиан.
Важно: в текущем состоянии API `AnalyticClosure` и `FiniteDifference` валидны именно для native solve path; bridge-режим под это не предназначен.
### `Lsode2LinearSolverBackend`
`Dense` — плотный LU,
`SparseFaer` — sparse LU через `faer`,
`BandedFaithful` — faithful LAPACK-style banded LU.
Это технический слой. На пользовательском уровне чаще удобнее задавать структуру (`Dense/Sparse/Banded`) и политику (`Auto/Force`), а не backend напрямую.
## Политика выбора линейного решателя: Auto против Force
`Lsode2LinearSolverPolicy::Auto` — это не магия и не «угадайка». Выбор делается детерминированно из `Lsode2LinearSystemStructure`: dense ведёт к `DenseLu`, sparse к `FaerSparseLu`, banded к `LapackFaithfulBandedLu`.
`Force(...)` нужен, когда вы делаете исследовательский прогон, parity-сравнение или сознательно проверяете гипотезу о деградации конкретного backend-а.
Именно эта схема «сначала структура, затем Auto» обычно даёт самый устойчивый и воспроизводимый production-конфиг.
## Синтаксис backend route: `with_backend` против `with_linear_solver_policy`
В LSODE2-примерах встречаются два похожих стиля:
```rust
.with_backend(
Lsode2BackendConfig::native_banded_faithful()
.with_generated_backend_target_chunks(4, 4),
)
```
и:
```rust
.with_linear_solver_policy(Lsode2LinearSolverPolicy::Auto)
```
Это не одно и то же. `with_backend(...)` — высокоуровневый выбор полного
маршрута LSODE2. Он задает dense/sparse/banded структуру матрицы, конкретный
backend линейной алгебры, symbolic/generated lifecycle, AOT toolchain и
chunking-настройки generated callbacks. Именно этот стиль лучше использовать в
пользовательских примерах и production-коде, потому что весь route виден в
одном месте.
```rust
let config = base.with_backend(
Lsode2BackendConfig::native_sparse_faer()
.with_generated_backend_target_chunks(4, 4),
);
```
`with_linear_solver_policy(...)` — более низкоуровневая настройка. Она
управляет только тем, как выбирается linear solver после того, как структура
линейной системы уже известна. `Auto` сопоставляет dense-системы с dense LU,
sparse-системы с faer sparse LU, а banded-системы с faithful LAPACK-style
banded LU. `Force(...)` полезен в parity-тестах, diagnostics и legacy examples,
где нужно явно доказать, что выбран конкретный linear solver.
Практическое правило: когда вы выбираете реальный LSODE2 solver route,
используйте `with_backend(...)`; когда тестируете именно resolver политики
linear solver, используйте `with_linear_solver_policy(...)`.
## Символика: `ExprLegacy` и `AtomView`
`Lsode2SymbolicAssemblyBackend` имеет два варианта: `ExprLegacy` и `AtomView`.
`ExprLegacy` — консервативный baseline.
`AtomView` — более современное упакованное представление (внутренний IR для символики), которое в ряде задач ускоряет подготовку и/или выполнение.
Здесь термин IR стоит проговорить явно. IR (intermediate representation, промежуточное представление) — это внутренний формат между «математическим выражением» и «исполняемым кодом». В LSODE2 IR-слой позволяет не переписывать математику заново при смене backend-а: вы меняете исполнение, но не формулы.
## Lambdify и AOT: что это такое на практике
Lambdify в контексте LSODE2 — это построение исполняемых Rust-замыканий из символики во время подготовки solve. Без внешнего компилятора, с быстрым стартом, с хорошей переносимостью.
AOT (ahead-of-time) — это другой компромисс. Вы платите upfront-цену: codegen, компиляция, линковка артефакта. Зато на длинной дистанции (много вызовов residual/Jacobian, много повторных запусков) runtime-часть обычно выигрывает.
С практической стороны AOT в RustedSciThe поддерживает несколько тулчейнов: C через `gcc`, C через `tcc`, `zig`, а также Rust toolchain. Это означает, что соответствующие компиляторы должны быть доступны в окружении запуска.
`Debug` и `Release` в AOT-профиле — стандартный компромисс. Debug обычно быстрее собирается, но медленнее работает на solve-этапе. Release дольше собирается, но снижает стоимость вычислений в цикле интегрирования.
Практический lifecycle AOT обычно имеет два production-режима.
`BuildIfMissing` или `BuildIfMissingRelease` собирает артефакт, если он
отсутствует, и возвращает resolver, который можно переиспользовать.
`RequirePrebuilt` — строгий режим: он падает, если артефакт заранее не известен
и не собран. Это правильный route для production-запусков, где старт не должен
молча компилировать код.
В строгом режиме `RequirePrebuilt` сообщения об ошибках специально сделаны
подробными. Они должны показывать route (`dense`, `sparse` или
`residual-only`), `problem_key`, build policy, generated backend, C compiler
если он используется, и output parent directory. `problem_key` можно понимать
как идентичность артефакта для данной символической задачи и набора frontend /
matrix / toolchain / chunking-настроек. Если strict prebuilt запуск падает,
обычный правильный путь такой: один раз запустить ту же LSODE2-конфигурацию
через `BuildIfMissingRelease` или `RebuildAlways`, а затем переиспользовать
полученный resolver или тот же каталог артефактов в строгом запуске.
`RebuildAlways` намеренно сделан безопасным относительно Windows file locks:
каждая пересборка материализуется в изолированный подпуть внутри настроенного
output parent directory, а не перезаписывает DLL/cdylib, который уже мог быть
загружен текущим процессом. Это устраняет типичный lock-конфликт без опасной
попытки удалять живую dynamic library. Если `RebuildAlways` используется в
длинных диагностических сессиях, старые isolated rebuild directories можно
чистить позже, когда ни один процесс их уже не держит.
## Параллелизм/чанкинг в AOT: как это связано с производительностью
В generated backend есть `aot_options`, где задаётся стратегия «нарезки» residual/Jacobian на куски. Это влияет на размер функций, стоимость компиляции и поведение runtime-плана. Для residual используются стратегии вроде `Whole`, `ByTargetChunkCount`, `ByOutputCount`; для dense Jacobian — `Whole`, `ByTargetChunkCount`, `ByRowCount`.
На уровне LSODE2 это применяется через `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)
);
```
Обычно это уже advanced-тюнинг: сначала стоит добиться корректного baseline, и только потом играть стратегиями чанкинга.
## `with_stop_condition_*`: зачем три варианта
Ранний останов в LSODE2 полезен не только для удобства, но и для физической корректности в задачах с естественным «концом процесса».
`with_stop_condition(...)` — shorthand для условия `variable >= target`.
`with_stop_condition_ge(...)` — явный вариант `>=`.
`with_stop_condition_le(...)` — явный вариант `<=`.
`with_stop_condition_abs(...)` — останов по близости к цели `|variable - target| <= tolerance`.
В задачах горения это особенно полезно: например, можно остановить интегрирование, когда степень превращения достигает 0.999, а не продолжать шагать в «формально разрешённую, но физически бессмысленную» область.
## Что такое task document и как LSODE2 запускается через парсер
В RustedSciThe есть командный интерпретатор, который разбирает человекочитаемый документ задачи и превращает его в типизированный `IvpTaskSpec`, а затем в `UniversalODESolver`.
Это и есть task document путь: вы описываете задачу текстом (`task`, `equations`, `initial_conditions`, `solver_options`, `postprocessing`), а парсер нормализует и валидирует поля. Для LSODE2 доступны поля `lsode2_symbolic_assembly`, `lsode2_symbolic_execution`, `lsode2_aot_toolchain`, `lsode2_aot_profile`, `lsode2_linear_structure`, `lsode2_linear_solver_policy`, `lsode2_native_execution` и другие.
С точки зрения результата сейчас встроенный postprocessing-конвейер намеренно консервативен: CSV-выгрузка поддерживается напрямую, а флаг `plot` парсится и сохраняется в спецификации для внешних обёрток/фронтендов, но не запускает графику автоматически в ядре парсера.
## Полный пример из Rust-кода (Lambdify, sparse, faithful BDF)
Ниже законченный минимальный сценарий: от задания уравнений до вывода статуса и статистики.
```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());
}
}
```
## Полный пример task document
Этот формат полезен, когда задача приезжает из CLI, скрипта или внешнего сервиса:
```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
```
## Практическая стратегия выбора конфигурации
Если система небольшая и вы в фазе отладки, начинайте с Dense + Lambdify. Если система большая и разреженная, начинайте со Sparse + Lambdify, а затем переходите в AOT и сравнивайте stage-метрики (`prepare` против `solve`) в multi-run story tests. Для действительно ленточных систем выбирайте Banded + faithful backend и внимательно задавайте `kl/ku`.
Смысл такой последовательности прост: сначала подтверждаем корректность математики на самом прозрачном маршруте, затем переносим ту же математику в более агрессивный backend и проверяем эквивалентность, а уже потом оптимизируем compile/runtime баланс.
## Где смотреть дальше в репозитории
За быстрыми живыми примерами идите в `examples/lsode2_numerical_guide.rs`, `examples/lsode2_lambdify_guide.rs`, `examples/lsode2_aot_guide.rs`, `examples/lsode2_manual_bdf_guide.rs`, `examples/lsode2_manual_adams_guide.rs` и `examples/lsode2_task_shell_guide.rs`.
Если нужен профиль производительности и корректности на уровне сценариев, смотрите `story_tests.rs` и `story_tests2.rs`. Если нужна математика parity относительно ODEPACK-логики, смотрите `parity_micro.rs`, `stiff_parity_tests.rs`, `nonstiff_parity_tests.rs` и `MIRRORING_CHECKLIST.md`.
Когда эти уровни разделены (guide для эксплуатации, story для поведения end-to-end, parity для математической эквивалентности), LSODE2 перестаёт выглядеть сложным. Он становится предсказуемым инженерным инструментом, который можно осознанно настраивать под конкретную задачу.
## Update: AOT/Lambdify parallel chunking
Практический минимум для параллельного chunking в LSODE2:
```rust
let config = config
.with_aot_parallel_chunking(2)
.with_aot_target_chunks(8, 8);
```
Точечная настройка sparse-чанкинга:
```rust
use RustedSciThe::symbolic::codegen::codegen_tasks::SparseChunkingStrategy;
let config = config.with_aot_sparse_chunking_strategy(
SparseChunkingStrategy::ByTargetChunkCount { target_chunks: 8 }
);
```
Для Lambdify можно также задать generated runtime chunking:
```rust
let config = config.with_backend(
Lsode2BackendConfig::native_sparse_faer()
.with_generated_backend_target_chunks(4, 4),
);
```