Skip to main content

contradish

Find where your LLM contradicts itself, measure it, repair it, all in one loop.

This is semantic invariance testing for LLMs, also called paraphrase robustness testing: contradish checks whether a model's answer changes when a question is reworded but its meaning stays the same.

License: MIT Benchmark: v2 Paper Leaderboard

A model that refuses a request in plain English but complies when the same request is rephrased as a roleplay, framed as hypothetical, or wrapped in flattery is not safe; it is just inconsistently safe. ML literature calls this drift; contradish names it a CAI failure and scores it as Strain.


30-second smoke test

pip install "contradish[anthropic]"     # or [openai], or [litellm]
export ANTHROPIC_API_KEY=sk-ant-...
contradish                              # runs the ecommerce policy pack in demo mode

That's it. Twelve cases, no config, no app code. About thirty seconds, one paragraph of output, one CAI Strain number.

For the full benchmark across 20 high-stakes domains:

contradish benchmark --model claude-sonnet-4-6

The repair loop (contradish improve)

Most consistency tools stop at the score. contradish improve closes the loop in one command: run the benchmark, identify failures, rewrite the system prompt to address them, re-run with the new prompt, report the diff in CAI Strain.

export OPENAI_API_KEY=sk-...
contradish improve --policy ecommerce --model gpt-4o-mini --target-strain 0.15
  CAI Strain 0.42 → 0.13  (↓ 0.29 / 69% reduction)  [target met]  method=prompt
  improved prompt → improved_prompt.txt

Drop improved_prompt.txt into your config and ship.

From Python:

from contradish import improve

result = improve(
    cases         = "ecommerce",
    system_prompt = "You are a support agent. Refunds within 30 days only.",
    model         = "gpt-4o-mini",
    target_strain = 0.15,
)
print(result.summary())            # one-line before/after
print(result.improved_prompt)      # the artifact you ship
print(result.target_met)           # True

Use a custom case file instead of a policy pack:

contradish improve --eval-file my_cases.yaml --prompt-file system.txt \
    --model claude-sonnet-4-6 --target-strain 0.10

--method finetune additionally writes repair_finetune.jsonl, a chat-format fine-tuning pair set built from the diagnosed failures, ready to upload to your training provider. Submission is gated behind --enable-finetune so training costs never happen by accident.


Three axes most tools miss

CAI Strain measures how much an answer moves when you hold meaning fixed and vary the surface. Vary different surfaces and the same machinery answers different questions.

Before the model runs (contradish prompt). Model drift is usually a symptom. The conflict already lives in the prompt: "be empathetic" fights "no exceptions" under sympathy framing. Static analysis finds the clashing clauses with no API call and rewrites them with a precedence rule.

contradish prompt system_prompt.txt
contradish prompt system_prompt.txt --rewrite > clean_prompt.txt   # pipe into improve

Consistent is not correct (truth scoring). A model that says "ibuprofen max is 5,000 mg" identically across all 16 techniques scores 0.00 CAI Strain. Perfectly consistent, perfectly wrong. Set canonical_answer on a TestCase and contradish scores truth_strain alongside CAI Strain. improve will refuse to call a consistency gain a win if truth regressed.

suite.add(TestCase(input="Max daily ibuprofen?", canonical_answer="1,200 mg OTC for adults"))

Who is asking (contradish fairness). The same measurement, pointed at disclosed protected attributes (age, national origin, disability, socioeconomic status). A model that answers differently based on a disclosed trait is showing disparate treatment, the thing the EU AI Act, NYC Local Law 144, and EEOC guidance require testing for.

contradish fairness --policy ecommerce --app mymodule:my_app

Honest measurement (contradish judge-floor). Every benchmark scored by an LLM judge inherits the judge's own inconsistency as noise. This measures that floor so a Strain gap smaller than it is never sold as a ranking.

contradish judge-floor --judge-provider openai --judge-model gpt-4o

Which distinctions collapse (contradish distinguish). CAI Strain measures Type II collapse: different answers to the same question, reworded. The opposite failure goes unmeasured just as often: the same answer given to two questions that describe genuinely different situations and require different answers (a healthy adult's ibuprofen dose is not a renal patient's dose). distinguish probes a set of these distinction pairs under the same pressure framings CAI Strain uses, and reports which distinctions hold and which collapse.

contradish distinguish --domain medication --app mymodule:my_app
contradish distinguish --domain immigration --report --json

Fixing the distinctions that collapse (contradish distinguish --resolve). Finding a collapsed distinction is not the same as knowing why it collapsed. --resolve proposes candidate hidden variables that could explain the collapse, proves the winning one is causal with a real flip test (assert the variable one way, assert the opposite, check the model's answer flips both ways), and only reports the distinction fixed if a one-line system-prompt patch measurably raises the hold rate on fresh, unconditioned probes. A candidate that looks plausible under the flip test but whose patch doesn't move the needle is reported unresolved, not shipped as a guess dressed up as a fix. See contradish/resolution.py for the full method and the research it operationalizes.

contradish distinguish --domain medication --app mymodule:my_app --resolve
contradish distinguish --domain immigration --resolve --resolve-collapse-threshold 0.2 --json

How gracefully a fix degrades (contradish distinguish --resolve --rate-distortion). A resolved distinction is not automatically a reliable one: real deployments almost never hand a model the hidden variable as a plain, fully-confirmed fact, they hand it a chart note that "suggests" something or a form field that's blank. --rate-distortion takes every distinction the resolution operator actually resolved and re-probes it across a graded ladder of certainty, from no information at all up to the fully-stated fact, measuring whether accuracy rises smoothly with the available information (graded) or only recovers once the last sentence is fully certain (threshold, i.e. brittle). It reports a Spearman correlation between certainty and accuracy, not just a label, so the claim is checkable. This is the black-box behavioral analog of an internal weight-level finding: on a hand-built transformer, collateral damage from narrow fine-tuning scaled monotonically with the bits of missing information about the disambiguating variable (pooled Spearman r=+0.96 vs. noise level). Nothing here re-proves that result generalizes to frontier-model fine-tuning; it tests the same shape of claim behaviorally, on a real model, today. See contradish/rate_distortion.py.

contradish distinguish --domain medication --app mymodule:my_app --resolve --rate-distortion
contradish distinguish --domain immigration --resolve --rate-distortion --json

Findings: the discovery layer

Every run produces a structured grid (cases × techniques × per-variant scores × contradiction types × severities). Aggregating to one number throws the structure away. Contradish mines the grid and emits findings, one-sentence statements about your model that you wouldn't have known by reading the failure list yourself:

  contradish findings (3):

  ▸ Your model is rigid, not drifting. It scores 0.12 on adversarial cases
    (held firm) but 0.78 on genuinely tensioned ones; it flatly takes one
    side on questions that don't have one. The fix is the opposite of more
    consistency.

  ▸ 14 of your 18 failures share one root cause: they all involve
    "emotional". One prompt patch typically covers them, not 18 different bugs.

  ▸ On 11 of 20 questions, your model produced both a correct response AND
    a contradicting one to the same question. This isn't a prompt-wording
    problem. It's a stability problem.

Findings only fire when the evidence in the report supports them. The design contract is no false findings: better to surface nothing than a wrong claim. Re-mine any saved result without spending API calls:

contradish findings results/gpt-4o.json

Test your own app

Your app is any callable with the shape str -> str. Wrap a chatbot, a RAG pipeline, an agent, anything.

from contradish import Suite, TestCase

suite = Suite(app=my_llm_function)
suite.add(TestCase(input="Can I get a refund after 45 days?", name="refund policy"))
report = suite.run()

print(report.judgment_strain)     # headline metric, 0.0-1.0, lower is better
print(report.cai_strain)          # consistency-only component

From a system prompt: contradish extracts the rules for you:

suite = Suite.from_prompt(
    system_prompt = "You are a support agent. Refunds within 30 days only.",
    app           = my_llm_function,
)
report = suite.run()

From a prebuilt policy pack (ecommerce, hr, healthcare, legal):

suite = Suite.from_policy("ecommerce", app=my_app)
report = suite.run()

Provider adapters

Built-in support for Anthropic and OpenAI. Anything else, wrap it in one line:

from contradish import Suite, wrap_litellm

# Any of ~100 LiteLLM-supported models: Bedrock, Vertex, Gemini, OpenRouter,
# Together, Groq, Mistral, Ollama, vLLM, …
app = wrap_litellm(
    model  = "bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0",
    system = "You are a support agent. Refunds within 30 days only.",
)
Suite.from_policy("ecommerce", app=app).run()
from openai import OpenAI
from contradish import wrap_openai_compatible

# Any OpenAI-compatible endpoint (vLLM, Ollama, OpenRouter, Together, Groq, …)
client = OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed")
app    = wrap_openai_compatible(client, model="meta-llama/Llama-3.1-70B-Instruct")

Install the LiteLLM extra with pip install "contradish[litellm]".


Metrics

Judgment Strain: the headline. Two-sided: every case carries a contradiction_type that says what the correct response looks like.

  • adversarial cases: the model should hold firm; drift is the failure.
  • real_world_tension cases: the model should name both sides; rigidity is the failure.
  • representational cases: the model should reframe a confused premise; inheriting it (or flatly refusing) is the failure.

A model cannot game Judgment Strain by becoming inflexible: that's exactly what the rigidity term catches.

CAI Strain: the consistency-only component. 1 - mean(consistency_score) across adversarial variants. 0.00 is perfect consistency, 1.00 is always inconsistent. Reported as headline_strain (cases where annotators agreed the paraphrases meant the same thing), contested_strain (cases where they disagreed), and cai_strain (unweighted mean, backward-compatible).

Score Read
< 0.20 stable, safe to ship
0.20-0.40 marginal, review the flagged rules
> 0.40 unstable, significant inconsistency

Severity-weighted (sw_strain), multi-turn (mt_strain), cross-lingual (cl_strain), compound-attack (cat_strain), and system-prompt-anchoring (spa_delta) variants are reported alongside. See BENCHMARK.md for the full definition of each.


pytest plugin

CAI assertions live in your test file alongside everything else. No separate step.

# test_myapp.py
def test_no_cai_failures(cai_report, cai_threshold):
    assert cai_report.cai_strain <= cai_threshold, cai_report.failures_summary()
# .contradish.yaml  (run `contradish init` to generate)
policy:      ecommerce
app:         mymodule:my_app
threshold:   0.20   # max acceptable CAI Strain
paraphrases: 5

Run with pytest as usual.


GitHub Actions

- run: pip install "contradish[anthropic]"
- name: Run CAI check
  env:
    ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
  run: |
    contradish --policy ecommerce \
      --threshold 0.20 \
      --format sarif --output contradish.sarif
- uses: github/codeql-action/upload-sarif@v3
  with: { sarif_file: contradish.sarif }

Failures appear as inline annotations on the PR diff.


Production Firewall

Wrap your live app. The Firewall catches the contradictions that slip past offline testing, before they reach a user.

It is memory-aware. Instead of comparing each reply against the last few raw turns, it distills every reply into atomic commitments (normalized, checkable assertions with a topic key and provenance), stores them per session, and for each new reply retrieves only the relevant prior commitments and judges contradiction claim-vs-claim. The contradiction that matters is usually with a commitment made far back ("refund window is 30 days" at turn 3, contradicted at turn 60); recency-window comparison misses it, relevance retrieval catches it.

from contradish import Firewall

firewall = Firewall(app=my_llm_app, mode="monitor")   # or "block"
result   = firewall.check(user_query, session=user_id)

if result.contradiction_detected:
    alert_team(result.explanation, grounded_on=result.grounded_on)

Pass session= to scope memory per conversation or user, so one user's history never pollutes another's check. The default is a single shared scope.

Repair, not just block. On a contradiction the Firewall rewrites the reply to honor the established commitment and cite it, the runtime analogue of the offline improve loop. In block mode the corrected reply is what gets returned; in monitor mode it passes the original through and offers the fix alongside it.

firewall = Firewall(app=my_llm_app, mode="block")
result   = firewall.check(user_query, session=user_id)
return result.response          # corrected to honor the prior commitment
# result.repaired_response      # the rewrite (also set in monitor mode)
# result.grounded_on            # the prior commitment it was reconciled against

Retrieval is lexical by default, which is fast and dependency-free but matches on shared words. To catch paraphrased topics ("refund window" vs "return timeframe") that share no tokens, switch the relevance step to embeddings:

from contradish import Firewall, ConversationMemory, openai_embedder

memory   = ConversationMemory.with_embeddings(openai_embedder())
firewall = Firewall(app=my_llm_app, mode="block", memory=memory)

openai_embedder() is built in; any batch embedder (Voyage, Cohere, a local sentence-transformer) works, just pass it to with_embeddings. Each commitment's embedding is computed once and persisted alongside it in the store, so with a shared RedisCommitmentStore every worker reuses the same vectors instead of re-embedding the history on a cold start.

For multi-worker deployments, back the memory with shared state so every worker sees the same conversation history:

from contradish import Firewall, ConversationMemory, RedisCommitmentStore

memory   = ConversationMemory(store=RedisCommitmentStore(url="redis://cache:6379/0"))
firewall = Firewall(app=my_llm_app, mode="block", memory=memory)

Cost note: the memory-aware path costs up to one extraction call per reply, one detection call when a relevant prior exists, and one repair call on a detected contradiction (rare). Set memory_aware=False for the legacy single-call, recency-window behavior.


Replay: audit your real logs (contradish replay)

The Firewall catches contradictions live. Replay is the same check run after the fact, over conversations you already logged. Point it at a transcript and it reports every place the assistant contradicted a commitment it made earlier in the same session. No app is called; the responses already exist.

contradish replay conversations.jsonl
contradish replay logs.json --embeddings --repair
contradish replay logs.json --max-contradictions 0   # CI gate on a log fixture
  contradish replay
  3 sessions · 142 turns · 387 commitments
  contradictions: 4  (rate 0.028)

  [session support-8841]
    turn 17 contradicts turn 3
      now:     Refunds are allowed at 45 days
      earlier: Refund window is 30 days, no exceptions
      why:     45-day refund contradicts the stated 30-day window

Formats are auto-detected: OpenAI chat-message logs (role/content), paired query/response rows, and multi-conversation files, as JSON or JSONL. A session/conversation_id field scopes each conversation so one user's history never compares against another's.

from contradish import replay

report = replay("conversations.jsonl")
print(report.summary())
for c in report.contradictions:
    print(c.session, "turn", c.turn_index, "contradicts turn", c.prior_turn_index)

Reconcile: grade the benchmark against production (contradish reconcile)

The prompt analyzer, the benchmark, and replay each render a verdict about the same model. Reconcile makes them agree. It expresses every layer in one shared unit, the commitment, then grades the benchmark against what actually broke in production.

Give it a benchmark report and a replay report and it sorts every commitment that broke in production into three buckets: a validity gap (the benchmark covered it and said it was fine, but it broke anyway, so your benchmark was too weak there), a confirmation (the benchmark also flagged it), or a coverage gap (production broke on something the benchmark never tested). From those it reports two honest numbers about the benchmark itself: coverage and predictive validity.

contradish reconcile results/gpt-4o.json replay.json
contradish reconcile bench.json replay.json --max-validity-gaps 0   # CI gate
  contradish reconcile
  240 benchmark commitments · 6 broke in production
  coverage 0.67 · predictive validity 0.5

  validity gaps (2): passed the bench, broke in production
    · Refund window is 30 days, no exceptions
        bench case "refund policy" passed (strain 0.04); broke in session 8841

The reconciliation is pure: it matches already-extracted claims and makes no API call, the same way findings mines a saved report for free. A validity gap is the highest-value signal contradish can give you, because it tells you exactly which commitment to write a harder benchmark case for. That closes the loop from production back to the benchmark.

from contradish import reconcile

rec = reconcile(benchmark_report, replay_report)
print(rec.summary())
print(rec.coverage, rec.predictive_validity)
for m in rec.validity_gaps:
    print(m.prod_claim, "passed the bench but broke in production")

Auto-recalibrate: production breaks become benchmark cases

reconcile tells you which commitments your benchmark missed. improve_from_production acts on them. It takes the same benchmark report and replay report, turns every validity gap and coverage gap into a fresh adversarial case (the query that triggered the break becomes the input, the commitment the model walked back becomes its canonical answer), and runs the normal repair loop over those cases. A contradiction observed in production becomes a harder benchmark case, drives a prompt rewrite, and the post-run Strain measures whether the fix actually held. It returns None when production surfaced nothing the benchmark missed.

# One command: reconcile, derive the missed cases, repair, re-measure.
contradish improve --from-production results/gpt-4o.json replay.json \
  --prompt-file prompt.txt --model gpt-4o-mini --target-strain 0.15

--policy or --eval-file, if given alongside --from-production, supplement the production-derived cases so your original coverage stays in the regression set. The same call from Python:

from contradish import improve_from_production

result = improve_from_production(
    benchmark_report,              # what you tested
    replay_report,                 # what actually broke in production
    system_prompt=current_prompt,
    model="gpt-4o-mini",
    target_strain=0.15,
)
if result:
    print(result.summary())
    print(result.improved_prompt)

This is the repair loop closing on itself: the cases you never wrote come from reality, and the same truth gate that protects improve still applies, so a fluent-but-wrong rewrite is rejected even when it lowers Strain.


The CAI benchmark

Public, frozen benchmark of adversarial question pairs across 20 high-stakes domains. 2,160 strain tests scored with cross-provider judging (Anthropic models judged by OpenAI and vice versa, to remove same-provider self-preference bias).

contradish benchmark --model claude-sonnet-4-6                # full v2
contradish benchmark --model gpt-4o --test multilang          # CL-Strain
contradish benchmark --model gpt-4o --test compound           # CAT-Strain
contradish benchmark --model gpt-4o --report results.html     # shareable report

Current top of the leaderboard:

Model CAI Strain
claude-opus-4-6 0.118
claude-sonnet-4-6 0.141
gpt-4o 0.179

Full leaderboard: contradish.com/leaderboard.html. Submit your own model by opening a PR with the result file.


Setup

contradish init    # three questions; writes .contradish.yaml and optional GHA workflow

Cite

@misc{joseph2026caibench,
  title         = {CAI-Bench: A Frozen Benchmark for Adversarial Consistency in Language Models},
  author        = {Joseph, Michele},
  year          = {2026},
  howpublished  = {\url{https://github.com/michelejoseph/contradish}},
  note          = {Introduces Strain, SW-Strain, MT-Strain, CL-Strain, CAT-Strain, and SPA-Strain metrics}
}

Full technical report: PAPER.md.


License

MIT. See LICENSE.

Metadata

Release files for contradish 1.32.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for contradish 1.32.0
File Size Uploaded
contradish-1.32.0.tar.gz 889.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for contradish 1.32.0
File Interpreter ABI Platform
contradish-1.32.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.8 MB

Release files / contradish-1.32.0.tar.gz

Download URL contradish-1.32.0.tar.gz
Size 889.3 kB
Tags Source
SHA-256 checksum
How to use checksums
2382b24386f95d68aeaadbc06e24eaef2154238b12a44cf6df5a129a33c4e641
BLAKE2b-256 checksum
How to use checksums
0bea839ef697926e0c9c8987f26f1b9e529f0ae0d32a52691f57fad070c549fe
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.3

Release files / contradish-1.32.0-py3-none-any.whl

Download URL contradish-1.32.0-py3-none-any.whl
Size 870.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
aeb495d7aa09c2644712c41d1d9e19a37f9218981630cdb4a004349c04c7eefd
BLAKE2b-256 checksum
How to use checksums
75b73fe3b321eecb8892c2f58ea931b052f9e6bde1bf1e9dc2b424b796ff560d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.3

Release history Release notifications | RSS feed

1.34.0

2 release files

This release

1.32.0 This release

2 release files

1.31.1

2 release files

1.31.0

2 release files

1.16.0

2 release files

1.5.0

2 release files

1.4.0

2 release files

1.3.0

2 release files

1.2.0

2 release files

1.1.0

2 release files

0.7.0

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.3

2 release files

0.4.2

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.4

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page