zero-slop 2.5.8 → 2.6.1
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.
- package/README.md +103 -121
- package/SKILL.md +160 -53
- package/data/corpus/performed-register/README.md +14 -4
- package/data/corpus/performed-register/judgment/bare-subject-swap.txt +1 -0
- package/data/corpus/performed-register/mechanical/candor-detail.txt +1 -0
- package/data/corpus/performed-register/mechanical/isocolon-adapt.txt +1 -0
- package/data/corpus/performed-register/mechanical/no-x-had-to.txt +1 -0
- package/data/corpus/performed-register/mechanical/this-is-what-looks-like.txt +1 -0
- package/data/learned-log.md +53 -0
- package/data/learned.json +70 -0
- package/data/patterns.json +29 -2
- package/package.json +1 -1
- package/references/copy-desk.md +6 -4
- package/references/evidence.md +22 -0
- package/references/fresh-eyes.md +62 -0
- package/references/overcorrection.md +5 -0
- package/references/readalong.md +22 -3
- package/references/tells.md +32 -7
- package/scripts/slopscore.py +313 -19
- /package/data/corpus/performed-register/{judgment → mechanical}/verdict-arithmetic.txt +0 -0
package/scripts/slopscore.py
CHANGED
|
@@ -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)"
|
|
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)
|
|
323
|
-
|
|
324
|
-
#
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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":
|
|
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(),
|
|
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
|
|
358
|
-
for ps, pe,
|
|
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
|
-
|
|
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
|
-
("
|
|
1380
|
+
("SOURCE CONTENT CHANGED" if not r["preserved"] else "")
|
|
1094
1381
|
+ (" · CONTENT INVENTED" if r["invented"] else "")),
|
|
1095
|
-
" This checks figures, names, quotes, links,
|
|
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,
|
|
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)
|
|
File without changes
|