ferrotherm 0.31.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
# Build and publish the Python wheels.
#
# One wheel per platform, each carrying the compiled library for that platform, because
# `pip install ferrotherm` has to work with no cargo, no checkout and no FERROTHERM_LIB. The wheels
# are `py3-none-<platform>`: the package is ctypes rather than a C extension, so it runs on any
# Python 3 and only the platform matters.
#
# Publishing uses PyPI Trusted Publishing (OIDC). There is no API token in this repository's
# secrets and there should not be: a token that leaks can publish anything, where a trusted
# publisher is bound to this workflow in this repository.
# The FILENAME is load-bearing: PyPI's Trusted Publisher is bound to `python-release.yml` in this
# repository, and renaming this file would make PyPI reject the OIDC token with an error about a
# workflow it has never heard of. It builds the Julia artifacts too, despite the name.
name: release

on:
  push:
    tags: ["v*"]
  workflow_dispatch:
    inputs:
      publish:
        description: "Publish to PyPI (otherwise the wheels are built and kept as artifacts)"
        type: boolean
        default: false

jobs:
  wheels:
    name: ${{ matrix.name }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          # macOS is Apple Silicon only. Intel Macs are outside Apple's own support window, and
          # the runners for them are scarce enough to stall a release for the best part of an hour.
          - { name: macos-arm64,   os: macos-14,     target: aarch64-apple-darwin }
          - { name: windows-x86_64, os: windows-latest, target: x86_64-pc-windows-msvc }
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}
      - uses: actions/setup-python@v5
        with:
          # Any interpreter in the supported range builds the same wheel: the package is ctypes,
          # not a C extension, so this is a build host rather than a compatibility axis.
          python-version: "3.13"

      - name: Build the library
        run: cargo build --release --target ${{ matrix.target }}

      - name: Stage it inside the package
        shell: bash
        run: |
          case "${{ matrix.target }}" in
            *apple-darwin)      lib=libferrotherm.dylib ;;
            *linux-gnu)         lib=libferrotherm.so ;;
            *windows-msvc)      lib=ferrotherm.dll ;;
          esac
          cp "target/${{ matrix.target }}/release/$lib" "python/ferrotherm/$lib"
          ls -la "python/ferrotherm/$lib"

      - name: Build the wheel
        shell: bash
        working-directory: python
        run: |
          python -m pip install -q --upgrade pip build
          case "${{ matrix.target }}" in
            aarch64-apple-darwin) plat=macosx_11_0_arm64 ;;
            x86_64-pc-windows-msvc)   plat=win_amd64 ;;
          esac
          python -m build --wheel -C--build-option=--plat-name="$plat"
          ls -la dist/

      - name: The wheel must work with nothing else present
        shell: bash
        working-directory: python
        run: |
          # A wheel that loads a library from outside itself passes every test and helps nobody.
          python -m pip install -q dist/*.whl pytest
          cp test_model.py "${RUNNER_TEMP}/test_model.py"
          python - "${RUNNER_TEMP}/test_model.py" <<'PY'
          import sys
          p = sys.argv[1]
          t = open(p).read().replace(
              "sys.path.insert(0, str(Path(__file__).resolve().parent))\n", "")
          open(p, "w").write(t)
          PY
          cd "${RUNNER_TEMP}"
          unset FERROTHERM_LIB
          python - <<'PY'
          import ferrotherm, pathlib, sys
          lib = pathlib.Path(ferrotherm.library_path()).resolve()
          pkg = pathlib.Path(ferrotherm.__file__).resolve().parent
          print("library:", lib)
          if lib.parent != pkg:
              sys.exit(f"not self-contained: loaded {lib} from outside the package")
          PY
          python -m pytest -q test_model.py

      - name: Stage the library for the Julia artifact
        shell: bash
        run: |
          mkdir -p julia-staging
          case "${{ matrix.target }}" in
            aarch64-apple-darwin)  cp target/${{ matrix.target }}/release/libferrotherm.dylib julia-staging/ ;;
            x86_64-pc-windows-msvc) cp target/${{ matrix.target }}/release/ferrotherm.dll julia-staging/ ;;
          esac
      - uses: actions/upload-artifact@v4
        with:
          name: lib-${{ matrix.name }}
          path: julia-staging/*

      - uses: actions/upload-artifact@v4
        with:
          name: wheel-${{ matrix.name }}
          path: python/dist/*.whl

  # Linux gets its own job because it has to be built INSIDE a manylinux container.
  #
  # A .so built on the ubuntu-latest runner links that runner's glibc, and auditwheel can only
  # relabel a wheel down to a policy the binary already satisfies -- it refused ours outright:
  # "cannot repair to manylinux_2_17_x86_64 because of the presence of too-recent versioned
  # symbols. You'll need to compile the wheel on an older toolchain." So we compile on an older
  # toolchain. manylinux_2_28 is glibc 2.28 (RHEL 8), which every current distribution satisfies.
  linux-wheel:
    name: linux-x86_64
    runs-on: ubuntu-latest
    container: quay.io/pypa/manylinux_2_28_x86_64
    steps:
      - uses: actions/checkout@v4

      - name: Rust
        run: |
          curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
            | sh -s -- -y --profile minimal --default-toolchain stable
          echo "$HOME/.cargo/bin" >> $GITHUB_PATH

      - name: Build the library
        run: cargo build --release

      - name: Build and repair the wheel
        shell: bash
        run: |
          # The manylinux images carry their own Pythons; any of them builds the same py3-none
          # wheel, since the package is ctypes rather than a C extension.
          PY=/opt/python/cp312-cp312/bin/python
          cp target/release/libferrotherm.so python/ferrotherm/libferrotherm.so
          cd python
          "$PY" -m pip install -q --upgrade pip build auditwheel setuptools wheel
          # Not --no-isolation: the manylinux image's Python has no setuptools, so the backend has
          # to come from the isolated build environment like it does on every other runner.
          "$PY" -m build --wheel -C--build-option=--plat-name=linux_x86_64
          "$PY" -m auditwheel repair dist/*.whl -w dist/ --plat manylinux_2_28_x86_64
          rm -f dist/*-linux_x86_64.whl
          ls -la dist/

      - name: The wheel must work with nothing else present
        shell: bash
        run: |
          PY=/opt/python/cp312-cp312/bin/python
          "$PY" -m pip install -q python/dist/*.whl pytest
          cp python/test_model.py /tmp/test_model.py
          "$PY" - /tmp/test_model.py <<'PY'
          import sys
          p = sys.argv[1]
          t = open(p).read().replace(
              "sys.path.insert(0, str(Path(__file__).resolve().parent))\n", "")
          open(p, "w").write(t)
          PY
          cd /tmp
          unset FERROTHERM_LIB
          "$PY" - <<'PY'
          import ferrotherm, pathlib, sys
          lib = pathlib.Path(ferrotherm.library_path()).resolve()
          pkg = pathlib.Path(ferrotherm.__file__).resolve().parent
          print("library:", lib)
          if lib.parent != pkg:
              sys.exit(f"not self-contained: loaded {lib} from outside the package")
          PY
          "$PY" -m pytest -q test_model.py

      - uses: actions/upload-artifact@v4
        with:
          name: wheel-linux-x86_64
          path: python/dist/*.whl

      - uses: actions/upload-artifact@v4
        with:
          name: lib-linux-x86_64
          path: target/release/libferrotherm.so

  # Julia has no wheels. A package needing a native library depends on a JLL, which carries prebuilt
  # binaries per platform. The usual route is a recipe submitted to Yggdrasil and built by other
  # people; this builds our own, out of the same libraries the wheels above already produced.
  #
  # Artifacts.toml names each tarball by URL AND by hash, so Julia refuses anything that does not
  # match. The hash lives in the package and only the bytes live on the release, which makes a
  # self-hosted artifact exactly as trustworthy as a registry-hosted one.
  julia-artifacts:
    needs: [wheels, linux-wheel]
    runs-on: ubuntu-latest
    permissions:
      # To create the release the manifest's URLs point at, and to commit the manifest back.
      contents: write
    steps:
      - uses: actions/checkout@v4
        with:
          # The manifest commit pushes to main, which needs a real ref rather than a detached tag.
          fetch-depth: 0
      - uses: julia-actions/setup-julia@v2
        with:
          version: "1.12"
      - uses: actions/download-artifact@v4
        with:
          pattern: lib-*
          path: libs
          merge-multiple: false

      - name: Build the artifacts and their manifest
        shell: bash
        run: |
          ls -R libs
          VERSION=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2)
          echo "FERROTHERM_VERSION=$VERSION" >> $GITHUB_ENV
          BASE="https://github.com/${{ github.repository }}/releases/download/v$VERSION"
          FERROTHERM_VERSION=$VERSION julia scripts/build-julia-artifacts.jl dist "$BASE" \
            aarch64-apple-darwin=libs/lib-macos-arm64/libferrotherm.dylib \
            x86_64-linux-gnu=libs/lib-linux-x86_64/libferrotherm.so \
            x86_64-w64-mingw32=libs/lib-windows-x86_64/ferrotherm.dll
          cp dist/Artifacts.toml julia/ferrotherm_jll/Artifacts.toml
          ls -la dist/

      - name: The JLL must load what it names
        env:
          # A FRESH DEPOT, so the artifact cannot already be present. This check passed locally for
          # days against a warm ~/.julia/artifacts left by a non-lazy build, which meant the lazy
          # download path -- the one that was broken -- was never taken. A cold depot is the only
          # way this tests what it claims to.
          JULIA_DEPOT_PATH: ${{ runner.temp }}/julia-depot-cold
        run: |
          cd julia/ferrotherm_jll
          # The URLs point at a release that does not exist until this run publishes one, so serve
          # the tarballs locally and check the hashes and the load path against those.
          python3 -m http.server 8791 --directory ../../dist &
          sleep 2
          sed -i "s|https://github.com/[^/]*/[^/]*/releases/download/v[^/]*|http://localhost:8791|" Artifacts.toml
          julia --project=. -e '
            using Pkg; Pkg.instantiate()
            using ferrotherm_jll, Libdl
            p = ferrotherm_jll.libferrotherm[]
            if isempty(p)
                # Print the reason rather than only the symptom. The first run of this said "no
                # library on this platform" and nothing about why, which is a dead end in a log.
                println("Artifacts.toml as tested:")
                println(read("Artifacts.toml", String))
                error("the JLL resolved no library. Julia said:\n" * ferrotherm_jll.why())
            end
            h = Libdl.dlopen(p)
            m = ccall(Libdl.dlsym(h, :ft_onsager), Cdouble, (Cdouble,), 0.5)
            println("onsager(0.5) = ", m)
            abs(m - 0.911319377877496) < 1e-12 || error("the artifact is not the library we built")
            println("the JLL loads the library it names")'
          git checkout Artifacts.toml 2>/dev/null || true

      - uses: actions/upload-artifact@v4
        with:
          name: julia-artifacts
          path: |
            dist/*.tar.gz
            dist/Artifacts.toml

      # The tarballs have to LAND somewhere the URLs in Artifacts.toml point at. Until now they went
      # to a CI artifact, which expires and lives at a different address entirely, so every URL in
      # the shipped manifest was a 404 from 0.7.0 onward -- while the check above passed, because it
      # rewrites those URLs to a localhost server before testing them. A gate that substitutes a
      # working stand-in for the thing under test reports on the stand-in.
      - name: Attach the artifacts to the release the manifest names
        if: startsWith(github.ref, 'refs/tags/v')
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          TAG="${GITHUB_REF_NAME}"
          gh release view "$TAG" >/dev/null 2>&1 \
            || gh release create "$TAG" --title "$TAG" --notes "See CHANGELOG.md."
          gh release upload "$TAG" dist/*.tar.gz --clobber

      # ...and then fetch them back from the REAL URL. Not localhost, not the local file: the exact
      # string a user's Julia will resolve, with the exact hash the manifest commits them to.
      - name: The published URLs resolve, and the bytes are the ones we hashed
        if: startsWith(github.ref, 'refs/tags/v')
        run: |
          bad=0
          n=0
          while read -r url; do
            n=$((n + 1))
            sha="$(grep -B3 "url = \"$url\"" dist/Artifacts.toml | grep -m1 sha256 | cut -d'"' -f2)"
            code="$(curl -sL -o /tmp/a.tar.gz -w '%{http_code}' "$url")"
            got="$(sha256sum /tmp/a.tar.gz | cut -d' ' -f1)"
            if [[ "$code" == "200" && "$got" == "$sha" ]]; then
              echo "  ok    $url"
            else
              echo "  BAD   $url  (http $code, sha $got, manifest $sha)"
              bad=1
            fi
          done < <(grep -oE 'url = "[^"]+"' dist/Artifacts.toml | cut -d'"' -f2)
          # A floor: no URLs found means this passed over nothing.
          [[ $n -ge 3 ]] || { echo "found $n URLs in the manifest, expected 3" >&2; exit 2; }
          [[ $bad -eq 0 ]] || { echo "the shipped manifest names URLs a user cannot fetch" >&2; exit 1; }

      # The manifest belongs IN the package, not only in a CI artifact. The committed one sat at
      # 0.8.0 hashes while the library moved on, so even a working URL would have pointed at the
      # wrong release.
      - name: Commit the manifest the release was built from
        if: startsWith(github.ref, 'refs/tags/v')
        run: |
          cp dist/Artifacts.toml julia/ferrotherm_jll/Artifacts.toml
          if git diff --quiet julia/ferrotherm_jll/Artifacts.toml; then
            echo "manifest unchanged"
            exit 0
          fi
          git config user.name  "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add julia/ferrotherm_jll/Artifacts.toml
          git commit -m "ferrotherm_jll: the manifest for ${GITHUB_REF_NAME}" \
            -m "The hashes and URLs come from the release this tag published, so the package names bytes that exist at the address it names them at."
          # This runs on a DETACHED checkout of the tag, and main moves -- a commit pushed after the
          # tag, or the previous release's own manifest commit, both leave HEAD behind. Pushing
          # straight to main then fails non-fast-forward, which is how v0.11.1 shipped its wheels,
          # its artifacts and its crates while leaving the JLL manifest pointing at v0.11.0.
          git fetch origin main
          git rebase origin/main || { echo "manifest rebase conflicted; commit it by hand" >&2; exit 1; }
          git push origin HEAD:main

  publish:
    needs: [wheels, linux-wheel]
    runs-on: ubuntu-latest
    if: startsWith(github.ref, 'refs/tags/v') || inputs.publish
    environment: pypi
    permissions:
      id-token: write        # Trusted Publishing: no API token anywhere
    steps:
      - uses: actions/download-artifact@v4
        with:
          pattern: wheel-*
          path: dist
          merge-multiple: true
      - run: ls -la dist/
      - uses: pypa/gh-action-pypi-publish@release/v1
        with:
          # So a re-run of a tag -- which is how a broken release job gets fixed -- does not fail on
          # the version it already published successfully.
          skip-existing: true