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.
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/SKILL.md +803 -0
- package/data/corpus/community-register/README.md +14 -0
- package/data/corpus/community-register/judgment/communicative-drift.txt +1 -0
- package/data/corpus/community-register/judgment/reflexive-agreement.txt +1 -0
- package/data/corpus/community-register/judgment/rhetorical-scale-mismatch.txt +1 -0
- package/data/corpus/must-not-flag/README.md +49 -0
- package/data/corpus/must-not-flag/esl-engineer-email.txt +7 -0
- package/data/corpus/must-not-flag/exec-memo.txt +9 -0
- package/data/corpus/must-not-flag/federalist.txt +5 -0
- package/data/corpus/must-not-flag/gettysburg.txt +1 -0
- package/data/corpus/must-not-flag/grant-abstract.txt +5 -0
- package/data/corpus/must-not-flag/ml-methods.txt +1 -0
- package/data/corpus/must-not-flag/personal-essay.txt +1 -0
- package/data/corpus/must-not-flag/press-release.txt +7 -0
- package/data/corpus/must-not-flag/recipe.txt +11 -0
- package/data/corpus/must-not-flag/sre-runbook.txt +1 -0
- package/data/corpus/must-not-flag/technical-postmortem.txt +1 -0
- package/data/corpus/must-not-flag/terse-engineer-note.txt +1 -0
- package/data/corpus/must-not-flag-shape/README.md +22 -0
- package/data/corpus/must-not-flag-shape/aphorism-list.txt +9 -0
- package/data/corpus/must-not-flag-shape/changelog.txt +13 -0
- package/data/corpus/must-not-flag-shape/dialogue.txt +19 -0
- package/data/corpus/must-not-flag-shape/lyrics.txt +15 -0
- package/data/corpus/must-not-flag-shape/poem.txt +8 -0
- package/data/corpus/must-not-flag-shape/sms-transcript.txt +15 -0
- package/data/corpus/performed-register/README.md +38 -0
- package/data/corpus/performed-register/judgment/detection-fragile.txt +1 -0
- package/data/corpus/performed-register/judgment/draft-cheap-signal.txt +1 -0
- package/data/corpus/performed-register/judgment/ear-chiasmus.txt +1 -0
- package/data/corpus/performed-register/judgment/fight-against-tagline.txt +1 -0
- package/data/corpus/performed-register/judgment/fine-beat.txt +1 -0
- package/data/corpus/performed-register/judgment/glow-finishing.txt +1 -0
- package/data/corpus/performed-register/judgment/jargon-compression.txt +1 -0
- package/data/corpus/performed-register/judgment/not-perfect-honest.txt +1 -0
- package/data/corpus/performed-register/judgment/punctuation-convict.txt +1 -0
- package/data/corpus/performed-register/judgment/recipe-ingredients.txt +1 -0
- package/data/corpus/performed-register/judgment/replay-lands.txt +1 -0
- package/data/corpus/performed-register/judgment/rhymes-with.txt +1 -0
- package/data/corpus/performed-register/judgment/rhythm-prints.txt +1 -0
- package/data/corpus/performed-register/judgment/same-leak.txt +1 -0
- package/data/corpus/performed-register/judgment/tools-work.txt +1 -0
- package/data/corpus/performed-register/judgment/tuning-gradients.txt +1 -0
- package/data/corpus/performed-register/judgment/verdict-arithmetic.txt +1 -0
- package/data/corpus/performed-register/judgment/whole-product.txt +1 -0
- package/data/corpus/performed-register/judgment/words-cheap.txt +1 -0
- package/data/corpus/performed-register/mechanical/argue-with-artifact.txt +1 -0
- package/data/corpus/performed-register/mechanical/bill-lands.txt +1 -0
- package/data/corpus/performed-register/mechanical/billed-to-reader.txt +1 -0
- package/data/corpus/performed-register/mechanical/economics-brutal.txt +1 -0
- package/data/corpus/performed-register/mechanical/has-receipts.txt +1 -0
- package/data/corpus/performed-register/mechanical/hired-adversary.txt +1 -0
- package/data/corpus/performed-register/mechanical/hyperbole-universal.txt +1 -0
- package/data/corpus/performed-register/mechanical/metaphor-sender-name.txt +1 -0
- package/data/corpus/performed-register/mechanical/minding-own-business.txt +1 -0
- package/data/corpus/performed-register/mechanical/on-the-tin.txt +1 -0
- package/data/corpus/performed-register/mechanical/opens-the-hood.txt +1 -0
- package/data/corpus/performed-register/mechanical/turns-out-payoff.txt +1 -0
- package/data/corpus/performed-register/mechanical/vibe-check.txt +1 -0
- package/data/corpus/performed-register/mechanical/vibe-isnt-a-vibe.txt +1 -0
- package/data/corpus/performed-register/mechanical/vibe-number-attached.txt +1 -0
- package/data/corpus/performed-register/mechanical/where-slop-lives.txt +1 -0
- package/data/learned-log.md +339 -0
- package/data/learned.json +128 -0
- package/data/patterns.json +2298 -0
- package/package.json +54 -0
- package/references/copy-desk.md +87 -0
- package/references/evidence.md +382 -0
- package/references/overcorrection.md +77 -0
- package/references/platforms.md +102 -0
- package/references/readalong.md +118 -0
- package/references/rewrite-moves.md +177 -0
- package/references/tells.md +190 -0
- package/scripts/calibrate.py +274 -0
- package/scripts/learn.py +1588 -0
- package/scripts/predictability.py +227 -0
- package/scripts/rerank.py +150 -0
- package/scripts/safeio.py +146 -0
- package/scripts/slopscore.py +1352 -0
- package/scripts/version_check.py +104 -0
package/scripts/learn.py
ADDED
|
@@ -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())
|