# Native HPO — Phase 1 implementation progress log **Branch:** `feat/native-hpo-phase1` · **Worktree:** `_worktrees/native-hpo-phase1` · **Scope:** `nirs4all-methods` only (stop before dag-ml / nirs4all-core / any other repo). **Plan:** [`FINETUNING_ROADMAP.md`](FINETUNING_ROADMAP.md) §6 (two-phase + parallel tracks) · [`FINETUNING_F0_PR.md`](FINETUNING_F0_PR.md) (F0 detail). **Rule:** each big block is Codex-reviewed before moving on. Everything stays green (`ctest dev-release`) + ABI-gated. ## Status board | Block | State | Codex review | Notes | |---|---|---|---| | Setup (worktree, baseline build) | ✅ done | — | isolated worktree; docs committed; toolchain OK | | **F0** — ABI surface + `random`/`none` slice + scaffolding | 🟢 done+reviewed | ✅ Codex | 365/365 tests; ABI 2.1; **Codex-reviewed (14 findings all applied)**. Catalog `--strict-abi` reconcile + HPO parity CI (Track Q) deferred. | | **F1** — samplers: sobol, lhs, ternary | ✅ done | 🟢 ternary+lhs | `ternary` + `lhs` ✅ **Codex-reviewed (7 findings applied)**; `sobol` ✅ **Tier-A bit-exact vs scipy** (embedded Joe–Kuo table), 380 tests + Python parity | | **F2/F5** — pruners: median, asha, hyperband, racing (+ fidelity engine) | ✅ done (pruners) | 🟢 median+asha+hyperband | `median`+`asha`+`racing`+`hyperband` ✅ **all 4 implemented + Codex-reviewed** (385 tests). `n_components` fidelity *engine* (F2c) → **Phase 2** (the pruner ABI already consumes any host-supplied rung stream; wiring a PLS learning-curve into a pruned finetune loop is dag-ml's orchestration job per the North Star). | | **F3** — RNG consolidation → ga, pso | ✅ done (samplers) | 🟢 Codex | `ga` + `pso` samplers ✅ (376 tests, shared `decode_candidate`); RNG-consolidation of the *feature-selection* loops deferred (HPO samplers are clean fresh impls) | | **F4** — cmaes, tpe, gp_ei | ✅ done | 🟢 cmaes+tpe | `cmaes` + `tpe` ✅ **Codex-reviewed**; `gp_ei` ✅ (GP+EI, from-scratch Cholesky, 381 tests, converges <0.03 across 10 seeds). **All 8 sampler kinds implemented.** | | **Q** — HpoSpec + comparators + parity CI | ✅ done | — | `parity/hpo/` — 12 HpoSpecs, golden traces, Sobol Tier-A vs scipy, pruner decisions vs independent pure-Python rules; 16 pytest + CLI gate wired into `parity-gate.yml`. | | **B** — bindings python→R/MATLAB/WASM + cross-binding gate | 🟡 python done | — | **Python binding ✅** (ask/tell wrapper + smoke/parity vs dev .so; cross-binding gate ✅ = Track Q golden traces). **R/MATLAB/WASM: blocked on runtimes** — not installable in this sandbox (no Rscript/Octave/EMSDK), so writing their C-shim marshallers blind can't be *validated*; they follow the Python thin-marshaller + established R `.Call` dispatch pattern and their acceptance test is the committed `parity/hpo/golden/*.json`. Left for a runtime-equipped env / CI. | | **catalog YAML** | ⬜ optional | — | HPO methods are enum-dispatched behind the shared `n4m_optimizer_*` surface (no per-method ABI symbols), so they don't fit the per-method-symbol catalog model. Docs + bib + Track-Q parity already cover "comme pour le reste". Deliberately deferred. | | **Phase 1** — native HPO in libn4m | ✅ **merged to main** | 🟢 | 9 samplers + 5 pruners + finetune_estimator + Python binding + Track-Q parity CI; ABI 2.1 (733 symbols). Merged `f69a2ec7..1733c0d9`. | | **Phase 1.5 / E2** — active-aware conditional cascade | ✅ **merged to main** | 🟢 Codex | Deactivate-only nested conditional activation (the substrate for sub-pipeline search). 387 tests. Merged `f53f2854`. E1 (multi-parent) / E3 (choice node) not needed by the nirs4all integration → deferred. | | **Phase 2 / nirs4all integration** — `engine:"n4m"` finetuning | ✅ **done (nirs4all repo)** | 🟢 Codex | `N4MFinetuneManager` (drop-in Optuna peer) + `when`/`when_not` conditional DSL + example U08 + docs + 22 tests. Merged to **nirs4all** `main` (`bff6710c`). Native TPE matches Optuna's optimum. | | **catalog YAML** | ⬜ optional | — | HPO methods are enum-dispatched (no per-method symbols); docs + bib + Track-Q parity cover "comme pour le reste". Deferred. | | **Remaining (cross-language)** | ⬜ | — | R/MATLAB/WASM finetuning bindings (thin marshallers over the native optimizer; acceptance = Track-Q golden traces) + the dag-ml cross-language pipeline compiler (`docs/PIPELINE_FINETUNING.md` P1–P6). | Legend: ⬜ todo · 🟡 in progress · ✅ done · 🟢 done+reviewed ## Log ### 2026-07-11 — Phase 1.5 (E2) + Phase 2 (nirs4all native finetuning) shipped - **Green light received** (ecosystem released, no other agent) → merged **Phase 1** to `nirs4all-methods` main, then built the pipeline-finetuning path. - **E2 (native enabler, merged):** rewrote `apply_conditions` to be **deactivate-only** — a param is active iff its own condition holds AND its conditional parent is active, resolved up the forest; the `#` cascade can no longer reactivate a dead branch regardless of declaration order. This is the correctness nested sub-pipeline search needs. **Codex-reviewed** (transcript 09: 1 Major + 2 Minor applied — order-independence, NOT_IN fail-closed, flat-space perf). 387 C++ tests; ABI unchanged. - **Phase 2 (nirs4all repo):** `nirs4all/optimization/n4m_engine.py` — `N4MFinetuneManager`, a drop-in peer of `OptunaManager` selected by `finetune_params={"engine":"n4m"}`. Same DSL/approaches/metrics/`FinetuneResult`; 9 samplers + 4 pruners; wired into `BaseModelController.finetune`. **`when`/`when_not` conditional clause** added (object__attribute conditional finetuning → uses E2). Example `examples/user/04_models/U08_native_finetuning.py`, user-guide section, **22 unit tests**. **Codex-reviewed** (9 Major + 3 Minor all applied — sorted_tuple, nested-static, approach routing, non-finite pruning, untold-trial, direction-blind aggregation, unknown-engine, seed=None, log-flag). Native TPE **matches Optuna's optimum** (`n_components=14`, identical best_value). 218 finetune tests pass. Merged to nirs4all `main` (`bff6710c`). - **Deferred:** E1/E3 native enablers (not needed by the integration); R/MATLAB/WASM finetuning bindings (runtimes absent here); the dag-ml cross-language pipeline compiler (`PIPELINE_FINETUNING.md` P1–P6) for full operator/sub-pipeline materialisation. ### 2026-07-10 — Pipeline finetuning design (`object__attribute` over sub-pipelines) - User asked: can we finetune a **pipeline directly** with the **operators in the search space** (tune sub-pipelines / whole pipelines, Optuna `object__attribute` style), not just scalars? Verified the native substrate in code and wrote [`docs/PIPELINE_FINETUNING.md`](PIPELINE_FINETUNING.md). - **Verdict:** the substrate is *already there* for a **flat pipeline, one operator-choice per slot** — categorical slot + `CONDITION_IN`/`_NOT_IN` gating + `#` name-cascade + `is_active` readback + TPE tree-sampler (exactly the `kernel/gamma` test). What's missing: (a) **the pipeline⇄space compiler + materialiser** (the bulk → **dag-ml** graph compile/materialise + a **nirs4all Python controller** for the `object__attribute` UX → **Phase 2**), and (b) a few **native enablers** for deep/branchy spaces — **E2** active-state-aware topological cascade (behavioural, no ABI), **E1** multi-parent conditions (ABI minor), **E3** first-class choice/branch node (ABI minor), **E4** bounded variable-length (convention). None needs new numerical kernels. Full phased plan (E1–E4 native; P1–P6 dag-ml/nirs4all) + dependency DAG in the doc. **Planned only — not implemented; handed back to the user.** ### 2026-07-10 — Phase-1 (verifiable) consolidation — full green, ABI invariant held - **Full project gate green:** `ctest --preset dev-release` = **2/2 binaries pass** (n4m_tests 385 + n4m_internal_tests; labels abi/parity/smoke). **version-sync OK** (project 1.0.9, ABI 2.1.0). **ABI symbol surface unchanged: 733 == 733, 0 added / 0 removed** — proves the *enum-value-only* invariant held across **all of F1–F5**: the entire sampler+pruner library plus the two hyperband option fields (carved from `reserved[]`) added **zero** public symbols beyond the single F0 ABI freeze. `sizeof(n4m_optimizer_options_t)` verified C == Python == 120. - **What's done & verified (nirs4all-methods, this worktree):** 9 sampler kinds (random, sobol[Tier-A], lhs, ternary, ga, pso, cmaes, tpe, gp_ei) · 5 pruner kinds (none, median, asha, hyperband, racing) · `n4m_finetune_estimator` · Python binding · **Track-Q HPO parity CI** (golden traces + Sobol-vs-scipy + pruner-vs-independent-reference, wired into `parity-gate.yml`) · per-method docs + bibliography · **7 Codex reviews applied** (F0/F1/F2/F3/F4/sobol-gp/hyperband). - **Honest remaining scope (NOT sandbox-verifiable or Phase-2-coupled):** (a) R/MATLAB/WASM bindings — need Rscript/Octave/EMSDK, absent here; pattern-ready, acceptance test = the committed golden traces; (b) `n_components` fidelity *engine* — the pruner ABI already consumes any host rung stream, so the PLS-learning-curve-driven pruned finetune loop is dag-ml orchestration (Phase 2, North Star); (c) catalog YAML — HPO methods are enum-dispatched (no per-method symbols), awkward fit, optional. **Stopping here per the goal: nothing touched outside nirs4all-methods; awaiting green light to merge + start Phase 2.** ### 2026-07-10 — Track Q: HPO cross-binding parity CI + hyperband Codex review applied - **Track Q (`parity/hpo/`)** — the HPO parity harness, the analogue of the numeric fixtures. `specs.py` (`HpoSpec` + a 12-cell `REGISTRY`: every sampler ≥1×, mixed/categorical, + median/asha/hyperband pruner cells), `objectives.py` (portable closed-form objectives so every binding computes the same `tell()` score), `run_native.py` (spec → StudyTrace via the Python binding), `comparators.py`, `references.py` (scipy Sobol + **independent pure-Python reimplementations** of the median/asha/hyperband rules), `run.py` (CLI gate) + `golden/*.json` (committed cross-binding contract) + `tests/test_hpo_parity.py`. Checks three things: **golden-trace stability** (same seed → same proposals, the cross-binding guarantee the R/MATLAB/WASM bindings must meet), **Sobol Tier-A == scipy**, and **native pruner verdicts == the pure-Python reference** (a second implementation guarding the native one). **16 pytest pass** + CLI gate green; **wired into `.github/workflows/parity-gate.yml`** (the job that already builds libn4m). The pruner-decision cross-check passing (median 9, asha 11, hyperband 10 pruned, all matching an independent reimplementation) is strong evidence the native pruners — including brand-new hyperband — are correct. - **Hyperband Codex review** (transcript 08: **0 Blocker, 3 Major, 1 Minor — all applied**). **Major:** (1) removed the moving-high-water-mark `R` derivation — `max_resource` is now **required (>0)** (`make_pruner` → `N4M_ERR_INVALID_ARGUMENT` for 0) so the bracket count is stable for the study's lifetime; (2) `int64_t` resource/`R` (no `step+1` overflow) + `step<0` guard; (3) `k > k_max` guard so rungs above `R` never prune. **Minor:** `test_hyperband_edges` (reject `max_resource=0`; no-prune above `R`). Pure-Python reference mirrors the same rule; `hyperband_prune` golden unchanged. **385 passed, 0 failed.** ### 2026-07-10 — sobol+gp Codex review applied (transcript 07) - Codex review of the sobol+gp block: **0 Blocker, 3 Major, 1 Minor — all applied** (`docs/reviews/finetuning-roadmap/codex-review-07-sobol-gp.md`). **Major:** (1) Sobol OOB after 2^30 asks — added an `exhausted_` guard; once the Gray-code column would exceed 30 bits the sampler falls back to base random (no OOB read). (2) GP fake-distinct observations — the GP now stores the **decoded** (snapped) continuous-axis coords, so two asks that decode to the same integer share one coordinate (models real observations, not fakes); kept int axes in the GP — `n_components` is an int and continuous relaxation is standard (consistent with the approved CMA-ES) — which fixes Codex's exact failure without crippling integer HPO. (3) `M_PI` → local `constexpr kPi` (C++17-portable). **Minor:** added `test_gp_ei_maximize` (EI-sign) + `test_gp_ei_edge_cases` (pure-categorical fallback + constant-objective/duplicate-coord Cholesky stability). **384 passed, 0 failed;** Sobol Tier-A + Python smoke still green. ### 2026-07-10 — F5: hyperband pruner (completes the pruner set) - Added `N4M_PRUNER_HYPERBAND` (`cpp/src/core/optimization/pruners.cpp`): Hyperband as an early-stopping pruner — multiple successive-halving **brackets** that hedge the early-stopping rate. Rungs at geometric resource levels `eta^k` (decision only on a rung boundary); each trial assigned a bracket `s` round-robin by stable ask order, with a **grace period** exempting it below rung `s`; at each rung, survive only if in the top 1/eta of **same-bracket** peers that reached it (ASHA-style, ties survive). Configured from **two new forward-compat option fields carved from `reserved[]`** — `max_resource` (top rung `R`; 0 = derive from largest reported step) and `reduction_factor` (`eta`; 0 = default 3) — so **no new ABI symbol** and `sizeof(n4m_optimizer_options_t)` is unchanged (verified **C == Python == 120 bytes**; ABI symbol surface diff = 733↔733, zero adds/removes). `asha`'s `eta` is now also taken from `reduction_factor` (default 3). Decision-level test proves bracket differentiation (worst-in-bracket-0 pruned at rung 0; bracket-1 trial with a far worse score survives via grace; non-rung step never prunes) + Python end-to-end. **382→384 passed.** Docs `docs/methods/hyperband.md`, updated `asha.md` + `docs/abi/changes_log.md`. **All 5 pruner kinds implemented.** ### 2026-07-10 — F4: gp_ei sampler (completes F4 + all 8 samplers) - Added `N4M_SAMPLER_GP_EI` (`cpp/src/core/optimization/gp.cpp`): Bayesian optimization with a Gaussian-process surrogate + Expected Improvement. After `n_startup_trials` random trials, each ask fits an RBF GP on the completed+scored history over the **continuous axes** (unit space) and returns the max-EI candidate over a 64-point random acquisition batch. Dependency-free: RBF kernel with a **median-distance lengthscale heuristic** (no marginal-likelihood inner loop), `K+1e-6·I` solved by a **from-scratch dense Cholesky** (forward/back substitution) — fine at NIRS trial counts. Direction-symmetric EI (MAXIMIZE negates the posterior mean), `ξ=0.01`. Non-continuous axes drawn by the shared decode (independent fallback); `enqueue` unsupported; pure-categorical spaces degrade to random. Stores its own per-trial proposals (id → cont-axis coords) so future fits read exact coordinates. - Convergence test on a smooth 2-D objective in **60 evals** (`best < 0.5`); measured `best < 0.03` across seeds 1–10 — much more sample-efficient than random/CMA (300 evals). **381 passed, 0 failed.** Reserved-sampler test now uses an out-of-range enum (all 8 kinds are implemented). ABI unchanged (enum-value-only). Doc `docs/methods/gp_ei.md` + `rasmussen2006gp` bib. **F4 complete; the full sampler library — random, sobol, lhs, ternary, ga, pso, cmaes, tpe, gp_ei — is done.** ### 2026-07-10 — F1: sobol sampler (Tier-A, completes F1) - Added `N4M_SAMPLER_SOBOL` (`cpp/src/core/optimization/sobol.cpp` + `sobol_direction.hpp`): Sobol low-discrepancy sequence, one Sobol dimension per parameter, unscrambled Gray-code recursion over the embedded **Joe–Kuo `new-joe-kuo-6.21201` direction numbers** (52 dims × 30 bits, extracted from `scipy.stats.qmc.Sobol._sv`). Numeric axes map the unit coord through `numeric_from_unit` (log/step/int aware); categoricals bucket it; params beyond dim 52 and conditional/sorted-tuple axes fall back to base random. Plugs into the base per-parameter hooks (`override_numeric`/`override_categorical`), so constraints/conditions/forced are handled by the base sampler. - **Tier-A bit-exact parity verified two ways.** Nailed the Gray-code algorithm in Python first (`np.array_equal` vs scipy, diff 0.0). C++ test `test_sobol_sequence_parity` asserts the first 5 points of a 3-D space equal the known dyadic reference *exactly* (`==`, not approx). Python `test_sobol_parity.py` drives the sampler through the binding and asserts `np.array_equal` vs `scipy.stats.qmc.Sobol(scramble=False)` for `d ∈ {1,3,6,10}`, `N` up to 32 — **all exact**. **380 passed, 0 failed** (C++); Python smoke + parity green. ABI unchanged (enum-value-only). Doc `docs/methods/sobol.md`. Scrambled (Owen) variant is a later Tier-B addition. **F1 complete.** - Fixed the stale `reserved sampler NOT_IMPLEMENTED` test to use `gp_ei` (sobol is now implemented; only the F4 GP surrogate stays reserved). ### 2026-07-10 — F6: Python binding - Added the Python binding for the native optimizer: ctypes decls for all 31 `n4m_optimizer_*`/`n4m_search_space_*`/`n4m_trial_*`/`n4m_finetune_estimator` symbols (`_ffi_decls.py`), an `OptimizerOptions` ctypes struct mirroring `n4m_optimizer_options_t` (native alignment; `_types.py`), and an idiomatic wrapper `n4m/model_selection/optimizer.py` (`SearchSpace`, `Trial`, `Optimizer` + `Sampler`/`Pruner`/`Direction`/`Metric` enums). Exported from `n4m.model_selection`. - Smoke test (`bindings/python/tests/test_optimizer_smoke.py`): random converges on a quadratic, **TPE converges on a mixed continuous+categorical space and picks the right category**, median pruner decisions, seed determinism — **all pass against the dev `.so`** (`N4M_LIB_PATH=…/libn4m.so.2.1.0`). Proves the ABI is usable from a consumer (struct layout, ask/tell, result round-trip). (Env note: two `.so` versions coexisted in the dev build — the stale pre-bump `2.0.0` and current `2.1.0`; point N4M_LIB_PATH at 2.1.0.) ### 2026-07-10 — F4 samplers Codex review applied - Codex review of cmaes+tpe: **3 Blocker, 3 Major, 1 Minor — all applied** (`docs/reviews/finetuning-roadmap/codex-review-06-F4-samplers.md`). **Blockers:** TPE now requires n≥2 + a non-degenerate split before activating (was an OOB read at `n_startup_trials=1`); CMA-ES updates from **completed+scored trials only** (weights renormalised over the scored count; skips the update when none scored) so pruned/failed members never corrupt the mean/covariance. **Major:** CMA-ES step-size path computed from the *repaired* step `yw/√C` (not the raw `z`) — consistent after box-clipping; CMA-ES restricted to the **continuous axes** (`cont_axes_` map) with non-continuous axes sampled independently (Optuna-style fallback) instead of polluting the covariance; TPE categorical proposal **sampled proportional to l/g** (not argmax) so the constraint-retry loop can't livelock. **Documented** (consistent with ga/pso): hard mutex/requires/exclude handled via fitness for population/CMA samplers; TPE stepped-axis snap at decode. **CMA-ES and TPE still converge after the restructure. 379 passed, 0 failed.** ### 2026-07-10 — F4: TPE sampler - Added `N4M_SAMPLER_TPE` (`cpp/src/core/optimization/tpe.cpp`): univariate Tree-structured Parzen Estimator (Optuna default). Per param: split the completed history into good (top γ=0.25) / bad, build Parzen `l(x)`/`g(x)` (KDE in unit space for numeric via a new `unit_from_numeric` inverse; Laplace-smoothed category frequencies for categorical), draw n_ei=24 candidates from `l` and keep argmax `l/g`. Added a base `override_categorical()` hook (parallel to `override_numeric`) so TPE plugs into the base sampler's constraint/condition/forced machinery. Handles **mixed/conditional** spaces. Convergence test on a continuous+categorical objective (finds x≈3, category 'a'). Also fixed a stale test (`reserved sampler` now uses `sobol`, since TPE is implemented). **379 passed, 0 failed.** ABI unchanged. Doc `docs/methods/tpe.md`. ### 2026-07-10 — F4: CMA-ES sampler - Added `N4M_SAMPLER_CMAES` (`cpp/src/core/optimization/cma.cpp`): **separable (diagonal) CMA-ES** (Ros & Hansen 2008) over the unit hypercube — the canonical mean/covariance/step-size/evolution-path update with a diagonal covariance (no eigendecomposition). Reuses the async-population lifecycle + boundary guard + shared decode (non-continuous axes bucketed = Optuna's independent fallback). Convergence test asserts `best < 0.1` on a smooth 2D objective (a broken CMA-ES would not converge tightly). **378 passed, 0 failed.** ABI unchanged. Doc `docs/methods/cmaes.md`. ### 2026-07-10 — F3 samplers Codex review applied - Codex review of ga+pso: **1 Blocker, 2 Major, 2 Minor — all applied** (`docs/reviews/finetuning-roadmap/codex-review-05-F3-samplers.md`). **Blocker:** population samplers refuse to cross a generation/iteration boundary until it is fully resolved (synchronous LIAR_NONE evolution) — `ask_batch` returns a *partial* batch at the boundary instead of evolving on unscored members (added `resolved_in_range`). **Major:** `enqueue`/warm-start rejected for population samplers (`N4M_ERR_UNSUPPORTED` via an `allow_enqueue()` hook) — a forced candidate can't be inverse-encoded into the population; constraint handling documented as fitness-only. **Minor:** PSO velocity clamp (vmax=0.5). +1 regression test (batch boundary + enqueue reject). **377 passed, 0 failed.** ### 2026-07-10 — F3: PSO sampler + shared decode - Added `N4M_SAMPLER_PSO` (`cpp/src/core/optimization/pso.cpp`): particle-swarm optimization over the unit hypercube (Clerc–Kennedy w=0.729, c1=c2=1.494) — swarm of 16 particles with position/velocity/personal-best, global best = best personal best, positions clamped to [0,1). Factored the unit-vector→trial decode into `Optimizer::decode_candidate()` (shared by `ga` + `pso`; GA simplified to use it, still green). Convergence test on a 2D continuous quadratic. **376 passed, 0 failed.** ABI unchanged. Doc `docs/methods/pso_search.md`. ### 2026-07-10 — F3: GA sampler - Added `N4M_SAMPLER_GA` (`cpp/src/core/optimization/ga.cpp`): real-coded genetic algorithm over the unit hypercube `[0,1)^P`, decoded per parameter (numeric via `numeric_from_unit`, categorical/ordinal bucketed) — handles mixed spaces uniformly. Generational: a population of 16 is asked out, then tournament selection + uniform crossover + Gaussian mutation + elitism produce the next generation once scores arrive (keyed on trial-id ranges; the async-population lifecycle will be reused by `pso`/`cmaes`). Convergence test on a 2D continuous quadratic. **375 passed, 0 failed.** ABI unchanged. Doc `docs/methods/ga_search.md`. (This is the HPO-sampler GA over the typed space — distinct from the feature-selection `ga_select`; sharing the RNG-consolidated loops is a later refinement.) ### 2026-07-10 — F2: racing pruner - Added `N4M_PRUNER_RACING` (Hoeffding racing) into the reviewed pruner architecture: each trial's intermediate scores are repeated observations; a trial is pruned once its Hoeffding confidence interval (δ=0.05) no longer overlaps the best trial's. This is the **fold-safe** early-stop (roadmap §2c) — correct for exchangeable CV folds where successive-halving's rank-preservation assumption fails. Decision-level test (clearly-worse trial pruned once enough observations accumulate). **374 passed, 0 failed.** ABI unchanged. Doc `docs/methods/racing.md`. Only `hyperband` remains reserved (needs the bracket scheduler / total budget → F5). ### 2026-07-10 — F2 pruners Codex review applied - Codex review of the pruner block: **1 Blocker, 3 Major, 3 Minor — all applied** (`docs/reviews/finetuning-roadmap/codex-review-04-F2-pruners.md`). **Blocker:** terminal state is now terminal — `tell_intermediate`/`tell_result` reject reports on a non-RUNNING trial (only an idempotent same-status re-report is accepted), so an auto-pruned trial can no longer be overwritten to COMPLETED and win `best()`. **Major:** true 50th-percentile median (mean of the two middle values for even n) — direction-symmetric; **finite-score validation** on both tell paths (rejects NaN/Inf before it corrupts `std::sort`); **centralized pruner-kind validation** in `make_optimizer` (out-of-range/unimplemented pruner is rejected, not silently degraded to none). **Minor:** one value per `(trial, step)` (update-in-place); ASHA `reduction_factor` documented as fixed-at-3 for F2 + ties-survive semantics. +2 regression tests (pruned-is-terminal, invalid-pruner + NaN). **373 passed, 0 failed.** ### 2026-07-10 — F2: ASHA pruner - Added `N4M_PRUNER_ASHA` (asynchronous successive halving) into the same factory: at each rung a trial survives only if in the top 1/reduction_factor (=3) of the peers at that rung, decided asynchronously (sound for a per-`tell` verdict). Decision-level test on a canned history. **371 passed, 0 failed.** ABI unchanged. Doc `docs/methods/asha.md`. (Hyperband bracket-scheduler + racing + the `n_components` fidelity engine remain in F2.) ### 2026-07-10 — F2 opener: pruner architecture + median pruner - Added the `Pruner` abstraction (`cpp/src/core/optimization/pruners.cpp`): `Optimizer` holds a `unique_ptr` set by a `make_pruner()` factory from `opts.pruner`; `tell_intermediate()` delegates the keep/prune verdict and marks pruned trials `N4M_TRIAL_PRUNED`. This makes pruners **orthogonal to samplers** (composed via the options struct), matching the roadmap's sampler ⟂ pruner split. `make_optimizer` now accepts `NONE`+`MEDIAN`; `asha`/`hyperband`/`racing` slot into the same factory later. - Implemented `N4M_PRUNER_MEDIAN` (Vizier median stopping rule): prune when a trial's intermediate score is worse than the median of peer scores at the same step; never before `min_peers` (= `n_startup_trials`) peers. Decision-level test on a canned history. **370 passed, 0 failed.** ABI unchanged. Doc `docs/methods/median_pruner.md`. ### 2026-07-10 — F1 samplers Codex review applied - Codex review of ternary+lhs: **0 Blocker, 5 Major, 2 Minor — all applied** (`docs/reviews/finetuning-roadmap/codex-review-03-F1-samplers.md`). Ternary reworked into **grid-index space**: honours `step` (proposes only on-grid values), **reserves RUNNING trials** so batched asks don't collide, skips inactive/off-domain history, and keeps arithmetic bounded (index space, guard against absurdly wide ranges). Base `enqueue()` now **validates numeric ranges + categorical indices** (rejects out-of-range warm-starts). LHS: seed **domain-separated** from the base RNG; `size_t` index comparison; clearer Fisher-Yates. +2 regression tests (stepped ternary + batch reservation distinctness, enqueue out-of-range rejection). **369 passed, 0 failed.** ### 2026-07-10 — F1: lhs sampler - Added `N4M_SAMPLER_LHS` (`cpp/src/core/optimization/lhs.cpp`): Latin Hypercube over the numeric axes for the first `n_startup_trials` asks (one independent permutation per dimension + per-cell jitter), random beyond the batch and for categoricals. Refactored the unit→value mapping into `Optimizer::numeric_from_unit()` so `random`/`lhs`/(future) `sobol` share it. Test asserts each decile is hit exactly once across the startup batch. **367 passed, 0 failed.** ABI unchanged. Doc `docs/methods/lhs.md`. - **`sobol` is intentionally deferred:** a *useful* Tier-A Sobol must bit-match `scipy.stats.qmc.Sobol(scramble=False)`, which requires the exact Joe–Kuo `new-joe-kuo-6.21201` direction-number table. Rather than ship an approximate/incorrect sequence, it will be done deliberately with the real table (fetch + embed a modest dimension subset) as a dedicated block. ### 2026-07-10 — F1: ternary sampler - Added `N4M_SAMPLER_TERNARY` (`cpp/src/core/optimization/ternary.cpp`): unimodal-integer ternary search porting the nirs4all `BinarySearchSampler` (triplet anchor low/high/mid → bisect the larger gap toward the current best). Introduced a small base-class hook `override_numeric()` so adaptive samplers reuse the constraint/loop/conditions machinery without duplicating `sample()`. Proposal is a pure function of the completed history (idempotent within an ask). Tunes the first integer axis; others stay random. - Test: converges to k∈{6,7,8} on a unimodal objective in ≤25 trials. **366 passed, 0 failed.** ABI snapshot unchanged (enum-value-only, no new symbol) — confirms the "later samplers add no ABI symbol" design. Doc `docs/methods/ternary.md`. ### 2026-07-10 — F0 Codex review applied - Codex read-only review of F0: **14 findings (4 Blocker, 9 Major, 1 Minor), all applied** (transcript `docs/reviews/finetuning-roadmap/codex-review-02-F0.md`). Highlights: made `optimization.h` independently includable (moved the `N4M_STATIC_ASSERT` macro above the role-header includes and relocated the HPO enum asserts into `optimization.h`) + added C/C++ compile-only include guards; hardened the ABI boundary (try/catch on every name-based trial accessor, `std::string_view` lookups, `struct_size` default-preserving copy with a `< 8` guard); made constraints authoritative (condition constraints reject unsupported shapes with `N4M_ERR_UNSUPPORTED`, enqueue validates param names, sampling skips RNG for forced dims and re-checks constraints, `ask` returns an error on constraint-exhaustion instead of a silent invalid trial); validated numeric ranges (reject NaN/Inf, log needs positive bounds); default `direction = AUTO` (derive from metric); `n4m_finetune_estimator` now rejects unsupported params, returns the full trial trace, and returns `NOT_FITTED` when nothing completes; implemented `timeout`/`duration` via `steady_clock`; documented MUTEX_GROUP (nirs4all `_mutex_` issubset) semantics. - 6 new regression tests lock the fixes (invalid ranges, struct_size guard, enqueue warm-start, conditional activation + conflicting-parent rejection, finetune unsupported-param rejection, AUTO+R2 maximization). **365 passed, 0 failed.** ABI snapshot unchanged (internal fixes), version-sync green. ### 2026-07-10 — F0 green - **Build clean, all tests pass: 359 passed, 0 failed** (7 new optimization tests incl. a real `n4m_finetune_estimator` PLS-CV run; `abi_version_compatible_with_header` green after the bump). - ABI snapshots regenerated (linux 734 + derived macos/windows 733) — drift is exactly the 31 new additive `n4m_*` symbols, nothing removed. ABI minor bumped **2.0 → 2.1**; `docs/abi/changes_log.md` entry added; CHANGELOG updated. - Docs: `docs/methods/{optimization,random}.md` + `_finetuning_bibliography.bib` (14 refs). - **Deferred to Track Q (not blocking F0 landing):** catalog `optimization.{random,none}.yaml` + `validate.py --strict-abi` reconcile (non-strict passes; new symbols are warnings), and the `HpoSpec` + parity comparators + CI job. The C++ doctest is F0's correctness gate with a single sampler. - Next: commit F0, Codex review, then F1 (sobol/lhs/ternary). ### 2026-07-10 — F0 code + env fix - Wrote F0: `cpp/include/n4m/optimization.h` (frozen ABI), `cpp/src/core/optimization/optimizer.{hpp,cpp}` (SearchSpace + Optimizer: random sampler, none pruner, constraint rejection, conditional activation, ask/tell), `cpp/src/c_api/c_api_optimization.cpp` (all wrappers + `n4m_finetune_estimator` internal PLS-CV driver), `cpp/tests/test_optimization.cpp` (7 cases). Wired n4m.h include + 4-byte enum asserts, CMakeLists (core .cpp), tests CMake + main.cpp registration. - **Toolchain/env fix (matters for every build here):** the conda-linked `~/.local/bin/gfortran` used by FITPACK pulls a `libm.so` linker script referencing the non-existent `/lib64/libm.so.6`, breaking the `libn4m.so` link. FITPACK (spline smoothing) has a non-Fortran fallback and is irrelevant to HPO, so configure with **`-DCMAKE_Fortran_COMPILER=NOTFOUND`** to disable it. C/C++ compiler is system `/usr/bin/{cc,c++}`; real libm is `/usr/lib/x86_64-linux-gnu`. Apply the same flag to the dev-release build used for ABI snapshots. ### 2026-07-10 — setup - Created isolated worktree `_worktrees/native-hpo-phase1` on branch `feat/native-hpo-phase1` from `main` (f69a2ec7). Main checkout left clean/untouched. - Moved + committed the 4 design docs (strategy, roadmap, F0 PR, Codex review) into the branch (`e39ac038`). - Toolchain: cmake 4.3.2, ninja, g++ (no clang++; g++ path). - Next: baseline build sanity, then F0.