iriq 0.30.2 → 0.35.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 598c04e3c1777787ae9e5d1be98e2bc68d441e2020ebe7743bdf8075b20fdaec
4
- data.tar.gz: 396ad6b0b0acffb76b7bc2b4e31792b02ac65749c6ac769fd70a47ce5d806496
3
+ metadata.gz: aad1274de2d7cae934f5b940e97f498193883addffcab77e8420f47425b155fb
4
+ data.tar.gz: f6b0f21f92304b161e43c5ca367a0ac7f24ae087a987634d902cddd9807fad74
5
5
  SHA512:
6
- metadata.gz: 16637ff46f4648a2cfc14404ed074d3cedc6fc08cabf0c46a6fa7a39553b8b78020907fde1d9024005cec3a4f2cdb4cb3e999802ec866a942c20c15be6c7af34
7
- data.tar.gz: 458aa6deba73a571a07801bb3df445a4d08500a55c3d1d8c50b11df0811dbc785270fd1ef908ebbb58c6f53dfb1ecfc7013fe137e216e88bca81c9bc56d21fa4
6
+ metadata.gz: 443e74eb1cb032d04baa078c1114d1287fb8597258003755fcadbea0b750be074de302a97c794cadd15783c3251296bf3f40735bf96c4886a20cd036180cb93d
7
+ data.tar.gz: 2a93211667789f9430df9dee556ecb0aa05702f5c39fad23d3a6b803b946ba91c3af3df9d57fbd0a5c84bca40e8e7a4b3a741c920a59dbf1755e7325ab323d06
data/CHANGELOG.md CHANGED
@@ -1,3 +1,128 @@
1
+ ### 0.35.0 (2026-09-15)
2
+
3
+ A hardening round. Rust library consumers have migration work; CLI users mostly get fixes, plus a few rules made explicit.
4
+
5
+ #### What you must do
6
+
7
+ **Rust library consumers.** None of this changes the CLI.
8
+
9
+ - **Import from the crate root.** The modules are private; the public API is what `lib.rs` re-exports. Gone from it: the `Storage` trait, `open_storage` and the storage backends; `Corpus::storage()`, `storage_mut()`, `new_with_classifier`, `stats_for` and `max_values_per_position`; the `Corpus.classifier` and `Corpus.host_strategy` fields (use `set_host_strategy`); `DEFAULT_CLASSIFIER`, `SegmentClassifier`, the recognizer types and `SynthesizedRecognizer`; `Clusterer`, `ClusterKey`, `cluster_key_for*` and `ExplainEntry`; `Event`, `Observation`, `Shape`, `ShapeRenderOptions` and `PathShape`; `derive_hints*`, `NormalizationEvidence`, `NullEvidence` and `normalize_identifier_with_evidence`; `explain_identifier`; the classifier helpers (`display_type`, `color_kind`, `file_kind`, `param_name_hint`, `canonical_currency`, `canonical_date`); `DEFAULT_MAX_VALUES_PER_POSITION` and the corpus tuning constants. They let one corpus change classification for every other corpus and for `normalize()`, or put a corpus out of step with its own source log.
10
+ - **Handle `iriq::Error` with `?`.** Every `Corpus` operation returns `iriq::Result<T>`: `open`, `observe`, `observe_iri`, `save`, `close`, `reinfer`, `activate_proposal`, `activate_proposals_above`, and every read (`normalize`, `normalize_identifier`, `explain`, `params_for`, `clusters`, `size`, `host_counts`, `path_length_counts`, `raw_shape_counts`, `fingerprint_counts`, `cross_host_shapes`, `propose_recognizers`, `observed_iri_count`, `activated_recognizer_count`). It replaces `std::io::Error` and `ParseError` in corpus code. `Error` is `#[non_exhaustive]`, with `Parse`, `Io`, `Corrupt`, `Unsupported` and, with the `sqlite` feature, `Sqlite`; each storage variant carries the corpus `path`, `Display` names the corpus, and `source()` is the cause. An in-memory corpus never fails a read, but the type can't know that. `Corpus::explain` and `Corpus::params_for` now return `Error::Parse` for input that doesn't parse, instead of an empty list. The pure functions (`parse`, `normalize`, `trace`, `explain`) still fail only with `ParseError`, whose field is now private (use `.message()`) and whose `Display` no longer starts with `iriq: `.
11
+ - **Return `iriq::Result<T>` from `batch` closures.** On SQLite a batch commits on `Ok` and rolls back on `Err` or a panic. A batch inside a batch, including an `observe` inside one, joins the outer transaction: only the outermost commits or rolls back, so an error you swallow inside keeps its writes.
12
+ - **Update changed signatures.** `Corpus::open` and `save` take `impl AsRef<Path>`. `save` exports only JSON: pointed at another path with a SQLite extension, it returns `Error::Unsupported` and writes nothing (it used to write JSON under a `.db` name nothing could reopen). `normalize_identifier(&iri, hints)` and `trace_identifier(&iri, hints)` no longer take a classifier, and `Corpus::normalize_identifier` gains the same `hints` argument (`true` gives the old output). `cross_host_shapes(&corpus, n)` is now `corpus.cross_host_shapes(n)`. `activate_proposals_above` returns only the recognizers it newly activated. `ParamSummary`'s `value_distribution`, `subtype_distribution` and `kind_distribution` are ordered `Vec<(K, f64)>`, and `SegmentPositionStat.values` is a `Vec<(String, usize)>`, most common first (see `cluster -J` below).
13
+ - **`.clone()` a `SegmentType`; it's no longer `Copy`.** Clone one you copied out of a borrow (`row.ty`, `hint.ty`), and match through a reference (`matches!(&t, SegmentType::Custom(c) if …)`). `SegmentType::as_str` and `CustomType::as_str` return a `&str` borrowed from the value; `.to_string()` it to keep it. Why: custom type names were interned and never freed, so every distinct name a process read stayed in memory until exit.
14
+ - **Build custom types with `segment_type_from_name`.** `SegmentType::Custom` wraps a `CustomType` instead of a `&'static str`, so `SegmentType::Custom("integer")`, which silently differed from `SegmentType::Integer`, no longer compiles. `segment_type_from_name("ghp")` returns the built-in variant for a built-in name.
15
+ - **Allow for `#[non_exhaustive]`.** Add a `_ =>` arm when matching `SegmentType`, `FileKind`, `Kind`, `PositionScope`, `Classification` or `HostStrategy`. The structs `Cluster`, `ParamSummary`, `SegmentPositionStat`, `PositionStats`, `Extractor`, `Identifier`, `Position`, `CorpusEntry`, `CrossHostShape`, `RecognizerProposal`, `ProposalOptions`, `SegmentHint`, `TraceResult` and `TraceRow` can't be built from literals: use `Cluster::new`, `PositionStats::new`, `Extractor::new()` or `ProposalOptions::default()`, then assign fields. `FileKind` gains `Unknown`, used only as `kind_distribution`'s bucket for unrecognized extensions.
16
+
17
+ **CLI users.**
18
+
19
+ - **Rebuild a `--host reg` corpus that saw hostnames ending in a dot.** The Rust CLI filed `api.foo.com.` and `api.bar.com.` together under `com.`. `iriq --corpus PATH --host reg --reinfer` splits them.
20
+ - **Run `--reinfer` once if a `.db` may be out of step with its log.** Three ways that happened: the Rust CLI's `--reinfer` (or `--activate-above`, which reinfers) overlapped another process writing the corpus; a long-running writer (`tail -f … | iriq -n`) kept its old recognizers after one was activated elsewhere; or an activation was killed after storing its recognizer but before its reinfer finished. Rerunning `--activate-above` doesn't repair the last one, because it now sees the recognizer as already held. Once writes stop, `iriq --corpus PATH --reinfer`.
21
+ - **Reinfer a Rust-CLI corpus with an activated recognizer** if it saw values that start with the prefix but aren't just letters and digits after it (`ghp_abc-def`, `ghp_a.b`, a bare `ghp_`). The Rust CLI filed them under the recognizer's type.
22
+ - **Reinfer a `.json` corpus the Rust CLI wrote or opened** to get back its params' numeric ranges (`min`, `max`, `avg`).
23
+ - **Expect status 141 from `iriq … | head`.** The Rust CLI now stops when its reader goes away, so a `set -o pipefail` pipeline ending in `| head` fails with 141, as it already did with the Ruby CLI.
24
+ - **Expect corpus-mode `-n` output to change** at slots seen fewer than 5 times (see the evidence rule below). Script against it accordingly.
25
+ - **Clear recognizers activated under a built-in type name.** `--propose-recognizers` could name a proposal after a built-in type (`literal_` proposed `literal`), and activating it turned every matching value into a fixed literal. `literal_` now proposes `literal_id`, but a recognizer already activated under a built-in name stays until `--reset` or a fresh corpus.
26
+
27
+ #### Changed
28
+
29
+ Both runtimes unless noted.
30
+
31
+ - **A positional that names an existing file is read as that file,** unless it contains `://`. `iriq access.log` used to parse as the host `https://access.log/`, and `iriq --stats access.log` reported 0 observations. A path-like argument (`/x`, `./x`, `../x`) that doesn't exist, or any missing file after `cluster`, is now `iriq: no such file: PATH` (exit 1, JSON code `file_not_found`) instead of a parse error or a Ruby backtrace. A bare name that isn't a file (`nope.log`) still parses as a host.
32
+ - **The corpus changes a shape only where it has evidence:** a position or param it has seen at least 5 times. Below that, output is exactly what `-C` gives. One sighting used to be enough to print a raw `v1` / `abc-123` / `usd` instead of `{version}` / `{post_id}` / `USD`, and corpus mode skipped param-name hints (`?phone=unknown` → `{phone}`).
33
+ - **Dates and currencies always print canonically** (`2024-01-15`, `USD`), with or without a corpus; corpus mode no longer renders `{currency}`. Currency upcasing is ASCII-only (Ruby turned `uſd` into `USD`).
34
+ - **`-N` / `--no-hints` works with a corpus.** It was ignored whenever a corpus was in play, which is by default. A slot that only the corpus knows is variable renders `{value}`.
35
+ - **Piped `-n`, `-c`, `-p` and `-e` stream, rendering from the corpus as it stands.** Each extracted URL is observed, then rendered from the corpus at that moment, and output is flushed as it goes, so `tail -f app.log | iriq -n` works with the default corpus. The Rust CLI used to print nothing until stdin closed whenever a corpus was in use; Ruby rendered piped input mechanically, and piped `-e` printed only the `# URL` header. With a warm corpus, the output matches single-input mode; with a cold one, a slot stays literal until it has been seen 5 times. On SQLite each chunk of lines commits before it prints, so a killed `iriq` never printed a line its corpus lost. `--json` (one array) still prints at the end.
36
+ - **A closed stdout stops the Rust CLI** quietly at its next write, with status 141 (the status Ruby's CLI gets from SIGPIPE); the corpus still saves everything read. `iriq -C -n big.log | head -1` used to read the whole file, and `tail -f access.log | iriq -n | head -1` never exited. Any other stdout failure exits 1 with `iriq: stdout: CAUSE` (JSON code `stdout_error`) instead of exiting 0 with the output lost.
37
+ - **`--host` applies with `-C`.** It was ignored there, so `-C --host reg cluster` kept subdomains apart.
38
+ - **Corpus files iriq can't safely use are refused and left untouched**, exiting 1 with `iriq: corpus PATH: REASON` (JSON code `corpus_error`): a `.json` file that isn't valid JSON or has none of a corpus's top-level keys (`{}` is still an empty corpus); a SQLite corpus written by a newer iriq (`schema version N is newer than this iriq supports (4); upgrade iriq`); a `.db` that isn't a database or can't be opened or written. Every corpus failure, read or write, now uses this form.
39
+ - **Unreadable input is a clean error:** invalid UTF-8 is `iriq: stream did not contain valid UTF-8` (JSON code `invalid_utf8`); a file without read permission, or stdin that is a directory, is `read_error`. Both exit 1 and honor `--json`. Ruby printed backtraces.
40
+ - **JSON corpora save through a writer-unique temp file** (`PATH.PID.N.tmp`), so concurrent saves no longer crash with `No such file or directory`. A `.json` corpus is still single-writer, and the last save wins; use `.db` for concurrent writers. `--reset` removes these temp files along with the corpus, its `-wal`/`-shm` sidecars and `PATH.tmp`, and nothing else.
41
+ - **Processes take turns with a SQLite corpus.** A writer waits up to 10 seconds for the write lock, then exits 1 with `iriq: corpus PATH: another process held the corpus lock for over 10s`. Long jobs share it: an ingest (`cluster`, `--stats`, or piped input with no section flag) commits about once a second, and `--reinfer` and `--activate-above` rebuild without the lock and take it only to install the result (about 1.4 s for 1.2 million observations), so a `tail -f app.log | iriq -n` stream keeps going beside them. Two catches: an ingest that's killed or fails keeps what it committed, so running the same input again counts those IRIs twice; and `--reinfer` needs free space in `TMPDIR`, more than the corpus file's own size. Ruby's `--reinfer` used to fail with `corpus PATH: database is locked` while another process was writing. For the Rust CLI's `--reinfer` bug, see Fixed.
42
+ - **`cluster -J` lists keys in one order,** the same from both runtimes and every backend: a segment's `values`, and a param's `value_distribution` and `kind_distribution`, most common first, ties by value; `subtype_distribution` `integer` before `float`. The Rust CLI's order changed from run to run, and Ruby's segment `values` depended on the backend.
43
+ - **Library: `observe_all`** (`Corpus::observe_all` in Rust, `Corpus#observe_all` in Ruby) observes many IRIs, committing about a second at a time on SQLite so other processes get turns. Inside `batch` it joins the batch's transaction.
44
+ - **`--help` says what the CLI does:** a file argument needs no `./`; streaming NDJSON needs a section flag (`tail -f app.log | iriq -nJ`); `--host` keys IRIs as they're observed and on `--reinfer`, and doesn't re-key an existing corpus's report; `--reset` also removes SQLite sidecars and JSON temp files; `cluster` shows every cluster in the corpus; `-e` stays mechanical even with a corpus.
45
+ - **The "created corpus" notice prints only once a corpus has opened.** Ruby printed it before an open that then failed (a `.db` in a read-only directory, say), so the error followed a false announcement.
46
+ - `--propose-recognizers --activate-above F` with nothing to activate says `no proposals at or above confidence F`. It said "coverage", but `F` is a confidence.
47
+ - `--host bogus` reports `invalid argument: --host bogus (expected full|registrable|reg|none)`, without the doubled `--host`.
48
+ - Ruby `--explain`: an already-canonical date or currency shows its value, agreeing with the normalized line, instead of `{date}` / `{currency}`; the JSON omits `host` for an IRI without one instead of emitting `null`. Rust already did both.
49
+ - **Rust crate:** `Corpus` implements `Debug`; `Cluster`, `ParamSummary`, `PositionStats`, `SegmentPositionStat`, `Identifier` and `OrderedMap` implement `PartialEq`. The crate declares `rust-version = "1.85"`. docs.rs builds with all features and labels the SQLite-only error variant. The crate README is the crate docs, so its samples are compiled and run as doctests. The published crate no longer ships integration tests, which could only fail outside the repository. The CLI detects a terminal with `std::io::IsTerminal` instead of an `unsafe`, Unix-only `isatty` call.
50
+
51
+ #### Fixed
52
+
53
+ **Rust**
54
+
55
+ - **Non-ASCII digits crashed the classifier** (exit 101) in a segment or query value that mixed ASCII digits with another script's (`https://x.com/1०००००००`, `?d=2024-٠١-١٥`). "Digit" and "space" now mean ASCII, as in Ruby, which also settles values the runtimes classified differently: `?v=1.٥` was `{float}`, `?v=v𝟎` was `{version}`, `?u=http://x.com/a<NBSP>b` stayed literal, and `registrable_domain("١.٢.٣.٤")` was treated as an IPv4 address.
56
+ - **Hostnames keyed differently from Ruby.** `--host reg` keyed trailing-dot hosts (`api.foo.com.`) as `com.`; they now key as `foo.com`. A host ending a word in capital sigma (`ΑΣ-x.com`) lowercased to the final form `ας-x.com`; it is now `ασ-x.com`.
57
+ - **IP addresses rendered as `{ipv4}` / `{ipv6}` with a corpus**, which is by default. They're `{ip}`, as with `-C` and in Ruby.
58
+ - **SQLite failures were silent.** A read-only, full or otherwise failing `.db` accepted every observation, wrote nothing, and the CLI exited 0, so a cron job could lose data indefinitely. A failed read was just as quiet: `-n` printed a mechanical shape, `--stats` dropped the row, and some reads panicked. Both now exit 1 with `iriq: corpus PATH: CAUSE`.
59
+ - **An observation that failed part-way stayed half-recorded** in a `.db` (a full disk, a lock timeout), leaving its views out of step with the source log until `--reinfer`. Each observation is now one transaction, as in Ruby.
60
+ - **`--reinfer` could corrupt a `.db` other processes were writing.** It read the log, cleared the views and replayed in separate steps, so it either exited 0 with views that no longer matched the log, or died with `UNIQUE constraint failed: cluster_examples…` after the clear had committed. What to do is under What you must do.
61
+ - **An activated recognizer matched too much.** After activating `ghp_`, the Rust CLI rendered `ghp_abc-def`, `ghp_a.b`, `ghp_x_y` and a bare `ghp_` as `{ghp}`. A recognizer now matches only its prefix followed by letters and digits, as the whole segment, as in Ruby. What to do is under What you must do.
62
+ - **`.json` corpora lost their numeric ranges.** The Rust CLI saved a `.json` corpus without the data behind a param's `min`, `max` and `avg`, so Ruby reading one showed no ranges, and a Rust run over a Ruby-written file removed them for good. It now writes them, and an unchanged corpus saves to the same bytes every run.
63
+ - **Exiting waited on other processes.** A run that had already printed its output waited, before exiting, for another process's read snapshot or long write on the same `.db` to clear. It now exits right away.
64
+ - **A `Corpus` on SQLite could wedge for the rest of the process.** A panic inside `batch` left its transaction open, so later "successful" writes rolled back at close; a panic while the connection lock was held poisoned it; iterating the corpus while normalizing against it deadlocked; and a `COMMIT` that SQLite refused but kept open made every later observation fail with `cannot start a transaction within a transaction`. Each now recovers.
65
+ - **`Corpus::save` could overwrite a live SQLite corpus** with a JSON export when given another spelling of its own path (`dir/./c.db`). It now resolves both paths and flushes in place.
66
+ - **`Corpus::open` accepted a JSON corpus in a directory that doesn't exist** and failed only at save, with an error that didn't name the file. It now fails at open, naming the path.
67
+ - **The human cluster view printed large or rounded numbers wrong.** A param past 2^63 (`?v=18446744073709551616`) printed `9223372036854775807..9223372036854775807 avg 9223372036854775807`; it now prints the exact value, as Ruby does. Averages and ranges round like Ruby's elsewhere too: a non-whole value that rounds to a whole number keeps its `.0` (`avg 3.0`), `-0.004` rounds to `-0.0`, `1.005` to `1.01`, and values past 15 digits use `e+15` notation. `--json` is unchanged.
68
+ - **`avg` could differ between runs over the same corpus** in its last digits (`100000000000002.52` vs `…2.53`), because reopening a corpus summed its values in hash-map order. The sum now follows the order the corpus stores them.
69
+ - **`kind_distribution` dropped unrecognized extensions.** A `file` param seen as `b.pdf` ×3 and `c.zzz` reported `{"document":0.75}`; it now reports `{"document":0.75,"unknown":0.25}`, as in Ruby.
70
+
71
+ **Both runtimes**
72
+
73
+ - **A numeric param with a 310+ digit value** overflowed to infinity: Ruby crashed `cluster` (losing the batch before a JSON corpus saved), and Rust printed `null` for `max` and `avg`. Such values still count as observations but stay out of `min`, `max` and `avg`.
74
+ - **An interrupted activation left its recognizer stored but not applied.** Activation stored the recognizer, then reinferred in a separate step; a process killed after the first step left the recognizer stored and the views not, or only partly, rebuilt. Storing and reinferring now commit together or not at all. What to do is under What you must do.
75
+ - **Activating a recognizer the corpus already held wasn't a no-op.** It put a second copy on the live classifier and reran `reinfer`, and Ruby also stored a duplicate in a Memory or JSON corpus. Now it changes nothing, whichever runtime or binary stored the first: an activation is identified by its prefix and type, and the Rust CLI stores a recognizer's specificity as 1.0, as Ruby does. Recognizers older Rust binaries stored at 0.3 classify the same and need nothing.
76
+ - **A running writer ignored recognizers another process activated,** and wrote clusters under the old types beside the new ones. Each write transaction (a streamed chunk, an ingest turn, a lone observation) now picks up activations committed before it began, at the cost of one small read, and only when another process has written since.
77
+
78
+ **Ruby**
79
+
80
+ - **Corpus failures were backtraces:** a `.db` that isn't a database, a read-only `.db`, a stored count hand-edited to a non-integer, an unreadable or directory `.json`, a corpus directory that can't be created, a failed JSON save. They're now `iriq: corpus PATH: REASON`, with the Rust CLI's reasons (`file is not a database`, `Invalid column type Text at index: 1, name: count`, `Permission denied (os error 13)`).
81
+
82
+ #### Performance
83
+
84
+ No action needed, and output is unchanged. Rust timings are single runs over 20,000 URLs, v0.34.0 against now.
85
+
86
+ - **Rust: corpus-informed `-n` no longer slows down as the corpus grows.** Shaping each URL read every value and example the corpus held for its route, so each line cost more than the last; it now reads only the counts classification uses. One route into a fresh corpus: `.db` 22 s → 0.4 s, `.json` 2.2 s → 0.1 s.
87
+ - **Rust, SQLite: observing skips redundant counting.** A known route no longer scans every route to number itself, and a new value in a path slot or query param no longer re-counts the values already there (a batch remembers the count, and forgets it when another process writes). `--stats` over one route: 3.1 s → 0.3 s; over 1,000 hosts: 0.9 s → 0.4 s; with a unique `?q=` on every URL: 2.2 s → 0.2 s. `-n` with a unique `?q=` on every URL is still slow (27 s → 17 s).
88
+ - **SQLite: a big ingest no longer shuts out other writers.** `cluster` and `--stats` held the write lock from their first observation to their last; they now hold it about a second at a time. A one-line `-n` run beside a 1.2-million-line `cluster` waited at most 1.3 s; with v0.34.0 it waited 258 s, for the whole ingest.
89
+ - **Ruby, SQLite: `-n` with a corpus does about 50× less work.** Normalizing read every value tracked at each path position and the whole cluster, observing reloaded the cluster, and each new value re-counted its position. 10,000 URLs into a fresh `.db` went from about 150 s to 3 s of CPU.
90
+
91
+ ### 0.34.0 (2026-08-12)
92
+ - **Rust: SQLite is now an optional (default-on) feature.** `cargo install iriq`, the Homebrew formula, and the CLI are unchanged — `default = ["sqlite"]`. Library consumers who only need parsing, extraction, or normalization can now take `iriq = { version = "0.34", default-features = false }` and skip the bundled C SQLite build entirely (`rusqlite` is the crate's only non-Rust dependency). Without the feature, `open_storage` rejects `.db`/`.sqlite`/`.sqlite3` paths with a clear `Unsupported` error and the CLI's auto-default corpus becomes `default.json` instead of `default.db`; Memory and JSON backends are unaffected. No behavior change with default features, so Ruby parity and the shared schema are untouched. CI now gates the no-default-features build (test + clippy) so the configuration can't rot.
93
+
94
+ ### 0.33.0 (2026-07-07)
95
+ - **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.
96
+ - **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).
97
+ - 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}`.
98
+ - **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.
99
+ - **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.
100
+ - 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.
101
+ - **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.
102
+ - **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).
103
+ - **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).
104
+
105
+ ### 0.32.1 (2026-06-26)
106
+ - 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.
107
+ - 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.
108
+
109
+ ### 0.32.0 (2026-06-26)
110
+ - **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.
111
+ - **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.
112
+ - **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.
113
+ - Ruby, Go, and Rust ship this together — same thresholds, same rendering, parity preserved (68/68 Ruby↔Go, 60/60 Rust↔Go).
114
+
115
+ ### 0.31.1 (2026-06-26)
116
+ - 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.
117
+
118
+ ### 0.31.0 (2026-06-26)
119
+ - **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.
120
+ - 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.
121
+ - New env var `IRIQ_CORPUS=PATH` — overrides the default location without needing `--corpus` on every call.
122
+ - 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.
123
+ - **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`.
124
+ - Ruby, Go, and Rust ship this together — same defaults, same flags, parity preserved.
125
+
1
126
  ### 0.30.2 (2026-06-23)
2
127
  - 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
128
 
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
- Iriq
2
- ======
1
+ # Iriq — IRI Query
2
+
3
3
  [![codecov](https://codecov.io/gh/dpep/iriq/branch/main/graph/badge.svg)](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
- (An **IRI** is just a URL the internationalized superset of URI/URL that also
13
- allows non-ASCII characters. If you know URLs, you know IRIs. The name is *IRI
14
- Query*: iriq queries an IRI for its structure.)
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. Point a *corpus* at a stream and
21
- classifications improve as data flows in high-churn slots get promoted to
22
- placeholders, and whole types emerge that you can't see in any single URL (a
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
 
@@ -61,29 +62,93 @@ $ iriq -n https://shop.com/pricing/usd?currency=eur
61
62
  https://shop.com/pricing/USD?currency=EUR # currency upcased
62
63
  ```
63
64
 
65
+ Pipe in text, or name a file, and iriq extracts every URL in it:
66
+
67
+ ```sh
68
+ $ cat urls.log | iriq # ≥ 10 IRIs → cluster view
69
+ [6] api.example.com /api/{version}/users/{user_id}
70
+ https://api.example.com/api/v1/users/123
71
+ https://api.example.com/api/v1/users/456
72
+ https://api.example.com/api/v1/users/789
73
+ + 3 more
74
+
75
+ [3] api.example.com /orders/{order_uuid}
76
+ https://api.example.com/orders/5f0c6a52-8b2e-4c1a-9f3d-2e7b1c9a0d11?status=open
77
+ https://api.example.com/orders/0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d?status=closed
78
+ https://api.example.com/orders/7c9e6679-7425-40de-944b-e07fc1f90ae7?status=open
79
+ status string conf 0.17 (2 distinct, 100%)
80
+
81
+ [3] api.example.com /products/{product_id}
82
+ https://api.example.com/products/blue-widget
83
+ https://api.example.com/products/red-gadget
84
+ https://api.example.com/products/green-gizmo
85
+
86
+ $ cat urls.log | iriq --stats # rolling aggregates
87
+ $ iriq urls.log -n # a file argument → normalize each URL
88
+ $ iriq -nJ < urls.log # one JSON line per URL
89
+ $ iriq --corpus team.db < urls.log # use a specific corpus file
90
+ ```
91
+
92
+ Reading a web server's access log? Its request lines have no host, so see
93
+ [Access logs](#access-logs) first.
94
+
95
+ Per-IRI sections (`-n`, `-c`, `-p`, `-e`) stream: each line is read, observed,
96
+ rendered from the corpus as it stands, and flushed, so iriq works on an
97
+ unbounded live feed:
98
+
64
99
  ```sh
65
- $ cat access.log | iriq # 10 IRIs cluster view
66
- [190] docs.example.com /users/{user_id}
67
- [186] app.example.com /users/{user_id}
68
- ...
69
-
70
- $ cat access.log | iriq --stats # rolling aggregates
71
- $ iriq ./access.log -n # auto-detect file → normalize each
72
- $ iriq -J < access.log # newline-delimited JSON
73
- $ iriq --corpus c.db < access.log # persist into a SQLite corpus
100
+ $ tail -f app.log | iriq -n # one shape per line, as logs land
101
+ $ tail -f app.log | iriq -nJ # same, as newline-delimited JSON
74
102
  ```
75
103
 
76
- Once a corpus has data, `-n` becomes corpus-informed a position that only ever
77
- holds integers clusters to a single `{user_id}` shape, and new values normalize
78
- to it:
104
+ `-J` on its own doesn't stream. Like the default view, it waits for the end of
105
+ input, then prints the URL list (fewer than 10 IRIs) or one object per cluster.
106
+
107
+ **Every invocation observes into a persistent corpus by default**, so iriq gets
108
+ smarter the more you run it. The corpus-only types (e.g. `enum` / `http_status`)
109
+ emerge from the *distribution* of values observed.
79
110
 
80
111
  ```sh
81
- $ for n in 1 2 3 4 5 6 7 8 9 10; do
82
- iriq --corpus c.db https://api.foo.com/users/$n >/dev/null
112
+ # Feed a stream where ?status only ever holds a couple of words:
113
+ $ for n in $(seq 1 20); do
114
+ iriq --corpus demo.db "https://api.foo.com/orders/$n?status=open" >/dev/null
115
+ iriq --corpus demo.db "https://api.foo.com/orders/$n?status=closed" >/dev/null
83
116
  done
84
117
 
85
- $ iriq -n --corpus c.db https://api.foo.com/users/999
86
- https://api.foo.com/users/{user_id}
118
+ # Ask what it learned. ?status is now an enum — a verdict no single URL
119
+ # could support, since one URL shows only one value:
120
+ $ iriq --corpus demo.db cluster
121
+ [40] api.foo.com /orders/{order_id}
122
+ https://api.foo.com/orders/1?status=open
123
+ https://api.foo.com/orders/1?status=closed
124
+ https://api.foo.com/orders/2?status=open
125
+ + 37 more
126
+ status enum conf 0.73 (2 distinct, 100%)
127
+ ```
128
+
129
+ `conf` is how much evidence backs the type, from 0 to 1. These learned types
130
+ also flow into normalized output:
131
+
132
+ ```sh
133
+ $ iriq --corpus demo.db -n 'https://api.foo.com/orders/99?status=open'
134
+ https://api.foo.com/orders/{order_id}?status={enum}
135
+ ```
136
+
137
+ The corpus only acts on evidence: it changes a shape only at a position or
138
+ param it has seen at least 5 times. Until then, `-n` prints exactly what `-C`
139
+ would. Dates and currencies always print canonically (`2024-01-15`, `USD`).
140
+
141
+ The default corpus lives at `$XDG_DATA_HOME/iriq/default.db` (Linux),
142
+ `~/Library/Application Support/iriq/default.db` (macOS), or
143
+ `%LOCALAPPDATA%/iriq/default.db` (Windows). First-run creation prints a
144
+ one-line stderr notice. Three knobs control it:
145
+
146
+ ```sh
147
+ $ iriq --no-corpus -n https://foo.com/users/123 # one-shot ephemeral; or -C
148
+ $ IRIQ_NO_CORPUS=1 iriq -n https://foo.com/users/123 # globally disable
149
+ $ IRIQ_CORPUS=/path/to/work.db iriq -n https://foo.com/users/123 # override path
150
+ $ iriq --corpus team.db https://foo.com/users/123 # explicit override (wins over env)
151
+ $ iriq --reset # delete the corpus and exit
87
152
  ```
88
153
 
89
154
  ### Two ways to normalize
@@ -95,8 +160,9 @@ Pick by the question you're asking:
95
160
  lowercased, default port dropped; path and query left alone). Handy, but
96
161
  table stakes — plenty of libraries do it.
97
162
  - **`--normalize`** *(the default)* — find the URL's *shape*, erasing the
98
- specifics into placeholders. `…/pull/42` → `…/pull/{id}`. This is the part
99
- you came to iriq for.
163
+ specifics into placeholders. `…/pull/42` → `…/pull/{pull_id}`. A shape
164
+ ignores the `#fragment`, so `-n` drops it. This is the part you came to iriq
165
+ for.
100
166
 
101
167
  Same input, two questions: "what's the clean form of *this* URL?" vs "what
102
168
  *kind* of URL is this?" The second is iriq's reason to exist.
@@ -107,41 +173,13 @@ Same input, two questions: "what's the clean form of *this* URL?" vs "what
107
173
  # Homebrew (recommended)
108
174
  brew install dpep/tools/iriq
109
175
 
110
- # Cargo, from crates.io
176
+ # Cargo, from crates.io (Rust 1.85 or newer)
111
177
  cargo install iriq
112
-
113
- # Cargo, from a source checkout
114
- cargo install --path rust/iriq
115
178
  ```
116
179
 
117
180
  One crate ships both the library and the `iriq` binary. Corpora persist to
118
181
  SQLite (bundled, WAL) out of the box — nothing to flag, install, or rebuild.
119
182
 
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
183
  ## Segment classification
146
184
 
147
185
  Iriq classifies each path/query segment into one of ~25 types — the first
@@ -180,12 +218,12 @@ what produces `{user_id}` from `/users/123` and `{order_id}` from `/orders/456`.
180
218
  Semantic types (`version`, `locale`, `currency`, `date`, `boolean`) skip the
181
219
  hint and surface as `{type}` — `/api/v1/status` renders as `/api/{version}/status`,
182
220
  not the misleading `/api/{api_id}/status`. Pass `-N` / `--no-hints` for
183
- mechanical placeholders (`{integer}` instead of `{user_id}`).
221
+ mechanical placeholders (`{integer}` instead of `{user_id}`); a slot only the
222
+ corpus knows is variable renders `{value}`.
184
223
 
185
224
  ### Types only the corpus can see
186
225
 
187
- Four types never come from a single URL they emerge from the *distribution*
188
- of values a position has held across many observations:
226
+ Four types emerge from the *distribution* of values across many observations:
189
227
 
190
228
  | Type | Emerges when a position… |
191
229
  | --- | --- |
@@ -195,18 +233,60 @@ of values a position has held across many observations:
195
233
  | `enum` | holds a small, bounded set of distinct values |
196
234
 
197
235
  Mechanically, `200` is just an integer. Across ten thousand URLs where that
198
- slot is always 100–599, it's an HTTP status. That's the corpus earning its keep.
236
+ slot is always 100–599, it's likely an HTTP status.
199
237
 
200
238
  ## Corpus (streaming + learning)
201
239
 
202
- For processing many identifiers possibly an unbounded stream — point iriq at a
203
- corpus. It maintains rolling aggregates and per-(host, prefix) frequency stats,
204
- so classification improves as more data comes in.
240
+ The corpus maintains rolling aggregates and per-(host, prefix) frequency stats,
241
+ so classification improves as more data comes in handy for an unbounded stream
242
+ of identifiers. The default corpus already persists; `--corpus PATH` points iriq
243
+ at a specific file instead, to keep separate corpora or share one across runs.
244
+
245
+ The extension picks the backend, and the two behave differently:
246
+
247
+ - **`.db` / `.sqlite` / `.sqlite3` (SQLite)** — the default, and the one to
248
+ share. Many `iriq` processes can write at once by taking turns: a writer
249
+ waits up to 10 seconds for its turn. A big `cluster` commits about a second
250
+ at a time, and `--reinfer` rebuilds on the side and holds the corpus only to
251
+ swap the result in, so a `tail -f` stream keeps flowing beside either. Use
252
+ SQLite for streams and concurrent writers.
253
+ - **Anything else (JSON)** — read when iriq starts and written once, when it
254
+ exits cleanly. It's single-writer: when two processes use one file, the last
255
+ to exit wins. A streaming run that's killed, Ctrl-C included, saves nothing.
256
+
257
+ A few things to know:
258
+
259
+ - The cluster view (`iriq cluster`, or 10+ piped IRIs) shows the whole corpus,
260
+ not just this input. Add `-C` to cluster one input on its own.
261
+ - iriq keeps every IRI it observes, repeats included, so `--reinfer` can replay
262
+ them. That log grows without bound.
263
+ - On SQLite, `cluster` and `--stats` commit their input about a second at a
264
+ time. If one is killed part-way, what it committed stays: feed it the same
265
+ input again and those IRIs count twice.
266
+ - `--reinfer` (and `--activate-above`) rebuilds in temporary tables before
267
+ swapping the result in, so it needs free space in `TMPDIR`: plan on more than
268
+ the corpus file's own size.
269
+ - `--reset` deletes the corpus file, its SQLite `-wal` / `-shm` sidecars, and any
270
+ temp files a JSON save left behind. Don't reset a corpus another process is
271
+ writing: that process carries on, exits 0, and its writes are lost with the
272
+ deleted file.
273
+ - iriq refuses a corpus file it can't safely use rather than overwrite it — a
274
+ JSON file that isn't an iriq corpus, or a SQLite corpus written by a newer
275
+ iriq (upgrade to open it).
276
+
277
+ ### Host keying
278
+
279
+ By default every hostname gets its own clusters. `--host reg` keys by
280
+ registrable domain, so `api.foo.com` and `www.foo.com` both cluster under
281
+ `foo.com`; `--host none` ignores the host. The mode applies when observations
282
+ are recorded, when you `--reinfer`, and to a `-C` run's throwaway corpus. It
283
+ doesn't re-key a report of an existing corpus: `iriq --host reg cluster` shows
284
+ the clusters as they were recorded. To re-key a corpus, reinfer it:
205
285
 
206
- `--corpus PATH` makes the corpus survive across invocations. A `.db` /
207
- `.sqlite` / `.sqlite3` path is stored in SQLite (WAL journaling, incremental
208
- UPSERTs multiple `iriq --corpus` processes can write concurrently); a
209
- `.json` path writes a plain JSON file instead.
286
+ ```sh
287
+ $ iriq --corpus c.db --host reg --reinfer
288
+ reinferred 3 observations: 3 1 cluster
289
+ ```
210
290
 
211
291
  ### Re-runnable inference
212
292
 
@@ -225,9 +305,7 @@ $ iriq --corpus c.db --reinfer
225
305
  Iriq doesn't just classify against a fixed list — it watches the stream and
226
306
  *proposes new recognizers* for patterns it keeps seeing. Notice `ghp_…` or
227
307
  `cus_…` recurring at a slug position and iriq will suggest a recognizer for it,
228
- with evidence: coverage, host count, confidence. Proposals are never
229
- auto-applied — you activate the ones you trust, and they persist with the
230
- corpus. Human-in-the-loop by design.
308
+ with evidence: coverage, host count, confidence.
231
309
 
232
310
  ```sh
233
311
  # Print proposals (human-readable, or --json)
@@ -268,6 +346,42 @@ Known limitations (intentional):
268
346
 
269
347
  Disable scheme-less extraction with `--no-scheme-less`.
270
348
 
349
+ ### Access logs
350
+
351
+ Extraction needs URLs with a host. A web server's request line
352
+ (`"GET /api/v1/users/123 HTTP/1.1"`) has none, so on a raw access log iriq
353
+ finds only the full URLs on each line, usually the Referer:
354
+
355
+ ```sh
356
+ $ cat access.log | iriq
357
+ [12] example.com /referrer
358
+ https://example.com/referrer
359
+ + 11 more
360
+ ```
361
+
362
+ Pull out the path and give it a host first. In the common and combined log
363
+ formats, the path is the seventh field:
364
+
365
+ ```sh
366
+ $ awk '{print "https://api.example.com" $7}' access.log | iriq
367
+ [6] api.example.com /api/{version}/users/{user_id}
368
+ https://api.example.com/api/v1/users/123
369
+ https://api.example.com/api/v1/users/456
370
+ https://api.example.com/api/v1/users/789
371
+ + 3 more
372
+
373
+ [3] api.example.com /orders/{order_uuid}
374
+ https://api.example.com/orders/5f0c6a52-8b2e-4c1a-9f3d-2e7b1c9a0d11?status=open
375
+ https://api.example.com/orders/0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d?status=closed
376
+ https://api.example.com/orders/7c9e6679-7425-40de-944b-e07fc1f90ae7?status=open
377
+ status string conf 0.17 (2 distinct, 100%)
378
+
379
+ [3] api.example.com /products/{product_id}
380
+ https://api.example.com/products/blue-widget
381
+ https://api.example.com/products/red-gadget
382
+ https://api.example.com/products/green-gizmo
383
+ ```
384
+
271
385
  ## How it works
272
386
 
273
387
  Under the shape sits one idea: **Position + Evidence**. A *Position* is a slot
@@ -282,21 +396,26 @@ underneath. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full model.
282
396
  **Single input** — combined parse + normalize summary; trim with section flags
283
397
  (`-p`, `-n`).
284
398
 
285
- **Piped stdin** — extraction runs by default. Output auto-switches: small inputs
286
- get a deduplicated URL list, larger inputs (≥ 10 IRIs) get the cluster view via
287
- an ephemeral corpus.
399
+ **Piped stdin, or a file argument** — extraction runs by default. With no
400
+ section flag, iriq reads all of the input, then prints a deduplicated URL list
401
+ (fewer than 10 IRIs) or the cluster view of the corpus (10 or more). With a
402
+ section flag, it prints each IRI's result as the line arrives. `-n` is
403
+ corpus-informed; `-e` is mechanical even with a corpus.
288
404
 
289
405
  | Flag | Effect |
290
406
  | ------------------- | ------------------------------------------------------- |
291
407
  | `-p, --parse` | Show parsed fields |
292
408
  | `-n, --normalize` | Show the shape-normalized form |
293
409
  | `-c, --canonical` | Show the canonical form (no shape normalization) |
410
+ | `-e, --explain` | Annotated trace — per-segment notes about why each placeholder / canonical value was chosen. Mechanical, even with a corpus |
294
411
  | `-j, --json` | Emit JSON |
295
- | `-J, --ndjson` | Newline-delimited JSON (one object per line); implies `--json` |
412
+ | `-J, --ndjson` | Newline-delimited JSON; implies `--json`. With a section flag (`-nJ`), one line per IRI as it arrives; alone, the URL list or clusters at end of input |
296
413
  | `-N, --no-hints` | Use `{integer}` etc. instead of `{user_id}` |
297
414
  | `--no-scheme-less` | Skip `foo.com/path`-style extraction (explicit-scheme only) |
298
- | `--corpus PATH` | Load/create a corpus at PATH (`.json` or `.db`/`.sqlite`/`.sqlite3`) |
299
- | `--host MODE` | Host-keying for clustering: `full` (default), `reg` strips subdomains, `none` ignores host |
415
+ | `--corpus PATH` | Use a specific corpus file (`.json` or `.db`/`.sqlite`/`.sqlite3`). Overrides the default |
416
+ | `-C, --no-corpus` | Disable corpus persistence for this invocation (same as `IRIQ_NO_CORPUS=1`) |
417
+ | `--reset` | Delete the corpus file, its SQLite sidecars and JSON temp files, and exit |
418
+ | `--host MODE` | Host keying: `full` (default), `reg` strips subdomains, `none` ignores host. Applies when observing, to `--reinfer`, and with `-C` |
300
419
  | `--stats` | Print rolling aggregates |
301
420
  | `--reinfer` | Drop the materialized views and replay the source-IRI log through the current classifier + reducers |
302
421
  | `--propose-recognizers` | Scan observed values for shape patterns that recur enough to suggest a new recognizer. Combine with `--json` for structured output |
@@ -305,15 +424,70 @@ an ephemeral corpus.
305
424
  | `--min-coverage F` | Proposal threshold; default 0.7 |
306
425
  | `--min-hosts N` | Threshold for both proposals and cross-host shapes; default 1 / 2 respectively |
307
426
  | `--activate-above F` | With `--propose-recognizers`, auto-activate every proposal whose confidence is ≥ F |
427
+ | `cluster [file]` | Force the cluster view |
308
428
  | `completion bash\|zsh` | Print shell completion script (Homebrew installs this automatically) |
309
429
  | `-V, --version` | Print version |
310
430
 
311
- A positional argument that doesn't parse as an IRI but IS an existing file is
312
- read and extracted from automatically — `iriq ./access.log` and
313
- `iriq /var/log/foo.log` Just Work. (Bare filenames like `README.md` may still
314
- parse as a URL; pipe with `cat` to disambiguate.)
431
+ Environment variables:
432
+
433
+ | Variable | Effect |
434
+ | -------------------- | ------------------------------------------------------- |
435
+ | `IRIQ_CORPUS=PATH` | Set the corpus path (overrides the default) |
436
+ | `IRIQ_NO_CORPUS=1` | Disable the default corpus (equivalent to `-C`) |
437
+
438
+ A positional argument that names an existing file is read as a file, unless it
439
+ contains `://` — `iriq access.log` and `iriq /var/log/foo.log` both work. A
440
+ path-like argument (`/x`, `./x`, `../x`) that doesn't exist is an error; a bare
441
+ name that isn't a file, like `foo.log`, parses as a host (`https://foo.log/`).
315
442
 
316
- Exit codes: `0` success, `1` usage error, `2` parse error.
443
+ Errors go to stderr as `iriq: MESSAGE`, or, with `--json` / `-J`, as
444
+ `{"error":{"code":"…","message":"…"}}`. A corpus error names the file:
445
+ `iriq: corpus team.db: attempt to write a readonly database`.
446
+
447
+ | Exit | Meaning | JSON codes |
448
+ | ----- | ------- | ---------- |
449
+ | `0` | Success | |
450
+ | `1` | Bad option or argument, missing or unreadable input, unusable corpus, or stdout failed | `option_error`, `unknown_shell`, `file_not_found`, `read_error`, `invalid_utf8`, `corpus_error`, `stdout_error` |
451
+ | `2` | The input isn't a parseable IRI | `parse_error` |
452
+ | `141` | The reader went away (`iriq … \| head`); iriq stops quietly | |
453
+
454
+ ## Rust library
455
+
456
+ ```sh
457
+ cargo add iriq
458
+ ```
459
+
460
+ ```rust
461
+ use iriq::{normalize, parse, Corpus};
462
+
463
+ fn main() -> iriq::Result<()> {
464
+ let iri = parse("https://foo.com/users/123")?;
465
+ println!("{} {:?}", iri.host, iri.path_segments); // foo.com ["users", "123"]
466
+ println!("{}", normalize("https://foo.com/users/123")?); // https://foo.com/users/{user_id}
467
+
468
+ // A persistent corpus: SQLite for .db, JSON otherwise.
469
+ let mut corpus = Corpus::open("c.db")?;
470
+ for n in 1..=3 {
471
+ corpus.observe(&format!("https://foo.com/users/{n}"))?;
472
+ }
473
+ for cluster in corpus.clusters()? {
474
+ println!("[{}] {} {}", cluster.count, cluster.host, cluster.shape); // [3] foo.com /users/{user_id}
475
+ }
476
+ corpus.save("c.db")?; // flushes in place; a .json corpus is written only here
477
+ Ok(())
478
+ }
479
+ ```
480
+
481
+ Every `Corpus` operation returns `iriq::Result`, whose `iriq::Error` names the
482
+ corpus that failed. SQLite comes from the default-on `sqlite` feature;
483
+ `cargo add iriq --no-default-features` drops it and keeps in-memory and JSON
484
+ corpora. Requires Rust 1.85 or newer. A long-lived `Corpus` sees recognizers
485
+ another process activated at its next `batch` or `observe`, not in reads
486
+ outside one.
487
+
488
+ The [crate README](rust/iriq/README.md) is the library tour: reading clusters
489
+ and params, batches, sharing a corpus, and errors. Full API on
490
+ [docs.rs/iriq](https://docs.rs/iriq).
317
491
 
318
492
  ## Limitations (intentional)
319
493
 
@@ -330,15 +504,3 @@ Iriq does **not**:
330
504
 
331
505
  Iriq's focus is the analysis side: classification, normalization, and clustering
332
506
  — 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