debatebench 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """debatebench: structured, multi-turn adversarial LLM debates.
2
+
3
+ Submodules are imported explicitly, never from here, so importing the backend
4
+ seam doesn't pull in httpx or PyYAML (ADR-008).
5
+ """
debatebench/api.py ADDED
@@ -0,0 +1,242 @@
1
+ """The public API (ADR-028). Everything here is supported; everything else isn't.
2
+
3
+ Two functions, named for the two commands:
4
+
5
+ import asyncio
6
+ from debatebench.api import debate, judge, load_run, write_transcript
7
+
8
+ config = load_run("run.yaml")
9
+ transcript = asyncio.run(debate(config))
10
+ write_transcript(transcript, config.output)
11
+
12
+ sheet = asyncio.run(judge(transcript, model="qwen3:8b",
13
+ base_url="http://localhost:11434/v1", budget=6000))
14
+ print(sheet.winner, [side.total for side in sheet.sides])
15
+
16
+ Nothing here writes a file on its own (ADR-028 §5) and nothing is synchronous
17
+ (§6). Pass ``backends=`` or ``backend=`` and no HTTP call is made at all —
18
+ anything with one ``async generate`` satisfies ADR-009's ``Backend``.
19
+
20
+ This is the one module allowed to import both httpx and PyYAML, because
21
+ composing the tool's two jobs needs both. That is why it is absent from
22
+ tests/test_layering.py, and why it is not re-exported from ``__init__.py``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ # Aliased out of the namespace: this module's name list is its contract, so a
28
+ # name that is here only to implement something must not read as an export.
29
+ from collections.abc import Sequence as _Sequence
30
+ from dataclasses import replace as _replace
31
+
32
+ from .backend import (
33
+ DEFAULT_READ_TIMEOUT,
34
+ Backend,
35
+ BackendError,
36
+ GenerationRequest,
37
+ GenerationResult,
38
+ Message,
39
+ )
40
+ from .config import ConfigError, JudgeConfig, RunConfig, Side, Team, load_run
41
+ from .event_stream import SCHEMA_VERSION as EVENT_SCHEMA_VERSION
42
+ from .event_stream import make_event_writer
43
+ from .events import DebateEvent, EventBus, EventType
44
+ from .judging import (
45
+ DIMENSIONS,
46
+ HIT_STATUSES,
47
+ SCORE_SCHEMA_VERSION,
48
+ VERDICTS,
49
+ Claim,
50
+ DimensionScore,
51
+ FactCheck,
52
+ Hit,
53
+ JudgeError,
54
+ ScoreSheet,
55
+ SideScore,
56
+ write_scores,
57
+ )
58
+ from .judging import fact_check_debate as _fact_check_debate
59
+ from .judging import score_debate as _score_debate
60
+ from .judging import as_json_dict as scores_json
61
+ from .openai_compat import OpenAICompatibleBackend, open_client
62
+ from .orchestrator import DebateError
63
+ from .orchestrator import run_debate as _orchestrate
64
+ from .retrieval import RetrievalError
65
+ from .transcript import SCHEMA_VERSION as TRANSCRIPT_SCHEMA_VERSION
66
+ from .transcript import (
67
+ Evidence,
68
+ RunSnapshot,
69
+ SideSnapshot,
70
+ TeamSnapshot,
71
+ Transcript,
72
+ TranscriptError,
73
+ Turn,
74
+ Usage,
75
+ load_transcript,
76
+ write_transcript,
77
+ )
78
+ from .transcript import as_json_dict as transcript_json
79
+
80
+ __all__ = [
81
+ # The two jobs (ADR-028 §2)
82
+ "debate",
83
+ "judge",
84
+ # Reading configs and transcripts
85
+ "load_run",
86
+ "load_transcript",
87
+ # Writing, which the two above deliberately do not do (ADR-028 §5)
88
+ "write_transcript",
89
+ "write_scores",
90
+ "transcript_json",
91
+ "scores_json",
92
+ # The backend seam: implement this and the API makes no HTTP call (ADR-009)
93
+ "Backend",
94
+ "GenerationRequest",
95
+ "GenerationResult",
96
+ "Message",
97
+ "OpenAICompatibleBackend",
98
+ "open_client",
99
+ "DEFAULT_READ_TIMEOUT",
100
+ # Watching a run in-process; --events is the out-of-process equivalent (ADR-027)
101
+ "DebateEvent",
102
+ "EventBus",
103
+ "EventType",
104
+ "make_event_writer",
105
+ # Config types
106
+ "RunConfig",
107
+ "JudgeConfig",
108
+ "Side",
109
+ "Team",
110
+ # Transcript types (ADR-005)
111
+ "Transcript",
112
+ "Turn",
113
+ "Usage",
114
+ "Evidence",
115
+ "RunSnapshot",
116
+ "SideSnapshot",
117
+ "TeamSnapshot",
118
+ # Score types (ADR-013, ADR-015)
119
+ "ScoreSheet",
120
+ "SideScore",
121
+ "DimensionScore",
122
+ "Hit",
123
+ "FactCheck",
124
+ "Claim",
125
+ # Every way this fails (ADR-028 Consequences: no common base, yet)
126
+ "ConfigError",
127
+ "DebateError",
128
+ "JudgeError",
129
+ "BackendError",
130
+ "TranscriptError",
131
+ "RetrievalError",
132
+ # The vocabularies a score file uses
133
+ "DIMENSIONS",
134
+ "HIT_STATUSES",
135
+ "VERDICTS",
136
+ # The three durable contracts (ADR-028 §9)
137
+ "TRANSCRIPT_SCHEMA_VERSION",
138
+ "SCORE_SCHEMA_VERSION",
139
+ "EVENT_SCHEMA_VERSION",
140
+ ]
141
+
142
+
143
+ async def debate(
144
+ config: RunConfig,
145
+ *,
146
+ backends: _Sequence[Backend] | None = None,
147
+ events: EventBus | None = None,
148
+ ) -> Transcript:
149
+ """Run every configured phase and return the transcript. Writes nothing.
150
+
151
+ ``backends`` is one per side, in side-index order; omit it and one
152
+ ``OpenAICompatibleBackend`` per side is built from the config and torn down
153
+ before this returns, honouring ``config.timeout`` (ADR-025). Pass your own
154
+ and no HTTP client is opened, so ``config.timeout`` is not consulted — the
155
+ waiting is then your backend's business.
156
+
157
+ ``config.output`` is *not* used — ADR-007 requires the key, this function
158
+ ignores it (ADR-028 §5). Write the result yourself:
159
+ ``write_transcript(transcript, config.output)``.
160
+
161
+ Raises ``DebateError`` if any phase fails (Hard Rule 1: nothing partial
162
+ comes back), or ``BackendError`` if a server does.
163
+ """
164
+ if backends is not None:
165
+ _check_backends(backends, config)
166
+ return await _orchestrate(config, backends, events)
167
+ async with open_client(config.timeout) as client:
168
+ built = [OpenAICompatibleBackend(client, side.base_url, side.model) for side in config.sides]
169
+ return await _orchestrate(config, built, events)
170
+
171
+
172
+ async def judge(
173
+ transcript: Transcript,
174
+ *,
175
+ model: str,
176
+ budget: int,
177
+ base_url: str | None = None,
178
+ backend: Backend | None = None,
179
+ fact_check: bool = True,
180
+ timeout: int | None = None,
181
+ ) -> ScoreSheet:
182
+ """Score a transcript, and audit its claims unless ``fact_check=False``.
183
+
184
+ One call for the rubric and, when the fact-check is on, a second for the
185
+ audit (ADR-015 §3). ``model`` is recorded in the sheet whichever backend
186
+ runs, so an injected one should be passed the name it is really using.
187
+
188
+ Pass ``base_url=`` to use the built-in adapter, or ``backend=`` for your
189
+ own. ``timeout`` applies only to a client this function opens, so it may not
190
+ be combined with ``backend=``. Writes nothing: ``write_scores(sheet, path)``
191
+ does that.
192
+
193
+ Raises ``JudgeError`` if the reply can't be parsed or overran its budget.
194
+ """
195
+ # Hard Rule 5: the budget is enforced here, not left to the caller's care.
196
+ # `debate` gets this from load_run's validation; these four arrive as bare
197
+ # keyword arguments, so this is the only place that can check them.
198
+ if budget < 1:
199
+ raise ValueError(f"judge() needs a budget of at least 1 completion token, got {budget}")
200
+ if not model.strip():
201
+ raise ValueError("judge() needs a non-empty model name: it is recorded in the score file")
202
+
203
+ if backend is not None:
204
+ if timeout is not None:
205
+ raise ValueError(
206
+ f"judge() was given both backend= and timeout={timeout}, and the timeout "
207
+ "would do nothing: it configures the HTTP client this function opens, and "
208
+ "your backend brings its own. Set the timeout on that backend instead"
209
+ )
210
+ return await _score(transcript, backend, model=model, budget=budget, fact_check=fact_check)
211
+ if base_url is None:
212
+ raise ValueError(
213
+ "judge() has nowhere to send the call: pass base_url= to use the built-in "
214
+ "openai-compatible adapter (e.g. 'http://localhost:11434/v1'), or backend= "
215
+ "with your own implementation of the Backend protocol (ADR-009)"
216
+ )
217
+ async with open_client(DEFAULT_READ_TIMEOUT if timeout is None else timeout) as client:
218
+ adapter = OpenAICompatibleBackend(client, base_url, model)
219
+ return await _score(transcript, adapter, model=model, budget=budget, fact_check=fact_check)
220
+
221
+
222
+ async def _score(
223
+ transcript: Transcript, backend: Backend, *, model: str, budget: int, fact_check: bool
224
+ ) -> ScoreSheet:
225
+ """The scoring call, then the fact-check call when it's on (ADR-015 §3)."""
226
+ sheet = await _score_debate(
227
+ transcript, backend, model=model, budget=budget, fact_check_enabled=fact_check
228
+ )
229
+ if not fact_check:
230
+ return sheet
231
+ checked = await _fact_check_debate(transcript, backend, budget=budget)
232
+ return _replace(sheet, fact_check=checked)
233
+
234
+
235
+ def _check_backends(backends: _Sequence[Backend], config: RunConfig) -> None:
236
+ """Caught here, not as an IndexError three turns into a run (Hard Rule 1)."""
237
+ if len(backends) != len(config.sides):
238
+ raise ValueError(
239
+ f"debate() was given {len(backends)} backend(s) for {len(config.sides)} sides; "
240
+ "pass one per side, in side-index order — backends[side.index] is the one "
241
+ "each side speaks through"
242
+ )
debatebench/backend.py ADDED
@@ -0,0 +1,56 @@
1
+ """The backend seam: one async method between the orchestrator and a model server.
2
+
3
+ Types per ADR-009. The single-method async ``Protocol`` shape is a design lift
4
+ from arbgjr/multi-agent-debate's ``LLMProviderProtocol`` (MIT; see ADR-001).
5
+ This module imports only the standard library (ADR-008).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Literal, Protocol
12
+
13
+ Role = Literal["system", "user", "assistant"]
14
+
15
+ # ADR-025 §1: seconds to wait for a reply. It lives here because it is the one
16
+ # module both `config` (which may not import httpx) and `openai_compat` (which
17
+ # may not import yaml) are allowed to import — ADR-008's layering, pinned by
18
+ # tests/test_layering.py. 600 was the hardcoded value before ADR-025 made it a
19
+ # setting, and stays the default so no existing config behaves differently.
20
+ DEFAULT_READ_TIMEOUT = 600
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Message:
25
+ role: Role
26
+ content: str
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class GenerationRequest:
31
+ messages: tuple[Message, ...]
32
+ max_completion_tokens: int # the per-phase budget
33
+ seed: int | None = None # the run's seed, sent with every request (ADR-009)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class GenerationResult:
38
+ """A reply exactly as the server gave it.
39
+
40
+ Empty text and a completion_tokens count over budget are returned as they
41
+ are: judging a reply is the orchestrator's job (Hard Rules 1 and 5).
42
+ """
43
+
44
+ text: str
45
+ prompt_tokens: int
46
+ completion_tokens: int
47
+ finish_reason: str
48
+ latency_ms: int
49
+
50
+
51
+ class BackendError(Exception):
52
+ """No usable reply: an HTTP error, a timeout, or a malformed or usage-less reply."""
53
+
54
+
55
+ class Backend(Protocol):
56
+ async def generate(self, request: GenerationRequest) -> GenerationResult: ...
debatebench/cli.py ADDED
@@ -0,0 +1,198 @@
1
+ """The ``debate`` command: a run.yaml, plus ADR-021's override flags.
2
+
3
+ It runs every configured phase, then writes the transcript as JSON to the
4
+ `output:` path, rotating any file already there to `<output>.1` (ADR-005).
5
+ Nothing but that JSON goes to the file; everything the command says goes to
6
+ stderr (Hard Rule 7).
7
+
8
+ `--model` and `--budget` override the file for one run, so an A/B needs no
9
+ second config (ADR-021). They change the loaded config before anything runs,
10
+ which is what keeps the transcript's snapshot a record of what actually spoke.
11
+
12
+ The run itself is `api.debate` (ADR-028 §4): this module is that function plus
13
+ argv, stderr and an exit code, so a library caller and this command cannot get
14
+ different behaviour out of the same config.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import asyncio
21
+ import sys
22
+ from collections.abc import Sequence
23
+ from dataclasses import replace
24
+ from typing import Any
25
+
26
+ from .api import debate
27
+ from .backend import BackendError
28
+ from .config import ConfigError, RunConfig, Side, load_run
29
+ from .event_stream import make_event_writer
30
+ from .events import DebateEvent, EventBus, EventType, Listener
31
+ from .orchestrator import DebateError
32
+ from .transcript import write_transcript
33
+
34
+
35
+ def _parser() -> argparse.ArgumentParser:
36
+ """One definition of the flags, so tests pin the names the CLI really uses."""
37
+ parser = argparse.ArgumentParser(
38
+ prog="debate",
39
+ description="Run a structured debate. Every setting, including the output path, "
40
+ "comes from the run.yaml file; --model and --budget override it for one run.",
41
+ )
42
+ parser.add_argument("run_yaml", metavar="run.yaml", help="the run's config file")
43
+ # ADR-021: model and budget only. Nothing else is overridable, on purpose —
44
+ # see its §6 for why output, seed, base_url and topic are not.
45
+ parser.add_argument("--model", help="override both sides' model for this run")
46
+ parser.add_argument("--budget", type=int, help="override both sides' per-phase cap")
47
+ parser.add_argument("--pro-model", help="override only the pro side's model")
48
+ parser.add_argument("--con-model", help="override only the con side's model")
49
+ parser.add_argument("--pro-budget", type=int, help="override only the pro side's cap")
50
+ parser.add_argument("--con-budget", type=int, help="override only the con side's cap")
51
+ # ADR-025 §5: not an experimental variable — it cannot change a token of the
52
+ # output, only whether the output arrives, so it sits outside ADR-021's list.
53
+ parser.add_argument("--timeout", type=int, help="seconds to wait for a reply (default 600)")
54
+ # ADR-027: a machine-readable view on the one stream this command leaves free.
55
+ parser.add_argument(
56
+ "--events",
57
+ action="store_true",
58
+ help="stream one JSON object per event to stdout, for another program to read",
59
+ )
60
+ return parser
61
+
62
+
63
+ def main(argv: Sequence[str] | None = None) -> int:
64
+ args = _parser().parse_args(argv)
65
+
66
+ try:
67
+ config = load_run(args.run_yaml)
68
+ except ConfigError as e:
69
+ _log(f"config error: {e}")
70
+ return 1
71
+ try:
72
+ config, overrides = _overridden(config, args)
73
+ except _OverrideError as e:
74
+ _log(str(e))
75
+ return 1
76
+ for line in overrides:
77
+ _log(line)
78
+ if config.seed_generated:
79
+ _log(f"run.yaml sets no seed; generated seed {config.seed}")
80
+ # Checked before the debate, so a missing directory can't waste a whole run.
81
+ if not config.output.parent.is_dir():
82
+ _log(f"config error: the output directory does not exist: {config.output.parent}")
83
+ return 1
84
+
85
+ events = EventBus()
86
+ events.subscribe(make_log_event(tuple(side.side for side in config.sides)))
87
+ if args.events:
88
+ # stdout, which Hard Rule 7 left free by putting the transcript in a named
89
+ # file rather than a redirect. The stderr log is unaffected (ADR-027 §1).
90
+ events.subscribe(make_event_writer(config))
91
+ try:
92
+ transcript = asyncio.run(debate(config, events=events))
93
+ except (DebateError, BackendError) as e:
94
+ _log(f"debate failed: {e}")
95
+ return 1
96
+
97
+ try:
98
+ backup = write_transcript(transcript, config.output)
99
+ except OSError as e:
100
+ _log(f"could not write the transcript to {config.output}: {e}")
101
+ return 1
102
+ if backup is not None:
103
+ _log(f"moved the previous transcript to {backup.name}")
104
+ _log(f"wrote {len(transcript.turns)} turns over {len(config.phases)} phases to {config.output}")
105
+ return 0
106
+
107
+
108
+ class _OverrideError(Exception):
109
+ """An override flag that can't be used as given (ADR-021)."""
110
+
111
+
112
+ def _overridden(
113
+ config: RunConfig, args: argparse.Namespace
114
+ ) -> tuple[RunConfig, tuple[str, ...]]:
115
+ """Apply ADR-021's override flags, before anything runs.
116
+
117
+ Overriding the *config* rather than the backend call is what keeps ADR-005's
118
+ snapshot honest: the transcript records the model that actually spoke, never
119
+ the one the file happened to name.
120
+ """
121
+ one_side = {
122
+ "model": {"pro": args.pro_model, "con": args.con_model},
123
+ "budget": {"pro": args.pro_budget, "con": args.con_budget},
124
+ }
125
+ both = {"model": args.model, "budget": args.budget}
126
+
127
+ # ADR-021 §4: no precedence rule, because any would discard something asked for.
128
+ for setting, shared in both.items():
129
+ if shared is None:
130
+ continue
131
+ clashing = [f"--{s}-{setting}" for s, v in one_side[setting].items() if v is not None]
132
+ if clashing:
133
+ raise _OverrideError(
134
+ f"--{setting} sets both sides and {' and '.join(clashing)} sets one; "
135
+ f"they conflict (ADR-021 §4). Drop one: --{setting} alone for a symmetric "
136
+ f"change, or --pro-{setting}/--con-{setting} alone for an asymmetric one"
137
+ )
138
+
139
+ changed: list[str] = []
140
+ sides: list[Side] = []
141
+ for side in config.sides:
142
+ values: dict[str, Any] = {}
143
+ for setting in ("model", "budget"):
144
+ picked = one_side[setting][side.side]
145
+ flag = f"--{side.side}-{setting}" if picked is not None else f"--{setting}"
146
+ new = picked if picked is not None else both[setting]
147
+ if new is None:
148
+ continue
149
+ _validate_override(setting, new, flag)
150
+ was = getattr(side, setting)
151
+ if new == was:
152
+ continue
153
+ values[setting] = new
154
+ changed.append(
155
+ f"{flag}: side {side.index} ({side.side}) {setting} {was!r} -> {new!r}"
156
+ )
157
+ sides.append(replace(side, **values) if values else side)
158
+
159
+ return replace(config, sides=(sides[0], sides[1])), tuple(changed)
160
+
161
+
162
+ def _validate_override(setting: str, value: Any, flag: str) -> None:
163
+ """ADR-021 §5: a flag faces the check its file key does, named for the flag."""
164
+ if setting == "budget" and value < 1:
165
+ raise _OverrideError(f"{flag} must be an integer of at least 1, got {value}")
166
+ if setting == "model" and not value.strip():
167
+ raise _OverrideError(f"{flag} must be a non-empty model name")
168
+
169
+
170
+ def make_log_event(labels: tuple[str, ...]) -> Listener:
171
+ """The CLI's own view of a run, through the same seam a dashboard would use.
172
+
173
+ ``labels`` is each side's ``pro``/``con`` from run.yaml (ADR-007 §7). A bare
174
+ index makes the reader hold the mapping in their head for the whole run, so a
175
+ turn names its side the way ``judge`` already does: ``side 0 (pro)``.
176
+ """
177
+
178
+ def log_event(event: DebateEvent) -> None:
179
+ if event.type is EventType.PHASE_STARTED:
180
+ _log(f"phase {event.phase_index}: {event.phase}")
181
+ elif event.type is EventType.TURN_COMPLETED and event.turn is not None:
182
+ turn = event.turn
183
+ # B2 only reports a reply that reached its budget; ADR-010 keeps it a valid turn.
184
+ capped = " (hit budget)" if turn.hit_budget else ""
185
+ _log(
186
+ f" side {turn.side_index} ({labels[turn.side_index]}), "
187
+ f"spoke {'first' if turn.order == 0 else 'second'}: "
188
+ f"{turn.usage.completion_tokens} of {turn.budget} completion tokens{capped}, "
189
+ f"{turn.usage.prompt_tokens} prompt tokens, {turn.latency_ms} ms"
190
+ )
191
+ for line in turn.text.strip().splitlines():
192
+ _log(f" | {line}")
193
+
194
+ return log_event
195
+
196
+
197
+ def _log(message: str) -> None:
198
+ print(f"debate: {message}", file=sys.stderr)