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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +95 -70
- data/completions/_iriq +2 -0
- data/completions/iriq.bash +1 -1
- data/iriq.gemspec +1 -1
- data/lib/iriq/cli.rb +117 -17
- data/lib/iriq/cluster.rb +63 -17
- data/lib/iriq/clusterer.rb +0 -11
- data/lib/iriq/corpus.rb +3 -2
- data/lib/iriq/identifier.rb +1 -1
- data/lib/iriq/parser.rb +5 -1
- data/lib/iriq/position_stats.rb +2 -2
- data/lib/iriq/recognizer.rb +1 -1
- data/lib/iriq/recognizer_proposal.rb +2 -2
- data/lib/iriq/segment_classifier.rb +8 -5
- data/lib/iriq/specificity.rb +3 -3
- data/lib/iriq/storage/sqlite.rb +63 -6
- data/lib/iriq/version.rb +1 -1
- metadata +2 -8
- data/CLAUDE.md +0 -208
- data/Gemfile +0 -3
- data/Gemfile.lock +0 -103
- data/Makefile +0 -113
- data/docs/ARCHITECTURE.md +0 -223
- data/docs/ROADMAP.md +0 -190
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4290f75faee81b1292a3404fd01221299490731378ace043999e6ab7e7a0dc81
|
|
4
|
+
data.tar.gz: 0b1b074eb746ec2b50a790fb38da54c0715a420ec752929f3eb2af85bed50670
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 81feb4d4b7c8fb0cd9f7e68dbec17fdc787642a6829f2f479849e458c14e5fa9812c251df08f57658b6cb338fcb112d8ec16393efb7aa4e4364e07b2dcf19ae1
|
|
7
|
+
data.tar.gz: 8979d705a2dd2d1db59c4b5fc794ba9307f061c2fbb7b3968c1585c8d633a7871d306c1ec372ad3dc15e11f1ec30c65a7f7833a6955dae8a3e11ca3f3a06d925
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
### 0.33.0 (2026-07-07)
|
|
2
|
+
- **New: end-to-end URL calibration corpus** — 160 messy real-world-shaped inputs (tokens, i18n, encoding damage, legacy endpoints, non-http schemes, garbage) with adjudicated expected templates at `spec/fixtures/calibration/urls.json`, generated by `script/build_url_calibration.rb`, asserted by both runtimes, and CI-gated for staleness. Building it caught the three fixes below.
|
|
3
|
+
- **Bugfix: opaque non-urn schemes were rewritten to `urn:` on normalize/canonical** — `mailto:support@foo.com` came back as `urn:support@foo.com`; same for `tel:`, `sms:`, `data:`, `blob:`, `magnet:`. The scheme is now preserved (both runtimes; cluster keys too).
|
|
4
|
+
- New `:font` file kind (`woff`/`woff2`/`ttf`/`otf`/`eot`), plus `m3u8` (video) and `map` (web) extensions — `Inter-Bold.woff2` now normalizes to `{file}` instead of `{font_id}`.
|
|
5
|
+
- **Bugfix (Rust):** out-of-range ports (`:99999`) were rejected where Ruby's deliberately lenient parser accepts them — the port field is now `Option<u64>` matching Ruby's nil-vs-integer semantics.
|
|
6
|
+
- **Bugfix (Ruby, SQLite backend):** rolling numeric stats (`min`/`max`/`avg` on numeric params) were never restored when loading a corpus from disk, so numeric ranges vanished from cluster output after a reopen — diverging from the Memory/JSON backends and from Rust. Now recomputed from tracked value counts on load; locked in by a new parity scenario and storage specs.
|
|
7
|
+
- Perf: trimmed hot-path allocations in both runtimes — Rust's classifier no longer clones the recognizer list (and locks once, not three times) per cache miss; Ruby builds each observation's Shape once instead of twice, passes the recognizer array without a splat, and drops a redundant `to_s`. No behavior change.
|
|
8
|
+
- **The Rust CLI's `completion` subcommand now matches Ruby exactly.** It previously carried its own divergent bash/zsh scripts plus a fish script Ruby never supported; it now embeds byte-identical copies of the shared bash/zsh scripts (parity-tested), defaults the shell from `$SHELL` like Ruby, and emits Ruby's `unknown_shell` error envelope. Fish is no longer supported. The `completion` subcommand is also listed in `--help` now.
|
|
9
|
+
- **Rust now supports dynamic synthesized types**, closing the last known Ruby↔Rust divergence: `--activate-above` activates a proposal under its suggested type (`activated: ghp (ghp_)`) instead of falling back to `opaque_id`, and the activated type drives `{ghp}`-style placeholders, survives corpus reopen (JSON + SQLite), and round-trips storage — parity-tested. Implementation: `SegmentType::Custom(&'static str)` backed by a leak-once interned name, keeping the enum `Copy`. Also fixes a latent Rust bug where `string`-typed param counts were dropped when reloading a JSON corpus (a hand-copied type table was missing the `string` entry).
|
|
10
|
+
- **The Go port is retired.** Ruby (reference) + Rust (shipped CLI via Homebrew/crates.io) continue; the `go/` module, its CI, and the Ruby↔Go / Rust↔Go parity harnesses are removed. `script/cli_parity.sh` now diffs Ruby ↔ Rust directly. Go consumers can pin `github.com/dpep/iriq/go@v0.32.1`, the last tag with Go support. `.db` corpora written by the Go binary still open cleanly (shared schema v4).
|
|
11
|
+
|
|
12
|
+
### 0.32.1 (2026-06-26)
|
|
13
|
+
- Go and Rust `cluster --json` now include the per-param `values` and `value_distribution` (plus `subtype_distribution`, `kind_distribution`, and numeric `min`/`max`/`avg`) that Ruby already emitted — the cluster JSON is now identical across all three runtimes. No change to the human-readable cluster view.
|
|
14
|
+
- Test coverage: new `param_summary` golden fixture exercises the const → string → enum ladder + confidence across Ruby/Go/Rust (`go test` / `cargo test`), and the parity harnesses gained a key-order-agnostic JSON comparison (`jq -S` + number canonicalization) with a `cluster --json` scenario.
|
|
15
|
+
|
|
16
|
+
### 0.32.0 (2026-06-26)
|
|
17
|
+
- **Query params now climb a confidence ladder: constant → string → enum.** A param with a single observed value is a constant (rendered as its value); one that varies across free-form literal values is the new `string` type (renders `{string}`); a bounded, well-supported value set is `enum` (renders `{enum}`). Previously a varying literal param just echoed whatever value you passed.
|
|
18
|
+
- **Enum detection is now coverage-based and straggler-robust.** An enum is promoted when its *established* values (each seen ≥ `ENUM_MIN_VALUE_COUNT`, now 3) number 2–10 and cover ≥ 90% of observations. A single brand-new value no longer knocks an established enum back down — fixing the observe-before-normalize order dependence where normalizing `?status=<new>` could flip the type. A lone repeated value is now correctly a constant, not a one-member enum.
|
|
19
|
+
- **New `confidence` score on every param** (`total / (total + 15)`): a 0–1 figure of how much evidence backs the classification, shown in the cluster view (`status enum conf 0.93 ...`) and the cluster JSON. The type is the guess; confidence says how sure.
|
|
20
|
+
- Ruby, Go, and Rust ship this together — same thresholds, same rendering, parity preserved (68/68 Ruby↔Go, 60/60 Rust↔Go).
|
|
21
|
+
|
|
22
|
+
### 0.31.1 (2026-06-26)
|
|
23
|
+
- Docs: clarified the README around corpus-on-by-default — sharper IRI definition (URLs are one member of the family, alongside URNs, `mailto:`, and internationalized addresses), an accurate worked example of the corpus learning a query param is an `enum`, and a streaming example (`tail -f access.log | iriq -J`). Added the streaming example to `--help` (Ruby + Go). No behavior change.
|
|
24
|
+
|
|
25
|
+
### 0.31.0 (2026-06-26)
|
|
26
|
+
- **Corpus is now on by default.** Every invocation observes into a persistent corpus, so classification gets sharper the more you use the tool — the streaming/learning behavior that was the selling point is no longer hidden behind a flag. Default location: `$XDG_DATA_HOME/iriq/default.db` on Linux, `~/Library/Application Support/iriq/default.db` on macOS, `%LOCALAPPDATA%/iriq/default.db` on Windows. First-run creation prints a one-line stderr notice.
|
|
27
|
+
- New flag `-C` / `--no-corpus` (and `IRIQ_NO_CORPUS=1` env) — disables the default corpus for a single invocation. Explicit `--corpus PATH` always wins, even with `--no-corpus` set in the env.
|
|
28
|
+
- New env var `IRIQ_CORPUS=PATH` — overrides the default location without needing `--corpus` on every call.
|
|
29
|
+
- New flag `--reset` — deletes the resolved corpus file (and SQLite `-wal` / `-shm` sidecars) and exits. Honors `--corpus` / `IRIQ_CORPUS` so you can reset a non-default file.
|
|
30
|
+
- **Go build simplification:** the slim / SQLite build split is gone. The Go binary now always links `modernc.org/sqlite` (pure Go, no cgo). `make build-sqlite`, `make release-sqlite`, and `-tags sqlite` are retired. The `dpep/tools/iriq-sqlite` Homebrew formula folds into `dpep/tools/iriq`.
|
|
31
|
+
- Ruby, Go, and Rust ship this together — same defaults, same flags, parity preserved.
|
|
32
|
+
|
|
1
33
|
### 0.30.2 (2026-06-23)
|
|
2
34
|
- Piped stdin and `--file` now **stream** the per-IRI sections (`-n`/`-p`/`-c`/`-e`) line by line, flushing each IRI as it's processed — `tail -f access.log | iriq -n` is live and memory stays bounded on huge inputs. Output is byte-identical to before; the aggregate views (deduped URL list, clusters, `--stats`) still read the whole input. Ruby, Go, and Rust.
|
|
3
35
|
|
data/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
Iriq
|
|
2
|
-
|
|
1
|
+
# Iriq — IRI Query
|
|
2
|
+
|
|
3
3
|
[](https://codecov.io/gh/dpep/iriq)
|
|
4
4
|
|
|
5
5
|
**Iriq finds the *shape* of a URL** — the structural template you get when you
|
|
@@ -9,17 +9,18 @@ URLs — a log file, a column of links, free-text prose — and it collapses the
|
|
|
9
9
|
into a small set of stable, deterministic route templates. Fifty thousand
|
|
10
10
|
distinct URLs become twelve shapes.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
15
16
|
|
|
16
17
|
Everything iriq does — parsing, normalizing, classifying path and query
|
|
17
18
|
components, clustering, learning new patterns — exists to derive, render, or
|
|
18
19
|
group by that shape.
|
|
19
20
|
|
|
20
|
-
And it gets sharper the more you feed it.
|
|
21
|
-
classifications
|
|
22
|
-
placeholders, and whole types emerge that
|
|
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
|
|
23
24
|
position that's always 100–599 is an HTTP status; one bounded to a dozen values
|
|
24
25
|
is an enum).
|
|
25
26
|
|
|
@@ -70,20 +71,53 @@ $ cat access.log | iriq # ≥ 10 IRIs → cluster view
|
|
|
70
71
|
$ cat access.log | iriq --stats # rolling aggregates
|
|
71
72
|
$ iriq ./access.log -n # auto-detect file → normalize each
|
|
72
73
|
$ iriq -J < access.log # newline-delimited JSON
|
|
73
|
-
$ iriq --corpus
|
|
74
|
+
$ iriq --corpus team.db < access.log # use a specific corpus file
|
|
75
|
+
```
|
|
76
|
+
|
|
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:
|
|
79
|
+
|
|
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
|
|
74
83
|
```
|
|
75
84
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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.
|
|
79
88
|
|
|
80
89
|
```sh
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
83
94
|
done
|
|
84
95
|
|
|
85
|
-
|
|
86
|
-
|
|
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%)
|
|
105
|
+
```
|
|
106
|
+
|
|
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}`.
|
|
109
|
+
|
|
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:
|
|
114
|
+
|
|
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
|
|
87
121
|
```
|
|
88
122
|
|
|
89
123
|
### Two ways to normalize
|
|
@@ -109,39 +143,11 @@ brew install dpep/tools/iriq
|
|
|
109
143
|
|
|
110
144
|
# Cargo, from crates.io
|
|
111
145
|
cargo install iriq
|
|
112
|
-
|
|
113
|
-
# Cargo, from a source checkout
|
|
114
|
-
cargo install --path rust/iriq
|
|
115
146
|
```
|
|
116
147
|
|
|
117
148
|
One crate ships both the library and the `iriq` binary. Corpora persist to
|
|
118
149
|
SQLite (bundled, WAL) out of the box — nothing to flag, install, or rebuild.
|
|
119
150
|
|
|
120
|
-
## Use it as a Rust library
|
|
121
|
-
|
|
122
|
-
```sh
|
|
123
|
-
cargo add iriq
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
```rust
|
|
127
|
-
use iriq::{parse, normalize, Corpus};
|
|
128
|
-
|
|
129
|
-
let iri = parse("https://foo.com/users/123")?;
|
|
130
|
-
iri.host; // "foo.com"
|
|
131
|
-
iri.path_segments; // ["users", "123"]
|
|
132
|
-
iri.canonical(); // "https://foo.com/users/123"
|
|
133
|
-
|
|
134
|
-
normalize("https://foo.com/users/123")?; // "https://foo.com/users/{user_id}"
|
|
135
|
-
|
|
136
|
-
// Streaming clustering against a persistent corpus.
|
|
137
|
-
let mut corpus = Corpus::open("c.db")?;
|
|
138
|
-
corpus.observe("https://foo.com/users/1")?;
|
|
139
|
-
corpus.save("c.db")?;
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
Full API on [docs.rs/iriq](https://docs.rs/iriq); see the
|
|
143
|
-
[crate README](rust/iriq/README.md) for the library tour.
|
|
144
|
-
|
|
145
151
|
## Segment classification
|
|
146
152
|
|
|
147
153
|
Iriq classifies each path/query segment into one of ~25 types — the first
|
|
@@ -184,8 +190,7 @@ mechanical placeholders (`{integer}` instead of `{user_id}`).
|
|
|
184
190
|
|
|
185
191
|
### Types only the corpus can see
|
|
186
192
|
|
|
187
|
-
Four types
|
|
188
|
-
of values a position has held across many observations:
|
|
193
|
+
Four types emerge from the *distribution* of values across many observations:
|
|
189
194
|
|
|
190
195
|
| Type | Emerges when a position… |
|
|
191
196
|
| --- | --- |
|
|
@@ -195,16 +200,16 @@ of values a position has held across many observations:
|
|
|
195
200
|
| `enum` | holds a small, bounded set of distinct values |
|
|
196
201
|
|
|
197
202
|
Mechanically, `200` is just an integer. Across ten thousand URLs where that
|
|
198
|
-
slot is always 100–599, it's an HTTP status.
|
|
203
|
+
slot is always 100–599, it's likely an HTTP status.
|
|
199
204
|
|
|
200
205
|
## Corpus (streaming + learning)
|
|
201
206
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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.
|
|
205
211
|
|
|
206
|
-
|
|
207
|
-
`.sqlite` / `.sqlite3` path is stored in SQLite (WAL journaling, incremental
|
|
212
|
+
A `.db` / `.sqlite` / `.sqlite3` path is stored in SQLite (WAL journaling, incremental
|
|
208
213
|
UPSERTs — multiple `iriq --corpus` processes can write concurrently); a
|
|
209
214
|
`.json` path writes a plain JSON file instead.
|
|
210
215
|
|
|
@@ -225,9 +230,7 @@ $ iriq --corpus c.db --reinfer
|
|
|
225
230
|
Iriq doesn't just classify against a fixed list — it watches the stream and
|
|
226
231
|
*proposes new recognizers* for patterns it keeps seeing. Notice `ghp_…` or
|
|
227
232
|
`cus_…` recurring at a slug position and iriq will suggest a recognizer for it,
|
|
228
|
-
with evidence: coverage, host count, confidence.
|
|
229
|
-
auto-applied — you activate the ones you trust, and they persist with the
|
|
230
|
-
corpus. Human-in-the-loop by design.
|
|
233
|
+
with evidence: coverage, host count, confidence.
|
|
231
234
|
|
|
232
235
|
```sh
|
|
233
236
|
# Print proposals (human-readable, or --json)
|
|
@@ -291,11 +294,14 @@ an ephemeral corpus.
|
|
|
291
294
|
| `-p, --parse` | Show parsed fields |
|
|
292
295
|
| `-n, --normalize` | Show the shape-normalized form |
|
|
293
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 |
|
|
294
298
|
| `-j, --json` | Emit JSON |
|
|
295
299
|
| `-J, --ndjson` | Newline-delimited JSON (one object per line); implies `--json` |
|
|
296
300
|
| `-N, --no-hints` | Use `{integer}` etc. instead of `{user_id}` |
|
|
297
301
|
| `--no-scheme-less` | Skip `foo.com/path`-style extraction (explicit-scheme only) |
|
|
298
|
-
| `--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 |
|
|
299
305
|
| `--host MODE` | Host-keying for clustering: `full` (default), `reg` strips subdomains, `none` ignores host |
|
|
300
306
|
| `--stats` | Print rolling aggregates |
|
|
301
307
|
| `--reinfer` | Drop the materialized views and replay the source-IRI log through the current classifier + reducers |
|
|
@@ -308,13 +314,44 @@ an ephemeral corpus.
|
|
|
308
314
|
| `completion bash\|zsh` | Print shell completion script (Homebrew installs this automatically) |
|
|
309
315
|
| `-V, --version` | Print version |
|
|
310
316
|
|
|
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
|
+
|
|
311
324
|
A positional argument that doesn't parse as an IRI but IS an existing file is
|
|
312
325
|
read and extracted from automatically — `iriq ./access.log` and
|
|
313
|
-
`iriq /var/log/foo.log` Just Work. (
|
|
314
|
-
parse as a URL; pipe with `cat` to disambiguate.)
|
|
326
|
+
`iriq /var/log/foo.log` Just Work. (pipe with `cat` to disambiguate)
|
|
315
327
|
|
|
316
328
|
Exit codes: `0` success, `1` usage error, `2` parse error.
|
|
317
329
|
|
|
330
|
+
## Rust library
|
|
331
|
+
|
|
332
|
+
```sh
|
|
333
|
+
cargo add iriq
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
```rust
|
|
337
|
+
use iriq::{parse, normalize, Corpus};
|
|
338
|
+
|
|
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"
|
|
343
|
+
|
|
344
|
+
normalize("https://foo.com/users/123")?; // "https://foo.com/users/{user_id}"
|
|
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")?;
|
|
350
|
+
```
|
|
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.
|
|
354
|
+
|
|
318
355
|
## Limitations (intentional)
|
|
319
356
|
|
|
320
357
|
Iriq does **not**:
|
|
@@ -330,15 +367,3 @@ Iriq does **not**:
|
|
|
330
367
|
|
|
331
368
|
Iriq's focus is the analysis side: classification, normalization, and clustering
|
|
332
369
|
— not a complete URL implementation.
|
|
333
|
-
|
|
334
|
-
----
|
|
335
|
-
## Contributing
|
|
336
|
-
|
|
337
|
-
Yes please :)
|
|
338
|
-
|
|
339
|
-
1. Fork it
|
|
340
|
-
1. Create your feature branch (`git checkout -b my-feature`)
|
|
341
|
-
1. Ensure the tests pass (`cd rust && cargo test`)
|
|
342
|
-
1. Commit your changes (`git commit -am 'awesome new feature'`)
|
|
343
|
-
1. Push your branch (`git push origin my-feature`)
|
|
344
|
-
1. Create a Pull Request
|
data/completions/_iriq
CHANGED
|
@@ -27,6 +27,8 @@ _iriq() {
|
|
|
27
27
|
'--no-scheme-less[skip schemeless URL extraction]' \
|
|
28
28
|
'--scheme-less[enable schemeless URL extraction]' \
|
|
29
29
|
'--corpus[load/create a JSON or SQLite corpus]:corpus path:_files -g "*.(json|db|sqlite|sqlite3)"' \
|
|
30
|
+
'(-C --no-corpus)'{-C,--no-corpus}'[disable corpus persistence for this invocation]' \
|
|
31
|
+
'--reset[delete the corpus database and exit]' \
|
|
30
32
|
'--host[host-keying strategy for clustering]:strategy:(full registrable reg none)' \
|
|
31
33
|
'--stats[print rolling aggregates]' \
|
|
32
34
|
'--reinfer[replay the source-IRI log]' \
|
data/completions/iriq.bash
CHANGED
|
@@ -44,7 +44,7 @@ _iriq() {
|
|
|
44
44
|
if [[ "$cur" == -* ]]; then
|
|
45
45
|
local flags="-h --help -V --version -p --parse -n --normalize -c --canonical -e --explain
|
|
46
46
|
-j --json -J --ndjson -N --no-hints --hints --no-scheme-less
|
|
47
|
-
--scheme-less --corpus --host --stats --reinfer
|
|
47
|
+
--scheme-less --corpus -C --no-corpus --reset --host --stats --reinfer
|
|
48
48
|
--propose-recognizers --activate-above --cross-host-shapes
|
|
49
49
|
--min-observations --min-coverage --min-hosts"
|
|
50
50
|
COMPREPLY=( $(compgen -W "$flags" -- "$cur") )
|
data/iriq.gemspec
CHANGED
|
@@ -5,7 +5,7 @@ Gem::Specification.new do |s|
|
|
|
5
5
|
s.version = Iriq::VERSION
|
|
6
6
|
s.authors = ["Daniel Pepper"]
|
|
7
7
|
s.description = "IRI extraction, normalization, and clustering."
|
|
8
|
-
s.files = `git ls-files
|
|
8
|
+
s.files = `git ls-files lib exe completions README.md LICENSE.txt CHANGELOG.md iriq.gemspec`.split("\n")
|
|
9
9
|
s.bindir = "exe"
|
|
10
10
|
s.executables = ["iriq"]
|
|
11
11
|
s.homepage = "https://github.com/dpep/iriq"
|
data/lib/iriq/cli.rb
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "fileutils"
|
|
1
2
|
require "json"
|
|
2
3
|
require "optparse"
|
|
3
4
|
require "stringio"
|
|
@@ -35,30 +36,37 @@ module Iriq
|
|
|
35
36
|
each placeholder / canonical value was chosen
|
|
36
37
|
|
|
37
38
|
Corpus + stats:
|
|
38
|
-
--corpus PATH
|
|
39
|
-
|
|
39
|
+
--corpus PATH Use a specific corpus file (overrides the default).
|
|
40
|
+
Extension picks the backend: .db/.sqlite/.sqlite3
|
|
41
|
+
are SQLite; anything else is JSON.
|
|
42
|
+
-C, --no-corpus Disable corpus persistence for this invocation.
|
|
43
|
+
Same as IRIQ_NO_CORPUS=1 in the environment.
|
|
44
|
+
--reset Delete the corpus database (default path or the
|
|
45
|
+
one resolved via --corpus / IRIQ_CORPUS) and exit.
|
|
40
46
|
--host MODE Host-keying strategy for clustering:
|
|
41
47
|
full (default), registrable (or reg) strips
|
|
42
48
|
subdomains, none ignores host entirely.
|
|
43
49
|
--stats Print rolling aggregates
|
|
44
50
|
--reinfer Replay the source-IRI log through the current
|
|
45
51
|
classifier + reducers; rebuilds materialized
|
|
46
|
-
views from scratch.
|
|
52
|
+
views from scratch.
|
|
47
53
|
--propose-recognizers
|
|
48
54
|
Scan observed values for shape patterns that
|
|
49
55
|
recur enough to suggest a new Recognizer.
|
|
50
56
|
Combine with --json for structured output.
|
|
51
|
-
Requires --corpus.
|
|
52
57
|
--cross-host-shapes
|
|
53
58
|
List route shapes that recur across
|
|
54
59
|
multiple hosts. Combine with --min-hosts.
|
|
55
|
-
Requires --corpus.
|
|
56
60
|
--activate-above F With --propose-recognizers, promote every
|
|
57
61
|
proposal at or above CONFIDENCE F into a
|
|
58
62
|
live Recognizer on the corpus, then
|
|
59
63
|
reinfer. Confidence integrates coverage
|
|
60
64
|
and cross-host corroboration.
|
|
61
65
|
|
|
66
|
+
Environment:
|
|
67
|
+
IRIQ_CORPUS=PATH Set the corpus path (overrides the default).
|
|
68
|
+
IRIQ_NO_CORPUS=1 Disable the default corpus (equivalent to -C).
|
|
69
|
+
|
|
62
70
|
Thresholds (apply to --propose-recognizers / --cross-host-shapes):
|
|
63
71
|
--min-observations N proposal noise floor (default 20)
|
|
64
72
|
--min-coverage F proposal coverage floor (default 0.7)
|
|
@@ -76,12 +84,14 @@ module Iriq
|
|
|
76
84
|
|
|
77
85
|
Subcommands:
|
|
78
86
|
cluster [file] Force cluster view (default for ≥10 IRIs anyway)
|
|
87
|
+
completion <shell> Print shell completion script (bash | zsh)
|
|
79
88
|
|
|
80
89
|
Examples:
|
|
81
90
|
iriq foo.com/users/456
|
|
82
91
|
iriq -n https://foo.com/users/123
|
|
83
92
|
iriq ./access.log # auto-detect file → extract URLs
|
|
84
93
|
cat README.md | iriq -n # one normalized URL per line
|
|
94
|
+
tail -f access.log | iriq -J # live stream → NDJSON per IRI
|
|
85
95
|
cat README.md | iriq --corpus c.json
|
|
86
96
|
TXT
|
|
87
97
|
|
|
@@ -123,9 +133,17 @@ module Iriq
|
|
|
123
133
|
batch_mode = explicit_cluster || positional_is_file ||
|
|
124
134
|
(args.empty? && piped_stdin?)
|
|
125
135
|
|
|
136
|
+
# --reset short-circuits: delete the resolved corpus file (+ sidecars)
|
|
137
|
+
# and exit. Resolves through the same precedence chain as the normal
|
|
138
|
+
# path so `--reset --corpus other.db` and `IRIQ_CORPUS=… --reset` Just Work.
|
|
139
|
+
if opts[:reset]
|
|
140
|
+
return cmd_reset(opts)
|
|
141
|
+
end
|
|
142
|
+
|
|
126
143
|
return print_usage(stdout, 0) if args.empty? && !batch_mode && !opts[:reinfer] && !opts[:propose] && !opts[:cross_host_shapes]
|
|
127
144
|
|
|
128
|
-
|
|
145
|
+
corpus_path = resolve_corpus_path(opts)
|
|
146
|
+
corpus = corpus_path ? load_corpus(corpus_path, host_strategy: opts[:host_strategy], announce_create: true) : nil
|
|
129
147
|
|
|
130
148
|
code = if opts[:reinfer]
|
|
131
149
|
cmd_reinfer(corpus, opts)
|
|
@@ -141,7 +159,7 @@ module Iriq
|
|
|
141
159
|
cmd_summary(args, opts, corpus)
|
|
142
160
|
end
|
|
143
161
|
|
|
144
|
-
corpus.save(
|
|
162
|
+
corpus.save(corpus_path) if corpus && corpus_path
|
|
145
163
|
code
|
|
146
164
|
rescue Iriq::ParseError => e
|
|
147
165
|
emit_error("parse_error", e.message, 2, human: "iriq: parse error: #{e.message}")
|
|
@@ -167,6 +185,8 @@ module Iriq
|
|
|
167
185
|
hints: true,
|
|
168
186
|
sections: [],
|
|
169
187
|
corpus: nil,
|
|
188
|
+
no_corpus: false,
|
|
189
|
+
reset: false,
|
|
170
190
|
stats: false,
|
|
171
191
|
reinfer: false,
|
|
172
192
|
propose: false,
|
|
@@ -191,6 +211,8 @@ module Iriq
|
|
|
191
211
|
o.on("--[no-]hints") { |v| opts[:hints] = v }
|
|
192
212
|
o.on("-N") { opts[:hints] = false }
|
|
193
213
|
o.on("--corpus PATH") { |v| opts[:corpus] = v }
|
|
214
|
+
o.on("-C", "--no-corpus") { opts[:no_corpus] = true }
|
|
215
|
+
o.on("--reset") { opts[:reset] = true }
|
|
194
216
|
o.on("--host MODE") { |v| opts[:host_strategy] = host_strategy_arg(v) }
|
|
195
217
|
o.on("--stats") { opts[:stats] = true }
|
|
196
218
|
o.on("--reinfer") { opts[:reinfer] = true }
|
|
@@ -220,10 +242,79 @@ module Iriq
|
|
|
220
242
|
end
|
|
221
243
|
end
|
|
222
244
|
|
|
223
|
-
def load_corpus(path, host_strategy: :full)
|
|
245
|
+
def load_corpus(path, host_strategy: :full, announce_create: false)
|
|
246
|
+
if announce_create && !File.exist?(path)
|
|
247
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
248
|
+
stderr.puts "iriq: created corpus at #{path} (disable with --no-corpus or IRIQ_NO_CORPUS=1)"
|
|
249
|
+
end
|
|
224
250
|
Corpus.open(path, host_strategy: host_strategy)
|
|
225
251
|
end
|
|
226
252
|
|
|
253
|
+
# Resolve the corpus file the CLI should use. Precedence:
|
|
254
|
+
# 1. --corpus PATH — explicit always wins (you asked for it)
|
|
255
|
+
# 2. --no-corpus / IRIQ_NO_CORPUS=1 — opt out of the default
|
|
256
|
+
# 3. IRIQ_CORPUS=PATH — env override of the default location
|
|
257
|
+
# 4. default_corpus_path — platform-aware location
|
|
258
|
+
def resolve_corpus_path(opts)
|
|
259
|
+
return opts[:corpus] if opts[:corpus]
|
|
260
|
+
return nil if opts[:no_corpus] || env_corpus_disabled?
|
|
261
|
+
|
|
262
|
+
env_path = ENV["IRIQ_CORPUS"].to_s
|
|
263
|
+
return env_path unless env_path.empty?
|
|
264
|
+
|
|
265
|
+
default_corpus_path
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Platform-aware default. XDG-honoring on Linux + BSD, Apple-style on
|
|
269
|
+
# macOS, %LOCALAPPDATA% on Windows. Same logic in Rust so both
|
|
270
|
+
# runtimes share the same default.db.
|
|
271
|
+
def default_corpus_path
|
|
272
|
+
base = if (xdg = ENV["XDG_DATA_HOME"].to_s) && !xdg.empty?
|
|
273
|
+
File.join(xdg, "iriq")
|
|
274
|
+
elsif RUBY_PLATFORM =~ /darwin/
|
|
275
|
+
File.expand_path("~/Library/Application Support/iriq")
|
|
276
|
+
elsif RUBY_PLATFORM =~ /mingw|mswin|cygwin/
|
|
277
|
+
File.join(ENV["LOCALAPPDATA"].to_s.empty? ? File.expand_path("~/AppData/Local") : ENV["LOCALAPPDATA"], "iriq")
|
|
278
|
+
else
|
|
279
|
+
File.expand_path("~/.local/share/iriq")
|
|
280
|
+
end
|
|
281
|
+
File.join(base, "default.db")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def env_corpus_disabled?
|
|
285
|
+
v = ENV["IRIQ_NO_CORPUS"].to_s.downcase
|
|
286
|
+
!v.empty? && v != "0" && v != "false" && v != "no"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def cmd_reset(opts)
|
|
290
|
+
path = resolve_reset_path(opts)
|
|
291
|
+
unless path
|
|
292
|
+
return emit_error("missing_argument", "no corpus path to reset (use --corpus PATH or unset --no-corpus)", 1)
|
|
293
|
+
end
|
|
294
|
+
removed = []
|
|
295
|
+
[path, "#{path}-wal", "#{path}-shm", "#{path}.tmp"].each do |p|
|
|
296
|
+
if File.exist?(p)
|
|
297
|
+
File.delete(p)
|
|
298
|
+
removed << p
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
if removed.empty?
|
|
302
|
+
stderr.puts "iriq: no corpus to reset at #{path}"
|
|
303
|
+
else
|
|
304
|
+
stderr.puts "iriq: reset corpus at #{path}"
|
|
305
|
+
end
|
|
306
|
+
0
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# --reset honors --corpus / IRIQ_CORPUS even when --no-corpus is set —
|
|
310
|
+
# the user is explicitly addressing a stored file, not the runtime state.
|
|
311
|
+
def resolve_reset_path(opts)
|
|
312
|
+
return opts[:corpus] if opts[:corpus]
|
|
313
|
+
env_path = ENV["IRIQ_CORPUS"].to_s
|
|
314
|
+
return env_path unless env_path.empty?
|
|
315
|
+
default_corpus_path
|
|
316
|
+
end
|
|
317
|
+
|
|
227
318
|
# Accept `--host=reg` as a short alias for the `registrable` mode.
|
|
228
319
|
HOST_STRATEGY_ALIASES = {
|
|
229
320
|
"full" => :full, "registrable" => :registrable, "reg" => :registrable, "none" => :none,
|
|
@@ -473,9 +564,10 @@ module Iriq
|
|
|
473
564
|
end
|
|
474
565
|
|
|
475
566
|
# `completion <shell>` — emit the bundled shell-completion script.
|
|
476
|
-
# Scripts live in completions/{iriq.bash,_iriq} alongside the
|
|
477
|
-
# Homebrew installs them automatically, but the user can also do
|
|
478
|
-
# `source <(iriq completion bash)` in their shell rc.
|
|
567
|
+
# Scripts live in completions/{iriq.bash,_iriq} alongside the
|
|
568
|
+
# gem; Homebrew installs them automatically, but the user can also do
|
|
569
|
+
# `source <(iriq completion bash)` in their shell rc. The Rust CLI
|
|
570
|
+
# inlines the same scripts — keep them byte-identical (parity-tested).
|
|
479
571
|
COMPLETIONS_DIR = File.expand_path("../../completions", __dir__).freeze
|
|
480
572
|
COMPLETION_FILES = {
|
|
481
573
|
"bash" => File.join(COMPLETIONS_DIR, "iriq.bash"),
|
|
@@ -653,7 +745,10 @@ module Iriq
|
|
|
653
745
|
end
|
|
654
746
|
|
|
655
747
|
def emit_clusters(clusters, opts)
|
|
656
|
-
|
|
748
|
+
# Stable sort: equal-count clusters keep first-seen order. Ruby's
|
|
749
|
+
# sort_by is unstable, so ties need the explicit index tie-break to
|
|
750
|
+
# match the Rust CLI's (stable) sort.
|
|
751
|
+
sorted = clusters.sort_by.with_index { |c, i| [-c.count, i] }
|
|
657
752
|
|
|
658
753
|
if opts[:json]
|
|
659
754
|
emit_json(sorted.map(&:to_h), opts)
|
|
@@ -672,8 +767,8 @@ module Iriq
|
|
|
672
767
|
end
|
|
673
768
|
end
|
|
674
769
|
|
|
675
|
-
# One line per param: type, range (numeric), cardinality,
|
|
676
|
-
# `page integer 1..100 avg 50.5 (10 distinct, 100%)`
|
|
770
|
+
# One line per param: type, confidence, range (numeric), cardinality,
|
|
771
|
+
# presence. `page integer conf 0.87 1..100 avg 50.5 (10 distinct, 100%)`
|
|
677
772
|
def emit_param_summary(cluster)
|
|
678
773
|
rows = cluster.param_summary
|
|
679
774
|
return if rows.empty?
|
|
@@ -681,6 +776,7 @@ module Iriq
|
|
|
681
776
|
width = rows.map { |r| r[:name].length }.max
|
|
682
777
|
rows.each do |r|
|
|
683
778
|
bits = ["#{r[:type]}"]
|
|
779
|
+
bits << "conf #{format_conf(r[:confidence])}" if r[:confidence]
|
|
684
780
|
if r[:min] && r[:max]
|
|
685
781
|
bits << format_range(r[:min], r[:max])
|
|
686
782
|
bits << "avg #{format_num(r[:avg])}" if r[:avg]
|
|
@@ -690,6 +786,11 @@ module Iriq
|
|
|
690
786
|
end
|
|
691
787
|
end
|
|
692
788
|
|
|
789
|
+
# Two-decimal confidence, e.g. 0.87. Matches the Rust CLI formatting.
|
|
790
|
+
def format_conf(conf)
|
|
791
|
+
format("%.2f", conf)
|
|
792
|
+
end
|
|
793
|
+
|
|
693
794
|
def format_range(lo, hi)
|
|
694
795
|
"#{format_num(lo)}..#{format_num(hi)}"
|
|
695
796
|
end
|
|
@@ -737,9 +838,8 @@ module Iriq
|
|
|
737
838
|
end
|
|
738
839
|
|
|
739
840
|
def top(hash)
|
|
740
|
-
# Lex tie-break on equal counts —
|
|
741
|
-
#
|
|
742
|
-
# order). Keeps Ruby ↔ Go --stats parity stable.
|
|
841
|
+
# Lex tie-break on equal counts — keeps --stats output deterministic
|
|
842
|
+
# and Ruby ↔ Rust parity stable regardless of insertion order.
|
|
743
843
|
hash.sort_by { |k, n| [-n, k] }.first(TOP_N_STATS).to_h
|
|
744
844
|
end
|
|
745
845
|
end
|