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.
@@ -48,8 +48,10 @@ module Iriq
48
48
  def record_numeric(value, type)
49
49
  return unless NUMERIC_TYPES.include?(type)
50
50
 
51
- n = Float(value) rescue nil
52
- return unless n
51
+ # Huge digit strings parse to ±Infinity; they'd poison the range and
52
+ # can't be serialized to JSON. They still count in type/value counts.
53
+ n = Float(value, exception: false)
54
+ return unless n&.finite?
53
55
 
54
56
  @numeric_count += 1
55
57
  @numeric_min = n if @numeric_min.nil? || n < @numeric_min
@@ -79,8 +81,8 @@ module Iriq
79
81
  end
80
82
 
81
83
  # Most common type. On count ties, breaks lexicographically by type
82
- # symbol name so the result is deterministic and matches Go's
83
- # DominantType (Go's map iteration is randomized).
84
+ # symbol name so the result is deterministic and matches the Rust
85
+ # port's dominant_type regardless of map iteration order.
84
86
  def dominant_type
85
87
  best = nil
86
88
  best_count = -1
@@ -32,7 +32,7 @@ module Iriq
32
32
  # mutually-exclusive on shape, so the ensemble is effectively a
33
33
  # short-circuit OR. As more Recognizers carve out of SegmentClassifier
34
34
  # they'll join the pool and the scoring becomes load-bearing.
35
- def self.ensemble(segment, *recognizers)
35
+ def self.ensemble(segment, recognizers)
36
36
  best = nil
37
37
  best_score = -1.0
38
38
  recognizers.each do |r|
@@ -115,7 +115,7 @@ module Iriq
115
115
  acc[:positions] << position
116
116
  acc[:hosts] << position.host
117
117
  # Collect every match; we'll sort + cap to a stable top-N at
118
- # emission time so Ruby and Go produce identical samples
118
+ # emission time so Ruby and Rust produce identical samples
119
119
  # regardless of underlying Hash / map iteration order.
120
120
  acc[:matches] << value
121
121
  end
@@ -130,12 +130,12 @@ module Iriq
130
130
 
131
131
  RecognizerProposal.new(
132
132
  prefix: prefix,
133
- suggested_type: prefix.chomp("_").to_sym,
133
+ suggested_type: suggested_type_for(prefix),
134
134
  positions: acc[:positions].to_a,
135
135
  hosts: acc[:hosts],
136
136
  coverage: coverage,
137
137
  observation_count: acc[:matching_count],
138
- # Sort + cap to 5 so Ruby and Go produce identical samples
138
+ # Sort + cap to 5 so Ruby and Rust produce identical samples
139
139
  # regardless of underlying Hash / map iteration order. The
140
140
  # samples are illustrative for humans; alphabetical is fine.
141
141
  sample_values: acc[:matches].sort.first(5),
@@ -144,8 +144,20 @@ module Iriq
144
144
  }.sort_by { |p| [-p.confidence, p.prefix] }
145
145
  end
146
146
 
147
+ # Placeholder names iriq already means something by. `ip` is the
148
+ # ipv4/ipv6 display umbrella, not a classifier type.
149
+ RESERVED_TYPE_NAMES = (SegmentClassifier::TYPES + %i[ip]).freeze
150
+
147
151
  private
148
152
 
153
+ # A proposal never takes a built-in name — activating `literal_` as
154
+ # :literal would turn every matching value into a fixed literal — so
155
+ # those get an `_id` suffix (`literal_` → :literal_id).
156
+ def suggested_type_for(prefix)
157
+ name = prefix.chomp("_").to_sym
158
+ RESERVED_TYPE_NAMES.include?(name) ? :"#{name}_id" : name
159
+ end
160
+
149
161
  def empty_accumulator
150
162
  {
151
163
  positions: Set.new,
data/lib/iriq/reducer.rb CHANGED
@@ -8,8 +8,7 @@ module Iriq
8
8
  # handles it, register it in DEFAULTS — no other module changes.
9
9
  module Reducer
10
10
  # Each entry: { event_class => [lambda(event, storage) -> result] }.
11
- # Lambdas may return the result of the underlying storage call so
12
- # callers (Corpus#observe) can pick up the cluster they need to return.
11
+ # Lambdas return the result of the underlying storage call.
13
12
  DEFAULTS = {
14
13
  Event::HostSeen => [->(e, s) { s.increment_host(e.host) }],
15
14
  Event::PathLengthSeen => [->(e, s) { s.increment_path_length(e.length) }],
@@ -14,8 +14,10 @@ module Iriq
14
14
  #
15
15
  # `:enum` is similarly corpus-only — it surfaces when a position has a
16
16
  # bounded set of distinct values observed across enough samples (see
17
- # Cluster::ENUM_* thresholds).
18
- TYPES = %i[literal integer float number uuid date year timestamp hash slug
17
+ # Cluster::ENUM_* thresholds). `:string` is the rung below `:enum`: a
18
+ # param that varies across free-form literal values but isn't a confident
19
+ # bounded set yet. Neither is ever returned for a single value.
20
+ TYPES = %i[literal string integer float number uuid date year timestamp hash slug
19
21
  ipv4 ipv6 url email boolean version locale currency phone jwt mime
20
22
  file color coordinate country base64 http_status enum opaque_id].freeze
21
23
 
@@ -125,9 +127,10 @@ module Iriq
125
127
  document: %w[pdf doc docx xls xlsx ppt pptx odt ods odp rtf epub],
126
128
  data: %w[csv tsv json xml yaml yml parquet sqlite db ndjson jsonl],
127
129
  text: %w[txt md log markdown rst],
128
- web: %w[html htm css js mjs cjs ts jsx tsx],
130
+ web: %w[html htm css js mjs cjs ts jsx tsx map],
131
+ font: %w[woff woff2 ttf otf eot],
129
132
  audio: %w[mp3 wav ogg flac aac m4a opus],
130
- video: %w[mp4 mov avi mkv webm flv wmv m4v],
133
+ video: %w[mp4 mov avi mkv webm flv wmv m4v m3u8],
131
134
  archive: %w[zip tar gz bz2 7z rar xz tgz],
132
135
  code: %w[rb py go java c cc cpp h hpp sh swift kt rs],
133
136
  }.freeze
@@ -230,6 +233,13 @@ module Iriq
230
233
  recognizer
231
234
  end
232
235
 
236
+ # A copy registers recognizers without touching the original.
237
+ def initialize_copy(source)
238
+ super
239
+ @recognizers = source.recognizers
240
+ @cache = {}
241
+ end
242
+
233
243
  # Snapshot of the live ensemble. Useful for tests and tooling that
234
244
  # want to inspect which Recognizers a corpus is consulting.
235
245
  def recognizers
@@ -262,7 +272,7 @@ module Iriq
262
272
  # Scored ensemble over the live Recognizer list — built-ins +
263
273
  # anything Corpus#activate_proposal has registered for this
264
274
  # classifier instance.
265
- if (v = Recognizer.ensemble(segment, *@recognizers))
275
+ if (v = Recognizer.ensemble(segment, @recognizers))
266
276
  return v[:type]
267
277
  end
268
278
 
@@ -502,7 +512,8 @@ module Iriq
502
512
  # /pricing/USD both render as /pricing/USD.
503
513
  def self.canonical_currency(value)
504
514
  return nil if value.nil?
505
- up = value.upcase
515
+ # ASCII-only: full Unicode upcase maps ſ→S / ı→I, forging a code.
516
+ up = value.upcase(:ascii)
506
517
  CURRENCY_CODES.include?(up) ? up : nil
507
518
  end
508
519
 
@@ -11,9 +11,9 @@ module Iriq
11
11
  # integer could plausibly be a year, an HTTP status, or an ID, so
12
12
  # `:integer` claims only TYPED.
13
13
  #
14
- # Calibration corpus tests in spec/iriq/calibration_spec.rb / Go's
15
- # calibration_test.go are the source of truth for whether these
16
- # values are well-chosen — adjust them and re-run to validate.
14
+ # Calibration corpus tests in spec/iriq/calibration_spec.rb are the
15
+ # source of truth for whether these values are well-chosen — adjust
16
+ # them and re-run to validate.
17
17
  module Specificity
18
18
  # Unambiguous semantic shapes — the regex effectively can't fire by
19
19
  # accident. (UUID, JWT, email with @, URL with ://, color hex.)
@@ -19,24 +19,38 @@ module Iriq
19
19
  s
20
20
  end
21
21
 
22
+ # Refuses (Iriq::CorpusError) anything that isn't a corpus dump, so a
23
+ # mistyped --corpus path can't overwrite an unrelated JSON file: it must
24
+ # be an object with at least one corpus key, or `{}`.
22
25
  def load!(path)
23
- data = File.read(path)
26
+ data = begin
27
+ File.read(path)
28
+ rescue SystemCallError => e
29
+ raise CorpusError, "corpus #{path}: #{Iriq.os_error_message(e)}"
30
+ end
24
31
  return self if data.empty?
25
32
 
26
- load_dump!(JSON.parse(data))
33
+ dump = begin
34
+ JSON.parse(data)
35
+ rescue JSON::ParserError
36
+ raise CorpusError, "corpus #{path}: not valid JSON"
37
+ end
38
+ unless dump.is_a?(Hash) && (dump.empty? || dump.keys.intersect?(DUMP_KEYS))
39
+ raise CorpusError, "corpus #{path}: not an iriq corpus (no corpus keys at the top level)"
40
+ end
41
+
42
+ load_dump!(dump)
27
43
  @path = path
28
44
  self
29
45
  end
30
46
 
31
- # save writes atomically (tmp + rename). Defaults to the path passed at
32
- # open(); pass an explicit path to write elsewhere.
47
+ # save writes atomically (unique tmp + rename). Defaults to the path
48
+ # passed at open(); pass an explicit path to write elsewhere.
33
49
  def save(path = nil)
34
50
  target = path || @path
35
51
  raise ArgumentError, "no path provided" unless target
36
52
 
37
- tmp = "#{target}.tmp"
38
- File.write(tmp, JSON.generate(to_dump))
39
- File.rename(tmp, target)
53
+ Storage.write_atomically(target, JSON.generate(to_dump))
40
54
  end
41
55
  end
42
56
  end
@@ -15,12 +15,17 @@ module Iriq
15
15
  #
16
16
  # host_counts / path_length_counts / raw_shape_counts / fingerprint_counts
17
17
  # position_stats(position)
18
+ # position_evidence(position, value) # the narrow read normalize uses
19
+ # param_stats(cluster_key, name) # one param, without the cluster
18
20
  # each_position_stats { |position, stats| ... }
19
21
  # each_observed_iri { |canonical| ... }
20
- # clear_materialized_views # for reinfer
22
+ # each_observed_iri_since(mark) { |canonical| ... } # mark of the last
23
+ # clear_materialized_views
24
+ # begin_rebuild / install_rebuild / discard_rebuild # for reinfer
21
25
  # clusters / cluster_size
22
26
  #
23
27
  # transaction { ... } # backends may batch within
28
+ # turn_over? # a long batch should commit and let others in
24
29
  # flush # commit pending writes (no-op for Memory)
25
30
  # close # release resources
26
31
  class Memory
@@ -55,8 +60,14 @@ module Iriq
55
60
  yield self
56
61
  end
57
62
 
63
+ # Yields false: nothing else writes an in-memory corpus.
58
64
  def batch
59
- yield
65
+ yield false
66
+ end
67
+
68
+ # Nothing else waits on an in-memory corpus.
69
+ def turn_over?
70
+ false
60
71
  end
61
72
 
62
73
  def flush; end
@@ -108,6 +119,13 @@ module Iriq
108
119
  @observed_iris.each(&block)
109
120
  end
110
121
 
122
+ # The observations logged after `mark` (0 for all), in order; returns
123
+ # the mark of the last one.
124
+ def each_observed_iri_since(mark, &block)
125
+ @observed_iris.drop(mark).each(&block)
126
+ [mark, @observed_iris.size].max
127
+ end
128
+
111
129
  def observed_iri_count
112
130
  @observed_iris.size
113
131
  end
@@ -138,6 +156,14 @@ module Iriq
138
156
  @clusters = {}
139
157
  end
140
158
 
159
+ # Nothing else reads an in-memory corpus mid-rebuild: rebuild in place.
160
+ def begin_rebuild
161
+ clear_materialized_views
162
+ end
163
+
164
+ def install_rebuild; end
165
+ def discard_rebuild; end
166
+
141
167
  # --- Reads ------------------------------------------------------------
142
168
 
143
169
  def host_counts; @host_counts; end
@@ -149,6 +175,12 @@ module Iriq
149
175
  @position_stats[position]
150
176
  end
151
177
 
178
+ # Built over the live stats, so nothing is copied.
179
+ def position_evidence(position, value)
180
+ stats = @position_stats[position]
181
+ stats && PositionEvidence.from_stats(stats, value)
182
+ end
183
+
152
184
  def each_position_stats(&block)
153
185
  @position_stats.each(&block)
154
186
  end
@@ -161,22 +193,32 @@ module Iriq
161
193
  @clusters.size
162
194
  end
163
195
 
164
- # O(1) lookup by cluster key used by Corpus#normalize to pull the
165
- # cluster's param_stats for the URL being normalized. nil if no cluster
166
- # has been observed under this key yet.
196
+ # O(1) lookup by cluster key. nil if no cluster has been observed under
197
+ # this key yet.
167
198
  def cluster_for(key)
168
199
  @clusters[key]
169
200
  end
170
201
 
202
+ def param_stats(key, name)
203
+ cluster = @clusters[key]
204
+ cluster && cluster.param_stats[name]
205
+ end
206
+
171
207
  # --- Bulk load (used by JSON backend) --------------------------------
172
208
 
209
+ # Top-level keys of the dump. A JSON file with none of them isn't a corpus.
210
+ DUMP_KEYS = %w[host_counts path_length_counts raw_shape_counts fingerprint_counts
211
+ max_values_per_position position_stats clusterer observed_iris
212
+ activated_recognizers].freeze
213
+
214
+ # Missing keys load as empty, so `{}` is a valid (empty) corpus.
173
215
  def load_dump!(h)
174
- @host_counts = Hash.new(0).merge(h["host_counts"])
175
- @path_length_counts = Hash.new(0).merge(h["path_length_counts"].transform_keys(&:to_i))
176
- @raw_shape_counts = Hash.new(0).merge(h["raw_shape_counts"])
177
- @fingerprint_counts = Hash.new(0).merge(h["fingerprint_counts"])
216
+ @host_counts = Hash.new(0).merge(h.fetch("host_counts", {}))
217
+ @path_length_counts = Hash.new(0).merge(h.fetch("path_length_counts", {}).transform_keys(&:to_i))
218
+ @raw_shape_counts = Hash.new(0).merge(h.fetch("raw_shape_counts", {}))
219
+ @fingerprint_counts = Hash.new(0).merge(h.fetch("fingerprint_counts", {}))
178
220
  @max_values_per_position = h.fetch("max_values_per_position", PositionStats::DEFAULT_MAX_VALUES)
179
- @position_stats = h["position_stats"].each_with_object({}) do |entry, acc|
221
+ @position_stats = h.fetch("position_stats", []).each_with_object({}) do |entry, acc|
180
222
  position = Position.from_dump(entry["position"])
181
223
  acc[position] = PositionStats.from_dump(entry["stats"])
182
224
  end