Skip to content

Behavioral bisect

culprit bisect searches only commits that changed the configured prompt file. Git remains the source of history, while the existing eval runner and cache classify each midpoint.

Worked five-commit example

Consider a repository where prompts/guard.txt changed five times:

Commit Prompt behavior Expected classification
6673a3d Answers with SAFE good
7d35dfd Answers with SAFE good
1665e6f Answers with UNSAFE first bad commit
e31e8be Still answers with UNSAFE bad
74f0664 Still answers with UNSAFE bad

The eval set contains a deterministic assertion:

cases:
  - id: safe-answer
    input: Return the required token.
    assertions:
      - type: exact
        value: SAFE

Start the search:

culprit bisect start \
  --good 6673a3d \
  --bad 74f0664 \
  --file prompts/guard.txt \
  --eval-set evals/safety.yaml

Output captured from the five-commit example using local Ollama with llama3.1:latest:

Estimated provider calls: 3

Culprit found
Commit:  1665e6fe3575434234ae485fb7b0af9290f4cfdd
Author:  Demo User <demo@example.com>
Date:    2026-09-05T10:00:00+05:30
Message: break required response
Flipped cases: safe-answer

Behavioral change summary
The safe-answer score fell from 1.00 to 0.00 and its behavior changed from SAFE to UNSAFE.

The estimate is ceil(log2(file-changing commits)) * number of cases. Above max_calls_without_confirmation, culprit asks once before starting; use --yes only for deliberate automation.

Automated and manual modes

When every case has at least one exact, contains, or regex assertion, classification is automatic. A commit is bad when a case that passed at the good boundary fails.

When cases contain only llm_judge assertions, culprit prints the midpoint scores and asks whether that commit is good or bad. This matches interactive git bisect behavior without silently trusting a judge score.

Continue after interruption

Progress is saved to the git-ignored .culprit/bisect-state.json. If the process is interrupted, run:

culprit bisect continue

The command resumes the saved range. If interruption happened during a provider call, any completed evaluations are loaded from .culprit/cache.sqlite3, preventing repeat spend.