iriq 0.30.2 → 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/cluster.rb CHANGED
@@ -30,14 +30,35 @@ module Iriq
30
30
  NUMBER_CONFIDENCE_THRESHOLD = 0.8
31
31
  NUMBER_SUBTYPE_THRESHOLD = 0.8
32
32
 
33
+ # Param classification is a confidence ladder: constant → string → enum.
34
+ # A param with a single observed value is a constant (rendered as-is); one
35
+ # that varies but isn't yet a trustworthy enum is :string (a generic
36
+ # placeholder); a bounded, well-supported value set is :enum.
37
+ #
33
38
  # `:enum` thresholds. Promote a param to :enum when the corpus has seen
34
- # enough samples to trust the bound, the value set is small, each value
35
- # appears more than once (rules out singletons), and the tracked values
36
- # account for nearly all observations (lets a few stragglers through).
39
+ # enough samples to trust the bound (ENUM_MIN_OBSERVATIONS), the *established*
40
+ # values those seen at least ENUM_MIN_VALUE_COUNT times are few
41
+ # (ENUM_MAX_CARDINALITY) and cover nearly all observations
42
+ # (ENUM_MIN_COVERAGE). Rare one-off values are stragglers, not
43
+ # disqualifiers: this is what keeps a single brand-new value from knocking
44
+ # an established enum back down (the observe-before-normalize case).
37
45
  ENUM_MIN_OBSERVATIONS = 20
38
46
  ENUM_MAX_CARDINALITY = 10
39
- ENUM_MIN_VALUE_COUNT = 2
40
- ENUM_MIN_COVERAGE = 0.95
47
+ ENUM_MIN_VALUE_COUNT = 3
48
+ ENUM_MIN_COVERAGE = 0.9
49
+ # An enum is a bounded *set*: a single repeated value is a constant, not an
50
+ # enum, so it takes at least two established members to qualify.
51
+ ENUM_MIN_MEMBERS = 2
52
+
53
+ # `:string` — a param that has taken on 2+ distinct non-typed values but
54
+ # isn't (yet) a confident enum. The intermediate rung: we know it varies
55
+ # and looks like free-form text, but haven't earned the bounded-set claim.
56
+ STRING_MIN_DISTINCT = 2
57
+
58
+ # Confidence smoothing constant. confidence = total / (total + K): a
59
+ # monotone curve that is 0.5 at K observations and asymptotes to 1.0. The
60
+ # type names our guess; this number says how much evidence backs it.
61
+ CONFIDENCE_SMOOTHING = 15
41
62
 
42
63
  def initialize(key:, host:, scheme:, shape:, max_values: PositionStats::DEFAULT_MAX_VALUES)
43
64
  @key = key
@@ -72,7 +93,8 @@ module Iriq
72
93
  return unless identifier.query_params
73
94
  identifier.query_params.each do |name, value|
74
95
  stats = @param_stats[name] ||= PositionStats.new(max_values: @max_values)
75
- stats.observe(value.to_s, classifier.classify(value.to_s))
96
+ value_s = value.to_s
97
+ stats.observe(value_s, classifier.classify(value_s))
76
98
  end
77
99
  end
78
100
 
@@ -105,7 +127,9 @@ module Iriq
105
127
  end
106
128
 
107
129
  # Per-param summary, ordered by descending presence. Each entry is:
108
- # { name: "page", count: N, type: :integer, cardinality: K, presence: 0.83 }
130
+ # { name: "page", count: N, type: :integer, confidence: 0.83,
131
+ # cardinality: K, presence: 0.83 }
132
+ # confidence is how much evidence backs the type (see param_confidence);
109
133
  # presence is count / @count — the fraction of observations that had
110
134
  # this param.
111
135
  def param_summary
@@ -118,6 +142,7 @@ module Iriq
118
142
  name: name,
119
143
  count: stats.total,
120
144
  type: type,
145
+ confidence: param_confidence(stats),
121
146
  cardinality: stats.cardinality,
122
147
  presence: @count.positive? ? stats.total.to_f / @count : 0.0,
123
148
  }
@@ -205,6 +230,11 @@ module Iriq
205
230
  return hint
206
231
  end
207
232
 
233
+ # :string rung — a literal-valued param that has taken on more than one
234
+ # distinct value varies, so it's a placeholder, not a fixed constant.
235
+ # Below the enum bar (checked above), so we claim only "free-form text".
236
+ return :string if type == :literal && stats.cardinality >= STRING_MIN_DISTINCT
237
+
208
238
  type
209
239
  end
210
240
 
@@ -239,21 +269,37 @@ module Iriq
239
269
  end
240
270
 
241
271
  # True when stats shows a bounded set of repeated values worth treating
242
- # as an enum. See ENUM_* constants at the top of this class.
272
+ # as an enum. Built around the *established* members (values seen at least
273
+ # ENUM_MIN_VALUE_COUNT times) so a stray one-off value is a straggler, not
274
+ # a disqualifier. See ENUM_* constants at the top of this class.
243
275
  def enum?(stats)
244
276
  return false if stats.total < ENUM_MIN_OBSERVATIONS
245
- return false if stats.cardinality.zero? || stats.cardinality > ENUM_MAX_CARDINALITY
246
- return false if stats.value_counts.any? { |_, n| n < ENUM_MIN_VALUE_COUNT }
247
277
 
248
- coverage = stats.value_counts.values.sum.to_f / stats.total
278
+ established = established_values(stats)
279
+ return false unless established.size.between?(ENUM_MIN_MEMBERS, ENUM_MAX_CARDINALITY)
280
+
281
+ coverage = established.values.sum.to_f / stats.total
249
282
  coverage >= ENUM_MIN_COVERAGE
250
283
  end
251
284
 
252
- # Distinct values tracked for this param, ordered by descending count
253
- # (lex tie-break). Returned alongside :enum-typed rows in param_summary
254
- # so verbose/explain consumers can render the value set.
285
+ # Values seen often enough to count as real members of the set (vs noise).
286
+ def established_values(stats)
287
+ stats.value_counts.select { |_, n| n >= ENUM_MIN_VALUE_COUNT }
288
+ end
289
+
290
+ # Confidence that the assigned type is right, given how much evidence backs
291
+ # it. Monotone in observation count; 0.5 at CONFIDENCE_SMOOTHING, → 1.0.
292
+ def param_confidence(stats)
293
+ return 0.0 if stats.total.zero?
294
+
295
+ (stats.total.to_f / (stats.total + CONFIDENCE_SMOOTHING)).round(2)
296
+ end
297
+
298
+ # The enum's member values — the established ones (seen enough to be real),
299
+ # ordered by descending count (lex tie-break). Stragglers are excluded so
300
+ # the advertised set is what the corpus is actually confident about.
255
301
  def enum_values(stats)
256
- stats.value_counts.sort_by { |v, n| [-n, v] }.map(&:first)
302
+ established_values(stats).sort_by { |v, n| [-n, v] }.map(&:first)
257
303
  end
258
304
 
259
305
  # value_distribution returns the fraction of total observations each
@@ -357,8 +403,8 @@ module Iriq
357
403
  if iri.urn?
358
404
  ns, value = (iri.nss || "").split(":", 2)
359
405
  derived = value ? urn_value_shape(ns, value, classifier) : nil
360
- key = "urn:#{ns}:#{derived}"
361
- [key, nil, "urn", key]
406
+ key = "#{iri.scheme}:#{ns}:#{derived}"
407
+ [key, nil, iri.scheme, key]
362
408
  else
363
409
  shape ||= PathShape.new(classifier: classifier).for(iri.path_segments)
364
410
  effective_host = host.nil? ? iri.host : host
@@ -47,17 +47,6 @@ module Iriq
47
47
  end
48
48
  end
49
49
 
50
- def dump
51
- { "clusters" => clusters.each_with_object({}) { |c, h| h[c.key] = c.dump } }
52
- end
53
-
54
- def self.from_dump(h, classifier: SegmentClassifier::DEFAULT)
55
- c = new(classifier: classifier)
56
- restored = h["clusters"].transform_values { |cdump| Cluster.from_dump(cdump) }
57
- c.instance_variable_get(:@storage).instance_variable_set(:@clusters, restored)
58
- c
59
- end
60
-
61
50
  private
62
51
 
63
52
  def coerce(input)
data/lib/iriq/corpus.rb CHANGED
@@ -232,8 +232,9 @@ module Iriq
232
232
  def events_for(input)
233
233
  iri = coerce(input)
234
234
  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)
235
+ shape = Shape.from_entries(hinted_entries)
236
+ raw_shape = shape.render(hints: false)
237
+ hinted_shape = shape.render(hints: true)
237
238
  keying_host = effective_host(iri.host)
238
239
 
239
240
  events = [
@@ -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
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
@@ -79,8 +79,8 @@ module Iriq
79
79
  end
80
80
 
81
81
  # Most common type. On count ties, breaks lexicographically by type
82
- # symbol name so the result is deterministic and matches Go's
83
- # DominantType (Go's map iteration is randomized).
82
+ # symbol name so the result is deterministic and matches the Rust
83
+ # port's dominant_type regardless of map iteration order.
84
84
  def dominant_type
85
85
  best = nil
86
86
  best_count = -1
@@ -32,7 +32,7 @@ module Iriq
32
32
  # mutually-exclusive on shape, so the ensemble is effectively a
33
33
  # short-circuit OR. As more Recognizers carve out of SegmentClassifier
34
34
  # they'll join the pool and the scoring becomes load-bearing.
35
- def self.ensemble(segment, *recognizers)
35
+ def self.ensemble(segment, recognizers)
36
36
  best = nil
37
37
  best_score = -1.0
38
38
  recognizers.each do |r|
@@ -115,7 +115,7 @@ module Iriq
115
115
  acc[:positions] << position
116
116
  acc[:hosts] << position.host
117
117
  # Collect every match; we'll sort + cap to a stable top-N at
118
- # emission time so Ruby and Go produce identical samples
118
+ # emission time so Ruby and Rust produce identical samples
119
119
  # regardless of underlying Hash / map iteration order.
120
120
  acc[:matches] << value
121
121
  end
@@ -135,7 +135,7 @@ module Iriq
135
135
  hosts: acc[:hosts],
136
136
  coverage: coverage,
137
137
  observation_count: acc[:matching_count],
138
- # Sort + cap to 5 so Ruby and Go produce identical samples
138
+ # Sort + cap to 5 so Ruby and Rust produce identical samples
139
139
  # regardless of underlying Hash / map iteration order. The
140
140
  # samples are illustrative for humans; alphabetical is fine.
141
141
  sample_values: acc[:matches].sort.first(5),
@@ -14,8 +14,10 @@ module Iriq
14
14
  #
15
15
  # `:enum` is similarly corpus-only — it surfaces when a position has a
16
16
  # bounded set of distinct values observed across enough samples (see
17
- # Cluster::ENUM_* thresholds).
18
- TYPES = %i[literal integer float number uuid date year timestamp hash slug
17
+ # Cluster::ENUM_* thresholds). `:string` is the rung below `:enum`: a
18
+ # param that varies across free-form literal values but isn't a confident
19
+ # bounded set yet. Neither is ever returned for a single value.
20
+ TYPES = %i[literal string integer float number uuid date year timestamp hash slug
19
21
  ipv4 ipv6 url email boolean version locale currency phone jwt mime
20
22
  file color coordinate country base64 http_status enum opaque_id].freeze
21
23
 
@@ -125,9 +127,10 @@ module Iriq
125
127
  document: %w[pdf doc docx xls xlsx ppt pptx odt ods odp rtf epub],
126
128
  data: %w[csv tsv json xml yaml yml parquet sqlite db ndjson jsonl],
127
129
  text: %w[txt md log markdown rst],
128
- web: %w[html htm css js mjs cjs ts jsx tsx],
130
+ web: %w[html htm css js mjs cjs ts jsx tsx map],
131
+ font: %w[woff woff2 ttf otf eot],
129
132
  audio: %w[mp3 wav ogg flac aac m4a opus],
130
- video: %w[mp4 mov avi mkv webm flv wmv m4v],
133
+ video: %w[mp4 mov avi mkv webm flv wmv m4v m3u8],
131
134
  archive: %w[zip tar gz bz2 7z rar xz tgz],
132
135
  code: %w[rb py go java c cc cpp h hpp sh swift kt rs],
133
136
  }.freeze
@@ -262,7 +265,7 @@ module Iriq
262
265
  # Scored ensemble over the live Recognizer list — built-ins +
263
266
  # anything Corpus#activate_proposal has registered for this
264
267
  # classifier instance.
265
- if (v = Recognizer.ensemble(segment, *@recognizers))
268
+ if (v = Recognizer.ensemble(segment, @recognizers))
266
269
  return v[:type]
267
270
  end
268
271
 
@@ -11,9 +11,9 @@ module Iriq
11
11
  # integer could plausibly be a year, an HTTP status, or an ID, so
12
12
  # `:integer` claims only TYPED.
13
13
  #
14
- # Calibration corpus tests in spec/iriq/calibration_spec.rb / Go's
15
- # calibration_test.go are the source of truth for whether these
16
- # values are well-chosen — adjust them and re-run to validate.
14
+ # Calibration corpus tests in spec/iriq/calibration_spec.rb are the
15
+ # source of truth for whether these values are well-chosen — adjust
16
+ # them and re-run to validate.
17
17
  module Specificity
18
18
  # Unambiguous semantic shapes — the regex effectively can't fire by
19
19
  # accident. (UUID, JWT, email with @, URL with ://, color hex.)
@@ -141,7 +141,7 @@ module Iriq
141
141
  # concurrent open, and without busy_timeout set they fail
142
142
  # immediately with SQLITE_BUSY.
143
143
  @db.execute("PRAGMA busy_timeout = 30000")
144
- @db.execute("PRAGMA journal_mode = WAL")
144
+ enable_wal!
145
145
  @db.execute("PRAGMA synchronous = NORMAL")
146
146
  @db.execute("PRAGMA foreign_keys = ON")
147
147
  @in_batch = false
@@ -151,8 +151,11 @@ module Iriq
151
151
  @db.execute_batch(SCHEMA)
152
152
  existing = @db.get_first_value("SELECT value FROM meta WHERE key = 'schema_version'")
153
153
  if existing.nil?
154
- @db.execute("INSERT INTO meta (key, value) VALUES ('schema_version', ?)", SCHEMA_VERSION.to_s)
155
- @db.execute("INSERT INTO meta (key, value) VALUES ('max_values_per_position', ?)",
154
+ # OR IGNORE: two processes can race to initialize a fresh corpus
155
+ # concurrently both read schema_version as nil, and the loser's
156
+ # INSERT must not blow up on the PRIMARY KEY.
157
+ @db.execute("INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', ?)", SCHEMA_VERSION.to_s)
158
+ @db.execute("INSERT OR IGNORE INTO meta (key, value) VALUES ('max_values_per_position', ?)",
156
159
  @max_values_per_position.to_s)
157
160
  else
158
161
  @max_values_per_position = (@db.get_first_value(
@@ -280,12 +283,18 @@ module Iriq
280
283
  ON CONFLICT(key) DO UPDATE SET count = count + 1
281
284
  SQL
282
285
 
283
- # Examples — capped at Cluster::MAX_EXAMPLES.
286
+ # Examples — capped at Cluster::MAX_EXAMPLES, deduped by canonical
287
+ # (mirrors Cluster#add so the SQLite view matches the in-memory one).
288
+ canonical = identifier.canonical
284
289
  examples_count = @db.get_first_value(
285
290
  "SELECT COUNT(*) FROM cluster_examples WHERE cluster_key = ?", [key],
286
291
  )
287
- if examples_count < Cluster::MAX_EXAMPLES
288
- @db.execute(<<~SQL, [key, examples_count, identifier.canonical])
292
+ already_present = @db.get_first_value(
293
+ "SELECT 1 FROM cluster_examples WHERE cluster_key = ? AND canonical = ?",
294
+ [key, canonical],
295
+ )
296
+ if examples_count < Cluster::MAX_EXAMPLES && already_present.nil?
297
+ @db.execute(<<~SQL, [key, examples_count, canonical])
289
298
  INSERT INTO cluster_examples (cluster_key, position, canonical)
290
299
  VALUES (?, ?, ?)
291
300
  SQL
@@ -439,6 +448,7 @@ module Iriq
439
448
  ) { |r| tc[r[0].to_sym] = r[1] }
440
449
  stats.instance_variable_set(:@type_counts, tc)
441
450
 
451
+ recompute_numeric!(stats)
442
452
  stats
443
453
  end
444
454
 
@@ -471,6 +481,24 @@ module Iriq
471
481
 
472
482
  private
473
483
 
484
+ # Converting a rollback-mode database to WAL takes an exclusive lock,
485
+ # and SQLite does NOT consult the busy handler for that lock — so
486
+ # concurrent first-opens of a fresh corpus can fail with SQLITE_BUSY
487
+ # even with busy_timeout set. WAL is a persistent database property:
488
+ # retry briefly — either this connection wins the conversion or
489
+ # another process already converted the file.
490
+ def enable_wal!
491
+ attempts = 0
492
+ begin
493
+ @db.execute("PRAGMA journal_mode = WAL")
494
+ rescue SQLite3::BusyException
495
+ raise if (attempts += 1) > 100
496
+
497
+ sleep 0.01
498
+ retry
499
+ end
500
+ end
501
+
474
502
  def upsert_shape(table, shape)
475
503
  @db.execute(<<~SQL, shape)
476
504
  INSERT INTO #{table} (shape, count) VALUES (?, 1)
@@ -537,10 +565,39 @@ module Iriq
537
565
  stats = params[r[0]] or next
538
566
  stats.type_counts[r[1].to_sym] = r[2]
539
567
  end
568
+ params.each_value { |stats| recompute_numeric!(stats) }
540
569
  c.instance_variable_set(:@param_stats, params)
541
570
 
542
571
  c
543
572
  end
573
+
574
+ # The rolling numeric aggregates (count/min/max/sum) aren't stored in
575
+ # the schema — rebuild them from the tracked value counts, mirroring
576
+ # the Rust backend: only positions with integer/float observations
577
+ # qualify, and cap-trimmed values are lost.
578
+ def recompute_numeric!(stats)
579
+ return if (stats.type_counts[:integer] + stats.type_counts[:float]).zero?
580
+
581
+ count = 0
582
+ min = nil
583
+ max = nil
584
+ sum = 0.0
585
+ stats.value_counts.each do |value, n|
586
+ num = Float(value, exception: false)
587
+ next unless num
588
+
589
+ count += n
590
+ min = num if min.nil? || num < min
591
+ max = num if max.nil? || num > max
592
+ n.times { sum += num }
593
+ end
594
+ return if count.zero?
595
+
596
+ stats.instance_variable_set(:@numeric_count, count)
597
+ stats.instance_variable_set(:@numeric_min, min)
598
+ stats.instance_variable_set(:@numeric_max, max)
599
+ stats.instance_variable_set(:@numeric_sum, sum)
600
+ end
544
601
  end
545
602
  end
546
603
  end
data/lib/iriq/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Iriq
2
- VERSION = "0.30.2"
2
+ VERSION = "0.33.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: iriq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.2
4
+ version: 0.33.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Pepper
@@ -86,16 +86,10 @@ extensions: []
86
86
  extra_rdoc_files: []
87
87
  files:
88
88
  - CHANGELOG.md
89
- - CLAUDE.md
90
- - Gemfile
91
- - Gemfile.lock
92
89
  - LICENSE.txt
93
- - Makefile
94
90
  - README.md
95
91
  - completions/_iriq
96
92
  - completions/iriq.bash
97
- - docs/ARCHITECTURE.md
98
- - docs/ROADMAP.md
99
93
  - exe/iriq
100
94
  - iriq.gemspec
101
95
  - lib/iriq.rb
@@ -153,7 +147,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
153
147
  - !ruby/object:Gem::Version
154
148
  version: '0'
155
149
  requirements: []
156
- rubygems_version: 4.0.11
150
+ rubygems_version: 3.6.9
157
151
  specification_version: 4
158
152
  summary: IRI extraction, normalization, and clustering.
159
153
  test_files: []
data/CLAUDE.md DELETED
@@ -1,208 +0,0 @@
1
- # Iriq development conventions
2
-
3
- > **⚠️ Behavior changes touch ALL THREE runtimes.** Ruby is the reference; Go
4
- > + Rust mirror it. Before committing any change to
5
- > parser/normalizer/extractor/CLI/etc:
6
- >
7
- > 1. Update Ruby + specs.
8
- > 2. `bundle exec ruby script/generate_fixtures.rb` (regenerate JSON parity fixtures).
9
- > 3. Port the change to Go (the Go module lives in `go/`).
10
- > 4. `go -C go test ./...` — fixture tests should still pass.
11
- > 5. `make build && script/cli_parity.sh` — Ruby ↔ Go CLI parity should still pass.
12
- > 6. Port the change to Rust under `rust/`.
13
- > 7. `cd rust && cargo test --workspace` — Rust fixture tests should still pass
14
- > (SQLite is a default feature).
15
- > 8. `cd rust && cargo build --release --bin iriq && cd .. && script/rust_parity.sh`
16
- > — Rust ↔ Go CLI parity (covers Ruby transitively).
17
- > 9. Commit the regenerated fixtures alongside the code change.
18
- >
19
- > CI's parity + Rust jobs will fail if any step is skipped. The **Rust gate**
20
- > (fmt + clippy + tests) is automated — run `make hooks` once to install the
21
- > committed pre-push hook that runs `make check`. Full multi-runtime pre-push
22
- > for a behavior change:
23
- > `bundle exec rspec && go -C go test ./... && script/cli_parity.sh && make check && script/rust_parity.sh`.
24
-
25
- ## Repo layout — Ruby at the root, Go and Rust in subdirs
26
-
27
- The Ruby gem lives at the repo root (it's the reference implementation and the
28
- published gem); the two mirror implementations are compartmentalized into
29
- `go/` and `rust/`. Earlier the Go code was intermixed at the root; it now sits
30
- in `go/`, symmetric with `rust/`, so the root reads as "Ruby + two ports."
31
-
32
- ```
33
- iriq/
34
- lib/ exe/ spec/ ← Ruby gem (library, CLI, specs) — the reference
35
- completions/ ← shell-completion scripts shipped by the gem
36
- iriq.gemspec
37
- Gemfile
38
-
39
- go/ ← Go module github.com/dpep/iriq/go
40
- go.mod go.sum
41
- *.go ← Go package `iriq`
42
- cmd/iriq/ ← Go CLI binary
43
- completions/ ← Go's own embedded copy (go:embed can't reach ../)
44
-
45
- rust/ ← Cargo workspace
46
- Cargo.toml ← workspace root
47
- iriq/ ← one crate: library + `iriq` CLI binary; inlines completions
48
- REPORT.md ← Go → Rust port spike notes + perf
49
- target/ ← Rust build artifacts (gitignored)
50
-
51
- bin/ ← built Go binary (gitignored)
52
- script/ ← shared dev scripts (fixture gen, parity, benches)
53
- spec/fixtures/ ← golden JSON shared by Ruby specs + Go + Rust tests
54
- .github/workflows/ ← Ruby CI, Go CI, Rust CI, parity CIs
55
- ```
56
-
57
- Notes on this layout:
58
-
59
- - Go's import path is now `github.com/dpep/iriq/go` (the `/go` suffix matches
60
- the subdir). Consumers import `github.com/dpep/iriq/go`.
61
- - One version tag (`vX.Y.Z`) serves all three runtimes — Ruby's gemspec, Go's
62
- module, and Rust's `Cargo.toml` use the same tag stream.
63
- - The gemspec ships only Ruby + `completions/`, excluding `go/` and `rust/`:
64
- `git ls-files * ':!:spec' ':!:script' ':!:bin' ':!:rust' ':!:go'`.
65
- - Completion scripts exist in three places (gem root `completions/`, `go/completions/`
66
- for `go:embed`, and inlined in the Rust CLI) — keep them in sync like fixtures.
67
-
68
- ## Building
69
-
70
- ```sh
71
- # Ruby gem
72
- bundle install
73
- bundle exec exe/iriq --help # runs the CLI from source
74
-
75
- # Go binary — convenience targets in the Makefile
76
- make build # → ./bin/iriq
77
- make install # go install into $GOBIN
78
- make uninstall # remove from $GOBIN
79
- make clean # remove ./bin/
80
- make test # go test ./...
81
-
82
- # Rust — one crate (library + `iriq` binary), SQLite bundled by default
83
- cd rust && cargo build --release --bin iriq # → ./rust/target/release/iriq
84
- cd rust && cargo install --path iriq # install into ~/.cargo/bin
85
- cd rust && cargo test --workspace
86
-
87
- # Via Homebrew (builds the Rust CLI from main)
88
- brew install dpep/tools/iriq
89
-
90
- # Via crates.io
91
- cargo install iriq
92
- ```
93
-
94
- ## Keeping the three runtimes in sync
95
-
96
- Ruby is the **reference implementation**. Go and Rust mirror its public API
97
- and behavior. Three layers of parity testing keep them aligned:
98
-
99
- 1. **Golden JSON fixtures** (`spec/fixtures/*.json`)
100
- Generated by `script/generate_fixtures.rb` from the Ruby implementation
101
- over a curated set of inputs. Go's `fixtures_test.go` and Rust's
102
- `rust/iriq/tests/fixtures.rs` both load each file and assert the same
103
- outputs.
104
-
105
- 2. **Ruby ↔ Go CLI parity harness** (`script/cli_parity.sh`)
106
- Runs the same input through `bundle exec exe/iriq` and the Go binary and
107
- diffs stdout. Lives in CI as the `Ruby ↔ Go parity` job.
108
-
109
- 3. **Rust ↔ Go CLI parity harness** (`script/rust_parity.sh`)
110
- Same idea — runs every Phase 1 + Phase 2 scenario (single-input,
111
- pipe-mode, JSON corpus, SQLite corpus, --stats, --reinfer,
112
- --propose-recognizers, --cross-host-shapes, --host=reg) through the
113
- Go and Rust binaries and diffs stdout. Lives in CI as the
114
- `Rust ↔ Go parity` job. Rust transitively inherits Ruby parity via Go.
115
-
116
- When changing behavior:
117
-
118
- 1. Update the Ruby code + specs first.
119
- 2. Regenerate fixtures: `bundle exec ruby script/generate_fixtures.rb`.
120
- 3. Port the change to Go.
121
- 4. `go test ./...` (uses the updated fixtures).
122
- 5. `script/cli_parity.sh` should pass.
123
- 6. Port the change to Rust under `rust/`.
124
- 7. `cd rust && cargo test --workspace`.
125
- 8. `cd rust && cargo build --release --bin iriq && cd .. && script/rust_parity.sh` should pass.
126
- 9. Commit fixtures with the change — CI will fail if they're stale.
127
-
128
- ## Tests
129
-
130
- ```sh
131
- bundle exec rspec # Ruby suite (305+ examples)
132
- go test ./... # Go suite (native + fixture parity)
133
- script/cli_parity.sh # Ruby ↔ Go CLI parity
134
- cd rust && cargo test --workspace
135
- cd rust && cargo fmt --check # formatting (CI-gated)
136
- cd rust && cargo clippy --workspace --all-targets -- -D warnings
137
- make check # the three Rust checks above, in one shot
138
- script/rust_parity.sh # Rust ↔ Go CLI parity (~59 scenarios)
139
- ```
140
-
141
- ## Releases
142
-
143
- Versioning is single-stream: one `vX.Y.Z` covers all three runtimes. Bump the
144
- three version constants **together** — the `--version` parity checks fail
145
- if they drift:
146
-
147
- 1. `lib/iriq/version.rb` (`VERSION`), `go/version.go` (`Version`), and the two
148
- `version = "X.Y.Z"` / `pub const VERSION` lines in `rust/iriq/Cargo.toml` and
149
- `rust/iriq/src/lib.rs` — same string.
150
- 2. `Gemfile.lock` — re-resolve so the pinned `iriq (X.Y.Z)` matches
151
- (`bundle install`, or it regenerates on the next `bundle exec`). Commit it.
152
- 3. Run `cd rust && cargo update -p iriq` to refresh `Cargo.lock`.
153
- 4. Tag `vX.Y.Z` and push. Go consumers pick it up via
154
- `go get github.com/dpep/iriq/go@vX.Y.Z`.
155
- 5. `gem push iriq-X.Y.Z.gem` to publish to RubyGems.
156
- 6. `cd rust && cargo publish -p iriq` to publish to crates.io (the crate ships
157
- both the library and the `iriq` binary).
158
-
159
- ### Keep Homebrew in sync — bump on EVERY version change
160
-
161
- The tap (`~/code/lib/homebrew-tools`) ships a single `Formula/iriq.rb` that
162
- builds the Rust CLI (`cargo install --path rust/iriq`) from `branch: "main"`.
163
- SQLite is on by default (the `iriq` crate's `default` feature set), so there is
164
- no longer a separate `iriq-sqlite` formula.
165
-
166
- The formula pins a static `version "X.Y.Z"` label. Because the build tracks
167
- `main` rather than a tagged tarball, `brew upgrade` only rebuilds when that
168
- label changes. So on every bump here, update the `version` string in
169
- `Formula/iriq.rb` to match `version.rb`, then commit + push the tap. Leaving it
170
- stale means brew users never get the new code even though it's already on `main`.
171
-
172
- ## Corpus storage backends
173
-
174
- The `Corpus` class delegates state to a `Storage` backend; three backends ship:
175
-
176
- - **Memory** — default, in-process only.
177
- - **JSON** — Memory wrapped with atomic load/save against a JSON file
178
- (`.json` by default). Same shape both runtimes have always written.
179
- - **SQLite** — incremental UPSERTs against a `.db` / `.sqlite` / `.sqlite3`
180
- file with WAL journaling. Supports concurrent observers and avoids
181
- loading the whole corpus into memory.
182
-
183
- `Corpus.open(path)` (Ruby) / `iriq.OpenCorpus(path)` (Go) picks the backend
184
- by file extension. `corpus.save(other_path)` exports as JSON regardless of
185
- the live backend; `corpus.save(same_path)` is idempotent (no clobbering a
186
- SQLite file with JSON, etc.).
187
-
188
- The Ruby `sqlite3` gem is loaded lazily (only when a `.db` path is opened),
189
- keeping the iriq install footprint minimal for users that stick with JSON.
190
- On the Go side we use `modernc.org/sqlite` (pure Go — no cgo). The Rust
191
- side uses `rusqlite` with the `bundled` feature (statically links C SQLite,
192
- ~3-4 MB binary cost). Schema v4 is shared across all three runtimes — a
193
- `.db` written by any binary opens cleanly in any other.
194
-
195
- When adding a new backend, replicate the contract in all three languages
196
- and add parity scenarios in `script/cli_parity.sh`'s `corpus_pair`
197
- section + `script/rust_parity.sh`'s `corpus_pair`.
198
-
199
- ## What lives where in scripts
200
-
201
- - `script/benchmark.rb` — Ruby-only throughput benchmark.
202
- - `script/memory.rb` — Ruby-only memory profile.
203
- - `script/generate_fixtures.rb` — produces `spec/fixtures/*.json` for cross-runtime parity.
204
- - `script/cli_parity.sh` — Ruby ↔ Go CLI diff.
205
- - `script/rust_parity.sh` — Rust ↔ Go CLI diff.
206
- - `script/bench_three_way.sh` — Go vs Rust wall-clock comparison.
207
- - `script/bench_compare.sh` — Ruby vs Go CLI wall-time comparison.
208
- - `script/bench_storage.sh` — JSON vs SQLite backend timing (single-process, incremental, concurrent).
data/Gemfile DELETED
@@ -1,3 +0,0 @@
1
- source "https://rubygems.org"
2
-
3
- gemspec