feat(analytics-skill): extract metrics from prose and sanity-check ranking magnitude

When the ranking value lives in a free-text column, extract the numeric token
with its separators before the strip/scale/cast pipeline; after ranking a
parsed-from-text metric, re-check parse coverage so dropped high-magnitude
values can't silently sink the true leaders out of a top-N.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q2q8GTZBn98eg6zUjDxvR
This commit is contained in:
Kevin Messiaen 2026-07-07 17:43:39 +07:00
parent 49a4ae6f5b
commit 61058d23f1
No known key found for this signature in database
GPG key ID: 19FF750A17315202
2 changed files with 4 additions and 0 deletions

View file

@ -49,6 +49,7 @@ Heuristics for writing *correct* (not merely runnable) SQL. Each is a default pl
- **Cast to the real type before comparing.** Compare a column against a literal of its actual type in `WHERE`/`JOIN`. A string column compared to a numeric literal (or the reverse) can silently match nothing instead of raising an error.
- **Parse text-encoded numerics before doing math on them.** When a column the question treats as a number is stored as text, sample its **distinct** values (the *Sample before you compose* habit) to learn the encodings actually present — unit suffixes (`K`/`M`/`B`), currency symbols, thousands separators, percent signs, and non-numeric sentinels (`-`, `N/A`, empty) — and never infer the format from the column name. *Why:* aggregated or compared as-is the text sorts lexically (`'100' < '9'`) and a naive cast collapses formatted values to `0`/NULL, so the query runs but the number is silently wrong instead of erroring.
- **Strip, scale, and cast in one early CTE.** Strip currency/separator/percent characters, multiply by the suffix scale (`K`=10^3, `M`=10^6, `B`=10^9), map sentinels to `0` **or** `NULL` (by the *Default by additivity* rule below), then cast to a numeric type — all in a single early CTE so every layer above sees clean numbers. This is the *meaning-is-numeric* complement to *Cast to the real type before comparing*. *Why:* one clean conversion at the base keeps the lexical-sort-and-cast-to-0 failure out of every downstream layer.
- **Extract the number from prose before parsing it.** When the value to rank or aggregate by lives inside a free-text/narrative column rather than its own numeric column, first extract the numeric token *with its separators* from the surrounding prose (get the engine's regex/substring-extract idiom from `sql_dialect_notes`), then apply *Strip, scale, and cast* and the failure-detecting cast to that token. Never `CAST` a raw prose substring directly, and never extract only the bare digit-run — a `,`/`_`/space thousands-separator dropped at extraction time silently truncates `"30,522"` to `30`, so the largest entities sink out of a top-N while smaller cleanly-formatted values float up. *Why:* the number embedded in narrative text is not yet isolated, so the existing parse rules cannot see it until extraction lifts it out intact.
- **Confirm the parse covered every value.** After parsing, count the non-sentinel rows that failed to parse — a failed parse should surface as `NULL`, visible only with a **failure-detecting cast** from `sql_dialect_notes` (a plain `CAST` errors on some engines and on sqlite silently returns `0`/partial, so an `IS NULL` check is meaningless there). *Why:* an encoding the sample missed would otherwise vanish into `0`/NULL instead of being caught.
- **Parse code/dependency text by its real grammar, not one broad regex.** When a question extracts imported/required/loaded packages or modules from stored source text or dependency manifests, parse by the *language or format*, not a single pattern: Java `import`/`import static` — drop the terminal class/member, keep the package path, and allow valid identifier segments with underscores and mixed case (e.g. com.planet_ink.coffee_mud); Python — handle both `import a, b as c` and `from a.b import c`, stripping aliases; R — handle `library(...)` and `require(...)`; notebooks (`.ipynb`) — parse the JSON and read each cell's `source` lines *before* applying the language rules (never regex the raw notebook file, whose prose contains the words "import"/"from"); JSON/manifest files — `PARSE_JSON` and flatten the dependency object's keys (e.g. `require`). Strip comments/prose lines first and split multi-import lines so each declared dependency is counted once. *Why:* a single lowercase-segment regex silently drops real identifiers and matches prose, so the ranking is wrong though the query runs.
- **Decide the counting population explicitly when a table is deduplicated.** If the source table is de-duplicated and carries a documented copy/occurrence count (e.g. a `copies` column = "repositories sharing this exact content"), the count grain is a real modeling choice: weight by that column only when the question's population is clearly the represented files/repositories; otherwise count the distinct stored rows. State which population the question names and match it — do not default to one silently. *Why:* on a deduplicated table `COUNT(*)` and `SUM(copies)` give different rankings, so the right metric depends on the population the question asks about, not on which is larger.
@ -185,6 +186,7 @@ FROM account_txns;
- **A comparison BETWEEN two specific extremes is one wide row.** When the question asks for a single value derived by comparing two named extremes — "the **difference between** the highest and the lowest month", "the ratio of the best to the worst" — present BOTH extremes side by side in ONE row: each extreme's attributes as their own columns (e.g. `highest_month`, `highest_value`, `lowest_month`, `lowest_value`) plus the comparison as a column (`difference`). The comparison is a single fact about the pair, so the answer is one wide row — NOT one row per extreme with the comparison repeated. (Contrast: "report a metric **for each** group/category" — e.g. "a percentage for each helmet group", "the top player for each outcome" — has no cross-item comparison and stays long, one row per group.)
- **Project BOTH identity and label.** When the result is per-entity, project the entity's **identifier and its human-readable name together** — whichever you grouped by, add the other. The id disambiguates duplicate names, and a consumer may legitimately expect either; supplying both is the safe, complete choice (a per-entity answer that gives only one is a frequent cause of an otherwise-correct result not matching).
- **Diagnose empty results.** When a result is unexpectedly empty, relax filters one at a time to find which predicate removed the rows instead of guessing.
- **A top-N built on a parsed-from-text metric must pass the parse-coverage check before you answer.** After ranking by a value parsed out of text, run the *Confirm the parse covered every value* count (non-sentinel rows that are `NULL` after the failure-detecting cast). If any high-looking rows failed to parse, the top-N may be missing its true leaders — re-inspect the raw narrative of the unparsed rows (a comma/suffix/locale format the strip missed is the usual cause) and fix the extraction before answering. This grounds "does the ceiling look too small?" in an observable parse-failure count, not a guessed threshold — do not invent a min-magnitude filter. *Why:* a dropped high-magnitude value does not error, it vanishes from the ranking, so a confidently-wrong top-N is indistinguishable from a correct one without the coverage check.
- **Spatial predicates ("within area / within N meters / inside this polygon / nearest").** When a question filters or relates rows by geography, use the engine's geospatial functions — get the exact ones from `sql_dialect_notes` — rather than hand-rolling latitude/longitude `BETWEEN` boxes (which are wrong off the equator and ignore polygon shape). Recipe: (1) turn each location into a geography point with the point constructor — **mind argument order, most take longitude before latitude**; (2) for an area of interest build a polygon from its boundary/corner coordinates, closing the ring (first point repeated last); (3) test the relation with the engine's containment (`contains`/`within`), proximity (`dwithin(g1,g2,meters)`), or overlap (`intersects`) predicate. For "the features within the same area as entity X", first resolve X's own geometry in a CTE, then join candidates on the spatial predicate against it. *Why:* spatial relationships are not axis-aligned ranges; the geodesic predicates are both correct and index-assisted, while a raw coordinate box silently includes/excludes the wrong rows.
- **Collapse a multi-valued attribute to one representative per entity before counting classes or a concentration metric.** When an entity carries a multi-valued classification array (IPC/CPC codes, tags, categories) and the methodology counts *entities per class* or computes a concentration/diversity measure (HHI, originality, a share), pick exactly **one representative value per entity** in a CTE first — use the array's `main`/`primary`/`first` flag when present, else a defined fallback (e.g. the most-frequent value) — then aggregate. Equally, when a metric's denominator is defined as a count of **entities** ("the number of patents cited"), use `COUNT(DISTINCT entity)`, not the count of exploded array rows. *Why:* `LATERAL FLATTEN`/unnest of the array multiplies an entity's weight by how many codes it has, inflating per-class frequencies and skewing any concentration metric — the query runs but the ranking/score is wrong. (Take the representative rule from the methodology/`external_knowledge` doc when it specifies one; do not invent a selection the source does not state.)
- **Final completeness check.** Before emitting the final SQL, re-read the question and confirm the projection covers: (1) every named **metric / attribute** asked for (→ *answer every requested output*); (2) the **identifier** of each grouped or named entity (→ *expose identity*); (3) every **input** to each derived value (→ *keep the inputs*); (4) all at the **grain** the question specifies (→ *for each X* / *complete the panel*). Run this on every query, not only when a result looks off. **Don't over-project:** anything outside that set — a column the question never asked for, added "to be safe" — adds noise, misleads the reader into thinking it matters, and makes the result harder to consume. Match the request exactly: neither short nor padded.

View file

@ -48,6 +48,8 @@ describe('analytics SKILL.md SQL craft', () => {
'Parse text-encoded numerics before doing math on them', // detect text-encoded numbers (spec 12)
'Strip, scale, and cast in one early CTE', // parse/scale early (spec 12)
'Confirm the parse covered every value', // failure-detecting cast coverage (spec 12)
'Extract the number from prose before parsing it', // lift the numeric token out of a narrative column first (spec 07)
'must pass the parse-coverage check before you answer', // parsed-from-text ranking re-checks parse coverage (spec 07)
'Answer every requested output', // multi-part/multi-output umbrella over identity+inputs (spec 14)
'Final completeness check', // re-read the question, confirm the projection covers all four facets (spec 14)
"Don't over-project", // match the request exactly, no padding columns (spec 14)