Skip to content

until-N in-spec draws: draw until N pass the filters, not exactly N - #47

Open
d-burg wants to merge 2 commits into
mainfrom
feat/until-n-inspec
Open

until-N in-spec draws: draw until N pass the filters, not exactly N#47
d-burg wants to merge 2 commits into
mainfrom
feat/until-n-inspec

Conversation

@d-burg

@d-burg d-burg commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Until-N-in-spec: draw until N draws pass the filters, not exactly N

GenerationConfig.n_inspec_target = N keeps generate() drawing until N
draws pass both postprocess filters
(coil-current spec + LCFS deviation),
capped by max_total_draws (default 5 × N, never below n_equils).
n_equils becomes the initial allocation rather than the total, so a
40%-yield shot spends ~2.5× the solves of a 100%-yield shot for the same
delivered ensemble. Default None = historical behaviour, bit-identical:
the sampler's RNG stream is untouched when the feature is off (the
jBS_scales block draw is unchanged and only extended lazily past
n_equils; verified bitwise on live draws).

One predicate, two call sites

The stopping rule counts through new filtering.passes_all_filters — the
same passes_coil_spec / passes_boundary_spec / boundary_deviation_mm
composition the postprocess filters now also call — so the count the loop
stops on is the count .filter() marks selected. This is factored out of
the two filters rather than re-implemented: the in-loop [bnd-diag] print
queries the KD-tree in the opposite direction and would have over-counted.
Where the in-loop verdict can diverge (a draw whose high-res LCFS trace
failed), it diverges conservatively: "no measurement → not in spec", so the
delivered ensemble is at least N selected, never fewer.

Guardrails

  • Config validation at construction and at generate() (the notebook
    idiom mutates fields after construction): target ≥ 1, cap ≥ target,
    cap-without-target warns.
  • A boundary-bounded target with BNDDIAG=0 (no baseline contour) fails
    fast with the cause named, instead of grinding to the attempt cap on
    all-NaN verdicts.
  • Reaching the cap is loud and non-fatal: a printed warning + Python
    RuntimeWarning that the requested ensemble was NOT delivered; the
    archive holds every attempt either way.
  • The summary line reports delivered-vs-requested and whether the target
    was met, so a short bouquet cannot read as a completed run.
  • Serial only: run_shard / parallel_generate / emit_slurm_script
    reject a config with a target set (shards cannot see each other's yield;
    silently ignoring the field would hand back the wrong ensemble size).

Deliberate postprocess behavior change (flagged)

boundary_deviation_mm returns (nan, nan) for degenerate contours
(fewer than 2 points, or wrong dimensionality) where main's inline code
would build a valid 1-point KD-tree and return a real (bogus) number.
Re-running filter_boundaries on an old archive containing such a
pathological contour can flip that draw's verdict pass→fail. This is the
conservative direction and intended: an unmeasurable draw is not silently
in spec.

Hardening (from adversarial review)

  • The cap-missed outcome is surfaced outside the quiet-mode output
    capture: per-draw until_n_inspec verdicts are stored on the
    diagnostics and Bouquet.generate re-derives the delivered count after
    the capture exits, warning visibly on a miss.
  • Coil-channel fail-fast: SKIP_HARD=1 / coil_drift=None NaN every
    verdict — with a target set that is a livelock to the attempt cap, so
    it now raises pre-loop with the cause named (mirroring the boundary
    guard, whose shape check now also matches the metric's).
  • The loop's arithmetic is executed by tests via extracted helpers
    (_resolve_attempt_budget / _until_n_verdict /
    _extend_scale_block), which also validate the target for direct
    generate_bouquet callers and announce an explicit cap below the
    allocation. The archived [in-spec] flag routes through
    passes_coil_spec.

Tests

tests/test_until_n_inspec.py (solve-free, 50 tests): the shared
predicate (directionality, NaN semantics, report-only mode), config
validation, the lazy jBS_scales extension's RNG invariance, the
attempt-budget arithmetic, and the parallel rejections.

🤖 Generated with Claude Code

GenerationConfig.n_inspec_target keeps drawing until N draws pass both
postprocess filters, capped by max_total_draws (default 5x target).
n_equils becomes the initial allocation. Default None = unchanged.

The stopping rule counts through filtering.passes_all_filters, the same
predicate the postprocess filters use, so the count the loop stops on is
the count .filter() marks selected. Factored out of the two filters; the
in-loop [bnd-diag] metric queries the KD-tree in the opposite direction
and would have over-counted.

Serial only: parallel_generate/run_shard/emit_slurm_script reject a target.
RNG stream untouched when off (jBS_scales block draw unchanged, extended
lazily only past n_equils) - verified bitwise on live draws.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 4, 2026 19:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new until-N configuration path currently relies on int(...) coercion that can silently truncate non-integer values (and accept booleans), which can change requested ensemble sizing/cap behavior without an explicit error.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR adds an “until-N in-spec draws” generation mode so bouquet generation can keep drawing/solving until N draws pass the same postprocess filters (coil-current spec + LCFS deviation), with a configurable attempt cap and explicit serial-only enforcement. It factors the in-spec predicate into shared helpers so the stopping condition and filter() selection agree by construction.

Changes:

  • Add GenerationConfig.n_inspec_target / max_total_draws and implement an until-N stopping rule in generate_bouquet, including attempt budgeting, progress/summary reporting, and cap warnings.
  • Factor the “in spec” logic into bouquet.filtering (passes_* + boundary_deviation_mm) and route both the generation loop and postprocess filters through it.
  • Enforce serial-only behavior in parallel entry points; add solve-free tests plus documentation updates describing the new mode and guardrails.
File summaries
File Description
tests/test_until_n_inspec.py Adds solve-free tests covering shared predicate semantics, config validation, RNG invariance, attempt budgeting, and parallel rejections.
README.md Documents n_inspec_target and max_total_draws in the config reference.
docs/workflows.md Adds a dedicated “until-N in-spec draws” section and updates workflow documentation accordingly.
docs/api-reference.md Documents the new module-level predicate helpers and their relation to until-N.
bouquet/TokaMaker_interface.py Implements attempt budgeting, lazy jBS_scales extension, in-loop predicate counting, and cap warning behavior.
bouquet/run.py Surfaces until-N behavior via Bouquet.generate() and improves generation summary reporting for delivered-vs-requested.
bouquet/parallel.py Rejects until-N configs for parallel entry points to avoid incorrect ensemble sizing.
bouquet/filtering.py Introduces shared predicate helpers and reuses them in the existing postprocess filters.
bouquet/config.py Adds new generation fields and validates coherence in __post_init__.
Review details
  • Files reviewed: 9/9 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread bouquet/TokaMaker_interface.py Outdated
Comment on lines +4586 to +4599
# ---- Attempt budget -------------------------------------------------
# Without a target this is exactly range(n_equils), as it always was.
# With one, n_equils is only the initial allocation and the loop runs to
# the cap, breaking early the moment the target is met.
_until_n = None if n_inspec_target is None else int(n_inspec_target)
if _until_n is None:
_max_attempts = int(n_equils)
else:
_max_attempts = (int(max_total_draws) if max_total_draws is not None
else max(int(n_equils), 5 * _until_n))
if _max_attempts < _until_n:
raise ValueError(
f"max_total_draws={_max_attempts} is below n_inspec_target="
f"{_until_n}; the target could never be met")
Comment thread bouquet/config.py
Comment on lines +582 to +598
_tgt = self.generation.n_inspec_target
_cap = self.generation.max_total_draws
if _tgt is not None:
if int(_tgt) < 1:
raise ValueError(
"generation.n_inspec_target must be >= 1 (or None to draw "
"exactly n_equils)")
if _cap is not None and int(_cap) < int(_tgt):
raise ValueError(
f"generation.max_total_draws ({int(_cap)}) is below "
f"n_inspec_target ({int(_tgt)}): the cap would stop the "
f"run before the target could ever be met")
elif _cap is not None:
raise ValueError(
"generation.max_total_draws only applies with "
"n_inspec_target set; without a target the run draws exactly "
"n_equils")
Comment thread bouquet/run.py
Comment on lines +1179 to +1189
# BouquetConfig validates in __post_init__, but the documented notebook
# idiom mutates fields afterwards (`bq.generation.n_equils = ...`), so
# re-check the until-N pair here -- the point where they take effect.
if gc.n_inspec_target is not None and int(gc.n_inspec_target) < 1:
raise ValueError("generation.n_inspec_target must be >= 1 or None")
if gc.n_inspec_target is None and gc.max_total_draws is not None:
import warnings as _w
_w.warn(
"max_total_draws has no effect without n_inspec_target: "
f"this run draws exactly {n_equils} (max_total_draws="
f"{gc.max_total_draws} ignored).", UserWarning, stacklevel=2)
…fast, executed loop coverage

Adversarial-review findings on PR #47:

- The cap-missed RuntimeWarning (and every [until-N] line) was swallowed
  by capture_native_output on the default quiet path -- a run that failed
  to deliver the requested ensemble returned with zero visible signal
  unless .filter() happened to run.  The accounting block now stores a
  per-draw until_n_inspec verdict (plus rms/max/reasons) on the
  diagnostics, and Bouquet.generate re-derives the outcome OUTSIDE the
  capture via filtering.until_n_delivered, warning visibly on a miss.
- Coil-channel fail-fast: SKIP_HARD=1 / coil_drift=None / no recon Ip
  NaN every verdict, which with a target set is a livelock to the
  attempt cap (hours of solves); it now raises pre-loop with the cause
  named, mirroring the boundary-channel guard.  That guard's shape check
  now also mirrors boundary_deviation_mm's (ndim==2, not just len>=2).
- The loop's arithmetic is now executed by tests, not grepped: the
  budget resolution, the verdict glue, and the lazy scale extension are
  extracted into _resolve_attempt_budget / _until_n_verdict /
  _extend_scale_block, closing four one-token regressions the old
  source-text asserts waved through (including a positional F/VSC swap
  invisible while both thresholds are 2%).  _resolve_attempt_budget also
  validates the target for DIRECT generate_bouquet callers (target=0
  used to 'meet' itself after one draw; bools and non-integral floats
  now raise) and announces an explicit cap below the allocation.
- The archived [in-spec] flag routes through passes_coil_spec instead of
  a second inline implementation; _report_bnd documents its deliberately
  opposite query direction; cap<target set by post-construction mutation
  is caught in Bouquet.generate before the baseline work; README and
  workflows.md state the cap default's n_equils floor.

15 new behavioral tests (50 in the feature file); fast suite 439 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants