Skip to content

Commit 2f94018

Browse files
d-burgclaude
andcommitted
Regression - TEST - Unit-test the golden comparison logic on synthetic data
Covers the comparison paths (scalar/integer/token/array with worst-element reporting and the non-finite-tolerance guard), type-change handling, the load/save validation refusals, infer_class, and the --check counting contract including crash-vs-tolerance separation. GOLDEN_DIR is redirected to a temp dir so the tracked pins are never touched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 262d15f commit 2f94018

1 file changed

Lines changed: 282 additions & 0 deletions

File tree

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
# Unit tests for the golden-value comparison logic (src/golden.jl), on synthetic data only —
2+
# no GPEC runs. Run against the harness project, not the GPEC package:
3+
#
4+
# julia --project=regression-harness regression-harness/test/runtests.jl
5+
#
6+
using Test
7+
using Dates, HDF5, JSON, Printf, SHA, SQLite, Tables, TOML
8+
9+
const HARNESS_DIR = abspath(joinpath(@__DIR__, ".."))
10+
const REPO_ROOT = abspath(joinpath(HARNESS_DIR, ".."))
11+
const DEFAULT_DB_PATH = joinpath(mktempdir(), "test_cache.sqlite")
12+
const CASES_DIR = joinpath(HARNESS_DIR, "cases")
13+
# Redirected to a temp dir so tests can write and corrupt golden files freely without ever
14+
# touching the tracked pins under regression-harness/golden/.
15+
const GOLDEN_DIR = mktempdir()
16+
17+
for f in ("types.jl", "env.jl", "config.jl", "database.jl", "utils.jl", "extractor.jl", "runner.jl", "reporter.jl", "golden.jl")
18+
include(joinpath(HARNESS_DIR, "src", f))
19+
end
20+
21+
qspec(name; type="real_scalar", extract="value", label=name, noise=0.0, order=1) =
22+
QuantitySpec(name, "", type, extract, label, noise, order)
23+
24+
golden_val(name; value_type="real", value_real=nothing, value_int=nothing, value_text=nothing,
25+
rtol=1e-6, atol=0.0, class="physics_converged", basis="measured", drift=NaN, spread=NaN, at="") =
26+
GoldenValue(name, value_type, value_real, value_int, value_text, rtol, atol, class, basis, drift, spread, at)
27+
28+
qtuple(; value_real=nothing, value_int=nothing, value_text=nothing, value_type="real", label="q") =
29+
(label=label, value_real=value_real, value_int=value_int, value_text=value_text, value_type=value_type, noise_threshold=0.0)
30+
31+
extracted_q(name; value_real=nothing, value_int=nothing, value_text=nothing, value_type="real") =
32+
ExtractedQuantity(name, name, value_real, value_int, value_text, value_type, 0.0)
33+
34+
@testset "golden comparison logic" begin
35+
36+
@testset "scalar real comparison" begin
37+
g = golden_val("x"; value_real=1.0, rtol=1e-6)
38+
passed, dev, detail = compare_to_golden(qtuple(value_real=1.0 + 5e-7), g)
39+
@test passed
40+
@test dev 5e-7 rtol = 1e-3
41+
@test isempty(detail)
42+
43+
passed, dev, detail = compare_to_golden(qtuple(value_real=1.0 + 1e-5), g)
44+
@test !passed
45+
@test dev 1e-5 rtol = 1e-3
46+
@test !isempty(detail)
47+
48+
# Zero golden value: rtol contributes nothing, deviation is reported absolute.
49+
g0 = golden_val("x"; value_real=0.0, rtol=1e-6, atol=0.0)
50+
passed, dev, _ = compare_to_golden(qtuple(value_real=1e-12), g0)
51+
@test !passed
52+
@test dev 1e-12
53+
g0a = golden_val("x"; value_real=0.0, rtol=1e-6, atol=1e-9)
54+
@test compare_to_golden(qtuple(value_real=1e-12), g0a)[1]
55+
56+
# Non-finite tolerances mean "recorded, never judged": must pass even on a zero golden
57+
# value, where atol + rtol*|gold| would otherwise evaluate to Inf + NaN.
58+
gd = golden_val("x"; value_real=0.0, rtol=Inf, atol=Inf, class="diagnostic")
59+
@test compare_to_golden(qtuple(value_real=42.0), gd)[1]
60+
61+
@test !compare_to_golden(qtuple(value_real=nothing), g)[1]
62+
end
63+
64+
@testset "integer and token comparison" begin
65+
# Topological integers gate on exact equality no matter how loose the rtol.
66+
gt = golden_val("n"; value_type="integer", value_int=5, rtol=10.0, class="topological")
67+
@test compare_to_golden(qtuple(value_int=5, value_type="integer"), gt)[1]
68+
@test !compare_to_golden(qtuple(value_int=6, value_type="integer"), gt)[1]
69+
70+
# Non-topological integers use the tolerance like a real.
71+
gi = golden_val("n"; value_type="integer", value_int=100, rtol=0.05, class="physics_converged")
72+
@test compare_to_golden(qtuple(value_int=103, value_type="integer"), gi)[1]
73+
@test !compare_to_golden(qtuple(value_int=110, value_type="integer"), gi)[1]
74+
75+
# Tokens are discrete: deviation 0.0 on match, 1.0 (not NaN) on mismatch so it sorts as
76+
# a real failure.
77+
gk = golden_val("integ"; value_type="token", value_text="riccati", class="topological")
78+
passed, dev, _ = compare_to_golden(qtuple(value_text="riccati", value_type="token"), gk)
79+
@test passed && dev == 0.0
80+
passed, dev, detail = compare_to_golden(qtuple(value_text="galerkin", value_type="token"), gk)
81+
@test !passed && dev == 1.0
82+
@test occursin("riccati", detail) && occursin("galerkin", detail)
83+
end
84+
85+
@testset "array worst-element path" begin
86+
g = golden_val("v"; value_type="json_array", value_text="[1.0,2.0,4.0]", rtol=1e-6)
87+
88+
passed, dev, detail = compare_to_golden(qtuple(value_text="[1.0000000001,2.0,4.0]", value_type="json_array"), g)
89+
@test passed
90+
@test dev 1e-10 rtol = 1e-3
91+
@test isempty(detail)
92+
93+
# One element out of tolerance fails the whole array and the detail names it.
94+
passed, dev, detail = compare_to_golden(qtuple(value_text="[1.0,2.002,4.0]", value_type="json_array"), g)
95+
@test !passed
96+
@test dev 1e-3 rtol = 1e-3
97+
@test occursin("worst element 2", detail)
98+
99+
# The reported deviation is the worst RELATIVE element, not the first failing one.
100+
passed, dev, detail = compare_to_golden(qtuple(value_text="[1.001,2.0,4.02]", value_type="json_array"), g)
101+
@test !passed
102+
@test dev 5e-3 rtol = 1e-3
103+
@test occursin("worst element 3", detail)
104+
105+
# A zero golden element is judged absolutely; atol=0 makes any movement on it fail.
106+
gz = golden_val("v"; value_type="json_array", value_text="[0.0,1.0]", rtol=1e-6, atol=0.0)
107+
@test !compare_to_golden(qtuple(value_text="[1.0e-9,1.0]", value_type="json_array"), gz)[1]
108+
gza = golden_val("v"; value_type="json_array", value_text="[0.0,1.0]", rtol=1e-6, atol=1e-8)
109+
@test compare_to_golden(qtuple(value_text="[1.0e-9,1.0]", value_type="json_array"), gza)[1]
110+
111+
# Non-finite tolerances never judge, including zero golden elements (the Inf*0 = NaN guard).
112+
gdz = golden_val("v"; value_type="json_array", value_text="[0.0,1.0]", rtol=Inf, atol=Inf, class="diagnostic")
113+
@test compare_to_golden(qtuple(value_text="[5.0,99.0]", value_type="json_array"), gdz)[1]
114+
115+
# Complex encoding: [re, im] pairs go through the same worst-element machinery.
116+
gc = golden_val("v"; value_type="json_array", value_text="[[1.0,0.5],[2.0,-1.0]]", rtol=1e-6)
117+
@test compare_to_golden(qtuple(value_text="[[1.0,0.5],[2.0,-1.0]]", value_type="json_array"), gc)[1]
118+
passed, _, detail = compare_to_golden(qtuple(value_text="[[1.0,0.5],[2.0,-1.01]]", value_type="json_array"), gc)
119+
@test !passed
120+
@test occursin("worst element 2", detail)
121+
122+
passed, _, detail = compare_to_golden(qtuple(value_text="[1.0,2.0]", value_type="json_array"), g)
123+
@test !passed
124+
@test occursin("length", detail)
125+
end
126+
127+
@testset "type-change handling" begin
128+
g = golden_val("x"; value_real=1.0)
129+
passed, dev, detail = compare_to_golden(qtuple(value_text="[1.0]", value_type="json_array"), g)
130+
@test !passed && isnan(dev)
131+
@test occursin("type changed", detail)
132+
133+
# A value_type change through build_golden_values resets class and tolerances to
134+
# provisional: measurements made under the old type must not carry over.
135+
spec = qspec("x")
136+
prior = Dict("x" => golden_val("x"; value_type="integer", value_int=5, rtol=0.0,
137+
class="topological", basis="measured", drift=1e-8, spread=1e-7, at="mpsi=1024"))
138+
vals = @test_logs (:warn, r"value_type changed") build_golden_values(
139+
[extracted_q("x"; value_real=5.0)], [spec], prior)
140+
@test vals["x"].class == "physics_converged"
141+
@test vals["x"].rtol == CLASS_DEFAULT_TOLERANCE["physics_converged"].rtol
142+
@test vals["x"].tolerance_basis == "class-default (provisional)"
143+
@test isnan(vals["x"].plateau_drift) && isnan(vals["x"].platform_spread)
144+
145+
# Same type carries the prior's hard-won measurements forward unchanged.
146+
prior2 = Dict("x" => golden_val("x"; value_real=1.0, rtol=3e-7, atol=1e-12,
147+
basis="measured", drift=1e-8, spread=2e-7, at="mpsi=1024"))
148+
vals2 = build_golden_values([extracted_q("x"; value_real=1.5)], [spec], prior2)
149+
@test vals2["x"].value_real == 1.5
150+
@test vals2["x"].rtol == 3e-7 && vals2["x"].atol == 1e-12
151+
@test vals2["x"].tolerance_basis == "measured"
152+
@test vals2["x"].plateau_drift == 1e-8 && vals2["x"].platform_spread == 2e-7
153+
154+
# Missing extractions, checksums, and quantities without a spec never become entries.
155+
vals3 = build_golden_values(
156+
[extracted_q("gone"; value_type="missing"),
157+
extracted_q("x"; value_text="abc123", value_type="checksum"),
158+
extracted_q("unspecced"; value_real=1.0)],
159+
[spec], nothing)
160+
@test isempty(vals3)
161+
end
162+
163+
@testset "load-validation refusals" begin
164+
write(golden_path("bad_class"), """
165+
[values.x]
166+
class = "definitely_not_a_class"
167+
rtol = 1.0e-6
168+
value = 1.0
169+
""")
170+
@test_throws ErrorException load_golden("bad_class")
171+
172+
# A gating entry with no finite rtol is malformed whether it was hand-edited or merged.
173+
write(golden_path("no_rtol"), """
174+
[values.x]
175+
class = "physics_converged"
176+
value = 1.0
177+
""")
178+
@test_throws ErrorException load_golden("no_rtol")
179+
180+
# An rtol below the recorded platform spread is a gate no second platform can pass;
181+
# enforced on every load so a hand edit cannot ship what save_golden refused to write.
182+
write(golden_path("too_tight"), """
183+
[values.x]
184+
class = "physics_converged"
185+
rtol = 1.0e-9
186+
platform_spread = 1.0e-6
187+
value = 1.0
188+
""")
189+
@test_throws ErrorException load_golden("too_tight")
190+
191+
# Non-gating classes are exempt: they are recorded, never judged.
192+
write(golden_path("diag_inf"), """
193+
[values.rt]
194+
class = "diagnostic"
195+
rtol = inf
196+
atol = inf
197+
value = 12.5
198+
""")
199+
loaded = load_golden("diag_inf")
200+
@test loaded !== nothing && !is_gating(loaded.values["rt"])
201+
202+
@test load_golden("no_such_case") === nothing
203+
@test !has_golden("no_such_case")
204+
end
205+
206+
@testset "save_golden refusals and round trip" begin
207+
meta = GoldenMeta("rt_case", 1, "2026-08-31", "deadbeef", "unit test", "1.11.6", "arm64", "abc", 4, 4)
208+
209+
@test_throws ErrorException save_golden(meta, Dict("empty" => golden_val("empty")))
210+
@test_throws ErrorException save_golden(meta,
211+
Dict("tight" => golden_val("tight"; value_real=1.0, rtol=1e-9, spread=1e-6)))
212+
213+
vals = Dict(
214+
"a" => golden_val("a"; value_real=2.5, rtol=1e-6, drift=1e-8, spread=1e-7, at="mpsi=512"),
215+
"n" => golden_val("n"; value_type="integer", value_int=7, rtol=0.0, class="topological"),
216+
"v" => golden_val("v"; value_type="json_array", value_text="[1.0,2.0]", rtol=1e-6))
217+
save_golden(meta, vals)
218+
back = load_golden("rt_case")
219+
@test back.meta.commit == "deadbeef" && back.meta.golden_version == 1
220+
@test back.values["a"].value_real == 2.5
221+
@test back.values["a"].rtol == 1e-6
222+
@test back.values["a"].plateau_drift == 1e-8 && back.values["a"].platform_spread == 1e-7
223+
@test back.values["n"].value_int == 7 && back.values["n"].class == "topological"
224+
@test back.values["v"].value_text == "[1.0,2.0]"
225+
end
226+
227+
@testset "infer_class" begin
228+
@test infer_class(qspec("runtime"; type="runtime")) == "diagnostic"
229+
@test infer_class(qspec("nstep"; type="int_scalar")) == "diagnostic"
230+
@test infer_class(qspec("msing"; type="int_scalar")) == "topological"
231+
@test infer_class(qspec("integrator"; type="token", extract="toml_key:ForceFreeStates.integrator")) == "topological"
232+
@test infer_class(qspec("q0")) == "equilibrium_scalar"
233+
@test infer_class(qspec("et1_re")) == "physics_converged"
234+
end
235+
236+
@testset "golden check counting and crash classification" begin
237+
db = open_database(joinpath(mktempdir(), "check.sqlite"))
238+
case = CaseSpec("count_case", "synthetic", "", [
239+
qspec("a"),
240+
qspec("rt"; type="runtime", extract=""),
241+
qspec("c"; type="int_scalar"),
242+
qspec("d"),
243+
qspec("e"),
244+
qspec("f")], "example", Dict{String,Any}())
245+
246+
meta = GoldenMeta("count_case", 1, "2026-08-31", "deadbeef", "unit test", "1.11.6", "arm64", "abc", 4, 4)
247+
save_golden(meta, Dict(
248+
"a" => golden_val("a"; value_real=1.0, rtol=1e-6),
249+
"c" => golden_val("c"; value_type="integer", value_int=5, rtol=0.0, class="topological"),
250+
"d" => golden_val("d"; value_real=3.0, rtol=Inf, atol=Inf, class="diagnostic"),
251+
"f" => golden_val("f"; value_real=9.0, rtol=1e-6),
252+
"orphan" => golden_val("orphan"; value_real=7.0, rtol=1e-6)))
253+
254+
# a passes, c fails (5 → 6), d moves wildly but is diagnostic, e has no golden,
255+
# f is golden-pinned but missing from the run, orphan has no spec left in the case.
256+
store_run(db, "hash1", "hash1", "", "", "count_case", 1.0, [
257+
extracted_q("a"; value_real=1.0 + 1e-8),
258+
extracted_q("rt"; value_real=33.0),
259+
extracted_q("c"; value_int=6, value_type="integer"),
260+
extracted_q("d"; value_real=300.0),
261+
extracted_q("e"; value_real=1.0)])
262+
s = report_golden_check(db, case, "hash1")
263+
@test s.n_pass == 1
264+
@test s.n_fail == 3 # c mismatch + f missing from run + orphaned golden entry
265+
@test s.n_informational == 1
266+
@test s.n_untracked == 1 # e only: an unpinned runtime is structurally un-goldenable
267+
@test s.n_run_failed == 0
268+
269+
# A crashed run is a crash, not a tolerance failure: n_run_failed, never n_fail.
270+
store_failed_run(db, "hash2", "hash2", "", "", "count_case", "boom: solver exploded")
271+
s = report_golden_check(db, case, "hash2")
272+
@test s.n_run_failed == 1
273+
@test s.n_fail == 0 && s.n_pass == 0
274+
275+
# No golden file: every count zero, so the caller's zero-coverage guard (a --check that
276+
# gated nothing must exit red) can see that nothing was checked.
277+
nocase = CaseSpec("never_pinned", "synthetic", "", [qspec("a")], "example", Dict{String,Any}())
278+
s = report_golden_check(db, nocase, "hash1")
279+
@test s == (n_pass=0, n_fail=0, n_untracked=0, n_informational=0, n_run_failed=0)
280+
close_database(db)
281+
end
282+
end

0 commit comments

Comments
 (0)