zero-slop 2.5.8 → 2.7.0

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.
@@ -41,6 +41,10 @@ HOME = Path(os.environ.get("ZERO_SLOP_HOME", Path.home() / ".zero-slop")).expand
41
41
  VOICE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
42
42
 
43
43
 
44
+ class PatternData(dict):
45
+ """JSON-compatible pattern mapping with an out-of-band compiled plan."""
46
+
47
+
44
48
  def _voice_path(name):
45
49
  """Resolve a profile name without letting it become a filesystem path."""
46
50
  if not VOICE_NAME.fullmatch(name or "") or name in (".", ".."):
@@ -117,7 +121,7 @@ def load_patterns(voice=None):
117
121
  _merge_learned(base, HOME / "learned.json") # private, live
118
122
  if voice:
119
123
  _apply_voice(base, voice)
120
- return base
124
+ return PatternData(base)
121
125
 
122
126
 
123
127
  def _apply_voice(base, name):
@@ -155,6 +159,78 @@ def _apply_voice(base, name):
155
159
  SENT_SPLIT = re.compile(r"(?<=[.!?])[\")”’]?\s+(?=[A-Z“\"(0-9])")
156
160
  WORD = re.compile(r"[A-Za-z’']+")
157
161
 
162
+ # Normalise only detector-evasion characters, never ordinary non-Latin prose.
163
+ # A Cyrillic or Greek lookalike is mapped only when it appears in the same word
164
+ # as an ASCII letter (for example, dеlvе). This keeps Russian and Greek text
165
+ # untouched while preventing an invisible substitution from bypassing a known
166
+ # phrase. Adapted from the normalisation pre-pass in conorbronsdon/
167
+ # avoid-ai-writing, reviewed at commit 40328bd292bc682d46010a6f9ac2cdbf4fb4ceca.
168
+ ZERO_WIDTH_RX = re.compile(r"[\u200b-\u200d\ufeff\u2060]")
169
+ SUSPICIOUS_UNICODE_RX = re.compile(
170
+ r"[\u200b-\u200d\ufeff\u2060\u0370-\u03ff\u0400-\u04ff"
171
+ r"\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000\uff01-\uff5e]"
172
+ )
173
+ # A run of non-breaking or typographic spaces defeats a phrase rule as surely as
174
+ # a zero-width joiner, and full-width Latin defeats it while still reading as
175
+ # ordinary prose. Both are folded to ASCII for matching only; the draft the
176
+ # writer gets back keeps its original characters.
177
+ UNICODE_SPACE_RX = re.compile(r"[\u00a0\u1680\u2000-\u200a\u202f\u205f\u3000]")
178
+ FULLWIDTH_RX = re.compile(r"[\uff01-\uff5e]")
179
+ CJK_RX = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af]")
180
+ MIXED_SCRIPT_WORD_RX = re.compile(r"[A-Za-z\u0370-\u03ff\u0400-\u04ff]+")
181
+ LOOKALIKES = {
182
+ "а": "a", "е": "e", "о": "o", "р": "p", "с": "c", "х": "x",
183
+ "у": "y", "к": "k", "м": "m", "н": "h", "в": "b", "т": "t",
184
+ "А": "A", "Е": "E", "О": "O", "Р": "P", "С": "C", "Х": "X",
185
+ "У": "Y", "К": "K", "М": "M", "Н": "H", "В": "B", "Т": "T",
186
+ "ο": "o", "Ο": "O", "α": "a", "Α": "A", "ρ": "p", "Ρ": "P",
187
+ }
188
+
189
+
190
+ def normalize_for_detection(text):
191
+ """Return detector text plus a count of hidden/lookalike characters."""
192
+ # Smart punctuation and accented prose are common, but neither requires a
193
+ # word-by-word mixed-script pass. Stop after one fast search unless the
194
+ # text actually contains a hidden, Cyrillic, or Greek code point.
195
+ if text.isascii() or not SUSPICIOUS_UNICODE_RX.search(text):
196
+ return text, {"zero_width": 0, "homoglyphs": 0}
197
+ text, zero_width = ZERO_WIDTH_RX.subn("", text)
198
+ # Typographic spaces are ordinary in real prose, so they are folded for
199
+ # matching but never counted as evidence of tampering.
200
+ text = UNICODE_SPACE_RX.sub(" ", text)
201
+ fullwidth = 0
202
+ if FULLWIDTH_RX.search(text):
203
+ counts_as_evasion = not CJK_RX.search(text)
204
+ text, replaced = FULLWIDTH_RX.subn(
205
+ lambda m: chr(ord(m.group(0)) - 0xFEE0), text
206
+ )
207
+ # Full-width Latin inside CJK text is normal typography, not evasion.
208
+ if counts_as_evasion:
209
+ fullwidth = replaced
210
+ if not re.search(r"[\u0370-\u03ff\u0400-\u04ff]", text):
211
+ return text, {"zero_width": zero_width, "homoglyphs": fullwidth}
212
+ homoglyphs = 0
213
+
214
+ def mixed_word(match):
215
+ nonlocal homoglyphs
216
+ token = match.group(0)
217
+ if not re.search(r"[A-Za-z]", token):
218
+ return token
219
+ out = []
220
+ for char in token:
221
+ replacement = LOOKALIKES.get(char)
222
+ if replacement is not None:
223
+ homoglyphs += 1
224
+ out.append(replacement)
225
+ else:
226
+ out.append(char)
227
+ return "".join(out)
228
+
229
+ return MIXED_SCRIPT_WORD_RX.sub(mixed_word, text), {
230
+ "zero_width": zero_width,
231
+ "homoglyphs": homoglyphs + fullwidth,
232
+ }
233
+
158
234
 
159
235
  @functools.lru_cache(maxsize=1024)
160
236
  def _pattern_regex(rx, multiline):
@@ -162,6 +238,26 @@ def _pattern_regex(rx, multiline):
162
238
  return re.compile(rx, re.I | (re.M if multiline else 0))
163
239
 
164
240
 
241
+ def _pattern_plan(data):
242
+ """Compile and validate the current pattern layer once per loaded profile."""
243
+ cached = getattr(data, "_compiled_pattern_plan", None)
244
+ if cached is not None:
245
+ return cached
246
+ plan = []
247
+ for pattern in data["patterns"]:
248
+ hints = pattern.get("hints")
249
+ if not (isinstance(hints, list) and hints
250
+ and all(isinstance(hint, str) for hint in hints)):
251
+ hints = None
252
+ plan.append((pattern.get("w"), pattern["cat"], pattern["name"],
253
+ _pattern_regex(pattern["rx"], bool(pattern.get("m"))),
254
+ pattern["rx"].lower(), hints))
255
+ compiled = tuple(plan)
256
+ if isinstance(data, PatternData):
257
+ data._compiled_pattern_plan = compiled
258
+ return compiled
259
+
260
+
165
261
  @functools.lru_cache(maxsize=16)
166
262
  def _term_scan_plan(entries):
167
263
  """Build a bounded, reusable first-character index for term scanning.
@@ -242,7 +338,9 @@ def strip_noise(text):
242
338
  text = re.sub(
243
339
  r"https?://\S+",
244
340
  lambda m: " " + " ".join(re.findall(
245
- r"utm_source=(?:chatgpt(?:\.com)?|openai)", m.group(0), re.I
341
+ r"(?:utm_source=(?:chatgpt(?:\.com)?|openai(?:\.com)?|"
342
+ r"copilot(?:\.com)?|claude\.ai|perplexity\.ai|gemini\.google\.com)"
343
+ r"|referrer=grok\.com)", m.group(0), re.I
246
344
  )) + " ",
247
345
  text,
248
346
  )
@@ -312,25 +410,54 @@ def score_text(text, data, formal=False):
312
410
  if not isinstance(text, str):
313
411
  raise TypeError("text must be a string")
314
412
  raw = text
315
- text = strip_noise(text)
413
+ text, normalization = normalize_for_detection(strip_noise(text))
316
414
  words = WORD.findall(text)
317
415
  n_words = len(words)
318
416
  word_den = max(n_words, 1)
417
+ type_token_ratio = (len({word.casefold() for word in words}) / word_den
418
+ if n_words >= 200 else None)
319
419
  sent_spans = _sentence_spans(text)
320
420
  sents = [text[a:b].replace("\n", " ") for a, b in sent_spans]
321
421
  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"):
422
+ pattern_spans = [] # (start, end, lower-rx, compiled-rx) for dedup below
423
+
424
+ # One stray hidden character can come from a rich-text paste. A cluster is
425
+ # worth reporting, but the normalised wording is scanned at either count.
426
+ if normalization["zero_width"] + normalization["homoglyphs"] >= 2:
427
+ hits.append({
428
+ "cat": "artifact", "name": "normalization-bypass", "w": 5,
429
+ "quote": (f"{normalization['zero_width']} hidden and "
430
+ f"{normalization['homoglyphs']} lookalike characters"),
431
+ })
432
+ # The incumbent's published 1,654-paragraph provenance corpus gives this
433
+ # conservative long-form signal 22.46x machine/human lift (20/779 versus
434
+ # 1/875). Keep it weak and cluster-dependent: narrow vocabulary is normal
435
+ # in some technical writing and never convicts on its own.
436
+ if type_token_ratio is not None and type_token_ratio < 0.40:
437
+ hits.append({
438
+ "cat": "rhythm", "name": "low-word-variety", "w": 1.5,
439
+ "quote": f"{type_token_ratio:.0%} distinct words across {n_words} words",
440
+ })
441
+
442
+ # 1. Pattern tells (regex, weighted). A reviewed pattern may include literal
443
+ # hints that are guaranteed to cover every branch. They cheaply skip a full
444
+ # regex scan when none is present; patterns without that guarantee run as
445
+ # before.
446
+ lowercase_text = None
447
+ for weight, category, name, compiled, lower_rx, hints in _pattern_plan(data):
448
+ if not weight:
327
449
  continue
328
- for m in _pattern_regex(p["rx"], bool(p.get("m"))).finditer(text):
450
+ if hints:
451
+ if lowercase_text is None:
452
+ lowercase_text = text.lower()
453
+ if not any(hint in lowercase_text for hint in hints):
454
+ continue
455
+ for m in compiled.finditer(text):
329
456
  hits.append({
330
- "cat": p["cat"], "name": p["name"], "w": p["w"],
457
+ "cat": category, "name": name, "w": weight,
331
458
  "quote": m.group(0)[:90].strip(),
332
459
  })
333
- pattern_spans.append((m.start(), m.end(), p["rx"]))
460
+ pattern_spans.append((m.start(), m.end(), lower_rx, compiled))
334
461
 
335
462
  # 2. Lexicon. Two tiers, because context decides. Always-on terms
336
463
  # ("delve", "tapestry") almost never appear in honest prose. Rider terms
@@ -347,15 +474,15 @@ def score_text(text, data, formal=False):
347
474
  # lands inside another tell's span — a lexicon word inside a
348
475
  # rhetorical-structure match — still counts. Overlapping lexicon stems
349
476
  # ("game-chang", "game-changing") collapse to one hit the same way.
350
- claimed = _merge_spans([(s, e) for s, e, _ in pattern_spans])
477
+ claimed = _merge_spans([(s, e) for s, e, _, _ in pattern_spans])
351
478
 
352
479
  def _pattern_owns(span, term, matched):
353
480
  if not _span_covered(span, claimed):
354
481
  return False
355
482
  s, e = span
356
483
  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)
484
+ and (term in rx_lower or compiled.search(matched))
485
+ for ps, pe, rx_lower, compiled in pattern_spans)
359
486
 
360
487
  candidates = [candidate for candidate in _term_candidates(text, data["lexicon"])
361
488
  if not _pattern_owns(candidate[:2], candidate[2], candidate[4])]
@@ -495,6 +622,8 @@ def score_text(text, data, formal=False):
495
622
  "tell_density_per_100w": round(tell_density, 2),
496
623
  "n_words": n_words,
497
624
  "n_sentences": len(sents),
625
+ "type_token_ratio": (None if type_token_ratio is None
626
+ else round(type_token_ratio, 3)),
498
627
  "burstiness": round(burstiness, 3),
499
628
  "emdash_per_100w": round(emdash, 2),
500
629
  "emoji_count": emoji,
@@ -505,6 +634,7 @@ def score_text(text, data, formal=False):
505
634
  "poly_ratio": round(poly_ratio, 3),
506
635
  "comma_chain_frac": round(chain_frac, 3),
507
636
  "overlong_frac": round(overlong_frac, 3),
637
+ "normalization": normalization,
508
638
  "categories": cats,
509
639
  "hits": hits,
510
640
  }
@@ -690,7 +820,10 @@ CAT_MEANING = {
690
820
  "lexicon": ("overused AI-style word", "use the plain word"),
691
821
  "rider": ("buzzword used as promotion", "use the plain word, or drop the hype around it"),
692
822
  "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"),
823
+ # Covers both the negation-marked family ("it's not X, it's Y") and the
824
+ # bare balanced pairs added in v2.5.10 (isocolon, "This is what X looks
825
+ # like", "No X had to…; Y did"), which carry no negation marker at all.
826
+ "contrast": ("two-part contrast used as a formula", "state the claim once, plainly; at most one per piece"),
694
827
  "puffery": ("unearned significance", "state the fact, let the reader judge"),
695
828
  "drama": ("manufactured drama", "the fact should carry the weight"),
696
829
  "triads": ("rule of three", "two items, or one, or a real list"),
@@ -888,6 +1021,13 @@ def facts(text, _other=""):
888
1021
  # rewrite), so entity detection runs on the text with links removed.
889
1022
  urls = text # links keep their spelled forms; numbers in a slug are not facts
890
1023
  prose = _spell_to_digits(re.sub(r"https?://\S+", " ", text))
1024
+ # The first word in a prose-style Markdown heading is capitalised by
1025
+ # position, not necessarily a named entity ("## Private learning"). Keep
1026
+ # real multi-token title-case names such as "Basis Ventures" intact.
1027
+ prose = re.sub(
1028
+ r"(?m)^(#{1,6}\s+)([A-Z][a-z]{2,})(?=\s+(?![A-Z][a-z]+\b))",
1029
+ lambda m: m.group(1) + m.group(2).lower(), prose,
1030
+ )
891
1031
  # Ordered-list markers describe structure, not quantities. Treating the
892
1032
  # ``1.`` in a three-item list as a dropped fact penalises a faithful prose
893
1033
  # rewrite and hides real numeric changes in noise.
@@ -984,6 +1124,145 @@ def interior_claims(text):
984
1124
  return out
985
1125
 
986
1126
 
1127
+ # Exact or logical document structures that an editorial rewrite must not
1128
+ # silently alter. The content checks are intentionally narrow and deterministic;
1129
+ # the AI assistant still compares full meaning and format after this script.
1130
+ FENCED_CODE_RX = re.compile(
1131
+ r"(?ms)^(?:```|~~~)[^\n]*\n.*?^(?:```|~~~)[ \t]*$"
1132
+ )
1133
+ YAML_FRONTMATTER_RX = re.compile(r"\A---\n.*?\n---(?=\n|\Z)", re.S)
1134
+ INLINE_CODE_RX = re.compile(r"`[^`\n]+`")
1135
+ BLOCKQUOTE_LINE_RX = re.compile(r"^[ \t]*>[^\n]*$", re.M)
1136
+ HEADING_RX = re.compile(r"^(#{1,6})[ \t]+(.+?)[ \t]*$", re.M)
1137
+ PATH_RX = re.compile(
1138
+ r"(?<![\w:])((?:\.\.?/|/)[A-Za-z0-9._~\-]+"
1139
+ r"(?:/[A-Za-z0-9._~\-]+)*|[A-Za-z]:\\[A-Za-z0-9._\\~\-]+)"
1140
+ )
1141
+
1142
+
1143
+ def _mask_fenced(text):
1144
+ return FENCED_CODE_RX.sub(
1145
+ lambda match: re.sub(r"[^\n]", " ", match.group(0)), text
1146
+ )
1147
+
1148
+
1149
+ def _line_blocks(text, predicate):
1150
+ """Consecutive matching lines, without swallowing adjacent prose."""
1151
+ blocks, current = [], []
1152
+ for line in text.splitlines():
1153
+ if predicate(line):
1154
+ current.append(line)
1155
+ elif current:
1156
+ blocks.append("\n".join(current))
1157
+ current = []
1158
+ if current:
1159
+ blocks.append("\n".join(current))
1160
+ return blocks
1161
+
1162
+
1163
+ def _blockquote_blocks(text):
1164
+ return _line_blocks(text, lambda line: bool(re.match(r"^[ \t]*>", line)))
1165
+
1166
+
1167
+ def _normalize_blockquote(block):
1168
+ return "\n".join(
1169
+ re.sub(r"^[ \t]*>[ \t]?", "", line).rstrip()
1170
+ for line in block.splitlines()
1171
+ ).rstrip()
1172
+
1173
+
1174
+ def _table_blocks(text):
1175
+ blocks = _line_blocks(
1176
+ text,
1177
+ lambda line: bool(re.match(r"^[ \t]*\|.*\|[ \t]*$", line)),
1178
+ )
1179
+ return [block for block in blocks if len(block.splitlines()) >= 2]
1180
+
1181
+
1182
+ def _normalize_table(block):
1183
+ rows = []
1184
+ for line in block.splitlines():
1185
+ cells = [re.sub(r"\s+", " ", cell.strip())
1186
+ for cell in line.strip().strip("|").split("|")]
1187
+ if cells and all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
1188
+ cells = ["-" for _ in cells]
1189
+ rows.append("|".join(cells))
1190
+ return "\n".join(rows)
1191
+
1192
+
1193
+ def _missing_items(left, right):
1194
+ """Multiset subtraction: duplicate protected spans stay significant."""
1195
+ remaining = list(right)
1196
+ missing = []
1197
+ for item in left:
1198
+ try:
1199
+ remaining.remove(item)
1200
+ except ValueError:
1201
+ missing.append(item)
1202
+ return missing
1203
+
1204
+
1205
+ def structure_changes(before, after):
1206
+ """Blocking changes to code, reference blocks, paths, and hierarchy."""
1207
+ findings = []
1208
+
1209
+ def add(code, message, added=False):
1210
+ findings.append({"code": code, "message": message, "added": added})
1211
+
1212
+ original_code = FENCED_CODE_RX.findall(before)
1213
+ edited_code = FENCED_CODE_RX.findall(after)
1214
+ if len(original_code) != len(edited_code):
1215
+ add("code-block-count",
1216
+ f"fenced code block count changed: {len(original_code)} to {len(edited_code)}",
1217
+ len(edited_code) > len(original_code))
1218
+ elif any(left != right for left, right in zip(original_code, edited_code)):
1219
+ add("code-block-modified", "a fenced code block changed")
1220
+
1221
+ original_yaml = YAML_FRONTMATTER_RX.search(before)
1222
+ edited_yaml = YAML_FRONTMATTER_RX.search(after)
1223
+ original_yaml = original_yaml.group(0) if original_yaml else None
1224
+ edited_yaml = edited_yaml.group(0) if edited_yaml else None
1225
+ if original_yaml != edited_yaml:
1226
+ add("frontmatter-modified", "YAML front matter changed",
1227
+ original_yaml is None and edited_yaml is not None)
1228
+
1229
+ original_prose, edited_prose = _mask_fenced(before), _mask_fenced(after)
1230
+ # URL path segments are already checked as URLs and are not filesystem
1231
+ # paths. Mask them here so sentence punctuation cannot manufacture a path
1232
+ # mismatch ("https://acme.io/blog" versus the same link before a full stop).
1233
+ original_path_prose = re.sub(r"https?://\S+", " ", original_prose)
1234
+ edited_path_prose = re.sub(r"https?://\S+", " ", edited_prose)
1235
+ protected = [
1236
+ ("blockquote", [_normalize_blockquote(x) for x in _blockquote_blocks(original_prose)],
1237
+ [_normalize_blockquote(x) for x in _blockquote_blocks(edited_prose)]),
1238
+ ("table", [_normalize_table(x) for x in _table_blocks(original_prose)],
1239
+ [_normalize_table(x) for x in _table_blocks(edited_prose)]),
1240
+ ("inline-code", INLINE_CODE_RX.findall(before), INLINE_CODE_RX.findall(after)),
1241
+ ("path", PATH_RX.findall(original_path_prose), PATH_RX.findall(edited_path_prose)),
1242
+ ]
1243
+ for label, original, edited in protected:
1244
+ missing = _missing_items(original, edited)
1245
+ added = _missing_items(edited, original)
1246
+ if missing:
1247
+ code = f"{label}-missing" if label in {"inline-code", "path"} else f"{label}-modified"
1248
+ add(code, f"{len(missing)} {label} item(s) changed or disappeared")
1249
+ if added:
1250
+ add(f"{label}-added", f"{len(added)} new {label} item(s) appeared", True)
1251
+
1252
+ original_headings = [(len(markers), text) for markers, text
1253
+ in HEADING_RX.findall(before)]
1254
+ edited_headings = [(len(markers), text) for markers, text
1255
+ in HEADING_RX.findall(after)]
1256
+ if len(original_headings) != len(edited_headings):
1257
+ add("heading-count",
1258
+ f"heading count changed: {len(original_headings)} to {len(edited_headings)}",
1259
+ len(edited_headings) > len(original_headings))
1260
+ elif any(left[0] != right[0]
1261
+ for left, right in zip(original_headings, edited_headings)):
1262
+ add("heading-level", "heading hierarchy changed")
1263
+ return findings
1264
+
1265
+
987
1266
  def fidelity(before, after):
988
1267
  """Did the rewrite keep every fact, and did it add any?
989
1268
 
@@ -994,6 +1273,7 @@ def fidelity(before, after):
994
1273
  added one is not.
995
1274
  """
996
1275
  a, b = facts(before, after), facts(after, before)
1276
+ structure = structure_changes(before, after)
997
1277
  rows, kept_all, invented_any = [], True, False
998
1278
  def entity_tokens(entity):
999
1279
  return {w for w in re.findall(r"[a-z]+", entity.lower())
@@ -1032,8 +1312,11 @@ def fidelity(before, after):
1032
1312
  if new_interior:
1033
1313
  rows.append(("feeling", set(), set(), new_interior))
1034
1314
  invented_any = True
1315
+ if structure:
1316
+ kept_all = False
1317
+ invented_any = invented_any or any(row["added"] for row in structure)
1035
1318
  return {"rows": rows, "preserved": kept_all, "invented": invented_any,
1036
- "interior": new_interior}
1319
+ "interior": new_interior, "structure": structure}
1037
1320
 
1038
1321
 
1039
1322
  # The shared rewrite-quality objective. One definition of "a better rewrite",
@@ -1087,12 +1370,17 @@ def render_fidelity(before, after):
1087
1370
  if r.get("interior"):
1088
1371
  out.append(" the author never said these; an added feeling is still a "
1089
1372
  "fabrication")
1373
+ if r.get("structure"):
1374
+ out.append(" protected document content changed:")
1375
+ for finding in r["structure"][:8]:
1376
+ out.append(f" {finding['code']:<23} {finding['message']}")
1090
1377
  out += ["",
1091
1378
  " Result: " + ("facts preserved; nothing added"
1092
1379
  if r["preserved"] and not r["invented"] else
1093
- ("FACTS DROPPED" if not r["preserved"] else "")
1380
+ ("SOURCE CONTENT CHANGED" if not r["preserved"] else "")
1094
1381
  + (" · CONTENT INVENTED" if r["invented"] else "")),
1095
- " This checks figures, names, quotes, links, and stated feelings.",
1382
+ " This checks figures, names, quotes, links, stated feelings, code,",
1383
+ " front matter, tables, blockquotes, inline identifiers, paths, and headings.",
1096
1384
  " Your AI assistant still compares the full meaning because a changed claim",
1097
1385
  " or emphasis may use all the same names and numbers.", ""]
1098
1386
  return out
@@ -1326,7 +1614,8 @@ def main():
1326
1614
  else "not checked for this kind of writing")))
1327
1615
  print(" What Zero Slop checked: word choice, formatting, sentence rhythm, "
1328
1616
  "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"
1617
+ print(" What your AI assistant reviews: strength of the ideas, voice, factual accuracy, "
1618
+ "and whether the writing is performing rather than saying"
1330
1619
  + ("" if sh["measured"] else "; page layout was not checked"))
1331
1620
  if explain:
1332
1621
  if unique_hits:
@@ -1335,7 +1624,12 @@ def main():
1335
1624
  name, fix = CAT_MEANING.get(h["cat"], ("generic wording", "rewrite plainly"))
1336
1625
  print(f" {h['quote']!r} — {name}; {fix}")
1337
1626
  else:
1627
+ # A clean pattern channel is the case where the register pass matters
1628
+ # most, so this line must not read as "nothing left to do".
1338
1629
  print("\n Flagged phrases: none. The remaining score comes from sentence rhythm and formatting.")
1630
+ print(" This channel cannot see performed register — balanced two-part contrasts,")
1631
+ print(" epigram cadence, announced significance. Run the register pass before")
1632
+ print(" calling the draft clean.")
1339
1633
  if "--heatmap" in sys.argv or explain:
1340
1634
  for line in render_heatmap(text, data, formal=formal):
1341
1635
  print(line)