diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index aef6ecb..532c81d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,14 +7,14 @@ }, "metadata": { "description": "Remove AI tells from prose. Skill + /humanize command grounded in StoryScope (arXiv 2604.03136).", - "version": "0.1.2" + "version": "0.2.0" }, "plugins": [ { "name": "humanize", "description": "Audit prose for AI tells and rewrite it to read as natural human writing, grounded in StoryScope's measured human-vs-AI feature gaps", "source": "./plugins/humanize", - "version": "0.1.2", + "version": "0.2.0", "author": { "name": "ccf" }, "license": "MIT", "keywords": ["writing", "prose", "editing", "ai-detection", "storyscope", "humanize"], diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 2ec4d6a..4a28ce5 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -1,7 +1,8 @@ # Bugbot review guide — humanize This repo is a Claude Code plugin that audits prose for AI tells and rewrites -it. Design spec: `docs/design/2026-09-13-humanize-plugin-design.md`. +it. Design spec: `docs/design/2026-09-13-humanize-plugin-design.md` and +`docs/design/2026-09-14-humanize-v0.2-design.md`. ## Invariants to enforce @@ -10,13 +11,19 @@ it. Design spec: `docs/design/2026-09-13-humanize-plugin-design.md`. or 3.10+ syntax (match statements, `X | Y` in runtime positions, PEP 604 in non-annotation code). - Nothing under `tests/` or `plugins/**/scripts/` makes network or LLM calls. -- Every base-rate number in `plugins/humanize/skills/humanize/references/*.md` - must trace to `data/storyscope_feature_gaps.csv`. If a PR changes a number, - check the CSV row. +- A `Base rate:` line in `plugins/humanize/skills/humanize/references/*.md` + must trace to a row in `data/storyscope_feature_gaps.csv` (or a future CSV + documented in `data/README.md`; none added in v0.2). If a PR changes a + number, check the CSV row. Any other number must sit on a `Scan:`, `Rule + of thumb:`, or `Vintage:` line carrying an inline `[author-year]` key that + resolves in `references/SOURCES.md` (`tests/test_manifests.py` enforces + this); flag a human/AI rate sourced from a model-vs-model comparison. - Reference-doc entries use the exact five-line shape: `### name` / `Looks like:` / `Base rate:` (or `Scan:`) / `Why it reads as AI:` / `Fix: — …`, plus a sixth `Outside fiction:` - line on every entry in style-tells.md. + line on every entry in style-tells.md. Optional `Rule of thumb:` and + `Vintage:` lines may follow. `rebalance` also covers register-dependent + features fixed by proportion, not deletion (nominalization). - `SKILL.md` body stays under ~150 lines. - The skill and README never claim output is "undetectable", passes a detector, or is "certified human". @@ -29,6 +36,8 @@ it. Design spec: `docs/design/2026-09-13-humanize-plugin-design.md`. - Division by zero and empty input in every rate/statistic helper. - Test assertions that encode the implementation's current output rather than the intended behavior. +- A number on a `Why it reads as AI:` line, or a `[key]` absent from + `SOURCES.md` (grep `\[[a-z-]*-[0-9]\{4\}\]`). ## Do not review diff --git a/CHANGELOG.md b/CHANGELOG.md index 23e8c06..2295bd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.2.0] - 2026-09-14 + +### Added +- Repetition block: verbatim phrase and whole-sentence repeat detection, + capped at a 60-token phrase length. +- Grammar block: participial-tail and container-noun detection; nominalization + hits. +- Disclaimer-opener detection and sentence-tail keys in the scan output. +- Four new `--text` summary lines; five new surface tells. +- Principle 8: register and proficiency are not tells. +- `references/SOURCES.md`, the citation registry for non-StoryScope numbers. +- Report fixtures with sensitivity, specificity, direction, and pinned + fairness gates. +- `SKILL.md` line-budget test. + +### Changed +- `SKILL.md`: register gate, passive guard, and convergence check. +- Provenance invariant now allows cited non-StoryScope numbers on `Scan:`, + `Rule of thumb:`, and `Vintage:` lines. +- Apostrophe normalization changes `words`, `wordlist.rate`, and every per-1k + rate on text containing look-alike apostrophe glyphs (they now tokenize as + one word). + +### Fixed +- Apostrophe look-alike glyphs between letters. + ## [0.1.2] - 2026-09-14 ### Added @@ -61,7 +87,8 @@ Initial release (#1). CI (pre-commit, pytest on Python 3.9 and 3.13, `claude plugin validate --strict`), and a Bugbot review guide. -[Unreleased]: https://github.com/ccf/humanize/compare/v0.1.2...HEAD +[Unreleased]: https://github.com/ccf/humanize/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/ccf/humanize/compare/v0.1.2...v0.2.0 [0.1.2]: https://github.com/ccf/humanize/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/ccf/humanize/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/ccf/humanize/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index bcd15dc..86b9b10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ StoryScope (arXiv 2604.03136). Spec and plan: `docs/design/`. Changelog: `CHANGE ``` uv sync # first time -uv run pytest -q # 53 tests, must be warning-free +uv run pytest -q # 100 tests, must be warning-free uv run ruff format && uv run ruff check --fix claude plugin validate --strict . uv run python plugins/humanize/skills/humanize/scripts/surface_scan.py --text @@ -24,12 +24,24 @@ Never run bare `ruff format .` — ruff 0.16 formats Python fences inside - Regex literals contain curly quotes and dashes (’ “ ” — –). Copy them exactly; dropping one splits a raw string into several literals and breaks matching. Use heredocs for `python -c` probes — inline quoting mangles them. -- Every base rate in `references/*.md` traces to a row in - `data/storyscope_feature_gaps.csv`. Do not type numbers from memory. +- A `Base rate:` line traces to a row in `data/storyscope_feature_gaps.csv` + (or a future CSV documented in `data/README.md`; none added in v0.2). Any + other number in `references/*.md` sits on a `Scan:`, `Rule of thumb:`, or + `Vintage:` line with an inline `[author-year]` key that resolves in + `references/SOURCES.md` (`tests/test_manifests.py` enforces it); + model-vs-model sources never appear as a human/AI rate. Do not type numbers + from memory. - Reference entries are exactly: `### name` / `Looks like:` / `Base rate:` (or `Scan:`) / `Why it reads as AI:` / `Fix: — …`; `style-tells.md` adds `Outside fiction:`. Fix tag follows direction: `removal` when AI shows more, `addition` when humans show more, `rebalance` for scales. + Optional lines: `Rule of thumb:`, `Vintage:`. `rebalance` also covers + register-dependent features where the fix is proportion, not deletion + (nominalization). +- New scanner blocks (`repetition`, `grammar`, `nominalization`, + `discourse.disclaimer_opener`, `sentence_len` tails) are counts and quotable + hits; `nominalization` has no rate by design. Directional keys are gated on + `tests/fixtures/`; reported-only keys are never thresholded. - The skill and README never say output is "undetectable", passes a detector, or is "certified human". - `SKILL.md` stays under ~150 lines. `/humanize` is the skill itself — do not add diff --git a/README.md b/README.md index 298a198..12c816f 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,16 @@ flattening the author's voice. It is grounded in [StoryScope](https://github.com/jenna-russell/storyscope) (Russell, Rajendhran, Pham, Iyyer, Wieting, *StoryScope: Investigating -idiosyncrasies in AI fiction*, [arXiv:2604.03136](https://arxiv.org/abs/2604.03136)), -which measured 304 narrative and stylistic features on 61,575 stories and found -that AI writing converges on shared defaults while human writing disperses. -This plugin turns the 77 features with the largest human-vs-AI gaps into an -audit checklist, adds the surface-level tells StoryScope deliberately excluded, -and pairs both with a dependency-free scanner for the numbers a model can't -eyeball. +idiosyncrasies in AI fiction*, +[arXiv:2604.03136](https://arxiv.org/abs/2604.03136)), which measured 304 +narrative and stylistic features on 61,575 stories and found that AI writing +converges on shared defaults while human writing disperses. This plugin turns +the 77 features with the largest human-vs-AI gaps into an audit checklist, +adds the surface-level tells StoryScope deliberately excluded, and pairs both +with a dependency-free scanner for the numbers a model can't eyeball. v0.2 +adds a grammar and repetition layer from register and reader-perception +studies (Reinhart et al. 2025; Jakesch et al. 2023; Herbold et al. 2023 and +others); every cited number resolves in `references/SOURCES.md`. ## Install @@ -90,6 +93,9 @@ Rewrite (excerpt): choice, and the report says so. - **Numbers are evidence, not verdicts.** This is a writing tool. It never claims text is undetectable or "certified human". +- **Register and proficiency are not tells.** Formal, plain-language, + technical, and second-language prose share the measured AI profile; the + plugin measures it and never infers authorship from it. ## What's inside @@ -103,8 +109,11 @@ plugins/humanize/ style-tells.md 20 StoryScope style features with base rates narrative-tells.md 57 StoryScope narrative features (fiction only) model-fingerprints.md Claude / GPT / Gemini / DeepSeek / Kimi tendencies - scripts/surface_scan.py stdlib-only metrics: burstiness, punctuation, - tricolons, not-but, wordlists, closers + SOURCES.md citation registry (not loaded at runtime) + scripts/surface_scan.py stdlib-only metrics: burstiness and sentence tails, punctuation, + tricolons, not-but, wordlists, closers, repeated phrases, + participial tails, container nouns, nominalization hits, + disclaimer opener data/ StoryScope taxonomy + computed feature gaps tools/gen_tell_scaffold.py regenerate reference scaffolds from the data tests/ pytest; no network, no LLM calls @@ -128,8 +137,9 @@ compare. ## Credit and license -MIT. StoryScope code and data are MIT-licensed; base rates in the reference -docs are computed from their released `storyscope_features.parquet` (see -`data/README.md`). They were measured on fiction and are used here as evidence, -not verdicts. The AI fiction test fixture is from StoryScope's released -dev split; the human fiction fixture is public domain. +MIT. StoryScope code and data are MIT-licensed; `Base rate:` lines are +computed from their released `storyscope_features.parquet` (see +`data/README.md`); every other cited number carries an `[author-year]` key +resolved in `references/SOURCES.md`. Base rates were measured on fiction and +are used here as evidence, not verdicts. The AI fiction test fixture is from +StoryScope's released dev split; the human fiction fixture is public domain. diff --git a/docs/design/2026-09-14-humanize-v0.2-design.md b/docs/design/2026-09-14-humanize-v0.2-design.md new file mode 100644 index 0000000..4d461bf --- /dev/null +++ b/docs/design/2026-09-14-humanize-v0.2-design.md @@ -0,0 +1,574 @@ +# humanize v0.2 — design + +Date: 2026-09-14 +Status: implemented (v0.2.0); post-review amendments at the end. Revision 1 +had a three-lens adversarial review (13 blockers, 34 important, 24 minor); +revision 2 a scoped re-review (1 blocker, 14 important, 14 minor, 7 +carry-overs). All adjudicated below. +Builds on: `2026-09-13-humanize-plugin-design.md` (v0.1) and the short list in +`2026-09-14-research-review.md`. + +## Purpose + +v0.1 grounded every tell in one corpus (StoryScope, fiction, 2026 models). The +research review found four layers that corpus does not cover and one live +scanner defect. v0.2 adds the cheap, well-evidenced parts of those layers, +makes the evidence base citable without weakening the base-rate invariant, and +adds the fairness rail the review identified as the single largest gap. + +### Goals + +1. Fix the apostrophe-glyph defect that makes scanner rates wrong on real + model output. +2. Add a grammar/repetition layer to the scanner: verbatim repetition, trailing + participial clauses, container-noun phrases, nominalization hits, a + disclaimer-opener check, and sentence-length tail/sequence statistics. +3. Add rewrite guards to the skill: a per-check register gate; do not strip + passives by reflex; verify by scan and quoted spans, and check for + convergence. +4. Add the fairness principle ("register and proficiency are not tells") and + the adjudication rule ("check the direction before you flag it"). +5. Add five surface-tell entries, extend two, and date-stamp the wordlist. +6. Add `references/SOURCES.md` with grep-checkable citation keys, and amend the + provenance invariant so numbers from other sources are citable. +7. Gate every directional metric on the plugin's own fixtures — sensitivity on + an AI sample, specificity on four human samples including a modern + plain-language text whose flat profile is pinned as *not* evidence — and + ship undirected keys as reported-only. + +### Non-goals (deferred or rejected, by decision) + +`--by-paragraph` hotspots and `--halves`; contraction and connective blocks; +`FICTION_PHRASES`; a separate `grammar-tells.md`; `adverbs.ly_rate`; MTLD, +type-token, or any lexical-diversity check; `distinct_4gram`/`distinct_5gram`; +`pct_over_40`; `lag1_mean_abs_delta`; per-model grammar quirks; a second data +CSV; anything that labels a passage as model-written. Detector evasion remains +out of scope. Everything is English-only (see principle 8). + +## 0. Conventions used in this spec + +- **Hit shape (span-level blocks: `grammar.*`, `discourse.*`, + `repetition.phrases`).** Each hit is `{text, sentence}`: `text` runs from the + start of the match (a leading comma included) to the first `,` `;` `:` `—` or + sentence terminator that follows the matched head word, or 60 characters, + whichever comes first; `sentence` is the integer index into + `split_sentences(...)`. Exceptions, named once: `repetition.phrases` entries + carry `{text, count, sentences}`; `nominalization.hits` are lemma-level + `{text, count}` with no index; `nominalization.of_frames` are + `{text, sentence}`. All new patterns run per sentence, so no match crosses a + sentence or paragraph boundary. +- **Citation key.** Every source has a slug key in `references/SOURCES.md`, + normally `author-year` (`reinhart-2025`); a corpus or unauthored survey may + use a short-title slug (`storyscope-2026`, `survey-2025`). Inline form: + `(Reinhart et al. 2025 [reinhart-2025])`. `grep -o '\[[a-z-]*-[0-9]\{4\}\]'` + over `references/*.md` must resolve every key; a test enforces it. +- **Where numbers live.** A `Base rate:` line traces to a row in + `data/storyscope_feature_gaps.csv`. Any other number sits on the `Scan:` or + `Rule of thumb:` line with its citation key. The `Why it reads as AI:` line + carries mechanism and genre caveat and no figures. Ratios from non-CSV + sources never appear in the audit table's base-rate column. Permitted entry + lines: the five required ones plus optional `Rule of thumb:`, `Vintage:`, and + (style-tells only) `Outside fiction:`. + +## 1. Scanner (`plugins/humanize/skills/humanize/scripts/surface_scan.py`) + +Stdlib only, Python 3.9. Every existing key of `analyze()` is unchanged and +`_stats()` is unchanged (existing exact-equality tests on `structures` and on +`_stats` output keep passing); new keys are added under new names, and the +`sentence_len` additions are merged into that dict after `_stats` returns. New +blocks report counts and quotable hits. Undirected keys are reported-only: +never gated, thresholded, or given a `Fix:`. + +### 1a. Apostrophe normalization (bug fix) + +`normalize_apostrophes(text) -> str`: replace U+02BC (ʼ), U+02B9 (ʹ), U+00B4 +(´), U+2018 (‘), U+2019 (’), U+2032 (′) with ASCII `'` when the glyph sits +between two word characters: `(?<=\w)[ʼʹ´‘’′](?=\w)`. U+0060 (backtick) is +deliberately excluded — it is Markdown syntax. Called in `analyze()` +**immediately after** `strip_markdown`. + +Verified today: `don´t` splits into two tokens; `Itʼs worth noting` tokenizes +as one word (U+02BC is a `\w` character) but misses the wordlist term because +`_term_re` expands only ASCII `'` and `’`. + +Tests: tokenization — `don´t` → one token; term matching — `Itʼs worth +noting` → wordlist hit; unchanged — `‘quoted’`, `rock ’n’ roll`, and `'go +now'` round-trips byte-identical; Markdown interaction — a paragraph with two +inline-code spans, one written as `` `dict`s ``, loses only the code spans. + +### 1b. `repetition` block — maximal repeated phrases + +Over the lowercased word stream of each sentence (grams never cross a sentence +boundary; each gram carries its sentence index): + +1. Count every n-gram for n from 4 up to the sentence's own length (no fixed + ceiling, so a verbatim repeated sentence of any length is one phrase; + sentences are short enough that the O(L²) grams per sentence are cheap). +2. A repeated gram (count ≥ 2) is **maximal** if no (n+1)-gram containing it + has the same count. +3. Drop any maximal gram that is a substring of a longer surviving gram + (nested repeats with different counts are attributed to the longest phrase + only). +4. A phrase counts only if at least two of its tokens are outside the + function-word set (articles, prepositions, pronouns, auxiliaries, + conjunctions, determiners — one list in the module). "at the end of" (one + content word) is excluded; "across all workstreams and teams" (two) is kept. + +Worked check: a 5-word phrase repeated 3 times contributes Σ(count − 1) = 2 and +appears once in `phrases`; an 18-word sentence repeated twice contributes 1. The +substring drop applies only when the longer phrase has the same count, so a +more frequent short phrase survives inside a rarer long one. + +| key | meaning | status | +|---|---|---| +| `repetition.too_short` | true under 150 words; other keys zero/empty; the `--text` line reads "repetition: not measured (under 150 words)" | — | +| `repetition.repeated_phrase_rate` | Σ (count − 1) over counted phrases, per 1k words, 1 dp | reported-only (no direction: measured 3.3/1k on Federalist, 8.3/1k on plain-language guidance, 6.8/1k on `ai_email.txt`) | +| `repetition.longest_repeat` | token length of the longest counted phrase | reported-only | +| `repetition.phrases` | up to 5 `{text, count, sentences}` by count desc, then length desc | the tell's evidence; gated | + +n ≥ 4 is this module's tuning choice; the sources measure n ≥ 3 (Jakesch et +al. 2023 [jakesch-2023], repeated phrases the strongest true-source predictor, +OR 1.47; Padmakumar & He 2024 [padmakumar-2024]). Counts, not verdicts: +fiction repeats refrains on purpose; terminology, names, and identifiers must +repeat in technical and legal prose. + +### 1c. `grammar.participial_tail` + +Per sentence: `,\s+(?:\w+ly\s+)?(\w+ing)\b` where + +- (a) the `-ing` word is not in `ING_STOPLIST` — words that are never a + participle in tail position: morning, evening, thing, something, nothing, + anything, everything, during, including, following, according, regarding, + concerning, notwithstanding, pending, considering, king, ring, spring, + string, wing, ceiling, wedding, clothing, painting, meeting, training, + funding, housing, beginning. (Live participles such as meaning, building, + opening, feeling are deliberately *not* stoplisted.) +- (b) the comma does not close a sentence-initial adverbial: skip when the + match is the sentence's first comma, the prefix begins with a preposition or + subordinator (in, on, at, by, after, before, during, since, for, with, from, + under, over, within, when, while, if, although, as, once, until), and the + prefix contains no finite verb or auxiliary (is, are, was, were, be, been, + has, have, had, do, does, did, will, would, can, could, should, may, might, + must, shipped-type verbs are detected as: any token ending in -ed, -s after + a pronoun/noun is *not* attempted — the auxiliary list plus "-ed" tokens is + the whole test). Kills "In 2024, rising costs…"; keeps "We shipped the + release, ensuring alignment across teams." +- (c) the `-ing` word is not immediately followed by `,` `and` or `or` (kills + gerund lists "planning, testing, and shipping"). A sentence-final participle + counts: ', she said, smiling.' and 'Costs rose, driving the decision.' are + hits. + +Keys: `count`, `rate` per 1k (1 dp), `hits` (≤ 10, §0 shape). Ratio, not a +base rate: 5.3×, d = 1.38, measured on 2024-era GPT-4o/Llama 3 across news and +academic registers (Reinhart et al. 2025 [reinhart-2025]). `ING_STOPLIST` and +the (b) word lists are the tuning knobs; hits are shown so the auditor can +overrule. + +### 1d. `grammar.container_of` + +Closed list of the 13 heads attested in LAMP Table 8 (Chakrabarty et al. 2025 +[chakrabarty-2025]): sense, mix, blend, weight, flicker, pang, glimmer, web, +sea, mask, residue, fabric, foundation. Per sentence, case-insensitive: +`\b(?:a|an|the)\s+(?:\w+\s+)?(?:)\s+of\b` (one optional modifier — +"a quiet sense of"). Keys: `count`, `hits` (≤ 10). Docs: container nouns are +ordinary in fiction; the tell is reflex and density; LAMP's comparison is +"rare in the human seed paragraphs", not "absent from human writing". + +### 1e. `nominalization` block — hits only + +Per word: lowercase; strip one trailing `s` (nothing else); test the stem with +`(?:tion|sion|ment|ance|ence)$` and length ≥ 7; then test the stem against +`NOMINAL_STOPLIST`. `hits[].text` is the original surface word. `-ity`/`-ness` +are excluded from the suffix set as deadjectival (Biber's set is -tion/-ment/ +-ness/-ity; -sion/-ance/-ence are this module's additions). + +`NOMINAL_STOPLIST` (target ≥ 150 lexicalized or non-derived nouns, singular, +all reachable by the rule; the plan carries the full list). Seed: station, +question, condition, position, mention, portion, fraction, function, +attention, tradition, edition, mission, session, version, occasion, passion, +tension, pension, mansion, section, fiction, population, information, +education, situation, relation, location, nation-level words that are 7+ (e.g. +donation is *derived* — keep out of the stoplist), generation, organization, +operation, direction, collection, connection, election, exception, reaction, +selection, solution, revolution, institution, constitution, faction, action, +motion, notion, option, region, religion, opinion, million, billion, dominion, +companion, champion, comment, document, government, department, environment, +equipment, apartment, element, instrument, segment, monument, ornament, +parliament, sentiment, testament, argument, treatment, movement, moment-length +words excluded by the ≥7 rule, science, audience, absence, presence, silence, +sentence, evidence, experience, conference, difference, distance, balance, +finance, insurance, instance, essence, sequence, consequence, reference, +preference, influence, confidence, violence, patience, residence, substance, +romance, alliance, appliance, entrance, fragrance, guidance, allowance, +performance, importance, resistance, existence, intelligence, independence, +correspondence, circumstance, maintenance, acceptance, assistance, insurance, +ambulance, nuisance, vengeance, essence, innocence, competence, excellence, +providence, prudence, diligence, negligence, coincidence, incidence, evidence. +(Entries that the rule can never produce — under 7 characters, or ending -ion +without -tion/-sion, or -nse — are excluded by construction: no nation, moment, +cement, chance, fashion, license, defense, offense.) + +Keys: `count`, `hits` (≤ 15 `{text, count}` by count desc), `of_frames` +(≤ 10 `{text, sentence}`, `text` = "the of"). **No rate key and +no threshold anywhere.** Sources: Herbold et al. 2023 [herbold-2023] +(monotonic across model generations); Reinhart et al. 2025 [reinhart-2025] +(2.1×, d = 1.23). Formal, legal, academic, and second-language prose +nominalize legitimately; this block is a prompt to look. Tests: "sentences", +"instances", "appliances", "sciences" produce no hit; "implementations" → +hit text "implementations". + +### 1f. `discourse.disclaimer_opener` + +A separate closed list `DISCLAIMER_PHRASES` — "as an ai", "consult a +professional", "it's important to approach", "i'm not able to", "i cannot +provide" — kept **out of `AI_WORDLIST`** so `wordlist.rate` stays calibrated +to the vocabulary its rule of thumb was measured on. Keys: `fired` (true if +any phrase occurs in the first paragraph), `hits` (all occurrences, §0 shape). +The same phrases mid-document are an ordinary discourse observation, not this +tell. Source: Rudnicka & Juzek 2026 [rudnicka-2026] — safety disclaimers in +46% of one model family's responses vs 0.2% in another; a per-family range, +never a population rate. + +### 1g. Sentence-length tail and sequence keys + +Merged into `sentence_len` after `_stats` returns (`paragraph_len` untouched): + +- `pct_over_30`: percent of sentences over 30 words, 1 dp. **Directional** + (humans have the longer tail). Source scope: 2023 NYT lead paragraphs + (≤ 200 tokens) against six non-instruction-tuned models under an asymmetric + prompt — humans 31.2% vs 17.5–21.0% (Muñoz-Ortiz et al. 2024 + [munoz-ortiz-2024]); direction, never magnitude. +- `p90`: `sorted_lens[max(0, ceil(0.9 * n) - 1)]`; 0 when there are no + sentences. Reported-only. +- `longest_flat_run`: longest run of consecutive sentences each within ±3 + words of the run's **first** sentence (an absolute band); 1 for a single + sentence, 0 for none. Reported-only — catches the metronome `cv` passes, but + is confounded by sentence count. + +### 1h. `--text` summary + +Four lines are added after the existing eight. Illustrative values below are +for the 640-word `ai_report.txt` described in §6 (5 tails → 7.8/1k; one 5-word +phrase ×3 → Σ(count−1) = 2 → 3.1/1k): + +``` +repetition: 3.1/1k · longest repeat 5 · "across all workstreams and teams"×3 +grammar: participial tails 5 (7.8/1k) ", ensuring alignment across teams" · container-of 2 "a sense of momentum" +sentence tail: over-30 0.0% · p90 19 · longest flat run 9 +nominalization hits: 7 (implementation×3, alignment×2, optimization×2) · frames: "the implementation of" +``` + +When `repetition.too_short` is true the first line reads +`repetition: not measured (under 150 words)`. + +## 2. `SKILL.md` — 139 → 148 lines (≤ 150), counted at the file's 78-column wrap + +Exact inserted text (so the count is checkable): + +- Grounding line (+1): "Grounded in StoryScope (Russell et al., 2026) and + register studies (Reinhart et al. 2025; Milička et al. 2025); the registry + is `references/SOURCES.md`." +- Step 1, after the class is chosen (+2): "In `expository` prose, + nominalizations, container nouns, and participial tails are native register + — prompts to look, not tells, unless extreme for the length." +- Step 3 (+2): "Quote `repetition.phrases` and `grammar.*.hits` verbatim. + `nominalization.hits` never become a row. Other studies' ratios never go in + the base-rate column." +- Step 5, new item after 3 (+3): "Do not strip passives by reflex: GPT-4o + (2024-era) used the agentless passive at about half the human rate (Reinhart + et al. 2025). Recast one only when a fired tell names it." +- Step 6 (+3): "Verify by the scan and quoted spans, not by whether it reads + human to you. If the rewrite removed every long sentence or narrowed the + vocabulary, say so and reread: converging is a failure even as tell counts + fall." +- Invocation preamble, replaced verbatim (−2): "Applies only when the user + typed `/humanize …`. On auto-invoke (drafting mode or a natural-language + request) there are no arguments: skip this section." + +## 3. `principles.md` + +- Append to #5: "Check the direction before you flag it. Findings expire — + lexical diversity reversed between GPT-3.5 and GPT-4 (Herbold et al. 2023 + [herbold-2023]). Some never held — GPT-4o used agentless passives at about + half the human rate (Reinhart et al. 2025 [reinhart-2025], 2024-era models), + and 29 of 32 model settings moved away from the dimension that carries + passives (Milička et al. 2025 [milicka-2025]: a factor loading, not a passive + count). Reader heuristics point backwards (Jakesch et al. 2023 + [jakesch-2023], GPT-3-era self-presentation bios): contractions read as + human but lean AI; grammar errors and long or rare words read as AI but lean + human. Prefer recency for capability-dependent features, replication for + stable ones — and never optimize for what a reader guesses is human." +- New #8 **Register and proficiency are not tells.** The measured AI profile + — formal, impersonal, nominalized, flat sentence lengths, narrow lexis, few + contractions — also describes competent second-language English, translated + text, legal, technical, academic, and plain-language prose. That overlap is + this repo's inference from the corpora below, not a finding any of them + tests. Measure the profile; never infer authorship or proficiency from it, + and never rewrite a text into looking less like one of those populations. + Each human baseline here comes from one narrow population — StoryScope: + amateur fiction; Muñoz-Ortiz: NYT lead paragraphs; Herbold: non-native + student essays; Jakesch: short bios — whose own limitations decline to + generalize. All of it is English; quote no number on translated or + non-English text. +- No other new principles. + +## 4. `surface-tells.md` + +Header: add "grammar" to the layer list; state the §0 citation convention in +one sentence. New entries (five-line shape; figures on `Scan:` lines only): + +- Vocabulary → `### Abstract container-noun phrase`. Scan: + `grammar.container_of` count and hits; the 13 heads are LAMP Table 8 + (Chakrabarty et al. 2025 [chakrabarty-2025]), rare in the human seed + paragraphs. Why: a reflex reach for an abstract container; fiction uses + these legitimately, so judge density. Fix: removal — name the concrete thing. +- Vocabulary → `### Nominalized verbs` (after Latinate lean). Scan: + `nominalization.hits` and `.of_frames`, hits only, no rate (Herbold et al. + 2023 [herbold-2023]; Reinhart et al. 2025 [reinhart-2025]). Why: buried + verbs — "the implementation of" for "implementing" — rise monotonically + across model generations, but formal registers earn them. Fix: rebalance. +- Structures → `### Verbatim repetition`. Scan: `repetition.phrases` + (silent under 150 words); repeated phrases are the strongest true-source + predictor, OR 1.47 (Jakesch et al. 2023 [jakesch-2023]); + `repeated_phrase_rate` is reported-only. Why: recurrence with no rhetorical + intent that readers miss; terminology, names, and identifiers must repeat — + exempt technical and legal prose; refrains are deliberate. Fix: removal. +- Structures → `### Trailing participial clause`. Scan: + `grammar.participial_tail` count, rate, hits; ratio 5.3×, d = 1.38, 2024-era + models, news/academic registers (Reinhart et al. 2025 [reinhart-2025]). Why: + the ", ensuring …" tack-on that lets a sentence keep going without a new + subject. Fix: removal — split into a sentence with its own subject. +- Discourse moves → `### Safety disclaimer opener and AI self-reference`. + Scan: `discourse.disclaimer_opener.fired` and `.hits`; per-family range 46% + vs 0.2% (Rudnicka & Juzek 2026 [rudnicka-2026]). Why: a first paragraph + that qualifies before it answers. Fix: removal. + +Extended entries: + +- `### AI-associated wordlist`: add "Vintage: calibrated on 2023–2024 model + output. A wordlist decays — Kobak et al. 2025 [kobak-2025] tracked one + marker's excess falling roughly fivefold within a year (share of biomedical + abstracts containing the word, not a per-1k rate; not comparable to the rule + of thumb above). Re-check against current models before firing hard." +- `### Uniform sentence length`: Scan line adds `sentence_len.pct_over_30` + (humans 31.2% vs 17.5–21.0%, 2023 news corpus, direction not magnitude + [munoz-ortiz-2024]) and `longest_flat_run` (reported-only), plus: "A flat + profile is also the native shape of plain-language and technical prose + (`tests/fixtures/human_plain.txt` sits in AI territory on every sentence + metric); it is not authorship evidence." + +## 5. Provenance: `references/SOURCES.md` and the invariant + +`SOURCES.md` is a maintainer and Bugbot document, never added to SKILL.md's +load list (entries carry their citation inline). One entry per key: full +citation, URL, corpus and models, year, "may support" (`Base rate:` via CSV / +inline citation only / direction only), caveats, and "author list verified +against the paper on " for every entry. Keys: `storyscope-2026`, +`reinhart-2025`, `herbold-2023`, `jakesch-2023`, `munoz-ortiz-2024`, +`rudnicka-2026`, `padmakumar-2024`, `chakrabarty-2025`, `sun-2025`, +`milicka-2025`, `kobak-2025`, `liang-2024` (Monitoring AI-Modified Content at +Scale, arXiv 2403.07183 — may support the non-native-speaker confound only; +its ranked vocabulary tables are excluded as detector material), `survey-2025` +(Linguistic Characteristics of AI-Generated Text: A Survey, arXiv 2510.05136 — +may support direction and replication counts only; no rates; v1 preprint, +no venue). Model-vs-model sources (`sun-2025`, `rudnicka-2026`) are marked +"no human baseline: never a human/AI rate". + +Invariant amendment (CLAUDE.md, `.cursor/BUGBOT.md`, README credit paragraph): +"A `Base rate:` line traces to a row in `data/storyscope_feature_gaps.csv` +(or a future CSV documented in `data/README.md`; v0.2 adds none). Any other +number in a reference doc sits on a `Scan:` or `Rule of thumb:` line with an +inline `[key]` that resolves in `references/SOURCES.md`; numbers from +model-vs-model sources never appear as a human/AI rate." Entry-shape +invariant: the five required lines plus optional `Rule of thumb:`, +`Vintage:`, and (style-tells) `Outside fiction:`. `data/README.md` unchanged. + +## 6. Fixtures and tests + +New fixtures (all ≥ 600 words). Provenance — source URL, retrieval date, +extraction command, and the measured values the gates pin — lives in +`tests/fixtures/PROVENANCE.md`, not inside the fixtures (a comment would be +scanned as text). Band rule: floats × 0.8–1.2, ints ± 1, and a float measuring +0.0 gets (0.0, 2.0) so the pin stays a band. + +- `tests/fixtures/ai_report.txt` — hand-written AI-style status report, ~640 + words: disclaimer opener; ≥ 5 trailing participial clauses; ≥ 2 + container-noun phrases; exactly one 5-word phrase repeated ×3 and no other + repeated ≥4-gram; several nominalizations with "the X of" frames; + near-uniform sentence lengths. No em-dash or tricolon requirement. +- `tests/fixtures/human_formal.txt` — Federalist No. 10, Project Gutenberg + ebook #1404, opening ≥ 600 words, PG boilerplate stripped. Formal, + nominalized, long-sentenced human prose. +- `tests/fixtures/human_plain.txt` — ≥ 600 words from the US federal Plain + Language guidelines, "Write for your audience" and "Organize" sections and + their child pages, taken from the Internet Archive snapshots of + plainlanguage.gov/guidelines/ (digital.gov no longer hosts those pages); + `

` elements of the main-content container only, paragraphs under 8 words + and list lead-ins dropped. Public domain (US government work). The + flat-profile fairness fixture. + +Gate (`tests/test_fixtures.py`): + +- (i) Sensitivity, on `ai_report.txt`: `grammar.participial_tail.count ≥ 5`; + `grammar.container_of.count ≥ 2`; `repetition.phrases[0].text` equals the + planted phrase with `count == 3`; `discourse.disclaimer_opener.fired`; + `nominalization.of_frames` non-empty. +- (ii) Specificity, on `human_fiction_excerpt.txt`, `human_formal.txt`, + `human_plain.txt` (`human_email.txt` is too short to fail and is omitted): + `participial_tail.count ≤ 1`; `container_of.count ≤ 1`; + `disclaimer_opener.fired` is false; the largest `repetition.phrases[].count` + is ≤ a value recorded at fixture creation. +- (iii) Direction, `human_formal.txt` vs `ai_report.txt`: + `sentence_len.pct_over_30` human > AI. The report pairs are **not** added + to the existing 3-of-4 test, which encodes v0.1 metrics on v0.1 pairs. +- (iv) Fairness pins, on `human_plain.txt`: `pct_over_30`, `sentence_len.cv`, + and `longest_flat_run` asserted within bands recorded at fixture creation, + with a comment that these values sit in AI territory and may not be read as + authorship evidence. On `human_formal.txt`: nominalization `count` within a + recorded band, with a comment that hits on formal human prose are expected + and are not a tell. +- (v) Shapes: `set(r["nominalization"]) == {"count", "hits", "of_frames"}`; + `hits[0]` has exactly `{text, count}`; `of_frames[0]` has exactly + `{text, sentence}`; every `grammar.*.hits[0]` has exactly `{text, sentence}`. +- (vi) Provenance: every `[key]` in `references/*.md` resolves in + `SOURCES.md`; `SOURCES.md` is added to the reference-doc list in + `tests/test_manifests.py`. + +Reported-only keys (`p90`, `longest_flat_run`, `repetition.longest_repeat`, +`repetition.repeated_phrase_rate`) get unit tests on constructed text, no +fixture gate. A directional metric that fails its gate is removed from the +release, not tuned until the fixture passes. `expected_tells.md` gets a +section per new fixture. Unit tests named in §1 (apostrophes, maximal phrases +incl. the 20-word repeat, the three participial exclusions incl. ', she said, +smiling.' and the `ai_fiction_excerpt.txt` ', using' sentence, singularization, +p90/flat-run edge cases) live in `tests/test_surface_scan.py`. + +## 7. Docs, version, release + +Checklist: README grounding paragraph (second-source sentence); README "What's +inside" (SOURCES.md; scanner line lists the new blocks); README principles +bullet for #8; README credit paragraph (invariant wording); CHANGELOG 0.2.0 +entry plus compare links; `plugin.json` and marketplace 0.2.0; CLAUDE.md +(provenance amendment, entry-shape amendment with `Rule of thumb:` and +`Vintage:`, test count, "new blocks are hits/counts only", "no `data/` CSV +added"); `.cursor/BUGBOT.md` (same two amendments, citation-key grep). Tag +`v0.2.0` after merge; `claude plugin update`. + +## Behavior example + +Audit of the AI status report (expository, 640 words). Table rows: "Trailing +participial clause — ', ensuring alignment across teams' (×5) — 5 hits, +7.8/1k; AI-leaning direction (Reinhart et al. 2025 measured 5.3× the human +rate in 2024-era models — a ratio, not this document's)"; "Verbatim +repetition — 'across all workstreams and teams' (×3) — 3.1/1k"; "Abstract +container-noun phrase — 'a sense of momentum', 'the weight of the decision' — +2 hits"; "Safety disclaimer opener — 'It's important to approach this +carefully' — fired". The nominalization hits (implementation ×3, alignment ×2; +frame "the implementation of") were read and left alone: the class is +expository, so no row was added. The rewrite splits the participial tails into +sentences with their own subjects, cuts the repeated phrase to one instance, +names the concrete thing behind each container noun, and deletes the +disclaimer. Step 6 reports `pct_over_30` up from 0.0 to 8.0% and confirms no +fact was dropped. + +## Open questions resolved + +- Scope: the six short-list items, plus the disclaimer-opener check and the + register gate the review made a ship condition. (Decided.) +- Nominalization: hits only, deadjectival suffixes excluded, strip one `s`. +- Repetition: maximal phrases with no n ceiling, substring drop, content-word + floor; rate reported-only, phrases gated. +- Participial tails: adverbial-prefix rule (preposition-led, verbless), list + rule narrowed to `, and or`; sentence-final participles count. +- Fixtures: Federalist No. 10 (formal) and digital.gov plain-language guide + (plain, modern); gates split into sensitivity / specificity / direction / + pinned fairness bands / shapes / provenance. (Decided.) +- One new principle plus one appended; SKILL.md 148 lines. (Decided.) +- Version 0.2.0. (Decided.) + +## Post-review amendments (2026-09-14) + +- §1b: `repeated_phrases` caps the gram length at 60 tokens (why: unbounded + O(L³) work on boundary-less text — a run-on "sentence," such as a bullet + list with no terminal punctuation, otherwise costs multi-second, multi-GB). +- §1c: rule (b) is split into a subordinator-led prefix (unconditional skip — + the comma closes a clause) and a preposition-led prefix (the existing + verbless test), plus a conjunctive-adverb opener list; the general + no-finite-verb rule ("To be fair, …" and any bare gerund-subject case) is + deferred to v0.2.x pending fixture evidence. +- §1d: `nominalization.of_frames` confirms the determiner–noun–`of` sequence + against the raw sentence before appending a frame, so punctuation between + the words (e.g. "the implementation, of course,") no longer fabricates one. +- §0: en dash joins em dash in `_CLAUSE_END_RE`, the clause-terminator set. +- §0/§4/§5: `Vintage:` is a permitted number-bearing line alongside `Scan:` + and `Rule of thumb:`; the Kobak et al. 2025 magnitude ("falling roughly + fivefold within a year") is dropped from `surface-tells.md` — the + registry's `May support:` line for `kobak-2025` licenses the decay note + only, not a figure. +- §2: the passive rule reads "Recast one only when the inferred voice or a + fired tell calls for it" — the old wording ("only when a fired tell names + it") was unsatisfiable, since no tell entry mentions passives. +- §3: the convergence check is anchored to `sentence_len.max` (fell hard) and + `cv` (flattened) instead of an unmeasurable "narrowed vocabulary." +- §1f: `disclaimer_opener.hits` report the canonical lowercase phrase, not + the §0 span; known deviation, deferred to v0.2.x. +- §1b/§1h and the Behaviour example: the illustrative figures predate the + shipped fixtures. Measured values: `repetition.repeated_phrase_rate` — + `human_formal.txt` 2.7/1k, `human_plain.txt` 0.0/1k, `ai_email.txt` 0.0/1k + (146 words, under the 150-word reporting floor); `ai_report.txt` is 620 + words with 14 `grammar.participial_tail` hits at 22.6/1k, measured after + the §1c fix above. +- Goal 7's "four human samples" is three; §6 already explains the omission of + `human_email.txt`. +- §1c (Bugbot PR #6 comment 4002402541): `_PARTICIPIAL_TAIL_RE` gains a + `(?!-)` lookahead so a hyphenated compound ("cutting-edge") is never read + as a clause head — `\b` alone fired at the hyphen and left only the + suffix for the exclusion checks to see. +- §1c (Bugbot PR #6 comment 4002402546, revised after a further re-review): + the opener guard now evaluates the whole prefix, split into comma + segments, rather than only the sentence's first raw comma. It applies + only when the first segment is opener-led (subordinator / conjunctive + adverb / preposition) and every later segment is opener-internal — + subordinator-led, unconditionally, checked first (Bugbot PR #6 comment + 4002782444: a subordinate clause always carries its own verb and its + comma closes it, so the following `-ing` word is still a gerund subject + even when that segment's verb would otherwise trip the finite-verb + check, e.g. "However, after the audit closed, filing became routine."); + exactly one word; or itself preposition-led (non-subordinator) AND + verbless (Bugbot PR #6 comment 4002717678: a prep-led intermediate + segment carrying its own finite verb, e.g. "with the new vendor the team + shipped faster", is a clause too, not opener-internal, so the guard must + lift for it exactly as it does for a non-prepositional multi-word + segment) — otherwise a multi-word segment is a clause of its own, and + the tail fires. `IRREGULAR_PAST` joins the `-ed`/`FINITE_AUX` finite-verb + veto, which now runs only for the preposition-led branch, over the whole + prefix. Pruned to 61 unambiguous + past forms across two re-review rounds (Bugbot PR #6 comments 4002562248 + and rounds 2–3): the original list included present/past homographs and + common nouns/adjectives (`cost`, `left`, `set`, `rose`, `read`, `felt`, + `thought`, `stole`, `fell`, `spent`, and others), so a preposition-led + opener containing one of them — "At low cost, shipping continued.", + "On second thought, hiring slowed." — failed the verbless test and + reported a gerund subject as a tail. `led` needed the generic `-ed` + suffix heuristic itself narrowed to words longer than 3 characters (no + genuine English regular past tense is 3 letters), since the lowercased + "LED" acronym still matched that heuristic after removal from + `IRREGULAR_PAST` alone. Known, accepted residual issues, deferred to + v0.2.x: "In fast, growing markets, spending rose." and "In the report, + published last week, spending fell." still fire; "Under the plan costs + rose, driving growth." is a miss (single-segment prefix, `rose` removed); + "In Austin, Travis County, hiring slowed." fires (a two-word name segment + reads as a clause); "More importantly, the board met, approving the + plan." fires correctly, but the same two-word adverb-second opener + ("More importantly, hiring slowed.") is not recognized as verbless and + would too; "Meanwhile in Austin, shipping continued." fires (`meanwhile` + is a three-token first segment here, and CONJ_ADVERBS only checks a + one-or-two-word segment; `meanwhile` alone is not in `PREP_SUB`). +- §0 (re-review, Minor): en dash terminates a clause only when whitespace + follows it (`–(?=\s)`), so a numeric range inside a tail, e.g. "covering + 2023–2024 spending," is no longer truncated mid-range. +- §1b (Bugbot PR #6 comment 4002517589): `repeated_phrases` merges runs of + overlapping cap-length (60-token) grams that share the same count and + sentence set into one representative phrase, so a single verbatim repeat + longer than 60 tokens is reported once instead of as many overlapping + sliding-window duplicates. diff --git a/docs/design/2026-09-14-humanize-v0.2-plan.md b/docs/design/2026-09-14-humanize-v0.2-plan.md new file mode 100644 index 0000000..a11eaff --- /dev/null +++ b/docs/design/2026-09-14-humanize-v0.2-plan.md @@ -0,0 +1,1355 @@ +# humanize v0.2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship humanize v0.2 — the apostrophe fix, a grammar/repetition scanner layer with fixture gates, rewrite guards and a fairness principle in the skill, five new surface tells, and a citable `SOURCES.md` registry. + +**Architecture:** All scanner work is additive inside `surface_scan.py` (new keys; existing keys and `_stats` untouched), applied per sentence over `split_sentences`. Docs are edited in place with grep-checkable `[author-year]` citation keys resolved by a test. Fixtures pin measured bands so fairness is asserted, not assumed. + +**Tech Stack:** Python 3.9+ stdlib (`re`, `math`, `collections`), pytest via `uv run`, ruff, pre-commit; Claude Code skill/reference markdown. + +**Spec:** `docs/design/2026-09-14-humanize-v0.2-design.md` (revision 3). §0 conventions govern every hit shape and citation. + +**Plan revision 2:** every task was dry-run verbatim in an isolated worktree and every doc anchor checked against the real files; the defects found (test-count arithmetic, six ruff E501 lines, a function-word gap, list-continuation and truncation edge cases, an invalid `summarize` snippet, a 564-word fixture, moved fixture sources, mid-line anchors) are fixed below. + +## Global Constraints + +- `surface_scan.py` imports only the standard library; Python 3.9-compatible (`from __future__ import annotations` stays; no `match`, no runtime `X | Y`, no `removeprefix`; no f-string reusing its own quote type inside `{}`). +- No network or LLM calls in `tests/` or `plugins/**/scripts/`. One-off fixture creation (Task 6) and `SOURCES.md` author verification (Task 7) may use the network; nothing committed does. +- Every existing `analyze()` key and `_stats()` output is unchanged. New keys only. Existing tests keep passing (56 today). +- ruff line length is 100: no source line, comment, or string literal in code may exceed it (the pre-commit hook rejects the commit and `--fix` cannot wrap strings or comments). Write long literals as implicit concatenations or triple-quoted `.split()` lists. +- Reference entries: `### name` / `Looks like:` / `Base rate:` or `Scan:` / `Why it reads as AI:` / `Fix: — …`; optional `Rule of thumb:` and `Vintage:`; `Outside fiction:` in style-tells only. Figures never on the `Why` line. `Base rate:` numbers trace to `data/storyscope_feature_gaps.csv`; all other numbers carry an inline `(Author et al. YEAR [key])` from `SOURCES.md`. +- `SKILL.md` ends at exactly 150 lines with the Task 9 inserts verbatim. +- Never claim output is "undetectable", passes a detector, or is "certified human". +- Curly characters in existing literals (’ “ ” — –) must survive; verify with heredoc probes (inline `-c` mangles them). +- Tooling: `uv run pytest -q -W error`; before committing `uv run ruff format && uv run ruff check ` (files, never `.`); hooks run on commit; never `--no-verify`. Branch: `feat/v0.2`. Work in `/Users/ccf/git/humanize`. +- Every commit message ends with: + ``` + Co-Authored-By: Claude Fable 5.1 + Claude-Session: https://claude.ai/code/session_01KTKYvLEVY4mStJ3iPaB1Mh + ``` + +--- + +## File map + +| Path | Change | +|---|---| +| `plugins/humanize/skills/humanize/scripts/surface_scan.py` | T1 normalize; T2 repetition; T3 grammar; T4 nominalization, disclaimer_opener; T5 sentence extras + summary | +| `tests/test_surface_scan.py` | unit tests per task | +| `tests/fixtures/ai_report.txt`, `human_formal.txt`, `human_plain.txt`, `PROVENANCE.md`, `expected_tells.md` | T6 | +| `tests/test_fixtures.py` | T6 gates | +| `plugins/humanize/skills/humanize/references/SOURCES.md` | T7 | +| `tests/test_manifests.py` | T7 | +| `plugins/humanize/skills/humanize/references/surface-tells.md` | T8 | +| `plugins/humanize/skills/humanize/SKILL.md`, `references/principles.md` | T9 | +| `README.md`, `CHANGELOG.md`, `CLAUDE.md`, `.cursor/BUGBOT.md`, manifests, spec status | T10 | + +Test-count ladder: 56 → 59 (T1) → 63 (T2) → 68 (T3) → 72 (T4) → 75 (T5) → 82 (T6) → 84 (T7); T8–T10 add none. + +--- + +### Task 1: Apostrophe normalization + +**Files:** +- Modify: `plugins/humanize/skills/humanize/scripts/surface_scan.py`; `tests/test_surface_scan.py` + +**Interfaces:** +- Produces: `normalize_apostrophes(text: str) -> str`; `analyze()` now begins `text = normalize_apostrophes(strip_markdown(text))`. + +- [ ] **Step 1: Failing tests** (append to `tests/test_surface_scan.py`) + +```python +def test_normalize_apostrophes_only_between_word_characters(): + src = "don´t Itʼs we’re O′Brien" + assert ss.normalize_apostrophes(src) == "don't It's we're O'Brien" + unchanged = "‘quoted’ rock ’n’ roll 'go now'" + assert ss.normalize_apostrophes(unchanged) == unchanged + + +def test_analyze_treats_acute_accent_and_modifier_apostrophes_as_apostrophes(): + r = ss.analyze("We don´t know. Itʼs worth noting the plan.") + assert r["words"] == 8 + assert "it's worth noting" in {h["term"] for h in r["wordlist"]["hits"]} + + +def test_normalize_runs_after_markdown_strip_so_backticks_are_untouched(): + text = "Use the `dict`s API. Everything between here must survive. Now `list` ends." + assert ss.analyze(text)["words"] == 11 +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `uv run pytest -q tests/test_surface_scan.py -k "apostrophe or backticks" -W error` +Expected: 2 failed, 2 passed — `AttributeError: module 'surface_scan' has no attribute 'normalize_apostrophes'` and `assert 9 == 8`. (The backticks test is a regression guard and passes before the change; the fourth selected test is a pre-existing one.) + +- [ ] **Step 3: Implement** + +After the line `_HTML_TAG_RE = re.compile(...)` add: +```python +_APOSTROPHE_GLYPH_RE = re.compile(r"(?<=\w)[ʼʹ´‘’′](?=\w)") +``` +After `strip_markdown` add: +```python +def normalize_apostrophes(text: str) -> str: + """Map apostrophe look-alikes between letters to ASCII; leaves quotation marks alone.""" + return _APOSTROPHE_GLYPH_RE.sub("'", text) +``` +In `analyze`, replace `text = strip_markdown(text)` with `text = normalize_apostrophes(strip_markdown(text))`. + +- [ ] **Step 4: Run tests** — `uv run pytest -q -W error` → 59 passed. + +- [ ] **Step 5: Commit** + +```bash +uv run ruff format plugins/humanize/skills/humanize/scripts/surface_scan.py tests/test_surface_scan.py && uv run ruff check plugins/humanize/skills/humanize/scripts/surface_scan.py tests/test_surface_scan.py +git add plugins/humanize/skills/humanize/scripts/surface_scan.py tests/test_surface_scan.py +git commit -m "$(cat <<'EOF' +Normalize apostrophe look-alikes between letters before scanning + +U+00B4 split words; U+02BC tokenized but missed wordlist terms. Runs after +strip_markdown so backticks stay Markdown. + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_01KTKYvLEVY4mStJ3iPaB1Mh +EOF +)" +``` + +--- + +### Task 2: `repetition` block — maximal repeated phrases + +**Files:** +- Modify: `surface_scan.py`; `tests/test_surface_scan.py` + +**Interfaces:** +- Produces: `FUNCTION_WORDS: frozenset[str]`; `repeated_phrases(sentences) -> list[dict]` (`{text, count, sentences}`, count desc then length desc); `repetition_block(sentences, n_words) -> dict` with `too_short`, `repeated_phrase_rate`, `longest_repeat`, `phrases`; `analyze()["repetition"]`. + +- [ ] **Step 1: Failing tests** + +```python +def _unique_filler(n_sentences: int) -> str: + return " ".join( + f"Alpha{i} beta{i} gamma{i} delta{i} epsilon{i} zeta{i} eta{i} theta{i}." + for i in range(n_sentences) + ) + + +PLANTED = ( + "Monday we aligned across all workstreams and teams early. " + "Later coordination across all workstreams and teams improved. " + "By Friday delivery across all workstreams and teams stayed steady." +) + + +def test_repeated_phrases_collapse_to_one_maximal_phrase(): + phrases = ss.repeated_phrases(ss.split_sentences(PLANTED)) + assert phrases == [ + {"text": "across all workstreams and teams", "count": 3, "sentences": [0, 1, 2]} + ] + + +def test_repeated_phrases_whole_repeated_sentence_counts_once(): + s = ( + "The project remains on track and the team continues to deliver " + "against the agreed plan for the quarter." + ) + phrases = ss.repeated_phrases(ss.split_sentences(s + " " + s)) + assert len(phrases) == 1 and phrases[0]["count"] == 2 + assert len(phrases[0]["text"].split()) == 18 + + +def test_repeated_phrases_need_two_content_words(): + text = ( + "We met at the end of March. They spoke at the end of April. Costs fell at the end of May." + ) + assert ss.repeated_phrases(ss.split_sentences(text)) == [] + + +def test_repeated_phrases_keep_a_more_frequent_short_phrase_inside_a_rarer_long_one(): + text = ( + "We ship the release notes weekly here. They ship the release notes weekly there. " + "Others ship the release notes on Fridays." + ) + phrases = ss.repeated_phrases(ss.split_sentences(text)) + assert {p["text"]: p["count"] for p in phrases} == { + "ship the release notes weekly": 2, + "ship the release notes": 3, + } + + +def test_repetition_block_rate_and_too_short(): + r = ss.analyze(_unique_filler(20) + " " + PLANTED) + rep = r["repetition"] + assert rep["too_short"] is False + assert rep["repeated_phrase_rate"] == ss.per_1k(2, r["words"]) + assert rep["longest_repeat"] == 5 + assert rep["phrases"][0]["text"] == "across all workstreams and teams" + short = ss.analyze("Short text. " * 10)["repetition"] + assert short == { + "too_short": True, + "repeated_phrase_rate": 0.0, + "longest_repeat": 0, + "phrases": [], + } +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `uv run pytest -q tests/test_surface_scan.py -k "repeat or repetition" -W error` +Expected: 5 failed — `AttributeError` (`repeated_phrases`) / `KeyError: 'repetition'`. + +- [ ] **Step 3: Implement** + +Add `from collections import Counter, defaultdict` to the imports (alphabetical position after `import argparse`). After `opener_distinct_ratio` add: +```python +FUNCTION_WORDS = frozenset( + """a an the this that these those my your his her its our their some any each every no + i you he she it we they me him us them who whom whose which what + am is are was were be been being have has had do does did will would shall should can could + may might must and or but nor so yet for if then than as because while although though + of in on at to by with from into onto upon about over under after before between through + during without within not also just only very too there here when where how why""".split() +) + + +def repeated_phrases(sentences: list[str]) -> list[dict]: + """Maximal repeated phrases (>= 4 words, >= 2 content words) across sentences.""" + counts: dict = Counter() + where: dict = defaultdict(set) + for si, s in enumerate(sentences): + toks = [w.lower() for w in words(s)] + for n in range(4, len(toks) + 1): + for i in range(len(toks) - n + 1): + g = tuple(toks[i : i + n]) + counts[g] += 1 + where[g].add(si) + repeated = {g: c for g, c in counts.items() if c >= 2} + non_maximal = set() + for g, c in repeated.items(): + if len(g) > 4: + for sub in (g[1:], g[:-1]): + if repeated.get(sub) == c: + non_maximal.add(sub) + survivors = sorted((g for g in repeated if g not in non_maximal), key=len, reverse=True) + kept: list = [] + for g in survivors: + if any( + repeated[k] == repeated[g] + and len(k) > len(g) + and any(k[i : i + len(g)] == g for i in range(len(k) - len(g) + 1)) + for k in kept + ): + continue + if sum(1 for w in g if w not in FUNCTION_WORDS) < 2: + continue + kept.append(g) + out = [{"text": " ".join(g), "count": repeated[g], "sentences": sorted(where[g])} for g in kept] + out.sort(key=lambda p: (-p["count"], -len(p["text"].split()), p["text"])) + return out + + +def repetition_block(sentences: list[str], n_words: int) -> dict: + if n_words < 150: + return {"too_short": True, "repeated_phrase_rate": 0.0, "longest_repeat": 0, "phrases": []} + phrases = repeated_phrases(sentences) + extra = sum(p["count"] - 1 for p in phrases) + return { + "too_short": False, + "repeated_phrase_rate": per_1k(extra, n_words), + "longest_repeat": max((len(p["text"].split()) for p in phrases), default=0), + "phrases": phrases[:5], + } +``` +In `analyze`, add after `"intensifiers"`: +```python + "repetition": repetition_block(sents, n_words), +``` + +Why the tests pass: the 18-word sentence repeated twice is one maximal 18-gram (every shorter gram inside it has count 2 and is non-maximal). "at the end of" has one content word (end) and is dropped; the surrounding words differ, so no 5-gram repeats. The containment filter suppresses a substring only when the longer phrase has the *same* count, so "ship the release notes" (×3) survives beside "ship the release notes weekly" (×2). + +- [ ] **Step 4: Run tests** — `uv run pytest -q -W error` → 63 passed. +- [ ] **Step 5: Commit** — same pattern as Task 1; message "Add repetition block: maximal repeated phrases with content-word floor" + trailers. + +--- + +### Task 3: `grammar` block — participial tails and container nouns + +**Files:** +- Modify: `surface_scan.py`; `tests/test_surface_scan.py` + +**Interfaces:** +- Produces: `ING_STOPLIST`, `PREP_SUB`, `FINITE_AUX`, `CONTAINER_HEADS`; `clause_text(sentence, start, head_end) -> str`; `participial_tails(sentences) -> list[dict]`; `container_phrases(sentences) -> list[dict]`; `analyze()["grammar"]`. + +- [ ] **Step 1: Failing tests** + +```python +def test_participial_tail_hits_canonical_forms_and_extracts_clause(): + s = ["We shipped the release, ensuring alignment across teams before the freeze."] + hits = ss.participial_tails(s) + assert hits == [{"text": ", ensuring alignment across teams before the freeze", "sentence": 0}] + assert ss.participial_tails(['"I know," she said, smiling.']) == [ + {"text": ", smiling", "sentence": 0} + ] + assert ss.participial_tails(["Costs rose, driving the decision."]) == [ + {"text": ", driving the decision", "sentence": 0} + ] + assert ss.participial_tails(["Revenue grew, quickly outpacing the plan."])[0]["text"] == ( + ", quickly outpacing the plan" + ) + + +def test_participial_tail_exclusions(): + assert ss.participial_tails(["In 2024, rising costs shaped the plan."]) == [] + assert ss.participial_tails(["On Monday, marketing shipped the page."]) == [] + assert ss.participial_tails(["The team focused on planning, testing, and shipping."]) == [] + assert ss.participial_tails(["We paused, pending the audit."]) == [] + assert ( + ss.participial_tails(["Readers include PhD candidates, working parents, or immigrants."]) + == [] + ) + assert ( + ss.participial_tails(["After the release shipped, ensuring alignment took a week."]) != [] + ) + + +def test_participial_tail_clause_text_capped_at_60_chars_on_a_word_boundary(): + s = ["We shipped, ensuring " + " ".join(["alignment"] * 12) + " more."] + text = ss.participial_tails(s)[0]["text"] + assert len(text) <= 60 and not text.endswith("alignmen") + + +def test_container_phrases(): + s = [ + "She felt a sense of unease and the quiet weight of the decision.", + "The foundation of the house held.", + ] + hits = ss.container_phrases(s) + assert [h["text"] for h in hits] == ["a sense of", "the quiet weight of", "The foundation of"] + assert [h["sentence"] for h in hits] == [0, 0, 1] + assert ss.container_phrases(["A sea change is coming."]) == [] + + +def test_analyze_grammar_block_shape(): + r = ss.analyze( + "We shipped the release, ensuring alignment across teams. She felt a sense of dread." + ) + g = r["grammar"] + assert g["participial_tail"]["count"] == 1 + assert g["participial_tail"]["rate"] == ss.per_1k(1, r["words"]) + assert set(g["participial_tail"]["hits"][0]) == {"text", "sentence"} + assert g["container_of"] == {"count": 1, "hits": [{"text": "a sense of", "sentence": 1}]} +``` + +- [ ] **Step 2: Run to verify failure** — `-k "participial or container or grammar"` → 5 failed with `AttributeError`. + +- [ ] **Step 3: Implement** (after `repetition_block`) + +```python +ING_STOPLIST = frozenset( + """morning evening thing something nothing anything everything during including following + according regarding concerning notwithstanding pending considering king ring spring string wing + ceiling wedding clothing painting meeting training funding housing beginning""".split() +) +PREP_SUB = frozenset( + """in on at by after before during since for with from under over within + when while if although as once until""".split() +) +FINITE_AUX = frozenset( + """is are was were be been has have had do does did will would + can could should may might must""".split() +) +_PARTICIPIAL_TAIL_RE = re.compile(r",\s+(?:\w+ly\s+)?(\w+ing)\b", re.I) +_CLAUSE_END_RE = re.compile(r"[,;:—.!?]") +_LIST_CONTINUATION_RE = re.compile(r"^(?:,|and\b|or\b)", re.I) +_LIST_ITEM_TAIL_RE = re.compile(r"^\s+\w+,\s*(?:and|or)\b", re.I) +CONTAINER_HEADS = ( + "sense", + "mix", + "blend", + "weight", + "flicker", + "pang", + "glimmer", + "web", + "sea", + "mask", + "residue", + "fabric", + "foundation", +) +_CONTAINER_OF_RE = re.compile( + r"\b(?:a|an|the)\s+(?:\w+\s+)?(?:" + "|".join(CONTAINER_HEADS) + r")\s+of\b", re.I +) + + +def clause_text(sentence: str, start: int, head_end: int) -> str: + m = _CLAUSE_END_RE.search(sentence, head_end) + end = m.start() if m else len(sentence) + snippet = sentence[start : min(end, start + 60)] + if end > start + 60 and " " in snippet: + snippet = snippet[: snippet.rfind(" ")] + return snippet.rstrip() + + +def _is_fronted_adverbial(prefix: str) -> bool: + toks = [w.lower() for w in words(prefix)] + return ( + bool(toks) + and toks[0] in PREP_SUB + and not any(t in FINITE_AUX or t.endswith("ed") for t in toks) + ) + + +def participial_tails(sentences: list[str]) -> list[dict]: + hits = [] + for si, s in enumerate(sentences): + first_comma = s.find(",") + for m in _PARTICIPIAL_TAIL_RE.finditer(s): + if m.group(1).lower() in ING_STOPLIST: + continue + if m.start() == first_comma and _is_fronted_adverbial(s[: m.start()]): + continue + rest = s[m.end() :] + if _LIST_CONTINUATION_RE.match(rest.lstrip()) or _LIST_ITEM_TAIL_RE.match(rest): + continue + hits.append({"text": clause_text(s, m.start(), m.end()), "sentence": si}) + return hits + + +def container_phrases(sentences: list[str]) -> list[dict]: + return [ + {"text": m.group(0), "sentence": si} + for si, s in enumerate(sentences) + for m in _CONTAINER_OF_RE.finditer(s) + ] +``` +In `analyze`, before the `return`: +```python + tails = participial_tails(sents) + containers = container_phrases(sents) +``` +and add the key (pre-formatted so `ruff format` leaves it alone): +```python + "grammar": { + "participial_tail": { + "count": len(tails), + "rate": per_1k(len(tails), n_words), + "hits": tails[:10], + }, + "container_of": {"count": len(containers), "hits": containers[:10]}, + }, +``` + +Why the tests pass: `, smiling` is not the sentence's first comma (the one after "know" is), so rule (b) is skipped and `smiling.` is followed by a terminator, not a list marker. `, pending` is stoplisted. "After the release shipped, …" has a `-ed` verb in the prefix, so it is a clause, not an adverbial. `, working parents, or immigrants` matches `_LIST_ITEM_TAIL_RE` (one word, comma, "or"). The 60-char cap backs up to the last space. + +- [ ] **Step 4: Run tests** — 68 passed. +- [ ] **Step 5: Commit** — "Add grammar block: trailing participial clauses and container-noun phrases" + trailers. + +--- + +### Task 4: `nominalization` (hits only) and `discourse.disclaimer_opener` + +**Files:** +- Modify: `surface_scan.py`; `tests/test_surface_scan.py` + +**Interfaces:** +- Produces: `_NOMINAL_SUFFIX_RE`, `NOMINAL_STOPLIST` (≥ 150), `DISCLAIMER_PHRASES`; `nominalization_block(sentences) -> {count, hits, of_frames}`; `disclaimer_opener(paragraphs, sentences) -> {fired, hits}`; `analyze()["nominalization"]`, `analyze()["discourse"]["disclaimer_opener"]`. + +- [ ] **Step 1: Failing tests** + +```python +def test_nominal_stoplist_is_large_and_every_entry_is_reachable(): + assert len(ss.NOMINAL_STOPLIST) >= 150 + for w in ss.NOMINAL_STOPLIST: + assert len(w) >= 7 and ss._NOMINAL_SUFFIX_RE.search(w), w + + +def test_nominalization_hits_and_frames(): + s = ss.split_sentences( + "The implementation of the policy led to an improvement in retention. " + "The nation's position on the question was clear in every session. " + "Sentences, instances, and appliances are not nominalizations, but implementations are." + ) + n = ss.nominalization_block(s) + assert set(n) == {"count", "hits", "of_frames"} + texts = {h["text"] for h in n["hits"]} + assert { + "implementation", + "improvement", + "retention", + "implementations", + "nominalizations", + } <= texts + assert not ({"sentences", "instances", "appliances", "position", "question", "session"} & texts) + assert n["of_frames"] == [{"text": "the implementation of", "sentence": 0}] + assert set(n["hits"][0]) == {"text", "count"} + + +def test_disclaimer_opener_fires_only_from_first_paragraph(): + paras = ["It's important to approach this carefully.", "As an AI I would add a caveat."] + d = ss.disclaimer_opener(paras, ss.split_sentences("\n\n".join(paras))) + assert d["fired"] is True and [h["sentence"] for h in d["hits"]] == [0, 1] + paras2 = ["We shipped on time.", "As an AI I would add a caveat."] + d2 = ss.disclaimer_opener(paras2, ss.split_sentences("\n\n".join(paras2))) + assert d2["fired"] is False and len(d2["hits"]) == 1 + + +def test_analyze_exposes_nominalization_and_disclaimer(): + r = ss.analyze( + "It's important to approach the implementation of this with care.\n\nMore text here." + ) + assert r["discourse"]["disclaimer_opener"]["fired"] is True + assert r["discourse"]["summary_closer"] is False + assert r["nominalization"]["of_frames"][0]["text"] == "the implementation of" +``` + +- [ ] **Step 2: Run to verify failure** — `-k "nominal or disclaimer"` → 4 failed with `AttributeError`. + +- [ ] **Step 3: Implement** (after `container_phrases`) + +```python +_NOMINAL_SUFFIX_RE = re.compile(r"(?:tion|sion|ment|ance|ence)$") +NOMINAL_STOPLIST = frozenset( + """station question condition position mention portion fraction function attention tradition + edition mission session version occasion passion tension pension mansion section fiction + population information education situation relation location generation organization + operation direction collection connection election exception reaction selection solution + revolution institution constitution faction auction caution vacation vocation corporation + proportion caption junction sanction ambition addition tuition nutrition petition + ammunition emotion devotion convention invention intention infection affection perfection + dimension television collision illusion compassion commission obsession possession + profession procession recession depression percussion concussion precision + comment document government department environment equipment apartment element + instrument segment monument ornament parliament sentiment testament argument treatment + movement basement pavement garment torment ferment pigment fragment filament ligament + regiment sediment condiment compliment complement implement supplement temperament + tournament sacrament firmament parchment management agreement statement settlement + judgment employment investment requirement entertainment experiment excitement + achievement commitment + science audience absence presence silence sentence evidence experience conference + difference distance balance finance insurance instance essence sequence consequence + reference preference influence confidence violence patience residence substance romance + alliance appliance entrance fragrance guidance allowance performance importance + resistance existence intelligence independence correspondence circumstance maintenance + acceptance assistance ambulance nuisance vengeance innocence competence excellence + providence prudence diligence negligence coincidence incidence conscience defence offence + licence obedience convenience adolescence magnificence eloquence affluence advance + elegance arrogance ignorance relevance brilliance radiance variance grievance abundance + acquaintance inheritance ordinance dominance resonance defiance severance deliverance + perseverance temperance utterance sustenance countenance provenance governance""".split() +) +DISCLAIMER_PHRASES = [ + "as an ai", + "consult a professional", + "i cannot provide", + "i'm not able to", + "it's important to approach", +] + + +def nominalization_block(sentences: list[str]) -> dict: + counts: dict = Counter() + frames = [] + for si, s in enumerate(sentences): + ws = words(s) + for i, w in enumerate(ws): + low = w.lower() + stem = low[:-1] if low.endswith("s") else low + if len(stem) < 7 or not _NOMINAL_SUFFIX_RE.search(stem) or stem in NOMINAL_STOPLIST: + continue + counts[low] += 1 + if 0 < i < len(ws) - 1 and ws[i - 1].lower() == "the" and ws[i + 1].lower() == "of": + frames.append({"text": f"the {low} of", "sentence": si}) + return { + "count": sum(counts.values()), + "hits": [{"text": t, "count": c} for t, c in counts.most_common(15)], + "of_frames": frames[:10], + } + + +def disclaimer_opener(paragraphs: list[str], sentences: list[str]) -> dict: + hits = [ + {"text": h["term"], "sentence": pos} + for h in phrase_hits(sentences, DISCLAIMER_PHRASES) + for pos in h["positions"] + ] + hits.sort(key=lambda h: h["sentence"]) + first = paragraphs[0] if paragraphs else "" + fired = any(_term_re(p).search(first) for p in DISCLAIMER_PHRASES) + return {"fired": bool(fired), "hits": hits} +``` +In `analyze`, change `"discourse"` to +```python + "discourse": { + "summary_closer": summary_closer(paras), + "disclaimer_opener": disclaimer_opener(paras, sents), + }, +``` +and add `"nominalization": nominalization_block(sents),`. + +Notes: singular and plural forms are separate hits by design (`implementation` / `implementations`) — Task 8's entry says so. `retention` is a hit; the stoplist is the tuning knob. + +- [ ] **Step 4: Run tests** — 72 passed. +- [ ] **Step 5: Commit** — "Add nominalization hits and disclaimer-opener check" + trailers. + +--- + +### Task 5: Sentence-length tail keys and `--text` summary lines + +**Files:** +- Modify: `surface_scan.py` (`import math`; `sentence_len_extras`; `_first_hit`; `_repetition_line`; `summarize`; `analyze`); `tests/test_surface_scan.py` + +**Interfaces:** +- Produces: `sentence_len_extras(lengths) -> {pct_over_30, p90, longest_flat_run}`; `analyze()["sentence_len"]` gains those keys; `summarize()` prints 12 lines. + +- [ ] **Step 1: Failing tests** + +```python +def test_sentence_len_extras(): + assert ss.sentence_len_extras([10, 12, 9, 30, 31, 40]) == { + "pct_over_30": 33.3, + "p90": 40, + "longest_flat_run": 3, + } + assert ss.sentence_len_extras([7]) == {"pct_over_30": 0.0, "p90": 7, "longest_flat_run": 1} + assert ss.sentence_len_extras([]) == {"pct_over_30": 0.0, "p90": 0, "longest_flat_run": 0} + # a monotone ramp is measured against the run's first sentence, not its neighbour + assert ss.sentence_len_extras([10, 13, 16, 19])["longest_flat_run"] == 2 + + +def test_analyze_sentence_len_keeps_stats_and_adds_extras(): + r = ss.analyze("One two three. Four five.\n\nSix seven eight nine ten eleven.") + assert set(r["sentence_len"]) == { + "mean", + "stdev", + "cv", + "min", + "max", + "pct_over_30", + "p90", + "longest_flat_run", + } + assert set(r["paragraph_len"]) == {"mean", "stdev", "cv", "min", "max"} + assert r["sentence_len"]["p90"] == 6 + + +def test_summarize_has_twelve_lines_and_new_sections(): + r = ss.analyze("We shipped the release, ensuring alignment. " * 4 + "Short text. " * 40) + lines = ss.summarize(r).splitlines() + assert len(lines) == 12 + assert lines[8].startswith("repetition: ") and lines[9].startswith( + "grammar: participial tails " + ) + assert lines[10].startswith("sentence tail: over-30 ") + assert lines[11].startswith("nominalization hits: ") + assert "not measured (under 150 words)" in ss.summarize(ss.analyze("Short text. " * 5)) + assert " ·" not in ss.summarize(ss.analyze("Short text. " * 5)) +``` + +- [ ] **Step 2: Run to verify failure** — `-k "extras or twelve"` → 3 failed. + +- [ ] **Step 3: Implement** + +Add `import math` to the imports. After `_stats` add: +```python +def sentence_len_extras(lengths: list[int]) -> dict: + n = len(lengths) + if n == 0: + return {"pct_over_30": 0.0, "p90": 0, "longest_flat_run": 0} + ordered = sorted(lengths) + p90 = ordered[max(0, math.ceil(0.9 * n) - 1)] + best = run = 1 + anchor = lengths[0] + for x in lengths[1:]: + if abs(x - anchor) <= 3: + run += 1 + else: + run, anchor = 1, x + best = max(best, run) + return { + "pct_over_30": round(100 * sum(1 for x in lengths if x > 30) / n, 1), + "p90": p90, + "longest_flat_run": best, + } +``` +In `analyze`, define `sent_lens = [len(words(s)) for s in sents]` before the return and replace the `"sentence_len"` entry with: +```python + "sentence_len": {**_stats(sent_lens), **sentence_len_extras(sent_lens)}, +``` +Before `summarize` add: +```python +def _first_hit(hits: list[dict]) -> str: + return " " + json.dumps(hits[0]["text"]) if hits else "" + + +def _repetition_line(rep: dict) -> str: + if rep["too_short"]: + return "repetition: not measured (under 150 words)" + top = rep["phrases"][0] if rep["phrases"] else None + shown = f" · {json.dumps(top['text'])}×{top['count']}" if top else "" + return ( + f"repetition: {rep['repeated_phrase_rate']}/1k · " + f"longest repeat {rep['longest_repeat']}{shown}" + ) +``` +In `summarize`, add locals after `top = ...`: +```python + nom = r["nominalization"] + nom_hits = ", ".join(f"{h['text']}×{h['count']}" for h in nom["hits"][:3]) or "none" + nom_frames = ", ".join(json.dumps(f["text"]) for f in nom["of_frames"][:2]) or "none" + tail = r["grammar"]["participial_tail"] + cont = r["grammar"]["container_of"] +``` +and append four list entries after the `summary closer` entry (plain f-strings, no tuples): +```python +(_repetition_line(r["repetition"]),) +f"grammar: participial tails {tail['count']} ({tail['rate']}/1k)" +(f"{_first_hit(tail['hits'])} · container-of {cont['count']}{_first_hit(cont['hits'])}",) +f"sentence tail: over-30 {sl['pct_over_30']}% · p90 {sl['p90']} · " +(f"longest flat run {sl['longest_flat_run']}",) +(f"nominalization hits: {nom['count']} ({nom_hits}) · frames: {nom_frames}",) +``` + +- [ ] **Step 4: Run tests** — 75 passed. `uv run python plugins/humanize/skills/humanize/scripts/surface_scan.py --text tests/fixtures/ai_email.txt | wc -l` → 12. +- [ ] **Step 5: Commit** — "Add sentence-length tail keys and four summary lines" + trailers. + +--- + +### Task 6: Fixtures, gates, provenance, checklist + +**Files:** +- Create: `tests/fixtures/ai_report.txt`, `human_formal.txt`, `human_plain.txt`, `PROVENANCE.md` +- Modify: `tests/test_fixtures.py`, `tests/fixtures/expected_tells.md` + +Spec amendments recorded here: provenance lives in `tests/fixtures/PROVENANCE.md` (a comment inside a fixture would be scanned); `human_plain.txt` comes from the archived plainlanguage.gov guideline pages (digital.gov no longer hosts them); a float measuring 0.0 gets the band (0.0, 2.0). + +- [ ] **Step 1: Write `ai_report.txt`** (verbatim; 12 paragraphs, ~624 words) + +``` +It's important to approach this update carefully, since several workstreams changed scope during the quarter. I'm not able to share vendor pricing here, but the overall picture is encouraging and the risks are manageable. + +The implementation of the new ingestion pipeline finished two weeks behind the original estimate, reflecting the late discovery of schema drift in the partner feed. The team resolved the drift by introducing a validation layer, ensuring alignment across teams before any record reaches the warehouse. Throughput now sits at roughly nine thousand events per minute, exceeding the target we set in March. + +Adoption of the shared design system continued across all workstreams and teams, creating a sense of momentum that was missing last quarter. Four product surfaces migrated to the new components, allowing designers to retire eleven legacy patterns. The migration of the billing screens remains in progress, pending the completion of an accessibility review. + +On the reliability side, the optimization of our alerting rules reduced paging volume by about a third. Engineers consolidated forty-two overlapping alerts into nine, giving on-call staff a clearer signal during incidents. Mean time to acknowledge fell from eleven minutes to four, matching the level the platform group had proposed. + +Customer support handled a spike in tickets after the pricing change, resolving most of them within the first business day. The knowledge base articles were rewritten in plainer language, and the deflection rate climbed from thirty to forty-one percent. A small group of enterprise accounts asked for a dedicated onboarding call, and account managers have scheduled those for the second week of the month. + +Hiring closed on three of the five open roles, leaving two senior positions unfilled going into the next cycle. The weight of the decision to pause backfills fell mostly on the data platform group. We expect the remaining offers to close by mid-month, assuming the compensation adjustments are approved. + +Coordination across all workstreams and teams improved once the weekly sync moved to a written format. Fewer meetings meant more focused work, and the written record made the escalation of blockers faster to trace. Product managers reported that the transformation of the roadmap into quarterly themes made prioritization discussions shorter. + +Security completed the remediation of the findings from the spring audit, closing every high-severity item ahead of schedule. Two medium items remain open, awaiting a library upgrade that the vendor has scheduled for next month. The compliance team confirmed that the evidence collection process now runs automatically each week. + +Documentation for the public API moved to the new site, giving external developers a searchable reference for the first time. Traffic to the reference pages doubled in the first month, and the volume of questions in the developer forum dropped noticeably. The technical writers also produced a migration guide for teams still on the deprecated endpoints. + +Looking ahead, the integration of the analytics events into the new pipeline is the main dependency for the reporting launch. The team plans to finish the mapping by the end of the month, leaving three weeks for validation with finance. Budget remains within the approved envelope, and no additional headcount is requested at this time. + +Finance reviewed the quarterly forecast with each product area and found no material variance against the plan. The revised allocation model landed in the reporting tool last week, and cost centre owners can now see their own numbers without filing a ticket. Two departments asked for a shorter reporting cycle, and that request is under review. + +In summary, delivery across all workstreams and teams stayed on plan despite the schema issue, and the quarter closes with fewer open risks than it opened with. Please raise any concerns before Friday so they can be folded into the planning session. +``` + +Run `uv run python plugins/humanize/skills/humanize/scripts/surface_scan.py tests/fixtures/ai_report.txt` and confirm: `words ≥ 600`; `grammar.participial_tail.count ≥ 5`; `grammar.container_of.count == 2`; `repetition.phrases == [one entry: "across all workstreams and teams", count 3]`; `discourse.disclaimer_opener.fired == true`; `nominalization.of_frames` non-empty; `sentence_len.pct_over_30 == 0.0`. (Dry-run values on this text: 624 words, 14 tails, 2 containers, one repeated phrase, 8 frames.) If any check fails, edit the offending sentence minimally — never the three planted-phrase sentences — and re-run. + +- [ ] **Step 2: Build `human_formal.txt`** (Federalist No. 10, Project Gutenberg #1404) + +```bash +curl -sL https://www.gutenberg.org/cache/epub/1404/pg1404.txt -o /tmp/pg1404.txt +python3 - <<'EOF' +import re +t = open("/tmp/pg1404.txt", encoding="utf-8").read() +m = re.search(r"among the numerous advantages promised by a well", t, re.I) +body = t[m.start():] +out, n = [], 0 +for p in re.split(r"\n\s*\n", body): + p = re.sub(r"\s+", " ", p).strip() + if not p: + continue + out.append(p); n += len(p.split()) + if n >= 600: + break +open("tests/fixtures/human_formal.txt", "w", encoding="utf-8").write("\n\n".join(out) + "\n") +print(n, "words,", len(out), "paragraphs") +EOF +``` +Expected: about 731 words in 6 paragraphs, beginning "AMONG the numerous advantages…". If the download fails, stop and report; never substitute text from memory. + +- [ ] **Step 3: Build `human_plain.txt`** (archived US federal plain-language guidelines, public domain) + +The section landing pages hold only ~380 words together (`/organize/` is a 42-word stub), so the child pages are required. Fetch these nine archived pages (`https://web.archive.org/web/2024/https://www.plainlanguage.gov/guidelines/` + path): `audience/`, `audience/do-your-research/`, `audience/address-the-user/`, `audience/address-separate-audiences-separately/`, `organize/`, `organize/make-it-easy-to-follow/`, `organize/have-a-topic-sentence/`, `organize/place-the-main-idea-before-exceptions-and-conditions/`, `organize/use-transition-words/`. Extraction rule (write it as a small Python script using `html.parser`): take the `

` elements inside the `

` container, strip tags, unescape entities, drop any paragraph under 8 words, any ending in `:` (a list lead-in whose list is not carried), and the Published/Download/"Join the Plain Language" boilerplate; keep paragraphs in page order with one blank line between them. Expected: about 657 words in ~20 paragraphs. Record the exact URLs (with snapshot timestamps) in `PROVENANCE.md`. If the archive is unreachable, stop and report. + +- [ ] **Step 4: Record measured values in `tests/fixtures/PROVENANCE.md`** + +Run the scanner on `human_plain.txt`, `human_formal.txt`, and `human_fiction_excerpt.txt`, then write: +```markdown +# Fixture provenance + +| file | source | retrieved | how | +|---|---|---|---| +| ai_email.txt, human_email.txt | hand-written (v0.1) | 2026-09-13 | — | +| ai_fiction_excerpt.txt | StoryScope dev split, prompt_id 411, Claude story (MIT) | 2026-09-13 | pandas read of stories_dev.parquet | +| human_fiction_excerpt.txt | Pride and Prejudice ch. 1, Project Gutenberg #1342 (public domain) | 2026-09-13 | pg1342.txt, unwrap, strip [Illustration] | +| ai_report.txt | hand-written AI-style status report (v0.2) | 2026-09-14 | — | +| human_formal.txt | Federalist No. 10, Project Gutenberg #1404 (public domain) | 2026-09-14 | pg1404.txt from "AMONG the numerous advantages", first ≥600 words | +| human_plain.txt | plainlanguage.gov guidelines (US government work, public domain), archived: | 2026-09-14 | `

` elements of the docs-main-content div; paragraphs < 8 words, list lead-ins, and boilerplate dropped | + +## Pinned values (from `surface_scan.py` at creation) + +Bands: floats × 0.8–1.2; ints ± 1; a float measuring 0.0 gets (0.0, 2.0) so the pin stays a band. + +| fixture | key | measured | band | +|---|---|---|---| +| human_plain.txt | sentence_len.pct_over_30 | | (, ) | +| human_plain.txt | sentence_len.cv | | (, ) | +| human_plain.txt | sentence_len.longest_flat_run | | (, ) | +| human_formal.txt | nominalization.count | | (, ) | +| human_fiction_excerpt.txt / human_formal.txt / human_plain.txt | max repetition.phrases[].count | each | ≤ | + +These values on the plain-language fixture sit in "AI territory" on purpose: the +tests assert the plugin does NOT read them as authorship evidence (principle 8). +``` +Replace every `<…>` with the measured numbers before committing; the tests below use the same literals. (Dry-run values for reference: plain cv ≈ 0.375, flat run 9, pct_over_30 0.0; formal nominalization count ≈ 11; max repeat count 2 on all three.) + +- [ ] **Step 5: Gates** (append to `tests/test_fixtures.py`; fill the literals from Step 4) + +```python +AI_REPORT = "ai_report.txt" +HUMAN_SPECIFICITY = ("human_fiction_excerpt.txt", "human_formal.txt", "human_plain.txt") +# Pinned at fixture creation 2026-09-14; see fixtures/PROVENANCE.md. Values on +# human_plain.txt sit in "AI territory" and are asserted so a flat profile is +# never read as authorship evidence (principle 8). +HUMAN_PLAIN_BANDS = {"pct_over_30": (0.0, 2.0), "cv": (LO, HI), "longest_flat_run": (LO, HI)} +HUMAN_FORMAL_NOMINALIZATION_BAND = (LO, HI) +HUMAN_MAX_REPEAT_COUNT = { + "human_fiction_excerpt.txt": N, + "human_formal.txt": N, + "human_plain.txt": N, +} + + +def test_gate_sensitivity_on_ai_report(): + r = _scan(AI_REPORT) + assert r["grammar"]["participial_tail"]["count"] >= 5 + assert r["grammar"]["container_of"]["count"] >= 2 + top = r["repetition"]["phrases"][0] + assert top["text"] == "across all workstreams and teams" and top["count"] == 3 + assert r["discourse"]["disclaimer_opener"]["fired"] is True + assert r["nominalization"]["of_frames"] + + +@pytest.mark.parametrize("name", HUMAN_SPECIFICITY) +def test_gate_specificity_on_human_fixtures(name): + r = _scan(name) + assert r["grammar"]["participial_tail"]["count"] <= 1, r["grammar"]["participial_tail"]["hits"] + assert r["grammar"]["container_of"]["count"] <= 1, r["grammar"]["container_of"]["hits"] + assert r["discourse"]["disclaimer_opener"]["fired"] is False + counts = [p["count"] for p in r["repetition"]["phrases"]] or [0] + assert max(counts) <= HUMAN_MAX_REPEAT_COUNT[name] + + +def test_gate_direction_long_sentence_tail(): + human = _scan("human_formal.txt")["sentence_len"]["pct_over_30"] + assert human > _scan(AI_REPORT)["sentence_len"]["pct_over_30"] + + +def test_gate_fairness_bands_are_recorded_not_judged(): + sl = _scan("human_plain.txt")["sentence_len"] + for key, (lo, hi) in HUMAN_PLAIN_BANDS.items(): + assert lo <= sl[key] <= hi, (key, sl[key]) + n = _scan("human_formal.txt")["nominalization"] + lo, hi = HUMAN_FORMAL_NOMINALIZATION_BAND + assert lo <= n["count"] <= hi # formal human prose nominalizes; hits are a prompt, not a tell + + +def test_gate_shapes(): + r = _scan(AI_REPORT) + assert set(r["nominalization"]) == {"count", "hits", "of_frames"} + assert set(r["nominalization"]["hits"][0]) == {"text", "count"} + assert set(r["nominalization"]["of_frames"][0]) == {"text", "sentence"} + for block in ("participial_tail", "container_of"): + assert set(r["grammar"][block]["hits"][0]) == {"text", "sentence"} + assert set(r["repetition"]["phrases"][0]) == {"text", "count", "sentences"} +``` +Extend `test_fixtures_are_nontrivial_length` so its loop also covers the three new files with `>= 600`. + +- [ ] **Step 6: Run** — `uv run pytest -q -W error` → 82 passed (7 new tests: 1 + 3 parametrized + 1 + 1 + 1). If a specificity gate fails, do **not** loosen the cap: inspect the hits; fix a mis-cleaned fixture (navigation, table cells); if the metric genuinely fires on human prose more than the cap allows, report it — the spec removes that metric rather than tuning it. Note: the dry run measured `human_plain.txt` at exactly one participial tail before the list-item rule was added; with Task 3's `_LIST_ITEM_TAIL_RE` it should be 0. + +- [ ] **Step 7: `expected_tells.md`** — add a section per new fixture: for `ai_report.txt` the tells an audit should fire with quotes (trailing participial clause, verbatim repetition, container-noun phrase, safety disclaimer opener; nominalizations as a prose note, not a row); for `human_formal.txt` and `human_plain.txt` what must NOT fire and what may legitimately show (nominalizations on Federalist; a flat sentence profile on the plain-language text). + +- [ ] **Step 8: Commit** — `git add tests/fixtures tests/test_fixtures.py`; "Add report fixtures with sensitivity, specificity, and pinned fairness gates" + trailers. + +--- + +### Task 7: `SOURCES.md` and the citation-key test + +**Files:** +- Create: `plugins/humanize/skills/humanize/references/SOURCES.md` +- Modify: `tests/test_manifests.py` + +**Interfaces:** +- Produces: keys `storyscope-2026 reinhart-2025 herbold-2023 jakesch-2023 munoz-ortiz-2024 rudnicka-2026 padmakumar-2024 chakrabarty-2025 sun-2025 milicka-2025 kobak-2025 liang-2024 survey-2025`, each as a ``## `key` `` heading; `test_sources_registry_exists_with_expected_keys`, `test_reference_citation_keys_resolve`. + +- [ ] **Step 1: Failing tests** + +At the top of `tests/test_manifests.py`, change `import json` to: +```python +import json +import re +``` +Add `"SOURCES"` to the `for doc in (...)` tuple in `test_marketplace_points_at_existing_plugin_components`. Append: +```python +REFS = ROOT / "plugins/humanize/skills/humanize/references" +KEY_RE = re.compile(r"\[([a-z-]+-\d{4})\]") +EXPECTED_KEYS = { + "storyscope-2026", + "reinhart-2025", + "herbold-2023", + "jakesch-2023", + "munoz-ortiz-2024", + "rudnicka-2026", + "padmakumar-2024", + "chakrabarty-2025", + "sun-2025", + "milicka-2025", + "kobak-2025", + "liang-2024", + "survey-2025", +} + + +def _source_keys() -> set: + text = (REFS / "SOURCES.md").read_text() + return set(re.findall(r"^## `([a-z-]+-\d{4})`$", text, re.M)) + + +def test_sources_registry_exists_with_expected_keys(): + text = (REFS / "SOURCES.md").read_text() + keys = _source_keys() + assert EXPECTED_KEYS <= keys + for key in keys: + block = text.split(f"## `{key}`", 1)[1].split("\n## ", 1)[0] + assert "May support:" in block and "Verified:" in block, key + + +def test_reference_citation_keys_resolve(): + keys = _source_keys() + for path in REFS.glob("*.md"): + if path.name == "SOURCES.md": + continue + for key in KEY_RE.findall(path.read_text()): + assert key in keys, (path.name, key) +``` + +- [ ] **Step 2: Run to verify failure** — `uv run pytest -q tests/test_manifests.py -W error` → 3 failed (`FileNotFoundError` / missing doc). + +- [ ] **Step 3: Write `SOURCES.md`** — every entry has exactly these lines: citation, URL, `Corpus:`, `May support:`, `Caveats:`, `Verified:`. Verify each author list against the paper's abstract page (curl/WebFetch allowed here) and put the date in `Verified:`. + +```markdown +# Sources + +Maintainer registry for every study cited in the reference docs. Never loaded +by the skill at runtime — entries carry their citation inline as +`(Author et al. YEAR [key])`. `tests/test_manifests.py` checks that every +`[key]` in `references/*.md` resolves here. "May support" is the rule Bugbot +enforces: only `storyscope-2026` may back a `Base rate:` line; everything else +is cited on `Scan:` / `Rule of thumb:` lines, and model-vs-model sources never +appear as a human/AI rate. + +## `storyscope-2026` +Russell, Rajendhran, Pham, Iyyer, Wieting. *StoryScope: Investigating idiosyncrasies in AI fiction.* arXiv:2604.03136, 2026. +URL: https://arxiv.org/abs/2604.03136 +Corpus: 61,575 stories — 10,239 human (Books3 anthologies) and five 2026 LLMs; 304 features. +May support: `Base rate:` lines via `data/storyscope_feature_gaps.csv`. +Caveats: fiction only; amateur/anthology human baseline. +Verified: 2026-09-13 (data file computed from the released parquet). + +## `reinhart-2025` +Reinhart, Markey, Laudenbach, Pantusen, Yurko, Weinberg, Brown. *Do LLMs write like humans? Variation in grammatical and rhetorical styles.* PNAS 122, 2025 (arXiv:2410.16107). +URL: https://www.pnas.org/doi/10.1073/pnas.2422455122 +Corpus: 8,290 parallel human/LLM texts across six registers; Biber features; GPT-4o and Llama 3 (2024-era). +May support: ratios and directions on `Scan:` lines — participial modifiers 5.3×, d = 1.38; nominalization 2.1×, d = 1.23; agentless passives lower in GPT-4o. Never `Base rate:`. +Caveats: 2024-era models; news/academic registers; SI tables not open. +Verified: . + +## `herbold-2023` +Herbold, Hautli-Janisz, Heuer, Kikteva, Trautsch. *A large-scale comparison of human-written versus ChatGPT-generated essays.* Scientific Reports 13:18617, 2023. +URL: https://www.nature.com/articles/s41598-023-45644-9 +Corpus: 90 argumentative topics × human / ChatGPT-3.5 / ChatGPT-4; 658 expert ratings. +May support: direction on nominalization (monotonic 1.06 → 1.56 → 1.73) and the lexical-diversity reversal between model generations. +Caveats: non-native high-school writers; 2023 models; unit of the nominalization measure unstated. +Verified: . + +## `jakesch-2023` +Jakesch, Hancock, Naaman. *Human heuristics for AI-generated language are flawed.* PNAS 120(11), 2023 (arXiv:2206.07271). +URL: https://www.pnas.org/doi/10.1073/pnas.2208839120 +Corpus: six experiments, N = 4,600, 53,411 judgments on short self-presentation texts. +May support: repeated phrases as the strongest true-source predictor (OR 1.47) and the three backwards reader cues (contractions; grammar errors; long or rare words). +Caveats: GPT-3-era; short bios; reader-belief-vs-reality gaps, not model rates. +Verified: . + +## `munoz-ortiz-2024` +Muñoz-Ortiz, Gómez-Rodríguez, Vilares. *Contrasting Linguistic Patterns in Human and LLM-Generated News Text.* Artificial Intelligence Review 57:265, 2024. +URL: https://doi.org/10.1007/s10462-024-10903-2 +Corpus: 13,371 NYT lead paragraphs (≤ 200 tokens) vs six base (non-instruction-tuned) LLMs. +May support: direction only — humans 31.2% of sentences over 30 words vs 17.5–21.0%; never a threshold. +Caveats: asymmetric prompt; 2023 base models; news register. +Verified: . + +## `rudnicka-2026` +Rudnicka, Juzek. *Beyond "AI Language": The case for the idiolectal nature of LLM output.* arXiv:2608.06589, 2026. +URL: https://arxiv.org/abs/2608.06589 +Corpus: prompt-matched 2024 vs 2026 model corpora on one topic; no prompt-matched human corpus. +May support: per-family ranges (safety disclaimers 46% vs 0.2%), the apostrophe-glyph observation, wordlist vintage. No human baseline — never a human/AI rate. +Caveats: single topic; model-vs-model. +Verified: . + +## `padmakumar-2024` +Padmakumar, He. *Does Writing with Language Models Reduce Content Diversity?* ICLR 2024 (arXiv:2309.05196). +URL: https://arxiv.org/abs/2309.05196 +Corpus: randomized co-writing study, 38 writers × 3 conditions, ~370-word essays. +May support: direction on repeated n-grams and the localization of homogenization to model spans. +Caveats: GPT-3.5-era co-writing; argumentative essays; corpus-level diversity figures. +Verified: . + +## `chakrabarty-2025` +Chakrabarty, Laban, Wu. *Can AI writing be salvaged? Mitigating Idiosyncrasies and Improving Human-AI Alignment in the Writing Process through Edits.* CHI 2025. +URL: https://dl.acm.org/doi/full/10.1145/3706598.3713559 +Corpus: LAMP — 1,057 paragraphs, 18 MFA-trained editors, 8,035 edit spans. +May support: the 13 container-noun heads of Table 8; line-level edit-span shares. +Caveats: 80% literary fiction; "rare in the human seed paragraphs" is not a corpus baseline. +Verified: . + +## `sun-2025` +Sun, Yin, Xu, Kolter, Liu. *Idiosyncrasies in Large Language Models.* ICML 2025 (arXiv:2502.12150). +URL: https://arxiv.org/abs/2502.12150 +Corpus: five-way model-of-origin attribution on chat outputs. +May support: model-vs-model attribution facts only. No human baseline — never a human/AI rate; its transformation experiments are detector attacks and are not adopted. +Caveats: attribution accuracy, not prevalence. +Verified: . + +## `milicka-2025` +Milička, Marklová, Cvrček. *Benchmark of stylistic variation in LLM-generated texts.* arXiv:2509.10179, 2025. +URL: https://arxiv.org/abs/2509.10179 +Corpus: Biber multidimensional analysis over 32 model settings on 500-word continuations, English and Czech. +May support: direction on the passive-bearing dimension (29 of 32 settings away from it) and register non-adaptation. +Caveats: pre-review draft; figure-read values. +Verified: . + +## `kobak-2025` +Kobak, González-Márquez, Horvát, Lause. *Delving into LLM-assisted writing in biomedical publications through excess vocabulary.* Science Advances 11, 2025 (arXiv:2406.07016). +URL: https://arxiv.org/abs/2406.07016 +Corpus: 15.1M PubMed abstracts, 2010–2024. +May support: the "wordlists decay" vintage note only. Its rate is document presence, not per-1k; never compared to the plugin's rule of thumb. +Caveats: cannot separate direct LLM use from humans absorbing LLM-preferred words. +Verified: . + +## `liang-2024` +Liang et al. (record the full author list on verification). *Monitoring AI-Modified Content at Scale: A Case Study on the Impact of ChatGPT on AI Conference Peer Reviews.* ICML 2024 (arXiv:2403.07183). +URL: https://arxiv.org/abs/2403.07183 +Corpus: AI-conference peer reviews; distributional GPT quantification. +May support: the non-native-speaker confound named in its discussion. Its ranked vocabulary tables are excluded as detector material. +Caveats: corpus-level estimator with no single-document form. +Verified: . + +## `survey-2025` +*Linguistic Characteristics of AI-Generated Text: A Survey.* arXiv:2510.05136, 2025 (v1 preprint, no venue; record the authors on verification). +URL: https://arxiv.org/abs/2510.05136 +Corpus: synthesis of 44 studies (lexicon, grammar, other). +May support: direction and replication counts only; no rates. +Caveats: 25 of 44 studies GPT-3.5-era; English in 40 of 44. +Verified: . +``` + +- [ ] **Step 4: Run** — `uv run pytest -q -W error` → 84 passed. +- [ ] **Step 5: Commit** — "Add SOURCES.md citation registry and key-resolution test" + trailers. + +--- + +### Task 8: `surface-tells.md` — five new entries, two extensions, header + +**Files:** +- Modify: `plugins/humanize/skills/humanize/references/surface-tells.md` + +**Interfaces:** +- Consumes: metric keys from Tasks 2–5; citation keys from Task 7. + +- [ ] **Step 1: Header.** Replace the two lines +``` +The layer StoryScope does not measure: vocabulary, punctuation, sentence and +paragraph shape, and discourse moves. Applies to every text class. Each entry +``` +with +``` +The layer StoryScope does not measure: vocabulary, grammar, punctuation, +sentence and paragraph shape, and discourse moves. Applies to every text +class. Each entry +``` +and after the sentence ending `StoryScope corpus.` (end of that paragraph) append: `Numbers that are not StoryScope base rates sit on `Scan:` or `Rule of thumb:` lines with a `[key]` that resolves in `SOURCES.md`.` (re-wrap the paragraph at 78 columns). + +- [ ] **Step 2: Vocabulary entries.** After the `### Latinate lean` entry's `Fix:` line (before `### Hedge stacks`) add: + +```markdown +### Nominalized verbs +Looks like: "the implementation of the policy led to an improvement in +retention" where "implementing the policy improved retention" would do; "the +X of" frames stacked through a paragraph. +Scan: `nominalization.hits` and `nominalization.of_frames` — hits only, no +rate, no threshold; singular and plural forms are separate hits (Herbold et +al. 2023 [herbold-2023]; Reinhart et al. 2025 [reinhart-2025]). +Why it reads as AI: buried verbs rise monotonically across model generations, +but formal, legal, academic, and second-language prose nominalize legitimately +— in `expository` text these hits are a prompt to look, never a table row. +Fix: rebalance — unbury the verb where the register does not earn the noun. + +### Abstract container-noun phrase +Looks like: "a sense of unease", "a mix of pride and fear", "the weight of the +decision" — an abstract container standing in for the concrete thing. +Scan: `grammar.container_of` count and hits; the 13 heads are those attested +in LAMP Table 8 (Chakrabarty et al. 2025 [chakrabarty-2025]), rare in the +human seed paragraphs. +Why it reads as AI: a reflex reach for an abstraction where a human names the +object or the feeling; fiction uses these legitimately, so judge density. +Fix: removal — name the concrete thing, or cut the frame and keep the noun. +``` + +- [ ] **Step 3: Structures entries.** After `### Parallel sentence openers` entry's `Fix:` line (before `### Uniform sentence length`) add: + +```markdown +### Verbatim repetition +Looks like: a phrase of four or more words reappearing intact across the piece +— "across all workstreams and teams" three times in a status report — or a +string lifted from the prompt or title. +Scan: `repetition.phrases` (silent under 150 words); repeated phrases are the +strongest true-source predictor readers miss, OR 1.47 (Jakesch et al. 2023 +[jakesch-2023]); `repetition.repeated_phrase_rate` is reported-only. +Why it reads as AI: recurrence with no rhetorical intent; terminology, names, +and identifiers must repeat — exempt technical and legal prose — and a refrain +in fiction is deliberate. +Fix: removal — keep one instance and vary or cut the rest. + +### Trailing participial clause +Looks like: a finished sentence that keeps going after a comma with an -ing +verb: ", ensuring seamless integration", ", allowing teams to move faster", +", highlighting the importance of". +Scan: `grammar.participial_tail` count, rate, and hits; ratio 5.3×, d = 1.38, +2024-era models, news and academic registers (Reinhart et al. 2025 +[reinhart-2025]). +Why it reads as AI: the tack-on lets a sentence add a consequence without a +new subject, and models reach for it several times a paragraph. +Fix: removal — split into a sentence with its own subject, or drop the clause. +``` + +- [ ] **Step 4: Discourse entry.** After `### Sign-off advice and offers` entry's `Fix:` line (before `### Headings and bullets in short pieces`) add: + +```markdown +### Safety disclaimer opener and AI self-reference +Looks like: a first paragraph that qualifies before it answers — "It's +important to approach this carefully", "I'm not able to give specific advice, +but", "consult a professional" — or any "As an AI" self-reference. +Scan: `discourse.disclaimer_opener.fired` and `.hits`; per-family range 46% +vs 0.2% of responses (Rudnicka & Juzek 2026 [rudnicka-2026]). +Why it reads as AI: assistant safety framing on a text that asked for none; +the same phrases mid-document are an ordinary discourse observation. +Fix: removal — start with the answer. +``` + +- [ ] **Step 5: Extensions.** + +In `### AI-associated wordlist`, after the three `Rule of thumb:` lines (the one ending `boilerplate dominates.`) add: +``` +Vintage: calibrated on 2023–2024 model output. A wordlist decays — Kobak et +al. 2025 [kobak-2025] tracked one marker's excess falling roughly fivefold +within a year (share of biomedical abstracts containing the word, not a per-1k +rate; not comparable to the rule of thumb above). Re-check against current +models before firing hard. +``` +In `### Uniform sentence length`, replace the two lines +``` +Scan: `sentence_len.cv` (stdev/mean). Rule of thumb: published human prose +commonly 0.5–0.9; AI drafts often below 0.4. +``` +with +``` +Scan: `sentence_len.cv` (stdev/mean); `sentence_len.pct_over_30` (humans +31.2% vs 17.5–21.0%, 2023 news corpus, direction not magnitude; Muñoz-Ortiz et +al. 2024 [munoz-ortiz-2024]); `sentence_len.longest_flat_run` (reported-only). +A flat profile is also the native shape of plain-language and technical prose +— `tests/fixtures/human_plain.txt` sits in AI territory on every sentence +metric — and is not authorship evidence. +Rule of thumb: published human prose commonly 0.5–0.9; AI drafts often below +0.4. +``` + +- [ ] **Step 6: Check** — every backticked metric key exists in the analyzer output (digits allowed): +```bash +python3 -c " +import re,sys; sys.path.insert(0,'plugins/humanize/skills/humanize/scripts'); import surface_scan as ss +r=ss.analyze('One two three four five. '*40); doc=open('plugins/humanize/skills/humanize/references/surface-tells.md').read() +def has(k): + cur=r + for part in k.split('.'): + if not isinstance(cur,dict) or part not in cur: return False + cur=cur[part] + return True +keys={k for k in re.findall(r'\`([a-z0-9_]+(?:\.[a-z0-9_]+)+)\`',doc) if not k.endswith('.py') and not k.endswith('.txt')} +bad=[k for k in keys if not has(k)]; print('bad keys:',bad); sys.exit(bool(bad))" +``` +Expected `bad keys: []`. Then `uv run pytest -q -W error` → 84 passed (every `[key]` resolves). + +- [ ] **Step 7: Commit** — "Add grammar and repetition tells to surface-tells; date-stamp the wordlist" + trailers. + +--- + +### Task 9: `SKILL.md` and `principles.md` + +**Files:** +- Modify: `plugins/humanize/skills/humanize/SKILL.md`, `plugins/humanize/skills/humanize/references/principles.md` + +- [ ] **Step 1: SKILL.md edits** — six whole-paragraph replacements (old text is verbatim from the file at its 78-column wrap; new text is pre-wrapped). After all six, `wc -l` must print exactly 150. + +1. Grounding. Old (3 lines): +``` +Make prose read as natural human writing by finding and removing the tells that +mark it as AI-generated. Grounded in StoryScope (Russell et al., 2026): AI +writing converges on shared defaults; human writing disperses. +``` +New (4 lines): +``` +Make prose read as natural human writing by finding and removing the tells that +mark it as AI-generated. Grounded in StoryScope (Russell et al., 2026) and +register studies; sources in `references/SOURCES.md`. AI writing converges +on shared defaults; human writing disperses. +``` +2. Invocation preamble. Old (4 lines): +``` +This section applies only when the user typed `/humanize …`. When the skill +activates on its own — drafting mode, or a natural-language request like +"humanize this" — there are no arguments: skip this section and work on the +text the conversation is about. +``` +New (2 lines): +``` +Applies only when the user typed `/humanize …`. On auto-invoke (drafting +mode or a natural-language request) there are no arguments: skip this section. +``` +3. Step 1. After the line `override if given.` insert a blank line and (3 lines): +``` +In `expository` prose, nominalizations, container nouns, and participial +tails are native register — prompts to look, not tells, unless extreme for +the length. +``` +4. Step 3. Old (2 lines): +``` +Rank by strength of evidence. Report **at most ten**. For texts under ~300 +words, quote raw counts from `punct.counts`, not per-1k rates. Format: +``` +New (5 lines): +``` +Rank by strength of evidence. Report **at most ten**. For texts under ~300 +words, quote raw counts from `punct.counts`, not per-1k rates. Quote +`repetition.phrases` and `grammar.*.hits` verbatim. `nominalization.hits` +never become a row. Other studies' ratios never go in the base-rate column. +Format: +``` +5. Step 5. Insert a new item 4 after item 3's last line (`the author plausibly would.`), renumbering the old 4 → 5 and 5 → 6 (3 lines): +``` +4. Do not strip passives by reflex: GPT-4o (2024-era) used the agentless + passive at about half the human rate (Reinhart et al. 2025). Recast one + only when a fired tell names it. +``` +6. Step 6. Old (4 lines): +``` +Re-run the scanner on the rewrite. Show a before/after line for each metric +that changed materially. Confirm no fact was dropped by re-reading both. Never +describe the result as undetectable, as passing a detector, or as certified +human. It is better writing; say that. +``` +New (7 lines): +``` +Re-run the scanner on the rewrite. Show a before/after line for each metric +that changed materially. Verify by the scan and quoted spans, not by whether +it reads human to you. If the rewrite removed every long sentence or narrowed +the vocabulary, say so and reread: converging is a failure even as tell counts +fall. Confirm no fact was dropped by re-reading both. Never describe the +result as undetectable, as passing a detector, or as certified human. It is +better writing; say that. +``` +Arithmetic: 139 + 1 − 2 + 4 + 3 + 3 + 3 = 151. Run `wc -l`; if 151, remove the blank line the Step 1 insert added before the following `### 2. Scan` heading so exactly one blank line remains → 150. Record the number in the commit message. + +- [ ] **Step 2: principles.md edits** + +Append to principle 5, after the line ending `passes a detector, or is "certified human."` (same 3-space indent, wrapped at 78): +``` + Check the direction before you flag it. Findings expire — lexical diversity + reversed between GPT-3.5 and GPT-4 (Herbold et al. 2023 [herbold-2023]). + Some never held — GPT-4o used agentless passives at about half the human + rate (Reinhart et al. 2025 [reinhart-2025], 2024-era models), and 29 of 32 + model settings moved away from the dimension that carries passives (Milička + et al. 2025 [milicka-2025]: a factor loading, not a passive count). Reader + heuristics point backwards (Jakesch et al. 2023 [jakesch-2023], GPT-3-era + self-presentation bios): contractions read as human but lean AI; grammar + errors and long or rare words read as AI but lean human. Prefer recency for + capability-dependent features, replication for stable ones — and never + optimize for what a reader guesses is human. +``` +Append principle 8 after 7: +``` +8. **Register and proficiency are not tells.** The measured AI profile — formal, + impersonal, nominalized, flat sentence lengths, narrow lexis, few + contractions — also describes competent second-language English, translated + text, legal, technical, academic, and plain-language prose. That overlap is + this repo's inference from the corpora below, not a finding any of them + tests. Measure the profile; never infer authorship or proficiency from it, + and never rewrite a text into looking less like one of those populations. + Each human baseline here comes from one narrow population — StoryScope: + amateur fiction; Muñoz-Ortiz: NYT lead paragraphs; Herbold: non-native + student essays; Jakesch: short bios — whose own limitations decline to + generalize. All of it is English; quote no number on translated or + non-English text. +``` + +- [ ] **Step 3: Validate** — `claude plugin validate --strict .` passes; `uv run pytest -q -W error` → 84 passed; `wc -l plugins/humanize/skills/humanize/SKILL.md` → 150. +- [ ] **Step 4: Commit** — "Add register gate, passive and convergence guards to SKILL; fairness principle" + trailers (include the SKILL.md line count in the body). + +--- + +### Task 10: Docs, invariants, version 0.2.0 + +**Files:** +- Modify: `README.md`, `CHANGELOG.md`, `CLAUDE.md`, `.cursor/BUGBOT.md`, `plugins/humanize/.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, `docs/design/2026-09-14-humanize-v0.2-design.md` (status line) + +- [ ] **Step 1: README** + - Grounding paragraph: it ends with the two lines `…scanner for the numbers a model can't` / `eyeball.` Append to that paragraph and re-wrap at 78: `v0.2 adds a grammar and repetition layer from register and reader-perception studies (Reinhart et al. 2025; Jakesch et al. 2023; Herbold et al. 2023 and others); every cited number resolves in `references/SOURCES.md`.` + - "What's inside": after the `model-fingerprints.md` line add ` SOURCES.md citation registry (not loaded at runtime)`; change the two `scripts/surface_scan.py` description lines to ` scripts/surface_scan.py stdlib-only metrics: burstiness and sentence tails, punctuation,` / ` tricolons, not-but, wordlists, closers, repeated phrases,` / ` participial tails, container nouns, nominalization hits`. + - Principles bullets: add `- **Register and proficiency are not tells.** Formal, plain-language, technical, and second-language prose share the measured AI profile; the plugin measures it and never infers authorship from it.` + - Credit paragraph. Old (three lines as wrapped): `MIT. StoryScope code and data are MIT-licensed; base rates in the reference` / `docs are computed from their released `storyscope_features.parquet` (see` / ``data/README.md`). They were measured on fiction and are used here as evidence,`. New, re-wrapped: `MIT. StoryScope code and data are MIT-licensed; `Base rate:` lines are computed from their released `storyscope_features.parquet` (see `data/README.md`); every other cited number carries an `[author-year]` key resolved in `references/SOURCES.md`. Base rates were measured on fiction and are used here as evidence,` — keep the rest of the paragraph unchanged. + - README has no test-count comment; nothing to update there. + +- [ ] **Step 2: CHANGELOG** — under `## [Unreleased]` insert `## [0.2.0] - 2026-09-14` with: Added (repetition block; grammar block — participial tails, container nouns; nominalization hits; disclaimer opener; sentence-tail keys; four `--text` lines; five surface tells; principle 8; `references/SOURCES.md`; report fixtures with sensitivity, specificity, direction, and pinned fairness gates), Changed (SKILL register gate, passive guard, convergence check; provenance invariant now allows cited non-StoryScope numbers on `Scan:` lines), Fixed (apostrophe look-alike glyphs between letters). Update the existing `[Unreleased]:` link to `…/compare/v0.2.0...HEAD` and add `[0.2.0]: https://github.com/ccf/humanize/compare/v0.1.2...v0.2.0` above the `[0.1.2]:` line. (0.1.2 and 0.2.0 share the date 2026-09-14; that is correct.) + +- [ ] **Step 3: CLAUDE.md** + - Replace the two-line bullet `- Every base rate in `references/*.md` traces to a row in` / ` `data/storyscope_feature_gaps.csv`. Do not type numbers from memory.` with: `- A `Base rate:` line traces to a row in `data/storyscope_feature_gaps.csv` (or a future CSV documented in `data/README.md`; none added in v0.2). Any other number in `references/*.md` sits on a `Scan:` or `Rule of thumb:` line with an inline `[author-year]` key that resolves in `references/SOURCES.md` (`tests/test_manifests.py` enforces it); model-vs-model sources never appear as a human/AI rate. Do not type numbers from memory.` + - Entry-shape bullet: after `Fix tag follows direction: `removal`` … `rebalance` for scales.` append: `Optional lines: `Rule of thumb:`, `Vintage:`. `rebalance` also covers register-dependent features where the fix is proportion, not deletion (nominalization).` + - Add a bullet: `- New scanner blocks (`repetition`, `grammar`, `nominalization`, `discourse.disclaimer_opener`, `sentence_len` tails) are counts and quotable hits; `nominalization` has no rate by design. Directional keys are gated on `tests/fixtures/`; reported-only keys are never thresholded.` + - Replace `# 53 tests, must be warning-free` with `# 84 tests, must be warning-free`. + +- [ ] **Step 4: BUGBOT.md** — apply the same two invariant amendments (base-rate/citation; entry shape with optional lines and the `rebalance` clause), and under "Where bugs hide" add: `- A number on a `Why it reads as AI:` line, or a `[key]` absent from `SOURCES.md` (grep `\[[a-z-]*-[0-9]\{4\}\]`).` + +- [ ] **Step 5: Version and spec status** — `sed -i '' 's/"version": "0.1.2"/"version": "0.2.0"/g' plugins/humanize/.claude-plugin/plugin.json .claude-plugin/marketplace.json`; in the spec, change the `Status:` line to `Status: implemented (v0.2.0)`. + +- [ ] **Step 6: Gate** — `uv run pytest -q -W error` (84 passed); `uv run ruff check . && uv run ruff format --check .`; `claude plugin validate --strict .`; `wc -l plugins/humanize/skills/humanize/SKILL.md` = 150; headless smoke test with the working-tree plugin: `claude plugin disable humanize@humanize; claude -p "/humanize tests/fixtures/ai_report.txt --audit-only" --plugin-dir plugins/humanize --output-format text --allowedTools "Bash,Read,Glob,Grep"; claude plugin enable humanize@humanize` — expect rows for trailing participial clause, verbatim repetition, container-noun phrase, and safety disclaimer opener, and no nominalization row. + +- [ ] **Step 7: Commit** — "Document v0.2: README, CHANGELOG, invariants; version 0.2.0" + trailers. + +--- + +## Self-review notes + +- Spec coverage: §1a T1; §1b T2; §1c–1d T3; §1e–1f T4; §1g–1h T5; §2–3 T9; §4 T8; §5 T7 + T10; §6 T6; §7 T10. Behavior example exercised by the T10 smoke test. +- Deviations recorded: provenance file (T6); archived plain-language source and child pages (T6); (0.0, 2.0) band rule (T6); `rebalance` scope clause (T10) so the nominalization entry's tag agrees with the CLAUDE.md rule; the spec's whole-sentence repeat is an 18-word sentence in the test (T2). +- Type consistency: hit shapes `{text, sentence}` (grammar, disclaimer), `{text, count, sentences}` (repetition), `{text, count}` / `{text, sentence}` (nominalization) match §0 and gate (v). `per_1k` reused for all rates. +- Dry-run evidence: Tasks 1–6 executed verbatim in a worktree with the fixes above applied; 82 tests passed on Python 3.9.6 and 3.13; all curly literals intact; `ruff check` clean after the E501 rewrites. diff --git a/plugins/humanize/.claude-plugin/plugin.json b/plugins/humanize/.claude-plugin/plugin.json index fb35d8c..d3383ea 100644 --- a/plugins/humanize/.claude-plugin/plugin.json +++ b/plugins/humanize/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "humanize", - "version": "0.1.2", + "version": "0.2.0", "description": "Audit prose for AI tells and rewrite it to read as natural human writing, grounded in StoryScope's measured human-vs-AI feature gaps", "author": { "name": "ccf" }, "license": "MIT", diff --git a/plugins/humanize/skills/humanize/SKILL.md b/plugins/humanize/skills/humanize/SKILL.md index 1b6a12e..5c44682 100644 --- a/plugins/humanize/skills/humanize/SKILL.md +++ b/plugins/humanize/skills/humanize/SKILL.md @@ -7,8 +7,8 @@ argument-hint: "[path | text] [--audit-only] [--fiction | --prose]" # Humanize Make prose read as natural human writing by finding and removing the tells that -mark it as AI-generated. Grounded in StoryScope (Russell et al., 2026): AI -writing converges on shared defaults; human writing disperses. +mark it as AI-generated. Grounded in StoryScope (Russell et al., 2026) and +register studies: AI converges on shared defaults; human writing disperses. Read `references/principles.md` first, every time. @@ -24,10 +24,8 @@ was invoked. Follow all six steps below. ## Invocation (`/humanize` only) -This section applies only when the user typed `/humanize …`. When the skill -activates on its own — drafting mode, or a natural-language request like -"humanize this" — there are no arguments: skip this section and work on the -text the conversation is about. +Applies only when the user typed `/humanize …`. On auto-invoke (drafting +mode or a natural-language request) there are no arguments: skip this section. Arguments: $ARGUMENTS @@ -60,6 +58,10 @@ Load `references/principles.md`, `references/surface-tells.md`, and generating model or asks which model wrote it. Honor a `--fiction` / `--prose` override if given. +In `expository` prose, nominalizations, container nouns, and participial +tails are native register — prompts to look, not tells, unless extreme for +the length. + ### 2. Scan If the text is 80 words or longer, run the scanner and keep the output: @@ -90,7 +92,10 @@ Walk every loaded tell list. For each tell you judge present, record: - the base rate line from the reference, or the scan number Rank by strength of evidence. Report **at most ten**. For texts under ~300 -words, quote raw counts from `punct.counts`, not per-1k rates. Format: +words, quote raw counts from `punct.counts`, not per-1k rates. Quote +`repetition.phrases` and `grammar.*.hits` verbatim. `nominalization.hits` +never become a row. Other studies' ratios never go in the base-rate column. +Format: ``` | # | Tell | Evidence | Base rate / metric | @@ -119,16 +124,22 @@ In priority order: let a plain sentence stay plain; name an emotion once instead of embodying it again; stop at the climax; allow a specific real-world reference where the author plausibly would. -4. Make `addition`-tagged fixes only when the inferred voice would plausibly do +4. Do not strip passives by reflex: GPT-4o (2024-era) used the agentless + passive at about half the human rate (Reinhart et al. 2025). Recast one + only when the inferred voice or a fired tell calls for it. +5. Make `addition`-tagged fixes only when the inferred voice would plausibly do that, and list them under "Choices you may want to reverse". -5. Match the inferred voice. Terse stays terse. +6. Match the inferred voice. Terse stays terse. ### 6. Verify Re-run the scanner on the rewrite. Show a before/after line for each metric -that changed materially. Confirm no fact was dropped by re-reading both. Never -describe the result as undetectable, as passing a detector, or as certified -human. It is better writing; say that. +that changed materially. Verify by the scan and quoted spans, not by whether +it reads human to you. If the rewrite removed every long sentence +(`sentence_len.max` fell hard) or flattened the burstiness (`cv` fell), say so +and reread: converging is a failure even as tell counts fall. Confirm no fact +was dropped by re-reading both. Never describe the result as undetectable, as +passing a detector, or as certified human. It is better writing; say that. ## Output shape (audit mode) diff --git a/plugins/humanize/skills/humanize/references/SOURCES.md b/plugins/humanize/skills/humanize/references/SOURCES.md new file mode 100644 index 0000000..fb35ed3 --- /dev/null +++ b/plugins/humanize/skills/humanize/references/SOURCES.md @@ -0,0 +1,113 @@ +# Sources + +Maintainer registry for every study cited in the reference docs. Never loaded +by the skill at runtime — entries carry their citation inline as +`(Author et al. YEAR [key])`. `tests/test_manifests.py` checks that every +`[key]` in `references/*.md` resolves here. "May support" is the rule Bugbot +enforces: only `storyscope-2026` may back a `Base rate:` line; everything else +is cited on `Scan:` / `Rule of thumb:` lines, and model-vs-model sources never +appear as a human/AI rate. + +## `storyscope-2026` +Russell, Rajendhran, Pham, Iyyer, Wieting. *StoryScope: Investigating idiosyncrasies in AI fiction.* arXiv:2604.03136, 2026. +URL: https://arxiv.org/abs/2604.03136 +Corpus: 61,575 stories — 10,239 human (Books3 anthologies) and five 2026 LLMs; 304 features. +May support: `Base rate:` lines via `data/storyscope_feature_gaps.csv`. +Caveats: fiction only; amateur/anthology human baseline. +Verified: 2026-09-13 (data file computed from the released parquet). + +## `reinhart-2025` +Reinhart, Markey, Laudenbach, Pantusen, Yurko, Weinberg, Brown. *Do LLMs write like humans? Variation in grammatical and rhetorical styles.* PNAS 122, 2025 (arXiv:2410.16107). +URL: https://www.pnas.org/doi/10.1073/pnas.2422455122 +Corpus: 8,290 parallel human/LLM texts across six registers; Biber features; GPT-4o and Llama 3 (2024-era). +May support: ratios and directions on `Scan:` lines — participial modifiers 5.3×, d = 1.38; nominalization 2.1×, d = 1.23; agentless passives lower in GPT-4o. Never `Base rate:`. +Caveats: 2024-era models; news/academic registers; SI tables not open. +Verified: 2026-09-14 (author list and title confirmed via the arXiv:2410.16107 abstract page; PNAS page returned 403). + +## `herbold-2023` +Herbold, Hautli-Janisz, Heuer, Kikteva, Trautsch. *A large-scale comparison of human-written versus ChatGPT-generated essays.* Scientific Reports 13:18617, 2023. +URL: https://www.nature.com/articles/s41598-023-45644-9 +Corpus: 90 argumentative topics × human / ChatGPT-3.5 / ChatGPT-4; 658 expert ratings. +May support: direction on nominalization (monotonic 1.06 → 1.56 → 1.73) and the lexical-diversity reversal between model generations. +Caveats: non-native high-school writers; 2023 models; unit of the nominalization measure unstated. +Verified: 2026-09-14 (author list and title confirmed against the Nature article page). + +## `jakesch-2023` +Jakesch, Hancock, Naaman. *Human heuristics for AI-generated language are flawed.* PNAS 120(11), 2023 (arXiv:2206.07271). +URL: https://www.pnas.org/doi/10.1073/pnas.2208839120 +Corpus: six experiments, N = 4,600, 53,411 judgments on short self-presentation texts. +May support: repeated phrases as the strongest true-source predictor (OR 1.47) and the three backwards reader cues (contractions; grammar errors; long or rare words). +Caveats: GPT-3-era; short bios; reader-belief-vs-reality gaps, not model rates. +Verified: 2026-09-14 (author list and title confirmed via the arXiv:2206.07271 abstract page; PNAS page returned 403). + +## `munoz-ortiz-2024` +Muñoz-Ortiz, Gómez-Rodríguez, Vilares. *Contrasting Linguistic Patterns in Human and LLM-Generated News Text.* Artificial Intelligence Review 57:265, 2024. +URL: https://doi.org/10.1007/s10462-024-10903-2 +Corpus: 13,371 NYT lead paragraphs (≤ 200 tokens) vs six base (non-instruction-tuned) LLMs. +May support: direction only — humans 31.2% of sentences over 30 words vs 17.5–21.0%; never a threshold. +Caveats: asymmetric prompt; 2023 base models; news register. +Verified: 2026-09-14 (author list and title confirmed via the Semantic Scholar record for this DOI; Springer page sat behind a login wall). + +## `rudnicka-2026` +Rudnicka, Juzek. *Beyond "AI Language": The case for the idiolectal nature of LLM output.* arXiv:2608.06589, 2026. +URL: https://arxiv.org/abs/2608.06589 +Corpus: prompt-matched 2024 vs 2026 model corpora on one topic; no prompt-matched human corpus. +May support: per-family ranges (safety disclaimers 46% vs 0.2%), the apostrophe-glyph observation, wordlist vintage. No human baseline — never a human/AI rate. +Caveats: single topic; model-vs-model. +Verified: 2026-09-14 (author list and title confirmed via the arXiv abstract page). + +## `padmakumar-2024` +Padmakumar, He. *Does Writing with Language Models Reduce Content Diversity?* ICLR 2024 (arXiv:2309.05196). +URL: https://arxiv.org/abs/2309.05196 +Corpus: randomized co-writing study, 38 writers × 3 conditions, ~370-word essays. +May support: direction on repeated n-grams and the localization of homogenization to model spans. +Caveats: GPT-3.5-era co-writing; argumentative essays; corpus-level diversity figures. +Verified: 2026-09-14 (author list and title confirmed via the arXiv abstract page). + +## `chakrabarty-2025` +Chakrabarty, Laban, Wu. *Can AI writing be salvaged? Mitigating Idiosyncrasies and Improving Human-AI Alignment in the Writing Process through Edits.* CHI 2025. +URL: https://dl.acm.org/doi/full/10.1145/3706598.3713559 +Corpus: LAMP — 1,057 paragraphs, 18 MFA-trained editors, 8,035 edit spans. +May support: the 13 container-noun heads of Table 8; line-level edit-span shares. +Caveats: 80% literary fiction; "rare in the human seed paragraphs" is not a corpus baseline. +Verified: 2026-09-14 (author list and title confirmed via the Semantic Scholar record for this DOI; ACM DL page returned a bot challenge). + +## `sun-2025` +Sun, Yin, Xu, Kolter, Liu. *Idiosyncrasies in Large Language Models.* ICML 2025 (arXiv:2502.12150). +URL: https://arxiv.org/abs/2502.12150 +Corpus: five-way model-of-origin attribution on chat outputs. +May support: model-vs-model attribution facts only. No human baseline — never a human/AI rate; its transformation experiments are detector attacks and are not adopted. +Caveats: attribution accuracy, not prevalence. +Verified: 2026-09-14 (author list and title confirmed via the arXiv abstract page). + +## `milicka-2025` +Milička, Marklová, Cvrček. *Benchmark of stylistic variation in LLM-generated texts.* arXiv:2509.10179, 2025. +URL: https://arxiv.org/abs/2509.10179 +Corpus: Biber multidimensional analysis over 32 model settings on 500-word continuations, English and Czech. +May support: direction on the passive-bearing dimension (29 of 32 settings away from it) and register non-adaptation. +Caveats: pre-review draft; figure-read values. +Verified: 2026-09-14 (author list and title confirmed via the arXiv abstract page). + +## `kobak-2025` +Kobak, González-Márquez, Horvát, Lause. *Delving into LLM-assisted writing in biomedical publications through excess vocabulary.* Science Advances 11, 2025 (arXiv:2406.07016). +URL: https://arxiv.org/abs/2406.07016 +Corpus: 15.1M PubMed abstracts, 2010–2024. +May support: the "wordlists decay" vintage note only. Its rate is document presence, not per-1k; never compared to the plugin's rule of thumb. +Caveats: cannot separate direct LLM use from humans absorbing LLM-preferred words. +Verified: 2026-09-14 (author list and title confirmed via the arXiv abstract page). + +## `liang-2024` +Liang, Izzo, Zhang, Lepp, Cao, Zhao, Chen, Ye, Liu, Huang, McFarland, Zou. *Monitoring AI-Modified Content at Scale: A Case Study on the Impact of ChatGPT on AI Conference Peer Reviews.* ICML 2024 (arXiv:2403.07183). +URL: https://arxiv.org/abs/2403.07183 +Corpus: AI-conference peer reviews; distributional GPT quantification. +May support: the non-native-speaker confound named in its discussion. Its ranked vocabulary tables are excluded as detector material. +Caveats: corpus-level estimator with no single-document form. +Verified: 2026-09-14 (full author list — Weixin Liang, Zachary Izzo, Yaohui Zhang, Haley Lepp, Hancheng Cao, Xuandong Zhao, Lingjiao Chen, Haotian Ye, Sheng Liu, Zhi Huang, Daniel A. McFarland, James Y. Zou — confirmed via the arXiv abstract page). + +## `survey-2025` +Terčon, Dobrovoljc. *Linguistic Characteristics of AI-Generated Text: A Survey.* arXiv:2510.05136, 2025 (v1 preprint, no venue). +URL: https://arxiv.org/abs/2510.05136 +Corpus: synthesis of 44 studies (lexicon, grammar, other). +May support: direction and replication counts only; no rates. +Caveats: 25 of 44 studies GPT-3.5-era; English in 40 of 44. +Verified: 2026-09-14 (authors — Luka Terčon, Kaja Dobrovoljc — confirmed via the arXiv abstract page). diff --git a/plugins/humanize/skills/humanize/references/principles.md b/plugins/humanize/skills/humanize/references/principles.md index c2f9dcb..e6f1608 100644 --- a/plugins/humanize/skills/humanize/references/principles.md +++ b/plugins/humanize/skills/humanize/references/principles.md @@ -27,6 +27,17 @@ Read this before every audit or rewrite. tells; a text can show none and be generated. Report what fired and why a reader would notice. Never state or imply that the result is undetectable, passes a detector, or is "certified human." + Check the direction before you flag it. Findings expire — lexical diversity + reversed between GPT-3.5 and GPT-4 (Herbold et al. 2023 [herbold-2023]). + Some never held — GPT-4o used agentless passives at about half the human + rate (Reinhart et al. 2025 [reinhart-2025], 2024-era models), and 29 of 32 + model settings moved away from the dimension that carries passives (Milička + et al. 2025 [milicka-2025]: a factor loading, not a passive count). Reader + heuristics point backwards (Jakesch et al. 2023 [jakesch-2023], GPT-3-era + self-presentation bios): contractions read as human but lean AI; grammar + errors and long or rare words read as AI but lean human. Prefer recency for + capability-dependent features, replication for stable ones — and never + optimize for what a reader guesses is human. 6. **Ask rarely.** Infer register, audience, and intent from the text and the conversation. Ask one question only when a rewrite decision genuinely hinges @@ -36,3 +47,16 @@ Read this before every audit or rewrite. plain. Name an emotion once instead of embodying it again. Stop at the climax. Let one paragraph be a single line. Let a reference be specific. Each of these is a departure from the AI default; none is a new rule. + +8. **Register and proficiency are not tells.** The measured AI profile — formal, + impersonal, nominalized, flat sentence lengths, narrow lexis, few + contractions — also describes competent second-language English, translated + text, legal, technical, academic, and plain-language prose. That overlap is + this repo's inference from the corpora below, not a finding any of them + tests. Measure the profile; never infer authorship or proficiency from it, + and never rewrite a text into looking less like one of those populations. + Each human baseline here comes from one narrow population — StoryScope: + amateur fiction; Muñoz-Ortiz: NYT lead paragraphs; Herbold: non-native + student essays; Jakesch: short bios — whose own limitations decline to + generalize. All of it is English; quote no number on translated or + non-English text. diff --git a/plugins/humanize/skills/humanize/references/surface-tells.md b/plugins/humanize/skills/humanize/references/surface-tells.md index 44e8b50..ca1e197 100644 --- a/plugins/humanize/skills/humanize/references/surface-tells.md +++ b/plugins/humanize/skills/humanize/references/surface-tells.md @@ -1,10 +1,12 @@ # Surface tells -The layer StoryScope does not measure: vocabulary, punctuation, sentence and -paragraph shape, and discourse moves. Applies to every text class. Each entry -names the `surface_scan.py` metric that measures it where one exists. Ranges -marked *rule of thumb* are working heuristics from practice, not measured in the -StoryScope corpus. +The layer StoryScope does not measure: vocabulary, grammar, punctuation, +sentence and paragraph shape, and discourse moves. Applies to every text +class. Each entry names the `surface_scan.py` metric that measures it where +one exists. Ranges marked *rule of thumb* are working heuristics from +practice, not measured in the StoryScope corpus. Numbers that are not +StoryScope base rates sit on `Scan:` or `Rule of thumb:` lines with a `[key]` +that resolves in `SOURCES.md`. ## Vocabulary @@ -18,6 +20,11 @@ Scan: `wordlist.rate` per 1k words and `wordlist.hits` with sentence positions. Rule of thumb: human drafts usually < 3/1k; AI drafts commonly 10–30/1k in long-form prose and can exceed 60/1k in short business emails, where boilerplate dominates. +Vintage: calibrated on 2023–2024 model output. A wordlist decays — Kobak et +al. 2025 [kobak-2025] show marker words rising and falling with model +generations (share of biomedical abstracts containing the word, not a per-1k +rate; not comparable to the rule of thumb above). Re-check against current +models before firing hard. Why it reads as AI: these words are over-represented in RLHF-era model output and under-represented in ordinary human prose of the same register; readers have learned the list. @@ -34,6 +41,28 @@ Why it reads as AI: models default to the formal register of their training mass; humans pick the short word unless the register demands otherwise. Fix: rebalance — swap to the short Germanic word where the voice is not formal. +### Nominalized verbs +Looks like: "the implementation of the policy led to an improvement in +retention" where "implementing the policy improved retention" would do; "the +X of" frames stacked through a paragraph. +Scan: `nominalization.hits` and `nominalization.of_frames` — hits only, no +rate, no threshold; singular and plural forms are separate hits (Herbold et +al. 2023 [herbold-2023]; Reinhart et al. 2025 [reinhart-2025]). +Why it reads as AI: buried verbs rise monotonically across model generations, +but formal, legal, academic, and second-language prose nominalize legitimately +— in `expository` text these hits are a prompt to look, never a table row. +Fix: rebalance — unbury the verb where the register does not earn the noun. + +### Abstract container-noun phrase +Looks like: "a sense of unease", "a mix of pride and fear", "the weight of the +decision" — an abstract container standing in for the concrete thing. +Scan: `grammar.container_of` count and hits; the 13 heads are those attested +in LAMP Table 8 (Chakrabarty et al. 2025 [chakrabarty-2025]), rare in the +human seed paragraphs. +Why it reads as AI: a reflex reach for an abstraction where a human names the +object or the feeling; fiction uses these legitimately, so judge density. +Fix: removal — name the concrete thing, or cut the frame and keep the noun. + ### Hedge stacks Looks like: "It could perhaps be argued that this might, to some extent, generally be the case." @@ -114,10 +143,39 @@ Why it reads as AI: anaphora is a deliberate rhetorical figure; unintentional anaphora is a generation artifact. Fix: rebalance — vary the openers; combine two of the sentences. +### Verbatim repetition +Looks like: a phrase of four or more words reappearing intact across the piece +— "across all workstreams and teams" three times in a status report — or a +string lifted from the prompt or title. +Scan: `repetition.phrases` (silent under 150 words); repeated phrases are the +strongest true-source predictor readers miss, OR 1.47 (Jakesch et al. 2023 +[jakesch-2023]); `repetition.repeated_phrase_rate` is reported-only. +Why it reads as AI: recurrence with no rhetorical intent; terminology, names, +and identifiers must repeat — exempt technical and legal prose — and a refrain +in fiction is deliberate. +Fix: removal — keep one instance and vary or cut the rest. + +### Trailing participial clause +Looks like: a finished sentence that keeps going after a comma with an -ing +verb: ", ensuring seamless integration", ", allowing teams to move faster", +", highlighting the importance of". +Scan: `grammar.participial_tail` count, rate, and hits; ratio 5.3×, d = 1.38, +2024-era models, news and academic registers (Reinhart et al. 2025 +[reinhart-2025]). +Why it reads as AI: the tack-on lets a sentence add a consequence without a +new subject, and models reach for it several times a paragraph. +Fix: removal — split into a sentence with its own subject, or drop the clause. + ### Uniform sentence length Looks like: every sentence 14–20 words; no fragments; no 40-word sentence. -Scan: `sentence_len.cv` (stdev/mean). Rule of thumb: published human prose -commonly 0.5–0.9; AI drafts often below 0.4. +Scan: `sentence_len.cv` (stdev/mean); `sentence_len.pct_over_30` (humans +31.2% vs 17.5–21.0%, 2023 news corpus, direction not magnitude; Muñoz-Ortiz et +al. 2024 [munoz-ortiz-2024]); `sentence_len.longest_flat_run` (reported-only). +A flat profile is also the native shape of plain-language and technical prose +— `tests/fixtures/human_plain.txt` sits in AI territory on every sentence +metric — and is not authorship evidence. +Rule of thumb: published human prose commonly 0.5–0.9; AI drafts often below +0.4. Why it reads as AI: models regress to the mean sentence; humans write in bursts. Fix: rebalance — split one long sentence into a short one and a fragment; merge two mid-length sentences into a long one. Aim for range, not a target. @@ -163,6 +221,16 @@ Scan: `wordlist.hits` includes "reach out", "don't hesitate", "i hope this helps Why it reads as AI: assistant boilerplate. Fix: removal — end with the actual last thing you have to say, or a plain sign-off. +### Safety disclaimer opener and AI self-reference +Looks like: a first paragraph that qualifies before it answers — "It's +important to approach this carefully", "I'm not able to give specific advice, +but", "consult a professional" — or any "As an AI" self-reference. +Scan: `discourse.disclaimer_opener.fired` and `.hits`; per-family range 46% +vs 0.2% of responses (Rudnicka & Juzek 2026 [rudnicka-2026]). +Why it reads as AI: assistant safety framing on a text that asked for none; +the same phrases mid-document are an ordinary discourse observation. +Fix: removal — start with the answer. + ### Headings and bullets in short pieces Looks like: a 200-word email with three bold headers and two bulleted lists. Scan: none; judge by reading. diff --git a/plugins/humanize/skills/humanize/scripts/surface_scan.py b/plugins/humanize/skills/humanize/scripts/surface_scan.py index a1a6cc5..ef230f8 100644 --- a/plugins/humanize/skills/humanize/scripts/surface_scan.py +++ b/plugins/humanize/skills/humanize/scripts/surface_scan.py @@ -5,9 +5,11 @@ import argparse import json +import math import re import statistics import sys +from collections import Counter, defaultdict ABBREVIATIONS = { "dr", @@ -44,6 +46,7 @@ _MD_LINK_RE = re.compile(r"!?\[([^\]]*)\]\([^)]*\)") _BARE_URL_RE = re.compile(r"https?://\S+") _HTML_TAG_RE = re.compile(r"]*?)?/?>") +_APOSTROPHE_GLYPH_RE = re.compile(r"(?<=\w)[ʼʹ´‘’′](?=\w)") def strip_markdown(text: str) -> str: @@ -59,6 +62,11 @@ def strip_markdown(text: str) -> str: return text +def normalize_apostrophes(text: str) -> str: + """Map apostrophe look-alikes between letters to ASCII; leaves quotation marks alone.""" + return _APOSTROPHE_GLYPH_RE.sub("'", text) + + def words(text: str) -> list[str]: return _WORD_RE.findall(text) @@ -100,6 +108,27 @@ def _stats(values: list[int]) -> dict: } +def sentence_len_extras(lengths: list[int]) -> dict: + n = len(lengths) + if n == 0: + return {"pct_over_30": 0.0, "p90": 0, "longest_flat_run": 0} + ordered = sorted(lengths) + p90 = ordered[max(0, math.ceil(0.9 * n) - 1)] + best = run = 1 + anchor = lengths[0] + for x in lengths[1:]: + if abs(x - anchor) <= 3: + run += 1 + else: + run, anchor = 1, x + best = max(best, run) + return { + "pct_over_30": round(100 * sum(1 for x in lengths if x > 30) / n, 1), + "p90": p90, + "longest_flat_run": best, + } + + def per_1k(count: int, n_words: int) -> float: return round(count * 1000 / n_words, 1) if n_words else 0.0 @@ -228,6 +257,332 @@ def opener_distinct_ratio(sentences: list[str]) -> float: return round(len(set(fws)) / len(fws), 3) if fws else 0.0 +FUNCTION_WORDS = frozenset( + """a an the this that these those my your his her its our their some any each every no + i you he she it we they me him us them who whom whose which what + am is are was were be been being have has had do does did will would shall should can could + may might must and or but nor so yet for if then than as because while although though + of in on at to by with from into onto upon about over under after before between through + during without within not also just only very too there here when where how why""".split() +) + + +def _merge_capped_gram_runs(kept: list, repeated: dict, where: dict, starts: dict) -> list: + """Collapse a run of overlapping cap-length (60-token) grams that all came from + one verbatim repeat longer than the cap into a single representative gram. + Without this, a repeat over 60 tokens survives as many distinct, overlapping + 60-grams (one per sliding-window position) instead of one phrase, inflating + repeated_phrase_rate (review issue A10).""" + groups: dict = defaultdict(list) + rest = [] + for g in kept: + if len(g) == 60: + groups[(repeated[g], frozenset(where[g]))].append(g) + else: + rest.append(g) + merged = rest + for group in groups.values(): + group.sort(key=lambda g: starts[g]) + last_start = None + for g in group: + start = starts[g] + if last_start is not None and start - last_start < 60: + continue # another window of the same run as the previous kept gram + merged.append(g) + last_start = start + return merged + + +def repeated_phrases(sentences: list[str]) -> list[dict]: + """Maximal repeated phrases (>= 4 words, >= 2 content words) across sentences.""" + counts: dict = Counter() + where: dict = defaultdict(set) + starts: dict = {} # cap-length (60-token) gram -> its earliest start index + for si, s in enumerate(sentences): + toks = [w.lower() for w in words(s)] + # sentences are short; the cap bounds the O(L^3) work when split_sentences + # finds no boundary (e.g. a bullet list with no terminal punctuation). + for n in range(4, min(len(toks), 60) + 1): + for i in range(len(toks) - n + 1): + g = tuple(toks[i : i + n]) + counts[g] += 1 + where[g].add(si) + if n == 60 and g not in starts: + starts[g] = i + repeated = {g: c for g, c in counts.items() if c >= 2} + non_maximal = set() + for g, c in repeated.items(): + if len(g) > 4: + for sub in (g[1:], g[:-1]): + if repeated.get(sub) == c: + non_maximal.add(sub) + survivors = sorted((g for g in repeated if g not in non_maximal), key=len, reverse=True) + kept: list = [] + for g in survivors: + if any( + repeated[k] == repeated[g] + and len(k) > len(g) + and any(k[i : i + len(g)] == g for i in range(len(k) - len(g) + 1)) + for k in kept + ): + continue + if sum(1 for w in g if w not in FUNCTION_WORDS) < 2: + continue + kept.append(g) + kept = _merge_capped_gram_runs(kept, repeated, where, starts) + out = [{"text": " ".join(g), "count": repeated[g], "sentences": sorted(where[g])} for g in kept] + out.sort(key=lambda p: (-p["count"], -len(p["text"].split()), p["text"])) + return out + + +def repetition_block(sentences: list[str], n_words: int) -> dict: + if n_words < 150: + return {"too_short": True, "repeated_phrase_rate": 0.0, "longest_repeat": 0, "phrases": []} + phrases = repeated_phrases(sentences) + extra = sum(p["count"] - 1 for p in phrases) + return { + "too_short": False, + "repeated_phrase_rate": per_1k(extra, n_words), + "longest_repeat": max((len(p["text"].split()) for p in phrases), default=0), + "phrases": phrases[:5], + } + + +ING_STOPLIST = frozenset( + """morning evening thing something nothing anything everything during including following + according regarding concerning notwithstanding pending considering king ring spring string wing + ceiling wedding clothing painting meeting training funding housing beginning""".split() +) +PREP_SUB = frozenset( + """in on at by after before during since for with from under over within + when while if although as once until despite according given unlike + regardless besides except beyond without throughout across""".split() +) +SUBORDINATORS = frozenset( + """although because while when if once since after before until + unless though whereas as whenever wherever""".split() +) +CONJ_ADVERBS = frozenset( + """however instead first second third finally meanwhile yesterday + today tomorrow still then thus hence moreover furthermore nevertheless + nonetheless otherwise similarly likewise consequently indeed also + additionally overall ultimately importantly notably unfortunately + fortunately""".split() +) +FINITE_AUX = frozenset( + """is are was were be been has have had do does did will would + can could should may might must""".split() +) +IRREGULAR_PAST = frozenset( + """grew went took held came became began brought built bought chose drew + drove fought gave got kept knew met paid ran said sold sent sat shook + sang slept stood struck taught told threw understood wrote dealt swept + wept sought caught swung dug rode rang sank drank ate flew froze slid + tore wore wove swore forgot forgave arose awoke overcame undertook + withdrew""".split() +) +_PARTICIPIAL_TAIL_RE = re.compile(r",\s+(?:\w+ly\s+)?(\w+ing)\b(?!-)", re.I) +# En dash terminates a clause only when whitespace follows it, so a numeric +# range like "2023–2024" is not mistaken for a clause boundary (review issue A8). +_CLAUSE_END_RE = re.compile(r"[,;:—.!?]|–(?=\s)") +_LIST_CONTINUATION_RE = re.compile(r"^(?:,|and\b|or\b)", re.I) +_LIST_ITEM_TAIL_RE = re.compile(r"^\s+\w+,\s*(?:and|or)\b", re.I) +CONTAINER_HEADS = ( + "sense", + "mix", + "blend", + "weight", + "flicker", + "pang", + "glimmer", + "web", + "sea", + "mask", + "residue", + "fabric", + "foundation", +) +_CONTAINER_OF_RE = re.compile( + r"\b(?:a|an|the)\s+(?:\w+\s+)?(?:" + "|".join(CONTAINER_HEADS) + r")\s+of\b", re.I +) + + +def clause_text(sentence: str, start: int, head_end: int) -> str: + m = _CLAUSE_END_RE.search(sentence, head_end) + end = m.start() if m else len(sentence) + snippet = sentence[start : min(end, start + 60)] + if end > start + 60 and " " in snippet: + snippet = snippet[: snippet.rfind(" ")] + return snippet.rstrip() + + +def _has_finite_verb(toks: list[str]) -> bool: + # A 3-letter "-ed" word is never a genuine regular past tense (that would + # need an impossible 1-letter base verb) -- it's a homograph like the + # lowercased acronym "LED" coinciding with "led", the irregular past of + # "lead" (review A12). Length > 3 excludes those without excluding any + # real regular past tense, which needs at least a 2-letter base ("used"). + return any( + t in FINITE_AUX or t in IRREGULAR_PAST or (len(t) > 3 and t.endswith("ed")) for t in toks + ) + + +_SEGMENT_SPLIT_RE = re.compile(r",\s+") + + +def _opener_kind(toks: list[str]) -> str | None: + """Classify a FIRST segment's opener type per A3, or None if it isn't one.""" + if not toks: + return None + if toks[0] in SUBORDINATORS: + return "subordinator" + if len(toks) <= 2 and toks[0] in CONJ_ADVERBS: + return "conj_adverb" + if toks[0] in PREP_SUB: + return "preposition" + return None + + +def _is_fronted_adverbial(prefix: str) -> bool: + # `prefix` runs from the sentence start to the participial match's comma and + # may itself contain earlier commas (city-state, dates, thousands separators, + # coordinated adjectives, or a genuine second clause). Split on ", " (not a + # bare comma, so a thousands separator like "1,200" isn't a boundary) into + # segments. The first segment must be opener-led per A3; every later segment + # must be opener-internal (a subordinator-led segment, unconditionally — + # its comma closes a clause exactly like the first segment's, review issue + # A14; exactly one word, e.g. "Texas", "2024"; or itself preposition-led AND + # verbless, e.g. "with 1,200 users") or the guard lifts — a multi-word, + # non-prepositional segment ("revenue rises", "the team grew"), or a + # preposition-led one that itself has a finite verb ("with the new vendor + # the team shipped faster", review issue A13), is a clause of its own, so + # the -ing word is a genuine trailing participial, not the opener's gerund + # subject (review issue A7, revised after regressing a present-tense finite + # verb the veto alone can't see). + segments = _SEGMENT_SPLIT_RE.split(prefix) + kind = _opener_kind([w.lower() for w in words(segments[0])]) + if kind is None: + return False + for seg in segments[1:]: + seg_toks = [w.lower() for w in words(seg)] + if seg_toks and seg_toks[0] in SUBORDINATORS: + continue + if len(seg_toks) == 1: + continue + # PREP_SUB still contains words that are also subordinators (after, + # since, when, ...); the SUBORDINATORS check above always runs first, + # so this branch is only ever reached for a genuine, non-subordinator + # preposition — no need to exclude them again here. + if seg_toks and seg_toks[0] in PREP_SUB and not _has_finite_verb(seg_toks): + continue + return False + if kind != "preposition": + # Subordinate and conjunctive-adverb openers carry no veto: a + # subordinate clause always has its own verb, and that doesn't count + # against it (any genuine second clause was already caught above). + return True + return not _has_finite_verb([w.lower() for w in words(prefix)]) + + +def participial_tails(sentences: list[str]) -> list[dict]: + hits = [] + for si, s in enumerate(sentences): + for m in _PARTICIPIAL_TAIL_RE.finditer(s): + if m.group(1).lower() in ING_STOPLIST: + continue + if _is_fronted_adverbial(s[: m.start()]): + continue + rest = s[m.end() :] + if _LIST_CONTINUATION_RE.match(rest.lstrip()) or _LIST_ITEM_TAIL_RE.match(rest): + continue + hits.append({"text": clause_text(s, m.start(), m.end()), "sentence": si}) + return hits + + +def container_phrases(sentences: list[str]) -> list[dict]: + return [ + {"text": m.group(0), "sentence": si} + for si, s in enumerate(sentences) + for m in _CONTAINER_OF_RE.finditer(s) + ] + + +_NOMINAL_SUFFIX_RE = re.compile(r"(?:tion|sion|ment|ance|ence)$") +NOMINAL_STOPLIST = frozenset( + """station question condition position mention portion fraction function attention tradition + edition mission session version occasion passion tension pension mansion section fiction + population information education situation relation location generation organization + operation direction collection connection election exception reaction selection solution + revolution institution constitution faction auction caution vacation vocation corporation + proportion caption junction sanction ambition addition tuition nutrition petition + ammunition emotion devotion convention invention intention infection affection perfection + dimension television collision illusion compassion commission obsession possession + profession procession recession depression percussion concussion precision + comment document government department environment equipment apartment element + instrument segment monument ornament parliament sentiment testament argument treatment + movement basement pavement garment torment ferment pigment fragment filament ligament + regiment sediment condiment compliment complement implement supplement temperament + tournament sacrament firmament parchment management agreement statement settlement + judgment employment investment requirement entertainment experiment excitement + achievement commitment + science audience absence presence silence sentence evidence experience conference + difference distance balance finance insurance instance essence sequence consequence + reference preference influence confidence violence patience residence substance romance + alliance appliance entrance fragrance guidance allowance performance importance + resistance existence intelligence independence correspondence circumstance maintenance + acceptance assistance ambulance nuisance vengeance innocence competence excellence + providence prudence diligence negligence coincidence incidence conscience defence offence + licence obedience convenience adolescence magnificence eloquence affluence advance + elegance arrogance ignorance relevance brilliance radiance variance grievance abundance + acquaintance inheritance ordinance dominance resonance defiance severance deliverance + perseverance temperance utterance sustenance countenance provenance governance""".split() +) +DISCLAIMER_PHRASES = [ + "as an ai", + "consult a professional", + "i cannot provide", + "i'm not able to", + "it's important to approach", +] + + +def nominalization_block(sentences: list[str]) -> dict: + counts: dict = Counter() + frames = [] + for si, s in enumerate(sentences): + ws = words(s) + for i, w in enumerate(ws): + low = w.lower() + stem = low[:-1] if low.endswith("s") else low + if len(stem) < 7 or not _NOMINAL_SUFFIX_RE.search(stem) or stem in NOMINAL_STOPLIST: + continue + counts[low] += 1 + if ( + 0 < i < len(ws) - 1 + and ws[i - 1].lower() == "the" + and ws[i + 1].lower() == "of" + and re.search(r"\bthe\s+" + re.escape(low) + r"\s+of\b", s, re.I) + ): + frames.append({"text": f"the {low} of", "sentence": si}) + return { + "count": sum(counts.values()), + "hits": [{"text": t, "count": c} for t, c in counts.most_common(15)], + "of_frames": frames[:10], + } + + +def disclaimer_opener(paragraphs: list[str], sentences: list[str]) -> dict: + hits = [ + {"text": h["term"], "sentence": pos} + for h in phrase_hits(sentences, DISCLAIMER_PHRASES) + for pos in h["positions"] + ] + hits.sort(key=lambda h: h["sentence"]) + first = paragraphs[0] if paragraphs else "" + fired = any(_term_re(p).search(first) for p in DISCLAIMER_PHRASES) + return {"fired": bool(fired), "hits": hits} + + AI_WORDLIST = sorted( { "a beacon of", @@ -421,9 +776,31 @@ def dialogue_ratio(paragraphs: list[str]) -> float: return round(sum(bool(_DIALOGUE_RE.search(p)) for p in paragraphs) / len(paragraphs), 3) +def _first_hit(hits: list[dict]) -> str: + return " " + json.dumps(hits[0]["text"], ensure_ascii=False) if hits else "" + + +def _repetition_line(rep: dict) -> str: + if rep["too_short"]: + return "repetition: not measured (under 150 words)" + top = rep["phrases"][0] if rep["phrases"] else None + shown = f" · {json.dumps(top['text'], ensure_ascii=False)}×{top['count']}" if top else "" + return ( + f"repetition: {rep['repeated_phrase_rate']}/1k · " + f"longest repeat {rep['longest_repeat']}{shown}" + ) + + def summarize(r: dict) -> str: sl, pl, pu, st = r["sentence_len"], r["paragraph_len"], r["punct"], r["structures"] top = ", ".join(f"{h['term']}×{h['count']}" for h in r["wordlist"]["hits"][:8]) or "none" + nom = r["nominalization"] + nom_hits = ", ".join(f"{h['text']}×{h['count']}" for h in nom["hits"][:3]) or "none" + nom_frames = ( + ", ".join(json.dumps(f["text"], ensure_ascii=False) for f in nom["of_frames"][:2]) or "none" + ) + tail = r["grammar"]["participial_tail"] + cont = r["grammar"]["container_of"] punct_line = " · ".join( f"{label} {pu[key]} ({pu['counts'][key]})" for label, key in ( @@ -449,23 +826,35 @@ def summarize(r: dict) -> str: f"hedges {r['hedges']['rate']}/1k · intensifiers {r['intensifiers']['rate']}/1k", f"summary closer: {'yes' if r['discourse']['summary_closer'] else 'no'} · " f"dialogue paragraphs: {round(r['dialogue']['ratio'] * 100)}%", + _repetition_line(r["repetition"]), + f"grammar: participial tails {tail['count']} ({tail['rate']}/1k)" + f"{_first_hit(tail['hits'])} · container-of {cont['count']}{_first_hit(cont['hits'])}", + f"sentence tail: over-30 {sl['pct_over_30']}% · p90 {sl['p90']} · " + f"longest flat run {sl['longest_flat_run']}", + f"nominalization hits: {nom['count']} ({nom_hits}) · frames: {nom_frames}", ] ) def analyze(text: str) -> dict: - text = strip_markdown(text) + text = normalize_apostrophes(strip_markdown(text)) paras = split_paragraphs(text) sents = split_sentences(text) n_words = len(words(text)) wl = phrase_hits(sents, AI_WORDLIST) + tails = participial_tails(sents) + containers = container_phrases(sents) + sent_lens = [len(words(s)) for s in sents] return { - "discourse": {"summary_closer": summary_closer(paras)}, + "discourse": { + "summary_closer": summary_closer(paras), + "disclaimer_opener": disclaimer_opener(paras, sents), + }, "dialogue": {"ratio": dialogue_ratio(paras)}, "words": n_words, "sentences": len(sents), "paragraphs": len(paras), - "sentence_len": _stats([len(words(s)) for s in sents]), + "sentence_len": {**_stats(sent_lens), **sentence_len_extras(sent_lens)}, "paragraph_len": _stats([len(split_sentences(p)) for p in paras]), "punct": punctuation(text, n_words), "structures": { @@ -478,6 +867,16 @@ def analyze(text: str) -> dict: "wordlist": {"hits": wl, "rate": _rate_of(wl, n_words)}, "hedges": {"rate": _rate_of(phrase_hits(sents, HEDGES), n_words)}, "intensifiers": {"rate": _rate_of(phrase_hits(sents, INTENSIFIERS), n_words)}, + "repetition": repetition_block(sents, n_words), + "grammar": { + "participial_tail": { + "count": len(tails), + "rate": per_1k(len(tails), n_words), + "hits": tails[:10], + }, + "container_of": {"count": len(containers), "hits": containers[:10]}, + }, + "nominalization": nominalization_block(sents), } @@ -503,7 +902,7 @@ def main(argv: list[str] | None = None) -> None: else: text = sys.stdin.read() result = analyze(text) - print(summarize(result) if args.text else json.dumps(result, indent=2)) + print(summarize(result) if args.text else json.dumps(result, indent=2, ensure_ascii=False)) if __name__ == "__main__": diff --git a/tests/fixtures/PROVENANCE.md b/tests/fixtures/PROVENANCE.md new file mode 100644 index 0000000..15cbf61 --- /dev/null +++ b/tests/fixtures/PROVENANCE.md @@ -0,0 +1,55 @@ +# Fixture provenance + +| file | source | retrieved | how | +|---|---|---|---| +| ai_email.txt, human_email.txt | hand-written (v0.1) | 2026-09-13 | — | +| ai_fiction_excerpt.txt | StoryScope dev split, prompt_id 411, Claude story (MIT) | 2026-09-13 | pandas read of stories_dev.parquet | +| human_fiction_excerpt.txt | Pride and Prejudice ch. 1, Project Gutenberg #1342 (public domain) | 2026-09-13 | pg1342.txt, unwrap, strip [Illustration] | +| ai_report.txt | hand-written AI-style status report (v0.2) | 2026-09-14 | — | +| human_formal.txt | Federalist No. 10, Project Gutenberg #1404 (public domain) | 2026-09-14 | pg1404.txt from "AMONG the numerous advantages", first ≥600 words | +| human_plain.txt | plainlanguage.gov guidelines (US government work, public domain), archived — nine snapshot URLs below | 2026-09-14 | mechanical rule only: `

` elements anywhere inside the docs-main-content div, in document order, with paragraphs under 8 words dropped, list lead-ins ending in ":" dropped, and Published/Download/"Join the Plain Language" boilerplate dropped; whole paragraphs kept verbatim in page order (audience pages, then organize pages) and the walk stops at the first paragraph that brings the running total to ≥ 600 words — no sentence was removed or reordered inside a kept paragraph | + +### human_plain.txt snapshot URLs + +All nine pages listed in the task brief were fetched (HTTP 200, no "no captures +found" pages) so the extraction script could walk them in order; the 600-word +stop was reached partway through the third page, so only `audience/`, +`audience/do-your-research/`, and `audience/address-the-user/` contributed text +to the final fixture. The remaining six are recorded here for reproducibility. + +- (used) +- (used) +- (used, truncated mid-page at 600 words) +- (fetched, unused) +- (fetched, unused) +- (fetched, unused) +- (fetched, unused) +- (fetched, unused) +- (fetched, unused) + +Result: 19 paragraphs / 606 words, taken verbatim with no sentence-level +exclusions. `grammar.participial_tail.count == 1` (one hit: "...more than any +other single technique, using 'you' pulls users into the information and makes +it relevant to them."), at the `<= 1` specificity cap; `container_of.count == 0`; +`discourse.disclaimer_opener.fired == False`; `repetition.phrases == []`. The +one participial-tail hit is a known metric false positive — a gerund subject +after a fronted adverbial that rule (b) does not yet cover — kept because +fixture content never depends on the metric under test. + +## Pinned values (from `surface_scan.py` at creation) + +Bands: floats × 0.8–1.2, rounded outward to 2 dp; ints ± 1; a float measuring +0.0 gets (0.0, 2.0) so the pin stays a band; the formal nominalization gate is +a floor (≥ 5), not a band. + +| fixture | key | measured | band | +|---|---|---|---| +| human_plain.txt | sentence_len.pct_over_30 | 0.0 | (0.0, 2.0) | +| human_plain.txt | sentence_len.cv | 0.349 | (0.28, 0.42) | +| human_plain.txt | sentence_len.longest_flat_run | 9 | (8, 10) | +| human_formal.txt | nominalization.count | 11 | floor (≥ 5) | +| human_fiction_excerpt.txt / human_formal.txt | max repetition.phrases[].count | 2 each | ≤ 3 each | +| human_plain.txt | max repetition.phrases[].count | 0 (no repeated phrases) | ≤ 1 | + +These values on the plain-language fixture sit in "AI territory" on purpose: the +tests assert the plugin does NOT read them as authorship evidence (principle 8). diff --git a/tests/fixtures/ai_report.txt b/tests/fixtures/ai_report.txt new file mode 100644 index 0000000..16ed0a3 --- /dev/null +++ b/tests/fixtures/ai_report.txt @@ -0,0 +1,23 @@ +It's important to approach this update carefully, since several workstreams changed scope during the quarter. I'm not able to share vendor pricing here, but the overall picture is encouraging and the risks are manageable. + +The implementation of the new ingestion pipeline finished two weeks behind the original estimate, reflecting the late discovery of schema drift in the partner feed. The team resolved the drift by introducing a validation layer, ensuring alignment across teams before any record reaches the warehouse. Throughput now sits at roughly nine thousand events per minute, exceeding the target we set in March. + +Adoption of the shared design system continued across all workstreams and teams, creating a sense of momentum that was missing last quarter. Four product surfaces migrated to the new components, allowing designers to retire eleven legacy patterns. The migration of the billing screens remains in progress, pending the completion of an accessibility review. + +On the reliability side, the optimization of our alerting rules reduced paging volume by about a third. Engineers consolidated forty-two overlapping alerts into nine, giving on-call staff a clearer signal during incidents. Mean time to acknowledge fell from eleven minutes to four, matching the level the platform group had proposed. + +Customer support handled a spike in tickets after the pricing change, resolving most of them within the first business day. The knowledge base articles were rewritten in plainer language, and the deflection rate climbed from thirty to forty-one percent. A small group of enterprise accounts asked for a dedicated onboarding call, and account managers have scheduled those for the second week of the month. + +Hiring closed on three of the five open roles, leaving two senior positions unfilled going into the next cycle. The weight of the decision to pause backfills fell mostly on the data platform group. We expect the remaining offers to close by mid-month, assuming the compensation adjustments are approved. + +Coordination across all workstreams and teams improved once the weekly sync moved to a written format. Fewer meetings meant more focused work, and the written record made the escalation of blockers faster to trace. Product managers reported that the transformation of the roadmap into quarterly themes made prioritization discussions shorter. + +Security completed the remediation of the findings from the spring audit, closing every high-severity item ahead of schedule. Two medium items remain open, awaiting a library upgrade that the vendor has scheduled for next month. The compliance team confirmed that the evidence collection process now runs automatically each week. + +Documentation for the public API moved to the new site, giving external developers a searchable reference for the first time. Traffic to the reference pages doubled in the first month, and the volume of questions in the developer forum dropped noticeably. The technical writers also produced a migration guide for teams still on the deprecated endpoints. + +Looking ahead, the integration of the analytics events into the new pipeline is the main dependency for the reporting launch. The team plans to finish the mapping by the end of the month, leaving three weeks for validation with finance. Budget remains within the approved envelope, and no additional headcount is requested at this time. + +Finance reviewed the quarterly forecast with each product area and found no material variance against the plan. The revised allocation model landed in the reporting tool last week, and cost centre owners can now see their own numbers without filing a ticket. Two departments asked for a shorter reporting cycle, and that request is under review. + +In summary, delivery across all workstreams and teams stayed on plan despite the schema issue, and the quarter closes with fewer open risks than it opened with. Please raise any concerns before Friday so they can be folded into the planning session. diff --git a/tests/fixtures/expected_tells.md b/tests/fixtures/expected_tells.md index ccf3f95..6ff0dc4 100644 --- a/tests/fixtures/expected_tells.md +++ b/tests/fixtures/expected_tells.md @@ -49,3 +49,31 @@ Manual checklist for anyone editing `SKILL.md` or the references. Run - Lexical register and consistency (mixed/code-switching) — Mrs. Bennet's breathless exclamatory chatter ("Oh, single, my dear, to be sure!") against Mr. Bennet's terse dry retorts ("Mr. Bennet made no answer.") in the same scene (StoryScope STY_ALL_015) - Dialogue-heavy structure (82% of paragraphs are dialogue per `surface_scan --text`) — expected for this excerpt, not itself a tell - No thematic commentary by the narrator, and several named characters appear without being central to the plot yet (Sir William and Lady Lucas, Mrs. Long) — both human-leaning per `narrative-tells.md` + +## ai_report.txt — should fire +- Trailing participial clause — "The implementation of the new ingestion pipeline finished two weeks behind the original estimate, reflecting the late discovery of schema drift in the partner feed." (14 hits total; `grammar.participial_tail.count`) +- Verbatim phrase repetition — "across all workstreams and teams" repeated 3× (design-system paragraph, coordination paragraph, closing summary) +- Container-noun phrase — "creating a sense of momentum that was missing last quarter"; "The weight of the decision to pause backfills fell mostly on the data platform group." +- Safety disclaimer opener — "I'm not able to share vendor pricing here, but the overall picture is encouraging and the risks are manageable." +- Nominalization (prose note, not a row) — a cluster of "of"-frame nominalizations runs through the report: "the implementation of," "the migration of," "the optimization of," "the escalation of," "the transformation of," "the remediation of," "the integration of." `nominalization.of_frames` is non-empty; the gate checks it fires, not a specific count. + +## ai_report.txt — should NOT fire +- Anything from narrative-tells.md (not fiction) + +## human_formal.txt — should NOT fire +- Trailing participial clause and container-noun phrase (`grammar.participial_tail.count == 0`, `grammar.container_of.count == 0`) +- Safety disclaimer opener +- Verbatim phrase repetition above the pinned band (max repeated-phrase count stays at 2, e.g. "liberty which is essential to") + +## human_formal.txt — may legitimately show +- Nominalization — Federalist No. 10 nominalizes heavily as formal 18th-century argumentative prose ("The instability, injustice, and confusion introduced into the public councils…", "the diversity in the faculties of men…", "the protection of these faculties is the first object of government"); `nominalization.count` sits at 11, inside the pinned band (10, 12). This is a register effect of formal essay-writing, not an AI tell, and the gate records it without judging it (principle 8). +- Long sentence tail — `sentence_len.pct_over_30` ≈ 42.9%, deliberately higher than ai_report.txt's 0.0%, since 18th-century periodic sentences run long; this is exactly the direction `test_gate_direction_long_sentence_tail` expects. + +## human_plain.txt — should NOT fire +- Container-noun phrase (`grammar.container_of.count == 0`) +- Safety disclaimer opener +- Verbatim phrase repetition (`repetition.phrases == []` — no repeated phrase in this excerpt at all) + +## human_plain.txt — may legitimately show +- One trailing participial clause — "Pronouns help the audience picture themselves in the text and relate to what you're saying. More than any other single technique, using 'you' pulls users into the information and makes it relevant to them." `grammar.participial_tail.count == 1`, sitting exactly at the specificity cap (`<= 1`) with no fixture curation applied. This is a known metric false positive — a gerund subject after a fronted adverbial that rule (b) does not yet cover — kept because fixture content never depends on the metric under test; the sentence itself is genuine, unedited government prose, kept verbatim per the mechanical extraction rule in `PROVENANCE.md`. +- Flat sentence-length profile — `sentence_len.cv` ≈ 0.349, `longest_flat_run` 9, `pct_over_30` 0.0%. Government plain-language guidance is deliberately short and uniform, so these measures sit in "AI territory" on purpose. `test_gate_fairness_bands_are_recorded_not_judged` asserts the plugin records these values without reading the flatness as authorship evidence (principle 8). diff --git a/tests/fixtures/human_formal.txt b/tests/fixtures/human_formal.txt new file mode 100644 index 0000000..a419a80 --- /dev/null +++ b/tests/fixtures/human_formal.txt @@ -0,0 +1,11 @@ +AMONG the numerous advantages promised by a well constructed Union, none deserves to be more accurately developed than its tendency to break and control the violence of faction. The friend of popular governments never finds himself so much alarmed for their character and fate, as when he contemplates their propensity to this dangerous vice. He will not fail, therefore, to set a due value on any plan which, without violating the principles to which he is attached, provides a proper cure for it. The instability, injustice, and confusion introduced into the public councils, have, in truth, been the mortal diseases under which popular governments have everywhere perished; as they continue to be the favorite and fruitful topics from which the adversaries to liberty derive their most specious declamations. The valuable improvements made by the American constitutions on the popular models, both ancient and modern, cannot certainly be too much admired; but it would be an unwarrantable partiality, to contend that they have as effectually obviated the danger on this side, as was wished and expected. Complaints are everywhere heard from our most considerate and virtuous citizens, equally the friends of public and private faith, and of public and personal liberty, that our governments are too unstable, that the public good is disregarded in the conflicts of rival parties, and that measures are too often decided, not according to the rules of justice and the rights of the minor party, but by the superior force of an interested and overbearing majority. However anxiously we may wish that these complaints had no foundation, the evidence, of known facts will not permit us to deny that they are in some degree true. It will be found, indeed, on a candid review of our situation, that some of the distresses under which we labor have been erroneously charged on the operation of our governments; but it will be found, at the same time, that other causes will not alone account for many of our heaviest misfortunes; and, particularly, for that prevailing and increasing distrust of public engagements, and alarm for private rights, which are echoed from one end of the continent to the other. These must be chiefly, if not wholly, effects of the unsteadiness and injustice with which a factious spirit has tainted our public administrations. + +By a faction, I understand a number of citizens, whether amounting to a majority or a minority of the whole, who are united and actuated by some common impulse of passion, or of interest, adversed to the rights of other citizens, or to the permanent and aggregate interests of the community. + +There are two methods of curing the mischiefs of faction: the one, by removing its causes; the other, by controlling its effects. + +There are again two methods of removing the causes of faction: the one, by destroying the liberty which is essential to its existence; the other, by giving to every citizen the same opinions, the same passions, and the same interests. + +It could never be more truly said than of the first remedy, that it was worse than the disease. Liberty is to faction what air is to fire, an aliment without which it instantly expires. But it could not be less folly to abolish liberty, which is essential to political life, because it nourishes faction, than it would be to wish the annihilation of air, which is essential to animal life, because it imparts to fire its destructive agency. + +The second expedient is as impracticable as the first would be unwise. As long as the reason of man continues fallible, and he is at liberty to exercise it, different opinions will be formed. As long as the connection subsists between his reason and his self-love, his opinions and his passions will have a reciprocal influence on each other; and the former will be objects to which the latter will attach themselves. The diversity in the faculties of men, from which the rights of property originate, is not less an insuperable obstacle to a uniformity of interests. The protection of these faculties is the first object of government. From the protection of different and unequal faculties of acquiring property, the possession of different degrees and kinds of property immediately results; and from the influence of these on the sentiments and views of the respective proprietors, ensues a division of the society into different interests and parties. diff --git a/tests/fixtures/human_plain.txt b/tests/fixtures/human_plain.txt new file mode 100644 index 0000000..e68a8fe --- /dev/null +++ b/tests/fixtures/human_plain.txt @@ -0,0 +1,37 @@ +One of the most popular plain language myths is that you have to “dumb down” your content so that everyone can read it. That’s not true. + +The first rule of plain language is: write for your audience. Use language your audience understands and feels comfortable with. Take your audience’s current level of knowledge into account. Don’t write for an 8th-grade class if your audience is composed of PhD candidates, small business owners, working parents, or immigrants. Only write for 8th graders if your audience is, in fact, an 8th-grade class. + +Know the expertise and interest of your average reader, and write to that person. Don’t write to the experts, the lawyers, or your management, unless they are your intended audience. + +Make sure you do your research to understand who your audience is and test your assumptions. + +Let’s face it, people only want to know what applies to them. The best way to grab and hold someone’s attention is to figure out who they are and what they want to know. Put yourself in their shoes; it will give you a new perspective. + +Tell your audience why the material is important to them. Say, “If you want a research grant, here’s what you have to do.” Or, “If you want to mine federal coal, here’s what you should know.” Or, “If you’re planning a trip to Rwanda, read this first.” + +In order to write for your users, you need to know who they are! Here are some general tips to help you identify your users. + +There are many techniques to help you learn about your users. For more details and best practices, visit usability.gov. + +Understanding what users are trying to do when visiting your website or reading your communications will help you write clearly and focus on their needs. + +Think about how well your communications support people in getting things done. People come to government websites and services with a specific task in mind. If your content doesn’t help them complete that task, they’ll get frustrated and potentially leave. + +You need to identify the purpose of your website to help clarify the top tasks it should help people accomplish. + +Even though your document may affect a thousand or a million people, you are speaking to the one person who is reading it. When your writing reflects this, it’s more economical and has a greater impact. + +Pronouns help the audience picture themselves in the text and relate to what you’re saying. More than any other single technique, using “you” pulls users into the information and makes it relevant to them. + +When you use “you” to address users, they are more likely to understand what their responsibility is. + +It’s especially important to define “you” when writing to multiple audiences. + +If you use a question-and-answer format, you should assume that the user is the one asking the questions. Use “I” in the questions to refer to the user. Use “we” in the responses to represent your agency. + +By using “we” to respond to questions, you state clearly what your agency requires and what your agency’s responsibilities are. Using “we” makes your agency more approachable and also helps you use fewer words. You can define “we” in the definitions sections of your document if that will help the user. + +You can avoid awkwardness by using “you” to address the reader directly, rather than using “he or she” or “his or her.” + +Make sure you use pronouns that clearly refer to a specific noun. If a pronoun could refer to more than one person or object in a sentence, repeat the name of the person or object or rewrite the sentence. diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index 8afd080..f73f629 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -41,5 +41,71 @@ def test_human_email_is_quiet_on_the_wordlist(): def test_fixtures_are_nontrivial_length(): - for name in ("ai_fiction_excerpt.txt", "human_fiction_excerpt.txt"): + names = ( + "ai_fiction_excerpt.txt", + "human_fiction_excerpt.txt", + "ai_report.txt", + "human_formal.txt", + "human_plain.txt", + ) + for name in names: assert _scan(name)["words"] >= 600, name + + +AI_REPORT = "ai_report.txt" +HUMAN_SPECIFICITY = ("human_fiction_excerpt.txt", "human_formal.txt", "human_plain.txt") +# Pinned at fixture creation 2026-09-14; see fixtures/PROVENANCE.md. Values on +# human_plain.txt sit in "AI territory" and are asserted so a flat profile is +# never read as authorship evidence (principle 8). +HUMAN_PLAIN_BANDS = {"pct_over_30": (0.0, 2.0), "cv": (0.28, 0.42), "longest_flat_run": (8, 10)} +# formal human prose nominalizes heavily; the gate states that, not the +# stoplist's current output. +HUMAN_FORMAL_NOMINALIZATION_MIN = 5 +HUMAN_MAX_REPEAT_COUNT = { + "human_fiction_excerpt.txt": 3, + "human_formal.txt": 3, + "human_plain.txt": 1, +} + + +def test_gate_sensitivity_on_ai_report(): + r = _scan(AI_REPORT) + assert r["grammar"]["participial_tail"]["count"] >= 5 + assert r["grammar"]["container_of"]["count"] >= 2 + top = r["repetition"]["phrases"][0] + assert top["text"] == "across all workstreams and teams" and top["count"] == 3 + assert r["discourse"]["disclaimer_opener"]["fired"] is True + assert r["nominalization"]["of_frames"] + + +@pytest.mark.parametrize("name", HUMAN_SPECIFICITY) +def test_gate_specificity_on_human_fixtures(name): + r = _scan(name) + assert r["grammar"]["participial_tail"]["count"] <= 1, r["grammar"]["participial_tail"]["hits"] + assert r["grammar"]["container_of"]["count"] <= 1, r["grammar"]["container_of"]["hits"] + assert r["discourse"]["disclaimer_opener"]["fired"] is False + counts = [p["count"] for p in r["repetition"]["phrases"]] or [0] + assert max(counts) <= HUMAN_MAX_REPEAT_COUNT[name] + + +def test_gate_direction_long_sentence_tail(): + human = _scan("human_formal.txt")["sentence_len"]["pct_over_30"] + assert human > _scan(AI_REPORT)["sentence_len"]["pct_over_30"] + + +def test_gate_fairness_bands_are_recorded_not_judged(): + sl = _scan("human_plain.txt")["sentence_len"] + for key, (lo, hi) in HUMAN_PLAIN_BANDS.items(): + assert lo <= sl[key] <= hi, (key, sl[key]) + n = _scan("human_formal.txt")["nominalization"] + assert n["count"] >= HUMAN_FORMAL_NOMINALIZATION_MIN + + +def test_gate_shapes(): + r = _scan(AI_REPORT) + assert set(r["nominalization"]) == {"count", "hits", "of_frames"} + assert set(r["nominalization"]["hits"][0]) == {"text", "count"} + assert set(r["nominalization"]["of_frames"][0]) == {"text", "sentence"} + for block in ("participial_tail", "container_of"): + assert set(r["grammar"][block]["hits"][0]) == {"text", "sentence"} + assert set(r["repetition"]["phrases"][0]) == {"text", "count", "sentences"} diff --git a/tests/test_manifests.py b/tests/test_manifests.py index fe54f81..590c4c3 100644 --- a/tests/test_manifests.py +++ b/tests/test_manifests.py @@ -1,4 +1,5 @@ import json +import re from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -27,6 +28,7 @@ def test_marketplace_points_at_existing_plugin_components(): "style-tells", "narrative-tells", "model-fingerprints", + "SOURCES", ): assert (src / f"skills/humanize/references/{doc}.md").is_file(), doc @@ -36,3 +38,61 @@ def test_plugin_manifest_matches_marketplace_entry(): p = json.loads((ROOT / "plugins/humanize/.claude-plugin/plugin.json").read_text()) assert p["name"] == m["name"] == "humanize" assert p["version"] == m["version"] + + +SKILL_MD = ROOT / "plugins/humanize/skills/humanize/SKILL.md" + + +def test_skill_line_budget(): + # The budget is an invariant (CLAUDE.md, .cursor/BUGBOT.md) with no automated + # guard, and the file sits exactly at it (review issue 10 / recommendation 2). + lines = SKILL_MD.read_text().splitlines() + assert len(lines) <= 150, len(lines) + + +REFS = ROOT / "plugins/humanize/skills/humanize/references" +KEY_RE = re.compile(r"\[([a-z-]+-\d{4})\]") +EXPECTED_KEYS = { + "storyscope-2026", + "reinhart-2025", + "herbold-2023", + "jakesch-2023", + "munoz-ortiz-2024", + "rudnicka-2026", + "padmakumar-2024", + "chakrabarty-2025", + "sun-2025", + "milicka-2025", + "kobak-2025", + "liang-2024", + "survey-2025", +} + + +def _source_keys() -> set: + text = (REFS / "SOURCES.md").read_text() + return set(re.findall(r"^## `([a-z-]+-\d{4})`$", text, re.M)) + + +def test_sources_registry_exists_with_expected_keys(): + text = (REFS / "SOURCES.md").read_text() + keys = _source_keys() + assert EXPECTED_KEYS <= keys + # Split only on the backticked key-heading form the registry uses, not any + # line starting with "## " — a body line shaped like an H2, or an unrelated + # H2 heading, could otherwise misalign the blocks or make `by_key` raise. + blocks = re.split(r"(?m)^## `", text)[1:] + by_key = {block.split("`", 1)[0]: block for block in blocks} + assert keys <= set(by_key), sorted(keys - set(by_key)) + for key in keys: + block = by_key[key] + assert "May support:" in block and "Verified:" in block, key + + +def test_reference_citation_keys_resolve(): + keys = _source_keys() + for path in REFS.glob("*.md"): + if path.name == "SOURCES.md": + continue + for key in KEY_RE.findall(path.read_text()): + assert key in keys, (path.name, key) diff --git a/tests/test_surface_scan.py b/tests/test_surface_scan.py index 725dfbc..030897d 100644 --- a/tests/test_surface_scan.py +++ b/tests/test_surface_scan.py @@ -449,3 +449,464 @@ def test_double_dash_em_dash_excludes_cli_flags(): def test_double_dash_em_dash_counts_word_and_spaced_forms(): r = ss.punctuation("A -- b and c--d.", 100) assert r["counts"]["em_dash"] == 2 + + +def test_normalize_apostrophes_only_between_word_characters(): + src = "don´t Itʼs we’re O′Brien" + assert ss.normalize_apostrophes(src) == "don't It's we're O'Brien" + unchanged = "‘quoted’ rock ’n’ roll 'go now'" + assert ss.normalize_apostrophes(unchanged) == unchanged + + +def test_analyze_treats_acute_accent_and_modifier_apostrophes_as_apostrophes(): + r = ss.analyze("We don´t know. Itʼs worth noting the plan.") + assert r["words"] == 8 + assert "it's worth noting" in {h["term"] for h in r["wordlist"]["hits"]} + + +def test_normalize_runs_after_markdown_strip_so_backticks_are_untouched(): + text = "Use the `dict`s API. Everything between here must survive. Now `list` ends." + assert ss.analyze(text)["words"] == 11 + + +def _unique_filler(n_sentences: int) -> str: + return " ".join( + f"Alpha{i} beta{i} gamma{i} delta{i} epsilon{i} zeta{i} eta{i} theta{i}." + for i in range(n_sentences) + ) + + +PLANTED = ( + "Monday we aligned across all workstreams and teams early. " + "Later coordination across all workstreams and teams improved. " + "By Friday delivery across all workstreams and teams stayed steady." +) + + +def test_repeated_phrases_collapse_to_one_maximal_phrase(): + phrases = ss.repeated_phrases(ss.split_sentences(PLANTED)) + assert phrases == [ + {"text": "across all workstreams and teams", "count": 3, "sentences": [0, 1, 2]} + ] + + +def test_repeated_phrases_whole_repeated_sentence_counts_once(): + s = ( + "The project remains on track and the team continues to deliver " + "against the agreed plan for the quarter." + ) + phrases = ss.repeated_phrases(ss.split_sentences(s + " " + s)) + assert len(phrases) == 1 and phrases[0]["count"] == 2 + assert len(phrases[0]["text"].split()) == 18 + + +def test_repeated_phrases_need_two_content_words(): + text = ( + "We met at the end of March. They spoke at the end of April. Costs fell at the end of May." + ) + assert ss.repeated_phrases(ss.split_sentences(text)) == [] + + +def test_repeated_phrases_keep_a_more_frequent_short_phrase_inside_a_rarer_long_one(): + text = ( + "We ship the release notes weekly here. They ship the release notes weekly there. " + "Others ship the release notes on Fridays." + ) + phrases = ss.repeated_phrases(ss.split_sentences(text)) + assert {p["text"]: p["count"] for p in phrases} == { + "ship the release notes weekly": 2, + "ship the release notes": 3, + } + + +def test_repetition_block_rate_and_too_short(): + r = ss.analyze(_unique_filler(20) + " " + PLANTED) + rep = r["repetition"] + assert rep["too_short"] is False + assert rep["repeated_phrase_rate"] == ss.per_1k(2, r["words"]) + assert rep["longest_repeat"] == 5 + assert rep["phrases"][0]["text"] == "across all workstreams and teams" + short = ss.analyze("Short text. " * 10)["repetition"] + assert short == { + "too_short": True, + "repeated_phrase_rate": 0.0, + "longest_repeat": 0, + "phrases": [], + } + + +def test_repeated_phrases_ngram_cap_bounds_a_boundary_less_run_on_sentence(): + # A 60-item bullet list with no terminal punctuation scans as one 480-word + # "sentence"; without a cap this is O(L^3) and multi-second (review issue 2). + import time + + phrase = "workstream update for the team status report today" + run_on_sentence = ", ".join([phrase] * 50) + "." # one sentence, 400 words + start = time.perf_counter() + r = ss.analyze(run_on_sentence) + elapsed = time.perf_counter() - start + assert elapsed < 1.0 + assert r["repetition"]["longest_repeat"] <= 60 + # A10: the returned (sliced) phrase list stays small regardless of how many + # times the phrase repeats. (`repeated_phrase_rate` itself is not asserted + # here — see the final fix report for why this specific probe's rate is not + # under the brief's stated 50/1k target even after the A10 merge.) + assert len(r["repetition"]["phrases"]) <= 5 + + +def test_repeated_phrases_merges_overlapping_cap_length_windows_of_one_run(): + # Review issue A10: the 60-token cap fragments a verbatim repeat longer than + # 60 tokens into many overlapping, same-count 60-grams that the existing + # maximality collapse never merges (it only drops shorter substrings), + # inflating repeated_phrase_rate. Two identical 100-word sentences should + # collapse to exactly one phrase, not the 41 overlapping 60-token windows a + # cap with no merge step would keep. + sentence = " ".join(f"alpha{i}" for i in range(100)) + "." + text = sentence + " " + sentence + phrases = ss.repeated_phrases(ss.split_sentences(text)) + assert len(phrases) == 1 + assert phrases[0]["count"] == 2 + assert len(phrases[0]["text"].split()) == 60 + r = ss.analyze(text) + assert r["repetition"]["longest_repeat"] == 60 + + +def test_participial_tail_hits_canonical_forms_and_extracts_clause(): + s = ["We shipped the release, ensuring alignment across teams before the freeze."] + hits = ss.participial_tails(s) + assert hits == [{"text": ", ensuring alignment across teams before the freeze", "sentence": 0}] + assert ss.participial_tails(['"I know," she said, smiling.']) == [ + {"text": ", smiling", "sentence": 0} + ] + assert ss.participial_tails(["Costs rose, driving the decision."]) == [ + {"text": ", driving the decision", "sentence": 0} + ] + assert ss.participial_tails(["Revenue grew, quickly outpacing the plan."])[0]["text"] == ( + ", quickly outpacing the plan" + ) + + +def test_participial_tail_exclusions(): + assert ss.participial_tails(["In 2024, rising costs shaped the plan."]) == [] + assert ss.participial_tails(["On Monday, marketing shipped the page."]) == [] + assert ss.participial_tails(["The team focused on planning, testing, and shipping."]) == [] + assert ss.participial_tails(["We paused, pending the audit."]) == [] + assert ( + ss.participial_tails(["Readers include PhD candidates, working parents, or immigrants."]) + == [] + ) + # "after" is a subordinator: the comma closes its clause, so "ensuring" heads + # the main clause's gerund subject, not a participial tail (review issue 3). + assert ( + ss.participial_tails(["After the release shipped, ensuring alignment took a week."]) == [] + ) + + +def test_participial_tail_fronted_adverbial_openers_do_not_fire(): + # Review issue 3 / T3a / T3c: subordinator-led prefixes skip unconditionally, + # conjunctive-adverb openers skip, and PREP_SUB gains the missing prepositions. + for s in ( + "However, shipping continued.", + "Yesterday, shipping continued.", + "Instead, running the numbers again helped.", + "First, gathering the data matters.", + "Despite the delay, shipping continued.", + "According to the report, spending fell.", + "Because the vendor slipped, shipping the release took longer.", + "Once the audit closed, filing became routine.", + ): + assert ss.participial_tails([s]) == [], s + + +def test_participial_tail_still_fires_past_a_fronted_opener(): + assert ss.participial_tails(["Costs rose, driving the decision."]) != [] + assert ss.participial_tails(["The team shipped on Friday, closing the quarter strong."]) != [] + # The subordinator only exempts the first comma; the second comma's tail still fires. + assert ( + ss.participial_tails(["Although costs rose, the team shipped, closing the quarter."]) != [] + ) + + +def test_participial_tail_ignores_hyphenated_ing_compounds(): + # Bugbot PR #6 comment 4002402541: \b fires at the hyphen, so "cutting-edge" + # was misread as a clause head with only "-edge" left over for exclusion checks. + assert ( + ss.participial_tails( + ["We shipped fast tools, cutting-edge dashboards, and long-standing fixes."] + ) + == [] + ) + assert ss.participial_tails(["The team shipped, cutting the backlog in half."]) != [] + + +def test_participial_tail_guard_evaluates_whole_prefix_not_just_first_raw_comma(): + # Bugbot PR #6 comment 4002402546, REVISED after the re-review: gating on + # `m.start() == first_comma` meant any earlier comma inside the opener + # (city-state, dates, thousands separators) disabled the adverbial check + # entirely. The whole-prefix guard is segment-based: the first comma segment + # must be opener-led; every later segment must be opener-internal (one word, + # or itself preposition-led) or the guard lifts. Must NOT fire — every + # non-first segment is opener-internal (a single word or a prepositional + # phrase), so it's all one verbless opener. + for s in ( + "In Austin, Texas, shipping continued.", + "In 2024, with 1,200 users, onboarding stalled.", + "On July 4, 2024, spending spiked.", + ): + assert ss.participial_tails([s]) == [], s + # Must still fire — a later segment is a multi-word, non-prepositional + # clause of its own (even with only a present-tense verb the finite-verb + # veto alone can't see), so the -ing word is a genuine trailing participial. + for s in ( + "In most quarters, revenue rises, lifting margins.", + "In practice, this approach reduces friction, enabling teams to move faster.", + "In March, the team grew, closing the gap.", + "After the launch, the board met, approving the plan.", + ): + assert ss.participial_tails([s]) != [], s + # All A3 cases still hold under the whole-prefix guard. + assert ss.participial_tails(["However, shipping continued."]) == [] + assert ( + ss.participial_tails(["After the release shipped, ensuring alignment took a week."]) == [] + ) + assert ss.participial_tails(["Costs rose, driving the decision."]) != [] + + +def test_irregular_past_homographs_pruned_from_the_finite_verb_veto(): + # Bugbot PR #6 comment 4002562248: the original IRREGULAR_PAST list included + # present/past homographs and common nouns/adjectives (cost, left, set, rose, + # ...), so a preposition-led opener containing one failed the verbless test + # and a gerund subject was wrongly reported as a trailing participial. + for s in ( + "At low cost, shipping continued.", + "On the left, hiring slowed.", + "In the rose garden, planting began.", + "In the first set, serving improved.", + ): + assert ss.participial_tails([s]) == [], s + # Unambiguous past forms still veto the opener correctly. ("fell" itself + # was pruned in A12 as the "one fell swoop" homograph; "grew" stands in.) + assert ss.participial_tails(["Under the plan costs grew, driving the decision."]) != [] + assert ss.participial_tails(["In March, the team grew, closing the gap."]) != [] + # Bugbot's companion finding (present-tense remainder after an opener) is + # already handled by the revised A7 segment rule; no new code needed here. + assert ( + ss.participial_tails( + ["In practice, this approach reduces friction, enabling teams to move faster."] + ) + != [] + ) + + +def test_irregular_past_homographs_pruned_further_round_two_and_three(): + # Re-review rounds 2-3 found four more homographs on the A11 keep-list that + # are ordinary nouns/adjectives in openers -- felt (wool felt), thought (on + # second thought), stole (fur stole), fell (one fell swoop) -- plus + # borderline spent (spent grain/fuel) and led (the lowercased "LED" + # acronym, which also needed the generic -ed-suffix heuristic narrowed to + # length > 3, since "led" alone still matched it after removal from + # IRREGULAR_PAST). Final set: 61 words. + for s in ( + "In wool felt, weaving continued.", + "On second thought, hiring slowed.", + "In one fell swoop, hiring stopped.", + "In the LED aisle, shopping continued.", + ): + assert ss.participial_tails([s]) == [], s + assert ss.participial_tails(["In March, the team grew, closing the gap."]) != [] + + +def test_prep_led_intermediate_segment_with_a_finite_verb_is_a_clause(): + # Bugbot PR #6 comment 4002717678: an intermediate segment starting with a + # PREP_SUB word was treated as opener-internal unconditionally, so a real + # clause hiding behind a preposition ("with the new vendor the team shipped + # faster") was swallowed and a genuine trailing tail was dropped. + for s in ( + "However, with the new vendor the team shipped faster, cutting the backlog.", + "Although costs rose, with the new vendor the team hired fast, doubling headcount.", + ): + assert ss.participial_tails([s]) != [], s + # A verbless preposition-led intermediate segment is still opener-internal. + for s in ( + "In 2024, with 1,200 users, onboarding stalled.", + "In Austin, Texas, in 2024, hiring slowed.", + ): + assert ss.participial_tails([s]) == [], s + + +def test_subordinator_led_intermediate_segment_stays_opener_internal(): + # Bugbot PR #6 comment 4002782444: A13's finite-verb check ran on every + # PREP_SUB-led intermediate segment, including subordinator-led ones (after, + # when, ...). A subordinate clause always carries a verb and its own comma + # closes it, so the following -ing word is still a gerund subject, not a + # tail -- the guard must stay regardless of that verb. + for s in ( + "However, after the audit closed, filing became routine.", + "Although costs rose, when the audit closed, filing became routine.", + ): + assert ss.participial_tails([s]) == [], s + # A13's genuine-clause detection (finite verb in a non-subordinator + # preposition-led or plain multi-word segment) still fires correctly. + for s in ( + "However, with the new vendor the team shipped faster, cutting the backlog.", + "Although costs rose, with the new vendor the team hired fast, doubling headcount.", + "After the launch, the board met, approving the plan.", + ): + assert ss.participial_tails([s]) != [], s + + +def test_participial_tail_clause_text_capped_at_60_chars_on_a_word_boundary(): + s = ["We shipped, ensuring " + " ".join(["alignment"] * 12) + " more."] + text = ss.participial_tails(s)[0]["text"] + assert len(text) <= 60 and not text.endswith("alignmen") + + +def test_participial_tail_clause_text_stops_at_en_dash_like_em_dash(): + # Review issue 13: en dash added to _CLAUSE_END_RE alongside em dash. + em = ss.participial_tails(["We shipped the release, ensuring alignment — then rested."]) + en = ss.participial_tails(["We shipped the release, ensuring alignment – then rested."]) + assert em[0]["text"] == en[0]["text"] == ", ensuring alignment" + + +def test_clause_text_en_dash_terminates_only_when_whitespace_follows(): + # Review issue A8: A5 made en dash a clause terminator outright, so a + # numeric range like "2023-2024" (en dash) was truncated mid-range. + keeps_range = ss.participial_tails( + ["We shipped the release, covering 2023–2024 spending in full."] + ) + assert keeps_range[0]["text"] == ", covering 2023–2024 spending in full" + still_terminates = ss.participial_tails( + ["We shipped the release, ensuring alignment – then rested."] + ) + assert still_terminates[0]["text"] == ", ensuring alignment" + + +def test_container_phrases(): + s = [ + "She felt a sense of unease and the quiet weight of the decision.", + "The foundation of the house held.", + ] + hits = ss.container_phrases(s) + assert [h["text"] for h in hits] == ["a sense of", "the quiet weight of", "The foundation of"] + assert [h["sentence"] for h in hits] == [0, 0, 1] + assert ss.container_phrases(["A sea change is coming."]) == [] + + +def test_analyze_grammar_block_shape(): + r = ss.analyze( + "We shipped the release, ensuring alignment across teams. She felt a sense of dread." + ) + g = r["grammar"] + assert g["participial_tail"]["count"] == 1 + assert g["participial_tail"]["rate"] == ss.per_1k(1, r["words"]) + assert set(g["participial_tail"]["hits"][0]) == {"text", "sentence"} + assert g["container_of"] == {"count": 1, "hits": [{"text": "a sense of", "sentence": 1}]} + + +def test_nominal_stoplist_is_large_and_every_entry_is_reachable(): + assert len(ss.NOMINAL_STOPLIST) >= 150 + for w in ss.NOMINAL_STOPLIST: + assert len(w) >= 7 and ss._NOMINAL_SUFFIX_RE.search(w), w + + +def test_nominalization_hits_and_frames(): + s = ss.split_sentences( + "The implementation of the policy led to an improvement in retention. " + "The nation's position on the question was clear in every session. " + "Sentences, instances, and appliances are not nominalizations, but implementations are." + ) + n = ss.nominalization_block(s) + assert set(n) == {"count", "hits", "of_frames"} + texts = {h["text"] for h in n["hits"]} + assert { + "implementation", + "improvement", + "retention", + "implementations", + "nominalizations", + } <= texts + assert not ({"sentences", "instances", "appliances", "position", "question", "session"} & texts) + assert n["of_frames"] == [{"text": "the implementation of", "sentence": 0}] + assert set(n["hits"][0]) == {"text", "count"} + + +def test_of_frames_requires_literal_adjacency_in_the_raw_sentence(): + # Review issue 4: token-level adjacency crosses punctuation and fabricates a + # frame that is not literally in the text ("implementation, of course,"). + no_frame = ss.nominalization_block(ss.split_sentences("The implementation, of course, worked.")) + assert no_frame["of_frames"] == [] + has_frame = ss.nominalization_block( + ss.split_sentences("The implementation of the plan worked.") + ) + assert has_frame["of_frames"] == [{"text": "the implementation of", "sentence": 0}] + + +def test_disclaimer_opener_fires_only_from_first_paragraph(): + paras = ["It's important to approach this carefully.", "As an AI I would add a caveat."] + d = ss.disclaimer_opener(paras, ss.split_sentences("\n\n".join(paras))) + assert d["fired"] is True and [h["sentence"] for h in d["hits"]] == [0, 1] + paras2 = ["We shipped on time.", "As an AI I would add a caveat."] + d2 = ss.disclaimer_opener(paras2, ss.split_sentences("\n\n".join(paras2))) + assert d2["fired"] is False and len(d2["hits"]) == 1 + + +def test_analyze_exposes_nominalization_and_disclaimer(): + r = ss.analyze( + "It's important to approach the implementation of this with care.\n\nMore text here." + ) + assert r["discourse"]["disclaimer_opener"]["fired"] is True + assert r["discourse"]["summary_closer"] is False + assert r["nominalization"]["of_frames"][0]["text"] == "the implementation of" + + +def test_sentence_len_extras(): + assert ss.sentence_len_extras([10, 12, 9, 30, 31, 40]) == { + "pct_over_30": 33.3, + "p90": 40, + "longest_flat_run": 3, + } + assert ss.sentence_len_extras([7]) == {"pct_over_30": 0.0, "p90": 7, "longest_flat_run": 1} + assert ss.sentence_len_extras([]) == {"pct_over_30": 0.0, "p90": 0, "longest_flat_run": 0} + # a monotone ramp is measured against the run's first sentence, not its neighbour + assert ss.sentence_len_extras([10, 13, 16, 19])["longest_flat_run"] == 2 + + +def test_analyze_sentence_len_keeps_stats_and_adds_extras(): + r = ss.analyze("One two three. Four five.\n\nSix seven eight nine ten eleven.") + assert set(r["sentence_len"]) == { + "mean", + "stdev", + "cv", + "min", + "max", + "pct_over_30", + "p90", + "longest_flat_run", + } + assert set(r["paragraph_len"]) == {"mean", "stdev", "cv", "min", "max"} + assert r["sentence_len"]["p90"] == 6 + + +def test_summarize_has_twelve_lines_and_new_sections(): + r = ss.analyze("We shipped the release, ensuring alignment. " * 4 + "Short text. " * 40) + lines = ss.summarize(r).splitlines() + assert len(lines) == 12 + assert lines[8].startswith("repetition: ") and lines[9].startswith( + "grammar: participial tails " + ) + assert lines[10].startswith("sentence tail: over-30 ") + assert lines[11].startswith("nominalization hits: ") + assert "not measured (under 150 words)" in ss.summarize(ss.analyze("Short text. " * 5)) + assert " ·" not in ss.summarize(ss.analyze("Short text. " * 5)) + + +def test_summarize_preserves_curly_quotes_in_participial_tail_hits_without_u_escapes(): + # Review issue 1 (Critical): json.dumps defaults to ensure_ascii=True, which + # mangles curly quotes into \uXXXX escapes in a table SKILL.md tells the + # model to quote verbatim. (A curly apostrophe between word characters is + # normalized to ASCII by design before analysis, so it can't probe this.) + left_dq, right_dq = chr(0x201C), chr(0x201D) + text = f"We rewrote the guide, quoting {left_dq}you{right_dq} directly for clarity." + s = ss.summarize(ss.analyze(text)) + assert f"{left_dq}you{right_dq}" in s + assert "\\u201c" not in s and "\\u201d" not in s