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.
@@ -0,0 +1,35 @@
1
+ module Iriq
2
+ # Per-Recognizer claim strength. Higher specificity wins when multiple
3
+ # Recognizers fire on the same segment; the ensemble picks the
4
+ # max(specificity × confidence).
5
+ #
6
+ # The bands below capture the current type taxonomy at coarse-grain:
7
+ # they're explicitly NOT linear "how confident" scores. They encode "how
8
+ # surprising would it be for this Recognizer to fire by accident on a
9
+ # different actual type." UUID's shape is so distinctive that a non-UUID
10
+ # producing that string is vanishingly unlikely (SEMANTIC); a 4-digit
11
+ # integer could plausibly be a year, an HTTP status, or an ID, so
12
+ # `:integer` claims only TYPED.
13
+ #
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
+ module Specificity
18
+ # Unambiguous semantic shapes — the regex effectively can't fire by
19
+ # accident. (UUID, JWT, email with @, URL with ://, color hex.)
20
+ SEMANTIC = 1.0
21
+ # Restrictive structured patterns. Could collide with broader types
22
+ # at edges. (date, file with known ext, ipv4, mime.)
23
+ STRUCTURED = 0.8
24
+ # Digit-shaped with an additional bound — range or allowlist — that
25
+ # makes the shape alone meaningful. (timestamp, currency, country,
26
+ # boolean.)
27
+ BOUNDED = 0.7
28
+ # Lexically broad but typed. (integer, float, version.)
29
+ TYPED = 0.5
30
+ # Generic pattern-based shape. (slug.)
31
+ PATTERN = 0.3
32
+ # Generic fallback shapes. (literal, opaque_id.)
33
+ FALLBACK = 0.1
34
+ end
35
+ end
@@ -9,11 +9,15 @@ module Iriq
9
9
  # increment_path_length(length)
10
10
  # increment_raw_shape(shape)
11
11
  # increment_fingerprint(shape)
12
- # observe_position(host, prefix, value, type)
12
+ # observe_position(position, value, type) # position is Iriq::Position
13
13
  # add_to_cluster(key, host, scheme, shape, identifier)
14
+ # record_observation(canonical) # append to source-IRI log
14
15
  #
15
16
  # host_counts / path_length_counts / raw_shape_counts / fingerprint_counts
16
- # position_stats(host, prefix)
17
+ # position_stats(position)
18
+ # each_position_stats { |position, stats| ... }
19
+ # each_observed_iri { |canonical| ... }
20
+ # clear_materialized_views # for reinfer
17
21
  # clusters / cluster_size
18
22
  #
19
23
  # transaction { ... } # backends may batch within
@@ -36,6 +40,15 @@ module Iriq
36
40
  @fingerprint_counts = Hash.new(0)
37
41
  @position_stats = {}
38
42
  @clusters = {}
43
+ # The source-IRI log. Persisted alongside materialized views; the
44
+ # log is the source of truth, the views are derived. Corpus#reinfer
45
+ # drops the views and replays the log through events + reducers.
46
+ @observed_iris = []
47
+ # Recognizers promoted from RecognizerProposal via
48
+ # Corpus#activate_proposal. Stored as {prefix, type, specificity}
49
+ # hashes so reopens can re-synthesize them onto the corpus's
50
+ # classifier.
51
+ @activated_recognizers = []
39
52
  end
40
53
 
41
54
  def transaction
@@ -70,17 +83,61 @@ module Iriq
70
83
  @fingerprint_counts[shape] += 1
71
84
  end
72
85
 
73
- def observe_position(host, prefix, value, type)
74
- stats = @position_stats[[host, prefix]] ||= PositionStats.new(max_values: @max_values_per_position)
86
+ def observe_position(position, value, type)
87
+ stats = @position_stats[position] ||= PositionStats.new(max_values: @max_values_per_position)
75
88
  stats.observe(value, type)
76
89
  end
77
90
 
78
91
  def add_to_cluster(key, host, scheme, shape, identifier)
79
- cluster = @clusters[key] ||= Cluster.new(key: key, host: host, scheme: scheme, shape: shape)
80
- cluster.add(identifier)
92
+ cluster = @clusters[key] ||= Cluster.new(
93
+ key: key, host: host, scheme: scheme, shape: shape,
94
+ max_values: @max_values_per_position,
95
+ )
96
+ cluster.add(identifier, classifier: @classifier)
81
97
  cluster
82
98
  end
83
99
 
100
+ # Append a canonical IRI to the source-IRI log. Called by Corpus#observe
101
+ # after the event reducers have applied; the log is the source of truth
102
+ # that Corpus#reinfer replays.
103
+ def record_observation(canonical)
104
+ @observed_iris << canonical
105
+ end
106
+
107
+ def each_observed_iri(&block)
108
+ @observed_iris.each(&block)
109
+ end
110
+
111
+ def observed_iri_count
112
+ @observed_iris.size
113
+ end
114
+
115
+ # --- Activated recognizers (Corpus#activate_proposal) -----------------
116
+
117
+ def record_activated_recognizer(dump)
118
+ @activated_recognizers << dump
119
+ end
120
+
121
+ def each_activated_recognizer(&block)
122
+ @activated_recognizers.each(&block)
123
+ end
124
+
125
+ def activated_recognizer_count
126
+ @activated_recognizers.size
127
+ end
128
+
129
+ # Drop every materialized view (host_counts, position_stats, clusters,
130
+ # …) without touching the source-IRI log. Corpus#reinfer calls this
131
+ # before replaying the log so views rebuild from scratch.
132
+ def clear_materialized_views
133
+ @host_counts = Hash.new(0)
134
+ @path_length_counts = Hash.new(0)
135
+ @raw_shape_counts = Hash.new(0)
136
+ @fingerprint_counts = Hash.new(0)
137
+ @position_stats = {}
138
+ @clusters = {}
139
+ end
140
+
84
141
  # --- Reads ------------------------------------------------------------
85
142
 
86
143
  def host_counts; @host_counts; end
@@ -88,8 +145,8 @@ module Iriq
88
145
  def raw_shape_counts; @raw_shape_counts; end
89
146
  def fingerprint_counts; @fingerprint_counts; end
90
147
 
91
- def position_stats(host, prefix)
92
- @position_stats[[host, prefix]]
148
+ def position_stats(position)
149
+ @position_stats[position]
93
150
  end
94
151
 
95
152
  def each_position_stats(&block)
@@ -104,6 +161,13 @@ module Iriq
104
161
  @clusters.size
105
162
  end
106
163
 
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.
167
+ def cluster_for(key)
168
+ @clusters[key]
169
+ end
170
+
107
171
  # --- Bulk load (used by JSON backend) --------------------------------
108
172
 
109
173
  def load_dump!(h)
@@ -112,11 +176,14 @@ module Iriq
112
176
  @raw_shape_counts = Hash.new(0).merge(h["raw_shape_counts"])
113
177
  @fingerprint_counts = Hash.new(0).merge(h["fingerprint_counts"])
114
178
  @max_values_per_position = h.fetch("max_values_per_position", PositionStats::DEFAULT_MAX_VALUES)
115
- @position_stats = h["position_stats"].each_with_object({}) do |(host, prefix, sdump), acc|
116
- acc[[host, prefix]] = PositionStats.from_dump(sdump)
179
+ @position_stats = h["position_stats"].each_with_object({}) do |entry, acc|
180
+ position = Position.from_dump(entry["position"])
181
+ acc[position] = PositionStats.from_dump(entry["stats"])
117
182
  end
118
183
  cdump = h.fetch("clusterer", { "clusters" => {} })
119
- @clusters = cdump["clusters"].transform_values { |c| Cluster.from_dump(c) }
184
+ @clusters = cdump["clusters"].transform_values { |c| Cluster.from_dump(c, max_values: @max_values_per_position) }
185
+ @observed_iris = h.fetch("observed_iris", [])
186
+ @activated_recognizers = h.fetch("activated_recognizers", [])
120
187
  self
121
188
  end
122
189
 
@@ -127,10 +194,14 @@ module Iriq
127
194
  "raw_shape_counts" => @raw_shape_counts,
128
195
  "fingerprint_counts" => @fingerprint_counts,
129
196
  "max_values_per_position" => @max_values_per_position,
130
- "position_stats" => @position_stats.map { |(host, prefix), s| [host, prefix, s.dump] },
197
+ "position_stats" => @position_stats.map { |pos, s|
198
+ { "position" => pos.to_dump, "stats" => s.dump }
199
+ },
131
200
  "clusterer" => {
132
201
  "clusters" => @clusters.transform_values(&:dump),
133
202
  },
203
+ "observed_iris" => @observed_iris,
204
+ "activated_recognizers" => @activated_recognizers,
134
205
  }
135
206
  end
136
207
  end
@@ -11,7 +11,7 @@ module Iriq
11
11
  # the existing `iriq --corpus c.db <url>` pattern works without a flock
12
12
  # at the application layer.
13
13
  class Sqlite
14
- SCHEMA_VERSION = 1
14
+ SCHEMA_VERSION = 4
15
15
 
16
16
  SCHEMA = <<~SQL.freeze
17
17
  CREATE TABLE IF NOT EXISTS meta (
@@ -34,25 +34,33 @@ module Iriq
34
34
  shape TEXT PRIMARY KEY,
35
35
  count INTEGER NOT NULL
36
36
  );
37
+ -- Position is (host, scope, locator). For scope='path' the locator
38
+ -- is the typed prefix; for scope='query' it's the param name.
39
+ -- Today only 'path' is observed here (query params live on the
40
+ -- cluster_* tables) — scope is in the schema so future commits
41
+ -- can fold query positions in without another migration.
37
42
  CREATE TABLE IF NOT EXISTS position_stats (
38
- host TEXT NOT NULL,
39
- prefix TEXT NOT NULL,
40
- total INTEGER NOT NULL DEFAULT 0,
41
- PRIMARY KEY (host, prefix)
43
+ host TEXT NOT NULL,
44
+ scope TEXT NOT NULL,
45
+ locator TEXT NOT NULL,
46
+ total INTEGER NOT NULL DEFAULT 0,
47
+ PRIMARY KEY (host, scope, locator)
42
48
  );
43
49
  CREATE TABLE IF NOT EXISTS position_values (
44
- host TEXT NOT NULL,
45
- prefix TEXT NOT NULL,
46
- value TEXT NOT NULL,
47
- count INTEGER NOT NULL,
48
- PRIMARY KEY (host, prefix, value)
50
+ host TEXT NOT NULL,
51
+ scope TEXT NOT NULL,
52
+ locator TEXT NOT NULL,
53
+ value TEXT NOT NULL,
54
+ count INTEGER NOT NULL,
55
+ PRIMARY KEY (host, scope, locator, value)
49
56
  );
50
57
  CREATE TABLE IF NOT EXISTS position_types (
51
- host TEXT NOT NULL,
52
- prefix TEXT NOT NULL,
53
- type TEXT NOT NULL,
54
- count INTEGER NOT NULL,
55
- PRIMARY KEY (host, prefix, type)
58
+ host TEXT NOT NULL,
59
+ scope TEXT NOT NULL,
60
+ locator TEXT NOT NULL,
61
+ type TEXT NOT NULL,
62
+ count INTEGER NOT NULL,
63
+ PRIMARY KEY (host, scope, locator, type)
56
64
  );
57
65
  CREATE TABLE IF NOT EXISTS clusters (
58
66
  key TEXT PRIMARY KEY,
@@ -75,6 +83,44 @@ module Iriq
75
83
  count INTEGER NOT NULL,
76
84
  PRIMARY KEY (cluster_key, position, value)
77
85
  );
86
+ CREATE TABLE IF NOT EXISTS cluster_params (
87
+ cluster_key TEXT NOT NULL,
88
+ name TEXT NOT NULL,
89
+ total INTEGER NOT NULL DEFAULT 0,
90
+ PRIMARY KEY (cluster_key, name)
91
+ );
92
+ CREATE TABLE IF NOT EXISTS cluster_param_values (
93
+ cluster_key TEXT NOT NULL,
94
+ name TEXT NOT NULL,
95
+ value TEXT NOT NULL,
96
+ count INTEGER NOT NULL,
97
+ PRIMARY KEY (cluster_key, name, value)
98
+ );
99
+ CREATE TABLE IF NOT EXISTS cluster_param_types (
100
+ cluster_key TEXT NOT NULL,
101
+ name TEXT NOT NULL,
102
+ type TEXT NOT NULL,
103
+ count INTEGER NOT NULL,
104
+ PRIMARY KEY (cluster_key, name, type)
105
+ );
106
+ -- Source-IRI log. The materialized views above are derived from
107
+ -- this log via events + reducers. Corpus#reinfer drops the views
108
+ -- and replays the log to rebuild them. id is monotonic so
109
+ -- iteration order is observation order.
110
+ CREATE TABLE IF NOT EXISTS observed_iris (
111
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
112
+ canonical TEXT NOT NULL
113
+ );
114
+ -- Recognizers promoted from RecognizerProposal via
115
+ -- Corpus#activate_proposal. Re-applied to the corpus's
116
+ -- classifier on Corpus.open so a reopen picks up its learned
117
+ -- patterns. Keyed by prefix; activating the same prefix twice
118
+ -- is a no-op.
119
+ CREATE TABLE IF NOT EXISTS activated_recognizers (
120
+ prefix TEXT PRIMARY KEY,
121
+ type TEXT NOT NULL,
122
+ specificity REAL NOT NULL DEFAULT 1.0
123
+ );
78
124
  SQL
79
125
 
80
126
  attr_reader :path, :max_values_per_position
@@ -95,7 +141,7 @@ module Iriq
95
141
  # concurrent open, and without busy_timeout set they fail
96
142
  # immediately with SQLITE_BUSY.
97
143
  @db.execute("PRAGMA busy_timeout = 30000")
98
- @db.execute("PRAGMA journal_mode = WAL")
144
+ enable_wal!
99
145
  @db.execute("PRAGMA synchronous = NORMAL")
100
146
  @db.execute("PRAGMA foreign_keys = ON")
101
147
  @in_batch = false
@@ -105,8 +151,11 @@ module Iriq
105
151
  @db.execute_batch(SCHEMA)
106
152
  existing = @db.get_first_value("SELECT value FROM meta WHERE key = 'schema_version'")
107
153
  if existing.nil?
108
- @db.execute("INSERT INTO meta (key, value) VALUES ('schema_version', ?)", SCHEMA_VERSION.to_s)
109
- @db.execute("INSERT INTO meta (key, value) VALUES ('max_values_per_position', ?)",
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', ?)",
110
159
  @max_values_per_position.to_s)
111
160
  else
112
161
  @max_values_per_position = (@db.get_first_value(
@@ -188,36 +237,38 @@ module Iriq
188
237
  upsert_shape("fingerprint_counts", shape)
189
238
  end
190
239
 
191
- def observe_position(host, prefix, value, type)
192
- host ||= ""
193
- @db.execute(<<~SQL, [host, prefix])
194
- INSERT INTO position_stats (host, prefix, total) VALUES (?, ?, 1)
195
- ON CONFLICT(host, prefix) DO UPDATE SET total = total + 1
240
+ def observe_position(position, value, type)
241
+ host = position.host || ""
242
+ scope = position.scope.to_s
243
+ locator = position.locator
244
+ @db.execute(<<~SQL, [host, scope, locator])
245
+ INSERT INTO position_stats (host, scope, locator, total) VALUES (?, ?, ?, 1)
246
+ ON CONFLICT(host, scope, locator) DO UPDATE SET total = total + 1
196
247
  SQL
197
248
 
198
249
  # Type counts are unbounded — always upsert.
199
- @db.execute(<<~SQL, [host, prefix, type.to_s])
200
- INSERT INTO position_types (host, prefix, type, count) VALUES (?, ?, ?, 1)
201
- ON CONFLICT(host, prefix, type) DO UPDATE SET count = count + 1
250
+ @db.execute(<<~SQL, [host, scope, locator, type.to_s])
251
+ INSERT INTO position_types (host, scope, locator, type, count) VALUES (?, ?, ?, ?, 1)
252
+ ON CONFLICT(host, scope, locator, type) DO UPDATE SET count = count + 1
202
253
  SQL
203
254
 
204
255
  # Value counts are capped at max_values_per_position. If the value
205
256
  # already exists, increment it; otherwise insert only when
206
257
  # cardinality is below the cap. Two-step rather than ON CONFLICT
207
258
  # because we need to enforce the cap on insert.
208
- @db.execute(<<~SQL, [host, prefix, value])
259
+ @db.execute(<<~SQL, [host, scope, locator, value])
209
260
  UPDATE position_values SET count = count + 1
210
- WHERE host = ? AND prefix = ? AND value = ?
261
+ WHERE host = ? AND scope = ? AND locator = ? AND value = ?
211
262
  SQL
212
263
  if @db.changes.zero?
213
264
  card = @db.get_first_value(
214
- "SELECT COUNT(*) FROM position_values WHERE host = ? AND prefix = ?",
215
- [host, prefix],
265
+ "SELECT COUNT(*) FROM position_values WHERE host = ? AND scope = ? AND locator = ?",
266
+ [host, scope, locator],
216
267
  )
217
268
  if card < @max_values_per_position
218
269
  @db.execute(
219
- "INSERT INTO position_values (host, prefix, value, count) VALUES (?, ?, ?, 1)",
220
- [host, prefix, value],
270
+ "INSERT INTO position_values (host, scope, locator, value, count) VALUES (?, ?, ?, ?, 1)",
271
+ [host, scope, locator, value],
221
272
  )
222
273
  end
223
274
  end
@@ -232,12 +283,18 @@ module Iriq
232
283
  ON CONFLICT(key) DO UPDATE SET count = count + 1
233
284
  SQL
234
285
 
235
- # 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
236
289
  examples_count = @db.get_first_value(
237
290
  "SELECT COUNT(*) FROM cluster_examples WHERE cluster_key = ?", [key],
238
291
  )
239
- if examples_count < Cluster::MAX_EXAMPLES
240
- @db.execute(<<~SQL, [key, examples_count, identifier.canonical])
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])
241
298
  INSERT INTO cluster_examples (cluster_key, position, canonical)
242
299
  VALUES (?, ?, ?)
243
300
  SQL
@@ -251,9 +308,99 @@ module Iriq
251
308
  SQL
252
309
  end
253
310
 
311
+ # Per-param stats (presence + value cardinality + type) — mirrors the
312
+ # in-memory Cluster#add path. Value table respects the same per-key
313
+ # cap as position_values.
314
+ (identifier.query_params || {}).each do |name, value|
315
+ v = value.to_s
316
+ type = @classifier.classify(v).to_s
317
+
318
+ @db.execute(<<~SQL, [key, name])
319
+ INSERT INTO cluster_params (cluster_key, name, total) VALUES (?, ?, 1)
320
+ ON CONFLICT(cluster_key, name) DO UPDATE SET total = total + 1
321
+ SQL
322
+ @db.execute(<<~SQL, [key, name, type])
323
+ INSERT INTO cluster_param_types (cluster_key, name, type, count) VALUES (?, ?, ?, 1)
324
+ ON CONFLICT(cluster_key, name, type) DO UPDATE SET count = count + 1
325
+ SQL
326
+
327
+ @db.execute(<<~SQL, [key, name, v])
328
+ UPDATE cluster_param_values SET count = count + 1
329
+ WHERE cluster_key = ? AND name = ? AND value = ?
330
+ SQL
331
+ if @db.changes.zero?
332
+ card = @db.get_first_value(
333
+ "SELECT COUNT(*) FROM cluster_param_values WHERE cluster_key = ? AND name = ?",
334
+ [key, name],
335
+ )
336
+ if card < @max_values_per_position
337
+ @db.execute(
338
+ "INSERT INTO cluster_param_values (cluster_key, name, value, count) VALUES (?, ?, ?, 1)",
339
+ [key, name, v],
340
+ )
341
+ end
342
+ end
343
+ end
344
+
254
345
  load_cluster(key)
255
346
  end
256
347
 
348
+ # Append a canonical IRI to the source-IRI log. Inside the same
349
+ # transaction as the event reducers, so the log and views stay
350
+ # consistent.
351
+ def record_observation(canonical)
352
+ @db.execute("INSERT INTO observed_iris (canonical) VALUES (?)", [canonical])
353
+ end
354
+
355
+ def each_observed_iri
356
+ @db.execute("SELECT canonical FROM observed_iris ORDER BY id") do |row|
357
+ yield row[0]
358
+ end
359
+ end
360
+
361
+ def observed_iri_count
362
+ @db.get_first_value("SELECT COUNT(*) FROM observed_iris") || 0
363
+ end
364
+
365
+ # --- Activated recognizers --------------------------------------------
366
+
367
+ def record_activated_recognizer(dump)
368
+ @db.execute(<<~SQL, [dump["prefix"], dump["type"], dump.fetch("specificity", 1.0)])
369
+ INSERT INTO activated_recognizers (prefix, type, specificity) VALUES (?, ?, ?)
370
+ ON CONFLICT(prefix) DO UPDATE SET type = excluded.type, specificity = excluded.specificity
371
+ SQL
372
+ end
373
+
374
+ def each_activated_recognizer
375
+ @db.execute("SELECT prefix, type, specificity FROM activated_recognizers ORDER BY prefix") do |row|
376
+ yield({ "prefix" => row[0], "type" => row[1], "specificity" => row[2] })
377
+ end
378
+ end
379
+
380
+ def activated_recognizer_count
381
+ @db.get_first_value("SELECT COUNT(*) FROM activated_recognizers") || 0
382
+ end
383
+
384
+ # Drop every materialized view without touching the source-IRI log.
385
+ # Corpus#reinfer calls this before replaying the log.
386
+ def clear_materialized_views
387
+ @db.execute_batch(<<~SQL)
388
+ DELETE FROM host_counts;
389
+ DELETE FROM path_length_counts;
390
+ DELETE FROM raw_shape_counts;
391
+ DELETE FROM fingerprint_counts;
392
+ DELETE FROM position_stats;
393
+ DELETE FROM position_values;
394
+ DELETE FROM position_types;
395
+ DELETE FROM clusters;
396
+ DELETE FROM cluster_examples;
397
+ DELETE FROM cluster_segments;
398
+ DELETE FROM cluster_params;
399
+ DELETE FROM cluster_param_values;
400
+ DELETE FROM cluster_param_types;
401
+ SQL
402
+ end
403
+
257
404
  # --- Reads ------------------------------------------------------------
258
405
 
259
406
  def host_counts
@@ -274,10 +421,13 @@ module Iriq
274
421
  rows_to_count_hash("fingerprint_counts", "shape")
275
422
  end
276
423
 
277
- def position_stats(host, prefix)
278
- host ||= ""
424
+ def position_stats(position)
425
+ host = position.host || ""
426
+ scope = position.scope.to_s
427
+ locator = position.locator
279
428
  total = @db.get_first_value(
280
- "SELECT total FROM position_stats WHERE host = ? AND prefix = ?", [host, prefix],
429
+ "SELECT total FROM position_stats WHERE host = ? AND scope = ? AND locator = ?",
430
+ [host, scope, locator],
281
431
  )
282
432
  return nil if total.nil?
283
433
 
@@ -286,25 +436,31 @@ module Iriq
286
436
 
287
437
  vc = Hash.new(0)
288
438
  @db.execute(
289
- "SELECT value, count FROM position_values WHERE host = ? AND prefix = ?", [host, prefix]
439
+ "SELECT value, count FROM position_values WHERE host = ? AND scope = ? AND locator = ?",
440
+ [host, scope, locator],
290
441
  ) { |r| vc[r[0]] = r[1] }
291
442
  stats.instance_variable_set(:@value_counts, vc)
292
443
 
293
444
  tc = Hash.new(0)
294
445
  @db.execute(
295
- "SELECT type, count FROM position_types WHERE host = ? AND prefix = ?", [host, prefix]
446
+ "SELECT type, count FROM position_types WHERE host = ? AND scope = ? AND locator = ?",
447
+ [host, scope, locator],
296
448
  ) { |r| tc[r[0].to_sym] = r[1] }
297
449
  stats.instance_variable_set(:@type_counts, tc)
298
450
 
451
+ recompute_numeric!(stats)
299
452
  stats
300
453
  end
301
454
 
302
455
  def each_position_stats
303
456
  seen = []
304
- @db.execute("SELECT DISTINCT host, prefix FROM position_stats ORDER BY ROWID") do |row|
457
+ @db.execute("SELECT DISTINCT host, scope, locator FROM position_stats ORDER BY ROWID") do |row|
305
458
  seen << row
306
459
  end
307
- seen.each { |host, prefix| yield [host, prefix], position_stats(host, prefix) }
460
+ seen.each do |host, scope, locator|
461
+ pos = Position.new(host: host, scope: scope.to_sym, locator: locator)
462
+ yield pos, position_stats(pos)
463
+ end
308
464
  end
309
465
 
310
466
  def clusters
@@ -319,8 +475,30 @@ module Iriq
319
475
  @db.get_first_value("SELECT COUNT(*) FROM clusters")
320
476
  end
321
477
 
478
+ def cluster_for(key)
479
+ load_cluster(key)
480
+ end
481
+
322
482
  private
323
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
+
324
502
  def upsert_shape(table, shape)
325
503
  @db.execute(<<~SQL, shape)
326
504
  INSERT INTO #{table} (shape, count) VALUES (?, 1)
@@ -340,7 +518,10 @@ module Iriq
340
518
  )
341
519
  return nil unless row
342
520
 
343
- c = Cluster.new(key: row[0], host: row[1], scheme: row[2], shape: row[3])
521
+ c = Cluster.new(
522
+ key: row[0], host: row[1], scheme: row[2], shape: row[3],
523
+ max_values: @max_values_per_position,
524
+ )
344
525
  c.instance_variable_set(:@count, row[4])
345
526
 
346
527
  examples = []
@@ -360,8 +541,63 @@ module Iriq
360
541
  end
361
542
  c.instance_variable_set(:@segment_counts, seg_counts)
362
543
 
544
+ # Rebuild @param_stats from the three cluster_param_* tables.
545
+ params = {}
546
+ @db.execute(
547
+ "SELECT name, total FROM cluster_params WHERE cluster_key = ?", [key],
548
+ ) do |r|
549
+ # PositionStats.new already initializes empty Hash.new(0) for value
550
+ # and type counts; only @total needs filling here. The followup
551
+ # SELECTs below populate value/type rows in place.
552
+ stats = PositionStats.new(max_values: @max_values_per_position)
553
+ stats.instance_variable_set(:@total, r[1])
554
+ params[r[0]] = stats
555
+ end
556
+ @db.execute(
557
+ "SELECT name, value, count FROM cluster_param_values WHERE cluster_key = ?", [key],
558
+ ) do |r|
559
+ stats = params[r[0]] or next
560
+ stats.value_counts[r[1]] = r[2]
561
+ end
562
+ @db.execute(
563
+ "SELECT name, type, count FROM cluster_param_types WHERE cluster_key = ?", [key],
564
+ ) do |r|
565
+ stats = params[r[0]] or next
566
+ stats.type_counts[r[1].to_sym] = r[2]
567
+ end
568
+ params.each_value { |stats| recompute_numeric!(stats) }
569
+ c.instance_variable_set(:@param_stats, params)
570
+
363
571
  c
364
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
365
601
  end
366
602
  end
367
603
  end