API Design¶
This page explains why the public API is shaped the way it is. It is aimed at contributors and at anyone extending the framework beyond what the TOML config exposes. For the exact signatures, follow the links into the API Reference.
The pipeline contract¶
Everything in APE is organized around one fixed pipeline:
The framework owns the orchestration of this pipeline; the benchmark author owns three typed contracts on it:
| Contract | Type | Responsibility |
|---|---|---|
DataModel subclass |
input schema | describe one problem instance |
SolutionAdapter |
Callable[[str], SolutionT] |
parse raw model text into a structured solution |
Checker |
check(data, solution) -> CheckResult |
decide pass/fail deterministically |
Nothing else is mandatory. The model backend, tools, concurrency, persistence, and the dashboard are all configuration on top of these three contracts.
Generics carry the types end to end¶
The two type variables DataT and SolutionT thread through the entire stack.
A Solver[DataT, SolutionT] produces a SolutionT, the
SolutionAdapter[SolutionT] constructs it, and the Checker[DataT, SolutionT]
consumes both — so the data schema and the solution type are checked for
consistency across the pipeline rather than degrading to Any at the seams.
DataProvider[DataT] ─► Solver[DataT, SolutionT] ─► SolutionAdapter[SolutionT]
│
Checker[DataT, SolutionT] ◄─────┘
The code uses PEP 695 syntax throughout (class Solver[DataT: DataModel,
SolutionT]:), which is why Python 3.12 is the floor.
Two return-value contracts¶
The pipeline has exactly two places where a stage reports an outcome, and each has a small, closed result type rather than exceptions or sentinel values:
- The solver returns a
Result[SolutionT]— a closed union defined inape/llm/utils.pyastype Result[T] = Ok[T] | Err, whereOk[T]is a frozen dataclass wrapping the parsed solution (val: T) andErris a frozen dataclass wrapping anErrKindenum (GAVE_UP,PARSING_FAILED,INTERNAL_ERROR). This lets the orchestrator pattern-match on the outcome and decide whether checking even makes sense. - The checker returns a
CheckResult—PassedorFailed(reason), each optionally carrying aruntime(Optional[timedelta], not a raw number — pick that up in any customReport) and a free-formmetadata: dict[str, Any].Failed.reasonis a string because it becomes a class label in the visualizer's breakdown; distinct reasons become distinct bars.
Because both are closed unions, downstream code (saving, visualizing, the
dashboard) handles every case explicitly. A solver Err means no CheckResult
is produced at all, which the OutputBatch records as None.
Configuration validates up front¶
SolverConfig and RunParams are frozen dataclasses, and SolverConfig
does all its validation in __post_init__ (non-empty prompts, a callable
adapter, a compaction threshold in (0, 1], a positive step limit, a non-empty
backoff schedule, …). The orchestrator builds one SolverConfig and constructs
a fresh Solver per worker from it via Solver.from_config.
The design goal is fail fast, fail before the dashboard: a misconfigured run
should raise during construction, not twenty minutes into a long evaluation. The
config-driven shell (ape.run) reinforces this by building and
validating every component — data provider, checker, solver config, run
params, hooks — before the CLI ever takes over the terminal.
Hooks are validated like everything else at startup (they must resolve to a
callable), but their runtime errors follow the opposite policy: exceptions
raised by on_batch_saved are logged and swallowed. The reasoning is
that a hook is a side-channel (mirror to an external store, push a metric);
letting a flaky external service abort the run — or worse, lose an already-saved
batch — would invert the priority. Hooks should not be load-bearing.
Components are injected, never imported by the core¶
The framework core contains no problem-specific imports. Instead, the TOML shell
resolves components by dotted path at runtime through _import_dotted_path,
raising ConfigError with an actionable message when a path is wrong:
[data]
schema = "problems.cubes.CubeProblem"
[adapter]
callable = "problems.cubes.parse_solution"
[checker]
callable = "problems.cubes.CubeChecker"
This keeps the dependency arrow pointing one way — your benchmark depends on
ape, never the reverse — and means a new benchmark is a new package plus a
TOML file, with no edits to the framework.
Factories, not instances¶
Two extension points are passed as factories rather than ready-made objects, because the runner needs to create one instance per worker thread:
CheckerFactory=Callable[[], Checker]. TheCheckerOrchestratorcalls it once per checker worker and keeps the instances in aQueue"carousel", checking one out per request and returning it afterward. A factory (not a shared instance) lets each worker own non-thread-safe state — a database connection, a subprocess, a cache.- Tool factories, normalized by
_instantiate_tools. A tool entry'scallablemay return a singleBaseTool, a list of them, or aToolProvider— a tinyProtocolwithget_tool(). The protocol exists so tools that need configuration or state (a connection pool, a server address) can take it through their constructor and still present the uniform "give me the tool(s)" interface.
The checker has two modes — verdict, and optionally an in-loop tool¶
By default the Checker is the deterministic
verifier at the end of the pipeline: the solver produces a solution, the
orchestrator hands it (with the data item) to the CheckerOrchestrator, and
the resulting CheckResult lands in the OutputBatch. The benchmark author
implements one check(data, solution) -> CheckResult method and the framework
takes care of the rest.
The same Checker can additionally be exposed to the LLM as a tool during
the solve, by setting in TOML:
[checker]
callable = "myproj.MyChecker"
expose_as_tool = true
checker_tool = "myproj.make_checker_tool" # required iff expose_as_tool
The checker_tool factory wraps the checker into a BaseTool that the LLM
can call mid-solve to test candidate answers before committing to a final one.
Two consequences worth knowing:
- The "verdict at the end" path runs regardless of whether the checker was
also exposed as a tool — the saved
CheckResultis always the final invocation, not a mid-solve one. - The two surfaces share a
Checkerinstance per worker (the carousel), so a stateful checker (e.g. one holding a database handle) sees both kinds of calls and must remain thread-safe with respect to itself.
This is why the design separates "the Checker contract" (the typed verifier)
from "exposing the checker" (a config-level decision): the contract stays the
same in either mode, only the call sites differ.
OutputBatch is the unit of work and of persistence¶
Because runs_per_data can be greater than one, the natural unit is not a single
attempt but all attempts for one data item: an
OutputBatch. It holds the input DataModel plus parallel
lists of solver results and (optional) check results, and it knows how to
pickle() / from_pickle() itself via jsonpickle. The runner produces one
batch per data item, the SaveHandler writes one JSONL line per batch, and the
visualizer reads them back. Keeping the batch as the atomic unit is what makes
"resume" and "combine sharded runs" well-defined operations.
The identifier system¶
Every DataModel designates exactly one field as its identifier. The default is
a field literally named id; any other field can be promoted with
id_field(), a drop-in for pydantic.Field() that tags the
field via metadata. __pydantic_init_subclass__ enforces "exactly one
identifier" at class-definition time.
Two class attributes fall out of this and matter elsewhere:
id_field_name— the Python attribute name, used to readdata.data_id.id_key— the serialized key (the field's alias if it has one), used when scanning the raw input file inDataProvider.count()without parsing every record.
The identifier is the join key for the whole framework: it labels dashboard
rows, names sessions (s{solver}-d{data}-r{run}), and is the resume key the
save handler tracks.
Telemetry is an observer, not a dependency¶
The runner reports to the outside world through a single
RunObserver protocol with two methods: on_progress and
on_worker_status. This inverts the dependency — the runner doesn't know about
the rich dashboard; it knows about an interface the CLI
happens to implement. Two consequences:
- A headless run (or a test) passes no observer and gets a
NullObserver, which drops every event. The runner's code path is identical either way, so "no UI" is never a special case. - Worker status is batched through a background pump thread
(
WorkerStatusTracker) so high-frequency status updates from many workers never block solving, and a misbehaving observer is swapped for aNullObserverrather than being allowed to crash the run.
Concurrency model¶
A run is multi-threaded, with two thread pools fed by queues:
- The
SolverOrchestratorowns a pool ofsolver_workersthreads. Each pulls data items off an input queue, runsruns_per_datasessions, and pushes a finishedOutputBatchonto an output queue. The main thread drains that queue to save results. - The
CheckerOrchestratorowns a pool ofchecker_workersand the checker carousel described above. A solver worker calls into it synchronously per solution. Implementation note: the call isexecutor.submit(...).result(timeout=...)in a loop that re-checksself._cancel.is_set()between polls, so a longcheck()cannot delay shutdown — the same cooperative-cancellation pattern used everywhere else.
Shutdown and interruption are cooperative. Each orchestrator owns its own
threading.Event (self._cancel), and Runner.run calls stop() on both in
its finally block, which sets the events and shuts down the underlying
ThreadPoolExecutor. The
solver orchestrator propagates its event to each Session (session.cancel),
so solver workers check session.cancelled() between steps and raise
InterruptedError to unwind cleanly. Sentinels (None) on the data queue wake
idle workers so the pools can drain on stop. The orchestrators also re-raise any
worker exception on the main thread (_check_futures) instead of letting it
vanish inside a future.
Persistence and resume¶
The SaveHandler is built for crash safety. Each batch
is appended to the JSONL results file and the run metadata is rewritten, both
flushed with os.fsync. Metadata carries a save_format_version; loading a save
written by a newer format version is refused rather than silently mis-parsed.
Resume is the natural consequence of streaming results plus tracking completed
ids: on startup with load_save enabled (the default), the handler returns the
set of completed ids, and the runner skips any incoming data item already in it.
Note the deliberate split of responsibilities — range selection (start_id /
end_id on the DataProvider) decides which items exist for
this run, while resume decides which of those to skip because they're
already done. They compose without interfering.
Where to extend¶
| You want to… | Implement / configure |
|---|---|
| Support a new problem type | a DataModel, a SolutionAdapter, a Checker |
| Give the model a new capability | a tool factory / ToolProvider, referenced from [tools.X] |
| Expose the checker to the LLM during the solve | [checker] expose_as_tool = true plus a checker_tool factory |
| Use a different model backend | any LangChain BaseChatModel, via [model] |
| Consume run telemetry differently | a RunObserver implementation |
| React to each saved batch | the on_batch_saved hook (exceptions are logged and swallowed, so a faulty hook can never stop the run) |
| Export results in a custom format | a Report subclass |
The solve loop itself — the part that turns a prompt into a parsable answer — is the one piece with enough internal structure to warrant its own page; see the LLM Backend Design.