Explain a conversation with ExplainRunner

Business context: a reviewer needs to see what was explained (the conversation and its players), how (the configuration), and whether the numbers are consistent (efficiency). It takes one conversation to a JSON result that can sit next to a ticket or an audit record.

Goal: the public explain workflow: messages, snapshot JSON, player spans, YAML configuration and its guards, explain / explain_sync, the efficiency check, the JSON result, every built-in estimator and estimand, and run monitoring.

Prerequisites: mllm-shap, Python 3.12. A rule-based stand-in plays the model, so every number is reproducible; text_explain_e2e.ipynb uses real backends.

Expected output: player spans, refused configurations, efficient exact Shapley values, an estimator and estimand table, and the stage events of a monitored run.

import asyncio
import json
import re

from pydantic import ValidationError

from mllm_shap import (
    AudioPart,
    CoalitionMask,
    ConfigError,
    ConversationSnapshot,
    DeletePolicy,
    ExplainConfig,
    ExplainRunner,
    ExplainRunOutput,
    GenerationRecord,
    InMemoryObservabilitySink,
    Message,
    MllmShapError,
    PluginContext,
    Role,
    TextPart,
    TokenPartitioner,
    Turn,
    __version__,
    configure_logging,
    explain_config_from_yaml,
    explain_config_to_yaml,
)
from mllm_shap.domain.config import GenerationConfig
from mllm_shap.value.generation_records import generation_record_from_text

A stand-in model

Business: a deterministic stand-in makes the walkthrough reproducible anywhere; a real backend changes one configuration key, not the workflow.

Technical: a backend is any object with a model_id property and an async generate(snapshot, generation) that returns a GenerationRecord, here built with generation_record_from_text. The bot confirms the refund, receipt and deadline when those words reach it; otherwise it asks for clarification.

class RefundBot:
    """Rule-based stand-in for a support model."""

    model_id = "refund-bot"

    async def generate(
        self, snapshot: ConversationSnapshot, generation: GenerationConfig
    ) -> GenerationRecord:
        """Answer from the keywords that survived masking."""
        _ = generation
        prompt = " ".join(message.text for message in snapshot.messages())
        words = set(re.findall(r"[a-z]+", prompt.lower()))
        parts = []
        if "refund" in words:
            parts.append("refund approved")
        if "receipt" in words:
            parts.append("receipt checked")
        if words & {"thirty", "days"}:
            parts.append("deadline met")
        reply = " ".join(parts) or "please clarify"
        return generation_record_from_text(reply)

The conversation

Business: the snapshot is the unit that gets explained and archived; its id comes from the content, so the same conversation gets the same id.

Technical: Message(role=..., text=...) holds one TextPart; Message(role=..., content=(...)) holds several parts, and Message.text joins the text parts. ConversationSnapshot.to_json / from_json use the command-line format and keep the id; messages with several parts or audio cannot be written.

SYSTEM = (
    "You are a support assistant for a store. Answer only from the question, in one "
    'short sentence. If it does not give enough information, say "Not enough '
    'information." Never invent policy details and never ask a question.'
)
snapshot = ConversationSnapshot.from_turns((
    Turn(
        messages=(
            Message(role=Role.SYSTEM, text=SYSTEM),
            Message(role=Role.USER, text="Refund within thirty days with receipt?"),
        )
    ),
))
print("snapshot_id:", snapshot.snapshot_id)
print(snapshot.to_json(indent=None))
restored = ConversationSnapshot.from_json(snapshot.to_json())
print("JSON round trip keeps the id:", restored.snapshot_id == snapshot.snapshot_id)

split = Message(
    role=Role.USER, content=(TextPart("Refund within"), TextPart("thirty days?"))
)
print("text of a two-part message:", repr(split.text))
voice = Message(
    role=Role.USER,
    content=(TextPart("Recording attached."), AudioPart(b"RIFF", sample_rate=16_000)),
)
try:
    ConversationSnapshot.from_turns((Turn(messages=(voice,)),)).to_json()
except ValueError as exc:
    print("to_json refuses:", exc)
snapshot_id: 84caf96b080cfbe6
{"turns": [{"messages": [{"role": "system", "text": "You are a support assistant for a store. Answer only from the question, in one short sentence. If it does not give enough information, say \"Not enough information.\" Never invent policy details and never ask a question."}, {"role": "user", "text": "Refund within thirty days with receipt?"}]}]}
JSON round trip keeps the id: True
text of a two-part message: 'Refund within thirty days?'

Configure the run in YAML

Business: the YAML file is the reviewable record of how attributions were produced; it is stored in the run manifest and hashed into the run_id.

Technical: explain_config_from_yaml validates the text into an ExplainConfig; explain_config_to_yaml writes it back with every default filled in. The run below injects the stand-in, so backend.kind is required but unused.

CONFIG_YAML = """
backend:
  kind: mock
  model_id: refund-bot
generation:
  max_new_tokens: 16
explanation:
  estimator: exact
  estimand: shapley
  value_fn: tfidf_cosine
  absence_policy: delete
  budget:
    fraction: 1.0
  archive:
    path: ""   # no archive here; see archive_reanalyze.ipynb
  seed: 0
  plugin_options:
    tokens: {roles: [user]}   # explain the user's words; the system prompt stays fixed
"""
config = explain_config_from_yaml(CONFIG_YAML)
print(explain_config_to_yaml(config))
backend:
  kind: mock
  model_id: refund-bot
  api_host: null
  timeout_s: 120.0
  max_retries: 3
  options: {}
generation:
  max_new_tokens: 16
  temperature: 0.0
  top_k: 1
  compute_embeddings: false
explanation:
  estimand: shapley
  estimator: exact
  value_fn: tfidf_cosine
  normalizer: identity
  players: tokens
  absence_policy: delete
  budget:
    fraction: 1.0
  include_minimal_masks: false
  neyman:
    use_standard_method: false
    initial_fraction: null
    initial_num_samples: null
  max_inflight: 2
  max_players: 24
  archive:
    path: ''
    flush_every: 50
    on_exists: error
  dedup:
    enabled: auto
  kv_cache:
    enabled: false
    max_entries: 8
  embedding_mode: static
  seed: 0
  plugin_options:
    tokens:
      roles:
      - user

Players and their spans

Business: players are the units that receive credit; reviewers need to map every value back to its exact text.

Technical: the tokens partitioner splits messages on whitespace into a PlayerSet: one id per player and one PlayerSpan (message index, character offsets, covered text). Policies and labels read these spans, so punctuation stays attached to its word (receipt?). The roles option keeps the system prompt out of the game: it is sent every time and gets no credit.

print("players if every role were explained:", TokenPartitioner().partition(snapshot).num_players)
options = config.explanation.plugin_options["tokens"]
player_set = TokenPartitioner(
    PluginContext(config=config, options=options)
).partition(snapshot)
for player_id, span in zip(player_set.player_ids, player_set.spans, strict=True):
    print(
        f"{player_id}  message {span.message_index} "
        f"[{span.start:>2}:{span.end:>2}]  {span.text!r}"
    )
players if every role were explained: 43
84caf96b080cfbe6:0:1:0  message 1 [ 0: 6]  'Refund'
84caf96b080cfbe6:0:1:1  message 1 [ 7:13]  'within'
84caf96b080cfbe6:0:1:2  message 1 [14:20]  'thirty'
84caf96b080cfbe6:0:1:3  message 1 [21:25]  'days'
84caf96b080cfbe6:0:1:4  message 1 [26:30]  'with'
84caf96b080cfbe6:0:1:5  message 1 [31:39]  'receipt?'

Validation guards

Business: a configuration that cannot produce the attribution it names should fail before any model call is paid for.

Technical: contradictory settings raise ConfigError: exact needs budget.fraction: 1.0, complementary and neyman_cc estimate Shapley values only, and neyman_cc does not take include_minimal_masks. Unknown keys and wrong types raise pydantic’s ValidationError. Every deliberate error derives from MllmShapError and a built-in exception (ConfigError is a ValueError); the command line exits with 2 for them (cli.ipynb).

def refused(yaml_text: str) -> None:
    """Print why a configuration is refused."""
    try:
        explain_config_from_yaml(yaml_text)
    except (ConfigError, ValidationError) as exc:
        reason = " / ".join(str(exc).splitlines()[:2])
        print(f"{type(exc).__name__}: {reason}")


BACKEND = "backend: {kind: mock, model_id: refund-bot}\n"
refused(BACKEND + "explanation: {estimator: exact, budget: {fraction: 0.5}}")
refused(BACKEND + "explanation: {estimator: complementary, estimand: banzhaf}")
refused(BACKEND + "explanation: {estimator: neyman_cc, include_minimal_masks: true}")
refused(BACKEND + "explanation: {estimatr: exact}")
refused("")
print(
    "ConfigError is a ValueError and an MllmShapError:",
    issubclass(ConfigError, ValueError) and issubclass(ConfigError, MllmShapError),
)
ConfigError: estimator 'exact' enumerates every coalition; set budget.fraction: 1.0
ConfigError: estimator 'complementary' estimates the shapley estimand only; use estimator 'mc' or 'exact' for banzhaf
ConfigError: estimator 'neyman_cc' does not support include_minimal_masks: true
ValidationError: 1 validation error for ExplainConfig / explanation.estimatr
ConfigError: explain config root must be a mapping, got NoneType
ConfigError is a ValueError and an MllmShapError: True

Run the explanation

Business: efficiency is a check a reviewer can do by hand: exact Shapley values add up to the change in the value function between the empty and the full prompt.

Technical: await runner.explain(snapshot) runs the pipeline and reuses the injected backend. Inside Jupyter’s event loop explain_sync refuses; from a worker thread it returns the same result. With tfidf_cosine a coalition’s value is the similarity of its answer to the full prompt’s answer, so grand_value is 1 and base_value (the “please clarify” answer) is 0. The metrics count 2^6 = 64 coalitions; the grand coalition reuses the base generation, the one cache hit.

runner = ExplainRunner(config, backend=RefundBot())
output = await runner.explain(snapshot)
result = output.result
for span, value in zip(output.player_set.spans, result.values, strict=True):
    print(f"{span.text:>9} {value:+.4f}")
print("base_value:", result.base_value, " grand_value:", result.grand_value)
print("sum of values:          ", round(sum(result.values), 6))
print("grand_value - base_value:", round(result.grand_value - result.base_value, 6))
print(output.metrics)
try:
    runner.explain_sync(snapshot)
except RuntimeError as exc:
    print("explain_sync inside Jupyter:", exc)


def explain_in_a_script() -> ExplainRunOutput:
    """What a script does: a blocking call; ``with`` closes the runner."""
    with ExplainRunner(config, backend=RefundBot()) as script_runner:
        return script_runner.explain_sync(snapshot)


scripted = await asyncio.to_thread(explain_in_a_script)
print(
    "explain_sync without an event loop gives the same result:",
    scripted.result == result,
)
   Refund +0.3435
   within +0.0000
   thirty +0.1565
     days +0.1565
     with +0.0000
 receipt? +0.3435
base_value: 0.0  grand_value: 1.0
sum of values:           1.0
grand_value - base_value: 1.0
SchedulerMetrics(requested=64, executed=63, deduplicated=0, cache_hits=1, kv_cache_hits=0, kv_cache_reused_tokens=0)
explain_sync inside Jupyter: explain_sync cannot run inside an event loop; use 'await runner.explain(...)'
explain_sync without an event loop gives the same result: True

What the model answers: the full prompt, then the prompt with one user word deleted.

backend = RefundBot()
full = await backend.generate(snapshot, config.generation)
print(f"{'full prompt':>16} -> {full.text}")
for index, span in enumerate(output.player_set.spans):
    mask = CoalitionMask.from_sequence(
        tuple(other != index for other in range(output.player_set.num_players))
    )
    absent = DeletePolicy().apply(snapshot, output.player_set, mask)
    answer = await backend.generate(absent, config.generation)
    print(f"{'without ' + span.text:>16} -> {answer.text}")
     full prompt -> refund approved receipt checked deadline met
  without Refund -> receipt checked deadline met
  without within -> refund approved receipt checked deadline met
  without thirty -> refund approved receipt checked deadline met
    without days -> refund approved receipt checked deadline met
    without with -> refund approved receipt checked deadline met
without receipt? -> refund approved deadline met

within and with never change the answer, so they are null players with value 0. Refund and receipt? each switch one phrase on and get the largest, equal shares. Either thirty or days triggers the deadline phrase, so they split its credit (symmetry). The two printed sums agree: exact Shapley values are efficient.

The JSON result

Business: a stable JSON payload can be attached to a ticket, diffed between releases and loaded by dashboards without mllm-shap.

Technical: ExplainRunOutput.to_dict() holds the values, one label per player, base_value, grand_value, the estimand, the counters, the manifest and the timings; the command line writes the same payload.

payload = output.to_dict()
print("keys:", list(payload))
print("label of player 3:", json.dumps(payload["labels"][3]))
manifest = payload["manifest"]
print({
    key: value for key, value in manifest.items() if key not in ("config", "player_ids")
})
print(
    "manifest records this package version:",
    manifest["mllm_shap_version"] == __version__,
)
print("JSON size:", len(json.dumps(payload)), "characters")
keys: ['values', 'labels', 'base_value', 'grand_value', 'estimand', 'metrics', 'manifest', 'perf']
label of player 3: {"player_id": "84caf96b080cfbe6:0:1:3", "text": "days", "message_index": 1, "start": 21, "end": 25, "speaker": null, "label": "days"}
{'schema_version': 4, 'mllm_shap_version': '0.6.0', 'run_id': '84caf96b080cfbe6-c8b90ed02a00', 'snapshot_id': '84caf96b080cfbe6', 'estimand': 'shapley', 'estimator': 'exact', 'value_fn': 'tfidf_cosine', 'normalizer': 'identity', 'absence_policy': 'delete', 'players': 'tokens', 'backend_kind': 'mock', 'model_id': 'refund-bot', 'embedding_source': 'none', 'embedding_model': ''}
manifest records this package version: True
JSON size: 2680 characters

Estimators, estimands and budgets

Business: exact enumeration costs 2^n model calls; approximate estimators trade accuracy for budget, and Banzhaf answers a different question than Shapley.

Technical: each row changes only explanation keys. budget.fraction is the share of the 2^n coalitions an estimator may request; complementary and Neyman-CC never go below 2n + 2 masks. Efficiency holds exactly only on the full table (exact Shapley, or any Shapley estimator at fraction: 1.0); sampled estimates converge to it, and Banzhaf values are not efficient by definition. max_players refuses oversized plans before any call.

def variant(**explanation: object) -> ExplainConfig:
    """Return ``config`` with some explanation keys replaced (validated)."""
    payload = config.model_dump(mode="json")
    payload["explanation"].update(explanation)
    return ExplainConfig.model_validate(payload)


ROWS = (
    ("exact", "shapley", 1.0),
    ("exact", "banzhaf", 1.0),
    ("mc", "shapley", 0.25),
    ("mc", "banzhaf", 0.25),
    ("complementary", "shapley", 0.25),
    ("neyman_cc", "shapley", 0.25),
    ("complementary", "shapley", 0.05),
)
print(
    f"{'estimator':13} {'estimand':8} {'fraction':>8} {'coalitions':>10} "
    f"{'sum':>7} {'v(N)-v(0)':>9}"
)
for estimator, estimand, fraction in ROWS:
    run = await ExplainRunner(
        variant(estimator=estimator, estimand=estimand, budget={"fraction": fraction}),
        backend=RefundBot(),
    ).explain(snapshot)
    gap = run.result.grand_value - run.result.base_value
    print(
        f"{estimator:13} {estimand:8} {fraction:8.2f} {run.metrics.requested:10d} "
        f"{sum(run.result.values):7.3f} {gap:9.3f}"
    )

try:
    await ExplainRunner(variant(max_players=5), backend=RefundBot()).explain(snapshot)
except ConfigError as exc:
    print("max_players guard:", exc)
estimator     estimand fraction coalitions     sum v(N)-v(0)
exact         shapley      1.00         64   1.000     1.000
exact         banzhaf      1.00         64   0.842     1.000
mc            shapley      0.25         16   1.340     1.000
mc            banzhaf      0.25         16   1.302     1.000
complementary shapley      0.25         16   1.000     1.000
neyman_cc     shapley      0.25         16   0.894     1.000
complementary shapley      0.05         14   1.000     1.000
max_players guard: snapshot has 6 players; budget.fraction 1.0 plans about 2^6.0 coalitions, more than the 2^5 allowed by explanation.max_players=5. Lower the budget, use coarser players, or raise max_players.

The exact Shapley row adds up to v(N) - v(∅); the exact Banzhaf row does not, because Banzhaf weights coalitions equally instead of splitting the total. The sampled rows land near the target. The last row shows the 2n + 2 floor: 14 coalitions although 5% of 64 would be 3. estimator_comparison.ipynb measures accuracy against budget.

Monitor a run

Business: long runs on paid APIs need progress and timing data for capacity planning and incident review.

Technical: progress= takes a CoalitionProgress (on_coalitions_planned, on_coalition_finished); Neyman-CC plans in two phases, so the planned total grows once. telemetry= takes an ObservabilitySink; InMemoryObservabilitySink keeps one span per stage. configure_logging(level="INFO") prints stage events to stderr (json=True for JSON lines). output.perf splits the wall-clock time into generation, scoring and aggregation.

class PrintProgress:
    """Print planned totals and every 32nd finished coalition."""

    def on_coalitions_planned(self, total: int) -> None:
        """Report the number of coalitions planned so far."""
        print("planned:", total)

    def on_coalition_finished(self, done: int, total: int) -> None:
        """Report progress every 32 coalitions and at the end."""
        if done % 32 == 0 or done == total:
            print(f"finished {done}/{total}")


configure_logging(level="INFO")
sink = InMemoryObservabilitySink()
monitored = ExplainRunner(
    variant(estimator="neyman_cc", budget={"fraction": 0.5}),
    telemetry=sink,
    progress=PrintProgress(),
    backend=RefundBot(),
)
monitored_output = await monitored.explain(snapshot)
configure_logging(level="WARNING")
print("stages:", [span.name for span in sink.spans])
print("perf fields:", list(monitored_output.to_dict()["perf"]))
planned: 32
finished 32/32
stages: ['orchestrator.generate_base', 'estimator.sample_masks', 'orchestrator.schedule', 'orchestrator.score', 'orchestrator.aggregate', 'orchestrator.run']
perf fields: ['total_ms', 'generation_ms', 'scoring_ms', 'aggregation_ms']
2026-10-13 09:35:21,867 INFO [mllm_shap.pipeline.orchestrator] explain run 84caf96b080cfbe6-c917fde971f9 started: 6 players, estimator neyman_cc, backend mock
2026-10-13 09:35:21,868 INFO [mllm_shap.pipeline.orchestrator] base generated in 0.1 ms
2026-10-13 09:35:21,869 INFO [mllm_shap.pipeline.orchestrator] 32 coalitions scheduled
2026-10-13 09:35:21,872 INFO [mllm_shap.pipeline.orchestrator] explain run 84caf96b080cfbe6-c917fde971f9 done in 4.5 ms (32 coalitions, 31 backend calls)