iriq 0.30.2 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +95 -70
- data/completions/_iriq +2 -0
- data/completions/iriq.bash +1 -1
- data/iriq.gemspec +1 -1
- data/lib/iriq/cli.rb +117 -17
- data/lib/iriq/cluster.rb +63 -17
- data/lib/iriq/clusterer.rb +0 -11
- data/lib/iriq/corpus.rb +3 -2
- data/lib/iriq/identifier.rb +1 -1
- data/lib/iriq/parser.rb +5 -1
- data/lib/iriq/position_stats.rb +2 -2
- data/lib/iriq/recognizer.rb +1 -1
- data/lib/iriq/recognizer_proposal.rb +2 -2
- data/lib/iriq/segment_classifier.rb +8 -5
- data/lib/iriq/specificity.rb +3 -3
- data/lib/iriq/storage/sqlite.rb +63 -6
- data/lib/iriq/version.rb +1 -1
- metadata +2 -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,7 +93,8 @@ 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
|
|
|
@@ -105,7 +127,9 @@ module Iriq
|
|
|
105
127
|
end
|
|
106
128
|
|
|
107
129
|
# Per-param summary, ordered by descending presence. Each entry is:
|
|
108
|
-
# { name: "page", count: N, type: :integer,
|
|
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);
|
|
109
133
|
# presence is count / @count — the fraction of observations that had
|
|
110
134
|
# this param.
|
|
111
135
|
def param_summary
|
|
@@ -118,6 +142,7 @@ module Iriq
|
|
|
118
142
|
name: name,
|
|
119
143
|
count: stats.total,
|
|
120
144
|
type: type,
|
|
145
|
+
confidence: param_confidence(stats),
|
|
121
146
|
cardinality: stats.cardinality,
|
|
122
147
|
presence: @count.positive? ? stats.total.to_f / @count : 0.0,
|
|
123
148
|
}
|
|
@@ -205,6 +230,11 @@ module Iriq
|
|
|
205
230
|
return hint
|
|
206
231
|
end
|
|
207
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
|
+
|
|
208
238
|
type
|
|
209
239
|
end
|
|
210
240
|
|
|
@@ -239,21 +269,37 @@ module Iriq
|
|
|
239
269
|
end
|
|
240
270
|
|
|
241
271
|
# True when stats shows a bounded set of repeated values worth treating
|
|
242
|
-
# as an enum.
|
|
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.
|
|
243
275
|
def enum?(stats)
|
|
244
276
|
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
277
|
|
|
248
|
-
|
|
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
|
|
249
282
|
coverage >= ENUM_MIN_COVERAGE
|
|
250
283
|
end
|
|
251
284
|
|
|
252
|
-
#
|
|
253
|
-
|
|
254
|
-
|
|
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.
|
|
255
301
|
def enum_values(stats)
|
|
256
|
-
stats.
|
|
302
|
+
established_values(stats).sort_by { |v, n| [-n, v] }.map(&:first)
|
|
257
303
|
end
|
|
258
304
|
|
|
259
305
|
# value_distribution returns the fraction of total observations each
|
|
@@ -357,8 +403,8 @@ module Iriq
|
|
|
357
403
|
if iri.urn?
|
|
358
404
|
ns, value = (iri.nss || "").split(":", 2)
|
|
359
405
|
derived = value ? urn_value_shape(ns, value, classifier) : nil
|
|
360
|
-
key = "
|
|
361
|
-
[key, nil,
|
|
406
|
+
key = "#{iri.scheme}:#{ns}:#{derived}"
|
|
407
|
+
[key, nil, iri.scheme, key]
|
|
362
408
|
else
|
|
363
409
|
shape ||= PathShape.new(classifier: classifier).for(iri.path_segments)
|
|
364
410
|
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)
|
data/lib/iriq/corpus.rb
CHANGED
|
@@ -232,8 +232,9 @@ module Iriq
|
|
|
232
232
|
def events_for(input)
|
|
233
233
|
iri = coerce(input)
|
|
234
234
|
hinted_entries = SegmentHints.derive(iri.path_segments, @classifier)
|
|
235
|
-
|
|
236
|
-
|
|
235
|
+
shape = Shape.from_entries(hinted_entries)
|
|
236
|
+
raw_shape = shape.render(hints: false)
|
|
237
|
+
hinted_shape = shape.render(hints: true)
|
|
237
238
|
keying_host = effective_host(iri.host)
|
|
238
239
|
|
|
239
240
|
events = [
|
data/lib/iriq/identifier.rb
CHANGED
data/lib/iriq/parser.rb
CHANGED
|
@@ -44,7 +44,11 @@ module Iriq
|
|
|
44
44
|
parse_authority_url(input, scheme, rest[2..])
|
|
45
45
|
else
|
|
46
46
|
# opaque scheme like mailto:foo@bar — keep nss, mark as urn-ish so we
|
|
47
|
-
# don't pretend we know its host/path layout.
|
|
47
|
+
# don't pretend we know its host/path layout. A bare scheme with no
|
|
48
|
+
# content ("mailto:") carries no identifier — and would canonicalize
|
|
49
|
+
# to "urn:", which itself fails to parse — so reject it.
|
|
50
|
+
raise ParseError, "opaque scheme missing content" if rest.empty?
|
|
51
|
+
|
|
48
52
|
Identifier.new(original: input, scheme: scheme, nss: rest, kind: :urn)
|
|
49
53
|
end
|
|
50
54
|
else
|
data/lib/iriq/position_stats.rb
CHANGED
|
@@ -79,8 +79,8 @@ module Iriq
|
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
# Most common type. On count ties, breaks lexicographically by type
|
|
82
|
-
# symbol name so the result is deterministic and matches
|
|
83
|
-
#
|
|
82
|
+
# symbol name so the result is deterministic and matches the Rust
|
|
83
|
+
# port's dominant_type regardless of map iteration order.
|
|
84
84
|
def dominant_type
|
|
85
85
|
best = nil
|
|
86
86
|
best_count = -1
|
data/lib/iriq/recognizer.rb
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
@@ -135,7 +135,7 @@ module Iriq
|
|
|
135
135
|
hosts: acc[:hosts],
|
|
136
136
|
coverage: coverage,
|
|
137
137
|
observation_count: acc[:matching_count],
|
|
138
|
-
# Sort + cap to 5 so Ruby and
|
|
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),
|
|
@@ -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
|
-
|
|
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
|
|
@@ -262,7 +265,7 @@ module Iriq
|
|
|
262
265
|
# Scored ensemble over the live Recognizer list — built-ins +
|
|
263
266
|
# anything Corpus#activate_proposal has registered for this
|
|
264
267
|
# classifier instance.
|
|
265
|
-
if (v = Recognizer.ensemble(segment,
|
|
268
|
+
if (v = Recognizer.ensemble(segment, @recognizers))
|
|
266
269
|
return v[:type]
|
|
267
270
|
end
|
|
268
271
|
|
data/lib/iriq/specificity.rb
CHANGED
|
@@ -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
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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.)
|
data/lib/iriq/storage/sqlite.rb
CHANGED
|
@@ -141,7 +141,7 @@ module Iriq
|
|
|
141
141
|
# concurrent open, and without busy_timeout set they fail
|
|
142
142
|
# immediately with SQLITE_BUSY.
|
|
143
143
|
@db.execute("PRAGMA busy_timeout = 30000")
|
|
144
|
-
|
|
144
|
+
enable_wal!
|
|
145
145
|
@db.execute("PRAGMA synchronous = NORMAL")
|
|
146
146
|
@db.execute("PRAGMA foreign_keys = ON")
|
|
147
147
|
@in_batch = false
|
|
@@ -151,8 +151,11 @@ module Iriq
|
|
|
151
151
|
@db.execute_batch(SCHEMA)
|
|
152
152
|
existing = @db.get_first_value("SELECT value FROM meta WHERE key = 'schema_version'")
|
|
153
153
|
if existing.nil?
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
# OR IGNORE: two processes can race to initialize a fresh corpus
|
|
155
|
+
# concurrently — both read schema_version as nil, and the loser's
|
|
156
|
+
# INSERT must not blow up on the PRIMARY KEY.
|
|
157
|
+
@db.execute("INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', ?)", SCHEMA_VERSION.to_s)
|
|
158
|
+
@db.execute("INSERT OR IGNORE INTO meta (key, value) VALUES ('max_values_per_position', ?)",
|
|
156
159
|
@max_values_per_position.to_s)
|
|
157
160
|
else
|
|
158
161
|
@max_values_per_position = (@db.get_first_value(
|
|
@@ -280,12 +283,18 @@ module Iriq
|
|
|
280
283
|
ON CONFLICT(key) DO UPDATE SET count = count + 1
|
|
281
284
|
SQL
|
|
282
285
|
|
|
283
|
-
# Examples — capped at Cluster::MAX_EXAMPLES
|
|
286
|
+
# Examples — capped at Cluster::MAX_EXAMPLES, deduped by canonical
|
|
287
|
+
# (mirrors Cluster#add so the SQLite view matches the in-memory one).
|
|
288
|
+
canonical = identifier.canonical
|
|
284
289
|
examples_count = @db.get_first_value(
|
|
285
290
|
"SELECT COUNT(*) FROM cluster_examples WHERE cluster_key = ?", [key],
|
|
286
291
|
)
|
|
287
|
-
|
|
288
|
-
|
|
292
|
+
already_present = @db.get_first_value(
|
|
293
|
+
"SELECT 1 FROM cluster_examples WHERE cluster_key = ? AND canonical = ?",
|
|
294
|
+
[key, canonical],
|
|
295
|
+
)
|
|
296
|
+
if examples_count < Cluster::MAX_EXAMPLES && already_present.nil?
|
|
297
|
+
@db.execute(<<~SQL, [key, examples_count, canonical])
|
|
289
298
|
INSERT INTO cluster_examples (cluster_key, position, canonical)
|
|
290
299
|
VALUES (?, ?, ?)
|
|
291
300
|
SQL
|
|
@@ -439,6 +448,7 @@ module Iriq
|
|
|
439
448
|
) { |r| tc[r[0].to_sym] = r[1] }
|
|
440
449
|
stats.instance_variable_set(:@type_counts, tc)
|
|
441
450
|
|
|
451
|
+
recompute_numeric!(stats)
|
|
442
452
|
stats
|
|
443
453
|
end
|
|
444
454
|
|
|
@@ -471,6 +481,24 @@ module Iriq
|
|
|
471
481
|
|
|
472
482
|
private
|
|
473
483
|
|
|
484
|
+
# Converting a rollback-mode database to WAL takes an exclusive lock,
|
|
485
|
+
# and SQLite does NOT consult the busy handler for that lock — so
|
|
486
|
+
# concurrent first-opens of a fresh corpus can fail with SQLITE_BUSY
|
|
487
|
+
# even with busy_timeout set. WAL is a persistent database property:
|
|
488
|
+
# retry briefly — either this connection wins the conversion or
|
|
489
|
+
# another process already converted the file.
|
|
490
|
+
def enable_wal!
|
|
491
|
+
attempts = 0
|
|
492
|
+
begin
|
|
493
|
+
@db.execute("PRAGMA journal_mode = WAL")
|
|
494
|
+
rescue SQLite3::BusyException
|
|
495
|
+
raise if (attempts += 1) > 100
|
|
496
|
+
|
|
497
|
+
sleep 0.01
|
|
498
|
+
retry
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
474
502
|
def upsert_shape(table, shape)
|
|
475
503
|
@db.execute(<<~SQL, shape)
|
|
476
504
|
INSERT INTO #{table} (shape, count) VALUES (?, 1)
|
|
@@ -537,10 +565,39 @@ module Iriq
|
|
|
537
565
|
stats = params[r[0]] or next
|
|
538
566
|
stats.type_counts[r[1].to_sym] = r[2]
|
|
539
567
|
end
|
|
568
|
+
params.each_value { |stats| recompute_numeric!(stats) }
|
|
540
569
|
c.instance_variable_set(:@param_stats, params)
|
|
541
570
|
|
|
542
571
|
c
|
|
543
572
|
end
|
|
573
|
+
|
|
574
|
+
# The rolling numeric aggregates (count/min/max/sum) aren't stored in
|
|
575
|
+
# the schema — rebuild them from the tracked value counts, mirroring
|
|
576
|
+
# the Rust backend: only positions with integer/float observations
|
|
577
|
+
# qualify, and cap-trimmed values are lost.
|
|
578
|
+
def recompute_numeric!(stats)
|
|
579
|
+
return if (stats.type_counts[:integer] + stats.type_counts[:float]).zero?
|
|
580
|
+
|
|
581
|
+
count = 0
|
|
582
|
+
min = nil
|
|
583
|
+
max = nil
|
|
584
|
+
sum = 0.0
|
|
585
|
+
stats.value_counts.each do |value, n|
|
|
586
|
+
num = Float(value, exception: false)
|
|
587
|
+
next unless num
|
|
588
|
+
|
|
589
|
+
count += n
|
|
590
|
+
min = num if min.nil? || num < min
|
|
591
|
+
max = num if max.nil? || num > max
|
|
592
|
+
n.times { sum += num }
|
|
593
|
+
end
|
|
594
|
+
return if count.zero?
|
|
595
|
+
|
|
596
|
+
stats.instance_variable_set(:@numeric_count, count)
|
|
597
|
+
stats.instance_variable_set(:@numeric_min, min)
|
|
598
|
+
stats.instance_variable_set(:@numeric_max, max)
|
|
599
|
+
stats.instance_variable_set(:@numeric_sum, sum)
|
|
600
|
+
end
|
|
544
601
|
end
|
|
545
602
|
end
|
|
546
603
|
end
|
data/lib/iriq/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: iriq
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.33.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel Pepper
|
|
@@ -86,16 +86,10 @@ extensions: []
|
|
|
86
86
|
extra_rdoc_files: []
|
|
87
87
|
files:
|
|
88
88
|
- CHANGELOG.md
|
|
89
|
-
- CLAUDE.md
|
|
90
|
-
- Gemfile
|
|
91
|
-
- Gemfile.lock
|
|
92
89
|
- LICENSE.txt
|
|
93
|
-
- Makefile
|
|
94
90
|
- README.md
|
|
95
91
|
- completions/_iriq
|
|
96
92
|
- completions/iriq.bash
|
|
97
|
-
- docs/ARCHITECTURE.md
|
|
98
|
-
- docs/ROADMAP.md
|
|
99
93
|
- exe/iriq
|
|
100
94
|
- iriq.gemspec
|
|
101
95
|
- lib/iriq.rb
|
|
@@ -153,7 +147,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
153
147
|
- !ruby/object:Gem::Version
|
|
154
148
|
version: '0'
|
|
155
149
|
requirements: []
|
|
156
|
-
rubygems_version:
|
|
150
|
+
rubygems_version: 3.6.9
|
|
157
151
|
specification_version: 4
|
|
158
152
|
summary: IRI extraction, normalization, and clustering.
|
|
159
153
|
test_files: []
|
data/CLAUDE.md
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
# Iriq development conventions
|
|
2
|
-
|
|
3
|
-
> **⚠️ Behavior changes touch ALL THREE runtimes.** Ruby is the reference; Go
|
|
4
|
-
> + Rust mirror it. Before committing any change to
|
|
5
|
-
> parser/normalizer/extractor/CLI/etc:
|
|
6
|
-
>
|
|
7
|
-
> 1. Update Ruby + specs.
|
|
8
|
-
> 2. `bundle exec ruby script/generate_fixtures.rb` (regenerate JSON parity fixtures).
|
|
9
|
-
> 3. Port the change to Go (the Go module lives in `go/`).
|
|
10
|
-
> 4. `go -C go test ./...` — fixture tests should still pass.
|
|
11
|
-
> 5. `make build && script/cli_parity.sh` — Ruby ↔ Go CLI parity should still pass.
|
|
12
|
-
> 6. Port the change to Rust under `rust/`.
|
|
13
|
-
> 7. `cd rust && cargo test --workspace` — Rust fixture tests should still pass
|
|
14
|
-
> (SQLite is a default feature).
|
|
15
|
-
> 8. `cd rust && cargo build --release --bin iriq && cd .. && script/rust_parity.sh`
|
|
16
|
-
> — Rust ↔ Go CLI parity (covers Ruby transitively).
|
|
17
|
-
> 9. Commit the regenerated fixtures alongside the code change.
|
|
18
|
-
>
|
|
19
|
-
> CI's parity + Rust jobs will fail if any step is skipped. The **Rust gate**
|
|
20
|
-
> (fmt + clippy + tests) is automated — run `make hooks` once to install the
|
|
21
|
-
> committed pre-push hook that runs `make check`. Full multi-runtime pre-push
|
|
22
|
-
> for a behavior change:
|
|
23
|
-
> `bundle exec rspec && go -C go test ./... && script/cli_parity.sh && make check && script/rust_parity.sh`.
|
|
24
|
-
|
|
25
|
-
## Repo layout — Ruby at the root, Go and Rust in subdirs
|
|
26
|
-
|
|
27
|
-
The Ruby gem lives at the repo root (it's the reference implementation and the
|
|
28
|
-
published gem); the two mirror implementations are compartmentalized into
|
|
29
|
-
`go/` and `rust/`. Earlier the Go code was intermixed at the root; it now sits
|
|
30
|
-
in `go/`, symmetric with `rust/`, so the root reads as "Ruby + two ports."
|
|
31
|
-
|
|
32
|
-
```
|
|
33
|
-
iriq/
|
|
34
|
-
lib/ exe/ spec/ ← Ruby gem (library, CLI, specs) — the reference
|
|
35
|
-
completions/ ← shell-completion scripts shipped by the gem
|
|
36
|
-
iriq.gemspec
|
|
37
|
-
Gemfile
|
|
38
|
-
|
|
39
|
-
go/ ← Go module github.com/dpep/iriq/go
|
|
40
|
-
go.mod go.sum
|
|
41
|
-
*.go ← Go package `iriq`
|
|
42
|
-
cmd/iriq/ ← Go CLI binary
|
|
43
|
-
completions/ ← Go's own embedded copy (go:embed can't reach ../)
|
|
44
|
-
|
|
45
|
-
rust/ ← Cargo workspace
|
|
46
|
-
Cargo.toml ← workspace root
|
|
47
|
-
iriq/ ← one crate: library + `iriq` CLI binary; inlines completions
|
|
48
|
-
REPORT.md ← Go → Rust port spike notes + perf
|
|
49
|
-
target/ ← Rust build artifacts (gitignored)
|
|
50
|
-
|
|
51
|
-
bin/ ← built Go binary (gitignored)
|
|
52
|
-
script/ ← shared dev scripts (fixture gen, parity, benches)
|
|
53
|
-
spec/fixtures/ ← golden JSON shared by Ruby specs + Go + Rust tests
|
|
54
|
-
.github/workflows/ ← Ruby CI, Go CI, Rust CI, parity CIs
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
Notes on this layout:
|
|
58
|
-
|
|
59
|
-
- Go's import path is now `github.com/dpep/iriq/go` (the `/go` suffix matches
|
|
60
|
-
the subdir). Consumers import `github.com/dpep/iriq/go`.
|
|
61
|
-
- One version tag (`vX.Y.Z`) serves all three runtimes — Ruby's gemspec, Go's
|
|
62
|
-
module, and Rust's `Cargo.toml` use the same tag stream.
|
|
63
|
-
- The gemspec ships only Ruby + `completions/`, excluding `go/` and `rust/`:
|
|
64
|
-
`git ls-files * ':!:spec' ':!:script' ':!:bin' ':!:rust' ':!:go'`.
|
|
65
|
-
- Completion scripts exist in three places (gem root `completions/`, `go/completions/`
|
|
66
|
-
for `go:embed`, and inlined in the Rust CLI) — keep them in sync like fixtures.
|
|
67
|
-
|
|
68
|
-
## Building
|
|
69
|
-
|
|
70
|
-
```sh
|
|
71
|
-
# Ruby gem
|
|
72
|
-
bundle install
|
|
73
|
-
bundle exec exe/iriq --help # runs the CLI from source
|
|
74
|
-
|
|
75
|
-
# Go binary — convenience targets in the Makefile
|
|
76
|
-
make build # → ./bin/iriq
|
|
77
|
-
make install # go install into $GOBIN
|
|
78
|
-
make uninstall # remove from $GOBIN
|
|
79
|
-
make clean # remove ./bin/
|
|
80
|
-
make test # go test ./...
|
|
81
|
-
|
|
82
|
-
# Rust — one crate (library + `iriq` binary), SQLite bundled by default
|
|
83
|
-
cd rust && cargo build --release --bin iriq # → ./rust/target/release/iriq
|
|
84
|
-
cd rust && cargo install --path iriq # install into ~/.cargo/bin
|
|
85
|
-
cd rust && cargo test --workspace
|
|
86
|
-
|
|
87
|
-
# Via Homebrew (builds the Rust CLI from main)
|
|
88
|
-
brew install dpep/tools/iriq
|
|
89
|
-
|
|
90
|
-
# Via crates.io
|
|
91
|
-
cargo install iriq
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
## Keeping the three runtimes in sync
|
|
95
|
-
|
|
96
|
-
Ruby is the **reference implementation**. Go and Rust mirror its public API
|
|
97
|
-
and behavior. Three layers of parity testing keep them aligned:
|
|
98
|
-
|
|
99
|
-
1. **Golden JSON fixtures** (`spec/fixtures/*.json`)
|
|
100
|
-
Generated by `script/generate_fixtures.rb` from the Ruby implementation
|
|
101
|
-
over a curated set of inputs. Go's `fixtures_test.go` and Rust's
|
|
102
|
-
`rust/iriq/tests/fixtures.rs` both load each file and assert the same
|
|
103
|
-
outputs.
|
|
104
|
-
|
|
105
|
-
2. **Ruby ↔ Go CLI parity harness** (`script/cli_parity.sh`)
|
|
106
|
-
Runs the same input through `bundle exec exe/iriq` and the Go binary and
|
|
107
|
-
diffs stdout. Lives in CI as the `Ruby ↔ Go parity` job.
|
|
108
|
-
|
|
109
|
-
3. **Rust ↔ Go CLI parity harness** (`script/rust_parity.sh`)
|
|
110
|
-
Same idea — runs every Phase 1 + Phase 2 scenario (single-input,
|
|
111
|
-
pipe-mode, JSON corpus, SQLite corpus, --stats, --reinfer,
|
|
112
|
-
--propose-recognizers, --cross-host-shapes, --host=reg) through the
|
|
113
|
-
Go and Rust binaries and diffs stdout. Lives in CI as the
|
|
114
|
-
`Rust ↔ Go parity` job. Rust transitively inherits Ruby parity via Go.
|
|
115
|
-
|
|
116
|
-
When changing behavior:
|
|
117
|
-
|
|
118
|
-
1. Update the Ruby code + specs first.
|
|
119
|
-
2. Regenerate fixtures: `bundle exec ruby script/generate_fixtures.rb`.
|
|
120
|
-
3. Port the change to Go.
|
|
121
|
-
4. `go test ./...` (uses the updated fixtures).
|
|
122
|
-
5. `script/cli_parity.sh` should pass.
|
|
123
|
-
6. Port the change to Rust under `rust/`.
|
|
124
|
-
7. `cd rust && cargo test --workspace`.
|
|
125
|
-
8. `cd rust && cargo build --release --bin iriq && cd .. && script/rust_parity.sh` should pass.
|
|
126
|
-
9. Commit fixtures with the change — CI will fail if they're stale.
|
|
127
|
-
|
|
128
|
-
## Tests
|
|
129
|
-
|
|
130
|
-
```sh
|
|
131
|
-
bundle exec rspec # Ruby suite (305+ examples)
|
|
132
|
-
go test ./... # Go suite (native + fixture parity)
|
|
133
|
-
script/cli_parity.sh # Ruby ↔ Go CLI parity
|
|
134
|
-
cd rust && cargo test --workspace
|
|
135
|
-
cd rust && cargo fmt --check # formatting (CI-gated)
|
|
136
|
-
cd rust && cargo clippy --workspace --all-targets -- -D warnings
|
|
137
|
-
make check # the three Rust checks above, in one shot
|
|
138
|
-
script/rust_parity.sh # Rust ↔ Go CLI parity (~59 scenarios)
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
## Releases
|
|
142
|
-
|
|
143
|
-
Versioning is single-stream: one `vX.Y.Z` covers all three runtimes. Bump the
|
|
144
|
-
three version constants **together** — the `--version` parity checks fail
|
|
145
|
-
if they drift:
|
|
146
|
-
|
|
147
|
-
1. `lib/iriq/version.rb` (`VERSION`), `go/version.go` (`Version`), and the two
|
|
148
|
-
`version = "X.Y.Z"` / `pub const VERSION` lines in `rust/iriq/Cargo.toml` and
|
|
149
|
-
`rust/iriq/src/lib.rs` — same string.
|
|
150
|
-
2. `Gemfile.lock` — re-resolve so the pinned `iriq (X.Y.Z)` matches
|
|
151
|
-
(`bundle install`, or it regenerates on the next `bundle exec`). Commit it.
|
|
152
|
-
3. Run `cd rust && cargo update -p iriq` to refresh `Cargo.lock`.
|
|
153
|
-
4. Tag `vX.Y.Z` and push. Go consumers pick it up via
|
|
154
|
-
`go get github.com/dpep/iriq/go@vX.Y.Z`.
|
|
155
|
-
5. `gem push iriq-X.Y.Z.gem` to publish to RubyGems.
|
|
156
|
-
6. `cd rust && cargo publish -p iriq` to publish to crates.io (the crate ships
|
|
157
|
-
both the library and the `iriq` binary).
|
|
158
|
-
|
|
159
|
-
### Keep Homebrew in sync — bump on EVERY version change
|
|
160
|
-
|
|
161
|
-
The tap (`~/code/lib/homebrew-tools`) ships a single `Formula/iriq.rb` that
|
|
162
|
-
builds the Rust CLI (`cargo install --path rust/iriq`) from `branch: "main"`.
|
|
163
|
-
SQLite is on by default (the `iriq` crate's `default` feature set), so there is
|
|
164
|
-
no longer a separate `iriq-sqlite` formula.
|
|
165
|
-
|
|
166
|
-
The formula pins a static `version "X.Y.Z"` label. Because the build tracks
|
|
167
|
-
`main` rather than a tagged tarball, `brew upgrade` only rebuilds when that
|
|
168
|
-
label changes. So on every bump here, update the `version` string in
|
|
169
|
-
`Formula/iriq.rb` to match `version.rb`, then commit + push the tap. Leaving it
|
|
170
|
-
stale means brew users never get the new code even though it's already on `main`.
|
|
171
|
-
|
|
172
|
-
## Corpus storage backends
|
|
173
|
-
|
|
174
|
-
The `Corpus` class delegates state to a `Storage` backend; three backends ship:
|
|
175
|
-
|
|
176
|
-
- **Memory** — default, in-process only.
|
|
177
|
-
- **JSON** — Memory wrapped with atomic load/save against a JSON file
|
|
178
|
-
(`.json` by default). Same shape both runtimes have always written.
|
|
179
|
-
- **SQLite** — incremental UPSERTs against a `.db` / `.sqlite` / `.sqlite3`
|
|
180
|
-
file with WAL journaling. Supports concurrent observers and avoids
|
|
181
|
-
loading the whole corpus into memory.
|
|
182
|
-
|
|
183
|
-
`Corpus.open(path)` (Ruby) / `iriq.OpenCorpus(path)` (Go) picks the backend
|
|
184
|
-
by file extension. `corpus.save(other_path)` exports as JSON regardless of
|
|
185
|
-
the live backend; `corpus.save(same_path)` is idempotent (no clobbering a
|
|
186
|
-
SQLite file with JSON, etc.).
|
|
187
|
-
|
|
188
|
-
The Ruby `sqlite3` gem is loaded lazily (only when a `.db` path is opened),
|
|
189
|
-
keeping the iriq install footprint minimal for users that stick with JSON.
|
|
190
|
-
On the Go side we use `modernc.org/sqlite` (pure Go — no cgo). The Rust
|
|
191
|
-
side uses `rusqlite` with the `bundled` feature (statically links C SQLite,
|
|
192
|
-
~3-4 MB binary cost). Schema v4 is shared across all three runtimes — a
|
|
193
|
-
`.db` written by any binary opens cleanly in any other.
|
|
194
|
-
|
|
195
|
-
When adding a new backend, replicate the contract in all three languages
|
|
196
|
-
and add parity scenarios in `script/cli_parity.sh`'s `corpus_pair`
|
|
197
|
-
section + `script/rust_parity.sh`'s `corpus_pair`.
|
|
198
|
-
|
|
199
|
-
## What lives where in scripts
|
|
200
|
-
|
|
201
|
-
- `script/benchmark.rb` — Ruby-only throughput benchmark.
|
|
202
|
-
- `script/memory.rb` — Ruby-only memory profile.
|
|
203
|
-
- `script/generate_fixtures.rb` — produces `spec/fixtures/*.json` for cross-runtime parity.
|
|
204
|
-
- `script/cli_parity.sh` — Ruby ↔ Go CLI diff.
|
|
205
|
-
- `script/rust_parity.sh` — Rust ↔ Go CLI diff.
|
|
206
|
-
- `script/bench_three_way.sh` — Go vs Rust wall-clock comparison.
|
|
207
|
-
- `script/bench_compare.sh` — Ruby vs Go CLI wall-time comparison.
|
|
208
|
-
- `script/bench_storage.sh` — JSON vs SQLite backend timing (single-process, incremental, concurrent).
|
data/Gemfile
DELETED