A TypeScript metrics face for @edgeproc/avow, pinned to Python by shared vectors - #25
Open
hseshadr wants to merge 6 commits into
Open
A TypeScript metrics face for @edgeproc/avow, pinned to Python by shared vectors#25hseshadr wants to merge 6 commits into
hseshadr wants to merge 6 commits into
Conversation
…ared vectors `@edgeproc/avow` exported zero metric functions, so every TypeScript metric in the portfolio was hand-rolled by construction — there was nothing to adopt. aml-filter's release gate reads a recall number computed by 29 lines of bespoke arithmetic, and it is the only shipped recall figure anywhere in the portfolio. Ports recall@k, precision@k, F1@k, MRR and the binary confusion set (TP/FP/TN/FN, precision, recall, F1, FPR, FNR), mirroring `assay.ranking` / `assay.metrics` semantics exactly, refusals included, with the same `assay.*` error codes. Deliberately NOT ported: nDCG@k, average precision / MAP, PR-AUC and ROC-AUC. All four carry their engine's conventions rather than a textbook's, and a version written from the definition would print a number that looks like Python's and is not. Python remains their only implementation. `testdata/vectors/metrics.json` holds 22 hand-computed cases replayed by BOTH `tests/test_metric_vectors.py` and `ts/src/metricVectors.test.ts`, so a cross-language divergence fails CI in both languages. Unlike the byte vectors it is not generated: a generated metric vector is a transcript of whatever the code returns. The mutation harness now speaks vitest as well as pytest — 17 new mutations, 16 of them breaking `ts/src`. Its vitest verdict reads the JSON reporter's pass/fail counts, never the exit code: `vitest run -t 'no-such-test'` exits 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mNrrQ5dDEr6ZwLntu7XtF
test_packaging pins ts/package.json's version to avow.__version__ — one v* tag fans out to PyPI and npm, and bumping only one would push an already-published version to the other registry. The npm package gains the metrics face, so both move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mNrrQ5dDEr6ZwLntu7XtF
The README said 18 mutations while the harness carried 19, and nothing was counting — the same 'constant asserted only against itself' defect the file exists to catch. The new test loads the harness by path and pins len(MUTATIONS) to 36. Watched red run: removing one Mutation entry gives 'assert 35 == 36'. Also corrects the measured test/coverage figures, which had drifted alongside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mNrrQ5dDEr6ZwLntu7XtF
Running `poe mutants` repeatedly at an unchanged commit showed ranking-k-reaches-trec-eval scored SURVIVED on two of three runs. CPython invalidates bytecode on (source mtime, source SIZE), and three existing mutations are one character for one character — 'P @ k' -> 'P @ 1', 'R @ k' -> 'P @ k', 'AP)' -> 'RR)'. Size never changes, so a write inside the same mtime tick as the cached .pyc left the next interpreter loading unmutated bytecode. The guard ran against code that was never broken and was reported blind to a break it never saw. The restore path was worse: the .pyc written while the file was mutated could shadow the restored source, so the next mutation's baseline ran mutated code. Evidence: 1/3 runs clean before, 4/4 runs at 36/36 with zero anomalies after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mNrrQ5dDEr6ZwLntu7XtF
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
…thon and 0 here Found by probing the new face, not by a test. binaryJudgments accumulated into a plain object, and `plain["__proto__"] = 1` does not create a property — it invokes Object.prototype's __proto__ setter, which ignores a non-object value. The document silently vanished from the judgments. Python has no such rule and keeps the key, so the same input gave precision@1 of 1.0 server-side and 0.0 browser-side. No refusal in either language, no rounding difference — two confident, different answers. That is exactly the defect the shared vectors exist to prevent, in the PR that introduces them. The accumulator now has a null prototype. Pinned by the document_id_named_proto shared vector (both suites) and by a unit test on binaryJudgments itself, with a mutation that puts the plain object back. Watched red run: "expected [ 'ok' ] to deeply equal [ '__proto__', 'ok' ]". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mNrrQ5dDEr6ZwLntu7XtF
ts/README.md is the package front page on npm and still said 'ships the envelope only'. The sentence's real point is that there is no ledger in the browser, so it now says that instead. CLAUDE.md's ts/ entry gains the metrics face and the vector files that pin it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mNrrQ5dDEr6ZwLntu7XtF
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The claim this PR touches
"A metric computed in the browser is the number the server would have printed."
@edgeproc/avowexported zero metric functions. Every TypeScript metric in theportfolio was therefore hand-rolled by construction — there was nothing to adopt.
aml-filter's release gate reads a recall number produced by 29 lines of bespoke
arithmetic in its own repo, and it is the only shipped recall figure anywhere in the
portfolio. Nothing checked it against a reference.
What was ported
precisionAtK,recallAtK,f1AtK,mrr,binaryJudgmentsassay.rankingconfusionCounts(TP/FP/TN/FN),binaryRates(accuracy, precision, recall, F1, FPR, FNR),ratesFromCountsassay.metricsAssayError,InvalidRankingRequest,EmptyRelevantSet,InvalidScoreRequest— sameassay.*codesassay.errorsSemantics are mirrored exactly, refusals included: empty ranked list, duplicate
document, fractional or negative gain, non-positive
k, nothing judged relevant,length mismatch, empty input, single-class labels.
What was deliberately NOT ported, and why
trec_eval's, whose engine is aC++ binary with no npm binding. Both carry conventions — how the ideal ranking is built
over documents the ranker never returned, how graded gains collapse to binary, where
truncation applies — that belong to that implementation rather than to any textbook. A
version written from the definition would print a number that looks like Python's and
is not, which is worse than not shipping it.
tie-handling; they are the two metrics on
binary_scoresthat are not a ratio ofconfusion cells.
ranking_report, bootstrap intervals,Estimate. A seeded resampling procedurecannot be made bit-identical across languages.
Python remains the only implementation of all of these. Stated in the module docstrings
and in
ts/README.mdunder "What is deliberately missing".Kept anyway: the two-class refusal. It exists in Python because the AUCs are
undefined on one class, and this module has no AUCs — but dropping it would mean an input
that scores in the browser and refuses on the server, which is the divergence the contract
exists to prevent. It also earns its keep: with both classes present, recall, FPR and FNR
can never divide by zero.
Wrap, don't rebuild — the audit
No JavaScript package clears provenance + currency + canonicity:
ir_measures/trec_evalml-confusion-matrix(mljs)node-dcg@mukundakatta/ragmetric-mcp,@ongravy/agent-kit0.1.0packagesWrapping any of them would buy a dependency and no correctness. So these are written
against their definitions — which is safe only because of the vectors below.
Zero new dependencies are added by this PR.
The cross-language pin
testdata/vectors/metrics.jsonholds 23 hand-computed cases — 7 ranking, 7 rankingrefusals, 5 classification, 4 classification refusals — replayed by both
tests/test_metric_vectors.pyandts/src/metricVectors.test.ts. Python reaches itsanswers through
trec_evaland scikit-learn; TypeScript counts them out against thedefinitions. A divergence fails CI in both languages.
It is deliberately not generated.
canonical.jsonholds bytes nobody could author byhand, so a generator is the only way to write it. A generated metric vector is a
transcript of whatever the code currently returns — green through the exact bug it exists
to catch. Every number was computed from the definition (each case carries its arithmetic
in a
handfield) and then checked against Python.How the confusion cells are pinned without a Python confusion-count function. The
actual-positive count is countable straight off
y_true; scikit-learn's recall andaccuracy then determine all four cells uniquely. The Python replay re-derives them and
requires the result to equal the cells the TypeScript suite asserts.
One honest asymmetry, recorded in the vector itself: a non-binary label is refused in
both languages with different classes — TypeScript raises the coded
InvalidScoreRequest, Python lets scikit-learn raise an uncodedValueError. Theaccept/reject boundary is identical; that row pins the refusal and deliberately does not
pin a shared code it does not have.
The vectors immediately earned their keep: a real divergence, found and fixed
A document id of
__proto__scored 1.0 in Python and 0 in the browser.binaryJudgmentsaccumulated into a plain object, andplain["__proto__"] = 1does notcreate a property — it invokes
Object.prototype's__proto__setter, which ignores anon-object value. The document silently vanished from the judgments. Python has no such
rule and keeps the key.
No refusal fired in either language. No rounding difference. Two confident, different
answers — precisely the failure this PR exists to prevent, present in the PR that
introduces the prevention. Found by probing the new face, not by a test, which is the
honest way to say it.
Fixed with a null-prototype accumulator, and pinned three ways: the
document_id_named_protoshared vector (both suites), a unit test onbinaryJudgmentsitself, and the
ts-ranking-keeps-a-document-called-protomutation. Watched red run:expected [ 'ok' ] to deeply equal [ '__proto__', 'ok' ]. Both languages now print1.0.Northstar: watched red runs
uv run poe mutants— 37/37 guards fired, 18 of them new, 17 breakingts/srcundervitest. Every mutation is read back off disk before its verdict is trusted.
Two mutations exist purely to prove the cross-language pin bites — their only guard is
the shared-vector suite:
ts-cells-match-pythons-sklearnvector one_error_of_each_kind: the four confusion cells matchts-ranking-matches-pythons-trec-evalvector graded_gains_and_a_zero_gain_judgment: every ranking metric matchesBoth go red. The vectors are load-bearing, not decoration.
Vitest's exit code is not a verdict
vitest run -t 'no-such-test'exits 0, counting every test in the file as "total"while running none of them. Read by exit code, a guard that no longer exists reports a
green baseline. The runner therefore reads
numPassedTests/numFailedTestsfrom theJSON reporter, and a reporter that writes no file at all is a harness error rather than a
verdict.
Found on the way: the harness was scoring a guard on stale bytecode
Running
poe mutantsrepeatedly at an unchanged commit,ranking-k-reaches-trec-evalwas scored
SURVIVEDon two of three runs.CPython invalidates a
.pycon the source's mtime and size, and three existingmutations are one character for one character (
P @ k->P @ 1,R @ k->P @ k,AP)->RR)). The size never changes, so a write inside the same mtime tick as thecached
.pycleft the next interpreter loading unmutated bytecode — the guard ranagainst code that was never broken. The restore path was worse: the
.pycwritten duringa mutated run could shadow the restored source, so the next mutation's baseline ran
mutated code.
Both writes now drop the cached
.pyc. 1/3 runs clean before, 4/4 runs at 36/36 withzero anomalies after. This is the harness's own instance of the defect it exists to
catch: it reported a verdict it had not measured.
Also pinned
len(MUTATIONS)to the literal the README states — that number had alreadydrifted (README said 18, harness carried 19) and nothing was counting. Red run:
assert 35 == 36.Evidence
uv run poe gate— 228 passed, 100% statement + branch coverage (803 stmts, 68 branches, none missed)pnpm -r --include-workspace-root gate— 112 in@edgeproc/avow(100%: 121 stmts, 52 branches, 34 funcs), 31 in@edgeproc/receipt-ui(100%)uv run poe mutants— 37/37 fired, 0 anomalies; deterministic across repeated runsts/dist/index.jsas a consumer: all 23 vector cases replay, 11 refusals refuse with the right codes, and the README quickstart prints the exact numbers it documents (recallAtK0.5,mrr0.5,falseNegativeRate0.5)# hand:/// hand:comment or ahandfield; all were independently checked against Python before the vectors were writtenmutation-gategained a Node/pnpm toolchain; no threshold, floor or check was lowered. Zero new dependencies.Publishing — NOT done here
This PR does not publish.
ts/package.jsonandsrc/avow/_version.pyboth move to0.4.0 because
test_packagingpins them to each other: onev*tag fans out to PyPIand npm, and bumping only one would push an already-published version to the other
registry.
To publish after merge: tag
v0.4.0onmainand let.github/workflows/publish.ymlfan out. Verify per the house rule —
npm view @edgeproc/avow versionreturning0.4.0and PyPI serving
avow0.4.0 with provenance at/integrity. A green Publish run is notevidence; the registry serving the version is.
Note on PR #23
#23 was open and unmerged when this branched, so this is cut from
mainand does nottouch #23's branch. Both set version
0.4.0— #23 bumps it for the Python agreement face,this one for the npm metrics face; they are the same release. On rebase, keep
0.4.0andmerge the two CHANGELOG
Addedblocks. #23 also adds a PythonConfusionCounts; once itlands,
tests/test_metric_vectors.pycan assert the vector's cells against it directlyinstead of re-deriving them from scikit-learn's recall and accuracy.
🤖 Generated with Claude Code
https://claude.ai/code/session_019mNrrQ5dDEr6ZwLntu7XtF