Cavil::Matcher Architecture

This document explains how the matcher works and, more importantly, why it is built the way it is. It is meant to be read start to finish by someone new to the project, before they open the source. It talks about concepts, not functions or line numbers.

Why this exists

Cavil reviews the licensing of software by scanning source code for the text of known licenses. The scanning is done by matching every file against a large, ever-growing collection of patterns — normalized fragments of license text. The previous engine did this well and fast, and this one keeps its core idea unchanged. What it changes is everything around that core, because the old design had three operational problems that grow worse over time:

The goal of this engine is to fix those three things — cheap incremental updates, one shared copy per machine, and a safe, versioned on-disk format — while keeping matching itself bit-for-bit identical, so switching to it requires no re-processing of existing data.

The Perl/native split, and why

Only one thing in a license scan is genuinely performance-critical: walking every word of every file through the pattern collection. That inner loop, and the hashing that feeds it, is where nearly all the time goes, and it is written in a small, carefully-frozen C++ core.

Everything else — deciding which patterns are active, recording that a pattern was removed, choosing when to compact, reading and writing the little file that describes the collection — happens rarely and on small data. All of that is plain Perl, because that is where the team is strongest, because it is where a newcomer can follow the logic without a debugger, and because none of it is on the hot path, so nothing is lost by keeping it in Perl. The native side is deliberately dumb: it is handed a list of things to search and simply searches them. Every decision is made in Perl.

The native core is C++ rather than a rewrite in another language for a simple reason: the matching algorithm and its hashing are already proven on a legal tool, where a subtle behavioural change is the worst kind of bug. Reusing that code unchanged is the safest possible choice, so the core stays in the language it is already written in. The genuinely new native code — the reader for the on-disk format — is small and only ever reads files this same software wrote, and it validates everything it reads, which removes the one real weakness the old format had.

How matching works

A pattern is neither a regular expression nor a literal string. Before anything is compared, both the patterns and the files being scanned are put through the same normalization: text is lower-cased and split into words, punctuation and common comment or markup noise is discarded, and each surviving word is reduced to a number. Only those numbers are ever compared. This is what lets a match survive reformatting, rewrapping, and different comment styles — the layout simply disappears during normalization.

One wildcard exists. A pattern may say "skip one to N words here" (at least one word, at most N - it does not match a zero-word gap), which lets a single pattern absorb the parts of a license that legitimately vary, such as a copyright holder or a year. A pattern must begin and end on real words, never on a skip, so that every match is anchored at both ends.

All of a collection's patterns are arranged into a shared prefix tree keyed on those word-numbers. Patterns that start with the same words share the same branch and only diverge where the words diverge. Scanning a file means walking its words down this tree and noting wherever a complete pattern is reached. The important consequence is that scanning speed barely depends on how many patterns there are: shared beginnings are walked once, and only branches that actually occur in the file are ever explored.

When several patterns match overlapping parts of a file, the longest match wins; an exact tie is resolved in favour of the higher pattern identifier, on the assumption that newer patterns are the more specific ones. This "higher id means newer" rule holds because identifiers are assigned monotonically as patterns are created (Cavil uses a database sequence), which is a required invariant of any caller: the resolver only ever sees identifiers, not creation timestamps or segment order, so if identifiers were reused or assigned non-monotonically the tie-break would no longer prefer the genuinely newer pattern. This resolution is a pure function of the set of raw matches, which is what makes the segmented design below safe: it does not matter how the raw matches were gathered, only what they are.

Segments, tombstones, and the manifest

Instead of one monolithic compiled file, a collection is stored as a set of segments. A segment is a compiled prefix tree for one batch of patterns, and once written it is never modified. Alongside the segments lives a small, human-readable manifest: it lists the active segments, the pattern identifiers that have been removed (tombstones), and a generation number that increases on every change.

This is the whole point of the design, and it follows the model search engines have used for decades:

Because deltas and tombstones accumulate, an occasional compaction folds the current pattern set back into a single fresh base segment and clears the tombstones. This is the one operation that reads the full pattern set from the database — Cavil's source of truth — and it is rare and runs in the background, off the scanning path. It exists only to keep the number of segments and the length of the tombstone list bounded over time.

Shared memory and reproducibility

Segments are mapped into memory read-only and searched in place, rather than being read and rebuilt in each worker's private memory. When many workers on the same machine use the same collection, the operating system keeps a single physical copy of each segment and shares it across all of them. The practical effect is dramatic: attaching a large index to dozens of workers costs kilobytes of private memory per worker instead of a full duplicate each. This is what lifts the memory ceiling that limited the previous design.

The generation number in the manifest gives reproducibility. A scan pins the generation it ran against, and a report can record it, so re-running an old report can use exactly the same patterns it originally saw. Updates are published atomically — a new segment and an updated manifest are written to the side and swapped into place in one step — so a reader never observes a half-written collection.

The on-disk format

Each segment file begins with a header identifying it, stating its format version, and carrying a checksum of everything that follows. When a segment is opened, all of this is verified, and every internal reference in the file is bounds-checked before it is ever used. A file that is truncated, corrupted, of the wrong version, or simply not a segment at all is rejected cleanly; it is never partially trusted and never able to send the scanner off the end of the data. The manifest additionally records a checksum for each segment, so a segment that has been damaged or swapped underneath a running system is detected and skipped rather than used. This validated, versioned format is the deliberate replacement for the old engine's headerless, unchecked file.

Robustness

Scanning a whole distribution means feeding the matcher every kind of file that exists, including binaries, deliberately malformed samples that security tools ship as test data, files full of null bytes, and enormous single-line files with no structure at all. The matcher treats all of this as ordinary input: it reads files in bounded chunks, stops cleanly at the end of usable data, and bounds the amount of a file it holds in memory at once. Unreadable paths and missing files produce empty results rather than errors. The guiding rule is simple and absolute — no input, however hostile or malformed, may crash the scan.

What deliberately stays the same

Some things are intentionally unchanged from the previous engine, because they were already right:

Scaling characteristics and limits

The two costs that grew worst in the old design are gone: a change no longer recompiles the whole collection, and workers no longer each hold a private copy of it. Matching speed was already largely independent of the number of patterns, and remains so.

The new costs to watch are different and milder. Every active segment adds a little fixed overhead to each scan, so a collection that accumulates a great many un-compacted delta segments will slow down gradually; compaction is the remedy, and how often to run it is the main tuning knob. A single pathologically long pattern still widens the scanning window for every file, and patterns built from many skips still make the tree branch more, so the shape of new patterns is worth watching, not only their number.

How to work on it

The behaviour of the matcher is pinned by a self-contained test suite that needs no external engine: it asserts exact matches on a set of curated license fixtures, exercises the segment lifecycle (incremental add, tombstone, compaction, atomic update), checks that a damaged segment or manifest is handled gracefully, and throws a barrage of hostile inputs at the scanner to confirm it never crashes. That suite is the contract; if you change behaviour, it should tell you.

While this engine and its predecessor coexist, a separate developer-only set of tests cross-checks that the two produce byte-for-byte identical results across the whole real pattern corpus and a large sample of real snippets. Those tests are how equivalence is proven during the transition; once the old engine is retired they can simply be deleted, and the self-contained suite stands on its own.

A change to the pattern set flows through the system in the obvious way: adding patterns writes a new segment, removing one writes a tombstone, and compaction periodically rewrites a single clean base — none of which disturbs the data an in-flight scan is already using.