Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/hooks/format_python.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
#!/usr/bin/env python3
# array-vectorize: compile scalar Python functions into exact Array API functions.
# Copyright (c) 2026 grAItools
# SPDX-License-Identifier: BSD-3-Clause
# See LICENSE for the full license text.

"""PostToolUse hook: format an edited Python file with the locked ruff.

Only formatting and import sorting run here. Other autofixes would delete
Expand Down
5 changes: 5 additions & 0 deletions .claude/hooks/stop_gate.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
#!/usr/bin/env python3
# array-vectorize: compile scalar Python functions into exact Array API functions.
# Copyright (c) 2026 grAItools
# SPDX-License-Identifier: BSD-3-Clause
# See LICENSE for the full license text.

"""Stop hook: when Python files changed, run `make lint type` before stopping.

On failure the output goes back to the agent (exit 2) so it fixes the
Expand Down
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,28 @@ jobs:
- name: Examples and notebooks
run: make smoke

windows:
name: Windows correctness (Python ${{ matrix.python-version }})
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.14"]
env:
CI: "1"
steps:
- name: Preserve repository symlinks
run: git config --global core.symlinks true
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.2.0
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
- name: Install locked dependencies
run: uv sync --locked
- name: Correctness tests
run: uv run --frozen pytest -m "not slow" -ra

lowest:
runs-on: ubuntu-latest
steps:
Expand Down
44 changes: 40 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,61 @@ on:
tags: ["v*"]

permissions:
contents: write
contents: read

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
release:
build:
runs-on: ubuntu-latest
outputs:
prerelease: ${{ steps.version.outputs.prerelease }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v10.2.0
with:
enable-cache: true
- name: Install
run: uv sync --locked
- name: Validate release
run: uv run --frozen python scripts/release.py --tag "$GITHUB_REF_NAME"
- name: Determine release kind
id: version
run: echo "prerelease=$(uv run --frozen python scripts/release.py --tag "$GITHUB_REF_NAME" --prerelease)" >> "$GITHUB_OUTPUT"
- name: Check (lint + types + tests + coverage)
run: make check
env:
CI: "1"
- name: Recheck cleanliness
run: uv run --frozen python scripts/release.py --check-clean
- name: Build sdist and wheel
run: uv build
run: uv build --no-sources
- name: Smoke-test installed artifacts
run: make package-smoke VERSION="${GITHUB_REF_NAME#v}"
- uses: actions/upload-artifact@v7
with:
name: distributions
path: dist/*
if-no-files-found: error
publish:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v8
with:
name: distributions
path: dist
- name: GitHub release
run: gh release create "$GITHUB_REF_NAME" dist/* --title "$GITHUB_REF_NAME" --generate-notes --verify-tag
run: |
flags=()
if [ "$PRERELEASE" = true ]; then flags+=(--prerelease); fi
gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --title "$GITHUB_REF_NAME" --generate-notes --verify-tag "${flags[@]}"
env:
GH_TOKEN: ${{ github.token }}
PRERELEASE: ${{ needs.build.outputs.prerelease }}
4 changes: 4 additions & 0 deletions .license-header.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# array-vectorize: compile scalar Python functions into exact Array API functions.
# Copyright (c) 2026 grAItools
# SPDX-License-Identifier: BSD-3-Clause
# See LICENSE for the full license text.
10 changes: 8 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# default_stages keeps the file-filtered hooks above from running at the
# commit-msg stage, where pre-commit would otherwise feed them
# commit-msg stage, where the hook runner would otherwise feed them
# .git/COMMIT_EDITMSG; default_install_hook_types makes
# `pre-commit install` install both hook types.
# `prek install` install both hook types. Git stage names stay unchanged.
default_install_hook_types: [pre-commit, commit-msg]
default_stages: [pre-commit]
repos:
Expand All @@ -18,6 +18,12 @@ repos:
# never disagree on the ruff version
- repo: local
hooks:
- id: license-headers
name: canonical license headers
entry: make headers
language: system
pass_filenames: false
always_run: true
- id: cleanporter-fix
name: cleanporter fix
entry: uv run --frozen cleanporter --fix --whole-project
Expand Down
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Invariants:
The code follows the Google Python Style Guide, single deviation:
100-column lines. Imports follow Google §2.2 (import modules, not
names), enforced by cleanporter in `make lint` and `make fmt`; ruff
enforces the rest via `make lint` / pre-commit.
enforces the rest via `make lint` / prek hooks.

## Tests

Expand Down Expand Up @@ -105,6 +105,12 @@ then link it for Claude:

## Docs and commits

- Python and stub files, including golden snapshots, notebooks, and hidden scripts,
carry the canonical `.license-header.txt` notice. `make headers` checks the entire
Git-owned tree plus nonignored untracked files; `make headers-fix` applies it.
Never replace conflicting ownership or license notices. Golden writers add the
header only to snapshots, not to generated function source.

- `docs/` is the single source for the supported subset, semantics,
divergences, and API. `README.md` only links to it. A user-visible
change updates the matching page in the same commit; moving modules
Expand Down
31 changes: 22 additions & 9 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,20 @@ export UV_PROJECT_ENVIRONMENT := .venv-$(PY)
endif
PY_PATHS := src tests scripts examples .claude/hooks

.PHONY: install fmt lint type type-mypy type-pyright type-zuban type-pyrefly test coverage check lowest smoke fuzz bench backends docs docs-serve notebook release
.PHONY: install hooks headers headers-fix fmt lint type type-mypy type-pyright type-zuban type-pyrefly test coverage check lowest smoke fuzz bench backends docs docs-serve notebook release package-smoke

install:
$(UV) sync
$(RUN) pre-commit install
$(RUN) --frozen prek install --force

hooks:
$(RUN) --frozen prek run --all-files --show-diff-on-failure

headers:
$(RUN) --frozen python scripts/license_headers.py --check

headers-fix:
$(RUN) --frozen python scripts/license_headers.py --fix

# fixers run to completion whatever findings remain; `make lint` stays
# the gate. cleanporter gets `test $$? -le 1`: it exits 1 whenever
Expand All @@ -25,13 +34,13 @@ install:
# gets --exit-zero: ruff cannot fix E501 itself, and cleanporter's
# module prefixes push lines past 100 columns; ruff format runs first
# and last so the fixes land on wrapped code
fmt:
fmt: headers-fix
$(RUN) cleanporter --fix $(PY_PATHS); test $$? -le 1
$(RUN) ruff format $(PY_PATHS)
$(RUN) ruff check --fix --exit-zero $(PY_PATHS)
$(RUN) ruff format $(PY_PATHS)

lint:
lint: headers
$(RUN) ruff check $(PY_PATHS)
$(RUN) ruff format --check $(PY_PATHS)
# the import gate: exits 0 only when clean -- treat_unresolved_as_error
Expand Down Expand Up @@ -68,11 +77,15 @@ check: lint type coverage
# git tag -a v$(VERSION) -m "v$(VERSION)"
release:
@test "$(VERSION)" != "" || { echo "usage: make release VERSION=0.1.0" >&2; exit 1; }
@grep -qx 'version = "$(VERSION)"' pyproject.toml || { echo "pyproject.toml is not at version $(VERSION)" >&2; exit 1; }
@grep -qx '__version__ = "$(VERSION)"' src/array_vectorize/__init__.py || { echo "src/array_vectorize/__init__.py is not at version $(VERSION)" >&2; exit 1; }
$(RUN) uv lock --check
$(MAKE) check
$(RUN) uv build
$(RUN) --frozen python scripts/release.py --tag "v$(VERSION)"
CI=1 $(MAKE) check
$(RUN) --frozen python scripts/release.py --check-clean
$(UV) build --no-sources
$(MAKE) package-smoke VERSION="$(VERSION)"

package-smoke:
@test "$(VERSION)" != "" || { echo "usage: make package-smoke VERSION=0.1.0" >&2; exit 1; }
$(RUN) --frozen python scripts/package_smoke.py --version "$(VERSION)"

# the test suite with every direct dependency at the minimum pyproject.toml
# allows, on the minimum Python; proves the declared floors
Expand Down
7 changes: 7 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,10 @@ python -m array_vectorize.fuzz --seconds 60 --seed 0

Runs the grammar fuzzer (differential checks of random scalar programs).
Reproducible with `--seed`.

| Option | Meaning |
|---|---|
| `--seconds` | time budget in seconds (default: 60) |
| `--seed` | random seed for a reproducible run |
| `--cases` | maximum cases; overrides the time budget |
| `--help` | show usage and exit |
77 changes: 61 additions & 16 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ are locked in `uv.lock`, the development Python version is pinned in
virtualenv needs to be activated.

```bash
make install # uv sync (dev group) + pre-commit hooks
make install # uv sync (dev group) + prek hooks
```

Optional dependency groups: `backends` (jax, CPU torch), `docs`
Expand All @@ -22,21 +22,25 @@ dependency at its floor (`--resolution lowest-direct`) on Python 3.12.
## Commands

```bash
make hooks # all Git-tracked files through prek, with diffs on failure
make check # cleanporter + ruff + four type checkers + pytest + coverage (95%)
make check PY=3.13 # any target on another Python (env: .venv-3.13)
make lint type test # the same gates without coverage
make type # mypy + pyright + zuban + pyrefly
make type-mypy # individual checker (also: type-pyright, type-zuban, type-pyrefly)
make lowest # the test suite at the minimum dependency versions
make fmt # cleanporter --fix, then ruff format / check --fix / format
make fmt # headers, cleanporter --fix, then ruff format / check --fix / format
make headers # check canonical headers, including untracked Python files
make headers-fix # apply canonical headers without changing source bodies
make smoke # run scripts/smoke.py, examples/demo.py, the notebooks
make fuzz # grammar fuzzer on the CI seeds
make bench # pytest-benchmark suite (timings + dispatch gates)
make backends # jax.jit / torch.compile compatibility tests
make docs # build this site (zensical, --strict)
make docs-serve # live-reload preview
make notebook # open the marimo example notebooks
make release VERSION=0.1.0 # verify + gate + build dist/ for a release
make release VERSION=0.1.0 # validate + gate + build + installed artifact checks
make package-smoke VERSION=0.1.0 # test wheel and sdist already in dist/
uv run pytest --update-golden # regenerate golden source snapshots
uv run python -m array_vectorize.fuzz --seconds 60 --seed 0 # fuzz longer
```
Expand All @@ -60,10 +64,10 @@ the Makefile, hook configuration, and dependency/typing configuration trigger th

| Checker | Scope | Mode |
|---|---|---|
| mypy | `src` | strict |
| Pyright | `src` | standard |
| Zuban | `src` and maintained tests | strict, with test annotation requirements relaxed |
| Pyrefly | `src` and maintained tests | default diagnostics, including unannotated bodies |
| mypy | `src` and `scripts` | strict |
| Pyright | `src` and `scripts` | standard |
| Zuban | `src`, `scripts`, and maintained tests | strict, with test annotation requirements relaxed |
| Pyrefly | `src`, `scripts`, and maintained tests | default diagnostics, including unannotated bodies |

All target Python 3.12. Zuban has its own configuration rather than inheriting
mypy's source-only scope. The wider pair resolves `support` and `corpus` from
Expand Down Expand Up @@ -112,6 +116,17 @@ numeric warnings (`overflow encountered`, `invalid value`,

## Style

Repository-owned Python and stub files carry the canonical template in
`.license-header.txt`, followed by one blank line. This includes golden snapshots,
marimo notebooks, tests, and hidden scripts. The notice identifies grAItools,
2026, and BSD-3-Clause; its year is updated centrally rather than per file.
`make headers` checks tracked and nonignored untracked files without writing;
`make headers-fix` applies the template, preserving shebangs, encoding declarations,
line endings, and source bodies. Conflicting ownership or license notices and source
symlinks are refused. Ignored environments, downloaded code, and embedded snippets
are outside the policy. The whole-tree hook runs even on template-only commits.
Golden snapshot writers preserve the header; generated function source is unchanged.

The code follows the [Google Python Style Guide]
(https://google.github.io/styleguide/pyguide.html). Ruff enforces it in
`make lint`: pydocstyle with the google convention, pep8-naming, import
Expand Down Expand Up @@ -145,6 +160,10 @@ the NumPy-style docstring subject).
- **static** — `make lint type` once on Python 3.12, including all four checkers.
- **check** — `make coverage` on Python 3.12–3.14
(Hypothesis derandomized), then `make smoke`.
- **windows** — correctness tests on Python 3.12 and 3.14 with locked dependencies
and the deterministic Hypothesis profile. Git symlink support is configured before
checkout so agent-layout checks remain enabled. Timing-sensitive performance tests
stay on Linux; optional JAX/PyTorch compilation stays in its dedicated job.
- **lowest** — `make lowest`: the suite at the declared dependency floors.
- **fuzz** — the five fixed fuzzer seeds.
- **compile** — proves the generated source traces correctly under
Expand All @@ -156,9 +175,13 @@ the NumPy-style docstring subject).
- **docs** — builds this site with `--strict`.
- **pages** — after CI passes on a push to `main`, deploys this site to
[GitHub Pages](https://grAItools.github.io/array-vectorize/).
- **release** — on a pushed `v*` tag, re-runs the gate, builds the sdist
and wheel with `uv build`, and publishes them as a GitHub release
generated from the tag (`.github/workflows/release.yml`).
- **release** — on a pushed `v*` tag, validates the version, lock, changelog,
clean checkout and ancestry on `origin/main`, then runs the full gate. It builds
once with `uv build --no-sources` and tests the wheel and sdist installed into
independent temporary environments. A separate publishing job creates the GitHub
release from those exact artifacts, with generated notes and prerelease marking.
Build permissions are read-only; only publishing can write repository contents.
Runs for the same tag queue rather than cancel during publishing.

## Releases

Expand All @@ -178,9 +201,18 @@ git tag -a v0.1.0 -m "v0.1.0"
git push origin main --tags
```

`make release` refuses to run when the two version declarations disagree
or the lock is stale; tagging stays a manual step so a re-run of the
target can never move a tag.
`make release` and the workflow share a stdlib validator. The tag must be `v`
plus the exact normalized public Python package version in `pyproject.toml`,
`__version__`, and the project entry in `uv.lock`; local version suffixes and
noncanonical spellings are refused. The changelog must contain that version.
The working tree must be clean, the lock fresh, and HEAD merged into `origin/main`
(fetch origin before releasing). Cleanliness is checked again immediately before
building. Keep `dist/` empty before building: artifact checks require exactly one
wheel and one sdist, preventing upload of leftovers from an earlier release.
Each artifact is installed separately outside the checkout; imports must come
from that environment, match the requested version, carry `py.typed`, and compile
and run a source-backed function correctly. Tagging remains manual; PyPI publishing
and workflow dispatch are not configured.

## Commit messages

Expand All @@ -205,8 +237,13 @@ to the new format like this:
| `ci: x` | `ci: x` (type, no scope) |
| `build: x` | `build: x` (type, no scope) |

Existing clones must re-run `make install` once to pick up the new
commit-msg hook.
The hook runner is `prek`; the configuration filename and Git's `pre-commit`
and `commit-msg` stage names stay unchanged. Existing clones must rerun
`make install` after switching runners to replace both installed hooks.
`make hooks` checks all Git-tracked files; staged-only runs and untracked new
files can be silently skipped by filename-filtered hooks. `make lint type`
walks maintained code directly, and the header hook additionally discovers
nonignored untracked Python files. CI uses the same Make checks without autofixing.

## Coding agents

Expand All @@ -225,7 +262,7 @@ harness's own directory only forwards to them:
Edit the shared files; the adapters pick up changes through the import
and the symlinks. `tests/test_agent_layout.py` fails when a skill uses
harness-specific frontmatter or lacks its `.claude/skills` link. On
Windows, the symlinks need `git config core.symlinks true` (and Developer
Windows, the symlinks need `git config core.symlinks true` before checkout (and Developer
Mode or an elevated shell) to check out as links.

## History
Expand All @@ -234,3 +271,11 @@ The original design document, the restructuring plan, and the log of
the 26-round adversarial review loop that drove the exactness work live
in git history; the design decisions that still hold are summarized in
[Architecture](architecture.md#design-decisions).

## Portable source files

Git checkouts use LF through `.gitattributes`. Repository text I/O names UTF-8
explicitly; generated snippet and snapshot writers name LF explicitly. Tests also
write CRLF and Unicode paths deliberately to verify source inspection on Windows.
No Make installation is required for Windows correctness testing: use
`uv run --frozen pytest -m "not slow" -ra` with `CI=1`.
Loading
Loading