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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +110 -0
- data/README.md +259 -356
- data/completions/_iriq +54 -0
- data/completions/iriq.bash +70 -0
- data/iriq.gemspec +2 -2
- data/lib/iriq/cli.rb +502 -50
- data/lib/iriq/cluster.rb +332 -14
- data/lib/iriq/clusterer.rb +0 -11
- data/lib/iriq/corpus.rb +321 -38
- data/lib/iriq/cross_host_shape.rb +37 -0
- data/lib/iriq/event.rb +22 -0
- data/lib/iriq/evidence.rb +114 -0
- data/lib/iriq/explanation.rb +1 -1
- data/lib/iriq/identifier.rb +1 -1
- data/lib/iriq/normalizer.rb +71 -29
- data/lib/iriq/parser.rb +5 -1
- data/lib/iriq/path_shape.rb +30 -24
- data/lib/iriq/position.rb +75 -0
- data/lib/iriq/position_stats.rb +74 -8
- data/lib/iriq/recognizer.rb +54 -0
- data/lib/iriq/recognizer_proposal.rb +167 -0
- data/lib/iriq/recognizers/date.rb +53 -0
- data/lib/iriq/recognizers/integer.rb +37 -0
- data/lib/iriq/recognizers/uuid.rb +16 -0
- data/lib/iriq/reducer.rb +37 -0
- data/lib/iriq/registrable_domain.rb +56 -0
- data/lib/iriq/segment_classifier.rb +478 -23
- data/lib/iriq/segment_hints.rb +9 -0
- data/lib/iriq/shape.rb +106 -0
- data/lib/iriq/specificity.rb +35 -0
- data/lib/iriq/storage/memory.rb +83 -12
- data/lib/iriq/storage/sqlite.rb +279 -43
- data/lib/iriq/synthesized_recognizer.rb +56 -0
- data/lib/iriq/trace.rb +294 -0
- data/lib/iriq/version.rb +1 -1
- data/lib/iriq.rb +17 -0
- metadata +19 -6
- data/CLAUDE.md +0 -121
- data/Gemfile +0 -3
- data/Gemfile.lock +0 -103
- data/Makefile +0 -56
data/README.md
CHANGED
|
@@ -1,46 +1,48 @@
|
|
|
1
|
-
Iriq
|
|
2
|
-
======
|
|
3
|
-

|
|
4
|
-
[](https://codecov.io/gh/dpep/iriq)
|
|
1
|
+
# Iriq — IRI Query
|
|
5
2
|
|
|
6
|
-
|
|
3
|
+
[](https://codecov.io/gh/dpep/iriq)
|
|
7
4
|
|
|
8
|
-
Iriq
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
32
|
+
It answers questions like:
|
|
31
33
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
import "github.com/dpep/iriq"
|
|
39
|
-
```
|
|
41
|
+
Iriq ships as a **command-line tool** (`iriq`) and a **Rust library**.
|
|
40
42
|
|
|
41
|
-
##
|
|
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
|
-
$
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
#
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
#
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
+
Pick by the question you're asking:
|
|
206
126
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
end
|
|
140
|
+
```sh
|
|
141
|
+
# Homebrew (recommended)
|
|
142
|
+
brew install dpep/tools/iriq
|
|
225
143
|
|
|
226
|
-
|
|
227
|
-
|
|
144
|
+
# Cargo, from crates.io
|
|
145
|
+
cargo install iriq
|
|
228
146
|
```
|
|
229
147
|
|
|
230
|
-
|
|
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
|
-
|
|
233
|
-
`classification:` symbol on top of the deterministic fields:
|
|
151
|
+
## Segment classification
|
|
234
152
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
##
|
|
205
|
+
## Corpus (streaming + learning)
|
|
244
206
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
265
|
-
Trade-off picked to keep CSV-shaped text working.
|
|
266
|
-
- No HTML entity decoding (`&` 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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
```
|
|
277
|
-
|
|
224
|
+
```sh
|
|
225
|
+
$ iriq --corpus c.db --reinfer
|
|
278
226
|
```
|
|
279
227
|
|
|
280
|
-
##
|
|
228
|
+
## Learning new types
|
|
281
229
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
320
|
-
https://foo.com/users/{user_id}
|
|
321
|
-
```
|
|
243
|
+
### Cross-host shape learning
|
|
322
244
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
extension picks the storage backend:
|
|
265
|
+
Known limitations (intentional):
|
|
349
266
|
|
|
350
|
-
-
|
|
351
|
-
|
|
352
|
-
-
|
|
353
|
-
|
|
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 (`&` stays as-is).
|
|
270
|
+
- Scheme-less mode skips bare hostnames without a path (too noisy in prose).
|
|
356
271
|
|
|
357
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
exports any backend as JSON.
|
|
285
|
+
**Single input** — combined parse + normalize summary; trim with section flags
|
|
286
|
+
(`-p`, `-n`).
|
|
371
287
|
|
|
372
|
-
|
|
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
|
-
| `-
|
|
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` |
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
##
|
|
330
|
+
## Rust library
|
|
393
331
|
|
|
394
|
-
|
|
395
|
-
|
|
332
|
+
```sh
|
|
333
|
+
cargo add iriq
|
|
334
|
+
```
|
|
396
335
|
|
|
397
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
460
|
-
|
|
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.
|