Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

mikronova/modgunn

.nornir/engine-reasoning.md

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:

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:

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.

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.

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 Verdicts. 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)

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.