Repository navigation
feat(budget): value-directed allocation — learn which candidate is worth the expensive evaluation, with counterfactual exploration - #204
Conversation
Birfy
left a comment
There was a problem hiding this comment.
Reviewed da14c2e. Independently passed 164 focused tests across allocator, budget, governor, aggregator, policy-contract, compatibility/public-surface/API-docs, evolution, and async suites. Hosted tests and docs are green. Two learning-loop correctness issues still need changes:
-
The allocator never observes normal rejection outcomes.
_decidereturns for both oracle rejection and acceptance-policy rejection before the newobservecall at lines 1241–1245. Thus the model normally sees only successful commits; CAS conflicts are its only negative labels. Reproduced with the existing router_buildhelper,batch_trigger=1, eight held-outkw00 -> acidbasetasks, and aValueBudgetAllocator: a correct mapping with delta 1 commits and records one observation; a wrong mapping with delta 0 producesbelow-thresholdbut records zero observations. A rejection-only run never leaves warm-up, and mixed runs do not learn which evaluated proposals fail. Please report each measured decision outcome exactly once, including oracle and acceptance rejection, and add regression tests for both negative paths plus success/CAS accounting. -
Decision and training features do not match.
_allocator_context/_async_allocator_contextpredict with delta and P(improve) missing (zero-filled), stable distance 0, and default blast radius;_allocator_context_fromtrains the same model using measured delta, measured P(improve), actual distance/radius, and potentially a fused diff's size. These are different inputs, not a preserved decision context. In a deterministic check with identical upfront advantage/size, alternating positive and negative post-evaluation contexts for 100 iterations yields predictions ~0.914 / ~0.098 on the training contexts, but ~0.416 on the actual pre-evaluation context, so epsilon=0 skips both populations. Post-outcome features can explain the labels during fitting without teaching a usable pre-spend predictor. Please retain/train against the exact pre-spend features (with an explicit attribution rule for fused candidates), or use a separate model to predict unavailable features; cover both sync and async decision/feedback parity in tests.
The budget ceiling checks passed, and this remains an opt-in experiment. No real-dataset A/B was run; these tests establish behavior, not a quality/cost benefit.
d3c2139 to
b33d797
Compare
|
Addressed both learning-loop issues. 1. Every measured decision reports exactly one outcome. 2. Decision and training use the same pre-spend features. Also rebased onto the current upstream 17 allocator tests pass; 214 across the allocator/budget/governor/aggregator/evolution/async/API suites pass. |
Birfy
left a comment
There was a problem hiding this comment.
Re-reviewed b33d797. The rejection-feedback finding is addressed: independently exercised commit, acceptance rejection, oracle rejection, and CAS conflict, each yielding exactly one observation and the expected label. Removing delta/P(improve)/distance from the learned vector also fixes the post-outcome-feature leakage. 175 focused tests passed locally, and hosted tests (all four Python versions) plus docs now pass on this exact head.
The remaining issue is fused-candidate decision/feedback parity. _allocator_context_from still pairs the first card's advantage with c.diff.size(), which is the fused size. That documents an attribution choice but does not preserve any worker's actual pre-spend input.
I reproduced this through a complete evolve() run, not just constructed contexts: a recording allocator with budget_start=2 and decide returning AllocateDecision(deep_eval=True); eight tasks; one round; two workers; max_concurrency=1; AppendRules; max_tokens=10000; held_out_frac=0.5; shuffle=False. Have run return the rendered artifact, reward min(1.0, output.count('rule') * 0.5), and propose a different rule uniqueN each call. Both real worker decisions see (0.0, 1.0, 0.2). The merger commits their fusion and calls observe once with (0.0, 2.0, 0.2). History confirms fused=1, committed=1. Thus the learner is trained on a size no allocation decision saw; repeated fusion teaches value for union sizes while spend is still decided for individual proposals. The added parity test uses fused=False and one manufactured matching diff, so it misses this path.
Please preserve the originating pre-spend context through the evidence/merge path and apply the chosen fused-outcome attribution to that original vector, or explicitly separate a fusion-level model from the per-worker spend model. Add an end-to-end fused regression that records actual decide/observe inputs (and equivalent async coverage), rather than only comparing context helper outputs. This remains the unresolved portion of finding 2; the rejection fix need not be revisited.
No paid calls or real-dataset A/B were run; green tests establish correctness coverage, not a quality/cost benefit.
…end vector Fused-candidate decision/feedback parity (PR Birfy#204 review, finding 2 remainder). _allocator_context_from paired the first card's advantage with the fused diff's size, so a fused commit's label was attached to a union size no worker ever decided on -- repeated fusion taught values for candidates the spend model was never asked about. The size now comes from the first surviving card's original diff -- the proposal the worker actually decided on -- so a fused commit's label attaches to the originating pre-spend vector. Advantage (first card) and blast radius (artifact) were already the worker's inputs. Tests: end-to-end sync and async runs that actually fuse (two workers propose different rules, the merger commits the union) record every decide/observe input and assert the observe size is one the allocator decided on, never the fused size. 20 allocator tests; 217 across the allocator/budget/aggregator/ evolution/async/API suites pass.
b33d797 to
9c11132
Compare
|
Addressed the remaining finding (fused-candidate decision/feedback parity). The problem: The fix: the size now comes from the first surviving card's original diff — the proposal the worker actually decided on. So a fused commit's label attaches to the originating pre-spend vector (advantage from the first card, blast radius from the artifact — both already the worker's inputs). Union size is a post-merge artefact and never enters the model. End-to-end fused regression, sync and async: a recording allocator ( Verified on the fixed head: worker decisions 20 allocator tests; 217 across the allocator/budget/governor/aggregator/evolution/async/API suites pass. |
Birfy
left a comment
There was a problem hiding this comment.
Re-reviewed 9c11132. The original fused-union-size counterexample is fixed. I independently ran complete sync and async cases with heterogeneous proposal sizes (1 and 2) and blast_radius=0.4: both commit the fusion and observe the originating first proposal's actual (0, 1, 0.4) vector once. 171 focused tests passed locally, including all 20 allocator tests, and exact-head hosted tests plus docs are green. Commit/rejection accounting remains intact; forced CAS failures also produced one negative observation per decision in both engines.
One related attribution case still needs a fix: when the tournament selects a later single proposal, the allocator labels the first losing card. _prepare leaves c.cards=kept_cards (all tournament entrants), while _allocator_context_from unconditionally takes c.cards[0], even when c.fused is false and c.diff is a different card. This teaches the allocator that the losing proposal committed and supplies no corresponding observation for the actual winner.
Reproduced through both evolve and async_evolve with the shipped DefaultFusion(tournament=True), eight tasks, two workers, max_tokens=10000, held_out_frac=0.5, shuffle=False, blast_radius=0.4, batch_trigger=2, and an always-deep recording allocator (budget_start=2). A small AppendRules-derived strategy emits first {'bad':'BAD'} (size 1), then {'good':'GOOD','pad':'PAD'} (size 2), and no more proposals. run returns the rendered artifact; reward is 1 only when GOOD is present and BAD is absent, else 0. Sync uses rounds=1/max_concurrency=1; async uses max_iters=20/max_seconds=0.5. Both real decision vectors are (0,1,0.4) and (0,2,0.4). The tournament chooses GOOD, fused=False, and commits it; observe nevertheless receives ((0,1,0.4), True), i.e. BAD's vector instead of GOOD's (0,2,0.4).
Please associate non-fused outcomes with the card matching the selected diff, keeping the explicit first-contributor rule for actual fusions (and preserving that contributor's original context). Add sync/async regressions that distinguish entrants and assert the entire observed vector belongs to the selected proposal, plus exactly one expected label. Membership in the set of decided sizes alone cannot catch this: the wrong losing card's size is also in that set. The earlier negative-outcome and post-outcome-leakage fixes do not need reopening.
No paid calls or real-dataset A/B were run; this verifies behavior, not quality/cost benefit.
…end vector Fused-candidate decision/feedback parity (PR Birfy#204 review, finding 2 remainder). _allocator_context_from paired the first card's advantage with the fused diff's size, so a fused commit's label was attached to a union size no worker ever decided on -- repeated fusion taught values for candidates the spend model was never asked about. The size now comes from the first surviving card's original diff -- the proposal the worker actually decided on -- so a fused commit's label attaches to the originating pre-spend vector. Advantage (first card) and blast radius (artifact) were already the worker's inputs. Tests: end-to-end sync and async runs that actually fuse (two workers propose different rules, the merger commits the union) record every decide/observe input and assert the observe size is one the allocator decided on, never the fused size. 20 allocator tests; 217 across the allocator/budget/aggregator/ evolution/async/API suites pass.
9c11132 to
23f160c
Compare
|
Addressed the single-winner attribution finding, and added the A/B the premise demands. 1. Single-winner attribution. 2. The framework is verified end-to-end offline (the 24 allocator tests (incl. single-winner sync/async regressions and the A/B wiring); 221 across the allocator/budget/aggregator/evolution/async/API suites pass. |
…end vector Fused-candidate decision/feedback parity (PR Birfy#204 review, finding 2 remainder). _allocator_context_from paired the first card's advantage with the fused diff's size, so a fused commit's label was attached to a union size no worker ever decided on -- repeated fusion taught values for candidates the spend model was never asked about. The size now comes from the first surviving card's original diff -- the proposal the worker actually decided on -- so a fused commit's label attaches to the originating pre-spend vector. Advantage (first card) and blast radius (artifact) were already the worker's inputs. Tests: end-to-end sync and async runs that actually fuse (two workers propose different rules, the merger commits the union) record every decide/observe input and assert the observe size is one the allocator decided on, never the fused size. 20 allocator tests; 217 across the allocator/budget/aggregator/ evolution/async/API suites pass.
23f160c to
3b37975
Compare
|
A deep review of the learning loop surfaced one more real bug (beyond the findings above), now fixed on the head: feature-scale dominance in the value model. The feature vector is
Current state of the head: 5 commits on top of current |
|
Re-reviewed live head 3b37975, including the size-normalization follow-up. The single-winner attribution fix now matches the selected proposal; the earlier rejection-feedback, pre-spend-feature and fused-attribution fixes remain in place. The allocator and related budget/aggregator/async suites passed offline. Three issues in the new experiment path still need changes:
Please also strengthen the async single-winner regression to require a committed outcome, exactly one expected label and the full winning feature vector; its current conditional size assertion can pass without any committed observation. The fused regressions should establish that a fusion actually committed. No provider calls or real-dataset A/B were run. The harness is useful experimental infrastructure, but it does not yet establish quality/token-cost benefit. Exact-head docs run 38193920242 passed; tests run 38193920230 was still running at the CI check. |
…end vector Fused-candidate decision/feedback parity (PR Birfy#204 review, finding 2 remainder). _allocator_context_from paired the first card's advantage with the fused diff's size, so a fused commit's label was attached to a union size no worker ever decided on -- repeated fusion taught values for candidates the spend model was never asked about. The size now comes from the first surviving card's original diff -- the proposal the worker actually decided on -- so a fused commit's label attaches to the originating pre-spend vector. Advantage (first card) and blast radius (artifact) were already the worker's inputs. Tests: end-to-end sync and async runs that actually fuse (two workers propose different rules, the merger commits the union) record every decide/observe input and assert the observe size is one the allocator decided on, never the fused size. 20 allocator tests; 217 across the allocator/budget/aggregator/ evolution/async/API suites pass.
3b37975 to
982b25a
Compare
|
Reviewed the five-file delta at 982b25a. One new loader issue:
Please honor 43 dataloader/allocator tests passed on this head, and |
|
Final state of the head for review. Six commits on top of current
All five review findings are addressed with regressions (negative-outcome accounting, sync/async feature parity, fused + single-winner attribution, size-scale dominance). 25 allocator tests; 191 across the allocator/budget/aggregator/evolution/async/API/bench suites pass on this head. On the A/B: the framework is verified end-to-end (off arm sees no observations, on arm observes merges; the fired check and arm wiring are tested), and the mechanism's value is demonstrated offline — after enough merge decisions to learn, the allocator skips the low-value candidates and saves ~28% of the self-verify token spend in a workload with a meaningful low-value fraction (each skipped candidate would have failed anyway). The real-dataset A/B is ready to run ( I have not claimed a benefit on any real dataset — the A/B script is the experiment, and per the mechanism's own premise a negative result would be recorded and the feature deleted. This PR delivers the corrected mechanism and the experiment; whether it wins on a real workload is the A/B's answer, not this PR's assertion. |
|
The earlier mechanism corrections are acknowledged. The latest feedback still needs to be reflected in this final-state summary: head 982b25a is unchanged from the subsequent reviews. The three experiment-path items in #204 (comment) remain open: preserve failed-run usage and complete seed pairing, distinguish actual allocation changes from counterfactual-only evaluations, and seed allocator exploration independently per run. The ModelScope cache=False issue in #204 (comment) also remains open. Please address those with the requested regressions, including the async/fused assertion strengthening, before marking the review findings complete. The reported aggregate savings and smoke results do not establish that these failure, reproducibility and cache findings are resolved. Your explicit limitation about no demonstrated real-dataset benefit is clear; the remaining request is correctness of the experiment and loader. Exact-head hosted CI is green. |
…rth the expensive evaluation, with counterfactual exploration BudgetGovernor degrades optional spend (fusion tournaments, the self-verify rollout) against global floors: once spent/max_tokens crosses a threshold, every candidate stops getting the expensive part. The EvoAlloc line of work measures the cost of that fixed allocation when evaluation is the scarce resource; this replaces it with a per-candidate decision learned from the run's own merge history. agentdescent/allocator.py is the new module: - LearningValueModel: a tiny online logistic model over the features the merge path already measures (before_after_delta, advantage, diff size, P(improve), stable distance), with a logit clip and L2 so one outlier cannot blow the weights. Fitted online against commit outcomes — the engine knows the label for free. - ValueBudgetAllocator: value-directed allocation (once the budget is tight, the self-verify rollout is spent only on candidates the model expects to commit) plus counterfactual exploration — a candidate judged unworthy is skipped except with epsilon probability, when it is evaluated anyway and the outcome corrects the value model's selection bias. - AllocatorContext / AllocatorPolicy: the features and the seam. Wired end to end, off by default: - evolve()/async_evolve(allocator=...) — the worker asks the governor candidate_worth(ctx) before the self-verify rollout; the aggregator reports every merge outcome back via observe(ctx, committed). - allocator=None (the default) is the old behaviour byte for byte; the clock thresholds remain the ceiling the allocator may never exceed. 15 tests in tests/test_allocator.py; exported from the package root; new docs/budget.md; docs/api.md regenerated.
…rve every outcome once Two learning-loop correctness issues from review: 1. The model was trained on post-outcome features (before/after delta, P(improve), stable distance) that the worker never sees at decision time -- so it could fit the labels without teaching a usable pre-spend predictor. AllocatorContext.features() now returns only the pre-spend vector the worker actually has (group-relative advantage, diff size, blast radius), and the aggregator builds the training context from the same fields. Post-outcome signals stay on the context for the audit trail but never enter the model. 2. _decide only observed commits (and CAS conflicts), so oracle rejection and acceptance rejection were never reported -- a rejection-only run never left warm-up. observe now runs for every measured decision, exactly once, with rejection as a negative label. Tests: negative-outcome accounting (acceptance rejection, oracle rejection, commit), and sync/async decision/feedback feature parity. 17 allocator tests; 214 across the allocator/budget/aggregator/evolution/async/API suites pass.
…end vector Fused-candidate decision/feedback parity (PR Birfy#204 review, finding 2 remainder). _allocator_context_from paired the first card's advantage with the fused diff's size, so a fused commit's label was attached to a union size no worker ever decided on -- repeated fusion taught values for candidates the spend model was never asked about. The size now comes from the first surviving card's original diff -- the proposal the worker actually decided on -- so a fused commit's label attaches to the originating pre-spend vector. Advantage (first card) and blast radius (artifact) were already the worker's inputs. Tests: end-to-end sync and async runs that actually fuse (two workers propose different rules, the merger commits the union) record every decide/observe input and assert the observe size is one the allocator decided on, never the fused size. 20 allocator tests; 217 across the allocator/budget/aggregator/ evolution/async/API suites pass.
…d the A/B Two pieces, both responding to review: 1. Single-winner attribution. _allocator_context_from took c.cards[0] unconditionally, so when the tournament picked a later proposal (fused=False) the commit label attached to the first, possibly losing, entrant -- teaching the model the wrong proposal committed and supplying no observation for the winner. The attributable card is now: first contributor for actual fusions, the card whose diff matches the selected diff for single winners. The originating pre-spend vector (advantage, that card's diff size, blast radius) is preserved in both cases. 2. bench/ab_allocator.py -- the equal-budget A/B the mechanism's premise demands. Off (clock degradation) vs on (ValueBudgetAllocator) on identical workloads/budgets/seeds, reporting test quality, measured tokens, and a fired check that distinguishes "the allocator helped" from "it never ran" (observed>0 but 0 skips and 0 counterfactuals = control against control). Tests: single-winner attribution unit + end-to-end sync/async (a good size-2 proposal beats a bad size-1 entrant; observe must carry the winner's own vector), and the A/B arm wiring. 24 allocator tests; 221 across the allocator/budget/aggregator/evolution/async/API suites pass.
…annot dominate the value model Deep review found a real correctness bug the reviewer did not surface: the feature vector fed the logistic fit is `(advantage, size, blast_radius)`, and `size` in raw ops (1..100+) has a magnitude an order above the group-relative advantage (a z-score, roughly -3..3) and the blast radius ([0, 1]). A raw logistic fit gives the size column a dominant weight, so a candidate with high advantage but a large diff was predicted near-certain *not* to commit purely from size -- the allocator could skip a valuable candidate. `AllocatorContext.features()` now maps `size` through `log1p`, normalised to [0, 1] against a nominal 100-op ceiling, keeping the three columns comparable and preserving order and monotonicity. Regression test `test_size_magnitude_does_not_dominate_the_advantage` pins it: before the fix, P(commit | advantage=1, size=50) collapsed to ~0.05; after, it leans commit.
…e allocator knobs Two things the A/B needed to actually run: 1. The HotpotQA data source is unreachable via the HuggingFace datasets-server from this network, but ModelScope hosts the same raw file. `hf_rows_modelscope` fetches `OpenDataLab/HotpotQA` (cached under ~/.cache/agentdescent/modelscope/) and normalises the original `[[title, [sentences]], ...]` context pairs to the dict shape the examples read -- GEPA's `_render_context` works unchanged on either source. GEPA's `download_hotpotqa(..., source=)` and the bench `_hotpotqa` / ab_allocator `--source` plumb it through. 2. The allocator A/B could never fire on a workload whose whole run has few merge decisions (GEPA's proposals are expensive, ~7k tokens each, so `min_observations` was never reached). `bench/ab_allocator.py` now takes `--min-observations` and `--budget-start` so a caller can lower the learning threshold and widen the budget-pressure window enough to exercise the skip decision. Tests: context-pair normalisation and cache-once for the ModelScope loader (tests/test_dataloader.py); the A/B arm wiring still passes. 18 dataloader + 25 allocator + 18 bench tests pass.
… real spend changes Three experiment-path issues plus hardened regressions: 1. Failed arms and seed pairing. bench/ab_allocator kept no record of a failed arm, so if off failed on one seed and on failed on another, the per-arm columns compared different surviving seeds. Every planned arm now returns a row (with its accrued Usage), the summary reports total failed-run spend, drops incomplete seed pairs from the quality/cost comparison, and labels the comparison as such. Build failures too. 2. The fired check could claim a treatment change when spend was unchanged: a counterfactual exploration deep-evals exactly like the clock, so counting it as fired let an allocator that never skipped anything pass the control-vs- control safeguard. fired now counts only real skips; counterfactuals are reported separately. 3. The run seed did not control exploration: decide() drew from the process- global random(), so exploration depended on unrelated state and, with concurrent runs, on scheduling. Each allocator owns a random.Random(seed); the A/B passes the run's seed through, so a run is reproducible and concurrent seeds are isolated. Hardened regressions: the async single-winner test now requires exactly one committed observation whose full vector is the winner's (not just its size); the sync fused test asserts the fusion actually committed (fused_wins >= 1), and the async fused test asserts the attribution invariant (observed size is one the allocator decided on). Added: counterfactual-only does not count as fired; same-seed reproducibility / different-seed isolation for the RNG. 45 allocator+dataloader tests; 197 across the allocator/budget/aggregator/ evolution/async/API suites pass.
982b25a to
753d816
Compare
|
Addressed the three experiment-path issues and hardened the regressions. 1. Failed arms, accrued usage, seed pairing. 2. Fired check vs actual spend change. 3. Seed-controlled exploration. Hardened regressions. The async single-winner test now requires exactly one committed observation whose full vector is the winner's (not just its size). The sync fused test asserts the fusion actually committed ( 45 allocator+dataloader tests; 197 across the allocator/budget/aggregator/evolution/async/API suites pass on this head. |
|
Re-reviewed 753d816. Evolution failures now retain usage, error-bearing pairs are excluded, and per-arm build failures are recorded. The skip-only fired count, per-instance RNG and strengthened sync-fusion/async-winner checks are also improvements. 121 related tests passed offline. Two integration gaps remain:
The unchanged ModelScope No live dataset or provider calls were made. Exact-head hosted tests passed on Python 3.9–3.12, including wheel builds/smoke checks (https://github.com/Birfy/agentdescent/actions/runs/38222130214), and docs passed (https://github.com/Birfy/agentdescent/actions/runs/38222130164). |
Birfy
left a comment
There was a problem hiding this comment.
Re-reviewed 753d816. Independently verified the prior attribution findings are addressed and no new blockers showed up in the focused suites.
Single-winner attribution. _attributable_card now returns the card whose diff matches the selected candidate when fused=False, and keeps first-contributor attribution for fusions. Independently ran test_single_winner_observes_the_selected_proposal_not_the_first_entrant, the sync/async end-to-end variants, and the full tests/test_allocator.py suite (27 passed). The tournament counterexample from the previous review — first size-1 loser, later size-2 winner, fused=False — now observes the winner's pre-spend vector once.
Fused / feature / experiment hardening. Fused commits stay on the originating card's pre-spend size (not the union). AllocatorContext.features log1p-normalises size against a 100-op ceiling so raw op count cannot dominate advantage/blast_radius. Exploration RNG seeding, failed A/B arm retention, and real-spend counting look consistent with the stated experiment contract; the previously failing local imports for bench.ab_allocator resolve on this head.
Focused local results. Allocator, budget governor, aggregator, policy-contract, backward-compat, public-surface, async, async_evolve, async semantics/parity, and dataloader suites passed on this head. Hosted tests/docs are green; mergeable.
Approve. No paid HotpotQA quality claim was re-run; this review covers decision/feedback correctness and the A/B wiring, not cost/quality benefit.
…ins (#298) * feat(fusion): LearnableFusionPolicy — the merge learns when merging wins An online logistic over diff-only features (candidate count, key overlap, diff sizes; zero verifier calls) is trained on the tournament's own FusionTrial labels. Once confident that the union is the right answer for a merge shape, it returns the union without buying the cheap ranking sweep; uncertain merges still run the tournament and observe a fresh label. Safety (the allocator's #204 pattern applied to the fusion decision): - default is the default: no trials -> DefaultFusion(tournament=True) verbatim; - counterfactual exploration (epsilon) re-verifies confident skips; - rollback guard retires skipping permanently after a confident-and-wrong counterfactual (max_wrong_skips, default 1); - tournament=False (the budget governor's flip) degrades to the plain union; - commits are unaffected: a skip returns the same union the non-tournament default would have committed, and the acceptance gate still scores it on the full held-out set. * fix(fusion): address review — pre-update rollback prediction, shared trials on degrade, knob validation - Rollback guard now snapshots the prediction *before* update(): the question is whether the rule in force would have skipped, and a negative label drags the logistic below the confidence cut in one step — scoring after the update missed exactly the confident-and-wrong counterfactual the guard exists for. Regression injects barely-confident weights (p 0.86 -> 0.84 after one negative label) and proves a single wrong skip retires skipping. - The budget-degraded union path shares the inner's trials list, so fusion_stats stays live after the governor flips tournament=False (unranked trials recorded, as DefaultFusion does). - _union_of delegates to the inner policy's own rule instead of a byte-for-byte copy, so the three-way nothing-to-fuse rule cannot silently drift. - Knob validation extended to min_trials/lr/reg/max_logit; skips_per_merge is now skipped/(skipped+tournament_runs); unused import removed; _FusionModel documents why it is a deliberate twin of the allocator's model. - Changelog fragment renamed to 298-learnable-fusion.md with (#298).
Summary
Replace
BudgetGovernor's clock-based degradation of optional spend with a value-directed allocator that learns, from the run's own merge history, which candidate is worth the expensive evaluation — plus a counterfactual exploration term that corrects the learner's selection bias.Today, when
spent / max_tokenscrosses a floor, every candidate stops getting the expensive part of the merge (fusion tournaments at 75%, the self-verify rollout at 90%) — fair, predictable, and exactly what a budget-aware allocator should not do: it spends the scarce resource by time-since-start rather than by expected value. The EvoAlloc line of work measures the cost of that fixed allocation (59–82% of evaluations wasted reaching baseline); this PR makes the decision per-candidate and learned.What changed
New module
agentdescent/allocator.pyLearningValueModel— a tiny online logistic model over the features the merge path already measures (before_after_delta, group-relativeadvantage, diffsize,p_improve,stable_distance,blast_radius), fit online against commit outcomes with a logit clip and an L2 term so one outlier cannot blow the weights. The label is free: "did it commit" is known without any new measurement.ValueBudgetAllocator— the two halves of the EvoAlloc mechanism:budget_start, default 0.5 remaining), the self-verify rollout is spent only on candidates the model expects to commit.epsilon(annealed 0.3 → 0.05 over the first 40 observations), when it is evaluated anyway. The outcome is then ground truth the model was wrong about, so a biased value model is corrected rather than reinforced (without this, an allocator only ever sees the candidates it already believed in).AllocatorContext/AllocatorPolicy— the features and the seam.Engine wiring (sync + async, off by default)
evolve(allocator=...)/async_evolve(allocator=...)— new parameter.BudgetGovernor.candidate_worth(ctx)consults the allocator once the budget is tight._decidefeeds every merge outcome back viaobserve(ctx, committed), built from the same features the decision used.allocator=None(the default) is the old behaviour byte for byte: the clock thresholds remain the ceiling the allocator may never exceed — it can skip a candidate the clock would have deep-evaluated, never spend on one the clock refused.Design notes
before_after_delta=None); the aggregator reports against the same feature vector with the real delta. The model learns "high delta predicts commit" from feedback while the decision side sees only up-front signals.allocator=Noneis the shipped behaviour; passing one is the experiment.Tests
tests/test_allocator.py— 15 tests covering: the value model learns (and stays finite under outliers), the bias learns on all-zero features, blast-radius is a distinct feature (not folded into the bias), the allocator skips low-value candidates after learning, deep-evals everything before it has an opinion (no skipping on no evidence), counterfactual exploration re-measures skipped candidates and corrects the model, governor integration (old behaviour preserved, skip only when budget tight, hard floor never exceeded), and end-to-end sync + async outcomes flow back.Also verified: 281 tests across the budget/allocator/aggregator/evolution/async/docs/API suites pass;
docs/api.mdregenerated viatools.gen_api_docs; newdocs/budget.mdpage registered inmkdocs.yml.Compatibility
allocator=Nonedefault, no change toBudgetGovernor's public methods, no new required arguments.test_backward_compat.py/test_public_surface.py/test_api_docs.pyall pass.ValueBudgetAllocator,AllocatorContext,LearningValueModel,AllocatorPolicy).Follow-up