mixle.evolve package

mixle.evolve – the self-improvement core: measure -> propose -> verify -> promote.

A pure, serving-agnostic orchestration algebra over capabilities that already exist in the library. It adds no new modeling capability: the proper scores and calibration diagnostics (mixle.inference.scoring / mixle.inference.calibration), the comparison tests (mixle.inference.model_comparison), the fitters (mixle.inference.estimation.optimize(), the streaming estimators, mixle.utils.automatic.get_estimator()), and the decision layer (mixle.inference.decision.bayes_action()) are all pre-existing. evolve wires them into a single anti-regression loop.

Core surface:

  • measure – Objective + nll / log_score / crps / interval / calibration / decision_regret builders.

  • propose – ImprovementOperator + Refit, OnlineUpdate, AutoSelect, Recalibrate, a scoped operator registry.

  • verify – challenger_beats_champion() + Verdict (the anti-regression gate).

  • drive – improve() + ImprovementResult, auto_select(), and the EvolutionLedger telemetry.

Search and population surface:

  • search – search() over a typed Space (Real / Integer / Categorical), method='evolutionary' / 'bandit' / 'bo', returning a SearchResult.

  • meta-search – Population + OperatorBandit (the policy that learns which operators help).

  • structure search – Recompose / Mutate (genetic-programming structural moves over the model’s compositional tree) + structural_distance() (a tree-edit genotype distance) driving the population’s diversity; registered but off by default (structural + expensive).

L1 surface (closed-loop self-evolution, mixle.evolve.closed_loop):

  • ClosedLoopSelfEvolution – harvest -> A5 acquire -> distill/refine/evolve challenger production -> challenger_beats_champion() -> deploy, as one budgeted background loop.

  • OperatorCreditBandit – a per-context meta-bandit (over mixle.task.bandit.UCB1) crediting WHICH challenger-production operator wins for which kind of failure.

  • GenealogyLedger – parent/operator/measured-gap receipts on every adopted champion, with a real lineage(model) walk-back.

improve(model, data, *, objective, operators=None, holdout=0.25, alpha=0.05, min_effect=0.0, budget=None, seed=0, ledger=None, parent_hash=None, require_calibration=True)[source]

Propose challengers, gate them, and return the best verified improvement (or the champion).

Parameters:
  • model (Any) – the champion (a fitted mixle distribution).

  • data (Sequence[Any]) – the raw held-out dataset (split once into train/verify here).

  • objective (Objective) – the Objective to improve on.

  • operators (Sequence[ImprovementOperator] | None) – the proposal moves; defaults to the applicable subset of [Refit, OnlineUpdate, AutoSelect, Recalibrate].

  • holdout (float | tuple) – train/verify split fraction, or an explicit (train, verify) tuple.

  • alpha (float) – significance level for the verify gate.

  • min_effect (float) – practical effect-size floor passed to the gate.

  • budget (float | None) – optional cost ceiling – operators whose cost_hint exceeds the remaining budget are skipped after ascending-cost ordering.

  • seed (int) – RNG seed for the split and the sampled objectives.

  • ledger (EvolutionLedger | None) – optional EvolutionLedger to record every attempt into.

  • parent_hash (str | None) – optional lineage hash for the champion (carried onto candidates and ledger rows).

  • require_calibration (bool) – forward to the gate’s calibration no-regression check.

Returns:

An ImprovementResult. result.verified is True guarantees result.model beat the champion significantly and non-regressively on the verify split.

Return type:

ImprovementResult

class ImprovementResult(model, verified, operator, delta, verdict, evidence=<factory>, parent_hash=None)[source]

Bases: object

The outcome of an improve() run.

Parameters:
  • model (Any)

  • verified (bool)

  • operator (str | None)

  • delta (float)

  • verdict (Verdict | None)

  • evidence (dict)

  • parent_hash (str | None)

auto_select(data, *, space=None, criterion='bic', verify=True, holdout=0.25, seed=0, max_its=20)[source]

Infer and fit a model from raw data, optionally gated by a held-out proper score.

Parameters:
  • data (Sequence[Any]) – the raw dataset.

  • space (Any | None) – reserved for typed search-space selection; must currently be None for auto_select.

  • criterion (str | Objective) – 'bic' (delegate to the automatic in-sample pick) or a proper-score Objective (add the held-out verify gate on top of BIC).

  • verify (bool) – when criterion is an Objective, whether to run the held-out gate (the BIC pick fitted on the train split is the champion; the challenger is the same automatic family refit on that SAME train split, so the comparison on the held-out data is genuinely held out. Promotion deploys a fresh refit of the verified winner on all the data – never the object that was itself scored – so a promoted model still benefits from the held-out split’s extra evidence).

  • holdout (float) – held-out fraction for the proper-score gate.

  • seed (int) – RNG seed for the split and sampled objectives.

  • max_its (int) – EM iterations for the fits.

Returns:

An ImprovementResult. For criterion='bic' it carries the fitted automatic model with verified=False (no out-of-sample test was requested). For an Objective criterion with verify=True it carries the gate verdict and verified reflects whether the train-only challenger beat the train-only champion out of sample.

Return type:

ImprovementResult

class Objective(*args, **kwargs)[source]

Bases: Protocol

A lower-is-better-or-higher-with-a-flag scalar fitness with an optional paired vector.

pointwise(model, data)[source]

The (n,) per-observation score, or None if the objective is scalar-only.

Parameters:
Return type:

ndarray | None

scalar(model, data)[source]

The single comparable fitness (mean of pointwise by default).

Parameters:
Return type:

float

nll_objective()[source]

Negative log-likelihood: per-obs -log p(y_i) (strictly proper, lower is better).

Return type:

Objective

log_score_objective()[source]

Logarithmic score (log loss) of the predictive density at the realised outcomes.

Computed directly from the log-density (-log p(y_i)), never by exponentiating the log-density to a plain probability and then re-deriving a log-scale score from it. That round trip is destructive: exp(x) underflows to exactly 0.0 for any x below about -745 (the float64 underflow boundary), so every log-density past that point becomes the identical clipped probability and hence the identical score, even though the underlying fits are genuinely – and very differently – bad. Working from the log-density keeps that information: float64 has ample range in log-space, just not in probability-space.

Return type:

Objective

crps_objective(*, ensemble=256, seed=0)[source]

Continuous Ranked Probability Score from a sampled predictive ensemble (lower is better).

Parameters:
  • ensemble (int) – number of predictive draws per observation (an exact positive integer).

  • seed (int) – RNG seed for the ensemble (reproducible).

Return type:

Objective

interval_objective(level=0.9, *, ensemble=256, seed=0)[source]

Winkler interval score for the central level predictive interval (lower is better).

Parameters:
  • level (float) – central coverage of the interval (e.g. 0.9 for a 90% interval).

  • ensemble (int) – number of predictive draws used to read off the interval endpoints (an exact positive integer).

  • seed (int) – RNG seed for the ensemble.

Return type:

Objective

calibration_objective(*, ensemble=256, seed=0, bins=10)[source]

PIT calibration error of the predictive distribution (scalar-only, lower is better).

Uses the rank-based Probability Integral Transform of a sampled ensemble: under a calibrated continuous forecast the PIT values are Uniform(0, 1), and pit_calibration_error measures the histogram’s mean absolute deviation from uniform. There is no per-observation paired vector for a histogram statistic, so pointwise returns None and the verify gate falls back to a bare scalar comparison with no p-value or CI, reported for human review only – see this module’s docstring and mixle.evolve.verify module docstring point 8.

(For classification models the natural calibration scalar is mixle.inference.calibration.expected_calibration_error(); this builder targets the continuous-predictive case, which is the common one for the streaming/auto-select loop.)

ensemble and bins must both be exact positive integers – they are the budget the reported calibration error is computed from.

Parameters:
Return type:

Objective

decision_regret_objective(loss, actions, *, n=2000, seed=0, vectorized=None)[source]

Realized decision regret of a model’s chosen action against actual data (scalar-only, lower is better).

For the chosen Bayes action a* = bayes_action(posterior(model), loss, actions) – optimal under the model’s OWN predictive belief – this reports the realized loss mean(loss(a*, y_i) for y_i in data) against the actual data, not draws from that same model’s own posterior. Scoring against self-generated draws would let a confidently wrong model look flawless: its chosen action and its “outcomes” both come from the same wrong belief, so they always agree with each other regardless of how far that belief is from reality. Checking the chosen action against real, shared data is what makes this a genuine promotion metric rather than a self-consistency check. Scalar-only (this is a single realized-loss number per model, not a per-observation quantity), so pointwise returns None.

Parameters:
  • loss (Callable[[Any, Any], float]) – loss(action, draw) -> float (or a numpy-vectorized loss(action, draws) -> array).

  • actions (Sequence[Any]) – the finite candidate-action set.

  • n (int) – posterior draws used only to pick the Bayes-optimal action under the model’s own predictive belief; the reported score is the realized loss of that action on data.

  • seed (int) – RNG seed for the action-selection draws.

  • vectorized (bool | None) – the loss’s calling convention, forwarded to bayes_action() and used for the realized-loss evaluation. True -> called once with the whole outcome array; False -> called once per outcome; None (default) -> read loss.vectorized if the loss declares it, else auto-detect by probing the array call once. A probe is a real invocation of the loss, and a search scores many models against the same loss, so the resolved convention is reused for the rest of the search rather than re-probed per model. A loss that keeps state or has side effects should declare its convention.

Return type:

Objective

challenger_beats_champion(champion, challenger, data, *, objective, alpha=0.05, min_effect=0.0, require_calibration=True, nonnested=False, multiplicity=None, calib_tol=1.0e-3, seed=0, elpd_pointwise=None)[source]

Decide whether challenger significantly and non-regressively beats champion on data.

Parameters:
  • champion (Any) – two fitted models scored on the same held-out data.

  • challenger (Any) – two fitted models scored on the same held-out data.

  • data (Any) – the held-out responses (one batch -> both models scored in the same order).

  • objective (Objective) – the Objective to compare on.

  • alpha (float) – significance level for the paired test (and the CI is at 1 - alpha).

  • min_effect (float) – practical effect-size floor on |mean score difference|.

  • require_calibration (bool) – if True, run the calibration no-regression check.

  • nonnested (bool) – if True (a family swap), additionally require Vuong + Clarke to favor the challenger.

  • multiplicity (str | None) – must stay None. A single champion/challenger comparison produces exactly one p-value, so it can never be corrected for multiplicity in isolation – passing a method name here raises rather than silently no-op’ing (every mixle.inference.multiple_testing method is the identity transform at family size 1). Kept as a parameter only to fail loudly on the mistake instead of TypeError’ing on removal; pool the raw p-values from every simultaneous comparison and call mixle.inference.multiple_testing.adjust_pvalues() once yourself, then compare each adjusted p-value to alpha.

  • calib_tol (float) – tolerance on the calibration-error increase the challenger may carry.

  • seed (int) – RNG seed for the (sampled) calibration scalars.

  • elpd_pointwise (tuple[ndarray, ndarray] | None) – optional (pointwise_champion, pointwise_challenger) LOO/WAIC arrays; when given, the compare_elpd() 2-SE band is required to also favor the challenger.

Returns:

A Verdict. verdict.promote is the single promotion predicate; it is always False for a scalar-only objective, regardless of favored – see module docstring point 8 and Verdict.has_statistical_evidence.

Raises:

ValueError – if a policy value is outside its domain (see _check_policy()), or if a pointwise vector does not carry exactly one score per held-out row (see _check_pointwise()).

Return type:

Verdict

class Verdict(favored, delta, p_value, ci, calibration_status, evidence=<factory>)[source]

Bases: object

The outcome of a single champion/challenger comparison.

Parameters:
property calibrated: bool

True unless calibration regressed, or its computation broke unexpectedly.

Kept for callers that only need the promotion-relevant boolean; calibration_status carries the more precise state a bare bool cannot: ‘unavailable’ (calibration wasn’t requested, or is genuinely inapplicable to this model/data – an explicit, checked applicability decision, not itself evidence of a regression, so it does not block promotion, the same as ‘passed’) is distinct from both ‘failed’ (calibration was computed for both models and the challenger’s is worse by more than calib_tol) and ‘error’ (the computation raised unexpectedly despite looking applicable – an implementation defect, not a legitimate skip). ‘failed’ and ‘error’ are the only statuses that block promotion; an ‘error’ must not silently read the same as a pass.

property has_statistical_evidence: bool

True unless this verdict came from a scalar-only objective with no paired test to run.

A scalar-only objective (module docstring point 8) has no per-observation vector to pair, so p_value/ci are the explicit nan “not applicable” sentinel: there is no sampling-uncertainty estimate – no p-value, CI, replication, or bootstrap – behind the raw scalar delta at all. Automatic promotion requires this to be True; a scalar-only win is still reported (favored/delta/evidence['scalar_only']) for a human to review, but it can never auto-promote on its own – see promote.

property promote: bool

True iff the challenger is favored, calibration didn’t fail or error, and the verdict is backed by an actual statistical test (see has_statistical_evidence) – the single promotion predicate.

as_dict()[source]

Serialize the verdict into JSON-compatible primitive fields.

Return type:

dict[str, Any]

class ImprovementOperator(*args, **kwargs)[source]

Bases: Protocol

A uniform proposal move: an applicability pre-flight plus a fitted-challenger propose.

applicable(model, data, *, ctx)[source]

Structural gate: whether this operator can run on the model and data.

Parameters:
Return type:

bool

propose(model, data, *, ctx)[source]

Return a fitted challenger (or raise if the proposal cannot be built).

Parameters:
Return type:

Candidate

class Candidate(model, operator, parent_hash=None, meta=<factory>)[source]

Bases: object

A proposed, already-fitted challenger plus the provenance of how it was made.

Parameters:
  • model (Any)

  • operator (str)

  • parent_hash (str | None)

  • meta (dict)

class Refit(name='refit', cost_hint=1.0, max_its=20)[source]

Bases: object

Re-fit the champion’s family on fresh data, warm-started from the champion’s parameters.

Parameters:
applicable(model, data, *, ctx)[source]

Return whether model can be re-fit on a non-empty batch.

Parameters:
Return type:

bool

propose(model, data, *, ctx)[source]

Fit the model family on data using model as the warm start.

Parameters:
Return type:

Candidate

class OnlineUpdate(mode='streaming', cost_hint=0.2)[source]

Bases: object

Fold a fresh batch into the champion via a streaming estimator.

mode:
  • 'streaming' – decay-mode StreamingEstimator (running-accumulator forgetting).

  • 'incremental' – Neal-Hinton IncrementalEstimator (replace one chunk).

  • 'posterior_carry' – exact recursive-Bayes BayesianStreamingEstimator (needs a

    conjugate family; applicable checks ConjugateUpdatable).

  • 'forgetting' – power-prior BayesianStreamingEstimator.

Parameters:
property name: str

Registry name including the selected online-update mode.

applicable(model, data, *, ctx)[source]

Return whether the selected update mode is legal for model and data.

Parameters:
Return type:

bool

propose(model, data, *, ctx)[source]

Apply the selected streaming update and return the updated challenger.

Parameters:
Return type:

Candidate

class AutoSelect(name='auto_select', cost_hint=3.0, max_its=20)[source]

Bases: object

Infer an estimator from the raw data (get_estimator) and fit it – a possible family swap.

Parameters:
applicable(model, data, *, ctx)[source]

Return whether there is data available for automatic family selection.

Parameters:
Return type:

bool

propose(model, data, *, ctx)[source]

Infer an estimator from data, fit it, and return the fitted challenger.

Parameters:
Return type:

Candidate

class Recalibrate(name='recalibrate', cost_hint=0.5, ensemble=256, seed=0, grid=(0.6, 0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 2.0))[source]

Bases: object

Learn a predictive spread temperature T that flattens the PIT, no parameter refit.

applicable requires a sampler (used both to estimate the predictive center and to evaluate PIT calibration). The temperature is chosen on the train split by minimising the PIT calibration error over a small grid; T == 1 (the identity) is always in the grid, so the recalibrated model can never be worse-calibrated than the base on the fitting data.

Parameters:
applicable(model, data, *, ctx)[source]

Return whether model can be sampled and the batch is non-empty.

Parameters:
Return type:

bool

propose(model, data, *, ctx)[source]

Search the temperature grid and wrap model in the best calibration transform.

Parameters:
Return type:

Candidate

class Recompose(name='recompose', cost_hint=4.0, max_its=30)[source]

Bases: object

Propose a richer two-component mixture structure for the champion family.

Each component is warm-fit on a different half of the data so EM starts away from the identical-component solution. The normal verification gate decides whether the additional structure improves held-out evidence enough to promote. The operator is registered but omitted from the default set because it is intentionally more expensive than the conservative updates.

Parameters:
applicable(model, data, *, ctx)[source]

Return whether the model can be re-estimated on enough rows for a split.

Parameters:
Return type:

bool

propose(model, data, *, ctx)[source]

Fit a two-component mixture challenger initialized from data splits.

Parameters:
Return type:

Candidate

class Mutate(name='mutate', cost_hint=4.0, max_its=30)[source]

Bases: object

Apply a random structural mutation to the champion and refit.

Available moves are grow for adding a bootstrap-fit component, shrink for dropping the lowest-weight component from an existing mixture, and perturb for a bootstrap re-fit. Repeated use inside a Population gives the search driver a structure-induction move, while the verification gate remains responsible for promotion. The operator is registered but omitted from the default set because it is comparatively expensive.

Parameters:
applicable(model, data, *, ctx)[source]

Return whether the model can be structurally mutated and re-fit.

Parameters:
Return type:

bool

propose(model, data, *, ctx)[source]

Sample one structural move, refit it, and return the resulting challenger.

Parameters:
Return type:

Candidate

structural_distance(a, b)[source]

A symmetric [0, 1] genotype distance between two models’ structures (tree-edit distance, size-normalized).

Parameters:
Return type:

float

model_signature(model)[source]

The compositional structure of model as a (type_label, [child signatures]) tree.

Parameters:

model (Any)

Return type:

tuple[str, list]

tree_edit_distance(a, b)[source]

Unordered tree-edit distance: relabel cost (0/1) + minimum-cost matching of children, with unmatched subtrees fully inserted/deleted. Symmetric, zero exactly on identical trees, and non-negative.

Parameters:
Return type:

int

register_operator(operator)[source]

Register operator in the scoped evolve operator registry (returns it for decorator use).

Parameters:

operator (ImprovementOperator)

Return type:

ImprovementOperator

unregister_operator(name)[source]

Remove a previously-registered operator by name (no-op if absent).

Parameters:

name (str)

Return type:

None

registered_operators()[source]

A copy of the current scoped operator registry.

Return type:

dict[str, ImprovementOperator]

default_operators()[source]

The Phase-1 default operator set: refit, online update, auto-select, recalibrate. (Recompose + Mutate are structural + expensive, so they are available via the registry but not enabled by default.)

Return type:

list[ImprovementOperator]

class EvolutionLedger(_rows=<factory>)[source]

Bases: object

An ordered, JSON-serializable, integrity-chained log of improvement attempts.

Parameters:

_rows (list[dict[str, Any]])

property rows: tuple[dict[str, Any], ...]

a tuple of deep copies of the recorded rows.

The backing list used to be the public attribute holding the live row dicts, so a caller could edit a recorded delta or verdict in place, or clear() the whole trail, with nothing to show it happened. Reading, indexing, iterating and json.dumps-ing the returned rows all work as before; writing to them simply does not reach the ledger.

Type:

A read-only view

record(*, operator, delta, verdict, cost, parent_hash, meta=None)[source]

Append one attempt row and return a copy of it.

The returned dict is a deep copy, not the stored object: the previous behaviour handed back the exact row still held by the ledger, so a caller that adjusted a field on “its” result silently rewrote the recorded attempt.

delta and cost are the row’s two measurements and must be finite; cost must also be non-negative, since it is spend, not a refund. See _finite_number(). (A nan p-value inside verdict is a different thing entirely – a documented “no paired test was run” sentinel – and is preserved, exactly, by the serialization codec.)

Parameters:
Return type:

dict[str, Any]

head()[source]

The chain’s current tip: the last row’s row_hash, or _GENESIS_HASH if empty.

This is the anchor verify() needs to detect truncation. Record it somewhere the ledger ‘s own storage cannot reach – a run report, a build artifact, a separate durable store – each time you finish appending; passing it back to verify(expect_head=...) is what turns “these rows are self-consistent” into “these are the rows that were written”.

Return type:

str

verify(*, expect_length=None, expect_head=None)[source]

Whether every row still matches its digest and its place in the chain.

Checks, per row: the sequence number matches its position, prev_hash matches the previous row’s row_hash (_GENESIS_HASH for the first), and row_hash still matches the row’s canonical content. A rewritten delta or verdict, a deleted or reordered attempt, and a front-truncated ledger all fail.

Truncation from the end cannot be detected from the rows alone, and no hash chain can do it. Deleting the last row leaves a shorter chain that is perfectly self-consistent – every remaining prev_hash still matches, every row_hash still verifies – because a prefix of a valid chain is a valid chain. Detecting it needs one fact the ledger does not hold: how long the chain is supposed to be, or what its tip was. Supply either and the check becomes real:

head = ledger.head() # store this outside the ledger … ledger.verify(expect_head=head, expect_length=n)

expect_head is the stronger of the two: it pins the exact final row, so it also rejects a ledger truncated and then re-extended to the original length. expect_length alone catches only a change in count. Omitting both is still meaningful – it proves internal consistency – but it is not proof that nothing was removed from the end.

Parameters:
  • expect_length (int | None)

  • expect_head (str | None)

Return type:

bool

to_json(**dumps_kwargs)[source]

Serialize the full ledger to a strict JSON string.

Non-finite floats anywhere in a row (a scalar-only verdict’s nan p-value, say) are encoded into the reversible tagged form _FLOAT_TAG documents, and allow_nan is then forced off so anything the encoder missed fails loudly here rather than emitting a bare NaN / Infinity token a conforming JSON parser refuses to read back. Round-trips exactly through from_json(), digests included.

Parameters:

dumps_kwargs (Any)

Return type:

str

classmethod from_json(s)[source]

Rebuild a ledger from to_json() output, checking the anchor it carries.

A document written by to_json() is an envelope holding the rows plus the length and chain tip they had when written; both are verified here, so a row deleted from the end – which verify() alone cannot see – is caught on load.

A bare JSON array is still accepted for documents written before the envelope existed. Those carry no anchor, so loading one proves only that the rows are internally consistent; pass a separately stored expect_head to verify() for the stronger check.

Parameters:

s (str)

Return type:

EvolutionLedger

search(space, data, *, objective, build_fn, method='bo', n_iter=25, holdout=0.25, seed=0, **method_kwargs)[source]

Search space for the config whose build_fn model scores best on a held-out split.

Parameters:
  • space (Space) – the typed Space to search.

  • data (Sequence[Any]) – the raw dataset (split once into train/val here for the inner objective).

  • objective (Objective) – the held-out Objective (lower-is-better aware).

  • build_fn (Callable[[dict[str, Any], list[Any]], Any]) – caller-supplied (config, train_data) -> fitted model (the search is family-agnostic). train_data is ALWAYS the train split search computed – never val – so a config can actually be fit to real data without risking a held-out leak.

  • method (str) – 'bo' (Bayesian optimization over the numeric box), 'evolutionary' (a (mu + lambda) loop over sample / neighbors), or 'bandit' (delegate the operator policy to an OperatorBandit).

  • n_iter (int) – the TOTAL evaluation budget (build/score attempts), consistently for bo and evolutionary – each reserves every evaluation against it before spending, so neither can exceed it (the evolutionary backend used to treat it as a generation count and spend mu + lam * n_iter). Both need at least one evaluation to produce any result, so n_iter<=0 is rejected with a clear error instead of silently spending an evaluation anyway. (bandit interprets n_iter as a generation count, unchanged – it delegates to Population, whose per-generation work depends on the operator policy rather than on a fixed offspring count.)

  • holdout (float) – held-out fraction for the inner objective.

  • seed (int) – RNG seed.

  • method_kwargs (Any) – backend-specific knobs (e.g. mu / lam for the evolutionary loop, operators / size for the bandit population).

Returns:

A SearchResult with best_config / best_model / best_score (native orientation) / history, plus search_failed / n_evaluations / n_successes for bo / evolutionary (see SearchResult).

Return type:

SearchResult

class SearchResult(best_config, best_model, best_score, history=<factory>, search_failed=False, n_evaluations=0, n_successes=0)[source]

Bases: object

The outcome of a search() (or Population.run()) run.

search_failed / n_evaluations / n_successes distinguish “we searched and found a real (if mediocre) model” from “every configuration failed and best_model is a hollow placeholder” – without them, a totally failed search (best_model=None, best_score at the internal penalty sentinel) is shaped identically to a real result. Every backend populates them, including the bandit one via mixle.evolve.population.Population.run() – it used to leave them at their defaults, so a run that evaluated seeds and generations still reported n_evaluations=0 and search_failed=False, which reads as a successful search that did no work.

Parameters:
class Space(dims)[source]

Bases: object

A typed, named search space over Real / Integer / Categorical dims.

Construct from a dict mapping each parameter name to its dimension:

space = Space({"mu": Real(-5, 5), "k": Integer(1, 4), "family": Categorical(["a", "b"])})

Dimension order is the insertion order of the dict; encode() / decode() and to_bounds() all use that same fixed order so the numeric vector and the box align.

Parameters:

dims (dict[str, Dimension])

property dims: MappingProxyType

The named dimensions, read-only.

This was a plain public dict. __init__ validates that a space is non-empty and that every dimension has a supported type, and then the caller could space.dims.clear() and get an object reporting ndim == 0 and sample() == {} – a space that the constructor would have refused to build, reached by mutating one it had just approved. Checks that run once only hold if the thing they checked cannot change afterwards. Build a new Space to change the dimensions.

property names: tuple[str, ...]

The fixed dimension order, always read straight from dims.

This was a tuple snapshotted in __init__ while dims stayed a public mutable dict, so adding or removing a dimension afterwards left the two disagreeing: ndim and every encode/decode kept using the old order and silently ignored the new dimension, while dims reported it as present. Deriving the order removes the possibility rather than documenting it.

property ndim: int

Number of dimensions in the fixed search-space order.

to_bounds()[source]

The continuous (low, high) box for the BO backend (categoricals as integer indices).

Return type:

list[tuple[float, float]]

sample(rng)[source]

Draw a random config dict, each dimension sampled natively.

Parameters:

rng (RandomState)

Return type:

dict[str, Any]

encode(config)[source]

Encode a config dict into the numeric vector (in the fixed dimension order).

Parameters:

config (dict[str, Any])

Return type:

ndarray

decode(vector)[source]

Decode a numeric vector (BO proposal) back into a config dict (rounding / clipping).

Parameters:

vector (Sequence[float])

Return type:

dict[str, Any]

neighbors(point)[source]

All configs one local move away in exactly one dimension (the evolutionary mutation set).

Parameters:

point (dict[str, Any])

Return type:

list[dict[str, Any]]

class Real(lo, hi)[source]

Bases: object

A continuous dimension over [lo, hi].

Both bounds must be finite. An infinite (or NaN) bound would construct fine but then silently poison downstream sample / neighbors draws with inf / nan (or crash deep inside NumPy’s RNG with an opaque OverflowError), far from this actual root cause – so it is rejected here instead.

Parameters:
bounds()[source]

Return the numeric bounds used by continuous optimizers.

Return type:

tuple[float, float]

sample(rng)[source]

Draw a uniformly distributed value from the interval.

Parameters:

rng (RandomState)

Return type:

float

encode(value)[source]

Clip and encode value as a floating-point coordinate.

Parameters:

value (Any)

Return type:

float

decode(x)[source]

Clip a numeric optimizer coordinate back into the interval.

Parameters:

x (float)

Return type:

float

neighbors(value)[source]

A coarse local move: +/- 10% of the range, clipped to bounds.

Parameters:

value (Any)

Return type:

list[float]

class Integer(lo, hi)[source]

Bases: object

An integer dimension over the inclusive range [lo, hi].

Parameters:
bounds()[source]

Return widened numeric bounds so rounding covers each integer level.

Return type:

tuple[float, float]

sample(rng)[source]

Draw an integer uniformly from the inclusive range.

Parameters:

rng (RandomState)

Return type:

int

encode(value)[source]

Round, clip, and encode value as a numeric coordinate.

Parameters:

value (Any)

Return type:

float

decode(x)[source]

Round and clip a numeric optimizer coordinate to an integer value.

Parameters:

x (float)

Return type:

int

neighbors(value)[source]

Return adjacent integer values within the range.

Parameters:

value (Any)

Return type:

list[int]

class Categorical(choices)[source]

Bases: object

An unordered categorical dimension over a finite list of choices.

choices must be unique under == (the same equality _index_of() uses to look a value up) – a duplicate raises ValueError at construction. Without this check, encode would silently collapse a duplicate to its first occurrence’s index while sample / bounds / the dimension’s own length would still count it as a distinct, independently-selectable level, so the dimension’s notion of “how many choices” would disagree with itself depending which path asked.

Parameters:

choices (tuple[Any, ...])

bounds()[source]

Return widened index bounds so rounding covers each categorical choice.

Return type:

tuple[float, float]

sample(rng)[source]

Draw one choice uniformly at random.

Parameters:

rng (RandomState)

Return type:

Any

encode(value)[source]

Encode a choice as its floating-point index.

Parameters:

value (Any)

Return type:

float

decode(x)[source]

Round and clip an optimizer coordinate back to a choice.

Parameters:

x (float)

Return type:

Any

neighbors(value)[source]

Return all choices except value.

Parameters:

value (Any)

Return type:

list[Any]

class Population(seeds, *, objective, operators=None, bandit=None, size=12, diversity_quota=2, holdout=0.25, seed=0)[source]

Bases: object

A diversity-preserving population of model structures, evolved by the OperatorBandit.

seeds are fitted models (the starting structures). Each step() selects operators via the bandit, applies them to parents chosen by fitness, gates the challengers with the Phase-1 champion/challenger rule – pooling the whole generation’s p-values for a single Benjamini-Hochberg correction before the per-candidate gate, since a generation produces many challengers at once – rewards the bandit with the verified deltas, and keeps the verified-best plus a coarse capability-diversity quota.

Data roles (MXR-080-0042). This class keeps three distinct data roles separate:

  1. Fit data – what operators actually fit challengers on (data in step() / run(), or its auto-derived fit-split when verify_data is omitted – see below).

  2. Reusable validation data (verify_data) – read by step() every single generation, to gate every challenger (challenger_beats_champion()) AND to score population fitness / drive survivor selection. When omitted, step() / run() derive a genuinely disjoint split from data (_auto_split()) rather than the old default of reusing data itself verbatim – but disjointness from the fit data is only half the fix. This SAME verify_data is then queried again, adaptively, by EVERY generation of a run(): survivor selection keeps whoever currently scores best against it, and OperatorBandit learns from whichever operators currently win against it. The per-generation Benjamini-Hochberg pool (see step()) only controls multiplicity WITHIN one generation’s simultaneous comparisons; it does nothing to correct for that same set being re-queried, and acted on, by every OTHER generation – the textbook way a held-out set stops being a fair test once you repeatedly tune against it. Treat every Verdict/score this class computes against a reused verify_data as a NOISY HEURISTIC that steers the search, never as a promotion-grade statistical claim on its own.

  3. One-shot final holdout – data never passed to this population as data or verify_data at any point, evaluated exactly ONCE via evaluate_holdout() after every intended step() / run() call is done. This is the only comparison this class makes that carries real evidentiary weight – a caller that needs a defensible promotion decision (as opposed to search guidance) must use it rather than trusting champion or a GenerationReport alone.

Parameters:
  • seeds (Sequence[Any]) – the initial fitted models (at least one).

  • objective (Objective) – the Objective to optimize (lower-is-better aware).

  • operators (Sequence[ImprovementOperator] | None) – the proposal-move pool; defaults to the Phase-1 safe set.

  • bandit (OperatorBandit | None) – an OperatorBandit over operators (built with the default policy if omitted).

  • size (int) – the carrying capacity of the population.

  • diversity_quota (int) – how many of size slots are reserved for capability-diverse members (the rest go to the fittest); the quota keeps the search from collapsing onto one structure too early.

  • holdout (float) – fraction of data reserved for the auto-derived verify_data split (_auto_split()) whenever a caller omits verify_data – irrelevant if the caller always passes an explicit, already-disjoint verify_data.

  • seed (int) – RNG seed for parent sampling, the bandit, and the auto-derived split.

step(data, verify_data=None)[source]

Run one generation: select -> propose -> gate -> reward -> survivor selection.

Parameters:
  • data (Any) – the pool operators FIT candidates from (data role 1, “fit data”, in the class docstring). When verify_data is omitted, data is split (_auto_split()) and only the fit-half is actually fit on – the other half becomes the auto-derived verify_data below, so a challenger is never fit and scored on the same rows.

  • verify_data (Any | None) – the held-out data used to score population fitness and to gate every challenger against its parent (challenger_beats_champion()) – data role 2, “reusable validation data”, in the class docstring: read again every generation, and its repeated, adaptive use across a run() is a HEURISTIC search signal, not evidence on its own – see evaluate_holdout() for the genuine one-shot check. Defaults to the verify-half of _auto_split() applied to data when omitted (never data itself – MXR-080-0042: the old default silently made verification equal training data). Pass an explicit, already-disjoint split here (e.g. search() does) to control the split yourself instead.

Return type:

GenerationReport

A generation proposes many challengers at once, so the gate applies a genuine population-wide Benjamini-Hochberg correction: every candidate is first compared on its own RAW p-value (pass 1 below), the whole generation’s raw p-values are then pooled and corrected together in ONE adjust_pvalues() call (pass 2), and only then is each candidate’s promotion finalized against its adjusted p-value (pass 3). Correcting each candidate’s single p-value in isolation – family size 1 – is the identity transform for every method in multiple_testing, which is why challenger_beats_champion() refuses to do that itself. This pooled correction operates WITHIN one generation only – it does not, and cannot, correct for the same verify_data being queried again by every OTHER generation of a run() (see the class docstring’s “Data roles” note and evaluate_holdout()).

A scalar-only self.objective (calibration_objective(), decision_regret_objective()) has no paired test to run at all – every candidate’s Verdict then carries p_value = nan by design (see that function’s scalar-only branch) – so pass 2 excludes non-finite p-values from the pooled family entirely rather than handing them to adjust_pvalues (which rejects non-finite input outright); such a candidate’s own unadjusted verdict.promote stands as its final promotion decision, since there is no p-value for a population-wide correction to act on – which, per promote, is now ALWAYS False for a scalar-only objective regardless of favored (no sampling-uncertainty estimate backs a bare scalar delta – see has_statistical_evidence).

run(data, verify_data=None, generations=5)[source]

Evolve for generations steps; return a SearchResult.

Parameters:
  • data (Any) – the pool operators fit candidates from each generation (data role 1, “fit data”, in the class docstring). When verify_data is omitted, data is split ONCE, up front (_auto_split()), and only the fit-half is fit on for every generation of this run.

  • verify_data (Any | None) – the held-out data used to score fitness and gate every challenger – data role 2, “reusable validation data” (see the class docstring’s “Data roles” note): the SAME split is reused for EVERY generation, and both survivor selection and OperatorBandit adapt to it, which is exactly the sequential/adaptive reuse the class docstring warns is a heuristic search signal, not evidence. Defaults to the verify-half of _auto_split() applied to data when omitted (never data itself – MXR-080-0042). Pass an explicit, already-disjoint split for a caller-controlled gate (e.g. search() always does).

  • generations (int) – number of step() calls.

Return type:

Any

The returned best_model is the run incumbent, guaranteed no worse than the best seed on whatever ``verify_data`` this run used (anti-regression is relative to that reused validation signal, not an unconditional guarantee about unseen data – see evaluate_holdout() for the one-shot check that actually earns that stronger claim). history is one row per generation (proposals / verified / score).

evaluate_holdout(holdout_data, *, reference=None, **verdict_kwargs)[source]

The one genuinely evidence-bearing check this class can make (data role 3, “one-shot final holdout”, in the class docstring) – call this EXACTLY ONCE, after every step() / run() call you intend to make is already done, on holdout_data that was never passed to this population as data or verify_data at any point.

Every Verdict / score step() computes internally along the way is checked against the SAME reused verify_data, generation after generation – a heuristic search signal, not a defensible promotion claim (see the class docstring’s “Data roles” note). This method is the genuine, single-look alternative: ONE challenger_beats_champion() comparison between reference (defaults to this population’s incumbent BEFORE its first step() / run() call – i.e. the best seed) and champion (the current incumbent).

Calling this more than once against the same holdout_data reintroduces exactly the adaptive-reuse problem it exists to avoid – treat holdout_data as spent after one call, the same as any other held-out test set.

Parameters:
  • holdout_data (Any) – fresh data this population has never scored anything on. Verifying that is the caller’s responsibility – Population has no way to check it from the object alone.

  • reference (Any | None) – the champion to compare champion against; defaults to the pre-run incumbent (see above). Pass the currently-deployed production model explicitly if that differs.

  • verdict_kwargs (Any) – forwarded to challenger_beats_champion() (e.g. alpha, min_effect, seed); nonnested defaults to whether reference and champion are different model types, but can be overridden.

Returns:

The one-shot Verdict; verdict.promote is the genuine, evidence-backed promotion decision.

Return type:

Verdict

property champion: Any

The current run incumbent (the best model seen, anti-regression guaranteed).

class OperatorBandit(operators, *, policy='thompson', decay=0.97, prior_cost_aware=True, seed=0)[source]

Bases: object

A non-stationary bandit over a fixed pool of ImprovementOperator.

select(k) returns the k highest-value operators under the chosen policy; reward folds a verified delta + cost back into the chosen arm; report is the “which operators help” artifact.

Policies:
  • 'thompson' – Beta-Bernoulli Thompson sampling on the win indicator (reward > 0), scaled by the mean positive reward, so an operator that wins rarely but big and one that wins often but small are compared on expected verified delta.

  • 'ucb' – UCB1 on the mean reward with a sqrt(2 ln N / n) exploration bonus.

Non-stationarity: each reward first multiplies every arm’s statistics by decay (a forgetting factor in (0, 1]), so old evidence fades and the policy can track a shifting best operator.

Parameters:
  • operators (Sequence[ImprovementOperator])

  • policy (str)

  • decay (float)

  • prior_cost_aware (bool)

  • seed (int)

value(name)[source]

The current policy value of operator name (a Thompson draw or the UCB index).

Parameters:

name (str)

Return type:

float

select(k=1)[source]

Return the k operators with the highest policy value (a fresh Thompson draw each call).

Parameters:

k (int)

Return type:

list[ImprovementOperator]

reward(op_name, delta, cost)[source]

Fold a verified delta (0 if the challenger was rejected) and cost into the arm.

Decays every arm first (non-stationarity), then updates the chosen arm. A delta is clipped at 0 below – a rejected challenger is a zero reward, never negative, matching the anti-regression guarantee (we never punish an operator for a rejected proposal beyond not rewarding it).

delta and cost must be finite (and cost non-negative). max(nan, 0.0) returns nan, so a single non-finite update used to poison the arm permanently: mean delta, mean cost, and every subsequent Thompson draw for it came back nan, and a nan policy value sorts unpredictably against real ones. Policy state is not a place to absorb an invalid measurement.

Parameters:
Return type:

None

report()[source]

Per-operator win-rate, mean verified delta, mean cost, and (decayed) pull count.

Return type:

dict[str, Any]

class ClosedLoopSelfEvolution(champion, *, objective, operators=None, context_fn=None, acquire_k=32, acquire_strategy='entropy', bandit_c=1.0, holdout=0.25, seed=0)[source]

Bases: object

The whole L1 loop, one budgeted step at a time: harvest -> acquire -> propose -> gate -> deploy, with per-context operator credit and genealogy receipts on every adoption.

Parameters:
  • champion (Any) – the initial fitted model (e.g. a CategoricalDistribution).

  • objective (Objective) – the Objective the loop optimizes (e.g. accuracy_objective()).

  • operators (dict[str, ImprovementOperator] | None) – challenger-production operators by name; defaults to default_challenger_operators().

  • context_fn (Callable[[Sequence[Any]], str] | None) – batch -> context key for the per-context bandit; defaults to a single global context ("default") when the caller has no domain/failure-type signal to condition on.

  • acquire_k (int) – how many harvested failures A5’s acquire prioritizes per step.

  • acquire_strategy (str) – the acquire() ranking strategy (default "entropy", the one that works for a marginal/non-conditional champion via _ConstantProbaAdapter).

  • holdout (float) – fraction of each arriving batch set aside, BEFORE harvest/acquire ever see it, as the default verify_data when a caller omits verify in step() – see step()’s “Note on verify” (MXR-080-0042). Irrelevant if the caller always passes an explicit verify.

  • seed (int) – RNG seed threaded through the bandit, the split, and the gate.

  • bandit_c (float)

step(batch, *, verify=None, context=None, budget_remaining=None)[source]

One cycle of the loop on one arriving batch of held-out-shaped observations.

  1. harvest failures under the CURRENT champion,

  2. rank them with A5’s acquire,

  3. pick a challenger-production operator via the per-context bandit,

  4. propose + gate the challenger (skipped if budget_remaining can’t afford it),

  5. score the model the step would deploy, while it is still only staged,

  6. commit as one transaction iff the gate promotes: genealogy receipt, deployment, bandit reward, and history entry, in that order.

Steps 5 and 6 are deliberately ordered that way. A step is all-or-nothing: either it returns a LoopStepResult that is also in history and whose champion/receipt/bandit state all agree, or it raises having changed nothing at all and can simply be retried. Scoring used to happen after the champion had already been swapped and its receipt written, so a scoring failure stranded the loop with a deployed challenger, an adoption row, and a bandit reward for a step that – as far as its caller and its own history were concerned – never happened.

Note on verify (MXR-080-0042 – data roles, see mixle.evolve.population’s module docstring for the fuller three-role framing this loop shares): train_data (what the challenger is fit on) is acquire’s top-priority SUBSET of batch. When verify is omitted, verify_data no longer defaults to the whole batch (which CONTAINS train_data, so the gate used to score a challenger on rows it had just been fit on – weaker evidence than a disjoint split). Instead, batch is split (_split(), fraction self.holdout) into a candidate pool and verify_data FIRST, before harvest/acquire ever see it, and ONLY the candidate pool is harvested + ranked for train_data. This is deliberately a RANDOM split up front, not batch minus whatever acquire happened to pick: acquire selects the pool’s most informative/hardest-to-classify items on purpose (that is the whole point of active acquisition), so its complement is systematically skewed toward whatever the CURRENT champion already gets right – an unrepresentative, biased verify set that would under-detect exactly the improvement the loop exists to find, which is arguably worse than the overlap it would replace. Splitting first keeps verify_data a fair, representative sample of batch’s actual distribution, genuinely disjoint from anything harvest/acquire/the challenger’s fit ever touches. Callers who want to control the split themselves (or whose batch is already disjoint from wherever train_data will come from) should pass an explicit verify batch instead. Note too that ONE step’s gate decision (verdict.promote above) is a fresh, one-shot look at that step’s own (now-disjoint, representative) data – the per-step promotion decision itself is evidence-bearing. What is only a heuristic is the per-CONTEXT OperatorCreditBandit’s ACCUMULATED reward across many steps: exactly like Population’s bandit, it is fine when each step draws fresh data (the normal run()-over-a-stream usage), but a caller who calls step() repeatedly against the SAME (or overlapping) verify/batch data should treat the bandit’s learned preferences as search guidance, not as a promotion-grade statistical claim about any one operator.

Note on budget_remaining: when given, the bandit-selected operator’s own cost_hint is checked against it BEFORE the operator runs – an operator whose cost exceeds what remains is skipped exactly like an inapplicable one (no propose/gate attempted, nothing charged). result.cost reports what this step actually charges: the operator’s cost_hint iff the attempt was genuinely applicable and completed without raising, 0.0 otherwise – see run(), which sums result.cost to track total spend. It must be a finite, nonnegative amount: every budget comparison here is an ordered one, and every ordered comparison against NaN is false, so a NaN ceiling is not a ceiling (_budget_amount()).

Parameters:
Return type:

LoopStepResult

run(stream, *, verify_batches=None, contexts=None, budget=None)[source]

Run the loop over a SEQUENCE of batches (a stream) – the budgeted background loop’s outer iteration. budget (if given) is a ceiling on total spend: each step’s operator cost is checked against the REMAINING budget and must fit BEFORE that operator runs (see step()’s budget_remaining), and only result.cost – what the step actually charged – accumulates spent, so a skipped-by-budget, inapplicable, or failed attempt is genuinely free, exactly as documented; only a genuinely-applicable, genuinely-completed attempt is charged its cost_hint. The loop stops early once the budget is fully spent, leaving the champion at whatever it last became.

verify_batches and contexts, when given, are per-step and must line up ONE-TO-ONE with stream. Both are validated here, before the first step runs, rather than being indexed as the loop goes (MXR-080-1902): a short contexts used to raise IndexError part-way through, after the earlier steps had already deployed champions, written adoption receipts, rewarded per-context bandit arms and appended history – an argument error discovered only once it had produced durable, half-finished state that no return value described. A long one was worse, because it raised nothing at all: the surplus entries were silently dropped, so a caller who had mis-zipped their stream against their contexts got a completed-looking run in which every step had been attributed to the wrong context. budget is validated the same way and for the same reason (_budget_amount()).

Parameters:
Return type:

list[LoopStepResult]

class LoopStepResult(context, operator, promoted, delta, champion_score, champion, cost=0.0)[source]

Bases: object

One ClosedLoopSelfEvolution.step() outcome.

cost is what this step actually charges against a ClosedLoopSelfEvolution.run() budget: the selected operator’s cost_hint iff the attempt was genuinely applicable and completed without raising, 0.0 otherwise – skipped-by-budget, inapplicable, and failed attempts are all free.

Parameters:
class OperatorCreditBandit(operator_names, *, c=1.0, seed=0)[source]

Bases: object

A per-context meta-bandit over challenger-production operators, wrapping one mixle.task.bandit.UCB1 per context (e.g. a failure type/domain) – so the loop learns, independently for each context, which operator actually produces winning challengers there.

Parameters:
select(context)[source]

The bandit-chosen operator name for context.

Parameters:

context (str)

Return type:

str

reward(context, operator, reward)[source]

Fold an observed (non-negative, anti-regression) reward back into operator’s arm.

reward must be finite: max(nan, 0.0) is nan, which would poison the arm’s running mean and every selection that reads it, permanently.

Parameters:
Return type:

None

report()[source]

Per-context, per-operator mean reward and pull count.

Return type:

dict[str, dict[str, float]]

class GenealogyLedger(ledger=<factory>)[source]

Bases: object

Genealogy receipts for adopted (gate-passing) champions, built directly on EvolutionLedger (never storing model objects in the ledger rows themselves – only their operator, measured gap, and a stable id – exactly the ledger’s own JSON-serializability discipline).

A model’s id is DURABLE and content-addressed (mixle.data.hashing.model_hash(), a hex SHA-256 over its serialized parameters) – never the model object’s id() (a CPython memory address). That makes ids stable across processes (a model reloaded from disk hashes to the exact same id it had when first recorded, so lineage() finds it) and immune to id()-reuse aliasing (once a model is garbage-collected, an unrelated new object reallocated at the same address can never be mistaken for it, because the id no longer depends on the address at all). Combined with the ledger rows’ existing JSON-serializability, the lineage this class records is genuinely replayable: every id in a persisted ledger can be recomputed independently from any model, in any later process.

Parameters:

ledger (EvolutionLedger)

record_adoption(*, parent, child, operator, gap, context, meta=None)[source]

Record ONE adoption: child replaced parent via operator, measured gap (the verified challenger-beats-champion delta). parent=None marks the root of a lineage.

Parameters:
Return type:

dict[str, Any]

lineage(model)[source]

Reconstruct model’s full lineage: the ordered (root-first) chain of adoption receipts {operator, delta (the measured gap), parent_hash, meta: {child_hash, context, ...}} back to the first recorded ancestor. Returns [] if model was never recorded as an adoption – including if model can’t be hashed at all (e.g. it isn’t a fitted mixle model), which is just another way of never having been recorded. Because the id is content-addressed (see class docstring), this works for ANY object whose serialized content matches a previously-recorded one – not only the exact live Python instance record_adoption() was originally called with – e.g. the same model reloaded from disk in a fresh process.

Parameters:

model (Any)

Return type:

list[dict[str, Any]]

accuracy_objective()[source]

Higher-is-better accuracy for a model exposing a single “most likely label” prediction (a fitted CategoricalDistribution’s argmax(pmap)): per-observation 1{y == predicted_label}.

Return type:

Objective

harvest_failures(champion, batch, objective)[source]

The objective-generic analog of mixle.task.router.Router.harvested(): the observations in batch where champion did NOT score at its own best-attainable pointwise value under objective – for accuracy_objective() this is exactly “the cases it got wrong.” A scalar-only objective (no honest per-observation vector) harvests the whole batch – there is no finer signal to rank on.

Parameters:
Return type:

list[Any]

harvested_from_router(router)[source]

Pull the REAL harvested failure pool from a live mixle.task.router.Router: every input that escalated all the way to the frontier, paired with its frontier label – exactly router.harvested(), just zipped into one pool for mixle.task.acquire.acquire().

Parameters:

router (Any)

Return type:

list[tuple[Any, Any]]

default_challenger_operators()[source]

The three L1 challenger-production operators, each a reused ImprovementOperator (no bespoke fitting logic):

  • "distill" -> AutoSelect – cold-start: fit a fresh model from the harvested pool, the “train a new model from teacher-labeled data” shape of distillation.

  • "refine" -> Refit – warm-started refit of the champion’s own parameters on the harvested pool.

  • "evolve" -> Mutate – genetic-programming structure edit (grow/shrink/perturb), mixle.evolve’s own structure-search operator.

Return type:

dict[str, ImprovementOperator]

principled_crossover(model_a, model_b, *, weight_a=0.5)[source]

Combine two champions the ONLY principled way this framework allows: mixture composition (mixle.ops.mixture()), never gene-splicing (cutting/pasting incompatible weight sub-blocks). Returns an unfitted mixture prototype; refit it against data (e.g. via its own .estimator()) before treating it as a challenger.

Parameters:
Return type:

Any

class ConceptLibrary(base_families=('gaussian',))[source]

Bases: object

A small, inspectable registry of admitted concept families – the “library” of CARD L6.

Starts with base_families (present from the outset, never revocable via revoke() – they are the starting library, not something this loop discovered). Everything admitted afterwards is a first-class, named, evidenced entry that can be queried by task signature and, if it turns out to be a mistake, genuinely revoked – removed from both the active family set and the design-prior ledger that backs query(), not merely hidden.

Parameters:

base_families (Sequence[str])

property families: tuple[str, ...]

the starting base plus everything still admitted.

Type:

Every family currently in the library

admit(family, evidence, *, task_signature, task_index, quality)[source]

Admit family to the library, receipted with evidence and recorded in the design-prior ledger under task_signature so query() can recommend it later.

family must name a REGISTERED family, and quality must be finite. An admission is the library’s promise that a later query() can hand this name back as something the search can actually reach for and fit; admitting an unregistered name made query() recommend a family that does not exist, and a non-finite quality made the ranking that picks between recorded recipes meaningless.

Parameters:
Return type:

None

revoke(family, *, task_index=-1, reason='')[source]

Undo an admission: family leaves the active family set AND the design-prior ledger, so query() can never recommend it again – genuinely reversible, not soft-hidden.

Parameters:
Return type:

None

query(signature)[source]

The design-prior recommendation for signature: the best-recorded, still-admitted family seen for a matching task signature, or None if nothing qualifies.

Parameters:

signature (str)

Return type:

str | None

class AdmissionEvent(action, family, task_index, evidence=<factory>)[source]

Bases: object

One receipted, timestamped-by-task-index entry in a ConceptLibrary’s audit log.

Parameters:
class TaskResult(task_index, task_signature, discrepancy, challenger_family, reused_concept, admitted_family, verdict, mdl_gain_bits)[source]

Bases: object

One task’s pass through the loop – the receipt the acceptance test reads.

Parameters:
  • task_index (int)

  • task_signature (str)

  • discrepancy (float)

  • challenger_family (str | None)

  • reused_concept (bool)

  • admitted_family (str | None)

  • verdict (Verdict | None)

  • mdl_gain_bits (float)

run_concept_discovery_loop(tasks, *, library=None, champion_family='gaussian', objective=None, train_frac=0.6, min_train=8, min_holdout=8, recurrence_window=2, discrepancy_threshold=0.003, min_effect=0.01, nonnested=False)[source]

Run discrepancy -> propose -> verify -> adopt -> remember over tasks, in order.

For every task: fit the champion_family on a train split, measure discrepancy against a held-out split (mixle.epistemic.discrepancy.discrepancy_report()). If the library already has an admitted concept recommended for this task’s signature (ConceptLibrary.query()), that concept is tried directly – no re-search. Otherwise, once high discrepancy has recurred for recurrence_window consecutive tasks sharing a signature with no admitted concept yet, the automatic profiler proposes a candidate family from its registered set (selection, not synthesis); it is gated via mixle.evolve.verify.challenger_beats_champion() and, only if it passes, admitted.

Each task’s data is split train, held_out = data[:n_train], data[n_train:] with n_train derived from train_frac but clamped so both sides meet an explicit floor: min_train rows to fit the champion/challenger on, min_holdout rows so discrepancy and the verify gate never run on empty (or near-empty) evidence. train_frac must lie in the open interval (0, 1); a task with fewer than min_train + min_holdout rows raises ValueError rather than silently starving the train or held-out side of the split.

Returns the (possibly newly-created) ConceptLibrary and one TaskResult per task.

Parameters:
  • tasks (Sequence[Sequence[float]])

  • library (ConceptLibrary | None)

  • champion_family (str)

  • objective (Objective | None)

  • train_frac (float)

  • min_train (int)

  • min_holdout (int)

  • recurrence_window (int)

  • discrepancy_threshold (float)

  • min_effect (float)

  • nonnested (bool)

Return type:

tuple[ConceptLibrary, list[TaskResult]]

task_signature(data)[source]

A coarse, reusable descriptor of a task’s data-generating shape.

Deliberately coarse (kind + skew sign, not a fingerprint of the exact data): the point is that different tasks sharing the same hidden family land on the same signature, so ConceptLibrary.query() can recommend a concept discovered on an earlier task to a later one.

Parameters:

data (Sequence[float])

Return type:

str

Submodules