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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -0
- data/README.md +259 -356
- data/completions/_iriq +54 -0
- data/completions/iriq.bash +70 -0
- data/iriq.gemspec +2 -2
- data/lib/iriq/cli.rb +502 -50
- data/lib/iriq/cluster.rb +332 -14
- data/lib/iriq/clusterer.rb +0 -11
- data/lib/iriq/corpus.rb +321 -38
- data/lib/iriq/cross_host_shape.rb +37 -0
- data/lib/iriq/event.rb +22 -0
- data/lib/iriq/evidence.rb +114 -0
- data/lib/iriq/explanation.rb +1 -1
- data/lib/iriq/identifier.rb +1 -1
- data/lib/iriq/normalizer.rb +71 -29
- data/lib/iriq/parser.rb +5 -1
- data/lib/iriq/path_shape.rb +30 -24
- data/lib/iriq/position.rb +75 -0
- data/lib/iriq/position_stats.rb +74 -8
- data/lib/iriq/recognizer.rb +54 -0
- data/lib/iriq/recognizer_proposal.rb +167 -0
- data/lib/iriq/recognizers/date.rb +53 -0
- data/lib/iriq/recognizers/integer.rb +37 -0
- data/lib/iriq/recognizers/uuid.rb +16 -0
- data/lib/iriq/reducer.rb +37 -0
- data/lib/iriq/registrable_domain.rb +56 -0
- data/lib/iriq/segment_classifier.rb +478 -23
- data/lib/iriq/segment_hints.rb +9 -0
- data/lib/iriq/shape.rb +106 -0
- data/lib/iriq/specificity.rb +35 -0
- data/lib/iriq/storage/memory.rb +83 -12
- data/lib/iriq/storage/sqlite.rb +279 -43
- data/lib/iriq/synthesized_recognizer.rb +56 -0
- data/lib/iriq/trace.rb +294 -0
- data/lib/iriq/version.rb +1 -1
- data/lib/iriq.rb +17 -0
- metadata +19 -6
- data/CLAUDE.md +0 -121
- data/Gemfile +0 -3
- data/Gemfile.lock +0 -103
- data/Makefile +0 -56
|
@@ -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
|
data/lib/iriq/storage/memory.rb
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
74
|
-
stats = @position_stats[
|
|
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(
|
|
80
|
-
|
|
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(
|
|
92
|
-
@position_stats[
|
|
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 |
|
|
116
|
-
|
|
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 { |
|
|
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
|
data/lib/iriq/storage/sqlite.rb
CHANGED
|
@@ -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 =
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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(
|
|
192
|
-
host
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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,
|
|
200
|
-
INSERT INTO position_types (host,
|
|
201
|
-
ON CONFLICT(host,
|
|
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,
|
|
259
|
+
@db.execute(<<~SQL, [host, scope, locator, value])
|
|
209
260
|
UPDATE position_values SET count = count + 1
|
|
210
|
-
WHERE host = ? AND
|
|
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
|
|
215
|
-
[host,
|
|
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,
|
|
220
|
-
[host,
|
|
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
|
-
|
|
240
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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(
|
|
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
|