Turn any confidence signal into a calibrated act-vs-escalate decision with a distribution-free statistical guarantee.
Project description
DeferGuard
Turn "should I trust this enough to act alone" into a number you can defend, not a threshold you picked by feel.
DeferGuard is a small, framework- and domain-agnostic Python library that takes any confidence signal your AI system already produces — a logprob, a self-consistency vote, an LLM-judge score, an ensemble agreement fraction — and turns "should this act autonomously or go to a human" into a decision backed by a distribution-free statistical guarantee, calibrated on your own labeled data.
- Calibrate once: feed it a batch of
(confidence_signal, was_it_correct)pairs from historical data. - Decide at runtime: for each new case, DeferGuard returns
actorescalate, plus a plain-language guarantee string. - Recalibrate cheaply: when the underlying model, judge, or expert pool changes, recalibrate on a fresh small batch — no retraining of a separate "rejector" model.
It is not an LLM router, not a guardrail/moderation heuristic library, and not a hosted platform — it's a lightweight statistics layer (no embeddings, no vector store, no GPU, no model API calls required) that sits on top of a well-established but under-productized statistical result: conformal deferral / conformal risk control.
Status
Phase 1 (core calibration engine), Phase 2 (pluggable signal adapters + ConformGuard interoperability), and Phase 3 (multi-expert routing + conditional coverage) are done — see CHANGELOG.md for what's implemented and Roadmap below for what's next.
Roadmap
DeferGuard is built in phases; each is meant to be fully working and tested before the next begins.
- Phase 1 — core calibration engine (done):
Calibratorwith.fit(),.decide(),.save()/.load(),.recalibrate(); local persistence; a CLI to calibrate, check coverage, and audit past decisions; the coverage-validation suite and negative controls described below; explicit assumption checks (monotonicity, minimum-n, an exchangeability-shift proxy). - Phase 2 — pluggable signal adapters + ConformGuard interoperability (done): a
signalsmodule (LogprobSignal,JudgeScoreSignal,EnsembleVoteSignalusing linear opinion pooling across multiple agents); a ConformGuard adapter converting a realWrapResult/MultiCheckWrapResultdecision into a DeferGuard signal; drift monitoring that tracks the live "act" error rate once delayed ground truth is available and warns when recalibration may be due;deferguard[signals]/deferguard[conformguard]install extras. Seedocs/signals_and_integrations.md. - Phase 3 — multi-expert routing + conditional coverage (done):
routing.SegregativityRouter, implementing Bary, Macq & Petit's training-free segregativity criterion (arXiv:2509.12573) to route an escalated case to a specific expert without retraining;core.GroupCalibrator, giving an error-rate guarantee that holds within caller-declared subgroups (Vovk 2012's conditional/Mondrian conformal prediction, arXiv:1209.2673), not just marginally overall; cost-aware calibration (Calibrator(..., max_escalation_rate=...)) respecting both an error-rate target and an escalation-rate budget, with an explicit, non-silent conflict signal when the two can't both be satisfied. Seedocs/multi_expert_and_conditional_coverage.md. - Phase 4 — monitoring integrations, federation, streaming recalibration: OpenTelemetry span export of every decision for existing observability tools like Phoenix or Langfuse (not a competing dashboard); optional streaming/online recalibration for high-volume, fast-drifting deployments; optional, opt-in shared/federated calibration sets across teams.
Quickstart
pip install deferguard
Developing DeferGuard itself, or installing from source instead of PyPI? Use an editable install: pip install -e . (see Development below).
from deferguard import Calibrator, CalibrationExample
calibrator = Calibrator(alpha=0.05) # target: at most 5% of "act" decisions are wrong
# calibration_set: historical (signal, correctness) pairs from your own domain
calibration_set = [
CalibrationExample(signal=0.91, correct=True),
CalibrationExample(signal=0.44, correct=False),
# ... hundreds to thousands more, depending on alpha -- see below
]
calibrator.fit(calibration_set)
calibrator.save("calibration.json")
# --- at runtime ---
calibrator = Calibrator.load("calibration.json")
decision = calibrator.decide(signal=0.83)
if decision.action == "escalate":
route_to_human(item)
else:
proceed_autonomously(item)
print(decision.guarantee)
# "With probability at least 95.0% over this calibration draw, at most 5.0% of
# ACT decisions are wrong, assuming the calibration set and live traffic are
# exchangeable. Calibrated with Conformal Risk Control on n=940 examples on
# 2026-07-19."
See examples/ for three fuller, runnable scripts (support-bot escalation, moderation pipeline, multi-agent ensemble) using synthetic data — no API keys required.
How much calibration data do you need?
More than a naive "n ≥ 1/alpha" rule of thumb would suggest — DeferGuard's guarantee is a training-conditional one (see docs/understanding_the_guarantee.md), which is stronger and more useful than a plain average-case guarantee, but costs more data for a given alpha. If you're not sure your data is enough, ask:
from deferguard.core.validation import recommend_alpha
recommend_alpha(n=500) # -> the tightest alpha your 500 examples can honestly support
or from the CLI: deferguard recommend-alpha 500. Calibrator.fit() raises InsufficientCalibrationDataError (with this same suggestion baked into the message) rather than silently producing a threshold that isn't backed by enough data.
Splitting your data across subgroups with GroupCalibrator (Phase 3) multiplies this requirement by roughly the number of groups — see docs/multi_expert_and_conditional_coverage.md for the specific numbers.
Coverage validation
This table is the project's actual proof, not illustrative numbers — produced by tests/coverage_validation/synthetic/test_known_distribution.py, which repeatedly (300 independent draws per row) calibrates against a known ground-truth process and checks the resulting threshold's exact, analytically-computed true selective risk against the target. Reproduce it yourself: pytest tests/coverage_validation/synthetic -v -s.
Well-separated signal (correctness concentrates sharply at high signal — required, asserted by CI):
| alpha | n (calibration) | achieved | mean true risk | P(true risk ≤ alpha) | target (1 − alpha) | result |
|---|---|---|---|---|---|---|
| 0.20 | 208 | 300/300 | 0.0315 | 1.000 | 0.800 | PASS |
| 0.10 | 507 | 300/300 | 0.0256 | 1.000 | 0.900 | PASS |
| 0.05 | 1222 | 300/300 | 0.0211 | 1.000 | 0.950 | PASS |
| 0.01 | 8281 | 300/300 | 0.0031 | 1.000 | 0.990 | PASS |
Moderately-separated signal (harder, more realistic case — informational, shown so the bound isn't just trivially loose; alpha=0.01 needs more calibration data than is practical to run in CI here and is omitted, not hidden — see docs/architecture.md):
| alpha | n (calibration) | achieved | mean true risk | P(true risk ≤ alpha) | target (1 − alpha) | result |
|---|---|---|---|---|---|---|
| 0.20 | 480 | 200/200 | 0.1078 | 1.000 | 0.800 | PASS |
| 0.10 | 1170 | 200/200 | 0.0442 | 1.000 | 0.900 | PASS |
| 0.05 | 2820 | 193/200 | 0.0161 | 1.000 | 0.950 | PASS |
mean true risk and P(true risk ≤ alpha) are both computed against the exact analytic selective risk of the known data-generating process, not an estimate from a finite held-out sample. Note that mean true risk sits closer to (while still safely under) the target than a first, buggier version of this table showed: an earlier select_threshold implementation stopped at the first act-set size in its search grid that satisfied alpha, instead of continuing to find the largest satisfying act-set (the documented design intent). That bug was always safe — every grid point covered by the underlying union bound is individually valid, and P(true risk ≤ alpha) was 1.000 both before and after — but it was needlessly conservative: on this same benchmark, mean escalation rate at alpha=0.05 dropped from 92.3% to 43.0% once fixed, i.e. the earlier version was sending more than 2x as many cases to a human as the guarantee actually required. See docs/architecture.md for the full account.
Negative controls (tests/coverage_validation/synthetic/test_negative_controls.py) — deliberately broken assumptions, confirmed to fail visibly rather than silently:
| assumption broken | what was checked | result |
|---|---|---|
| Exchangeability | Calibrate on an easy population, evaluate the resulting threshold against a shifted, harder population | Guarantee measurably fails — true risk 0.639 vs. target alpha=0.1 (6.4× over), exactly as documented, not hidden |
| Monotonicity | Construct a signal anti-correlated with correctness | MonotonicityWarning fires during .fit(), and the resulting threshold's true risk exceeds alpha, confirming the warning isn't a formality |
Understanding the guarantee — read this before you rely on it
The one-sentence version: "at most alpha% of ACT decisions are wrong" holds only if your calibration set and live traffic are exchangeable — statistically, "live cases look like a random draw from the same population the calibration set came from." If your model changes, your user base shifts, or your product adds a new use case, this can silently stop being true until you recalibrate. See docs/understanding_the_guarantee.md for the full, plain-language explanation — this is not optional reading.
CLI
deferguard calibrate data.csv --alpha 0.05 --output calibration.json
deferguard check calibration.json holdout.csv
deferguard audit audit.jsonl
deferguard recommend-alpha 500
Signals and integrations (Phase 2)
Optional helpers for turning a specific kind of raw input into the float decide()
already accepts — logprobs, a judge score, multiple agents' verbalized probabilities,
or a ConformGuard decision:
from deferguard.signals import LogprobSignal, JudgeScoreSignal, EnsembleVoteSignal
from deferguard.integrations import to_signal # requires deferguard[conformguard]
signal = LogprobSignal.from_token_logprobs([-0.05, -0.12, -0.01])
signal = JudgeScoreSignal.from_scaled_score(8, score_range=(1, 10))
signal = EnsembleVoteSignal.aggregate([0.92, 0.88, 0.95]) # linear opinion pool
signal = to_signal(conformguard_wrap_result) # WrapResult / MultiCheckWrapResult
decision = calibrator.decide(signal=signal)
Also in Phase 2: deferguard.telemetry.check_drift compares the live "act" error rate
against your calibrated alpha target once delayed ground truth is available, and
warns (never auto-recalibrates) when they've drifted apart. Full usage and the exact
math behind EnsembleVoteSignal (with the paper equation it implements) are in
docs/signals_and_integrations.md.
Documentation
docs/architecture.md— module layout and the CRC-adaptation design decision (including a real correctness bug this project caught and fixed during Phase 1 development, and why)docs/understanding_the_guarantee.md— what the guarantee promises, what it doesn't, and what "training-conditional" meansdocs/choosing_a_confidence_signal.md— what makes a signal usable, and common pitfallsdocs/signals_and_integrations.md— Phase 2 API reference:signals,integrations.conformguard_adapter,telemetry.driftdocs/multi_expert_and_conditional_coverage.md— Phase 3 API reference:routing.SegregativityRouter,core.GroupCalibrator, cost-aware calibrationdocs/real_world_validation.md— real self-consistency validation against 5 locally-running open-weight models via Ollama, on both an easy and a deliberately harder benchmark; includes models where the signal wasn't monotonic, a model that lost calibratability entirely on the harder set, and one whose monotonicity reversed between the two runsdocs/phase3_real_world_validation.md— real-model validation ofGroupCalibrator, cost-aware calibration, andSegregativityRouteragainst 4 of the same local models; includes a real per-subgroup coverage violation thatGroupCalibratoravoids, a genuine cost-aware conflict case, and an honestly-reported case whereSegregativityRouter's real-model exercise turned out less informative than hoped
References
The statistical core is Conformal Risk Control, adapted (see docs/architecture.md for the full derivation, including a real bug the adaptation caught and fixed) into a training-conditional, selective-risk guarantee via a Bonferroni-corrected, simultaneous Clopper-Pearson bound.
- Angelopoulos, A. N., Bates, S., Fisch, A., Lei, L., & Schuster, T. (2024). Conformal Risk Control. ICLR 2024. arXiv:2208.02814 — the foundational finite-sample correction this project's core is built on.
- Verma, R., Barrejón, D., & Nalisnick, E. (2023). Learning to Defer to Multiple Experts: Consistent Surrogate Losses, Confidence Calibration, and Conformal Ensembles. arXiv:2210.16955
- Fang, Y., & Nalisnick, E. (2025). Learning to Defer with an Uncertain Rejector via Conformal Prediction. TMLR / NeurIPS 2025 Workshop on Bayesian Decision-making and Uncertainty. Reference implementation
- Bary, T., Macq, B., & Petit, L. (2025). No Need for Learning to Defer? A Training-Free Deferral Framework to Multiple Experts through Conformal Prediction. arXiv:2509.12573 — the training-free-recalibration principle this project follows (see "Recalibrate cheaply" above).
- Wang, M. F., et al. (2026). From Debate to Decision: Conformal Social Choice for Safe Multi-Agent Deliberation. arXiv:2604.07667 — applies the same act-vs-escalate idea to multi-agent voting/debate; Definition 1's linear opinion pool is what
signals.EnsembleVoteSignalimplements (seesignals/ensemble.pyfor the cited equation and what's deliberately out of scope).
Development
pip install -e ".[dev]"
pytest tests/unit tests/property # fast, default CI gate
pytest tests/coverage_validation/synthetic # the coverage-guarantee proof
pytest tests/coverage_validation/live -m live # opt-in, real API calls
pytest tests/integration # CLI end-to-end
See CONTRIBUTING.md.
License
Apache 2.0 — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file deferguard-0.3.0.tar.gz.
File metadata
- Download URL: deferguard-0.3.0.tar.gz
- Upload date:
- Size: 146.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4f877c5ed178d3b857a19450ac1b00ba6405d352efdf9d0c66d0a04558cb7350
|
|
| MD5 |
991d7d058eb92aba6dbeeb2182bdfd1c
|
|
| BLAKE2b-256 |
41e1192493a22385b33c20f5644decf48e4d9d004f2f39d31dc531772a5eee78
|
Provenance
The following attestation bundles were made for deferguard-0.3.0.tar.gz:
Publisher:
publish.yml on amir2628/deferguard
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
deferguard-0.3.0.tar.gz -
Subject digest:
4f877c5ed178d3b857a19450ac1b00ba6405d352efdf9d0c66d0a04558cb7350 - Sigstore transparency entry: 2207418425
- Sigstore integration time:
-
Permalink:
amir2628/deferguard@1a1ea25fd0cab78a859c404a357ebfb244d4b264 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/amir2628
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1a1ea25fd0cab78a859c404a357ebfb244d4b264 -
Trigger Event:
push
-
Statement type:
File details
Details for the file deferguard-0.3.0-py3-none-any.whl.
File metadata
- Download URL: deferguard-0.3.0-py3-none-any.whl
- Upload date:
- Size: 62.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
994f97f5f1673392d2570fbb2c35fdd1b3b9ecb0aa00eb3977df9f1d2c87ccfe
|
|
| MD5 |
532811080b7ba14f4fd8eaf950f58aa2
|
|
| BLAKE2b-256 |
c5948074f39c6d2bb00a9c91ad9816fcc3c1e9bdbf8927894cf5a7847f6fb660
|
Provenance
The following attestation bundles were made for deferguard-0.3.0-py3-none-any.whl:
Publisher:
publish.yml on amir2628/deferguard
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
deferguard-0.3.0-py3-none-any.whl -
Subject digest:
994f97f5f1673392d2570fbb2c35fdd1b3b9ecb0aa00eb3977df9f1d2c87ccfe - Sigstore transparency entry: 2207418460
- Sigstore integration time:
-
Permalink:
amir2628/deferguard@1a1ea25fd0cab78a859c404a357ebfb244d4b264 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/amir2628
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1a1ea25fd0cab78a859c404a357ebfb244d4b264 -
Trigger Event:
push
-
Statement type: