ferrotherm 0.8.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:
          python-version: "3.12"

      - 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
    steps:
      - uses: actions/checkout@v4
      - 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
        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[]
            isempty(p) && error("the JLL resolved no library on this platform")
            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

  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