Skip to content

Stage 2 loses ~30% of candidates to provider moderation refusals, with no aggregate reporting #212

Description

@NahumKorda

Summary

Stage 2 verification returned a verdict for fewer than a third of the candidates it was given. The rest were refused by the inference provider's moderation layer or recorded as needing manual review. Stage 2 consumed roughly three-quarters of total run cost while adjudicating 29% of its input, and no aggregate coverage figure is surfaced anywhere in the console output or the summary report.

Evidence

Refusal rate by phase — same route (OpenRouter) and same model (Claude Opus 5) in both cases, single run:

Phase Units Refused Rate
Stage 1 detection 1,349 2 0.15%
Stage 2 verification 467 139 29.8%

Of 467 Stage 1 candidates, Stage 2 produced a verdict for 135 (82 upheld, 53 disagreed, 1 overturned); 139 errored and 192 were flagged as needing manual review — 331 unadjudicated.

The Stage 2 rate was stable across the phase, sampled every 50 candidates: 32.0%, 33.7%, 33.3%, 31.3%, 33.0%, 30.8%, 30.7%. It is a property of the phase, not a transient. Refusals arrive from OpenRouter as finish_reason='content_filter' and are correctly mapped to a typed refusal error by the adapter. Note both phases used the same model and route, so the difference is attributable to prompt content rather than to model or provider choice.

The asymmetry has a plausible mechanism: Stage 1 asks the model to classify code, whereas Stage 2 asks it to construct a working exploit — which is what content moderation exists to block. One trivial utility function was refused deterministically on three separate occasions across two runs, so specific inputs reliably trip it rather than it being random sampling.

Why it matters

This is the finding I would prioritise, because it undercuts the core value proposition rather than a peripheral feature. "What survives is real" becomes "what survives is real, and roughly a third was never tested" — and a user has to read the JSON to discover which. verify.report.json exposes error_count but nothing frames it as a coverage percentage, and the console summary omits it entirely.

Suggested fix

  1. Report Stage 2 coverage as a headline figure — e.g. "328/467 adjudicated (70%); 139 refused by provider moderation." This is cheap and would have changed how we read our own results.
  2. Retry a refused unit once on a configured fallback binding. Moderation behaviour differs between routes and vendors, so a second attempt elsewhere often succeeds.
  3. Document the route sensitivity. Per-phase provider binding already makes this fixable without code changes — pointing verify at the Anthropic API directly rather than through OpenRouter is the obvious mitigation — but nothing tells a user that the choice materially affects verification coverage.

Observed on one full-pipeline run at production scale: a private TypeScript monorepo (Angular front end, NestJS back end), 1,351 analysis units from 866 source files, run with --verify and no --limit, ~6.5 hours wall clock. The engine was invoked directly (python -m openant scan …, Python 3.13); the Go CLI was not built, so none of this involves the Go↔Python envelope. Stage 1 detection and Stage 2 verification ran on Claude Opus 5; application context, enhancement and reporting on Claude Sonnet 5 — all seven phases routed through OpenRouter (anthropic/claude-opus-5, anthropic/claude-sonnet-5) via a locally-added openrouter provider adapter, on a build based on upstream 2ed78f6. Cost figures are expressed as proportions of the run total; absolute amounts are omitted deliberately, as is the identity of the scanned repository.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions