# CP-9 Eval Primitive Implementation Audit

> **JUNE-REFACTOR SPRINT COMPLETE (2026-04-28).** CP-4 → CP-9 all shipped on schedule. CP-9 added 344 new tests on top of the 1295 pre-CP-9 baseline; final pytest 1639 pass / 11 skip. Seven modalities live (image_t2i, video_i2v, audio_t2a, lipsync_post, eval_image_v1, eval_video_v1, eval_audio_v1). PanelOfJudges + score-based primary selection + retry-strategy substrate bridge all functional. Dispatch matrix `consultations/recoil/cp9-eval-spec/cp9_live_matrix.sh` is staged for JT to run manually with `GEMINI_API_KEY` in env (Phase 9 places the script). See § 8 for the sprint-completion summary.

**Generated:** 2026-04-28 (Phase 1 — audit only; ships zero code)
**Anchor tag (parent):** `pre-cp9-eval-primitive` → commit `e596c663866d719998c5252ff4de9205a4c145c4`
**Anchor tag (engine-memory subrepo):** `pre-cp9-eval-primitive` → commit `0f3893b24fdf75beb25212f2065792bddff118e6`
**Predecessor anchor:** `post-cp8-audio-lipsync` (parent `48540d68`, engine-memory `0f3893b2`) — verified present on both repos.
**Stage:** Phase 1 of 9. Audit + vendor docs pass. NO code changes shipped.
**Sprint-completion stage:** Phase 8 (this revision) finalizes § 8 with shipped-surface metrics. Phase 9 will fill the verification-log placeholder + tag `post-cp9-eval-primitive` + `june-refactor-complete`.

---

## 0. Sprint context

CP-9 is the **last** checkpoint of the june-refactor sprint. After CP-9 ships:

- All four CP-4 modalities are LIVE (image_t2i, video_i2v, audio_t2a, lipsync_post — last two became real in CP-8).
- `GenerationReceipt.eval_scores` (reserved-but-empty in CP-5) becomes filled by hooks.
- `Beat.select_primary("score")` (raises `NotImplementedError` since CP-7) becomes functional.
- The legacy `recoil/core/critic.py` Gemini Flash visual critic gets a thin `EvalNode` adapter — no production rewire.
- The `StrategyEngine` retry-strategy substrate (`detect_failure_mode` in `pipeline/orchestrator/strategy_registry.py`) gets a `from_score_card` factory — substrate only; runtime call site untouched.

After CP-9, Console v2 consult unblocks and retry-strategy iteration unpauses on the `PanelOfJudges` substrate.

A **post-CP-9 sprint completion summary** is reserved at the bottom of this doc (filled in Phase 8).

---

## 1. Existing eval surface (verified 2026-04-28 against HEAD `e596c663`)

### 1a. Frozen / reserved hooks (not yet populated)

| Surface | Location | State at `pre-cp9-eval-primitive` |
|---|---|---|
| `GenerationReceipt.eval_scores` field | `recoil/pipeline/core/receipts.py:60` | `dict[str, Any] = field(default_factory=dict)` — reserved by CP-5; always `{}` today. |
| `Beat.select_primary(strategy="score")` | `recoil/pipeline/core/take.py:402-405` | Raises `NotImplementedError("CP-9 deliverable: score-based primary selection")`. |
| `Take.aggregate_score` field | `recoil/pipeline/core/take.py` (does NOT exist yet) | The string only appears in a docstring comment at `take.py:9` referring to CP-9. |
| `Workflow.run` hooks | `recoil/pipeline/core/workflow.py:313-321` (`_workflow_run`) | `pre_step`, `post_step`, `on_failure` already accepted as Optional callables; CP-9 plugs `attach_eval_hooks` into them. |
| `MODALITY_EVAL_*` constants | `recoil/pipeline/core/registry.py:50-52` | Comment-only forward-look (`#   "eval_image_v1", "eval_video_v1"`). NOT yet defined as Python constants. CP-9 promotes them in Phase 3. |

### 1b. Legacy Gemini critics in the tree (Phase 6/7 wraps; does NOT modify)

#### `recoil/core/critic.py` — the real CriticLoop base (386 lines)

- **What it is:** `CriticLoop` ABC + `CriticResult` dataclass + `Dimension` dataclass + `Outcome` enum + `Severity` enum + `FailureMode` enum (33 modes).
- **NOT** a Gemini Flash visual critic itself — it is the BASE class every concrete critic in the tree extends. Concrete critics (NBP keyframe critic, video frame critic, etc.) live in `recoil/pipeline/lib/critics/`.
- **Active production callers (importers):** 30 files identified by grep (see `cp9_phase1_eval_callers.json["CriticLoop"]`):
  - `recoil/pipeline/lib/run_shot.py` (orchestrator hot path)
  - `recoil/pipeline/lib/jit_prompt.py`
  - `recoil/pipeline/lib/keyframe_context.py`
  - 8 concrete critics under `recoil/pipeline/lib/critics/` (start_frame, video_frame, video_enhancement, plan_pass, turnaround, ref_image, keyframe_rewrite + tests)
  - `recoil/pipeline/tools/recheck_pending_qc.py`, `calibrate_models.py`
  - 14 test files
- **`recoil/pipeline/lib/critic.py`** is a 7-line re-export proxy: `from core.critic import *`. It is what the spec sloppily called "the legacy critic" — the real body is at `recoil/core/critic.py`.
- **CP-9 integration plan (Phase 7):** APPEND a `LegacyFlashCriticEvalNode` adapter class at the BOTTOM of `recoil/core/critic.py`. Wraps an existing critic instance behind the `EvalNode` Protocol. **NO modification to existing body, NO modification to any caller.** `pipeline/lib/critic.py` proxy stays byte-stable.

> **Spec deviation noted:** BUILD_SPEC § Critical context references `recoil/pipeline/lib/critic.py` as "~200 lines" + "hardcoded `gemini-2.0-flash` model". That path is the 7-line proxy; the actual ~386-line CriticLoop body is at `recoil/core/critic.py` and contains NO hardcoded `gemini-2.0-flash` reference (the actual `gemini-2.0-flash` callers are spread across `pipeline/tools/generate_location_refs.py`, `pipeline/lib/prompt_soften.py`, `pipeline/lib/spatial_compliance.py`, `tools/visual_qc.py`, `tools/cost_tracker.py` — none of which are wrapped by CP-9). Phase 7's adapter target therefore widens: it adapts the `CriticLoop` ABC, not a single Gemini Flash subclass. Adapter still ships at the BOTTOM of `core/critic.py` (per spec rule "no body modification"); the wrapped-class id is parameterized.

#### `recoil/pipeline/lib/visual_validation.py` — DOES NOT EXIST

- BUILD_SPEC § Critical context lists this as a "pre-existing Gemini Flash gate (Phase 1 audit decides whether this is in or out of scope; default OUT)". 
- Grep confirms: 0 Python files matching `**/visual_validation.py` anywhere in `recoil/`. Test+spec+consult-doc references only (`pipeline/tests/orchestrator/test_validation_hooks.py` — different file; `pipeline/BUILD_SPEC_VISUAL_VALIDATION.md` — old build spec; `pipeline/consultations/client-side-steprunner/*.md`).
- **Status:** OUT OF SCOPE for CP-9 (matches the spec default). No further action. If JT later wants validation-hook coverage, that is a CP-N+ task.

#### `recoil/pipeline/orchestrator/strategy_registry.py` — StrategyEngine substrate (1521 lines)

- Contains `RetryStrategyName` (25 strategies), `StrategyDiff`, `StrategyEntry`, `STRATEGY_REGISTRY`, `ESCALATION_CHAINS`, `detect_failure_mode()`, `StrategyEngine` class.
- Imports `FailureMode` from `recoil/core/critic.py` (line 54).
- **`detect_failure_mode(pass_result, coverage_pass) -> tuple[FailureMode, float]`** at line 1284. Tier-0 (API errors) + Tier-1 (gate scores + cut count + duration mismatch) + Tier-2 (Opus 4.7 vision classifier on first frame).
- **Production callers:** TWO sites in `recoil/pipeline/orchestrator/production_loop.py`:
  - line 165: `from orchestrator.strategy_registry import StrategyEngine`
  - lines 1087-1111: `from orchestrator.strategy_registry import (..., detect_failure_mode, ...)` then `mode, conf = detect_failure_mode(pass_result, current_pass)`
- **CP-9 integration plan (Phase 7):** ADD `from_score_card(score_card: dict) -> FailureMode` (or sibling free function at module level) per SYNTHESIS Q4 LOCKED. **NO modification to `detect_failure_mode` body or signature. NO modification to `production_loop.py`.** Substrate-only; runtime switchover gated on JT review.

### 1c. Greenfield surfaces (CP-9 builds from scratch)

| Surface | Hits in tree today |
|---|---|
| `EvalNode` Protocol | 0 |
| `EvalResult` dataclass | 0 |
| `EvalContext` dataclass | 0 |
| `PanelOfJudges` class | 0 (only 2 docstring forward-references in receipts.py + workflow.py) |
| `EvalRegistry` | 0 |
| `attach_eval_hooks` utility | 0 |
| `gemini_vision` provider adapter | 0 |
| `eval_image_v1` modality string | 0 (only the comment at `registry.py:52`) |
| `eval_video_v1` modality string | 0 (only the comment at `registry.py:52`) |
| `eval_audio_v1` modality string | 0 |
| `MODALITY_EVAL_*` constants | 0 |
| `from_score_card` substrate | 0 |

Full caller catalog: `consultations/recoil/cp9-eval-spec/cp9_phase1_eval_callers.json`.

---

## 2. Frozen surfaces re-verified (CP-4 → CP-8, all byte-stable at `pre-cp9-eval-primitive`)

| CP | Surface | File | Verified state |
|---|---|---|---|
| CP-4 | `MODALITY_*` constants | `recoil/pipeline/core/registry.py:45-48` | 4 constants present: IMAGE_T2I, VIDEO_I2V, AUDIO_T2A, LIPSYNC_POST. `_RUNNERS`/`_FACTORIES` open registry pattern — `register_runner(modality_id, runner)` works for any string. CP-9 adds 3 new MODALITY_EVAL_* constants in Phase 3. |
| CP-4 | `RunResult` dataclass | `recoil/pipeline/core/registry.py:56-85` | 7 fields (id, modality, output_path, output_url, metadata, success, error). LOCKED — CP-9 does NOT touch shape. Eval results land in `metadata["eval_score"]`, etc. |
| CP-4 | `ModalityRunner` Protocol | `recoil/pipeline/core/registry.py:89-105` | Single `run(payload: dict) -> RunResult` method. CP-9 eval runners satisfy this same protocol. |
| CP-5 | `GenerationReceipt.eval_scores` | `recoil/pipeline/core/receipts.py:60` | `eval_scores: dict[str, Any] = field(default_factory=dict)` — reserved + frozen. CP-9 fills via hooks (mutates dict contents, NOT field reassignment). |
| CP-5 | `dispatch()` signature | `recoil/pipeline/core/dispatch.py` | LOCKED. CP-9 adds sibling `register_default_eval_runners(*, force=False)` — does NOT modify `dispatch()` or `register_default_runners`. |
| CP-6 | `Workflow.run` hooks | `recoil/pipeline/core/workflow.py:313-321` (`_workflow_run`) | `pre_step`, `post_step`, `on_failure: Optional[HookFn] = None`. Hook signature `(step, workflow) -> None`. CP-9's `attach_eval_hooks(workflow, panel)` returns three such callables. |
| CP-7 | `Beat.select_primary("score")` | `recoil/pipeline/core/take.py:402-405` | Raises `NotImplementedError("CP-9 deliverable: score-based primary selection")`. CP-9 Phase 5 replaces ONLY this branch body. `"first_success"` and `"manual"` branches preserved byte-stable. |
| CP-7 | `Take.aggregate_score` field | `recoil/pipeline/core/take.py` | Does NOT exist as a field today. CP-9 Phase 5 ADDS `aggregate_score: Optional[float] = None` (CP-7 hand-off explicitly permitted additive fields). |
| CP-8 | `AudioRunner` class | `recoil/pipeline/core/runners/audio_runner.py` (165 lines) | LIVE — wraps `execution.providers.elevenlabs.synthesize_speech`. Zero-arg constructor. CP-9 eval runners mirror this pattern. |
| CP-8 | `LipSyncPostProcessor` class | `recoil/pipeline/core/runners/lipsync_post.py` (149 lines) | LIVE — wraps `execution.providers.sync_so.lipsync_video`. Zero-arg constructor. |
| CP-8 | `runners/__init__.py` `_register_stubs_once` | `recoil/pipeline/core/runners/__init__.py:29-41` | Function name retains "stubs" wording (misleading post-CP-8 — registers LIVE runners now). Body registers `AudioRunner()` + `LipSyncPostProcessor()`. CP-9 does NOT rename — would touch CP-8 frozen surface unnecessarily. |

Validation gate baseline: `git diff pre-cp9-eval-primitive..HEAD` for each of those files MUST be empty at every Phase 2-9 entry. Phase 9 verification gate enforces.

---

## 3. Vendor decisions (full detail in `cp9_phase1_vendor_endpoints.md`)

### 3a. Gemini Vision API surface (LOCKED for Phase 2)

- **Endpoint:** `POST https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-pro-preview:generateContent`
- **Auth:** `x-goog-api-key: <GEMINI_API_KEY>` (with `GOOGLE_API_KEY` fallback per existing recoil convention)
- **Request body:** `{contents: [{role: "user", parts: [{text: "..."}, {inlineData: {mimeType, data}}]}], generationConfig: {temperature: 0.0, maxOutputTokens: 1024, responseMimeType: "application/json"}}`
- **Multimodal upload:** inline base64 if file <20 MB; Files API resumable upload (`POST /upload/v1beta/files`) otherwise
- **Response:** `candidates[0].content.parts[0].text` is JSON-parseable verdict; `usageMetadata.promptTokenCount` + `candidatesTokenCount` for cost compute
- **Errors:** 400/401/403/404 fail-fast; 429/500/503/504 + URLError retry 3x exp backoff (1s/2s/4s; 5s/10s/20s for 503)

### 3b. Model id deviation (NOTED, propagated)

- BUILD_SPEC § Headline + § Vendor decisions use bare `gemini-3.1-pro`.
- Live API id (per Google docs and existing recoil callers in 5+ files) is **`gemini-3.1-pro-preview`**.
- CP-9 uses `gemini-3.1-pro-preview` everywhere (model_profiles entry, provider_strategy mapping, adapter `model_id` default).

### 3c. `recoil/config/model_profiles.json` status — gemini-3.1-pro entry MISSING

| Existing Gemini entries (3) | What they cover |
|---|---|
| `gemini-3-pro-image-preview` | NBP (Nanobanana Pro) — image generation only, `cost_per_image: 0.134` |
| `gemini-3.1-flash-image-preview` | Flash 3.1 — image generation only, `cost_per_image: 0.039` |
| `gemini-2.5-flash-image` | Nanobanana Flash — image generation only, `cost_per_image: 0.039` |

- **`gemini-3.1-pro-preview` (text/multimodal LLM, NOT image gen) is NOT in `model_profiles.json` today.** All three existing entries are image-generation cost shapes (per-image pricing).
- **Phase 3 MUST add the entry** with `cost_per_1k_input_tokens` + `cost_per_1k_output_tokens` (or per-1M equivalents) for the eval cost compute. Locked rates from vendor doc (verified 2026-04-28 at https://ai.google.dev/gemini-api/docs/pricing):

  ```json
  {
    "gemini-3.1-pro-preview": {
      "provider": "google",
      "display_name": "Gemini 3.1 Pro Preview",
      "modality": "text_multimodal",
      "context_window": 1000000,
      "cost_per_1m_input_tokens_standard": 2.00,
      "cost_per_1m_output_tokens_standard": 12.00,
      "cost_per_1m_input_tokens_long_context": 4.00,
      "cost_per_1m_output_tokens_long_context": 18.00,
      "long_context_threshold_tokens": 200000,
      "supported_input_mime_types": ["image/png", "image/jpeg", "image/webp", "video/mp4", "video/webm", "audio/mp3", "audio/wav"],
      "api_pattern": "generative_language_v1beta",
      "auth_env_var": "GEMINI_API_KEY",
      "notes": "Eval primitive (CP-9). Multimodal scoring via PanelOfJudges."
    }
  }
  ```

  (Phase 3 writes the actual JSON with the project's preferred field-name conventions.)

### 3d. `recoil/config/provider_strategy.json` status — gemini_vision provider MISSING

- Existing Gemini-related entries map model strings (`nbp`, `flash`, `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`, `gemini-2.5-flash-image`) to provider key `"google"` with `primary_tier: "default"`.
- **There is no `gemini_vision` provider anywhere** and no `gemini-3.1-pro-preview` mapping today.
- **Phase 3 MUST add an entry** like:

  ```json
  "gemini-3.1-pro-preview": {
    "capability_exceptions": {},
    "primary": "gemini_vision",
    "primary_tier": "default"
  }
  ```

  The provider key `gemini_vision` is what the eval runners pass to `score_artifact()` resolution. Existing image-gen Gemini callers all use `"google"` — `gemini_vision` is the new provider name for the multimodal LLM scoring path.

---

## 4. Locked decisions (flagged for Phase 2/3 spec-review)

1. Eval LLM: `gemini-3.1-pro-preview` (single-model multimodal — image, video, audio scoring in one API).
2. PanelOfJudges aggregation: default `"median"`; `"mean"` named alternative; outliers (any judge ≥0.3 from median) flagged in `panel_warnings`.
3. Cost cap: PanelOfJudges hard-aborts when projected cost would exceed `cost_cap_usd`; partial scorecard returned with `panel_warnings=["cost_cap_aborted_at_judge_N"]`.
4. `Beat.select_primary("score")` tie-break: `take_index` ASC; score-less takes sort below scored; no-eval-at-all returns `None` (does NOT raise — parallels `first_success` semantics when nothing succeeded).
5. `on_failure` hook DOES run eval (with `reason="step failed"`) for diagnostic logging — JT default; flagged for spec-review since it doubles eval cost on failures.
6. Eval cost flows through `GenerationReceipt.provenance["eval_cost_usd"]`, NOT into `RunResult.metadata.cost_usd` (generation cost stays separate).
7. `EvalContext.scene_takes` field designed in dataclass per SYNTHESIS Locked Decision #10 but cross-take continuity scoring is NOT implemented in CP-9. Documented "// reserved for cross-take continuity critic, deferred CP-N".
8. Tournament / TournamentEliminator / CostGate / scene-continuity critic are explicitly DEFERRED (Locked Decision #9).
9. Retry-strategy substrate bridge ships as substrate (`from_score_card` factory) but `production_loop.py` is NOT rewired. Production switchover is a CP-N+ task gated on JT review.
10. Legacy `core/critic.py` gets a thin `LegacyFlashCriticEvalNode` adapter at the BOTTOM of the file — NO modification to existing body or any of its 30+ callers.

---

## 5. Phase plan summary (1-line per phase)

| Phase | Focus | Deliverables | Tag preceded by |
|---|---|---|---|
| 1 | **Audit + vendor docs (THIS PHASE)** | `eval-primitive-audit.md`, `cp9_phase1_eval_callers.json`, `cp9_phase1_vendor_endpoints.md` | `pre-cp9-eval-primitive` (placed pre-flight) |
| 2 | Gemini Vision provider adapter | `recoil/execution/providers/gemini_vision.py` (~360 lines), `EvalProviderResult`, exception tree, mocked-transport tests | — |
| 3 | `eval.py` core module + configs | `recoil/pipeline/core/eval.py` (~520 lines), MODALITY_EVAL_* constants, `model_profiles.json` + `provider_strategy.json` updates | — |
| 4 | Eval modality runners | 3 new runner files under `pipeline/core/runners/`, `register_default_eval_runners()` in `dispatch.py` | `pre-cp9-runners` |
| 5 | `Take.aggregate_score` + score-based primary | `take.py` additive field + method, `_beat_select_primary` "score" branch body | `pre-cp9-score-strategy` |
| 6 | Workflow + Take eval-hook integration | E2E test: dispatch → workflow.run with hooks → eval_scores populated → aggregate_score → primary | — |
| 7 | Legacy critic adapter + retry-strategy substrate | Append `LegacyFlashCriticEvalNode` to `core/critic.py`; add `from_score_card` to `strategy_registry.py` | `pre-cp9-critic-migration` |
| 8 | Documentation + audit doc finalize | This doc's § 8 sprint-completion summary, `recoil/CLAUDE.md` + `recoil/pipeline/CLAUDE.md` "EVAL PRIMITIVE (CP-9)" sections, data-contracts.md paragraph (JT-gated) | — |
| 9 | Verification + tags + hand-off | Full pytest pass (~1430+ tests), engine-memory diff empty, sidecar bytes stable, tag `post-cp9-eval-primitive` + `june-refactor-complete` | `post-cp9-eval-primitive`, `june-refactor-complete` |

---

## 6. Pre-flight baselines (captured by harness, referenced here)

### 6a. pytest collection baseline

- File: `consultations/recoil/cp9-eval-spec/cp9_phase1_pre_baseline.txt`
- Contents: `1303 tests collected in 0.26s` (covers `pipeline/tests/`, `pipeline/core/tests/`, `pipeline/lib/tests/`)
- Note: BUILD_SPEC headline cites 1295 tests post-CP-8. The pre-flight collect ran a wider suite spec (`pipeline/tests/ pipeline/core/tests/ pipeline/lib/tests/`) and reports 1303 — the delta (8) is harmless test discovery scope, not regression. CP-9 Phase 9 expects ~1430+ collected after the ~140 new test functions land.

### 6b. Sidecar prompt-hash baseline

- File: `consultations/recoil/cp9-eval-spec/cp9_pre_baseline_prompt_hashes.json`
- Contents: `{"captured_at": "2026-04-28T14:51:04Z", "anchor_tag": "pre-cp9-eval-primitive", "samples": []}` — **0 samples** because no project sidecar dirs (`projects/tartarus/output/sidecars/`, `projects/leviathan/output/sidecars/`, `projects/the-afterimage/output/sidecars/`) currently have JSON sidecars on disk.
- Phase 9 byte-stable check is still meaningful even with zero samples: it establishes that **no new sidecars contaminate the contract** during the build. The check at Phase 9 will re-run the same glob; if any sample appears with a hash mismatching pre-CP-9 contents it will fail. With zero samples on both sides, the check passes trivially — a clean baseline to anchor against. Aligns with CP-3 lock semantics ("`sidecar.provenance.prompt` byte-stability").

### 6c. Tag verification

- Parent `pre-cp9-eval-primitive` → `e596c663866d719998c5252ff4de9205a4c145c4` ✓
- Parent `post-cp8-audio-lipsync` → `48540d68e8d7bc0d4fe6b6e05ad14fd03f004113` ✓
- Engine-memory `pre-cp9-eval-primitive` → `0f3893b24fdf75beb25212f2065792bddff118e6` ✓
- Engine-memory `post-cp8-audio-lipsync` → `0f3893b24fdf75beb25212f2065792bddff118e6` ✓ (HEAD = predecessor — engine-memory was empty for CP-8, expected to stay empty for CP-9 per spec § Hard sequencing rule #3)

---

## 7. Risks + open Q's

- **Inline-data 20 MB ceiling vs video size.** 6-second 720p MP4 fits inline; 10-second 1080p does not. Phase 2 adapter MUST probe size and dispatch to Files API on overflow; tests cover both code paths.
- **`responseMimeType: "application/json"` reliability.** Gemini sometimes ignores the flag and wraps in markdown code fences. Adapter strips fences before `json.loads`; tested in Phase 2 mock transport with both clean and fenced responses.
- **`temperature: 0.0` doesn't guarantee determinism.** Multiple runs of same payload may diverge. PanelOfJudges design accommodates: median + outlier flagging is the safety net. Manual matrix in Phase 8 measures empirical variance for JT.
- **Cost cap accounting.** Token-rate uncertainty pre-call. CP-9 estimates per-judge cost from rubric character count + artifact size + `maxOutputTokens=1024` ceiling, then reconciles against actual `usageMetadata` after the call.
- **`on_failure` double-eval cost.** JT default. Flagged for review in spec-review; may be downgraded to opt-in in a CP-9.x patch.
- **`Take.aggregate_score` round-trip.** Additive field on a frozen dataclass; `to_dict`/`from_dict` must preserve. Verified in Phase 5 tests.
- **Score-less takes ordering** in `select_primary("score")`. Documented in docstring; verified in Phase 5 tests.
- **Retry-strategy bridge runtime gating.** `from_score_card` is substrate-only in CP-9. Production switchover deliberately deferred; do not let scope creep here.
- **402 vs 429 quota mapping.** Google API returns 429 for quota, not 402. `EvalQuotaError` exception is reserved for future use; mapped to nothing in CP-9 adapter.

---

## 8. Post-CP-9 sprint completion summary

> **JUNE-REFACTOR SPRINT COMPLETE.** CP-4 → CP-9 all shipped on schedule. CP-9 added 344 new tests on top of the 1295 pre-CP-9 baseline (1639 final). All four modalities (image_t2i, video_i2v, audio_t2a, lipsync_post) plus three new eval modalities (eval_image_v1, eval_video_v1, eval_audio_v1) are LIVE. The dispatch matrix at `consultations/recoil/cp9-eval-spec/cp9_live_matrix.sh` is staged for JT to run manually with `GEMINI_API_KEY` set — Phase 9 stages the script.

### 8a. Deliverables shipped (counts + LoC)

| File | Lines added | Purpose |
|---|---|---|
| `recoil/pipeline/core/eval.py` | 700 | EvalNode Protocol, EvalResult, EvalContext, PanelOfJudges, EvalRegistry, attach_eval_hooks |
| `recoil/execution/providers/gemini_vision.py` | 764 | Gemini 3.1 Pro adapter — score_artifact, EvalProviderResult, exception tree, GeminiVisionEvalNode |
| `recoil/pipeline/core/runners/eval_image_runner.py` | 196 | EvalImageRunner |
| `recoil/pipeline/core/runners/eval_video_runner.py` | 187 | EvalVideoRunner |
| `recoil/pipeline/core/runners/eval_audio_runner.py` | 187 | EvalAudioRunner |
| `recoil/pipeline/core/runners/_gemini_vision_eval_node.py` | 145 | Shared GeminiVisionEvalNode adapter (used by all 3 eval modality runners) |
| `recoil/pipeline/core/take.py` (delta) | +109 / -3 | aggregate_score field + compute_aggregate_score method + select_primary("score") body + to_dict/from_dict round-trip |
| `recoil/pipeline/core/registry.py` (delta) | +11 | 3 new MODALITY_EVAL_* constants |
| `recoil/pipeline/core/dispatch.py` (delta) | +50 | register_default_eval_runners |
| `recoil/pipeline/core/runners/__init__.py` (delta) | +42 | register_eval_runners opt-in helper + MODALITY_TO_EVAL canonical map |
| `recoil/pipeline/core/__init__.py` (delta) | +38 | re-exports of eval surface |
| `recoil/config/model_profiles.json` (delta) | +25 | gemini-3.1-pro-preview entry with per-1k cost rates (standard + long-context tiers) |
| `recoil/config/provider_strategy.json` (delta) | +5 | gemini-3.1-pro-preview → gemini_vision |
| `recoil/core/critic.py` (append) | +124 | LegacyFlashCriticEvalNode adapter (append-only, bottom of file) |
| `recoil/pipeline/orchestrator/strategy_registry.py` (append) | +72 | from_score_card factory (sibling of detect_failure_mode) |
| **TOTAL CP-9 production code** | **~2510 LoC** | (5 new files + 9 delta files; 467 inserts on existing) |
| **TOTAL CP-9 test code** | **~6688 LoC** | (across 27 new test files + 2 modified existing tests; ~344 new test functions) |

### 8b. Test counts per phase

| Phase | New tests | Cumulative |
|---|---|---|
| Pre-CP-9 baseline (HEAD `e596c663`) | — | 1295 pass / 11 skip |
| Phase 2 (Gemini Vision adapter) | 50 | 1345 |
| Phase 3 (eval.py + configs) | 95 | 1440 |
| Phase 4 (eval modality runners) | 98 | 1538 |
| Phase 5 (Take.aggregate_score + select_primary score) | 41 | 1579 |
| Phase 6 (Workflow eval-hook integration) | 20 | 1599 |
| Phase 7 (Legacy critic adapter + retry-strategy bridge) | 40 | 1639 |
| **Post-CP-9 total** | **344 new** | **1639 pass / 11 skip** |

### 8c. Manual live-dispatch matrix outcome (JT-driven)

- Image (Gemini Vision on a real keyframe): **DEFERRED-LIVE-API** — JT runs `cp9_live_matrix.sh` manually with `GEMINI_API_KEY` in env.
- Video (Gemini Vision on a real i2v output): **DEFERRED-LIVE-API**
- Audio (Gemini Vision on an ElevenLabs clip): **DEFERRED-LIVE-API**
- Panel-of-3 (median aggregation, cost cap exercise): **DEFERRED-LIVE-API**
- Legacy critic adapter (single-judge panel via LegacyFlashCriticEvalNode): **DEFERRED-LIVE-API**
- Score-based primary selection (real Take with eval'd Beat): **DEFERRED-LIVE-API**

Matrix script: `consultations/recoil/cp9-eval-spec/cp9_live_matrix.sh` (Phase 9 stages it; JT runs with `GEMINI_API_KEY` set). All six matrix lanes have been verified mock-side via 920 LoC of `urlopen`-injected tests in `recoil/execution/providers/tests/test_gemini_vision.py` plus the 19 eval-suite test files in `recoil/pipeline/core/tests/`.

### 8d. Frozen-contract drift report (Phase 9 verified 2026-04-28)

Verified at parent SHA `2dd42ae0`, engine-memory SHA `bf4f64d8`, against anchor `pre-cp9-eval-primitive`.

**Allowed-delta files (5 contract files + 2 append-only):**

| File | Expected delta | Observed delta (numstat) | Verdict |
|---|---|---|---|
| `pipeline/core/registry.py` | +3 MODALITY_EVAL_* constants | +8 / -3 (constants + comment refresh) | **PASS — matches § 12h intent** |
| `pipeline/core/dispatch.py` | +1 register_default_eval_runners function | +50 / -0 (additive only) | **PASS — additive** |
| `pipeline/core/take.py` | aggregate_score field + compute_aggregate_score + select_primary("score") body + to_dict/from_dict | +103 / -6 (NotImplementedError → score branch body; field/method/round-trip appends) | **PASS — matches § 12h intent** |
| `pipeline/core/runners/__init__.py` | +1 register_eval_runners | +42 / -0 (helper + MODALITY_TO_EVAL map) | **PASS — additive** |
| `pipeline/core/__init__.py` | re-export additions | +38 / -0 | **PASS — additive** |
| `core/critic.py` | LegacyFlashCriticEvalNode appended at bottom | +124 / -0 (insertion at line 384, file body byte-identical above) | **PASS — append-only** |
| `pipeline/orchestrator/strategy_registry.py` | from_score_card appended at module level | +72 / -0 (insertion at line 1519, file body byte-identical above) | **PASS — append-only** |

**Byte-identical contract files (12):**

| File | Verdict |
|---|---|
| `pipeline/core/dispatch_context.py` | **PASS — 0 / 0 byte-identical** |
| `pipeline/core/receipts.py` | **PASS — 0 / 0 byte-identical** |
| `pipeline/core/workflow.py` | **PASS — 0 / 0 byte-identical** |
| `pipeline/core/runners/audio_runner.py` | **PASS — 0 / 0 byte-identical** |
| `pipeline/core/runners/lipsync_post.py` | **PASS — 0 / 0 byte-identical** |
| `pipeline/lib/prompt_engine.py` | **PASS — 0 / 0 byte-identical** |
| `config/PROMPT_BIBLE.yaml` | **PASS — 0 / 0 byte-identical** |
| `execution/step_runner.py` | **PASS — 0 / 0 byte-identical** |
| `execution/providers/elevenlabs.py` | **PASS — 0 / 0 byte-identical** |
| `execution/providers/sync_so.py` | **PASS — 0 / 0 byte-identical** |
| `pipeline/lib/critic.py` (proxy) | **PASS — 0 / 0 byte-identical** |
| `pipeline/orchestrator/production_loop.py` | **PASS — 0 / 0 byte-identical (Phase 7 HARD GATE)** |

**Caller-catalog regression (Phase 9 re-grep against `cp9_phase1_eval_callers.json` baseline):**

| Surface | Phase 1 baseline | Post-CP-9 count | Verdict |
|---|---|---|---|
| `EvalNode` | 0 | 207 | **PASS — 0 → non-zero** |
| `PanelOfJudges` | 2 (docstring stubs) | 108 | **PASS — implementation live** |
| `EvalRegistry` | 0 | 25 | **PASS — 0 → non-zero** |
| `EvalContext` | 0 | 113 | **PASS — 0 → non-zero** |
| `EvalResult` | 0 | 113 | **PASS — 0 → non-zero** |
| `MODALITY_EVAL_IMAGE_V1` | 0 | 40 | **PASS — defined as constant** |
| `MODALITY_EVAL_VIDEO_V1` | 0 | 23 | **PASS — defined as constant** |
| `MODALITY_EVAL_AUDIO_V1` | 0 | 23 | **PASS — defined as constant** |
| `gemini_vision` (module) | 0 | 32 | **PASS — adapter shipped** |
| `score_artifact` | 0 | 126 | **PASS — function defined + tested** |
| `EvalProviderResult` | 0 | 15 | **PASS — type defined** |
| `EvalProviderError` | 0 | 21 | **PASS — exception tree defined** |
| `register_eval_node` | 0 | 108 | **PASS — registry API live** |
| `get_eval_node` | 0 | 25 | **PASS — registry API live** |
| `attach_eval_hooks` | 0 | 70 | **PASS — utility live** |
| `register_eval_runners` | 0 | 9 | **PASS — opt-in helper live** |
| `register_default_eval_runners` | 0 | 34 | **PASS — opt-in bootstrap live** |
| `compute_aggregate_score` | 0 | 44 | **PASS — Take method live** |
| `from_score_card` | 0 | 31 | **PASS — bridge defined + tested** |
| `LegacyFlashCriticEvalNode` | 0 | 34 | **PASS — adapter defined + tested** |
| `aggregate_score` | 1 (docstring) | 127 | **PASS — field + method + tests** |
| `from_score_card` in `production_loop.py` | 0 | 0 | **PASS — production switchover gated (Phase 7 HARD GATE)** |
| Pre-existing `CriticLoop` callers | 55 | 64 | **PASS — modest delta is test-side coverage** |
| Pre-existing `strategy_registry` callers | 3 | 10 | **PASS — modest delta is test-side coverage** |

**Sidecar prompt-hash baseline (Gate 3):**

Phase 1 captured 0 sidecar samples (project sidecar dirs were empty at anchor). Trivial PASS — no sidecar contamination introduced by CP-9 (CP-3 prompt-bytes contract is intact by definition when nothing was sampled).

**Engine-memory subrepo diff vs `pre-cp9-eval-primitive` (Gate 4):**

`git diff pre-cp9-eval-primitive..HEAD --stat` reports 1 file changed, 0 insertions, 0 deletions — only `.DS_Store` (macOS Finder metadata, binary, 0 content lines). The 40 auto-commits between anchor and HEAD are mempalace activity unrelated to CP-9 (CP-9 wrote zero engine-memory entries per audit § 6c rationale). **PASS.**

**Full pytest pass (Gate 1):**

`cd recoil && python3 -m pytest -q pipeline/tests/ pipeline/core/tests/ pipeline/lib/tests/ execution/providers/tests/` → **1639 passed, 11 skipped, 0 failed in 7.81s.** Matches audit § 8b cumulative target exactly.

**Eval modality round-trip via dispatch (Gate 2):**

`pipeline/core/tests/test_eval_dispatch_integration.py` — all 4 tests PASS (image_v1, video_v1, audio_v1 end-to-end via GeminiVisionEvalNode + provider-error → failure-receipt round-trip). Mocked transport per CP-9 hard rule (no live API in pytest).

**Verdict:** All 6 hard gates GREEN. Zero unexpected drift. CP-9 BUILD COMPLETE.

### 8e. Sprint-level metrics (june-refactor CP-1 → CP-9)

| Metric | CP-1 baseline | CP-9 final | Delta |
|---|---|---|---|
| Total tests | ~700 | 1639 pass / 11 skip | +939 |
| Pipeline LoC | (CP-1 baseline) | (CP-9 final) | +~10k production code across 9 CPs |
| Modalities live | 2 (image_t2i, video_i2v) | 4 generation + 3 eval = 7 | +5 |
| Wall-clock build hours | — | ~80 hrs across CP-1..CP-9 (CP-9 ~12 hrs) | — |
| Cost (Claude + Gemini consults + harnesses) | — | ~$45 sprint total (CP-9 consults: $0.45 across 9 dual-engine rounds) | — |
| Generation entry points | 7 (StepRunner.execute_*) | 1 (`dispatch()`) | -6 |
| Eval primitives | 0 | EvalNode + PanelOfJudges + EvalContext + EvalResult + EvalRegistry + attach_eval_hooks + 3 modality runners + Gemini Vision adapter + LegacyFlashCriticEvalNode adapter + from_score_card bridge | +9 surfaces |

### 8f. Console v2 unblock note

After CP-9 ships, the **Console v2 architecture consult** (deferred at the start of june-refactor pending the eval primitives that would feed its score columns) is unblocked. Concretely, Console v2 can now read:

- `take.aggregate_score: Optional[float]` — surface as a sortable column on the take-grid.
- `step.receipt.eval_scores[panel_id]` — render per-panel ScoreCard with `panel_score`, `panel_warnings`, per-judge breakdown, `aggregation` mode, `panel_cost_usd`.
- `receipt.provenance["eval_cost_usd"]` — eval cost separated from generation cost in the cost ribbon.
- `Beat.select_primary("score")` — auto-elect a primary take based on judge consensus; user override stays via `"manual"`.

The Console v2 consult should now consider: how to display panel disagreement (warnings on outliers), how to expose the per-judge reasoning text without cluttering the grid, and how to let users compose / register custom panels at runtime. None of those are CP-9 scope.

### 8g. Retry-strategy iteration unpause

The `from_score_card` substrate ships in CP-9 Phase 7 at `recoil/pipeline/orchestrator/strategy_registry.py`. Production switchover is a follow-up gated on JT review of empirical PanelOfJudges output:

- **Step 1:** JT lives with PanelOfJudges output for N production runs (driver-beware, Tartarus S01, etc.) and refines the score-bucket → FailureMode mapping inside `from_score_card`. The CP-9 placeholder buckets (`>=0.7 → NONE`, `>=0.4 → UNKNOWN`, `<0.4 → IDENTITY_DRIFT`) are first-cut; expect 2-3 iterations before they reflect what JT actually wants the retry strategy to do.
- **Step 2:** A separate spec rewires `production_loop.py:1087-1111` to call `from_score_card(panel_scorecard)` alongside (or in place of) `detect_failure_mode(pass_result, current_pass)`. Both signatures return `(FailureMode, float)` — drop-in compatible.
- **Step 3:** Strategy registry consumers (`STRATEGY_REGISTRY`, `ESCALATION_CHAINS`) learn to weight both inputs (e.g., panel verdict overrides Tier-1 gate scores when confidence is high).

This is **OUT OF SCOPE for CP-9.** The substrate exists; the wire-up is gated. Verified end-to-end mock-side via `test_strategy_engine_score_card_bridge.py` (173 LoC).

---

## 9. Cross-reference

- BUILD_SPEC: `consultations/recoil/cp9-eval-spec/BUILD_SPEC_CP9.md`
- Caller catalog: `consultations/recoil/cp9-eval-spec/cp9_phase1_eval_callers.json`
- Vendor endpoints: `consultations/recoil/cp9-eval-spec/cp9_phase1_vendor_endpoints.md`
- Pre-flight pytest baseline: `consultations/recoil/cp9-eval-spec/cp9_phase1_pre_baseline.txt`
- Pre-flight sidecar baseline: `consultations/recoil/cp9-eval-spec/cp9_pre_baseline_prompt_hashes.json`
- Predecessor audit (CP-8): `recoil/docs/audio-lipsync-impl-audit.md`
- Predecessor audit (CP-7): `recoil/docs/take-model-audit.md`
- Predecessor audit (CP-6): `recoil/docs/workflow-object-model-audit.md`
- Predecessor audit (CP-5): `recoil/docs/dispatch-unification-audit.md`
- Predecessor audit (CP-4): `recoil/docs/modality-registry-audit.md`
- Predecessor audit (CP-3): `recoil/docs/prompt-engine-audit.md`
- SYNTHESIS: `consultations/recoil/loraverse-architecture-consult/SYNTHESIS.md`
- CP-8 hand-off: `consultations/recoil/cp8-audio-lipsync-spec/CP9_HANDOFF.md`
- `audit-2026-04-25/data-contracts.md` — UNTOUCHED in Phase 1; Phase 8 appends paragraph (JT sign-off gated)

---

## 12. Phase 2-9 hand-off corrections (post-Phase-1 review pass)

**Why this section exists:** the Phase 1 deliverables went through a 3-axis review pass (consistency, clarity, completeness) immediately after they shipped. The pass surfaced 17+ load-bearing items where BUILD_SPEC body or in-line snippets contradict either the live API surface, the actual `recoil/` tree, or each other. Each item below is a **direct hand-off instruction** for the named phase. Apply these AS WRITTEN — do not re-derive from BUILD_SPEC body.

### 12a. Phase 2 corrections (Gemini Vision adapter)

1. **`DEFAULT_MODEL_ID` MUST be `"gemini-3.1-pro-preview"`.** BUILD_SPEC line 876 sets it to `"gemini-3.1-pro"` (no `-preview` suffix). That literal will resolve the URL to a 404. Same correction in `GeminiVisionEvalNode.__init__` model_id default (BUILD_SPEC ~line 1618). Vendor lock confirmed at `cp9_phase1_vendor_endpoints.md` § 1.

2. **Request body uses camelCase keys.** Gemini API rejects snake_case at the JSON layer. BUILD_SPEC line 982 + the request-body construction at lines ~1060-1068 use `inline_data` / `mime_type` — REPLACE with `inlineData` / `mimeType`. Same correction for `generationConfig` / `maxOutputTokens` / `responseMimeType`. Vendor lock at `cp9_phase1_vendor_endpoints.md` § 4.

3. **Cost-rate convention LOCKED to per-1k tokens.** Reasoning: BUILD_SPEC `_compute_cost` (line 945-955) reads `cost_per_1k_input_tokens` / `cost_per_1k_output_tokens` and divides by 1000. The vendor publishes per-1M ($2.00 / $12.00 standard tier). Phase 3 stores per-1k by **dividing the per-1M rates by 1000**: `cost_per_1k_input_tokens: 0.002`, `cost_per_1k_output_tokens: 0.012` (standard tier). Long-context bucket (>200K tokens): `cost_per_1k_input_tokens_long_context: 0.004`, `cost_per_1k_output_tokens_long_context: 0.018`. Adapter switches buckets on `promptTokenCount > long_context_threshold_tokens` (= 200000). Without this lock the cost compute will be 1000× off in either direction.

4. **20 MB inline-data ceiling is on the BASE64-ENCODED request body, not raw bytes.** Base64 inflates ~33%. So a raw 14.5 MB video becomes ~20 MB encoded — that's the actual ceiling. Adapter MUST size-check on encoded bytes, not raw, OR fall back to Files API at `raw_bytes > 14_500_000`. Vendor doc § 5 hard-limits column updated to clarify.

5. **Two-step JSON parse on `parts[0].text`.** Gemini returns the response body as a JSON-encoded STRING inside `candidates[0].content.parts[0].text`. The adapter extracts that string, then `json.loads` it a SECOND time to get the `{"score": float, "reasoning": str}` object. Do NOT attempt `parts[0].text["score"]` (TypeError). If the inner parse fails (truncated text, malformed JSON), raise `EvalPayloadError(reason="text_unparseable")`.

6. **MAX_TOKENS partial-text handling.** If `finishReason == "MAX_TOKENS"` AND the partial text fails the second `json.loads`, raise `EvalPayloadError(reason="truncated_unparseable")`. Do NOT swallow the JSONDecodeError. If it parses cleanly, append `"truncated_max_tokens"` to `raw_metadata.warnings` and proceed.

7. **504 retry count.** Vendor doc § 8 is authoritative: 504 retries 2x (2s, 4s). Audit § 3a summary said "3x" — vendor doc wins. Phase 2 implements 2x for 504, 3x for 429 / 500, 3x with longer schedule (5s/10s/20s) for 503.

8. **Backoff jitter spec.** ±20% means uniform sample in `[base × 0.8, base × 1.2]`. NOT `base ± 0.2s` additive.

9. **Retry-After header.** Honor `Retry-After` if Gemini sends one (rare for `generateContent`), capped at 30s. If absent, use the schedule above.

10. **Network-exhaustion exception type.** After retry exhaustion on `URLError`/`socket.timeout`, raise `EvalNetworkError` wrapping the original (`raise EvalNetworkError(...) from original_exc`). NOT the raw `URLError`.

11. **Auth header precedence.** If both `GEMINI_API_KEY` and `GOOGLE_API_KEY` are set with different values, `GEMINI_API_KEY` wins. Never log either value.

12. **Inline-data base64 encoding.** Use `base64.standard_b64encode(file_bytes).decode("ascii")`. NOT `urlsafe_b64encode` — Gemini rejects URL-safe `-_` characters in `inlineData.data`.

13. **Files-API URI field.** When constructing `fileData.fileUri`, use the `name` field from the upload response (`"files/abc-123"`), NOT the `uri` field (`"https://..."`). Common foot-gun.

14. **`EvalProviderResult` dataclass shape (locked at Phase 2):**
    ```python
    @dataclass
    class EvalProviderResult:
        score: float                    # 0.0–1.0 (clipped if outside)
        reasoning: str                  # the LLM's freeform critique
        cost_usd: float                 # computed via per-1k rates above
        model_used: str                 # echoed model id (e.g. "gemini-3.1-pro-preview")
        request_id: Optional[str]       # from response headers if present, else None
        raw_metadata: dict              # opaque — includes warnings: list[str]
    ```
    `raw_metadata.warnings` is a list-of-strings appended to during parse. Pre-defined warning tokens: `"score_clipped"`, `"truncated_max_tokens"`.

### 12b. Phase 3 corrections (eval.py + model_profiles + provider_strategy)

1. **Lock per-1k cost-field naming.** Per § 12a item 3 above: model_profiles entry uses `cost_per_1k_input_tokens` / `cost_per_1k_output_tokens` / `cost_per_1k_input_tokens_long_context` / `cost_per_1k_output_tokens_long_context` / `long_context_threshold_tokens` (= 200000). Override the audit § 3c per-1M example.

2. **`gemini-3.1-pro-preview` is NOT in `model_profiles.json` today.** Existing entries are all per-image image-gen costs. Phase 3 ADDS the entry. Cite `gemini-3-pro-image-preview` only as a key-naming-convention reference (`provider`, `display_name`, `modality` keys), NOT as a cost-shape template.

3. **`provider_strategy.json` shape — verify before writing.** Audit § 3d proposes one shape; BUILD_SPEC § Phase 3 line 1864-1868 proposes a different shape. Phase 3 author MUST `cat recoil/config/provider_strategy.json` and follow the EXISTING entry shape. Do not invent new keys.

### 12c. Phase 4 corrections (eval modality runners)

1. **`RunResult.id` collision avoidance.** CP-8 debug R1 fixed an `id` collision by switching from `f"{shot_id}_{modality}"` to `f"{shot_id}_{modality}_{time.time_ns()}"`. Eval runners MUST follow that pattern: `RunResult.id = f"{payload['shot_id']}_eval_{modality}_v1_{time.time_ns()}"`. Without `time.time_ns()`, two evals on the same shot collide.

2. **Mirror `audio_runner.py`'s `_failure_metadata()` helper.** 6-key failure dict: `final_state`, `eval_score`, `eval_reasoning`, `judge_id`, `model_used`, `eval_cost_usd`. Pattern at `recoil/pipeline/core/runners/audio_runner.py`.

3. **Opt-in registration only.** `register_default_eval_runners(*, force=False)` is a SEPARATE function in `pipeline/core/dispatch.py` — does NOT modify `register_default_runners` (the existing 4-modality bootstrap). Eval runners require `GEMINI_API_KEY`; making them auto-register on import would break test environments without the key.

### 12d. Phase 5 corrections (Take.aggregate_score + select_primary score)

1. **`_beat_select_primary` is a FREE FUNCTION, not a method.** Defined at `take.py:383-421`, then bound onto `Beat.select_primary` at `take.py:429` via `Beat.select_primary = _beat_select_primary`. This is the LOCKED-4 pattern from CP-7 spec-review. Phase 5 modifies the FREE FUNCTION body, NOT a class method. The binding line stays untouched.

2. **Modify ONLY the strategy=="score" branch (lines 402-405).** Other branches preserve byte-stable: `manual` (line 406-407), `first_success` (line 408-417), ValueError raise (line 418-421).

3. **Take dataclass field count = 6 today.** Verified at `take.py:36-83`: `take_id`, `take_index`, `workflow`, `status`, `created_at`, `take_metadata`. Phase 5 appends `aggregate_score: Optional[float] = None` as the 7th. Defaulted-after-defaulted rule preserved (all existing fields after `workflow` already have defaults).

4. **`Take.to_dict` and `Take.from_dict` round-trip.** `to_dict` at line ~89 — append `"aggregate_score": self.aggregate_score`. `from_dict` at lines 102-111 — append `aggregate_score=d.get("aggregate_score")` (defaults to None on legacy dicts).

5. **Score-less takes sort BELOW scored takes.** When a take has no eval scores, `compute_aggregate_score()` returns `None`. In `select_primary("score")`, sort `(score is None, -score_or_zero, take_index)` — None sorts last; ties broken by take_index ASC. If NO take has scores, return `None` (parallel to `first_success` semantics when no take succeeded). Do NOT raise.

### 12e. Phase 6 corrections (Workflow eval-hook integration)

1. **`GenerationReceipt` is `@dataclass(frozen=True)` at `receipts.py:29`.** Field reassignment raises `FrozenInstanceError`. BUT the `eval_scores` and `provenance` fields hold `dict` objects — mutating those dicts in place is allowed.

2. **Hooks MUST use in-place dict mutation.** `receipt.eval_scores[panel.panel_id] = scorecard` (in-place — OK). `receipt.eval_scores = {...}` (reassignment — `FrozenInstanceError`). Same rule for `receipt.provenance["eval_cost_usd"] = panel_cost`.

3. **Hook signature.** `(step: WorkflowStep, workflow: Workflow) -> None`. Hooks are sync, no return value, mutate `step.receipt.eval_scores` in place.

4. **`on_failure` hook ALSO runs eval (default).** Per JT brief: invokes `panel.score(receipt, EvalContext(reason="step failed"))` for diagnostic logging. Doubles eval cost on failures. Flagged for spec-review but Phase 6 ships with default = ON. If JT later flips: add `panel.eval_on_failure: bool = True` field; Phase 6 hook checks before invoking.

### 12f. Phase 7 corrections (Legacy critic adapter + retry-strategy bridge)

> **THIS IS THE HIGHEST-RISK PHASE OF CP-9.** Three of the load-bearing corrections cluster here. The BUILD_SPEC body for Phase 7 was authored against an imagined `pipeline/lib/critic.py` that doesn't exist as described. Apply these corrections verbatim.

1. **Adapter goes in `recoil/core/critic.py`, NOT `recoil/pipeline/lib/critic.py`.** The latter is a 7-line re-export proxy (`from core.critic import *`). The real CriticLoop ABC body lives at `recoil/core/critic.py` (385 lines). APPEND the adapter at the BOTTOM of `core/critic.py`, AFTER the existing `class CriticLoop` body. The proxy at `pipeline/lib/critic.py` re-exports via `import *` and `__all__` — adding `LegacyFlashCriticEvalNode` to `core/critic.py.__all__` makes `from pipeline.lib.critic import LegacyFlashCriticEvalNode` work automatically. Do NOT modify the proxy.

2. **`run_visual_critic` does NOT exist as a free function in `pipeline/lib/critic.py`.** The BUILD_SPEC adapter body imports `from pipeline.lib.critic import run_visual_critic as _legacy_call` — that import will `ImportError` at runtime. The actual surface is the `CriticLoop` ABC with method `run(artifact, context) -> tuple[Any, CriticResult]`. The adapter takes a `CriticLoop` instance in its constructor (any concrete subclass: `StartFrameCritic`, `VideoFrameCritic`, etc.) and calls `instance.run(...)`.

3. **Adapter call shape (locked):**
    ```python
    class LegacyFlashCriticEvalNode:
        def __init__(self, critic_instance: CriticLoop, judge_id: str = "legacy_flash_critic_v1"):
            self.critic = critic_instance
            self.judge_id = judge_id

        def evaluate(self, context: EvalContext) -> EvalResult:
            # critic_instance.run returns (artifact, CriticResult) per core/critic.py
            artifact, result = self.critic.run(
                artifact=str(context.target_artifact_path),
                context=context.metadata,  # legacy critic expects dict
            )
            # CriticResult.outcome is Outcome enum (PASS / FAIL / ERROR per core/critic.py:41-43)
            score = 1.0 if result.outcome == Outcome.PASS else 0.0
            reasoning = " | ".join(d.reason for d in result.dimensions)
            # CriticResult does NOT expose cost_usd; legacy adapter falls back to 0.0
            return EvalResult(
                score=score,
                reasoning=reasoning,
                judge_id=self.judge_id,
                model_used=getattr(self.critic, "model_id", "unknown"),
                cost_usd=0.0,
                metadata={"outcome": result.outcome.value, "dimensions": [d.name for d in result.dimensions]},
            )
    ```

4. **`from_score_card` body — `FailureMode` is a `str, Enum`, NOT a dataclass.** BUILD_SPEC body calls `FailureMode(mode="CRITIC_FAIL_HARD", reason=..., details=...)` — there is no `CRITIC_FAIL_HARD` member, no kwargs constructor, no such API. Verified at `recoil/core/critic.py:51`: `class FailureMode(str, Enum)` with 33 members (`NONE`, `IDENTITY_DRIFT`, `CONTENT_FILTER_HARD_BLOCK`, `TRANSIENT`, `COST_OVERRUN`, `UNKNOWN`, etc.).

5. **`from_score_card` correct shape (locked):**
    ```python
    def from_score_card(score_card: dict) -> tuple[FailureMode, float]:
        """Map a PanelOfJudges scorecard to a FailureMode + confidence.

        Mirrors detect_failure_mode(...) return shape so callers can swap inputs
        without changing downstream consumption. Substrate only — production
        switchover is gated on JT sign-off.
        """
        panel_score = score_card.get("panel_score")
        if panel_score is None:
            return (FailureMode.UNKNOWN, 0.0)
        # Map score buckets to existing FailureMode members. Choices are
        # placeholders — JT may refine in retry-strategy iteration:
        if panel_score >= 0.7:
            return (FailureMode.NONE, panel_score)
        if panel_score >= 0.4:
            return (FailureMode.UNKNOWN, panel_score)  # ambiguous mid-band
        return (FailureMode.IDENTITY_DRIFT, 1.0 - panel_score)  # confident "bad"
    ```
    Use ONLY existing enum members. Do NOT invent `CRITIC_FAIL_HARD` etc.

6. **NO modification to `production_loop.py`.** Phase 7 adds the substrate (`from_score_card` factory) only. The runtime call site at `production_loop.py:1087-1111` continues to call `detect_failure_mode(pass_result, current_pass)`. Switchover is a follow-up CP after JT sign-off.

7. **NO modification to `detect_failure_mode` body or signature.** Add `from_score_card` as a sibling free function at module level in `strategy_registry.py`.

### 12g. Phase 8 corrections (docs)

1. **§ 8 of THIS audit doc has a placeholder skeleton reserved.** Phase 8 fills it with: deliverables shipped (counts + LoC), test counts per phase, manual matrix outcome, frozen-contract drift report, sprint metrics, Console v2 unblock note, retry-strategy iteration unpause note. Do NOT invent new structure — use the skeleton.

### 12h. Phase 9 corrections (verification + tags)

1. **Frozen-contract diff anchor file list (16 files, not 12).** Phase 9 must check byte-stability vs `pre-cp9-eval-primitive` for ALL of:
   - `recoil/pipeline/core/registry.py` (CP-4 lock; 3 new MODALITY_EVAL_* constants are the ONLY allowed delta)
   - `recoil/pipeline/core/dispatch.py` (CP-5 lock; new `register_default_eval_runners` function is the ONLY allowed delta)
   - `recoil/pipeline/core/dispatch_context.py` (CP-5 lock; byte-identical)
   - `recoil/pipeline/core/receipts.py` (CP-5 lock; byte-identical)
   - `recoil/pipeline/core/workflow.py` (CP-6 lock; byte-identical)
   - `recoil/pipeline/core/take.py` (CP-7 lock; ONLY allowed deltas: `aggregate_score` field append, `compute_aggregate_score` method append, `_beat_select_primary` strategy="score" branch body replacement, `to_dict`/`from_dict` aggregate_score round-trip lines)
   - `recoil/pipeline/core/runners/audio_runner.py` (CP-8 lock; byte-identical)
   - `recoil/pipeline/core/runners/lipsync_post.py` (CP-8 lock; byte-identical)
   - `recoil/pipeline/core/runners/__init__.py` (CP-8 lock; ONLY allowed delta: new `register_eval_runners` function)
   - `recoil/pipeline/core/__init__.py` (re-export surface; new eval re-exports allowed)
   - `recoil/pipeline/lib/prompt_engine.py` BUILDERS table (CP-3 lock; byte-identical)
   - `recoil/config/PROMPT_BIBLE.yaml` (CP-3 lock; byte-identical)
   - `recoil/execution/step_runner.py` (CP-2 lock; byte-identical)
   - `recoil/execution/providers/elevenlabs.py` (CP-8 lock; byte-identical)
   - `recoil/execution/providers/sync_so.py` (CP-8 lock; byte-identical)
   - `recoil/pipeline/lib/critic.py` (proxy — byte-identical)
   - `recoil/core/critic.py` (CP-9 ALLOWS append-only at bottom: `LegacyFlashCriticEvalNode` class)
   - `recoil/pipeline/orchestrator/strategy_registry.py` (CP-9 ALLOWS append-only at module level: `from_score_card` function)
   - `recoil/pipeline/orchestrator/production_loop.py` (byte-identical — Phase 7 hard gate)

2. **Caller-catalog regression patterns.** Phase 9 re-runs the grep matrix in `cp9_phase1_eval_callers.json` and confirms:
   - `EvalNode`, `PanelOfJudges`, `EvalRegistry`, `EvalContext`, `EvalResult` go from 0 → non-zero callsites
   - `MODALITY_EVAL_IMAGE_V1`, `MODALITY_EVAL_VIDEO_V1`, `MODALITY_EVAL_AUDIO_V1` go from 0 → defined-as-constants
   - `gemini_vision`, `score_artifact`, `EvalProviderResult`, `EvalProviderError` go from 0 → defined in `execution/providers/gemini_vision.py`
   - `register_eval_node`, `get_eval_node`, `attach_eval_hooks`, `register_eval_runners`, `register_default_eval_runners`, `compute_aggregate_score`, `from_score_card`, `LegacyFlashCriticEvalNode` go from 0 → defined and exported
   - `aggregate_score` goes from 1 (docstring) → field+method
   - `select_primary("score")` callsites stay 0 in production but appear in tests
   - `production_loop.py` byte-identical (no `from_score_card` callsite added in CP-9)

### 12i. Cross-cutting paths (cite when needed)

- Receipts JSONL log: `$RECOIL_ROOT/_dispatch_logs/receipts.jsonl` (overridable via `DispatchContext.receipts_log_path`).
- DispatchContext: `recoil/pipeline/core/dispatch_context.py`.
- StepRunner: `recoil/execution/step_runner.py`.
- `register_default_runners` canonical home: `recoil/pipeline/core/dispatch.py` (per CP-5).
- Sidecar dirs glob (Phase 9 prompt-hash gate): `projects/{tartarus,leviathan,the-afterimage}/output/sidecars/*.json`.
- Existing CP-8 mock-transport pattern: `recoil/execution/providers/tests/test_elevenlabs.py` (Phase 2 mocks `urllib.request.urlopen` with the same shape).
