Changelog
Notable changes per release. Dates are the release date, not the tag date when those differ.
This project is pre-1.0. The measurement behaviour is the stable part; the Python API and the CLI flags are not, and a minor version may change either. Anything that changes what a number means is called out under Changed, not buried under Fixed, because a figure that moved for a methodology reason is a different event from one that moved because it was wrong.
Unreleased #
Added #
- Ordinal score rows are scored (#6). Each is read by three rank-aware
figures, in a score block of its own and never beside a choice figure: mean
absolute error of the expected score in levels, against a permutation null;
the ranked probability score; and a cumulative calibration error, the
predicted probability of being at or below each threshold against how often
it was, pooled and binned as ECE is. The last two are read against a
calibrated-model floor, built by redrawing each row's level from its own
distribution.
typesafe_wireasks a real Score with the row's rubric, which the JevBench loader now keeps as the levels' descriptions; the other arms answer the levels as options and say so. A score row whose options are not integer levels is refused. On the public fixture the six score rows now run, so its report covers 111 rows, with the choice figures unchanged over 105. METHODOLOGY's "Ordinal score questions are scored by rank" is the design. - A temperature per predicted label, tried only when the global fit is refused
as the wrong shape or stops short of the floor (#4). It uses the global fit's
split and verdict rule, leaves a label with under 100 fit rows as it came and
names it, prints its temperatures in a block of their own that says they are
not comparable with the global one, and names which correction to apply. On
the mock, from about 100 fit rows per label, it lands a per-label bias inside
the floor where one temperature leaves 1.2 to 3.3 times it; where one
temperature is the right shape its extra parameters cost 5 to 15 percent more
held-out ECE.
scripts/per_label_study.pyand METHODOLOGY carry the tables. --option-style letterasks a local checkpoint its options as A, B, C and reads the letter tokens, so options of any length can be scored (#3). The default still reads each option's own token and refuses one that is several tokens. On the public fixture the default scores 39 of 105 rows; by letter, all 105, at chance accuracy with an ECE of 0.349 against a floor of 0.087. The style is part of the cache key only when it isletter, so existing entries keep their keys, and the artifact and the report record it. METHODOLOGY says why sequence probability was not used instead: longer options would lose probability for being long.
v0.1.1 (2026-09-25) #
The review release: every issue the repository review filed is fixed, and the local arm has run for real. None of the v0.2 milestone's new measurement (score support, batching, per-label scaling) is in it; that is what v0.2 is for.
Added #
- The report states the grid an arm's probabilities arrived on, when every one
sits on a grid of 0.001 or coarser, and warns when the bins are narrower than
it (#9). Hosted Jev rounds to two decimals, and a reader needs that to know
what a small difference is worth.
scripts/quantization_floor.pymeasures whether the rounding raises the ECE floor: across 40 to 10,000 rows, 10 or 20 bins, and grids of 0.01 or 0.05, a calibrated model reported on the grid clears the floor's 95th percentile at the nominal 5 percent, so it does not. METHODOLOGY carries the table. - A row whose top probability is shared by two or more options is now recorded
as one (#10). Each artifact record carries
tied_for_top, and when any row tied the report says how many beside the accuracy figure, and on how many the gold label was a tied option the vendor did not choose. On a two-decimal grid such ties are ordinary, and a row decided by a tie-break looked like any other. plumbline run --dry-runloads and checks the dataset, builds the adapter, applies the cost guard, and prints the case count, the endpoint, the timeout, the semantics, and the estimated cost with the pricing entry behind it, then exits without sending a request or writing a file (#58). It refuses exactly what the run would refuse, because the run starts from the same plan.--base-urland--timeoutonplumbline run, for an adapter that takes them (#58). The endpoint could be changed only through an environment variable before; it is recorded in the artifact and the cache key either way, and the timeout is now recorded too.--semanticsnow overrides the declared semantics for any adapter that takes it, not only the mock, and the report says when the operator set it. An option an adapter does not take is refused by name before anything is built.- An artifact now stores what loading the dataset found, so a report rebuilt
with
plumbline reportprints the same Dataset section, with the read, loaded, and refused counts, as the one written at run time (#58). Artifacts written before this still report, without the section. - The site scores your own predictions (#56). Paste a probability and an
outcome per line, as CSV, tabs, or spaces, with or without a header, and the
page computes the ECE, the floor for exactly those predictions, and the
verdict a report would print, word for word. A row that cannot be read is
named by its line, and nothing is scored until every row reads. Nothing
pasted leaves the browser: it is not uploaded, stored, or put in the address.
The parity check holds the path to
ece_figureon pasted text in four formats, two of them on and beside a rounding tie, and pins the parser's refusals; the smoke test pastes a bad row and the worked example's rows, which must reproduce the report's line. - A browser calculator for the ECE floor (
site/), a JavaScript port ofsynthetic_floorthat reproduces numpy's seeded random stream draw for draw.scripts/floor_golden.pyexports golden values from the Python andscripts/check_floor_parity.mjsholds the port to them within 1e-9 in CI. - The site page: the argument, a worked example that derives the example
report's ECE line from its 105 rows in the browser, and a sample-size planner.
scripts/build_site.pyregenerates the example from the mock adapter at build time and refuses ifdocs/example-report.mdno longer matches its own command. - The site hosts the repository's docs (README, METHODOLOGY, PLAN, the example report, CHANGELOG, CONTRIBUTING, SECURITY, and the dataset README), rendered at build time from the commit being deployed by a vendored markdown-it under the runner's Node. Each page names the commit and build time it came from. The build fails on a broken link or anchor, on raw HTML outside a short allowlist, and on a modified vendored renderer.
- The site is live at https://tmhsdigital.github.io/plumbline/, deployed by
site.ymlfrommainonly after the parity check passes andscripts/check_site_links.mjsfinds every link, anchor, and meta tag in the assembled site resolving and nothing loading from another origin. Every page carries a canonical URL and Open Graph and Twitter card tags; the card image (site/og.png) is rendered fromscripts/og_image.html. Missing paths get a 404 page that links back. - The site has a header on every page (calculator, docs, example report,
GitHub), and the doc pages have a grouped docs sidebar, an "On this page"
contents list that follows the section in view, a link on every h2 and h3
that copies itself, previous and next links, an "Edit on GitHub" link, and
copy buttons on code blocks. All of the navigation is HTML written at build
time, so it works with scripts off;
site/site.jsadds only the conveniences and a light, dark, or automatic theme that is remembered between visits. Form borders and the histogram's bars now meet 3:1 contrast in both themes. - Search across the explainer and every doc, from the header or with
/or Ctrl+K. The index (search-index.json) is written at build time from the rendered sections and fetched from the site only when search opens; the ranking (site/search.js) runs in the browser with no library.scripts/check_search.mjsholds the ranking to its cases and every index entry to a page and id that exist. - The explainer opens with the example report's result drawn as a card (the measured ECE against its floor's 95th percentile, and the verdict), written at build time from the report's own line so it reads the same with scripts off, and keeps its contents in a rail beside the text on wide screens.
- The calculator and planner each run in their own worker and can be cancelled; their inputs are kept in the address, so a link reproduces a result, and each result has "Copy link" and "Copy result" buttons.
- Every page carries a Content-Security-Policy that allows nothing but the
site's own files (no inline script or style), and
check_site_links.mjsfails a page that lacks it or carries anything it would block. - The site is checked in a real browser before it deploys.
scripts/smoke_site.mjsdrives the runner's own Chrome over the DevTools protocol with Node's standard library (nothing is installed), runs the calculator, a shared link, and search, and fails on any console error or CSP violation on any page. After a deploy, a new job checks the live site's links, anchors, meta tags, policy, and 404.
Changed #
- Report bullets separate the label from the figure with a colon instead of an
em dash (
- **Cost**: not reported.), and the docs no longer use em dashes. A CI job now fails on an em dash in tracked markdown orsrc/. This changes report text, not any number. The job also coverssite/,scripts/, and.github/, and the HTML entity and JavaScript escape spellings of the character. --is no longer used as a stand-in for a dash: METHODOLOGY, the source comments and docstrings, and one report note (the ordinal-score line in the load summary) now use commas, colons, or parentheses. A second check in the same CI job fails on a bare--between words; flags, git's end-of-options separator, and HTML comments are unaffected. This changes report text, not any number.
Fixed #
-
The local arm, run for real for the first time against a pinned Qwen2.5-1.5B-Instruct on a GPU, turned up four problems the fakes could not (#3). The run's eight workers each loaded their own copy of the checkpoint on the first case; it now loads once, and forward passes run one at a time, with latency timed after any wait. There was no way to put it on a GPU from the command line;
--devicenow does, and the artifact records it. The first pass on a device paid for kernel setup, which put a 10 second call in a latency tail whose p50 was 66 ms; the readout now makes that pass when it loads, and p99 fell to 487 ms. The report called every refused or failed case a cache hit in its latency line, on runs with no cache at all, and blamed a missing confidence on yes/no answers for any arm that reports none; both now say what happened. -
The site printed some figures one step off from the report, such as a measured ECE of 0.00125 as
0.0012where the report prints0.0013(#48). Its formatter meant to round half to even only on an exact tie, but its tie test compared a product with itself, so every near tie was rounded to even. It now detects exact binary ties alone (odd multiples of 1/32, the only values a double can hold exactly on a tie) and otherwise rounds the exact value, as Python does. The golden fixture gains 890 values on and beside ties, and near-tie measured values, so the parity check covers them. -
The release workflow gave its whole run a write token while it executed the project's code and every dependency's, expanded the tag, which a manual run takes as free text, straight into shell, and let a tag push and a manual run of the same tag race to replace the same assets (#51). The tag is now checked against
vX.Y.Zin a job of its own and passed through the environment; the build job can only read; a separate job with no checkout and no project code signs build provenance for the wheel and sdist (gh attestation verify) and attaches them; and runs queue per tag. -
Every action was referenced by a movable major tag (#52). Each is now pinned to a commit, with its version beside it, and Dependabot keeps the pins and uv.lock current, waiting a week after any release. CI now also runs on macOS and Python 3.14, so
requires-python = ">=3.12"says what is tested. -
The dependency floors in pyproject were never tested, and three were wrong: scipy 1.14.0 has no wheel for Python 3.13, anthropic before 0.77 lacks the structured output types the generative adapter uses, and typer before 0.16 breaks against click 8.2 (#47). The floors are now the oldest releases the whole suite passes on: numpy 2.1, scipy 1.14.1, typer 0.16, typesafe-sdk 0.5.7, and anthropic 0.77. The two SDKs are capped below their next breaking series, httpx is no longer declared (nothing imported it), and a new CI job runs the suite on the oldest direct dependencies pyproject allows.
-
A broken or missing SDK took down every command, the mock included, because the registry imported all four adapters at startup (#47). An adapter is now imported the first time it is created, and one that cannot be says which module is missing; the other adapters, and the adapters list, are unaffected.
-
scripts/build_site.py --outdeleted whatever directory it was given unless it was the repository,site/, or above them, so a typo such as--out docsor--out .gitdeleted source or history (#50). It now deletes only a directory that is empty or carries the.plumbline-sitemarker a build writes, and refuses anything else before doing any work. A_sitebuilt before this change has no marker, so it is refused once; remove it by hand. -
A row whose response named no model was never priced, because pricing looked up only the model reported; the guard, which prices the requested model, and the report then disagreed about the same run (#36). Such a row is now priced by the requested model, and its pricing key says so. A response that names a model missing from the table is still unpriced: a newer version never inherits an older rate.
-
The cache key sorted the options, but
generativeandlocal_logitslist them in their prompts in the order given, so a cached answer could be served for a prompt that was never sent (#37). For those two the key keeps the order. -
Option descriptions were parsed and then dropped:
typesafe_wiresent every criterion as empty, and the dataset hash ignored descriptions and question types (#39).typesafe_wirenow sends them as the choice's criteria and keys its cache on them, the dataset hash covers both where a row carries them (a plain choice dataset keeps its hash), and the report says when an adapter did not send the descriptions its rows carried. The public fixture's dataset hash changes, fromc18e9496to1b96dc91, and the example report gains that note; no number changes. -
plumbline reportcombined artifacts from different datasets under the first one's hash, as if their figures were comparable, and gave two runs of one adapter identical headings (#43). Different datasets are now refused unless--allow-mixedis passed, when every arm names its dataset and row count; arms that share an adapter name are told apart by model, then by time. -
A credential inside a list in the run config reached the artifact, because redaction walked only dictionaries; it now walks lists and tuples too (#46). The report and the artifact named the dataset by the path as typed, so an absolute path shared a username and a directory layout; a dataset inside the working directory is named relative to it and one outside by its file name, and the hash still says which rows they were. A backtick in a model name no longer breaks out of its code span.
-
The JevBench loader skipped the duplicate-id check the JSONL loader makes, so repeated ids loaded silently (#44). Both loaders now share it. The dataset loader read a byte order mark as part of the first row and refused it, turned a label of
null,trueor1into the text "None", "True" or "1", accepted empty labels and descriptions of options that do not exist, and failed on a non-UTF-8 file without naming it (#45). It now reads the mark as nothing, refuses each of the others with the reason, and names the file and line that is not UTF-8. The pricing loader accepted NaN, infinite and negative prices,as_ofvalues such as20260901or2026-W36-1, and dates in the future, which kept the report from ever calling a price stale; each is refused now, and a byte order mark is read as nothing there too. -
The CLI accepted options that misbehaved or crashed (#41):
--limit 0ran every row and a negative limit sliced from the end,--boot 0failed with a traceback after the run had been paid for, an option the adapter does not take and a missing API key each printed a traceback, and a--reportpath that was a directory crashed after the run. Counts must now be positive, and each of the rest is one line, before anything is sent. -
plumbline runexited 0 when every case failed, and printed its status and its errors on stdout, sorun > report.mdcaptured them and a script could not tell a run with no figures from a good one (#42). Status and errors now go to stderr, stdout carries only the report, and a run with no figures exits 1 after writing the artifact and the report. -
When every case failed for the same reason (usually a missing extra or key), the report said only that the failures were in the artifact (#15). It now prints that reason, once; different reasons are still left to the artifact.
-
The cascade's threshold was chosen and scored on the same rows, so the coverage and cost it printed were its best case rather than what it would do; with no temperature recommended it used every row and still called them held-out (#30). It is now chosen on the fit half and reported on the held-out half, and the 200-row minimum counts the held-out half, so a run needs at least 400 scored rows for a threshold. This changes which runs print a threshold, and the cost and coverage printed with it.
-
Equal-count binning split tied predictions across a bin boundary by input order, so the same rows gave an ECE of 0.3 in one order and 0.2 in another, and bins holding the same values were labelled with different ranges (#38). A cut now never falls inside a tie, and each bin's edges come from the rows it holds. Equal-width binning, the default, is unchanged.
-
The cascade never considered escalating every case, because the highest observed score always kept the rows that reached it covered; with every case wrong and errors dear it chose a threshold costing $201 where escalating all three cost $3 (#34). Escalating everything is now a candidate, and the report says so in words when it wins. Cost per correct answer divided the priced total by correct answers from unpriced rows too, understating it (#35); it now counts correct answers among priced rows.
-
A prediction whose distribution held NaN, a negative, or a value above 1 was accepted as long as the entries summed to about 1, and a NaN latency was accepted too (#32); each is now refused with the option it concerns. The multiclass Brier floor raised
IndexErroron a distribution summing to a little under 1, whichPredictionallows (#31); it now draws from the distribution as reported, and one that sums to 1 draws exactly as before. -
An accuracy or AUROC far below its null was reported as INCONCLUSIVE with the advice to collect more rows, so an inverted score over 300 rows (AUROC 0.0) read as a sample-size problem (#33). Both nulls now carry a 5th percentile, and a value below it gets its own verdict: accuracy "worse than chance", AUROC "ranks incorrect above correct", each with the likely reason. Values above or inside the null read exactly as before.
-
The pricing template priced every call at $0, so a copy used unedited let any run past
--max-cost-usd(#28). Its prices are now null and itsas_ofaYYYY-MM-DDplaceholder that the loader refuses, and any entry pricing both input and output at 0 is refused unless it says"free": true. -
A failure no retry can fix was retried anyway, and each retry was another billed call (#26). The runner retried every error but a refusal, including a wire-contract error, a checkpoint mismatch, a missing optional dependency and a 401, and both SDKs retried again underneath, so one case could make nine calls. Now only transport failures are retried (a dropped connection, a timeout, 408, 409, 425, 429 and 5xx),
Retry-Afteris honoured up to 60 seconds, the SDKs' own retries are off, and a failed case records the attempts it actually made. -
Two workers answering cases with the same text wrote the same cache entry through one shared temporary file; on Windows the loser raised
PermissionErrorand aborted the run, losing every call already paid for (#27). Each write now has a temporary file of its own, a write that still fails is counted in the cache stats (write_errors) instead of raised, and cases sharing a key are answered once per run, the rest from the cache. -
An endpoint set through
TYPESAFE_BASE_URLorANTHROPIC_BASE_URLchanged which server answered but reached neither the cache key nor the artifact, so a self-hosted run and a hosted one shared cache entries (#40). The adapters now resolve the endpoint the way their SDKs do, it is part of the key, the artifact records it asendpoint, and the report names it when it is not the vendor's default. Runs against the default endpoint keep their cache keys. -
Every validation message on the site's calculator and planner read "[object Object]" (#25). Messages now say what is wrong, mark the field invalid, and are tied to it for screen readers; a result is announced as one line rather than the whole result block (#53).
-
A failure to draw the worked example was reported as the example being missing; the page now says which happened.
floor.jsturned a bin count of 0 into 10 where the Python refuses it, and now refuses it too. The parity check holds the worked example's floor to 1e-9, not only to the four decimals the report prints (#55). -
A link to a later section of the explainer (
#planner) stopped short of it, because the worked example grows the page after it loads. -
The inconclusive verdict read as a pass. "Not distinguishable from a perfectly calibrated model at this sample size" is what the arithmetic establishes and close to the opposite of what it means, and readers took it as a clean result. Every such figure now leads with
INCONCLUSIVEand states that nothing was established in either direction. This changes report text, not any number. -
The package shipped no PEP 561
py.typedmarker, so downstream type checkers ignored its annotations and consumers silently sawAny. -
plumbline versionprinted0.1.0.dev0from the v0.1.0 release, because the test asserting the version used a substring match that0.1.0.dev0satisfies. -
plumbline reporton a missing or malformed artifact raised a bareFileNotFoundErrortraceback instead of refusing with a reason. -
An adapter built without a required setting raised a bare
TypeErrorfrom__init__instead of naming the setting. -
CI's guard against a traceback leaking from the quickstart refusal could never fail:
grep -qv Tracebacksucceeds on any output with one line without the word (#49). -
CI ran every pull request twice, because both the
pushandpull_requesttriggers fired on a branch pushed to origin.
Documentation #
-
METHODOLOGY now measures how the penalty for reporting no distribution changes with row count (#8).
scripts/distribution_penalty.pyrepeats the comparison from 500 to 20,000 rows: the top-line form's leftover miscalibration stays about 0.18 on an underconfident model at every size, while the floor falls, so its ratio to the floor grows from 2.9 to 17 times. The penalty is a bias of the answer shape, not a small-sample artifact. -
The load summary said every JevBench row "is asked as a one-of-n choice", which is not true of an adapter that asks yes/no rows as yes/no questions; it now says each row is asked as the question type it states, and the report's "3 choice asked as failed" reads "3 choice rows failed" (#62). This changes report text, not any number.
-
CONTRIBUTING said "all three" above four commands and named only the test jobs as the gate; it now lists every required check and has a section on working on the site (#63). PLAN no longer lists finished work as to do (#64).
.env.exampleno longer claims a.envfile is read or lists an Ollama adapter that does not exist, and the docs describegenerativeas the Anthropic Messages API it is (#65). The CLI's help says what the tool is and lists each option's choices, andplumbline adaptersmarks an adapter whose optional extra is missing (#66). The adapter template applies a label that exists, and there is a template for site bugs (#67). -
A new page, Your own data, gives the row format, what the loader refuses and why, and the commands a user needs next: a local checkpoint with its pinned revision, the cascade's two costs, and
plumbline reportfor runs that already happened (#61). A test holds its example rows to the loader. -
The README's hosted-vendor command used
--max-cases 40against 105 rows, which refuses rather than truncates, so it sent nothing (#29); it uses--limit 40, and--max-casessays in its help that it is a guard. -
The README said
pip install plumbline"will not work"; it installs an unrelated project of the same name (#60). It now says so and gives pip commands that install this one, and the missing-extra error no longer points at the PyPI package. -
docs/example-report.mdis now checked line for line against its recorded command on every site build, not only its ECE line, and the README says that instead of calling it "unedited". -
README rewritten for a reader arriving from a link: what the tool is now precedes what it is not, and decision model, calibration, cascade, Noul, binning noise and the Jev wire format are each defined where they appear. Adds prerequisites, a bash quickstart, and a note that this is not on PyPI.
-
The adapters table gained a "Run for real" column, saying which transports have run outside the test suite.
-
SECURITY.md distinguishes what secret scanning covers from what it does not, since the closest thing to a disclosure this project has had was a vendor's price, which no scanner recognises.
v0.1.0 (2026-09-21) #
First release.
Added #
- Calibration measured against its own null. ECE, MCE and Brier are each reported beside the floor a perfectly calibrated model would produce at the same row count, computed by simulation. A figure inside its floor is reported as unresolvable rather than as a result.
- Choice and Noul question types. 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, so the two are never averaged together.
- Three adapter transports.
typesafe_wirefor the Jev wire format over HTTP,local_logitsfor option-token logits from a pinned local checkpoint, andgenerativeas a text-generating control arm. Plus a seededmock. - Three probability semantics classes.
calibrated_claim,restricted_softmaxandnone. The report groups on this field and refuses to place figures from different classes side by side. - Temperature scaling with a refusal gate. Fitted on a held-out split. When the residual says temperature is the wrong correction, or the split has fewer than 200 rows, the tool emits no temperature rather than one that does not fit.
- Cascade threshold selection. Given the cost of one escalation and one wrong answer, it states where to cut and what that buys. Without both numbers it refuses, because no benchmark can know them.
- Cost from reported tokens against a dated pricing table. Every entry carries its source and the date it was read. A blank cost column names which of four reasons made it blank.
- Operator-supplied pricing via
--pricing, for vendors whose terms treat their rates as confidential. Those ship unpriced, naming the page to read. - A loader that refuses rather than repairs. A row whose gold label is not among its own options is refused with its line number, because scoring it would mark every system wrong and read as a model failure.
- Latency percentiles by nearest rank, and a results artifact recording the requested model, the model that answered, the dataset hash and row count, and the pricing entry applied, with credentials redacted.
- Apache-2.0. The vendored JevBench fixture is MIT and attributed in
datasets/public/README.md.
Known limitations at release #
Ordinal Score rows load but are excluded from every figure. One request per case, so cost and latency are conservative relative to batched use. Temperature scaling only. Verified on Windows and Ubuntu, Python 3.12 and 3.13.