--- name: prompt-regression description: > Use when the user has changed a prompt (system prompt, RAG template, agent instruction, etc.) and wants to know whether the candidate is better or worse than the baseline. Also use when the user mentions prompt A/B testing, prompt comparison, prompt optimization validation, "did my prompt change help," or prompt regression testing. Outputs per-dimension win rates with statistical significance using OpenJudge PairwiseAnalyzer. --- NO conclusion about which prompt is better WITHOUT bootstrap 95% CI reported. NO candidate declared "better" WITHOUT position-debiased (swap-aggregate) comparison. NO comparison with fewer than 10 samples per axis — CI is too wide to be meaningful. # Prompt Regression Compare two prompts head-to-head and determine, with statistical rigor, whether the candidate is better, worse, or tied on each evaluation dimension. ## When to Activate - You changed the system prompt and want to verify it's actually better - You're iterating on RAG answer templates - You're optimizing agent step-by-step instructions - You want data to support a prompt change decision ## Checklist You MUST create a task for each item and complete them in order: 1. **Load and analyze prompts** — diff the baseline vs candidate 2. **Derive comparison dimensions** — from the prompt changes + task type 3. **Select graders per dimension** — pairwise, judge, or rule 4. **Run position-debiased comparison** — swap-aggregate to eliminate order bias 5. **Compute statistics** — win rates + bootstrap 95% CI per dimension 6. **Present results** — per-dimension verdict with confidence intervals ## Fast path: run the bundled script Don't hand-write the win-rate + bootstrap math (the swap-aggregation and CI are easy to get wrong). Run the bundled, tested script (`scripts/pairwise.py`, standard library only, **no OpenJudge dependency**): ```bash python scripts/pairwise.py --comparisons comparisons.jsonl --candidate candidate --baseline baseline ``` Each comparison row: `{"id","model_a","model_b","score","dimension"?}` where `score >= 0.5` means `model_a` won. Emit two rows per query with A/B **swapped** to debias position. The script reports per-dimension candidate/baseline/tie rates, bootstrap 95% CI, and a verdict (`BETTER` / `WORSE` / `TIED` / `INSUFFICIENT_EVIDENCE` / `INCONCLUSIVE`; exit 0 only if better). `--self-test` to verify it. Steps below explain how to derive dimensions and produce the comparisons (with OpenJudge or any judge); the inline snippets are the reference behind the script. ## Step 1: Load and Analyze Prompts Read the baseline and candidate prompts. Identify: - **Task type**: chatbot / RAG generation / code review / translation / summarization / agent instruction / other - **What changed**: added constraints, changed tone, new examples, different output format, expanded/shortened instructions - **Intent of change**: what problem was the user trying to fix? ## Step 2: Derive Comparison Dimensions Based on the task type and what changed, derive 3-5 comparison dimensions. ### Dimension templates by task type **Chatbot / Conversational**: - Answer relevance — does it address the user's question? - Tone appropriateness — does the tone match context? - Factual accuracy — no fabricated information - Conciseness — doesn't ramble or over-explain - Instruction following — obeys system prompt constraints **RAG Generation**: - Faithfulness — grounded in retrieved documents - Citation accuracy — correctly references sources - Completeness — covers all aspects of the query - No hallucination — no claims beyond documents **Code Review / Generation**: - Bug detection — finds real issues - False positive rate — doesn't flag correct code - Actionability — suggestions are specific and implementable - Code style — follows conventions **Agent Instructions**: - Tool selection — picks the right tool - Step efficiency — minimal steps to goal - Error recovery — handles failures gracefully - Output format — follows specified structure Each dimension gets: - An `id` (slug) - A one-sentence description - A grader type: `pairwise` or `judge` or `rule` ## Step 3: Select Graders Decision priority: 1. **Can a rule check this?** → `FunctionGrader` or `StringMatchGrader`. Free, deterministic. Example: output length, keyword presence, JSON validity. 2. **Is there a reference answer?** → `pairwise` against reference. 3. **Subjective quality, no reference?** → `pairwise` A/B comparison. 4. **Single-output judgment needed?** → `judge` (binary pass/fail per output). ## Step 4: Run Position-Debiased Comparison ### Pairwise comparison with swap-aggregate LLM judges have position bias — the first response shown wins 5-15% more often. Swap-aggregate eliminates this: run each comparison twice with swapped positions, keep only consistent wins: ```python from openjudge.graders.llm_grader import LLMGrader from openjudge.graders.schema import GraderMode from openjudge.runner.grading_runner import GradingRunner from openjudge.analyzer.pairwise_analyzer import PairwiseAnalyzer # Judge prompt for relevance comparison relevance_judge = LLMGrader( model=model, name="relevance_compare", mode=GraderMode.POINTWISE, template=""" Compare Response A and Response B for the query below. Which response better addresses the user's question? Query: {query} Response A: {response_a} Response B: {response_b} Score 1.0 if A is better, 0.0 if B is better, 0.5 if tied. Respond in JSON: {{"score": , "reason": ""}} """, ) # Build pairwise dataset with position swap dataset = [] for sample in test_samples: # Original order dataset.append({ "query": sample["query"], "response_a": baseline_outputs[sample["id"]], "response_b": candidate_outputs[sample["id"]], "metadata": {"model_a": "baseline", "model_b": "candidate"}, }) # Swapped order — critical for debiasing dataset.append({ "query": sample["query"], "response_a": candidate_outputs[sample["id"]], "response_b": baseline_outputs[sample["id"]], "metadata": {"model_a": "candidate", "model_b": "baseline"}, }) runner = GradingRunner( grader_configs={"relevance": relevance_judge}, max_concurrency=8, ) results = await runner.arun(dataset) # Analyze with PairwiseAnalyzer analyzer = PairwiseAnalyzer(model_names=["baseline", "candidate"]) analysis = analyzer.analyze(dataset, results["relevance"]) print(f"Win rates: {analysis.win_rates}") # → {'baseline': 0.35, 'candidate': 0.55} → candidate wins 55% of comparisons print(f"Best model: {analysis.best_model}") ``` Why swap-aggregate? Without it, if the judge prefers the first response shown, and you always show baseline first, you'll systematically underrate the candidate. ## Step 5: Compute Statistics For each dimension, report: - Candidate win rate, baseline win rate, tie rate - Bootstrap 95% confidence interval - Verdict: better / worse / tied / inconclusive `PairwiseAnalyzer.analyze` interprets each comparison as `score >= 0.5 → model_a wins`, using the row's `metadata.model_a` / `metadata.model_b`. So derive a per-comparison winner list from `dataset` + `results`, then bootstrap over that list — never index the `PairwiseAnalysisResult` object (it has no per-sample rows). ```python import numpy as np from openjudge.graders.schema import GraderScore def per_comparison_winners(dataset, grader_results): """One named winner per comparison row (handles swapped order via metadata).""" winners = [] for sample, result in zip(dataset, grader_results): if not isinstance(result, GraderScore): continue # skip errors meta = sample.get("metadata", {}) winners.append(meta["model_a"] if result.score >= 0.5 else meta["model_b"]) return winners def bootstrap_win_rate(winners, target, n_iter=1000): n = len(winners) rates = [] for _ in range(n_iter): idx = np.random.choice(n, n, replace=True) rates.append(sum(1 for i in idx if winners[i] == target) / n) return float(np.percentile(rates, 2.5)), float(np.percentile(rates, 97.5)) winners = per_comparison_winners(dataset, results["relevance"]) n = len(winners) candidate_rate = sum(1 for w in winners if w == "candidate") / n baseline_rate = sum(1 for w in winners if w == "baseline") / n ci_low, ci_high = bootstrap_win_rate(winners, target="candidate") if ci_low > 0.5: verdict = "candidate BETTER" elif ci_high < 0.5: verdict = "candidate WORSE" elif (ci_high - ci_low) < 0.3: verdict = "TIED (CI brackets 0.5, narrow)" else: verdict = "INCONCLUSIVE (CI too wide — need more samples)" print({"candidate_win_rate": candidate_rate, "baseline_win_rate": baseline_rate, "ci_95": [ci_low, ci_high], "verdict": verdict}) ``` Note: with swap-aggregate each query produces 2 comparison rows. Bootstrapping over rows (above) is the simple approach; for a tighter estimate, bootstrap over *queries* and average the 2 swapped rows per query so position pairs stay together. ## Step 6: Present Results ``` Prompt Regression: v1 (baseline) vs v2 (candidate) Task: Customer support chatbot Samples: 50 Dimension Candidate Baseline Tie 95% CI Verdict =========================================================================== Answer relevance 58% 32% 10% [51%, 65%] ✓ BETTER Factual accuracy 48% 44% 8% [41%, 55%] = TIED Tone appropriateness 38% 52% 10% [31%, 45%] ✗ WORSE Conciseness 62% 28% 10% [55%, 69%] ✓ BETTER Summary: v2 is significantly better on relevance and conciseness, but worse on tone appropriateness. The tone regression likely comes from the new "be direct" instruction — consider softening it. Top 3 tone failures (candidate worse): 1. Query: "I'm really frustrated..." → v2 response too curt 2. Query: "This is my first time..." → v2 missing empathetic opening 3. Query: "Can you help me understand..." → v2 skipped explanation ``` ## Common Mistakes - **Not doing position swap.** Position bias in LLM judges is 5-15%. Without swap-aggregate, results are systematically skewed. - **Comparing with < 10 samples.** Bootstrap CI at n=10 is ±15%+ half-width. At n=5 it's ±25%+. Results are noise, not signal. Minimum 10, prefer 30+. - **Single "overall" comparison without dimensions.** "V2 is 55% better" hides that it's +20% on relevance but -15% on tone. Always report per-dimension. - **Accepting ties as "no difference."** A true tie and insufficient data look identical without CI. Always report confidence intervals. - **Not pinning model versions.** If baseline and candidate are run on different model versions (even same model, different date), model drift contaminates the prompt comparison. Same model, same version, same temperature. ## Next Skills After `06-prompt-regression`: - **`03-align-human`**: Calibrate the pairwise judge against human preferences. - **`02-metric-design`**: Turn validated dimensions into permanent graders. - **`04-eval-report`**: Include prompt comparison results in a comprehensive report.