Skip to content

maint-86: prepare (not auto-apply) verifier model promotions (#2819 move 3) - #2834

Merged
stranske merged 1 commit into
mainfrom
model-promotion-prepare
Jul 26, 2026
Merged

maint-86: prepare (not auto-apply) verifier model promotions (#2819 move 3)#2834
stranske merged 1 commit into
mainfrom
model-promotion-prepare

Conversation

@stranske

@stranske stranske commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Completes #2819move 3 of 3. Builds on move 1 (#2831) + move 2 (#2832), now on main.

What this is

Moves 1–2 made the evaluation evidence flow on its own (registry-derived candidates + a corpus that grows from realized PR outcomes). Move 3 turns a passing benchmark into a prepared selection change that a human approves by merging a PR — per your choice of auto-prepare + one-click approve + auto-rollback. human_approval_required stays true; the machine does everything expensive, the human just merges.

Guardrails — a promotion is prepared only when ALL hold

  • Same family as the incumbent (openai gpt-<major>, anthropic claude-<line>). Cross-family swaps are never auto-prepared — those still need a human to initiate.
  • Passed every quality gate on the benchmark, including paired non-inferiority.
  • Costs the incumbent per accepted review.

apply_promotion() writes the registry mutation and records the prior selection in a new selection_history array. The inverse path (find_rollbacks/apply_rollback) reverts to the prior selection when the active model shows a failed workload-benchmark (a quality-gate breach).

maint-86 workflow

Dispatch-only: it evaluates a benchmark artifact, prepares the change, and opens a PR that is not auto-merged — merging is the approval. The schedule trigger + the pilot→benchmark bridge are deliberately deferred until the corpus reaches the approval minimum (grown weekly by maint-79), so this cannot fabricate a promotion from thin data. That deferral is the honest state: the logic is built and tested now; it goes live when real approval-grade evidence exists.

Verification (local, CI-pinned)

  • 12 promoter tests, including a promote → gate-breach → rollback round-trip and same-family/cost/pass/baseline-mismatch guards.
  • black==26.5.1 / ruff==0.15.20 / mypy==2.1.0 clean; maint-86 YAML valid; registered in EXPECTED_NAMES + workflow inventory docs.
  • Against the real config/model_registry.json, a synthetic passing benchmark correctly prepares claude-opus-4-6 → claude-opus-4-8 (same-family, passed, cheaper) — and correctly refuses a cross-family (claude-sonnet-5) or more-expensive candidate.

With this, #2819's three moves are complete: candidates derive themselves (1), the corpus grows itself (2), and a qualifying same-family upgrade prepares itself for one-click human approval with auto-rollback (3).

Summary by CodeRabbit

  • New Features

    • Added benchmark-driven preparation for verifier model promotions and rollbacks.
    • Eligible promotions require quality-gate success, same-family compatibility, and lower or equal cost.
    • Prepared changes preserve selection history and require human approval through a pull request.
    • Added automatic rollback preparation when an active model breaches quality gates.
  • Documentation

    • Documented promotion, rollback, approval, and workflow behavior.
  • Tests

    • Added coverage for eligibility rules, promotions, rollbacks, no-op scenarios, and workflow naming.

…ove 3)

Move 3 of the self-feeding verifier-model promotion system. Moves 1-2 made the
evaluation evidence flow on its own; this turns a passing benchmark into a
*prepared* selection change that a human approves by merging a PR —
human_approval_required stays true.

- tools/prepare_model_promotion.py: find_promotions() prepares a candidate only
  when it is the SAME FAMILY as the incumbent (openai gpt-<major>, anthropic
  claude-<line>), PASSED every quality gate (incl. paired non-inferiority), and
  costs <= the incumbent per accepted review. apply_promotion() writes the
  registry mutation and records the prior selection in selection_history (new).
  find_rollbacks()/apply_rollback() are the inverse: revert to the prior
  selection when the active model shows a failed workload-benchmark
  (quality-gate breach). Pure/deterministic given an injected `today`; CLI exits
  10 when a change is prepared.
- maint-86: dispatch-only workflow that evaluates a benchmark artifact, prepares
  the change, and opens a PR that is NOT auto-merged (merge = approval). The
  schedule trigger + pilot->benchmark bridge are deliberately deferred until the
  corpus reaches the approval minimum (grown by maint-79) so this cannot
  fabricate a promotion from thin data.
- Registered maint-86 in EXPECTED_NAMES + the WORKFLOWS.md / WORKFLOW_SYSTEM.md
  inventory; documented the mechanism in MODEL_SELECTION_POLICY.md.

Verified: 12 promoter tests incl. a promote->breach->rollback round-trip; CI
pinned black/ruff/mypy clean; against the real registry it correctly prepares
claude-opus-4-6 -> claude-opus-4-8 (same-family, passed, cheaper).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 26, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@stranske
stranske enabled auto-merge (squash) July 26, 2026 00:16
@stranske
stranske temporarily deployed to agent-high-privilege July 26, 2026 00:16 — with GitHub Actions Inactive
@agents-workflows-bot

Copy link
Copy Markdown
Contributor

Workflow source needed

PR #2834 needs either a linked GitHub issue or one valid non-issue Workflow Source before PR metadata automation can manage it safely.

Please do one of:

  • Add <!-- meta:issue:123 --> or a normal Closes #123 / Related to #123 line.
  • Check one Workflow Source option in the PR body.
  • Add a hidden marker such as <!-- workflow-source:local_request -->, <!-- workflow-source:manual_remote -->, <!-- workflow-source:review_followup -->, <!-- workflow-source:sync_campaign -->, or <!-- workflow-source:dependabot -->.
  • Add a workflow source label such as workflow:source-direct-pr, workflow:source-local-request, workflow:source-review-followup, workflow:source-sync, or workflow:no-automation.

Once a valid source is present, this warning will not be reposted.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds deterministic benchmark-driven model promotion and rollback preparation, a manually dispatched GitHub Actions workflow that opens human-reviewed registry PRs, comprehensive tests, and supporting policy and workflow documentation.

Changes

Model promotion preparation

Layer / File(s) Summary
Promotion and rollback selection engine
tools/prepare_model_promotion.py
Adds same-family and cost-gated promotion selection, registry history updates, benchmark-failure rollback detection, rollback application, and CLI input helpers.
CLI and workflow orchestration
tools/prepare_model_promotion.py, .github/workflows/maint-86-model-promotion-prepare.yml
Adds JSON-driven CLI modes and a manual workflow that writes registry changes and opens a non-automerge pull request when changes are prepared.
Behavior validation and policy documentation
tests/tools/test_prepare_model_promotion.py, tests/workflows/test_workflow_naming.py, docs/MODEL_SELECTION_POLICY.md, docs/ci/*
Tests promotion, rollback, CLI, and workflow naming behavior, while documenting eligibility and human-approval requirements.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Maintainer
  participant GitHubActions
  participant prepare_model_promotion
  participant ModelRegistry
  participant HumanReviewPR
  Maintainer->>GitHubActions: dispatch workflow with benchmark_path and mode
  GitHubActions->>prepare_model_promotion: run promotion preparation
  prepare_model_promotion->>ModelRegistry: evaluate and write prepared selection
  prepare_model_promotion-->>GitHubActions: return code 10 when changed
  GitHubActions->>HumanReviewPR: open registry update PR
  HumanReviewPR-->>Maintainer: require human merge approval
Loading

Possibly related issues

  • Issue 2819: Implements same-family, cost-gated promotion PRs and prior-selection rollback behavior described by the issue.

Suggested labels: maintenance, automation

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: preparing verifier model promotions instead of auto-applying them.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch model-promotion-prepare

Comment @coderabbitai help to get the list of available commands.

runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v6
exit 0
- name: Open promotion PR (human merges to approve)
if: steps.prep.outputs.rc == '10'
uses: peter-evans/create-pull-request@v8

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 44d1d2755f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +55 to +56
--write config/model_registry.json | tee prepare.log
rc=$?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the promoter's exit status through tee

In maint-86-model-promotion-prepare.yml, this pipeline returns tee's status because pipefail is not enabled, so a successful preparation where Python exits 10 records rc=0 and the PR step at line 70 never runs; input errors are likewise swallowed. Capture ${PIPESTATUS[0]} or enable set -o pipefail before reading the status.

Useful? React with 👍 / 👎.

Comment on lines +192 to +196
evidence_ids = list(selection.get("evidence_ids", []))
if promotion["evidence_id"] not in evidence_ids:
evidence_ids.append(promotion["evidence_id"])
selection["model_id"] = promotion["to_model_id"]
selection["evidence_ids"] = evidence_ids

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Store benchmark evidence with the promoted selection

When a candidate qualifies, this adds its evidence ID to the selection but never copies the corresponding registry_evidence object into the registry's evidence array. The generated PR therefore triggers maint-77-model-registry-freshness.yml, whose missing_evidence check treats that dangling ID as a blocking structural finding, so every promotion PR will fail its model-registry gate.

Useful? React with 👍 / 👎.

f"{promotion['to_model_id']} ({promotion['reason']})"
)
mutated = apply_promotion(mutated, promotion, today=today)
for rollback in rollbacks:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Choose one action when auto mode finds both paths

In auto mode, a report can contain a failed active baseline and a cheaper passing same-family candidate, causing both promotions and rollbacks to be populated. The promotion loop first inserts the failed incumbent at the front of history; this subsequent rollback then reads that mutated history and restores the failed incumbent rather than the prior model identified by find_rollbacks, producing a prepared PR that performs neither intended action. Auto mode should select one path or apply rollback against its explicit target.

Useful? React with 👍 / 👎.

@agents-workflows-bot

Copy link
Copy Markdown
Contributor

Automated Status Summary

Head SHA: 1446583
Latest Runs: ⏳ pending — Gate
Required contexts: Gate / gate, Health 45 Agents Guard / guard
Required: core tests (3.12): ⏳ pending, core tests (3.13): ⏳ pending, docker smoke: ⏳ pending, gate: ⏳ pending

Workflow / Job Result Logs
(no jobs reported) ⏳ pending

Coverage Overview

  • Coverage history entries: 1

Coverage Trend

Metric Value
Current 75.90%
Baseline 85.00%
Delta -9.10%
Minimum 70.00%
Status ✅ Pass

Top Coverage Hotspots (lowest coverage)

File Coverage Missing
scripts/issue_dedup_smoke.py 0.0% 4
scripts/runner_lib/__main__.py 0.0% 3
scripts/validate_template_sync.py 0.0% 81
scripts/langchain/topic_splitter.py 19.1% 57
tools/codex_log_analyzer.py 19.6% 140
scripts/repo_review_round2_runner.py 25.1% 344
scripts/prune_agent_stubs.py 39.7% 26
scripts/repo_review_round1_runner.py 40.7% 133
tools/ensure_workflow_timeout_variables.py 42.1% 74
scripts/sync_label_docs.py 42.9% 64
tools/discover_model_catalog.py 44.8% 55
scripts/repo_review_backlog_scan.py 45.3% 116
scripts/repo_review_body_writer.py 46.5% 86
tools/codex_session_analyzer.py 47.9% 59
scripts/create_verifier_labels.py 48.3% 58

Low Coverage Files (<50.0%)

File Coverage Missing
scripts/issue_dedup_smoke.py 0.0% 4
scripts/runner_lib/__main__.py 0.0% 3
scripts/validate_template_sync.py 0.0% 81
scripts/langchain/topic_splitter.py 19.1% 57
tools/codex_log_analyzer.py 19.6% 140
scripts/repo_review_round2_runner.py 25.1% 344
scripts/prune_agent_stubs.py 39.7% 26
scripts/repo_review_round1_runner.py 40.7% 133
tools/ensure_workflow_timeout_variables.py 42.1% 74
scripts/sync_label_docs.py 42.9% 64
tools/discover_model_catalog.py 44.8% 55
scripts/repo_review_backlog_scan.py 45.3% 116
scripts/repo_review_body_writer.py 46.5% 86
tools/codex_session_analyzer.py 47.9% 59
scripts/create_verifier_labels.py 48.3% 58

Updated automatically; will refresh on subsequent CI/Docker completions.


Keepalive checklist

Scope

No scope information available

Tasks

  • No tasks defined

Acceptance criteria

  • No acceptance criteria defined

@stranske
stranske merged commit 9d719c0 into main Jul 26, 2026
57 of 58 checks passed
@stranske
stranske deleted the model-promotion-prepare branch July 26, 2026 00:24

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
.github/workflows/maint-86-model-promotion-prepare.yml (2)

69-89: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Pin the third-party create-pull-request action to a commit SHA.

peter-evans/create-pull-request@v8 (line 71) is pinned to a mutable major-version tag rather than a commit SHA. Since this workflow is synced fleet-wide across consumer repos, a compromised or re-pointed tag on this third-party action would replicate everywhere.

🔒 Proposed fix
-      - uses: peter-evans/create-pull-request@v8
+      - uses: peter-evans/create-pull-request@<full-length-commit-sha>  # v8.x.x

As per path instructions, "Flag template-injection, unpinned third-party actions, and spoofable bot-actor checks — this workflow YAML is synced across the consumer repos, so one bug replicates fleet-wide."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/maint-86-model-promotion-prepare.yml around lines 69 - 89,
Update the create-pull-request action in the “Open promotion PR (human merges to
approve)” step to reference a full immutable commit SHA instead of the mutable
`@v8` tag. Preserve the existing action version and all surrounding workflow
inputs.

Source: Path instructions


1-89: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Mirror this workflow to templates/consumer-repo/.github/workflows/maint-86-model-promotion-prepare.yml. The consumer template has no matching copy, so this new workflow won’t propagate to consumer repos.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/maint-86-model-promotion-prepare.yml around lines 1 - 89,
Copy the complete Maint 86 model promotion prepare workflow, including its
workflow_dispatch inputs, preparation step, outputs, and pull-request creation
step, into
templates/consumer-repo/.github/workflows/maint-86-model-promotion-prepare.yml
so consumer repositories receive the same workflow.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/maint-86-model-promotion-prepare.yml:
- Around line 45-68: Update the “Prepare promotion/rollback” step’s piped Python
command so rc captures the Python process exit status rather than tee’s status,
using PIPESTATUS[0] or explicitly configuring shell: bash. Preserve the existing
rc-based handling, including the input-error path and promotion-gate status.

In `@tests/tools/test_prepare_model_promotion.py`:
- Around line 121-130: Extend the promotion tests around find_promotions to
cover equal-cost same-family candidates, asserting the candidate with lower p95
latency wins, and add a report containing qualifying candidates from two
providers, asserting one independent promotion is returned for each provider.
Preserve the existing cost-first and one-winner-per-provider behavior, including
deterministic to_model_id ordering when cost and latency also tie.

In `@tools/prepare_model_promotion.py`:
- Around line 109-169: Prevent promotion and rollback proposals from targeting
the same profile/provider in a single run. In main(), after evaluating
find_promotions and find_rollbacks against the original registry, suppress the
conflicting rollback (or otherwise give promotion precedence) before sequential
application. Ensure the resulting rollback rationale and from_model_id cannot
describe a rollback that was skipped or applied against a mutated selection.
- Around line 162-169: Update the sorting key in the proposal-selection logic to
treat only a missing p95_latency_ms value as infinite, while preserving
legitimate zero latency as the fastest value. Keep the existing candidate_cost,
latency, and to_model_id ordering and best_by_provider selection unchanged.

---

Outside diff comments:
In @.github/workflows/maint-86-model-promotion-prepare.yml:
- Around line 69-89: Update the create-pull-request action in the “Open
promotion PR (human merges to approve)” step to reference a full immutable
commit SHA instead of the mutable `@v8` tag. Preserve the existing action version
and all surrounding workflow inputs.
- Around line 1-89: Copy the complete Maint 86 model promotion prepare workflow,
including its workflow_dispatch inputs, preparation step, outputs, and
pull-request creation step, into
templates/consumer-repo/.github/workflows/maint-86-model-promotion-prepare.yml
so consumer repositories receive the same workflow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 036b7fc4-d42b-4b6e-a2ff-a3ea363c0f50

📥 Commits

Reviewing files that changed from the base of the PR and between 1aae759 and 44d1d27.

📒 Files selected for processing (7)
  • .github/workflows/maint-86-model-promotion-prepare.yml
  • docs/MODEL_SELECTION_POLICY.md
  • docs/ci/WORKFLOWS.md
  • docs/ci/WORKFLOW_SYSTEM.md
  • tests/tools/test_prepare_model_promotion.py
  • tests/workflows/test_workflow_naming.py
  • tools/prepare_model_promotion.py

Comment on lines +45 to +68
- name: Prepare promotion/rollback
id: prep
env:
BENCHMARK_PATH: ${{ inputs.benchmark_path }}
MODE: ${{ inputs.mode }}
run: |
set +e
python -m tools.prepare_model_promotion \
--benchmark "$BENCHMARK_PATH" \
--mode "$MODE" \
--write config/model_registry.json | tee prepare.log
rc=$?
echo "rc=$rc" >> "$GITHUB_OUTPUT"
{
echo '## Model promotion prepare'
echo '```'
cat prepare.log
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
if [ "$rc" = "2" ]; then
echo "::error::prepare_model_promotion input error."
exit 2
fi
exit 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== workflow snippet ==\n'
sed -n '35,80p' .github/workflows/maint-86-model-promotion-prepare.yml

printf '\n== template workflow snippet ==\n'
sed -n '35,80p' templates/consumer-repo/.github/workflows/maint-86-model-promotion-prepare.yml 2>/dev/null || true

printf '\n== shell/default hints ==\n'
rg -n 'defaults:|shell:' .github/workflows/maint-86-model-promotion-prepare.yml templates/consumer-repo/.github/workflows/maint-86-model-promotion-prepare.yml

Repository: stranske/Workflows

Length of output: 1923


🌐 Web query:

GitHub Actions default shell ubuntu-latest bash --noprofile --norc -eo pipefail unspecified shell documentation

💡 Result:

In GitHub Actions, the command used to execute bash script steps depends on whether you specify the shell explicitly [1]. If you do not specify a shell (i.e., it is unspecified/default), GitHub Actions uses the following command on Linux and macOS [1]: bash -e {0} If you explicitly specify shell: bash, GitHub Actions uses a more restrictive set of flags [1]: bash --noprofile --norc -eo pipefail {0} The differences between these two are significant: 1. --noprofile --norc: When shell: bash is specified, these flags are included to ensure a clean shell environment by preventing the loading of system-wide or user-specific profile/initialization files (e.g., ~/.bashrc, ~/.profile) [2][3][4]. The default/unspecified shell does not explicitly include these flags [1]. 2. pipefail: When shell: bash is specified, -o pipefail is enabled [5][1]. This causes a pipeline to return a non-zero exit code if any command in the pipeline fails [5]. The default/unspecified shell (-e only) only tracks the exit code of the final command in the pipeline [6][1]. In both cases, the -e (errexit) flag is used, which causes the step to exit immediately if a command returns a non-zero status [5][6][2]. Top results: [5][6][1]

Citations:


Capture the pipeline exit code explicitly
rc=$? here reads tee’s status because the step uses the default Linux shell (bash -e), so both the rc == 2 error path and the rc == 10 promotion gate can be skipped. Use rc=${PIPESTATUS[0]} or set the step to shell: bash.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/maint-86-model-promotion-prepare.yml around lines 45 - 68,
Update the “Prepare promotion/rollback” step’s piped Python command so rc
captures the Python process exit status rather than tee’s status, using
PIPESTATUS[0] or explicitly configuring shell: bash. Preserve the existing
rc-based handling, including the input-error path and promotion-gate status.

Comment on lines +121 to +130
def test_cheapest_same_family_candidate_wins_per_provider():
report = _report(
[
_result("claude-opus-4-6", "anthropic", status="passed", cost=0.10),
_result("claude-opus-4-8", "anthropic", status="passed", cost=0.09),
_result("claude-opus-4-7", "anthropic", status="passed", cost=0.05),
]
)
props = pmp.find_promotions(report, _registry())
assert len(props) == 1 and props[0]["to_model_id"] == "claude-opus-4-7"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Missing coverage for latency tie-break and multi-provider selection.

find_promotions documents "cheapest, then lowest-latency" per-provider winner selection and iterates across all providers in the report, but no test here exercises the latency tie-break (two same-family candidates with equal cost) or a report with two different providers each qualifying for an independent promotion. These are exactly the branches the "one winner per provider" sort key (candidate_cost, p95_latency_ms or float("inf"), to_model_id) is meant to protect.

As per path instructions, "Prioritize correctness, error handling, and test coverage. Flag new or changed behavior with no accompanying test."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/tools/test_prepare_model_promotion.py` around lines 121 - 130, Extend
the promotion tests around find_promotions to cover equal-cost same-family
candidates, asserting the candidate with lower p95 latency wins, and add a
report containing qualifying candidates from two providers, asserting one
independent promotion is returned for each provider. Preserve the existing
cost-first and one-winner-per-provider behavior, including deterministic
to_model_id ordering when cost and latency also tie.

Comment on lines +109 to +169
def find_promotions(
report: dict[str, Any], registry: dict[str, Any], *, profile: str = DEFAULT_PROFILE
) -> list[dict[str, Any]]:
"""Return prepared same-family, passing, cost<= promotions for the profile.

At most one promotion per provider (the cheapest, then lowest-latency, of the
qualifying same-family candidates).
"""
incumbent_id = str(report.get("baseline_model_id", "")).strip()
if not incumbent_id:
return []
incumbent_result = _result_for(report, incumbent_id)
incumbent_cost = _cost(incumbent_result) if incumbent_result else None

proposals: list[dict[str, Any]] = []
for result in report.get("results", []):
model_id = str(result.get("model_id", "")).strip()
provider = str(result.get("provider", "")).strip()
if not model_id or model_id == incumbent_id:
continue
selection = _selection_for(registry, profile, provider)
if selection is None:
continue
# Only prepare a swap of the *actual* incumbent for this provider/profile.
if str(selection.get("model_id", "")).strip() != incumbent_id:
continue
if result.get("status") != "passed":
continue
if model_family(provider, model_id) != model_family(provider, incumbent_id):
continue
cand_cost = _cost(result)
if cand_cost is None or incumbent_cost is None or cand_cost > incumbent_cost:
continue
evidence_id = _evidence_id(report, provider, model_id)
if not evidence_id:
continue
proposals.append(
{
"profile": profile,
"provider": _normalize_provider(provider),
"from_model_id": incumbent_id,
"to_model_id": model_id,
"evidence_id": evidence_id,
"incumbent_cost": incumbent_cost,
"candidate_cost": cand_cost,
"p95_latency_ms": (result.get("metrics") or {}).get("p95_latency_ms"),
"reason": (
f"same-family ({model_family(provider, model_id)}) non-inferior pass at "
f"cost/accepted {cand_cost} <= incumbent {incumbent_cost}"
),
}
)

# One winner per provider: cheapest, then lowest latency.
best_by_provider: dict[str, dict[str, Any]] = {}
for proposal in sorted(
proposals,
key=lambda p: (p["candidate_cost"], p["p95_latency_ms"] or float("inf"), p["to_model_id"]),
):
best_by_provider.setdefault(proposal["provider"], proposal)
return list(best_by_provider.values())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Promotion and rollback can silently collide for the same profile/provider in one run.

find_promotions and find_rollbacks are both evaluated (mode auto, the CLI and workflow default) against the original registry, then applied sequentially in main() (lines 313-325). If the incumbent's own result is "failed" (triggering a rollback for that profile/provider) while a different, cheaper same-family candidate simultaneously "passed" (triggering a promotion for the same profile/provider), both proposals target the same selection:

  1. apply_promotion runs first, moving the incumbent into selection_history[0] and setting the active model to the new candidate.
  2. apply_rollback then re-resolves prior_index on the now-mutated history (line 254-262) and finds index 0 — the just-superseded, failed incumbent — and reverts the active model back to it.

The prepared PR ends up reverting the legitimate promotion back to the model that just failed, while rollback["reason"]/rationale still reference the stale, pre-mutation from_model_id, making this hard for a human reviewer to spot in the diff/PR body.

🐛 Suggested guard
     promotions = (
         find_promotions(report, registry, profile=args.profile)
         if args.mode in {"promote", "auto"}
         else []
     )
     rollbacks = (
         find_rollbacks(report, registry, profile=args.profile)
         if args.mode in {"rollback", "auto"}
         else []
     )
+    # A profile/provider that is being promoted this run should never also be
+    # rolled back in the same run (rollback would revert the promotion using
+    # stale from/to ids and a stale history index).
+    promoted_keys = {(p["profile"], p["provider"]) for p in promotions}
+    rollbacks = [r for r in rollbacks if (r["profile"], r["provider"]) not in promoted_keys]

No test in the provided snippets exercises this combined case; consider adding one alongside the fix.

Also applies to: 206-232, 282-334

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/prepare_model_promotion.py` around lines 109 - 169, Prevent promotion
and rollback proposals from targeting the same profile/provider in a single run.
In main(), after evaluating find_promotions and find_rollbacks against the
original registry, suppress the conflicting rollback (or otherwise give
promotion precedence) before sequential application. Ensure the resulting
rollback rationale and from_model_id cannot describe a rollback that was skipped
or applied against a mutated selection.

Source: Path instructions

Comment on lines +162 to +169
# One winner per provider: cheapest, then lowest latency.
best_by_provider: dict[str, dict[str, Any]] = {}
for proposal in sorted(
proposals,
key=lambda p: (p["candidate_cost"], p["p95_latency_ms"] or float("inf"), p["to_model_id"]),
):
best_by_provider.setdefault(proposal["provider"], proposal)
return list(best_by_provider.values())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Falsy-zero bug in the latency tiebreaker.

p["p95_latency_ms"] or float("inf") maps a legitimate 0 latency to infinity (same as None), because 0 is falsy in Python. For the "cheapest, then lowest-latency" tiebreak, this would wrongly rank a genuinely fastest candidate (0ms) as worst.

🐛 Proposed fix
-        key=lambda p: (p["candidate_cost"], p["p95_latency_ms"] or float("inf"), p["to_model_id"]),
+        key=lambda p: (
+            p["candidate_cost"],
+            p["p95_latency_ms"] if p["p95_latency_ms"] is not None else float("inf"),
+            p["to_model_id"],
+        ),
As per path instructions, "unguarded NaN/None propagation in numeric or scoring code" should be flagged; this is the closest analog (falsy-zero instead of None) in this tiebreak/scoring path.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# One winner per provider: cheapest, then lowest latency.
best_by_provider: dict[str, dict[str, Any]] = {}
for proposal in sorted(
proposals,
key=lambda p: (p["candidate_cost"], p["p95_latency_ms"] or float("inf"), p["to_model_id"]),
):
best_by_provider.setdefault(proposal["provider"], proposal)
return list(best_by_provider.values())
# One winner per provider: cheapest, then lowest latency.
best_by_provider: dict[str, dict[str, Any]] = {}
for proposal in sorted(
proposals,
key=lambda p: (
p["candidate_cost"],
p["p95_latency_ms"] if p["p95_latency_ms"] is not None else float("inf"),
p["to_model_id"],
),
):
best_by_provider.setdefault(proposal["provider"], proposal)
return list(best_by_provider.values())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/prepare_model_promotion.py` around lines 162 - 169, Update the sorting
key in the proposal-selection logic to treat only a missing p95_latency_ms value
as infinite, while preserving legitimate zero latency as the fastest value. Keep
the existing candidate_cost, latency, and to_model_id ordering and
best_by_provider selection unchanged.

Source: Path instructions

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants