|
| 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