normalizing-text-for-measurement
Skill111 starsChanged 18 days agoMITView on GitHub
Strip, parse, and segment a document before something else measures it — normalizers that fuse two blocks into one word because a close-token emits no separator, the two metric classes failing on different residue — character-based ones (reading time, token budgets, size limits) inflated by leftover syntax, word-based ones (word count, readability) corrupted by boundaries the strip never inserted — sibling consumers that skip the normalizer entirely, parser presets whose rule set silently reclassifies a construct, sections grouped by a non-unique heading key that collapse and double-count into an ancestor, and deliberate lossiness that the next reader mistakes for a bug. Use when writing or reviewing a markdown/HTML stripper, a plain-text extractor, a section or paragraph splitter, a reading-time/word-count/readability/token-count function, a dedup key or checksum built from prose, or a search-index preprocessing step.
In this repository41 other files it gives its agents.
- building-accessible-interfacesSkill
- wiring-application-configSkill
- shipping-build-artifactsSkill
- permissioning-ci-workflowsSkill
- concurrent-branchesSkill
- shipping-across-surfacesSkill
- writing-defect-reportsSkill
- reporting-derived-metricsSkill
- guarding-destructive-operationsSkill
- running-github-actions-efficientlySkill
- keeping-git-repos-cleanSkill
- designing-interaction-statesSkill
- designing-marketing-pagesSkill
- rendering-untrusted-contentSkill
- reproducing-ci-locallySkill
- running-resumable-sync-jobsSkill
- calibrating-thresholds-and-baselinesSkill
- designing-interfacesSkill
- verifying-external-behaviorSkill
- building-go-projectsSkill
- building-browser-extensionsSkill
- designing-python-apisSkill
- building-python-clisSkill
- improving-python-code-qualitySkill
- building-python-communitiesSkill
- documenting-python-librariesSkill
- deferring-heavy-dependenciesSkill
- reviewing-python-librariesSkill
- building-llm-backed-featuresSkill
- building-python-mcp-serversSkill
- packaging-python-librariesSkill
- optimizing-python-performanceSkill
- setting-up-python-librariesSkill
- managing-python-releasesSkill
- auditing-python-securitySkill
- testing-python-librariesSkill
- building-python-web-appsSkill
- building-rust-cratesSkill
- building-scala-projectsSkill
- building-swift-appsSkill
- shipping-swift-appsSkill
The file
We split this skill into the rules it gives an agent
6 rules. About 0 are common, 4 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈4
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 51
Every metric consumes normalized text.
- Very specific: line 98
Check the segmented paths too.
- Very specific: line 116
Before trusting a parse tree, print the enabled rule set (md.get_active_rules() or the library's equivalent) and confirm the constructs you …
- Distinctive: line 122
Treat this as one case of a general habit: verify what a dependency actually does on your input rather than what its README implies.
- Distinctive: line 171
Keep the original heading separately for display.
- Distinctive: line 220
A test that pins buggy output is worse than no test: When you find a normalizer defect, read its test file before assuming it is uncovered …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Normalizing Text for Measurement
Any pipeline that reduces a document to a number — word count, reading time, readability, a token budget, a dedup key, a checksum, a search index — has two halves: a normalizer that turns markup into something comparable, and a measurer that consumes it. Reviews concentrate on the measurer. Almost every bug in this shape lives in the normalizer, and none of them raise.
The failure mode is uniform: the output is still a plausible string, the metric is still a plausible number, and nothing anywhere reports a problem. See reporting-derived-metrics for the other end of the same pipeline — what the measurer does when the sample is too small.
A missing separator fuses two blocks into one token
A token-walking stripper emits text for content tokens and separators for close tokens. Miss one close token and two blocks become one word.
# Bad — paragraphs are separated, headings are not
for token in tokens:
if token.type == "inline":
out.append(token.content)
elif token.type == "paragraph_close":
out.append("\n\n")"# Title\n\nBody text here." normalizes to "TitleBody text here.". One word
instead of four, and a word that appears in no dictionary. Every consumer
inherits it:
- word count drops by one per heading
- average word length rises, so readability scores report plain prose as far harder than it is — and the error scales with heading density, so the most structured documents are the most wrong
- a dedup key or checksum diverges from the same prose written without a heading
The general rule: every block-level construct the parser opens must close with
a separator. Enumerate the close-token types your parser emits (heading_close,
list_item_close, blockquote_close, table_row_close, …) and account for each
one explicitly. A stripper that handles two of six is not "mostly right"; it is
wrong on every document containing the other four.
Both metric classes need normalized text — they break on different residue
Every metric consumes normalized text. Classifying a metric does not tell you whether it needs the normalizer; it tells you which failure to hunt for first.
- Character-based metrics (reading time computed as
chars × ms_per_char, byte-size limits, character quotas, rough token estimates) count every character of syntax as something a human reads. They are ruined by leftover syntax characters: a link's target URL, an image's path, fence delimiters, table pipes. On link- and image-heavy prose a character-based reading time can run roughly double the true figure; a single small table adds a noticeable double-digit percentage. The overshoot grows with markup density, so it is worst on exactly the documents (docs pages, READMEs, changelogs) the estimate matters most for. - Word- and syllable-based metrics (word count, readability grade levels, syllable ratios) tokenize on whitespace and punctuation, so leftover delimiters largely wash out — table pipes leave readability scores essentially unchanged. What ruins them is a lost block boundary: the fused heading in the previous section corrupts the word and syllable stream itself, and no amount of downstream tokenizing recovers it.
Audit rule: for every metric, confirm it consumes normalized text. Then check the failure its class is blind to — character-based ones for syntax that survived the strip, word-based ones for boundaries the strip failed to insert. Both are verified the same way, by comparing against a plain-text equivalent rather than asserting a shape.
Enumerate every consumer of the normalizer
The most common version of this bug is not a broken normalizer — it is a sibling function that never calls it.
def readability(text: str) -> dict:
return score(strip_markup(text)) # normalized
def reading_time(text: str) -> float:
return textstat.reading_time(text) # not normalized — same module, same inputNothing fails. The two functions take the same argument, live in the same file, are described the same way in the docs, and disagree by a factor of two on any markdown input. Whenever you add or fix a normalizer:
- Grep for every call site of the measurer, not the normalizer, and check each one goes through normalization.
- Grep for every call site of the normalizer and note them — the count matters in both directions. Once a stripper has two production consumers, a one-line change to it moves two public outputs, and the PR description has to say so.
- Check the segmented paths too. If the API exposes
level="full" | "section" | "paragraph", a fix applied to the full-document path may leave the segmented ones on the old behaviour.
A parser preset is a rule set you did not choose
Constructing a parser with no arguments selects a default profile, and that profile decides which constructs are recognized at all.
md = MarkdownIt() # CommonMark preset — no `table` ruleA GFM table under this preset is not a table. It is an ordinary paragraph whose
text happens to contain | and ---, so every delimiter survives normalization
into what you are calling "plain text". Nothing errors, because a paragraph is a
perfectly valid parse.
Before trusting a parse tree, print the enabled rule set (md.get_active_rules()
or the library's equivalent) and confirm the constructs you actually receive are
in it. When you enable a rule, the stripper needs matching close-token handling
in the same change — enabling table without emitting a space at cell close and
a newline at row close just moves the problem.
Treat this as one case of a general habit: verify what a dependency actually does on your input rather than what its README implies. See verifying-external-behavior.
Grouping by a natural key that is not unique
Segmenting a document into sections and returning them as a dict keyed by
heading text assumes headings are unique. They are not — ### Example,
## Notes, and ## Usage repeat all through real documents.
# Bad — the second "## Notes" overwrites or is discarded
sections = {heading_text: body for heading_text, body in walk(doc)}Two consequences, and the second is the one people miss:
- Collapse. The first occurrence keeps the key; later ones are never reported under any key of their own. A document with four sections returns three.
- Double counting. The lost content usually is not dropped. A hierarchy stack extends it into the nearest ancestor section, so the ancestor's word count and reading time silently include a body that is also reported elsewhere. With no ancestor, it appears only in the full-document text. So the per-section numbers neither sum to the whole nor stay disjoint.
Key sections by something structurally unique — an index, a (level, index) pair,
or a slug with a disambiguating suffix — and carry the display heading as a field.
Then audit downstream: any dict comprehension over the sections and any
processed_keys-style dedup guard was written assuming uniqueness too.
A generated suffix can collide with a literal heading
A per-heading occurrence counter is not enough: ## Notes, another ## Notes,
and a literal ## Notes (2) can still produce the same key twice. Allocate each
key against all keys already used, at the point sections are first created:
used_keys = set()
def allocate_section_key(heading):
candidate = heading
suffix = 2
while candidate in used_keys:
candidate = f"{heading} ({suffix})"
suffix += 1
used_keys.add(candidate)
return candidateKeep the original heading separately for display. Unique headings retain their original keys unless a prior allocation already claimed that key. Test repeated headings and literal suffixed headings in both orders; assert every body survives under a distinct key. Reverting to heading-only keys catches the original collapse; replacing the loop with a per-heading counter must fail the literal-suffix case. The ordinary duplicate-heading fixture alone cannot distinguish the correct fix from that plausible wrong fix.
Write down which lossiness is deliberate
Normalizers are intentionally lossy in places. A stripper may drop list markers
so - a\n- b measures as two bare items, or ignore raw HTML block content
entirely. Those are design decisions, and they are indistinguishable at a glance
from the missing-separator bug above.
Both directions of the mistake happen: a deliberate drop gets "fixed" by someone
who read it as a defect, and an accidental fusion gets pinned by a test and
survives for years as apparent intent. Put a one-line comment stating the intent
next to the branch, and make the test name say it (test_list_markers_are_dropped,
not test_list_output). A future reader should be able to tell which branches are
decisions without reconstructing the argument.
Testing a normalizer without depending on a third-party constant
Two rules specific to this pipeline; for mutation-testing technique generally see testing-python-libraries.
Assert equivalence, not magic numbers. The strongest assertion needs no constant from the analysis library at all: the markdown input must measure identically to its hand-written plain-text equivalent.
def test_markdown_measures_as_its_plain_equivalent():
markdown = "# Title\n\nSome [linked](https://example.com/a/long/path) text.\n"
plain = "Title\n\nSome linked text.\n"
assert reading_time(markdown) == reading_time(plain)
assert strip_markup(markdown).strip() == "Title\n\nSome linked text."
assert len(strip_markup(markdown).split()) == 3This fails hard when the stripper regresses, and keeps passing when the scoring
library is upgraded. If the analysis dependency is unpinned, an exact-float
assertion on its output is version-brittle: the same document can score several
points differently across releases. Where you want a number for documentation,
use a tolerance sized against the bug's own gap (if the defect moved a score by
~30 points, abs=1.0 is both a real value assertion and version-tolerant), and
measure the expected value by running the code — never estimate it by hand.
These constants are not intuitive and a plausible guess fails CI.
A test that pins buggy output is worse than no test. When you find a normalizer defect, read its test file before assuming it is uncovered — often the wrong output is already asserted verbatim, which is why nobody noticed. Fixing the bug means updating those assertions, and there is usually more than one: the obvious unit test named after the construct, and a longer "mixed content" or "end to end" fixture whose expected string contains the same fused or dropped text mid-way. Scope the change by grepping the fixture bodies for the affected text, not by the test names.
A whitespace-only or empty input test guards nothing here. Most measurers
already return 0 for " \n\n " before any fix, so such a test passes on the
broken code and reads as coverage it is not.
Review checklist
- Every block-level close token the parser emits has an explicit separator branch; the list was enumerated from the parser, not from memory
- Every metric consumes normalized text; character-based ones additionally audited for leftover syntax characters, word-based ones for fused block boundaries
- Every call site of the measurer goes through the normalizer — including
segmented (
section/paragraph) paths, not just the full-document path - Number of production consumers of the normalizer is known, and a change to it names every output it moves
- Parser rule set confirmed to include the constructs the input actually uses; newly enabled rules have matching close-token handling
- Section/segment maps keyed by something structurally unique, not by heading text; downstream comprehensions and dedup guards audited for the same assumption
- Deliberate lossiness commented at the branch and named in the test
- Regression tests assert markdown-equals-plain-equivalent plus the exact stripped string and word count, rather than a float from an unpinned library
- Existing tests checked for pinned buggy output before declaring the defect untested
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

