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
⇄ Transform
dbt on DuckDB — entities, exclusions, signals, scoring
◆ Classify
One LLM call per unresolved entity — company or infrastructure?
▣ Serve
Curated Parquet → Streamlit. No inference at request time
The pipeline as built, not as planned. Two stages were rebuilt after reading their output.
Where it actually ran
What comes out
| Tier | Accounts | Meaning |
|---|---|---|
| A — call now | 818 | Good fit, and something urgent right now |
| B — nurture | 150 | Good fit, nothing urgent |
| R — needs review | 37 | Looks like a company, below 0.60 confidence |
| X — not a prospect | 1,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 →Overview / Ingest & Entity Resolution
8.9M records · 50 columnsIngest & 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 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:
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.
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
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 testsTransform & 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
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.
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.
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:
- CVSS — how bad it would be if exploited
- EPSS — how likely it is being exploited, right now, in the wild
| Host | Max CVSS | EPSS | Urgency |
|---|---|---|---|
| Host A | 9.8 | 0.99999 | Being exploited now |
| Host B | 9.8 | 0.01225 | Effectively 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.
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 classifiedClassification
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.
So rules settle the provable half, and anything unresolved sits in
U - unclassified and cannot reach a rep until the model adjudicates it.
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:
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 added | Words | |
|---|---|---|
| v1 | The six classes and how to weigh evidence. The minimum. | 541 |
| v2 | Seven worked examples. "A wrong exclusion costs more than an extra call." Output capped at 20 words. | 1,223 |
| v3 | Regional naming — .gov.pl and .ac.jp
signal institutions. How to break ties. What each input field is worth. |
2,297 |
| v4 | Scan 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:
| Model | Minimum cacheable prefix |
|---|---|
| Opus 5 | 512 tokens |
| Sonnet 5 | 1,024 |
| Opus 4.7 | 2,048 |
| Haiku 4.5 | 4,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:
| Read | Write | Never cached | |
|---|---|---|---|
| Which call | v4 · Sonnet | v3 · Sonnet, first of run | v3 · Haiku |
input_tokenswhat was not served from cache |
511 entity JSON |
463 entity JSON |
4,467 tools + system + JSON |
cached_tokensprefix read back |
6,470 | 0 | 0 |
cache_write_tokensprefix stored |
0 | 5,541 | — |
| Total input | 6,981 | 6,004 | 4,467 |
| This model's floor | 1,024 | 1,024 | 4,096 |
| Prefix clears it? | ✓ 6,470 › 1,024 | ✓ 5,541 › 1,024 | ✗ under 4,096 |
cost_usd | 0.003996 | 0.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.
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.
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 organisationsServing & 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
| Question | Answered by |
|---|---|
| Why this account | Fit score, with every scoring component shown |
| Why now | Findings, each tagged with its evidence class |
| What do I say | Drafted 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.
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.Overview / Evals
75 labels · 5 configurationsEvals & 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
| Config | Accuracy | Precision | Precision held-out | Recall | Recall held-out | $/call |
|---|---|---|---|---|---|---|
| v4 · Sonnet 5 | 0.773 | 0.765 | 0.727 | 0.722 | 0.667 | $0.00368 |
| v3 · Sonnet 5 | 0.667 | 0.615 | 0.667 | 0.444 | 0.500 | $0.00378 |
| v2 · Haiku 4.5 | 0.653 | 0.588 | 0.500 | 0.556 | 0.500 | $0.00341 |
| v4 · Haiku 4.5 | 0.667 | 0.524 | 0.467 | 0.611 | 0.583 | $0.00205 |
| v3 · Haiku 4.5 | 0.627 | 0.471 | 0.385 | 0.444 | 0.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 one | Reversal two | |
|---|---|---|
| What went wrong | the measurement was unreliable | the measurement was incomplete |
| Cause | too few examples | a cache fix that also changed the prompt |
| Fix | more labels | measure 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.
| Config | at 25 labels | at 75 labels |
|---|---|---|
| v3 · Haiku | 0.750 — best | 0.385 — worst |
| v2 · Haiku | 0.667 | 0.500 |
| v3 · Sonnet | 0.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.
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:
Overview / Cost Model
$0.00368 per entityCost Model
Measured from traces, not derived from assumed token counts.
The rates, so every figure here can be checked
| Model | Input / MTok | Output / MTok | Cache 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
| Configuration | 43,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.
cache_creation_input_tokens back — a fraction of a cent, two
minutes. Which is why a longer prompt costs 2.5× less.--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 entriesKnown 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.
| Limitation | Why 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 mode | Coverage | Where |
|---|---|---|---|
| 1 | Context overflow | Partial — cost, not truncation | render() caps list fields at 12; max_tokens=400 |
| 2 | Infinite loop | N/A — bounded retries only | MAX_ATTEMPTS = 3, exponential backoff |
| 3 | Tool hallucination | Full | forced tool_choice, Pydantic, Literal enum |
| 4 | Memory drift | N/A — stateless | no turns to drift across |
| 5 | Silent wrong answer | Partial — threshold, no calibration | MIN_CONFIDENCE = 0.60 routes to tier R |
| 6 | Cost explosion | Partial — pre-flight only, no runtime guard | --dry-run prices a run; nothing halts one mid-flight |
| 7 | Observability blindness | Full | 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
What I would do differently
| # | Change |
|---|---|
| 1 | Run 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. |
| 2 | More 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. |
| 3 | 150+ examples and a second labeller, with disagreements adjudicated. |
| 4 | Repeated-run confidence intervals in the harness. We measured self-consistency by accident; the tooling should report an interval, not a point. |
| 5 | Set 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. |
| 6 | Carry 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 GBScaling 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.
| Layer | At 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
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.