zero-slop 2.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -0
  3. package/SKILL.md +803 -0
  4. package/data/corpus/community-register/README.md +14 -0
  5. package/data/corpus/community-register/judgment/communicative-drift.txt +1 -0
  6. package/data/corpus/community-register/judgment/reflexive-agreement.txt +1 -0
  7. package/data/corpus/community-register/judgment/rhetorical-scale-mismatch.txt +1 -0
  8. package/data/corpus/must-not-flag/README.md +49 -0
  9. package/data/corpus/must-not-flag/esl-engineer-email.txt +7 -0
  10. package/data/corpus/must-not-flag/exec-memo.txt +9 -0
  11. package/data/corpus/must-not-flag/federalist.txt +5 -0
  12. package/data/corpus/must-not-flag/gettysburg.txt +1 -0
  13. package/data/corpus/must-not-flag/grant-abstract.txt +5 -0
  14. package/data/corpus/must-not-flag/ml-methods.txt +1 -0
  15. package/data/corpus/must-not-flag/personal-essay.txt +1 -0
  16. package/data/corpus/must-not-flag/press-release.txt +7 -0
  17. package/data/corpus/must-not-flag/recipe.txt +11 -0
  18. package/data/corpus/must-not-flag/sre-runbook.txt +1 -0
  19. package/data/corpus/must-not-flag/technical-postmortem.txt +1 -0
  20. package/data/corpus/must-not-flag/terse-engineer-note.txt +1 -0
  21. package/data/corpus/must-not-flag-shape/README.md +22 -0
  22. package/data/corpus/must-not-flag-shape/aphorism-list.txt +9 -0
  23. package/data/corpus/must-not-flag-shape/changelog.txt +13 -0
  24. package/data/corpus/must-not-flag-shape/dialogue.txt +19 -0
  25. package/data/corpus/must-not-flag-shape/lyrics.txt +15 -0
  26. package/data/corpus/must-not-flag-shape/poem.txt +8 -0
  27. package/data/corpus/must-not-flag-shape/sms-transcript.txt +15 -0
  28. package/data/corpus/performed-register/README.md +38 -0
  29. package/data/corpus/performed-register/judgment/detection-fragile.txt +1 -0
  30. package/data/corpus/performed-register/judgment/draft-cheap-signal.txt +1 -0
  31. package/data/corpus/performed-register/judgment/ear-chiasmus.txt +1 -0
  32. package/data/corpus/performed-register/judgment/fight-against-tagline.txt +1 -0
  33. package/data/corpus/performed-register/judgment/fine-beat.txt +1 -0
  34. package/data/corpus/performed-register/judgment/glow-finishing.txt +1 -0
  35. package/data/corpus/performed-register/judgment/jargon-compression.txt +1 -0
  36. package/data/corpus/performed-register/judgment/not-perfect-honest.txt +1 -0
  37. package/data/corpus/performed-register/judgment/punctuation-convict.txt +1 -0
  38. package/data/corpus/performed-register/judgment/recipe-ingredients.txt +1 -0
  39. package/data/corpus/performed-register/judgment/replay-lands.txt +1 -0
  40. package/data/corpus/performed-register/judgment/rhymes-with.txt +1 -0
  41. package/data/corpus/performed-register/judgment/rhythm-prints.txt +1 -0
  42. package/data/corpus/performed-register/judgment/same-leak.txt +1 -0
  43. package/data/corpus/performed-register/judgment/tools-work.txt +1 -0
  44. package/data/corpus/performed-register/judgment/tuning-gradients.txt +1 -0
  45. package/data/corpus/performed-register/judgment/verdict-arithmetic.txt +1 -0
  46. package/data/corpus/performed-register/judgment/whole-product.txt +1 -0
  47. package/data/corpus/performed-register/judgment/words-cheap.txt +1 -0
  48. package/data/corpus/performed-register/mechanical/argue-with-artifact.txt +1 -0
  49. package/data/corpus/performed-register/mechanical/bill-lands.txt +1 -0
  50. package/data/corpus/performed-register/mechanical/billed-to-reader.txt +1 -0
  51. package/data/corpus/performed-register/mechanical/economics-brutal.txt +1 -0
  52. package/data/corpus/performed-register/mechanical/has-receipts.txt +1 -0
  53. package/data/corpus/performed-register/mechanical/hired-adversary.txt +1 -0
  54. package/data/corpus/performed-register/mechanical/hyperbole-universal.txt +1 -0
  55. package/data/corpus/performed-register/mechanical/metaphor-sender-name.txt +1 -0
  56. package/data/corpus/performed-register/mechanical/minding-own-business.txt +1 -0
  57. package/data/corpus/performed-register/mechanical/on-the-tin.txt +1 -0
  58. package/data/corpus/performed-register/mechanical/opens-the-hood.txt +1 -0
  59. package/data/corpus/performed-register/mechanical/turns-out-payoff.txt +1 -0
  60. package/data/corpus/performed-register/mechanical/vibe-check.txt +1 -0
  61. package/data/corpus/performed-register/mechanical/vibe-isnt-a-vibe.txt +1 -0
  62. package/data/corpus/performed-register/mechanical/vibe-number-attached.txt +1 -0
  63. package/data/corpus/performed-register/mechanical/where-slop-lives.txt +1 -0
  64. package/data/learned-log.md +339 -0
  65. package/data/learned.json +128 -0
  66. package/data/patterns.json +2298 -0
  67. package/package.json +54 -0
  68. package/references/copy-desk.md +87 -0
  69. package/references/evidence.md +382 -0
  70. package/references/overcorrection.md +77 -0
  71. package/references/platforms.md +102 -0
  72. package/references/readalong.md +118 -0
  73. package/references/rewrite-moves.md +177 -0
  74. package/references/tells.md +190 -0
  75. package/scripts/calibrate.py +274 -0
  76. package/scripts/learn.py +1588 -0
  77. package/scripts/predictability.py +227 -0
  78. package/scripts/rerank.py +150 -0
  79. package/scripts/safeio.py +146 -0
  80. package/scripts/slopscore.py +1352 -0
  81. package/scripts/version_check.py +104 -0
@@ -0,0 +1,1352 @@
1
+ #!/usr/bin/env python3
2
+ """slopscore — check writing for common AI-style patterns.
3
+
4
+ The 0-to-100 writing score covers familiar phrases, sentence variety,
5
+ readability, formatting, and tone. Lower is better. The score describes the
6
+ writing; it does not identify who wrote it, decide whether the ideas are useful,
7
+ or check whether every claim is true. Zero Slop handles those questions in its
8
+ editorial review.
9
+
10
+ Usage (runnable from any cwd; data resolves relative to this script):
11
+ python3 slopscore.py <file> # pretty report
12
+ python3 slopscore.py --json <file> # machine-readable
13
+ python3 slopscore.py --dna a.md b.md # show what changed
14
+ python3 slopscore.py --fidelity a.md b.md # facts kept? anything added?
15
+ cat text | python3 slopscore.py # stdin
16
+ python3 slopscore.py --explain <file> # report + reasons + line-by-line map
17
+ python3 slopscore.py --heatmap <file> # line-by-line map only
18
+ python3 slopscore.py --portfolio <dir> # repeated wording across related drafts
19
+ python3 slopscore.py --batch <dir> --json --gate 25 # machine-readable CI gate
20
+ python3 slopscore.py --formal <file> # use the rules for professional writing
21
+
22
+ The phrase lists live beside this script in ../data/patterns.json and
23
+ ../data/learned.json. Editing those files requires no code change.
24
+ """
25
+ import bisect
26
+ import functools
27
+ import json
28
+ import math
29
+ import re
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ DATA_DIR = Path(__file__).resolve().parent.parent / "data"
34
+ SHAPE_SOLO_THRESHOLD = 0.62 # calibrated, see calibrate.py --shape
35
+
36
+
37
+ # Where personal voice profiles live — outside the repo, since they are the
38
+ # user's own writing. One file per author, git-ignored by construction.
39
+ import os
40
+ HOME = Path(os.environ.get("ZERO_SLOP_HOME", Path.home() / ".zero-slop")).expanduser()
41
+ VOICE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
42
+
43
+
44
+ def _voice_path(name):
45
+ """Resolve a profile name without letting it become a filesystem path."""
46
+ if not VOICE_NAME.fullmatch(name or "") or name in (".", ".."):
47
+ raise ValueError(
48
+ "voice name must be 1-64 letters, digits, dots, underscores, or hyphens"
49
+ )
50
+ root = (HOME / "voices").resolve()
51
+ path = (root / f"{name}.json").resolve()
52
+ try:
53
+ path.relative_to(root)
54
+ except ValueError as exc: # defense in depth if the name rule changes
55
+ raise ValueError("voice profile resolves outside the voice directory") from exc
56
+ return path
57
+
58
+
59
+ def _merge_learned(base, learned_path):
60
+ """Merge one validated layer; malformed entries never break scoring."""
61
+ if not learned_path.exists():
62
+ return
63
+ try:
64
+ learned = json.loads(learned_path.read_text())
65
+ if not isinstance(learned, dict):
66
+ raise ValueError("learned data must be an object")
67
+ raw_patterns = learned.get("patterns", [])
68
+ if not isinstance(raw_patterns, list):
69
+ raise ValueError("learned patterns must be a list")
70
+ valid_patterns = []
71
+ for q in raw_patterns:
72
+ if not isinstance(q, dict):
73
+ continue
74
+ name, rx, weight, category = (q.get("name"), q.get("rx"),
75
+ q.get("w"), q.get("cat"))
76
+ if (not isinstance(name, str) or not 1 <= len(name) <= 128
77
+ or not isinstance(category, str) or not 1 <= len(category) <= 64
78
+ or not isinstance(rx, str)
79
+ or not isinstance(weight, (int, float))
80
+ or isinstance(weight, bool)
81
+ or not math.isfinite(weight) or not 0 <= weight <= 10
82
+ or len(rx) > 2000
83
+ or re.search(r"\\[1-9]|\(\?<*[=!]|\([^()]*[+*][^()]*\)[+*]", rx)):
84
+ continue
85
+ try:
86
+ re.compile(rx)
87
+ except re.error:
88
+ continue
89
+ valid_patterns.append(q)
90
+
91
+ # Later layers win by name. This is how a private false-positive update
92
+ # can lower one shared weight without editing the installed taxonomy.
93
+ by_name = {q["name"]: q for q in base["patterns"]}
94
+ order = [q["name"] for q in base["patterns"]]
95
+ for q in valid_patterns:
96
+ if q["name"] not in by_name:
97
+ order.append(q["name"])
98
+ by_name[q["name"]] = q
99
+ base["patterns"] = [by_name[name] for name in order]
100
+ for field in ("lexicon", "riders"):
101
+ raw = learned.get(field, {})
102
+ if not isinstance(raw, dict):
103
+ continue
104
+ clean = {term: weight for term, weight in raw.items()
105
+ if isinstance(term, str) and 1 <= len(term) <= 80
106
+ and isinstance(weight, (int, float))
107
+ and not isinstance(weight, bool)
108
+ and math.isfinite(weight) and 0 <= weight <= 10}
109
+ base.setdefault(field, {}).update(clean)
110
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError, ValueError, TypeError):
111
+ return
112
+
113
+
114
+ def load_patterns(voice=None):
115
+ base = json.loads((DATA_DIR / "patterns.json").read_text())
116
+ _merge_learned(base, DATA_DIR / "learned.json") # reviewed, shared
117
+ _merge_learned(base, HOME / "learned.json") # private, live
118
+ if voice:
119
+ _apply_voice(base, voice)
120
+ return base
121
+
122
+
123
+ def _apply_voice(base, name):
124
+ """Apply one explicitly selected private scoring profile.
125
+
126
+ ``keep`` zero-weights existing lexicon and rider terms. ``mute`` lists the
127
+ labels of existing patterns, but the sample-based builder does not populate
128
+ it. This changes only the local score; it does not infer or model the
129
+ writer's full style, and an unselected profile has no effect.
130
+ """
131
+ prof_path = _voice_path(name)
132
+ if not prof_path.exists():
133
+ return
134
+ try:
135
+ prof = json.loads(prof_path.read_text())
136
+ if not isinstance(prof, dict):
137
+ return
138
+ keep_raw, mute_raw = prof.get("keep", []), prof.get("mute", [])
139
+ if not isinstance(keep_raw, list) or not isinstance(mute_raw, list):
140
+ return
141
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError, TypeError):
142
+ return
143
+ keep = {k.lower() for k in keep_raw if isinstance(k, str)}
144
+ for term in list(base.get("lexicon", {})):
145
+ if term.lower() in keep:
146
+ base["lexicon"][term] = 0
147
+ for term in list(base.get("riders", {})):
148
+ if term.lower() in keep:
149
+ base["riders"][term] = 0
150
+ for pat in base["patterns"]:
151
+ if pat["name"] in {m for m in mute_raw if isinstance(m, str)}:
152
+ pat["w"] = 0
153
+
154
+
155
+ SENT_SPLIT = re.compile(r"(?<=[.!?])[\")”’]?\s+(?=[A-Z“\"(0-9])")
156
+ WORD = re.compile(r"[A-Za-z’']+")
157
+
158
+
159
+ @functools.lru_cache(maxsize=1024)
160
+ def _pattern_regex(rx, multiline):
161
+ """Compile a weighted pattern once without changing its match semantics."""
162
+ return re.compile(rx, re.I | (re.M if multiline else 0))
163
+
164
+
165
+ @functools.lru_cache(maxsize=16)
166
+ def _term_scan_plan(entries):
167
+ """Build a bounded, reusable first-character index for term scanning.
168
+
169
+ The previous implementation ran one full document scan per term. This plan
170
+ scans word starts once, then tests only terms that can begin there. Odd
171
+ private terms that do not start with a word character keep the old path.
172
+ """
173
+ buckets = {}
174
+ fallback = []
175
+ for order, (term, weight) in enumerate(entries):
176
+ if not weight:
177
+ continue
178
+ # Python's IGNORECASE has a few non-ASCII equivalences that ``casefold``
179
+ # does not map back to one character (İ/i is the common example). Keep
180
+ # those uncommon private terms on the reference path so indexing cannot
181
+ # silently miss a match.
182
+ if (term and term[0].isascii()
183
+ and (term[0].isalnum() or term[0] == "_")):
184
+ key = term[0].casefold()
185
+ buckets.setdefault(key, []).append(
186
+ (order, term, weight, re.compile(re.escape(term) + r"\w*", re.I))
187
+ )
188
+ else:
189
+ fallback.append(
190
+ (order, term, weight,
191
+ re.compile(r"\b" + re.escape(term) + r"\w*", re.I))
192
+ )
193
+ groups = []
194
+ by_group = {}
195
+ for index, (key, rows) in enumerate(buckets.items()):
196
+ group = f"c{index}"
197
+ groups.append(f"(?P<{group}>{re.escape(key)})")
198
+ by_group[group] = rows
199
+ starter = re.compile(r"\b(?:" + "|".join(groups) + r")", re.I) if groups else None
200
+ return starter, by_group, tuple(fallback)
201
+
202
+
203
+ def _term_candidates(text, terms):
204
+ """Return the old term-match vector with one document-wide starter scan."""
205
+ entries = tuple(terms.items())
206
+ starter, by_group, fallback = _term_scan_plan(entries)
207
+ found = []
208
+ if starter is not None:
209
+ for start_match in starter.finditer(text):
210
+ start = start_match.start()
211
+ for order, term, weight, pattern in by_group[start_match.lastgroup]:
212
+ match = pattern.match(text, start)
213
+ if match is not None:
214
+ found.append((start, match.end(), order, term, weight,
215
+ match.group(0).lower()))
216
+ for order, term, weight, pattern in fallback:
217
+ for match in pattern.finditer(text):
218
+ found.append((match.start(), match.end(), order, term, weight,
219
+ match.group(0).lower()))
220
+ found.sort(key=lambda row: (row[0], -row[1], row[2]))
221
+ return [(start, end, term, weight, quote)
222
+ for start, end, _, term, weight, quote in found]
223
+
224
+
225
+ def strip_noise(text):
226
+ text = re.sub(r"```.*?```", " ", text, flags=re.S)
227
+ # Markdown table rules are layout syntax, not repeated dashes in prose.
228
+ # Leave the table's words available to the language and rhythm checks, but
229
+ # remove delimiter rows such as ``|---|---:|`` before punctuation scoring.
230
+ text = re.sub(
231
+ r"(?m)^[ \t]*\|?[ \t]*:?-{3,}:?[ \t]*"
232
+ r"(?:\|[ \t]*:?-{3,}:?[ \t]*)+\|?[ \t]*$",
233
+ " ",
234
+ text,
235
+ )
236
+ # Inline `code` spans still render as visible prose, so their words are
237
+ # scored; only the backticks go. Fenced blocks are genuinely code.
238
+ text = re.sub(r"`([^`\n]*)`", r"\1", text)
239
+ # URLs are otherwise noise, but a model-specific tracking parameter is a
240
+ # machine artifact in its own right. Preserve only the artifact token so
241
+ # ordinary URL text cannot affect prose rhythm or vocabulary.
242
+ text = re.sub(
243
+ r"https?://\S+",
244
+ lambda m: " " + " ".join(re.findall(
245
+ r"utm_source=(?:chatgpt(?:\.com)?|openai)", m.group(0), re.I
246
+ )) + " ",
247
+ text,
248
+ )
249
+ return text
250
+
251
+
252
+ def _sentence_spans(text):
253
+ """(start, end) spans of ``sentences(text)`` in ``text`` coordinates.
254
+
255
+ Newlines inside a paragraph flatten to spaces, which preserves length, so
256
+ a span's slice differs from its sentence string only by that replacement.
257
+ Rider hits are sentence-scoped but dedup against pattern hits needs
258
+ document offsets; this keeps one sentence definition for both.
259
+ """
260
+ spans = []
261
+ start = 0
262
+ breaks = [m.span() for m in re.finditer(r"\n\s*\n", text)]
263
+ for para_end, next_start in breaks + [(len(text), len(text))]:
264
+ flat = text[start:para_end].replace("\n", " ")
265
+ prev = 0
266
+ cuts = [m.span() for m in SENT_SPLIT.finditer(flat)]
267
+ for cut_start, cut_end in cuts + [(len(flat), len(flat))]:
268
+ seg = flat[prev:cut_start]
269
+ core = seg.strip()
270
+ if len(WORD.findall(core)) >= 2:
271
+ lead = len(seg) - len(seg.lstrip())
272
+ spans.append((start + prev + lead,
273
+ start + prev + lead + len(core)))
274
+ prev = cut_end
275
+ start = next_start
276
+ return spans
277
+
278
+
279
+ def sentences(text):
280
+ return [text[a:b].replace("\n", " ") for a, b in _sentence_spans(text)]
281
+
282
+
283
+ def _merge_spans(spans):
284
+ merged = []
285
+ for s, e in sorted(spans):
286
+ if merged and s <= merged[-1][1]:
287
+ if e > merged[-1][1]:
288
+ merged[-1] = (merged[-1][0], e)
289
+ else:
290
+ merged.append((s, e))
291
+ return merged
292
+
293
+
294
+ def _span_covered(span, merged):
295
+ """True if [s, e) intersects any interval in a merged, sorted list."""
296
+ s, e = span
297
+ i = bisect.bisect_left(merged, (e,))
298
+ return i > 0 and merged[i - 1][1] > s
299
+
300
+
301
+ def cv(values):
302
+ if len(values) < 2:
303
+ return 1.0
304
+ m = sum(values) / len(values)
305
+ if m == 0:
306
+ return 1.0
307
+ var = sum((v - m) ** 2 for v in values) / (len(values) - 1)
308
+ return math.sqrt(var) / m
309
+
310
+
311
+ def score_text(text, data, formal=False):
312
+ if not isinstance(text, str):
313
+ raise TypeError("text must be a string")
314
+ raw = text
315
+ text = strip_noise(text)
316
+ words = WORD.findall(text)
317
+ n_words = len(words)
318
+ word_den = max(n_words, 1)
319
+ sent_spans = _sentence_spans(text)
320
+ sents = [text[a:b].replace("\n", " ") for a, b in sent_spans]
321
+ hits = []
322
+ pattern_spans = [] # (start, end, rx) per pattern hit, for dedup below
323
+
324
+ # 1. Pattern tells (regex, weighted)
325
+ for p in data["patterns"]:
326
+ if not p.get("w"):
327
+ continue
328
+ for m in _pattern_regex(p["rx"], bool(p.get("m"))).finditer(text):
329
+ hits.append({
330
+ "cat": p["cat"], "name": p["name"], "w": p["w"],
331
+ "quote": m.group(0)[:90].strip(),
332
+ })
333
+ pattern_spans.append((m.start(), m.end(), p["rx"]))
334
+
335
+ # 2. Lexicon. Two tiers, because context decides. Always-on terms
336
+ # ("delve", "tapestry") almost never appear in honest prose. Rider terms
337
+ # ("robust", "landscape", "elevated") are ordinary technical vocabulary
338
+ # and only count when a marketing-register trigger shares their sentence —
339
+ # so "elevated write volume" in a runbook is silent while "elevate your
340
+ # brand with our seamless platform" fires. Sentence-scoped, not global.
341
+ #
342
+ # A term a pattern already charges is the same evidence counted twice —
343
+ # "is a testament to" must convict the phrase once, not the phrase plus
344
+ # the word. The pattern owns the term when its regex writes the term out
345
+ # ("testament" in puffery-testament) or matches the term's own text
346
+ # ("game.?chang" on "game-changing"); an independent tell that merely
347
+ # lands inside another tell's span — a lexicon word inside a
348
+ # rhetorical-structure match — still counts. Overlapping lexicon stems
349
+ # ("game-chang", "game-changing") collapse to one hit the same way.
350
+ claimed = _merge_spans([(s, e) for s, e, _ in pattern_spans])
351
+
352
+ def _pattern_owns(span, term, matched):
353
+ if not _span_covered(span, claimed):
354
+ return False
355
+ s, e = span
356
+ return any(ps < e and s < pe
357
+ and (term in rx.lower() or re.search(rx, matched, re.I))
358
+ for ps, pe, rx in pattern_spans)
359
+
360
+ candidates = [candidate for candidate in _term_candidates(text, data["lexicon"])
361
+ if not _pattern_owns(candidate[:2], candidate[2], candidate[4])]
362
+ last_end = 0
363
+ for s, e, term, w, quote in candidates:
364
+ if s < last_end:
365
+ continue
366
+ last_end = e
367
+ hits.append({"cat": "lexicon", "name": term, "w": w, "quote": quote})
368
+ riders, triggers = data.get("riders", {}), data.get("rider_triggers", [])
369
+ if riders:
370
+ for (a, _), sent in zip(sent_spans, sents):
371
+ sl = sent.lower()
372
+ if not any(t in sl for t in triggers):
373
+ continue
374
+ for term, w in riders.items():
375
+ if not w:
376
+ continue
377
+ for m in re.finditer(r"\b" + re.escape(term) + r"\w*", sent, re.I):
378
+ if _pattern_owns((a + m.start(), a + m.end()),
379
+ term, m.group(0)):
380
+ continue
381
+ hits.append({"cat": "rider", "name": term, "w": w,
382
+ "quote": m.group(0).lower()})
383
+
384
+ pattern_weight = sum(h["w"] for h in hits)
385
+ # Density window is floored at 60 words (a single tell in a 7-word tweet
386
+ # must not read as 100/100) and the long-text dilution is bounded by also
387
+ # tracking absolute weight: a 2000-word piece cannot hide 20 tells. The
388
+ # absolute floor scales with length past 1,000 words, because a fixed
389
+ # floor convicts on sheer accumulation — weight 42 anywhere meant a
390
+ # book-length text with one mild tell every couple thousand words scored
391
+ # the same as a tell-dense post and could never pass the gate.
392
+ tell_density = 100.0 * pattern_weight / max(n_words, 60)
393
+ weight_floor = min(pattern_weight / 3.0, 14.0) * min(1.0, 1000.0 / word_den)
394
+ tell_density = max(tell_density, weight_floor)
395
+
396
+ # 3. Rhythm: burstiness = coefficient of variation of sentence lengths.
397
+ # Human prose ~0.55-0.75; machine prose clusters ~0.25-0.45.
398
+ slens = [len(WORD.findall(s)) for s in sents]
399
+ burstiness = cv(slens)
400
+ # Short texts give unstable CV estimates — scale the penalty in by length.
401
+ length_conf = min(1.0, len(sents) / 8.0)
402
+ uniformity_penalty = 0.0 if formal else (
403
+ max(0.0, (0.42 - burstiness)) * 35 * length_conf)
404
+
405
+ # 4. Punctuation / formatting densities (per 100 words)
406
+ # Fenced code is not prose. Counting CLI flags such as `--gate` as dash-heavy
407
+ # style made technical READMEs look machine-written, so formatting channels
408
+ # operate on the same code-stripped text as the language channels.
409
+ emdash = 100.0 * len(re.findall(r"—|--", text)) / max(n_words, 120)
410
+ # Capped: dash-heavy but otherwise excellent prose (Lincoln, Dickinson)
411
+ # must not be convicted on punctuation alone.
412
+ emdash_penalty = min(max(0.0, emdash - 0.6) * 6, 8.0)
413
+ emoji = len(re.findall(r"[\U0001F300-\U0001FAFF✅✨⚡\U0001F449\U0001F447\U0001F680\U0001F525]", text))
414
+ emoji_penalty = min(emoji * 2.0, 12)
415
+ # Bold as mid-sentence emphasis is the tell (WP:AICATCH); bold used as a
416
+ # label at the start of a line/list item is ordinary document formatting.
417
+ bold = 0
418
+ for match in re.finditer(r"\*\*[^*\n]{2,60}\*\*", raw):
419
+ prefix = raw[raw.rfind("\n", 0, match.start()) + 1:match.start()]
420
+ if re.match(r"[\s>*#-]*(?:\d+\.\s*)?$", prefix):
421
+ continue
422
+ if re.match(r"[ \t]*\|", prefix):
423
+ continue # bold totals in a Markdown table are ordinary layout
424
+ bold += 1
425
+ bold_penalty = min(max(0, bold - 1) * 1.5, 9)
426
+ hashtags = len(re.findall(r"(?<!\S)#\w+", text))
427
+ hashtag_penalty = min(hashtags * 1.2, 8)
428
+
429
+ # 5. Register: contraction scarcity in casual genres reads machine-formal.
430
+ contractions = len(re.findall(r"\b\w+[’'](?:t|s|re|ve|ll|d|m)\b", text))
431
+ contraction_rate = 100.0 * contractions / word_den
432
+ formality_penalty = 0.0 if formal else (
433
+ 3.0 if contraction_rate < 0.4 and n_words > 80 else 0.0)
434
+
435
+ # 6. Followability: density without accessibility reads machine-compressed,
436
+ # not expert. Signals: noun-phrase chains (many commas, no verbs between),
437
+ # heavy polysyllabic ratio, and overlong sentences. Formal genres exempt
438
+ # (their register legitimately runs denser).
439
+ poly_ratio = sum(1 for w in words if len(w) >= 9) / word_den
440
+ chain_frac = sum(1 for s in sents if s.count(",") >= 4) / max(len(sents), 1)
441
+ overlong_frac = sum(1 for L in slens if L > 38) / max(len(slens), 1)
442
+ followability_penalty = 0.0 if formal else min(
443
+ max(0.0, poly_ratio - 0.14) * 40 + chain_frac * 9 + overlong_frac * 7,
444
+ 12.0)
445
+
446
+ # Clusters convict, singles don't. Em-dash density and missing contractions
447
+ # are stylistic habits, not evidence on their own — 19th-century oratory and
448
+ # plenty of excellent formal prose trip both. So corroborate them against
449
+ # lexical evidence: with no tells present they contribute little. Emoji and
450
+ # hashtags stay at full strength (they convict alone), and burstiness is an
451
+ # independent statistical signal, so neither is scaled. Bold emphasis rides
452
+ # in the stylistic sum below: heavy mid-sentence bold is a real tell in
453
+ # company, but on its own it is a formatting habit, and seven bold spans
454
+ # with zero other evidence must not reach the gate.
455
+ # The floor was 0.45, which handed style 45% weight on text with no lexical
456
+ # evidence whatsoever. Measured against genuine human technical prose that
457
+ # convicted 5 of 8 documents: AGENTS.md scored 59.2 on one weight-2.5 hit in
458
+ # 392 words. Corroboration has to be earned, so the floor is now low enough
459
+ # that dashes and formal register alone cannot carry a verdict.
460
+ corroboration = min(1.0, 0.10 + tell_density / 2.5)
461
+ stylistic = ((emdash_penalty + formality_penalty) * corroboration
462
+ + uniformity_penalty + followability_penalty + bold_penalty)
463
+ # No lexical evidence at all means no cluster, and the rule is that
464
+ # clusters convict. Style alone (dashes, long sentences, formal register,
465
+ # even rhythm, bold-heavy emphasis) describes plenty of excellent human
466
+ # prose — 19th-century oratory, dense technical writing — so with zero
467
+ # emoji or hashtag spam, style can raise suspicion but must never convict.
468
+ # The cap releases gradually as lexical evidence accumulates. A step
469
+ # release at density 1.5 rebuilt the cliff this clamp exists to prevent:
470
+ # one weight-1 arrow in a 66-word note crossed the threshold and unlocked
471
+ # the whole stylistic budget in a single jump, 20 to 87. Interpolating the
472
+ # cap between density 1.5 and 4 means each increment of lexical evidence
473
+ # buys a proportional amount of style; a lone weak hit still charges its
474
+ # own density, but never someone else's category.
475
+ if emoji == 0 and hashtags == 0:
476
+ release = min(1.0, max(0.0, (tell_density - 1.5) / 2.5))
477
+ stylistic = min(stylistic, 3.5 + release * max(0.0, stylistic - 3.5))
478
+ evidence = (
479
+ tell_density * 1.15
480
+ + stylistic
481
+ + emoji_penalty
482
+ + hashtag_penalty
483
+ )
484
+ ai_likelihood = round(100 / (1 + math.exp(-(evidence - 9.0) / 4.0)), 1)
485
+
486
+ cats = {}
487
+ for h in hits:
488
+ cats[h["cat"]] = round(cats.get(h["cat"], 0) + h["w"], 1)
489
+
490
+ return {
491
+ "score_kind": "heuristic_surface_meter",
492
+ "calibrated_probability": False,
493
+ "ai_likelihood": ai_likelihood,
494
+ "evidence": round(evidence, 2),
495
+ "tell_density_per_100w": round(tell_density, 2),
496
+ "n_words": n_words,
497
+ "n_sentences": len(sents),
498
+ "burstiness": round(burstiness, 3),
499
+ "emdash_per_100w": round(emdash, 2),
500
+ "emoji_count": emoji,
501
+ "bold_spans": bold,
502
+ "hashtags": hashtags,
503
+ "contraction_per_100w": round(contraction_rate, 2),
504
+ "followability_penalty": round(followability_penalty, 2),
505
+ "poly_ratio": round(poly_ratio, 3),
506
+ "comma_chain_frac": round(chain_frac, 3),
507
+ "overlong_frac": round(overlong_frac, 3),
508
+ "categories": cats,
509
+ "hits": hits,
510
+ }
511
+
512
+
513
+ # ── cross-draft portfolio channel ────────────────────────────────────────────
514
+ # A single draft cannot reveal that ten unrelated posts all begin with the same
515
+ # five words. The Slop Index measures opener repetition across repeated samples
516
+ # of one prompt, and Shaib et al. (arXiv:2509.19163) identify repetition and
517
+ # templatedness as separate slop dimensions. This channel reports that evidence
518
+ # across a directory of drafts. It deliberately stays outside the 0–100 score:
519
+ # the current corpus is too small to calibrate a safe universal weight, and
520
+ # repeated domain language can be legitimate.
521
+ PORTFOLIO_STOPWORDS = {
522
+ "a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "from",
523
+ "has", "have", "he", "her", "his", "i", "in", "is", "it", "its", "of",
524
+ "on", "or", "our", "she", "that", "the", "their", "they", "this", "to",
525
+ "was", "we", "were", "will", "with", "you", "your",
526
+ }
527
+
528
+
529
+ def portfolio_metrics(documents, opener_words=5, phrase_words=5):
530
+ """Return interpretable repetition evidence across several drafts.
531
+
532
+ ``documents`` is an iterable of ``(name, text)`` pairs. Exact opener and
533
+ phrase matches are normalized to lowercase words. The result is a
534
+ diagnostic, not a score or authorship verdict.
535
+ """
536
+ if (not isinstance(opener_words, int) or isinstance(opener_words, bool)
537
+ or opener_words < 1 or not isinstance(phrase_words, int)
538
+ or isinstance(phrase_words, bool) or phrase_words < 1):
539
+ raise ValueError("opener_words and phrase_words must be positive integers")
540
+ docs, names = [], set()
541
+ for name, text in documents:
542
+ name = str(name)
543
+ if name in names:
544
+ raise ValueError(f"duplicate document name: {name}")
545
+ if not isinstance(text, str):
546
+ raise ValueError(f"document {name!r} is not text")
547
+ names.add(name)
548
+ docs.append((name, WORD.findall(strip_noise(text).lower())))
549
+ out = {
550
+ "score_kind": "portfolio_template_diagnostic",
551
+ "calibrated_probability": False,
552
+ "measured": len(docs) >= 3,
553
+ "n_documents": len(docs),
554
+ "opener_words": opener_words,
555
+ "phrase_words": phrase_words,
556
+ "repeated_openers": [],
557
+ "shared_phrases": [],
558
+ "reason": "",
559
+ }
560
+ if len(docs) < 3:
561
+ out["reason"] = "needs at least 3 drafts"
562
+ return out
563
+
564
+ opener_docs = {}
565
+ phrase_docs = {}
566
+ for name, words in docs:
567
+ if len(words) >= opener_words:
568
+ opener = " ".join(words[:opener_words])
569
+ opener_docs.setdefault(opener, set()).add(name)
570
+ seen = set()
571
+ for i in range(max(0, len(words) - phrase_words + 1)):
572
+ gram_words = words[i:i + phrase_words]
573
+ # Common glue shared by several documents is not a useful template.
574
+ if all(w in PORTFOLIO_STOPWORDS for w in gram_words):
575
+ continue
576
+ seen.add(" ".join(gram_words))
577
+ for phrase in seen:
578
+ phrase_docs.setdefault(phrase, set()).add(name)
579
+
580
+ repeated = [(opener, sorted(names)) for opener, names in opener_docs.items()
581
+ if len(names) >= 2]
582
+ repeated.sort(key=lambda item: (-len(item[1]), item[0]))
583
+ repeated_opener_texts = {opener for opener, _ in repeated}
584
+ shared = [(phrase, sorted(names)) for phrase, names in phrase_docs.items()
585
+ if len(names) >= 2 and phrase not in repeated_opener_texts]
586
+ shared.sort(key=lambda item: (-len(item[1]), item[0]))
587
+ out["repeated_openers"] = [
588
+ {"text": opener, "documents": names, "document_count": len(names)}
589
+ for opener, names in repeated
590
+ ]
591
+ out["shared_phrases"] = [
592
+ {"text": phrase, "documents": names, "document_count": len(names)}
593
+ for phrase, names in shared[:20]
594
+ ]
595
+ return out
596
+
597
+
598
+ def render_portfolio(result):
599
+ """Plain-language portfolio report for the command-line interface."""
600
+ out = ["", " RELATED DRAFTS · repeated wording", "",
601
+ " This check is separate from the 0-to-100 writing score."]
602
+ if not result["measured"]:
603
+ return out + [f" Not checked: {result['reason']}.", ""]
604
+ out.append(f" Drafts checked: {result['n_documents']}")
605
+ if result["repeated_openers"]:
606
+ out.append(" repeated openings:")
607
+ for row in result["repeated_openers"][:8]:
608
+ out.append(f" {row['document_count']:>2} drafts {row['text']!r}")
609
+ else:
610
+ out.append(" repeated openings: none")
611
+ if result["shared_phrases"]:
612
+ out.append(" shared five-word phrases:")
613
+ for row in result["shared_phrases"][:8]:
614
+ out.append(f" {row['document_count']:>2} drafts {row['text']!r}")
615
+ else:
616
+ out.append(" shared five-word phrases: none")
617
+ return out + [" Suggestion: vary repeated openings and stock wording while keeping facts and voice.", ""]
618
+
619
+
620
+
621
+ # ── shape channel ─────────────────────────────────────────────────────────────
622
+ # Broetry (every sentence its own paragraph) is invisible to every other
623
+ # channel: paragraph structure is flattened before scoring, so identical words
624
+ # in 26 paragraphs or 1 score the same to the decimal. Worse, broetry's
625
+ # fragment/long-sentence mix INFLATES burstiness, so the rhythm channel that
626
+ # exists to catch machine cadence is satisfied by the tell itself.
627
+ #
628
+ # This is reported as its own axis and never folded into ai_likelihood, for
629
+ # two reasons. Mechanically, anything added to `stylistic` dies at the
630
+ # corroboration clamp exactly when broetry is the only tell. Conceptually,
631
+ # broetry is a slop tell, not a machine tell: LinkedIn writers invented it
632
+ # years before GPT-3, and it demonstrably performs on the platform. Whether to
633
+ # trade reach for a human voice is the author's call, not the meter's.
634
+ STRUCT_MARK = re.compile(r"^\s*(?:[-*+•>#]|\d+[.)]|\|)")
635
+ DIALOGUE_OPEN = re.compile("^[\"“‘']")
636
+
637
+
638
+ def shape_metrics(text, genre="general"):
639
+ """Paragraph-shape signals. Gated by genre; abstains when unreliable."""
640
+ out = {"genre": genre, "measured": False, "solo_frac": None,
641
+ "prose_paras": 0, "max_fragment_run": 0, "broetry": None,
642
+ "reason": ""}
643
+ if genre != "social":
644
+ out["reason"] = f"not measured (genre={genre}; shape signals apply to social posts)"
645
+ return out
646
+ raw = [p.strip() for p in re.split(r"\n\s*\n", strip_noise(text)) if p.strip()]
647
+ # Guards BEFORE the metric — these genres are structurally identical to
648
+ # broetry and score harder than the real thing.
649
+ prose = [p for p in raw
650
+ if not STRUCT_MARK.match(p) # lists, headings, tables
651
+ and not DIALOGUE_OPEN.match(p) # dialogue
652
+ and len(WORD.findall(p)) >= 3] # stubs
653
+ out["prose_paras"] = len(prose)
654
+ if len(prose) < 8: # mirrors length_conf
655
+ out["reason"] = f"abstains ({len(prose)} prose paragraphs; needs 8+)"
656
+ return out
657
+ solo = sum(1 for p in prose if len(sentences(p)) <= 1)
658
+ # Lists and dialogue were excluded from ``prose`` above, so they must also be
659
+ # excluded from the fragment-run half of the verdict. Otherwise three short
660
+ # bullets after an ordinary post can manufacture a broetry failure.
661
+ frag, run, best = [len(WORD.findall(s))
662
+ for s in sentences("\n\n".join(prose))], 0, 0
663
+ for L in frag:
664
+ run = run + 1 if L < 7 else 0
665
+ best = max(best, run)
666
+ out.update(measured=True, solo_frac=round(solo / len(prose), 2),
667
+ max_fragment_run=best, reason="")
668
+ out["broetry"] = out["solo_frac"] >= SHAPE_SOLO_THRESHOLD and best >= 3
669
+ return out
670
+
671
+
672
+ def band(score):
673
+ if score < 25:
674
+ return "clear"
675
+ if score < 50:
676
+ return "some issues"
677
+ if score < 75:
678
+ return "needs work"
679
+ return "major rewrite"
680
+
681
+
682
+ # Plain-English names and fixes, keyed by pattern category. The internal
683
+ # category is a maintenance label; a writer needs to know what it is and what
684
+ # to do instead.
685
+ CAT_MEANING = {
686
+ "linkedin": ("canned LinkedIn phrase", "say what happened without the stock opening"),
687
+ "marketing": ("promotional language", "name what it does; cut the adjectives"),
688
+ "scaffolding": ("empty setup", "delete the opening and keep the point"),
689
+ "hedging": ("empty hedge", "commit, or cut the sentence"),
690
+ "lexicon": ("overused AI-style word", "use the plain word"),
691
+ "rider": ("buzzword used as promotion", "use the plain word, or drop the hype around it"),
692
+ "performed": ("performed writer's voice", "say the thing plainly instead of performing it"),
693
+ "contrast": ("repeated 'not this, but that' formula", "state the point directly"),
694
+ "puffery": ("unearned significance", "state the fact, let the reader judge"),
695
+ "drama": ("manufactured drama", "the fact should carry the weight"),
696
+ "triads": ("rule of three", "two items, or one, or a real list"),
697
+ "filler": ("filler word", "cut it; the sentence survives"),
698
+ "stakes": ("manufactured stakes", "start where the reader needs to start"),
699
+ "verbs": ("weak verb", "use the direct verb"),
700
+ "assistant": ("assistant voice", "delete; you are not a chatbot"),
701
+ "artifact": ("unfinished template language", "fill it in or remove it"),
702
+ "overcorrection":("forced edgy phrasing", "restore a natural speaking voice"),
703
+ "spec-notation": ("shorthand inside a sentence", "write it as a sentence"),
704
+ "cliche": ("stock cliché", "disassemble it: say the actual trade-off or change"),
705
+ "rhetorical": ("staged question or setup", "make the point without the setup"),
706
+ "email": ("form-letter email phrase", "say the actual ask in the first sentence"),
707
+ "misc": ("generic AI-style wording", "rewrite plainly"),
708
+ }
709
+
710
+
711
+ def _severity(w):
712
+ """Absolute bands, so bars mean the same thing in every document."""
713
+ if w >= 10: return "heavy", 8
714
+ if w >= 5: return "moderate", 5
715
+ if w >= 2: return "mild", 3
716
+ return "trace", 2
717
+
718
+
719
+ def render_heatmap(text, data, formal=False, max_rows=8, width=8):
720
+ """A map a writer can act on: where the slop is, how bad, and what to do."""
721
+ clean = strip_noise(text)
722
+ doc = score_text(text, data, formal=formal)
723
+ paras = [p for p in re.split(r"\n\s*\n", clean) if p.strip()]
724
+ rows = []
725
+ for pi, para in enumerate(paras, 1):
726
+ for s in sentences(para):
727
+ w, cats, quotes = 0.0, [], []
728
+ _sl = s.lower() # hoisted out of the hit loop: this was
729
+ # recomputed once per hit, giving O(sentences x hits)
730
+ for h in doc["hits"]:
731
+ q = h["quote"].lower()
732
+ if q and q in _sl:
733
+ w += h["w"]
734
+ cats.append(h["cat"])
735
+ quotes.append(q)
736
+ rows.append({"para": pi, "sent": s, "w": round(w, 1),
737
+ "cats": cats, "quotes": quotes})
738
+ total = len(rows)
739
+ dirty = [r for r in rows if r["w"] > 0]
740
+ out = []
741
+ if not total:
742
+ return out
743
+ if not dirty:
744
+ out.append(f" WRITING CHECK · {total} sentences · no flagged phrases")
745
+ out.append(" " + "·" * min(total, 40) + " all clean")
746
+ return out
747
+
748
+ out.append(f" WHERE TO EDIT · {total} sentences · {len(dirty)} flagged "
749
+ f"· strongest first")
750
+ out.append("")
751
+ for r in sorted(dirty, key=lambda r: -r["w"])[:max_rows]:
752
+ label, fill = _severity(r["w"])
753
+ bar = "█" * fill + "░" * (width - fill)
754
+ # quote the trigger, not the whole sentence — that is what to change
755
+ trig = max(r["quotes"], key=len)[:46]
756
+ out.append(f' {bar} {label:<8} ¶{r["para"]} “{trig}”')
757
+ seen, notes = set(), []
758
+ for c in r["cats"]:
759
+ if c in seen:
760
+ continue
761
+ seen.add(c)
762
+ name, fix = CAT_MEANING.get(c, (c, "rewrite plainly"))
763
+ notes.append(f"{name} — {fix}")
764
+ for n in notes[:2]:
765
+ out.append(f' {" " * width} {n}')
766
+ if len(dirty) > max_rows:
767
+ out.append(f' {" " * width} …and {len(dirty)-max_rows} more')
768
+ out.append("")
769
+ # document shape: one block per paragraph, so clustering is visible
770
+ shape = []
771
+ for pi in range(1, len(paras) + 1):
772
+ pw = sum(r["w"] for r in rows if r["para"] == pi)
773
+ shape.append("█" if pw >= 10 else "▓" if pw >= 5 else "▒" if pw > 0 else "·")
774
+ out.append(f' draft overview {" ".join(shape)} █ heavy ▓ moderate '
775
+ f'▒ mild · clean')
776
+ return out
777
+
778
+
779
+ def gate_value():
780
+ """Return (threshold, raw_token) for --gate, consuming its argument."""
781
+ if "--gate" not in sys.argv:
782
+ return None, None
783
+ i = sys.argv.index("--gate")
784
+ try:
785
+ tok = sys.argv[i + 1]
786
+ value = float(tok)
787
+ if not math.isfinite(value) or not 0 <= value <= 100:
788
+ raise ValueError
789
+ return value, tok
790
+ except (IndexError, ValueError):
791
+ raise SystemExit("--gate needs a finite threshold from 0 to 100")
792
+
793
+
794
+ CHANNELS = [
795
+ # label, how to pull the number, which direction is better, how to show it
796
+ ("word choice", lambda r: sum(h["w"] for h in r["hits"]
797
+ if h["cat"] in ("lexicon", "rider")), "low"),
798
+ ("phrasing", lambda r: sum(h["w"] for h in r["hits"]
799
+ if h["cat"] not in ("lexicon", "rider")), "low"),
800
+ ("sentence variety", lambda r: r["burstiness"], "high"),
801
+ ("readability", lambda r: r["followability_penalty"], "low"),
802
+ ("formatting", lambda r: r["emdash_per_100w"] + r["emoji_count"]
803
+ + r["hashtags"], "low"),
804
+ ]
805
+
806
+
807
+ # What counts as a fact worth preserving. Deliberately narrow: things a reader
808
+ # could check, and things whose invention is the failure the skill forbids.
809
+ FACT_RX = [
810
+ ("figure", r"(?<![\w.])\$?\d[\d,]*(?:\.\d+)?\s*(?:%|percent|x|bn|m|k|million|billion)?(?![\w])"),
811
+ ("name", r"\b(?:[A-Z][a-z]{2,}(?:\s+[A-Z][a-z]+)*)\b"),
812
+ ("quote", r"[\u201c\"]([^\u201d\"]{6,120})[\u201d\"]"),
813
+ ("url", r"https?://\S+"),
814
+ ]
815
+ # Sentence-initial capitals are not names. Neither are these.
816
+ NOT_NAMES = set("""The This That These Those We They It He She You I A An And But Or
817
+ So Then Now Here There When While If After Before Our Their His Her Its My Your
818
+ Most Many Some Every Each Both All No Not One Two Three Four Five Six Seven
819
+ Eight Nine Ten First Second Third Last Next Why How What Which Who Where
820
+ See Read Use Run Add Set Get Let Note Also Just Only Even Still Yet Once
821
+ More Less Best Worst Same Other Another Such Very Much Well Then Than
822
+ Shipped Built Made Added Fixed Moved Cut Kept Found Gave Took Went Came
823
+ Said Did Had Was Were Been Being Done Going Getting Started Stopped
824
+ Because Since Though Although Unless Until Whether Given Once Yet""".split())
825
+
826
+ # Spelled-out numbers, mapped to digits. A rewrite that turns "18 months" into
827
+ # "Eighteen months" is faithful, but the raw extractor read "18" as a dropped
828
+ # figure and "Eighteen" as an invented name — two false alarms from one honest
829
+ # edit. Normalising both texts to digits before extracting cancels that, and
830
+ # because the same transform runs on the original and the rewrite, it can never
831
+ # manufacture a mismatch that was not already there.
832
+ NUM_WORDS = {
833
+ "zero": "0", "one": "1", "two": "2", "three": "3", "four": "4", "five": "5",
834
+ "six": "6", "seven": "7", "eight": "8", "nine": "9", "ten": "10",
835
+ "eleven": "11", "twelve": "12", "thirteen": "13", "fourteen": "14",
836
+ "fifteen": "15", "sixteen": "16", "seventeen": "17", "eighteen": "18",
837
+ "nineteen": "19", "twenty": "20", "thirty": "30", "forty": "40",
838
+ "fifty": "50", "sixty": "60", "seventy": "70", "eighty": "80",
839
+ "ninety": "90", "hundred": "100", "thousand": "1000",
840
+ "million": "1000000", "billion": "1000000000",
841
+ }
842
+ # Only normalise numbers of eleven or more. "one".."ten" double as articles and
843
+ # pronouns ("one of them", "two ways"), so digitising them invents figures that
844
+ # were never quantities; from eleven up, a spelled number is almost always a real
845
+ # count ("eighteen months", "forty percent", "a hundred users").
846
+ _NUM_RX = re.compile(
847
+ r"\b(" + "|".join(w for w, d in NUM_WORDS.items() if int(d) >= 11) + r")\b", re.I)
848
+
849
+
850
+ def _spell_to_digits(text):
851
+ return _NUM_RX.sub(lambda m: NUM_WORDS[m.group(0).lower()], text)
852
+
853
+
854
+ # Common English words that legitimately start sentences and so get capitalised,
855
+ # but are not names — "Draw the diagram", "Usually it works", "Start here". The
856
+ # entity regex cannot tell these from "Priya" or "Acme" (which are never ordinary
857
+ # words), so an explicit frequency list carries the difference. This is a
858
+ # precision aid only: a word here is skipped as a name in BOTH texts, so it can
859
+ # widen a miss but never invent a false fabrication flag.
860
+ COMMON_WORDS = set("""
861
+ about above across again against along already also although always among another
862
+ any anyone around away back become been before behind below better between beyond
863
+ build building built call called celebrate change changed come coming could deploy
864
+ deployment deployments double doing down draw during each either enough every
865
+ everyone everything except finally find found from give given going gone great grow
866
+ growing hard help here however instead into keep kept later least leave less look
867
+ looking made make making many maybe might migrate more most move moving much must
868
+ never next nobody nothing often once only other over people perhaps ready really
869
+ right run running same say saying send sent set ship shipping should show shown
870
+ since some someone something soon start started still stop such take taken talk
871
+ tell than that their them then there these they thing things think this those
872
+ though through today together too took toward tried true trying turn under until
873
+ upon usually using very want was way well went were what when where which while
874
+ whole will with within without work working would writing agree agreeing
875
+ monday tuesday wednesday thursday friday saturday sunday none plenty seats reps
876
+ fix sit mid ambiguity team teams user users product feature features day days week
877
+ weeks month months year years time thing done anyway besides meanwhile therefore
878
+ worse worst harder easier simpler faster slower bigger smaller lots plus minus
879
+ are artificial bookmark despite hey modern please researchers save unpopular welcome
880
+ """.split())
881
+ NOT_NAME_WORDS = {word.lower() for word in NOT_NAMES} | COMMON_WORDS
882
+
883
+
884
+ def facts(text, _other=""):
885
+ """Checkable claims in a draft: figures, named entities, quotes, links."""
886
+ # URLs contain lowercase forms of the names they point at ("acme.io" made
887
+ # "Acme" look like a sentence opener in the source and an invention in the
888
+ # rewrite), so entity detection runs on the text with links removed.
889
+ urls = text # links keep their spelled forms; numbers in a slug are not facts
890
+ prose = _spell_to_digits(re.sub(r"https?://\S+", " ", text))
891
+ # Ordered-list markers describe structure, not quantities. Treating the
892
+ # ``1.`` in a three-item list as a dropped fact penalises a faithful prose
893
+ # rewrite and hides real numeric changes in noise.
894
+ prose = re.sub(r"(?m)^\s*\d+[.)]\s+", "", prose)
895
+ other = _spell_to_digits(_other)
896
+ other = re.sub(r"(?m)^\s*\d+[.)]\s+", "", other)
897
+ out = {}
898
+ for kind, rx in FACT_RX:
899
+ found = set()
900
+ for m in re.finditer(rx, urls if kind == "url" else prose):
901
+ v = (m.group(1) if m.lastindex else m.group(0)).strip()
902
+ if kind == "name":
903
+ if v in NOT_NAMES or len(v) < 3:
904
+ continue
905
+ low = v.lower()
906
+ tokens = re.findall(r"[a-z]+", low)
907
+ # A title-cased run made entirely of ordinary sentence words is
908
+ # not an entity (for example, "Shipped Tuesday").
909
+ if tokens and all(token in NOT_NAME_WORDS for token in tokens):
910
+ continue
911
+ # A capitalised common word ("Draw", "Usually", "Start"), an
912
+ # adverb ("Finally"), or a sentence-opening gerund ("Watching",
913
+ # "Calling") is not an entity; a real name never is.
914
+ if " " not in v and (low in COMMON_WORDS
915
+ or low.endswith("ly") or low.endswith("ing")):
916
+ continue
917
+ # A word is only a name if it is never used as an ordinary
918
+ # lowercase word — not here, and not in the text we compare
919
+ # against. "Under"/"Shipped" appear lowercased somewhere in
920
+ # normal prose; "Priya"/"Acme" do not. Multi-word entities keep
921
+ # their head token for this test.
922
+ head = v.split()[0]
923
+ # Is this token ever used as an ordinary lowercase word, here or
924
+ # in the compared text? Sentence openers are ("under load",
925
+ # "shipped tuesday"); real names never are. Strip the capitalized
926
+ # forms first so the entity cannot vouch for itself.
927
+ blob = re.sub(r"\b" + re.escape(head) + r"\b", " ", prose + " " + other)
928
+ if re.search(r"\b" + re.escape(head.lower()) + r"\b", blob):
929
+ continue
930
+ if kind == "figure":
931
+ v = v.replace(",", "").lstrip("$").rstrip()
932
+ v = re.sub(r"\s*percent$", "%", v)
933
+ v = re.sub(r"\s*(million|bn|billion|m|k)$",
934
+ lambda x: {"million":"m","billion":"bn"}.get(x.group(1), x.group(1)), v)
935
+ if kind == "url":
936
+ # a link at the end of a sentence carries the full stop
937
+ v = v.rstrip(".,;:)]}\u201d\"'")
938
+ if v:
939
+ found.add(v)
940
+ out[kind] = found
941
+ return out
942
+
943
+
944
+ # Interior states the author has to have supplied. The benchmark's one
945
+ # fabrication was exactly this shape — "by test day the real thing felt
946
+ # familiar" — and an entity check cannot see it, because no name or figure moved.
947
+ # First-person emotional state and the body-as-feeling idiom. Kept deliberately
948
+ # tight: "I felt/was <emotion>", "my heart/stomach ...", not every clause with
949
+ # a feeling verb, because the goal is catching an INVENTED inner state, and the
950
+ # comparison below cancels any that were already in the source.
951
+ INTERIOR_STATE_RX = re.compile(
952
+ r"\b(?:I|we)\s+(?:was|were|am|felt|feel|got)\s+"
953
+ r"(?:(?:very|really|extremely|quite|so)\s+)?(?P<state>[A-Za-z]+)", re.I)
954
+ INTERIOR_COGNITION_RX = re.compile(
955
+ r"\b(?:I|we)\s+(?P<cognition>remember(?:ed)?|recall(?:ed)?|realise(?:d)?|"
956
+ r"realize(?:d)?|knew|fear(?:ed)?|hope(?:d)?|worr(?:y|ied)|panic(?:ked)?|"
957
+ r"struggl(?:e|ed)|doubt(?:ed)?)\b", re.I)
958
+ INTERIOR_BODY_RX = re.compile(
959
+ r"\b(?:my|our)\s+(?P<body>heart|stomach|gut|chest|hands|mind)\b", re.I)
960
+ INTERIOR_IMPERSONAL_RX = re.compile(
961
+ r"\bit\s+felt\s+(?P<impersonal>surreal|unreal|impossible|inevitable|like)\b", re.I)
962
+ INTERIOR_BARE_RX = re.compile(
963
+ r"\bfelt\s+(?P<bare>familiar|natural|surreal|foreign|inevitable|effortless)\b", re.I)
964
+ COGNITION_CANON = {
965
+ "remembered": "remember", "recalled": "remember", "recall": "remember",
966
+ "realised": "realize", "realise": "realize", "realized": "realize",
967
+ "feared": "fear", "hoped": "hope", "worried": "worry",
968
+ "panicked": "panic", "struggled": "struggle", "doubted": "doubt",
969
+ }
970
+
971
+
972
+ def interior_claims(text):
973
+ """Inner-state assertions, reduced to a comparable core so paraphrase of an
974
+ existing one does not read as a new invention."""
975
+ out = {m.group("state").lower() for m in INTERIOR_STATE_RX.finditer(text)}
976
+ for m in INTERIOR_COGNITION_RX.finditer(text):
977
+ word = m.group("cognition").lower()
978
+ out.add(COGNITION_CANON.get(word, word))
979
+ out.update("body:" + m.group("body").lower()
980
+ for m in INTERIOR_BODY_RX.finditer(text))
981
+ out.update(m.group("impersonal").lower()
982
+ for m in INTERIOR_IMPERSONAL_RX.finditer(text))
983
+ out.update(m.group("bare").lower() for m in INTERIOR_BARE_RX.finditer(text))
984
+ return out
985
+
986
+
987
+ def fidelity(before, after):
988
+ """Did the rewrite keep every fact, and did it add any?
989
+
990
+ The benchmark's worst result was a rewrite that invented a feeling the
991
+ author never described — the exact thing hard rule 1 forbids — and nothing
992
+ in the gate measured it. Preservation is checkable; invention is the half
993
+ that matters, because a dropped figure is visible to the author and an
994
+ added one is not.
995
+ """
996
+ a, b = facts(before, after), facts(after, before)
997
+ rows, kept_all, invented_any = [], True, False
998
+ def entity_tokens(entity):
999
+ return {w for w in re.findall(r"[a-z]+", entity.lower())
1000
+ if w not in NOT_NAME_WORDS}
1001
+
1002
+ def entity_match(entity, candidates):
1003
+ """Exact names and honest shortenings match; partial renames do not."""
1004
+ left = entity_tokens(entity)
1005
+ if not left:
1006
+ return False
1007
+ for candidate in candidates:
1008
+ right = entity_tokens(candidate)
1009
+ if right and (left == right or left < right or right < left):
1010
+ return True
1011
+ return False
1012
+ # Interior experience is the fabrication the judges actually caught, and the
1013
+ # one no entity check sees: nothing was renamed, a feeling was added.
1014
+ ia, ib = interior_claims(before), interior_claims(after)
1015
+ new_interior = ib - ia
1016
+ for kind, _ in FACT_RX:
1017
+ if kind == "name":
1018
+ dropped = {e for e in a[kind] if not entity_match(e, b[kind])}
1019
+ added = {e for e in b[kind] if not entity_match(e, a[kind])}
1020
+ kept = a[kind] - dropped
1021
+ else:
1022
+ kept = a[kind] & b[kind]
1023
+ dropped = a[kind] - b[kind]
1024
+ added = b[kind] - a[kind]
1025
+ if not (a[kind] or b[kind]):
1026
+ continue
1027
+ rows.append((kind, kept, dropped, added))
1028
+ if dropped:
1029
+ kept_all = False
1030
+ if added:
1031
+ invented_any = True
1032
+ if new_interior:
1033
+ rows.append(("feeling", set(), set(), new_interior))
1034
+ invented_any = True
1035
+ return {"rows": rows, "preserved": kept_all, "invented": invented_any,
1036
+ "interior": new_interior}
1037
+
1038
+
1039
+ # The shared rewrite-quality objective. One definition of "a better rewrite",
1040
+ # used by scripts/rerank.py to pick the best of N candidates. Fidelity is
1041
+ # reported alongside, never folded in, so a candidate can never win by dropping
1042
+ # or inventing a fact however clean it reads.
1043
+ RW_GATE = {"email": 35, "research": 40, "professional": 40}
1044
+ RW_GATE_DEFAULT = 25
1045
+ RW_FORMAL = {"research", "professional"}
1046
+ RW_WEIGHTS = {"deslop": 0.45, "gate": 0.25, "rhythm": 0.15, "length": 0.15}
1047
+
1048
+
1049
+ def rewrite_score(before_text, after_text, genre=None, data=None):
1050
+ """Score one rewrite: a soft quality in [0,1] plus its fidelity flags."""
1051
+ if data is None:
1052
+ data = load_patterns()
1053
+ formal = genre in RW_FORMAL
1054
+ b = score_text(before_text, data, formal=formal)
1055
+ a = score_text(after_text, data, formal=formal)
1056
+ b_ai = b["ai_likelihood"] or 1e-9
1057
+ clamp = lambda x: max(0.0, min(1.0, x))
1058
+ deslop = clamp((b_ai - a["ai_likelihood"]) / b_ai)
1059
+ gate = 1.0 if a["ai_likelihood"] <= RW_GATE.get(genre, RW_GATE_DEFAULT) else 0.0
1060
+ rhythm = clamp(a.get("burstiness", 0.0) / 0.45)
1061
+ bw, aw = len(before_text.split()), len(after_text.split())
1062
+ length = 1.0 if not bw or aw / bw >= 0.6 else clamp((aw / bw) / 0.6)
1063
+ soft = sum(RW_WEIGHTS[k] * v for k, v in
1064
+ {"deslop": deslop, "gate": gate, "rhythm": rhythm, "length": length}.items())
1065
+ fid = fidelity(before_text, after_text)
1066
+ return {"soft": round(soft, 4), "deslop": round(deslop, 3), "gate": gate,
1067
+ "rhythm": round(rhythm, 3), "length": round(length, 3),
1068
+ "after_ai": a["ai_likelihood"], "before_ai": b["ai_likelihood"],
1069
+ "burstiness": round(a.get("burstiness", 0.0), 3),
1070
+ "high_tells": sum(1 for h in a.get("hits", []) if h.get("w", 0) >= 4),
1071
+ "preserved": fid["preserved"], "invented": fid["invented"]}
1072
+
1073
+
1074
+ def render_fidelity(before, after):
1075
+ r = fidelity(before, after)
1076
+ out = ["", " FACT AND MEANING CHECK · original vs edited text", ""]
1077
+ for kind, kept, dropped, added in r["rows"]:
1078
+ out.append(f" {kind:<8} {len(kept)} kept"
1079
+ + (f" · {len(dropped)} DROPPED" if dropped else "")
1080
+ + (f" · {len(added)} ADDED" if added else ""))
1081
+ for v in sorted(dropped)[:4]:
1082
+ out.append(f" dropped {v[:56]!r}")
1083
+ for v in sorted(added)[:4]:
1084
+ out.append(f" ADDED {v[:56]!r} <-- not in the source")
1085
+ if not r["rows"]:
1086
+ out.append(" no checkable facts in either text")
1087
+ if r.get("interior"):
1088
+ out.append(" the author never said these; an added feeling is still a "
1089
+ "fabrication")
1090
+ out += ["",
1091
+ " Result: " + ("facts preserved; nothing added"
1092
+ if r["preserved"] and not r["invented"] else
1093
+ ("FACTS DROPPED" if not r["preserved"] else "")
1094
+ + (" · CONTENT INVENTED" if r["invented"] else "")),
1095
+ " This checks figures, names, quotes, links, and stated feelings.",
1096
+ " Your AI assistant still compares the full meaning because a changed claim",
1097
+ " or emphasis may use all the same names and numbers.", ""]
1098
+ return out
1099
+
1100
+
1101
+ def dna(before, after, data, formal=False, width=22):
1102
+ """Side-by-side channel anatomy of a draft and its rewrite.
1103
+
1104
+ The composite says a draft got better; it never says what *kind* of better.
1105
+ A writer who sees that the whole score was vocabulary learns to stop
1106
+ reaching for those words, which outlasts the edit. Bars are scaled per
1107
+ channel against the worse of the two texts, so each row reads as its own
1108
+ before-and-after rather than against an arbitrary ceiling.
1109
+ """
1110
+ a, b = score_text(before, data, formal), score_text(after, data, formal)
1111
+ out = ["", " WHAT CHANGED · before → after", ""]
1112
+ for label, get, better in CHANNELS:
1113
+ x, y = get(a), get(b)
1114
+ top = max(x, y) or 1.0
1115
+ fx, fy = x / top, y / top
1116
+ bar = "".join("█" if i < round(fx * width) else
1117
+ ("▁" if i < round(max(fx, fy) * width) else " ")
1118
+ for i in range(width))
1119
+ gone = (x - y) if better == "low" else (y - x)
1120
+ mark = "improved" if gone > 1e-9 else ("unchanged" if abs(gone) < 1e-9 else "WORSE")
1121
+ fmt = (lambda v: f"{v:.2f}") if max(x, y) < 10 else (lambda v: f"{v:g}")
1122
+ out.append(f" {label:<14}{bar} {fmt(x):>6} → {fmt(y):<6} {mark}")
1123
+ out += ["",
1124
+ f" writing score {a['ai_likelihood']:.1f} → {b['ai_likelihood']:.1f}"
1125
+ f" ({band(a['ai_likelihood'])} → {band(b['ai_likelihood'])})",
1126
+ f" length {a['n_words']} → {b['n_words']} words "
1127
+ f"({(b['n_words']-a['n_words'])/max(a['n_words'],1)*100:+.0f}%)",
1128
+ f" flagged phrases {len(a['hits'])} → {len(b['hits'])}"]
1129
+ kept = {h["name"] for h in b["hits"]}
1130
+ fixed = [h["name"] for h in a["hits"] if h["name"] not in kept]
1131
+ if fixed:
1132
+ out.append(" fixed " + ", ".join(sorted(set(fixed))[:6]))
1133
+ if kept:
1134
+ out.append(" still present " + ", ".join(sorted(kept)[:6]))
1135
+ # A shorter text with the same tells is not a better text.
1136
+ if b["n_words"] < a["n_words"] * 0.75 and len(b["hits"]) >= len(a["hits"]):
1137
+ out.append(" note got shorter without fixing flagged phrases — "
1138
+ "check this is an edit, not a deletion")
1139
+ return out + [""]
1140
+
1141
+
1142
+ def _required_option_value(argv, flag):
1143
+ if flag not in argv:
1144
+ return None
1145
+ if argv.count(flag) > 1:
1146
+ raise SystemExit(f"{flag} may be supplied only once")
1147
+ index = argv.index(flag)
1148
+ if index + 1 >= len(argv) or argv[index + 1].startswith("--"):
1149
+ raise SystemExit(f"{flag} needs a value")
1150
+ return argv[index + 1]
1151
+
1152
+
1153
+ def _read_text_file(path):
1154
+ try:
1155
+ return Path(path).read_text()
1156
+ except (OSError, UnicodeDecodeError) as exc:
1157
+ raise SystemExit(f"cannot read {path}: {exc}") from exc
1158
+
1159
+
1160
+ def _text_files(root_arg):
1161
+ root = Path(root_arg)
1162
+ if not root.exists():
1163
+ raise SystemExit(f"directory does not exist: {root}")
1164
+ if not root.is_dir():
1165
+ raise SystemExit(f"expected a directory, got: {root}")
1166
+ return sorted(p for p in root.rglob("*") if p.suffix.lower() in
1167
+ (".md", ".txt", ".markdown") and p.is_file())
1168
+
1169
+
1170
+ def main():
1171
+ argv = sys.argv[1:]
1172
+ if "--help" in argv or "-h" in argv:
1173
+ print(__doc__)
1174
+ return 0
1175
+ value_flags = {"--gate", "--genre", "--voice"}
1176
+ bool_flags = {"--json", "--explain", "--formal", "--fidelity", "--dna",
1177
+ "--portfolio", "--batch", "--heatmap"}
1178
+ unknown = [arg for arg in argv if arg.startswith("--")
1179
+ and arg not in value_flags | bool_flags]
1180
+ if unknown:
1181
+ raise SystemExit(f"unknown option: {unknown[0]}")
1182
+ for flag in value_flags:
1183
+ _required_option_value(argv, flag)
1184
+ modes = [flag for flag in ("--fidelity", "--dna", "--portfolio", "--batch")
1185
+ if flag in argv]
1186
+ if len(modes) > 1:
1187
+ raise SystemExit("choose only one mode: " + ", ".join(modes))
1188
+
1189
+ gv, _ = gate_value()
1190
+ # Values that belong to a flag (--gate 25, --genre social, --voice manav)
1191
+ # are not positional file arguments. Drop each flag and the token after it.
1192
+ VALUE_FLAGS = value_flags
1193
+ args, skip = [], False
1194
+ for a in argv:
1195
+ if skip:
1196
+ skip = False
1197
+ continue
1198
+ if a in VALUE_FLAGS:
1199
+ skip = True
1200
+ continue
1201
+ if not a.startswith("--"):
1202
+ args.append(a)
1203
+ as_json = "--json" in sys.argv
1204
+ explain = "--explain" in sys.argv
1205
+ formal = "--formal" in sys.argv
1206
+ genre = "general"
1207
+ if "--genre" in sys.argv:
1208
+ genre = _required_option_value(argv, "--genre")
1209
+ if formal: genre = "formal"
1210
+ voice = None
1211
+ if "--voice" in sys.argv:
1212
+ voice = _required_option_value(argv, "--voice")
1213
+ try:
1214
+ data = load_patterns(voice=voice)
1215
+ except ValueError as exc:
1216
+ sys.exit(str(exc))
1217
+
1218
+ if "--fidelity" in sys.argv:
1219
+ if len(args) != 2:
1220
+ sys.exit("--fidelity needs exactly two files: before and after")
1221
+ before, after = _read_text_file(args[0]), _read_text_file(args[1])
1222
+ for line in render_fidelity(before, after):
1223
+ print(line)
1224
+ r = fidelity(before, after)
1225
+ sys.exit(0 if (r["preserved"] and not r["invented"]) else 1)
1226
+
1227
+ if "--dna" in sys.argv:
1228
+ if len(args) != 2:
1229
+ sys.exit("--dna needs exactly two files: before and after")
1230
+ for line in dna(_read_text_file(args[0]), _read_text_file(args[1]),
1231
+ data, formal=formal):
1232
+ print(line)
1233
+ return
1234
+
1235
+ if "--portfolio" in sys.argv:
1236
+ if len(args) > 1:
1237
+ raise SystemExit("--portfolio accepts one directory")
1238
+ root = Path(args[0]) if args else Path(".")
1239
+ files = _text_files(root)
1240
+ if not files:
1241
+ raise SystemExit(f"no .md, .txt, or .markdown files under {root}")
1242
+ result = portfolio_metrics((str(p), _read_text_file(p)) for p in files)
1243
+ if as_json:
1244
+ print(json.dumps(result, ensure_ascii=False, indent=1))
1245
+ else:
1246
+ for line in render_portfolio(result):
1247
+ print(line)
1248
+ return
1249
+
1250
+ if "--batch" in sys.argv:
1251
+ if len(args) > 1:
1252
+ raise SystemExit("--batch accepts one directory")
1253
+ root = Path(args[0]) if args else Path(".")
1254
+ files = _text_files(root)
1255
+ if not files:
1256
+ raise SystemExit(f"no .md, .txt, or .markdown files under {root}")
1257
+ rows = []
1258
+ for p in files:
1259
+ r = score_text(_read_text_file(p), data, formal=formal)
1260
+ rows.append((r["ai_likelihood"], p, band(r["ai_likelihood"])))
1261
+ rows.sort(key=lambda x: -x[0])
1262
+ worst = max(sc for sc, _, _ in rows)
1263
+ passed = gv is None or worst <= gv
1264
+ if as_json:
1265
+ print(json.dumps({
1266
+ "result_kind": "batch_score",
1267
+ "directory": str(root),
1268
+ "documents": len(rows),
1269
+ "max_score": worst,
1270
+ "gate_applied": gv is not None,
1271
+ "gate": gv,
1272
+ "passed": passed,
1273
+ "items": [
1274
+ {"file": str(p), "score": sc, "band": b}
1275
+ for sc, p, b in rows
1276
+ ],
1277
+ }, ensure_ascii=False, indent=1))
1278
+ else:
1279
+ for sc, p, b in rows:
1280
+ print(f"{sc:6.1f} {b:12s} {p}")
1281
+ sys.exit(1 if gv is not None and worst > gv else 0)
1282
+
1283
+ if len(args) > 1:
1284
+ raise SystemExit("score mode accepts one file, or '-' for stdin")
1285
+ # No file argument, or the conventional "-", means read stdin.
1286
+ text = sys.stdin.read() if (not args or args[0] == "-") else _read_text_file(args[0])
1287
+ r = score_text(text, data, formal=formal)
1288
+ if as_json:
1289
+ print(json.dumps(r, ensure_ascii=False, indent=1))
1290
+ if gv is None:
1291
+ return
1292
+ # --json --gate is documented CI usage; returning here exited 0 on a
1293
+ # failing document, so a broken gate silently passed every build.
1294
+ sh_j = shape_metrics(text, genre=genre)
1295
+ sys.exit(0 if (r["ai_likelihood"] <= gv and not sh_j.get("broetry")) else 1)
1296
+ print(f"Writing score: {r['ai_likelihood']}/100 [{band(r['ai_likelihood'])}]")
1297
+ print(" Lower is better. This describes the writing, not who wrote it.")
1298
+ unique_hits = []
1299
+ seen_quotes = set()
1300
+ for hit in sorted(r["hits"], key=lambda item: -item["w"]):
1301
+ key = hit["quote"].strip().lower()
1302
+ if key and key not in seen_quotes:
1303
+ seen_quotes.add(key)
1304
+ unique_hits.append(hit)
1305
+ print(f" Flagged phrases : {len(unique_hits)} across {r['n_words']} words")
1306
+ variety = "natural" if r["burstiness"] >= 0.45 else "too even"
1307
+ print(f" Sentence variety: {variety}")
1308
+ print(f" Punctuation : {r['emoji_count']} emoji, {r['bold_spans']} bold spans, "
1309
+ f"{r['hashtags']} hashtags, {r['emdash_per_100w']:.2f} em dashes per 100 words")
1310
+ if r["followability_penalty"] > 2:
1311
+ print(f" Readability : needs work — "
1312
+ f"{r['comma_chain_frac']:.0%} of sentences chain clauses with commas; "
1313
+ f"{r['overlong_frac']:.0%} are unusually long")
1314
+ else:
1315
+ print(" Readability : clear")
1316
+ if r["categories"]:
1317
+ top = sorted(r["categories"].items(), key=lambda kv: -kv[1])[:8]
1318
+ labels = [CAT_MEANING.get(k, (k, ""))[0] for k, _ in top]
1319
+ print(" Main issues : " + ", ".join(labels))
1320
+ sh = shape_metrics(text, genre=genre)
1321
+ r["shape"] = sh
1322
+ print(" Page layout : " + (
1323
+ f"too many short, one-sentence paragraphs ({sh['solo_frac']:.0%}); "
1324
+ f"longest fragment run {sh['max_fragment_run']}" if sh.get("broetry")
1325
+ else (f"looks natural ({sh['solo_frac']:.0%} one-sentence paragraphs)" if sh["measured"]
1326
+ else "not checked for this kind of writing")))
1327
+ print(" What Zero Slop checked: word choice, formatting, sentence rhythm, "
1328
+ "readability, and tone" + (", plus page layout" if sh["measured"] else ""))
1329
+ print(" What your AI assistant reviews: strength of the ideas, voice, and factual accuracy"
1330
+ + ("" if sh["measured"] else "; page layout was not checked"))
1331
+ if explain:
1332
+ if unique_hits:
1333
+ print(f"\n Flagged phrases ({len(unique_hits)}), strongest first:")
1334
+ for h in unique_hits:
1335
+ name, fix = CAT_MEANING.get(h["cat"], ("generic wording", "rewrite plainly"))
1336
+ print(f" {h['quote']!r} — {name}; {fix}")
1337
+ else:
1338
+ print("\n Flagged phrases: none. The remaining score comes from sentence rhythm and formatting.")
1339
+ if "--heatmap" in sys.argv or explain:
1340
+ for line in render_heatmap(text, data, formal=formal):
1341
+ print(line)
1342
+ if gv is not None:
1343
+ ok = r["ai_likelihood"] <= gv and not sh.get("broetry")
1344
+ why = "" if ok else (" (page layout needs work)" if sh.get("broetry") and r["ai_likelihood"] <= gv else "")
1345
+ verdict = "PASSED" if ok else "NEEDS WORK"
1346
+ print(f" Check against {gv:g}: {verdict}{why}. This covers writing patterns and "
1347
+ f"layout; your AI assistant still reviews the ideas, voice, and facts.")
1348
+ sys.exit(0 if ok else 1)
1349
+
1350
+
1351
+ if __name__ == "__main__":
1352
+ main()