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/lib/iriq/cluster.rb CHANGED
@@ -30,14 +30,35 @@ module Iriq
30
30
  NUMBER_CONFIDENCE_THRESHOLD = 0.8
31
31
  NUMBER_SUBTYPE_THRESHOLD = 0.8
32
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
+ #
33
38
  # `:enum` thresholds. Promote a param to :enum when the corpus has seen
34
- # enough samples to trust the bound, the value set is small, each value
35
- # appears more than once (rules out singletons), and the tracked values
36
- # account for nearly all observations (lets a few stragglers through).
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).
37
45
  ENUM_MIN_OBSERVATIONS = 20
38
46
  ENUM_MAX_CARDINALITY = 10
39
- ENUM_MIN_VALUE_COUNT = 2
40
- ENUM_MIN_COVERAGE = 0.95
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
41
62
 
42
63
  def initialize(key:, host:, scheme:, shape:, max_values: PositionStats::DEFAULT_MAX_VALUES)
43
64
  @key = key
@@ -72,21 +93,24 @@ module Iriq
72
93
  return unless identifier.query_params
73
94
  identifier.query_params.each do |name, value|
74
95
  stats = @param_stats[name] ||= PositionStats.new(max_values: @max_values)
75
- stats.observe(value.to_s, classifier.classify(value.to_s))
96
+ value_s = value.to_s
97
+ stats.observe(value_s, classifier.classify(value_s))
76
98
  end
77
99
  end
78
100
 
79
- # Per-position summary:
101
+ # Per-position summary, values by descending count then value:
80
102
  # [
81
103
  # { position: 0, stable: true, values: { "users" => 3 } },
82
104
  # { position: 1, stable: false, values: { "1" => 1, "2" => 1, "3" => 1 } },
83
105
  # ]
106
+ # Not storage order: SQLite reads values back sorted, memory and JSON
107
+ # in first-seen order.
84
108
  def segment_stats
85
109
  @segment_counts.each_with_index.map do |counts, i|
86
110
  {
87
111
  position: i,
88
112
  stable: counts.size == 1,
89
- values: counts.dup,
113
+ values: counts.sort_by { |v, n| [-n, v] }.to_h,
90
114
  }
91
115
  end
92
116
  end
@@ -105,7 +129,9 @@ module Iriq
105
129
  end
106
130
 
107
131
  # Per-param summary, ordered by descending presence. Each entry is:
108
- # { name: "page", count: N, type: :integer, cardinality: K, presence: 0.83 }
132
+ # { name: "page", count: N, type: :integer, confidence: 0.83,
133
+ # cardinality: K, presence: 0.83 }
134
+ # confidence is how much evidence backs the type (see param_confidence);
109
135
  # presence is count / @count — the fraction of observations that had
110
136
  # this param.
111
137
  def param_summary
@@ -118,6 +144,7 @@ module Iriq
118
144
  name: name,
119
145
  count: stats.total,
120
146
  type: type,
147
+ confidence: param_confidence(stats),
121
148
  cardinality: stats.cardinality,
122
149
  presence: @count.positive? ? stats.total.to_f / @count : 0.0,
123
150
  }
@@ -155,7 +182,11 @@ module Iriq
155
182
  # agree on what the corpus "thinks" about a param.
156
183
  def param_type(name)
157
184
  stats = @param_stats[name]
158
- 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)
159
190
  return nil if stats.total.zero?
160
191
 
161
192
  type = stats.dominant_type
@@ -205,6 +236,11 @@ module Iriq
205
236
  return hint
206
237
  end
207
238
 
239
+ # :string rung — a literal-valued param that has taken on more than one
240
+ # distinct value varies, so it's a placeholder, not a fixed constant.
241
+ # Below the enum bar (checked above), so we claim only "free-form text".
242
+ return :string if type == :literal && stats.cardinality >= STRING_MIN_DISTINCT
243
+
208
244
  type
209
245
  end
210
246
 
@@ -213,7 +249,7 @@ module Iriq
213
249
  YEAR_MIN_DISTINCT = 2
214
250
  YEAR_MAX_DISTINCT = 150
215
251
 
216
- def year_position?(type, stats)
252
+ def self.year_position?(type, stats)
217
253
  return false unless type == :integer
218
254
  return false if stats.numeric_count.zero?
219
255
  return false if stats.cardinality < YEAR_MIN_DISTINCT
@@ -228,7 +264,7 @@ module Iriq
228
264
  HTTP_STATUS_MIN_DISTINCT = 2
229
265
  HTTP_STATUS_MAX_DISTINCT = 30
230
266
 
231
- def http_status_position?(type, stats)
267
+ def self.http_status_position?(type, stats)
232
268
  return false unless type == :integer
233
269
  return false if stats.numeric_count.zero?
234
270
  return false if stats.cardinality < HTTP_STATUS_MIN_DISTINCT
@@ -239,21 +275,37 @@ module Iriq
239
275
  end
240
276
 
241
277
  # True when stats shows a bounded set of repeated values worth treating
242
- # as an enum. See ENUM_* constants at the top of this class.
243
- def enum?(stats)
278
+ # as an enum. Built around the *established* members (values seen at least
279
+ # ENUM_MIN_VALUE_COUNT times) so a stray one-off value is a straggler, not
280
+ # a disqualifier. See ENUM_* constants at the top of this class.
281
+ def self.enum?(stats)
244
282
  return false if stats.total < ENUM_MIN_OBSERVATIONS
245
- return false if stats.cardinality.zero? || stats.cardinality > ENUM_MAX_CARDINALITY
246
- return false if stats.value_counts.any? { |_, n| n < ENUM_MIN_VALUE_COUNT }
247
283
 
248
- coverage = stats.value_counts.values.sum.to_f / stats.total
284
+ established = established_values(stats)
285
+ return false unless established.size.between?(ENUM_MIN_MEMBERS, ENUM_MAX_CARDINALITY)
286
+
287
+ coverage = established.values.sum.to_f / stats.total
249
288
  coverage >= ENUM_MIN_COVERAGE
250
289
  end
251
290
 
252
- # Distinct values tracked for this param, ordered by descending count
253
- # (lex tie-break). Returned alongside :enum-typed rows in param_summary
254
- # so verbose/explain consumers can render the value set.
291
+ # Values seen often enough to count as real members of the set (vs noise).
292
+ def self.established_values(stats)
293
+ stats.value_counts.select { |_, n| n >= ENUM_MIN_VALUE_COUNT }
294
+ end
295
+
296
+ # Confidence that the assigned type is right, given how much evidence backs
297
+ # it. Monotone in observation count; 0.5 at CONFIDENCE_SMOOTHING, → 1.0.
298
+ def param_confidence(stats)
299
+ return 0.0 if stats.total.zero?
300
+
301
+ (stats.total.to_f / (stats.total + CONFIDENCE_SMOOTHING)).round(2)
302
+ end
303
+
304
+ # The enum's member values — the established ones (seen enough to be real),
305
+ # ordered by descending count (lex tie-break). Stragglers are excluded so
306
+ # the advertised set is what the corpus is actually confident about.
255
307
  def enum_values(stats)
256
- stats.value_counts.sort_by { |v, n| [-n, v] }.map(&:first)
308
+ Cluster.established_values(stats).sort_by { |v, n| [-n, v] }.map(&:first)
257
309
  end
258
310
 
259
311
  # value_distribution returns the fraction of total observations each
@@ -304,7 +356,7 @@ module Iriq
304
356
 
305
357
  # Most common type in stats.type_counts excluding `skip` — lex tie-break
306
358
  # so the choice is deterministic across runtimes.
307
- def dominant_excluding(stats, skip)
359
+ def self.dominant_excluding(stats, skip)
308
360
  best = nil
309
361
  best_count = -1
310
362
  stats.type_counts.each do |t, n|
@@ -357,8 +409,8 @@ module Iriq
357
409
  if iri.urn?
358
410
  ns, value = (iri.nss || "").split(":", 2)
359
411
  derived = value ? urn_value_shape(ns, value, classifier) : nil
360
- key = "urn:#{ns}:#{derived}"
361
- [key, nil, "urn", key]
412
+ key = "#{iri.scheme}:#{ns}:#{derived}"
413
+ [key, nil, iri.scheme, key]
362
414
  else
363
415
  shape ||= PathShape.new(classifier: classifier).for(iri.path_segments)
364
416
  effective_host = host.nil? ? iri.host : host
@@ -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)