From c76de359e3b86c66e7f6775f7bead66fbb41db9d Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 23 Oct 2026 13:43:14 -0400 Subject: [PATCH 1/3] InnerLayer.GGJ - BUGFIX - Ignore the profile-only options in the :ray solve and warn on very large ray grids A GGJModel{:ray} built with npc or certify_rtol threw a MethodError in solve_inner, which forwarded them to solve_ray. They are now accepted and ignored there, as real_profiles already was. solve_ray warns when the Hermite grid exceeds warn_cells (default 6000), and its docstring states that the grid is fixed rather than refined against a residual, with the expected accuracy and how to get an error bar. Co-Authored-By: Claude Opus 5.5 --- src/InnerLayer/GGJ/Ray.jl | 18 ++++++++++++------ test/runtests_innerlayer.jl | 6 ++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/InnerLayer/GGJ/Ray.jl b/src/InnerLayer/GGJ/Ray.jl index f7e1804d0..0e6247110 100644 --- a/src/InnerLayer/GGJ/Ray.jl +++ b/src/InnerLayer/GGJ/Ray.jl @@ -324,7 +324,7 @@ end """ solve_ray(params::GGJParameters, Q; θ=angle(Q)/4, kmax=12, S=nothing, smax_tol=1e-9, - cfl=0.2, min_cells=1024, nq=6, verbose=false, …) -> RaySolveResult + cfl=0.2, min_cells=1024, warn_cells=6000, nq=6, verbose=false, …) -> RaySolveResult Solve the GGJ inner-layer problem on the rotated ray x = e^{iθ}s. `Δ` is the rescaled pair (Δ₊, Δ₋) = (interchange, tearing), directly comparable to the `:galerkin` backend; θ = 0 is a @@ -334,7 +334,11 @@ Both boundary points are computed per solve. `S` defaults to the smallest radius residual is below `smax_tol` with order `kmax` ([`pick_smax`](@ref); it warns and uses the best point if the tolerance is not reached). The BVP edge s_m is where every exponential family has decayed 45 e-folds from the origin, from the local spectrum, scaled by `sm_fac`. `cfl`, -`min_cells` set the Hermite grid ([`ray_breaks`](@ref)) and `nq` its quadrature. `march_rtol` +`min_cells` set the Hermite grid ([`ray_breaks`](@ref)) and `nq` its quadrature. The grid is fixed by +these rates, not refined against a residual, and no run-time error estimate is returned: at small |Q| +the cubic BVP gives Δ to about 1e-8–2e-7 relative, so pass a smaller `cfl` or larger `min_cells` +when more is needed, or [`delta_convergence`](@ref) for an error bar. A grid of more than +`warn_cells` cells, which only very large |Q| reaches, warns. `march_rtol` is [`march_boundary`](@ref)'s `rtol`; `growth`, `seed`, `ztrk`, `purge_budget`, `dp_res` go to `march_boundary`/[`decaying_pair`](@ref); `handoff` (diagnostic) supplies (Us, Ub) at S. """ @@ -342,7 +346,7 @@ function solve_ray(params::GGJParameters, Q::ComplexF64; θ::Float64=angle(Q) / 4, kmax::Int=12, S::Union{Nothing,Float64}=nothing, smax_tol::Float64=1e-9, growth::Float64=30.0, march_rtol::Float64=1e-9, sm_fac::Float64=1.0, - cfl::Float64=0.2, min_cells::Int=1024, nq::Int=6, seed::Int=20260707, + cfl::Float64=0.2, min_cells::Int=1024, warn_cells::Int=6000, nq::Int=6, seed::Int=20260707, ztrk::Float64=0.3, purge_budget::Float64=2.5, dp_res::Float64=20.0, handoff::Union{Nothing,NTuple{2,Vector{ComplexF64}}}=nothing, verbose::Bool=false) @@ -393,6 +397,7 @@ function solve_ray(params::GGJParameters, Q::ComplexF64; bc_cond < 1e12 || @warn "boundary basis poorly conditioned (cond ≈ $bc_cond)" breaks = ray_breaks(params, Q, θ, Sbvp; cfl=cfl, min_cells=min_cells) + length(breaks) - 1 > warn_cells && @warn "GGJ ray: $(length(breaks) - 1) Hermite cells exceed warn_cells = $warn_cells; the solve is slow at this |Q|" Q ws = _galerkin_workspace(breaks, fill(CT_NONE, length(breaks) - 1)) Δraw, cexp = _assemble_and_solve_point!(ws, params, Q, θ, Us, Ub, E; nq=nq) verbose && @printf(" BVP: %d Hermite cells on [0, %.4g], Δraw[1] = %.6e %+.6eim\n", @@ -414,10 +419,11 @@ Rotated-ray backend: converts γ via `inner_Q`, forwards keywords to [`solve_ray`](@ref), and returns the parity-projected matching data (Δ₊, Δ₋) as the `interchange` and `tearing` fields of [`InnerLayerResponse`](@ref). Preferred for |Q| ≳ 1 and near the imaginary axis; use `solve_ray` directly -when the full [`RaySolveResult`](@ref) is wanted. `real_profiles` is a profile-only model -option (see `solve_inner_profile`) and is ignored here. +when the full [`RaySolveResult`](@ref) is wanted. `real_profiles`, `npc` and `certify_rtol` are +profile-only model options (see `solve_inner_profile`) and are ignored here. """ -function solve_inner(::_BareGGJ{:ray}, params::GGJParameters, γ::Number; real_profiles::Bool=false, kwargs...) +function solve_inner(::_BareGGJ{:ray}, params::GGJParameters, γ::Number; + real_profiles::Bool=false, npc::Int=8, certify_rtol::Float64=1e-3, kwargs...) res = solve_ray(params, inner_Q(params, γ); kwargs...) return InnerLayerResponse(; interchange=res.Δ[1], tearing=res.Δ[2]) end diff --git a/test/runtests_innerlayer.jl b/test/runtests_innerlayer.jl index 6cda60780..810e115c0 100644 --- a/test/runtests_innerlayer.jl +++ b/test/runtests_innerlayer.jl @@ -67,6 +67,9 @@ end @test abs(imag(Δ.tearing)) < 1e-3 * abs(Δ.tearing) # :ray is the default backend. @test IL.GGJModel() === IL.GGJModel{:ray}() + # Profile-only options on the model are ignored by solve_inner. + Δo = IL.solve_inner(IL.GGJModel(; solver=:ray, real_profiles=true, npc=4, certify_rtol=1e-2), p, γ) + @test Δo.interchange == Δ.interchange && Δo.tearing == Δ.tearing end @testset "q4 physical benchmark at Q = 500i (regime beyond :galerkin)" begin @@ -97,6 +100,9 @@ end r = IL.solve_ray(p, Q) r2 = IL.solve_ray(p, Q; cfl=0.025, min_cells=2048) @test maximum(abs.(r2.Δraw .- r.Δraw) ./ abs.(r.Δraw)) < 1e-6 + # A grid larger than warn_cells warns; the solve is unchanged. + r3 = @test_logs (:warn, r"exceed warn_cells") IL.solve_ray(p, Q; warn_cells=100) + @test r3.Δraw == r.Δraw end @testset "ode_matrix: ordinary point and type-generic build" begin From 98e9167b9520ebc09a6aa8ebb32831cf1f8aa00a Mon Sep 17 00:00:00 2001 From: d-burg Date: Fri, 23 Oct 2026 14:04:03 -0400 Subject: [PATCH 2/3] Regression - FEATURE - Add a relative noise floor to harness quantities and use it for ggj_reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A quantity may set relative_threshold, a fraction of the first ref's magnitude below which a change is noise, alongside the absolute noise_threshold. It is stored with each run (an older database gains the column on open). ggj_reference's Δ values are up to ~2e4, where the absolute 1e-10 floor was ~5e-15 relative, below cross-machine rounding; they now use 1e-10 relative. Co-Authored-By: Claude Opus 5.5 --- docs/development/regression-harness.md | 2 ++ docs/development/toml-conventions.md | 2 +- regression-harness/cases/ggj_reference.toml | 7 +++- regression-harness/src/config.jl | 1 + regression-harness/src/database.jl | 18 ++++++---- regression-harness/src/extractor.jl | 38 +++++++++++---------- regression-harness/src/types.jl | 2 ++ 7 files changed, 44 insertions(+), 26 deletions(-) diff --git a/docs/development/regression-harness.md b/docs/development/regression-harness.md index 0dab467f4..0a48c6ed7 100644 --- a/docs/development/regression-harness.md +++ b/docs/development/regression-harness.md @@ -27,6 +27,8 @@ Available regression cases: regress --cases diiid_n1 --refs develop,feature/kinetic-damping ``` +**Noise floors.** Each `[quantities.*]` block sets `noise_threshold`, an absolute floor (default 1e-10), and may set `relative_threshold`, a fraction of the first ref's magnitude (default 0; for arrays, of its largest element). A difference is reported as changed only when it exceeds both. The floors are stored with each run, so after changing them re-run the refs with `--force`. + **Build mode.** Every case runs with GPEC's PrecompileTools workload disabled (the harness sets the `precompile_workload` preference per subprocess), so results compare code compiled on first use. `diiid_n1_riccati_precompiled` sets `precompile_workload = true` in its `[case]` table to track the precompiled build users get by default; the two builds can differ at the percent level in Δ′, so compare that case only against itself. That case is a variant: its file sets `base = "diiid_n1_riccati"` and supplies only `name`, `description` and `precompile_workload`, inheriting the base's `example_dir`, `[quantities.*]` and `[overrides]`. Any `[case]` key, `[quantities.*]` block or `[overrides]` entry a variant sets replaces the base's same-named one. The base is read from the sibling `.toml`; a missing base, or a base that itself has a base, is a load error. To check a variant, `regress --list-cases` should show it with the same quantity count as its base. Each run records the build mode it actually ran (`aot` or `jit`) in its environment fingerprint; the report flags two compared runs whose modes differ, and a run whose mode differs from its case's (e.g. the precompiled case at a commit that predates the workload) logs a warning. All cases requested in a single invocation share one git worktree (and one `Pkg.instantiate`/precompile) per commit, so `--cases a,b,c` in one command is substantially faster than three separate runs. diff --git a/docs/development/toml-conventions.md b/docs/development/toml-conventions.md index 5ee2c3d5d..d41c486d6 100644 --- a/docs/development/toml-conventions.md +++ b/docs/development/toml-conventions.md @@ -16,7 +16,7 @@ modify them. **Do not invent a new convention** — match what the existing exam files** — `examples/DIIID-like_ideal_example/gpec.toml` is the canonical reference for the common sections. Exception: in `regression-harness/cases/*.toml` the repeated `[quantities.*]` schema keys (`h5path`, `type`, `extract`, `label`, `noise_threshold`, - `order`) are developer metadata and do **not** need inline comments — a case file needs + `relative_threshold`, `order`) are developer metadata and do **not** need inline comments — a case file needs the header plus an informative block comment per quantity group instead. 3. **Section comments only when informative.** No comment is required above a section. Keep a block comment only when it carries real information (e.g. the Solovev `[Wall]` note on why a diff --git a/regression-harness/cases/ggj_reference.toml b/regression-harness/cases/ggj_reference.toml index bcf1b76e3..e7aee85f1 100644 --- a/regression-harness/cases/ggj_reference.toml +++ b/regression-harness/cases/ggj_reference.toml @@ -1,7 +1,8 @@ # Regression case: GGJ inner-layer Galerkin solver reference (Glasser & Wang 2020, Eq. 55). # A "computed" case (kind = "computed", no example_dir) that calls solve_inner directly and # tracks the interchange (Δ₊) / tearing (Δ₋) inner-layer coefficients. Each [quantities.*] block names an -# HDF5 path in the computed output, how to extract it, and the noise floor for diffs. +# HDF5 path in the computed output, how to extract it, and the noise floor for diffs. The +# Δ₊ values are ~1e3–1e4, so the floors are relative: a change below 1e-10 of the value is noise. [case] name = "ggj_reference" description = "GGJ inner-layer Galerkin solver, Glasser & Wang 2020 Eq. 55 with physical τ_A, τ_R, 1 kHz rotation (Q ≈ 0.22i)" @@ -14,6 +15,7 @@ type = "real_scalar" extract = "value" label = "Re(Δ₊ interchange)" noise_threshold = 1e-10 +relative_threshold = 1e-10 order = 10 [quantities.delta_interchange_imag] @@ -22,6 +24,7 @@ type = "real_scalar" extract = "value" label = "Im(Δ₊ interchange)" noise_threshold = 1e-10 +relative_threshold = 1e-10 order = 11 [quantities.delta_tearing_real] @@ -30,6 +33,7 @@ type = "real_scalar" extract = "value" label = "Re(Δ₋ tearing)" noise_threshold = 1e-10 +relative_threshold = 1e-10 order = 12 [quantities.delta_tearing_imag] @@ -38,6 +42,7 @@ type = "real_scalar" extract = "value" label = "Im(Δ₋ tearing)" noise_threshold = 1e-10 +relative_threshold = 1e-10 order = 13 [quantities.runtime] diff --git a/regression-harness/src/config.jl b/regression-harness/src/config.jl index cf3ade34d..056b0eba3 100644 --- a/regression-harness/src/config.jl +++ b/regression-harness/src/config.jl @@ -49,6 +49,7 @@ function load_case(filepath::String)::CaseSpec qty_data["extract"], get(qty_data, "label", qty_name), get(qty_data, "noise_threshold", 1e-10), + get(qty_data, "relative_threshold", 0.0), get(qty_data, "order", 1000) ) ) diff --git a/regression-harness/src/database.jl b/regression-harness/src/database.jl index 4999c39b2..179102a23 100644 --- a/regression-harness/src/database.jl +++ b/regression-harness/src/database.jl @@ -35,6 +35,7 @@ CREATE TABLE IF NOT EXISTS quantities ( value_text TEXT, value_type TEXT NOT NULL, noise_threshold REAL NOT NULL DEFAULT 0.0, + relative_threshold REAL NOT NULL DEFAULT 0.0, UNIQUE(run_id, qty_name) ); @@ -74,7 +75,7 @@ const ENV_COLUMNS = [ ] """ -Add any `runs` columns missing from a database created by an earlier harness version. +Add any `runs` and `quantities` columns missing from a database created by an earlier harness version. """ function migrate_schema!(db::SQLite.DB) existing = Set(String[]) @@ -85,6 +86,9 @@ function migrate_schema!(db::SQLite.DB) col in existing && continue DBInterface.execute(db, "ALTER TABLE runs ADD COLUMN $col $sqltype") end + qcols = Set(String(something(row.name, "")) for row in query_rows(db, "PRAGMA table_info(quantities)")) + "relative_threshold" in qcols || + DBInterface.execute(db, "ALTER TABLE quantities ADD COLUMN relative_threshold REAL NOT NULL DEFAULT 0.0") end function open_database(path::String)::SQLite.DB @@ -170,10 +174,11 @@ function store_run(db::SQLite.DB, commit_hash::AbstractString, commit_short::Abs for eq in extracted DBInterface.execute(db, """INSERT INTO quantities - (run_id, qty_name, qty_label, value_real, value_int, value_text, value_type, noise_threshold) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (run_id, qty_name, qty_label, value_real, value_int, value_text, value_type, noise_threshold, + relative_threshold) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", (run_id, eq.name, eq.label, eq.value_real, eq.value_int, eq.value_text, - eq.value_type, eq.noise_threshold)) + eq.value_type, eq.noise_threshold, eq.relative_threshold)) end end end @@ -198,7 +203,7 @@ function get_quantities(db::SQLite.DB, commit_hash::String, case_name::String) results = Dict{String,NamedTuple}() rows = query_rows(db, """SELECT q.qty_name, q.qty_label, q.value_real, q.value_int, q.value_text, - q.value_type, q.noise_threshold + q.value_type, q.noise_threshold, q.relative_threshold FROM quantities q JOIN runs r ON q.run_id = r.id WHERE r.commit_hash = ? AND r.case_name = ?""", @@ -212,7 +217,8 @@ function get_quantities(db::SQLite.DB, commit_hash::String, case_name::String) value_int=row.value_int, value_text=row.value_text, value_type=something(row.value_type, "missing"), - noise_threshold=something(row.noise_threshold, 0.0) + noise_threshold=something(row.noise_threshold, 0.0), + relative_threshold=_column(row.relative_threshold, 0.0) ) end return results diff --git a/regression-harness/src/extractor.jl b/regression-harness/src/extractor.jl index 33a328d7c..e5a593564 100644 --- a/regression-harness/src/extractor.jl +++ b/regression-harness/src/extractor.jl @@ -19,7 +19,7 @@ function extract_quantities(h5path::String, qty_specs::Vector{QuantitySpec}, run push!(results, ExtractedQuantity( spec.name, spec.label, runtime_s, nothing, nothing, - "real", spec.noise_threshold)) + "real", spec.noise_threshold, spec.relative_threshold)) continue end @@ -28,7 +28,7 @@ function extract_quantities(h5path::String, qty_specs::Vector{QuantitySpec}, run push!(results, ExtractedQuantity( spec.name, spec.label, nothing, nothing, nothing, - "missing", spec.noise_threshold)) + "missing", spec.noise_threshold, spec.relative_threshold)) continue end @@ -48,36 +48,37 @@ function apply_extraction(spec::QuantitySpec, raw)::ExtractedQuantity name = spec.name label = spec.label threshold = spec.noise_threshold + rel = spec.relative_threshold if spec.extract == "value" if raw isa Integer - return ExtractedQuantity(name, label, nothing, Int(raw), nothing, "integer", threshold) + return ExtractedQuantity(name, label, nothing, Int(raw), nothing, "integer", threshold, rel) else - return ExtractedQuantity(name, label, Float64(raw), nothing, nothing, "real", threshold) + return ExtractedQuantity(name, label, Float64(raw), nothing, nothing, "real", threshold, rel) end elseif spec.extract == "real_first" val = real(raw[1]) - return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold) + return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold, rel) elseif spec.extract == "imag_first" val = imag(raw[1]) - return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold) + return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold, rel) elseif spec.extract == "abs_first" val = abs(raw[1]) - return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold) + return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold, rel) elseif spec.extract == "norm" val = sqrt(sum(abs2, raw)) - return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold) + return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold, rel) elseif spec.extract == "all_real" arr = Float64.(real.(raw)) # allownan: SLAYER γ/Q_root are NaN when no dispersion root is found — # a legitimate state to record so a future root appearing flags a diff. json_str = JSON.json(arr; allownan=true) - return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold) + return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold, rel) elseif startswith(spec.extract, "first_") && endswith(spec.extract, "_complex") # "first_N_complex": [re, im] pairs of the leading N elements of a complex @@ -85,7 +86,7 @@ function apply_extraction(spec::QuantitySpec, raw)::ExtractedQuantity nkeep = parse(Int, spec.extract[(length("first_")+1):(end-length("_complex"))]) pairs = [[real(x), imag(x)] for x in raw[1:min(nkeep, length(raw))]] json_str = JSON.json(pairs; allownan=true) - return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold) + return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold, rel) elseif startswith(spec.extract, "first_") # "first_N": real values of the leading N vector elements only. Used to @@ -95,12 +96,12 @@ function apply_extraction(spec::QuantitySpec, raw)::ExtractedQuantity nkeep = parse(Int, spec.extract[(length("first_")+1):end]) arr = Float64.(real.(raw))[1:min(nkeep, length(raw))] json_str = JSON.json(arr; allownan=true) - return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold) + return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold, rel) elseif spec.extract == "all_complex" pairs = [[real(x), imag(x)] for x in raw] json_str = JSON.json(pairs; allownan=true) - return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold) + return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold, rel) elseif spec.extract == "diagonal_complex" # Extract the diagonal of a square matrix as a complex array. @@ -110,12 +111,12 @@ function apply_extraction(spec::QuantitySpec, raw)::ExtractedQuantity diag_vec = [raw[i, i] for i in 1:size(raw, 1)] pairs = [[real(x), imag(x)] for x in diag_vec] json_str = JSON.json(pairs; allownan=true) - return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold) + return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold, rel) elseif spec.extract == "checksum" bytes = reinterpret(UInt8, vec(collect(raw))) hash = bytes2hex(sha256(bytes)) - return ExtractedQuantity(name, label, nothing, nothing, hash, "checksum", threshold) + return ExtractedQuantity(name, label, nothing, nothing, hash, "checksum", threshold, rel) else error("Unknown extraction mode: $(spec.extract)") @@ -172,7 +173,8 @@ function compare_values(q1::NamedTuple, q2::NamedTuple) return (NaN, NaN, "N/A (type changed)") end - threshold = q1.noise_threshold + # A change is noise below the absolute floor or below `relative_threshold` × the first ref's magnitude. + floor(scale) = max(q1.noise_threshold, get(q1, :relative_threshold, 0.0) * scale) if vtype == "real" v1 = q1.value_real @@ -182,7 +184,7 @@ function compare_values(q1::NamedTuple, q2::NamedTuple) end abs_diff = abs(v2 - v1) rel_diff = v1 == 0.0 ? (v2 == 0.0 ? 0.0 : Inf) : abs_diff / abs(v1) - status = abs_diff <= threshold ? "OK" : "CHANGED" + status = abs_diff <= floor(abs(v1)) ? "OK" : "CHANGED" return (abs_diff, rel_diff, status) elseif vtype == "integer" @@ -193,7 +195,7 @@ function compare_values(q1::NamedTuple, q2::NamedTuple) end abs_diff = Float64(abs(v2 - v1)) rel_diff = v1 == 0 ? (v2 == 0 ? 0.0 : Inf) : abs_diff / abs(v1) - status = abs_diff <= threshold ? "OK" : "CHANGED" + status = abs_diff <= floor(abs(v1)) ? "OK" : "CHANGED" return (abs_diff, rel_diff, status) elseif vtype == "json_array" @@ -215,7 +217,7 @@ function compare_values(q1::NamedTuple, q2::NamedTuple) # For relative diff, use max absolute value as reference max_val = maximum(x -> _json_element_abs(x), arr1; init=0.0) rel_diff = max_val == 0.0 ? (max_diff == 0.0 ? 0.0 : Inf) : max_diff / max_val - status = max_diff <= threshold ? "OK" : "CHANGED" + status = max_diff <= floor(max_val) ? "OK" : "CHANGED" return (max_diff, rel_diff, status) elseif vtype == "checksum" diff --git a/regression-harness/src/types.jl b/regression-harness/src/types.jl index 90489fc07..fb709452e 100644 --- a/regression-harness/src/types.jl +++ b/regression-harness/src/types.jl @@ -12,6 +12,7 @@ struct QuantitySpec extract::String # "value", "real_first", "imag_first", "abs_first", "norm", "all_real", "all_complex", "checksum" label::String # Human-readable label for reports noise_threshold::Float64 # Absolute changes below this are noise + relative_threshold::Float64 # Changes below this fraction of the first ref's magnitude are also noise order::Int # Display order in reports (lower = earlier) end @@ -49,6 +50,7 @@ struct ExtractedQuantity value_text::Union{String,Nothing} # JSON for arrays, hex for checksums value_type::String # "real", "integer", "json_array", "checksum", "missing" noise_threshold::Float64 + relative_threshold::Float64 end """ From 2dc99e1c84c61f0198e1b60d5ffa314866fe5f49 Mon Sep 17 00:00:00 2001 From: d-burg Date: Wed, 28 Oct 2026 16:17:26 -0400 Subject: [PATCH 3/3] InnerLayer.GGJ - BUGFIX - Keep develop's harness floors, warn once on a large ray grid and leave profile-only options untyped Co-Authored-By: Claude Opus 5.5 --- docs/development/regression-harness.md | 2 -- docs/development/toml-conventions.md | 2 +- regression-harness/cases/ggj_reference.toml | 7 +--- regression-harness/src/config.jl | 1 - regression-harness/src/database.jl | 18 ++++------ regression-harness/src/extractor.jl | 38 ++++++++++----------- regression-harness/src/types.jl | 2 -- src/InnerLayer/GGJ/Ray.jl | 7 ++-- 8 files changed, 30 insertions(+), 47 deletions(-) diff --git a/docs/development/regression-harness.md b/docs/development/regression-harness.md index 0a48c6ed7..0dab467f4 100644 --- a/docs/development/regression-harness.md +++ b/docs/development/regression-harness.md @@ -27,8 +27,6 @@ Available regression cases: regress --cases diiid_n1 --refs develop,feature/kinetic-damping ``` -**Noise floors.** Each `[quantities.*]` block sets `noise_threshold`, an absolute floor (default 1e-10), and may set `relative_threshold`, a fraction of the first ref's magnitude (default 0; for arrays, of its largest element). A difference is reported as changed only when it exceeds both. The floors are stored with each run, so after changing them re-run the refs with `--force`. - **Build mode.** Every case runs with GPEC's PrecompileTools workload disabled (the harness sets the `precompile_workload` preference per subprocess), so results compare code compiled on first use. `diiid_n1_riccati_precompiled` sets `precompile_workload = true` in its `[case]` table to track the precompiled build users get by default; the two builds can differ at the percent level in Δ′, so compare that case only against itself. That case is a variant: its file sets `base = "diiid_n1_riccati"` and supplies only `name`, `description` and `precompile_workload`, inheriting the base's `example_dir`, `[quantities.*]` and `[overrides]`. Any `[case]` key, `[quantities.*]` block or `[overrides]` entry a variant sets replaces the base's same-named one. The base is read from the sibling `.toml`; a missing base, or a base that itself has a base, is a load error. To check a variant, `regress --list-cases` should show it with the same quantity count as its base. Each run records the build mode it actually ran (`aot` or `jit`) in its environment fingerprint; the report flags two compared runs whose modes differ, and a run whose mode differs from its case's (e.g. the precompiled case at a commit that predates the workload) logs a warning. All cases requested in a single invocation share one git worktree (and one `Pkg.instantiate`/precompile) per commit, so `--cases a,b,c` in one command is substantially faster than three separate runs. diff --git a/docs/development/toml-conventions.md b/docs/development/toml-conventions.md index b3513726e..43bd03a08 100644 --- a/docs/development/toml-conventions.md +++ b/docs/development/toml-conventions.md @@ -16,7 +16,7 @@ modify them. **Do not invent a new convention** — match what the existing exam files** — `examples/DIIID-like_ideal_example/gpec.toml` is the canonical reference for the common sections. Exception: in `regression-harness/cases/*.toml` the repeated `[quantities.*]` schema keys (`h5path`, `type`, `extract`, `label`, `noise_threshold`, - `relative_threshold`, `order`) are developer metadata and do **not** need inline comments — a case file needs + `order`) are developer metadata and do **not** need inline comments — a case file needs the header plus an informative block comment per quantity group instead. 3. **Section comments only when informative.** No comment is required above a section. Keep a block comment only when it carries real information (e.g. the Solovev `[Wall]` note on why a diff --git a/regression-harness/cases/ggj_reference.toml b/regression-harness/cases/ggj_reference.toml index e7aee85f1..bcf1b76e3 100644 --- a/regression-harness/cases/ggj_reference.toml +++ b/regression-harness/cases/ggj_reference.toml @@ -1,8 +1,7 @@ # Regression case: GGJ inner-layer Galerkin solver reference (Glasser & Wang 2020, Eq. 55). # A "computed" case (kind = "computed", no example_dir) that calls solve_inner directly and # tracks the interchange (Δ₊) / tearing (Δ₋) inner-layer coefficients. Each [quantities.*] block names an -# HDF5 path in the computed output, how to extract it, and the noise floor for diffs. The -# Δ₊ values are ~1e3–1e4, so the floors are relative: a change below 1e-10 of the value is noise. +# HDF5 path in the computed output, how to extract it, and the noise floor for diffs. [case] name = "ggj_reference" description = "GGJ inner-layer Galerkin solver, Glasser & Wang 2020 Eq. 55 with physical τ_A, τ_R, 1 kHz rotation (Q ≈ 0.22i)" @@ -15,7 +14,6 @@ type = "real_scalar" extract = "value" label = "Re(Δ₊ interchange)" noise_threshold = 1e-10 -relative_threshold = 1e-10 order = 10 [quantities.delta_interchange_imag] @@ -24,7 +22,6 @@ type = "real_scalar" extract = "value" label = "Im(Δ₊ interchange)" noise_threshold = 1e-10 -relative_threshold = 1e-10 order = 11 [quantities.delta_tearing_real] @@ -33,7 +30,6 @@ type = "real_scalar" extract = "value" label = "Re(Δ₋ tearing)" noise_threshold = 1e-10 -relative_threshold = 1e-10 order = 12 [quantities.delta_tearing_imag] @@ -42,7 +38,6 @@ type = "real_scalar" extract = "value" label = "Im(Δ₋ tearing)" noise_threshold = 1e-10 -relative_threshold = 1e-10 order = 13 [quantities.runtime] diff --git a/regression-harness/src/config.jl b/regression-harness/src/config.jl index 056b0eba3..cf3ade34d 100644 --- a/regression-harness/src/config.jl +++ b/regression-harness/src/config.jl @@ -49,7 +49,6 @@ function load_case(filepath::String)::CaseSpec qty_data["extract"], get(qty_data, "label", qty_name), get(qty_data, "noise_threshold", 1e-10), - get(qty_data, "relative_threshold", 0.0), get(qty_data, "order", 1000) ) ) diff --git a/regression-harness/src/database.jl b/regression-harness/src/database.jl index 3220f5751..0165f7806 100644 --- a/regression-harness/src/database.jl +++ b/regression-harness/src/database.jl @@ -35,7 +35,6 @@ CREATE TABLE IF NOT EXISTS quantities ( value_text TEXT, value_type TEXT NOT NULL, noise_threshold REAL NOT NULL DEFAULT 0.0, - relative_threshold REAL NOT NULL DEFAULT 0.0, UNIQUE(run_id, qty_name) ); @@ -75,7 +74,7 @@ const ENV_COLUMNS = [ ] """ -Add any `runs` and `quantities` columns missing from a database created by an earlier harness version. +Add any `runs` columns missing from a database created by an earlier harness version. """ function migrate_schema!(db::SQLite.DB) existing = Set(String[]) @@ -86,9 +85,6 @@ function migrate_schema!(db::SQLite.DB) col in existing && continue DBInterface.execute(db, "ALTER TABLE runs ADD COLUMN $col $sqltype") end - qcols = Set(String(something(row.name, "")) for row in query_rows(db, "PRAGMA table_info(quantities)")) - "relative_threshold" in qcols || - DBInterface.execute(db, "ALTER TABLE quantities ADD COLUMN relative_threshold REAL NOT NULL DEFAULT 0.0") end function open_database(path::String)::SQLite.DB @@ -174,11 +170,10 @@ function store_run(db::SQLite.DB, commit_hash::AbstractString, commit_short::Abs for eq in extracted DBInterface.execute(db, """INSERT INTO quantities - (run_id, qty_name, qty_label, value_real, value_int, value_text, value_type, noise_threshold, - relative_threshold) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (run_id, qty_name, qty_label, value_real, value_int, value_text, value_type, noise_threshold) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", (run_id, eq.name, eq.label, eq.value_real, eq.value_int, eq.value_text, - eq.value_type, eq.noise_threshold, eq.relative_threshold)) + eq.value_type, eq.noise_threshold)) end end end @@ -203,7 +198,7 @@ function get_quantities(db::SQLite.DB, commit_hash::String, case_name::String) results = Dict{String,NamedTuple}() rows = query_rows(db, """SELECT q.qty_name, q.qty_label, q.value_real, q.value_int, q.value_text, - q.value_type, q.noise_threshold, q.relative_threshold + q.value_type, q.noise_threshold FROM quantities q JOIN runs r ON q.run_id = r.id WHERE r.commit_hash = ? AND r.case_name = ?""", @@ -217,8 +212,7 @@ function get_quantities(db::SQLite.DB, commit_hash::String, case_name::String) value_int=row.value_int, value_text=row.value_text, value_type=something(row.value_type, "missing"), - noise_threshold=something(row.noise_threshold, 0.0), - relative_threshold=_column(row.relative_threshold, 0.0) + noise_threshold=something(row.noise_threshold, 0.0) ) end return results diff --git a/regression-harness/src/extractor.jl b/regression-harness/src/extractor.jl index e5a593564..33a328d7c 100644 --- a/regression-harness/src/extractor.jl +++ b/regression-harness/src/extractor.jl @@ -19,7 +19,7 @@ function extract_quantities(h5path::String, qty_specs::Vector{QuantitySpec}, run push!(results, ExtractedQuantity( spec.name, spec.label, runtime_s, nothing, nothing, - "real", spec.noise_threshold, spec.relative_threshold)) + "real", spec.noise_threshold)) continue end @@ -28,7 +28,7 @@ function extract_quantities(h5path::String, qty_specs::Vector{QuantitySpec}, run push!(results, ExtractedQuantity( spec.name, spec.label, nothing, nothing, nothing, - "missing", spec.noise_threshold, spec.relative_threshold)) + "missing", spec.noise_threshold)) continue end @@ -48,37 +48,36 @@ function apply_extraction(spec::QuantitySpec, raw)::ExtractedQuantity name = spec.name label = spec.label threshold = spec.noise_threshold - rel = spec.relative_threshold if spec.extract == "value" if raw isa Integer - return ExtractedQuantity(name, label, nothing, Int(raw), nothing, "integer", threshold, rel) + return ExtractedQuantity(name, label, nothing, Int(raw), nothing, "integer", threshold) else - return ExtractedQuantity(name, label, Float64(raw), nothing, nothing, "real", threshold, rel) + return ExtractedQuantity(name, label, Float64(raw), nothing, nothing, "real", threshold) end elseif spec.extract == "real_first" val = real(raw[1]) - return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold, rel) + return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold) elseif spec.extract == "imag_first" val = imag(raw[1]) - return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold, rel) + return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold) elseif spec.extract == "abs_first" val = abs(raw[1]) - return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold, rel) + return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold) elseif spec.extract == "norm" val = sqrt(sum(abs2, raw)) - return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold, rel) + return ExtractedQuantity(name, label, Float64(val), nothing, nothing, "real", threshold) elseif spec.extract == "all_real" arr = Float64.(real.(raw)) # allownan: SLAYER γ/Q_root are NaN when no dispersion root is found — # a legitimate state to record so a future root appearing flags a diff. json_str = JSON.json(arr; allownan=true) - return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold, rel) + return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold) elseif startswith(spec.extract, "first_") && endswith(spec.extract, "_complex") # "first_N_complex": [re, im] pairs of the leading N elements of a complex @@ -86,7 +85,7 @@ function apply_extraction(spec::QuantitySpec, raw)::ExtractedQuantity nkeep = parse(Int, spec.extract[(length("first_")+1):(end-length("_complex"))]) pairs = [[real(x), imag(x)] for x in raw[1:min(nkeep, length(raw))]] json_str = JSON.json(pairs; allownan=true) - return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold, rel) + return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold) elseif startswith(spec.extract, "first_") # "first_N": real values of the leading N vector elements only. Used to @@ -96,12 +95,12 @@ function apply_extraction(spec::QuantitySpec, raw)::ExtractedQuantity nkeep = parse(Int, spec.extract[(length("first_")+1):end]) arr = Float64.(real.(raw))[1:min(nkeep, length(raw))] json_str = JSON.json(arr; allownan=true) - return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold, rel) + return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold) elseif spec.extract == "all_complex" pairs = [[real(x), imag(x)] for x in raw] json_str = JSON.json(pairs; allownan=true) - return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold, rel) + return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold) elseif spec.extract == "diagonal_complex" # Extract the diagonal of a square matrix as a complex array. @@ -111,12 +110,12 @@ function apply_extraction(spec::QuantitySpec, raw)::ExtractedQuantity diag_vec = [raw[i, i] for i in 1:size(raw, 1)] pairs = [[real(x), imag(x)] for x in diag_vec] json_str = JSON.json(pairs; allownan=true) - return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold, rel) + return ExtractedQuantity(name, label, nothing, nothing, json_str, "json_array", threshold) elseif spec.extract == "checksum" bytes = reinterpret(UInt8, vec(collect(raw))) hash = bytes2hex(sha256(bytes)) - return ExtractedQuantity(name, label, nothing, nothing, hash, "checksum", threshold, rel) + return ExtractedQuantity(name, label, nothing, nothing, hash, "checksum", threshold) else error("Unknown extraction mode: $(spec.extract)") @@ -173,8 +172,7 @@ function compare_values(q1::NamedTuple, q2::NamedTuple) return (NaN, NaN, "N/A (type changed)") end - # A change is noise below the absolute floor or below `relative_threshold` × the first ref's magnitude. - floor(scale) = max(q1.noise_threshold, get(q1, :relative_threshold, 0.0) * scale) + threshold = q1.noise_threshold if vtype == "real" v1 = q1.value_real @@ -184,7 +182,7 @@ function compare_values(q1::NamedTuple, q2::NamedTuple) end abs_diff = abs(v2 - v1) rel_diff = v1 == 0.0 ? (v2 == 0.0 ? 0.0 : Inf) : abs_diff / abs(v1) - status = abs_diff <= floor(abs(v1)) ? "OK" : "CHANGED" + status = abs_diff <= threshold ? "OK" : "CHANGED" return (abs_diff, rel_diff, status) elseif vtype == "integer" @@ -195,7 +193,7 @@ function compare_values(q1::NamedTuple, q2::NamedTuple) end abs_diff = Float64(abs(v2 - v1)) rel_diff = v1 == 0 ? (v2 == 0 ? 0.0 : Inf) : abs_diff / abs(v1) - status = abs_diff <= floor(abs(v1)) ? "OK" : "CHANGED" + status = abs_diff <= threshold ? "OK" : "CHANGED" return (abs_diff, rel_diff, status) elseif vtype == "json_array" @@ -217,7 +215,7 @@ function compare_values(q1::NamedTuple, q2::NamedTuple) # For relative diff, use max absolute value as reference max_val = maximum(x -> _json_element_abs(x), arr1; init=0.0) rel_diff = max_val == 0.0 ? (max_diff == 0.0 ? 0.0 : Inf) : max_diff / max_val - status = max_diff <= floor(max_val) ? "OK" : "CHANGED" + status = max_diff <= threshold ? "OK" : "CHANGED" return (max_diff, rel_diff, status) elseif vtype == "checksum" diff --git a/regression-harness/src/types.jl b/regression-harness/src/types.jl index fb709452e..90489fc07 100644 --- a/regression-harness/src/types.jl +++ b/regression-harness/src/types.jl @@ -12,7 +12,6 @@ struct QuantitySpec extract::String # "value", "real_first", "imag_first", "abs_first", "norm", "all_real", "all_complex", "checksum" label::String # Human-readable label for reports noise_threshold::Float64 # Absolute changes below this are noise - relative_threshold::Float64 # Changes below this fraction of the first ref's magnitude are also noise order::Int # Display order in reports (lower = earlier) end @@ -50,7 +49,6 @@ struct ExtractedQuantity value_text::Union{String,Nothing} # JSON for arrays, hex for checksums value_type::String # "real", "integer", "json_array", "checksum", "missing" noise_threshold::Float64 - relative_threshold::Float64 end """ diff --git a/src/InnerLayer/GGJ/Ray.jl b/src/InnerLayer/GGJ/Ray.jl index 0e6247110..4d720ae28 100644 --- a/src/InnerLayer/GGJ/Ray.jl +++ b/src/InnerLayer/GGJ/Ray.jl @@ -338,7 +338,8 @@ decayed 45 e-folds from the origin, from the local spectrum, scaled by `sm_fac`. these rates, not refined against a residual, and no run-time error estimate is returned: at small |Q| the cubic BVP gives Δ to about 1e-8–2e-7 relative, so pass a smaller `cfl` or larger `min_cells` when more is needed, or [`delta_convergence`](@ref) for an error bar. A grid of more than -`warn_cells` cells, which only very large |Q| reaches, warns. `march_rtol` +`warn_cells` cells warns once, as a cost guard: it is set by `cfl`, `min_cells` and the local rates, +which grow with |Q|. `march_rtol` is [`march_boundary`](@ref)'s `rtol`; `growth`, `seed`, `ztrk`, `purge_budget`, `dp_res` go to `march_boundary`/[`decaying_pair`](@ref); `handoff` (diagnostic) supplies (Us, Ub) at S. """ @@ -397,7 +398,7 @@ function solve_ray(params::GGJParameters, Q::ComplexF64; bc_cond < 1e12 || @warn "boundary basis poorly conditioned (cond ≈ $bc_cond)" breaks = ray_breaks(params, Q, θ, Sbvp; cfl=cfl, min_cells=min_cells) - length(breaks) - 1 > warn_cells && @warn "GGJ ray: $(length(breaks) - 1) Hermite cells exceed warn_cells = $warn_cells; the solve is slow at this |Q|" Q + length(breaks) - 1 > warn_cells && @warn "GGJ ray: $(length(breaks) - 1) Hermite cells exceed warn_cells = $warn_cells, so the solve is slow; the grid follows cfl = $cfl, min_cells = $min_cells and the local rates at this Q" Q maxlog = 1 _id = (:ggj_ray_cells, warn_cells) ws = _galerkin_workspace(breaks, fill(CT_NONE, length(breaks) - 1)) Δraw, cexp = _assemble_and_solve_point!(ws, params, Q, θ, Us, Ub, E; nq=nq) verbose && @printf(" BVP: %d Hermite cells on [0, %.4g], Δraw[1] = %.6e %+.6eim\n", @@ -423,7 +424,7 @@ when the full [`RaySolveResult`](@ref) is wanted. `real_profiles`, `npc` and `ce profile-only model options (see `solve_inner_profile`) and are ignored here. """ function solve_inner(::_BareGGJ{:ray}, params::GGJParameters, γ::Number; - real_profiles::Bool=false, npc::Int=8, certify_rtol::Float64=1e-3, kwargs...) + real_profiles=nothing, npc=nothing, certify_rtol=nothing, kwargs...) res = solve_ray(params, inner_Q(params, γ); kwargs...) return InnerLayerResponse(; interchange=res.Δ[1], tearing=res.Δ[2]) end