iriq 0.30.2 → 0.35.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.
data/completions/_iriq CHANGED
@@ -27,6 +27,8 @@ _iriq() {
27
27
  '--no-scheme-less[skip schemeless URL extraction]' \
28
28
  '--scheme-less[enable schemeless URL extraction]' \
29
29
  '--corpus[load/create a JSON or SQLite corpus]:corpus path:_files -g "*.(json|db|sqlite|sqlite3)"' \
30
+ '(-C --no-corpus)'{-C,--no-corpus}'[disable corpus persistence for this invocation]' \
31
+ '--reset[delete the corpus database and exit]' \
30
32
  '--host[host-keying strategy for clustering]:strategy:(full registrable reg none)' \
31
33
  '--stats[print rolling aggregates]' \
32
34
  '--reinfer[replay the source-IRI log]' \
@@ -44,7 +44,7 @@ _iriq() {
44
44
  if [[ "$cur" == -* ]]; then
45
45
  local flags="-h --help -V --version -p --parse -n --normalize -c --canonical -e --explain
46
46
  -j --json -J --ndjson -N --no-hints --hints --no-scheme-less
47
- --scheme-less --corpus --host --stats --reinfer
47
+ --scheme-less --corpus -C --no-corpus --reset --host --stats --reinfer
48
48
  --propose-recognizers --activate-above --cross-host-shapes
49
49
  --min-observations --min-coverage --min-hosts"
50
50
  COMPREPLY=( $(compgen -W "$flags" -- "$cur") )
data/iriq.gemspec CHANGED
@@ -5,7 +5,7 @@ Gem::Specification.new do |s|
5
5
  s.version = Iriq::VERSION
6
6
  s.authors = ["Daniel Pepper"]
7
7
  s.description = "IRI extraction, normalization, and clustering."
8
- s.files = `git ls-files * ':!:spec' ':!:script' ':!:bin' ':!:rust' ':!:go'`.split("\n")
8
+ s.files = `git ls-files lib exe completions README.md LICENSE.txt CHANGELOG.md iriq.gemspec`.split("\n")
9
9
  s.bindir = "exe"
10
10
  s.executables = ["iriq"]
11
11
  s.homepage = "https://github.com/dpep/iriq"
data/lib/iriq/cli.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require "fileutils"
1
2
  require "json"
2
3
  require "optparse"
3
4
  require "stringio"
@@ -24,8 +25,8 @@ module Iriq
24
25
  iriq [options] < text
25
26
  iriq cluster [options] [file]
26
27
 
27
- <input> may be an IRI, a file path (extracted automatically), or piped
28
- text via stdin.
28
+ <input> may be an IRI, a file to extract IRIs from (an existing file wins
29
+ unless the argument contains ://), or piped text via stdin.
29
30
 
30
31
  Sections (combine freely):
31
32
  -n, --normalize Shape — variable parts become placeholders
@@ -33,32 +34,43 @@ module Iriq
33
34
  -p, --parse Parsed fields
34
35
  -e, --explain Annotated trace — per-segment notes about why
35
36
  each placeholder / canonical value was chosen
37
+ (mechanical rules only, even with a corpus)
36
38
 
37
39
  Corpus + stats:
38
- --corpus PATH Load/create a JSON corpus; observe and save atomically.
39
- -n becomes corpus-informed once it has data.
40
+ --corpus PATH Use a specific corpus file (overrides the default).
41
+ Extension picks the backend: .db/.sqlite/.sqlite3
42
+ are SQLite; anything else is JSON.
43
+ -C, --no-corpus Disable corpus persistence for this invocation.
44
+ Same as IRIQ_NO_CORPUS=1 in the environment.
45
+ --reset Delete the corpus (default path or the one
46
+ resolved via --corpus / IRIQ_CORPUS), its SQLite
47
+ sidecars and JSON temp files, and exit.
40
48
  --host MODE Host-keying strategy for clustering:
41
49
  full (default), registrable (or reg) strips
42
- subdomains, none ignores host entirely.
50
+ subdomains, none ignores host entirely. Keys
51
+ IRIs as they're observed (-C included) and on
52
+ --reinfer; existing clusters keep their keys.
43
53
  --stats Print rolling aggregates
44
54
  --reinfer Replay the source-IRI log through the current
45
55
  classifier + reducers; rebuilds materialized
46
- views from scratch. Requires --corpus.
56
+ views from scratch.
47
57
  --propose-recognizers
48
58
  Scan observed values for shape patterns that
49
59
  recur enough to suggest a new Recognizer.
50
60
  Combine with --json for structured output.
51
- Requires --corpus.
52
61
  --cross-host-shapes
53
62
  List route shapes that recur across
54
63
  multiple hosts. Combine with --min-hosts.
55
- Requires --corpus.
56
64
  --activate-above F With --propose-recognizers, promote every
57
65
  proposal at or above CONFIDENCE F into a
58
66
  live Recognizer on the corpus, then
59
67
  reinfer. Confidence integrates coverage
60
68
  and cross-host corroboration.
61
69
 
70
+ Environment:
71
+ IRIQ_CORPUS=PATH Set the corpus path (overrides the default).
72
+ IRIQ_NO_CORPUS=1 Disable the default corpus (equivalent to -C).
73
+
62
74
  Thresholds (apply to --propose-recognizers / --cross-host-shapes):
63
75
  --min-observations N proposal noise floor (default 20)
64
76
  --min-coverage F proposal coverage floor (default 0.7)
@@ -70,18 +82,23 @@ module Iriq
70
82
  -h, --help Show this message
71
83
  -j, --json Emit JSON instead of human-readable output
72
84
  -J, --ndjson Newline-delimited JSON (one object per line). Implies --json.
85
+ Streams per IRI only with -n/-p/-c/-e; alone, it
86
+ prints the URL list or clusters at end of input.
73
87
  -N, --no-hints Use {integer} placeholders instead of {user_id}
74
88
  --no-scheme-less Skip foo.com/path extraction (explicit-scheme only)
75
89
  -V, --version Print version
76
90
 
77
91
  Subcommands:
78
- cluster [file] Force cluster view (default for ≥10 IRIs anyway)
92
+ cluster [file] Observe file (or stdin), then show every cluster
93
+ in the corpus (default view for ≥10 IRIs anyway)
94
+ completion <shell> Print shell completion script (bash | zsh)
79
95
 
80
96
  Examples:
81
97
  iriq foo.com/users/456
82
98
  iriq -n https://foo.com/users/123
83
- iriq ./access.log # auto-detect file extract URLs
99
+ iriq access.log # extract URLs (request paths have no host)
84
100
  cat README.md | iriq -n # one normalized URL per line
101
+ tail -f app.log | iriq -nJ # live stream → NDJSON per IRI
85
102
  cat README.md | iriq --corpus c.json
86
103
  TXT
87
104
 
@@ -114,18 +131,30 @@ module Iriq
114
131
  explicit_cluster = (args.first == "cluster")
115
132
  args.shift if explicit_cluster
116
133
 
117
- # Auto-detect: a positional argument that isn't parseable as an IRI
118
- # but IS an existing file gets treated as a file to extract from. This
119
- # is what makes `iriq ./access.log` and `iriq /var/log/foo.log` Just
120
- # Work without a separate --extract flag.
121
- positional_is_file = args.first && File.file?(args.first) && !parseable_iri?(args.first)
134
+ # A positional that names an existing file is read as a file, so
135
+ # `iriq access.log` works without ./ (it also parses as a host). An
136
+ # argument containing "://" is always an IRI.
137
+ positional_is_file = args.first && !args.first.include?("://") && File.file?(args.first)
122
138
 
123
139
  batch_mode = explicit_cluster || positional_is_file ||
124
140
  (args.empty? && piped_stdin?)
125
141
 
142
+ # --reset short-circuits: delete the resolved corpus file (+ sidecars)
143
+ # and exit. Resolves through the same precedence chain as the normal
144
+ # path so `--reset --corpus other.db` and `IRIQ_CORPUS=… --reset` Just Work.
145
+ if opts[:reset]
146
+ return cmd_reset(opts)
147
+ end
148
+
149
+ # Before any corpus is opened, so a typo doesn't create one.
150
+ if (missing = missing_input_file(args.first, explicit_cluster))
151
+ raise InputError.new("file_not_found", "no such file: #{missing}")
152
+ end
153
+
126
154
  return print_usage(stdout, 0) if args.empty? && !batch_mode && !opts[:reinfer] && !opts[:propose] && !opts[:cross_host_shapes]
127
155
 
128
- corpus = opts[:corpus] ? load_corpus(opts[:corpus], host_strategy: opts[:host_strategy]) : nil
156
+ corpus_path = resolve_corpus_path(opts)
157
+ corpus = corpus_path ? load_corpus(corpus_path, host_strategy: opts[:host_strategy], announce_create: true) : nil
129
158
 
130
159
  code = if opts[:reinfer]
131
160
  cmd_reinfer(corpus, opts)
@@ -141,12 +170,26 @@ module Iriq
141
170
  cmd_summary(args, opts, corpus)
142
171
  end
143
172
 
144
- corpus.save(opts[:corpus]) if corpus && opts[:corpus]
173
+ corpus.save(corpus_path) if corpus && corpus_path
145
174
  code
146
175
  rescue Iriq::ParseError => e
147
176
  emit_error("parse_error", e.message, 2, human: "iriq: parse error: #{e.message}")
148
177
  rescue OptionParser::ParseError => e
149
178
  emit_error("option_error", e.message, 1)
179
+ rescue InputError => e
180
+ emit_error(e.code, e.message, 1)
181
+ rescue Iriq::CorpusError => e
182
+ emit_error("corpus_error", e.message, 1)
183
+ end
184
+
185
+ # Input iriq can't read. `code` is the JSON error envelope's code.
186
+ class InputError < Iriq::Error
187
+ attr_reader :code
188
+
189
+ def initialize(code, message)
190
+ super(message)
191
+ @code = code
192
+ end
150
193
  end
151
194
 
152
195
  def parseable_iri?(input)
@@ -158,6 +201,15 @@ module Iriq
158
201
 
159
202
  private
160
203
 
204
+ # The argument iriq would have read as a file but can't find: anything
205
+ # after `cluster`, or a /, ./, ../ path that isn't an IRI.
206
+ def missing_input_file(arg, explicit_cluster)
207
+ return nil if arg.nil? || arg == "-" || File.file?(arg)
208
+ return arg if explicit_cluster
209
+
210
+ arg if arg.start_with?("/", "./", "../") && !parseable_iri?(arg)
211
+ end
212
+
161
213
  def parse_options(argv)
162
214
  opts = {
163
215
  json: false,
@@ -167,6 +219,8 @@ module Iriq
167
219
  hints: true,
168
220
  sections: [],
169
221
  corpus: nil,
222
+ no_corpus: false,
223
+ reset: false,
170
224
  stats: false,
171
225
  reinfer: false,
172
226
  propose: false,
@@ -191,6 +245,8 @@ module Iriq
191
245
  o.on("--[no-]hints") { |v| opts[:hints] = v }
192
246
  o.on("-N") { opts[:hints] = false }
193
247
  o.on("--corpus PATH") { |v| opts[:corpus] = v }
248
+ o.on("-C", "--no-corpus") { opts[:no_corpus] = true }
249
+ o.on("--reset") { opts[:reset] = true }
194
250
  o.on("--host MODE") { |v| opts[:host_strategy] = host_strategy_arg(v) }
195
251
  o.on("--stats") { opts[:stats] = true }
196
252
  o.on("--reinfer") { opts[:reinfer] = true }
@@ -220,8 +276,95 @@ module Iriq
220
276
  end
221
277
  end
222
278
 
223
- def load_corpus(path, host_strategy: :full)
224
- Corpus.open(path, host_strategy: host_strategy)
279
+ def load_corpus(path, host_strategy: :full, announce_create: false)
280
+ creating = announce_create && !File.exist?(path)
281
+ if creating
282
+ begin
283
+ FileUtils.mkdir_p(File.dirname(path))
284
+ rescue SystemCallError => e
285
+ raise CorpusError, "corpus #{path}: #{Iriq.os_error_message(e)}"
286
+ end
287
+ end
288
+ corpus = Corpus.open(path, host_strategy: host_strategy)
289
+ # Only announce once the corpus actually exists — mkdir_p can succeed
290
+ # (e.g. the dir is there but read-only) while the open still fails.
291
+ stderr.puts "iriq: created corpus at #{path} (disable with --no-corpus or IRIQ_NO_CORPUS=1)" if creating
292
+ corpus
293
+ end
294
+
295
+ # Resolve the corpus file the CLI should use. Precedence:
296
+ # 1. --corpus PATH — explicit always wins (you asked for it)
297
+ # 2. --no-corpus / IRIQ_NO_CORPUS=1 — opt out of the default
298
+ # 3. IRIQ_CORPUS=PATH — env override of the default location
299
+ # 4. default_corpus_path — platform-aware location
300
+ def resolve_corpus_path(opts)
301
+ return opts[:corpus] if opts[:corpus]
302
+ return nil if opts[:no_corpus] || env_corpus_disabled?
303
+
304
+ env_path = ENV["IRIQ_CORPUS"].to_s
305
+ return env_path unless env_path.empty?
306
+
307
+ default_corpus_path
308
+ end
309
+
310
+ # Platform-aware default. XDG-honoring on Linux + BSD, Apple-style on
311
+ # macOS, %LOCALAPPDATA% on Windows. Same logic in Rust so both
312
+ # runtimes share the same default.db.
313
+ def default_corpus_path
314
+ base = if (xdg = ENV["XDG_DATA_HOME"].to_s) && !xdg.empty?
315
+ File.join(xdg, "iriq")
316
+ elsif RUBY_PLATFORM =~ /darwin/
317
+ File.expand_path("~/Library/Application Support/iriq")
318
+ elsif RUBY_PLATFORM =~ /mingw|mswin|cygwin/
319
+ File.join(ENV["LOCALAPPDATA"].to_s.empty? ? File.expand_path("~/AppData/Local") : ENV["LOCALAPPDATA"], "iriq")
320
+ else
321
+ File.expand_path("~/.local/share/iriq")
322
+ end
323
+ File.join(base, "default.db")
324
+ end
325
+
326
+ def env_corpus_disabled?
327
+ v = ENV["IRIQ_NO_CORPUS"].to_s.downcase
328
+ !v.empty? && v != "0" && v != "false" && v != "no"
329
+ end
330
+
331
+ def cmd_reset(opts)
332
+ path = resolve_reset_path(opts)
333
+ unless path
334
+ return emit_error("missing_argument", "no corpus path to reset (use --corpus PATH or unset --no-corpus)", 1)
335
+ end
336
+ removed = []
337
+ [path, "#{path}-wal", "#{path}-shm", "#{path}.tmp", *json_temp_files(path)].each do |p|
338
+ if File.exist?(p)
339
+ File.delete(p)
340
+ removed << p
341
+ end
342
+ end
343
+ if removed.empty?
344
+ stderr.puts "iriq: no corpus to reset at #{path}"
345
+ else
346
+ stderr.puts "iriq: reset corpus at #{path}"
347
+ end
348
+ 0
349
+ end
350
+
351
+ # The JSON writer's per-save temp files, PATH.<pid>.<n>.tmp
352
+ # (Storage.write_atomically); a save killed before its rename leaves one.
353
+ def json_temp_files(path)
354
+ dir = File.dirname(path)
355
+ return [] unless Dir.exist?(dir)
356
+
357
+ pattern = /\A#{Regexp.escape(File.basename(path))}\.\d+\.\d+\.tmp\z/
358
+ Dir.children(dir).grep(pattern).map { |name| File.join(dir, name) }
359
+ end
360
+
361
+ # --reset honors --corpus / IRIQ_CORPUS even when --no-corpus is set —
362
+ # the user is explicitly addressing a stored file, not the runtime state.
363
+ def resolve_reset_path(opts)
364
+ return opts[:corpus] if opts[:corpus]
365
+ env_path = ENV["IRIQ_CORPUS"].to_s
366
+ return env_path unless env_path.empty?
367
+ default_corpus_path
225
368
  end
226
369
 
227
370
  # Accept `--host=reg` as a short alias for the `registrable` mode.
@@ -231,7 +374,9 @@ module Iriq
231
374
 
232
375
  def host_strategy_arg(value)
233
376
  mode = HOST_STRATEGY_ALIASES[value.to_s.downcase]
234
- raise OptionParser::InvalidArgument, "--host: expected full|registrable|reg|none, got #{value.inspect}" unless mode
377
+ # OptionParser prefixes the switch itself ("--host bogus" / "--host=bogus"),
378
+ # so the message carries only the accepted modes.
379
+ raise OptionParser::InvalidArgument.new(value, "(expected full|registrable|reg|none)") unless mode
235
380
 
236
381
  mode
237
382
  end
@@ -255,9 +400,7 @@ module Iriq
255
400
  data = {}
256
401
  data[:parse] = identifier_hash(iri) if sections.include?(:parse)
257
402
  data[:canonical] = iri.canonical if sections.include?(:canonical)
258
- if sections.include?(:normalize)
259
- data[:normalize] = corpus ? corpus.normalize(iri) : Normalizer.normalize_identifier(iri, hints: opts[:hints])
260
- end
403
+ data[:normalize] = normalize_section(iri, opts, corpus) if sections.include?(:normalize)
261
404
  if sections.include?(:explain)
262
405
  data[:explain] = Trace.for(iri, hints: opts[:hints])
263
406
  end
@@ -276,20 +419,20 @@ module Iriq
276
419
  # of URLs (one per line) and a file of prose with URLs both work. The
277
420
  # corpus is ephemeral unless --corpus was given.
278
421
  def cmd_batch(args, opts, corpus, explicit_cluster: false)
279
- corpus ||= Corpus.new
280
-
281
- # Per-IRI sections (-n/-p/-c/-e) are independent line to line, so we
282
- # stream: read input lazily, extract per line, and emit each IRI as it
283
- # arrives (flushed for live `tail -f | iriq -n` pipelines). The aggregate
284
- # views below — stats, clusters, the deduped URL list — need the whole
285
- # input, so they slurp.
422
+ # Per-IRI sections (-n/-p/-c/-e) stream: read input lazily, extract per
423
+ # line, and emit each IRI as it arrives (flushed for live
424
+ # `tail -f | iriq -n` pipelines). Each IRI renders as single-input would,
425
+ # so with -C there is no corpus, not a throwaway one. The aggregate views
426
+ # below stats, clusters, the deduped URL list need the whole input,
427
+ # so they slurp.
286
428
  if opts[:sections].any?
287
429
  emit_per_iri_sections(lazy_iris(args.first, opts), opts, corpus)
288
430
  return 0
289
431
  end
290
432
 
291
- iris = extract_text(read_text(args.first), opts)
292
- corpus.batch { iris.each { |iri| corpus.observe(iri) } }
433
+ corpus ||= Corpus.new(host_strategy: opts[:host_strategy])
434
+ iris = extract_text(utf8!(read_text(args.first)), opts)
435
+ corpus.observe_all(iris)
293
436
 
294
437
  if opts[:stats]
295
438
  emit_stats(corpus, opts)
@@ -309,19 +452,42 @@ module Iriq
309
452
  # (URL_CHAR_CLASS excludes whitespace) and `extract` does not dedup.
310
453
  def lazy_iris(path, opts)
311
454
  extractor = Extractor.new(scheme_less: opts[:scheme_less])
312
- input_lines(path).lazy.flat_map { |line| extractor.extract(line) }
455
+ input_lines(path).lazy.flat_map { |line| extractor.extract(utf8!(line)) }
313
456
  end
314
457
 
458
+ # Yields input lines as they arrive. Only the reads are guarded, so a
459
+ # failure in the caller's block isn't mistaken for a read error.
315
460
  def input_lines(path)
316
- if path.nil? || path == "-"
317
- stdin.each_line
318
- else
319
- File.foreach(path)
461
+ return enum_for(:input_lines, path) unless block_given?
462
+
463
+ io = path.nil? || path == "-" ? stdin : read_guard { File.open(path) }
464
+ while (line = read_guard { io.gets })
465
+ yield line
320
466
  end
467
+ ensure
468
+ io.close if io && !io.equal?(stdin)
321
469
  end
322
470
 
323
- # Emit the requested sections (parse/normalize/explain) for each extracted
324
- # IRI, observing each into `corpus` as it passes. `iris` may be a lazy
471
+ # Input iriq can't read is the OS error in the Rust CLI's words,
472
+ # `iriq: Permission denied (os error 13)`, code read_error.
473
+ def read_guard
474
+ yield
475
+ rescue SystemCallError => e
476
+ raise InputError.new("read_error", Iriq.os_error_message(e))
477
+ end
478
+
479
+ # Input is UTF-8 regardless of locale; anything else is an error, not a
480
+ # backtrace. Message matches the Rust CLI's io::Error text.
481
+ def utf8!(text)
482
+ text = text.dup.force_encoding(Encoding::UTF_8)
483
+ raise InputError.new("invalid_utf8", "stream did not contain valid UTF-8") unless text.valid_encoding?
484
+
485
+ text
486
+ end
487
+
488
+ # Emit the requested sections (parse/canonical/normalize/explain) for each
489
+ # extracted IRI, observing each into `corpus` (when there is one) and then
490
+ # rendering it from the corpus as it now stands. `iris` may be a lazy
325
491
  # enumerator; human and NDJSON output stream (flushed per IRI) while a single
326
492
  # JSON array must be materialized. -n alone is the cleanest case: one line
327
493
  # per URL.
@@ -331,14 +497,14 @@ module Iriq
331
497
  # A wrapping JSON array can't be emitted incrementally — collect it
332
498
  # (force the lazy enumerator to a real Array so emit_json sees an array).
333
499
  if opts[:json] && !opts[:ndjson]
334
- payloads = iris.map { |iri| corpus.observe(iri); section_payload(iri, sections, opts) }.to_a
500
+ payloads = iris.map { |iri| corpus&.observe(iri); section_payload(iri, sections, opts, corpus) }.to_a
335
501
  out = sections.size == 1 ? payloads.map(&:values).flatten(1) : payloads
336
502
  return emit_json(out, opts)
337
503
  end
338
504
 
339
505
  iris.each_with_index do |iri, i|
340
- corpus.observe(iri)
341
- p = section_payload(iri, sections, opts)
506
+ corpus&.observe(iri)
507
+ p = section_payload(iri, sections, opts, corpus)
342
508
  if opts[:ndjson]
343
509
  items = sections.size == 1 ? p.values : [p]
344
510
  items.each { |item| stdout.puts JSON.generate(item) }
@@ -354,6 +520,7 @@ module Iriq
354
520
  when :parse then emit_parse_human(p[:parse])
355
521
  when :canonical then stdout.puts p[:canonical]
356
522
  when :normalize then stdout.puts p[:normalize]
523
+ when :explain then emit_explain_human(p[:explain])
357
524
  end
358
525
  end
359
526
  end
@@ -361,14 +528,21 @@ module Iriq
361
528
  end
362
529
  end
363
530
 
364
- def section_payload(iri, sections, opts)
531
+ # Key order is fixed (parse, canonical, normalize, explain) whatever the
532
+ # flag order, matching cmd_summary's multi-section JSON.
533
+ def section_payload(iri, sections, opts, corpus)
365
534
  data = {}
366
- data[:parse] = identifier_hash(iri) if sections.include?(:parse)
367
- data[:canonical] = iri.canonical if sections.include?(:canonical)
368
- data[:normalize] = Normalizer.normalize_identifier(iri, hints: opts[:hints]) if sections.include?(:normalize)
535
+ data[:parse] = identifier_hash(iri) if sections.include?(:parse)
536
+ data[:canonical] = iri.canonical if sections.include?(:canonical)
537
+ data[:normalize] = normalize_section(iri, opts, corpus) if sections.include?(:normalize)
538
+ data[:explain] = Trace.for(iri, hints: opts[:hints]) if sections.include?(:explain)
369
539
  data
370
540
  end
371
541
 
542
+ def normalize_section(iri, opts, corpus)
543
+ corpus ? corpus.normalize(iri, hints: opts[:hints]) : Normalizer.normalize_identifier(iri, hints: opts[:hints])
544
+ end
545
+
372
546
  def extract_text(text, opts)
373
547
  Extractor.new(scheme_less: opts[:scheme_less]).extract(text)
374
548
  end
@@ -406,7 +580,7 @@ module Iriq
406
580
  # --propose-recognizers: scan observed values for prefix patterns
407
581
  # that recur enough to suggest a new Recognizer. Prints one block
408
582
  # per proposal in human mode, or a JSON array under --json. With
409
- # --activate-above F, every proposal at or above coverage F is
583
+ # --activate-above F, every proposal at or above confidence F is
410
584
  # promoted to a live Recognizer on the corpus's classifier and the
411
585
  # corpus reinfers to apply the new classifier to existing
412
586
  # observations.
@@ -421,7 +595,7 @@ module Iriq
421
595
  if opts[:activate_above]
422
596
  activated = corpus.activate_proposals_above(opts[:activate_above], **kwargs)
423
597
  if activated.empty?
424
- stdout.puts "no proposals at or above coverage #{opts[:activate_above]}"
598
+ stdout.puts "no proposals at or above confidence #{opts[:activate_above]}"
425
599
  else
426
600
  activated.each do |r|
427
601
  stdout.puts "activated: #{r.type} (#{r.prefix})"
@@ -473,9 +647,10 @@ module Iriq
473
647
  end
474
648
 
475
649
  # `completion <shell>` — emit the bundled shell-completion script.
476
- # Scripts live in completions/{iriq.bash,_iriq} alongside the gem;
477
- # Homebrew installs them automatically, but the user can also do
478
- # `source <(iriq completion bash)` in their shell rc.
650
+ # Scripts live in completions/{iriq.bash,_iriq} alongside the
651
+ # gem; Homebrew installs them automatically, but the user can also do
652
+ # `source <(iriq completion bash)` in their shell rc. The Rust CLI
653
+ # inlines the same scripts — keep them byte-identical (parity-tested).
479
654
  COMPLETIONS_DIR = File.expand_path("../../completions", __dir__).freeze
480
655
  COMPLETION_FILES = {
481
656
  "bash" => File.join(COMPLETIONS_DIR, "iriq.bash"),
@@ -551,20 +726,8 @@ module Iriq
551
726
  exit_code
552
727
  end
553
728
 
554
- def read_input(path)
555
- if path.nil? || path == "-"
556
- stdin.read.lines
557
- else
558
- File.readlines(path)
559
- end
560
- end
561
-
562
729
  def read_text(path)
563
- if path.nil? || path == "-"
564
- stdin.read
565
- else
566
- File.read(path)
567
- end
730
+ read_guard { path.nil? || path == "-" ? stdin.read : File.read(path) }
568
731
  end
569
732
 
570
733
  # Compact identifier hash for parse output (both JSON and human). Drops
@@ -653,7 +816,10 @@ module Iriq
653
816
  end
654
817
 
655
818
  def emit_clusters(clusters, opts)
656
- sorted = clusters.sort_by { |c| -c.count }
819
+ # Stable sort: equal-count clusters keep first-seen order. Ruby's
820
+ # sort_by is unstable, so ties need the explicit index tie-break to
821
+ # match the Rust CLI's (stable) sort.
822
+ sorted = clusters.sort_by.with_index { |c, i| [-c.count, i] }
657
823
 
658
824
  if opts[:json]
659
825
  emit_json(sorted.map(&:to_h), opts)
@@ -672,8 +838,8 @@ module Iriq
672
838
  end
673
839
  end
674
840
 
675
- # One line per param: type, range (numeric), cardinality, presence.
676
- # `page integer 1..100 avg 50.5 (10 distinct, 100%)`
841
+ # One line per param: type, confidence, range (numeric), cardinality,
842
+ # presence. `page integer conf 0.87 1..100 avg 50.5 (10 distinct, 100%)`
677
843
  def emit_param_summary(cluster)
678
844
  rows = cluster.param_summary
679
845
  return if rows.empty?
@@ -681,6 +847,7 @@ module Iriq
681
847
  width = rows.map { |r| r[:name].length }.max
682
848
  rows.each do |r|
683
849
  bits = ["#{r[:type]}"]
850
+ bits << "conf #{format_conf(r[:confidence])}" if r[:confidence]
684
851
  if r[:min] && r[:max]
685
852
  bits << format_range(r[:min], r[:max])
686
853
  bits << "avg #{format_num(r[:avg])}" if r[:avg]
@@ -690,6 +857,11 @@ module Iriq
690
857
  end
691
858
  end
692
859
 
860
+ # Two-decimal confidence, e.g. 0.87. Matches the Rust CLI formatting.
861
+ def format_conf(conf)
862
+ format("%.2f", conf)
863
+ end
864
+
693
865
  def format_range(lo, hi)
694
866
  "#{format_num(lo)}..#{format_num(hi)}"
695
867
  end
@@ -737,9 +909,8 @@ module Iriq
737
909
  end
738
910
 
739
911
  def top(hash)
740
- # Lex tie-break on equal counts — Ruby Hash insertion order would
741
- # otherwise diverge from Go's map iteration (which has no insertion
742
- # order). Keeps Ruby ↔ Go --stats parity stable.
912
+ # Lex tie-break on equal counts — keeps --stats output deterministic
913
+ # and Ruby Rust parity stable regardless of insertion order.
743
914
  hash.sort_by { |k, n| [-n, k] }.first(TOP_N_STATS).to_h
744
915
  end
745
916
  end