iriq 0.33.0 → 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/lib/iriq/cli.rb CHANGED
@@ -25,8 +25,8 @@ module Iriq
25
25
  iriq [options] < text
26
26
  iriq cluster [options] [file]
27
27
 
28
- <input> may be an IRI, a file path (extracted automatically), or piped
29
- 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.
30
30
 
31
31
  Sections (combine freely):
32
32
  -n, --normalize Shape — variable parts become placeholders
@@ -34,6 +34,7 @@ module Iriq
34
34
  -p, --parse Parsed fields
35
35
  -e, --explain Annotated trace — per-segment notes about why
36
36
  each placeholder / canonical value was chosen
37
+ (mechanical rules only, even with a corpus)
37
38
 
38
39
  Corpus + stats:
39
40
  --corpus PATH Use a specific corpus file (overrides the default).
@@ -41,11 +42,14 @@ module Iriq
41
42
  are SQLite; anything else is JSON.
42
43
  -C, --no-corpus Disable corpus persistence for this invocation.
43
44
  Same as IRIQ_NO_CORPUS=1 in the environment.
44
- --reset Delete the corpus database (default path or the
45
- one resolved via --corpus / IRIQ_CORPUS) and exit.
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.
46
48
  --host MODE Host-keying strategy for clustering:
47
49
  full (default), registrable (or reg) strips
48
- 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.
49
53
  --stats Print rolling aggregates
50
54
  --reinfer Replay the source-IRI log through the current
51
55
  classifier + reducers; rebuilds materialized
@@ -78,20 +82,23 @@ module Iriq
78
82
  -h, --help Show this message
79
83
  -j, --json Emit JSON instead of human-readable output
80
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.
81
87
  -N, --no-hints Use {integer} placeholders instead of {user_id}
82
88
  --no-scheme-less Skip foo.com/path extraction (explicit-scheme only)
83
89
  -V, --version Print version
84
90
 
85
91
  Subcommands:
86
- 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)
87
94
  completion <shell> Print shell completion script (bash | zsh)
88
95
 
89
96
  Examples:
90
97
  iriq foo.com/users/456
91
98
  iriq -n https://foo.com/users/123
92
- iriq ./access.log # auto-detect file → extract URLs
99
+ iriq access.log # extract URLs (request paths have no host)
93
100
  cat README.md | iriq -n # one normalized URL per line
94
- tail -f access.log | iriq -J # live stream → NDJSON per IRI
101
+ tail -f app.log | iriq -nJ # live stream → NDJSON per IRI
95
102
  cat README.md | iriq --corpus c.json
96
103
  TXT
97
104
 
@@ -124,11 +131,10 @@ module Iriq
124
131
  explicit_cluster = (args.first == "cluster")
125
132
  args.shift if explicit_cluster
126
133
 
127
- # Auto-detect: a positional argument that isn't parseable as an IRI
128
- # but IS an existing file gets treated as a file to extract from. This
129
- # is what makes `iriq ./access.log` and `iriq /var/log/foo.log` Just
130
- # Work without a separate --extract flag.
131
- 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)
132
138
 
133
139
  batch_mode = explicit_cluster || positional_is_file ||
134
140
  (args.empty? && piped_stdin?)
@@ -140,6 +146,11 @@ module Iriq
140
146
  return cmd_reset(opts)
141
147
  end
142
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
+
143
154
  return print_usage(stdout, 0) if args.empty? && !batch_mode && !opts[:reinfer] && !opts[:propose] && !opts[:cross_host_shapes]
144
155
 
145
156
  corpus_path = resolve_corpus_path(opts)
@@ -165,6 +176,20 @@ module Iriq
165
176
  emit_error("parse_error", e.message, 2, human: "iriq: parse error: #{e.message}")
166
177
  rescue OptionParser::ParseError => e
167
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
168
193
  end
169
194
 
170
195
  def parseable_iri?(input)
@@ -176,6 +201,15 @@ module Iriq
176
201
 
177
202
  private
178
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
+
179
213
  def parse_options(argv)
180
214
  opts = {
181
215
  json: false,
@@ -243,11 +277,19 @@ module Iriq
243
277
  end
244
278
 
245
279
  def load_corpus(path, host_strategy: :full, announce_create: false)
246
- if announce_create && !File.exist?(path)
247
- FileUtils.mkdir_p(File.dirname(path))
248
- stderr.puts "iriq: created corpus at #{path} (disable with --no-corpus or IRIQ_NO_CORPUS=1)"
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
249
287
  end
250
- Corpus.open(path, host_strategy: host_strategy)
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
251
293
  end
252
294
 
253
295
  # Resolve the corpus file the CLI should use. Precedence:
@@ -292,7 +334,7 @@ module Iriq
292
334
  return emit_error("missing_argument", "no corpus path to reset (use --corpus PATH or unset --no-corpus)", 1)
293
335
  end
294
336
  removed = []
295
- [path, "#{path}-wal", "#{path}-shm", "#{path}.tmp"].each do |p|
337
+ [path, "#{path}-wal", "#{path}-shm", "#{path}.tmp", *json_temp_files(path)].each do |p|
296
338
  if File.exist?(p)
297
339
  File.delete(p)
298
340
  removed << p
@@ -306,6 +348,16 @@ module Iriq
306
348
  0
307
349
  end
308
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
+
309
361
  # --reset honors --corpus / IRIQ_CORPUS even when --no-corpus is set —
310
362
  # the user is explicitly addressing a stored file, not the runtime state.
311
363
  def resolve_reset_path(opts)
@@ -322,7 +374,9 @@ module Iriq
322
374
 
323
375
  def host_strategy_arg(value)
324
376
  mode = HOST_STRATEGY_ALIASES[value.to_s.downcase]
325
- 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
326
380
 
327
381
  mode
328
382
  end
@@ -346,9 +400,7 @@ module Iriq
346
400
  data = {}
347
401
  data[:parse] = identifier_hash(iri) if sections.include?(:parse)
348
402
  data[:canonical] = iri.canonical if sections.include?(:canonical)
349
- if sections.include?(:normalize)
350
- data[:normalize] = corpus ? corpus.normalize(iri) : Normalizer.normalize_identifier(iri, hints: opts[:hints])
351
- end
403
+ data[:normalize] = normalize_section(iri, opts, corpus) if sections.include?(:normalize)
352
404
  if sections.include?(:explain)
353
405
  data[:explain] = Trace.for(iri, hints: opts[:hints])
354
406
  end
@@ -367,20 +419,20 @@ module Iriq
367
419
  # of URLs (one per line) and a file of prose with URLs both work. The
368
420
  # corpus is ephemeral unless --corpus was given.
369
421
  def cmd_batch(args, opts, corpus, explicit_cluster: false)
370
- corpus ||= Corpus.new
371
-
372
- # Per-IRI sections (-n/-p/-c/-e) are independent line to line, so we
373
- # stream: read input lazily, extract per line, and emit each IRI as it
374
- # arrives (flushed for live `tail -f | iriq -n` pipelines). The aggregate
375
- # views below — stats, clusters, the deduped URL list — need the whole
376
- # 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.
377
428
  if opts[:sections].any?
378
429
  emit_per_iri_sections(lazy_iris(args.first, opts), opts, corpus)
379
430
  return 0
380
431
  end
381
432
 
382
- iris = extract_text(read_text(args.first), opts)
383
- 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)
384
436
 
385
437
  if opts[:stats]
386
438
  emit_stats(corpus, opts)
@@ -400,19 +452,42 @@ module Iriq
400
452
  # (URL_CHAR_CLASS excludes whitespace) and `extract` does not dedup.
401
453
  def lazy_iris(path, opts)
402
454
  extractor = Extractor.new(scheme_less: opts[:scheme_less])
403
- input_lines(path).lazy.flat_map { |line| extractor.extract(line) }
455
+ input_lines(path).lazy.flat_map { |line| extractor.extract(utf8!(line)) }
404
456
  end
405
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.
406
460
  def input_lines(path)
407
- if path.nil? || path == "-"
408
- stdin.each_line
409
- else
410
- 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
411
466
  end
467
+ ensure
468
+ io.close if io && !io.equal?(stdin)
469
+ end
470
+
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))
412
477
  end
413
478
 
414
- # Emit the requested sections (parse/normalize/explain) for each extracted
415
- # IRI, observing each into `corpus` as it passes. `iris` may be a lazy
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
416
491
  # enumerator; human and NDJSON output stream (flushed per IRI) while a single
417
492
  # JSON array must be materialized. -n alone is the cleanest case: one line
418
493
  # per URL.
@@ -422,14 +497,14 @@ module Iriq
422
497
  # A wrapping JSON array can't be emitted incrementally — collect it
423
498
  # (force the lazy enumerator to a real Array so emit_json sees an array).
424
499
  if opts[:json] && !opts[:ndjson]
425
- 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
426
501
  out = sections.size == 1 ? payloads.map(&:values).flatten(1) : payloads
427
502
  return emit_json(out, opts)
428
503
  end
429
504
 
430
505
  iris.each_with_index do |iri, i|
431
- corpus.observe(iri)
432
- p = section_payload(iri, sections, opts)
506
+ corpus&.observe(iri)
507
+ p = section_payload(iri, sections, opts, corpus)
433
508
  if opts[:ndjson]
434
509
  items = sections.size == 1 ? p.values : [p]
435
510
  items.each { |item| stdout.puts JSON.generate(item) }
@@ -445,6 +520,7 @@ module Iriq
445
520
  when :parse then emit_parse_human(p[:parse])
446
521
  when :canonical then stdout.puts p[:canonical]
447
522
  when :normalize then stdout.puts p[:normalize]
523
+ when :explain then emit_explain_human(p[:explain])
448
524
  end
449
525
  end
450
526
  end
@@ -452,14 +528,21 @@ module Iriq
452
528
  end
453
529
  end
454
530
 
455
- 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)
456
534
  data = {}
457
- data[:parse] = identifier_hash(iri) if sections.include?(:parse)
458
- data[:canonical] = iri.canonical if sections.include?(:canonical)
459
- 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)
460
539
  data
461
540
  end
462
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
+
463
546
  def extract_text(text, opts)
464
547
  Extractor.new(scheme_less: opts[:scheme_less]).extract(text)
465
548
  end
@@ -497,7 +580,7 @@ module Iriq
497
580
  # --propose-recognizers: scan observed values for prefix patterns
498
581
  # that recur enough to suggest a new Recognizer. Prints one block
499
582
  # per proposal in human mode, or a JSON array under --json. With
500
- # --activate-above F, every proposal at or above coverage F is
583
+ # --activate-above F, every proposal at or above confidence F is
501
584
  # promoted to a live Recognizer on the corpus's classifier and the
502
585
  # corpus reinfers to apply the new classifier to existing
503
586
  # observations.
@@ -512,7 +595,7 @@ module Iriq
512
595
  if opts[:activate_above]
513
596
  activated = corpus.activate_proposals_above(opts[:activate_above], **kwargs)
514
597
  if activated.empty?
515
- stdout.puts "no proposals at or above coverage #{opts[:activate_above]}"
598
+ stdout.puts "no proposals at or above confidence #{opts[:activate_above]}"
516
599
  else
517
600
  activated.each do |r|
518
601
  stdout.puts "activated: #{r.type} (#{r.prefix})"
@@ -643,20 +726,8 @@ module Iriq
643
726
  exit_code
644
727
  end
645
728
 
646
- def read_input(path)
647
- if path.nil? || path == "-"
648
- stdin.read.lines
649
- else
650
- File.readlines(path)
651
- end
652
- end
653
-
654
729
  def read_text(path)
655
- if path.nil? || path == "-"
656
- stdin.read
657
- else
658
- File.read(path)
659
- end
730
+ read_guard { path.nil? || path == "-" ? stdin.read : File.read(path) }
660
731
  end
661
732
 
662
733
  # Compact identifier hash for parse output (both JSON and human). Drops
data/lib/iriq/cluster.rb CHANGED
@@ -98,17 +98,19 @@ module Iriq
98
98
  end
99
99
  end
100
100
 
101
- # Per-position summary:
101
+ # Per-position summary, values by descending count then value:
102
102
  # [
103
103
  # { position: 0, stable: true, values: { "users" => 3 } },
104
104
  # { position: 1, stable: false, values: { "1" => 1, "2" => 1, "3" => 1 } },
105
105
  # ]
106
+ # Not storage order: SQLite reads values back sorted, memory and JSON
107
+ # in first-seen order.
106
108
  def segment_stats
107
109
  @segment_counts.each_with_index.map do |counts, i|
108
110
  {
109
111
  position: i,
110
112
  stable: counts.size == 1,
111
- values: counts.dup,
113
+ values: counts.sort_by { |v, n| [-n, v] }.to_h,
112
114
  }
113
115
  end
114
116
  end
@@ -180,7 +182,11 @@ module Iriq
180
182
  # agree on what the corpus "thinks" about a param.
181
183
  def param_type(name)
182
184
  stats = @param_stats[name]
183
- return nil unless stats
185
+ stats && Cluster.param_type_for(name, stats)
186
+ end
187
+
188
+ # param_type for one param's stats, read without the rest of the cluster.
189
+ def self.param_type_for(name, stats)
184
190
  return nil if stats.total.zero?
185
191
 
186
192
  type = stats.dominant_type
@@ -243,7 +249,7 @@ module Iriq
243
249
  YEAR_MIN_DISTINCT = 2
244
250
  YEAR_MAX_DISTINCT = 150
245
251
 
246
- def year_position?(type, stats)
252
+ def self.year_position?(type, stats)
247
253
  return false unless type == :integer
248
254
  return false if stats.numeric_count.zero?
249
255
  return false if stats.cardinality < YEAR_MIN_DISTINCT
@@ -258,7 +264,7 @@ module Iriq
258
264
  HTTP_STATUS_MIN_DISTINCT = 2
259
265
  HTTP_STATUS_MAX_DISTINCT = 30
260
266
 
261
- def http_status_position?(type, stats)
267
+ def self.http_status_position?(type, stats)
262
268
  return false unless type == :integer
263
269
  return false if stats.numeric_count.zero?
264
270
  return false if stats.cardinality < HTTP_STATUS_MIN_DISTINCT
@@ -272,7 +278,7 @@ module Iriq
272
278
  # as an enum. Built around the *established* members (values seen at least
273
279
  # ENUM_MIN_VALUE_COUNT times) so a stray one-off value is a straggler, not
274
280
  # a disqualifier. See ENUM_* constants at the top of this class.
275
- def enum?(stats)
281
+ def self.enum?(stats)
276
282
  return false if stats.total < ENUM_MIN_OBSERVATIONS
277
283
 
278
284
  established = established_values(stats)
@@ -283,7 +289,7 @@ module Iriq
283
289
  end
284
290
 
285
291
  # Values seen often enough to count as real members of the set (vs noise).
286
- def established_values(stats)
292
+ def self.established_values(stats)
287
293
  stats.value_counts.select { |_, n| n >= ENUM_MIN_VALUE_COUNT }
288
294
  end
289
295
 
@@ -299,7 +305,7 @@ module Iriq
299
305
  # ordered by descending count (lex tie-break). Stragglers are excluded so
300
306
  # the advertised set is what the corpus is actually confident about.
301
307
  def enum_values(stats)
302
- established_values(stats).sort_by { |v, n| [-n, v] }.map(&:first)
308
+ Cluster.established_values(stats).sort_by { |v, n| [-n, v] }.map(&:first)
303
309
  end
304
310
 
305
311
  # value_distribution returns the fraction of total observations each
@@ -350,7 +356,7 @@ module Iriq
350
356
 
351
357
  # Most common type in stats.type_counts excluding `skip` — lex tie-break
352
358
  # so the choice is deterministic across runtimes.
353
- def dominant_excluding(stats, skip)
359
+ def self.dominant_excluding(stats, skip)
354
360
  best = nil
355
361
  best_count = -1
356
362
  stats.type_counts.each do |t, n|