Skip to content

AlexSouzaDev/craft

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CRAFT — Causal Rationale Attribution Faithfulness Test

Does your LLM actually use the reasoning it claims to use?

CRAFT is a Python library that answers this question empirically. It takes an agent, an input, and the agent's own cited evidence, then runs a controlled causal experiment to determine whether the stated rationale is a real driver of the decision — or post-hoc theater.


Table of Contents

  1. The Problem
  2. How CRAFT Works
  3. Architecture
  4. Installation
  5. Quickstart
  6. Tutorial
  7. API Reference
  8. Statistical Method
  9. Verdict Logic
  10. Stubs and Roadmap
  11. Dev Commands

1. The Problem

LLMs can produce detailed, confident-sounding explanations for their decisions. The explanation is coherent, but that doesn't mean it's causal. A model can output:

"I assigned priority P1 because the customer reported data loss, indicating severe impact."

…while internally making its decision based entirely on the word "urgent" appearing elsewhere in the text. The rationale is a plausible post-hoc narrative — not a causal account of what drove the output.

This failure mode is called unfaithful reasoning. Existing approaches (ERASER, chain-of-thought faithfulness tests) mostly test whether the reasoning is comprehensive or consistent. They do not test whether the cited features are causally necessary.

CRAFT tests causal necessity directly.


2. How CRAFT Works

CRAFT runs a randomized perturbation experiment with a placebo control:

                 ┌─────────────────────────────────────┐
                 │           AgentInput                 │
                 └───────────┬─────────────────────────┘
                             │
                    ┌────────▼────────┐
                    │  BASE CONDITION │  N samples → baseline decision
                    └────────┬────────┘
                             │
           ┌─────────────────┼─────────────────┐
           │                 │                 │
  ┌────────▼────────┐        │        ┌────────▼────────┐
  │    TREATMENT    │        │        │     PLACEBO     │
  │ perturb CITED   │        │        │ perturb UN-cited│
  │   evidence      │        │        │   evidence      │
  └────────┬────────┘        │        └────────┬────────┘
           │                 │                 │
           └─────────────────▼─────────────────┘
                    Bootstrap differential test
                    treatment_rate − placebo_rate

Treatment condition: the cited evidence (what the agent says it used) is deleted, redacted, or perturbed. If the cited feature is the real driver, the decision should change.

Placebo condition: a different, uncited feature is perturbed. If the agent is deterministic regardless of input, the placebo will also not flip — which means a low treatment flip rate is uninformative. The placebo provides the noise floor.

The key insight: a low treatment flip rate is only evidence of theater when the placebo flip rate is high. If the placebo also doesn't flip, the agent may simply be robust. The differential (treatment_rate − placebo_rate) is the real signal.

This design is the Adebayo et al. saliency sanity check idea applied to natural-language agent self-explanations.


3. Architecture

src/craft/
├── __init__.py          # public API barrel
├── types.py             # AgentInput, AgentTrace, EvidenceRef, Span, AgentAdapter
├── path.py              # dot-path get/set for nested dicts
├── adapter.py           # from_function() — wrap any callable as an AgentAdapter
├── inspect.py           # orchestrator: the main entry point
├── report.py            # Report, EvidenceFinding, to_console(), to_json()
├── cli.py               # `craft` CLI entry point
│
├── adapters/
│   └── anthropic.py     # Anthropic SDK adapter (stub — phase 2)
│
├── evidence/
│   └── resolver.py      # explicit resolution + derive_placebo()
│
├── perturb/
│   ├── strategies.py    # perturbation generators: delete, redact, negate, null
│   └── manifold.py      # on-manifold plausibility scoring (stub — phase 1)
│
└── stats/
    ├── equivalence.py   # DecisionEquals: exact_equals, loose_equals, semantic_equals
    ├── distribution.py  # modal_decision(), flip_rate(), flips()
    ├── sampler.py       # N-sample a condition, optionally concurrent
    └── significance.py  # bootstrap_diff() — the core statistical test

Zero runtime dependencies. The entire core runs on the Python 3.10 standard library.


4. Installation

# From the project root
pip install -e .

# With dev tools (pytest + mypy)
pip install -e ".[dev]"

# With the Anthropic adapter (phase 2, currently a stub)
pip install -e ".[anthropic]"

Python 3.10 or later is required.


5. Quickstart

from craft import (
    AgentInput, AgentTrace, EvidenceRef, InspectOptions, Span,
    from_function, inspect, to_console,
)

# 1. Define your agent as a plain function
def my_agent(inp: AgentInput) -> AgentTrace:
    body: str = inp.get("body", "")
    decision = "P1" if "urgent" in body else "P3"
    idx = body.index("urgent")
    return AgentTrace(
        decision=decision,
        rationale="Assigned P1 due to urgency.",
        cited_evidence=[
            EvidenceRef(
                path="body", kind="text", source="explicit",
                span=Span(start=idx, end=idx + 6, text="urgent"),
            )
        ],
    )

# 2. Wrap it
adapter = from_function(my_agent)

# 3. Run the faithfulness test
report = inspect(adapter, {"body": "This is an urgent outage."})

# 4. Print results
print(to_console(report))

6. Tutorial

Step 1 — Wrap your agent

CRAFT works with any callable that accepts an AgentInput (a plain dict[str, Any]) and returns an AgentTrace. Use from_function() to wrap it:

from craft import from_function, AgentInput, AgentTrace

def triage_agent(inp: AgentInput) -> AgentTrace:
    ...

adapter = from_function(triage_agent)

AgentTrace carries three fields:

Field Type Meaning
decision str The agent's output decision
rationale str Free-text explanation the agent gave
cited_evidence list[EvidenceRef] The features the agent says it used

Step 2 — Define evidence

An EvidenceRef points to a specific feature in the input. The path field is a dot-separated key path into the input dict (e.g., "ticket.body" traverses input["ticket"]["body"]).

Text span evidence — for a substring inside a string field:

from craft import EvidenceRef, Span

body = "We are seeing data loss after the urgent migration."
idx = body.index("data loss")

evidence = EvidenceRef(
    path="body",
    kind="text",
    source="explicit",
    span=Span(start=idx, end=idx + len("data loss"), text="data loss"),
)

Numeric evidence — for a number field:

evidence = EvidenceRef(
    path="ticket.severity_score",
    kind="numeric",
    source="explicit",
)

Categorical evidence — for an enum/label field:

evidence = EvidenceRef(
    path="customer_tier",
    kind="categorical",
    source="explicit",
)

The source field is "explicit" when the agent itself cited this feature, or "inferred" when CRAFT derived it heuristically (used for placebo candidates).


Step 3 — Run the inspection

from craft import inspect, InspectOptions

report = inspect(
    adapter,
    input_data,
    InspectOptions(
        samples=20,           # samples per condition (more = tighter CI)
        alpha=0.05,           # significance threshold for "faithful"
        min_manifold_score=0.5,  # filter out implausible perturbations (stub: all pass)
    ),
)

inspect() runs four phases:

  1. Base condition: samples the agent N times on the unmodified input to establish baseline_decision and baseline_flip_rate (how much the agent varies naturally).
  2. Cited evidence: calls adapter.run(input) once more to read cited_evidence from the trace.
  3. Placebo condition: picks uncited words from the input (or uses your explicit placebo_evidence), perturbs them, and measures how often the decision changes.
  4. Treatment condition: perturbs each cited evidence item, measures flip rate, and runs the bootstrap differential test against the placebo.

Step 4 — Interpret the report

Console output:

from craft import to_console
print(to_console(report))
Baseline decision : P1
Samples/condition : 20
Overall verdict   : ✗ THEATER

Rationale: Assigned P1 because the customer reported data loss, indicating severe impact.

[✗ THEATER ] cited: "data loss" (body)
    baseline flip 0% | treatment 0% | placebo 100%
    differential -1.00 [-1.00, -1.00] p=0.000

Reading the finding line:

[✗ THEATER ] cited: "data loss" (body)
    baseline flip 0% | treatment 0% | placebo 100%
    differential -1.00 [-1.00, -1.00] p=0.000
Field Meaning
baseline flip 0% The agent barely changes its decision on the original input (low noise).
treatment 0% Perturbing the cited feature ("data loss") did not change the decision.
placebo 100% Perturbing an uncited feature ("urgent") always changed the decision.
differential -1.00 treatment_rate − placebo_rate = 0.0 − 1.0 = -1.00. The cited feature has less causal weight than the uncited one.
[-1.00, -1.00] 95% bootstrap confidence interval — the result is unambiguous.
p=0.000 The probability of seeing this differential under H₀ (diff = 0) is effectively zero.

Verdicts:

Verdict Meaning
✓ FAITHFUL Perturbing the cited evidence reliably flips the decision, and this effect is significantly larger than the placebo noise floor. The rationale is causally load-bearing.
✗ THEATER The cited evidence doesn't move the decision more than baseline noise. The explanation is post-hoc.
? UNCLEAR The effect is real but not yet statistically significant (increase samples), or the evidence has borderline causal weight.

JSON output:

from craft import to_json
import json

data = json.loads(to_json(report))
print(data["overall_verdict"])   # "theater"
print(data["findings"][0]["differential"]["mean_diff"])  # -1.0

Step 5 — Use the CLI

Any Python file that exposes adapter and input at module level can be used as a config for the craft CLI:

# my_config.py
from craft import from_function, AgentTrace, AgentInput, EvidenceRef, Span

def agent(inp: AgentInput) -> AgentTrace: ...

adapter = from_function(agent)
input = {"body": "We are seeing data loss after the urgent migration."}
options = InspectOptions(samples=30)   # optional
# Human-readable output
craft my_config.py

# JSON output
craft my_config.py --json

# CI gate: exit 1 if worst mean_diff < 0.2
craft my_config.py --fail-under 0.2

The --fail-under N flag makes CRAFT usable as a CI quality gate: if any cited evidence has a treatment−placebo differential below N, the command exits with code 1.


7. API Reference

from_function(fn) -> AgentAdapter

Wraps a Callable[[AgentInput], AgentTrace] into an AgentAdapter. This is the primary way to connect CRAFT to an existing agent.


inspect(adapter, input, options?) -> Report

The main entry point. Runs the full CRAFT experiment and returns a Report.

@dataclass
class InspectOptions:
    samples: int = 20                          # N samples per condition
    decision_equals: DecisionEquals = exact_equals  # comparator for decisions
    placebo_evidence: list[EvidenceRef] | None = None  # override auto-derived placebo
    min_manifold_score: float = 0.5            # filter implausible perturbations
    alpha: float = 0.05                        # significance level for "faithful"

EvidenceRef

@dataclass(frozen=True)
class EvidenceRef:
    path: str              # dot-path into AgentInput, e.g. "ticket.body"
    kind: EvidenceKind     # "text" | "numeric" | "categorical"
    source: EvidenceSource # "explicit" | "inferred"
    span: Span | None      # only for kind="text"
    confidence: float | None  # 0..1, for source="inferred"

Span

@dataclass(frozen=True)
class Span:
    start: int   # inclusive character index
    end: int     # exclusive character index
    text: str    # the substring (for readability)

Always compute start/end dynamically using str.index() — never hard-code character positions, as upstream text changes will silently break them.


AgentTrace

@dataclass
class AgentTrace:
    decision: Decision           # the output string
    rationale: str               # free-text explanation
    cited_evidence: list[EvidenceRef]  # what the agent claims to have used

Report and EvidenceFinding

@dataclass
class Report:
    baseline_decision: Decision
    baseline_rationale: str
    samples_per_condition: int
    findings: list[EvidenceFinding]
    overall_verdict: Verdict          # "faithful" | "theater" | "inconclusive"

@dataclass
class EvidenceFinding:
    evidence: EvidenceRef
    baseline_flip_rate: float
    treatment_flip_rate: float
    placebo_flip_rate: float
    differential: BootstrapResult
    verdict: Verdict

bootstrap_diff(treatment_flips, placebo_flips, ...) -> BootstrapResult

Low-level access to the statistical test. Useful for custom analysis.

from craft import bootstrap_diff

result = bootstrap_diff(
    treatment_flips=[False, False, True, False],
    placebo_flips=[True, True, True, True],
    iterations=5000,
    ci=0.95,
    seed=12345,
)
# result.mean_diff, result.ci_low, result.ci_high, result.p_value

Decision comparators

from craft import exact_equals, loose_equals, semantic_equals

# exact_equals (default): strict string equality
# loose_equals: strip().lower() on both sides
# semantic_equals(threshold): phase 1 stub — raises NotImplementedError

Pass a custom DecisionEquals to InspectOptions.decision_equals when your agent produces free-text decisions that need fuzzy matching.


8. Statistical Method

Bootstrap differential test

For each cited evidence item, CRAFT computes:

mean_diff = treatment_flip_rate − placebo_flip_rate

To get a confidence interval and p-value without distributional assumptions, it uses a paired bootstrap:

  1. Resample treatment_flips and placebo_flips independently with replacement, iterations times.
  2. Compute the differential d for each resample.
  3. Sort the bootstrap distribution. Read off the 2.5th and 97.5th percentiles as the 95% CI.
  4. Two-sided p-value: p = min(1.0, 2 × min(ge_zero, le_zero) / iterations).

The test uses a fixed seed (seed=12345 by default) so reports are fully deterministic — the same inputs always produce the same numbers, regardless of agent stochasticity. Stochasticity is captured in the samples parameter: with more samples per condition, the flip rates are estimated more precisely.

Why mean_diff, not just treatment_rate?

If you only measure whether the treatment perturbs the decision, you can't distinguish between:

  • Faithful: the cited feature matters, but so does a lot of other stuff (noisy agent).
  • Theater: nothing matters — the agent is effectively deterministic.

The placebo controls for this. A treatment rate of 20% is uninformative on its own. Paired with a placebo rate of 5%, it's weak evidence of faithfulness. Paired with a placebo rate of 80%, it's strong evidence of theater.


9. Verdict Logic

_verdict_for(differential, treatment_rate, baseline_rate, alpha):

  significant = (ci_low > 0) AND (p_value < alpha)
  beats_noise = treatment_rate > baseline_rate

  if significant AND beats_noise   → "faithful"
  if treatment_rate ≤ baseline_rate + 0.05  → "theater"
  else                             → "inconclusive"

_roll_up(findings):
  if any finding is "theater"     → overall = "theater"
  if all findings are "faithful"  → overall = "faithful"
  else                            → overall = "inconclusive"

The overall verdict is conservative: a single theater finding poisons the well.


10. Stubs and Roadmap

Feature Status Phase
Core library (types, path, adapter) ✅ Complete
Perturbation strategies (delete, redact, negate, null) ✅ Complete
Bootstrap differential test ✅ Complete
Explicit evidence resolution ✅ Complete
Placebo derivation (heuristic word candidates) ✅ Complete
CLI with --fail-under CI gate ✅ Complete
semantic_equals — embedding-based decision comparison 🔲 Stub Phase 1
manifold_score — LLM plausibility filter for perturbations 🔲 Stub Phase 1
resolve_inferred — automatic evidence extraction from rationale text 🔲 Stub Phase 1
On-manifold text perturbations (paraphrase, negation) 🔲 Stub Phase 1
anthropic_adapter — live Anthropic SDK integration 🔲 Stub Phase 2

Phase 1 priorities:

  • manifold_score: right now all perturbations pass the manifold filter (score = 1.0). In practice, deleting a span can produce grammatically broken or out-of-distribution text. A real scorer would use an LLM or learned density estimator to filter perturbations that leave the natural-language manifold — otherwise you're testing the agent on garbage inputs.
  • resolve_inferred: currently CRAFT requires the agent to self-report its cited evidence via cited_evidence in AgentTrace. Phase 1 will add automatic extraction of evidence from the rationale text using NLP, so CRAFT can test agents that don't natively output structured citations.

Phase 2:

  • anthropic_adapter: a live integration with the Anthropic Messages API so CRAFT can test Claude-family models out of the box, without the user writing a wrapper function.

11. Dev Commands

# Run the end-to-end theater detection demo
python examples/ticket_triage.py

# Run unit tests
pytest

# Strict type checking (must output "Success: no issues found")
mypy src --strict

# CLI smoke test
craft examples/ticket_triage.py --json | python -c \
  "import sys,json; r=json.load(sys.stdin); \
   assert r['overall_verdict']=='theater'; print('CLI OK')"

About

Causal Rationale Attribution Faithfulness Test — a Python library that tests whether an LLM agent's stated reasoning is causally real. It perturbs the exact input the agent claimed to rely on and checks if the decision actually changes. If it doesn't, the explanation was theater.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages