sloplint-judge 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,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+ require "json"
7
+ require_relative "../secret"
8
+
9
+ module Sloplint
10
+ module Judge
11
+ module Backends
12
+ # Jev, TypeSafe's System One model. The rule's question shape is Jev's,
13
+ # so questions pass through verbatim.
14
+ class Jev
15
+ DEFAULT_URL = "https://api.typesafe.ai/v1/systemone"
16
+ DEFAULT_MODEL = "jev-latest"
17
+ # The environment variable, and the keychain account, this backend's
18
+ # key lives under. Another backend declares its own, so two keys are
19
+ # two items.
20
+ KEY = "TYPESAFE_API_KEY"
21
+
22
+ # key: nil means look it up (environment, then keychain; see Secret).
23
+ def initialize(url: ENV.fetch("SYSTEMONE_URL", DEFAULT_URL),
24
+ model: ENV.fetch("SYSTEMONE_MODEL", DEFAULT_MODEL),
25
+ key: nil)
26
+ # The URL first: reading the key means the environment and then a
27
+ # keychain subprocess, which can sit for fifteen seconds on a
28
+ # dialog nobody can see, and a run that is about to be refused for
29
+ # its URL has no business pulling the secret out of the keychain.
30
+ @url = self.class.https!(url)
31
+ key ||= Secret.fetch(KEY)&.first
32
+ raise ArgumentError, Secret.missing(KEY) if key.nil? || key.empty?
33
+
34
+ @model = model
35
+ @key = key
36
+ end
37
+
38
+ # The name a run would report, without building the backend or
39
+ # reading the key, so a run that asks nothing still names the model
40
+ # the way a run that asks something does.
41
+ def self.model_name = ENV.fetch("SYSTEMONE_MODEL", DEFAULT_MODEL)
42
+
43
+ # The endpoint settings, checked without building the backend or
44
+ # reading the key; `status` prints what this returns.
45
+ def self.configured!
46
+ https!(ENV.fetch("SYSTEMONE_URL", DEFAULT_URL))
47
+ "model #{model_name}"
48
+ end
49
+
50
+ # Case-insensitive, because a DNS name is: API.TypeSafe.ai is the
51
+ # same host as api.typesafe.ai, and URI hands back the host as it was
52
+ # written. The scheme needs no such care -- URI lowercases that one.
53
+ HOST = /\A(?:.+\.)?typesafe\.ai\z/i
54
+
55
+ # The key and the whole document go to this URL, so it is https and
56
+ # a TypeSafe host or nothing. The host pin is what keeps an injected
57
+ # `SYSTEMONE_URL=https://attacker.example sloplint check --judge` from
58
+ # being a valid way to run the sanctioned command.
59
+ def self.https!(url)
60
+ # URI raises its own error class, which the commands do not catch:
61
+ # a misspelt SYSTEMONE_URL would end `status` with a backtrace
62
+ # instead of the usage error every other bad setting gets.
63
+ uri = begin
64
+ URI(url)
65
+ rescue URI::InvalidURIError
66
+ raise ArgumentError, "SYSTEMONE_URL is not a URL: #{url.inspect}"
67
+ end
68
+ raise ArgumentError, "SYSTEMONE_URL must be https, got #{uri.scheme.inspect}" unless uri.scheme == "https"
69
+ raise ArgumentError, "SYSTEMONE_URL must be a typesafe.ai host, got #{uri.host.inspect}" unless uri.host.to_s.match?(HOST)
70
+ raise ArgumentError, "SYSTEMONE_URL needs a path, for example #{DEFAULT_URL}" if uri.path.empty?
71
+
72
+ uri
73
+ end
74
+
75
+ # Jev's response carries token counts and no price. TypeSafe's public
76
+ # price is $42 per billion input tokens; output tokens are free. So
77
+ # the cost is the input tokens times this, exactly, not an estimate.
78
+ # ponytail: one constant, not a config; move to an env var when the
79
+ # price moves.
80
+ USD_PER_INPUT_TOKEN = 42.0 / 1_000_000_000
81
+
82
+ def name = @model
83
+
84
+ # Dollars for a summed usage Hash. Output tokens cost nothing. On the
85
+ # class as well, because the run that asks nothing has no built
86
+ # backend to ask and still prints a price.
87
+ def self.cost_usd(usage) = usage.fetch("input_tokens", 0) * USD_PER_INPUT_TOKEN
88
+ def cost_usd(usage) = self.class.cost_usd(usage)
89
+
90
+ def ask(state, questions)
91
+ http = Net::HTTP.new(@url.host, @url.port)
92
+ http.use_ssl = true
93
+ http.read_timeout = 90
94
+ req = Net::HTTP::Post.new(@url.request_uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{@key}")
95
+ req.body = JSON.generate({ "state" => state, "model" => @model, "questions" => questions })
96
+ res = http.request(req)
97
+ raise BackendError, "#{@url.host} returned #{res.code}: #{res.body.to_s[0, 200]}" unless res.code == "200"
98
+
99
+ body = JSON.parse(res.body)
100
+ # A body of [] or null reads as an answer until something is asked
101
+ # of it. Said here, it is a malformed body like any other; left to
102
+ # the reads below it would be a TypeError, which is a bug's class.
103
+ malformed!("the body is #{body.class}, not a Hash") unless body.is_a?(Hash)
104
+
105
+ usage = body["usage"].is_a?(Hash) ? body["usage"] : {}
106
+ answers = body.fetch("answers")
107
+ malformed!("answers is #{answers.class}, not a Hash keyed by question") unless answers.is_a?(Hash)
108
+ questions.to_h do |qname, q|
109
+ a = answers.fetch(qname) { raise KeyError, "no answer for #{qname}" }
110
+ malformed!("the answer for #{qname} is #{a.class}, not a Hash") unless a.is_a?(Hash)
111
+ type = q.fetch("type")
112
+ probs = normalise(type, type == "noul" ? a.fetch("noul") : a.fetch("probabilities"), q)
113
+ [qname, Answer.new(type: type, probabilities: probs, confidence: confidence_for(a, type, probs), usage: usage)]
114
+ end
115
+ # Transport errors, and a body missing a key Jev documents, are one
116
+ # thing to the caller: the backend did not answer. Exit 3, not a
117
+ # backtrace. A body of the wrong shape raises BackendError where it is
118
+ # read, so this list stays off our own code: a bug in normalise is a
119
+ # bug, and it gets to raise like one.
120
+ # Net::HTTPBadResponse and Net::HTTPHeaderSyntaxError are named one by
121
+ # one because they descend from StandardError and not from
122
+ # Net::ProtocolError: a proxy that answers with something that is not
123
+ # an HTTP status line raises one of them.
124
+ rescue SocketError, IOError, SystemCallError, OpenSSL::SSL::SSLError, Net::ProtocolError,
125
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::OpenTimeout,
126
+ Net::ReadTimeout, Net::WriteTimeout, JSON::ParserError, KeyError => e
127
+ raise BackendError, "#{e.class}: #{e.message}"
128
+ end
129
+
130
+ private
131
+
132
+ # Jev returns score probabilities keyed by level index as strings, choice
133
+ # probabilities keyed by option, and a noul as one float under "noul".
134
+ # Anything else in those slots is a malformed body, said so here, which
135
+ # is what lets `ask` rescue only the reads and not our own arithmetic.
136
+ def normalise(type, probs, question)
137
+ case type
138
+ when "score" then scores(probs, question.fetch("criteria").size)
139
+ when "choice"
140
+ malformed!("choice probabilities are #{probs.class}, not a Hash keyed by option") unless probs.is_a?(Hash)
141
+
142
+ probs.transform_values { |p| number(p) }
143
+ else
144
+ malformed!("noul is #{probs.class}, not a number") unless probs.is_a?(Numeric)
145
+
146
+ probs.to_f
147
+ end
148
+ end
149
+
150
+ # One probability per level, in level order. Jev may leave out a level
151
+ # whose probability is zero, so the array is as long as the question's
152
+ # criteria and the gaps are 0.0. Otherwise a short array would slide
153
+ # every level down one and Answer#top would name the wrong criterion.
154
+ def scores(probs, levels)
155
+ malformed!("score probabilities are #{probs.class}, not a Hash keyed by level") unless probs.is_a?(Hash)
156
+ # No level at all is not a level of zero. Filled with zeros the
157
+ # levels would tie, the tie would go to level 0, and level 0 is the
158
+ # one every score rule flags on.
159
+ malformed!("score probabilities for none of the #{levels} levels") if probs.empty?
160
+
161
+ out = Array.new(levels, 0.0)
162
+ probs.each do |k, p|
163
+ i = Integer(k.to_s, 10, exception: false)
164
+ malformed!("score probability key #{k.inspect} is not one of the #{levels} levels") unless i&.between?(0, levels - 1)
165
+ out[i] = number(p)
166
+ end
167
+ out
168
+ end
169
+
170
+ def number(p) = Float(p, exception: false) || malformed!("#{p.class} is not a probability")
171
+
172
+ def malformed!(why) = raise(BackendError, "#{@url.host} returned a malformed body: #{why}")
173
+
174
+ # Score and choice answers carry a confidence. A noul does not, so it
175
+ # gets the one thing its answer can defend: distance from the fence,
176
+ # scaled so 0.5 is 0 and 0 or 1 is 1.
177
+ def confidence_for(answer, type, probs)
178
+ # Read like any other number Jev sends: a confidence of {} or [] or
179
+ # true is a malformed body, not a NoMethodError on our own side.
180
+ return number(answer["confidence"]) if answer["confidence"]
181
+ return (probs - 0.5).abs * 2 if type == "noul"
182
+
183
+ sorted = (probs.is_a?(Hash) ? probs.values : probs).sort.reverse
184
+ malformed!("an answer with no probabilities at all") if sorted.empty?
185
+
186
+ (sorted[0] - (sorted[1] || 0)).clamp(0.0, 1.0)
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "json"
5
+ require "sloplint/judge"
6
+
7
+ module Sloplint
8
+ module Judge
9
+ # The judge's own executable: judge-only check, compare, rules, explain.
10
+ # Exit codes are sloplint's plus 3 for a backend failure. See docs/JUDGE.md.
11
+ module CLI
12
+ module_function
13
+
14
+ def run(argv, out: $stdout, err: $stderr, stdin: $stdin)
15
+ opts = { format: "full", register: Engine::DEFAULT_REGISTER }
16
+ parser = global_parser(opts, out:)
17
+ begin
18
+ parser.order!(argv)
19
+ rescue OptionParser::InvalidOption => e
20
+ argv.unshift("check", *e.args)
21
+ end
22
+ return 0 if opts[:help_shown] || opts[:version_shown]
23
+
24
+ command = argv.shift
25
+ command ||= (stdin.tty? ? "help" : "check")
26
+ case command
27
+ when "check" then cmd_check(argv, opts, out:, err:, stdin:)
28
+ when "compare" then cmd_compare(argv, opts, out:, err:)
29
+ when "rules" then cmd_rules(argv, out:)
30
+ when "explain" then cmd_explain(argv, out:, err:)
31
+ when "status" then cmd_status(opts, out:, err:)
32
+ when "key" then cmd_key(argv, opts, out:, err:, stdin:)
33
+ when "version" then out.puts(VERSION); 0
34
+ when "help" then out.puts(parser.help); 0
35
+ else cmd_check(argv.unshift(command), opts, out:, err:, stdin:)
36
+ end
37
+ rescue OptionParser::ParseError => e
38
+ err.puts("sloplint-judge: #{e.message}")
39
+ 2
40
+ rescue BackendError => e
41
+ err.puts("sloplint-judge: backend failure: #{e.message}")
42
+ 3
43
+ end
44
+
45
+ # ── check ───────────────────────────────────────────────────────────────
46
+ def cmd_check(argv, opts, out:, err:, stdin:)
47
+ markdown = false
48
+ strict = false
49
+ select = nil
50
+ ignore = nil
51
+ help = false
52
+ OptionParser.new do |o|
53
+ o.banner = "usage: sloplint-judge check [options] [paths...] (\"-\" or no paths = stdin)"
54
+ o.on("-o", "--output-format FORMAT", %w[full json], "Output format: 'full' or 'json' (default: full).") { |v| opts[:format] = v }
55
+ o.on("--markdown", "Skip fenced/inline code spans, HTML comments, URLs and Markdown furniture.") { markdown = true }
56
+ o.on("--select IDS", "Only run these rules (comma-separated rule ids or categories).") { |v| select = v.split(",").map(&:strip) }
57
+ o.on("--ignore IDS", "Skip these rules (comma-separated rule ids or categories).") { |v| ignore = v.split(",").map(&:strip) }
58
+ o.on("--strict", "Run the rules that are off by default, ask the sentence rules about",
59
+ "every sentence, and keep low-confidence notes; about three times the",
60
+ "requests of a default run.") { strict = true }
61
+ o.on("--register TEXT", "Who the reader is.") { |v| opts[:register] = v }
62
+ o.on("--backend NAME", "Which adapter to use.") { |v| opts[:backend] = v }
63
+ # OptionParser answers -h itself when nobody else does, and it
64
+ # answers it on the real stdout and ends the process. This command
65
+ # prints to the out it was given and returns, as every other one does.
66
+ o.on("-h", "--help", "Show this help.") { out.puts(o.help); help = true }
67
+ # permute!, so a flag written after the path is a flag: `sloplint-judge
68
+ # check draft.md --markdown` reads the way anyone would write it.
69
+ end.permute!(argv)
70
+ return 0 if help
71
+
72
+ unknown = Sloplint::CLI.unknown_rule_refs(select, RULES) + Sloplint::CLI.unknown_rule_refs(ignore, RULES)
73
+ unless unknown.empty?
74
+ err.puts("sloplint-judge: unknown rule or category: #{unknown.join(", ")}")
75
+ err.puts("run `sloplint-judge rules` to list them.")
76
+ return 2
77
+ end
78
+ rules = Sloplint::CLI.select_rules(select, ignore, strict, RULES)
79
+
80
+ # Only the read is invalid input. An ArgumentError out of the run is
81
+ # a bug in this code, and it raises like one instead of sending the
82
+ # reader to look for bad bytes in a file that has none.
83
+ sources = begin
84
+ Sloplint::CLI.read_sources(argv.empty? ? ["-"] : argv, err:, stdin:, name: "sloplint-judge")
85
+ rescue ArgumentError, Encoding::CompatibilityError => e
86
+ err.puts("sloplint-judge: invalid input: #{e.message}")
87
+ return 2
88
+ end
89
+ return 2 unless sources
90
+
91
+ # A selection that holds no rule is decided here, before a backend is
92
+ # built, because building one reads the key: `--ignore` every rule
93
+ # must run on a machine that has none. Nothing was asked, so nothing
94
+ # was found, and that is exit 0 with a 0-request usage line -- not
95
+ # the "nothing examined" error below, which is a document that had no
96
+ # prose in it.
97
+ if rules.empty?
98
+ usage = begin
99
+ Engine.nothing_asked(opts[:backend])
100
+ rescue ArgumentError => e
101
+ err.puts("sloplint-judge: #{e.message}")
102
+ return 2
103
+ end
104
+ err.puts("sloplint-judge #{Engine.usage_line(usage)}")
105
+ Sloplint::CLI.emit([], opts[:format], out:, by_path: false, judge: usage)
106
+ return 0
107
+ end
108
+
109
+ backend = load_backend(opts[:backend], err:) or return 2
110
+ result = Engine.scan_sources(sources, name: "sloplint-judge", err:, rules:, backend:, markdown:, register: opts[:register], strict:)
111
+ # Nothing asked is nothing examined, and exit 0 would report that the
112
+ # judge read the document and found nothing in it. The same reason
113
+ # empty input is a usage error, and the same exit code.
114
+ if result.usage["requests"].zero?
115
+ names = sources.map { |label, _| label == "-" ? "stdin" : label }
116
+ err.puts("sloplint-judge: nothing examined in #{names.join(", ")}: no prose reached the judge.")
117
+ err.puts("a document that is all Markdown furniture, or whose paragraphs are too short for the rules selected, leaves nothing to ask about.")
118
+ return 2
119
+ end
120
+
121
+ Sloplint::CLI.emit(result.notes, opts[:format], out:, by_path: sources.count { |label, _| label != "-" } > 1, judge: result.usage)
122
+ result.notes.empty? ? 0 : 1
123
+ end
124
+
125
+ # ── compare A B ─────────────────────────────────────────────────────────
126
+ def cmd_compare(argv, opts, out:, err:)
127
+ drift = false
128
+ help = false
129
+ OptionParser.new do |o|
130
+ o.banner = "usage: sloplint-judge compare [--drift] A B (two files)"
131
+ o.on("--drift", "Also ask whether B changes what A says.") { drift = true }
132
+ o.on("--register TEXT", "Who the reader is.") { |v| opts[:register] = v }
133
+ o.on("--backend NAME", "Which adapter to use.") { |v| opts[:backend] = v }
134
+ o.on("-h", "--help", "Show this help.") { out.puts(o.help); help = true }
135
+ # permute!, so `compare A B --drift` sees the flag. There is no nested
136
+ # subcommand here whose own flags an option after the files could be.
137
+ end.permute!(argv)
138
+ return 0 if help
139
+
140
+ a, b = argv
141
+ unless argv.size == 2 && File.file?(a) && File.file?(b)
142
+ err.puts("usage: sloplint-judge compare [--drift] A B")
143
+ return 2
144
+ end
145
+ # The same read `check` does, so an unreadable or badly encoded file
146
+ # is an invalid input here too, before a request is paid for.
147
+ sources = Sloplint::CLI.read_sources([a, b], err:, name: "sloplint-judge") or return 2
148
+
149
+ backend = load_backend(opts[:backend], err:) or return 2
150
+ verdict = Compare.run(sources[0].last, sources[1].last, place: "for #{opts[:register]}", backend:, drift:)
151
+ out.puts(JSON.pretty_generate(verdict.to_h.except(:usage)))
152
+ # Engine.numbers for the same reason a scan uses it: a count that is
153
+ # not a number must not take down a verdict already printed.
154
+ usage = Engine.with_cost({ "backend" => backend.name }.merge(Engine.numbers(verdict.usage)), backend)
155
+ err.puts("sloplint-judge: #{Engine.usage_line(usage)}")
156
+ 0
157
+ end
158
+
159
+ # An unknown backend name or a missing key is a usage error (exit 2),
160
+ # not a backend failure (exit 3): nothing was tried.
161
+ def load_backend(name, err:)
162
+ Backend.load(name)
163
+ rescue ArgumentError => e
164
+ err.puts("sloplint-judge: #{e.message}")
165
+ nil
166
+ end
167
+
168
+ # ── status / key set ────────────────────────────────────────────────────
169
+ # Could a run happen here? Answered without reading the key: this is the
170
+ # check skill's probe and it runs before anyone has agreed to send text.
171
+ # Exit 0 configured, 2 not, with the message `check` would give.
172
+ def cmd_status(opts, out:, err:)
173
+ klass = Backend.klass(opts[:backend])
174
+ settings = klass.configured!
175
+ source = Secret.present?(klass::KEY) or raise ArgumentError, Secret.missing(klass::KEY)
176
+ out.puts("backend #{Backend.default_name(opts[:backend])} (#{settings}), key from #{source}")
177
+ 0
178
+ rescue ArgumentError => e
179
+ err.puts("sloplint-judge: #{e.message}")
180
+ 2
181
+ end
182
+
183
+ # Stores the key once. The platform tool owns the prompt and the
184
+ # terminal, so the value is never on a command line, in a pipe, in shell
185
+ # history or in this process. A person types this; the skill never does.
186
+ def cmd_key(argv, opts, out:, err:, stdin:)
187
+ key = Backend.klass(opts[:backend])::KEY
188
+ return cmd_key_unset(key, out:, err:) if argv == ["unset"]
189
+ return err.puts("usage: sloplint-judge [--backend NAME] key set|unset") || 2 unless argv == ["set"]
190
+
191
+ command = Secret.store_command(key)
192
+ return err.puts("sloplint-judge: no supported key store on this platform; export #{key} instead") || 2 unless command
193
+ return err.puts("sloplint-judge: key set needs a terminal: the keychain tool prompts for the key itself") || 2 unless stdin.tty?
194
+
195
+ out.puts("Storing #{key} in the OS keychain under #{Secret::SERVICE}; the keychain tool will prompt for it.")
196
+ out.puts("Replacing the item stored earlier.") if Secret.stored?(key)
197
+ out.puts("Any process running as you can read it back. This keeps the key out of dotfiles and out of the agent's environment, not out of your account.")
198
+ out.flush
199
+ Process.exec(*command)
200
+ rescue ArgumentError => e
201
+ err.puts("sloplint-judge: #{e.message}")
202
+ 2
203
+ end
204
+
205
+ # Removes the keychain item. Says so when there was none, and says when a
206
+ # key in the environment is still there, because that one it cannot touch.
207
+ def cmd_key_unset(key, out:, err:)
208
+ command = Secret.delete_command(key)
209
+ return err.puts("sloplint-judge: no supported key store on this platform") || 2 unless command
210
+ return err.puts("sloplint-judge: no #{key} item in the keychain") || 2 unless Secret.stored?(key)
211
+
212
+ removed = system(*command, out: File::NULL, err: File::NULL)
213
+ return err.puts("sloplint-judge: the keychain tool could not remove the item") || 2 unless removed
214
+
215
+ out.puts("Removed #{key} from the OS keychain (#{Secret::SERVICE}).")
216
+ out.puts("It is still set in this shell's environment; unset it there too.") unless ENV[key].to_s.empty?
217
+ 0
218
+ end
219
+
220
+ # ── rules / explain ─────────────────────────────────────────────────────
221
+ def cmd_rules(argv, out:)
222
+ as_json = false
223
+ OptionParser.new { |o| o.on("--json") { as_json = true } }.order!(argv)
224
+ Sloplint::CLI.render_rules(RULES, json: as_json, out:)
225
+ 0
226
+ end
227
+
228
+ def cmd_explain(argv, out:, err:)
229
+ id = argv.shift
230
+ rule = RULES.find { |r| r.id == id }
231
+ unless rule
232
+ err.puts(id ? "sloplint-judge: no such rule: #{id}" : "usage: sloplint-judge explain RULE_ID")
233
+ return 2
234
+ end
235
+ levels = rule.question["criteria"].each_with_index.map do |c, i|
236
+ " #{i}#{i == rule.flag[:level] ? " (flags)" : ""}: #{c["what"]}"
237
+ end
238
+ out.puts(<<~TXT)
239
+ #{rule.id} (#{rule.category}, #{rule.severity}, #{rule.confidence} confidence, per #{rule.unit})
240
+
241
+ #{rule.message}
242
+
243
+ Asks: #{format(rule.question["instructions"], register: Engine::DEFAULT_REGISTER)}
244
+ #{levels.join("\n")}
245
+
246
+ Why: #{rule.rationale}
247
+ Fix: #{rule.suggestion}
248
+
249
+ Flags: #{Sloplint::CLI.fixture_list(rule.examples_bad)}
250
+ Does not: #{Sloplint::CLI.fixture_list(rule.examples_ok)}
251
+ TXT
252
+ 0
253
+ end
254
+
255
+ def global_parser(opts, out:)
256
+ OptionParser.new do |o|
257
+ o.banner = <<~BANNER
258
+ sloplint-judge — rules that ask a System One model what a regex cannot.
259
+
260
+ # Recommended for agents (both linters, one document). The judge sends the text to
261
+ # api.typesafe.ai, so ask the person before running it. Check first, ask, then run:
262
+ sloplint-judge status # exit 0 = a key is set up (reads no key), 2 = not
263
+ sloplint check --judge --markdown -o json FILE
264
+ # exit 0 = clean, 1 = notes found, 2 = usage error or not configured, 3 = the model could not be reached (nothing checked)
265
+ # output: {"notes": [...], "judge": {"backend","requests","input_tokens","output_tokens","cost_usd"}}
266
+
267
+ usage: sloplint-judge [-o full|json] [--register TEXT] [--backend NAME] [command] [args]
268
+
269
+ commands:
270
+ check scan paths (or stdin) with the judge's rules only [default]
271
+ compare A B which of two passages a plain-prose editor keeps (--drift for rewrites)
272
+ rules list the judge's rule catalog (add --json)
273
+ explain ID print one rule's question, levels, rationale and fixtures
274
+ status say whether a run could happen here, and where the key is, without reading it
275
+ key set store the backend's key in the OS keychain (the keychain tool prompts for it)
276
+ key unset remove it from the OS keychain
277
+ version print the sloplint-judge version
278
+
279
+ global options:
280
+ BANNER
281
+ o.on("-o", "--output-format FORMAT", %w[full json]) { |v| opts[:format] = v }
282
+ o.on("--register TEXT", "Who the reader is (default: #{Engine::DEFAULT_REGISTER}).") { |v| opts[:register] = v }
283
+ o.on("--backend NAME", "Which adapter to use (default: $SLOPLINT_JUDGE_BACKEND or jev).") { |v| opts[:backend] = v }
284
+ o.on("-h", "--help", "Show this help, including the agent recipe above.") { out.puts(o.help); opts[:help_shown] = true }
285
+ o.on("-v", "--version", "Print the sloplint-judge version.") { out.puts(VERSION); opts[:version_shown] = true }
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "backend"
4
+
5
+ module Sloplint
6
+ module Judge
7
+ # The pairwise judge: which of two passages a plain-prose editor keeps,
8
+ # and optionally whether B changed what A says. See docs/JUDGE.md "compare".
9
+ module Compare
10
+ KEEP = "A careful editor who wants prose that is plain, specific and economical, and who distrusts polish " \
11
+ "for its own sake, must run exactly one of A and B %{place}. Which does the editor run?"
12
+ DRIFT = "Does B state any fact, claim or qualification that A does not, or drop any that A states? " \
13
+ "Answer yes if the meaning differs in any way a careful reader would notice, no if only the wording differs."
14
+
15
+ # The acceptance rule for a rewrite (keep B, drift at or under 0.5, the
16
+ # lower confidence at or above 0.7) belongs to the phase-two loop; this
17
+ # reports the inputs and applies nothing. See docs/JUDGE.md "compare".
18
+ Verdict = Data.define(:keep, :p_keep_b, :keep_confidence, :drift, :drift_confidence, :usage)
19
+
20
+ module_function
21
+
22
+ # a, b: the passages. place: the register as a venue. drift: also ask
23
+ # whether B changes what A says. The order sent is randomised because
24
+ # the judge has a position bias; the answer is mapped back.
25
+ def run(a, b, place:, backend: Backend.load, drift: false, swap: rand < 0.5)
26
+ first, second = swap ? [b, a] : [a, b]
27
+ questions = {
28
+ "keep" => { "type" => "choice", "instructions" => format(KEEP, place: place),
29
+ "criteria" => { "A" => "The editor runs passage A.", "B" => "The editor runs passage B." } }
30
+ }
31
+ questions["drift"] = { "type" => "noul", "instructions" => DRIFT } if drift
32
+ answers = backend.ask({ "A" => first, "B" => second }, questions)
33
+ keep = answers["keep"] or raise BackendError, "the backend answered no keep question"
34
+ p_b = probability(keep, swap ? "A" : "B")
35
+ d = answers["drift"]
36
+ Verdict.new(keep: p_b >= 0.5 ? "B" : "A", p_keep_b: p_b, keep_confidence: keep.confidence,
37
+ drift: d&.probabilities, drift_confidence: d&.confidence, usage: keep.usage)
38
+ end
39
+
40
+ # The keep answer is a choice between the options named A and B. An
41
+ # answer keyed any other way is a malformed body, which ends `compare`
42
+ # with exit 3 like any other backend failure, not with a backtrace.
43
+ def probability(keep, option)
44
+ probs = keep.probabilities
45
+ raise BackendError, "the keep answer is #{probs.class}, not a probability for A and one for B" unless probs.is_a?(Hash)
46
+
47
+ probs.fetch(option) { raise BackendError, "the keep answer has no probability for #{option}" }
48
+ end
49
+ end
50
+ end
51
+ end