Skip to content

Commit 043c197

Browse files
authored
Merge pull request #361 from OpenFUSIONToolkit/feature/harness-env-integrity
REGRESSION - IMPROVEMENT - Pin the package set and fingerprint run environments
2 parents 2b14cc4 + c380625 commit 043c197

9 files changed

Lines changed: 590 additions & 98 deletions

File tree

docs/development/regression-harness.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,48 @@ regress --cases solovev_n1 --ref-range develop~10..develop
9696
- `--force` — re-run even if cached
9797
- `--verbose` — print GPEC subprocess output
9898
- `--no-instantiate` — skip `Pkg.instantiate()` (faster if deps are already resolved)
99+
- `--no-pin-manifest` — let each ref resolve its own package set (see below)
100+
- `--allow-env-mismatch` — reuse cached results produced in a different environment
101+
- `--fail-on-change` — exit non-zero when any tracked quantity changed
102+
103+
## Making source code the only variable
104+
105+
`Manifest.toml` is untracked, so a worktree checked out at an old commit used to resolve whatever
106+
package versions were newest at run time. Machine-epsilon differences in library math then get
107+
amplified by the adaptive ODE step controller and by ill-conditioned near-resonant diagnostics
108+
into double-digit-percent "regressions" that no source change caused.
109+
110+
Two mechanisms prevent that:
111+
112+
**The working tree's Manifest is pinned into every worktree** before `Pkg.instantiate()`, so all
113+
refs in a comparison run against one package set. `--no-pin-manifest` opts out (and says so
114+
loudly). If a commit declares a direct dependency the pinned Manifest lacks, `Pkg.instantiate()`
115+
refuses to run: that ref is recorded as a failed run whose error suggests `--no-pin-manifest` to
116+
let it resolve its own package set.
117+
118+
**Every run records the environment that produced it** — Julia version, host, resolved Manifest
119+
hash, Julia and BLAS thread counts. The cache still holds a single result per
120+
`(commit, case)`, so a re-run replaces the stored one rather than keeping a result per
121+
environment; what the fingerprint adds is that a cached result whose environment differs from
122+
the current one is re-run instead of silently reused. `--allow-env-mismatch` skips that check
123+
and reuses whatever is cached, whatever produced it. Every report prints the environment of
124+
each ref:
125+
126+
```
127+
Ref 1: develop @ a0cad260 (2026-08-12)
128+
env: julia 1.11.6, arm64-apple-darwin24.0.0, manifest 7e5c34ad (pinned), 1 thread/8 BLAS
129+
```
130+
131+
When two compared runs did not share an environment, the report says so before the table rather
132+
than leaving you to infer it from the numbers.
133+
134+
Results cached before environment fingerprinting existed carry no environment and are therefore
135+
re-run once — those are exactly the entries whose provenance cannot be established.
136+
137+
Thread counts are recorded but **not** forced: the harness does not silently change how your runs
138+
execute. If the two refs in a comparison ran under different thread counts, the report flags it.
139+
140+
## Exit status
141+
142+
- `0` — every run completed (and, with `--fail-on-change`, nothing changed)
143+
- `1` — a run failed, or a quantity changed under `--fail-on-change`

docs/src/developer_notes.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,7 @@ regress --cases solovev_n1 --ref-range develop~10..develop
220220
- `--force` — re-run even if cached
221221
- `--verbose` — print GPEC subprocess output
222222
- `--no-instantiate` — skip `Pkg.instantiate()` (faster if deps are already resolved)
223+
224+
The full flag list, the environment-pinning behaviour that keeps source code the only variable in
225+
a comparison, and the exit-status contract are documented in
226+
[`docs/development/regression-harness.md`](https://github.com/OpenFUSIONToolkit/GPEC/blob/develop/docs/development/regression-harness.md).

regression-harness/regress.jl

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const DEFAULT_DB_PATH = joinpath(HARNESS_DIR, ".regress_cache.sqlite")
88
const CASES_DIR = joinpath(HARNESS_DIR, "cases")
99

1010
include("src/types.jl")
11+
include("src/env.jl")
1112
include("src/config.jl")
1213
include("src/database.jl")
1314
include("src/utils.jl")
@@ -26,6 +27,9 @@ function parse_args(args)
2627
db_path = nothing
2728
verbose = false
2829
no_instantiate = false
30+
no_pin_manifest = false
31+
allow_env_mismatch = false
32+
fail_on_change = false
2933
help = false
3034

3135
i = 1
@@ -46,6 +50,15 @@ function parse_args(args)
4650
elseif arg == "--no-instantiate"
4751
no_instantiate = true
4852
i += 1
53+
elseif arg == "--no-pin-manifest"
54+
no_pin_manifest = true
55+
i += 1
56+
elseif arg == "--allow-env-mismatch"
57+
allow_env_mismatch = true
58+
i += 1
59+
elseif arg == "--fail-on-change"
60+
fail_on_change = true
61+
i += 1
4962
elseif arg == "--cases" && i < length(args)
5063
cases = split(args[i+1], ",") |> collect .|> strip
5164
i += 2
@@ -69,7 +82,8 @@ function parse_args(args)
6982
end
7083
end
7184

72-
return CLIOptions(cases, refs, ref_range, force, list_cases, show_qty, show_case, db_path, verbose, no_instantiate, help)
85+
return CLIOptions(cases, refs, ref_range, force, list_cases, show_qty, show_case, db_path, verbose,
86+
no_instantiate, no_pin_manifest, allow_env_mismatch, fail_on_change, help)
7387
end
7488

7589
const HELP_TEXT = """
@@ -93,8 +107,19 @@ Options:
93107
--db path Override database path
94108
--verbose Print subprocess output
95109
--no-instantiate Skip Pkg.instantiate() in subprocess
110+
--no-pin-manifest Let each ref resolve its own package set (default: pin the working
111+
tree's Manifest.toml into every worktree so source code is the only
112+
variable in a comparison)
113+
--allow-env-mismatch Reuse cached results produced in a different environment instead of
114+
re-running them
115+
--fail-on-change Exit non-zero if any tracked quantity changed (for CI use; a failed
116+
run always exits non-zero regardless)
96117
--help Print this help message
97118
119+
Exit status:
120+
0 all runs completed (and, with --fail-on-change, nothing changed)
121+
1 a run failed, or a quantity changed under --fail-on-change
122+
98123
Examples:
99124
# Compare two refs
100125
julia --project=regression-harness regression-harness/regress.jl \\
@@ -170,6 +195,25 @@ function main(args=ARGS)
170195
error("No commits resolved from the given refs")
171196
end
172197

198+
warn_stale_refs(resolved_refs, REPO_ROOT)
199+
200+
# Pin every ref to the working tree's package set unless asked not to, so that a
201+
# comparison varies source code alone.
202+
manifest_path = joinpath(REPO_ROOT, "Manifest.toml")
203+
pin_manifest = if opts.no_pin_manifest
204+
@warn "Manifest pinning disabled — refs may resolve different package sets, and differences below may not be caused by source changes"
205+
nothing
206+
elseif !isfile(manifest_path)
207+
@warn "No Manifest.toml in the working tree; cannot pin the package set. Run Pkg.instantiate() first."
208+
nothing
209+
else
210+
manifest_path
211+
end
212+
expected_key = opts.allow_env_mismatch ? nothing : expected_env_key(pin_manifest)
213+
214+
n_failed = 0
215+
n_changed = 0
216+
173217
# Run each case at each commit
174218
for case_spec in case_specs
175219
println("\n", "="^64)
@@ -179,18 +223,28 @@ function main(args=ARGS)
179223
for ref in resolved_refs
180224
run_commit(db, ref.commit_hash, ref.name, case_spec, REPO_ROOT;
181225
force=opts.force, verbose=opts.verbose,
182-
no_instantiate=opts.no_instantiate)
226+
no_instantiate=opts.no_instantiate,
227+
pin_manifest=pin_manifest, expected_key=expected_key)
183228
end
184229

185230
# Report
186-
if length(resolved_refs) == 1
187-
report_multi_ref(db, case_spec, resolved_refs)
188-
elseif length(resolved_refs) == 2
231+
summary = if length(resolved_refs) == 2
189232
report_two_ref_comparison(db, case_spec,
190233
resolved_refs[1], resolved_refs[2])
191234
else
192235
report_multi_ref(db, case_spec, resolved_refs)
193236
end
237+
n_failed += summary.n_failed
238+
n_changed += summary.n_changed
239+
end
240+
241+
if n_failed > 0
242+
@error "$n_failed run(s) failed — see the reports above"
243+
exit(1)
244+
end
245+
if opts.fail_on_change && n_changed > 0
246+
@error "$n_changed quantity/quantities changed (--fail-on-change)"
247+
exit(1)
194248
end
195249
finally
196250
close_database(db)

regression-harness/src/database.jl

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ CREATE TABLE IF NOT EXISTS runs (
1414
runtime_s REAL,
1515
success INTEGER NOT NULL DEFAULT 1,
1616
error_msg TEXT,
17+
env_key TEXT,
18+
julia_version TEXT,
19+
os_arch TEXT,
20+
manifest_sha TEXT,
21+
nthreads INTEGER,
22+
blas_threads INTEGER,
23+
pinned INTEGER,
1724
UNIQUE(commit_hash, case_name)
1825
);
1926
@@ -35,6 +42,14 @@ CREATE INDEX IF NOT EXISTS idx_runs_case ON runs(case_name);
3542
CREATE INDEX IF NOT EXISTS idx_quantities_run ON quantities(run_id);
3643
"""
3744

45+
"""
46+
Value of a possibly-absent SQLite column, falling back to `default`.
47+
48+
SQLite.jl returns `missing` for NULL while Julia's `something` only skips `nothing`, so columns
49+
added by a later schema migration (NULL on every pre-existing row) need both cases handled.
50+
"""
51+
_column(x, default) = (x === nothing || x === missing) ? default : x
52+
3853
"""Materialize SQLite query results as a Vector of NamedTuples."""
3954
function query_rows(db::SQLite.DB, sql::String, params=())
4055
result = DBInterface.execute(db, sql, params)
@@ -44,6 +59,29 @@ function query_rows(db::SQLite.DB, sql::String, params=())
4459
return [NamedTuple{keys(ct)}(Tuple(col[i] for col in values(ct))) for i in 1:nrows]
4560
end
4661

62+
"""
63+
Environment columns added to `runs` after the original schema shipped. Databases created before
64+
fingerprinting keep their rows, with NULL in these columns — such rows never match a computed
65+
`env_key`, so they are re-run rather than silently trusted.
66+
"""
67+
const ENV_COLUMNS = [
68+
("env_key", "TEXT"), ("julia_version", "TEXT"), ("os_arch", "TEXT"),
69+
("manifest_sha", "TEXT"), ("nthreads", "INTEGER"), ("blas_threads", "INTEGER"),
70+
("pinned", "INTEGER")
71+
]
72+
73+
"""Add any `runs` columns missing from a database created by an earlier harness version."""
74+
function migrate_schema!(db::SQLite.DB)
75+
existing = Set(String[])
76+
for row in query_rows(db, "PRAGMA table_info(runs)")
77+
push!(existing, String(something(row.name, "")))
78+
end
79+
for (col, sqltype) in ENV_COLUMNS
80+
col in existing && continue
81+
DBInterface.execute(db, "ALTER TABLE runs ADD COLUMN $col $sqltype")
82+
end
83+
end
84+
4785
function open_database(path::String)::SQLite.DB
4886
db = SQLite.DB(path)
4987
DBInterface.execute(db, "PRAGMA journal_mode=WAL")
@@ -53,19 +91,47 @@ function open_database(path::String)::SQLite.DB
5391
isempty(s) && continue
5492
DBInterface.execute(db, s)
5593
end
94+
migrate_schema!(db)
5695
return db
5796
end
5897

5998
function close_database(db::SQLite.DB)
6099
SQLite.close(db)
61100
end
62101

63-
function is_cached(db::SQLite.DB, commit_hash::String, case_name::String)::Bool
64-
rows = query_rows(db, "SELECT id FROM runs WHERE commit_hash = ? AND case_name = ? AND success = 1",
65-
(commit_hash, case_name))
102+
"""
103+
Is there a usable cached result for this (commit, case)?
104+
105+
With `expected_key` supplied, a cached run only counts when it was produced in the same
106+
environment. Rows predating fingerprinting hold NULL and therefore never match — the cache
107+
entries most likely to be misleading are exactly the ones that get re-run.
108+
"""
109+
function is_cached(db::SQLite.DB, commit_hash::String, case_name::String;
110+
expected_key::Union{String,Nothing}=nothing)::Bool
111+
if expected_key === nothing
112+
rows = query_rows(db, "SELECT id FROM runs WHERE commit_hash = ? AND case_name = ? AND success = 1",
113+
(commit_hash, case_name))
114+
return !isempty(rows)
115+
end
116+
rows = query_rows(db,
117+
"SELECT id FROM runs WHERE commit_hash = ? AND case_name = ? AND success = 1 AND env_key = ?",
118+
(commit_hash, case_name, expected_key))
66119
return !isempty(rows)
67120
end
68121

122+
"""
123+
Environment key stored for a cached run, or `nothing` when the run is absent or predates
124+
fingerprinting. Used to explain *why* a cached result was rejected.
125+
"""
126+
function cached_env_key(db::SQLite.DB, commit_hash::String, case_name::String)::Union{String,Nothing}
127+
rows = query_rows(db, "SELECT env_key FROM runs WHERE commit_hash = ? AND case_name = ?",
128+
(commit_hash, case_name))
129+
isempty(rows) && return nothing
130+
key = _column(first(rows).env_key, nothing)
131+
key === nothing && return nothing
132+
return String(key)
133+
end
134+
69135
function delete_cached(db::SQLite.DB, commit_hash::String, case_name::String)
70136
# ON DELETE CASCADE handles quantities cleanup automatically
71137
DBInterface.execute(db, "DELETE FROM runs WHERE commit_hash = ? AND case_name = ?",
@@ -76,18 +142,23 @@ function store_run(db::SQLite.DB, commit_hash::AbstractString, commit_short::Abs
76142
commit_date::AbstractString, commit_msg::AbstractString,
77143
case_name::AbstractString, runtime_s::Float64,
78144
extracted::Vector{ExtractedQuantity};
79-
success::Bool=true, error_msg::AbstractString="")
145+
success::Bool=true, error_msg::AbstractString="",
146+
fingerprint::EnvFingerprint=UNKNOWN_ENV)
80147
ran_at = Dates.format(Dates.now(), "yyyy-mm-ddTHH:MM:SS")
81148

82149
SQLite.transaction(db) do
83150
delete_cached(db, String(commit_hash), String(case_name))
84151

85152
DBInterface.execute(db,
86153
"""INSERT INTO runs
87-
(commit_hash, commit_short, commit_date, commit_msg, case_name, ran_at, runtime_s, success, error_msg)
88-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
154+
(commit_hash, commit_short, commit_date, commit_msg, case_name, ran_at, runtime_s, success, error_msg,
155+
env_key, julia_version, os_arch, manifest_sha, nthreads, blas_threads, pinned)
156+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
89157
(String(commit_hash), String(commit_short), String(commit_date), String(commit_msg),
90-
String(case_name), ran_at, runtime_s, success ? 1 : 0, String(error_msg)))
158+
String(case_name), ran_at, runtime_s, success ? 1 : 0, String(error_msg),
159+
env_key(fingerprint), fingerprint.julia_version, fingerprint.os_arch,
160+
fingerprint.manifest_sha, fingerprint.nthreads, fingerprint.blas_threads,
161+
fingerprint.pinned ? 1 : 0))
91162

92163
run_id = SQLite.last_insert_rowid(db)
93164

@@ -147,18 +218,28 @@ Get run info for a (commit, case) pair. Returns NamedTuple or nothing.
147218
"""
148219
function get_run_info(db::SQLite.DB, commit_hash::String, case_name::String)
149220
rows = query_rows(db,
150-
"""SELECT commit_short, commit_date, commit_msg, runtime_s, success, error_msg
221+
"""SELECT commit_short, commit_date, commit_msg, runtime_s, success, error_msg,
222+
julia_version, os_arch, manifest_sha, nthreads, blas_threads, pinned
151223
FROM runs WHERE commit_hash = ? AND case_name = ?""",
152224
(commit_hash, case_name))
153225
isempty(rows) && return nothing
154226
row = first(rows)
227+
fingerprint = EnvFingerprint(
228+
String(_column(row.julia_version, "")),
229+
String(_column(row.os_arch, "")),
230+
String(_column(row.manifest_sha, "")),
231+
Int(_column(row.nthreads, -1)),
232+
Int(_column(row.blas_threads, -1)),
233+
_column(row.pinned, 0) == 1
234+
)
155235
return (
156236
commit_short = something(row.commit_short, ""),
157237
commit_date = something(row.commit_date, ""),
158238
commit_msg = something(row.commit_msg, ""),
159239
runtime_s = something(row.runtime_s, 0.0),
160240
success = coalesce(row.success, 0) == 1,
161241
error_msg = something(row.error_msg, ""),
242+
fingerprint = fingerprint,
162243
)
163244
end
164245

0 commit comments

Comments
 (0)