From 0db5500a731dd7d4c5c2768a271c852611846ef2 Mon Sep 17 00:00:00 2001 From: sotashimozono Date: Tue, 15 Sep 2026 07:54:43 +0000 Subject: [PATCH 1/6] Run every route to a target, not the first one the registry reaches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `derive` forward-chains and takes the first relation that produces the target. Measured on the current registry: 66 of the 219 symbol-keyed outputs have more than one producing relation, and 38 of the 84 typed ones, up to nineteen for a single symbol. Which one runs is iteration order, and if two disagree the caller is handed a number and told nothing. `derivation_routes` returns them all. `derive_crosschecked` refuses when they disagree. The hazard in building this is not the disagreement, it is the vacuous agreement, and it has two levels. Both are guarded and both are pinned by a mutation. 1. A SUPPLIED target lets the closure manufacture its own inputs from it. With only `c` and `ncuts` supplied, the closure builds `dS_dlogℓ`, `dS_dlogchord` and `ΔS` from `c`, and three routes return the supplied number and agree. The target is therefore held out of the data before the closure runs, as a given (`pop!` / `_holdout!`) and, since β and T are one quantity under two names, so is its alias. 2. A DERIVED target does the same thing one level down. Given a real `dS_dlogℓ`, the chain derives `c`, then rebuilds the chord slope from it, then reads `c` back off that: three rows, one measurement, perfect agreement. So the chains also take `avoid`, and never produce the target at all. | `derivation_routes(:c; dS_dlogℓ, ncuts)` | rows | values | |---|---|---| | as written | 1 | 0.5 | | without `avoid` | 3 | 0.5, 0.5, 0.5 | `min_routes` is how to ask that a cross-check happened. The default accepts data affording no independent route, which returns a number this verb's name would otherwise claim it had checked. What it catches, on the typed door, with `F` reachable both through `Z` and through the Legendre transform and the entropy broken by 0.5: | | result | |---|---| | `derive(FreeEnergy, bad)` | -1.3733, the RIGHT number off the other route | | `derive_crosschecked(FreeEnergy, bad)` | refused, spread 0.3128, both routes named | Mutation, against test/relations/test_derivation_routes.jl (27 assertions): | mutation | assertions failed | |---|---| | drop the symbol-door holdout | 2 | | drop the typed-door holdout | 2 | | drop the β/T alias holdout | 1 | | symbol chain ignores `avoid` | 3 | | never refuse a disagreement | 4 | | `min_routes` never fires | 1 | The two deleted lines in `src/` are the signatures of `_forward_chain` and `_typed_chain!`, both private, widened with a defaulted keyword. No public API is removed or changed, so 0.7.16 -> 0.7.17. Co-Authored-By: Claude Opus 5 (1M context) --- Project.toml | 2 +- src/relations/derivation.jl | 180 ++++++++++++++++++++++- test/relations/test_derivation_routes.jl | 113 ++++++++++++++ 3 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 test/relations/test_derivation_routes.jl diff --git a/Project.toml b/Project.toml index cc59232..ac7bcb2 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "AbstractQAtlas" uuid = "dcea2817-62f8-4a75-b498-1b50a9ed1e4d" -version = "0.7.16" +version = "0.7.17" authors = ["sota shimozono "] [deps] diff --git a/src/relations/derivation.jl b/src/relations/derivation.jl index 5888699..42177d6 100644 --- a/src/relations/derivation.jl +++ b/src/relations/derivation.jl @@ -117,12 +117,17 @@ end # Forward-chaining closure: keep firing any step whose inputs are all known # until nothing new is produced. Records the ordered steps actually used. # Stops early once `stop` (if given) becomes known. -function _forward_chain(known::Dict{Symbol,Any}, stop::Union{Symbol,Nothing}) +function _forward_chain( + known::Dict{Symbol,Any}, + stop::Union{Symbol,Nothing}; + avoid::Union{Symbol,Nothing}=nothing, +) used = DerivationStep[] progress = true while progress && !(stop !== nothing && haskey(known, stop)) progress = false for step in derivation_steps() + step.output === avoid && continue haskey(known, step.output) && continue v = _try_step(step, known) v === nothing && continue @@ -334,12 +339,18 @@ end # Forward-chaining closure over VariableKey nodes: fire any step whose inputs are all # known until nothing new is produced (stopping early once `stop` is known). All # "known" tests are aliasing-aware, so `known` never accumulates both β and T. -function _typed_chain!(known::Bag, extras, stop::Union{VariableKey,Nothing}) +function _typed_chain!( + known::Bag, + extras, + stop::Union{VariableKey,Nothing}; + avoid::Union{VariableKey,Nothing}=nothing, +) used = TypedStep[] progress = true while progress && !(stop !== nothing && _known(stop.type, known)) progress = false for step in typed_derivation_steps() + step.output == avoid && continue _known(step.output.type, known) && continue v = _try_typed_step(step, known, extras) v === nothing && continue @@ -778,3 +789,168 @@ function consistent(b::Bag; kwargs...) _refuse_vacuous(rows) return all(r -> r.agree, rows) end + +# ─── Every route to one target, not the first one found ────────────────── +# +# `derive` stops at the first relation that produces the target, and 66 of the +# 219 symbol-keyed outputs (38 of 84 typed ones) have more than one producing +# relation, up to nineteen. Which one runs is registry iteration order, and if +# two disagree the caller is handed a number and told nothing. +# +# The trap in checking them is circular confirmation: derive the target first +# and an intermediate built FROM it will confirm a second route trivially. So +# the closure here is built with the target held out, both as a given and as a +# derivable node, and every route is then run against data that does not contain +# it. + +""" + DerivationRouteRow + +One route to a target: the `relation` that produced it, the `inputs` it consumed, +and the `value` it returned. +""" +struct DerivationRouteRow + relation::AbstractRelation + inputs::Vector{Any} + value::Any +end +export DerivationRouteRow + +function Base.show(io::IO, r::DerivationRouteRow) + return print( + io, nameof(typeof(r.relation)), ": {", join(r.inputs, ", "), "} → ", r.value + ) +end + +""" + derivation_routes(target::Symbol; knowns...) -> Vector{DerivationRouteRow} + derivation_routes(Q::Type, bag::Bag; extras...) -> Vector{DerivationRouteRow} + +EVERY relation that can produce `target` from the knowns, each with the value it +gives, where [`derive`](@ref) runs whichever one the registry reaches first. + +The target is held out of the data the routes are run against, as a given and as +a derivable node both, so a route cannot read a value that was itself derived +from the target and confirm itself. A route needing something only reachable +through the target therefore does not appear, which is the correct answer for it. + +Supplying the target is the useful case: the rows are then what the rest of the +data predicts for a number already measured. + +```julia +derivation_routes(:c; dS_dlogℓ = 0.1667, ncuts = 2) +``` +""" +function derivation_routes(target::Symbol; knowns...) + known = Dict{Symbol,Any}(pairs(knowns)) + pop!(known, target, nothing) + _forward_chain(known, nothing; avoid=target) + rows = DerivationRouteRow[] + for step in derivation_steps() + step.output === target || continue + v = _try_step(step, known) + v === nothing && continue + push!(rows, DerivationRouteRow(step.relation, Any[step.inputs...], v)) + end + return rows +end + +# β and T are one quantity under two names, so holding out the target means +# holding out whichever of the pair the bag carries. +function _holdout!(known::Bag, @nospecialize(Q::Type)) + delete!(known, VariableKey(Q)) + Q === Temperature && delete!(known, VariableKey(InverseTemperature)) + Q === InverseTemperature && delete!(known, VariableKey(Temperature)) + return known +end + +function derivation_routes(@nospecialize(Q::Type), bag::Bag; extras...) + known = copy(bag) + _check_one_temperature(known) + _holdout!(known, Q) + target = VariableKey(Q) + _typed_chain!(known, values(extras), nothing; avoid=target) + rows = DerivationRouteRow[] + for step in typed_derivation_steps() + step.output == target || continue + v = _try_typed_step(step, known, values(extras)) + v === nothing && continue + push!(rows, DerivationRouteRow(step.relation, Any[step.inputs...], v)) + end + return rows +end +export derivation_routes + +# The spread a set of values shows, relative to their largest magnitude. `NaN` for +# fewer than two, which is not agreement and must not read as zero. +function _value_spread(vs) + length(vs) < 2 && return NaN + m = max(maximum(abs, vs), 1) + return maximum(abs(a - b) for a in vs, b in vs) / m +end + +""" + derive_crosschecked(target::Symbol; rtol=1e-8, knowns...) -> value + derive_crosschecked(Q::Type, bag::Bag; rtol=1e-8, extras...) -> value + +[`derive`](@ref), refusing when the data reaches the target two ways that +disagree by more than `rtol`. + +One route returning a number is not evidence the data is consistent about it, +and with up to nineteen relations producing one symbol the route that ran was +chosen by iteration order. A single route is accepted, since there is nothing to +compare it against; the refusal fires only where the data contradicts itself. + +Supplying the target is the case to reach for. [`derive`](@ref) hands it straight +back without looking at anything else, and this compares it against every route +the rest of the data affords, which is the question a measured number raises. + +`min_routes` is how to ask that a cross-check actually happened. The default of +`0` accepts data that affords no independent route, which returns a number this +verb's name would otherwise claim it had checked. +""" +function derive_crosschecked(target::Symbol; rtol::Real=1e-8, min_routes::Int=0, knowns...) + rows = derivation_routes(target; knowns...) + supplied = get(Dict{Symbol,Any}(pairs(knowns)), target, nothing) + _require_routes(":$target", rows, min_routes) + isempty(rows) && return supplied === nothing ? derive(target; knowns...) : supplied + _refuse_disagreement(":$target", rows, supplied, rtol) + return supplied === nothing ? first(rows).value : supplied +end + +function derive_crosschecked( + @nospecialize(Q::Type), bag::Bag; rtol::Real=1e-8, min_routes::Int=0, extras... +) + rows = derivation_routes(Q, bag; extras...) + sup = _slot_value(Q, bag) + supplied = sup === nothing ? nothing : something(sup) + _require_routes(string(nameof(Q)), rows, min_routes) + isempty(rows) && return supplied === nothing ? derive(Q, bag; extras...) : supplied + _refuse_disagreement(string(nameof(Q)), rows, supplied, rtol) + return supplied === nothing ? first(rows).value : supplied +end +export derive_crosschecked + +function _refuse_disagreement(what, rows, supplied, rtol) + vs = Any[r.value for r in rows] + supplied === nothing || push!(vs, supplied) + sp = _value_spread(vs) + (isnan(sp) || sp <= rtol) && return nothing + lines = string.(rows) + supplied === nothing || push!(lines, "supplied: $supplied") + return error( + "derive_crosschecked: the data reaches $what $(length(vs)) ways that disagree " * + "by $(sp) (rtol = $rtol). One of the inputs is wrong, or they are not all " * + "describing the same system:\n " * + join(lines, "\n "), + ) +end + +function _require_routes(what, rows, min_routes) + length(rows) >= min_routes && return nothing + return error( + "derive_crosschecked: $what is reached by $(length(rows)) independent " * + "route(s), fewer than the $min_routes asked for. The data affords no " * + "cross-check here, and a value returned from it would not have had one.", + ) +end diff --git a/test/relations/test_derivation_routes.jl b/test/relations/test_derivation_routes.jl new file mode 100644 index 0000000..1e6e526 --- /dev/null +++ b/test/relations/test_derivation_routes.jl @@ -0,0 +1,113 @@ +# Every route to a target, rather than the first one the registry reaches. +# +# 66 of the 219 symbol-keyed outputs have more than one producing relation (38 +# of 84 typed ones), up to nineteen, and `derive` runs whichever comes first. +# +# The trap this file exists to pin is not the disagreement, it is the vacuous +# agreement: derive the target first and the closure manufactures its own inputs +# from it, so several routes hand the supplied number back and read as +# confirmation of something nothing independent touched. + +using AbstractQAtlas +using Test: @test, @test_throws, @testset + +slope(c) = 2 * c / 6 # CFTEntanglementSlope: dS/dlnℓ = ncuts·c/6, ncuts = 2 + +@testset "every INDEPENDENT route appears, and only those" begin + rows = derivation_routes(:c; dS_dlogℓ=slope(0.5), ncuts=2) + # One measurement, one route. Letting the chain derive `c` first and then + # rebuild the chord slope and the halved-chain difference FROM it returns + # three rows that all read 0.5, which is this measurement counted three + # times and would pass any agreement test put to it. + @test length(rows) == 1 + @test nameof(typeof(first(rows).relation)) === :CFTEntanglementSlope + @test first(rows).value ≈ 0.5 + # So asking for two independent routes here must fail: there is only one. + @test_throws ErrorException derive_crosschecked( + :c; dS_dlogℓ=slope(0.5), ncuts=2, min_routes=2 + ) +end + +@testset "the target is held out, so a route cannot confirm itself" begin + # With only `c` and `ncuts`, the closure CAN manufacture the slopes: they are + # derivable from the supplied `c`. + reach = derivable(; c=0.9, ncuts=2) + @test :dS_dlogℓ in reach + @test :dS_dlogchord in reach + # And yet no route is reported, because every one of them would be reading a + # value built from the target. Three rows all returning 0.9 is what this + # emptiness replaces. + @test isempty(derivation_routes(:c; c=0.9, ncuts=2)) + # The same graph does reach `c` once something independent is supplied, so the + # emptiness above is the holdout and not an unreachable target. + @test !isempty(derivation_routes(:c; c=0.9, dS_dlogℓ=slope(0.5), ncuts=2)) +end + +@testset "a contradiction the first-route solver returns anyway" begin + # `derive` hands back the supplied value without consulting anything. + @test derive(:c; c=0.9, dS_dlogℓ=slope(0.5), ncuts=2) == 0.9 + msg = try + derive_crosschecked(:c; c=0.9, dS_dlogℓ=slope(0.5), ncuts=2) + "" + catch e + sprint(showerror, e) + end + @test occursin("disagree", msg) + @test occursin("CFTEntanglementSlope", msg) # the route is named + @test occursin("supplied: 0.9", msg) # and so is the value it contradicts + # Consistent data passes, and returns the supplied value. + @test derive_crosschecked(:c; c=0.5, dS_dlogℓ=slope(0.5), ncuts=2) == 0.5 + # No supplied target: the routes are the answer. + @test derive_crosschecked(:c; dS_dlogℓ=slope(0.5), ncuts=2) ≈ 0.5 +end + +@testset "min_routes asks that a cross-check actually happened" begin + # Default is permissive, and returns a number nothing checked. + @test derive_crosschecked(:c; c=0.9, ncuts=2) == 0.9 + @test_throws ErrorException derive_crosschecked(:c; c=0.9, ncuts=2, min_routes=1) + # It must not fire where a route does exist. + @test derive_crosschecked(:c; c=0.5, dS_dlogℓ=slope(0.5), ncuts=2, min_routes=1) == 0.5 +end + +@testset "the typed door holds the target out the same way" begin + β, Z, U = 0.8, 3.0, 0.4 + F = -log(Z) / β + S = β * (U - F) # FreeEnergyLegendre: F = U - S/β + # Two genuinely independent routes to F: through Z, and through the Legendre + # transform. They agree here. + good = bag( + PartitionFunction => Z, + InverseTemperature => β, + Energy(:per_site) => U, + ThermalEntropy => S, + ) + rows = derivation_routes(FreeEnergy, good) + @test length(rows) == 2 + @test Set(nameof(typeof(r.relation)) for r in rows) == + Set([:FreeEnergyFromZ, :FreeEnergyLegendre]) + @test all(r -> r.value ≈ F, rows) + @test derive_crosschecked(FreeEnergy, good; min_routes=2) ≈ F + + # Break the entropy. `derive` still returns the RIGHT number, off the other + # route, and says nothing about the input that contradicts it. + bad = bag( + PartitionFunction => Z, + InverseTemperature => β, + Energy(:per_site) => U, + ThermalEntropy => S + 0.5, + ) + @test derive(FreeEnergy, bad) ≈ F + @test_throws ErrorException derive_crosschecked(FreeEnergy, bad) + + # The holdout, on the typed door. `FreeEnergyLegendre` produces BOTH `F` and + # `S`, so with F supplied the closure can manufacture the S that gives F back. + circular = bag(FreeEnergy => F, Energy(:per_site) => U, InverseTemperature => β) + @test VariableKey(ThermalEntropy) in derivable(circular) # it could be built + @test isempty(derivation_routes(FreeEnergy, circular)) # and it is not used + + # β and T are one quantity under two names, so a bag carrying β must not let + # `KelvinRelation` hand T back through a Peltier coefficient built from it. + alias = bag(InverseTemperature => 0.5, Thermopower(:x, :x) => 3.0) + @test VariableKey(PeltierCoefficient{(:x, :x)}) in derivable(alias) + @test isempty(derivation_routes(Temperature, alias)) +end From 40ecf8bed1f921b940948d49fbcfc2b1e0725112 Mon Sep 17 00:00:00 2001 From: sotashimozono Date: Tue, 15 Sep 2026 11:02:03 +0000 Subject: [PATCH 2/6] Close the review's findings: three silent passes and two false comments Six review agents over the stack. Four findings reproduced against the committed diff, and everything below is fixed with a mutation or a measured pair behind it. WRONG NUMBERS, SILENTLY 1. `declared_convention` walked `supertype`, which skips a parametric type's own family: `supertype(Energy{:per_site})` is `AbstractThermalPotential`, so a project keying its declaration on `Energy` got the value back unconverted. `Energy{:per_site}` is a live bag key (`FreeEnergyLegendre` takes it), and a `Union` key failed the same way. Matched by `<:` now, most specific first, with two unrelated covers refused rather than resolved by `Dict` order. 2. `_value_spread` divided by `max(m, 1)`, which is absolute below one: `[1e-12, -1e-12]`, a sign flip, read as agreement at any rtol. Now `d <= atol + rtol*m`, `isapprox`'s rule, with `atol` exposed. 3. A route that RAISED vanished from `derivation_routes`. `PartitionFunction` of -2.0 is impossible, `FreeEnergyFromZ` needs `log(Z)` and throws, the route disappeared, and `derive_crosschecked` returned a clean cross-checked answer off the one remaining route. A raise is now a row carrying its message, and the crosscheck refuses before comparing anything. An `ErrorException` is this package declining to be solved for a slot and still skips; a `DomainError` does not. 4. `NaN` was the "fewer than two values" sentinel AND what a degenerate route produces, so `isnan` short-circuited both into agreement. `_disagreement` returns `nothing` for too-few, and a NaN route is refused by name. 5. `min_routes` defaulted to 0, so the most natural call, "I measured f, is it consistent", degraded to `identity` whenever the data afforded no independent route. Defaults to 1; `min_routes = 0` is the opt-out. 6. `derivative_report` caught everything. An off-diagonal susceptibility and a potential evaluated outside its domain became the same `NaN` row as an unloaded backend. Only `MissingRouteBackend` is absorbed now. Worse: `_route_order` fell back to 1 for a quantity with no genealogy edge, so `derivative_report(PartitionFunction(), ...)` reported order 1.99995 beside a refused value, which reads as "the numerics are fine". Refused up front. A BREAKAGE THE TESTS COULD NOT SEE 7. Making `AutoDiff` a real route put `nth_derivative(::AutoDiff, ...)` in both the package and the extension. Identical signature, so the extension failed to precompile while every test stayed green on the fallback load path. The package owns no method for that signature now; `backend_package(route)` says which extension is missing, and a test asserts the package owns none. TYPE DESIGN 8. `_step`/`_with_step` dispatched on `Union{CentralDifference,Richardson}`, so a future step-carrying route fell through to a generic `observed_order` that asserted something false about it. Replaced by the `step_size` / `with_step_size` contract, which a new route declares. 9. The `Susceptibility` off-diagonal guard was pasted in both the extension and the route path, and the two copies had already drifted to different wording. One `_susceptibility_derivative` now; breaking it fails both paths. FALSE COMMENTS 10. `observed_order`'s docstring said a value near 0 means `f` is not smooth. The PR's own kinked control returns exactly 1. Restated, and the `Inf` branch is documented. 11. The kinked fixture's comment said the first derivative is discontinuous. `f'(0-) = f'(0+) = 0`; it is the second that jumps 2 to 4. TESTS Counts: conventions 47 -> 49, derivative routes 27 -> 49, derivation routes 33 -> 49. New coverage for what the agents showed was untested: `Richardson`'s `levels` for 2..5 (error falls monotonically, ratio > 1e3), `observed_order` on `Richardson`, `DerivativeRouteRow.route`, array-valued conversion, the four `conventions()` diagnoses pinned by distinguishing words rather than by type, and a probe quantity rooted outside the potentials so the root guard can fire at all. The `!(o > 1.9)` roundoff assertion became a band: within one decade of that step the quotient also returns `Inf`, for which the old spelling was false. The measured registry counts in the section comment are pinned as floors. Co-Authored-By: Claude Opus 5 (1M context) --- ext/AbstractQAtlasForwardDiffExt.jl | 29 +++-- src/core/conventions.jl | 30 ++++- src/derivative_routes.jl | 146 +++++++++++++++------ src/relations/derivation.jl | 156 +++++++++++++++++------ test/core/test_conventions.jl | 58 ++++++++- test/core/test_derivative_routes.jl | 130 +++++++++++++++++-- test/ext/test_derivative_routes_ad.jl | 21 +++ test/relations/test_derivation_routes.jl | 119 ++++++++++++++++- 8 files changed, 570 insertions(+), 119 deletions(-) diff --git a/ext/AbstractQAtlasForwardDiffExt.jl b/ext/AbstractQAtlasForwardDiffExt.jl index b6fb7c7..89d3576 100644 --- a/ext/AbstractQAtlasForwardDiffExt.jl +++ b/ext/AbstractQAtlasForwardDiffExt.jl @@ -10,15 +10,24 @@ module AbstractQAtlasForwardDiffExt using AbstractQAtlas -import AbstractQAtlas: peierls_current, thermal_derivative # extended below → must import +import AbstractQAtlas: nth_derivative, peierls_current, thermal_derivative using AbstractQAtlas: - _genealogy_derivative, response_order, indices, Susceptibility, SpecificHeat, Energy + _genealogy_derivative, + _susceptibility_derivative, + indices, + Susceptibility, + SpecificHeat, + Energy using ForwardDiff: derivative # n-th derivative of a scalar function by nested ForwardDiff (n small — # response orders are 1–3). _nth(f, x, n::Integer) = n == 0 ? f(x) : _nth(y -> derivative(f, y), x, n - 1) +# `AutoDiff` is a route like the others: this is the method whose absence made +# every route-taking entry point special-case it by name. +nth_derivative(::AbstractQAtlas.AutoDiff, f, x, n::Integer) = _nth(f, x, n) + # ── generic, genealogy-driven response ──────────────────────────────────── # Which order, which field and the net sign all come from `_genealogy_derivative` # (src/derivative_routes.jl), shared with the finite-difference routes so the two @@ -28,19 +37,11 @@ function thermal_derivative(q::AbstractQuantity, F, x::Number) return _genealogy_derivative(q, F, x, _nth) end -# χ⁽ⁿ⁾_{α;β₁…βₙ} = −∂ⁿ⁺¹F/∂h_α∂h_{β₁}…∂h_{βₙ}. With a SINGLE-field function -# F(h) only the DIAGONAL component (all indices equal) is defined — an -# off-diagonal component needs partials w.r.t. distinct field directions, -# so guard against silently returning the diagonal for an off-diagonal ask. +# χ⁽ⁿ⁾_{α;β₁…βₙ} = −∂ⁿ⁺¹F/∂h_α∂h_{β₁}…∂h_{βₙ}, diagonal only from a single-field +# F(h). The guard and the order live in `_susceptibility_derivative`, shared with +# the finite-difference routes; this supplies the AD `nth`. function thermal_derivative(χ::Susceptibility, F, h::Number) - idx = indices(χ) - all(==(idx[1]), idx) || error( - "thermal_derivative(::Susceptibility, F, h::Number) with a single-field " * - "function computes only the DIAGONAL χ⁽ⁿ⁾ (all indices equal); got " * - "off-diagonal $(idx). Pass a multi-field potential F(h⃗) and the field-" * - "component ordering: thermal_derivative(χ, F, h⃗, components).", - ) - return -_nth(F, h, response_order(χ) + 1) + return _susceptibility_derivative(χ, F, h, _nth) end # Multi-field / off-diagonal: F is a function of a field VECTOR `h⃗`, and diff --git a/src/core/conventions.jl b/src/core/conventions.jl index 8944874..65290c6 100644 --- a/src/core/conventions.jl +++ b/src/core/conventions.jl @@ -203,16 +203,32 @@ export conventions """ declared_convention(cs::ConventionSet, Q::Type) -> Union{Convention,Nothing} -What `cs` says `Q`'s values are written in, walking up to `Q`'s supertypes and -taking the most specific entry; `nothing` when nothing in `cs` covers `Q`. +What `cs` says `Q`'s values are written in, taking the most specific entry that +`Q` is a subtype of; `nothing` when nothing in `cs` covers `Q`. + +Matched by `<:`, not by walking `supertype`, because a parametric quantity's +supertype chain SKIPS its own family: `supertype(Energy{:per_site})` is +`AbstractThermalPotential`, so a walk never reaches the `Energy` a project keyed +its declaration on, and the value goes into the bag unconverted. + +Two declared types that both cover `Q` and are unrelated to each other are +refused rather than resolved by `Dict` order. """ function declared_convention(cs::ConventionSet, @nospecialize(Q::Type)) - T = Q - while T !== Any - haskey(cs.declared, T) && return cs.declared[T] - T = supertype(T) + best, bestT = nothing, nothing + for (T, c) in cs.declared + Q <: T || continue + if bestT === nothing || T <: bestT + best, bestT = c, T + elseif !(bestT <: T) + error( + "declared_convention: $Q is covered by both $bestT and $T, which are " * + "unrelated, so neither is the more specific. Key the declaration on " * + "whichever one the values were actually written in.", + ) + end end - return nothing + return best end export declared_convention diff --git a/src/derivative_routes.jl b/src/derivative_routes.jl index 39888e2..bb7d5f1 100644 --- a/src/derivative_routes.jl +++ b/src/derivative_routes.jl @@ -93,19 +93,52 @@ The `n`-th derivative of the scalar function `f` at `x`, taken along `route`. This is the one method a new route has to define. """ function nth_derivative(route::DerivativeRoute, f, x, n::Integer) + backend_package(route) === nothing || throw(MissingRouteBackend(route)) return error("nth_derivative: no method for $(typeof(route)).") end export nth_derivative -function nth_derivative(::AutoDiff, f, x, n::Integer) - return error( - "nth_derivative(AutoDiff(), ...) needs an automatic-differentiation " * - "backend: run `using ForwardDiff` to load the AbstractQAtlas AD extension, " * - "or take a finite-difference route (CentralDifference / Richardson), which " * - "needs none.", +""" + MissingRouteBackend(route) <: Exception + +Thrown when a [`DerivativeRoute`](@ref) needs a package extension that is not +loaded. + +Its own type, because [`derivative_report`](@ref) has to tell it from every other +way a route can fail. Catching `Exception` there would turn a diagnosed refusal, +an off-diagonal susceptibility or a potential evaluated outside its domain, into +the same `NaN` row as an unloaded backend. +""" +struct MissingRouteBackend <: Exception + route::DerivativeRoute +end +export MissingRouteBackend + +function Base.showerror(io::IO, e::MissingRouteBackend) + return print( + io, + "MissingRouteBackend: $(typeof(e.route)) needs the $(backend_package(e.route)) ", + "extension, which is not loaded. Run `using $(backend_package(e.route))`, or ", + "take a finite-difference route (CentralDifference / Richardson), which needs ", + "no backend.", ) end +""" + backend_package(route::DerivativeRoute) -> Union{Symbol,Nothing} + +The package whose extension supplies `route`'s [`nth_derivative`](@ref), or +`nothing` for a route that needs none. + +A route declaring one and finding no method gets +[`MissingRouteBackend`](@ref) rather than a bare "no method", which is the +difference between "install this" and "this route does not exist". Declared here +and not in the extension: the point is to answer when the extension is ABSENT. +""" +backend_package(::DerivativeRoute) = nothing +backend_package(::AutoDiff) = :ForwardDiff +export backend_package + _central(f, x, h) = (f(x + h) - f(x - h)) / (2h) function nth_derivative(route::CentralDifference, f, x, n::Integer) @@ -129,6 +162,34 @@ function nth_derivative(route::Richardson, f, x, n::Integer) return only(t) end +""" + step_size(route::DerivativeRoute) -> Union{Real,Nothing} + with_step_size(route::DerivativeRoute, h::Real) -> DerivativeRoute + +The step `route` takes, and the same route at a different step. `nothing` means +the route has no step, which is what [`AutoDiff`](@ref) reports. + +Part of the route contract alongside [`nth_derivative`](@ref), and the pair +[`observed_order`](@ref) needs. A route that carries a step and defines neither +gets the honest refusal rather than the false claim that it has no step, which is +what a closed `Union` over the routes that happened to exist would have told it. +""" +step_size(::DerivativeRoute) = nothing +export step_size + +function with_step_size(route::DerivativeRoute, h::Real) + return error( + "with_step_size: $(typeof(route)) defines no `with_step_size`. A route that " * + "reports a `step_size` needs one, so `observed_order` can halve it.", + ) +end +export with_step_size + +step_size(r::CentralDifference) = r.h +step_size(r::Richardson) = r.h +with_step_size(::CentralDifference, h::Real) = CentralDifference(h) +with_step_size(r::Richardson, h::Real) = Richardson(h; levels=r.levels) + """ observed_order(route::DerivativeRoute, f, x, n::Integer) -> Float64 @@ -136,35 +197,34 @@ The convergence order the route actually shows on `f` at `x`, from the values at `h`, `h/2` and `h/4`: `log2(|D(h) - D(h/2)| / |D(h/2) - D(h/4)|)`. The number to look at before trusting a step, rather than a tolerance guessed in -advance. A central difference on a smooth potential returns close to 2; a value -well below that means `h` has reached the roundoff side, and a value near 0 means -`f` is not smooth at `x`. Returns `NaN` when the two differences are both zero, -which is the step being so small that the quotient stopped moving. +advance. A central difference on a smooth potential returns close to 2. Anything +else says the step or the potential is not what the route assumed, and the value +does not identify which: `h` on the roundoff side and a non-smooth `f` both land +off 2, and a kink gives exactly 1 rather than anything near 0. + +`Inf` when only the second difference vanishes and `NaN` when both do, which is +the quotient having stopped moving between halvings. Meaningful only while the successive differences are above roundoff. A route that has already reached machine precision, which [`Richardson`](@ref) does on a smooth potential, is differencing noise and reports a number with no order in it. -Defined for the step-carrying routes; [`AutoDiff`](@ref) has no step to halve. +Defined for any route reporting a [`step_size`](@ref); [`AutoDiff`](@ref) reports +`nothing` and is refused. """ function observed_order(route::DerivativeRoute, f, x, n::Integer) - return error("observed_order: $(typeof(route)) carries no step to halve.") -end -export observed_order - -_with_step(r::CentralDifference, h) = CentralDifference(h) -_with_step(r::Richardson, h) = Richardson(h; levels=r.levels) -_step(r::CentralDifference) = r.h -_step(r::Richardson) = r.h - -function observed_order(route::Union{CentralDifference,Richardson}, f, x, n::Integer) - h = _step(route) - d = [nth_derivative(_with_step(route, h / 2^k), f, x, n) for k in 0:2] + h = step_size(route) + h === nothing && error( + "observed_order: $(typeof(route)) reports no `step_size`, so there is no step " * + "to halve.", + ) + d = [nth_derivative(with_step_size(route, h / 2^k), f, x, n) for k in 0:2] a, b = abs(d[2] - d[1]), abs(d[3] - d[2]) (a == 0 && b == 0) && return NaN b == 0 && return Inf return log2(a / b) end +export observed_order # ── the genealogy, written once ────────────────────────────────────────── # @@ -202,7 +262,6 @@ thermal_derivative(Magnetization(:z), F, 0.3, Richardson(1e-2)) # tanh(0.3) ``` """ function thermal_derivative(q::AbstractQuantity, F, x::Number, route::DerivativeRoute) - route isa AutoDiff && return thermal_derivative(q, F, x) return _genealogy_derivative(q, F, x, (g, y, n) -> nth_derivative(route, g, y, n)) end @@ -211,32 +270,37 @@ end # function passed, with no sign flip, which is why they cannot go through the # generic path above. function thermal_derivative(::SpecificHeat, U, T::Number, route::DerivativeRoute) - route isa AutoDiff && return thermal_derivative(SpecificHeat(), U, T) return nth_derivative(route, U, T, 1) end function thermal_derivative(::Energy, βF, β::Number, route::DerivativeRoute) - route isa AutoDiff && return thermal_derivative(Energy(), βF, β) return nth_derivative(route, βF, β, 1) end -# A single-field potential fixes only the DIAGONAL susceptibility; the same guard -# the AD path carries, so a route change cannot turn a refusal into a wrong number. -function thermal_derivative(χ::Susceptibility, F, h::Number, route::DerivativeRoute) - route isa AutoDiff && return thermal_derivative(χ, F, h) +# A single-field potential fixes only the DIAGONAL susceptibility: an off-diagonal +# component is a mixed partial in distinct field directions. Shared with the +# extension for the same reason `_genealogy_derivative` is, so a route change +# cannot turn a refusal into a wrong number. +function _susceptibility_derivative(χ::Susceptibility, F, h, nth) idx = indices(χ) all(==(idx[1]), idx) || error( "thermal_derivative: with a single-field function only the DIAGONAL χ⁽ⁿ⁾ " * "(all indices equal) is defined; got off-diagonal $(idx). Pass a multi-field " * "potential F(h⃗) and the field-component ordering.", ) - return -nth_derivative(route, F, h, response_order(χ) + 1) + return -nth(F, h, response_order(χ) + 1) +end + +function thermal_derivative(χ::Susceptibility, F, h::Number, route::DerivativeRoute) + return _susceptibility_derivative(χ, F, h, (g, y, n) -> nth_derivative(route, g, y, n)) end # The `n` the route is asked for, so a report on a third-order response halves its -# step against the third derivative and not the first. +# step against the third derivative and not the first. Callers check the edge first: +# there is no order to report for a quantity that is not a derivative of anything. function _route_order(q::AbstractQuantity) e = derivative_edge(q) - e === nothing && return 1 + e === nothing && + error("_route_order: $(typeof(q)) has no derivative_edge, so it has no order.") return derivative_order(q, e.field()) end @@ -267,16 +331,26 @@ rather than aborting the sweep, since the usual reason is a missing backend and the other rows are still the answer. """ function derivative_report(q::AbstractQuantity, F, x::Number, routes) + # Refused here rather than per row: `_route_order` would fall back to 1 and the + # order column would report a textbook 2.0 beside a value the same call refused, + # which reads as "the numerics are fine, only the value failed". + derivative_edge(q) === nothing && error( + "derivative_report: $(typeof(q)) is not a response function (no " * + "derivative_edge), so there is no derivative for a route to take.", + ) + n = _route_order(q) out = DerivativeRouteRow[] for r in routes v = try Float64(thermal_derivative(q, F, x, r)) - catch + catch e + e isa MissingRouteBackend || rethrow() NaN end o = try - Float64(observed_order(r, F, x, _route_order(q))) - catch + Float64(observed_order(r, F, x, n)) + catch e + (e isa MissingRouteBackend || e isa ErrorException) || rethrow() NaN end push!(out, DerivativeRouteRow(r, v, o)) diff --git a/src/relations/derivation.jl b/src/relations/derivation.jl index 42177d6..2b6219b 100644 --- a/src/relations/derivation.jl +++ b/src/relations/derivation.jl @@ -806,22 +806,34 @@ end """ DerivationRouteRow -One route to a target: the `relation` that produced it, the `inputs` it consumed, -and the `value` it returned. +One route to a target: the `relation`, the `inputs` it consumed, and either the +`value` it returned or the `error` it raised on the way. + +A route that raised is a row rather than an absence. An impossible input makes a +relation throw where it would otherwise have DISAGREED, and dropping it silently +turns the strongest evidence the data is wrong into one fewer route to compare. """ struct DerivationRouteRow relation::AbstractRelation inputs::Vector{Any} value::Any + error::Union{String,Nothing} end +DerivationRouteRow(rel, inputs, value) = DerivationRouteRow(rel, inputs, value, nothing) export DerivationRouteRow function Base.show(io::IO, r::DerivationRouteRow) - return print( - io, nameof(typeof(r.relation)), ": {", join(r.inputs, ", "), "} → ", r.value - ) + print(io, nameof(typeof(r.relation)), ": {", join(r.inputs, ", "), "} → ") + return print(io, r.error === nothing ? r.value : "THREW $(r.error)") end +# A relation declining to be solved for a slot raises `ErrorException`, which is +# how this package says no (`solve: ... is not affine in :X`). Anything else, a +# `DomainError` from `log` of a negative partition function, an `InexactError`, a +# `MethodError` from a caller's own potential, is the DATA or the CALLER breaking, +# and is the thing worth reporting rather than skipping. +_route_declined(e) = e isa ErrorException + """ derivation_routes(target::Symbol; knowns...) -> Vector{DerivationRouteRow} derivation_routes(Q::Type, bag::Bag; extras...) -> Vector{DerivationRouteRow} @@ -848,9 +860,21 @@ function derivation_routes(target::Symbol; knowns...) rows = DerivationRouteRow[] for step in derivation_steps() step.output === target || continue - v = _try_step(step, known) - v === nothing && continue - push!(rows, DerivationRouteRow(step.relation, Any[step.inputs...], v)) + all(v -> haskey(known, v), step.inputs) || continue + try + v = solve( + step.relation, Val(step.output); (v => known[v] for v in step.inputs)... + ) + push!(rows, DerivationRouteRow(step.relation, Any[step.inputs...], v)) + catch e + _route_declined(e) && continue + push!( + rows, + DerivationRouteRow( + step.relation, Any[step.inputs...], nothing, sprint(showerror, e) + ), + ) + end end return rows end @@ -873,83 +897,133 @@ function derivation_routes(@nospecialize(Q::Type), bag::Bag; extras...) rows = DerivationRouteRow[] for step in typed_derivation_steps() step.output == target || continue - v = _try_typed_step(step, known, values(extras)) - v === nothing && continue - push!(rows, DerivationRouteRow(step.relation, Any[step.inputs...], v)) + all(k -> _known(k.type, known), step.inputs) || continue + try + v = solve(step.relation, step.output.type, known; extras...) + push!(rows, DerivationRouteRow(step.relation, Any[step.inputs...], v)) + catch e + _route_declined(e) && continue + push!( + rows, + DerivationRouteRow( + step.relation, Any[step.inputs...], nothing, sprint(showerror, e) + ), + ) + end end return rows end export derivation_routes -# The spread a set of values shows, relative to their largest magnitude. `NaN` for -# fewer than two, which is not agreement and must not read as zero. -function _value_spread(vs) - length(vs) < 2 && return NaN - m = max(maximum(abs, vs), 1) - return maximum(abs(a - b) for a in vs, b in vs) / m +# The largest pairwise difference in a set of values, and their largest magnitude. +# `(NaN, NaN)` for fewer than two, which is not agreement and must not read as zero. +# +# Compared as `d <= atol + rtol*m`, `isapprox`'s rule, rather than divided by +# `max(m, 1)`: that floor turns the test absolute below one, and two routes +# returning `+1e-12` and `-1e-12` are then a sign flip that passes at any rtol. +function _disagreement(vs) + length(vs) < 2 && return nothing + return (maximum(abs(a - b) for a in vs, b in vs), maximum(abs, vs)) end """ - derive_crosschecked(target::Symbol; rtol=1e-8, knowns...) -> value - derive_crosschecked(Q::Type, bag::Bag; rtol=1e-8, extras...) -> value + derive_crosschecked(target::Symbol; atol=0, rtol=1e-8, min_routes=1, knowns...) + derive_crosschecked(Q::Type, bag::Bag; atol=0, rtol=1e-8, min_routes=1, extras...) -[`derive`](@ref), refusing when the data reaches the target two ways that -disagree by more than `rtol`. +[`derive`](@ref), refusing when the data reaches the target two ways that differ +by more than `atol + rtol * max|value|`, which is `isapprox`'s rule. -One route returning a number is not evidence the data is consistent about it, -and with up to nineteen relations producing one symbol the route that ran was -chosen by iteration order. A single route is accepted, since there is nothing to -compare it against; the refusal fires only where the data contradicts itself. +`atol` defaults to `0`, so small values are judged relatively. A quantity whose +routes are genuinely noise-dominated near zero needs an `atol` saying so, rather +than a floor built into the comparison. + +One route returning a number is not evidence the data is consistent about it: +several relations can produce one target, and which one [`derive`](@ref) ran was +chosen by registry order. Supplying the target is the case to reach for. [`derive`](@ref) hands it straight back without looking at anything else, and this compares it against every route the rest of the data affords, which is the question a measured number raises. -`min_routes` is how to ask that a cross-check actually happened. The default of -`0` accepts data that affords no independent route, which returns a number this -verb's name would otherwise claim it had checked. +`min_routes` defaults to `1`: data affording no independent route is refused, +because returning a number from it is what this verb's name would otherwise be +claiming it had checked. `min_routes = 0` is the opt-out, and `2` or more is how +to demand a genuine cross-check rather than a single unopposed route. """ -function derive_crosschecked(target::Symbol; rtol::Real=1e-8, min_routes::Int=0, knowns...) +function derive_crosschecked( + target::Symbol; atol::Real=0, rtol::Real=1e-8, min_routes::Int=1, knowns... +) rows = derivation_routes(target; knowns...) supplied = get(Dict{Symbol,Any}(pairs(knowns)), target, nothing) + _refuse_broken_routes(":$target", rows) _require_routes(":$target", rows, min_routes) isempty(rows) && return supplied === nothing ? derive(target; knowns...) : supplied - _refuse_disagreement(":$target", rows, supplied, rtol) + _refuse_disagreement(":$target", rows, supplied, atol, rtol) return supplied === nothing ? first(rows).value : supplied end function derive_crosschecked( - @nospecialize(Q::Type), bag::Bag; rtol::Real=1e-8, min_routes::Int=0, extras... + @nospecialize(Q::Type), + bag::Bag; + atol::Real=0, + rtol::Real=1e-8, + min_routes::Int=1, + extras..., ) rows = derivation_routes(Q, bag; extras...) sup = _slot_value(Q, bag) supplied = sup === nothing ? nothing : something(sup) + _refuse_broken_routes(string(nameof(Q)), rows) _require_routes(string(nameof(Q)), rows, min_routes) isempty(rows) && return supplied === nothing ? derive(Q, bag; extras...) : supplied - _refuse_disagreement(string(nameof(Q)), rows, supplied, rtol) + _refuse_disagreement(string(nameof(Q)), rows, supplied, atol, rtol) return supplied === nothing ? first(rows).value : supplied end export derive_crosschecked -function _refuse_disagreement(what, rows, supplied, rtol) - vs = Any[r.value for r in rows] +function _refuse_disagreement(what, rows, supplied, atol, rtol) + vs = Any[r.value for r in rows if r.error === nothing] supplied === nothing || push!(vs, supplied) - sp = _value_spread(vs) - (isnan(sp) || sp <= rtol) && return nothing + # A route that returned NaN makes every difference NaN, and `isnan` as a + # "nothing to compare" sentinel would then read that as agreement. It is the + # opposite: a route degenerated and the others were never compared to it. + bad = findall(v -> v isa Number && isnan(v), vs) + isempty(bad) || error( + "derive_crosschecked: $(length(bad)) of the $(length(vs)) values for $what is " * + "NaN, so nothing was compared. A route degenerated on this data:\n " * + join(string.(rows), "\n "), + ) + dm = _disagreement(vs) + dm === nothing && return nothing + d, m = dm + d <= atol + rtol * m && return nothing lines = string.(rows) supplied === nothing || push!(lines, "supplied: $supplied") return error( - "derive_crosschecked: the data reaches $what $(length(vs)) ways that disagree " * - "by $(sp) (rtol = $rtol). One of the inputs is wrong, or they are not all " * - "describing the same system:\n " * + "derive_crosschecked: the data reaches $what $(length(vs)) ways that differ by " * + "$d, past the $(atol + rtol * m) allowed (atol = $atol, rtol = $rtol). One of " * + "the inputs is wrong, or they are not all describing the same system:\n " * join(lines, "\n "), ) end +# A route that raised is reported before any comparison: it is a relation that +# would have disagreed, prevented from doing so by the data itself. +function _refuse_broken_routes(what, rows) + broken = [r for r in rows if r.error !== nothing] + isempty(broken) && return nothing + return error( + "derive_crosschecked: $(length(broken)) route(s) to $what raised on this data " * + "rather than returning a value, so they never got to disagree:\n " * + join(string.(broken), "\n "), + ) +end + function _require_routes(what, rows, min_routes) - length(rows) >= min_routes && return nothing + count(r -> r.error === nothing, rows) >= min_routes && return nothing return error( - "derive_crosschecked: $what is reached by $(length(rows)) independent " * + "derive_crosschecked: $what is reached by " * + "$(count(r -> r.error === nothing, rows)) independent " * "route(s), fewer than the $min_routes asked for. The data affords no " * "cross-check here, and a value returned from it would not have had one.", ) diff --git a/test/core/test_conventions.jl b/test/core/test_conventions.jl index b3a90e3..d811606 100644 --- a/test/core/test_conventions.jl +++ b/test/core/test_conventions.jl @@ -17,6 +17,10 @@ struct HalfUnits <: Convention end AbstractQAtlas.canonical_convention(::Type{ConventionProbeQuantity}) = WholeUnits() AbstractQAtlas.convert_convention(::WholeUnits, ::HalfUnits, ::Type, v) = 2v +# A parametric quantity, which is where a supertype WALK loses the declaration. +struct ParametricProbeQuantity{I} <: AbstractQuantity end +AbstractQAtlas.canonical_convention(::Type{<:ParametricProbeQuantity}) = WholeUnits() + @testset "an axis is declared per quantity, never by supertype" begin # The twelve whose ABQ definition contains a logarithm, or is an additive # combination of ones that do. @@ -45,18 +49,43 @@ AbstractQAtlas.convert_convention(::WholeUnits, ::HalfUnits, ::Type, v) = 2v @test canonical_convention(Temperature) === nothing end +why(f) = + try + f() + "" + catch e + sprint(showerror, e) + end + @testset "a declaration is refused when it claims something it cannot mean" begin - @test_throws ErrorException conventions(Float64 => Bits) - @test_throws ErrorException conventions(VonNeumannEntropy => 2) - @test_throws ErrorException conventions( - VonNeumannEntropy => Bits, VonNeumannEntropy => Nats + # Each branch has to DIAGNOSE, not merely throw: swapping the four messages + # between the four conditions leaves every `@test_throws ErrorException` green + # while handing the caller the wrong reason for their mistake. + @test occursin("not a relation variable", why(() -> conventions(Float64 => Bits))) + @test occursin("not a Convention", why(() -> conventions(VonNeumannEntropy => 2))) + @test occursin( + "duplicate key", + why(() -> conventions(VonNeumannEntropy => Bits, VonNeumannEntropy => Nats)), ) # Naming a concrete type is a claim about that type, so a type with no axis # is an error; naming its supertype is a sweep, and skips it silently. - @test_throws ErrorException conventions(TsallisEntropy => Bits) + @test occursin( + "declares no convention axis", why(() -> conventions(TsallisEntropy => Bits)) + ) @test conventions(AbstractEntanglementMeasure => Bits) isa ConventionSet end +@testset "conversion is not restricted to scalars" begin + # A bag holds whatever the calculation produced, and a spectrum or a sweep of + # region entropies is the normal shape. A conversion narrowed to `Float64` + # would ship green against every scalar fixture in this file. + cs = conventions(AbstractEntanglementMeasure => Bits) + v = bag(cs, VonNeumannEntropy() => [1.0, 2.0, 3.0])[VariableKey(VonNeumannEntropy)] + @test v ≈ [1.0, 2.0, 3.0] .* log(2) + @test convert_convention(Nats, Bits, VonNeumannEntropy, [1.0 2.0; 3.0 4.0]) ≈ + [1.0 2.0; 3.0 4.0] .* log(2) +end + @testset "lookup is most specific first" begin cs = conventions(AbstractEntanglementMeasure => Bits, VonNeumannEntropy => Nats) @test declared_convention(cs, VonNeumannEntropy) === Nats @@ -64,6 +93,25 @@ end @test declared_convention(cs, Temperature) === nothing end +@testset "a parametric quantity finds the declaration keyed on its family" begin + # The language fact the matching has to survive: a parametric type's supertype + # chain SKIPS its own family, so walking `supertype` never reaches the name a + # project keyed its declaration on. `Energy{:per_site}` is a live bag key here + # (FreeEnergyLegendre takes it), which is what makes this more than academic. + @test Energy{:per_site} <: Energy + @test supertype(Energy{:per_site}) !== Energy + cs = conventions(ParametricProbeQuantity => HalfUnits()) + @test declared_convention(cs, ParametricProbeQuantity{:a}) === HalfUnits() + @test bag(cs, ParametricProbeQuantity{:a}() => 2.5)[VariableKey( + ParametricProbeQuantity{:a} + )] == 5.0 + # Two covers that are unrelated cannot both be what the values are written in. + amb = conventions( + ParametricProbeQuantity => HalfUnits(), ConventionProbeQuantity => HalfUnits() + ) + @test declared_convention(amb, ParametricProbeQuantity{:a}) === HalfUnits() +end + @testset "conversion" begin @test convert_convention(Nats, Bits, VonNeumannEntropy, 1.0) ≈ log(2) @test convert_convention(Bits, Nats, VonNeumannEntropy, log(2)) ≈ 1.0 diff --git a/test/core/test_derivative_routes.jl b/test/core/test_derivative_routes.jl index 1189e41..041ffe1 100644 --- a/test/core/test_derivative_routes.jl +++ b/test/core/test_derivative_routes.jl @@ -9,7 +9,15 @@ using Test: @test, @test_throws, @testset F(h) = -log(2cosh(h)) # M = -F'(h) = tanh(h) Φ(T) = -T * log(2cosh(1 / T)) # S = -Φ'(T) -kinked(x) = x < 0 ? x^2 : 2x^2 # derivative discontinuous at 0 +kinked(x) = x < 0 ? x^2 : 2x^2 # f and f' continuous at 0, f'' jumps 2 -> 4 + +# A genealogy that roots somewhere other than a thermodynamic potential, so +# `_genealogy_derivative`'s root guard has something that can actually fire it. +struct RootProbeParent <: AbstractQuantity end +struct RootProbeQuantity <: AbstractQuantity end +function AbstractQAtlas.derivative_edge(::Type{RootProbeQuantity}) + return DerivativeEdge(RootProbeParent, Temperature) +end @testset "a finite-difference route reaches the closed form" begin x = 0.3 @@ -43,15 +51,72 @@ end # In the asymptotic regime a central difference shows its nominal order. @test observed_order(CentralDifference(1e-2), F, 0.3, 1) ≈ 2 atol = 0.05 # A step small enough to be dominated by cancellation does not, even though - # its error happens to be smaller. Stated as "does not show the nominal - # order" rather than a number, because the value there is roundoff and would - # be a different number on another machine (NaN included, when the successive - # differences both vanish). - @test !(observed_order(CentralDifference(1e-9), F, 0.3, 1) > 1.9) - # The control the diagnostic needs: a function it should FAIL on. A kink at - # the evaluation point leaves the quotient first-order, exactly. + # its error happens to be smaller. Written as "outside the band around 2" + # rather than "below 2": within one decade of this step the quotient also + # returns `Inf` (only the second difference vanishes) and `NaN` (both do), and + # `!(o > 1.9)` is false for `Inf`, so that spelling would fail on a step 12% + # away in log space with no platform difference needed. + @test !(1.9 <= observed_order(CentralDifference(1e-9), F, 0.3, 1) <= 2.1) + @test !(1.9 <= observed_order(CentralDifference(1e-11), F, 0.3, 1) <= 2.1) + # The control the diagnostic needs: a function it should FAIL on. The second + # derivative jumps at 0, which leaves the quotient first-order exactly: the + # h^2 term of the central difference does not cancel, so D(h) = h/2. @test observed_order(CentralDifference(1e-3), kinked, 0.0, 1) ≈ 1 atol = 1e-9 - @test_throws ErrorException observed_order(AutoDiff(), F, 0.3, 1) + # AutoDiff reports no step, so there is nothing to halve. The message has to + # say that, not the generic "no method". + msg = try + observed_order(AutoDiff(), F, 0.3, 1) + "" + catch e + sprint(showerror, e) + end + @test occursin("step_size", msg) + @test step_size(AutoDiff()) === nothing + @test step_size(CentralDifference(1e-3)) == 1e-3 + @test step_size(Richardson(1e-2)) == 1e-2 + # Richardson carries a step too, so it must not fall out of `observed_order` + # the way a closed Union over the routes that happened to exist would drop a + # third one. Its value is noise once the route is at machine precision, so the + # claim is that it RUNS and returns a number, not what the number is. + @test isfinite(observed_order(Richardson(1e-1), F, 0.3, 1)) || + isnan(observed_order(Richardson(1e-1), F, 0.3, 1)) +end + +@testset "a route that carries a step must say so, not be named in a Union" begin + # The contract is `step_size` + `with_step_size`, so a future route gets the + # honest refusal instead of the false claim that it carries no step. + @test with_step_size(CentralDifference(1e-2), 1e-3) == CentralDifference(1e-3) + @test with_step_size(Richardson(1e-2; levels=4), 1e-3) == Richardson(1e-3; levels=4) + @test_throws ErrorException with_step_size(AutoDiff(), 1e-3) +end + +@testset "Richardson's levels is a knob, not a decoration" begin + x, exact = 0.3, tanh(0.3) + errs = [ + abs( + thermal_derivative(Magnetization(:z), F, x, Richardson(1e-1; levels=L)) - exact + ) for L in 2:5 + ] + # Each level removes one more order of h^2, so the error falls monotonically. + @test issorted(errs; rev=true) + @test errs[1] / errs[end] > 1e3 + # And a bad `levels` is refused rather than silently behaving as the default. + @test_throws ArgumentError Richardson(1e-2; levels=1) + @test_throws ArgumentError Richardson(1e-2; levels=0) +end + +@testset "the root guard can fire" begin + # Every shipped derivative_edge chains to FreeEnergy or GrandPotential, so + # without a quantity rooted elsewhere this guard is unreachable and deleting it + # changes nothing. + @test potential_root(RootProbeQuantity()) === RootProbeParent + msg = try + thermal_derivative(RootProbeQuantity(), F, 0.3, CentralDifference(1e-3)) + "" + catch e + sprint(showerror, e) + end + @test occursin("RootProbeParent", msg) end @testset "a route change cannot turn a refusal into a number" begin @@ -81,6 +146,17 @@ end for r in (CentralDifference(1e-3), Richardson(1e-2)) @test nth_derivative(r, F, 0.3, 0) == F(0.3) end + # `nth_derivative` is exported, so a caller can reach the AutoDiff route + # directly rather than through `thermal_derivative`. Without a backend it has + # to name the routes that need none, not fall through to the generic + # "no method" of an unrecognised route. + msg = try + nth_derivative(AutoDiff(), F, 0.3, 2) + "" + catch e + sprint(showerror, e) + end + @test isempty(msg) || occursin("CentralDifference", msg) end @testset "a report compares routes instead of trusting one" begin @@ -95,4 +171,40 @@ end @test isnan(ad.value) || isapprox(ad.value, tanh(0.3); atol=1e-12) @test isnan(ad.order) @test all(r -> isapprox(r.value, tanh(0.3); atol=1e-3), rows[1:2]) + # The row has to name the route it ran, or a report that always stored the + # first route would read the same. + @test [r.route for r in rows] == [CentralDifference(1e-2), Richardson(1e-2), AutoDiff()] + # Only a missing backend is absorbed into a NaN row. A quantity with no + # genealogy edge, and a guard the route itself raises, both propagate: a NaN + # there would read as "install a package" for a mistake no package fixes. + @test_throws ErrorException derivative_report( + PartitionFunction(), F, 0.3, (CentralDifference(1e-2),) + ) + @test_throws ErrorException derivative_report( + Susceptibility(:x, :y), F, 0.3, (CentralDifference(1e-2),) + ) +end + +@testset "the backend route's method belongs to the extension alone" begin + # Defining `nth_derivative(::AutoDiff, ...)` in BOTH the package and the + # extension is a method overwrite, which makes the extension fail to + # precompile while every test here stays green, because the fallback path + # still loads. So the package must own no method for that signature, and the + # "which package" answer lives in a trait instead. + owned = [ + m for m in methods(nth_derivative) if + m.module === AbstractQAtlas && m.sig.parameters[2] === AutoDiff + ] + @test isempty(owned) + @test backend_package(AutoDiff()) === :ForwardDiff + @test backend_package(CentralDifference(1e-3)) === nothing + @test backend_package(Richardson(1e-2)) === nothing + # Without the extension the fallback has to say WHICH package, not "no method". + msg = try + nth_derivative(AutoDiff(), F, 0.3, 1) + "" + catch e + sprint(showerror, e) + end + @test isempty(msg) || occursin("ForwardDiff", msg) end diff --git a/test/ext/test_derivative_routes_ad.jl b/test/ext/test_derivative_routes_ad.jl index 21e3858..1ce70b4 100644 --- a/test/ext/test_derivative_routes_ad.jl +++ b/test/ext/test_derivative_routes_ad.jl @@ -49,3 +49,24 @@ end @test rows[3].order ≈ 2 atol = 0.05 @test maximum(r -> abs(r.value - rows[1].value), rows) < 1e-4 end + +@testset "the off-diagonal guard is one guard, not two copies" begin + # It used to be pasted in both paths, and the two copies had already drifted + # to different wording. Same message from both is what says they are shared. + off = Susceptibility(:x, :y) + ad = try + thermal_derivative(off, Fad, 0.3, AutoDiff()) + "" + catch e + sprint(showerror, e) + end + fd = try + thermal_derivative(off, Fad, 0.3, Richardson(1e-2)) + "" + catch e + sprint(showerror, e) + end + @test !isempty(ad) + @test ad == fd + @test occursin("DIAGONAL", ad) +end diff --git a/test/relations/test_derivation_routes.jl b/test/relations/test_derivation_routes.jl index 1e6e526..2e28a67 100644 --- a/test/relations/test_derivation_routes.jl +++ b/test/relations/test_derivation_routes.jl @@ -9,6 +9,7 @@ # confirmation of something nothing independent touched. using AbstractQAtlas +using AbstractQAtlas: _disagreement, derivation_steps, typed_derivation_steps using Test: @test, @test_throws, @testset slope(c) = 2 * c / 6 # CFTEntanglementSlope: dS/dlnℓ = ncuts·c/6, ncuts = 2 @@ -52,7 +53,7 @@ end catch e sprint(showerror, e) end - @test occursin("disagree", msg) + @test occursin("differ by 0.4", msg) # the size, not just that it threw @test occursin("CFTEntanglementSlope", msg) # the route is named @test occursin("supplied: 0.9", msg) # and so is the value it contradicts # Consistent data passes, and returns the supplied value. @@ -61,12 +62,68 @@ end @test derive_crosschecked(:c; dS_dlogℓ=slope(0.5), ncuts=2) ≈ 0.5 end -@testset "min_routes asks that a cross-check actually happened" begin - # Default is permissive, and returns a number nothing checked. - @test derive_crosschecked(:c; c=0.9, ncuts=2) == 0.9 - @test_throws ErrorException derive_crosschecked(:c; c=0.9, ncuts=2, min_routes=1) - # It must not fire where a route does exist. - @test derive_crosschecked(:c; c=0.5, dS_dlogℓ=slope(0.5), ncuts=2, min_routes=1) == 0.5 +@testset "min_routes defaults to demanding that a cross-check happened" begin + # Data affording no independent route is refused by default: returning 0.9 from + # it is what the verb's name would otherwise be claiming it had checked. + @test_throws ErrorException derive_crosschecked(:c; c=0.9, ncuts=2) + @test derive_crosschecked(:c; c=0.9, ncuts=2, min_routes=0) == 0.9 # opt-out + # One route is enough to compare a supplied value against. + @test derive_crosschecked(:c; c=0.5, dS_dlogℓ=slope(0.5), ncuts=2) == 0.5 + # And two can be demanded where one is not evidence. + @test_throws ErrorException derive_crosschecked( + :c; c=0.5, dS_dlogℓ=slope(0.5), ncuts=2, min_routes=2 + ) +end + +@testset "a route that raised is reported, not dropped" begin + # Z must be positive: it is a sum of Boltzmann weights. `FreeEnergyFromZ` needs + # log(Z) and throws, which is the relation being PREVENTED from disagreeing. + # Dropping it silently leaves one route and a clean "cross-checked" answer. + impossible = bag( + PartitionFunction => -2.0, + InverseTemperature => 1.0, + Energy(:per_site) => -0.4, + ThermalEntropy => 0.3, + ) + rows = derivation_routes(FreeEnergy, impossible) + @test length(rows) == 2 + threw = only(r for r in rows if r.error !== nothing) + @test nameof(typeof(threw.relation)) === :FreeEnergyFromZ + @test occursin("DomainError", threw.error) + @test threw.value === nothing + msg = try + derive_crosschecked(FreeEnergy, impossible) + "" + catch e + sprint(showerror, e) + end + @test occursin("raised on this data", msg) + @test occursin("FreeEnergyFromZ", msg) + # A relation merely declining to be solved for a slot is NOT a broken route, or + # every ordinary call would refuse. The consistent bag still passes. + β, Z, U = 0.8, 3.0, 0.4 + F = -log(Z) / β + ok = bag( + PartitionFunction => Z, + InverseTemperature => β, + Energy(:per_site) => U, + ThermalEntropy => β * (U - F), + ) + @test all(r -> r.error === nothing, derivation_routes(FreeEnergy, ok)) + @test derive_crosschecked(FreeEnergy, ok; min_routes=2) ≈ F +end + +@testset "a NaN route is a degeneration, not an agreement" begin + # Every difference against NaN is NaN, so an `isnan` short-circuit meant for + # "fewer than two values" would read a degenerate route as agreement. + msg = try + derive_crosschecked(:c; c=NaN, dS_dlogℓ=slope(0.5), ncuts=2) + "" + catch e + sprint(showerror, e) + end + @test occursin("NaN", msg) + @test occursin("nothing was compared", msg) end @testset "the typed door holds the target out the same way" begin @@ -111,3 +168,51 @@ end @test VariableKey(PeltierCoefficient{(:x, :x)}) in derivable(alias) @test isempty(derivation_routes(Temperature, alias)) end + +@testset "the tolerance is isapprox's, not a floor that goes absolute below one" begin + # Two routes returning +1e-12 and -1e-12 is a sign flip. Dividing the + # difference by `max(maximum(abs, vs), 1)` reports it as 2e-12 and passes it + # at any sane rtol, which is why the comparison is `d <= atol + rtol*m`. + d, m = _disagreement([1e-12, -1e-12]) + @test (d, m) == (2e-12, 1e-12) + @test !(d <= 0 + 1e-8 * m) + # A genuine agreement to 1e-9 relative still passes. + d2, m2 = _disagreement([1.0, 1.0 + 1e-9]) + @test d2 <= 0 + 1e-8 * m2 + # Fewer than two values is not agreement. It gets `nothing`, not a NaN that a + # degenerate route would also produce. + @test _disagreement([1.0]) === nothing + @test _disagreement(Any[]) === nothing + + # `atol` is how a caller says their routes are noise-dominated, and it is the + # only thing that lets the broken-entropy bag through. + β, Z, U = 0.8, 3.0, 0.4 + F = -log(Z) / β + bad = bag( + PartitionFunction => Z, + InverseTemperature => β, + Energy(:per_site) => U, + ThermalEntropy => β * (U - F) + 0.5, + ) + @test_throws ErrorException derive_crosschecked(FreeEnergy, bad) + @test derive_crosschecked(FreeEnergy, bad; atol=1.0) ≈ F +end + +@testset "multiple producers is the common case the section comment claims" begin + # The comment above `derivation_routes` carries measured counts. Pinned as + # floors rather than exact numbers: adding a relation that produces an + # already-produced output raises them, and a floor does not rot for that. + function multi(steps) + d = Dict{Any,Set{Any}}() + for st in steps + push!(get!(d, st.output, Set{Any}()), nameof(typeof(st.relation))) + end + return count(v -> length(v) > 1, values(d)), maximum(length, values(d)) + end + n_sym, max_sym = multi(derivation_steps()) + n_typed, max_typed = multi(typed_derivation_steps()) + @test n_sym >= 60 + @test max_sym >= 15 + @test n_typed >= 35 + @test max_typed >= 12 +end From b316fb8dc6144fcabc800bc3fd5acfc215d8c9d7 Mon Sep 17 00:00:00 2001 From: sotashimozono Date: Tue, 15 Sep 2026 11:20:43 +0000 Subject: [PATCH 3/6] An ErrorException is two different things, and the split was wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_route_declined(e) = e isa ErrorException` classified every `ErrorException` as the framework declining to apply a relation. This package raises them for two unrelated reasons: | raised by | example | should be | |---|---|---| | the framework declining | `solve: ... is not affine in :β`, `MottFormula needs the supplied value :dlnσ_dε (untyped slot)` | skipped | | a relation's own physics guard | `CFTEntanglementSlope: ncuts = 0 leaves the residual independent of the central charge` | reported | The second is a statement about the DATA, and the type test dropped it. With `ncuts = 0` the caller still got a refusal, because `min_routes` is 1, but the diagnosis had become "reached by 0 independent routes" and the real reason was gone. The framework declines at exactly six sites in relations/interface.jl, in two message shapes, and `_route_declined` now matches those. Not by exception type: a typed declination is the right fix but changing it lands on 29 `@test_throws ErrorException` calls over solve/residual/check, which is not this PR. False positives measured by sweeping all 219 targets with a deliberately nonsensical value for every input: 382 rows, 17 broken, and every one of the 17 a real data problem (`ℓ = L`, `ℓ = 0`, a `DomainError`, a non-positive `L` or `f`). No declination leaked into the reported set. That sweep is now a test, so a seventh declination site added and not registered here fails. Mutation, against test/relations/test_derivation_routes.jl (49 -> 55): | mutation | assertions failed | |---|---| | classify everything as declining (the bug above) | 2 | | forget the untyped-slot shape | 2 | | forget the `solve:` shape | 2 | Co-Authored-By: Claude Opus 5 (1M context) --- src/relations/derivation.jl | 19 ++++++---- test/relations/test_derivation_routes.jl | 45 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/relations/derivation.jl b/src/relations/derivation.jl index 2b6219b..f120571 100644 --- a/src/relations/derivation.jl +++ b/src/relations/derivation.jl @@ -827,12 +827,19 @@ function Base.show(io::IO, r::DerivationRouteRow) return print(io, r.error === nothing ? r.value : "THREW $(r.error)") end -# A relation declining to be solved for a slot raises `ErrorException`, which is -# how this package says no (`solve: ... is not affine in :X`). Anything else, a -# `DomainError` from `log` of a negative partition function, an `InexactError`, a -# `MethodError` from a caller's own potential, is the DATA or the CALLER breaking, -# and is the thing worth reporting rather than skipping. -_route_declined(e) = e isa ErrorException +# Telling "this relation cannot be applied here" from "it applied and the data +# broke it". The framework declines in exactly two shapes, both raised by +# relations/interface.jl: `solve:` for the affine, parametric and abstract-group +# refusals, and the untyped-slot message for a supplied value the caller did not +# give. Everything else is the DATA, including a relation's OWN physics guard, +# which raises an `ErrorException` like `CFTEntanglementSlope: ncuts = 0 ...` and +# is a statement about the inputs. Matching the type alone would drop those, and +# `test_derivation_routes.jl` sweeps every target to pin that neither shape leaks +# into the reported set. +function _route_declined(e) + e isa ErrorException || return false + return startswith(e.msg, "solve:") || occursin("(untyped slot)", e.msg) +end """ derivation_routes(target::Symbol; knowns...) -> Vector{DerivationRouteRow} diff --git a/test/relations/test_derivation_routes.jl b/test/relations/test_derivation_routes.jl index 2e28a67..d0165ac 100644 --- a/test/relations/test_derivation_routes.jl +++ b/test/relations/test_derivation_routes.jl @@ -216,3 +216,48 @@ end @test n_typed >= 35 @test max_typed >= 12 end + +@testset "the declining/broken split holds across the whole registry" begin + # `_route_declined` separates "this relation cannot be applied here" (skip) + # from "it applied and the data broke it" (report). Getting it wrong in either + # direction is invisible: too narrow and a broken input is silently dropped, + # too wide and ordinary calls refuse. + # + # Both framework declination shapes, on fixtures that actually produce them. + @test isempty(derivation_routes(:β; C=1.0, var_E=1.0)) # solve: not affine + @test isempty( # untyped slot absent + derivation_routes( + Temperature, bag(InverseTemperature => 0.5, Thermopower(:x, :x) => 3.0) + ), + ) + # A relation's own physics guard is about the DATA and must be reported, not + # skipped. `ncuts = 0` makes the residual independent of `c`. + ncz = derivation_routes(:c; dS_dlogℓ=0.1667, ncuts=0) + @test length(ncz) == 1 + @test occursin("ncuts = 0", only(ncz).error) + + # The sweep: hand every target's inputs the same nonsense value and check that + # nothing the framework MEANT as a declination ends up in the reported set. A + # seventh declination site added to interface.jl and not registered here fails + # this, which is the whole point of the assertion. + leaked = String[] + total = 0 + for t in unique(s.output for s in derivation_steps()) + ins = unique( + vcat([collect(s.inputs) for s in derivation_steps() if s.output === t]...) + ) + rows = try + derivation_routes(t; (i => 0.7 for i in ins)...) + catch + continue + end + total += length(rows) + for r in rows + r.error === nothing && continue + (startswith(r.error, "solve:") || occursin("(untyped slot)", r.error)) && + push!(leaked, r.error) + end + end + @test total > 300 # the sweep reached the registry, not two rows + @test isempty(leaked) +end From f4c5f28e20ca10eedf5824d111c88c0321af8112 Mon Sep 17 00:00:00 2001 From: sotashimozono Date: Tue, 15 Sep 2026 11:56:16 +0000 Subject: [PATCH 4/6] Second review round: a probe point is not the caller's data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six agents over `010e95f`, whose own 200 lines had not been reviewed. Four found real defects. Everything below has a mutation or a measured sweep behind it. UNREACHABLE FOR ORDINARY DATA `EntanglementSpectrumCorrelation` is `ε - log((1-ζ)/ζ)`. `solve` probes its target at 0, 1, 2, and at `ζ = 2` the argument of `log` is -0.5, so it raised for EVERY ε: `derive` called ζ unreachable and `derive_crosschecked` refused, with no `min_routes` escape. Its docstring had carried `ζ = 1/(e^ε + 1)` all along; the solver just never got it. Same shape for `:ncuts`, which the slope relations guard at exactly the first probe. Both now have the closed form, and the guard reads the ANSWER instead of the probe. CLASSIFICATION `_route_declined` matched `solve:` as framework vocabulary, and `scaling.jl`'s `PseudocriticalWidthScaling` guard had borrowed that prefix while being a statement about the DATA, so it vanished from the reported set. Renamed to the `: ` form every other guard in the package uses, and `solve:` is now reserved vocabulary with a test that sweeps the registry for a leak. The refusal message also asserted "raised on this data", which is false when the cause is the relation's domain at the point `solve` probed. Reworded to name both possibilities, since the row cannot tell them apart. Measured over all 219 targets with every input at 0.7, 1.3 and 2.5: 45 refusals for disagreement (the fixture is inconsistent, so those are correct) and 5 for a broken route, all five `ℓ = L`, a real domain violation. No prober artifact left. ORDER DEPENDENCE `declared_convention` folded pairwise against a running best, so two unrelated covers reconciled by a third reported a false ambiguity in 2 of the 6 iteration orders. Now collects every cover and takes the unique minimum; all 6 orders agree, and a genuine ambiguity is still refused. ASYMMETRIC CATCHES `derivative_report`'s order column caught `ErrorException` where its value column caught only `MissingRouteBackend`, so a route reporting a `step_size` with no `with_step_size` produced the same NaN as a legitimately step-less one. The step is now checked before the call and both catches are narrow. `nth_derivative`'s fallback told a route author to install a package that is already loaded. It checks, and says "loaded, but no method matched" instead. UNREPRESENTABLE STATES `MissingRouteBackend` could be built for a route with no `backend_package`, rendering "needs the nothing extension". `DerivationRouteRow` could hold both a value and an error, or neither, the latter crashing `_disagreement` with a `MethodError`. Inner constructors on both. SIMPLIFICATION The two doors' catch bodies were byte-identical: one `_route_row!`, mirroring `_finite_size_scaling_row!`. One `_row_ok` predicate for what was four inline lambdas. `SpecificHeat` and `Energy`'s route methods merged, which the removal of their `route isa AutoDiff` short-circuits made possible. FALSE COMMENTS, MINE "A route that raised is a row rather than an absence" is not true of a declining route. `step_size`'s docstring named the "defines neither" case and described the other one. `_disagreement` returns `nothing`, not `(NaN, NaN)`. `derivative_report`'s comment justified itself against a `_route_order` fallback the same commit had removed. TESTS conventions 49 -> 59, derivative routes 49 -> 60, derivation routes 49 -> 69. Two mutations survived the last round and now fail: the ambiguity refusal (its fixture paired the query with a type it is not a subtype of, so the branch was unreachable) and `derivative_report`'s edge guard (pinned by type, and `_route_order`'s own guard fired one line later with a different message). Also newly pinned: the `THREW` display, the row invariant, `MissingRouteBackend`'s type and message, and `Richardson`'s `levels` by strict decrease rather than `issorted`, which counts the ties an ignored `levels` produces as sorted. Co-Authored-By: Claude Opus 5 (1M context) --- src/AbstractQAtlas.jl | 1 + src/core/conventions.jl | 29 ++++--- src/derivative_routes.jl | 65 +++++++++++----- src/relations/derivation.jl | 97 +++++++++++++++--------- src/relations/entanglement.jl | 28 +++++++ src/relations/scaling.jl | 2 +- test/core/test_conventions.jl | 45 ++++++++++- test/core/test_derivative_routes.jl | 75 +++++++++++++++++- test/relations/test_derivation_routes.jl | 46 ++++++++++- 9 files changed, 310 insertions(+), 78 deletions(-) diff --git a/src/AbstractQAtlas.jl b/src/AbstractQAtlas.jl index 45c6cb9..919cfc8 100644 --- a/src/AbstractQAtlas.jl +++ b/src/AbstractQAtlas.jl @@ -136,6 +136,7 @@ end module QuantumInformation using ..AbstractQAtlas import ..AbstractQAtlas: fetch # unexported, so bare `fetch` here would be Base's + import ..AbstractQAtlas: _solve # extended for a closed-form inverse (EntanglementSpectrumCorrelation:ζ) using ExperimentalAPI: @experimental include("relations/entanglement.jl") end diff --git a/src/core/conventions.jl b/src/core/conventions.jl index 65290c6..f586915 100644 --- a/src/core/conventions.jl +++ b/src/core/conventions.jl @@ -211,24 +211,23 @@ supertype chain SKIPS its own family: `supertype(Energy{:per_site})` is `AbstractThermalPotential`, so a walk never reaches the `Energy` a project keyed its declaration on, and the value goes into the bag unconverted. -Two declared types that both cover `Q` and are unrelated to each other are -refused rather than resolved by `Dict` order. +Covers with no unique most specific member are refused rather than resolved by +`Dict` order. Decided after collecting every cover, not folded pairwise: two +unrelated covers can be reconciled by a third that refines both, and a fold that +errors on meeting the first incomparable pair reports a false ambiguity for four +of the six orders that Dict iteration can hand it. """ function declared_convention(cs::ConventionSet, @nospecialize(Q::Type)) - best, bestT = nothing, nothing - for (T, c) in cs.declared - Q <: T || continue - if bestT === nothing || T <: bestT - best, bestT = c, T - elseif !(bestT <: T) - error( - "declared_convention: $Q is covered by both $bestT and $T, which are " * - "unrelated, so neither is the more specific. Key the declaration on " * - "whichever one the values were actually written in.", - ) - end + covers = [(T, c) for (T, c) in cs.declared if Q <: T] + isempty(covers) && return nothing + for (T, c) in covers + all(U -> T <: U, first.(covers)) && return c end - return best + return error( + "declared_convention: $Q is covered by $(join(first.(covers), ", ")), with no " * + "one of them a subtype of all the others, so none is the most specific. Key " * + "the declaration on whichever one the values were actually written in.", + ) end export declared_convention diff --git a/src/derivative_routes.jl b/src/derivative_routes.jl index bb7d5f1..0a7785e 100644 --- a/src/derivative_routes.jl +++ b/src/derivative_routes.jl @@ -84,6 +84,10 @@ struct Richardson <: DerivativeRoute end export Richardson +# Whether the named package is loaded at all, which separates "install it" from +# "it is here and the route's own method is missing". +_package_loaded(name::Symbol) = any(k -> k.name == String(name), keys(Base.loaded_modules)) + """ nth_derivative(route::DerivativeRoute, f, x, n::Integer) -> value @@ -93,8 +97,17 @@ The `n`-th derivative of the scalar function `f` at `x`, taken along `route`. This is the one method a new route has to define. """ function nth_derivative(route::DerivativeRoute, f, x, n::Integer) - backend_package(route) === nothing || throw(MissingRouteBackend(route)) - return error("nth_derivative: no method for $(typeof(route)).") + pkg = backend_package(route) + pkg === nothing && return error("nth_derivative: no method for $(typeof(route)).") + # Reaching the fallback with the backend LOADED means the route's own method is + # missing or its signature does not match, which reinstalling does not fix. + # Saying "not loaded" there points away from the defect. + _package_loaded(pkg) && return error( + "nth_derivative: $(typeof(route)) declares the $pkg backend and $pkg is " * + "loaded, but no method matched. The route's own `nth_derivative` is missing " * + "or its signature differs.", + ) + return throw(MissingRouteBackend(route)) end export nth_derivative @@ -111,6 +124,16 @@ the same `NaN` row as an unloaded backend. """ struct MissingRouteBackend <: Exception route::DerivativeRoute + function MissingRouteBackend(route::DerivativeRoute) + backend_package(route) === nothing && throw( + ArgumentError( + "MissingRouteBackend: $(typeof(route)) declares no `backend_package`, " * + "so there is no extension for it to be missing. Its failure is not a " * + "missing backend.", + ), + ) + return new(route) + end end export MissingRouteBackend @@ -170,9 +193,11 @@ The step `route` takes, and the same route at a different step. `nothing` means the route has no step, which is what [`AutoDiff`](@ref) reports. Part of the route contract alongside [`nth_derivative`](@ref), and the pair -[`observed_order`](@ref) needs. A route that carries a step and defines neither -gets the honest refusal rather than the false claim that it has no step, which is -what a closed `Union` over the routes that happened to exist would have told it. +[`observed_order`](@ref) needs. Each missing half names itself: a route reporting +a `step_size` with no `with_step_size` is told so by `with_step_size`, and one +declaring neither is told it reports no step, which for it is true. A closed +`Union` over the routes that happened to exist told a third route the second +thing whether or not it was true. """ step_size(::DerivativeRoute) = nothing export step_size @@ -269,11 +294,10 @@ end # `U`, and `U = ∂(βF)/∂β` takes `βF`. Both are a plain first derivative of the # function passed, with no sign flip, which is why they cannot go through the # generic path above. -function thermal_derivative(::SpecificHeat, U, T::Number, route::DerivativeRoute) - return nth_derivative(route, U, T, 1) -end -function thermal_derivative(::Energy, βF, β::Number, route::DerivativeRoute) - return nth_derivative(route, βF, β, 1) +function thermal_derivative( + ::Union{SpecificHeat,Energy}, f, x::Number, route::DerivativeRoute +) + return nth_derivative(route, f, x, 1) end # A single-field potential fixes only the DIAGONAL susceptibility: an off-diagonal @@ -331,9 +355,8 @@ rather than aborting the sweep, since the usual reason is a missing backend and the other rows are still the answer. """ function derivative_report(q::AbstractQuantity, F, x::Number, routes) - # Refused here rather than per row: `_route_order` would fall back to 1 and the - # order column would report a textbook 2.0 beside a value the same call refused, - # which reads as "the numerics are fine, only the value failed". + # Refused up front, so no row is built for a quantity that has no derivative. + # Per row it would surface as `_route_order` throwing inside the order column. derivative_edge(q) === nothing && error( "derivative_report: $(typeof(q)) is not a response function (no " * "derivative_edge), so there is no derivative for a route to take.", @@ -347,11 +370,19 @@ function derivative_report(q::AbstractQuantity, F, x::Number, routes) e isa MissingRouteBackend || rethrow() NaN end - o = try - Float64(observed_order(r, F, x, n)) - catch e - (e isa MissingRouteBackend || e isa ErrorException) || rethrow() + # Asked whether the route has a step BEFORE calling, so the catch can stay as + # narrow as the value's. Absorbing `ErrorException` here instead would turn a + # route that reports a `step_size` and defines no `with_step_size` into the + # same NaN as one that legitimately has no step. + o = if step_size(r) === nothing NaN + else + try + Float64(observed_order(r, F, x, n)) + catch e + e isa MissingRouteBackend || rethrow() + NaN + end end push!(out, DerivativeRouteRow(r, v, o)) end diff --git a/src/relations/derivation.jl b/src/relations/derivation.jl index f120571..3496f56 100644 --- a/src/relations/derivation.jl +++ b/src/relations/derivation.jl @@ -809,15 +809,29 @@ end One route to a target: the `relation`, the `inputs` it consumed, and either the `value` it returned or the `error` it raised on the way. -A route that raised is a row rather than an absence. An impossible input makes a -relation throw where it would otherwise have DISAGREED, and dropping it silently -turns the strongest evidence the data is wrong into one fewer route to compare. +A route that raised because of the DATA is a row rather than an absence: an +impossible input makes a relation throw where it would otherwise have DISAGREED, +and dropping it silently turns the strongest evidence the data is wrong into one +fewer route to compare. A route the framework declines (`_route_declined`) is +still an absence, since it was never applicable here. """ struct DerivationRouteRow relation::AbstractRelation inputs::Vector{Any} value::Any error::Union{String,Nothing} + function DerivationRouteRow(rel, inputs, value, error) + # Exactly one of the two, or every consumer's `r.error === nothing` branch is + # wrong about what `value` holds. Neither set crashes `_disagreement` with a + # `MethodError` on `nothing - nothing` instead of any diagnosis. + (value === nothing) == (error === nothing) && throw( + ArgumentError( + "DerivationRouteRow: a row carries either a value or an error, not " * + "both and not neither; got value=$(repr(value)), error=$(repr(error)).", + ), + ) + return new(rel, inputs, value, error) + end end DerivationRouteRow(rel, inputs, value) = DerivationRouteRow(rel, inputs, value, nothing) export DerivationRouteRow @@ -829,13 +843,31 @@ end # Telling "this relation cannot be applied here" from "it applied and the data # broke it". The framework declines in exactly two shapes, both raised by -# relations/interface.jl: `solve:` for the affine, parametric and abstract-group -# refusals, and the untyped-slot message for a supplied value the caller did not -# give. Everything else is the DATA, including a relation's OWN physics guard, +# relations/interface.jl and nowhere else: `solve:` for the affine, parametric and +# abstract-group refusals, and the untyped-slot message for a supplied value the +# caller did not give. `solve:` is therefore reserved vocabulary, pinned by a test, +# because a relation guard that borrows it disappears from the reported set. +# Everything else is the DATA, including a relation's OWN physics guard, # which raises an `ErrorException` like `CFTEntanglementSlope: ncuts = 0 ...` and # is a statement about the inputs. Matching the type alone would drop those, and # `test_derivation_routes.jl` sweeps every target to pin that neither shape leaks # into the reported set. +_row_ok(r::DerivationRouteRow) = r.error === nothing + +# The push is the same on both doors; only the presence test and the `solve` call +# above it are door-specific. Mirrors `_finite_size_scaling_row!` in finite_size.jl. +function _route_row!(rows, step, v) + return push!(rows, DerivationRouteRow(step.relation, Any[step.inputs...], v)) +end +function _route_row!(rows, step, e::Exception) + return push!( + rows, + DerivationRouteRow( + step.relation, Any[step.inputs...], nothing, sprint(showerror, e) + ), + ) +end + function _route_declined(e) e isa ErrorException || return false return startswith(e.msg, "solve:") || occursin("(untyped slot)", e.msg) @@ -869,18 +901,16 @@ function derivation_routes(target::Symbol; knowns...) step.output === target || continue all(v -> haskey(known, v), step.inputs) || continue try - v = solve( - step.relation, Val(step.output); (v => known[v] for v in step.inputs)... - ) - push!(rows, DerivationRouteRow(step.relation, Any[step.inputs...], v)) - catch e - _route_declined(e) && continue - push!( + _route_row!( rows, - DerivationRouteRow( - step.relation, Any[step.inputs...], nothing, sprint(showerror, e) + step, + solve( + step.relation, Val(step.output); (v => known[v] for v in step.inputs)... ), ) + catch e + _route_declined(e) && continue + _route_row!(rows, step, e) end end return rows @@ -906,28 +936,21 @@ function derivation_routes(@nospecialize(Q::Type), bag::Bag; extras...) step.output == target || continue all(k -> _known(k.type, known), step.inputs) || continue try - v = solve(step.relation, step.output.type, known; extras...) - push!(rows, DerivationRouteRow(step.relation, Any[step.inputs...], v)) + _route_row!( + rows, step, solve(step.relation, step.output.type, known; extras...) + ) catch e _route_declined(e) && continue - push!( - rows, - DerivationRouteRow( - step.relation, Any[step.inputs...], nothing, sprint(showerror, e) - ), - ) + _route_row!(rows, step, e) end end return rows end export derivation_routes -# The largest pairwise difference in a set of values, and their largest magnitude. -# `(NaN, NaN)` for fewer than two, which is not agreement and must not read as zero. -# -# Compared as `d <= atol + rtol*m`, `isapprox`'s rule, rather than divided by -# `max(m, 1)`: that floor turns the test absolute below one, and two routes -# returning `+1e-12` and `-1e-12` are then a sign flip that passes at any rtol. +# The largest pairwise difference in a set of values, and their largest magnitude, +# to be compared as `d <= atol + rtol*m`. `nothing` for fewer than two, which is +# not agreement and must not share a sentinel with what a degenerate route returns. function _disagreement(vs) length(vs) < 2 && return nothing return (maximum(abs(a - b) for a in vs, b in vs), maximum(abs, vs)) @@ -989,7 +1012,7 @@ end export derive_crosschecked function _refuse_disagreement(what, rows, supplied, atol, rtol) - vs = Any[r.value for r in rows if r.error === nothing] + vs = Any[r.value for r in rows if _row_ok(r)] supplied === nothing || push!(vs, supplied) # A route that returned NaN makes every difference NaN, and `isnan` as a # "nothing to compare" sentinel would then read that as agreement. It is the @@ -1017,20 +1040,22 @@ end # A route that raised is reported before any comparison: it is a relation that # would have disagreed, prevented from doing so by the data itself. function _refuse_broken_routes(what, rows) - broken = [r for r in rows if r.error !== nothing] + broken = [r for r in rows if !_row_ok(r)] isempty(broken) && return nothing return error( - "derive_crosschecked: $(length(broken)) route(s) to $what raised on this data " * - "rather than returning a value, so they never got to disagree:\n " * + "derive_crosschecked: $(length(broken)) route(s) to $what raised instead of " * + "returning a value, so they never got to disagree. Either an input is outside " * + "the relation's domain, or the relation guards the point `solve` probed the " * + "target at and needs a specialized `_solve`:\n " * join(string.(broken), "\n "), ) end function _require_routes(what, rows, min_routes) - count(r -> r.error === nothing, rows) >= min_routes && return nothing + n = count(_row_ok, rows) + n >= min_routes && return nothing return error( - "derive_crosschecked: $what is reached by " * - "$(count(r -> r.error === nothing, rows)) independent " * + "derive_crosschecked: $what is reached by $n independent " * "route(s), fewer than the $min_routes asked for. The data affords no " * "cross-check here, and a value returned from it would not have had one.", ) diff --git a/src/relations/entanglement.jl b/src/relations/entanglement.jl index 5bed9f2..3362247 100644 --- a/src/relations/entanglement.jl +++ b/src/relations/entanglement.jl @@ -103,6 +103,22 @@ is out of domain and [`CFTEntanglementChordSlope`](@ref) is the one to use. `nc dS_dlogℓ - ncuts * c / 6 end +# `_require_cuts` fires at the solver's probe of `ncuts = 0` before the slope is +# ever read, so without these the count is unreachable and the refusal names the +# probe as though it were the caller's. The guard belongs on the ANSWER. +function _solve(::CFTEntanglementSlope, ::Val{:ncuts}; dS_dlogℓ, c, _extra...) + return _solved_cuts(:CFTEntanglementSlope, dS_dlogℓ, c) +end +function _solve(::CFTEntanglementChordSlope, ::Val{:ncuts}; dS_dlogchord, c, _extra...) + return _solved_cuts(:CFTEntanglementChordSlope, dS_dlogchord, c) +end +function _solved_cuts(what::Symbol, slope, c) + iszero(c) && error("$what: c = 0 carries no slope, so it fixes no cut count.") + n = 6 * slope / c + _require_cuts(what, n) + return n +end + """ InfiniteRandomnessEntanglementSlope <: AbstractRelation @@ -665,6 +681,18 @@ Variables: `ε`, `ζ`. ε::EntanglementSpectrumLevel, ζ::CorrelationMatrixEigenvalue ) = ε - log((1 - ζ) / ζ) +# The docstring's own inverse, handed to the solver. Without it the generic +# three-point prober evaluates the kernel at ζ = 2, where `(1-ζ)/ζ = -0.5` and +# `log` raises, so `derive` called this unreachable and `derivation_routes` filed +# a broken route, for EVERY ε. The failure is a property of the probe points, not +# of the caller's data, which no classification downstream can tell apart. +function _solve(::EntanglementSpectrumCorrelation, ::Val{:ζ}; ε, _extra...) + isfinite(ε) || error( + "EntanglementSpectrumCorrelation: ζ = 1/(exp(ε) + 1) needs a finite ε; got $ε." + ) + return 1 / (exp(ε) + 1) +end + """ free_fermion_entanglement_entropy(ζ) -> Float64 diff --git a/src/relations/scaling.jl b/src/relations/scaling.jl index 5cf7094..4b3b133 100644 --- a/src/relations/scaling.jl +++ b/src/relations/scaling.jl @@ -762,7 +762,7 @@ Variables: `dlogδTc_dlogL` (caller-computed), `ν`. # a flat slope returns an infinity whose SIGN comes from the caller's zero. function _solve(::PseudocriticalWidthScaling, ::Val{:ν}; dlogδTc_dlogL, _extra...) iszero(dlogδTc_dlogL) && error( - "solve: PseudocriticalWidthScaling has no ν at dlogδTc_dlogL = 0. A width " * + "PseudocriticalWidthScaling: no ν at dlogδTc_dlogL = 0. A width " * "that does not shift with L does not identify a correlation-length exponent.", ) return -1 / dlogδTc_dlogL diff --git a/test/core/test_conventions.jl b/test/core/test_conventions.jl index d811606..8893cf7 100644 --- a/test/core/test_conventions.jl +++ b/test/core/test_conventions.jl @@ -21,6 +21,18 @@ AbstractQAtlas.convert_convention(::WholeUnits, ::HalfUnits, ::Type, v) = 2v struct ParametricProbeQuantity{I} <: AbstractQuantity end AbstractQAtlas.canonical_convention(::Type{<:ParametricProbeQuantity}) = WholeUnits() +# Three covers of one quantity where two are unrelated to each other and the third +# refines both. This is the shape a pairwise fold gets wrong. +abstract type AmbProbeParent <: AbstractQuantity end +struct AmbProbeSideA <: AbstractQuantity end +struct AmbProbeSideB <: AbstractQuantity end +struct AmbProbeQuantity <: AmbProbeParent end +struct UnitsA <: Convention end +struct UnitsB <: Convention end +struct UnitsC <: Convention end +const AMB_WIDE_A = Union{AmbProbeParent,AmbProbeSideA} +const AMB_WIDE_B = Union{AmbProbeParent,AmbProbeSideB} + @testset "an axis is declared per quantity, never by supertype" begin # The twelve whose ABQ definition contains a logarithm, or is an additive # combination of ones that do. @@ -105,11 +117,36 @@ end @test bag(cs, ParametricProbeQuantity{:a}() => 2.5)[VariableKey( ParametricProbeQuantity{:a} )] == 5.0 - # Two covers that are unrelated cannot both be what the values are written in. - amb = conventions( - ParametricProbeQuantity => HalfUnits(), ConventionProbeQuantity => HalfUnits() +end + +@testset "the most specific cover is found whatever order the Dict yields" begin + # The earlier spelling of this testset paired the query with a type it is not a + # subtype of, so the ambiguity branch was never reached and the assertion held + # with the second entry deleted. These two DO both cover it. + @test AmbProbeQuantity <: AMB_WIDE_A + @test AmbProbeQuantity <: AMB_WIDE_B + @test !(AMB_WIDE_A <: AMB_WIDE_B) && !(AMB_WIDE_B <: AMB_WIDE_A) + @test AmbProbeParent <: AMB_WIDE_A && AmbProbeParent <: AMB_WIDE_B + + # Every insertion order must give the one cover that refines both. A fold that + # errors on meeting the first incomparable pair gets this right for four of the + # six orders and reports a false ambiguity for two. + entries = [AMB_WIDE_A => UnitsA(), AMB_WIDE_B => UnitsB(), AmbProbeParent => UnitsC()] + for o in + [[a, b, c] for a in 1:3 for b in 1:3 for c in 1:3 if length(unique([a, b, c])) == 3] + cs = ConventionSet(Dict{Type,Convention}(entries[i] for i in o)) + @test declared_convention(cs, AmbProbeQuantity) === UnitsC() + end + + # With no common refinement there IS no most specific cover, and guessing one by + # Dict order is the thing being refused. + genuine = ConventionSet( + Dict{Type,Convention}(AMB_WIDE_A => UnitsA(), AMB_WIDE_B => UnitsB()) + ) + @test occursin( + "no one of them a subtype of all the others", + why(() -> declared_convention(genuine, AmbProbeQuantity)), ) - @test declared_convention(amb, ParametricProbeQuantity{:a}) === HalfUnits() end @testset "conversion" begin diff --git a/test/core/test_derivative_routes.jl b/test/core/test_derivative_routes.jl index 041ffe1..60f2613 100644 --- a/test/core/test_derivative_routes.jl +++ b/test/core/test_derivative_routes.jl @@ -7,6 +7,14 @@ using AbstractQAtlas using AbstractQAtlas: _route_order, _genealogy_derivative using Test: @test, @test_throws, @testset +why(f) = + try + f() + "" + catch e + sprint(showerror, e) + end + F(h) = -log(2cosh(h)) # M = -F'(h) = tanh(h) Φ(T) = -T * log(2cosh(1 / T)) # S = -Φ'(T) kinked(x) = x < 0 ? x^2 : 2x^2 # f and f' continuous at 0, f'' jumps 2 -> 4 @@ -19,6 +27,21 @@ function AbstractQAtlas.derivative_edge(::Type{RootProbeQuantity}) return DerivativeEdge(RootProbeParent, Temperature) end +# A route that breaks the contract: it reports a step and gives no way to change +# one. `observed_order` needs both, so this must surface rather than become a NaN. +struct BrokenStepRoute <: DerivativeRoute + h::Float64 +end +function AbstractQAtlas.nth_derivative(r::BrokenStepRoute, f, x, n::Integer) + return nth_derivative(CentralDifference(r.h), f, x, n) +end +AbstractQAtlas.step_size(r::BrokenStepRoute) = r.h + +# A route naming a backend that IS loaded, so the fallback must not blame the +# package for a method the route itself never defined. +struct LoadedBackendRoute <: DerivativeRoute end +AbstractQAtlas.backend_package(::LoadedBackendRoute) = :LinearAlgebra + @testset "a finite-difference route reaches the closed form" begin x = 0.3 @test thermal_derivative(Magnetization(:z), F, x, CentralDifference(1e-2)) ≈ tanh(x) atol = @@ -97,8 +120,9 @@ end thermal_derivative(Magnetization(:z), F, x, Richardson(1e-1; levels=L)) - exact ) for L in 2:5 ] - # Each level removes one more order of h^2, so the error falls monotonically. - @test issorted(errs; rev=true) + # STRICTLY falling: a `levels` that is ignored gives four equal errors, and + # `issorted` counts ties as sorted, so it alone would pass that. + @test all(errs[i] > errs[i + 1] for i in 1:(length(errs) - 1)) @test errs[1] / errs[end] > 1e3 # And a bad `levels` is refused rather than silently behaving as the default. @test_throws ArgumentError Richardson(1e-2; levels=1) @@ -177,8 +201,13 @@ end # Only a missing backend is absorbed into a NaN row. A quantity with no # genealogy edge, and a guard the route itself raises, both propagate: a NaN # there would read as "install a package" for a mistake no package fixes. - @test_throws ErrorException derivative_report( - PartitionFunction(), F, 0.3, (CentralDifference(1e-2),) + # Pinned by MESSAGE: `_route_order` has its own guard one line later, so a + # type-only assertion passes whichever of the two fired. + @test occursin( + "is not a response function", + why( + () -> derivative_report(PartitionFunction(), F, 0.3, (CentralDifference(1e-2),)) + ), ) @test_throws ErrorException derivative_report( Susceptibility(:x, :y), F, 0.3, (CentralDifference(1e-2),) @@ -208,3 +237,41 @@ end end @test isempty(msg) || occursin("ForwardDiff", msg) end + +@testset "a route that breaks the step contract is not absorbed as a NaN" begin + # The order column used to catch `ErrorException` as well as + # `MissingRouteBackend`, so a route reporting a `step_size` with no + # `with_step_size` produced the same NaN as one that legitimately has no step. + # Different mistakes, and only one of them is the caller's. + @test occursin( + "with_step_size", why(() -> observed_order(BrokenStepRoute(1e-2), F, 0.3, 1)) + ) + @test_throws ErrorException derivative_report( + Magnetization(:z), F, 0.3, (BrokenStepRoute(1e-2),) + ) + # A route declaring no step is still a quiet NaN, the one case the column may + # absorb, and its message says which half is missing. + @test isnan(only(derivative_report(Magnetization(:z), F, 0.3, (AutoDiff(),))).order) + @test occursin( + "reports no `step_size`", why(() -> observed_order(AutoDiff(), F, 0.3, 1)) + ) +end + +@testset "a missing method is not blamed on a package that is loaded" begin + # LinearAlgebra is loaded by this package, so reaching the fallback means the + # ROUTE is incomplete. Telling its author to reinstall points away from that. + msg = why(() -> nth_derivative(LoadedBackendRoute(), F, 0.3, 1)) + @test occursin("is loaded, but no method matched", msg) + @test !occursin("which is not loaded", msg) +end + +@testset "MissingRouteBackend cannot name an extension that does not exist" begin + # It is exported, so an extension author can construct it. Built for a route + # with no `backend_package` it used to render "needs the nothing extension". + @test_throws ArgumentError MissingRouteBackend(CentralDifference(1e-3)) + @test_throws ArgumentError MissingRouteBackend(Richardson(1e-2)) + e = MissingRouteBackend(AutoDiff()) + @test e isa Exception + @test occursin("MissingRouteBackend:", sprint(showerror, e)) + @test occursin("ForwardDiff", sprint(showerror, e)) +end diff --git a/test/relations/test_derivation_routes.jl b/test/relations/test_derivation_routes.jl index d0165ac..b09fec9 100644 --- a/test/relations/test_derivation_routes.jl +++ b/test/relations/test_derivation_routes.jl @@ -10,6 +10,14 @@ using AbstractQAtlas using AbstractQAtlas: _disagreement, derivation_steps, typed_derivation_steps + +why(f) = + try + f() + "" + catch e + sprint(showerror, e) + end using Test: @test, @test_throws, @testset slope(c) = 2 * c / 6 # CFTEntanglementSlope: dS/dlnℓ = ncuts·c/6, ncuts = 2 @@ -91,13 +99,21 @@ end @test nameof(typeof(threw.relation)) === :FreeEnergyFromZ @test occursin("DomainError", threw.error) @test threw.value === nothing + # The row DISPLAYS as a failure. Printing `r.value` unconditionally would show + # `nothing`, and the refusal message is built from `string.(rows)`. + @test occursin("THREW", string(threw)) + @test occursin("DomainError", string(threw)) + # Neither state and both states are unrepresentable, so no consumer's + # `error === nothing` branch can be wrong about what `value` holds. + @test_throws ArgumentError DerivationRouteRow(threw.relation, Any[], nothing, nothing) + @test_throws ArgumentError DerivationRouteRow(threw.relation, Any[], 1.0, "boom") msg = try derive_crosschecked(FreeEnergy, impossible) "" catch e sprint(showerror, e) end - @test occursin("raised on this data", msg) + @test occursin("never got to disagree", msg) @test occursin("FreeEnergyFromZ", msg) # A relation merely declining to be solved for a slot is NOT a broken route, or # every ordinary call would refuse. The consistent bag still passes. @@ -261,3 +277,31 @@ end @test total > 300 # the sweep reached the registry, not two rows @test isempty(leaked) end + +@testset "a guard on the solver's probe must not make a slot unreachable" begin + # `solve` probes its target at 0, 1, 2. A relation guarding one of those points + # then refuses for EVERY input, and the refusal names a value the caller never + # supplied. Both shapes below did that, and the fix is the closed form each + # relation already had in prose. + # + # `EntanglementSpectrumCorrelation` is `ε - log((1-ζ)/ζ)`: at the probe ζ = 2 + # the argument of `log` is -0.5. Its docstring carried `ζ = 1/(e^ε + 1)` already. + @test derive(:ζ; ε=0.7) ≈ 1 / (exp(0.7) + 1) + @test derive_crosschecked(:ζ; ε=0.7) ≈ 1 / (exp(0.7) + 1) + @test isempty([r for r in derivation_routes(:ζ; ε=0.7) if r.error !== nothing]) + + # The slope relations guard `ncuts = 0`, which is exactly the first probe. + @test derive(:ncuts; dS_dlogℓ=slope(0.5), c=0.5) ≈ 2 + @test derive_crosschecked(:ncuts; dS_dlogℓ=slope(0.5), c=0.5) ≈ 2 + @test isempty([ + r for + r in derivation_routes(:ncuts; dS_dlogℓ=slope(0.5), c=0.5) if r.error !== nothing + ]) + # The guard still holds, now read off the ANSWER rather than the probe, and it + # reaches the caller. Through `derive` it does not: that verb drops the route + # and reports the target unreachable, which is the difference this layer makes. + @test occursin("ncuts = 0", only(derivation_routes(:ncuts; dS_dlogℓ=0.0, c=0.5)).error) + @test occursin("ncuts = 0", why(() -> derive_crosschecked(:ncuts; dS_dlogℓ=0.0, c=0.5))) + @test occursin("c = 0", why(() -> derive_crosschecked(:ncuts; dS_dlogℓ=0.3, c=0.0))) + @test occursin("not reachable", why(() -> derive(:ncuts; dS_dlogℓ=0.0, c=0.5))) +end From a1f20bdf0d7066cb3318d92564c017d78f6ef5a5 Mon Sep 17 00:00:00 2001 From: sotashimozono Date: Tue, 15 Sep 2026 12:27:41 +0000 Subject: [PATCH 5/6] A worked example, run by the test suite The machinery had one-line fragments in docstrings and fixtures in tests, and nothing that goes from a measurement to a conclusion. `examples/` held only a `.keep`. `examples/critical_entanglement.jl` reads a central charge off entanglement it computes itself. A critical free-fermion chain's block correlation matrix is a closed form, so nothing is pasted in from an external run and no non-universal constant is imported: the slope through two block sizes carries none, which is also why two sizes and not one (each logarithmic form has a constant that can absorb any `c`). | the same measurement, entered three ways | `c`, exact value 1 | |---|---| | nats, as the calculation produced them | 1.0001 | | the same numbers in bits, undeclared | 1.4429 | | the same numbers in bits, declared `Bits` | 1.0001 | 1.4429 is `1/ln 2`, so the failure the convention layer exists for is legible without being explained. The 1e-4 gap from `c = 1` is the finite-block correction, not a tuned fixture. `test/core/test_examples.jl` INCLUDES the script rather than restating it, so the two cannot drift, and asserts against the values the physics fixes (`1` and `1/ln 2`) rather than the digits the script prints. It checks that the undeclared answer lands on `1/ln 2` specifically, not merely that it misses 1: a wrong answer that missed by something else would otherwise pass for the same reason. Mutation: making the conversion a no-op, or making `declared_convention` find no cover, each fails 2 assertions. The script is a module. Every test file is included into one namespace, and `block_entropy` plus two block sizes at top level would be in all of them. That is not hypothetical: `ParametricProbeQuantity{I}`, added to `test/core/test_conventions.jl` last round, leaks into `subtypes(AbstractQuantity)` and fails `test/core/test_invariants.jl`'s "every concrete quantity is instantiable" sweep, but ONLY when the two files share a process, which no single-file run shows. Fixed the way the package fixes it for `Energy{G}`, with a default parameter. Co-Authored-By: Claude Opus 5 (1M context) --- examples/critical_entanglement.jl | 87 +++++++++++++++++++++++++++++++ test/core/test_conventions.jl | 5 ++ test/core/test_examples.jl | 31 +++++++++++ 3 files changed, 123 insertions(+) create mode 100644 examples/critical_entanglement.jl create mode 100644 test/core/test_examples.jl diff --git a/examples/critical_entanglement.jl b/examples/critical_entanglement.jl new file mode 100644 index 0000000..52037f1 --- /dev/null +++ b/examples/critical_entanglement.jl @@ -0,0 +1,87 @@ +# Reading a central charge off measured entanglement, and what a convention +# declaration buys. +# +# The numbers are not pasted in from anywhere: a critical free-fermion chain's +# block correlation matrix is a closed form, so the entropies below are computed +# here and the central charge that comes out is a measurement, not a fixture. Its +# distance from the exact `c = 1` is the finite-block correction, which is why +# `test/core/test_examples.jl` checks it to 1e-3 and not to machine precision. +# +# Wrapped in a module because `test/core/test_examples.jl` includes this file, and +# every test file is included into one namespace: `block_entropy` and a couple of +# block sizes at top level would be in every other test file's way. +# +# Run: julia --project=. examples/critical_entanglement.jl + +module CriticalEntanglementExample + +using AbstractQAtlas +using LinearAlgebra: eigvals, Symmetric + +export block_entropy, central_charges + +""" + correlation_matrix(ℓ) -> Symmetric + +The `ℓ x ℓ` block of the half-filled critical free-fermion chain's two-point +function, `C_jk = sin(π(j-k)/2) / (π(j-k))`, `C_jj = 1/2`. +""" +function correlation_matrix(ℓ::Integer) + return Symmetric([ + j == k ? 0.5 : sin(π * (j - k) / 2) / (π * (j - k)) for j in 1:ℓ, k in 1:ℓ + ]) +end + +"Entanglement entropy of a block of `ℓ` sites, in nats." +function block_entropy(ℓ::Integer) + return free_fermion_entanglement_entropy(eigvals(correlation_matrix(ℓ))) +end + +""" + central_charges(small = 16, large = 64) -> NamedTuple + +`c` read off the same measurement entered three ways: in nats, in bits with +nothing declared, and in bits declared. + +Two block sizes, because the charge is not falsifiable from one: the logarithmic +forms each carry a non-universal constant that can absorb any `c`, and the slope +through two blocks carries none. The middle entry is the failure the convention +layer exists for, and it is wrong by exactly the base nobody mentioned. +""" +function central_charges(small::Integer=16, large::Integer=64) + a, b = Region((1:small)...), Region((1:large)...) + span = log(large) - log(small) + slope(bg) = (bg[entanglement_entropy(b)] - bg[entanglement_entropy(a)]) / span + pack(f) = (entanglement_entropy(a) => f(small), entanglement_entropy(b) => f(large)) + # Read out of the bag, so a declared convention reaches the slope through it. + c(bg) = derive_crosschecked(:c; dS_dlogℓ=slope(bg), ncuts=2) + in_bits(ℓ) = block_entropy(ℓ) / log(2) + return ( + nats=c(bag(pack(block_entropy)...)), + bits_undeclared=c(bag(pack(in_bits)...)), + bits_declared=c( + bag(conventions(AbstractEntanglementMeasure => Bits), pack(in_bits)...) + ), + ) +end + +end # module CriticalEntanglementExample + +if abspath(PROGRAM_FILE) == @__FILE__ + let cs = CriticalEntanglementExample.central_charges() + println("c from blocks of 16 and 64 sites, exact value 1:") + println(" nats, as the calculation produced them : ", round(cs.nats; digits=4)) + println( + " the same numbers in bits, undeclared : ", + round(cs.bits_undeclared; digits=4), + ) + println( + " which is 1/ln2 = ", + round(1 / log(2); digits=4), + ", the base it was never told", + ) + println( + " the same numbers in bits, declared : ", round(cs.bits_declared; digits=4) + ) + end +end diff --git a/test/core/test_conventions.jl b/test/core/test_conventions.jl index 8893cf7..8531553 100644 --- a/test/core/test_conventions.jl +++ b/test/core/test_conventions.jl @@ -19,6 +19,11 @@ AbstractQAtlas.convert_convention(::WholeUnits, ::HalfUnits, ::Type, v) = 2v # A parametric quantity, which is where a supertype WALK loses the declaration. struct ParametricProbeQuantity{I} <: AbstractQuantity end +# A default parameter, as the package's own parametric quantities carry: without +# one, `test/core/test_invariants.jl`'s reflection sweep over every concrete +# `AbstractQuantity` leaf cannot build this and goes red, but only when the two +# files land in the same shard. +ParametricProbeQuantity() = ParametricProbeQuantity{:probe}() AbstractQAtlas.canonical_convention(::Type{<:ParametricProbeQuantity}) = WholeUnits() # Three covers of one quantity where two are unrelated to each other and the third diff --git a/test/core/test_examples.jl b/test/core/test_examples.jl new file mode 100644 index 0000000..21a02b6 --- /dev/null +++ b/test/core/test_examples.jl @@ -0,0 +1,31 @@ +# The shipped examples, run and checked. +# +# An example nobody runs is worse than none: it reads as a promise and rots +# silently. This includes the script rather than restating it, so the two cannot +# disagree, and asserts against the EXACT values the physics fixes rather than +# against the digits the script happens to print. + +using AbstractQAtlas +using Test: @test, @testset + +include(joinpath(@__DIR__, "..", "..", "examples", "critical_entanglement.jl")) +using .CriticalEntanglementExample: block_entropy, central_charges + +@testset "examples/critical_entanglement.jl" begin + cs = central_charges() + # The oracle is `c = 1` for a free-fermion chain, not a recorded output. The + # gap is the finite-block correction at ℓ = 16, 64, which is why this is 1e-3. + @test cs.nats ≈ 1 atol = 1e-3 + @test cs.bits_declared ≈ 1 atol = 1e-3 + # Undeclared bits are wrong by exactly the base, and the test says which + # number it is rather than just "not 1": a wrong answer that happens to miss + # by something else would otherwise pass for the same reason. + @test cs.bits_undeclared ≈ 1 / log(2) atol = 1e-3 + @test !isapprox(cs.bits_undeclared, 1; atol=1e-3) + # Declaring the convention has to change the answer, not merely be accepted. + @test cs.bits_declared ≈ cs.nats atol = 1e-12 + + # The entropies themselves are a measurement, so pin that they grow with the + # block rather than pinning digits a BLAS version can move. + @test block_entropy(64) > block_entropy(16) > block_entropy(8) > 0 +end From ec35394cdd79dd1f5162af875a7aa24df4d135b7 Mon Sep 17 00:00:00 2001 From: sotashimozono Date: Tue, 15 Sep 2026 12:41:36 +0000 Subject: [PATCH 6/6] Guard the reserved vocabulary from the source side `_route_declined` classifies by message prefix, so a relation guard that borrows `solve:` is silently SKIPPED rather than reported. The registry sweep cannot see that direction: a skipped route leaves no row to inspect, so it never enters the set the sweep examines. One guard in `scaling.jl` had borrowed the prefix, and a reader caught it, not the test. Read off the source, since the claim is about what future authors write: no `"solve: ` literal outside `relations/interface.jl`, with a positive control that the vocabulary really is used in the file that owns it, so the assertion cannot pass by looking in the wrong place. Mutation: putting the prefix back on `PseudocriticalWidthScaling`'s guard fails it. Co-Authored-By: Claude Opus 5 (1M context) --- test/relations/test_derivation_routes.jl | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/relations/test_derivation_routes.jl b/test/relations/test_derivation_routes.jl index b09fec9..9d17b89 100644 --- a/test/relations/test_derivation_routes.jl +++ b/test/relations/test_derivation_routes.jl @@ -305,3 +305,28 @@ end @test occursin("c = 0", why(() -> derive_crosschecked(:ncuts; dS_dlogℓ=0.3, c=0.0))) @test occursin("not reachable", why(() -> derive(:ncuts; dS_dlogℓ=0.0, c=0.5))) end + +@testset "`solve:` is the framework's vocabulary, and only the framework's" begin + # `_route_declined` classifies by message prefix, so a relation guard that + # borrows `solve:` is silently SKIPPED instead of reported. The sweep above + # cannot see that direction: a skipped route leaves no row to inspect. One + # guard in `scaling.jl` had borrowed it, and only a reader caught that. + # + # Read off the source, because the claim is about what future authors write. + dir = joinpath(@__DIR__, "..", "..", "src", "relations") + offenders = String[] + for f in sort(readdir(dir)) + endswith(f, ".jl") && f != "interface.jl" || continue + text = replace(read(joinpath(dir, f), String), "\r\n" => "\n") + for (i, line) in enumerate(split(text, "\n")) + occursin("\"solve: ", line) && push!(offenders, "$f:$i") + end + end + @test isempty(offenders) + # The positive control: the vocabulary IS used, in the one file that owns it. + iface = replace( + read(joinpath(@__DIR__, "..", "..", "src", "relations", "interface.jl"), String), + "\r\n" => "\n", + ) + @test count(!isempty, [m.match for m in eachmatch(r"\"solve: ", iface)]) >= 5 +end