---
name: the-cleaner
description: The Cleaner - continuous invariant checks that catch data, learning and prediction mistakes across Tech Score and Price Predict, auto-fix the safe ones, and score itself honestly on how many bugs it caught before Ben did. Read before adding any check, before trusting any number either product asserts, and whenever a new bug is found (it must become a check).
sources: [cowork]
aliases: [cleaner, the cleaner, invariant checks, incident ledger, cleaner.py]
---
Built 14 Sep 2026. Watches [[casig-gmgn-overlay]] (Tech Score) and [[trench-model]] / [[trench-model-2]] (Price Predict). Service `cleaner.service` on kookwatch, code `/opt/cleaner/cleaner.py`, ledger `/opt/cleaner/incidents.db`, log `/opt/cleaner/cleaner.log`. Runs every 10 minutes.

# What Ben asked for
- [stated] "build a process that prevents hallucinations and inventions and other data collection and learning and prediction mistakes, and scans for these also for all of these and fixes them automatically. also self learning, getting better at it. we call this The cleaner."

# THE ONE CORRECTION I MADE TO THE BRIEF, and Ben's standing rule says to make it
**"Prevent hallucinations" cannot be built as stated.** Nothing stops a model inventing a plausible number. What CAN be built is a system where **an invented number cannot survive contact with the data**: every figure must trace to a stored measurement, and anything that does not trace is flagged. That is stronger than "try not to invent", because it does not depend on anyone behaving well.
**And "self-learning" here is a RATCHET plus a SCORE, not a model.** Every new bug, found by anyone, becomes a permanent check, so the set of things that can go wrong unnoticed only shrinks. Every incident records who found it: `cleaner` or `human`. **That ratio is the only honest measure of improvement** and it is printed every run. Calling a fixed rule set "self-learning" would be exactly the overclaim this thing exists to catch.

# THE FIVE FAMILIES, each derived from a mistake that actually happened here
| family | what it is | how many times it happened |
|---|---|---|
| **denominator** | a number computed over the wrong set | 4 |
| **absence** | a failed measurement recorded as a finding | 4 |
| **stoppage** | something stopped and nothing said so | 4 |
| **degeneracy** | one answer for everything | 1 |
| **provenance** | a number with no measurement behind it | the catch-all |
**Absence is the most dangerous**, because the output looks like data.

# THE 16 CHECKS
`den.rates_monotonic` · `den.peak_dist_clean` · `den.training_excludes_late` · `abs.no_value_for_failure` · `abs.handles_not_sites` · `abs.dead_tokens_labelled` · `abs.tech_score_reaching_predict` · `stop.writers_alive` · `stop.one_champion` (auto-fixes) · `stop.queue_drains` · `stop.outcomes_settle` · `deg.verdict_spreads` · `deg.board_not_uniform` · `deg.predictions_match_reality` · `deg.categoriser_reads` · `prov.numbers_have_sources`
Each returns `(ok, detail, fix)`. A `fix` is a callable and is only attached where the repair is unambiguous; after fixing, the check is **re-run to confirm**, and a fix that does not clear it is recorded as failed rather than as fixed.

# RULES FOR THIS THING, which matter more than the check list
- **Every new bug becomes a check, immediately.** That is the ratchet. A bug fixed without a check will recur.
- **Never let the scorecard flatter.** The first run said "caught 2 of 2, 100%" because only its own finds were in the ledger. `seed_lessons.py` loaded the 13 bugs humans found this session, so it honestly reads **13%**. A scorecard that starts at 100% can only fall and will be ignored inside a week.
- **A Cleaner that reports zero incidents is not passing**: it is perfect or blind, and the human-find count is what tells you which.
- The service is a `while true` loop, not a systemd timer, **so a hang shows as a stuck service rather than as silently not firing**. The thing that catches silent stoppage must not be able to stop silently.
- `record_human_find(family, what, check_id)` is how a miss gets logged. Call it every time.

# WHAT IT CAUGHT IN ITS FIRST TEN MINUTES
`worm_robinhood` still being scanned as a website: my handle guard lived in `channels()` and `stars_for(site, ...)` bypassed it. **A guard at one entrance is not a guard**; the rule moved to the point of use. First find neither Ben nor I made.

# CATEGORISER, `/opt/casig/kinds.py`, built in the same session
- [stated] Ben: categorise the project type during the Tech Score scan, let the category list **develop over time as products change**, put it in the GMGN overlay and feed it to Price Predict. His example of a category nobody would have written down in advance: **AI agents competing at trading**.
- [stated] And the hypothesis to test with it: **"during certain times certain projects might do better than during other times"**, the meta idea applied to project type. A theory, so the report must be able to say plainly if category turns out not to move the outcome.

## TWO AXES, NOT ONE SCALE. This is where I pushed back and Ben accepted.
    SHAPE   meme / meme+tech / tech      what kind of object is this
    SCORE   1..5 within that shape       how good an example of it
Put "meme" on the 1-to-5 scale and no meme can be compared to another meme, and Ben's own counter-example (AI, a meme with real liquidity pools) has nowhere to sit. A five-star meme and a five-star protocol are different claims and Price Predict must be able to learn them apart.
- 13 seed categories matched by **evidence** (terms the site itself uses), never by opinion: ai-agents, launchpad, dex, lending, perps, yield, rwa, prediction, gaming, infra-tools, analytics, social, stablecoin.
- **The list grows from the data.** Unmatched-but-substantial sites have their distinctive vocabulary harvested into `unmatched`; a term shared by 4+ of them is auto-proposed as a new category in `proposed`. The list keeps up with what the chain launches instead of what I imagined.
- Storage `/opt/casig/kinds.db`: `kind(ca, shape, category, confidence, evidence, site)`, plus `unmatched` and `proposed`.

## Two bugs in the categoriser, both mine, both the session's recurring shape
- **I read fields that do not exist.** `classify()` used `depth["text"]` and `bcopy["text"]`; the real keys are `buttons/headings/routes/text_chars/words` and `sample/strings/words`. Everything matched nothing and PAIREX (a launchpad) came back unclassified. `evidence()` now keeps a lowercase `ctext` sample, and **`deg.categoriser_reads` fires if most things land in unclassified**, because an empty string is a legal input and nothing else would catch it.
- **I assumed a field's type.** `headings` is sometimes a count, not a list, so `" ".join` raised inside a silent `except`. Coerce, never assume.
- **MEME_HINTS was too loose**: it included `"$"` and `"degen"`, which match nearly every crypto site, so PAIREX classified as meme+tech. Tightened to phrases only a meme actually says, and meme+tech now needs 2+ strong hits AND chain code.
Verified: `PAIREX 0xb8cf4ad387cfd607c66a207cfffc46498aacd9d6 -> 5 stars | tech | launchpad | "launchpad, bonding curve"`.

# STILL TO DO
- Surface shape and category in the GMGN overlay popup and in the Price Predict report.
- Add shape and category as Price Predict FEATURES (one-hot), then test Ben's meta hypothesis: category hit rate BY TIME WINDOW.
- A Cleaner page rather than only the log and the printed scorecard.

# TWO SESSIONS EDITING THE SAME FILES. 14 Sep 2026 04:15 UTC. Read this before touching /opt/casig.
Ben asked earlier whether he could work on Tech Score and Price Predict in two separate Cowork sessions. **He then did it, and both sessions were editing `/opt/casig` at the same time.** Evidence, found while answering an unrelated question: file mtimes 04:00 (my `kinds.py`), 04:08 `ledger.py`, 04:09 `stars.py`, 04:10 `casig.py`, plus a `bak-20260914-0402/` directory and a `patch_casig2.py` I did not write.

## What the other session had already built, and it is better than my version
`ledger.py` now carries **`cohort_of(age_min, mcap)`** returning `trench / young / mature / unknown`, and keeps **`training_rows(cohort='trench')` separate from `reference_rows()`** (young and mature, for calibrating the rubric by eye). Its docstring makes the point I was about to make: *"Unknown age is its OWN answer, never quietly folded into trench, that is exactly how a mature project would end up in the training set."* The `score` table gained `chain, view, cohort, age_min, mcap, holders, liq, shape, category, chain_judged`.
**So the cohort separation I proposed for the mature-project backfill already exists, done properly.** My `CREATE TABLE IF NOT EXISTS` silently did nothing against the richer table, which is why my ad-hoc `cohort` column read back values I never wrote.

## THE RULE THIS NEEDS, and it is not optional
**ONE OWNER PER DIRECTORY.** Two sessions writing the same files is how work gets clobbered, and the only reason nothing was lost here is that the other session built on top rather than overwriting.
| directory | owner |
|---|---|
| `/opt/casig` | the **Tech Score** session |
| `/opt/trench` | the **Price Predict** session |
| `/opt/cleaner` | either, but announce it |
Before editing outside your own directory: check mtimes and `ls` for a `bak-*` directory first. If another session has touched it in the last hour, say so and stop rather than patching over it.
**I stopped my own `outcome_backfill.py` mid-run for this reason** (it had written no rows, so nothing was half-formed). The known-outcome scoring belongs to the Tech Score session, which already has the cohort machinery for it.

## Also fixed in this exchange
- [stated] Ben: "Is it good that the ledger started empty this morning? If you can't look at them when you talk to me about things, I'm not sure that's ideal." **He was right and it was an accident of when I built it, not a design choice.** `import_cache.py` imported **945 historical ratings** from `cache.json`. The top of the scale went from 1 five-star to **4 five-star and 9 four-star**.
- His two named projects were real and I had claimed not to have them: **"H Farm" is `hoodfarm.io` at `0x99f5359b422a47e4482b938979d5af9202531ea4`, 5 stars**, and SGT is almost certainly **`surrogate.finance` at `0x5f22f87723240963d2f516bcb7c1f099d4c27126`, 5 stars**. Also `maul.lol` at `0x76a218d74e016d9a57319bc24f4e297001c6ce85` and `pairex.market` at `0xb8cf4ad387cfd607c66a207cfffc46498aacd9d6`.
- **`/opt/casig/tech.py`**, the lookup tool, because the deeper half of Ben's point was ACCESS not coverage: answering "what did we rate X" meant hand-writing SQL over SSH, and **my hand-written queries have been wrong in this project more than once**. Fixed queries, used every time, same reason `peek.py` exists for Price Predict. `tech.py` / `top` / `<ca or symbol>` / `site <x>` / `why <n>` / `outcome`.
