<!-- nornir:class: reasoning -->
# Engine A / Engine B — the reasoning (why two, and how they relate)
> Rationale + the A-vs-B comparison. The *chosen* design (the trait + both engines
> behind it) lives in the code (`src/engine.rs`) and is summarised in
> `.nornir/README-full.md`; the head-to-head numbers are in
> `.nornir/engine-benchmarks.md`. This file is the *why*.
## Two different "Engine A / Engine B" axes — don't conflate them
modgunn already uses "Engine A / Engine B" at the **subsystem** level:
- **Engine A = `scan`** — the artifact security *verdict* (CVE / license / provenance
→ `core::Verdict`). Lean leaf.
- **Engine B = `scip`** — the whole-world *behaviour-delta* (heavy, 128 GB–1 TB RAM).
That split is about *what* is computed and stays as-is. This work adds a **second,
finer A/B axis, entirely inside Engine-A-the-scan**: two interchangeable
*implementations of the same verdict contract* that differ only in **how they match
advisories**. Both names are legitimate; this doc + the code are careful to say
"matching strategy A/B" so the two axes never blur.
## The seam we split
The verdict pipeline is: `find matching advisories → fold into a Verdict`. The fold
(`Policy::assemble`: vuln + license + provenance → decision table) is fixed and
shared. The **matching** — "which advisories affect this artifact?" — is the hot path
at whole-OSS-world scale (`components × advisories`), and it is the *only* thing that
varies between the engines. So the trait `engine::AdvisoryEngine` abstracts exactly
that one method:
```rust
fn matching(&self, artifact: &ArtifactRef) -> Vec<&Advisory>;
```
Everything else (`verdict`, `fleet`) is a provided method on top of it, so both
engines are guaranteed to share verdict semantics — they cannot drift.
## Engine A — linear scan (`LinearEngine`)
The strategy modgunn shipped: `AdvisoryDb::query` walks **every** advisory for
**every** artifact, keeping those whose `Advisory::matches` returns true.
- **Pro**: zero build cost, minimal memory, trivially correct, cache-friendly for a
*small* db; nothing to invalidate when the db moves.
- **Con**: fleet cost is `Θ(artifacts × advisories)` — quadratic-ish as both the SBOM
and the advisory corpus grow. At a real CVE-warehouse db (10⁵–10⁶ advisories) it
collapses.
## Engine B — inverted index (`IndexedEngine`)
The alternative: one up-front pass buckets advisory **indices** by
`(ecosystem, package)` (lower-cased). A query grabs at most one bucket and
version-tests only those advisories.
- **Pro**: fleet cost is `Θ(artifacts × hits_per_package)` after an `Θ(advisories)`
build — the win grows with db size (measured **195×** at 25 k advisories × 1 M
artifacts, 12 cores; see benchmarks).
- **Con**: the index costs memory (`Θ(advisories)` in `usize` handles) and a build
step; for a *tiny* db or a one-shot single-artifact scan that overhead isn't repaid.
### Why they are provably equal (not "usually")
`Advisory::matches` returns false unless `advisory.ecosystem == artifact.ecosystem &&
advisory.package == artifact.name`. So an advisory can *only* match an artifact in its
own `(ecosystem, package)` bucket — bucketing on that key is **lossless**. B tests
exactly the advisories A's package guard would have admitted, **in the same original
order** (bucket indices are pushed ascending), so the matched set *and* its order are
identical → byte-identical `Verdict`s. The key is lower-cased on both sides so the
bucket lookup can never miss a case-variant match that A's own compare would make;
`matches` still applies its own rule, so B never *adds* a match A wouldn't. This is
pinned by unit tests (`src/engine.rs`), a 20 000-artifact randomized integration test
(`tests/engine_ab_equivalence.rs`), **and** the benchmark's own `assert_eq!` on total
findings.
### Where they *legitimately* differ
Only in **cost** (build time, memory, throughput) — never in results. That is the
whole point of the split and exactly what the benchmark measures.
## Selection (the runtime swap)
- **Library**: `engine::build(EngineKind, &AdvisoryDb) -> Box<dyn AdvisoryEngine>`.
- **CLI**: `modgunn warehouse scan --engine a|b` (aliases `linear`/`indexed`).
- **Config/env**: `MODGUNN_ENGINE=a|b` is the fallback when no flag is given.
Default is **A** (`EngineKind::default()`), so every existing caller keeps modgunn's
shipped linear behaviour until it opts into B — a pure, back-compatible addition.
## Recommendation
Default **A** for the lean per-artifact gate (holger's promotion path: one artifact,
tiny db — the index would never be repaid). Switch to **B** for any **fleet / warehouse
scan** over the real advisory corpus (`warehouse scan --engine b`), where the index is
built once and amortised over the whole SBOM. The `warehouse-sql` pushdown remains a
third, orthogonal strategy (narrow candidates *in* the warehouse before Rust ever runs)
for the S3/S4 scale where even loading the db into RAM stops being affordable.