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,1588 @@
1
+ #!/usr/bin/env python3
2
+ """learn — learn privately from changes a writer makes later.
3
+
4
+ The most honest training signal a linter can get is the edit a writer makes
5
+ *after* it hands back a rewrite. If the skill returns a draft and the writer
6
+ strips a phrase before publishing, that phrase was a tell the meter missed.
7
+ Unlike a benchmark, that label comes from the genre, the voice, and the model
8
+ generation the user actually faces.
9
+
10
+ python3 scripts/learn.py --reflect --produced out.md --shipped final.md
11
+ python3 scripts/learn.py --promote # mint patterns that cleared threshold
12
+ python3 scripts/learn.py --confirm drafts/ # re-earn weight on real slop
13
+ python3 scripts/learn.py --stats # the learning curve
14
+
15
+ Why an edit does not immediately become a pattern
16
+ -------------------------------------------------
17
+ A single diff cannot distinguish a stylistic tell from a writer cutting a
18
+ sentence for length. An early version of this script watched one writer delete
19
+ "the people is the standard we hold ourselves to" and proposed it as an AI
20
+ tell; it was just content. So `--reflect` only *records an observation*. A span
21
+ becomes a pattern when it has been cut in PROMOTE_AT content-distinct edit pairs.
22
+ Content hashing prevents filename or caller-ID duplication from earning extra votes;
23
+ it does not authenticate different writers, so learned rules remain private until a
24
+ maintainer reviews and tests an explicit export.
25
+
26
+ Three gates stand between an observation and a shipped pattern:
27
+
28
+ 1. Recurrence — seen in PROMOTE_AT content-distinct edit pairs (default 3).
29
+ 2. Novelty — not already scored by the existing meter.
30
+ 3. Safety — must not fire on data/corpus/must-not-flag/, the certified
31
+ human writing. This is the one that matters: a pattern
32
+ learned from one writer's edit must never start convicting
33
+ Lincoln, an SRE runbook, or a terse engineering note.
34
+ Learning that corrupts the meter is worse than not learning.
35
+
36
+ On regex brittleness
37
+ --------------------
38
+ Literal n-grams are brittle by construction: they miss inflection, insertions,
39
+ and next year's phrasing. Two mitigations here, and one architectural answer.
40
+ Generated patterns tolerate inflection (`moves/moved/moving the needle`) and
41
+ permit a short insertion for longer spans (`at the very end of the day`). The
42
+ architectural answer is that this pattern list is one channel of five — rhythm,
43
+ followability, formatting, and register are all phrasing-independent, and the
44
+ frequency-derived lexicon in calibrate.py generalizes where regexes cannot.
45
+ Patterns are the precise instrument for known constructions, never the whole
46
+ detector. See references/evidence.md.
47
+
48
+ Stdlib only, no network, no subprocess — same contract as the rest of the repo.
49
+ """
50
+ import argparse
51
+ import difflib
52
+ from functools import wraps
53
+ import hashlib
54
+ import json
55
+ import math
56
+ import os
57
+ import re
58
+ import sys
59
+ from datetime import date
60
+ from pathlib import Path
61
+
62
+ from safeio import atomic_write_text, file_locks, is_within
63
+
64
+ ROOT = Path(__file__).resolve().parent.parent
65
+ DATA = ROOT / "data"
66
+ CORPUS = DATA / "corpus" / "must-not-flag"
67
+ SHARED = DATA / "learned.json"
68
+ SHARED_LOG = DATA / "learned-log.md"
69
+
70
+ # Reflection evidence is derived from the user's own drafts, so it is private by
71
+ # default and lives outside the repository — a checkout should never carry one
72
+ # person's writing. Override with ZERO_SLOP_HOME to relocate or to share a
73
+ # deliberate team-scoped store.
74
+ HOME = Path(os.environ.get("ZERO_SLOP_HOME",
75
+ Path.home() / ".zero-slop")).expanduser()
76
+ OBS = HOME / "reflections.json"
77
+ LOCAL = HOME / "learned.json"
78
+ LOCAL_LOG = HOME / "learned-log.md"
79
+
80
+ # An edit has to be worth generalizing. One-word cuts are usually taste; very
81
+ # long ones are unique to the draft and would never fire twice.
82
+ MIN_WORDS, MAX_WORDS = 3, 9
83
+ # Content-distinct edit pairs a span must recur in before it earns a pattern.
84
+ PROMOTE_AT = 3
85
+ # Learned patterns start low; --confirm keeps current evidence from aging out.
86
+ START_WEIGHT = 2.5
87
+ DECAY_MONTHS = 18
88
+ # Suffixes stripped so a pattern matches inflected forms of the same construction.
89
+ SUFFIXES = ("ing", "ed", "es", "s")
90
+ CONTEXT = 34
91
+ # Single words are the lexicon's business, not the pattern list's. They are also
92
+ # riskier — one word convicts far more text than a six-word construction — so
93
+ # they need more corroboration before they count.
94
+ LEXICON_PROMOTE_AT = 5
95
+ LEXICON_MIN_LEN = 6
96
+ VOICE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
97
+ # Words too common to ever be a tell, whatever the diff says.
98
+ STOPWORDS = set("""about above after again against because been before being
99
+ between both cannot could during each from further having however itself more
100
+ most other over should some such than that their them then there these they
101
+ this those through under until very were what when where which while with
102
+ would your""".split())
103
+
104
+ # Editorial reasons are deliberately few and stable. They describe why a change
105
+ # helped; they are not model probabilities.
106
+ REASON_LABELS = {
107
+ "unspecified", "hollow_substance", "semantic_redundancy",
108
+ "vague_reference", "canned_framing", "genre_mismatch",
109
+ "local_repetition", "unsupported_attribution", "reader_process_leak",
110
+ "rhythm", "formatting", "grammar", "fidelity", "other",
111
+ }
112
+ GENRES = {
113
+ "general", "linkedin", "x", "email", "blog", "newsletter",
114
+ "research", "professional", "social",
115
+ }
116
+ MAX_RETRIEVAL_RESULTS = 20
117
+ MAX_RETRIEVAL_TEXT = 500000
118
+ MAX_RETRIEVAL_PREFERENCES = 50000
119
+ MAX_FEEDBACK_BYTES = 1024 * 1024
120
+
121
+
122
+ def validate_label(label, allowed, kind):
123
+ if label not in allowed:
124
+ raise SystemExit(f"unknown {kind} {label!r}; choose one of: "
125
+ + ", ".join(sorted(allowed)))
126
+ return label
127
+
128
+
129
+ def valid_counts(value, allowed, maximum):
130
+ return (isinstance(value, dict)
131
+ and all(isinstance(key, str) and key in allowed
132
+ and isinstance(count, int) and not isinstance(count, bool)
133
+ and count >= 0
134
+ for key, count in value.items())
135
+ and sum(value.values()) <= maximum)
136
+
137
+
138
+ def bump_count(record, field, label):
139
+ counts = record.setdefault(field, {})
140
+ counts[label] = counts.get(label, 0) + 1
141
+
142
+
143
+ def text_sha256(text):
144
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
145
+
146
+
147
+ def write_json(path, obj, *, private=False):
148
+ """Durably replace JSON; private observations are owner-readable only."""
149
+ atomic_write_text(path, json.dumps(obj, indent=1) + "\n",
150
+ mode=0o600 if private else None)
151
+
152
+
153
+ def state_locked(*path_providers):
154
+ """Serialize a read-modify-write operation across processes."""
155
+ def decorate(fn):
156
+ @wraps(fn)
157
+ def wrapped(*args, **kwargs):
158
+ paths = [p(*args, **kwargs) if callable(p) else p
159
+ for p in path_providers]
160
+ with file_locks(paths):
161
+ return fn(*args, **kwargs)
162
+ return wrapped
163
+ return decorate
164
+
165
+
166
+ def append_log(path, entry, *, private=False):
167
+ current = path.read_text() if path.exists() else ""
168
+ atomic_write_text(path, current + entry, mode=0o600 if private else None)
169
+
170
+
171
+ def empty_learned(scope="local"):
172
+ return {"_comment": f"Zero Slop {scope} learning overlay.",
173
+ "patterns": [], "lexicon": {}, "riders": {},
174
+ "fix_preferences": []}
175
+
176
+
177
+ def empty_observations():
178
+ return {"_comment": "Private reflect-loop evidence. Observations, false "
179
+ "positives, and recurring fixes need corroboration before "
180
+ "they change the live overlay.",
181
+ "observations": {}, "false_positives": {},
182
+ "lexicon_candidates": {}, "fix_observations": {}}
183
+
184
+
185
+ def learned_layers():
186
+ """Shared reviewed rules first, then the private live overlay."""
187
+ return (load_learned(SHARED, "shared"),
188
+ load_learned(LOCAL, "local"))
189
+
190
+
191
+ def safe_voice_name(name):
192
+ if not VOICE_NAME.fullmatch(name or "") or name in (".", ".."):
193
+ raise SystemExit(
194
+ "voice name must be 1-64 letters, digits, dots, underscores, or hyphens"
195
+ )
196
+ return name
197
+
198
+
199
+ def load(p, default=None):
200
+ p = Path(p)
201
+ if not p.exists() and default is not None:
202
+ return default
203
+ try:
204
+ return json.loads(p.read_text())
205
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError):
206
+ raise SystemExit(
207
+ f"{p} is not readable, valid JSON. Repair or restore it before re-running."
208
+ )
209
+
210
+
211
+ def load_learned(path, scope):
212
+ """Load an overlay without ever normalizing malformed state into a write."""
213
+ data = load(path, empty_learned(scope))
214
+ valid = (isinstance(data, dict)
215
+ and isinstance(data.get("patterns", []), list)
216
+ and isinstance(data.get("lexicon", {}), dict)
217
+ and isinstance(data.get("riders", {}), dict)
218
+ and isinstance(data.get("fix_preferences", []), list))
219
+ if valid:
220
+ names = set()
221
+ for pattern in data.get("patterns", []):
222
+ if (not isinstance(pattern, dict)
223
+ or not isinstance(pattern.get("name"), str)
224
+ or not 1 <= len(pattern["name"]) <= 128
225
+ or not isinstance(pattern.get("cat"), str)
226
+ or not 1 <= len(pattern["cat"]) <= 64
227
+ or not isinstance(pattern.get("rx"), str)
228
+ or len(pattern["rx"]) > 2000
229
+ or re.search(r"\\[1-9]|\(\?<*[=!]|\([^()]*[+*][^()]*\)[+*]",
230
+ pattern["rx"])
231
+ or not isinstance(pattern.get("w"), (int, float))
232
+ or isinstance(pattern.get("w"), bool)
233
+ or not math.isfinite(pattern["w"])
234
+ or not 0 <= pattern["w"] <= 10
235
+ or pattern["name"] in names):
236
+ valid = False
237
+ break
238
+ names.add(pattern["name"])
239
+ try:
240
+ re.compile(pattern["rx"])
241
+ except re.error:
242
+ valid = False
243
+ break
244
+ for key in ("first_seen", "last_confirmed", "decayed"):
245
+ if key in pattern and not isinstance(pattern[key], str):
246
+ valid = False
247
+ break
248
+ if ("confirmations" in pattern
249
+ and (not isinstance(pattern["confirmations"], int)
250
+ or isinstance(pattern["confirmations"], bool)
251
+ or pattern["confirmations"] < 0)):
252
+ valid = False
253
+ break
254
+ for key in ("source_span", "preferred_fix"):
255
+ if (key in pattern
256
+ and (not isinstance(pattern[key], str)
257
+ or not 1 <= len(pattern[key]) <= 500)):
258
+ valid = False
259
+ break
260
+ if ("fix_seen_in_docs" in pattern
261
+ and (not isinstance(pattern["fix_seen_in_docs"], int)
262
+ or isinstance(pattern["fix_seen_in_docs"], bool)
263
+ or pattern["fix_seen_in_docs"] < 0)):
264
+ valid = False
265
+ break
266
+ evidence_n = pattern.get("seen_in_docs", pattern.get("fix_seen_in_docs", 0))
267
+ if ("reasons" in pattern
268
+ and not valid_counts(pattern["reasons"], REASON_LABELS, evidence_n)):
269
+ valid = False
270
+ break
271
+ if ("genres" in pattern
272
+ and not valid_counts(pattern["genres"], GENRES, evidence_n)):
273
+ valid = False
274
+ break
275
+ for field in ("lexicon", "riders"):
276
+ for term, weight in data.get(field, {}).items():
277
+ if (not isinstance(term, str) or not 1 <= len(term) <= 80
278
+ or not isinstance(weight, (int, float))
279
+ or isinstance(weight, bool) or not math.isfinite(weight)
280
+ or not 0 <= weight <= 10):
281
+ valid = False
282
+ break
283
+ preference_spans = set()
284
+ for pref in data.get("fix_preferences", []):
285
+ if (not isinstance(pref, dict)
286
+ or not isinstance(pref.get("source_span"), str)
287
+ or not 1 <= len(pref.get("source_span", "")) <= 500
288
+ or not isinstance(pref.get("preferred_fix"), str)
289
+ or not 1 <= len(pref.get("preferred_fix", "")) <= 500
290
+ or not isinstance(pref.get("seen_in_pairs", 0), int)
291
+ or isinstance(pref.get("seen_in_pairs", 0), bool)
292
+ or pref.get("seen_in_pairs", 0) < 0
293
+ or ("active" in pref and not isinstance(pref["active"], bool))
294
+ or pref.get("source_span") in preference_spans
295
+ or ("reasons" in pref and not valid_counts(
296
+ pref["reasons"], REASON_LABELS, pref.get("seen_in_pairs", 0)))
297
+ or ("genres" in pref and not valid_counts(
298
+ pref["genres"], GENRES, pref.get("seen_in_pairs", 0)))
299
+ or any(key in pref and not isinstance(pref[key], str)
300
+ for key in ("first_seen", "last_confirmed", "decayed"))):
301
+ valid = False
302
+ break
303
+ preference_spans.add(pref["source_span"])
304
+ if not valid:
305
+ raise SystemExit(
306
+ f"{path} has an invalid learning-overlay schema; repair it before re-running."
307
+ )
308
+ return data
309
+
310
+
311
+ def load_observations():
312
+ """Load private evidence without silently repairing malformed structure."""
313
+ data = load(OBS, empty_observations())
314
+ fields = ("observations", "false_positives", "lexicon_candidates",
315
+ "fix_observations")
316
+ if not isinstance(data, dict) or any(
317
+ not isinstance(data.get(field, {}), dict) for field in fields):
318
+ raise SystemExit(f"{OBS} has an invalid reflection schema; repair it before re-running.")
319
+ key_limits = {"observations": 500, "false_positives": 128,
320
+ "lexicon_candidates": 80, "fix_observations": 500}
321
+ for field in fields:
322
+ for key, rec in data.get(field, {}).items():
323
+ if (not isinstance(key, str) or not key.strip()
324
+ or len(key) > key_limits[field]):
325
+ raise SystemExit(
326
+ f"{OBS} has an invalid reflection schema; repair it before re-running."
327
+ )
328
+ docs = rec.get("docs", []) if isinstance(rec, dict) else []
329
+ if (not isinstance(rec, dict)
330
+ or not isinstance(rec.get("count", 0), int)
331
+ or isinstance(rec.get("count", 0), bool)
332
+ or rec.get("count", 0) < 0
333
+ or not isinstance(docs, list)
334
+ or not all(isinstance(doc, str) and doc for doc in docs)
335
+ or len(docs) != len(set(docs))
336
+ or rec.get("count", 0) != len(docs)):
337
+ raise SystemExit(
338
+ f"{OBS} has an invalid reflection schema; repair it before re-running."
339
+ )
340
+ if ("reasons" in rec
341
+ and not valid_counts(rec["reasons"], REASON_LABELS, rec["count"])):
342
+ raise SystemExit(
343
+ f"{OBS} has an invalid reflection schema; repair it before re-running."
344
+ )
345
+ if ("genres" in rec
346
+ and not valid_counts(rec["genres"], GENRES, rec["count"])):
347
+ raise SystemExit(
348
+ f"{OBS} has an invalid reflection schema; repair it before re-running."
349
+ )
350
+ list_field = ("examples" if field == "observations" else
351
+ "quotes" if field == "false_positives" else None)
352
+ if (list_field and (not isinstance(rec.get(list_field), list)
353
+ or not all(isinstance(value, str)
354
+ for value in rec[list_field]))):
355
+ raise SystemExit(
356
+ f"{OBS} has an invalid reflection schema; repair it before re-running."
357
+ )
358
+ if field == "false_positives":
359
+ weight = rec.get("weight")
360
+ if (not isinstance(weight, (int, float))
361
+ or isinstance(weight, bool)
362
+ or not math.isfinite(weight)
363
+ or not 0 <= weight <= 10):
364
+ raise SystemExit(
365
+ f"{OBS} has an invalid reflection schema; repair it before re-running."
366
+ )
367
+ if field == "fix_observations":
368
+ applied_count = rec.get("applied_count", 0)
369
+ if (not isinstance(applied_count, int)
370
+ or isinstance(applied_count, bool)
371
+ or applied_count < 0
372
+ or applied_count > rec.get("count", 0)):
373
+ raise SystemExit(
374
+ f"{OBS} has an invalid reflection schema; repair it before re-running."
375
+ )
376
+ replacements = rec.get("replacements", {})
377
+ if not isinstance(replacements, dict):
378
+ raise SystemExit(
379
+ f"{OBS} has an invalid reflection schema; repair it before re-running."
380
+ )
381
+ for replacement, fix in replacements.items():
382
+ fix_docs = fix.get("docs", []) if isinstance(fix, dict) else []
383
+ if (not isinstance(fix, dict)
384
+ or not isinstance(replacement, str)
385
+ or not 1 <= len(replacement) <= 500
386
+ or not isinstance(fix.get("count", 0), int)
387
+ or isinstance(fix.get("count", 0), bool)
388
+ or fix.get("count", 0) < 0
389
+ or not isinstance(fix_docs, list)
390
+ or not all(isinstance(doc, str) and doc for doc in fix_docs)
391
+ or len(fix_docs) != len(set(fix_docs))
392
+ or fix.get("count", 0) != len(fix_docs)
393
+ or not set(fix_docs).issubset(set(docs))
394
+ or ("reasons" in fix and not valid_counts(
395
+ fix["reasons"], REASON_LABELS, fix.get("count", 0)))
396
+ or ("genres" in fix and not valid_counts(
397
+ fix["genres"], GENRES, fix.get("count", 0)))):
398
+ raise SystemExit(
399
+ f"{OBS} has an invalid reflection schema; repair it before re-running."
400
+ )
401
+ return data
402
+
403
+
404
+ def bounded_weight(value):
405
+ """Argparse type for weights that the scorer can load safely."""
406
+ try:
407
+ weight = float(value)
408
+ except (TypeError, ValueError) as exc:
409
+ raise argparse.ArgumentTypeError("weight must be a number from 0 to 10") from exc
410
+ if not math.isfinite(weight) or not 0 <= weight <= 10:
411
+ raise argparse.ArgumentTypeError("weight must be a finite number from 0 to 10")
412
+ return weight
413
+
414
+
415
+ def words(t):
416
+ return re.findall(r"\S+", t)
417
+
418
+
419
+ def norm(s):
420
+ """Lowercase, keeping the punctuation that carries meaning.
421
+
422
+ Apostrophes and hyphens are part of the token: stripping them turned
423
+ "it's worth pointing out" into a pattern for "its", which matched nothing —
424
+ and a regex that matches nothing passes the safety gate trivially, so the
425
+ gate reported success on a pattern that could never fire.
426
+ """
427
+ return re.sub(r"[^a-z0-9 '\u2019-]", "", s.lower()).strip()
428
+
429
+
430
+ def is_all_function_words(span):
431
+ """A span of nothing but grammar is not a style tell.
432
+
433
+ "for us", "over time", "in practice" all cleared recurrence, novelty and the
434
+ safety corpus, then shipped as AI tells and fired on ordinary sentences.
435
+ A construction has to carry at least one content word to be a construction.
436
+ """
437
+ toks = [t for t in norm(span).split() if t]
438
+ return not any(t not in STOPWORDS and len(t) > 3 for t in toks)
439
+
440
+
441
+ def is_content_specific(span):
442
+ """Reject spans that belong to one document rather than to a style.
443
+
444
+ Digits and mid-span proper nouns mean the writer cut a fact, not a tell.
445
+ Learning "raised 12M in Series B" as an AI pattern would be nonsense.
446
+ """
447
+ toks = span.split()
448
+ if re.search(r"\d", span):
449
+ return "contains a figure"
450
+ # every token, not toks[1:]: a cut starting at a sentence boundary
451
+ # was leaking brand names ("Acme really improved the flow")
452
+ caps = [w for w in toks if w[:1].isupper() and w.lower() not in STOPWORDS]
453
+ if caps:
454
+ return f"proper noun ({caps[0].strip('.,')})"
455
+ return None
456
+
457
+
458
+ def lexicon_candidates(produced, shipped):
459
+ """Single words the writer struck. These belong in the lexicon, not in a regex.
460
+
461
+ Why route them differently: a regex encodes one phrasing and dies when the
462
+ next model generation rephrases. A lexicon term is phrasing-independent — it
463
+ fires wherever the word appears — which is the channel that actually
464
+ generalizes across eras. The excess-frequency method in calibrate.py works
465
+ on exactly this representation, so a term learned here can later have its
466
+ weight re-derived from corpus statistics rather than guessed.
467
+ """
468
+ a, b = words(produced), words(shipped)
469
+ sm = difflib.SequenceMatcher(None, [norm(w) for w in a], [norm(w) for w in b])
470
+ out = []
471
+ for tag, i1, i2, j1, j2 in sm.get_opcodes():
472
+ if tag in ("delete", "replace"):
473
+ for w in a[i1:i2]:
474
+ n = norm(w)
475
+ if (len(n) >= LEXICON_MIN_LEN and n not in STOPWORDS
476
+ and not re.search(r"\d", n) and not w[:1].isupper()):
477
+ out.append(n)
478
+ return out
479
+
480
+
481
+ def stem(tok):
482
+ for s in SUFFIXES:
483
+ if len(tok) > len(s) + 3 and tok.endswith(s):
484
+ return tok[: -len(s)]
485
+ return tok
486
+
487
+
488
+ def to_regex(span):
489
+ """Inflection-tolerant, insertion-tolerant, word-bounded.
490
+
491
+ Content words match their own inflections; for spans of four or more tokens
492
+ a single short insertion is allowed, which is what turns "at the end of the
493
+ day" into something that also catches "at the very end of the day".
494
+ """
495
+ toks = norm(span).split()
496
+ parts = []
497
+ for tok in toks:
498
+ st = stem(tok)
499
+ body = re.escape(st) + r"\w{0,3}" if st != tok or len(tok) > 5 else re.escape(tok)
500
+ # straight and curly apostrophes are the same word; so are hyphen and space
501
+ body = body.replace(re.escape("'"), "['\u2019]").replace("\\-", "[-\\s]")
502
+ parts.append(body)
503
+ # One optional insertion across the whole span, not between every pair: with
504
+ # a gap at each join a 7-token pattern accepted six filler words and matched
505
+ # sentences it had nothing to do with.
506
+ if len(toks) >= 4:
507
+ joined = r"\s+".join(parts)
508
+ alts = [r"\s+".join(parts[:i] + [r"\w+"] + parts[i:]) for i in range(1, len(parts))]
509
+ return r"\b(?:" + "|".join([joined] + alts) + r")\b"
510
+ return r"\b" + r"\s+".join(parts) + r"\b"
511
+
512
+
513
+ def already_caught(span, pats, lex):
514
+ for p in pats:
515
+ try:
516
+ if re.search(p["rx"], span, re.I):
517
+ return p["name"]
518
+ except re.error:
519
+ continue
520
+ low = span.lower()
521
+ for term in lex:
522
+ if re.search(r"\b" + re.escape(term.lower()) + r"\w*", low):
523
+ return f"lexicon:{term}"
524
+ return None
525
+
526
+
527
+ # A learned span that borrows this many consecutive words from certified human
528
+ # writing is too close to it, even when the full pattern does not match.
529
+ OVERLAP_NGRAM = 4
530
+
531
+
532
+ def corpus_files():
533
+ return [f for f in sorted(CORPUS.rglob("*"))
534
+ if f.is_file() and f.suffix.lower() in (".txt", ".md")
535
+ and f.name.lower() != "readme.md"]
536
+
537
+
538
+ def required_text(path, label):
539
+ """Read user-supplied prose with a clean, path-specific failure."""
540
+ target = Path(path)
541
+ if not target.exists() or not target.is_file():
542
+ raise SystemExit(f"{label} is not a readable file: {target}")
543
+ try:
544
+ return target.read_text()
545
+ except (OSError, UnicodeDecodeError) as exc:
546
+ raise SystemExit(f"cannot read {label} at {target}: {exc}") from exc
547
+
548
+
549
+ def fp_gate(rx, span=None):
550
+ """Return the human sample this pattern endangers, or None if it is safe.
551
+
552
+ Two checks. The direct one asks whether the pattern fires on certified human
553
+ writing. The second asks whether the span *borrows* from it: a pattern like
554
+ "all men are created equal here" does not match the Gettysburg Address
555
+ literally, because of that trailing word, yet it is built almost entirely
556
+ out of it. Learning it would put the meter one small edit away from
557
+ convicting Lincoln, so overlap is disqualifying on its own.
558
+ """
559
+ try:
560
+ cre = re.compile(rx, re.I)
561
+ except re.error as e:
562
+ return f"regex error: {e}"
563
+ files = corpus_files()
564
+ if not files:
565
+ return "safety corpus missing or empty"
566
+ for f in files:
567
+ if cre.search(f.read_text()):
568
+ return f.name
569
+ if span:
570
+ toks = norm(span).split()
571
+ if len(toks) >= OVERLAP_NGRAM:
572
+ grams = {" ".join(toks[i:i + OVERLAP_NGRAM])
573
+ for i in range(len(toks) - OVERLAP_NGRAM + 1)}
574
+ for f in files:
575
+ body = " ".join(norm(f.read_text()).split())
576
+ for g in grams:
577
+ if g in body:
578
+ return f"{f.name} (borrows {g!r})"
579
+ return None
580
+
581
+
582
+ def survived_hits(produced, shipped, pats):
583
+ """Patterns that fired on the draft and whose text the writer KEPT anyway.
584
+
585
+ This is the other half of the loop, and the half that makes it safe. Learning
586
+ only from deletions gives a meter that can grow and never shrink, which ends
587
+ at a detector that flags everything. A span the meter convicted that a human
588
+ read, considered, and published unchanged is evidence the meter was wrong.
589
+ Enough of those and the weight comes down, or the sentence joins the
590
+ must-not-flag corpus so no future pattern can convict it either.
591
+ """
592
+ kept = shipped if isinstance(shipped, str) else ""
593
+ out = []
594
+ for p in pats:
595
+ try:
596
+ cre = re.compile(p["rx"], re.I)
597
+ except re.error:
598
+ continue
599
+ for m in cre.finditer(produced):
600
+ if m.group(0).lower() in kept.lower():
601
+ out.append({"pattern": p["name"], "weight": p.get("w"),
602
+ "quote": m.group(0)[:70]})
603
+ break
604
+ return out
605
+
606
+
607
+ def diff_spans(produced, shipped):
608
+ """Spans the skill emitted that the writer removed before publishing."""
609
+ a, b = words(produced), words(shipped)
610
+ sm = difflib.SequenceMatcher(None, [norm(w) for w in a], [norm(w) for w in b])
611
+ opcodes = sm.get_opcodes()
612
+
613
+ def sentence_replacement(src_start, src_end):
614
+ """Recover the edited sentence when SequenceMatcher reports a deletion.
615
+
616
+ A rewrite such as "This puts wood ... on latency for us" -> "Latency
617
+ dropped" is represented as a deletion, a one-word equality, and a
618
+ replacement. Looking only at the deletion loses the human fix. Bound the
619
+ lookup to the affected source sentence and collect its aligned target
620
+ words; recurrence gates still require the same fix in multiple
621
+ content-distinct edit pairs before it can become guidance.
622
+ """
623
+ sent_start = src_start
624
+ while sent_start > 0 and not re.search(r"[.!?][\"')\]]*$", a[sent_start - 1]):
625
+ sent_start -= 1
626
+ sent_end = src_end
627
+ while sent_end < len(a):
628
+ if re.search(r"[.!?][\"')\]]*$", a[sent_end]):
629
+ sent_end += 1
630
+ break
631
+ sent_end += 1
632
+ target_ranges = []
633
+ for _, oi1, oi2, oj1, oj2 in opcodes:
634
+ overlaps = oi1 < sent_end and oi2 > sent_start
635
+ insertion_inside = oi1 == oi2 and sent_start <= oi1 <= sent_end
636
+ if (overlaps or insertion_inside) and oj2 > oj1:
637
+ target_ranges.append((oj1, oj2))
638
+ if not target_ranges:
639
+ return ""
640
+ j1 = min(r[0] for r in target_ranges)
641
+ j2 = max(r[1] for r in target_ranges)
642
+ return " ".join(b[j1:j2])
643
+
644
+ out = []
645
+ for tag, i1, i2, j1, j2 in opcodes:
646
+ if tag in ("delete", "replace") and MIN_WORDS <= i2 - i1 <= MAX_WORDS:
647
+ replacement = " ".join(b[j1:j2]) if tag == "replace" else ""
648
+ if not replacement:
649
+ replacement = sentence_replacement(i1, i2)
650
+ out.append({
651
+ "span": " ".join(a[i1:i2]),
652
+ "before": " ".join(a[max(0, i1 - 5):i1])[-CONTEXT:],
653
+ "after": " ".join(a[i2:i2 + 5])[:CONTEXT],
654
+ "replaced_with": replacement,
655
+ })
656
+ return out
657
+
658
+
659
+ def load_edit_feedback(path, produced, shipped, diffs):
660
+ """Load optional per-edit reason labels bound to an exact before/after pair."""
661
+ source = Path(path)
662
+ if not source.is_file():
663
+ raise SystemExit(f"feedback is not a readable file: {source}")
664
+ try:
665
+ if source.stat().st_size > MAX_FEEDBACK_BYTES:
666
+ raise SystemExit(f"feedback exceeds {MAX_FEEDBACK_BYTES} bytes")
667
+ payload = json.loads(source.read_text())
668
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
669
+ raise SystemExit(f"feedback must be readable UTF-8 JSON: {exc}") from exc
670
+ if (not isinstance(payload, dict)
671
+ or set(payload) != {"schema", "source_sha256", "target_sha256", "edits"}
672
+ or payload.get("schema") != 1
673
+ or payload.get("source_sha256") != text_sha256(produced)
674
+ or payload.get("target_sha256") != text_sha256(shipped)
675
+ or not isinstance(payload.get("edits"), list)
676
+ or len(payload["edits"]) > 1000):
677
+ raise SystemExit("feedback schema or source/target hash is invalid")
678
+ available = {norm(item["span"]): item["span"] for item in diffs if norm(item["span"])}
679
+ labels = {}
680
+ for index, item in enumerate(payload["edits"], 1):
681
+ if (not isinstance(item, dict)
682
+ or set(item) != {"source_span", "reason", "genre"}
683
+ or not isinstance(item.get("source_span"), str)
684
+ or not 1 <= len(item["source_span"]) <= 500):
685
+ raise SystemExit(f"feedback edit {index} is malformed")
686
+ key = norm(item["source_span"])
687
+ if key not in available or key in labels:
688
+ raise SystemExit(f"feedback edit {index} has an unknown or duplicate source_span")
689
+ labels[key] = (
690
+ validate_label(item.get("reason"), REASON_LABELS, "reason label"),
691
+ validate_label(item.get("genre"), GENRES, "genre"),
692
+ )
693
+ return labels
694
+
695
+
696
+ @state_locked(lambda *a, **k: OBS)
697
+ def reflect(produced, shipped, doc_id=None, *, reason="unspecified", genre="general",
698
+ feedback=None):
699
+ reason = validate_label(reason, REASON_LABELS, "reason label")
700
+ genre = validate_label(genre, GENRES, "genre")
701
+ base = load(DATA / "patterns.json")
702
+ shared, local = learned_layers()
703
+ pats = (base["patterns"] + shared.get("patterns", [])
704
+ + local.get("patterns", []))
705
+ lex = (list(base.get("lexicon", {})) + list(base.get("riders", {}))
706
+ + list(shared.get("lexicon", {})) + list(shared.get("riders", {}))
707
+ + list(local.get("lexicon", {})) + list(local.get("riders", {})))
708
+ obs = load_observations()
709
+ obs.setdefault("false_positives", {})
710
+ obs.setdefault("fix_observations", {})
711
+
712
+ today = str(date.today())
713
+ prod_text = required_text(produced, "produced draft")
714
+ ship_text = required_text(shipped, "shipped draft")
715
+ # A vote is one unique edit pair, regardless of filenames or caller-supplied
716
+ # labels. Otherwise three copies of the same before/after text could cross
717
+ # the recurrence threshold by changing only --doc-id. The optional label is
718
+ # accepted for CLI compatibility but never grants another vote.
719
+ doc = hashlib.sha256(
720
+ (prod_text + "\0" + ship_text).encode()).hexdigest()[:16]
721
+ diffs = diff_spans(prod_text, ship_text)
722
+ edit_labels = (load_edit_feedback(feedback, prod_text, ship_text, diffs)
723
+ if feedback else {})
724
+ caught_words = {word for d in diffs if already_caught(d["span"], pats, lex)
725
+ for word in norm(d["span"]).split()}
726
+
727
+ obs.setdefault("lexicon_candidates", {})
728
+ base_lex = {k.lower() for k in base.get("lexicon", {})}
729
+ base_lex |= {k.lower() for k in base.get("riders", {})}
730
+ for layer in (shared, local):
731
+ base_lex |= {k.lower() for k in layer.get("lexicon", {})}
732
+ base_lex |= {k.lower() for k in layer.get("riders", {})}
733
+ for w in lexicon_candidates(prod_text, ship_text):
734
+ if w in caught_words:
735
+ continue # a narrower known phrase already catches it
736
+ if any(w.startswith(t_) or t_.startswith(w) for t_ in base_lex):
737
+ continue # the lexicon already speaks to this
738
+ rec = obs["lexicon_candidates"].setdefault(
739
+ w, {"count": 0, "docs": [], "first_seen": today})
740
+ if doc not in rec["docs"]:
741
+ rec["count"] += 1
742
+ rec["docs"].append(doc)
743
+ rec["last_seen"] = today
744
+ bump_count(rec, "reasons", reason)
745
+ bump_count(rec, "genres", genre)
746
+
747
+ # The meter was wrong here: it convicted text a human read and published.
748
+ fps = survived_hits(prod_text, ship_text, pats)
749
+ for h in fps:
750
+ rec = obs["false_positives"].setdefault(
751
+ h["pattern"], {"count": 0, "docs": [], "first_seen": today,
752
+ "weight": h["weight"], "quotes": []})
753
+ if doc in rec["docs"]:
754
+ continue
755
+ rec["count"] += 1
756
+ rec["docs"].append(doc)
757
+ rec["last_seen"] = today
758
+ if len(rec["quotes"]) < 3:
759
+ rec["quotes"].append(h["quote"])
760
+ recorded = skipped = agreed = fix_recorded = 0
761
+ fresh = []
762
+
763
+ for d in diffs:
764
+ key = norm(d["span"])
765
+ if not key:
766
+ continue
767
+ edit_reason, edit_genre = edit_labels.get(key, (reason, genre))
768
+ caught = already_caught(d["span"], pats, lex)
769
+ why = is_content_specific(d["span"]) or (
770
+ "all function words" if is_all_function_words(d["span"]) else None)
771
+ if caught:
772
+ agreed += 1
773
+ elif why:
774
+ skipped += 1
775
+ continue
776
+
777
+ # Learn recurring human fixes for both known tells and tells the meter
778
+ # missed. Keep this evidence separate from detector observations so an
779
+ # existing base pattern can still teach the rewrite pass. A number in a
780
+ # replacement is likely draft-specific fact, not reusable guidance.
781
+ replacement = norm(d.get("replaced_with", ""))
782
+ if (replacement and 1 <= len(replacement.split()) <= MAX_WORDS
783
+ and not re.search(r"\d", d.get("replaced_with", ""))):
784
+ frec = obs["fix_observations"].setdefault(
785
+ key, {"count": 0, "docs": [], "first_seen": today,
786
+ "last_seen": today, "replacements": {}})
787
+ choice = frec["replacements"].setdefault(
788
+ replacement, {"count": 0, "docs": []})
789
+ if doc not in choice["docs"]:
790
+ choice["docs"].append(doc)
791
+ choice["count"] += 1
792
+ bump_count(choice, "reasons", edit_reason)
793
+ bump_count(choice, "genres", edit_genre)
794
+ frec["count"] = len(set(frec["docs"] + [doc]))
795
+ if doc not in frec["docs"]:
796
+ frec["docs"].append(doc)
797
+ bump_count(frec, "reasons", edit_reason)
798
+ bump_count(frec, "genres", edit_genre)
799
+ frec["last_seen"] = today
800
+ fix_recorded += 1
801
+
802
+ if caught:
803
+ continue
804
+ rec = obs["observations"].setdefault(
805
+ key, {"count": 0, "docs": [], "first_seen": today,
806
+ "last_seen": today, "examples": []})
807
+ if doc in rec["docs"]:
808
+ continue # one vote per unique edit pair
809
+ rec["count"] += 1
810
+ rec["docs"].append(doc)
811
+ rec["last_seen"] = today
812
+ bump_count(rec, "reasons", edit_reason)
813
+ bump_count(rec, "genres", edit_genre)
814
+ if len(rec["examples"]) < 3:
815
+ rec["examples"].append(f"…{d['before']} [{d['span']}] {d['after']}…".strip())
816
+ recorded += 1
817
+ fresh.append((key, rec["count"]))
818
+
819
+ write_json(OBS, obs, private=True)
820
+
821
+ print(f"reflect: {Path(produced).name} → {Path(shipped).name}\n")
822
+ print(f" {agreed} edit(s) Zero Slop had already flagged — the writer agreed")
823
+ print(f" {skipped} content-specific cut(s) ignored (figures, proper nouns)")
824
+ print(f" {recorded} new phrase(s) saved for review")
825
+ print(f" {fix_recorded} repeated replacement(s) saved")
826
+ print(f" {len(fps)} flag(s) appeared in text the writer kept\n")
827
+ for h in fps:
828
+ rec = obs["false_positives"][h["pattern"]]
829
+ state = "REVIEW" if rec["count"] >= PROMOTE_AT else f"{rec['count']}/{PROMOTE_AT}"
830
+ print(f" ✗ {state:>6} {h['pattern']:24s} kept: {h['quote'][:34]!r}")
831
+ if fps:
832
+ print()
833
+ for key, n in fresh:
834
+ bar = "▮" * n + "▯" * max(0, PROMOTE_AT - n)
835
+ state = "READY" if n >= PROMOTE_AT else f"{n}/{PROMOTE_AT}"
836
+ print(f" {bar} {state:>5} {key[:52]!r}")
837
+ lex_ready = [(w, r["count"]) for w, r in obs["lexicon_candidates"].items()
838
+ if r["count"] >= LEXICON_PROMOTE_AT]
839
+ lex_pend = [(w, r["count"]) for w, r in obs["lexicon_candidates"].items()
840
+ if 0 < r["count"] < LEXICON_PROMOTE_AT]
841
+ if lex_ready or lex_pend:
842
+ print(" single words under review ("
843
+ f"{LEXICON_PROMOTE_AT} edit pairs needed):")
844
+ for w, n in sorted(lex_ready + lex_pend, key=lambda x: -x[1])[:8]:
845
+ state = "READY" if n >= LEXICON_PROMOTE_AT else f"{n}/{LEXICON_PROMOTE_AT}"
846
+ print(f" {state:>6} {w}")
847
+ print()
848
+ ready = [k for k, v in obs["observations"].items() if v["count"] >= PROMOTE_AT]
849
+ if ready:
850
+ print(f"\n {len(ready)} phrase(s) are ready. Run --promote --apply to "
851
+ "activate them locally, or use --auto-apply with --reflect.")
852
+ else:
853
+ print(f"\n nothing is ready yet. A phrase needs {PROMOTE_AT} "
854
+ f"content-distinct edit pairs.")
855
+ return 0
856
+
857
+
858
+ @state_locked(lambda *a, **k: OBS,
859
+ lambda *a, **k: LOCAL,
860
+ lambda *a, **k: LOCAL_LOG)
861
+ def promote(apply_, cat, weight):
862
+ """Mint safe, recurrent patterns into the private live overlay."""
863
+ obs = load_observations()
864
+ base = load(DATA / "patterns.json")
865
+ shared, learned = learned_layers()
866
+ pats = (base["patterns"] + shared.get("patterns", [])
867
+ + learned.get("patterns", []))
868
+ lex = (list(base.get("lexicon", {})) + list(base.get("riders", {}))
869
+ + list(shared.get("lexicon", {})) + list(shared.get("riders", {}))
870
+ + list(learned.get("lexicon", {})) + list(learned.get("riders", {})))
871
+ known = {p["name"] for p in pats}
872
+
873
+ eligible, blocked, dup = [], [], []
874
+ for key, rec in sorted(obs.get("observations", {}).items(),
875
+ key=lambda kv: -kv[1]["count"]):
876
+ if rec["count"] < PROMOTE_AT or rec.get("promoted"):
877
+ continue
878
+ rx = to_regex(key)
879
+ if already_caught(key, pats, lex):
880
+ dup.append((key, already_caught(key, pats, lex)))
881
+ continue
882
+ hit = fp_gate(rx, key)
883
+ (blocked if hit else eligible).append((key, rec, rx, hit))
884
+
885
+ print(f"promote: {len(eligible)} ready, {len(blocked)} blocked by the "
886
+ f"human-writing check, {len(dup)} already covered\n")
887
+ for key, hit in dup:
888
+ print(f" covered {key[:44]!r} ← {hit}")
889
+ for key, rec, rx, hit in blocked:
890
+ print(f" REJECTED {key[:44]!r}")
891
+ print(f" would flag {hit}; not learned at any threshold")
892
+ for key, rec, rx, _ in eligible:
893
+ print(f" ready {key[:44]!r} cut from {rec['count']} edit pairs")
894
+ print(f" rx {rx[:76]}")
895
+
896
+ # Single words enter as *riders*, never as always-on lexicon terms. A word
897
+ # like "robust" or "elevated" is ordinary technical vocabulary until a
898
+ # marketing register shares its sentence; shipping it always-on is how a
899
+ # meter starts convicting runbooks. Context-gated is the safe default, and
900
+ # a term only graduates to always-on if excess-frequency data from
901
+ # calibrate.py later justifies it.
902
+ lex_ready = [(w, r) for w, r in obs.get("lexicon_candidates", {}).items()
903
+ if r["count"] >= LEXICON_PROMOTE_AT and not r.get("promoted")]
904
+ lex_safe = []
905
+ for w, r in lex_ready:
906
+ hit = fp_gate(r"\b" + re.escape(w), None)
907
+ if hit:
908
+ print(f" REJECTED lexicon {w!r} appears in {hit}")
909
+ else:
910
+ lex_safe.append((w, r))
911
+ for w, r in lex_safe:
912
+ print(f" ready lexicon {w!r} cut from {r['count']} edit pairs "
913
+ f"-> rider (context-gated)")
914
+
915
+ fix_ready = []
916
+ for key, rec in sorted(obs.get("fix_observations", {}).items()):
917
+ choices = sorted(rec.get("replacements", {}).items(),
918
+ key=lambda item: (-item[1].get("count", 0), item[0]))
919
+ if not choices:
920
+ continue
921
+ top_fix, top = choices[0]
922
+ runner_up = choices[1][1].get("count", 0) if len(choices) > 1 else 0
923
+ if (top.get("count", 0) < PROMOTE_AT
924
+ or top.get("count", 0) <= runner_up
925
+ or top.get("count", 0) <= rec.get("applied_count", 0)):
926
+ continue
927
+ # A known detector rule has already cleared repository review. A missed
928
+ # tell must clear the same human-corpus gate before it can influence
929
+ # rewrite guidance.
930
+ if not already_caught(key, pats, lex) and fp_gate(to_regex(key), key):
931
+ continue
932
+ fix_ready.append((key, top_fix, top, rec))
933
+ print(f" ready fix {key[:32]!r} -> {top_fix[:32]!r} "
934
+ f"from {top['count']} edit pairs")
935
+
936
+ if not eligible and not lex_safe and not fix_ready:
937
+ return 0
938
+ if not apply_:
939
+ print(f"\n dry run. Re-run with --apply to mint {len(eligible)} pattern(s)"
940
+ f", {len(lex_safe)} rider(s), and {len(fix_ready)} fix preference(s).")
941
+ return 0
942
+
943
+ today = str(date.today())
944
+ added = []
945
+ for key, rec, rx, _ in eligible:
946
+ # A readable name is the author's phrase. Tracked files get a digest;
947
+ # the readable form stays in ~/.zero-slop/ where the author can see it.
948
+ stem_name = "learned-" + hashlib.sha256(key.encode()).hexdigest()[:10]
949
+ name, i = stem_name, 2
950
+ while name in known:
951
+ name, i = f"{stem_name}-{i}", i + 1
952
+ known.add(name)
953
+ pattern = {"name": name, "cat": cat, "rx": rx, "w": weight,
954
+ "first_seen": today, "last_confirmed": today,
955
+ "source": "reflect", "seen_in_docs": rec["count"],
956
+ "reasons": dict(sorted(rec.get("reasons", {}).items())),
957
+ "genres": dict(sorted(rec.get("genres", {}).items())),
958
+ "digest": hashlib.sha256(key.encode()).hexdigest()[:12],
959
+ # These readable fields stay in the private overlay and let
960
+ # the rewrite pass learn from repeated human replacements.
961
+ "source_span": key}
962
+ added.append(pattern)
963
+ rec["promoted"] = today
964
+ learned.setdefault("patterns", []).extend(added)
965
+ for w, r in lex_safe:
966
+ # half the pattern start-weight: a single word convicts far more
967
+ # text than a phrase, so it enters quieter and earns weight back
968
+ learned.setdefault("riders", {})[w] = round(START_WEIGHT / 2, 2)
969
+ r["promoted"] = today
970
+ preferences = {p["source_span"]: p
971
+ for p in learned.setdefault("fix_preferences", [])}
972
+ for key, preferred, evidence, rec in fix_ready:
973
+ pref = preferences.get(key)
974
+ if pref is None:
975
+ pref = {"source_span": key, "first_seen": rec.get("first_seen", today),
976
+ "source": "reflect"}
977
+ learned["fix_preferences"].append(pref)
978
+ preferences[key] = pref
979
+ pref.update(preferred_fix=preferred,
980
+ seen_in_pairs=evidence["count"],
981
+ reasons=dict(sorted(evidence.get("reasons", {}).items())),
982
+ genres=dict(sorted(evidence.get("genres", {}).items())),
983
+ last_confirmed=rec.get("last_seen", today), active=True)
984
+ pref.pop("decayed", None)
985
+ rec["applied_count"] = evidence["count"]
986
+ rec["promoted"] = today
987
+ write_json(LOCAL, learned, private=True)
988
+ write_json(OBS, obs, private=True)
989
+ entry = ""
990
+ if lex_safe:
991
+ entry += (f"\n- {today} — Reflect loop added {len(lex_safe)} "
992
+ f"context-gated rider(s) ({', '.join(w for w, _ in lex_safe)}) "
993
+ f"after each was struck from {LEXICON_PROMOTE_AT}+ edit pairs. "
994
+ f"Entered as riders, not always-on lexicon terms.\n")
995
+ if added:
996
+ entry += (f"\n- {today} — Reflect loop promoted {len(added)} pattern(s) "
997
+ f"after each was cut from {PROMOTE_AT}+ content-distinct edit pairs "
998
+ f"({', '.join(a['name'] for a in added)}); "
999
+ f"{len(blocked)} rejected by the human-writing check. "
1000
+ f"Source documents are not recorded: learning records stay "
1001
+ f"on the machine that produced it.\n")
1002
+ if fix_ready:
1003
+ entry += (f"\n- {today} — Reflect loop activated or reconfirmed "
1004
+ f"{len(fix_ready)} private rewrite preference(s) after the same "
1005
+ f"replacement recurred in {PROMOTE_AT}+ edit pairs.\n")
1006
+ append_log(LOCAL_LOG, entry, private=True)
1007
+ print(f"\n activated {len(added)} pattern(s) and "
1008
+ f"{len(fix_ready)} fix preference(s) in {LOCAL}")
1009
+ print(" Zero Slop will use this private settings file on its next run")
1010
+ print(" run: python3 scripts/calibrate.py --selftest")
1011
+ return 0
1012
+
1013
+
1014
+ @state_locked(lambda *a, **k: OBS,
1015
+ lambda *a, **k: LOCAL,
1016
+ lambda *a, **k: LOCAL_LOG)
1017
+ def demote(apply_):
1018
+ """Act on false-positive evidence: lower the weight of patterns humans overrule.
1019
+
1020
+ A pattern that repeatedly convicts text writers then publish unchanged is
1021
+ measuring the tool's taste, not the reader's. Halving its weight is the
1022
+ conservative move; a base pattern is never edited in place, it gets a
1023
+ lower-weighted override in learned.json, which keeps the base taxonomy
1024
+ auditable and the change reversible.
1025
+ """
1026
+ obs = load_observations()
1027
+ base = load(DATA / "patterns.json")
1028
+ shared, learned = learned_layers()
1029
+ base_by = {p["name"]: p for p in base["patterns"] + shared.get("patterns", [])}
1030
+ learned_by = {p["name"]: p for p in learned.get("patterns", [])}
1031
+
1032
+ all_due = [(n, r) for n, r in obs.get("false_positives", {}).items()
1033
+ if r["count"] >= PROMOTE_AT and not r.get("demoted")]
1034
+ due = [(n, r) for n, r in all_due if n in learned_by or n in base_by]
1035
+ retired = [n for n, _ in all_due if n not in learned_by and n not in base_by]
1036
+ if retired:
1037
+ print(f"demote: ignored {len(retired)} observation(s) for rules no longer installed")
1038
+ if not due:
1039
+ pend = len(obs.get("false_positives", {}))
1040
+ print(f"demote: nothing at threshold ({pend} pattern(s) under observation, "
1041
+ f"{PROMOTE_AT} content-distinct kept instances needed)")
1042
+ return 0
1043
+ print(f"demote: {len(due)} pattern(s) overruled by writers {PROMOTE_AT}+ times\n")
1044
+ for n, r in due:
1045
+ cur = learned_by.get(n, base_by.get(n, {})).get("w", r.get("weight", 0))
1046
+ print(f" {n:26s} w {cur} -> {round(cur/2, 2)} kept in {r['count']} docs")
1047
+ for q in r["quotes"][:2]:
1048
+ print(f" writer published: {q!r}")
1049
+ if not apply_:
1050
+ print("\n dry run. Re-run with --apply to lower these weights.")
1051
+ return 0
1052
+ today = str(date.today())
1053
+ for n, r in due:
1054
+ cur = learned_by.get(n, base_by.get(n, {})).get("w", r.get("weight", 2))
1055
+ new_w = round(cur / 2, 2)
1056
+ if n in learned_by:
1057
+ learned_by[n]["w"] = new_w
1058
+ learned_by[n]["demoted"] = today
1059
+ else:
1060
+ src = dict(base_by[n]); src["w"] = new_w
1061
+ src["demoted"] = today; src["source"] = "reflect-fp"
1062
+ learned.setdefault("patterns", []).append(src)
1063
+ r["demoted"] = today
1064
+ write_json(LOCAL, learned, private=True)
1065
+ write_json(OBS, obs, private=True)
1066
+ append_log(LOCAL_LOG,
1067
+ f"\n- {today} — Reflect loop lowered {len(due)} pattern weight(s) "
1068
+ f"after writers published the flagged text unchanged in "
1069
+ f"{PROMOTE_AT}+ content-distinct edit pairs ({', '.join(n for n, _ in due)}).\n",
1070
+ private=True)
1071
+ print(f"\n lowered {len(due)} weight(s) in {LOCAL}")
1072
+ return 0
1073
+
1074
+
1075
+ def export(out, yes):
1076
+ """Package what was learned for upstream, carrying evidence but never text.
1077
+
1078
+ A span only becomes exportable once it has been cut from PROMOTE_AT
1079
+ content-distinct edit pairs. The payload omits context, filenames, paths,
1080
+ authors, and precise dates, but the learned span itself is still user prose.
1081
+ That is why export prints the complete payload and requires explicit --yes;
1082
+ recurrence is a quality gate, not a privacy guarantee or proof of authorship.
1083
+
1084
+ What ships is the same shape as data/learned.json, so a maintainer can read
1085
+ the diff before merging. The user reviews the exact payload here first, and
1086
+ nothing is written without --yes.
1087
+ """
1088
+ obs = load_observations()
1089
+ payload = {"_comment": "Zero Slop reflect-loop contribution. Contains only "
1090
+ "spans observed in " + str(PROMOTE_AT) + "+ content-distinct "
1091
+ "edit pairs, with no context, filenames or dates finer "
1092
+ "than a month. Spans remain user prose; review before sharing.",
1093
+ "schema": 1, "promote_at": PROMOTE_AT,
1094
+ "spans": [], "false_positives": []}
1095
+ for key, rec in sorted(obs.get("observations", {}).items(),
1096
+ key=lambda kv: -kv[1]["count"]):
1097
+ if rec["count"] < PROMOTE_AT:
1098
+ continue
1099
+ rx = to_regex(key)
1100
+ if fp_gate(rx, key):
1101
+ continue # never ship a pattern that endangers the corpus
1102
+ payload["spans"].append({"span": key, "rx": rx, "documents": rec["count"],
1103
+ "month": rec.get("first_seen", "")[:7]})
1104
+ for name, rec in sorted(obs.get("false_positives", {}).items(),
1105
+ key=lambda kv: -kv[1]["count"]):
1106
+ if rec["count"] >= PROMOTE_AT:
1107
+ payload["false_positives"].append(
1108
+ {"pattern": name, "kept_in_documents": rec["count"],
1109
+ "month": rec.get("first_seen", "")[:7]})
1110
+
1111
+ n = len(payload["spans"]) + len(payload["false_positives"])
1112
+ if not n:
1113
+ print(f"export: nothing has reached the {PROMOTE_AT}-document threshold yet. "
1114
+ f"Nothing below it is shareable, by design.")
1115
+ return 0
1116
+ print("export: this is the complete contents of the contribution.\n")
1117
+ print(json.dumps(payload, indent=1))
1118
+ print(f"\n {len(payload['spans'])} span(s), "
1119
+ f"{len(payload['false_positives'])} false-positive report(s).")
1120
+ print(" Short recurrent spans only. No surrounding context, filenames, author,\n"
1121
+ " or dates finer than a month. The spans are still user prose; read the\n"
1122
+ " complete payload above before deciding whether to share it.")
1123
+ if not yes:
1124
+ print(f"\n Nothing written. Re-run with --yes --out {out} to save it, "
1125
+ f"then attach that file to a pull request.")
1126
+ return 0
1127
+ dest = Path(out).resolve()
1128
+ if not is_within(dest, Path.cwd()):
1129
+ raise SystemExit(f"refusing to write outside the working directory: {dest}")
1130
+ if DATA.resolve() in dest.parents:
1131
+ raise SystemExit(f"refusing to write into data/: {dest}")
1132
+ if dest.exists():
1133
+ raise SystemExit(f"{dest} exists; choose another --out")
1134
+ atomic_write_text(dest, json.dumps(payload, indent=1) + "\n", mode=0o600)
1135
+ print(f"\n wrote {out}. Review it once more, then open a PR against "
1136
+ f"data/learned.json.")
1137
+ return 0
1138
+
1139
+
1140
+ @state_locked(lambda *a, **k: SHARED,
1141
+ lambda *a, **k: SHARED_LOG)
1142
+ def merge(path, apply_, cat, weight):
1143
+ """Maintainer side: fold a reviewed contribution into the shared taxonomy.
1144
+
1145
+ Contributions are untrusted input. Every span is re-gated locally against
1146
+ this checkout's corpus rather than trusting the sender's claim, because the
1147
+ contributor's corpus may be older, smaller, or edited.
1148
+ """
1149
+ c = load(path)
1150
+ if not isinstance(c, dict):
1151
+ print("merge: contribution must be a JSON object")
1152
+ return 1
1153
+ if c.get("schema") != 1:
1154
+ print(f"merge: unrecognised contribution schema {c.get('schema')!r}")
1155
+ return 1
1156
+ if not isinstance(c.get("spans", []), list):
1157
+ print("merge: spans must be a list")
1158
+ return 1
1159
+ if not isinstance(c.get("false_positives", []), list):
1160
+ print("merge: false_positives must be a list")
1161
+ return 1
1162
+ seen_false_positives = set()
1163
+ for fp in c.get("false_positives", []):
1164
+ if (not isinstance(fp, dict) or not isinstance(fp.get("pattern"), str)
1165
+ or not 1 <= len(fp["pattern"]) <= 128
1166
+ or not isinstance(fp.get("kept_in_documents"), int)
1167
+ or isinstance(fp.get("kept_in_documents"), bool)
1168
+ or fp["kept_in_documents"] < 0
1169
+ or fp["pattern"] in seen_false_positives):
1170
+ print("merge: false_positives contains a malformed or duplicate entry")
1171
+ return 1
1172
+ seen_false_positives.add(fp["pattern"])
1173
+ base, learned = load(DATA / "patterns.json"), load_learned(SHARED, "shared")
1174
+ known = {p["name"] for p in base["patterns"] + learned.get("patterns", [])}
1175
+ lex = list(base.get("lexicon", {})) + list(learned.get("lexicon", {}))
1176
+ pats = base["patterns"] + learned.get("patterns", [])
1177
+
1178
+ accept, reject = [], []
1179
+ seen_spans = set()
1180
+ for s in c.get("spans", []):
1181
+ if not isinstance(s, dict) or not isinstance(s.get("span"), str):
1182
+ reject.append((str(s)[:40], "malformed entry")); continue
1183
+ if len(s["span"]) > 500:
1184
+ reject.append((s["span"][:40], "span exceeds 500 characters")); continue
1185
+ n = len(norm(s["span"]).split())
1186
+ if not (MIN_WORDS <= n <= MAX_WORDS):
1187
+ reject.append((s["span"], f"{n} words, outside {MIN_WORDS}-{MAX_WORDS}")); continue
1188
+ documents = s.get("documents")
1189
+ if (not isinstance(documents, int) or isinstance(documents, bool)
1190
+ or documents < 0):
1191
+ reject.append((s["span"], "documents must be a non-negative integer")); continue
1192
+ normalized = norm(s["span"])
1193
+ if normalized in seen_spans:
1194
+ reject.append((s["span"], "duplicate span in contribution")); continue
1195
+ seen_spans.add(normalized)
1196
+ # Never trust a contributed regex: rebuild it from the bounded span
1197
+ # locally, so crafted input cannot smuggle catastrophic backtracking
1198
+ # into the meter or the safety-corpus scan.
1199
+ s["rx"] = to_regex(s["span"])
1200
+ if is_content_specific(s["span"]) or is_all_function_words(s["span"]):
1201
+ reject.append((s["span"], "content-specific or all function words")); continue
1202
+ if s.get("documents", 0) < PROMOTE_AT:
1203
+ reject.append((s["span"], f"below threshold ({s.get('documents')})"))
1204
+ elif already_caught(s["span"], pats, lex):
1205
+ reject.append((s["span"], "already covered"))
1206
+ elif fp_gate(s["rx"], s["span"]):
1207
+ reject.append((s["span"], f"unsafe here: {fp_gate(s['rx'], s['span'])}"))
1208
+ else:
1209
+ accept.append(s)
1210
+ print(f"merge: {len(accept)} accepted, {len(reject)} rejected\n")
1211
+ for span, why in reject:
1212
+ print(f" reject {span[:44]!r} {why}")
1213
+ for s in accept:
1214
+ print(f" accept {s['span'][:44]!r} seen in {s['documents']} edit pairs")
1215
+ for fp in c.get("false_positives", []):
1216
+ print(f" note writers kept text flagged by {fp['pattern']!r} "
1217
+ f"in {fp['kept_in_documents']} edit pairs")
1218
+ if not accept or not apply_:
1219
+ if accept:
1220
+ print(f"\n dry run. Re-run with --apply to add {len(accept)} pattern(s).")
1221
+ return 0
1222
+ today = str(date.today())
1223
+ added = []
1224
+ for s in accept:
1225
+ stem_name = "contrib-" + hashlib.sha256(s["span"].encode()).hexdigest()[:10]
1226
+ name, i = stem_name, 2
1227
+ while name in known:
1228
+ name, i = f"{stem_name}-{i}", i + 1
1229
+ known.add(name)
1230
+ added.append({"name": name, "cat": cat, "rx": s["rx"], "w": weight,
1231
+ "first_seen": today, "last_confirmed": today,
1232
+ "source": "contributed", "seen_in_docs": s["documents"],
1233
+ "digest": hashlib.sha256(s["span"].encode()).hexdigest()[:12]})
1234
+ learned.setdefault("patterns", []).extend(added)
1235
+ write_json(SHARED, learned)
1236
+ append_log(SHARED_LOG,
1237
+ f"\n- {today} — Merged a reflect-loop contribution: "
1238
+ f"{len(added)} pattern(s) ({', '.join(a['name'] for a in added)}), "
1239
+ f"{len(reject)} rejected on re-gating against this corpus.\n")
1240
+ print(f"\n merged {len(added)} pattern(s). Run: "
1241
+ f"python3 scripts/calibrate.py --selftest")
1242
+ return 0
1243
+
1244
+
1245
+ @state_locked(lambda *a, **k: LOCAL)
1246
+ def confirm(target):
1247
+ """Refresh evidence. Patterns that keep firing stay; the rest decay out."""
1248
+ p = LOCAL
1249
+ d = load_learned(p, "local")
1250
+ t = Path(target)
1251
+ if not t.exists():
1252
+ raise SystemExit(f"confirmation path does not exist: {t}")
1253
+ files = [t] if t.is_file() else sorted(
1254
+ f for f in t.rglob("*") if f.is_file() and f.suffix.lower() in (".md", ".txt"))
1255
+ if not files:
1256
+ raise SystemExit(f"confirmation path contains no .md or .txt files: {t}")
1257
+ try:
1258
+ text = "\n".join(f.read_text() for f in files)
1259
+ except (OSError, UnicodeDecodeError) as exc:
1260
+ raise SystemExit(f"cannot read confirmation corpus at {t}: {exc}") from exc
1261
+ if not text.strip():
1262
+ raise SystemExit(f"confirmation corpus contains no text: {t}")
1263
+ today, n = str(date.today()), 0
1264
+ for pat in d.get("patterns", []):
1265
+ try:
1266
+ if re.search(pat["rx"], text, re.I):
1267
+ pat["last_confirmed"] = today
1268
+ pat["confirmations"] = pat.get("confirmations", 0) + 1
1269
+ pat.pop("decayed", None)
1270
+ n += 1
1271
+ except re.error:
1272
+ continue
1273
+ write_json(p, d, private=True)
1274
+ print(f"confirmed {n}/{len(d.get('patterns', []))} learned pattern(s) "
1275
+ f"against {len(files)} file(s)")
1276
+ return 0
1277
+
1278
+
1279
+ @state_locked(lambda *a, **k: LOCAL)
1280
+ def decay_local():
1281
+ """Reduce stale detector rules and retire stale rewrite preferences."""
1282
+ learned = load_learned(LOCAL, "local")
1283
+ today, changed, fixes_changed = date.today(), 0, 0
1284
+ for pat in learned.get("patterns", []):
1285
+ confirmed = pat.get("last_confirmed")
1286
+ if not confirmed:
1287
+ continue
1288
+ try:
1289
+ year, month, _ = (int(x) for x in confirmed.split("-"))
1290
+ except (TypeError, ValueError):
1291
+ continue
1292
+ age = (today.year - year) * 12 + today.month - month
1293
+ if (age > DECAY_MONTHS and pat.get("w", 0) > 0.5
1294
+ and not pat.get("decayed")):
1295
+ pat["w"] = round(pat["w"] / 2, 2)
1296
+ pat["decayed"] = str(today)
1297
+ changed += 1
1298
+ for pref in learned.get("fix_preferences", []):
1299
+ confirmed = pref.get("last_confirmed")
1300
+ if not confirmed or not pref.get("active", True):
1301
+ continue
1302
+ try:
1303
+ year, month, _ = (int(x) for x in confirmed.split("-"))
1304
+ except (AttributeError, TypeError, ValueError):
1305
+ continue
1306
+ age = (today.year - year) * 12 + today.month - month
1307
+ if age > DECAY_MONTHS:
1308
+ pref["active"] = False
1309
+ pref["decayed"] = str(today)
1310
+ fixes_changed += 1
1311
+ if changed or fixes_changed or LOCAL.exists():
1312
+ write_json(LOCAL, learned, private=True)
1313
+ print(f"decayed {changed} local pattern(s) and retired {fixes_changed} "
1314
+ f"rewrite preference(s) unconfirmed for over {DECAY_MONTHS} months")
1315
+ return 0
1316
+
1317
+
1318
+ def all_preferences(learned):
1319
+ """Read current and prerelease preference shapes without mutating either."""
1320
+ preference_count = (len(learned.get("fix_preferences", []))
1321
+ + len(learned.get("patterns", [])))
1322
+ if preference_count > MAX_RETRIEVAL_PREFERENCES:
1323
+ raise SystemExit(
1324
+ f"learning overlay exceeds the {MAX_RETRIEVAL_PREFERENCES}-record "
1325
+ "retrieval limit; archive or review stale evidence first"
1326
+ )
1327
+ rows = [{"when": p["source_span"], "prefer": p["preferred_fix"],
1328
+ "edit_pairs": p.get("seen_in_pairs", 0),
1329
+ "reasons": p.get("reasons", {}), "genres": p.get("genres", {})}
1330
+ for p in learned.get("fix_preferences", [])
1331
+ if p.get("active", True) and p.get("source_span")
1332
+ and p.get("preferred_fix")]
1333
+ seen = {row["when"] for row in rows}
1334
+ rows.extend({"when": p["source_span"], "prefer": p["preferred_fix"],
1335
+ "edit_pairs": p.get("fix_seen_in_docs", 0),
1336
+ "reasons": p.get("reasons", {}), "genres": p.get("genres", {})}
1337
+ for p in learned.get("patterns", [])
1338
+ if p.get("source_span") not in seen and p.get("preferred_fix"))
1339
+ return rows
1340
+
1341
+
1342
+ def retrieval_tokens(text):
1343
+ return {token for token in norm(text).split()
1344
+ if (len(token) > 2 or token.isdigit()) and token not in STOPWORDS}
1345
+
1346
+
1347
+ def retrieve_preferences(text, reason=None, genre=None, limit=5, learned=None):
1348
+ """Retrieve relevant local fixes with a deterministic lexical rank.
1349
+
1350
+ Similarity is source-token coverage, not a probability. A result must share
1351
+ at least half of its content-bearing source tokens with the current draft.
1352
+ Reason and genre labels narrow and rerank that evidence; they never permit a
1353
+ lexically unrelated replacement to surface.
1354
+ """
1355
+ if not isinstance(text, str) or not text.strip():
1356
+ return []
1357
+ if len(text) > MAX_RETRIEVAL_TEXT:
1358
+ raise SystemExit(f"retrieval input exceeds {MAX_RETRIEVAL_TEXT} characters")
1359
+ if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= MAX_RETRIEVAL_RESULTS:
1360
+ raise SystemExit(f"retrieval limit must be 1-{MAX_RETRIEVAL_RESULTS}")
1361
+ if reason is not None:
1362
+ validate_label(reason, REASON_LABELS, "reason label")
1363
+ if genre is not None:
1364
+ validate_label(genre, GENRES, "genre")
1365
+ if learned is None:
1366
+ learned = load_learned(LOCAL, "local")
1367
+ query_norm = " ".join(norm(text).split())
1368
+ query_tokens = retrieval_tokens(text)
1369
+ ranked = []
1370
+ for row in all_preferences(learned):
1371
+ source_norm = " ".join(norm(row["when"]).split())
1372
+ source_tokens = retrieval_tokens(row["when"])
1373
+ if not source_tokens:
1374
+ continue
1375
+ exact = bool(source_norm and re.search(
1376
+ r"(?<!\w)" + re.escape(source_norm) + r"(?!\w)", query_norm
1377
+ ))
1378
+ similarity = 1.0 if exact else len(source_tokens & query_tokens) / len(source_tokens)
1379
+ if similarity < 0.5:
1380
+ continue
1381
+ reasons, genres = row.get("reasons", {}), row.get("genres", {})
1382
+ if reason is not None and reasons and reason not in reasons:
1383
+ continue
1384
+ if genre is not None and genres and genre not in genres:
1385
+ continue
1386
+ reason_match = bool(reason is not None and reasons.get(reason, 0))
1387
+ genre_match = bool(genre is not None and genres.get(genre, 0))
1388
+ recurrence = min(0.05, 0.01 * row.get("edit_pairs", 0))
1389
+ rank_score = similarity + 0.15 * reason_match + 0.05 * genre_match + recurrence
1390
+ ranked.append({**row, "similarity": round(similarity, 3),
1391
+ "rank_score": round(rank_score, 3),
1392
+ "reason_match": reason_match, "genre_match": genre_match})
1393
+ ranked.sort(key=lambda row: (-row["rank_score"], -row["edit_pairs"],
1394
+ row["when"], row["prefer"]))
1395
+ return ranked[:limit]
1396
+
1397
+
1398
+ def guide(as_json=False, target=None, reason=None, genre=None, limit=5):
1399
+ """Return private rewrite preferences learned from repeated human edits."""
1400
+ learned = load_learned(LOCAL, "local")
1401
+ if target is None:
1402
+ rows = all_preferences(learned)
1403
+ else:
1404
+ text = required_text(target, "retrieval draft")
1405
+ rows = retrieve_preferences(text, reason=reason, genre=genre,
1406
+ limit=limit, learned=learned)
1407
+ if as_json:
1408
+ print(json.dumps({"result_kind": "retrieved_rewrite_preferences",
1409
+ "calibrated_probability": False,
1410
+ "rewrite_preferences": rows}, indent=1))
1411
+ elif not rows:
1412
+ print("rewrite guidance: no relevant recurring local replacement preferences")
1413
+ else:
1414
+ print("rewrite guidance retrieved from recurring local edits:")
1415
+ for row in rows:
1416
+ print(f" when {row['when']!r}, consider {row['prefer']!r} "
1417
+ f"({row['edit_pairs']} edit pairs)")
1418
+ print(" These are suggestions, not automatic replacements. Preserve meaning and facts.")
1419
+ return 0
1420
+
1421
+
1422
+ def build_voice(name, sample_path):
1423
+ """Build a private exemption profile for existing watchlist terms.
1424
+
1425
+ This exact-term scan covers only the scorer's current lexicon and riders.
1426
+ It does not infer arbitrary phrases, cadence, syntax, humor, tone, or a
1427
+ complete writing style. The resulting profile changes scoring only when
1428
+ the caller explicitly selects it with ``--voice NAME``.
1429
+ """
1430
+ import slopscore
1431
+ name = safe_voice_name(name)
1432
+ base = slopscore.load_patterns()
1433
+ terms = list(base.get("lexicon", {})) + list(base.get("riders", {}))
1434
+ src = Path(sample_path)
1435
+ if not src.exists():
1436
+ raise SystemExit(f"voice sample path does not exist: {src}")
1437
+ files = [src] if src.is_file() else sorted(
1438
+ f for f in src.rglob("*") if f.is_file() and f.suffix.lower() in (".md", ".txt"))
1439
+ if not files:
1440
+ raise SystemExit(f"voice sample contains no .md or .txt files: {src}")
1441
+ try:
1442
+ blob = " ".join(f.read_text() for f in files).lower()
1443
+ except (OSError, UnicodeDecodeError) as exc:
1444
+ raise SystemExit(f"cannot read voice sample at {src}: {exc}") from exc
1445
+ if not blob.strip():
1446
+ raise SystemExit(f"voice sample contains no text: {src}")
1447
+ keep = sorted({term for term in terms
1448
+ if re.search(r"\b" + re.escape(term.lower()) + r"\b", blob)})
1449
+ prof = {"_comment": f"Named scoring profile for {name}. Exact matches for "
1450
+ "existing lexicon or rider terms found at least once "
1451
+ "in the supplied sample are "
1452
+ "zero-weighted only when this profile is selected with "
1453
+ "--voice NAME. This does not model the author's full style. "
1454
+ "Derived by learn.py --voice; edit freely.",
1455
+ "keep": keep, "mute": []}
1456
+ dest = slopscore._voice_path(name)
1457
+ dest.parent.mkdir(parents=True, exist_ok=True)
1458
+ with file_locks([dest]):
1459
+ atomic_write_text(dest, json.dumps(prof, indent=1) + "\n", mode=0o600)
1460
+ print(f"wrote {dest}")
1461
+ print(f" {len(keep)} existing watchlist terms found; scoring ignores them "
1462
+ f"only when this profile is selected: {', '.join(keep[:12])}"
1463
+ f"{'...' if len(keep) > 12 else ''}")
1464
+ print(" this profile does not learn cadence, syntax, humor, tone, arbitrary "
1465
+ "phrases, or full writing style")
1466
+ print(f" use it: python3 scripts/slopscore.py --voice {name} draft.md")
1467
+ return 0
1468
+
1469
+
1470
+ def stats():
1471
+ base = load(DATA / "patterns.json")
1472
+ shared, local = learned_layers()
1473
+ obs = load_observations().get("observations", {})
1474
+ shared_patterns = shared.get("patterns", [])
1475
+ local_patterns = local.get("patterns", [])
1476
+ lp = shared_patterns + local_patterns
1477
+ allp = base["patterns"] + lp
1478
+ prov = sum(1 for p in allp if p.get("first_seen"))
1479
+ pending = {k: v for k, v in obs.items() if not v.get("promoted")}
1480
+ ready = sum(1 for v in pending.values() if v["count"] >= PROMOTE_AT)
1481
+ corpus = corpus_files()
1482
+ print(f" phrase rules {len(allp)} ({len(base['patterns'])} built in, "
1483
+ f"{len(shared_patterns)} shared, {len(local_patterns)} local)")
1484
+ print(f" dated rules {prov}/{len(allp)} — old rules can retire"
1485
+ if prov == len(allp) else
1486
+ f" dated rules {prov}/{len(allp)} — undated rules cannot retire automatically")
1487
+ src = {}
1488
+ for p in lp:
1489
+ k = p.get("source", "manual")
1490
+ src[k] = src.get(k, 0) + 1
1491
+ print(f" learned via {', '.join(f'{k}={v}' for k, v in sorted(src.items())) or 'nothing yet'}")
1492
+ print(f" under review {len(pending)} phrase(s), {ready} ready after "
1493
+ f"{PROMOTE_AT} matching edit pairs")
1494
+ print(f" re-confirmed {sum(1 for p in lp if p.get('confirmations'))} pattern(s) "
1495
+ f"have fired again since being added")
1496
+ active_fixes = sum(1 for p in local.get("fix_preferences", [])
1497
+ if p.get("active", True))
1498
+ print(f" fix memory {active_fixes} active recurring local replacement "
1499
+ "preference(s)")
1500
+ print(f" safety set {len(corpus)} human samples every new phrase rule must clear")
1501
+ print(f" private rules {LOCAL} (loaded for every writing check)")
1502
+ return 0
1503
+
1504
+
1505
+ def main():
1506
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
1507
+ ap.add_argument("--reflect", action="store_true",
1508
+ help="record what the writer changed about the skill's output")
1509
+ ap.add_argument("--produced", help="what the skill returned")
1510
+ ap.add_argument("--shipped", help="what the writer actually published")
1511
+ ap.add_argument("--doc-id", help="optional diagnostic label; duplicate edit "
1512
+ "content still counts as one vote")
1513
+ ap.add_argument("--reason", help="editorial reason label for --reflect, or a "
1514
+ "retrieval filter for --guide --for")
1515
+ ap.add_argument("--genre", help="genre label for --reflect or --guide --for")
1516
+ ap.add_argument("--feedback", metavar="JSON",
1517
+ help="optional source-bound per-edit reason labels for --reflect")
1518
+ ap.add_argument("--promote", action="store_true",
1519
+ help="mint patterns from observations that cleared threshold")
1520
+ ap.add_argument("--apply", action="store_true", help="write changes (default: dry run)")
1521
+ ap.add_argument("--auto-apply", action="store_true",
1522
+ help="after --reflect, activate/demote locally when all gates pass")
1523
+ ap.add_argument("--cat", default="reflect-learned")
1524
+ ap.add_argument("--weight", type=bounded_weight, default=START_WEIGHT)
1525
+ ap.add_argument("--confirm", metavar="PATH")
1526
+ ap.add_argument("--demote", action="store_true",
1527
+ help="lower weights on patterns writers repeatedly overruled")
1528
+ ap.add_argument("--export", action="store_true",
1529
+ help="package learnings for upstream, with no source text")
1530
+ ap.add_argument("--out", default="zero-slop-contribution.json")
1531
+ ap.add_argument("--yes", action="store_true", help="confirm writing the export")
1532
+ ap.add_argument("--merge", metavar="FILE",
1533
+ help="maintainer: fold a reviewed contribution in, re-gated locally")
1534
+ ap.add_argument("--voice", metavar="NAME",
1535
+ help="build a private scoring profile from exact watchlist matches")
1536
+ ap.add_argument("--from", dest="sample", metavar="PATH",
1537
+ help="the writing sample for --voice")
1538
+ ap.add_argument("--stats", action="store_true")
1539
+ ap.add_argument("--guide", action="store_true",
1540
+ help="show recurring private rewrite preferences")
1541
+ ap.add_argument("--for", dest="guide_target", metavar="DRAFT",
1542
+ help="with --guide, retrieve only preferences relevant to this draft")
1543
+ ap.add_argument("--limit", type=int, default=5,
1544
+ help=f"maximum retrieved preferences (1-{MAX_RETRIEVAL_RESULTS})")
1545
+ ap.add_argument("--json", action="store_true", help="machine-readable --guide output")
1546
+ ap.add_argument("--decay", action="store_true",
1547
+ help="halve stale patterns in the private live overlay")
1548
+ a = ap.parse_args()
1549
+
1550
+ if a.voice:
1551
+ if not a.sample:
1552
+ ap.error("--voice needs --from <file-or-dir of your writing>")
1553
+ return build_voice(a.voice, a.sample)
1554
+ if a.stats:
1555
+ return stats()
1556
+ if a.guide:
1557
+ if (a.reason or a.genre) and not a.guide_target:
1558
+ ap.error("--reason and --genre need --guide --for <draft>")
1559
+ return guide(a.json, a.guide_target, a.reason, a.genre, a.limit)
1560
+ if a.decay:
1561
+ return decay_local()
1562
+ if a.confirm:
1563
+ return confirm(a.confirm)
1564
+ if a.export:
1565
+ return export(a.out, a.yes)
1566
+ if a.merge:
1567
+ return merge(a.merge, a.apply, "contributed", a.weight)
1568
+ if a.demote:
1569
+ return demote(a.apply)
1570
+ if a.promote:
1571
+ return promote(a.apply, a.cat, a.weight)
1572
+ if a.reflect:
1573
+ if not (a.produced and a.shipped):
1574
+ ap.error("--reflect needs --produced and --shipped")
1575
+ result = reflect(a.produced, a.shipped, a.doc_id,
1576
+ reason=a.reason or "unspecified",
1577
+ genre=a.genre or "general", feedback=a.feedback)
1578
+ if a.auto_apply:
1579
+ promote(True, a.cat, a.weight)
1580
+ demote(True)
1581
+ decay_local()
1582
+ return result
1583
+ ap.print_help()
1584
+ return 1
1585
+
1586
+
1587
+ if __name__ == "__main__":
1588
+ sys.exit(main())