mixle.evolve.closed_loop module¶
L1: closed-loop self-evolution with operator credit – the loop at five altitudes, wired end to end.
Router-harvested failures -> acquisition (A5’s mixle.task.acquire.acquire()) -> challenger
production (a distill/refine/evolve operator, all reused
ImprovementOperator instances, not reimplemented) -> the held-out
challenger_beats_champion() gate -> deploy, as ONE budgeted background
loop (ClosedLoopSelfEvolution). A per-context meta-bandit (OperatorCreditBandit,
wrapping mixle.task.bandit.UCB1 – the same UCB1 machinery reused across this codebase for
meta-bandits) learns which challenger-production operator wins for which kind of failure, and every
ADOPTED champion gets a genealogy receipt (GenealogyLedger, built directly on
EvolutionLedger) recording its parent, the operator that produced it,
and the measured gap – a real, walk-backable lineage.
Data roles (MXR-080-0042). ClosedLoopSelfEvolution.step() splits each arriving batch into a
candidate pool and a held-out verify_data split BEFORE harvest/acquire touch anything, fits a
challenger only on an acquired subset of the candidate pool, and gates it against verify_data by
default (see step()’s “Note on verify”) – never on data the challenger was just fit on, and
never on the biased-by-construction complement of acquire’s own hardest-example selection. See
mixle.evolve.population’s module docstring for the fuller three-role framing (fit / reusable
validation / one-shot holdout) this loop shares: unlike Population,
each step here ordinarily gates against a FRESH batch from the caller’s stream, so a single step’s
promotion decision is itself evidence-bearing; only the per-context OperatorCreditBandit’s
reward, accumulated across many steps, is a heuristic search signal in the same sense.
Which subsystems this wires, not rebuilds:
mixle.task.router–harvested_from_router()reads a realRouter’s.harvested()(the frontier-answered cases the cheap tiers could not handle) as the harvested-failure source when a caller already runs one. For domains without a live Router (e.g. this module’s own tests, which evolve a marginal label model rather than a routed per-input classifier),harvest_failures()is the same idea generalized to anyObjective: the observations where the current champion did NOT score at its own best-attainable pointwise value – literally “the cases it got wrong.”mixle.task.acquire.acquire()(A5) ranks the harvested pool before it is spent on challenger production.acquireneeds a “scoreable” model (predict_proba-shaped); a champion that is a bare marginal distribution (does not condition on the item) is wrapped by_ConstantProbaAdapterso the realacquire()code path runs (entropy strategy) instead of being skipped – honestly, this degenerates to “prioritize while the champion overall is unconfident” since the per-item probability is constant, but it is the real primitive, not a mock.mixle.evolve.operators– three EXISTING operators stand in for L1’s “distill / refine / evolve ops” triad (seedefault_challenger_operators()):AutoSelect(cold-start refit – distillation’s “train a fresh model from the teacher-labeled pool” shape) as"distill",Refit(warm-started refit of the champion’s own parameters) as"refine", andMutate(the genetic-programming structure-edit operator) as"evolve".mixle.evolve.verify.challenger_beats_champion()is the held-out gate, used exactly as-is.mixle.evolve.ledger.EvolutionLedgeris the genealogy substrate, wrapped (not replaced) byGenealogyLedger.mixle.task.bandit.UCB1is the meta-bandit’s arm-selection machinery, one instance per context.
Principled crossover. If a challenger-production operator ever combines two existing champions
(a “crossover” in evolutionary-algorithm terms), it MUST do so via mixture composition
(mixle.ops.mixture(), exactly what Mutate’s grow move and
Recompose already do) – never by cutting and pasting incompatible
weight sub-blocks from two unrelated models. principled_crossover() is the explicit primitive for
that case.
- accuracy_objective()[source]
Higher-is-better accuracy for a model exposing a single “most likely label” prediction (a fitted
CategoricalDistribution’sargmax(pmap)): per-observation1{y == predicted_label}.- Return type:
Objective
- harvest_failures(champion, batch, objective)[source]
The objective-generic analog of
mixle.task.router.Router.harvested(): the observations inbatchwherechampiondid NOT score at its own best-attainable pointwise value underobjective– foraccuracy_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.
- 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 – exactlyrouter.harvested(), just zipped into one pool formixle.task.acquire.acquire().
- 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.
- 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.
- class OperatorCreditBandit(operator_names, *, c=1.0, seed=0)[source]
Bases:
objectA per-context meta-bandit over challenger-production operators, wrapping one
mixle.task.bandit.UCB1per context (e.g. a failure type/domain) – so the loop learns, independently for each context, which operator actually produces winning challengers there.- select(context)[source]
The bandit-chosen operator name for
context.
- reward(context, operator, reward)[source]
Fold an observed (non-negative, anti-regression) reward back into
operator’s arm.rewardmust be finite:max(nan, 0.0)isnan, which would poison the arm’s running mean and every selection that reads it, permanently.
- class GenealogyLedger(ledger=<factory>)[source]
Bases:
objectGenealogy 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’sid()(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, solineage()finds it) and immune toid()-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:
childreplacedparentviaoperator, measuredgap(the verified challenger-beats-champion delta).parent=Nonemarks the root of a lineage.
- 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[]ifmodelwas never recorded as an adoption – including ifmodelcan’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 instancerecord_adoption()was originally called with – e.g. the same model reloaded from disk in a fresh process.
- class LoopStepResult(context, operator, promoted, delta, champion_score, champion, cost=0.0)[source]
Bases:
objectOne
ClosedLoopSelfEvolution.step()outcome.costis what this step actually charges against aClosedLoopSelfEvolution.run()budget: the selected operator’scost_hintiff the attempt was genuinely applicable and completed without raising,0.0otherwise – skipped-by-budget, inapplicable, and failed attempts are all free.
- 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:
objectThe 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
Objectivethe 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 keyfor 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
acquireprioritizes 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
batchset aside, BEFORE harvest/acquire ever see it, as the defaultverify_datawhen a caller omitsverifyinstep()– seestep()’s “Note on verify” (MXR-080-0042). Irrelevant if the caller always passes an explicitverify.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
batchof held-out-shaped observations.harvest failures under the CURRENT champion,
rank them with A5’s
acquire,pick a challenger-production operator via the per-context bandit,
propose + gate the challenger (skipped if
budget_remainingcan’t afford it),score the model the step would deploy, while it is still only staged,
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
LoopStepResultthat is also inhistoryand 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, seemixle.evolve.population’s module docstring for the fuller three-role framing this loop shares):train_data(what the challenger is fit on) isacquire’s top-priority SUBSET ofbatch. Whenverifyis omitted,verify_datano longer defaults to the wholebatch(which CONTAINStrain_data, so the gate used to score a challenger on rows it had just been fit on – weaker evidence than a disjoint split). Instead,batchis split (_split(), fractionself.holdout) into a candidate pool andverify_dataFIRST, before harvest/acquire ever see it, and ONLY the candidate pool is harvested + ranked fortrain_data. This is deliberately a RANDOM split up front, notbatchminus whateveracquirehappened to pick:acquireselects 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 keepsverify_dataa fair, representative sample ofbatch’s actual distribution, genuinely disjoint from anything harvest/acquire/the challenger’s fit ever touches. Callers who want to control the split themselves (or whosebatchis already disjoint from wherevertrain_datawill come from) should pass an explicitverifybatch instead. Note too that ONE step’s gate decision (verdict.promoteabove) 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-CONTEXTOperatorCreditBandit’s ACCUMULATED reward across many steps: exactly likePopulation’s bandit, it is fine when each step draws fresh data (the normalrun()-over-a-stream usage), but a caller who callsstep()repeatedly against the SAME (or overlapping)verify/batchdata 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 owncost_hintis checked against it BEFORE the operator runs – an operator whose cost exceeds what remains is skipped exactly like an inapplicable one (nopropose/gate attempted, nothing charged).result.costreports what this step actually charges: the operator’scost_hintiff the attempt was genuinely applicable and completed without raising,0.0otherwise – seerun(), which sumsresult.costto 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()).
- 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 (seestep()’sbudget_remaining), and onlyresult.cost– what the step actually charged – accumulatesspent, so a skipped-by-budget, inapplicable, or failed attempt is genuinely free, exactly as documented; only a genuinely-applicable, genuinely-completed attempt is charged itscost_hint. The loop stops early once the budget is fully spent, leaving the champion at whatever it last became.verify_batchesandcontexts, when given, are per-step and must line up ONE-TO-ONE withstream. Both are validated here, before the first step runs, rather than being indexed as the loop goes (MXR-080-1902): a shortcontextsused to raiseIndexErrorpart-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.budgetis validated the same way and for the same reason (_budget_amount()).