ai-coding-minesIndexGitHub

"Absent" and "not found" are different facts

Python and databases

Symptom

A query returns 0 rows. Two readings branch here and they lead to opposite conclusions.

ObservationReading A (absent)Reading B (failure)
Search: 0The thing doesn't existRandom bot block
Mail: 0No new mailCaught by the SINCE / LIMIT filter
Item not in catalogDiscontinuedDidn't scrape enough pages
Batch processed 0Nothing to doTarget list came from a different source
0 characters of textNo bodyBody is nothing but image tags
products.json 404No catalogJust not that platform
Domain not foundNo siteYou guessed the domain
Aggregate says "no catalog"Never collectedCollected, but never registered in the mapping table

How to tell them apart

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.

Mail: 0, a crash, and missing IDs — three traps from one collector migration

SymptomCauseFix
Order number not extractedregex {6,20} — one vendor uses 5 digitsDon't assume ID length. {4,20}
Crashheader charset unknown-8bit blows up the decoderFallback chain: utf-8 → cp949 → euc-kr → latin-1
Mail: 0SINCE 7 days and "last 120 messages" hard-coded as defaultsDefaults 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.

ObservationMeaning
zero for the target country, others return optionsgenuinely unavailable
zero for every countrythis 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.