zero-slop 2.7.7 → 2.8.0
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 +1 -1
- package/README.md +110 -39
- package/SKILL.md +16 -4
- package/bin/zero-slop.mjs +85 -15
- package/package.json +3 -2
- package/references/eval.md +5 -2
- package/scripts/learn.py +2 -2
- package/scripts/register.py +196 -13
- package/scripts/rerank.py +12 -3
- package/scripts/safeio.py +4 -3
- package/scripts/slopscore.py +99 -22
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -5,20 +5,70 @@
|
|
|
5
5
|
<img alt="tests" src="https://img.shields.io/badge/tests-passing-227B5B">
|
|
6
6
|
<img alt="dependencies" src="https://img.shields.io/badge/runtime%20dependencies-0-227B5B">
|
|
7
7
|
<img alt="privacy" src="https://img.shields.io/badge/learning-private-227B5B">
|
|
8
|
-
<img alt="version" src="https://img.shields.io/badge/version-2.
|
|
8
|
+
<img alt="version" src="https://img.shields.io/badge/version-2.8.0-72528F">
|
|
9
|
+
<a href="https://hol.org/guard/plugins"><img alt="Listed in the HOL plugin registry" src="https://img.shields.io/badge/HOL%20registry-listed-2C6E8F"></a>
|
|
10
|
+
<a href="https://github.com/hashgraph-online/awesome-ai-plugins#tools--integrations"><img alt="Listed in awesome-ai-plugins" src="https://img.shields.io/badge/awesome--ai--plugins-listed-2C6E8F"></a>
|
|
11
|
+
<a href="https://zero-slop.ai/try/"><img alt="This README scores 12.0 out of 100 on the slop score, inside the human range" src="https://img.shields.io/badge/slop%20score-12.0%2F100-0f7d55"></a>
|
|
9
12
|
</p>
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
Less slop, more pop in your writing.
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
Zero Slop finds stock phrasing, mechanical rhythm, vague claims, and canned
|
|
17
|
+
formatting, then gives your AI assistant or agent harness an editing workflow on guardrails. Its
|
|
18
|
+
MIT-licensed local checks run offline; your existing AI assistant or harness does the editing.
|
|
19
|
+
Score a draft at [zero-slop.ai/try](https://zero-slop.ai/try/); benchmark at
|
|
20
|
+
[zero-slop.ai](https://zero-slop.ai).
|
|
14
21
|
|
|
15
|
-
|
|
22
|
+
```sh
|
|
23
|
+
npx zero-slop install
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
### What it does, on a real draft
|
|
29
|
+
|
|
30
|
+
A launch post, as AI wrote it:
|
|
31
|
+
|
|
32
|
+
> We're thrilled to announce that our team has leveraged cutting-edge AI to deliver a seamless onboarding experience. It's not just a redesign, it's a fundamental reimagining of how users engage with our platform. In today's fast-paced landscape, the ability to iterate quickly is crucial. By harnessing the power of machine learning, we've been able to reduce setup time by 40%. Here's the thing nobody tells you: onboarding is where most products lose their users. We're excited to continue this journey with you.
|
|
33
|
+
|
|
34
|
+
`slopscore.py --explain` on that paragraph, unedited:
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
Writing score: 100.0/100 [major rewrite]
|
|
38
|
+
Flagged phrases : 11 across 83 words
|
|
39
|
+
Sentence variety: too even
|
|
40
|
+
Main issues : canned LinkedIn phrase, promotional language, manufactured
|
|
41
|
+
stakes, two-part contrast used as a formula, performed
|
|
42
|
+
writer's voice, buzzword used as promotion
|
|
43
|
+
|
|
44
|
+
Flagged phrases (11), strongest first:
|
|
45
|
+
"In today's fast-paced" manufactured stakes; start where the reader needs to start
|
|
46
|
+
"We're thrilled to" canned LinkedIn phrase; say what happened without the stock opening
|
|
47
|
+
"It's not just a redesign, it's"
|
|
48
|
+
two-part contrast used as a formula; state the claim once, plainly
|
|
49
|
+
"Here's the thing" performed writer's voice; say the thing plainly
|
|
50
|
+
'cutting-edge' promotional language; name what it does; cut the adjectives
|
|
51
|
+
'leveraged' buzzword used as promotion; use the plain word
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The rewrite, limited to the draft's stated claims:
|
|
55
|
+
|
|
56
|
+
> We used machine learning to reduce onboarding setup time by 40%.
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
Writing score: 9.5/100 [clear]
|
|
60
|
+
Flagged phrases : 0 across 10 words
|
|
61
|
+
```
|
|
16
62
|
|
|
17
63
|
## Problem
|
|
18
64
|
|
|
19
|
-
AI writing
|
|
65
|
+
AI-assisted writing often converges on the same constructions: "It's not X. It's Y."
|
|
66
|
+
"Here's the thing nobody tells you." One emdash is fine. Multiple emdashes, definitely slop. The same effect can come from repetition in the wording or the structure. Mechanical rhythm and overworked formatting can do it too.
|
|
20
67
|
|
|
21
|
-
Zero Slop is an Agent Skill and ships no model. Claude, GPT, or another compatible
|
|
68
|
+
Zero Slop is an Agent Skill and ships no model. Claude, GPT, or another compatible
|
|
69
|
+
model edits; local tools check names, numbers, quotations, links, code, tables, and
|
|
70
|
+
paths. Your AI compares meaning because matching words cannot catch every changed
|
|
71
|
+
claim. It runs in any harness that reads `SKILL.md`.
|
|
22
72
|
|
|
23
73
|
## How to install Zero Slop
|
|
24
74
|
|
|
@@ -34,7 +84,10 @@ Or install it with `npx`:
|
|
|
34
84
|
npx skills add manavmishra/ZeroSlop --global
|
|
35
85
|
```
|
|
36
86
|
|
|
37
|
-
Or from the registry, which also installs the scorer as a command
|
|
87
|
+
Or from the registry, which also installs the scorer as a command. Zero Slop is
|
|
88
|
+
listed in [awesome-ai-plugins](https://github.com/hashgraph-online/awesome-ai-plugins#tools--integrations)
|
|
89
|
+
and carries a public profile in the [HOL plugin registry](https://hol.org/guard/plugins),
|
|
90
|
+
where its trust score and scanner results are published:
|
|
38
91
|
|
|
39
92
|
```sh
|
|
40
93
|
npx zero-slop install # add --harness codex|cursor|opencode|zed
|
|
@@ -42,7 +95,8 @@ npx zero-slop score draft.md # score without installing anything
|
|
|
42
95
|
```
|
|
43
96
|
|
|
44
97
|
ChatGPT users can download [`dist/zero-slop-single-file.md`](dist/zero-slop-single-file.md).
|
|
45
|
-
Claude.ai users can upload [
|
|
98
|
+
Claude.ai users can upload the [latest release ZIP](https://github.com/manavmishra/ZeroSlop/releases/latest/download/zero-slop.zip).
|
|
99
|
+
`npx skills update zero-slop --global` updates a skills CLI installation later.
|
|
46
100
|
|
|
47
101
|
## How to use Zero Slop
|
|
48
102
|
|
|
@@ -50,8 +104,21 @@ Claude.ai users can upload [`dist/zero-slop.zip`](dist/zero-slop.zip). `npx skil
|
|
|
50
104
|
/zero-slop (your writing)
|
|
51
105
|
```
|
|
52
106
|
|
|
53
|
-
You get the edited draft,
|
|
54
|
-
|
|
107
|
+
You get the edited draft, before-and-after scores, and quoted flagged phrases.
|
|
108
|
+
`/zero-slop inspect (your writing)` reviews without rewriting. For a folder,
|
|
109
|
+
`slopscore.py --batch drafts/ --gate 25` fails above the threshold.
|
|
110
|
+
|
|
111
|
+
## Show your score
|
|
112
|
+
|
|
113
|
+
The badge above is this README's own, from `npx zero-slop score README.md`.
|
|
114
|
+
Put yours up the same way:
|
|
115
|
+
|
|
116
|
+
```md
|
|
117
|
+
[](https://zero-slop.ai/try/)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Colours are the scorer's bands: `0f7d55` under 25, `b8860b` under 60, `b0502c`
|
|
121
|
+
above.
|
|
55
122
|
|
|
56
123
|
## The slop that Zero Slop catches
|
|
57
124
|
|
|
@@ -68,9 +135,9 @@ For a folder, `slopscore.py --batch drafts/ --gate 25` fails the build above the
|
|
|
68
135
|
9. Synonym cycling: the agent, the assistant, the tool, all one thing.
|
|
69
136
|
10. Marketing riders: "robust" and "leverage" score only beside a marketing trigger, so a runbook stays quiet.
|
|
70
137
|
|
|
71
|
-
A reading pass covers
|
|
72
|
-
paragraphs that shuffle without loss. [`references/eval.md`](references/eval.md)
|
|
73
|
-
80 checks.
|
|
138
|
+
A reading pass covers document-wide problems: repeated shapes, crowded statistics,
|
|
139
|
+
and paragraphs that shuffle without loss. [`references/eval.md`](references/eval.md)
|
|
140
|
+
has all 80 checks.
|
|
74
141
|
|
|
75
142
|
Human writing scored 9 to 21 in [`data/corpus/must-not-flag/`](data/corpus/must-not-flag/);
|
|
76
143
|
unedited AI drafts averaged 77 across [`bench/examples.json`](bench/examples.json).
|
|
@@ -84,7 +151,7 @@ Eight roles form one workflow. Each is a job rather than a service, run as its o
|
|
|
84
151
|
|
|
85
152
|
| Role | Who does it | What happens |
|
|
86
153
|
|---|---|---|
|
|
87
|
-
| 1. Scorer | Local tools | Finds the exact
|
|
154
|
+
| 1. Scorer | Local tools | Finds the exact phrases behind the writing score, then checks pacing and readability. It also catches overworked formatting. |
|
|
88
155
|
| 2. Interpreter | Your AI assistant | Reads the claims, purpose, audience, structure, and voice before changing anything. |
|
|
89
156
|
| 3. Rewriter | Your AI assistant | Removes stock language and rebuilds order, rhythm, and tone without inventing detail. |
|
|
90
157
|
| 4. Fact gate | Local tools | Rejects any version that changes names, numbers, quotations, links, code, tables, paths, or structure. |
|
|
@@ -101,15 +168,13 @@ tools use only Python's standard library.
|
|
|
101
168
|
|
|
102
169
|
## Private learning from your edits
|
|
103
170
|
|
|
104
|
-
Learning starts only when you
|
|
105
|
-
|
|
171
|
+
Learning starts only when you provide the original output and your edited version.
|
|
172
|
+
Zero Slop does not monitor files, browsers, or publishing tools.
|
|
106
173
|
|
|
107
|
-
Private
|
|
108
|
-
data stays under `$ZERO_SLOP_HOME`.
|
|
174
|
+
Private data stays under `$ZERO_SLOP_HOME`.
|
|
109
175
|
|
|
110
|
-
This
|
|
111
|
-
|
|
112
|
-
words when selected by name; it does not learn cadence, tone, or a complete writing
|
|
176
|
+
This human-in-the-loop learning never retrains the model. A profile selected by name
|
|
177
|
+
can exempt existing watchlist words; it does not learn cadence, tone, or a complete
|
|
113
178
|
style.
|
|
114
179
|
|
|
115
180
|
## What's inside
|
|
@@ -120,16 +185,18 @@ with [`scripts/register.py`](scripts/register.py) running the reading pass.
|
|
|
120
185
|
[`bench/README.md`](bench/README.md) documents every benchmark with its limits.
|
|
121
186
|
[zero-slop.ai](https://zero-slop.ai) has the same reference as browsable pages, plus the [benchmark in full](https://zero-slop.ai/benchmark/).
|
|
122
187
|
|
|
123
|
-
##
|
|
188
|
+
## Testing and limits
|
|
124
189
|
|
|
125
190
|
### Against other tools, same model, same drafts
|
|
126
191
|
|
|
127
|
-
|
|
192
|
+
The saved replay ran Zero Slop, [avoid-ai-writing](https://github.com/conorbronsdon/avoid-ai-writing),
|
|
128
193
|
[no-ai-slop](https://github.com/petergyang/no-ai-slop) and
|
|
129
|
-
[humanizer](https://github.com/blader/humanizer) on the same
|
|
130
|
-
GPT-5.4, high reasoning, batches of three, and
|
|
194
|
+
[humanizer](https://github.com/blader/humanizer) on the same 18 drafts, each with
|
|
195
|
+
GPT-5.4, high reasoning, batches of three, and pinned instructions. The Zero Slop
|
|
196
|
+
outputs came from v2.5.9; later releases rescore those frozen outputs but do not
|
|
197
|
+
pretend to have regenerated them.
|
|
131
198
|
|
|
132
|
-
| Method | Mean writing score ↓ | Passed
|
|
199
|
+
| Method | Mean writing score ↓ | Passed Zero Slop's local gates | Source check passed | Average length change |
|
|
133
200
|
|---|---:|---:|---:|---:|
|
|
134
201
|
| Original drafts | 76.3 | 0/18 | — | — |
|
|
135
202
|
| Zero Slop | 12.8 | 18/18 | 18/18 | -8.9% |
|
|
@@ -151,19 +218,23 @@ rates, and a method-hidden quality ranking.
|
|
|
151
218
|

|
|
152
219
|
|
|
153
220
|
This is a small LLM-reviewed regression study. It measures neither field accuracy nor a
|
|
154
|
-
universal ranking. Drafts, mappings, verdicts, hashes and limits
|
|
155
|
-
[`bench/
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
221
|
+
universal ranking. Drafts, mappings, verdicts, hashes, version records, and limits are
|
|
222
|
+
in [`bench/README.md`](bench/README.md). The separate method-hidden two-way replay used
|
|
223
|
+
Zero Slop v2.6.0 and is preserved in
|
|
224
|
+
[`bench/incumbent-blind-replay/`](bench/incumbent-blind-replay/).
|
|
225
|
+
|
|
226
|
+
For the 38-item editorial panel, the current scorer matched the prior 84.2% result.
|
|
227
|
+
All frozen scores stayed unchanged, all 18 human controls remained below the gate,
|
|
228
|
+
and all 18 obvious search cases remained above it. These fixed-sample checks are not
|
|
229
|
+
proof of general accuracy. We recorded 1.86% lower median throughput across 31 interleaved
|
|
230
|
+
local runs; treat that as machine noise, not a speed claim.
|
|
161
231
|
|
|
162
232
|
### Speed
|
|
163
233
|
|
|
164
|
-
On one Apple silicon Mac: 1,000 documents in
|
|
165
|
-
15,201-word document in 0.
|
|
166
|
-
8,000-word learning pass in 0.
|
|
234
|
+
On one busy Apple silicon Mac: 1,000 documents in 3.2233 seconds (310.2 per second),
|
|
235
|
+
a 15,201-word document in 0.4757 seconds, the slowest stress case in 3.3487 seconds,
|
|
236
|
+
and an 8,000-word learning pass in 0.2284 seconds. Editing time is excluded; expect
|
|
237
|
+
different results on other machines and loads.
|
|
167
238
|
|
|
168
239
|
### Current models
|
|
169
240
|
|
|
@@ -183,9 +254,9 @@ Beemo, raw responses averaged
|
|
|
183
254
|
|
|
184
255
|
## Where Zero Slop came from
|
|
185
256
|
|
|
186
|
-
Zero Slop
|
|
187
|
-
avoid-ai-writing
|
|
188
|
-
private learning, portfolio analysis and release tests.
|
|
257
|
+
Zero Slop builds on work by no-ai-slop, humanizer, de-slop, stop-slop, unslop-text,
|
|
258
|
+
and avoid-ai-writing. It adds a writing score, source protection, separate editorial
|
|
259
|
+
passes, private learning, portfolio analysis, and release tests.
|
|
189
260
|
|
|
190
261
|

|
|
191
262
|
|
package/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: zero-slop
|
|
3
3
|
license: MIT
|
|
4
4
|
metadata:
|
|
5
|
-
version: "2.
|
|
5
|
+
version: "2.8.0"
|
|
6
6
|
author: manavmishra
|
|
7
7
|
description: Turn drafts into sharp, natural prose or inspect them without rewriting. Zero Slop runs inside the user's existing AI assistant; Claude, GPT, or another compatible model reads and edits in context while local tools point to exact phrases and protect the source. Use when the user asks to humanize or de-slop writing, inspect AI-sounding patterns, fix text that reads like ChatGPT, polish outward-facing prose, draft social or LinkedIn content, or apply a final quality check to prose the agent generated. The workflow preserves facts, voice, and format and learns privately from repeated, reason-labelled human edits.
|
|
8
8
|
---
|
|
@@ -504,6 +504,17 @@ Re-run the local tools. A version clears the fact gate only when ALL hold:
|
|
|
504
504
|
wording may change; their content and nesting may not. This deterministic
|
|
505
505
|
check still cannot see a subtly reframed claim, changed emphasis, or shifted
|
|
506
506
|
implication, so the judgment pass below remains mandatory
|
|
507
|
+
- if a reviewer confirms that a dropped figure was an unsourced flourish rather
|
|
508
|
+
than a fact, record the decision in a source-bound JSON file and rerun:
|
|
509
|
+
|
|
510
|
+
```
|
|
511
|
+
python3 <skill-root>/scripts/slopscore.py --fidelity \
|
|
512
|
+
--adjudication <ruling.json> <original> <rewrite>
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
The file contains schema `1`, the SHA-256 of the exact original text, and
|
|
516
|
+
`allow_dropped_figures`. It can excuse only figures found in that source; it
|
|
517
|
+
cannot weaken checks for names, quotations, links, feelings, or structure
|
|
507
518
|
- shape (social genres only): the scorer reports `broetry` when most
|
|
508
519
|
paragraphs are single sentences and fragments run three or more deep. This
|
|
509
520
|
is its own axis, never folded into the score, because broetry is a slop tell
|
|
@@ -741,9 +752,10 @@ facts, meaning, structure, and whether the writing is performing rather than say
|
|
|
741
752
|
```
|
|
742
753
|
|
|
743
754
|
The "two-part contrasts / announcements" row is the performed-register count from
|
|
744
|
-
step 2. Add a line
|
|
745
|
-
|
|
746
|
-
|
|
755
|
+
step 2. Add a line beside it: `Final review: 80 checks, 0 failed` or the count
|
|
756
|
+
that did fail. The 80 checks include AI reading, local measurements, and source
|
|
757
|
+
protection; do not imply that one script performed all of them. **Print it even when
|
|
758
|
+
both numbers are zero**, and print it on a draft that
|
|
747
759
|
scored clean. It is the only evidence that the pass ran; a report without it is a
|
|
748
760
|
report that skipped it.
|
|
749
761
|
|
package/bin/zero-slop.mjs
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
// mirrors rather than people. This is the executable half: it installs the same
|
|
6
6
|
// runtime the tarball already carries, and runs the scorer without a checkout.
|
|
7
7
|
|
|
8
|
-
import { cp, mkdir, readFile, rm, stat } from "node:fs/promises";
|
|
8
|
+
import { cp, lstat, mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
|
-
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import { dirname, join, parse, resolve } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
|
|
14
14
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -41,7 +41,7 @@ Usage
|
|
|
41
41
|
Install targets
|
|
42
42
|
--harness ${Object.keys(HARNESS_DIRS).join(", ")} (default: claude)
|
|
43
43
|
--dir install into an explicit directory instead
|
|
44
|
-
--force
|
|
44
|
+
--force replace a verified Zero Slop installation
|
|
45
45
|
|
|
46
46
|
Examples
|
|
47
47
|
npx zero-slop install # ~/.claude/skills/zero-slop
|
|
@@ -63,10 +63,18 @@ function parseArgs(argv) {
|
|
|
63
63
|
break;
|
|
64
64
|
}
|
|
65
65
|
if (a === "--force") flags.force = true;
|
|
66
|
-
else if (a === "--harness" || a === "--dir")
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
else if (a === "--harness" || a === "--dir") {
|
|
67
|
+
const value = argv[i + 1];
|
|
68
|
+
if (!value || value.startsWith("--")) throw new Error(`${a} needs a value`);
|
|
69
|
+
flags[a.slice(2)] = value;
|
|
70
|
+
i += 1;
|
|
71
|
+
} else if (a.startsWith("--harness=")) {
|
|
72
|
+
flags.harness = a.slice("--harness=".length);
|
|
73
|
+
if (!flags.harness) throw new Error("--harness needs a value");
|
|
74
|
+
} else if (a.startsWith("--dir=")) {
|
|
75
|
+
flags.dir = a.slice("--dir=".length);
|
|
76
|
+
if (!flags.dir) throw new Error("--dir needs a value");
|
|
77
|
+
} else rest.push(a);
|
|
70
78
|
}
|
|
71
79
|
return { flags, rest, passthrough };
|
|
72
80
|
}
|
|
@@ -92,21 +100,77 @@ async function exists(p) {
|
|
|
92
100
|
}
|
|
93
101
|
}
|
|
94
102
|
|
|
103
|
+
function assertNarrowTarget(dest) {
|
|
104
|
+
const target = resolve(dest);
|
|
105
|
+
const forbidden = new Set([
|
|
106
|
+
parse(target).root,
|
|
107
|
+
resolve(homedir()),
|
|
108
|
+
resolve(process.cwd()),
|
|
109
|
+
ROOT,
|
|
110
|
+
]);
|
|
111
|
+
if (forbidden.has(target)) {
|
|
112
|
+
throw new Error(`refusing broad install target: ${target}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function verifiedExistingInstall(dest) {
|
|
117
|
+
const info = await lstat(dest);
|
|
118
|
+
if (info.isSymbolicLink() || !info.isDirectory()) return false;
|
|
119
|
+
const entries = await readdir(dest);
|
|
120
|
+
if (!entries.length) return true;
|
|
121
|
+
try {
|
|
122
|
+
const skill = await readFile(join(dest, "SKILL.md"), "utf8");
|
|
123
|
+
const header = skill.split("---", 3)[1] ?? "";
|
|
124
|
+
const runtime = await lstat(join(dest, "scripts", "slopscore.py"));
|
|
125
|
+
const patterns = await lstat(join(dest, "data", "patterns.json"));
|
|
126
|
+
return /^name:\s*zero-slop\s*$/m.test(header)
|
|
127
|
+
&& runtime.isFile() && patterns.isFile();
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
95
133
|
async function install(flags) {
|
|
96
134
|
const dest = targetDir(flags);
|
|
97
|
-
|
|
135
|
+
assertNarrowTarget(dest);
|
|
136
|
+
const present = await exists(dest);
|
|
137
|
+
if (present && !flags.force) {
|
|
98
138
|
console.error(
|
|
99
139
|
`zero-slop is already installed at ${dest}\n` +
|
|
100
140
|
`Re-run with --force to overwrite it, or --dir to install elsewhere.`,
|
|
101
141
|
);
|
|
102
142
|
return 1;
|
|
103
143
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
144
|
+
if (present && !(await verifiedExistingInstall(dest))) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`refusing to overwrite ${dest}: it is not a Zero Slop installation`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Build the complete payload beside the destination before replacing an
|
|
151
|
+
// existing install. A copy failure therefore leaves the working version
|
|
152
|
+
// untouched, and the backup is restored if the final rename fails.
|
|
153
|
+
const nonce = `${process.pid}-${Date.now()}`;
|
|
154
|
+
const staged = `${dest}.installing-${nonce}`;
|
|
155
|
+
const backup = `${dest}.backup-${nonce}`;
|
|
156
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
157
|
+
await mkdir(staged);
|
|
158
|
+
try {
|
|
159
|
+
for (const entry of PAYLOAD) {
|
|
160
|
+
await cp(join(ROOT, entry), join(staged, entry), { recursive: true });
|
|
161
|
+
}
|
|
162
|
+
if (present) await rename(dest, backup);
|
|
163
|
+
try {
|
|
164
|
+
await rename(staged, dest);
|
|
165
|
+
} catch (exc) {
|
|
166
|
+
if (present && (await exists(backup)) && !(await exists(dest))) {
|
|
167
|
+
await rename(backup, dest);
|
|
168
|
+
}
|
|
169
|
+
throw exc;
|
|
170
|
+
}
|
|
171
|
+
if (present) await rm(backup, { recursive: true, force: true });
|
|
172
|
+
} finally {
|
|
173
|
+
await rm(staged, { recursive: true, force: true });
|
|
110
174
|
}
|
|
111
175
|
console.log(`Installed zero-slop ${await version()} into ${dest}`);
|
|
112
176
|
console.log("Restart your agent, then run: /zero-slop (your writing)");
|
|
@@ -147,7 +211,13 @@ async function main() {
|
|
|
147
211
|
console.log(usage(v));
|
|
148
212
|
return command || argv.length ? 0 : 0;
|
|
149
213
|
}
|
|
150
|
-
if (command === "install")
|
|
214
|
+
if (command === "install") {
|
|
215
|
+
if (rest.length !== 1 || passthrough.length) {
|
|
216
|
+
console.error("install accepts only --harness, --dir, and --force");
|
|
217
|
+
return 2;
|
|
218
|
+
}
|
|
219
|
+
return install(flags);
|
|
220
|
+
}
|
|
151
221
|
if (command === "where") {
|
|
152
222
|
console.log(targetDir(flags));
|
|
153
223
|
return 0;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zero-slop",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "An Agent Skill that scores AI-sounding prose
|
|
3
|
+
"version": "2.8.0",
|
|
4
|
+
"description": "An Agent Skill that scores AI-sounding prose locally, guides the host AI through an editorial rewrite, and checks changed source details. The local tools run offline with zero dependencies.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"zero-slop": "bin/zero-slop.mjs"
|
|
7
7
|
},
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"!scripts/contextual.py",
|
|
51
51
|
"!scripts/contextual-signals.md",
|
|
52
52
|
"!references/contextual-signals.md",
|
|
53
|
+
"!scripts/make-readme-gif.mjs",
|
|
53
54
|
"!scripts/growth-snapshot.mjs",
|
|
54
55
|
"!data/voices/",
|
|
55
56
|
"!**/__pycache__/"
|
package/references/eval.md
CHANGED
|
@@ -232,8 +232,11 @@ the report even when they are zero.
|
|
|
232
232
|
(`figure_evidence`) and waits. Rule on each one: a figure with a source, a unit,
|
|
233
233
|
a baseline or a citation must be restored; an unsourced multiplier or statistic
|
|
234
234
|
doing an intensifier's job -- "the 10x move", "tenfold", "~70% of pilots fail" --
|
|
235
|
-
was right to cut
|
|
236
|
-
|
|
235
|
+
was right to cut. Record that ruling in a source-bound JSON file and rerun
|
|
236
|
+
`slopscore.py --fidelity --adjudication ruling.json original rewrite`; the
|
|
237
|
+
file carries schema `1`, the exact original text's SHA-256, and an
|
|
238
|
+
`allow_dropped_figures` list. Never invent a citation to keep a number alive.
|
|
239
|
+
Unruled means unresolved:
|
|
237
240
|
the default is that the figure stays, so silence here is a failed check and not
|
|
238
241
|
a pass. Both errors are real. Restoring a flourish is how a rewrite ships fake
|
|
239
242
|
precision, and dropping a measurement is how it ships a lie.
|
package/scripts/learn.py
CHANGED
|
@@ -71,8 +71,8 @@ SHARED_LOG = DATA / "learned-log.md"
|
|
|
71
71
|
# default and lives outside the repository — a checkout should never carry one
|
|
72
72
|
# person's writing. Override with ZERO_SLOP_HOME to relocate or to share a
|
|
73
73
|
# deliberate team-scoped store.
|
|
74
|
-
HOME = Path(os.environ.get("ZERO_SLOP_HOME"
|
|
75
|
-
|
|
74
|
+
HOME = Path(os.environ.get("ZERO_SLOP_HOME")
|
|
75
|
+
or Path.home() / ".zero-slop").expanduser()
|
|
76
76
|
OBS = HOME / "reflections.json"
|
|
77
77
|
LOCAL = HOME / "learned.json"
|
|
78
78
|
LOCAL_LOG = HOME / "learned-log.md"
|
package/scripts/register.py
CHANGED
|
@@ -35,6 +35,7 @@ import sys
|
|
|
35
35
|
# certified-human samples would let these budgets be derived rather than argued.
|
|
36
36
|
MIN_WORDS = 300
|
|
37
37
|
BUDGETS = {
|
|
38
|
+
"antithesis_pair": (0.0, 2),
|
|
38
39
|
"subtractive_contrast": (6.0, 3),
|
|
39
40
|
"comma_series": (26.0, 8),
|
|
40
41
|
"significance_scaffolding": (0.0, 1),
|
|
@@ -51,6 +52,90 @@ BUDGETS = {
|
|
|
51
52
|
"referent_cluster": (1.5, 1),
|
|
52
53
|
"adjective_inflation": (1.5, 1),
|
|
53
54
|
}
|
|
55
|
+
# A short draft cannot support a per-1,000-word rate, so it uses an absolute
|
|
56
|
+
# recurrence floor instead. Two families appear once in the certified-human
|
|
57
|
+
# corpus; requiring two keeps those controls quiet while still closing the old
|
|
58
|
+
# "everything under 300 words passes" bypass.
|
|
59
|
+
SHORT_FLOORS = {
|
|
60
|
+
**{key: floor for key, (_budget, floor) in BUDGETS.items()},
|
|
61
|
+
"dangling_pointer": 2,
|
|
62
|
+
"referent_cluster": 2,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# eval.md A1 / SKILL.md step 2.1: antithesis pairs -- two balanced statements,
|
|
66
|
+
# the second landing the twist. Budget is ONE per piece, and "three or more under
|
|
67
|
+
# 500 words means the register failed whatever the score said."
|
|
68
|
+
#
|
|
69
|
+
# This report had no row for that family. RX_SUBTRACTIVE below is a different
|
|
70
|
+
# check -- eval.md A2, the corrective appositive, judged on density -- and it was
|
|
71
|
+
# carrying the whole contrast family's name in the report while A1 went
|
|
72
|
+
# unmeasured. A 479-word manifesto with five antithesis pairs in it therefore
|
|
73
|
+
# printed "Binary contrasts: 1 found, ok" and nothing else about contrast.
|
|
74
|
+
#
|
|
75
|
+
# Two of the four shapes are reachable without a parser, and only those two ship:
|
|
76
|
+
# marked staccato -- "Not perfect. Honest."
|
|
77
|
+
# adjacent isocolon -- one verb frame, both arguments swapped: "Speed is moving
|
|
78
|
+
# fast. Velocity is moving fast in the same direction."
|
|
79
|
+
# Bare subject swap and unmarked reversal stay the reader's judgment, exactly as
|
|
80
|
+
# references/tells.md already says they must.
|
|
81
|
+
ANTITHESIS_STOP = frozenset("""
|
|
82
|
+
a an the this that these those it its is are was were be been being am do does did
|
|
83
|
+
to of in on at by for with from as and or but so if then than not no nor yet
|
|
84
|
+
we you they he she i us our your their his her them me my their there here
|
|
85
|
+
one two three first second next last own same very just only also more most less
|
|
86
|
+
""".split())
|
|
87
|
+
|
|
88
|
+
# The negation can open the pair ("Not perfect. Honest.") or land at the end of
|
|
89
|
+
# it ("The draft was cheap. The signal it sent was not."). Both are in tells.md.
|
|
90
|
+
RX_MARKED_OPEN = re.compile(r"^\W*not\b", re.I)
|
|
91
|
+
RX_MARKED_CLOSE = re.compile(r"\b(?:was|were|is|are|did|does|do|has|have|had|will)\s+not\W*$", re.I)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _antithesis_content(sent: str) -> list[str]:
|
|
95
|
+
"""Content words, with a trailing -s folded away so a verb frame still
|
|
96
|
+
matches when only its agreement changed: 'let' and 'lets' are one frame."""
|
|
97
|
+
out = []
|
|
98
|
+
for word in re.findall(r"[A-Za-z][\w'-]*", sent):
|
|
99
|
+
word = word.lower()
|
|
100
|
+
if word in ANTITHESIS_STOP:
|
|
101
|
+
continue
|
|
102
|
+
if len(word) > 3 and word.endswith("s") and not word.endswith("ss"):
|
|
103
|
+
word = word[:-1]
|
|
104
|
+
out.append(word)
|
|
105
|
+
return out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def antithesis_pairs(prose: str) -> list[str]:
|
|
109
|
+
"""Adjacent balanced sentences: the marked form, and the swapped-argument one."""
|
|
110
|
+
out = []
|
|
111
|
+
sents = _sentences(prose)
|
|
112
|
+
for first, second in zip(sents, sents[1:]):
|
|
113
|
+
n_first, n_second = len(first.split()), len(second.split())
|
|
114
|
+
# Both halves have to be short enough to read as one figure. A short
|
|
115
|
+
# line beside a paragraph-length sentence is not a balanced pair.
|
|
116
|
+
if not (2 <= n_first <= 14 and 1 <= n_second <= 14):
|
|
117
|
+
continue
|
|
118
|
+
# Marked: the twist is announced, at either end of the pair.
|
|
119
|
+
if RX_MARKED_OPEN.match(first) and n_second <= 10:
|
|
120
|
+
out.append(f"{first} {second}")
|
|
121
|
+
continue
|
|
122
|
+
if RX_MARKED_CLOSE.search(second) and n_first <= 10:
|
|
123
|
+
out.append(f"{first} {second}")
|
|
124
|
+
continue
|
|
125
|
+
head, tail = _antithesis_content(first), _antithesis_content(second)
|
|
126
|
+
if len(head) < 2 or len(tail) < 2:
|
|
127
|
+
continue
|
|
128
|
+
# Identical content is repetition, not antithesis: the figure needs the
|
|
129
|
+
# arguments to have changed.
|
|
130
|
+
if head == tail:
|
|
131
|
+
continue
|
|
132
|
+
shared = set(head) & set(tail)
|
|
133
|
+
# A shared frame carrying most of the shorter sentence, with the
|
|
134
|
+
# arguments changed: that is the figure, not an accidental echo.
|
|
135
|
+
if len(shared) >= 2 and len(shared) / min(len(head), len(tail)) >= 0.5:
|
|
136
|
+
out.append(f"{first} {second}")
|
|
137
|
+
return out
|
|
138
|
+
|
|
54
139
|
|
|
55
140
|
# "X, not Y." and "A rather than B." The corrective appositive. Each instance is
|
|
56
141
|
# usually careful writing, which is why no pattern list contains it.
|
|
@@ -133,6 +218,28 @@ RX_INFLATION = re.compile(
|
|
|
133
218
|
r"(?:improvement|progress|difference|impact|result|results|value|win|shift"
|
|
134
219
|
r"|change|benefit|breakthrough|game.?changer)\b", re.I)
|
|
135
220
|
|
|
221
|
+
# An imperative is the bare base form, so it carries none of the inflection
|
|
222
|
+
# (-s, -ed, -es) or the auxiliaries FINITE_VERB looks for: "Play to win." and
|
|
223
|
+
# "Build durable growth." were both being reported as verbless fragments. On the
|
|
224
|
+
# seven-line manifesto that surfaced this, two of the five hits were imperatives,
|
|
225
|
+
# so 40% of the document's one finding was wrong. A closed list is the same
|
|
226
|
+
# device FINITE_VERB already is, and it only ever fires at a sentence opening.
|
|
227
|
+
IMPERATIVE_OPENER = re.compile(
|
|
228
|
+
r"^\W*(?:and|but|so|then|now|first|next|finally)?\W*"
|
|
229
|
+
r"(?:add|aim|allow|apply|ask|assume|avoid|begin|book|break|bring|build|buy"
|
|
230
|
+
r"|call|change|check|choose|clean|clear|click|close|collect|come|compare"
|
|
231
|
+
r"|consider|copy|count|cover|create|cut|decide|define|delete|deliver|design"
|
|
232
|
+
r"|do|draw|drive|drop|edit|enter|expect|explain|fill|find|finish|fix|focus"
|
|
233
|
+
r"|follow|forget|get|give|go|grab|grow|handle|help|hire|hold|imagine|include"
|
|
234
|
+
r"|install|invest|join|keep|know|launch|lead|learn|leave|let|listen|look"
|
|
235
|
+
r"|love|make|meet|move|name|note|notice|open|pay|pick|plan|play|point|prefer"
|
|
236
|
+
r"|press|pull|push|put|read|remember|remove|repeat|replace|report|reset"
|
|
237
|
+
r"|return|review|run|save|say|scale|see|select|sell|send|set|share|ship|show"
|
|
238
|
+
r"|sign|skip|solve|sort|speak|spend|stand|start|stay|stop|study|take|talk"
|
|
239
|
+
r"|teach|tell|test|think|throw|touch|track|treat|trust|try|turn|update|use"
|
|
240
|
+
r"|wait|walk|want|watch|win|write)\b",
|
|
241
|
+
re.I)
|
|
242
|
+
|
|
136
243
|
FINITE_VERB = re.compile(
|
|
137
244
|
r"\b(?:is|are|was|were|be|been|being|has|have|had|do|does|did|can|could|will"
|
|
138
245
|
r"|would|shall|should|may|might|must|gets?|goes|comes?|makes?|takes?|gives?"
|
|
@@ -152,6 +259,8 @@ def verbless_fragments(prose: str) -> list[str]:
|
|
|
152
259
|
continue
|
|
153
260
|
if sent.rstrip().endswith(":") or sent.lstrip().startswith(("-", "*", "#", "|")):
|
|
154
261
|
continue
|
|
262
|
+
if IMPERATIVE_OPENER.match(sent):
|
|
263
|
+
continue
|
|
155
264
|
if not FINITE_VERB.search(sent):
|
|
156
265
|
out.append(sent)
|
|
157
266
|
return out
|
|
@@ -287,7 +396,7 @@ def measure(text: str) -> dict:
|
|
|
287
396
|
|
|
288
397
|
paras = paragraphs(prose)
|
|
289
398
|
lengths = [len(p.split()) for p in paras]
|
|
290
|
-
|
|
399
|
+
paragraph_uniformity = (
|
|
291
400
|
round(statistics.pstdev(lengths) / statistics.mean(lengths), 2)
|
|
292
401
|
if len(lengths) > 2 and statistics.mean(lengths)
|
|
293
402
|
else None
|
|
@@ -298,9 +407,10 @@ def measure(text: str) -> dict:
|
|
|
298
407
|
triads = [" ".join(m.group(0).split()) for m in NEGATION_TRIAD.finditer(prose)]
|
|
299
408
|
dangling = dangling_pointers(text)
|
|
300
409
|
fragments = verbless_fragments(prose)
|
|
410
|
+
antithesis = antithesis_pairs(prose)
|
|
301
411
|
thin = thin_sections(text)
|
|
302
412
|
clusters = referent_clusters(text)
|
|
303
|
-
|
|
413
|
+
table_uniformity, column = table_row_uniformity(text)
|
|
304
414
|
|
|
305
415
|
return {
|
|
306
416
|
"words": words,
|
|
@@ -311,14 +421,15 @@ def measure(text: str) -> dict:
|
|
|
311
421
|
"verbless_fragment": {"count": len(fragments), "per_1k": per_k(len(fragments)), "hits": fragments[:5]},
|
|
312
422
|
"thin_section": {"count": len(thin), "per_1k": per_k(len(thin)), "hits": thin[:6]},
|
|
313
423
|
"referent_cluster": {"count": len(clusters), "per_1k": per_k(len(clusters)), "hits": clusters[:3]},
|
|
314
|
-
"table_uniformity": {"share":
|
|
424
|
+
"table_uniformity": {"share": table_uniformity, "column": column},
|
|
425
|
+
"antithesis_pair": {"count": len(antithesis), "per_1k": per_k(len(antithesis)), "hits": antithesis[:6]},
|
|
315
426
|
"subtractive_contrast": {"count": len(subtractive), "per_1k": per_k(len(subtractive)), "hits": subtractive[:12]},
|
|
316
427
|
"comma_series": {"count": len(series), "per_1k": per_k(len(series))},
|
|
317
428
|
"significance_scaffolding": {"count": len(significance), "per_1k": per_k(len(significance)), "hits": significance[:6]},
|
|
318
429
|
"classifier_scaffolding": {"count": len(classifier), "per_1k": per_k(len(classifier)), "hits": classifier[:6]},
|
|
319
430
|
"inanimate_agent": {"count": len(inanimate), "per_1k": per_k(len(inanimate)), "hits": inanimate[:8]},
|
|
320
431
|
"repeated_openings": {"count": len(repeated), "per_1k": per_k(len(repeated)), "hits": repeated[:6]},
|
|
321
|
-
"paragraph_uniformity":
|
|
432
|
+
"paragraph_uniformity": paragraph_uniformity,
|
|
322
433
|
}
|
|
323
434
|
|
|
324
435
|
|
|
@@ -329,7 +440,10 @@ def verdicts(m: dict) -> list[tuple[str, float, float, bool]]:
|
|
|
329
440
|
for key, (budget, floor) in BUDGETS.items():
|
|
330
441
|
value = m[key]["per_1k"]
|
|
331
442
|
count = m[key]["count"]
|
|
332
|
-
|
|
443
|
+
# Rates are unstable on short drafts, but an absolute budget is not.
|
|
444
|
+
# Below MIN_WORDS, use only the recurrence floor; otherwise require
|
|
445
|
+
# both enough instances and a rate over budget before failing.
|
|
446
|
+
ok = count < SHORT_FLOORS[key] if short else value <= budget or count < floor
|
|
333
447
|
rows.append((key, value, budget, ok))
|
|
334
448
|
return rows
|
|
335
449
|
|
|
@@ -342,7 +456,8 @@ LABEL = {
|
|
|
342
456
|
"verbless_fragment": "Verbless fragments",
|
|
343
457
|
"thin_section": "Headings over a sentence or two",
|
|
344
458
|
"referent_cluster": "One thing under several names",
|
|
345
|
-
"
|
|
459
|
+
"antithesis_pair": "Antithesis pairs",
|
|
460
|
+
"subtractive_contrast": "Binary contrasts (X, not Y)",
|
|
346
461
|
"comma_series": "Comma-series density",
|
|
347
462
|
"significance_scaffolding": "Announced significance",
|
|
348
463
|
"classifier_scaffolding": "Graded not delivered (stems)",
|
|
@@ -360,8 +475,11 @@ def render(m: dict, name: str) -> str:
|
|
|
360
475
|
out.append(f" Under {MIN_WORDS} words. Rates are not reported: one instance in a short")
|
|
361
476
|
out.append(" document swamps the rate. Counts only.")
|
|
362
477
|
out.append("")
|
|
478
|
+
states = {key: ok for key, _value, _budget, ok in verdicts(m)}
|
|
363
479
|
for key in BUDGETS:
|
|
364
|
-
|
|
480
|
+
mark = "ok " if states[key] else "OVER"
|
|
481
|
+
out.append(f" {mark} {LABEL[key]:<32} {m[key]['count']:>6} found"
|
|
482
|
+
f" limit {SHORT_FLOORS[key] - 1}")
|
|
365
483
|
return "\n".join(out)
|
|
366
484
|
for key, value, budget, ok in verdicts(m):
|
|
367
485
|
mark = "ok " if ok else "OVER"
|
|
@@ -500,8 +618,8 @@ def read_packet(text: str, name: str) -> dict:
|
|
|
500
618
|
"file": name,
|
|
501
619
|
"instruction": (
|
|
502
620
|
"Work section by section, one pass per section: answer all of section A "
|
|
503
|
-
"before opening B, and so on.
|
|
504
|
-
"
|
|
621
|
+
"before opening B, and so on. Do not hold the whole checklist in "
|
|
622
|
+
"attention at once; work in small sections and answer each item. Answer with "
|
|
505
623
|
"pass or fail; where a question asks for a count, give the number. Quote "
|
|
506
624
|
"exact spans as evidence; never paraphrase. Then fill _coverage: map "
|
|
507
625
|
"every paragraph id to \"clean\" or to the list of check ids that fire "
|
|
@@ -547,9 +665,22 @@ def check_evidence(raw: str, answers: dict, checks: list[dict]) -> list[str]:
|
|
|
547
665
|
if not isinstance(got, dict):
|
|
548
666
|
continue
|
|
549
667
|
answer = got.get("answer")
|
|
550
|
-
|
|
668
|
+
raw_quotes = got.get("evidence")
|
|
669
|
+
if raw_quotes is not None and not isinstance(raw_quotes, list):
|
|
670
|
+
problems.append(f"{check['id']} evidence must be a list of exact quotes")
|
|
671
|
+
raw_quotes = []
|
|
672
|
+
elif isinstance(raw_quotes, list) and any(not isinstance(q, str) for q in raw_quotes):
|
|
673
|
+
problems.append(f"{check['id']} evidence entries must all be strings")
|
|
674
|
+
quotes = [q for q in (raw_quotes or []) if isinstance(q, str)]
|
|
551
675
|
count = got.get("count")
|
|
552
676
|
|
|
677
|
+
if ("___" in check["title"] and "count" in check["title"].lower()
|
|
678
|
+
and (isinstance(count, bool) or not isinstance(count, int) or count < 0)):
|
|
679
|
+
problems.append(f"{check['id']} is missing a required count")
|
|
680
|
+
elif count is not None and (isinstance(count, bool)
|
|
681
|
+
or not isinstance(count, int) or count < 0):
|
|
682
|
+
problems.append(f"{check['id']} count must be a non-negative integer or null")
|
|
683
|
+
|
|
553
684
|
if answer == "fail" and not quotes:
|
|
554
685
|
problems.append(f"{check['id']} failed with no quoted evidence")
|
|
555
686
|
if answer == "fail" and isinstance(count, int) and count == 0:
|
|
@@ -572,6 +703,9 @@ def check_evidence(raw: str, answers: dict, checks: list[dict]) -> list[str]:
|
|
|
572
703
|
|
|
573
704
|
def verdict(text: str, answers: dict) -> tuple[int, str]:
|
|
574
705
|
"""Combine the measured rates with the model's read. Both must clear."""
|
|
706
|
+
if not isinstance(answers, dict):
|
|
707
|
+
return 1, ("Register verdict\n\n FAIL answer packet must be a JSON object "
|
|
708
|
+
"keyed by checklist id. No checks were accepted.")
|
|
575
709
|
m = measure(text)
|
|
576
710
|
checks = [c for c in load_checks() if not c["skip"] and not c["auto"]]
|
|
577
711
|
out = ["Register verdict", ""]
|
|
@@ -602,7 +736,10 @@ def verdict(text: str, answers: dict) -> tuple[int, str]:
|
|
|
602
736
|
out.append(f" {state:<5} {qid:<5}{check['title'][:48]}{shown}")
|
|
603
737
|
if got["answer"] == "fail":
|
|
604
738
|
failed.append(qid)
|
|
605
|
-
|
|
739
|
+
quotes = got.get("evidence")
|
|
740
|
+
if not isinstance(quotes, list):
|
|
741
|
+
quotes = []
|
|
742
|
+
for quote in quotes[:3]:
|
|
606
743
|
out.append(f" · {str(quote)[:84]}")
|
|
607
744
|
out.append("")
|
|
608
745
|
|
|
@@ -614,12 +751,53 @@ def verdict(text: str, answers: dict) -> tuple[int, str]:
|
|
|
614
751
|
out.append(" paragraph nobody read; the checklist was answered from memory.")
|
|
615
752
|
failed.append("coverage (missing)")
|
|
616
753
|
else:
|
|
754
|
+
known_paragraphs = set(para_ids)
|
|
755
|
+
# JSON object keys arrive as strings, but verdict() is also a public
|
|
756
|
+
# library function. Treat an in-process packet with non-string keys as
|
|
757
|
+
# invalid input instead of letting sorting or joining raise TypeError.
|
|
758
|
+
unknown = sorted(
|
|
759
|
+
(key for key in coverage if not isinstance(key, str)
|
|
760
|
+
or key not in known_paragraphs),
|
|
761
|
+
key=str,
|
|
762
|
+
)
|
|
617
763
|
unread = [i for i in para_ids if i not in coverage]
|
|
764
|
+
coverage_problems = []
|
|
765
|
+
valid_check_ids = {c["id"] for c in checks}
|
|
766
|
+
if unknown:
|
|
767
|
+
coverage_problems.append(
|
|
768
|
+
"unknown paragraph id(s): " + ", ".join(map(str, unknown[:8])))
|
|
769
|
+
for para_id in para_ids:
|
|
770
|
+
if para_id not in coverage:
|
|
771
|
+
continue
|
|
772
|
+
value = coverage[para_id]
|
|
773
|
+
if value == "clean":
|
|
774
|
+
continue
|
|
775
|
+
if not isinstance(value, list) or not value:
|
|
776
|
+
coverage_problems.append(
|
|
777
|
+
f"{para_id} must be clean or a non-empty list of failed check ids")
|
|
778
|
+
continue
|
|
779
|
+
if any(not isinstance(check_id, str) for check_id in value):
|
|
780
|
+
coverage_problems.append(f"{para_id} check ids must be strings")
|
|
781
|
+
continue
|
|
782
|
+
if len(value) != len(set(value)):
|
|
783
|
+
coverage_problems.append(f"{para_id} repeats a check id")
|
|
784
|
+
for check_id in value:
|
|
785
|
+
if check_id not in valid_check_ids:
|
|
786
|
+
coverage_problems.append(f"{para_id} names unknown check {check_id}")
|
|
787
|
+
else:
|
|
788
|
+
referenced = answers.get(check_id)
|
|
789
|
+
if not isinstance(referenced, dict) or referenced.get("answer") != "fail":
|
|
790
|
+
coverage_problems.append(
|
|
791
|
+
f"{para_id} names {check_id}, but that check did not fail")
|
|
618
792
|
if unread:
|
|
619
793
|
out.append(f" FAIL {len(unread)} paragraph(s) never dispositioned: "
|
|
620
794
|
+ ", ".join(unread[:8]))
|
|
621
795
|
failed.append("coverage (incomplete)")
|
|
622
|
-
|
|
796
|
+
if coverage_problems:
|
|
797
|
+
for problem in coverage_problems:
|
|
798
|
+
out.append(f" FAIL {problem}")
|
|
799
|
+
failed.append("coverage (invalid)")
|
|
800
|
+
if not unread and not coverage_problems:
|
|
623
801
|
flagged = sum(1 for v in coverage.values() if v != "clean")
|
|
624
802
|
out.append(f" ok all {len(para_ids)} paragraphs dispositioned, "
|
|
625
803
|
f"{flagged} carrying findings")
|
|
@@ -634,7 +812,12 @@ def verdict(text: str, answers: dict) -> tuple[int, str]:
|
|
|
634
812
|
out.append(" Re-read the draft and quote the exact span, or change the answer.")
|
|
635
813
|
else:
|
|
636
814
|
answered = sum(1 for c in checks if isinstance(answers.get(c["id"]), dict))
|
|
637
|
-
quoted =
|
|
815
|
+
quoted = 0
|
|
816
|
+
for check in checks:
|
|
817
|
+
got = answers.get(check["id"])
|
|
818
|
+
quotes = got.get("evidence") if isinstance(got, dict) else None
|
|
819
|
+
if isinstance(quotes, list):
|
|
820
|
+
quoted += sum(isinstance(quote, str) for quote in quotes)
|
|
638
821
|
if not answered:
|
|
639
822
|
out.append(" none no answer matched any check id. The answers file is for a")
|
|
640
823
|
out.append(" different checklist, or the ids are wrong.")
|
package/scripts/rerank.py
CHANGED
|
@@ -36,7 +36,7 @@ def _tier(s):
|
|
|
36
36
|
return 0 if s["preserved"] else 1
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
def rank(original, candidates, genre=None):
|
|
39
|
+
def rank(original, candidates, genre=None, adjudicated=None):
|
|
40
40
|
"""candidates: {name: text}. Returns them scored and sorted, best first.
|
|
41
41
|
|
|
42
42
|
Sort key: fidelity tier first (a fabrication can never win), then soft quality,
|
|
@@ -55,7 +55,7 @@ def rank(original, candidates, genre=None):
|
|
|
55
55
|
data = slopscore.load_patterns()
|
|
56
56
|
scored = []
|
|
57
57
|
for name, text in candidates.items():
|
|
58
|
-
s = slopscore.rewrite_score(original, text, genre, data)
|
|
58
|
+
s = slopscore.rewrite_score(original, text, genre, data, adjudicated)
|
|
59
59
|
s["name"], s["text"] = name, text
|
|
60
60
|
scored.append(s)
|
|
61
61
|
scored.sort(key=lambda s: (_tier(s), -s["soft"], s["after_ai"],
|
|
@@ -93,6 +93,8 @@ def main(argv=None):
|
|
|
93
93
|
ap.add_argument("--original", required=True, metavar="FILE")
|
|
94
94
|
ap.add_argument("--candidates", metavar="JSON_FILE")
|
|
95
95
|
ap.add_argument("--genre")
|
|
96
|
+
ap.add_argument("--adjudication", metavar="JSON_FILE",
|
|
97
|
+
help="source-bound rulings for figures intentionally removed")
|
|
96
98
|
ap.add_argument("--out", metavar="FILE")
|
|
97
99
|
ap.add_argument("--emit", action="store_true")
|
|
98
100
|
ap.add_argument("files", nargs="*")
|
|
@@ -122,8 +124,15 @@ def main(argv=None):
|
|
|
122
124
|
if len(candidates) < 2:
|
|
123
125
|
ap.error("give at least two candidate rewrites to choose between")
|
|
124
126
|
|
|
127
|
+
adjudicated = None
|
|
128
|
+
if args.adjudication:
|
|
129
|
+
import slopscore
|
|
130
|
+
try:
|
|
131
|
+
adjudicated = slopscore.load_adjudication(args.adjudication, original)
|
|
132
|
+
except ValueError as exc:
|
|
133
|
+
ap.error(str(exc))
|
|
125
134
|
try:
|
|
126
|
-
scored = rank(original, candidates, args.genre)
|
|
135
|
+
scored = rank(original, candidates, args.genre, adjudicated)
|
|
127
136
|
except ValueError as exc:
|
|
128
137
|
ap.error(str(exc))
|
|
129
138
|
print(render(scored))
|
package/scripts/safeio.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Small, stdlib-only primitives for durable and concurrency-safe state updates.
|
|
3
3
|
|
|
4
|
-
Zero Slop
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
Zero Slop never alters a scored draft. Learning and calibration update private state,
|
|
5
|
+
and an interactive score can update one local, one-time note marker. Atomic
|
|
6
|
+
replacement prevents torn files; lock directories prevent concurrent processes from
|
|
7
|
+
silently overwriting each other's state.
|
|
7
8
|
"""
|
|
8
9
|
from contextlib import contextmanager
|
|
9
10
|
import hashlib
|
package/scripts/slopscore.py
CHANGED
|
@@ -24,6 +24,8 @@ The phrase lists live beside this script in ../data/patterns.json and
|
|
|
24
24
|
"""
|
|
25
25
|
import bisect
|
|
26
26
|
import functools
|
|
27
|
+
import hashlib
|
|
28
|
+
import importlib.util
|
|
27
29
|
import json
|
|
28
30
|
import math
|
|
29
31
|
import re
|
|
@@ -37,7 +39,19 @@ SHAPE_SOLO_THRESHOLD = 0.62 # calibrated, see calibrate.py --shape
|
|
|
37
39
|
# Where personal voice profiles live — outside the repo, since they are the
|
|
38
40
|
# user's own writing. One file per author, git-ignored by construction.
|
|
39
41
|
import os
|
|
40
|
-
|
|
42
|
+
|
|
43
|
+
# The scorer is both a CLI and an importable single-file module. Load its
|
|
44
|
+
# adjacent helper by path so importlib callers do not have to modify sys.path,
|
|
45
|
+
# and so an unrelated third-party module named ``safeio`` cannot be selected.
|
|
46
|
+
_SAFEIO_SPEC = importlib.util.spec_from_file_location(
|
|
47
|
+
"_zero_slop_safeio", Path(__file__).resolve().with_name("safeio.py"))
|
|
48
|
+
if _SAFEIO_SPEC is None or _SAFEIO_SPEC.loader is None: # pragma: no cover
|
|
49
|
+
raise ImportError("cannot load Zero Slop's adjacent safeio.py")
|
|
50
|
+
_SAFEIO = importlib.util.module_from_spec(_SAFEIO_SPEC)
|
|
51
|
+
_SAFEIO_SPEC.loader.exec_module(_SAFEIO)
|
|
52
|
+
atomic_write_text = _SAFEIO.atomic_write_text
|
|
53
|
+
file_locks = _SAFEIO.file_locks
|
|
54
|
+
HOME = Path(os.environ.get("ZERO_SLOP_HOME") or Path.home() / ".zero-slop").expanduser()
|
|
41
55
|
VOICE_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}\Z")
|
|
42
56
|
|
|
43
57
|
|
|
@@ -77,13 +91,10 @@ def _load_notes():
|
|
|
77
91
|
|
|
78
92
|
def _save_notes(state):
|
|
79
93
|
try:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
with open(tmp, "w", encoding="utf-8") as fh:
|
|
83
|
-
json.dump(state, fh)
|
|
84
|
-
os.replace(tmp, NOTES_FILE)
|
|
94
|
+
atomic_write_text(NOTES_FILE, json.dumps(state) + "\n", mode=0o600)
|
|
95
|
+
return True
|
|
85
96
|
except OSError:
|
|
86
|
-
|
|
97
|
+
return False # a read-only home must never break a score
|
|
87
98
|
|
|
88
99
|
|
|
89
100
|
def star_note_is_due(argv=None, isatty=None, env=None):
|
|
@@ -112,14 +123,22 @@ def record_human_run(argv=None, isatty=None, env=None):
|
|
|
112
123
|
return None
|
|
113
124
|
if not (sys.stdout.isatty() if isatty is None else isatty):
|
|
114
125
|
return None
|
|
115
|
-
|
|
116
|
-
|
|
126
|
+
try:
|
|
127
|
+
# A short best-effort lock prevents simultaneous terminal runs from
|
|
128
|
+
# both printing the one-time note. Contention or a read-only state
|
|
129
|
+
# directory must never delay or fail the score itself.
|
|
130
|
+
with file_locks([NOTES_FILE], timeout=0.25):
|
|
131
|
+
state = _load_notes()
|
|
132
|
+
if state.get("star_note_shown"):
|
|
133
|
+
return None
|
|
134
|
+
state["human_runs"] = int(state.get("human_runs", 0)) + 1
|
|
135
|
+
due = state["human_runs"] >= STAR_NOTE_AFTER_RUNS
|
|
136
|
+
if due:
|
|
137
|
+
state["star_note_shown"] = True
|
|
138
|
+
if not _save_notes(state):
|
|
139
|
+
return None
|
|
140
|
+
except (OSError, SystemExit):
|
|
117
141
|
return None
|
|
118
|
-
state["human_runs"] = int(state.get("human_runs", 0)) + 1
|
|
119
|
-
due = state["human_runs"] >= STAR_NOTE_AFTER_RUNS
|
|
120
|
-
if due:
|
|
121
|
-
state["star_note_shown"] = True
|
|
122
|
-
_save_notes(state)
|
|
123
142
|
if not due:
|
|
124
143
|
return None
|
|
125
144
|
return ("\n If Zero Slop has been useful, a star helps people find it: "
|
|
@@ -1201,14 +1220,15 @@ def facts(text, _other=""):
|
|
|
1201
1220
|
out = {}
|
|
1202
1221
|
for kind, rx in FACT_RX:
|
|
1203
1222
|
found = set()
|
|
1204
|
-
|
|
1223
|
+
flags = re.I if kind == "figure" else 0
|
|
1224
|
+
for m in re.finditer(rx, urls if kind == "url" else prose, flags):
|
|
1205
1225
|
v = (m.group(1) if m.lastindex else m.group(0)).strip()
|
|
1206
1226
|
if kind == "name":
|
|
1207
1227
|
v = _peel_entity(v, prose, other)
|
|
1208
1228
|
if not v:
|
|
1209
1229
|
continue
|
|
1210
1230
|
if kind == "figure":
|
|
1211
|
-
v = v.replace(",", "").lstrip("$").rstrip()
|
|
1231
|
+
v = v.replace(",", "").lstrip("$").rstrip().lower()
|
|
1212
1232
|
v = re.sub(r"\s*percent$", "%", v)
|
|
1213
1233
|
v = re.sub(r"\s*(million|bn|billion|m|k)$",
|
|
1214
1234
|
lambda x: {"million":"m","billion":"bn"}.get(x.group(1), x.group(1)), v)
|
|
@@ -1276,7 +1296,9 @@ BLOCKQUOTE_LINE_RX = re.compile(r"^[ \t]*>[^\n]*$", re.M)
|
|
|
1276
1296
|
HEADING_RX = re.compile(r"^(#{1,6})[ \t]+(.+?)[ \t]*$", re.M)
|
|
1277
1297
|
PATH_RX = re.compile(
|
|
1278
1298
|
r"(?<![\w:])((?:\.\.?/|/)[A-Za-z0-9._~\-]+"
|
|
1279
|
-
r"(?:/[A-Za-z0-9._~\-]+)*|[A-Za-
|
|
1299
|
+
r"(?:/[A-Za-z0-9._~\-]+)*|(?:[A-Za-z0-9._~\-]+/)+"
|
|
1300
|
+
r"[A-Za-z0-9._~\-]+\.[A-Za-z0-9._~\-]+|"
|
|
1301
|
+
r"[A-Za-z]:\\[A-Za-z0-9._\\~\-]+)"
|
|
1280
1302
|
)
|
|
1281
1303
|
|
|
1282
1304
|
|
|
@@ -1438,6 +1460,49 @@ def figure_contexts(text, figures):
|
|
|
1438
1460
|
return out
|
|
1439
1461
|
|
|
1440
1462
|
|
|
1463
|
+
def load_adjudication(path, original):
|
|
1464
|
+
"""Load explicit dropped-figure rulings bound to one exact source text.
|
|
1465
|
+
|
|
1466
|
+
The file is intentionally small and closed-schema. It cannot weaken name,
|
|
1467
|
+
quote, URL, feeling, or structure checks, and it cannot excuse a number that
|
|
1468
|
+
was not present in the source it names.
|
|
1469
|
+
"""
|
|
1470
|
+
source = Path(path)
|
|
1471
|
+
try:
|
|
1472
|
+
if source.stat().st_size > 65_536:
|
|
1473
|
+
raise ValueError("adjudication file exceeds 64 KiB")
|
|
1474
|
+
payload = json.loads(source.read_text(encoding="utf-8"))
|
|
1475
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
1476
|
+
raise ValueError(f"cannot read adjudication file: {exc}") from exc
|
|
1477
|
+
if not isinstance(payload, dict):
|
|
1478
|
+
raise ValueError("adjudication file must be a JSON object")
|
|
1479
|
+
expected = {"schema", "original_sha256", "allow_dropped_figures"}
|
|
1480
|
+
if set(payload) != expected or payload.get("schema") != 1:
|
|
1481
|
+
raise ValueError("adjudication file must use schema 1 and only documented keys")
|
|
1482
|
+
digest = payload.get("original_sha256")
|
|
1483
|
+
actual = hashlib.sha256(original.encode("utf-8")).hexdigest()
|
|
1484
|
+
if not isinstance(digest, str) or digest != actual:
|
|
1485
|
+
raise ValueError("adjudication source hash does not match the original text")
|
|
1486
|
+
raw = payload.get("allow_dropped_figures")
|
|
1487
|
+
if not isinstance(raw, list) or len(raw) > 100:
|
|
1488
|
+
raise ValueError("allow_dropped_figures must be a list of at most 100 figures")
|
|
1489
|
+
original_figures = facts(original)["figure"]
|
|
1490
|
+
allowed = []
|
|
1491
|
+
for item in raw:
|
|
1492
|
+
if not isinstance(item, str) or not item.strip() or len(item) > 80:
|
|
1493
|
+
raise ValueError("each allowed figure must be a short non-empty string")
|
|
1494
|
+
parsed = facts(item)["figure"]
|
|
1495
|
+
if len(parsed) != 1:
|
|
1496
|
+
raise ValueError(f"allowed figure is not one unambiguous figure: {item!r}")
|
|
1497
|
+
canonical = next(iter(parsed))
|
|
1498
|
+
if canonical not in original_figures:
|
|
1499
|
+
raise ValueError(f"allowed figure is absent from the original: {item!r}")
|
|
1500
|
+
allowed.append(canonical)
|
|
1501
|
+
if len(allowed) != len(set(allowed)):
|
|
1502
|
+
raise ValueError("allow_dropped_figures contains a duplicate")
|
|
1503
|
+
return set(allowed)
|
|
1504
|
+
|
|
1505
|
+
|
|
1441
1506
|
def fidelity(before, after, adjudicated=None):
|
|
1442
1507
|
"""Did the rewrite keep every fact, and did it add any?
|
|
1443
1508
|
|
|
@@ -1620,8 +1685,8 @@ def rewrite_score(before_text, after_text, genre=None, data=None,
|
|
|
1620
1685
|
"preserved": fid["preserved"], "invented": fid["invented"]}
|
|
1621
1686
|
|
|
1622
1687
|
|
|
1623
|
-
def render_fidelity(before, after):
|
|
1624
|
-
r = fidelity(before, after)
|
|
1688
|
+
def render_fidelity(before, after, adjudicated=None):
|
|
1689
|
+
r = fidelity(before, after, adjudicated)
|
|
1625
1690
|
out = ["", " FACT AND MEANING CHECK · original vs edited text", ""]
|
|
1626
1691
|
for kind, kept, dropped, added in r["rows"]:
|
|
1627
1692
|
out.append(f" {kind:<8} {len(kept)} kept"
|
|
@@ -1640,6 +1705,9 @@ def render_fidelity(before, after):
|
|
|
1640
1705
|
out.append(" protected document content changed:")
|
|
1641
1706
|
for finding in r["structure"][:8]:
|
|
1642
1707
|
out.append(f" {finding['code']:<23} {finding['message']}")
|
|
1708
|
+
if r.get("unsourced"):
|
|
1709
|
+
for figure in sorted(r["unsourced"]):
|
|
1710
|
+
out.append(f" ruled cut {figure!r} (reviewer marked it unsourced)")
|
|
1643
1711
|
out += ["",
|
|
1644
1712
|
" Result: " + ("facts preserved; nothing added"
|
|
1645
1713
|
if r["preserved"] and not r["invented"] else
|
|
@@ -1726,7 +1794,7 @@ def main():
|
|
|
1726
1794
|
if "--help" in argv or "-h" in argv:
|
|
1727
1795
|
print(__doc__)
|
|
1728
1796
|
return 0
|
|
1729
|
-
value_flags = {"--gate", "--genre", "--voice"}
|
|
1797
|
+
value_flags = {"--gate", "--genre", "--voice", "--adjudication"}
|
|
1730
1798
|
bool_flags = {"--json", "--explain", "--formal", "--fidelity", "--dna",
|
|
1731
1799
|
"--portfolio", "--batch", "--heatmap"}
|
|
1732
1800
|
unknown = [arg for arg in argv if arg.startswith("--")
|
|
@@ -1739,6 +1807,8 @@ def main():
|
|
|
1739
1807
|
if flag in argv]
|
|
1740
1808
|
if len(modes) > 1:
|
|
1741
1809
|
raise SystemExit("choose only one mode: " + ", ".join(modes))
|
|
1810
|
+
if "--adjudication" in argv and "--fidelity" not in argv:
|
|
1811
|
+
raise SystemExit("--adjudication is valid only with --fidelity")
|
|
1742
1812
|
|
|
1743
1813
|
gv, _ = gate_value()
|
|
1744
1814
|
# Values that belong to a flag (--gate 25, --genre social, --voice manav)
|
|
@@ -1773,9 +1843,16 @@ def main():
|
|
|
1773
1843
|
if len(args) != 2:
|
|
1774
1844
|
sys.exit("--fidelity needs exactly two files: before and after")
|
|
1775
1845
|
before, after = _read_text_file(args[0]), _read_text_file(args[1])
|
|
1776
|
-
|
|
1846
|
+
adjudicated = None
|
|
1847
|
+
ruling_path = _required_option_value(argv, "--adjudication")
|
|
1848
|
+
if ruling_path:
|
|
1849
|
+
try:
|
|
1850
|
+
adjudicated = load_adjudication(ruling_path, before)
|
|
1851
|
+
except ValueError as exc:
|
|
1852
|
+
raise SystemExit(str(exc)) from exc
|
|
1853
|
+
for line in render_fidelity(before, after, adjudicated):
|
|
1777
1854
|
print(line)
|
|
1778
|
-
r = fidelity(before, after)
|
|
1855
|
+
r = fidelity(before, after, adjudicated)
|
|
1779
1856
|
sys.exit(0 if (r["preserved"] and not r["invented"]) else 1)
|
|
1780
1857
|
|
|
1781
1858
|
if "--dna" in sys.argv:
|