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
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""calibrate — derive tell weights from evidence instead of intuition.
|
|
3
|
+
|
|
4
|
+
Two jobs, both stdlib-only and offline:
|
|
5
|
+
|
|
6
|
+
1. WEIGHTS FROM DATA. Given a folder of known-human writing and a folder of
|
|
7
|
+
known-AI drafts, compute each lexicon term's *excess frequency* in the AI
|
|
8
|
+
set — the method Kobak et al. used to find the LLM vocabulary in 15M
|
|
9
|
+
PubMed abstracts. Terms the two corpora use equally get weight ~0. Terms
|
|
10
|
+
the AI set over-uses get weight proportional to the log-ratio. This makes
|
|
11
|
+
the meter self-calibrating: point it at this month's model output and the
|
|
12
|
+
weights follow the current era, not 2024's.
|
|
13
|
+
|
|
14
|
+
2. FALSE-POSITIVE REGRESSION. Any pattern change is checked against a
|
|
15
|
+
corpus of writing that must never be flagged. A pattern that fires on
|
|
16
|
+
known-good human prose is rejected before it can ship. This is what makes
|
|
17
|
+
continuous updating safe rather than reckless.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
python3 calibrate.py --human dir/ --ai dir/ [--out data/calibrated.json]
|
|
21
|
+
python3 calibrate.py --selftest # run the FP regression only
|
|
22
|
+
python3 calibrate.py --decay # age out unconfirmed patterns
|
|
23
|
+
|
|
24
|
+
Provenance: every weight written carries `first_seen`, `last_confirmed`, and
|
|
25
|
+
`n_obs`, so a future maintainer can tell a well-evidenced tell from a guess.
|
|
26
|
+
"""
|
|
27
|
+
import json
|
|
28
|
+
import math
|
|
29
|
+
import re
|
|
30
|
+
import sys
|
|
31
|
+
import argparse
|
|
32
|
+
from collections import Counter
|
|
33
|
+
from datetime import date
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from safeio import atomic_write_text, file_locks
|
|
37
|
+
|
|
38
|
+
DATA = Path(__file__).resolve().parent.parent / "data"
|
|
39
|
+
WORD = re.compile(r"[a-z’']+")
|
|
40
|
+
MIN_OBS = 5 # a term needs this many AI-side occurrences to earn a weight
|
|
41
|
+
MAX_WEIGHT = 6.0 # cap so no single word can convict alone
|
|
42
|
+
DECAY_MONTHS = 18 # unconfirmed patterns lose half their weight after this
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _json_texts(obj):
|
|
46
|
+
"""Extract prose from supported JSON corpus shapes without scoring JSON syntax."""
|
|
47
|
+
if isinstance(obj, str):
|
|
48
|
+
return [obj]
|
|
49
|
+
values = obj.values() if isinstance(obj, dict) else obj if isinstance(obj, list) else []
|
|
50
|
+
texts = []
|
|
51
|
+
for value in values:
|
|
52
|
+
if isinstance(value, str):
|
|
53
|
+
texts.append(value)
|
|
54
|
+
elif isinstance(value, dict) and isinstance(value.get("draft"), str):
|
|
55
|
+
texts.append(value["draft"])
|
|
56
|
+
return texts
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def read_corpus(d):
|
|
60
|
+
root = Path(d)
|
|
61
|
+
if not root.exists():
|
|
62
|
+
raise ValueError(f"corpus directory does not exist: {root}")
|
|
63
|
+
if not root.is_dir():
|
|
64
|
+
raise ValueError(f"corpus path is not a directory: {root}")
|
|
65
|
+
texts = []
|
|
66
|
+
for p in sorted(root.rglob("*")):
|
|
67
|
+
if p.suffix.lower() in (".txt", ".md", ".json") and p.is_file():
|
|
68
|
+
try:
|
|
69
|
+
t = p.read_text(errors="ignore")
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
raise ValueError(f"cannot read corpus file: {p}") from exc
|
|
72
|
+
if p.suffix.lower() == ".json":
|
|
73
|
+
try:
|
|
74
|
+
extracted = _json_texts(json.loads(t))
|
|
75
|
+
except json.JSONDecodeError as exc:
|
|
76
|
+
raise ValueError(f"invalid JSON corpus file: {p}") from exc
|
|
77
|
+
texts.extend(value for value in extracted if value.strip())
|
|
78
|
+
elif t.strip():
|
|
79
|
+
texts.append(t)
|
|
80
|
+
if not texts:
|
|
81
|
+
raise ValueError(f"corpus contains no non-empty .txt, .md, or supported JSON text: {root}")
|
|
82
|
+
return texts
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def freqs(texts):
|
|
86
|
+
c = Counter()
|
|
87
|
+
total = 0
|
|
88
|
+
for t in texts:
|
|
89
|
+
w = WORD.findall(t.lower())
|
|
90
|
+
c.update(w)
|
|
91
|
+
total += len(w)
|
|
92
|
+
if total == 0:
|
|
93
|
+
raise ValueError("corpus contains no words")
|
|
94
|
+
return c, total
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def excess_weights(human_dir, ai_dir):
|
|
98
|
+
hc, hn = freqs(read_corpus(human_dir))
|
|
99
|
+
ac, an = freqs(read_corpus(ai_dir))
|
|
100
|
+
out = {}
|
|
101
|
+
for term, n_ai in ac.items():
|
|
102
|
+
if n_ai < MIN_OBS or len(term) < 4:
|
|
103
|
+
continue
|
|
104
|
+
p_ai = n_ai / an
|
|
105
|
+
p_hu = (hc.get(term, 0) + 0.5) / hn # smoothed: unseen != impossible
|
|
106
|
+
ratio = p_ai / p_hu
|
|
107
|
+
if ratio <= 2.0: # not over-represented enough
|
|
108
|
+
continue
|
|
109
|
+
w = round(min(math.log2(ratio) * 1.2, MAX_WEIGHT), 1)
|
|
110
|
+
if w >= 1.0:
|
|
111
|
+
out[term] = {"w": w, "n_obs": n_ai, "ratio": round(ratio, 1)}
|
|
112
|
+
return out, hn, an
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def shape_selftest():
|
|
116
|
+
"""Run the shape channel against genres that mimic broetry structurally.
|
|
117
|
+
|
|
118
|
+
Hostile setting: every sample is declared `social`, the only genre where
|
|
119
|
+
the channel engages. Anything that flags here is a documented boundary,
|
|
120
|
+
not a silent one.
|
|
121
|
+
"""
|
|
122
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
123
|
+
import slopscore
|
|
124
|
+
d = DATA / "corpus" / "must-not-flag-shape"
|
|
125
|
+
if not d.exists():
|
|
126
|
+
print("no shape corpus — shape channel untested")
|
|
127
|
+
return 1
|
|
128
|
+
KNOWN = {"lyrics"} # see data/corpus/must-not-flag-shape/README.md
|
|
129
|
+
flagged, total = [], 0
|
|
130
|
+
files = sorted(d.glob("*.txt"))
|
|
131
|
+
if not files:
|
|
132
|
+
print(f"shape corpus at {d} is empty — shape channel untested")
|
|
133
|
+
return 1
|
|
134
|
+
for p in files:
|
|
135
|
+
total += 1
|
|
136
|
+
m = slopscore.shape_metrics(p.read_text(), genre="social")
|
|
137
|
+
if m.get("broetry"):
|
|
138
|
+
tag = "known boundary" if p.stem in KNOWN else "REGRESSION"
|
|
139
|
+
flagged.append((p.stem, tag))
|
|
140
|
+
print(f" {tag:14s} {p.name} solo={m['solo_frac']} run={m['max_fragment_run']}")
|
|
141
|
+
new = [f for f, t in flagged if t == "REGRESSION"]
|
|
142
|
+
print(f"shape regression: {total - len(flagged)}/{total} silent, "
|
|
143
|
+
f"{len([f for f,t in flagged if t=='known boundary'])} known boundary, "
|
|
144
|
+
f"{len(new)} new")
|
|
145
|
+
return 1 if new else 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def selftest(fp_dir=None):
|
|
149
|
+
"""No pattern may fire on writing that must never be flagged."""
|
|
150
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
151
|
+
import slopscore
|
|
152
|
+
data = slopscore.load_patterns()
|
|
153
|
+
fp_dir = Path(fp_dir or DATA / "corpus" / "must-not-flag")
|
|
154
|
+
if not fp_dir.exists():
|
|
155
|
+
print(f"no false-positive corpus at {fp_dir} — nothing to check")
|
|
156
|
+
return 1
|
|
157
|
+
files = sorted(p for p in fp_dir.rglob("*")
|
|
158
|
+
if p.is_file() and p.suffix.lower() in (".txt", ".md")
|
|
159
|
+
and p.name.lower() != "readme.md")
|
|
160
|
+
if not files:
|
|
161
|
+
print(f"false-positive corpus at {fp_dir} is empty — nothing to check")
|
|
162
|
+
return 1
|
|
163
|
+
failures = []
|
|
164
|
+
for p in files:
|
|
165
|
+
r = slopscore.score_text(p.read_text(), data)
|
|
166
|
+
if r["ai_likelihood"] > 25:
|
|
167
|
+
failures.append((p.name, r["ai_likelihood"],
|
|
168
|
+
[h["name"] for h in r["hits"]][:4]))
|
|
169
|
+
for name, score, hits in failures:
|
|
170
|
+
print(f" FAIL {score:5.1f} {name} ← {', '.join(hits) or 'no lexical hits'}")
|
|
171
|
+
n = len(files)
|
|
172
|
+
print(f"false-positive regression: {n - len(failures)}/{n} passed")
|
|
173
|
+
return 1 if failures else 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def decay():
|
|
177
|
+
"""Halve the weight of patterns not re-confirmed within DECAY_MONTHS."""
|
|
178
|
+
p = DATA / "learned.json"
|
|
179
|
+
with file_locks([p]):
|
|
180
|
+
try:
|
|
181
|
+
d = json.loads(p.read_text())
|
|
182
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
183
|
+
raise SystemExit(f"cannot read valid learned state at {p}: {exc}") from exc
|
|
184
|
+
if not isinstance(d, dict) or not isinstance(d.get("patterns", []), list):
|
|
185
|
+
raise SystemExit(f"invalid learned-state schema at {p}")
|
|
186
|
+
for index, pat in enumerate(d.get("patterns", [])):
|
|
187
|
+
if (not isinstance(pat, dict)
|
|
188
|
+
or not isinstance(pat.get("w"), (int, float))
|
|
189
|
+
or isinstance(pat.get("w"), bool)
|
|
190
|
+
or not math.isfinite(pat["w"])
|
|
191
|
+
or not 0 <= pat["w"] <= 10
|
|
192
|
+
or ("last_confirmed" in pat
|
|
193
|
+
and not isinstance(pat["last_confirmed"], str))
|
|
194
|
+
or ("decayed" in pat and not isinstance(pat["decayed"], str))):
|
|
195
|
+
raise SystemExit(
|
|
196
|
+
f"invalid learned pattern at index {index} in {p}"
|
|
197
|
+
)
|
|
198
|
+
today = date.today()
|
|
199
|
+
changed = 0
|
|
200
|
+
for index, pat in enumerate(d.get("patterns", [])):
|
|
201
|
+
lc = pat.get("last_confirmed")
|
|
202
|
+
if not lc:
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
confirmed = date.fromisoformat(lc)
|
|
206
|
+
except ValueError as exc:
|
|
207
|
+
raise SystemExit(
|
|
208
|
+
f"invalid last_confirmed date at pattern index {index}"
|
|
209
|
+
) from exc
|
|
210
|
+
y, m = confirmed.year, confirmed.month
|
|
211
|
+
months = (today.year - y) * 12 + (today.month - m)
|
|
212
|
+
weight = pat["w"]
|
|
213
|
+
# One stale interval earns one decay. Re-running the command must be
|
|
214
|
+
# idempotent; a later confirmation clears this marker and starts a
|
|
215
|
+
# fresh interval.
|
|
216
|
+
if months > DECAY_MONTHS and weight > 0.5 and not pat.get("decayed"):
|
|
217
|
+
pat["w"] = round(weight / 2, 2)
|
|
218
|
+
pat["decayed"] = str(today)
|
|
219
|
+
changed += 1
|
|
220
|
+
atomic_write_text(p, json.dumps(d, indent=1) + "\n")
|
|
221
|
+
print(f"decayed {changed} pattern(s) unconfirmed for over {DECAY_MONTHS} months")
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main(argv=None):
|
|
226
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
227
|
+
mode = ap.add_mutually_exclusive_group()
|
|
228
|
+
mode.add_argument("--selftest", action="store_true")
|
|
229
|
+
mode.add_argument("--decay", action="store_true")
|
|
230
|
+
ap.add_argument("--human", metavar="DIR")
|
|
231
|
+
ap.add_argument("--ai", metavar="DIR")
|
|
232
|
+
ap.add_argument("--out", metavar="FILE")
|
|
233
|
+
args = ap.parse_args(argv)
|
|
234
|
+
if args.selftest:
|
|
235
|
+
fp_status = selftest()
|
|
236
|
+
shape_status = shape_selftest()
|
|
237
|
+
return 1 if fp_status or shape_status else 0
|
|
238
|
+
if args.decay:
|
|
239
|
+
return decay()
|
|
240
|
+
if not args.human or not args.ai:
|
|
241
|
+
ap.error("calibration needs --human DIR and --ai DIR")
|
|
242
|
+
try:
|
|
243
|
+
weights, hn, an = excess_weights(args.human, args.ai)
|
|
244
|
+
except ValueError as exc:
|
|
245
|
+
ap.error(str(exc))
|
|
246
|
+
print(f"human corpus {hn:,} words · ai corpus {an:,} words")
|
|
247
|
+
print(f"terms over-represented in AI text (ratio > 2, n >= {MIN_OBS}): {len(weights)}")
|
|
248
|
+
for t, v in sorted(weights.items(), key=lambda kv: -kv[1]["w"])[:25]:
|
|
249
|
+
print(f" {v['w']:4.1f} {t:18s} {v['ratio']:6.1f}x n={v['n_obs']}")
|
|
250
|
+
out = Path(args.out) if args.out else DATA / "calibrated.json"
|
|
251
|
+
protected = {(DATA / "patterns.json").resolve(), (DATA / "learned.json").resolve()}
|
|
252
|
+
if out.resolve() in protected:
|
|
253
|
+
ap.error(f"refusing to overwrite tracked detector state: {out}; write a separate calibration file")
|
|
254
|
+
today = str(date.today())
|
|
255
|
+
payload = {"_comment": "Weights derived from corpus excess frequency by "
|
|
256
|
+
"calibrate.py. Merge into learned.json after "
|
|
257
|
+
"--selftest passes.",
|
|
258
|
+
"calibrated": today,
|
|
259
|
+
"lexicon": {t: v["w"] for t, v in weights.items()},
|
|
260
|
+
"provenance": {t: dict(v, first_seen=today, last_confirmed=today)
|
|
261
|
+
for t, v in weights.items()}}
|
|
262
|
+
try:
|
|
263
|
+
with file_locks([out]):
|
|
264
|
+
atomic_write_text(out, json.dumps(payload, indent=1) + "\n")
|
|
265
|
+
except OSError as exc:
|
|
266
|
+
ap.error(f"cannot write calibration output {out}: {exc}")
|
|
267
|
+
print(f"\nwrote {out}")
|
|
268
|
+
print("next: review, merge the lexicon into data/learned.json, then run "
|
|
269
|
+
"`python3 calibrate.py --selftest` before shipping")
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__":
|
|
274
|
+
sys.exit(main())
|