Derived from: RAGPROOF_BUILD_PLAN.md v1.0 Plan version: 1.0 Date: 2026-07-02 Status: Ready for execution
This document is the authoritative execution plan. It preserves the phase order of the build spec (Section 12) but hardens every phase against real-world failure modes the spec does not fully address. Where this plan and the spec conflict, this plan wins; the conflict is recorded in Section 2 below.
The companion TASKS.md is the flat, trackable checklist. Task IDs there
(P0-01, P3-07, …) map to the phases here.
| Check | Result |
|---|---|
PyPI name ragproof |
Free - pypi.org/pypi/ragproof/json returns 404 |
GitHub name ragproof |
Free - only an unrelated RAGProofofConcept notebook exists |
The name is cleared. Reserve it early: create the GitHub repo in Phase 0 and publish a
0.0.1 placeholder to PyPI at the end of Phase 0 (PyPI has no name reservation other
than publishing).
These are deliberate deviations/additions. Each is folded into the phase tasks below.
The spec pairs RAGPROOF_MAX_CONCURRENCY=4 async workers with SQLite. Concurrent
writers on SQLite produce database is locked errors under default settings.
Fix (Phase 1): enable WAL mode + busy timeout at connection setup; route all writes
through a single writer task fed by an asyncio.Queue (workers never touch the DB
directly); use aiosqlite via SQLAlchemy async. Add a stress test: 32 concurrent
cases against a jittery mock adapter, zero lock errors.
Evaluations hit flaky pipelines and rate limits; without checkpointing, a 20-minute
run dies at the last case and all cost is wasted.
Fix (Phase 1): persist each Result as it completes; runs have a state machine
(running → completed | partial | aborted); ragproof run --resume <run_id> skips
already-completed cases. Per-case errors are recorded as case status
(error / timeout / judge_error), never as a score of 0, and never kill the run.
LLM-judge metrics are noisy; a relative threshold of 0.03 will fire on judge noise
alone for small datasets, and teams will delete the gate the first week it blocks a
release falsely.
Fix (Phase 3/6): judge calls at temperature 0 with structured JSON output; a
content-addressed judge cache (model + prompt hash + input hash) makes re-runs on
unchanged cases exactly reproducible and nearly free; compare and gate compute
bootstrap 95% confidence intervals on judge-backed metrics and warn when a delta is
within noise; gate config supports a noise_floor per metric; docs recommend tight
gates on deterministic metrics, loose gates + CI-aware deltas on judge metrics; a
minimum-sample warning fires when n < 30.
The spec says exit 0/1. A pipeline outage would then read as a quality regression.
Fix (Phase 1, enforced Phase 6): exit code contract: 0 pass, 1 gate/threshold
failure, 2 execution error (adapter down, judge unreachable, budget aborted),
3 configuration error. JUnit output marks execution errors as errors, not
failures.
Scoring only "did it decline on unanswerable questions" gives a perfect score to a
pipeline that answers nothing.
Fix (Phase 5): add robustness.overrefusal - the refusal rate on answerable
QA cases - and report abstention and overrefusal side by side. The report calls out
the trade-off explicitly.
Fix (Phase 3): budget is checked before every judge/generation call against
accumulated actuals (provider-reported token usage where available, tokenizer
estimate otherwise); on breach the run stops gracefully, is marked aborted:budget,
persists everything done so far, and exits 2. The judge cache (RW-3) is the main
cost reducer: repeated runs over an unchanged dataset only pay for changed cases.
Fix (Phase 3): every judge prompt demands a JSON object; responses are validated
against a Pydantic schema; on failure, one repair retry with the validation error
appended; on second failure the case is marked judge_error (excluded from the mean,
counted and displayed - never scored as 0 or dropped silently).
Naïve json.dumps hashing varies with key order, whitespace, and unicode escaping.
Fix (Phase 1/4): one canonical-JSON function (sorted keys, UTF-8, no ASCII
escaping, fixed separators) used for every hash; algorithm (sha256) recorded with
the hash; loading a frozen dataset re-verifies the hash and refuses on mismatch.
Fix (Phase 2, documented in docs/metrics.md): defined behavior for: fewer than
k chunks retrieved; empty retrieval; duplicate chunk IDs (deduplicate, keep first
rank); no relevant item found (MRR = 0); empty expected-source set (case invalid at
freeze time, rejected then - not at run time); zero-claim answers for groundedness
(status not_applicable, excluded from mean, counted); citations referencing
duplicate IDs. Every edge case has a fixture test with an exact expected value.
The spec states this for missing retrieve(); this plan generalizes it: any metric
that cannot be computed for a case or run gets status skipped with a
machine-readable reason, surfaced in report and gate output. A gate threshold on a
skipped metric is a gate failure by default (on_missing: fail | skip config).
Development happens on Windows; most eval tools break there first (paths, event
loops, file locking, console encoding).
Fix (Phase 0): CI matrix {ubuntu, windows, macos} × {3.11, 3.12, 3.13} from the
first commit; pathlib everywhere; no fcntl; UTF-8 explicit on every file
open; Rich handles console encoding.
PDF/DOCX parsing drags in large dependencies most CI users don't need.
Fix (Phase 0/4): packaging extras - pip install ragproof stays lean (CLI, run,
gate, report); ragproof[ingest] adds pypdf/python-docx; a missing extra
produces a clear install hint, not a stack trace.
CI artifact viewers and client laptops can't always reach a CDN.
Fix (Phase 6): Chart.js is vendored into the template (license header retained);
the report is one file with zero network requests; add a CI test that greps the
generated report for http(s):// resource loads.
Fix (Phase 3): every run records judge model + per-prompt hashes; compare and
gate refuse-by-default (override flag --allow-mixed-judges) when judge-backed
metrics were produced under different judges/prompts, and always label the output.
Fix (Phase 1): ragproof.yaml is validated by Pydantic with unknown-key
rejection and "did you mean" suggestions; ragproof check validates config, env
vars, adapter reachability (1 live probe question), and judge connectivity before
any expensive run. Referenced-but-unset env vars are named explicitly.
Fix (Phase 1): Alembic migrations from the first table; schema_version stored;
opening a newer-schema DB with an older CLI gives a clear error, and older DBs are
auto-migrated with a backup file written first.
Fix (Phase 5): a payload lint test asserts every payload uses inert markers and
*.invalid / example.com URLs only - no real domains, no shell commands, no
data-exfil endpoints. The test is part of the standard suite so contributions
can't regress it.
Fix (Phase 1/3): a redaction filter (registered on the root logger and applied
before persisting adapter/judge raw payloads) masks values of any env var matching
*_API_KEY|*_TOKEN|*_SECRET plus common bearer-token patterns. Adapter configs
store env var names only; a test asserts a leaked key never reaches the DB or logs.
- Language/tooling: Python 3.11+;
uvfor env + lockfile (uv.lockcommitted);ruff(lint + format),mypy --strictonragproof/(tests may relax);pytest+pytest-asyncio+coverage. - Types: Pydantic v2 models at every boundary (config, adapter I/O, judge I/O,
dataset schema). No
dict[str, Any]crossing module boundaries. - Commits: conventional commits, small and reviewable. Each phase ends with a
tagged commit
phase-N-complete. - Tests-before-done: a metric or detector without known-answer fixtures is not
done (spec rule 3). Coverage gate ≥ 85% on
metrics/,engine.py,judge/. - Docs-with-code:
docs/metrics.mdupdated in the same PR as any scoring change;.env.exampleupdated in the same PR as any new env var. - PROGRESS.md: updated at the end of every phase - what shipped, what's next,
open
TODO(decision)items. - CI (from Phase 0): lint → typecheck → tests on the 3-OS × 3-Python matrix; CodeQL; dependency review; secret scanning (gitleaks); coverage report.
- Determinism: all randomness through a seeded
random.Randompassed explicitly; seeds recorded in run/dataset metadata.
Dependency chain: P0 → P1 → P2 → P3 → P4 → P5 → P6 → P7.
P2 and P3 could theoretically parallelize, but sequential is safer since P3 reuses
P2's registry and aggregation. Estimates assume one focused engineer (or agent) and
include tests + docs.
Goal: installable, CI-green skeleton; name secured.
Scope: repo scaffold per spec Section 10; pyproject.toml (PEP 621, hatchling,
single-sourced version, extras: ingest, dev); Typer CLI stub with all commands
registered (generate, freeze, run, compare, gate, report, calibrate,
check) each returning "not implemented" with exit 3; ragproof --version;
ruff + mypy configs; GitHub Actions CI matrix (RW-11); CodeQL + gitleaks + dependency
review; .env.example; MIT LICENSE; README stub; PROGRESS.md; create GitHub repo;
publish 0.0.1 placeholder to PyPI via Trusted Publishing (OIDC - no long-lived
token secrets).
Acceptance:
uv sync && uv run ragproof --helplists all commands on Windows and Linux.- CI green on the full matrix; CodeQL and secret scan enabled and passing.
pip install ragproof==0.0.1works from PyPI (placeholder).- Exit code contract (RW-4) documented in README and encoded in a
ExitCodeenum.
Risks: PyPI Trusted Publishing needs the repo public or configured - do repo creation first, publishing last in the phase.
Goal: RAGProof executes a run against a real pipeline and persists everything, crash-safely.
Scope:
- Adapter protocol (
retrieve,answer) with Pydantic I/O models (RetrievedChunk,RAGAnswer,ChunkRef). Capability introspection: adapters declaresupports_retrieval/supports_answerso the engine plans metric skips up front (RW-10). - Python adapter: import-path loading; supports both sync and async user implementations (sync wrapped via thread offload).
- HTTP adapter: request/response mapping via JSONPath (
jsonpath-ng); auth header values sourced from named env vars only (RW-18); per-call timeout; tenacity retries with exponential backoff + jitter, honoringRetry-After, retrying only 429/5xx/timeouts (RW not: 4xx client errors fail fast). - Run store: SQLAlchemy 2.x async +
aiosqlite; WAL + busy-timeout on connect; single-writer queue task (RW-1); Alembic migrations +schema_version(RW-16); models:Project,Dataset,Case,Run,Result,MetricSummaryper spec Section 5, plus runstatusand per-resultstatusfields (RW-2). - Engine: dataset iteration with
asyncio.Semaphore(RAGPROOF_MAX_CONCURRENCY); per-case timeout; per-case error capture (RW-2); incremental result persistence;--resume; run manifest (config hash, dataset hash, seeds, versions) via canonical JSON (RW-8); trivialecho.exact_matchmetric to prove the loop. - Config:
ragproof.yamlloader with strict Pydantic validation + unknown-key suggestions (RW-15); env var layer per spec Section 11. ragproof check: validates config, env, adapter reachability, DB writability (RW-15).- Redaction filter installed on logging and on persisted raw payloads (RW-18).
- Cost-tracking scaffold:
CostLedgeraccumulating per-call token/cost entries (real accounting lands in Phase 3).
Acceptance:
- 5-case hand-written JSONL run against the example Python adapter persists results
and metadata; second run is comparable;
--resumeafter a forced mid-run kill completes only the remaining cases. - Concurrency stress test (32 cases, jittery mock adapter) → zero SQLite lock errors.
- HTTP adapter tested against a mocked server: mapping, retries, timeout, 4xx
fail-fast,
Retry-Afterhonored. - A planted API key never appears in logs or DB (automated test).
- Exit codes 2/3 verified for adapter-down and bad-config scenarios.
Goal: the deterministic metric family, exact and edge-case-proof.
Scope: precision_at_k, recall_at_k, mrr, ndcg_at_k (binary relevance
default; graded left as documented future); metric registry (stable string names,
declared requirements - e.g. needs: expected_source_ids, retrieval); edge-case
semantics per RW-9, all fixture-tested with exact values; MetricSummary
aggregation (mean/p50/p95, plus counts of scored/skipped/error cases);
ragproof compare run_a run_b with per-metric deltas and skip/error visibility;
graceful skip-with-reason when the adapter lacks retrieve or cases lack
expected_source_ids (RW-10); chunk-ID vs document-ID matching granularity
(config, spec §17.4).
Acceptance:
- Known-answer fixtures pass with exact values, including all RW-9 edge cases.
compareprints deltas and marks skipped metrics as skipped, not 0.00.- A no-retrieval adapter yields a report/summary that states the skip reason.
Goal: groundedness, citation, relevance scoring - calibrated, cached, budgeted.
Scope:
- Judge client: provider-agnostic (OpenRouter, Ollama, OpenAI, Anthropic);
temperature 0; structured JSON responses validated by Pydantic with one repair
retry then
judge_error(RW-7); per-call timeout + tenacity retries; raw output persisted verbatim post-redaction (spec §14, RW-18). - Judge cache: SQLite-backed, keyed
(model, prompt_hash, canonical_input_hash); hit/miss stats printed per run;--no-cacheflag (RW-3, RW-6). - Prompts: versioned files in
judge/prompts/; content hash recorded per run; mixed-judge comparability guard incompare/gate(RW-14). - Metrics:
generation.groundedness(claim decomposition → per-claim verdicts injudge_raw_json; zero-claim answers →not_applicable, RW-9);generation.citation_validity(deterministic; duplicate-ID semantics defined);generation.citation_support;generation.answer_relevance;generation.completeness(only whenexpected_answerpresent, else skipped). - Cost: provider-reported usage preferred, tokenizer estimate fallback; budget
checked pre-call; graceful
aborted:budgetstop, exit 2 (RW-6); per-run cost in summary and report. - Calibration: ≥10 human-scored fixtures per judge prompt;
ragproof calibratereports exact and within-1-band agreement; agreement thresholds in config; CI job runs calibration whenjudge/prompts/**orjudge/fixtures/**change and fails below threshold (spec §7.4 - not cut). - Unit tests use recorded judge fixtures (no live calls in CI).
Acceptance:
- Full run on the example corpus separates planted good vs bad answers on groundedness.
- Malformed judge output → repair retry →
judge_errorcase; run completes; the error count is visible in summary, report, and JUnit. - Cache: re-running an unchanged run costs ~$0 and reproduces judge-metric scores exactly.
- Budget breach mid-run: partial results persisted, status
aborted:budget, exit 2. calibrateproduces an agreement report; CI calibration gate demonstrated on a deliberately bad prompt change.
Goal: users get a trustworthy eval set without hand-writing one.
Scope: corpus ingestion (TXT/MD in core; PDF/DOCX behind ragproof[ingest]
with clear missing-extra error, RW-12; per-file size cap; extraction-failure report
listing skipped files - never silent); chunk sampling with explicit seed; QA
synthesis with second-pass answerability verification (discard + count failures);
unanswerable synthesis verified by retrieval + judge pass; injection case generation
from the payload library (poisoned document variants registered with expected
non-compliance markers); JSONL review file; ragproof freeze computing
corpus_hash + dataset hash via canonical JSON (RW-8); frozen datasets verified on
load, mutation refused; generation metadata (models, seeds, prompt hashes, discard
counts) embedded in the dataset record.
Acceptance:
generateon the tiny test corpus yields spot-checkable answerable QA cases; the discard rate is reported.- Frozen dataset: hash verified on load; edited file refuses to load with a clear message.
- Same seed + same corpus → identical sampling and case ordering (model output may vary; sampling may not).
- Ingesting a corrupt PDF skips the file with a report line, exit code unaffected.
Goal: the differentiating metric family, safely built.
Scope: payload library with 10+ types (instruction override, exfiltration-URL
to *.invalid, tone hijack, citation spoofing, system-prompt fishing, formatting
hijack, competitor-praise steering, fake-citation injection, link-bait, chain
instructions); per-payload deterministic compliance detectors (string/regex),
each with positive and negative fixture tests; payload safety lint (RW-17);
robustness.injection_resistance = 1 − compliance rate;
robustness.abstention (heuristic refusal detection + judge confirmation) on
unanswerable cases; robustness.overrefusal on answerable cases (RW-5);
fabrication-on-unanswerable given prominent weight in summaries per spec §7.3;
two example pipelines in examples/: deliberately vulnerable and guarded.
Acceptance:
- Vulnerable example scores low, guarded example scores high on injection resistance (asserted in an integration test).
- Abstention distinguishes "not in the documents" from fabrication; an always-refusing pipeline shows high abstention and high overrefusal.
- Every payload detector has fixture tests; payload safety lint passes and is in CI.
Goal: the product surface teams actually touch.
Scope:
- HTML report: single self-contained file, Chart.js vendored (RW-13); overview
scores, per-metric distributions, run comparison, worst-10 cases per metric with
question/answer/context/judge reasoning, skip/error counts, cost summary, dataset
- config + prompt hashes displayed (spec §14); no-network test in CI.
- Markdown summary for PR comments; JUnit XML (one test per metric;
execution errors as
<error>, threshold failures as<failure>, RW-4). ragproof gate: absolute + relative thresholds; per-metricnoise_floor; bootstrap 95% CIs on judge-backed metrics with in-noise warnings (RW-3);on_missing: fail|skipfor skipped metrics (RW-10); minimum-sample warning (n < 30); exit code contract enforced end to end.- GitHub Action (
action.ymlin-repo): install → run → gate → upload HTML artifact → sticky PR comment with the Markdown summary. - Dockerfile: slim multi-stage image, non-root user, pinned base digest.
--jsonoutput flag onrun/compare/gatefor tooling.
Acceptance:
- Gate exits 1 on threshold breach, 2 on execution error, with JUnit rendering correctly in GitHub Actions.
- HTML report opens from disk with zero network requests (automated check).
- The Action runs end to end in this repo's own CI against the example pipeline, uploading the report and commenting on a test PR.
- A judge-noise delta inside the noise floor does not fail the gate; a genuine regression does (both covered by integration tests).
Goal: proof, not just code.
Scope: DOC-007-AI and Legate Agent adapters (first-class citation/kb.search
mapping); run both, fix at least one real issue the scores expose, publish
before/after numbers in each repo's README; docs/metrics.md (exact computation of
every metric, incl. edge-case semantics from RW-9), docs/quickstart.md,
docs/adapters.md, docs/ci.md; PyPI 1.0.0 via Trusted Publishing; demo GIF
(degrading PR → red gate → fix → green gate); launch post draft; README leads with
case-study numbers and the GIF (spec §18).
Acceptance:
- Both case studies show real numbers and ≥1 real improvement found by RAGProof.
pip install ragproofgets 1.0.0; quickstart works verbatim on a clean machine (Windows and Linux verified).- README leads with GIF + case-study numbers.
| # | Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| R1 | Judge noise makes gates flaky → users remove the gate | High | High | RW-3: cache, CIs, noise floors, deterministic-first guidance |
| R2 | SQLite lock/corruption under concurrency | High | Medium | RW-1: WAL + single-writer queue + stress test |
| R3 | Eval cost surprises users | Medium | High | RW-6: pre-call budget checks, cache, cost always printed |
| R4 | Judge model deprecations break calibration | Medium | Medium | Calibration is per model+prompt; agreement re-verified in CI; provider-agnostic client |
| R5 | Windows breakage discovered late | Medium | Medium | RW-11: full OS matrix from Phase 0 |
| R6 | Payload library accused of shipping attack tooling | Low | High | RW-17: inert-payload lint enforced in CI; docs state intent |
| R7 | Scope creep into dashboard/SaaS territory | Medium | Medium | Spec §1 out-of-scope list honored; HTML report is the only viewer in v1 |
| R8 | Synthetic dataset quality poor → misleading scores | Medium | High | Answerability verification pass, discard-rate reporting, human review file before freeze |
| R9 | Name squatting between now and repo creation | Low | Medium | Phase 0 creates repo + placeholder PyPI release immediately |
| Milestone | Phases | Cumulative estimate | You can… |
|---|---|---|---|
| M1 Skeleton | P0 | ~2 days | install it, CI green |
| M2 It runs | P1 | ~6 days | evaluate a real pipeline, crash-safe |
| M3 It measures | P2–P3 | ~13 days | trust retrieval + generation scores, calibrated |
| M4 It generates | P4–P5 | ~18 days | build datasets, break pipelines on purpose |
| M5 It gates | P6 | ~22 days | block a bad PR automatically |
| M6 It's proven | P7 | ~27 days | show real before/after numbers publicly |
Definition of done per feature: spec Section 16 applies unchanged.