AI Sales Intelligence

Sales Intelligence Platform

How 8.9 million internet-exposure scan records become a ranked, explainable prospect list — every stage, what it decides, and what it costs. Written for someone who needs the real picture, including the parts that don't work.


◉ Ingest

Stream zstd, flatten, project 50 columns to Parquet shards

8.9M records
→
⇄ Transform

dbt on DuckDB — entities, exclusions, signals, scoring

6 models · 48 tests
→
◆ Classify

One LLM call per unresolved entity — company or infrastructure?

3,000 classified
→
▣ Serve

Curated Parquet → Streamlit. No inference at request time

1,005 organisations
Measurement & Tracing 75-example eval set · per-call traces · 21-entry decision log — applies across every stage above

The pipeline as built, not as planned. Two stages were rebuilt after reading their output.

Where it actually ran

Deployment diagram: a DigitalOcean droplet holds the source, Parquet shards, DuckDB
    warehouse, classifier and evals, calling the Anthropic API; the 350 KB curated Parquet is
    scp'd to GitHub, which Streamlit Community Cloud clones on deploy.
Everything expensive ran on one disposable box. The API key never left it, and the serving layer has no connection to the processing layer — which is why destroying the droplet did not touch the live app.
8.9MSource records
252,078Distinct entities
1,005Confirmed organisations
0.765Precision, is-a-company
48dbt tests
$0.0037Cost per entity

What comes out

TierAccountsMeaning
A — call now818 Good fit, and something urgent right now
B — nurture150 Good fit, nothing urgent
R — needs review37 Looks like a company, below 0.60 confidence
X — not a prospect1,995 Hosting, CDN, ISP — the landlords

818 in tier A is 81% of confirmed organisations, which is too many to be a call list. The queue was already filtered to entities carrying a finding, so by scoring time almost everything left is urgent and the threshold stops separating anything. Documented rather than retuned — picking a threshold after seeing which rows it moves is the same error as tuning a prompt after reading its eval. See Known Limitations.

Start Here

◉
Ingest & Entity Resolution

74 GB decompressed onto a box with 64 GB free. And the field naming who owns each IP is 31% Google.

The hard part →
⇄
Transform & Scoring

Every deterministic decision in SQL. Fit and intent scored separately, then crossed into tiers.

See the logic →
◆
Classification

The LLM answers exactly one question. Why that one, and why rules cannot answer it.

The asymmetry →
↗
Evals & Measured Quality

75 hand-labelled entities. The eval overturned its own recommendation twice.

What it measures →
⚑
Known Limitations

What doesn't work, by layer — including a school that was silently dropped from the market.

Read the gaps →
⬈
Scaling to 10×

Two layers hold, two need restructuring, and DuckDB stops being the right tool.

At 89M records →
New here? Read the sidebar top to bottom — each page builds on the last. If you have ten minutes, read Ingest and Evals: the first explains why this problem is hard, the second explains how much to trust the answer.

Overview / Ingest & Entity Resolution

8.9M records · 50 columns

Ingest & Entity Resolution

The dataset describes machines. A sales team needs companies. Everything difficult about this project lives in that gap.


The dataset is not what the brief implied

Profiling the first 8 MB settled the product direction before any code was written. It is not company data — it is internet scan records, one row per exposed service on an IP address. That makes it intent data, not fit data: almost nothing about company size or industry, a great deal about what is broken right now.

The trap

The org field names whoever registered the IP block — not the company using it.

Google LLC         2,745,419   30.8%
Incapsula Inc      1,778,843   20.0%
Cloudflare, Inc.     302,449
Amazon.com, Inc.     287,584

Group by org and your top prospects are Google, Imperva and Amazon — worthless to a rep and instantly disqualifying. So the entity key is the registrable domain (eTLD+1) from reverse-DNS hostnames, with the TLS certificate CN as a secondary anchor. org is used only to match the infrastructure denylist.

Why it streams

The source is 12.36 GB compressed and ~74 GB decompressed — a 6.03× ratio — on a box with about 64 GB free. It does not fit. zstd -d fails outright, so decompression is incremental and the JSON is never materialised. Memory stays flat regardless of input size.

The design decision mattered more than the constraint. Ingest is a pure projection — no scoring, no filtering, no business logic. So the one expensive read produces a reusable artifact, and every later change to scoring reruns against Parquet in seconds instead of re-reading 74 GB.
12.36 GBCompressed source
~74 GBDecompressed
50Columns projected
73.5%Domain coverage

The IPv6 gap, and why it was left alone

Shodan fills ip_str for IPv4 and ipv6 for IPv6, leaving the other null. The ingest projects ip_str only — so IPv6-only services arrive with no address at all. That is 106,419 records, 1.19% of the source.

There is no "fix the data" option here — Shodan produced it. The only things under our control are which fields we project, and what we assert about them. So the first move was to measure what the gap actually costs:

81,94577% — no identity at all, already dropped
24,383Attribute and score normally
1.6%Of entities affected at all

Three quarters cost nothing: they have no hostname and no certificate CN either, so int_entity_hosts already discards them for lack of any identity. The rest work normally — because entity resolution keys on hostnames, not addresses. A host named mail.acme.com resolves to acme.com whether or not its address was captured.

Found by a test that was itself wrong — and the obvious fix would not have worked. A not_null test on ip failed with 106,419 rows. The tempting principled response is to capture the missing field and re-ingest. But ip is populated from ip_str alone, so adding an ipv6 column leaves ip null for exactly the same rows — the test fails identically, 40 minutes later.

not_null(ip) asserts that every exposed service has an IPv4 address. That is false about the internet, not about this pipeline, and nothing we control could make it true. Replaced with assert_hosts_are_identifiable: every host must have an address or a name. When a test fails, the first question is whether the assertion is true — not how to satisfy it.

What nearly went wrong

A truncated shard could have counted as complete. Shards were written straight to their final filename, and Parquet writes its footer last — so a crash mid-write would leave a truncated file that the resume glob counts as done, silently skipping 100,000 records. Nothing would have errored; the run would simply have finished 100,000 records short.

It never happened, because it surfaced by accident first: reading the output directory during a run raised No magic bytes found at end of file part-00006.parquet — which proved partial files really do appear under final names, contradicting a comment in the code asserting they could not. Fixed with a staged write to .part-NNNNN.inflight, which the glob ignores, and an atomic rename on completion.

Overview / Transform & Scoring

6 models · 48 tests

Transform & Scoring

Every deterministic decision lives in dbt on DuckDB — grouped by what it decides, not by table. These are set operations over columns; they need to be readable by someone who didn't write them, and tested as part of the build rather than by a suite someone remembers to invoke.


The models staging → intermediate → marts

SQL
stg_hostsCast and normalise only
SQL
int_entity_hostsGroup hosts into entities
SQL
int_entity_classificationRule-based exclusion
MART
company_signalsAggregate security findings
MART
company_scoresFit + intent, tiering
MART
llm_classification_queueWhat is worth a model call

Four of the 48 tests guard failures that would actually cost something: infrastructure reaching a rep's list, unclassified entities being treated as prospects, honeypots surviving into the entity table, and scores drifting outside their range. The rest are schema hygiene.

Five ways to spot infrastructure, not one list of names

125,037 of 252,078 entities are excluded here. A curated list of provider names cannot reach the long tail of regional hosts — there are thousands of them, and adding the ones visible in a sample would overfit to that sample. So four behavioural heuristics sit alongside the list, each catching a different shape of infrastructure.

reverse_dns_zone — definitional

The entity is a .in-addr.arpa zone. Not a company by construction; no judgement required.

seed_list — ~70 named providers

Matched against normalised org strings, because the source carries the same company under several spellings — Incapsula Inc and Incapsula Inc. are 1.8M and 62k records respectively.

cloud_tag_volume — scale + cloud

cloud_host_ratio ≥ 0.9 AND n_hosts ≥ 50. Catches providers absent from the seed list. Both numbers chosen by inspection, not tuned — the LLM tier absorbs the imprecision.

sequential_hostnames — machine naming

srv12., vps-104. — customer-premises equipment is machine-named throughout, while municipal and ministry estates are small and hand-named.

port_diversity — multi-tenancy

n_hosts ≥ 20 AND n_ports ≥ 20. A single company rarely exposes twenty distinct services across twenty hosts; a shared host does.

Everything else → the model

127,041 entities survive the rules. They are not "companies" — they are unresolved, and that distinction is the whole design.

Prefer an extra model call to a silent exclusion. One rule excludes entities whose hostnames look numbered — srv12., dsl-123-45. — because providers name machines by number. It fires when 80% or more of an entity's hosts look that way.

It used to fire at 60%. Two samples of 25 excluded entities turned up two mistakes: esteri.it, the Italian foreign ministry, at 64%, and hajnowka.pl, a Polish municipality, at 62%. Both score high innocently — ns1, mx2 and vpn3 all look numbered. Real ISPs sat at 79–100%, so the line moved into that gap.

An excluded company is never reviewed and never recovers; an extra classification costs a fraction of a cent. That difference sets the threshold.
🚩 The name-list matches the wrong field. It compares patterns against orgs_seen — the org values of an entity's hosts. But org names whoever owns the IP address block, not the company using it. So a company with one server on AWS matches the pattern amazon and is classified as infrastructure: silently removed, never reviewed.

On this data, 32,967 entities — of which only 583 would have been caught by any other rule. The other rules were already finding the real providers on their own.

Found by tracing the join, not by a test, because nothing checks what gets excluded. That missing check is the real finding; the one-line fix is sequenced behind it. See D18.

Two scores, deliberately not one

Intent — how urgently they need help: exploitation probability, critical CVEs, exposed datastores, remote access, end-of-life software, certificate state, TLS posture. Fit — how well they match the ICP: estate size band, whitespace, real estate, multi-market presence, security maturity.

Every component is kept as its own column, so the weights can be refitted against real outcomes later — and so a rep can disagree with a ranking by seeing what drove it.

Classification is checked before either score matters:

no verdict yet                 →  U - unclassified
verdict is infrastructure      →  X - not a prospect
company, confidence < 0.60     →  R - needs review

then, and only then:
  fit ≥ 50  and  urgency ≥ 50  →  A - call now
  fit ≥ 50  and  urgency < 50  →  B - nurture
  fit < 50  and  urgency ≥ 50  →  C - opportunistic
  otherwise                    →  D - deprioritise

Tier is assigned twice. dbt assigns a provisional one, where everything the rules could not resolve sits in U. The export reassigns it once the model has spoken — the only point R can exist, since it depends on a confidence score that does not exist until then. The shipped artifact has no U rows at all, because all 3,000 queued entities were classified.

Why EPSS, not CVSS or CVE counts

Every CVE in this dataset is version-inferred, never tested. Shodan matches a detected version against advisories; verified was false on 100% of 7,066 sampled CVE entries. So a host reporting 99 CVEs may have every one of them patched.

Both are published scores about the vulnerability, not about this host — so both survive the uncertainty. The difference is what they measure:

HostMax CVSSEPSSUrgency
Host A9.80.99999Being exploited now
Host B9.80.01225Effectively dormant

Identical severity, completely different urgency. A CVSS-ordered list treats them as equivalent. EPSS is a published probability about the vulnerability rather than an inference about this host, so it survives the same uncertainty.

Tier A filled with hosting providers. The first build's top 20 were Beget, Forpsi, startdedicated, vps-10, hostsila and the reverse-DNS zone 64.in-addr.arpa — all scoring 90 fit and 100 intent, because a multi-tenant estate accumulates every finding belonging to every tenant. The scores were correct; the inference drawn from them was not. The tiering logic read "rules found no evidence of infrastructure" as "confirmed company". No test caught it — the SQL was right and every schema test passed. Fixed by gating tiers on classification status.

Overview / Classification

6 classes · 3,000 classified

Classification

The LLM answers exactly one question: is this entity a real company, or is it infrastructure? Everything else in the pipeline is SQL.


Why a model is here at all the asymmetry

The boundary is set by an asymmetry, not by taste.

Rules can prove an entity is infrastructure — a seed match, a reverse-DNS zone, machine-named hosts across a large estate. Nothing can prove it is not. A regional hosting provider nobody has heard of looks exactly like a small business until someone reads the name.

So rules settle the provable half, and anything unresolved sits in U - unclassified and cannot reach a rep until the model adjudicates it.

end_customer_companyhosting_or_cloud cdn_or_security_vendorisp_telco government_or_educationunknown

How it runs

Tool-use against a fixed schema — never free text. Three retries with exponential backoff, concurrency 8, resume keyed on the (prompt version, model) pair, and a dry-run estimator that prices the work before spending and reports whether caching will engage.

Every call appends a trace. One real line, unedited:

{
  "task":              "entity_classification",
  "prompt_version":    "v4",
  "model":             "claude-sonnet-5",
  "subject":           "imshealth.com",
  "decision":          "end_customer_company",
  "confidence":        0.92,
  "input_tokens":      511,
  "output_tokens":     168,
  "cached_tokens":     6470,
  "cache_write_tokens": 0,
  "cost_usd":          0.003996,
  "latency_ms":        2422,
  "attempt":           1,
  "error":             null,
  "response": {
    "entity_class":   "end_customer_company",
    "canonical_name": "IQVIA",
    "confidence":     0.92,
    "reasoning":      "Org matches domain (IQVIA Holdings, formerly IMS Health);
                       coherent enterprise stack: OWA, IIS, Azure gateway."
  }
}

Three things that line proves. Caching is working — 6,470 tokens read from cache against 511 fresh, and zero written, so this call reused a prefix someone else's call paid for. The cost is computed, not estimated: 6,470 × $0.20 + 511 × $2.00 + 168 × $10.00 per million is $0.003996, exactly what the line records. And the verdict is auditable — the reasoning names the evidence, so a wrong call can be attributed to the model or to the input rather than guessed at.

One account, and all three thousand

The trace above is one call. Totalled across the shipped run only — v4 on Sonnet 5, the configuration that produced the current artifact:

$11.05Shipped run · 3,000 accounts
$0.00368Mean per account
94.2%Input tokens from cache
$1,105300,000 accounts at this rate

Every call hit the cache — 3,000 of 3,000, no retries. But the useful figure is 94.2%, the share of input tokens read from cache rather than billed fresh. The missing 5.8% is the per-account JSON, which differs on every call and so can never be cached. That is a structural ceiling, not a tuning target.

At 300,000 accounts the same work is $1,105 on-demand, or about $553 batched — the point where a budget guard stops being optional. Full economics on the Cost Model page.

The four prompt versions

The prompt is the instruction sheet sent with every call. It is identical for all 3,000 — only the entity's data changes. Every version is kept, and every verdict records which one produced it.

What it addedWords
v1The six classes and how to weigh evidence. The minimum. 541
v2Seven worked examples. "A wrong exclusion costs more than an extra call." Output capped at 20 words.1,223
v3Regional naming — .gov.pl and .ac.jp signal institutions. How to break ties. What each input field is worth. 2,297
v4Scan the organisation list for security-vendor names first. A recognisable name outweighs a small estate. Three more examples. 2,676

Two models, and why the prompt got longer on purpose

Haiku 4.5 is cheap, Sonnet 5 is stronger. Each prompt file names a default model, which the runner can override with --model.

Caching only engages if the unchanging part of the prompt clears a minimum size, and that minimum is different for each model and not in any sensible order:

ModelMinimum cacheable prefix
Opus 5512 tokens
Sonnet 51,024
Opus 4.72,048
Haiku 4.54,096

The floor measures the prefix, not the whole request:

request  =  [ tools schema + system prompt ]  +  [ entity JSON ]
            └──── cacheable prefix ────────┘     └ never cached ┘
            └──────────────── total input ──────────────────────┘

Which is why the same field means different things depending on whether caching worked. Three real trace lines:

ReadWriteNever cached
Which callv4 · Sonnetv3 · Sonnet, first of run v3 · Haiku
input_tokens
what was not served from cache
511
entity JSON
463
entity JSON
4,467
tools + system + JSON
cached_tokens
prefix read back
6,47000
cache_write_tokens
prefix stored
05,541—
Total input6,9816,0044,467
This model's floor1,0241,024 4,096
Prefix clears it?✓ 6,470 › 1,024 ✓ 5,541 › 1,024✗ under 4,096
cost_usd0.0039960.016299 0.005147

In the last column 4,467 > 4,096 is true and irrelevant — most of it is the entity JSON, which was never going to be cached. And the prefix there is never reported: cached_tokens and cache_write_tokens both describe cache activity, so when nothing caches they are zero and no field gives the prefix size. A sweep placed the real boundary between 3,875 (refused) and 4,203 (cached).

Note the cost row: Haiku costs more than Sonnet, paying full price on 4,467 tokens while Sonnet pays a tenth on 6,470 of its 6,981. v4 is v3 plus ~300 tokens of margin, which is why a longer prompt costs 2.5× less.

Shipped: v4 on Sonnet 5 — 6,470 cached tokens per call, $0.00368.

The funnel is the cost model see Cost Model

Naive — one call per source record         8,914,693 × ~2,000 tok  ≈ $17,800

Designed
  8,914,693 records
  →   252,078 entities        aggregation
  →   127,041 unresolved      rule tier removed 125,037
  →    43,577 queued          signal filter removed 83,464   ≈ $161

The bigger lever is the signal filter, not the rules. An entity with no security findings is not a prospect whether or not it is a real company, so classifying it buys nothing — and that filter is a WHERE clause on an aggregate, not a model call.

A school was silently dropped from the market. entity_class was typed str; the permitted values lived only in the tool schema's enum, which is advisory without strict: true. One call in 3,000 returned education instead of government_or_education — a Brazilian school, correctly named, at confidence 0.9 — and the tier logic matches class names exactly, so it fell through to X - not a prospect with nothing recording it. Fixed at both layers: entity_class is now a Literal that raises and retries, and the export refuses to run on any unrecognised class rather than bucketing it.

Overview / Serving & App

1,005 organisations

Serving & App

A curated Parquet and a Streamlit app that performs no inference and holds no credentials.


The boundary between pipeline and product

Three things happen in the export and nowhere else. Classification verdicts are merged in, so tiers can finally be assigned. Raw IP addresses are dropped — a rep needs the company, the finding and the reason to call, never an address. And per-call telemetry is joined on, so the app can show the real trace of the decision.

The export filters to exactly one (prompt version, model) configuration: the one the eval measured.

Three questions per account

QuestionAnswered by
Why this accountFit score, with every scoring component shown
Why nowFindings, each tagged with its evidence class
What do I sayDrafted outreach — cold email, InMail, or call script

The evidence distinction

Observed — seen directly in the scan. Tested — confirmed by an active probe. Inferred — implied by a version banner; the software is confirmed, the vulnerability is not. Collapsing those is how a rep ends up asserting a vulnerability that was never confirmed.

A generated email made a false technical claim. The draft asserted "you are running Apache httpd, a version associated with CVE-2015-0235". CVE-2015-0235 is GHOST — a glibc bug, not an Apache one. Both values came from arg_max(..., top_cve_epss) but were picked independently, so they can come from different services on the same host. Every value was correct; the sentence was not. Caught by reading three generated openers aloud. The opener no longer asserts the product alongside the CVE — it cites the finding, states that it is inferred, and asks the recipient to confirm.
The outreach is templated, not generated. An LLM would write better prose, but the binding constraint is accuracy: every claim must trace to an observed fact, and hedging on version-inferred CVEs must be exact. Template assembly guarantees that; generation would need its own eval to guarantee it. Logged as the next increment, not skipped by accident.

Overview / Evals

75 labels · 5 configurations

Evals & Measured Quality

75 hand-labelled entities, two batches, so a prompt tuned on the first is scored on the second. The most useful thing this eval did was prove itself wrong — twice.


Shipped: v4 on Sonnet 5 current

ConfigAccuracyPrecision Precision
held-out
Recall Recall
held-out
$/call
v4 · Sonnet 50.7730.765 0.7270.7220.667 $0.00368
v3 · Sonnet 50.6670.615 0.6670.4440.500 $0.00378
v2 · Haiku 4.50.6530.588 0.5000.5560.500 $0.00341
v4 · Haiku 4.50.6670.524 0.4670.6110.583 $0.00205
v3 · Haiku 4.50.6270.471 0.3850.4440.417 $0.00518

What Precision, Recall and Held-out mean here

Take eight entities, five of them really companies. The model calls four of them companies — three correctly, plus one hosting provider it gets wrong — and misses two companies entirely, returning unknown. Two numbers come out of that, and they count different things:

Precision  look only at what the model CALLED a company
           said "company" 4 times, 3 were right          3/4 = 0.750

Recall     look only at the companies that really exist
           5 exist, it found 3                           3/5 = 0.600

Precision is the quality of the list you hand a salesperson — one name in four is wrong. Recall is how much of the market you found — two real prospects exist that nobody will ever call. A model that says "company" once and is right scores 1.000 precision and 0.200 recall: a perfect list with four prospects missing.

Precision on end_customer_company governs deployment — not accuracy. A false positive puts a hosting provider in a rep's call list; they phone a datacenter about its customers' vulnerabilities and never trust the tool again. A false negative removes a company from the market invisibly — costly, but recoverable.

Held-out — the same number, measured on entities the prompt never saw

The 75 labels were made in two batches, and the order matters:

1. labelled 25 entities
2. ran v3 on them, read which ones it got wrong
3. wrote v4 specifically to fix those failures
4. labelled 50 more
5. scored v4 on all 75
Precision on all 75      0.765   <- includes the 25 it was built from
Precision on the 50      0.727   <- never seen before

Held-out is the 0.727. A student who studies the practice paper will ace those questions; their score on the new ones is the real measure. That is why the lower number is the one quoted, and why the batch is recorded in evals/labelled_set.jsonl rather than merged in — so a later prompt can always be scored on data that postdates it.

The eval was wrong twice, for two different reasons

Both are called reversals, but they are not the same failure:

Reversal oneReversal two
What went wrongthe measurement was unreliable the measurement was incomplete
Causetoo few examples a cache fix that also changed the prompt
Fixmore labelsmeasure it on the model that ships

A bigger eval set fixes the first and does nothing for the second. You can have a perfectly reliable eval and still ship the wrong thing, because a change made for one reason turned out to do something else as well.

Reversal one — same configurations, more examples

Three combinations, scored twice: first on 25 labels, then on 75.

Configat 25 labelsat 75 labels
v3 · Haiku0.750 — best 0.385 — worst
v2 · Haiku0.6670.500
v3 · Sonnet0.500 — worst 0.667 — best

First became last, last became first. Nothing changed except how many examples were measured — so the original ranking was never real. It was noise reported to three decimal places, with a plausible story attached: that Sonnet "commits where Haiku hedges", and decisiveness is the wrong disposition when a wrong commitment reaches a salesperson.

The caveat was already written, before the reversal — that support of 6 made one row worth 0.25 of precision, and that only one comparison was defensible. It turned out to be the only reliable line in that table. That is the argument for writing a limitation down when you notice it rather than after it embarrasses you.

Reversal two — v4 turned out to be more than a cache fix

v3's cacheable prefix sat just under Haiku's 4,096-token floor, so caching silently failed there. v4 is v3 with enough margin to clear it, and it was evaluated on Haiku because Haiku is where the problem was. It worked — cost fell 3.3×.

But clearing the floor meant adding content, and that content was three real changes: scan the organisation list for security-vendor names, a recognisable organisation name outweighs a small estate, and three more worked examples. Those are improvements to the prompt, not padding — so they were worth measuring on Sonnet, the model that had already won on quality. $0.28, and it changed what ships.

Held-out precision across the grid:

                 Haiku 4.5        Sonnet 5
     v2            0.500              ·
     v3            0.385            0.667
     v4            0.467            0.727   <- best of all five

Recall decided it, not precision. The held-out precision gain of 0.060 is less than one row and sits inside the measured noise. Held-out recall moved 0.500 → 0.667 — two more real companies out of twelve — with the same number of false positives, three each. Judging on precision alone would have called it a tie and shipped the worse configuration.

Across all 75 labels the same gap reads larger — recall 0.444 → 0.722, five more companies — but that set includes the 25 entities v4 was tuned against, so the held-out figure is the one quoted. v4 · Sonnet leads both held-out columns and no other configuration leads either, by about two rows each. A real margin, not a wide one.

Both times the recommendation changed, production was re-run rather than leaving a conclusion the evidence no longer supported.

The noise floor underneath all of it

The same 3,000 entities were classified twice under an identical configuration — same prompt version, same model, nothing changed between the runs. Why that happened, and what it cost, is on the Cost Model page. Any difference between the two is therefore the model disagreeing with itself, and it can be counted:

95.5%Class agreement
136Class flips
96Tier changes
0.023Mean confidence move
Held-out support is 12, so one row moves precision by 0.083 — and the gap between the best and worst configuration is a few rows, the same order as a model's disagreement with itself. The honest claim is "best measured so far", not "best".

Overview / Cost Model

$0.00368 per entity

Cost Model

Measured from traces, not derived from assumed token counts.


The rates, so every figure here can be checked

ModelInput / MTokOutput / MTokCache read
Sonnet 5$2.00$10.00$0.20
Haiku 4.5$1.00$5.00$0.10
(6,470 cached × $0.20  +  400 fresh × $2.00  +  159 out × $10.00) / 1e6  =  $0.00368

That reconciles with the traces to five decimal places. Note where the money goes: output is 43% of per-call cost on 159 tokens, while 6,470 cached input tokens are 35%. That is why the reasoning field is capped at twenty words.

Per full refresh

Configuration43,577 entities
v3 · Haiku — prefix below the cache floor$220
v4 · Sonnet — as run$161
v4 · Haiku, batched$45
v4 · Sonnet, Batch API~$80

Caching, not model choice, is the dominant term. The shipped Sonnet configuration is cheaper per call than uncached Haiku, despite triple the list price.

What it actually cost: $34.46

Every figure above is a projection for a full 43,577-entity refresh, which was never run. The experiment classified 3,000 entities, three times. Summed from traces/entity_classification.jsonl and traces/eval.jsonl:

pilots, 25 entities each    v1 / v2 / v3 Haiku, v3 Sonnet      $0.39
v3 Sonnet, 2,975 entities   first production run              $10.49
v3 Sonnet, 3,000 + 837      the same work, billed again       $10.69
v4 Sonnet, 3,000 entities   shipped configuration             $11.05
evals, 5 configurations     75 labels each                     $1.84
                                                             ------
                                                             $34.46

Nearly a third of the total classified entities that were already classified. The duplicate run cost almost exactly what the shipped run cost — $10.69 against $11.05 — which is what a broken resume key buys you.

Batching would have halved every line above — about $16 — which is the honest size of that gap, not the $81 a reader might infer from the projection table.

Caching failed silently, and four diagnoses all failed the same way

The minimum cacheable prefix is model-dependent and non-monotonic — Haiku 4.5 needs 4,096 tokens where Opus 5 needs 512. Below the floor nothing caches, the request still succeeds, and the cache fields just return zero.

Two compounding errors. The floor applies to the prefix rather than the whole request — the anatomy is on the Classification page. And every attempt derived the prefix instead of observing it: from chars-per-token, then by subtracting an estimated message size from an observed total, then from a purpose-built script that did the same subtraction in tokens. That script over-reported by ~315 tokens — enough to report v3's prefix as 86 above a floor it was ~230 below.

Each estimate was within about 5%. Against a gradient, a 5% error means a 5% wrong answer. Against a hard cutoff it puts you confidently on the wrong side. The fix was one real API call reading cache_creation_input_tokens back — a fraction of a cent, two minutes. Which is why a longer prompt costs 2.5× less.
The production run was billed twice. The first Sonnet run wrote results before --model was recorded, so those rows carry model: null. The rerun's resume key is the (prompt_version, model) pair — correctly — which matched none of them, so all 3,000 were classified again. A key change is a migration: rows written under the old key are invisible to the new one, and "resume" silently becomes "restart". It cost $10.69 and produced the self-consistency measurement.

Overview / Known Limitations

36 live entries

Known Limitations

Eight limitations change how you should read the output. After them, the same pipeline checked against the standard LLM failure modes. The full list is in the repo, organised by layer — 36 live entries, plus bugs already fixed, kept because the same kind of mistake can happen again.


LimitationWhy it matters
Every CVE is version-inferred, never tested verified was false on 100% of 7,066 sampled entries. Scoring leads on EPSS instead, and outreach hedges accordingly.
Tier A holds 818 of 1,005 accounts 81%. The queue is pre-filtered to entities with a finding, so urgency stops discriminating. Now a slider in the app rather than a hardcoded 50 — urgency at 60 splits 438/530, and the fit axis turns out to be inert: no classified account scores below 65.
The model disagrees with itself on 4.5% of entities Measured across two identical runs of 3,000. It is the noise floor under every eval number here.
75 examples, one labeller, no adjudication Support of 18 overall, 12 held-out. At least two labels are probably wrong — 3cx.ae is a PBX software vendor labelled isp_telco.
Classification precision is 0.765, recall 0.722 0.727 and 0.667 on the held-out batch. Four false positives out of 75 would have reached a rep's call list, and two of the four are probably mislabelled.
Entity resolution is a best guess Shortest-domain-wins picks the anchor, ~26% of records have no resolvable domain, and host count is a poor size proxy.
Only 3,000 of 43,577 queued entities are classified Budget. Everything below the cut stays U - unclassified and never reaches a rep.
The addressable market is understated A company with one server at a cloud provider matches that provider's name and is silently excluded — 32,967 entities here. Nothing validates exclusions, which is why it went unnoticed.

Audited against the standard LLM failure modes

This is not an agent. It is a stateless single-turn classifier — one request per entity, no conversation, no tool-selection loop — so three of these cannot occur by construction. Claiming coverage there would be false.

#Failure modeCoverageWhere
1Context overflow Partial — cost, not truncation render() caps list fields at 12; max_tokens=400
2Infinite loop N/A — bounded retries only MAX_ATTEMPTS = 3, exponential backoff
3Tool hallucinationFull forced tool_choice, Pydantic, Literal enum
4Memory driftN/A — stateless no turns to drift across
5Silent wrong answer Partial — threshold, no calibration MIN_CONFIDENCE = 0.60 routes to tier R
6Cost explosion Partial — pre-flight only, no runtime guard --dry-run prices a run; nothing halts one mid-flight
7Observability blindnessFull tracing.py, append-only per-call JSONL

Cost explosion is the one real gap. --dry-run prices the work before a single call is made, which is what makes a token budget a decision rather than a discovery — but nothing checks accumulated spend while a run is in flight. The one time a run needed halting, the provider's credit limit did it rather than the pipeline. What that costs at ten times the volume is on the Scaling page.

The two marked Full are the two that paid off. Schema-forced output caught a class the enum alone would have let through, and per-call tracing is why the 4.5% self-disagreement rate below could be measured at all rather than assumed.

The pattern worth naming

Three of the four bugs that mattered were found by reading output, not by running tests. Hosting providers in tier A, an IP address as a company, an email blaming a web server for a glibc vulnerability. Schema tests verify that data has the right shape. They cannot tell you the shape is describing the wrong thing.

What I would do differently

#Change
1Run the queue on the Batch API. 50% of standard rates with caching still applied — ~$80 a refresh against $161. Classification is the textbook workload. The architecture document asserted batch while the code only did the synchronous loop; a design that exists only in the prose is not a design.
2More evidence, not better prompting. The consistent failure is single-host entities with unfamiliar names. The model gets one host, a product string and an org name belonging to someone else. Page titles or WHOIS would fix most of it; no amount of prompt work will.
3150+ examples and a second labeller, with disagreements adjudicated.
4Repeated-run confidence intervals in the harness. We measured self-consistency by accident; the tooling should report an interval, not a point.
5Set tier thresholds from the distribution — a percentile cut decided in advance. Not retuned after the fact, because adjusting a threshold once you have seen which rows it moves is the same error as tuning a prompt after seeing its eval.
6Carry product and CVE as one struct through aggregation, so the Apache/glibc class of error becomes impossible rather than reworded around.

Overview / Scaling to 10×

89M records · ~740 GB

Scaling to 10×

89 million records, ~740 GB decompressed. Two layers already hold, two need restructuring, one changes character entirely — and the real bottleneck is accuracy, not compute.


LayerAt 10×
Ingest Design holds, parallelism doesn't. Memory is already flat. But a single-threaded parse turns 40 minutes into ~7 hours. Have the producer write many smaller zstd files and run one worker per file — embarrassingly parallel. The filesystem-glob resume state also needs a manifest with per-shard row counts.
Storage & compute DuckDB stops being the right tool. Correct for this exercise, wrong at 10×: it is single-node, and ~150–200 GB of Parquet will not aggregate comfortably in memory. Same models, different engine — Snowflake or Spark with dbt on top. Putting the business logic in dbt is what makes that migration cheap: swapping the adapter changes the warehouse, not the logic. Partition by country and scan date; make the entity and signal models incremental on scanned_at.
Entity resolution The real bottleneck — and it is about accuracy, not speed. The compute scales fine; the problem does not. 26% with no resolvable domain becomes ~650,000 unattributed hosts, wrong anchors multiply in absolute terms, and cross-TLD fragmentation grows. At this size I would stop treating it as string manipulation: ASN-to-organisation reference data to attribute domainless hosts, plus blocking-and-similarity matching to merge a company across TLDs. That is a real entity-resolution system rather than a public-suffix lookup, and it is where the engineering budget should go.
LLM layer Economics change, architecture mostly holds. 43,577 queued becomes ~435,000 — ~$1,600 on-demand, ~$800 batched. Batch becomes non-negotiable; add two-tier routing with Haiku for clear cases and Sonnet for ambiguous ones (measured, not assumed — this project is a case study in rankings inverting on more data); use the 1-hour cache TTL, since batch scheduling can outlast the 5-minute default. The signal filter matters more, not less — it already prevents 83,464 calls.
Serving Changes character completely. A 350 KB Parquet in git is right for a demo and absurd at ~1 million prospect rows. This becomes a served query layer — Postgres or DuckDB-over-object-storage behind an API, paginating server-side. And the app needs auth, which it does not have today.

The things that get worse faster than linearly

The eval set. 75 examples is already small. At 10× the population is more diverse — more countries, more regional providers — so a fixed-size eval covers proportionally less. It needs stratified sampling by region and estate size, and re-labelling as the data drifts.

Prompt drift. A prompt tuned on this snapshot degrades as the population shifts. At recurring 10× scale the eval belongs in CI, scoring every prompt change against a held-out set automatically.

The seed list. ~70 hand-curated patterns covering what was visible in this snapshot, with nothing refreshing it. Derive it from ASN ownership data instead of string matching.

Cost monitoring becomes mandatory. At $161 a refresh you can eyeball it; at $1,600 you need a budget guard that halts the run. The degradation path is documented — fall back to rules-only tiering, queue the remainder, alert — but it is not implemented.