Contributing
New vendors are config, not code #
This is the one thing to read before opening a pull request.
plumbline has three transports, and almost every "please support X" is already one of them. Adding a vendor should not add a file.
- Anything serving the Jev wire format is a
base_urlfortypesafe_wire. A self-hosted endpoint, a compatible server in front of an open model, a provider that implemented the same shape. It isplumbline run --base-url, recorded in the artifact and part of the cache key. That includes the open decision models that serve this wire format: the unchanged adapter runs them. - Any open-weights checkpoint is a config entry for
local_logits, as a HuggingFace model id and a pinned revision. Pin the revision. A moving checkpoint makes every stored result unreproducible. - Any Anthropic model you want as a control arm is a model string for
generative. It speaks Anthropic's Messages API only, so another provider's chat model is a new transport, not a config entry.
A new adapter module is for a genuinely new transport, which is rare. If you are writing one, say in the issue what wire shape it speaks and why none of the three fit. There is an issue template for exactly this, and it exists to route the conversation toward config before anyone writes code.
If a vendor is nearly compatible and something small blocks it, that is a bug in the adapter worth fixing, not a reason for a fourth one.
A new adapter config must not commit the vendor's published rates. Name the
tariff page and leave the figures to the operator's own --pricing table. Some
vendors' terms make their pricing confidential and override the usual
public-knowledge exclusion, so copying a rate out of a public page into a file
this project publishes is a disclosure by this project. Secret scanning will not
catch it, because a price is not a credential format; see SECURITY.md and the
test named there.
The rule about figures #
No figure may be rendered without its n and its null.
This is not a style preference. A calibration number without its sample size and its floor is not a weaker version of a finding, it is a number that reads like a finding and is not one, and the entire point of this tool is to not do that. A pull request that prints a bare metric will be asked to change, however correct the arithmetic is.
The same applies to refusals. When there is not enough data, print the verdict and the reason, not a value with a caveat attached.
Before you open a pull request #
All four must pass:
uv run pytest
uv run ruff check .
uv run ruff format --check .
uv run mypy --strict
CI runs these on Python 3.12, 3.13 and 3.14, on Ubuntu, Windows and macOS, and runs the tests once more on the oldest dependencies pyproject allows.
Tests never need a network connection or an API key. Every test must pass
without either. If a change needs a live call to be tested, the live call is
mocked and the real one goes in a manual check, recorded in docs/PLAN.md. A
test that reaches the network is a test that fails in CI for reasons unrelated
to the change.
Working on this #
main is protected by a ruleset. It requires a pull request and a green CI run,
and it blocks force-pushes and deletion. Merges are squash only.
The flow:
- Fork the repository, or branch directly if you have write access.
- Branch off
main. Name it after the concern, not the ticket. - Commit as below: one concern per commit, conventional messages.
- Open a pull request. No approval is required, because there is currently one maintainer and a rule demanding one would only demand it of them. CI is the gate that actually matters.
- CI must be green before merge. The required checks are the nine test
jobs (ruff, ruff format, mypy --strict and pytest, on Python 3.12, 3.13 and
3.14, on Ubuntu, Windows and macOS), the tests on the oldest dependencies
pyproject allows, the quickstart as the README documents it, the prose
check (no em dashes, no
--used as a dash), the built wheel, and the site's two checks (the floor agrees with the Python; every link, anchor, meta tag and policy resolves, and the pages work in a real browser). - Squash on merge. The branch is deleted automatically afterwards.
Some checks are advisory and do not gate a merge. CodeQL and Socket Security both report on pull requests, and neither is a required check. The checks listed above are the gate. This is deliberate, not an oversight: a supply-chain advisory is a judgement call that a human should make, and a scanner that can block a merge on a false positive ends up being routed around rather than read.
So read them. A Socket alert on a dependency change is the one worth stopping for, because it is the case the tooling is actually good at: a package that has started running install scripts or reaching the network is a real signal even with no CVE attached. Say in the pull request what you concluded. Clicking past it silently is the failure mode, and so is panicking at a report that turns out to be a transitive dependency's changelog.
The maintainer can bypass the ruleset, and does so for typos and documentation rather than opening a pull request against themselves. That bypass is a convenience for trivial changes, not a way around CI for real ones.
Commits #
One concern per commit, conventional messages.
fix(adapters): request a model the API actually serves
feat(report): print the recalibration verdict and the cascade sentence
docs(methodology): write down what this build measured
The body should say why, not what. The diff already says what. If a commit is fixing something that was wrong, say what the wrong behavior was, because that is the part nobody can reconstruct later.
Pricing entries #
Every pricing entry carries the source it was read from and the date it was read. Both are required, in shipped config and in a user-supplied table alike. An undated price with no source reads in a report exactly like one checked this morning.
Do not add a figure for a vendor whose terms treat pricing as confidential.
Those ship unpriced, naming the page to read, and the operator supplies the
numbers with --pricing. See docs/pricing.example.json.
Use null, never 0, where a vendor publishes no price. null is the absence
of a claim; 0 is the claim that those tokens are free.
Datasets #
Never commit anything to datasets/private/. It is gitignored and it is where
real labeled data goes.
Run artifacts in results/ are gitignored too. They contain per-case records
from your own data, and on a live run, from a vendor call. Do not paste one into
an issue without reading it first.
Working on the site #
The site at https://tmhsdigital.github.io/plumbline/ is built from site/
and the repository's markdown by scripts/build_site.py, and deployed by
.github/workflows/site.yml from main only. It needs Node 22 or later on
the path (for its built-in WebSocket) and, for the browser check, Chrome.
Nothing is installed by npm.
uv run python scripts/build_site.py --out _site
node scripts/check_floor_parity.mjs _site/example-run.json
node scripts/check_site_links.mjs _site
node scripts/check_search.mjs _site
node scripts/smoke_site.mjs _site
site/floor.jsis a JavaScript port of the Python floor, held to it within 1e-9. After changing the floor in Python, runuv run python scripts/floor_golden.pyto regeneratesite/floor-golden.json, and commit both; CI refuses a stale fixture.docs/example-report.mdis checked line for line against the command it records. After changing any report wording, rerun that command and replace everything from# plumbline reportdown; the build refuses otherwise.- Every page carries a Content-Security-Policy that allows only the site's own
files, so no inline script, inline style block, or
style=attribute.