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.
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 = coerce(input)
99
- events = events_for(iri)
100
- cluster = nil
101
-
102
- @storage.transaction do |s|
103
- events.each do |e|
104
- result = Reducer.apply(e, s)
105
- cluster = result if e.is_a?(Event::ClusterAddition)
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, cluster: cluster)
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
- # Drop every materialized view (host counts, position stats, clusters,
114
- # …) and rebuild them by replaying the source-IRI log through the
115
- # current events + reducers pipeline. Useful for:
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
- # Wrapped in a single backend transaction so a failure mid-replay
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
- @storage.transaction do |s|
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
- # Mechanics:
160
- # 1. Synthesize a SynthesizedRecognizer from the proposal's prefix.
161
- # 2. Switch to a per-corpus classifier (if we were sharing the
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
- ensure_per_corpus_classifier!
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 Recognizers. Confidence
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
- proposals = propose_recognizers(**propose_opts)
188
- proposals.select { |p| p.confidence >= confidence_threshold }.map { |p| activate_proposal(p) }
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
- raw_shape = PathShape.new(classifier: @classifier, hints: false).from_entries(hinted_entries)
236
- hinted_shape = PathShape.new(classifier: @classifier, hints: true).from_entries(hinted_entries)
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: true, evidence: self)
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, _hints)
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 with
282
- # cluster-inferred param types where available. The mechanical
283
- # NullEvidenceSource provides the classifier-only fallback; this
284
- # version prefers the cluster's observed type per param (dominant
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
- cluster = @storage.cluster_for(key)
295
+ mechanical = NullEvidenceSource.new
292
296
 
293
297
  iri.query_params.keys.sort.map do |k|
294
- v = iri.query_params[k].to_s
295
- type = inferred_param_type(cluster, k, v)
296
- shaped = render_param_value(v, type)
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
- def batch(&block)
377
- @storage.batch(&block)
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
- # If we're still sharing the module-level DEFAULT classifier, switch
383
- # to our own copy so register_recognizer doesn't leak into other
384
- # corpora using the same default singleton.
385
- def ensure_per_corpus_classifier!
386
- return if @classifier != SegmentClassifier::DEFAULT
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
- @classifier = SegmentClassifier.new
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
- return if @storage.activated_recognizer_count.zero?
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
- ensure_per_corpus_classifier!
398
- @storage.each_activated_recognizer do |dump|
399
- @classifier.register_recognizer(SynthesizedRecognizer.from_dump(dump))
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
- stats = @storage.position_stats(Position.path(host: keying_host, prefix: prefix))
531
+ position = Position.path(host: keying_host, prefix: prefix)
413
532
  out = entry.merge(
414
533
  prefix: prefix,
415
- classification: classify(entry, stats),
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
- def classify(entry, stats)
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(value)
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
- # fall through to :variable_identifier (the per-type placeholder).
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.value_counts.key?(value) ? :rare_literal : :ambiguous
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, value) ? :stable_literal : :corpus_inferred_variable
599
+ popular_outlier?(stats) ? :stable_literal : :corpus_inferred_variable
480
600
  elsif stats.cardinality == 1
481
601
  :stable_literal
482
- elsif stats.value_counts.key?(value)
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, value)
497
- count = stats.value_counts[value] || 0
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(value) >= POPULAR_BASELINE_MULTIPLE * baseline
620
+ stats.value_fraction >= POPULAR_BASELINE_MULTIPLE * baseline
502
621
  end
503
622
 
504
- def inferred_param_type(cluster, name, value)
505
- # Prefer the cluster's confident type when we have enough samples;
506
- # otherwise classify the current value directly. Cluster#param_type
507
- # applies the :date quorum gate (see Cluster::DATE_CONFIDENCE_THRESHOLD).
508
- stats = cluster && cluster.param_stats[name]
509
- if stats && stats.total >= MIN_OBSERVATIONS_FOR_INFERENCE
510
- cluster.param_type(name) || @classifier.classify(value)
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 type == :date && (canon = SegmentClassifier.canonical_date(value))
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
- # Dates render in canonical ISO form rather than as a `{date}` placeholder
537
- # matches what mechanical Iriq.normalize does for path segments and
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. Derive a hint from the prefix's last literal segment if
546
- # we can.
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
- tmp = "#{path}.tmp"
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
@@ -37,7 +37,7 @@ module Iriq
37
37
  # Unicode display form (no punycode / percent-encoding pass).
38
38
  def canonical
39
39
  if urn?
40
- "urn:#{nss}"
40
+ "#{scheme}:#{nss}"
41
41
  else
42
42
  out = +""
43
43
  out << "#{scheme}://" if scheme
@@ -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
- v = iri.query_params[k]
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
@@ -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, :cluster
5
+ attr_reader :identifier
6
6
 
7
- def initialize(corpus:, identifier:, cluster:)
8
- @corpus = corpus
9
- @identifier = identifier
10
- @cluster = cluster
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