Skip to content

public-api: rename, reshape, and tighten the cross-language surface#43

Open
schroedk wants to merge 14 commits into
mainfrom
cleanup/public-api
Open

public-api: rename, reshape, and tighten the cross-language surface#43
schroedk wants to merge 14 commits into
mainfrom
cleanup/public-api

Conversation

@schroedk
Copy link
Copy Markdown
Collaborator

@schroedk schroedk commented May 12, 2026

Summary

Cleanup pass on the public API of within (Rust + Python) and schwarz-precond. Aligns Rust/Python names, makes internal modules honestly private, closes silent-validation holes, separates problem state from solve-time tuning, and trims dead scaffolding. Closes #33.

Public API

  • Cross-language renames: SolverParamsLsmrOptions, Preconditioner (config enum)→PreconditionerConfig, FePreconditionerPreconditioner, SchurComplementLocalSolverConfig, SparseMatrixCsrMatrix.
  • PreconditionerConfig: new Off variant; Additive { local_solver, reduction } (struct variant; the wrapping Rust AdditiveSchwarz struct was collapsed).
  • Preconditioner: opaque public struct; #[serde(transparent)] pins the wire format. Cheap O(1) clone via internal Arc.
  • Unified Solver::new: impl IntoDesign + impl Into<PreconditionerInput>; accepts five preconditioner shapes (None, &Cfg, Some(&Cfg), owned Cfg, owned-or-& Preconditioner). Removes from_design, from_design_with_preconditioner, with_preconditioner.
  • LSMR tuning per call: LsmrOptions moves from Solver::new to Solver::solve / solve_batch. Python: Solver(...) loses config=; solver.solve(y, options=...) and solver.solve_batch(Y, options=...) accept it instead. Free functions: config=options=.
  • Design is pure data + layout; weights live on DesignOperator::new / Solver::new / build_preconditioner.

Validation

  • Solver::solve rejects y.len() != n_obs (was silent truncation).
  • Solver::new rejects a prebuilt Preconditioner whose nrows/ncols don't match design.n_dofs via new BuildError::PreconditionerDimensionMismatch.
  • Python Solver.solve_batch rejects Y.shape[0] != n_obs up front (incl. empty-Y case).
  • Operator::apply / apply_adjoint return Result<(), SolveError>.

Tests

  • New api_pinning.rs compile-pins the five Solver::new preconditioner shapes.
  • New wire_format_fixture.rs + 218-byte fixture: defends the #[serde(transparent)] wire-stability claim against silent encoding drift.

Module visibility

within::{domain, operator, orchestrate, solver} are pub(crate); public items remain re-exported from the crate root. New within.config Python submodule exposes advanced configs.

Test plan

  • cargo test --workspace
  • pixi run test — 97 Python tests
  • cargo doc -p within -p schwarz-precond --no-deps clean under RUSTDOCFLAGS='-D warnings'
  • Pickle round-trip + reuse path smoke-tested across preconditioner input forms

schroedk added 14 commits May 12, 2026 13:58
…exports

- Rename SparseMatrix to CsrMatrix and rename sparse_matrix.rs to
  csr_matrix.rs to match the existing module-naming convention.
- Move LocalSolveError out of the crate-root re-exports; expose it via
  schwarz_precond::error::LocalSolveError. Make the error module pub.
- Fix the redundant-link rustdoc warning on LocalSolver::solve_local.
- Update within's internal callers (schur_complement, schwarz,
  local_solver, operator/tests) for the rename + relocated LocalSolveError.
Cross-language rename so Rust and Python use the same names:
- SolverParams → LsmrConfig (Python LSMR → LsmrConfig)
- Preconditioner config enum → PreconditionerConfig (Python likewise)
- FePreconditioner (built handle) → Preconditioner
- SchurComplement → LocalSolverConfig

Reshape PreconditionerConfig:
- Add explicit Off variant; Additive(LocalSolverConfig, ReductionStrategy)
  collapses into Additive(AdditiveSchwarz) — a new struct that owns the
  local solver + reduction strategy together.
- Option<&PreconditionerConfig> now means: None = library default,
  Some(::Off) = explicit identity, Some(::Additive(_)) = tuned Schwarz.
- Drop solver_default(); the single LocalSolverConfig::default() uses
  split_merge: Some(2) (no one-shot use case).

Make Preconditioner an opaque struct:
- Wraps a private inner enum. #[non_exhaustive] removed (future variants
  are non-breaking additions to the inner enum). #[serde(transparent)]
  keeps the postcard wire format identical to the inner enum.
- FeSchwarz is no longer reachable from the public surface.

Tighten module visibility on the within crate:
- domain / operator / orchestrate / solver are now pub(crate). Public
  items remain re-exported from the crate root.
- Drop top-level Operator (use schwarz_precond::Operator),
  DEFAULT_DENSE_SCHUR_THRESHOLD, Subdomain, and the
  domain::{PartitionWeights, SubdomainCore} re-exports.
- DesignOperator stays reachable via a #[doc(hidden)] re-export for
  integration tests and benches.

Close two silent-validation holes:
- Solver::solve rejects y.len() != n_obs with SolveError::InvalidInput
  (was silent truncation by the zip with sqrt-weights).
- Python solve_batch rejects Y.shape[0] != n_obs up front (was bypassed
  by empty Y).

Python API:
- Top-level within.* exposes the 9 stable call-site names; advanced
  configs live in a new within.config submodule.
- Solver / solve / solve_batch accept a 4-form preconditioner argument:
  None, PreconditionerConfig.{Off,Additive}, AdditiveSchwarz(...), or a
  pre-built Preconditioner (reuse path).
- LocalSolverConfig is now a thin Python subclass over the PyO3 native
  with __slots__ = (). approx_schur=None requests exact Schur; omitting
  uses the library default approximate. Preserves the previous semantic
  used in benchmarks/suites/fixest_comparison.py.
- Every PyO3 class reports __module__ == "within._within".
- All config and result classes are now picklable via __reduce__.

Added Rust convenience APIs:
- solve_with_preconditioner / solve_batch_with_preconditioner for
  one-shot solves that reuse a pre-built Preconditioner without
  holding a Solver.
- BatchSolveResult::n_dofs() / n_obs() accessors so callers can recover
  design dimensions for empty batches.
- README.md (root): rewrite the Python quickstart for LsmrConfig +
  PreconditionerConfig; the preconditioner section now documents the
  4-form Union (None / PreconditionerConfig.{Off,Additive} /
  AdditiveSchwarz / built Preconditioner). Rewrite the Rust API
  section: opaque Preconditioner, two-channel signaling, within.config
  module imports. Replace the "lower-level types" table with a module
  visibility table reflecting the new pub(crate) boundary.
- crates/within/README.md: Rust example uses LsmrConfig +
  PreconditionerConfig. Updated module description.
- python/within/README.md: solver.preconditioner is a property (was a
  method); document the approx_schur=None = exact Schur semantic;
  drop the removed FullSddm reference.
- CHANGELOG.md: consolidate the [Unreleased] block into the standard
  Added/Changed/Removed sections with concise BREAKING bullets.
…itioner

These were two-line forwarders to Solver::with_preconditioner(...).solve(...).
The Python bridge now inlines that pattern directly; no released code
depended on the wrappers (they only existed in the Unreleased CHANGELOG).
Replace the 9-arg pub(crate) BatchSolveResult::new and set_time_total
setter with direct struct-literal construction and field assignment —
both producer and only consumer live in the crate. Collapse the two
adjacent pub use error::... lines (same names exported, one line).
- Drop dead Preconditioner::additive_reduction_strategy /
  resolved_additive_reduction_strategy accessors plus their
  FeSchwarz feeders (reduction_strategy, resolved_reduction_strategy,
  with_reduction_strategy). Zero external callers; only fed each
  other. Test rewrites build two preconditioners explicitly instead
  of using with_reduction_strategy.
- Merge operator/preconditioner.rs into operator/schwarz.rs — the
  opaque Preconditioner handle wraps FeSchwarz and belongs next to it.
- Hoist operator/gramian/cross_tab.rs to operator/cross_tab.rs and
  drop the 18-line operator/gramian.rs routing module; tests move
  with it.
- Trim module-level rustdoc essays across lib.rs, config.rs,
  domain.rs, observation.rs, solver.rs, orchestrate.rs, operator.rs,
  operator/schwarz.rs, error.rs. lib.rs drops from 175 to 33 lines
  (problem formulation, normal equations, block structure, references
  essays gone; one-paragraph summary plus quick-start kept).

Net: -489 lines, -1 file, -1 directory. No public API change.
cargo test --workspace passes.
- BREAKING: BatchSolveResult fields are now pub; drop n_rhs/n_dofs/n_obs/
  x_all/demeaned_all/converged/iterations/final_residual/time_solve/
  time_total accessors. Keep x(i)/demeaned(i) slicing methods.
- within-py: inline extract_lsmr_config and extract_local_solver_{config,
  or_default} into their single callers.
- within: drop compute_dense, DenseSchurResult, dense_from_elimination
  and the two dense-Schur cross-check tests (all #[cfg(test)] scaffolding;
  production uses only compute_dense_anchored).
- schwarz-precond, schur_complement: trim module-level rustdoc essays.
Drop math derivations, paper references, and forward-compat justification
prose from eight module heads, reducing each to a short orientation that
names the public symbols readers actually need. Inline the one-line
dense_fast_path_enabled predicate at its sole caller. Public API,
serialization, and behavior unchanged; cargo test --workspace stays green.
Deletions (all test-only or single-use):
- find_active_levels and CrossTab::build_for_pair (cross_tab.rs): both
  #[cfg(test)] and consumed only by cross_tab/tests.rs — tests now use
  the production find_all_active_levels + build_for_pair_with_active
  path that domain/factor_pairs.rs::build_local_domains already uses.
- sample_factor_levels (observation.rs): single-call helper used only
  by the in-file test; inlined the vec! literal.
- BlockElimSolver::uses_dense_reduced_factor (local_solver.rs): bumped
  the reduced_factor field to pub(crate) and inlined the matches! check
  at the three test sites that asserted on it.

Inlines:
- lsmr_stop_reason at its single call site in lsmr_from_bidiag.
- validate_lsmr_preconditioner at its single call site in mlsmr.
…eSchwarz struct

The AdditiveSchwarz wrapper struct held only (local_solver, reduction)
and was always reached through PreconditionerConfig::Additive(_) at
every Rust call site — there was no reuse-as-a-value benefit. Inline
the fields directly on the enum variant:

    Additive { local_solver: LocalSolverConfig, reduction: ReductionStrategy }

Saves one nesting level at the three Rust construction sites
(within-py boundary, orchestrate_solve test, fixest bench) and removes
the standalone Rust struct. The PyAdditiveSchwarz PyO3 class is a
separate type and is unaffected, so Python users still write
solve(..., preconditioner=AdditiveSchwarz(...)) as before.

Also drops the corresponding entry from the Unreleased CHANGELOG since
the public Rust struct never shipped.
Collapse the four separate LSMR-related bullets in [Unreleased] Added
into a single **Modified LSMR:** entry to match the themed-bullet
style used in [0.1.0]. No information dropped — every detail
(local_size, LsmrStopReason, non-finite rejection) survives inline.
Reshape Solver so it owns problem state (design, weights, preconditioner)
and accepts LSMR tuning per call:

  Solver::new(design, weights, precond)       // problem state only
  solver.solve(&y, &lsmr_opts)                // tuning per call
  solver.solve_batch(&ys, &lsmr_opts)         // tuning per call

The free `solve` / `solve_batch` keep `&LsmrOptions` in the same slot.
On the Python side, `Solver(...)` loses its `config=` kwarg;
`solver.solve(y, options=...)` and `solver.solve_batch(Y, options=...)`
accept it instead. Free functions rename `config=` to `options=`.

Other changes folded in:

- Rename LsmrConfig -> LsmrOptions (Rust + Python). End users see
  SolverParams -> LsmrOptions; the intermediate name doesn't ship.
- Add `From<&Preconditioner> for PreconditionerInput` so `&precond`
  works as the fifth Solver::new shape. Cheap clone is preserved via
  refcount bumps on the inner Arc'd subdomains + BufferPool.
- Add `BuildError::PreconditionerDimensionMismatch` -- Solver::new
  fails fast when a reused preconditioner's nrows/ncols don't match
  design.n_dofs instead of bubbling a confusing late mlsmr error.
- Document the cheap-clone invariant on Preconditioner, including
  the BufferPool interior-mutability nuance and the requirement
  for future Variant additions.
- Alias `schwarz_precond::lsmr` to `lsmr_solve` in solver.rs so the
  `lsmr` identifier is free for the new per-call parameter.
- Fix Python Solver.solve_batch: rename PyO3 arg `y_matrix` -> `Y`
  (was a stub/impl mismatch) and validate Y.shape[0] against the
  solver's n_obs up front (parity with the free solve_batch).
- Rename test_solver_from_design -> test_solver_accepts_prebuilt_design
  (constructor was inlined into Solver::new earlier on this branch).
Two new integration tests guard parts of the public surface that
neither the unit tests nor the workspace build catch on their own:

- api_pinning.rs: compile-time pinning of the six preconditioner-arg
  shapes accepted by Solver::new (bare None, &PreconditionerConfig,
  Some(&PreconditionerConfig), owned PreconditionerConfig, owned
  Preconditioner, &Preconditioner). If a future PR adds a From impl
  that makes bare `None` ambiguous, or removes one of these shapes,
  this file fails to compile -- an explicit signal.

- wire_format_fixture.rs + fixtures/preconditioner_v1.postcard: load
  a known postcard payload via include_bytes!, deserialize into a
  Preconditioner, plug it into Solver::new, run a solve, and compare
  coefficients against a fresh-default solver. Defends the CHANGELOG's
  `#[serde(transparent)]` wire-stability claim against silent encoding
  drift (which the existing same-build round-trip test cannot detect).
  A companion `#[ignore]`'d regenerate_wire_format_fixture test writes
  a fresh fixture for intentional wire-format bumps.
Codex-flagged inconsistencies and missed entries from the LsmrOptions
move:

- Root README Python API table: `config?` -> `options?` for the free
  `solve` / `solve_batch` functions; stale `Solver::with_preconditioner`
  reference -> `Solver::new(.., precond)`.
- Root README Rust example: remove dead references to the standalone
  Rust `AdditiveSchwarz` struct (collapsed in aefd08b); use the
  struct-variant syntax `PreconditionerConfig::Additive { local_solver,
  reduction }`. Drop `AdditiveSchwarz` from the type tables.
- Within/Python READMEs: `.solve(y)` -> `.solve(y, options?)` to match
  the new method signature.
- CHANGELOG (Added): new From<&Preconditioner> impl; new
  PreconditionerDimensionMismatch BuildError variant.
- CHANGELOG (Changed): `Solver::new` shape change — `impl IntoDesign`
  + `impl Into<PreconditionerInput>` with the legacy `from_design` /
  `from_design_with_preconditioner` / `with_preconditioner`
  constructors removed; LsmrOptions move from constructor to call site.
- CHANGELOG (Fixed): new section for the Python solve_batch fixes
  (Y kwarg actually accepted; Y.shape[0] validated up front).
- CHANGELOG: soften the `LsmrStopReason` claim — it's carried on
  schwarz_precond::LsmrResult, not yet surfaced on within::SolveResult.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Trim Solver and FE-preconditioner public surface

1 participant