From 58463486f1c34476408bed14cfcf4f46f64f83f3 Mon Sep 17 00:00:00 2001 From: Oluwafemi Adadayo Date: Wed, 15 Apr 2026 22:32:12 +0100 Subject: [PATCH 1/3] docs(v0.2.2): Cycle 2/3 verification evidence report (G2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G2's deliverable is proof — not claims — that v0.2.2's code actually closes every Cycle 2 / Cycle 3 finding attributed to RAG-Forge. This file is the artifact. One section per finding with: - what the original audit saw (raw error signatures) - why earlier cycles could not verify the fix (masking) - what v0.2.2 changes to close the gap - passing test output naming each test by ID Covers: Cycle 2 #4 — OpenAIEmbeddings.embed_query AttributeError (FIXED) Cycle 2 #5 — max_tokens overflow on long responses (FIXED) Cycle 2 #6 — silent 0.0 coercion (FULLY FIXED) Cycle 3 C3-2 — RagForgeRagasLLM.generate missing (FIXED) Cycle 3 C3-5 — stale __version__ (FIXED) Closes with a falsifiability section naming the concrete error signatures that, if they reappear in a Cycle 4 run against real PearMedica telemetry, would falsify the report and justify holding the release. The release bar is "Cycle 4 produces non-empty metrics OR skip records with real error signatures — not empty both." Combined test suite run shows 31/31 passing on the rebased G1+G3+G5 branch. --- .../v0.2.2-verification-report.md | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 docs/release-notes/v0.2.2-verification-report.md diff --git a/docs/release-notes/v0.2.2-verification-report.md b/docs/release-notes/v0.2.2-verification-report.md new file mode 100644 index 0000000..98a15da --- /dev/null +++ b/docs/release-notes/v0.2.2-verification-report.md @@ -0,0 +1,225 @@ +# RAG-Forge v0.2.2 — Cycle 2 / Cycle 3 Verification Report + +**Date:** 2026-04-15 +**Release being verified:** v0.2.2 (unreleased, combined state of G1 + G3 + G5 branches) +**Test environment:** `C:\Users\halle\Downloads\RAGforge\.venv`, Python 3.11.15, pytest 9.0.3 +**ragas version under test:** 0.4.3 (installed via `rag-forge-evaluator[ragas]`) +**Baseline being verified against:** v0.2.1 (shipped 2026-04-14, audited by PearMedica Cycle 3) + +This report is the evidence artifact for Workstream G2 in the v0.2.2 release plan. It is the bridge between "the plan says v0.2.2 fixes X" and "here is proof that v0.2.2 actually fixed X." + +## Cycle 2 Finding #4 — `OpenAIEmbeddings.embed_query` AttributeError + +**Verdict: VERIFIED FIXED.** + +### What Cycle 2 saw + +Cycle 2 (2026-04-14, v0.1.3) observed this exception 2x in the RAGAS run against the PearMedica golden set: + +``` +AttributeError: 'OpenAIEmbeddings' object has no attribute 'embed_query' +Exception raised in Job[41], Job[44], Job[45] +``` + +Root cause was that ragas 0.4.x's `OpenAIEmbeddings` shape dropped the `embed_query` method in favor of a new `Embeddings` interface, and `rag-forge-evaluator/engines/ragas_evaluator.py` was still calling `.embed_query()` on it. + +### What Cycle 3 could not verify + +Cycle 3 (2026-04-15, v0.2.1) could not verify the fix because v0.2.1's entirely separate C3-2 bug (`RagForgeRagasLLM.generate` missing) crashed the engine before any embeddings code was reached. The RAGAS pass produced 0/48 scored samples with 6 seconds wall time — no network round-trip, no embeddings call. + +### What v0.2.2 verifies + +Two layers of evidence: + +1. **Contract test** (`tests/test_ragas_adapters_contract.py::test_embeddings_wrapper_declares_every_base_class_public_method`) iterates every public method on `ragas.embeddings.base.BaseRagasEmbeddings` and asserts `RagForgeRagasEmbeddings` declares a callable of the same name. Methods enumerated at run time on the installed ragas 0.4.3: `aembed_documents`, `aembed_query`, `embed_documents`, `embed_query`, `embed_text`, `embed_texts`, `set_run_config`. All seven exist on our wrapper after v0.2.2's G1 changes. +2. **End-to-end smoke** (`tests/test_ragas_adapters_e2e.py::test_ragas_evaluate_never_raises_attribute_error_on_wrapper`) runs `ragas.evaluate()` on a 1-sample dataset passing `RagForgeRagasEmbeddings(provider="mock")`, then checks that no `AttributeError` referencing our wrapper classes was raised. The test completes with the stub judge without hitting any embeddings-related AttributeError. + +### Passing test output + +``` +tests/test_ragas_adapters_contract.py::test_embeddings_wrapper_declares_every_base_class_public_method PASSED +tests/test_ragas_adapters_contract.py::test_llm_wrapper_declares_every_base_class_public_method PASSED +tests/test_ragas_adapters_e2e.py::test_ragas_evaluate_never_raises_attribute_error_on_wrapper PASSED +``` + +--- + +## Cycle 2 Finding #5 — `max_tokens` overflow on long structured clinical responses + +**Verdict: VERIFIED FIXED.** + +### What Cycle 2 saw + +Cycle 2 observed 4x `finish_reason='length'` and 3x `InstructorRetryException` during the RAGAS faithfulness statement-extraction pass: + +``` +ChatCompletion(... finish_reason='length', ... + usage=CompletionUsage(completion_tokens=3072 / 2048 / 1024 ... )) +InstructorRetryException: The output is incomplete due to a max_tokens length limit. +``` + +Root cause was that PearMedica's structured clinical responses (`conditions`, `triage`, `differential_diagnoses`, `next_question`, `red_flags`, `treatment_guidance`) produce 20-50 extractable statements per case, and `gpt-4o-mini`'s default `max_tokens` truncated the judge's statement-extraction output mid-response. + +### What v0.2.0 changed + +v0.2.0 raised `ClaudeJudge` default `max_tokens` from 1024 to 4096. The adapter now also honors `AuditConfig.ragas_max_tokens` (default 8192). Source: the existing v0.2.0 release notes. + +### What Cycle 3 could not verify + +Same masking problem as Finding #4: v0.2.1's C3-2 crash prevented RAGAS from reaching the statement-extraction step at all. + +### What v0.2.2 verifies + +The v0.2.2 test suite includes a dedicated long-structured-response regression test that reads the Cycle 2 fixture (`tests/fixtures/ragas_regression/cycle-2-excerpt.jsonl`, 3 samples from the real audit with responses sanitized to remove PHI) and asserts: + +1. The response is long enough to trigger Finding #5 (`> 50 words per response, producing 20+ extractable statements`). +2. No skip record in the result has an `exception_type` of `"InstructorRetryException"`. +3. No skip record has a `reason` containing `"finish_reason='length'"`. + +### Passing test output + +``` +tests/test_cycle2_regression.py::test_cycle2_fixture_handles_long_structured_responses PASSED +tests/test_cycle2_regression.py::test_cycle2_fixture_does_not_reproduce_v013_failures PASSED +tests/test_cycle2_regression.py::test_cycle2_fixture_exceptions_produce_skip_records_not_silent_zeros PASSED +tests/test_cycle2_regression.py::test_cycle2_fixture_no_openaiembeddings_attribute_error PASSED +``` + +--- + +## Cycle 2 Finding #6 — silent 0.0 coercion + +**Verdict: PARTIALLY FIXED in v0.2.0, FULLY FIXED in v0.2.2.** + +### What Cycle 2 saw + +Cycle 2 reported `"Scored: 48, Overall: 0.0000"` — every metric at 0.0 with `Skipped: 0`. v0.1.3 caught RAGAS exceptions at a low level in `ragas_evaluator.py` and silently coerced them into 0.0 "scored" entries, double-counting broken infrastructure as "everything scored zero." + +### What v0.2.0 fixed + +v0.2.0 replaced the 0.0 fallback with `SkipRecord` tracking. The `skipped_samples` list on `EvaluationResult` is now populated, and skip records surface in both the JSON and HTML/PDF reports. + +### What Cycle 3 still saw + +Cycle 3 reported `"But Skipped: 0 is still wrong — the 48 exceptions should flow into the skipped counter, not disappear."` Two residual gaps: + +1. The `skipped_evaluations` integer counter on `EvaluationResult` was never populated by the RAGAS engine — only the `skipped_samples` detail list. The report's top-level "Skipped: N" line reads from the integer counter and reported `0` even when every job had crashed. +2. Individual-metric extraction failures produced one aggregate `SkipRecord` per metric name (`sample_id=""`) instead of fanning out per (sample, metric) pair. A 12-sample x 4-metric run that failed entirely produced 4 skip records instead of 48. + +### What v0.2.2 fixes + +Workstream G3: + +1. `skipped_evaluations` is now set to `len(skipped_samples)` on both return sites in `RagasEvaluator.evaluate()`. A new regression test (`test_skipped_evaluations_counter_matches_skip_record_length`) asserts the two can never drift apart silently again. +2. Skip-record creation is extracted into `_fan_out_skip_records()` which produces one record per (sample, metric) pair with the real sample_id attached. A new regression test (`test_whole_batch_crash_fans_out_to_every_sample_metric_pair`) asserts a 3-sample x 4-metric run produces exactly 12 skip records with real sample IDs. +3. Reason strings are truncated to 400 chars with trailing ellipsis (`test_skip_reason_truncated_to_400_chars`). + +### Passing test output + +``` +tests/test_ragas_skip_counter.py::test_skipped_evaluations_counter_matches_skip_record_length PASSED +tests/test_ragas_skip_counter.py::test_whole_batch_crash_fans_out_to_every_sample_metric_pair PASSED +tests/test_ragas_skip_counter.py::test_skip_reason_truncated_to_400_chars PASSED +tests/test_ragas_integration.py::test_ragas_whole_batch_crash_captured_as_skips PASSED +``` + +--- + +## Cycle 3 Finding C3-2 — `RagForgeRagasLLM.generate` missing + +**Verdict: VERIFIED FIXED.** + +### What Cycle 3 saw + +``` +Exception raised in Job[0]: AttributeError('RagForgeRagasLLM' object has no attribute 'generate') +Exception raised in Job[1]: AttributeError('RagForgeRagasLLM' object has no attribute 'generate') +... (repeats identically for Job[0] through Job[47], all 48 jobs) +Evaluating: 100%|##########| 48/48 [00:00<00:00, 1402.31it/s] +``` + +6-second wall time, 0 network round-trips, 0/48 scored. The generate method wasn't on our duck-typed wrapper because the adapter's module docstring incorrectly claimed `"ragas only calls generate_text / agenerate_text / model_name"`. + +### What v0.2.2 fixes + +v0.2.2's G1 workstream adds duck-typed shims for every public method on `ragas.llms.base.BaseRagasLLM`: + +- `async def generate(prompt, n, temperature, stop, callbacks) -> LLMResult` +- `def is_finished(response) -> bool` +- `def get_temperature(n) -> float` +- `def set_run_config(run_config) -> None` +- `run_config: Any = None` attribute + +Plus the contract test that asserts every public method on the real `BaseRagasLLM` is declared on our wrapper, so the next ragas release that grows a new method fails in CI instead of in a user audit. + +### Passing test output + +``` +tests/test_ragas_adapters_contract.py::test_llm_wrapper_declares_every_base_class_public_method PASSED +tests/test_ragas_adapters_contract.py::test_llm_wrapper_generate_is_async_and_callable_on_instance PASSED +tests/test_ragas_adapters_contract.py::test_llm_wrapper_is_finished_returns_true_by_default PASSED +tests/test_ragas_adapters_contract.py::test_llm_wrapper_get_temperature_matches_base_convention PASSED +tests/test_ragas_adapters_contract.py::test_llm_wrapper_set_run_config_stores_the_value PASSED +tests/test_ragas_adapters.py::test_wrapper_async_generate_forwards_to_agenerate_text PASSED +tests/test_ragas_adapters.py::test_wrapper_async_generate_resolves_default_temperature_when_none PASSED +tests/test_ragas_adapters_e2e.py::test_ragas_evaluate_never_raises_attribute_error_on_wrapper PASSED +``` + +--- + +## Cycle 3 Finding C3-5 — stale `__version__` constant + +**Verdict: VERIFIED FIXED.** + +### What Cycle 3 saw + +``` +pip show rag-forge-evaluator → 0.2.1 +import rag_forge_evaluator; rag_forge_evaluator.__version__ → "0.1.0" +``` + +### What v0.2.2 fixes + +v0.2.2's G5 workstream: + +1. Bumps `rag_forge_core.__version__`, `rag_forge_evaluator.__version__`, and `rag_forge_observability.__version__` to match their respective `pyproject.toml` versions. +2. Adds `__version__` to `rag_forge_observability.__init__.py` (was missing entirely). +3. Adds `tests/test_version_drift.py`, a parametrized CI test that reads every Python package's `pyproject.toml` and `__init__.py` and asserts the two versions match. Any future release that bumps one half but forgets the other fails fast in CI. + +### Passing test output + +``` +tests/test_version_drift.py::test_package_version_matches_pyproject[core-rag_forge_core] PASSED +tests/test_version_drift.py::test_package_version_matches_pyproject[evaluator-rag_forge_evaluator] PASSED +tests/test_version_drift.py::test_package_version_matches_pyproject[observability-rag_forge_observability] PASSED +``` + +--- + +## Combined suite run + +Full test run on the combined G1 + G3 + G5 state (`fix/v0.2.2-g3-ragas-skip-counter` branch, which is rebased onto G1 and contains all three G1/G3/G5 commit ranges): + +``` +uv run pytest tests/test_ragas_adapters.py tests/test_ragas_adapters_contract.py \ + tests/test_ragas_adapters_e2e.py tests/test_ragas_integration.py \ + tests/test_cycle2_regression.py tests/test_ragas_skip_counter.py \ + tests/test_version_drift.py -v + +======================= 31 passed, 90 warnings in 11.94s ======================= +``` + +All verification tests green. 0 failures. The three pre-existing test failures in `test_cycle2_regression.py` and `test_evaluator_factory.py` that v0.2.1 left behind on `main` are now either passing (cycle2_regression, after the G3-era test reframing) or correctly skipped (evaluator_factory's ragas-not-installed test). + +## What would still falsify this report + +This report is an artifact of the rag-forge repository's own test suite against sanitized fixtures. It proves the v0.2.2 code no longer crashes on any of the signatures Cycles 2 and 3 documented. It does **not** prove the v0.2.2 code would produce higher RAGAS scores on the real PearMedica Cycle 4 run — that requires running the evaluator against live telemetry with a real judge, which is outside the scope of this release's tests. + +If a Cycle 4 run against the PearMedica `eval/telemetry/run-2026-04-15.jsonl` fixture under v0.2.2 + a real `claude-sonnet-4-6` judge + Voyage embeddings shows any of these signatures return: + +- `AttributeError` mentioning `RagForgeRagasLLM`, `RagForgeRagasEmbeddings`, `OpenAIEmbeddings`, or `embed_query` +- `InstructorRetryException` or `finish_reason='length'` +- `skipped_evaluations: 0` while every score is 0.0 +- A metric with `sample_id=""` in any skip record + +then this report is falsified and v0.2.2 did not do its job. The release-ready bar is "Cycle 4 re-runs the same golden set under v0.2.2 and produces non-empty RAGAS metrics or skip records with real error signatures, not empty both." From 16232fdf71b7cb1c2ec0020e2ac0847ff1d9e89d Mon Sep 17 00:00:00 2001 From: Oluwafemi Adadayo Date: Wed, 15 Apr 2026 23:22:33 +0100 Subject: [PATCH 2/3] docs(v0.2.2): markdown polish on G2 verification report Addresses CodeRabbit findings on PR #40. - Added language specifiers to every fenced code block (10 blocks, all previously bare ```). text for error traces and pytest output, bash for the shell command block. Silences markdownlint MD040. - Reformatted the inline 7-method enumeration in the Finding #4 section as a proper bulleted list. The methods now render one per line instead of as a comma-separated prose run, which was hard to scan. --- .../v0.2.2-verification-report.md | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/docs/release-notes/v0.2.2-verification-report.md b/docs/release-notes/v0.2.2-verification-report.md index 98a15da..ec13473 100644 --- a/docs/release-notes/v0.2.2-verification-report.md +++ b/docs/release-notes/v0.2.2-verification-report.md @@ -16,7 +16,7 @@ This report is the evidence artifact for Workstream G2 in the v0.2.2 release pla Cycle 2 (2026-04-14, v0.1.3) observed this exception 2x in the RAGAS run against the PearMedica golden set: -``` +```text AttributeError: 'OpenAIEmbeddings' object has no attribute 'embed_query' Exception raised in Job[41], Job[44], Job[45] ``` @@ -31,12 +31,22 @@ Cycle 3 (2026-04-15, v0.2.1) could not verify the fix because v0.2.1's entirely Two layers of evidence: -1. **Contract test** (`tests/test_ragas_adapters_contract.py::test_embeddings_wrapper_declares_every_base_class_public_method`) iterates every public method on `ragas.embeddings.base.BaseRagasEmbeddings` and asserts `RagForgeRagasEmbeddings` declares a callable of the same name. Methods enumerated at run time on the installed ragas 0.4.3: `aembed_documents`, `aembed_query`, `embed_documents`, `embed_query`, `embed_text`, `embed_texts`, `set_run_config`. All seven exist on our wrapper after v0.2.2's G1 changes. +1. **Contract test** (`tests/test_ragas_adapters_contract.py::test_embeddings_wrapper_declares_every_base_class_public_method`) iterates every public method on `ragas.embeddings.base.BaseRagasEmbeddings` and asserts `RagForgeRagasEmbeddings` declares a callable of the same name. Methods enumerated at run time on the installed ragas 0.4.3: + + - `aembed_documents` + - `aembed_query` + - `embed_documents` + - `embed_query` + - `embed_text` + - `embed_texts` + - `set_run_config` + + All seven exist on our wrapper after v0.2.2's G1 changes. 2. **End-to-end smoke** (`tests/test_ragas_adapters_e2e.py::test_ragas_evaluate_never_raises_attribute_error_on_wrapper`) runs `ragas.evaluate()` on a 1-sample dataset passing `RagForgeRagasEmbeddings(provider="mock")`, then checks that no `AttributeError` referencing our wrapper classes was raised. The test completes with the stub judge without hitting any embeddings-related AttributeError. ### Passing test output -``` +```text tests/test_ragas_adapters_contract.py::test_embeddings_wrapper_declares_every_base_class_public_method PASSED tests/test_ragas_adapters_contract.py::test_llm_wrapper_declares_every_base_class_public_method PASSED tests/test_ragas_adapters_e2e.py::test_ragas_evaluate_never_raises_attribute_error_on_wrapper PASSED @@ -52,7 +62,7 @@ tests/test_ragas_adapters_e2e.py::test_ragas_evaluate_never_raises_attribute_err Cycle 2 observed 4x `finish_reason='length'` and 3x `InstructorRetryException` during the RAGAS faithfulness statement-extraction pass: -``` +```text ChatCompletion(... finish_reason='length', ... usage=CompletionUsage(completion_tokens=3072 / 2048 / 1024 ... )) InstructorRetryException: The output is incomplete due to a max_tokens length limit. @@ -78,7 +88,7 @@ The v0.2.2 test suite includes a dedicated long-structured-response regression t ### Passing test output -``` +```text tests/test_cycle2_regression.py::test_cycle2_fixture_handles_long_structured_responses PASSED tests/test_cycle2_regression.py::test_cycle2_fixture_does_not_reproduce_v013_failures PASSED tests/test_cycle2_regression.py::test_cycle2_fixture_exceptions_produce_skip_records_not_silent_zeros PASSED @@ -116,7 +126,7 @@ Workstream G3: ### Passing test output -``` +```text tests/test_ragas_skip_counter.py::test_skipped_evaluations_counter_matches_skip_record_length PASSED tests/test_ragas_skip_counter.py::test_whole_batch_crash_fans_out_to_every_sample_metric_pair PASSED tests/test_ragas_skip_counter.py::test_skip_reason_truncated_to_400_chars PASSED @@ -131,7 +141,7 @@ tests/test_ragas_integration.py::test_ragas_whole_batch_crash_captured_as_skips ### What Cycle 3 saw -``` +```text Exception raised in Job[0]: AttributeError('RagForgeRagasLLM' object has no attribute 'generate') Exception raised in Job[1]: AttributeError('RagForgeRagasLLM' object has no attribute 'generate') ... (repeats identically for Job[0] through Job[47], all 48 jobs) @@ -154,7 +164,7 @@ Plus the contract test that asserts every public method on the real `BaseRagasLL ### Passing test output -``` +```text tests/test_ragas_adapters_contract.py::test_llm_wrapper_declares_every_base_class_public_method PASSED tests/test_ragas_adapters_contract.py::test_llm_wrapper_generate_is_async_and_callable_on_instance PASSED tests/test_ragas_adapters_contract.py::test_llm_wrapper_is_finished_returns_true_by_default PASSED @@ -173,7 +183,7 @@ tests/test_ragas_adapters_e2e.py::test_ragas_evaluate_never_raises_attribute_err ### What Cycle 3 saw -``` +```text pip show rag-forge-evaluator → 0.2.1 import rag_forge_evaluator; rag_forge_evaluator.__version__ → "0.1.0" ``` @@ -188,7 +198,7 @@ v0.2.2's G5 workstream: ### Passing test output -``` +```text tests/test_version_drift.py::test_package_version_matches_pyproject[core-rag_forge_core] PASSED tests/test_version_drift.py::test_package_version_matches_pyproject[evaluator-rag_forge_evaluator] PASSED tests/test_version_drift.py::test_package_version_matches_pyproject[observability-rag_forge_observability] PASSED @@ -200,7 +210,7 @@ tests/test_version_drift.py::test_package_version_matches_pyproject[observabilit Full test run on the combined G1 + G3 + G5 state (`fix/v0.2.2-g3-ragas-skip-counter` branch, which is rebased onto G1 and contains all three G1/G3/G5 commit ranges): -``` +```bash uv run pytest tests/test_ragas_adapters.py tests/test_ragas_adapters_contract.py \ tests/test_ragas_adapters_e2e.py tests/test_ragas_integration.py \ tests/test_cycle2_regression.py tests/test_ragas_skip_counter.py \ From a839d2b33c6188b917d07827adc96c5624bc015a Mon Sep 17 00:00:00 2001 From: Oluwafemi Adadayo Date: Wed, 15 Apr 2026 23:47:11 +0100 Subject: [PATCH 3/3] docs(v0.2.2): clarify version-fix scoping + US spelling on G2 report CodeRabbit on PR #40 round 2 raised two findings on the C3-5 section: 1. The evidence snippet showing __version__ = "0.1.0" vs pyproject version = "0.2.1" could be read as a current-state claim rather than a pre-fix snapshot. Added an explicit "pre-v0.2.2 state" header and a paragraph scoping the snippet so nobody reads it as the repo's current state. 2. "parametrized" -> "parameterized" for US spelling consistency. Also updated the description to reflect G5's round-2 move to auto-discovery so the report matches the actual test impl. --- docs/release-notes/v0.2.2-verification-report.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/v0.2.2-verification-report.md b/docs/release-notes/v0.2.2-verification-report.md index ec13473..adbab05 100644 --- a/docs/release-notes/v0.2.2-verification-report.md +++ b/docs/release-notes/v0.2.2-verification-report.md @@ -181,7 +181,9 @@ tests/test_ragas_adapters_e2e.py::test_ragas_evaluate_never_raises_attribute_err **Verdict: VERIFIED FIXED.** -### What Cycle 3 saw +### What Cycle 3 saw (pre-v0.2.2 state) + +The snippet below is **the state v0.2.2 inherited from v0.2.1**, captured before the G5 workstream ran. It is documented here as evidence of the bug, not as the current state of the repo. After G5 shipped, both sides report `0.2.1`; the subsequent release-prep PR will bump both sides to `0.2.2` in lockstep, enforced by the drift guard below. ```text pip show rag-forge-evaluator → 0.2.1 @@ -194,7 +196,7 @@ v0.2.2's G5 workstream: 1. Bumps `rag_forge_core.__version__`, `rag_forge_evaluator.__version__`, and `rag_forge_observability.__version__` to match their respective `pyproject.toml` versions. 2. Adds `__version__` to `rag_forge_observability.__init__.py` (was missing entirely). -3. Adds `tests/test_version_drift.py`, a parametrized CI test that reads every Python package's `pyproject.toml` and `__init__.py` and asserts the two versions match. Any future release that bumps one half but forgets the other fails fast in CI. +3. Adds `tests/test_version_drift.py`, a parameterized CI test that auto-discovers every Python package under `packages/` and asserts each one's `pyproject.toml` and `__init__.py` versions stay in lockstep. Any future release that bumps one half but forgets the other fails fast in CI. ### Passing test output