From 82822bf31b265cf203ac54f26c32c77e3a3e77fb Mon Sep 17 00:00:00 2001 From: sotashimozono Date: Tue, 15 Sep 2026 07:03:23 +0000 Subject: [PATCH] Declare what a project's numbers are written in, and convert on entry A VariableKey pins WHICH quantity and WHERE. It does not pin how the number is written, so relations/entanglement.jl's "Entropies here are in NATS" is prose that nothing reads, and an app counting bits hands its entropies straight to a relation written in nats. Three parts, kept separate because different people declare them: canonical_convention(Q) says what the relations here are written in, conventions(...) says what a project's values are written in, and bag(cs, ...) converts between them on entry, so a relation never sees the project's convention and there is no call that skips the conversion. The axis is a type, not an entry in a list: a new convention is a subtype plus a convert_convention method, with nothing to change in this package. The test proves that with a quantity, an axis and a conversion all defined outside src/. Shipped axis: LogBase, with Nats and Bits. Twelve of the sixteen entanglement measures declare it, selected by whether the definition in this package contains a logarithm: | canonical_convention | | |---|---| | Nats | VonNeumann, FermionicEE, Renyi, MutualInformation, ConditionalEntropy, RelativeEntropy, MeasurementEntropy, MarkovEntropy, TripartiteInformation, TopologicalEE, LogNegativity, Page | | nothing | Tsallis, Concurrence, Tangle, ThreeTangle | The four have no logarithm to take a base of (Tsallis is (1 - Tr p^q)/(q - 1), and the other three are wavefunction amplitudes and their squares), which is why the declaration is per quantity rather than by supertype. What it changes, on region_report's maximum-entropy bound at local_dim = 2, two qubits, ceiling 2 bits and 2 ln 2 = 1.386 nats: | S | undeclared | declared Bits | |---|---|---| | 1.75, legal in bits | FAIL, slack -0.3637 | pass, slack +0.1733 | | 2.50, over in bits too | FAIL, slack -1.1137 | FAIL, slack -0.3466 | The second row is the control: declaring the convention does not make an impossible entropy pass. Mutation: replacing the LogBase conversion with `return v` fails 4 assertions across 3 testsets, including both rows above. Co-Authored-By: Claude Opus 5 (1M context) --- Project.toml | 2 +- src/AbstractQAtlas.jl | 1 + src/core/conventions.jl | 235 ++++++++++++++++++++++++++++++++++ src/relations/interface.jl | 23 ++++ test/core/test_conventions.jl | 113 ++++++++++++++++ 5 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 src/core/conventions.jl create mode 100644 test/core/test_conventions.jl diff --git a/Project.toml b/Project.toml index f23a8539..3a49207d 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "AbstractQAtlas" uuid = "dcea2817-62f8-4a75-b498-1b50a9ed1e4d" -version = "0.7.14" +version = "0.7.15" authors = ["sota shimozono "] [deps] diff --git a/src/AbstractQAtlas.jl b/src/AbstractQAtlas.jl index 0dfe6d52..8cc95b97 100644 --- a/src/AbstractQAtlas.jl +++ b/src/AbstractQAtlas.jl @@ -62,6 +62,7 @@ include("core/distributions.jl") include("core/fields.jl") include("core/relation_variables.jl") # the RelationVariable layer (type-keyed variables) include("core/region.jl") # the Region set layer (entanglement support, §5) +include("core/conventions.jl") # how a value is written, and the conversion into the one the relations use # structure — model-independent definitional correspondences between the # core quantities: transition classification, the critical diff --git a/src/core/conventions.jl b/src/core/conventions.jl new file mode 100644 index 00000000..8944874a --- /dev/null +++ b/src/core/conventions.jl @@ -0,0 +1,235 @@ +# core/conventions.jl: the convention layer. +# +# A `VariableKey` pins WHICH quantity and WHERE it is evaluated. It does not pin +# how the number is written, so one type at one support is still two different +# values when one calculation counts bits and the other nats. Today that is +# prose: relations/entanglement.jl's header says "Entropies here are in NATS" +# and nothing reads it. +# +# Three parts, kept separate because they are declared by different people: +# the owner of a quantity says what the relations are written in +# (`canonical_convention`), a project says what ITS numbers are written in +# (`conventions`), and `bag` converts between them on entry so an undeclared +# value can never reach a relation. +# +# Conversion is opt-in per convention pair and refuses otherwise, because most +# pairs are not a rescaling: Pauli and spin-1/2 operators move each term of a +# Hamiltonian by a different factor, and a disorder average is not a rescaled +# typical value. + +""" + Convention + +Parent for the ways one quantity's value can be written: the base of the +logarithm in an entropy, the normalisation of the operators an energy is built +from, the statistic a disorder average reports. + +Open by design. A new axis is a new subtype plus a [`convert_convention`](@ref) +method and needs no change to this file, which is why the package ships the +protocol rather than a list of axes. A quantity with two independent axes gets +one subtype carrying both, since [`canonical_convention`](@ref) answers with a +single value. +""" +abstract type Convention end +export Convention + +""" + canonical_convention(Q::Type) -> Union{Convention,Nothing} + +The convention this package's relations are written in for `Q`, or `nothing` +when `Q` has no convention axis at all. + +`nothing` is the default and is deliberately not filled in by supertype: the +Tsallis entropy is `(1 - Tr ρ^q)/(q - 1)`, which carries no logarithm, so +declaring a base for every [`AbstractEntanglementMeasure`](@ref) would give it +an axis it does not have. Same opt-in reasoning as +[`obeys_entropy_inequalities`](@ref). +""" +canonical_convention(::Type{<:RelationVariable}) = nothing +export canonical_convention + +""" + convert_convention(to::Convention, from::Convention, Q::Type, v) + +`v`, written in `from`, expressed in `to`. + +Equal conventions return `v` untouched, so an exact-arithmetic value stays +exact. Anything else refuses unless a method says the pair converts: two +conventions that are not a rescaling have no conversion, and passing `v` +through there would hand a relation a number from the wrong statistic. +""" +function convert_convention(to::Convention, from::Convention, @nospecialize(Q::Type), v) + to == from && return v + return error( + "convert_convention: no conversion from $from to $to for $Q. Define a " * + "`convert_convention` method if the two are a rescaling of each other; " * + "if they are not, the values have to be recomputed rather than converted.", + ) +end +export convert_convention + +# ─── The log-base axis ─────────────────────────────────────────────────── + +""" + LogBase(base::Real) <: Convention + +The base of the logarithm an entropy is measured with: [`Nats`](@ref) is +`LogBase(ℯ)` and [`Bits`](@ref) is `LogBase(2)`. + +A coefficient multiplying a logarithm (`c`, `c̃`) is unchanged by the base, since +it rescales entropy and logarithm alike. An additive constant (`c₁`, `ln g`) and +a bare difference of entropies are not, and that is where a transcribed formula +silently gains or loses a factor of `ln 2`. +""" +struct LogBase <: Convention + base::Float64 + function LogBase(base::Real) + base > 0 && base != 1 || + throw(ArgumentError("LogBase: base must be positive and not 1; got $base")) + return new(Float64(base)) + end +end +export LogBase + +""" + Nats + +`LogBase(ℯ)`, the convention every entropy relation in this package is written in. +""" +const Nats = LogBase(ℯ) +export Nats + +""" + Bits + +`LogBase(2)`, `S = -Tr ρ log₂ ρ`, which is what the entanglement literature +usually counts. +""" +const Bits = LogBase(2) +export Bits + +function convert_convention(to::LogBase, from::LogBase, @nospecialize(Q::Type), v) + to == from && return v + return v * (log(from.base) / log(to.base)) +end + +# The twelve entanglement measures whose ABQ definition contains a logarithm, or +# is an additive combination of ones that do. The four that are absent are absent +# because their defining relation has no logarithm to take a base of: +# `TsallisEntropy` is `(1 - Tr ρ^q)/(q - 1)`, `Concurrence` is a wavefunction +# amplitude, and `Tangle`/`ThreeTangle` are built from it by squaring. +for Q in ( + :VonNeumannEntropy, + :FermionicEntanglementEntropy, + :RenyiEntropy, + :MutualInformation, + :ConditionalEntropy, + :RelativeEntropy, + :MeasurementEntropy, + :MarkovEntropy, + :TripartiteInformation, + :TopologicalEntanglementEntropy, + :LogarithmicNegativity, + :PageEntropy, +) + @eval canonical_convention(::Type{$Q}) = Nats +end + +# ─── What a project declares ───────────────────────────────────────────── + +""" + ConventionSet + +What one project's numbers are written in, as a map from quantity type to +[`Convention`](@ref). Build one with [`conventions`](@ref) and hand it to +[`bag`](@ref). + +Declared once next to the calculation, not repeated at each call, because the +convention is a property of how the numbers were produced. +""" +struct ConventionSet + declared::Dict{Type,Convention} +end +export ConventionSet + +""" + conventions(pairs...) -> ConventionSet + +Declare what this project's values are written in: + +```julia +conventions(VonNeumannEntropy => Bits) # this one quantity +conventions(AbstractEntanglementMeasure => Bits) # every entropy that has a base +``` + +A key may be a concrete type, an INSTANCE (reduced to its type), or an abstract +supertype. The supertype form is the usable one when a bag holds several +entropies: it reaches every subtype that declares a +[`canonical_convention`](@ref) and skips the ones that have no such axis, so +naming `AbstractEntanglementMeasure` does not claim a base for the Tsallis +entropy. Naming a concrete type that has no axis is an error, since that is a +claim about that type. + +Lookup is most-specific-first, so a concrete entry overrides a supertype entry. +""" +function conventions(pairs::Pair...) + d = Dict{Type,Convention}() + for (k, c) in pairs + T = k isa Type ? k : typeof(k) + T <: RelationVariable || + error("conventions: $T is not a relation variable, so no relation reads it.") + c isa Convention || + error("conventions: the value for $T is $(typeof(c)), not a Convention.") + haskey(d, T) && error( + "conventions: duplicate key $T. A quantity is written in one " * + "convention; two entries for it cannot both be what the numbers are.", + ) + # A concrete type with no axis is a claim about that type, so it is + # refused here rather than silently ignored at conversion time. + if isconcretetype(T) && canonical_convention(T) === nothing + error( + "conventions: $T declares no convention axis, so $c cannot be " * + "converted away from. If $T does have one, give it a " * + "`canonical_convention` method; if the declaration was meant for " * + "its siblings, key it on the supertype instead.", + ) + end + d[T] = c + end + return ConventionSet(d) +end +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`. +""" +function declared_convention(cs::ConventionSet, @nospecialize(Q::Type)) + T = Q + while T !== Any + haskey(cs.declared, T) && return cs.declared[T] + T = supertype(T) + end + return nothing +end +export declared_convention + +""" + in_canonical_convention(cs::ConventionSet, Q::Type, v) + +`v` rewritten in the convention this package's relations use for `Q`. + +Returns `v` untouched when `cs` says nothing about `Q`, or when `Q` has no +convention axis, which is what makes the layer opt-in: a project that declares +nothing gets the behaviour it had before. +""" +function in_canonical_convention(cs::ConventionSet, @nospecialize(Q::Type), v) + from = declared_convention(cs, Q) + from === nothing && return v + to = canonical_convention(Q) + to === nothing && return v + return convert_convention(to, from, Q, v) +end +export in_canonical_convention diff --git a/src/relations/interface.jl b/src/relations/interface.jl index f7204051..aa0582bc 100644 --- a/src/relations/interface.jl +++ b/src/relations/interface.jl @@ -1041,6 +1041,29 @@ function bag(pairs::Pair...) end export bag +""" + bag(cs::ConventionSet, pairs...) -> Bag + +A bag whose values are converted, on entry, out of the conventions `cs` declares +and into the ones this package's relations are written in. + +This is the only door: a relation never sees the project's convention, so there +is no call that silently skips the conversion. A quantity `cs` says nothing +about, or one with no convention axis, is stored unchanged. + +```julia +cs = conventions(AbstractEntanglementMeasure => Bits) +bag(cs, VonNeumannEntropy() => 3.0) # stored as 3.0 * log(2), in nats +``` +""" +function bag(cs::ConventionSet, pairs::Pair...) + b = bag(pairs...) + for key in collect(keys(b)) + b[key] = in_canonical_convention(cs, key.type, b[key]) + end + return b +end + # Look up one identity slot in a bag, PRESENCE-aware: `Some(value)` if the key is # present (even when the stored value is itself `nothing`), else `nothing` — so a # truly absent key is never confused with a present-but-`nothing` one (issue C2). diff --git a/test/core/test_conventions.jl b/test/core/test_conventions.jl new file mode 100644 index 00000000..b3a90e37 --- /dev/null +++ b/test/core/test_conventions.jl @@ -0,0 +1,113 @@ +# The convention layer: what a project's numbers are written in, and the +# conversion into what the relations here are written in. +# +# The measurement that justifies the layer is the `region_report` pair below. +# `S = 1.75` on two qubits is legal in bits (the ceiling is 2) and impossible in +# nats (the ceiling is 2 ln 2 = 1.386), so a bits-counting calculation handed to +# this package raw is reported as violating a physical bound it does not violate. + +using AbstractQAtlas +using Test: @test, @test_throws, @testset + +# A quantity, an axis and a conversion, none of them in `src/`: the claim that +# the layer is a protocol rather than a list of axes is a test, not a docstring. +struct ConventionProbeQuantity <: AbstractQuantity end +struct WholeUnits <: Convention end +struct HalfUnits <: Convention end +AbstractQAtlas.canonical_convention(::Type{ConventionProbeQuantity}) = WholeUnits() +AbstractQAtlas.convert_convention(::WholeUnits, ::HalfUnits, ::Type, v) = 2v + +@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. + for Q in ( + VonNeumannEntropy, + FermionicEntanglementEntropy, + RenyiEntropy, + MutualInformation, + ConditionalEntropy, + RelativeEntropy, + MeasurementEntropy, + MarkovEntropy, + TripartiteInformation, + TopologicalEntanglementEntropy, + LogarithmicNegativity, + PageEntropy, + ) + @test canonical_convention(Q) === Nats + end + # `(1 - Tr ρ^q)/(q - 1)` has no logarithm, and neither has a concurrence or + # a tangle built by squaring one. A supertype declaration would give all four + # a base they do not have. + for Q in (TsallisEntropy, Concurrence, Tangle, ThreeTangle) + @test canonical_convention(Q) === nothing + end + @test canonical_convention(Temperature) === nothing +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 + ) + # 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 conventions(AbstractEntanglementMeasure => Bits) isa ConventionSet +end + +@testset "lookup is most specific first" begin + cs = conventions(AbstractEntanglementMeasure => Bits, VonNeumannEntropy => Nats) + @test declared_convention(cs, VonNeumannEntropy) === Nats + @test declared_convention(cs, RenyiEntropy) === Bits + @test declared_convention(cs, Temperature) === nothing +end + +@testset "conversion" begin + @test convert_convention(Nats, Bits, VonNeumannEntropy, 1.0) ≈ log(2) + @test convert_convention(Bits, Nats, VonNeumannEntropy, log(2)) ≈ 1.0 + # Equal conventions never touch the value, so an exact input stays exact. + v = convert_convention(Nats, Nats, VonNeumannEntropy, 3//2) + @test v === 3//2 + # Two axes that are not a rescaling of each other have no conversion, and + # returning the value unchanged there is the failure this refuses. + @test_throws ErrorException convert_convention( + Nats, HalfUnits(), VonNeumannEntropy, 1.0 + ) + @test_throws ArgumentError LogBase(1) + @test_throws ArgumentError LogBase(0) +end + +@testset "bag converts on entry, and only what is declared" begin + cs = conventions(AbstractEntanglementMeasure => Bits) + b = bag(cs, VonNeumannEntropy() => 3.0, TsallisEntropy(2) => 1.0, Temperature => 2.0) + @test b[VariableKey(VonNeumannEntropy)] ≈ 3.0 * log(2) + @test b[VariableKey(TsallisEntropy, OrderSupport(2.0))] == 1.0 # no axis + @test b[VariableKey(Temperature)] == 2.0 # not declared + # An undeclared project is the behaviour this package had before the layer. + @test bag(VonNeumannEntropy() => 3.0)[VariableKey(VonNeumannEntropy)] == 3.0 + # The plain constructor's refusals still hold through this door. + @test_throws ErrorException bag(cs, VonNeumannEntropy() => nothing) + @test_throws ErrorException bag( + cs, VonNeumannEntropy() => 1.0, VonNeumannEntropy() => 2.0 + ) +end + +@testset "a new axis needs no change to the package" begin + cs = conventions(ConventionProbeQuantity => HalfUnits()) + @test bag(cs, ConventionProbeQuantity() => 2.5)[VariableKey(ConventionProbeQuantity)] == + 5.0 +end + +@testset "the declaration flips a physical-bound verdict, in both directions" begin + cs = conventions(AbstractEntanglementMeasure => Bits) + verdict(b) = all(r -> r.pass, region_report(b; local_dim=2)) + # Two qubits: the ceiling is 2 bits, and 2 ln 2 = 1.386 nats. + @test !verdict(bag(entanglement_entropy(1, 2) => 1.75)) # false alarm + @test verdict(bag(cs, entanglement_entropy(1, 2) => 1.75)) # declared away + # The control: 2.5 bits is over the ceiling in bits too, so declaring the + # convention must not make it pass. + @test !verdict(bag(entanglement_entropy(1, 2) => 2.5)) + @test !verdict(bag(cs, entanglement_entropy(1, 2) => 2.5)) +end