Repository navigation
feat(audit): verifier bootstrap flywheel — the audit's labels improve the verifier, under a gate - #273
Conversation
…r under a gate VerifierBoost wraps any VerifierProtocol and learns a monotone calibration curve (isotonic_fit, numpy-only PAV) from the audit's IMPROVEMENT pool. The bootstrap gate activates it only when the corrected residual error beats the raw by min_gain on a held-out slice of the same pool; otherwise the curve is recorded as a rejection, never installed. Safety invariants: - trains on the IMPROVEMENT pool only — never the CALIBRATION pool that measures the verifier (store.for_calibration already asserts the split); - corrects the cheap/learned layers (ranking + audit trust signal) but delegates eval_counts/full_eval raw, so the commit gate keeps the oracle's authority; - declares itself a new verifier version (version_tag) so wiring it as the audit's version_extra makes VerifierWatch withdraw the old calibration and re-measure the boosted verifier. attach(boost=...) and Audit.recompute() retrain on a resolved batch. Default unchanged: no boost -> no fit, no correction.
Birfy
left a comment
There was a problem hiding this comment.
Thanks for this, @HugoXK. Using audit labels to improve the ranking layers, gated on a held-out check and with the commit gate left raw, is a direction worth having, and the PR is clean and well documented. The isotonic learner is small and dependency-free. eval_counts/full_eval stay raw. attach(boost=None) is a true no-op. The 14 new tests pass locally (tests/test_verifier_boost.py, py3.13), and the docs build is green. The test matrix was still pending when I looked.
I'm requesting changes because two of the safety properties the description relies on don't hold as wired, and one input makes the code crash. I reproduced each one with a short script against c5c448f:
Blocking
1. "Version honesty" isn't actually wired. The audit's version never changes when the boost activates.
AuditedReward.verifier_version is computed once in __init__ (verifier_fingerprint(verifier, extra=version_extra)), so attach(version_extra=boost.version_tag()) freezes the tag at attach time. Repro: build a store and a boost (inactive), then attach(..., version_extra=boost.version_tag(), boost=boost), append 200 biased IMPROVEMENT labels, and call audit.recompute(). The result is boost.is_active == True and version_tag() is boost:active:…, but audit.verifier_version is unchanged. VerifierWatch never fires, and the old calibration keeps applying. Passing the bound method instead doesn't help either, since repr() of a bound method doesn't change with state. The "caught twice" argument depends on this, so it needs real wiring. For example, recompute() could re-derive and propagate the version (reward, gate, watch) when boost.version_tag() changes, or verifier_version could become a live property. Either way, it needs an integration test that asserts audit.verifier_version changes and that the calibration for the old version is withdrawn after activation.
2. The training pool mixes verifier versions.
retrain() calls store.for_improvement() with no verifier_version, so labels scored by an older judge get fitted together with the current one. Repro: put 200 records from an "old" verifier biased by +0.4 next to 200 from an unbiased "new" one. The boost activates with g(0.5) = 0.361, which is a correction learned mostly from a verifier that is no longer running. This is the same staleness the store's version filter exists to prevent. Please filter to the version currently being scored. The cleanest way is probably to have the boost take the audit's version, or a callable for it, and call for_improvement(version).
3. A crash when min_labels <= min_val_labels.
n_val = max(min_val_labels, …) can consume every record, which leaves train empty. Then isotonic_fit([]) returns empty arrays, and _apply() raises IndexError: index 0 is out of bounds from self._vals[0]. That happens outside the try, and since auto=True it happens inside the constructor. Repro: 10 records with min_labels=10, min_val_labels=10. Please guard len(train) == 0 (or validate min_labels > min_val_labels in __init__) and add a regression test.
Should fix
4. The flywheel is learned and applied on different scales.
The curve is fit on AuditRecord.verifier_score, which is the per-unit (task, output) -> float reward that AuditedReward wraps. It's then applied to cheap_eval / learned_eval, which (in ThreeLayerVerifier) are artifact-level means over subsets plus noise, and 0.5*rule + 0.5*learned. Those aren't the same function, and for a non-linear g, g(mean f) != mean g(f). _FakeVerifier returns the same constant for every layer, so the tests can't see this. At minimum, the docstring should state the assumption that the wrapped verifier's layer scores are on the same scale as the audited per-unit reward. Ideally there'd be a test with a real ThreeLayerVerifier showing the corrected ranking moves closer to the oracle. A related point: because the audit keeps recording the raw per-unit reward, recalibrating after activation measures the raw reward again, not the boosted ranking layers. So even with (1) fixed, the second check doesn't actually measure the boost.
5. The uncertainty change does the opposite of what the docs say.
Audit priority is blast_radius * uncertainty / trust (aggregator.py ~L1189), so higher uncertainty means more oracle spend. max(self._resid_sd, uncertainty) can only raise it. In the repro, resid_sd = 0.037 and the inner uncertainty is 0.1, giving 0.1, unchanged. The module docstring and PR body say a boost that tracks the oracle makes the audit spend less. Either the docs or the code should change, and test_the_learned_uncertainty_reflects_the_validation_residual should pin the intended direction. Also note that resid_sd is an RMSE in score units, while the learned layer's uncertainty is a noise band, so taking max across the two needs a sentence of justification.
6. retrain() swaps the curve before the gate decides.
self._xs, self._vals are overwritten at fit time while _state may still say active from the previous fit. Evaluation is concurrent (eval_concurrency), so a cheap_eval in that window applies an unvalidated curve under the old version_tag. Fit into locals and assign _xs/_vals/_fit_hash/_resid_sd/_state together only after the gate passes.
7. The gate is weak by default.
min_gain=0.0 with <= accepts a curve that is merely equal on one random split. With n_val around 10 to 20, that's mostly noise, and isotonic fits overfit small train sets. I'd suggest a default min_gain > 0 (e.g. 0.05), or a paired bootstrap / sign test on the per-example squared-error differences. Also, len(val) >= self.min_val_labels is always true by construction, so that clause is dead code.
Nits
Callableis imported but unused inboost.py.- Typo: "uncertainy" in the module docstring.
- The PR description says the correction "preserves score order". It's non-decreasing, so flat PAV blocks can still turn distinct raw scores into ties, so "never inverts order" is more accurate.
- There's no linked issue. That's fine, but given how large the design surface is, an issue or design note first would help scope the follow-ups.
CHANGELOG.mdwill probably conflict with the other open PRs touching### Added. A rebase will be needed before merge.
Happy to re-review once 1–3 are fixed and 4–5 are either addressed or explicitly documented as limitations.
|
P1: Preserve the wrapped verifier’s full-set equivalence declaration. At c5c448f, boost.py lines 182–183 default full_eval_matches_counts to True when the inner verifier has no such attribute, and also prefer inherited True over an explicitly declared legacy oracle_shares_full_set=False. The existing shares_eval_counts helper deliberately treats both cases as independent measurements. After wrapping, it sees the wrapper’s newly declared True, so Aggregator._audit skips full_eval and substitutes count-derived scores. A candidate whose counts improve but whose independent full evaluation regresses loses that veto, even with an empty store and inactive boost. Please derive this declaration with shares_eval_counts(verifier) and add regressions for missing declarations and explicit legacy False. Source: agentdescent/agentdescent/audit/boost.py Lines 182 to 183 in c5c448f Helper: agentdescent/agentdescent/verifier.py Lines 298 to 320 in c5c448f Consumer: agentdescent/agentdescent/aggregator.py Lines 1223 to 1232 in c5c448f Source inspection only; local execution remains paused, so no runtime reproduction is claimed. |
|
Status check (four-hour routine, 2026-10-16 ~12:30 CST): no new pushes since the CHANGES_REQUESTED review; still CONFLICTING vs |
|
主分支已有较多更新,当前 PR 与 main 存在冲突。请 rebase/merge main 并解决冲突后推送,我们会尽快合入。谢谢! |
Status note (four-hour routine)Still CONFLICTING with |
- Restore CHANGELOG.md to main; add changelog.d/273-verifier-boost.md - Version honesty: live version_extra + refresh on recompute/activation - Train only on current verifier_version labels - Guard empty train split / require min_labels > min_val_labels - Fit into locals; assign curve only after bootstrap gate passes - Preserve inner full_eval declaration via shares_eval_counts - Document uncertainty floor + scale assumption; default min_gain=0.05
|
Maintainer push on
Auto-squash armed once CI is green. |
Birfy
left a comment
There was a problem hiding this comment.
Re-approve on 0e6a890 after push race. Verifier-boost CR items + changelog fragment addressed; see prior review body / PR comment.
CI failed test_the_api_reference_is_in_sync_with_the_package — run tools.gen_api_docs so VerifierBoost exports are reflected.
Birfy
left a comment
There was a problem hiding this comment.
Re-approve after main merge + docs/api.md regen (CI api-reference sync).
|
Follow-up: merged |
|
Two distinct source-review findings at feca0bd, after the previous shares_eval_counts fix:
Sources: agentdescent/agentdescent/audit/wiring.py Lines 103 to 111 in feca0bd agentdescent/agentdescent/audit/boost.py Lines 218 to 228 in feca0bd agentdescent/agentdescent/audit/boost.py Lines 347 to 351 in feca0bd agentdescent/agentdescent/verifier.py Lines 275 to 296 in feca0bd Source inspection only. These regression scenarios have not been executed here; local execution remains paused. |
feat(audit): verifier bootstrap flywheel — the audit's labels improve the verifier, under a gate
The problem
The audit package already turns labelled records into a correction of the loop:
Calibratorcomputes a rectification (delta_hat,se,resid_sd) thatRectifiedAcceptanceuses to discount the gate's evidence. What it never does is turn those labels into a better verifier — the correction is subtracted at the decision, and the scorer itself is left exactly as it was.AuditedRewardalways returns the verifier's score, and no code path rewrites the verifier function the aggregator consumes from the labels the audit collects. That is the missing half of "the audit learns" — the frontier direction (Self-Rewarding LM, V-STaR) where a judge improves itself from its own labeled data.The dangerous part is the same as the valuable part: a verifier that learns from its own labels could learn to be confident-but-wrong. This PR closes the loop under a bootstrap gate and two hard invariants, so the flywheel cannot install a worse verifier and cannot substitute itself for the oracle.
What this PR adds
agentdescent/audit/boost.py—VerifierBoost, a drop-inVerifierProtocolwrapper (satisfies the engine's verifier seam,Policies(verifier=...)):g: verifier_score -> oracle-consistent score(isotonic regression — pool-adjacent-violators, numpy-only, so score order is preserved, which is exactly what a ranking correction must not break).retrain()fits on a tail slice of the pool and validates on a held-out slice: the curve activates only ifE[(g(f)-Y)²] ≤ E[(f-Y)²]·(1 − min_gain). A curve that does not provably help is recorded as a rejection instatus()and never installed.cheap_evalandlearned_evalreturng(score)— the scores the aggregator uses to rank candidates (conflict resolution, the fusion tournament) and the audit uses for its trust/priority signal.learned_eval's uncertainty is raised to the boost's own validation residual, so the audit spends less oracle budget on a verifier that demonstrably tracks the oracle.eval_countsandfull_evalare delegated raw — the commit gate keeps the oracle's authority over what commits. The correction never substitutes for the oracle.The governance invariants
for_calibrationassertion already enforces the split; the boost reads onlyfor_improvement().version_tag()changes when the boost activates and is stable while active. A caller wiring it as the audit'sversion_extramakesVerifierWatchwithdraw the old calibration and re-measure the boosted verifier with fresh CALIBRATION labels — so the flywheel's output is measured by the same audit that produced its training labels. A boost that is not actually better is caught twice: by the bootstrap gate at install time, and by the new version's calibration afterwards.min_labelsshortfall leaves the boost inactive; the wrapped verifier is returned raw. Default behaviour is unchanged.Wiring.
attach(..., boost=...)andAudit.recompute()retrain the boost when a batch of labels resolves — the moment a flywheel should learn.version_tag()is meant forattach(version_extra=...)so the audit's version tracks the boost.Backward compatibility
attach(boost=)NoneAudit.recompute()unchangedVerifierBoostversion_tag == "boost:inactive"VerifierBoost.cheap_eval/learned_evalVerifierBoost.eval_counts/full_evalAuditdataclassboost=Noneaudit/__init__exportsVerification
min_gainraises the bar (lenient accepts, strict refuses)eval_counts/full_eval/rule_eval) stays raw when activeversion_tagchanges on activation and is stable while activeAudit.recompute()retrains an attached boost; no-boost recompute unchangedPolicies(verifier=...), commits normally)docs/api.mdregenerated,test_api_referencegreenFiles
7 files changed, +618, -6
New:
agentdescent/audit/boost.py(+~330) —VerifierBoost,BoostState,isotonic_fittests/test_verifier_boost.py(+~300) — 14 testsModified:
agentdescent/audit/wiring.py—Audit.boost,attach(boost=),recompute()retrainsagentdescent/audit/__init__.py— exportsagentdescent/__init__.py—VerifierBoost,BoostState,isotonic_fitdocs/api.md— regeneratedCHANGELOG.md— entry