Skip to content

Regression harness compares results across different package environments (Manifest drift), producing spurious regressions #338

Description

@jhalpern30

Regression harness compares results across different package environments (Manifest drift), producing spurious regressions

Summary

The regression harness caches numerical baselines keyed only on (commit_hash, case_name), with no fingerprint of the package environment that produced them. Because Manifest.toml is untracked and each worktree run calls Pkg.instantiate() against a bare checkout, a baseline cached weeks ago is silently resolved against older package versions than a fresh local run. The resulting machine-epsilon (~1e-16) differences in library math (BLAS/SciML/ArrayInterface) are then amplified by the adaptive ODE step controller and by ill-conditioned near-resonant diagnostics into large apparent regressions — up to 200% — that are pure environment artifacts, not code changes.

This matters because the harness is mandated on every PR (per CLAUDE.md). A reviewer can waste significant time chasing a phantom, or — worse — "fix" correct code to mask an artifact.

Root cause (confirmed in code)

Three independent factors combine:

  1. Manifest.toml is untracked. A worktree created for a cached commit has no pinned Manifest.

    • regression-harness/src/utils.jl:74-83 (create_worktree) does a bare git worktree add --detach.
  2. Each run resolves the environment fresh. With no Manifest present, Pkg.instantiate() picks the newest versions compatible with Project.toml's [compat] at run time.

    • regression-harness/src/runner.jl:43-54 (RUNNER_SCRIPT_TEMPLATE) → %INSTANTIATE% expands to Pkg.instantiate() (default; only skipped with --no-instantiate).
    • Invoked at regression-harness/src/runner.jl:409-423 against --project=$worktree_path.
  3. The cache has no environment key. Results are stored/looked up by (commit_hash, case_name) only.

    • Schema: regression-harness/src/database.jl:5-18 (runs table, UNIQUE(commit_hash, case_name)) — no Julia version, no Manifest hash, no package-set hash.
    • is_cached (regression-harness/src/database.jl:63-67) ignores the environment entirely.

Net effect: regress --refs develop,local compares a develop baseline produced under whatever package set was newest at cache-population time against a fresh local run under today's package set.

Evidence

Investigating a branch that only refactors FourierTransforms/Vacuum (no changes to EulerLagrange.jl/Riccati.jl/Fourfit.jl or the equilibrium metric), the harness reported for develop (cached) vs local:

Quantity Reported "regression"
diiid ODE steps (saved) 1353 → 1325 (2.07%)
diiid ODE steps (total) 1990 → 1951 (1.96%)
diiid Chirikov parameter 11.13%
diiid resonant field 10.80%
diiid PE toroidal torque 200.47%
solovev ODE steps (total) 605 → 614 (1.49%)

Controlled re-test — same source diff, but the develop worktree pinned to the identical Manifest.toml as the working tree (source code the only variable), 4 threads throughout:

Quantity develop local Result
solovev nstep / total 385 / 614 385 / 614 identical
diiid nstep / total 1325 / 1951 1325 / 1951 identical
diiid Chirikov rel 2.8e-14
diiid resonant field rel 6.3e-14
all other datasets rel ≤ 1e-13

The develop worktree instantiated without pinning had resolved newer versions (e.g. 4.6.1→4.7.0, 7.25.0→7.28.1 in the SciML/ArrayInterface stack). Pinning the Manifest collapsed every reported regression to machine epsilon. Two back-to-back runs of the same environment are bit-identical, so this is not thread-schedule noise.

Mechanism: a 1e-16 change in equilibrium/EL-matrix math (from a different library version) crosses an adaptive-step acceptance boundary (384↔385 steps), which reshuffles the integration ψ-grid, and the near-resonant singular-coupling quantities (Chirikov, Δ′, torque) are ill-conditioned enough to turn that seed into double-digit-percent swings.

Proposed fixes (increasing effort)

  1. Fingerprint the environment and warn on mismatch (minimum). Store a hash of the resolved Manifest.toml (or Julia version + package-set) alongside each cached run, and print a loud warning (or auto-invalidate) when a cached ref's fingerprint differs from the current run's. Cheapest change; converts a silent confound into a visible one. Requires a runs schema column + a check in is_cached/reporter.

  2. Run both refs in one shared, pinned environment at compare time. Resolve a single Manifest once and reuse it for every worktree (--project pointed at a shared depot, or copy the working tree's Manifest into each worktree before instantiate). Makes source code the only variable by construction.

  3. Track Manifest.toml (or a harness-specific pinned Manifest) so worktree checkouts are reproducible, and instantiate against it.

Workaround (today)

Regenerate the baseline in the current environment before trusting a report:

regress --cases diiid_n1,solovev_n1 --refs develop,local --force

--force re-runs the develop ref under the same packages as local, eliminating the cross-environment artifact.

Metadata

Metadata

Assignees

No one assigned

    Labels

    testTests only

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions