diff --git a/projects/ExampleMonteCarlo/.gitignore b/projects/ExampleMonteCarlo/.gitignore new file mode 100644 index 0000000..ba39cc5 --- /dev/null +++ b/projects/ExampleMonteCarlo/.gitignore @@ -0,0 +1 @@ +Manifest.toml diff --git a/projects/ExampleMonteCarlo/Project.toml b/projects/ExampleMonteCarlo/Project.toml new file mode 100644 index 0000000..d7630d0 --- /dev/null +++ b/projects/ExampleMonteCarlo/Project.toml @@ -0,0 +1,34 @@ +name = "ExampleMonteCarlo" +uuid = "00000000-0000-4000-8000-000000000003" +version = "0.1.0" +authors = ["Your Name "] + +# COMPUTE env. No plotting backend and no Pinax — those are in `report/`, which reads the same vault +# off disk. That separation is the reason this project can exist at all: the physics packages below +# were each carrying Plots as a hard dependency until it was moved behind an extension, and a +# `load=` of the work module ships whatever it pulls to every worker. + +[deps] +ClassicalMonteCarlo = "faaa8aa3-7fc9-41cf-84af-9d0c24f78989" +DataVault = "23f5f8f6-b4da-40ee-8c72-c53b6c5de94f" +Lattice2D = "e8031020-b7ff-4f1d-bee4-f79aea4cf140" +ParamIO = "938a3ac2-d340-473c-bcf1-88af577e4ccf" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +SweepRunner = "be946ad2-3cb3-4b6e-8f7e-4a5ecc3c255b" + +[sources] +# None of these four is in a public registry yet; ParamIO and DataVault resolve from General. +ClassicalMonteCarlo = {rev = "main", url = "https://github.com/sotashimozono/ClassicalMonteCarlo.jl"} +Lattice2D = {rev = "main", url = "https://github.com/sotashimozono/Lattice2D.jl"} +SweepRunner = {rev = "main", url = "https://github.com/QAtlasHub/SweepRunner.jl"} + +[compat] +DataVault = "0.7" +ParamIO = "0.4" +julia = "1.10" + +[extras] +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[targets] +test = ["Test"] diff --git a/projects/ExampleMonteCarlo/README.md b/projects/ExampleMonteCarlo/README.md new file mode 100644 index 0000000..d8cec93 --- /dev/null +++ b/projects/ExampleMonteCarlo/README.md @@ -0,0 +1,35 @@ +# ExampleMonteCarlo + +Ising on a square lattice, swept over size and temperature. + +``` +configs/ the sweep at three sizes — smoke / debug / production +scripts/ compute.jl (the run), collect.jl (read it back as a table) +batch/ SLURM submission; the only place site-specific lines belong +report/ a SEPARATE environment that draws from the vault +out/ DataVault writes here; gitignored +``` + +```bash +julia --project=. -e 'using Pkg; Pkg.instantiate()' +julia --project=. scripts/compute.jl configs/smoke.toml # two points, seconds +julia --project=. scripts/collect.jl configs/smoke.toml +sbatch batch/run.sh configs/production.toml # the same command, at size +``` + +## What this project shows that the other does not + +**A real work package.** The physics is `ClassicalMonteCarlo` over a `Lattice2D` lattice, both +reached through the `LatticeCore` interface. `run!(…; load=ExampleMonteCarlo)` ships this project's +module to the workers, and everything it reaches travels with it. + +**Why the compute environment has no plotting backend.** It is not hygiene. Both physics packages +carried `Plots` as a hard dependency until it was moved behind an extension; with `load=`, that +would have put a plotting stack on every worker. + +**Why every call is qualified.** `ClassicalMonteCarlo` exports `run!` and so does `SweepRunner`, and +they mean different things — one advances a Markov chain, the other dispatches the sweep. Every +module here is imported as `using X: X`, so neither can be called unqualified by accident. + +The grids crowd `kbT ≈ 2.269`, the exact 2D Ising transition, because a sweep earns its cost when +the interesting thing happens between two of its points. diff --git a/projects/ExampleMonteCarlo/batch/run.sh b/projects/ExampleMonteCarlo/batch/run.sh new file mode 100755 index 0000000..b74f544 --- /dev/null +++ b/projects/ExampleMonteCarlo/batch/run.sh @@ -0,0 +1,14 @@ +#!/bin/bash +#SBATCH --job-name=ising-sweep +#SBATCH --nodes=1 +#SBATCH --ntasks-per-node=16 +#SBATCH --time=02:00:00 +#SBATCH --output=out/log/%x-%j.out +# +# Site-specific lines (partition, account, module load) belong here and nowhere else. +# sbatch batch/run.sh configs/production.toml +set -euo pipefail +CONFIG="${1:-configs/smoke.toml}" +mkdir -p out/log +julia --project=. -e 'using Pkg; Pkg.instantiate()' +julia --project=. scripts/compute.jl "$CONFIG" diff --git a/projects/ExampleMonteCarlo/configs/debug.toml b/projects/ExampleMonteCarlo/configs/debug.toml new file mode 100644 index 0000000..0eadeae --- /dev/null +++ b/projects/ExampleMonteCarlo/configs/debug.toml @@ -0,0 +1,24 @@ +# Ising on a square lattice, swept over size and temperature. The exact transition of the 2D Ising +# model is at kbT/J = 2/ln(1+√2) ≈ 2.269, which is why the grids below crowd there: a sweep is worth +# running when the interesting thing happens between two of its points. +# +# A LIST value is a swept axis; a SCALAR is fixed. In DataKey.params the entries appear under their +# DOTTED names, e.g. "system.kbT". + +[study] +project_name = "ising" +total_samples = 1 +outdir = "out" + +[datavault] +path_keys = ["system.L", "system.kbT"] + +[[paramsets]] + +[paramsets.system] +L = [8, 12] +kbT = [2.0, 2.27, 2.5] + +[paramsets.numerics] +nsteps = 2000 +burn = 1000 diff --git a/projects/ExampleMonteCarlo/configs/production.toml b/projects/ExampleMonteCarlo/configs/production.toml new file mode 100644 index 0000000..656c769 --- /dev/null +++ b/projects/ExampleMonteCarlo/configs/production.toml @@ -0,0 +1,24 @@ +# Ising on a square lattice, swept over size and temperature. The exact transition of the 2D Ising +# model is at kbT/J = 2/ln(1+√2) ≈ 2.269, which is why the grids below crowd there: a sweep is worth +# running when the interesting thing happens between two of its points. +# +# A LIST value is a swept axis; a SCALAR is fixed. In DataKey.params the entries appear under their +# DOTTED names, e.g. "system.kbT". + +[study] +project_name = "ising" +total_samples = 4 +outdir = "out" + +[datavault] +path_keys = ["system.L", "system.kbT"] + +[[paramsets]] + +[paramsets.system] +L = [8, 16, 32] +kbT = [1.6, 1.8, 2.0, 2.15, 2.27, 2.4, 2.6, 2.8, 3.2] + +[paramsets.numerics] +nsteps = 20000 +burn = 5000 diff --git a/projects/ExampleMonteCarlo/configs/smoke.toml b/projects/ExampleMonteCarlo/configs/smoke.toml new file mode 100644 index 0000000..9a418b4 --- /dev/null +++ b/projects/ExampleMonteCarlo/configs/smoke.toml @@ -0,0 +1,24 @@ +# Ising on a square lattice, swept over size and temperature. The exact transition of the 2D Ising +# model is at kbT/J = 2/ln(1+√2) ≈ 2.269, which is why the grids below crowd there: a sweep is worth +# running when the interesting thing happens between two of its points. +# +# A LIST value is a swept axis; a SCALAR is fixed. In DataKey.params the entries appear under their +# DOTTED names, e.g. "system.kbT". + +[study] +project_name = "ising" +total_samples = 1 +outdir = "out" + +[datavault] +path_keys = ["system.L", "system.kbT"] + +[[paramsets]] + +[paramsets.system] +L = [8] +kbT = [2.0, 2.5] + +[paramsets.numerics] +nsteps = 200 +burn = 100 diff --git a/projects/ExampleMonteCarlo/note/README.md b/projects/ExampleMonteCarlo/note/README.md new file mode 100644 index 0000000..5c61426 --- /dev/null +++ b/projects/ExampleMonteCarlo/note/README.md @@ -0,0 +1,4 @@ +# note/ + +Working notes for this project — what was tried, what the numbers looked like, +why a parameter range was chosen. Not documentation for others; a record for you. diff --git a/projects/ExampleMonteCarlo/out/.gitignore b/projects/ExampleMonteCarlo/out/.gitignore new file mode 100644 index 0000000..62ec09e --- /dev/null +++ b/projects/ExampleMonteCarlo/out/.gitignore @@ -0,0 +1,6 @@ +# DataVault writes here. Results are not source; keep the directory, drop the data. +* +!.gitignore +!log/ +log/* +!log/.gitkeep diff --git a/projects/ExampleMonteCarlo/out/log/.gitkeep b/projects/ExampleMonteCarlo/out/log/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/projects/ExampleMonteCarlo/report/Project.toml b/projects/ExampleMonteCarlo/report/Project.toml new file mode 100644 index 0000000..31e8167 --- /dev/null +++ b/projects/ExampleMonteCarlo/report/Project.toml @@ -0,0 +1,20 @@ +# report/ — the render environment. SEPARATE from the compute env one level up: that one runs on a +# cluster and carries no plotting backend, while this reads the same on-disk vault and draws it. +# The reduction is not duplicated here — it is `../src/ExampleMonteCarlo.jl`, and both sides call it. +# +# No `name`/`uuid`: an environment, not a package. + +[deps] +DataVault = "23f5f8f6-b4da-40ee-8c72-c53b6c5de94f" +ExampleMonteCarlo = "00000000-0000-4000-8000-000000000003" +ParamIO = "938a3ac2-d340-473c-bcf1-88af577e4ccf" +Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" + +[sources] +ExampleMonteCarlo = {path = ".."} + +[compat] +DataVault = "0.7" +ParamIO = "0.4" +Plots = "1" +julia = "1.10" diff --git a/projects/ExampleMonteCarlo/report/report.jl b/projects/ExampleMonteCarlo/report/report.jl new file mode 100644 index 0000000..6288305 --- /dev/null +++ b/projects/ExampleMonteCarlo/report/report.jl @@ -0,0 +1,17 @@ +# Draw the sweep. Run with `--project=report`, never the compute env. +# +# julia --project=report report/report.jl configs/production.toml + +using DataVault: DataVault +using ExampleMonteCarlo: ExampleMonteCarlo +using ParamIO: ParamIO + +const CONFIG = get(ARGS, 1, joinpath(@__DIR__, "..", "configs", "smoke.toml")) +const OUTDIR = get(ENV, "DATAVAULT_OUTDIR", joinpath(@__DIR__, "..", "out")) + +vault = DataVault.Vault(CONFIG; run="phase1", outdir=OUTDIR) +rows = ExampleMonteCarlo.summarise(vault) # the same reduction scripts/collect.jl uses + +@info "reporting over" n = length(rows) +# The Binder cumulant's crossing in L is where the transition shows up — draw it here, with Plots, +# which is a dependency of THIS environment and of nothing that runs on a compute node. diff --git a/projects/ExampleMonteCarlo/scripts/collect.jl b/projects/ExampleMonteCarlo/scripts/collect.jl new file mode 100644 index 0000000..328dc59 --- /dev/null +++ b/projects/ExampleMonteCarlo/scripts/collect.jl @@ -0,0 +1,28 @@ +# The reader side. No plotting dependency, so it runs where the compute did. +# +# julia --project=. scripts/collect.jl configs/smoke.toml + +using DataVault: DataVault +using ExampleMonteCarlo: ExampleMonteCarlo +using ParamIO: ParamIO +using Printf + +const CONFIG = get(ARGS, 1, joinpath(@__DIR__, "..", "configs", "smoke.toml")) +const OUTDIR = get(ENV, "DATAVAULT_OUTDIR", joinpath(@__DIR__, "..", "out")) + +vault = DataVault.Vault(CONFIG; run="phase1", outdir=OUTDIR) +rows = ExampleMonteCarlo.summarise(vault) + +@printf("\n %-5s %-7s %-11s %-11s %s\n", "L", "kbT", "E/N", "|M|", "Binder") +println(" ────────────────────────────────────────────────────") +for r in rows + @printf( + " %-5d %-7.3f %-11.5f %-11.5f %.4f\n", + r.L, + r.kbT, + r.energy, + r.magnetization, + r.binder + ) +end +@printf("\n %d points\n\n", length(rows)) diff --git a/projects/ExampleMonteCarlo/scripts/compute.jl b/projects/ExampleMonteCarlo/scripts/compute.jl new file mode 100644 index 0000000..9a9bce1 --- /dev/null +++ b/projects/ExampleMonteCarlo/scripts/compute.jl @@ -0,0 +1,36 @@ +#============================================================================== + compute.jl — the three-layer driver, over an Ising sweep. + + ParamIO : config TOML -> Vector{DataKey} (what to compute) + DataVault : (study, run) -> file storage (where it goes) + SweepRunner: run!(work_fn) -> parallel runtime (do it, lock-safe, resumable) + + julia --project=. scripts/compute.jl configs/smoke.toml + + Every module is imported as `using X: X` and every call is qualified. Here that is load-bearing + rather than tidy: `ClassicalMonteCarlo` exports `run!` and so does `SweepRunner`, and the two mean + different things — one advances a Markov chain, the other dispatches the sweep. +==============================================================================# + +using DataVault: DataVault +using ExampleMonteCarlo: ExampleMonteCarlo +using ParamIO: ParamIO +using SweepRunner: SweepRunner + +const CONFIG = get(ARGS, 1, joinpath(@__DIR__, "..", "configs", "smoke.toml")) +const OUTDIR = get(ENV, "DATAVAULT_OUTDIR", joinpath(@__DIR__, "..", "out")) + +spec = ParamIO.load(CONFIG) +keys = ParamIO.expand(spec) +vault = DataVault.Vault(CONFIG; run="phase1", outdir=OUTDIR) + +SweepRunner.init_workers!(; mode=:auto) +opts = SweepRunner.RunOpts(; stop_flag=get(ENV, "PM_STOP_FLAG", nothing)) + +result = SweepRunner.run!( + ExampleMonteCarlo.work_fn, vault, keys; opts=opts, load=ExampleMonteCarlo +) +@info "phase1 complete" result + +ledger = DataVault.build_ledger(vault) +@info "ledger written" ledger diff --git a/projects/ExampleMonteCarlo/src/ExampleMonteCarlo.jl b/projects/ExampleMonteCarlo/src/ExampleMonteCarlo.jl new file mode 100644 index 0000000..5c778bc --- /dev/null +++ b/projects/ExampleMonteCarlo/src/ExampleMonteCarlo.jl @@ -0,0 +1,106 @@ +module ExampleMonteCarlo + +# This project's own code: one equilibrated chain, and the reduction over finished points. +# +# `work_fn` lives here rather than in `scripts/` because `run!(…; load=…)` ships THIS module to the +# workers. Everything it reaches travels with it, which is why the compute environment is kept free +# of plotting. +# +# Note the qualified calls. `ClassicalMonteCarlo` exports `run!` and so does `SweepRunner`; the sweep +# calls one and a chain calls the other, so neither is imported unqualified anywhere in this project. + +using ClassicalMonteCarlo: ClassicalMonteCarlo +using DataVault: DataVault +using Lattice2D: Lattice2D +using Random: MersenneTwister + +export work_fn, summarise + +""" + work_fn(key) -> Dict{String,Any} + +One `(lattice, L, kbT)` point of the Ising sweep: burn in with no observers, then accumulate a +`ThermodynamicObserver` over `nsteps` sweeps and return the thermodynamics. + +Returns the payload; it never saves. The runtime writes what comes back, plus the `.done` marker. +""" +function work_fn(key) + L = Int(key.params["system.L"]) + kbT = Float64(key.params["system.kbT"]) + nsteps = Int(key.params["numerics.nsteps"]) + burn = Int(key.params["numerics.burn"]) + + # `build_lattice` takes the topology TYPE, not an instance: + # `build_lattice(::Type{<:AbstractTopology{2}}, Int, Int)`. + lat = Lattice2D.build_lattice(Lattice2D.Square, L, L) + model = ClassicalMonteCarlo.IsingModel(; J=1.0, h=0.0) + alg = ClassicalMonteCarlo.LocalUpdate(; + rule=ClassicalMonteCarlo.Metropolis(), + selection=ClassicalMonteCarlo.RandomSiteSelection(), + ) + + N = Lattice2D.num_sites(lat) + rng = MersenneTwister(hash((L, kbT, key.sample))) + grids = rand(rng, [-1, 1], N) + + # Burn-in carries no observer: measuring the approach to equilibrium would bias the average. + ClassicalMonteCarlo.run!( + rng, + grids, + lat, + model, + alg, + ClassicalMonteCarlo.AbstractObserver[]; + kbT=kbT, + nsteps=burn, + ) + obs = ClassicalMonteCarlo.ThermodynamicObserver(; interval=10) + ClassicalMonteCarlo.run!( + rng, + grids, + lat, + model, + alg, + ClassicalMonteCarlo.AbstractObserver[obs]; + kbT=kbT, + nsteps=nsteps, + ) + res = ClassicalMonteCarlo.get_thermodynamics(obs, kbT, N, model) + + return Dict{String,Any}( + "L" => L, + "kbT" => kbT, + "N" => N, + "energy" => res["Energy"], + "magnetization" => res["Magnetization"], + "specific_heat" => res["SpecificHeat"], + "susceptibility" => res["Susceptibility"], + "binder" => res["BinderParam"], + ) +end + +""" + summarise(vault) -> Vector{NamedTuple} + +Every finished point, sorted. `scripts/collect.jl` and `report/report.jl` both call this, so the +table printed on the cluster and the figure drawn afterwards cannot disagree. +""" +function summarise(vault) + rows = NamedTuple[] + for key in DataVault.keys(vault; status=:done) + d = DataVault.load(vault, key) + push!( + rows, + (; + L=d["L"], + kbT=d["kbT"], + energy=d["energy"], + magnetization=d["magnetization"], + binder=d["binder"], + ), + ) + end + return sort(rows; by=r -> (r.L, r.kbT)) +end + +end # module ExampleMonteCarlo diff --git a/projects/ExampleMonteCarlo/test/runtests.jl b/projects/ExampleMonteCarlo/test/runtests.jl new file mode 100644 index 0000000..818bf15 --- /dev/null +++ b/projects/ExampleMonteCarlo/test/runtests.jl @@ -0,0 +1,27 @@ +using ExampleMonteCarlo +using Test + +@testset "ExampleMonteCarlo" begin + @test isfile(joinpath(@__DIR__, "..", "configs", "smoke.toml")) + + # One point, small enough to run in a test. The assertion is physics, not a threshold: deep in + # the ordered phase the Ising magnetisation is near saturation, and well above the transition it + # is near zero. A chain that silently stopped equilibrating fails both ways. + mk(L, kbT) = (; + params=Dict( + "system.L" => L, + "system.kbT" => kbT, + "numerics.nsteps" => 400, + "numerics.burn" => 200, + ), + sample=1, + ) + + cold = ExampleMonteCarlo.work_fn(mk(8, 1.2)) + hot = ExampleMonteCarlo.work_fn(mk(8, 4.0)) + + @test cold isa Dict{String,Any} + @test cold["magnetization"] > 0.8 # ordered + @test hot["magnetization"] < 0.3 # disordered + @test cold["energy"] < hot["energy"] # and the ordered phase is the lower-energy one +end