iriq 0.2.0 → 0.33.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/cluster.rb CHANGED
@@ -1,31 +1,101 @@
1
1
  module Iriq
2
2
  # A group of identifiers that share a host + shape key. Tracks examples and
3
3
  # per-position segment statistics so callers can ask which positions are
4
- # actually stable in practice (e.g. /users/ always literal, /{integer_id}
4
+ # actually stable in practice (e.g. /users/ always literal, /{integer}
5
5
  # always variable).
6
6
  class Cluster
7
- attr_reader :key, :host, :scheme, :shape, :examples, :count
7
+ attr_reader :key, :host, :scheme, :shape, :examples, :count, :param_stats, :max_values
8
+
9
+ # Structured Shape lazily derived from the first observed example —
10
+ # Iriq::Shape, or nil if no examples are present yet. Cached after the
11
+ # first call.
12
+ def shape_object(classifier: SegmentClassifier::DEFAULT)
13
+ return @shape_object if @shape_object
14
+ return nil if @examples.empty?
15
+
16
+ @shape_object = Shape.from_segments(@examples.first.path_segments, classifier: classifier)
17
+ end
8
18
 
9
19
  MAX_EXAMPLES = 10
10
20
 
11
- def initialize(key:, host:, scheme:, shape:)
21
+ # Share of date-typed observations required before the corpus promotes
22
+ # a param to :date. 8-digit IDs in the 1900..2100 range look like
23
+ # YYYYMMDD by accident — without quorum we'd canonicalize random IDs.
24
+ DATE_CONFIDENCE_THRESHOLD = 0.8
25
+
26
+ # `:number` umbrella thresholds. Promote a position to :number when
27
+ # the combined :integer + :float observations dominate (≥ majority)
28
+ # AND neither subtype alone hits the strong threshold (we have a clear
29
+ # numeric pattern but it isn't purely ints or purely floats).
30
+ NUMBER_CONFIDENCE_THRESHOLD = 0.8
31
+ NUMBER_SUBTYPE_THRESHOLD = 0.8
32
+
33
+ # Param classification is a confidence ladder: constant → string → enum.
34
+ # A param with a single observed value is a constant (rendered as-is); one
35
+ # that varies but isn't yet a trustworthy enum is :string (a generic
36
+ # placeholder); a bounded, well-supported value set is :enum.
37
+ #
38
+ # `:enum` thresholds. Promote a param to :enum when the corpus has seen
39
+ # enough samples to trust the bound (ENUM_MIN_OBSERVATIONS), the *established*
40
+ # values — those seen at least ENUM_MIN_VALUE_COUNT times — are few
41
+ # (ENUM_MAX_CARDINALITY) and cover nearly all observations
42
+ # (ENUM_MIN_COVERAGE). Rare one-off values are stragglers, not
43
+ # disqualifiers: this is what keeps a single brand-new value from knocking
44
+ # an established enum back down (the observe-before-normalize case).
45
+ ENUM_MIN_OBSERVATIONS = 20
46
+ ENUM_MAX_CARDINALITY = 10
47
+ ENUM_MIN_VALUE_COUNT = 3
48
+ ENUM_MIN_COVERAGE = 0.9
49
+ # An enum is a bounded *set*: a single repeated value is a constant, not an
50
+ # enum, so it takes at least two established members to qualify.
51
+ ENUM_MIN_MEMBERS = 2
52
+
53
+ # `:string` — a param that has taken on 2+ distinct non-typed values but
54
+ # isn't (yet) a confident enum. The intermediate rung: we know it varies
55
+ # and looks like free-form text, but haven't earned the bounded-set claim.
56
+ STRING_MIN_DISTINCT = 2
57
+
58
+ # Confidence smoothing constant. confidence = total / (total + K): a
59
+ # monotone curve that is 0.5 at K observations and asymptotes to 1.0. The
60
+ # type names our guess; this number says how much evidence backs it.
61
+ CONFIDENCE_SMOOTHING = 15
62
+
63
+ def initialize(key:, host:, scheme:, shape:, max_values: PositionStats::DEFAULT_MAX_VALUES)
12
64
  @key = key
13
65
  @host = host
14
66
  @scheme = scheme
15
67
  @shape = shape
68
+ @shape_object = nil
16
69
  @examples = []
70
+ @example_keys = Set.new
17
71
  @count = 0
18
72
  @segment_counts = []
73
+ @max_values = max_values
74
+ # Query-param stats keyed by param name. Each is a PositionStats — same
75
+ # cardinality cap, same type-counts machinery, just indexed by ?key=
76
+ # instead of by path position.
77
+ @param_stats = {}
19
78
  end
20
79
 
21
- def add(identifier)
80
+ def add(identifier, classifier: SegmentClassifier::DEFAULT)
22
81
  @count += 1
23
- @examples << identifier if @examples.size < MAX_EXAMPLES
82
+ if @examples.size < MAX_EXAMPLES
83
+ canon = identifier.canonical
84
+ @examples << identifier unless @example_keys.include?(canon)
85
+ @example_keys << canon
86
+ end
24
87
 
25
88
  identifier.path_segments.each_with_index do |seg, i|
26
89
  @segment_counts[i] ||= Hash.new(0)
27
90
  @segment_counts[i][seg] += 1
28
91
  end
92
+
93
+ return unless identifier.query_params
94
+ identifier.query_params.each do |name, value|
95
+ stats = @param_stats[name] ||= PositionStats.new(max_values: @max_values)
96
+ value_s = value.to_s
97
+ stats.observe(value_s, classifier.classify(value_s))
98
+ end
29
99
  end
30
100
 
31
101
  # Per-position summary:
@@ -52,9 +122,247 @@ module Iriq
52
122
  count: count,
53
123
  examples: examples.map(&:canonical),
54
124
  segments: segment_stats,
125
+ params: param_summary,
55
126
  }
56
127
  end
57
128
 
129
+ # Per-param summary, ordered by descending presence. Each entry is:
130
+ # { name: "page", count: N, type: :integer, confidence: 0.83,
131
+ # cardinality: K, presence: 0.83 }
132
+ # confidence is how much evidence backs the type (see param_confidence);
133
+ # presence is count / @count — the fraction of observations that had
134
+ # this param.
135
+ def param_summary
136
+ return [] if @param_stats.empty?
137
+
138
+ @param_stats.map { |name, _stats|
139
+ stats = @param_stats[name]
140
+ type = param_type(name)
141
+ row = {
142
+ name: name,
143
+ count: stats.total,
144
+ type: type,
145
+ confidence: param_confidence(stats),
146
+ cardinality: stats.cardinality,
147
+ presence: @count.positive? ? stats.total.to_f / @count : 0.0,
148
+ }
149
+ row[:values] = enum_values(stats) if type == :enum
150
+ # Verbose value distribution — fractions over tracked occurrences.
151
+ # Boolean and enum positions get the per-value breakdown (e.g.
152
+ # `true: 0.97, false: 0.03`). Number positions get the int-vs-float
153
+ # split via :subtype_distribution.
154
+ if type == :boolean || type == :enum
155
+ row[:value_distribution] = value_distribution(stats)
156
+ end
157
+ if type == :number
158
+ row[:subtype_distribution] = subtype_distribution(stats, %i[integer float])
159
+ end
160
+ # :file kind breakdown — derived from tracked value_counts at
161
+ # summary time. Best-effort: only reflects observations within
162
+ # the value-tracking cap.
163
+ if type == :file
164
+ row[:kind_distribution] = file_kind_distribution(stats)
165
+ end
166
+ if stats.numeric_count.positive?
167
+ row[:min] = stats.numeric_min
168
+ row[:max] = stats.numeric_max
169
+ row[:avg] = stats.numeric_avg
170
+ end
171
+ row
172
+ }.sort_by { |row| [-row[:count], row[:name]] }
173
+ end
174
+
175
+ # Returns the type the corpus is confident enough to call this param.
176
+ # Equals stats.dominant_type when the dominant type isn't :date; when
177
+ # :date is dominant but below DATE_CONFIDENCE_THRESHOLD, falls back to
178
+ # the most-common non-date type (or :literal if none exists). Shared
179
+ # by Cluster#param_summary and Corpus#inferred_param_type so both views
180
+ # agree on what the corpus "thinks" about a param.
181
+ def param_type(name)
182
+ stats = @param_stats[name]
183
+ return nil unless stats
184
+ return nil if stats.total.zero?
185
+
186
+ type = stats.dominant_type
187
+
188
+ # :year takes priority over :enum for numeric range columns —
189
+ # a "years 2020..2026" position is more useful described as a
190
+ # ranged year than as an enum of those specific values.
191
+ return :year if year_position?(type, stats)
192
+ # :http_status — 3-digit ints clustered in 100..599 are almost
193
+ # certainly HTTP statuses. Same shape as :year (range check) but
194
+ # tighter window. Useful for `?status=...` or path positions that
195
+ # echo a status code.
196
+ return :http_status if http_status_position?(type, stats)
197
+
198
+ # :enum check — bounded set of repeated values trumps the underlying
199
+ # value type. `?status=active|draft|archived` surfaces as :enum
200
+ # (with the value list) rather than :literal even though each value
201
+ # individually classifies as a literal. Skip the override when the
202
+ # dominant type is already specific (`:boolean` carries more meaning
203
+ # than a 2-value enum).
204
+ return :enum if enum?(stats) && type != :boolean
205
+
206
+ # :date gate — demote when there isn't enough date-typed quorum.
207
+ if type == :date
208
+ date_frac = stats.type_counts[:date].to_f / stats.total
209
+ return type if date_frac >= DATE_CONFIDENCE_THRESHOLD
210
+
211
+ return dominant_excluding(stats, :date) || :literal
212
+ end
213
+
214
+ # :number umbrella — promote when ints + floats together dominate
215
+ # but neither alone is the clear winner.
216
+ if type == :integer || type == :float
217
+ int_frac = stats.type_counts[:integer].to_f / stats.total
218
+ float_frac = stats.type_counts[:float].to_f / stats.total
219
+ if int_frac < NUMBER_SUBTYPE_THRESHOLD &&
220
+ float_frac < NUMBER_SUBTYPE_THRESHOLD &&
221
+ (int_frac + float_frac) >= NUMBER_CONFIDENCE_THRESHOLD
222
+ return :number
223
+ end
224
+ end
225
+
226
+ # Param-name fallback — `?phone=...` overrides a generic literal
227
+ # type with `:phone` when the value's shape was too weak to detect
228
+ # on its own. Only fires for overridable types (literal/opaque_id/slug).
229
+ if (hint = SegmentClassifier.param_name_hint(name, type))
230
+ return hint
231
+ end
232
+
233
+ # :string rung — a literal-valued param that has taken on more than one
234
+ # distinct value varies, so it's a placeholder, not a fixed constant.
235
+ # Below the enum bar (checked above), so we claim only "free-form text".
236
+ return :string if type == :literal && stats.cardinality >= STRING_MIN_DISTINCT
237
+
238
+ type
239
+ end
240
+
241
+ YEAR_RANGE = 1900..2100
242
+ YEAR_MIN_OBSERVATIONS = 5
243
+ YEAR_MIN_DISTINCT = 2
244
+ YEAR_MAX_DISTINCT = 150
245
+
246
+ def year_position?(type, stats)
247
+ return false unless type == :integer
248
+ return false if stats.numeric_count.zero?
249
+ return false if stats.cardinality < YEAR_MIN_DISTINCT
250
+ return false if stats.cardinality > YEAR_MAX_DISTINCT
251
+ return false if stats.total < YEAR_MIN_OBSERVATIONS
252
+
253
+ YEAR_RANGE.cover?(stats.numeric_min) && YEAR_RANGE.cover?(stats.numeric_max)
254
+ end
255
+
256
+ HTTP_STATUS_RANGE = 100..599
257
+ HTTP_STATUS_MIN_OBSERVATIONS = 5
258
+ HTTP_STATUS_MIN_DISTINCT = 2
259
+ HTTP_STATUS_MAX_DISTINCT = 30
260
+
261
+ def http_status_position?(type, stats)
262
+ return false unless type == :integer
263
+ return false if stats.numeric_count.zero?
264
+ return false if stats.cardinality < HTTP_STATUS_MIN_DISTINCT
265
+ return false if stats.cardinality > HTTP_STATUS_MAX_DISTINCT
266
+ return false if stats.total < HTTP_STATUS_MIN_OBSERVATIONS
267
+
268
+ HTTP_STATUS_RANGE.cover?(stats.numeric_min) && HTTP_STATUS_RANGE.cover?(stats.numeric_max)
269
+ end
270
+
271
+ # True when stats shows a bounded set of repeated values worth treating
272
+ # as an enum. Built around the *established* members (values seen at least
273
+ # ENUM_MIN_VALUE_COUNT times) so a stray one-off value is a straggler, not
274
+ # a disqualifier. See ENUM_* constants at the top of this class.
275
+ def enum?(stats)
276
+ return false if stats.total < ENUM_MIN_OBSERVATIONS
277
+
278
+ established = established_values(stats)
279
+ return false unless established.size.between?(ENUM_MIN_MEMBERS, ENUM_MAX_CARDINALITY)
280
+
281
+ coverage = established.values.sum.to_f / stats.total
282
+ coverage >= ENUM_MIN_COVERAGE
283
+ end
284
+
285
+ # Values seen often enough to count as real members of the set (vs noise).
286
+ def established_values(stats)
287
+ stats.value_counts.select { |_, n| n >= ENUM_MIN_VALUE_COUNT }
288
+ end
289
+
290
+ # Confidence that the assigned type is right, given how much evidence backs
291
+ # it. Monotone in observation count; 0.5 at CONFIDENCE_SMOOTHING, → 1.0.
292
+ def param_confidence(stats)
293
+ return 0.0 if stats.total.zero?
294
+
295
+ (stats.total.to_f / (stats.total + CONFIDENCE_SMOOTHING)).round(2)
296
+ end
297
+
298
+ # The enum's member values — the established ones (seen enough to be real),
299
+ # ordered by descending count (lex tie-break). Stragglers are excluded so
300
+ # the advertised set is what the corpus is actually confident about.
301
+ def enum_values(stats)
302
+ established_values(stats).sort_by { |v, n| [-n, v] }.map(&:first)
303
+ end
304
+
305
+ # value_distribution returns the fraction of total observations each
306
+ # tracked value represents, ordered by descending count then lex. Used
307
+ # by param_summary for :boolean and :enum positions so callers can
308
+ # render "true 97%, false 3%"-style breakdowns.
309
+ def value_distribution(stats)
310
+ return {} if stats.total.zero?
311
+
312
+ stats.value_counts.sort_by { |v, n| [-n, v] }.to_h.transform_values do |n|
313
+ (n.to_f / stats.total).round(4)
314
+ end
315
+ end
316
+
317
+ # subtype_distribution slices type_counts to a specific subset and
318
+ # returns the fraction each subtype represents. Used for the :number
319
+ # umbrella to expose the int-vs-float split.
320
+ def subtype_distribution(stats, subtypes)
321
+ return {} if stats.total.zero?
322
+
323
+ subtypes.each_with_object({}) do |t, out|
324
+ n = stats.type_counts[t] || 0
325
+ out[t] = (n.to_f / stats.total).round(4) if n.positive?
326
+ end
327
+ end
328
+
329
+ # file_kind_distribution buckets tracked values by file kind and
330
+ # returns the fraction each kind represents over tracked observations.
331
+ # `:unknown` covers values that classified as :file but whose extension
332
+ # isn't in the kind allowlist (shouldn't normally happen since the
333
+ # classifier already gates on the kind map). Sums to ≤ 1.0 since
334
+ # value_counts caps at PositionStats::DEFAULT_MAX_VALUES.
335
+ def file_kind_distribution(stats)
336
+ return {} if stats.value_counts.empty?
337
+
338
+ total = stats.value_counts.values.sum
339
+ return {} if total.zero?
340
+
341
+ kinds = Hash.new(0)
342
+ stats.value_counts.each do |value, n|
343
+ kind = SegmentClassifier.file_kind(value) || :unknown
344
+ kinds[kind] += n
345
+ end
346
+ kinds.sort_by { |k, n| [-n, k.to_s] }.to_h.transform_values do |n|
347
+ (n.to_f / total).round(4)
348
+ end
349
+ end
350
+
351
+ # Most common type in stats.type_counts excluding `skip` — lex tie-break
352
+ # so the choice is deterministic across runtimes.
353
+ def dominant_excluding(stats, skip)
354
+ best = nil
355
+ best_count = -1
356
+ stats.type_counts.each do |t, n|
357
+ next if t == skip
358
+ if n > best_count || (n == best_count && t.to_s < best.to_s)
359
+ best = t
360
+ best_count = n
361
+ end
362
+ end
363
+ best
364
+ end
365
+
58
366
  # JSON-friendly dump for persistence (distinct from #to_h which is a
59
367
  # display form). Examples are dumped as canonical strings and re-parsed
60
368
  # on load.
@@ -67,31 +375,41 @@ module Iriq
67
375
  "count" => count,
68
376
  "examples" => examples.map(&:canonical),
69
377
  "segment_counts" => @segment_counts.map { |h| h || {} },
378
+ "param_stats" => @param_stats.transform_values(&:dump),
70
379
  }
71
380
  end
72
381
 
73
- def self.from_dump(h)
74
- cluster = new(key: h["key"], host: h["host"], scheme: h["scheme"], shape: h["shape"])
382
+ def self.from_dump(h, max_values: PositionStats::DEFAULT_MAX_VALUES)
383
+ cluster = new(
384
+ key: h["key"], host: h["host"], scheme: h["scheme"], shape: h["shape"],
385
+ max_values: max_values,
386
+ )
75
387
  cluster.instance_variable_set(:@count, h["count"])
76
- cluster.instance_variable_set(:@examples, h["examples"].map { |s| Parser.parse(s) })
388
+ examples = h["examples"].map { |s| Parser.parse(s) }
389
+ cluster.instance_variable_set(:@examples, examples)
390
+ cluster.instance_variable_set(:@example_keys, examples.map(&:canonical).to_set)
77
391
  cluster.instance_variable_set(:@segment_counts, h["segment_counts"].map { |sub| Hash.new(0).merge(sub) })
392
+ params = (h["param_stats"] || {}).transform_values { |sd| PositionStats.from_dump(sd) }
393
+ cluster.instance_variable_set(:@param_stats, params)
78
394
  cluster
79
395
  end
80
396
 
81
397
  # Shared cluster-key derivation. Returns [key, host, scheme, shape] —
82
398
  # callers that already have a hinted shape can pass it in to skip the
83
399
  # recomputation; URN inputs ignore the override and always derive their
84
- # own shape from the NSS value.
85
- def self.key_for(iri, classifier:, shape: nil)
400
+ # own shape from the NSS value. `host:` overrides iri.host — used by
401
+ # Corpus when host_strategy collapses subdomains or ignores the host.
402
+ def self.key_for(iri, classifier:, shape: nil, host: nil)
86
403
  if iri.urn?
87
404
  ns, value = (iri.nss || "").split(":", 2)
88
405
  derived = value ? urn_value_shape(ns, value, classifier) : nil
89
- key = "urn:#{ns}:#{derived}"
90
- [key, nil, "urn", key]
406
+ key = "#{iri.scheme}:#{ns}:#{derived}"
407
+ [key, nil, iri.scheme, key]
91
408
  else
92
409
  shape ||= PathShape.new(classifier: classifier).for(iri.path_segments)
93
- key = "#{iri.scheme}://#{iri.host}#{shape}"
94
- [key, iri.host, iri.scheme, shape]
410
+ effective_host = host.nil? ? iri.host : host
411
+ key = "#{iri.scheme}://#{effective_host}#{shape}"
412
+ [key, effective_host, iri.scheme, shape]
95
413
  end
96
414
  end
97
415
 
@@ -47,17 +47,6 @@ module Iriq
47
47
  end
48
48
  end
49
49
 
50
- def dump
51
- { "clusters" => clusters.each_with_object({}) { |c, h| h[c.key] = c.dump } }
52
- end
53
-
54
- def self.from_dump(h, classifier: SegmentClassifier::DEFAULT)
55
- c = new(classifier: classifier)
56
- restored = h["clusters"].transform_values { |cdump| Cluster.from_dump(cdump) }
57
- c.instance_variable_get(:@storage).instance_variable_set(:@clusters, restored)
58
- c
59
- end
60
-
61
50
  private
62
51
 
63
52
  def coerce(input)