Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
661 changes: 428 additions & 233 deletions CHANGELOG.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,31 @@ Rules live in `alkahest-core/src/simplify/`. Each rule is a `RewriteRule` with a
- Add the rule to the appropriate rule set (`arithmetic_rules`, `trig_rules`, `log_exp_rules_safe`, etc.).
- Add a proptest case verifying the rule is idempotent: `simplify(simplify(expr)) == simplify(expr)`.

## Accessors: property or method?

One rule, applied to every `#[pymethods]` entry in `alkahest-py/src/lib.rs`:

> **A zero-argument, O(1), non-allocating accessor that returns a scalar or a flag is a `#[getter]` (a Python property). Anything that returns a collection, allocates, or does real work is a method.**

```rust
#[getter] // property: reads a field, cannot fail
fn n_equations(&self) -> usize { self.inner.n_equations() }

fn polys(&self) -> Vec<PyGbPoly> { … } // method: allocates a collection
fn rank(&self, py: Python<'_>) -> PyResult<usize> { … } // method: real work, can fail
```

A property and a method sitting side by side on the same class is not in itself a problem — `RegularChain.n_vars` (property) next to `RegularChain.polys()` (method) is exactly what the rule asks for. What the rule rules out is the *same* kind of question being asked two different ways on two different classes, which is what made the surface unpredictable before 3.8.0.

Why the split falls there:

- A property that can raise, block, or take a noticeable amount of time is a trap — the caller reads `x.rank` as a field access. Real work stays behind parentheses.
- A method that returns a scalar is the more dangerous mistake in the other direction: `if x.n_equations:` on a bound method is always `True` and `f"{x.n_equations}"` prints `<built-in method …>`. Neither raises. Converting these was the whole point of the 3.8.0 sweep.

`tests/test_accessor_convention.py` enforces this. It pins the converted accessors at runtime and statically scans `alkahest-py/src/lib.rs` for zero-argument scalar-returning methods; if a new one is genuinely doing real work, add it to `REAL_WORK_EXEMPTIONS` there with a one-line reason. A handful of pre-3.8.0 getters return small collections (`AsymptoticReport.terms`, `CertifiedSolution.coordinates`, `PositivityCertificate.log`, …); they are grandfathered, not precedent.

Changing an existing accessor's form is a **breaking change**: record it in `CHANGELOG.md` under the release's "Behaviour changes to plan for" with a before/after line, and update every caller in `tests/`, `examples/`, `benchmarks/`, `docs/mdbook/`, `alkahest-skill/alkahest.md` and the `.pyi` stubs.

## Pull requests

- Keep PRs focused on one item from `ROADMAP.md` or one issue.
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ exclude = ["fuzz"]
resolver = "2"

[workspace.package]
version = "3.8.0"
version = "3.9.0"
edition = "2021"
authors = ["Alkahest Contributors"]
license = "Apache-2.0"
Expand Down
131 changes: 86 additions & 45 deletions alkahest-core/src/diffalg/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,22 @@ pub fn rosenfeld_groebner_with_options(
order: MonomialOrder,
max_prolong_rounds: usize,
) -> Result<RosenfeldGroebnerResult, DiffAlgError> {
rosenfeld_groebner_ranked(dae, pool, order, max_prolong_rounds).map(|(result, _)| result)
}

/// [`rosenfeld_groebner_with_options`] plus the jet [`DifferentialRanking`] that
/// indexes the exponent vectors of [`RosenfeldGroebnerResult::final_basis`].
///
/// The elimination result is unreadable without this: a [`GbPoly`] stores only
/// exponent vectors, so `ranking.vars[i]` is what exponent slot `i` refers to.
/// Pair it with [`crate::solver::gbpoly_to_expr`] to recover the input–output
/// equations as [`ExprId`]s.
pub fn rosenfeld_groebner_ranked(
dae: &DAE,
pool: &ExprPool,
order: MonomialOrder,
max_prolong_rounds: usize,
) -> Result<(RosenfeldGroebnerResult, DifferentialRanking), DiffAlgError> {
if dae.equations.is_empty() {
return Err(DiffAlgError::EmptySystem);
}
Expand All @@ -256,14 +272,17 @@ pub fn rosenfeld_groebner_with_options(
for round in 0..max_prolong_rounds {
let gb = GroebnerBasis::compute(active.clone(), order);
if is_unit_ideal_gb(&gb) {
return Ok(RosenfeldGroebnerResult {
consistent: false,
chains: vec![],
working_dae: work,
final_basis: None,
prolongation_rounds,
truncated: false,
});
return Ok((
RosenfeldGroebnerResult {
consistent: false,
chains: vec![],
working_dae: work,
final_basis: None,
prolongation_rounds,
truncated: false,
},
DifferentialRanking { vars },
));
}

let mut next_prolong = Vec::with_capacity(prolong_exprs.len());
Expand Down Expand Up @@ -305,14 +324,17 @@ pub fn rosenfeld_groebner_with_options(
} else {
vec![]
};
return Ok(RosenfeldGroebnerResult {
consistent,
chains,
working_dae: work,
final_basis: if consistent { Some(final_basis) } else { None },
prolongation_rounds,
truncated: false,
});
return Ok((
RosenfeldGroebnerResult {
consistent,
chains,
working_dae: work,
final_basis: if consistent { Some(final_basis) } else { None },
prolongation_rounds,
truncated: false,
},
DifferentialRanking { vars },
));
}

active.extend(to_add);
Expand All @@ -328,33 +350,39 @@ pub fn rosenfeld_groebner_with_options(
} else {
vec![]
};
return Ok(RosenfeldGroebnerResult {
consistent,
chains,
working_dae: work,
final_basis: if consistent { Some(final_basis) } else { None },
prolongation_rounds,
truncated: true,
});
return Ok((
RosenfeldGroebnerResult {
consistent,
chains,
working_dae: work,
final_basis: if consistent { Some(final_basis) } else { None },
prolongation_rounds,
truncated: true,
},
DifferentialRanking { vars },
));
}
}

let final_basis = GroebnerBasis::compute(active, order);
let consistent = !is_unit_ideal_gb(&final_basis);
Ok(RosenfeldGroebnerResult {
consistent,
chains: if consistent {
vec![RegularDifferentialChain {
basis: final_basis.clone(),
}]
} else {
vec![]
Ok((
RosenfeldGroebnerResult {
consistent,
chains: if consistent {
vec![RegularDifferentialChain {
basis: final_basis.clone(),
}]
} else {
vec![]
},
working_dae: work,
final_basis: if consistent { Some(final_basis) } else { None },
prolongation_rounds,
truncated: true,
},
working_dae: work,
final_basis: if consistent { Some(final_basis) } else { None },
prolongation_rounds,
truncated: true,
})
DifferentialRanking { vars },
))
}

/// Calls [`rosenfeld_groebner_with_options`] with the default maximum prolongation rounds.
Expand All @@ -372,16 +400,29 @@ pub fn dae_index_reduce(
pool: &ExprPool,
order: MonomialOrder,
) -> Result<DaeIndexReduction, DaeError> {
dae_index_reduce_ranked(dae, pool, order).map(|(r, _)| r)
}

/// [`dae_index_reduce`] plus the jet [`DifferentialRanking`] for the Gröbner
/// fallback — `None` when Pantelides succeeded and no basis was built.
pub fn dae_index_reduce_ranked(
dae: &DAE,
pool: &ExprPool,
order: MonomialOrder,
) -> Result<(DaeIndexReduction, Option<DifferentialRanking>), DaeError> {
match pantelides(dae, pool) {
Ok(p) => Ok(DaeIndexReduction::Pantelides(p)),
Ok(p) => Ok((DaeIndexReduction::Pantelides(p), None)),
Err(DaeError::IndexTooHigh) => {
let r = rosenfeld_groebner(dae, pool, order).map_err(|e| match e {
DiffAlgError::DiffError(s) | DiffAlgError::NotPolynomial(s) => {
DaeError::DiffError(s)
}
DiffAlgError::EmptySystem => DaeError::StructurallyInconsistent,
})?;
Ok(DaeIndexReduction::Rosenfeld(r))
let (r, ranking) =
rosenfeld_groebner_ranked(dae, pool, order, DEFAULT_MAX_PROLONG_ROUNDS).map_err(
|e| match e {
DiffAlgError::DiffError(s) | DiffAlgError::NotPolynomial(s) => {
DaeError::DiffError(s)
}
DiffAlgError::EmptySystem => DaeError::StructurallyInconsistent,
},
)?;
Ok((DaeIndexReduction::Rosenfeld(r), Some(ranking)))
}
Err(e) => Err(e),
}
Expand Down
30 changes: 16 additions & 14 deletions alkahest-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,10 @@ pub use lean::{
// V2-1 — Modular / CRT framework
#[cfg(feature = "groebner")]
pub use diffalg::{
dae_index_reduce, rosenfeld_groebner, rosenfeld_groebner_algebraic,
rosenfeld_groebner_with_options, DaeIndexReduction, DiffAlgError, DifferentialIdeal,
DifferentialRanking, DifferentialRing, RegularDifferentialChain, RosenfeldGroebnerResult,
dae_index_reduce, dae_index_reduce_ranked, rosenfeld_groebner, rosenfeld_groebner_algebraic,
rosenfeld_groebner_ranked, rosenfeld_groebner_with_options, DaeIndexReduction, DiffAlgError,
DifferentialIdeal, DifferentialRanking, DifferentialRing, RegularDifferentialChain,
RosenfeldGroebnerResult,
};
#[cfg(feature = "groebner")]
pub use ideal::{
Expand All @@ -224,10 +225,10 @@ pub use number_theory::{
pub use primitive::{Capabilities, CoverageReport, CoverageRow, Primitive, PrimitiveRegistry};
#[cfg(feature = "groebner")]
pub use solver::{
diophantine, expr_to_gbpoly, extract_regular_chain_from_basis, main_variable_recursive,
solve_numerical, solve_polynomial_system, solve_transcendental, triangularize, CertifiedPoint,
DiophantineError, DiophantineSolution, HomotopyError, HomotopyOpts, RegularChain, Solution,
SolutionSet, SolverError, TranscendentalOutcome,
diophantine, expr_to_gbpoly, extract_regular_chain_from_basis, gbpoly_to_expr,
main_variable_recursive, solve_numerical, solve_polynomial_system, solve_transcendental,
triangularize, CertifiedPoint, DiophantineError, DiophantineSolution, HomotopyError,
HomotopyOpts, RegularChain, Solution, SolutionSet, SolverError, TranscendentalOutcome,
};

pub fn version() -> &'static str {
Expand All @@ -252,9 +253,10 @@ pub mod stable {
pub use crate::diff::{diff, diff_forward, grad, DiffError};
#[cfg(feature = "groebner")]
pub use crate::diffalg::{
dae_index_reduce, rosenfeld_groebner, rosenfeld_groebner_algebraic,
rosenfeld_groebner_with_options, DaeIndexReduction, DiffAlgError, DifferentialIdeal,
DifferentialRanking, DifferentialRing, RegularDifferentialChain, RosenfeldGroebnerResult,
dae_index_reduce, dae_index_reduce_ranked, rosenfeld_groebner,
rosenfeld_groebner_algebraic, rosenfeld_groebner_ranked, rosenfeld_groebner_with_options,
DaeIndexReduction, DiffAlgError, DifferentialIdeal, DifferentialRanking, DifferentialRing,
RegularDifferentialChain, RosenfeldGroebnerResult,
};
pub use crate::errors::AlkahestError;
pub use crate::eval::{
Expand Down Expand Up @@ -319,10 +321,10 @@ pub mod stable {
};
#[cfg(feature = "groebner")]
pub use crate::solver::{
diophantine, expr_to_gbpoly, extract_regular_chain_from_basis, main_variable_recursive,
solve_numerical, solve_polynomial_system, triangularize, CertifiedPoint, DiophantineError,
DiophantineSolution, HomotopyError, HomotopyOpts, RegularChain, Solution, SolutionSet,
SolverError,
diophantine, expr_to_gbpoly, extract_regular_chain_from_basis, gbpoly_to_expr,
main_variable_recursive, solve_numerical, solve_polynomial_system, triangularize,
CertifiedPoint, DiophantineError, DiophantineSolution, HomotopyError, HomotopyOpts,
RegularChain, Solution, SolutionSet, SolverError,
};
pub use crate::stablehlo::emit_stablehlo;
pub use crate::sum::{
Expand Down
8 changes: 8 additions & 0 deletions alkahest-core/src/poly/groebner/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ impl GroebnerBasis {
&self.generators
}

/// The monomial order the generators were reduced under.
///
/// Needed to interpret leading terms of [`Self::generators`] and to build a
/// compatible polynomial before calling [`Self::reduce`].
pub fn order(&self) -> MonomialOrder {
self.order
}

/// Reduce a polynomial by this basis. Returns the remainder.
pub fn reduce(&self, p: &GbPoly) -> GbPoly {
reduce(p, &self.generators, self.order)
Expand Down
9 changes: 9 additions & 0 deletions alkahest-core/src/poly/groebner/monomial_order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ impl MonomialOrder {
_ => None,
}
}

/// The canonical name, round-tripping through [`Self::from_str`].
pub fn as_str(self) -> &'static str {
match self {
MonomialOrder::Lex => "lex",
MonomialOrder::GrLex => "grlex",
MonomialOrder::GRevLex => "grevlex",
}
}
}

#[cfg(test)]
Expand Down
Loading
Loading