Routing eval
Measures the orchestrator's top-level capability decision - the one that sends a turn to
pdf_edit, pdf_question, pdf_review, pdf_create, user_spec, or unsupported.
It exists because the reported failure ("asking a question and it runs a tool instead") is a routing failure, and prompt changes aimed at it were otherwise unmeasurable.
Running it
Needs a reachable Ollama with the model pulled. Nothing else - no engine server, no database.
uv run --group engine python evals/routing/runner.py
Useful flags:
| Flag | Purpose |
|---|---|
--strategies thinking_off,fewshot_thinking_off |
Run a subset. |
--repeats 5 |
Run each case N times; feeds the unstable_cases count. |
--concurrency 3 |
Parallel in-flight requests. |
--model qwen2.5:7b |
Compare models on the same cases. |
--limit 8 |
Smoke test. |
Then turn the results into the cost table:
uv run --group engine python evals/routing/cost.py
Outputs land in evals/routing/results/: summary.json (per-strategy scores),
observations.jsonl (one row per case per strategy, for re-scoring without re-spending),
and cost.json.
What is scored
dataset.py holds 82 labelled turns in four bands:
- clear - unambiguous either way.
- boundary - question and edit phrased almost identically ("Are there any blank pages?" vs "Remove the blank pages"). This is the reported failure mode.
- contextual - only resolvable from conversation history ("yes do that", "now rotate it").
- adversarial - a tool's name appears in a turn that is not asking for that tool ("Which section talks about the merger?").
Beyond plain accuracy the runner reports:
- destructive misroutes - a read-only turn sent to a mutating capability. Weighted separately because that direction changes the user's file; the reverse only answers.
- hard failures - truncated, unparsable, or off-menu model output.
- unstable cases - with
--repeats > 1, cases where the same input routed differently across runs.
tolerated on a case marks a genuinely defensible second answer, scored as a near-miss
rather than a failure, so the boundary cases do not punish reasonable disagreement.
Adding a strategy
Implement the Strategy protocol in strategies.py (a name, a description, and an
async route) and add it to build_strategies(). The prompts come from
stirling.agents.orchestrator, so the baseline tracks production rather than a copy of it.
Caveats
- Latency is measured under
--concurrency, so it reflects throughput, not the latency a single user sees. Re-run with--concurrency 1for user-facing latency. reasoning_effort: "none"is the only thinking switch Ollama honours on the OpenAI-compatible path for qwen3;chat_template_kwargs={"enable_thinking": false}and a/no_thinksuffix are both ignored.- The eval sends prompts directly rather than through pydantic-ai, so it does not exercise
pydantic-ai's output-validation retries. A
__truncated__result here is what production spends a retry on.