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,227 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""predictability — see how easily the AI assistant can guess the original wording.
|
|
3
|
+
|
|
4
|
+
The strongest signal that a machine wrote something is that a machine finds it
|
|
5
|
+
predictable: the words sit where a language model would have put them. Detectors
|
|
6
|
+
measure this with token log-probabilities, but Zero Slop ships no model and Claude's
|
|
7
|
+
API exposes no logprobs, so this computes the same thing a different way — a cloze
|
|
8
|
+
probe the harness's own model answers.
|
|
9
|
+
|
|
10
|
+
It masks a spread of content words, hands the blanks (context only, answer withheld)
|
|
11
|
+
to the model executing the skill, and measures how often the model's guesses hit the
|
|
12
|
+
word the author actually used. High agreement means the text is what the model would
|
|
13
|
+
have written — machine-predictable. Human writing picks the less-likely word more
|
|
14
|
+
often, and the score falls. This works with any harness model (Claude, GPT, …) because
|
|
15
|
+
it needs only generation, never logprobs, and Zero Slop supplies no model of its own.
|
|
16
|
+
|
|
17
|
+
The protocol is three steps, so any agent can drive it:
|
|
18
|
+
|
|
19
|
+
python3 scripts/predictability.py --probes draft.md > probes.json # 1. blanks
|
|
20
|
+
# 2. the agent fills {id: [top-3 guesses]} from each context, into preds.json
|
|
21
|
+
python3 scripts/predictability.py --score draft.md preds.json # 3. reading
|
|
22
|
+
|
|
23
|
+
Probe selection and scoring are deterministic, so the Python is fully testable. The
|
|
24
|
+
model's three guesses can vary unless the host provides deterministic generation. It is
|
|
25
|
+
a corroborating channel reported beside the surface score, never folded into it — the
|
|
26
|
+
surface score stays traceable to spans, and this says whether a model finds the prose
|
|
27
|
+
predictable.
|
|
28
|
+
"""
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
import argparse
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
# Function words carry no predictability signal (everyone writes "the", "of"), so
|
|
36
|
+
# probes land on content words only.
|
|
37
|
+
STOP = set("""the a an and or but if then else of to in on at by for with from into
|
|
38
|
+
over under again once here there when while as is are was were be been being have has
|
|
39
|
+
had do does did will would can could should may might must not no nor so than too very
|
|
40
|
+
this that these those it its their your our his her they them we you i he she who whom
|
|
41
|
+
which what how why about after before between during through above below up down out off
|
|
42
|
+
only just also even still yet more most less least own same other such both each any all
|
|
43
|
+
some few many much one two three""".split())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _words(text):
|
|
47
|
+
"""(word, start, end) for alphabetic tokens, code and quotes stripped out."""
|
|
48
|
+
# Preserve character offsets while blanking code. Probe contexts slice this
|
|
49
|
+
# cleaned text; shrinking a fenced block made every later offset point into
|
|
50
|
+
# the wrong word in the original document.
|
|
51
|
+
text = re.sub(r"```.*?```", lambda m: " " * len(m.group(0)), text, flags=re.S)
|
|
52
|
+
text = re.sub(r"`[^`]*`", lambda m: " " * len(m.group(0)), text)
|
|
53
|
+
return [(m.group(0), m.start(), m.end()) for m in re.finditer(r"[A-Za-z][A-Za-z'-]+", text)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _without_code(text):
|
|
57
|
+
text = re.sub(r"```.*?```", lambda m: " " * len(m.group(0)), text, flags=re.S)
|
|
58
|
+
return re.sub(r"`[^`]*`", lambda m: " " * len(m.group(0)), text)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _norm(w):
|
|
62
|
+
return re.sub(r"[^a-z]", "", w.lower())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _morph_roots(word):
|
|
66
|
+
"""Conservative English inflection roots; never equate by prefix alone."""
|
|
67
|
+
word = _norm(word)
|
|
68
|
+
roots = {word}
|
|
69
|
+
for suffix in ("ingly", "edly", "ing", "ied", "ed", "es", "s", "ly"):
|
|
70
|
+
if word.endswith(suffix) and len(word) - len(suffix) >= 4:
|
|
71
|
+
stem = word[:-len(suffix)]
|
|
72
|
+
roots.add(stem)
|
|
73
|
+
if suffix in {"ing", "ed", "edly", "ingly"}:
|
|
74
|
+
roots.add(stem + "e")
|
|
75
|
+
if suffix == "ied":
|
|
76
|
+
roots.add(stem + "y")
|
|
77
|
+
return roots
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def probes(text, k=12, window=45):
|
|
81
|
+
"""Deterministically pick up to k content words to mask.
|
|
82
|
+
|
|
83
|
+
Eligible = alphabetic, >=4 letters, not a stopword, not the document's first word.
|
|
84
|
+
They are taken at an even stride across the eligible list so the probes span the
|
|
85
|
+
whole piece, and the selection is a pure function of the text — same input, same
|
|
86
|
+
probes, which is what makes the score reproducible and the scorer testable.
|
|
87
|
+
"""
|
|
88
|
+
if not isinstance(k, int) or isinstance(k, bool) or k < 1:
|
|
89
|
+
raise ValueError("k must be a positive integer")
|
|
90
|
+
if not isinstance(window, int) or isinstance(window, bool) or window < 0:
|
|
91
|
+
raise ValueError("window must be a non-negative integer")
|
|
92
|
+
clean = _without_code(text)
|
|
93
|
+
ws = _words(clean)
|
|
94
|
+
eligible = [i for i, (w, s, e) in enumerate(ws)
|
|
95
|
+
if len(_norm(w)) >= 4 and _norm(w) not in STOP and i > 0]
|
|
96
|
+
if not eligible:
|
|
97
|
+
return []
|
|
98
|
+
k = min(k, len(eligible))
|
|
99
|
+
stride = len(eligible) / k
|
|
100
|
+
picks = [eligible[int(j * stride)] for j in range(k)]
|
|
101
|
+
|
|
102
|
+
out = []
|
|
103
|
+
for pid, wi in enumerate(picks):
|
|
104
|
+
w, s, e = ws[wi]
|
|
105
|
+
before = clean[:s]
|
|
106
|
+
ctx_words = re.findall(r"\S+", before)[-window:]
|
|
107
|
+
context = " ".join(ctx_words) + " ___"
|
|
108
|
+
out.append({"id": pid, "context": context, "answer": _norm(w)})
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _hit(answer, guesses):
|
|
113
|
+
"""A guess counts if it matches the answer up to case and light morphology —
|
|
114
|
+
exact, or a shared 4-letter stem, so "raised"/"raise" and "quickly"/"quick" land."""
|
|
115
|
+
a = _norm(answer)
|
|
116
|
+
answer_roots = _morph_roots(a)
|
|
117
|
+
for g in guesses:
|
|
118
|
+
gg = _norm(g)
|
|
119
|
+
if not gg:
|
|
120
|
+
continue
|
|
121
|
+
if gg == a or answer_roots & _morph_roots(gg):
|
|
122
|
+
return True
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def score(text, predictions, k=12):
|
|
127
|
+
"""predictions: {id: [top guesses]}. Returns the predictability reading.
|
|
128
|
+
|
|
129
|
+
predictability = share of masked words the model's top-three guesses recovered,
|
|
130
|
+
0–100. Higher means more machine-predictable. The descriptive bands are operating
|
|
131
|
+
thresholds, not calibrated probabilities, and the result is never silently folded
|
|
132
|
+
into the surface score.
|
|
133
|
+
"""
|
|
134
|
+
if not isinstance(text, str):
|
|
135
|
+
raise ValueError("text must be a string")
|
|
136
|
+
if not isinstance(predictions, dict):
|
|
137
|
+
raise ValueError("predictions must be a JSON object keyed by probe id")
|
|
138
|
+
pr = probes(text, k=k)
|
|
139
|
+
if not pr:
|
|
140
|
+
return {"predictability": None, "hits": 0, "total": 0,
|
|
141
|
+
"reading": "too short to probe", "backend": "harness-model"}
|
|
142
|
+
preds = {}
|
|
143
|
+
for key, guesses in predictions.items():
|
|
144
|
+
try:
|
|
145
|
+
probe_id = int(key)
|
|
146
|
+
except (TypeError, ValueError) as exc:
|
|
147
|
+
raise ValueError(f"invalid probe id: {key!r}") from exc
|
|
148
|
+
if probe_id in preds:
|
|
149
|
+
raise ValueError(f"duplicate probe id after normalization: {key!r}")
|
|
150
|
+
if isinstance(guesses, str):
|
|
151
|
+
guesses = [guesses]
|
|
152
|
+
if (not isinstance(guesses, list) or not guesses or len(guesses) > 20
|
|
153
|
+
or not all(isinstance(g, str) and len(g) <= 200 for g in guesses)):
|
|
154
|
+
raise ValueError(f"predictions for probe {probe_id} must be a string list")
|
|
155
|
+
preds[probe_id] = guesses
|
|
156
|
+
expected = {p["id"] for p in pr}
|
|
157
|
+
if set(preds) != expected:
|
|
158
|
+
missing, extra = sorted(expected - set(preds)), sorted(set(preds) - expected)
|
|
159
|
+
raise ValueError(f"prediction ids do not match probes: missing={missing}, extra={extra}")
|
|
160
|
+
hits = 0
|
|
161
|
+
for p in pr:
|
|
162
|
+
g = preds.get(p["id"], [])
|
|
163
|
+
if _hit(p["answer"], g[:3]):
|
|
164
|
+
hits += 1
|
|
165
|
+
total = len(pr)
|
|
166
|
+
val = round(100 * hits / total, 1)
|
|
167
|
+
if val >= 50:
|
|
168
|
+
reading = "easy to guess — much of the wording follows familiar patterns"
|
|
169
|
+
elif val >= 33:
|
|
170
|
+
reading = "moderately easy to guess"
|
|
171
|
+
else:
|
|
172
|
+
reading = "hard to guess — the wording is less predictable"
|
|
173
|
+
return {"predictability": val, "hits": hits, "total": total,
|
|
174
|
+
"reading": reading, "backend": "harness-model"}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _selftest():
|
|
178
|
+
"""The scaffold is deterministic; prove it without a model, both extremes."""
|
|
179
|
+
text = ("The startup raised a substantial round from investors who believed in the "
|
|
180
|
+
"mission. Revenue climbed steadily through the difficult second quarter, and "
|
|
181
|
+
"the founders remained cautiously optimistic about the coming year ahead.")
|
|
182
|
+
pr = probes(text, k=6)
|
|
183
|
+
assert pr and probes(text, k=6) == pr, "probe selection must be deterministic"
|
|
184
|
+
allright = {p["id"]: [p["answer"]] for p in pr}
|
|
185
|
+
allwrong = {p["id"]: ["xyzzy"] for p in pr}
|
|
186
|
+
hi = score(text, allright, k=6)
|
|
187
|
+
lo = score(text, allwrong, k=6)
|
|
188
|
+
assert hi["predictability"] == 100.0, hi
|
|
189
|
+
assert lo["predictability"] == 0.0, lo
|
|
190
|
+
assert _hit("raised", ["raise"]), "light morphology must match"
|
|
191
|
+
assert not _hit("station", ["statue"]), "shared prefixes are not morphology"
|
|
192
|
+
print(f"predictability selftest OK — {len(pr)} probes, all-right=100.0, all-wrong=0.0")
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main(argv=None):
|
|
197
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
198
|
+
mode = ap.add_mutually_exclusive_group(required=True)
|
|
199
|
+
mode.add_argument("--selftest", action="store_true")
|
|
200
|
+
mode.add_argument("--probes", metavar="TEXT_FILE")
|
|
201
|
+
mode.add_argument("--score", nargs=2, metavar=("TEXT_FILE", "PREDICTIONS_JSON"))
|
|
202
|
+
args = ap.parse_args(argv)
|
|
203
|
+
if args.selftest:
|
|
204
|
+
return _selftest()
|
|
205
|
+
if args.probes:
|
|
206
|
+
try:
|
|
207
|
+
text = Path(args.probes).read_text()
|
|
208
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
209
|
+
ap.error(f"cannot read text: {exc}")
|
|
210
|
+
blanks = [{"id": p["id"], "context": p["context"]} for p in probes(text)]
|
|
211
|
+
print(json.dumps(blanks, indent=1))
|
|
212
|
+
return 0
|
|
213
|
+
if args.score:
|
|
214
|
+
try:
|
|
215
|
+
text = Path(args.score[0]).read_text()
|
|
216
|
+
preds = json.loads(Path(args.score[1]).read_text())
|
|
217
|
+
r = score(text, preds)
|
|
218
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
|
|
219
|
+
ap.error(str(exc))
|
|
220
|
+
print(f"How easy the original wording was to guess: {r['predictability']}/100")
|
|
221
|
+
print(f" The model guessed {r['hits']} of {r['total']} hidden words. {r['reading']}.")
|
|
222
|
+
return 0
|
|
223
|
+
return 2
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
if __name__ == "__main__":
|
|
227
|
+
sys.exit(main())
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""rerank — generate several rewrites, keep the best one, objectively.
|
|
3
|
+
|
|
4
|
+
A single rewrite is one sample from the model. Two or three, written with different
|
|
5
|
+
strategies (strip hard vs. keep the warmth, reorder vs. stay put), give the loop
|
|
6
|
+
something to choose between — and the choice should be made by the meter, not by the
|
|
7
|
+
same taste that wrote them. This ranks candidates for one draft and returns the
|
|
8
|
+
winner, using the shared rewrite objective in slopscore (`rewrite_score`).
|
|
9
|
+
|
|
10
|
+
The order is not negotiable on fidelity. A version that invents a fact loses to any
|
|
11
|
+
version that does not, however much cleaner it reads, because inventing a detail is
|
|
12
|
+
the one thing hard rule 1 forbids. Of the versions that preserve the source, the
|
|
13
|
+
cleanest one wins.
|
|
14
|
+
|
|
15
|
+
python3 scripts/rerank.py --original draft.md cand1.md cand2.md cand3.md
|
|
16
|
+
python3 scripts/rerank.py --original draft.md --candidates cands.json # {name: text}
|
|
17
|
+
python3 scripts/rerank.py --original draft.md --genre linkedin *.md --out best.md
|
|
18
|
+
|
|
19
|
+
Prints a comparison table and marks the winner; writes the winning text to --out, or
|
|
20
|
+
to stdout with --emit. Offline, standard library only, like the rest of the scorer.
|
|
21
|
+
"""
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
import argparse
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
28
|
+
sys.path.insert(0, str(ROOT / "scripts"))
|
|
29
|
+
from safeio import atomic_write_text
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _tier(s):
|
|
33
|
+
"""0 clean · 1 dropped a fact · 2 invented a fact. Lower is better, always."""
|
|
34
|
+
if s["invented"]:
|
|
35
|
+
return 2
|
|
36
|
+
return 0 if s["preserved"] else 1
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def rank(original, candidates, genre=None):
|
|
40
|
+
"""candidates: {name: text}. Returns them scored and sorted, best first.
|
|
41
|
+
|
|
42
|
+
Sort key: fidelity tier first (a fabrication can never win), then soft quality,
|
|
43
|
+
then the tie-breaks the verify gate cares about — lower surface score, more
|
|
44
|
+
burstiness, fewer high-weight tells.
|
|
45
|
+
"""
|
|
46
|
+
if not isinstance(original, str):
|
|
47
|
+
raise ValueError("original must be text")
|
|
48
|
+
if not isinstance(candidates, dict) or not candidates:
|
|
49
|
+
raise ValueError("candidates must be a non-empty object")
|
|
50
|
+
for name, text in candidates.items():
|
|
51
|
+
if (not isinstance(name, str) or not name.strip()
|
|
52
|
+
or not isinstance(text, str) or not text.strip()):
|
|
53
|
+
raise ValueError("candidate names and values must be non-empty names and text")
|
|
54
|
+
import slopscore
|
|
55
|
+
data = slopscore.load_patterns()
|
|
56
|
+
scored = []
|
|
57
|
+
for name, text in candidates.items():
|
|
58
|
+
s = slopscore.rewrite_score(original, text, genre, data)
|
|
59
|
+
s["name"], s["text"] = name, text
|
|
60
|
+
scored.append(s)
|
|
61
|
+
scored.sort(key=lambda s: (_tier(s), -s["soft"], s["after_ai"],
|
|
62
|
+
-s["burstiness"], s["high_tells"]))
|
|
63
|
+
return scored
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def render(scored):
|
|
67
|
+
out = ["", " version writing score sentence variety strong flags source check",
|
|
68
|
+
" " + "-" * 88]
|
|
69
|
+
labels = {0: "source kept ✓", 1: "dropped a fact", 2: "ADDED A FACT ✗"}
|
|
70
|
+
for i, s in enumerate(scored):
|
|
71
|
+
mark = "→" if i == 0 else " "
|
|
72
|
+
variety = "natural" if s["burstiness"] >= 0.45 else "too even"
|
|
73
|
+
out.append(f" {mark} {s['name'][:18]:<18} {s['after_ai']:>13} "
|
|
74
|
+
f"{variety:<16} {s['high_tells']:>12} {labels[_tier(s)]}")
|
|
75
|
+
win = scored[0]
|
|
76
|
+
out.append("")
|
|
77
|
+
if _tier(win) == 0:
|
|
78
|
+
out.append(
|
|
79
|
+
f" Chosen: {win['name']} — the clearest of {len(scored)} versions "
|
|
80
|
+
"that preserve the source."
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
out.append(
|
|
84
|
+
f" Chosen: {win['name']} — but every version changed or dropped "
|
|
85
|
+
f"source material ({labels[_tier(win)]}). Regenerate or fix the fact "
|
|
86
|
+
"before shipping."
|
|
87
|
+
)
|
|
88
|
+
return "\n".join(out)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main(argv=None):
|
|
92
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
93
|
+
ap.add_argument("--original", required=True, metavar="FILE")
|
|
94
|
+
ap.add_argument("--candidates", metavar="JSON_FILE")
|
|
95
|
+
ap.add_argument("--genre")
|
|
96
|
+
ap.add_argument("--out", metavar="FILE")
|
|
97
|
+
ap.add_argument("--emit", action="store_true")
|
|
98
|
+
ap.add_argument("files", nargs="*")
|
|
99
|
+
args = ap.parse_args(argv)
|
|
100
|
+
try:
|
|
101
|
+
original = Path(args.original).read_text()
|
|
102
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
103
|
+
ap.error(f"cannot read original: {exc}")
|
|
104
|
+
|
|
105
|
+
if args.candidates:
|
|
106
|
+
if args.files:
|
|
107
|
+
ap.error("use either --candidates JSON_FILE or candidate files, not both")
|
|
108
|
+
try:
|
|
109
|
+
candidates = json.loads(Path(args.candidates).read_text())
|
|
110
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
111
|
+
ap.error(f"cannot read candidates: {exc}")
|
|
112
|
+
else:
|
|
113
|
+
candidates = {}
|
|
114
|
+
for file_name in args.files:
|
|
115
|
+
path = Path(file_name)
|
|
116
|
+
if path.name in candidates:
|
|
117
|
+
ap.error(f"candidate basenames must be unique: {path.name}")
|
|
118
|
+
try:
|
|
119
|
+
candidates[path.name] = path.read_text()
|
|
120
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
121
|
+
ap.error(f"cannot read candidate {path}: {exc}")
|
|
122
|
+
if len(candidates) < 2:
|
|
123
|
+
ap.error("give at least two candidate rewrites to choose between")
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
scored = rank(original, candidates, args.genre)
|
|
127
|
+
except ValueError as exc:
|
|
128
|
+
ap.error(str(exc))
|
|
129
|
+
print(render(scored))
|
|
130
|
+
win = scored[0]
|
|
131
|
+
if args.out:
|
|
132
|
+
output = Path(args.out).resolve()
|
|
133
|
+
protected = {Path(args.original).resolve()}
|
|
134
|
+
if args.candidates:
|
|
135
|
+
protected.add(Path(args.candidates).resolve())
|
|
136
|
+
protected.update(Path(path).resolve() for path in args.files)
|
|
137
|
+
if output in protected:
|
|
138
|
+
ap.error("--out must not overwrite the original or a candidate input")
|
|
139
|
+
try:
|
|
140
|
+
atomic_write_text(output, win["text"])
|
|
141
|
+
except OSError as exc:
|
|
142
|
+
ap.error(f"cannot write winner: {exc}")
|
|
143
|
+
print(f"\n wrote {args.out}")
|
|
144
|
+
elif args.emit:
|
|
145
|
+
print("\n" + "=" * 60 + "\n" + win["text"])
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
sys.exit(main())
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Small, stdlib-only primitives for durable and concurrency-safe state updates.
|
|
3
|
+
|
|
4
|
+
Zero Slop's scorer is read-only, but the learning and calibration tools perform
|
|
5
|
+
read-modify-write updates. Atomic replacement prevents torn JSON; lock directories
|
|
6
|
+
prevent two processes from silently overwriting each other's observations.
|
|
7
|
+
"""
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import stat
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
LOCK_TIMEOUT = 30.0
|
|
18
|
+
STALE_AFTER = 3600.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _lock_path(path):
|
|
22
|
+
path = Path(path).resolve()
|
|
23
|
+
digest = hashlib.sha256(str(path).encode()).hexdigest()[:12]
|
|
24
|
+
return path.parent / f".{path.name}.{digest}.lock"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@contextmanager
|
|
28
|
+
def file_locks(paths, timeout=LOCK_TIMEOUT, stale_after=STALE_AFTER):
|
|
29
|
+
"""Acquire process-safe locks for several files in a stable order.
|
|
30
|
+
|
|
31
|
+
Directory creation is atomic on every supported platform. Locks older than an
|
|
32
|
+
hour are treated as abandoned after a crash. All locks are acquired in sorted
|
|
33
|
+
order, so two commands touching the same files cannot deadlock each other.
|
|
34
|
+
"""
|
|
35
|
+
targets = sorted({_lock_path(p) for p in paths}, key=lambda p: str(p))
|
|
36
|
+
acquired = []
|
|
37
|
+
deadline = time.monotonic() + timeout
|
|
38
|
+
try:
|
|
39
|
+
for lock in targets:
|
|
40
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
while True:
|
|
42
|
+
try:
|
|
43
|
+
lock.mkdir(mode=0o700)
|
|
44
|
+
acquired.append(lock)
|
|
45
|
+
break
|
|
46
|
+
except FileExistsError:
|
|
47
|
+
try:
|
|
48
|
+
if time.time() - lock.stat().st_mtime > stale_after:
|
|
49
|
+
lock.rmdir()
|
|
50
|
+
continue
|
|
51
|
+
except (FileNotFoundError, OSError):
|
|
52
|
+
continue
|
|
53
|
+
if time.monotonic() >= deadline:
|
|
54
|
+
raise SystemExit(
|
|
55
|
+
f"Zero Slop state is busy ({lock.name}); retry in a moment."
|
|
56
|
+
)
|
|
57
|
+
time.sleep(0.05)
|
|
58
|
+
yield
|
|
59
|
+
finally:
|
|
60
|
+
for lock in reversed(acquired):
|
|
61
|
+
try:
|
|
62
|
+
lock.rmdir()
|
|
63
|
+
except FileNotFoundError:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def atomic_write_text(path, text, *, mode=None):
|
|
68
|
+
"""Durably replace a text file without exposing a partial write."""
|
|
69
|
+
path = Path(path)
|
|
70
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
if mode is None and path.exists():
|
|
72
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
73
|
+
mode = 0o644 if mode is None else mode
|
|
74
|
+
tmp_name = None
|
|
75
|
+
try:
|
|
76
|
+
with tempfile.NamedTemporaryFile(
|
|
77
|
+
"w", encoding="utf-8", dir=path.parent,
|
|
78
|
+
prefix=f".{path.name}.", suffix=".tmp", delete=False) as fh:
|
|
79
|
+
tmp_name = fh.name
|
|
80
|
+
fh.write(text)
|
|
81
|
+
fh.flush()
|
|
82
|
+
os.fsync(fh.fileno())
|
|
83
|
+
os.chmod(tmp_name, mode)
|
|
84
|
+
os.replace(tmp_name, path)
|
|
85
|
+
tmp_name = None
|
|
86
|
+
# Make the rename durable where directory fsync is available.
|
|
87
|
+
try:
|
|
88
|
+
fd = os.open(path.parent, os.O_RDONLY)
|
|
89
|
+
try:
|
|
90
|
+
os.fsync(fd)
|
|
91
|
+
finally:
|
|
92
|
+
os.close(fd)
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
finally:
|
|
96
|
+
if tmp_name:
|
|
97
|
+
try:
|
|
98
|
+
os.unlink(tmp_name)
|
|
99
|
+
except FileNotFoundError:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def atomic_write_bytes(path, data, *, mode=None):
|
|
104
|
+
"""Binary counterpart to :func:`atomic_write_text`."""
|
|
105
|
+
if not isinstance(data, (bytes, bytearray, memoryview)):
|
|
106
|
+
raise TypeError("data must be bytes-like")
|
|
107
|
+
path = Path(path)
|
|
108
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
if mode is None and path.exists():
|
|
110
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
111
|
+
mode = 0o644 if mode is None else mode
|
|
112
|
+
tmp_name = None
|
|
113
|
+
try:
|
|
114
|
+
with tempfile.NamedTemporaryFile(
|
|
115
|
+
"wb", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp",
|
|
116
|
+
delete=False) as fh:
|
|
117
|
+
tmp_name = fh.name
|
|
118
|
+
fh.write(data)
|
|
119
|
+
fh.flush()
|
|
120
|
+
os.fsync(fh.fileno())
|
|
121
|
+
os.chmod(tmp_name, mode)
|
|
122
|
+
os.replace(tmp_name, path)
|
|
123
|
+
tmp_name = None
|
|
124
|
+
try:
|
|
125
|
+
fd = os.open(path.parent, os.O_RDONLY)
|
|
126
|
+
try:
|
|
127
|
+
os.fsync(fd)
|
|
128
|
+
finally:
|
|
129
|
+
os.close(fd)
|
|
130
|
+
except OSError:
|
|
131
|
+
pass
|
|
132
|
+
finally:
|
|
133
|
+
if tmp_name:
|
|
134
|
+
try:
|
|
135
|
+
os.unlink(tmp_name)
|
|
136
|
+
except FileNotFoundError:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def is_within(path, parent):
|
|
141
|
+
"""True only when the resolved path is inside the resolved parent."""
|
|
142
|
+
try:
|
|
143
|
+
Path(path).resolve().relative_to(Path(parent).resolve())
|
|
144
|
+
return True
|
|
145
|
+
except ValueError:
|
|
146
|
+
return False
|