Skip to content

feat(categorisation): bootstrap the classifier from the merchant dictionary - #11

Merged
suiramdev merged 6 commits into
mainfrom
feat/dictionary-bootstrapped-classifier
Sep 2, 2026
Merged

suiramdev merged 6 commits into
mainfrom
feat/dictionary-bootstrapped-classifier

Conversation

@suiramdev

@suiramdev suiramdev commented Sep 1, 2026

Copy link
Copy Markdown
Owner

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 the data-* release only when the gate passes. Read docs/adr/004 §6–7 first — the negative result is the deliverable.

Motivation

  • The obvious shortcut — synthesising plausible bank descriptors and fitting to them — would write confident wrong categories into budgets and suppress the corrections that are the only real training signal. §6 closes it with a number instead of an argument.
  • Nothing gated model quality: a bad run would have shipped silently.
  • The gate needed the right operating point. resolve.ts discarded anything below 0.7 as an unnamed literal while predict() abstained at 0.5; the trainer was measuring a population that never reaches a budget. That floor is now the shared MODEL_ACCEPT_THRESHOLD.
  • Training used Math.random(), so the reproducibility ADR-001 claimed was never true.

Drawbacks

  • train:model exits 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.
  • A missing dictionary artifact now refuses instead of writing a corrections-only model. That is a deliberate capability regression: such a model cannot be evaluated, so it must not ship.
  • The holdout is dictionary-only, so contributed corrections cannot clear this gate by arriving; §4 records that the gate must be revised to hold them out first. Not built, because zero corrections exist anywhere yet.
  • Reported precision covers dictionary merchant names, not real bank descriptors. A floor, not a production figure.
  • Half the local build's corpus is Wikidata categorised by SIRENE name-matching; the CI release has almost none of it and scores 29 points higher. ADR §7 lists the confounds — it is two observations, not an ablation.

Prior art

  • ADR-001 §4 already builds the dictionary from open data (NSI BSD-3-Clause, Wikidata CC0, SIRENE); this reuses that provenance discipline rather than inventing a source.
  • Rejected: a random split over strings, which scores aliases of merchants the model trained on and measures memorisation.
  • Rejected: pooling country slices into one precision figure, which lets a strong slice carry a weak one — and which slice is weakest is not stable across epoch counts.

Notes

  • This branch carries feat(categorisation): run deterministic layers before the AI classifier #9. It was rebased onto 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 vs main; 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.
  • No visual change; no user-facing behaviour changes. The 0.7 floor is unchanged in value, only named.
  • Tests: new 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 full build:data to regenerate.
  • Docs: docs/adr/004 added, docs/adr/001 cross-referenced, packages/api/data/README.md updated. No apps/fumadocs change — budget.mdx already says the classifier runs only where a trained model is installed, which stays true.
  • No artifacts committed. generate-data.yml trains --dictionary-only after the dictionary build and adds model-weights.json to the release tarball only on a gate pass; a refusal (exit 2) keeps the run green with the curves in the job summary. build:data deletes a local weights file before downloading, so only a release installs one. Not yet exercised on GitHub — it triggers on main only; 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, and model.ts verifies no signature (neither does the CI-built dictionary today — the workflow signs nothing).
  • Reviewed via the review gate: approved, empty findings, after three rounds. Every quantitative claim in the ADR and README was reproduced independently against the artifact.
  • Verification gap: Docker wedged on my machine partway through, so the 7 Prisma-backed tests could not be re-run at the final commit. They passed 215/215 earlier in the session; 35 DB-free tests pass now, and no Prisma-touching code changed.

@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 440d54cc-dd58-4dc4-b34e-96106c68ca72


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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
suiramdev force-pushed the feat/dictionary-bootstrapped-classifier branch from bfe793a to fbf5567 Compare September 2, 2026 01:15
@suiramdev
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.
@suiramdev
suiramdev merged commit efd85da into main Sep 2, 2026
2 checks passed
@suiramdev
suiramdev deleted the feat/dictionary-bootstrapped-classifier branch September 2, 2026 09:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant