multivon-eval attribution is the structured-diff substrate that the
/eval-audit skill uses to figure out which prompts
actually changed in a PR before deciding what to re-run. It walks a Python
repo with an AST visitor, finds every anthropic.messages.create /
openai.chat.completions.create / litellm.completion call site, extracts
the string-literal prompts, fingerprints them, and emits a
PR-comment-ready Markdown diff.
Shipped in 0.9.4.
Implementation lives in
multivon_eval/attribution/
— five small modules, no extra dependencies, pure stdlib.
This is Phase 1 — descriptive, not causal. Output says “these N prompts
changed between base and head”; it never says “this prompt change caused this
regression.” The hardened calibration spike of 2026-05-30 showed Haiku-based
causal attribution failing catastrophically on mixed-cause regressions
(14% HIGH-confidence-and-wrong rate). Causal attribution is gated on a
future non-prompt-change sidecar signal — see the package docstring for
the gate.
When to use it
- In CI, on every PR: run
attribution diff base headand post the Markdown output as a PR comment. Reviewers see exactly which prompts changed without scrolling the unified diff. - From
/eval-audit: the skill callsscanto identify which call sites the PR touched, then runs only the eval cases that stress those surfaces. - One-off audits:
attribution scan .lists every prompt literal in your codebase. Useful when onboarding to a repo you didn’t write.
What it captures
The AST extractor inast_extractor.py
matches three call shapes:
system or messages kwarg are
silently dropped.
Literals captured as static:
- Plain string literals:
system="..." - f-strings with zero runtime interpolation (their content is fully known at parse time)
- Module-level string constants referenced by name at the call site (one hop, same file; see below)
Attribute lookups, runtime f-strings, .join(...)
calls, names that don’t resolve under the rules below) is recorded as a
PromptRecord with is_dynamic=True and a placeholder text. The count is
preserved and the gap is visible in the diff, but the actual text isn’t
compared.
Scanner v3 (0.10.1)
0.10.1 shippedSCANNER_VERSION = 3.
v2 (0.10.0) added the first two items; v3 closed three detection blind
spots found by running the scanner against five real open-source repos
(aider, gpt-researcher, open-interpreter, letta, pr-agent), where v2 saw
zero call sites in four of the five:
-
Aliased litellm imports (
import litellm as llm,from litellm import completion as c) now resolve to litellm call sites. -
**kwargs-unpacked calls are recorded as dynamic records with placeholder<dynamic:KwargsUnpack>instead of being invisible. -
messages=<variable>(the dominant real-world pattern) produces an honest dynamic record instead of nothing. A statically-known emptymessages=[]still produces nothing — there is no prompt there. -
One-hop module-constant resolution.
SYSTEM_PROMPT = "..."at module scope followed bysystem=SYSTEM_PROMPTat a call site now resolves to the real text instead of a dynamic placeholder. The rules are deliberately conservative, because a false “static” poisons every downstream verdict while a false “dynamic” is merely cautious. A name assigned more than once, assigned under a conditional/loop/try, augmented, tuple-unpacked, shadowed in the enclosing scope, declaredglobal, or defined in another module stays dynamic. One hop only:X = Ychains do not resolve. -
loose_fingerprinton every record: a hash of the whitespace-collapsed prompt text, alongside the strictfingerprint. It exists solely to label a change as formatting-only, never to suppress it.
Scanner v4 (0.11.1)
The scanner that ships with 0.11.1+ is versionedSCANNER_VERSION = 4.
v4 is hardening from an adversarial audit. Every fix replaces a crash or a
false verdict with an accurate one:
- Fingerprints are NFC-normalized (the version bump). Composed vs
decomposed unicode (“é” as one codepoint vs
e+ combining accent) is an editor/OS artifact, not a prompt change — it previously fingerprinted as drift. match-statement capture patterns disqualify module constants.case PROMPT:rebinds the name through a path the constant resolver didn’t see, letting a rebound constant read as static. Such names now stay dynamic.- Unscannable files surface as UNSCANNABLE, never REMOVED. A file the
scanner can’t parse (syntax error, non-UTF8 encoding) previously
returned zero records, so the staleness report
marked every baselined site in it REMOVED, and
--fail-on removedfailed CI with a misleading verdict. They now report as a distinct UNSCANNABLE tier (“file exists but could not be parsed — verdict unknown, NOT removed”): a warning names each file with its reason in all three renderers, the JSON report gainsskipped_files, and--fail-on removedno longer trips. Skipped files are a report-time concept, never written into baselines. - Symlinks resolving outside the repo root are skipped, not recorded. They previously wrote machine-specific absolute paths into the baseline, producing false REMOVED+ADDED churn on every other checkout.
prompt_baseline.json; a baseline
written by an older scanner version read by a newer scan prints a “rescan
recommended” warning instead of fake drift.
Attribution is the scanner; the staleness report
(also 0.10.0) is the drift detector built on it: a committed baseline of
every call site plus per-case provenance, answering “which prompts changed
since my cases were authored?” rather than “what changed between these two
checkouts?”.
attribution scan
Walks a Python repo and lists every prompt call site it finds.
[dynamic] marker flags call sites where the prompt is built at
runtime; the extractor records the location but can’t compare the text
across refs.
JSON output is a flat list of records — fingerprint, file/line, sdk,
role, role position, qualname, and a 200-char text preview. Suitable for
piping into jq or feeding into a CI step that wants to fail on
unfingerprinted prompts.
Walk skips .venv/, node_modules/, __pycache__/, and other build
directories automatically.
attribution diff
Compute the structured prompt diff between two repo checkouts.
--format markdown emits a PR-comment-ready block. The
render.py
output truncates each prompt body to the first six lines or 400 chars
(whichever hits first) with an explicit … (truncated) marker — so the
PR comment stays readable on a long system prompt.
Diffs are ordered: modified first, then added, then removed, then
dynamic, sorted by call_site_id within each group. That order is
stable across runs and surfaces the highest-signal changes (modified
literals) first.
How identity works
TwoPromptRecords are considered “the same call site across refs” iff
(file_path, line, sdk, role, role_position) all match. This is the
Tier-1 identity used by diff_records. A renamed file or a shifted line
will register as removed + added rather than modified — Tier-2
file-move / call-site-shift detection is a future addition (see
schema.py).
change_type values
A purely-dynamic call site with identical placeholder text on both sides
is not emitted — there’s no meaningful change to surface.
Programmatic API
Same surface as the CLI, importable from any Python:scan returns list[PromptRecord]. diff_records returns
list[PromptDiff]. render_markdown emits the same string the CLI
prints with --format markdown. The dataclasses are frozen and exposed
from the package root, so you can build your own renderer if the bundled
one doesn’t fit your PR-comment style.
Wiring it into CI
The two-line GitHub Actions step (assuming the workflow already checks out base and head into separate directories):.github/workflows/eval-pr.yml
attribution scan|diff with the suite runner and the
calibrated threshold gate.
What’s next
- The Phase 2 sidecar that gates causal attribution on a non-prompt-change
signal is under design — see the package docstring in
attribution/__init__.py. - Tier-2 identity (file-move / line-shift detection) is on the roadmap; until it lands, a refactor that moves a prompt to a different line will show up as removed + added.
See also
- Prompt-drift staleness — the standing drift report built on this scanner: baseline snapshot, case provenance, CI gate.
/eval-auditskill — the PR-gating workflow that consumesattribution scanto scope which cases to re-run.- eval-action on GitHub — the GitHub Action that wraps this CLI plus the suite runner.
- Bootstrap workflow — generate the suite that
/eval-auditruns. - Source on GitHub — five small files, ~400 lines total.

