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/README.md CHANGED
@@ -1,46 +1,48 @@
1
- Iriq
2
- ======
3
- ![Gem](https://img.shields.io/gem/dt/iriq?style=plastic)
4
- [![codecov](https://codecov.io/gh/dpep/iriq/branch/main/graph/badge.svg)](https://codecov.io/gh/dpep/iriq)
1
+ # Iriq — IRI Query
5
2
 
6
- IRI extraction, normalization, and clustering.
3
+ [![codecov](https://codecov.io/gh/dpep/iriq/branch/main/graph/badge.svg)](https://codecov.io/gh/dpep/iriq)
7
4
 
8
- Iriq pulls IRIs out of free text, parses them, normalizes them into
9
- canonical shape-aware forms, classifies their path and query components,
10
- and clusters similar identifiers surfacing what's stable vs. unique.
5
+ **Iriq finds the *shape* of a URL** the structural template you get when you
6
+ erase the parts that vary and keep the parts that don't. `…/users/123` and
7
+ `…/users/999` are the same shape: `/users/{user_id}`. Feed iriq a pile of messy
8
+ URLs — a log file, a column of links, free-text prose — and it collapses them
9
+ into a small set of stable, deterministic route templates. Fifty thousand
10
+ distinct URLs become twelve shapes.
11
11
 
12
- Ships as both a **command-line tool** (`iriq`) and a **library** (Ruby and
13
- Go same behavior, enforced by parity tests).
12
+ What's an IRI? Internationalized Resource Identifiers cover everyday URLs `https://…`, plus URNs like `urn:isbn:0451450523`,
13
+ other schemes like `mailto:`, and internationalized addresses with non-ASCII
14
+ characters like `https://例え.jp/パス`. Formally it's the Unicode superset of
15
+ URI/URL. The name is *IRI Query*: iriq queries an IRI for its structure.
14
16
 
15
- ## Install
17
+ Everything iriq does — parsing, normalizing, classifying path and query
18
+ components, clustering, learning new patterns — exists to derive, render, or
19
+ group by that shape.
16
20
 
17
- The CLI is available three ways. Pick whichever fits your workflow:
21
+ And it gets sharper the more you feed it. A *corpus* on by default — records
22
+ what it sees and improves classifications as data flows in: high-churn slots get
23
+ promoted to placeholders, and whole types emerge that no single URL can reveal (a
24
+ position that's always 100–599 is an HTTP status; one bounded to a dozen values
25
+ is an enum).
18
26
 
19
27
  ```sh
20
- # Homebrew (recommended)
21
- brew install dpep/tools/iriq
22
-
23
- # RubyGems — installs the CLI shim and the library
24
- gem install iriq
25
-
26
- # Go — installs the CLI binary into $GOBIN
27
- go install github.com/dpep/iriq/cmd/iriq@latest
28
+ $ iriq -n https://foo.com/users/123
29
+ https://foo.com/users/{user_id}
28
30
  ```
29
31
 
30
- For library use, depend on whichever runtime you're working in:
32
+ It answers questions like:
31
33
 
32
- ```ruby
33
- # Gemfile
34
- gem "iriq"
35
- ```
34
+ - "What routes does this service actually expose?" (cluster a log file)
35
+ - "Which params are stable identifiers vs. churning IDs vs. enums?"
36
+ (`--stats`)
37
+ - "Are these 50,000 distinct URLs really just 12 templates?" (clustering)
38
+ - "What does `/api/v1/users/abc-123-def` become as a route shape?"
39
+ (`/api/{version}/users/{user_id}`)
36
40
 
37
- ```go
38
- import "github.com/dpep/iriq"
39
- ```
41
+ Iriq ships as a **command-line tool** (`iriq`) and a **Rust library**.
40
42
 
41
- ## CLI quick start
43
+ ## Quick start
42
44
 
43
- ```
45
+ ```sh
44
46
  $ iriq https://foo.com/users/123
45
47
  # parse
46
48
  original: https://foo.com/users/123
@@ -56,368 +58,303 @@ https://foo.com/users/{user_id}
56
58
  $ iriq -n https://foo.com/users/123
57
59
  https://foo.com/users/{user_id}
58
60
 
59
- $ cat access.log | iriq # extract → URL list (or clusters at scale)
60
- $ cat access.log | iriq --stats # rolling aggregates
61
- $ iriq ./access.log -n # file auto-detected → normalize each found URL
61
+ $ iriq -n https://shop.com/pricing/usd?currency=eur
62
+ https://shop.com/pricing/USD?currency=EUR # currency upcased
62
63
  ```
63
64
 
64
- Full CLI reference is below under [CLI](#cli).
65
-
66
- ## Library quick start
67
-
68
- ```ruby
69
- # Ruby
70
- iri = Iriq.parse("https://foo.com/users/123")
71
- iri.scheme # => "https"
72
- iri.host # => "foo.com"
73
- iri.path_segments # => ["users", "123"]
74
- iri.canonical # => "https://foo.com/users/123"
75
-
76
- Iriq.normalize("https://foo.com/users/123")
77
- # => "https://foo.com/users/{user_id}"
65
+ ```sh
66
+ $ cat access.log | iriq # ≥ 10 IRIs → cluster view
67
+ [190] docs.example.com /users/{user_id}
68
+ [186] app.example.com /users/{user_id}
69
+ ...
78
70
 
79
- Iriq.explain("https://foo.com/users/123/orders/456")
80
- # => [
81
- # { value: "users", type: :literal, variable: false, hint: nil },
82
- # { value: "123", type: :integer_id, variable: true, hint: "user_id" },
83
- # { value: "orders", type: :literal, variable: false, hint: nil },
84
- # { value: "456", type: :integer_id, variable: true, hint: "order_id" },
85
- # ]
71
+ $ cat access.log | iriq --stats # rolling aggregates
72
+ $ iriq ./access.log -n # auto-detect file → normalize each
73
+ $ iriq -J < access.log # newline-delimited JSON
74
+ $ iriq --corpus team.db < access.log # use a specific corpus file
86
75
  ```
87
76
 
88
- ```go
89
- // Go (same surface)
90
- iri, _ := iriq.Parse("https://foo.com/users/123")
91
- iri.Scheme // "https"
92
- iri.Host // "foo.com"
93
- iri.PathSegments // []string{"users", "123"}
94
- iri.Canonical() // "https://foo.com/users/123"
77
+ Per-IRI output (`-n`, `-p`, `-c`, `-J`) streams — each line is read, classified,
78
+ and flushed as it arrives, so iriq works on an unbounded live feed:
95
79
 
96
- norm, _ := iriq.Normalize("https://foo.com/users/123")
97
- // "https://foo.com/users/{user_id}"
80
+ ```sh
81
+ $ tail -f access.log | iriq -n # one shape per line, as logs land
82
+ $ tail -f access.log | iriq -J # same, as newline-delimited JSON
98
83
  ```
99
84
 
100
- The Ruby gem is the reference implementation; Go mirrors its API and is
101
- kept in sync via JSON fixtures plus a CLI parity harness. See
102
- [CLAUDE.md](CLAUDE.md) for the dev process.
103
-
104
- Pass `hints: false` to `Iriq.normalize` (or `PathShape`) for mechanical
105
- placeholders (`{integer_id}` instead of `{user_id}`).
106
-
107
- ## RESTful hints
85
+ **Every invocation observes into a persistent corpus by default**, so iriq gets
86
+ smarter the more you run it. The corpus-only types (e.g. `enum` / `http_status`)
87
+ emerge from the *distribution* of values observed.
108
88
 
109
- When a variable segment follows a literal one, Iriq derives a hint by
110
- singularizing the literal and suffixing `_id` (or `_uuid` for UUIDs). This is
111
- what produces `{user_id}` from `/users/123` and `{order_id}` from
112
- `/orders/456`. Singularization uses `Iriq::Inflector`, which delegates to a
113
- swappable adapter:
114
-
115
- ```ruby
116
- # Default: ActiveSupport::Inflector if `active_support/inflector` is loadable,
117
- # otherwise a built-in adapter with rules adapted from ActiveSupport.
118
-
119
- Iriq::Inflector.singularize("categories") # => "category"
120
- Iriq::Inflector.singularize("people") # => "person"
89
+ ```sh
90
+ # Feed a stream where ?status only ever holds a couple of words:
91
+ $ for n in $(seq 1 20); do
92
+ iriq --corpus demo.db "https://api.foo.com/orders/$n?status=open" >/dev/null
93
+ iriq --corpus demo.db "https://api.foo.com/orders/$n?status=closed" >/dev/null
94
+ done
121
95
 
122
- # Override:
123
- Iriq::Inflector.adapter = MyAdapter # must respond to .singularize(String)
124
- Iriq::Inflector.reset_adapter!
96
+ # Ask what it learned. ?status is now an enum — a verdict no single URL
97
+ # could support, since one URL shows only one value:
98
+ $ iriq --corpus demo.db cluster
99
+ [40] api.foo.com /orders/{order_id}
100
+ https://api.foo.com/orders/1?status=open
101
+ https://api.foo.com/orders/1?status=closed
102
+ https://api.foo.com/orders/2?status=open
103
+ + 37 more
104
+ status enum (2 distinct, 100%)
125
105
  ```
126
106
 
127
- ## Supported inputs
128
-
129
- | Input | Notes |
130
- | ------------------------------------ | ------------------------------------------------ |
131
- | `https://foo.com/users/123` | Standard URL |
132
- | `foo.com/users/456` | Scheme-less; `https://` is assumed |
133
- | `urn:isbn:0451450523` | URN — `scheme` and `nss` are populated |
134
- | `https://例え.テスト/こんにちは` | Unicode IRI — display form preserved |
135
- | `HTTPS://Foo.com:443/A` | Scheme + host lowercased; default port dropped |
136
- | `https://foo.com/a/./b/../c` | Dot segments normalized |
107
+ These learned types also flow into normalized output: once the corpus has pegged
108
+ `?status` as an enum, `iriq -n …?status=open` renders `?status={enum}`.
137
109
 
138
- ## Segment classification
110
+ The default corpus lives at `$XDG_DATA_HOME/iriq/default.db` (Linux),
111
+ `~/Library/Application Support/iriq/default.db` (macOS), or
112
+ `%LOCALAPPDATA%/iriq/default.db` (Windows). First-run creation prints a
113
+ one-line stderr notice. Three knobs control it:
139
114
 
140
- `Iriq::SegmentClassifier` returns one of:
141
-
142
- - `:literal` plain word (`users`, `orders`, `Profile`, `こんにちは`)
143
- - `:integer_id` pure digits below the timestamp range (`1`, `123`, `42`)
144
- - `:uuid` `f47ac10b-58cc-4372-a567-0e02b2c3d479`
145
- - `:date` `2024-05-23`
146
- - `:timestamp` — ISO 8601, or 10/13-digit UNIX epoch
147
- - `:hash` — 32+ hex chars (md5 / sha)
148
- - `:slug` — `my-cool-post`, `my_cool_post`
149
- - `:opaque_id` — short alphanumeric mix that doesn't fit elsewhere
150
-
151
- Heuristics are deterministic and ordered — the first matching rule wins.
152
-
153
- ## Clustering
154
-
155
- ```ruby
156
- clusterer = Iriq::Clusterer.new
157
- clusterer.add("https://foo.com/users/123")
158
- clusterer.add("https://foo.com/users/456")
159
- clusterer.add("https://foo.com/users/789/orders/1")
160
-
161
- clusterer.clusters.map(&:shape)
162
- # => ["/users/{user_id}", "/users/{user_id}/orders/{order_id}"]
163
-
164
- clusterer.clusters.first.segment_stats
165
- # => [
166
- # { position: 0, stable: true, values: { "users" => 2 } },
167
- # { position: 1, stable: false, values: { "123" => 1, "456" => 1 } },
168
- # ]
169
-
170
- clusterer.explain("https://foo.com/users/999")
171
- # => [
172
- # { value: "users", type: :literal, variable: false, hint: nil, stable: true },
173
- # { value: "999", type: :integer_id, variable: true, hint: "user_id", stable: false },
174
- # ]
115
+ ```sh
116
+ $ iriq --no-corpus -n https://foo.com/users/123 # one-shot ephemeral; or -C
117
+ $ IRIQ_NO_CORPUS=1 iriq -n https://foo.com/users/123 # globally disable
118
+ $ IRIQ_CORPUS=/path/to/work.db iriq -n https://foo.com/users/123 # override path
119
+ $ iriq --corpus team.db https://foo.com/users/123 # explicit override (wins over env)
120
+ $ iriq --reset # delete the corpus DB and exit
175
121
  ```
176
122
 
177
- The clusterer combines classifier output with what it has actually observed:
178
- a position the classifier *would* call variable but that is empirically
179
- constant across all members of the cluster will be reported with
180
- `stable: true, variable: false`.
181
-
182
- ## Corpus (streaming + learning)
183
-
184
- For processing many identifiers — possibly an unbounded stream — use
185
- `Iriq::Corpus`. It maintains rolling aggregates and per-(host, prefix)
186
- frequency stats so classification improves as more data comes in.
187
-
188
- ```ruby
189
- corpus = Iriq::Corpus.new
190
-
191
- iris.each do |iri|
192
- obs = corpus.observe(iri)
193
- obs.fingerprint # deterministic shape: "https://foo.com/users/{user_id}"
194
- obs.cluster # the Iriq::Cluster this fell into
195
- obs.explanation # per-segment annotations with corpus-informed classification
196
- end
197
-
198
- corpus.host_counts # { "foo.com" => 1234, "bar.com" => 7 }
199
- corpus.path_length_counts # { 2 => 800, 3 => 434 }
200
- corpus.fingerprint_counts # shape → count
201
- corpus.raw_shape_counts # hint-free shape → count
202
- corpus.clusters # Iriq::Cluster instances
203
- ```
123
+ ### Two ways to normalize
204
124
 
205
- ### Deterministic vs. corpus-informed normalization
125
+ Pick by the question you're asking:
206
126
 
207
- ```ruby
208
- Iriq.normalize("https://foo.com/users/me")
209
- # => "https://foo.com/users/me" # mechanical: "me" is a literal
127
+ - **`--canonical`** — clean up *this* URL, keeping the specifics.
128
+ `HTTP://Foo.com:80/pull/42` → `http://foo.com/pull/42` (scheme/host
129
+ lowercased, default port dropped; path and query left alone). Handy, but
130
+ table stakes — plenty of libraries do it.
131
+ - **`--normalize`** *(the default)* — find the URL's *shape*, erasing the
132
+ specifics into placeholders. `…/pull/42` → `…/pull/{id}`. This is the part
133
+ you came to iriq for.
210
134
 
211
- corpus.normalize("https://foo.com/users/me")
212
- # => depends on what the corpus has seen
213
- ```
135
+ Same input, two questions: "what's the clean form of *this* URL?" vs "what
136
+ *kind* of URL is this?" The second is iriq's reason to exist.
214
137
 
215
- If many `/users/{integer_id}` paths flow in alongside a handful of
216
- `/users/me`, the cluster `/users/me` is preserved (mechanical clustering
217
- keeps literal routes distinct). If many *distinct literal handles*
218
- (`/users/alice`, `/users/bob`, `/users/carol`, ...) flow in, the corpus
219
- promotes that position to a `{user}` placeholder:
138
+ ## Install
220
139
 
221
- ```ruby
222
- %w[alice bob carol dave erin frank gina hank ivan jane].each do |name|
223
- corpus.observe("https://foo.com/users/#{name}/profile")
224
- end
140
+ ```sh
141
+ # Homebrew (recommended)
142
+ brew install dpep/tools/iriq
225
143
 
226
- corpus.normalize("https://foo.com/users/alice/profile")
227
- # => "https://foo.com/users/{user}/profile"
144
+ # Cargo, from crates.io
145
+ cargo install iriq
228
146
  ```
229
147
 
230
- ### Explainability
148
+ One crate ships both the library and the `iriq` binary. Corpora persist to
149
+ SQLite (bundled, WAL) out of the box — nothing to flag, install, or rebuild.
231
150
 
232
- Each row of `corpus.explain(...)` (and `observation.explanation`) carries a
233
- `classification:` symbol on top of the deterministic fields:
151
+ ## Segment classification
234
152
 
235
- | Classification | Meaning |
236
- | --------------------------- | ---------------------------------------------------- |
237
- | `:stable_literal` | Literal value dominates this position |
238
- | `:variable_identifier` | Classifier said variable (uuid, integer, etc.) |
239
- | `:rare_literal` | Literal seen here, but not dominant |
240
- | `:corpus_inferred_variable` | Classifier said literal, but position has high entropy |
241
- | `:ambiguous` | Insufficient signal never seen, or mixed |
153
+ Iriq classifies each path/query segment into one of ~25 types — the first
154
+ matching rule wins, and heuristics are deterministic:
155
+
156
+ - `literal` plain word (`users`, `orders`, `Profile`, `こんにちは`)
157
+ - `integer` pure digits below the timestamp range
158
+ - `float` decimal with digits on both sides (`3.14`, `-2.5`, `1.0`)
159
+ - `boolean` `true` / `false` (any case)
160
+ - `version` — semver-ish with `v` prefix (`v1`, `v2.0.1`, `v1.2.3-beta`)
161
+ - `locale` — BCP 47-ish (`en-US`, `fr_CA`, `zh-Hant`, bare `en`/`fr`/`ja`)
162
+ - `currency` — ISO 4217 codes (`USD`, `EUR`, `JPY`)
163
+ - `uuid` — `f47ac10b-58cc-4372-a567-0e02b2c3d479`
164
+ - `date` — `2024-05-23`, `2024/05/23`, `20240523`, `05/23/2024`. Canonicalized to ISO in `--normalize` output.
165
+ - `timestamp` — ISO 8601, or 10/13-digit UNIX epoch
166
+ - `hash` — 32+ hex chars (md5 / sha)
167
+ - `slug` — `my-cool-post`, `my_cool_post`
168
+ - `ipv4` / `ipv6` — collapsed to `{ip}` in normalized output
169
+ - `url` — `https://...`, `ftp://...`, also scheme-less `foo.com/path`
170
+ - `email` — `local@host.tld`
171
+ - `phone` — E.164 (`+15551234567`) or NANP (`555-666-7777`, `(555) 666-7777`)
172
+ - `jwt` — three base64url segments separated by dots
173
+ - `mime` — `image/png`, `application/vnd.api+json`
174
+ - `file` — `name.ext` for known extensions; per-kind grouping (image/document/data/...)
175
+ - `color` — hex form (`#fff`, `#ffffff`, `#ffffff80`)
176
+ - `coordinate` — `lat,lng` pair with plausible-range validation
177
+ - `country` — ISO 3166-1 alpha-2 codes (`US`, `JP`, `GB`)
178
+ - `base64` — standard base64 blobs with disambiguating `+`/`/`/`=`
179
+ - `opaque_id` — short alphanumeric mix that doesn't fit elsewhere
180
+
181
+ ### RESTful hints
182
+
183
+ When a variable segment follows a literal one, iriq derives a hint by
184
+ singularizing the literal and suffixing `_id` (or `_uuid` for UUIDs). That's
185
+ what produces `{user_id}` from `/users/123` and `{order_id}` from `/orders/456`.
186
+ Semantic types (`version`, `locale`, `currency`, `date`, `boolean`) skip the
187
+ hint and surface as `{type}` — `/api/v1/status` renders as `/api/{version}/status`,
188
+ not the misleading `/api/{api_id}/status`. Pass `-N` / `--no-hints` for
189
+ mechanical placeholders (`{integer}` instead of `{user_id}`).
190
+
191
+ ### Types only the corpus can see
192
+
193
+ Four types emerge from the *distribution* of values across many observations:
194
+
195
+ | Type | Emerges when a position… |
196
+ | --- | --- |
197
+ | `number` | holds both integers and floats |
198
+ | `year` | holds integers that all land in 1900–2100 |
199
+ | `http_status` | holds integers that all land in 100–599 |
200
+ | `enum` | holds a small, bounded set of distinct values |
201
+
202
+ Mechanically, `200` is just an integer. Across ten thousand URLs where that
203
+ slot is always 100–599, it's likely an HTTP status.
242
204
 
243
- ## Extracting IRIs from text
205
+ ## Corpus (streaming + learning)
244
206
 
245
- `Iriq::Extractor` is what powers pipe-mode in the CLI. Picks up explicit-
246
- scheme URLs (`http`, `https`, `ftp`, `ws`, `wss`, `urn`) and `foo.com/path`-
247
- style scheme-less URLs (small TLD allow-list, required path). Trims trailing
248
- sentence punctuation iteratively and preserves balanced parens
249
- (`https://en.wikipedia.org/wiki/Ruby_(programming_language)` stays intact;
250
- `(see https://foo.com)` drops the outer paren).
251
-
252
- ```ruby
253
- Iriq.extract("Visit https://foo.com today, also hit foo.com/users.")
254
- # => [#<Iriq::Identifier https://foo.com>,
255
- # #<Iriq::Identifier https://foo.com/users>]
256
-
257
- # Disable scheme-less:
258
- Iriq::Extractor.new(scheme_less: false).extract("hit foo.com/users today")
259
- # => []
260
- ```
207
+ The corpus maintains rolling aggregates and per-(host, prefix) frequency stats,
208
+ so classification improves as more data comes in handy for an unbounded stream
209
+ of identifiers. The default corpus already persists; `--corpus PATH` points iriq
210
+ at a specific file instead, to keep separate corpora or share one across runs.
261
211
 
262
- Known limitations (intentional):
212
+ A `.db` / `.sqlite` / `.sqlite3` path is stored in SQLite (WAL journaling, incremental
213
+ UPSERTs — multiple `iriq --corpus` processes can write concurrently); a
214
+ `.json` path writes a plain JSON file instead.
263
215
 
264
- - Comma is a URL boundary, so query strings like `?q=37.7,-122.4` truncate.
265
- Trade-off picked to keep CSV-shaped text working.
266
- - No HTML entity decoding (`&amp;` stays as-is).
267
- - Scheme-less mode skips bare hostnames without a path (too noisy in prose).
268
-
269
- ### Memory bounds
216
+ ### Re-runnable inference
270
217
 
271
- - Per-position `value_counts` is capped (`max_values_per_position`, default
272
- 1000) once full, `total` keeps growing but only existing keys count up.
273
- - Cluster examples are capped at `Iriq::Cluster::MAX_EXAMPLES`.
274
- - No raw IRI strings are retained outside the bounded cluster examples.
218
+ A corpus persists the source-IRI log alongside the materialized views.
219
+ `--reinfer` drops every view and replays the log through the current classifier
220
+ and reducers. Tune a threshold, swap in a different classifier, or activate new
221
+ recognizers (below) then reinfer to see the new results without re-feeding
222
+ URLs.
275
223
 
276
- ```ruby
277
- Iriq::Corpus.new(max_values_per_position: 200)
224
+ ```sh
225
+ $ iriq --corpus c.db --reinfer
278
226
  ```
279
227
 
280
- ## Object model
228
+ ## Learning new types
281
229
 
282
- | Class | Responsibility |
283
- | --------------------------- | ---------------------------------------------------- |
284
- | `Iriq::Parser` | String `Identifier` |
285
- | `Iriq::Identifier` | Structured fields + `canonical` reconstruction |
286
- | `Iriq::SegmentClassifier` | Single segment → type symbol |
287
- | `Iriq::PathShape` | Segments → `/users/{user_id}` route shape |
288
- | `Iriq::SegmentHints` | Derives `user_id`-style hints from neighbors |
289
- | `Iriq::Inflector` | Singularization with swappable adapter (AS or built-in) |
290
- | `Iriq::Normalizer` | Identifier → canonical, shape-aware string |
291
- | `Iriq::Explanation` | Per-segment `{value, type, variable, hint}` rows |
292
- | `Iriq::Cluster` | One host + shape group, with examples & stats |
293
- | `Iriq::Clusterer` | Many identifiers → `Cluster` set + explain |
294
- | `Iriq::PositionStats` | Capped value/type frequencies for one position |
295
- | `Iriq::Observation` | What `Corpus#observe` returns |
296
- | `Iriq::Corpus` | Streaming observer with rolling aggregates + learning |
297
- | `Iriq::Extractor` | Pulls IRIs out of free text (scheme-anchored) |
230
+ Iriq doesn't just classify against a fixed list — it watches the stream and
231
+ *proposes new recognizers* for patterns it keeps seeing. Notice `ghp_…` or
232
+ `cus_…` recurring at a slug position and iriq will suggest a recognizer for it,
233
+ with evidence: coverage, host count, confidence.
298
234
 
299
- ## CLI
300
-
301
- Installing the gem installs an `iriq` executable. Two main modes:
302
-
303
- **Single input** — combined parse + normalize summary; trim with section
304
- flags (`-p`, `-n`).
235
+ ```sh
236
+ # Print proposals (human-readable, or --json)
237
+ $ iriq --corpus c.db --propose-recognizers
305
238
 
239
+ # Auto-activate every proposal with confidence ≥ 0.9, then reinfer
240
+ $ iriq --corpus c.db --propose-recognizers --activate-above 0.9
306
241
  ```
307
- $ iriq foo.com/users/456
308
- # parse
309
- original: foo.com/users/456
310
- kind: url
311
- scheme: https
312
- host: foo.com
313
- path_segments: ["users", "456"]
314
- canonical: https://foo.com/users/456
315
-
316
- # normalize
317
- https://foo.com/users/{user_id}
318
242
 
319
- $ iriq -n https://foo.com/users/123
320
- https://foo.com/users/{user_id}
321
- ```
243
+ ### Cross-host shape learning
322
244
 
323
- **Piped stdin** extraction runs by default. Output auto-switches: small
324
- inputs get a deduplicated URL list, larger inputs (≥ 10 IRIs) get the
325
- cluster view via an ephemeral corpus. Section flags work too — emit one
326
- normalized URL / parsed record per extracted IRI.
245
+ A route shape that recurs across multiple hosts is independent evidence of a
246
+ semantic pattern two unrelated hosts inventing the same `/users/{integer}`
247
+ structure by accident is unlikely.
327
248
 
249
+ ```sh
250
+ $ iriq --corpus c.db --cross-host-shapes [--min-hosts N]
328
251
  ```
329
- $ cat short.txt | iriq
330
- [2] https://github.com/dpep/iriq
331
- [1] https://foo.com/users
332
252
 
333
- $ cat short.txt | iriq -n # normalized URL per line
334
- https://github.com/dpep/iriq
335
- https://foo.com/users
253
+ The same signal feeds back into proposal `confidence`: each additional host
254
+ beyond the first adds `0.05` to the score (capped at 1.0), so a prefix proposed
255
+ on 5 hosts is meaningfully stronger than the same coverage seen on 1 host.
336
256
 
337
- $ cat access.log | iriq # ≥ 10 IRIs cluster view
338
- [190] docs.example.com /users/{user_id}
339
- [186] app.example.com /users/{user_id}
340
- ...
257
+ ## Extracting IRIs from text
341
258
 
342
- $ cat README.md | iriq --stats # rolling aggregates
343
- $ cat README.md | iriq cluster # force cluster view
344
- $ cat README.md | iriq --corpus c.json # persist into a corpus
345
- ```
259
+ Pipe-mode extraction picks up explicit-scheme URLs (`http`, `https`, `ftp`,
260
+ `ws`, `wss`, `urn`) and `foo.com/path`-style scheme-less URLs (small TLD
261
+ allow-list, required path). It trims trailing sentence punctuation and preserves
262
+ balanced parens (`https://en.wikipedia.org/wiki/Ruby_(programming_language)`
263
+ stays intact; `(see https://foo.com)` drops the outer paren).
346
264
 
347
- `--corpus PATH` makes the corpus survive across invocations. The file
348
- extension picks the storage backend:
265
+ Known limitations (intentional):
349
266
 
350
- - `.json` a single atomically-written JSON file (default). Best for small
351
- corpora and when you want the data human-readable.
352
- - `.db` / `.sqlite` / `.sqlite3` a SQLite database with WAL journaling.
353
- Each observation is an incremental UPSERT, so multiple `iriq --corpus`
354
- processes can write concurrently without clobbering each other, and the
355
- cost of opening doesn't scale with corpus size.
267
+ - Comma is a URL boundary, so query strings like `?q=37.7,-122.4` truncate.
268
+ Trade-off picked to keep CSV-shaped text working.
269
+ - No HTML entity decoding (`&amp;` stays as-is).
270
+ - Scheme-less mode skips bare hostnames without a path (too noisy in prose).
356
271
 
357
- Once the corpus has data, `-n` becomes corpus-informed:
272
+ Disable scheme-less extraction with `--no-scheme-less`.
358
273
 
359
- ```
360
- $ for n in alice bob carol dave erin frank gina hank ivan jane; do
361
- iriq --corpus c.db https://foo.com/users/$n/profile >/dev/null
362
- done
274
+ ## How it works
363
275
 
364
- $ iriq -n --corpus c.db https://foo.com/users/zoe/profile
365
- https://foo.com/users/{user}/profile # mechanical would keep "zoe"
366
- ```
276
+ Under the shape sits one idea: **Position + Evidence**. A *Position* is a slot
277
+ in a host's structure — a typed path prefix, or a query-param name. *Evidence*
278
+ is everything the corpus has observed about that slot: which values, how often,
279
+ across how many hosts. Strings are observations; types are inferences drawn from
280
+ the pile. Shape is the surface you see; Position + Evidence is the engine
281
+ underneath. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full model.
282
+
283
+ ## CLI reference
367
284
 
368
- Library: `Iriq::Corpus.open("c.db")` (or `iriq.OpenCorpus("c.db")` in Go)
369
- dispatches on the same extension rules. `corpus.save("export.json")`
370
- exports any backend as JSON.
285
+ **Single input** combined parse + normalize summary; trim with section flags
286
+ (`-p`, `-n`).
371
287
 
372
- Flags:
288
+ **Piped stdin** — extraction runs by default. Output auto-switches: small inputs
289
+ get a deduplicated URL list, larger inputs (≥ 10 IRIs) get the cluster view via
290
+ an ephemeral corpus.
373
291
 
374
292
  | Flag | Effect |
375
293
  | ------------------- | ------------------------------------------------------- |
376
294
  | `-p, --parse` | Show parsed fields |
377
295
  | `-n, --normalize` | Show the shape-normalized form |
296
+ | `-c, --canonical` | Show the canonical form (no shape normalization) |
297
+ | `-e, --explain` | Annotated trace — per-segment notes about why each placeholder / canonical value was chosen |
378
298
  | `-j, --json` | Emit JSON |
379
- | `-N, --no-hints` | Use `{integer_id}` etc. instead of `{user_id}` |
299
+ | `-J, --ndjson` | Newline-delimited JSON (one object per line); implies `--json` |
300
+ | `-N, --no-hints` | Use `{integer}` etc. instead of `{user_id}` |
380
301
  | `--no-scheme-less` | Skip `foo.com/path`-style extraction (explicit-scheme only) |
381
- | `--corpus PATH` | Load/create a corpus at PATH (`.json` or `.db`/`.sqlite`/`.sqlite3`) |
302
+ | `--corpus PATH` | Use a specific corpus file (`.json` or `.db`/`.sqlite`/`.sqlite3`). Overrides the default |
303
+ | `-C, --no-corpus` | Disable corpus persistence for this invocation (same as `IRIQ_NO_CORPUS=1`) |
304
+ | `--reset` | Delete the corpus database and exit |
305
+ | `--host MODE` | Host-keying for clustering: `full` (default), `reg` strips subdomains, `none` ignores host |
382
306
  | `--stats` | Print rolling aggregates |
307
+ | `--reinfer` | Drop the materialized views and replay the source-IRI log through the current classifier + reducers |
308
+ | `--propose-recognizers` | Scan observed values for shape patterns that recur enough to suggest a new recognizer. Combine with `--json` for structured output |
309
+ | `--cross-host-shapes` | List route shapes that recur across multiple hosts |
310
+ | `--min-observations N` | Proposal threshold; default 20 |
311
+ | `--min-coverage F` | Proposal threshold; default 0.7 |
312
+ | `--min-hosts N` | Threshold for both proposals and cross-host shapes; default 1 / 2 respectively |
313
+ | `--activate-above F` | With `--propose-recognizers`, auto-activate every proposal whose confidence is ≥ F |
314
+ | `completion bash\|zsh` | Print shell completion script (Homebrew installs this automatically) |
383
315
  | `-V, --version` | Print version |
384
316
 
385
- A positional argument that doesn't parse as an IRI but IS an existing
386
- file is read and extracted from automatically — `iriq ./access.log` and
387
- `iriq /var/log/foo.log` Just Work. (Bare filenames like `README.md`
388
- may still parse as a URL; pipe with `cat` to disambiguate.)
317
+ Environment variables:
318
+
319
+ | Variable | Effect |
320
+ | -------------------- | ------------------------------------------------------- |
321
+ | `IRIQ_CORPUS=PATH` | Set the corpus path (overrides the default) |
322
+ | `IRIQ_NO_CORPUS=1` | Disable the default corpus (equivalent to `-C`) |
323
+
324
+ A positional argument that doesn't parse as an IRI but IS an existing file is
325
+ read and extracted from automatically — `iriq ./access.log` and
326
+ `iriq /var/log/foo.log` Just Work. (pipe with `cat` to disambiguate)
389
327
 
390
328
  Exit codes: `0` success, `1` usage error, `2` parse error.
391
329
 
392
- ## Performance
330
+ ## Rust library
393
331
 
394
- Measured on the deterministic `IriGenerator` fixture (Ruby 3.4.9, single
395
- thread):
332
+ ```sh
333
+ cargo add iriq
334
+ ```
396
335
 
397
- | Operation | Throughput |
398
- | ------------------------ | ------------ |
399
- | `Iriq.parse` | ~260k URLs/s |
400
- | `Iriq.normalize` | ~148k URLs/s |
401
- | `Iriq.explain` | ~205k URLs/s |
402
- | `Iriq.extract` (prose) | ~9.6 MB/s |
403
- | `Corpus#observe` | ~80k URLs/s |
404
- | Corpus save/load (10k) | ~135 ms |
336
+ ```rust
337
+ use iriq::{parse, normalize, Corpus};
405
338
 
406
- Linear scaling holds through 100k observations; per-observation retained
407
- memory amortizes to ~100 bytes at that scale. Memoization caches are
408
- bounded by `CACHE_MAX = 10_000` (cleared when full) — overhead is a few
409
- hundred KB regardless of corpus size.
339
+ let iri = parse("https://foo.com/users/123")?;
340
+ iri.host; // "foo.com"
341
+ iri.path_segments; // ["users", "123"]
342
+ iri.canonical(); // "https://foo.com/users/123"
410
343
 
411
- Re-run anytime with:
344
+ normalize("https://foo.com/users/123")?; // "https://foo.com/users/{user_id}"
412
345
 
346
+ // Streaming clustering against a persistent corpus.
347
+ let mut corpus = Corpus::open("c.db")?;
348
+ corpus.observe("https://foo.com/users/1")?;
349
+ corpus.save("c.db")?;
413
350
  ```
414
- bundle exec script/benchmark.rb # throughput
415
- bundle exec script/memory.rb # retained memory + cache footprints
416
- ```
351
+
352
+ Full API on [docs.rs/iriq](https://docs.rs/iriq); see the
353
+ [crate README](rust/iriq/README.md) for the library tour.
417
354
 
418
355
  ## Limitations (intentional)
419
356
 
420
- This is an MVP. Iriq does **not**:
357
+ Iriq does **not**:
421
358
 
422
359
  - Implement RFC 3986, RFC 3987, or the WHATWG URL standard fully.
423
360
  - Convert between Unicode (IRI) and punycode (URI) — the display form is
@@ -425,42 +362,8 @@ This is an MVP. Iriq does **not**:
425
362
  - Percent-encode or decode path/query bytes. Bytes are kept as written.
426
363
  - Validate scheme-specific structure beyond URL vs. URN.
427
364
  - Resolve relative references against a base URL.
428
- - Round-trip `canonical` back to the exact original byte-for-byte (whitespace
429
- is stripped, default ports are dropped, dot segments are collapsed).
430
-
431
- For richer IRI handling, see `addressable`. Iriq's focus is the analysis
432
- side: classification, normalization, and clustering — not a complete URL
433
- implementation.
434
-
435
- ----
436
- ## Go port
437
-
438
- A Go implementation lives under [`go/`](go/) — same public surface, same
439
- behavior, ~10× faster CLI on extraction-heavy workloads. The Ruby gem is
440
- the reference; the Go port stays in sync via golden JSON fixtures
441
- (`spec/fixtures/`) and a CLI parity harness (`script/cli_parity.sh`), both
442
- checked in CI.
443
-
444
- ```go
445
- import "github.com/dpep/iriq/go/iriq"
446
-
447
- iri, _ := iriq.Parse("https://foo.com/users/123")
448
- norm, _ := iriq.Normalize("https://foo.com/users/123")
449
- // "https://foo.com/users/{user_id}"
450
- ```
451
-
452
- See [`go/README.md`](go/README.md) for the full API table and porting workflow.
453
-
454
- ----
455
- ## Contributing
456
-
457
- Yes please :)
365
+ - Round-trip `canonical` back to the exact original byte-for-byte (whitespace is
366
+ stripped, default ports are dropped, dot segments are collapsed).
458
367
 
459
- 1. Fork it
460
- 1. Create your feature branch (`git checkout -b my-feature`)
461
- 1. Ensure the tests pass (`bundle exec rspec`)
462
- 1. If you changed library behavior, port the change to Go (or open an
463
- issue) and regenerate fixtures: `bundle exec ruby script/generate_fixtures.rb`
464
- 1. Commit your changes (`git commit -am 'awesome new feature'`)
465
- 1. Push your branch (`git push origin my-feature`)
466
- 1. Create a Pull Request
368
+ Iriq's focus is the analysis side: classification, normalization, and clustering
369
+ not a complete URL implementation.