Release preparation — state on 2026-09-11 (cycle: peclet 1.0.0, shipped; then 1.0.1 and 1.1.0)¶
Archived 2026-10-08. The
docs/RELEASE_PREP.mdrecord of the 1.0.0, 1.0.1 and 1.1.0 cycles (plus the 1.1.1 metapackage note of 2026-09-19), moved here verbatim when RELEASE_PREP was re-cut for the 1.4.0 cycle; only relative links were re-rooted for the new location. Section numbers are unchanged, so a citation "RELEASE_PREP §N" with N ≤ 11 refers to this file. Nothing here is maintained: status lines and "next release" wording are snapshots of their date.This file still describes the 1.0.0 cycle. 1.0.0 shipped 2026-09-12, 1.0.1 on 2026-09-14 and 1.1.0 on 2026-09-16 (flow 1.1.0; core/pnm/dem/voro 1.0.2; morton/coupling 1.0.1 and amr 0.1.1 unchanged). It has NOT been re-cut for those, so read every "next release" below as 1.0.0 and check the item against the repo before acting on it. RELEASE.md's instruction to re-cut this file and archive the previous cycle is outstanding — see the 1.1.0 entry in the CHANGELOG for what actually shipped.
The one-off companion of RELEASE.md (the durable workflow), re-cut for the 1.0.0 cycle as that file's own header instructed. The previous cycle's record (0.7.0/0.7.1/0.7.2, measured 2026-09-04) is archived verbatim at archive/RELEASE_PREP_0.7.x.md — §8 of that file, the physical-domains feature, is still live work and is carried forward as §7 below.
Everything here was measured on 2026-09-11 with tools/release/check_release_state.sh,
tools/release/audit_docstrings.py, tools/release/audit_examples.py and four read-only surveys of
the repos. Development continues meanwhile: re-run the three tools on the day and refresh these
numbers.
Legend: [B] blocks the release · [R] do during the release window · [A] after the release, does not block · [D] decision needed from the maintainer.
0. Snapshot¶
| package | version | last tag | commits since | notes |
|---|---|---|---|---|
| core | 0.6.1 | v0.6.1 |
22 | must be tagged and published first (RELEASE.md §4) |
| amr | 0.1.0 | — | — | new eighth package (split 2026-09-10); ships at 0.x per QUALITY_PLAN D9 exception, sdist-only |
| morton | 0.2.1 | pre-legacy-removal |
4 | |
| flow | 0.5.1 | v0.5.1 |
45 | |
| pnm | 0.1.2 | v0.1.2 |
13 | |
| dem | 0.5.1 | v0.5.1 |
20 | |
| voro | 0.5.1 | v0.5.1 |
26 | |
| coupling | 0.4.0 | v0.4.0 |
10 | |
| umbrella | 0.7.2 | — | — |
Single version source (D4) is honoured everywhere: every CMakeLists.txt reads its own
pyproject.toml, __version__ is importlib.metadata-derived, and CITATION.cff / Doxyfile
agree. No drift found. Kokkos 5.1.1 / ArborX v2.1 are pinned identically across every repo's CI,
tools/bootstrap_deps.sh and every PecletDeps.cmake.
QUALITY_PLAN status correction. The umbrella CLAUDE.md claim that "F and G remain" is stale:
verified against the repos, F and G.1–G.7 are done in all eight. Only G.8 (flow explicit
instantiation, the build-time refactor) is outstanding, and QUALITY_PLAN queues it explicitly as
"before or after the tag, not breaking".
1. Blockers [B]¶
1.1 The core pin must be advanced as part of the release sequence — and nothing enforced it¶
voro and amr include core headers that do not exist at the PECLET_CORE_TAG "v0.6.1" they
pin. A wheel build sets PECLET_VENDOR_DEPS=ON, FetchContents core at the pin, and fails to
compile:
voro→peclet/core/solver/{coloring,csr_bicgstab}.hppamr→peclet/core/scheme/cut_cell_closure.hpp,peclet/core/solver/{coloring,csr_bicgstab,csr_operator,face_csr,vector_ops}.hpp
Cause is benign — core d93c323 lifted the face-CSR solver layer out of amr/ on 2026-09-10 and
voro aee039b moved to it the same day — and amr's CI documents the pin as a placeholder its release
repins. This is therefore a sequencing requirement, not a defect to patch today: core is tagged
and published first, then every consumer repins, then consumers are tagged. Do not cut an interim
v0.6.2 for this; it would burn a PyPI version for nothing.
What made it a blocker is that three independent safety nets were blind to it:
- the
PECLET_VENDOR_SIBLINGSCI guard configured without compiling, so it proved the tag resolved but not that anything built against it — a missing header fails at compile; check_release_state.shgreppedPECLET_TPX_TAG, a name the rename table retired, so it printedcore=blank for every consumer and reported nothing wrong;release.yml(the wheel build, which would have failed honestly) triggers only on av*tag — i.e. it discovers the problem during the release.
Fixed 2026-09-11: check_release_state.sh now greps the correct variable, covers amr, and
verifies every peclet/core/*.hpp a consumer includes against the tag that consumer pins, failing
loudly. voro's ci.yml guard now compiles the module the wheel actually builds. Still to do [R]:
give flow, dem, pnm, coupling and amr the same compiling pre-tag guard — today the
pre-flight is their only net.
1.2 SCALING_ISSUES #1 — DECIDED 2026-09-11: double operator storage is the default [R]¶
Not fixed. PECLET_FLOW_OPERATOR_DOUBLE is an opt-in compile flag, default OFF
(flow/CMakeLists.txt:52); the production double-diagonal storage was deliberately not shipped
(flow/doc/history/vof_workorders_v34.md:636). Live corroboration from QUALITY_PLAN.md:371-372,
written 2026-09-11: the porous path's default MG-PCG reports a non-finite preconditioner on 2 of 5
steps, deterministically.
Decision taken 2026-09-11: flip the default. PECLET_FLOW_OPERATOR_DOUBLE is now ON
(flow CMakeLists.txt:48-72) and a float build emits a CMake warning naming this issue. Documenting
the limitation was rejected because the failure is silent — documentation protects only a user who
already knows. The double-diagonal fallback stays retired (65x worse on divergence). Recorded in
decisions/flow.md and SCALING_ISSUES.md §1.
What this leaves to do in the release window [R] — measured 2026-09-12, outcomes inline:
- ~~Re-bless the regression baselines.~~ NOT NEEDED — the baselines do not move.
sdflow_regression.pyagainst the existingperf_baseline.json, run on a double-default build (flow/build_rel_omp, host-openmp): PASS, with every gated metric at +0.00 % —K_inf7.447,k*_inf0.0062362 and 0.017183 (was 0.017184, +0.00 %), convergence orders 2.29 / 2.19 / 1.38 unchanged, and every pressure-iteration count identical (368/560/525/976/1607, 635/573/439/717, 746/667/1308/2250). What moved ismax|div|, by about two orders of magnitude in the right direction — e.g. the N=64 ring bed 1.4e-10 → 1.0e-11, the Z&H sphere 4.5e-11 → 2.1e-12 — and the gate on divergence is an upper bound, so an improvement passes. Do NOT run--update: it would overwrite a baseline that still holds and discard the historical reference. The three regression beds are simply not the high-contrast dense beds where float storage breaks. What does move isstate_hash.py: nine of the ten single-rank entry paths and the np=2 case change, which is expected and is the deliberate numerics change.scalaris byte-IDENTICAL (711f587dab95ca…) because that case carries no cut-cell operator and no multigrid contrast, so the storage type never reaches it — a useful sanity check that the flip did only what it claims. New hashes: staggered_bede933ca12437925a8, colocated_ghostb5208af291d680c0, colocated_gauge_exact43fe1408541bbe83, colocated_plain0fa88904b2ea78b6, colocated_embed22a77cf1104be469, channel9d1daa0e9928ec50, vof_droplet929eb7c21c99250f, porous44d3ffd55797efcb, scene_moving6d8df1d3917f7b62, mpi_np26b300a8da0c97703. And the defect the flip exists to fix is gone: the float build printedCutcellMG::solvePCG: preconditioner produced non-finite z … (reported as 500/500 iterations, i.e. a CAPPED solve)twice on the porous case; the double build prints it zero times. - Re-measure the ~12% — STILL OPEN. A first attempt on 2026-09-12 was contaminated: four
builds were running concurrently and the float arm measured 358 ms/step against a quiet-machine
value of 311 ms/step for the same binary, i.e. the load term was larger than the effect. Redo it
on a quiet host as a back-to-back A/B of the two modules (
build_g8h2float vsbuild_rel_ompdouble), min of three runs of twenty steps. The regression's own wall-clock column suggests +2 % to +10 % depending on the case rather than a uniform 12 %, but that column is a single-shot number and is not evidence. - ~~Check the CUDA build.~~ Host half DONE, CUDA half open. On host-openmp the macro reaches
every target:
PECLET_FLOW_OPERATOR_DOUBLE=1is present inflags.makeforpeclet_flow_solver,peclet_flow_solver_mpi(G.8's two instantiation libraries),peclet_flowand the consumer tests — so there is no TU at a different precision from the headers it shares, and no silent ODR violation. Repeat the sameflags.makecheck on annvidia-cudatree before tagging. - Re-check any published dense-bed number produced by a float build — the porous-scaling study and RingBed in particular. STILL OPEN.
1.3 The verification scripts silently bind to a stale build [B]¶
flow/scripts/*.py (38 files) open with
sys.path.insert(0, .../os.environ.get("SDFLOW_BUILD", "build_mpi"))
which puts flow/build_mpi at sys.path[0] and overrides PYTHONPATH. So the workflow
flow's own CLAUDE.md documents —
PYTHONPATH=$PWD/build python scripts/verify_poiseuille_flow.py
— does not test $PWD/build. It silently tests build_mpi, whose _flow.so is currently dated
2026-09-01, ten days and ~45 commits stale. The override variable also still carries the retired
SDFLOW_ prefix (QUALITY_PLAN rename table: sdflow → flow), so it reads as dead configuration
and nobody sets it.
Found 2026-09-11 while validating the operator-precision flip: the run failed with
set_body_force(): incompatible function arguments, because the Sep-1 module still had the
three-float signature that the current source replaced with a 3-sequence. The failure mode that
matters is the opposite one — when the stale module's API happens to still match, the script
prints PASS and nobody learns which binary it validated.
This is the same class as §2.1's stale build_rel* trees, and together they mean the suite's two
validation entry points — the verification scripts and the docstring audit — can both certify a
build nobody intended.
Fix before the tag: have the scripts prefer PYTHONPATH when it is set, rename the variable to
PECLET_FLOW_BUILD, and make the default an explicit failure rather than a stale directory. Then
re-run the verification battery and note which build produced each number. scripts/ is 38 files
and was heavily edited on 2026-09-11 by the G.8 work, so this wants one owner doing it in a single
pass, not a scattered edit.
2. Documentation [R]¶
Doxygen is in good shape: 7 of 8 repos ship a CI-wired Doxyfile and mkdocs.yml:78-85 links all
seven sites correctly (voro generates its Doxyfile via CMake by design). No stale module names
(sdflow, vorflow, tpx, pnm_backend) survive outside docs/archive/ and historical citations.
No stale version literals on site pages. tools/gen_python_api.py's PAGES list is current. amr is
fully integrated — README, CLAUDE.md, four workflows, docs/python/amr.md, mkdocs nav, umbrella
README.
- [B for the audit, not the release] Re-run
audit_docstrings.pyagainst a CLEAN family build. The 2026-09-11 run reported894 callables, 36 undocumented, 0 stale, but everybuild_rel*tree it imported predates the current HEAD — e.g.core/python/build_rel_ompwas built 2026-09-05, beforeMigrator→ParticleMigratorlanded, so the audit flagged classes that no longer exist while the real ones are documented. RELEASE.md §5.1 already requires the clean rebuild; it has not happened since the renames. Treat the 36 as unverified until re-run. - Fixed 2026-09-11:
audit_docstrings.pyauditedpeclet.core.amr, removed when AMR split out, sopeclet.amrwas audited by nothing. Now corrected. (A manual run of the script's logic againstamr/build_qgives 98 callables, 0 undocumented — clean, but by luck, not by process.) peclet.couplingdocstrings — was 10 of 14 public callables undocumented (71%), the worst public surface in the suite:CfdDemand itsstep/compute_forces/update_void_fraction/last_drag/last_slip,ResolvedCfdDemand itsstep/forces/torques. Being written 2026-09-11; verify on the rendered page before the tag.- Remaining undocumented singles:
core/python/geom_bindings.cpp—SceneBuilder.__init__,add_union,add_intersection,num_nodes,num_instances;flow/src/flow_bindings.cpp—has_scene,pressure_telescope(both solvers),velocity_multigrid_active(both diagnostics). couplinghas no Doxygen setup — nodocs/Doxyfile, nodocs.yml, not in mkdocs' C++ API list, thoughsrc/{drag.hpp,coupling_kernels.hpp,coupling_bindings.cpp}are real C++. The only such gap in the suite. [A] unless coupling's C++ is considered public API for 1.0.0.
3. Packaging and CI [R]¶
- The repin gate — see §1.1.
check_release_state.shnow fails loudly; run it before tagging anything and again after the core tag. coupling/CITATION.cffis missing — the only package without one; a per-repo Zenodo metadata gap.docs/RELEASE.md§11 predates amr (not listed) and states "coupling has no CI at all", which is false —coupling/.github/workflows/ci.ymlbuilds flow+dem+coupling and runs the pytest battery. Re-verify the rest of that gap list (family rehearsal workflow,--ciinrelease.yml, CUDA dry-run).- CI is honest by the D standard: no
xfailanywhere, no disabled jobs; the onlycontinue-on-errorjobs are clang-format (informational by design) and morton's Intel-SDE AVX-512 job (the SDE download URL rots). amr'sci.ymlcarries an explicit "Honest CI" header — the best-documented CI in the suite. - The 0.7.1 core-sdist trap does not recur in amr: its
cmake/PecletDeps.cmakedefines a self-containedpeclet_require_nanobind().
4. Tests — qualify any "tests pass" claim [R]¶
Several repos register zero tests under a plain cmake -B build:
| repo | default | full |
|---|---|---|
| voro | 0 (needs -DPECLET_VORO_BUILD_TESTS=ON) |
42 (24 + 18 MPI) |
| pnm | 0 (needs -DPECLET_PNM_BUILD_TESTS=ON) |
+ MPI with -DPECLET_PNM_MPI |
| flow | 43 kernel (needs -DPECLET_FLOW_BUILD_TESTS=ON) |
106 distributed with -DPECLET_FLOW_MPI=ON |
| dem | 11 | 47 with -DPECLET_DEM_MPI=ON |
| core | 52 plain / 67 Kokkos np1–8 / 6 Python | |
| amr | 100 (92 C++ + 5 bench + 3 Python) |
"No tests were found!!!" is not a pass signal. The exit-77 SKIP convention is honoured everywhere, and the MPI launcher is pinned to the linked MPI in every repo — both former footguns are fixed and regression-tested.
5. Open technical issues, ranked [R/A]¶
- [R] SCALING_ISSUES #1 — decided 2026-09-11, default flipped; the re-blessing work is §1.2.
- [R] SCALING_ISSUES #2, MG depth cap: telescoping ships and is the default, but the underlying defect (intermediate levels must coarsen in place) is routed around, not solved. flow's CLAUDE.md calls it "the top open item at scale".
- [A] SCALING_ISSUES #3 — FIXED 2026-09-16, so it is no longer a limitation to state: the
SDF ghost outside a non-periodic face was periodic-wrapped (an inconsistent pressure row at a cut
inlet) and the Dirichlet outlet row carried the literal openness 1.0 instead of the face
aperture (mass leaving through solid). Gated by
test_openbc_solid{,_mpi}, the first tests to combineset_domain_bcwithset_solid. Two consequences for the release: the 1.0.0 "Known limitations" bullet in the CHANGELOG is superseded and 1.1.0 needs a Fixed entry; and a THIRD, MPI-only defect it surfaced is new issue #8 (the halo wraps a non-periodic face's HIGH boundary plane), also fixed — both halves, openness and the outflow velocity plane, since fixing either alone breaksvof_bc_mpi's composed conservation budget. - [R] Collocated ghost-mode
(matrix_order=1, rhs_order=2)is march-unstable above ~2000 spheres and is documented do-not-use. Collocated MPI validated only at np=1,2,4; np≥16 unresolved. - [R] voro CUDA
clipCellAgainstSdfwrong by up to 22% on device at 128/256 grids (correct on OpenMP). - [D] Interstitial vs superficial drag normalisation — the only ⚠️ UNRESOLVED entry in
DECISIONS.md.
ibm-accuracy-sphere-validation.mdassigns Zick–Homsy and vdH/Beetstra to opposite conventions in the same file. Decides a (1−φ) factor on published permeabilities, so it reaches the porous-scaling study, RingBed and the gallery. - [A]
check_decomposition.pyunusable above ~100 ranks (still models full coarsening). - [A] Fixed and verified: SCALING_ISSUES #4 (NBX inter-round tag race, core
10294e6) and #5 (momentum residual stop, now default). - [A]
vof-w4is parked and unmerged (f29c8e7), correctly scoped out — flow's CLAUDE.md already documents colliding markers as outside the rating. Only a gate if release docs claim VoF collision physics; they do not. - [A] Open from QUALITY_PLAN §6: C.3 (one shared
cmake/PecletDeps.cmake— pnm/dem/coupling are byte-identical, voro and flow differ), D.6 (composite Kokkos CI action), D7 (INTERFACES.md still carries its "design sketch, not realised as code" banner — the decision betweenconcepts.hpp+static_asserts and a duck-typed contract is still open).
6. Decisions needed [D]¶
- ~~SCALING_ISSUES #1~~ — decided 2026-09-11: default flipped to double operator storage (§1.2). What remains is re-blessing, not deciding.
- Drag normalisation — settle interstitial vs superficial (§5.6) before any permeability number is republished under 1.0.0.
- D7 / INTERFACES.md — realise the concepts or restate the file as a contract.
- coupling C++ as public API? — decides whether §2.5 (no Doxygen) is [A] or [R].
7. Physical domains — carried forward¶
Phase 1 landed 2026-09-06, Phase 2 (anisotropic cells) 2026-09-07; see
PHYSICAL_UNITS_PLAN.md and the archived
RELEASE_PREP_0.7.x.md §8. This is the headline feature of 1.0.0
alongside the API clean break. Open from Phase 2: the rotating-body torque gate,
check_decomposition predict(), and the coupling/AMR guards.
Note for readers of the submodule docs: flow/doc/anisotropic_vof.md and
amr/docs/amr_anisotropic.md cite "RELEASE_PREP §8.1 / §8.2". Those refer to the archived
0.7.x file, not to a section of this one.
8. Gallery [A]¶
audit_examples.py over 56 pages flags 14 as NEEDS-NEXT-RELEASE, all for one symbol — dem's
set_periodic/relax. That is expected: the gallery is already written against the unreleased 1.0.0
dem API, and the flag clears when dem is tagged. Re-run after the release and re-check the pages the
QUALITY_PLAN rename table touches.
9. Execution log — the 1.0.0 release run (started 2026-09-12)¶
Appended as the release is executed. Newest entries at the bottom. Anything here that contradicts §§1-8 supersedes them; §§1-8 were written before the work was done.
2026-09-12, Phase A + blockers.
- G.8 landed first (flow
61e9f58+8f79c3b, umbrella02b389b), since it changes build time and nothing else:Solver<Grid>is compiled once per grid rather than once per consumer, and consumers see declarations only. Bit-exact, no step-time change, CI green. Full numbers in QUALITY_PLAN §3.G.8. A flow rebuild is now 12 CPU-minutes instead of 45, which is what makes re-running the release matrix on this host affordable at all. - §1.1 core-pin staleness, confirmed as sequencing, not a defect. voro's CI is RED on main and the
cause is exactly the predicted one: run
34645794829, jobkokkos-openmp, step "Build against the pinned core / morton tags", failing withpeclet/core/solver/coloring.hpp: No such file or directory. It turned red only now because voro's own8cdac25made that guard compile rather than merely configure. It clears when core is tagged and voro repins. voro cannot be green on main before core is tagged, so RELEASE.md §1.4's "CI green on every repo" is not satisfiable for voro until step one of Phase E is done. Expect it; do not treat it as a new blocker. - The umbrella Site workflow was red and is now green (
f6392ba).mkdocs build --strictaborted on four broken links indocs/archive/RELEASE_PREP_0.7.x.md: archiving the 0.7.x prep file moved it intodocs/archive/without repointing its relative links, soQUALITY_PLAN.md,RELEASE.mdandPHYSICAL_UNITS_PLAN.mdstill resolved againstdocs/, andarchive/VORONOI_METHODS_PLAN.mdkept a now-doubledarchive/prefix. Worth remembering as a class: archiving a document breaks every relative link inside it, and strict mode turns that into a red release gate. - §1.2 measured — the regression baselines do NOT move. See §1.2 above, rewritten with the
numbers.
--updatemust not be run.
Phase B, as it completes (host prefix extern/install/host-openmp, CUDA prefix
extern/install/nvidia-cuda, OMP_NUM_THREADS=4 OMP_PROC_BIND=false, trees named build_rel*):
| package | result | counts |
|---|---|---|
| core | PASS | 53 plain / 68 Kokkos / 6 Python, host and CUDA, np 1-8, 0 skips |
| morton | PASS | default 1/1, non-BMI2 2/2 including the PDEP/PEXT-free contract, Kokkos-OpenMP 2/2, Kokkos-CUDA 2/2, pytest 9/9, 0 skips |
| flow | PASS at the float default | 155/155 host (49 single-rank + 106 MPI) and 49/49 CUDA single-rank, before and after G.8; regression PASS; re-run at the double default pending §1.3's landing |
| dem | pending | |
| voro | pending | |
| pnm | pending | |
| amr | pending | |
| coupling | pending |
Corrections Phase B has produced so far:
core/CLAUDE.md's ctest counts are stale: it says 52 plain / 67 Kokkos; the measured numbers are 53 plain / 68 Kokkos (54/69 counting thebenchlabel), identically on host and CUDA. The umbrellaCLAUDE.md's "109 plain / 164 Kokkos / 7 Python" for core is wrong by a wider margin and looks like a pre-AMR-split figure. Fix both before tagging; they are quoted in release notes.- morton's AVX-512 batch kernels are UNVALIDATED for this HEAD. This host is a Threadripper PRO 5965WX with no AVX-512F, and Intel SDE is not installed and could not be obtained. The path was not silently skipped — it was not exercised. Either confirm it was validated elsewhere for this HEAD or state it as an untested path in the release notes. Do not claim it passed.
- morton's nearest tag by ancestry is
pre-legacy-removal, notv0.2.1—git describepicks the marker tag, which is why the pre-flight prints an odd "since" count. The changelog writer should diff againstv0.2.1.
2026-09-12, the one step that needs the maintainer's hands. peclet-amr is a NEW PyPI project
(https://pypi.org/pypi/peclet-amr/json returns 404), and Trusted Publishing will not accept a first
upload for a name that has no publisher registered. Two halves:
- GitHub half: DONE.
peclet-amrhad nopypideployment environment, which itsrelease.ymlpublish job declares (environment: pypi), so the job would have failed after building the sdist. Created to mirrorpeclet-pnm's (no protection rules, no branch policy). - PyPI half: CANNOT BE DONE HEADLESSLY. Register a pending publisher at
https://pypi.org/manage/account/publishing/ BEFORE the
v0.1.0tag is pushed, with exactly:
| field | value |
|---|---|
| PyPI Project Name | peclet-amr |
| Owner | computational-chemical-engineering |
| Repository name | peclet-amr |
| Workflow name | release.yml |
| Environment name | pypi |
Every other package already exists on PyPI and needs nothing. If the tag goes first, the build succeeds and only the publish step fails; re-running that job after registering is enough, so this is recoverable rather than fatal — but it burns a release run.
Also created: coupling/CITATION.cff (coupling fefc2c4), closing §3.2. It was the only package
without one, so its GitHub release would have been the family's only archive with no authorship or
title metadata for Zenodo to read.
2026-09-12, the double default made two GATES fail, and both were the gates' fault.
Running flow's full battery at the new default (build_rel_omp, host-openmp) gave 153/155, with
exactly two failures. Neither was a solver defect, and neither was tolerance noise to be widened away;
both were tests measuring the wrong thing, which float storage had been hiding.
verify_lid_cavity_sdflowmarched to "steady state" by watching the plane-MEAN ofuchange by less than 1e-5 relative between 50-step samples. In a lid-driven cavity that mean is near zero by symmetry, so it is dominated by round-off in the pressure solve rather than by the flow: the test was watching noise settle, not the field converge. Float's higher noise floor kept the march running by accident. With double storage the mean settled sooner, the march stopped at step 400 instead of 650, and an unconverged field was compared to Ghia — centreline min u −0.1934 against the tabulated −0.2058, rms 0.0247/0.0256 against a 0.02 gate. Fixed (flow1ef4330) to the largest velocity change anywhere on the plane, with the threshold read off a measured trajectory rather than guessed:maxdu/Udecays 1.5e-1 (step 100) → 1.4e-2 (400) → 4.3e-3 (650) → 9.6e-4 (1000) → 2.1e-7 (3000), while the Ghia error reaches a plateau of u_rms 0.0067 / v_rms 0.0053 by about step 1400 and never moves again. The result that settles the question: with the corrected criterion the float and double builds agree to four digits — both 1000 steps, u_rms 0.0067, v_rms 0.0037, min centreline u −0.2124, max flux divergence 1.1e-16. Operator storage precision does not change the converged cavity answer at all.vardensity_mpi_np4required the distributed Chebyshev V-cycle count to equal the single-rank count EXACTLY at every rank count. That is not well posed above np = 1: the stopping test ismaxabs(r) < rtol*r0and at np > 1 both sides come from a global reduction whose summation order is not the single-rank order, so a step near the threshold can spend one V-cycle more or less. The test's own comment already recorded that the count sequence changes with the OpenMP thread count alone at fixed np = 1 — the conclusion was simply never drawn. Every physics gate passed by three orders of magnitude or more while it failed: du 4.4e-17 against 1.0e-15, dp 5.7e-13 against 1.2e-08, max|u| 2.8e-17 against 1e-14, dP/dz error 1.1e-15 against 1e-11. Fixed (flow65b2b6a): the count gate is exact at np = 1, ±1 above it; the answer tolerances are untouched. A real decomposition defect shows as a large and growing divergence, never as a steady ±1.
flow 228fe02 corrects flow/CLAUDE.md, which described the double build as "151/155 with four
float-tuned gates". Two of those four (vof_bc_mpi_np2, vof_bc_mpi_np4) do not reproduce at all.
The lesson for the rest of this release: the stale-build trap (§1.3, fixed in flow 41c8356) was
hiding both of these. The first honest local run of the verify battery is the run that found them. Any
"it passed before" from before that commit is not evidence.
2026-09-12, Phase B results and two more traps.
| package | result | counts |
|---|---|---|
| core | PASS | 53 plain / 68 Kokkos / 6 Python, host and CUDA, np 1-8, 0 skips |
| morton | PASS | default 1/1, non-BMI2 2/2 incl. the PDEP/PEXT-free contract, Kokkos-OpenMP 2/2, Kokkos-CUDA 2/2, pytest 9/9, 0 skips. AVX-512 NOT re-validated (no AVX-512F on this host, no Intel SDE) |
| pnm | PASS | 9/9 host and CUDA, 0 skips; np = 1/2/4 bit-exactness genuinely ASSERTED against a single-rank oracle, not assumed; 7199 pores / 7499 labels / 53020 connections on packing_ring.vti, identical on both backends |
| flow | PASS at the DOUBLE default | 49/49 single-rank; MPI block re-running after the two gate fixes. 49/49 CUDA single-rank. Regression PASS |
| amr | PASS, shippable as 0.1.0 | 100/100 host; CUDA 99/100 + 1 skip |
| coupling | PASS | 3/3, 0 skips; docstring audit 12/12 documented |
| dem | running | |
| voro | running |
Docstring audit, finally run against a CLEAN family build (§2.1's blocked item): 955 callables,
15 undocumented, 0 stale — not the "894 / 36" that §2.1 correctly refused to trust, because every
tree that earlier run imported predated the renames. All nine modules import from build trees in one
interpreter, none from site-packages, verified before the audit ran. The 15 are being written: flow 6
(has_scene, pressure_telescope, velocity_multigrid_active, each on both grids), pnm 1
(SDFReader), voro 2 (the two __init__s), core 6 (SceneBuilder and five of its members).
amr's one CUDA skip is self-diagnosing and correct. python_state_hash exits 77 because
peclet.amr.build_toolchain reports 'GNU14.2.0Releasex86_64Kokkos5.1.1' on the CUDA build against
the committed reference's spaced 'GNU 14.2.0 Release x86_64 Kokkos 5.1.1': the
target_compile_definitions(amr PRIVATE "PECLET_AMR_BUILD_TOOLCHAIN=\"…\"") string loses its
embedded spaces when the TU is routed through the Kokkos launch compiler. Same compiler, same
everything — a quoting defect in the label, not in the build. The gate did exactly what it is for:
skipped rather than compare across toolchains, and it cannot produce a false pass. [A] fix the
quoting after the release.
Worktrees and unmerged branches — RELEASE.md §1.1, resolved by PARKING, not merging. flow carries
14 worktrees and the suite has 7 unmerged flow branches (analysis/high-re-stability, vof-issues,
vof-v6, vof-w0, vof-w12, vof-w4, vof-wor2), plus dev/aperture-compat-rhs and
drag-study-harness in core/amr and feature/flexible-cell-storage in voro. Every one of the flow
branches is between 72 and 629 commits BEHIND main, and their content has been integrated by other
routes — vof-issues, the only one ahead by a substantial 10 commits, carries the free-slip domain
BC, step_adaptive, the visible preconditioner-breakdown flag and the contact-angle wall binding,
and main already has all four (kBcTypes includes "slip", stepAdaptive is in
flow_ibm_vof.hpp, pressure_solve_failed is bound, and test_wall_slip_mpi.cpp supersedes the
branch's test_freeslip_bc_mpi.cpp). Decision: all are explicitly PARKED and their work is NOT in
1.0.0. The worktrees are NOT removed — removal would destroy other sessions' build trees for no
release benefit, and §1.1 is satisfied by explicit parking. Reversible: run the removals later.
2026-09-12, Phase E — tagging and publishing. Phase B finished green in all eight packages, so the release was cut. Order as §0 requires: core and morton first, then the four method packages that vendor their headers, then coupling, then the umbrella.
| package | tag | PyPI |
|---|---|---|
| peclet-core | v1.0.0 |
1.0.0 |
| peclet-morton | v1.0.0 |
1.0.0 |
| peclet-pnm | v1.0.0 |
1.0.0 (+ peclet-pnm-cu13 1.0.0) |
| peclet-dem | v1.0.0 |
publishing |
| peclet-voro | v1.0.0 |
publishing |
| peclet-coupling | v1.0.0 |
1.0.0 |
| peclet-amr | v0.1.0 |
0.1.0 |
| peclet-flow | pending its CI |
§1.1's sequencing premise is CONFIRMED, not merely assumed. voro's CI had been red on main
since 2026-09-11 because it vendored core at v0.6.1, a tag predating the two solver headers it
includes. The moment core v1.0.0 existed and voro's PECLET_CORE_TAG was repinned to it, that CI
went green with no other change. amr was in the same state against six core headers. This is worth
keeping in the durable workflow: a consumer's pinned-tag CI job cannot be green between the commit
that adopts new core headers and the release that tags them — it is a scheduled red, not a defect,
and RELEASE.md §1.4's "CI green on every repo" has to be read with that exception.
The amr PyPI worry was half right and the half that mattered was fixable from a shell. The
missing piece was not a PyPI pending publisher but the pypi DEPLOYMENT ENVIRONMENT in the
peclet-amr GitHub repo, which its release.yml publish job declares (environment: pypi) and
which every other package already had. Created to mirror peclet-pnm's, and the first v0.1.0
publish then went through untouched — sdist and publish both green, peclet-amr 0.1.0 live. No
manual PyPI registration was needed. Record it that way so the next new package in this family
checks the GitHub environment first.
GitHub Releases created (the Zenodo webhook mints a version DOI from each): peclet-core
v1.0.0, peclet-morton v1.0.0, peclet-pnm v1.0.0, peclet-dem v1.0.0,
peclet-coupling v1.0.0, peclet-amr v0.1.0. Each carries a package-specific paragraph plus the
shared 1.0.0 note; voro, flow and the umbrella follow once their wheels finish. Check the Zenodo
deposition for each afterwards — a webhook that fires before CITATION.cff is in the tag reads no
metadata, which is why coupling/CITATION.cff was created before any tagging (coupling fefc2c4).
What remains, as of 2026-09-12 ~01:50Z¶
Everything below is mechanical and ordered; the working tree is already prepared for it.
- Waiting on:
peclet-voroandpeclet-flowCUDA-wheel jobs (Releaseworkflow on theirv1.0.0tags). Their sdists and CPU wheels are already green; onlycuda wheelsis outstanding. These jobs build Kokkos-CUDA statically with SASS for every supported GPU major, so 40-90 minutes is normal. Nothing needs doing until they finish. - When they do: confirm
peclet-voro,peclet-voro-cu13,peclet-flow,peclet-flow-cu13all read 1.0.0 on the PyPI JSON endpoint (pip index versionscaches for minutes — use the JSON), thengh release create v1.0.0for both, with the same per-package paragraph plus shared body used for the other six. - Then the umbrella, LAST — its pins are exact (
==) and cannot resolve until every member is live. The commit is already staged in the working tree:CHANGELOG.md(the[Unreleased]heading is now## [1.0.0] — 2026-09-12and the Tested table is written),CITATION.cff,pyproject.toml,packaging/pyproject-cu13.toml, and all eight submodule pointers, which already equal the tagged commits (verified tag-by-tag). Commit those by NAME, push, then tagv1.0.0— that publishes both thepecletandpeclet-cu13metapackages and triggersContainers. - Smoke test in a fresh venv per RELEASE.md §6:
pip install peclet==1.0.0then import flow/dem/voro/pnm/morton; repeat withpeclet[mpi,cfd-dem](the sdist members, core and coupling — the core sdist was silently unbuildable 0.1.0 through 0.6.0, so this one matters). - Check a CUDA wheel carries native SASS for every GPU major, not just PTX:
cuobjdump --list-elf <module>.so. RELEASE.md records that 0.4.0-0.5.0 shipped sm_75 plus PTX only and failed on every non-Turing GPU with a 13.0/13.1 driver, because a driver older than the toolkit that emitted the PTX cannot JIT it and the launch silently no-ops. - After the release, not blocking: the Snellius site package (Phase F), the LUMI one (Phase G,
still untested on AMD hardware), Zenodo deposition checks, and the example gallery re-render
(Phase I) —
~/Codes/peclet-examplesholds ~30 local commits written against this API and pushes nothing until the wheels exist, which they now do.
10. RELEASED — peclet 1.0.0, 2026-09-12¶
All nine repositories are tagged, published and GitHub-released.
| package | tag | PyPI | CUDA twin |
|---|---|---|---|
| peclet-core | v1.0.0 |
1.0.0 (sdist) | — |
| peclet-morton | v1.0.0 |
1.0.0 | — |
| peclet-flow | v1.0.0 |
1.0.0 | peclet-flow-cu13 1.0.0 |
| peclet-pnm | v1.0.0 |
1.0.0 | peclet-pnm-cu13 1.0.0 |
| peclet-dem | v1.0.0 |
1.0.0 | peclet-dem-cu13 1.0.0 |
| peclet-voro | v1.0.0 |
1.0.0 | peclet-voro-cu13 1.0.0 |
| peclet-coupling | v1.0.0 |
1.0.0 (sdist) | — |
| peclet-amr | v0.1.0 |
0.1.0 (sdist) | — |
| peclet (metapackage) | v1.0.0 |
1.0.0 | peclet-cu13 1.0.0 |
Post-publish verification, all done:
- CPU wheels install and run in a clean interpreter, reporting 1.0.0 and the OpenMP backend.
- The core SOURCE distribution builds and imports — 5 s in a fresh venv. This is the check RELEASE.md flags as having silently failed from 0.1.0 through 0.6.0; it does not recur.
- flow's CUDA wheel carries native SASS for sm_75, 80, 90, 100 and 120, confirmed with
cuobjdump --list-elfon the downloaded wheel — not PTX alone, which is what made 0.4.0 and 0.5.0 abort at import on every non-Turing GPU with a 13.0/13.1 driver. pip install "peclet[mpi,cfd-dem,amr]==1.0.0"into a fresh venv resolves all nine packages in 70 s, every module imports in one interpreter, and a five-step flow solve runs.
One operational lesson worth carrying into RELEASE.md. The umbrella's publish job failed the
first time with requests.exceptions.ConnectionError: Connection reset by peer raised inside
sigstore while signing the attestation against the Rekor transparency log. Both build jobs had
succeeded and nothing had been uploaded — the failure is upstream of the upload. gh run rerun
<id> --failed fixed it on the first retry. Treat a publish failure whose traceback ends in
sigstore/_internal/rekor/client.py as transient infrastructure, re-run the job, and do NOT start
editing versions or cutting a new tag; skip-existing: true makes the retry safe even if part of an
upload had landed.
Containers: ALL FOUR published, Containers workflow green on the v1.0.0 tag —
peclet-cpu, peclet-cuda (sm80 and sm90) and peclet-hip (gfx90a), each pushed under both
the versioned and the moving tag (log confirms e.g. peclet-cuda:1.0.0-sm80 then
peclet-cuda:sm80, both "Upload complete"). This is the first tag on which hip-gfx90a has
succeeded — RELEASE.md §0 records it failing on every tag from 0.3.0 through 0.6.0, and §8 that it
has built since 2026-09-04. The image is published; it remains UNTESTED on AMD hardware, which is a
different claim and stays a known limitation.
Remaining, none of it blocking and all of it after-the-release by design: the Snellius site
package (RELEASE.md Phase F), the LUMI one (Phase G, still untested on AMD hardware), confirming
each Zenodo deposition minted a version DOI, and re-rendering the example gallery (Phase I) —
~/Codes/peclet-examples holds around thirty local commits written against this API and pushes
nothing until the wheels exist, which they now do.
Deferred with a reason, not forgotten: the interstitial-vs-superficial drag normalisation is
still the one unresolved entry in DECISIONS.md, so no permeability number is published in the
1.0.0 notes; morton's AVX-512 batch kernels were not re-validated for this HEAD and the notes say
so; and amr's CUDA python_state_hash skip is a quoting defect in the build-toolchain LABEL that
loses its embedded spaces through the Kokkos launch compiler.
Post-release: the landing page did not run (found by a user, 2026-09-12)¶
The very first thing 1.0.0 offered a new reader — the quick start on README.md, docs/index.md
and the Colab notebook — raised TypeError on line 12. The clean break had renamed
cell_centres() to cell_centers() and repacked set_body_force(fx, fy, fz) into one
3-sequence, and no check in this document looks at the suite's own docs: audit_examples.py scans
the sibling gallery only (examples/, benchmarks/, sanity-checks/), and §5.2's instruction
is to read the prose, which catches a stale version number but not a deleted method.
Fixed on those four pages (docs/CONVENTIONS.md carried the old spelling too), verified by running
all three pages against pip install peclet-flow == 1.0.0 in a fresh venv: k = 1.23512e-01 at
N = 48, matching the notebook's committed output and its refinement table, so the stored outputs
did not need regenerating.
The gate is now CI, not a checklist line. .github/workflows/quickstart.yml runs
tools/release/check_docs_snippets.py --installed --run on a docs push or PR, weekly, and as a
needs: publish job of release.yml on every tag — against the wheels that tag just put on
PyPI, retrying while the index propagates (RELEASE.md §5.2, §11). A release is not done until that
job is green. Two lessons in the shape of the tool's two passes:
- Nothing was executing the docs. A page reachable from the front door is a test, and it should be run in a venv holding the published wheels — the same thing the Colab badge does.
- A static name audit cannot see a signature change.
cell_centres→cell_centersis a NAMES failure;set_body_force(fx, fy, fz)→set_body_force(force)renamed nothing and is invisible to every audit intools/release/— only running it finds that class. Any future clean break should assume there are more of these than renames.
Two coverage holes in audit_examples.py surfaced while fixing this and are closed in the same
commit: amr's bindings moved out of core in G.2 and were never re-registered (so every
peclet.amr call in the gallery read as an unknown name), and voro's packaging/voro_scenes.py /
voro_pore_mesh.py were missing (scenes.sphere_union_scene read as retired). Both were silent
because surface() swallowed a missing path; it now warns.
Post-release: the film, and the channel goes public (2026-09-12)¶
The 1.0.0 release film is live — https://www.youtube.com/watch?v=L_uAHnL6Gq8, 7:30, public, and the channel trailer — and every clip on @PecletHPC is now public (trickle flow was the one held back for review). The workflow is written up as RELEASE.md §10.1; what this run learned:
- The film was cut on two clocks and nobody would have noticed for six minutes.
assemble.pygives each beat its narration plus a 0.55 s pause, butnarration_full.wavhad been concatenated by hand from the beat wavs with no pauses at all. The picture therefore lagged the voice by 0.55 s per beat — 13.2 s by beat 25 — and-shortestcut the tail: the last frame of the published-to-be film was the packed-bed g(r) figure while the narrator said "Peclet, version one point zero". Fixed by building the track fromnarration.jsonandvoice.yaml'spause.beatinsideassemble.py, so one constant governs both tracks (the caption clock was countingbeat - sentence, drifting 0.28 s a beat the other way, and now agrees too). The film was re-muxed from the unchanged picture: 7:17 → 7:30, closing card restored. A narrated film needs watching to the end before it is published; a duration that looks plausible is not evidence. - The four remaining pages with a local mp4 now embed the YouTube copy (peclet-examples
56d7ed2), which took the last 11 MB of video out of that repository. Their Quarto freezes were deliberately not re-stamped: 45 of the 48 frozen pages are already stale from the 1.0.0 migration and are waiting on the §10 re-render, and marking three of them fresh would have published pre-1.0.0 output as current. That also leaves the committed.ipynbcopies for the same pass (quarto convertregenerates them). - Interlinking, both directions. Every video description carries its page, the repository and
the documentation —
site.docsinvideos.yamlhad been pointing at the PyPI project page, not the docs site. The docs home page, the gallery landing page and the README carry the film; the gallery navbar and the docs footer carry the channel. - Captions are not uploaded.
captions.insertneeds theyoutube.force-sslscope, which the stored token does not hold, and widening it means another browser consent round; YouTube's automatic captions cover the narration in the meantime.films/release-1.0.0/build/captions.srtis correct on the film's clock if that is ever revisited.
11. NEXT CYCLE — 1.0.1, the container thread-pool patch (opened 2026-09-13)¶
Why a release at all. The fix lives inside the compiled modules, so no user gets it from a docs
change: peclet::core::python::install() now sizes the host pool by the process's real CPU budget
(core/include/peclet/core/common/cpu_budget.hpp, SCALING_ISSUES issue 7). Without it, a Colab or
Binder user's FIRST run collapses by ≥35x, silently. That is a patch-worthy regression in practice
even though no code was wrong at 1.0.0.
Scope: patch only. No API change, no numerics change. On an unconstrained machine the new code
requests nothing and the build is inert — that is the property to state in the notes, and the one
core's test_cpu_budget pins.
Order (RELEASE.md §6: core first, umbrella last):
core→ v1.0.1. Merge branchcpu-budget(worktreesuite/core-cpu-budget), gate on the full ctest battery, tag, publish.- Repin
PECLET_CORE_TAG "v1.0.0"→"v1.0.1"incmake/PecletDeps.cmakeof flow, dem, voro, pnm, coupling, amr (mortondoes not use core), rebuild, gate, tag: flow/pnm/dem/voro/coupling → 1.0.1, amr → 0.1.1 (D9, stays 0.x). - CUDA twins with them:
peclet-flow-cu13,peclet-pnm-cu13,peclet-dem-cu13,peclet-voro-cu13→ 1.0.1 (four literals, §5.3). - Umbrella
pecletandpeclet-cu13→ 1.0.1, pins bumped, pushed LAST.
Gates. Each repo's own ctest battery, plus:
core:test_cpu_budget(fixture cgroup trees) — and the real-cgroup probe, which is the check that actually matters and is not automatable in CI:systemd-run --user --scope -p CPUQuota=200% <probe>must reportusable=2with 48 CPUs visible,0threads requested when unconstrained, and0whenOMP_NUM_THREADSis set.- End-to-end, on a rebuilt flow wheel: the quick start under a 2-CPU quota with all CPUs visible must finish in ~2.5 s — the retuned N = 32 page (§11.1); the N = 48 page it replaces was ~26 s. At 1.0.0, with the pool unbounded, neither finishes in 15 minutes (both measured). This is the number the release exists for; do not tag without it.
tools/release/check_docs_snippets.py --installed --runagainst the published 1.0.1 wheels — thequickstart.ymljob does this automatically as aneeds: publishjob of the tag.
11.1 What the first outside reports turned up (2026-09-13)¶
A Windows laptop and a Colab runtime, both on the published 1.0.0 wheels. Two separate problems, one release.
Windows: pip install peclet ends inside CMake. There is no Windows wheel and no macOS wheel;
pip answers a missing wheel by falling back to the sdist, and the source build then dies with
"No CMAKE_CXX_COMPILER could be found" — a message about a compiler, when the answer is "not this
operating system". Nothing on the PyPI page, in README.md or in DEPLOYMENT.md had said Linux.
Fixed: a pre-project() guard in flow, dem, pnm, voro, core, amr and coupling (commit say "Linux
only" before CMake says "no compiler") naming WSL2, a Linux container and Colab, with
-DPECLET_ALLOW_UNSUPPORTED_PLATFORM=ON as the escape hatch; Operating System :: POSIX :: Linux
in every package's classifiers (umbrella metapackages included); an "Operating system: Linux
x86-64" section in docs/DEPLOYMENT.md and a paragraph in README.md / docs/index.md. The
guard ships inside the sdists, so it reaches a Windows user only at 1.0.1 — the docs reach them
today.
Colab: the quick start took ~5 minutes, not the promised half-minute. Not the thread-pool trap
of issue 7: the bootstrap cell bounded the pool correctly (it printed OMP_NUM_THREADS = 2) and
the run returned the same 42 steps and the same k as CI. The promise was simply written against
the wrong machine — the N = 48 solve is 25.6 s on two Threadripper 5965WX cores and ~35 s on a
two-core GitHub runner, and a free Colab runtime's two shared vCPUs are several times slower again.
So the quick start was retuned (docs-only, live now):
| 1.0.0 | now | |
|---|---|---|
| resolution | N = 48 | N = 32 |
| stop rule | increment < 1e-5, single step | three-step window < 1e-3 |
| steps to steady | 42 | 14 |
| solve, two cores | 25.6 s | 2.5 s |
| permeability k | 1.23512e-01 | 1.2407e-01 |
Both k values sit within 0.2 % of what their own grid gives when iterated to convergence (N = 32 →
0.123885, N = 48 → 0.123495 at 200 steps), so the cheap run is not the less honest one; N is
still the knob, and the page now says what the finer grids cost. The single-step increment test was
the worse half of the old bill: at N = 32 it fires at 13 steps for tol 1e-4 and for 3e-5, and
only at 43 for 1e-5, because the increment dips through the tolerance on the way down. The
three-step window does not have that failure mode at any N tested.
Two measurements from the diagnosis, worth keeping:
- Mild oversubscription is cheap. 2 OpenMP threads on 1 CPU (affinity) cost 12 % against 1 thread on 1 CPU; 4 threads on 2 CPUs cost 35 %. The >=35x collapse of issue 7 needs a large ratio (48 visible, 2 granted) — a slightly-too-big pool is not a cliff.
- Per-cell step cost is flat to N = 48 and doubles at N = 64: 5.6 / 5.6 / 5.5 / 10.4 µs per cell
per step at N = 24 / 32 / 48 / 64 on two cores, while the steps to steady go 13 / 14 / 31 / 51.
N = 64 therefore costs ~80x N = 32, not the 8x the cell count suggests. Unexplained — a candidate
for
SCALING_ISSUES.mdif it reproduces at scale or on a GPU.
11.2 Wheels for four platforms (probe, 2026-09-13)¶
The Windows report above asked a question nobody had asked: does the family build anywhere but
Linux x86-64? A throwaway wheel-probe.yml (umbrella, workflow_dispatch) answered it in five
rounds — cibuildwheel for one interpreter per package per runner, fail-fast: false.
Result: everything builds. Nothing in peclet's C++ was the obstacle, and nothing numerical
changed. Kokkos 5.1.1 configures under MSVC 19.51 with /std:c++20 and under AppleClang 21 with
-std=gnu++20. Five build-system assumptions failed, each inert on Linux, each now fixed:
| what failed | fix | |
|---|---|---|
| MSVC | C1128: number of sections exceeded object file format limit in the templated Kokkos TUs |
/bigobj |
| MSVC | LNK2038: RuntimeLibrary MDd_DynamicDebug doesn't match MD_DynamicRelease |
a multi-config generator ignores CMAKE_BUILD_TYPE; the nested install needed --config Release |
| MSVC | D8021: invalid numeric argument '/Wextra', including inside nanobind-static |
voro handed GNU warning flags to every target |
| MSVC | C2065: 'M_PI': undeclared identifier ×8 |
M_PI is POSIX, not C++; replaced by the same double (memcmp-identical to glibc's) |
| MSVC, AppleClang | Could NOT find OpenMP_CXX — version 2.0, and absent, respectively |
vendor Kokkos with OpenMP where find_package(OpenMP 3.0) succeeds, Serial where it does not |
The OpenMP finding is the one that shaped the outcome. MSVC defines _OPENMP as 200203 whatever
runtime is selected — /openmp:llvm reaches the LLVM runtime and CMake still reads 2.0, measured —
and Kokkos requires 3.0. AppleClang ships no OpenMP at all, and Homebrew's libomp is a dead end for
wheels: it now publishes only a Tahoe bottle, so delocate refuses anything with a floor below macOS
26. So:
DECISION — Windows and macOS wheels ship the Kokkos Serial backend; Linux keeps OpenMP.
Rejected: vendoring a libomp built from LLVM source in every macOS wheel job (a floor of macOS 11 for
free is worth more than threads on a laptop, and coupling importing flow+dem would then load two
libomp copies); and arguing MSVC's _OPENMP down by pre-setting OpenMP_CXX_SPEC_DATE, which fakes
a capability check. execution_space reports the backend, and the quick start prints it, so nobody
is misled. Reversible in one line of each cmake/PecletDeps.cmake; a toolchain that grows a usable
OpenMP is picked up with no change at all.
The shipping matrix (each cell green on the probe, in the release configuration):
| x86-64 | aarch64 | backend | |
|---|---|---|---|
| Linux | ✅ | ✅ | OpenMP |
| Windows | ✅ | — (no runner) | Serial |
| macOS | — (no Intel runner) | ✅ | Serial |
CPython 3.10–3.14 (morton from 3.9). cp314 was missing everywhere and is the same trap from a different direction: a user on a current Python fell through to a source build even on Linux. Verified green for all five packages.
Not shipped, and why: macOS x86-64 — GitHub has retired the Intel runners (a macos-13 job now
queues forever), so it could only be cross-compiled and never tested; Intel Macs build from the sdist,
which the probe showed works. musllinux — never tried. win_arm64 — no runner, nobody asking.
Full-matrix validation (run 34771289649, peclet-flow, every interpreter on every runner): 20
wheels, all green, and each one reports the backend this section claims —
| runner | wheels | peclet.flow.execution_space |
|---|---|---|
| ubuntu-latest | cp310–cp314 manylinux_2_28_x86_64 |
OpenMP |
| ubuntu-24.04-arm | cp310–cp314 manylinux_2_28_aarch64 |
OpenMP |
| windows-latest | cp310–cp314 win_amd64 |
Serial |
| macos-latest | cp310–cp314 macosx_11_0_arm64 |
Serial |
Free-threaded CPython built and passed too (cp314t on all four) because the probe's selector was
cp3*; the release selector is cp314-*, which excludes it. nanobind needs an explicit
mod_gil_not_used() before that is worth shipping — a separate decision, not this cycle's.
Still unexercised: the release workflow itself. The matrix above was measured through
wheel-probe.yml, which runs the same cibuildwheel against the same pyproject but not the sdist,
artifact-collection or publish jobs around it. Dispatch one release.yml manually before tagging —
workflow_dispatch builds everything and the publish job is gated on a tag push, so nothing reaches
PyPI.
Delete wheel-probe.yml once a real release has gone out on this matrix; it exists only to
answer a question that is now answered.
11.3 Threads, not Serial, where there is no OpenMP (2026-09-13)¶
§11.2 shipped Serial to Windows and macOS because neither toolchain supplies an OpenMP that Kokkos
accepts. That was the wrong stopping point: Kokkos' C++ std::thread backend needs no OpenMP
runtime at all, and the suite is indifferent to which host backend it gets — exactly one line in
eight repositories names Kokkos::OpenMP, and it is #ifdef-guarded (core/.../grid_halo.hpp, an
MPI-only early-out, which now names Kokkos::Threads as well).
Measured, quick start at N = 32 on the wheel each runner built (runs 34784791863 / 34784795953). Every run returned 14 steps and k = 1.2407e-01 — the backend changes nothing numerical:
| platform | vCPU | backend | 1 thread | 2 threads | all threads |
|---|---|---|---|---|---|
| Linux | 4 | OpenMP | 5.73 s | 2.99 s | 3.04 s |
| Windows | 4 | Threads | 6.74 s | 3.87 s | 6.79 s |
| Windows | 4 | Serial | 6.37 s | 6.37 s | 6.34 s |
| macOS | 3 | Threads | 3.85 s | 3.00 s | 3.91 s |
| macOS | 3 | Serial | 5.12 s | 4.99 s | 5.72 s |
DECISION — Windows and macOS wheels ship Kokkos::Threads. Supersedes the Serial decision in
§11.2, which stood for about four hours. What changed: Serial was chosen against OpenMP and its
vendored-libomp costs, and the third option was not on the table. Threads is 1.65x faster than
Serial on both platforms at the same numbers (3.87 vs 6.37 on Windows, 3.00 vs 4.99 on macOS),
needs no bundled runtime, asserts no version the build cannot check, and keeps the macOS wheel floor
at 11. Rejected, unchanged: clang-cl (real OpenMP 5.0, but swaps the compiler and bundles
libomp.dll) and pre-setting OpenMP_CXX_SPEC_DATE to walk MSVC's _OPENMP = 200203 past Kokkos'
3.0 gate (asserting a version nothing verifies).
The "all threads" column above is mislabelled, and the reason matters. Those runs left the thread
count unset, and on both platforms they came back at exactly the one-thread time (6.79 vs 6.74 s;
3.91 vs 3.85 s). That is not a spin-wait collapse — it is the default. Kokkos::Threads asks hwloc
for the topology and falls back to one thread when hwloc is absent
(Kokkos_Threads_Instance.cpp:487), and hwloc is absent in every peclet wheel. On 48 real cores, the
same wheel:
| threads | 1 | 2 | 4 | 8 | 16 | 24 | 48 | unset |
|---|---|---|---|---|---|---|---|---|
| best of 3 | 4.420 s | 2.314 | 1.240 | 0.905 | 0.741 | 0.644 | 1.023 | 4.428 |
So the backend scales perfectly well — 6.9x on 24 cores for a 32³ problem — and ships at 1/7th of it unless something supplies a count. OpenMP never had this problem: its default is the whole machine.
Ordering, and what it means for the wheels. The thread-count fix lives in peclet-core, which
the wheel builds pull by tag (PECLET_CORE_TAG, FetchContent). So it reaches a Windows or macOS
wheel only after step 1 of this cycle — core tagged v1.0.1 — and step 2, the repin. Until then a
Threads wheel built from main is single-threaded. Verified locally instead, end to end on a real
Threads wheel built against the branch: the quick start's default goes 4.428 s → 0.899 s (4.9x,
k unchanged at 1.24075e-01), OMP_NUM_THREADS=4 gives 1.209 s and KOKKOS_NUM_THREADS=8 gives
0.890 s — both honoured, as before.
Consequence, fixed in core 36966ad: defaultHostThreads() now asks whether the backend sizes
itself. OpenMP does (its runtime reads OMP_NUM_THREADS, its default is the machine), so its
behaviour is unchanged in every case, including the inertness on an unconstrained workstation that
the whole file exists to preserve. Threads and Serial do neither, so they are handed the budget
outright. Without that, every Windows and macOS wheel would have shipped single-threaded and said
nothing about it — the same shape of bug as SCALING_ISSUES issue 7, and this cycle exists for that
one.
11.4 vof_bc_mpi_np2 and vof_collocated_mpi — RESOLVED 2026-09-14 (gates, not code)¶
Release gate, on the tagged commit (flow v1.0.1): 155/155 at
OMP_NUM_THREADS=8AND atOMP_NUM_THREADS=2. The second number is the one that matters — 2 is the thread count at whichvof_collocated_mpi_np{2,4}used to fail while passing at 1, 4 and 8. The re-specified gates are not merely green at the documented count, they are green at the count that caught the old ones.
Both gates were asking for something the discretization does not provide, and both have been
replaced. flow's local battery is 155/155 at OMP_NUM_THREADS=8 and at OMP_NUM_THREADS=2.
The reasoning is in decisions/flow.md ("The np>1 VoF colour parity gates gate
conservation, not the pointwise field"); the work order that produced it is
wo_vof_mpi_parity_gates.md. In short:
vof_bc_mpi. The packing case's 3.174e-09 is not a decomposition defect and not a tolerance
leak. It is one ulp of colour, amplified by mycNormal's estimator selection — a strict
comparison, if (fabs(mm[cn][cn]) > t0) cn = 3;, between the centred and the Youngs candidate,
which at a near-axis-aligned interface is an exact tie. Traced on the failing face: the two runs'
3x3x3 stencils agree to 1.4e-17, the centred candidate scores 0.99986893026188128 against Youngs'
0.99986893026188106 at np=2 (Youngs wins by one ulp) and 0.99986893026188106 against
0.99986893026188106 single-rank (equal, so the centred one wins); the winners differ by 2.9e-06 in
their transverse components and the face's slab volume by 4.795e-09, which lands as +/-4.795e-09 in
two adjacent cells, equal and opposite. Over 24 (ranks, threads) pairs the outcome is binary: np=1
bitwise at every thread count, np=2 flips at 2..16 threads and not at 1, np=4 flips at 2, 3, 4, 6
and 12 but not at 1, 8 and 16 — which is the whole reason np4 passed here and CI saw nothing.
The pressure-rtol hypothesis recorded in this section is falsified: under step() the velocity
and pressure fields agree between decompositions to 1.4e-14 and 1.4e-13 (on |p| = 1.9e+02), ten
thousand times tighter than the gate, and the colour difference is not proportional to any
tolerance.
The gates now are: pointwise colour at 1e-6 (a bound on the field going wrong, verified by
injection — deleting the WO-F owner rule gives 1.000e+00 against it); a new signed-sum gate at
1e-11 relative, which is the reduction-floor gate on the quantity that actually has one (a
difference that cancels is colour moved, one that does not is colour created); and a new
packing-kin case that drives the same composed cut-cell x open-boundary scene with a
prescribed 3-D velocity and is gated bitwise — measured 0.000e+00 at np=2 and 4 and at 1..16
threads, which is the exactness the 1e-11 was reaching for and never had.
vof_collocated_mpi. hydro-z is a rest state: converged |uf| = 1.9e-13 on |P| = 1.2e+03, so
the pressure solve's r0 is the round-off of the hydrostatic balance, not a physical residual, and
differs between decompositions by an O(1) relative amount. The V-cycle count is a random walk, not
a shifted copy — over 20 steps at np=2 the sequences part at ten steps, by one at nine and by two at
step 11 (10 vs 12), and whether the worst gap is one or two depends on the OpenMP thread count alone
(one at 1, 4, 8; two at 2). d(iters) <= 1 (flow 65b2b6a's shape) was a coin toss here. The gate
is now the cost envelope — worst step within one V-cycle of the reference's worst, total within
one V-cycle per step — with np=1 keeping its exact zero.
No numerics changed: the diff is tests/kokkos_mpi/ only.
11.5 voro's CUDA job, and the rehearsal nobody knew they had (2026-09-14)¶
peclet-voro 1.0.1 is tagged but unpublished: every CPU wheel job and the sdist went green, then
cuda-wheel was killed at 6 h 0 m 3 s — GitHub's hard per-job cap — and publish, which needs
it, was skipped. PyPI still serves voro 1.0.0, so the peclet metapackage (which pins
peclet-voro==1.0.1) cannot go out either.
Nothing in 1.0.1 caused it. At 1.0.0 the same job took 4 h 59 m: it has been running at 83 % of the cap all along and this run drew a slower machine.
Where the five hours go (step timings, 1.0.0 run): CUDA toolkit install 44 s, Kokkos-CUDA prefix
2 m 20 s, then for PY in cp310..cp313 — four serial pip wheel calls at ~73 min each.
No tests run in that job at all; it is pure compilation. And each 73 minutes is one translation
unit: voro is header-only, so nanobind_add_module(voro NB_STATIC NOMINSIZE src/voro_bindings.cpp)
pulls ~12,250 lines of templated device code through nvcc, for five GPU architectures (sm_75
base + 80/90/100/120 via NVCC_APPEND_FLAGS).
The five architectures are not the thing to cut — they are the fix for a real field failure (0.4.0–0.5.0 shipped sm_75 + PTX only and Kokkos aborted at import on every non-Turing GPU with a 13.0/13.1 driver; RELEASE.md §6). What is wasteful is the structure: the device half of that TU does not depend on the Python ABI, yet it is recompiled once per CPython, serially. flow, whose module is split across TUs, does all four wheels in 54 min — 5.4x faster per wheel.
| repo | cuda-wheel duration |
|---|---|
| pnm | 9 min |
| dem | 25 min |
| flow | 54 min |
| voro | 6 h (killed) |
The fix, and the reason it need not be a gamble. Matrix the for PY loop over CPython: Kokkos
costs 2 minutes, so four parallel jobs are ~80 min each — 4.5x margin instead of none, with no build
flag and no wheel content changed. And it can be verified before any tag moves, because
cuda-wheel carries no if: in any of the four repos (RELEASE.md §11, corrected): a plain
gh workflow run release.yml builds the CUDA wheel, uploads it as an artifact and publishes nothing.
That rehearsal existed all along and the guide said it did not.
Open, for the user: (a) fix, rehearse by dispatch, then re-point v1.0.1 — never published, so
the tag has no consumers; (b) same, shipped as 1.0.2, no ref rewrite; (c) re-run unchanged. The
serial loop is in all four wheel-building members, so this is a family-wide fragility that voro
merely reached first.
Also in this cycle, if cheap: peclet-core and peclet-amr publish an sdist only. That is
why pip install peclet-core starts a Kokkos source build, and why the quick-start CI job had to
stop installing them (it hung for 20 minutes on the first attempt). Either ship wheels or say so in
their PyPI descriptions; the BUILD_GATED list in check_docs_snippets.py widens on its own once
they do.
11.6 The same fix in the other three, and the cp314 hole it exposed (2026-09-15)¶
§11.5 ended by saying the serial for PY loop was in all four wheel-building members and voro
had merely reached the cap first. It is now matrixed in all four, together with timeout-minutes
on every job, so no release.yml in the family can meet the 6-hour cap as a silent kill again.
Measured, from each package's own v1.0.1 release run:
| repo | v1.0.1 run | fixed setup (container + CUDA + Kokkos[+ArborX]) | 4 wheels, serial | per wheel | 5 wheels serial → predicted | measured, fanned out | timeout |
|---|---|---|---|---|---|---|---|
| pnm | 34788223729 | 4 m 04 s | 4 m 54 s | ~1 m 14 s | ~10 min → ~5 min | 4–5 min | 60 |
| dem | 34788226605 | 4 m 11 s | 20 m 31 s | ~5 m 08 s | ~30 min → ~9 min | 8–10 min | 60 |
| flow | 34863077902 | 3 m 35 s | 40 m 58 s | ~10 m 15 s | ~55 min → ~14 min | 12–18 min | 90 |
| voro | 34876338696 | ~3 min | (matrixed 09-14) | 72–93 min | ~5 h → ~95 min | 92–98 min | 180 |
Rehearsed by dispatch before anything was tagged, which is the property §11.5 established and
RELEASE.md §11 had wrongly denied: cuda-wheel carries no if:, only publish does, so
gh workflow run release.yml builds everything and publishes nothing. Runs 34976227196 (pnm),
34976230935 (dem), 34976234668 (flow), 34976238591 (voro), 2026-09-15: every job green in all four,
publish skipped in all four, PyPI untouched. Each produced five separate wheels-cuda-cp3XX-cp3XX
artifacts — the per-interpreter artifact name is what keeps the five jobs from overwriting each other
— and the cp314 CUDA wheel, which had never been built before, built everywhere. flow's CUDA stage
went 41 min → 18 min wall while gaining a fifth interpreter; voro's ~5 h → 98 min.
The timeouts are budgets with 5–12x margin except voro's, which is 1.8x on a 98-minute worst job. That is deliberate: the 1.0.1 kill came from a machine ~1.2x slower than the 1.0.0 one, and 180 holds that same slowdown at 118 min. Revisit if a voro job is ever seen past ~2 h.
None of the other three was near the cap, and for pnm the fan-out is a wash on wall clock — the setup dominates a 1-minute wheel, so five jobs spend ~2.7x the machine minutes to save ~5 minutes. It is done anyway: these four files diverging is not a neutral state, and the next item is what that costs.
The cp314 hole (found by the user, 2026-09-15). [tool.cibuildwheel] build gained cp314-* on
2026-09-13 (§11.2) and the cuda-wheel interpreter list did not — in all four repos. So
peclet-{flow,pnm,dem,voro} 1.0.1 each ship a CPU wheel for Python 3.14 and their -cu13 twins stop
at cp313, confirmed against PyPI: CPU ['cp310','cp311','cp312','cp313','cp314','sdist'] against
cu13 ['cp310','cp311','cp312','cp313']. A 3.14 user doing pip install peclet-cu13 gets a source
build that cannot succeed (no CUDA toolkit on the host by design). Both lists now read cp310–cp314,
and the comment above each matrix says they are one decision. /opt/python/cp314-cp314 is present in
the quay.io/pypa/manylinux_2_28_x86_64 image the CUDA job runs in — checked, not assumed.
docs/RELEASE.md §0 said "cp310–cp313" and that three of the four cu13 packages "do not exist yet";
both were stale and are corrected.
1.1.0: the film re-cut, and what a viewer's eye found that no gate did (2026-09-18)¶
The 1.1.0 release film is https://www.youtube.com/watch?v=7D5Em5zvZRI (7:52, public, the
channel trailer); the 1.0.0 cut is private on the channel, kept rather than deleted so a later
ytpub sync keeps pushing private instead of leaving it public. Two things were wrong with the
1.0.0 film and neither had a gate:
- The picture. A viewer reported it as blurred at the start with static graphs shimmering left
to right.
films/QUALITY.mdis the design note that came out of measuring it, and the ranking was not what the pipeline's parameters suggested. The shimmer waszoompan, entirely: it truncates its crop origin and its crop size to whole input pixels, so a 0.02 %-per-frame push-in advances in whole-pixel jumps. Fitted to the zoom model, translation noise went 0.69 px std / 2.21 px max → 0.0008 / 0.0055 by replacing it withperspectiveand per-frame corner expressions. The blur was three separate things — the figures' source DPI (the gallery pages setfigure.dpi: 130in their ownrcParams, so a plot is 627–751 px and gets enlarged 1.5–2.7×), YouTube's 1080p AVC rendition of a slow zoom over text, and an opening that was the YouTube channel banner run through the figure branch. The film now masters at 3840×2160, draws its opening natively, fits pictures into the box the strips leave free (the old rule put a 4:3 figure's title under the caption strip), and encodes each item once. Generation loss, the thing that looked most suspect, measured ≤ 0.4 % and was not a cause. The colour was also wrong and nobody had looked: the composite went toyuv420pthrough swscale's BT.601 default, untagged, so every BT.709 player showed the channel teal as (0,132,136) instead of (13,148,136). The 1.1.0 master reads back (12,147,134). - The numbers. The 1.0.0 film's headline — "7.7 Gcell/s at 90 % weak efficiency on 32 H100" —
is the head-to-head study on a geometry-free box with pressure iterations pinned at 4.0. True,
still published, and not what a release film should lead with. Beat 18 is now
benchmarks/release-1.1.0-scaling, measured at the tag on the case the solver is for: 1.81 billion cells on 32 H100 at 64 % weak efficiency, with ⟨u⟩ identical to 8e-11 across 34 runs, 1 → 1536 ranks, both backends. Beat 20 carries 1.1.0's momentum-solve doubling and the 16-GPU rung the record reports as reproducible and undiagnosed; the old "MG depth cap costs a third at 1536 ranks" line was dropped because it had stopped being true — SCALING_ISSUES #2 was fixed by telescoping on 2026-09-02.
Gates now run on every cut (tools/quality_check.py film): the frame ledger against the narration
clock, BT.709 tagging, one lossy encode per item asserted from the command log, and the zoom fit.
The 1.1.0 master passes all four. Open, and recommended in films/QUALITY.md §4.3:
fig-format: retina in the gallery, which is the only remaining fix for figure sharpness and needs
Quarto — not installed on this host, so it belongs to whoever re-executes the pages next.
The README on PyPI is a snapshot, not a mirror (2026-09-19)¶
pypi.org/project/peclet/ showed the 1.0.0 film two days after the 1.1.0 film replaced it, and
nothing in the repositories could change that: a distribution carries a frozen copy of the
README, the 1.1.0 sdist was uploaded 2026-09-16 19:36 UTC, and PyPI forbids re-uploading a
version. Anything on a PyPI page — README prose, keywords, classifiers, project URLs — lands only
with the next upload.
Two consequences worth planning around:
- A superseded film is retired to
unlisted, neverprivate(RELEASE.md §10.1). Private broke the 1.1.0 page: 404 thumbnail, sign-in wall. - The metadata improvements of 2026-09-19 are not on PyPI yet — keywords (there were none),
classifiers (6 → 14/15), and the Examples/Issues/Changelog URLs are all in the repositories and
will appear at the next upload. For the metapackage that upload is cheap:
pecletis dependency-only, so a patch release is one sdist and one pure-Python wheel with no compilation and no per-interpreter matrix. Worth doing on its own if the front page matters before the next feature release; the component packages can wait for theirs.