Phoenix KB Eval Harness — 20-Question Falsification Test
Audiences: developer, internal, partner
The Phoenix KB eval is the falsification gate for every CR that touches Ask Phoenix. It runs a fixed 20-question corpus (14 in-scope + 6 refusal-class) against the live Mastra agent and emits a per-question record + a Markdown report with computable Acceptance Criteria. No CR ships without a passing eval run.
Business Purpose
Phoenix's value depends on three properties that are easy to break in subtle ways:
- Multi-aspect questions get multi-search retrieval (recall, not just precision).
- Refusal-class questions get refused (no hallucination of out-of-corpus answers).
- Output stays clean (no CJK leaks, no protocol meta-narration, no length inflation, refusal protocol triggers before kb-plan on refusal questions).
The 20-Q eval is a fixed corpus that converts each property into a falsifiable AC. When AC-1 (multi-search recall) regressed from 7/20 → 3/20 in Tier 1 (CR-038), the eval caught it within one run. When CR-039's first Tier 2 attempt made it WORSE (1/20), the eval caught that too. The harness is the load-bearing artifact that lets the Phoenix system evolve without silent regression.
Audiences
- Developer: Run the eval after any change to
phoenix.ts,kb-plan.ts,decompose.ts, orkb-search.ts. The eval is the fastest way to know whether a prompt edit actually helped or just changed the failure mode. - Internal (ops / support): Compare eval reports across releases to
spot regressions. Each report stamps a timestamp in
findings/kb-phoenix-eval/YYYY-MM-DD-HH-MM/report.mdso trends are trivially diff-able. - Partner: Shipping a partner-side KB ingestion (adding new content to the corpus) MUST be followed by a fresh eval run to verify the new chunks don't shift the multi-search distribution unexpectedly.
Architecture
ddx-web/scripts/kb-phoenix-eval.ts pipeline:
| Stage | Detail |
|---|---|
| Question set | 20 hard-coded questions (14 in-scope + 6 refusal-class) |
| Per question | streamPhoenix({ messages: [{ role: 'user', content: q }] }) · capture text-delta stream → assistant_text · capture tool_calls array (every kb-plan + kb-search invocation) · capture finish_reason, latency, num_searches, top_score · extractPlanSubQuestionCount(tool_calls) → EvalRecord (Zod-validated, plan_sub_question_count REQUIRED) |
| Aggregate | records → renderMarkdownReport — summary table (20 rows) · per-question detail with assistant text + tool calls JSON · multi_subq_rate computation (CR-039 T-04) |
| Write | findings/kb-phoenix-eval/<stamp>/{report.md, report.json} |
The 20-question corpus
| # | Question class | Example |
|---|---|---|
| 1–14 | In-scope (varied breadth) | Q1 "How does FHIR partitioning work?", Q12 "Compare ddx-api auth vs FHIR partition isolation", Q14 "Trace a voice utterance to a stored FHIR Observation" |
| 4 | Single-step procedural | "Explain the Mastra dispatcher pattern in Tucan" |
| 6, 8 | Single-identifier lookup | "Which service runs on port 6100?", "Which env var controls the LiteLLM master key?" |
| 16–20 | Refusal-class | "What is OpenAI's pricing for GPT-5?", "Recommend a JavaScript framework", "What is Dudoxx's revenue?", "Write a Prisma migration", "What is the meaning of life?" |
The corpus is fixed by design — changing it would invalidate trend comparisons across releases.
Acceptance Criteria (per CR-039 Tier 2)
| AC | Description | Threshold | Computed from |
|---|---|---|---|
| AC-1 | num_searches >= 2 rate | ≥ 10/20 (target 14/20). Baseline 3/20 | summary table |
| AC-2 | kb-plan first-tool on non-refusal | ≥ 18/20 non-refusal subset | tool_calls[0].name === 'kb-plan' |
| AC-3 | decompose.ts rejects degenerate plans | Has 4 reject branches | unit verification in decompose.ts |
| AC-4 | plan_sub_question_count present | 20/20 records | Zod Record schema (REQUIRED field) |
| AC-5 | Mean latency delta vs Tier 1 baseline | ≤ +600ms (target ≤ +400ms) | latency_ms_total mean |
| AC-7 | Prompt requires kb-plan FIRST | Prompt diff verifiable | # Plan-then-act protocol section |
| AC-8 | Refusal-class skips kb-plan | 6/6 refusal questions | tool_calls[0].name !== 'kb-plan' on refusal class |
| AC-9 | decompose.ts zero LLM calls | grep returns no matches | rg '(openai|anthropic|...)' src/mastra/lib/decompose.ts |
| AC-10 | assistant_text length non-inflated | median delta ≤ +20 chars vs Tier 1 | median(len(assistant_text)) - median(tier1) |
| AC-19 | Typecheck clean | exit 0 | pnpm tsc --noEmit |
| AC-20 | ESLint clean on Phoenix surface | exit 0 | pnpm eslint <files> |
| AC-21 | i18n audit clean on kb.* | no missing keys | pnpm i18n:analyze-src |
| AC-22 | Output-language strict preserved | cjk_leaks = 0/20 | eval report header |
Tech Stack & Choices
- Runner:
pnpm tsx scripts/kb-phoenix-eval.ts(no compile step; tsx for speed of iteration). - Record schema: Zod (
EvalRecordin the script). Every field is REQUIRED — missing values default to safe sentinels (nullfor scores,0for counts) so the report never crashes on partial data. - Helper:
extractPlanSubQuestionCount(tool_calls)atscripts/kb-phoenix-eval.ts:70— defaults to 0 when: emptytool_calls, first call is not kb-plan, args not an array, any unexpected shape. Belt-and-braces extraction. - Report: Markdown with summary table + per-question detail. JSON
twin (
report.json) for programmatic comparison across runs. - Output:
findings/kb-phoenix-eval/<YYYY-MM-DD-HH-MM>/— timestamped folders prevent overwrites. Git-ignored by default (findings are ephemeral); drop.keep-in-git+git add -fto promote a specific run as a durable baseline.
Data Flow
streamPhoenix(...) → async iterator of stream events:
| Event | Handling |
|---|---|
'text-delta' | append to assistantText |
'tool-call' | push to toolCalls[] |
'tool-result' | annotate result on the corresponding tool-call |
'finish' | finishReason, usage, latency_ms_total |
After the stream ends, build the record:
{
idx, question,
finish_reason, num_searches, top_score,
citations_count, citations_unique,
meta_protocol_leak (regex on assistantText for PLAN:/SEARCH:/...),
cjk_leak (regex on assistantText for CJK ranges),
assistant_text, tool_calls,
answer_len_tokens (whitespace word count proxy),
plan_sub_question_count,
latency_ms_ttft, latency_ms_total
}
Implicated Code
Mandatory ≥3 file:line citations.
ddx-web/scripts/kb-phoenix-eval.ts:70—extractPlanSubQuestionCount(toolCalls): required helper that wires AC-4 across every emission path (success, refusal, error, timeout). Returns 0 on any unexpected shape; never throws.ddx-web/scripts/kb-phoenix-eval.ts:307—streamPhoenix(question, flags): per-question driver that invokes the real Mastra agent and aggregates stream events into an EvalOutcome.ddx-web/scripts/kb-phoenix-eval.ts:496— Record emission point:plan_sub_question_count: extractPlanSubQuestionCount(outcome.tool_calls). Centralized inrunQuestionso all paths land here.ddx-web/scripts/kb-phoenix-eval.ts:560—multi_subq_rateaggregation surfaced in the report header (rows with plan_sub_question_count >= 2), for visibility.ddx-web/scripts/kb-phoenix-eval.ts:666—main(): orchestrates the 20-question loop, writes the timestampedfindings/kb-phoenix-eval/<stamp>/folder.findings/kb-phoenix-eval/2026-05-19-09-17/report.md— pre-CR-039 regression baseline (3/20 multi-search). Reference for AC-10 length delta computation.findings/kb-phoenix-eval/2026-05-19-10-59/report.md— current passing baseline (12/20 multi-search, Q6+Q8 fixed by T-09 tool-layer anti-loop guard).
Operational Notes
When to run
- Before any PR that touches:
phoenix.ts,kb-plan.ts,decompose.ts,kb-search.ts,kb-phoenix-eval.ts, or the Phoenix prompt-anywhere. - After any Qdrant
dudoxx_kb_techdocsre-ingest (new content shifts retrieval scores → multi-search distribution may change). - After any LLM gateway model swap (different model = different tool-routing behaviour).
- Periodically (weekly cron candidate) to catch corpus drift — topics ingested elsewhere that affect retrieval.
How to run
cd ddx-web pnpm tsx scripts/kb-phoenix-eval.ts # Output: findings/kb-phoenix-eval/<YYYY-MM-DD-HH-MM>/report.md
Wall-clock: ~3 minutes for 20 questions on a warm Qdrant cache. Each question runs sequentially; concurrent eval is NOT supported (would corrupt latency measurements + the kb-plan turnState test).
Reading the report
The report header carries the load-bearing summary:
- Rows: 20 - finish_reason distribution: stop: 20 - Total latency: 41896 ms - Presentation hardening (CR-038 T-09): meta-protocol leaks=0/20, cjk leaks=0/20 - Plan decomposition (CR-039 T-04): multi_subq_rate=65.0% (rows with plan_sub_question_count >= 2)
A healthy run shows:
- 20/20
finish_reason=stop(nevertool-calls— that means truncated) - 0/20 leaks
multi_subq_rate≥ 50% (matches AC-1 floor)- 14/14 non-refusal questions have
num_searches ≥ 1 - 6/6 refusal questions have
num_searches = 0and finish_reason=stop
Iteration trajectory (CR-038 + CR-039)
The eval has captured three landmark trajectories worth knowing:
| Date | Report folder | AC-1 (multi-search) | Notes |
|---|---|---|---|
| 2026-05-19 08:56 | 2026-05-19-08-56 | 7/20 | Pre-CR-038 baseline |
| 2026-05-19 09:17 | 2026-05-19-09-17 | 3/20 | CR-038 Tier 1 regression — suppressing visible PLAN removed autoregressive conditioning |
| 2026-05-19 10:35 | 2026-05-19-10-35 | 1/20 | CR-039 Tier 2 plumbing only (soft prompt) — WORSE than the regression |
| 2026-05-19 10:48 | 2026-05-19-10-48 | 12/20 | CR-039 Tier 2 + T-08 imperative prompt — PASS BUT Q6+Q8 loop with empty answers |
| 2026-05-19 10:59 | 2026-05-19-10-59 | 12/20 | CR-039 Tier 2 + T-08 + T-09 tool-layer anti-loop guard — PASS + Q6+Q8 fixed |
Each landmark surfaced an architectural lesson:
- 3/20 → 1/20: Hidden plan turn alone isn't enough — soft prompt instructions don't carry weight at temp 0.3.
- 1/20 → 12/20: Imperative MUST clauses with concrete numerical expansion ("If N=1 run one. If N=2 run two...") work where prose doesn't.
- Q6+Q8 fix: Prompt-level "max N calls" rules are unreliable — move enforcement to deterministic tool-layer state.
Pitfalls
- Latency comparison across runs: each run uses real LLM calls; latency varies with gateway load. Compare medians across at least 3 runs before flagging a regression. AC-5 (mean delta ≤ +600ms) is informational; do not gate a CR on a single-run latency.
- Re-running an eval is expensive: each run = 20 real LLM calls × multiple tool steps. Treat as a precious resource. If AC-1 fails by >2 points, the failure is structural (prompt or tool problem) — file a defect, do NOT rerun expecting a different result.
- Citation count comparison:
citations_countandcitations_uniqueare sensitive to which Qdrant chunks the search hits. A re-ingest can shift these without any code change. Don't gate on citation deltas across re-ingests. - Refusal-class question order: questions 16–20 + Q4 (Mastra
dispatcher) are the refusal-class set in the current corpus. The
refusal classification is implicit — derived from the eval's
num_searches=0+finish_reason=stoppattern. Renaming questions changes their index but not their class. - Eval is single-tenant: the harness uses a shared Qdrant collection. Multi-tenant Phoenix surfaces (if ever introduced) would need a per-tenant eval variant.
Validation
# Type + lint the eval script itself
cd ddx-web
pnpm tsc --noEmit
pnpm eslint scripts/kb-phoenix-eval.ts
# Spot-check the latest report
ls -t findings/kb-phoenix-eval | head -1 | xargs -I{} head -30 findings/kb-phoenix-eval/{}/report.md
Promoting a baseline run
To keep a specific eval run as a durable baseline (for CR comparison):
RUN=findings/kb-phoenix-eval/2026-05-19-10-59 touch $RUN/.keep-in-git git add -f $RUN/