feat(categorisation): bootstrap the classifier from the merchant dictionary - #11
Merged
Merged
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The pipeline reached the local classifier at stage 4 and the ISO 18245 merchant-category-code lookup only at stage 6, so a model guess outranked the banking data the transaction already carried, and the country keyword tables were not in the pipeline at all. Deterministic signals now resolve first: channel, user override, shared dictionary, then a new deterministic layer reading the merchant category code and the keyword tables for the transaction's country. The classifier sees only what those leave undecided, and below threshold the transaction stays uncategorised instead of being forced into a category. The classifier stays one global model: modelInput() joins the country code to the descriptor as its own token at both training and inference, so it learns country-specific patterns over one taxonomy.
- Chunk userIds alongside merchantKeys in loadOverrideCountries, grouping keys by their own user so a query binds at most one user chunk plus one key chunk. - Stamp INPUT_VERSION into model-weights.json and refuse a file trained on a different input representation, so the cc:<country> token cannot score against weights that never saw it; covered by two tests. - Name the channel stage in the budget doc's resolution order. - Mark ADR-001 section 1 superseded at the section, not just in Status.
buildDictionaryFromFile warns and returns an empty map when the artifact is missing, so a wrong DATA_DIR turns every stage-3 lookup into a silent miss. That had already happened once — the loader resolved to packages/data instead of packages/api/data — and nothing in the suite would have caught it. The fixture occupies the real artifact path, moving the build output aside for the duration, because resolving there is the property under test. A run that dies between the hooks leaves the real file in the backup, so beforeAll puts it back before swapping rather than renaming the fixture over it: the artifact is gitignored and costs a full build:data to regenerate. Also covers the countries contract the scope filter relies on: a merchant scoped elsewhere is dropped while an unscoped worldwide one survives.
…ionary ADR-001 shipped the classifier as a stub until training data exists, and named the k-anonymity contribution pipeline as the source. That endpoint does not exist, so the trainer read only MerchantOverride rows and hand-recategorised transactions — both empty on a fresh instance. The dictionary is the one labelled corpus already present: 32,556 distinct labelled strings over 20,510 merchants, from Wikidata via SIRENE NAF, NSI's OSM tags, and the curated supplement. It trains as a bootstrap prior at weight 1; corrections stay ground truth at weight 20 and take over as they accumulate. Nothing is synthesised. Country comes from each merchant's countries scope, reusing isInCountryScope so the trainer and the runtime loader agree on what worldwide means. A string binds to the first merchant that claims it and then expands over that merchant's whole country set, so it cannot straddle the merchant holdout once the build starts emitting scopes. The evaluation gates the write, at MODEL_ACCEPT_THRESHOLD — the 0.7 floor resolve.ts applies before it writes a category, not predict()'s 0.5 abstention threshold, since nothing between the two ever reaches a budget. That constant was an unnamed literal in resolve.ts and is now shared. The gate takes the worst country slice, because pooling would let a strong slice carry a weak one and which slice is worst is not stable across epoch counts. The result is negative and recorded in ADR-004 section 6: 43.4% precision on the worst slice against a 75% bar, no point on either curve reaching it, and more epochs making it worse. So no weights are written, the classifier stays inert, and transactions stay uncategorised and correctable. Category from an unseen brand name is close to unlearnable; the shortcut is now closed with a number. The holdout is drawn from the dictionary alone, so a missing artifact leaves nothing to score against and the trainer now refuses before training instead of writing an unevaluated corrections-only model. Section 4 records that the gate must be revised to hold out corrections before contributed data can ship one. Also makes training reproducible — seeded PRNG instead of Math.random(), and orderBy tiebreaks on the correction queries whose row order feeds the shuffle.
suiramdev
force-pushed
the
feat/dictionary-bootstrapped-classifier
branch
from
September 2, 2026 01:15
bfe793a to
fbf5567
Compare
suiramdev
changed the base branch from
feat/deterministic-first-categorisation
to
main
September 2, 2026 01:15
… release Nobody ran train:model and its output travelled nowhere. The data workflow now trains --dictionary-only against the dictionary it just built and adds model-weights.json to the data-* release only when the shipping gate passes. A refusal exits 2 and keeps the run green with the curves in the job summary; any other failure is red. build:data drops a local weights file before it does anything, so only a release ever installs one. Measured on data-2026-09-01 the worst slice reaches 72.4% at the 0.7 threshold against the 75% bar, versus 43.4% on the full local build ADR-004 section 6 scored. Section 7 records it as a second observation, not an ablation: the release's SIRENE pass categorised 308 Wikidata entries, the local build 9,463.
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.
Summary
ADR-001 left the classifier a stub "until training data exists", pointing at a k-anonymity contribution endpoint that does not exist yet — so on a fresh instance the trainer had nothing to read. This adds the merchant dictionary as a bootstrap prior (real, licensed, already in the data pipeline), gates the write on held-out precision at the confidence the pipeline actually writes a category at, and measures that the bootstrap fails: 43.4% on a full local build and 72.4% on the CI release
data-2026-09-01, against a 75% bar, so no weights are written and the classifier stays inert. The data workflow now runs that trainer and attaches weights to thedata-*release only when the gate passes. Readdocs/adr/004§6–7 first — the negative result is the deliverable.Motivation
resolve.tsdiscarded anything below0.7as an unnamed literal whilepredict()abstained at0.5; the trainer was measuring a population that never reaches a budget. That floor is now the sharedMODEL_ACCEPT_THRESHOLD.Math.random(), so the reproducibility ADR-001 claimed was never true.Drawbacks
train:modelexits non-zero whenever the gate refuses, which is every run today. Intentional — CI must not ship an unevaluated model — but it is not a green command until a model passes.Prior art
Notes
main, and since feat(categorisation): run deterministic layers before the AI classifier #9 is still open its two commits (feat: run deterministic layers…,Address PR review feedback) came along. The PR diff is 23 files vsmain; my change is the last two commits only — 8 files,git diff bfffdbf..HEAD. Merging this merges feat(categorisation): run deterministic layers before the AI classifier #9's work too. Review feat(categorisation): run deterministic layers before the AI classifier #9 there, or merge it first and this collapses to the two commits.0.7floor is unchanged in value, only named.dictionary.test.ts(4 cases, mutation-checked). It occupies the real artifact path, so it restores a leftover backup before swapping — the artifact is gitignored and costs a fullbuild:datato regenerate.docs/adr/004added,docs/adr/001cross-referenced,packages/api/data/README.mdupdated. Noapps/fumadocschange —budget.mdxalready says the classifier runs only where a trained model is installed, which stays true.generate-data.ymltrains--dictionary-onlyafter the dictionary build and addsmodel-weights.jsonto the release tarball only on a gate pass; a refusal (exit 2) keeps the run green with the curves in the job summary.build:datadeletes a local weights file before downloading, so only a release installs one. Not yet exercised on GitHub — it triggers onmainonly; dispatch it once after merge. Still open before a passing model ships: the sparse JSON serialisation is 42.9 MB gzipped, over ADR-001's budget, andmodel.tsverifies no signature (neither does the CI-built dictionary today — the workflow signs nothing).approved, empty findings, after three rounds. Every quantitative claim in the ADR and README was reproduced independently against the artifact.