snifftest 0.1.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.
@@ -0,0 +1,458 @@
1
+ # Sniff Test, the default ruleset.
2
+ #
3
+ # Fifteen rules, ten of which apply to most prose and five of which are
4
+ # conventions of copy meant to sell or persuade. Five rules are countable and
5
+ # run as regular expressions on your machine, with nothing sent anywhere. Ten
6
+ # are judgment calls and are answered by the judgment model, one probability
7
+ # per rule per paragraph. A flag names its rule and its probability.
8
+ #
9
+ # The rules tagged `marketing` encode a convention of marketing copy, and they
10
+ # sit out an ordinary run because `off_by_default` names that tag below. Ask
11
+ # for them on a landing page with `--only marketing`, or run everything else
12
+ # and add them with a project file that extends this one and writes
13
+ # `off_by_default: []`. Every rule here is one view of good writing. Disagree
14
+ # with any of them by editing your own copy of the file; that is the feature.
15
+ #
16
+ # A countable rule may name the kinds of block it applies to with `chunks`.
17
+ # The kinds are front_matter, heading, table, link_definition, html_comment,
18
+ # list, block_quote and prose. A rule that names none applies to prose, block
19
+ # quotes, lists and headings, which is everything a person would call writing.
20
+ # The judgment rules are asked about prose, block quotes and lists long enough
21
+ # to hold a sentence, and never about the rest: a heading is not a paragraph,
22
+ # and a request about one costs what a request about a paragraph costs.
23
+ #
24
+ # A `.snifftest.yaml` in your project extends or replaces this file. Copy a
25
+ # rule, change its words, keep its id unique. The `seed` on each rule is how
26
+ # `snifftest eval` manufactures one defect of that kind inside your own clean
27
+ # text, so that recall and false alarms can be measured on your prose rather
28
+ # than ours. A transform seed is mechanical; a splice seed inserts one of the
29
+ # listed sentences, at the start, at the end, or anywhere, per `position`.
30
+ #
31
+ # Each judgment rule carries four parts. `what` describes the defect.
32
+ # `not_for` lists the near misses that must not be flagged. `examples` are
33
+ # sentences that do trip the rule. `criteria` are the two statements the
34
+ # model decides between. The contrast is what makes the judgment reliable;
35
+ # a rule with a `what` and no `not_for` will flag things you did not mean.
36
+
37
+ version: 1
38
+ threshold: 0.7
39
+
40
+ # Tags whose rules sit out a run unless they are asked for by name.
41
+ off_by_default: [marketing]
42
+
43
+ rules:
44
+ # --- countable rules: regular expressions, no network -------------------
45
+
46
+ - id: dash_present
47
+ kind: regex
48
+ # The built-in matches two characters: an en dash (U+2013) and an em dash
49
+ # (U+2014). A plain hyphen in a compound word does not count, and neither
50
+ # does an en dash between numerals, because "pages 10-20" written with the
51
+ # typographer's dash is a range and this rule is about a writer reaching
52
+ # for a dash instead of a full stop.
53
+ #
54
+ # A block quote is somebody else's text, so its dashes are theirs and the
55
+ # rule stays out of it. A quotation running inline inside a sentence cannot
56
+ # be told from the writer's own words without parsing quotation marks that
57
+ # mean five other things as well, so an inline quotation is still flagged.
58
+ builtin: dash_present
59
+ chunks: [prose, list, heading]
60
+ message: "A long dash. Give the sentence a full stop instead."
61
+ seed: { transform: insert_em_dash, count: 1 }
62
+
63
+ - id: colon_heavy
64
+ kind: regex
65
+ # The built-in counts the colon character (U+003A) in the paragraph and
66
+ # trips at `min` or more. A semicolon is not a colon. The colon in a URL,
67
+ # the one in a clock time and the ones inside an HTML comment are not
68
+ # punctuation a reader sees, so they are not counted.
69
+ #
70
+ # A list is not one paragraph: four bullets with a colon apiece are four
71
+ # sentences, and counting them together told a design note to pick one
72
+ # colon out of four.
73
+ builtin: colon_count
74
+ min: 3
75
+ chunks: [prose, block_quote]
76
+ message: "Three colons in one paragraph. Pick one."
77
+ seed: { transform: add_colons, count: 3 }
78
+
79
+ - id: sentence_rhythm
80
+ kind: regex
81
+ # The built-in splits on (?<=[.!?])\s+, counts words per sentence, and
82
+ # trips when the coefficient of variation (standard deviation over mean)
83
+ # is below `floor`. Paragraphs with fewer than `min_sentences` sentences
84
+ # or fewer than `min_words` words are skipped: four six-word sentences are
85
+ # a status note, and telling its author that every sentence runs to the
86
+ # same length is telling them their note is a note.
87
+ builtin: sentence_rhythm
88
+ floor: 0.25
89
+ min_sentences: 4
90
+ min_words: 60
91
+ chunks: [prose, block_quote]
92
+ message: "Every sentence runs to the same length. The reader nods off by the third."
93
+ seed: { transform: equalize_sentences }
94
+
95
+ - id: slop_vocab
96
+ kind: regex
97
+ # The built-in matches any listed word and its ordinary inflections: the
98
+ # stem as written plus s, es, ed, ing, ly, ment(s) and ion(s), with a final
99
+ # silent e dropped where English drops it, so "delve" catches "delves" and
100
+ # "delving", and "seamless" catches "seamlessly". Matching is
101
+ # case-insensitive and stops at a word boundary, so "landscape" does not
102
+ # catch "landscaped lawn" through some longer word. A derivation further
103
+ # from the stem than those suffixes, such as "intricacies", is a separate
104
+ # word: add it to the list if you want it.
105
+ #
106
+ # `except` lists phrases in which a listed word is doing its literal job.
107
+ # These are matched case-insensitively and take the word out of the count.
108
+ # Edit both lists freely; a word on this list is a tell in some registers
109
+ # and the plain choice in others.
110
+ builtin: slop_vocab
111
+ words:
112
+ - delve
113
+ - tapestry
114
+ - landscape
115
+ - unlock
116
+ - testament
117
+ - navigate
118
+ - realm
119
+ - myriad
120
+ - intricate
121
+ - pivotal
122
+ - seamless
123
+ - showcase
124
+ except:
125
+ - landscape architect
126
+ - landscape architecture
127
+ - landscape gardener
128
+ - landscape orientation
129
+ - landscape mode
130
+ - navigate to
131
+ - navigate by
132
+ - navigate the menu
133
+ - navigation bar
134
+ - navigation pane
135
+ - unlock the door
136
+ - unlock the screen
137
+ - unlock your phone
138
+ message: "A word that turns up far more often in generated text than in prose a person wrote. Try the plain one."
139
+ seed: { transform: insert_slop_word, count: 1 }
140
+
141
+ - id: banned_words
142
+ kind: regex
143
+ # Same matcher as slop_vocab, over your own list. Empty by default, so it
144
+ # flags nothing until you add a word. Put the words your house never uses.
145
+ builtin: banned_words
146
+ words: []
147
+ message: "A word from your own banned list."
148
+ seed: { transform: insert_banned_word, count: 1 }
149
+
150
+ # --- judgment rules: answered by the model, one probability each ---------
151
+
152
+ - id: not_x_but_y
153
+ kind: judgment
154
+ what: |
155
+ A sentence, or a pair of sentences, whose whole job is to swap one
156
+ label for another. The writer denies one description of a thing and
157
+ puts a replacement in its place, and the swap is the point. The shape
158
+ is "it is not X, it is Y", or "this was never X. It was Y.", or "not a
159
+ X but a Y", or the same swap in reverse, "it is Y, not X". Both halves
160
+ describe the same subject, and the sentence carries nothing else.
161
+ not_for: |
162
+ A plain negation with no replacement offered ("This is not a
163
+ guarantee.").
164
+ A denial followed by a separate fact rather than a relabelling ("It is
165
+ not finished, and two tests still fail.").
166
+ A comparison with "rather than" or "instead of" inside a sentence that
167
+ carries other information ("We ship on Tuesdays rather than Fridays so
168
+ the weekend stays quiet.").
169
+ A factual correction of a claim someone else made, with the evidence
170
+ for it.
171
+ Two sentences that each carry their own information, where the second
172
+ does not exist only to replace the first.
173
+ examples:
174
+ - "It is not a shortcut, it is the whole method."
175
+ - "The problem was never the tooling. It was the calendar."
176
+ - "That is a habit, not a strategy."
177
+ criteria:
178
+ true: "At least one sentence, or sentence pair, denies one description of a thing and substitutes another for effect, in either order, with the swap as its point."
179
+ false: "Every negation either stands alone, is followed by new information rather than a relabelling, or corrects a claim with evidence."
180
+ message: "Not X, it's Y. The reframe is doing the arguing for you."
181
+ seed:
182
+ splice:
183
+ - "Calling Tuesday an outage would be generous. Somebody chose to turn it off."
184
+ - "Whatever the Friday meeting has become, a review is no longer the word for it. A rehearsal is."
185
+ - "Nobody has a scheduling problem here. What everyone has is a saying-no problem."
186
+ position: any
187
+
188
+ - id: tricolon
189
+ kind: judgment
190
+ what: |
191
+ Judge sentence by sentence. One sentence is enough to trip this rule,
192
+ wherever it sits in the paragraph and however informative the rest of
193
+ the paragraph is.
194
+ A decorative rule of three. Three short parallel words or phrases in a
195
+ row whose job is the beat rather than the information, such as three
196
+ adjectives, three verbs, or a three-word fragment used as a flourish.
197
+ not_for: |
198
+ A list of three distinct things the reader needs, such as three named
199
+ steps, three options, or three findings, each adding something the
200
+ others do not.
201
+ A list of two, or of four or more.
202
+ Three items quoted from somewhere else and discussed.
203
+ examples:
204
+ - "The fix is cleaner, faster, and truer."
205
+ - "Plan, build, repeat."
206
+ - "It was slow, it was loud, it was late."
207
+ criteria:
208
+ true: "At least one sentence, anywhere in the paragraph, carries a three-item run whose purpose is rhythm rather than information. One such sentence is enough, and the informative lists around it do not excuse it."
209
+ false: "Every three-item run in the paragraph is an informative list, or there are none."
210
+ message: "Three items for the cadence. Keep the one that carries the weight."
211
+ seed:
212
+ splice:
213
+ - "The result was quieter, lighter, and kinder."
214
+ - "Read, mark, move on."
215
+ - "Small budget, small room, small hopes."
216
+ position: any
217
+
218
+ - id: stacked_hedging
219
+ kind: judgment
220
+ what: |
221
+ One claim carrying two or more softeners at once, so that the sentence
222
+ never commits. "Might perhaps", "arguably, in some sense, probably",
223
+ "it seems to me that it could possibly".
224
+ not_for: |
225
+ A single hedge used once and on purpose, such as one "I think" or one
226
+ "roughly".
227
+ Honest uncertainty stated plainly ("The cause is unknown.").
228
+ A precise qualifier on a number ("about 40 percent", "at least three").
229
+ A passage that quotes or discusses hedging as its subject.
230
+ examples:
231
+ - "It might perhaps, in some sense, arguably be the main reason."
232
+ - "It seems to me that this could possibly tend to help a little."
233
+ criteria:
234
+ true: "At least one claim carries two or more piled-up softeners in the same sentence."
235
+ false: "Hedges are absent, or each claim carries at most one, used deliberately."
236
+ message: "Three hedges on one claim. Either say it or leave it out."
237
+ seed:
238
+ splice:
239
+ - "That could perhaps, in a way, maybe explain part of the gap."
240
+ - "I would think it might arguably, more or less, be worth a try."
241
+ position: any
242
+
243
+ - id: rhetorical_opener
244
+ kind: judgment
245
+ what: |
246
+ The paragraph's first sentence is a question asked for effect, to set
247
+ up what the writer is about to say, rather than to get an answer from
248
+ anyone.
249
+ not_for: |
250
+ A question that appears later in the paragraph.
251
+ A first sentence that is a statement, even a curious-sounding one.
252
+ An opening question addressed to a named person, or one the paragraph
253
+ reports someone else asking.
254
+ examples:
255
+ - "What if the checker itself is the thing that is wrong?"
256
+ - "But what happens when nobody reads the draft twice?"
257
+ criteria:
258
+ true: "The first sentence is a question asked for rhetorical effect."
259
+ false: "The first sentence is a statement, or the only questions come later, or the opening question is one someone actually asked."
260
+ # About the paragraph's opening, so it is not asked of the later pieces of a
261
+ # paragraph that was too long to send in one request.
262
+ sentence: first
263
+ message: "Opens on a question nobody asked. Start with the answer."
264
+ seed:
265
+ splice:
266
+ - "Who decided that a draft has to be finished before anyone reads it?"
267
+ - "And what is a rule worth if nobody remembers it on a Friday?"
268
+ position: start
269
+
270
+ - id: restating_closer
271
+ kind: judgment
272
+ what: |
273
+ The paragraph's final sentence only says again what the paragraph has
274
+ already said. It often opens with "in short", "in other words", "to sum
275
+ up", or "put simply", or refers back to "everything above".
276
+ not_for: |
277
+ A closing sentence that lands something new: a consequence, a named
278
+ mechanism, a number, a source, a condition, or a turn the paragraph had
279
+ not yet made, even when it is short or emphatic.
280
+ A paragraph of a single sentence.
281
+ examples:
282
+ - "In short, everything above is what we said."
283
+ - "To sum up, that is how the process works, as this paragraph has described."
284
+ criteria:
285
+ true: "The last sentence is a summary of the paragraph and adds nothing."
286
+ false: "The last sentence advances the argument or adds information, or the paragraph is a single sentence."
287
+ # About the paragraph's ending, so it is not asked of the earlier pieces of
288
+ # a paragraph that was too long to send in one request.
289
+ sentence: last
290
+ message: "The last sentence says the paragraph again. End one sentence earlier."
291
+ seed:
292
+ splice:
293
+ - "In other words, this paragraph has just made the point it set out to make."
294
+ - "Put simply, that is the whole of what was said above."
295
+ position: end
296
+
297
+ - id: self_undercutting
298
+ kind: judgment
299
+ tags: [marketing]
300
+ # A page meant to sell has to make its offer. A page meant to document
301
+ # has to state its limits, which is why this rule sits out by default.
302
+ what: |
303
+ Judge sentence by sentence. One sentence is enough to trip this rule,
304
+ however confident and specific the rest of the paragraph is.
305
+ A line that talks the reader out of the thing being offered. It says
306
+ the thing may not work, that the writer does not claim it works, that
307
+ there is no guarantee, or that it may not be for them. On a page meant
308
+ to sell, describe, or recommend, this weakens the offer and tells the
309
+ reader nothing they can use.
310
+ not_for: |
311
+ A precise statement of scope or a known limit ("It checks Markdown and
312
+ plain text, not PDFs.").
313
+ A measured result with its range ("Recall was 0.85 to 0.94 across three
314
+ runs.").
315
+ A specific past failure that the paragraph then explains.
316
+ An honest statement of what a piece of work does and does not cover
317
+ ("We do not claim this catches every case."), which is a scope note and
318
+ belongs in good documentation.
319
+ Disputing a claim someone else made.
320
+ examples:
321
+ - "This may not work for you, and we are not saying it will."
322
+ - "There is no guarantee any of this helps."
323
+ criteria:
324
+ true: "At least one sentence, anywhere in the paragraph, tells the reader not to count on the thing working, with no specific limit named. One such sentence is enough."
325
+ false: "Limits are named precisely, or no self-undercutting line appears."
326
+ message: "The copy is talking the reader out of it. Name the limit and drop the shrug."
327
+ seed:
328
+ splice:
329
+ - "It might do nothing at all for your team, and that is fine."
330
+ - "Plenty of people try this once and shrug, so keep your expectations low."
331
+ - "Results vary and we make no promises about yours."
332
+ position: any
333
+
334
+ - id: first_x_that
335
+ kind: judgment
336
+ tags: [marketing]
337
+ # A first claim is a marketing habit; a technical note may need the word
338
+ # for a sequence, which `not_for` covers.
339
+ what: |
340
+ Judge sentence by sentence. One sentence is enough to trip this rule,
341
+ wherever it sits in the paragraph.
342
+ A claim that the writer, or their team, product, or company, is the
343
+ first to do something. "The first tool that", "the first team to", "we
344
+ were the first to".
345
+ not_for: |
346
+ A first-ever claim about someone else, with a source.
347
+ "First" as a position in a sequence, such as the first step, the first
348
+ draft, or the first of two things.
349
+ A claim of being different, deeper, or better that does not use
350
+ "first".
351
+ A dated historical fact with a source ("The first edition appeared in
352
+ 1918.").
353
+ examples:
354
+ - "This is the first tool that reads a draft the way an editor does."
355
+ - "We are the first team to run this check on every save."
356
+ criteria:
357
+ true: "At least one sentence, anywhere in the paragraph, claims the writer, or their product, team, or company, was first to do something. One such sentence is enough."
358
+ false: "No first claim is made about the writer or their own work."
359
+ message: '"The first X that." Someone will check.'
360
+ seed:
361
+ splice:
362
+ - "Nobody had put a probability beside every flag until our team did."
363
+ - "Ours is the earliest field notebook a crew could carry down a mine with no signal."
364
+ position: any
365
+
366
+ - id: naked_cost_figure
367
+ kind: judgment
368
+ tags: [marketing]
369
+ # This is a house rule about marketing surfaces, not about writing.
370
+ what: |
371
+ The paragraph states what something cost to make or run, in model
372
+ spend, compute, hours, or "it cost us", without naming the price a
373
+ customer pays and the alternative beside it.
374
+ not_for: |
375
+ A cost quoted together with the customer price and a comparison ("It
376
+ costs us about a dollar to run, you pay $49, and an agency charges
377
+ $2,000.").
378
+ A cost in a technical appendix or a bench table that names what it
379
+ measures.
380
+ A customer price quoted as a price.
381
+ A revenue figure, a valuation, or a figure about someone else's
382
+ business.
383
+ examples:
384
+ - "This report cost us $1.30 in API calls."
385
+ - "Each run costs us about nine cents in model spend."
386
+ criteria:
387
+ true: "A production or running cost appears with no customer price and no alternative next to it."
388
+ false: "Every cost figure is accompanied by the price and an alternative, or no cost figure appears."
389
+ message: "A cost with no price next to it. Sniffs like a discount nobody asked for."
390
+ seed:
391
+ splice:
392
+ - "It cost us about four dollars of compute to make this."
393
+ - "Rendering the page costs us a little under two cents each time."
394
+ position: any
395
+
396
+ - id: jobs_claim
397
+ kind: judgment
398
+ tags: [marketing]
399
+ # A positioning rule for pages that sell. A report about employment
400
+ # says what it measures and is not covered by this.
401
+ what: |
402
+ Judge sentence by sentence. One sentence is enough to trip this rule,
403
+ wherever it sits in the paragraph and however carefully the rest of the
404
+ paragraph describes the division of work.
405
+ A claim about employment. That the thing creates jobs, that it
406
+ replaces the people who used to do the work, or that it would never
407
+ replace a person.
408
+ not_for: |
409
+ Describing what the tool does and what a person still decides, with no
410
+ claim about employment.
411
+ Naming who reviews or signs off.
412
+ Replacing a thing rather than a person ("It replaces the spreadsheet.").
413
+ A sourced statistic about employment, in a report whose subject is
414
+ employment.
415
+ examples:
416
+ - "We would never replace a person."
417
+ - "This replaces the people who used to do the checking by hand."
418
+ - "Four seats means we create jobs rather than removing them."
419
+ criteria:
420
+ true: "At least one sentence, anywhere in the paragraph, makes an employment claim of one of those kinds. One such sentence is enough, whatever the surrounding sentences say about what people still do."
421
+ false: "No sentence makes an employment claim; the paragraph describes the division of work and stops there."
422
+ message: "A claim about jobs. Say what the tool does and what people keep, then stop."
423
+ seed:
424
+ splice:
425
+ - "Nobody loses their job to this, and if anything it adds a few."
426
+ - "The tool now does the work that three people used to do."
427
+ - "We have promised the team that no role will ever be automated away."
428
+ position: any
429
+
430
+ - id: pullquote_fragment
431
+ kind: judgment
432
+ tags: [marketing]
433
+ # A typographic convention of published copy, not of ordinary prose.
434
+ what: |
435
+ Judge line by line. One display line is enough to trip this rule,
436
+ wherever it sits in the paragraph and however complete the other lines
437
+ around it are.
438
+ A pull quote, meaning a short line set apart in quotation marks or
439
+ italics as a display line, whose text is a fragment with no subject and
440
+ verb of its own.
441
+ not_for: |
442
+ A quotation from a named source running inline as part of a sentence,
443
+ however short.
444
+ A complete sentence used as a pull quote.
445
+ A heading or a title.
446
+ A paragraph with no pull quote at all.
447
+ examples:
448
+ - "*\"Built to be argued with.\"*"
449
+ - "*\"The part nobody reads twice.\"*"
450
+ criteria:
451
+ true: "At least one display line, anywhere in the paragraph, is set apart as a pull quote and is a fragment rather than a complete sentence. One such line is enough, and the complete ones beside it do not excuse it."
452
+ false: "There is no pull quote, or every pull quote in the paragraph is a complete sentence."
453
+ message: "A pull quote with no verb. Quote a whole sentence."
454
+ seed:
455
+ splice:
456
+ - "*\"Cheaper by the paragraph.\"*"
457
+ - "*\"A pen on a string.\"*"
458
+ position: any