diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..80195ad --- /dev/null +++ b/.credo.exs @@ -0,0 +1,48 @@ +%{ + configs: [ + %{ + name: "default", + strict: true, + checks: [ + {Credo.Check.Consistency.TabsOrSpaces}, + {Credo.Check.Consistency.SpaceInParentheses}, + {Credo.Check.Consistency.SpaceAroundOperators}, + {Credo.Check.Consistency.MultiAliasUnfold}, + {Credo.Check.Consistency.ParameterPatternMatching}, + {Credo.Check.Readability.ModuleDoc}, + {Credo.Check.Readability.FunctionNames}, + {Credo.Check.Readability.PredicateFunctionNames}, + {Credo.Check.Readability.LargeNumbers}, + {Credo.Check.Readability.SinglePipe}, + {Credo.Check.Readability.StringSigils}, + {Credo.Check.Readability.TrailingWhiteSpace}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, + {Credo.Check.Readability.AliasOrder}, + {Credo.Check.Readability.PreferImplicitTry}, + {Credo.Check.Readability.ModuleSpatialStructure}, + {Credo.Check.Refactor.PipeChainStart, excluded_functions: ["from_coo", "to_coo", "new", "reduce", "resolve"]}, + {Credo.Check.Refactor.CaseTrivialMatches}, + {Credo.Check.Refactor.NegatedConditionsInUnless}, + {Credo.Check.Refactor.NegatedConditions}, + {Credo.Check.Refactor.FilterFilter}, + {Credo.Check.Refactor.RejectReject}, + {Credo.Check.Refactor.MapJoin}, + {Credo.Check.Warning.IoInspect}, + {Credo.Check.Warning.IExPry}, + {Credo.Check.Warning.OperationOnSameValues}, + {Credo.Check.Warning.BoolOperationOnSameValues}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck}, + {Credo.Check.Warning.DuplicatedErlangOption}, + {Credo.Check.Warning.LazyLogging}, + {Credo.Check.Warning.UnusedEnumOperation}, + {Credo.Check.Warning.UnusedKeywordOperation}, + {Credo.Check.Warning.UnusedListOperation}, + {Credo.Check.Warning.UnusedStringOperation}, + {Credo.Check.Warning.UnusedTupleOperation}, + # Disable checks that conflict with GraphBLAS naming conventions + {Credo.Check.Readability.Specs, false}, + {Credo.Check.Readability.MaxLineLength, max_length: 120} + ] + } + ] +} \ No newline at end of file diff --git a/.github/workflows/elixir-build.yml b/.github/workflows/elixir-build.yml new file mode 100644 index 0000000..29dec7d --- /dev/null +++ b/.github/workflows/elixir-build.yml @@ -0,0 +1,67 @@ +name: Build + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +env: + MIX_ENV: test + +jobs: + test: + name: Test -- Elixir ${{ matrix.elixir }} / OTP ${{ matrix.otp }} + runs-on: ubuntu-22.04 + + strategy: + fail-fast: false + matrix: + include: + # - elixir: "1.17.3" + # otp: "26" + - elixir: "1.20.0-rc.3" + otp: "28.4.1" + # Latest stable versions + - elixir: "1.19.5" + otp: "28.4.1" + coverage: true + # Previous versions for compatibility + - elixir: "1.18.4" + otp: "27.3" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + + - name: Cache Mix dependencies + uses: actions/cache@v4 + with: + path: deps + key: ${{ runner.os }}-test-${{ matrix.elixir }}-${{ matrix.otp }}-v1-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-test-${{ matrix.elixir }}-${{ matrix.otp }}-v1- + + - name: Install dependencies + run: mix deps.get + + - name: Install Zig + run: | + ZIG_VERSION="0.15.2" + curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-x86_64-linux-${ZIG_VERSION}.tar.xz" -o zig.tar.xz + tar -xf zig.tar.xz + ZIG_DIR="${PWD}/zig-x86_64-linux-${ZIG_VERSION}" + echo "${ZIG_DIR}" >> "$GITHUB_PATH" + "${ZIG_DIR}/zig" version + + - name: Compile (warnings as errors) + run: mix compile --warnings-as-errors + + - name: Run tests + run: mix test diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..be8e770 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,105 @@ +name: Lint + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +env: + MIX_ENV: dev + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + lint: + name: Lint + runs-on: ubuntu-22.04 + + # Pin to the canonical lint version -- this is not the build matrix. + # The matrix for compatibility testing lives in elixir-build.yml. + env: + ELIXIR_VERSION: "1.19.5" + OTP_VERSION: "28.4.1" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + deps + _build + key: ${{ runner.os }}-lint-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-lint-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}- + + - name: Install SuiteSparse:GraphBLAS + run: | + sudo apt-get update + sudo apt-get install -y libgraphblas-dev pkg-config + + - name: Install dependencies + run: mix deps.get + + - name: Download Zig (for Zigler) + run: mix zig.get + + - name: Check formatting + run: mix format --check-formatted + + - name: Compile (warnings as errors) + run: mix compile --warnings-as-errors + env: + MIX_ENV: dev + EX_GRAPHBLAS_BUILD: "1" + + - name: Run Credo + run: mix credo --strict + + - name: Check documentation compiles without warnings + run: | + mix docs 2>&1 | tee /tmp/docs_output.txt + if grep -i "warning" /tmp/docs_output.txt; then + echo "ExDoc produced warnings -- failing." + exit 1 + fi + env: + MIX_ENV: dev + + - name: Check for outdated dependencies + run: mix hex.outdated || true + + - name: Check for unused locked dependencies + run: mix deps.unlock --check-unused + + - name: Audit dependencies for known vulnerabilities + run: mix deps.audit + + # Dialyzer (runs after lint, reusing the same setup) + - name: Cache PLT + uses: actions/cache@v4 + id: plt-cache + with: + path: priv/plts + key: ${{ runner.os }}-plt-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-plt-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}- + + - name: Create PLT directory + if: steps.plt-cache.outputs.cache-hit != 'true' + run: mkdir -p priv/plts + + - name: Build PLT + if: steps.plt-cache.outputs.cache-hit != 'true' + run: mix dialyzer --plt + + - name: Run Dialyzer + run: mix dialyzer --format github diff --git a/.github/workflows/precompiled-nifs.yml b/.github/workflows/precompiled-nifs.yml new file mode 100644 index 0000000..f43e0be --- /dev/null +++ b/.github/workflows/precompiled-nifs.yml @@ -0,0 +1,257 @@ +name: Precompiled NIFs + +on: + push: + # Only run this workflow automatically when pushing a version tag + # like v0.2.0. Regular pushes to branches will not trigger it. + tags: + - "v*" + pull_request: + paths: + - ".github/workflows/precompiled-nifs.yml" + - "native/suite_sparse.ex" + - "priv/native/suite_sparse/" + workflow_dispatch: + +permissions: + contents: write + id-token: write + attestations: write + +jobs: + build_nifs: + name: NIF - ${{ matrix.target }} (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + + env: + ELIXIR_VERSION: "1.19.5" + OTP_VERSION: "28.4.1" + + strategy: + fail-fast: false + matrix: + include: + - { target: aarch64-macos-none, os: macos-14 } + - { target: x86_64-macos-none, os: macos-14 } + - { target: aarch64-linux-gnu, os: ubuntu-22.04 } + - { target: x86_64-linux-gnu, os: ubuntu-22.04 } + - { target: x86_64-linux-musl, os: ubuntu-22.04 } + - { target: aarch64-linux-musl, os: ubuntu-22.04 } + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} + + - name: Extract project version + shell: bash + run: | + echo "PROJECT_VERSION=$(sed -n 's/^ @version "\(.*\)"/\1/p' mix.exs | head -n1)" >> "$GITHUB_ENV" + + - name: Install build dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + pkg-config \ + build-essential \ + clang \ + cmake \ + libomp-dev \ + git + + - name: Clone SuiteSparse:GraphBLAS (Linux) + if: runner.os == 'Linux' + run: git clone --depth 1 --branch v9.4.5 https://github.com/DrTimothyAldenDavis/GraphBLAS.git + + - name: Configure GraphBLAS with CMake (Linux) + if: runner.os == 'Linux' + working-directory: GraphBLAS + run: | + cmake -S . -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr/local \ + -DGRAPHBLAS_BUILD_PKGCONFIG=ON + + - name: Cache GraphBLAS installation (Linux) + if: runner.os == 'Linux' + uses: actions/cache@v4 + id: graphblas-cache + with: + path: | + /usr/local/lib/libgraphblas.so* + /usr/local/include/suitesparse/GraphBLAS.h + /usr/local/lib/pkgconfig/GraphBLAS.pc + # Cache per OS/arch/target so each NIF build target only pays the + # GraphBLAS build cost once across runs. + key: graphblas-v9.4.5-${{ runner.os }}-${{ runner.arch }}-${{ matrix.target }} + + - name: Build and install GraphBLAS (Linux) + if: runner.os == 'Linux' && steps.graphblas-cache.outputs.cache-hit != 'true' + working-directory: GraphBLAS + run: | + cmake --build build -j"$(nproc)" + sudo cmake --install build + sudo ldconfig + + - name: Verify GraphBLAS installation and set PKG_CONFIG_PATH (Linux) + if: runner.os == 'Linux' + run: | + # Set PKG_CONFIG_PATH so pkg-config can see GraphBLAS + echo "PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig" >> "$GITHUB_ENV" + + # Verify a GraphBLAS pkg-config file exists (casing may differ) + if ! sudo find /usr/local -type f \( -name 'GraphBLAS.pc' -o -name 'graphblas.pc' \) | grep -q '\.pc$'; then + echo "GraphBLAS pkg-config file not found under /usr/local" + sudo find /usr/local -maxdepth 4 -type f -name '*.pc' || true + exit 1 + fi + + # Verify pkg-config can resolve the module (try both common names) + pkg-config --print-errors --modversion GraphBLAS || pkg-config --print-errors --modversion graphblas + + - name: Set PKG_CONFIG_PATH for later steps (Linux) + if: runner.os == 'Linux' + run: echo "PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig" >> "$GITHUB_ENV" + + - name: Install SuiteSparse (macOS) + if: runner.os == 'macOS' + run: brew install suite-sparse + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + deps + _build + key: ${{ runner.os }}-nif-${{ matrix.target }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-nif-${{ matrix.target }}- + + - name: Install dependencies + run: mix deps.get + + - name: Download Zig (for Zigler) + run: mix zig.get + + - name: Build NIF from source + env: + EX_GRAPHBLAS_BUILD: "1" + MIX_ENV: dev + run: mix compile --warnings-as-errors + + - name: Package artifact + shell: bash + run: | + VERSION="${PROJECT_VERSION}" + BASENAME="Elixir.GraphBLAS.Native.SuiteSparse" + NIF_DIR="_build/dev/lib/ex_graphblas/priv/lib" + if [ ! -d "$NIF_DIR" ]; then + echo "::error::NIF directory not found: $NIF_DIR" + echo "Existing NIF candidates under _build:" + find _build -maxdepth 6 -type f -name "Elixir.GraphBLAS.Native.SuiteSparse*.so" || true + exit 1 + fi + mkdir -p artifacts + tar czf "artifacts/${BASENAME}-v${VERSION}-${{ matrix.target }}.so.tar.gz" -C "$NIF_DIR" . + echo "Packaged: artifacts/${BASENAME}-v${VERSION}-${{ matrix.target }}.so.tar.gz" + + - name: Artifact attestation + if: startsWith(github.ref, 'refs/tags/') + uses: actions/attest-build-provenance@v1 + with: + subject-path: artifacts/*.tar.gz + + - name: Verify artifact + shell: bash + run: | + if [ -z "$(ls artifacts/*.tar.gz 2>/dev/null)" ]; then + echo "::error::Build did not produce artifact" + exit 1 + fi + ls -la artifacts/ + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: Elixir.GraphBLAS.Native.SuiteSparse-v${{ env.PROJECT_VERSION }}-${{ matrix.target }} + path: artifacts/*.tar.gz + + release: + name: Create release + needs: build_nifs + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: release-artifacts + merge-multiple: true + + - name: Flatten tarballs + run: | + mkdir -p nifs + find release-artifacts -name '*.tar.gz' -exec cp {} nifs/ \; + ls -la nifs/ + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + files: nifs/*.tar.gz + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish_hex: + name: Publish to Hex.pm + needs: release + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + env: + EX_GRAPHBLAS_BUILD: "1" + steps: + - uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: "27" + elixir-version: "1.18.4" + + - name: Install SuiteSparse + run: | + sudo apt-get update + sudo apt-get install -y libgraphblas-dev pkg-config + + - name: Restore Mix dependencies cache + uses: actions/cache@v4 + with: + path: deps + key: mix-deps-publish-${{ hashFiles('mix.lock') }} + restore-keys: | + mix-deps-publish- + + - name: Install dependencies + run: mix deps.get + + - name: Download Zig (for Zigler) + run: mix zig.get + + - name: Compile NIF from source + env: + MIX_ENV: dev + run: mix compile + + - name: Generate checksum file from GitHub Release + run: mix zigler_precompiled.download GraphBLAS.Native.SuiteSparse --all --print 2>/dev/null + + - name: Publish to Hex + run: mix hex.publish --yes + env: + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} diff --git a/.gitignore b/.gitignore index 062f684..ddca2dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,14 @@ # The directory Mix will write compiled artifacts to. /_build/ +/docs/phases +/prompts/ +/plans/ + +.tool-versions + +/priv/plts/ + # If you run "mix test --cover", coverage assets end up here. /cover/ @@ -21,3 +29,6 @@ ex_graphblas-*.tar # Temporary files, for example, from tests. /tmp/ + +# Zigler staging files (generated during compilation) +.Elixir.*.zig diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..98058b7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,89 @@ +# Changelog + +## 0.2.0 (Unreleased) + +### Added + +- `backend` field in `%Matrix{}` and `%Vector{}` structs for robust backend dispatch. + Inspection functions (`nvals`, `to_entries`, `to_coo`, `to_list`, `to_dense`) now + dispatch via the owning backend instead of always defaulting to Elixir. +- SuiteSparse include path configurable via `SUITESPARSE_INCLUDE_PATH` environment + variable or `:suitesparse_include_path` config key. Platform-specific defaults. +- Core operations benchmark suite (`bench/core_ops_benchmarks.exs`). +- Benchmark runner script (`bench/run_all.exs`). +- Installation guide (`guides/installation_guide.md`). +- Apache License 2.0. +- `coveralls.json` for coverage configuration. + +### Fixed + +- `Vector.nvals/1`, `Vector.to_entries/1`, `Vector.to_list/1` now correctly dispatch + to the backend that created the vector. Previously always used Elixir backend, + causing runtime errors on SuiteSparse vectors. +- `Matrix.nvals/1`, `Matrix.to_coo/1`, `Matrix.to_dense/1` same dispatch fix. +- `Matrix.set/5`, `Matrix.extract/4`, `Vector.set/4`, `Vector.extract/3` now use + the container's backend instead of ignoring the backend parameter. +- `config/config.exs` updated to reflect SuiteSparse backend availability. +- Removed private `vector_to_entries/1` and `matrix_to_coo/1` helpers from + `algorithm.ex` — public API now handles backend dispatch correctly. +- Moved `bench_data.ex` from `lib/` to `bench/` (benchmark-only code). +- Fixed `Range.new` deprecation warning in `BenchData.undirected_random_graph/2`. + +### Changed + +- `README.md` overhauled to reflect Phases 1-6 completion. +- `mix.exs` updated with package metadata for Hex.pm. +- Credo clean (0 issues). + +## 0.1.0 (Phase 1) + +### Added + +- Architecture, API shape, and scaffolding. +- Backend behaviour and reference implementation. +- Matrix and vector construction, inspection, and core operations. +- Semirings and monoids (built-in set). +- Masks and descriptors (type definitions). +- Configuration mechanism for backend selection. +- Full test suite for the reference backend. + +--- + +## Phase History + +### Phase 2 — Pure Elixir Reference Backend + +- Correctness-first pure Elixir backend using `%{{row, col} => value}` maps. +- `mxm`, `mxv`, `vxm`, `ewise_add`, `ewise_mult`, `reduce`, `transpose`. +- Initial semiring set: `plus_times`, `lor_land`, `plus_min`. + +### Phase 3 — SuiteSparse Native Backend Foundation + +- SuiteSparse:GraphBLAS native backend via Zigler. +- NIF resource lifecycle with dirty CPU schedulers. +- Error mapping from GrB error codes to Elixir errors. + +### Phase 4 — Core Sparse Operations Parity and Semantic Validation + +- Full parity between Elixir and SuiteSparse backends. +- Shared test suites with property-based testing (StreamData). +- Extended semiring set to 10 built-in semirings. +- JIT disabled for reproducibility. + +### Phase 5 — Masks, Descriptors, and API Honing + +- Structural and complement masks for all compute operations. +- Descriptor control: input transpose, output replacement. +- `set`, `extract`, `dup` for both Matrix and Vector. +- Masked mxm/vxm benchmarks. + +### Phase 6 — Graph Algorithms, Knowledge Graphs, and Query Foundations + +- 7 graph algorithms: bfs_reach, bfs_levels, sssp, triangle_count, + connected_components, degree, pagerank. +- `GraphBLAS.Relation` module for knowledge graph modeling. +- `traverse/4` for multi-hop traversal across predicates. +- `closure/4` for transitive closure. +- `fixed_point/3` generic iteration primitive. +- Added `:min_plus` and `:min_plus_fp64` semirings. +- 470 tests passing, 88% coverage. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ea039ce --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,162 @@ +# Contributing to GraphBLAS + +## Development Setup + +### Local Testing with Docker + +To test the CI/CD pipeline locally before pushing: + +```bash +./test-ci-local.sh +``` + +This runs the full test suite (format, lint, compile, tests) in an isolated Docker environment that matches GitHub Actions. + +**Requirements**: Docker must be installed and running. + +**First run**: The Docker image will be built (~5-10 minutes as it compiles GraphBLAS v9.4.5 from source). + +### Manual Local Development + +For day-to-day development without Docker: + +```bash +# Install dependencies +mix deps.get + +# Download Zig (if not already done) +mix zig.get + +# Compile with warnings as errors +mix compile --warnings-as-errors + +# Run tests +mix test + +# Run linter +mix format --check-formatted +mix credo + +# Run Dialyzer +mix dialyzer +``` + +## GitHub Actions Workflows + +Three workflows run automatically on push/PR: + +### 1. **Lint** (`.github/workflows/lint.yml`) +- **Purpose**: Code quality checks +- **Jobs**: + - `lint`: Format, Credo, compile, docs, hex audit + - `dialyzer`: Type checking via Dialyzer (with PLT caching) +- **Duration**: ~2-3 minutes +- **Triggers**: Push, Pull Request + +### 2. **Build** (`.github/workflows/elixir-build.yml`) +- **Purpose**: Compile and test the library +- **Matrix**: Elixir 1.18.4 / OTP 27 (coverage enabled) +- **Steps**: + 1. Install build dependencies (clang, cmake, pkg-config, libomp-dev) + 2. Clone & build SuiteSparse:GraphBLAS v9.4.5 from source (cached) + 3. Download Zig toolchain (pinned by Zigler) + 4. Compile Elixir code with warnings as errors + 5. Verify NIFs compiled successfully + 6. Run full test suite (~470 tests) + 7. Generate coverage reports (via `mix coveralls.github`) +- **Duration**: ~11-12 minutes (mostly GraphBLAS build) +- **Caching**: + - GraphBLAS installation (`/usr/local`) by platform/arch + - Dependencies (`deps/`, `_build/`) +- **Triggers**: Push, Pull Request + +### 3. **Precompiled NIFs** (`.github/workflows/precompiled-nifs.yml`) +- **Purpose**: Build and publish precompiled native code +- **Targets**: 6 platforms (aarch64/x86_64 × macOS/Linux-gnu/Linux-musl) +- **Steps**: + 1. Build NIFs for each target with `EX_GRAPHBLAS_BUILD=1` + 2. Create GitHub Release with NIF artifacts + 3. Publish to Hex.pm (via `mix zigler_precompiled.download`) +- **Duration**: ~30-40 minutes (6 targets in parallel) +- **Caching**: GraphBLAS source per platform +- **Triggers**: Push, Pull Request, Manual via `workflow_dispatch` + +## GraphBLAS Build Strategy + +GraphBLAS (v9.4.5) is built from source in CI because: + +1. **Ubuntu 22.04** apt repos don't include `graphblas.pc` (needed by pkg-config) +2. **Source build** includes pkg-config support via `-DGRAPHBLAS_BUILD_PKGCONFIG=ON` +3. **Caching** at `/usr/local` per platform reduces rebuild time significantly + +The `graphblas.pc` file is discovered via: +```bash +find /usr/local -type f -name 'graphblas.pc' +``` + +And `PKG_CONFIG_PATH` is set for downstream `pkg-config` queries. + +## Local Debugging + +If tests fail locally: + +```bash +# Check compilation errors +mix compile + +# Run tests with verbose output +mix test --verbose + +# Run dialyzer for type issues +mix dialyzer --explain + +# Check specific module/file +mix test test/ex_graphblas/matrix_test.exs +``` + +## Before Committing + +1. **Run tests locally** or via Docker: + ```bash + ./test-ci-local.sh + ``` + +2. **Format code**: + ```bash + mix format + ``` + +3. **Check types**: + ```bash + mix dialyzer + ``` + +4. **Verify warnings**: + ```bash + mix compile --warnings-as-errors + ``` + +## Commit Message Style + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +(): + + +``` + +**Types**: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `ci`, `chore` + +**Examples**: +- `feat(matrix): add transpose operation` +- `fix(backend): correct type spec for error handling` +- `ci: add Docker setup for local CI testing` + +## Release Process + +Currently deferred—Phase 8 work in progress. See `.github/workflows/precompiled-nifs.yml` for planned release automation. + +--- + +**Questions?** Open an issue on GitHub. diff --git a/Dockerfile.ci b/Dockerfile.ci new file mode 100644 index 0000000..9576760 --- /dev/null +++ b/Dockerfile.ci @@ -0,0 +1,71 @@ +# ============================================================================ +# Stage 1: System dependencies and Erlang/Elixir (force linux/amd64, OTP 28) +# ============================================================================ +FROM --platform=linux/amd64 hexpm/elixir:1.18.4-erlang-28.0-debian-trixie-20251117-slim AS base + +ENV DEBIAN_FRONTEND=noninteractive \ + ELIXIR_ERL_OPTIONS="+fnu" \ + LC_ALL=C.UTF-8 \ + LANG=C.UTF-8 + +# Install system dependencies (cached by Docker) +RUN apt-get update && apt-get install -y \ + build-essential \ + clang \ + git \ + pkg-config \ + libomp-dev \ + curl \ + wget \ + unzip && \ + rm -rf /var/lib/apt/lists/* + +# Install newer CMake (v3.20+) from GitHub releases (linux/x86_64) +RUN mkdir -p /tmp/cmake && \ + curl -fsSL https://github.com/Kitware/CMake/releases/download/v3.29.0/cmake-3.29.0-linux-x86_64.tar.gz | tar -C /tmp/cmake -xz && \ + mv /tmp/cmake/cmake-3.29.0-linux-x86_64/bin/cmake /usr/local/bin/ && \ + mv /tmp/cmake/cmake-3.29.0-linux-x86_64/share/cmake-3.29 /usr/local/share/ && \ + rm -rf /tmp/cmake && \ + cmake --version + +# Set up Mix +RUN mix local.hex --force && \ + mix local.rebar --force + +# ============================================================================ +# Stage 2: GraphBLAS (expensive, cached separately) +# ============================================================================ +FROM base AS graphblas-builder + +WORKDIR /tmp/GraphBLAS + +# Clone GraphBLAS (cached by Docker unless URL changes) +RUN git clone --depth 1 --branch v9.4.5 https://github.com/DrTimothyAldenDavis/GraphBLAS.git . + +# Build GraphBLAS (cached unless source changes - which it won't with --depth 1 --branch) +RUN cmake -S . -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr/local \ + -DGRAPHBLAS_BUILD_PKGCONFIG=ON && \ + cmake --build build -j$(nproc) && \ + cmake --install build && \ + ldconfig + +# ============================================================================ +# Stage 3: Final runtime image +# ============================================================================ +FROM base AS runtime + +# Copy pre-built GraphBLAS from stage 2 +COPY --from=graphblas-builder /usr/local /usr/local + +# Refresh library cache +RUN ldconfig + +# Set up workspace +WORKDIR /workspace +ENV MIX_ENV=test \ + PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig" + +# Default command +CMD ["/bin/bash"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8f6c47c --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2024-2026 Thanos Antypas + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 89ebe79..119d4df 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,276 @@ # GraphBLAS -`ex_graphblas` is an Elixir library for sparse linear algebra and graph computation inspired by the GraphBLAS model: -- sparse matrices and vectors; -- sparse matrices and vectors; -- semirings, monoids, masks, descriptors; -- graph algorithms expressed as linear algebra; -- native-performance execution where needed; -- idiomatic Elixir data structures and documentation at the boundary +An Elixir library for sparse linear algebra and graph computation, inspired by the GraphBLAS standard. +[![Hex.pm](https://img.shields.io/hexpm/v/ex_graphblas.svg)](https://hex.pm/packages/ex_graphblas) +[![Hex.pm](https://img.shields.io/hexpm/dt/ex_graphblas.svg)](https://hex.pm/packages/ex_graphblas) +[![Hex.pm](https://img.shields.io/hexpm/l/ex_graphblas.svg)](https://hex.pm/packages/ex_graphblas) +[![HexDocs.pm](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/ex_graphblas) +[![Precompiled NIFs](https://github.com/thanos/ex_graphblas/actions/workflows/precompiled-nifs.yml/badge.svg)](https://github.com/thanos/ex_graphblas/actions/workflows/precompiled-nifs.yml) + +GraphBLAS provides idiomatic Elixir data structures at the boundary while delegating computation to swappable backends. The same code runs on a pure Elixir reference backend for development and testing, and on SuiteSparse:GraphBLAS via Zigler NIFs for native performance in production. + +## Contents + +- [Features](#features) +- [Architecture](#architecture) +- [Quick start](#quick-start) +- [Use cases](#use-cases) +- [Built-in semirings](#built-in-semirings) +- [Graph algorithms](#graph-algorithms) +- [Installation](#installation) +- [Guides](#guides) +- [Status](#status) +- [License](#license) + +## Features + +- **Sparse matrices and vectors** -- COO construction, inspection, element-wise operations +- **12 built-in semirings** -- `plus_times`, `plus_min`, `lor_land`, `min_plus`, and type-specific variants +- **Graph algorithms** -- BFS reachability, BFS levels, SSSP, triangle count, connected components, degree, PageRank +- **Knowledge graph queries** -- `GraphBLAS.Relation` module with multi-predicate traversal and transitive closure +- **Masked operations** -- structural and complement masks on all compute operations +- **Backend selection** -- per-call or application config; structs carry their backend for correct dispatch +- **3 scalar types** -- `:int64`, `:fp64`, `:bool` +- **470 tests**, property-based testing via StreamData + +## Architecture + +GraphBLAS separates the public API from the computation engine via a backend behaviour: + +``` +GraphBLAS.Matrix / GraphBLAS.Vector -- public API (backend-neutral) +GraphBLAS.Backend -- behaviour defining the computation contract +GraphBLAS.Backend.Elixir -- pure Elixir reference implementation +GraphBLAS.Backend.SuiteSparse -- SuiteSparse:GraphBLAS via Zigler NIFs +``` + +Each `%Matrix{}` and `%Vector{}` struct carries a `backend` field, so inspection and mutation operations dispatch to the correct backend automatically. + +Select a backend via application config: + + config :ex_graphblas, default_backend: GraphBLAS.Backend.Elixir + +Or per-call: + + GraphBLAS.Matrix.from_coo(3, 3, entries, :int64, backend: GraphBLAS.Backend.SuiteSparse) + +## Quick start + +### Sparse matrix operations + + # Create a sparse 3x3 matrix from COO triples + {:ok, m} = GraphBLAS.Matrix.from_coo(3, 3, [ + {0, 1, 1}, {1, 2, 2}, {2, 0, 3} + ], :int64) + + # Matrix multiplication + {:ok, result} = GraphBLAS.Matrix.mxm(m, m, :plus_times) + + # Extract entries + {:ok, entries} = GraphBLAS.Matrix.to_coo(result) + +### Graph algorithms + + # Build an adjacency matrix + {:ok, adj} = GraphBLAS.Matrix.from_coo(5, 5, [ + {0, 1, 1}, {1, 2, 1}, {2, 3, 1}, {3, 4, 1} + ], :int64) + + # BFS from vertex 0 + {:ok, visited} = GraphBLAS.Algorithm.bfs_reach(adj, 0) + + # Single-source shortest paths + {:ok, distances} = GraphBLAS.Algorithm.sssp(adj, 0) + + # Triangle count + {:ok, count} = GraphBLAS.Algorithm.triangle_count(adj) + + # PageRank + {:ok, ranks} = GraphBLAS.Algorithm.pagerank(adj) + +### Knowledge graph queries + + rel = GraphBLAS.Relation.new(100) + {:ok, rel} = GraphBLAS.Relation.add_triples(rel, :follows, [{0, 1}, {1, 2}, {2, 3}]) + {:ok, rel} = GraphBLAS.Relation.add_triples(rel, :likes, [{1, 3}, {2, 0}]) + + # Two-hop traversal: who can X reach by following then liking? + {:ok, result} = GraphBLAS.Relation.traverse(rel, [:follows, :likes], :lor_land) + + # Transitive closure over :follows + {:ok, closure} = GraphBLAS.Relation.closure(rel, :follows, :lor_land) + +## Use cases + +This section sketches a few concrete scenarios and how to approach them with GraphBLAS. The guides go deeper; here we focus on the high-level shape of a solution. + +### 1. Social graph: two-hop follower recommendations + +Goal: “Suggest accounts I might want to follow based on people my friends follow.” + +1. Model your social graph as an adjacency matrix `A` where `A[i,j] == 1` when user `i` follows `j`. +2. Compute `A * A` using a boolean semiring to capture two-hop reachability. + +```elixir +alias GraphBLAS.{Matrix, Algorithm} + +# 0 follows 1, 1 follows 2 +{:ok, adj} = Matrix.from_coo(3, 3, [{0, 1, 1}, {1, 2, 1}], :int64) +{:ok, reach2} = Matrix.mxm(adj, adj, :lor_land) + +# reach2[0,2] is true: "0 can reach 2 in two hops" +``` + +Interpretation: non-zero entries in row `i` of `reach2` are candidates for “people you may know” based on two-hop paths. You can mask out already-followed accounts with a mask matrix (see the masks and descriptors guide). + +### 2. Weighted shortest paths in logistics or routing + +Goal: “Compute the cheapest cost from a depot to every destination.” + +1. Model edges as weights (cost, time, distance) in a weighted adjacency matrix. +2. Use `GraphBLAS.Algorithm.sssp/2` which builds on the min-plus semiring. + +```elixir +alias GraphBLAS.{Matrix, Algorithm} + +{:ok, weighted} = Matrix.from_coo(4, 4, [ + {0, 1, 2.0}, + {1, 2, 3.0}, + {0, 2, 10.0} +], :fp64) + +{:ok, dist} = Algorithm.sssp(weighted, 0) +# dist[1] = 2.0, dist[2] = 5.0 (0→1→2 is cheaper than 0→2) +``` + +This pattern generalises to road networks, communication networks, and any place where “cheapest path” matters. + +### 3. Knowledge graph traversal and path queries + +Goal: “Answer multi-hop questions over typed relationships.” + +1. Store triples `(subject, predicate, object)` in `GraphBLAS.Relation`. +2. Use `Relation.traverse/3` to describe a path of predicates. + +```elixir +alias GraphBLAS.Relation + +# alice=0, bob=1, carol=2, dave=3 +rel = Relation.new(4) +{:ok, rel} = Relation.add_triples(rel, :follows, [{0, 1}, {1, 2}]) +{:ok, rel} = Relation.add_triples(rel, :likes, [{1, 2}]) + +{:ok, result} = Relation.traverse(rel, [:follows, :likes], :lor_land) +# result[0,2] == true means: +# "there exists x such that 0 --follows→ x --likes→ 2" +``` + +This pattern scales to more complex path expressions and aggregation semirings (e.g. counting paths with `:plus_times`). + +For a deeper, tutorial-style treatment of these examples, see the guides listed below. + +## Built-in semirings + +| Name | Multiply | Add | Type | Use | +|--------------------|----------|-------|--------|-------------------------------| +| `:plus_times` | `a * b` | `a + b` | `:int64` | Standard matrix multiply | +| `:plus_times_fp64` | `a * b` | `a + b` | `:fp64` | Standard matrix multiply | +| `:plus_min` | `min(a,b)` | `a + b` | `:int64` | Shortest path | +| `:plus_min_fp64` | `min(a,b)` | `a + b` | `:fp64` | Shortest path | +| `:min_plus` | `a + b` | `min(a,b)` | `:int64` | Shortest path variant | +| `:min_plus_fp64` | `a + b` | `min(a,b)` | `:fp64` | Shortest path variant | +| `:max_plus` | `a + b` | `max(a,b)` | `:int64` | Longest / critical path | +| `:max_plus_fp64` | `a + b` | `max(a,b)` | `:fp64` | Longest / critical path | +| `:max_min` | `min(a,b)` | `max(a,b)` | `:int64` | Capacity / bottleneck | +| `:max_min_fp64` | `min(a,b)` | `max(a,b)` | `:fp64` | Capacity / bottleneck | +| `:lor_land` | `a and b` | `a or b` | `:bool` | Boolean adjacency (BFS) | +| `:land_lor` | `a or b` | `a and b` | `:bool` | Dual boolean semiring | + +## Graph algorithms + +| Algorithm | Description | +|-----------|-------------| +| `bfs_reach/2` | BFS reachability -- bool vector of visited vertices | +| `bfs_levels/2` | BFS levels -- integer vector of hop distances from source | +| `sssp/2` | Single-source shortest paths (Dijkstra via min-plus semiring) | +| `triangle_count/1` | Undirected triangle count via masked element-wise multiply | +| `connected_components/1` | Connected components via iterative label propagation | +| `degree/2` | In-degree or out-degree vector | +| `pagerank/2` | PageRank with damping factor and convergence check | + +All algorithms accept a `backend:` option and work identically on both backends. ## Installation -If [available in Hex](https://hex.pm/docs/publish), the package can be installed -by adding `ex_graphblas` to your list of dependencies in `mix.exs`: +Add `ex_graphblas` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:ex_graphblas, "~> 0.1.0"} + {:ex_graphblas, "~> 0.2.0"} ] end ``` -Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) -and published on [HexDocs](https://hexdocs.pm). Once published, the docs can -be found at . +### SuiteSparse backend + +To use the native SuiteSparse backend, install SuiteSparse:GraphBLAS and set the include path: + + export SUITESPARSE_INCLUDE_PATH=/opt/homebrew/include/suitesparse + +Or in your `config/config.exs`: + + config :ex_graphblas, suitesparse_include_path: "/opt/homebrew/include/suitesparse" + +See `guides/installation_guide.md` for platform-specific instructions. + +## Guides + +The guides in the `guides/` directory provide end-to-end walkthroughs: + +- `guides/installation_guide.md` – installing ex_graphblas and SuiteSparse. +- `guides/architecture_walkthrough.md` – how the library is structured and why. +- `guides/reference_backend_walkthrough.md` – the pure Elixir backend and data model. +- `guides/native_backend_walkthrough.md` – the SuiteSparse backend and NIF boundary. +- `guides/graph_algorithms_guide.md` – graph algorithms and knowledge graph operations. +- `guides/masks_and_descriptors_guide.md` – controlling computation with masks and descriptors. +- `guides/parity_testing_guide.md` – keeping backends in sync with property tests. + +## Status + +| Phase | Description | Status | +|-------|-------------|--------| +| 1 | Architecture, API, scaffolding | Complete | +| 2 | Pure Elixir reference backend | Complete | +| 3 | SuiteSparse native backend | Complete | +| 4 | Parity and semantic validation | Complete | +| 5 | Masks, descriptors, API honing | Complete | +| 6 | Graph algorithms and knowledge graphs | Complete | +| 7 | Nx integration | Deferred | +| 8 | Hardening, benchmarks, release prep | In progress | + +### Roadmap & milestones + +These are not hard promises, but they capture the intended evolution of the library: + +- **Milestone: 0.2.x – Native backend + CI hardening (current)** + - Ship a robust SuiteSparse backend behind `GraphBLAS.Backend.SuiteSparse`. + - Provide precompiled NIFs for common platforms, with CI-tested source builds as a fallback. + - Stabilise dialyzer, lint, and precompiled-NIF workflows. + +- **Milestone: 0.3.x – Developer experience and observability** + - Improve error messages from the native backend, with clearer reasons and actionable hints. + - Add instrumentation hooks so long-running operations can be measured (timers, counters) without exposing backend internals. + +- **Milestone: 0.4.x – Higher-level graph APIs** + - Add richer graph helpers (graph builders, canned patterns) on top of the existing primitive operations. + - Extend the `GraphBLAS.Relation` API with more path-query combinators and convenience functions. + +- **Milestone: 0.5.x – Nx and ecosystem integration (Phase 7)** + - Revisit Nx integration once the core library is stable, focusing on zero-copy interop where possible. + - Explore bridges to popular Elixir data tooling (e.g. Explorer, Livebook examples) using GraphBLAS under the hood. + +## License +Apache License 2.0. See [LICENSE](LICENSE). diff --git a/bench/bench_data.ex b/bench/bench_data.ex new file mode 100644 index 0000000..5e527d0 --- /dev/null +++ b/bench/bench_data.ex @@ -0,0 +1,83 @@ +defmodule GraphBLAS.BenchData do + @moduledoc false + + def chain_graph(n) do + Enum.map(0..(n - 2), fn i -> {i, i + 1, true} end) + end + + def cycle_graph(n) do + Enum.map(0..(n - 1), fn i -> {i, rem(i + 1, n), true} end) + end + + def star_graph(n) do + Enum.map(1..(n - 1), fn i -> {0, i, true} end) + end + + defp random_pairs(n) do + rows = Enum.to_list(0..(n - 1)) + + Enum.flat_map(rows, fn r -> + cols = Enum.to_list(0..(n - 1)) + Enum.map(cols, fn c -> {r, c} end) + end) + end + + def random_graph(n, density) do + pairs = random_pairs(n) + filtered = Enum.filter(pairs, fn _ -> :rand.uniform() < density end) + entries = Enum.map(filtered, fn {r, c} -> {r, c, true} end) + + fallback = [{0, 1, true}] + + result = + if entries == [] do + fallback + else + entries + end + + result + end + + def weighted_random_graph(n, density) do + pairs = random_pairs(n) + filtered = Enum.filter(pairs, fn _ -> :rand.uniform() < density end) + entries = Enum.map(filtered, fn {r, c} -> {r, c, :rand.uniform() * 10.0} end) + + fallback = [{0, 1, 1.0}] + + result = + if entries == [] do + fallback + else + entries + end + + result + end + + def undirected_random_graph(n, density) do + rows = Enum.to_list(0..(n - 1)) + + upper = + Enum.flat_map(rows, fn r -> + cols = Enum.to_list((r + 1)..(n - 1)//1) + Enum.map(cols, fn c -> {r, c} end) + end) + + filtered = Enum.filter(upper, fn _ -> :rand.uniform() < density end) + edges = Enum.map(filtered, fn {r, c} -> {r, c, true} end) + + fallback = [{0, 1, true}] + + final = + if edges == [] do + fallback + else + edges + end + + reverse = Enum.map(final, fn {r, c, v} -> {c, r, v} end) + final ++ reverse + end +end diff --git a/bench/core_ops_benchmarks.exs b/bench/core_ops_benchmarks.exs new file mode 100644 index 0000000..bbf07d2 --- /dev/null +++ b/bench/core_ops_benchmarks.exs @@ -0,0 +1,209 @@ +# Core Operations Benchmarks: Elixir vs SuiteSparse +# +# Run with: mix run bench/core_ops_benchmarks.exs +# +# Benchmarks core sparse operations at multiple sizes and densities. + +Application.ensure_all_started(:ex_graphblas) + +alias GraphBLAS.Backend.Elixir, as: RefBackend +alias GraphBLAS.Backend.SuiteSparse +alias GraphBLAS.{Matrix, Vector} + +benchee_opts = [ + time: 2, + memory_time: 1, + warmup: 1, + formatters: [{Benchee.Formatters.Console, comparison: true}] +] + +# --- from_coo construction --- + +IO.puts("\n=== from_coo (100x100, 5% density, :int64) ===") + +entries_100_5pct = + Enum.flat_map(0..99, fn r -> + Enum.flat_map(0..99, fn c -> + if :rand.uniform() < 0.05, do: [{r, c, :rand.uniform(100)}], else: [] + end) + end) + +Benchee.run( + %{ + "elixir_from_coo" => fn -> + {:ok, m} = Matrix.from_coo(100, 100, entries_100_5pct, :int64, backend: RefBackend) + m + end, + "suitesparse_from_coo" => fn -> + {:ok, m} = Matrix.from_coo(100, 100, entries_100_5pct, :int64, backend: SuiteSparse) + SuiteSparse.matrix_free(m) + end + }, + benchee_opts +) + +IO.puts("\n=== from_coo (500x500, 10% density, :int64) ===") + +entries_500_10pct = + Enum.flat_map(0..499, fn r -> + Enum.flat_map(0..499, fn c -> + if :rand.uniform() < 0.10, do: [{r, c, :rand.uniform(100)}], else: [] + end) + end) + +Benchee.run( + %{ + "elixir_from_coo" => fn -> + {:ok, m} = Matrix.from_coo(500, 500, entries_500_10pct, :int64, backend: RefBackend) + m + end, + "suitesparse_from_coo" => fn -> + {:ok, m} = Matrix.from_coo(500, 500, entries_500_10pct, :int64, backend: SuiteSparse) + SuiteSparse.matrix_free(m) + end + }, + benchee_opts +) + +# --- Pre-build matrices for compute benchmarks --- + +{:ok, m100_elixir} = Matrix.from_coo(100, 100, entries_100_5pct, :int64, backend: RefBackend) +{:ok, m100_ss} = Matrix.from_coo(100, 100, entries_100_5pct, :int64, backend: SuiteSparse) + +entries_100_5pct_v2 = + Enum.flat_map(0..99, fn r -> + Enum.flat_map(0..99, fn c -> + if :rand.uniform() < 0.05, do: [{r, c, :rand.uniform(100)}], else: [] + end) + end) + +{:ok, m100b_elixir} = Matrix.from_coo(100, 100, entries_100_5pct_v2, :int64, backend: RefBackend) +{:ok, m100b_ss} = Matrix.from_coo(100, 100, entries_100_5pct_v2, :int64, backend: SuiteSparse) + +# --- mxm --- + +IO.puts("\n=== mxm (100x100 * 100x100, :plus_times) ===") + +Benchee.run( + %{ + "elixir_mxm" => fn -> + {:ok, _} = Matrix.mxm(m100_elixir, m100b_elixir, :plus_times, backend: RefBackend) + end, + "suitesparse_mxm" => fn -> + {:ok, r} = Matrix.mxm(m100_ss, m100b_ss, :plus_times, backend: SuiteSparse) + SuiteSparse.matrix_free(r) + end + }, + benchee_opts +) + +# --- ewise_add --- + +IO.puts("\n=== ewise_add (100x100, :plus) ===") + +Benchee.run( + %{ + "elixir_ewise_add" => fn -> + {:ok, _} = Matrix.ewise_add(m100_elixir, m100b_elixir, :plus, backend: RefBackend) + end, + "suitesparse_ewise_add" => fn -> + {:ok, r} = Matrix.ewise_add(m100_ss, m100b_ss, :plus, backend: SuiteSparse) + SuiteSparse.matrix_free(r) + end + }, + benchee_opts +) + +# --- ewise_mult --- + +IO.puts("\n=== ewise_mult (100x100, :times) ===") + +Benchee.run( + %{ + "elixir_ewise_mult" => fn -> + {:ok, _} = Matrix.ewise_mult(m100_elixir, m100b_elixir, :times, backend: RefBackend) + end, + "suitesparse_ewise_mult" => fn -> + {:ok, r} = Matrix.ewise_mult(m100_ss, m100b_ss, :times, backend: SuiteSparse) + SuiteSparse.matrix_free(r) + end + }, + benchee_opts +) + +# --- reduce --- + +IO.puts("\n=== reduce (100x100, :plus) ===") + +Benchee.run( + %{ + "elixir_reduce" => fn -> + {:ok, _} = Matrix.reduce(m100_elixir, :plus, backend: RefBackend) + end, + "suitesparse_reduce" => fn -> + {:ok, r} = Matrix.reduce(m100_ss, :plus, backend: SuiteSparse) + SuiteSparse.vector_free(r) + end + }, + benchee_opts +) + +# --- transpose --- + +IO.puts("\n=== transpose (100x100) ===") + +Benchee.run( + %{ + "elixir_transpose" => fn -> + {:ok, _} = Matrix.transpose(m100_elixir, backend: RefBackend) + end, + "suitesparse_transpose" => fn -> + {:ok, r} = Matrix.transpose(m100_ss, backend: SuiteSparse) + SuiteSparse.matrix_free(r) + end + }, + benchee_opts +) + +# --- mxv --- + +{:ok, v100_elixir} = Vector.from_entries(100, Enum.map(0..99, fn i -> {i, :rand.uniform(10)} end), :int64, backend: RefBackend) +{:ok, v100_ss} = Vector.from_entries(100, Enum.map(0..99, fn i -> {i, :rand.uniform(10)} end), :int64, backend: SuiteSparse) + +IO.puts("\n=== mxv (100x100 * 100, :plus_times) ===") + +Benchee.run( + %{ + "elixir_mxv" => fn -> + {:ok, _} = Matrix.mxv(m100_elixir, v100_elixir, :plus_times, backend: RefBackend) + end, + "suitesparse_mxv" => fn -> + {:ok, r} = Matrix.mxv(m100_ss, v100_ss, :plus_times, backend: SuiteSparse) + SuiteSparse.vector_free(r) + end + }, + benchee_opts +) + +# --- vxm --- + +IO.puts("\n=== vxm (100 * 100x100, :plus_times) ===") + +Benchee.run( + %{ + "elixir_vxm" => fn -> + {:ok, _} = Vector.vxm(v100_elixir, m100_elixir, :plus_times, backend: RefBackend) + end, + "suitesparse_vxm" => fn -> + {:ok, r} = Vector.vxm(v100_ss, m100_ss, :plus_times, backend: SuiteSparse) + SuiteSparse.vector_free(r) + end + }, + benchee_opts +) + +# --- Cleanup --- + +SuiteSparse.matrix_free(m100_ss) +SuiteSparse.matrix_free(m100b_ss) +SuiteSparse.vector_free(v100_ss) diff --git a/bench/parity_benchmarks.exs b/bench/parity_benchmarks.exs new file mode 100644 index 0000000..6851e88 --- /dev/null +++ b/bench/parity_benchmarks.exs @@ -0,0 +1,145 @@ +# Parity Benchmarks: Elixir Reference Backend vs SuiteSparse Native Backend +# +# Run with: mix run bench/parity_benchmarks.exs +# +# These benchmarks compare the two backends on identical workloads. + +Application.ensure_all_started(:ex_graphblas) + +alias GraphBLAS.Backend.Elixir, as: RefBackend +alias GraphBLAS.Backend.SuiteSparse + +small_entries = for r <- 0..9, c <- 0..9, do: {r, c, r * 10 + c + 1} + +Benchee.run( + %{ + "ref_backend_matrix_from_coo" => fn -> + {:ok, m} = RefBackend.matrix_from_coo(10, 10, small_entries, :int64, []) + m + end, + "ss_backend_matrix_from_coo" => fn -> + {:ok, m} = SuiteSparse.matrix_from_coo(10, 10, small_entries, :int64, []) + SuiteSparse.matrix_free(m) + end + }, + time: 2, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +# Pre-build matrices for compute benchmarks +{:ok, ref_a} = RefBackend.matrix_from_coo(10, 10, small_entries, :int64, []) +{:ok, ss_a} = SuiteSparse.matrix_from_coo(10, 10, small_entries, :int64, []) + +Benchee.run( + %{ + "ref_backend_matrix_mxm" => fn -> + {:ok, _} = RefBackend.matrix_mxm(ref_a, ref_a, :plus_times, []) + end, + "ss_backend_matrix_mxm" => fn -> + {:ok, c} = SuiteSparse.matrix_mxm(ss_a, ss_a, :plus_times, []) + SuiteSparse.matrix_free(c) + end + }, + time: 2, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +Benchee.run( + %{ + "ref_backend_matrix_transpose" => fn -> + {:ok, _} = RefBackend.matrix_transpose(ref_a, []) + end, + "ss_backend_matrix_transpose" => fn -> + {:ok, t} = SuiteSparse.matrix_transpose(ss_a, []) + SuiteSparse.matrix_free(t) + end + }, + time: 2, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +Benchee.run( + %{ + "ref_backend_matrix_ewise_add" => fn -> + {:ok, _} = RefBackend.matrix_ewise_add(ref_a, ref_a, :plus, []) + end, + "ss_backend_matrix_ewise_add" => fn -> + {:ok, c} = SuiteSparse.matrix_ewise_add(ss_a, ss_a, :plus, []) + SuiteSparse.matrix_free(c) + end + }, + time: 2, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +Benchee.run( + %{ + "ref_backend_matrix_reduce" => fn -> + {:ok, _} = RefBackend.matrix_reduce(ref_a, :plus, []) + end, + "ss_backend_matrix_reduce" => fn -> + {:ok, v} = SuiteSparse.matrix_reduce(ss_a, :plus, []) + SuiteSparse.vector_free(v) + end + }, + time: 2, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +# Vector benchmarks +vec_entries = for i <- 0..9, do: {i, i * 10 + 1} +{:ok, ref_v} = RefBackend.vector_from_entries(10, vec_entries, :int64, []) +{:ok, ss_v} = SuiteSparse.vector_from_entries(10, vec_entries, :int64, []) + +Benchee.run( + %{ + "ref_backend_vector_ewise_add" => fn -> + {:ok, _} = RefBackend.vector_ewise_add(ref_v, ref_v, :plus, []) + end, + "ss_backend_vector_ewise_add" => fn -> + {:ok, c} = SuiteSparse.vector_ewise_add(ss_v, ss_v, :plus, []) + SuiteSparse.vector_free(c) + end + }, + time: 2, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +Benchee.run( + %{ + "ref_backend_vector_reduce" => fn -> + {:ok, _} = RefBackend.vector_reduce(ref_v, :plus, []) + end, + "ss_backend_vector_reduce" => fn -> + {:ok, _} = SuiteSparse.vector_reduce(ss_v, :plus, []) + end + }, + time: 2, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +# Cleanup +SuiteSparse.matrix_free(ss_a) +SuiteSparse.vector_free(ss_v) +GraphBLAS.Native.grb_finalize() + +IO.puts(""" + +===================================== +Benchmarks complete. +===================================== +""") + +IO.puts(""" + +===================================== +Benchmarks complete. +===================================== +""") diff --git a/bench/phase5_benchmarks.exs b/bench/phase5_benchmarks.exs new file mode 100644 index 0000000..a72c5e2 --- /dev/null +++ b/bench/phase5_benchmarks.exs @@ -0,0 +1,253 @@ +# Phase 5 Benchmarks: Masks, Descriptors, Set/Extract/Dup +# +# Run with: mix run bench/phase5_benchmarks.exs +# +# Benchmarks for the new Phase 5 functionality: +# 1. Masked mxm (structural vs complement) on both backends +# 2. Descriptor inp0_transpose on mxm on both backends +# 3. matrix_set / matrix_extract / matrix_dup on SuiteSparse +# 4. vector_set / vector_extract / vector_dup on SuiteSparse +# 5. Masked vs unmasked mxm overhead on SuiteSparse + +Application.ensure_all_started(:ex_graphblas) + +alias GraphBLAS.Backend.Elixir, as: RefBackend +alias GraphBLAS.Backend.SuiteSparse +alias GraphBLAS.{Mask, Descriptor, Matrix, Vector} + +# --- Shared data --- + +# 100x100 sparse matrix with ~10% density (~1000 entries) +density = 0.1 +size = 100 + +large_entries = + for r <- 0..(size - 1), + c <- 0..(size - 1), + :rand.uniform() < density do + {r, c, :rand.uniform(100)} + end + +# Ensure at least some entries +large_entries = if large_entries == [], do: [{0, 0, 1}, {1, 1, 2}], else: large_entries + +# 50x50 for descriptor transpose (non-square to show effect) +# A=50x80, A^T=80x50. B=50x40. +# mxm(A^T, B): ncols(A^T)=50 == nrows(B)=50 → 80x40 result. Works. +nrows_a = 50 +ncols_a = 80 +nrows_b = 50 +ncols_b = 40 + +a_entries = for r <- 0..(nrows_a - 1), c <- 0..(ncols_a - 1), :rand.uniform() < 0.05, do: {r, c, :rand.uniform(50)} +b_entries = for r <- 0..(nrows_b - 1), c <- 0..(ncols_b - 1), :rand.uniform() < 0.05, do: {r, c, :rand.uniform(50)} +a_entries = if a_entries == [], do: [{0, 1, 1}], else: a_entries +b_entries = if b_entries == [], do: [{1, 0, 1}], else: b_entries + +# Pre-build SuiteSparse matrices +{:ok, ss_m} = SuiteSparse.matrix_from_coo(size, size, large_entries, :int64, []) +{:ok, ss_a} = SuiteSparse.matrix_from_coo(nrows_a, ncols_a, a_entries, :int64, []) +{:ok, ss_b} = SuiteSparse.matrix_from_coo(nrows_b, ncols_b, b_entries, :int64, []) + +# Pre-build reference matrices +{:ok, ref_m} = RefBackend.matrix_from_coo(size, size, large_entries, :int64, []) +{:ok, ref_a} = RefBackend.matrix_from_coo(nrows_a, ncols_a, a_entries, :int64, []) +{:ok, ref_b} = RefBackend.matrix_from_coo(nrows_b, ncols_b, b_entries, :int64, []) + +# Mask: diagonal-only (structural mask that allows only diagonal positions) +mask_entries = for i <- 0..(size - 1), do: {i, i, 1} +{:ok, ss_mask_src} = SuiteSparse.matrix_from_coo(size, size, mask_entries, :int64, []) +{:ok, ref_mask_src} = RefBackend.matrix_from_coo(size, size, mask_entries, :int64, []) +ss_mask = Mask.new(ss_mask_src) +ref_mask = Mask.new(ref_mask_src) +ss_complement_mask = Mask.complement(ss_mask_src) +ref_complement_mask = Mask.complement(ref_mask_src) + +# Vector for set/extract/dup +vec_entries = for i <- 0..(size - 1), :rand.uniform() < 0.1, do: {i, :rand.uniform(1000)} +vec_entries = if vec_entries == [], do: [{0, 1}, {5, 2}], else: vec_entries +{:ok, ss_v} = SuiteSparse.vector_from_entries(size, vec_entries, :int64, []) +{:ok, ref_v} = RefBackend.vector_from_entries(size, vec_entries, :int64, []) + +# Small matrix for set/extract/dup +small_entries = for r <- 0..9, c <- 0..9, do: {r, c, r * 10 + c + 1} +{:ok, ss_small} = SuiteSparse.matrix_from_coo(10, 10, small_entries, :int64, []) +{:ok, ref_small} = RefBackend.matrix_from_coo(10, 10, small_entries, :int64, []) + +# ============================================================================= +# 1. Masked mxm: structural mask vs complement mask vs unmasked +# ============================================================================= + +IO.puts("\n=== Masked mxm (#{size}x#{size}, ~#{length(large_entries)} entries) ===\n") + +Benchee.run( + %{ + "ss_mxm_unmasked" => fn -> + {:ok, c} = SuiteSparse.matrix_mxm(ss_m, ss_m, :plus_times, []) + SuiteSparse.matrix_free(c) + end, + "ss_mxm_structural_mask" => fn -> + {:ok, c} = SuiteSparse.matrix_mxm(ss_m, ss_m, :plus_times, mask: ss_mask) + SuiteSparse.matrix_free(c) + end, + "ss_mxm_complement_mask" => fn -> + {:ok, c} = SuiteSparse.matrix_mxm(ss_m, ss_m, :plus_times, mask: ss_complement_mask) + SuiteSparse.matrix_free(c) + end, + "ref_mxm_unmasked" => fn -> + {:ok, _} = RefBackend.matrix_mxm(ref_m, ref_m, :plus_times, []) + end, + "ref_mxm_structural_mask" => fn -> + {:ok, _} = RefBackend.matrix_mxm(ref_m, ref_m, :plus_times, mask: ref_mask) + end, + "ref_mxm_complement_mask" => fn -> + {:ok, _} = RefBackend.matrix_mxm(ref_m, ref_m, :plus_times, mask: ref_complement_mask) + end + }, + time: 2, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +# ============================================================================= +# 2. Descriptor inp0_transpose on mxm +# ============================================================================= + +IO.puts("\n=== Descriptor inp0_transpose on mxm (#{nrows_a}x#{ncols_a} * #{nrows_b}x#{ncols_b}) ===\n") + +desc = Descriptor.new(inp0_transpose: :transpose) + +Benchee.run( + %{ + "ss_mxm_explicit_transpose" => fn -> + {:ok, at} = SuiteSparse.matrix_transpose(ss_a, []) + {:ok, c} = SuiteSparse.matrix_mxm(at, ss_b, :plus_times, []) + SuiteSparse.matrix_free(at) + SuiteSparse.matrix_free(c) + end, + "ss_mxm_descriptor_transpose" => fn -> + {:ok, c} = SuiteSparse.matrix_mxm(ss_a, ss_b, :plus_times, descriptor: desc) + SuiteSparse.matrix_free(c) + end, + "ref_mxm_explicit_transpose" => fn -> + {:ok, at} = RefBackend.matrix_transpose(ref_a, []) + {:ok, _} = RefBackend.matrix_mxm(at, ref_b, :plus_times, []) + end, + "ref_mxm_descriptor_transpose" => fn -> + {:ok, _} = RefBackend.matrix_mxm(ref_a, ref_b, :plus_times, descriptor: desc) + end + }, + time: 2, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +# ============================================================================= +# 3. matrix_set / matrix_extract / matrix_dup +# ============================================================================= + +IO.puts("\n=== matrix_set / matrix_extract / matrix_dup (10x10) ===\n") + +Benchee.run( + %{ + "ss_matrix_set_int64" => fn -> + {:ok, _} = SuiteSparse.matrix_set(ss_small, 5, 5, 999) + end, + "ss_matrix_extract_int64" => fn -> + {:ok, _} = SuiteSparse.matrix_extract(ss_small, 5, 5) + end, + "ss_matrix_dup" => fn -> + {:ok, copy} = SuiteSparse.matrix_dup(ss_small) + SuiteSparse.matrix_free(copy) + end, + "ref_matrix_set_int64" => fn -> + {:ok, _} = RefBackend.matrix_set(ref_small, 5, 5, 999) + end, + "ref_matrix_extract_int64" => fn -> + {:ok, _} = RefBackend.matrix_extract(ref_small, 5, 5) + end, + "ref_matrix_dup" => fn -> + {:ok, _} = RefBackend.matrix_dup(ref_small) + end + }, + time: 2, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +# ============================================================================= +# 4. vector_set / vector_extract / vector_dup +# ============================================================================= + +IO.puts("\n=== vector_set / vector_extract / vector_dup (size=#{size}) ===\n") + +Benchee.run( + %{ + "ss_vector_set_int64" => fn -> + {:ok, _} = SuiteSparse.vector_set(ss_v, 10, 999) + end, + "ss_vector_extract_int64" => fn -> + {:ok, _} = SuiteSparse.vector_extract(ss_v, 10) + end, + "ss_vector_dup" => fn -> + {:ok, copy} = SuiteSparse.vector_dup(ss_v) + SuiteSparse.vector_free(copy) + end, + "ref_vector_set_int64" => fn -> + {:ok, _} = RefBackend.vector_set(ref_v, 10, 999) + end, + "ref_vector_extract_int64" => fn -> + {:ok, _} = RefBackend.vector_extract(ref_v, 10) + end, + "ref_vector_dup" => fn -> + {:ok, _} = RefBackend.vector_dup(ref_v) + end + }, + time: 2, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +# ============================================================================= +# 5. Masked vs unmasked mxm overhead on SuiteSparse (same data, different mask) +# ============================================================================= + +IO.puts("\n=== Mask overhead: unmasked vs masked mxm on SuiteSparse ===\n") + +Benchee.run( + %{ + "ss_mxm_no_mask" => fn -> + {:ok, c} = SuiteSparse.matrix_mxm(ss_m, ss_m, :plus_times, []) + SuiteSparse.matrix_free(c) + end, + "ss_mxm_with_mask" => fn -> + {:ok, c} = SuiteSparse.matrix_mxm(ss_m, ss_m, :plus_times, mask: ss_mask) + SuiteSparse.matrix_free(c) + end, + "ss_mxm_with_desc_mask" => fn -> + desc = Descriptor.new(mask: :structural) + {:ok, c} = SuiteSparse.matrix_mxm(ss_m, ss_m, :plus_times, mask: ss_mask, descriptor: desc) + SuiteSparse.matrix_free(c) + end + }, + time: 2, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +# --- Cleanup --- + +SuiteSparse.matrix_free(ss_m) +SuiteSparse.matrix_free(ss_a) +SuiteSparse.matrix_free(ss_b) +SuiteSparse.matrix_free(ss_mask_src) +SuiteSparse.matrix_free(ss_small) +SuiteSparse.vector_free(ss_v) +GraphBLAS.Native.grb_finalize() + +IO.puts(""" + +===================================== +Phase 5 benchmarks complete. +===================================== +""") diff --git a/bench/phase6_algorithms_benchmarks.exs b/bench/phase6_algorithms_benchmarks.exs new file mode 100644 index 0000000..636bc11 --- /dev/null +++ b/bench/phase6_algorithms_benchmarks.exs @@ -0,0 +1,296 @@ +# Phase 6 Benchmarks: Graph Algorithms - Elixir vs SuiteSparse +# +# Run with: mix run bench/phase6_algorithms_benchmarks.exs +# +# Benchmarks all Phase 6 algorithms on both backends: +# - bfs_reach, bfs_levels, sssp, triangle_count, connected_components +# - degree, pagerank, fixed_point +# - Relation.traverse, Relation.closure + +Application.ensure_all_started(:ex_graphblas) + +alias GraphBLAS.Backend.Elixir, as: RefBackend +alias GraphBLAS.Backend.SuiteSparse +alias GraphBLAS.{Algorithm, BenchData, Matrix, Relation} + +size_bfs = 100 +chain_entries = BenchData.chain_graph(size_bfs) + +{:ok, ref_bfs_chain} = RefBackend.matrix_from_coo(size_bfs, size_bfs, chain_entries, :bool, []) +{:ok, ss_bfs_chain} = SuiteSparse.matrix_from_coo(size_bfs, size_bfs, chain_entries, :bool, []) + +IO.puts("\n=== bfs_reach (chain graph, #{size_bfs} vertices) ===\n") + +Benchee.run( + %{ + "elixir_bfs_reach" => fn -> + {:ok, v} = Algorithm.bfs_reach(ref_bfs_chain, 0, backend: RefBackend) + v + end, + "suitesparse_bfs_reach" => fn -> + {:ok, v} = Algorithm.bfs_reach(ss_bfs_chain, 0, backend: SuiteSparse) + v + end + }, + time: 3, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +size_levels = 200 +rand_entries = BenchData.random_graph(size_levels, 0.05) + +{:ok, ref_bfs_rand} = RefBackend.matrix_from_coo(size_levels, size_levels, rand_entries, :bool, []) +{:ok, ss_bfs_rand} = SuiteSparse.matrix_from_coo(size_levels, size_levels, rand_entries, :bool, []) + +IO.puts("\n=== bfs_levels (random graph, #{size_levels} vertices, 5% density) ===\n") + +Benchee.run( + %{ + "elixir_bfs_levels" => fn -> + {:ok, v} = Algorithm.bfs_levels(ref_bfs_rand, 0, backend: RefBackend) + v + end, + "suitesparse_bfs_levels" => fn -> + {:ok, v} = Algorithm.bfs_levels(ss_bfs_rand, 0, backend: SuiteSparse) + v + end + }, + time: 3, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +size_sssp = 100 +weighted_entries = BenchData.weighted_random_graph(size_sssp, 0.05) + +{:ok, ref_sssp} = RefBackend.matrix_from_coo(size_sssp, size_sssp, weighted_entries, :fp64, []) +{:ok, ss_sssp} = SuiteSparse.matrix_from_coo(size_sssp, size_sssp, weighted_entries, :fp64, []) + +IO.puts("\n=== sssp (weighted random graph, #{size_sssp} vertices, 5% density) ===\n") + +Benchee.run( + %{ + "elixir_sssp" => fn -> + {:ok, v} = Algorithm.sssp(ref_sssp, 0, backend: RefBackend) + v + end, + "suitesparse_sssp" => fn -> + {:ok, v} = Algorithm.sssp(ss_sssp, 0, backend: SuiteSparse) + v + end + }, + time: 3, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +size_tri = 100 +undir_entries = BenchData.undirected_random_graph(size_tri, 0.10) + +{:ok, ref_tri} = RefBackend.matrix_from_coo(size_tri, size_tri, undir_entries, :bool, []) +{:ok, ss_tri} = SuiteSparse.matrix_from_coo(size_tri, size_tri, undir_entries, :bool, []) + +IO.puts("\n=== triangle_count (undirected random graph, #{size_tri} vertices, 10% density) ===\n") + +Benchee.run( + %{ + "elixir_triangle_count" => fn -> + {:ok, _} = Algorithm.triangle_count(ref_tri, backend: RefBackend) + end, + "suitesparse_triangle_count" => fn -> + {:ok, _} = Algorithm.triangle_count(ss_tri, backend: SuiteSparse) + end + }, + time: 3, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +size_cc = 100 +cc_entries = BenchData.undirected_random_graph(size_cc, 0.05) + +{:ok, ref_cc} = RefBackend.matrix_from_coo(size_cc, size_cc, cc_entries, :bool, []) +{:ok, ss_cc} = SuiteSparse.matrix_from_coo(size_cc, size_cc, cc_entries, :bool, []) + +IO.puts("\n=== connected_components (undirected random graph, #{size_cc} vertices, 5% density) ===\n") + +Benchee.run( + %{ + "elixir_connected_components" => fn -> + {:ok, v} = Algorithm.connected_components(ref_cc, backend: RefBackend) + v + end, + "suitesparse_connected_components" => fn -> + {:ok, v} = Algorithm.connected_components(ss_cc, backend: SuiteSparse) + v + end + }, + time: 3, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +size_deg = 500 +star_entries = BenchData.star_graph(size_deg) + +{:ok, ref_deg} = RefBackend.matrix_from_coo(size_deg, size_deg, star_entries, :bool, []) +{:ok, ss_deg} = SuiteSparse.matrix_from_coo(size_deg, size_deg, star_entries, :bool, []) + +IO.puts("\n=== degree (star graph, #{size_deg} vertices) ===\n") + +Benchee.run( + %{ + "elixir_degree" => fn -> + {:ok, _} = Algorithm.degree(ref_deg, backend: RefBackend) + end, + "suitesparse_degree" => fn -> + {:ok, _} = Algorithm.degree(ss_deg, backend: SuiteSparse) + end + }, + time: 3, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +size_pr = 100 +pr_entries = BenchData.random_graph(size_pr, 0.05) + +{:ok, ref_pr} = RefBackend.matrix_from_coo(size_pr, size_pr, pr_entries, :bool, []) +{:ok, ss_pr} = SuiteSparse.matrix_from_coo(size_pr, size_pr, pr_entries, :bool, []) + +IO.puts("\n=== pagerank (random graph, #{size_pr} vertices, 5% density) ===\n") + +Benchee.run( + %{ + "elixir_pagerank" => fn -> + {:ok, v} = Algorithm.pagerank(ref_pr, backend: RefBackend, max_iter: 20) + v + end, + "suitesparse_pagerank" => fn -> + {:ok, v} = Algorithm.pagerank(ss_pr, backend: SuiteSparse, max_iter: 20) + v + end + }, + time: 3, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +size_fp = 50 +cycle_entries = BenchData.cycle_graph(size_fp) + +{:ok, ref_fp} = RefBackend.matrix_from_coo(size_fp, size_fp, cycle_entries, :bool, []) +{:ok, ss_fp} = SuiteSparse.matrix_from_coo(size_fp, size_fp, cycle_entries, :bool, []) + +IO.puts("\n=== fixed_point (cycle graph closure, #{size_fp} vertices) ===\n") + +Benchee.run( + %{ + "elixir_fixed_point" => fn -> + {:ok, _result, _info} = + Algorithm.fixed_point( + ref_fp, + fn p -> + with {:ok, np} <- Matrix.mxm(p, ref_fp, :lor_land, backend: RefBackend), + {:ok, r} <- Matrix.ewise_add(p, np, :lor, backend: RefBackend) do + {:ok, r} + end + end, + max_iter: 10, + backend: RefBackend + ) + end, + "suitesparse_fixed_point" => fn -> + {:ok, _result, _info} = + Algorithm.fixed_point( + ss_fp, + fn p -> + with {:ok, np} <- Matrix.mxm(p, ss_fp, :lor_land, backend: SuiteSparse), + {:ok, r} <- Matrix.ewise_add(p, np, :lor, backend: SuiteSparse) do + {:ok, r} + end + end, + max_iter: 10, + backend: SuiteSparse + ) + end + }, + time: 3, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +size_rel = 50 +rel_density = 0.08 + +rel_entries_a = BenchData.random_graph(size_rel, rel_density) +rel_entries_b = BenchData.random_graph(size_rel, rel_density) +rel_entries_c = BenchData.random_graph(size_rel, rel_density) + +rel_pairs_a = Enum.map(rel_entries_a, fn {s, o, _} -> {s, o} end) +rel_pairs_b = Enum.map(rel_entries_b, fn {s, o, _} -> {s, o} end) +rel_pairs_c = Enum.map(rel_entries_c, fn {s, o, _} -> {s, o} end) + +ref_rel = Relation.new(size_rel) +{:ok, ref_rel} = Relation.add_triples(ref_rel, :follows, rel_pairs_a) +{:ok, ref_rel} = Relation.add_triples(ref_rel, :likes, rel_pairs_b) +{:ok, ref_rel} = Relation.add_triples(ref_rel, :knows, rel_pairs_c) + +ss_rel = Relation.new(size_rel) +{:ok, ss_rel} = Relation.add_triples(ss_rel, :follows, rel_pairs_a) +{:ok, ss_rel} = Relation.add_triples(ss_rel, :likes, rel_pairs_b) +{:ok, ss_rel} = Relation.add_triples(ss_rel, :knows, rel_pairs_c) + +IO.puts("\n=== Relation.traverse (3-hop, #{size_rel} vertices, 3 predicates) ===\n") + +Benchee.run( + %{ + "elixir_traverse_3hop" => fn -> + {:ok, _} = Relation.traverse(ref_rel, [:follows, :likes, :knows], :lor_land) + end, + "suitesparse_traverse_3hop" => fn -> + {:ok, _} = Relation.traverse(ss_rel, [:follows, :likes, :knows], :lor_land) + end + }, + time: 3, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +closure_entries = BenchData.random_graph(50, 0.08) +closure_pairs = Enum.map(closure_entries, fn {s, o, _} -> {s, o} end) + +ref_closure_rel = Relation.new(50) +{:ok, ref_closure_rel} = Relation.add_triples(ref_closure_rel, :follows, closure_pairs) + +ss_closure_rel = Relation.new(50) +{:ok, ss_closure_rel} = Relation.add_triples(ss_closure_rel, :follows, closure_pairs) + +IO.puts("\n=== Relation.closure (transitive closure, 50 vertices) ===\n") + +Benchee.run( + %{ + "elixir_closure" => fn -> + {:ok, _} = Relation.closure(ref_closure_rel, :follows, :lor_land) + end, + "suitesparse_closure" => fn -> + {:ok, _} = Relation.closure(ss_closure_rel, :follows, :lor_land) + end + }, + time: 3, + memory_time: 0, + formatters: [{Benchee.Formatters.Console, comparison: false}] +) + +SuiteSparse.matrix_free(ss_bfs_chain) +SuiteSparse.matrix_free(ss_bfs_rand) +SuiteSparse.matrix_free(ss_sssp) +SuiteSparse.matrix_free(ss_tri) +SuiteSparse.matrix_free(ss_cc) +SuiteSparse.matrix_free(ss_deg) +SuiteSparse.matrix_free(ss_pr) +SuiteSparse.matrix_free(ss_fp) +GraphBLAS.Native.grb_finalize() + +IO.puts("\n=====================================\nPhase 6 algorithm benchmarks complete.\n=====================================\n") diff --git a/bench/run_all.exs b/bench/run_all.exs new file mode 100644 index 0000000..075c1ff --- /dev/null +++ b/bench/run_all.exs @@ -0,0 +1,17 @@ +# Run all benchmark suites sequentially. +# +# Usage: mix run bench/run_all.exs + +IO.puts("=== Running Parity Benchmarks ===") +Code.require_file("parity_benchmarks.exs", __DIR__) + +IO.puts("\n=== Running Core Operations Benchmarks ===") +Code.require_file("core_ops_benchmarks.exs", __DIR__) + +IO.puts("\n=== Running Phase 5 Benchmarks ===") +Code.require_file("phase5_benchmarks.exs", __DIR__) + +IO.puts("\n=== Running Phase 6 Algorithms Benchmarks ===") +Code.require_file("phase6_algorithms_benchmarks.exs", __DIR__) + +IO.puts("\n=== All benchmarks complete ===") diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..3ba84b9 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,32 @@ +# GraphBLAS Configuration +# +# This configuration file sets application-level defaults. +# Environment-specific overrides go in config/#{env}.exs. +# +# Backend selection: +# GraphBLAS.Backend.Elixir - Pure Elixir reference backend. +# Correct but not performant. Suitable for +# testing and development. This is the default +# for safety. +# +# GraphBLAS.Backend.SuiteSparse - SuiteSparse:GraphBLAS via Zigler. +# High-performance native backend. Requires +# SuiteSparse:GraphBLAS C library installed. +# +# To override at runtime, set: +# config :ex_graphblas, :default_backend, GraphBLAS.Backend.Elixir +# +# SuiteSparse include path: +# Set SUITESPARSE_INCLUDE_PATH env variable or :suitesparse_include_path +# config key to override the default path for your platform. +# Defaults: +# macOS (Homebrew): /opt/homebrew/include/suitesparse +# Linux: /usr/include/suitesparse +# Linux (local): /usr/local/include/suitesparse + +import Config + +config :ex_graphblas, + default_backend: GraphBLAS.Backend.Elixir + +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..48d377e --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,9 @@ +import Config + +config :ex_graphblas, + default_backend: GraphBLAS.Backend.Elixir + +config :zigler_precompiled, + force_build_all: true + +config :zigler_precompiled, :force_build, ex_graphblas: true diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..8692ab4 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,4 @@ +import Config + +config :ex_graphblas, + default_backend: GraphBLAS.Backend.Elixir diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..8692ab4 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,4 @@ +import Config + +config :ex_graphblas, + default_backend: GraphBLAS.Backend.Elixir diff --git a/coveralls.json b/coveralls.json new file mode 100644 index 0000000..756f352 --- /dev/null +++ b/coveralls.json @@ -0,0 +1,3 @@ +{ + "skip_files": [] +} diff --git a/guides/architecture_walkthrough.md b/guides/architecture_walkthrough.md new file mode 100644 index 0000000..da00a77 --- /dev/null +++ b/guides/architecture_walkthrough.md @@ -0,0 +1,220 @@ +# Architecture Walkthrough: GraphBLAS for the Elixir Engineer + +## The problem GraphBLAS solves + +Suppose you have a social network with 10 million users. Each user follows a few hundred others. You want to find all users reachable within two hops from user 42. + +The adjacency matrix is 10 million by 10 million. Storing it densely requires 100 trillion entries. But only about 1 billion entries are non-zero (each user follows ~100 others). That is 99.99% sparsity. + +A sparse matrix stores only the 1 billion non-zeros. A sparse semiring multiplication operates only on stored entries, skipping the 99.99% that are zero. This turns a computationally intractable problem into one that finishes in seconds. + +GraphBLAS provides the Elixir API for this kind of computation. + +## Creating your first sparse matrix + +```elixir +# A 4-node directed cycle: 0 -> 1 -> 2 -> 3 -> 0 +{:ok, adj} = GraphBLAS.Matrix.from_coo(4, 4, [ + {0, 1, 1}, + {1, 2, 1}, + {2, 3, 1}, + {3, 0, 1} +], :int64) +``` + +The arguments are: +1. Number of rows (4) +2. Number of columns (4) +3. A list of `{row, col, value}` triples (COO format) +4. The scalar type (`:int64` for integers) + +This creates a sparse matrix with exactly 4 stored entries. The other 12 entries are implicitly zero. + +```elixir +{:ok, {4, 4}} = GraphBLAS.Matrix.shape(adj) # dimensions +{:ok, 4} = GraphBLAS.Matrix.nvals(adj) # stored entries +{:ok, :int64} = GraphBLAS.Matrix.type(adj) # scalar type +``` + +## Inspecting a matrix + +```elixir +{:ok, entries} = GraphBLAS.Matrix.to_coo(adj) +# [{0, 1, 1}, {1, 2, 1}, {2, 3, 1}, {3, 0, 1}] +``` + +Entries are returned in row-major order (sorted by row, then column). This is the canonical form for comparison. + +## Matrix multiplication: the heart of GraphBLAS + +### Standard multiplication + +```elixir +# adj * adj gives two-hop reachability +{:ok, reach2} = GraphBLAS.Matrix.mxm(adj, adj, :plus_times) +``` + +What happens internally: +1. For each pair `(i, j)`, compute `C[i,j] = sum(A[i,k] * B[k,j])` for all k where both A and B have stored entries +2. In our case, `adj * adj` connects nodes that are two hops apart +3. The `:plus_times` semiring tells us: multiply matching entries with `*`, combine using `+` + +### Changing the semiring changes the algorithm + +```elixir +# Boolean reachability (two hops, ignoring edge weights) +{:ok, bool_reach2} = GraphBLAS.Matrix.mxm(adj, adj, :lor_land) + +# Shortest path (two edges, minimum total weight) +{:ok, shortest2} = GraphBLAS.Matrix.mxm(weighted_adj, weighted_adj, :plus_min) +``` + +The same `mxm` operation, different semiring, different algorithm. This is the core power of GraphBLAS. + +## Vectors: the one-dimensional sibling + +```elixir +# A vector representing "start from node 0" +{:ok, start} = GraphBLAS.Vector.from_entries(4, [{0, 1}], :int64) + +# One hop: which nodes are reachable from node 0? +{:ok, reachable} = GraphBLAS.Matrix.mxv(adj, start, :lor_land) + +# The result tells us: node 1 is reachable from node 0 +{:ok, entries} = GraphBLAS.Vector.to_entries(reachable) +# [{1, 1}] +``` + +`mxv` multiplies a matrix by a vector. `vxm` multiplies a vector by a matrix (from the left). Both use the same semiring mechanism. + +## Element-wise operations + +### ewise_add: union of positions + +```elixir +{:ok, union} = GraphBLAS.Matrix.ewise_add(adj, adj, :plus) +# Positions that exist in EITHER matrix appear in the result +# Overlapping positions are combined using the monoid (+: sum the values) +``` + +### ewise_mult: intersection of positions + +```elixir +{:ok, intersection} = GraphBLAS.Matrix.ewise_mult(adj, adj, :times) +# Only positions that exist in BOTH matrices appear in the result +# Values are combined using the monoid (*: multiply the values) +``` + +The mnemonic: `ewise_add` is like set union, `ewise_mult` is like set intersection. + +## Reduction + +```elixir +# Sum all values in each row +{:ok, row_sums} = GraphBLAS.Matrix.reduce(adj, :plus) + +# Sum all values in a vector +{:ok, total} = GraphBLAS.Vector.reduce(start, :plus) +GraphBLAS.Scalar.value(total) # => 1 +``` + +Reduction collapses a dimension using a monoid. For matrices, it reduces each row to a scalar, producing a vector. For vectors, it reduces all values to a single scalar. + +## Transpose + +```elixir +{:ok, t} = GraphBLAS.Matrix.transpose(adj) +# The transpose swaps rows and columns: A[i,j] becomes A^T[j,i] +# For our directed cycle: 0->1 becomes 1->0 (reversed edges) +``` + +## How backend selection works + +Every operation resolves its backend before executing: + +```elixir +# Inside GraphBLAS.Matrix.mxm/4: +def mxm(%Matrix{} = a, %Matrix{} = b, semiring \\ :plus_times, opts \\ []) do + backend = Config.resolve_backend(opts) # 1. Check opts for :backend key + backend.matrix_mxm(a, b, semiring, opts) # 2. Delegate to backend +end +``` + +The backend resolution chain: +1. If `opts` contains `backend: SomeModule`, use that module +2. Otherwise, check `Application.get_env(:ex_graphblas, :default_backend)` +3. Otherwise, fall back to `GraphBLAS.Backend.Elixir` + +This means you can: +- Use the default backend globally (via config) +- Override per-call (via options) +- Write tests with mock backends (by passing a test module) + +## The backend contract + +Every backend must implement `GraphBLAS.Backend`, which defines 24 callbacks: + +```elixir +# Matrix callbacks +@callback matrix_new(nrows, ncols, type, opts) :: {:ok, Matrix.t()} | {:error, Error.t()} +@callback matrix_from_coo(nrows, ncols, entries, type, opts) :: ... +@callback matrix_nvals(matrix) :: {:ok, non_neg_integer()} | ... +@callback matrix_mxm(a, b, semiring, opts) :: ... +# ... and so on for all matrix operations + +# Vector callbacks +@callback vector_new(size, type, opts) :: ... +@callback vector_from_entries(size, entries, type, opts) :: ... +# ... and so on for all vector operations +``` + +The Elixir backend implements all 24 callbacks (plus `matrix_to_dense` and `vector_to_list`) using flat tuple-key maps. The SuiteSparse backend (Phase 3) will implement them using NIF calls to the C library. + +## Why opaque data? + +The `:data` field in Matrix and Vector structs is intentionally opaque: + +```elixir +# Elixir backend stores: +%{entries: %{{0, 1} => 1}, nrows: 4, ncols: 4, type: :int64} + +# SuiteSparse backend will store: +# A NIF resource reference (an opaque pointer to a GrB_Matrix handle) +``` + +If calling code pattern-matched on the data field, it would break when switching backends. By keeping data opaque, we guarantee that backend swapping is safe. + +**Rule**: Never access the `:data` field directly. Always use API functions. + +## Masks and descriptors (what's coming) + +Masks and descriptors are defined in Phase 1 but not yet wired into operations. Here is a preview: + +### Masks + +```elixir +# A mask restricts which output positions are written +mask = GraphBLAS.Mask.new(frontier) # write only where frontier has stored entries +cmask = GraphBLAS.Mask.complement(frontier) # write only where frontier does NOT have stored entries +``` + +Masks will be passed as options: `GraphBLAS.Matrix.mxm(a, b, :plus_times, mask: mask)` + +### Descriptors + +```elixir +# A descriptor modifies how the operation interprets inputs and outputs +desc = GraphBLAS.Descriptor.new(inp0_transpose: :transpose) +# This tells mxm to treat A as if it were transposed, without creating A^T +``` + +Descriptors will be passed as: `GraphBLAS.Matrix.mxm(a, b, :plus_times, descriptor: desc)` + +## Next steps + +- **Phase 2 (done)**: The Elixir reference backend now uses flat tuple-key maps and has semantic correctness tests. Dense conversion helpers (`Matrix.to_dense`, `Vector.to_list`) added. +- **Phase 3**: SuiteSparse native backend via Zigler +- **Phase 3**: More operations (additional semirings, assignment, extraction) +- **Phase 4**: Full mask and descriptor support in compute operations +- **Phase 5**: Graph algorithms built on the operations API (BFS, PageRank, triangle counting) +- **Phase 6**: Nx integration (conversion helpers, reference adapter) diff --git a/guides/graph_algorithms_guide.md b/guides/graph_algorithms_guide.md new file mode 100644 index 0000000..7ccc4d7 --- /dev/null +++ b/guides/graph_algorithms_guide.md @@ -0,0 +1,195 @@ +# Graph Algorithms and Knowledge Graphs Guide + +**Status: IMPLEMENTED** + +## Using graph algorithms + +All algorithms are in `GraphBLAS.Algorithm` and accept the adjacency matrix convention: `A[i][j]` means edge FROM vertex i TO vertex j. + +### BFS reachability + +Find all vertices reachable from a source: + +```elixir +alias GraphBLAS.{Matrix, Algorithm} + +{:ok, adj} = Matrix.from_coo(5, 5, [{0, 1, true}, {1, 2, true}, {2, 3, true}, {3, 4, true}], :bool) +{:ok, visited} = Algorithm.bfs_reach(adj, 0) +# visited: true at indices 0, 1, 2, 3, 4 +``` + +### BFS with levels + +Find hop distances from a source: + +```elixir +{:ok, levels} = Algorithm.bfs_levels(adj, 0) +# levels: 0 at index 0, 1 at index 1, 2 at index 2, etc. +``` + +### Shortest path + +Find minimum-weight distances from a source (fp64 only): + +```elixir +{:ok, weighted} = Matrix.from_coo(4, 4, [{0, 1, 2.0}, {1, 2, 3.0}, {0, 2, 10.0}], :fp64) +{:ok, dist} = Algorithm.sssp(weighted, 0) +# dist[1] = 2.0, dist[2] = 5.0 (0→1→2 is shorter than 0→2) +``` + +### Triangle counting + +Count triangles in an undirected graph (symmetric adjacency): + +```elixir +# K4: 4 vertices, all connected = 4 triangles +{:ok, k4} = Matrix.from_coo(4, 4, [ + {0, 1, true}, {1, 0, true}, {0, 2, true}, {2, 0, true}, + {0, 3, true}, {3, 0, true}, {1, 2, true}, {2, 1, true}, + {1, 3, true}, {3, 1, true}, {2, 3, true}, {3, 2, true} +], :bool) +{:ok, 4} = Algorithm.triangle_count(k4) +``` + +### Connected components + +Assign component IDs to vertices: + +```elixir +{:ok, components} = Algorithm.connected_components(adj) +# All vertices in the same component share the same ID +``` + +### PageRank + +Compute importance scores: + +```elixir +{:ok, ranks} = Algorithm.pagerank(adj, damping: 0.85, max_iter: 100, tol: 1.0e-6) +``` + +### Degree + +Compute in-degree and out-degree: + +```elixir +{:ok, %{in_degree: ind, out_degree: outd}} = Algorithm.degree(adj) +``` + +## Using knowledge graph operations + +### Building a relation + +```elixir +alias GraphBLAS.Relation + +# 4 entities: alice=0, bob=1, carol=2, dave=3 +rel = Relation.new(4) + +{:ok, rel} = Relation.add_triples(rel, :follows, [{0, 1}, {1, 2}]) # alice follows bob, bob follows carol +{:ok, rel} = Relation.add_triples(rel, :likes, [{0, 2}, {1, 2}]) # alice likes carol, bob likes carol +{:ok, rel} = Relation.add_triples(rel, :knows, [{0, 1}, {1, 0}]) # alice knows bob, bob knows alice +``` + +### Multi-hop traversal + +"Who does Alice follow-then-like?" + +```elixir +{:ok, result} = Relation.traverse(rel, [:follows, :likes], :lor_land) +# result[0][2] = true (alice→bob→carol: follows then likes) +``` + +"Are there any follows-then-likes paths?" + +```elixir +{:ok, result} = Relation.traverse(rel, [:follows, :likes], :lor_land) +``` + +"How many follows-then-likes paths exist between each pair?" + +```elixir +{:ok, result} = Relation.traverse(rel, [:follows, :likes], :plus_times) +``` + +### Transitive closure + +"Who can each person eventually reach by following?" + +```elixir +{:ok, reachable} = Relation.closure(rel, :follows, :lor_land) +# alice can reach bob (direct) and carol (via bob) +``` + +"What is the shortest following-path between each pair?" + +```elixir +{:ok, shortest} = Relation.closure(rel, :follows, :plus_min) +``` + +## Using fixed-point iteration + +The `fixed_point` primitive iterates a step function until convergence: + +```elixir +alias GraphBLAS.Algorithm + +# Transitive closure of an edge matrix +{:ok, closure, info} = Algorithm.fixed_point( + edges, + fn p -> + {:ok, new_paths} = GraphBLAS.Matrix.mxm(p, edges, :lor_land) + GraphBLAS.Matrix.ewise_add(edges, new_paths, :lor) + end, + max_iter: 50 +) +# info = %{iterations: 3, converged: true} +``` + +Different semirings answer different queries with the same iteration: + +```elixir +# Reachability +Algorithm.fixed_point(edges, fn p -> + GraphBLAS.Matrix.ewise_add(edges, GraphBLAS.Matrix.mxm(p, edges, :lor_land), :lor) +end, []) + +# Shortest path +Algorithm.fixed_point(edges, fn p -> + GraphBLAS.Matrix.ewise_add(edges, GraphBLAS.Matrix.mxm(p, edges, :plus_min_fp64), :min_fp64) +end, []) + +# Path counting +Algorithm.fixed_point(edges, fn p -> + GraphBLAS.Matrix.ewise_add(edges, GraphBLAS.Matrix.mxm(p, edges, :plus_times), :plus) +end, []) +``` + +## How algorithms map to matrix operations + +| Algorithm | Core operation | Semiring | Mask usage | +|-----------|---------------|----------|------------| +| BFS reach | `vxm` | `lor_land` | Complement mask prevents revisits | +| BFS levels | `vxm` | `lor_land` + Elixir stamping | Complement mask + structural mask | +| SSSP | `vxm` | `plus_min_fp64` | None (full relaxation) | +| Triangle count | `mxm` | `plus_times` | Structural mask on lower triangle | +| Connected comp | `vxm` + `ewise_add` | `lor_land` + `plus_min` | Complement mask per component | +| Degree | `reduce` | `plus` (monoid) | None | +| PageRank | `mxv` | `plus_times_fp64` | None | + +## How knowledge graph queries map to matrix operations + +| Query | Operation | Semiring | +|-------|-----------|----------| +| Does path exist? | `mxm` chain | `lor_land` | +| How many paths? | `mxm` chain | `plus_times` | +| Shortest path? | `mxm` chain | `plus_min` | +| Widest path? | `mxm` chain | `max_min` | +| Can reach eventually? | Fixed-point `mxm` | `lor_land` | +| Shortest eventual path? | Fixed-point `mxm` | `plus_min` | + +## Memory management + +Algorithm functions manage intermediate containers internally. On the SuiteSparse backend, intermediate matrices and vectors created during iteration are freed within the algorithm loop. Users receive only the final result. + +For the Elixir reference backend, no manual memory management is needed. diff --git a/guides/installation_guide.md b/guides/installation_guide.md new file mode 100644 index 0000000..e869ede --- /dev/null +++ b/guides/installation_guide.md @@ -0,0 +1,206 @@ +# Installation Guide + +This guide covers installing ex_graphblas and its native dependencies. + +## Prerequisites + +- Elixir >= 1.18 +- Erlang/OTP >= 27 +- A C compiler (gcc or clang) +- Zig (managed by Zigler dependency) +- SuiteSparse:GraphBLAS (for native backend only) + +## Basic installation (Elixir backend only) + +If you only need the pure Elixir reference backend, no C dependencies are required: + +```elixir +# mix.exs +def deps do + [{:ex_graphblas, "~> 0.2.0"}] +end +``` + +```bash +mix deps.get +mix compile +``` + +The Elixir backend is the default. It works without any native libraries. + +## SuiteSparse native backend installation + +The SuiteSparse backend provides high-performance sparse operations via the SuiteSparse:GraphBLAS C library. It requires additional system-level dependencies. + +### macOS (Homebrew) + +```bash +brew install suite-sparse +``` + +SuiteSparse headers install to `/opt/homebrew/include/suitesparse`. This is the default include path. + +### Linux (Debian/Ubuntu) + +```bash +sudo apt-get install libsuitesparse-dev +``` + +Headers install to `/usr/include/suitesparse`. + +### Linux (Fedora/RHEL) + +```bash +sudo dnf install suitesparse-devel +``` + +### Linux (Nix) + +```nix +# shell.nix +with import {}; +mkShell { + buildInputs = [ elixir suitesparse ]; + SUITESPARSE_INCLUDE_PATH = "${suitesparse}/include/suitesparse"; +} +``` + +### From source + +If SuiteSparse is not available via package manager: + +```bash +git clone https://github.com/DrTimothyAldenDavis/SuiteSparse.git +cd SuiteSparse +make library +sudo make install +``` + +Default install prefix is `/usr/local`. Headers go to `/usr/local/include/suitesparse`. + +## Configuring the include path + +The SuiteSparse include path is configured via an environment variable read at compile time. + +### Default paths + +The library checks the following defaults based on platform: + +| Platform | Default path | +|----------|-------------| +| macOS (Homebrew) | `/opt/homebrew/include/suitesparse` | +| macOS (Homebrew, Intel) | `/usr/local/include/suitesparse` | +| Linux | `/usr/include/suitesparse` | +| Linux (local install) | `/usr/local/include/suitesparse` | + +### Override via environment variable + +If SuiteSparse is installed to a non-standard location: + +```bash +SUITESPARSE_INCLUDE_PATH=/custom/path/include/suitesparse mix compile +``` + +### Override via config + +In `config/config.exs`: + +```elixir +config :ex_graphblas, + suitesparse_include_path: "/custom/path/include/suitesparse" +``` + +## Selecting a backend + +### Default backend (config) + +```elixir +# config/config.exs +config :ex_graphblas, + default_backend: GraphBLAS.Backend.Elixir # default, no native dependency + # default_backend: GraphBLAS.Backend.SuiteSparse # requires SuiteSparse +``` + +### Per-call backend + +```elixir +# Use SuiteSparse for a specific operation +{:ok, m} = GraphBLAS.Matrix.from_coo(100, 100, entries, :int64, + backend: GraphBLAS.Backend.SuiteSparse +) +``` + +## Verifying the installation + +### Check Elixir backend + +```elixir +iex -S mix +{:ok, m} = GraphBLAS.Matrix.from_coo(3, 3, [{0, 1, 1}, {1, 2, 2}], :int64) +{:ok, entries} = GraphBLAS.Matrix.to_coo(m) +# Should return [{0, 1, 1}, {1, 2, 2}] +``` + +### Check SuiteSparse backend + +```elixir +iex -S mix +{:ok, m} = GraphBLAS.Matrix.from_coo(3, 3, [{0, 1, 1}, {1, 2, 2}], :int64, + backend: GraphBLAS.Backend.SuiteSparse +) +{:ok, entries} = GraphBLAS.Matrix.to_coo(m) +# Should return [{0, 1, 1}, {1, 2, 2}] +GraphBLAS.Backend.SuiteSparse.matrix_free(m) +``` + +If the SuiteSparse backend fails to load, verify: +1. SuiteSparse:GraphBLAS is installed (`ldconfig -p | grep graphblas` on Linux) +2. The include path is correct +3. Zigler can find the C headers at compile time + +## Troubleshooting + +### "SuiteSparse headers not found" + +The Zigler NIF cannot find `GraphBLAS.h`. Set the include path: + +```bash +SUITESPARSE_INCLUDE_PATH=/usr/include/suitesparse mix compile +``` + +### "undefined NIF function" + +The native library did not compile or load. Run: + +```bash +mix compile --force +``` + +Check the compilation output for Zig/C errors. Common causes: +- SuiteSparse not installed +- Wrong include path +- Incompatible SuiteSparse version (requires >= 7.0) + +### "function clause error" on Vector.nvals with SuiteSparse + +This was a bug in earlier versions where inspection functions dispatched to the wrong backend. Fixed in 0.2.0 via the `backend` field in structs. Ensure you are running >= 0.2.0. + +### BEAM VM crashes during SuiteSparse operations + +SuiteSparse NIFs run on dirty CPU schedulers. If the BEAM VM crashes: +1. Ensure your Erlang runtime has dirty scheduler support (OTP >= 20) +2. Check that `erl +SDcpu` is not set to 0 +3. The NIF may be accessing freed resources -- ensure `maybe_free/2` is called correctly in iterative algorithms + +## Running the benchmarks + +```bash +# All benchmarks +mix run bench/run_all.exs + +# Specific benchmark +mix run bench/phase6_algorithms_benchmarks.exs +mix run bench/core_ops_benchmarks.exs +``` + +Benchmarks require both backends to be operational. diff --git a/guides/masks_and_descriptors_guide.md b/guides/masks_and_descriptors_guide.md new file mode 100644 index 0000000..653628f --- /dev/null +++ b/guides/masks_and_descriptors_guide.md @@ -0,0 +1,215 @@ +# Masks and Descriptors Guide + +**Status: IMPLEMENTED** + +## Why masks and descriptors exist + +Without masks, every GraphBLAS compute operation writes all computed positions to the result. This is mathematically complete but practically wasteful. Real graph algorithms care about subsets of positions: + +- BFS visits unvisited nodes, not all nodes. +- PageRank updates scores for all nodes, but only if they changed. +- Triangle counting intersects adjacency lists, not unions them. + +Masks let you restrict which output positions are written. Descriptors let you modify operation semantics (transpose inputs, replace output) without creating new operation variants. + +## Using masks + +### Structural mask + +A structural mask writes only at positions where the mask has stored entries: + +```elixir +alias GraphBLAS.{Matrix, Mask} + +# Create a result matrix with entries at many positions +{:ok, a} = Matrix.from_coo(3, 3, [{0, 0, 1}, {0, 1, 2}, {1, 1, 3}, {2, 2, 4}], :int64) +{:ok, b} = Matrix.from_coo(3, 3, [{0, 0, 5}, {1, 0, 6}, {2, 1, 7}], :int64) + +# Create a mask that only allows writing to the diagonal +{:ok, mask_m} = Matrix.from_coo(3, 3, [{0, 0, 1}, {1, 1, 1}, {2, 2, 1}], :bool) +mask = Mask.new(mask_m) + +# mxm result is masked — only diagonal entries survive +{:ok, c} = Matrix.mxm(a, b, :plus_times, mask: mask) +``` + +The mask's values are ignored in structural mode. Only its structure (which positions have entries) matters. + +### Complement mask + +A complement mask writes only at positions where the mask does NOT have stored entries: + +```elixir +# "visited" vector marks nodes 0 and 1 as visited +{:ok, visited} = Vector.from_entries(4, [{0, true}, {1, true}], :bool) + +# Compute neighbors, but only keep unvisited ones +{:ok, neighbors} = Vector.vxm(frontier, adjacency, :lor_land, + mask: Mask.complement(visited) +) +``` + +Complement masks are the primary mechanism for BFS-style "only process new things" patterns. + +### Mask type rules + +The mask type must match the output type: + +| Operation | Output type | Mask type | +|-----------|-------------|-----------| +| `matrix_mxm` | Matrix | Matrix | +| `matrix_mxv` | Vector | Vector | +| `matrix_ewise_add` | Matrix | Matrix | +| `matrix_ewise_mult` | Matrix | Matrix | +| `matrix_reduce` | Vector | Vector | +| `matrix_transpose` | Matrix | Matrix | +| `vector_vxm` | Vector | Vector | +| `vector_ewise_add` | Vector | Vector | +| `vector_ewise_mult` | Vector | Vector | + +A type mismatch returns `{:error, {:mask_type_mismatch, ...}}`. + +## Using descriptors + +### Transpose input descriptors + +The most common descriptor use is transposing inputs without copying: + +```elixir +alias GraphBLAS.{Matrix, Descriptor} + +# Without descriptor: must explicitly transpose (creates a copy) +{:ok, at} = Matrix.transpose(a) +{:ok, c} = Matrix.mxm(at, b, :plus_times) + +# With descriptor: no copy needed +{:ok, c} = Matrix.mxm(a, b, :plus_times, descriptor: Descriptor.inp0_transpose()) +``` + +The second form is more efficient (no intermediate matrix) and more readable (intent is explicit). + +### Combining masks and descriptors + +Masks and descriptors compose naturally: + +```elixir +# Compute A^T * B, writing only to positions not in the visited mask +{:ok, c} = Matrix.mxm(a, b, :plus_times, + mask: Mask.complement(visited), + descriptor: Descriptor.new(inp0_transpose: :transpose) +) +``` + +### Replace output descriptor + +The `output: :replace` descriptor clears the output before writing. In the current API (which always creates fresh results), it is a no-op. It becomes meaningful when in-place operations are added: + +```elixir +# Future: in-place mxm that replaces the contents of c +# {:ok, c} = Matrix.mxm_into(c, a, b, :plus_times, descriptor: Descriptor.replace_output()) +``` + +For now, use `Descriptor.replace_output()` to prepare for in-place operations. + +## Container manipulation + +### Setting entries + +Write a value at a specific position: + +```elixir +{:ok, m} = Matrix.from_coo(3, 3, [{0, 0, 1}], :int64) + +# Add an entry at (1, 2) +{:ok, m} = Matrix.set(m, 1, 2, 5) + +# Overwrite an existing entry at (0, 0) +{:ok, m} = Matrix.set(m, 0, 0, 10) +``` + +`set` always overwrites. It does not combine with the existing value. + +### Extracting entries + +Read a value at a specific position: + +```elixir +{:ok, m} = Matrix.from_coo(3, 3, [{0, 0, 42}], :int64) + +{:ok, 42} = Matrix.extract(m, 0, 0) # stored entry +{:ok, 0} = Matrix.extract(m, 1, 1) # structural zero returns default +``` + +For structural zeros, `extract` returns the default value for the type (0 for :int64, 0.0 for :fp64, false for :bool). + +### Duplicating containers + +Deep-copy a container. The copy is independent — modifying it does not affect the original: + +```elixir +{:ok, m} = Matrix.from_coo(3, 3, [{0, 0, 1}, {1, 1, 2}], :int64) +{:ok, copy} = Matrix.dup(m) + +# Modifying copy does not affect m +{:ok, copy} = Matrix.set(copy, 0, 0, 99) +{:ok, 1} = Matrix.extract(m, 0, 0) # original unchanged +{:ok, 99} = Matrix.extract(copy, 0, 0) # copy modified +``` + +## How masks are implemented + +### Elixir reference backend + +The reference backend computes the full result, then filters: + +1. Compute the result as usual. +2. Get the mask's structural positions from its `:data` map (the keys). +3. For structural mask: keep result entries whose position is in the mask positions. +4. For complement mask: keep result entries whose position is NOT in the mask positions. + +### SuiteSparse native backend + +The native backend passes the mask pointer directly to the C function: + +1. Extract the mask container's C pointer from its `:data` field (`data[:ptr]`). +2. Pass it as the `Mask` parameter to the SuiteSparse function. +3. If complement mask, set `GrB_MASK_COMP` on the descriptor. +4. If structural mask (default), set `GrB_STRUCTURE` on the MASK field of the descriptor. +5. SuiteSparse applies the mask internally (possibly with optimization). + +The SuiteSparse backend defaults to "valued" mask mode (uses mask values), while the Elixir backend defaults to "structural" (uses positions). When a mask is provided without an explicit descriptor, the SuiteSparse backend sets `mask_structural = true` to match the Elixir backend's default. + +## How descriptors are implemented + +### Elixir reference backend + +The reference backend interprets descriptor fields in Elixir: + +- `inp0_transpose: :transpose` → call `matrix_transpose` on first input +- `inp1_transpose: :transpose` → call `matrix_transpose` on second input +- `output: :replace` → no-op (always creates fresh result) +- `mask: :value` → filter mask entries by their values (nonzero/true = mask position) + +### SuiteSparse native backend + +The native backend creates a `GrB_Descriptor` object: + +1. Resolve descriptor flags from the `Descriptor` struct and mask options. +2. Map flags to a pre-defined descriptor global (e.g., `GrB_DESC_SC` for structural complement mask) when available. +3. For uncommon combinations, create a custom descriptor via `descriptor_create` NIF. +4. Pass the descriptor to the compute function. +5. After the NIF call, free only custom descriptors (pre-defined globals must NOT be freed). + +Transposition is handled on the Elixir side (not via SuiteSparse descriptor `GrB_INP0_TRAN`) to avoid dimension mismatches with the pre-created result matrix. The `maybe_transpose_inp0/inp1` helpers call `matrix_transpose` before passing to the NIF. + +## Memory management with masks + +Masks reference existing containers. They do NOT take ownership: + +```elixir +# After using a mask, the mask container still needs to be freed +# (for SuiteSparse backend) +SuiteSparse.matrix_free(mask_matrix) +``` + +The mask container must outlive the compute operation. Do not free the mask before using it. diff --git a/guides/native_backend_walkthrough.md b/guides/native_backend_walkthrough.md new file mode 100644 index 0000000..500932c --- /dev/null +++ b/guides/native_backend_walkthrough.md @@ -0,0 +1,287 @@ +# Native Backend Walkthrough: How Elixir Talks to SuiteSparse + +**Status: IMPLEMENTED** + +## The boundary we are crossing + +Phase 2 gave us a backend that runs entirely in the BEAM. Every operation is pure Elixir — flat maps, `Enum.reduce`, `Map.merge`. It is correct, inspectable, and slow. + +Phase 3 crosses the BEAM-native boundary. When you call `GraphBLAS.Matrix.mxm(a, b, :plus_times, backend: GraphBLAS.Backend.SuiteSparse)`, the computation leaves the BEAM and enters the C world of SuiteSparse:GraphBLAS. + +This walkthrough explains exactly what happens at that boundary, why it is safe, and how to reason about it. + +## Your first native matrix multiplication + +```elixir +# Create matrices using the SuiteSparse backend +{:ok, a} = GraphBLAS.Matrix.from_coo(3, 3, [{0, 1, 1}, {1, 2, 1}, {2, 0, 1}], :int64, + backend: GraphBLAS.Backend.SuiteSparse +) +{:ok, b} = GraphBLAS.Matrix.from_coo(3, 3, [{0, 1, 1}, {1, 2, 1}, {2, 0, 1}], :int64, + backend: GraphBLAS.Backend.SuiteSparse +) + +# Multiply using SuiteSparse +{:ok, c} = GraphBLAS.Matrix.mxm(a, b, :plus_times, + backend: GraphBLAS.Backend.SuiteSparse +) + +# Inspect the result +{:ok, {3, 3}} = GraphBLAS.Matrix.shape(c) +{:ok, coo} = GraphBLAS.Matrix.to_coo(c) + +# IMPORTANT: Free SuiteSparse objects when done +SuiteSparse.matrix_free(a) +SuiteSparse.matrix_free(b) +SuiteSparse.matrix_free(c) +``` + +The API is identical to the Elixir backend. The `backend:` option selects which implementation runs. The result data structure has the same shape — only the `:data` field contents differ. + +## What happens inside mxm + +### Step 1: Elixir-side dispatch + +```elixir +# In GraphBLAS.Matrix.mxm/4 +def mxm(%Matrix{} = a, %Matrix{} = b, semiring \\ :plus_times, opts \\ []) do + backend = Config.resolve_backend(opts) + backend.matrix_mxm(a, b, semiring, opts) +end +``` + +`Config.resolve_backend(opts)` reads the `:backend` key from opts, or falls back to the configured default. + +### Step 2: SuiteSparse backend — atom resolution + +```elixir +# In GraphBLAS.Backend.SuiteSparse.matrix_mxm/4 +def matrix_mxm(%Matrix{data: %{ptr: a_ptr}}, %Matrix{data: %{ptr: b_ptr}}, semiring, _opts) do + with {:ok, sr} <- resolve_semiring(semiring) do + semiring_code = semiring_to_code(sr) + + case GraphBLAS.Native.matrix_mxm(a_ptr, b_ptr, semiring_code) do + ptr when is_integer(ptr) -> + nrows = GraphBLAS.Native.matrix_nrows(ptr) + ncols = GraphBLAS.Native.matrix_ncols(ptr) + {:ok, %Matrix{shape: {nrows, ncols}, type: sr.type, data: %{ptr: ptr}}} + + {:error, reason} -> + Error.error({:backend_error, __MODULE__, reason}) + end + end +end +``` + +The key step: `resolve_semiring(:plus_times)` returns `{:ok, %Semiring{...}}`, then `semiring_to_code/1` maps it to integer code `1`. The NIF receives `1`, not an opaque handle. + +### Step 3: NIF boundary — Zig code + +```zig +// In the Zig NIF (simplified) +pub fn matrix_mxm(a_ptr: usize, b_ptr: usize, semiring_code: u8) !usize { + const a = @as(*grb.GrB_Matrix, @ptrFromInt(a_ptr)); + const b = @as(*grb.GrB_Matrix, @ptrFromInt(b_ptr)); + const semiring = semiring_from_code(semiring_code); + + var result: grb.GrB_Matrix = undefined; + const info = grb.GrB_mxm( + &result, + null, // mask (none) + null, // accum (none) + semiring, + a.*, + b.*, + null, // descriptor (none) + ); + + if (info != grb.GrB_SUCCESS) { + return translate_info(info); + } + return @intFromPtr(result); +} +``` + +This runs on a dirty CPU scheduler — it does not block the BEAM. + +### Step 4: Back to Elixir + +The NIF returns a `usize` integer (the pointer address). The Elixir code wraps it in `%Matrix{data: %{ptr: ptr}}` and returns `{:ok, matrix}`. + +No data was copied. The matrix lives in SuiteSparse's memory. Elixir holds only an integer reference to it. + +## Pointer lifecycle: creation to destruction + +### Creating a matrix + +```elixir +{:ok, m} = SuiteSparse.matrix_from_coo(3, 3, entries, :int64, []) +``` + +What happens: + +1. `Backend.SuiteSparse.matrix_from_coo/5` validates dimensions and type +2. Calls `Native.matrix_new(3, 3, 8)` — Zig creates `GrB_Matrix`, returns pointer as `usize` +3. Calls `Native.matrix_build_int64(ptr, rows, cols, vals, length)` — Zig calls `GrB_Matrix_build_INT64` +4. Returns `{:ok, %Matrix{shape: {3, 3}, type: :int64, data: %{ptr: 46128889856}}}` + +### Using a matrix + +```elixir +{:ok, nvals} = SuiteSparse.matrix_nvals(m) # Fast, not dirty_cpu +{:ok, coo} = SuiteSparse.matrix_to_coo(m) # Requires data extraction, dirty_cpu +``` + +`nvals/1` calls `GrB_Matrix_nvals` — O(1), no dirty scheduler needed. + +`to_coo/1` calls `GrB_Matrix_extractTuples_INT64` — copies all data out of SuiteSparse into Elixir lists. + +### Destroying a matrix + +```elixir +SuiteSparse.matrix_free(m) +``` + +Explicit. Required. The BEAM garbage collector does NOT free the underlying C memory because the pointer is stored as an integer, not as a managed resource. + +**If you forget to free a matrix, it leaks C memory.** This is documented and intentional — the alternative (Zigler resources with GC) was attempted and could not be made to work from `~Z` sigil code. + +## Error handling across the boundary + +SuiteSparse returns `GrB_Info` error codes. The Zig layer checks these and returns Zig error unions. Zigler converts these to Elixir values. + +For functions that return data (`!usize`, `!u64`, `!struct{}`), success returns the value directly and failure returns `{:error, atom}`: + +```elixir +# Success path: +case GraphBLAS.Native.matrix_new(3, 3, 8) do + ptr when is_integer(ptr) -> # success, ptr is the usize pointer + {:error, reason} -> # failure from SuiteSparse +end + +# For void-returning functions: +case GraphBLAS.Native.matrix_free(ptr) do + :ok -> # success + {:error, reason} -> # failure +end +``` + +For functions that can raise (like `grb_init` on double-init), use `try/rescue`: + +```elixir +try do + GraphBLAS.Native.grb_init() +rescue + _ -> :ok # Already initialized +end +``` + +## Type dispatch: why we need type-specific functions + +SuiteSparse is a C library. C does not have generics. Instead, SuiteSparse provides type-specific functions: + +```c +GrB_Matrix_build_INT64(C, I, J, X, nvals, dup); // X is int64_t* +GrB_Matrix_build_FP64(C, I, J, X, nvals, dup); // X is double* +GrB_Matrix_build_BOOL(C, I, J, X, nvals, dup); // X is bool* +``` + +In Zigler, we need separate NIF functions for each type: + +```elixir +# In GraphBLAS.Native +matrix_build_int64: [:dirty_cpu], +matrix_build_fp64: [:dirty_cpu], +matrix_build_bool: [:dirty_cpu], +``` + +The Elixir `Backend.SuiteSparse` module dispatches based on the type atom: + +```elixir +build_result = + case type do + :int64 -> GraphBLAS.Native.matrix_build_int64(ptr, rows, cols, vals, length(entries)) + :fp64 -> GraphBLAS.Native.matrix_build_fp64(ptr, rows, cols, vals, length(entries)) + :bool -> GraphBLAS.Native.matrix_build_bool(ptr, rows, cols, vals, length(entries)) + end +``` + +## Semiring resolution: atoms to integer codes + +SuiteSparse has pre-defined global objects for built-in semirings and monoids. Instead of looking them up by name at runtime, we pass integer codes: + +```elixir +# In Backend.SuiteSparse +@semiring_codes %{ + plus_times: 1, + plus_times_fp64: 2, + plus_min: 3, + # ... etc +} + +defp semiring_to_code(%Semiring{name: name}), do: Map.fetch!(@semiring_codes, name) +``` + +In the Zig NIF: + +```zig +fn semiring_from_code(code: u8) GrB_Semiring { + return switch (code) { + 1 => GrB_PLUS_TIMES_SEMIRING_INT64, + 2 => GrB_PLUS_TIMES_SEMIRING_FP64, + 3 => GrB_PLUS_MIN_SEMIRING_INT64, + // ... etc + }; +} +``` + +This is a lookup, not a creation. SuiteSparse owns the global semiring objects for the entire process lifetime. We never free them. + +**Important naming note**: SuiteSparse uses inconsistent prefixes. The boolean OR monoid is `GxB_LOR_BOOL`, not `GrB_LOR_BOOL`. All symbol names were verified with `nm -g /opt/homebrew/lib/libgraphblas.dylib`. + +## Parity testing: the correctness guarantee + +The parity tests create identical inputs with both backends, perform the same operation, and compare results: + +```elixir +defmodule GraphBLAS.Backend.ParityTest do + use ExUnit.Case, async: false + + alias GraphBLAS.Backend.Elixir, as: RefBackend + alias GraphBLAS.Backend.SuiteSparse + + test "int64: both backends produce same mxm result" do + entries_a = [{0, 1, 1}, {1, 2, 1}] + entries_b = [{1, 0, 2}, {2, 1, 3}] + + {:ok, ref_a} = RefBackend.matrix_from_coo(2, 3, entries_a, :int64, []) + {:ok, ref_b} = RefBackend.matrix_from_coo(3, 2, entries_b, :int64, []) + {:ok, ss_a} = SuiteSparse.matrix_from_coo(2, 3, entries_a, :int64, []) + {:ok, ss_b} = SuiteSparse.matrix_from_coo(3, 2, entries_b, :int64, []) + + {:ok, ref_c} = RefBackend.matrix_mxm(ref_a, ref_b, :plus_times, []) + {:ok, ss_c} = SuiteSparse.matrix_mxm(ss_a, ss_b, :plus_times, []) + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref_c) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss_c) + + assert sort_coo(ref_coo) == sort_coo(ss_coo) + + # Must free SuiteSparse objects explicitly + SuiteSparse.matrix_free(ss_a) + SuiteSparse.matrix_free(ss_b) + SuiteSparse.matrix_free(ss_c) + end +end +``` + +If the two backends disagree, the Elixir backend is right — by definition, because it is the semantic oracle. + +## What this walkthrough covered + +1. **The boundary**: Elixir → atom resolution → integer code NIF call → `GrB_*` function → usize pointer → wrap in struct +2. **Pointer lifecycle**: Created by NIF, stored as usize in Elixir struct, freed explicitly with `matrix_free`/`vector_free` +3. **Type dispatch**: Separate NIF functions per scalar type because SuiteSparse is type-specific +4. **Semiring resolution**: Atom names map to integer codes, codes map to SuiteSparse global constants in Zig +5. **Error handling**: `GrB_Info` codes → Zig error unions → Elixir exceptions or `{:error, reason}` tuples +6. **Parity testing**: Same inputs, both backends, identical expected results, explicit memory cleanup \ No newline at end of file diff --git a/guides/parity_testing_guide.md b/guides/parity_testing_guide.md new file mode 100644 index 0000000..8a64022 --- /dev/null +++ b/guides/parity_testing_guide.md @@ -0,0 +1,241 @@ +# Parity Testing Guide: How to Verify Two Backends Agree + +**Status: IMPLEMENTED** + +## Why parity testing exists + +This library has two backends: a pure Elixir reference backend (correct by inspection) and a SuiteSparse native backend (fast by design). They implement the same mathematical operations using completely different algorithms and data structures. + +If they disagree, the Elixir backend is right — by definition, because it is simple enough to verify by reading the code. The SuiteSparse backend is valuable only because it produces the same results faster. + +Parity testing is the proof that both backends agree. + +## The parity test pattern + +Every parity test follows the same five-step pattern: + +```elixir +test "mxm parity: plus_times with int64" do + # 1. Create identical inputs with both backends + {:ok, ref_a} = RefBackend.matrix_from_coo(3, 3, entries_a, :int64, []) + {:ok, ss_a} = SuiteSparse.matrix_from_coo(3, 3, entries_a, :int64, []) + + # 2. Perform the same operation + {:ok, ref_c} = RefBackend.matrix_mxm(ref_a, ref_a, :plus_times, []) + {:ok, ss_c} = SuiteSparse.matrix_mxm(ss_a, ss_a, :plus_times, []) + + # 3. Extract results + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref_c) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss_c) + + # 4. Compare results + assert sort_coo(ref_coo) == sort_coo(ss_coo) + + # 5. Free SuiteSparse objects + SuiteSparse.matrix_free(ss_a) + SuiteSparse.matrix_free(ss_c) +end +``` + +Steps 1-3 are the same regardless of the operation. Step 4 differs for `:fp64` (use `assert_in_delta`). Step 5 is always required for SuiteSparse objects. + +## Parameterized test generation + +Rather than writing 150+ individual tests by hand, we generate them from data structures: + +```elixir +@semiring_mxm_cases [ + {:plus_times, :int64, [{0, 1, 2}, {1, 0, 3}], [{0, 0, 10}, {0, 1, 4}, {1, 0, 3}]}, + {:plus_times, :fp64, [{0, 1, 1.5}, {1, 0, 2.5}], [{0, 0, 3.75}]}, + {:lor_land, :bool, [{0, 1, true}, {1, 0, true}], [{0, 0, true}, {0, 1, true}]}, + # ... etc +] + +for {semiring, type, input, expected} <- @semiring_mxm_cases do + @tag semiring: semiring, type: type + test "mxm with #{semiring} / #{type}" do + # Generate test using input and expected + end +end +``` + +This ensures every combination is tested. Adding a new semiring or type requires only updating the data structure. + +## Comparison rules + +### Exact equality (`:int64`, `:bool`) + +For integer and boolean types, we require exact equality: + +```elixir +assert sort_coo(ref_coo) == sort_coo(ss_coo) +``` + +COO entries are sorted by `(row, col)` before comparison because the two backends may produce entries in different orders. + +### Approximate equality (`:fp64`) + +For floating-point types, we use `assert_in_delta`: + +```elixir +assert_coo_approx_equal(ref_coo, ss_coo, 0.001) + +defp assert_coo_approx_equal(ref, ss, delta) do + ref_sorted = sort_coo(ref) + ss_sorted = sort_coo(ss) + assert length(ref_sorted) == length(ss_sorted) + Enum.zip_with(ref_sorted, ss_sorted, fn {r1, c1, v1}, {r2, c2, v2} -> + assert r1 == r2 + assert c1 == c2 + assert_in_delta v1, v2, delta + end) +end +``` + +The tolerance of 0.001 is generous. For chain multiplications (mxm), a larger tolerance may be needed. + +### Sorting + +COO format does not guarantee entry order. The Elixir backend stores entries in insertion order with deduplication. SuiteSparse stores entries in column-major order. We sort by `(row, col)` before comparing: + +```elixir +defp sort_coo(entries) do + Enum.sort_by(entries, fn {r, c, _v} -> {r, c} end) +end +``` + +## Memory management in tests + +The SuiteSparse backend requires explicit memory management. Every SuiteSparse object created in a test must be freed: + +```elixir +# At the end of every parity test: +SuiteSparse.matrix_free(ss_a) +SuiteSparse.matrix_free(ss_result) +# For vectors: +SuiteSparse.vector_free(ss_v) +``` + +The Elixir backend's objects are BEAM data structures and are garbage collected normally. No freeing is needed. + +## Test isolation + +All parity tests use `async: false` because SuiteSparse:GraphBLAS is not thread-safe. Running SuiteSparse operations concurrently from multiple processes could cause data corruption. + +```elixir +defmodule GraphBLAS.Backend.ParityTest do + use ExUnit.Case, async: false # Required for SuiteSparse +``` + +## The test matrix + +### Semiring operations tested by operation + +| Operation | Semirings | Types | Tests | +|-----------|-----------|-------|-------| +| `mxm` | All 10 | Per semiring | ~20 | +| `mxv` | All 10 | Per semiring | ~20 | +| `vxm` | All 10 | Per semiring | ~20 | +| `ewise_add` | All 11 monoids | Per monoid | ~25 | +| `ewise_mult` | All 11 monoids | Per monoid | ~25 | +| `reduce (matrix)` | All 11 monoids | Per monoid | ~25 | +| `reduce (vector)` | All 11 monoids | Per monoid | ~25 | + +### Edge cases tested + +| Category | Tests | +|----------|-------| +| Empty matrix/vector | ~10 | +| Single-element | ~5 | +| Identity matrix | ~3 | +| Zero matrix | ~5 | +| Duplicate COO entries | ~8 | +| Error paths | ~5 | +| Semantic correctness | ~15 | + +### Total: ~175-200 new tests + +## Debugging parity failures + +When a parity test fails, the two backends disagree. Here is the debugging process: + +1. **Determine which backend is wrong**: The Elixir backend is the oracle. If SuiteSparse disagrees, SuiteSparse is wrong. But verify this — check the expected result manually. + +2. **Check the semiring/monoid mapping**: The most common bug is an incorrect mapping between Elixir atoms and SuiteSparse constants. Verify the integer code in `@semiring_codes` and `@monoid_codes` matches the Zig NIF's `semiring_from_code` and `monoid_from_code` functions. + +3. **Check the type mapping**: Ensure the `type_to_code` mapping in the Elixir backend matches the Zig NIF's `type_code_to_grb_type` function. + +4. **Check duplicate handling**: Both backends must combine duplicate COO entries using the same monoid operator. If they differ, check which monoid is being used for `matrix_build`. + +5. **Check floating-point precision**: For `:fp64` tests, verify the tolerance is appropriate. If the difference is tiny (e.g., 1e-15), it may be a floating-point rounding difference, not a bug. + +6. **Check empty handling**: Reducing an empty collection should return the monoid's identity element. Verify both backends agree on what that identity is. + +## Property-based testing with StreamData + +In addition to deterministic parity tests, Phase 4 introduces property-based testing using StreamData. Property tests generate random sparse matrices and vectors, then verify that algebraic properties hold for all generated inputs. + +### How to write a property test + +```elixir +defmodule GraphBLAS.PropertyTest do + use ExUnit.Case, async: false + use ExUnitProperties + + alias GraphBLAS.Backend.Elixir, as: RefBackend + alias GraphBLAS.Backend.SuiteSparse + + property "mxm parity holds for random int64 matrices" do + check all entries <- sparse_int64_matrix(5, 5, max_density: 0.3) do + {:ok, ref_a} = RefBackend.matrix_from_coo(5, 5, entries, :int64, []) + {:ok, ss_a} = SuiteSparse.matrix_from_coo(5, 5, entries, :int64, []) + + {:ok, ref_b} = RefBackend.matrix_from_coo(5, 5, entries, :int64, []) + {:ok, ss_b} = SuiteSparse.matrix_from_coo(5, 5, entries, :int64, []) + + {:ok, ref_c} = RefBackend.matrix_mxm(ref_a, ref_b, :plus_times, []) + {:ok, ss_c} = SuiteSparse.matrix_mxm(ss_a, ss_b, :plus_times, []) + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref_c) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss_c) + + assert sort_coo(ref_coo) == sort_coo(ss_coo) + + SuiteSparse.matrix_free(ss_a) + SuiteSparse.matrix_free(ss_b) + SuiteSparse.matrix_free(ss_c) + end + end +end +``` + +### Generators + +StreamData generators produce random values. For sparse matrix testing, we need generators that produce valid COO entries: + +```elixir +# Generator for sparse int64 matrix entries +defp sparse_int64_matrix(rows, cols, opts) do + density = Keyword.get(opts, :max_density, 0.5) + StreamData.list_of( + StreamData.tuple({ + StreamData.integer(0..(rows - 1)), + StreamData.integer(0..(cols - 1)), + StreamData.integer(-100..100) + }), + min: 0, + max: trunc(rows * cols * density) + ) +end +``` + +### Shrinking + +When a property test fails, StreamData automatically shrinks the input to the minimal counterexample. For example, if a 20-entry matrix causes a parity failure, StreamData will reduce it to the smallest matrix that still fails. This is invaluable for debugging. + +### What properties to test + +1. **Parity**: For any valid input, both backends produce identical results +2. **Identity**: `reduce(empty, M)` = `M.identity` for any monoid M +3. **Associativity**: `(A mxm B) mxm C` = `A mxm (B mxm C)` for matrix multiplication +4. **Distributivity**: `A mxm (B ewise_add C)` = `(A mxm B) ewise_add (A mxm C)` for relevant semirings \ No newline at end of file diff --git a/guides/reference_backend_walkthrough.md b/guides/reference_backend_walkthrough.md new file mode 100644 index 0000000..16e891e --- /dev/null +++ b/guides/reference_backend_walkthrough.md @@ -0,0 +1,265 @@ +# Reference Backend Walkthrough: Understanding GraphBLAS Through Pure Elixir + +**Status: IMPLEMENTED** + +## The problem we are solving + +You have a sparse matrix. Most of its entries are zero. You want to multiply it by another sparse matrix, or add it to another sparse matrix, or reduce it to a vector. You need the result to be correct, and you need to understand what the operation actually computed. + +GraphBLAS answers this by defining operations in terms of **semirings**. But before you can appreciate semirings, you need to see what the operations actually do with real numbers in real matrices. + +The reference backend is the simplest possible implementation of each operation. It is designed to be read, understood, and verified by hand. If you can read the code and see that `mxm` does what you expect, then you understand GraphBLAS matrix multiplication. + +## Your first sparse matrix + +```elixir +# A 4-node directed cycle: 0 -> 1 -> 2 -> 3 -> 0 +{:ok, adj} = GraphBLAS.Matrix.from_coo(4, 4, [ + {0, 1, 1}, + {1, 2, 1}, + {2, 3, 1}, + {3, 0, 1} +], :int64) +``` + +This creates a 4x4 matrix where position (0,1)=1, position (1,2)=1, position (2,3)=1, position (3,0)=1, and all other entries are implicitly 0. + +The `from_coo` function takes: +1. Number of rows (4) +2. Number of columns (4) +3. A list of `{row, col, value}` triples (COO format) +4. The scalar type (`:int64` for integers) + +### What the matrix looks like inside + +The Elixir backend stores it as: + +```elixir +%{ + entries: %{{0, 1} => 1, {1, 2} => 1, {2, 3} => 1, {3, 0} => 1}, + nrows: 4, + ncols: 4, + type: :int64 +} +``` + +This is a flat map with tuple keys. You can inspect it directly: + +```elixir +{:ok, 4} = GraphBLAS.Matrix.nvals(adj) # 4 stored entries +{:ok, {4, 4}} = GraphBLAS.Matrix.shape(adj) # 4 rows, 4 columns +``` + +### Inspecting as a dense matrix + +For debugging, you can see the whole matrix with zeros filled in: + +```elixir +GraphBLAS.Matrix.to_dense(adj) +# => [ +# [0, 1, 0, 0], +# [0, 0, 1, 0], +# [0, 0, 0, 1], +# [1, 0, 0, 0] +# ] +``` + +This is the adjacency matrix of a directed cycle graph. Row i, column j is 1 if there is an edge from i to j. + +## Multiplication with different semirings + +### Standard multiplication: plus_times + +```elixir +{:ok, reach2} = GraphBLAS.Matrix.mxm(adj, adj, :plus_times) +GraphBLAS.Matrix.to_dense(reach2) +# => [ +# [0, 0, 1, 0], # node 0 reaches node 2 in 2 hops +# [0, 0, 0, 1], # node 1 reaches node 3 in 2 hops +# [1, 0, 0, 0], # node 2 reaches node 0 in 2 hops +# [0, 1, 0, 0] # node 3 reaches node 1 in 2 hops +# ] +``` + +What happened? The `plus_times` semiring computes: + +``` +C[i,j] = sum over k: A[i,k] * B[k,j] +``` + +For each pair (i, j), we find all k where A has an entry at (i,k) and B has an entry at (k,j), multiply the values, and sum the products. With an adjacency matrix (values are all 1), multiplication counts the number of 2-hop paths from i to j. + +### Boolean reachability: lor_land + +```elixir +{:ok, bool_reach2} = GraphBLAS.Matrix.mxm(adj, adj, :lor_land) +GraphBLAS.Matrix.to_dense(bool_reach2) +# => [ +# [0, 0, 1, 0], +# [0, 0, 0, 1], +# [1, 0, 0, 0], +# [0, 1, 0, 0] +# ] +``` + +The `lor_land` semiring (logical OR, logical AND) asks: "Is there ANY path from i to j in exactly 2 hops?" The answer is yes or no (1 or 0). With a boolean adjacency matrix, this gives you 2-hop reachability. + +### Shortest path: min_plus + +For weighted graphs, the `min_plus` semiring finds the shortest path: + +```elixir +# Weighted cycle: edges have different weights +{:ok, weighted} = GraphBLAS.Matrix.from_coo(4, 4, [ + {0, 1, 2}, # edge 0->1 with weight 2 + {1, 2, 3}, # edge 1->2 with weight 3 + {2, 3, 1}, # edge 2->3 with weight 1 + {3, 0, 4} # edge 3->0 with weight 4 +], :int64) + +# Two-hop shortest paths +{:ok, sp2} = GraphBLAS.Matrix.mxm(weighted, weighted, :min_plus) +``` + +The `min_plus` semiring computes: + +``` +C[i,j] = min over k: A[i,k] + B[k,j] +``` + +Instead of adding products, it takes the minimum sum. This is the foundation of shortest path algorithms. + +**Same `mxm` function. Different semiring. Different algorithm.** This is the core insight of GraphBLAS. + +## How mxm works in the reference backend + +Let's trace through `plus_times` multiplication of our adjacency matrix with itself: + +``` +A (adjacency): B (same as A): +(0,1)=1 (0,1)=1 +(1,2)=1 (1,2)=1 +(2,3)=1 (2,3)=1 +(3,0)=1 (3,0)=1 +``` + +Step 1: For each pair `(i, k)` in A, find if `(k, j)` exists in B: + +``` +A has (0,1)=1. Does B have (1, j)? Yes: (1,2)=1. + Product: 1 * 1 = 1. This contributes to C[0,2]. + +A has (1,2)=1. Does B have (2, j)? Yes: (2,3)=1. + Product: 1 * 1 = 1. This contributes to C[1,3]. + +A has (2,3)=1. Does B have (3, j)? Yes: (3,0)=1. + Product: 1 * 1 = 1. This contributes to C[2,0]. + +A has (3,0)=1. Does B have (0, j)? Yes: (0,1)=1. + Product: 1 * 1 = 1. This contributes to C[3,1]. +``` + +Step 2: Combine products at the same position using addition: + +``` +C[0,2] = 1 (only one product, nothing to add) +C[1,3] = 1 +C[2,0] = 1 +C[3,1] = 1 +``` + +Result: `C = {(0,2): 1, (1,3): 1, (2,0): 1, (3,1): 1}` + +This is exactly what you get from `GraphBLAS.Matrix.mxm(adj, adj, :plus_times)`. + +## Element-wise operations + +### ewise_add: set union + +```elixir +{:ok, a} = GraphBLAS.Matrix.from_coo(2, 2, [{0, 0, 1}, {1, 1, 2}], :int64) +{:ok, b} = GraphBLAS.Matrix.from_coo(2, 2, [{0, 0, 3}, {0, 1, 4}], :int64) + +{:ok, c} = GraphBLAS.Matrix.ewise_add(a, b, :plus) +GraphBLAS.Matrix.to_dense(c) +# => [[4, 4], # (0,0): 1+3=4, (0,1): 0+4=4 +# [0, 2]] # (1,0): neither, (1,1): 2+0=2 +``` + +`ewise_add` takes the **union** of structural positions. Where both matrices have an entry, the values are combined using the monoid (here: addition). Where only one has an entry, that value is kept. + +### ewise_mult: set intersection + +```elixir +{:ok, a} = GraphBLAS.Matrix.from_coo(2, 2, [{0, 0, 2}, {0, 1, 3}], :int64) +{:ok, b} = GraphBLAS.Matrix.from_coo(2, 2, [{0, 0, 4}, {1, 0, 5}], :int64) + +{:ok, c} = GraphBLAS.Matrix.ewise_mult(a, b, :times) +GraphBLAS.Matrix.to_dense(c) +# => [[8, 0], # (0,0): 2*4=8, (0,1): not in both => 0 +# [0, 0]] # (1,0): not in both => 0, (1,1): not in both => 0 +``` + +`ewise_mult` takes the **intersection** of structural positions. Only positions present in both matrices appear in the result. + +## Reduction + +```elixir +{:ok, m} = GraphBLAS.Matrix.from_coo(3, 3, [{0, 0, 1}, {0, 1, 2}, {2, 0, 5}], :int64) +{:ok, row_sums} = GraphBLAS.Matrix.reduce(m, :plus) +GraphBLAS.Vector.to_list(row_sums) +# => [3, 0, 5] # row 0: 1+2=3, row 1: empty=0, row 2: 5 +``` + +`reduce` collapses each row (or column, with a descriptor) to a single value using the monoid. + +## Transpose + +```elixir +{:ok, m} = GraphBLAS.Matrix.from_coo(2, 3, [{0, 2, 7}, {1, 0, 3}], :int64) +{:ok, t} = GraphBLAS.Matrix.transpose(m) +GraphBLAS.Matrix.to_dense(t) +# => [[0, 3], # was column 0: (1,0)=3 -> (0,1)=3 +# [0, 0], +# [7, 0]] # was column 2: (0,2)=7 -> (2,0)=7 +``` + +## How the backend resolution works + +Every API function resolves its backend before dispatching: + +```elixir +def mxm(%Matrix{} = a, %Matrix{} = b, semiring \\ :plus_times, opts \\ []) do + backend = GraphBLAS.Config.resolve_backend(opts) + backend.matrix_mxm(a, b, semiring, opts) +end +``` + +This means: +1. By default, operations use `GraphBLAS.Backend.Elixir` (from config) +2. You can override per-call: `Matrix.mxm(a, b, :plus_times, backend: MyBackend)` +3. Adding a new backend (SuiteSparse, port-based) requires only implementing the behaviour + +## Why this matters for the future + +When the SuiteSparse backend arrives in Phase 3, we will run the exact same tests against it: + +```elixir +# Phase 4 parity test (conceptual) +ref_result = Matrix.mxm(a, b, :plus_times, backend: GraphBLAS.Backend.Elixir) +nat_result = Matrix.mxm(a, b, :plus_times, backend: GraphBLAS.Backend.SuiteSparse) +assert results_equal?(ref_result, nat_result) +``` + +If the native backend ever disagrees with the reference backend, the reference backend is right by definition. This is why the reference backend must be simple, clear, and obviously correct. + +## Next steps + +After reading this walkthrough, you should be able to: + +1. Create a sparse matrix from COO triples +2. Multiply matrices using different semirings +3. Understand what the result means (reachability, shortest path, etc.) +4. Inspect matrix contents using `to_dense` +5. Explain why the reference backend uses flat maps instead of nested structures +6. Follow the code in `GraphBLAS.Backend.Elixir` and verify each operation step by step \ No newline at end of file diff --git a/lib/ex_graphblas.ex b/lib/ex_graphblas.ex index 242c7f4..cffeffb 100644 --- a/lib/ex_graphblas.ex +++ b/lib/ex_graphblas.ex @@ -1,18 +1,82 @@ defmodule GraphBLAS do @moduledoc """ - Documentation for `GraphBLAS`. + GraphBLAS: sparse linear algebra and graph computation for Elixir. + + GraphBLAS is a library for sparse linear algebra and graph computation + inspired by the GraphBLAS mathematical model. It provides sparse matrices + and vectors, semirings and monoids for expressing graph algorithms as + linear algebra, and a backend-neutral API that can delegate computation + to different execution engines. + + ## Architecture overview + + The library is organized into three layers: + + 1. **Public API** -- modules like `GraphBLAS.Matrix`, + `GraphBLAS.Vector`, `GraphBLAS.Semiring`, and + `GraphBLAS.Monoid` provide the user-facing interface. + + 2. **Backend behaviour** -- `GraphBLAS.Backend` defines the contract + that every computation backend must implement. Backends are swappable + at runtime via configuration. + + 3. **Backend implementations** -- concrete modules that implement + `GraphBLAS.Backend`. The `Reference` backend ships with Phase 1 + as a correct-but-not-performant pure Elixir implementation. The + `SuiteSparse` backend is reserved for Phase 2. + + ## Quick start + + # Create a sparse 3x3 matrix from COO triples + {:ok, m} = GraphBLAS.Matrix.from_coo(3, 3, [ + {0, 1, 1.0}, + {1, 2, 2.0}, + {2, 0, 3.0} + ], :fp64) + + # Multiply it by itself using the plus-times semiring + {:ok, result} = GraphBLAS.Matrix.mxm(m, m, :plus_times) + + ## Backend selection + + The default backend is `GraphBLAS.Backend.Elixir`. To select a + different backend: + + # Application config + config :ex_graphblas, default_backend: GraphBLAS.Backend.Elixir + + # Per-call override + GraphBLAS.Matrix.from_coo(3, 3, entries, :int64, backend: MyBackend) + + ## Phase 1 scope + + Phase 1 establishes the architecture and a reference backend. It does + not yet include native execution, Nx integration, or graph algorithms. + See the Phase 1 educational materials for details. """ + alias GraphBLAS.Config + @doc """ - Hello world. + Returns the currently configured default backend module. - ## Examples + This is a convenience function equivalent to `GraphBLAS.Config.default_backend/0`. + """ + @spec default_backend() :: module() + defdelegate default_backend(), to: Config - iex> GraphBLAS.hello() - :world + @doc """ + Returns information about the library version and configuration. + Useful for debugging and logging. """ - def hello do - :world + @spec info() :: map() + def info do + %{ + version: "0.1.0", + default_backend: Config.default_backend(), + phase: 1, + description: "Architecture, API shape, and scaffolding" + } end end diff --git a/lib/ex_graphblas/algorithm.ex b/lib/ex_graphblas/algorithm.ex new file mode 100644 index 0000000..1d99475 --- /dev/null +++ b/lib/ex_graphblas/algorithm.ex @@ -0,0 +1,562 @@ +defmodule GraphBLAS.Algorithm do + @moduledoc """ + Graph algorithms built on the GraphBLAS sparse linear algebra primitives. + + All algorithms are backend-agnostic: they call the public `Matrix`/`Vector` + API and work on both the Elixir reference backend and the SuiteSparse native + backend. No new backend callbacks are needed. + + ## Adjacency matrix convention + + All algorithms assume `A[i][j]` means edge FROM vertex i TO vertex j. + + - Row i contains the OUT-neighbors of vertex i + - Column j contains the IN-neighbors of vertex j + - For undirected graphs, A is symmetric (A = A^T) + + ## Families + + - **Phase 6A** -- Classic graph analytics (BFS, SSSP, triangle count, etc.) + - **Phase 6C** -- Query foundations (fixed-point iteration primitive) + """ + + alias GraphBLAS.{Config, Descriptor, Error, Helpers, Mask, Matrix, Scalar, Vector} + + import Helpers, only: [ok: 1, maybe_free: 2] + + @default_max_iter 100 + @default_tol 1.0e-6 + @sssp_inf 1.0e18 + + # --------------------------------------------------------------------------- + # Phase 6A: Classic Graph Analytics + # --------------------------------------------------------------------------- + + @doc """ + BFS reachability from a source vertex. + + Returns a bool vector where `visited[i] = true` iff vertex i is reachable + from the source vertex. + + Uses the `:lor_land` semiring with a complement mask to prevent revisiting. + Converges when the frontier is empty. + """ + @spec bfs_reach(Matrix.t(), non_neg_integer(), keyword()) :: + {:ok, Vector.t()} | {:error, Error.t()} + def bfs_reach(%Matrix{shape: {n, _}} = adj, source, opts \\ []) do + with :ok <- validate_source(source, n) do + backend = Config.resolve_backend(opts) + + {:ok, frontier} = Vector.from_entries(n, [{source, true}], :bool, backend: backend) + {:ok, visited} = Vector.dup(frontier, backend: backend) + bfs_reach_loop(adj, frontier, visited, opts) + end + end + + defp bfs_reach_loop(adj, frontier, visited, opts) do + max_iter = Keyword.get(opts, :max_iter, @default_max_iter) + backend = Config.resolve_backend(opts) + + bfs_reach_loop(adj, frontier, visited, 0, max_iter, backend) + end + + defp bfs_reach_loop(_adj, _frontier, visited, iter, max_iter, _backend) when iter >= max_iter do + {:ok, visited} + end + + defp bfs_reach_loop(adj, frontier, visited, iter, max_iter, backend) do + {:ok, nvals} = Vector.nvals(frontier) + + if nvals == 0 do + {:ok, visited} + else + mask = Mask.complement(visited) + + with {:ok, new_frontier} <- + ok(Vector.vxm(frontier, adj, :lor_land, mask: mask, backend: backend)), + {:ok, new_visited} <- + ok(Vector.ewise_add(visited, new_frontier, :lor, backend: backend)) do + maybe_free(frontier, backend) + maybe_free(visited, backend) + bfs_reach_loop(adj, new_frontier, new_visited, iter + 1, max_iter, backend) + end + end + end + + @doc """ + BFS with hop-distance tracking from a source vertex. + + Returns an int64 vector where `levels[i]` is the hop distance from the + source to vertex i. Vertices not reachable from the source have no + stored entry (structural zero = 0). + + Uses `:lor_land` for frontier expansion, then stamps level numbers in + Elixir and merges via `ewise_add` with `:min` monoid. + """ + @spec bfs_levels(Matrix.t(), non_neg_integer(), keyword()) :: + {:ok, Vector.t()} | {:error, Error.t()} + def bfs_levels(%Matrix{shape: {n, _}} = adj, source, opts \\ []) do + with :ok <- validate_source(source, n) do + backend = Config.resolve_backend(opts) + max_iter = Keyword.get(opts, :max_iter, @default_max_iter) + + with {:ok, frontier} <- Vector.from_entries(n, [{source, true}], :bool, backend: backend), + {:ok, levels} <- Vector.from_entries(n, [{source, 0}], :int64, backend: backend) do + bfs_levels_loop(adj, frontier, levels, 0, max_iter, n, backend) + end + end + end + + defp bfs_levels_loop(_adj, _frontier, levels, level, max_iter, _n, _backend) + when level >= max_iter do + {:ok, levels} + end + + defp bfs_levels_loop(adj, frontier, levels, level, max_iter, n, backend) do + {:ok, nvals} = Vector.nvals(frontier) + + if nvals == 0 do + {:ok, levels} + else + bfs_levels_expand(adj, frontier, levels, level, max_iter, n, backend) + end + end + + defp bfs_levels_expand(adj, frontier, levels, level, max_iter, n, backend) do + levels_mask = Mask.complement(levels) + + with {:ok, new_frontier} <- + ok(Vector.vxm(frontier, adj, :lor_land, mask: levels_mask, backend: backend)), + {:ok, frontier_entries} <- Vector.to_entries(new_frontier), + level_entries <- Enum.map(frontier_entries, fn {idx, _} -> {idx, level + 1} end), + {:ok, level_vec} <- Vector.from_entries(n, level_entries, :int64, backend: backend), + {:ok, new_levels} <- ok(Vector.ewise_add(levels, level_vec, :min, backend: backend)) do + maybe_free(frontier, backend) + maybe_free(levels, backend) + bfs_levels_loop(adj, new_frontier, new_levels, level + 1, max_iter, n, backend) + end + end + + @doc """ + Single-source shortest path using the Bellman-Ford algorithm. + + Returns an fp64 vector where `dist[i]` is the shortest distance from + the source to vertex i. Unreachable vertices have no stored entry. + + The adjacency matrix must be fp64 with edge weights as entries. + Uses the `:min_plus_fp64` semiring. Converges when distances stop changing. + """ + @spec sssp(Matrix.t(), non_neg_integer(), keyword()) :: + {:ok, Vector.t()} | {:error, Error.t()} + def sssp(%Matrix{shape: {n, _}} = adj, source, opts \\ []) do + with :ok <- validate_source(source, n) do + backend = Config.resolve_backend(opts) + max_iter = Keyword.get(opts, :max_iter, @default_max_iter) + + entries = for i <- 0..(n - 1), do: {i, if(i == source, do: 0.0, else: @sssp_inf)} + + with {:ok, dist} <- Vector.from_entries(n, entries, :fp64, backend: backend), + {:ok, final} <- sssp_loop(adj, dist, 0, max_iter, backend) do + sssp_strip_inf(final, n, backend) + end + end + end + + defp sssp_strip_inf(dist, n, backend) do + {:ok, entries} = Vector.to_entries(dist) + reachable = Enum.reject(entries, fn {_, v} -> v >= @sssp_inf end) + Vector.from_entries(n, reachable, :fp64, backend: backend) + end + + defp sssp_loop(_adj, dist, iter, max_iter, _backend) when iter >= max_iter do + {:ok, dist} + end + + defp sssp_loop(adj, dist, iter, max_iter, backend) do + with {:ok, candidates} <- ok(Vector.vxm(dist, adj, :min_plus_fp64, backend: backend)), + {:ok, dist_new} <- ok(Vector.ewise_add(dist, candidates, :min, backend: backend)) do + if converged_exact?(dist, dist_new, backend) do + maybe_free(candidates, backend) + maybe_free(dist_new, backend) + {:ok, dist} + else + maybe_free(candidates, backend) + maybe_free(dist, backend) + sssp_loop(adj, dist_new, iter + 1, max_iter, backend) + end + end + end + + @doc """ + Triangle counting in an undirected graph. + + Returns the number of triangles. The adjacency matrix must be bool + and symmetric (undirected). Uses the Cuenta method: mxm of the lower + triangle with the full adjacency, masked to the lower triangle. + """ + @spec triangle_count(Matrix.t(), keyword()) :: + {:ok, non_neg_integer()} | {:error, Error.t()} + def triangle_count(%Matrix{} = adj, opts \\ []) do + backend = Config.resolve_backend(opts) + {n, _} = adj.shape + + with {:ok, adj_coo} <- Matrix.to_coo(adj) do + lower_int = for {r, c, _v} <- adj_coo, r > c, do: {r, c, 1} + full_int = for {r, c, _v} <- adj_coo, do: {r, c, 1} + + with {:ok, lint} <- Matrix.from_coo(n, n, lower_int, :int64, backend: backend), + {:ok, aint} <- Matrix.from_coo(n, n, full_int, :int64, backend: backend), + {:ok, cmat} <- + ok(Matrix.mxm(lint, aint, :plus_times, mask: Mask.new(lint), backend: backend)), + {:ok, vvec} <- ok(Matrix.reduce(cmat, :plus, backend: backend)), + {:ok, sval} <- ok(Vector.reduce(vvec, :plus, backend: backend)) do + count = Scalar.value(sval) + maybe_free(lint, backend) + maybe_free(aint, backend) + maybe_free(cmat, backend) + maybe_free(vvec, backend) + {:ok, div(count, 2)} + end + end + end + + @doc """ + Connected components via multi-source BFS. + + Returns an int64 vector where `component[i]` is the component ID for + vertex i. Vertices in the same connected component share the same ID. + + Uses `:lor_land` for BFS reachability and `:plus_min` for merging + component IDs. Works with only built-in semirings. + """ + @spec connected_components(Matrix.t(), keyword()) :: + {:ok, Vector.t()} | {:error, Error.t()} + def connected_components(%Matrix{shape: {n, _}} = adj, opts \\ []) do + backend = Config.resolve_backend(opts) + + component_entries = for i <- 0..(n - 1), do: {i, i} + + with {:ok, component} <- Vector.from_entries(n, component_entries, :int64, backend: backend), + {:ok, unvisited} <- + Vector.from_entries(n, for(i <- 0..(n - 1), do: {i, true}), :bool, backend: backend) do + cc_loop(adj, component, unvisited, n, backend) + end + end + + defp cc_loop(adj, component, unvisited, _n, backend) do + {:ok, nvals} = Vector.nvals(unvisited) + + if nvals == 0 do + maybe_free(unvisited, backend) + {:ok, component} + else + cc_expand_component(adj, component, unvisited, backend) + end + end + + defp cc_expand_component(adj, component, unvisited, backend) do + {:ok, unvisited_entries} = Vector.to_entries(unvisited) + {v, _} = hd(unvisited_entries) + + with {:ok, visited} <- bfs_reach(adj, v, backend: backend), + {:ok, comp_val} <- Vector.extract(component, v), + {:ok, visited_entries} <- Vector.to_entries(visited), + {:ok, vec_size} <- Vector.size(component), + stamp_entries <- Enum.map(visited_entries, fn {i, _} -> {i, comp_val} end), + {:ok, stamp_vec} <- + Vector.from_entries(vec_size, stamp_entries, :int64, backend: backend), + {:ok, new_component} <- + ok(Vector.ewise_add(component, stamp_vec, :min, backend: backend)), + visited_complement <- Mask.complement(visited), + {:ok, new_unvisited} <- + ok( + Vector.ewise_mult(unvisited, unvisited, :land, + mask: visited_complement, + backend: backend + ) + ) do + maybe_free(component, backend) + maybe_free(unvisited, backend) + maybe_free(visited, backend) + maybe_free(stamp_vec, backend) + cc_loop(adj, new_component, new_unvisited, nil, backend) + end + end + + @doc """ + In-degree and out-degree calculation. + + Returns a map with `:in_degree` and `:out_degree` vectors (int64). + For bool adjacency, degree values count the number of edges. + """ + @spec degree(Matrix.t(), keyword()) :: + {:ok, %{in_degree: Vector.t(), out_degree: Vector.t()}} | {:error, Error.t()} + def degree(%Matrix{} = adj, opts \\ []) do + backend = Config.resolve_backend(opts) + desc = Descriptor.new(inp0_transpose: :transpose) + + with {:ok, adj_int} <- bool_to_int64(adj, backend), + {:ok, out_deg} <- ok(Matrix.reduce(adj_int, :plus, backend: backend)), + {:ok, in_deg} <- ok(Matrix.reduce(adj_int, :plus, descriptor: desc, backend: backend)) do + maybe_free(adj_int, backend) + {:ok, %{in_degree: in_deg, out_degree: out_deg}} + end + end + + @doc """ + PageRank via power iteration with damping and dangling-node correction. + + Returns an fp64 vector of PageRank scores. The adjacency matrix should + be bool (unweighted). Uses `:plus_times_fp64` semiring. + + ## Options + + - `:damping` -- damping factor (default: 0.85) + - `:max_iter` -- maximum iterations (default: 100) + - `:tol` -- convergence tolerance (default: 1.0e-6) + """ + @spec pagerank(Matrix.t(), keyword()) :: + {:ok, Vector.t()} | {:error, Error.t()} + def pagerank(%Matrix{shape: {n, _}} = adj, opts \\ []) do + backend = Config.resolve_backend(opts) + damping = Keyword.get(opts, :damping, 0.85) + max_iter = Keyword.get(opts, :max_iter, @default_max_iter) + tol = Keyword.get(opts, :tol, @default_tol) + + with {:ok, adj_int} <- bool_to_int64(adj, backend), + {:ok, at} <- ok(Matrix.transpose(adj_int, backend: backend)), + {:ok, out_deg} <- ok(Matrix.reduce(adj_int, :plus, backend: backend)), + {:ok, recip} <- build_reciprocal_degree(out_deg, n, backend), + r_init = 1.0 / n, + r_entries <- for(i <- 0..(n - 1), do: {i, r_init}), + {:ok, r} <- Vector.from_entries(n, r_entries, :fp64, backend: backend) do + maybe_free(adj_int, backend) + + pagerank_loop(at, out_deg, recip, r, %{ + n: n, + damping: damping, + tol: tol, + iter: 0, + max_iter: max_iter, + backend: backend + }) + end + end + + defp pagerank_loop(_at, _out_deg, _recip, r, %{ + iter: iter, + max_iter: max_iter + }) + when iter >= max_iter do + {:ok, r} + end + + defp pagerank_loop(at, out_deg, recip, r, %{ + n: n, + damping: damping, + tol: tol, + iter: iter, + max_iter: max_iter, + backend: backend + }) do + with {:ok, r_scaled} <- ok(Vector.ewise_mult(r, recip, :times_fp64, backend: backend)), + {:ok, r_new} <- ok(Matrix.mxv(at, r_scaled, :plus_times_fp64, backend: backend)), + {:ok, r_new} <- scale_and_shift(r_new, damping, (1 - damping) / n, n, backend), + {:ok, r_new} <- apply_dangling_correction(r_new, r, out_deg, damping, n, backend) do + if converged_tolerance?(r, r_new, tol, backend) do + maybe_free(r_scaled, backend) + maybe_free(r, backend) + {:ok, r_new} + else + maybe_free(r_scaled, backend) + maybe_free(r, backend) + + pagerank_loop(at, out_deg, recip, r_new, %{ + n: n, + damping: damping, + tol: tol, + iter: iter + 1, + max_iter: max_iter, + backend: backend + }) + end + end + end + + # --------------------------------------------------------------------------- + # Phase 6C: Query Foundations + # --------------------------------------------------------------------------- + + @doc """ + Generic fixed-point iteration primitive. + + Applies `step_fn` to `initial` repeatedly until convergence or + `max_iter` is reached. Returns `{:ok, final_value, info}` where + info contains `%{iterations: n, converged: boolean}`. + + ## Options + + - `:max_iter` -- maximum iterations (default: 100) + - `:tol` -- convergence tolerance for fp64 (default: 1.0e-6) + - `:convergence_fn` -- custom convergence check `(old, new -> boolean)` + """ + @spec fixed_point(term(), (term() -> {:ok, term()} | {:error, Error.t()}), keyword()) :: + {:ok, term(), map()} | {:error, Error.t()} + def fixed_point(initial, step_fn, opts \\ []) do + max_iter = Keyword.get(opts, :max_iter, @default_max_iter) + tol = Keyword.get(opts, :tol, @default_tol) + conv_fn = Keyword.get(opts, :convergence_fn) + + fixed_point_loop(initial, step_fn, conv_fn, tol, 0, max_iter) + end + + defp fixed_point_loop(current, _step_fn, _conv_fn, _tol, iter, max_iter) + when iter >= max_iter do + {:ok, current, %{iterations: iter, converged: false}} + end + + defp fixed_point_loop(current, step_fn, conv_fn, tol, iter, max_iter) do + case step_fn.(current) do + {:ok, next} -> + converged = + if conv_fn do + conv_fn.(current, next) + else + default_converged?(current, next, tol) + end + + if converged do + {:ok, next, %{iterations: iter + 1, converged: true}} + else + fixed_point_loop(next, step_fn, conv_fn, tol, iter + 1, max_iter) + end + + {:error, _} = err -> + err + end + end + + # --------------------------------------------------------------------------- + # Private helpers + # --------------------------------------------------------------------------- + + defp validate_source(source, n) when source >= 0 and source < n, do: :ok + defp validate_source(source, _n), do: Error.error({:index_out_of_bounds, source, :source, 0}) + + defp bool_to_int64(%Matrix{type: :int64} = m, _backend), do: {:ok, m} + + defp bool_to_int64(%Matrix{shape: {nrows, ncols}, type: :bool} = m, backend) do + {:ok, coo} = ok(Matrix.to_coo(m)) + int_coo = Enum.map(coo, fn {r, c, true} -> {r, c, 1} end) + Matrix.from_coo(nrows, ncols, int_coo, :int64, backend: backend) + end + + defp bool_to_int64(%Matrix{shape: {nrows, ncols}, type: :fp64} = m, backend) do + {:ok, coo} = ok(Matrix.to_coo(m)) + int_coo = Enum.map(coo, fn {r, c, v} -> {r, c, trunc(v)} end) + Matrix.from_coo(nrows, ncols, int_coo, :int64, backend: backend) + end + + defp converged_exact?(old_vec, new_vec, _backend) do + {:ok, old_entries} = Vector.to_entries(old_vec) + {:ok, new_entries} = Vector.to_entries(new_vec) + Enum.sort(old_entries) == Enum.sort(new_entries) + end + + defp converged_tolerance?(old_vec, new_vec, tol, _backend) do + {:ok, old_entries} = Vector.to_entries(old_vec) + {:ok, new_entries} = Vector.to_entries(new_vec) + + old_map = Map.new(old_entries) + new_map = Map.new(new_entries) + all_keys = MapSet.union(MapSet.new(Map.keys(old_map)), MapSet.new(Map.keys(new_map))) + + Enum.all?(all_keys, fn k -> + abs(Map.get(old_map, k, 0.0) - Map.get(new_map, k, 0.0)) < tol + end) + end + + defp build_reciprocal_degree(out_deg, n, backend) do + {:ok, entries} = Vector.to_entries(out_deg) + degree_map = Map.new(entries) + + recip_entries = + for i <- 0..(n - 1) do + case Map.get(degree_map, i) do + nil -> {i, 0.0} + 0 -> {i, 0.0} + d -> {i, 1.0 / d} + end + end + + Vector.from_entries(n, recip_entries, :fp64, backend: backend) + end + + defp scale_and_shift(vec, alpha, beta, n, backend) do + with {:ok, vec_entries} <- Vector.to_entries(vec), + scaled_entries <- Enum.map(vec_entries, fn {i, v} -> {i, v * alpha + beta} end), + {:ok, result} <- Vector.from_entries(n, scaled_entries, :fp64, backend: backend) do + maybe_free(vec, backend) + {:ok, result} + end + end + + defp apply_dangling_correction(r_new, r, out_deg, damping, n, backend) do + {:ok, deg_entries} = Vector.to_entries(out_deg) + {:ok, r_entries} = Vector.to_entries(r) + degree_map = Map.new(deg_entries) + r_map = Map.new(r_entries) + + dangling_sum = + Enum.reduce(r_map, 0.0, fn {i, val}, acc -> + case Map.get(degree_map, i) do + nil -> acc + val + 0 -> acc + val + _ -> acc + end + end) + + if dangling_sum == 0.0 do + {:ok, r_new} + else + shift = damping * dangling_sum / n + apply_shift(r_new, shift, n, backend) + end + end + + defp apply_shift(r_new, shift, n, backend) do + with {:ok, entries} <- Vector.to_entries(r_new), + shifted <- Enum.map(entries, fn {i, v} -> {i, v + shift} end), + {:ok, result} <- Vector.from_entries(n, shifted, :fp64, backend: backend) do + maybe_free(r_new, backend) + {:ok, result} + end + end + + defp default_converged?(%Matrix{} = old, %Matrix{} = new, _tol) do + {:ok, old_coo} = ok(Matrix.to_coo(old)) + {:ok, new_coo} = ok(Matrix.to_coo(new)) + Enum.sort(old_coo) == Enum.sort(new_coo) + end + + defp default_converged?(%Vector{} = old, %Vector{} = new, tol) when tol > 0 do + {:ok, old_entries} = ok(Vector.to_entries(old)) + {:ok, new_entries} = ok(Vector.to_entries(new)) + old_map = Map.new(old_entries) + new_map = Map.new(new_entries) + + all_keys = MapSet.union(MapSet.new(Map.keys(old_map)), MapSet.new(Map.keys(new_map))) + + Enum.all?(all_keys, fn k -> + abs(Map.get(old_map, k, 0.0) - Map.get(new_map, k, 0.0)) < tol + end) + end + + defp default_converged?(%Vector{} = old, %Vector{} = new, _tol) do + {:ok, old_entries} = ok(Vector.to_entries(old)) + {:ok, new_entries} = ok(Vector.to_entries(new)) + Enum.sort(old_entries) == Enum.sort(new_entries) + end + + defp default_converged?(_old, _new, _tol), do: false +end diff --git a/lib/ex_graphblas/backend.ex b/lib/ex_graphblas/backend.ex new file mode 100644 index 0000000..11b835a --- /dev/null +++ b/lib/ex_graphblas/backend.ex @@ -0,0 +1,399 @@ +defmodule GraphBLAS.Backend do + @moduledoc """ + Behaviour that defines the contract for GraphBLAS computation backends. + + Every backend must implement this behaviour. The public API modules + (`GraphBLAS.Matrix`, `GraphBLAS.Vector`, etc.) dispatch to the + resolved backend rather than performing computation themselves. This + separation ensures that: + + - The public API remains stable and backend-neutral. + - New backends (native, port, reference) can be added without changing + calling code. + - Backend-specific details (NIF resource lifecycles, port protocols) + stay behind the boundary. + + ## Backend lifecycle + + Backends are stateless modules. Container data (matrices, vectors) carries + its backend identity in its struct, specifically in the `:data` field + which is opaque and backend-specific. A reference backend stores Elixir + maps; a native backend stores NIF resource references. + + The calling pattern is: + + GraphBLAS.Matrix.from_coo(3, 3, entries) + # Resolves backend via config, calls: + backend.matrix_from_coo(3, 3, entries, :int64, []) + + Backends do not need to be started or supervised. They are plain modules + that implement the callbacks. State that persists across calls (such as + native initialization) is the backend's own responsibility and should be + handled through the native layer's standard mechanisms (e.g., on-first-call + initialization via `:persistent_term` or a gen_server). + + ## Opaque data convention + + The `:data` field in Matrix and Vector structs is **opaque to calling code**. + Backends can store any term there. The only requirement is that the backend + can interpret its own data. You must not pattern-match on `:data` outside + the backend module. + + ## Phase 1 scope + + This behaviour defines the full callback surface needed for Phase 1 + operations. Not all callbacks need working implementations in every + backend; a backend may return `{:error, {:unsupported_operation, ...}}` + for callbacks it does not yet support. The Reference backend implements + all callbacks listed here. + """ + + alias GraphBLAS.Types + + @typedoc """ + Backend-specific opaque data stored in the `:data` field of containers. + + For the Reference backend, this is a map containing sparse entries + and metadata. For the SuiteSparse backend, this will be a NIF resource + reference. Calling code must not inspect or pattern-match on this value. + """ + @type backend_data :: term() + + ############################################################################# + # Matrix callbacks + ############################################################################# + + @doc """ + Creates an empty matrix with the given dimensions and scalar type. + + Returns `{:ok, matrix}` on success or `{:error, GraphBLAS.Error.t()}` on failure. + """ + @callback matrix_new( + nrows :: non_neg_integer(), + ncols :: non_neg_integer(), + type :: Types.scalar_type(), + opts :: Types.opts() + ) :: + {:ok, GraphBLAS.Matrix.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Creates a sparse matrix from COO (coordinate) triples. + + `entries` is a list of `{row, col, value}` tuples. Duplicate entries + (same row, col) are combined using the provided monoid's operator + (defaulting to addition). + """ + @callback matrix_from_coo( + nrows :: non_neg_integer(), + ncols :: non_neg_integer(), + entries :: [Types.coo_entry()], + type :: Types.scalar_type(), + opts :: Types.opts() + ) :: + {:ok, GraphBLAS.Matrix.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Returns the number of stored (non-default) values in the matrix. + + In a sparse matrix, "stored values" means the explicit entries, not + the implicit zeros. + """ + @callback matrix_nvals(matrix :: GraphBLAS.Matrix.t()) :: + {:ok, non_neg_integer()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Returns `{nrows, ncols}` the dimensions of the matrix. + """ + @callback matrix_shape(matrix :: GraphBLAS.Matrix.t()) :: + {:ok, Types.shape()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Returns the scalar type of the matrix's stored values. + """ + @callback matrix_type(matrix :: GraphBLAS.Matrix.t()) :: + {:ok, Types.scalar_type()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Extracts the stored entries of a matrix as COO triples. + + Returns a sorted list of `{row, col, value}` tuples. + """ + @callback matrix_to_coo(matrix :: GraphBLAS.Matrix.t()) :: + {:ok, [Types.coo_entry()]} | {:error, GraphBLAS.Error.t()} + + @doc """ + Multiplies two matrices using the given semiring. + + `semiring` can be an atom naming a built-in semiring (e.g., `:plus_times`) + or a struct defining a custom semiring. The result matrix `C = A * B` + has shape `{nrows(A), ncols(B)}`. + """ + @callback matrix_mxm( + a :: GraphBLAS.Matrix.t(), + b :: GraphBLAS.Matrix.t(), + semiring :: atom() | GraphBLAS.Semiring.t(), + opts :: Types.opts() + ) :: + {:ok, GraphBLAS.Matrix.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Multiplies a matrix by a vector using the given semiring. + + The result vector `v = A * x` has length `nrows(A)`. + """ + @callback matrix_mxv( + matrix :: GraphBLAS.Matrix.t(), + vector :: GraphBLAS.Vector.t(), + semiring :: atom() | GraphBLAS.Semiring.t(), + opts :: Types.opts() + ) :: + {:ok, GraphBLAS.Vector.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Element-wise addition of two matrices using the given monoid. + + The result has the union of the structural nonzero positions, with + overlapping positions combined using the monoid's binary operator. + """ + @callback matrix_ewise_add( + a :: GraphBLAS.Matrix.t(), + b :: GraphBLAS.Matrix.t(), + monoid :: atom() | GraphBLAS.Monoid.t(), + opts :: Types.opts() + ) :: + {:ok, GraphBLAS.Matrix.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Element-wise multiplication of two matrices using the given monoid. + + The result has only positions that are structural nonzeros in both + operands, with values combined using the monoid's binary operator + (which for multiplication is typically `times`). + """ + @callback matrix_ewise_mult( + a :: GraphBLAS.Matrix.t(), + b :: GraphBLAS.Matrix.t(), + monoid :: atom() | GraphBLAS.Monoid.t(), + opts :: Types.opts() + ) :: + {:ok, GraphBLAS.Matrix.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Reduces a matrix to a vector along rows (or columns, per descriptor). + + Each element of the result vector is the monoid reduction of the + corresponding row (or column) of the input matrix. + """ + @callback matrix_reduce( + matrix :: GraphBLAS.Matrix.t(), + monoid :: atom() | GraphBLAS.Monoid.t(), + opts :: Types.opts() + ) :: + {:ok, GraphBLAS.Vector.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Returns the transpose of a matrix. + """ + @callback matrix_transpose(matrix :: GraphBLAS.Matrix.t(), opts :: Types.opts()) :: + {:ok, GraphBLAS.Matrix.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Converts a sparse matrix to a dense list-of-lists representation. + + Returns `{:ok, [[value]]}` where each inner list is a row. Default values + (typically zero) fill positions that are structural zeros in the sparse + representation. + + This is a debugging and inspection helper, not intended for production use + on large matrices. + """ + @callback matrix_to_dense(matrix :: GraphBLAS.Matrix.t()) :: + {:ok, [[term()]]} | {:error, GraphBLAS.Error.t()} + + ############################################################################# + # Vector callbacks + ############################################################################# + + @doc """ + Creates an empty vector with the given size and scalar type. + """ + @callback vector_new( + size :: non_neg_integer(), + type :: Types.scalar_type(), + opts :: Types.opts() + ) :: + {:ok, GraphBLAS.Vector.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Creates a sparse vector from index-value pairs. + + `entries` is a list of `{index, value}` tuples. + """ + @callback vector_from_entries( + size :: non_neg_integer(), + entries :: [Types.vector_entry()], + type :: Types.scalar_type(), + opts :: Types.opts() + ) :: + {:ok, GraphBLAS.Vector.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Returns the number of stored (non-default) values in the vector. + """ + @callback vector_nvals(vector :: GraphBLAS.Vector.t()) :: + {:ok, non_neg_integer()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Returns the size (declared length) of the vector. + """ + @callback vector_size(vector :: GraphBLAS.Vector.t()) :: + {:ok, non_neg_integer()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Returns the scalar type of the vector's stored values. + """ + @callback vector_type(vector :: GraphBLAS.Vector.t()) :: + {:ok, Types.scalar_type()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Extracts the stored entries of a vector as index-value pairs. + """ + @callback vector_to_entries(vector :: GraphBLAS.Vector.t()) :: + {:ok, [Types.vector_entry()]} | {:error, GraphBLAS.Error.t()} + + @doc """ + Multiplies a vector by a matrix (from the left) using the given semiring. + + The result vector `v = x^T * A` has length `ncols(A)`. + """ + @callback vector_vxm( + vector :: GraphBLAS.Vector.t(), + matrix :: GraphBLAS.Matrix.t(), + semiring :: atom() | GraphBLAS.Semiring.t(), + opts :: Types.opts() + ) :: + {:ok, GraphBLAS.Vector.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Element-wise addition of two vectors using the given monoid. + """ + @callback vector_ewise_add( + a :: GraphBLAS.Vector.t(), + b :: GraphBLAS.Vector.t(), + monoid :: atom() | GraphBLAS.Monoid.t(), + opts :: Types.opts() + ) :: + {:ok, GraphBLAS.Vector.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Element-wise multiplication of two vectors using the given monoid. + """ + @callback vector_ewise_mult( + a :: GraphBLAS.Vector.t(), + b :: GraphBLAS.Vector.t(), + monoid :: atom() | GraphBLAS.Monoid.t(), + opts :: Types.opts() + ) :: + {:ok, GraphBLAS.Vector.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Reduces a vector to a scalar using the given monoid. + + Returns the monoid reduction of all stored values. + """ + @callback vector_reduce( + vector :: GraphBLAS.Vector.t(), + monoid :: atom() | GraphBLAS.Monoid.t(), + opts :: Types.opts() + ) :: + {:ok, GraphBLAS.Scalar.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Converts a sparse vector to a dense list representation. + + Returns `{:ok, [value]}` where the list has length equal to the vector's + declared size. Default values (typically zero) fill positions that are + structural zeros in the sparse representation. + + This is a debugging and inspection helper, not intended for production use + on large vectors. + """ + @callback vector_to_list(vector :: GraphBLAS.Vector.t()) :: + {:ok, [term()]} | {:error, GraphBLAS.Error.t()} + + ############################################################################# + # Container manipulation callbacks + ############################################################################# + + @doc """ + Sets the value at position (row, col) in the matrix. + + If the position already has a stored value, it is overwritten. + If the position was a structural zero, it becomes a stored entry. + Returns `{:ok, matrix}` with the updated matrix. + """ + @callback matrix_set( + matrix :: GraphBLAS.Matrix.t(), + row :: non_neg_integer(), + col :: non_neg_integer(), + value :: term() + ) :: + {:ok, GraphBLAS.Matrix.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Extracts the value at position (row, col) from the matrix. + + Returns `{:ok, value}` for stored entries. + For structural zeros (positions with no stored entry), returns the default + value for the matrix's type (0 for `:int64`, 0.0 for `:fp64`, false for `:bool`). + """ + @callback matrix_extract( + matrix :: GraphBLAS.Matrix.t(), + row :: non_neg_integer(), + col :: non_neg_integer() + ) :: + {:ok, term()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Creates a deep copy of the matrix. + + The copy is independent — modifying it does not affect the original. + For the SuiteSparse backend, this creates a new C object with a new pointer. + """ + @callback matrix_dup(matrix :: GraphBLAS.Matrix.t()) :: + {:ok, GraphBLAS.Matrix.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Sets the value at the given index in the vector. + + If the index already has a stored value, it is overwritten. + Returns `{:ok, vector}` with the updated vector. + """ + @callback vector_set( + vector :: GraphBLAS.Vector.t(), + index :: non_neg_integer(), + value :: term() + ) :: + {:ok, GraphBLAS.Vector.t()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Extracts the value at the given index from the vector. + + Returns `{:ok, value}` for stored entries. + For structural zeros (indices with no stored entry), returns the default + value for the vector's type. + """ + @callback vector_extract( + vector :: GraphBLAS.Vector.t(), + index :: non_neg_integer() + ) :: + {:ok, term()} | {:error, GraphBLAS.Error.t()} + + @doc """ + Creates a deep copy of the vector. + + The copy is independent — modifying it does not affect the original. + """ + @callback vector_dup(vector :: GraphBLAS.Vector.t()) :: + {:ok, GraphBLAS.Vector.t()} | {:error, GraphBLAS.Error.t()} +end diff --git a/lib/ex_graphblas/backend/elixir.ex b/lib/ex_graphblas/backend/elixir.ex new file mode 100644 index 0000000..0202cb0 --- /dev/null +++ b/lib/ex_graphblas/backend/elixir.ex @@ -0,0 +1,684 @@ +defmodule GraphBLAS.Backend.Elixir do + @moduledoc """ + Pure Elixir backend implementing the `GraphBLAS.Backend` behaviour. + + This is the correctness-first reference implementation. It uses the + simplest possible data structures to make each operation easy to + understand, inspect, and verify by hand. + + **Do not benchmark this backend and draw conclusions about the + library's performance.** It is intentionally not optimized. + + ## Purpose + + 1. **Semantic oracle** -- defines what each operation must produce; + future backends (SuiteSparse) must match these results exactly. + + 2. **API validation** -- proves the backend behaviour contract is + complete and well-typed before native code is written. + + 3. **Tutorial vehicle** -- readable Elixir code that shows exactly + what each operation computes. + + 4. **Regression baseline** -- test oracle for future backend parity. + + ## Data representation + + Matrices use flat maps with tuple keys: + + %{entries: %{{0, 1} => 5, {1, 2} => 7}, nrows: 3, ncols: 3, type: :int64} + + Vectors use flat maps with integer keys: + + %{entries: %{0 => 4, 2 => 8}, size: 4, type: :int64} + + These representations are chosen for clarity, not performance. + Single-level lookups, trivial pattern matching, easy inspection. + """ + + @behaviour GraphBLAS.Backend + + alias GraphBLAS.{Descriptor, Error, Mask, Matrix, Monoid, Scalar, Semiring, Types, Vector} + + ############################################################################# + # Matrix callbacks + ############################################################################# + + @impl GraphBLAS.Backend + def matrix_new(nrows, ncols, type, _opts) do + with :ok <- validate_dimensions(nrows, ncols), + :ok <- Types.validate_scalar_type(type) do + data = %{entries: %{}, nrows: nrows, ncols: ncols, type: type} + {:ok, %Matrix{shape: {nrows, ncols}, type: type, backend: __MODULE__, data: data}} + end + end + + @impl GraphBLAS.Backend + def matrix_from_coo(nrows, ncols, entries, type, opts) do + with :ok <- validate_dimensions(nrows, ncols), + :ok <- Types.validate_scalar_type(type), + :ok <- validate_entries(entries, nrows, ncols) do + monoid = Keyword.get(opts, :combine_monoid, :plus) + combined = combine_coo_entries(entries, monoid) + data = %{entries: combined, nrows: nrows, ncols: ncols, type: type} + {:ok, %Matrix{shape: {nrows, ncols}, type: type, backend: __MODULE__, data: data}} + end + end + + @impl GraphBLAS.Backend + def matrix_nvals(%Matrix{data: data}) do + {:ok, map_size(data.entries)} + end + + @impl GraphBLAS.Backend + def matrix_shape(%Matrix{shape: shape}) do + {:ok, shape} + end + + @impl GraphBLAS.Backend + def matrix_type(%Matrix{type: type}) do + {:ok, type} + end + + @impl GraphBLAS.Backend + def matrix_to_coo(%Matrix{data: data}) do + entries = + data.entries + |> Enum.map(fn {{r, c}, v} -> {r, c, v} end) + |> Enum.sort_by(fn {r, c, _v} -> {r, c} end) + + {:ok, entries} + end + + @impl GraphBLAS.Backend + def matrix_mxm(%Matrix{} = a, %Matrix{} = b, semiring, opts) do + with {:ok, sr} <- resolve_semiring(semiring), + {a, b} = apply_descriptor_inputs(a, b, opts), + :ok <- validate_mxm_dims(a, b) do + {nrows_a, _ncols_a} = a.shape + {_nrows_b, ncols_b} = b.shape + multiply_fn = resolve_operator_fn(sr.multiply) + add_fn = resolve_operator_fn(sr.add) + + result_entries = mxm_multiply(a, b, multiply_fn, add_fn) + + with {:ok, masked} <- apply_matrix_mask(result_entries, opts, nrows_a, ncols_b) do + data = %{entries: masked, nrows: nrows_a, ncols: ncols_b, type: sr.type} + + {:ok, %Matrix{shape: {nrows_a, ncols_b}, type: sr.type, backend: __MODULE__, data: data}} + end + end + end + + @impl GraphBLAS.Backend + def matrix_mxv(%Matrix{} = matrix, %Vector{} = vector, semiring, opts) do + with {:ok, sr} <- resolve_semiring(semiring), + {matrix, _} <- apply_descriptor_inputs(matrix, nil, opts), + :ok <- validate_mxv_dims(matrix, vector) do + multiply_fn = resolve_operator_fn(sr.multiply) + add_fn = resolve_operator_fn(sr.add) + + result_entries = mxv_multiply(matrix, vector, multiply_fn, add_fn) + nrows = elem(matrix.shape, 0) + + with {:ok, masked} <- apply_vector_mask(result_entries, opts, nrows) do + data = %{entries: masked, size: nrows, type: sr.type} + {:ok, %Vector{size: nrows, type: sr.type, backend: __MODULE__, data: data}} + end + end + end + + @impl GraphBLAS.Backend + def matrix_ewise_add(%Matrix{} = a, %Matrix{} = b, monoid, opts) do + with {:ok, m} <- resolve_monoid(monoid), + :ok <- validate_same_shape(a, b) do + op_fn = resolve_operator_fn(m.operator) + + combined = + Map.merge(a.data.entries, b.data.entries, fn _key, v1, v2 -> apply_op(op_fn, v1, v2) end) + + nrows = elem(a.shape, 0) + ncols = elem(a.shape, 1) + + with {:ok, masked} <- apply_matrix_mask(combined, opts, nrows, ncols) do + data = %{entries: masked, nrows: nrows, ncols: ncols, type: a.type} + {:ok, %Matrix{shape: a.shape, type: a.type, backend: __MODULE__, data: data}} + end + end + end + + @impl GraphBLAS.Backend + def matrix_ewise_mult(%Matrix{} = a, %Matrix{} = b, monoid, opts) do + with {:ok, m} <- resolve_monoid(monoid), + :ok <- validate_same_shape(a, b) do + op_fn = resolve_operator_fn(m.operator) + + intersection = + Map.intersect(a.data.entries, b.data.entries, fn _key, v1, v2 -> + apply_op(op_fn, v1, v2) + end) + + nrows = elem(a.shape, 0) + ncols = elem(a.shape, 1) + + with {:ok, masked} <- apply_matrix_mask(intersection, opts, nrows, ncols) do + data = %{entries: masked, nrows: nrows, ncols: ncols, type: a.type} + {:ok, %Matrix{shape: a.shape, type: a.type, backend: __MODULE__, data: data}} + end + end + end + + @impl GraphBLAS.Backend + def matrix_reduce(%Matrix{} = matrix, monoid, opts) do + with {:ok, m} <- resolve_monoid(monoid) do + op_fn = resolve_operator_fn(m.operator) + + result_entries = + matrix.data.entries + |> Enum.group_by(fn {{r, _c}, _v} -> r end, fn {{_r, _c}, v} -> v end) + |> Map.new(fn {row, vals} -> + {row, Enum.reduce(vals, fn a, b -> apply_op(op_fn, b, a) end)} + end) + + nrows = elem(matrix.shape, 0) + + with {:ok, masked} <- apply_vector_mask(result_entries, opts, nrows) do + data = %{entries: masked, size: nrows, type: matrix.type} + {:ok, %Vector{size: nrows, type: matrix.type, backend: __MODULE__, data: data}} + end + end + end + + @impl GraphBLAS.Backend + def matrix_transpose(%Matrix{} = matrix, opts) do + {nrows, ncols} = matrix.shape + + result_entries = + matrix.data.entries + |> Enum.map(fn {{r, c}, v} -> {{c, r}, v} end) + |> Map.new() + + with {:ok, masked} <- apply_matrix_mask(result_entries, opts, ncols, nrows) do + data = %{entries: masked, nrows: ncols, ncols: nrows, type: matrix.type} + {:ok, %Matrix{shape: {ncols, nrows}, type: matrix.type, backend: __MODULE__, data: data}} + end + end + + @impl GraphBLAS.Backend + def matrix_to_dense(%Matrix{data: data, shape: {nrows, ncols}, type: type}) do + default = default_value(type) + + rows = + for r <- 0..(nrows - 1) do + for c <- 0..(ncols - 1) do + Map.get(data.entries, {r, c}, default) + end + end + + {:ok, rows} + end + + ############################################################################# + # Vector callbacks + ############################################################################# + + @impl GraphBLAS.Backend + def vector_new(size, type, _opts) do + with :ok <- validate_size(size), + :ok <- Types.validate_scalar_type(type) do + data = %{entries: %{}, size: size, type: type} + {:ok, %Vector{size: size, type: type, backend: __MODULE__, data: data}} + end + end + + @impl GraphBLAS.Backend + def vector_from_entries(size, entries, type, opts) do + with :ok <- validate_size(size), + :ok <- Types.validate_scalar_type(type), + :ok <- validate_vector_entries(entries, size) do + monoid = Keyword.get(opts, :combine_monoid, :plus) + combined = combine_vector_entries(entries, monoid) + data = %{entries: combined, size: size, type: type} + {:ok, %Vector{size: size, type: type, backend: __MODULE__, data: data}} + end + end + + @impl GraphBLAS.Backend + def vector_nvals(%Vector{data: data}) do + {:ok, map_size(data.entries)} + end + + @impl GraphBLAS.Backend + def vector_size(%Vector{size: size}) do + {:ok, size} + end + + @impl GraphBLAS.Backend + def vector_type(%Vector{type: type}) do + {:ok, type} + end + + @impl GraphBLAS.Backend + def vector_to_entries(%Vector{data: data}) do + entries = Enum.sort_by(data.entries, fn {idx, _val} -> idx end) + {:ok, entries} + end + + @impl GraphBLAS.Backend + def vector_vxm(%Vector{} = vector, %Matrix{} = matrix, semiring, opts) do + with {:ok, sr} <- resolve_semiring(semiring), + {_, matrix} <- apply_descriptor_inputs(nil, matrix, opts), + :ok <- validate_vxm_dims(vector, matrix) do + multiply_fn = resolve_operator_fn(sr.multiply) + add_fn = resolve_operator_fn(sr.add) + + result_entries = vxm_multiply(vector, matrix, multiply_fn, add_fn) + result_size = elem(matrix.shape, 1) + + with {:ok, masked} <- apply_vector_mask(result_entries, opts, result_size) do + data_result = %{entries: masked, size: result_size, type: sr.type} + {:ok, %Vector{size: result_size, type: sr.type, backend: __MODULE__, data: data_result}} + end + end + end + + @impl GraphBLAS.Backend + def vector_ewise_add(%Vector{} = a, %Vector{} = b, monoid, opts) do + with {:ok, m} <- resolve_monoid(monoid), + :ok <- validate_same_vector_size(a, b) do + op_fn = resolve_operator_fn(m.operator) + + combined = + Map.merge(a.data.entries, b.data.entries, fn _k, v1, v2 -> apply_op(op_fn, v1, v2) end) + + with {:ok, masked} <- apply_vector_mask(combined, opts, a.size) do + data = %{entries: masked, size: a.size, type: a.type} + {:ok, %Vector{size: a.size, type: a.type, backend: __MODULE__, data: data}} + end + end + end + + @impl GraphBLAS.Backend + def vector_ewise_mult(%Vector{} = a, %Vector{} = b, monoid, opts) do + with {:ok, m} <- resolve_monoid(monoid), + :ok <- validate_same_vector_size(a, b) do + op_fn = resolve_operator_fn(m.operator) + + intersection = + Map.intersect(a.data.entries, b.data.entries, fn _k, v1, v2 -> apply_op(op_fn, v1, v2) end) + + with {:ok, masked} <- apply_vector_mask(intersection, opts, a.size) do + data = %{entries: masked, size: a.size, type: a.type} + {:ok, %Vector{size: a.size, type: a.type, backend: __MODULE__, data: data}} + end + end + end + + @impl GraphBLAS.Backend + def vector_reduce(%Vector{} = vector, monoid, _opts) do + with {:ok, m} <- resolve_monoid(monoid) do + result = reduce_values(vector.data.entries, m) + {:ok, %Scalar{type: m.type, value: result}} + end + end + + @impl GraphBLAS.Backend + def vector_to_list(%Vector{data: data, size: size, type: type}) do + default = default_value(type) + list = for i <- 0..(size - 1), do: Map.get(data.entries, i, default) + {:ok, list} + end + + ############################################################################# + # Private helpers + ############################################################################# + + defp validate_dimensions(nrows, ncols) when nrows >= 0 and ncols >= 0, do: :ok + + defp validate_dimensions(nrows, ncols), + do: + Error.error( + {:invalid_argument, "dimensions must be non-negative, got {#{nrows}, #{ncols}}"} + ) + + defp validate_size(size) when size >= 0, do: :ok + + defp validate_size(size), + do: Error.error({:invalid_argument, "vector size must be non-negative, got #{size}"}) + + defp validate_entries([], _nrows, _ncols), do: :ok + + defp validate_entries([{r, c, _v} | rest], nrows, ncols) do + cond do + r < 0 or r >= nrows -> Error.error({:index_out_of_bounds, r, :row, nrows}) + c < 0 or c >= ncols -> Error.error({:index_out_of_bounds, c, :col, ncols}) + true -> validate_entries(rest, nrows, ncols) + end + end + + defp validate_vector_entries([], _size), do: :ok + + defp validate_vector_entries([{idx, _v} | rest], size) do + if idx < 0 or idx >= size do + Error.error({:index_out_of_bounds, idx, :index, size}) + else + validate_vector_entries(rest, size) + end + end + + defp validate_mxm_dims(%Matrix{shape: {_, ncols_a}}, %Matrix{shape: {nrows_b, _}}) do + if ncols_a == nrows_b, + do: :ok, + else: Error.error({:dimension_mismatch, {ncols_a, nrows_b}, "ncols(A) != nrows(B)"}) + end + + defp validate_mxv_dims(%Matrix{shape: {_, ncols}}, %Vector{size: size}) do + if ncols == size, + do: :ok, + else: Error.error({:dimension_mismatch, {ncols, size}, "ncols(matrix) != size(vector)"}) + end + + defp validate_vxm_dims(%Vector{size: size}, %Matrix{shape: {nrows, _}}) do + if size == nrows, + do: :ok, + else: Error.error({:dimension_mismatch, {size, nrows}, "size(vector) != nrows(matrix)"}) + end + + defp validate_same_shape(%Matrix{shape: s1}, %Matrix{shape: s2}) do + if s1 == s2, do: :ok, else: Error.error({:dimension_mismatch, s2, s1}) + end + + defp validate_same_vector_size(%Vector{size: s1}, %Vector{size: s2}) do + if s1 == s2, do: :ok, else: Error.error({:dimension_mismatch, s2, s1}) + end + + defp combine_coo_entries(entries, monoid) do + {:ok, m} = resolve_monoid(monoid) + op_fn = resolve_operator_fn(m.operator) + + entries + |> Enum.group_by(fn {r, c, _v} -> {r, c} end, fn {_r, _c, v} -> v end) + |> Map.new(fn {{r, c}, vals} -> + combined = Enum.reduce(vals, fn a, b -> apply_op(op_fn, b, a) end) + {{r, c}, combined} + end) + end + + defp combine_vector_entries(entries, monoid) do + {:ok, m} = resolve_monoid(monoid) + op_fn = resolve_operator_fn(m.operator) + + entries + |> Enum.group_by(fn {idx, _v} -> idx end, fn {_idx, v} -> v end) + |> Map.new(fn {idx, vals} -> + combined = Enum.reduce(vals, fn a, b -> apply_op(op_fn, b, a) end) + {idx, combined} + end) + end + + defp resolve_semiring(name) when is_atom(name), do: Semiring.resolve(name) + defp resolve_semiring(%Semiring{} = s), do: {:ok, s} + + defp resolve_monoid(name) when is_atom(name), do: Monoid.resolve(name) + defp resolve_monoid(%Monoid{} = m), do: {:ok, m} + + defp resolve_operator_fn(:plus), do: &Kernel.+/2 + defp resolve_operator_fn(:times), do: &Kernel.*/2 + defp resolve_operator_fn(:min), do: &min/2 + defp resolve_operator_fn(:max), do: &max/2 + defp resolve_operator_fn(:land), do: &Kernel.and/2 + defp resolve_operator_fn(:lor), do: &Kernel.or/2 + defp resolve_operator_fn(:lxor), do: fn a, b -> a != b end + defp resolve_operator_fn(fun) when is_function(fun, 2), do: fun + + defp apply_op(fun, a, b), do: fun.(a, b) + + defp reduce_values(entries, %{operator: op, identity: identity}) do + op_fn = resolve_operator_fn(op) + vals = Map.values(entries) + + case vals do + [] -> identity + [single] -> single + _ -> Enum.reduce(vals, fn a, b -> apply_op(op_fn, b, a) end) + end + end + + defp mxm_multiply(a, b, multiply_fn, add_fn) do + Enum.reduce(a.data.entries, %{}, fn {{i, k}, a_val}, acc -> + mxm_row(k, i, a_val, b, multiply_fn, add_fn, acc) + end) + end + + defp mxm_row(k, i, a_val, b, multiply_fn, add_fn, acc) do + Enum.reduce(b.data.entries, acc, fn {{k2, j}, b_val}, inner_acc -> + if k == k2 do + accumulate_product(inner_acc, {i, j}, a_val, b_val, multiply_fn, add_fn) + else + inner_acc + end + end) + end + + defp mxv_multiply(matrix, vector, multiply_fn, add_fn) do + Enum.reduce(matrix.data.entries, %{}, fn {{i, k}, a_val}, acc -> + case Map.get(vector.data.entries, k) do + nil -> acc + b_val -> accumulate_product(acc, i, a_val, b_val, multiply_fn, add_fn) + end + end) + end + + defp vxm_multiply(vector, matrix, multiply_fn, add_fn) do + Enum.reduce(vector.data.entries, %{}, fn {k, v_val}, acc -> + vxm_row(k, v_val, matrix, multiply_fn, add_fn, acc) + end) + end + + defp vxm_row(k, v_val, matrix, multiply_fn, add_fn, acc) do + Enum.reduce(matrix.data.entries, acc, fn {{k2, j}, m_val}, inner_acc -> + if k == k2 do + accumulate_product(inner_acc, j, v_val, m_val, multiply_fn, add_fn) + else + inner_acc + end + end) + end + + defp accumulate_product(acc, key, a_val, b_val, multiply_fn, add_fn) do + product = apply_op(multiply_fn, a_val, b_val) + Map.update(acc, key, product, fn existing -> apply_op(add_fn, existing, product) end) + end + + defp default_value(:bool), do: false + defp default_value(:fp64), do: 0.0 + defp default_value(_), do: 0 + + ############################################################################# + # Container manipulation callbacks + ############################################################################# + + @impl GraphBLAS.Backend + def matrix_set( + %Matrix{shape: {nrows, ncols}, type: type, backend: __MODULE__, data: data}, + row, + col, + value + ) do + with :ok <- validate_index(row, nrows), + :ok <- validate_index(col, ncols) do + updated = Map.put(data.entries, {row, col}, value) + + {:ok, + %Matrix{ + shape: {nrows, ncols}, + type: type, + backend: __MODULE__, + data: %{data | entries: updated} + }} + end + end + + @impl GraphBLAS.Backend + def matrix_extract( + %Matrix{shape: {nrows, ncols}, type: type, backend: __MODULE__, data: data}, + row, + col + ) do + with :ok <- validate_index(row, nrows), + :ok <- validate_index(col, ncols) do + {:ok, Map.get(data.entries, {row, col}, default_value(type))} + end + end + + @impl GraphBLAS.Backend + def matrix_dup(%Matrix{} = matrix) do + {:ok, + %Matrix{ + matrix + | backend: __MODULE__, + data: %{matrix.data | entries: Map.new(matrix.data.entries)} + }} + end + + @impl GraphBLAS.Backend + def vector_set(%Vector{size: size, type: type, backend: __MODULE__, data: data}, index, value) do + with :ok <- validate_index(index, size) do + updated = Map.put(data.entries, index, value) + + {:ok, + %Vector{size: size, type: type, backend: __MODULE__, data: %{data | entries: updated}}} + end + end + + @impl GraphBLAS.Backend + def vector_extract(%Vector{size: size, type: type, backend: __MODULE__, data: data}, index) do + with :ok <- validate_index(index, size) do + {:ok, Map.get(data.entries, index, default_value(type))} + end + end + + @impl GraphBLAS.Backend + def vector_dup(%Vector{} = vector) do + {:ok, + %Vector{ + vector + | backend: __MODULE__, + data: %{vector.data | entries: Map.new(vector.data.entries)} + }} + end + + ############################################################################# + # Mask and descriptor helpers + ############################################################################# + + defp validate_index(idx, max) when is_integer(idx) and idx >= 0 and idx < max, do: :ok + defp validate_index(idx, max), do: Error.error({:index_out_of_bounds, idx, :index, max}) + + defp apply_matrix_mask(entries, opts, _nrows, _ncols) do + case Keyword.get(opts, :mask) do + nil -> + {:ok, entries} + + %Mask{source: %Matrix{data: mask_data}, complement: complement?} -> + mask_positions = get_matrix_mask_positions(mask_data, get_mask_mode(opts)) + + masked = + entries + |> Enum.filter(&in_mask_positions?(&1, mask_positions, complement?)) + |> Map.new() + + {:ok, masked} + + %Mask{source: %Vector{}} -> + Error.error({:mask_type_mismatch, :vector, :matrix}) + + _ -> + {:ok, entries} + end + end + + defp apply_vector_mask(entries, opts, _size) do + case Keyword.get(opts, :mask) do + nil -> + {:ok, entries} + + %Mask{source: %Vector{data: mask_data}, complement: complement?} -> + mask_positions = get_vector_mask_positions(mask_data, get_mask_mode(opts)) + + masked = + entries + |> Enum.filter(&in_mask_positions?(&1, mask_positions, complement?)) + |> Map.new() + + {:ok, masked} + + %Mask{source: %Matrix{}} -> + Error.error({:mask_type_mismatch, :matrix, :vector}) + + _ -> + {:ok, entries} + end + end + + defp in_mask_positions?({{r, c}, _v}, mask_positions, complement?) do + in_mask = MapSet.member?(mask_positions, {r, c}) + if complement?, do: not in_mask, else: in_mask + end + + defp in_mask_positions?({idx, _v}, mask_positions, complement?) do + in_mask = MapSet.member?(mask_positions, idx) + if complement?, do: not in_mask, else: in_mask + end + + defp get_mask_mode(opts) do + case Keyword.get(opts, :descriptor) do + %Descriptor{mask: :value} -> :value + _ -> :structural + end + end + + defp get_matrix_mask_positions(mask_data, :structural) do + mask_data.entries |> Map.keys() |> MapSet.new() + end + + defp get_matrix_mask_positions(mask_data, :value) do + mask_data.entries + |> Enum.filter(fn {_k, v} -> v != false and v != 0 and v != 0.0 end) + |> Enum.map(fn {k, _v} -> k end) + |> MapSet.new() + end + + defp get_vector_mask_positions(mask_data, :structural) do + mask_data.entries |> Map.keys() |> MapSet.new() + end + + defp get_vector_mask_positions(mask_data, :value) do + mask_data.entries + |> Enum.filter(fn {_k, v} -> v != false and v != 0 and v != 0.0 end) + |> Enum.map(fn {k, _v} -> k end) + |> MapSet.new() + end + + defp apply_descriptor_inputs(a, b, opts) do + desc = Keyword.get(opts, :descriptor) + + a = + if is_struct(desc, Descriptor) and desc.inp0_transpose == :transpose and a != nil do + {:ok, t} = matrix_transpose(a, []) + t + else + a + end + + b = + if is_struct(desc, Descriptor) and desc.inp1_transpose == :transpose and b != nil do + {:ok, t} = matrix_transpose(b, []) + t + else + b + end + + {a, b} + end +end diff --git a/lib/ex_graphblas/backend/zig_stub.ex b/lib/ex_graphblas/backend/zig_stub.ex new file mode 100644 index 0000000..7eaf2e8 --- /dev/null +++ b/lib/ex_graphblas/backend/zig_stub.ex @@ -0,0 +1,179 @@ +defmodule GraphBLAS.Backend.ZigStub do + @moduledoc """ + Zigler integration test backend. + + This backend verifies that Zigler compiles and loads NIFs correctly + on a given Elixir/OTP combination. It implements the + `GraphBLAS.Backend` behaviour with lightweight operations backed by + `GraphBLAS.Nif.ZigStub` and returns + `{:error, {:unsupported_operation, __MODULE__}}` for compute-heavy + callbacks that would require SuiteSparse:GraphBLAS. + + ## Purpose + + 1. **CI verification** -- proves Zigler works on the target Elixir/OTP + without requiring SuiteSparse:GraphBLAS to be installed. + + 2. **Backend contract validation** -- ensures every `@callback` in + `GraphBLAS.Backend` has an implementation, so the compiler catches + missing functions when new callbacks are added. + + ## Configuration + + config :ex_graphblas, + default_backend: GraphBLAS.Backend.ZigStub + + This is primarily useful in CI. For development and testing, use + `GraphBLAS.Backend.Elixir`. For production, use + `GraphBLAS.Backend.SuiteSparse`. + """ + + @behaviour GraphBLAS.Backend + + alias GraphBLAS.{Error, Matrix, Nif.ZigStub, Vector} + + @unsupported {:error, + %Error{ + reason: {:unsupported_operation, __MODULE__}, + message: "ZigStub backend does not implement this operation", + context: %{} + }} + + ############################################################################# + # Zigler verification + ############################################################################# + + @doc """ + Returns `true` if the Zig stub NIF loaded successfully. + """ + @spec available?() :: boolean() + def available? do + Code.ensure_loaded?(ZigStub) and function_exported?(ZigStub, :ping, 0) + end + + @doc """ + Calls the Zig stub `add_one/1` NIF. + """ + @spec add_one(integer()) :: {:ok, integer()} | {:error, term()} + def add_one(n) when is_integer(n) do + {:ok, ZigStub.add_one(n)} + rescue + error -> {:error, error} + end + + @doc """ + Calls the Zig stub `ping/0` NIF. + """ + @spec ping() :: {:ok, :ok} | {:error, term()} + def ping do + {:ok, ZigStub.ping()} + rescue + error -> {:error, error} + end + + ############################################################################# + # Matrix callbacks (lightweight operations only) + ############################################################################# + + @impl GraphBLAS.Backend + def matrix_new(nrows, ncols, type, _opts) do + {:ok, + %Matrix{ + shape: {nrows, ncols}, + type: type, + data: %{zig_stub: true, entries: %{}}, + backend: __MODULE__ + }} + end + + @impl GraphBLAS.Backend + def matrix_nvals(%Matrix{data: %{entries: entries}}) do + {:ok, map_size(entries)} + end + + @impl GraphBLAS.Backend + def matrix_shape(%Matrix{shape: shape}) do + {:ok, shape} + end + + @impl GraphBLAS.Backend + def matrix_type(%Matrix{type: type}) do + {:ok, type} + end + + @impl GraphBLAS.Backend + def matrix_from_coo(_nrows, _ncols, _entries, _type, _opts), do: @unsupported + @impl GraphBLAS.Backend + def matrix_to_coo(_matrix), do: @unsupported + @impl GraphBLAS.Backend + def matrix_mxm(_a, _b, _semiring, _opts), do: @unsupported + @impl GraphBLAS.Backend + def matrix_mxv(_matrix, _vector, _semiring, _opts), do: @unsupported + @impl GraphBLAS.Backend + def matrix_ewise_add(_a, _b, _monoid, _opts), do: @unsupported + @impl GraphBLAS.Backend + def matrix_ewise_mult(_a, _b, _monoid, _opts), do: @unsupported + @impl GraphBLAS.Backend + def matrix_reduce(_matrix, _monoid, _opts), do: @unsupported + @impl GraphBLAS.Backend + def matrix_transpose(_matrix, _opts), do: @unsupported + @impl GraphBLAS.Backend + def matrix_to_dense(_matrix), do: @unsupported + @impl GraphBLAS.Backend + def matrix_set(_matrix, _row, _col, _value), do: @unsupported + @impl GraphBLAS.Backend + def matrix_extract(_matrix, _row, _col), do: @unsupported + @impl GraphBLAS.Backend + def matrix_dup(_matrix), do: @unsupported + + ############################################################################# + # Vector callbacks (lightweight operations only) + ############################################################################# + + @impl GraphBLAS.Backend + def vector_new(size, type, _opts) do + {:ok, + %Vector{ + size: size, + type: type, + data: %{zig_stub: true, entries: %{}}, + backend: __MODULE__ + }} + end + + @impl GraphBLAS.Backend + def vector_nvals(%Vector{data: %{entries: entries}}) do + {:ok, map_size(entries)} + end + + @impl GraphBLAS.Backend + def vector_size(%Vector{size: size}) do + {:ok, size} + end + + @impl GraphBLAS.Backend + def vector_type(%Vector{type: type}) do + {:ok, type} + end + + @impl GraphBLAS.Backend + def vector_from_entries(_size, _entries, _type, _opts), do: @unsupported + @impl GraphBLAS.Backend + def vector_to_entries(_vector), do: @unsupported + @impl GraphBLAS.Backend + def vector_vxm(_vector, _matrix, _semiring, _opts), do: @unsupported + @impl GraphBLAS.Backend + def vector_ewise_add(_a, _b, _monoid, _opts), do: @unsupported + @impl GraphBLAS.Backend + def vector_ewise_mult(_a, _b, _monoid, _opts), do: @unsupported + @impl GraphBLAS.Backend + def vector_reduce(_vector, _monoid, _opts), do: @unsupported + @impl GraphBLAS.Backend + def vector_to_list(_vector), do: @unsupported + @impl GraphBLAS.Backend + def vector_set(_vector, _index, _value), do: @unsupported + @impl GraphBLAS.Backend + def vector_extract(_vector, _index), do: @unsupported + @impl GraphBLAS.Backend + def vector_dup(_vector), do: @unsupported +end diff --git a/lib/ex_graphblas/binary_op.ex b/lib/ex_graphblas/binary_op.ex new file mode 100644 index 0000000..340cd68 --- /dev/null +++ b/lib/ex_graphblas/binary_op.ex @@ -0,0 +1,79 @@ +defmodule GraphBLAS.BinaryOp do + @moduledoc """ + Binary operator definitions for GraphBLAS. + + Binary operators are the building blocks of semirings and monoids. + In GraphBLAS, a binary operator is a function `f(a, b) -> c` that + combines two values. They are used as: + + - The multiplicative operator in a semiring (e.g., `times` in `plus_times`) + - The additive operator in a semiring (e.g., `plus` in `plus_times`) + - The operator in a monoid (e.g., `plus` in the additive monoid) + - Element-wise operations on matrices and vectors + + ## Built-in binary operators + + Phase 1 provides the operators needed by the built-in semirings and + monoids. These are referenced by atom name throughout the API. + """ + + alias GraphBLAS.Types + + @type t :: %__MODULE__{ + name: atom(), + function: (term(), term() -> term()), + type: Types.scalar_type() + } + + @enforce_keys [:name, :function, :type] + defstruct [:name, :function, :type] + + @doc """ + Creates a custom binary operator struct. + + Prefer using built-in operators by atom name when possible. + Custom operators are for cases where the built-in set does not suffice. + """ + @spec new(keyword()) :: t() + def new(opts) do + name = Keyword.fetch!(opts, :name) + function = Keyword.fetch!(opts, :function) + type = Keyword.fetch!(opts, :type) + + %__MODULE__{name: name, function: function, type: type} + end + + @doc """ + Applies a binary operator to two values. + + The operator can be an atom naming a built-in, or a `%BinaryOp{}` struct. + """ + @spec apply(atom() | t(), term(), term()) :: term() + def apply(%__MODULE__{function: fun}, a, b), do: fun.(a, b) + + def apply(name, a, b) when is_atom(name) do + fn_for(name).(a, b) + end + + @doc """ + Returns the function implementation for a built-in binary operator name. + """ + @spec fn_for(atom()) :: (term(), term() -> term()) + def fn_for(:plus), do: &Kernel.+/2 + def fn_for(:times), do: &Kernel.*/2 + def fn_for(:minus), do: &Kernel.-/2 + def fn_for(:min), do: &min/2 + def fn_for(:max), do: &max/2 + def fn_for(:land), do: &Kernel.and/2 + def fn_for(:lor), do: &Kernel.or/2 + def fn_for(:lxor), do: fn a, b -> a != b end + def fn_for(name), do: raise(ArgumentError, "Unknown binary operator: #{inspect(name)}") + + @doc """ + Returns the list of all built-in binary operator name atoms. + """ + @spec builtin_names() :: [atom()] + def builtin_names do + [:plus, :times, :minus, :min, :max, :land, :lor, :lxor] + end +end diff --git a/lib/ex_graphblas/config.ex b/lib/ex_graphblas/config.ex new file mode 100644 index 0000000..a8e0b16 --- /dev/null +++ b/lib/ex_graphblas/config.ex @@ -0,0 +1,81 @@ +defmodule GraphBLAS.Config do + @moduledoc """ + Configuration for GraphBLAS backend selection and defaults. + + GraphBLAS supports multiple backends. The active backend is determined + in this order: + + 1. Explicit `:backend` option passed to an individual function call. + 2. Application configuration (`config :ex_graphblas, :default_backend`). + 3. The compiled-in default: `GraphBLAS.Backend.Elixir`. + + This module provides runtime access to configuration so that backend + modules can remain decoupled from the application environment. + + ## Example configuration + + In `config/config.exs`: + + config :ex_graphblas, + default_backend: GraphBLAS.Backend.Elixir + + Or at runtime: + + Application.put_env(:ex_graphblas, :default_backend, MyCustomBackend) + """ + + @default_backend GraphBLAS.Backend.Elixir + + @doc """ + Returns the currently configured default backend module. + + The default backend is used when no `:backend` option is provided + to an individual API call. It can be configured via application + environment or defaults to `GraphBLAS.Backend.Elixir`. + """ + @spec default_backend() :: module() + def default_backend do + Application.get_env(:ex_graphblas, :default_backend, @default_backend) + end + + @doc """ + Resolves the effective backend for an operation. + + Takes an optional keyword list and extracts the `:backend` key if present. + Falls back to the configured default backend if absent. + + ## Examples + + iex> GraphBLAS.Config.resolve_backend([]) + GraphBLAS.Backend.Elixir + + iex> GraphBLAS.Config.resolve_backend(backend: MyBackend) + MyBackend + """ + @spec resolve_backend(keyword()) :: module() + def resolve_backend(opts) when is_list(opts) do + Keyword.get(opts, :backend, default_backend()) + end + + @doc """ + Returns the default scalar type for integer containers. + + This is `:int64` by default, matching the GraphBLAS convention. + Can be overridden via application configuration. + """ + @spec default_int_type() :: GraphBLAS.Types.scalar_type() + def default_int_type do + Application.get_env(:ex_graphblas, :default_int_type, :int64) + end + + @doc """ + Returns the default scalar type for floating-point containers. + + This is `:fp64` by default, matching the GraphBLAS convention. + Can be overridden via application configuration. + """ + @spec default_fp_type() :: GraphBLAS.Types.scalar_type() + def default_fp_type do + Application.get_env(:ex_graphblas, :default_fp_type, :fp64) + end +end diff --git a/lib/ex_graphblas/descriptor.ex b/lib/ex_graphblas/descriptor.ex new file mode 100644 index 0000000..eda9afe --- /dev/null +++ b/lib/ex_graphblas/descriptor.ex @@ -0,0 +1,109 @@ +defmodule GraphBLAS.Descriptor do + @moduledoc """ + Descriptor type definition for GraphBLAS. + + A **descriptor** controls how a GraphBLAS operation interprets its + inputs and writes its output. Descriptors are the GraphBLAS mechanism + for modifying operation semantics without creating new operation + variants. + + ## What descriptors control + + In GraphBLAS C, a descriptor has fields that modify each operand: + + - **Input modifiers**: transpose the input (`:inp0_transpose`, + `:inp1_transpose`), or apply a structural/complement mask. + - **Output modifiers**: replace (overwrite) the output instead of + merging, or restrict writes via a mask. + - **Mask modifiers**: complement the mask, or use the mask's structure + vs. its values. + + ## Phase 1 scope + + Phase 1 defines the Descriptor type with a minimal set of options. + Full descriptor support (transpose inputs, replace output, complement + mask) is planned for Phase 4. The Reference backend currently + recognizes descriptors but may not implement all modifiers until then. + + ## Usage (conceptual) + + # In Phase 4, descriptors will be passed to operations: + {:ok, c} = GraphBLAS.Matrix.mxm(a, b, :plus_times, + descriptor: GraphBLAS.Descriptor.new(inp0_transpose: true) + ) + + Instead of creating `A_transposed` as a separate matrix, you tell + the operation to treat `A` as if it were transposed. This avoids + a copy and is more efficient. + """ + + @type transpose_input :: :none | :transpose + @type output_mode :: :merge | :replace + @type mask_mode :: :structural | :value + + @type t :: %__MODULE__{ + inp0_transpose: transpose_input(), + inp1_transpose: transpose_input(), + output: output_mode(), + mask: mask_mode() + } + + @enforce_keys [] + defstruct inp0_transpose: :none, + inp1_transpose: :none, + output: :merge, + mask: :structural + + @doc """ + Creates a new descriptor with the given options. + + All fields have sensible defaults that result in standard (unmodified) + operation semantics. + + ## Options + + - `:inp0_transpose` -- `:none` (default) or `:transpose` to transpose + the first input before the operation. + - `:inp1_transpose` -- `:none` (default) or `:transpose` to transpose + the second input before the operation. + - `:output` -- `:merge` (default) to merge results into the output, or + `:replace` to clear the output before writing. + - `:mask` -- `:structural` (default) to use the mask's structure, or + `:value` to use the mask's values. + + ## Examples + + iex> GraphBLAS.Descriptor.new() + %GraphBLAS.Descriptor{inp0_transpose: :none, inp1_transpose: :none, output: :merge, mask: :structural} + + iex> GraphBLAS.Descriptor.new(inp0_transpose: :transpose) + %GraphBLAS.Descriptor{inp0_transpose: :transpose, inp1_transpose: :none, output: :merge, mask: :structural} + """ + @spec new(keyword()) :: t() + def new(opts \\ []) do + %__MODULE__{ + inp0_transpose: Keyword.get(opts, :inp0_transpose, :none), + inp1_transpose: Keyword.get(opts, :inp1_transpose, :none), + output: Keyword.get(opts, :output, :merge), + mask: Keyword.get(opts, :mask, :structural) + } + end + + @doc """ + Returns a descriptor that transposes the first input. + """ + @spec inp0_transpose() :: t() + def inp0_transpose, do: new(inp0_transpose: :transpose) + + @doc """ + Returns a descriptor that transposes the second input. + """ + @spec inp1_transpose() :: t() + def inp1_transpose, do: new(inp1_transpose: :transpose) + + @doc """ + Returns a descriptor that replaces the output instead of merging. + """ + @spec replace_output() :: t() + def replace_output, do: new(output: :replace) +end diff --git a/lib/ex_graphblas/error.ex b/lib/ex_graphblas/error.ex new file mode 100644 index 0000000..96cae7a --- /dev/null +++ b/lib/ex_graphblas/error.ex @@ -0,0 +1,194 @@ +defmodule GraphBLAS.Error do + @moduledoc """ + Error types for GraphBLAS. + + All errors returned by GraphBLAS are structured using the types + defined in this module. Errors are returned as `{:error, reason}` + tuples rather than raised as exceptions, following Elixir convention + for library code. Exceptions are reserved for programming errors + (invalid arguments, type mismatches) that indicate bugs in calling code. + + ## Error categories + + - **Dimension errors** -- shape mismatches between operands (e.g., + multiplying matrices with incompatible inner dimensions). + - **Type errors** -- scalar type mismatches or unsupported type + combinations for a given operation. + - **Backend errors** -- failures originating in the native layer + (allocation failures, null handles, native crashes). These are + wrapped to preserve stack traces and backend context. + - **Invalid argument errors** -- programming errors such as negative + indices, empty required arguments, or invalid option keys. + + ## Design note + + GraphBLAS uses structured error reasons rather than atoms or strings. + This enables pattern matching on specific failure modes without + string parsing. Each reason is a tuple where the first element identifies + the error category and subsequent elements provide context. + """ + + @type t :: %__MODULE__{ + reason: reason(), + message: String.t() | nil, + context: map() + } + + @enforce_keys [:reason] + defstruct [:reason, :message, :context] + + @typedoc """ + A structured error reason. + + Each reason is a tuple where: + - The first element is the error category atom. + - Subsequent elements provide category-specific context. + + ## Categories + + - `{:dimension_mismatch, expected, actual}` -- shape mismatch + - `{:type_mismatch, expected_type, actual_type}` -- scalar type mismatch + - `{:unsupported_type, type}` -- type not supported by this operation/backend + - `{:unsupported_operation, operation, backend}` -- operation not available + - `{:backend_error, backend, detail}` -- native/backend failure + - `{:null_handle, handle_type}` -- operation on destroyed or uninitialized handle + - `{:invalid_argument, detail}` -- invalid input to an API call + - `{:index_out_of_bounds, index, dimension, size}` -- index exceeds dimension + - `{:mask_type_mismatch, actual, expected}` -- mask container type mismatch + - `{:unknown_predicate, name}` -- predicate not found in relation + - `{:empty_predicate_path}` -- traverse called with empty path + - `{:empty_collection, detail}` -- operation requiring data on empty container + """ + @type reason :: + {:dimension_mismatch, term(), term()} + | {:type_mismatch, term(), term()} + | {:unsupported_type, term()} + | {:unsupported_operation, atom(), module()} + | {:unsupported_operation, module()} + | {:backend_error, module(), term()} + | {:null_handle, atom()} + | {:invalid_argument, term()} + | {:index_out_of_bounds, non_neg_integer(), atom(), non_neg_integer()} + | {:mask_type_mismatch, atom(), atom()} + | {:unknown_predicate, atom()} + | {:empty_predicate_path} + | {:empty_collection, term()} + + @doc """ + Creates a new structured error. + + ## Examples + + iex> GraphBLAS.Error.new({:dimension_mismatch, {3, 3}, {2, 4}}) + %GraphBLAS.Error{reason: {:dimension_mismatch, {3, 3}, {2, 4}}, message: nil, context: %{}} + + iex> GraphBLAS.Error.new({:type_mismatch, :int64, :fp64}, message: "operand types must agree") + %GraphBLAS.Error{reason: {:type_mismatch, :int64, :fp64}, message: "operand types must agree", context: %{}} + """ + @spec new(reason(), keyword()) :: t() + def new(reason, opts \\ []) do + %__MODULE__{ + reason: reason, + message: Keyword.get(opts, :message), + context: Keyword.get(opts, :context, %{}) + } + end + + @doc """ + Wraps an error into an `{:error, t}` tuple. + + This is the primary way to return errors from GraphBLAS operations. + + ## Examples + + iex> GraphBLAS.Error.error({:dimension_mismatch, {3, 3}, {2, 4}}) + {:error, %GraphBLAS.Error{reason: {:dimension_mismatch, {3, 3}, {2, 4}}, message: nil, context: %{}}} + """ + @spec error(reason(), keyword()) :: {:error, t()} + def error(reason, opts \\ []) do + {:error, new(reason, opts)} + end + + @doc """ + Raises an `ArgumentError` for programming errors that indicate bugs + in calling code. + + Use this for invalid arguments, type errors, and other conditions that + should not occur in correct usage. For expected failure modes (e.g., + backend errors after retrying), return `{:error, t}` instead. + """ + @dialyzer {:nowarn_function, raise!: 1} + @spec raise!(reason(), keyword()) :: no_return() + def raise!(reason, opts \\ []) do + err = new(reason, opts) + raise ArgumentError, format_error(err) + end + + @doc """ + Formats an error into a human-readable string. + + This is used for log messages and exception messages, not for pattern + matching. Prefer pattern matching on `reason` directly in calling code. + """ + @spec format_error(t()) :: String.t() + def format_error(%__MODULE__{reason: reason, message: msg}) do + base = format_reason(reason) + if msg, do: "#{base}: #{msg}", else: base + end + + defp format_reason({:dimension_mismatch, expected, actual}) do + "Dimension mismatch: expected #{inspect(expected)}, got #{inspect(actual)}" + end + + defp format_reason({:type_mismatch, expected, actual}) do + "Type mismatch: expected #{inspect(expected)}, got #{inspect(actual)}" + end + + defp format_reason({:unsupported_type, type}) do + "Unsupported scalar type: #{inspect(type)}" + end + + defp format_reason({:unsupported_operation, op, backend}) do + "Operation #{inspect(op)} not supported by backend #{inspect(backend)}" + end + + defp format_reason({:unsupported_operation, backend}) do + "Operation not supported by backend #{inspect(backend)}" + end + + defp format_reason({:backend_error, backend, detail}) do + "Backend error in #{inspect(backend)}: #{inspect(detail)}" + end + + defp format_reason({:null_handle, handle_type}) do + "Operation on null or destroyed #{inspect(handle_type)} handle" + end + + defp format_reason({:invalid_argument, detail}) do + "Invalid argument: #{inspect(detail)}" + end + + defp format_reason({:index_out_of_bounds, idx, dimension, size}) do + "Index #{idx} out of bounds for #{dimension} of size #{size}" + end + + defp format_reason({:empty_collection, detail}) do + "Empty collection: #{inspect(detail)}" + end + + defp format_reason({:mask_type_mismatch, actual, expected}) do + "Mask type mismatch: got #{inspect(actual)}, expected #{inspect(expected)}" + end + + defp format_reason({:unknown_predicate, name}) do + "Unknown predicate: #{inspect(name)}" + end + + defp format_reason({:empty_predicate_path}) do + "Empty predicate path: at least one predicate required" + end + + defp format_reason(reason) do + "Unknown error: #{inspect(reason)}" + end +end diff --git a/lib/ex_graphblas/helpers.ex b/lib/ex_graphblas/helpers.ex new file mode 100644 index 0000000..53dc7e8 --- /dev/null +++ b/lib/ex_graphblas/helpers.ex @@ -0,0 +1,49 @@ +defmodule GraphBLAS.Helpers do + @moduledoc false + + alias GraphBLAS.Backend.Elixir, as: ElixirBackend + alias GraphBLAS.{Matrix, Scalar, Vector} + + @doc """ + Normalizes result values into `{:ok, val}` tuples. + + Handles the common pattern where functions return either `{:ok, val}`, + bare structs, or `{:error, reason}`. + """ + @spec ok({:ok, term()} | Matrix.t() | Vector.t() | Scalar.t() | {:error, term()} | :ok) :: + {:ok, term()} | {:error, term()} | :ok + def ok({:ok, val}), do: {:ok, val} + def ok(%Matrix{} = m), do: {:ok, m} + def ok(%Vector{} = v), do: {:ok, v} + def ok(%Scalar{} = s), do: {:ok, s} + def ok({:error, _} = err), do: err + def ok(:ok), do: :ok + + @doc """ + Frees backend resources for containers. + + For Elixir backend, this is a no-op. For backends that export + `matrix_free`/`vector_free` (e.g. SuiteSparse), calls the appropriate + free function. For other backends, no-op. + """ + @spec maybe_free(Matrix.t() | Vector.t(), module()) :: :ok + def maybe_free(_container, ElixirBackend), do: :ok + + def maybe_free(%Matrix{} = m, backend) do + if Code.ensure_loaded?(backend) and function_exported?(backend, :matrix_free, 1) do + backend.matrix_free(m) + else + :ok + end + end + + def maybe_free(%Vector{} = v, backend) do + if Code.ensure_loaded?(backend) and function_exported?(backend, :vector_free, 1) do + backend.vector_free(v) + else + :ok + end + end + + def maybe_free(_container, _backend), do: :ok +end diff --git a/lib/ex_graphblas/mask.ex b/lib/ex_graphblas/mask.ex new file mode 100644 index 0000000..2368008 --- /dev/null +++ b/lib/ex_graphblas/mask.ex @@ -0,0 +1,90 @@ +defmodule GraphBLAS.Mask do + @moduledoc """ + Mask type definition for GraphBLAS. + + A **mask** controls which positions in the output of a GraphBLAS + operation are written to. Masks are fundamental to expressing graph + algorithms efficiently: instead of creating a new result container + for every intermediate step, masks allow you to restrict computation + to the structural positions you care about. + + ## How masks work + + When a mask `M` is applied to an operation computing result `C`: + + - **Structural mask** (the default): only positions where `M` has + a stored (structural) entry are written in `C`. + - **Complement mask** (controlled by descriptor): only positions + where `M` does *not* have a stored entry are written in `C`. + + Masks can be applied to matrices or vectors, and the mask type + (matrix or vector) must match the output type of the operation. + + ## Phase 1 scope + + Phase 1 defines the Mask type but does not implement masked operations + in the Reference backend. Full mask support is planned for Phase 4. + + ## Example (conceptual) + + Given a matrix `A` and a mask `M`: + + # Without mask: C = A + B writes to all positions + # With mask M: C = A + B (mask: M) writes only where M is structural + + This saves memory and computation in sparse graph algorithms. + """ + + alias GraphBLAS.{Matrix, Vector} + + @type mask_source :: Matrix.t() | Vector.t() + + @type t :: %__MODULE__{ + source: mask_source(), + complement: boolean() + } + + @enforce_keys [:source] + defstruct [:source, complement: false] + + @doc """ + Creates a mask from a matrix or vector. + + By default, the mask is structural (complement: false), meaning + only positions where the source has stored entries are written. + + ## Examples + + iex> {:ok, m} = GraphBLAS.Matrix.from_coo(3, 3, [{0, 0, 1}], :int64) + iex> mask = GraphBLAS.Mask.new(m) + %GraphBLAS.Mask{source: %GraphBLAS.Matrix{...}, complement: false} + """ + @spec new(mask_source(), keyword()) :: t() + def new(source, opts \\ []) do + complement = Keyword.get(opts, :complement, false) + %__MODULE__{source: source, complement: complement} + end + + @doc """ + Creates a complement mask from a matrix or vector. + + A complement mask inverts the structural positions: only positions + where the source does NOT have stored entries are written. + """ + @spec complement(mask_source()) :: t() + def complement(source) do + %__MODULE__{source: source, complement: true} + end + + @doc """ + Returns whether this mask is a complement mask. + """ + @spec complement?(t()) :: boolean() + def complement?(%__MODULE__{complement: c}), do: c + + @doc """ + Returns the source container (matrix or vector) underlying this mask. + """ + @spec source(t()) :: mask_source() + def source(%__MODULE__{source: s}), do: s +end diff --git a/lib/ex_graphblas/matrix.ex b/lib/ex_graphblas/matrix.ex new file mode 100644 index 0000000..f9918fd --- /dev/null +++ b/lib/ex_graphblas/matrix.ex @@ -0,0 +1,355 @@ +defmodule GraphBLAS.Matrix do + @moduledoc """ + Sparse matrix operations for GraphBLAS. + + A sparse matrix stores only its computed (non-default) values. + In GraphBLAS terminology, the "default" value is typically zero + (though it can be other values depending on the monoid in use). + This representation makes matrix operations on graphs efficient: + most graphs have adjacency matrices that are overwhelmingly sparse. + + ## Construction + + The primary way to create a matrix in Phase 1 is from COO + (coordinate) triples: + + {:ok, m} = GraphBLAS.Matrix.from_coo(4, 4, [ + {0, 1, 1}, {1, 2, 1}, {2, 3, 1}, {3, 0, 1} + ]) + + You can also create an empty matrix and populate it later: + + {:ok, m} = GraphBLAS.Matrix.new(4, 4, :int64) + + ## Struct design + + The `%Matrix{}` struct carries: + - `:shape` -- the `{nrows, ncols}` dimensions + - `:type` -- the scalar type of stored values + - `:data` -- opaque backend-specific data (do not pattern-match on this) + + The `:data` field is intentionally opaque. Different backends store + different representations: the Reference backend uses maps, while a + native backend will store NIF resource references. You must not access + `:data` directly; use the API functions instead. + """ + + alias GraphBLAS.{Backend, Config, Error, Types} + + @type t :: %__MODULE__{ + shape: Types.shape(), + type: Types.scalar_type(), + backend: module(), + data: Backend.backend_data() + } + + @enforce_keys [:shape, :type, :data] + defstruct [:shape, :type, :backend, :data] + + @doc """ + Creates an empty sparse matrix with the given dimensions and scalar type. + + The matrix will have no stored values. This is useful when you need + a container before populating it. + + ## Examples + + iex> {:ok, m} = GraphBLAS.Matrix.new(3, 3, :int64) + iex> GraphBLAS.Matrix.shape(m) + {:ok, {3, 3}} + + iex> GraphBLAS.Matrix.nvals(m) + {:ok, 0} + + """ + @spec new(non_neg_integer(), non_neg_integer(), Types.scalar_type(), Types.opts()) :: + {:ok, t()} | {:error, Error.t()} + def new(nrows, ncols, type \\ :int64, opts \\ []) do + backend = Config.resolve_backend(opts) + backend.matrix_new(nrows, ncols, type, opts) + end + + @doc """ + Creates a sparse matrix from COO (coordinate) triples. + + `entries` is a list of `{row, col, value}` tuples, where `row` and + `col` are zero-based indices. Duplicate entries at the same position + are combined using the additive monoid for the scalar type (integers + are summed, floats are summed, booleans are OR'd). + + ## Examples + + iex> entries = [{0, 1, 1}, {1, 2, 2}, {2, 0, 3}] + iex> {:ok, m} = GraphBLAS.Matrix.from_coo(3, 3, entries, :int64) + iex> GraphBLAS.Matrix.shape(m) + {:ok, {3, 3}} + + iex> GraphBLAS.Matrix.nvals(m) + {:ok, 3} + + ## Errors + + - `{:error, {:index_out_of_bounds, ...}}` if any index exceeds the dimensions + - `{:error, {:dimension_mismatch, ...}}` if dimensions are invalid + + """ + @spec from_coo( + non_neg_integer(), + non_neg_integer(), + [Types.coo_entry()], + Types.scalar_type(), + Types.opts() + ) :: + {:ok, t()} | {:error, Error.t()} + def from_coo(nrows, ncols, entries, type \\ :int64, opts \\ []) do + backend = Config.resolve_backend(opts) + backend.matrix_from_coo(nrows, ncols, entries, type, opts) + end + + @doc """ + Returns the `{nrows, ncols}` dimensions of the matrix. + """ + @spec shape(t()) :: {:ok, Types.shape()} | {:error, Error.t()} + def shape(%__MODULE__{} = matrix) do + {:ok, matrix.shape} + end + + @doc """ + Returns the scalar type of the matrix's stored values. + """ + @spec type(t()) :: {:ok, Types.scalar_type()} | {:error, Error.t()} + def type(%__MODULE__{} = matrix) do + {:ok, matrix.type} + end + + @doc """ + Returns the number of stored (non-default) values in the matrix. + + In a sparse matrix, this is the count of explicitly stored entries, + not the product of dimensions. + + Dispatches to the backend that created the matrix. + """ + @spec nvals(t()) :: {:ok, non_neg_integer()} | {:error, Error.t()} + def nvals(%__MODULE__{backend: nil} = matrix), + do: nvals(%{matrix | backend: Config.default_backend()}) + + def nvals(%__MODULE__{backend: backend} = matrix) do + backend.matrix_nvals(matrix) + end + + @doc """ + Extracts the stored entries as COO triples `{row, col, value}`. + + Returns a sorted list of the non-default entries. The sort order is + row-major (row first, then column). + + Dispatches to the backend that created the matrix. + """ + @spec to_coo(t()) :: {:ok, [Types.coo_entry()]} | {:error, Error.t()} + def to_coo(%__MODULE__{backend: nil} = matrix), + do: to_coo(%{matrix | backend: Config.default_backend()}) + + def to_coo(%__MODULE__{backend: backend} = matrix) do + backend.matrix_to_coo(matrix) + end + + @doc """ + Multiplies two matrices using the given semiring. + + `semiring` can be an atom naming a built-in semiring (e.g., + `:plus_times`, `:max_plus`, `:min_plus`) or a `%Semiring{}` struct. + + The result `C = A * B` has shape `{nrows(A), ncols(B)}` and type + determined by the semiring's output type. + + ## Examples + + iex> {:ok, a} = GraphBLAS.Matrix.from_coo(2, 3, [{0, 1, 1}, {1, 2, 1}], :int64) + iex> {:ok, b} = GraphBLAS.Matrix.from_coo(3, 2, [{1, 0, 1}, {2, 1, 1}], :int64) + iex> {:ok, c} = GraphBLAS.Matrix.mxm(a, b, :plus_times) + iex> GraphBLAS.Matrix.shape(c) + {:ok, {2, 2}} + + ## Errors + + - `{:error, {:dimension_mismatch, ...}}` if `ncols(A) != nrows(B)` + + """ + @spec mxm(t(), t(), atom() | GraphBLAS.Semiring.t(), Types.opts()) :: + {:ok, t()} | {:error, Error.t()} + def mxm(%__MODULE__{} = a, %__MODULE__{} = b, semiring \\ :plus_times, opts \\ []) do + backend = Config.resolve_backend(opts) + backend.matrix_mxm(a, b, semiring, opts) + end + + @doc """ + Multiplies a matrix by a vector using the given semiring. + + The result vector `v = A * x` has length `nrows(A)`. + """ + @spec mxv(t(), GraphBLAS.Vector.t(), atom() | GraphBLAS.Semiring.t(), Types.opts()) :: + {:ok, GraphBLAS.Vector.t()} | {:error, Error.t()} + def mxv( + %__MODULE__{} = matrix, + %GraphBLAS.Vector{} = vector, + semiring \\ :plus_times, + opts \\ [] + ) do + backend = Config.resolve_backend(opts) + backend.matrix_mxv(matrix, vector, semiring, opts) + end + + @doc """ + Element-wise addition of two matrices using the given monoid. + + The result has the union of the structural nonzero positions, with + overlapping positions combined using the monoid's binary operator. + """ + @spec ewise_add(t(), t(), atom() | GraphBLAS.Monoid.t(), Types.opts()) :: + {:ok, t()} | {:error, Error.t()} + def ewise_add(%__MODULE__{} = a, %__MODULE__{} = b, monoid \\ :plus, opts \\ []) do + backend = Config.resolve_backend(opts) + backend.matrix_ewise_add(a, b, monoid, opts) + end + + @doc """ + Element-wise multiplication of two matrices using the given monoid. + + The result has only positions that are structural nonzeros in both + operands, with values combined using the monoid's binary operator. + """ + @spec ewise_mult(t(), t(), atom() | GraphBLAS.Monoid.t(), Types.opts()) :: + {:ok, t()} | {:error, Error.t()} + def ewise_mult(%__MODULE__{} = a, %__MODULE__{} = b, monoid \\ :times, opts \\ []) do + backend = Config.resolve_backend(opts) + backend.matrix_ewise_mult(a, b, monoid, opts) + end + + @doc """ + Reduces a matrix to a vector along the row dimension using the given monoid. + + Each element of the result vector is the monoid reduction of the + corresponding row of the matrix. This is equivalent to multiplying + by a vector of all ones under the given monoid. + """ + @spec reduce(t(), atom() | GraphBLAS.Monoid.t(), Types.opts()) :: + {:ok, GraphBLAS.Vector.t()} | {:error, Error.t()} + def reduce(%__MODULE__{} = matrix, monoid \\ :plus, opts \\ []) do + backend = Config.resolve_backend(opts) + backend.matrix_reduce(matrix, monoid, opts) + end + + @doc """ + Returns the transpose of the matrix. + + The transpose matrix `A^T` has `shape(A^T) = (ncols(A), nrows(A))` + and `A^T[j, i] = A[i, j]` for all stored positions. + """ + @spec transpose(t(), Types.opts()) :: {:ok, t()} | {:error, Error.t()} + def transpose(%__MODULE__{} = matrix, opts \\ []) do + backend = Config.resolve_backend(opts) + backend.matrix_transpose(matrix, opts) + end + + @doc """ + Converts a sparse matrix to a dense list-of-lists representation. + + Returns `{:ok, rows}` where `rows` is a list of rows, each row being + a list of values. Positions that are structural zeros in the sparse + representation are filled with the default value for the matrix's type. + + This is a debugging and inspection helper. Do not use on large matrices. + + ## Examples + + iex> {:ok, m} = GraphBLAS.Matrix.from_coo(2, 2, [{0, 0, 5}, {1, 1, 7}], :int64) + iex> {:ok, dense} = GraphBLAS.Matrix.to_dense(m) + iex> dense + [[5, 0], [0, 7]] + + """ + @spec to_dense(t()) :: {:ok, [[term()]]} | {:error, Error.t()} + def to_dense(%__MODULE__{backend: nil} = matrix), + do: to_dense(%{matrix | backend: Config.default_backend()}) + + def to_dense(%__MODULE__{backend: backend} = matrix) do + backend.matrix_to_dense(matrix) + end + + @doc """ + Sets the value at position (row, col) in the matrix, overwriting any existing value. + + Note: This function dispatches to the backend stored in the matrix's `backend` field. + The `opts` parameter is accepted for API consistency but is currently unused. + + ## Examples + + iex> {:ok, m} = GraphBLAS.Matrix.from_coo(2, 2, [{0, 0, 1}], :int64) + iex> {:ok, m} = GraphBLAS.Matrix.set(m, 1, 1, 5) + iex> {:ok, 5} = GraphBLAS.Matrix.extract(m, 1, 1) + + """ + @spec set(t(), non_neg_integer(), non_neg_integer(), term(), Types.opts()) :: + {:ok, t()} | {:error, Error.t()} + def set(matrix, row, col, value, opts \\ []) + + def set(%__MODULE__{backend: nil} = matrix, row, col, value, opts), + do: set(%{matrix | backend: Config.default_backend()}, row, col, value, opts) + + def set(%__MODULE__{backend: backend} = matrix, row, col, value, _opts) do + backend.matrix_set(matrix, row, col, value) + end + + @doc """ + Extracts the value at position (row, col) from the matrix. + + Returns the default value (0, 0.0, or false) for structural zeros. + + Note: This function dispatches to the backend stored in the matrix's `backend` field. + The `opts` parameter is accepted for API consistency but is currently unused. + + ## Examples + + iex> {:ok, m} = GraphBLAS.Matrix.from_coo(2, 2, [{0, 0, 42}], :int64) + iex> {:ok, 42} = GraphBLAS.Matrix.extract(m, 0, 0) + iex> {:ok, 0} = GraphBLAS.Matrix.extract(m, 1, 1) + + """ + @spec extract(t(), non_neg_integer(), non_neg_integer(), Types.opts()) :: + {:ok, term()} | {:error, Error.t()} + def extract(matrix, row, col, opts \\ []) + + def extract(%__MODULE__{backend: nil} = matrix, row, col, opts), + do: extract(%{matrix | backend: Config.default_backend()}, row, col, opts) + + def extract(%__MODULE__{backend: backend} = matrix, row, col, _opts) do + backend.matrix_extract(matrix, row, col) + end + + @doc """ + Creates a deep copy of the matrix. + + The copy is independent — modifying it does not affect the original. + The copy uses the same backend as the original matrix. + + Note: The `opts` parameter is accepted for API consistency but is currently unused. + To convert a matrix to a different backend, use `to_coo/1` followed by `from_coo/5`. + + ## Examples + + iex> {:ok, m} = GraphBLAS.Matrix.from_coo(2, 2, [{0, 0, 1}], :int64) + iex> {:ok, copy} = GraphBLAS.Matrix.dup(m) + iex> {:ok, 1} = GraphBLAS.Matrix.extract(m, 0, 0) + + """ + @spec dup(t(), Types.opts()) :: {:ok, t()} | {:error, Error.t()} + def dup(matrix, opts \\ []) + + def dup(%__MODULE__{backend: nil} = matrix, _opts), + do: dup(%{matrix | backend: Config.default_backend()}, []) + + def dup(%__MODULE__{backend: backend} = matrix, _opts) do + backend.matrix_dup(matrix) + end +end diff --git a/lib/ex_graphblas/monoid.ex b/lib/ex_graphblas/monoid.ex new file mode 100644 index 0000000..42b4c7d --- /dev/null +++ b/lib/ex_graphblas/monoid.ex @@ -0,0 +1,146 @@ +defmodule GraphBLAS.Monoid do + @moduledoc """ + Monoid definitions for GraphBLAS. + + A **monoid** `(S, ⊕, 𝟎)` consists of: + + - A set `S` of values + - An associative binary operator `⊕` + - An identity element `𝟎` such that `a ⊕ 𝟎 = 𝟎 ⊕ a = a` + + Monoids are used in GraphBLAS for: + - Element-wise addition (`ewise_add`) combines overlapping entries + - Reduction (`reduce`) collapses a vector or matrix to a scalar + - Duplicate entry resolution during matrix/vector construction + + ## Built-in monoids + + | Name | Operator | Identity | Use | + |-----------|----------|----------|----------------------------------| + | `:plus` | `a + b` | `0` | Sum reduction, duplicate merging | + | `:times` | `a * b` | `1` | Product reduction | + | `:min` | `min(a,b)` | max(S) | Minimum reduction, shortest path | + | `:max` | `max(a,b)` | min(S) | Maximum reduction, critical path | + | `:land` | `a ∧ b` | `true` | Logical AND | + | `:lor` | `a ∨ b` | `false` | Logical OR | + | `:lxor` | `a ⊻ b` | `false` | Logical XOR | + + Each built-in monoid has typed variants (e.g., `:plus` for integers, + `:plus_fp64` for doubles) to avoid implicit type ambiguity. + """ + + alias GraphBLAS.Types + + @type t :: %__MODULE__{ + name: atom(), + operator: atom() | (term(), term() -> term()), + identity: term(), + type: Types.scalar_type() + } + + @enforce_keys [:name, :operator, :identity, :type] + defstruct [:name, :operator, :identity, :type] + + @doc """ + Creates a custom monoid struct. + + Prefer using built-in monoids by atom name when possible. + """ + @spec new(keyword()) :: t() + def new(opts) do + name = Keyword.fetch!(opts, :name) + operator = Keyword.fetch!(opts, :operator) + identity = Keyword.fetch!(opts, :identity) + type = Keyword.fetch!(opts, :type) + + %__MODULE__{ + name: name, + operator: operator, + identity: identity, + type: type + } + end + + @doc """ + Resolves a monoid name (atom) to its struct, or passes through + an existing `%Monoid{}` struct. + """ + @spec resolve(atom() | t()) :: {:ok, t()} | {:error, {:unknown_monoid, atom()}} + def resolve(%__MODULE__{} = m), do: {:ok, m} + + def resolve(name) when is_atom(name) do + case builtin(name) do + nil -> {:error, {:unknown_monoid, name}} + m -> {:ok, m} + end + end + + @doc """ + Returns the built-in monoid struct for the given name, or nil if unknown. + """ + @spec builtin(atom()) :: t() | nil + def builtin(:plus), do: new(name: :plus, operator: :plus, identity: 0, type: :int64) + def builtin(:plus_fp32), do: new(name: :plus_fp32, operator: :plus, identity: 0.0, type: :fp32) + def builtin(:plus_fp64), do: new(name: :plus_fp64, operator: :plus, identity: 0.0, type: :fp64) + def builtin(:times), do: new(name: :times, operator: :times, identity: 1, type: :int64) + + def builtin(:times_fp32), + do: new(name: :times_fp32, operator: :times, identity: 1.0, type: :fp32) + + def builtin(:times_fp64), + do: new(name: :times_fp64, operator: :times, identity: 1.0, type: :fp64) + + def builtin(:min), + do: + new( + name: :min, + operator: :min, + identity: max_int(GraphBLAS.Scalar.new(:int64, 0), :int64), + type: :int64 + ) + + def builtin(:min_fp64), + do: new(name: :min_fp64, operator: :min, identity: :infinity, type: :fp64) + + def builtin(:max), + do: + new( + name: :max, + operator: :max, + identity: min_int(GraphBLAS.Scalar.new(:int64, 0), :int64), + type: :int64 + ) + + def builtin(:max_fp64), + do: new(name: :max_fp64, operator: :max, identity: :neg_infinity, type: :fp64) + + def builtin(:land), do: new(name: :land, operator: :land, identity: true, type: :bool) + def builtin(:lor), do: new(name: :lor, operator: :lor, identity: false, type: :bool) + def builtin(:lxor), do: new(name: :lxor, operator: :lxor, identity: false, type: :bool) + def builtin(_), do: nil + + defp max_int(_, :int64), do: 9_223_372_036_854_775_807 + defp min_int(_, :int64), do: -9_223_372_036_854_775_808 + + @doc """ + Returns the list of all built-in monoid name atoms. + """ + @spec builtin_names() :: [atom()] + def builtin_names do + [ + :plus, + :plus_fp32, + :plus_fp64, + :times, + :times_fp32, + :times_fp64, + :min, + :min_fp64, + :max, + :max_fp64, + :land, + :lor, + :lxor + ] + end +end diff --git a/lib/ex_graphblas/nif/zig_stub.ex b/lib/ex_graphblas/nif/zig_stub.ex new file mode 100644 index 0000000..712dfe8 --- /dev/null +++ b/lib/ex_graphblas/nif/zig_stub.ex @@ -0,0 +1,17 @@ +defmodule GraphBLAS.Nif.ZigStub do + @moduledoc """ + Minimal Zig NIF module for CI verification. + + This module uses `use Zig` directly (no ZiglerPrecompiled, no C libraries) + to verify that Zigler compiles and loads NIFs correctly on a given + Elixir/OTP combination. + + It is not a computation backend. Use `GraphBLAS.Backend.ZigStub` which + wraps this module and implements the `GraphBLAS.Backend` behaviour. + """ + + use Zig, + otp_app: :ex_graphblas, + zig_code_path: "../../../priv/native/zig_stub/zig_stub.zig", + nifs: [:add_one, :ping] +end diff --git a/lib/ex_graphblas/relation.ex b/lib/ex_graphblas/relation.ex new file mode 100644 index 0000000..f505483 --- /dev/null +++ b/lib/ex_graphblas/relation.ex @@ -0,0 +1,287 @@ +defmodule GraphBLAS.Relation do + @moduledoc """ + Knowledge graph and multi-relation model for GraphBLAS. + + A `Relation` wraps a map of predicate names to adjacency matrices, + all sharing the same entity space size. This models RDF-style + (subject, predicate, object) triples as predicate-specific + sparse matrices. + + ## Core concept + + Each predicate maps to its own adjacency matrix: + + A_pred[i][j] = 1 if triple (entity_i, pred, entity_j) exists + + Multi-hop traversal is expressed as chained `mxm`: + + "X follows Y AND Y likes Z" = A_follows mxm A_likes + + The semiring determines query semantics: `:lor_land` for existence, + `:plus_times` for path counting, `:plus_min` for shortest path. + """ + + alias GraphBLAS.{Config, Error, Helpers, Matrix} + + import Helpers, only: [ok: 1] + + @type t :: %__MODULE__{ + size: non_neg_integer(), + predicates: %{atom() => Matrix.t()} + } + + @enforce_keys [:size] + defstruct [:size, predicates: %{}] + + @doc """ + Creates an empty relation with the given entity space size. + """ + @spec new(non_neg_integer()) :: t() + def new(size) do + %__MODULE__{size: size, predicates: %{}} + end + + @doc """ + Adds (subject, object) pairs for a predicate as bool entries. + + Creates or extends the adjacency matrix for the given predicate. + Each pair `{s, o}` sets `A_pred[s][o] = true`. + + When extending an existing predicate, the backend is inherited from the existing matrix. + When creating a new predicate, the backend is taken from `opts` or defaults to the configured backend. + """ + @spec add_triples(t(), atom(), [{non_neg_integer(), non_neg_integer()}], keyword()) :: + {:ok, t()} | {:error, Error.t()} + def add_triples(%__MODULE__{size: n, predicates: preds} = rel, predicate, pairs, opts \\ []) do + entries = Enum.map(pairs, fn {s, o} -> {s, o, true} end) + + case Map.fetch(preds, predicate) do + {:ok, existing} -> + backend = existing.backend || Config.default_backend() + + with {:ok, new_entries} <- ok(Matrix.to_coo(existing)), + combined <- new_entries ++ entries, + {:ok, updated} <- Matrix.from_coo(n, n, combined, :bool, backend: backend) do + {:ok, %{rel | predicates: Map.put(preds, predicate, updated)}} + end + + :error -> + case Matrix.from_coo(n, n, entries, :bool, opts) do + {:ok, mat} -> {:ok, %{rel | predicates: Map.put(preds, predicate, mat)}} + {:error, _} = err -> err + end + end + end + + @doc """ + Adds weighted (subject, object, value) triples for a predicate. + + Creates or extends the adjacency matrix with the given scalar type. + + When extending an existing predicate, the backend is inherited from the existing matrix. + When creating a new predicate, the backend is taken from `opts` or defaults to the configured backend. + """ + @spec add_weighted_triples( + t(), + atom(), + [{non_neg_integer(), non_neg_integer(), number()}], + atom(), + keyword() + ) :: + {:ok, t()} | {:error, Error.t()} + def add_weighted_triples( + %__MODULE__{size: n, predicates: preds} = rel, + predicate, + triples, + type, + opts \\ [] + ) do + case Map.fetch(preds, predicate) do + {:ok, existing} -> + backend = existing.backend || Config.default_backend() + + with {:ok, new_entries} <- ok(Matrix.to_coo(existing)), + combined <- new_entries ++ triples, + {:ok, updated} <- Matrix.from_coo(n, n, combined, type, backend: backend) do + {:ok, %{rel | predicates: Map.put(preds, predicate, updated)}} + end + + :error -> + case Matrix.from_coo(n, n, triples, type, opts) do + {:ok, mat} -> {:ok, %{rel | predicates: Map.put(preds, predicate, mat)}} + {:error, _} = err -> err + end + end + end + + @doc """ + Returns the list of predicate names in this relation. + """ + @spec predicates(t()) :: [atom()] + def predicates(%__MODULE__{predicates: preds}) do + Map.keys(preds) + end + + @doc """ + Returns the adjacency matrix for a predicate. + + Returns `{:error, {:unknown_predicate, name}}` if the predicate + does not exist in this relation. + """ + @spec matrix(t(), atom()) :: {:ok, Matrix.t()} | {:error, Error.t()} + def matrix(%__MODULE__{predicates: preds}, predicate) do + case Map.fetch(preds, predicate) do + {:ok, mat} -> {:ok, mat} + :error -> Error.error({:unknown_predicate, predicate}) + end + end + + @doc """ + Multi-hop traversal across predicates via chained mxm. + + `predicate_path` is an ordered list of predicates. The traversal + chains their adjacency matrices: `A_p1 mxm A_p2 mxm ... mxm A_pk`. + + The semiring determines query semantics: + - `:lor_land` -- does a path exist? (boolean reachability) + - `:plus_times` -- how many paths exist? (path counting) + - `:plus_min` -- shortest path distance + - `:max_min` -- widest path / bottleneck capacity + """ + @spec traverse(t(), [atom()], atom() | GraphBLAS.Semiring.t(), keyword()) :: + {:ok, Matrix.t()} | {:error, Error.t()} + def traverse(rel, predicate_path, semiring, opts \\ []) + + def traverse(_rel, [], _semiring, _opts), do: Error.error({:empty_predicate_path}) + + def traverse(%__MODULE__{} = rel, [p], semiring, opts) do + with {:ok, mat} <- matrix(rel, p) do + traverse_single(mat, semiring, opts) + end + end + + def traverse(%__MODULE__{} = rel, [p | rest], semiring, opts) do + with {:ok, first} <- matrix(rel, p), + {:ok, rest_mats} <- collect_path_mats(rel, rest) do + needs_int = + semiring in [ + :plus_times, + :plus_times_fp64, + :plus_min, + :plus_min_fp64, + :max_plus, + :max_plus_fp64, + :min_plus, + :min_plus_fp64 + ] + + traverse_chain([first | rest_mats], semiring, opts, needs_int) + end + end + + defp traverse_single(mat, semiring, opts) do + backend = Config.resolve_backend(opts) + + case semiring do + :lor_land -> + {:ok, mat} + + _ -> + {:ok, coo} = ok(Matrix.to_coo(mat)) + + case coo do + [] -> Matrix.new(elem(mat.shape, 0), elem(mat.shape, 1), :int64, backend: backend) + _ -> {:ok, mat} + end + end + end + + defp collect_path_mats(rel, predicates) do + Enum.reduce_while(predicates, {:ok, []}, fn p, {:ok, acc} -> + case matrix(rel, p) do + {:ok, mat} -> {:cont, {:ok, acc ++ [mat]}} + {:error, _} = err -> {:halt, err} + end + end) + end + + defp traverse_chain([acc], _semiring, _opts, _needs_int), do: {:ok, acc} + + defp traverse_chain([a, b | rest], semiring, opts, needs_int) do + backend = Config.resolve_backend(opts) + + with {:ok, a_mx} <- maybe_to_int64(a, needs_int, backend), + {:ok, b_mx} <- maybe_to_int64(b, needs_int, backend), + {:ok, result} <- ok(Matrix.mxm(a_mx, b_mx, semiring, backend: backend)) do + cleanup_intermediates(a, b, a_mx, b_mx, backend) + traverse_chain([result | rest], semiring, opts, needs_int) + end + end + + defp cleanup_intermediates(a, b, a_mx, b_mx, backend) do + maybe_free_intermediate(a, backend) + maybe_free_intermediate(b, backend) + if a_mx != a, do: maybe_free_intermediate(a_mx, backend) + if b_mx != b, do: maybe_free_intermediate(b_mx, backend) + end + + defp maybe_to_int64(%Matrix{type: :bool} = m, true, backend) do + with {:ok, coo} <- ok(Matrix.to_coo(m)) do + int_coo = Enum.map(coo, fn {r, c, true} -> {r, c, 1} end) + {nrows, ncols} = m.shape + Matrix.from_coo(nrows, ncols, int_coo, :int64, backend: backend) + end + end + + defp maybe_to_int64(%Matrix{} = m, _needs_int, _backend), do: {:ok, m} + + @doc """ + Transitive closure of a single predicate. + + Computes `I + A + A^2 + A^3 + ...` until convergence using + the `fixed_point` primitive. + + The semiring determines closure semantics: + - `:lor_land` -- reachability closure (who can eventually reach whom?) + - `:plus_min` -- shortest path closure + - `:plus_times` -- all-paths counting closure + """ + @spec closure(t(), atom(), atom() | GraphBLAS.Semiring.t(), keyword()) :: + {:ok, Matrix.t()} | {:error, Error.t()} + def closure(%__MODULE__{predicates: preds} = _rel, predicate, semiring, opts \\ []) do + case Map.fetch(preds, predicate) do + {:ok, matrix} -> + backend = Config.resolve_backend(opts) + + with {:ok, sr} <- GraphBLAS.Semiring.resolve(semiring) do + closure_loop(matrix, matrix, sr, backend, 0) + end + + :error -> + Error.error({:unknown_predicate, predicate}) + end + end + + defp closure_loop(_a, p, _semiring, _backend, iter) when iter >= 100, do: {:ok, p} + + defp closure_loop(a, p, semiring, backend, iter) do + add_monoid = semiring.add + + with {:ok, new_paths} <- Matrix.mxm(p, a, semiring.name, backend: backend), + {:ok, p_new} <- Matrix.ewise_add(p, new_paths, add_monoid, backend: backend), + {:ok, old_coo} <- Matrix.to_coo(p), + {:ok, new_coo} <- Matrix.to_coo(p_new) do + maybe_free_intermediate(new_paths, backend) + + if Enum.sort(old_coo) == Enum.sort(new_coo) do + maybe_free_intermediate(p, backend) + {:ok, p_new} + else + maybe_free_intermediate(p, backend) + closure_loop(a, p_new, semiring, backend, iter + 1) + end + end + end + + defp maybe_free_intermediate(container, backend), do: Helpers.maybe_free(container, backend) +end diff --git a/lib/ex_graphblas/scalar.ex b/lib/ex_graphblas/scalar.ex new file mode 100644 index 0000000..d2d0348 --- /dev/null +++ b/lib/ex_graphblas/scalar.ex @@ -0,0 +1,109 @@ +defmodule GraphBLAS.Scalar do + @moduledoc """ + A scalar value with an associated GraphBLAS type. + + Scalars are the atomic elements of GraphBLAS computations. They appear + as the result of reduction operations (e.g., reducing a vector to its + sum) and can be used as implicit identities in some contexts. + + ## Design note + + In GraphBLAS C, scalars are first-class containers. In GraphBLAS, + we represent them as a thin struct that pairs a value with its type. + This avoids ambiguous type inference (e.g., the integer `0` could be + `:int64` or `:int8`) while keeping the representation trivially simple. + + For most operations, you do not need to construct scalars directly; + they are produced by reductions and consumed internally. + """ + + alias GraphBLAS.Types + + @type t :: %__MODULE__{ + type: Types.scalar_type(), + value: number() | boolean() + } + + @enforce_keys [:type, :value] + defstruct [:type, :value] + + @doc """ + Creates a scalar with the given type and value. + + ## Examples + + iex> GraphBLAS.Scalar.new(:int64, 42) + %GraphBLAS.Scalar{type: :int64, value: 42} + + iex> GraphBLAS.Scalar.new(:fp64, 3.14) + %GraphBLAS.Scalar{type: :fp64, value: 3.14} + + iex> GraphBLAS.Scalar.new(:bool, true) + %GraphBLAS.Scalar{type: :bool, value: true} + """ + @spec new(Types.scalar_type(), number() | boolean()) :: t() + def new(type, value) do + %__MODULE__{type: type, value: value} + end + + @doc """ + Returns the value wrapped by the scalar. + + ## Examples + + iex> GraphBLAS.Scalar.new(:int64, 42) |> GraphBLAS.Scalar.value() + 42 + """ + @spec value(t()) :: number() | boolean() + def value(%__MODULE__{value: v}), do: v + + @doc """ + Returns the scalar type of the wrapped value. + + ## Examples + + iex> GraphBLAS.Scalar.new(:fp64, 1.0) |> GraphBLAS.Scalar.type() + :fp64 + """ + @spec type(t()) :: Types.scalar_type() + def type(%__MODULE__{type: t}), do: t + + @doc """ + Returns the zero (identity) value for the given monoid and scalar type. + + The zero/identity is the neutral element of the monoid's binary operator: + - For `:plus` monoids: 0 (integer or float) + - For `:times` monoids: 1 (integer or float) + - For `:min` monoids: the maximum representable value of the type + - For `:max` monoids: the minimum representable value of the type + - For `:land` (logical and): true + - For `:lor` (logical or): false + """ + @spec zero(atom(), Types.scalar_type()) :: t() + def zero(monoid, type) + + def zero(:plus, type), do: new(type, numeric_zero(type)) + def zero(:times, type), do: new(type, numeric_one(type)) + def zero(:min, type), do: new(type, type_max(type)) + def zero(:max, type), do: new(type, type_min(type)) + def zero(:land, :bool), do: new(:bool, true) + def zero(:lor, :bool), do: new(:bool, false) + + defp numeric_zero(:bool), do: false + defp numeric_zero(:fp32), do: 0.0 + defp numeric_zero(:fp64), do: 0.0 + defp numeric_zero(_int_type), do: 0 + + defp numeric_one(:bool), do: true + defp numeric_one(:fp32), do: 1.0 + defp numeric_one(:fp64), do: 1.0 + defp numeric_one(_int_type), do: 1 + + defp type_max(:int64), do: 9_223_372_036_854_775_807 + defp type_max(:fp64), do: :math.pow(2, 1023) * (2 - :math.pow(2, -52)) + defp type_max(:bool), do: true + + defp type_min(:int64), do: -9_223_372_036_854_775_808 + defp type_min(:fp64), do: -:math.pow(2, 1023) * (2 - :math.pow(2, -52)) + defp type_min(:bool), do: false +end diff --git a/lib/ex_graphblas/semiring.ex b/lib/ex_graphblas/semiring.ex new file mode 100644 index 0000000..bbdef7f --- /dev/null +++ b/lib/ex_graphblas/semiring.ex @@ -0,0 +1,265 @@ +defmodule GraphBLAS.Semiring do + @moduledoc """ + Semiring definitions for GraphBLAS. + + A **semiring** is the fundamental algebraic structure in GraphBLAS. + It generalizes ring multiplication and addition to operate on sparse + data, and it is what makes graph algorithms expressible as linear algebra. + + ## Mathematical definition + + A semiring `(S, ⊗, ⊕, 𝟎, 𝟏)` consists of: + + - A set `S` of values + - A multiplicative (binary) operator `⊗` with identity `𝟏` + - An additive (commutative, associative) operator `⊕` with identity `𝟎` + - The property that `⊗` distributes over `⊕` + - `𝟎` is an annihilator for `⊗`: `a ⊗ 𝟎 = 𝟎 ⊗ a = 𝟎` + + In GraphBLAS matrix multiplication `C = A ⊗.⊕ B`, the semiring tells us: + - `⊗` is the "multiplication" applied to matching pairs from A and B + - `⊕` is the "addition" used to combine the products for each output entry + + ## Built-in semirings + + Phase 1 provides these commonly used semirings: + + | Name | ⊗ (multiply) | ⊕ (add) | Use | + |-------------|--------------|-----------|--------------------------------| + | `:plus_times` | `a * b` | `a + b` | Standard matrix multiplication | + | `:plus_min` | `min(a, b)` | `a + b` | Shortest path algorithms | + | `:plus_min2` | `min(a, b)` | `min(a, b)`| Minimum-weight path | + | `:max_plus` | `a + b` | `max(a,b)`| Longest path / critical path | + | `:max_min` | `min(a, b)` | `max(a,b)`| Reachability with capacity | + | `:lor_land` | `a ∧ b` | `a ∨ b` | Boolean adjacency (BFS) | + + ## Custom semirings + + You can define custom semirings using `%Semiring{}` structs when the + built-in set does not cover your needs. The `multiply` and `add` + fields should be atoms naming a `BinaryOp` or functions of arity 2. + """ + + alias GraphBLAS.Types + + @type t :: %__MODULE__{ + name: atom(), + multiply: atom() | (term(), term() -> term()), + add: atom() | (term(), term() -> term()), + add_identity: term(), + multiply_identity: term(), + type: Types.scalar_type() + } + + @enforce_keys [:name, :multiply, :add, :add_identity, :multiply_identity, :type] + defstruct [:name, :multiply, :add, :add_identity, :multiply_identity, :type] + + @doc """ + Creates a custom semiring struct. + + Prefer using built-in semirings by atom name when possible. Custom + semirings are for cases where the built-in set does not suffice. + + ## Parameters + + - `:name` -- an atom identifying this semiring + - `:multiply` -- the ⊗ operator (atom naming a BinaryOp, or a function) + - `:add` -- the ⊕ operator (atom naming a BinaryOp or Monoid, or a function) + - `:add_identity` -- the 𝟎 identity element for the add operator + - `:multiply_identity` -- the 𝟏 identity element for the multiply operator + - `:type` -- the scalar type this semiring operates on + """ + @spec new(keyword()) :: t() + def new(opts) do + name = Keyword.fetch!(opts, :name) + multiply = Keyword.fetch!(opts, :multiply) + add = Keyword.fetch!(opts, :add) + add_identity = Keyword.fetch!(opts, :add_identity) + multiply_identity = Keyword.fetch!(opts, :multiply_identity) + type = Keyword.fetch!(opts, :type) + + %__MODULE__{ + name: name, + multiply: multiply, + add: add, + add_identity: add_identity, + multiply_identity: multiply_identity, + type: type + } + end + + @doc """ + Resolves a semiring name (atom) to its struct, or passes through + an existing `%Semiring{}` struct. + + This is the canonical way to normalize a semiring argument from API calls. + """ + @spec resolve(atom() | t()) :: {:ok, t()} | {:error, {:unknown_semiring, atom()}} + def resolve(%__MODULE__{} = s), do: {:ok, s} + + def resolve(name) when is_atom(name) do + case builtin(name) do + nil -> {:error, {:unknown_semiring, name}} + s -> {:ok, s} + end + end + + @doc """ + Returns the built-in semiring struct for the given name, or nil if unknown. + """ + @spec builtin(atom()) :: t() | nil + def builtin(:plus_times), + do: + new( + name: :plus_times, + multiply: :times, + add: :plus, + add_identity: 0, + multiply_identity: 1, + type: :int64 + ) + + def builtin(:plus_times_fp64), + do: + new( + name: :plus_times_fp64, + multiply: :times, + add: :plus, + add_identity: 0.0, + multiply_identity: 1.0, + type: :fp64 + ) + + def builtin(:plus_min), + do: + new( + name: :plus_min, + multiply: :min, + add: :plus, + add_identity: 0, + multiply_identity: nil, + type: :int64 + ) + + def builtin(:plus_min_fp64), + do: + new( + name: :plus_min_fp64, + multiply: :min, + add: :plus, + add_identity: 0.0, + multiply_identity: nil, + type: :fp64 + ) + + def builtin(:max_plus), + do: + new( + name: :max_plus, + multiply: :plus, + add: :max, + add_identity: nil, + multiply_identity: 0, + type: :int64 + ) + + def builtin(:max_plus_fp64), + do: + new( + name: :max_plus_fp64, + multiply: :plus, + add: :max, + add_identity: nil, + multiply_identity: 0.0, + type: :fp64 + ) + + def builtin(:max_min), + do: + new( + name: :max_min, + multiply: :min, + add: :max, + add_identity: nil, + multiply_identity: nil, + type: :int64 + ) + + def builtin(:max_min_fp64), + do: + new( + name: :max_min_fp64, + multiply: :min, + add: :max, + add_identity: nil, + multiply_identity: nil, + type: :fp64 + ) + + def builtin(:lor_land), + do: + new( + name: :lor_land, + multiply: :land, + add: :lor, + add_identity: false, + multiply_identity: true, + type: :bool + ) + + def builtin(:land_lor), + do: + new( + name: :land_lor, + multiply: :lor, + add: :land, + add_identity: true, + multiply_identity: false, + type: :bool + ) + + def builtin(:min_plus), + do: + new( + name: :min_plus, + multiply: :plus, + add: :min, + add_identity: nil, + multiply_identity: 0, + type: :int64 + ) + + def builtin(:min_plus_fp64), + do: + new( + name: :min_plus_fp64, + multiply: :plus, + add: :min, + add_identity: nil, + multiply_identity: 0.0, + type: :fp64 + ) + + def builtin(_), do: nil + + @doc """ + Returns the list of all built-in semiring name atoms. + """ + @spec builtin_names() :: [atom()] + def builtin_names do + [ + :plus_times, + :plus_times_fp64, + :plus_min, + :plus_min_fp64, + :max_plus, + :max_plus_fp64, + :max_min, + :max_min_fp64, + :lor_land, + :land_lor, + :min_plus, + :min_plus_fp64 + ] + end +end diff --git a/lib/ex_graphblas/types.ex b/lib/ex_graphblas/types.ex new file mode 100644 index 0000000..061330c --- /dev/null +++ b/lib/ex_graphblas/types.ex @@ -0,0 +1,179 @@ +defmodule GraphBLAS.Types do + @moduledoc """ + Shared type definitions for GraphBLAS. + + This module centralizes all public typespecs so they can be referenced + consistently across the library. The types here define the vocabulary + of the GraphBLAS algebraic model as it appears at the Elixir boundary. + + ## Scalar types + + GraphBLAS operates on typed containers. The supported scalar types + correspond to the numeric kinds needed for sparse linear algebra: + + - `:bool` -- boolean values (0 or 1, structural or valued) + - `:int8` -- signed 8-bit integer + - `:int16` -- signed 16-bit integer + - `:int32` -- signed 32-bit integer + - `:int64` -- signed 64-bit integer (default for integer data) + - `:uint8` -- unsigned 8-bit integer + - `:uint16` -- unsigned 16-bit integer + - `:uint32` -- unsigned 32-bit integer + - `:uint64` -- unsigned 64-bit integer + - `:fp32` -- 32-bit IEEE float + - `:fp64` -- 64-bit IEEE float (default for floating-point data) + + These map directly to SuiteSparse:GraphBLAS type codes and are chosen + to align with Nx type conventions where practical. + + ## Design note + + We define our own type atoms rather than reusing Nx types because + GraphBLAS must remain Nx-independent at its core. The type names + are chosen to be familiar to Nx users while preserving GraphBLAS + semantics (for example, unsigned integer types are first-class in + GraphBLAS but rare in typical tensor libraries). + """ + + @typedoc """ + A scalar type that can be stored in a GraphBLAS container. + + This type determines the element representation for matrices and vectors. + When a type is not specified, `:int64` is the default integer type and + `:fp64` is the default floating-point type, matching GraphBLAS conventions. + """ + @type scalar_type :: + :bool + | :int8 + | :int16 + | :int32 + | :int64 + | :uint8 + | :uint16 + | :uint32 + | :uint64 + | :fp32 + | :fp64 + + @typedoc """ + A pair of dimensions for a matrix: `{rows, columns}`. + + Both dimensions must be non-negative. A matrix of size `{0, 0}` is valid + and represents an empty container. + """ + @type shape :: {non_neg_integer(), non_neg_integer()} + + @typedoc """ + A zero-based index into a matrix dimension or vector. + + GraphBLAS uses zero-based indexing throughout, matching the C convention. + This differs from Elixir's one-based indexing convention; the choice is + deliberate to preserve GraphBLAS semantics and enable direct mapping to + the underlying C API without index translation. + """ + @type index :: non_neg_integer() + + @typedoc """ + A COO (coordinate format) entry: `{row_index, col_index, value}`. + + This is the primary sparse construction format for matrices in Phase 1. + The entries are stored as tuples for clarity and pattern-matching + convenience. Duplicate indices in the same entry list are resolved + according to the monoid provided at construction time (duplicates + are combined using the monoid's operator, defaulting to addition). + """ + @type coo_entry :: {index(), index(), number() | boolean()} + + @typedoc """ + A sparse vector entry: `{index, value}`. + """ + @type vector_entry :: {index(), number() | boolean()} + + @typedoc """ + Options keyword list for matrix or vector operations. + + Common options include: + + - `:backend` -- the backend module to use (overrides the default) + - `:type` -- the scalar type for a new container + - `:mask` -- a mask to apply to the operation + - `:descriptor` -- a descriptor controlling operation semantics + """ + @type opts :: keyword() + + @doc """ + Returns the default scalar type for integer data. + + This is `:int64`, matching the GraphBLAS convention. + """ + @spec default_int_type :: :int64 + def default_int_type, do: :int64 + + @doc """ + Returns the default scalar type for floating-point data. + + This is `:fp64`, matching the GraphBLAS convention. + """ + @spec default_fp_type :: :fp64 + def default_fp_type, do: :fp64 + + @doc """ + Returns the default scalar type for boolean data. + """ + @spec default_bool_type :: :bool + def default_bool_type, do: :bool + + @doc """ + Validates that the given atom is a supported scalar type. + + Only `:int64`, `:fp64`, and `:bool` are currently supported by both backends. + + Returns `:ok` if valid, or `{:error, {:unsupported_type, term}}` otherwise. + """ + @spec validate_scalar_type(term()) :: :ok | {:error, {:unsupported_type, term()}} + def validate_scalar_type(type) when type in [:int64, :fp64, :bool] do + :ok + end + + def validate_scalar_type(other) do + {:error, {:unsupported_type, other}} + end + + @doc """ + Returns the default scalar type based on the kind of values provided. + + For integer values, defaults to `:int64`. For float values, defaults + to `:fp64`. For boolean values, defaults to `:bool`. + + This is a heuristic for convenience. When the type matters for + correctness, specify it explicitly. + """ + @spec infer_type([number() | boolean()]) :: scalar_type() + def infer_type([]), do: :int64 + + def infer_type(values) do + cond do + Enum.all?(values, &is_boolean/1) -> :bool + Enum.any?(values, &is_float/1) -> :fp64 + true -> :int64 + end + end + + @doc """ + Returns the size in bytes of a single element of the given scalar type. + + Useful for estimating memory requirements before allocating large containers. + """ + @spec type_size(scalar_type()) :: pos_integer() + def type_size(:bool), do: 1 + def type_size(:int8), do: 1 + def type_size(:int16), do: 2 + def type_size(:int32), do: 4 + def type_size(:int64), do: 8 + def type_size(:uint8), do: 1 + def type_size(:uint16), do: 2 + def type_size(:uint32), do: 4 + def type_size(:uint64), do: 8 + def type_size(:fp32), do: 4 + def type_size(:fp64), do: 8 +end diff --git a/lib/ex_graphblas/unary_op.ex b/lib/ex_graphblas/unary_op.ex new file mode 100644 index 0000000..4bd31c8 --- /dev/null +++ b/lib/ex_graphblas/unary_op.ex @@ -0,0 +1,75 @@ +defmodule GraphBLAS.UnaryOp do + @moduledoc """ + Unary operator definitions for GraphBLAS. + + Unary operators apply a function to a single value: `f(a) -> b`. + They are used for element-wise transformations on matrices and vectors, + such as negation, absolute value, or logical complement. + + Phase 1 provides a minimal set of unary operators. The primary use case + is descriptor-controlled operations where a transform is applied to + an input before the main computation (e.g., complementing a mask). + + ## Built-in unary operators + + | Name | Function | Description | + |----------|----------|-----------------------| + | `:identity` | `fn x -> x end` | No-op passthrough | + | `:negate` | `fn x -> -x end` | Arithmetic negation | + | `:abs_val` | `fn x -> abs(x) end` | Absolute value | + | `:l_not` | `fn x -> not x end` | Logical complement | + + These are placeholders for the full set needed in later phases. + The Reference backend implements all listed operators. + """ + + alias GraphBLAS.Types + + @type t :: %__MODULE__{ + name: atom(), + function: (term() -> term()), + type: Types.scalar_type() + } + + @enforce_keys [:name, :function, :type] + defstruct [:name, :function, :type] + + @doc """ + Creates a custom unary operator struct. + """ + @spec new(keyword()) :: t() + def new(opts) do + name = Keyword.fetch!(opts, :name) + function = Keyword.fetch!(opts, :function) + type = Keyword.fetch!(opts, :type) + + %__MODULE__{name: name, function: function, type: type} + end + + @doc """ + Applies a unary operator to a value. + """ + @spec apply(atom() | t(), term()) :: term() + def apply(%__MODULE__{function: fun}, a), do: fun.(a) + + def apply(name, a) when is_atom(name) do + fn_for(name).(a) + end + + @doc """ + Returns the function implementation for a built-in unary operator name. + """ + @spec fn_for(atom()) :: (term() -> term()) + def fn_for(:identity), do: &Function.identity/1 + def fn_for(:negate_int), do: fn x -> -x end + def fn_for(:negate_fp), do: fn x -> -x * 1.0 end + def fn_for(:abs_val), do: &abs/1 + def fn_for(:l_not), do: &Kernel.not/1 + def fn_for(name), do: raise(ArgumentError, "Unknown unary operator: #{inspect(name)}") + + @doc """ + Returns the list of all built-in unary operator name atoms. + """ + @spec builtin_names() :: [atom()] + def builtin_names, do: [:identity, :negate_int, :negate_fp, :abs_val, :l_not] +end diff --git a/lib/ex_graphblas/vector.ex b/lib/ex_graphblas/vector.ex new file mode 100644 index 0000000..562b7cb --- /dev/null +++ b/lib/ex_graphblas/vector.ex @@ -0,0 +1,250 @@ +defmodule GraphBLAS.Vector do + @moduledoc """ + Sparse vector operations for GraphBLAS. + + A sparse vector stores only its non-default entries. In GraphBLAS, + vectors are the one-dimensional counterpart to sparse matrices and + serve as both operands and results in many graph algorithms. + + ## Construction + + Create a vector from index-value pairs: + + {:ok, v} = GraphBLAS.Vector.from_entries(4, [{0, 1.0}, {2, 3.0}], :fp64) + + Or create an empty vector: + + {:ok, v} = GraphBLAS.Vector.new(4, :int64) + + ## Struct design + + The `%Vector{}` struct carries: + - `:size` -- the declared length of the vector + - `:type` -- the scalar type of stored values + - `:data` -- opaque backend-specific data (do not pattern-match on this) + """ + + alias GraphBLAS.{Backend, Config, Error, Types} + + @type t :: %__MODULE__{ + size: non_neg_integer(), + type: Types.scalar_type(), + backend: module(), + data: Backend.backend_data() + } + + @enforce_keys [:size, :type, :data] + defstruct [:size, :type, :backend, :data] + + @doc """ + Creates an empty sparse vector with the given size and scalar type. + """ + @spec new(non_neg_integer(), Types.scalar_type(), Types.opts()) :: + {:ok, t()} | {:error, Error.t()} + def new(size, type \\ :int64, opts \\ []) do + backend = Config.resolve_backend(opts) + backend.vector_new(size, type, opts) + end + + @doc """ + Creates a sparse vector from index-value pairs. + + `entries` is a list of `{index, value}` tuples with zero-based indices. + """ + @spec from_entries(non_neg_integer(), [Types.vector_entry()], Types.scalar_type(), Types.opts()) :: + {:ok, t()} | {:error, Error.t()} + def from_entries(size, entries, type \\ :int64, opts \\ []) do + backend = Config.resolve_backend(opts) + backend.vector_from_entries(size, entries, type, opts) + end + + @doc """ + Returns the declared length of the vector. + """ + @spec size(t()) :: {:ok, non_neg_integer()} | {:error, Error.t()} + def size(%__MODULE__{} = vector) do + {:ok, vector.size} + end + + @doc """ + Returns the scalar type of the vector's stored values. + """ + @spec type(t()) :: {:ok, Types.scalar_type()} | {:error, Error.t()} + def type(%__MODULE__{} = vector) do + {:ok, vector.type} + end + + @doc """ + Returns the number of stored (non-default) values in the vector. + + Dispatches to the backend that created the vector. + """ + @spec nvals(t()) :: {:ok, non_neg_integer()} | {:error, Error.t()} + def nvals(%__MODULE__{backend: nil} = vector), + do: nvals(%{vector | backend: Config.default_backend()}) + + def nvals(%__MODULE__{backend: backend} = vector) do + backend.vector_nvals(vector) + end + + @doc """ + Extracts the stored entries as `{index, value}` pairs. + + Dispatches to the backend that created the vector. + """ + @spec to_entries(t()) :: {:ok, [Types.vector_entry()]} | {:error, Error.t()} + def to_entries(%__MODULE__{backend: nil} = vector), + do: to_entries(%{vector | backend: Config.default_backend()}) + + def to_entries(%__MODULE__{backend: backend} = vector) do + backend.vector_to_entries(vector) + end + + @doc """ + Multiplies a vector by a matrix (from the left) using the given semiring. + + The result vector `v = x^T * A` has length `ncols(A)`. + """ + @spec vxm(t(), GraphBLAS.Matrix.t(), atom() | GraphBLAS.Semiring.t(), Types.opts()) :: + {:ok, t()} | {:error, Error.t()} + def vxm( + %__MODULE__{} = vector, + %GraphBLAS.Matrix{} = matrix, + semiring \\ :plus_times, + opts \\ [] + ) do + backend = Config.resolve_backend(opts) + backend.vector_vxm(vector, matrix, semiring, opts) + end + + @doc """ + Element-wise addition of two vectors using the given monoid. + """ + @spec ewise_add(t(), t(), atom() | GraphBLAS.Monoid.t(), Types.opts()) :: + {:ok, t()} | {:error, Error.t()} + def ewise_add(%__MODULE__{} = a, %__MODULE__{} = b, monoid \\ :plus, opts \\ []) do + backend = Config.resolve_backend(opts) + backend.vector_ewise_add(a, b, monoid, opts) + end + + @doc """ + Element-wise multiplication of two vectors using the given monoid. + """ + @spec ewise_mult(t(), t(), atom() | GraphBLAS.Monoid.t(), Types.opts()) :: + {:ok, t()} | {:error, Error.t()} + def ewise_mult(%__MODULE__{} = a, %__MODULE__{} = b, monoid \\ :times, opts \\ []) do + backend = Config.resolve_backend(opts) + backend.vector_ewise_mult(a, b, monoid, opts) + end + + @doc """ + Reduces a vector to a scalar using the given monoid. + """ + @spec reduce(t(), atom() | GraphBLAS.Monoid.t(), Types.opts()) :: + {:ok, GraphBLAS.Scalar.t()} | {:error, Error.t()} + def reduce(%__MODULE__{} = vector, monoid \\ :plus, opts \\ []) do + backend = Config.resolve_backend(opts) + backend.vector_reduce(vector, monoid, opts) + end + + @doc """ + Converts a sparse vector to a dense list representation. + + Returns `{:ok, list}` where the list has length equal to the vector's + declared size. Positions that are structural zeros in the sparse + representation are filled with the default value for the vector's type. + + This is a debugging and inspection helper. Do not use on large vectors. + + ## Examples + + iex> {:ok, v} = GraphBLAS.Vector.from_entries(4, [{0, 5}, {2, 3}], :int64) + iex> {:ok, list} = GraphBLAS.Vector.to_list(v) + iex> list + [5, 0, 3, 0] + + """ + @spec to_list(t()) :: {:ok, [term()]} | {:error, Error.t()} + def to_list(%__MODULE__{backend: nil} = vector), + do: to_list(%{vector | backend: Config.default_backend()}) + + def to_list(%__MODULE__{backend: backend} = vector) do + backend.vector_to_list(vector) + end + + @doc """ + Sets the value at the given index in the vector, overwriting any existing value. + + Note: This function dispatches to the backend stored in the vector's `backend` field. + The `opts` parameter is accepted for API consistency but is currently unused. + + ## Examples + + iex> {:ok, v} = GraphBLAS.Vector.from_entries(3, [{0, 1}], :int64) + iex> {:ok, v} = GraphBLAS.Vector.set(v, 1, 5) + iex> {:ok, 5} = GraphBLAS.Vector.extract(v, 1) + + """ + @spec set(t(), non_neg_integer(), term(), Types.opts()) :: + {:ok, t()} | {:error, Error.t()} + def set(vector, index, value, opts \\ []) + + def set(%__MODULE__{backend: nil} = vector, index, value, opts), + do: set(%{vector | backend: Config.default_backend()}, index, value, opts) + + def set(%__MODULE__{backend: backend} = vector, index, value, _opts) do + backend.vector_set(vector, index, value) + end + + @doc """ + Extracts the value at the given index from the vector. + + Returns the default value (0, 0.0, or false) for structural zeros. + + Note: This function dispatches to the backend stored in the vector's `backend` field. + The `opts` parameter is accepted for API consistency but is currently unused. + + ## Examples + + iex> {:ok, v} = GraphBLAS.Vector.from_entries(3, [{0, 42}], :int64) + iex> {:ok, 42} = GraphBLAS.Vector.extract(v, 0) + iex> {:ok, 0} = GraphBLAS.Vector.extract(v, 1) + + """ + @spec extract(t(), non_neg_integer(), Types.opts()) :: + {:ok, term()} | {:error, Error.t()} + def extract(vector, index, opts \\ []) + + def extract(%__MODULE__{backend: nil} = vector, index, opts), + do: extract(%{vector | backend: Config.default_backend()}, index, opts) + + def extract(%__MODULE__{backend: backend} = vector, index, _opts) do + backend.vector_extract(vector, index) + end + + @doc """ + Creates a deep copy of the vector. + + The copy is independent — modifying it does not affect the original. + The copy uses the same backend as the original vector. + + Note: The `opts` parameter is accepted for API consistency but is currently unused. + To convert a vector to a different backend, use `to_entries/1` followed by `from_entries/4`. + + ## Examples + + iex> {:ok, v} = GraphBLAS.Vector.from_entries(3, [{0, 1}], :int64) + iex> {:ok, copy} = GraphBLAS.Vector.dup(v) + iex> {:ok, 1} = GraphBLAS.Vector.extract(v, 0) + + """ + @spec dup(t(), Types.opts()) :: {:ok, t()} | {:error, Error.t()} + def dup(vector, opts \\ []) + + def dup(%__MODULE__{backend: nil} = vector, _opts), + do: dup(%{vector | backend: Config.default_backend()}, []) + + def dup(%__MODULE__{backend: backend} = vector, _opts) do + backend.vector_dup(vector) + end +end diff --git a/lib/graph_blas/application.ex b/lib/graph_blas/application.ex new file mode 100644 index 0000000..ef3c6d6 --- /dev/null +++ b/lib/graph_blas/application.ex @@ -0,0 +1,43 @@ +defmodule GraphBLAS.Application do + @moduledoc false + + use Application + + @native_mod GraphBLAS.Native.SuiteSparse + + @impl true + def start(_type, _args) do + case Code.ensure_loaded(@native_mod) do + {:module, mod} -> + try do + mod.grb_init() + rescue + _ -> :ok + end + + {:error, _} -> + :ok + end + + children = [] + opts = [strategy: :one_for_one, name: GraphBLAS.Supervisor] + Supervisor.start_link(children, opts) + end + + @impl true + def stop(_state) do + case Code.ensure_loaded(@native_mod) do + {:module, mod} -> + try do + mod.grb_finalize() + rescue + _ -> :ok + end + + {:error, _} -> + :ok + end + + :ok + end +end diff --git a/mix.exs b/mix.exs index fa683b2..c183971 100644 --- a/mix.exs +++ b/mix.exs @@ -1,28 +1,145 @@ defmodule GraphBLAS.MixProject do use Mix.Project + @moduledoc """ + Mix project configuration for GraphBLAS. + + GraphBLAS is an Elixir library for sparse linear algebra and graph + computation inspired by the GraphBLAS model. It provides idiomatic + Elixir data structures at the boundary while delegating computation + to swappable backends (initially SuiteSparse:GraphBLAS via Zigler). + """ + + @version "0.2.0" + @source_url "https://github.com/thanos/ex_graphblas" + def project do [ app: :ex_graphblas, - version: "0.1.0", - elixir: "~> 1.18", + version: @version, + elixir: "~> 1.17", start_permanent: Mix.env() == :prod, - deps: deps() + deps: deps(), + aliases: [ + verify: &verify/1 + ], + elixirc_paths: elixirc_paths(Mix.env()), + test_coverage: [tool: ExCoveralls], + dialyzer: [ + plt_file: {:no_warn, "priv/plts/project.plt"}, + plt_add_apps: [:mix] + ], + preferred_cli_env: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test + ], + docs: [ + # Hex.pm landing page: /doc/readme.html + main: "readme", + extras: [ + {"README.md", [title: "GraphBLAS"]}, + "LICENSE", + {"guides/installation_guide.md", [group: "Guides", title: "Installation"]}, + {"guides/architecture_walkthrough.md", + [group: "Guides", title: "Architecture walkthrough"]}, + {"guides/reference_backend_walkthrough.md", + [group: "Guides", title: "Reference backend"]}, + {"guides/native_backend_walkthrough.md", [group: "Guides", title: "Native backend"]}, + {"guides/graph_algorithms_guide.md", [group: "Guides", title: "Graph algorithms"]}, + {"guides/masks_and_descriptors_guide.md", + [group: "Guides", title: "Masks and descriptors"]}, + {"guides/parity_testing_guide.md", [group: "Guides", title: "Parity testing"]} + ] + ], + package: package(), + description: description(), + homepage_url: "https://github.com/thanos/ex_graphblas" ] end - # Run "mix help compile.app" to learn about applications. def application do [ - extra_applications: [:logger] + extra_applications: [:logger], + mod: {GraphBLAS.Application, []} ] end - # Run "mix help deps" to learn about dependencies. defp deps do [ - # {:dep_from_hexpm, "~> 0.3.0"}, - # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} + {:zigler, "~> 0.15"}, + {:zigler_precompiled, "~> 0.1"}, + {:ex_doc, "~> 0.34", only: :dev, runtime: false}, + {:benchee, "~> 1.3", only: :dev, runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:excoveralls, "~> 0.18", only: :test}, + {:stream_data, "~> 1.1", only: [:dev, :test]}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false} ] end + + defp elixirc_paths(:test) do + paths = ["lib", "test/support"] + + if System.get_env("EX_GRAPHBLAS_COMPILE_NATIVE") in ["1", "true"] do + paths ++ ["native"] + else + paths + end + end + + defp elixirc_paths(:dev), do: ["lib", "native", "bench"] + defp elixirc_paths(_), do: ["lib", "native"] + + defp description do + "Sparse linear algebra and graph computation in Elixir, inspired by the GraphBLAS standard." + end + + defp package do + [ + licenses: ["Apache-2.0"], + maintainers: ["Thanos Vassilakis"], + links: %{"GitHub" => @source_url}, + # files: ~w(lib .formatter.exs mix.exs README.md LICENSE CHANGELOG.md), + exclude_patterns: ["bench", "docs", "guides", "plans", "prompts"] + ] + end + + defp verify(_) do + steps = [ + # ["precommit", :dev], + {"compile --warnings-as-errors", :dev}, + {"format --check-formatted", :dev}, + {"credo --strict", :dev}, + # {"sobelow --config", :dev}, + {"dialyzer", :dev}, + {"test --cover", :test}, + {"docs --warnings-as-errors", :dev} + ] + + Enum.each(steps, fn {task, env} -> + Mix.shell().info(IO.ANSI.format([:bright, "==> mix #{task}", :reset])) + + mix_executable = + System.find_executable("mix") || + Mix.raise("Could not find `mix` executable on PATH") + + {_, exit_code} = + System.cmd(mix_executable, String.split(task), + env: [{"MIX_ENV", to_string(env)}], + into: IO.stream(:stdio, :line), + stderr_to_stdout: true + ) + + if exit_code != 0 do + Mix.raise("mix #{task} failed (exit code #{exit_code})") + end + end) + + Mix.shell().info( + IO.ANSI.format([:green, :bright, "\nAll verification checks passed!", :reset]) + ) + end end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..d676421 --- /dev/null +++ b/mix.lock @@ -0,0 +1,29 @@ +%{ + "benchee": {:hex, :benchee, "1.5.0", "4d812c31d54b0ec0167e91278e7de3f596324a78a096fd3d0bea68bb0c513b10", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "5b075393aea81b8ae74eadd1c28b1d87e8a63696c649d8293db7c4df3eb67535"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"}, + "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, + "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, + "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, + "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "pegasus": {:hex, :pegasus, "0.2.6", "b4af6522326fbb2ffd1bb706e78ec05854fbcb4b03a7fe08f8db2e9de1d0be67", [:mix], [{:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0ac159f0ccab7967cf90208327cc8a35788874814c8d78e19d47104d3fc049b9"}, + "protoss": {:hex, :protoss, "1.1.0", "853533313989751c7da5b0e1ce71501a23f3dc41f128709478af40daccc6e959", [:mix], [], "hexpm", "c2f874383dd047fcfdf467b814dd33a208a4d24a467aef90d86185b41a0752ad"}, + "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, + "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, + "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"}, + "zig_get": {:hex, :zig_get, "0.15.2", "a6ccaa894213839ba95615bf9be2b2c9268e37ab9547a2344830202cfd6d7cc0", [:mix], [], "hexpm", "e6b0028f2d5a8da791ff8037deff5b492784017d8d241e598377a24bd765f56f"}, + "zig_parser": {:hex, :zig_parser, "0.6.0", "b1296c64bf5c2592de2eb4bc8bbf79d85b1d6a3ab444c443becd7af579d85681", [:mix], [{:pegasus, "~> 0.2.4", [hex: :pegasus, repo: "hexpm", optional: false]}], "hexpm", "bb7a1523b69f7f8f6b74ba5bf9dabc83f512c0cd67d9eebba1c501a529a2f95e"}, + "zigler": {:hex, :zigler, "0.15.2", "d3e8c7b6d88be2eea72c341376b7e7b0aa36248e26d5798b580cad9815311559", [:mix], [{:protoss, "~> 1.0", [hex: :protoss, repo: "hexpm", optional: false]}, {:zig_get, "0.15.2", [hex: :zig_get, repo: "hexpm", optional: false]}, {:zig_parser, "~> 0.6", [hex: :zig_parser, repo: "hexpm", optional: false]}], "hexpm", "6bf96df41e281a70147e8826e439e24945f0222e08d058fc544da84f5c7ea8dd"}, + "zigler_precompiled": {:hex, :zigler_precompiled, "0.1.3", "2fdc919d4b9cb1b370d99ddb9acf0464147e03daf7427a91f65864bc7d961de2", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:zigler, "~> 0.13", [hex: :zigler, repo: "hexpm", optional: true]}], "hexpm", "da7b175085e86fa9eed46d1626f9189a2453ffdb380ef4d07dc557cfb5c08eaa"}, +} diff --git a/native/suite_sparse.ex b/native/suite_sparse.ex new file mode 100644 index 0000000..6020d1c --- /dev/null +++ b/native/suite_sparse.ex @@ -0,0 +1,103 @@ +defmodule GraphBLAS.Native.SuiteSparse do + @moduledoc """ + Zigler NIF module wrapping SuiteSparse:GraphBLAS. + + This module provides the native bridge between Elixir and the + SuiteSparse:GraphBLAS C library. All compute-heavy functions run + on dirty CPU schedulers to avoid blocking the BEAM. + + Do not call this module directly. Use GraphBLAS.Backend.SuiteSparse + which provides error mapping, type resolution, and struct wrapping. + + ## Precompiled NIFs + + When the EX_GRAPHBLAS_BUILD env variable is not set, this module + downloads precompiled NIFs from GitHub Releases instead of compiling + from source. Set EX_GRAPHBLAS_BUILD=1 to force local compilation + (requires Zig and SuiteSparse:GraphBLAS installed). + + ## Pointer lifecycle + + C pointers (GrB_Matrix, GrB_Vector) are stored as usize integers + and passed between Elixir and this NIF module. The Elixir backend + must call matrix_free/1 and vector_free/1 to release SuiteSparse + objects, or they will be leaked. Future phases may integrate BEAM + GC via Zigler resources once the module boundary is resolved. + """ + + version = Mix.Project.config()[:version] + + @suitesparse_include_path Application.compile_env( + :ex_graphblas, + :suitesparse_include_path, + System.get_env( + "SUITESPARSE_INCLUDE_PATH", + "/opt/homebrew/include/suitesparse" + ) + ) + + use ZiglerPrecompiled, + otp_app: :ex_graphblas, + base_url: "https://github.com/thanos/ex_graphblas/releases/download/v#{version}", + version: version, + force_build: System.get_env("EX_GRAPHBLAS_BUILD") in ["1", "true"], + zig_code_path: "../priv/native/suite_sparse/graphblas.zig", + c: [ + link_lib: {:system, "graphblas"}, + include_dirs: @suitesparse_include_path + ], + nifs: [ + grb_init: 0, + grb_finalize: 0, + matrix_new: 3, + matrix_free: 1, + matrix_nrows: 1, + matrix_ncols: 1, + matrix_nvals: 1, + matrix_build_int64: 5, + matrix_build_fp64: 5, + matrix_build_bool: 5, + matrix_extract_tuples_int64: 2, + matrix_extract_tuples_fp64: 2, + matrix_extract_tuples_bool: 2, + matrix_mxm: 5, + matrix_mxv: 5, + matrix_transpose: 3, + matrix_ewise_add: 5, + matrix_ewise_mult: 5, + matrix_reduce_to_vector: 4, + matrix_set_int64: 4, + matrix_set_fp64: 4, + matrix_set_bool: 4, + matrix_extract_int64: 3, + matrix_extract_fp64: 3, + matrix_extract_bool: 3, + matrix_dup: 1, + vector_new: 2, + vector_free: 1, + vector_size: 1, + vector_nvals: 1, + vector_build_int64: 4, + vector_build_fp64: 4, + vector_build_bool: 4, + vector_extract_tuples_int64: 2, + vector_extract_tuples_fp64: 2, + vector_extract_tuples_bool: 2, + vector_vxm: 5, + vector_ewise_add: 5, + vector_ewise_mult: 5, + vector_reduce_to_scalar_int64: 2, + vector_reduce_to_scalar_fp64: 2, + vector_reduce_to_scalar_bool: 2, + vector_set_int64: 3, + vector_set_fp64: 3, + vector_set_bool: 3, + vector_extract_int64: 2, + vector_extract_fp64: 2, + vector_extract_bool: 2, + vector_dup: 1, + descriptor_create: 5, + descriptor_free: 1, + descriptor_is_custom: 1 + ] +end diff --git a/native/suite_sparse_backend.ex b/native/suite_sparse_backend.ex new file mode 100644 index 0000000..e5e5896 --- /dev/null +++ b/native/suite_sparse_backend.ex @@ -0,0 +1,795 @@ +defmodule GraphBLAS.Backend.SuiteSparse do + @moduledoc """ + SuiteSparse:GraphBLAS-backed implementation of the `GraphBLAS.Backend` behaviour. + + This backend delegates all compute-heavy operations to the native + SuiteSparse:GraphBLAS C library via the `GraphBLAS.Native` NIF module. + + It is intended for production use once SuiteSparse is installed and + correctly configured. For development and testing, the + `GraphBLAS.Backend.Elixir` backend remains the safer default. + """ + + @behaviour GraphBLAS.Backend + + alias GraphBLAS.{Error, Mask, Matrix, Monoid, Scalar, Semiring, Vector} + + # Type codes matching native.ex + @type_bool 1 + @type_int64 8 + @type_fp64 11 + + # Semiring codes matching native.ex + @semiring_plus_times 1 + @semiring_plus_times_fp64 2 + @semiring_plus_min 3 + @semiring_plus_min_fp64 4 + @semiring_max_plus 5 + @semiring_max_plus_fp64 6 + @semiring_max_min 7 + @semiring_max_min_fp64 8 + @semiring_lor_land 9 + @semiring_land_lor 10 + @semiring_min_plus 11 + @semiring_min_plus_fp64 12 + + # Monoid codes matching native.ex + @monoid_plus 1 + @monoid_plus_fp64 2 + @monoid_times 3 + @monoid_times_fp64 4 + @monoid_min 5 + @monoid_min_fp64 6 + @monoid_max 7 + @monoid_max_fp64 8 + @monoid_land 9 + @monoid_lor 10 + @monoid_lxor 11 + + @supported_types [:int64, :fp64, :bool] + + ############################################################################# + # Matrix callbacks + ############################################################################# + + @impl GraphBLAS.Backend + def matrix_new(nrows, ncols, type, _opts) do + with :ok <- validate_type(type), + :ok <- validate_dimensions(nrows, ncols) do + code = type_to_code(type) + + try do + ptr = GraphBLAS.Native.SuiteSparse.matrix_new(nrows, ncols, code) + {:ok, %Matrix{shape: {nrows, ncols}, type: type, backend: __MODULE__, data: %{ptr: ptr}}} + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def matrix_from_coo(nrows, ncols, entries, type, _opts) do + with :ok <- validate_type(type), + :ok <- validate_dimensions(nrows, ncols) do + code = type_to_code(type) + + try do + ptr = GraphBLAS.Native.SuiteSparse.matrix_new(nrows, ncols, code) + build_matrix_from_coo(ptr, nrows, ncols, entries, type) + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def matrix_nvals(%Matrix{data: %{ptr: ptr}}) do + n = GraphBLAS.Native.SuiteSparse.matrix_nvals(ptr) + {:ok, n} + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + + @impl GraphBLAS.Backend + def matrix_shape(%Matrix{shape: shape}), do: {:ok, shape} + + @impl GraphBLAS.Backend + def matrix_type(%Matrix{type: type}), do: {:ok, type} + + @impl GraphBLAS.Backend + def matrix_to_coo(%Matrix{type: type, data: %{ptr: ptr}}) do + n = GraphBLAS.Native.SuiteSparse.matrix_nvals(ptr) + + extract_result = + case type do + :int64 -> GraphBLAS.Native.SuiteSparse.matrix_extract_tuples_int64(ptr, n) + :fp64 -> GraphBLAS.Native.SuiteSparse.matrix_extract_tuples_fp64(ptr, n) + :bool -> GraphBLAS.Native.SuiteSparse.matrix_extract_tuples_bool(ptr, n) + end + + %{rows: rows, cols: cols, vals: vals, actual_nvals: _} = extract_result + {:ok, Enum.zip_with([rows, cols, vals], fn [r, c, v] -> {r, c, v} end)} + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + + @impl GraphBLAS.Backend + def matrix_mxm(%Matrix{} = a, %Matrix{} = b, semiring, opts) do + with {:ok, sr} <- resolve_semiring(semiring) do + semiring_code = semiring_to_code(sr) + + desc = Keyword.get(opts, :descriptor) + {a_ptr, _a} = maybe_transpose_inp0(a, desc) + {b_ptr, _b} = maybe_transpose_inp1(b, desc) + + mask_ptr = extract_mask_ptr(opts) + mask_comp = mask_is_complement?(opts) + desc_ptr = build_descriptor_ptr(opts, mask_comp, skip_transpose: true) + + try do + ptr = GraphBLAS.Native.SuiteSparse.matrix_mxm(a_ptr, b_ptr, semiring_code, mask_ptr, desc_ptr) + cleanup_descriptor(desc_ptr) + nrows = GraphBLAS.Native.SuiteSparse.matrix_nrows(ptr) + ncols = GraphBLAS.Native.SuiteSparse.matrix_ncols(ptr) + + {:ok, + %Matrix{shape: {nrows, ncols}, type: sr.type, backend: __MODULE__, data: %{ptr: ptr}}} + rescue + e -> + cleanup_descriptor(desc_ptr) + Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def matrix_mxv(%Matrix{} = matrix, %Vector{data: %{ptr: v_ptr}}, semiring, opts) do + with {:ok, sr} <- resolve_semiring(semiring) do + semiring_code = semiring_to_code(sr) + + desc = Keyword.get(opts, :descriptor) + {m_ptr, _matrix} = maybe_transpose_inp0(matrix, desc) + + mask_ptr = extract_mask_ptr(opts) + mask_comp = mask_is_complement?(opts) + desc_ptr = build_descriptor_ptr(opts, mask_comp, skip_transpose: true) + + try do + ptr = GraphBLAS.Native.SuiteSparse.matrix_mxv(m_ptr, v_ptr, semiring_code, mask_ptr, desc_ptr) + cleanup_descriptor(desc_ptr) + size = GraphBLAS.Native.SuiteSparse.vector_size(ptr) + {:ok, %Vector{size: size, type: sr.type, backend: __MODULE__, data: %{ptr: ptr}}} + rescue + e -> + cleanup_descriptor(desc_ptr) + Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def matrix_ewise_add( + %Matrix{data: %{ptr: a_ptr}, type: type} = a, + %Matrix{data: %{ptr: b_ptr}}, + monoid, + opts + ) do + with {:ok, m} <- resolve_monoid(monoid) do + monoid_code = monoid_to_code(m) + mask_ptr = extract_mask_ptr(opts) + mask_comp = mask_is_complement?(opts) + desc_ptr = build_descriptor_ptr(opts, mask_comp) + + try do + ptr = GraphBLAS.Native.SuiteSparse.matrix_ewise_add(a_ptr, b_ptr, monoid_code, mask_ptr, desc_ptr) + cleanup_descriptor(desc_ptr) + {:ok, %Matrix{shape: a.shape, type: type, backend: __MODULE__, data: %{ptr: ptr}}} + rescue + e -> + cleanup_descriptor(desc_ptr) + Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def matrix_ewise_mult( + %Matrix{data: %{ptr: a_ptr}, type: type} = a, + %Matrix{data: %{ptr: b_ptr}}, + monoid, + opts + ) do + with {:ok, m} <- resolve_monoid(monoid) do + monoid_code = monoid_to_code(m) + mask_ptr = extract_mask_ptr(opts) + mask_comp = mask_is_complement?(opts) + desc_ptr = build_descriptor_ptr(opts, mask_comp) + + try do + ptr = GraphBLAS.Native.SuiteSparse.matrix_ewise_mult(a_ptr, b_ptr, monoid_code, mask_ptr, desc_ptr) + cleanup_descriptor(desc_ptr) + {:ok, %Matrix{shape: a.shape, type: type, backend: __MODULE__, data: %{ptr: ptr}}} + rescue + e -> + cleanup_descriptor(desc_ptr) + Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def matrix_reduce(%Matrix{data: %{ptr: ptr}, type: type}, monoid, opts) do + with {:ok, m} <- resolve_monoid(monoid) do + monoid_code = monoid_to_code(m) + mask_ptr = extract_mask_ptr(opts) + mask_comp = mask_is_complement?(opts) + desc_ptr = build_descriptor_ptr(opts, mask_comp) + + try do + v_ptr = GraphBLAS.Native.SuiteSparse.matrix_reduce_to_vector(ptr, monoid_code, mask_ptr, desc_ptr) + cleanup_descriptor(desc_ptr) + size = GraphBLAS.Native.SuiteSparse.vector_size(v_ptr) + {:ok, %Vector{size: size, type: type, backend: __MODULE__, data: %{ptr: v_ptr}}} + rescue + e -> + cleanup_descriptor(desc_ptr) + Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def matrix_transpose(%Matrix{data: %{ptr: ptr}, type: type}, opts) do + mask_ptr = extract_mask_ptr(opts) + mask_comp = mask_is_complement?(opts) + desc_ptr = build_descriptor_ptr(opts, mask_comp) + + try do + t_ptr = GraphBLAS.Native.SuiteSparse.matrix_transpose(ptr, mask_ptr, desc_ptr) + cleanup_descriptor(desc_ptr) + nrows = GraphBLAS.Native.SuiteSparse.matrix_nrows(t_ptr) + ncols = GraphBLAS.Native.SuiteSparse.matrix_ncols(t_ptr) + {:ok, %Matrix{shape: {nrows, ncols}, type: type, backend: __MODULE__, data: %{ptr: t_ptr}}} + rescue + e -> + cleanup_descriptor(desc_ptr) + Error.error({:backend_error, __MODULE__, e}) + end + end + + @impl GraphBLAS.Backend + def matrix_to_dense(%Matrix{type: type, shape: {nrows, ncols}} = matrix) do + case matrix_to_coo(matrix) do + {:ok, entries} -> + default = default_value(type) + dense = List.duplicate(List.duplicate(default, ncols), nrows) + + filled = + Enum.reduce(entries, dense, fn {r, c, v}, acc -> + row = Enum.at(acc, r) + updated_row = List.replace_at(row, c, v) + List.replace_at(acc, r, updated_row) + end) + + {:ok, filled} + + {:error, _} = err -> + err + end + end + + @impl GraphBLAS.Backend + def matrix_set( + %Matrix{shape: {nrows, ncols}, type: type, backend: __MODULE__, data: %{ptr: ptr}}, + row, + col, + value + ) do + with :ok <- validate_index(row, nrows), + :ok <- validate_index(col, ncols) do + try do + case type do + :int64 -> GraphBLAS.Native.SuiteSparse.matrix_set_int64(ptr, row, col, value) + :fp64 -> GraphBLAS.Native.SuiteSparse.matrix_set_fp64(ptr, row, col, value) + :bool -> GraphBLAS.Native.SuiteSparse.matrix_set_bool(ptr, row, col, value) + end + + {:ok, %Matrix{shape: {nrows, ncols}, type: type, backend: __MODULE__, data: %{ptr: ptr}}} + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def matrix_extract( + %Matrix{shape: {nrows, ncols}, type: type, backend: __MODULE__, data: %{ptr: ptr}}, + row, + col + ) do + with :ok <- validate_index(row, nrows), + :ok <- validate_index(col, ncols) do + try do + value = + case type do + :int64 -> GraphBLAS.Native.SuiteSparse.matrix_extract_int64(ptr, row, col) + :fp64 -> GraphBLAS.Native.SuiteSparse.matrix_extract_fp64(ptr, row, col) + :bool -> GraphBLAS.Native.SuiteSparse.matrix_extract_bool(ptr, row, col) + end + + {:ok, value} + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def matrix_dup(%Matrix{ + shape: {nrows, ncols}, + type: type, + backend: __MODULE__, + data: %{ptr: ptr} + }) do + new_ptr = GraphBLAS.Native.SuiteSparse.matrix_dup(ptr) + + {:ok, %Matrix{shape: {nrows, ncols}, type: type, backend: __MODULE__, data: %{ptr: new_ptr}}} + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + + ############################################################################# + # Vector callbacks + ############################################################################# + + @impl GraphBLAS.Backend + def vector_new(size, type, _opts) do + with :ok <- validate_type(type) do + code = type_to_code(type) + + try do + ptr = GraphBLAS.Native.SuiteSparse.vector_new(size, code) + {:ok, %Vector{size: size, type: type, backend: __MODULE__, data: %{ptr: ptr}}} + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def vector_from_entries(size, entries, type, _opts) do + with :ok <- validate_type(type) do + code = type_to_code(type) + + try do + ptr = GraphBLAS.Native.SuiteSparse.vector_new(size, code) + build_vector_from_entries(ptr, size, entries, type) + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def vector_nvals(%Vector{data: %{ptr: ptr}}) do + n = GraphBLAS.Native.SuiteSparse.vector_nvals(ptr) + {:ok, n} + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + + @impl GraphBLAS.Backend + def vector_size(%Vector{size: size}), do: {:ok, size} + + @impl GraphBLAS.Backend + def vector_type(%Vector{type: type}), do: {:ok, type} + + @impl GraphBLAS.Backend + def vector_to_entries(%Vector{type: type, data: %{ptr: ptr}}) do + n = GraphBLAS.Native.SuiteSparse.vector_nvals(ptr) + + extract_result = + case type do + :int64 -> GraphBLAS.Native.SuiteSparse.vector_extract_tuples_int64(ptr, n) + :fp64 -> GraphBLAS.Native.SuiteSparse.vector_extract_tuples_fp64(ptr, n) + :bool -> GraphBLAS.Native.SuiteSparse.vector_extract_tuples_bool(ptr, n) + end + + %{indices: indices, vals: vals, actual_nvals: _} = extract_result + {:ok, Enum.zip_with([indices, vals], fn [i, v] -> {i, v} end)} + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + + @impl GraphBLAS.Backend + def vector_vxm(%Vector{data: %{ptr: v_ptr}}, %Matrix{} = matrix, semiring, opts) do + with {:ok, sr} <- resolve_semiring(semiring) do + semiring_code = semiring_to_code(sr) + + desc = Keyword.get(opts, :descriptor) + {m_ptr, _matrix} = maybe_transpose_inp1(matrix, desc) + + mask_ptr = extract_mask_ptr(opts) + mask_comp = mask_is_complement?(opts) + desc_ptr = build_descriptor_ptr(opts, mask_comp, skip_transpose: true) + + try do + ptr = GraphBLAS.Native.SuiteSparse.vector_vxm(v_ptr, m_ptr, semiring_code, mask_ptr, desc_ptr) + cleanup_descriptor(desc_ptr) + size = GraphBLAS.Native.SuiteSparse.vector_size(ptr) + {:ok, %Vector{size: size, type: sr.type, backend: __MODULE__, data: %{ptr: ptr}}} + rescue + e -> + cleanup_descriptor(desc_ptr) + Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def vector_ewise_add( + %Vector{data: %{ptr: a_ptr}, size: size, type: type}, + %Vector{data: %{ptr: b_ptr}}, + monoid, + opts + ) do + with {:ok, m} <- resolve_monoid(monoid) do + monoid_code = monoid_to_code(m) + mask_ptr = extract_mask_ptr(opts) + mask_comp = mask_is_complement?(opts) + desc_ptr = build_descriptor_ptr(opts, mask_comp) + + try do + ptr = GraphBLAS.Native.SuiteSparse.vector_ewise_add(a_ptr, b_ptr, monoid_code, mask_ptr, desc_ptr) + cleanup_descriptor(desc_ptr) + {:ok, %Vector{size: size, type: type, backend: __MODULE__, data: %{ptr: ptr}}} + rescue + e -> + cleanup_descriptor(desc_ptr) + Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def vector_ewise_mult( + %Vector{data: %{ptr: a_ptr}, size: size, type: type}, + %Vector{data: %{ptr: b_ptr}}, + monoid, + opts + ) do + with {:ok, m} <- resolve_monoid(monoid) do + monoid_code = monoid_to_code(m) + mask_ptr = extract_mask_ptr(opts) + mask_comp = mask_is_complement?(opts) + desc_ptr = build_descriptor_ptr(opts, mask_comp) + + try do + ptr = GraphBLAS.Native.SuiteSparse.vector_ewise_mult(a_ptr, b_ptr, monoid_code, mask_ptr, desc_ptr) + cleanup_descriptor(desc_ptr) + {:ok, %Vector{size: size, type: type, backend: __MODULE__, data: %{ptr: ptr}}} + rescue + e -> + cleanup_descriptor(desc_ptr) + Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def vector_reduce(%Vector{type: type, data: %{ptr: ptr}}, monoid, _opts) do + with {:ok, m} <- resolve_monoid(monoid) do + monoid_code = monoid_to_code(m) + + try do + value = + case type do + :int64 -> GraphBLAS.Native.SuiteSparse.vector_reduce_to_scalar_int64(ptr, monoid_code) + :fp64 -> GraphBLAS.Native.SuiteSparse.vector_reduce_to_scalar_fp64(ptr, monoid_code) + :bool -> GraphBLAS.Native.SuiteSparse.vector_reduce_to_scalar_bool(ptr, monoid_code) + end + + {:ok, %Scalar{type: type, value: value}} + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def vector_to_list(%Vector{type: type, size: size} = vector) do + case vector_to_entries(vector) do + {:ok, entries} -> + default = default_value(type) + list = List.duplicate(default, size) + + list = + Enum.reduce(entries, list, fn {idx, val}, acc -> + List.replace_at(acc, idx, val) + end) + + {:ok, list} + + {:error, _} = err -> + err + end + end + + @impl GraphBLAS.Backend + def vector_set( + %Vector{size: size, type: type, backend: __MODULE__, data: %{ptr: ptr}}, + index, + value + ) do + with :ok <- validate_index(index, size) do + try do + case type do + :int64 -> GraphBLAS.Native.SuiteSparse.vector_set_int64(ptr, index, value) + :fp64 -> GraphBLAS.Native.SuiteSparse.vector_set_fp64(ptr, index, value) + :bool -> GraphBLAS.Native.SuiteSparse.vector_set_bool(ptr, index, value) + end + + {:ok, %Vector{size: size, type: type, backend: __MODULE__, data: %{ptr: ptr}}} + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def vector_extract( + %Vector{size: size, type: type, backend: __MODULE__, data: %{ptr: ptr}}, + index + ) do + with :ok <- validate_index(index, size) do + try do + value = + case type do + :int64 -> GraphBLAS.Native.SuiteSparse.vector_extract_int64(ptr, index) + :fp64 -> GraphBLAS.Native.SuiteSparse.vector_extract_fp64(ptr, index) + :bool -> GraphBLAS.Native.SuiteSparse.vector_extract_bool(ptr, index) + end + + {:ok, value} + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + end + end + + @impl GraphBLAS.Backend + def vector_dup(%Vector{size: size, type: type, backend: __MODULE__, data: %{ptr: ptr}}) do + new_ptr = GraphBLAS.Native.SuiteSparse.vector_dup(ptr) + {:ok, %Vector{size: size, type: type, backend: __MODULE__, data: %{ptr: new_ptr}}} + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + + ############################################################################# + # Explicit memory management + ############################################################################# + + @doc """ + Releases the SuiteSparse:GraphBLAS resources for a matrix. + + After calling this function, the matrix struct is no longer valid and + must not be used. This is required because C pointers are stored as + opaque integers in Elixir structs and are not automatically garbage + collected by the BEAM. + """ + @spec matrix_free(Matrix.t()) :: :ok + def matrix_free(%Matrix{data: %{ptr: ptr}}) do + GraphBLAS.Native.SuiteSparse.matrix_free(ptr) + end + + @doc """ + Releases the SuiteSparse:GraphBLAS resources for a vector. + + After calling this function, the vector struct is no longer valid and + must not be used. + """ + @spec vector_free(Vector.t()) :: :ok + def vector_free(%Vector{data: %{ptr: ptr}}) do + GraphBLAS.Native.SuiteSparse.vector_free(ptr) + end + + ############################################################################# + # Private helpers + ############################################################################# + + defp validate_type(type) when type in @supported_types, do: :ok + defp validate_type(type), do: Error.error({:unsupported_type, type}) + + defp validate_dimensions(nrows, ncols) when nrows >= 0 and ncols >= 0, do: :ok + + defp validate_dimensions(nrows, ncols) do + Error.error({:invalid_argument, "dimensions must be non-negative, got {#{nrows}, #{ncols}}"}) + end + + defp type_to_code(:bool), do: @type_bool + defp type_to_code(:int64), do: @type_int64 + defp type_to_code(:fp64), do: @type_fp64 + + @semiring_codes %{ + plus_times: @semiring_plus_times, + plus_times_fp64: @semiring_plus_times_fp64, + plus_min: @semiring_plus_min, + plus_min_fp64: @semiring_plus_min_fp64, + max_plus: @semiring_max_plus, + max_plus_fp64: @semiring_max_plus_fp64, + max_min: @semiring_max_min, + max_min_fp64: @semiring_max_min_fp64, + lor_land: @semiring_lor_land, + land_lor: @semiring_land_lor, + min_plus: @semiring_min_plus, + min_plus_fp64: @semiring_min_plus_fp64 + } + + @monoid_codes %{ + plus: @monoid_plus, + plus_fp64: @monoid_plus_fp64, + times: @monoid_times, + times_fp64: @monoid_times_fp64, + min: @monoid_min, + min_fp64: @monoid_min_fp64, + max: @monoid_max, + max_fp64: @monoid_max_fp64, + land: @monoid_land, + lor: @monoid_lor, + lxor: @monoid_lxor + } + + defp semiring_to_code(%Semiring{name: name}), do: Map.fetch!(@semiring_codes, name) + defp monoid_to_code(%Monoid{name: name}), do: Map.fetch!(@monoid_codes, name) + + defp resolve_semiring(name) when is_atom(name), do: Semiring.resolve(name) + defp resolve_semiring(%Semiring{} = s), do: {:ok, s} + + defp resolve_monoid(name) when is_atom(name), do: Monoid.resolve(name) + defp resolve_monoid(%Monoid{} = m), do: {:ok, m} + + defp unzip_coo(entries, _type) do + rows = Enum.map(entries, fn {r, _c, _v} -> r end) + cols = Enum.map(entries, fn {_r, c, _v} -> c end) + vals = Enum.map(entries, fn {_r, _c, v} -> v end) + {rows, cols, vals} + end + + defp unzip_vector_entries(entries, _type) do + indices = Enum.map(entries, fn {i, _v} -> i end) + vals = Enum.map(entries, fn {_i, v} -> v end) + {indices, vals} + end + + defp default_value(:bool), do: false + defp default_value(:fp64), do: 0.0 + defp default_value(_), do: 0 + + defp validate_index(idx, max) when is_integer(idx) and idx >= 0 and idx < max, do: :ok + defp validate_index(idx, max), do: Error.error({:index_out_of_bounds, idx, :index, max}) + + defp maybe_transpose_inp0(%Matrix{data: %{ptr: ptr}} = m, desc) do + if is_struct(desc, GraphBLAS.Descriptor) and desc.inp0_transpose == :transpose do + case matrix_transpose(m, []) do + {:ok, %Matrix{data: %{ptr: t_ptr}} = t} -> + {t_ptr, t} + + {:error, _} = err -> + err + end + else + {ptr, m} + end + end + + defp maybe_transpose_inp1(%Matrix{data: %{ptr: ptr}} = m, desc) do + if is_struct(desc, GraphBLAS.Descriptor) and desc.inp1_transpose == :transpose do + case matrix_transpose(m, []) do + {:ok, %Matrix{data: %{ptr: t_ptr}} = t} -> + {t_ptr, t} + + {:error, _} = err -> + err + end + else + {ptr, m} + end + end + + defp extract_mask_ptr(opts) do + case Keyword.get(opts, :mask) do + nil -> 0 + %Mask{source: %Matrix{data: %{ptr: ptr}}} -> ptr + %Mask{source: %Vector{data: %{ptr: ptr}}} -> ptr + end + end + + defp mask_is_complement?(opts) do + case Keyword.get(opts, :mask) do + %Mask{complement: true} -> true + _ -> false + end + end + + defp build_descriptor_ptr(opts, mask_complement, opts_overrides \\ []) do + desc = Keyword.get(opts, :descriptor) + skip_transpose = Keyword.get(opts_overrides, :skip_transpose, false) + has_mask = Keyword.get(opts, :mask) != nil + + {inp0_tran, inp1_tran, output_replace, mask_structural} = + resolve_descriptor_flags(desc, skip_transpose, has_mask, mask_complement) + + if inp0_tran or inp1_tran or output_replace or mask_complement or mask_structural do + try do + GraphBLAS.Native.SuiteSparse.descriptor_create( + inp0_tran, + inp1_tran, + output_replace, + mask_complement, + mask_structural + ) + rescue + e -> Error.error({:backend_error, __MODULE__, e}) + end + else + 0 + end + end + + defp resolve_descriptor_flags(desc, skip_transpose, has_mask, _mask_complement) do + desc_struct? = is_struct(desc, GraphBLAS.Descriptor) + + inp0_tran = not skip_transpose and desc_struct? and desc.inp0_transpose == :transpose + inp1_tran = not skip_transpose and desc_struct? and desc.inp1_transpose == :transpose + output_replace = desc_struct? and desc.output == :replace + + mask_mode = if desc_struct?, do: desc.mask, else: :structural + mask_structural = has_mask and mask_mode == :structural + + {inp0_tran, inp1_tran, output_replace, mask_structural} + end + + defp cleanup_descriptor(0), do: :ok + + defp cleanup_descriptor(ptr) when is_integer(ptr) and ptr != 0 do + if GraphBLAS.Native.SuiteSparse.descriptor_is_custom(ptr) do + GraphBLAS.Native.SuiteSparse.descriptor_free(ptr) + end + end + + defp build_matrix_from_coo(ptr, nrows, ncols, entries, type) do + {rows, cols, vals} = unzip_coo(entries, type) + + try do + case type do + :int64 -> GraphBLAS.Native.SuiteSparse.matrix_build_int64(ptr, rows, cols, vals, length(entries)) + :fp64 -> GraphBLAS.Native.SuiteSparse.matrix_build_fp64(ptr, rows, cols, vals, length(entries)) + :bool -> GraphBLAS.Native.SuiteSparse.matrix_build_bool(ptr, rows, cols, vals, length(entries)) + end + + {:ok, %Matrix{shape: {nrows, ncols}, type: type, backend: __MODULE__, data: %{ptr: ptr}}} + rescue + e -> + GraphBLAS.Native.SuiteSparse.matrix_free(ptr) + Error.error({:backend_error, __MODULE__, e}) + end + end + + defp build_vector_from_entries(ptr, size, entries, type) do + {indices, vals} = unzip_vector_entries(entries, type) + + try do + case type do + :int64 -> GraphBLAS.Native.SuiteSparse.vector_build_int64(ptr, indices, vals, length(entries)) + :fp64 -> GraphBLAS.Native.SuiteSparse.vector_build_fp64(ptr, indices, vals, length(entries)) + :bool -> GraphBLAS.Native.SuiteSparse.vector_build_bool(ptr, indices, vals, length(entries)) + end + + {:ok, %Vector{size: size, type: type, backend: __MODULE__, data: %{ptr: ptr}}} + rescue + e -> + GraphBLAS.Native.SuiteSparse.vector_free(ptr) + Error.error({:backend_error, __MODULE__, e}) + end + end +end diff --git a/priv/native/suite_sparse/graphblas.zig b/priv/native/suite_sparse/graphblas.zig new file mode 100644 index 0000000..def3cfe --- /dev/null +++ b/priv/native/suite_sparse/graphblas.zig @@ -0,0 +1,994 @@ + const beam = @import("beam"); + + // ============================================================================= + // C type declarations (manual externs to avoid @cImport) + // ============================================================================= + + const GrB_Info = c_int; + const GrB_Index = u64; + const GrB_Matrix = ?*anyopaque; + const GrB_Vector = ?*anyopaque; + const GrB_Type = ?*anyopaque; + const GrB_Semiring = ?*anyopaque; + const GrB_Monoid = ?*anyopaque; + const GrB_BinaryOp = ?*anyopaque; + const GrB_Descriptor = ?*anyopaque; + + const GrB_BLOCKING: c_int = 0; + const GrB_SUCCESS: c_int = 0; + const GxB_JIT_OFF: c_int = 0; + const GxB_JIT_C_CONTROL: c_int = 7029; + const GrB_UNINITIALIZED_OBJECT: c_int = 4; + const GrB_NULL_POINTER: c_int = 5; + const GrB_INVALID_VALUE: c_int = 6; + const GrB_INVALID_INDEX: c_int = 7; + const GrB_DOMAIN_MISMATCH: c_int = 8; + const GrB_DIMENSION_MISMATCH: c_int = 9; + const GrB_OUTPUT_NOT_EMPTY: c_int = 10; + const GrB_NOT_IMPLEMENTED: c_int = 11; + const GrB_ALREADY_SET: c_int = 12; + const GrB_PANIC: c_int = -1; + const GrB_OUT_OF_MEMORY: c_int = -2; + const GrB_INSUFFICIENT_SPACE: c_int = -3; + const GrB_INVALID_OBJECT: c_int = -4; + const GrB_INDEX_OUT_OF_BOUNDS: c_int = -5; + const GrB_EMPTY_OBJECT: c_int = -6; + + // Type globals (resolved at runtime from SuiteSparse shared library) + extern var GrB_BOOL: GrB_Type; + extern var GrB_INT64: GrB_Type; + extern var GrB_FP64: GrB_Type; + + // C function declarations + extern fn GrB_init(mode: c_int) GrB_Info; + extern fn GrB_finalize() GrB_Info; + extern fn GrB_Matrix_new(A: *GrB_Matrix, thetype: GrB_Type, nrows: GrB_Index, ncols: GrB_Index) GrB_Info; + extern fn GrB_Matrix_free(A: *GrB_Matrix) GrB_Info; + extern fn GrB_Matrix_nrows(nrows: *GrB_Index, A: GrB_Matrix) GrB_Info; + extern fn GrB_Matrix_ncols(ncols: *GrB_Index, A: GrB_Matrix) GrB_Info; + extern fn GrB_Matrix_nvals(nvals: *GrB_Index, A: GrB_Matrix) GrB_Info; + extern fn GrB_Matrix_build_INT64(A: GrB_Matrix, I: [*]const GrB_Index, J: [*]const GrB_Index, X: [*]const i64, nvals: GrB_Index, dup: GrB_BinaryOp) GrB_Info; + extern fn GrB_Matrix_build_FP64(A: GrB_Matrix, I: [*]const GrB_Index, J: [*]const GrB_Index, X: [*]const f64, nvals: GrB_Index, dup: GrB_BinaryOp) GrB_Info; + extern fn GrB_Matrix_build_BOOL(A: GrB_Matrix, I: [*]const GrB_Index, J: [*]const GrB_Index, X: [*]const bool, nvals: GrB_Index, dup: GrB_BinaryOp) GrB_Info; + extern fn GrB_Matrix_extractTuples_INT64(I: [*]GrB_Index, J: [*]GrB_Index, X: [*]i64, nvals: *GrB_Index, A: GrB_Matrix) GrB_Info; + extern fn GrB_Matrix_extractTuples_FP64(I: [*]GrB_Index, J: [*]GrB_Index, X: [*]f64, nvals: *GrB_Index, A: GrB_Matrix) GrB_Info; + extern fn GrB_Matrix_extractTuples_BOOL(I: [*]GrB_Index, J: [*]GrB_Index, X: [*]bool, nvals: *GrB_Index, A: GrB_Matrix) GrB_Info; + extern fn GrB_mxm(C: GrB_Matrix, Mask: GrB_Matrix, accum: GrB_BinaryOp, semiring: GrB_Semiring, A: GrB_Matrix, B: GrB_Matrix, desc: GrB_Descriptor) GrB_Info; + extern fn GrB_mxv(w: GrB_Vector, mask: GrB_Vector, accum: GrB_BinaryOp, semiring: GrB_Semiring, A: GrB_Matrix, u: GrB_Vector, desc: GrB_Descriptor) GrB_Info; + extern fn GrB_transpose(C: GrB_Matrix, Mask: GrB_Matrix, accum: GrB_BinaryOp, A: GrB_Matrix, desc: GrB_Descriptor) GrB_Info; + extern fn GrB_Matrix_eWiseAdd_Monoid(C: GrB_Matrix, Mask: GrB_Matrix, accum: GrB_BinaryOp, monoid: GrB_Monoid, A: GrB_Matrix, B: GrB_Matrix, desc: GrB_Descriptor) GrB_Info; + extern fn GrB_Matrix_eWiseMult_Monoid(C: GrB_Matrix, Mask: GrB_Matrix, accum: GrB_BinaryOp, monoid: GrB_Monoid, A: GrB_Matrix, B: GrB_Matrix, desc: GrB_Descriptor) GrB_Info; + extern fn GrB_Matrix_reduce_Monoid(w: GrB_Vector, mask: GrB_Vector, accum: GrB_BinaryOp, monoid: GrB_Monoid, A: GrB_Matrix, desc: GrB_Descriptor) GrB_Info; + extern fn GxB_Matrix_type(type_ptr: *GrB_Type, A: GrB_Matrix) GrB_Info; + extern fn GxB_Vector_type(type_ptr: *GrB_Type, v: GrB_Vector) GrB_Info; + extern fn GrB_Vector_new(v: *GrB_Vector, thetype: GrB_Type, n: GrB_Index) GrB_Info; + extern fn GrB_Vector_free(v: *GrB_Vector) GrB_Info; + extern fn GrB_Vector_size(n: *GrB_Index, v: GrB_Vector) GrB_Info; + extern fn GrB_Vector_nvals(nvals: *GrB_Index, v: GrB_Vector) GrB_Info; + extern fn GrB_Vector_build_INT64(w: GrB_Vector, I: [*]const GrB_Index, X: [*]const i64, nvals: GrB_Index, dup: GrB_BinaryOp) GrB_Info; + extern fn GrB_Vector_build_FP64(w: GrB_Vector, I: [*]const GrB_Index, X: [*]const f64, nvals: GrB_Index, dup: GrB_BinaryOp) GrB_Info; + extern fn GrB_Vector_build_BOOL(w: GrB_Vector, I: [*]const GrB_Index, X: [*]const bool, nvals: GrB_Index, dup: GrB_BinaryOp) GrB_Info; + extern fn GrB_Vector_extractTuples_INT64(I: [*]GrB_Index, X: [*]i64, nvals: *GrB_Index, v: GrB_Vector) GrB_Info; + extern fn GrB_Vector_extractTuples_FP64(I: [*]GrB_Index, X: [*]f64, nvals: *GrB_Index, v: GrB_Vector) GrB_Info; + extern fn GrB_Vector_extractTuples_BOOL(I: [*]GrB_Index, X: [*]bool, nvals: *GrB_Index, v: GrB_Vector) GrB_Info; + extern fn GrB_vxm(w: GrB_Vector, mask: GrB_Vector, accum: GrB_BinaryOp, semiring: GrB_Semiring, u: GrB_Vector, A: GrB_Matrix, desc: GrB_Descriptor) GrB_Info; + extern fn GrB_Vector_eWiseAdd_Monoid(w: GrB_Vector, mask: GrB_Vector, accum: GrB_BinaryOp, monoid: GrB_Monoid, u: GrB_Vector, v: GrB_Vector, desc: GrB_Descriptor) GrB_Info; + extern fn GrB_Vector_eWiseMult_Monoid(w: GrB_Vector, mask: GrB_Vector, accum: GrB_BinaryOp, monoid: GrB_Monoid, u: GrB_Vector, v: GrB_Vector, desc: GrB_Descriptor) GrB_Info; + extern fn GrB_Vector_reduce_INT64(c: *i64, accum: GrB_BinaryOp, monoid: GrB_Monoid, u: GrB_Vector, desc: GrB_Descriptor) GrB_Info; + extern fn GrB_Vector_reduce_FP64(c: *f64, accum: GrB_BinaryOp, monoid: GrB_Monoid, u: GrB_Vector, desc: GrB_Descriptor) GrB_Info; + extern fn GrB_Vector_reduce_BOOL(c: *bool, accum: GrB_BinaryOp, monoid: GrB_Monoid, u: GrB_Vector, desc: GrB_Descriptor) GrB_Info; + extern fn GxB_Global_Option_set_INT32(option: c_int, value: c_int) GrB_Info; + + // Descriptor management + extern fn GrB_Descriptor_new(desc: *GrB_Descriptor) GrB_Info; + extern fn GrB_Descriptor_set_INT32(desc: GrB_Descriptor, field: c_int, value: c_int) GrB_Info; + extern fn GrB_Descriptor_free(desc: *GrB_Descriptor) GrB_Info; + + // Descriptor field constants (from GraphBLAS.h) + const GrB_OUTP: c_int = 0; + const GrB_MASK: c_int = 1; + const GrB_INP0: c_int = 2; + const GrB_INP1: c_int = 3; + + // Descriptor value constants (from GraphBLAS.h) + const GrB_REPLACE: c_int = 1; + const GrB_COMP: c_int = 2; + const GrB_TRAN: c_int = 3; + const GrB_STRUCTURE: c_int = 4; + const GrB_COMP_STRUCTURE: c_int = 6; + + // Pre-defined descriptor globals (extern) + extern var GrB_DESC_S: GrB_Descriptor; + extern var GrB_DESC_SC: GrB_Descriptor; + extern var GrB_DESC_T0: GrB_Descriptor; + extern var GrB_DESC_T1: GrB_Descriptor; + extern var GrB_DESC_T0T1: GrB_Descriptor; + extern var GrB_DESC_ST0: GrB_Descriptor; + extern var GrB_DESC_ST1: GrB_Descriptor; + extern var GrB_DESC_ST0T1: GrB_Descriptor; + extern var GrB_DESC_SCT0: GrB_Descriptor; + extern var GrB_DESC_SCT1: GrB_Descriptor; + extern var GrB_DESC_SCT0T1: GrB_Descriptor; + extern var GrB_DESC_R: GrB_Descriptor; + extern var GrB_DESC_RS: GrB_Descriptor; + extern var GrB_DESC_RSC: GrB_Descriptor; + extern var GrB_DESC_RT0: GrB_Descriptor; + extern var GrB_DESC_RT1: GrB_Descriptor; + extern var GrB_DESC_RT0T1: GrB_Descriptor; + extern var GrB_DESC_RST0: GrB_Descriptor; + extern var GrB_DESC_RST1: GrB_Descriptor; + extern var GrB_DESC_RST0T1: GrB_Descriptor; + extern var GrB_DESC_RSCT0: GrB_Descriptor; + extern var GrB_DESC_RSCT1: GrB_Descriptor; + extern var GrB_DESC_RSCT0T1: GrB_Descriptor; + + // GrB_NO_VALUE — returned by extractElement when entry is structural zero + const GrB_NO_VALUE: c_int = 1; + + // Matrix set/extract/dup + extern fn GrB_Matrix_setElement_INT64(m: GrB_Matrix, val: i64, row: GrB_Index, col: GrB_Index) GrB_Info; + extern fn GrB_Matrix_setElement_FP64(m: GrB_Matrix, val: f64, row: GrB_Index, col: GrB_Index) GrB_Info; + extern fn GrB_Matrix_setElement_BOOL(m: GrB_Matrix, val: bool, row: GrB_Index, col: GrB_Index) GrB_Info; + extern fn GrB_Matrix_extractElement_INT64(val: *i64, m: GrB_Matrix, row: GrB_Index, col: GrB_Index) GrB_Info; + extern fn GrB_Matrix_extractElement_FP64(val: *f64, m: GrB_Matrix, row: GrB_Index, col: GrB_Index) GrB_Info; + extern fn GrB_Matrix_extractElement_BOOL(val: *bool, m: GrB_Matrix, row: GrB_Index, col: GrB_Index) GrB_Info; + extern fn GrB_Matrix_dup(copy: *GrB_Matrix, source: GrB_Matrix) GrB_Info; + + // Vector set/extract/dup + extern fn GrB_Vector_setElement_INT64(v: GrB_Vector, val: i64, idx: GrB_Index) GrB_Info; + extern fn GrB_Vector_setElement_FP64(v: GrB_Vector, val: f64, idx: GrB_Index) GrB_Info; + extern fn GrB_Vector_setElement_BOOL(v: GrB_Vector, val: bool, idx: GrB_Index) GrB_Info; + extern fn GrB_Vector_extractElement_INT64(val: *i64, v: GrB_Vector, idx: GrB_Index) GrB_Info; + extern fn GrB_Vector_extractElement_FP64(val: *f64, v: GrB_Vector, idx: GrB_Index) GrB_Info; + extern fn GrB_Vector_extractElement_BOOL(val: *bool, v: GrB_Vector, idx: GrB_Index) GrB_Info; + extern fn GrB_Vector_dup(copy: *GrB_Vector, source: GrB_Vector) GrB_Info; + + // SuiteSparse semiring globals + extern var GrB_PLUS_TIMES_SEMIRING_INT64: GrB_Semiring; + extern var GrB_PLUS_TIMES_SEMIRING_FP64: GrB_Semiring; + extern var GrB_PLUS_MIN_SEMIRING_INT64: GrB_Semiring; + extern var GrB_PLUS_MIN_SEMIRING_FP64: GrB_Semiring; + extern var GxB_PLUS_TIMES_INT64: GrB_Semiring; + extern var GxB_PLUS_TIMES_FP64: GrB_Semiring; + extern var GxB_PLUS_MIN_INT64: GrB_Semiring; + extern var GxB_PLUS_MIN_FP64: GrB_Semiring; + extern var GxB_MAX_PLUS_INT64: GrB_Semiring; + extern var GxB_MAX_PLUS_FP64: GrB_Semiring; + extern var GxB_MAX_MIN_INT64: GrB_Semiring; + extern var GxB_MAX_MIN_FP64: GrB_Semiring; + extern var GxB_LOR_LAND_BOOL: GrB_Semiring; + extern var GxB_LAND_LOR_BOOL: GrB_Semiring; + + // SuiteSparse monoid globals + extern var GrB_PLUS_MONOID_INT64: GrB_Monoid; + extern var GrB_PLUS_MONOID_FP64: GrB_Monoid; + extern var GrB_TIMES_MONOID_INT64: GrB_Monoid; + extern var GrB_TIMES_MONOID_FP64: GrB_Monoid; + extern var GrB_MIN_MONOID_INT64: GrB_Monoid; + extern var GrB_MIN_MONOID_FP64: GrB_Monoid; + extern var GrB_MAX_MONOID_INT64: GrB_Monoid; + extern var GrB_MAX_MONOID_FP64: GrB_Monoid; + extern var GrB_LAND_MONOID_BOOL: GrB_Monoid; + extern var GrB_LOR_MONOID_BOOL: GrB_Monoid; + extern var GrB_LXOR_MONOID_BOOL: GrB_Monoid; + + // Binary ops for build duplicate resolution + extern var GrB_PLUS_INT64: GrB_BinaryOp; + extern var GrB_PLUS_FP64: GrB_BinaryOp; + extern var GxB_LOR_BOOL: GrB_BinaryOp; + + // ============================================================================= + // Error translation + // ============================================================================= + + const GraphBLASError = error { + dimension_mismatch, + null_pointer, + invalid_value, + invalid_index, + domain_mismatch, + output_not_empty, + not_implemented, + already_set, + panic_error, + out_of_memory, + insufficient_space, + invalid_object, + index_out_of_bounds, + empty_object, + uninitialized_object, + unhandled_grb_error, + }; + + fn translate_info(info: GrB_Info) GraphBLASError!void { + return switch (info) { + GrB_SUCCESS => {}, + GrB_UNINITIALIZED_OBJECT => error.uninitialized_object, + GrB_NULL_POINTER => error.null_pointer, + GrB_INVALID_VALUE => error.invalid_value, + GrB_INVALID_INDEX => error.invalid_index, + GrB_DOMAIN_MISMATCH => error.domain_mismatch, + GrB_DIMENSION_MISMATCH => error.dimension_mismatch, + GrB_OUTPUT_NOT_EMPTY => error.output_not_empty, + GrB_NOT_IMPLEMENTED => error.not_implemented, + GrB_ALREADY_SET => error.already_set, + GrB_PANIC => error.panic_error, + GrB_OUT_OF_MEMORY => error.out_of_memory, + GrB_INSUFFICIENT_SPACE => error.insufficient_space, + GrB_INVALID_OBJECT => error.invalid_object, + GrB_INDEX_OUT_OF_BOUNDS => error.index_out_of_bounds, + GrB_EMPTY_OBJECT => error.empty_object, + else => error.unhandled_grb_error, + }; + } + + // ============================================================================= + // Type / semiring / monoid resolution + // ============================================================================= + + fn type_code_to_grb_type(code: u8) GrB_Type { + return switch (code) { + 1 => GrB_BOOL, + 8 => GrB_INT64, + 11 => GrB_FP64, + else => GrB_INT64, + }; + } + + fn semiring_from_code(code: u8) GrB_Semiring { + return switch (code) { + 1 => GrB_PLUS_TIMES_SEMIRING_INT64, + 2 => GrB_PLUS_TIMES_SEMIRING_FP64, + 3 => GrB_PLUS_MIN_SEMIRING_INT64, + 4 => GrB_PLUS_MIN_SEMIRING_FP64, + 5 => GxB_MAX_PLUS_INT64, + 6 => GxB_MAX_PLUS_FP64, + 7 => GxB_MAX_MIN_INT64, + 8 => GxB_MAX_MIN_FP64, + 9 => GxB_LOR_LAND_BOOL, + 10 => GxB_LAND_LOR_BOOL, + else => GrB_PLUS_TIMES_SEMIRING_INT64, + }; + } + + fn monoid_from_code(code: u8) GrB_Monoid { + return switch (code) { + 1 => GrB_PLUS_MONOID_INT64, + 2 => GrB_PLUS_MONOID_FP64, + 3 => GrB_TIMES_MONOID_INT64, + 4 => GrB_TIMES_MONOID_FP64, + 5 => GrB_MIN_MONOID_INT64, + 6 => GrB_MIN_MONOID_FP64, + 7 => GrB_MAX_MONOID_INT64, + 8 => GrB_MAX_MONOID_FP64, + 9 => GrB_LAND_MONOID_BOOL, + 10 => GrB_LOR_MONOID_BOOL, + 11 => GrB_LXOR_MONOID_BOOL, + else => GrB_PLUS_MONOID_INT64, + }; + } + + // Pointer helpers: usize <-> opaque pointer conversions + fn mat_from_ptr(ptr: usize) GrB_Matrix { + return @ptrFromInt(ptr); + } + + fn mat_to_ptr(matrix: GrB_Matrix) usize { + return @intFromPtr(matrix); + } + + fn vec_from_ptr(ptr: usize) GrB_Vector { + return @ptrFromInt(ptr); + } + + fn vec_to_ptr(vector: GrB_Vector) usize { + return @intFromPtr(vector); + } + + // ============================================================================= + // Lifecycle + // ============================================================================= + + pub fn grb_init() !void { + const info = GrB_init(GrB_BLOCKING); + try translate_info(info); + // Disable SuiteSparse JIT compiler — pre-compiled kernels suffice + // and JIT fails on some semirings (plus_min, etc.) returning + // GxB_JIT_ERROR (-7001) which causes dirty_cpu NIFs to hang. + _ = GxB_Global_Option_set_INT32(GxB_JIT_C_CONTROL, GxB_JIT_OFF); + } + + pub fn grb_finalize() void { + _ = GrB_finalize(); + } + + // ============================================================================= + // Matrix creation and destruction + // ============================================================================= + + pub fn matrix_new(nrows: u64, ncols: u64, type_code: u8) !usize { + var matrix: GrB_Matrix = null; + const grb_type = type_code_to_grb_type(type_code); + const info = GrB_Matrix_new(&matrix, grb_type, nrows, ncols); + try translate_info(info); + return mat_to_ptr(matrix); + } + + pub fn matrix_free(ptr: usize) void { + if (ptr == 0) return; + var matrix: GrB_Matrix = mat_from_ptr(ptr); + _ = GrB_Matrix_free(&matrix); + } + + // ============================================================================= + // Matrix inspection + // ============================================================================= + + pub fn matrix_nrows(ptr: usize) !u64 { + const matrix = mat_from_ptr(ptr); + var nrows: u64 = undefined; + const info = GrB_Matrix_nrows(&nrows, matrix); + try translate_info(info); + return nrows; + } + + pub fn matrix_ncols(ptr: usize) !u64 { + const matrix = mat_from_ptr(ptr); + var ncols: u64 = undefined; + const info = GrB_Matrix_ncols(&ncols, matrix); + try translate_info(info); + return ncols; + } + + pub fn matrix_nvals(ptr: usize) !u64 { + const matrix = mat_from_ptr(ptr); + var nvals: u64 = undefined; + const info = GrB_Matrix_nvals(&nvals, matrix); + try translate_info(info); + return nvals; + } + + // ============================================================================= + // Matrix data loading + // ============================================================================= + + pub fn matrix_build_int64(ptr: usize, rows: []u64, cols: []u64, vals: []i64, nvals: u64) !void { + const matrix = mat_from_ptr(ptr); + const info = GrB_Matrix_build_INT64(matrix, rows.ptr, cols.ptr, vals.ptr, nvals, GrB_PLUS_INT64); + try translate_info(info); + } + + pub fn matrix_build_fp64(ptr: usize, rows: []u64, cols: []u64, vals: []f64, nvals: u64) !void { + const matrix = mat_from_ptr(ptr); + const info = GrB_Matrix_build_FP64(matrix, rows.ptr, cols.ptr, vals.ptr, nvals, GrB_PLUS_FP64); + try translate_info(info); + } + + pub fn matrix_build_bool(ptr: usize, rows: []u64, cols: []u64, vals: []bool, nvals: u64) !void { + const matrix = mat_from_ptr(ptr); + const info = GrB_Matrix_build_BOOL(matrix, rows.ptr, cols.ptr, vals.ptr, nvals, GxB_LOR_BOOL); + try translate_info(info); + } + + // ============================================================================= + // Matrix data extraction + // ============================================================================= + + pub fn matrix_extract_tuples_int64(ptr: usize, nvals: u64) !struct { rows: []u64, cols: []u64, vals: []i64, actual_nvals: u64 } { + const matrix = mat_from_ptr(ptr); + const alloc = beam.allocator; + var size: u64 = nvals; + const rows = try alloc.alloc(u64, size); + const cols = try alloc.alloc(u64, size); + const vals = try alloc.alloc(i64, size); + const info = GrB_Matrix_extractTuples_INT64(rows.ptr, cols.ptr, vals.ptr, &size, matrix); + try translate_info(info); + return .{ .rows = rows[0..size], .cols = cols[0..size], .vals = vals[0..size], .actual_nvals = size }; + } + + pub fn matrix_extract_tuples_fp64(ptr: usize, nvals: u64) !struct { rows: []u64, cols: []u64, vals: []f64, actual_nvals: u64 } { + const matrix = mat_from_ptr(ptr); + const alloc = beam.allocator; + var size: u64 = nvals; + const rows = try alloc.alloc(u64, size); + const cols = try alloc.alloc(u64, size); + const vals = try alloc.alloc(f64, size); + const info = GrB_Matrix_extractTuples_FP64(rows.ptr, cols.ptr, vals.ptr, &size, matrix); + try translate_info(info); + return .{ .rows = rows[0..size], .cols = cols[0..size], .vals = vals[0..size], .actual_nvals = size }; + } + + pub fn matrix_extract_tuples_bool(ptr: usize, nvals: u64) !struct { rows: []u64, cols: []u64, vals: []bool, actual_nvals: u64 } { + const matrix = mat_from_ptr(ptr); + const alloc = beam.allocator; + var size: u64 = nvals; + const rows = try alloc.alloc(u64, size); + const cols = try alloc.alloc(u64, size); + const vals = try alloc.alloc(bool, size); + const info = GrB_Matrix_extractTuples_BOOL(rows.ptr, cols.ptr, vals.ptr, &size, matrix); + try translate_info(info); + return .{ .rows = rows[0..size], .cols = cols[0..size], .vals = vals[0..size], .actual_nvals = size }; + } + + // ============================================================================= + // Matrix computation + // ============================================================================= + + fn desc_from_ptr(ptr: usize) GrB_Descriptor { + if (ptr == 0) return null; + return @ptrFromInt(ptr); + } + + fn mask_mat_from_ptr(ptr: usize) GrB_Matrix { + if (ptr == 0) return null; + return @ptrFromInt(ptr); + } + + fn mask_vec_from_ptr(ptr: usize) GrB_Vector { + if (ptr == 0) return null; + return @ptrFromInt(ptr); + } + + pub fn matrix_mxm(a_ptr: usize, b_ptr: usize, semiring_code: u8, mask_ptr: usize, desc_ptr: usize) !usize { + const a = mat_from_ptr(a_ptr); + const b = mat_from_ptr(b_ptr); + const semiring = semiring_from_code(semiring_code); + const mask = mask_mat_from_ptr(mask_ptr); + const desc = desc_from_ptr(desc_ptr); + var nrows_a: u64 = undefined; + var ncols_b: u64 = undefined; + _ = GrB_Matrix_nrows(&nrows_a, a); + _ = GrB_Matrix_ncols(&ncols_b, b); + var a_type: GrB_Type = null; + _ = GxB_Matrix_type(&a_type, a); + var result: GrB_Matrix = null; + const info_new = GrB_Matrix_new(&result, a_type, nrows_a, ncols_b); + try translate_info(info_new); + const info = GrB_mxm(result, mask, null, semiring, a, b, desc); + if (info != GrB_SUCCESS) { + _ = GrB_Matrix_free(&result); + try translate_info(info); + } + return mat_to_ptr(result); + } + + pub fn matrix_mxv(matrix_ptr: usize, vector_ptr: usize, semiring_code: u8, mask_ptr: usize, desc_ptr: usize) !usize { + const matrix = mat_from_ptr(matrix_ptr); + const vector = vec_from_ptr(vector_ptr); + const semiring = semiring_from_code(semiring_code); + const mask = mask_vec_from_ptr(mask_ptr); + const desc = desc_from_ptr(desc_ptr); + var nrows: u64 = undefined; + _ = GrB_Matrix_nrows(&nrows, matrix); + var mat_type: GrB_Type = null; + _ = GxB_Matrix_type(&mat_type, matrix); + var result: GrB_Vector = null; + const info_new = GrB_Vector_new(&result, mat_type, nrows); + try translate_info(info_new); + const info = GrB_mxv(result, mask, null, semiring, matrix, vector, desc); + if (info != GrB_SUCCESS) { + _ = GrB_Vector_free(&result); + try translate_info(info); + } + return vec_to_ptr(result); + } + + pub fn matrix_transpose(ptr: usize, mask_ptr: usize, desc_ptr: usize) !usize { + const matrix = mat_from_ptr(ptr); + const mask = mask_mat_from_ptr(mask_ptr); + const desc = desc_from_ptr(desc_ptr); + var nrows: u64 = undefined; + var ncols: u64 = undefined; + _ = GrB_Matrix_nrows(&nrows, matrix); + _ = GrB_Matrix_ncols(&ncols, matrix); + var mat_type: GrB_Type = null; + _ = GxB_Matrix_type(&mat_type, matrix); + var result: GrB_Matrix = null; + const info_new = GrB_Matrix_new(&result, mat_type, ncols, nrows); + try translate_info(info_new); + const info = GrB_transpose(result, mask, null, matrix, desc); + if (info != GrB_SUCCESS) { + _ = GrB_Matrix_free(&result); + try translate_info(info); + } + return mat_to_ptr(result); + } + + pub fn matrix_ewise_add(a_ptr: usize, b_ptr: usize, monoid_code: u8, mask_ptr: usize, desc_ptr: usize) !usize { + const a = mat_from_ptr(a_ptr); + const b = mat_from_ptr(b_ptr); + const monoid = monoid_from_code(monoid_code); + const mask = mask_mat_from_ptr(mask_ptr); + const desc = desc_from_ptr(desc_ptr); + var nrows: u64 = undefined; + var ncols: u64 = undefined; + _ = GrB_Matrix_nrows(&nrows, a); + _ = GrB_Matrix_ncols(&ncols, a); + var a_type: GrB_Type = null; + _ = GxB_Matrix_type(&a_type, a); + var result: GrB_Matrix = null; + const info_new = GrB_Matrix_new(&result, a_type, nrows, ncols); + try translate_info(info_new); + const info = GrB_Matrix_eWiseAdd_Monoid(result, mask, null, monoid, a, b, desc); + if (info != GrB_SUCCESS) { + _ = GrB_Matrix_free(&result); + try translate_info(info); + } + return mat_to_ptr(result); + } + + pub fn matrix_ewise_mult(a_ptr: usize, b_ptr: usize, monoid_code: u8, mask_ptr: usize, desc_ptr: usize) !usize { + const a = mat_from_ptr(a_ptr); + const b = mat_from_ptr(b_ptr); + const monoid = monoid_from_code(monoid_code); + const mask = mask_mat_from_ptr(mask_ptr); + const desc = desc_from_ptr(desc_ptr); + var nrows: u64 = undefined; + var ncols: u64 = undefined; + _ = GrB_Matrix_nrows(&nrows, a); + _ = GrB_Matrix_ncols(&ncols, a); + var a_type: GrB_Type = null; + _ = GxB_Matrix_type(&a_type, a); + var result: GrB_Matrix = null; + const info_new = GrB_Matrix_new(&result, a_type, nrows, ncols); + try translate_info(info_new); + const info = GrB_Matrix_eWiseMult_Monoid(result, mask, null, monoid, a, b, desc); + if (info != GrB_SUCCESS) { + _ = GrB_Matrix_free(&result); + try translate_info(info); + } + return mat_to_ptr(result); + } + + pub fn matrix_reduce_to_vector(matrix_ptr: usize, monoid_code: u8, mask_ptr: usize, desc_ptr: usize) !usize { + const matrix = mat_from_ptr(matrix_ptr); + const monoid = monoid_from_code(monoid_code); + const mask = mask_vec_from_ptr(mask_ptr); + const desc = desc_from_ptr(desc_ptr); + var nrows: u64 = undefined; + _ = GrB_Matrix_nrows(&nrows, matrix); + var mat_type: GrB_Type = null; + _ = GxB_Matrix_type(&mat_type, matrix); + var result: GrB_Vector = null; + const info_new = GrB_Vector_new(&result, mat_type, nrows); + try translate_info(info_new); + const info = GrB_Matrix_reduce_Monoid(result, mask, null, monoid, matrix, desc); + if (info != GrB_SUCCESS) { + _ = GrB_Vector_free(&result); + try translate_info(info); + } + return vec_to_ptr(result); + } + + // ============================================================================= + // Vector creation and destruction + // ============================================================================= + + pub fn vector_new(size: u64, type_code: u8) !usize { + var vector: GrB_Vector = null; + const grb_type = type_code_to_grb_type(type_code); + const info = GrB_Vector_new(&vector, grb_type, size); + try translate_info(info); + return vec_to_ptr(vector); + } + + pub fn vector_free(ptr: usize) void { + if (ptr == 0) return; + var vector: GrB_Vector = vec_from_ptr(ptr); + _ = GrB_Vector_free(&vector); + } + + // ============================================================================= + // Vector inspection + // ============================================================================= + + pub fn vector_size(ptr: usize) !u64 { + const vector = vec_from_ptr(ptr); + var size: u64 = undefined; + const info = GrB_Vector_size(&size, vector); + try translate_info(info); + return size; + } + + pub fn vector_nvals(ptr: usize) !u64 { + const vector = vec_from_ptr(ptr); + var nvals: u64 = undefined; + const info = GrB_Vector_nvals(&nvals, vector); + try translate_info(info); + return nvals; + } + + // ============================================================================= + // Vector data loading + // ============================================================================= + + pub fn vector_build_int64(ptr: usize, indices: []u64, vals: []i64, nvals: u64) !void { + const vector = vec_from_ptr(ptr); + const info = GrB_Vector_build_INT64(vector, indices.ptr, vals.ptr, nvals, GrB_PLUS_INT64); + try translate_info(info); + } + + pub fn vector_build_fp64(ptr: usize, indices: []u64, vals: []f64, nvals: u64) !void { + const vector = vec_from_ptr(ptr); + const info = GrB_Vector_build_FP64(vector, indices.ptr, vals.ptr, nvals, GrB_PLUS_FP64); + try translate_info(info); + } + + pub fn vector_build_bool(ptr: usize, indices: []u64, vals: []bool, nvals: u64) !void { + const vector = vec_from_ptr(ptr); + const info = GrB_Vector_build_BOOL(vector, indices.ptr, vals.ptr, nvals, GxB_LOR_BOOL); + try translate_info(info); + } + + // ============================================================================= + // Vector data extraction + // ============================================================================= + + pub fn vector_extract_tuples_int64(ptr: usize, nvals: u64) !struct { indices: []u64, vals: []i64, actual_nvals: u64 } { + const vector = vec_from_ptr(ptr); + const alloc = beam.allocator; + var size: u64 = nvals; + const indices = try alloc.alloc(u64, size); + const vals = try alloc.alloc(i64, size); + const info = GrB_Vector_extractTuples_INT64(indices.ptr, vals.ptr, &size, vector); + try translate_info(info); + return .{ .indices = indices[0..size], .vals = vals[0..size], .actual_nvals = size }; + } + + pub fn vector_extract_tuples_fp64(ptr: usize, nvals: u64) !struct { indices: []u64, vals: []f64, actual_nvals: u64 } { + const vector = vec_from_ptr(ptr); + const alloc = beam.allocator; + var size: u64 = nvals; + const indices = try alloc.alloc(u64, size); + const vals = try alloc.alloc(f64, size); + const info = GrB_Vector_extractTuples_FP64(indices.ptr, vals.ptr, &size, vector); + try translate_info(info); + return .{ .indices = indices[0..size], .vals = vals[0..size], .actual_nvals = size }; + } + + pub fn vector_extract_tuples_bool(ptr: usize, nvals: u64) !struct { indices: []u64, vals: []bool, actual_nvals: u64 } { + const vector = vec_from_ptr(ptr); + const alloc = beam.allocator; + var size: u64 = nvals; + const indices = try alloc.alloc(u64, size); + const vals = try alloc.alloc(bool, size); + const info = GrB_Vector_extractTuples_BOOL(indices.ptr, vals.ptr, &size, vector); + try translate_info(info); + return .{ .indices = indices[0..size], .vals = vals[0..size], .actual_nvals = size }; + } + + // ============================================================================= + // Vector computation + // ============================================================================= + + pub fn vector_vxm(vector_ptr: usize, matrix_ptr: usize, semiring_code: u8, mask_ptr: usize, desc_ptr: usize) !usize { + const vector = vec_from_ptr(vector_ptr); + const matrix = mat_from_ptr(matrix_ptr); + const semiring = semiring_from_code(semiring_code); + const mask = mask_vec_from_ptr(mask_ptr); + const desc = desc_from_ptr(desc_ptr); + var ncols: u64 = undefined; + _ = GrB_Matrix_ncols(&ncols, matrix); + var vec_type: GrB_Type = null; + _ = GxB_Vector_type(&vec_type, vector); + var result: GrB_Vector = null; + const info_new = GrB_Vector_new(&result, vec_type, ncols); + try translate_info(info_new); + const info = GrB_vxm(result, mask, null, semiring, vector, matrix, desc); + if (info != GrB_SUCCESS) { + _ = GrB_Vector_free(&result); + try translate_info(info); + } + return vec_to_ptr(result); + } + + pub fn vector_ewise_add(a_ptr: usize, b_ptr: usize, monoid_code: u8, mask_ptr: usize, desc_ptr: usize) !usize { + const a = vec_from_ptr(a_ptr); + const b = vec_from_ptr(b_ptr); + const monoid = monoid_from_code(monoid_code); + const mask = mask_vec_from_ptr(mask_ptr); + const desc = desc_from_ptr(desc_ptr); + var size: u64 = undefined; + _ = GrB_Vector_size(&size, a); + var a_type: GrB_Type = null; + _ = GxB_Vector_type(&a_type, a); + var result: GrB_Vector = null; + const info_new = GrB_Vector_new(&result, a_type, size); + try translate_info(info_new); + const info = GrB_Vector_eWiseAdd_Monoid(result, mask, null, monoid, a, b, desc); + if (info != GrB_SUCCESS) { + _ = GrB_Vector_free(&result); + try translate_info(info); + } + return vec_to_ptr(result); + } + + pub fn vector_ewise_mult(a_ptr: usize, b_ptr: usize, monoid_code: u8, mask_ptr: usize, desc_ptr: usize) !usize { + const a = vec_from_ptr(a_ptr); + const b = vec_from_ptr(b_ptr); + const monoid = monoid_from_code(monoid_code); + const mask = mask_vec_from_ptr(mask_ptr); + const desc = desc_from_ptr(desc_ptr); + var size: u64 = undefined; + _ = GrB_Vector_size(&size, a); + var a_type: GrB_Type = null; + _ = GxB_Vector_type(&a_type, a); + var result: GrB_Vector = null; + const info_new = GrB_Vector_new(&result, a_type, size); + try translate_info(info_new); + const info = GrB_Vector_eWiseMult_Monoid(result, mask, null, monoid, a, b, desc); + if (info != GrB_SUCCESS) { + _ = GrB_Vector_free(&result); + try translate_info(info); + } + return vec_to_ptr(result); + } + + pub fn vector_reduce_to_scalar_int64(ptr: usize, monoid_code: u8) !i64 { + const vector = vec_from_ptr(ptr); + const monoid = monoid_from_code(monoid_code); + var result: i64 = 0; + const info = GrB_Vector_reduce_INT64(&result, null, monoid, vector, null); + try translate_info(info); + return result; + } + + pub fn vector_reduce_to_scalar_fp64(ptr: usize, monoid_code: u8) !f64 { + const vector = vec_from_ptr(ptr); + const monoid = monoid_from_code(monoid_code); + var result: f64 = 0.0; + const info = GrB_Vector_reduce_FP64(&result, null, monoid, vector, null); + try translate_info(info); + return result; + } + + pub fn vector_reduce_to_scalar_bool(ptr: usize, monoid_code: u8) !bool { + const vector = vec_from_ptr(ptr); + const monoid = monoid_from_code(monoid_code); + var result: bool = false; + const info = GrB_Vector_reduce_BOOL(&result, null, monoid, vector, null); + try translate_info(info); + return result; + } + + // ============================================================================= + // Descriptor management + // ============================================================================= + + fn resolve_descriptor(inp0_tran: bool, inp1_tran: bool, output_replace: bool, mask_comp: bool, mask_structural: bool) GrB_Descriptor { + // Use pre-defined descriptor globals when possible for reliability. + // Custom descriptors via GrB_Descriptor_set_INT32 fail for some mask + // field values, so we map to the closest pre-defined global. + if (output_replace) { + if (mask_comp and mask_structural) { + if (inp0_tran and inp1_tran) return GrB_DESC_RSCT0T1; + if (inp0_tran) return GrB_DESC_RSCT0; + if (inp1_tran) return GrB_DESC_RSCT1; + return GrB_DESC_RSC; + } else if (mask_structural) { + if (inp0_tran and inp1_tran) return GrB_DESC_RST0T1; + if (inp0_tran) return GrB_DESC_RST0; + if (inp1_tran) return GrB_DESC_RST1; + return GrB_DESC_RS; + } else if (mask_comp) { + // replace + complement valued mask: not a common pre-defined + // fall through to custom descriptor below + } else { + if (inp0_tran and inp1_tran) return GrB_DESC_RT0T1; + if (inp0_tran) return GrB_DESC_RT0; + if (inp1_tran) return GrB_DESC_RT1; + return GrB_DESC_R; + } + } + if (mask_comp and mask_structural) { + if (inp0_tran and inp1_tran) return GrB_DESC_SCT0T1; + if (inp0_tran) return GrB_DESC_SCT0; + if (inp1_tran) return GrB_DESC_SCT1; + return GrB_DESC_SC; + } else if (mask_structural) { + if (inp0_tran and inp1_tran) return GrB_DESC_ST0T1; + if (inp0_tran) return GrB_DESC_ST0; + if (inp1_tran) return GrB_DESC_ST1; + return GrB_DESC_S; + } else if (mask_comp) { + // complement valued mask without structural: not common + // fall through to custom descriptor below + } else { + if (inp0_tran and inp1_tran) return GrB_DESC_T0T1; + if (inp0_tran) return GrB_DESC_T0; + if (inp1_tran) return GrB_DESC_T1; + } + return null; + } + + pub fn descriptor_create(inp0_tran: bool, inp1_tran: bool, output_replace: bool, mask_comp: bool, mask_structural: bool) !usize { + // Use pre-defined descriptor globals for common combinations. + // These must not be freed via descriptor_free. + const predefined = resolve_descriptor(inp0_tran, inp1_tran, output_replace, mask_comp, mask_structural); + if (predefined != null) { + return @intFromPtr(predefined); + } + + // Fallback: create custom descriptor for uncommon combinations + var desc: GrB_Descriptor = null; + const info_new = GrB_Descriptor_new(&desc); + try translate_info(info_new); + + if (desc == null) return error.null_pointer; + + if (output_replace) { + _ = GrB_Descriptor_set_INT32(desc, GrB_OUTP, GrB_REPLACE); + } + if (inp0_tran) { + _ = GrB_Descriptor_set_INT32(desc, GrB_INP0, GrB_TRAN); + } + if (inp1_tran) { + _ = GrB_Descriptor_set_INT32(desc, GrB_INP1, GrB_TRAN); + } + // For mask field in custom descriptors, try setting each value + // If GrB_Descriptor_set_INT32 fails for some values, we proceed + // anyway since the default behavior may be acceptable + if (mask_comp and mask_structural) { + _ = GrB_Descriptor_set_INT32(desc, GrB_MASK, GrB_COMP_STRUCTURE); + } else if (mask_comp) { + _ = GrB_Descriptor_set_INT32(desc, GrB_MASK, GrB_COMP); + } else if (mask_structural) { + _ = GrB_Descriptor_set_INT32(desc, GrB_MASK, GrB_STRUCTURE); + } + + return @intFromPtr(desc); + } + + pub fn descriptor_is_custom(ptr: usize) bool { + // Check if the ptr matches any pre-defined descriptor global. + // If it does, it's not custom and should not be freed. + const known_predefined = [_]usize{ + @intFromPtr(GrB_DESC_S), + @intFromPtr(GrB_DESC_SC), + @intFromPtr(GrB_DESC_T0), + @intFromPtr(GrB_DESC_T1), + @intFromPtr(GrB_DESC_T0T1), + @intFromPtr(GrB_DESC_ST0), + @intFromPtr(GrB_DESC_ST1), + @intFromPtr(GrB_DESC_ST0T1), + @intFromPtr(GrB_DESC_SCT0), + @intFromPtr(GrB_DESC_SCT1), + @intFromPtr(GrB_DESC_SCT0T1), + @intFromPtr(GrB_DESC_R), + @intFromPtr(GrB_DESC_RS), + @intFromPtr(GrB_DESC_RSC), + @intFromPtr(GrB_DESC_RT0), + @intFromPtr(GrB_DESC_RT1), + @intFromPtr(GrB_DESC_RT0T1), + @intFromPtr(GrB_DESC_RST0), + @intFromPtr(GrB_DESC_RST1), + @intFromPtr(GrB_DESC_RST0T1), + @intFromPtr(GrB_DESC_RSCT0), + @intFromPtr(GrB_DESC_RSCT1), + @intFromPtr(GrB_DESC_RSCT0T1), + }; + for (known_predefined) |p| { + if (ptr == p) return false; + } + return true; + } + + pub fn descriptor_free(ptr: usize) void { + if (ptr == 0) return; + var desc: GrB_Descriptor = @ptrFromInt(ptr); + _ = GrB_Descriptor_free(&desc); + } + + // ============================================================================= + // Matrix set / extract / dup + // ============================================================================= + + pub fn matrix_set_int64(ptr: usize, row: u64, col: u64, val: i64) !void { + const matrix = mat_from_ptr(ptr); + const info = GrB_Matrix_setElement_INT64(matrix, val, row, col); + try translate_info(info); + } + + pub fn matrix_set_fp64(ptr: usize, row: u64, col: u64, val: f64) !void { + const matrix = mat_from_ptr(ptr); + const info = GrB_Matrix_setElement_FP64(matrix, val, row, col); + try translate_info(info); + } + + pub fn matrix_set_bool(ptr: usize, row: u64, col: u64, val: bool) !void { + const matrix = mat_from_ptr(ptr); + const info = GrB_Matrix_setElement_BOOL(matrix, val, row, col); + try translate_info(info); + } + + pub fn matrix_extract_int64(ptr: usize, row: u64, col: u64) !i64 { + const matrix = mat_from_ptr(ptr); + var val: i64 = 0; + const info = GrB_Matrix_extractElement_INT64(&val, matrix, row, col); + if (info == GrB_NO_VALUE) return 0; + try translate_info(info); + return val; + } + + pub fn matrix_extract_fp64(ptr: usize, row: u64, col: u64) !f64 { + const matrix = mat_from_ptr(ptr); + var val: f64 = 0.0; + const info = GrB_Matrix_extractElement_FP64(&val, matrix, row, col); + if (info == GrB_NO_VALUE) return 0.0; + try translate_info(info); + return val; + } + + pub fn matrix_extract_bool(ptr: usize, row: u64, col: u64) !bool { + const matrix = mat_from_ptr(ptr); + var val: bool = false; + const info = GrB_Matrix_extractElement_BOOL(&val, matrix, row, col); + if (info == GrB_NO_VALUE) return false; + try translate_info(info); + return val; + } + + pub fn matrix_dup(ptr: usize) !usize { + const source = mat_from_ptr(ptr); + var copy: GrB_Matrix = null; + const info = GrB_Matrix_dup(©, source); + try translate_info(info); + return mat_to_ptr(copy); + } + + // ============================================================================= + // Vector set / extract / dup + // ============================================================================= + + pub fn vector_set_int64(ptr: usize, idx: u64, val: i64) !void { + const vector = vec_from_ptr(ptr); + const info = GrB_Vector_setElement_INT64(vector, val, idx); + try translate_info(info); + } + + pub fn vector_set_fp64(ptr: usize, idx: u64, val: f64) !void { + const vector = vec_from_ptr(ptr); + const info = GrB_Vector_setElement_FP64(vector, val, idx); + try translate_info(info); + } + + pub fn vector_set_bool(ptr: usize, idx: u64, val: bool) !void { + const vector = vec_from_ptr(ptr); + const info = GrB_Vector_setElement_BOOL(vector, val, idx); + try translate_info(info); + } + + pub fn vector_extract_int64(ptr: usize, idx: u64) !i64 { + const vector = vec_from_ptr(ptr); + var val: i64 = 0; + const info = GrB_Vector_extractElement_INT64(&val, vector, idx); + if (info == GrB_NO_VALUE) return 0; + try translate_info(info); + return val; + } + + pub fn vector_extract_fp64(ptr: usize, idx: u64) !f64 { + const vector = vec_from_ptr(ptr); + var val: f64 = 0.0; + const info = GrB_Vector_extractElement_FP64(&val, vector, idx); + if (info == GrB_NO_VALUE) return 0.0; + try translate_info(info); + return val; + } + + pub fn vector_extract_bool(ptr: usize, idx: u64) !bool { + const vector = vec_from_ptr(ptr); + var val: bool = false; + const info = GrB_Vector_extractElement_BOOL(&val, vector, idx); + if (info == GrB_NO_VALUE) return false; + try translate_info(info); + return val; + } + + pub fn vector_dup(ptr: usize) !usize { + const source = vec_from_ptr(ptr); + var copy: GrB_Vector = null; + const info = GrB_Vector_dup(©, source); + try translate_info(info); + return vec_to_ptr(copy); + } diff --git a/priv/native/zig_stub/zig_stub.zig b/priv/native/zig_stub/zig_stub.zig new file mode 100644 index 0000000..0a493c4 --- /dev/null +++ b/priv/native/zig_stub/zig_stub.zig @@ -0,0 +1,9 @@ +const beam = @import("beam"); + +pub fn add_one(number: i64) i64 { + return number + 1; +} + +pub fn ping() beam.term { + return beam.make(.ok, .{}); +} diff --git a/test-ci-local.sh b/test-ci-local.sh new file mode 100755 index 0000000..60b28c1 --- /dev/null +++ b/test-ci-local.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Test CI/CD locally with Docker + +set -e + +DOCKER_IMAGE="ex_graphblas:ci-test" +REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" + +echo "Building Docker image for CI testing (linux/amd64)..." +docker build --platform=linux/amd64 -f "$REPO_ROOT/Dockerfile.ci" -t "$DOCKER_IMAGE" "$REPO_ROOT" + +echo "" +echo "Running CI tests in Docker container (linux/amd64)..." +docker run --rm --platform=linux/amd64 \ + -v "$REPO_ROOT:/src:ro" \ + -w /workspace \ + -e MIX_ENV=test \ + -e PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig" \ + "$DOCKER_IMAGE" \ + bash -c ' + set -e + + # Work on a clean copy of the repo so host _build/ and deps/ from + # a different OTP/Elixir dont confuse protocol compilation. + mkdir -p /workspace + cp -R /src/. /workspace + + echo "Installing Elixir dependencies..." + mix deps.get --force + + echo "Downloading Zig..." + mix zig.get + + echo "Compiling with warnings as errors..." + mix compile --warnings-as-errors + + echo "Verifying NIFs compiled..." + find _build -maxdepth 5 -type f \( -name "*.so" -o -name "*.dylib" \) -print || echo "No prebuilt NIFs found" + + echo "Running tests..." + mix test + ' + +echo "" +echo "✓ CI tests passed locally!" diff --git a/test/ex_graphblas/algorithm_test.exs b/test/ex_graphblas/algorithm_test.exs new file mode 100644 index 0000000..8ac8a0f --- /dev/null +++ b/test/ex_graphblas/algorithm_test.exs @@ -0,0 +1,474 @@ +defmodule GraphBLAS.AlgorithmTest do + use ExUnit.Case, async: true + + alias GraphBLAS.{Algorithm, Matrix, Vector} + + describe "bfs_reach/3" do + test "reaches all vertices on a chain graph" do + {:ok, adj} = + Matrix.from_coo(5, 5, [{0, 1, true}, {1, 2, true}, {2, 3, true}, {3, 4, true}], :bool) + + {:ok, visited} = Algorithm.bfs_reach(adj, 0) + {:ok, entries} = Vector.to_entries(visited) + visited_indices = Enum.sort(Enum.map(entries, fn {i, _} -> i end)) + assert visited_indices == [0, 1, 2, 3, 4] + end + + test "only reaches source when no out-edges" do + {:ok, adj} = Matrix.from_coo(3, 3, [{0, 1, true}, {0, 2, true}], :bool) + {:ok, visited} = Algorithm.bfs_reach(adj, 1) + {:ok, entries} = Vector.to_entries(visited) + visited_indices = Enum.sort(Enum.map(entries, fn {i, _} -> i end)) + assert visited_indices == [1] + end + + test "visits only connected component on disconnected graph" do + {:ok, adj} = + Matrix.from_coo( + 6, + 6, + [{0, 1, true}, {1, 0, true}, {2, 3, true}, {3, 2, true}], + :bool + ) + + {:ok, visited} = Algorithm.bfs_reach(adj, 0) + {:ok, entries} = Vector.to_entries(visited) + visited_indices = Enum.sort(Enum.map(entries, fn {i, _} -> i end)) + assert visited_indices == [0, 1] + end + + test "single vertex graph" do + {:ok, adj} = Matrix.new(1, 1, :bool) + {:ok, visited} = Algorithm.bfs_reach(adj, 0) + {:ok, entries} = Vector.to_entries(visited) + assert Enum.map(entries, fn {i, _} -> i end) == [0] + end + + test "rejects out-of-bounds source" do + {:ok, adj} = Matrix.from_coo(3, 3, [], :bool) + assert {:error, _} = Algorithm.bfs_reach(adj, 5) + end + + test "handles cycle without infinite loop" do + {:ok, adj} = Matrix.from_coo(3, 3, [{0, 1, true}, {1, 2, true}, {2, 0, true}], :bool) + {:ok, visited} = Algorithm.bfs_reach(adj, 0) + {:ok, entries} = Vector.to_entries(visited) + assert length(entries) == 3 + end + end + + describe "bfs_levels/3" do + test "computes correct hop distances on a chain" do + {:ok, adj} = + Matrix.from_coo(5, 5, [{0, 1, true}, {1, 2, true}, {2, 3, true}, {3, 4, true}], :bool) + + {:ok, levels} = Algorithm.bfs_levels(adj, 0) + {:ok, entries} = Vector.to_entries(levels) + level_map = Map.new(entries) + assert level_map[0] == 0 + assert level_map[1] == 1 + assert level_map[2] == 2 + assert level_map[3] == 3 + assert level_map[4] == 4 + end + + test "level 0 for source only in star graph" do + {:ok, adj} = Matrix.from_coo(4, 4, [{0, 1, true}, {0, 2, true}, {0, 3, true}], :bool) + {:ok, levels} = Algorithm.bfs_levels(adj, 0) + {:ok, entries} = Vector.to_entries(levels) + level_map = Map.new(entries) + assert level_map[0] == 0 + assert level_map[1] == 1 + assert level_map[2] == 1 + assert level_map[3] == 1 + end + + test "unvisited vertices absent in disconnected graph" do + {:ok, adj} = + Matrix.from_coo( + 6, + 6, + [{0, 1, true}, {1, 0, true}, {2, 3, true}, {3, 2, true}], + :bool + ) + + {:ok, levels} = Algorithm.bfs_levels(adj, 0) + {:ok, entries} = Vector.to_entries(levels) + level_map = Map.new(entries) + indices = Enum.sort(Map.keys(level_map)) + assert indices == [0, 1] + end + end + + describe "sssp/3" do + test "finds shortest paths on weighted chain" do + {:ok, adj} = + Matrix.from_coo(4, 4, [{0, 1, 1.0}, {1, 2, 1.0}, {2, 3, 1.0}, {0, 2, 5.0}], :fp64) + + {:ok, dist} = Algorithm.sssp(adj, 0) + {:ok, entries} = Vector.to_entries(dist) + dist_map = Map.new(entries) + assert dist_map[0] == 0.0 + assert dist_map[1] == 1.0 + assert dist_map[2] == 2.0 + assert dist_map[3] == 3.0 + end + + test "source distance is zero" do + {:ok, adj} = Matrix.from_coo(3, 3, [{0, 1, 3.0}, {1, 2, 2.0}], :fp64) + {:ok, dist} = Algorithm.sssp(adj, 0) + {:ok, entries} = Vector.to_entries(dist) + dist_map = Map.new(entries) + assert dist_map[0] == 0.0 + assert dist_map[1] == 3.0 + assert dist_map[2] == 5.0 + end + + test "unreachable vertices are absent" do + {:ok, adj} = Matrix.from_coo(4, 4, [{0, 1, 1.0}], :fp64) + {:ok, dist} = Algorithm.sssp(adj, 0) + {:ok, entries} = Vector.to_entries(dist) + dist_map = Map.new(entries) + assert Map.has_key?(dist_map, 2) == false + assert Map.has_key?(dist_map, 3) == false + end + + test "handles varying edge weights" do + {:ok, adj} = + Matrix.from_coo( + 5, + 5, + [ + {0, 1, 2.0}, + {0, 2, 10.0}, + {1, 2, 3.0}, + {1, 3, 7.0}, + {2, 3, 1.0}, + {3, 4, 2.0} + ], + :fp64 + ) + + {:ok, dist} = Algorithm.sssp(adj, 0) + {:ok, entries} = Vector.to_entries(dist) + dist_map = Map.new(entries) + assert dist_map[0] == 0.0 + assert dist_map[1] == 2.0 + assert dist_map[2] == 5.0 + assert dist_map[3] == 6.0 + assert dist_map[4] == 8.0 + end + end + + describe "triangle_count/2" do + test "counts triangles in K4 (complete graph on 4 vertices)" do + edges = + for i <- 0..3, j <- 0..3, i != j do + {i, j, true} + end + + {:ok, adj} = Matrix.from_coo(4, 4, edges, :bool) + {:ok, count} = Algorithm.triangle_count(adj) + assert count == 4 + end + + test "counts one triangle in a 3-cycle" do + {:ok, adj} = + Matrix.from_coo( + 3, + 3, + [{0, 1, true}, {1, 0, true}, {1, 2, true}, {2, 1, true}, {0, 2, true}, {2, 0, true}], + :bool + ) + + {:ok, count} = Algorithm.triangle_count(adj) + assert count == 1 + end + + test "zero triangles in a chain" do + {:ok, adj} = + Matrix.from_coo( + 4, + 4, + [{0, 1, true}, {1, 0, true}, {1, 2, true}, {2, 1, true}, {2, 3, true}, {3, 2, true}], + :bool + ) + + {:ok, count} = Algorithm.triangle_count(adj) + assert count == 0 + end + + test "zero triangles in empty graph" do + {:ok, adj} = Matrix.from_coo(3, 3, [], :bool) + {:ok, count} = Algorithm.triangle_count(adj) + assert count == 0 + end + end + + describe "pagerank/2" do + test "computes pagerank on a simple graph" do + {:ok, adj} = + Matrix.from_coo( + 4, + 4, + [ + {0, 1, true}, + {0, 2, true}, + {1, 2, true}, + {2, 0, true}, + {2, 3, true}, + {3, 2, true} + ], + :bool + ) + + {:ok, ranks} = Algorithm.pagerank(adj, max_iter: 50) + {:ok, entries} = Vector.to_entries(ranks) + rank_map = Map.new(entries) + + assert Map.has_key?(rank_map, 0) + assert Map.has_key?(rank_map, 1) + assert Map.has_key?(rank_map, 2) + assert Map.has_key?(rank_map, 3) + + total = Enum.reduce(rank_map, 0.0, fn {_k, v}, acc -> acc + v end) + assert abs(total - 1.0) < 0.01 + end + + test "pagerank on two-node mutual graph" do + {:ok, adj} = Matrix.from_coo(2, 2, [{0, 1, true}, {1, 0, true}], :bool) + {:ok, ranks} = Algorithm.pagerank(adj, max_iter: 50) + {:ok, entries} = Vector.to_entries(ranks) + rank_map = Map.new(entries) + assert Map.has_key?(rank_map, 0) + assert Map.has_key?(rank_map, 1) + total = Enum.reduce(rank_map, 0.0, fn {_k, v}, acc -> acc + v end) + assert abs(total - 1.0) < 0.01 + end + end + + describe "connected_components/2" do + test "single component in connected undirected graph" do + {:ok, adj} = + Matrix.from_coo( + 4, + 4, + [{0, 1, true}, {1, 0, true}, {1, 2, true}, {2, 1, true}, {2, 3, true}, {3, 2, true}], + :bool + ) + + {:ok, comp} = Algorithm.connected_components(adj) + {:ok, entries} = Vector.to_entries(comp) + comp_map = Map.new(entries) + ids = Enum.uniq(Map.values(comp_map)) + assert length(ids) == 1 + end + + test "two components in disconnected graph" do + {:ok, adj} = + Matrix.from_coo( + 6, + 6, + [{0, 1, true}, {1, 0, true}, {2, 3, true}, {3, 2, true}], + :bool + ) + + {:ok, comp} = Algorithm.connected_components(adj) + {:ok, entries} = Vector.to_entries(comp) + comp_map = Map.new(entries) + ids = Enum.uniq(Map.values(comp_map)) + assert length(ids) == 4 + end + + test "isolated vertices get own component" do + {:ok, adj} = Matrix.from_coo(3, 3, [{0, 1, true}, {1, 0, true}], :bool) + {:ok, comp} = Algorithm.connected_components(adj) + {:ok, entries} = Vector.to_entries(comp) + comp_map = Map.new(entries) + assert Map.has_key?(comp_map, 0) + assert Map.has_key?(comp_map, 1) + end + end + + describe "degree/2" do + test "out-degree of star graph center" do + {:ok, adj} = + Matrix.from_coo(5, 5, [{0, 1, true}, {0, 2, true}, {0, 3, true}, {0, 4, true}], :bool) + + {:ok, %{out_degree: out_deg}} = Algorithm.degree(adj) + {:ok, entries} = Vector.to_entries(out_deg) + deg_map = Map.new(entries) + assert deg_map[0] == 4 + end + + test "in-degree vector is computed" do + {:ok, adj} = + Matrix.from_coo(5, 5, [{0, 1, true}, {0, 2, true}, {0, 3, true}, {0, 4, true}], :bool) + + {:ok, %{in_degree: in_deg}} = Algorithm.degree(adj) + {:ok, entries} = Vector.to_entries(in_deg) + assert entries != [] + end + end + + describe "fixed_point/3 (Phase 6C)" do + test "converges and reports iteration count" do + {:ok, adj} = + Matrix.from_coo(3, 3, [{0, 1, true}, {1, 0, true}, {1, 2, true}, {2, 1, true}], :bool) + + {:ok, _result, info} = + Algorithm.fixed_point( + adj, + fn p -> + {:ok, new_paths} = Matrix.mxm(p, adj, :lor_land) + {:ok, result} = Matrix.ewise_add(adj, new_paths, :lor) + {:ok, result} + end, + max_iter: 10 + ) + + assert info.converged == true + assert info.iterations > 0 + end + + test "reports not converged when max_iter exceeded" do + {:ok, adj} = Matrix.from_coo(3, 3, [{0, 1, true}, {1, 2, true}], :bool) + + {:ok, _result, info} = + Algorithm.fixed_point( + adj, + fn p -> {:ok, p} end, + max_iter: 0 + ) + + assert info.converged == false + end + + test "custom convergence function" do + {:ok, adj} = Matrix.from_coo(3, 3, [{0, 1, true}], :bool) + + {:ok, _result, info} = + Algorithm.fixed_point( + adj, + fn p -> {:ok, p} end, + max_iter: 10, + convergence_fn: fn _old, _new -> true end + ) + + assert info.converged == true + assert info.iterations == 1 + end + + test "tolerance-based convergence with vectors" do + {:ok, v} = Vector.from_entries(3, [{0, 1.0}, {1, 2.0}], :fp64) + + {:ok, _result, info} = + Algorithm.fixed_point( + v, + fn p -> + {:ok, entries} = Vector.to_entries(p) + new_entries = Enum.map(entries, fn {i, val} -> {i, val * 0.99} end) + Vector.from_entries(3, new_entries, :fp64) + end, + tol: 0.01, + max_iter: 100 + ) + + assert info.converged == true + end + + test "exact convergence with vectors (tol = 0)" do + {:ok, v} = Vector.from_entries(2, [{0, 5}, {1, 10}], :int64) + + {:ok, _result, info} = + Algorithm.fixed_point( + v, + fn p -> {:ok, p} end, + tol: 0, + max_iter: 5 + ) + + assert info.converged == true + assert info.iterations == 1 + end + + test "default_converged with matrices" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64) + + {:ok, _result, info} = + Algorithm.fixed_point( + m, + fn p -> {:ok, p} end, + max_iter: 5 + ) + + assert info.converged == true + assert info.iterations == 1 + end + + test "default_converged with different container types across iterations" do + {:ok, v} = Vector.from_entries(2, [{0, 1}], :int64) + + {:ok, result, info} = + Algorithm.fixed_point( + v, + fn _p -> Matrix.from_coo(2, 2, [{0, 0, 1}], :int64) end, + max_iter: 5 + ) + + assert %Matrix{} = result + assert info.iterations <= 5 + end + end + + describe "pagerank/2 edge cases" do + test "pagerank with max_iter limit reached (tests pagerank_loop max_iter guard)" do + {:ok, adj} = Matrix.from_coo(2, 2, [{0, 1, true}, {1, 0, true}], :bool) + {:ok, ranks} = Algorithm.pagerank(adj, max_iter: 2) + {:ok, entries} = Vector.to_entries(ranks) + assert length(entries) == 2 + end + + test "pagerank with custom damping factor" do + {:ok, adj} = Matrix.from_coo(3, 3, [{0, 1, true}, {1, 2, true}, {2, 0, true}], :bool) + {:ok, ranks} = Algorithm.pagerank(adj, damping: 0.5) + {:ok, entries} = Vector.to_entries(ranks) + rank_map = Map.new(entries) + total = Enum.reduce(rank_map, 0.0, fn {_k, v}, acc -> acc + v end) + assert abs(total - 1.0) < 0.01 + end + + test "pagerank with custom tolerance" do + {:ok, adj} = Matrix.from_coo(2, 2, [{0, 1, true}, {1, 0, true}], :bool) + {:ok, ranks} = Algorithm.pagerank(adj, tol: 0.1) + {:ok, entries} = Vector.to_entries(ranks) + assert length(entries) == 2 + end + + test "pagerank on graph with dangling nodes (tests apply_dangling_correction)" do + {:ok, adj} = Matrix.from_coo(3, 3, [{0, 1, true}, {1, 2, true}], :bool) + {:ok, ranks} = Algorithm.pagerank(adj, max_iter: 20) + {:ok, entries} = Vector.to_entries(ranks) + assert is_list(entries) + end + + test "pagerank with zero damping (tests apply_shift with different shift values)" do + {:ok, adj} = Matrix.from_coo(2, 2, [{0, 1, true}, {1, 0, true}], :bool) + {:ok, ranks} = Algorithm.pagerank(adj, damping: 0.0, max_iter: 10) + {:ok, entries} = Vector.to_entries(ranks) + assert length(entries) == 2 + end + end + + describe "degree/2 with fp64 adjacency (tests bool_to_int64 fp64 path)" do + test "computes degree from fp64 weighted matrix" do + {:ok, adj} = Matrix.from_coo(3, 3, [{0, 1, 2.5}, {0, 2, 1.5}, {1, 0, 3.0}], :fp64) + {:ok, %{out_degree: out_deg, in_degree: in_deg}} = Algorithm.degree(adj) + {:ok, out_entries} = Vector.to_entries(out_deg) + {:ok, in_entries} = Vector.to_entries(in_deg) + + assert out_entries != [] + assert in_entries != [] + end + end +end diff --git a/test/ex_graphblas/application_test.exs b/test/ex_graphblas/application_test.exs new file mode 100644 index 0000000..d0b2cb2 --- /dev/null +++ b/test/ex_graphblas/application_test.exs @@ -0,0 +1,28 @@ +defmodule GraphBLAS.ApplicationTest do + use ExUnit.Case, async: false + + describe "start/2" do + test "starts the application supervisor" do + assert Process.whereis(GraphBLAS.Supervisor) != nil + end + end + + describe "stop/1" do + test "calls grb_finalize and returns :ok" do + result = GraphBLAS.Application.stop(:normal) + assert result == :ok + + case Code.ensure_loaded(GraphBLAS.Native.SuiteSparse) do + {:module, mod} -> + try do + mod.grb_init() + rescue + _ -> :ok + end + + {:error, _} -> + :ok + end + end + end +end diff --git a/test/ex_graphblas/backend/elixir_test.exs b/test/ex_graphblas/backend/elixir_test.exs new file mode 100644 index 0000000..eae9ccf --- /dev/null +++ b/test/ex_graphblas/backend/elixir_test.exs @@ -0,0 +1,612 @@ +defmodule GraphBLAS.Backend.ElixirTest do + use ExUnit.Case, async: true + + alias GraphBLAS.Backend.Elixir, as: RefBackend + alias GraphBLAS.{Matrix, Scalar, Vector} + + describe "matrix_new/4" do + test "creates an empty matrix" do + assert {:ok, %Matrix{} = m} = RefBackend.matrix_new(3, 3, :int64, []) + assert {:ok, {3, 3}} = Matrix.shape(m) + assert {:ok, 0} = Matrix.nvals(m) + end + + test "rejects negative dimensions" do + assert {:error, %GraphBLAS.Error{reason: {:invalid_argument, _}}} = + RefBackend.matrix_new(-1, 3, :int64, []) + end + + test "rejects unknown types" do + assert {:error, {:unsupported_type, :bad}} = + RefBackend.matrix_new(3, 3, :bad, []) + end + end + + describe "matrix_from_coo/5" do + test "creates a matrix from COO triples" do + entries = [{0, 1, 1}, {1, 2, 2}, {2, 0, 3}] + assert {:ok, %Matrix{} = m} = RefBackend.matrix_from_coo(3, 3, entries, :int64, []) + assert {:ok, {3, 3}} = Matrix.shape(m) + assert {:ok, 3} = Matrix.nvals(m) + end + + test "combines duplicate entries with additive monoid" do + entries = [{0, 0, 1}, {0, 0, 2}] + assert {:ok, m} = RefBackend.matrix_from_coo(2, 2, entries, :int64, []) + assert {:ok, coo} = Matrix.to_coo(m) + assert [{0, 0, 3}] = coo + end + + test "rejects out-of-bounds indices" do + entries = [{5, 0, 1}] + + assert {:error, %GraphBLAS.Error{reason: {:index_out_of_bounds, 5, :row, 3}}} = + RefBackend.matrix_from_coo(3, 3, entries, :int64, []) + end + + test "creates identity matrix" do + entries = [{0, 0, 1}, {1, 1, 1}, {2, 2, 1}] + assert {:ok, m} = RefBackend.matrix_from_coo(3, 3, entries, :int64, []) + assert {:ok, 3} = Matrix.nvals(m) + assert {:ok, entries_sorted} = Matrix.to_coo(m) + assert entries_sorted == entries + end + end + + describe "matrix_to_coo/1" do + test "returns sorted entries" do + entries = [{2, 0, 3}, {0, 1, 1}, {1, 2, 2}] + assert {:ok, m} = RefBackend.matrix_from_coo(3, 3, entries, :int64, []) + assert {:ok, coo} = Matrix.to_coo(m) + assert coo == [{0, 1, 1}, {1, 2, 2}, {2, 0, 3}] + end + end + + describe "matrix_mxm/4" do + test "multiplies two matrices with plus_times semiring" do + entries_a = [{0, 1, 1}, {1, 2, 1}] + entries_b = [{1, 0, 2}, {2, 1, 3}] + + assert {:ok, a} = RefBackend.matrix_from_coo(2, 3, entries_a, :int64, []) + assert {:ok, b} = RefBackend.matrix_from_coo(3, 2, entries_b, :int64, []) + assert {:ok, c} = RefBackend.matrix_mxm(a, b, :plus_times, []) + + assert {:ok, {2, 2}} = Matrix.shape(c) + assert {:ok, coo} = Matrix.to_coo(c) + # (0,1)*(1,0) = 1*2 = 2 => (0,0) + # (1,2)*(2,1) = 1*3 = 3 => (1,1) + assert {0, 0, 2} in coo + assert {1, 1, 3} in coo + end + + test "rejects dimension mismatch" do + assert {:ok, a} = RefBackend.matrix_from_coo(2, 3, [{0, 0, 1}], :int64, []) + assert {:ok, b} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + + assert {:error, %GraphBLAS.Error{reason: {:dimension_mismatch, _, _}}} = + RefBackend.matrix_mxm(a, b, :plus_times, []) + end + end + + describe "matrix_mxv/4" do + test "multiplies matrix by vector" do + entries_m = [{0, 0, 1}, {0, 1, 1}] + assert {:ok, m} = RefBackend.matrix_from_coo(1, 2, entries_m, :int64, []) + assert {:ok, v} = RefBackend.vector_from_entries(2, [{0, 2}, {1, 3}], :int64, []) + assert {:ok, result} = RefBackend.matrix_mxv(m, v, :plus_times, []) + assert {:ok, entries} = Vector.to_entries(result) + assert {0, 5} in entries + end + end + + describe "matrix_ewise_add/4" do + test "adds two matrices element-wise" do + assert {:ok, a} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}, {1, 1, 2}], :int64, []) + assert {:ok, b} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 3}, {0, 1, 4}], :int64, []) + assert {:ok, c} = RefBackend.matrix_ewise_add(a, b, :plus, []) + assert {:ok, 3} = Matrix.nvals(c) + assert {:ok, coo} = Matrix.to_coo(c) + assert {0, 0, 4} in coo + assert {0, 1, 4} in coo + assert {1, 1, 2} in coo + end + + test "rejects shape mismatch" do + assert {:ok, a} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + assert {:ok, b} = RefBackend.matrix_from_coo(3, 3, [{0, 0, 1}], :int64, []) + + assert {:error, %GraphBLAS.Error{reason: {:dimension_mismatch, _, _}}} = + RefBackend.matrix_ewise_add(a, b, :plus, []) + end + end + + describe "matrix_ewise_mult/4" do + test "multiplies overlapping entries" do + assert {:ok, a} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 2}, {0, 1, 3}], :int64, []) + assert {:ok, b} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 4}, {1, 0, 5}], :int64, []) + assert {:ok, c} = RefBackend.matrix_ewise_mult(a, b, :times, []) + # Only (0,0) is in both + assert {:ok, 1} = Matrix.nvals(c) + assert {:ok, [{0, 0, 8}]} = Matrix.to_coo(c) + end + end + + describe "matrix_reduce/3" do + test "reduces matrix rows to a vector" do + entries = [{0, 0, 1}, {0, 1, 2}, {1, 0, 3}] + assert {:ok, m} = RefBackend.matrix_from_coo(2, 2, entries, :int64, []) + assert {:ok, v} = RefBackend.matrix_reduce(m, :plus, []) + assert {:ok, entries_v} = Vector.to_entries(v) + assert {0, 3} in entries_v + assert {1, 3} in entries_v + end + end + + describe "matrix_transpose/2" do + test "transposes a matrix" do + entries = [{0, 1, 2}, {1, 0, 3}] + assert {:ok, m} = RefBackend.matrix_from_coo(2, 2, entries, :int64, []) + assert {:ok, t} = RefBackend.matrix_transpose(m, []) + assert {:ok, {2, 2}} = Matrix.shape(t) + assert {:ok, coo} = Matrix.to_coo(t) + assert {0, 1, 3} in coo + assert {1, 0, 2} in coo + end + end + + describe "vector operations" do + test "creates and inspects a vector" do + assert {:ok, v} = RefBackend.vector_new(5, :int64, []) + assert {:ok, 5} = Vector.size(v) + assert {:ok, 0} = Vector.nvals(v) + assert {:ok, :int64} = Vector.type(v) + end + + test "creates a vector from entries" do + assert {:ok, v} = RefBackend.vector_from_entries(4, [{0, 1}, {2, 3}], :int64, []) + assert {:ok, 2} = Vector.nvals(v) + assert {:ok, entries} = Vector.to_entries(v) + assert [{0, 1}, {2, 3}] == entries + end + + test "combines duplicate vector entries" do + assert {:ok, v} = RefBackend.vector_from_entries(4, [{0, 1}, {0, 2}], :int64, []) + assert {:ok, 1} = Vector.nvals(v) + assert {:ok, [{0, 3}]} = Vector.to_entries(v) + end + + test "rejects out-of-bounds vector indices" do + assert {:error, %GraphBLAS.Error{reason: {:index_out_of_bounds, 5, :index, 4}}} = + RefBackend.vector_from_entries(4, [{5, 1}], :int64, []) + end + end + + describe "vector_ewise_add/4" do + test "adds two vectors element-wise" do + assert {:ok, a} = RefBackend.vector_from_entries(3, [{0, 1}, {1, 2}], :int64, []) + assert {:ok, b} = RefBackend.vector_from_entries(3, [{0, 3}, {2, 4}], :int64, []) + assert {:ok, c} = RefBackend.vector_ewise_add(a, b, :plus, []) + assert {:ok, entries} = Vector.to_entries(c) + assert {0, 4} in entries + assert {1, 2} in entries + assert {2, 4} in entries + end + end + + describe "vector_ewise_mult/4" do + test "multiplies overlapping entries" do + assert {:ok, a} = RefBackend.vector_from_entries(3, [{0, 2}, {1, 3}], :int64, []) + assert {:ok, b} = RefBackend.vector_from_entries(3, [{0, 4}, {1, 5}], :int64, []) + assert {:ok, c} = RefBackend.vector_ewise_mult(a, b, :times, []) + assert {:ok, entries} = Vector.to_entries(c) + assert {0, 8} in entries + assert {1, 15} in entries + end + end + + describe "vector_reduce/3" do + test "reduces a vector to a scalar" do + assert {:ok, v} = RefBackend.vector_from_entries(3, [{0, 1}, {1, 2}, {2, 3}], :int64, []) + assert {:ok, scalar} = RefBackend.vector_reduce(v, :plus, []) + assert %Scalar{type: :int64, value: 6} = scalar + end + + test "reduces single-element vector" do + assert {:ok, v} = RefBackend.vector_from_entries(3, [{1, 42}], :int64, []) + assert {:ok, scalar} = RefBackend.vector_reduce(v, :plus, []) + assert %Scalar{value: 42} = scalar + end + end + + describe "matrix_set/4" do + test "sets a value at a position" do + {:ok, m} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + {:ok, updated} = RefBackend.matrix_set(m, 1, 1, 5) + {:ok, coo} = RefBackend.matrix_to_coo(updated) + assert {1, 1, 5} in coo + end + + test "overwrites existing value" do + {:ok, m} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + {:ok, updated} = RefBackend.matrix_set(m, 0, 0, 42) + {:ok, coo} = RefBackend.matrix_to_coo(updated) + assert {0, 0, 42} in coo + refute {0, 0, 1} in coo + end + + test "rejects out-of-bounds row" do + {:ok, m} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + assert {:error, %GraphBLAS.Error{}} = RefBackend.matrix_set(m, 5, 0, 1) + end + + test "rejects out-of-bounds col" do + {:ok, m} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + assert {:error, %GraphBLAS.Error{}} = RefBackend.matrix_set(m, 0, 5, 1) + end + + test "sets bool value" do + {:ok, m} = RefBackend.matrix_new(2, 2, :bool, []) + {:ok, updated} = RefBackend.matrix_set(m, 0, 0, true) + {:ok, coo} = RefBackend.matrix_to_coo(updated) + assert {0, 0, true} in coo + end + + test "sets fp64 value" do + {:ok, m} = RefBackend.matrix_new(2, 2, :fp64, []) + {:ok, updated} = RefBackend.matrix_set(m, 1, 0, 3.14) + {:ok, coo} = RefBackend.matrix_to_coo(updated) + assert {1, 0, 3.14} in coo + end + end + + describe "matrix_extract/3" do + test "extracts a stored value" do + {:ok, m} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 42}], :int64, []) + assert {:ok, 42} = RefBackend.matrix_extract(m, 0, 0) + end + + test "returns default for structural zero (int64)" do + {:ok, m} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 42}], :int64, []) + assert {:ok, 0} = RefBackend.matrix_extract(m, 1, 1) + end + + test "returns default for structural zero (bool)" do + {:ok, m} = RefBackend.matrix_from_coo(2, 2, [{0, 0, true}], :bool, []) + assert {:ok, false} = RefBackend.matrix_extract(m, 1, 1) + end + + test "returns default for structural zero (fp64)" do + {:ok, m} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1.5}], :fp64, []) + assert {:ok, +0.0} = RefBackend.matrix_extract(m, 1, 1) + end + + test "rejects out-of-bounds row" do + {:ok, m} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + assert {:error, %GraphBLAS.Error{}} = RefBackend.matrix_extract(m, 5, 0) + end + end + + describe "matrix_dup/1" do + test "creates an independent copy" do + {:ok, m} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + {:ok, copy} = RefBackend.matrix_dup(m) + + {:ok, coo_orig} = RefBackend.matrix_to_coo(m) + {:ok, coo_copy} = RefBackend.matrix_to_coo(copy) + assert coo_orig == coo_copy + + {:ok, modified} = RefBackend.matrix_set(copy, 0, 0, 99) + {:ok, coo_modified} = RefBackend.matrix_to_coo(modified) + assert {0, 0, 99} in coo_modified + + {:ok, coo_orig_still} = RefBackend.matrix_to_coo(m) + assert {0, 0, 1} in coo_orig_still + end + end + + describe "vector_set/3" do + test "sets a value at an index" do + {:ok, v} = RefBackend.vector_from_entries(3, [{0, 1}], :int64, []) + {:ok, updated} = RefBackend.vector_set(v, 1, 5) + {:ok, entries} = RefBackend.vector_to_entries(updated) + assert {1, 5} in entries + end + + test "overwrites existing value" do + {:ok, v} = RefBackend.vector_from_entries(3, [{0, 1}], :int64, []) + {:ok, updated} = RefBackend.vector_set(v, 0, 42) + {:ok, entries} = RefBackend.vector_to_entries(updated) + assert {0, 42} in entries + end + + test "rejects out-of-bounds index" do + {:ok, v} = RefBackend.vector_from_entries(3, [{0, 1}], :int64, []) + assert {:error, %GraphBLAS.Error{}} = RefBackend.vector_set(v, 5, 1) + end + + test "sets bool value" do + {:ok, v} = RefBackend.vector_new(3, :bool, []) + {:ok, updated} = RefBackend.vector_set(v, 1, true) + {:ok, entries} = RefBackend.vector_to_entries(updated) + assert {1, true} in entries + end + end + + describe "vector_extract/2" do + test "extracts a stored value" do + {:ok, v} = RefBackend.vector_from_entries(3, [{0, 42}], :int64, []) + assert {:ok, 42} = RefBackend.vector_extract(v, 0) + end + + test "returns default for structural zero (int64)" do + {:ok, v} = RefBackend.vector_from_entries(3, [{0, 42}], :int64, []) + assert {:ok, 0} = RefBackend.vector_extract(v, 1) + end + + test "returns default for structural zero (fp64)" do + {:ok, v} = RefBackend.vector_from_entries(3, [{0, 1.5}], :fp64, []) + assert {:ok, +0.0} = RefBackend.vector_extract(v, 1) + end + + test "returns default for structural zero (bool)" do + {:ok, v} = RefBackend.vector_from_entries(3, [{0, true}], :bool, []) + assert {:ok, false} = RefBackend.vector_extract(v, 1) + end + + test "rejects out-of-bounds index" do + {:ok, v} = RefBackend.vector_from_entries(3, [{0, 1}], :int64, []) + assert {:error, %GraphBLAS.Error{}} = RefBackend.vector_extract(v, 5) + end + end + + describe "vector_dup/1" do + test "creates an independent copy" do + {:ok, v} = RefBackend.vector_from_entries(3, [{0, 1}], :int64, []) + {:ok, copy} = RefBackend.vector_dup(v) + + {:ok, entries_orig} = RefBackend.vector_to_entries(v) + {:ok, entries_copy} = RefBackend.vector_to_entries(copy) + assert entries_orig == entries_copy + + {:ok, modified} = RefBackend.vector_set(copy, 0, 99) + {:ok, entries_modified} = RefBackend.vector_to_entries(modified) + assert {0, 99} in entries_modified + + {:ok, entries_orig_still} = RefBackend.vector_to_entries(v) + assert {0, 1} in entries_orig_still + end + end + + describe "vector_vxm/4" do + test "multiplies vector by matrix from left" do + {:ok, v} = RefBackend.vector_from_entries(3, [{0, 1}, {2, 2}], :int64, []) + {:ok, m} = RefBackend.matrix_from_coo(3, 2, [{0, 0, 3}, {2, 1, 4}], :int64, []) + {:ok, result} = RefBackend.vector_vxm(v, m, :plus_times, []) + assert {:ok, 2} = Vector.size(result) + end + + test "rejects dimension mismatch" do + {:ok, v} = RefBackend.vector_from_entries(2, [{0, 1}], :int64, []) + {:ok, m} = RefBackend.matrix_from_coo(3, 3, [{0, 0, 1}], :int64, []) + + assert {:error, %GraphBLAS.Error{reason: {:dimension_mismatch, _, _}}} = + RefBackend.vector_vxm(v, m, :plus_times, []) + end + end + + describe "vector size mismatch errors" do + test "vector_ewise_add rejects size mismatch" do + {:ok, a} = RefBackend.vector_from_entries(3, [{0, 1}], :int64, []) + {:ok, b} = RefBackend.vector_from_entries(4, [{0, 2}], :int64, []) + + assert {:error, %GraphBLAS.Error{reason: {:dimension_mismatch, 4, 3}}} = + RefBackend.vector_ewise_add(a, b, :plus, []) + end + + test "vector_ewise_mult rejects size mismatch" do + {:ok, a} = RefBackend.vector_from_entries(3, [{0, 1}], :int64, []) + {:ok, b} = RefBackend.vector_from_entries(5, [{0, 2}], :int64, []) + + assert {:error, %GraphBLAS.Error{reason: {:dimension_mismatch, 5, 3}}} = + RefBackend.vector_ewise_mult(a, b, :times, []) + end + end + + describe "mask type mismatch errors" do + test "matrix operation with vector mask returns error" do + {:ok, m1} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + {:ok, m2} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 2}], :int64, []) + {:ok, v_mask} = RefBackend.vector_from_entries(2, [{0, true}], :bool, []) + mask = GraphBLAS.Mask.new(v_mask) + + assert {:error, %GraphBLAS.Error{reason: {:mask_type_mismatch, :vector, :matrix}}} = + RefBackend.matrix_mxm(m1, m2, :plus_times, mask: mask) + end + + test "vector operation with matrix mask returns error" do + {:ok, v1} = RefBackend.vector_from_entries(3, [{0, 1}], :int64, []) + {:ok, v2} = RefBackend.vector_from_entries(3, [{0, 2}], :int64, []) + {:ok, m_mask} = RefBackend.matrix_from_coo(3, 3, [{0, 0, true}], :bool, []) + mask = GraphBLAS.Mask.new(m_mask) + + assert {:error, %GraphBLAS.Error{reason: {:mask_type_mismatch, :matrix, :vector}}} = + RefBackend.vector_ewise_add(v1, v2, :plus, mask: mask) + end + + test "matrix_ewise_add with vector mask returns error" do + {:ok, a} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + {:ok, b} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 2}], :int64, []) + {:ok, v_mask} = RefBackend.vector_from_entries(2, [{0, true}], :bool, []) + mask = GraphBLAS.Mask.new(v_mask) + + assert {:error, %GraphBLAS.Error{reason: {:mask_type_mismatch, :vector, :matrix}}} = + RefBackend.matrix_ewise_add(a, b, :plus, mask: mask) + end + + test "matrix_transpose with vector mask returns error" do + {:ok, m} = RefBackend.matrix_from_coo(2, 2, [{0, 1, 1}], :int64, []) + {:ok, v_mask} = RefBackend.vector_from_entries(2, [{0, true}], :bool, []) + mask = GraphBLAS.Mask.new(v_mask) + + assert {:error, %GraphBLAS.Error{reason: {:mask_type_mismatch, :vector, :matrix}}} = + RefBackend.matrix_transpose(m, mask: mask) + end + + test "vector_vxm with matrix mask returns error" do + {:ok, v} = RefBackend.vector_from_entries(3, [{0, 1}], :int64, []) + {:ok, m} = RefBackend.matrix_from_coo(3, 2, [{0, 0, 1}], :int64, []) + {:ok, m_mask} = RefBackend.matrix_from_coo(2, 2, [{0, 0, true}], :bool, []) + mask = GraphBLAS.Mask.new(m_mask) + + assert {:error, %GraphBLAS.Error{reason: {:mask_type_mismatch, :matrix, :vector}}} = + RefBackend.vector_vxm(v, m, :plus_times, mask: mask) + end + end + + describe "empty container edge cases" do + test "mxm on empty matrices" do + {:ok, a} = RefBackend.matrix_from_coo(2, 2, [], :int64, []) + {:ok, b} = RefBackend.matrix_from_coo(2, 2, [], :int64, []) + {:ok, c} = RefBackend.matrix_mxm(a, b, :plus_times, []) + assert {:ok, 0} = Matrix.nvals(c) + end + + test "mxv on empty matrix" do + {:ok, m} = RefBackend.matrix_from_coo(2, 3, [], :int64, []) + {:ok, v} = RefBackend.vector_from_entries(3, [{0, 1}], :int64, []) + {:ok, result} = RefBackend.matrix_mxv(m, v, :plus_times, []) + assert {:ok, 0} = Vector.nvals(result) + end + + test "vector_reduce on empty vector" do + {:ok, v} = RefBackend.vector_from_entries(3, [], :int64, []) + {:ok, scalar} = RefBackend.vector_reduce(v, :plus, []) + assert %Scalar{value: 0} = scalar + end + + test "matrix_reduce on empty matrix" do + {:ok, m} = RefBackend.matrix_from_coo(3, 3, [], :int64, []) + {:ok, v} = RefBackend.matrix_reduce(m, :plus, []) + assert {:ok, 0} = Vector.nvals(v) + end + end + + describe "matrix_mxv/4 with mxv dimension checks" do + test "rejects dimension mismatch" do + {:ok, m} = RefBackend.matrix_from_coo(2, 3, [{0, 0, 1}], :int64, []) + {:ok, v} = RefBackend.vector_from_entries(2, [{0, 1}], :int64, []) + + assert {:error, %GraphBLAS.Error{reason: {:dimension_mismatch, {3, 2}, _}}} = + RefBackend.matrix_mxv(m, v, :plus_times, []) + end + end + + describe "matrix_to_dense/1" do + test "converts empty matrix to dense" do + {:ok, m} = RefBackend.matrix_from_coo(2, 2, [], :int64, []) + {:ok, dense} = RefBackend.matrix_to_dense(m) + assert dense == [[0, 0], [0, 0]] + end + + test "converts bool matrix to dense" do + {:ok, m} = RefBackend.matrix_from_coo(2, 2, [{0, 0, true}, {1, 1, true}], :bool, []) + {:ok, dense} = RefBackend.matrix_to_dense(m) + assert dense == [[true, false], [false, true]] + end + end + + describe "vector_to_list/1" do + test "converts empty vector to list" do + {:ok, v} = RefBackend.vector_from_entries(3, [], :int64, []) + {:ok, list} = RefBackend.vector_to_list(v) + assert list == [0, 0, 0] + end + + test "converts bool vector to list" do + {:ok, v} = RefBackend.vector_from_entries(3, [{0, true}, {2, true}], :bool, []) + {:ok, list} = RefBackend.vector_to_list(v) + assert list == [true, false, true] + end + end + + describe "vector_size/1" do + test "returns the size of a vector" do + {:ok, v} = RefBackend.vector_from_entries(10, [{0, 1}, {5, 2}], :int64, []) + assert {:ok, 10} = RefBackend.vector_size(v) + end + + test "returns size for empty vector" do + {:ok, v} = RefBackend.vector_new(7, :int64, []) + assert {:ok, 7} = RefBackend.vector_size(v) + end + end + + describe "vector_type/1" do + test "returns the type of an int64 vector" do + {:ok, v} = RefBackend.vector_from_entries(3, [{0, 1}], :int64, []) + assert {:ok, :int64} = RefBackend.vector_type(v) + end + + test "returns the type of a fp64 vector" do + {:ok, v} = RefBackend.vector_from_entries(3, [{0, 1.5}], :fp64, []) + assert {:ok, :fp64} = RefBackend.vector_type(v) + end + + test "returns the type of a bool vector" do + {:ok, v} = RefBackend.vector_from_entries(3, [{0, true}], :bool, []) + assert {:ok, :bool} = RefBackend.vector_type(v) + end + end + + describe "mask with :value mode (tests get_matrix_mask_positions :value path)" do + test "matrix operation with value mask filters by non-zero values" do + {:ok, m1} = RefBackend.matrix_from_coo(3, 3, [{0, 0, 1}, {1, 1, 2}, {2, 2, 3}], :int64, []) + {:ok, m2} = RefBackend.matrix_from_coo(3, 3, [{0, 0, 5}, {1, 1, 10}], :int64, []) + + {:ok, mask_m} = + RefBackend.matrix_from_coo(3, 3, [{0, 0, 1}, {1, 1, 0}, {2, 2, 5}], :int64, []) + + mask = GraphBLAS.Mask.new(mask_m) + descriptor = GraphBLAS.Descriptor.new(mask: :value) + + {:ok, result} = + RefBackend.matrix_ewise_add(m1, m2, :plus, mask: mask, descriptor: descriptor) + + {:ok, coo} = Matrix.to_coo(result) + + assert {0, 0, 6} in coo + assert {2, 2, 3} in coo + refute Enum.any?(coo, fn {r, c, _} -> r == 1 and c == 1 end) + end + + test "matrix operation with value mask filters false values in bool mask" do + {:ok, m1} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}, {1, 1, 2}], :int64, []) + {:ok, m2} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 3}, {1, 1, 4}], :int64, []) + {:ok, mask_m} = RefBackend.matrix_from_coo(2, 2, [{0, 0, true}, {1, 1, false}], :bool, []) + + mask = GraphBLAS.Mask.new(mask_m) + descriptor = GraphBLAS.Descriptor.new(mask: :value) + + {:ok, result} = + RefBackend.matrix_ewise_add(m1, m2, :plus, mask: mask, descriptor: descriptor) + + {:ok, coo} = Matrix.to_coo(result) + assert [{0, 0, 4}] = coo + end + + test "matrix operation with value mask filters 0.0 in fp64 mask" do + {:ok, m1} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1.0}, {1, 1, 2.0}], :fp64, []) + {:ok, m2} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 3.0}, {1, 1, 4.0}], :fp64, []) + {:ok, mask_m} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1.5}, {1, 1, 0.0}], :fp64, []) + + mask = GraphBLAS.Mask.new(mask_m) + descriptor = GraphBLAS.Descriptor.new(mask: :value) + + {:ok, result} = + RefBackend.matrix_ewise_add(m1, m2, :plus, mask: mask, descriptor: descriptor) + + {:ok, coo} = Matrix.to_coo(result) + assert [{0, 0, 4.0}] = coo + end + end +end diff --git a/test/ex_graphblas/backend/parity_test.exs b/test/ex_graphblas/backend/parity_test.exs new file mode 100644 index 0000000..66bf461 --- /dev/null +++ b/test/ex_graphblas/backend/parity_test.exs @@ -0,0 +1,771 @@ +if System.get_env("EX_GRAPHBLAS_COMPILE_NATIVE") in ["1", "true"] do + defmodule GraphBLAS.Backend.ParityTest do + use ExUnit.Case, async: false + + @moduletag :native_backend + + alias GraphBLAS.Backend.Elixir, as: RefBackend + alias GraphBLAS.Backend.SuiteSparse + + # Valid semiring/type pairs and test data for mxm/mxv/vxm + # Each entry: {semiring, type, matrix_a_entries, matrix_b_entries, expected_nonzero_count} + # Matrix A is 2x3, Matrix B is 3x2 for mxm + # Matrix is 2x3, Vector is 3 for mxv + # Vector is 2, Matrix is 2x3 for vxm + + @semiring_mxm_cases [ + {:plus_times, :int64, [{0, 1, 2}, {1, 2, 3}], [{1, 0, 1}, {2, 1, 4}]}, + {:plus_times_fp64, :fp64, [{0, 1, 1.5}, {1, 2, 2.5}], [{1, 0, 2.0}, {2, 1, 3.0}]}, + {:plus_min, :int64, [{0, 1, 5}, {1, 2, 3}], [{1, 0, 2}, {2, 1, 4}]}, + {:plus_min_fp64, :fp64, [{0, 1, 5.0}, {1, 2, 3.0}], [{1, 0, 2.0}, {2, 1, 4.0}]}, + {:max_plus, :int64, [{0, 1, 2}, {1, 2, 3}], [{1, 0, 1}, {2, 1, 4}]}, + {:max_plus_fp64, :fp64, [{0, 1, 2.0}, {1, 2, 3.0}], [{1, 0, 1.0}, {2, 1, 4.0}]}, + {:max_min, :int64, [{0, 1, 5}, {1, 2, 3}], [{1, 0, 2}, {2, 1, 4}]}, + {:max_min_fp64, :fp64, [{0, 1, 5.0}, {1, 2, 3.0}], [{1, 0, 2.0}, {2, 1, 4.0}]}, + {:lor_land, :bool, [{0, 1, true}, {1, 2, true}], [{1, 0, true}, {2, 1, true}]}, + {:land_lor, :bool, [{0, 1, true}, {1, 2, false}], [{1, 0, true}, {2, 1, true}]} + ] + + # Monoid/type pairs for ewise operations + # {monoid, type, entries_a, entries_b} + @monoid_ewise_cases [ + {:plus, :int64, [{0, 0, 1}, {1, 1, 2}], [{0, 0, 3}, {1, 1, 4}]}, + {:plus_fp64, :fp64, [{0, 0, 1.5}, {1, 1, 2.5}], [{0, 0, 3.5}, {1, 1, 4.5}]}, + {:times, :int64, [{0, 0, 2}, {1, 1, 3}], [{0, 0, 4}, {1, 1, 5}]}, + {:times_fp64, :fp64, [{0, 0, 2.0}, {1, 1, 3.0}], [{0, 0, 4.0}, {1, 1, 5.0}]}, + {:min, :int64, [{0, 0, 5}, {1, 1, 2}], [{0, 0, 3}, {1, 1, 7}]}, + {:min_fp64, :fp64, [{0, 0, 5.0}, {1, 1, 2.0}], [{0, 0, 3.0}, {1, 1, 7.0}]}, + {:max, :int64, [{0, 0, 5}, {1, 1, 2}], [{0, 0, 3}, {1, 1, 7}]}, + {:max_fp64, :fp64, [{0, 0, 5.0}, {1, 1, 2.0}], [{0, 0, 3.0}, {1, 1, 7.0}]}, + {:land, :bool, [{0, 0, true}, {1, 1, true}], [{0, 0, true}, {1, 1, false}]}, + {:lor, :bool, [{0, 0, true}, {1, 1, false}], [{0, 0, true}, {1, 1, true}]}, + {:lxor, :bool, [{0, 0, true}, {1, 1, true}], [{0, 0, true}, {1, 1, false}]} + ] + + describe "matrix_mxm parity — all semirings" do + for {semiring, type, entries_a, entries_b} <- @semiring_mxm_cases do + test "#{semiring} / #{type}" do + {semiring, type, entries_a, entries_b} = + unquote(Macro.escape({semiring, type, entries_a, entries_b})) + + {:ok, ref_a} = RefBackend.matrix_from_coo(2, 3, entries_a, type, []) + {:ok, ref_b} = RefBackend.matrix_from_coo(3, 2, entries_b, type, []) + {:ok, ss_a} = SuiteSparse.matrix_from_coo(2, 3, entries_a, type, []) + {:ok, ss_b} = SuiteSparse.matrix_from_coo(3, 2, entries_b, type, []) + + {:ok, ref_c} = RefBackend.matrix_mxm(ref_a, ref_b, semiring, []) + {:ok, ss_c} = SuiteSparse.matrix_mxm(ss_a, ss_b, semiring, []) + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref_c) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss_c) + + if type == :fp64 do + assert_coo_approx_equal(ref_coo, ss_coo) + else + assert sort_coo(ref_coo) == sort_coo(ss_coo) + end + + SuiteSparse.matrix_free(ss_a) + SuiteSparse.matrix_free(ss_b) + SuiteSparse.matrix_free(ss_c) + end + end + end + + describe "matrix_mxv parity — all semirings" do + for {semiring, type, entries_m, _entries_b} <- @semiring_mxm_cases do + test "#{semiring} / #{type}" do + {semiring, type, entries_m, _} = unquote(Macro.escape({semiring, type, entries_m, nil})) + + vector_entries = + case type do + :int64 -> [{1, 10}, {2, 5}] + :fp64 -> [{1, 10.0}, {2, 5.0}] + :bool -> [{1, true}, {2, true}] + end + + {:ok, ref_m} = RefBackend.matrix_from_coo(2, 3, entries_m, type, []) + {:ok, ref_v} = RefBackend.vector_from_entries(3, vector_entries, type, []) + {:ok, ss_m} = SuiteSparse.matrix_from_coo(2, 3, entries_m, type, []) + {:ok, ss_v} = SuiteSparse.vector_from_entries(3, vector_entries, type, []) + + {:ok, ref_r} = RefBackend.matrix_mxv(ref_m, ref_v, semiring, []) + {:ok, ss_r} = SuiteSparse.matrix_mxv(ss_m, ss_v, semiring, []) + + {:ok, ref_e} = RefBackend.vector_to_entries(ref_r) + {:ok, ss_e} = SuiteSparse.vector_to_entries(ss_r) + + if type == :fp64 do + assert_entries_approx_equal(ref_e, ss_e) + else + assert sort_entries(ref_e) == sort_entries(ss_e) + end + + SuiteSparse.matrix_free(ss_m) + SuiteSparse.vector_free(ss_v) + SuiteSparse.vector_free(ss_r) + end + end + end + + describe "vector_vxm parity — all semirings" do + for {semiring, type, entries_a, _} <- @semiring_mxm_cases do + test "#{semiring} / #{type}" do + {semiring, type, entries_a, _} = unquote(Macro.escape({semiring, type, entries_a, nil})) + + vector_entries = + case type do + :int64 -> [{0, 1}, {2, 2}] + :fp64 -> [{0, 1.0}, {2, 2.0}] + :bool -> [{0, true}, {2, true}] + end + + matrix_entries = + case type do + :int64 -> [{0, 0, 3}, {2, 1, 4}] + :fp64 -> [{0, 0, 3.0}, {2, 1, 4.0}] + :bool -> [{0, 0, true}, {2, 1, true}] + end + + {:ok, ref_v} = RefBackend.vector_from_entries(3, vector_entries, type, []) + {:ok, ref_m} = RefBackend.matrix_from_coo(3, 2, matrix_entries, type, []) + {:ok, ss_v} = SuiteSparse.vector_from_entries(3, vector_entries, type, []) + {:ok, ss_m} = SuiteSparse.matrix_from_coo(3, 2, matrix_entries, type, []) + + {:ok, ref_r} = RefBackend.vector_vxm(ref_v, ref_m, semiring, []) + {:ok, ss_r} = SuiteSparse.vector_vxm(ss_v, ss_m, semiring, []) + + {:ok, ref_e} = RefBackend.vector_to_entries(ref_r) + {:ok, ss_e} = SuiteSparse.vector_to_entries(ss_r) + + if type == :fp64 do + assert_entries_approx_equal(ref_e, ss_e) + else + assert sort_entries(ref_e) == sort_entries(ss_e) + end + + SuiteSparse.vector_free(ss_v) + SuiteSparse.matrix_free(ss_m) + SuiteSparse.vector_free(ss_r) + end + end + end + + describe "matrix_ewise_add parity — all monoids" do + for {monoid, type, entries_a, entries_b} <- @monoid_ewise_cases do + test "#{monoid} / #{type}" do + {monoid, type, entries_a, entries_b} = + unquote(Macro.escape({monoid, type, entries_a, entries_b})) + + {:ok, ref_a} = RefBackend.matrix_from_coo(2, 2, entries_a, type, []) + {:ok, ref_b} = RefBackend.matrix_from_coo(2, 2, entries_b, type, []) + {:ok, ss_a} = SuiteSparse.matrix_from_coo(2, 2, entries_a, type, []) + {:ok, ss_b} = SuiteSparse.matrix_from_coo(2, 2, entries_b, type, []) + + {:ok, ref_c} = RefBackend.matrix_ewise_add(ref_a, ref_b, monoid, []) + {:ok, ss_c} = SuiteSparse.matrix_ewise_add(ss_a, ss_b, monoid, []) + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref_c) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss_c) + + if type == :fp64 do + assert_coo_approx_equal(ref_coo, ss_coo) + else + assert sort_coo(ref_coo) == sort_coo(ss_coo) + end + + SuiteSparse.matrix_free(ss_a) + SuiteSparse.matrix_free(ss_b) + SuiteSparse.matrix_free(ss_c) + end + end + end + + describe "matrix_ewise_mult parity — all monoids" do + for {monoid, type, entries_a, entries_b} <- @monoid_ewise_cases do + test "#{monoid} / #{type}" do + {monoid, type, entries_a, entries_b} = + unquote(Macro.escape({monoid, type, entries_a, entries_b})) + + {:ok, ref_a} = RefBackend.matrix_from_coo(2, 2, entries_a, type, []) + {:ok, ref_b} = RefBackend.matrix_from_coo(2, 2, entries_b, type, []) + {:ok, ss_a} = SuiteSparse.matrix_from_coo(2, 2, entries_a, type, []) + {:ok, ss_b} = SuiteSparse.matrix_from_coo(2, 2, entries_b, type, []) + + {:ok, ref_c} = RefBackend.matrix_ewise_mult(ref_a, ref_b, monoid, []) + {:ok, ss_c} = SuiteSparse.matrix_ewise_mult(ss_a, ss_b, monoid, []) + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref_c) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss_c) + + if type == :fp64 do + assert_coo_approx_equal(ref_coo, ss_coo) + else + assert sort_coo(ref_coo) == sort_coo(ss_coo) + end + + SuiteSparse.matrix_free(ss_a) + SuiteSparse.matrix_free(ss_b) + SuiteSparse.matrix_free(ss_c) + end + end + end + + describe "matrix_reduce parity — all monoids" do + for {monoid, type, entries_a, _entries_b} <- @monoid_ewise_cases do + test "#{monoid} / #{type}" do + {monoid, type, entries_a, _} = unquote(Macro.escape({monoid, type, entries_a, nil})) + + {:ok, ref} = RefBackend.matrix_from_coo(2, 2, entries_a, type, []) + {:ok, ss} = SuiteSparse.matrix_from_coo(2, 2, entries_a, type, []) + + {:ok, ref_v} = RefBackend.matrix_reduce(ref, monoid, []) + {:ok, ss_v} = SuiteSparse.matrix_reduce(ss, monoid, []) + + {:ok, ref_e} = RefBackend.vector_to_entries(ref_v) + {:ok, ss_e} = SuiteSparse.vector_to_entries(ss_v) + + if type == :fp64 do + assert_entries_approx_equal(ref_e, ss_e) + else + assert sort_entries(ref_e) == sort_entries(ss_e) + end + + SuiteSparse.matrix_free(ss) + SuiteSparse.vector_free(ss_v) + end + end + end + + describe "vector_ewise_add parity — all monoids" do + for {monoid, type, entries_a, entries_b} <- @monoid_ewise_cases do + test "#{monoid} / #{type}" do + {monoid, type, entries_a, entries_b} = + unquote(Macro.escape({monoid, type, entries_a, entries_b})) + + vec_a = Enum.map(entries_a, fn {r, _c, v} -> {r, v} end) + vec_b = Enum.map(entries_b, fn {r, _c, v} -> {r, v} end) + + {:ok, ref_a} = RefBackend.vector_from_entries(2, vec_a, type, []) + {:ok, ref_b} = RefBackend.vector_from_entries(2, vec_b, type, []) + {:ok, ss_a} = SuiteSparse.vector_from_entries(2, vec_a, type, []) + {:ok, ss_b} = SuiteSparse.vector_from_entries(2, vec_b, type, []) + + {:ok, ref_c} = RefBackend.vector_ewise_add(ref_a, ref_b, monoid, []) + {:ok, ss_c} = SuiteSparse.vector_ewise_add(ss_a, ss_b, monoid, []) + + {:ok, ref_e} = RefBackend.vector_to_entries(ref_c) + {:ok, ss_e} = SuiteSparse.vector_to_entries(ss_c) + + if type == :fp64 do + assert_entries_approx_equal(ref_e, ss_e) + else + assert sort_entries(ref_e) == sort_entries(ss_e) + end + + SuiteSparse.vector_free(ss_a) + SuiteSparse.vector_free(ss_b) + SuiteSparse.vector_free(ss_c) + end + end + end + + describe "vector_ewise_mult parity — all monoids" do + for {monoid, type, entries_a, entries_b} <- @monoid_ewise_cases do + test "#{monoid} / #{type}" do + {monoid, type, entries_a, entries_b} = + unquote(Macro.escape({monoid, type, entries_a, entries_b})) + + vec_a = Enum.map(entries_a, fn {r, _c, v} -> {r, v} end) + vec_b = Enum.map(entries_b, fn {r, _c, v} -> {r, v} end) + + {:ok, ref_a} = RefBackend.vector_from_entries(2, vec_a, type, []) + {:ok, ref_b} = RefBackend.vector_from_entries(2, vec_b, type, []) + {:ok, ss_a} = SuiteSparse.vector_from_entries(2, vec_a, type, []) + {:ok, ss_b} = SuiteSparse.vector_from_entries(2, vec_b, type, []) + + {:ok, ref_c} = RefBackend.vector_ewise_mult(ref_a, ref_b, monoid, []) + {:ok, ss_c} = SuiteSparse.vector_ewise_mult(ss_a, ss_b, monoid, []) + + {:ok, ref_e} = RefBackend.vector_to_entries(ref_c) + {:ok, ss_e} = SuiteSparse.vector_to_entries(ss_c) + + if type == :fp64 do + assert_entries_approx_equal(ref_e, ss_e) + else + assert sort_entries(ref_e) == sort_entries(ss_e) + end + + SuiteSparse.vector_free(ss_a) + SuiteSparse.vector_free(ss_b) + SuiteSparse.vector_free(ss_c) + end + end + end + + describe "vector_reduce parity — all monoids" do + for {monoid, type, entries_a, _} <- @monoid_ewise_cases do + test "#{monoid} / #{type}" do + {monoid, type, entries_a, _} = unquote(Macro.escape({monoid, type, entries_a, nil})) + + vec_entries = Enum.map(entries_a, fn {r, _c, v} -> {r, v} end) + + {:ok, ref} = RefBackend.vector_from_entries(2, vec_entries, type, []) + {:ok, ss} = SuiteSparse.vector_from_entries(2, vec_entries, type, []) + + {:ok, ref_s} = RefBackend.vector_reduce(ref, monoid, []) + {:ok, ss_s} = SuiteSparse.vector_reduce(ss, monoid, []) + + if type == :fp64 do + assert_in_delta ref_s.value, ss_s.value, 0.001 + else + assert ref_s.value == ss_s.value + end + + SuiteSparse.vector_free(ss) + end + end + end + + # --- Edge case tests --- + + describe "edge cases" do + test "empty matrix produces zero nvals" do + {:ok, ref} = RefBackend.matrix_from_coo(3, 3, [], :int64, []) + {:ok, ss} = SuiteSparse.matrix_from_coo(3, 3, [], :int64, []) + + {:ok, ref_nvals} = RefBackend.matrix_nvals(ref) + {:ok, ss_nvals} = SuiteSparse.matrix_nvals(ss) + + assert ref_nvals == 0 + assert ss_nvals == 0 + SuiteSparse.matrix_free(ss) + end + + test "empty matrix to_coo returns empty list" do + {:ok, ref} = RefBackend.matrix_from_coo(3, 3, [], :int64, []) + {:ok, ss} = SuiteSparse.matrix_from_coo(3, 3, [], :int64, []) + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss) + + assert ref_coo == [] + assert ss_coo == [] + SuiteSparse.matrix_free(ss) + end + + test "empty vector reduces to monoid identity" do + for {monoid, type, identity} <- [ + {:plus, :int64, 0}, + {:plus_fp64, :fp64, 0.0}, + {:times, :int64, 1}, + {:times_fp64, :fp64, 1.0}, + {:lor, :bool, false}, + {:land, :bool, true} + ] do + {:ok, ref} = RefBackend.vector_from_entries(3, [], type, []) + {:ok, ss} = SuiteSparse.vector_from_entries(3, [], type, []) + + {:ok, ref_s} = RefBackend.vector_reduce(ref, monoid, []) + {:ok, ss_s} = SuiteSparse.vector_reduce(ss, monoid, []) + + if type == :fp64 do + assert_in_delta identity, ss_s.value, 0.001 + assert_in_delta identity, ref_s.value, 0.001 + else + assert identity == ref_s.value + assert identity == ss_s.value + end + + SuiteSparse.vector_free(ss) + end + end + + test "single element matrix mxm with identity" do + entries = [{0, 0, 1}] + {:ok, ref_i} = RefBackend.matrix_from_coo(2, 2, entries, :int64, []) + {:ok, ss_i} = SuiteSparse.matrix_from_coo(2, 2, entries, :int64, []) + + {:ok, ref_c} = RefBackend.matrix_mxm(ref_i, ref_i, :plus_times, []) + {:ok, ss_c} = SuiteSparse.matrix_mxm(ss_i, ss_i, :plus_times, []) + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref_c) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss_c) + + assert sort_coo(ref_coo) == sort_coo(ss_coo) + SuiteSparse.matrix_free(ss_i) + SuiteSparse.matrix_free(ss_c) + end + + test "duplicate entries are combined with plus monoid (default)" do + entries = [{0, 0, 3}, {0, 0, 5}] + {:ok, ref} = RefBackend.matrix_from_coo(2, 2, entries, :int64, []) + {:ok, ss} = SuiteSparse.matrix_from_coo(2, 2, entries, :int64, []) + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss) + + assert {0, 0, 8} in sort_coo(ref_coo) + assert {0, 0, 8} in sort_coo(ss_coo) + SuiteSparse.matrix_free(ss) + end + + test "duplicate entries with custom combine monoid (RefBackend)" do + entries = [{0, 0, 3}, {0, 0, 5}] + + {:ok, ref_times} = + RefBackend.matrix_from_coo(2, 2, entries, :int64, combine_monoid: :times) + + {:ok, times_coo} = RefBackend.matrix_to_coo(ref_times) + assert {0, 0, 15} in sort_coo(times_coo) + + {:ok, ref_max} = RefBackend.matrix_from_coo(2, 2, entries, :int64, combine_monoid: :max) + {:ok, max_coo} = RefBackend.matrix_to_coo(ref_max) + assert {0, 0, 5} in sort_coo(max_coo) + + {:ok, ref_min} = RefBackend.matrix_from_coo(2, 2, entries, :int64, combine_monoid: :min) + {:ok, min_coo} = RefBackend.matrix_to_coo(ref_min) + assert {0, 0, 3} in sort_coo(min_coo) + end + + test "dimension mismatch returns error" do + {:ok, ref_a} = RefBackend.matrix_from_coo(2, 3, [{0, 0, 1}], :int64, []) + {:ok, ref_b} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + {:ok, ss_a} = SuiteSparse.matrix_from_coo(2, 3, [{0, 0, 1}], :int64, []) + {:ok, ss_b} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + + ref_result = RefBackend.matrix_mxm(ref_a, ref_b, :plus_times, []) + + # SuiteSparse may crash or return error for dimension mismatch + # Both backends should reject this + assert match?({:error, _}, ref_result) + + SuiteSparse.matrix_free(ss_a) + SuiteSparse.matrix_free(ss_b) + end + + test "unsupported type returns error" do + result = SuiteSparse.matrix_new(3, 3, :int32, []) + + assert match?({:error, _}, result) + end + + test "fp64 mxm with larger matrix" do + entries_a = [{0, 0, 1.0}, {0, 1, 2.0}, {1, 1, 3.0}] + entries_b = [{0, 0, 1.0}, {1, 0, 2.0}, {1, 1, 4.0}] + + {:ok, ref_a} = RefBackend.matrix_from_coo(2, 2, entries_a, :fp64, []) + {:ok, ref_b} = RefBackend.matrix_from_coo(2, 2, entries_b, :fp64, []) + {:ok, ss_a} = SuiteSparse.matrix_from_coo(2, 2, entries_a, :fp64, []) + {:ok, ss_b} = SuiteSparse.matrix_from_coo(2, 2, entries_b, :fp64, []) + + {:ok, ref_c} = RefBackend.matrix_mxm(ref_a, ref_b, :plus_times_fp64, []) + {:ok, ss_c} = SuiteSparse.matrix_mxm(ss_a, ss_b, :plus_times_fp64, []) + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref_c) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss_c) + + assert_coo_approx_equal(ref_coo, ss_coo) + SuiteSparse.matrix_free(ss_a) + SuiteSparse.matrix_free(ss_b) + SuiteSparse.matrix_free(ss_c) + end + end + + # --- Helper functions --- + + defp sort_coo(entries) do + Enum.sort_by(entries, fn {r, c, _v} -> {r, c} end) + end + + defp sort_entries(entries) do + Enum.sort_by(entries, fn {i, _v} -> i end) + end + + defp assert_coo_approx_equal(ref, ss) do + ref_sorted = sort_coo(ref) + ss_sorted = sort_coo(ss) + + assert length(ref_sorted) == length(ss_sorted) + + Enum.zip_with(ref_sorted, ss_sorted, fn {r1, c1, v1}, {r2, c2, v2} -> + assert r1 == r2 + assert c1 == c2 + assert_in_delta v1, v2, 0.001 + end) + end + + defp assert_entries_approx_equal(ref, ss) do + ref_sorted = sort_entries(ref) + ss_sorted = sort_entries(ss) + + assert length(ref_sorted) == length(ss_sorted) + + Enum.zip_with(ref_sorted, ss_sorted, fn {i1, v1}, {i2, v2} -> + assert i1 == i2 + assert_in_delta v1, v2, 0.001 + end) + end + + # --- Phase 5: set/extract/dup parity --- + + describe "matrix_set / matrix_extract parity" do + test "set and extract int64 values match between backends" do + {:ok, ref} = RefBackend.matrix_from_coo(3, 3, [{0, 0, 1}, {1, 1, 2}], :int64, []) + {:ok, ss} = SuiteSparse.matrix_from_coo(3, 3, [{0, 0, 1}, {1, 1, 2}], :int64, []) + + {:ok, ref_set} = RefBackend.matrix_set(ref, 2, 2, 42) + {:ok, ss_set} = SuiteSparse.matrix_set(ss, 2, 2, 42) + + {:ok, ref_val} = RefBackend.matrix_extract(ref_set, 2, 2) + {:ok, ss_val} = SuiteSparse.matrix_extract(ss_set, 2, 2) + assert ref_val == ss_val + + {:ok, ref_default} = RefBackend.matrix_extract(ref_set, 0, 1) + {:ok, ss_default} = SuiteSparse.matrix_extract(ss_set, 0, 1) + assert ref_default == ss_default + + SuiteSparse.matrix_free(ss) + SuiteSparse.matrix_free(ss_set) + end + + test "set and extract fp64 values match between backends" do + {:ok, ref} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1.5}], :fp64, []) + {:ok, ss} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1.5}], :fp64, []) + + {:ok, ref_set} = RefBackend.matrix_set(ref, 1, 1, 3.14) + {:ok, ss_set} = SuiteSparse.matrix_set(ss, 1, 1, 3.14) + + {:ok, ref_val} = RefBackend.matrix_extract(ref_set, 1, 1) + {:ok, ss_val} = SuiteSparse.matrix_extract(ss_set, 1, 1) + assert_in_delta ref_val, ss_val, 0.001 + + SuiteSparse.matrix_free(ss) + SuiteSparse.matrix_free(ss_set) + end + + test "set and extract bool values match between backends" do + {:ok, ref} = RefBackend.matrix_from_coo(2, 2, [], :bool, []) + {:ok, ss} = SuiteSparse.matrix_from_coo(2, 2, [], :bool, []) + + {:ok, ref_set} = RefBackend.matrix_set(ref, 0, 1, true) + {:ok, ss_set} = SuiteSparse.matrix_set(ss, 0, 1, true) + + {:ok, ref_val} = RefBackend.matrix_extract(ref_set, 0, 1) + {:ok, ss_val} = SuiteSparse.matrix_extract(ss_set, 0, 1) + assert ref_val == ss_val + + {:ok, ref_default} = RefBackend.matrix_extract(ref_set, 1, 0) + {:ok, ss_default} = SuiteSparse.matrix_extract(ss_set, 1, 0) + assert ref_default == ss_default + + SuiteSparse.matrix_free(ss) + SuiteSparse.matrix_free(ss_set) + end + end + + describe "matrix_dup parity" do + test "dup produces same entries as original" do + entries = [{0, 0, 5}, {0, 1, 3}, {1, 1, 7}] + {:ok, ref} = RefBackend.matrix_from_coo(2, 2, entries, :int64, []) + {:ok, ss} = SuiteSparse.matrix_from_coo(2, 2, entries, :int64, []) + + {:ok, ref_dup} = RefBackend.matrix_dup(ref) + {:ok, ss_dup} = SuiteSparse.matrix_dup(ss) + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref_dup) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss_dup) + + assert sort_coo(ref_coo) == sort_coo(ss_coo) + + SuiteSparse.matrix_free(ss) + SuiteSparse.matrix_free(ss_dup) + end + end + + describe "vector_set / vector_extract parity" do + test "set and extract int64 values match between backends" do + {:ok, ref} = RefBackend.vector_from_entries(3, [{0, 1}], :int64, []) + {:ok, ss} = SuiteSparse.vector_from_entries(3, [{0, 1}], :int64, []) + + {:ok, ref_set} = RefBackend.vector_set(ref, 2, 42) + {:ok, ss_set} = SuiteSparse.vector_set(ss, 2, 42) + + {:ok, ref_val} = RefBackend.vector_extract(ref_set, 2) + {:ok, ss_val} = SuiteSparse.vector_extract(ss_set, 2) + assert ref_val == ss_val + + {:ok, ref_default} = RefBackend.vector_extract(ref_set, 1) + {:ok, ss_default} = SuiteSparse.vector_extract(ss_set, 1) + assert ref_default == ss_default + + SuiteSparse.vector_free(ss) + SuiteSparse.vector_free(ss_set) + end + + test "set and extract fp64 values match between backends" do + {:ok, ref} = RefBackend.vector_from_entries(3, [{0, 1.5}], :fp64, []) + {:ok, ss} = SuiteSparse.vector_from_entries(3, [{0, 1.5}], :fp64, []) + + {:ok, ref_set} = RefBackend.vector_set(ref, 1, 2.5) + {:ok, ss_set} = SuiteSparse.vector_set(ss, 1, 2.5) + + {:ok, ref_val} = RefBackend.vector_extract(ref_set, 1) + {:ok, ss_val} = SuiteSparse.vector_extract(ss_set, 1) + assert_in_delta ref_val, ss_val, 0.001 + + SuiteSparse.vector_free(ss) + SuiteSparse.vector_free(ss_set) + end + + test "set and extract bool values match between backends" do + {:ok, ref} = RefBackend.vector_from_entries(3, [], :bool, []) + {:ok, ss} = SuiteSparse.vector_from_entries(3, [], :bool, []) + + {:ok, ref_set} = RefBackend.vector_set(ref, 0, true) + {:ok, ss_set} = SuiteSparse.vector_set(ss, 0, true) + + {:ok, ref_val} = RefBackend.vector_extract(ref_set, 0) + {:ok, ss_val} = SuiteSparse.vector_extract(ss_set, 0) + assert ref_val == ss_val + + SuiteSparse.vector_free(ss) + SuiteSparse.vector_free(ss_set) + end + end + + describe "vector_dup parity" do + test "dup produces same entries as original" do + entries = [{0, 5}, {2, 3}] + {:ok, ref} = RefBackend.vector_from_entries(4, entries, :int64, []) + {:ok, ss} = SuiteSparse.vector_from_entries(4, entries, :int64, []) + + {:ok, ref_dup} = RefBackend.vector_dup(ref) + {:ok, ss_dup} = SuiteSparse.vector_dup(ss) + + {:ok, ref_e} = RefBackend.vector_to_entries(ref_dup) + {:ok, ss_e} = SuiteSparse.vector_to_entries(ss_dup) + + assert sort_entries(ref_e) == sort_entries(ss_e) + + SuiteSparse.vector_free(ss) + SuiteSparse.vector_free(ss_dup) + end + end + + # --- Phase 5: mask/descriptor parity --- + + describe "mask parity on matrix_mxm" do + test "structural mask produces same results on both backends" do + entries_a = [{0, 0, 1}, {0, 1, 2}, {1, 0, 3}, {1, 1, 4}] + entries_b = [{0, 0, 1}, {1, 1, 1}] + mask_entries = [{0, 0, 1}] + + {:ok, ref_a} = RefBackend.matrix_from_coo(2, 2, entries_a, :int64, []) + {:ok, ref_b} = RefBackend.matrix_from_coo(2, 2, entries_b, :int64, []) + {:ok, ref_mask_src} = RefBackend.matrix_from_coo(2, 2, mask_entries, :int64, []) + ref_mask = GraphBLAS.Mask.new(ref_mask_src) + + {:ok, ss_a} = SuiteSparse.matrix_from_coo(2, 2, entries_a, :int64, []) + {:ok, ss_b} = SuiteSparse.matrix_from_coo(2, 2, entries_b, :int64, []) + {:ok, ss_mask_src} = SuiteSparse.matrix_from_coo(2, 2, mask_entries, :int64, []) + ss_mask = GraphBLAS.Mask.new(ss_mask_src) + + {:ok, ref_c} = RefBackend.matrix_mxm(ref_a, ref_b, :plus_times, mask: ref_mask) + {:ok, ss_c} = SuiteSparse.matrix_mxm(ss_a, ss_b, :plus_times, mask: ss_mask) + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref_c) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss_c) + + assert sort_coo(ref_coo) == sort_coo(ss_coo) + + SuiteSparse.matrix_free(ss_a) + SuiteSparse.matrix_free(ss_b) + SuiteSparse.matrix_free(ss_mask_src) + SuiteSparse.matrix_free(ss_c) + end + + test "complement mask produces same results on both backends" do + entries_a = [{0, 0, 1}, {1, 1, 1}] + entries_b = [{0, 0, 1}, {1, 1, 1}] + mask_entries = [{0, 0, 1}] + + {:ok, ref_a} = RefBackend.matrix_from_coo(2, 2, entries_a, :int64, []) + {:ok, ref_b} = RefBackend.matrix_from_coo(2, 2, entries_b, :int64, []) + {:ok, ref_mask_src} = RefBackend.matrix_from_coo(2, 2, mask_entries, :int64, []) + ref_mask = GraphBLAS.Mask.complement(ref_mask_src) + + {:ok, ss_a} = SuiteSparse.matrix_from_coo(2, 2, entries_a, :int64, []) + {:ok, ss_b} = SuiteSparse.matrix_from_coo(2, 2, entries_b, :int64, []) + {:ok, ss_mask_src} = SuiteSparse.matrix_from_coo(2, 2, mask_entries, :int64, []) + ss_mask = GraphBLAS.Mask.complement(ss_mask_src) + + {:ok, ref_c} = RefBackend.matrix_mxm(ref_a, ref_b, :plus_times, mask: ref_mask) + {:ok, ss_c} = SuiteSparse.matrix_mxm(ss_a, ss_b, :plus_times, mask: ss_mask) + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref_c) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss_c) + + assert sort_coo(ref_coo) == sort_coo(ss_coo) + + SuiteSparse.matrix_free(ss_a) + SuiteSparse.matrix_free(ss_b) + SuiteSparse.matrix_free(ss_mask_src) + SuiteSparse.matrix_free(ss_c) + end + end + + describe "mask parity on vector_ewise_add" do + test "structural vector mask produces same results on both backends" do + {:ok, ref_a} = RefBackend.vector_from_entries(3, [{0, 1}, {1, 2}, {2, 3}], :int64, []) + {:ok, ref_b} = RefBackend.vector_from_entries(3, [{0, 10}, {1, 20}], :int64, []) + {:ok, ref_mask_src} = RefBackend.vector_from_entries(3, [{0, 1}], :int64, []) + ref_mask = GraphBLAS.Mask.new(ref_mask_src) + + {:ok, ss_a} = SuiteSparse.vector_from_entries(3, [{0, 1}, {1, 2}, {2, 3}], :int64, []) + {:ok, ss_b} = SuiteSparse.vector_from_entries(3, [{0, 10}, {1, 20}], :int64, []) + {:ok, ss_mask_src} = SuiteSparse.vector_from_entries(3, [{0, 1}], :int64, []) + ss_mask = GraphBLAS.Mask.new(ss_mask_src) + + {:ok, ref_c} = RefBackend.vector_ewise_add(ref_a, ref_b, :plus, mask: ref_mask) + {:ok, ss_c} = SuiteSparse.vector_ewise_add(ss_a, ss_b, :plus, mask: ss_mask) + + {:ok, ref_e} = RefBackend.vector_to_entries(ref_c) + {:ok, ss_e} = SuiteSparse.vector_to_entries(ss_c) + + assert sort_entries(ref_e) == sort_entries(ss_e) + + SuiteSparse.vector_free(ss_a) + SuiteSparse.vector_free(ss_b) + SuiteSparse.vector_free(ss_mask_src) + SuiteSparse.vector_free(ss_c) + end + end + + describe "descriptor parity on matrix_mxm — inp0_transpose" do + test "inp0_transpose produces same results on both backends" do + entries_a = [{0, 1, 1}, {1, 2, 2}] + entries_b = [{0, 1, 3}, {1, 2, 4}] + + desc = GraphBLAS.Descriptor.new(inp0_transpose: :transpose) + + {:ok, ref_a} = RefBackend.matrix_from_coo(2, 3, entries_a, :int64, []) + {:ok, ref_b} = RefBackend.matrix_from_coo(2, 3, entries_b, :int64, []) + {:ok, ss_a} = SuiteSparse.matrix_from_coo(2, 3, entries_a, :int64, []) + {:ok, ss_b} = SuiteSparse.matrix_from_coo(2, 3, entries_b, :int64, []) + + {:ok, ref_c} = RefBackend.matrix_mxm(ref_a, ref_b, :plus_times, descriptor: desc) + {:ok, ss_c} = SuiteSparse.matrix_mxm(ss_a, ss_b, :plus_times, descriptor: desc) + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref_c) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss_c) + + assert sort_coo(ref_coo) == sort_coo(ss_coo) + + SuiteSparse.matrix_free(ss_a) + SuiteSparse.matrix_free(ss_b) + SuiteSparse.matrix_free(ss_c) + end + end + end +end diff --git a/test/ex_graphblas/backend/semantic_correctness_test.exs b/test/ex_graphblas/backend/semantic_correctness_test.exs new file mode 100644 index 0000000..dff6e20 --- /dev/null +++ b/test/ex_graphblas/backend/semantic_correctness_test.exs @@ -0,0 +1,444 @@ +defmodule GraphBLAS.Backend.SemanticCorrectnessTest do + @moduledoc """ + Tier 2 semantic correctness tests for the Elixir backend. + + Each test states the mathematical operation being tested, shows the + expected result computed by hand, and verifies the backend produces + the exact same result. These tests serve as the authoritative oracle + for future backend parity validation. + """ + use ExUnit.Case, async: true + + alias GraphBLAS.Backend.Elixir, as: RefBackend + alias GraphBLAS.{Matrix, Scalar, Vector} + + ############################################################################# + # Matrix construction from COO + ############################################################################# + + describe "matrix construction (semantic)" do + test "identity matrix produces correct COO output" do + # 3x3 identity: diag(1,1,1) + # Expected: Entries at (0,0)=1, (1,1)=1, (2,2)=1 + entries = [{0, 0, 1}, {1, 1, 1}, {2, 2, 1}] + assert {:ok, m} = RefBackend.matrix_from_coo(3, 3, entries, :int64, []) + assert {:ok, {3, 3}} = Matrix.shape(m) + assert {:ok, 3} = Matrix.nvals(m) + assert {:ok, coo} = Matrix.to_coo(m) + assert coo == [{0, 0, 1}, {1, 1, 1}, {2, 2, 1}] + end + + test "duplicate entries are combined with additive monoid" do + # Two entries at (0,0) with values 3 and 7 + # Expected: (0,0) = 3+7 = 10 + entries = [{0, 0, 3}, {0, 0, 7}] + assert {:ok, m} = RefBackend.matrix_from_coo(2, 2, entries, :int64, []) + assert {:ok, [{0, 0, 10}]} = Matrix.to_coo(m) + end + + test "empty matrix has zero stored values" do + assert {:ok, m} = RefBackend.matrix_new(5, 5, :int64, []) + assert {:ok, 0} = Matrix.nvals(m) + assert {:ok, {5, 5}} = Matrix.shape(m) + end + end + + ############################################################################# + # mxm with plus_times semiring + ############################################################################# + + describe "mxm with plus_times semiring (semantic)" do + test "2x3 times 3x2 matrix multiplication" do + # A = [[0,1,2], B = [[1,0], + # [3,0,0]] [0,4], + # [5,0]] + # + # C = A * B = plus_times + # C(0,0) = 0*1 + 1*0 + 2*5 = 10 + # C(0,1) = 0*0 + 1*4 + 2*0 = 4 + # C(1,0) = 3*1 + 0*0 + 0*5 = 3 + # C(1,1) = 3*0 + 0*4 + 0*0 = 0 (structural zero, omitted) + entries_a = [{0, 1, 1}, {0, 2, 2}, {1, 0, 3}] + entries_b = [{0, 0, 1}, {1, 1, 4}, {2, 0, 5}] + + assert {:ok, a} = RefBackend.matrix_from_coo(2, 3, entries_a, :int64, []) + assert {:ok, b} = RefBackend.matrix_from_coo(3, 2, entries_b, :int64, []) + assert {:ok, c} = RefBackend.matrix_mxm(a, b, :plus_times, []) + + assert {:ok, {2, 2}} = Matrix.shape(c) + assert {:ok, coo} = Matrix.to_coo(c) + # C should have: (0,0)=10, (0,1)=4, (1,0)=3 + assert Enum.sort(coo) == [{0, 0, 10}, {0, 1, 4}, {1, 0, 3}] + end + + test "identity times identity equals identity (plus_times)" do + # I * I = I with plus_times semiring + entries_i = [{0, 0, 1}, {1, 1, 1}, {2, 2, 1}] + assert {:ok, i} = RefBackend.matrix_from_coo(3, 3, entries_i, :int64, []) + assert {:ok, result} = RefBackend.matrix_mxm(i, i, :plus_times, []) + + assert {:ok, coo} = Matrix.to_coo(result) + assert Enum.sort(coo) == [{0, 0, 1}, {1, 1, 1}, {2, 2, 1}] + end + + test "zero matrix times anything yields zero (empty result)" do + # Empty matrix A(2x2) times non-empty B(2x2) should yield empty + assert {:ok, a} = RefBackend.matrix_new(2, 2, :int64, []) + assert {:ok, b} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 5}], :int64, []) + assert {:ok, c} = RefBackend.matrix_mxm(a, b, :plus_times, []) + assert {:ok, 0} = Matrix.nvals(c) + end + end + + ############################################################################# + # mxm with lor_land semiring (boolean adjacency / BFS reachability) + ############################################################################# + + describe "mxm with lor_land semiring (semantic)" do + test "boolean adjacency: 1-hop reachability" do + # Graph: 0 -> 1 -> 2 + # Adjacency matrix A: + # A(0,1)=true, A(1,2)=true + # + # A * A (lor_land): + # C(i,j) = lor over k of (A(i,k) land A(k,j)) + # C(0,2) = A(0,1) land A(1,2) = true land true = true + # All other products are false + entries_a = [{0, 1, true}, {1, 2, true}] + assert {:ok, a} = RefBackend.matrix_from_coo(3, 3, entries_a, :bool, []) + + assert {:ok, c} = RefBackend.matrix_mxm(a, a, :lor_land, []) + assert {:ok, coo} = Matrix.to_coo(c) + # Only (0,2) should be true + assert Enum.sort(coo) == [{0, 2, true}] + end + + test "boolean identity with lor_land" do + # Boolean identity: diag(true, true, true) + # I_lor_land * I_lor_land should equal I_lor_land + entries_i = [{0, 0, true}, {1, 1, true}, {2, 2, true}] + assert {:ok, i} = RefBackend.matrix_from_coo(3, 3, entries_i, :bool, []) + + assert {:ok, result} = RefBackend.matrix_mxm(i, i, :lor_land, []) + assert {:ok, coo} = Matrix.to_coo(result) + assert Enum.sort(coo) == [{0, 0, true}, {1, 1, true}, {2, 2, true}] + end + end + + ############################################################################# + # mxm with plus_min semiring (shortest path) + ############################################################################# + + describe "mxm with plus_min semiring (semantic)" do + test "plus_min: add=min-aggregates, multiply=min-pairwise" do + # plus_min semiring: multiply=min, add=plus + # C(i,j) = sum over k of min(A(i,k), B(k,j)) + # + # A = [[0,3,7], + # [0,0,5], + # [0,0,0]] (sparse: only entries at (0,1)=3, (0,2)=7, (1,2)=5) + # + # A*A with plus_min: + # C(0,2): matching k where both A(0,k) and A(k,2) stored: + # k=1: min(A(0,1), A(1,2)) = min(3, 5) = 3 + # (k=2: A(0,2)=7 but A(2,j) has no stored entries -> no match) + # C(0,2) = 3 + entries_a = [{0, 1, 3}, {0, 2, 7}, {1, 2, 5}] + assert {:ok, a} = RefBackend.matrix_from_coo(3, 3, entries_a, :int64, []) + + assert {:ok, c} = RefBackend.matrix_mxm(a, a, :plus_min, []) + assert {:ok, coo} = Matrix.to_coo(c) + assert {0, 2, 3} in coo + end + end + + ############################################################################# + # mxv (matrix-vector multiplication) + ############################################################################# + + describe "mxv (semantic)" do + test "matrix times vector with plus_times" do + # A = [[1,2],[3,4]], x = [5, 6]^T + # A*x = [1*5+2*6, 3*5+4*6] = [17, 39] + entries_a = [{0, 0, 1}, {0, 1, 2}, {1, 0, 3}, {1, 1, 4}] + assert {:ok, a} = RefBackend.matrix_from_coo(2, 2, entries_a, :int64, []) + assert {:ok, v} = RefBackend.vector_from_entries(2, [{0, 5}, {1, 6}], :int64, []) + + assert {:ok, result} = RefBackend.matrix_mxv(a, v, :plus_times, []) + assert {:ok, entries} = Vector.to_entries(result) + assert Enum.sort(entries) == [{0, 17}, {1, 39}] + end + + test "mxv with sparse matrix and sparse vector" do + # A(2,3) = [[0,1,0],[0,0,2]] + # x = [0, 3, 0]^T (only index 1 is nonzero) + # A*x = [1*3, 0] = [3, 0] + # But (1,*): row 1 = [0,0,2], x = [0,3,0] + # (1,0)*x(0) + (1,1)*x(1) + (1,2)*x(2) = 0*0 + 0*3 + 2*0 = 0 + # Actually: sparse semantics. Only stored entries count. + # Row 0: A(0,1)*x(1) = 1*3 = 3 => result(0) = 3 + # Row 1: A(1,2)*x(2) = ??? but x only has entry at index 1, not 2 + # So row 1 has no matching k where both A(1,k) and x(k) are stored + entries_a = [{0, 1, 1}, {1, 2, 2}] + assert {:ok, a} = RefBackend.matrix_from_coo(2, 3, entries_a, :int64, []) + assert {:ok, v} = RefBackend.vector_from_entries(3, [{1, 3}], :int64, []) + + assert {:ok, result} = RefBackend.matrix_mxv(a, v, :plus_times, []) + # Only row 0 produces a nonzero result: 1*3 = 3 + assert {:ok, entries} = Vector.to_entries(result) + assert Enum.sort(entries) == [{0, 3}] + end + end + + ############################################################################# + # vxm (vector-matrix multiplication) + ############################################################################# + + describe "vxm (semantic)" do + test "vector times matrix with plus_times" do + # x = [2, 3]^T (row vector * matrix): x^T * A + # A = [[1,0],[0,4]] + # x^T * A = [2*1, 3*4] = [2, 12] + entries_a = [{0, 0, 1}, {1, 1, 4}] + assert {:ok, a} = RefBackend.matrix_from_coo(2, 2, entries_a, :int64, []) + assert {:ok, v} = RefBackend.vector_from_entries(2, [{0, 2}, {1, 3}], :int64, []) + + assert {:ok, result} = RefBackend.vector_vxm(v, a, :plus_times, []) + assert {:ok, entries} = Vector.to_entries(result) + assert Enum.sort(entries) == [{0, 2}, {1, 12}] + end + end + + ############################################################################# + # ewise_add and ewise_mult (semantic) + ############################################################################# + + describe "ewise_add (semantic - union)" do + test "union of positions: overlapping summed, disjoint carried" do + # A has (0,0)=1, (1,1)=2 + # B has (0,0)=3, (0,1)=4 + # ewise_add with :plus: + # (0,0) = 1+3 = 4 (overlapping) + # (0,1) = 4 (from B only) + # (1,1) = 2 (from A only) + assert {:ok, a} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}, {1, 1, 2}], :int64, []) + assert {:ok, b} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 3}, {0, 1, 4}], :int64, []) + + assert {:ok, c} = RefBackend.matrix_ewise_add(a, b, :plus, []) + assert {:ok, 3} = Matrix.nvals(c) + assert {:ok, coo} = Matrix.to_coo(c) + assert {0, 0, 4} in coo + assert {0, 1, 4} in coo + assert {1, 1, 2} in coo + end + + test "ewise_add with :max monoid" do + # A has (0,0)=5 + # B has (0,0)=3 + # max(5, 3) = 5 + assert {:ok, a} = RefBackend.matrix_from_coo(1, 1, [{0, 0, 5}], :int64, []) + assert {:ok, b} = RefBackend.matrix_from_coo(1, 1, [{0, 0, 3}], :int64, []) + + assert {:ok, c} = RefBackend.matrix_ewise_add(a, b, :max, []) + assert {:ok, [{0, 0, 5}]} = Matrix.to_coo(c) + end + end + + describe "ewise_mult (semantic - intersection)" do + test "intersection: only overlapping positions kept" do + # A has (0,0)=2, (0,1)=3 + # B has (0,0)=4, (1,0)=5 + # ewise_mult with :times: + # (0,0) = 2*4 = 8 (overlapping) + # (0,1) from A only -> dropped + # (1,0) from B only -> dropped + assert {:ok, a} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 2}, {0, 1, 3}], :int64, []) + assert {:ok, b} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 4}, {1, 0, 5}], :int64, []) + + assert {:ok, c} = RefBackend.matrix_ewise_mult(a, b, :times, []) + assert {:ok, 1} = Matrix.nvals(c) + assert {:ok, [{0, 0, 8}]} = Matrix.to_coo(c) + end + + test "ewise_mult with disjoint matrices yields empty" do + # A has (0,0)=1, B has (1,1)=2. No overlap. + assert {:ok, a} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + assert {:ok, b} = RefBackend.matrix_from_coo(2, 2, [{1, 1, 2}], :int64, []) + + assert {:ok, c} = RefBackend.matrix_ewise_mult(a, b, :times, []) + assert {:ok, 0} = Matrix.nvals(c) + end + end + + ############################################################################# + # reduce (semantic) + ############################################################################# + + describe "reduce (semantic)" do + test "row reduction with :plus monoid" do + # A = [[1,2,0], + # [3,0,4]] + # Reduce columns per row with plus: + # Row 0: 1+2 = 3 + # Row 1: 3+4 = 7 + # Result vector: [3, 7] + entries = [{0, 0, 1}, {0, 1, 2}, {1, 0, 3}, {1, 2, 4}] + assert {:ok, m} = RefBackend.matrix_from_coo(2, 3, entries, :int64, []) + + assert {:ok, v} = RefBackend.matrix_reduce(m, :plus, []) + assert {:ok, result} = Vector.to_entries(v) + assert Enum.sort(result) == [{0, 3}, {1, 7}] + end + + test "row reduction with :max monoid" do + # A = [[1,5,3], + # [2,0,7]] + # Max per row: + # Row 0: max(1,5,3) = 5 + # Row 1: max(2,7) = 7 + entries = [{0, 0, 1}, {0, 1, 5}, {0, 2, 3}, {1, 0, 2}, {1, 2, 7}] + assert {:ok, m} = RefBackend.matrix_from_coo(2, 3, entries, :int64, []) + + assert {:ok, v} = RefBackend.matrix_reduce(m, :max, []) + assert {:ok, result} = Vector.to_entries(v) + assert {0, 5} in result + assert {1, 7} in result + end + + test "vector reduce to scalar with :plus" do + # v = [10, 20, 30] + # plus reduction = 60 + assert {:ok, v} = RefBackend.vector_from_entries(3, [{0, 10}, {1, 20}, {2, 30}], :int64, []) + assert {:ok, scalar} = RefBackend.vector_reduce(v, :plus, []) + assert %Scalar{type: :int64, value: 60} = scalar + end + end + + ############################################################################# + # transpose (semantic) + ############################################################################# + + describe "transpose (semantic)" do + test "transpose of non-square matrix" do + # A is 2x3: + # [[0, 1, 2], + # [3, 0, 0]] + # A^T is 3x2: + # [[0, 3], + # [1, 0], + # [2, 0]] + entries = [{0, 1, 1}, {0, 2, 2}, {1, 0, 3}] + assert {:ok, a} = RefBackend.matrix_from_coo(2, 3, entries, :int64, []) + + assert {:ok, t} = RefBackend.matrix_transpose(a, []) + assert {:ok, {3, 2}} = Matrix.shape(t) + assert {:ok, coo} = Matrix.to_coo(t) + # (0,1) in A -> (1,0) in A^T, value 1 + # (0,2) in A -> (2,0) in A^T, value 2 + # (1,0) in A -> (0,1) in A^T, value 3 + assert Enum.sort(coo) == [{0, 1, 3}, {1, 0, 1}, {2, 0, 2}] + end + + test "transpose of symmetric matrix equals itself" do + # Symmetric: (0,1) and (1,0) both present + entries = [{0, 0, 5}, {0, 1, 3}, {1, 0, 3}, {1, 1, 7}] + assert {:ok, m} = RefBackend.matrix_from_coo(2, 2, entries, :int64, []) + + assert {:ok, t} = RefBackend.matrix_transpose(m, []) + assert {:ok, coo} = Matrix.to_coo(t) + assert Enum.sort(coo) == [{0, 0, 5}, {0, 1, 3}, {1, 0, 3}, {1, 1, 7}] + end + end + + ############################################################################# + # Dense conversion (to_dense / to_list) + ############################################################################# + + describe "Matrix.to_dense (semantic)" do + test "converts sparse matrix to dense list-of-lists" do + # A = [[5, 0], + # [0, 7]] + assert {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 5}, {1, 1, 7}], :int64) + assert {:ok, dense} = Matrix.to_dense(m) + assert dense == [[5, 0], [0, 7]] + end + + test "all-zeros matrix to_dense" do + assert {:ok, m} = Matrix.new(2, 3, :int64) + assert {:ok, dense} = Matrix.to_dense(m) + assert dense == [[0, 0, 0], [0, 0, 0]] + end + + test "3x3 matrix to_dense" do + # [[1,0,2], + # [0,3,0], + # [4,0,5]] + assert {:ok, m} = + Matrix.from_coo( + 3, + 3, + [{0, 0, 1}, {0, 2, 2}, {1, 1, 3}, {2, 0, 4}, {2, 2, 5}], + :int64 + ) + + assert {:ok, dense} = Matrix.to_dense(m) + assert dense == [[1, 0, 2], [0, 3, 0], [4, 0, 5]] + end + end + + describe "Vector.to_list (semantic)" do + test "converts sparse vector to dense list" do + assert {:ok, v} = Vector.from_entries(4, [{0, 5}, {2, 3}], :int64) + assert {:ok, list} = Vector.to_list(v) + assert list == [5, 0, 3, 0] + end + + test "all-zeros vector to_list" do + assert {:ok, v} = Vector.new(3, :int64) + assert {:ok, list} = Vector.to_list(v) + assert list == [0, 0, 0] + end + + test "full vector to_list" do + assert {:ok, v} = Vector.from_entries(3, [{0, 10}, {1, 20}, {2, 30}], :int64) + assert {:ok, list} = Vector.to_list(v) + assert list == [10, 20, 30] + end + end + + ############################################################################# + # Vector ewise operations (semantic) + ############################################################################# + + describe "vector ewise_add (semantic)" do + test "union with overlapping indices summed" do + # a = [1, 0, 3] (indices 0 and 2) + # b = [4, 2, 0] (indices 0 and 1) + # ewise_add(plus): [5, 2, 3] + assert {:ok, a} = RefBackend.vector_from_entries(3, [{0, 1}, {2, 3}], :int64, []) + assert {:ok, b} = RefBackend.vector_from_entries(3, [{0, 4}, {1, 2}], :int64, []) + + assert {:ok, c} = RefBackend.vector_ewise_add(a, b, :plus, []) + assert {:ok, entries} = Vector.to_entries(c) + assert {0, 5} in entries + assert {1, 2} in entries + assert {2, 3} in entries + end + end + + describe "vector ewise_mult (semantic)" do + test "intersection: only overlapping indices kept" do + # a = [2, 0, 3] (indices 0 and 2) + # b = [4, 0, 5] (indices 0 and 2) + # ewise_mult(times): [8, _, 15] + assert {:ok, a} = RefBackend.vector_from_entries(3, [{0, 2}, {2, 3}], :int64, []) + assert {:ok, b} = RefBackend.vector_from_entries(3, [{0, 4}, {2, 5}], :int64, []) + + assert {:ok, c} = RefBackend.vector_ewise_mult(a, b, :times, []) + assert {:ok, entries} = Vector.to_entries(c) + assert {0, 8} in entries + assert {2, 15} in entries + assert length(entries) == 2 + end + end +end diff --git a/test/ex_graphblas/backend/suite_sparse_test.exs b/test/ex_graphblas/backend/suite_sparse_test.exs new file mode 100644 index 0000000..54c2c2f --- /dev/null +++ b/test/ex_graphblas/backend/suite_sparse_test.exs @@ -0,0 +1,1016 @@ +if System.get_env("EX_GRAPHBLAS_COMPILE_NATIVE") in ["1", "true"] do + defmodule GraphBLAS.SuiteSparseBackendTest do + use ExUnit.Case, async: false + + @moduletag :native_backend + + alias GraphBLAS.Backend.SuiteSparse + + describe "matrix_new" do + test "creates an empty matrix with correct shape" do + {:ok, m} = SuiteSparse.matrix_new(3, 4, :int64, []) + assert {:ok, {3, 4}} = SuiteSparse.matrix_shape(m) + assert {:ok, :int64} = SuiteSparse.matrix_type(m) + assert {:ok, 0} = SuiteSparse.matrix_nvals(m) + SuiteSparse.matrix_free(m) + end + + test "rejects unsupported types" do + assert {:error, %GraphBLAS.Error{reason: {:unsupported_type, :int32}}} = + SuiteSparse.matrix_new(3, 3, :int32, []) + end + end + + describe "matrix_from_coo" do + test "creates matrix from COO entries" do + entries = [{0, 1, 5}, {1, 0, 3}, {2, 2, 7}] + {:ok, m} = SuiteSparse.matrix_from_coo(3, 3, entries, :int64, []) + assert {:ok, 3} = SuiteSparse.matrix_nvals(m) + SuiteSparse.matrix_free(m) + end + + test "creates bool matrix from COO entries" do + entries = [{0, 0, true}, {1, 1, true}] + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, entries, :bool, []) + assert {:ok, 2} = SuiteSparse.matrix_nvals(m) + SuiteSparse.matrix_free(m) + end + + test "creates fp64 matrix from COO entries" do + entries = [{0, 0, 1.5}, {1, 1, 2.5}] + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, entries, :fp64, []) + assert {:ok, 2} = SuiteSparse.matrix_nvals(m) + SuiteSparse.matrix_free(m) + end + end + + describe "matrix_to_coo" do + test "extracts COO entries matching input" do + entries = [{0, 1, 5}, {1, 0, 3}] + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, entries, :int64, []) + {:ok, result} = SuiteSparse.matrix_to_coo(m) + + sorted = Enum.sort_by(result, fn {r, c, _v} -> {r, c} end) + assert sorted == [{0, 1, 5}, {1, 0, 3}] + SuiteSparse.matrix_free(m) + end + end + + describe "matrix_mxm" do + test "multiplies two matrices" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 3, [{0, 1, 1}, {1, 2, 2}], :int64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(3, 2, [{1, 0, 3}, {2, 1, 4}], :int64, []) + {:ok, c} = SuiteSparse.matrix_mxm(a, b, :plus_times, []) + assert {:ok, {2, 2}} = SuiteSparse.matrix_shape(c) + {:ok, nvals} = SuiteSparse.matrix_nvals(c) + assert nvals > 0 + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(c) + end + end + + describe "matrix_transpose" do + test "transposes a matrix" do + entries = [{0, 1, 5}, {1, 0, 3}] + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, entries, :int64, []) + {:ok, t} = SuiteSparse.matrix_transpose(m, []) + {:ok, result} = SuiteSparse.matrix_to_coo(t) + + sorted = Enum.sort_by(result, fn {r, c, _v} -> {r, c} end) + assert sorted == [{0, 1, 3}, {1, 0, 5}] + SuiteSparse.matrix_free(m) + SuiteSparse.matrix_free(t) + end + end + + describe "matrix_ewise_add" do + test "element-wise addition of two matrices" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}, {1, 1, 2}], :int64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 3}, {1, 1, 4}], :int64, []) + {:ok, c} = SuiteSparse.matrix_ewise_add(a, b, :plus, []) + {:ok, result} = SuiteSparse.matrix_to_coo(c) + + sorted = Enum.sort_by(result, fn {r, c, _v} -> {r, c} end) + assert {0, 0, 4} in sorted + assert {1, 1, 6} in sorted + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(c) + end + end + + describe "matrix_mxm with other semirings" do + test "plus_min/int64: multiply two matrices" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 3, [{0, 1, 5}, {1, 2, 3}], :int64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(3, 2, [{1, 0, 2}, {2, 1, 4}], :int64, []) + {:ok, c} = SuiteSparse.matrix_mxm(a, b, :plus_min, []) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + refute coo == [] + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(c) + end + + test "max_plus/int64: multiply two matrices" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 3, [{0, 1, 2}, {1, 2, 3}], :int64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(3, 2, [{1, 0, 1}, {2, 1, 4}], :int64, []) + {:ok, c} = SuiteSparse.matrix_mxm(a, b, :max_plus, []) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + refute coo == [] + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(c) + end + + test "max_min/int64: multiply two matrices" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 3, [{0, 1, 5}, {1, 2, 3}], :int64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(3, 2, [{1, 0, 2}, {2, 1, 4}], :int64, []) + {:ok, c} = SuiteSparse.matrix_mxm(a, b, :max_min, []) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + refute coo == [] + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(c) + end + + test "lor_land/bool: boolean adjacency multiplication" do + {:ok, a} = SuiteSparse.matrix_from_coo(3, 3, [{0, 1, true}, {1, 2, true}], :bool, []) + {:ok, c} = SuiteSparse.matrix_mxm(a, a, :lor_land, []) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + assert {0, 2, true} in coo + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(c) + end + + test "land_lor/bool: dual boolean semiring multiplication" do + {:ok, a} = SuiteSparse.matrix_from_coo(3, 3, [{0, 1, true}, {1, 2, true}], :bool, []) + {:ok, c} = SuiteSparse.matrix_mxm(a, a, :land_lor, []) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + assert is_list(coo) + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(c) + end + + test "plus_times_fp64: multiply two fp64 matrices" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 3, [{0, 1, 1.5}, {1, 2, 2.5}], :fp64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(3, 2, [{1, 0, 2.0}, {2, 1, 3.0}], :fp64, []) + {:ok, c} = SuiteSparse.matrix_mxm(a, b, :plus_times_fp64, []) + {:ok, nvals} = SuiteSparse.matrix_nvals(c) + assert nvals > 0 + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(c) + end + end + + describe "matrix_ewise with other monoids" do + test "ewise_add with :times monoid (int64)" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 2}, {1, 1, 3}], :int64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 4}, {1, 1, 5}], :int64, []) + {:ok, c} = SuiteSparse.matrix_ewise_add(a, b, :times, []) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + assert {0, 0, 8} in coo + assert {1, 1, 15} in coo + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(c) + end + + test "ewise_add with :min monoid (int64)" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 5}, {1, 1, 2}], :int64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 3}, {1, 1, 7}], :int64, []) + {:ok, c} = SuiteSparse.matrix_ewise_add(a, b, :min, []) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + assert {0, 0, 3} in coo + assert {1, 1, 2} in coo + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(c) + end + + test "ewise_add with :max monoid (int64)" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 5}, {1, 1, 2}], :int64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 3}, {1, 1, 7}], :int64, []) + {:ok, c} = SuiteSparse.matrix_ewise_add(a, b, :max, []) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + assert {0, 0, 5} in coo + assert {1, 1, 7} in coo + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(c) + end + + test "ewise_add with :lor monoid (bool)" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, true}, {1, 1, false}], :bool, []) + {:ok, b} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, false}, {1, 1, true}], :bool, []) + {:ok, c} = SuiteSparse.matrix_ewise_add(a, b, :lor, []) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + assert {0, 0, true} in coo + assert {1, 1, true} in coo + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(c) + end + + test "ewise_mult with :min monoid (int64)" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 5}, {1, 1, 2}], :int64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 3}, {1, 1, 7}], :int64, []) + {:ok, c} = SuiteSparse.matrix_ewise_mult(a, b, :min, []) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + assert {0, 0, 3} in coo + assert {1, 1, 2} in coo + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(c) + end + + test "ewise_mult with :max monoid (int64)" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 5}, {1, 1, 2}], :int64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 3}, {1, 1, 7}], :int64, []) + {:ok, c} = SuiteSparse.matrix_ewise_mult(a, b, :max, []) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + assert {0, 0, 5} in coo + assert {1, 1, 7} in coo + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(c) + end + + test "ewise_mult with :land monoid (bool)" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, true}, {1, 1, true}], :bool, []) + {:ok, b} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, true}, {1, 1, false}], :bool, []) + {:ok, c} = SuiteSparse.matrix_ewise_mult(a, b, :land, []) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + assert {0, 0, true} in coo + assert {1, 1, false} in coo + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(c) + end + end + + describe "matrix_reduce with other monoids" do + test "reduce with :times monoid (int64)" do + {:ok, m} = + SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 2}, {0, 1, 3}, {1, 1, 4}], :int64, []) + + {:ok, v} = SuiteSparse.matrix_reduce(m, :times, []) + {:ok, entries} = SuiteSparse.vector_to_entries(v) + assert {0, 6} in entries + assert {1, 4} in entries + SuiteSparse.matrix_free(m) + SuiteSparse.vector_free(v) + end + + test "reduce with :min monoid (int64)" do + {:ok, m} = + SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 5}, {0, 1, 2}, {1, 0, 3}], :int64, []) + + {:ok, v} = SuiteSparse.matrix_reduce(m, :min, []) + {:ok, entries} = SuiteSparse.vector_to_entries(v) + assert {0, 2} in entries + assert {1, 3} in entries + SuiteSparse.matrix_free(m) + SuiteSparse.vector_free(v) + end + + test "reduce with :max monoid (int64)" do + {:ok, m} = + SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 5}, {0, 1, 2}, {1, 0, 3}], :int64, []) + + {:ok, v} = SuiteSparse.matrix_reduce(m, :max, []) + {:ok, entries} = SuiteSparse.vector_to_entries(v) + assert {0, 5} in entries + assert {1, 3} in entries + SuiteSparse.matrix_free(m) + SuiteSparse.vector_free(v) + end + + test "reduce with :lor monoid (bool)" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, true}, {1, 1, false}], :bool, []) + {:ok, v} = SuiteSparse.matrix_reduce(m, :lor, []) + {:ok, entries} = SuiteSparse.vector_to_entries(v) + assert {0, true} in entries + assert {1, false} in entries + SuiteSparse.matrix_free(m) + SuiteSparse.vector_free(v) + end + end + + describe "matrix_transpose with all types" do + test "transpose fp64 matrix" do + entries = [{0, 1, 1.5}, {1, 0, 2.5}] + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, entries, :fp64, []) + {:ok, t} = SuiteSparse.matrix_transpose(m, []) + {:ok, result} = SuiteSparse.matrix_to_coo(t) + sorted = Enum.sort_by(result, fn {r, c, _v} -> {r, c} end) + assert {0, 1, 2.5} in sorted + assert {1, 0, 1.5} in sorted + SuiteSparse.matrix_free(m) + SuiteSparse.matrix_free(t) + end + + test "transpose bool matrix" do + entries = [{0, 1, true}, {1, 0, true}] + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, entries, :bool, []) + {:ok, t} = SuiteSparse.matrix_transpose(m, []) + {:ok, result} = SuiteSparse.matrix_to_coo(t) + sorted = Enum.sort_by(result, fn {r, c, _v} -> {r, c} end) + assert sorted == [{0, 1, true}, {1, 0, true}] + SuiteSparse.matrix_free(m) + SuiteSparse.matrix_free(t) + end + + test "transpose non-square matrix" do + entries = [{0, 2, 7}] + {:ok, m} = SuiteSparse.matrix_from_coo(2, 3, entries, :int64, []) + {:ok, t} = SuiteSparse.matrix_transpose(m, []) + assert {:ok, {3, 2}} = SuiteSparse.matrix_shape(t) + {:ok, result} = SuiteSparse.matrix_to_coo(t) + assert [{2, 0, 7}] == result + SuiteSparse.matrix_free(m) + SuiteSparse.matrix_free(t) + end + end + + describe "mxv and vxm" do + test "matrix_mxv with plus_times" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 3, [{0, 1, 1}, {1, 2, 2}], :int64, []) + {:ok, v} = SuiteSparse.vector_from_entries(3, [{1, 3}, {2, 4}], :int64, []) + {:ok, r} = SuiteSparse.matrix_mxv(m, v, :plus_times, []) + {:ok, entries} = SuiteSparse.vector_to_entries(r) + assert {0, 3} in entries + assert {1, 8} in entries + SuiteSparse.matrix_free(m) + SuiteSparse.vector_free(v) + SuiteSparse.vector_free(r) + end + + test "matrix_mxv with lor_land (bool)" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 3, [{0, 1, true}, {1, 2, true}], :bool, []) + {:ok, v} = SuiteSparse.vector_from_entries(3, [{1, true}, {2, true}], :bool, []) + {:ok, r} = SuiteSparse.matrix_mxv(m, v, :lor_land, []) + {:ok, entries} = SuiteSparse.vector_to_entries(r) + assert {0, true} in entries + assert {1, true} in entries + SuiteSparse.matrix_free(m) + SuiteSparse.vector_free(v) + SuiteSparse.vector_free(r) + end + + test "vector_vxm with plus_times" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [{0, 1}, {2, 2}], :int64, []) + {:ok, m} = SuiteSparse.matrix_from_coo(3, 2, [{0, 0, 3}, {2, 1, 4}], :int64, []) + {:ok, r} = SuiteSparse.vector_vxm(v, m, :plus_times, []) + {:ok, entries} = SuiteSparse.vector_to_entries(r) + assert {0, 3} in entries + assert {1, 8} in entries + SuiteSparse.vector_free(v) + SuiteSparse.matrix_free(m) + SuiteSparse.vector_free(r) + end + end + + describe "vector operations with all types" do + test "creates fp64 vector and extracts entries" do + {:ok, v} = SuiteSparse.vector_from_entries(4, [{0, 1.5}, {2, 3.5}], :fp64, []) + assert {:ok, 2} = SuiteSparse.vector_nvals(v) + assert {:ok, 4} = SuiteSparse.vector_size(v) + {:ok, entries} = SuiteSparse.vector_to_entries(v) + assert {0, 1.5} in entries + assert {2, 3.5} in entries + SuiteSparse.vector_free(v) + end + + test "creates bool vector and extracts entries" do + {:ok, v} = SuiteSparse.vector_from_entries(4, [{0, true}, {2, false}], :bool, []) + assert {:ok, 2} = SuiteSparse.vector_nvals(v) + assert {:ok, 4} = SuiteSparse.vector_size(v) + {:ok, entries} = SuiteSparse.vector_to_entries(v) + assert {0, true} in entries + assert {2, false} in entries + SuiteSparse.vector_free(v) + end + + test "element-wise addition of two fp64 vectors" do + {:ok, a} = SuiteSparse.vector_from_entries(3, [{0, 1.5}, {1, 2.5}], :fp64, []) + {:ok, b} = SuiteSparse.vector_from_entries(3, [{0, 3.5}, {1, 4.5}], :fp64, []) + {:ok, c} = SuiteSparse.vector_ewise_add(a, b, :plus_fp64, []) + {:ok, entries} = SuiteSparse.vector_to_entries(c) + assert {0, 5.0} in entries + assert {1, 7.0} in entries + SuiteSparse.vector_free(a) + SuiteSparse.vector_free(b) + SuiteSparse.vector_free(c) + end + + test "element-wise addition of two bool vectors with :lor" do + {:ok, a} = SuiteSparse.vector_from_entries(3, [{0, true}, {1, false}], :bool, []) + {:ok, b} = SuiteSparse.vector_from_entries(3, [{0, false}, {1, true}], :bool, []) + {:ok, c} = SuiteSparse.vector_ewise_add(a, b, :lor, []) + {:ok, entries} = SuiteSparse.vector_to_entries(c) + assert {0, true} in entries + assert {1, true} in entries + SuiteSparse.vector_free(a) + SuiteSparse.vector_free(b) + SuiteSparse.vector_free(c) + end + + test "element-wise mult of two bool vectors with :land" do + {:ok, a} = SuiteSparse.vector_from_entries(3, [{0, true}, {1, true}], :bool, []) + {:ok, b} = SuiteSparse.vector_from_entries(3, [{0, true}, {1, false}], :bool, []) + {:ok, c} = SuiteSparse.vector_ewise_mult(a, b, :land, []) + {:ok, entries} = SuiteSparse.vector_to_entries(c) + assert {0, true} in entries + assert {1, false} in entries + SuiteSparse.vector_free(a) + SuiteSparse.vector_free(b) + SuiteSparse.vector_free(c) + end + + test "reduces a fp64 vector to a scalar" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [{0, 1.5}, {1, 2.5}, {2, 3.0}], :fp64, []) + {:ok, scalar} = SuiteSparse.vector_reduce(v, :plus_fp64, []) + assert_in_delta scalar.value, 7.0, 0.001 + SuiteSparse.vector_free(v) + end + + test "reduces a bool vector with :lor" do + {:ok, v} = + SuiteSparse.vector_from_entries(3, [{0, true}, {1, false}, {2, true}], :bool, []) + + {:ok, scalar} = SuiteSparse.vector_reduce(v, :lor, []) + assert scalar.value == true + SuiteSparse.vector_free(v) + end + + test "reduces a bool vector with :land" do + {:ok, v} = + SuiteSparse.vector_from_entries(3, [{0, true}, {1, true}, {2, true}], :bool, []) + + {:ok, scalar} = SuiteSparse.vector_reduce(v, :land, []) + assert scalar.value == true + SuiteSparse.vector_free(v) + end + end + + describe "error paths" do + test "unsupported type :fp32 returns error" do + assert {:error, %GraphBLAS.Error{reason: {:unsupported_type, :fp32}}} = + SuiteSparse.matrix_new(3, 3, :fp32, []) + end + + test "unsupported type :int8 returns error" do + assert {:error, %GraphBLAS.Error{reason: {:unsupported_type, :int8}}} = + SuiteSparse.matrix_new(3, 3, :int8, []) + end + + test "unsupported type for vector_new" do + assert {:error, %GraphBLAS.Error{reason: {:unsupported_type, :int32}}} = + SuiteSparse.vector_new(3, :int32, []) + end + + test "unknown semiring returns error via mxm" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + result = SuiteSparse.matrix_mxm(a, b, :nonexistent_semiring, []) + assert match?({:error, _}, result) + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + end + + test "unknown monoid returns error via ewise_add" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + result = SuiteSparse.matrix_ewise_add(a, b, :nonexistent_monoid, []) + assert match?({:error, _}, result) + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + end + + test "negative dimension returns error" do + result = SuiteSparse.matrix_new(-1, 3, :int64, []) + assert match?({:error, _}, result) + end + + test "negative ncols returns error" do + result = SuiteSparse.matrix_new(3, -1, :int64, []) + assert match?({:error, _}, result) + end + end + + describe "matrix_to_dense" do + test "converts int64 matrix to dense" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 5}, {1, 1, 7}], :int64, []) + {:ok, dense} = SuiteSparse.matrix_to_dense(m) + assert dense == [[5, 0], [0, 7]] + SuiteSparse.matrix_free(m) + end + + test "converts bool matrix to dense" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, true}, {1, 1, true}], :bool, []) + {:ok, dense} = SuiteSparse.matrix_to_dense(m) + assert dense == [[true, false], [false, true]] + SuiteSparse.matrix_free(m) + end + + test "converts fp64 matrix to dense" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 3, [{0, 1, 1.5}, {1, 2, 2.5}], :fp64, []) + {:ok, dense} = SuiteSparse.matrix_to_dense(m) + assert dense == [[0.0, 1.5, 0.0], [0.0, 0.0, 2.5]] + SuiteSparse.matrix_free(m) + end + + test "converts empty matrix to all-zeros dense" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 3, [], :int64, []) + {:ok, dense} = SuiteSparse.matrix_to_dense(m) + assert dense == [[0, 0, 0], [0, 0, 0]] + SuiteSparse.matrix_free(m) + end + + test "converts 1x1 matrix to dense" do + {:ok, m} = SuiteSparse.matrix_from_coo(1, 1, [{0, 0, 42}], :int64, []) + {:ok, dense} = SuiteSparse.matrix_to_dense(m) + assert dense == [[42]] + SuiteSparse.matrix_free(m) + end + end + + describe "vector_to_list" do + test "converts int64 vector to dense list" do + {:ok, v} = SuiteSparse.vector_from_entries(4, [{0, 5}, {2, 3}], :int64, []) + {:ok, list} = SuiteSparse.vector_to_list(v) + assert list == [5, 0, 3, 0] + SuiteSparse.vector_free(v) + end + + test "converts bool vector to dense list" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [{0, true}, {2, true}], :bool, []) + {:ok, list} = SuiteSparse.vector_to_list(v) + assert list == [true, false, true] + SuiteSparse.vector_free(v) + end + + test "converts fp64 vector to dense list" do + {:ok, v} = SuiteSparse.vector_from_entries(4, [{0, 1.5}, {2, 3.5}], :fp64, []) + {:ok, list} = SuiteSparse.vector_to_list(v) + assert list == [1.5, 0.0, 3.5, 0.0] + SuiteSparse.vector_free(v) + end + + test "converts empty vector to all-defaults list" do + {:ok, v} = SuiteSparse.vector_new(3, :int64, []) + {:ok, list} = SuiteSparse.vector_to_list(v) + assert list == [0, 0, 0] + SuiteSparse.vector_free(v) + end + + test "converts empty fp64 vector to all-zeros list" do + {:ok, v} = SuiteSparse.vector_new(2, :fp64, []) + {:ok, list} = SuiteSparse.vector_to_list(v) + assert list == [0.0, 0.0] + SuiteSparse.vector_free(v) + end + + test "converts empty bool vector to all-false list" do + {:ok, v} = SuiteSparse.vector_new(2, :bool, []) + {:ok, list} = SuiteSparse.vector_to_list(v) + assert list == [false, false] + SuiteSparse.vector_free(v) + end + end + + describe "vector_new" do + test "creates an empty vector" do + {:ok, v} = SuiteSparse.vector_new(5, :int64, []) + assert {:ok, 5} = SuiteSparse.vector_size(v) + assert {:ok, 0} = SuiteSparse.vector_nvals(v) + SuiteSparse.vector_free(v) + end + + test "creates empty fp64 vector" do + {:ok, v} = SuiteSparse.vector_new(3, :fp64, []) + assert {:ok, :fp64} = SuiteSparse.vector_type(v) + assert {:ok, 0} = SuiteSparse.vector_nvals(v) + SuiteSparse.vector_free(v) + end + + test "creates empty bool vector" do + {:ok, v} = SuiteSparse.vector_new(3, :bool, []) + assert {:ok, :bool} = SuiteSparse.vector_type(v) + assert {:ok, 0} = SuiteSparse.vector_nvals(v) + SuiteSparse.vector_free(v) + end + + test "rejects unsupported type :int32" do + assert {:error, %GraphBLAS.Error{reason: {:unsupported_type, :int32}}} = + SuiteSparse.vector_new(3, :int32, []) + end + + test "rejects unsupported type :fp32" do + assert {:error, %GraphBLAS.Error{reason: {:unsupported_type, :fp32}}} = + SuiteSparse.vector_new(3, :fp32, []) + end + + test "empty vector to_entries returns empty list" do + {:ok, v} = SuiteSparse.vector_new(3, :int64, []) + assert {:ok, []} = SuiteSparse.vector_to_entries(v) + SuiteSparse.vector_free(v) + end + end + + describe "empty matrix and vector operations" do + test "empty matrix nvals is zero" do + {:ok, m} = SuiteSparse.matrix_from_coo(5, 5, [], :int64, []) + assert {:ok, 0} = SuiteSparse.matrix_nvals(m) + SuiteSparse.matrix_free(m) + end + + test "empty matrix to_coo returns empty list" do + {:ok, m} = SuiteSparse.matrix_from_coo(5, 5, [], :int64, []) + {:ok, coo} = SuiteSparse.matrix_to_coo(m) + assert coo == [] + SuiteSparse.matrix_free(m) + end + + test "empty vector nvals is zero" do + {:ok, v} = SuiteSparse.vector_from_entries(5, [], :int64, []) + assert {:ok, 0} = SuiteSparse.vector_nvals(v) + SuiteSparse.vector_free(v) + end + + test "empty vector reduce with :plus returns 0" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [], :int64, []) + {:ok, scalar} = SuiteSparse.vector_reduce(v, :plus, []) + assert scalar.value == 0 + SuiteSparse.vector_free(v) + end + + test "empty vector reduce with :times returns 1" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [], :int64, []) + {:ok, scalar} = SuiteSparse.vector_reduce(v, :times, []) + assert scalar.value == 1 + SuiteSparse.vector_free(v) + end + + test "empty vector reduce with :lor returns false" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [], :bool, []) + {:ok, scalar} = SuiteSparse.vector_reduce(v, :lor, []) + assert scalar.value == false + SuiteSparse.vector_free(v) + end + + test "empty vector reduce with :land returns true" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [], :bool, []) + {:ok, scalar} = SuiteSparse.vector_reduce(v, :land, []) + assert scalar.value == true + SuiteSparse.vector_free(v) + end + end + + describe "matrix_set" do + test "sets an int64 value" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + {:ok, updated} = SuiteSparse.matrix_set(m, 1, 1, 5) + {:ok, coo} = SuiteSparse.matrix_to_coo(updated) + assert {1, 1, 5} in coo + SuiteSparse.matrix_free(updated) + end + + test "sets a fp64 value" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [], :fp64, []) + {:ok, updated} = SuiteSparse.matrix_set(m, 0, 1, 3.14) + {:ok, coo} = SuiteSparse.matrix_to_coo(updated) + assert {0, 1, 3.14} in coo + SuiteSparse.matrix_free(updated) + end + + test "sets a bool value" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [], :bool, []) + {:ok, updated} = SuiteSparse.matrix_set(m, 0, 0, true) + {:ok, coo} = SuiteSparse.matrix_to_coo(updated) + assert {0, 0, true} in coo + SuiteSparse.matrix_free(updated) + end + + test "overwrites existing value" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + {:ok, updated} = SuiteSparse.matrix_set(m, 0, 0, 42) + {:ok, coo} = SuiteSparse.matrix_to_coo(updated) + assert {0, 0, 42} in coo + refute {0, 0, 1} in coo + SuiteSparse.matrix_free(updated) + end + + test "rejects out-of-bounds row" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + assert {:error, %GraphBLAS.Error{}} = SuiteSparse.matrix_set(m, 5, 0, 1) + SuiteSparse.matrix_free(m) + end + + test "rejects out-of-bounds col" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + assert {:error, %GraphBLAS.Error{}} = SuiteSparse.matrix_set(m, 0, 5, 1) + SuiteSparse.matrix_free(m) + end + end + + describe "matrix_extract" do + test "extracts a stored int64 value" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 42}], :int64, []) + assert {:ok, 42} = SuiteSparse.matrix_extract(m, 0, 0) + SuiteSparse.matrix_free(m) + end + + test "returns default for structural zero (int64)" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 42}], :int64, []) + assert {:ok, 0} = SuiteSparse.matrix_extract(m, 1, 1) + SuiteSparse.matrix_free(m) + end + + test "returns default for structural zero (fp64)" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1.5}], :fp64, []) + assert {:ok, +0.0} = SuiteSparse.matrix_extract(m, 1, 1) + SuiteSparse.matrix_free(m) + end + + test "returns default for structural zero (bool)" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, true}], :bool, []) + assert {:ok, false} = SuiteSparse.matrix_extract(m, 1, 1) + SuiteSparse.matrix_free(m) + end + + test "extracts a stored fp64 value" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 3.14}], :fp64, []) + assert {:ok, v} = SuiteSparse.matrix_extract(m, 0, 0) + assert_in_delta v, 3.14, 0.001 + SuiteSparse.matrix_free(m) + end + + test "extracts a stored bool value" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, true}], :bool, []) + assert {:ok, true} = SuiteSparse.matrix_extract(m, 0, 0) + SuiteSparse.matrix_free(m) + end + + test "rejects out-of-bounds row" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + assert {:error, %GraphBLAS.Error{}} = SuiteSparse.matrix_extract(m, 5, 0) + SuiteSparse.matrix_free(m) + end + end + + describe "matrix_dup" do + test "creates a deep copy with same data" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}, {1, 1, 2}], :int64, []) + {:ok, copy} = SuiteSparse.matrix_dup(m) + + {:ok, coo_orig} = SuiteSparse.matrix_to_coo(m) + {:ok, coo_copy} = SuiteSparse.matrix_to_coo(copy) + assert sort_coo(coo_orig) == sort_coo(coo_copy) + + SuiteSparse.matrix_free(m) + SuiteSparse.matrix_free(copy) + end + + test "copy is independent from original" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + {:ok, copy} = SuiteSparse.matrix_dup(m) + + {:ok, updated} = SuiteSparse.matrix_set(copy, 0, 0, 99) + {:ok, val_copy} = SuiteSparse.matrix_extract(updated, 0, 0) + assert val_copy == 99 + + {:ok, val_orig} = SuiteSparse.matrix_extract(m, 0, 0) + assert val_orig == 1 + + SuiteSparse.matrix_free(m) + SuiteSparse.matrix_free(copy) + SuiteSparse.matrix_free(updated) + end + + test "duplicates fp64 matrix" do + {:ok, m} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1.5}], :fp64, []) + {:ok, copy} = SuiteSparse.matrix_dup(m) + + {:ok, coo_orig} = SuiteSparse.matrix_to_coo(m) + {:ok, coo_copy} = SuiteSparse.matrix_to_coo(copy) + assert length(coo_orig) == length(coo_copy) + + SuiteSparse.matrix_free(m) + SuiteSparse.matrix_free(copy) + end + end + + describe "vector_set" do + test "sets an int64 value" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [{0, 1}], :int64, []) + {:ok, updated} = SuiteSparse.vector_set(v, 1, 5) + {:ok, entries} = SuiteSparse.vector_to_entries(updated) + assert {1, 5} in entries + SuiteSparse.vector_free(updated) + end + + test "sets a fp64 value" do + {:ok, v} = SuiteSparse.vector_new(3, :fp64, []) + {:ok, updated} = SuiteSparse.vector_set(v, 0, 2.5) + {:ok, entries} = SuiteSparse.vector_to_entries(updated) + assert {0, 2.5} in entries + SuiteSparse.vector_free(updated) + end + + test "sets a bool value" do + {:ok, v} = SuiteSparse.vector_new(3, :bool, []) + {:ok, updated} = SuiteSparse.vector_set(v, 1, true) + {:ok, entries} = SuiteSparse.vector_to_entries(updated) + assert {1, true} in entries + SuiteSparse.vector_free(updated) + end + + test "overwrites existing value" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [{0, 1}], :int64, []) + {:ok, updated} = SuiteSparse.vector_set(v, 0, 42) + {:ok, entries} = SuiteSparse.vector_to_entries(updated) + assert {0, 42} in entries + refute {0, 1} in entries + SuiteSparse.vector_free(updated) + end + + test "rejects out-of-bounds index" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [{0, 1}], :int64, []) + assert {:error, %GraphBLAS.Error{}} = SuiteSparse.vector_set(v, 5, 1) + SuiteSparse.vector_free(v) + end + end + + describe "vector_extract" do + test "extracts a stored int64 value" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [{0, 42}], :int64, []) + assert {:ok, 42} = SuiteSparse.vector_extract(v, 0) + SuiteSparse.vector_free(v) + end + + test "returns default for structural zero (int64)" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [{0, 42}], :int64, []) + assert {:ok, 0} = SuiteSparse.vector_extract(v, 1) + SuiteSparse.vector_free(v) + end + + test "returns default for structural zero (fp64)" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [{0, 1.5}], :fp64, []) + assert {:ok, +0.0} = SuiteSparse.vector_extract(v, 1) + SuiteSparse.vector_free(v) + end + + test "returns default for structural zero (bool)" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [{0, true}], :bool, []) + assert {:ok, false} = SuiteSparse.vector_extract(v, 1) + SuiteSparse.vector_free(v) + end + + test "extracts a stored fp64 value" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [{0, 3.14}], :fp64, []) + assert {:ok, val} = SuiteSparse.vector_extract(v, 0) + assert_in_delta val, 3.14, 0.001 + SuiteSparse.vector_free(v) + end + + test "rejects out-of-bounds index" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [{0, 1}], :int64, []) + assert {:error, %GraphBLAS.Error{}} = SuiteSparse.vector_extract(v, 5) + SuiteSparse.vector_free(v) + end + end + + describe "vector_dup" do + test "creates a deep copy with same data" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [{0, 1}, {1, 2}], :int64, []) + {:ok, copy} = SuiteSparse.vector_dup(v) + + {:ok, entries_orig} = SuiteSparse.vector_to_entries(v) + {:ok, entries_copy} = SuiteSparse.vector_to_entries(copy) + assert sort_entries(entries_orig) == sort_entries(entries_copy) + + SuiteSparse.vector_free(v) + SuiteSparse.vector_free(copy) + end + + test "copy is independent from original" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [{0, 1}], :int64, []) + {:ok, copy} = SuiteSparse.vector_dup(v) + + {:ok, updated} = SuiteSparse.vector_set(copy, 0, 99) + {:ok, val_copy} = SuiteSparse.vector_extract(updated, 0) + assert val_copy == 99 + + {:ok, val_orig} = SuiteSparse.vector_extract(v, 0) + assert val_orig == 1 + + SuiteSparse.vector_free(v) + SuiteSparse.vector_free(copy) + SuiteSparse.vector_free(updated) + end + + test "duplicates bool vector" do + {:ok, v} = SuiteSparse.vector_from_entries(3, [{0, true}, {2, true}], :bool, []) + {:ok, copy} = SuiteSparse.vector_dup(v) + + {:ok, entries_orig} = SuiteSparse.vector_to_entries(v) + {:ok, entries_copy} = SuiteSparse.vector_to_entries(copy) + assert sort_entries(entries_orig) == sort_entries(entries_copy) + + SuiteSparse.vector_free(v) + SuiteSparse.vector_free(copy) + end + end + + describe "mask on mxm" do + test "structural mask restricts output positions" do + {:ok, a} = + SuiteSparse.matrix_from_coo( + 2, + 2, + [{0, 0, 1}, {0, 1, 2}, {1, 0, 3}, {1, 1, 4}], + :int64, + [] + ) + + {:ok, b} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}, {1, 1, 1}], :int64, []) + {:ok, mask_src} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + mask = GraphBLAS.Mask.new(mask_src) + + {:ok, c} = SuiteSparse.matrix_mxm(a, b, :plus_times, mask: mask) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + + for {r, cv, _v} <- coo do + assert {r, cv} == {0, 0} + end + + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(mask_src) + SuiteSparse.matrix_free(c) + end + + test "complement mask restricts output to non-mask positions" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}, {1, 1, 1}], :int64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}, {1, 1, 1}], :int64, []) + {:ok, mask_src} = SuiteSparse.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + mask = GraphBLAS.Mask.complement(mask_src) + + {:ok, c} = SuiteSparse.matrix_mxm(a, b, :plus_times, mask: mask) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + + for {r, cv, _v} <- coo do + refute {r, cv} == {0, 0} + end + + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(mask_src) + SuiteSparse.matrix_free(c) + end + end + + describe "descriptor on mxm" do + test "descriptor with inp0_transpose" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 3, [{0, 1, 1}, {1, 2, 2}], :int64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(2, 3, [{0, 1, 3}, {1, 2, 4}], :int64, []) + + desc = GraphBLAS.Descriptor.new(inp0_transpose: :transpose) + {:ok, c} = SuiteSparse.matrix_mxm(a, b, :plus_times, descriptor: desc) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + + assert coo != [] + + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(c) + end + + test "descriptor with inp1_transpose" do + {:ok, a} = SuiteSparse.matrix_from_coo(2, 3, [{0, 1, 1}], :int64, []) + {:ok, b} = SuiteSparse.matrix_from_coo(2, 3, [{0, 1, 1}], :int64, []) + + desc = GraphBLAS.Descriptor.new(inp1_transpose: :transpose) + {:ok, c} = SuiteSparse.matrix_mxm(a, b, :plus_times, descriptor: desc) + {:ok, coo} = SuiteSparse.matrix_to_coo(c) + + assert is_list(coo) + + SuiteSparse.matrix_free(a) + SuiteSparse.matrix_free(b) + SuiteSparse.matrix_free(c) + end + end + + defp sort_coo(entries) do + Enum.sort_by(entries, fn {r, c, _v} -> {r, c} end) + end + + defp sort_entries(entries) do + Enum.sort_by(entries, fn {i, _v} -> i end) + end + end +end diff --git a/test/ex_graphblas/binary_op_test.exs b/test/ex_graphblas/binary_op_test.exs new file mode 100644 index 0000000..eaebe4d --- /dev/null +++ b/test/ex_graphblas/binary_op_test.exs @@ -0,0 +1,47 @@ +defmodule GraphBLAS.BinaryOpTest do + use ExUnit.Case, async: true + + alias GraphBLAS.BinaryOp + + describe "fn_for/1" do + test "returns correct function for each built-in" do + assert 3 = BinaryOp.fn_for(:plus).(1, 2) + assert 6 = BinaryOp.fn_for(:times).(2, 3) + assert -1 = BinaryOp.fn_for(:minus).(2, 3) + assert 1 = BinaryOp.fn_for(:min).(1, 5) + assert 5 = BinaryOp.fn_for(:max).(1, 5) + assert true = BinaryOp.fn_for(:land).(true, true) + assert true = BinaryOp.fn_for(:lor).(true, false) + assert true = BinaryOp.fn_for(:lxor).(true, false) + assert BinaryOp.fn_for(:lxor).(true, true) == false + end + + test "raises for unknown operator" do + assert_raise ArgumentError, ~r/Unknown binary operator/, fn -> + BinaryOp.fn_for(:nonexistent) + end + end + end + + describe "apply/3" do + test "applies builtin operator by name" do + assert 5 = BinaryOp.apply(:plus, 2, 3) + assert 6 = BinaryOp.apply(:times, 2, 3) + end + + test "applies custom operator struct" do + op = BinaryOp.new(name: :double_add, function: fn a, b -> a + b * 2 end, type: :int64) + assert 7 = BinaryOp.apply(op, 1, 3) + end + end + + describe "builtin_names/0" do + test "returns list of known operators" do + names = BinaryOp.builtin_names() + assert :plus in names + assert :times in names + assert :min in names + assert :max in names + end + end +end diff --git a/test/ex_graphblas/config_test.exs b/test/ex_graphblas/config_test.exs new file mode 100644 index 0000000..53bf1ed --- /dev/null +++ b/test/ex_graphblas/config_test.exs @@ -0,0 +1,37 @@ +defmodule GraphBLAS.ConfigTest do + use ExUnit.Case, async: true + + alias GraphBLAS.Config + + describe "default_backend/0" do + test "returns Reference backend by default" do + assert Config.default_backend() == GraphBLAS.Backend.Elixir + end + end + + describe "resolve_backend/1" do + test "returns configured backend when no option given" do + assert Config.resolve_backend([]) == GraphBLAS.Backend.Elixir + end + + test "returns explicit backend when provided" do + assert Config.resolve_backend(backend: SomeOtherBackend) == SomeOtherBackend + end + + test "ignores other options" do + assert Config.resolve_backend(type: :int64, backend: MyBackend) == MyBackend + end + end + + describe "default_int_type/0" do + test "returns int64 by default" do + assert Config.default_int_type() == :int64 + end + end + + describe "default_fp_type/0" do + test "returns fp64 by default" do + assert Config.default_fp_type() == :fp64 + end + end +end diff --git a/test/ex_graphblas/descriptor_test.exs b/test/ex_graphblas/descriptor_test.exs new file mode 100644 index 0000000..3c29ca1 --- /dev/null +++ b/test/ex_graphblas/descriptor_test.exs @@ -0,0 +1,87 @@ +defmodule GraphBLAS.DescriptorTest do + use ExUnit.Case, async: true + + alias GraphBLAS.Backend.Elixir, as: RefBackend + alias GraphBLAS.{Descriptor, Matrix} + + describe "new/1" do + test "creates descriptor with defaults" do + d = Descriptor.new() + assert d.inp0_transpose == :none + assert d.inp1_transpose == :none + assert d.output == :merge + assert d.mask == :structural + end + + test "creates descriptor with custom options" do + d = Descriptor.new(inp0_transpose: :transpose, output: :replace) + assert d.inp0_transpose == :transpose + assert d.inp1_transpose == :none + assert d.output == :replace + end + end + + describe "inp0_transpose/0" do + test "returns descriptor with first input transposed" do + d = Descriptor.inp0_transpose() + assert d.inp0_transpose == :transpose + end + end + + describe "inp1_transpose/0" do + test "returns descriptor with second input transposed" do + d = Descriptor.inp1_transpose() + assert d.inp1_transpose == :transpose + end + end + + describe "replace_output/0" do + test "returns descriptor with replace output mode" do + d = Descriptor.replace_output() + assert d.output == :replace + end + end + + describe "descriptor with matrix_mxm — inp0_transpose" do + test "transposing first input changes mxm result" do + {:ok, a} = RefBackend.matrix_from_coo(2, 3, [{0, 1, 1}, {1, 2, 2}], :int64, []) + {:ok, b} = RefBackend.matrix_from_coo(2, 3, [{0, 1, 3}, {1, 2, 4}], :int64, []) + + desc = Descriptor.new(inp0_transpose: :transpose) + {:ok, c} = RefBackend.matrix_mxm(a, b, :plus_times, descriptor: desc) + {:ok, coo} = RefBackend.matrix_to_coo(c) + + assert coo != [] + end + end + + describe "descriptor with matrix_mxm — inp1_transpose" do + test "transposing second input changes mxm result" do + {:ok, a} = RefBackend.matrix_from_coo(2, 3, [{0, 1, 1}], :int64, []) + {:ok, b} = RefBackend.matrix_from_coo(2, 3, [{0, 1, 1}], :int64, []) + + desc = Descriptor.new(inp1_transpose: :transpose) + {:ok, c} = RefBackend.matrix_mxm(a, b, :plus_times, descriptor: desc) + {:ok, coo} = RefBackend.matrix_to_coo(c) + + assert is_list(coo) + end + end + + describe "descriptor with mask mode :value" do + test "value mask filters by truthy values in mask source" do + {:ok, a} = RefBackend.vector_from_entries(3, [{0, 1}, {1, 2}, {2, 3}], :int64, []) + {:ok, b} = RefBackend.vector_from_entries(3, [{0, 10}, {1, 20}], :int64, []) + {:ok, mask_src} = RefBackend.vector_from_entries(3, [{0, true}, {1, false}], :bool, []) + + mask = GraphBLAS.Mask.new(mask_src) + desc = Descriptor.new(mask: :value) + + {:ok, c} = RefBackend.vector_ewise_add(a, b, :plus, mask: mask, descriptor: desc) + {:ok, entries} = RefBackend.vector_to_entries(c) + + assert {0, 11} in entries + refute Enum.any?(entries, fn {i, _v} -> i == 1 end) + end + end +end diff --git a/test/ex_graphblas/error_test.exs b/test/ex_graphblas/error_test.exs new file mode 100644 index 0000000..9380445 --- /dev/null +++ b/test/ex_graphblas/error_test.exs @@ -0,0 +1,99 @@ +defmodule GraphBLAS.ErrorTest do + use ExUnit.Case, async: true + + alias GraphBLAS.Error + + describe "new/2" do + test "creates error with reason only" do + err = Error.new({:dimension_mismatch, {3, 3}, {2, 4}}) + assert err.reason == {:dimension_mismatch, {3, 3}, {2, 4}} + assert err.message == nil + assert err.context == %{} + end + + test "creates error with reason and message" do + err = Error.new({:type_mismatch, :int64, :fp64}, message: "types must agree") + assert err.reason == {:type_mismatch, :int64, :fp64} + assert err.message == "types must agree" + end + + test "creates error with reason, message, and context" do + err = + Error.new({:backend_error, SomeBackend, :crash}, + message: "native crash", + context: %{exit_code: 1} + ) + + assert err.context == %{exit_code: 1} + end + end + + describe "error/2" do + test "returns {:error, %Error{}} tuple" do + assert {:error, %Error{}} = Error.error({:invalid_argument, "bad"}) + end + end + + describe "format_error/1" do + test "formats dimension mismatch" do + err = Error.new({:dimension_mismatch, {3, 3}, {2, 4}}) + msg = Error.format_error(err) + assert msg =~ "Dimension mismatch" + assert msg =~ "{3, 3}" + assert msg =~ "{2, 4}" + end + + test "formats type mismatch" do + err = Error.new({:type_mismatch, :int64, :fp64}) + assert Error.format_error(err) =~ "Type mismatch" + end + + test "formats unsupported type" do + err = Error.new({:unsupported_type, :bad_type}) + assert Error.format_error(err) =~ "Unsupported scalar type" + end + + test "formats unsupported operation" do + err = Error.new({:unsupported_operation, :mxm, SomeBackend}) + assert Error.format_error(err) =~ "Operation :mxm not supported" + end + + test "formats backend error" do + err = Error.new({:backend_error, SomeBackend, :segfault}) + assert Error.format_error(err) =~ "Backend error" + end + + test "formats null handle" do + err = Error.new({:null_handle, :matrix}) + assert Error.format_error(err) =~ "null or destroyed :matrix handle" + end + + test "formats index out of bounds" do + err = Error.new({:index_out_of_bounds, 5, :row, 3}) + assert Error.format_error(err) =~ "Index 5 out of bounds" + end + + test "formats empty collection" do + err = Error.new({:empty_collection, :vector}) + assert Error.format_error(err) =~ "Empty collection" + end + + test "appends message when present" do + err = Error.new({:dimension_mismatch, {3, 3}, {2, 4}}, message: "during mxm") + assert Error.format_error(err) =~ "during mxm" + end + + test "formats unknown reason" do + err = Error.new(:something_weird) + assert Error.format_error(err) =~ "Unknown error" + end + end + + describe "raise!/2" do + test "raises ArgumentError with formatted message" do + assert_raise ArgumentError, ~r/Dimension mismatch/, fn -> + Error.raise!({:dimension_mismatch, {3, 3}, {2, 4}}) + end + end + end +end diff --git a/test/ex_graphblas/helpers_test.exs b/test/ex_graphblas/helpers_test.exs new file mode 100644 index 0000000..96676b1 --- /dev/null +++ b/test/ex_graphblas/helpers_test.exs @@ -0,0 +1,211 @@ +defmodule GraphBLAS.HelpersTest do + use ExUnit.Case, async: true + + alias GraphBLAS.Backend.Elixir, as: ElixirBackend + + if System.get_env("EX_GRAPHBLAS_COMPILE_NATIVE") in ["1", "true"] do + alias GraphBLAS.Backend.SuiteSparse + end + + alias GraphBLAS.{Error, Helpers, Matrix, Scalar, Vector} + + describe "ok/1" do + test "passes through {:ok, val} tuples unchanged" do + assert {:ok, 42} = Helpers.ok({:ok, 42}) + assert {:ok, :atom} = Helpers.ok({:ok, :atom}) + assert {:ok, [1, 2, 3]} = Helpers.ok({:ok, [1, 2, 3]}) + assert {:ok, %{key: :value}} = Helpers.ok({:ok, %{key: :value}}) + end + + test "wraps Matrix structs in {:ok, ...}" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64) + assert {:ok, ^m} = Helpers.ok(m) + end + + test "wraps Vector structs in {:ok, ...}" do + {:ok, v} = Vector.from_entries(3, [{0, 1}], :int64) + assert {:ok, ^v} = Helpers.ok(v) + end + + test "wraps Scalar structs in {:ok, ...}" do + s = Scalar.new(42, :int64) + assert {:ok, ^s} = Helpers.ok(s) + end + + test "passes through {:error, ...} tuples unchanged" do + err = {:error, :some_reason} + assert ^err = Helpers.ok(err) + end + + test "passes through Error structs in error tuples" do + err_struct = Error.new({:dimension_mismatch, {3, 3}, {2, 2}}) + err_tuple = {:error, err_struct} + assert ^err_tuple = Helpers.ok(err_tuple) + end + + test "passes through :ok atom unchanged" do + assert :ok = Helpers.ok(:ok) + end + + test "works in with chains for error propagation" do + result = + with {:ok, m} <- Helpers.ok(Matrix.from_coo(2, 2, [{0, 0, 1}], :int64)), + {:ok, v} <- Helpers.ok(Vector.from_entries(2, [{0, 1}], :int64)) do + {:ok, {m, v}} + end + + assert {:ok, {%Matrix{}, %Vector{}}} = result + end + + test "stops with chain on error" do + result = + with {:ok, _m} <- Helpers.ok(Matrix.from_coo(2, 2, [{0, 0, 1}], :int64)), + {:ok, _v} <- Helpers.ok({:error, :test_error}), + {:ok, _other} <- Helpers.ok(:ok) do + {:ok, :should_not_reach} + end + + assert {:error, :test_error} = result + end + end + + describe "maybe_free/2" do + test "is no-op for Elixir backend with Matrix" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64, backend: ElixirBackend) + assert :ok = Helpers.maybe_free(m, ElixirBackend) + + # Matrix should still be valid after "free" + assert {:ok, 1} = Matrix.nvals(m) + end + + test "is no-op for Elixir backend with Vector" do + {:ok, v} = Vector.from_entries(3, [{0, 1}], :int64, backend: ElixirBackend) + assert :ok = Helpers.maybe_free(v, ElixirBackend) + + # Vector should still be valid after "free" + assert {:ok, 1} = Vector.nvals(v) + end + + if System.get_env("EX_GRAPHBLAS_COMPILE_NATIVE") in ["1", "true"] do + @tag :native_backend + test "frees SuiteSparse Matrix resources" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64, backend: SuiteSparse) + assert :ok = Helpers.maybe_free(m, SuiteSparse) + end + + @tag :native_backend + test "frees SuiteSparse Vector resources" do + {:ok, v} = Vector.from_entries(3, [{0, 1}], :int64, backend: SuiteSparse) + assert :ok = Helpers.maybe_free(v, SuiteSparse) + end + end + + test "is no-op for unknown backend with Matrix" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64, backend: ElixirBackend) + # Pass a fake backend module + assert :ok = Helpers.maybe_free(m, FakeBackend) + end + + test "is no-op for unknown backend with Vector" do + {:ok, v} = Vector.from_entries(3, [{0, 1}], :int64, backend: ElixirBackend) + # Pass a fake backend module + assert :ok = Helpers.maybe_free(v, FakeBackend) + end + + test "handles nil backend gracefully" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64, backend: ElixirBackend) + assert :ok = Helpers.maybe_free(m, nil) + end + + test "can be called multiple times on same container (idempotent for Elixir)" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64, backend: ElixirBackend) + assert :ok = Helpers.maybe_free(m, ElixirBackend) + assert :ok = Helpers.maybe_free(m, ElixirBackend) + assert :ok = Helpers.maybe_free(m, ElixirBackend) + + # Still usable + assert {:ok, 1} = Matrix.nvals(m) + end + + if System.get_env("EX_GRAPHBLAS_COMPILE_NATIVE") in ["1", "true"] do + @tag :native_backend + test "frees SuiteSparse Matrix and Vector in same call" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64, backend: SuiteSparse) + {:ok, v} = Vector.from_entries(3, [{0, 1}], :int64, backend: SuiteSparse) + + assert :ok = Helpers.maybe_free(m, SuiteSparse) + assert :ok = Helpers.maybe_free(v, SuiteSparse) + end + end + end + + describe "ok/1 with complex nesting" do + test "handles nested {:ok, {:ok, val}}" do + assert {:ok, {:ok, 42}} = Helpers.ok({:ok, {:ok, 42}}) + end + + test "handles nested errors" do + assert {:error, {:inner, :error}} = Helpers.ok({:error, {:inner, :error}}) + end + + test "works with multiple struct types in sequence" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64) + {:ok, v} = Vector.from_entries(2, [{0, 1}], :int64) + s = Scalar.new(42, :int64) + + assert {:ok, ^m} = Helpers.ok(m) + assert {:ok, ^v} = Helpers.ok(v) + assert {:ok, ^s} = Helpers.ok(s) + end + end + + describe "maybe_free/2 edge cases" do + test "handles arbitrary container with arbitrary backend" do + assert :ok = Helpers.maybe_free(:not_a_container, SomeBackend) + assert :ok = Helpers.maybe_free("string", ElixirBackend) + + if System.get_env("EX_GRAPHBLAS_COMPILE_NATIVE") in ["1", "true"] do + assert :ok = Helpers.maybe_free(%{custom: :struct}, SuiteSparse) + end + end + + test "handles Matrix with wrong backend field type" do + # Matrix with backend: :atom instead of module + m_with_bad_backend = %Matrix{ + shape: {2, 2}, + type: :int64, + backend: :not_a_module, + data: %{entries: %{}, nrows: 2, ncols: 2, type: :int64} + } + + assert :ok = Helpers.maybe_free(m_with_bad_backend, :not_a_module) + end + end + + describe "integration with algorithm patterns" do + test "ok/1 integrates with typical algorithm error handling" do + result = + with {:ok, m} <- Helpers.ok(Matrix.from_coo(2, 2, [{0, 0, 1}], :int64)), + {:ok, m2} <- Helpers.ok(Matrix.from_coo(2, 2, [{1, 1, 1}], :int64)), + {:ok, result} <- Helpers.ok(Matrix.mxm(m, m2, :plus_times)) do + Helpers.ok(Matrix.to_coo(result)) + end + + assert {:ok, []} = result + end + + test "maybe_free/2 integrates with typical algorithm cleanup" do + backend = ElixirBackend + {:ok, m1} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64, backend: backend) + {:ok, m2} = Matrix.from_coo(2, 2, [{1, 1, 1}], :int64, backend: backend) + {:ok, result} = Matrix.mxm(m1, m2, :plus_times, backend: backend) + + # Typical algorithm pattern: free intermediates + :ok = Helpers.maybe_free(m1, backend) + :ok = Helpers.maybe_free(m2, backend) + + # Result is still valid + assert {:ok, 0} = Matrix.nvals(result) + end + end +end diff --git a/test/ex_graphblas/mask_test.exs b/test/ex_graphblas/mask_test.exs new file mode 100644 index 0000000..5862e31 --- /dev/null +++ b/test/ex_graphblas/mask_test.exs @@ -0,0 +1,140 @@ +defmodule GraphBLAS.MaskTest do + use ExUnit.Case, async: true + + alias GraphBLAS.Backend.Elixir, as: RefBackend + alias GraphBLAS.{Mask, Matrix, Vector} + + describe "new/2" do + test "creates a structural mask from a matrix" do + assert {:ok, m} = Matrix.from_coo(3, 3, [{0, 0, 1}], :int64) + mask = Mask.new(m) + assert mask.complement == false + assert mask.source == m + end + + test "creates a complement mask" do + assert {:ok, m} = Matrix.from_coo(3, 3, [{0, 0, 1}], :int64) + mask = Mask.new(m, complement: true) + assert mask.complement == true + end + end + + describe "complement/1" do + test "creates a complement mask" do + assert {:ok, m} = Matrix.from_coo(3, 3, [{0, 0, 1}], :int64) + mask = Mask.complement(m) + assert Mask.complement?(mask) == true + end + end + + describe "complement?/1" do + test "returns false for structural mask" do + assert {:ok, m} = Matrix.from_coo(3, 3, [{0, 0, 1}], :int64) + mask = Mask.new(m) + assert Mask.complement?(mask) == false + end + end + + describe "source/1" do + test "returns the source container" do + assert {:ok, m} = Matrix.from_coo(3, 3, [{0, 0, 1}], :int64) + mask = Mask.new(m) + assert Mask.source(mask) == m + end + end + + describe "structural mask on matrix_mxm" do + test "only writes positions present in mask" do + {:ok, a} = + RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}, {0, 1, 2}, {1, 0, 3}, {1, 1, 4}], :int64, []) + + {:ok, b} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + {:ok, c_unmasked} = RefBackend.matrix_mxm(a, b, :plus_times, []) + {:ok, mask_entries} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + mask = Mask.new(mask_entries) + + {:ok, c_masked} = RefBackend.matrix_mxm(a, b, :plus_times, mask: mask) + + {:ok, coo} = RefBackend.matrix_to_coo(c_masked) + {:ok, unmasked_coo} = RefBackend.matrix_to_coo(c_unmasked) + + for {r, c, _v} <- coo do + assert {r, c, 1} in [{0, 0, 1}] + end + + assert Enum.count(coo) <= Enum.count(unmasked_coo) + end + + test "complement mask writes positions NOT present in mask" do + {:ok, a} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}, {1, 1, 1}], :int64, []) + {:ok, b} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}, {1, 1, 1}], :int64, []) + {:ok, mask_src} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + mask = Mask.complement(mask_src) + + {:ok, c} = RefBackend.matrix_mxm(a, b, :plus_times, mask: mask) + {:ok, coo} = RefBackend.matrix_to_coo(c) + + for {r, cv, _v} <- coo do + refute r == 0 and cv == 0 + end + end + end + + describe "structural mask on vector operations" do + test "vector ewise_add with structural vector mask" do + {:ok, a} = RefBackend.vector_from_entries(3, [{0, 1}, {1, 2}, {2, 3}], :int64, []) + {:ok, b} = RefBackend.vector_from_entries(3, [{0, 10}, {1, 20}], :int64, []) + {:ok, mask_src} = RefBackend.vector_from_entries(3, [{0, 1}], :int64, []) + mask = Mask.new(mask_src) + + {:ok, c} = RefBackend.vector_ewise_add(a, b, :plus, mask: mask) + {:ok, entries} = RefBackend.vector_to_entries(c) + + assert {0, 11} in entries + refute Enum.any?(entries, fn {i, _v} -> i == 1 end) + refute Enum.any?(entries, fn {i, _v} -> i == 2 end) + end + + test "vector ewise_add with complement vector mask" do + {:ok, a} = RefBackend.vector_from_entries(3, [{0, 1}, {1, 2}], :int64, []) + {:ok, b} = RefBackend.vector_from_entries(3, [{0, 10}, {1, 20}], :int64, []) + {:ok, mask_src} = RefBackend.vector_from_entries(3, [{0, 1}], :int64, []) + mask = Mask.complement(mask_src) + + {:ok, c} = RefBackend.vector_ewise_add(a, b, :plus, mask: mask) + {:ok, entries} = RefBackend.vector_to_entries(c) + + assert {1, 22} in entries + refute Enum.any?(entries, fn {i, _v} -> i == 0 end) + end + end + + describe "mask on matrix_reduce" do + test "reduce with structural mask" do + {:ok, m} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}, {0, 1, 2}, {1, 0, 3}], :int64, []) + {:ok, mask_src} = RefBackend.vector_from_entries(2, [{0, 1}], :int64, []) + mask = Mask.new(mask_src) + + {:ok, v} = RefBackend.matrix_reduce(m, :plus, mask: mask) + {:ok, entries} = RefBackend.vector_to_entries(v) + + assert {0, 3} in entries + refute Enum.any?(entries, fn {i, _v} -> i == 1 end) + end + end + + describe "mask on matrix_transpose" do + test "transpose with structural mask" do + {:ok, m} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}, {0, 1, 2}, {1, 0, 3}], :int64, []) + {:ok, mask_src} = RefBackend.matrix_from_coo(2, 2, [{0, 0, 1}], :int64, []) + mask = Mask.new(mask_src) + + {:ok, t} = RefBackend.matrix_transpose(m, mask: mask) + {:ok, coo} = RefBackend.matrix_to_coo(t) + + assert {0, 0, 1} in coo + refute Enum.any?(coo, fn {r, c, _v} -> r == 0 and c == 1 end) + refute Enum.any?(coo, fn {r, c, _v} -> r == 1 and c == 0 end) + end + end +end diff --git a/test/ex_graphblas/matrix_api_test.exs b/test/ex_graphblas/matrix_api_test.exs new file mode 100644 index 0000000..740f0e9 --- /dev/null +++ b/test/ex_graphblas/matrix_api_test.exs @@ -0,0 +1,232 @@ +defmodule GraphBLAS.MatrixAPITest do + use ExUnit.Case, async: true + + alias GraphBLAS.Backend.Elixir, as: RefBackend + alias GraphBLAS.Matrix + + describe "Matrix.from_coo/5" do + test "creates a matrix via public API" do + assert {:ok, m} = Matrix.from_coo(3, 3, [{0, 1, 1}, {1, 2, 2}], :int64) + assert {:ok, {3, 3}} = Matrix.shape(m) + assert {:ok, :int64} = Matrix.type(m) + assert {:ok, 2} = Matrix.nvals(m) + end + end + + describe "Matrix.new/4" do + test "creates an empty matrix" do + assert {:ok, m} = Matrix.new(4, 4, :fp64) + assert {:ok, {4, 4}} = Matrix.shape(m) + assert {:ok, 0} = Matrix.nvals(m) + end + end + + describe "Matrix.mxm/4" do + test "multiplies two matrices via public API" do + assert {:ok, a} = Matrix.from_coo(2, 2, [{0, 1, 1}, {1, 0, 1}], :int64) + assert {:ok, b} = Matrix.from_coo(2, 2, [{0, 0, 2}, {1, 1, 3}], :int64) + assert {:ok, c} = Matrix.mxm(a, b, :plus_times) + assert {:ok, {2, 2}} = Matrix.shape(c) + end + end + + describe "Matrix.mxv/4" do + test "multiplies matrix by vector via public API" do + assert {:ok, m} = Matrix.from_coo(2, 3, [{0, 1, 2}, {1, 2, 3}], :int64) + assert {:ok, v} = GraphBLAS.Vector.from_entries(3, [{1, 5}, {2, 7}], :int64) + assert {:ok, r} = Matrix.mxv(m, v, :plus_times) + assert {:ok, 2} = GraphBLAS.Vector.size(r) + end + end + + describe "Matrix.ewise_add/4" do + test "element-wise addition via public API" do + assert {:ok, a} = Matrix.from_coo(2, 2, [{0, 0, 1}, {1, 1, 2}], :int64) + assert {:ok, b} = Matrix.from_coo(2, 2, [{0, 0, 3}, {1, 1, 4}], :int64) + assert {:ok, c} = Matrix.ewise_add(a, b, :plus) + assert {:ok, coo} = Matrix.to_coo(c) + assert {0, 0, 4} in coo + assert {1, 1, 6} in coo + end + end + + describe "Matrix.ewise_mult/4" do + test "element-wise multiplication via public API" do + assert {:ok, a} = Matrix.from_coo(2, 2, [{0, 0, 2}, {1, 1, 3}], :int64) + assert {:ok, b} = Matrix.from_coo(2, 2, [{0, 0, 4}, {1, 1, 5}], :int64) + assert {:ok, c} = Matrix.ewise_mult(a, b, :times) + assert {:ok, coo} = Matrix.to_coo(c) + assert {0, 0, 8} in coo + assert {1, 1, 15} in coo + end + end + + describe "Matrix.reduce/3" do + test "reduces matrix to vector via public API" do + assert {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}, {0, 1, 2}, {1, 0, 3}], :int64) + assert {:ok, v} = Matrix.reduce(m, :plus) + assert {:ok, entries} = GraphBLAS.Vector.to_entries(v) + assert {0, 3} in entries + assert {1, 3} in entries + end + end + + describe "Matrix.transpose/2" do + test "transposes a non-square matrix" do + entries = [{0, 1, 5}, {2, 0, 7}] + assert {:ok, m} = Matrix.from_coo(3, 2, entries, :int64) + assert {:ok, t} = Matrix.transpose(m) + assert {:ok, {2, 3}} = Matrix.shape(t) + end + end + + describe "Matrix.to_dense/1" do + test "converts sparse matrix to dense list-of-lists" do + assert {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 5}, {1, 1, 7}], :int64) + assert {:ok, dense} = Matrix.to_dense(m) + assert dense == [[5, 0], [0, 7]] + end + + test "to_dense with fp64 matrix" do + assert {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1.5}, {1, 1, 2.5}], :fp64) + assert {:ok, dense} = Matrix.to_dense(m) + assert dense == [[1.5, 0.0], [0.0, 2.5]] + end + end + + describe "Matrix with explicit backend option" do + test "delegates to specified backend" do + assert {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64, backend: RefBackend) + assert {:ok, {2, 2}} = Matrix.shape(m) + end + end + + describe "Matrix.set/5" do + test "sets a value at position" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64) + {:ok, updated} = Matrix.set(m, 1, 1, 5) + {:ok, 5} = Matrix.extract(updated, 1, 1) + end + end + + describe "Matrix.extract/4" do + test "returns default for structural zero" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 42}], :int64) + {:ok, 0} = Matrix.extract(m, 1, 1) + end + end + + describe "Matrix.dup/3" do + test "creates an independent copy" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64) + {:ok, copy} = Matrix.dup(m) + {:ok, coo_orig} = Matrix.to_coo(m) + {:ok, coo_copy} = Matrix.to_coo(copy) + assert coo_orig == coo_copy + end + end + + describe "Matrix with nil backend (fallback behavior)" do + test "nvals/1 falls back to default backend when backend is nil" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}, {1, 1, 2}], :int64) + m_nil_backend = %{m | backend: nil} + assert {:ok, 2} = Matrix.nvals(m_nil_backend) + end + + test "to_coo/1 falls back to default backend when backend is nil" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}, {1, 1, 2}], :int64) + m_nil_backend = %{m | backend: nil} + assert {:ok, coo} = Matrix.to_coo(m_nil_backend) + assert Enum.sort(coo) == [{0, 0, 1}, {1, 1, 2}] + end + + test "to_dense/1 falls back to default backend when backend is nil" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 5}, {1, 1, 7}], :int64) + m_nil_backend = %{m | backend: nil} + assert {:ok, dense} = Matrix.to_dense(m_nil_backend) + assert dense == [[5, 0], [0, 7]] + end + + test "set/5 falls back to default backend when backend is nil" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64) + m_nil_backend = %{m | backend: nil} + assert {:ok, updated} = Matrix.set(m_nil_backend, 1, 1, 5) + assert {:ok, 5} = Matrix.extract(updated, 1, 1) + end + + test "extract/4 falls back to default backend when backend is nil" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 42}], :int64) + m_nil_backend = %{m | backend: nil} + assert {:ok, 42} = Matrix.extract(m_nil_backend, 0, 0) + assert {:ok, 0} = Matrix.extract(m_nil_backend, 1, 1) + end + + test "dup/2 falls back to default backend when backend is nil" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}, {1, 1, 2}], :int64) + m_nil_backend = %{m | backend: nil} + assert {:ok, copy} = Matrix.dup(m_nil_backend) + assert {:ok, coo} = Matrix.to_coo(copy) + assert Enum.sort(coo) == [{0, 0, 1}, {1, 1, 2}] + end + + test "nil backend fallback works across multiple operations" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64) + m_nil = %{m | backend: nil} + + assert {:ok, 1} = Matrix.nvals(m_nil) + assert {:ok, coo} = Matrix.to_coo(m_nil) + assert [{0, 0, 1}] = coo + assert {:ok, dense} = Matrix.to_dense(m_nil) + assert [[1, 0], [0, 0]] = dense + end + end + + describe "Matrix edge cases" do + test "empty matrix operations" do + {:ok, m} = Matrix.new(3, 3, :int64) + assert {:ok, 0} = Matrix.nvals(m) + assert {:ok, []} = Matrix.to_coo(m) + assert {:ok, [[0, 0, 0], [0, 0, 0], [0, 0, 0]]} = Matrix.to_dense(m) + end + + test "single element matrix" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 42}], :int64) + assert {:ok, 1} = Matrix.nvals(m) + assert {:ok, [{0, 0, 42}]} = Matrix.to_coo(m) + assert {:ok, [[42, 0], [0, 0]]} = Matrix.to_dense(m) + end + + test "non-square matrix" do + {:ok, m} = Matrix.from_coo(2, 3, [{0, 1, 5}, {1, 2, 7}], :int64) + assert {:ok, {2, 3}} = Matrix.shape(m) + assert {:ok, 2} = Matrix.nvals(m) + end + + test "extract with opts parameter (even though unused)" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 42}], :int64) + assert {:ok, 42} = Matrix.extract(m, 0, 0, backend: RefBackend) + assert {:ok, 0} = Matrix.extract(m, 1, 1, some_option: :ignored) + end + + test "set with opts parameter (even though unused)" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64) + assert {:ok, updated} = Matrix.set(m, 1, 1, 5, backend: RefBackend) + assert {:ok, 5} = Matrix.extract(updated, 1, 1) + end + + test "dup with opts parameter" do + {:ok, m} = Matrix.from_coo(2, 2, [{0, 0, 1}], :int64) + assert {:ok, copy} = Matrix.dup(m, some_option: :ignored) + assert {:ok, coo} = Matrix.to_coo(copy) + assert [{0, 0, 1}] = coo + end + + test "dense matrix (all positions filled)" do + entries = for r <- 0..2, c <- 0..2, do: {r, c, r * 3 + c + 1} + {:ok, m} = Matrix.from_coo(3, 3, entries, :int64) + assert {:ok, 9} = Matrix.nvals(m) + assert {:ok, dense} = Matrix.to_dense(m) + assert dense == [[1, 2, 3], [4, 5, 6], [7, 8, 9]] + end + end +end diff --git a/test/ex_graphblas/monoid_test.exs b/test/ex_graphblas/monoid_test.exs new file mode 100644 index 0000000..805a742 --- /dev/null +++ b/test/ex_graphblas/monoid_test.exs @@ -0,0 +1,49 @@ +defmodule GraphBLAS.MonoidTest do + use ExUnit.Case, async: true + + alias GraphBLAS.Monoid + + describe "builtin/1" do + test "returns monoid struct for known names" do + for name <- Monoid.builtin_names() do + assert %Monoid{name: ^name} = Monoid.builtin(name) + end + end + + test "returns nil for unknown names" do + assert nil == Monoid.builtin(:nonexistent) + end + end + + describe "resolve/1" do + test "resolves atom to monoid struct" do + assert {:ok, %Monoid{name: :plus}} = Monoid.resolve(:plus) + end + + test "passes through existing monoid struct" do + m = Monoid.builtin(:plus) + assert {:ok, ^m} = Monoid.resolve(m) + end + + test "returns error for unknown atom" do + assert {:error, {:unknown_monoid, :bad}} = Monoid.resolve(:bad) + end + end + + describe "plus monoid" do + test "has correct structure" do + m = Monoid.builtin(:plus) + assert m.operator == :plus + assert m.identity == 0 + assert m.type == :int64 + end + end + + describe "times monoid" do + test "has correct structure" do + m = Monoid.builtin(:times) + assert m.operator == :times + assert m.identity == 1 + end + end +end diff --git a/test/ex_graphblas/property_test.exs b/test/ex_graphblas/property_test.exs new file mode 100644 index 0000000..3a54b0e --- /dev/null +++ b/test/ex_graphblas/property_test.exs @@ -0,0 +1,608 @@ +if System.get_env("EX_GRAPHBLAS_COMPILE_NATIVE") in ["1", "true"] do + defmodule GraphBLAS.PropertyTest do + @moduledoc """ + StreamData property-based tests verifying algebraic properties + and backend parity for arbitrary inputs. + + These tests complement the deterministic parameterized parity tests + by exploring input spaces that hand-written tests may miss. + + Tested properties: + - Monoid identity: reduce of single-element vector returns the element + - Monoid identity: reduce of empty vector returns the identity + - Backend parity: both backends produce identical results for random inputs + - Semiring identity: I * A = A for identity matrix I + - Plus associativity via vector reduce + - Plus-times distributivity: A * (B + C) = (A * B) + (A * C) + """ + use ExUnit.Case, async: false + use ExUnitProperties + + @moduletag :native_backend + + alias GraphBLAS.Backend.Elixir, as: RefBackend + alias GraphBLAS.Backend.SuiteSparse + + # Value generators — small ranges to avoid overflow in semiring multiply ops + defp pos_int64, do: integer(1..30) + defp pos_fp64, do: float(min: 0.5, max: 30.0) + + ############################################################################# + # Monoid identity: reduce of single-element vector = element + ############################################################################# + + describe "monoid identity: reduce of single-element vector" do + property "plus/int64 returns the element (both backends)" do + check all(v <- pos_int64()) do + {:ok, ref_v} = RefBackend.vector_from_entries(5, [{2, v}], :int64, []) + {:ok, ss_v} = SuiteSparse.vector_from_entries(5, [{2, v}], :int64, []) + + {:ok, ref_s} = RefBackend.vector_reduce(ref_v, :plus, []) + {:ok, ss_s} = SuiteSparse.vector_reduce(ss_v, :plus, []) + + assert ref_s.value == v + assert ss_s.value == v + + SuiteSparse.vector_free(ss_v) + end + end + + property "plus_fp64/fp64 returns the element (both backends)" do + check all(v <- pos_fp64()) do + {:ok, ref_v} = RefBackend.vector_from_entries(5, [{2, v}], :fp64, []) + {:ok, ss_v} = SuiteSparse.vector_from_entries(5, [{2, v}], :fp64, []) + + {:ok, ref_s} = RefBackend.vector_reduce(ref_v, :plus_fp64, []) + {:ok, ss_s} = SuiteSparse.vector_reduce(ss_v, :plus_fp64, []) + + assert_in_delta ref_s.value, v, 0.001 + assert_in_delta ss_s.value, v, 0.001 + + SuiteSparse.vector_free(ss_v) + end + end + + property "times/int64 returns the element (both backends)" do + check all(v <- pos_int64()) do + {:ok, ref_v} = RefBackend.vector_from_entries(5, [{2, v}], :int64, []) + {:ok, ss_v} = SuiteSparse.vector_from_entries(5, [{2, v}], :int64, []) + + {:ok, ref_s} = RefBackend.vector_reduce(ref_v, :times, []) + {:ok, ss_s} = SuiteSparse.vector_reduce(ss_v, :times, []) + + assert ref_s.value == v + assert ss_s.value == v + + SuiteSparse.vector_free(ss_v) + end + end + + property "times_fp64/fp64 returns the element (both backends)" do + check all(v <- pos_fp64()) do + {:ok, ref_v} = RefBackend.vector_from_entries(5, [{2, v}], :fp64, []) + {:ok, ss_v} = SuiteSparse.vector_from_entries(5, [{2, v}], :fp64, []) + + {:ok, ref_s} = RefBackend.vector_reduce(ref_v, :times_fp64, []) + {:ok, ss_s} = SuiteSparse.vector_reduce(ss_v, :times_fp64, []) + + assert_in_delta ref_s.value, v, 0.001 + assert_in_delta ss_s.value, v, 0.001 + + SuiteSparse.vector_free(ss_v) + end + end + + property "min/int64 returns the element (both backends)" do + check all(v <- pos_int64()) do + {:ok, ref_v} = RefBackend.vector_from_entries(5, [{2, v}], :int64, []) + {:ok, ss_v} = SuiteSparse.vector_from_entries(5, [{2, v}], :int64, []) + + {:ok, ref_s} = RefBackend.vector_reduce(ref_v, :min, []) + {:ok, ss_s} = SuiteSparse.vector_reduce(ss_v, :min, []) + + assert ref_s.value == v + assert ss_s.value == v + + SuiteSparse.vector_free(ss_v) + end + end + + property "max/int64 returns the element (both backends)" do + check all(v <- pos_int64()) do + {:ok, ref_v} = RefBackend.vector_from_entries(5, [{2, v}], :int64, []) + {:ok, ss_v} = SuiteSparse.vector_from_entries(5, [{2, v}], :int64, []) + + {:ok, ref_s} = RefBackend.vector_reduce(ref_v, :max, []) + {:ok, ss_s} = SuiteSparse.vector_reduce(ss_v, :max, []) + + assert ref_s.value == v + assert ss_s.value == v + + SuiteSparse.vector_free(ss_v) + end + end + + property "lor/bool returns the element (both backends)" do + check all(v <- boolean()) do + {:ok, ref_v} = RefBackend.vector_from_entries(5, [{2, v}], :bool, []) + {:ok, ss_v} = SuiteSparse.vector_from_entries(5, [{2, v}], :bool, []) + + {:ok, ref_s} = RefBackend.vector_reduce(ref_v, :lor, []) + {:ok, ss_s} = SuiteSparse.vector_reduce(ss_v, :lor, []) + + assert ref_s.value == v + assert ss_s.value == v + + SuiteSparse.vector_free(ss_v) + end + end + + property "land/bool returns the element (both backends)" do + check all(v <- boolean()) do + {:ok, ref_v} = RefBackend.vector_from_entries(5, [{2, v}], :bool, []) + {:ok, ss_v} = SuiteSparse.vector_from_entries(5, [{2, v}], :bool, []) + + {:ok, ref_s} = RefBackend.vector_reduce(ref_v, :land, []) + {:ok, ss_s} = SuiteSparse.vector_reduce(ss_v, :land, []) + + assert ref_s.value == v + assert ss_s.value == v + + SuiteSparse.vector_free(ss_v) + end + end + end + + ############################################################################# + # Backend parity: vector operations with random values + ############################################################################# + + describe "backend parity: vector ewise_add with random values" do + property "plus/int64" do + check all(v1 <- pos_int64(), v2 <- pos_int64(), v3 <- pos_int64()) do + entries_a = [{0, v1}, {1, v2}] + entries_b = [{0, v3}, {1, v2}] + + assert_vector_parity(entries_a, entries_b, :int64, :plus, :ewise_add) + end + end + + property "plus_fp64/fp64" do + check all(v1 <- pos_fp64(), v2 <- pos_fp64(), v3 <- pos_fp64()) do + entries_a = [{0, v1}, {1, v2}] + entries_b = [{0, v3}, {1, v2}] + + assert_vector_parity_fp64(entries_a, entries_b, :plus_fp64, :ewise_add) + end + end + + property "lor/bool" do + check all(b1 <- boolean(), b2 <- boolean(), b3 <- boolean()) do + entries_a = [{0, b1}, {1, b2}] + entries_b = [{0, b3}, {1, b2}] + + assert_vector_parity(entries_a, entries_b, :bool, :lor, :ewise_add) + end + end + end + + describe "backend parity: vector ewise_mult with random values" do + property "times/int64" do + check all(v1 <- pos_int64(), v2 <- pos_int64()) do + entries_a = [{0, v1}, {1, v2}] + entries_b = [{0, v2}, {1, v1}] + + assert_vector_parity(entries_a, entries_b, :int64, :times, :ewise_mult) + end + end + end + + describe "backend parity: vector_reduce with random values" do + property "plus/int64" do + check all(v1 <- pos_int64(), v2 <- pos_int64(), v3 <- pos_int64()) do + entries = [{0, v1}, {1, v2}, {2, v3}] + + {:ok, ref_v} = RefBackend.vector_from_entries(5, entries, :int64, []) + {:ok, ss_v} = SuiteSparse.vector_from_entries(5, entries, :int64, []) + + {:ok, ref_s} = RefBackend.vector_reduce(ref_v, :plus, []) + {:ok, ss_s} = SuiteSparse.vector_reduce(ss_v, :plus, []) + + assert ref_s.value == ss_s.value + + SuiteSparse.vector_free(ss_v) + end + end + + property "plus_fp64/fp64" do + check all(v1 <- pos_fp64(), v2 <- pos_fp64(), v3 <- pos_fp64()) do + entries = [{0, v1}, {1, v2}, {2, v3}] + + {:ok, ref_v} = RefBackend.vector_from_entries(5, entries, :fp64, []) + {:ok, ss_v} = SuiteSparse.vector_from_entries(5, entries, :fp64, []) + + {:ok, ref_s} = RefBackend.vector_reduce(ref_v, :plus_fp64, []) + {:ok, ss_s} = SuiteSparse.vector_reduce(ss_v, :plus_fp64, []) + + assert_in_delta ref_s.value, ss_s.value, 0.001 + + SuiteSparse.vector_free(ss_v) + end + end + end + + ############################################################################# + # Backend parity: matrix operations with random values + ############################################################################# + + describe "backend parity: matrix_mxm with random values" do + property "plus_times/int64" do + check all(a01 <- pos_int64(), a12 <- pos_int64(), b10 <- pos_int64(), b21 <- pos_int64()) do + entries_a = [{0, 1, a01}, {1, 2, a12}] + entries_b = [{1, 0, b10}, {2, 1, b21}] + + assert_matrix_mxm_parity(entries_a, entries_b, :int64, :plus_times) + end + end + + property "plus_times_fp64/fp64" do + check all(a01 <- pos_fp64(), a12 <- pos_fp64(), b10 <- pos_fp64(), b21 <- pos_fp64()) do + entries_a = [{0, 1, a01}, {1, 2, a12}] + entries_b = [{1, 0, b10}, {2, 1, b21}] + + assert_matrix_mxm_parity_fp64(entries_a, entries_b, :plus_times_fp64) + end + end + + property "lor_land/bool" do + check all(a01 <- boolean(), a12 <- boolean(), b10 <- boolean(), b21 <- boolean()) do + entries_a = [{0, 1, a01}, {1, 2, a12}] + entries_b = [{1, 0, b10}, {2, 1, b21}] + + assert_matrix_mxm_parity(entries_a, entries_b, :bool, :lor_land) + end + end + end + + describe "backend parity: matrix_ewise_add with random values" do + property "plus/int64" do + check all(v1 <- pos_int64(), v2 <- pos_int64(), v3 <- pos_int64()) do + entries_a = [{0, 0, v1}, {1, 1, v2}] + entries_b = [{0, 0, v3}, {1, 1, v2}] + + assert_matrix_ewise_parity(entries_a, entries_b, :int64, :plus, :ewise_add) + end + end + + property "max/int64" do + check all(v1 <- pos_int64(), v2 <- pos_int64(), v3 <- pos_int64()) do + entries_a = [{0, 0, v1}, {1, 1, v2}] + entries_b = [{0, 0, v3}, {1, 1, v2}] + + assert_matrix_ewise_parity(entries_a, entries_b, :int64, :max, :ewise_add) + end + end + end + + describe "backend parity: matrix_ewise_mult with random values" do + property "times/int64" do + check all(v1 <- pos_int64(), v2 <- pos_int64()) do + entries_a = [{0, 0, v1}, {1, 1, v2}] + entries_b = [{0, 0, v2}, {1, 1, v1}] + + assert_matrix_ewise_parity(entries_a, entries_b, :int64, :times, :ewise_mult) + end + end + end + + describe "backend parity: matrix_reduce with random values" do + property "plus/int64" do + check all(v1 <- pos_int64(), v2 <- pos_int64(), v3 <- pos_int64()) do + entries = [{0, 0, v1}, {0, 1, v2}, {1, 0, v3}] + + {:ok, ref_m} = RefBackend.matrix_from_coo(2, 2, entries, :int64, []) + {:ok, ss_m} = SuiteSparse.matrix_from_coo(2, 2, entries, :int64, []) + + {:ok, ref_v} = RefBackend.matrix_reduce(ref_m, :plus, []) + {:ok, ss_v} = SuiteSparse.matrix_reduce(ss_m, :plus, []) + + {:ok, ref_e} = RefBackend.vector_to_entries(ref_v) + {:ok, ss_e} = SuiteSparse.vector_to_entries(ss_v) + + assert sort_entries(ref_e) == sort_entries(ss_e) + + SuiteSparse.matrix_free(ss_m) + SuiteSparse.vector_free(ss_v) + end + end + end + + ############################################################################# + # Algebraic properties + ############################################################################# + + describe "semiring identity: I * A = A (plus_times)" do + property "identity matrix times A equals A (RefBackend, int64)" do + check all(v01 <- pos_int64(), v10 <- pos_int64()) do + entries_i = [{0, 0, 1}, {1, 1, 1}] + entries_a = [{0, 1, v01}, {1, 0, v10}] + + {:ok, i} = RefBackend.matrix_from_coo(2, 2, entries_i, :int64, []) + {:ok, a} = RefBackend.matrix_from_coo(2, 2, entries_a, :int64, []) + + {:ok, result} = RefBackend.matrix_mxm(i, a, :plus_times, []) + {:ok, result_coo} = RefBackend.matrix_to_coo(result) + {:ok, a_coo} = RefBackend.matrix_to_coo(a) + + assert sort_coo(result_coo) == sort_coo(a_coo) + end + end + end + + describe "plus associativity via vector reduce" do + property "(a + b) + c = a + (b + c) for int64 via reduce" do + check all(a <- pos_int64(), b <- pos_int64(), c <- pos_int64()) do + left = a + b + c + right = a + (b + c) + + assert left == right + + {:ok, v} = RefBackend.vector_from_entries(3, [{0, a}, {1, b}, {2, c}], :int64, []) + {:ok, s} = RefBackend.vector_reduce(v, :plus, []) + assert s.value == left + end + end + end + + describe "plus-times distributivity: A*(B+C) = (A*B)+(A*C)" do + property "distributivity holds for int64 matrices (RefBackend)" do + # A is 2x3, B and C are 3x2 + # A*(B+C) should equal (A*B)+(A*C) for plus_times semiring and plus monoid + check all( + a01 <- pos_int64(), + a12 <- pos_int64(), + b10 <- pos_int64(), + b21 <- pos_int64(), + c10 <- pos_int64(), + c21 <- pos_int64() + ) do + entries_a = [{0, 1, a01}, {1, 2, a12}] + entries_b = [{1, 0, b10}, {2, 1, b21}] + entries_c = [{1, 0, c10}, {2, 1, c21}] + + {:ok, a} = RefBackend.matrix_from_coo(2, 3, entries_a, :int64, []) + {:ok, b} = RefBackend.matrix_from_coo(3, 2, entries_b, :int64, []) + {:ok, c} = RefBackend.matrix_from_coo(3, 2, entries_c, :int64, []) + + # Left side: A * (B + C) + {:ok, b_plus_c} = RefBackend.matrix_ewise_add(b, c, :plus, []) + {:ok, left} = RefBackend.matrix_mxm(a, b_plus_c, :plus_times, []) + + # Right side: (A * B) + (A * C) + {:ok, ab} = RefBackend.matrix_mxm(a, b, :plus_times, []) + {:ok, ac} = RefBackend.matrix_mxm(a, c, :plus_times, []) + {:ok, right} = RefBackend.matrix_ewise_add(ab, ac, :plus, []) + + {:ok, left_coo} = RefBackend.matrix_to_coo(left) + {:ok, right_coo} = RefBackend.matrix_to_coo(right) + + assert sort_coo(left_coo) == sort_coo(right_coo) + end + end + + property "distributivity holds for int64 matrices (both backends)" do + check all( + a01 <- pos_int64(), + a12 <- pos_int64(), + b10 <- pos_int64(), + b21 <- pos_int64(), + c10 <- pos_int64(), + c21 <- pos_int64() + ) do + entries_a = [{0, 1, a01}, {1, 2, a12}] + entries_b = [{1, 0, b10}, {2, 1, b21}] + entries_c = [{1, 0, c10}, {2, 1, c21}] + + # Compute on RefBackend + {:ok, ref_a} = RefBackend.matrix_from_coo(2, 3, entries_a, :int64, []) + {:ok, ref_b} = RefBackend.matrix_from_coo(3, 2, entries_b, :int64, []) + {:ok, ref_c} = RefBackend.matrix_from_coo(3, 2, entries_c, :int64, []) + + {:ok, ref_bc} = RefBackend.matrix_ewise_add(ref_b, ref_c, :plus, []) + {:ok, ref_left} = RefBackend.matrix_mxm(ref_a, ref_bc, :plus_times, []) + + # Compute on SuiteSparse + {:ok, ss_a} = SuiteSparse.matrix_from_coo(2, 3, entries_a, :int64, []) + {:ok, ss_b} = SuiteSparse.matrix_from_coo(3, 2, entries_b, :int64, []) + {:ok, ss_c} = SuiteSparse.matrix_from_coo(3, 2, entries_c, :int64, []) + + {:ok, ss_bc} = SuiteSparse.matrix_ewise_add(ss_b, ss_c, :plus, []) + {:ok, ss_left} = SuiteSparse.matrix_mxm(ss_a, ss_bc, :plus_times, []) + + # Parity: both backends agree on A*(B+C) + {:ok, ref_left_coo} = RefBackend.matrix_to_coo(ref_left) + {:ok, ss_left_coo} = SuiteSparse.matrix_to_coo(ss_left) + + assert sort_coo(ref_left_coo) == sort_coo(ss_left_coo) + + SuiteSparse.matrix_free(ss_a) + SuiteSparse.matrix_free(ss_b) + SuiteSparse.matrix_free(ss_c) + SuiteSparse.matrix_free(ss_bc) + SuiteSparse.matrix_free(ss_left) + end + end + end + + ############################################################################# + # Helpers + ############################################################################# + + defp sort_coo(entries) do + Enum.sort_by(entries, fn {r, c, _v} -> {r, c} end) + end + + defp sort_entries(entries) do + Enum.sort_by(entries, fn {i, _v} -> i end) + end + + # Exact-comparison vector parity helper (int64, bool) + defp assert_vector_parity(entries_a, entries_b, type, monoid, op) do + size = length(entries_a) + 2 + + {:ok, ref_a} = RefBackend.vector_from_entries(size, entries_a, type, []) + {:ok, ref_b} = RefBackend.vector_from_entries(size, entries_b, type, []) + {:ok, ss_a} = SuiteSparse.vector_from_entries(size, entries_a, type, []) + {:ok, ss_b} = SuiteSparse.vector_from_entries(size, entries_b, type, []) + + {ref_c, ss_c} = + case op do + :ewise_add -> + {:ok, rc} = RefBackend.vector_ewise_add(ref_a, ref_b, monoid, []) + {:ok, sc} = SuiteSparse.vector_ewise_add(ss_a, ss_b, monoid, []) + {rc, sc} + + :ewise_mult -> + {:ok, rc} = RefBackend.vector_ewise_mult(ref_a, ref_b, monoid, []) + {:ok, sc} = SuiteSparse.vector_ewise_mult(ss_a, ss_b, monoid, []) + {rc, sc} + end + + {:ok, ref_e} = RefBackend.vector_to_entries(ref_c) + {:ok, ss_e} = SuiteSparse.vector_to_entries(ss_c) + + assert sort_entries(ref_e) == sort_entries(ss_e) + + SuiteSparse.vector_free(ss_a) + SuiteSparse.vector_free(ss_b) + SuiteSparse.vector_free(ss_c) + end + + # Approx-comparison vector parity helper (fp64) + defp assert_vector_parity_fp64(entries_a, entries_b, monoid, op) do + size = length(entries_a) + 2 + + {:ok, ref_a} = RefBackend.vector_from_entries(size, entries_a, :fp64, []) + {:ok, ref_b} = RefBackend.vector_from_entries(size, entries_b, :fp64, []) + {:ok, ss_a} = SuiteSparse.vector_from_entries(size, entries_a, :fp64, []) + {:ok, ss_b} = SuiteSparse.vector_from_entries(size, entries_b, :fp64, []) + + {ref_c, ss_c} = + case op do + :ewise_add -> + {:ok, rc} = RefBackend.vector_ewise_add(ref_a, ref_b, monoid, []) + {:ok, sc} = SuiteSparse.vector_ewise_add(ss_a, ss_b, monoid, []) + {rc, sc} + + :ewise_mult -> + {:ok, rc} = RefBackend.vector_ewise_mult(ref_a, ref_b, monoid, []) + {:ok, sc} = SuiteSparse.vector_ewise_mult(ss_a, ss_b, monoid, []) + {rc, sc} + end + + {:ok, ref_e} = RefBackend.vector_to_entries(ref_c) + {:ok, ss_e} = SuiteSparse.vector_to_entries(ss_c) + + assert_entries_approx_equal(ref_e, ss_e) + + SuiteSparse.vector_free(ss_a) + SuiteSparse.vector_free(ss_b) + SuiteSparse.vector_free(ss_c) + end + + # Exact-comparison matrix mxm parity helper (int64, bool) + defp assert_matrix_mxm_parity(entries_a, entries_b, type, semiring) do + {:ok, ref_a} = RefBackend.matrix_from_coo(2, 3, entries_a, type, []) + {:ok, ref_b} = RefBackend.matrix_from_coo(3, 2, entries_b, type, []) + {:ok, ss_a} = SuiteSparse.matrix_from_coo(2, 3, entries_a, type, []) + {:ok, ss_b} = SuiteSparse.matrix_from_coo(3, 2, entries_b, type, []) + + {:ok, ref_c} = RefBackend.matrix_mxm(ref_a, ref_b, semiring, []) + {:ok, ss_c} = SuiteSparse.matrix_mxm(ss_a, ss_b, semiring, []) + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref_c) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss_c) + + assert sort_coo(ref_coo) == sort_coo(ss_coo) + + SuiteSparse.matrix_free(ss_a) + SuiteSparse.matrix_free(ss_b) + SuiteSparse.matrix_free(ss_c) + end + + # Approx-comparison matrix mxm parity helper (fp64) + defp assert_matrix_mxm_parity_fp64(entries_a, entries_b, semiring) do + {:ok, ref_a} = RefBackend.matrix_from_coo(2, 3, entries_a, :fp64, []) + {:ok, ref_b} = RefBackend.matrix_from_coo(3, 2, entries_b, :fp64, []) + {:ok, ss_a} = SuiteSparse.matrix_from_coo(2, 3, entries_a, :fp64, []) + {:ok, ss_b} = SuiteSparse.matrix_from_coo(3, 2, entries_b, :fp64, []) + + {:ok, ref_c} = RefBackend.matrix_mxm(ref_a, ref_b, semiring, []) + {:ok, ss_c} = SuiteSparse.matrix_mxm(ss_a, ss_b, semiring, []) + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref_c) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss_c) + + assert_coo_approx_equal(ref_coo, ss_coo) + + SuiteSparse.matrix_free(ss_a) + SuiteSparse.matrix_free(ss_b) + SuiteSparse.matrix_free(ss_c) + end + + # Exact-comparison matrix ewise parity helper (int64, bool) + defp assert_matrix_ewise_parity(entries_a, entries_b, type, monoid, op) do + {:ok, ref_a} = RefBackend.matrix_from_coo(2, 2, entries_a, type, []) + {:ok, ref_b} = RefBackend.matrix_from_coo(2, 2, entries_b, type, []) + {:ok, ss_a} = SuiteSparse.matrix_from_coo(2, 2, entries_a, type, []) + {:ok, ss_b} = SuiteSparse.matrix_from_coo(2, 2, entries_b, type, []) + + {ref_c, ss_c} = + case op do + :ewise_add -> + {:ok, rc} = RefBackend.matrix_ewise_add(ref_a, ref_b, monoid, []) + {:ok, sc} = SuiteSparse.matrix_ewise_add(ss_a, ss_b, monoid, []) + {rc, sc} + + :ewise_mult -> + {:ok, rc} = RefBackend.matrix_ewise_mult(ref_a, ref_b, monoid, []) + {:ok, sc} = SuiteSparse.matrix_ewise_mult(ss_a, ss_b, monoid, []) + {rc, sc} + end + + {:ok, ref_coo} = RefBackend.matrix_to_coo(ref_c) + {:ok, ss_coo} = SuiteSparse.matrix_to_coo(ss_c) + + assert sort_coo(ref_coo) == sort_coo(ss_coo) + + SuiteSparse.matrix_free(ss_a) + SuiteSparse.matrix_free(ss_b) + SuiteSparse.matrix_free(ss_c) + end + + defp assert_coo_approx_equal(ref, ss) do + ref_sorted = sort_coo(ref) + ss_sorted = sort_coo(ss) + + assert length(ref_sorted) == length(ss_sorted) + + Enum.zip_with(ref_sorted, ss_sorted, fn {r1, c1, v1}, {r2, c2, v2} -> + assert r1 == r2 + assert c1 == c2 + assert_in_delta v1, v2, 0.001 + end) + end + + defp assert_entries_approx_equal(ref, ss) do + ref_sorted = sort_entries(ref) + ss_sorted = sort_entries(ss) + + assert length(ref_sorted) == length(ss_sorted) + + Enum.zip_with(ref_sorted, ss_sorted, fn {i1, v1}, {i2, v2} -> + assert i1 == i2 + assert_in_delta v1, v2, 0.001 + end) + end + end +end diff --git a/test/ex_graphblas/relation_test.exs b/test/ex_graphblas/relation_test.exs new file mode 100644 index 0000000..b6b518b --- /dev/null +++ b/test/ex_graphblas/relation_test.exs @@ -0,0 +1,294 @@ +defmodule GraphBLAS.RelationTest do + use ExUnit.Case, async: true + + alias GraphBLAS.{Matrix, Relation} + + describe "new/1" do + test "creates empty relation with size" do + rel = Relation.new(4) + assert rel.size == 4 + assert Relation.predicates(rel) == [] + end + end + + describe "add_triples/3" do + test "adds bool entries for a predicate" do + rel = Relation.new(4) + {:ok, rel} = Relation.add_triples(rel, :follows, [{0, 1}, {1, 2}]) + assert :follows in Relation.predicates(rel) + {:ok, mat} = Relation.matrix(rel, :follows) + {:ok, coo} = Matrix.to_coo(mat) + assert length(coo) == 2 + end + + test "extends existing predicate" do + rel = Relation.new(4) + {:ok, rel} = Relation.add_triples(rel, :follows, [{0, 1}]) + {:ok, rel} = Relation.add_triples(rel, :follows, [{1, 2}]) + {:ok, mat} = Relation.matrix(rel, :follows) + {:ok, coo} = Matrix.to_coo(mat) + assert length(coo) == 2 + end + end + + describe "add_weighted_triples/4" do + test "adds weighted entries for a predicate" do + rel = Relation.new(4) + + {:ok, rel} = + Relation.add_weighted_triples(rel, :distance, [{0, 1, 3.0}, {1, 2, 5.0}], :fp64) + + assert :distance in Relation.predicates(rel) + {:ok, mat} = Relation.matrix(rel, :distance) + {:ok, {4, 4}} = Matrix.shape(mat) + end + + test "extends existing weighted predicate" do + rel = Relation.new(4) + {:ok, rel} = Relation.add_weighted_triples(rel, :distance, [{0, 1, 3.0}], :fp64) + {:ok, rel} = Relation.add_weighted_triples(rel, :distance, [{1, 2, 5.0}], :fp64) + {:ok, mat} = Relation.matrix(rel, :distance) + {:ok, coo} = Matrix.to_coo(mat) + assert length(coo) == 2 + end + end + + describe "matrix/2" do + test "returns error for unknown predicate" do + rel = Relation.new(4) + assert {:error, _} = Relation.matrix(rel, :nonexistent) + end + end + + describe "traverse/4" do + setup do + rel = Relation.new(4) + {:ok, rel} = Relation.add_triples(rel, :follows, [{0, 1}, {1, 2}]) + {:ok, rel} = Relation.add_triples(rel, :likes, [{1, 2}, {0, 2}]) + {:ok, rel} = Relation.add_triples(rel, :knows, [{0, 1}, {1, 0}]) + {:ok, rel} = Relation.add_triples(rel, :works_with, [{2, 3}, {3, 2}]) + {:ok, rel: rel} + end + + test "2-hop traversal with lor_land finds existence", %{rel: rel} do + {:ok, result} = Relation.traverse(rel, [:follows, :likes], :lor_land) + {:ok, coo} = Matrix.to_coo(result) + positions = Enum.sort(Enum.map(coo, fn {r, c, _} -> {r, c} end)) + # 0→follows→1→likes→2 and 1→follows→2→likes→? (no) + assert {0, 2} in positions + end + + test "2-hop traversal with plus_times counts paths", %{rel: rel} do + {:ok, result} = Relation.traverse(rel, [:follows, :likes], :plus_times) + {:ok, coo} = Matrix.to_coo(result) + value_map = Map.new(coo, fn {r, c, v} -> {{r, c}, v} end) + # 0→1→2 (1 path), 1→2→2 (no, 2 doesn't like anything in our data) + assert Map.get(value_map, {0, 2}) != nil + end + + test "3-hop traversal finds transitive paths", %{rel: rel} do + {:ok, rel} = Relation.add_triples(rel, :reports_to, [{0, 1}, {1, 2}, {2, 3}]) + {:ok, result} = Relation.traverse(rel, [:reports_to, :reports_to, :reports_to], :lor_land) + {:ok, coo} = Matrix.to_coo(result) + positions = Enum.map(coo, fn {r, c, _} -> {r, c} end) + assert {0, 3} in positions + end + + test "returns error for empty path", %{rel: rel} do + assert {:error, _} = Relation.traverse(rel, [], :lor_land) + end + + test "returns error for unknown predicate", %{rel: rel} do + assert {:error, _} = Relation.traverse(rel, [:nonexistent], :lor_land) + end + end + + describe "closure/4" do + setup do + rel = Relation.new(4) + {:ok, rel} = Relation.add_triples(rel, :follows, [{0, 1}, {1, 2}, {2, 3}]) + {:ok, rel: rel} + end + + test "transitive closure with lor_land reaches all reachable", %{rel: rel} do + closure_result = Relation.closure(rel, :follows, :lor_land) + assert match?({:ok, _}, closure_result) + {:ok, result} = closure_result + {:ok, coo} = Matrix.to_coo(result) + positions = MapSet.new(Enum.map(coo, fn {r, c, _} -> {r, c} end)) + # Direct: 0→1, 1→2, 2→3 + # 2-hop: 0→2, 1→3 + # 3-hop: 0→3 + assert MapSet.member?(positions, {0, 1}) + assert MapSet.member?(positions, {0, 2}) + assert MapSet.member?(positions, {0, 3}) + assert MapSet.member?(positions, {1, 2}) + assert MapSet.member?(positions, {1, 3}) + assert MapSet.member?(positions, {2, 3}) + end + + test "closure returns error for unknown predicate", %{rel: rel} do + assert {:error, %GraphBLAS.Error{reason: {:unknown_predicate, :unknown}}} = + Relation.closure(rel, :unknown, :lor_land) + end + + test "closure on cyclic graph converges" do + rel = Relation.new(3) + {:ok, rel} = Relation.add_triples(rel, :cycle, [{0, 1}, {1, 2}, {2, 0}]) + {:ok, result} = Relation.closure(rel, :cycle, :lor_land) + {:ok, coo} = Matrix.to_coo(result) + # In a 3-cycle, every node reaches every node + assert length(coo) == 9 + end + + test "closure with plus_times semiring (path counting)" do + rel = Relation.new(3) + {:ok, rel} = Relation.add_weighted_triples(rel, :edge, [{0, 1, 1}, {1, 2, 1}], :int64) + {:ok, result} = Relation.closure(rel, :edge, :plus_times) + {:ok, coo} = Matrix.to_coo(result) + # Should have paths: 0→1 (1), 1→2 (1), 0→2 (1 via 1) + assert length(coo) >= 3 + end + + test "closure with plus_min semiring (shortest path)" do + rel = Relation.new(3) + {:ok, rel} = Relation.add_weighted_triples(rel, :dist, [{0, 1, 5}, {1, 2, 3}], :int64) + {:ok, result} = Relation.closure(rel, :dist, :plus_min) + {:ok, coo} = Matrix.to_coo(result) + # Closure iteratively computes A + A^2 + A^3 + ..., should find all paths + assert length(coo) >= 2 + end + end + + describe "traverse/4 single predicate" do + test "single-hop traversal with lor_land" do + rel = Relation.new(3) + {:ok, rel} = Relation.add_triples(rel, :follows, [{0, 1}, {1, 2}]) + {:ok, result} = Relation.traverse(rel, [:follows], :lor_land) + {:ok, coo} = Matrix.to_coo(result) + assert length(coo) == 2 + assert {0, 1, true} in coo + assert {1, 2, true} in coo + end + + test "single-hop traversal with plus_times on empty matrix" do + rel = Relation.new(3) + {:ok, rel} = Relation.add_triples(rel, :empty, []) + {:ok, result} = Relation.traverse(rel, [:empty], :plus_times) + {:ok, coo} = Matrix.to_coo(result) + assert coo == [] + end + + test "single-hop returns error for unknown predicate" do + rel = Relation.new(3) + assert {:error, _} = Relation.traverse(rel, [:unknown], :lor_land) + end + end + + describe "traverse/4 multi-hop edge cases" do + test "returns error when second predicate in path is unknown" do + rel = Relation.new(3) + {:ok, rel} = Relation.add_triples(rel, :follows, [{0, 1}]) + assert {:error, _} = Relation.traverse(rel, [:follows, :unknown], :lor_land) + end + + test "returns error when last predicate in 3-hop path is unknown" do + rel = Relation.new(3) + {:ok, rel} = Relation.add_triples(rel, :a, [{0, 1}]) + {:ok, rel} = Relation.add_triples(rel, :b, [{1, 2}]) + assert {:error, _} = Relation.traverse(rel, [:a, :b, :unknown], :lor_land) + end + + test "traverse with plus_min_fp64 semiring" do + rel = Relation.new(3) + {:ok, rel} = Relation.add_weighted_triples(rel, :cost, [{0, 1, 2.5}, {1, 2, 1.5}], :fp64) + {:ok, result} = Relation.traverse(rel, [:cost, :cost], :plus_min_fp64) + {:ok, coo} = Matrix.to_coo(result) + # Traverse computes A * A (not closure), so the result depends on the semiring + assert Enum.empty?(coo) == false + end + end + + describe "add_triples/4 with backend option" do + test "creates new predicate with specified backend" do + rel = Relation.new(3) + backend = GraphBLAS.Backend.Elixir + {:ok, rel} = Relation.add_triples(rel, :edge, [{0, 1}], backend: backend) + {:ok, mat} = Relation.matrix(rel, :edge) + assert mat.backend == backend + end + + if System.get_env("EX_GRAPHBLAS_COMPILE_NATIVE") in ["1", "true"] do + @tag :native_backend + test "extends existing predicate preserving its backend" do + rel = Relation.new(3) + backend = GraphBLAS.Backend.SuiteSparse + {:ok, rel} = Relation.add_triples(rel, :edge, [{0, 1}], backend: backend) + {:ok, rel} = Relation.add_triples(rel, :edge, [{1, 2}]) + + {:ok, mat} = Relation.matrix(rel, :edge) + assert mat.backend == backend + end + end + end + + describe "add_weighted_triples/5 with backend option" do + test "creates new predicate with specified backend" do + rel = Relation.new(3) + backend = GraphBLAS.Backend.Elixir + + {:ok, rel} = + Relation.add_weighted_triples(rel, :dist, [{0, 1, 5}], :int64, backend: backend) + + {:ok, mat} = Relation.matrix(rel, :dist) + assert mat.backend == backend + end + + if System.get_env("EX_GRAPHBLAS_COMPILE_NATIVE") in ["1", "true"] do + @tag :native_backend + test "extends existing predicate preserving its backend" do + rel = Relation.new(3) + backend = GraphBLAS.Backend.SuiteSparse + + {:ok, rel} = + Relation.add_weighted_triples(rel, :dist, [{0, 1, 5}], :int64, backend: backend) + + {:ok, rel} = Relation.add_weighted_triples(rel, :dist, [{1, 2, 3}], :int64) + + {:ok, mat} = Relation.matrix(rel, :dist) + assert mat.backend == backend + end + end + end + + describe "Relation edge cases" do + test "empty relation has no predicates" do + rel = Relation.new(10) + assert Relation.predicates(rel) == [] + end + + test "multiple predicates coexist" do + rel = Relation.new(5) + {:ok, rel} = Relation.add_triples(rel, :p1, [{0, 1}]) + {:ok, rel} = Relation.add_triples(rel, :p2, [{1, 2}]) + {:ok, rel} = Relation.add_weighted_triples(rel, :p3, [{2, 3, 10}], :int64) + + predicates = Relation.predicates(rel) + assert :p1 in predicates + assert :p2 in predicates + assert :p3 in predicates + assert length(predicates) == 3 + end + + test "closure with max iterations (convergence limit)" do + # Create a large enough graph that might not converge quickly + rel = Relation.new(10) + edges = for i <- 0..8, do: {i, i + 1} + {:ok, rel} = Relation.add_triples(rel, :path, edges) + {:ok, result} = Relation.closure(rel, :path, :lor_land) + {:ok, coo} = Matrix.to_coo(result) + # Should eventually converge + assert Enum.empty?(coo) == false + end + end +end diff --git a/test/ex_graphblas/scalar_test.exs b/test/ex_graphblas/scalar_test.exs new file mode 100644 index 0000000..334862e --- /dev/null +++ b/test/ex_graphblas/scalar_test.exs @@ -0,0 +1,98 @@ +defmodule GraphBLAS.ScalarTest do + use ExUnit.Case, async: true + + alias GraphBLAS.Scalar + + describe "new/2" do + test "creates integer scalar" do + s = Scalar.new(:int64, 42) + assert s.type == :int64 + assert s.value == 42 + end + + test "creates float scalar" do + s = Scalar.new(:fp64, 3.14) + assert s.type == :fp64 + assert s.value == 3.14 + end + + test "creates boolean scalar" do + s = Scalar.new(:bool, true) + assert s.type == :bool + assert s.value == true + end + end + + describe "value/1" do + test "extracts the value" do + assert 42 = Scalar.value(Scalar.new(:int64, 42)) + end + end + + describe "type/1" do + test "extracts the type" do + assert :fp64 = Scalar.type(Scalar.new(:fp64, 1.0)) + end + end + + describe "zero/2" do + test "returns additive identity for plus monoid" do + s = Scalar.zero(:plus, :int64) + assert s.value == 0 + end + + test "returns multiplicative identity for times monoid" do + s = Scalar.zero(:times, :int64) + assert s.value == 1 + end + + test "returns boolean identity for lor monoid" do + s = Scalar.zero(:lor, :bool) + assert s.value == false + end + + test "returns boolean identity for land monoid" do + s = Scalar.zero(:land, :bool) + assert s.value == true + end + + test "returns max value for min monoid with int64" do + s = Scalar.zero(:min, :int64) + assert s.value == 9_223_372_036_854_775_807 + end + + test "returns min value for max monoid with int64" do + s = Scalar.zero(:max, :int64) + assert s.value == -9_223_372_036_854_775_808 + end + + test "returns additive identity for plus monoid with fp64" do + s = Scalar.zero(:plus, :fp64) + assert s.value == 0.0 + end + + test "returns multiplicative identity for times monoid with fp64" do + s = Scalar.zero(:times, :fp64) + assert s.value == 1.0 + end + + test "returns max value for min monoid with fp64" do + s = Scalar.zero(:min, :fp64) + assert is_float(s.value) + assert s.value > 0 + end + + test "returns min value for max monoid with fp64" do + s = Scalar.zero(:max, :fp64) + assert is_float(s.value) + assert s.value < 0 + end + + test "returns boolean identities for bool type" do + assert Scalar.zero(:plus, :bool).value == false + assert Scalar.zero(:times, :bool).value == true + assert Scalar.zero(:min, :bool).value == true + assert Scalar.zero(:max, :bool).value == false + end + end +end diff --git a/test/ex_graphblas/semiring_test.exs b/test/ex_graphblas/semiring_test.exs new file mode 100644 index 0000000..87db572 --- /dev/null +++ b/test/ex_graphblas/semiring_test.exs @@ -0,0 +1,53 @@ +defmodule GraphBLAS.SemiringTest do + use ExUnit.Case, async: true + + alias GraphBLAS.Semiring + + describe "builtin/1" do + test "returns semiring struct for known names" do + for name <- Semiring.builtin_names() do + assert %Semiring{name: ^name} = Semiring.builtin(name) + end + end + + test "returns nil for unknown names" do + assert nil == Semiring.builtin(:nonexistent) + end + end + + describe "resolve/1" do + test "resolves atom to semiring struct" do + assert {:ok, %Semiring{name: :plus_times}} = Semiring.resolve(:plus_times) + end + + test "passes through existing semiring struct" do + s = Semiring.builtin(:plus_times) + assert {:ok, ^s} = Semiring.resolve(s) + end + + test "returns error for unknown atom" do + assert {:error, {:unknown_semiring, :bad}} = Semiring.resolve(:bad) + end + end + + describe "plus_times semiring" do + test "has correct structure" do + s = Semiring.builtin(:plus_times) + assert s.multiply == :times + assert s.add == :plus + assert s.add_identity == 0 + assert s.multiply_identity == 1 + end + end + + describe "lor_land semiring" do + test "has correct structure for boolean adjacency" do + s = Semiring.builtin(:lor_land) + assert s.multiply == :land + assert s.add == :lor + assert s.add_identity == false + assert s.multiply_identity == true + assert s.type == :bool + end + end +end diff --git a/test/ex_graphblas/top_level_test.exs b/test/ex_graphblas/top_level_test.exs new file mode 100644 index 0000000..59fb9f4 --- /dev/null +++ b/test/ex_graphblas/top_level_test.exs @@ -0,0 +1,20 @@ +defmodule GraphBLAS.TopLevelTest do + use ExUnit.Case, async: true + + alias GraphBLAS + + describe "default_backend/0" do + test "returns Reference backend" do + assert GraphBLAS.default_backend() == GraphBLAS.Backend.Elixir + end + end + + describe "info/0" do + test "returns library information" do + info = GraphBLAS.info() + assert info.version == "0.1.0" + assert info.phase == 1 + assert info.default_backend == GraphBLAS.Backend.Elixir + end + end +end diff --git a/test/ex_graphblas/types_test.exs b/test/ex_graphblas/types_test.exs new file mode 100644 index 0000000..036876d --- /dev/null +++ b/test/ex_graphblas/types_test.exs @@ -0,0 +1,77 @@ +defmodule GraphBLAS.TypesTest do + use ExUnit.Case, async: true + + alias GraphBLAS.Types + + describe "validate_scalar_type/1" do + test "accepts supported scalar types" do + for type <- [:int64, :fp64, :bool] do + assert :ok = Types.validate_scalar_type(type) + end + end + + test "rejects unsupported types" do + assert {:error, {:unsupported_type, :float}} = Types.validate_scalar_type(:float) + assert {:error, {:unsupported_type, :string}} = Types.validate_scalar_type(:string) + assert {:error, {:unsupported_type, 42}} = Types.validate_scalar_type(42) + assert {:error, {:unsupported_type, :int8}} = Types.validate_scalar_type(:int8) + assert {:error, {:unsupported_type, :fp32}} = Types.validate_scalar_type(:fp32) + end + end + + describe "infer_type/1" do + test "infers bool type from boolean values" do + assert :bool = Types.infer_type([true, false, true]) + end + + test "infers fp64 type from float values" do + assert :fp64 = Types.infer_type([1.0, 2.5, 3.0]) + end + + test "infers fp64 when mixed ints and floats" do + assert :fp64 = Types.infer_type([1, 2.5]) + end + + test "infers int64 from integer values" do + assert :int64 = Types.infer_type([1, 2, 3]) + end + + test "defaults to int64 for empty list" do + assert :int64 = Types.infer_type([]) + end + end + + describe "type_size/1" do + test "returns correct byte sizes" do + assert 1 = Types.type_size(:bool) + assert 1 = Types.type_size(:int8) + assert 2 = Types.type_size(:int16) + assert 4 = Types.type_size(:int32) + assert 8 = Types.type_size(:int64) + assert 1 = Types.type_size(:uint8) + assert 2 = Types.type_size(:uint16) + assert 4 = Types.type_size(:uint32) + assert 8 = Types.type_size(:uint64) + assert 4 = Types.type_size(:fp32) + assert 8 = Types.type_size(:fp64) + end + end + + describe "default_bool_type/0" do + test "returns :bool" do + assert :bool = Types.default_bool_type() + end + end + + describe "default_int_type/0" do + test "returns :int64" do + assert :int64 = Types.default_int_type() + end + end + + describe "default_fp_type/0" do + test "returns :fp64" do + assert :fp64 = Types.default_fp_type() + end + end +end diff --git a/test/ex_graphblas/unary_op_test.exs b/test/ex_graphblas/unary_op_test.exs new file mode 100644 index 0000000..3813d67 --- /dev/null +++ b/test/ex_graphblas/unary_op_test.exs @@ -0,0 +1,46 @@ +defmodule GraphBLAS.UnaryOpTest do + use ExUnit.Case, async: true + + alias GraphBLAS.UnaryOp + + describe "fn_for/1" do + test "returns correct function for identity" do + assert 42 = UnaryOp.fn_for(:identity).(42) + end + + test "returns correct function for negate_int" do + assert -5 = UnaryOp.fn_for(:negate_int).(5) + end + + test "returns correct function for negate_fp" do + assert -2.5 = UnaryOp.fn_for(:negate_fp).(2.5) + end + + test "returns correct function for abs_val" do + assert 5 = UnaryOp.fn_for(:abs_val).(-5) + end + + test "returns correct function for l_not" do + assert UnaryOp.fn_for(:l_not).(true) == false + assert UnaryOp.fn_for(:l_not).(false) == true + end + + test "raises for unknown operator" do + assert_raise ArgumentError, ~r/Unknown unary operator/, fn -> + UnaryOp.fn_for(:nonexistent) + end + end + end + + describe "apply/2" do + test "applies builtin operator by name" do + assert 5 = UnaryOp.apply(:abs_val, -5) + assert -3 = UnaryOp.apply(:negate_int, 3) + end + + test "applies custom operator struct" do + op = UnaryOp.new(name: :square, function: fn x -> x * x end, type: :int64) + assert 9 = UnaryOp.apply(op, 3) + end + end +end diff --git a/test/ex_graphblas/vector_api_test.exs b/test/ex_graphblas/vector_api_test.exs new file mode 100644 index 0000000..1d321c1 --- /dev/null +++ b/test/ex_graphblas/vector_api_test.exs @@ -0,0 +1,222 @@ +defmodule GraphBLAS.VectorAPITest do + use ExUnit.Case, async: true + + alias GraphBLAS.Backend.Elixir, as: RefBackend + alias GraphBLAS.Vector + + describe "Vector.from_entries/4" do + test "creates a vector via public API" do + assert {:ok, v} = Vector.from_entries(4, [{0, 1.0}, {2, 3.0}], :fp64) + assert {:ok, 4} = Vector.size(v) + assert {:ok, :fp64} = Vector.type(v) + assert {:ok, 2} = Vector.nvals(v) + end + end + + describe "Vector.new/3" do + test "creates an empty vector" do + assert {:ok, v} = Vector.new(10, :int64) + assert {:ok, 0} = Vector.nvals(v) + end + end + + describe "Vector.ewise_add/4" do + test "adds two vectors" do + assert {:ok, a} = Vector.from_entries(3, [{0, 1}, {1, 2}], :int64) + assert {:ok, b} = Vector.from_entries(3, [{0, 3}, {2, 4}], :int64) + assert {:ok, c} = Vector.ewise_add(a, b) + assert {:ok, entries} = Vector.to_entries(c) + assert {0, 4} in entries + end + end + + describe "Vector.ewise_mult/4" do + test "multiplies two vectors" do + assert {:ok, a} = Vector.from_entries(3, [{0, 2}, {1, 3}], :int64) + assert {:ok, b} = Vector.from_entries(3, [{0, 4}, {1, 5}], :int64) + assert {:ok, c} = Vector.ewise_mult(a, b, :times) + assert {:ok, entries} = Vector.to_entries(c) + assert {0, 8} in entries + assert {1, 15} in entries + end + end + + describe "Vector.reduce/3" do + test "reduces a vector to a scalar" do + assert {:ok, v} = Vector.from_entries(3, [{0, 10}, {1, 20}, {2, 30}], :int64) + assert {:ok, scalar} = Vector.reduce(v, :plus) + assert GraphBLAS.Scalar.value(scalar) == 60 + end + end + + describe "Vector.vxm/4" do + test "multiplies vector by matrix" do + assert {:ok, v} = Vector.from_entries(3, [{0, 1}, {2, 2}], :int64) + assert {:ok, m} = GraphBLAS.Matrix.from_coo(3, 2, [{0, 0, 3}, {2, 1, 4}], :int64) + assert {:ok, r} = Vector.vxm(v, m, :plus_times) + assert {:ok, 2} = Vector.size(r) + end + end + + describe "Vector.to_list/1" do + test "converts sparse vector to dense list" do + assert {:ok, v} = Vector.from_entries(4, [{0, 5}, {2, 3}], :int64) + assert {:ok, list} = Vector.to_list(v) + assert list == [5, 0, 3, 0] + end + + test "converts fp64 vector to dense list" do + assert {:ok, v} = Vector.from_entries(3, [{0, 1.5}, {1, 2.5}], :fp64) + assert {:ok, list} = Vector.to_list(v) + assert list == [1.5, 2.5, 0.0] + end + + test "converts bool vector to dense list" do + assert {:ok, v} = Vector.from_entries(3, [{0, true}, {2, true}], :bool) + assert {:ok, list} = Vector.to_list(v) + assert list == [true, false, true] + end + end + + describe "Vector.set/4" do + test "sets a value at index" do + {:ok, v} = Vector.from_entries(3, [{0, 1}], :int64) + {:ok, updated} = Vector.set(v, 1, 5) + {:ok, 5} = Vector.extract(updated, 1) + end + + test "overwrites existing value" do + {:ok, v} = Vector.from_entries(3, [{0, 1}], :int64) + {:ok, updated} = Vector.set(v, 0, 42) + {:ok, 42} = Vector.extract(updated, 0) + end + end + + describe "Vector.extract/3" do + test "returns default for structural zero" do + {:ok, v} = Vector.from_entries(3, [{0, 42}], :int64) + {:ok, 0} = Vector.extract(v, 1) + end + + test "returns stored value" do + {:ok, v} = Vector.from_entries(3, [{0, 42}], :int64) + {:ok, 42} = Vector.extract(v, 0) + end + end + + describe "Vector.dup/3" do + test "creates an independent copy" do + {:ok, v} = Vector.from_entries(3, [{0, 1}, {1, 2}], :int64) + {:ok, copy} = Vector.dup(v) + {:ok, entries_orig} = Vector.to_entries(v) + {:ok, entries_copy} = Vector.to_entries(copy) + assert Enum.sort(entries_orig) == Enum.sort(entries_copy) + end + end + + describe "Vector with explicit backend option" do + test "delegates to specified backend" do + assert {:ok, v} = Vector.from_entries(3, [{0, 1}], :int64, backend: RefBackend) + assert {:ok, 3} = Vector.size(v) + end + end + + describe "Vector with nil backend (fallback behavior)" do + test "nvals/1 falls back to default backend when backend is nil" do + {:ok, v} = Vector.from_entries(3, [{0, 1}, {1, 2}], :int64) + v_nil_backend = %{v | backend: nil} + assert {:ok, 2} = Vector.nvals(v_nil_backend) + end + + test "to_entries/1 falls back to default backend when backend is nil" do + {:ok, v} = Vector.from_entries(3, [{0, 1}, {1, 2}], :int64) + v_nil_backend = %{v | backend: nil} + assert {:ok, entries} = Vector.to_entries(v_nil_backend) + assert Enum.sort(entries) == [{0, 1}, {1, 2}] + end + + test "to_list/1 falls back to default backend when backend is nil" do + {:ok, v} = Vector.from_entries(3, [{0, 5}, {2, 3}], :int64) + v_nil_backend = %{v | backend: nil} + assert {:ok, list} = Vector.to_list(v_nil_backend) + assert list == [5, 0, 3] + end + + test "set/4 falls back to default backend when backend is nil" do + {:ok, v} = Vector.from_entries(3, [{0, 1}], :int64) + v_nil_backend = %{v | backend: nil} + assert {:ok, updated} = Vector.set(v_nil_backend, 1, 5) + assert {:ok, 5} = Vector.extract(updated, 1) + end + + test "extract/3 falls back to default backend when backend is nil" do + {:ok, v} = Vector.from_entries(3, [{0, 42}], :int64) + v_nil_backend = %{v | backend: nil} + assert {:ok, 42} = Vector.extract(v_nil_backend, 0) + assert {:ok, 0} = Vector.extract(v_nil_backend, 1) + end + + test "dup/2 falls back to default backend when backend is nil" do + {:ok, v} = Vector.from_entries(3, [{0, 1}, {1, 2}], :int64) + v_nil_backend = %{v | backend: nil} + assert {:ok, copy} = Vector.dup(v_nil_backend) + assert {:ok, entries} = Vector.to_entries(copy) + assert Enum.sort(entries) == [{0, 1}, {1, 2}] + end + + test "nil backend fallback works across multiple operations" do + {:ok, v} = Vector.from_entries(3, [{0, 1}], :int64) + v_nil = %{v | backend: nil} + + # Chain of operations with nil backend + assert {:ok, 1} = Vector.nvals(v_nil) + assert {:ok, entries} = Vector.to_entries(v_nil) + assert [{0, 1}] = entries + assert {:ok, list} = Vector.to_list(v_nil) + assert [1, 0, 0] = list + end + end + + describe "Vector edge cases" do + test "empty vector operations" do + {:ok, v} = Vector.new(5, :int64) + assert {:ok, 0} = Vector.nvals(v) + assert {:ok, []} = Vector.to_entries(v) + assert {:ok, [0, 0, 0, 0, 0]} = Vector.to_list(v) + end + + test "single element vector" do + {:ok, v} = Vector.from_entries(1, [{0, 42}], :int64) + assert {:ok, 1} = Vector.nvals(v) + assert {:ok, [{0, 42}]} = Vector.to_entries(v) + assert {:ok, [42]} = Vector.to_list(v) + end + + test "dense vector (all positions filled)" do + entries = for i <- 0..9, do: {i, i * 10} + {:ok, v} = Vector.from_entries(10, entries, :int64) + assert {:ok, 10} = Vector.nvals(v) + assert {:ok, list} = Vector.to_list(v) + assert list == [0, 10, 20, 30, 40, 50, 60, 70, 80, 90] + end + + test "extract with opts parameter (even though unused)" do + {:ok, v} = Vector.from_entries(3, [{0, 42}], :int64) + assert {:ok, 42} = Vector.extract(v, 0, backend: RefBackend) + assert {:ok, 0} = Vector.extract(v, 1, some_option: :ignored) + end + + test "set with opts parameter (even though unused)" do + {:ok, v} = Vector.from_entries(3, [{0, 1}], :int64) + assert {:ok, updated} = Vector.set(v, 1, 5, backend: RefBackend) + assert {:ok, 5} = Vector.extract(updated, 1) + end + + test "dup with opts parameter" do + {:ok, v} = Vector.from_entries(3, [{0, 1}], :int64) + assert {:ok, copy} = Vector.dup(v, some_option: :ignored) + assert {:ok, entries} = Vector.to_entries(copy) + assert [{0, 1}] = entries + end + end +end diff --git a/test/ex_graphblas/zig_stub_test.exs b/test/ex_graphblas/zig_stub_test.exs new file mode 100644 index 0000000..7432a1a --- /dev/null +++ b/test/ex_graphblas/zig_stub_test.exs @@ -0,0 +1,56 @@ +defmodule GraphBLAS.ZigStubTest do + use ExUnit.Case, async: true + + alias GraphBLAS.Backend.ZigStub + + setup do + if ZigStub.available?() do + :ok + else + {:skip, "ZigStub NIF not available (ensure Zig toolchain is installed)"} + end + end + + test "ZigStub backend is available" do + assert ZigStub.available?() + end + + test "add_one/1 increments an integer via Zig NIF" do + assert {:ok, 42} = ZigStub.add_one(41) + end + + test "ping/0 returns :ok via Zig NIF" do + assert {:ok, :ok} = ZigStub.ping() + end + + test "matrix_new/4 creates a stub matrix" do + assert {:ok, %GraphBLAS.Matrix{}} = ZigStub.matrix_new(3, 4, :int64, []) + end + + test "matrix_shape/1 returns dimensions" do + {:ok, m} = ZigStub.matrix_new(3, 4, :int64, []) + assert {:ok, {3, 4}} = ZigStub.matrix_shape(m) + end + + test "matrix_type/1 returns the scalar type" do + {:ok, m} = ZigStub.matrix_new(3, 4, :fp64, []) + assert {:ok, :fp64} = ZigStub.matrix_type(m) + end + + test "vector_new/3 creates a stub vector" do + assert {:ok, %GraphBLAS.Vector{}} = ZigStub.vector_new(5, :int64, []) + end + + test "vector_size/1 returns the declared size" do + {:ok, v} = ZigStub.vector_new(5, :int64, []) + assert {:ok, 5} = ZigStub.vector_size(v) + end + + test "compute operations return unsupported" do + {:ok, a} = ZigStub.matrix_new(2, 2, :int64, []) + {:ok, b} = ZigStub.matrix_new(2, 2, :int64, []) + + assert {:error, %GraphBLAS.Error{reason: {:unsupported_operation, GraphBLAS.Backend.ZigStub}}} = + ZigStub.matrix_mxm(a, b, :plus_times, []) + end +end diff --git a/test/ex_graphblas_test.exs b/test/ex_graphblas_test.exs deleted file mode 100644 index 9280f8e..0000000 --- a/test/ex_graphblas_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule GraphBLASTest do - use ExUnit.Case - doctest GraphBLAS - - test "greets the world" do - assert GraphBLAS.hello() == :world - end -end