Eight releases in nine hours: the vibe-qc v0.4.0 → v0.4.7 bug arc

Timeline of eight releases from v0.4.0 to v0.4.7 over nine hours forty-one minutes

vibe-qc tagged v0.4.0 at 14:20 on April 27. By midnight on April 28 it had tagged seven more releases. All eight share the codename “Schrödinger’s Llama.” None of the patches fixed quantum chemistry bugs. Every single one fixed something that only surfaced when a real user on a non-dev machine tried to install and run the code.

This is the story of those nine hours and forty-one minutes, and what they taught about the gap between a working release and a usable one.

Timeline of eight releases from v0.4.0 to v0.4.7 over nine hours forty-one minutes
Eight releases in 9h41m. Green: release milestones. Pink: Linux or build fixes. Blue: docs and workflow. Every patch fixed something invisible on the developer’s macOS machine.

v0.4.0: what shipped

The v0.4.0 tag represented the culmination of the development arc described in the previous post: end-to-end periodic SCF across all method and spin combinations, effective-core potentials via libecpint, Fermi-Dirac smearing, Saunders-Hillier level shifting, quadratic SCF fallback, DFT-D3(BJ) dispersion, and 25 tutorials with theory sections and verified citations. 879 tests passing, one xfailed, Sphinx building clean with -W. The GitLab CI pipeline auto-deployed the docs site on push. The release branch was public.

What the pre-flight checks cannot tell you is what happens when someone on a different OS, with a different CMake version, on a different Linux distribution, follows your quickstart from a cold start.

v0.4.1: the build system has opinions (+7h 24m)

The first stress test came from running setup_native_deps.sh on Arch Linux. Three independent failures inside an hour:

Eigen 5 detection. The check in build_libint.sh used ls a b to test for header existence. On Arch, Eigen 5 installs only at /usr/include/eigen3/ with no legacy symlink at /usr/include/Eigen/. The ls call returned non-zero, the check reported Eigen missing, and the build failed — even after a fresh pacman -S eigen. Fix: replace with pkg-config --exists eigen3 plus a [ -e ... ] short-circuit.

CMake 4.x compatibility. libxc 7.0.0, FFTW 3.3.10, and several libecpint vendored dependencies still declare cmake_minimum_required(VERSION <3.5), which CMake 4.2 on Arch rejects outright without an override. Fix: add -DCMAKE_POLICY_VERSION_MINIMUM=3.5 to every vendored-dep CMake invocation.

CMP0167 Boost warning. libint 2.13.1 uses the deprecated find_package(Boost) interface, which produces a wall of CMake dev warnings that frightened users into thinking something was broken. Fix: add -Wno-dev.

The lesson is not specific to these three bugs. It is that build systems accumulate assumptions about the developer’s environment — which headers live where, which CMake version the project owner happens to be running, which deprecated interfaces are still tolerated on the local machine — and those assumptions are invisible until someone runs the script on a different machine.

v0.4.2: “I built the docs” does not mean “I followed them” (+8h 2m)

After getting past the build, I followed my own quickstart on a clean box. Three things blocked me before the first SCF calculation ran.

The landing page pointed users to the pre-orchestrator scripts: ./scripts/build_libint.sh && ./scripts/setup_basis_library.sh. Those two scripts leave libxc, spglib, FFTW, and libecpint unbuilt. The correct entry point is ./scripts/setup_native_deps.sh, which runs everything. The fix was straightforward; the fact that the wrong path survived the docs CI is the point. CI proves the docs are warning-free. Only a real user proves the docs make sense.

The “your first calculation” snippet had no filename, no run command, and no mention that import vibeqc requires the virtualenv’s Python specifically. A reader who installed into a venv and then typed python water.py got ModuleNotFoundError and no obvious next step. The fix: explicit “save as water.py, run with .venv/bin/python water.py,” plus a tip box covering the activate pattern and the failure mode.

There was also no single page that answered “how do I run a vibe-qc calculation?” Added docs/running.md: the virtualenv Python pattern, OMP_NUM_THREADS, tee / nohup / tmux for long jobs, and a common-errors table.

v0.4.3: the update workflow needs to be one command (+8h 11m)

Existing checkouts needed a four-step manual sequence to update: git fetch, checkout the tag, pull, re-run setup_native_deps.sh, pip install, verify. That is too many steps to remember, too many places to make a mistake, and too likely to leave a user on a stale build without realising it.

scripts/update.sh replaced all of it: one command, handles tag/branch/remote refs uniformly, refuses to run on a dirty tree, prints the release banner at the end so the user knows which version they are on. Paired with docs/updating.md covering the common failure modes: banner shows old version (re-install the Python package), missing library (pass --rebuild-native-deps).

v0.4.4 + v0.4.5: two Linux loader bugs, one after the other (+8h 38m, +8h 57m)

These two patches have the same root — the mismatch between how macOS and Linux resolve shared library dependencies — but they hit at different levels of the dependency tree, which is why they needed two separate fixes.

Side-by-side diagram showing DT_RUNPATH non-transitivity causing libxc and libcerfcpp not found, versus DT_RPATH transitivity fixing both
v0.4.4 added rpath entries for all five vendored deps in vibeqc_core.so, fixing libxc.so.15. v0.4.5 addressed a deeper issue: DT_RUNPATH (CMake’s default) is non-transitive — when libecpint loads its own deps, the loader does not inherit vibeqc_core.so‘s rpath. Switching to DT_RPATH via -Wl,--disable-new-dtags plus $ORIGIN in libecpint’s own build fixed libcerfcpp.so.3.

v0.4.4 fixed ImportError: libxc.so.15: cannot open shared object file. The v0.4 vendoring commit had added libxc, spglib, FFTW, and libecpint under third_party/<dep>/install/lib/ but only listed libint and libecpint in the compiled extension’s rpath. macOS’s DYLD_LIBRARY_PATH fallback search resolved the others silently. Linux’s glibc loader does not have that fallback. Fix: a foreach loop over all five vendored deps adds an rpath entry for each.

v0.4.5 fixed libcerfcpp.so.3 not found even after v0.4.4 — same symptom, different cause. After the extension loaded libecpint successfully (because libecpint was now in the rpath), libecpint itself tried to load its own transitive dependencies (libcerfcpp, pugixml) and failed. The reason: modern CMake emits DT_RUNPATH by default, which is non-transitive. The loader searches it only for the binary’s direct dependencies. When libecpint’s loader runs, it does not inherit the extension’s rpath. Old-style DT_RPATH is transitive. Two-prong fix: -Wl,--disable-new-dtags in cpp/CMakeLists.txt forces DT_RPATH for the extension, and -DCMAKE_INSTALL_RPATH='$ORIGIN' baked into the libecpint build lets it find its own siblings via $ORIGIN.

macOS never revealed either bug because @rpath on macOS is transitive by default and DYLD has the fallback. Every month of development on macOS was silently papering over a problem that any Linux user would hit in the first thirty seconds.

v0.4.6: when you split a change across two commits, write it down (+9h 15m)

The v0.4.5 hotfix was assembled under time pressure: cherry-pick the docs-CI banner fix, ship. What got left behind was the companion commit that adds the RELEASE_CODENAMES catalogue to vibeqc.banner and threads it into the runtime banner string. The result: import vibeqc; print_banner() on v0.4.5 read Release v0.4.5 with no codename. Nine new tests in tests/test_banner_codename.py shipped with v0.4.6 to pin the catalogue contract permanently: PEP 440 dev-suffix stripping, patch-to-minor fallback, three-step lookup. The banner now reads:

Release v0.4.6 "Schrödinger's Llama"  —  Quantum chemistry for molecules and solids

The fix itself was trivial. The lesson is about process: when a logical change spans two commits and you are mid-crisis, write down which commit goes with which patch. We did not, and a 30-minute omission cost a tagged release.

v0.4.7: the relative-path trap and the Furo TOC (+9h 41m)

Two more user-visible bugs caught by reading the live v0.4.6 docs site cold.

The quickstart said: save water.py, then run .venv/bin/python water.py. A user who ran git clone, then cd ~, then followed the snippet hit .venv/bin/python: No such file or directory. The venv is in the source tree. The quickstart assumed the reader was still in it. The fix: mkdir -p ~/vibeqc-runs/<project> plus explicit absolute paths throughout — ~/path/to/vibeqc/.venv/bin/python water.py. The activate-venv tip uses the same absolute form so it works from any working directory.

Three pages (quickstart, running, updating) were rendering a visible red Furo error block inline: ERROR: Adding a table of contents.... The Furo theme fires this as an in-page assertion when a {contents} directive is present but the right-sidebar already shows the same navigation. Fix: remove the redundant {contents} blocks. The assertion was always technically correct; the sidebar was already doing the job.

Two-panel diagram: left shows what broke across five categories, right shows why each bug was invisible during development
Left: the five categories of bugs across the eight patches, with patch counts. Right: the four structural reasons each category was invisible during development on macOS.

Three patterns worth naming

First-deploy bugs are almost entirely environment, not code. The glibc loader’s strictness, DT_RUNPATH vs DT_RPATH semantics, Arch packaging conventions, CMake 4 policy changes — none of these reproduce on the dev machine. They surface within minutes of the first non-dev user trying to install. The only defence is testing on Linux before declaring “ready to ship,” and having a patch-release workflow that is fast enough to respond.

Docs need to be followed, not just built. CI proved the docs were warning-free. It did not prove they worked. v0.4.2 and v0.4.7 are both “I followed my own quickstart and hit a wall” patches. The difference between building docs and following them is the author’s accumulated context — the venv path, the working directory, the sequence of steps that is obvious to someone who wrote the code and invisible to someone who just cloned it.

The patch-release workflow has to be in place before you ship. Seven patches in nine hours is only possible because scripts/update.sh, docs/release_process.md, and the GitLab CI auto-deploy-on-tag pipeline were all documented and operational before v0.4.0 tagged. Without that infrastructure, each patch would have been a half-day affair. The workflow is part of the release.

What comes next

H2 molecular crystal band structure and density of states, HF/STO-3G
Band structure and DOS for the H₂ molecular crystal from tutorial 12: bonding band flat near the Fermi level, antibonding band showing dispersion along Γ to X. One of the tutorials that shipped in the v0.4 window alongside the installer fixes.

With the “Schrödinger’s Llama” arc closed, the next milestone is v0.5.0 “Wilson’s Otter” — analytic forces, vibrational frequencies, and the periodic gradient machinery that unlocks phonons and elastic constants. The tutorial series grows with it: 25 tutorials at v0.4.7, with the Peierls dimerisation (tutorial 17), NEB reaction paths (tutorial 19), DFT functional comparison (tutorial 15), and the band structure and DOS (tutorial 12) all shipping in the v0.4 window.

The code is at vibe-qc.com, installation instructions in the installation guide, and scripts/update.sh handles everything for existing checkouts. MPL 2.0.

vibe-qc v0.4 “Schrödinger’s Llama”: from Day 3 to first public release

vibe-qc is an open-source quantum chemistry and solid-state code — C++17 backend, Python frontend via pybind11, ASE Calculator interface — being written in the open. Day 1 shipped the molecular stack and Ewald infrastructure. Day 2 closed v0.2.0. Day 3 set the public-release bar at v0.4.0 and shipped the first convergence aid. This post covers everything from that decision to v0.4.6 “Schrödinger’s Llama” — 150 commits on main, six patch tags on release, 131 tests growing to 909, and the gap between “compiles on the developer’s machine” and “actually installs on Manjaro” navigated in full.

Three-panel summary: periodic SCF method matrix all shipped, six-patch release cascade, test count 131 to 909
Left: every cell of the periodic SCF matrix — RHF, UHF, RKS, UKS × Γ-only and multi-k — shipped by v0.4. Centre: six hotfixes in 48 hours, four driven by Linux installer bugs. Right: test count from Day 1 through v0.4.6, zero regressions across all six patches.

Closing the method matrix

The v0.4 engineering goal was to close every combination of method, spin, and k-sampling for periodic Ewald SCF. Eight cells: RHF and UHF, RKS and UKS, each at Γ-only and multi-k. Day 2 had RHF at both k-samplings. The remaining six shipped across the v0.4 arc.

Periodic UHF (Phases 15a and 15b) opened open-shell Hartree-Fock on solids — magnetic systems, spin-polarised defect cells, antiferromagnetic unit cells. The unrestricted SCF minimises the energy separately in the $\alpha$ and $\beta$ spin channels, with the constraint that both share the same Coulomb field. Periodic RKS and UKS (Phases 15c-1 through 15c-3) required a new C++ kernel, build_xc_periodic_uks, to handle open-shell libxc evaluation on a LatticeMatrixSet density. The exchange-correlation energy for the open-shell case is

$$E_{\mathrm{xc}}[\rho^\alpha, \rho^\beta] = \int \varepsilon_{\mathrm{xc}}(\rho^\alpha(\mathbf{r}), \rho^\beta(\mathbf{r}),|\nabla\rho^\alpha|, |\nabla\rho^\beta|)\,d\mathbf{r}$$

evaluated on the periodic grid with Becke partitioning extended to image atoms. With that kernel in place, the SCF matrix was closed: any combination of method and spin runs at either Γ-only or multi-k with the same dispatcher entry point. The periodic DFT tutorial, the tight-cell Becke tutorial, and the periodic SCF convergence tutorial all exercise different corners of this matrix.

ECPs, dispersion, and convergence aids

Three capability tracks landed on top of the SCF matrix.

Phase 14 added effective-core potentials via libecpint 1.0.7, vendored with pugixml and libcerf. ECP support is wired through all four molecular drivers and validated against PySCF on Zn²⁺/LANL2DZ to micro-hartree accuracy. This opens the pob-* ECP basis sets for Rb through Lu — the heavy-element range that covers metal oxides, perovskites, and lanthanoid chemistry. The ECP integrals add a non-local potential term to the core Hamiltonian:

$$\hat{V}_{\mathrm{ECP}} = \hat{U}_{\mathrm{local}} + \sum_{\ell=0}^{L} \hat{U}_\ell \hat{P}_\ell$$

where $\hat{P}_\ell$ projects onto angular momentum $\ell$ and $\hat{U}_\ell$ is the semi-local correction. The integration hit one genuinely tricky bug: the libecpint API header documentation was actively misleading about whether the function appended /xml/ to the share-dir argument. It does. The source at api.cpp:73 was the authority.

Phase D1 added DFT-D3(BJ) dispersion correction, wired through run_job and the ASE Calculator. The dispersion tutorial shows the binding-curve comparison that makes the underbinding failure of uncorrected GGAs visible in a single plot.

The convergence track (C1a through C1c) shipped three tools. C1a is Saunders-Hillier level shifting (covered in the Day 3 post), which modifies the Fock matrix before diagonalisation as $\tilde{\mathbf{F}} = \mathbf{F} + b(\mathbf{S} – \frac{1}{2}\mathbf{S}\mathbf{D}\mathbf{S})$, raising virtual eigenvalues by $b$ without changing the converged fixed point. C1b added Fermi-Dirac smearing with fractional occupations. C1c shipped a quadratic SCF fallback — a Newton step in MO space,

$$\Delta\mathbf{\kappa} = -\mathbf{H}^{-1}_{\mathrm{diag}}\,\mathbf{g}$$

where $\mathbf{g}$ is the orbital-rotation gradient and $\mathbf{H}_{\mathrm{diag}}$ is the diagonal orbital Hessian, with a trust-region cap to prevent overlarge steps. Default is off; opts.quadratic_fallback_iter = N activates after iteration $N$. The SCF convergence user guide covers all three tools in order of when to reach for them.

The bug that tutorials found

The most instructive quality event of the v0.4 arc was a 12-item bug report that came from the process of writing tutorials. The pattern: reading the API carefully enough to explain it surfaces issues that running the API does not.

The sharpest example was level-shift silently dropped in the dispatcher. Engineering had shipped C1a, added level_shift to SCFOptions, and the tests passed. But _copy_options_to_rhf and _copy_options_to_scf copied nine fields by hand and had not been updated. Setting level_shift=0.4 in a tutorial script produced no effect on the SCF trace. The fix was one line in two functions. The regression test now asserts that a dispatcher round-trip preserves all SCFOptions fields — the same class of bug cannot recur silently.

The other significant find: the basis library was not inside the wheel. basis_library/ lived at repo root with basis_library/basis/ gitignored, populated by a shell script at build time. A pip wheel does not run shell scripts. The fix was moving the whole tree to python/vibeqc/basis_library/. Ninety standard .g94 basis sets plus three solid-state pob-* sets now ship inside the wheel. No environment variable required.

Schrödinger’s Llama: the patch-release cascade

Schrödinger's Llama comic: a worried llama in a wooden box labelled macOS OK, Linux question mark, Arch triple question mark, with a radioactive pip install vibe-qc flask and a thought bubble asking whether v0.4.5 is fixed on Manjaro
The release is simultaneously “working” and “broken on Linux” — until a user opens the box. Six patch releases in 48 hours, four of them Linux installer bugs that development on macOS had never revealed.

Tagging v0.4.0 followed docs/release_process.md: bump pyproject.toml, write the changelog, tag from main, fast-forward release. The banner started reading Release v0.4.0. Then the first user on a different machine tried to install it.

v0.4.1 hit three independent Arch/CMake failures. libint’s Eigen detection used ls a b, which returns nonzero if any argument is missing — on Arch, Eigen 5 installs only at /usr/include/eigen3/Eigen/Core, not the top-level path that the detection script expected. libint’s FindBoost invocation triggered fatal CMake 4.x deprecation warnings. And all three vendored dependencies declared cmake_minimum_required(VERSION <3.5), which CMake 4.x rejects outright. None of these were vibe-qc bugs. They were upstream code aging into newer toolchains, absorbed because the install path is part of the public surface.

v0.4.4 and v0.4.5 were the genuinely interesting failures. After vendoring five native libraries, the rpath only mentioned two of them. macOS found the others via DYLD‘s fallback search. Linux does not.

Side-by-side dependency tree showing DT_RUNPATH non-transitivity causing libxc not found, versus DT_RPATH transitivity fixing it
Left: under DT_RUNPATH (the modern GNU ld default), the search path in vibeqc_core.so does not propagate to libecpint’s own dependency search — libcerfcpp.so.3 is not found. Right: -Wl,--disable-new-dtags forces DT_RPATH, which is transitive. macOS’s @rpath is transitive by default, which is why this was Linux-only.

v0.4.4 added a foreach loop over all five vendored deps — problem solved for direct dependencies. Then v0.4.5 hit libcerfcpp.so.3 not found, even though the file existed on disk. The root cause was DT_RPATH vs DT_RUNPATH in ELF binaries. Modern GNU ld defaults to emitting DT_RUNPATH, which is non-transitive: the rpath in vibeqc_core.so applies only to its direct dependencies. When libecpint loads its own deps, the loader does not inherit that rpath. Two-prong fix: -Wl,--disable-new-dtags on the Linux link of vibeqc_core.so (forcing DT_RPATH), plus -DCMAKE_INSTALL_RPATH='$ORIGIN' baked into libecpint at build time. The ELF loader’s transitivity semantics are not something most scientific software developers need to know about — until they do.

v0.4.6 closed the codename system. RELEASE_CODENAMES lives in python/vibeqc/banner.py. The docs CI container does not install vibe-qc (no C++ stack in python:3.13-slim), so importlib.metadata.version("vibe-qc") fell through to a placeholder. The fix was loading banner.py as a standalone module via importlib.util, bypassing vibeqc/__init__.py entirely. Docs and runtime now read the same dictionary without either depending on the other’s import chain. Every SCF log from v0.4.6 onwards carries Release v0.4.6 "Schrödinger's Llama" — unambiguous build provenance in every pasted traceback.

Documentation: 19 tutorials to 25

Six new tutorials shipped: natural orbitals and the idempotency diagnostic, projected density of states, periodic Bloch orbital cubes, tight-cell DFT with the periodic Becke partition, periodic SCF convergence, and symmetry-aware lattice integral storage. All 25 now carry resource callouts — peak RSS and wall-clock on a reference machine, generated by a dev script and pasted in. The quickstart was restructured from a 2000-line everything-page into four focused documents.

The CI/CD side replaced a silent nightly cron with a GitLab pipeline: sphinx-build -W --keep-going (warnings are errors), rsync to the production webroot, triggered only on the release branch. One gotcha worth documenting: GitLab File-type variables silently corrupt multi-line OpenSSH private keys. Store the key base64-encoded in a regular Variable and decode it in the YAML with base64 -d.

Going public

GitLab project visibility flipped to Public at v0.4.0. Anonymous clone lands on the release branch by default — the latest tagged release, not the dev main. The project has a communication address (mpei@vibe-qc.com), a PGP key for security reports (fingerprint CC6D 30BB DF96 F694 C615 FBDE 4CD5 65CF 26B1 E7E5, public key at vibe-qc.com/_static/pgp/mpei.asc), a SECURITY.md, and a CONTRIBUTING.md decision tree.

The number that matters

909 tests pass on a fresh install. The verification command is in installation.md. Zero regressions across six patch releases. The feature work — closing the periodic SCF matrix, ECPs, D3(BJ), three convergence tools — was the straightforward part. The work that made it real for someone on a different machine was four patches driven by Linux loader semantics, one by CMake version policy, and one by a codename dictionary that two separate CI environments needed to read from the same source. All six are in the changelog. All six are fixed permanently.

The bug arc continues: Eight releases in nine hours: the v0.4.0 to v0.4.7 story.

The code is at vibe-qc.com. MPL 2.0. Clone it, run the tests, read the 25 tutorials.

vibe-qc day 3: setting the public-release bar and the first convergence fix

Left: SCF energy vs iteration showing oscillation without level shift and smooth convergence with level shift b=0.3. Right: vibe-qc v0.4.0 public release roadmap.

vibe-qc is an open-source quantum chemistry and solid-state code — C++17 backend, Python frontend via pybind11, ASE Calculator interface — being written in the open one session at a time. Day 1 shipped the molecular stack and the first Ewald infrastructure. Day 2 closed out v0.2.0 — end-to-end 3D Ewald dispatch, bulk benchmarks, multi-k DIIS, periodic Becke partition — and opened v0.2.5 with SYM3a orbit-reduced lattice-integral storage. Day 3 was narrower by design: a release-target decision and the first piece of the next milestone. 746 tests in, 754 out.

Setting the public-release bar at v0.4.0

The first question of the day was where to draw the public-release line. Three candidates.

v0.2.0 is technically coherent — “3D periodic bulk HF/DFT with quantitative Ewald Coulomb” is a complete story, and we are 95% of the way there (the remaining piece is tightening the CRYSTAL cross-check witness bounds on LiH, NaCl, MgO, and Si). But shipping there would hand a user a code that oscillates the moment they try a metal or a narrow-gap oxide. The DIIS accelerator that works beautifully on insulators stalls on systems with near-degenerate occupied and virtual orbitals near the Fermi level. That is not a missing feature — it is a known failure mode with known remedies, and shipping before those remedies exist would damage the project’s credibility faster than it builds it.

v0.3.0 adds visualisation polish: properties, cube files, better band plots. Useful, but it does not fix the convergence problem. A code that looks nicer while still oscillating on MgO is not a more useful code.

v0.4.0 is where vibe-qc becomes “I can do real solid-state chemistry with this.” The convergence-tooling track (C1: level shifting, Fermi-Dirac smearing, second-order SCF) handles the metals and tight-gap systems. Phase 14 adds effective-core potentials via libecpint, opening the pob-* ECP basis sets for Rb through Lu — metal oxides, perovskites, lanthanoids. Phase 15 adds periodic UHF/UKS for magnetic systems and spin-polarised transition-metal compounds. That combination covers the tutorial cases that currently bounce off the wall, and it matches the capability level where researchers in solid-state chemistry will actually trust the results enough to publish. That is the bar. The full roadmap is on vibe-qc.com.

Left: SCF energy vs iteration showing oscillation without level shift and smooth convergence with level shift b=0.3. Right: vibe-qc v0.4.0 public release roadmap.
Left: the level-shift effect on a tight-gap periodic cell — unshifted SCF oscillates; $b = 0.3$ converges in 9 iterations to the same energy. Right: the v0.4.0 public-release roadmap, with today’s C1a already green.

Phase C1a: Saunders-Hillier level shift in periodic Ewald SCF

The first piece of the v0.4.0 convergence track shipped today. The Saunders-Hillier level shift (Saunders and Hillier, 1973; also Goedecker, 1996) modifies the Fock matrix before diagonalisation:

$$\tilde{\mathbf{F}} = \mathbf{F} + b\left(\mathbf{S} – \tfrac{1}{2}\mathbf{S}\mathbf{D}\mathbf{S}\right)$$

where $b$ is the shift parameter and $\mathbf{D}$ is the density matrix. The effect is to raise virtual MO eigenvalues by $b$ while leaving the occupied block untouched. This widens the effective HOMO-LUMO gap seen by the diagonaliser each iteration, suppressing the near-degenerate orbital swaps that cause oscillation in small-gap systems. The SCF fixed point is unchanged — at convergence, $\mathbf{F}\mathbf{D} = \mathbf{D}\mathbf{F}$ (in the $\mathbf{S}$-metric), so the shift term vanishes and the converged density is the same regardless of $b$.

That last point is the correctness contract that the test suite validates directly. A tight H₂ chain at $a = 8$ bohr (Γ point) and at $a = 10$ bohr with a $[2,2,2]$ k-mesh: $b = 0.3$ reproduces the $b = 0$ total energy to within $10^{-9}$ Ha. The reported mo_energies in the result object are the un-shifted physical orbital energies — the final self-consistency pass at convergence omits the shift, so the orbital spectrum is independent of the $b$ value chosen during iteration. Choosing $b$ too large just slows convergence; choosing it too small provides no benefit on the difficult cases. A value between 0.2 and 0.5 Ha covers most practical situations.

The implementation is wired into both the Γ-Ewald (run_rhf_periodic_gamma_ewald3d) and multi-k Ewald (run_rhf_periodic_multi_k_ewald3d) drivers via a new level_shift field on PeriodicRHFOptions, PeriodicSCFOptions, and PeriodicKSOptions. The default is 0.0 — the field is purely opt-in, and existing callers see bit-for-bit identical results. The SCF convergence section of the user guide covers when and how to use it. Eight new tests: field exposure on all three option structs, Γ-Ewald and multi-k inertness contracts, MO eigenvalues physical at convergence, default behaviour preserved. The molecular RHF/UHF/RKS/UKS level-shift wiring (C1a-2) needs a small parallel-code change in four C++ drivers and is deferred to a follow-up commit.

A segfault, triaged but not fixed

Honest engineering means mentioning this. A crash surfaced in the molecular gradient code: compute_gradient(Molecule, BasisSet, RHFResult)overlap_gradient_contribution → null function pointer inside __kmp_invoke_microtask. Multiple OpenMP worker threads hitting pc = 0x0 is a strong signal of either a libint2 Engine dispatch table that was not initialized when freshly-copied engines raced into .compute() from worker threads, or an out-of-bounds access into the engine pool when nested or dynamic OpenMP ran more threads than the pool was sized for.

The existing 32 gradient tests all pass cleanly, so the crash triggers on a specific input pattern — most likely a basis set with shells exceeding the prototype’s max_l, or a thread-count change between pool construction and the parallel region. The crash binary’s offsets shifted after rebuild, making direct symbolisation unhelpful. This is deferred: waiting on a reproducer that pins the exact shell configuration. It will be fixed before v0.4.0 tags, because the gradient code is load-bearing for geometry optimisation and eventually periodic forces.

What is next

Two immediate priorities. First, C1b — Fermi-Dirac smearing with fractional occupations and an electronic-entropy contribution to the free energy. This is the single biggest credibility-unblocker for v0.4.0: it covers metals, oxide surfaces, and defect cells with mid-gap states that DIIS plus level shifting still cannot handle. Second, the segfault reproducer — once we have an input that reliably triggers the crash, the fix should be straightforward.

SYM3b (kernel-level compute reduction — the $|G|$-fold wall-clock speedup from space-group symmetry, not just the memory saving from SYM3a) remains open on the v0.2.5 track and will slot in once C1b is done. The CRYSTAL cross-check pass that formally closes v0.2.0 is also still outstanding.

The full v0.4 story continues in the next post: vibe-qc v0.4 “Schrödinger’s Llama”.

The code is at vibe-qc.com. MPL 2.0.

When the tutorial is the product: how vibe-qc treats documentation as a first-class deliverable

Split view: theory section of a vibe-qc tutorial on the left, running Python code on the right

vibe-qc is an open-source quantum chemistry and solid-state code: a C++17 numerical backend, a Python frontend via pybind11, and an ASE Calculator interface that connects it to the wider atomistic simulation ecosystem. The code is being written in the open, one session at a time, with the cyclic cluster model — a method for treating solid-state defects at post-HF accuracy — as its eventual destination. But the thing that distinguishes vibe-qc from other projects at this stage is not its roadmap length. It is that the tutorials are treated as first-class deliverables, written concurrently with the code they document, and held to the same quality gate as the implementation itself.

This post is about why that choice was made and what it looks like in practice.

What a tutorial is, in this project

Every tutorial in vibe-qc follows the same structure: a working Python script, representative output, an embedded figure where the calculation produces something visual, a theory section (roughly half a page, three to six equations), a references block, and a pointer to the next tutorial in the sequence. None of these sections are optional. A tutorial without a theory section is a recipe. A tutorial without a working script is an essay. The combination is what makes it an educational artefact rather than documentation.

The theory sections are typeset with explicit equations. Since peintinger.com does not currently run a MathJax or KaTeX plugin, the equations in the docs themselves are rendered by MathJax inside the Sphinx build at vibe-qc.com. For example, the molecular Hartree-Fock tutorial works through the Roothaan equations in matrix form, $\mathbf{F}\mathbf{C} = \mathbf{S}\mathbf{C}\boldsymbol{\varepsilon}$, where $\mathbf{F}$ is the Fock matrix, $\mathbf{C}$ the MO coefficient matrix, $\mathbf{S}$ the overlap matrix, and $\boldsymbol{\varepsilon}$ the diagonal matrix of orbital energies:

Split view: theory section of a vibe-qc tutorial on the left, running Python code on the right
The dual character of a vibe-qc tutorial: theory on the left (Bloch theorem, dispersion relation, DOS formula, foundation references), running code on the right. Both sections are required; neither is optional.

The citation discipline matters as much as the equations. Foundation-era papers — Roothaan 1951, Bloch 1928, Becke 1988, Pulay 1980 — are cited with full journal-volume-page triples drawn directly from the original. Post-2010 results that are harder to verify from memory carry a [Ref: verify] marker in the draft and are only promoted to full citation after the volume and page have been confirmed against the journal. This is not the standard practice for informal software documentation. It is standard practice for a journal article, and the tutorials are being written to that standard deliberately.

The parity matrix as a dual roadmap

The roadmap page carries two distinct structures. The first is an engineering milestone list tracking Ewald phases, symmetry sub-phases, and versioned capabilities. The second is a tutorial parity matrix that pins vibe-qc against ORCA’s molecular tutorial set and CRYSTAL’s solid-state tutorial set — the two reference programs that cover the two halves of what vibe-qc is trying to do.

Table showing vibe-qc tutorial parity against ORCA and CRYSTAL reference programs
The tutorial parity matrix from vibe-qc.com/roadmap.html. Green means a tutorial covering this capability is shipped and cross-checked against the reference program. Yellow means partial. Red means the capability is on the roadmap but the tutorial does not yet exist.

The value of the parity matrix is that it answers two different questions from a single table. For a user evaluating whether vibe-qc can support their work, it gives a one-page answer: “can I reproduce the ORCA tutorial on geometry optimisation with this code?” For a developer deciding what to build next, it gives a prioritised queue: the red cells in the CRYSTAL column are where the periodic capability is missing and the tutorials cannot yet be written. The engineering roadmap and the documentation roadmap are the same document.

The framing is deliberately not competitive. The parity matrix does not claim vibe-qc exceeds ORCA or CRYSTAL at anything. It says, plainly, where vibe-qc is today relative to what those programs can teach, and what it would take to close the gap. That honesty is part of the project’s credibility.

Fourteen tutorials, all shipped today

As of the current tutorial index, fourteen tutorials are live. They run from molecular Hartree-Fock — the entry point, which teaches the Roothaan equations, basis set input, and how to read an SCF trace — through open-shell methods, geometry optimisation, vibrational frequencies, thermodynamics at finite temperature, orbital visualisation, basis set convergence, and dispersion corrections, and then into the periodic side: periodic HF on a 1D hydrogen chain, periodic KS-DFT, Madelung constants via Ewald summation, and band structure and density of states.

The band structure tutorial is worth looking at as the canonical example of what the format can carry. It delivers a full Γ-to-X k-path calculation on the hydrogen chain, plots bands and DOS in a single combined figure, explains the Bloch theorem, $\psi_{n\mathbf{k}}(\mathbf{r}) = e^{i\mathbf{k}\cdot\mathbf{r}}u_{n\mathbf{k}}(\mathbf{r})$, and the origin of band dispersion in terms of tight-binding bonding and antibonding combinations, cites Bloch 1928 and Monkhorst-Pack 1976, and ends with a pointer forward to the Peierls distortion. The code block is short enough to read in under a minute. The theory section is long enough to understand what the code is computing and why the result looks the way it does.

That rhythm — code, output, figure, theory, references, next — is the same across all fourteen tutorials. A student who works through them in order gets a coherent course in molecular and periodic quantum chemistry. A researcher who arrives at a specific tutorial looking for a recipe gets that too, because the code blocks are self-contained. The two use cases are not in tension; they are served by the same document structure.

The no-code-required backlog

One discipline the roadmap makes explicit is the distinction between tutorials that require new code and tutorials that require only documentation work on existing capability. Several entries in the tutorial backlog fall into the second category: the capability is already in the code, the API is stable, and the only work is writing the tutorial itself — the explanation, the example script, the figure, the theory section, the references.

This is worth naming because it is easy to defer. The code works; the tutorial can wait. What the project has committed to instead is treating those deferred tutorials as a first-class backlog item, tracked in the parity matrix, with the same visibility as unimplemented features. A shipped capability without a tutorial is a capability that most users will never find.

The user guide plays a complementary role here. It covers the reference-level detail — how to specify k-point meshes, how to configure Ewald parameters, how to read the memory budget report — that would clutter a tutorial but that a user needs once they move past the introductory examples. The split between tutorial and user guide is deliberate: tutorials teach phenomena and procedures, the user guide documents options and behaviour.

Plots as the lesson, not decoration

Every tutorial that produces a figure ships that figure embedded in the page. The figures are not screenshots pasted in after the fact. They are generated by scripts in the repository, regenerable by any user who runs the tutorial code, and updated whenever the underlying calculation changes.

This matters because the figures are doing pedagogical work that the code blocks cannot. A code block teaches syntax and API. A band structure plot teaches what band dispersion looks like, where the gap sits, how the DOS integrates the band structure into a density, and what it means for a state at the zone boundary to be antibonding. The band structure tutorial includes both panels — bands and DOS in a single combined figure — because showing them side by side is how a reader learns to interpret both simultaneously.

The same logic applies to the dispersion tutorial, which shows a binding curve with and without D3-BJ correction, making visible the underbinding failure of uncorrected GGAs on weakly-bound systems. The figure is the argument. The code block shows how to reproduce it.

The doc build as a gate

The documentation is built with sphinx-build -W --keep-going: warnings are errors, and the build does not finish if any cross-reference is broken or any code block fails to parse. This is the same discipline applied to the test suite — 746 passing tests as of the most recent commit — extended to the documentation layer. A tutorial that links to a non-existent API page, or a code block that uses a function name that was renamed, fails the build and blocks the merge.

This is not the default posture for scientific software projects. The default is to build docs as a separate step, treat failures as non-blocking, and accumulate rot over time. The cost of running docs under the same gate as code is low — a few minutes added to CI. The benefit is that the tutorials stay synchronized with the implementation without anyone having to remember to update them.

Where this fits in the larger project

vibe-qc is aiming at the cyclic cluster model: a method that treats a finite cluster of atoms embedded in a periodic Madelung field, enabling post-HF correlation calculations on solid-state defects at a cost that scales with cluster size rather than unit cell size. The cyclic cluster model is v2.0 on the roadmap. Every tutorial from the molecular HF entry point through the periodic band structure is load-bearing preparation for the moment when a researcher can open a CIF file, define a vacancy cluster, run CCSD(T) on it, and understand what the result means.

The tutorials exist not to fill a documentation requirement but because the project’s audience includes people who need to understand what they are computing, not just how to invoke the code. That is a different audience from “users who already know periodic HF/DFT and just need the API reference.” The user guide and API reference serve that second audience. The tutorials serve everyone, and they are the harder document to write well.

The code lives at vibe-qc.com. MPL 2.0.