Symptom
A query returns 0 rows. Two readings branch here and they lead to opposite conclusions.
| Observation | Reading A (absent) | Reading B (failure) |
|---|---|---|
| Search: 0 | The thing doesn't exist | Random bot block |
| Mail: 0 | No new mail | Caught by the SINCE / LIMIT filter |
| Item not in catalog | Discontinued | Didn't scrape enough pages |
| Batch processed 0 | Nothing to do | Target list came from a different source |
| 0 characters of text | No body | Body is nothing but image tags |
products.json 404 | No catalog | Just not that platform |
| Domain not found | No site | You guessed the domain |
| Aggregate says "no catalog" | Never collected | Collected, but never registered in the mapping table |
How to tell them apart
0/N, if you don't know N you don't know what 0 means.What it cost
Three days for not making this distinction: four locales misjudged as "out of stock"; 12 emails judged as 0 (three times over); a batch with 0 targets; a "discontinued" verdict where the real story was a different domain.
Blocking is random per request and per locale; a single query cannot judge
The same query ran twice. Round one: only one locale succeeded (1.1MB). Round two: that locale was the only failure (2.2KB) and the others succeeded. It isn't a locale problem — it's random per request. Every "no source" verdict made from four locales returning 0 had to be re-checked.
len against a threshold (under 50KB is a block) and the presence of a core marker (the result-item tag).Mail: 0, a crash, and missing IDs — three traps from one collector migration
| Symptom | Cause | Fix |
|---|---|---|
| Order number not extracted | regex {6,20} — one vendor uses 5 digits | Don't assume ID length. {4,20} |
| Crash | header charset unknown-8bit blows up the decoder | Fallback chain: utf-8 → cp949 → euc-kr → latin-1 |
| Mail: 0 | SINCE 7 days and "last 120 messages" hard-coded as defaults | Defaults live in env vars |
★ ID length, charset, and query window are all facts about the other side. An assumption baked into your code shows up as 0 rows or a dead run the moment they change — and 0 rows looks like normal.
★★★ Interpreting a zero requires a control — and once writes are attached, give the verdict a third value
A shipping-quote API returned zero options for one country. Read as "we can't ship there," it triggered a routing change on 60 records. Wrong — that store computes shipping at checkout, so this API returns zero for every country you ask about.
★★★ Making the same call once more with a different input separates the two cases.
| Observation | Meaning |
|---|---|
| zero for the target country, others return options | genuinely unavailable |
| zero for every country | this store doesn't use this API → undecidable |
★★★ To interpret a zero you need an input you expect to be non-zero. That is the control. Without one, "my query is wrong" and "they don't use that feature" both look like 0.
★★★ So the verdict has three values, not two
True # confirmed (or there is a past record of it) → use the measured value
False # the control responded and only this target is zero → genuinely unavailable
None # the control was also all zeros → undecidable
★★★ And None does not mean "defer," it means "do not write." Leave the existing value exactly as it is and do nothing. In another classifier in this collection, UNKNOWN meant retry — but that was a read, and this is a position that can overwrite existing data.
★★ A write-side abstention must be stronger than a read-side one. Not "don't read when you don't know" but "don't change when you don't know."
★★★ This collection already holds three cases of reading "not found" as "not there" — a failed fetch as out-of-stock, a blocked search as zero results, a rate limit as high risk. Those three stopped at a wrong verdict; this one changed the data. ★ Build the None path before you attach writes to a classifier. The same bug costs something entirely different once a write hangs off it.
★★ A record of actual outcomes beats any static check — don't add it to the score, intercept ahead of it
That store had an actual delivery record to that country. The fix was to check the tracking history first and skip the API verdict when it exists.
★★★ Ordering is the whole point. The same signal was being used in an earlier scorer as one +1 line item — and adding your strongest evidence in the same unit as your weakest dissolves the strength. The proof: in that scorer, the highest-scoring subject turned out to be one we cannot actually transact with.
★ Static checks accumulate "probably"; a record states "it happened." When you have the latter, there is no reason to consult the former — make it an override, not a line item.
⚠️ ★★★ But an override must not outrank a blocklist
That classifier kept its blocklist — counterparties that had already caused real losses — as one of the penalty rules. If the override "skips the static checks," it skips the blocklist too.
★★★ A counterparty that transacted normally once and then turned fraudulent sails straight through. The real loss case was "paid, never shipped" — and if even one order had shipped normally before that, it would score as maximally trustworthy today.
→ Pin the precedence explicitly.
① blocklist — reject unconditionally
② prohibitions on a separate axis — score-independent (rights issues, etc.)
③ track-record override — only here do you skip
④ static score
★★ An override is evidence of safety, not authority to lift a block. ★★★ A skip that doesn't say what it skips will skip the blocks too.
⚠️ ★★★ And encoding precedence as sentinel values makes them cancel
The natural implementation gives a block -99 and an override +99. It looks clean, and in an additive scorer it breaks.
-99 + 99 = 0
★★★ Zero is a middle band. Out comes a value that is neither blocked nor confirmed. And that combination is not hypothetical — "on the blocklist AND has a prior transaction record" is exactly it, and anyone you blocklisted after dealing with them once satisfies both conditions, always. The most dangerous combination draws the most ambiguous score.
→ ★★★ Precedence cannot be expressed as a value. Express it as an early return.
if blocked(x): return Verdict.BLOCKED # return immediately
if prohibited(x): return Verdict.PROHIBITED # return immediately
if has_record(x): return Verdict.CONFIRMED # return immediately
return static_score(x)
★★ A sentinel value rests on the promise that "this number never gets added to the others." In an additive scorer that promise breaks the moment someone adds one += line — and that someone does not know about the sentinel.
★ If it must be a value, multiply rather than add. 0 times anything is 0, and it does not cancel. ★ Better still, keep grades and scores in different types — a Verdict for blocks, an int for confidence. Put them on the same axis and sooner or later they get summed.
⚠️ ★★★ Splitting the types breaks every call site — and that is the point
The moment score can be None, a comparison like score < THRESHOLD raises TypeError. Every call site has to be fixed.
★★★ Breaking is the deliverable here. What previously returned a quiet 0 and drifted into the middle band now surfaces as a crash. A loud failure beats a silent one — the exact inverse of what this chapter's opening entry describes.
⚠️ ★★★ But score or 0 resurrects the cancellation bug intact. Folding None into 0 turns a block back into the middle band — the very value you just fixed. It is especially dangerous because it is the first thing anyone reaches for when trying to make a TypeError go away.
verdict, score = check(x)
if score is None: # do not fill in a default
return verdict # the grade IS the conclusion
★ An or default swallows 0, "" and [] along with it. Test verdicts with is None. ★★ Silencing a crash and fixing its cause are different jobs — and here the former exactly undoes the latter.
⚠️ ★★ And a track record is a fact about the past, not a guarantee about the present
★ Don't let a success from a year ago vouch for today. Weigh the record's recency, and demote stale ones from override back to line item.
★ Implementing the override as a large value inside the scoring system (a 99 that dwarfs the threshold) is a good choice — it clears the threshold decisively while still coming down if a penalty lands on it. Putting ① and ② ahead of it makes that design safe.
★★★ One more — a new classifier loses facts you paid dearly for
The None (undecidable) list contained a target already settled in the past. For that one we had established that "the policy claims availability but the country is absent from the actual list", after registering 32 items and pulling all of them. That expensive fact came back as "unknown" in the new classifier.
★★★ None must mean "not looked at yet," never "looked at before and forgotten." Since None means keep-existing-value there's no immediate damage, but storing that None buries the earlier finding.
★★ Put manually confirmed values above the classifier — one manual_verdict field. However sophisticated automatic scoring gets, it cannot outrank a fact a human established by experiment. Whenever you write a new classifier, first ask where the previous conclusions were kept.
★★ And aligning keys comes before matching
The name/domain mismatch above happens because each store picked the key that was right in its own context — a human-read log uses brand names, a machine-scraped catalog uses domains. Both are correct choices; the problem appears only when you join them.
★★★ An alias table is a remediation for past data, not a solution for future data. Add a shared key field on the writing side and populate it at write time. Otherwise match accuracy becomes permanent debt.
★★ Aside — two stores call the same entity by different names
The order history used brand names (including local-language forms); the catalog used domains. String matching will never connect them. Keep an alias table as the source of truth and use first-word name matching only as a fallback — ★ invert that order and you reproduce this collection's three consecutive mismatches from matching on names.
It was collected, and the aggregate still says "absent"
Collect a catalog but never add it to the brand mapping table, and the aggregate reports "no catalog". The top 25 brands all showed ✘ when several were already sitting in the catalog. Collection and mapping are separate jobs. Registering in the mapping table right after collecting is part of collecting.