Skip to content

Add validated D2D MOBO workflow through Step 2C - #1

Draft
Colin-Qi wants to merge 21 commits into
mainfrom
colin
Draft

Add validated D2D MOBO workflow through Step 2C#1
Colin-Qi wants to merge 21 commits into
mainfrom
colin

Conversation

@Colin-Qi

Copy link
Copy Markdown
Collaborator

Summary

This pull request establishes the tested D2D MOBO workflow through Step 2C:

  • hardens the Python packaging, dependency constraints, CLI boundaries, and discrete design utilities;
  • adds read-only D2D workbook/schema adapters using the supplied Uniformity, Optoelectronic, and Thickness scores as three maximization objectives;
  • implements and tests R1 UCB-HVI batch selection, local penalization, and R2 qLogNEHVI compatibility;
  • adds GP validation, observation-influence analysis, nested Sobol search-convergence checks, exact-grid refinement, robust-region analysis, and proposal-stability gates;
  • provides synthetic CI workflows, public configuration templates, notebook guidance, campaign contracts, and handoff documentation;
  • removes tracked caches, editor state, and unreferenced legacy material from the public tree.

Why

Step 2B showed that candidate selection is sensitive to control inclusion, boundary behavior, and GP assumptions. Step 2C adds the validation and convergence evidence needed to assess those sensitivities reproducibly before any experimental batch is considered.

Safety and privacy

  • Public campaign configurations are template-only and fail closed for real workbooks.
  • Private workbooks, campaign configurations, local outputs, and private evidence archives are Git-ignored.
  • The source workbook is read-only and hash/mtime guarded; no workbook writeback is performed.
  • Stability is not used as an optimization objective.
  • Generated campaign artifacts remain watermarked DEBUG ONLY - NOT APPROVED FOR EXPERIMENT.
  • Public ZIPs contain aggregate-only summaries; private evidence is kept in a separately labelled ignored archive.
  • This PR does not approve an R1 experimental batch.

Validation

  • Full test suite: 427 passed, 2 skipped
  • Black formatting: all 67 changed/new Python files clean
  • Python compilation and dependency consistency checks passed
  • YAML and notebook structure checks passed; the D2D notebook has no stored outputs or execution counts
  • Git diff integrity and final privacy/credential scans passed
  • Remote branch is one commit ahead of main and zero commits behind

Reviewer focus

Please review the public/private configuration boundary, objective and reference-point contracts, GP validation gates, robust-region consensus criteria, and whether the current debug-only evidence is sufficient to proceed to a later experimental approval step.

Colin-Qi and others added 21 commits July 27, 2026 12:00
The R0 models were not learning. Two separate degenerate modes were found and
fixed, one objective was made learnable by modelling the right quantity, and the
three-round campaign path now runs end to end.

Model
- Retire `default_current` to `legacy_matern_no_prior` and register
  `dim_scaled_prior` as the default. The old contract is kept byte-identical so
  archived Step 2B/2C runs stay reproducible; it must now be asked for by name.
- BoTorch 0.15.1 already supplies a dimension-scaled LogNormal lengthscale prior;
  MOBO-Kit was discarding it by passing an explicit covar_module. Restored.
  Median ARD lengthscale 1121 -> 0.88, flat directions 6/10 -> 0/10.
- The lengthscale prior alone opens a second degenerate mode: the outputscale
  collapses and the model calls the data pure noise (10/15 thickness folds,
  latent sd 1e-4 against fitted noise 0.93). Both priors are now required, and
  `_assert_signal_not_collapsed` checks it numerically on every fit, because a
  config name cannot prevent a degeneracy on refit.
- Predictive interval coverage moves toward nominal on all three objectives.

Objectives
- Thickness trains on nanometres, not on its score. The score is a peaked
  Gaussian on 650 nm, so the map is 2-to-1 and destroys learnable signal.
  LOO R2 goes -0.503 (score) to +0.384 (nm with a structured mean).
- Physics-informed mean functions, declared per objective in config. Thickness
  needs log(speed_1)+log(precur_conc); optoelectronic needs a single linear
  anneal_temp term. Opposite shapes; neither generalises. Optoelectronic's plain
  GP sat below the null at -0.342.
- `ObjectiveSpec.model_link` records that a GP output is log-space. Link decode
  happens in exactly one place, so the sampling and quadrature paths cannot
  disagree; a test asserts they match to 4e-3.
- Lognormal utility expectations use Gauss-Hermite quadrature. Moment-matching
  is ~500x less accurate and its bias changes sign across the range, which
  reorders candidates rather than shifting them.
- Reference point declared in utility space. The old raw-scale point gave the
  optoelectronic axis 4.01x the uniformity axis.

Campaign
- `campaign.py`: run_r0_lhs / run_r1_ucb(5) / run_r2_qlognehvi(3), orchestrating
  the existing modules. 23 distinct conditions, three replicate films each.
- The canonical config is runnable: resolved objectives, fixed scales, declared
  mean functions.
- Debug/production approval tiers replaced by one validity check (count,
  uniqueness, on-grid, finite, spacing). Approval is a human decision recorded
  outside the code.
- `assert_scaling_is_campaign_fixed` runs inside `build_objective_transform`, so
  no transform can exist without it. Data-derived scaling would make hypervolume
  incomparable between rounds.

Workbook
- `workbook_io.py` reads the source and writes candidates to a sibling file.
  openpyxl discards cached formula values on save, and Uniformity score is a
  formula column, so adding sheets to the source would blank a training column
  for every non-Excel reader. Verified directly.
- Entry columns come from `model_source_columns(config)`, so thickness-in-nm is
  collected. Round detection fails closed on a partly scored sheet.
- UTF-8 forced at every boundary; utf-8-sig for Excel-facing CSVs.

Docs: GP_MODEL_DECISION.md records the numbers, both resolution floors, and the
decision to keep sample 1. CAMPAIGN_STATUS.md orients collaborators and lists
open issues.

Not validated for fabrication. See CAMPAIGN_STATUS.md: an unexplained 0.089
discrepancy on optoelectronic, column AA is a stale pasted literal rather than a
formula, and no proposed batch has been reviewed by a human.

Tests: 517 passed, 2 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Runs a synthetic 3-objective, 10-input problem with a known Pareto front through
run_r0_lhs -> run_r1_ucb(5) -> run_r2_qlognehvi(3). Nothing touches the
experimental data, so it answers "does the algorithm work" separately from "are
the measurements right".

Two sign conventions fail silently and are now covered:
- DTLZ2 minimises by default; negate=True is mandatory or the test would measure
  the opposite of optimisation.
- BoTorch's Hypervolume assumes maximisation and silently DROPS points that do
  not dominate the reference -- no warning, no exception, just a smaller number
  or 0.0. The helper asserts at least one point dominates first.

On the optimisation claim: cumulative hypervolume rises monotonically by
construction, so that alone would pass for random sampling. The test therefore
compares against a random baseline at equal budget, and asserts the MEAN gain
rather than a per-seed win: measured 5 of 8 seeds, mean gain +0.075 vs +0.056,
a ratio of 1.35x. With 8 added points in 10 dimensions that is the honest
expectation; asserting a per-seed win would be flaky and false.

Structural invariants covered: exact batch sizes (5 and 3, 23 distinct
conditions), uniqueness, on-grid, in-bounds, finiteness, batch spacing well
above the configured floor (0.735 and 0.859 against 0.15), proposals distinct
from observed points, and determinism at a fixed seed.

Pool sizes are shrunk for runtime; R2 is the bottleneck and mc_samples is the
cheapest lever. Fast tests ~12 s, the multi-seed comparison ~33 s behind a new
`slow` marker.

Also records a finding from the recon: metrics.compute_ref_pareto_hv builds its
automatic reference as mins - 1e-8, giving a degenerate 6e-8 hypervolume against
1.448 from botorch's infer_reference_point on the same data, and recomputes it
per call so values are not comparable across iterations. The production path
passes an explicit reference and is unaffected.

Tests: 528 passed, 2 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
That machinery existed to audit a GP model this branch has since replaced. Its
findings are recorded in docs/GP_MODEL_DECISION.md, so the code itself no longer
earns its place: it was 16,442 lines that nothing on the campaign path imports.

Removed 50 files -- 14 source modules, their tests, 5 examples, 4 configs, 9 docs
and the Step 2B notebook -- plus stale demo/experiment output. Source drops from
~24k to ~9.6k lines; the campaign path itself is 7,073 lines across 16 modules.

Everything is recoverable: tag pre-cleanup-2026-07-29 holds the full tree.
  git show pre-cleanup-2026-07-29:src/mobo_kit/<file>.py

Kept despite not being on the campaign path yet, because they are genuinely
useful rather than historical: discrete_refinement (exact-grid local search),
sobol_pool (nested pools), candidate_diagnostics and plotting (both wanted for
the visualization work).

production_gate.py went with the ceremony. Its one valuable check -- that
objective scales are never re-derived from observed data, which would make
hypervolume incomparable between rounds -- was already extracted into
campaign.assert_scaling_is_campaign_fixed, where it runs inside
build_objective_transform and cannot be bypassed.

main.py's propose_candidates branch was permanently blocked by that gate, i.e.
dead. It now raises a message pointing at mobo_kit.campaign, which is the real
proposal path and carries the objective contract and batch validity checks that
the legacy path never had.

Naming made consistent: configs are campaign_d2d_perovskite.yaml,
example_demo.yaml, example_from_csv.yaml. No file is named after a step number
any more.

README rewritten around the campaign loop, the DTLZ2 acceptance evidence, the
current parameter values, and where the removed history lives.

Tests: 280 passed (was 528; the difference is tests for removed modules).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reading order, the open issues in the order I would work them, the two
resolution floors (-0.148 null and +/-0.236 resolution sd) that this project
argued inside twice, the settled questions not to reopen (sample 1 stays,
speed_2 does not explain the low-speed contradiction, uniformity has no
learnable signal), and the two tooling traps that fail silently (openpyxl
dropping cached formula values, BoTorch Hypervolume dropping non-dominating
points).

Also records the working advice: develop against DTLZ2, not the campaign
workbook, since anything data-specific lives in config.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three of the workbook's derived score cells are pasted literals rather than
formulas, so they do not update when the measurements behind them are edited.
That is the failure that produced the original uniformity discrepancy, and an
audit of all 15 R0 rows found the same divergence already present between AB and
Y: Y evaluates the thickness Gaussian on the rounded X, AB was pasted from the
same Gaussian on the unrounded T mean, and the two disagree by up to 1.7e-3.

So the polarity is inverted. scores.py computes each objective from the raw
measurement columns via a recipe declared in config, and the stored cells become
cross-checks that warn on disagreement with a per-column tolerance -- a live
formula and a deliberately rounded literal do not deserve the same one. On the R0
rows the recomputation reproduces Z to 1.1e-16, AA/R to 1.8e-15, and X to the
0.5 nm its rounding allows, so no campaign number changed except that thickness
now reaches the GP unrounded.

The formulas come from the removed d2d_scores.py with the polarity inverted;
recover the original with
  git show pre-cleanup-2026-07-29:src/mobo_kit/d2d_scores.py

Also here, because they share the read path:

- read_candidate_results aggregates a filled-in candidate sheet to design points,
  so R1 -> R2 can advance at all. Thickness averages across replicate films in
  LOG space, matching the space the GP trains in and the train_Yvar pooling
  policy; within-film T1..T4 stays arithmetic, being a spatial average over one
  film. replicate_spread keeps the scatter that aggregation discards.
- The candidate sheet now asks for raw measurement columns instead of derived
  scores, and round detection knows two thickness readings is complete while a
  missing Coverage is not.
- fit_campaign_models and normalise_inputs are public, so callers stop reaching
  for _fit_models.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
compute_ref_pareto_hv defaulted to Y.min(dim=0) - 1e-8 when handed no reference,
essentially the nadir of whatever data it was given, and re-derived it on every
call. Two consequences: every hypervolume slab could be 1e-8 thick, and two
rounds were measured against two different reference points, which is exactly
what tracking hypervolume across rounds is supposed to rule out.

Passing no reference now raises and names reference_point_utility.

The precise condition, since the old issue text overstated it: mins - 1e-8 is
harmless while some dominated point sets the per-objective minima, and collapses
once the Pareto set itself sets them -- each point best in one objective and
worst in another, which is what a genuine trade-off front is. So the default was
worst exactly where the function mattered most. Pinned in a test.

A reference that nothing dominates now also raises rather than reporting 0.0.
BoTorch's Hypervolume assumes maximisation and silently drops non-dominating
points, so an unreachable or wrongly signed reference is indistinguishable from
a campaign that has not beaten its baseline yet.

There were no tests on this function at all, which is how it survived: it
returned a plausible number, and plausible numbers do not get questioned.
main.py already passed an explicit reference. The demo notebook had one bare
call, in a cell that then declared a different reference on the next line; its
cached output came from the degenerate path and has been cleared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifteen films is a real cost, and nothing here showed a human what a batch meant
-- only that it passed its validity checks.

batch_review.py writes a Review sheet into the candidate workbook and echoes the
same text into the launcher pane, so the artifact can be forwarded to the
experimental group on its own. Per candidate: inputs in physical units; predicted
utility and sd per objective through posterior_utility_moments, the function the
acquisition itself called; the prediction decoded into the measurement's own
units; normalised distance to the nearest observed point; and which coordinates
sit at a range edge, not only how many.

For a log-link objective the decoded value is the posterior MEDIAN with a
multiplicative interval, because exp of a mean of logs is not a mean. Labelling
it as such matters: the two get quoted back later.

Probes are declared in config under review:, because which counterfactual is
worth asking is campaign knowledge. A probe holds every other coordinate and
forces one input to a value worth interrogating. The informative comparison is
the sd, not the mean: UCB pays for uncertainty, so a region skipped while the
model still calls it uncertain is losing a trade-off, whereas a region skipped
while the model calls it certain has been resolved -- possibly into an average.
SD_MATERIALITY_RATIO makes that threshold explicit and the ratio is printed
either way, because on the campaign's own R0 fit the probed sd came out 1-8%
above the batch's on all three objectives, which a bare > reads as more
uncertain while predicted thickness utility falls from 0.79 to 0.22.

On the R0-trained batch the low-speed probe reports thickness utility 0.223
against 0.786 at an sd ratio of 1.02: skipped as known and bad, not unexplored.
And because speed_1 is a feature of the thickness mean function, that confidence
is a fitted global trend extrapolating to its range edge rather than a local
average of the two contradictory observations there -- which the verdict says,
derived from config rather than hardcoded.

The launcher is a tkinter shell over inspect_campaign, gather_observations and
generate_next_round, all testable without a display. It refuses an existing sheet
before the ten seconds of model fitting rather than after, shows plain sentences
for expected failures and a traceback only for unexpected ones, and approves
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he Step 1 spec

Stale claims, all verified against the code before changing:

- GP_MODEL_DECISION said the structured mean was not yet wired into campaign.py.
  It is; fit_campaign_models builds the mean modules. The same claim appeared as
  an open item and is now under Closed.
- CAMPAIGN_STATUS listed removal of the legacy debug ceremony as not started.
  production_gate.py and 22 other modules went in 33f101f, and
  test_validity_report_carries_no_approval_flags holds the tiers out.
- The config comment said to pool thickness replicate variance in NANOMETRES
  while claiming to match what the GP trains on. With response: log the GP trains
  on log T, so an nm^2 variance is wrong by a factor of T^2 -- 1.3e5 to 1.7e6
  across the observed thicknesses, not even a constant rescaling.

docs/D2D_CAMPAIGN_SPEC.md is deleted. Its grid rules are enforced by design.py
and campaign.validate_batch, its thirteen unresolved decisions blocking real R1
are resolved in config, and its workbook audit describes a revision of the
summary workbook that no longer exists -- it reports duplicate Uniformity score
headers at Q/T, which the current workbook does not have. The one fact that lived
nowhere else, the 177,816,994,740-recipe design space and why it must never be
materialised, moved to the README. Recover with

    git show 19591cc:docs/D2D_CAMPAIGN_SPEC.md

Also recorded: the workbook column audit and its numbers; sample 12's thickness
being ROUND(mean(1600, 709)); the within-film log-T spread of 0.244 over 24 dof
as a floor rather than an estimate of between-film variance; and a newly found
issue -- the signal-collapse guard cannot distinguish a collapsed GP from a mean
function that works, because it measures posterior variance, which a mean module
does not enter, then reports that the posterior mean is effectively constant.

Doc snippets now use the public fit_campaign_models and normalise_inputs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_assert_signal_not_collapsed compared the GP's latent sd against the fitted noise
and stopped there. A mean module is not part of the covariance and so never enters
posterior().variance, which means a structured mean that explains most of the data
drove the residual GP's latent sd to ~0 and the fit was refused -- with a message
asserting that the posterior mean is effectively constant, which is verifiably
false in exactly that case.

Two situations share the signature and now get different answers.

True collapse -- zero-mean GP, outputscale to zero, posterior mean genuinely flat,
nothing rankable -- still raises. The mean function having done its job now warns
and lets the round proceed: refusing there dead-ends the campaign at the moment
the physics model starts working, with no remedy available, because better data
cannot be collected without first proposing conditions. The review artifact is the
designed gate for a suspect batch.

The warning is not a formality and says why: UCB's exploration term reads the
latent posterior that just collapsed, and the mean module's coefficients are
frozen buffers with no uncertainty of their own, so the narrow intervals such a
model reports are understated rather than earned. It surfaces above the numbers in
the launcher pane and the Review sheet, and in
RoundResult.diagnostics[model_fit_warnings].

Two calibration decisions worth keeping:

- Near-constant is measured against the objective's observed spread, floor 5%, not
  against the fitted noise sd. Noise-relative was the first attempt and is wrong:
  the noise is inflated precisely in the degenerate case, so the test co-varies
  with what it is trying to detect. Measured instance -- a linear mean on
  anneal_temp against a forced noise of 0.9 scored 0.38 on the noise yardstick and
  would have been called constant while it was tracking the data.
- Only the guard's own warnings reach a human. record.warnings also collects every
  Python warning raised while fitting, about 18 numpy-2.0 deprecation notices per
  fit on this stack, and putting those in front of someone reviewing a batch is how
  people learn to ignore warnings.

Whether a dataset trips the collapse is knife-edge: measured across residual
magnitudes from 0 to 0.3 it fires at 0, 1e-4, 0.01 and 0.03 but not at 0.001 or
0.1, depending on where the MLL optimiser lands. So the guard's decision is tested
directly on both branches, and the propagation tests force the condition rather
than hoping data produces it. No fit on the current R0 data warns; the live
campaign is unchanged.

fit_campaign_models now returns (model, warnings).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
test_the_window_shows_a_readable_error_rather_than_a_traceback failed in a
full-suite run and passed on its own. Two real problems, both mine:

The launcher schedules a check() 200 ms after construction when it remembers a
workbook. An earlier test in the same file checks a real workbook, which writes
that path to ~/.mobo_kit/launcher.json. If pytest's tmp_path from that run still
exists, the next window auto-checks it, and that result races the explicit check()
the test performs -- overwriting the pane with a successful status where the test
expected an error. Order-dependent, so it only appeared once the suite grew.

Second, and true regardless of the race: a test suite has no business writing to
the user's home directory.

Both fixed by an isolated_settings fixture that stubs remembered_workbook to None
and remember_workbook to a no-op. Verified with three consecutive runs of the file
and two consecutive full-suite runs.

Noted for the record: the previous commit was made in the same shell invocation as
its verification run and did not stop when that run reported this failure. Verify
first, commit second.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rnings

Two hardenings.

The launcher runs its work off the main thread and reports back through a queue,
so a reply could paint the pane with an answer to a question the user had moved
on from. Two ways in: the auto-check scheduled 200 ms after startup against the
remembered workbook, and a second press while the first was still running. Human
reaction times made it unlikely rather than impossible, and the previous commit
only papered over it in the test fixture.

Now each dispatch takes a request id and a reply carrying a stale one is dropped;
a status reply also names the workbook it examined and is dropped if the selection
has changed since. A dropped reply still clears the busy state -- without that the
fix would trade a rare wrong answer for a window that disables its own buttons
forever. Pending auto-checks are cancelled as soon as the user browses or presses
anything.

The drop rules are tested by putting messages on the queue, not by racing two real
threads. The first version of those tests did race them, passed alone, and failed
intermittently in a full-suite run. A flaky test of a race-condition fix is worse
than no test, because it teaches people to re-run until green.

Second: the unfiltered fit-warning list is retained under
diagnostics[fit_warnings_raw], unsurfaced. The human-facing channel stays
guard-only -- 18 numpy deprecation notices per fit is how people learn to ignore
the one warning that matters -- but a BoTorch or scipy convergence warning the
filter dropped is exactly what someone will want when a fit looks strange weeks
from now. Entries carry objective, stage and category.

Also noted next to the guard's spread-based threshold: it is a diagnostic and
never touches utility space, so it does not fall under the campaign-fixed-scaling
rule, which governs the objective scales feeding hypervolume. Written down so it
is not later "corrected" to a fixed constant.

Verified: three consecutive runs of tests/test_launcher.py and three consecutive
full-suite runs, 399 passed each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each proposed condition is run in triplicate, and the scatter across those films
is the only direct measurement this campaign has of its own reproducibility.
replicate_variance.py pools it and hands it to the GP as train_Yvar, so the
marginal likelihood no longer has to guess the noise from 15 points in 10
dimensions.

Wired now, enabled later: model.observation_noise stays
fit_from_marginal_likelihood until the R1 triplicates land, and turning it on is
then one config key. That is the point of building it before the data exists --
arrival should be a data event, not a code event -- so it is tested against
synthetic replicates end to end.

Four things it is careful about, three of which fail silently otherwise:

- The variance handed over is of the MEAN, pooled / n_films, because the
  observation is an average of n films. Passing the single-film variance
  understates it threefold on a triplicate.
- BoTorch accepts BOTH train_Yvar and an explicit likelihood and then silently
  ignores the variance: the likelihood wins, stays single-element, and the
  replicate information is dropped with no error. Verified on 0.15.1.
  _build_single_task_gp now passes one or the other, never both.
- Standardize rescales train_Yvar along with the targets, so it must arrive in
  the target's own units -- and in the model's space, which for thickness is
  log T, not nanometres. Aggregation and pooling therefore share one space by
  construction.
- Zero pooled variance is refused. Replicate films agreeing to the last digit are
  a transcription, not a measurement, and a zero train_Yvar tells the model the
  observation is exact.

Between-film and within-film stay distinct, in the config comment and in the
module docstring. Between-film is the quantity train_Yvar needs. The within-film
0.0593 on log T (24 dof) contains no run-to-run variation, so it is a FLOOR:
sanity_floor_findings reports a pooled estimate below it, because films cannot be
more reproducible than points on a single film.

The collapse guard now averages the noise vector rather than reading its first
element, since a FixedNoiseGaussianLikelihood carries one value per observation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two operator-facing scripts, plus the results of running the first one.

scripts/dtlz2_parameter_sweep.py sweeps UCB beta against the local-penalization
radius on DTLZ2, whose Pareto front is known, so the answer does not depend on
whether the campaign's measurements are right. The decision rule is written in the
script above the code and was committed before any number existed: keep 4.0 / 0.25
unless a cell beats its mean hypervolume gain by more than the per-seed sd of
gains without reducing spacing.

Result, 8 seeds per cell: NO CHANGE. The default scores +0.0780 with a per-seed sd
of 0.0428, so a challenger needed +0.1208; the whole grid spans +0.0776 to +0.0961
against sds of 0.043 to 0.074. Seven cells have a higher mean and none is close.
Flat within noise, which is the outcome that says the default was not a lucky
pick. BO beats the random baseline on the mean in 9 of 9 cells, and the
edge-coordinate count is flat at 16-17 of 80 everywhere -- so neither knob is what
drove the live campaign's batches onto range edges. That was the monotone
anneal_temp mean function, as the review artifact said.

Stated as a limit rather than buried: radius is NOT properly tested by this sweep.
DTLZ2 batches land 0.72-0.98 apart, far outside every radius tried, so local
penalization rarely has two candidates close enough to penalise -- visible in
beta=8 giving bit-identical results at radius 0.15 and 0.25. The sweep validates
beta and says little about radius.

scripts/intake_new_data.py is the one command to run when the group returns
re-measured or corrected data. It audits the read, checks the anchors still span
the data and that the campaign-fixed scaling guard passes, recomputes BOTH floors
at the new N rather than reusing the N=15 values, and gives a per-objective
verdict: keep the mean function only if it beats plain by more than the resolution
floor, and name the exact config block to delete otherwise. It also reports the fit
guard's status per objective, which exercises the warning path added in f528f84 --
cleaner re-measured thickness is exactly the data that would trip it.

On the current 15 rows it reports uniformity failing to beat the null (-0.6809),
optoelectronic keeping its mean function (-0.3420 -> +0.2670) and thickness keeping
its (+0.1160 -> +0.3806), with a clean guard for both.

Also repaired: scripts/plot_dtlz2_report.py called the private _fit_models, whose
return type changed when fit warnings started travelling with the model. It now
uses the public fit_campaign_models. The CAMPAIGN_STATUS snippet had the same
staleness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 0.089 optoelectronic discrepancy is closed as a numerical artifact. The
decisive experiment -- disable Standardize in both pipelines -- was run: the gap
does NOT collapse, moving only 0.0881 to 0.0715. So the standardization scale is
about a fifth of it, not the cause.

The rest is the MLL optimiser. Without the outcome transform the two pipelines are
mathematically the same model: a zero-mean GP on (y - trend) and a fixed-mean GP on
y with mean trend have identical marginal likelihoods, because a fixed mean only
shifts the data. Yet the fits land in different places -- across folds the
outputscale differs by up to 2.7%, the noise by 2.7%, the median lengthscale by
9.6% -- and at N=15 that is worth 0.07 of LOO R2. Deterministic, not stochastic:
the earlier seed sweep was bit-identical across four seeds.

There was never a modelling question to answer, and 0.0881 sits well inside the
+-0.236 resolution floor, so it was never evidence of anything.

Second: the intake command and the decision record disagreed on plain thickness,
+0.116 against +0.183. Same 15 rows and no resampling, so per this project's own
rule that had to be explained rather than absorbed. It is entirely the DATA, not
the method:

  rounded X    two-stage +0.1830   mean module +0.1830
  unrounded    two-stage +0.1160   mean module +0.1160

The pipeline contributes exactly nothing -- with no mean function the two routes
are the same code -- and the whole 0.067 comes from 7 of 15 rows changing by at
most 0.50 nm. Half a nanometre on seven rows moves LOO R2 by 0.067, which is the
same fragility that produced the 0.089. The structured numbers move by 0.006 and
the swing that justifies the mean function clears the floor either way, so no
conclusion changes.

intake_new_data.py is now declared canonical, and GP_MODEL_DECISION says at the top
which instrument produced its tables and that it reads a column the model no longer
trains on.

Third: radius is now verified by construction. The DTLZ2 sweep could not test it --
batches land 0.72-0.98 apart, outside every radius tried -- and the live campaign's
R1 spacing of 0.921 says the same. Inert is fine for a safety knob, but its function
should not be inferred from a campaign that never exercised it, so there is now a
test with three candidates crowded 0.02 apart scoring above an isolated fourth:
greedy takes the two best, penalization pushes the second pick past the radius. A
companion pins the inert case.

Also: the single-film variance comment had its direction inverted -- passing it
would OVERSTATE the mean's variance threefold, not understate it. The implemented
choice was right, the sentence was not. And the plot script's unused import is gone
with its blanket warnings filter scoped, so a fit-guard message can still get
through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The project had two floors to check before comparing LOO R2 values. It has three.

Two measurements this week, from completely different directions, each moved LOO R2
by about 0.07 at N=15 without changing anything meaningful: the MLL optimiser
landing elsewhere on an IDENTICAL likelihood surface (0.0715, from outputscale and
noise differing by under 2.7% and the median lengthscale by 9.6%), and rounding the
thickness input to whole nanometres (0.0670, from at most 0.5 nm on 7 of 15 rows).
Neither is sampling noise -- both are deterministic and reproducible. So a
second-decimal difference in LOO R2 at this N is below what the metric can
reproduce even on identical data with identical models.

Where the sampling floor says a difference may be luck, the numerical floor says it
may not be a difference at all. Both were learned by walking into them.

A corollary worth its own line: under the same rounding the plain GP moved 0.067
while the structured model moved 0.006. A model whose answer turns on half a
nanometre is reporting arithmetic; one that ignores it is reporting a trend. That
is independent evidence for the mean function, arrived at without looking at either
model's score.

Also in this pass:

- The 0.089 closure now leads with the mechanism and cites the floor as a
  corollary, so the record shows this project's "explained, not absorbed" rule was
  satisfied rather than sidestepped.
- Confirmed and strengthened the sentence stating that the thickness mean
  function's evidentiary weight is the rank permutation (p = 0.0350, CI
  [0.0270, 0.0446]), with the R2 swing merely consistent -- it survived the
  reconciliation edits, and now says explicitly that the reconciliation could not
  have touched it, being about ranks rather than a regression score.
- CAMPAIGN_STATUS's model-state table now carries the canonical intake numbers
  (+0.116 -> +0.381 thickness, -0.342 -> +0.267 optoelectronic) with the older
  figures explained rather than silently contradicted.
- HANDOFF rewritten as a conclusion: where the project stands, what is actually
  open, the three floors, the four tooling traps, and which instrument produced
  which numbers. Its old "open issues" list was mostly closed items.
- Stale text removed: issue 3 still described the 0.089 as unexplained with
  untested suspects; issue 8 was headed "Not started" for work that is done; a
  cross-reference pointed at an issue number that had shifted.
- README gained a runnable snippet showing where Y_model comes from -- the
  computed values, not the workbook's stored score cells -- and a note that the
  two tuned parameters were checked under a pre-committed rule.
- Sampling floor and resolution floor are the same number under two names; the
  floors section now says so rather than leaving a reader to guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audited the notebook and the requirements, neither of which had been checked
against this arc's changes.

The requirements are clean: all 17 pins match what is installed, and every
pyproject range contains its pin. Its header still pointed at
docs/STEP1_HANDOFF.md, removed in the cleanup commit, and said nothing about why
linear_operator is pinned when pyproject does not declare it. Both fixed -- the
pin matters because it is a gpytorch/botorch transitive dependency whose version
affects fitted numbers, and a second-decimal change in LOO R2 is inside the
numerical floor.

The notebook is structurally sound: every mobo_kit import resolves, no call passes
a keyword the current signature does not accept, and it no longer reads stored
score columns, reaches for private helpers, or calls compute_ref_pareto_hv without
a reference.

What it does do is build its GPs with models.fit_gp_models and
models.loocv_select_models -- the prior-free ScaleKernel(MaternKernel(nu=2.5, ard))
construction that GP_MODEL_DECISION.md documents as degenerate on small data: ARD
lengthscales from 0.13 to 38,000, six to nine of ten directions switched off, noise
pinned at its floor so the model believed the data were noiseless. That is the
model this project spent a session replacing, and a reader following the notebook
as the recommended path would rebuild it.

Rather than rewrite a demo that works and cannot be re-executed here, the notebook
now opens with a scope note: it is the general toolkit API on an arbitrary CSV, not
the campaign path; the campaign uses fit_model_variant with dim_scaled_prior via
campaign.fit_campaign_models; objective values are computed rather than read; and
compute_ref_pareto_hv now requires an explicit reference. HANDOFF says the same in
one paragraph, and that models.py is legacy-but-live rather than dead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Runs R0 -> R1 -> R2 end to end with a GP fitted to the 15 real films standing in
for the measurements, so the loop can be exercised before the R1 triplicates
exist. 13 OFAT cells (9 radii at beta 4, 5 betas at radius 0.25), 45 input pairs
as views of one 10-D run per cell, and a manifest of batch hashes that answers
whether a knob changes the proposed batch at all.

Derived from Annie Xu's examples/round_simulations.py on ax_plots_simulation.
Her directory convention, slug rule, round legend and overlaid-boxplot figure are
preserved. More importantly, her branch already carried the fix for the R1
baseline mis-encoding that colin still has: run_r1_ucb hands the objective
transform measurement-space nanometres, which it exponentiates a second time,
pinning every observation's thickness utility to exactly 0.0. Her
_physical_to_model_output is that fix, written before we knew the bug existed.
This script uses the corrected encoding and records both baseline hypervolumes
(0.436442 against 0.004659) on every manifest row. campaign.py is deliberately
untouched here; the fix lands as its own change.

Two corrections recorded in docs/ROUND_SIM_DELTA.md are corrections to the brief,
not to her code: her exp(mu + v/2) was the correct lognormal mean and her
physical-mean label was accurate for it, and her slice fixing already used the
median with a grid snap. The oracle here still reports the median, for a
different reason -- a variance-dependent oracle bulges wherever R0 is sparse, so
the simulated ground truth would encode sampling history rather than belief.

Main finding: radius is not inert on this landscape. Achieved R1 spacing
staircases 0.455 -> 0.921 as radius goes 0.05 -> 0.45, saturating around 0.30,
and buys that spacing at a cost of 11 -> 15 range-edge coordinates. DTLZ2's
not-exercised conclusion does not transfer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
run_r1_ucb handed its observed HVI baseline to ObjectiveTransform.transform in
MEASUREMENT space. That transform is a model-output decoder: it applies exp() to
a log-link objective before computing utility, so thickness in nanometres was
exponentiated a second time. exp(360..1303) saturates the 650 nm Gaussian to
exactly 0.0 -- finite, so neither the transform's own finiteness check nor the
caller's fired -- and every observation's thickness utility was zero.

Measured on the 15 R0 rows: baseline hypervolume 0.004659 against a correct
0.436442, a factor of 94, with the baseline Pareto set collapsing from 5 points
to 2. Every candidate's improvement was scored against a front with no thickness
axis. On the live campaign's own R1 step this moves one of the five proposed
conditions (speed_1 4000 -> 2500, precur_conc 1.70 -> 1.45) and the batch's
minimum spacing from 0.9209 to 0.6337 -- so the 0.921 on record was itself an
artifact of the defect.

The fix is ObjectiveTransform.encode_measurements, with transform_measurements
as the one-call safe route, and run_r1_ucb encoding before it proposes. The
acquisition modules are untouched: ucb_hvi.py stays byte-identical and the
defect was in campaign.py orchestration.

Annie Xu found this independently on ax_plots_simulation and fixed it there as
_physical_to_model_output, before we knew it existed. This promotes her fix to
the public contract.

Audit of every ObjectiveTransform.transform call site: objectives.py 403/481/516
and ucb_hvi.py:342 all operate on posterior samples, which are already in model
space; batch_review.py never routes measurements through the transform at all
(it decodes exp(mu) itself and labels it a median). Exactly one call site was
defective, ucb_hvi.py:698, reached only from run_r1_ucb. R2 was never affected:
qLogNEHVI takes train_X_norm and derives its baseline through the model.

Two things about why it survived 446 tests, both now closed.

The baseline was invisible. It was a plausible finite number that nothing
reproduced independently -- the same shape as the hypervolume auto-reference and
the swallowed train_Yvar. run_r1_ucb now reports observed_baseline_hypervolume
and observed_baseline_pareto_size, and the tests recompute them by a separate
route. Reverting the encoding while keeping the diagnostic fails on the value,
not merely on a missing key; that was checked rather than assumed.

The synthetic acceptance test could not have caught it. Every DTLZ2 objective is
affine, and for an affine objective measurement space and model space are the
same numbers, so a link-encoding mistake is invisible by construction. DTLZ2 now
also runs with its third objective reached through a log link -- reported as
exp(f2), modelled with response: log, so the GP trains on the same latent
quantity by a different route -- and asserts that no measurement strictly inside
its anchors scores exactly zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The R1 batch was built on the mis-encoded UCB-HVI baseline fixed in 4b76670, so
it is withdrawn. Regenerated through launcher.generate_next_round -- the same
path the double-click launcher uses -- into
local_inputs/Summary Table_R1_Candidates.xlsx.

Four of the five conditions are unchanged. One is replaced: speed_1 4000 /
precur_conc 1.70 out, speed_1 2500 / precur_conc 1.45 in. Baseline hypervolume
0.004659 -> 0.436442, baseline Pareto set 2 -> 5 points, minimum spacing 0.9209
-> 0.6337. NO FILMS WERE FABRICATED from the withdrawn batch; the human review
gate caught it, which is what it is for. There was no prior candidate workbook on
disk to archive: the withdrawn batch had been described in CAMPAIGN_STATUS and
echoed to the launcher pane but never written.

The withdrawal is declared in configs under review.notes, so it travels with the
Review sheet if that is forwarded on its own. Delete that note once R1 is
measured. Full diff in docs/R1_BATCH_WITHDRAWAL.md.

Two things the defect had written into the docs, now corrected.

The claim that radius is "probably inert" on the live campaign rested on the R1
batch's minimum spacing of 0.921 -- a figure the mis-encoding itself inflated.
Corrected, the live batch spaces at 0.6337, and the round simulation measures a
monotone staircase: achieved R1 spacing 0.455 / 0.455 / 0.455 / 0.543 / 0.720 /
0.921 / 0.921 / 0.921 / 0.921 across radius 0.05 to 0.45, with nine cells
producing six distinct batches. radius binds below about 0.30 and saturates
above it, and buys spacing at a cost of 11 -> 15 range-edge coordinates. That
trade-off is a policy choice for the group. 0.25 stays the default as a declared
choice; the hypervolumes are single-seed and cannot rank cells, so re-run the arm
at ~5 seeds before moving it on performance grounds.

Issue 4's probe numbers turn out NOT to be artifact-born, and are kept rather
than voided. The 0.786 -> 0.223 fall with an sd ratio of 1.02 describes R1_C01,
one of the four conditions that survived the reissue, and re-reads identically
from the regenerated artifact. The batch-level figures are added alongside:
0.791 -> 0.299, mean sd ratio 1.10. The speed_1 = 1000 corner is still skipped
(minimum proposed speed_1 is still 1500) and anneal_temp still pins at 100-105.

The bug is recorded as CAMPAIGN_STATUS issue 9, and as the third
plausible-finite-number failure after the hypervolume auto-reference and the
swallowed train_Yvar: all three were finite, ordinary-looking wrong answers that
no test reproduced independently. GP_MODEL_DECISION files it deliberately OUTSIDE
the three floors -- a floor says when a difference is too small to argue about,
and never licenses accepting one that is simply wrong.

plot_round_simulation.py now calls the public run_r1_ucb instead of its own
corrected copy, verified to reproduce it hash-for-hash on three cells, so the
13-cell manifest already on disk stays valid. Its manifest carries the baseline
tripwire: reported (from the acquisition) must equal independent (recomputed by
another route) or run_cell raises, with the unencoded value kept beside them as
the size of the historical mistake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aims

README now separates the original design from the reconstruction: initially
designed by Ethan Schwartz, Daniel Abdoue, Nicky Evans and Tonio Buonassisi,
updated and reconstructed by Ziyang (Colin) Qi and Annie Xu.

Three stale claims the first doc sweep missed, all traceable to the same defect
or to work that has since landed.

test_batch_selection.py still carried "the knob is probably inert there too" in a
docstring, resting on the 0.921 spacing the mis-encoded baseline produced. The
test itself is unaffected -- it verifies the mechanism by construction, which is
exactly why it kept its value when the campaign evidence turned out to be wrong,
and the docstring now says so.

HANDOFF listed plotting as "the obvious next build"; it landed in f191730. Its
"settled, do not reopen" entry for radius = 0.25 is split: beta = 4.0 stays
settled, radius = 0.25 stays as a DECLARED POLICY CHOICE with the measured
staircase and the diversity-versus-edge-pinning trade-off, because the evidence
that made it settled was itself an artifact.

CAMPAIGN_STATUS's plotting section now points at the implementation, and its
round-comparison colours are corrected to the palette actually shipped
(R0 #2a78d6 / R1 #eb6834 / R2 #1baf7a, shared with plot_dtlz2_report.py) rather
than the grey/blue/orange it had described.

Also strips a UTF-8 BOM accidentally written into plot_round_simulation.py by a
PowerShell Set-Content during an earlier refactor. Python's loader tolerates it,
so nothing failed, but it is the only such file in the repo and would confuse any
tool reading the source as plain UTF-8.

Not done deliberately: `black` would reformat 22 files here, including
pre-existing core modules, so the repo is not black-clean as a baseline and
reformatting now would bury this work in unrelated churn.

Co-Authored-By: Claude Opus 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.

1 participant