Skip to content

Run every route to a target, not the first one the registry reaches - #163

Merged
sotashimozono merged 6 commits into
mainfrom
feat/all-routes
Sep 15, 2026
Merged

sotashimozono merged 6 commits into
mainfrom
feat/all-routes

Conversation

@sotashimozono

Copy link
Copy Markdown
Member

Stacked on #162, which is stacked on #161. Merge order: #161, #162, then this.

derive forward-chains and takes the first relation that produces the target. Measured on the current registry:

door outputs with MORE THAN ONE producing relation worst
symbol 219 66 19 routes (:β)
typed 84 38 15 routes (InverseTemperature)

Which one runs is iteration order. 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 is the vacuous agreement, and it has two levels

Both are guarded, and each guard is pinned by a mutation that removes it.

1. A supplied target lets the closure manufacture its own inputs. With only c and ncuts supplied, the closure builds dS_dlogℓ, dS_dlogchord and ΔS from c, and three routes hand the supplied number back:

CFTEntanglementChordSlope:    {dS_dlogchord, ncuts} → 0.9
CFTEntanglementSlope:         {dS_dlogℓ, ncuts}     → 0.9
HalvedChainEntropyDifference: {ΔS, ncuts}           → 0.9

So the target is held out before the closure runs, as a given, and (β and T being 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, rebuilds the chord slope from it, and reads c back off that:

derivation_routes(:c; dS_dlogℓ, ncuts) rows values
as written 1 0.5
without avoid 3 0.5, 0.5, 0.5

Three rows, one measurement, perfect agreement. The chains therefore take avoid and never produce the target at all.

min_routes is how to ask that a cross-check actually 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

Typed door, F reachable both through Z and through the Legendre transform, 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

derive returning the correct value from good data while silently ignoring an input that contradicts it is the case this exists for.

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 first pass of this test file let two of these through. The typed assertions passed for reasons unrelated to what they claimed, and were rewritten against fixtures that can fail: FreeEnergyLegendre produces both F and S, and KelvinRelation produces T from a Peltier coefficient that β can build.

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 removed or changed, so 0.7.16 -> 0.7.17.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📚 Docs preview: https://qatlashub.github.io/AbstractQAtlas.jl/previews/PR163/

(updates on each push to this PR)

@codecov

codecov Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.33333% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/derivative_routes.jl 97.29% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

sotashimozono and others added 6 commits September 15, 2026 12:57
`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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
`_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) <noreply@anthropic.com>
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 `<Relation>: ` 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
`_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) <noreply@anthropic.com>
@sotashimozono
sotashimozono changed the base branch from feat/derivative-routes to main September 15, 2026 12:57
@sotashimozono
sotashimozono merged commit 4ec9dab into main Sep 15, 2026
26 checks passed
@sotashimozono
sotashimozono deleted the feat/all-routes branch September 15, 2026 13:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant