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/corpus.rb
CHANGED
|
@@ -54,6 +54,10 @@ module Iriq
|
|
|
54
54
|
unless HOST_STRATEGIES.include?(host_strategy)
|
|
55
55
|
|
|
56
56
|
@classifier = classifier
|
|
57
|
+
# Stored activations layer onto the base; @activations is the set
|
|
58
|
+
# @classifier was built from.
|
|
59
|
+
@base_classifier = classifier
|
|
60
|
+
@activations = []
|
|
57
61
|
@host_strategy = host_strategy
|
|
58
62
|
@storage = storage || Storage::Memory.new(
|
|
59
63
|
classifier: classifier,
|
|
@@ -95,42 +99,50 @@ module Iriq
|
|
|
95
99
|
# replay against alternate reducers / thresholds for re-runnable
|
|
96
100
|
# inference. See lib/iriq/event.rb and lib/iriq/reducer.rb.
|
|
97
101
|
def observe(input)
|
|
98
|
-
iri
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
events.each
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
end
|
|
107
|
-
s.record_observation(iri.canonical) if s.respond_to?(:record_observation)
|
|
102
|
+
iri = coerce(input)
|
|
103
|
+
|
|
104
|
+
addition = batch do
|
|
105
|
+
# Derived inside the batch, which may have just adopted activations.
|
|
106
|
+
events = events_for(iri)
|
|
107
|
+
events.each { |e| Reducer.apply(e, @storage) }
|
|
108
|
+
@storage.record_observation(iri.canonical) if @storage.respond_to?(:record_observation)
|
|
109
|
+
events.find { |e| e.is_a?(Event::ClusterAddition) }
|
|
108
110
|
end
|
|
109
111
|
|
|
110
|
-
Observation.new(corpus: self, identifier: iri,
|
|
112
|
+
Observation.new(corpus: self, identifier: iri, cluster_key: addition.key)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Observe every IRI. On SQLite they commit a turn of about a second at a
|
|
116
|
+
# time, so other processes writing the corpus get the lock in between; a
|
|
117
|
+
# failure keeps the turns already committed. Inside #batch they all join
|
|
118
|
+
# its transaction.
|
|
119
|
+
def observe_all(iris)
|
|
120
|
+
done = 0
|
|
121
|
+
while done < iris.size
|
|
122
|
+
batch do
|
|
123
|
+
while done < iris.size
|
|
124
|
+
observe(iris[done])
|
|
125
|
+
done += 1
|
|
126
|
+
break if @storage.turn_over?
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
nil
|
|
111
131
|
end
|
|
112
132
|
|
|
113
|
-
#
|
|
114
|
-
# …)
|
|
115
|
-
#
|
|
133
|
+
# Rebuild every materialized view (host counts, position stats, clusters,
|
|
134
|
+
# …) by replaying the source-IRI log through the current events +
|
|
135
|
+
# reducers pipeline. Useful for:
|
|
116
136
|
#
|
|
117
137
|
# - Tuning thresholds (swap a Corpus constant, call reinfer)
|
|
118
138
|
# - Swapping the classifier (open the Corpus with a different
|
|
119
139
|
# classifier, call reinfer — events are re-derived from raw IRIs)
|
|
120
140
|
# - Recovering after a Reducer-set change
|
|
121
141
|
#
|
|
122
|
-
#
|
|
123
|
-
# leaves the prior views intact.
|
|
142
|
+
# The views change all at once, keeping what other connections observe
|
|
143
|
+
# meanwhile; a failure leaves the prior views intact.
|
|
124
144
|
def reinfer
|
|
125
|
-
|
|
126
|
-
iris = []
|
|
127
|
-
s.each_observed_iri { |canonical| iris << canonical }
|
|
128
|
-
s.clear_materialized_views
|
|
129
|
-
iris.each do |canonical|
|
|
130
|
-
iri = Parser.parse(canonical)
|
|
131
|
-
events_for(iri).each { |e| Reducer.apply(e, s) }
|
|
132
|
-
end
|
|
133
|
-
end
|
|
145
|
+
rebuild(nil)
|
|
134
146
|
nil
|
|
135
147
|
end
|
|
136
148
|
|
|
@@ -154,38 +166,28 @@ module Iriq
|
|
|
154
166
|
strategies.flat_map { |s| s.propose(@storage, **opts) }
|
|
155
167
|
end
|
|
156
168
|
|
|
157
|
-
# Promote a RecognizerProposal into a live Recognizer for this corpus
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
162
|
-
# module-level DEFAULT) so activation doesn't leak to other
|
|
163
|
-
# corpora using the same default singleton.
|
|
164
|
-
# 3. Register the Recognizer on the classifier — the ensemble
|
|
165
|
-
# picks it up on the next classify() call.
|
|
166
|
-
# 4. Persist the activation in storage so reopens re-apply it.
|
|
167
|
-
# 5. Reinfer so existing observations get re-classified through
|
|
168
|
-
# the new Recognizer.
|
|
169
|
+
# Promote a RecognizerProposal into a live Recognizer for this corpus:
|
|
170
|
+
# store the activation, then reinfer existing observations through it.
|
|
171
|
+
# Both commit in one transaction — a failure leaves neither behind, in
|
|
172
|
+
# storage or in this corpus's classifier. Activating a recognizer the
|
|
173
|
+
# corpus already holds changes nothing.
|
|
169
174
|
#
|
|
170
175
|
# Returns the synthesized Recognizer.
|
|
171
176
|
def activate_proposal(proposal)
|
|
172
177
|
recognizer = SynthesizedRecognizer.from_proposal(proposal)
|
|
173
|
-
|
|
174
|
-
@classifier.register_recognizer(recognizer)
|
|
175
|
-
if @storage.respond_to?(:record_activated_recognizer)
|
|
176
|
-
@storage.record_activated_recognizer(recognizer.to_dump)
|
|
177
|
-
end
|
|
178
|
-
reinfer
|
|
178
|
+
activate(recognizer)
|
|
179
179
|
recognizer
|
|
180
180
|
end
|
|
181
181
|
|
|
182
182
|
# Convenience: activate every proposal whose confidence clears the
|
|
183
|
-
# given threshold. Returns the activated
|
|
184
|
-
# incorporates both per-position coverage AND cross-host
|
|
183
|
+
# given threshold. Returns the Recognizers this call newly activated.
|
|
184
|
+
# Confidence incorporates both per-position coverage AND cross-host
|
|
185
185
|
# corroboration — see RecognizerProposal#compute_confidence.
|
|
186
186
|
def activate_proposals_above(confidence_threshold, **propose_opts)
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
propose_recognizers(**propose_opts)
|
|
188
|
+
.select { |p| p.confidence >= confidence_threshold }
|
|
189
|
+
.map { |p| SynthesizedRecognizer.from_proposal(p) }
|
|
190
|
+
.select { |r| activate(r) }
|
|
189
191
|
end
|
|
190
192
|
|
|
191
193
|
# Number of activated recognizers persisted with this corpus.
|
|
@@ -232,8 +234,9 @@ module Iriq
|
|
|
232
234
|
def events_for(input)
|
|
233
235
|
iri = coerce(input)
|
|
234
236
|
hinted_entries = SegmentHints.derive(iri.path_segments, @classifier)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
+
shape = Shape.from_entries(hinted_entries)
|
|
238
|
+
raw_shape = shape.render(hints: false)
|
|
239
|
+
hinted_shape = shape.render(hints: true)
|
|
237
240
|
keying_host = effective_host(iri.host)
|
|
238
241
|
|
|
239
242
|
events = [
|
|
@@ -263,9 +266,9 @@ module Iriq
|
|
|
263
266
|
# call into Normalizer with `evidence: self`; the corpus-informed path
|
|
264
267
|
# and query rendering live in #render_path / #render_query below
|
|
265
268
|
# (the evidence-source interface).
|
|
266
|
-
def normalize(input)
|
|
269
|
+
def normalize(input, hints: true)
|
|
267
270
|
iri = coerce(input)
|
|
268
|
-
Normalizer.normalize_identifier(iri, classifier: @classifier, hints:
|
|
271
|
+
Normalizer.normalize_identifier(iri, classifier: @classifier, hints: hints, evidence: self)
|
|
269
272
|
end
|
|
270
273
|
|
|
271
274
|
# Evidence-source interface — called by Normalizer when this Corpus is
|
|
@@ -273,27 +276,33 @@ module Iriq
|
|
|
273
276
|
# classifications (variability promotion, popular-outlier preservation).
|
|
274
277
|
# Always emits a leading "/" — empty path collapses to "/" to match
|
|
275
278
|
# mechanical output and anchor any trailing query.
|
|
276
|
-
def render_path(iri, _classifier,
|
|
277
|
-
tokens = annotate_segments(iri).map { |entry| corpus_token(entry) }
|
|
279
|
+
def render_path(iri, _classifier, hints)
|
|
280
|
+
tokens = annotate_segments(iri).map { |entry| corpus_token(entry, hints) }
|
|
278
281
|
"/" + tokens.join("/")
|
|
279
282
|
end
|
|
280
283
|
|
|
281
|
-
# Evidence-source interface — render the query string
|
|
282
|
-
# cluster
|
|
283
|
-
#
|
|
284
|
-
#
|
|
285
|
-
# type_count, subject to the corpus thresholds).
|
|
284
|
+
# Evidence-source interface — render the query string. A param the
|
|
285
|
+
# cluster has seen at least MIN_OBSERVATIONS_FOR_INFERENCE times renders
|
|
286
|
+
# with the cluster's type; below that the corpus has no opinion and the
|
|
287
|
+
# param renders exactly as mechanical normalize would.
|
|
286
288
|
def render_query(iri, _classifier = @classifier)
|
|
289
|
+
return "" if iri.query_params.nil? || iri.query_params.empty?
|
|
290
|
+
|
|
287
291
|
hinted_shape = PathShape.new(classifier: @classifier, hints: true)
|
|
288
292
|
.from_entries(SegmentHints.derive(iri.path_segments, @classifier))
|
|
289
293
|
key, * = Cluster.key_for(iri, classifier: @classifier, shape: hinted_shape,
|
|
290
294
|
host: effective_host(iri.host))
|
|
291
|
-
|
|
295
|
+
mechanical = NullEvidenceSource.new
|
|
292
296
|
|
|
293
297
|
iri.query_params.keys.sort.map do |k|
|
|
294
|
-
v
|
|
295
|
-
|
|
296
|
-
shaped =
|
|
298
|
+
v = iri.query_params[k].to_s
|
|
299
|
+
stats = @storage.param_stats(key, k)
|
|
300
|
+
shaped =
|
|
301
|
+
if stats && stats.total >= MIN_OBSERVATIONS_FOR_INFERENCE
|
|
302
|
+
render_param_value(v, Cluster.param_type_for(k, stats) || @classifier.classify(v))
|
|
303
|
+
else
|
|
304
|
+
mechanical.render_param(k, v, @classifier)
|
|
305
|
+
end
|
|
297
306
|
"#{k}=#{shaped}"
|
|
298
307
|
end.join("&")
|
|
299
308
|
end
|
|
@@ -373,31 +382,141 @@ module Iriq
|
|
|
373
382
|
# Wrap many observations in a single backend transaction. For SQLite this
|
|
374
383
|
# turns thousands of fsyncs into one; for in-memory backends it's a
|
|
375
384
|
# no-op. Use when ingesting a batch.
|
|
376
|
-
|
|
377
|
-
|
|
385
|
+
#
|
|
386
|
+
# Every write runs in one, and classifies with exactly the activations
|
|
387
|
+
# stored as of its start — including any another connection committed.
|
|
388
|
+
def batch
|
|
389
|
+
@storage.batch do |changed|
|
|
390
|
+
reapply_activated_recognizers! if changed
|
|
391
|
+
yield
|
|
392
|
+
end
|
|
378
393
|
end
|
|
379
394
|
|
|
380
395
|
private
|
|
381
396
|
|
|
382
|
-
#
|
|
383
|
-
#
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
397
|
+
# Whether this call activated `recognizer`. The dedup check runs under
|
|
398
|
+
# the write lock, so two corpora racing to activate it can't both win.
|
|
399
|
+
def activate(recognizer)
|
|
400
|
+
rebuild(recognizer.to_dump)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Storage's activations changed while a rebuild replayed; they now read
|
|
404
|
+
# `stored`.
|
|
405
|
+
class StaleRebuild < StandardError
|
|
406
|
+
attr_reader :stored
|
|
407
|
+
|
|
408
|
+
def initialize(stored)
|
|
409
|
+
super("activations changed during a rebuild")
|
|
410
|
+
@stored = stored
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Rebuild every view from the observation log and, given `activation`,
|
|
415
|
+
# store that recognizer with them: both commit together or not at all.
|
|
416
|
+
# The replay runs into views only this connection sees, without the write
|
|
417
|
+
# lock, which it takes just to replay what others observed meanwhile and
|
|
418
|
+
# install the result. False, changing nothing, when `activation` is
|
|
419
|
+
# already stored.
|
|
420
|
+
def rebuild(activation)
|
|
421
|
+
planned = stored_activations
|
|
422
|
+
if activation
|
|
423
|
+
return false if holds_activation?(planned, activation)
|
|
424
|
+
|
|
425
|
+
planned += [activation]
|
|
426
|
+
end
|
|
427
|
+
loop do
|
|
428
|
+
outcome = begin
|
|
429
|
+
install_views(build_views(planned), planned, activation)
|
|
430
|
+
ensure
|
|
431
|
+
@storage.discard_rebuild
|
|
432
|
+
end
|
|
433
|
+
# The rebuild classified with `planned`; storage decides from here.
|
|
434
|
+
reapply_activated_recognizers!
|
|
435
|
+
return outcome unless outcome.is_a?(StaleRebuild)
|
|
387
436
|
|
|
388
|
-
|
|
437
|
+
planned = outcome.stored
|
|
438
|
+
end
|
|
439
|
+
rescue StandardError
|
|
440
|
+
reapply_activated_recognizers! rescue nil
|
|
441
|
+
raise
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Replay the log into a rebuild classified with `activations`, catching
|
|
445
|
+
# up while each pass replays less than the one before. Returns the log
|
|
446
|
+
# mark replayed through.
|
|
447
|
+
def build_views(activations)
|
|
448
|
+
use_activations(activations)
|
|
449
|
+
@storage.begin_rebuild
|
|
450
|
+
mark = 0
|
|
451
|
+
last_pass = Float::INFINITY
|
|
452
|
+
loop do
|
|
453
|
+
replayed, mark = replay_log_since(mark)
|
|
454
|
+
return mark if replayed.zero? || replayed >= last_pass
|
|
455
|
+
|
|
456
|
+
last_pass = replayed
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Under the write lock, record `activation`, replay the log past `mark`
|
|
461
|
+
# and install the rebuild — unless the stored activations are no longer
|
|
462
|
+
# the `planned` set the rebuild classified with (a StaleRebuild, returned).
|
|
463
|
+
def install_views(mark, planned, activation)
|
|
464
|
+
batch do
|
|
465
|
+
if activation
|
|
466
|
+
next false if holds_activation?(stored_activations, activation)
|
|
467
|
+
|
|
468
|
+
@storage.record_activated_recognizer(activation)
|
|
469
|
+
end
|
|
470
|
+
stored = stored_activations
|
|
471
|
+
raise StaleRebuild, stored unless stored == planned
|
|
472
|
+
|
|
473
|
+
use_activations(stored)
|
|
474
|
+
replay_log_since(mark)
|
|
475
|
+
@storage.install_rebuild
|
|
476
|
+
true
|
|
477
|
+
end
|
|
478
|
+
rescue StaleRebuild => e
|
|
479
|
+
e
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Replay the observations logged after `mark`. Returns how many, and the
|
|
483
|
+
# mark through them.
|
|
484
|
+
def replay_log_since(mark)
|
|
485
|
+
iris = []
|
|
486
|
+
mark = @storage.each_observed_iri_since(mark) { |canonical| iris << canonical }
|
|
487
|
+
iris.each { |canonical| events_for(Parser.parse(canonical)).each { |e| Reducer.apply(e, @storage) } }
|
|
488
|
+
[iris.size, mark]
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# An activation is its prefix and type: specificity never changes what it
|
|
492
|
+
# classifies, and older Rust binaries stored a different one.
|
|
493
|
+
def holds_activation?(activations, activation)
|
|
494
|
+
activations.any? { |a| a.values_at("prefix", "type") == activation.values_at("prefix", "type") }
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def stored_activations
|
|
498
|
+
[].tap { |stored| @storage.each_activated_recognizer { |dump| stored << dump } }
|
|
389
499
|
end
|
|
390
500
|
|
|
391
|
-
# On Corpus.open, walk the stored activations and register each one
|
|
392
|
-
# on this corpus's classifier. Switches to a per-corpus classifier
|
|
393
|
-
# if any activations exist.
|
|
394
501
|
def reapply_activated_recognizers!
|
|
395
|
-
|
|
502
|
+
use_activations(stored_activations)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# The classifier is a function of the activations: the base classifier
|
|
506
|
+
# when there are none, otherwise a private copy of it holding exactly
|
|
507
|
+
# them (so nothing leaks into a shared DEFAULT). Rebuilt only when the
|
|
508
|
+
# set changed, so the classifier keeps its cache.
|
|
509
|
+
def use_activations(activations)
|
|
510
|
+
return if activations == @activations
|
|
396
511
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
512
|
+
@classifier = if activations.empty?
|
|
513
|
+
@base_classifier
|
|
514
|
+
else
|
|
515
|
+
@base_classifier.dup.tap do |c|
|
|
516
|
+
activations.each { |dump| c.register_recognizer(SynthesizedRecognizer.from_dump(dump)) }
|
|
517
|
+
end
|
|
400
518
|
end
|
|
519
|
+
@activations = activations
|
|
401
520
|
end
|
|
402
521
|
|
|
403
522
|
def coerce(input)
|
|
@@ -409,10 +528,10 @@ module Iriq
|
|
|
409
528
|
prefix = ""
|
|
410
529
|
keying_host = effective_host(iri.host)
|
|
411
530
|
hinted.map do |entry|
|
|
412
|
-
|
|
531
|
+
position = Position.path(host: keying_host, prefix: prefix)
|
|
413
532
|
out = entry.merge(
|
|
414
533
|
prefix: prefix,
|
|
415
|
-
classification: classify(entry,
|
|
534
|
+
classification: classify(entry) { @storage.position_evidence(position, entry[:value]) },
|
|
416
535
|
)
|
|
417
536
|
prefix = "#{prefix}/#{placeholder(entry)}"
|
|
418
537
|
out
|
|
@@ -437,28 +556,29 @@ module Iriq
|
|
|
437
556
|
# position, the literal is almost always the better display.
|
|
438
557
|
STABLE_VARIABLE_TYPES = %i[version locale currency boolean slug opaque_id].freeze
|
|
439
558
|
|
|
440
|
-
|
|
559
|
+
# The block reads the position's PositionEvidence; a variable of a
|
|
560
|
+
# non-stable type is answered without it.
|
|
561
|
+
def classify(entry)
|
|
441
562
|
variable = entry[:variable]
|
|
563
|
+
return :variable_identifier if variable && !STABLE_VARIABLE_TYPES.include?(entry[:type])
|
|
442
564
|
|
|
565
|
+
stats = yield
|
|
443
566
|
return variable ? :variable_identifier : :ambiguous if stats.nil? || stats.total.zero?
|
|
444
|
-
if variable && !STABLE_VARIABLE_TYPES.include?(entry[:type])
|
|
445
|
-
return :variable_identifier
|
|
446
|
-
end
|
|
447
567
|
|
|
448
|
-
value = entry[:value]
|
|
449
568
|
total = stats.total
|
|
450
569
|
variable_frac = stats.variable_fraction(@classifier)
|
|
451
570
|
cardinality_frac = stats.cardinality.to_f / total
|
|
452
571
|
enough_data = total >= MIN_OBSERVATIONS_FOR_INFERENCE
|
|
453
|
-
value_frac = stats.value_fraction
|
|
572
|
+
value_frac = stats.value_fraction
|
|
454
573
|
|
|
455
574
|
# For STABLE_VARIABLE_TYPES (version, locale, currency, boolean),
|
|
456
575
|
# a dominant value wins over the variable-dominance branch — a
|
|
457
576
|
# single-version /api/v1/... pattern stays as the literal `v1`
|
|
458
|
-
# rather than placeholdering to {version}. Without dominance,
|
|
459
|
-
#
|
|
577
|
+
# rather than placeholdering to {version}. Without dominance, or
|
|
578
|
+
# without enough observations to call anything dominant (one sample
|
|
579
|
+
# is always 100%), fall through to :variable_identifier.
|
|
460
580
|
if variable
|
|
461
|
-
return :stable_literal if value_frac >= STABLE_LITERAL_THRESHOLD
|
|
581
|
+
return :stable_literal if enough_data && value_frac >= STABLE_LITERAL_THRESHOLD
|
|
462
582
|
|
|
463
583
|
return :variable_identifier
|
|
464
584
|
end
|
|
@@ -466,7 +586,7 @@ module Iriq
|
|
|
466
586
|
if enough_data && variable_frac >= VARIABLE_DOMINANCE_THRESHOLD
|
|
467
587
|
# Position is dominated by variable types (UUIDs, integers, etc.).
|
|
468
588
|
# A literal here is a special-case outlier (e.g. /users/me).
|
|
469
|
-
stats.
|
|
589
|
+
stats.value_count ? :rare_literal : :ambiguous
|
|
470
590
|
elsif value_frac >= STABLE_LITERAL_THRESHOLD
|
|
471
591
|
# This specific value dominates — preserve it regardless of how
|
|
472
592
|
# diverse the rest of the position is.
|
|
@@ -476,10 +596,10 @@ module Iriq
|
|
|
476
596
|
# recognize values that dramatically exceed the uniform baseline as
|
|
477
597
|
# "popular outliers" (e.g. /workspaces/mainspace surviving in a slot
|
|
478
598
|
# full of one-shot user-created workspace names).
|
|
479
|
-
popular_outlier?(stats
|
|
599
|
+
popular_outlier?(stats) ? :stable_literal : :corpus_inferred_variable
|
|
480
600
|
elsif stats.cardinality == 1
|
|
481
601
|
:stable_literal
|
|
482
|
-
elsif stats.
|
|
602
|
+
elsif stats.value_count
|
|
483
603
|
:rare_literal
|
|
484
604
|
else
|
|
485
605
|
:ambiguous
|
|
@@ -493,28 +613,25 @@ module Iriq
|
|
|
493
613
|
stats.cardinality >= MIN_CARDINALITY_FOR_INFERENCE
|
|
494
614
|
end
|
|
495
615
|
|
|
496
|
-
def popular_outlier?(stats
|
|
497
|
-
|
|
498
|
-
return false if count < POPULAR_MIN_COUNT
|
|
616
|
+
def popular_outlier?(stats)
|
|
617
|
+
return false if (stats.value_count || 0) < POPULAR_MIN_COUNT
|
|
499
618
|
|
|
500
619
|
baseline = 1.0 / stats.cardinality
|
|
501
|
-
stats.value_fraction
|
|
620
|
+
stats.value_fraction >= POPULAR_BASELINE_MULTIPLE * baseline
|
|
502
621
|
end
|
|
503
622
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
else
|
|
512
|
-
@classifier.classify(value)
|
|
623
|
+
# Dates and currencies print in canonical form (ISO date, upper-case code)
|
|
624
|
+
# rather than as a placeholder — same as mechanical normalize. nil when
|
|
625
|
+
# the value isn't one.
|
|
626
|
+
def canonical_form(type, value)
|
|
627
|
+
case type
|
|
628
|
+
when :date then SegmentClassifier.canonical_date(value)
|
|
629
|
+
when :currency then SegmentClassifier.canonical_currency(value)
|
|
513
630
|
end
|
|
514
631
|
end
|
|
515
632
|
|
|
516
633
|
def render_param_value(value, type)
|
|
517
|
-
if
|
|
634
|
+
if (canon = canonical_form(type, value))
|
|
518
635
|
canon
|
|
519
636
|
elsif @classifier.variable?(type)
|
|
520
637
|
"{#{SegmentClassifier.display_type(type)}}"
|
|
@@ -523,27 +640,29 @@ module Iriq
|
|
|
523
640
|
end
|
|
524
641
|
end
|
|
525
642
|
|
|
526
|
-
def corpus_token(entry)
|
|
643
|
+
def corpus_token(entry, hints)
|
|
644
|
+
if (canon = canonical_form(entry[:type], entry[:value]))
|
|
645
|
+
return canon
|
|
646
|
+
end
|
|
647
|
+
|
|
527
648
|
case entry[:classification]
|
|
528
649
|
when :variable_identifier, :corpus_inferred_variable
|
|
529
|
-
placeholder_for_variable(entry)
|
|
650
|
+
placeholder_for_variable(entry, hints)
|
|
530
651
|
else
|
|
531
652
|
entry[:value]
|
|
532
653
|
end
|
|
533
654
|
end
|
|
534
655
|
|
|
535
|
-
def placeholder_for_variable(entry)
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
# what render_param_value does for query params.
|
|
539
|
-
if entry[:type] == :date && (canon = SegmentClassifier.canonical_date(entry[:value]))
|
|
540
|
-
return canon
|
|
656
|
+
def placeholder_for_variable(entry, hints)
|
|
657
|
+
if entry[:variable]
|
|
658
|
+
return "{#{(hints && entry[:hint]) || SegmentClassifier.display_type(entry[:type])}}"
|
|
541
659
|
end
|
|
542
|
-
return "{#{entry[:hint] || SegmentClassifier.display_type(entry[:type])}}" if entry[:variable]
|
|
543
660
|
|
|
544
661
|
# corpus-inferred variable: classifier said literal, corpus says
|
|
545
|
-
# otherwise.
|
|
546
|
-
#
|
|
662
|
+
# otherwise. There's no type to show, so without hints it's {value};
|
|
663
|
+
# with hints, name it after the prefix's last literal segment.
|
|
664
|
+
return "{value}" unless hints
|
|
665
|
+
|
|
547
666
|
last_literal = entry[:prefix].split("/").reject(&:empty?).reject { |s| s.start_with?("{") }.last
|
|
548
667
|
base = last_literal ? Inflector.singularize(last_literal) : nil
|
|
549
668
|
base ? "{#{base}}" : "{value}"
|
|
@@ -575,9 +694,7 @@ module Iriq
|
|
|
575
694
|
private
|
|
576
695
|
|
|
577
696
|
def write_json_dump(path)
|
|
578
|
-
|
|
579
|
-
File.write(tmp, JSON.generate(memory_view.to_dump))
|
|
580
|
-
File.rename(tmp, path)
|
|
697
|
+
Storage.write_atomically(path, JSON.generate(memory_view.to_dump))
|
|
581
698
|
end
|
|
582
699
|
|
|
583
700
|
# Materialize a Memory snapshot of the current state — used by dump for
|
data/lib/iriq/errors.rb
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
module Iriq
|
|
2
2
|
class Error < StandardError; end
|
|
3
3
|
class ParseError < Error; end
|
|
4
|
+
# A corpus iriq can't use: unrecognized, from a newer iriq, or failing to
|
|
5
|
+
# open, read or write. The message is `corpus PATH: reason`.
|
|
6
|
+
class CorpusError < Error; end
|
|
7
|
+
|
|
8
|
+
# An OS error in the words Rust's io::Error uses, `Permission denied (os
|
|
9
|
+
# error 13)`, without Ruby's ` @ rb_sysopen - PATH` suffix.
|
|
10
|
+
def self.os_error_message(error)
|
|
11
|
+
"#{SystemCallError.new(nil, error.errno).message} (os error #{error.errno})"
|
|
12
|
+
end
|
|
4
13
|
end
|
data/lib/iriq/identifier.rb
CHANGED
data/lib/iriq/normalizer.rb
CHANGED
|
@@ -70,25 +70,28 @@ module Iriq
|
|
|
70
70
|
|
|
71
71
|
def render_query(iri, classifier)
|
|
72
72
|
iri.query_params.keys.sort.map do |k|
|
|
73
|
-
|
|
74
|
-
type = classifier.classify(v.to_s)
|
|
75
|
-
# Param-name hint can lift a generic literal/opaque_id/slug into
|
|
76
|
-
# a semantic type — `?phone=unknown` becomes `{phone}`.
|
|
77
|
-
if (hint = SegmentClassifier.param_name_hint(k, type))
|
|
78
|
-
type = hint
|
|
79
|
-
end
|
|
80
|
-
shaped =
|
|
81
|
-
if type == :date && (canon = SegmentClassifier.canonical_date(v.to_s))
|
|
82
|
-
canon
|
|
83
|
-
elsif type == :currency && (canon = SegmentClassifier.canonical_currency(v.to_s))
|
|
84
|
-
canon
|
|
85
|
-
elsif classifier.variable?(type)
|
|
86
|
-
"{#{SegmentClassifier.display_type(type)}}"
|
|
87
|
-
else
|
|
88
|
-
v
|
|
89
|
-
end
|
|
90
|
-
"#{k}=#{shaped}"
|
|
73
|
+
"#{k}=#{render_param(k, iri.query_params[k], classifier)}"
|
|
91
74
|
end.join("&")
|
|
92
75
|
end
|
|
76
|
+
|
|
77
|
+
# One param's mechanical rendering. Corpus#render_query delegates here for
|
|
78
|
+
# params it lacks evidence on, so the two paths can't drift.
|
|
79
|
+
def render_param(name, value, classifier)
|
|
80
|
+
type = classifier.classify(value.to_s)
|
|
81
|
+
# Param-name hint can lift a generic literal/opaque_id/slug into
|
|
82
|
+
# a semantic type — `?phone=unknown` becomes `{phone}`.
|
|
83
|
+
if (hint = SegmentClassifier.param_name_hint(name, type))
|
|
84
|
+
type = hint
|
|
85
|
+
end
|
|
86
|
+
if type == :date && (canon = SegmentClassifier.canonical_date(value.to_s))
|
|
87
|
+
canon
|
|
88
|
+
elsif type == :currency && (canon = SegmentClassifier.canonical_currency(value.to_s))
|
|
89
|
+
canon
|
|
90
|
+
elsif classifier.variable?(type)
|
|
91
|
+
"{#{SegmentClassifier.display_type(type)}}"
|
|
92
|
+
else
|
|
93
|
+
value
|
|
94
|
+
end
|
|
95
|
+
end
|
|
93
96
|
end
|
|
94
97
|
end
|
data/lib/iriq/observation.rb
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
module Iriq
|
|
2
2
|
# The result of Corpus#observe. Lightweight value object — heavy work
|
|
3
|
-
# (explanation, normalization) is deferred until you ask.
|
|
3
|
+
# (cluster load, explanation, normalization) is deferred until you ask.
|
|
4
4
|
class Observation
|
|
5
|
-
attr_reader :identifier
|
|
5
|
+
attr_reader :identifier
|
|
6
6
|
|
|
7
|
-
def initialize(corpus:, identifier:,
|
|
8
|
-
@corpus
|
|
9
|
-
@identifier
|
|
10
|
-
@
|
|
7
|
+
def initialize(corpus:, identifier:, cluster_key:)
|
|
8
|
+
@corpus = corpus
|
|
9
|
+
@identifier = identifier
|
|
10
|
+
@cluster_key = cluster_key
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The cluster as it stands when first read; observing doesn't load it.
|
|
14
|
+
def cluster
|
|
15
|
+
@cluster ||= @corpus.storage.cluster_for(@cluster_key)
|
|
11
16
|
end
|
|
12
17
|
|
|
13
18
|
def fingerprint
|
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
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Iriq
|
|
2
|
+
# The slice of a position's stats that classifying one value reads
|
|
3
|
+
# (Corpus#classify). A backend can answer it without materializing every
|
|
4
|
+
# value tracked at the position.
|
|
5
|
+
PositionEvidence = Struct.new(:total, :type_counts, :cardinality, :value_count, keyword_init: true) do
|
|
6
|
+
# value_count is nil when the value isn't tracked (never seen, or dropped
|
|
7
|
+
# at the cap).
|
|
8
|
+
def self.from_stats(stats, value)
|
|
9
|
+
new(
|
|
10
|
+
total: stats.total,
|
|
11
|
+
type_counts: stats.type_counts,
|
|
12
|
+
cardinality: stats.cardinality,
|
|
13
|
+
value_count: stats.value_counts.fetch(value, nil),
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Same as PositionStats#variable_fraction.
|
|
18
|
+
def variable_fraction(classifier)
|
|
19
|
+
return 0.0 if total.zero?
|
|
20
|
+
|
|
21
|
+
type_counts.sum { |t, c| classifier.variable?(t) ? c : 0 }.to_f / total
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Same as PositionStats#value_fraction for the probed value.
|
|
25
|
+
def value_fraction
|
|
26
|
+
return 0.0 if total.zero?
|
|
27
|
+
|
|
28
|
+
(value_count || 0).to_f / total
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|