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 +4 -4
- data/CHANGELOG.md +125 -0
- data/README.md +254 -92
- data/completions/_iriq +2 -0
- data/completions/iriq.bash +1 -1
- data/iriq.gemspec +1 -1
- data/lib/iriq/cli.rb +241 -70
- data/lib/iriq/cluster.rb +76 -24
- data/lib/iriq/clusterer.rb +0 -11
- data/lib/iriq/corpus.rb +236 -119
- data/lib/iriq/errors.rb +9 -0
- data/lib/iriq/identifier.rb +1 -1
- data/lib/iriq/normalizer.rb +21 -18
- data/lib/iriq/observation.rb +11 -6
- data/lib/iriq/parser.rb +5 -1
- data/lib/iriq/position_evidence.rb +31 -0
- data/lib/iriq/position_stats.rb +6 -4
- data/lib/iriq/recognizer.rb +1 -1
- data/lib/iriq/recognizer_proposal.rb +15 -3
- data/lib/iriq/reducer.rb +1 -2
- data/lib/iriq/segment_classifier.rb +17 -6
- data/lib/iriq/specificity.rb +3 -3
- data/lib/iriq/storage/json.rb +21 -7
- data/lib/iriq/storage/memory.rb +52 -10
- data/lib/iriq/storage/sqlite.rb +351 -44
- data/lib/iriq/storage.rb +18 -0
- data/lib/iriq/trace.rb +29 -33
- data/lib/iriq/version.rb +1 -1
- data/lib/iriq.rb +1 -0
- metadata +3 -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: aad1274de2d7cae934f5b940e97f498193883addffcab77e8420f47425b155fb
|
|
4
|
+
data.tar.gz: f6b0f21f92304b161e43c5ca367a0ac7f24ae087a987634d902cddd9807fad74
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](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
|
|
|
@@ -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
|
-
$
|
|
66
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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/{
|
|
99
|
-
you came to iriq
|
|
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
|
|
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.
|
|
236
|
+
slot is always 100–599, it's likely an HTTP status.
|
|
199
237
|
|
|
200
238
|
## Corpus (streaming + learning)
|
|
201
239
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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.
|
|
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.
|
|
286
|
-
|
|
287
|
-
|
|
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
|
|
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` |
|
|
299
|
-
|
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|