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.
data/lib/iriq/corpus.rb CHANGED
@@ -42,13 +42,20 @@ module Iriq
42
42
  POPULAR_MIN_COUNT = 5
43
43
  POPULAR_BASELINE_MULTIPLE = 3
44
44
 
45
- attr_reader :storage
45
+ HOST_STRATEGIES = %i[full registrable none].freeze
46
+
47
+ attr_reader :storage, :host_strategy, :classifier
46
48
 
47
49
  def initialize(classifier: SegmentClassifier::DEFAULT,
48
50
  max_values_per_position: PositionStats::DEFAULT_MAX_VALUES,
51
+ host_strategy: :full,
49
52
  storage: nil)
50
- @classifier = classifier
51
- @storage = storage || Storage::Memory.new(
53
+ raise ArgumentError, "host_strategy must be one of #{HOST_STRATEGIES.inspect}" \
54
+ unless HOST_STRATEGIES.include?(host_strategy)
55
+
56
+ @classifier = classifier
57
+ @host_strategy = host_strategy
58
+ @storage = storage || Storage::Memory.new(
52
59
  classifier: classifier,
53
60
  max_values_per_position: max_values_per_position,
54
61
  )
@@ -58,53 +65,252 @@ module Iriq
58
65
  # `.db`/`.sqlite`/`.sqlite3` use SQLite (incremental writes); anything
59
66
  # else uses JSON.
60
67
  def self.open(path, classifier: SegmentClassifier::DEFAULT,
61
- max_values_per_position: PositionStats::DEFAULT_MAX_VALUES)
68
+ max_values_per_position: PositionStats::DEFAULT_MAX_VALUES,
69
+ host_strategy: :full)
62
70
  storage = Storage.open(path,
63
71
  classifier: classifier,
64
72
  max_values_per_position: max_values_per_position)
65
- new(classifier: classifier, storage: storage)
73
+ corpus = new(classifier: classifier, storage: storage, host_strategy: host_strategy)
74
+ corpus.send(:reapply_activated_recognizers!) if storage.respond_to?(:each_activated_recognizer)
75
+ corpus
76
+ end
77
+
78
+ # Normalize the host for keying purposes. `:full` keeps the original
79
+ # host; `:registrable` collapses subdomains via the inline-PSL heuristic
80
+ # (api.foo.com + app.foo.com → foo.com); `:none` ignores host entirely
81
+ # so clusters group across all hosts by shape alone.
82
+ def effective_host(host)
83
+ case @host_strategy
84
+ when :registrable then RegistrableDomain.for(host)
85
+ when :none then ""
86
+ else host
87
+ end
66
88
  end
67
89
 
68
90
  # Observe a single IRI. Returns an Observation.
91
+ #
92
+ # Internally: builds an Event list for the IRI, then applies each event
93
+ # through the Reducer registry inside a single storage transaction. The
94
+ # event list is transient today — a future commit can persist it and
95
+ # replay against alternate reducers / thresholds for re-runnable
96
+ # inference. See lib/iriq/event.rb and lib/iriq/reducer.rb.
69
97
  def observe(input)
70
- iri = coerce(input)
71
- hinted_entries = SegmentHints.derive(iri.path_segments, @classifier)
72
- raw_shape = PathShape.new(classifier: @classifier, hints: false).from_entries(hinted_entries)
73
- hinted_shape = PathShape.new(classifier: @classifier, hints: true).from_entries(hinted_entries)
74
-
98
+ iri = coerce(input)
99
+ events = events_for(iri)
75
100
  cluster = nil
101
+
76
102
  @storage.transaction do |s|
77
- s.increment_host(iri.host)
78
- s.increment_path_length(iri.path_segments.size)
79
- s.increment_raw_shape(raw_shape)
80
- s.increment_fingerprint(hinted_shape)
81
-
82
- prefix = ""
83
- hinted_entries.each do |entry|
84
- s.observe_position(iri.host, prefix, entry[:value], entry[:type])
85
- prefix = "#{prefix}/#{placeholder(entry)}"
103
+ events.each do |e|
104
+ result = Reducer.apply(e, s)
105
+ cluster = result if e.is_a?(Event::ClusterAddition)
86
106
  end
87
-
88
- key, host, scheme, shape = Cluster.key_for(iri, classifier: @classifier, shape: hinted_shape)
89
- cluster = s.add_to_cluster(key, host, scheme, shape, iri)
107
+ s.record_observation(iri.canonical) if s.respond_to?(:record_observation)
90
108
  end
91
109
 
92
110
  Observation.new(corpus: self, identifier: iri, cluster: cluster)
93
111
  end
94
112
 
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:
116
+ #
117
+ # - Tuning thresholds (swap a Corpus constant, call reinfer)
118
+ # - Swapping the classifier (open the Corpus with a different
119
+ # classifier, call reinfer — events are re-derived from raw IRIs)
120
+ # - Recovering after a Reducer-set change
121
+ #
122
+ # Wrapped in a single backend transaction so a failure mid-replay
123
+ # leaves the prior views intact.
124
+ 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
134
+ nil
135
+ end
136
+
137
+ # Number of IRIs in the source-IRI log. The materialized views are
138
+ # derived from this log; reinfer replays it.
139
+ def observed_iri_count
140
+ return @storage.observed_iri_count if @storage.respond_to?(:observed_iri_count)
141
+ 0
142
+ end
143
+
144
+ # Scan observed values for shape patterns that recur frequently enough
145
+ # to suggest a new Recognizer. Returns RecognizerProposal records; nothing
146
+ # is automatically applied — the proposal carries enough evidence for a
147
+ # human to decide whether to bake the Recognizer in.
148
+ #
149
+ # Strategies are pluggable; the default set lives in
150
+ # Iriq::ProposalStrategy::DEFAULTS. Pass `strategies:` to limit / extend.
151
+ # Pass `min_observations:` / `min_coverage:` / `min_hosts:` to tune
152
+ # what passes the noise floor.
153
+ def propose_recognizers(strategies: ProposalStrategy::DEFAULTS, **opts)
154
+ strategies.flat_map { |s| s.propose(@storage, **opts) }
155
+ end
156
+
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
+ #
170
+ # Returns the synthesized Recognizer.
171
+ def activate_proposal(proposal)
172
+ 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
179
+ recognizer
180
+ end
181
+
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
185
+ # corroboration — see RecognizerProposal#compute_confidence.
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) }
189
+ end
190
+
191
+ # Number of activated recognizers persisted with this corpus.
192
+ def activated_recognizer_count
193
+ return @storage.activated_recognizer_count if @storage.respond_to?(:activated_recognizer_count)
194
+ 0
195
+ end
196
+
197
+ # Route shapes that recur across `min_hosts` or more distinct hosts.
198
+ # Returns CrossHostShape records sorted by host_count desc, then by
199
+ # observation_count desc, then by shape (stable, deterministic).
200
+ #
201
+ # Cross-host recurrence is independent evidence of a real semantic
202
+ # pattern — two unrelated hosts inventing the same `/users/{integer}`
203
+ # structure by accident is unlikely. A natural follow-up is feeding
204
+ # this signal back into RecognizerProposal confidence: a proposal
205
+ # supported by N hosts is much stronger than one seen on a single
206
+ # host with the same per-position coverage.
207
+ def cross_host_shapes(min_hosts: 2)
208
+ by_shape = Hash.new { |h, k| h[k] = { hosts: Set.new, count: 0 } }
209
+ @storage.clusters.each do |cluster|
210
+ # Skip non-URL clusters (URN clusters have no host).
211
+ next if cluster.host.nil? || cluster.host.empty?
212
+
213
+ agg = by_shape[cluster.shape]
214
+ agg[:hosts] << cluster.host
215
+ agg[:count] += cluster.count
216
+ end
217
+
218
+ by_shape.filter_map do |shape, data|
219
+ next nil if data[:hosts].size < min_hosts
220
+
221
+ CrossHostShape.new(
222
+ shape: shape,
223
+ hosts: data[:hosts],
224
+ observation_count: data[:count],
225
+ )
226
+ end.sort_by { |s| [-s.host_count, -s.observation_count, s.shape] }
227
+ end
228
+
229
+ # Build the ordered Event list for `input` without applying it. Useful
230
+ # for inspection, tests, and future event-log persistence. Each call is
231
+ # pure — no storage side-effects.
232
+ def events_for(input)
233
+ iri = coerce(input)
234
+ hinted_entries = SegmentHints.derive(iri.path_segments, @classifier)
235
+ shape = Shape.from_entries(hinted_entries)
236
+ raw_shape = shape.render(hints: false)
237
+ hinted_shape = shape.render(hints: true)
238
+ keying_host = effective_host(iri.host)
239
+
240
+ events = [
241
+ Event::HostSeen.new(keying_host),
242
+ Event::PathLengthSeen.new(iri.path_segments.size),
243
+ Event::RawShapeSeen.new(raw_shape),
244
+ Event::FingerprintSeen.new(hinted_shape),
245
+ ]
246
+
247
+ prefix = ""
248
+ hinted_entries.each do |entry|
249
+ events << Event::PositionSeen.new(
250
+ Position.path(host: keying_host, prefix: prefix),
251
+ entry[:value], entry[:type],
252
+ )
253
+ prefix = "#{prefix}/#{placeholder(entry)}"
254
+ end
255
+
256
+ key, host, scheme, shape = Cluster.key_for(iri, classifier: @classifier, shape: hinted_shape, host: keying_host)
257
+ events << Event::ClusterAddition.new(key, host, scheme, shape, iri)
258
+
259
+ events
260
+ end
261
+
95
262
  # Corpus-informed normalization. Falls back to mechanical normalization
96
- # when the corpus has no signal for a position.
263
+ # when the corpus has no signal for a position. Implemented as a thin
264
+ # call into Normalizer with `evidence: self`; the corpus-informed path
265
+ # and query rendering live in #render_path / #render_query below
266
+ # (the evidence-source interface).
97
267
  def normalize(input)
98
268
  iri = coerce(input)
99
- return Normalizer.normalize_identifier(iri) if iri.urn? || iri.path_segments.empty?
269
+ Normalizer.normalize_identifier(iri, classifier: @classifier, hints: true, evidence: self)
270
+ end
100
271
 
272
+ # Evidence-source interface — called by Normalizer when this Corpus is
273
+ # passed as `evidence:`. Renders the path using corpus-informed
274
+ # classifications (variability promotion, popular-outlier preservation).
275
+ # Always emits a leading "/" — empty path collapses to "/" to match
276
+ # mechanical output and anchor any trailing query.
277
+ def render_path(iri, _classifier, _hints)
101
278
  tokens = annotate_segments(iri).map { |entry| corpus_token(entry) }
102
- out = +""
103
- out << "#{iri.scheme}://" if iri.scheme
104
- out << iri.host if iri.host
105
- out << ":#{iri.port}" if iri.port
106
- out << "/" << tokens.join("/")
107
- out
279
+ "/" + tokens.join("/")
280
+ end
281
+
282
+ # Evidence-source interface render the query string with
283
+ # cluster-inferred param types where available. The mechanical
284
+ # NullEvidenceSource provides the classifier-only fallback; this
285
+ # version prefers the cluster's observed type per param (dominant
286
+ # type_count, subject to the corpus thresholds).
287
+ def render_query(iri, _classifier = @classifier)
288
+ hinted_shape = PathShape.new(classifier: @classifier, hints: true)
289
+ .from_entries(SegmentHints.derive(iri.path_segments, @classifier))
290
+ key, * = Cluster.key_for(iri, classifier: @classifier, shape: hinted_shape,
291
+ host: effective_host(iri.host))
292
+ cluster = @storage.cluster_for(key)
293
+
294
+ iri.query_params.keys.sort.map do |k|
295
+ v = iri.query_params[k].to_s
296
+ type = inferred_param_type(cluster, k, v)
297
+ shaped = render_param_value(v, type)
298
+ "#{k}=#{shaped}"
299
+ end.join("&")
300
+ end
301
+
302
+ # Inferred params for the cluster `input` would fall into. Returns the
303
+ # same shape as Cluster#param_summary — useful for "what query params
304
+ # might this URL accept?" tooling. Empty array if no cluster has been
305
+ # observed for this shape yet.
306
+ def params_for(input)
307
+ iri = coerce(input)
308
+ hinted_shape = PathShape.new(classifier: @classifier, hints: true)
309
+ .from_entries(SegmentHints.derive(iri.path_segments, @classifier))
310
+ key, * = Cluster.key_for(iri, classifier: @classifier, shape: hinted_shape,
311
+ host: effective_host(iri.host))
312
+ cluster = @storage.cluster_for(key)
313
+ cluster ? cluster.param_summary : []
108
314
  end
109
315
 
110
316
  # Per-segment explanation with corpus-informed `classification`.
@@ -123,7 +329,7 @@ module Iriq
123
329
  def raw_shape_counts; @storage.raw_shape_counts; end
124
330
  def fingerprint_counts; @storage.fingerprint_counts; end
125
331
 
126
- # Iterates (host, prefix) → PositionStats over all observed positions.
332
+ # Iterates Position → PositionStats over all observed positions.
127
333
  # Used by inspection tooling; not part of the hot path.
128
334
  def each_position_stats(&block)
129
335
  @storage.each_position_stats(&block)
@@ -137,10 +343,12 @@ module Iriq
137
343
  @storage.cluster_size
138
344
  end
139
345
 
140
- # Stats for a given (host, prefix_shape) — useful for tests and
346
+ # Stats for a given (host, path-prefix) — useful for tests and
141
347
  # debugging. Returns nil if nothing has been observed there.
142
- def stats_for(host, prefix)
143
- @storage.position_stats(host, prefix)
348
+ # Accepts either a Position or (host, prefix) for ergonomics.
349
+ def stats_for(host_or_position, prefix = nil)
350
+ position = host_or_position.is_a?(Position) ? host_or_position : Position.path(host: host_or_position, prefix: prefix)
351
+ @storage.position_stats(position)
144
352
  end
145
353
 
146
354
  # Persist the corpus.
@@ -172,6 +380,27 @@ module Iriq
172
380
 
173
381
  private
174
382
 
383
+ # If we're still sharing the module-level DEFAULT classifier, switch
384
+ # to our own copy so register_recognizer doesn't leak into other
385
+ # corpora using the same default singleton.
386
+ def ensure_per_corpus_classifier!
387
+ return if @classifier != SegmentClassifier::DEFAULT
388
+
389
+ @classifier = SegmentClassifier.new
390
+ end
391
+
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
+ def reapply_activated_recognizers!
396
+ return if @storage.activated_recognizer_count.zero?
397
+
398
+ ensure_per_corpus_classifier!
399
+ @storage.each_activated_recognizer do |dump|
400
+ @classifier.register_recognizer(SynthesizedRecognizer.from_dump(dump))
401
+ end
402
+ end
403
+
175
404
  def coerce(input)
176
405
  input.is_a?(Identifier) ? input : Parser.parse(input)
177
406
  end
@@ -179,8 +408,9 @@ module Iriq
179
408
  def annotate_segments(iri)
180
409
  hinted = SegmentHints.derive(iri.path_segments, @classifier)
181
410
  prefix = ""
411
+ keying_host = effective_host(iri.host)
182
412
  hinted.map do |entry|
183
- stats = @storage.position_stats(iri.host, prefix)
413
+ stats = @storage.position_stats(Position.path(host: keying_host, prefix: prefix))
184
414
  out = entry.merge(
185
415
  prefix: prefix,
186
416
  classification: classify(entry, stats),
@@ -193,14 +423,28 @@ module Iriq
193
423
  def placeholder(entry)
194
424
  return entry[:value] unless entry[:variable]
195
425
 
196
- "{#{entry[:hint] || entry[:type]}}"
426
+ "{#{entry[:hint] || SegmentClassifier.display_type(entry[:type])}}"
197
427
  end
198
428
 
429
+ # Types whose values are often a small fixed set (or a single static
430
+ # value baked into a REST route). For these, run through the same
431
+ # cardinality / value-fraction analysis literals get — a dominant
432
+ # value gets preserved as :stable_literal instead of being
433
+ # placeholdered as a generic {version}/{slug}/etc.
434
+ #
435
+ # Slug + opaque_id are here because a lot of route literals
436
+ # accidentally match those shapes (`/users/{id}/create-new`,
437
+ # reference codes like `WK1234`). When a single value dominates the
438
+ # position, the literal is almost always the better display.
439
+ STABLE_VARIABLE_TYPES = %i[version locale currency boolean slug opaque_id].freeze
440
+
199
441
  def classify(entry, stats)
200
442
  variable = entry[:variable]
201
443
 
202
444
  return variable ? :variable_identifier : :ambiguous if stats.nil? || stats.total.zero?
203
- return :variable_identifier if variable
445
+ if variable && !STABLE_VARIABLE_TYPES.include?(entry[:type])
446
+ return :variable_identifier
447
+ end
204
448
 
205
449
  value = entry[:value]
206
450
  total = stats.total
@@ -209,6 +453,17 @@ module Iriq
209
453
  enough_data = total >= MIN_OBSERVATIONS_FOR_INFERENCE
210
454
  value_frac = stats.value_fraction(value)
211
455
 
456
+ # For STABLE_VARIABLE_TYPES (version, locale, currency, boolean),
457
+ # a dominant value wins over the variable-dominance branch — a
458
+ # single-version /api/v1/... pattern stays as the literal `v1`
459
+ # rather than placeholdering to {version}. Without dominance,
460
+ # fall through to :variable_identifier (the per-type placeholder).
461
+ if variable
462
+ return :stable_literal if value_frac >= STABLE_LITERAL_THRESHOLD
463
+
464
+ return :variable_identifier
465
+ end
466
+
212
467
  if enough_data && variable_frac >= VARIABLE_DOMINANCE_THRESHOLD
213
468
  # Position is dominated by variable types (UUIDs, integers, etc.).
214
469
  # A literal here is a special-case outlier (e.g. /users/me).
@@ -247,6 +502,28 @@ module Iriq
247
502
  stats.value_fraction(value) >= POPULAR_BASELINE_MULTIPLE * baseline
248
503
  end
249
504
 
505
+ def inferred_param_type(cluster, name, value)
506
+ # Prefer the cluster's confident type when we have enough samples;
507
+ # otherwise classify the current value directly. Cluster#param_type
508
+ # applies the :date quorum gate (see Cluster::DATE_CONFIDENCE_THRESHOLD).
509
+ stats = cluster && cluster.param_stats[name]
510
+ if stats && stats.total >= MIN_OBSERVATIONS_FOR_INFERENCE
511
+ cluster.param_type(name) || @classifier.classify(value)
512
+ else
513
+ @classifier.classify(value)
514
+ end
515
+ end
516
+
517
+ def render_param_value(value, type)
518
+ if type == :date && (canon = SegmentClassifier.canonical_date(value))
519
+ canon
520
+ elsif @classifier.variable?(type)
521
+ "{#{SegmentClassifier.display_type(type)}}"
522
+ else
523
+ value
524
+ end
525
+ end
526
+
250
527
  def corpus_token(entry)
251
528
  case entry[:classification]
252
529
  when :variable_identifier, :corpus_inferred_variable
@@ -257,7 +534,13 @@ module Iriq
257
534
  end
258
535
 
259
536
  def placeholder_for_variable(entry)
260
- return "{#{entry[:hint] || entry[:type]}}" if entry[:variable]
537
+ # Dates render in canonical ISO form rather than as a `{date}` placeholder
538
+ # — matches what mechanical Iriq.normalize does for path segments and
539
+ # what render_param_value does for query params.
540
+ if entry[:type] == :date && (canon = SegmentClassifier.canonical_date(entry[:value]))
541
+ return canon
542
+ end
543
+ return "{#{entry[:hint] || SegmentClassifier.display_type(entry[:type])}}" if entry[:variable]
261
544
 
262
545
  # corpus-inferred variable: classifier said literal, corpus says
263
546
  # otherwise. Derive a hint from the prefix's last literal segment if
@@ -0,0 +1,37 @@
1
+ require "set"
2
+
3
+ module Iriq
4
+ # A route shape that recurs across multiple hosts.
5
+ #
6
+ # Emitted by Corpus#cross_host_shapes. The shape string ("/users/{user_id}")
7
+ # is the cluster's rendered placeholder form; two clusters with the same
8
+ # shape but different hosts coalesce into one CrossHostShape record.
9
+ #
10
+ # A shape appearing at N hosts is strong evidence of a semantic pattern
11
+ # rather than a host-local quirk — independent hosts are unlikely to
12
+ # invent the same `/users/{integer}` structure by accident. Future work
13
+ # can feed this signal into proposal confidence and corpus-informed
14
+ # normalization (raise weight when a Shape has cross-host support).
15
+ class CrossHostShape
16
+ attr_reader :shape, :hosts, :observation_count
17
+
18
+ def initialize(shape:, hosts:, observation_count:)
19
+ @shape = shape
20
+ @hosts = hosts.is_a?(Set) ? hosts.dup.freeze : Set.new(hosts).freeze
21
+ @observation_count = observation_count
22
+ end
23
+
24
+ def host_count
25
+ @hosts.size
26
+ end
27
+
28
+ def to_h
29
+ {
30
+ shape: @shape,
31
+ hosts: @hosts.to_a.sort,
32
+ host_count: host_count,
33
+ observation_count: @observation_count,
34
+ }
35
+ end
36
+ end
37
+ end
data/lib/iriq/event.rb ADDED
@@ -0,0 +1,22 @@
1
+ module Iriq
2
+ # Events are the atomic observation-time facts emitted by Corpus#observe
3
+ # before any state changes. A single observe(iri) call emits a small
4
+ # ordered list of Events; Reducers consume that list to update materialized
5
+ # views (host counts, position stats, clusters, etc.).
6
+ #
7
+ # Today the event list is transient — built fresh per observe(), applied,
8
+ # and discarded. The shape is in place so a future commit can persist the
9
+ # log and replay it to re-derive materialized views without re-feeding
10
+ # source IRIs (the "re-runnable inference" win from ROADMAP.md).
11
+ #
12
+ # Each Event is a Struct so callers can pattern-match on type and access
13
+ # fields positionally or by name.
14
+ module Event
15
+ HostSeen = Struct.new(:host)
16
+ PathLengthSeen = Struct.new(:length)
17
+ RawShapeSeen = Struct.new(:shape)
18
+ FingerprintSeen = Struct.new(:shape)
19
+ PositionSeen = Struct.new(:position, :value, :type)
20
+ ClusterAddition = Struct.new(:key, :host, :scheme, :shape, :identifier)
21
+ end
22
+ end
@@ -0,0 +1,114 @@
1
+ module Iriq
2
+ # Evidence is the structured substrate for explanation. Each Record
3
+ # captures one fact about the system's reasoning: "this segment
4
+ # classified as :integer because the Integer recognizer fired with
5
+ # specificity TYPED", "the IPv4 type collapses to {ip} by policy",
6
+ # "Position P is mostly variable because of corpus stats".
7
+ #
8
+ # Trace and Explanation are views over a list of Evidence records;
9
+ # the structured form is what programmatic consumers (test assertions,
10
+ # PR-diff annotators, downstream tooling) should build on. Human note
11
+ # strings emitted by Trace are derived from Evidence payloads, so
12
+ # adding a new note kind starts with adding a new Evidence shape.
13
+ #
14
+ # Two axes:
15
+ #
16
+ # subject_kind ∈ {:segment, :position, :cluster}
17
+ # What this Evidence is about. Today most Evidence is :segment
18
+ # (per-segment classification facts). :position and :cluster
19
+ # Evidence become load-bearing once corpus-informed Trace lands
20
+ # in a follow-up step.
21
+ #
22
+ # source ∈ {:lexical, :recognizer, :corpus, :neighbor, :policy}
23
+ # What kind of fact is being asserted.
24
+ # :lexical — pure shape match (e.g. "matches DATE_RE")
25
+ # :recognizer — a named Recognizer fired with confidence/specificity
26
+ # :corpus — aggregated counts/distributions support this
27
+ # :neighbor — adjacent context informed this (prior literal,
28
+ # param name hint)
29
+ # :policy — a normalization policy applied (ip umbrella
30
+ # collapse, canonical date, currency upcase)
31
+ module Evidence
32
+ SUBJECT_KINDS = %i[segment position cluster].freeze
33
+ SOURCES = %i[lexical recognizer corpus neighbor policy].freeze
34
+
35
+ # A single Evidence fact.
36
+ #
37
+ # subject_kind — :segment | :position | :cluster
38
+ # subject — kind-specific identity:
39
+ # :segment → { index:, value: }
40
+ # :position → Iriq::Position
41
+ # :cluster → cluster key (string)
42
+ # source — :lexical | :recognizer | :corpus | :neighbor | :policy
43
+ # payload — source-and-kind-specific structured data
44
+ # weight — optional float in [0,1] — contribution to the
45
+ # ultimate decision. Set when scoring is meaningful;
46
+ # nil otherwise.
47
+ # notes — optional human-readable strings. Trace renders
48
+ # these directly; programmatic consumers can ignore.
49
+ class Record
50
+ attr_reader :subject_kind, :subject, :source, :payload, :weight, :notes
51
+
52
+ def initialize(subject_kind:, subject:, source:, payload:, weight: nil, notes: [])
53
+ unless SUBJECT_KINDS.include?(subject_kind)
54
+ raise ArgumentError, "subject_kind must be one of #{SUBJECT_KINDS.inspect}"
55
+ end
56
+ unless SOURCES.include?(source)
57
+ raise ArgumentError, "source must be one of #{SOURCES.inspect}"
58
+ end
59
+
60
+ @subject_kind = subject_kind
61
+ @subject = subject
62
+ @source = source
63
+ @payload = payload || {}
64
+ @weight = weight
65
+ @notes = notes || []
66
+ end
67
+
68
+ def to_h
69
+ {
70
+ subject_kind: @subject_kind,
71
+ subject: subject_serialized,
72
+ source: @source,
73
+ payload: @payload,
74
+ weight: @weight,
75
+ notes: @notes,
76
+ }.compact
77
+ end
78
+
79
+ private
80
+
81
+ def subject_serialized
82
+ return @subject.to_h if @subject.respond_to?(:to_h) && !@subject.is_a?(Hash)
83
+ @subject
84
+ end
85
+ end
86
+
87
+ module_function
88
+
89
+ # Factories so call sites don't have to repeat subject_kind:.
90
+ def segment(index:, value:, source:, payload:, weight: nil, notes: [])
91
+ Record.new(
92
+ subject_kind: :segment,
93
+ subject: { index: index, value: value },
94
+ source: source, payload: payload, weight: weight, notes: notes,
95
+ )
96
+ end
97
+
98
+ def position(position:, source:, payload:, weight: nil, notes: [])
99
+ Record.new(
100
+ subject_kind: :position,
101
+ subject: position,
102
+ source: source, payload: payload, weight: weight, notes: notes,
103
+ )
104
+ end
105
+
106
+ def cluster(key:, source:, payload:, weight: nil, notes: [])
107
+ Record.new(
108
+ subject_kind: :cluster,
109
+ subject: key,
110
+ source: source, payload: payload, weight: weight, notes: notes,
111
+ )
112
+ end
113
+ end
114
+ end
@@ -4,7 +4,7 @@ module Iriq
4
4
  # Explanation.explain("https://foo.com/users/123")
5
5
  # # => [
6
6
  # # { value: "users", type: :literal, variable: false, hint: nil },
7
- # # { value: "123", type: :integer_id, variable: true, hint: "user_id" },
7
+ # # { value: "123", type: :integer, variable: true, hint: "user_id" },
8
8
  # # ]
9
9
  module Explanation
10
10
  module_function
@@ -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