zero-slop 2.8.0 → 2.8.2

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/README.md CHANGED
@@ -5,7 +5,7 @@
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.0-72528F">
8
+ <img alt="version" src="https://img.shields.io/badge/version-2.8.2-72528F">
9
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
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
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>
@@ -191,10 +191,9 @@ with [`scripts/register.py`](scripts/register.py) running the reading pass.
191
191
 
192
192
  The saved replay ran Zero Slop, [avoid-ai-writing](https://github.com/conorbronsdon/avoid-ai-writing),
193
193
  [no-ai-slop](https://github.com/petergyang/no-ai-slop) 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.
194
+ [humanizer](https://github.com/blader/humanizer) over 18 drafts with GPT-5.4, high
195
+ reasoning, and pinned instructions. Zero Slop's outputs came from v2.5.9; later
196
+ releases rescore those frozen outputs rather than pretend to regenerate them.
198
197
 
199
198
  | Method | Mean writing score ↓ | Passed Zero Slop's local gates | Source check passed | Average length change |
200
199
  |---|---:|---:|---:|---:|
@@ -218,23 +217,44 @@ rates, and a method-hidden quality ranking.
218
217
  ![Method-hidden quality ranking, lower is better](assets/bench-blind-quality.png)
219
218
 
220
219
  This is a small LLM-reviewed regression study. It measures neither field accuracy nor a
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
220
+ universal ranking. Drafts, hashes, and limits are in [`bench/README.md`](bench/README.md). The separate method-hidden two-way replay used
223
221
  Zero Slop v2.6.0 and is preserved in
224
222
  [`bench/incumbent-blind-replay/`](bench/incumbent-blind-replay/).
225
223
 
226
224
  For the 38-item editorial panel, the current scorer matched the prior 84.2% result.
227
225
  All frozen scores stayed unchanged, all 18 human controls remained below the gate,
228
226
  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.
227
+ proof of general accuracy.
231
228
 
232
229
  ### Speed
233
230
 
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.
231
+ One busy Apple silicon Mac. Meter: 1,000 documents in 2.5035 seconds (399.4 per
232
+ second), 15,201 words in 0.4301 seconds, worst stress case 3.2518 seconds. Reading
233
+ pass, untimed on record until now: 0.8304 seconds for the same 1,000 (1,204.2 per
234
+ second), 0.1359 for the same large document, linear to 96,000 words. Learning pass,
235
+ 8,000 words: 0.2027 seconds. Across 24 interleaved runs against 2.7.7 we measured
236
+ 0.08% lower median throughput; that is machine noise, not a speed claim. Editing
237
+ time is excluded.
238
+
239
+ ### Reading-pass accuracy
240
+
241
+ The reading pass budgets antithesis pairs by frequency, so the count has to be right
242
+ before the budget means anything. It had never been measured. On 58 labelled pairs in
243
+ [`bench/antithesis/`](bench/antithesis/):
244
+
245
+ | Reading pass | 2.8.0 | now |
246
+ |---|---:|---:|
247
+ | Recall, all shapes | 40.0% | 90.0% |
248
+ | Recall, shapes in reach | 44.4% | 100% |
249
+ | Precision | 80.0% | 100% |
250
+ | False positives | 3 | 0 |
251
+
252
+ ![Antithesis detection before and after, on 58 labelled pairs](assets/bench-antithesis.png)
253
+
254
+ Bare subject swap and the weak isocolon stay out of reach and count against recall: the
255
+ first is a judgment call in `references/tells.md`, the second is identical to ordinary
256
+ parallel prose on every lexical statistic. Maintainer labels on constructed pairs, so
257
+ this is a regression floor, not field accuracy.
238
258
 
239
259
  ### Current models
240
260
 
package/SKILL.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: zero-slop
3
3
  license: MIT
4
4
  metadata:
5
- version: "2.8.0"
5
+ version: "2.8.2"
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
  ---
@@ -1,142 +1,155 @@
1
1
  {
2
- "_comment": "The ratchet: every miss an audit or a competitor catches becomes a fixture here, in the same change. metric entries are verified by register.py --recall; check entries name the eval.md family a reading pass must catch, and --recall verifies the span exists and the family is a real check.",
3
- "fixtures": [
4
- {
5
- "file": "adjective-inflation.md",
6
- "expect": [
7
- {
8
- "metric": "adjective_inflation",
9
- "span": "a real improvement"
10
- },
11
- {
12
- "metric": "adjective_inflation",
13
- "span": "a genuine breakthrough"
14
- }
15
- ]
16
- },
17
- {
18
- "file": "monument-negation.md",
19
- "expect": [
20
- {
21
- "metric": "monument_verb",
22
- "span": "stands as a testament"
23
- },
24
- {
25
- "metric": "negation_triad",
26
- "span": "no runbook, no owner, no rollback"
2
+ "_comment": "The ratchet: every miss an audit or a competitor catches becomes a fixture here, in the same change. metric entries are verified by register.py --recall; check entries name the eval.md family a reading pass must catch, and --recall verifies the span exists and the family is a real check.",
3
+ "fixtures": [
4
+ {
5
+ "file": "adjective-inflation.md",
6
+ "expect": [
7
+ {
8
+ "metric": "adjective_inflation",
9
+ "span": "a real improvement"
10
+ },
11
+ {
12
+ "metric": "adjective_inflation",
13
+ "span": "a genuine breakthrough"
14
+ }
15
+ ]
27
16
  },
28
17
  {
29
- "metric": "negation_triad",
30
- "span": "Not reviewed, not staged, not announced"
31
- }
32
- ]
33
- },
34
- {
35
- "file": "dangling-thin.md",
36
- "expect": [
37
- {
38
- "metric": "thin_section",
39
- "span": "Install"
18
+ "file": "monument-negation.md",
19
+ "expect": [
20
+ {
21
+ "metric": "monument_verb",
22
+ "span": "stands as a testament"
23
+ },
24
+ {
25
+ "metric": "negation_triad",
26
+ "span": "no runbook, no owner, no rollback"
27
+ },
28
+ {
29
+ "metric": "negation_triad",
30
+ "span": "Not reviewed, not staged, not announced"
31
+ }
32
+ ]
40
33
  },
41
34
  {
42
- "metric": "thin_section",
43
- "span": "Configure"
35
+ "file": "dangling-thin.md",
36
+ "expect": [
37
+ {
38
+ "metric": "thin_section",
39
+ "span": "Install"
40
+ },
41
+ {
42
+ "metric": "thin_section",
43
+ "span": "Configure"
44
+ },
45
+ {
46
+ "metric": "dangling_pointer",
47
+ "span": "download the installer"
48
+ }
49
+ ]
44
50
  },
45
51
  {
46
- "metric": "dangling_pointer",
47
- "span": "download the installer"
48
- }
49
- ]
50
- },
51
- {
52
- "file": "referent-cycling.md",
53
- "expect": [
54
- {
55
- "metric": "referent_cluster",
56
- "span": "local tools"
57
- },
58
- {
59
- "check": "Synonym cycling",
60
- "span": "the local checker"
61
- }
62
- ]
63
- },
64
- {
65
- "file": "mic-drop.md",
66
- "expect": [
67
- {
68
- "metric": "verbless_fragment",
69
- "span": "The review queue."
70
- },
71
- {
72
- "check": "Antithesis pairs",
73
- "span": "Different teams, different fixes, same bottleneck."
74
- }
75
- ]
76
- },
77
- {
78
- "file": "caveat-template.md",
79
- "expect": [
80
- {
81
- "check": "Robotic symmetry",
82
- "span": "Neither figure includes bot traffic"
83
- }
84
- ]
85
- },
86
- {
87
- "file": "label-period-list.md",
88
- "expect": [
89
- {
90
- "check": "List-label periods and bare-noun bullets",
91
- "span": "**Latency.**"
92
- }
93
- ]
94
- },
95
- {
96
- "file": "unnamed-validator.md",
97
- "expect": [
98
- {
99
- "check": "Weasel attribution",
100
- "span": "An external audit"
101
- },
102
- {
103
- "check": "Weasel attribution",
104
- "span": "A\nthird-party checker"
105
- }
106
- ]
107
- },
108
- {
109
- "file": "classifier-scaffolding.md",
110
- "expect": [
111
- {
112
- "metric": "classifier_scaffolding",
113
- "span": "The economics are simple"
52
+ "file": "referent-cycling.md",
53
+ "expect": [
54
+ {
55
+ "metric": "referent_cluster",
56
+ "span": "local tools"
57
+ },
58
+ {
59
+ "check": "Synonym cycling",
60
+ "span": "the local checker"
61
+ }
62
+ ]
114
63
  },
115
64
  {
116
- "metric": "classifier_scaffolding",
117
- "span": "The dangerous part is"
65
+ "file": "mic-drop.md",
66
+ "expect": [
67
+ {
68
+ "metric": "verbless_fragment",
69
+ "span": "The review queue."
70
+ },
71
+ {
72
+ "check": "Antithesis pairs",
73
+ "span": "Different teams, different fixes, same bottleneck."
74
+ }
75
+ ]
118
76
  },
119
77
  {
120
- "metric": "classifier_scaffolding",
121
- "span": "has a simple cause"
78
+ "file": "caveat-template.md",
79
+ "expect": [
80
+ {
81
+ "check": "Robotic symmetry",
82
+ "span": "Neither figure includes bot traffic"
83
+ }
84
+ ]
122
85
  },
123
86
  {
124
- "metric": "classifier_scaffolding",
125
- "span": "limits worth stating"
87
+ "file": "label-period-list.md",
88
+ "expect": [
89
+ {
90
+ "check": "List-label periods and bare-noun bullets",
91
+ "span": "**Latency.**"
92
+ }
93
+ ]
126
94
  },
127
95
  {
128
- "metric": "classifier_scaffolding",
129
- "span": "One honest caveat"
96
+ "file": "unnamed-validator.md",
97
+ "expect": [
98
+ {
99
+ "check": "Weasel attribution",
100
+ "span": "An external audit"
101
+ },
102
+ {
103
+ "check": "Weasel attribution",
104
+ "span": "A\nthird-party checker"
105
+ }
106
+ ]
130
107
  },
131
108
  {
132
- "metric": "classifier_scaffolding",
133
- "span": "the ones that matter"
109
+ "file": "classifier-scaffolding.md",
110
+ "expect": [
111
+ {
112
+ "metric": "classifier_scaffolding",
113
+ "span": "The economics are simple"
114
+ },
115
+ {
116
+ "metric": "classifier_scaffolding",
117
+ "span": "The dangerous part is"
118
+ },
119
+ {
120
+ "metric": "classifier_scaffolding",
121
+ "span": "has a simple cause"
122
+ },
123
+ {
124
+ "metric": "classifier_scaffolding",
125
+ "span": "limits worth stating"
126
+ },
127
+ {
128
+ "metric": "classifier_scaffolding",
129
+ "span": "One honest caveat"
130
+ },
131
+ {
132
+ "metric": "classifier_scaffolding",
133
+ "span": "the ones that matter"
134
+ },
135
+ {
136
+ "check": "Interpretive metadiscourse",
137
+ "span": "It counts what it can see, and the rest is the reader's judgment."
138
+ }
139
+ ]
134
140
  },
135
141
  {
136
- "check": "Interpretive metadiscourse",
137
- "span": "It counts what it can see, and the rest is the reader's judgment."
142
+ "file": "plural-noun-fragment.md",
143
+ "expect": [
144
+ {
145
+ "metric": "verbless_fragment",
146
+ "span": "Same compound, three identifiers."
147
+ },
148
+ {
149
+ "metric": "verbless_fragment",
150
+ "span": "Same assay, two units."
151
+ }
152
+ ]
138
153
  }
139
- ]
140
- }
141
- ]
154
+ ]
142
155
  }
@@ -0,0 +1,4 @@
1
+ Every dataset you add widens the space of things that look relevant. Same
2
+ compound, three identifiers. Same assay, two units. Two sources that disagree
3
+ and no rule for which one wins. The model stays exactly as smart. The retrieval
4
+ gets ambiguous, and fluent ambiguity reads as confidence.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zero-slop",
3
- "version": "2.8.0",
3
+ "version": "2.8.2",
4
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"
@@ -81,22 +81,41 @@ SHORT_FLOORS = {
81
81
  ANTITHESIS_STOP = frozenset("""
82
82
  a an the this that these those it its is are was were be been being am do does did
83
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
84
+ we you they he she i us our your their his her them me my there here
85
85
  one two three first second next last own same very just only also more most less
86
+ every all each any some many few way thing
86
87
  """.split())
87
88
 
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.
89
+ # Negation, including the contracted forms. "isn't", "don't" and "won't" carry
90
+ # the figure exactly as "is not" does, and the shipped detector matched none of
91
+ # them: "Slop isn't a vibe. It's measurable." is a documented anchor in
92
+ # references/tells.md and it walked straight past.
93
+ NEGATION = re.compile(r"\b(?:not|never|cannot)\b|\w+n['’]t\b", re.I)
94
+ # A copula in both halves is the "X is A. Y is B." frame, which is the marked
95
+ # figure's usual carrier and is absent from ordinary negated prose ("The server
96
+ # did not respond. We restarted it.").
97
+ COPULA = re.compile(r"\b(?:is|are|was|were|be|been|am)\b|\w+['’]s\b|\w+n['’]t\b", re.I)
90
98
  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)
99
+ RX_MARKED_CLOSE = re.compile(
100
+ r"\b(?:was|were|is|are|did|does|do|has|have|had|will|can|could|would)\s+not\W*$", re.I)
101
+ # The two template shapes the meter already anchors (this-is-what-looks-like,
102
+ # no-x-had-to). The meter scores them as spans; the register pass has to COUNT
103
+ # them, because tells.md budgets the family by frequency and a span hit is not
104
+ # a count.
105
+ RX_CONTRASTIVE = re.compile(r"^\W*(?:but|yet|however)\b", re.I)
106
+ RX_STOCK_CLOSER = re.compile(r"\bthis is what\b.{0,60}?\blooks? like\b", re.I)
107
+ RX_UNMARKED_REVERSAL = re.compile(r"^\W*no\s+[\w-]+(?:\s+[\w-]+){0,3}\s+had to\b", re.I)
92
108
 
93
109
 
94
- def _antithesis_content(sent: str) -> list[str]:
110
+ def _tokens(sent: str) -> list[str]:
111
+ return [w.lower() for w in re.findall(r"[A-Za-z][\w'’-]*", sent)]
112
+
113
+
114
+ def _antithesis_content(tokens: list[str]) -> list[str]:
95
115
  """Content words, with a trailing -s folded away so a verb frame still
96
116
  matches when only its agreement changed: 'let' and 'lets' are one frame."""
97
117
  out = []
98
- for word in re.findall(r"[A-Za-z][\w'-]*", sent):
99
- word = word.lower()
118
+ for word in tokens:
100
119
  if word in ANTITHESIS_STOP:
101
120
  continue
102
121
  if len(word) > 3 and word.endswith("s") and not word.endswith("ss"):
@@ -105,35 +124,102 @@ def _antithesis_content(sent: str) -> list[str]:
105
124
  return out
106
125
 
107
126
 
127
+ def _common_prefix(a: list[str], b: list[str]) -> int:
128
+ n = 0
129
+ for x, y in zip(a, b):
130
+ if x != y:
131
+ break
132
+ n += 1
133
+ return n
134
+
135
+
136
+ # A heading or a list lead-in carries no terminal punctuation, so it arrives
137
+ # from _sentences glued to the paragraph beneath it. Rather than change the
138
+ # splitter or prose_of -- both are shared by every family, and moving either one
139
+ # pushed four to eleven documents over budget on rate alone -- this family
140
+ # rejects the glued span itself. Nothing else sees the change.
141
+ RX_SCAFFOLD = re.compile(r"^\s*(?:[#>|]|[-*+]\s|\d+\.\s)|\*\*|\n\s*(?:[#>|]|[-*+]\s|\d+\.\s)")
142
+
143
+
108
144
  def antithesis_pairs(prose: str) -> list[str]:
109
- """Adjacent balanced sentences: the marked form, and the swapped-argument one."""
145
+ """Adjacent balanced sentences where the second lands the twist.
146
+
147
+ Recall is bounded and the bound is a property of the figure, not of the
148
+ implementation. "A meter reports a number. A reader reports a feeling." and
149
+ "The report lists every vendor. The appendix lists every contract." are the
150
+ same construction to every lexical statistic -- same lengths, same one
151
+ shared word, same 0.33 overlap -- and only the first is antithesis. What
152
+ separates them is semantic opposition, which no word count can see. So the
153
+ marked shapes, where a negation anchors the figure, are matched broadly;
154
+ the unmarked ones are matched only where the parallel is strong enough to
155
+ be structural. Bare subject swap stays the reader's call, as tells.md says.
156
+ """
110
157
  out = []
111
158
  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):
159
+ toks = [_tokens(s) for s in sents]
160
+ cont = [_antithesis_content(t) for t in toks]
161
+ # Matches do not overlap. The figure is a pair, so a sentence that has
162
+ # already landed one twist cannot also be the setup for the next: three
163
+ # short consecutive sentences were producing two pairs out of one figure
164
+ # and inflating a rate the budget reads directly.
165
+ consumed = -1
166
+ for i in range(len(sents) - 1):
167
+ if i <= consumed:
117
168
  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}")
169
+ first, second = sents[i], sents[i + 1]
170
+ ta, tb = toks[i], toks[i + 1]
171
+ na, nb = len(ta), len(tb)
172
+ if not (2 <= na <= 14 and 1 <= nb <= 14):
121
173
  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:
174
+ if RX_SCAFFOLD.search(first) or RX_SCAFFOLD.search(second):
131
175
  continue
176
+ head, tail = cont[i], cont[i + 1]
132
177
  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.
178
+ prefix = _common_prefix(ta, tb)
179
+ negated = bool(NEGATION.search(first) or NEGATION.search(second))
180
+
181
+ # Marked, announced at the open or the close of the pair.
182
+ if RX_MARKED_OPEN.match(first) and nb <= 10:
183
+ out.append(f"{first} {second}"); consumed = i + 1; continue
184
+ if RX_MARKED_CLOSE.search(second) and na <= 10:
185
+ out.append(f"{first} {second}"); consumed = i + 1; continue
186
+ # Marked, carried inside the pair. A negation alone is ordinary prose,
187
+ # so the halves also have to be short AND share a frame. One shared
188
+ # topic word is not a frame: "The AI roles supply judgment. A generating
189
+ # role never certifies its own output." shares "role" and is ordinary
190
+ # prose. The frame is a repeated opening, a copula on both sides, or two
191
+ # words in common.
192
+ if negated and na <= 10 and nb <= 8 and (prefix >= 1 or len(shared) >= 2):
193
+ out.append(f"{first} {second}"); consumed = i + 1; continue
194
+ # A copula on both sides is the weakest of the three frames, so it only
195
+ # counts when the halves are staccato-short. "Passwords are never stored
196
+ # in plain text. They are hashed with a per-user salt." is two copulas
197
+ # and a negation and no figure at all.
198
+ if (negated and na <= 7 and nb <= 6
199
+ and COPULA.search(first) and COPULA.search(second)):
200
+ out.append(f"{first} {second}"); consumed = i + 1; continue
201
+ # A contrastive opener on the second half is the twist announced by a
202
+ # conjunction rather than by the negation's position: "That creates
203
+ # speed. But speed is not velocity."
204
+ if (negated and RX_CONTRASTIVE.match(second) and na <= 8 and nb <= 7
205
+ and (shared or COPULA.search(second))):
206
+ out.append(f"{first} {second}"); consumed = i + 1; continue
207
+ # The two template shapes the meter anchors, counted here.
208
+ if RX_STOCK_CLOSER.search(second) or RX_UNMARKED_REVERSAL.match(first):
209
+ out.append(f"{first} {second}"); consumed = i + 1; continue
210
+ # Isocolon: one frame, both arguments swapped. A repeated opening is
211
+ # restatement rather than a swap, but only when the repeated part
212
+ # carries meaning: "Version one shipped in March. Version two shipped in
213
+ # June." repeats the subject and is enumeration, while "A junior
214
+ # engineer reads the error. A senior engineer reads the stack trace."
215
+ # repeats only the article and is the figure. So the test is whether any
216
+ # CONTENT word opens both halves, not whether any token does.
217
+ shared_open = any(w not in ANTITHESIS_STOP for w in ta[:prefix])
218
+ if shared_open or head == tail or len(head) < 2 or len(tail) < 2:
219
+ continue
135
220
  if len(shared) >= 2 and len(shared) / min(len(head), len(tail)) >= 0.5:
136
221
  out.append(f"{first} {second}")
222
+ consumed = i + 1
137
223
  return out
138
224
 
139
225
 
@@ -246,6 +332,74 @@ FINITE_VERB = re.compile(
246
332
  r"|gate[sd]?|gives?|runs?|gets?|gave|gone)\b"
247
333
  r"|\b\w+(?:s|ed|es)\b", re.I)
248
334
 
335
+ # The -s half of that catch-all cannot tell a verb from a plural noun, and a
336
+ # plural noun in a fragment made the whole fragment invisible: "Same compound,
337
+ # three identifiers." and "Same assay, two units." both read as verbed on
338
+ # `identifiers` and `units`. Since fragments are mostly noun phrases, and noun
339
+ # phrases are mostly plural, the detector was blind to its own commonest shape.
340
+ #
341
+ # A determiner, number or quantifier immediately before an -s word makes it a
342
+ # noun ("three identifiers", "no rules"), not a verb ("the model runs" keeps
343
+ # its verb because "model" is not in this list). Same closed-list device as
344
+ # IMPERATIVE_OPENER above, and measured the same way: on data/corpus/
345
+ # must-not-flag it adds no findings at all.
346
+ NOUN_MARKER = frozenset("""
347
+ a an the this that these those my your his her its our their no some any
348
+ many several few both all each every another other more most much little
349
+ two three four five six seven eight nine ten dozen hundred thousand
350
+ one first second third next last same own other
351
+ of at in on for by with from to into onto over under about across through
352
+ without within per via against between during after before
353
+ """.split())
354
+ # The prepositions are there for the same reason as the determiners: what
355
+ # follows one is a noun, not a verb. "In innovation at scale." has two words
356
+ # that are verbs elsewhere ("scale", and "innovation" is safe), and without
357
+ # this the fragment reads as a sentence. An infinitive after "to" is not a
358
+ # finite verb either, so listing it here is correct rather than convenient.
359
+
360
+
361
+ # The base forms IMPERATIVE_OPENER already knows, reused away from the sentence
362
+ # opening. A plural subject takes a bare verb -- "our engineers ship weekly" --
363
+ # which carries no inflection for the catch-all to find, so without this the
364
+ # fix above turns every such sentence into a fragment.
365
+ BASE_VERBS = frozenset(
366
+ IMPERATIVE_OPENER.pattern
367
+ .split("(?:add|", 1)[1]
368
+ .split(")\\b", 1)[0]
369
+ .replace("|", " ")
370
+ .split()
371
+ ) | {"add"} | frozenset("""
372
+ work need mean seem feel matter differ vary exist remain happen occur tend
373
+ cost fail pass fit last agree apply depend belong arrive land stick
374
+ """.split())
375
+ # The extras are the stative and intransitive verbs a plural subject takes and
376
+ # the imperative list has no reason to carry -- you do not tell someone to
377
+ # "matter". "The tools work." was the fragment this produced without them. Each
378
+ # one is also a noun in some context ("the work", "the cost"), which the
379
+ # determiner test in _looks_like_noun already handles.
380
+
381
+
382
+ def _looks_like_noun(sent: str, start: int) -> bool:
383
+ """Is the word at `start` sitting in a noun phrase rather than a verb slot?"""
384
+ prev = re.findall(r"[A-Za-z][\w'-]*", sent[:start])
385
+ return bool(prev) and prev[-1].lower() in NOUN_MARKER
386
+
387
+
388
+ def _has_finite_verb(sent: str) -> bool:
389
+ for match in FINITE_VERB.finditer(sent):
390
+ word = match.group(0)
391
+ # An explicit auxiliary or an -ed form is a verb wherever it appears.
392
+ if not re.fullmatch(r"\w+(?:s|es)", word, re.I):
393
+ return True
394
+ if not _looks_like_noun(sent, match.start()):
395
+ return True # "the model runs"
396
+ # "three identifiers" is a noun, "one report" is a noun, but "engineers
397
+ # ship" is a verb: the same determiner test decides both.
398
+ for match in re.finditer(r"\b[A-Za-z][\w'-]*\b", sent):
399
+ if match.group(0).lower() in BASE_VERBS and not _looks_like_noun(sent, match.start()):
400
+ return True
401
+ return False
402
+
249
403
 
250
404
  def _sentences(prose: str) -> list[str]:
251
405
  return [x.strip() for x in re.split(r"(?<=[.!?])\s+", prose) if x.strip()]
@@ -261,7 +415,7 @@ def verbless_fragments(prose: str) -> list[str]:
261
415
  continue
262
416
  if IMPERATIVE_OPENER.match(sent):
263
417
  continue
264
- if not FINITE_VERB.search(sent):
418
+ if not _has_finite_verb(sent):
265
419
  out.append(sent)
266
420
  return out
267
421