Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ jobs:
fi

test:
name: runtests ${{ matrix.version }} - ${{ matrix.os }}
# Branch protection lists the single-threaded job names as required checks, so those must
# stay byte-identical; the multi-threaded leg gets a distinct name and is purely additive.
name: runtests ${{ matrix.version }} - ${{ matrix.os }}${{ matrix.threads != '1' && format(' ({0} threads)', matrix.threads) || '' }}
needs: changes
if: needs.changes.outputs.julia == 'true'
runs-on: ${{ matrix.os }}
Expand All @@ -99,8 +101,17 @@ jobs:
- '1.x' # latest (currently 1.12)
os:
- ubuntu-latest
threads:
- '1'
include:
# Threads.@threads executes serially at one thread, so a single-threaded matrix never
# exercises threaded scheduling; this leg covers real concurrent execution.
- version: '1.11'
os: ubuntu-latest
threads: '4'

env:
JULIA_NUM_THREADS: ${{ matrix.threads }}
DEPOT_PATHS: |
~/.julia/artifacts
~/.julia/packages
Expand Down
26 changes: 16 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,22 @@ GPEC (Generalized Perturbed Equilibrium Code, Julia implementation) is a compreh
# Run all tests
julia --project=. -e 'using Pkg; Pkg.activate("."); Pkg.instantiate(); include("test/runtests.jl")'

# Run specific test file
julia --project=. test/runtests.jl test/runtests_solovev.jl

# Available test files:

# - test/runtests_vacuum_julia.jl # Julia vacuum module
# - test/runtests_solovev.jl # Analytical equilibrium
# - test/runtests_ode.jl # ODE integration
# - test/runtests_sing.jl # Singular surface handling
# - test/runtests_fullruns.jl # End-to-end tests
# Run specific test file — the argument is included relative to test/, so pass the bare
# filename, not a path prefixed with test/
julia --project=. test/runtests.jl runtests_sing.jl

# Run the suite multi-threaded (the parallel FM/BVP paths reduce to their serial form at one
# thread, so a single-threaded run never exercises threaded execution)
julia -t 4 --project=. test/runtests.jl

# A few of the available test files (see test/runtests.jl for the full list):

# - test/runtests_vacuum.jl # Vacuum module
# - test/runtests_equil.jl # Equilibrium reconstruction
# - test/runtests_sing.jl # Singular surface handling
# - test/runtests_parallel_integration.jl # Parallel FM integration and BVP Delta'
# - test/runtests_decomposition_invariance.jl # Riccati Δ' chunk-decomposition invariance
# - test/runtests_fullruns.jl # End-to-end tests
```

### Building Documentation
Expand Down
1 change: 1 addition & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ else
include("./runtests_parallel_integration.jl")
include("./runtests_result_struct.jl")
include("./runtests_solve_api.jl")
include("./runtests_decomposition_invariance.jl")
include("./runtests_sing.jl")
include("./runtests_innerlayer.jl")
include("./runtests_tj_analytic.jl")
Expand Down
77 changes: 77 additions & 0 deletions test/runtests_decomposition_invariance.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using Test
using TOML

# Decomposition invariance of the Riccati Δ′ path.
#
# The chunked propagator driver reassociates the fundamental-matrix products whenever the chunk
# decomposition changes, and floating-point products do not reassociate exactly in general — so
# Δ′ reproducibility is a real end-to-end claim, beyond the structural chunk-count invariance
# pinned by the unit tests in runtests_parallel_integration.jl. This file asserts it: the Δ′
# matrix is bit-identical when the same integration is cut into a genuinely different set of
# chunks. Driven through the public solve API so it exercises the production path itself.

const GP_TI = GeneralizedPerturbedEquilibrium

"""
Solve the DIII-D-like deck with the Riccati integrator at a given chunk count (`nchunks = 0` is
the msing-derived auto target) and return the published Δ′ matrix alongside the leading energy
eigenvalue, which is used only as a witness that the two decompositions really differed.
"""
function _solve_at_nchunks(dir::String, nchunks::Int)
inputs = TOML.parsefile(joinpath(dir, "gpec.toml"))
ffs_in = inputs["ForceFreeStates"]
eq_config = GP_TI.Equilibrium.EquilibriumConfig(inputs["Equilibrium"], dir)
equil = GP_TI.Equilibrium.setup_equilibrium(eq_config, nothing)
if GP_TI.Equilibrium.wants_two_pass(eq_config)
mand = GP_TI.ForceFreeStates.rational_psi_nodes(equil; nlow=ffs_in["nn_low"], nhigh=ffs_in["nn_high"])
psi_nodes = GP_TI.Equilibrium.refined_psi_grid(equil; tau=eq_config.psi_accuracy, mandatory=mand)
rerun_input = GP_TI.Equilibrium.build_direct_from_ingest(eq_config, equil.ingest)
equil = GP_TI.Equilibrium.setup_equilibrium(eq_config, rerun_input; override_psi_nodes=psi_nodes)
end

# Every ForceFreeStatesControl key from the deck except the ones the problem or the
# integrator owns: nn_low/nn_high come from `nn`, nchunks and the formalism from the alg.
ctrl_kwargs = Dict(Symbol(k) => v for (k, v) in ffs_in
if !(k in ("nn_low", "nn_high", "nchunks", "integrator")))
ctrl_kwargs[:verbose] = false
ctrl_kwargs[:write_outputs_to_HDF5] = false

wall = GP_TI.Vacuum.WallShapeSettings(; (Symbol(k) => v for (k, v) in inputs["Wall"])...)
prob = GP_TI.EulerLagrangeProblem(equil; nn=ffs_in["nn_low"], wall=wall, dir_path=dir, ctrl_kwargs...)
return GP_TI.solve(prob, GP_TI.ForceFreeStates.Riccati(; nchunks=nchunks))
end

@testset "Decomposition invariance of the Riccati Δ′ path" begin
# The deck whose Δ′ the regression harness pins, and where the BVP Δ′ is well-conditioned
# (Solovev sits near marginal stability and its BVP Δ′ is pathological there).
dir = joinpath(@__DIR__, "..", "examples", "DIIID-like_ideal_example")
auto = _solve_at_nchunks(dir, 0)
@test auto.delta_prime !== nothing
msing = size(auto.delta_prime.matrix, 1)

# The nchunks=0 target, mirroring balance_integration_chunks' internal formula (as
# runtests_parallel_integration.jl does). Invariance is asserted ABOVE this floor only:
# fewer chunks than the msing-derived minimum is not a different decomposition but a
# structurally deficient one (the floor gives the rational-surface crossings room).
auto_target = max(2 * msing + 3, 8 * (msing + 1) + msing)
finer = _solve_at_nchunks(dir, auto_target + 11)
@test finer.delta_prime !== nothing

# Premise check: the two runs must be genuinely different computations, or the equality
# below is vacuous. et[1] is decomposition-SENSITIVE on the unified driver, so its differing
# witnesses that the decompositions differed; a failure here means the chunk steering
# stopped taking effect (or et[1] invariance was restored) and wants a look. Its value is
# pinned by the regression harness, not here.
@test auto.free_boundary !== nothing && finer.free_boundary !== nothing
@test finer.free_boundary.et[1] != auto.free_boundary.et[1]

@test size(finer.delta_prime.matrix) == size(auto.delta_prime.matrix)

# Bit-identical, not approximate: a tolerance would hide exactly the reassociation drift
# this test exists to catch. The element-wise `===` pairs with the whole-matrix `==`
# because NaN === NaN — the `==` is what fails if the computation degrades to NaN.
for j in 1:size(auto.delta_prime.matrix, 1)
@test finer.delta_prime.matrix[j, j] === auto.delta_prime.matrix[j, j]
end
@test finer.delta_prime.matrix == auto.delta_prime.matrix
end
Loading