---
name: casig-gmgn-overlay
description: casig, the GMGN trench-view overlay that puts a green/red/grey dot on every card saying whether the project's OWN website or X publishes that exact contract address. Read before touching the GMGN overlay, the /opt/casig service, or any "is this the real token" automation.
sources: [cowork]
aliases: [casig, gmgn overlay, contract check, real token check, impostor dot, gmgn-ext]
---
Built 13 Sep 2026. Companion to [[trenchbuy]] (same install pattern, same droplet). Impostor background in [[rugscan-3]] and the RAILX receipt in Ben's preferences.

# What Ben asked for
- [stated] "Add an overlay on GMGN trench view... my main issue is to find out if the token is actually the real token of the project. Sometimes they have a website, sometimes a Twitter, but there are sometimes multiple tokens that launch. I always need to manually go and check if that contract address is on the website somewhere or it's somewhere tweeted by the account." Green icon / red icon per token. Must be a LAYER on the existing site, not a new dashboard, and must run on whatever filters he has set.

# The mechanism, stated once
**A contract address is real only if a channel the PROJECT controls publishes it.** Everything else (the card's ticker, its logo, its name, even its social links) is set by whoever deployed the token, so it is a claim, not proof. The handshake has to go the other way: token claims a site -> does that site name this token back.
- **green** the CA is on the project's own site, or in its X bio, or in a post / the linked launch tweet from its own account.
- **red** that same channel publishes a DIFFERENT contract that is a real traded token. Impostor or relaunch.
- **grey** nothing to check, or nothing found. **Absence is never painted red**, because a meme whose only link is a Reuters article has no channel to be silent with.

# Architecture, and why each piece sits where it does
- **The checking runs on the droplet, not in the browser and not in the Cowork container.** The container's egress proxy resets on arbitrary project domains (railx.dev died with `Connection reset by peer`); the droplet fetched the same URL 200/153KB. And a page-side fetch is dead on arrival anyway: gmgn.ai is https, the droplet is http, so mixed content blocks it. **An MV3 service-worker fetch is NOT mixed-content blocked**, which is the entire reason `background.js` exists.
- **Service `casig.service`, `/opt/casig/casig.py`, port 8092**, ufw opened. `config.json` holds the twitterapi.io key, a `ui_key` (`k=` on every request) and `workers`. Log `/opt/casig/casig.log`, cache `/opt/casig/cache.json`. `/health` needs no key.
- `POST /batch?k=` with `{items:[{ca,links}]}` returns `{results, pending}`. Unknown CAs are queued to a 5-thread pool and the extension re-asks every 8s until `pending` empties. `GET /v?ca=&l=&l=` does one synchronously.
- **Cache TTL by verdict: green 24h, red 1h, grey 15m.** Grey is short on purpose, a token 4 minutes old often gets its website filled in later.

# The extension
`/Users/bk/casig-ext` (manifest.json, background.js, gmgn.js, casig.css, INSTALL.txt, KEY.txt). Loaded unpacked, same four clicks only Ben can do. Host permissions `https://gmgn.ai/*` and `http://159.223.88.168/*`.

## GMGN trench DOM, measured 13 Sep 2026, this is the spec
- **Card selector: a `div` whose className matches `/!w-full flex gap-8px p-\[14px\]/`.** 23 to 26 cards on screen across the three columns (New / Almost bonded / Migrated). Card height 124px, `position:relative` already.
- **The full 42-character contract is ONLY in the card's launchpad link** (`www.ponsfamily.com/launchpad/0x...`, `app.long.xyz/...`, `flap.sh/...`). The visible address on the card is truncated (`0xa9...e4b7`) and there is no title attribute. Fallbacks in order: any `a[href]` with a 40-hex match, then `img[src]`, then the card's innerHTML. **Coverage measured at 23/23 and 25/26.**
- **The card already carries the socials GMGN resolved**, so no metadata lookup is needed in the hot path: an x.com profile link, or an x.com `/status/` link, plus a website when there is one. Ignore `lens.google.com` (GMGN's own reverse-image link) and the launchpad link itself.
- **The list is VIRTUALISED.** A card element is recycled for a different token, so nothing may be keyed on the element: the dot stores `dataset.ca` and repaints when it changes. A MutationObserver (400ms debounce) plus a 2.5s interval drives it.
- Dot is `position:absolute; top:6px; left:6px` on the card. Verified on screen: it sits on the avatar corner, clear of GMGN's own Buy buttons, and breaks no layout.

# Traps hit and what they cost
- **Every website and every JS bundle is full of 40-hex constants that are not tokens.** The first build painted RAILX-fake red for "publishing a different contract: `0x0000...0001`", and prm.market red for the zero address. Two-stage fix: a `junk_addr()` filter (zero/dead/ff addresses, anything with 3 or fewer distinct nibbles, plus multicall3, EntryPoint, the RH PoolManager, WETH, USDG and the three pons contracts), then **`real_tokens()` confirms each survivor against DexScreener `tokens/v1/robinhood/{a,b,...}` and keeps only addresses that are actually traded tokens.** After that RAILX-fake reads correctly: "railx.dev publishes a DIFFERENT contract: 0xfead46f7babd84b7f450aaeece081def6e973c28". **RULE: never conclude from a raw 0x match scraped off a page; confirm it is a token first.**
- **twitterapi.io does not index accounts that are minutes old.** `from:clickingcoin_`, `from:OcraMarkets`, `from:FlypayHood` all returned 0 tweets against live, posting accounts, and `user/last_tweets` returned 0 by both userName and userId. This is the same index gap recorded in `research-sources`, and it bites hardest on exactly the tokens Ben screens. **What still works on a brand-new account: `twitter/user/info` (bio, url, entities) and `twitter/tweets?tweet_ids=` for a specific tweet.** So the bio is the reliable X channel, and the card's linked launch tweet is read by id as a second chance. Search is a bonus, never the basis.
- **The Desktop_Commander write_file payload is JSON, so `\uXXXX` in the content becomes the real character on disk.** A later string-replace patch that spelled the escape did not match and printed MISS. Anchor patches on plain-ASCII lines.
- Scanning a site means: homepage, up to 5 same-origin links matching `token|contract|buy|chart|trade|address|tokenomics|invest|about`, and up to 8 `.js` bundles. **The bundle scan is what catches a single-page app that prints the CA from a constant**, which a plain HTML fetch misses.

# Measured behaviour, 13 Sep 2026
- 9 live trench tokens resolved in **9.1s total** (5 workers); per token 0.9s to 10.2s, most 2 to 3.5s. Cached after, so a repeat card paints instantly.
- Verdict mix on that sample: 4 green, 5 grey, 0 red. Regression set: RAILX real green, RAILX fake red, PAIREX green, everythingpad green (website), YOYO green (bio), meme-with-a-news-link grey, no-links grey.
- Green came from a website 3 times and an X bio 2 times. **No green has yet come from search**, which matches the index gap above.

# Open, and what to do next if he asks
- Ben's own wording treats absence as red. This build uses grey for absence and reserves red for a proven contradiction. If he wants his version, it is one line in `verify()`: return red instead of grey when a project-controlled channel loaded fine and did not carry the CA.
- prm.market takes 14 to 27s and still reads grey; it is a launchpad listing many tokens and is probably client-rendered. A per-token deadline is not yet in the code.
- Not built yet: a recheck-on-demand gesture, and any second overlay feature. He said "maybe we'll add some more things later".

# 13 Sep 2026. THE STAR RATING. V4 due diligence rendered as 1 to 5 stars on every card.
- [stated] Ben: 5 stars "something special", 4 "solid", 3 "average", 2 "bad", 1 "a scam", and "every project on that page that has a website should get this score."
- The dot and the stars answer different questions and stay separate: **the dot is "is this the real contract", the stars are "is there anything here".** Code in `/opt/casig/stars.py`, rubric in its docstring. Full V4 method in [[techscan]].

## The rubric, derived from the mechanism rather than from a feature count
At minute five a buyer is asking four things: **WORKS** (a live product or a poster), **THEIRS** (their build, not a stranger's page and not a stock template), **ABOUT THIS TOKEN** (does any of it name the contract you would buy), **LYING** (does its own code or its own channel contradict it). A veto is a veto; everything else is a ladder.
- **1 scam**: casig red (its own channel publishes a different contract) OR it publishes contracts that do not exist on chain with the controls answering OR it is an empty shell that answers 200 to any path, prints a zero-code address and never names its own token.
- **2 bad**: site dead, empty shell, somebody else's page (shared host, or an own domain that never names the token), a BLANKET "not real money" admission, boots on a testnet, or the site targets another chain.
- **3 average**: a real site with real copy, but it never names this token, or it has no chain code at all, or it is one page shape.
- **4 solid**: live product, own domain, names its own contract, talks to a chain, real surface.
- **5 special**: 4 plus contract code they wrote themselves (above the token band and not shared infrastructure) OR 8+ routes with 2,000+ words.
- **0 is not a grade.** No website, an unreachable host or a half-fetched bundle returns no rating at all. L3 from the port kit: a missing measurement must never read as a clean one.

## Five false ratings found and fixed during the build, all in one session
- **F5, chain infrastructure is not code they wrote.** coltprotocol.sbs scored 5 stars for publishing `0x7ed598bcef8bd9edd8c97a195c6d13f40801ec7e` (the Pons factory), `0xe33e9e479df8802cb0866d5d05258bec4cf62948` (the launcher) and `0xe5e702641ea86f4ae6cc3cdaed2b886f976be044` (the fee hook). It is a token ANALYSER, so those are the contracts it reads. An `INFRA` set now excludes them.
- **F9, a token is not a protocol.** The same site then scored 5 for `0xc7819b64a1daecd7ec19856d026cb14efbd89046` at 4,402 bytes, which is just another taxed Pons token it was displaying. The stock pad is 3,248 and a taxed variant about 4,400, so the authorship floor is now **8,000 bytes**, above the whole token band. COLT settled at 4, which is correct: a real working tool that wrote no contracts.
- **F1, a testnet RPC beside a mainnet RPC is a vendor default.** pairex.market was called a testnet app because Privy's wallet SDK ships arbitrum-sepolia and base-sepolia. It also ships the real Robinhood mainnet RPC. Testnet now fires only when there is **no mainnet RPC at all**, which still catches talonfinance.app (only `rpc.testnet.chain.robinhood.com`).
- **F8, the chain detector could not read its own chain's name.** The robinhood hint matched `robinhoodchain` as one word, so fomossaur.com, which writes "Robinhood Chain" with a space four times and says "Connect your EVM address", read as ZERO mentions of this chain and was labelled a Solana project. Widened to `robinhood[\s_-]?chain`, and Phantom was dropped from the Solana hints because Phantom is multi-chain now.
- **F2 and F10.** A bundled wallet SDK names every chain it supports (pairex.market: solana 609, robinhood 12), so the "mostly mentions another chain" ratio was vendor noise; only ZERO mentions of this chain is a mismatch now. And an empty shell is judged on page words and bundle words **separately**, because summing them let plip.run (2 page words, 489 framework words) hide behind its own boilerplate while hyip.fi (1 page word, 3,189 real ones) is a genuine app.
- **F4, a sentinel is not a contract.** `0xeeeeeeee14d718c2b47d9923deab1335e144eeee` is the native-token placeholder. Reading it as a published contract with no code turned a real site into a scam call. Eight identical leading nibbles, or four identical leading and four identical trailing, is now a hand-made constant.

## The board it produces, every row hand-checked
| stars | project |
|---|---|
| 5 | PAIREX `0xb8cf4ad387cfd607c66a207cfffc46498aacd9d6` (106 routes, 8,370 words, own contracts) |
| 4 | COLT `0xf61836823abab0152842df9a53fb8dfee354e094` |
| 3 | HYIP, ALPHA, Hold, FOMOSSAUR, DASP, DVK, MOONTAGE, LOOP, PUSHIN, PREDICT |
| 2 | TALON (mock tokens, boots on testnet), HARVEST (simulated currency), ORDER (a Google Form), MICHUL (its only link is a dead launchpad page), any dead site |
| 1 | ORIONIS `0xfceae4df6961cbb3d22491b72e66c7adf415bca8` (12 contracts that do not exist), Plip `0xaba8866fec42d193373e251d3ed200320d220503`, GARROW `0xacc61edf6bdeb135606883b8521f7517b68bd089` (pools.trade publishes a different contract) |
- **3 is by far the most common grade, and "never names the token you would be buying" is the most common reason.** That matches the base rate in [[techscan]]: 8 of 11 Pons launches never print their own contract.

## Plumbing
- **Two phases, so the dot is never delayed by a site scan.** The worker writes the record with `stars: None`, then `add_stars()` fills it in and saves. A star failure is caught and leaves the record unrated rather than wrong. `fresh()` refuses to serve a record whose star pass has not finished within 20s, so it gets re-queued.
- The site scan is cached **by domain** for 30 minutes (`_TECH` in stars.py), separate from the per-CA cache, because the site is the object being measured. The veto half is token-specific and is applied after the cache, never inside it.
- `/batch` items now accept `sym`, read off the card by the extension (first bare word that is not the truncated address and not a number). The ticker is one of the three ways `own_site()` proves the page belongs to this token.
- Extension: `.casig-stars` badge sits at `top:5px; left:26px`, beside the dot, colour-coded gold/green/grey/orange/red. Clicking either the dot or the stars opens the popup, which now lists the rating and its receipts. The bottom bar counts the grades (`★ 5:1 4:1 3:7 2:4 1:2`). The content script keeps re-asking while `stars` is null, so a card fills in on the next poll.
- **NOT_OWN_HOST gained the hosts that show up as a token's "website" on live cards**: telegram.me, o1.exchange, bags.fm, four.meme, believe.app, instagram.com, tiktok.com, on top of forms.gle, github.com, app.long.xyz, ponsfamily.com and the rest.
- **Gate: 9 star cases in `/opt/casig/selftest.py`**, each a hand-checked project pinned to its expected grade, plus "an unreachable host gets no rating" and "the impostor veto beats every good signal". 44 checks, 36 seconds, and `deploy.sh` blocks on them. Moving any star case is a change to the rubric and has to be argued for.
- Measured live through the running service: **7 trench tokens rated in 12 seconds**, warm cache instant.

# PRODUCT NAMES, set by Ben 14 Sep 2026
- [stated] Ben named the two products explicitly: **"1. The original one, where we give ratings from 1 to 5 stars to the project based on the website, essentially, and what we can see there. We call that TECH SCORE. 2. What we've built and was running overnight, this is what we call PRICE PREDICT, right, because we're trying to predict if this thing is going to be a runner or not."**

| product | question it answers | where it lives |
|---|---|---|
| **Tech Score** | did anyone actually build this | the 1-5 stars in this overlay, `/opt/casig/stars.py` |
| **Price Predict** | is this going to run | [[trench-model]] / [[trench-model-2]], `/opt/trench` |

**Use these names in every reply, label and page from now on.** They are separate questions and the interface must never imply one answers the other: a five-star project nobody is buying is a real and common outcome, and so is a two-star meme that does $50M.
Applied 14 Sep 2026: the overlay popup now prints "Tech Score" beside the stars and "Tech Score: rating this project..." while pending; the Price Predict report is titled **Price Predict** with the subtitle "Will this token run. Separate from Tech Score, which rates whether the project was actually built."; and the prediction reason strings say "Tech Score 3" / "no Tech Score" instead of "3 stars" / "no stars".
Note the dependency, which is deliberate and stays: **Tech Score feeds Price Predict as two of its features** (the star value and whether it says scam), so Tech Score must keep working for Price Predict to keep its full feature set. Price Predict does NOT feed back into Tech Score.

# 14 Sep 2026. THE LEDGER, AND FOUR BUGS IT IMMEDIATELY EXPOSED.
- [stated] Ben: "create a file or database (whatever makes more sense) where you have all the ratings and details of what Tech Score is doing: the contract address, the final score, the breakdown of how it got to that score... good for stats... we can possibly go back and optimize the scoring system based on that table."
- [stated] Ben also asked whether pure memes should get a "meme" category instead of no score, noting **"some memes are technical. AI, for example, has liquidity pools"**, and explicitly asked to be pushed back on rather than agreed with.

## `/opt/casig/scores.db`, the Tech Score ledger. A CACHE AND A LEDGER ARE DIFFERENT OBJECTS.
`cache.json` expires (green 24h, grey 15m) and is rewritten on every save: it answers "what is this token's score now" and **structurally cannot** answer "how many got each rating", "why", or "which parts of the rubric predict a runner". The ledger is append-only, one row per rating EVENT, so a rubric change shows up as a change in the scores it produces instead of silently overwriting history.
Every signal the rubric READ is its own column, not just the verdict: `words, bundle_words, routes, buttons, own_site, own_ca_where, bundle_copy, sim_tier, chain_code, testnet, chains, mismatch, max_code_size, wrote_code, false_claims, live_code, dead, partial, ms`, plus `stars, label, rated, unrated_reason, verdict, veto, why, raw`. **"3 stars" is not analysable; "3 stars because own_ca_where=nowhere and chain_code=0 with 412 words over 1 route" is.** `python3 /opt/casig/ledger.py` prints the stats. Wired into `stars_for()` so it records both the normal path and the no-website early return; wrapped so a ledger failure can never break a rating.

## FOUR REAL BUGS, three of them found BY the ledger within an hour
1. **`save_cache` raced and lost hours of ratings.** Five worker threads, one shared `CACHE_PATH + ".tmp"`: the first `os.replace` consumes it, the second raises FileNotFoundError. **462 logged failures.** The ratings were being computed correctly and dropped on the way to disk, which is worse than not computing them. Fixed with a per-thread temp name. This is why 821 of 1,763 records (46.6%) had no star fields despite a median age of 2.6h.
2. **A "no website" answer cached from a call with NO LINKS was served forever after.** PAIREX rates **5 stars** on a direct scan and the service returned `"no website and no X account on this token"` with no `stars` field at all, even when the request supplied `l=https://pairex.market`. `fresh()` now refuses a record that concluded "nothing to check" when the caller IS supplying something to check.
3. **X HANDLES WERE BEING SCANNED AS WEBSITES, and this is the big one.** The ledger's first 49 rows: **23 failed as `host_unreachable`**, and the hosts were `MEADGod`, `theinformation`, `artificiallyinu`, `stakebankdotfun`, `humanevolvd`. Not domains, X handles. GMGN's `twitter` field often arrives WITHOUT the domain (`"MEADGod/status/209..."`), so `"https://" + u` produced `https://MEADGod/status/...`, which `channels()` filed as a WEBSITE and the scanner then spent a ten-second DNS timeout failing to resolve. **Rule now enforced: a host with no dot in it is not a domain**, it is an X handle. Verified on five cases.
   **So a large share of what looked like "projects with no tech" were tokens whose only channel is X, misfiled as broken websites.** Those are overwhelmingly memes. The system was meeting Ben's meme category constantly and had no way to say so.
4. **`stars: 0` for "no website" conflated a grade with the absence of one.** Now `stars: None` plus `star_unrated: "no_website"`. Zero is a point on a five-point scale; "there was nothing to score" is not.

## The first honest picture, from the ledger
Of 49 rating events: rated 1★ 1, 2★ 17, 3★ 5, 5★ 1; **not rated: host_unreachable 23, too_little_fetched 2**. Before the ledger every one of those 25 reached Price Predict as a zero.
**This is the answer to "optimise the rating as an input": the rating was not mainly imprecise, it was mainly ABSENT or WRONG for mechanical reasons.** Fix the plumbing before touching the rubric.

# 14 Sep 2026. V2: TECH SCORE ON THE INDIVIDUAL COIN PAGE, ONE SHARED RENDERER, AND THE COHORT COLUMN.
- [stated] Ben: "I want to get the star rating there as well [on the individual coin page]... When I make changes to the tech score process, it should always automatically reflect on the main page, but also on the individual pages. Set it up in a way where any changes are reflected automatically in both views. Also, when looking at coins individually like this, they should also write into the main database of the tech score."
- [stated] Ben: "I might give you projects that are much more mature, so they might have much bigger market caps. They might be much older... this will be good to give you also some maybe very strong projects so that you can see what strong technology can also look like. But you should not make the mistake of trying to use this as the same training data."

## The extension is now core + two views, and a view may not know what a star looks like
`gmgn.js` (one file, one view) retired to `gmgn.js.bak-v1-preshared`. Replaced by:
- **`core.js`** everything shared: the KNOWN cache, the transport, `paintDot`, `paintStars`, the popup, the labels, the bar, and THE LOOP (one MutationObserver + 2.5s interval for all views).
- **`view_trench.js`** and **`view_token.js`**, each of which implements exactly one function, `collect() -> [{el, spec}]`, where spec is `{ca, chain, links, sym, view, meta}`.
- A third view inherits every rendering and scoring change for free. **That is the only construction under which "changes reflect in both views automatically" is actually true**: there is nothing in a view to drift.
- Manifest v2.0.0 loads `core.js, view_trench.js, view_token.js`. Placement (inline on the coin page vs absolute on a card) is CSS only: `.casig-mount` makes the same dot and stars static/inline, `.casig-big` enlarges them.

## The coin page, measured 14 Sep 2026
- Route `^/<chain>/token/<address>` e.g. `https://gmgn.ai/robinhood/token/0xb8cf4ad387cfd607c66a207cfffc46498aacd9d6`.
- **Socials are NOT scraped from the DOM.** The coin page renders other tokens in its side panels, so "the links on this page" would hand one token's website to another. GMGN's own same-origin endpoints answer for exactly one address: **`/api/v1/token_link/<chain>/<ca>`** (twitter_username, website, telegram, description) and **`/api/v1/token_info/<chain>/<ca>`** (creation_timestamp, open_timestamp, holder_count, liquidity, symbol). Also `/api/v1/token_stat/<chain>/<ca>` for holder/bot stats. `token_info` carries NO socials, `token_link` carries no timestamps: both calls are needed.
- **Mount point**: the one header meta row matching className `gap-x-8px` + `text-text-300` with `30 < top < 300` and height under 56px. Exactly one match, verified. Badge is inserted as its first child and sits under the ticker. Fallback is a fixed card at top right, because a missing score reads as "nothing built" and that is the one thing it must never do.
- Market cap is parsed off `document.title` ("PAIREX ↓ $844.74K | ...").

## CHAIN-AWARENESS, which the coin page forced and which was a WRONG answer, not a missing one
Every on-chain check was pinned to the Robinhood RPC and the word "robinhood". Point it at a Base project and: `chain_mismatch` reads its own site as "SITE TARGETS BASE, NOT ROBINHOOD", and `code_sizes` asks the Robinhood RPC about Base contracts, gets zero, and reads a real deployed protocol as **contracts that do not exist**, which is the 1-star scam call.
- **`/opt/casig/chains.py`** is the registry: slug -> rpc, DexScreener id, CHAIN_HINT key, address shape. robinhood, eth, base, bsc, arb, opt, polygon, avax, ink, blast, sol. `norm()` folds every spelling. **eth.llamarpc.com 525s from the droplet and polygon-rpc.com returns "API key disabled"**: use `ethereum-rpc.publicnode.com` and `polygon-bor-rpc.publicnode.com`. All others verified answering `eth_chainId`.
- claimcheck is now **thread-local** chain (`set_chain/cur_chain/cur_rpc/chain_is_evm`), because five workers rate five tokens at once and a module global would leak one token's chain into another's rating.
- `CONTROLS` became `CONTROLS_BY_CHAIN` (WETH/USDC style pairs per chain) and **`ctrl_ok` now requires a non-empty control list**: an empty list made `all()` return True and would have licensed a FALSE verdict with nothing proving the RPC even answered.
- `CHAIN_HINT` gained bsc, arbitrum, optimism, polygon, avalanche, ink, blast. `address_judgeable` and `chain_mismatch` read the live chain. `code_sizes` on a chain with no EVM RPC returns **-1 (UNKNOWN), never 0**.
- The code cache was keyed by bare address and would collide across chains: `_ckey()` namespaces every chain except robinhood, which keeps the bare key so the existing 110KB cache stays valid. Same trick for `CACHE`/`KEY(chain, ca)` in casig.py.
- `norm_ca()` lowercases EVM addresses and **does not touch base58**, because lowercasing a Solana mint destroys it.
- Verified live: **AERO on Base -> green, 4 stars, shape tech / category dex**; PAIREX on Robinhood -> green, 5 stars, launchpad.

## THE COHORT COLUMN. Ben's point, and the sharper version of it
Tech Score itself is population-independent and a mature project scoring 5 is exactly the calibration reference he asked for. What is NOT population-independent is every RATE computed off the ledger. So the ledger gained **two independent columns**:
- **`cohort`** is a property of the TOKEN: `trench` (age <= 24h) / `young` (<= 7d) / `mature` / `unknown`. **This is the denominator.** Derived from AGE, never from market cap, because market cap is an outcome and bucketing on it sorts tokens by the thing we are trying to predict.
- **`view`** is provenance only (trench / token / api) and is explicitly NOT the denominator: a three-hour-old token opened on its own coin page is the same animal as the ones on the board.
- **Unknown age stays `unknown`** and is never defaulted into trench. A default is exactly how a mature project would silently enter the training set.
- Plus `chain, age_min, mcap, holders, liq, shape, category, chain_judged`, added by ALTER in `db()` so the existing rows survive.
- **`ledger.training_rows()` is THE ONLY DOOR** (defaults cohort='trench', chain='robinhood'), `reference_rows()` returns the young/mature ones for calibrating the rubric by eye. A note in a file gets forgotten; a function plus a gate check cannot.
- The trench view sends the card's **age chip** (`12s/7m/3h/1d`) so board rows land in the right cohort; an unparseable chip sends nothing rather than a guess.
- 1,040 pre-v2 rows backfilled to `trench/robinhood` on an argument from the mechanism, not a guess: `/batch` was only ever called by the trench content script, and that board only lists tokens under a day old. `age_min` left NULL because nobody measured it.

## Bugs fixed on the way
- **`/v` was dead**: `do_GET` called `fresh(CACHE.get(ca), links)` with no `links` in scope, so the single-token endpoint raised NameError on every call. Nothing used it, which is why it survived. It is now the coin page's and the CLI's endpoint, runs the star pass inline, and returns the full record. New `/ledger` endpoint returns the cohort stats.
- **"not rated" looked identical to "still rating"** in the badge. A no-website token is FINISHED; leaving it pulsing forever reads as a hung check on a fact we already know. `star_unrated` now renders as grey ☆☆☆☆☆ with its label.
- `TTL_MATURE` (green 3d / red 1d / grey 6h): a months-old project is not going to rewrite its website in the next quarter of an hour.
- The "no website" early return in `add_stars` never wrote a ledger row (only the stars.py path did), so the single biggest reason for no score was being lost for every token that got there.

## THE STALE LIVE CASE, and the rule it produced
The gate blocked on `HARVEST blanket sim` and it was **not** caused by any of today's changes (verified against the pre-change backup). stonkharvest.com reworded to "free items, demo balances and **simulated stocks** never turn into harvest or redeemable stock".
1. `SIM` matched `simulated (currency|trading|data|balances|prices)` and so was **blind to "simulated stocks"** - on a chain whose whole premise is tokenised STOCKS, the single most likely phrasing there is. Widened to stocks/shares/assets/holdings/portfolios. **A real detector gap, surfaced by the gate.**
2. Once seen, the phrase reads as MODE, not BLANKET, and correctly: the sentence scopes the admission to the demo things, which is what an honest product says. HARVEST's star pin moved **2 -> 3** with that argument written into the case.
3. **The rule: a case pinned to a LIVE site tests the world, not the detector, and silently stops testing anything the day the copy changes.** So `sim_fixture_cases()` freezes BLANKET / MODE / NOISE against synthetic blobs no website can edit, and the live case keeps only what it can still honestly assert.

## Gate and state
`selftest.py` now **67 checks, 0 failed** (was 44), `deploy.sh` blocks on them. New: `sim_fixture_cases()` and `chain_cohort_cases()`, the latter asserting that the RPC follows the chain, that one blob is a mismatch on robinhood and not on base, that a chain with no RPC returns UNKNOWN not 0, that controls fail closed with no control list, that cache keys are namespaced per chain, that a base58 mint is not lowercased, the cohort bands, and **the hard gate: a synthetic mature row never reaches `training_rows()` and does reach `reference_rows()`**.
casig.service active, deployed 2026-09-14T04:24:29Z. **The extension still needs Ben's four clicks at chrome://extensions to reload; nothing else does.**

## Three more things the live run caught, all the same shape
1. **THE GATE WAS WRITING TO THE LEDGER IT GATES.** selftest's nine pinned star cases call `stars_for` against nine fixed websites, and `stars_for` appends. Every deploy silently added nine rating events for PAIREX, COLT, ORIONIS, TALON, HYIP, ALPHA, HARVEST, Plip and ORDER, always the same nine, always ageless so always cohort `unknown`. **A measurement instrument writing into its own sample.** Fixed with `ledger.MUTED` / `ledger.mute()`, honoured in `record()`; selftest mutes at import and the one case that must write (the mature-row gate) unmutes around its own insert and deletes the row. **Gate check 68: "THE GATE IS MUTED"**, so this cannot regress silently. History was NOT rewritten: those rows cannot be told from real screens of the same tokens without guessing, and guessing is the worse error. Roughly 50 rows of known origin in a table of 1,600.
2. **`stars_client.ask()` was sending no age because my key list had every near-miss spelling and not the real one** (`creation_timestamp`, `created_ts`, `created_at`, `creation_ts`, `open_ts`, but not **`created_timestamp`**, which is what GMGN's rank rows actually use). Caught by stubbing the transport and asking the function what it would SEND, rather than reading the code and assuming. Predict rows now land as cohort `trench` (55 in the first 5 minutes) instead of `unknown`.
3. **Two code paths gave two different answers to "no website"**: `add_stars` returned `stars=None` + "no tech to score", `stars_for` returned `stars=0` + "not rated yet", and whichever got there first decided what the card said. Zero is a point on a five-point scale and "nothing to score" is not, which is the whole reason stars became None yesterday. `stars_for` now matches `add_stars` word for word.

## Verified live, 14 Sep 2026
- **Coin page**: PAIREX at gmgn.ai/robinhood/token/0xb8cf4ad387cfd607c66a207cfffc46498aacd9d6 shows green dot + ★★★★★ "something special" + "tech / launchpad" inline under the ticker.
- **Cross-chain**: AERO at gmgn.ai/base/token/0x940181a94a35a4569e4529a3cdfb74e38fd98631 ($1.13B, months old) shows ★★★★☆ "solid" + "tech / dex", recorded as cohort `mature` on chain `base`, and `training_rows()` excludes it.
- **Board**: gmgn.ai/?chain=robinhood, 30 cards, 30 dots, 30 stars, bar reading "casig ✓7 ✗7 ?15  Tech Score 5:0 4:0 3:12 2:4 1:6". The board renders nothing at all for the first ~25s and pauses in a background tab; that is GMGN's own stream, not the overlay.
- Gate 68 of 68, smoke ok, deployed 2026-09-14T04:39:47Z.

## AERO at 4 rather than 5 is the first real calibration datapoint
The five-star rule needs contract code they wrote above 8,000 bytes found on the site itself, and aerodrome.finance names its contract only in the bundle. **Whether a $1.13B DEX that is obviously "something special" should clear that bar is exactly the question reference rows exist to answer** - do not touch the rubric on one example, collect a few more mature projects first, then argue it from the set.

# 14 Sep 2026, SECOND PASS. Colour, type, deployer, and SIX REAL ERRORS the mature-project scan exposed.
- [stated] Ben: "the Project Categorization/Classifier needs to be visible on overview page and also on drill down page for every project... it needs to be self-learning, so it needs to expand categories when new metas evolve. Also fix the coloring on the entire tech score stack... You can put everything in gray. All the ratings can be in gray. Only the ones that I need to pay attention to, which are basically 4-star or 5-star, should be in green or gold. Green is 4-star, and gold is 5-star. 1-3 stars is gray. Also add the deployer check."
- [stated] Ben names the whole thing the **TECH SCORE STACK**: contract-identity check + 1-5 stars + project categorisation + deployer check, all one product.

## COLOUR: one rule, in casig.css
Everything grey. **4 stars green (#16c172), 5 stars gold (#ffc93c), 1-3 grey.** The verified tick went GREY too, because normal is not news and a colour on every card stops meaning anything. **Two alarms stay red and they are not ratings, they are warnings**: the contract is not this project's, and the deployer made something you cannot sell out of. Tokens in `:root`, one palette for badge, popup and bar.

## TYPE on both views
`C.typeLabel()` in core.js is the single place that decides how a type reads, so board, coin page and popup cannot disagree. Chip on every card AND the coin-page badge; popup gets a TYPE block with the evidence terms.
**Why Ben saw no type at all**: `add_stars`'s no-website early return never wrote shape/category onto the cached record, and that is the MAJORITY of the board. The categoriser had already decided (meme / pure-meme); the answer was simply not carried out of the function.

## SELF-LEARNING CATEGORIES, rebuilt (the old loop was open at both ends)
Opened it and looked: **39 unmatched sites, 0 proposals**, and a proposal would have gone nowhere because nothing accepted one and `classify()` never read them. Worse, most unmatched rows had EMPTY terms and the rest had harvested `class, calc, span, flex, items-center, chunks, cucznd, mbxfry` - CSS class names and hashed bundle identifiers. **The learner's entire vocabulary was framework noise.**
- Harvest from **ptext (visible page copy) only, never the bundle** (new `ev["ptext"]`). A bundle's most frequent words are its framework's.
- **Bigrams as well as unigrams**: "prediction market" is a category, "market" is not.
- `learn()` clusters terms that co-occur on the same sites, auto-accepts at 4 distinct sites, writes to a `learned` table that `classify()` reads at runtime via `all_categories()` - **so the list grows with no code change**. `relabel()` re-labels sites scanned before a category existed.
- **THE RULE THAT REPLACED AN ENDLESS BLOCKLIST: a learned category must be named by a BIGRAM.** A single token may support a cluster and may never name one. `rgba` and `cucznd` survived three blocklist passes; this one rule kills the whole class.
- Second rule learned the same hour: **filter at the point of USE, not only at collection.** The first fix cleaned the harvest and the learner still produced `class`, because rows already in the table bypassed it. `clean_terms()` now runs inside `learn()`. And a junk WORD does not make a junk PHRASE: "market" alone is noise, "prediction market" is a category, so a phrase is rejected only when every word in it is junk.

## THE DEPLOYER CHECK, `/opt/casig/deployer.py` + `gmgnapi.py`
**First build was wrong in a way that looked right.** Blockscout's `creator_address_hash` on Robinhood Chain is the **LAUNCHPAD**, not the person: SPERMA and KNOB shared one "creator", SPARK and COCO shared another, and that one was also PAIREX's. And asking the explorer what else that address created returned ZERO every time, because a pad creates tokens by internal transaction. **Every token on the chain would have read "first token from this deployer, no earlier tokens": one confident sentence, always the same, always wrong.**
- Right source: **GMGN openapi `/v1/token/info` -> `dev`**, 0.2-0.5s, server-side. Carries `creator_address` (the human), `creator_token_status` (hold/close), `fund_from`, `creator_open_count`, `twitter_create_token_count`, and `ath_token_info.ath_mc` - **the best market cap anything this wallet ever launched reached, which is the serial-launcher test in one number**. Plus `/v1/token/security` for honeypot, taxes, renounce, top-10.
- `gmgnapi.py` is a deliberate COPY of two calls, not an import of `/opt/trench/gmgn.py`, because of the one-owner-per-directory rule. Key and rate limiter ARE shared (`/opt/common/gmgn_key`, `gmgnlimit`); Tech Score acquires **without priority** so the Price Predict recorder always outranks it.
- **THRESHOLDS SET FROM MEASURED BASE RATES, not guesses** (`basecheck_dev.py`, 50 launches): renounced 100%, open source 100%, buy/sell tax p90 = 0, locked_ratio always 0, dev sold out 34%, has an earlier token 60%, best-ever-by-this-dev p50 $25K / p90 $277K, top-10 p50 0 / p90 0.10 / max 1.00. **On a launchpad chain the pad sets the contract, so renounce/tax/open-source are CONSTANTS and were removed from the verdict** (`CONSTANTS_CHAIN`); they still vote on other chains. "He has launched before" is not a signal (60% have); **how far anything he launched ever got is**, and $30K splits the population near the middle.
- Levels ok / risky / bad. Measured board mix: **83% ok, 17% risky.** Veto caps stars (honeypot or cannot sell -> 1, heavy sell tax -> 1, top ten over 80% -> 2).
- **PAIREX was briefly vetoed to 1 star for having 22 tokens on its X account** - which is what a working launchpad looks like. `twitter_create_token_count` is now SHOWN and scores nothing: **a signal whose meaning has not been measured must never condemn anything.** Open measurement.

## SIX ERRORS FOUND BY ACTUALLY SCORING THE MATURE PROJECTS
1. **THE GREEN TICK WAS CIRCULAR.** Ben: "How did Sparkles get a check mark implying that this is the correct contract address?" Opened sparkles.dev: it is **"Sparkles - coding agents for your whole team", a real software company**, X handle @Sparklesdotdev, publishing no contract at all, with THREE different Sparkles tokens pointing at it. The tick came from @aidaniil posting the CA. **The X handle on a card is metadata the DEPLOYER chose, so "the linked account posted this CA" is the deployer agreeing with himself.** The website half was never circular (he cannot edit someone else's site); the X half always was. X evidence now needs corroboration: the site links that handle (`scan_site` now returns `handles`), or the account is independently established (>=5,000 followers AND >=90 days old, both captured from the user/info call we already make). Otherwise grey, saying exactly that. **49 false green ticks were sitting in the cache and were purged.**
2. **PONS SCORED 2.** The chain's own $536M launchpad, "shared host www.ponsfamily.com, not a domain this project controls". Fixed with two proofs that a stranger's page can never produce: the page publishes THIS contract, or **the DOMAIN IS NAMED AFTER THE TOKEN** (ponsfamily.com/PONS, long.xyz/LONG). A ticker mention is NOT enough, because every page on a pad mentions the pad. Verified: PONS lifts to 5, a stranger's token on the same pad stays 2.
3. **THE SCAN READ ONLY THE LINK IT WAS GIVEN.** GMGN reports PONS's website as ponsfamily.com/**launchpad**, whose partial bundle gave "own_ca_where: unknown" and no chain code -> 3 stars, while the root gives 5,598 words, six routes, its own contract. `evidence()` now always reads the ORIGIN ROOT as well. This also fixes a quiet bug: the site cache was keyed by HOST while evidence came from a URL, so two subpages of one host shared whichever answer was scanned first.
4. **A LEAN APP READ AS AN EMPTY SHELL.** Fables (PROLOGUE), a live Uniswap v4 ve(3,3) DEX, scored 2: "no product copy anywhere, 7 words on the page, 353 in the bundle" against a 600 threshold. The threshold was wrong in KIND. I tried separating product prose from framework prose and **it does not separate** - React's error strings are prose too ("Double check you have provided the correct parameters"), and plip.run turned out to carry real copy. `is_shell` now needs ALL THREE of page words, bundle words and **number of distinct product strings** to be empty (40 / 250 / 12).
5. **A PROXY OUTLIVED ITS REASON.** Fixing `is_shell` silently drained the scam veto, which read "an empty SHELL that answers 200 to any path, prints a dead address and never names its own token" - and Plip went 1 -> 3 with its evidence completely unchanged. "Empty shell" was standing in for "there is nothing behind this"; the leg is now the thing it meant: **no chain code at all**. **Lesson: name the thing you mean, or a correct fix elsewhere drains your rule.**
6. **A TWEET IS NOT A WEBSITE.** MOO's "website" was an x.com/status link and it scored 2, i.e. we published a judgement about a project's tech based on a tweet. `SOCIAL_HOSTS` now returns "not rated (social_only)". ORDER's pinned case moved 2 -> not rated for the same reason: **forms.gle is a channel, not a product.** And the gate crashed on it, because `"%d" % None` raises - a harness that cannot express "there is no score" cannot test the distinction the ledger is built on.

## GATE AND STATE
`selftest.py` **79 checks, 0 failed**, plus `deployer_cases()` (the pad trap, veto only ever lowers, X reuse never vetoes) and `learner_cases()` (junk is never vocabulary, bigram naming rule, classify reads the learned list). deploy.sh runs parse -> import -> gate -> restart -> **post-restart smoke**. Extension v2: `core.js` + `view_trench.js` + `view_token.js`, one renderer, both views.

## THE MATURE ROBINHOOD BOARD, scored 14 Sep 2026 (`mature_scan.py`, top 60 by mcap, age > 7d)
**5**: PONS 527M (infra-tools), STONKBROKER 27M, BUN 23M, DELTA 8.3M, PROLOGUE/Fables 8.1M, HOOKR 7.6M, MANCER 6.0M, PAIR 5.4M. **4**: WOOD. **2**: Bucket ("simulated balance"). **Not rated (no website)**: WALLET 57M, MEME 46M, ZZZ 30M, UBIK 25M, MOO, SHROOM, website, MSTR, GME.
**21 of 31 rated projects sit on 3, including AI at $250M and CASHCAT at $153M.** Reasons: 14 "no wallet or chain code at all", 8 "one page shape only", 6 "never names the token". After the SPA fixes those are now mostly TRUE (marketing sites with no app behind them), where before they were the scanner's blindness. **The middle of the scale is still the weak part: 3 is where everything with a real site and no app lands, and it does not rank.**
Distribution to date: 5:8  4:1  3:21  2:1  unrated:9. `/opt/casig/mature_scan.json` holds the rows; they are `reference_rows()`, never training rows.


