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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -0
- data/README.md +187 -50
- data/lib/iriq/cli.rb +131 -60
- data/lib/iriq/cluster.rb +15 -9
- data/lib/iriq/corpus.rb +233 -117
- data/lib/iriq/errors.rb +9 -0
- data/lib/iriq/normalizer.rb +21 -18
- data/lib/iriq/observation.rb +11 -6
- data/lib/iriq/position_evidence.rb +31 -0
- data/lib/iriq/position_stats.rb +4 -2
- data/lib/iriq/recognizer_proposal.rb +13 -1
- data/lib/iriq/reducer.rb +1 -2
- data/lib/iriq/segment_classifier.rb +9 -1
- data/lib/iriq/storage/json.rb +21 -7
- data/lib/iriq/storage/memory.rb +52 -10
- data/lib/iriq/storage/sqlite.rb +291 -41
- data/lib/iriq/storage.rb +18 -0
- data/lib/iriq/trace.rb +29 -33
- data/lib/iriq/version.rb +1 -1
- data/lib/iriq.rb +1 -0
- metadata +2 -1
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
|
|
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
|
|
45
|
-
|
|
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]
|
|
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
|
|
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
|
|
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
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
#
|
|
375
|
-
#
|
|
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
|
-
|
|
383
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
#
|
|
415
|
-
#
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|
|
458
|
-
data[:canonical] = iri.canonical
|
|
459
|
-
data[: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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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|
|