diff --git a/projects/ExampleMonteCarlo/Project.toml b/projects/ExampleMonteCarlo/Project.toml index d7630d0..12c1621 100644 --- a/projects/ExampleMonteCarlo/Project.toml +++ b/projects/ExampleMonteCarlo/Project.toml @@ -3,11 +3,6 @@ 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" diff --git a/projects/ExampleMonteCarlo/report/Project.toml b/projects/ExampleMonteCarlo/report/Project.toml index 31e8167..c10b767 100644 --- a/projects/ExampleMonteCarlo/report/Project.toml +++ b/projects/ExampleMonteCarlo/report/Project.toml @@ -1,20 +1,18 @@ -# 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" +Pinax = "e782a80a-1ac9-479e-ba2b-0f3433ef5b4f" Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" [sources] ExampleMonteCarlo = {path = ".."} +# TEMPORARY: Pinax is awaiting its first General registration. Delete this line once it lands. +Pinax = {rev = "main", url = "https://github.com/QAtlasHub/Pinax.jl.git"} [compat] DataVault = "0.7" ParamIO = "0.4" +Pinax = "0.1" Plots = "1" -julia = "1.10" +julia = "1.12" diff --git a/projects/ExampleMonteCarlo/report/report.jl b/projects/ExampleMonteCarlo/report/report.jl index 9308794..0277cd6 100644 --- a/projects/ExampleMonteCarlo/report/report.jl +++ b/projects/ExampleMonteCarlo/report/report.jl @@ -1,16 +1,79 @@ -# Draw the sweep. Run with `--project=report`, never the compute env. +# report.jl — the finished vault, rendered twice: an HTML gallery for a human and an +# `agent.json` for an LLM. `Pinax.report` discovers the `:done` keys, loads each payload and +# hands the `(DataKey, Dict)` pairs to `recipe`; only `recipe` is this project's. # # julia --project=report report/report.jl configs/production.toml using DataVault: DataVault using ExampleMonteCarlo: ExampleMonteCarlo -using ParamIO: ParamIO +using ParamIO: ParamIO # also a PinaxDataVaultExt trigger; without it `Pinax.report` is a stub +using Pinax +using Plots + +ENV["GKSwstype"] = get(ENV, "GKSwstype", "100") # headless GR: renders with no display attached +gr() const CONFIG = get(ARGS, 1, joinpath(@__DIR__, "..", "configs", "smoke.toml")) +const TC = 2 / log(1 + sqrt(2)) # the exact 2D Ising transition, kbT/J ≈ 2.269 vault = DataVault.Vault(CONFIG; run="phase1") -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. +""" + recipe(pairs) + +Build the document. `summarise` is `../src/ExampleMonteCarlo.jl`'s, so this and +`scripts/collect.jl` cannot report different numbers. +""" +function recipe(pairs) + rows = ExampleMonteCarlo.summarise(pairs) + + binder = plot(; xlabel="kbT / J", ylabel="Binder cumulant U₄", legend=:bottomleft) + mag = plot(; xlabel="kbT / J", ylabel="|magnetization| per spin", legend=:topright) + for L in sort(unique(r.L for r in rows)) + sel = filter(r -> r.L == L, rows) + Ts = sort(unique(r.kbT for r in sel)) + # one point per swept `kbT`, so `total_samples > 1` averages the chains rather than overplots + avg(f) = [(v=[f(r) for r in sel if r.kbT == T]; sum(v) / length(v)) for T in Ts] + plot!(binder, Ts, avg(r -> r.binder); marker=:circle, label="L = $L") + plot!(mag, Ts, avg(r -> r.magnetization); marker=:circle, label="L = $L") + end + # both labelled: an unlabelled series reaches `agent.json` as a nameless row + vline!(binder, [TC]; ls=:dash, c=:red, label="exact Tc") + vline!(mag, [TC]; ls=:dash, c=:red, label="exact Tc") + + @page :ising "ExampleMonteCarlo — 2D Ising on a square lattice" summary = "A Metropolis sweep over lattice size and temperature, read back out of the vault." begin + @section :binder "Binder cumulant" begin + @desc md""" + $U_4$ is dimensionless at the critical point, so the curves for different $L$ cross + there and the crossing locates $T_c$ without an extrapolation. The dashed line is the + exact $k_BT/J = 2/\ln(1+\sqrt{2}) \approx 2.269$ — it is a check on the sweep, not an + output of it. A single $L$ draws one curve and no crossing; that needs `production.toml`. + """ + @figure binder caption = "One curve per lattice size; they should meet at the dashed line." + end + @section :mag "Order parameter" begin + @desc md"The magnetisation falls through the transition, and the fall sharpens with $L$ — the finite-size rounding is the thing the sweep is measuring." + @figure mag caption = "|m| per spin against temperature." + end + @section :table "The points themselves" begin + @desc md"The same rows `scripts/collect.jl` prints, so what an LLM reads out of `agent.json` is what the cluster printed." + @table ( + L=[r.L for r in rows], + kbT=[r.kbT for r in rows], + energy=[r.energy for r in rows], + magnetization=[r.magnetization for r in rows], + binder=[r.binder for r in rows], + ) caption = "Every finished point." + end + end +end + +res = Pinax.report( + vault, + recipe; + title="ExampleMonteCarlo", + out=joinpath(vault.outdir, "report"), + study="phase1", +) + +@info "report written" n = res.n gallery = res.gallery agent = res.agent diff --git a/projects/ExampleMonteCarlo/src/ExampleMonteCarlo.jl b/projects/ExampleMonteCarlo/src/ExampleMonteCarlo.jl index 5c778bc..0b938ae 100644 --- a/projects/ExampleMonteCarlo/src/ExampleMonteCarlo.jl +++ b/projects/ExampleMonteCarlo/src/ExampleMonteCarlo.jl @@ -80,15 +80,16 @@ function work_fn(key) end """ + summarise(pairs) -> Vector{NamedTuple} 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. +Every finished point, sorted. `pairs` is the reduction; the `vault` method only reads them off +disk first — `Pinax.report` already holds the pairs, `scripts/collect.jl` does not. One reduction +either way, so the two cannot report different numbers. """ -function summarise(vault) +function summarise(pairs::AbstractVector) rows = NamedTuple[] - for key in DataVault.keys(vault; status=:done) - d = DataVault.load(vault, key) + for (_, d) in pairs push!( rows, (; @@ -103,4 +104,10 @@ function summarise(vault) return sort(rows; by=r -> (r.L, r.kbT)) end +function summarise(vault::DataVault.Vault) + return summarise([ + (k, DataVault.load(vault, k)) for k in DataVault.keys(vault; status=:done) + ]) +end + end # module ExampleMonteCarlo diff --git a/projects/ExampleSweep/Project.toml b/projects/ExampleSweep/Project.toml index 6464d34..c1a210e 100644 --- a/projects/ExampleSweep/Project.toml +++ b/projects/ExampleSweep/Project.toml @@ -3,10 +3,6 @@ uuid = "00000000-0000-4000-8000-000000000001" version = "0.1.0" authors = ["Your Name "] -# COMPUTE env. Kept lean on purpose: no plotting, no Pinax. Those live in -# `report/`, which reads the same DataVault off disk rather than sharing this -# environment — so a figure dependency can never pull itself onto a compute node. - [deps] DataVault = "23f5f8f6-b4da-40ee-8c72-c53b6c5de94f" MyModule = "00000000-0000-4000-8000-000000000000" diff --git a/projects/ExampleSweep/report/Project.toml b/projects/ExampleSweep/report/Project.toml index 8fbe7b3..0e20d7a 100644 --- a/projects/ExampleSweep/report/Project.toml +++ b/projects/ExampleSweep/report/Project.toml @@ -1,24 +1,18 @@ -# report/ — the render environment. -# -# SEPARATE from the compute env one level up, deliberately: `scripts/compute.jl` -# runs with lean deps and may run on a cluster, while this reads the same on-disk -# vault and draws it. The reduction is NOT duplicated here — it lives in -# `../src/ExampleSweep.jl` and both sides call it. -# -# No `name`/`uuid`: this is an environment, not a package. - [deps] DataVault = "23f5f8f6-b4da-40ee-8c72-c53b6c5de94f" ExampleSweep = "00000000-0000-4000-8000-000000000001" +ParamIO = "938a3ac2-d340-473c-bcf1-88af577e4ccf" Pinax = "e782a80a-1ac9-479e-ba2b-0f3433ef5b4f" Plots = "91a5bcdd-55d7-5caf-9e0b-520d859cae80" [sources] ExampleSweep = {path = ".."} -# TEMPORARY, like SweepRunner one level up: Pinax is awaiting General registration. +# TEMPORARY: Pinax is awaiting its first General registration. Delete this line once it lands. Pinax = {rev = "main", url = "https://github.com/QAtlasHub/Pinax.jl.git"} [compat] DataVault = "0.7" +ParamIO = "0.4" +Pinax = "0.1" Plots = "1" -julia = "1.10" +julia = "1.12" diff --git a/projects/ExampleSweep/report/report.jl b/projects/ExampleSweep/report/report.jl index c10da56..2704752 100644 --- a/projects/ExampleSweep/report/report.jl +++ b/projects/ExampleSweep/report/report.jl @@ -1,16 +1,74 @@ -# Draw from the finished vault. Run with `--project=report`, never the compute env — -# that is the whole reason the two environments are separate. +# report.jl — the finished vault, rendered twice: an HTML gallery for a human and an +# `agent.json` for an LLM. `Pinax.report` discovers the `:done` keys, loads each payload and +# hands the `(DataKey, Dict)` pairs to `recipe`; only `recipe` is this project's. # -# julia --project=report report/report.jl configs/smoke.toml +# julia --project=report report/report.jl configs/production.toml using DataVault: DataVault using ExampleSweep: ExampleSweep -using ParamIO: ParamIO +using ParamIO: ParamIO # also a PinaxDataVaultExt trigger; without it `Pinax.report` is a stub +using Pinax +using Plots + +ENV["GKSwstype"] = get(ENV, "GKSwstype", "100") # headless GR: renders with no display attached +gr() const CONFIG = get(ARGS, 1, joinpath(@__DIR__, "..", "configs", "smoke.toml")) vault = DataVault.Vault(CONFIG; run="phase1") -rows = ExampleSweep.summarise(vault) # the same reduction scripts/collect.jl uses -@info "reporting over" n = length(rows) -# Draw here — Pinax and a plotting backend are dependencies of THIS environment. +""" + recipe(pairs) + +Build the document. `summarise` is `../src/ExampleSweep.jl`'s, so this and +`scripts/collect.jl` cannot report different numbers. +""" +function recipe(pairs) + rows = ExampleSweep.summarise(pairs) + + fig = plot(; + xscale=:log10, + yscale=:log10, + xlabel="step size dt", + ylabel="relative error at t = 1", + legend=:bottomright, + ) + for a in sort(unique(r.a for r in rows)) + sel = filter(r -> r.a == a, rows) + dts = sort(unique(r.dt for r in sel)) + # one point per swept `dt`, so `total_samples > 1` averages rather than overplots + errs = [ + (v=[r.rel_error for r in sel if r.dt == d]; sum(v) / length(v)) for d in dts + ] + plot!(fig, dts, errs; marker=:circle, label="a = $a") + end + + @page :convergence "ExampleSweep — explicit Euler on x' = -a x" summary = "Relative error of the placeholder kernel against its exact solution, over the swept step size." begin + @section :rate "Error vs step size" begin + @desc md""" + Explicit Euler is first order, so on these log axes each curve should approach slope 1: + halving `dt` halves the error. A curve that flattens has hit round-off rather than + discretisation, and one that steepens is a bug — this figure is what tells them apart. + """ + @figure fig caption = "One curve per decay rate `a`; each point is one swept `dt`." + end + @section :table "The points themselves" begin + @desc md"The same rows `scripts/collect.jl` prints, so the number an LLM reads out of `agent.json` is the number on the cluster's stdout." + @table ( + a=[r.a for r in rows], + dt=[r.dt for r in rows], + rel_error=[r.rel_error for r in rows], + ) caption = "Every finished point." + end + end +end + +res = Pinax.report( + vault, + recipe; + title="ExampleSweep", + out=joinpath(vault.outdir, "report"), + study="phase1", +) + +@info "report written" n = res.n gallery = res.gallery agent = res.agent diff --git a/projects/ExampleSweep/src/ExampleSweep.jl b/projects/ExampleSweep/src/ExampleSweep.jl index 7a28781..bd08a8c 100644 --- a/projects/ExampleSweep/src/ExampleSweep.jl +++ b/projects/ExampleSweep/src/ExampleSweep.jl @@ -9,18 +9,27 @@ using DataVault: DataVault export summarise """ + summarise(pairs) -> Vector{NamedTuple} summarise(vault) -> Vector{NamedTuple} -Read every finished point back and reduce it. Replace the body; keep the shape, -so `scripts/collect.jl` and `report/report.jl` stay in agreement. +Reduce every finished point. `pairs` is the reduction; the `vault` method only reads +them off disk first — `Pinax.report` already holds the pairs, `scripts/collect.jl` does +not. One reduction either way, so the two cannot report different numbers. + +Replace the body; keep the shape. """ -function summarise(vault) +function summarise(pairs::AbstractVector) rows = NamedTuple[] - for key in DataVault.keys(vault; status=:done) - d = DataVault.load(vault, key) + for (_, d) in pairs push!(rows, (; a=d["a"], dt=d["dt"], rel_error=d["rel_error"])) end return sort(rows; by=r -> (r.a, r.dt)) end +function summarise(vault::DataVault.Vault) + return summarise([ + (k, DataVault.load(vault, k)) for k in DataVault.keys(vault; status=:done) + ]) +end + end # module ExampleSweep