# Wave 3 PR 4 — Query Correctness Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add proptest-based cross-feature property tests with a reference evaluator (#212), Datalog parser/evaluator fuzz targets with seed corpus (#213), and CI coverage gates (#219).

**Architecture:** The reference evaluator is a naive nested-loop implementation (~150 lines) intentionally independent of production code. proptest generates random small graphs and queries; both evaluators are compared after normalizing output. Fuzz targets replace the stubs from PR 1. Coverage gates use the existing `llvm-cov.yml` pattern with per-module thresholds in `coverage-thresholds.toml`.

**Tech Stack:** Rust stable for property tests; Rust nightly for fuzz runs; proptest 1.x (added in PR 1); cargo-llvm-cov (already in CI); GitHub Actions

**Prerequisites:** PR 1 merged (proptest dev-dep, fuzz/ stub targets)

**Closes:** #212, #213, #219

---

## File Map

| Action | Path | Purpose |
|---|---|---|
| Create | `tests/property_test.rs` | proptest reference evaluator (#212) |
| Modify | `fuzz/fuzz_targets/datalog_parser.rs` | real fuzz target (#213) |
| Modify | `fuzz/fuzz_targets/datalog_eval.rs` | real fuzz target (#213) |
| Create | `fuzz/corpus/datalog_parser/` | seed corpus (#213) |
| Create | `fuzz/corpus/datalog_eval/` | seed corpus (#213) |
| Create | `.github/workflows/coverage-gates.yml` | per-module coverage CI (#219) |
| Create | `coverage-thresholds.toml` | threshold values (#219) |
| Modify | `.github/workflows/fuzz.yml` | add Datalog fuzz targets if file exists, else create |

---

## Task 1: proptest reference evaluator (#212)

**Files:**
- Create: `tests/property_test.rs`

The reference evaluator handles only the small query shapes generated by the test — basic joins and simple negation. It does NOT need to handle recursion, aggregation, or prepared statements; the generators only emit what the reference evaluator can handle.

- [ ] **Step 1: Write the reference evaluator and failing proptest**

Create `tests/property_test.rs`:

```rust
//! Cross-feature property tests (#212).
//!
//! Compares Minigraf query results against a deliberately simple reference
//! evaluator for randomly-generated small graphs and queries.
//!
//! Run: cargo test --test property_test
//! Increase cases: PROPTEST_CASES=1000 cargo test --test property_test

use minigraf::db::Minigraf;
use minigraf::QueryResult;
use proptest::prelude::*;

// ── Types used in this file ───────────────────────────────────────────────────

/// A minimal in-memory fact for the reference evaluator.
#[derive(Debug, Clone)]
struct TestFact {
    entity: usize,     // small integer entity ID
    attribute: String, // e.g. ":color"
    value: TestValue,
}

#[derive(Debug, Clone, PartialEq)]
enum TestValue {
    Str(String),
    Int(i64),
    Bool(bool),
}

impl TestValue {
    fn to_edn(&self) -> String {
        match self {
            TestValue::Str(s) => format!(r#""{s}""#),
            TestValue::Int(n) => n.to_string(),
            TestValue::Bool(b) => b.to_string(),
        }
    }
}

/// A simple query pattern: find entities that have a specific attribute.
#[derive(Debug, Clone)]
struct TestQuery {
    /// The attribute to match on.
    attribute: String,
    /// If Some, also require a specific value.
    value_filter: Option<TestValue>,
    /// If true, exclude entities that ALSO have `negation_attr`.
    negation_attr: Option<String>,
}

// ── Reference evaluator (naive, independent of production code) ───────────────

fn ref_eval(facts: &[TestFact], query: &TestQuery) -> Vec<usize> {
    // Find all entities with the matching attribute (and optional value).
    let mut matched: Vec<usize> = facts
        .iter()
        .filter(|f| {
            f.attribute == query.attribute
                && match &query.value_filter {
                    None => true,
                    Some(v) => &f.value == v,
                }
        })
        .map(|f| f.entity)
        .collect();

    // Apply negation: remove entities that also have the negation attribute.
    if let Some(neg_attr) = &query.negation_attr {
        let neg_entities: std::collections::HashSet<usize> = facts
            .iter()
            .filter(|f| &f.attribute == neg_attr)
            .map(|f| f.entity)
            .collect();
        matched.retain(|e| !neg_entities.contains(e));
    }

    matched.sort();
    matched.dedup();
    matched
}

// ── Minigraf evaluator ────────────────────────────────────────────────────────

/// Loads `facts` into an in-memory Minigraf and runs `query`.
/// Returns a sorted list of matching entity indices (as strings, then parsed).
fn minigraf_eval(facts: &[TestFact], query: &TestQuery) -> Vec<usize> {
    let db = Minigraf::in_memory().unwrap();

    // Transact all facts.
    for fact in facts {
        let entity_kw = format!(":e{}", fact.entity);
        let val_edn = fact.value.to_edn();
        let attr = &fact.attribute;
        let edn = format!(r#"(transact [[{entity_kw} {attr} {val_edn}]])"#);
        let _ = db.execute(&edn); // ignore errors from duplicate facts
    }

    // Build the Datalog query.
    let val_clause = match &query.value_filter {
        Some(v) => format!(" (= ?v {})", v.to_edn()),
        None => String::new(),
    };

    let neg_clause = match &query.negation_attr {
        Some(neg) => format!(" (not [?e {} _])", neg),
        None => String::new(),
    };

    let attr = &query.attribute;
    let datalog = format!(
        "(query [:find ?e :where [?e {attr} ?v]{val_clause}{neg_clause}])"
    );

    match db.execute(&datalog) {
        Ok(QueryResult::QueryResults { results, .. }) => {
            let mut entities: Vec<usize> = results
                .into_iter()
                .flat_map(|r| r.into_values())
                .filter_map(|v| match v {
                    minigraf::graph::types::Value::Keyword(kw) => {
                        kw.strip_prefix(":e").and_then(|s| s.parse().ok())
                    }
                    _ => None,
                })
                .collect();
            entities.sort();
            entities.dedup();
            entities
        }
        Err(_) => vec![],
    }
}

// ── proptest generators ───────────────────────────────────────────────────────

fn arb_attribute() -> impl Strategy<Value = String> {
    prop_oneof![
        Just(":color".to_string()),
        Just(":size".to_string()),
        Just(":active".to_string()),
        Just(":tag".to_string()),
        Just(":score".to_string()),
    ]
}

fn arb_value() -> impl Strategy<Value = TestValue> {
    prop_oneof![
        Just(TestValue::Str("red".to_string())),
        Just(TestValue::Str("blue".to_string())),
        Just(TestValue::Int(1)),
        Just(TestValue::Int(2)),
        Just(TestValue::Bool(true)),
    ]
}

fn arb_fact(max_entity: usize) -> impl Strategy<Value = TestFact> {
    (0..max_entity, arb_attribute(), arb_value()).prop_map(|(entity, attribute, value)| {
        TestFact { entity, attribute, value }
    })
}

fn arb_small_graph() -> impl Strategy<Value = Vec<TestFact>> {
    prop::collection::vec(arb_fact(8), 3..20)
}

fn arb_query(facts: &[TestFact]) -> impl Strategy<Value = TestQuery> {
    // Extract the unique attributes present in the graph for realistic queries.
    let attrs: Vec<String> = {
        let mut v: Vec<String> = facts.iter().map(|f| f.attribute.clone()).collect();
        v.sort();
        v.dedup();
        if v.is_empty() { vec![":color".to_string()] } else { v }
    };
    let neg_attrs = attrs.clone();

    let attr_strategy = prop::sample::select(attrs);
    let neg_attr_strategy = prop::sample::select(neg_attrs);

    (attr_strategy, any::<bool>(), arb_value(), any::<bool>(), neg_attr_strategy).prop_map(
        |(attribute, use_value, value, use_neg, neg_attr)| TestQuery {
            attribute: attribute.clone(),
            value_filter: if use_value { Some(value) } else { None },
            negation_attr: if use_neg && neg_attr != attribute {
                Some(neg_attr)
            } else {
                None
            },
        },
    )
}

// ── Property tests ────────────────────────────────────────────────────────────

proptest! {
    /// Minigraf results must match the reference evaluator for random small graphs.
    #[test]
    fn query_results_match_reference(facts in arb_small_graph()) {
        let query_strategy = arb_query(&facts);
        // Use a fixed seed for the inner query generation to keep the test deterministic.
        let mut runner = proptest::test_runner::TestRunner::default();
        let query = query_strategy.new_tree(&mut runner).unwrap().current();

        let ref_result = ref_eval(&facts, &query);
        let mg_result = minigraf_eval(&facts, &query);

        prop_assert_eq!(
            ref_result,
            mg_result,
            "reference and minigraf disagree for query={:?}",
            query
        );
    }

    /// Negation: entities with negation_attr must not appear in results.
    #[test]
    fn negation_excludes_correct_entities(facts in arb_small_graph()) {
        // Always use negation for this test.
        let attrs: Vec<String> = {
            let mut v: Vec<String> = facts.iter().map(|f| f.attribute.clone()).collect();
            v.sort();
            v.dedup();
            v
        };
        prop_assume!(attrs.len() >= 2); // need at least 2 attributes for negation

        let query = TestQuery {
            attribute: attrs[0].clone(),
            value_filter: None,
            negation_attr: Some(attrs[1].clone()),
        };

        let ref_result = ref_eval(&facts, &query);
        let mg_result = minigraf_eval(&facts, &query);

        // Negation check: no entity in the result should also have the negation attr.
        let neg_entities: std::collections::HashSet<usize> = facts
            .iter()
            .filter(|f| f.attribute == attrs[1])
            .map(|f| f.entity)
            .collect();

        for &e in &mg_result {
            prop_assert!(
                !neg_entities.contains(&e),
                "entity {e} has negated attribute but appears in result"
            );
        }

        prop_assert_eq!(ref_result, mg_result);
    }

    /// Impossible paths return empty results, not broad catch-all output.
    #[test]
    fn impossible_path_returns_empty(facts in arb_small_graph()) {
        // Query for an attribute that does not exist in the graph.
        let query = TestQuery {
            attribute: ":__nonexistent_attr__".to_string(),
            value_filter: None,
            negation_attr: None,
        };

        let ref_result = ref_eval(&facts, &query);
        let mg_result = minigraf_eval(&facts, &query);

        prop_assert!(ref_result.is_empty(), "reference: impossible path must be empty");
        prop_assert!(mg_result.is_empty(), "minigraf: impossible path must be empty");
    }
}
```

- [ ] **Step 2: Run the property tests**

```bash
cargo test --test property_test -- --nocapture 2>&1 | tail -20
```
Expected: all 3 proptest cases pass. If any fails, the output includes the minimal shrunk counter-example.

- [ ] **Step 3: Commit**

```bash
git add tests/property_test.rs
git commit -m "test(query): add #212 proptest reference evaluator for cross-feature property tests"
```

---

## Task 2: Datalog fuzz targets (#213)

**Files:**
- Modify: `fuzz/fuzz_targets/datalog_parser.rs`
- Modify: `fuzz/fuzz_targets/datalog_eval.rs`
- Create: `fuzz/corpus/datalog_parser/` (seed files)
- Create: `fuzz/corpus/datalog_eval/` (seed files)

First, identify the public API surface for the parser. From `src/query/datalog/parser.rs`, the parse entry points are likely accessible through `Minigraf::execute()`. For fuzz targets, call the parser directly if possible, or fall back to `Minigraf::in_memory().execute()`.

- [ ] **Step 1: Check parser public API**

```bash
grep -n 'pub fn\|pub use' src/query/datalog/parser.rs | head -20
grep -n 'pub fn parse\|pub fn execute\|pub fn transact' src/query/datalog/parser.rs | head -10
```

If `parse()` or similar is pub, use it directly. If not, use `Minigraf::in_memory().execute()` as the target — any panic is a bug.

- [ ] **Step 2: Replace datalog_parser.rs stub**

Replace `fuzz/fuzz_targets/datalog_parser.rs` content:

```rust
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    // Arbitrary bytes → UTF-8 string → Datalog parser.
    // Contract: must never panic. Invalid input returns Err.
    if let Ok(s) = std::str::from_utf8(data) {
        let db = match minigraf::db::Minigraf::in_memory() {
            Ok(db) => db,
            Err(_) => return, // construction failure is not a fuzz finding
        };
        // execute handles transact, retract, and query — all parser entry points.
        let _ = db.execute(s);
    }
});
```

- [ ] **Step 3: Replace datalog_eval.rs stub**

Replace `fuzz/fuzz_targets/datalog_eval.rs` content:

```rust
#![no_main]
use libfuzzer_sys::fuzz_target;

// Fuzz the evaluator with a fixed small graph and arbitrary query bytes.
// This targets the expression evaluation and predicate paths, not the parser.
fuzz_target!(|data: &[u8]| {
    if let Ok(s) = std::str::from_utf8(data) {
        let db = match minigraf::db::Minigraf::in_memory() {
            Ok(db) => db,
            Err(_) => return,
        };
        // Pre-seed with known facts so the evaluator has data to work with.
        let _ = db.execute(r#"(transact [[:alice :name "Alice"] [:bob :name "Bob"] [:alice :age 30]])"#);

        // Now fuzz the query/rule evaluation path.
        let _ = db.execute(s);
    }
});
```

- [ ] **Step 4: Create seed corpus for datalog_parser**

```bash
mkdir -p fuzz/corpus/datalog_parser fuzz/corpus/datalog_eval
```

Create seed files (each file is one corpus entry — a single input):

`fuzz/corpus/datalog_parser/seed_query.txt`:
```
(query [:find ?e :where [?e :name ?n]])
```

`fuzz/corpus/datalog_parser/seed_transact.txt`:
```
(transact [[:e1 :name "Alice"]])
```

`fuzz/corpus/datalog_parser/seed_retract.txt`:
```
(retract [[:e1 :name "Alice"]])
```

`fuzz/corpus/datalog_parser/seed_rule.txt`:
```
(rule [(parent ?x ?y) [?x :parent ?y]])
```

`fuzz/corpus/datalog_parser/seed_not.txt`:
```
(query [:find ?e :where [?e :name ?n] (not [?e :banned true])])
```

`fuzz/corpus/datalog_parser/seed_agg.txt`:
```
(query [:find (count ?e) :where [?e :name ?n]])
```

`fuzz/corpus/datalog_parser/seed_empty.txt`:
```

```

`fuzz/corpus/datalog_parser/seed_truncated.txt`:
```
(query [:find ?e :whe
```

- [ ] **Step 5: Create seed corpus for datalog_eval**

`fuzz/corpus/datalog_eval/seed_basic.txt`:
```
(query [:find ?n :where [?e :name ?n]])
```

`fuzz/corpus/datalog_eval/seed_predicate.txt`:
```
(query [:find ?n :where [?e :name ?n] [(str/starts-with? ?n "A")]])
```

`fuzz/corpus/datalog_eval/seed_not_join.txt`:
```
(query [:find ?e :where [?e :name ?n] (not-join [?e] [?e :age ?a])])
```

`fuzz/corpus/datalog_eval/seed_temporal.txt`:
```
(query [:find ?n :where [?e :name ?n]] :as-of 1)
```

- [ ] **Step 6: Verify fuzz targets compile**

```bash
cargo +nightly check --manifest-path fuzz/Cargo.toml 2>&1 | tail -10
```
Expected: no errors.

- [ ] **Step 7: Verify fuzz targets run without crashing on seed corpus**

```bash
cargo +nightly fuzz run datalog_parser fuzz/corpus/datalog_parser -- -max_total_time=10 2>&1 | tail -15
cargo +nightly fuzz run datalog_eval fuzz/corpus/datalog_eval -- -max_total_time=10 2>&1 | tail -15
```
Expected: runs for 10 seconds each, no crashes or panics found.

- [ ] **Step 8: Add fuzz.yml workflow (or update if it exists)**

Check if `.github/workflows/fuzz.yml` exists:
```bash
ls .github/workflows/fuzz.yml 2>/dev/null && echo exists || echo missing
```

If missing, create `.github/workflows/fuzz.yml`:

```yaml
name: Fuzz (nightly)

on:
  schedule:
    - cron: "0 4 * * *"
  workflow_dispatch:

permissions:
  contents: read

jobs:
  fuzz:
    name: cargo-fuzz
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install nightly Rust
        uses: dtolnay/rust-toolchain@nightly

      - name: Install cargo-fuzz
        run: cargo install cargo-fuzz

      - name: Fuzz wal_entry (60s)
        run: cargo fuzz run wal_entry fuzz/corpus/wal_entry -- -max_total_time=60
        continue-on-error: false

      - name: Fuzz file_header (60s)
        run: cargo fuzz run file_header fuzz/corpus/file_header -- -max_total_time=60
        continue-on-error: false

      - name: Fuzz fact_page (60s)
        run: cargo fuzz run fact_page fuzz/corpus/fact_page -- -max_total_time=60
        continue-on-error: false

      - name: Fuzz btree_page (60s)
        run: cargo fuzz run btree_page fuzz/corpus/btree_page -- -max_total_time=60
        continue-on-error: false

      - name: Fuzz datalog_parser (60s)
        run: cargo fuzz run datalog_parser fuzz/corpus/datalog_parser -- -max_total_time=60
        continue-on-error: false

      - name: Fuzz datalog_eval (60s)
        run: cargo fuzz run datalog_eval fuzz/corpus/datalog_eval -- -max_total_time=60
        continue-on-error: false
```

- [ ] **Step 9: Commit**

```bash
git add fuzz/fuzz_targets/datalog_parser.rs fuzz/fuzz_targets/datalog_eval.rs \
    fuzz/corpus/datalog_parser/ fuzz/corpus/datalog_eval/ \
    .github/workflows/fuzz.yml
git commit -m "test(fuzz): add #213 Datalog parser/eval fuzz targets with seed corpus"
```

---

## Task 3: Coverage gates (#219)

**Files:**
- Create: `coverage-thresholds.toml`
- Create: `.github/workflows/coverage-gates.yml`

The existing `llvm-cov.yml` generates branch coverage and uploads to Codecov. The new `coverage-gates.yml` generates line coverage and fails the PR if any critical module drops below its threshold.

- [ ] **Step 1: Create coverage-thresholds.toml**

Create `coverage-thresholds.toml` in the repo root:

```toml
# Coverage thresholds for critical reliability modules.
# These values were set at Wave 3 completion; ratchet upward in future waves.
# Run: cargo llvm-cov --text 2>&1 | grep -E "(wal|db|persistent|btree_v6|executor|evaluator|stratification)"
# to see current values and update these thresholds accordingly.

[modules]
"src/wal.rs"                              = 80
"src/db.rs"                               = 75
"src/storage/persistent_facts.rs"         = 75
"src/storage/btree_v6.rs"                 = 70
"src/query/datalog/executor.rs"           = 80
"src/query/datalog/evaluator.rs"          = 80
"src/query/datalog/stratification.rs"     = 80
```

Note: Thresholds are set conservatively. After running `cargo llvm-cov --text` to see actual current coverage, update these values to what the modules currently achieve (round down to nearest 5). Do NOT set thresholds higher than current coverage — they should pass immediately on PR creation.

- [ ] **Step 2: Measure current coverage to calibrate thresholds**

```bash
cargo llvm-cov --text 2>&1 | grep -E "(wal\.rs|db\.rs|persistent_facts|btree_v6|executor\.rs|evaluator\.rs|stratification)" | head -20
```

Read the `Lines%` column for each file. Update `coverage-thresholds.toml` so each threshold ≤ current coverage (rounded down to nearest 5 percentage points to give a buffer).

- [ ] **Step 3: Create .github/workflows/coverage-gates.yml**

```yaml
name: Coverage Gates

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

permissions:
  contents: read

jobs:
  coverage-gates:
    name: Per-module coverage gates
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Install Rust stable
        uses: dtolnay/rust-toolchain@stable
        with:
          components: llvm-tools-preview

      - name: Install cargo-llvm-cov
        uses: taiki-e/install-action@cargo-llvm-cov

      - name: Generate coverage report (JSON)
        run: cargo llvm-cov --json --output-path coverage.json

      - name: Check per-module thresholds
        run: |
          python3 - <<'PYEOF'
          import json, sys, tomllib

          with open("coverage.json") as f:
              data = json.load(f)

          with open("coverage-thresholds.toml", "rb") as f:
              thresholds = tomllib.load(f)["modules"]

          failed = []
          file_data = {}
          for entry in data.get("data", [{}])[0].get("files", []):
              fn = entry.get("filename", "")
              # Strip leading path components to match threshold keys.
              for key in thresholds:
                  if fn.endswith(key.lstrip("/")):
                      pct = entry["summary"]["lines"]["percent"]
                      file_data[key] = pct
                      if pct < thresholds[key]:
                          failed.append(f"  {key}: {pct:.1f}% < {thresholds[key]}% required")

          for key in thresholds:
              if key not in file_data:
                  print(f"WARNING: {key} not found in coverage report — skipping")

          if failed:
              print("Coverage gate FAILED:")
              for line in failed:
                  print(line)
              sys.exit(1)
          else:
              print("All coverage gates passed.")
              for k, v in sorted(file_data.items()):
                  print(f"  {k}: {v:.1f}% >= {thresholds[k]}%")
          PYEOF
```

Note: `tomllib` requires Python 3.11+. GitHub Actions ubuntu-latest has Python 3.11+. If the runner uses an older Python, replace `tomllib` with `tomli` (install via `pip install tomli`) or parse the TOML with a simpler grep-based approach.

Alternative grep-based threshold check script (if Python TOML is unavailable):

```bash
# Simpler alternative: use cargo llvm-cov --text and grep
cargo llvm-cov --text 2>&1 > cov_text.txt
check_module() {
    local file=$1 threshold=$2
    local pct=$(grep "$file" cov_text.txt | grep -oP '\d+\.\d+(?=%)' | tail -1)
    if [ -z "$pct" ]; then echo "WARNING: $file not found"; return 0; fi
    python3 -c "import sys; sys.exit(0 if float('$pct') >= $threshold else 1)" \
        || { echo "FAIL: $file: ${pct}% < ${threshold}%"; return 1; }
    echo "OK: $file: ${pct}% >= ${threshold}%"
}
FAIL=0
check_module "wal.rs"            80 || FAIL=1
check_module "db.rs"             75 || FAIL=1
check_module "persistent_facts"  75 || FAIL=1
check_module "btree_v6"          70 || FAIL=1
check_module "executor.rs"       80 || FAIL=1
check_module "evaluator.rs"      80 || FAIL=1
check_module "stratification"    80 || FAIL=1
exit $FAIL
```

Choose whichever approach works reliably on ubuntu-latest. The grep-based version is more robust.

- [ ] **Step 4: Run coverage locally to verify gates pass**

```bash
cargo llvm-cov --text 2>&1 | grep -E "(wal\.rs|db\.rs|persistent_facts|btree_v6|executor\.rs|evaluator\.rs|stratification)" | head -20
```

Verify all modules meet their thresholds. Adjust `coverage-thresholds.toml` if any is too high.

- [ ] **Step 5: Run full test suite**

```bash
cargo test 2>&1 | tail -15
```
Expected: all tests pass.

- [ ] **Step 6: Commit**

```bash
git add coverage-thresholds.toml .github/workflows/coverage-gates.yml
git commit -m "ci: add #219 per-module coverage gates with cargo-llvm-cov"
```

---

## Task 4: Open PR

- [ ] **Push and open PR**

```bash
git push -u origin HEAD
gh pr create \
  --title "test(query): proptest reference evaluator, Datalog fuzz targets, coverage gates (#212, #213, #219)" \
  --body "$(cat <<'EOF'
## Wave 3 PR 4 — Query Correctness

Closes #212, #213, #219.

### #221 — proptest reference evaluator (`tests/property_test.rs`)
- Naive reference evaluator (independent of production code)
- 3 proptest cases: results match reference, negation correctness, impossible path returns empty
- Generators cover: random small graphs (3–20 facts, 8 entities), attribute queries, negation

### #213 — Datalog fuzz targets (`fuzz/fuzz_targets/`)
- `datalog_parser.rs`: fuzzes all parser entry points via `Minigraf::execute()`
- `datalog_eval.rs`: fuzzes evaluator with pre-seeded facts and arbitrary query bytes
- Seed corpus: 8 entries for parser, 4 entries for evaluator
- `fuzz.yml` nightly workflow added (60s per target, all 6 targets)

### #212 — Coverage gates (`.github/workflows/coverage-gates.yml`)
- `coverage-thresholds.toml`: per-module thresholds for 7 critical modules
- `coverage-gates.yml`: runs on every PR, fails if any module drops below threshold
- Uses existing `cargo-llvm-cov` already in CI toolchain

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```

- [ ] **Monitor CI until green before merging**
