ferrotherm 0.35.0

Thermodynamic computing in pure Rust: sparse energy-based models, chromatic block-Gibbs, parallel tempering, thermodynamic linear algebra, stochastic differentiable programs, a variational compiler onto device topologies, exact inference by variable elimination, planted instances with known optima, sampler certificates, and a first-class joules ledger. std-only, zero dependencies, wasm-clean, deterministic by seed.
Documentation
name: ci
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      # The cross-binding gates below compare SURFACES, so this job needs the toolchains those
      # surfaces are written in -- otherwise they skip, exit 0, and report success having compared
      # four of seven. That is the same shape as check-exports.sh sitting in a job with no Julia:
      # a gate that runs and covers less than it appears to. Measured before fixing: with a
      # CI-like PATH, check-answers.sh compared 5 and silently skipped julia and zig.
      - uses: julia-actions/setup-julia@v2
        with:
          version: "1.12"
      - uses: mlugg/setup-zig@v2
        with:
          version: 0.16.0
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
      - name: Playwright, for the wasm surface
        working-directory: web-tests
        run: npm install && npx playwright install --with-deps chromium
      - name: Install icarus-verilog (RTL bit-exactness gate)
        run: sudo apt-get update && sudo apt-get install -y iverilog
      # GH_TOKEN for the same reason the landscape step needs it, in a quieter and worse shape.
      # This gate's JLL drift branch asks `gh release view v<x>` whether the release has tarballs
      # the Artifacts manifest never picked up -- the v0.18.0 failure, where all three uploaded, the
      # manifest commit lost a rebase, and the JLL served v0.17.0 under a 0.18.0 version number.
      # Unauthenticated, that call fails and the branch reports "no release yet, so nothing to point
      # at" AND PASSES. It does not go red; it goes quiet, which is the reading this whole file
      # argues against.
      - name: One version across every package
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: scripts/check-versions.sh --selftest && scripts/check-versions.sh
      - name: Every C ABI symbol reaches every binding, or the gap is written down
        # --selftest first. This gate discovered symbols by grepping for `pub extern "C" fn`, which
        # cannot see the twelve certificate accessors a macro generates -- so twelve exports were
        # never checked against any binding while it reported perfect parity.
        run: scripts/check-parity.sh --selftest && scripts/check-parity.sh
      # The node editor is a surface like any other, and check-parity.sh does not look at it. That
      # gap was real: the model layer had nine constraints and the editor offered six, for as long
      # as nobody compared the lists. --selftest first, because this gate is a pile of greps over a
      # moving file and the first version of it passed an editor with a node type cut out.
      - name: The visual editor can say everything the model layer can
        run: scripts/check-editor-parity.sh --selftest && scripts/check-editor-parity.sh
      # And MEANS it. Parity is a check on vocabulary and passes an editor whose fromModel drops a
      # k, a soft price or an objective term -- which still draws every node type, still runs, and
      # answers a different question. Same pair as check-parity vs check-semantics.
      - name: One model through the API and through the picture, same compiled size
        run: scripts/check-editor-model.sh
      # Parity proves the names exist; this proves they compute the same thing. Bindings whose
      # toolchain the runner lacks skip; a binding that is present and produces nothing FAILS.
      - name: Every binding compiles one model to the same bytes
        env:
          FERROTHERM_REQUIRE_ALL: "1"
        run: scripts/check-semantics.sh --selftest && scripts/check-semantics.sh
      # And SOLVES it to the same answer, which is a different question. check-semantics compares
      # the model BEFORE it is solved, and its harness calls compile() explicitly -- so it exercised
      # the exact step Zig's `solve` was skipping, and stayed green while that binding could not
      # solve anything at all.
      - name: Every binding solves one model to the same answer
        env:
          FERROTHERM_REQUIRE_ALL: "1"
        run: scripts/check-answers.sh --selftest && scripts/check-answers.sh
      # And one HIGHER-ORDER model, which is a different claim: the model above is pairwise, so a
      # surface that quietly routed a k-body term through the reduction would still answer it
      # correctly. The three-body parity term cannot be expressed pairwise at all, and the ancilla
      # count is in the comparison so a surface that reduced it anyway is caught reporting the
      # wrong saving for the right answer.
      - name: Every binding solves one higher-order model to the same answer
        env:
          FERROTHERM_REQUIRE_ALL: "1"
        run: scripts/check-hubo-answers.sh --selftest && scripts/check-hubo-answers.sh
      # Parity proves a symbol is DECLARED in each binding. For Julia that is an @cfn line, which
      # says nothing about whether a function wraps it -- both OMMX functions were once declared and
      # exported with no body in between, and the module loaded fine.
      - name: Unit tests
        # --workspace, not the root package alone. This was `cargo test --release`, which builds
        # and tests only `ferrotherm` -- so ferrotherm-serve, ferrotherm-cloud and
        # ferrotherm-silicon were never compiled by CI at all, and their tests never ran.
        run: cargo test --release --workspace
      # The tool that found the 0.12.0 headline defect ran in no job and no script. It aborted on
      # ffi.rs's deny-by-default lint, so five of the six crates and all 21 examples had never been
      # linted at all. The three lints this codebase deliberately does not follow are recorded in
      # Cargo.toml's [workspace.lints.clippy] with the reason for each; everything else is denied.
      - name: Clippy, across the whole workspace
        run: cargo clippy --release --workspace --all-targets -- -D warnings
      # THE GPU PATH IS EXECUTED HERE, not merely compiled.
      #
      # This step used to run `cargo test -p ferrotherm-gpu` on a runner with no adapter, where
      # every hardware-gated test skips. That caught an API break and verified no physics at all:
      # the fastest sampler in the stack had ZERO CI coverage, and its correctness rested on
      # whichever machine somebody remembered to run by hand. A second vendor then found a SIGSEGV
      # that had been latent for releases.
      #
      # lavapipe is a software Vulkan implementation, so a hosted runner can execute the real
      # shader. It says nothing about speed and everything about correctness, which is exactly what
      # a gate should assert -- the same split the README already makes for the DX12/WARP run.
      - name: Install lavapipe, a software Vulkan device
        run: sudo apt-get update && sudo apt-get install -y mesa-vulkan-drivers
      - name: GPU backend, actually run against a software Vulkan adapter
        run: |
          icd=$(ls /usr/share/vulkan/icd.d/lvp_icd*.json 2>/dev/null | head -1)
          test -n "$icd" || { echo "::error::mesa-vulkan-drivers installed but no lvp ICD found"; exit 1; }
          echo "using $icd"
          out=$(VK_ICD_FILENAMES="$icd" cargo test --release -p ferrotherm-gpu 2>&1)
          echo "$out"
          # A SKIP IS NOW A FAILURE. A driver was installed on purpose, so "no GPU adapter" means
          # it did not load -- and the shader went unverified while the job stayed green, which is
          # the exact shape of the hole this step was added to close.
          if grep -q "no GPU adapter" <<<"$out"; then
            echo "::error::lavapipe is installed but wgpu found no adapter; the GPU path did NOT run"
            exit 1
          fi
          # Name the test rather than counting them: a count goes stale the moment one is added,
          # and this is the one that puts the whole conformance suite through the GPU.
          grep -q "conform_can_finally_score_the_gpu_path ... ok" <<<"$out" || {
            echo "::error::the conformance case did not run on the GPU path"
            exit 1
          }
      # Same shape: no macmon on a Linux runner, so the measuring tests skip and the parsing and
      # refusal logic -- which is most of the crate -- still runs.
      - name: Power meter builds, and its tests skip rather than fail without a backend
        run: cargo test --release -p ferrotherm-meter
      # The OMMX bridge hand-rolls protobuf, so the check that matters is the REFERENCE
      # implementation reading what it writes. Installed here on purpose; the test skips without it.
      - name: OMMX bridge, checked against the reference implementation
        run: |
          python3 -m pip install --quiet ommx
          OMMX_PYTHON=python3 cargo test --release --test ommx_reference
      - name: Examples build
        run: cargo build --release --examples
      # RUN them, not merely build them.
      #
      # This step was two examples while `cargo build --examples` compiled all twenty, so eighteen
      # were checked for compiling and never for working. `dtm_scale` had been panicking on a
      # missing argument the whole time and nothing could see it, because a panic at runtime is
      # invisible to a build.
      #
      # Every example that runs unattended is run, and a non-zero exit fails the job -- which is
      # what the seven gate examples use to report a physics check failing. 115 s for seventeen of
      # them, measured, which is affordable on every push.
      #
      # Three are excluded by name, with the reason, rather than by a silent filter:
      #   depth_vs_dimension  199 s   too slow for every push
      #   reach_on_z1         107 s   the flagship; slow, and run before a release
      #   dtm_scale           needs a Fashion-MNIST idx3-ubyte file this runner does not have
      #   exact_reach         a measurement, not a gate: it runs the size up until the node budget
      #                       runs out, which is the opposite of what a per-push check should do
      #   sdp_in_tree         same: it sweeps an expensive bound past the point where it pays
      #   maxcut_shootout     needs a G-set instance file this runner does not have
      #   planar_exact        exact max-cut up to 10,000 spins; minutes, not seconds
      #   toroidal_bound      needs a G-set instance file this runner does not have
      #   icm_scaling         a trend measurement across five lattice sizes; minutes
      # docs.rs is a published surface and nothing here gated it. Turned on with 22 broken
      # intra-doc links already in the tree -- most of them prose like `[0,1]` or `reals[x]` that
      # rustdoc read as a link and could not resolve, two of them real (`Verdict::Runnable` and
      # `crate::graph::GRAPH_BUILDS` name items that do not exist). All fixed in the same commit
      # that added this step, because a gate turned on over a failing tree is a gate that gets
      # turned off again.
      # The landscape claims are FALSIFIABLE statements the 2026-08 survey staked, and re-running
      # that survey is a day's work while checking whether it still holds is five seconds. It was
      # never in CI, so the one gate whose whole purpose is to notice the world moving was the one
      # thing nobody ran on a schedule.
      #
      # GH_TOKEN IS NOT OPTIONAL HERE, and leaving it out is why this step was red for every run
      # between landing and now. `check-landscape.sh` drives `gh api`, `gh` on a runner has no
      # credentials of its own, and the script correctly refuses rather than reporting "unchanged"
      # -- "I could not look" and "nothing moved" are different answers and only one of them is
      # safe. So the gate was doing exactly the right thing and CI failed on every commit, which
      # makes a red build mean nothing and is how a real failure gets waved through. The runner's
      # automatic token turns 60 unauthenticated requests an hour, shared across every runner on
      # that IP, into 5,000 for this repository.
      - name: The competitive landscape has not moved out from under the survey
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: scripts/check-landscape.sh --selftest && scripts/check-landscape.sh

      - name: The published documentation has no broken links
        run: RUSTDOCFLAGS='-D warnings' cargo doc --workspace --no-deps

      - name: Every unattended example runs, and a gate example failing fails the build
        # FERROTHERM_ALLOW_BUSY, deliberately. `flips_bench` and `parity_bench` refuse to print a
        # throughput rate above a load average of 2, because a rate taken under contention measures
        # the run queue -- and a hosted runner that has just finished `cargo build --release
        # --examples` is exactly that. What this step checks is that the examples RUN, not what they
        # measure, so the guard is overridden here and the numbers from this job are not quotable.
        # The override is not silence: both print the load average and a caveat above their tables.
        env:
          FERROTHERM_ALLOW_BUSY: "1"
        run: |
          set -euo pipefail
          # mixing_expressivity is the only NEW skip: it draws 40,000 samples at each of four betas
          # over five shapes and three seeds, which is four minutes. trained_tradeoff (16 s) and
          # metric_calibration (3 s) RUN here -- both assert an ordering, so a run that exits 0 is
          # worth having on every commit.
          skip="depth_vs_dimension reach_on_z1 dtm_scale exact_reach sdp_in_tree maxcut_shootout planar_exact toroidal_bound icm_scaling hubo_vs_reduction hfs_reach mixing_expressivity"
          ran=0
          for f in examples/*.rs; do
            n=$(basename "$f" .rs)
            case " $skip " in *" $n "*) echo "  skipped $n"; continue;; esac
            echo "  running $n"
            cargo run --release --quiet --example "$n" >/dev/null
            ran=$((ran + 1))
          done
          # A floor: if the glob ever stops matching, this passes over nothing.
          if [ "$ran" -lt 15 ]; then
            echo "only $ran examples ran; this checked almost nothing" >&2
            exit 2
          fi
          echo "  $ran examples ran, all exit 0"
  # Do the tests have teeth? A green suite says the code runs, not that a wrong answer would be
  # noticed -- and the 0.12.0 release exists because 320 green tests could not tell an invented LP
  # bound from a correct one. This breaks the code on purpose and requires the named test to go red.
  mutation:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - name: Every recorded mutation is caught by the test named for it
        run: scripts/mutation-suite.sh

  c-abi:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - name: Build the library the header describes
        run: cargo build --release
      - name: The header compiles and links against it
        run: |
          cc -I include include/check.c -L target/release -lferrotherm -o /tmp/check
          LD_LIBRARY_PATH=target/release /tmp/check

  python:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python: ["3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: actions/setup-python@v5
        with:
          # The range we CLAIM, not one point in it. requires-python said >=3.9 while CI tested a
          # single version, so the floor was a claim nobody had ever run.
          python-version: "${{ matrix.python }}"
      - name: Build the library the bindings load
        run: cargo build --release
      - name: Python tests
        run: |
          pip install pytest
          python -m pytest python/test_model.py python/test_readme.py -q
      # The stub is the surface an editor, a type checker and any model writing code against this
      # package actually read. It is generated, so this regenerates and diffs -- renaming a
      # parameter without regenerating fails here rather than autocompleting a lie.
      - name: the type stub matches the library it describes
        run: scripts/check-stubs.sh

  wasm:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: wasm32-unknown-unknown
      - name: wasm cdylib builds clean
        run: RUSTFLAGS='-C strip=symbols' cargo build --release --lib --target wasm32-unknown-unknown
      # Not a byte comparison against a fresh build: a wasm binary is not reproducible across
      # toolchain versions, so that fails on a runner whose rustc differs from the author's and
      # says nothing about whether the artefact works. What matters is that every symbol the pages
      # reach for is present -- a missing export makes the call `undefined`, and calling undefined
      # in a click handler leaves the page looking fine and doing nothing.
      - name: the committed wasm exports everything the pages call
        run: scripts/check-wasm-exports.sh --selftest && scripts/check-wasm-exports.sh
      # A page that loads is not a page that works. This instantiates the committed binary and
      # fits a model through it, then requires the answer to match the native library EXACTLY --
      # so a stale wasm, or a marshalling bug in the fitting path, fails here rather than in a
      # click handler where nobody sees it.
      - name: fitting agrees between the browser binary and the native library
        run: |
          cargo build --release --lib
          scripts/check-fit.sh

  zig:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: mlugg/setup-zig@v2
        with:
          version: 0.16.0
      # `zig build test` runs cargo itself and links with an rpath, so there is one command here
      # rather than a hand-written link line that can drift from what a consumer would use.
      - name: Zig tests
        working-directory: zig
        run: zig build test --summary all

  julia:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: julia-actions/setup-julia@v2
        with:
          version: "1.12"
      - name: Build the library the binding loads
        run: cargo build --release
      # Moved here from the `test` job, which has no Julia -- so it printed "skipping", exited 0,
      # and had never checked anything in CI while reading as a step that passed. REQUIRE_JULIA
      # makes the skip a failure, so it cannot go quiet again.
      - name: Every exported binding name resolves
        env:
          FERROTHERM_REQUIRE_JULIA: "1"
        run: scripts/check-exports.sh --selftest && scripts/check-exports.sh
      - name: Julia tests
        working-directory: julia/Ferrotherm
        env:
          FERROTHERM_LIB: ${{ github.workspace }}/target/release/libferrotherm.so
        run: julia --project=. -e 'using Pkg; Pkg.test()'

  editor:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
      - name: Install
        working-directory: web-tests
        run: npm install && npx playwright install --with-deps chromium
      # Through the gate, not `npm test` directly, so the one command that covers BOTH pages is the
      # same one a developer can run. `docs/ide.html` had no gate at all until this: the editor
      # gates drive graph.html, and a hand-run of `node editor.test.mjs` looked like coverage while
      # leaving the workbench untested through two releases.
      - name: Browser pages run
        env:
          FERROTHERM_REQUIRE_ALL: "1"
        run: scripts/check-pages.sh --selftest && scripts/check-pages.sh