Skip to content

Contributing

Getting set up

python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest

pip install -e ".[dev]" resolves the pinned ruff and mypy versions, so the local gates match CI. If you use uv, uv run pytest works from a clean checkout.

No install step is strictly required to run the tests: pyproject.toml puts src on pythonpath for pytest, so a fresh clone needs only pytest.

Make targets, hooks and the container

make help lists them. The ones you will use:

make install     # editable install with the pinned dev tools
make hooks       # install the pre-commit hooks (ruff, ruff format, mypy, file hygiene)
make check       # lint + SRS traceability + test + coverage ratchet: the gates a pull request faces
make srs         # only the SRS traceability check
make repro       # regenerate every figure and table from scratch (measured 321 s, about 5 minutes)
make docker      # build the container image and run the CLI inside it
make docs        # build the documentation site (pip install -e ".[docs]" first)

The Dockerfile builds a clean environment, and .devcontainer/devcontainer.json reuses it for VS Code and Codespaces. CI builds the image on every pull request, but the image is not published anywhere.

Local agent configuration

opencode.json in the repository root configures the coding agent used on this project. It carries a provider API key, so it is listed in .gitignore and must stay there: git add opencode.json is refused, which is the intended behaviour, not a mistake to work around with git add -f.

opencode.example.json is the committed, credential-free template. Copy it and supply the key through the environment:

cp opencode.example.json opencode.json
export FREELLMAPI_API_KEY=...        # never written to the config file

OpenCode substitutes {env:FREELLMAPI_API_KEY} at load time. If the variable is unset the substitution yields an empty string, so the config parses and the provider fails to authenticate rather than the file failing to load. Rotating the key is a manual step for the key's owner; ignore rules keep it out of Git but do not invalidate it anywhere it has already been exposed.

The template was checked against OpenCode 1.19.0 with a local OpenAI-compatible mock endpoint. @ai-sdk/openai-compatible is the supported package for this kind of provider. To switch a built-in provider off, list it in disabled_providers: a "disabled": true key inside a provider entry is not part of the schema and is dropped without a warning, so the provider stays on.

Before opening a pull request

All of these must pass locally. They are what CI runs.

pytest                                    # all pass; xfails and skips are expected
python scripts/coverage_report.py --ratchet
python scripts/metrics.py --check          # counts quoted in the docs
ruff check src tests scripts
ruff format --check src tests scripts
mypy src --ignore-missing-imports

ruff format src tests scripts applies formatting; the --check form is the gate.

The suite collects 1322 tests and all of them pass on a fresh clone, except the deliberate xfails and the skips. pytest -rs lists the skips: two need TUM VI reference data that is not vendored (see below), and the doc-table check runs once python scripts/run_benchmark.py has written results/benchmark.json. The count and the other numbers the docs quote are in docs/data/metrics.json. After adding a test, run python scripts/metrics.py --write (a second); after a change that moves coverage by more than half a point, run python scripts/coverage_report.py and then python scripts/metrics.py --write --coverage. The two xfails are deliberate: they pin ADR-0007's spoof-permanence signal, which is inert for every reachable configuration. They are meant to keep failing until Track B lands, at which point they become real tests.

Datasets

No real-sensor data is vendored, and none will be. TUM-VI is available from TUM under its own terms; download it separately if you want to run the reference checks. Until then those tests skip, and the skip is visible in the suite output rather than hidden.

Every published number comes from configs/benchmark.yaml, a synthetic known-answer fixture. Adding a real dataset does not change a number already in the docs; it adds a new claim, and new claims need the claim-type tagging in navkit/analysis/findings.py.

Conventions worth knowing

Never hand-type a number. Every figure in README.md and docs/ is generated and checked by scripts/check_doc_tables.py, which CI runs. If you change a result, regenerate rather than editing prose, and let the check tell you what moved.

Do not add # noqa. The repository has none, deliberately. A rule the project does not want is disabled in [tool.ruff.lint] in pyproject.toml with a comment saying why, so the decision is visible and reviewable. A noqa hides it at the call site.

No error-code suppression for mypy. pyproject.toml disables none. Three codes were disabled once; they were hiding 12 real errors on a mis-annotated state dict, which has since been made a TypedDict.

Tag claims by type. ClaimType is FACT, MEASUREMENT, INTERPRETATION, or HYPOTHESIS, and validate_claims enforces what each may assert. If your change makes a previously-falsifiable claim untestable, that is a regression even if the code still runs.

Record an ADR for a decision, not an implementation. Anything that closes off an alternative belongs in docs/adr/. ADR-0008 is the model: it records that a cross-check was implemented, tested, and left disabled, and why.

Keep the tests able to fail. A test that asserts X or True, or indexes [0] into a list it expects to have one element of, verifies nothing. If a mutation to the code would not fail your test, it is not a test yet. Both patterns existed in this repository and both were found during the 2026-09-29 audit.

Reproducing the published results

python scripts/run_benchmark.py --out results/benchmark.json   # ~16 s, 7 cases
python scripts/seed_sweep.py --seeds 10 --markdown             # sensor noise
python scripts/scene_sweep.py --seeds 8  --markdown            # trajectory
python scripts/outage_sweep.py --markdown --figure docs/figures/outage-sweep.png   # outage window, ~2.5 min
python scripts/make_figures.py                                 # docs/figures/*.png
python scripts/check_doc_tables.py --results results/benchmark.json

After pip install -e . each script has an installed equivalent with the same options: navkit run, navkit sweep seeds, navkit sweep scenes, navkit sweep outages and navkit figures. The scripts are thin wrappers around the same code in src/navkit/.

The benchmark is bit-reproducible: running it twice gives byte-identical output once wall-clock timings are stripped, and CI enforces that. The sweeps are reproducible too: CI checks the seed and scene sweeps at three seeds and the outage sweep on a reduced grid (one start, two durations, two seeds).

results/ is gitignored. The numbers in the docs are verified against a fresh run rather than committed, so a stale artifact can never be mistaken for evidence.

The PNGs under docs/figures/ are the exception: they are committed, so they must be regenerated with scripts/make_figures.py in the same change that alters any published number, or they will contradict the tables beside them. Nothing checks this automatically, because pixel output depends on the Matplotlib build and a byte comparison would fail on a different machine for reasons that have nothing to do with the data. Compare the underlying results/benchmark.json instead, which CI does verify.

Commits and pull requests

Conventional commit prefixes, one coherent change each. In this repository the subject line is the explanation; the body says why, and says what was measured.

fix(io): repair the EuRoC IMU loader, which raised on every call

A commit that reformats most of the tree is a separate commit, even when it belongs to the same piece of work -- otherwise the behavioural change cannot be reviewed on its own.

Releasing

Only the maintainer releases. A release is a tag, and the workflow in .github/workflows/release.yml does the rest, as a draft.

  1. Re-measure the numbers in docs/releases/<version>.md and CONSTRAINTS.md.
  2. Remove the DRAFT banner from the notes, rename ## [Unreleased] in CHANGELOG.md to ## [<version>] - <date> and add a fresh ## [Unreleased], and set the version in pyproject.toml and src/navkit/__init__.py.
  3. Run python scripts/check_release.py --tag v<version>. It must print release check OK.
  4. Merge that change, then push the tag v<version> from main.
  5. The workflow checks, builds and attests, and creates a draft release. Read it, then publish it.

A manual run of the Release workflow is a dry run: it builds and checks, and prints what would block a release. It publishes and attests nothing.

Coverage

Coverage is measured by scripts/coverage_report.py, the project's own tracer, because constraint S1 keeps pytest-cov out. --ratchet fails a pull request if the total falls below 75% or any module falls below its floor, and it runs in CI on Linux. There is no coverage badge: a badge needs either a third-party service or a committed file that goes stale, and neither is a live source of this project's number. The figure that counts is the one scripts/coverage_report.py prints, and the quoted copies in CONSTRAINTS.md are re-measured when they drift.

These are settings for the repository owner to apply, in Settings, Branches, Branch protection rules, for main. Nothing here is changed by a pull request.

Setting Value Why
Require a pull request before merging on main never receives a direct push
Required approvals 0 while there is one maintainer, 1 when there are two GitHub does not let an author approve their own pull request
Require status checks to pass on, and "require branches to be up to date" on the checks below
Require conversation resolution on a review thread is answered before merge
Restrict force pushes and deletions on (the default) history on main is not rewritten
Include administrators on the owner is bound by the same gates
Require linear history off pull requests are merged with merge commits here
Require signed commits off not part of this project's workflow

Required checks, by the names GitHub shows (they come from the name: of each job):

  • lint, build, docs
  • test (py3.11), test (py3.12), test (py3.13)
  • test (py3.13, macos-latest), test (py3.13, windows-latest)
  • benchmark reproducibility, sweep reproducibility
  • analyze (python) (CodeQL)
  • once those workflows are merged: container, secret scan, pip-audit, dependency review, relative links

Do not require github-advanced-security: it is an automated review service, not a gate (it failed on every pull request for a month on a quota error that has nothing to do with the code). SBOM and external links are informational.

Pull requests and issues

Pull requests start from .github/pull_request_template.md, issues from the templates in .github/ISSUE_TEMPLATE/ (bug report, feature request, roadmap item). CODEOWNERS routes review to the maintainer. The issue forms apply the labels bug, enhancement and roadmap; create them once under Issues, Labels, or GitHub drops the label silently. Conduct is covered by CODE_OF_CONDUCT.md; security reports go through SECURITY.md.