iriq 0.33.0 → 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 +93 -0
- data/README.md +187 -50
- data/lib/iriq/cli.rb +131 -60
- data/lib/iriq/cluster.rb +15 -9
- data/lib/iriq/corpus.rb +233 -117
- data/lib/iriq/errors.rb +9 -0
- data/lib/iriq/normalizer.rb +21 -18
- data/lib/iriq/observation.rb +11 -6
- data/lib/iriq/position_evidence.rb +31 -0
- data/lib/iriq/position_stats.rb +4 -2
- data/lib/iriq/recognizer_proposal.rb +13 -1
- data/lib/iriq/reducer.rb +1 -2
- data/lib/iriq/segment_classifier.rb +9 -1
- data/lib/iriq/storage/json.rb +21 -7
- data/lib/iriq/storage/memory.rb +52 -10
- data/lib/iriq/storage/sqlite.rb +291 -41
- 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 +2 -1
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.
|
|
@@ -264,9 +266,9 @@ module Iriq
|
|
|
264
266
|
# call into Normalizer with `evidence: self`; the corpus-informed path
|
|
265
267
|
# and query rendering live in #render_path / #render_query below
|
|
266
268
|
# (the evidence-source interface).
|
|
267
|
-
def normalize(input)
|
|
269
|
+
def normalize(input, hints: true)
|
|
268
270
|
iri = coerce(input)
|
|
269
|
-
Normalizer.normalize_identifier(iri, classifier: @classifier, hints:
|
|
271
|
+
Normalizer.normalize_identifier(iri, classifier: @classifier, hints: hints, evidence: self)
|
|
270
272
|
end
|
|
271
273
|
|
|
272
274
|
# Evidence-source interface — called by Normalizer when this Corpus is
|
|
@@ -274,27 +276,33 @@ module Iriq
|
|
|
274
276
|
# classifications (variability promotion, popular-outlier preservation).
|
|
275
277
|
# Always emits a leading "/" — empty path collapses to "/" to match
|
|
276
278
|
# mechanical output and anchor any trailing query.
|
|
277
|
-
def render_path(iri, _classifier,
|
|
278
|
-
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) }
|
|
279
281
|
"/" + tokens.join("/")
|
|
280
282
|
end
|
|
281
283
|
|
|
282
|
-
# Evidence-source interface — render the query string
|
|
283
|
-
# cluster
|
|
284
|
-
#
|
|
285
|
-
#
|
|
286
|
-
# 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.
|
|
287
288
|
def render_query(iri, _classifier = @classifier)
|
|
289
|
+
return "" if iri.query_params.nil? || iri.query_params.empty?
|
|
290
|
+
|
|
288
291
|
hinted_shape = PathShape.new(classifier: @classifier, hints: true)
|
|
289
292
|
.from_entries(SegmentHints.derive(iri.path_segments, @classifier))
|
|
290
293
|
key, * = Cluster.key_for(iri, classifier: @classifier, shape: hinted_shape,
|
|
291
294
|
host: effective_host(iri.host))
|
|
292
|
-
|
|
295
|
+
mechanical = NullEvidenceSource.new
|
|
293
296
|
|
|
294
297
|
iri.query_params.keys.sort.map do |k|
|
|
295
|
-
v
|
|
296
|
-
|
|
297
|
-
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
|
|
298
306
|
"#{k}=#{shaped}"
|
|
299
307
|
end.join("&")
|
|
300
308
|
end
|
|
@@ -374,31 +382,141 @@ module Iriq
|
|
|
374
382
|
# Wrap many observations in a single backend transaction. For SQLite this
|
|
375
383
|
# turns thousands of fsyncs into one; for in-memory backends it's a
|
|
376
384
|
# no-op. Use when ingesting a batch.
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
379
393
|
end
|
|
380
394
|
|
|
381
395
|
private
|
|
382
396
|
|
|
383
|
-
#
|
|
384
|
-
#
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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)
|
|
388
436
|
|
|
389
|
-
|
|
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 } }
|
|
390
499
|
end
|
|
391
500
|
|
|
392
|
-
# On Corpus.open, walk the stored activations and register each one
|
|
393
|
-
# on this corpus's classifier. Switches to a per-corpus classifier
|
|
394
|
-
# if any activations exist.
|
|
395
501
|
def reapply_activated_recognizers!
|
|
396
|
-
|
|
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
|
|
397
511
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|
401
518
|
end
|
|
519
|
+
@activations = activations
|
|
402
520
|
end
|
|
403
521
|
|
|
404
522
|
def coerce(input)
|
|
@@ -410,10 +528,10 @@ module Iriq
|
|
|
410
528
|
prefix = ""
|
|
411
529
|
keying_host = effective_host(iri.host)
|
|
412
530
|
hinted.map do |entry|
|
|
413
|
-
|
|
531
|
+
position = Position.path(host: keying_host, prefix: prefix)
|
|
414
532
|
out = entry.merge(
|
|
415
533
|
prefix: prefix,
|
|
416
|
-
classification: classify(entry,
|
|
534
|
+
classification: classify(entry) { @storage.position_evidence(position, entry[:value]) },
|
|
417
535
|
)
|
|
418
536
|
prefix = "#{prefix}/#{placeholder(entry)}"
|
|
419
537
|
out
|
|
@@ -438,28 +556,29 @@ module Iriq
|
|
|
438
556
|
# position, the literal is almost always the better display.
|
|
439
557
|
STABLE_VARIABLE_TYPES = %i[version locale currency boolean slug opaque_id].freeze
|
|
440
558
|
|
|
441
|
-
|
|
559
|
+
# The block reads the position's PositionEvidence; a variable of a
|
|
560
|
+
# non-stable type is answered without it.
|
|
561
|
+
def classify(entry)
|
|
442
562
|
variable = entry[:variable]
|
|
563
|
+
return :variable_identifier if variable && !STABLE_VARIABLE_TYPES.include?(entry[:type])
|
|
443
564
|
|
|
565
|
+
stats = yield
|
|
444
566
|
return variable ? :variable_identifier : :ambiguous if stats.nil? || stats.total.zero?
|
|
445
|
-
if variable && !STABLE_VARIABLE_TYPES.include?(entry[:type])
|
|
446
|
-
return :variable_identifier
|
|
447
|
-
end
|
|
448
567
|
|
|
449
|
-
value = entry[:value]
|
|
450
568
|
total = stats.total
|
|
451
569
|
variable_frac = stats.variable_fraction(@classifier)
|
|
452
570
|
cardinality_frac = stats.cardinality.to_f / total
|
|
453
571
|
enough_data = total >= MIN_OBSERVATIONS_FOR_INFERENCE
|
|
454
|
-
value_frac = stats.value_fraction
|
|
572
|
+
value_frac = stats.value_fraction
|
|
455
573
|
|
|
456
574
|
# For STABLE_VARIABLE_TYPES (version, locale, currency, boolean),
|
|
457
575
|
# a dominant value wins over the variable-dominance branch — a
|
|
458
576
|
# single-version /api/v1/... pattern stays as the literal `v1`
|
|
459
|
-
# rather than placeholdering to {version}. Without dominance,
|
|
460
|
-
#
|
|
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.
|
|
461
580
|
if variable
|
|
462
|
-
return :stable_literal if value_frac >= STABLE_LITERAL_THRESHOLD
|
|
581
|
+
return :stable_literal if enough_data && value_frac >= STABLE_LITERAL_THRESHOLD
|
|
463
582
|
|
|
464
583
|
return :variable_identifier
|
|
465
584
|
end
|
|
@@ -467,7 +586,7 @@ module Iriq
|
|
|
467
586
|
if enough_data && variable_frac >= VARIABLE_DOMINANCE_THRESHOLD
|
|
468
587
|
# Position is dominated by variable types (UUIDs, integers, etc.).
|
|
469
588
|
# A literal here is a special-case outlier (e.g. /users/me).
|
|
470
|
-
stats.
|
|
589
|
+
stats.value_count ? :rare_literal : :ambiguous
|
|
471
590
|
elsif value_frac >= STABLE_LITERAL_THRESHOLD
|
|
472
591
|
# This specific value dominates — preserve it regardless of how
|
|
473
592
|
# diverse the rest of the position is.
|
|
@@ -477,10 +596,10 @@ module Iriq
|
|
|
477
596
|
# recognize values that dramatically exceed the uniform baseline as
|
|
478
597
|
# "popular outliers" (e.g. /workspaces/mainspace surviving in a slot
|
|
479
598
|
# full of one-shot user-created workspace names).
|
|
480
|
-
popular_outlier?(stats
|
|
599
|
+
popular_outlier?(stats) ? :stable_literal : :corpus_inferred_variable
|
|
481
600
|
elsif stats.cardinality == 1
|
|
482
601
|
:stable_literal
|
|
483
|
-
elsif stats.
|
|
602
|
+
elsif stats.value_count
|
|
484
603
|
:rare_literal
|
|
485
604
|
else
|
|
486
605
|
:ambiguous
|
|
@@ -494,28 +613,25 @@ module Iriq
|
|
|
494
613
|
stats.cardinality >= MIN_CARDINALITY_FOR_INFERENCE
|
|
495
614
|
end
|
|
496
615
|
|
|
497
|
-
def popular_outlier?(stats
|
|
498
|
-
|
|
499
|
-
return false if count < POPULAR_MIN_COUNT
|
|
616
|
+
def popular_outlier?(stats)
|
|
617
|
+
return false if (stats.value_count || 0) < POPULAR_MIN_COUNT
|
|
500
618
|
|
|
501
619
|
baseline = 1.0 / stats.cardinality
|
|
502
|
-
stats.value_fraction
|
|
620
|
+
stats.value_fraction >= POPULAR_BASELINE_MULTIPLE * baseline
|
|
503
621
|
end
|
|
504
622
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
else
|
|
513
|
-
@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)
|
|
514
630
|
end
|
|
515
631
|
end
|
|
516
632
|
|
|
517
633
|
def render_param_value(value, type)
|
|
518
|
-
if
|
|
634
|
+
if (canon = canonical_form(type, value))
|
|
519
635
|
canon
|
|
520
636
|
elsif @classifier.variable?(type)
|
|
521
637
|
"{#{SegmentClassifier.display_type(type)}}"
|
|
@@ -524,27 +640,29 @@ module Iriq
|
|
|
524
640
|
end
|
|
525
641
|
end
|
|
526
642
|
|
|
527
|
-
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
|
+
|
|
528
648
|
case entry[:classification]
|
|
529
649
|
when :variable_identifier, :corpus_inferred_variable
|
|
530
|
-
placeholder_for_variable(entry)
|
|
650
|
+
placeholder_for_variable(entry, hints)
|
|
531
651
|
else
|
|
532
652
|
entry[:value]
|
|
533
653
|
end
|
|
534
654
|
end
|
|
535
655
|
|
|
536
|
-
def placeholder_for_variable(entry)
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
# what render_param_value does for query params.
|
|
540
|
-
if entry[:type] == :date && (canon = SegmentClassifier.canonical_date(entry[:value]))
|
|
541
|
-
return canon
|
|
656
|
+
def placeholder_for_variable(entry, hints)
|
|
657
|
+
if entry[:variable]
|
|
658
|
+
return "{#{(hints && entry[:hint]) || SegmentClassifier.display_type(entry[:type])}}"
|
|
542
659
|
end
|
|
543
|
-
return "{#{entry[:hint] || SegmentClassifier.display_type(entry[:type])}}" if entry[:variable]
|
|
544
660
|
|
|
545
661
|
# corpus-inferred variable: classifier said literal, corpus says
|
|
546
|
-
# otherwise.
|
|
547
|
-
#
|
|
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
|
+
|
|
548
666
|
last_literal = entry[:prefix].split("/").reject(&:empty?).reject { |s| s.start_with?("{") }.last
|
|
549
667
|
base = last_literal ? Inflector.singularize(last_literal) : nil
|
|
550
668
|
base ? "{#{base}}" : "{value}"
|
|
@@ -576,9 +694,7 @@ module Iriq
|
|
|
576
694
|
private
|
|
577
695
|
|
|
578
696
|
def write_json_dump(path)
|
|
579
|
-
|
|
580
|
-
File.write(tmp, JSON.generate(memory_view.to_dump))
|
|
581
|
-
File.rename(tmp, path)
|
|
697
|
+
Storage.write_atomically(path, JSON.generate(memory_view.to_dump))
|
|
582
698
|
end
|
|
583
699
|
|
|
584
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/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
|
|
@@ -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
|
data/lib/iriq/position_stats.rb
CHANGED
|
@@ -48,8 +48,10 @@ module Iriq
|
|
|
48
48
|
def record_numeric(value, type)
|
|
49
49
|
return unless NUMERIC_TYPES.include?(type)
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
# Huge digit strings parse to ±Infinity; they'd poison the range and
|
|
52
|
+
# can't be serialized to JSON. They still count in type/value counts.
|
|
53
|
+
n = Float(value, exception: false)
|
|
54
|
+
return unless n&.finite?
|
|
53
55
|
|
|
54
56
|
@numeric_count += 1
|
|
55
57
|
@numeric_min = n if @numeric_min.nil? || n < @numeric_min
|