alkahest.ansatz
Ansatz families — structured conjecture generation for search loops.
P2 item 1 — see docs/mdbook/src/ansatz.md.
Stage 1 of an autoresearch loop is generate: propose a structured family of candidate objects ("every polynomial of degree ≤ 3 in x and y", "a Padé approximant of type (2, 2)", "a quadratic Lyapunov candidate"), then either sweep it numerically or solve for the coefficients that make some residual vanish. Agents hand-roll this constantly, and the hand-rolled version is usually wrong in one of three specific ways:
- it loses the distinction between an unknown coefficient and an independent variable, so the follow-up substitution solves for the wrong thing;
- it assumes the first m sample points give m independent equations, which is false exactly when the family is degenerate — the interesting case;
- it never substitutes the fitted answer back, so a fit that only satisfies the sampled constraints is reported as if it satisfied the identity.
This module is that plumbing, done once. Ansatz keeps the unknowns and
the variables apart; fit() over-samples and reads the rank off the
reduced row echelon form rather than assuming independence; and fit(..., certify="residual")
— the default — substitutes the solution back and only reports
"exactly_verified" when the residual provably normalises to zero.
Honesty invariants
Solving may be heuristic; checking is exact. The linear system is built by
collocation (probing the residual at sample points), which is a sufficient
condition for identical vanishing only for polynomial residuals of bounded
degree. So the fit is never trusted on its own: it is substituted back and
normalised. A solution that normalises to zero is
verification["status"] == "exactly_verified"; one that does not is returned
intact but labelled "numerically_checked", with the surviving residual
stated in verification["residual"]. There is no status meaning "solved".
Inconsistent is a result, not a malfunction. When no member of the family
can satisfy the constraints, fit() raises
AnsatzError E-ANSATZ-003. For a loop that is a closed
branch — a positive finding worth recording — in the same spirit as a
non-elementarity verdict from integrate().
Underdetermined is also a result. When the rank is below the number of
unknowns, the members that work form a positive-dimensional family. That is
often the answer the loop wanted, so AnsatzSolution.free returns the
free parameters rather than picking an arbitrary member of the space.
Quick start
>>> import alkahest as ak
>>> from alkahest.ansatz import polynomial, fit
>>> pool = ak.ExprPool()
>>> x = pool.symbol("x")
>>> A = polynomial(pool, [x], degree=2)
>>> len(A.unknowns)
3
>>> target = x**2 - pool.integer(3) * x + pool.integer(2)
>>> sol = fit(A, A.expr - target)
>>> sol.status
'exactly_verified'
>>> sol.rank, sol.free
(3, ())Everything here is pure Python composed from primitives that are already fast
in Rust (Matrix.rref, simplify, subs), so it works in a build
without the groebner feature — see fit() for the one path that needs
it, and refuses rather than degrading when it is absent.
A parametric family of expressions with named unknown coefficients.
An ansatz is an object rather than a bare Expr because a
bare expression loses the distinction between an unknown coefficient and
an independent variable, and every downstream step needs it. Construct
one with polynomial(), rational(), linear_combination(),
exponential_polynomial(), or quadratic_form() rather than
directly.
exprExprThe family member with symbolic coefficients, e.g.
c_0 + c_1*x + c_2*x^2.unknownstuple of ExprThe coefficient symbols, in a deterministic order (graded, then lexicographic by exponent).
basistuple of ExprParallel to unknowns: the expression each unknown multiplies. For
rational()this is the numerator basis followed by the denominator basis, since the two blocks multiply into different places — readmetadata["numerator_terms"]for the split.varstuple of ExprThe independent variables. Disjoint from unknowns by construction.
familystrWhich constructor produced this, e.g.
"polynomial".namestrPrefix used for the coefficient symbol names.
poolExprPoolPool everything is interned in.
metadatadictFamily-specific detail (degree, rates, the numerator/denominator split).
>>> import alkahest as ak
>>> from alkahest.ansatz import polynomial
>>> pool = ak.ExprPool()
>>> x, y = pool.symbol("x"), pool.symbol("y")
>>> A = polynomial(pool, [x, y], degree=1, name="c")
>>> A.names
('c_0_0', 'c_1_0', 'c_0_1')
>>> A.expr
(c_0_0 + (x * c_1_0) + (y * c_0_1))Coefficient symbol names, parallel to unknowns.
Names are predictable, never gensym-ed: an agent that cannot predict them cannot write the follow-up call.
Substitute coefficient values, returning a concrete member.
assignmentmapping{Expr or str: Expr or int or float}. Unknowns left out stay symbolic, which is how an underdetermined fit is returned.simplifyboolFold the substituted expression (so a zeroed coefficient really disappears). Turn off for a hot enumeration whose consumer normalises anyway.
- Expr
>>> import alkahest as ak
>>> from alkahest.ansatz import polynomial
>>> pool = ak.ExprPool()
>>> x = pool.symbol("x")
>>> A = polynomial(pool, [x], degree=1)
>>> A.instantiate({"c_0": 2, "c_1": -1})
(2 + (x * -1))The residual whose vanishing means "this member equals target".
For most families this is just self.expr - target. For
rational() it is the denominator-cleared form
numerator - target * denominator, which is affine in the unknowns
where p/q - f is not. Passing the naive difference to fit()
works too — it clears denominators itself — but this states the intent.
>>> import alkahest as ak
>>> from alkahest.ansatz import rational
>>> pool = ak.ExprPool()
>>> x = pool.symbol("x")
>>> A = rational(pool, [x], num_degree=1, den_degree=1)
>>> A.residual(pool.integer(1))
(a_0 + (x * a_1) + ((1 + (x * b_1)) * -1))The outcome of fitting an Ansatz.
Shaped so that research.ResearchSession.record() accepts it
unchanged: it exposes .value, .steps, .verification, and
.certificate exactly as a DerivedResult does, and
verification["status"] is one of the existing
research.STATUS_BADGES keys — no new vocabulary.
exprExprThe fitted member. When the system is underdetermined this still contains the free unknowns, symbolically.
assignmentdict{unknown: Expr}for the determined coefficients. Values may mention the free unknowns.freetuple of ExprUnknowns the constraints do not pin down. Non-empty means the members that work form a positive-dimensional family — often the interesting answer, so no arbitrary member is picked for you.
rankintRank of the collocation system;
rank == len(ansatz)iff the fit is unique.statusstrMirrors
verification["status"]:"exactly_verified","numerically_checked", or"unverified".verificationdict{"status", "evidence", "method", "externally_verified", ...}."residual"carries the surviving normal form when the exact check did not close, and"max_abs_residual"the worst numeric sample.stepstuple of dictDerivation log in the
STEP_FIELDSschema.residualExprThe residual as fitted (after any denominator clearing).
checkdictA re-verification recipe in the shape
research.ClaimGraph.verify()understands ({"kind": "zero", "expr": ...}— the back-substituted residual, which must normalise to zero). Pass it through assession.record(solution, check=solution.check)so a graph loaded from disk months later can re-derive the check rather than trust it.pointstupleThe rows' provenance, as nested rational strings: the sample points actually used for
certify="residual", or the multi-indicesαforcertify="exact". Enough to reproduce the system.ansatzAnsatzThe family this came from.
certificateNoneAlways
None: this module emits no Lean certificate. Withholding beats emitting one nothing checked.
DEFAULT_MAX_MEMBERS = 100000No docstring.
DEFAULT_MAX_TERMS = 256No docstring.
DEFAULT_SEED = 1592631573No docstring.