Documentation

Rendered from docs/PLAN.md at commit fd90404, built 2026-09-25 23:24 UTC. Markdown source. Edit on GitHub.

Plan

The build's memory, not a spec. What is left, what was already decided, and what is still unknown. Kept short on purpose: a cleared context should be able to read this page and pick up where the work stopped.

v0.1 is complete. Code, docs, CI, licence and the live validation all landed on 2026-09-21, and v0.1.0 was released that day.

v0.1.1 is released, 2026-09-25: the review release. A repository review filed 43 issues against v0.1.0 and every one is fixed in it, the local arm has run against a real checkpoint, and it is the first release whose wheel and sdist are built, checked, and attested by the release workflow. CI is green on Ubuntu, Windows, and macOS across Python 3.12 to 3.14, and on the oldest dependencies pyproject allows.

The repository is public as of 2026-09-21. The "Pre-public checklist" at the bottom of this page is kept as the record of what was verified before that happened, not as outstanding work.

The remaining work is the v0.2 milestone, and #3 comes first. Its local half is done; its generative half needs a paid run. Two decisions are open before v0.2 builds on the local arm: whether it should score multi-token options by sequence probability rather than refuse them (raised on #3), and whether MCE stays in the report at all (#7).

Phases #

  • Phase 6: adapters, local_logits and generative. Landed. The restricted softmax over a pinned checkpoint, and the text-generating control arm.
  • Phase 7: datasets. Landed. The JSONL loader that refuses unscoreable rows, the JevBench public fixture and its translation, and the end-to-end smoke run in examples/smoke_public_dataset.py.
  • Phase 8: report and CLI. Landed. The markdown report groups arms by probability_semantics, states every figure's row count and null, and demotes MCE to diagnostics. src/plumbline/cli.py exists, so the plumbline console script pyproject declares now works: run, report, adapters, version.
  • Phase 9: recalibration, the cascade, and the methodology. Landed. The report fits a temperature on a held-out half and prints the verdict rather than the number when the verdict is a refusal; the cascade section ends in one sentence naming the threshold, the coverage, the expected cost, and the cost of escalating everything. METHODOLOGY has no pending sections left.

Before this goes public #

All three items are done. Kept here because the answers matter, not the list.

  1. Terms read, 2026-09-21. No clause mentions benchmarking or restricts publishing results. Three others constrain what a public README may carry, and all three are handled: 14.1 (pricing confidential) is why no figure ships, 16.4 (publicity) is why the example report uses the mock, and 2.3(c) (reverse engineering) turned out not to apply because the vendor publishes the confidence formula themselves.
  2. Live call made, 2026-09-21. See "Live validation" below. Both open questions are settled, and the call found two bugs that had never been exercised.
  3. Decision on going public: made. The repository is public, the v0.1.0 tag is pushed, and the checklist at the bottom of this page records what was done.

v0.2 #

  • Ordinal score questions. Landed (#6): mean absolute error against a permutation null, the ranked probability score, and a cumulative calibration error, each against a calibrated-model floor, in a score block of their own. typesafe_wire asks a real Score with the row's rubric. Recalibration and the cascade for scores are not designed yet.
  • Batching. One request per case today, so cost and latency are both conservative relative to batched use. The vendor's own documentation describes packing many questions against one shared state in a single call, which is a materially different cost and latency profile and is the single largest measurement gap in v0.1.
  • Per-label scaling. Landed (#4): after a global fit refused as the wrong shape or stopped short of the floor, a temperature per predicted label, on the same split and verdict rule, with a 100-row gate per label. Vector and matrix scaling stay out until per-label proves insufficient on real data.
  • Option descriptions on the wire. Landed in v0.1.1 (#39): typesafe_wire sends a Choice's descriptions as its criteria, they join the cache key and the dataset hash, and the report says when an adapter does not send them.

Decisions #

Settled during the build. Reopen one only with a reason, not from scratch.

  • ECE and MCE are always reported against their calibrated-null floor.

  • MCE is demoted to a diagnostics block, never beside ECE, because it cannot detect gross overconfidence at 500 rows.

  • Recalibration has three verdicts (recommended, partial, refused) and emits no temperature on refusal.

  • Latency percentiles use nearest rank, not interpolation.

  • Adapters are organized by transport; a new vendor is config, not code.

  • plumbline is not a leaderboard. JevBench is.

  • Every pricing entry carries its source and the date it was read; an entry older than DEFAULT_PRICING_MAX_AGE_DAYS is used but flagged rather than presented as current.

  • A blank cost column names its reason (CostBasis): an adapter that reports no tokens at all is a different finding from an API that reported none on this run.

  • local_logits is fixed at restricted_softmax and generative at none; neither is configurable, because the report groups on that field.

  • A row whose gold label is not one of its options is refused by the loader, never scored: it would mark every system wrong and read as a model failure.

  • Every calibration figure carries its row count, and the artifact records dataset_rows beside dataset_hash, because the floor depends on n.

  • The JevBench public rows are vendored as a fixture under MIT with attribution. Each is asked as the question type it states, through plumbline's own harness, so results from them are never comparable with JevBench's published numbers.

  • A yes/no row is asked as a Noul where the transport has one, and every record carries both what the row asks and how it was asked. A noul figure is never compared with a two-option-choice figure without that line between them.

  • Ordinal score rows are read by rank in a block of their own and left out of every choice figure; before v0.2 they were excluded from every figure.

  • Artifacts never overwrite each other: the timestamp is only accurate to the second, so a repeated name gets a suffix rather than replacing user records.

  • A refused recalibration prints no number: the verdict, the split sizes, and nothing that could be lifted into production code.

  • The cascade threshold is chosen on the fit half of the rows and its cost and coverage are reported on the held-out half, which neither the threshold nor any temperature has seen; a threshold chosen and scored on the same rows would report its best case. Both halves are on the recalibrated scale when a temperature was recommended, because a threshold set against a raw overconfident probability sits in the wrong place.

  • Escalation cost and error cost are supplied by the caller and never defaulted. No benchmark can know them, and a made-up default would decide the threshold.

  • Below 200 held-out rows no threshold is printed at all.

  • generative configures no fallback model. This is deliberate and deviates from the SDK's default advice for this model family: server-side fallback would re-run a declined case on another model inside the same call, so one dataset would be answered partly by two models and the run would no longer be about the system under test. A decline is recorded as a refusal with its category and counted. The tradeoff is stated at the call site in adapters/generative.py; it is not an oversight.

  • CodeQL default setup is kept for its actions coverage, not its Python coverage. Workflow script injection is a real class of bug and ci.yml is where it would hide. The Python queries are expected to be low yield on this codebase: it is a CLI with no attacker in its threat model, run by an operator on their own data with their own key. The first full scan produced exactly one alert, a false positive on a test assertion (py/incomplete-url-substring-sanitization, a substring check that makes no security decision), dismissed with that reasoning. Turn it off if a second false positive appears on ordinary work; at that point it is costing review attention it is not repaying. It is a setting, not a workflow file, so disabling it is one API call and leaves no trace in the tree.

  • Secret scanning and push protection guard credential formats and nothing else. The disclosure risk this project actually has is contractual, and the control for it is the --pricing design plus a test. SECURITY.md says so in full, because a green scanning badge invites the wrong assumption.

  • The site's calculator (site/floor.js) is a JavaScript port of the floor that reproduces numpy's random stream draw for draw, not a statistical approximation of it. It is held to golden values from the Python within 1e-9 by .github/workflows/site.yml, and a failing check blocks the deploy: a stale site is the better failure than a wrong one.

  • The site's worked example is regenerated at deploy time from the mock adapter by the command docs/example-report.md records. scripts/build_site.py refuses any other adapter, so no vendor's rows can reach the site through it.

The site's port depends on numpy's random streams #

numpy does not promise that Generator streams are stable across versions. The port reproduces numpy 2.5.3's PCG64, its ziggurat normal and exponential samplers (with their tables copied from that release), random_standard_gamma, and random_beta. If a numpy release changes any of those, the port and the Python silently diverge.

  • What breaks. The first draw that differs shifts every draw after it, so the floors move by around 1e-3, not by rounding error.
  • How it shows up. site.yml runs on every pull request, with no path filter, so a Dependabot pull request that bumps numpy in uv.lock runs it. scripts/floor_golden.py --check fails first if the Python's own floors moved ("fixture is stale"); after regenerating the fixture, check_floor_parity.mjs fails with every case listed. Either way it fails on the pull request, not on main, and the site keeps serving the last good build.
  • The fix. Re-port the sampler that changed from the new numpy source (numpy/random/src/distributions/distributions.c, ziggurat_constants.h, and pcg64/pcg64.h at the new tag), update the version named in floor.js, regenerate the fixture, and let the check pass. Do not loosen the tolerance to make it pass: a tolerance wide enough to absorb a desynchronised stream is wide enough to absorb a wrong port.
  • Why numpy is not pinned tighter in pyproject.toml. The site builds with uv sync --locked, so the version the port runs against is uv.lock's exact pin, which is already tighter than a major.minor bound. A bound in pyproject.toml would constrain everyone who installs plumbline, for the sake of a page they never run, and would protect the site from nothing the lock does not already cover. The existing numpy>=2.1 floor is also not looser than the port tolerates: on 2026-09-22, floor_golden.py --check matched all 27 cases under numpy 2.1.3, 2.2.6, 2.3, 2.4.6, and 2.5.3, so a user on any of those gets the floors the site shows. To recheck a version, run uv run --isolated --with 'numpy==X.Y.*' python scripts/floor_golden.py --check.

Repository files and tooling deliberately not added #

Considered and declined on 2026-09-21. Listed so they are not re-proposed as oversights. Each would be defensible later for a stated reason; none is defensible merely because projects usually have one.

  • .editorconfig. ruff already owns formatting here, CI enforces ruff format --check, and no tool in this repository reads an editorconfig. Adding one creates a second source of truth for line length and indentation that can silently disagree with the first. .gitattributes already pins the vendored fixture's bytes, which is the only line-ending rule that affects correctness. Revisit if a contributor's editor is actually fighting ruff.
  • pre-commit. It would catch exactly what CI already catches, in exchange for a setup step in CONTRIBUTING, a pinned-hook config to keep current, and a second place where the lint versions live. uv run ruff check . && uv run mypy --strict is already documented and is one command. Revisit if CI minutes or review round-trips become the bottleneck, which at this size they are not.
  • CODEOWNERS. One maintainer. The file would assign every path to the person who would be reviewing it anyway, and the ruleset already requires a pull request. Revisit on the second maintainer.
  • CITATION.cff. Nobody has cited this. A citation file asserting how to cite work nobody has referenced is a claim about its significance rather than a service to a reader. Revisit if someone references the METHODOLOGY results.
  • PyPI publishing. Premature, and the name is taken: plumbline on PyPI is an unrelated project, so publishing needs a different distribution name first. It commits the project to a name and to a release cadence before the API has settled, and the API is explicitly not stable before v0.2. The README says cloning is the install path, which is honest and costs a reader one command. Revisit when the CLI flags stop moving.
  • CI dependency caching was not added because it was already there: astral-sh/setup-uv runs with enable-cache: true.

Live validation #

Run on 2026-09-21. Model requested jev-latest; model that answered jev-1.13.0. 40 choice rows from the vendored JevBench public fixture (datasets/public/jevbench-hard.jsonl, choice rows only, first 40 of 67), one request per case, 4 concurrent workers, no cache. Total billed input 66,458 tokens, output 2,555 tokens.

The artifact is not committed: it holds per-case records from a vendor call and lives in gitignored results/.

No dollar figure from this run appears in this file or in any other committed file. The vendor's MCA makes its pricing information confidential and overrides the public-knowledge carve-out, and a spend total stated next to a token count lets a reader divide one out. The figures exist in the local artifact under results/, which is gitignored, and that is where they stay.

Two bugs this run found before it could answer anything #

  • jev-1 is not a model. The adapter's default model_requested was jev-1, which the API rejects with 400 Unknown model: jev-1. The SDK's own default is jev-latest (typesafe_sdk/constants.py, DEFAULT_MODEL). Fixed in adapters/typesafe_wire.py. The first five-case probe spent nothing because every request 400'd.
  • The pricing table was keyed on aliases only. pricing_for prefers the model the API reported, and a call to jev-latest reports jev-1.13.0. With only alias keys, every live row priced as model_not_priced. The table is now keyed on the reported version as well as the aliases, with no prefix matching, so a future jev-1.14.0 will not silently inherit a superseded rate.

Answers #

  1. Does response.usage populate the token counts? Yes. 40 of 40 rows returned non-None input_tokens and output_tokens. The wire schema is right and the SDK's int | None is a widening, not a description of behaviour. The None-handling path stays: it costs nothing and it is the difference between a blank cost and a false zero. But the report no longer needs to hedge about how often tokens_not_reported is taken on this vendor. Not observed once in 40 calls.

  2. What is response.model, and does it match what was requested? It is jev-1.13.0 on every row, and it does not match the requested jev-latest. This is documented behaviour, not a fault: TypeSafe's models page states that jev-latest and jev-preview are aliases that both currently resolve to jev-1.13.0, and that "the response's model field reports the versioned ID that answered". Recording both strings in the artifact is what makes a result readable after an alias moves.

  3. Do probabilities sum to 1? Yes, to floating-point exactness. Maximum observed deviation across 40 rows is 1.11e-16, mean 2.78e-18, and 39 of 40 rows sum to exactly 1.0. Every one of the 165 probability values returned lies exactly on a two-decimal grid, so the distribution is quantized to 0.01 on the wire. Worth knowing for calibration work: with 10 equal-width ECE bins, a 0.01 grid is finer than the binning and does not bias it, but it does put a floor on how finely a threshold can be tuned.

  4. Is probabilities[choice] always the maximum? The value always is: zero rows of 40 had prob_selected differing from max(distribution.values()). But the reported choice is not always what a naive argmax picks, because ties happen. One row (hard-opus-c-temporal_numeric-04) returned two options tied at 0.23; the API selected usd_2303_01 and the gold label was the other member of the tie, usd_2335_00. That row scored wrong on a tie-break. At a 0.01 quantization over 3 to 6 options, ties are not rare events and code that re-derives the choice by argmax instead of reading answer.choice will disagree with the vendor. plumbline reads answer.choice, which is correct.

  5. Does confidence equal (n * max_prob - 1) / (n - 1)? Effectively yes, to within wire rounding. Maximum absolute deviation over 40 rows is 0.0167, mean 0.0053, median 0.0050; 10 rows match exactly and all 40 are within 0.02. Both the probabilities and the confidences are returned on a two-decimal grid, and a +/-0.005 rounding of max_prob propagates to +/-0.005 * n/(n-1) in the confidence, which accounts for the spread. The residual is consistent with the vendor computing confidence from full-precision internal probabilities and rounding both for the wire.

    This is not a reverse-engineering finding. TypeSafe publishes the formula themselves on https://docs.typesafe.ai/confidence, where the interactive explainer computes (count * peak - 1) / (count - 1) clamped to [0, 1], and the accompanying text describes it as an approximation of the production definition. The docs also confirm that Noul answers carry no confidence, which is what the adapter already assumes.

    Consequence for METHODOLOGY: confidence is a deterministic function of max_prob at fixed n, so AUROC parity between the probability column and the confidence column on fixed-width rows is structural rather than empirical, and the divergence on mixed-width rows follows from n varying. That is exactly what METHODOLOGY already claims from the seeded mock, so the existing hedged wording stands and now has a mechanism behind it. Per the decision recorded below, the relationship is not published.

  6. Latency percentiles, nearest rank, over 40 live calls (4 concurrent workers, so these include some self-inflicted queueing): min 135ms, p50 178ms, p90 235ms, p95 367ms, p99 469ms, max 469ms.

  7. Did anything fail, rate limit, or refuse? No. 40 of 40 rows produced a prediction, zero refusals, zero rate limits. One row needed a second attempt and succeeded on it. No 429 was seen; the published limits are 250,000 tokens per second and 1,200 requests per minute, far above this run.

Pricing #

TypeSafe publishes a tariff for jev-1.13.0 on https://docs.typesafe.ai/models.md, covering both the input and the output side. The figures are deliberately not reproduced here; read the page.

That settles the open question that had blocked cost entirely. The previous shipped entry quoted an SDK field description for the output side and carried no input price at all, so is_priced was False and the cost guard refused any run with --max-cost-usd set: it cannot bound a run it cannot cost.

What plumbline ships is a separate decision from what the tariff says. MCA 14.1 makes the vendor's pricing information confidential and explicitly overrides the public-knowledge carve-out in 14.3, so a figure written into the default table would be published to everyone who clones this repository. The shipped entries therefore price nothing and name the page instead.

Of the two options considered, the number moves out of the committed table into operator-supplied config, rather than the table keeping the number while the source string omits it. The second does not work: a populated input_usd_per_million field in a committed file is the per-token price, whatever the adjacent string says. Only removing the number removes the disclosure.

So:

  • config.py ships jev-1.13.0, jev-latest and jev-preview with both prices None and a source naming the tariff page. None rather than 0.0 on the output side, because a zero there would be plumbline asserting those tokens are free.
  • load_pricing_file and merged_pricing_table read an operator's own JSON table and lay it over the shipped one, wired to plumbline run --pricing. Every supplied entry must carry its own source and as_of, on the same reasoning that applies to a shipped one.
  • docs/pricing.example.json is a template whose prices are null and whose as_of is the placeholder YYYY-MM-DD, which the loader refuses, so an unedited copy cannot price a run. An entry with both prices at 0 is refused unless it says "free": true.
  • A test asserts no shipped entry for this vendor carries a numeric price, so a figure cannot drift back in unnoticed.

Verified end to end with a local supplied table: estimate before the run, actual after, cost_basis priced on all 40 rows, and the guard admitted the run on its own arithmetic rather than being bypassed. With no supplied table the same run reports model_not_priced and --max-cost-usd refuses, which is the honest default.

Already-committed material that stated a price, and the history rewrite #

The working tree was cleaned first: config.py no longer quotes the SDK field description that stated the output price, METHODOLOGY.md no longer repeats that quotation, and the old open-questions section that carried it is gone.

A scan of all 48 commits then found the claim in five files rather than the three first identified, plus one commit message:

  • src/plumbline/config.py, as JEV_OUTPUT_SOURCE
  • METHODOLOGY.md, in "Prices are dated, and so is every result"
  • docs/PLAN.md, in the old open-questions section
  • src/plumbline/metrics/cost.py, in the module docstring (not previously spotted)
  • tests/test_cost_and_latency.py, in an assertion on the source string (not previously spotted)
  • the message of the commit that introduced dated pricing

The numeric input price never reached a commit, and neither did any spend total from the live run: both were removed before the first commit that would have carried them. The zero output price was also present in history as a populated price field, which is the same claim in another form, so it was included.

History was rewritten with git filter-repo on 2026-09-21, using a replace-text rule over file contents and a replace-message rule over commit messages, rather than by rebasing three commits by hand. The replacement is a neutral pointer to the vendor's own models page, not a redaction marker: a marker would advertise that something was taken out, which is the opposite of the point. The populated zero price became None, which keeps the historical file valid Python and says what the current entry says.

The replacement rules were checked against datasets/public/LICENSE-jevbench and datasets/public/jevbench-hard.jsonl before running, because both contain similar wording for unrelated reasons: MIT licence boilerplate in the first, and warranty scenarios and a grant figure in the dataset rows of the second. No rule matched either file, and both blobs are byte-identical before and after the rewrite.

What the run says about the tool #

Accuracy 0.6500 over 40 rows against a chance null of 0.2571, better than chance. ECE 0.1070 against a calibrated-model floor of 0.1400, inconclusive: 40 rows cannot tell this apart from a perfectly calibrated model, which establishes nothing in either direction. Brier 0.1737 against a floor of 0.1512, also inconclusive. Confidence AUROC 0.7981 against a permutation null of 0.5013, separates correct from incorrect. Recalibration refused: it needs 200 held-out rows and this split has 20.

That is the intended behaviour at n = 40 and it is the argument the README makes.

Open questions #

  • Ask TypeSafe, in writing, for permission to name them in a public repository's example output. MCA 16.4 is the clause to ask about: it bars either party from publicly announcing that they entered the agreement, and a committed report from a keyed run arguably does that. Note the clause is one-sided in the other direction, since it expressly lets TypeSafe name its customers.

    This is why docs/example-report.md currently shows the seeded mock arm rather than the live run, and says so at the top. Publicity clauses of this kind are boilerplate and routinely waived, and a vendor is usually glad to be named by a tool that measures it fairly and refuses to rank it. A one-line written yes swaps the mock report for a real vendor one, which is a strictly better page: a reader could then see the tool decline to draw a conclusion from an actual vendor at 40 rows.

    Worth asking about 14.1 in the same message, since a written yes on restating the published tariff would let the shipped pricing table carry figures again and remove the --pricing step for every user of this vendor.

    Follow-up, not a release blocker. Nothing about v0.1 depends on the answer.

    A yes swaps the mock example report for a real one with no other work required. The live run already happened, its artifact is in gitignored results/, and plumbline report <artifact> --out docs/example-report.md regenerates the page from it. The header above the generated half is the only hand-written part, and only its provenance table and the sentence explaining why the arm is a mock would change.

  • Whether the residual in the confidence relationship (max 0.0167) is purely wire rounding or a slightly different production formula. Not worth another spend to settle, and nothing in plumbline depends on the answer.

  • datasets/private/.gitkeep is not tracked. Resolved: .gitignore now ignores datasets/private/* with a negation for .gitkeep, so a fresh clone has the directory the README names and still never commits what goes in it.

Repo description and topics #

For the GitHub About box. Paste as is.

Description (108 characters, as set):

Measure whether a decision model's probabilities are trustworthy on your own labeled data. Not a leaderboard.

Topics:

calibration
evaluation
llm
classification
machine-learning
benchmarking
uncertainty-quantification
model-evaluation
confidence-calibration
expected-calibration-error
python
cli

No vendor name is included. The tool is not about one vendor, the README says so in its first section, and a vendor topic would file it as a fan project.

Pre-public checklist #

Everything below is a manual step. Nothing in this repository does any of it for you.

Verified already, listed so you can re-check rather than re-derive:

  • [x] Full secret scan across all history, not just the working tree. The live key appears in no commit and no blob; .env has never been committed; apik_, sk-ant- and Bearer match nothing anywhere in history.
  • [x] results/, cache/, models/ and datasets/private/ are all ignored and none has ever been committed. No run artifact is in history.
  • [x] uv.lock is tracked and current (uv lock --check passes).
  • [x] CI green on ubuntu-latest and windows-latest, Python 3.12 and 3.13. No cross-platform failure appeared; the suspected path handling was fine.
  • [x] Fresh install from the built wheel: entry point resolves, --help, adapters and version all work, the package imports with core dependencies only, and nothing writes into the working directory.
  • [x] LICENSE present, complete, Apache-2.0, dated 2026, holder TMHSDigital.
  • [x] History rewritten to remove the vendor price claim, and re-scanned after. The MIT fixture and licence are byte-identical before and after.

Done since: the v0.1.0 tag is pushed, the repository is public, the About box carries the description above, and the CI badge renders for logged-out readers.

Still open: email TypeSafe about 16.4 and 14.1 (see "Open questions"). A written yes converts the mock example report into a real vendor one and would let the shipped pricing table carry figures again. Not a blocker for anything.

On this page