Backtest plan: what could we have predicted at arrival, and what happened?

Rendered from docs/backtest-plan.md at build time.

Agreed 2026-09-27. Status is tracked in the checkboxes; findings go at the bottom.

The question

For every email that reached billing@alpha.school in the window, using only what a production system could have seen at the moment it arrived, how well could we have predicted what the AP team and QuickBooks eventually recorded — and which observable factors carry the signal?

Decisions

The as-of rule

A feature is admissible only if it was observable when the message arrived. Everything else is an outcome.

Admissible (features) Outcome (ground truth)
Headers: From, To/Cc (billing@ in To vs only Cc), Reply-To, Date, In-Reply-To, References, thread id Gmail labels the AP team applied later: Vendor/<name>, entity labels (Alpha 33155, …), state labels (Approved, Posted, …)
Subject, body, attachment names/types/sizes, attachment text (pdftotext / OCR) The QuickBooks bill: company (= campus), class, account(s), vendor, amount, DocNumber, date entered
Origin category (src/mail_origin.py), extracted invoice numbers, amounts, ZIPs, campus names Whether any bill was created (invoice vs. not-AP traffic)
QuickBooks history as of the arrival date: this vendor's prior bills (which companies, which accounts, typical amounts), whether the vendor exists in each company's vendor list, prior messages in the same thread Time-to-post, rejections, cancellations (later)

Two consequences worth stating plainly:

  1. DocNumber lookup is only legitimate for follow-ups. For a first-arrival invoice the bill does not exist yet. Every message gets a flag, bill_existed_at_arrival, from the bill's MetaData.CreateTime. Results are reported separately for new invoice (the hard, valuable case) and follow-up on a known bill (mostly solvable by lookup).
  2. Gmail labels are outcomes, not features — including the vendor label, even though a production system could read labels on older messages in the same thread. Thread history before the message is admissible; the message's own labels are not.

Targets, in order of business value

  1. Is this an invoice / bill request at all? Binary. Negatives from the labels: Tuition and other Fees Queries, Trilogy Query, Newsletter/*, Account Access, Reports, … and anything never labelled AP.
  2. Which company / campus? 73-way + not_campus_specific. Truth: the bill's company; the entity label as a cross-check.
  3. Which vendor? Open set. Truth: the bill's VendorRef and the Vendor/<name> label.
  4. Which account? ~90-way within a company. Truth: bill line AccountRef.
  5. Class: nearly deterministic per company; report but don't optimise.

Dataset construction (scripts/build_backtest.py)

Baselines, cheapest first

  1. Majority class (not_campus_specific; most common account).
  2. Vendor → company prior from history alone (the per-company vendor lists in records/qbo_discovery/*/vendors.csv, restricted to bills before the message).
  3. Rules: origin category + extracted ZIP / campus name / entity name.
  4. Jev on text (v2/v3 as they stand).
  5. Jev + the history features in the input.
  6. sklearn (src/classifier.py, TF-IDF + logistic) on (vendor, description, amount, entity) → account, trained on the pre-split bills.

Feature design (2026-09-27)

The cascade reached 85% with three free signals; the remaining 15% is a feature-design problem. Three tiers, each admissible at arrival:

Tier 1 — structural (rules, exact, free). Sender domain and whether it is internal / an invoicing platform; origin category; forwarded-original sender; billing@ in To vs Cc; reply/forward; attachment count, types, PDF present; position in thread; weekday/hour.

Tier 2 — extracted from text (regex now, LLM later). Invoice numbers; amounts; ZIPs and which company each maps to; campus / entity names and their distinctive tokens; where in the text they occur — a ZIP in a "Bill To" / "Ship To" block is the campus, a ZIP in a signature or letterhead is the vendor (this is the "vendor address mistaken for campus" failure); service-location phrases ("at the Miami campus", "for Brownsville"); "PO", "quote", "estimate", "statement", "past due" cues that separate invoices from other paper. An LLM extractor makes these robust: one structured call per message returning {is_invoice, vendor_name, invoice_number, invoice_date, amount, bill_to_name, bill_to_address, bill_to_zip, service_location, service_description, document_type}. Cached per message id, so it costs once. Needs an Anthropic API key (the local gateway fronts the Claude Code subscription, which is not for bulk API work).

Tier 3 — history as of arrival (the strongest, and only a production system with QuickBooks access has it). For the sender domain, the forwarded-original domain, and the extracted vendor name: distribution of companies and accounts on earlier bills; whether the vendor exists in each company's vendor list; typical amount; days since last bill. For the thread: company already established. For the extracted invoice number: whether a bill with that DocNumber already exists (follow-ups only).

Interactions worth a model rather than a cascade: a domain that serves several campuses plus a ZIP; a platform sender plus the vendor name in the subject; an internal forwarder plus the original vendor's history.

Later experiment: Jev vs. an explainable model

Jev returns a label and a confidence and nothing else. A traditional model on the same admissible features — TF-IDF over subject/body/attachment text, one-hot sender domain and origin category, the as-of vendor→company and vendor→account priors, amount — gives, per prediction, which features drove it (logistic-regression coefficients, or per-row SHAP on a gradient-boosted model). That is the only route to answering "what are the helpful predictors" directly rather than by ablation. Worth doing once the backtest table exists, because both models then score on exactly the same rows: same targets, same split, same metrics. Expected shape of the result: Jev wins on messy text (forwards, OCR'd invoices), the explainable model wins wherever the history prior decides it — and the explanation tells us which cases to route to which.

Metrics

Steps

Findings

Ground truth (2026-09-27)

No-model baselines, company (campus), test split = September (2026-09-27)

454 linked test messages (202 new-invoice, 252 follow-up).

baseline coverage accuracy new-invoice acc. follow-up acc.
majority (Miami) 100% 42% 24% 56%
sender-domain history 86% 65% 52% 74%
thread already linked 54% 96% 95% 97%
ZIP / campus name in text 18% 80% 90% 72%
thread → rules → history 95% 85% 80% 89%

By origin, the combined baseline: employee forward 94% (n=185, 100% coverage), vendor reply 97%, vendor platform 74%, vendor direct 73%, employee thread 100% at 53% coverage.

Reading: a thread that has already produced a bill almost never changes company, so the thread is the best single feature; the sender's domain is weak on its own because vendors serve several campuses; explicit ZIPs and campus names are precise but rare. The three together, with no model, route 95% of linked messages at 85% accuracy — that is the bar Jev has to clear on the same rows.

Jev v3 on the same 454 test messages (2026-09-27)

classify_locations_jev.py run over exactly the linked September messages (records/datasets/backtest_jev/v3_test.csv; the run record moved out of classification_runs/ so the site's "latest run" stays the INBOX slice). Scored with the same rules: an answer only when confidence ≥ 0.7 and the label is a specific company.

coverage accuracy new-invoice follow-up employee fwd vendor direct vendor platform
thread → rules → history 95% 85% 80% 89% 94% 73% 74%
Jev v3 (confident, specific) 52% 45% 66% 28% 37% 67% 43%
thread → rules → Jev → history 97% 80% 81% 80% 94% 67% 62%

Jev's confident errors, by shape:

Caveat: the truth set only contains messages that became bills in the seven authorized companies, so Jev's "not specific" answers can't be credited here even when a message genuinely had no campus — but for the target population (invoices), that is the right test.

Consequences for the next classifier version, in order of expected gain: 1. Thread and history cascade first; call the model only for what's left. 2. Choice set = companies with bills (plus a per-company vendor list as a prior); drop not_campus_specific for bill-like messages. 3. Strip vendor letterhead/footer addresses before the model sees the text, or pass the vendor's known HQ so it can discount it. 4. Then the explainable-model comparison on the same rows.