Project

iriq

0.0
The project is in a healthy, maintained state
IRI extraction, normalization, and clustering.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

>= 1
>= 3.10
>= 0.22
>= 1.6
 Project Readme

Iriq — IRI Query

codecov

Iriq finds the shape of a URL — the structural template you get when you erase the parts that vary and keep the parts that don't. …/users/123 and …/users/999 are the same shape: /users/{user_id}. Feed iriq a pile of messy URLs — a log file, a column of links, free-text prose — and it collapses them into a small set of stable, deterministic route templates. Fifty thousand distinct URLs become twelve shapes.

What's an IRI? Internationalized Resource Identifiers cover everyday URLs https://…, plus URNs like urn:isbn:0451450523, other schemes like mailto:, and internationalized addresses with non-ASCII characters like https://例え.jp/パス. Formally it's the Unicode superset of URI/URL. The name is IRI Query: iriq queries an IRI for its structure.

Everything iriq does — parsing, normalizing, classifying path and query components, clustering, learning new patterns — exists to derive, render, or group by that shape.

And it gets sharper the more you feed it. A corpus — on by default — records what it sees and improves classifications as data flows in: high-churn slots get promoted to placeholders, and whole types emerge that no single URL can reveal (a position that's always 100–599 is an HTTP status; one bounded to a dozen values is an enum).

$ iriq -n https://foo.com/users/123
https://foo.com/users/{user_id}

It answers questions like:

  • "What routes does this service actually expose?" (cluster a log file)
  • "Which params are stable identifiers vs. churning IDs vs. enums?" (--stats)
  • "Are these 50,000 distinct URLs really just 12 templates?" (clustering)
  • "What does /api/v1/users/abc-123-def become as a route shape?" (/api/{version}/users/{user_id})

Iriq ships as a command-line tool (iriq) and a Rust library.

Quick start

$ iriq https://foo.com/users/123
# parse
original:      https://foo.com/users/123
kind:          url
scheme:        https
host:          foo.com
path_segments: ["users", "123"]
canonical:     https://foo.com/users/123

# normalize
https://foo.com/users/{user_id}

$ iriq -n https://foo.com/users/123
https://foo.com/users/{user_id}

$ iriq -n https://shop.com/pricing/usd?currency=eur
https://shop.com/pricing/USD?currency=EUR     # currency upcased
$ cat access.log | iriq                       # ≥ 10 IRIs → cluster view
[190] docs.example.com  /users/{user_id}
[186] app.example.com   /users/{user_id}
...

$ cat access.log | iriq --stats               # rolling aggregates
$ iriq ./access.log -n                        # auto-detect file → normalize each
$ iriq -J < access.log                        # newline-delimited JSON
$ iriq --corpus team.db < access.log          # use a specific corpus file

Per-IRI output (-n, -p, -c, -J) streams — each line is read, classified, and flushed as it arrives, so iriq works on an unbounded live feed:

$ tail -f access.log | iriq -n                # one shape per line, as logs land
$ tail -f access.log | iriq -J                # same, as newline-delimited JSON

Every invocation observes into a persistent corpus by default, so iriq gets smarter the more you run it. The corpus-only types (e.g. enum / http_status) emerge from the distribution of values observed.

# Feed a stream where ?status only ever holds a couple of words:
$ for n in $(seq 1 20); do
    iriq --corpus demo.db "https://api.foo.com/orders/$n?status=open"   >/dev/null
    iriq --corpus demo.db "https://api.foo.com/orders/$n?status=closed" >/dev/null
  done

# Ask what it learned. ?status is now an enum — a verdict no single URL
# could support, since one URL shows only one value:
$ iriq --corpus demo.db cluster
[40] api.foo.com  /orders/{order_id}
    https://api.foo.com/orders/1?status=open
    https://api.foo.com/orders/1?status=closed
    https://api.foo.com/orders/2?status=open
    + 37 more
    status  enum  (2 distinct, 100%)

These learned types also flow into normalized output: once the corpus has pegged ?status as an enum, iriq -n …?status=open renders ?status={enum}.

The default corpus lives at $XDG_DATA_HOME/iriq/default.db (Linux), ~/Library/Application Support/iriq/default.db (macOS), or %LOCALAPPDATA%/iriq/default.db (Windows). First-run creation prints a one-line stderr notice. Three knobs control it:

$ iriq --no-corpus -n https://foo.com/users/123    # one-shot ephemeral; or -C
$ IRIQ_NO_CORPUS=1 iriq -n https://foo.com/users/123  # globally disable
$ IRIQ_CORPUS=/path/to/work.db iriq -n https://foo.com/users/123  # override path
$ iriq --corpus team.db https://foo.com/users/123  # explicit override (wins over env)
$ iriq --reset                                     # delete the corpus DB and exit

Two ways to normalize

Pick by the question you're asking:

  • --canonical — clean up this URL, keeping the specifics. HTTP://Foo.com:80/pull/42http://foo.com/pull/42 (scheme/host lowercased, default port dropped; path and query left alone). Handy, but table stakes — plenty of libraries do it.
  • --normalize (the default) — find the URL's shape, erasing the specifics into placeholders. …/pull/42…/pull/{id}. This is the part you came to iriq for.

Same input, two questions: "what's the clean form of this URL?" vs "what kind of URL is this?" The second is iriq's reason to exist.

Install

# Homebrew (recommended)
brew install dpep/tools/iriq

# Cargo, from crates.io
cargo install iriq

One crate ships both the library and the iriq binary. Corpora persist to SQLite (bundled, WAL) out of the box — nothing to flag, install, or rebuild.

Segment classification

Iriq classifies each path/query segment into one of ~25 types — the first matching rule wins, and heuristics are deterministic:

  • literal — plain word (users, orders, Profile, こんにちは)
  • integer — pure digits below the timestamp range
  • float — decimal with digits on both sides (3.14, -2.5, 1.0)
  • booleantrue / false (any case)
  • version — semver-ish with v prefix (v1, v2.0.1, v1.2.3-beta)
  • locale — BCP 47-ish (en-US, fr_CA, zh-Hant, bare en/fr/ja)
  • currency — ISO 4217 codes (USD, EUR, JPY)
  • uuidf47ac10b-58cc-4372-a567-0e02b2c3d479
  • date2024-05-23, 2024/05/23, 20240523, 05/23/2024. Canonicalized to ISO in --normalize output.
  • timestamp — ISO 8601, or 10/13-digit UNIX epoch
  • hash — 32+ hex chars (md5 / sha)
  • slugmy-cool-post, my_cool_post
  • ipv4 / ipv6 — collapsed to {ip} in normalized output
  • urlhttps://..., ftp://..., also scheme-less foo.com/path
  • emaillocal@host.tld
  • phone — E.164 (+15551234567) or NANP (555-666-7777, (555) 666-7777)
  • jwt — three base64url segments separated by dots
  • mimeimage/png, application/vnd.api+json
  • filename.ext for known extensions; per-kind grouping (image/document/data/...)
  • color — hex form (#fff, #ffffff, #ffffff80)
  • coordinatelat,lng pair with plausible-range validation
  • country — ISO 3166-1 alpha-2 codes (US, JP, GB)
  • base64 — standard base64 blobs with disambiguating +///=
  • opaque_id — short alphanumeric mix that doesn't fit elsewhere

RESTful hints

When a variable segment follows a literal one, iriq derives a hint by singularizing the literal and suffixing _id (or _uuid for UUIDs). That's what produces {user_id} from /users/123 and {order_id} from /orders/456. Semantic types (version, locale, currency, date, boolean) skip the hint and surface as {type}/api/v1/status renders as /api/{version}/status, not the misleading /api/{api_id}/status. Pass -N / --no-hints for mechanical placeholders ({integer} instead of {user_id}).

Types only the corpus can see

Four types emerge from the distribution of values across many observations:

Type Emerges when a position…
number holds both integers and floats
year holds integers that all land in 1900–2100
http_status holds integers that all land in 100–599
enum holds a small, bounded set of distinct values

Mechanically, 200 is just an integer. Across ten thousand URLs where that slot is always 100–599, it's likely an HTTP status.

Corpus (streaming + learning)

The corpus maintains rolling aggregates and per-(host, prefix) frequency stats, so classification improves as more data comes in — handy for an unbounded stream of identifiers. The default corpus already persists; --corpus PATH points iriq at a specific file instead, to keep separate corpora or share one across runs.

A .db / .sqlite / .sqlite3 path is stored in SQLite (WAL journaling, incremental UPSERTs — multiple iriq --corpus processes can write concurrently); a .json path writes a plain JSON file instead.

Re-runnable inference

A corpus persists the source-IRI log alongside the materialized views. --reinfer drops every view and replays the log through the current classifier and reducers. Tune a threshold, swap in a different classifier, or activate new recognizers (below) — then reinfer to see the new results without re-feeding URLs.

$ iriq --corpus c.db --reinfer

Learning new types

Iriq doesn't just classify against a fixed list — it watches the stream and proposes new recognizers for patterns it keeps seeing. Notice ghp_… or cus_… recurring at a slug position and iriq will suggest a recognizer for it, with evidence: coverage, host count, confidence.

# Print proposals (human-readable, or --json)
$ iriq --corpus c.db --propose-recognizers

# Auto-activate every proposal with confidence ≥ 0.9, then reinfer
$ iriq --corpus c.db --propose-recognizers --activate-above 0.9

Cross-host shape learning

A route shape that recurs across multiple hosts is independent evidence of a semantic pattern — two unrelated hosts inventing the same /users/{integer} structure by accident is unlikely.

$ iriq --corpus c.db --cross-host-shapes [--min-hosts N]

The same signal feeds back into proposal confidence: each additional host beyond the first adds 0.05 to the score (capped at 1.0), so a prefix proposed on 5 hosts is meaningfully stronger than the same coverage seen on 1 host.

Extracting IRIs from text

Pipe-mode extraction picks up explicit-scheme URLs (http, https, ftp, ws, wss, urn) and foo.com/path-style scheme-less URLs (small TLD allow-list, required path). It trims trailing sentence punctuation and preserves balanced parens (https://en.wikipedia.org/wiki/Ruby_(programming_language) stays intact; (see https://foo.com) drops the outer paren).

Known limitations (intentional):

  • Comma is a URL boundary, so query strings like ?q=37.7,-122.4 truncate. Trade-off picked to keep CSV-shaped text working.
  • No HTML entity decoding (&amp; stays as-is).
  • Scheme-less mode skips bare hostnames without a path (too noisy in prose).

Disable scheme-less extraction with --no-scheme-less.

How it works

Under the shape sits one idea: Position + Evidence. A Position is a slot in a host's structure — a typed path prefix, or a query-param name. Evidence is everything the corpus has observed about that slot: which values, how often, across how many hosts. Strings are observations; types are inferences drawn from the pile. Shape is the surface you see; Position + Evidence is the engine underneath. See docs/ARCHITECTURE.md for the full model.

CLI reference

Single input — combined parse + normalize summary; trim with section flags (-p, -n).

Piped stdin — extraction runs by default. Output auto-switches: small inputs get a deduplicated URL list, larger inputs (≥ 10 IRIs) get the cluster view via an ephemeral corpus.

Flag Effect
-p, --parse Show parsed fields
-n, --normalize Show the shape-normalized form
-c, --canonical Show the canonical form (no shape normalization)
-e, --explain Annotated trace — per-segment notes about why each placeholder / canonical value was chosen
-j, --json Emit JSON
-J, --ndjson Newline-delimited JSON (one object per line); implies --json
-N, --no-hints Use {integer} etc. instead of {user_id}
--no-scheme-less Skip foo.com/path-style extraction (explicit-scheme only)
--corpus PATH Use a specific corpus file (.json or .db/.sqlite/.sqlite3). Overrides the default
-C, --no-corpus Disable corpus persistence for this invocation (same as IRIQ_NO_CORPUS=1)
--reset Delete the corpus database and exit
--host MODE Host-keying for clustering: full (default), reg strips subdomains, none ignores host
--stats Print rolling aggregates
--reinfer Drop the materialized views and replay the source-IRI log through the current classifier + reducers
--propose-recognizers Scan observed values for shape patterns that recur enough to suggest a new recognizer. Combine with --json for structured output
--cross-host-shapes List route shapes that recur across multiple hosts
--min-observations N Proposal threshold; default 20
--min-coverage F Proposal threshold; default 0.7
--min-hosts N Threshold for both proposals and cross-host shapes; default 1 / 2 respectively
--activate-above F With --propose-recognizers, auto-activate every proposal whose confidence is ≥ F
completion bash|zsh Print shell completion script (Homebrew installs this automatically)
-V, --version Print version

Environment variables:

Variable Effect
IRIQ_CORPUS=PATH Set the corpus path (overrides the default)
IRIQ_NO_CORPUS=1 Disable the default corpus (equivalent to -C)

A positional argument that doesn't parse as an IRI but IS an existing file is read and extracted from automatically — iriq ./access.log and iriq /var/log/foo.log Just Work. (pipe with cat to disambiguate)

Exit codes: 0 success, 1 usage error, 2 parse error.

Rust library

cargo add iriq
use iriq::{parse, normalize, Corpus};

let iri = parse("https://foo.com/users/123")?;
iri.host;             // "foo.com"
iri.path_segments;    // ["users", "123"]
iri.canonical();      // "https://foo.com/users/123"

normalize("https://foo.com/users/123")?;   // "https://foo.com/users/{user_id}"

// Streaming clustering against a persistent corpus.
let mut corpus = Corpus::open("c.db")?;
corpus.observe("https://foo.com/users/1")?;
corpus.save("c.db")?;

Full API on docs.rs/iriq; see the crate README for the library tour.

Limitations (intentional)

Iriq does not:

  • Implement RFC 3986, RFC 3987, or the WHATWG URL standard fully.
  • Convert between Unicode (IRI) and punycode (URI) — the display form is preserved as-is.
  • Percent-encode or decode path/query bytes. Bytes are kept as written.
  • Validate scheme-specific structure beyond URL vs. URN.
  • Resolve relative references against a base URL.
  • Round-trip canonical back to the exact original byte-for-byte (whitespace is stripped, default ports are dropped, dot segments are collapsed).

Iriq's focus is the analysis side: classification, normalization, and clustering — not a complete URL implementation.