Skip to content

maint-79: grow verifier corpus from realized PR outcomes (#2819 move 2) - #2832

Merged
stranske merged 3 commits into
mainfrom
model-eval-corpus-harvest
Jul 25, 2026
Merged

maint-79: grow verifier corpus from realized PR outcomes (#2819 move 2)#2832
stranske merged 3 commits into
mainfrom
model-eval-corpus-harvest

Conversation

@stranske

@stranske stranske commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Part of #2819 (self-feeding verifier-model promotion) — move 2 of 3. Builds on move 1 (#2831).

Problem

The evaluation corpus (config/model_eval_pilot.json) was hand-built and never grows, so the approval benchmark stays perpetually under its 75-case minimum and no model is ever promoted. The evidence supply chain — not the ranking math — is the real chokepoint.

Change

Harvest new corpus cases from outcomes the world has already adjudicated by merging or reverting a PR:

  • tools/harvest_verifier_corpus.pyclassify() labels a merged PR from its realized outcome:

    • stable-for-N-days clean merge → high-confidence clean-pass (PASS)
    • reverted within window → high-confidence regression-after-merge (NON_PASS)
    • resolved verifier follow-up → high-confidence follow-up-required (NON_PASS)

    High-confidence cases auto-promote into the frozen corpus (version → +harvestN, dedup by repo+pr, per-category caps so easy-to-source clean-pass can't flood it and starve category balance). Ambiguous cases (too-recent to be stable, unresolved follow-up) route to an FYI-only staging file that auto-expires — no adjudication backlog can accumulate. fetch_records() targets a stability-aged merged-date window (a newest-N fetch only returns too-recent merges that can never promote — a live-data failure mode I hit and fixed).

  • config/model_selection_policy.jsoncorpus_growth block (stability/expiry/window days, max size, category caps, the 12-repo lane fleet as sources).

  • tests/tools/test_model_eval_pilot.py — the freeze test now digest-freezes only the owner-adjudicated seed (provenance != harvested) and validates harvested cases structurally, so growth is allowed but silent tampering with the seed still reddens CI.

  • maint-79 — weekly + dispatch harvest that opens an auto-merging corpus-growth PR (the PR is the audit trail, never a gate).

Design guarantees

  • Zero-Tim (per the owner's human-involvement rule): high-confidence cases flow automatically; only genuinely ambiguous ones stage, and staging auto-expires — a mounting backlog is structurally impossible.
  • Owner-adjudication preserved where it matters: the semantic NON_PASS categories (stale-verifier-claim, review-thread-debt, missing-acceptance-criterion) cannot be labeled from outcomes and are never machine-added; the original 30 seed cases stay digest-frozen.
  • Model promotion is unchanged — a larger corpus just makes the existing gated benchmark reachable; new_catalog_models_auto_promote=false and human approval still hold. That gate is move 3.

Verification (local, CI-pinned)

  • black==26.5.1 / ruff==0.15.20 / mypy==2.1.0 clean
  • 16 tests pass (13 harvester + 3 corpus-freeze)
  • Live FLOW: ran fetch_records + partition + grow_corpus against real stranske/Manager-Database PRs — 30→51 cases, seed digest still frozen, only clean-pass harvested, ids unique. The grown corpus passes the freeze test, confirming the future auto-PR merges cleanly.

Summary by CodeRabbit

  • New Features

    • Added automated harvesting of realized verifier outcomes to grow the evaluation corpus.
    • Added support for dry runs, persisted updates, staging, deduplication, expiration, and corpus size limits.
    • Added scheduled and manually triggered corpus maintenance with automatic update proposals.
    • Added configuration for corpus growth, promotion confidence, category limits, and source repositories.
  • Tests

    • Added comprehensive coverage for classification, promotion, staging, limits, expiration, and disabled-growth scenarios.
    • Added safeguards for the owner-adjudicated evaluation seed data.

Move 2 of the self-feeding verifier-model promotion system (#2819).

The evaluation corpus was hand-built and never grew, so the approval
benchmark stays perpetually under its 75-case minimum and no model is ever
promoted — the evidence supply chain is the real chokepoint. This harvests
new cases from outcomes the world already adjudicated by merging/reverting
a PR, so the corpus grows with near-zero owner time.

- tools/harvest_verifier_corpus.py: classify() labels a merged PR from its
  realized outcome — stable-for-N-days clean merge -> high-confidence
  clean-pass PASS; reverted-within-window -> regression-after-merge NON_PASS;
  resolved verifier follow-up -> follow-up-required NON_PASS. High-confidence
  cases auto-promote into the frozen corpus (version bumps +harvestN, dedup
  by repo+pr, per-category caps so clean-pass can't flood it). Ambiguous
  cases (too-recent, unresolved follow-up) route to an FYI-only staging file
  that auto-expires after staging_expiry_days, so no adjudication backlog can
  accumulate. fetch_records() targets PRs merged in a stability-aged date
  window (a newest-N fetch only returns too-recent merges that never
  promote). The semantic NON_PASS categories (stale-verifier-claim,
  review-thread-debt, missing-acceptance-criterion) cannot be labeled from
  outcomes and remain owner-sourced; the tool never fabricates them.
- config/model_selection_policy.json: corpus_growth block (stability_days,
  staging_expiry_days, harvest_window_days, max_corpus_size, category_caps,
  source_repos = the 12-repo lane fleet).
- config/model_eval_corpus_staging.json: FYI staging seed.
- tests/tools/test_model_eval_pilot.py: the freeze test now digest-freezes
  only the owner-adjudicated SEED (provenance != harvested) and validates
  harvested cases structurally — so growth is allowed but silent tampering
  with the seed still fails CI.
- maint-79: weekly + dispatch harvest that opens an auto-merging
  corpus-growth PR (the PR is the audit trail, never a gate); PR events run
  the unit tests only.

Zero-Tim by design: high-confidence cases flow automatically; only genuinely
ambiguous ones stage, FYI-only and auto-expiring. Promotion of MODELS is
unchanged — a larger corpus just makes the existing gated benchmark reachable.

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

cursor Bot commented Jul 25, 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.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 25 minutes

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e4b61690-88c5-4430-a537-143e8d44a78d

📥 Commits

Reviewing files that changed from the base of the PR and between f7a12cd and e85bcc7.

📒 Files selected for processing (4)
  • .github/workflows/maint-79-verifier-corpus-harvest.yml
  • docs/ci/WORKFLOWS.md
  • docs/ci/WORKFLOW_SYSTEM.md
  • tests/workflows/test_workflow_naming.py
📝 Walkthrough

Walkthrough

Adds a configurable verifier-corpus harvester that classifies merged PR outcomes, promotes stable cases, stages uncertain cases, and updates corpus files. It includes tests, scheduled/manual GitHub Actions automation, auto-merge PR creation, policy configuration, and worker-attempt metadata updates.

Changes

Verifier corpus growth

Layer / File(s) Summary
Corpus configuration and outcome classification
config/model_eval_corpus_staging.json, config/model_selection_policy.json, tools/harvest_verifier_corpus.py
Defines corpus-growth settings and staging data, then classifies PR records into promotion and staging outcomes.
Corpus updates and live harvesting
tools/harvest_verifier_corpus.py
Adds deduplication, size and category caps, version increments, staging expiry, gh-based record harvesting, configuration loading, and CLI execution.
Harvest and pilot corpus validation
tests/tools/test_harvest_verifier_corpus.py, tests/tools/test_model_eval_pilot.py
Tests classification, partitioning, corpus growth, staging expiry, CLI writes, disabled growth, seed preservation, and harvested-case constraints.
Scheduled harvest workflow
.github/workflows/maint-79-verifier-corpus-harvest.yml
Adds pull-request tests, scheduled and manual harvesting, corpus update pull requests, and conditional squash auto-merge.

Worker attempt metadata

Layer / File(s) Summary
Worker attempt record
langsmith-fleet-worker-attempt.json
Updates the emitted timestamp and pull request number while retaining the fallback model entry.

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

Sequence Diagram(s)

sequenceDiagram
  participant GitHubActions
  participant Harvester
  participant CorpusFiles
  participant PullRequest
  GitHubActions->>Harvester: run scheduled or manual harvest
  Harvester->>CorpusFiles: classify records and update corpus/staging
  GitHubActions->>PullRequest: create corpus-growth pull request
  PullRequest-->>GitHubActions: return pull request number
  GitHubActions->>PullRequest: enable squash auto-merge
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.84% 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 clearly summarizes the main change: growing the verifier corpus from realized PR outcomes.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch model-eval-corpus-harvest

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

@stranske
stranske enabled auto-merge (squash) July 25, 2026 22:55
@stranske
stranske temporarily deployed to agent-high-privilege July 25, 2026 22:56 — with GitHub Actions Inactive
@agents-workflows-bot

Copy link
Copy Markdown
Contributor

Workflow source needed

PR #2832 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.

if: steps.cpr.outputs.pull-request-number
env:
GH_TOKEN: ${{ secrets.OWNER_PR_PAT }}
run: gh pr merge "${{ steps.cpr.outputs.pull-request-number }}" --squash --auto
Comment on lines +65 to +78
run: |
mode=""
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.write }}" != "true" ]; then
mode="dry-run"
python -m tools.harvest_verifier_corpus | tee harvest.log
else
python -m tools.harvest_verifier_corpus --write | tee harvest.log
fi
{
echo '## Verifier corpus harvest'
echo '```'
cat harvest.log
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v6
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v6
- name: Open auto-merging corpus-growth PR
if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.write)
id: cpr
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: f15c5cae46

ℹ️ 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 +308 to +312
labels & {"verify:create-issue", "verifier-followup"}
),
# Resolution of a follow-up needs semantic judgment; stay conservative
# (unresolved -> staged, never auto-labeled NON_PASS).
"followup_resolved": False,

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 Record durable follow-up outcomes

In the scheduled live-data path, every detected follow-up is hard-coded as unresolved, while the only checked signals are current verify:create-issue/verifier-followup labels. The repository's follow-up workflows remove their trigger labels after processing, and verify:create-new-pr is not checked at all, so a completed verifier follow-up will normally be classified as clean-pass after 30 days; even if a label remains, it stays staged forever. Resolve follow-up status from durable workflow/issue evidence before assigning the expected verdict.

Useful? React with 👍 / 👎.

Comment on lines +184 to +185
if key in existing or len(cases) >= max_size:
continue

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 Replace stale PASS cases after a revert

When a PR is first harvested just after the 30-day threshold, it can be added as a PASS; if it is reverted on a later weekly run, classify produces NON_PASS, but this repo/PR deduplication silently skips the updated outcome. The corpus therefore retains a known-false PASS indefinitely, so existing harvested cases need to be replaced or quarantined when their realized outcome changes.

Useful? React with 👍 / 👎.

@@ -0,0 +1,106 @@
name: Maint 79 Verifier Corpus Harvest

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 Register the workflow in required inventories

This new workflow is absent from docs/ci/WORKFLOWS.md, docs/ci/WORKFLOW_SYSTEM.md, and EXPECTED_NAMES in tests/workflows/test_workflow_naming.py. The naming test asserts that every workflow has a display-name mapping, and the inventory test requires every workflow in both documents, so the repository's workflow validation will fail; scripts/check_docs_drift.py also reports this file as undocumented. Register the workflow in all three required surfaces.

AGENTS.md reference: AGENTS.md:L81-L81

Useful? React with 👍 / 👎.

Comment on lines +256 to +258
for pr in reverts:
for match in _REVERT_REF.findall(f"{pr.get('title', '')} {pr.get('body', '')}"):
numbers.add(int(match))

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 Restrict revert matching to the reverted PR

For any merged PR with revert in its title, this loop treats every #N in both title and body as a reverted PR. If the revert body also references an issue or unrelated PR for context, that unrelated candidate is assigned a high-confidence regression-after-merge verdict and permanently contaminates the evaluation corpus. Parse the canonical revert target or verify the reverted commit relationship instead of accepting all references.

Useful? React with 👍 / 👎.

@agents-workflows-bot

agents-workflows-bot Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Automated Status Summary

Head SHA: 874dc01
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.83%
Baseline 85.00%
Delta -9.17%
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

CI feedback on #2832:
- Register maint-79 in EXPECTED_NAMES (tests/workflows/test_workflow_naming.py)
  and the WORKFLOWS.md / WORKFLOW_SYSTEM.md inventories, as required for any
  new workflow.
- Remove the pull_request `test` job: it ran `python -m pytest` without
  installing pytest (No module named pytest). The harvester's unit tests
  already run in the standard python-ci suite, so the job was redundant.
- Drop the unused `mode` shell variable (actionlint shellcheck SC2034).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@stranske
stranske temporarily deployed to agent-high-privilege July 25, 2026 23:15 — with GitHub Actions Inactive

@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: 8

🤖 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-79-verifier-corpus-harvest.yml:
- Around line 65-72: Remove the unused mode variable and its assignments from
the workflow step while preserving the existing conditional command selection
between dry-run and --write modes.
- Around line 23-27: Update the workflow path filters to include
config/model_eval_pilot.json and tests/tools/test_model_eval_pilot.py, then
adjust the test command at the existing test step to run both test modules:
test_harvest_verifier_corpus.py and test_model_eval_pilot.py. Preserve the
current harvest-related trigger paths and validation behavior.

In `@tests/tools/test_harvest_verifier_corpus.py`:
- Around line 141-156: Add coverage in the harvest verifier test around hv.main
to invoke the same arguments without --write, then assert both corpus and
staging files remain unchanged. Preserve the existing --write assertions for
promoted and staged cases, and verify the default dry-run behavior before any
persistence occurs.

In `@tools/harvest_verifier_corpus.py`:
- Around line 71-106: Update the follow-up classification behavior and module
documentation consistently: either implement resolution detection in
fetch_records so classify can produce high-confidence CATEGORY_FOLLOW_UP results
from live data, or revise the advertised auto-promotion documentation to state
that follow-up records are staging-only in production because followup_resolved
remains false. Preserve the existing --from-json fixture behavior.
- Around line 200-220: Update prune_staging so cases with missing or unparseable
harvested_at are conservatively expired rather than treating now as their
first_seen time. Use _parse_ts within the existing case loop and skip the case
when it returns no valid timestamp; preserve the current age-based expiry and
deduplication behavior for valid timestamps.
- Around line 321-402: Update _load so it returns the supplied default only when
the target file is missing, while propagating OSError and JSONDecodeError for
existing unreadable or malformed files. Ensure main’s corpus loading cannot
silently treat a corrupted existing corpus as empty before the --write path;
retain the empty default for genuinely absent corpus and staging files.
- Around line 226-260: Restrict _REVERT_REF and its use in _reverted_pr_numbers
to extract only the original PR number from GitHub’s default revert title
format, rather than every `#N` mention across title and body text. Match the
revert title structure and stop scanning the body for unrelated references;
preserve the resulting set[int] behavior for valid revert PRs.
- Around line 262-315: Update _gh_json to enforce a finite subprocess timeout
while preserving its existing non-shell invocation, and wrap each repo’s fetch
work in fetch_records—including _gh_json and _reverted_pr_numbers—in a
subprocess.SubprocessError handler. Log the failing repository and continue
processing remaining repos so one timeout or gh failure does not abort the
harvest.
🪄 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: e0be2fdd-b2f3-4a6e-869a-4b2b30d70036

📥 Commits

Reviewing files that changed from the base of the PR and between 9aa4404 and f7a12cd.

📒 Files selected for processing (7)
  • .github/workflows/maint-79-verifier-corpus-harvest.yml
  • config/model_eval_corpus_staging.json
  • config/model_selection_policy.json
  • langsmith-fleet-worker-attempt.json
  • tests/tools/test_harvest_verifier_corpus.py
  • tests/tools/test_model_eval_pilot.py
  • tools/harvest_verifier_corpus.py

Comment on lines +23 to +27
paths:
- tools/harvest_verifier_corpus.py
- tests/tools/test_harvest_verifier_corpus.py
- config/model_selection_policy.json
- config/model_eval_corpus_staging.json

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Run pilot-corpus validation for harvest PRs.

config/model_eval_pilot.json and tests/tools/test_model_eval_pilot.py are excluded from the trigger, while Line 48 runs only the harvester tests. A corpus-only update can therefore bypass the frozen-seed and harvested-case invariants before auto-merge. Add both paths and run both test modules.

Proposed fix
   pull_request:
     paths:
       - tools/harvest_verifier_corpus.py
       - tests/tools/test_harvest_verifier_corpus.py
+      - tests/tools/test_model_eval_pilot.py
       - config/model_selection_policy.json
       - config/model_eval_corpus_staging.json
+      - config/model_eval_pilot.json
...
       - name: Unit tests
-        run: python -m pytest tests/tools/test_harvest_verifier_corpus.py -q
+        run: python -m pytest tests/tools/test_harvest_verifier_corpus.py tests/tools/test_model_eval_pilot.py -q

As per path instructions, “Flag new or changed behavior with no accompanying test.”

Also applies to: 47-48

🤖 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-79-verifier-corpus-harvest.yml around lines 23 - 27,
Update the workflow path filters to include config/model_eval_pilot.json and
tests/tools/test_model_eval_pilot.py, then adjust the test command at the
existing test step to run both test modules: test_harvest_verifier_corpus.py and
test_model_eval_pilot.py. Preserve the current harvest-related trigger paths and
validation behavior.

Source: Path instructions

Comment thread .github/workflows/maint-79-verifier-corpus-harvest.yml
Comment on lines +141 to +156
argv = [
"--policy",
str(pol_p),
"--corpus",
str(cor_p),
"--staging",
str(stg_p),
"--from-json",
str(rec_p),
"--write",
]
assert hv.main(argv) == 0
grown = json.loads(cor_p.read_text())
assert [c["pr"] for c in grown["cases"]] == [1] # only the stable merge promoted
staged = json.loads(stg_p.read_text())
assert [c["pr"] for c in staged["cases"]] == [2] # recent merge staged

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Test the default dry-run path.

This test always passes --write; it never verifies that the default invocation leaves corpus and staging files unchanged. Add a no---write invocation and assert no persistence occurs.

As per path instructions, “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_harvest_verifier_corpus.py` around lines 141 - 156, Add
coverage in the harvest verifier test around hv.main to invoke the same
arguments without --write, then assert both corpus and staging files remain
unchanged. Preserve the existing --write assertions for promoted and staged
cases, and verify the default dry-run behavior before any persistence occurs.

Source: Path instructions

Comment on lines +71 to +106
def classify(
record: dict[str, Any], *, now: datetime, stability_days: int
) -> dict[str, Any] | None:
"""Label a single PR record from its realized outcome.

Returns ``{expected_verdict, category, confidence}`` or ``None`` when the PR
carries no usable verifier signal (e.g. never merged).
"""
if not record.get("merged"):
return None
merged_at = _parse_ts(record.get("merged_at"))
if merged_at is None:
return None

if record.get("reverted"):
return {
"expected_verdict": "NON_PASS",
"category": CATEGORY_REGRESSION,
"confidence": "high",
}

if record.get("verifier_followup"):
confidence = "high" if record.get("followup_resolved") else "low"
return {
"expected_verdict": "NON_PASS",
"category": CATEGORY_FOLLOW_UP,
"confidence": confidence,
}

age_days = (now - merged_at).total_seconds() / 86400.0
confidence = "high" if age_days >= stability_days else "low"
return {
"expected_verdict": "PASS",
"category": CATEGORY_CLEAN_PASS,
"confidence": confidence,
}

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

High-confidence follow-up-required path is unreachable from live data.

classify() only returns confidence: "high" for the follow-up category when record["followup_resolved"] is true (Line 93), but fetch_records() always sets "followup_resolved": False (Line 312), with a comment explaining resolution needs semantic judgment. In practice this means the third auto-promotion mechanism the module docstring advertises (lines 17-18: "resolved verifier-driven follow-up → high-confidence") never fires outside of --from-json test fixtures — real follow-up cases will always be staged and eventually auto-expire, never promoted. Worth either implementing real resolution detection or updating the docstring/note to reflect that this category is currently staging-only in production.

🤖 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/harvest_verifier_corpus.py` around lines 71 - 106, Update the follow-up
classification behavior and module documentation consistently: either implement
resolution detection in fetch_records so classify can produce high-confidence
CATEGORY_FOLLOW_UP results from live data, or revise the advertised
auto-promotion documentation to state that follow-up records are staging-only in
production because followup_resolved remains false. Preserve the existing
--from-json fixture behavior.

Comment on lines +200 to +220
def prune_staging(
staging: dict[str, Any], stage_new: list[dict[str, Any]], *, now: datetime, expiry_days: int
) -> dict[str, Any]:
"""Merge new staging cases and drop any older than ``expiry_days`` (auto-expiry)."""
kept: list[dict[str, Any]] = []
seen: set[tuple[Any, Any]] = set()
for case in list(staging.get("cases", [])) + stage_new:
key = (case.get("repo"), case.get("pr"))
if key in seen:
continue
first_seen = _parse_ts(case.get("harvested_at")) or now
if (now - first_seen).total_seconds() / 86400.0 > expiry_days:
continue
seen.add(key)
kept.append(case)
return {
"schema": staging.get("schema", "verifier-corpus-staging/v1"),
"note": "FYI-only. Auto-expiring candidate cases pending stability or adjudication; "
"nothing here gates anything.",
"cases": kept,
}

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

Malformed/missing harvested_at is treated as "just harvested" instead of expired.

first_seen = _parse_ts(case.get("harvested_at")) or now (Line 210) falls back to now when the timestamp is missing or unparseable, which makes (now - first_seen) evaluate to ~0 — the opposite of what the auto-expiry safety net intends. A corrupted/missing harvested_at should be treated conservatively (i.e., expire it), not perpetually renewed as "fresh," since the whole design relies on this file self-cleaning without human action.

🛠️ Proposed fix
-        first_seen = _parse_ts(case.get("harvested_at")) or now
-        if (now - first_seen).total_seconds() / 86400.0 > expiry_days:
+        first_seen = _parse_ts(case.get("harvested_at"))
+        if first_seen is None or (now - first_seen).total_seconds() / 86400.0 > expiry_days:
             continue
📝 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
def prune_staging(
staging: dict[str, Any], stage_new: list[dict[str, Any]], *, now: datetime, expiry_days: int
) -> dict[str, Any]:
"""Merge new staging cases and drop any older than ``expiry_days`` (auto-expiry)."""
kept: list[dict[str, Any]] = []
seen: set[tuple[Any, Any]] = set()
for case in list(staging.get("cases", [])) + stage_new:
key = (case.get("repo"), case.get("pr"))
if key in seen:
continue
first_seen = _parse_ts(case.get("harvested_at")) or now
if (now - first_seen).total_seconds() / 86400.0 > expiry_days:
continue
seen.add(key)
kept.append(case)
return {
"schema": staging.get("schema", "verifier-corpus-staging/v1"),
"note": "FYI-only. Auto-expiring candidate cases pending stability or adjudication; "
"nothing here gates anything.",
"cases": kept,
}
def prune_staging(
staging: dict[str, Any], stage_new: list[dict[str, Any]], *, now: datetime, expiry_days: int
) -> dict[str, Any]:
"""Merge new staging cases and drop any older than ``expiry_days`` (auto-expiry)."""
kept: list[dict[str, Any]] = []
seen: set[tuple[Any, Any]] = set()
for case in list(staging.get("cases", [])) + stage_new:
key = (case.get("repo"), case.get("pr"))
if key in seen:
continue
first_seen = _parse_ts(case.get("harvested_at"))
if first_seen is None or (now - first_seen).total_seconds() / 86400.0 > expiry_days:
continue
seen.add(key)
kept.append(case)
return {
"schema": staging.get("schema", "verifier-corpus-staging/v1"),
"note": "FYI-only. Auto-expiring candidate cases pending stability or adjudication; "
"nothing here gates anything.",
"cases": kept,
}
🤖 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/harvest_verifier_corpus.py` around lines 200 - 220, Update
prune_staging so cases with missing or unparseable harvested_at are
conservatively expired rather than treating now as their first_seen time. Use
_parse_ts within the existing case loop and skip the case when it returns no
valid timestamp; preserve the current age-based expiry and deduplication
behavior for valid timestamps.

Comment on lines +226 to +260
def _gh_json(args: list[str]) -> Any:
result = subprocess.run(["gh", *args], check=True, capture_output=True, text=True) # noqa: S607
return json.loads(result.stdout or "null")


_REVERT_REF = re.compile(r"#(\d+)")


def _reverted_pr_numbers(repo: str) -> set[int]: # pragma: no cover - integration
"""PR numbers referenced by any recent revert PR/commit in the repo."""
reverts = (
_gh_json(
[
"pr",
"list",
"--repo",
repo,
"--state",
"merged",
"--search",
"revert in:title",
"--limit",
"100",
"--json",
"title,body",
]
)
or []
)
numbers: set[int] = set()
for pr in reverts:
for match in _REVERT_REF.findall(f"{pr.get('title', '')} {pr.get('body', '')}"):
numbers.add(int(match))
return numbers

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 | 🟠 Major | ⚡ Quick win

Revert-reference regex over-matches, risking mislabeled regression-after-merge cases.

_REVERT_REF = re.compile(r"#(\d+)") (Line 231) is applied to the full title+body text of every revert-titled PR (Line 257). Any unrelated #N mention in that text (e.g. "fixes #456", "follow-up to #789") gets added to the reverted-PR set, so an unrelated, perfectly healthy merged PR could be auto-labeled high-confidence regression-after-merge and land in the frozen corpus untouched by human review. Since this harvester's whole premise is trustworthy auto-labeled outcomes, a loose match here directly threatens corpus quality.

(Note: the static-analysis "XPath injection" hint on this regex is a false positive — this is a plain Python re.findall, not XPath.)

🛠️ Proposed fix — anchor to GitHub's default revert title format
-_REVERT_REF = re.compile(r"#(\d+)")
+_REVERT_REF = re.compile(r'^Revert ".*"\s*\(#(\d+)\)\s*$')
...
-    for pr in reverts:
-        for match in _REVERT_REF.findall(f"{pr.get('title', '')} {pr.get('body', '')}"):
-            numbers.add(int(match))
+    for pr in reverts:
+        match = _REVERT_REF.match(pr.get("title", "") or "")
+        if match:
+            numbers.add(int(match.group(1)))
📝 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
def _gh_json(args: list[str]) -> Any:
result = subprocess.run(["gh", *args], check=True, capture_output=True, text=True) # noqa: S607
return json.loads(result.stdout or "null")
_REVERT_REF = re.compile(r"#(\d+)")
def _reverted_pr_numbers(repo: str) -> set[int]: # pragma: no cover - integration
"""PR numbers referenced by any recent revert PR/commit in the repo."""
reverts = (
_gh_json(
[
"pr",
"list",
"--repo",
repo,
"--state",
"merged",
"--search",
"revert in:title",
"--limit",
"100",
"--json",
"title,body",
]
)
or []
)
numbers: set[int] = set()
for pr in reverts:
for match in _REVERT_REF.findall(f"{pr.get('title', '')} {pr.get('body', '')}"):
numbers.add(int(match))
return numbers
def _gh_json(args: list[str]) -> Any:
result = subprocess.run(["gh", *args], check=True, capture_output=True, text=True) # noqa: S607
return json.loads(result.stdout or "null")
_REVERT_REF = re.compile(r'^Revert ".*"\s*\(#(\d+)\)\s*$')
def _reverted_pr_numbers(repo: str) -> set[int]: # pragma: no cover - integration
"""PR numbers referenced by any recent revert PR/commit in the repo."""
reverts = (
_gh_json(
[
"pr",
"list",
"--repo",
repo,
"--state",
"merged",
"--search",
"revert in:title",
"--limit",
"100",
"--json",
"title,body",
]
)
or []
)
numbers: set[int] = set()
for pr in reverts:
match = _REVERT_REF.match(pr.get("title", "") or "")
if match:
numbers.add(int(match.group(1)))
return numbers
🧰 Tools
🪛 ast-grep (0.44.1)

[error] 226-226: Command coming from incoming request
Context: subprocess.run(["gh", *args], check=True, capture_output=True, text=True)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)


[warning] 256-256: XPath query is request-/variable-derived; use parameterized XPath to prevent injection.
Context: _REVERT_REF.findall(f"{pr.get('title', '')} {pr.get('body', '')}")
Note: [CWE-643] Improper Neutralization of Data within XPath Expressions ('XPath Injection').

(xpath-injection-python)

🤖 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/harvest_verifier_corpus.py` around lines 226 - 260, Restrict
_REVERT_REF and its use in _reverted_pr_numbers to extract only the original PR
number from GitHub’s default revert title format, rather than every `#N` mention
across title and body text. Match the revert title structure and stop scanning
the body for unrelated references; preserve the resulting set[int] behavior for
valid revert PRs.

Comment on lines +262 to +315
def fetch_records(
repos: list[str], *, per_repo: int, stability_days: int, harvest_window_days: int
) -> list[dict[str, Any]]: # pragma: no cover - integration
"""Fetch PRs that crossed the stability line recently, with revert/follow-up signals.

Targets PRs merged in ``[now - stability_days - harvest_window, now - stability_days]``
so every candidate is already past the stability window (a newest-N fetch only
returns too-recent merges that can never promote — the live-data failure mode
this window fixes).
"""
now = datetime.now(UTC)
end = (now - timedelta(days=stability_days)).date().isoformat()
start = (now - timedelta(days=stability_days + harvest_window_days)).date().isoformat()
records: list[dict[str, Any]] = []
for repo in repos:
merged = (
_gh_json(
[
"pr",
"list",
"--repo",
repo,
"--state",
"merged",
"--search",
f"merged:{start}..{end}",
"--limit",
str(per_repo),
"--json",
"number,title,mergedAt,body,labels",
]
)
or []
)
reverted = _reverted_pr_numbers(repo)
for pr in merged:
number = pr.get("number")
labels = {lb.get("name") for lb in pr.get("labels", []) if isinstance(lb, dict)}
records.append(
{
"repo": repo,
"pr": number,
"merged": True,
"merged_at": pr.get("mergedAt"),
"reverted": number in reverted,
"verifier_followup": bool(
labels & {"verify:create-issue", "verifier-followup"}
),
# Resolution of a follow-up needs semantic judgment; stay conservative
# (unresolved -> staged, never auto-labeled NON_PASS).
"followup_resolved": False,
}
)
return records

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

gh subprocess calls lack timeouts and per-repo failure isolation.

_gh_json (Line 227) has no timeout=, so a hung gh call (auth prompt, network stall) blocks the whole harvest run. Additionally, check=True with no try/except around each repo's _gh_json/_reverted_pr_numbers call in fetch_records's loop (lines 276-315) means one flaky/renamed/inaccessible repo in source_repos aborts the entire scheduled harvest, losing candidate signal from every other repo for that run.

(The static-analysis "OS command injection" hint on _gh_json is a false positive here — no shell=True is used and repo values come from the static policy config, not external request input.)

🛠️ Suggested direction
-def _gh_json(args: list[str]) -> Any:
-    result = subprocess.run(["gh", *args], check=True, capture_output=True, text=True)  # noqa: S607
+def _gh_json(args: list[str], *, timeout: int = 60) -> Any:
+    result = subprocess.run(
+        ["gh", *args], check=True, capture_output=True, text=True, timeout=timeout  # noqa: S607
+    )
     return json.loads(result.stdout or "null")

And wrap the per-repo body in fetch_records with try/except subprocess.SubprocessError to skip and log a failing repo rather than aborting the whole run.

🤖 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/harvest_verifier_corpus.py` around lines 262 - 315, Update _gh_json to
enforce a finite subprocess timeout while preserving its existing non-shell
invocation, and wrap each repo’s fetch work in fetch_records—including _gh_json
and _reverted_pr_numbers—in a subprocess.SubprocessError handler. Log the
failing repository and continue processing remaining repos so one timeout or gh
failure does not abort the harvest.

Comment on lines +321 to +402
def _load(path: Path, default: Any) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return default


def _growth_config(policy: dict[str, Any], profile: str) -> dict[str, Any]:
prof = policy.get("profiles", {}).get(profile, {})
cfg = dict(prof.get("corpus_growth", {}))
cfg.setdefault("enabled", False)
cfg.setdefault("stability_days", 30)
cfg.setdefault("staging_expiry_days", 60)
cfg.setdefault("harvest_window_days", 60)
cfg.setdefault("max_corpus_size", 150)
cfg.setdefault(
"category_caps",
{CATEGORY_CLEAN_PASS: 40, CATEGORY_REGRESSION: 20, CATEGORY_FOLLOW_UP: 20},
)
cfg.setdefault("source_repos", [])
return cfg


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Grow the verifier corpus from realized PR outcomes."
)
parser.add_argument("--corpus", type=Path, default=DEFAULT_CORPUS_PATH)
parser.add_argument("--staging", type=Path, default=DEFAULT_STAGING_PATH)
parser.add_argument("--policy", type=Path, default=DEFAULT_POLICY_PATH)
parser.add_argument("--profile", default=DEFAULT_PROFILE)
parser.add_argument("--from-json", type=Path, help="Read pre-fetched PR records instead of gh.")
parser.add_argument("--per-repo", type=int, default=50)
parser.add_argument(
"--write", action="store_true", help="Persist corpus + staging (default: dry-run)."
)
args = parser.parse_args(argv)

policy = _load(args.policy, {})
cfg = _growth_config(policy, args.profile)
if not cfg["enabled"]:
print("corpus_growth is disabled in the policy; nothing to do.")
return 0

now = datetime.now(UTC)
if args.from_json:
records = _load(args.from_json, [])
else: # pragma: no cover - integration path
records = fetch_records(
list(cfg["source_repos"]),
per_repo=args.per_repo,
stability_days=int(cfg["stability_days"]),
harvest_window_days=int(cfg["harvest_window_days"]),
)

promote, stage = partition(records, now=now, stability_days=int(cfg["stability_days"]))
corpus = _load(args.corpus, {"cases": []})
grown, added = grow_corpus(
corpus,
promote,
max_size=int(cfg["max_corpus_size"]),
category_caps=dict(cfg.get("category_caps") or {}),
)
staging = _load(args.staging, {"cases": []})
new_staging = prune_staging(
staging, stage, now=now, expiry_days=int(cfg["staging_expiry_days"])
)

print(
f"harvest: {len(records)} records → {len(added)} promoted "
f"(corpus {len(corpus.get('cases', []))}→{len(grown.get('cases', []))}), "
f"{len(new_staging['cases'])} staged (FYI, auto-expiring)."
)
for case in added:
print(f" + {case['case_id']} {case['expected_verdict']} ({case['category']})")

if args.write:
if added:
args.corpus.write_text(json.dumps(grown, indent=2) + "\n", encoding="utf-8")
args.staging.write_text(json.dumps(new_staging, indent=2) + "\n", encoding="utf-8")
print(f"wrote corpus={args.corpus} staging={args.staging}")
return 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.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Silent load failure can wipe the frozen corpus on --write.

_load() (lines 321-325) catches both OSError and json.JSONDecodeError and returns the caller-supplied default. In main(), corpus = _load(args.corpus, {"cases": []}) (Line 377) uses that same fallback for the existing frozen pilot corpus. If the corpus file is ever unreadable or transiently corrupted (bad encoding, partial write from a previous run, wrong path), this silently proceeds as if the corpus were empty, and — with --writeargs.corpus.write_text(json.dumps(grown, ...)) (Line 399) then overwrites the real file with only the newly-harvested cases, permanently destroying the human-adjudicated, versioned seed corpus this tool explicitly says must stay "frozen, versioned, and trustworthy" (module docstring, lines 6-7). The same swallow applies to args.staging (Line 384), though the FYI/auto-expiring nature there makes that loss far less severe.

As per path instructions, "Flag new or changed behavior with no accompanying test, silently swallowed exceptions, and unguarded NaN/None propagation" for **/*.py files — this is exactly such a swallowed exception feeding a destructive write path.

🛠️ Proposed fix — only default on missing file, not on corrupt JSON
 def _load(path: Path, default: Any) -> Any:
-    try:
-        return json.loads(path.read_text(encoding="utf-8"))
-    except (OSError, json.JSONDecodeError):
-        return default
+    try:
+        text = path.read_text(encoding="utf-8")
+    except FileNotFoundError:
+        return default
+    return json.loads(text)

This preserves "file doesn't exist yet" as a legitimate empty-default case while letting a corrupted/unreadable existing corpus abort the run loudly instead of being silently overwritten.

📝 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
def _load(path: Path, default: Any) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return default
def _growth_config(policy: dict[str, Any], profile: str) -> dict[str, Any]:
prof = policy.get("profiles", {}).get(profile, {})
cfg = dict(prof.get("corpus_growth", {}))
cfg.setdefault("enabled", False)
cfg.setdefault("stability_days", 30)
cfg.setdefault("staging_expiry_days", 60)
cfg.setdefault("harvest_window_days", 60)
cfg.setdefault("max_corpus_size", 150)
cfg.setdefault(
"category_caps",
{CATEGORY_CLEAN_PASS: 40, CATEGORY_REGRESSION: 20, CATEGORY_FOLLOW_UP: 20},
)
cfg.setdefault("source_repos", [])
return cfg
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Grow the verifier corpus from realized PR outcomes."
)
parser.add_argument("--corpus", type=Path, default=DEFAULT_CORPUS_PATH)
parser.add_argument("--staging", type=Path, default=DEFAULT_STAGING_PATH)
parser.add_argument("--policy", type=Path, default=DEFAULT_POLICY_PATH)
parser.add_argument("--profile", default=DEFAULT_PROFILE)
parser.add_argument("--from-json", type=Path, help="Read pre-fetched PR records instead of gh.")
parser.add_argument("--per-repo", type=int, default=50)
parser.add_argument(
"--write", action="store_true", help="Persist corpus + staging (default: dry-run)."
)
args = parser.parse_args(argv)
policy = _load(args.policy, {})
cfg = _growth_config(policy, args.profile)
if not cfg["enabled"]:
print("corpus_growth is disabled in the policy; nothing to do.")
return 0
now = datetime.now(UTC)
if args.from_json:
records = _load(args.from_json, [])
else: # pragma: no cover - integration path
records = fetch_records(
list(cfg["source_repos"]),
per_repo=args.per_repo,
stability_days=int(cfg["stability_days"]),
harvest_window_days=int(cfg["harvest_window_days"]),
)
promote, stage = partition(records, now=now, stability_days=int(cfg["stability_days"]))
corpus = _load(args.corpus, {"cases": []})
grown, added = grow_corpus(
corpus,
promote,
max_size=int(cfg["max_corpus_size"]),
category_caps=dict(cfg.get("category_caps") or {}),
)
staging = _load(args.staging, {"cases": []})
new_staging = prune_staging(
staging, stage, now=now, expiry_days=int(cfg["staging_expiry_days"])
)
print(
f"harvest: {len(records)} records → {len(added)} promoted "
f"(corpus {len(corpus.get('cases', []))}{len(grown.get('cases', []))}), "
f"{len(new_staging['cases'])} staged (FYI, auto-expiring)."
)
for case in added:
print(f" + {case['case_id']} {case['expected_verdict']} ({case['category']})")
if args.write:
if added:
args.corpus.write_text(json.dumps(grown, indent=2) + "\n", encoding="utf-8")
args.staging.write_text(json.dumps(new_staging, indent=2) + "\n", encoding="utf-8")
print(f"wrote corpus={args.corpus} staging={args.staging}")
return 0
def _load(path: Path, default: Any) -> Any:
try:
text = path.read_text(encoding="utf-8")
except FileNotFoundError:
return default
return json.loads(text)
🧰 Tools
🪛 ast-grep (0.44.1)

[info] 398-398: use jsonify instead of json.dumps for JSON output
Context: json.dumps(grown, indent=2)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)


[info] 399-399: use jsonify instead of json.dumps for JSON output
Context: json.dumps(new_staging, indent=2)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🤖 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/harvest_verifier_corpus.py` around lines 321 - 402, Update _load so it
returns the supplied default only when the target file is missing, while
propagating OSError and JSONDecodeError for existing unreadable or malformed
files. Ensure main’s corpus loading cannot silently treat a corrupted existing
corpus as empty before the --write path; retain the empty default for genuinely
absent corpus and staging files.

Source: Path instructions

@stranske
stranske merged commit 1aae759 into main Jul 25, 2026
49 checks passed
@stranske
stranske deleted the model-eval-corpus-harvest branch July 25, 2026 23:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants