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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +125 -0
- data/README.md +254 -92
- data/completions/_iriq +2 -0
- data/completions/iriq.bash +1 -1
- data/iriq.gemspec +1 -1
- data/lib/iriq/cli.rb +241 -70
- data/lib/iriq/cluster.rb +76 -24
- data/lib/iriq/clusterer.rb +0 -11
- data/lib/iriq/corpus.rb +236 -119
- data/lib/iriq/errors.rb +9 -0
- data/lib/iriq/identifier.rb +1 -1
- data/lib/iriq/normalizer.rb +21 -18
- data/lib/iriq/observation.rb +11 -6
- data/lib/iriq/parser.rb +5 -1
- data/lib/iriq/position_evidence.rb +31 -0
- data/lib/iriq/position_stats.rb +6 -4
- data/lib/iriq/recognizer.rb +1 -1
- data/lib/iriq/recognizer_proposal.rb +15 -3
- data/lib/iriq/reducer.rb +1 -2
- data/lib/iriq/segment_classifier.rb +17 -6
- data/lib/iriq/specificity.rb +3 -3
- data/lib/iriq/storage/json.rb +21 -7
- data/lib/iriq/storage/memory.rb +52 -10
- data/lib/iriq/storage/sqlite.rb +351 -44
- 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 +3 -8
- data/CLAUDE.md +0 -208
- data/Gemfile +0 -3
- data/Gemfile.lock +0 -103
- data/Makefile +0 -113
- data/docs/ARCHITECTURE.md +0 -223
- data/docs/ROADMAP.md +0 -190
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
|
|
35
|
-
#
|
|
36
|
-
#
|
|
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 =
|
|
40
|
-
ENUM_MIN_COVERAGE = 0.
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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.
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
253
|
-
|
|
254
|
-
|
|
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.
|
|
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 = "
|
|
361
|
-
[key, nil,
|
|
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
|
data/lib/iriq/clusterer.rb
CHANGED
|
@@ -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)
|