Step 1: scoring engine, improvement-prompt generator, API, 44 green tests
Browse files- Scorer adapter interface + LocalScorer (judge mode plugs in later)
- taxonomy: task type -> research-grounded parameters with default weights
- parameter evaluators with quoted evidence and honesty flags
- flagship generator: failure mode x task x model family, complete
paste-ready replacement prompts, no-generic-advice contract under test
- FastAPI app (/evaluate, /health, /warmup)
- curated planted-failure test pairs; classifier/constraint/regex fixes
found by the suite; question coverage reframed as honest topical proxy
after empirical probe showed bi-encoders cannot detect answerhood
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- CLAUDE.md +11 -3
- README.md +63 -0
- rubriq/api/__init__.py +0 -0
- rubriq/api/main.py +51 -0
- rubriq/classify.py +8 -3
- rubriq/constraints.py +1 -1
- rubriq/metrics/hallucination_risk.py +1 -1
- rubriq/prompts/__init__.py +0 -0
- rubriq/prompts/generator.py +454 -0
- rubriq/prompts/model_profiles.py +89 -0
- rubriq/scoring/__init__.py +4 -0
- rubriq/scoring/base.py +25 -0
- rubriq/scoring/local.py +72 -0
- rubriq/scoring/parameters.py +533 -0
- rubriq/scoring/taxonomy.py +82 -0
- tests/samples.py +122 -0
- tests/test_classifier.py +45 -0
- tests/test_constraints.py +70 -0
- tests/test_prompts.py +88 -0
- tests/test_scoring.py +135 -0
CLAUDE.md
CHANGED
|
@@ -74,6 +74,7 @@ model), and the entire improvement-prompt template library.
|
|
| 74 |
| No ROUGE/BLEU | n-gram metrics excluded everywhere | SummEval showed weak human correlation; embedding + NLI methods used instead. |
|
| 75 |
| No code execution in v1 | static analysis ladder for code tasks | Executing arbitrary pasted code is a safety/dependency problem; HumanEval-style pass@k logged as judge-mode-era future work. |
|
| 76 |
| No toxicity metric in v1 | deferred | Lexicon approaches false-positive heavily; a proper local classifier is future work. |
|
|
|
|
| 77 |
| Scoring adapter | `Scorer` interface with `LocalScorer` now; `LLMJudgeScorer` later (BYO-key free tier, our-key paid tier) | Required by brief. Interface lands in Step 1. |
|
| 78 |
| Frontend | Decided at Step 2 (leaning React/Next.js + Vercel) | Not needed before Checkpoint 2. |
|
| 79 |
| Document evaluation | Deferred decision until the text pipeline exists | Include only if nearly free per brief. |
|
|
@@ -122,9 +123,16 @@ Work autonomously between checkpoints; stop and wait at each one.
|
|
| 122 |
CLAUDE.md created, names proposed.
|
| 123 |
- 2026-07-05 — **Checkpoint 1 passed**: name = Rubriq. Folder rename deferred
|
| 124 |
by user (still `LLM Quality` on disk — harmless, git doesn't care). Repo
|
| 125 |
-
`devadit1515/rubriq` created and pushed.
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
## Future work log
|
| 130 |
|
|
|
|
| 74 |
| No ROUGE/BLEU | n-gram metrics excluded everywhere | SummEval showed weak human correlation; embedding + NLI methods used instead. |
|
| 75 |
| No code execution in v1 | static analysis ladder for code tasks | Executing arbitrary pasted code is a safety/dependency problem; HumanEval-style pass@k logged as judge-mode-era future work. |
|
| 76 |
| No toxicity metric in v1 | deferred | Lexicon approaches false-positive heavily; a proper local classifier is future work. |
|
| 77 |
+
| Question coverage is a topical proxy | flag a question part only when its topic/keywords are absent from the output | Empirically verified (2026-07-05) that bi-encoder similarity cannot distinguish "mentioned" from "answered" (unanswered part scored 0.65 vs answered 0.59). Labeled is_proxy, limitation documented in a dedicated test; true answerhood is judge-mode work. |
|
| 78 |
| Scoring adapter | `Scorer` interface with `LocalScorer` now; `LLMJudgeScorer` later (BYO-key free tier, our-key paid tier) | Required by brief. Interface lands in Step 1. |
|
| 79 |
| Frontend | Decided at Step 2 (leaning React/Next.js + Vercel) | Not needed before Checkpoint 2. |
|
| 80 |
| Document evaluation | Deferred decision until the text pipeline exists | Include only if nearly free per brief. |
|
|
|
|
| 123 |
CLAUDE.md created, names proposed.
|
| 124 |
- 2026-07-05 — **Checkpoint 1 passed**: name = Rubriq. Folder rename deferred
|
| 125 |
by user (still `LLM Quality` on disk — harmless, git doesn't care). Repo
|
| 126 |
+
`devadit1515/rubriq` created and pushed.
|
| 127 |
+
- 2026-07-05 — **Step 1 complete**: RESEARCH.md (all sources logged),
|
| 128 |
+
classifier (rules + bge prototypes + source split), IFEval-style constraint
|
| 129 |
+
engine, metric modules (SummaC-ZS faithfulness, relevance, readability,
|
| 130 |
+
lexical, hallucination signals, static code ladder), Scorer adapter +
|
| 131 |
+
LocalScorer, improvement-prompt generator (failure × task × model family),
|
| 132 |
+
FastAPI app, 44 tests green on curated planted-failure pairs. Demo pair
|
| 133 |
+
verified end-to-end: sabotaged summary scores 33/100 with all four planted
|
| 134 |
+
fabrications quoted. **Waiting at Checkpoint 2**: user reviews, sets up
|
| 135 |
+
Supabase, hands over credentials; Step 2 (frontend + deploy) after that.
|
| 136 |
|
| 137 |
## Future work log
|
| 138 |
|
README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Rubriq
|
| 2 |
+
|
| 3 |
+
Paste the prompt you gave an LLM and the output you got back. Rubriq scores
|
| 4 |
+
the output against evaluation parameters drawn from published research,
|
| 5 |
+
quotes the evidence for every finding, and generates ready-to-paste
|
| 6 |
+
replacement prompts that target the specific failures it found — adapted to
|
| 7 |
+
the model you name (GPT, Claude, Gemini, or open-weights).
|
| 8 |
+
|
| 9 |
+
Everything runs locally. No API calls, no keys, no data leaving your machine.
|
| 10 |
+
|
| 11 |
+
## How it works
|
| 12 |
+
|
| 13 |
+
1. **Classify** — rule signals plus embedding prototypes decide the task
|
| 14 |
+
type (summarization, code generation, extraction, Q&A, creative writing,
|
| 15 |
+
reasoning, rewriting) and split pasted source material from the
|
| 16 |
+
instruction.
|
| 17 |
+
2. **Score** — each task type gets the parameters the literature assigns it:
|
| 18 |
+
NLI-based faithfulness when source text exists (SummaC method),
|
| 19 |
+
deterministic instruction-following checks (IFEval method), embedding
|
| 20 |
+
relevance (RAGAS-style), readability against the stated audience,
|
| 21 |
+
hallucination-risk signals, static code analysis, style statistics.
|
| 22 |
+
3. **Report** — per-parameter verdicts with quotes from the output itself.
|
| 23 |
+
Parameters that local methods cannot judge say so instead of guessing:
|
| 24 |
+
open-world fact checking and subjective quality are labeled as
|
| 25 |
+
judge-mode territory, and proxies are labeled proxies.
|
| 26 |
+
4. **Repair** — the flagship. Every diagnosed failure generates a complete
|
| 27 |
+
replacement prompt using the named model family's own documented
|
| 28 |
+
mechanisms (XML tags and quote-first grounding for Claude, delimiters and
|
| 29 |
+
literal phrasing for GPT, few-shot and positive instructions for Gemini,
|
| 30 |
+
constraint repetition for open-weights models).
|
| 31 |
+
|
| 32 |
+
Every parameter and template traces to a named source — see
|
| 33 |
+
[RESEARCH.md](RESEARCH.md).
|
| 34 |
+
|
| 35 |
+
## Run it
|
| 36 |
+
|
| 37 |
+
```bash
|
| 38 |
+
python -m venv .venv
|
| 39 |
+
.venv/Scripts/pip install -e ".[dev]" # first run downloads two small models (~350 MB)
|
| 40 |
+
.venv/Scripts/uvicorn rubriq.api.main:app --reload
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
`POST /evaluate` with `{"prompt": ..., "output": ..., "model_name": "gpt-4o"}`
|
| 44 |
+
returns the full report. `GET /health` shows engine and model status.
|
| 45 |
+
|
| 46 |
+
Models: `BAAI/bge-small-en-v1.5` (embeddings) and
|
| 47 |
+
`cross-encoder/nli-deberta-v3-small` (entailment). Both run CPU-only.
|
| 48 |
+
|
| 49 |
+
## Tests
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
.venv/Scripts/python -m pytest tests -q
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
The suite includes curated prompt/output pairs with known planted failures
|
| 56 |
+
(invented facts, broken code, fabricated citations, ignored constraints) and
|
| 57 |
+
asserts Rubriq finds each one — plus a documented-limitation test that keeps
|
| 58 |
+
the honest boundary of the coverage proxy visible.
|
| 59 |
+
|
| 60 |
+
## Status
|
| 61 |
+
|
| 62 |
+
Step 1 (evaluation engine) complete. Frontend and deployment are Step 2.
|
| 63 |
+
An LLM-as-judge engine plugs in behind the same `Scorer` interface later.
|
rubriq/api/__init__.py
ADDED
|
File without changes
|
rubriq/api/main.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI surface. One evaluation endpoint; the engine behind it is chosen
|
| 2 |
+
by the Scorer adapter, so judge mode later is a query param, not a rewrite.
|
| 3 |
+
|
| 4 |
+
Run: uvicorn rubriq.api.main:app --reload
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI, HTTPException
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
|
| 12 |
+
from .. import models
|
| 13 |
+
from ..schemas import EvalReport, EvalRequest
|
| 14 |
+
from ..scoring import LocalScorer, Scorer
|
| 15 |
+
|
| 16 |
+
app = FastAPI(
|
| 17 |
+
title="Rubriq",
|
| 18 |
+
version="0.1.0",
|
| 19 |
+
description="Local, research-grounded LLM output quality evaluation.",
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
app.add_middleware(
|
| 23 |
+
CORSMiddleware,
|
| 24 |
+
allow_origins=["*"], # tightened at deployment (Step 2)
|
| 25 |
+
allow_methods=["*"],
|
| 26 |
+
allow_headers=["*"],
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
_SCORERS: dict[str, Scorer] = {"local": LocalScorer()}
|
| 30 |
+
# LLMJudgeScorer registers here later: {"judge-byok": ..., "judge-managed": ...}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@app.post("/evaluate", response_model=EvalReport)
|
| 34 |
+
def evaluate(request: EvalRequest, engine: str = "local") -> EvalReport:
|
| 35 |
+
scorer = _SCORERS.get(engine)
|
| 36 |
+
if scorer is None:
|
| 37 |
+
raise HTTPException(status_code=400, detail=f"unknown engine '{engine}'; available: {list(_SCORERS)}")
|
| 38 |
+
if not request.prompt.strip() or not request.output.strip():
|
| 39 |
+
raise HTTPException(status_code=422, detail="both prompt and output are required")
|
| 40 |
+
return scorer.evaluate(request)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@app.get("/health")
|
| 44 |
+
def health() -> dict:
|
| 45 |
+
return {"status": "ok", "engines": list(_SCORERS), "models": models.availability()}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@app.post("/warmup")
|
| 49 |
+
def warmup() -> dict:
|
| 50 |
+
"""Load both local models so the first evaluation doesn't pay the cost."""
|
| 51 |
+
return models.availability()
|
rubriq/classify.py
CHANGED
|
@@ -28,7 +28,8 @@ _DELIM_BLOCK = re.compile(
|
|
| 28 |
)
|
| 29 |
_SOURCE_INTRO = re.compile(
|
| 30 |
r"(?i)(?:following (?:text|article|document|passage|email|report|code|excerpt|transcript|paragraph)s?|"
|
| 31 |
-
r"text below|below is|here is the (?:text|article|document|passage|code)|this (?:text|article|document))
|
|
|
|
| 32 |
)
|
| 33 |
|
| 34 |
|
|
@@ -53,14 +54,14 @@ def split_instruction_and_source(prompt: str) -> tuple[str, str | None]:
|
|
| 53 |
if m:
|
| 54 |
source = prompt[m.end():].strip()
|
| 55 |
instruction = prompt[:m.end()].strip()
|
| 56 |
-
if word_count(source) >=
|
| 57 |
return instruction, source
|
| 58 |
|
| 59 |
# Length heuristic: short first paragraph, long remainder.
|
| 60 |
parts = prompt.split("\n", 1)
|
| 61 |
if len(parts) == 2:
|
| 62 |
head, rest = parts[0].strip(), parts[1].strip()
|
| 63 |
-
if head and word_count(head) <= 60 and word_count(rest) >=
|
| 64 |
return head, rest
|
| 65 |
|
| 66 |
return prompt.strip(), None
|
|
@@ -95,6 +96,10 @@ _RULES: list[tuple[TaskType, str, re.Pattern]] = [
|
|
| 95 |
re.compile(r"(?i)\b(rewrite|rephrase|paraphrase|edit|proofread|improve|polish|make (?:this|it) (?:more|less|sound)|change the tone|translate)\b")),
|
| 96 |
(TaskType.QA_OPEN, "prompt asks a direct question",
|
| 97 |
re.compile(r"(?i)^(?:please\s+)?(what|who|when|where|why|how|which|is|are|does|do|can|could|should|explain)\b")),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
]
|
| 99 |
|
| 100 |
_CODE_FENCE = re.compile(r"```")
|
|
|
|
| 28 |
)
|
| 29 |
_SOURCE_INTRO = re.compile(
|
| 30 |
r"(?i)(?:following (?:text|article|document|passage|email|report|code|excerpt|transcript|paragraph)s?|"
|
| 31 |
+
r"text below|below is|here is the (?:text|article|document|passage|code)|this (?:text|article|document))"
|
| 32 |
+
r"[^\n]*\n"
|
| 33 |
)
|
| 34 |
|
| 35 |
|
|
|
|
| 54 |
if m:
|
| 55 |
source = prompt[m.end():].strip()
|
| 56 |
instruction = prompt[:m.end()].strip()
|
| 57 |
+
if word_count(source) >= 30:
|
| 58 |
return instruction, source
|
| 59 |
|
| 60 |
# Length heuristic: short first paragraph, long remainder.
|
| 61 |
parts = prompt.split("\n", 1)
|
| 62 |
if len(parts) == 2:
|
| 63 |
head, rest = parts[0].strip(), parts[1].strip()
|
| 64 |
+
if head and word_count(head) <= 60 and word_count(rest) >= 60 and _looks_instructional(head):
|
| 65 |
return head, rest
|
| 66 |
|
| 67 |
return prompt.strip(), None
|
|
|
|
| 96 |
re.compile(r"(?i)\b(rewrite|rephrase|paraphrase|edit|proofread|improve|polish|make (?:this|it) (?:more|less|sound)|change the tone|translate)\b")),
|
| 97 |
(TaskType.QA_OPEN, "prompt asks a direct question",
|
| 98 |
re.compile(r"(?i)^(?:please\s+)?(what|who|when|where|why|how|which|is|are|does|do|can|could|should|explain)\b")),
|
| 99 |
+
(TaskType.QA_OPEN, "prompt asks to answer questions",
|
| 100 |
+
re.compile(r"(?i)\banswer (?:the |these |the following |my )?questions?\b")),
|
| 101 |
+
(TaskType.QA_OPEN, "prompt contains interrogative sentences",
|
| 102 |
+
re.compile(r"(?:^|[.!?]\s+)(?:What|Who|When|Where|Why|How|Which)\b[^.\n?]*\?")),
|
| 103 |
]
|
| 104 |
|
| 105 |
_CODE_FENCE = re.compile(r"```")
|
rubriq/constraints.py
CHANGED
|
@@ -80,7 +80,7 @@ _INCLUDE_PAT = re.compile(
|
|
| 80 |
)
|
| 81 |
_EXCLUDE_PAT = re.compile(
|
| 82 |
r"(?i)\b(?:do not|don'?t|never|avoid)\s+"
|
| 83 |
-
r"(?:
|
| 84 |
)
|
| 85 |
_START_PAT = re.compile(r"(?i)\b(?:begin|start)\s+(?:your\s+)?(?:response|answer|output|reply|with)?\s*with\s+[\"“']([^\"”']{2,80})[\"”']")
|
| 86 |
_END_PAT = re.compile(r"(?i)\bend\s+(?:your\s+)?(?:response|answer|output|reply)?\s*with\s+[\"“']([^\"”']{2,80})[\"”']")
|
|
|
|
| 80 |
)
|
| 81 |
_EXCLUDE_PAT = re.compile(
|
| 82 |
r"(?i)\b(?:do not|don'?t|never|avoid)\s+"
|
| 83 |
+
r"(?:us(?:e|ing)|mention(?:ing)?|includ(?:e|ing)|say(?:ing)?)(?:\s+the\s+(?:word|term|phrase)s?)?\s+[\"“']([^\"”']{2,60})[\"”']"
|
| 84 |
)
|
| 85 |
_START_PAT = re.compile(r"(?i)\b(?:begin|start)\s+(?:your\s+)?(?:response|answer|output|reply|with)?\s*with\s+[\"“']([^\"”']{2,80})[\"”']")
|
| 86 |
_END_PAT = re.compile(r"(?i)\bend\s+(?:your\s+)?(?:response|answer|output|reply)?\s*with\s+[\"“']([^\"”']{2,80})[\"”']")
|
rubriq/metrics/hallucination_risk.py
CHANGED
|
@@ -17,7 +17,7 @@ from ..textutils import snippet
|
|
| 17 |
|
| 18 |
_SIGNALS: list[tuple[str, re.Pattern, str]] = [
|
| 19 |
("citation_pattern",
|
| 20 |
-
re.compile(r"\([A-Z][A-Za-z-]+(?:
|
| 21 |
"citation-shaped reference — LLMs fabricate plausible-looking citations; verify each one exists"),
|
| 22 |
("case_or_statute",
|
| 23 |
re.compile(r"\b[A-Z][a-z]+ v\.? [A-Z][a-z]+\b|\b\d+ U\.S\.C?\.? §? ?\d+\b"),
|
|
|
|
| 17 |
|
| 18 |
_SIGNALS: list[tuple[str, re.Pattern, str]] = [
|
| 19 |
("citation_pattern",
|
| 20 |
+
re.compile(r"\([A-Z][A-Za-z-]+(?:\s+et al\.?|\s+(?:&|and)\s+[A-Z][A-Za-z-]+)?,?\s+(?:19|20)\d{2}\)|\[[0-9]{1,3}\]"),
|
| 21 |
"citation-shaped reference — LLMs fabricate plausible-looking citations; verify each one exists"),
|
| 22 |
("case_or_statute",
|
| 23 |
re.compile(r"\b[A-Z][a-z]+ v\.? [A-Z][a-z]+\b|\b\d+ U\.S\.C?\.? §? ?\d+\b"),
|
rubriq/prompts/__init__.py
ADDED
|
File without changes
|
rubriq/prompts/generator.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Improvement-prompt generation — the flagship.
|
| 2 |
+
|
| 3 |
+
Contract (RESEARCH.md Part 4, enforced by tests):
|
| 4 |
+
(a) the diagnosis names the specific failure with the evidence that triggered it,
|
| 5 |
+
(b) prompt_text is a complete, ready-to-paste replacement prompt,
|
| 6 |
+
(c) the fix uses the named model family's documented mechanism.
|
| 7 |
+
"Be more specific" is a bug, not a template.
|
| 8 |
+
|
| 9 |
+
Builders receive the user's original instruction and rebuild it with the
|
| 10 |
+
repair applied, so the user pastes one block, not advice.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from ..schemas import (Evidence, FailureMode, ImprovementPrompt, ModelFamily,
|
| 16 |
+
ParameterScore, TaskClassification, TaskType)
|
| 17 |
+
from ..textutils import snippet
|
| 18 |
+
from .model_profiles import Profile, get_profile
|
| 19 |
+
|
| 20 |
+
_SEVERITY = [
|
| 21 |
+
FailureMode.UNFAITHFUL_CONTENT,
|
| 22 |
+
FailureMode.INVALID_CODE,
|
| 23 |
+
FailureMode.INVENTED_FIELDS,
|
| 24 |
+
FailureMode.MISSED_CONSTRAINT,
|
| 25 |
+
FailureMode.FORMAT_VIOLATION,
|
| 26 |
+
FailureMode.INCOMPLETE_COVERAGE,
|
| 27 |
+
FailureMode.HALLUCINATION_RISK,
|
| 28 |
+
FailureMode.OFF_TOPIC_DRIFT,
|
| 29 |
+
FailureMode.WEAK_REASONING,
|
| 30 |
+
FailureMode.VERBOSITY,
|
| 31 |
+
FailureMode.WRONG_REGISTER,
|
| 32 |
+
FailureMode.POOR_STRUCTURE,
|
| 33 |
+
FailureMode.LOW_DIVERSITY,
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
MAX_PROMPTS = 5
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _quotes(findings, limit=3) -> list[str]:
|
| 40 |
+
out = []
|
| 41 |
+
for f in findings:
|
| 42 |
+
for e in f.evidence:
|
| 43 |
+
if e.source == "output" and e.quote:
|
| 44 |
+
out.append(e.quote)
|
| 45 |
+
break
|
| 46 |
+
if len(out) >= limit:
|
| 47 |
+
break
|
| 48 |
+
return out
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _source_block(profile: Profile, task: TaskClassification) -> str:
|
| 52 |
+
if not task.has_source_text:
|
| 53 |
+
return ""
|
| 54 |
+
return profile.wrap_source() + "\n\n"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _rules_block(profile: Profile, rules: list[str]) -> str:
|
| 58 |
+
"""Render hard rules in the family's preferred structure."""
|
| 59 |
+
if profile.family == ModelFamily.CLAUDE:
|
| 60 |
+
inner = "\n".join(f"- {r}" for r in rules)
|
| 61 |
+
return f"<rules>\n{inner}\n</rules>"
|
| 62 |
+
if profile.family == ModelFamily.GPT:
|
| 63 |
+
inner = "\n".join(f"{i+1}. {r}" for i, r in enumerate(rules))
|
| 64 |
+
return f"### Requirements\n{inner}"
|
| 65 |
+
inner = "\n".join(f"- {r}" for r in rules)
|
| 66 |
+
return f"Rules:\n{inner}"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _tail_emphasis(profile: Profile, critical: str) -> str:
|
| 70 |
+
"""GPT/open-weights/generic get the critical constraint repeated last
|
| 71 |
+
(lost-in-the-middle, RESEARCH.md 2.7). Claude/Gemini get their own levers
|
| 72 |
+
elsewhere, so no noisy repetition."""
|
| 73 |
+
if profile.family in (ModelFamily.OPEN_WEIGHTS, ModelFamily.GPT, ModelFamily.GENERIC):
|
| 74 |
+
return f"\n\nIMPORTANT — before finishing, verify: {critical}"
|
| 75 |
+
return ""
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# --------------------------------------------------------------- builders
|
| 79 |
+
|
| 80 |
+
def _build_unfaithful(findings, task, profile: Profile) -> ImprovementPrompt:
|
| 81 |
+
bad = _quotes(findings)
|
| 82 |
+
bad_list = "\n".join(f'- "{q}"' for q in bad) if bad else "- (see report)"
|
| 83 |
+
grounding_rules = [
|
| 84 |
+
"Use ONLY information stated in the source text. If the source does not say it, do not write it.",
|
| 85 |
+
"If something the task needs is missing from the source, write \"the source does not specify\" rather than filling the gap.",
|
| 86 |
+
]
|
| 87 |
+
if profile.family == ModelFamily.CLAUDE:
|
| 88 |
+
pre = ("First, copy the exact sentences from the source that support your answer into <quotes> tags. "
|
| 89 |
+
"Then write the final answer in <answer> tags using only what you quoted.\n\n")
|
| 90 |
+
elif profile.family == ModelFamily.GEMINI:
|
| 91 |
+
pre = "Base every sentence of your answer on the text above. Quote or closely paraphrase the source.\n\n"
|
| 92 |
+
else:
|
| 93 |
+
pre = "Answer using only the provided text. Quote the source where possible.\n\n"
|
| 94 |
+
body = (
|
| 95 |
+
f"{_source_block(profile, task)}"
|
| 96 |
+
f"{task.instruction.strip()}\n\n"
|
| 97 |
+
f"{pre}"
|
| 98 |
+
f"{_rules_block(profile, grounding_rules)}\n\n"
|
| 99 |
+
f"A previous attempt invented content the source does not support, including:\n{bad_list}\n"
|
| 100 |
+
f"Do not repeat these claims unless the source states them."
|
| 101 |
+
f"{_tail_emphasis(profile, 'every claim traces to a sentence in the source text')}"
|
| 102 |
+
)
|
| 103 |
+
return ImprovementPrompt(
|
| 104 |
+
failure_mode=FailureMode.UNFAITHFUL_CONTENT,
|
| 105 |
+
title="Pin the output to the source",
|
| 106 |
+
diagnosis=(f"The output makes claims the source text does not support"
|
| 107 |
+
+ (f', e.g. "{bad[0]}"' if bad else "") + ". "
|
| 108 |
+
f"Repair: {profile.grounding_mechanism}"),
|
| 109 |
+
prompt_text=body,
|
| 110 |
+
model_family=profile.family,
|
| 111 |
+
technique_source=profile.source,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _build_constraints(findings, task, profile: Profile) -> ImprovementPrompt:
|
| 116 |
+
missed = []
|
| 117 |
+
for f in findings:
|
| 118 |
+
kind = f.data.get("kind", "")
|
| 119 |
+
actual = f.data.get("actual", "")
|
| 120 |
+
missed.append((f.detail, kind, actual))
|
| 121 |
+
rules = []
|
| 122 |
+
for f in findings:
|
| 123 |
+
prompt_ev = next((e.quote for e in f.evidence if e.source == "prompt"), None)
|
| 124 |
+
rules.append(f.detail.split(" — ")[0].removeprefix("Prompt asked for ").strip().capitalize()
|
| 125 |
+
+ (f" (your prompt said: \"{prompt_ev}\")" if prompt_ev else ""))
|
| 126 |
+
if profile.family == ModelFamily.GEMINI:
|
| 127 |
+
example = ("\n\nHere is an example of a response with the right shape (replace the content, keep the form):\n"
|
| 128 |
+
"[write one short example that satisfies every rule above]")
|
| 129 |
+
else:
|
| 130 |
+
example = ""
|
| 131 |
+
critical = rules[0] if rules else "all stated constraints are met"
|
| 132 |
+
body = (
|
| 133 |
+
f"{_source_block(profile, task)}"
|
| 134 |
+
f"{task.instruction.strip()}\n\n"
|
| 135 |
+
f"{_rules_block(profile, rules)}"
|
| 136 |
+
f"{example}"
|
| 137 |
+
f"\n\nAfter drafting, count and check each requirement above before giving your final answer."
|
| 138 |
+
f"{_tail_emphasis(profile, critical)}"
|
| 139 |
+
)
|
| 140 |
+
first = findings[0]
|
| 141 |
+
return ImprovementPrompt(
|
| 142 |
+
failure_mode=first.failure_mode,
|
| 143 |
+
title="Make the constraints unmissable",
|
| 144 |
+
diagnosis=(f"The output missed {len(findings)} verifiable constraint(s): "
|
| 145 |
+
+ "; ".join(f.detail for f in findings[:3]) + " "
|
| 146 |
+
f"Repair: {profile.format_mechanism}"),
|
| 147 |
+
prompt_text=body,
|
| 148 |
+
model_family=profile.family,
|
| 149 |
+
technique_source=profile.source,
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _build_coverage(findings, task, profile: Profile) -> ImprovementPrompt:
|
| 154 |
+
parts = []
|
| 155 |
+
for f in findings:
|
| 156 |
+
q = next((e.quote for e in f.evidence if e.source == "prompt"), None)
|
| 157 |
+
if q:
|
| 158 |
+
parts.append(q)
|
| 159 |
+
part_list = "\n".join(f"{i+1}. {p}" for i, p in enumerate(parts)) or "1. (see report)"
|
| 160 |
+
body = (
|
| 161 |
+
f"{_source_block(profile, task)}"
|
| 162 |
+
f"{task.instruction.strip()}\n\n"
|
| 163 |
+
f"Answer every numbered part separately, under its own heading:\n{part_list}\n\n"
|
| 164 |
+
f"If you cannot answer a part, say so under that heading rather than skipping it."
|
| 165 |
+
f"{_tail_emphasis(profile, f'all {len(parts)} parts have their own answer')}"
|
| 166 |
+
)
|
| 167 |
+
return ImprovementPrompt(
|
| 168 |
+
failure_mode=FailureMode.INCOMPLETE_COVERAGE,
|
| 169 |
+
title="Force full coverage of the question",
|
| 170 |
+
diagnosis=(f"{len(parts)} part(s) of the request went unanswered, e.g. \"{parts[0] if parts else ''}\". "
|
| 171 |
+
"Repair: enumerate the parts and demand one answer per part — models skip sub-questions "
|
| 172 |
+
"buried in prose but not numbered lists."),
|
| 173 |
+
prompt_text=body,
|
| 174 |
+
model_family=profile.family,
|
| 175 |
+
technique_source=profile.source,
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _build_hallucination(findings, task, profile: Profile) -> ImprovementPrompt:
|
| 180 |
+
flagged = _quotes(findings)
|
| 181 |
+
fl = "\n".join(f'- "{q}"' for q in flagged) if flagged else ""
|
| 182 |
+
rules = [
|
| 183 |
+
"For every statistic, name its source inline, or present it as an estimate in words (\"roughly\", \"on the order of\").",
|
| 184 |
+
"Do not cite papers, cases, or URLs unless you are certain they exist; prefer \"research on X suggests\" with no fake citation.",
|
| 185 |
+
"Mark claims you are unsure about explicitly (\"I may be wrong about the exact figure\").",
|
| 186 |
+
]
|
| 187 |
+
body = (
|
| 188 |
+
f"{task.instruction.strip()}\n\n"
|
| 189 |
+
f"{_rules_block(profile, rules)}\n\n"
|
| 190 |
+
+ (f"A previous attempt stated the following as fact; each is unverifiable as written:\n{fl}\n"
|
| 191 |
+
f"Restate these with sources or with honest uncertainty, or drop them.\n" if fl else "")
|
| 192 |
+
+ "\nEnd your answer with a one-line note: which claims above should I double-check independently?"
|
| 193 |
+
f"{_tail_emphasis(profile, 'no statistic or citation appears without a named source or an uncertainty marker')}"
|
| 194 |
+
)
|
| 195 |
+
return ImprovementPrompt(
|
| 196 |
+
failure_mode=FailureMode.HALLUCINATION_RISK,
|
| 197 |
+
title="Demand attribution or honest uncertainty",
|
| 198 |
+
diagnosis=(f"The output states unverifiable specifics as fact"
|
| 199 |
+
+ (f', e.g. "{flagged[0]}"' if flagged else "") + ". "
|
| 200 |
+
"No local check can verify these; the repair makes the model attribute or hedge them, "
|
| 201 |
+
"and hands you a verification checklist."),
|
| 202 |
+
prompt_text=body,
|
| 203 |
+
model_family=profile.family,
|
| 204 |
+
technique_source=profile.source,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _build_reasoning(findings, task, profile: Profile) -> ImprovementPrompt:
|
| 209 |
+
if profile.family == ModelFamily.CLAUDE:
|
| 210 |
+
think = ("Work through the problem step by step inside <thinking> tags first. "
|
| 211 |
+
"Then give the final answer in <answer> tags.")
|
| 212 |
+
else:
|
| 213 |
+
think = ("Solve this step by step. Show each step of your working on its own line, "
|
| 214 |
+
"then state the final answer on a last line beginning \"Answer:\".")
|
| 215 |
+
body = (
|
| 216 |
+
f"{task.instruction.strip()}\n\n"
|
| 217 |
+
f"{think}\n\n"
|
| 218 |
+
f"After stating the answer, re-derive it a second way if possible and confirm both routes agree; "
|
| 219 |
+
f"if they disagree, say which one you trust and why."
|
| 220 |
+
f"{_tail_emphasis(profile, 'working shown, final answer explicitly labeled')}"
|
| 221 |
+
)
|
| 222 |
+
return ImprovementPrompt(
|
| 223 |
+
failure_mode=FailureMode.WEAK_REASONING,
|
| 224 |
+
title="Make the model show its working",
|
| 225 |
+
diagnosis=("The output gives a conclusion without checkable working. "
|
| 226 |
+
"Repair: request the reasoning *before* the answer (chain-of-thought), plus a second "
|
| 227 |
+
"independent derivation — the manual form of self-consistency."),
|
| 228 |
+
prompt_text=body,
|
| 229 |
+
model_family=profile.family,
|
| 230 |
+
technique_source="Wei et al. 2022 (CoT); Wang et al. 2023 (self-consistency); " + profile.source,
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _build_verbosity(findings, task, profile: Profile) -> ImprovementPrompt:
|
| 235 |
+
fillers = _quotes(findings)
|
| 236 |
+
limit_hint = next((f.data.get("ratio") for f in findings if "ratio" in f.data), None)
|
| 237 |
+
rules = [
|
| 238 |
+
"Open with the answer itself — no preamble, no restating the question.",
|
| 239 |
+
"Cut framing phrases (e.g. " + ", ".join(f'"{f}"' for f in fillers[:3]) + ")." if fillers
|
| 240 |
+
else "Cut framing phrases and meta-commentary.",
|
| 241 |
+
"One idea per sentence; delete any sentence that repeats an earlier one.",
|
| 242 |
+
]
|
| 243 |
+
if profile.family == ModelFamily.CLAUDE:
|
| 244 |
+
opener = "\nBegin your response directly with the substance (no introduction)."
|
| 245 |
+
else:
|
| 246 |
+
opener = ""
|
| 247 |
+
body = (
|
| 248 |
+
f"{_source_block(profile, task)}"
|
| 249 |
+
f"{task.instruction.strip()}\n\n"
|
| 250 |
+
f"{_rules_block(profile, rules)}{opener}"
|
| 251 |
+
f"{_tail_emphasis(profile, 'the response starts with substance and contains no filler phrases')}"
|
| 252 |
+
)
|
| 253 |
+
return ImprovementPrompt(
|
| 254 |
+
failure_mode=FailureMode.VERBOSITY,
|
| 255 |
+
title="Strip the padding",
|
| 256 |
+
diagnosis=("The output pads its length" + (f" ({limit_hint}x the asked-for size)" if limit_hint else "")
|
| 257 |
+
+ (f' with filler like "{fillers[0]}"' if fillers else "") + ". "
|
| 258 |
+
"Repair: answer-first structure plus an explicit ban on the exact filler found."),
|
| 259 |
+
prompt_text=body,
|
| 260 |
+
model_family=profile.family,
|
| 261 |
+
technique_source=profile.source,
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _build_register(findings, task, profile: Profile) -> ImprovementPrompt:
|
| 266 |
+
data = findings[0].data
|
| 267 |
+
fk, band = data.get("fk_grade"), data.get("band", [6, 14])
|
| 268 |
+
audience = data.get("audience", "the intended audience")
|
| 269 |
+
too_hard = fk is not None and fk > band[1]
|
| 270 |
+
style = ("short sentences, everyday words, one idea at a time"
|
| 271 |
+
if too_hard else "precise terminology and compact, information-dense prose")
|
| 272 |
+
role = f"You are writing for {audience}."
|
| 273 |
+
body = (
|
| 274 |
+
f"{role}\n\n"
|
| 275 |
+
f"{_source_block(profile, task)}"
|
| 276 |
+
f"{task.instruction.strip()}\n\n"
|
| 277 |
+
f"Write in a register that fits that audience: {style}. "
|
| 278 |
+
f"Read your draft as that reader before finalizing; rewrite any sentence they would stumble on."
|
| 279 |
+
f"{_tail_emphasis(profile, f'the text reads naturally for {audience}')}"
|
| 280 |
+
)
|
| 281 |
+
return ImprovementPrompt(
|
| 282 |
+
failure_mode=FailureMode.WRONG_REGISTER,
|
| 283 |
+
title="Match the audience's reading level",
|
| 284 |
+
diagnosis=(f"The output reads at US grade level {fk}, outside the {band[0]:g}-{band[1]:g} band for {audience} — "
|
| 285 |
+
f"{'harder' if too_hard else 'simpler'} than it should. "
|
| 286 |
+
"Repair: an explicit audience role. Role prompting reliably shifts register (it does not fix facts)."),
|
| 287 |
+
prompt_text=body,
|
| 288 |
+
model_family=profile.family,
|
| 289 |
+
technique_source="role prompting for register (RESEARCH.md 2.8); " + profile.source,
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def _build_structure(findings, task, profile: Profile) -> ImprovementPrompt:
|
| 294 |
+
repeats = any("repeat" in f.detail.lower() or "duplicate" in f.detail.lower() for f in findings)
|
| 295 |
+
rules = [
|
| 296 |
+
"Before writing, produce a 3-6 point outline; then write one section per point.",
|
| 297 |
+
"Each section must add new information — no restating earlier sections." if repeats
|
| 298 |
+
else "Order sections so each follows from the previous one.",
|
| 299 |
+
]
|
| 300 |
+
body = (
|
| 301 |
+
f"{_source_block(profile, task)}"
|
| 302 |
+
f"{task.instruction.strip()}\n\n"
|
| 303 |
+
f"{_rules_block(profile, rules)}"
|
| 304 |
+
f"{_tail_emphasis(profile, 'sections follow the outline and none repeats another')}"
|
| 305 |
+
)
|
| 306 |
+
return ImprovementPrompt(
|
| 307 |
+
failure_mode=FailureMode.POOR_STRUCTURE,
|
| 308 |
+
title="Impose an outline",
|
| 309 |
+
diagnosis=("Adjacent sections " + ("repeat each other" if repeats else "jump between topics without connection")
|
| 310 |
+
+ " (flagged by flow analysis). Repair: outline-first generation pins the structure before prose exists."),
|
| 311 |
+
prompt_text=body,
|
| 312 |
+
model_family=profile.family,
|
| 313 |
+
technique_source=profile.source,
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def _build_drift(findings, task, profile: Profile) -> ImprovementPrompt:
|
| 318 |
+
drifting = _quotes(findings, 1)
|
| 319 |
+
body = (
|
| 320 |
+
f"{_source_block(profile, task)}"
|
| 321 |
+
f"{task.instruction.strip()}\n\n"
|
| 322 |
+
f"Stay strictly on this request. Do not add background, related topics, or general advice unless asked."
|
| 323 |
+
+ (f"\n\nA previous attempt drifted into: \"{drifting[0]}\" — leave this out unless the request explicitly needs it."
|
| 324 |
+
if drifting else "")
|
| 325 |
+
+ f"{_tail_emphasis(profile, 'every paragraph directly serves the request')}"
|
| 326 |
+
)
|
| 327 |
+
return ImprovementPrompt(
|
| 328 |
+
failure_mode=FailureMode.OFF_TOPIC_DRIFT,
|
| 329 |
+
title="Fence the scope",
|
| 330 |
+
diagnosis=(f"Part of the output drifts off the request"
|
| 331 |
+
+ (f' ("{drifting[0]}")' if drifting else "") +
|
| 332 |
+
". Repair: an explicit scope fence naming the drift to exclude."),
|
| 333 |
+
prompt_text=body,
|
| 334 |
+
model_family=profile.family,
|
| 335 |
+
technique_source=profile.source,
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def _build_code(findings, task, profile: Profile) -> ImprovementPrompt:
|
| 340 |
+
problems = [f.detail for f in findings]
|
| 341 |
+
plist = "\n".join(f"- {p}" for p in problems[:4])
|
| 342 |
+
rules = [
|
| 343 |
+
"Return complete, runnable code — no TODOs, placeholders, or elided sections.",
|
| 344 |
+
"If the code needs assumptions (versions, inputs), state them in a comment at the top.",
|
| 345 |
+
"After the code, list 2-3 quick test cases I can run to verify it.",
|
| 346 |
+
]
|
| 347 |
+
body = (
|
| 348 |
+
f"{task.instruction.strip()}\n\n"
|
| 349 |
+
f"{_rules_block(profile, rules)}\n\n"
|
| 350 |
+
f"A previous attempt had these problems — fix each:\n{plist}"
|
| 351 |
+
f"{_tail_emphasis(profile, 'the code parses, is complete, and includes the requested names')}"
|
| 352 |
+
)
|
| 353 |
+
return ImprovementPrompt(
|
| 354 |
+
failure_mode=FailureMode.INVALID_CODE,
|
| 355 |
+
title="Demand complete, verifiable code",
|
| 356 |
+
diagnosis=("Static analysis found concrete defects: " + "; ".join(problems[:2]) + " "
|
| 357 |
+
"Repair: completeness rules plus self-supplied test cases give you an immediate way to check the redo."),
|
| 358 |
+
prompt_text=body,
|
| 359 |
+
model_family=profile.family,
|
| 360 |
+
technique_source=profile.source,
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _build_invented(findings, task, profile: Profile) -> ImprovementPrompt:
|
| 365 |
+
invented = _quotes(findings)
|
| 366 |
+
il = "\n".join(f'- "{q}"' for q in invented) if invented else ""
|
| 367 |
+
rules = [
|
| 368 |
+
"Copy every extracted value verbatim from the source text — no paraphrasing, no normalizing.",
|
| 369 |
+
"If a requested field is not in the text, output null for it. Never fill gaps from general knowledge.",
|
| 370 |
+
]
|
| 371 |
+
body = (
|
| 372 |
+
f"{_source_block(profile, task)}"
|
| 373 |
+
f"{task.instruction.strip()}\n\n"
|
| 374 |
+
f"{_rules_block(profile, rules)}\n\n"
|
| 375 |
+
+ (f"A previous attempt output values that are not in the source:\n{il}\n" if il else "")
|
| 376 |
+
+ f"{_tail_emphasis(profile, 'every value string-matches the source text or is null')}"
|
| 377 |
+
)
|
| 378 |
+
return ImprovementPrompt(
|
| 379 |
+
failure_mode=FailureMode.INVENTED_FIELDS,
|
| 380 |
+
title="Verbatim-only extraction",
|
| 381 |
+
diagnosis=("Extracted values don't appear in the source text"
|
| 382 |
+
+ (f', e.g. "{invented[0]}"' if invented else "") +
|
| 383 |
+
". Repair: verbatim-copy rule plus explicit null for missing fields — the two rules that stop "
|
| 384 |
+
"extraction models from 'helpfully' completing data."),
|
| 385 |
+
prompt_text=body,
|
| 386 |
+
model_family=profile.family,
|
| 387 |
+
technique_source=profile.source,
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def _build_diversity(findings, task, profile: Profile) -> ImprovementPrompt:
|
| 392 |
+
cliches = _quotes(findings)
|
| 393 |
+
cl = ", ".join(f'"{c}"' for c in cliches[:5])
|
| 394 |
+
rules = [
|
| 395 |
+
(f"Banned phrases: {cl}. Find a fresh image instead." if cliches else
|
| 396 |
+
"Avoid stock phrases; when a familiar expression appears in your draft, replace it with a concrete image."),
|
| 397 |
+
"Vary sentence length deliberately: mix sentences under 8 words with longer ones.",
|
| 398 |
+
"Prefer specific, sensory detail over abstract description.",
|
| 399 |
+
]
|
| 400 |
+
body = (
|
| 401 |
+
f"{task.instruction.strip()}\n\n"
|
| 402 |
+
f"{_rules_block(profile, rules)}"
|
| 403 |
+
f"{_tail_emphasis(profile, 'no banned phrase appears and sentence lengths vary')}"
|
| 404 |
+
)
|
| 405 |
+
return ImprovementPrompt(
|
| 406 |
+
failure_mode=FailureMode.LOW_DIVERSITY,
|
| 407 |
+
title="Ban the clichés it reached for",
|
| 408 |
+
diagnosis=("Style statistics flagged " + (f"stock phrases ({cl})" if cliches else "flat, repetitive prose") +
|
| 409 |
+
". Repair: ban the exact phrases found and constrain rhythm — negative examples work when they are this specific."),
|
| 410 |
+
prompt_text=body,
|
| 411 |
+
model_family=profile.family,
|
| 412 |
+
technique_source=profile.source,
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
_BUILDERS = {
|
| 417 |
+
FailureMode.UNFAITHFUL_CONTENT: _build_unfaithful,
|
| 418 |
+
FailureMode.MISSED_CONSTRAINT: _build_constraints,
|
| 419 |
+
FailureMode.FORMAT_VIOLATION: _build_constraints,
|
| 420 |
+
FailureMode.INCOMPLETE_COVERAGE: _build_coverage,
|
| 421 |
+
FailureMode.HALLUCINATION_RISK: _build_hallucination,
|
| 422 |
+
FailureMode.WEAK_REASONING: _build_reasoning,
|
| 423 |
+
FailureMode.VERBOSITY: _build_verbosity,
|
| 424 |
+
FailureMode.WRONG_REGISTER: _build_register,
|
| 425 |
+
FailureMode.POOR_STRUCTURE: _build_structure,
|
| 426 |
+
FailureMode.OFF_TOPIC_DRIFT: _build_drift,
|
| 427 |
+
FailureMode.INVALID_CODE: _build_code,
|
| 428 |
+
FailureMode.INVENTED_FIELDS: _build_invented,
|
| 429 |
+
FailureMode.LOW_DIVERSITY: _build_diversity,
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def generate_improvement_prompts(
|
| 434 |
+
model_name: str,
|
| 435 |
+
task: TaskClassification,
|
| 436 |
+
parameters: list[ParameterScore],
|
| 437 |
+
) -> list[ImprovementPrompt]:
|
| 438 |
+
profile = get_profile(model_name)
|
| 439 |
+
|
| 440 |
+
by_mode: dict[FailureMode, list] = {}
|
| 441 |
+
for ps in parameters:
|
| 442 |
+
for f in ps.findings:
|
| 443 |
+
by_mode.setdefault(f.failure_mode, []).append(f)
|
| 444 |
+
# constraint + format failures repair through the same rebuilt prompt
|
| 445 |
+
if FailureMode.FORMAT_VIOLATION in by_mode and FailureMode.MISSED_CONSTRAINT in by_mode:
|
| 446 |
+
by_mode[FailureMode.MISSED_CONSTRAINT].extend(by_mode.pop(FailureMode.FORMAT_VIOLATION))
|
| 447 |
+
|
| 448 |
+
prompts: list[ImprovementPrompt] = []
|
| 449 |
+
for mode in _SEVERITY:
|
| 450 |
+
if mode in by_mode and mode in _BUILDERS:
|
| 451 |
+
prompts.append(_BUILDERS[mode](by_mode[mode], task, profile))
|
| 452 |
+
if len(prompts) >= MAX_PROMPTS:
|
| 453 |
+
break
|
| 454 |
+
return prompts
|
rubriq/prompts/model_profiles.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model-family detection and prompting mechanisms (RESEARCH.md 2.1-2.3).
|
| 2 |
+
|
| 3 |
+
Each family gets the *mechanisms its own provider documents*: XML tags and
|
| 4 |
+
prefills for Claude, delimiters and literal phrasing for GPT, few-shot and
|
| 5 |
+
positive instructions for Gemini, repetition and explicit format for open
|
| 6 |
+
weights. The generic profile is the fallback when no model is named.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
|
| 14 |
+
from ..schemas import ModelFamily
|
| 15 |
+
|
| 16 |
+
_FAMILY_PATTERNS = [
|
| 17 |
+
(ModelFamily.CLAUDE, re.compile(r"(?i)claude|anthropic|sonnet|opus|haiku|fable")),
|
| 18 |
+
(ModelFamily.GPT, re.compile(r"(?i)gpt|openai|o[134][\b-]|chatgpt|davinci")),
|
| 19 |
+
(ModelFamily.GEMINI, re.compile(r"(?i)gemini|bard|google|palm")),
|
| 20 |
+
(ModelFamily.OPEN_WEIGHTS, re.compile(r"(?i)llama|mistral|mixtral|qwen|deepseek|phi-|falcon|vicuna|ollama")),
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def detect_family(model_name: str) -> ModelFamily:
|
| 25 |
+
for family, pat in _FAMILY_PATTERNS:
|
| 26 |
+
if pat.search(model_name or ""):
|
| 27 |
+
return family
|
| 28 |
+
return ModelFamily.GENERIC
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass(frozen=True)
|
| 32 |
+
class Profile:
|
| 33 |
+
family: ModelFamily
|
| 34 |
+
display: str
|
| 35 |
+
source: str # provider guidance citation (RESEARCH.md key)
|
| 36 |
+
# mechanism snippets used by the builders:
|
| 37 |
+
structure_hint: str # how this family likes prompts organized
|
| 38 |
+
format_mechanism: str # strongest format-control lever
|
| 39 |
+
grounding_mechanism: str # strongest faithfulness lever
|
| 40 |
+
|
| 41 |
+
def wrap_source(self, placeholder: str = "<paste your source text here>") -> str:
|
| 42 |
+
if self.family == ModelFamily.CLAUDE:
|
| 43 |
+
return f"<source>\n{placeholder}\n</source>"
|
| 44 |
+
if self.family == ModelFamily.GPT:
|
| 45 |
+
return f'"""\n{placeholder}\n"""'
|
| 46 |
+
return f"--- SOURCE TEXT ---\n{placeholder}\n--- END SOURCE ---"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
PROFILES: dict[ModelFamily, Profile] = {
|
| 50 |
+
ModelFamily.CLAUDE: Profile(
|
| 51 |
+
family=ModelFamily.CLAUDE, display="Claude",
|
| 52 |
+
source="Anthropic prompt engineering docs (RESEARCH.md 2.1)",
|
| 53 |
+
structure_hint="Claude responds well to XML-tagged sections and an assigned role.",
|
| 54 |
+
format_mechanism="Tell Claude exactly how to open its reply (a prefill-style instruction) and tag the rules in XML.",
|
| 55 |
+
grounding_mechanism="Ask Claude to extract supporting quotes into <quotes> tags before writing, and to use only what it quoted.",
|
| 56 |
+
),
|
| 57 |
+
ModelFamily.GPT: Profile(
|
| 58 |
+
family=ModelFamily.GPT, display="GPT",
|
| 59 |
+
source="OpenAI prompt engineering guide (RESEARCH.md 2.2)",
|
| 60 |
+
structure_hint="Newer GPT models follow instructions literally — state every requirement explicitly, separated by ### delimiters.",
|
| 61 |
+
format_mechanism="Number the requirements and restate the output format as the final line of the prompt.",
|
| 62 |
+
grounding_mechanism="Provide the reference text inside triple quotes and instruct: answer using only the provided text; say \"not in the text\" otherwise.",
|
| 63 |
+
),
|
| 64 |
+
ModelFamily.GEMINI: Profile(
|
| 65 |
+
family=ModelFamily.GEMINI, display="Gemini",
|
| 66 |
+
source="Google Gemini prompting guidance (RESEARCH.md 2.3)",
|
| 67 |
+
structure_hint="Gemini's guidance: context first, question last, and always include a worked example (few-shot).",
|
| 68 |
+
format_mechanism="Show one example of the exact output you want — Gemini docs treat few-shot as the primary format lever. Phrase rules positively (\"do X\", not \"don't do Y\").",
|
| 69 |
+
grounding_mechanism="Place the source text before the question and add a positive rule: base every sentence on the text above.",
|
| 70 |
+
),
|
| 71 |
+
ModelFamily.OPEN_WEIGHTS: Profile(
|
| 72 |
+
family=ModelFamily.OPEN_WEIGHTS, display="open-weights model",
|
| 73 |
+
source="lost-in-the-middle + instruction-following headroom notes (RESEARCH.md 2.7/Part 4)",
|
| 74 |
+
structure_hint="Smaller instruction-following headroom: keep the prompt short, one requirement per line, no nested asks.",
|
| 75 |
+
format_mechanism="Spell out the format mechanically and REPEAT the critical constraint as the last line — end-of-prompt text gets the most attention.",
|
| 76 |
+
grounding_mechanism="Keep the source short and close to the question; instruct it to copy exact phrases from the source rather than paraphrase.",
|
| 77 |
+
),
|
| 78 |
+
ModelFamily.GENERIC: Profile(
|
| 79 |
+
family=ModelFamily.GENERIC, display="the model",
|
| 80 |
+
source="cross-provider consensus (RESEARCH.md Part 2)",
|
| 81 |
+
structure_hint="Separate instructions from content with clear delimiters.",
|
| 82 |
+
format_mechanism="State the output format explicitly and restate the critical constraint at the end of the prompt.",
|
| 83 |
+
grounding_mechanism="Label the source text clearly and instruct the model to use only that text.",
|
| 84 |
+
),
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def get_profile(model_name: str) -> Profile:
|
| 89 |
+
return PROFILES[detect_family(model_name)]
|
rubriq/scoring/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .base import Scorer
|
| 2 |
+
from .local import LocalScorer
|
| 3 |
+
|
| 4 |
+
__all__ = ["Scorer", "LocalScorer"]
|
rubriq/scoring/base.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The Scorer adapter interface (required by brief).
|
| 2 |
+
|
| 3 |
+
LocalScorer implements it today with deterministic/statistical methods.
|
| 4 |
+
LLMJudgeScorer will implement the same contract later, in two configurations:
|
| 5 |
+
BYO-key (free tier) and our-key (paid tier). Nothing downstream — API,
|
| 6 |
+
frontend, tests — may import anything but this interface and the schemas.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from abc import ABC, abstractmethod
|
| 12 |
+
|
| 13 |
+
from ..schemas import EvalReport, EvalRequest
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Scorer(ABC):
|
| 17 |
+
"""Turns an EvalRequest into an EvalReport. Implementations must set
|
| 18 |
+
EvalReport.engine and fill honesty_notes with whatever the engine
|
| 19 |
+
cannot judge."""
|
| 20 |
+
|
| 21 |
+
name: str = "abstract"
|
| 22 |
+
|
| 23 |
+
@abstractmethod
|
| 24 |
+
def evaluate(self, request: EvalRequest) -> EvalReport:
|
| 25 |
+
...
|
rubriq/scoring/local.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LocalScorer: the v1 engine. Deterministic + statistical, no API calls."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from ..classify import classify
|
| 6 |
+
from ..prompts.generator import generate_improvement_prompts
|
| 7 |
+
from ..schemas import EvalReport, EvalRequest, OverallScore, TaskType
|
| 8 |
+
from .base import Scorer
|
| 9 |
+
from .parameters import EvalContext
|
| 10 |
+
from .taxonomy import TAXONOMY
|
| 11 |
+
|
| 12 |
+
_CARVEOUT_NOTES = {
|
| 13 |
+
"open_world": ("Factual accuracy against world knowledge is not verifiable locally; "
|
| 14 |
+
"Rubriq reports fabrication-risk signals instead. LLM-judge mode adds verification."),
|
| 15 |
+
"proxy": ("Parameters marked 'proxy' measure statistical stand-ins (diversity, rhythm, flow), "
|
| 16 |
+
"not the subjective quality itself."),
|
| 17 |
+
"static_code": "Code is analyzed statically, never executed; parsing cleanly does not mean it runs correctly.",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class LocalScorer(Scorer):
|
| 22 |
+
name = "local"
|
| 23 |
+
|
| 24 |
+
def evaluate(self, request: EvalRequest) -> EvalReport:
|
| 25 |
+
task = classify(request.prompt, request.output)
|
| 26 |
+
ctx = EvalContext(
|
| 27 |
+
prompt=request.prompt,
|
| 28 |
+
output=request.output,
|
| 29 |
+
task=task,
|
| 30 |
+
audience=request.options.audience,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
specs = TAXONOMY[task.task_type]
|
| 34 |
+
params = []
|
| 35 |
+
weights: dict[str, float] = {}
|
| 36 |
+
for evaluator, default_w in specs:
|
| 37 |
+
score = evaluator(ctx)
|
| 38 |
+
params.append(score)
|
| 39 |
+
weights[score.parameter] = request.options.weights.get(score.parameter, default_w)
|
| 40 |
+
|
| 41 |
+
scored = [ps for ps in params if ps.score is not None and weights.get(ps.parameter, 0) > 0]
|
| 42 |
+
if scored:
|
| 43 |
+
total_w = sum(weights[ps.parameter] for ps in scored)
|
| 44 |
+
overall_val = sum(ps.score * weights[ps.parameter] for ps in scored) / total_w
|
| 45 |
+
else:
|
| 46 |
+
overall_val = 0.0
|
| 47 |
+
skipped = [ps.display_name for ps in params if ps.score is None]
|
| 48 |
+
note = (f"Weighted over {len(scored)} locally scoreable parameters"
|
| 49 |
+
+ (f"; not included: {', '.join(skipped)}" if skipped else "") + ".")
|
| 50 |
+
|
| 51 |
+
honesty: list[str] = []
|
| 52 |
+
if task.task_type in (TaskType.QA_OPEN, TaskType.GENERAL, TaskType.REASONING) or not task.has_source_text:
|
| 53 |
+
honesty.append(_CARVEOUT_NOTES["open_world"])
|
| 54 |
+
if any(ps.is_proxy for ps in params):
|
| 55 |
+
honesty.append(_CARVEOUT_NOTES["proxy"])
|
| 56 |
+
if task.task_type == TaskType.CODE_GENERATION:
|
| 57 |
+
honesty.append(_CARVEOUT_NOTES["static_code"])
|
| 58 |
+
|
| 59 |
+
prompts = generate_improvement_prompts(
|
| 60 |
+
model_name=request.model_name,
|
| 61 |
+
task=task,
|
| 62 |
+
parameters=params,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
return EvalReport(
|
| 66 |
+
task=task,
|
| 67 |
+
parameters=params,
|
| 68 |
+
overall=OverallScore(score=round(overall_val, 1), note=note),
|
| 69 |
+
improvement_prompts=prompts,
|
| 70 |
+
honesty_notes=honesty,
|
| 71 |
+
engine=self.name,
|
| 72 |
+
)
|
rubriq/scoring/parameters.py
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Parameter evaluators: metric results -> ParameterScore with verdicts,
|
| 2 |
+
strengths, findings, and evidence quotes. Each evaluator names its rubric
|
| 3 |
+
source (RESEARCH.md entry). Scores are 0-100; None means "could not judge",
|
| 4 |
+
with the reason stated — never a silently invented number.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass, field
|
| 10 |
+
|
| 11 |
+
from .. import constraints as con
|
| 12 |
+
from ..metrics import (code_static, faithfulness, hallucination_risk, lexical,
|
| 13 |
+
readability, relevance, structure)
|
| 14 |
+
from ..schemas import (Evidence, FailureMode, Finding, ParameterScore,
|
| 15 |
+
TaskClassification, TaskType)
|
| 16 |
+
from ..textutils import snippet, split_sentences, word_count, words
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class EvalContext:
|
| 21 |
+
"""Shared inputs + lazily cached metric results for one evaluation."""
|
| 22 |
+
prompt: str
|
| 23 |
+
output: str
|
| 24 |
+
task: TaskClassification
|
| 25 |
+
audience: str = ""
|
| 26 |
+
_cache: dict = field(default_factory=dict)
|
| 27 |
+
|
| 28 |
+
@property
|
| 29 |
+
def instruction(self) -> str:
|
| 30 |
+
return self.task.instruction or self.prompt
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def source(self) -> str | None:
|
| 34 |
+
return self.task.source_text
|
| 35 |
+
|
| 36 |
+
def get(self, key: str, fn):
|
| 37 |
+
if key not in self._cache:
|
| 38 |
+
self._cache[key] = fn()
|
| 39 |
+
return self._cache[key]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _clamp(x: float) -> float:
|
| 43 |
+
return max(0.0, min(100.0, round(x, 1)))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _band(x: float, floor: float, ceil: float) -> float:
|
| 47 |
+
"""Linear map [floor..ceil] -> [0..100]."""
|
| 48 |
+
if ceil <= floor:
|
| 49 |
+
return 50.0
|
| 50 |
+
return _clamp(100 * (x - floor) / (ceil - floor))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ------------------------------------------------------------ evaluators
|
| 54 |
+
|
| 55 |
+
def instruction_following(ctx: EvalContext) -> ParameterScore:
|
| 56 |
+
results = ctx.get("constraints", lambda: con.run_checks(ctx.instruction, ctx.output))
|
| 57 |
+
src = "IFEval (Zhou et al. 2023)"
|
| 58 |
+
if not results:
|
| 59 |
+
return ParameterScore(
|
| 60 |
+
parameter="instruction_following", display_name="Instruction following",
|
| 61 |
+
score=None, verdict="No verifiable constraints (length, format, required content) detected in the prompt.",
|
| 62 |
+
skipped_reason="nothing checkable was asked", rubric_source=src)
|
| 63 |
+
passed = [r for r in results if r.passed]
|
| 64 |
+
findings = con.to_findings(results)
|
| 65 |
+
strengths = [f"Met: {r.constraint.description} ({r.detail})" for r in passed]
|
| 66 |
+
score = _clamp(100 * len(passed) / len(results))
|
| 67 |
+
verdict = (f"Met {len(passed)} of {len(results)} verifiable constraints extracted from the prompt."
|
| 68 |
+
if findings else f"All {len(results)} verifiable constraints met.")
|
| 69 |
+
return ParameterScore(
|
| 70 |
+
parameter="instruction_following", display_name="Instruction following",
|
| 71 |
+
score=score, verdict=verdict, strengths=strengths, findings=findings,
|
| 72 |
+
rubric_source=src)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def faithfulness_param(ctx: EvalContext) -> ParameterScore:
|
| 76 |
+
src = "SummaC (Laban et al. 2022) / RAGAS (Es et al. 2024)"
|
| 77 |
+
if not ctx.source:
|
| 78 |
+
return ParameterScore(
|
| 79 |
+
parameter="faithfulness", display_name="Faithfulness to source",
|
| 80 |
+
score=None, verdict="No source text found in the prompt to check against.",
|
| 81 |
+
skipped_reason="no source text", rubric_source=src)
|
| 82 |
+
result = ctx.get("faithfulness", lambda: faithfulness.compute(ctx.source, ctx.output))
|
| 83 |
+
if result is None:
|
| 84 |
+
return ParameterScore(
|
| 85 |
+
parameter="faithfulness", display_name="Faithfulness to source",
|
| 86 |
+
score=None, verdict="NLI model unavailable on this machine.",
|
| 87 |
+
skipped_reason="nli model not loaded", rubric_source=src)
|
| 88 |
+
findings: list[Finding] = []
|
| 89 |
+
for s in result.unsupported:
|
| 90 |
+
findings.append(Finding(
|
| 91 |
+
failure_mode=FailureMode.UNFAITHFUL_CONTENT,
|
| 92 |
+
detail=f"This claim has no support in the source (entailment {s.entailment:.2f}).",
|
| 93 |
+
evidence=[Evidence(quote=snippet(s.sentence)),
|
| 94 |
+
Evidence(quote=s.best_chunk, source="prompt", note="closest source passage")],
|
| 95 |
+
data={"entailment": s.entailment}))
|
| 96 |
+
for s in result.contradicted:
|
| 97 |
+
if all(s.sentence != f.evidence[0].quote for f in findings if f.evidence):
|
| 98 |
+
findings.append(Finding(
|
| 99 |
+
failure_mode=FailureMode.UNFAITHFUL_CONTENT,
|
| 100 |
+
detail=f"The source appears to contradict this claim (contradiction {s.contradiction:.2f}).",
|
| 101 |
+
evidence=[Evidence(quote=snippet(s.sentence))],
|
| 102 |
+
data={"contradiction": s.contradiction}))
|
| 103 |
+
n = len(result.sentences)
|
| 104 |
+
supported = n - len(result.unsupported)
|
| 105 |
+
strengths = [f"{supported} of {n} output claims are entailed by the source."] if supported else []
|
| 106 |
+
return ParameterScore(
|
| 107 |
+
parameter="faithfulness", display_name="Faithfulness to source",
|
| 108 |
+
score=_clamp(result.mean_entailment * 100),
|
| 109 |
+
verdict=(f"{supported}/{n} claims supported by the source; "
|
| 110 |
+
f"{len(result.unsupported)} unsupported" +
|
| 111 |
+
(f", {len(result.contradicted)} possibly contradicted." if result.contradicted else ".")),
|
| 112 |
+
strengths=strengths, findings=findings, rubric_source=src)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def answer_relevance(ctx: EvalContext) -> ParameterScore:
|
| 116 |
+
src = "RAGAS answer relevancy (Es et al. 2024) / TruLens RAG triad"
|
| 117 |
+
result = ctx.get("relevance", lambda: relevance.compute(ctx.instruction, ctx.output))
|
| 118 |
+
if result is None:
|
| 119 |
+
return ParameterScore(
|
| 120 |
+
parameter="answer_relevance", display_name="Relevance to the request",
|
| 121 |
+
score=None, verdict="Embedding model unavailable on this machine.",
|
| 122 |
+
skipped_reason="embedding model not loaded", rubric_source=src)
|
| 123 |
+
findings: list[Finding] = []
|
| 124 |
+
if result.worst_paragraph is not None and result.worst_paragraph_sim is not None \
|
| 125 |
+
and result.worst_paragraph_sim < 0.45 and result.overall >= 0.45:
|
| 126 |
+
findings.append(Finding(
|
| 127 |
+
failure_mode=FailureMode.OFF_TOPIC_DRIFT,
|
| 128 |
+
detail=f"One section drifts from the request (similarity {result.worst_paragraph_sim:.2f} vs {result.overall:.2f} overall).",
|
| 129 |
+
evidence=[Evidence(quote=result.worst_paragraph)],
|
| 130 |
+
data={"paragraph_sim": result.worst_paragraph_sim}))
|
| 131 |
+
if result.overall < 0.45:
|
| 132 |
+
findings.append(Finding(
|
| 133 |
+
failure_mode=FailureMode.OFF_TOPIC_DRIFT,
|
| 134 |
+
detail=f"The output as a whole tracks the request weakly (semantic similarity {result.overall:.2f}).",
|
| 135 |
+
evidence=[Evidence(quote=snippet(ctx.instruction), source="prompt", note="the request")],
|
| 136 |
+
data={"overall_sim": result.overall}))
|
| 137 |
+
score = _band(result.overall, 0.35, 0.80)
|
| 138 |
+
strengths = ["Output stays on the topic of the request."] if not findings else []
|
| 139 |
+
return ParameterScore(
|
| 140 |
+
parameter="answer_relevance", display_name="Relevance to the request",
|
| 141 |
+
score=score,
|
| 142 |
+
verdict=f"Semantic similarity between request and output: {result.overall:.2f}.",
|
| 143 |
+
strengths=strengths, findings=findings, rubric_source=src)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def coherence_proxy(ctx: EvalContext) -> ParameterScore:
|
| 147 |
+
src = "Coh-Metrix tradition (Graesser et al. 2004), embedding adaptation"
|
| 148 |
+
result = ctx.get("coherence", lambda: structure.coherence_proxy(ctx.output))
|
| 149 |
+
if result is None:
|
| 150 |
+
return ParameterScore(
|
| 151 |
+
parameter="coherence", display_name="Coherence (proxy)",
|
| 152 |
+
score=None, verdict="Output too short for flow analysis, or embedding model unavailable.",
|
| 153 |
+
skipped_reason="too short or model unavailable", is_proxy=True, rubric_source=src)
|
| 154 |
+
findings: list[Finding] = []
|
| 155 |
+
for a, b, sim in result.flagged_low:
|
| 156 |
+
findings.append(Finding(
|
| 157 |
+
failure_mode=FailureMode.POOR_STRUCTURE,
|
| 158 |
+
detail=f"Abrupt topic jump between adjacent sections (similarity {sim}).",
|
| 159 |
+
evidence=[Evidence(quote=a, note="…followed by…"), Evidence(quote=b)]))
|
| 160 |
+
for a, b, sim in result.flagged_high:
|
| 161 |
+
findings.append(Finding(
|
| 162 |
+
failure_mode=FailureMode.POOR_STRUCTURE,
|
| 163 |
+
detail=f"Near-duplicate adjacent sections (similarity {sim}) — the output repeats itself.",
|
| 164 |
+
evidence=[Evidence(quote=a), Evidence(quote=b)]))
|
| 165 |
+
score = _clamp(100 - 18 * len(result.flagged_low) - 12 * len(result.flagged_high))
|
| 166 |
+
return ParameterScore(
|
| 167 |
+
parameter="coherence", display_name="Coherence (proxy)",
|
| 168 |
+
score=score,
|
| 169 |
+
verdict=("Flow between sections reads consistent." if not findings
|
| 170 |
+
else f"{len(findings)} flow problems between adjacent sections."),
|
| 171 |
+
strengths=["No abrupt jumps or repeated sections detected."] if not findings else [],
|
| 172 |
+
findings=findings, is_proxy=True, rubric_source=src)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def conciseness(ctx: EvalContext) -> ParameterScore:
|
| 176 |
+
src = "MT-Bench verbosity-bias finding (Zheng et al. 2023), inverted"
|
| 177 |
+
checks = ctx.get("constraints", lambda: con.run_checks(ctx.instruction, ctx.output))
|
| 178 |
+
expected = None
|
| 179 |
+
for r in checks:
|
| 180 |
+
p = r.constraint.params
|
| 181 |
+
if p.get("unit") == "word" and p.get("bound") in ("max", "approx", "exact"):
|
| 182 |
+
expected = p["n"]
|
| 183 |
+
result = structure.conciseness(ctx.instruction, ctx.output, expected)
|
| 184 |
+
findings: list[Finding] = []
|
| 185 |
+
if result.filler_hits:
|
| 186 |
+
findings.append(Finding(
|
| 187 |
+
failure_mode=FailureMode.VERBOSITY,
|
| 188 |
+
detail=f"Filler phrasing adds words without content: {', '.join(repr(h) for h in result.filler_hits[:4])}.",
|
| 189 |
+
evidence=[Evidence(quote=h) for h in result.filler_hits[:3]]))
|
| 190 |
+
if result.ratio is not None and result.ratio > 1.3:
|
| 191 |
+
findings.append(Finding(
|
| 192 |
+
failure_mode=FailureMode.VERBOSITY,
|
| 193 |
+
detail=f"Output is {result.output_words} words against an expected ~{result.expected_max} ({result.ratio}x).",
|
| 194 |
+
data={"ratio": result.ratio}))
|
| 195 |
+
score = _clamp(100 - 8 * len(result.filler_hits) - (25 if result.ratio and result.ratio > 1.3 else 0))
|
| 196 |
+
return ParameterScore(
|
| 197 |
+
parameter="conciseness", display_name="Conciseness",
|
| 198 |
+
score=score,
|
| 199 |
+
verdict=(f"{result.output_words} words, no filler detected." if not findings
|
| 200 |
+
else f"{result.output_words} words with padding present."),
|
| 201 |
+
strengths=["No filler phrases; length fits the ask."] if not findings else [],
|
| 202 |
+
findings=findings, rubric_source=src)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def readability_audience(ctx: EvalContext) -> ParameterScore:
|
| 206 |
+
src = "Flesch (1948) / Kincaid et al. (1975)"
|
| 207 |
+
stats = readability.compute(ctx.output)
|
| 208 |
+
if stats is None:
|
| 209 |
+
return ParameterScore(
|
| 210 |
+
parameter="readability", display_name="Readability vs. audience",
|
| 211 |
+
score=None, verdict="Output too short for readability statistics.",
|
| 212 |
+
skipped_reason="too short", rubric_source=src)
|
| 213 |
+
(lo, hi), label = readability.audience_band(ctx.audience, ctx.instruction)
|
| 214 |
+
fk = stats.fk_grade
|
| 215 |
+
findings: list[Finding] = []
|
| 216 |
+
if fk < lo or fk > hi:
|
| 217 |
+
direction = "harder" if fk > hi else "simpler"
|
| 218 |
+
findings.append(Finding(
|
| 219 |
+
failure_mode=FailureMode.WRONG_REGISTER,
|
| 220 |
+
detail=(f"Reads at US grade level {fk}, outside the {lo:g}-{hi:g} band for {label} — "
|
| 221 |
+
f"{direction} than the audience needs."),
|
| 222 |
+
data={"fk_grade": fk, "band": [lo, hi], "audience": label}))
|
| 223 |
+
dist = (lo - fk) if fk < lo else (fk - hi)
|
| 224 |
+
score = _clamp(100 - 12 * dist)
|
| 225 |
+
else:
|
| 226 |
+
score = 100.0
|
| 227 |
+
return ParameterScore(
|
| 228 |
+
parameter="readability", display_name="Readability vs. audience",
|
| 229 |
+
score=score,
|
| 230 |
+
verdict=f"Grade level {fk} (Flesch ease {stats.flesch_reading_ease}); target band {lo:g}-{hi:g} for {label}.",
|
| 231 |
+
strengths=[f"Reading level fits {label}."] if not findings else [],
|
| 232 |
+
findings=findings, rubric_source=src)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def hallucination_signals(ctx: EvalContext) -> ParameterScore:
|
| 236 |
+
src = "FActScore precondition (Min et al. 2023); fabricated-citation literature"
|
| 237 |
+
result = hallucination_risk.compute(ctx.output, word_count(ctx.output))
|
| 238 |
+
findings: list[Finding] = []
|
| 239 |
+
for s in result.signals:
|
| 240 |
+
findings.append(Finding(
|
| 241 |
+
failure_mode=FailureMode.HALLUCINATION_RISK,
|
| 242 |
+
detail=f"{s.why} (signal: {s.kind}).",
|
| 243 |
+
evidence=[Evidence(quote=s.quote)],
|
| 244 |
+
data={"kind": s.kind}))
|
| 245 |
+
score = _clamp(100 - 14 * len(result.signals))
|
| 246 |
+
verdict = ("No fabrication-prone patterns detected. This does NOT verify the facts — see honesty notes."
|
| 247 |
+
if not findings else
|
| 248 |
+
f"{len(result.signals)} unverifiable-claim signals found. These are risk flags, not verdicts of falsehood.")
|
| 249 |
+
return ParameterScore(
|
| 250 |
+
parameter="hallucination_risk", display_name="Hallucination risk signals",
|
| 251 |
+
score=score, verdict=verdict,
|
| 252 |
+
strengths=["Output avoids unattributed statistics, invented-looking citations, and vague authority claims."] if not findings else [],
|
| 253 |
+
findings=findings, rubric_source=src)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def factual_accuracy_placeholder(ctx: EvalContext) -> ParameterScore:
|
| 257 |
+
"""The carve-out, rendered as a first-class parameter so the UI shows
|
| 258 |
+
what local scoring cannot do and what judge mode adds."""
|
| 259 |
+
return ParameterScore(
|
| 260 |
+
parameter="factual_accuracy", display_name="Factual accuracy",
|
| 261 |
+
score=None,
|
| 262 |
+
verdict="Verifying claims against world knowledge requires a judge model with external knowledge. "
|
| 263 |
+
"Local mode reports risk signals instead (see Hallucination risk).",
|
| 264 |
+
skipped_reason="requires LLM-judge mode", requires_judge=True,
|
| 265 |
+
rubric_source="FActScore (Min et al. 2023)")
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def creative_proxies(ctx: EvalContext) -> ParameterScore:
|
| 269 |
+
src = "MTLD (McCarthy & Jarvis 2010); cliché/rhythm proxies"
|
| 270 |
+
stats = lexical.compute(ctx.output)
|
| 271 |
+
findings: list[Finding] = []
|
| 272 |
+
score = 100.0
|
| 273 |
+
if stats.cliche_hits:
|
| 274 |
+
findings.append(Finding(
|
| 275 |
+
failure_mode=FailureMode.LOW_DIVERSITY,
|
| 276 |
+
detail=f"Stock phrases weaken the writing: {', '.join(repr(c) for c in stats.cliche_hits[:5])}.",
|
| 277 |
+
evidence=[Evidence(quote=c) for c in stats.cliche_hits[:3]]))
|
| 278 |
+
score -= 10 * min(5, len(stats.cliche_hits))
|
| 279 |
+
if stats.mtld is not None and stats.mtld < 50:
|
| 280 |
+
findings.append(Finding(
|
| 281 |
+
failure_mode=FailureMode.LOW_DIVERSITY,
|
| 282 |
+
detail=f"Low lexical diversity (MTLD {stats.mtld}; fluent varied prose typically sits above ~70).",
|
| 283 |
+
data={"mtld": stats.mtld}))
|
| 284 |
+
score -= 20
|
| 285 |
+
if stats.sentence_len_cv is not None and stats.sentence_len_cv < 0.25:
|
| 286 |
+
findings.append(Finding(
|
| 287 |
+
failure_mode=FailureMode.LOW_DIVERSITY,
|
| 288 |
+
detail=f"Flat sentence rhythm (length variation {stats.sentence_len_cv}) — sentences are all the same size.",
|
| 289 |
+
data={"cv": stats.sentence_len_cv}))
|
| 290 |
+
score -= 15
|
| 291 |
+
if stats.repeated_ngrams:
|
| 292 |
+
findings.append(Finding(
|
| 293 |
+
failure_mode=FailureMode.LOW_DIVERSITY,
|
| 294 |
+
detail="Repeated phrasing: " + "; ".join(f'"{g}"' for g in stats.repeated_ngrams[:3]),
|
| 295 |
+
evidence=[Evidence(quote=g) for g in stats.repeated_ngrams[:3]]))
|
| 296 |
+
score -= 10
|
| 297 |
+
strengths = []
|
| 298 |
+
if stats.mtld and stats.mtld >= 70:
|
| 299 |
+
strengths.append(f"Strong lexical variety (MTLD {stats.mtld}).")
|
| 300 |
+
if stats.sentence_len_cv and stats.sentence_len_cv >= 0.4:
|
| 301 |
+
strengths.append("Varied sentence rhythm.")
|
| 302 |
+
if not stats.cliche_hits:
|
| 303 |
+
strengths.append("No stock phrases detected.")
|
| 304 |
+
return ParameterScore(
|
| 305 |
+
parameter="creative_proxies", display_name="Style proxies (diversity, clichés, rhythm)",
|
| 306 |
+
score=_clamp(score),
|
| 307 |
+
verdict="Statistical style proxies only — creativity itself needs a human or judge model.",
|
| 308 |
+
strengths=strengths, findings=findings, is_proxy=True, rubric_source=src)
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def code_validity(ctx: EvalContext) -> ParameterScore:
|
| 312 |
+
src = "static ladder; execution reserved per HumanEval (Chen et al. 2021)"
|
| 313 |
+
result = ctx.get("code", lambda: code_static.compute(ctx.instruction, ctx.output))
|
| 314 |
+
findings: list[Finding] = []
|
| 315 |
+
if result.prose_only:
|
| 316 |
+
findings.append(Finding(
|
| 317 |
+
failure_mode=FailureMode.INVALID_CODE,
|
| 318 |
+
detail="The prompt asks for code but the output contains none.",
|
| 319 |
+
evidence=[Evidence(quote=snippet(ctx.instruction), source="prompt")]))
|
| 320 |
+
return ParameterScore(
|
| 321 |
+
parameter="code_validity", display_name="Code validity (static)",
|
| 322 |
+
score=0.0, verdict="No code found in the output.", findings=findings, rubric_source=src)
|
| 323 |
+
score = 100.0
|
| 324 |
+
for b in result.blocks:
|
| 325 |
+
if b.parses is False:
|
| 326 |
+
findings.append(Finding(
|
| 327 |
+
failure_mode=FailureMode.INVALID_CODE,
|
| 328 |
+
detail=f"{b.language} block does not parse: {b.parse_error}.",
|
| 329 |
+
evidence=[Evidence(quote=snippet(b.code, 200))]))
|
| 330 |
+
score -= 40
|
| 331 |
+
for name in result.requested_names_missing:
|
| 332 |
+
findings.append(Finding(
|
| 333 |
+
failure_mode=FailureMode.INCOMPLETE_COVERAGE,
|
| 334 |
+
detail=f'The prompt asks for "{name}" but it never appears in the code.',
|
| 335 |
+
data={"missing_name": name}))
|
| 336 |
+
score -= 20
|
| 337 |
+
if result.placeholders:
|
| 338 |
+
findings.append(Finding(
|
| 339 |
+
failure_mode=FailureMode.INVALID_CODE,
|
| 340 |
+
detail="Placeholder stubs left in the code: " + "; ".join(result.placeholders[:3]),
|
| 341 |
+
evidence=[Evidence(quote=p) for p in result.placeholders[:3]]))
|
| 342 |
+
score -= 15
|
| 343 |
+
n_ok = sum(1 for b in result.blocks if b.parses)
|
| 344 |
+
strengths = []
|
| 345 |
+
if n_ok:
|
| 346 |
+
strengths.append(f"{n_ok} code block(s) parse cleanly.")
|
| 347 |
+
if not result.placeholders:
|
| 348 |
+
strengths.append("No TODO/placeholder stubs.")
|
| 349 |
+
return ParameterScore(
|
| 350 |
+
parameter="code_validity", display_name="Code validity (static)",
|
| 351 |
+
score=_clamp(score),
|
| 352 |
+
verdict=("Code parses and covers the requested surface (static checks only — not executed)."
|
| 353 |
+
if not findings else "Static analysis found problems (code was not executed)."),
|
| 354 |
+
strengths=strengths, findings=findings, rubric_source=src)
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
_STOPWORDS = frozenset(
|
| 358 |
+
"the a an of to in on at for with and or is are was were did do does what who "
|
| 359 |
+
"when where why how which whom whose it its they their this that these those "
|
| 360 |
+
"there here be been has have had will would can could should you your".split())
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def _distinctive_words(part: str, other_parts: list[str]) -> set[str]:
|
| 364 |
+
"""Content words unique to this question part (5-char prefixes so
|
| 365 |
+
'funded' matches 'funding')."""
|
| 366 |
+
shared = {w[:5] for other in other_parts for w in words(other)}
|
| 367 |
+
return {w[:5] for w in words(part) if w not in _STOPWORDS and len(w) >= 4 and w[:5] not in shared}
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def question_coverage(ctx: EvalContext) -> ParameterScore:
|
| 371 |
+
"""Multi-part questions: is every part addressed?
|
| 372 |
+
|
| 373 |
+
PROXY, honestly bounded: bi-encoders measure topicality, not answerhood,
|
| 374 |
+
so a part is only flagged when its topic is missing — low embedding
|
| 375 |
+
similarity to every output sentence, or its distinctive keywords absent.
|
| 376 |
+
A question that is mentioned but left unanswered can slip through;
|
| 377 |
+
judge mode closes that gap.
|
| 378 |
+
"""
|
| 379 |
+
src = "completeness adaptation of IFEval / RAGAS (topical proxy)"
|
| 380 |
+
parts = [s for s in split_sentences(ctx.instruction) if s.strip().endswith("?")]
|
| 381 |
+
if len(parts) < 2:
|
| 382 |
+
return ParameterScore(
|
| 383 |
+
parameter="question_coverage", display_name="Question coverage (proxy)",
|
| 384 |
+
score=None, verdict="Single-part question; coverage check not applicable.",
|
| 385 |
+
skipped_reason="not multi-part", is_proxy=True, rubric_source=src)
|
| 386 |
+
|
| 387 |
+
from ..models import get_embedder
|
| 388 |
+
embedder = get_embedder()
|
| 389 |
+
if embedder is None:
|
| 390 |
+
return ParameterScore(
|
| 391 |
+
parameter="question_coverage", display_name="Question coverage (proxy)",
|
| 392 |
+
score=None, verdict="Embedding model unavailable.",
|
| 393 |
+
skipped_reason="embedding model not loaded", is_proxy=True, rubric_source=src)
|
| 394 |
+
|
| 395 |
+
out_sents = split_sentences(ctx.output) or [ctx.output]
|
| 396 |
+
qv = embedder.encode(parts, normalize_embeddings=True)
|
| 397 |
+
ov = embedder.encode(out_sents, normalize_embeddings=True)
|
| 398 |
+
low_out_prefixes = {w[:5] for w in words(ctx.output)}
|
| 399 |
+
|
| 400 |
+
findings: list[Finding] = []
|
| 401 |
+
hit = 0
|
| 402 |
+
for i, part in enumerate(parts):
|
| 403 |
+
best_sim = float(max(qv[i] @ v for v in ov))
|
| 404 |
+
distinctive = _distinctive_words(part, parts[:i] + parts[i + 1:])
|
| 405 |
+
keywords_present = (not distinctive) or any(d in low_out_prefixes for d in distinctive)
|
| 406 |
+
if best_sim < 0.45 or (best_sim < 0.68 and not keywords_present):
|
| 407 |
+
findings.append(Finding(
|
| 408 |
+
failure_mode=FailureMode.INCOMPLETE_COVERAGE,
|
| 409 |
+
detail=(f"This part of the question appears unaddressed — its key terms never appear in the "
|
| 410 |
+
f"output (best sentence similarity {best_sim:.2f})."),
|
| 411 |
+
evidence=[Evidence(quote=part, source="prompt")],
|
| 412 |
+
data={"similarity": round(best_sim, 3)}))
|
| 413 |
+
else:
|
| 414 |
+
hit += 1
|
| 415 |
+
return ParameterScore(
|
| 416 |
+
parameter="question_coverage", display_name="Question coverage (proxy)",
|
| 417 |
+
score=_clamp(100 * hit / len(parts)),
|
| 418 |
+
verdict=(f"{hit} of {len(parts)} question parts topically addressed. "
|
| 419 |
+
"Topical check only — a part mentioned but not truly answered can pass."),
|
| 420 |
+
strengths=[f"All {len(parts)} question parts are addressed in the output."] if not findings else [],
|
| 421 |
+
findings=findings, is_proxy=True, rubric_source=src)
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def meaning_preservation(ctx: EvalContext) -> ParameterScore:
|
| 425 |
+
src = "embedding similarity (RAGAS-style), rewriting adaptation"
|
| 426 |
+
if not ctx.source:
|
| 427 |
+
return ParameterScore(
|
| 428 |
+
parameter="meaning_preservation", display_name="Meaning preservation",
|
| 429 |
+
score=None, verdict="Original text not found in the prompt.",
|
| 430 |
+
skipped_reason="no source text", rubric_source=src)
|
| 431 |
+
from ..metrics.relevance import similarity
|
| 432 |
+
sim = similarity(ctx.source, ctx.output)
|
| 433 |
+
if sim is None:
|
| 434 |
+
return ParameterScore(
|
| 435 |
+
parameter="meaning_preservation", display_name="Meaning preservation",
|
| 436 |
+
score=None, verdict="Embedding model unavailable.",
|
| 437 |
+
skipped_reason="embedding model not loaded", rubric_source=src)
|
| 438 |
+
findings: list[Finding] = []
|
| 439 |
+
if sim < 0.6:
|
| 440 |
+
findings.append(Finding(
|
| 441 |
+
failure_mode=FailureMode.UNFAITHFUL_CONTENT,
|
| 442 |
+
detail=f"The rewrite drifts from the original's meaning (similarity {sim:.2f}).",
|
| 443 |
+
data={"similarity": sim}))
|
| 444 |
+
return ParameterScore(
|
| 445 |
+
parameter="meaning_preservation", display_name="Meaning preservation",
|
| 446 |
+
score=_band(sim, 0.45, 0.9),
|
| 447 |
+
verdict=f"Semantic similarity to the original: {sim:.2f}.",
|
| 448 |
+
strengths=[f"Rewrite preserves the original's meaning (similarity {sim:.2f})."] if not findings else [],
|
| 449 |
+
findings=findings, rubric_source=src)
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
def extraction_grounding(ctx: EvalContext) -> ParameterScore:
|
| 453 |
+
"""No-invention check: extracted values must appear in the source."""
|
| 454 |
+
src = "no-invention adaptation of faithfulness (RESEARCH.md 1.2/1.9)"
|
| 455 |
+
if not ctx.source:
|
| 456 |
+
return ParameterScore(
|
| 457 |
+
parameter="extraction_grounding", display_name="Extracted values appear in source",
|
| 458 |
+
score=None, verdict="No source text found to validate extractions against.",
|
| 459 |
+
skipped_reason="no source text", rubric_source=src)
|
| 460 |
+
import json
|
| 461 |
+
import re as _re
|
| 462 |
+
values: list[str] = []
|
| 463 |
+
try:
|
| 464 |
+
fence = _re.search(r"```(?:json)?\s*(.+?)```", ctx.output, flags=_re.S)
|
| 465 |
+
data = json.loads((fence.group(1) if fence else ctx.output).strip())
|
| 466 |
+
|
| 467 |
+
def walk(node):
|
| 468 |
+
if isinstance(node, dict):
|
| 469 |
+
for v in node.values():
|
| 470 |
+
walk(v)
|
| 471 |
+
elif isinstance(node, list):
|
| 472 |
+
for v in node:
|
| 473 |
+
walk(v)
|
| 474 |
+
elif isinstance(node, str) and 2 <= len(node) <= 80:
|
| 475 |
+
values.append(node)
|
| 476 |
+
walk(data)
|
| 477 |
+
except (json.JSONDecodeError, AttributeError):
|
| 478 |
+
values = [_re.sub(r"^[-*\d.)\s]+", "", ln).strip()
|
| 479 |
+
for ln in ctx.output.splitlines() if ln.strip()]
|
| 480 |
+
values = [v for v in values if 2 <= len(v) <= 80]
|
| 481 |
+
if not values:
|
| 482 |
+
return ParameterScore(
|
| 483 |
+
parameter="extraction_grounding", display_name="Extracted values appear in source",
|
| 484 |
+
score=None, verdict="Could not isolate extracted values in the output.",
|
| 485 |
+
skipped_reason="no parseable values", rubric_source=src)
|
| 486 |
+
low_src = ctx.source.lower()
|
| 487 |
+
invented = [v for v in values if v.lower() not in low_src]
|
| 488 |
+
findings = [Finding(
|
| 489 |
+
failure_mode=FailureMode.INVENTED_FIELDS,
|
| 490 |
+
detail="Extracted value does not appear in the source text.",
|
| 491 |
+
evidence=[Evidence(quote=v)]) for v in invented[:5]]
|
| 492 |
+
ok = len(values) - len(invented)
|
| 493 |
+
return ParameterScore(
|
| 494 |
+
parameter="extraction_grounding", display_name="Extracted values appear in source",
|
| 495 |
+
score=_clamp(100 * ok / len(values)),
|
| 496 |
+
verdict=f"{ok} of {len(values)} extracted values found verbatim in the source.",
|
| 497 |
+
strengths=[f"All {len(values)} extracted values trace to the source."] if not invented else [],
|
| 498 |
+
findings=findings, rubric_source=src)
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def reasoning_structure(ctx: EvalContext) -> ParameterScore:
|
| 502 |
+
src = "CoT structure (Wei et al. 2022); derivation-shape heuristics"
|
| 503 |
+
out = ctx.output
|
| 504 |
+
findings: list[Finding] = []
|
| 505 |
+
has_steps = bool(__import__("re").search(
|
| 506 |
+
r"(?im)^(?:step\s*\d|first|then|next|finally|\d+[.)])[,:\s]", out))
|
| 507 |
+
sents = split_sentences(out)
|
| 508 |
+
has_answer = bool(__import__("re").search(
|
| 509 |
+
r"(?i)\b(?:therefore|so|thus|the answer is|equals?|=\s*\S|in total|final answer)\b", out))
|
| 510 |
+
score = 100.0
|
| 511 |
+
if not has_steps and len(sents) <= 2:
|
| 512 |
+
findings.append(Finding(
|
| 513 |
+
failure_mode=FailureMode.WEAK_REASONING,
|
| 514 |
+
detail="The output gives a conclusion with no visible working — nothing to check the answer against.",
|
| 515 |
+
evidence=[Evidence(quote=snippet(out, 200))]))
|
| 516 |
+
score -= 45
|
| 517 |
+
if not has_answer:
|
| 518 |
+
findings.append(Finding(
|
| 519 |
+
failure_mode=FailureMode.WEAK_REASONING,
|
| 520 |
+
detail="No clearly stated final answer found.",
|
| 521 |
+
))
|
| 522 |
+
score -= 30
|
| 523 |
+
strengths = []
|
| 524 |
+
if has_steps:
|
| 525 |
+
strengths.append("Shows step-by-step working.")
|
| 526 |
+
if has_answer:
|
| 527 |
+
strengths.append("States a final answer explicitly.")
|
| 528 |
+
return ParameterScore(
|
| 529 |
+
parameter="reasoning_structure", display_name="Reasoning structure",
|
| 530 |
+
score=_clamp(score),
|
| 531 |
+
verdict=("Derivation shows its work and lands on an answer." if not findings
|
| 532 |
+
else "The derivation's shape has gaps (this checks structure, not mathematical truth)."),
|
| 533 |
+
strengths=strengths, findings=findings, is_proxy=True, rubric_source=src)
|
rubriq/scoring/taxonomy.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Task type -> parameter set with default weights (RESEARCH.md Part 3).
|
| 2 |
+
|
| 3 |
+
Weights are relative within a task and user-overridable via
|
| 4 |
+
EvalOptions.weights (advanced section). Parameters that return score=None
|
| 5 |
+
drop out of the weighted overall automatically.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Callable
|
| 11 |
+
|
| 12 |
+
from . import parameters as p
|
| 13 |
+
from .parameters import EvalContext
|
| 14 |
+
from ..schemas import ParameterScore, TaskType
|
| 15 |
+
|
| 16 |
+
Evaluator = Callable[[EvalContext], ParameterScore]
|
| 17 |
+
|
| 18 |
+
# (evaluator, default weight)
|
| 19 |
+
TAXONOMY: dict[TaskType, list[tuple[Evaluator, float]]] = {
|
| 20 |
+
TaskType.SUMMARIZATION: [
|
| 21 |
+
(p.faithfulness_param, 3.0),
|
| 22 |
+
(p.answer_relevance, 2.0),
|
| 23 |
+
(p.instruction_following, 2.0),
|
| 24 |
+
(p.conciseness, 1.5),
|
| 25 |
+
(p.coherence_proxy, 1.0),
|
| 26 |
+
(p.readability_audience, 1.0),
|
| 27 |
+
],
|
| 28 |
+
TaskType.QA_GROUNDED: [
|
| 29 |
+
(p.faithfulness_param, 3.0),
|
| 30 |
+
(p.answer_relevance, 2.5),
|
| 31 |
+
(p.question_coverage, 2.0),
|
| 32 |
+
(p.instruction_following, 1.5),
|
| 33 |
+
(p.conciseness, 1.0),
|
| 34 |
+
],
|
| 35 |
+
TaskType.QA_OPEN: [
|
| 36 |
+
(p.hallucination_signals, 2.5),
|
| 37 |
+
(p.factual_accuracy_placeholder, 0.0),
|
| 38 |
+
(p.answer_relevance, 2.5),
|
| 39 |
+
(p.question_coverage, 2.0),
|
| 40 |
+
(p.instruction_following, 1.5),
|
| 41 |
+
(p.coherence_proxy, 1.0),
|
| 42 |
+
(p.readability_audience, 1.0),
|
| 43 |
+
],
|
| 44 |
+
TaskType.CODE_GENERATION: [
|
| 45 |
+
(p.code_validity, 3.0),
|
| 46 |
+
(p.instruction_following, 2.0),
|
| 47 |
+
(p.answer_relevance, 1.0),
|
| 48 |
+
(p.conciseness, 0.5),
|
| 49 |
+
],
|
| 50 |
+
TaskType.CREATIVE_WRITING: [
|
| 51 |
+
(p.creative_proxies, 2.5),
|
| 52 |
+
(p.instruction_following, 2.5),
|
| 53 |
+
(p.coherence_proxy, 1.0),
|
| 54 |
+
(p.readability_audience, 1.0),
|
| 55 |
+
],
|
| 56 |
+
TaskType.EXTRACTION: [
|
| 57 |
+
(p.extraction_grounding, 3.0),
|
| 58 |
+
(p.instruction_following, 3.0),
|
| 59 |
+
(p.answer_relevance, 1.0),
|
| 60 |
+
],
|
| 61 |
+
TaskType.REASONING: [
|
| 62 |
+
(p.reasoning_structure, 2.5),
|
| 63 |
+
(p.answer_relevance, 1.5),
|
| 64 |
+
(p.instruction_following, 2.0),
|
| 65 |
+
(p.hallucination_signals, 1.0),
|
| 66 |
+
(p.conciseness, 0.5),
|
| 67 |
+
],
|
| 68 |
+
TaskType.REWRITING: [
|
| 69 |
+
(p.meaning_preservation, 3.0),
|
| 70 |
+
(p.instruction_following, 2.5),
|
| 71 |
+
(p.readability_audience, 1.5),
|
| 72 |
+
(p.creative_proxies, 1.0),
|
| 73 |
+
],
|
| 74 |
+
TaskType.GENERAL: [
|
| 75 |
+
(p.instruction_following, 2.5),
|
| 76 |
+
(p.answer_relevance, 2.0),
|
| 77 |
+
(p.hallucination_signals, 1.5),
|
| 78 |
+
(p.coherence_proxy, 1.0),
|
| 79 |
+
(p.readability_audience, 1.0),
|
| 80 |
+
(p.conciseness, 1.0),
|
| 81 |
+
],
|
| 82 |
+
}
|
tests/samples.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Curated prompt/output pairs with known, intended failure modes.
|
| 2 |
+
Each pair documents what a sensible evaluator MUST notice."""
|
| 3 |
+
|
| 4 |
+
SOURCE_ARTICLE = """\
|
| 5 |
+
The Larsen Glacier expedition of 1987 was led by Norwegian climatologist
|
| 6 |
+
Dr. Ingrid Solheim. Over four months, the twelve-person team drilled ice
|
| 7 |
+
cores to a depth of 800 meters, recovering samples dating back roughly
|
| 8 |
+
12,000 years. The expedition's findings, published in 1989, showed that
|
| 9 |
+
regional temperatures had risen 1.4 degrees Celsius over the preceding
|
| 10 |
+
century. Funding came primarily from the Norwegian Polar Institute, with
|
| 11 |
+
additional support from the University of Bergen. Two team members were
|
| 12 |
+
evacuated in the third month after a crevasse accident, but there were no
|
| 13 |
+
fatalities. The ice core archive remains in Tromsø and is still used by
|
| 14 |
+
researchers today."""
|
| 15 |
+
|
| 16 |
+
# --- summarization ---------------------------------------------------------
|
| 17 |
+
|
| 18 |
+
SUMM_PROMPT = (
|
| 19 |
+
"Summarize the following text in at most 60 words.\n\n" + SOURCE_ARTICLE
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
SUMM_GOOD = (
|
| 23 |
+
"In 1987, Dr. Ingrid Solheim led a twelve-person Norwegian expedition that "
|
| 24 |
+
"drilled 800-meter ice cores on the Larsen Glacier, capturing 12,000 years "
|
| 25 |
+
"of climate data. Published in 1989, the findings showed regional warming "
|
| 26 |
+
"of 1.4 degrees Celsius over a century. Despite a crevasse accident, "
|
| 27 |
+
"no one died."
|
| 28 |
+
) # 51 words, faithful
|
| 29 |
+
|
| 30 |
+
SUMM_BAD = (
|
| 31 |
+
"The famous 1987 Larsen Glacier expedition, led by the celebrated Dr. Ingrid "
|
| 32 |
+
"Solheim, made groundbreaking discoveries that changed climate science forever. "
|
| 33 |
+
"The team of fifteen brave scientists drilled to an unprecedented 1,200 meters, "
|
| 34 |
+
"and tragically three researchers lost their lives in an avalanche. Their "
|
| 35 |
+
"findings proved that global temperatures rose 3 degrees. It is important to "
|
| 36 |
+
"note that this expedition remains a testament to human perseverance and "
|
| 37 |
+
"stands as a pivotal moment in the history of polar research, inspiring "
|
| 38 |
+
"generations of scientists around the world to pursue climate research."
|
| 39 |
+
) # ~85 words: over limit, wrong team size, wrong depth, invented deaths, wrong figure, filler
|
| 40 |
+
|
| 41 |
+
# --- code generation --------------------------------------------------------
|
| 42 |
+
|
| 43 |
+
CODE_PROMPT = (
|
| 44 |
+
"Write a Python function called parse_log_line that takes a log line like "
|
| 45 |
+
"'2024-01-03 ERROR disk full' and returns a dict with keys date, level, "
|
| 46 |
+
"and message."
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
CODE_GOOD = '''Here's the function:
|
| 50 |
+
|
| 51 |
+
```python
|
| 52 |
+
def parse_log_line(line: str) -> dict:
|
| 53 |
+
date, level, message = line.split(" ", 2)
|
| 54 |
+
return {"date": date, "level": level, "message": message}
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
It splits on the first two spaces so messages may contain spaces.'''
|
| 58 |
+
|
| 59 |
+
CODE_BAD = '''```python
|
| 60 |
+
def parse_line(line):
|
| 61 |
+
# TODO: implement parsing logic
|
| 62 |
+
parts = line.split(" "
|
| 63 |
+
return {"date": parts[0]}
|
| 64 |
+
```''' # wrong name, syntax error, TODO stub
|
| 65 |
+
|
| 66 |
+
# --- extraction -------------------------------------------------------------
|
| 67 |
+
|
| 68 |
+
EXTRACT_PROMPT = (
|
| 69 |
+
"Extract all person names and institutions from the following text as JSON "
|
| 70 |
+
'with keys "people" and "institutions".\n\n' + SOURCE_ARTICLE
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
EXTRACT_GOOD = """{"people": ["Dr. Ingrid Solheim"], "institutions": ["Norwegian Polar Institute", "University of Bergen"]}"""
|
| 74 |
+
|
| 75 |
+
EXTRACT_BAD = """{"people": ["Dr. Ingrid Solheim", "Dr. Erik Larsen"], "institutions": ["Norwegian Polar Institute", "Oslo Climate Center"]}"""
|
| 76 |
+
# Erik Larsen and Oslo Climate Center are invented
|
| 77 |
+
|
| 78 |
+
# --- open-world QA ----------------------------------------------------------
|
| 79 |
+
|
| 80 |
+
QA_OPEN_PROMPT = "What are the health benefits of intermittent fasting?"
|
| 81 |
+
|
| 82 |
+
QA_OPEN_RISKY = (
|
| 83 |
+
"Intermittent fasting has remarkable health benefits. Studies show that it "
|
| 84 |
+
"extends lifespan by 23.5% and reduces cancer risk by 41%. According to "
|
| 85 |
+
"Dr. James Hoffman of Harvard Medical School, \"fasting is the single most "
|
| 86 |
+
"powerful intervention in modern medicine\" (Hoffman et al., 2019). Research "
|
| 87 |
+
"published at https://fasting-institute.org/study confirms these findings."
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# --- creative writing -------------------------------------------------------
|
| 91 |
+
|
| 92 |
+
CREATIVE_PROMPT = "Write a short story opening about a sailor returning home after ten years at sea."
|
| 93 |
+
|
| 94 |
+
CREATIVE_CLICHED = (
|
| 95 |
+
"At the end of the day, the old sailor knew that home was where the heart is. "
|
| 96 |
+
"Little did he know what awaited him. His heart skipped a beat as the ship "
|
| 97 |
+
"pulled into the bustling harbor, a whirlwind of emotions washing over him. "
|
| 98 |
+
"In the blink of an eye, ten long years had passed, and time stood still as "
|
| 99 |
+
"he gazed upon the picturesque village nestled in the hills, a hidden gem "
|
| 100 |
+
"against all odds. It was a testament to his perseverance."
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# --- multi-part grounded QA -------------------------------------------------
|
| 104 |
+
|
| 105 |
+
QA_MULTI_PROMPT = (
|
| 106 |
+
"Based on the following text, answer these questions. Who led the expedition? "
|
| 107 |
+
"How deep did they drill? Who funded it?\n\n"
|
| 108 |
+
+ SOURCE_ARTICLE
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
QA_MULTI_PARTIAL = (
|
| 112 |
+
"The expedition was led by Dr. Ingrid Solheim, a Norwegian climatologist. "
|
| 113 |
+
"The team drilled ice cores to a depth of 800 meters over four months."
|
| 114 |
+
) # funding question ignored — its key terms never appear in the answer
|
| 115 |
+
|
| 116 |
+
# Documented limitation of the topical coverage proxy: a question whose TOPIC
|
| 117 |
+
# appears in the answer but is not actually answered will pass. Kept as a test
|
| 118 |
+
# so the limitation stays visible.
|
| 119 |
+
QA_MULTI_TOPIC_MENTIONED = (
|
| 120 |
+
"Based on the following text, answer these questions. Who led the expedition? "
|
| 121 |
+
"What happened to the ice cores afterwards?\n\n" + SOURCE_ARTICLE
|
| 122 |
+
)
|
tests/test_classifier.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Task classification on the sample pairs (rules; embeddings when cached)."""
|
| 2 |
+
|
| 3 |
+
import samples
|
| 4 |
+
from rubriq.classify import classify, split_instruction_and_source
|
| 5 |
+
from rubriq.schemas import TaskType
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_summarization_detected():
|
| 9 |
+
c = classify(samples.SUMM_PROMPT, samples.SUMM_GOOD)
|
| 10 |
+
assert c.task_type == TaskType.SUMMARIZATION
|
| 11 |
+
assert c.has_source_text
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_code_detected():
|
| 15 |
+
c = classify(samples.CODE_PROMPT, samples.CODE_GOOD)
|
| 16 |
+
assert c.task_type == TaskType.CODE_GENERATION
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_extraction_detected():
|
| 20 |
+
c = classify(samples.EXTRACT_PROMPT, samples.EXTRACT_GOOD)
|
| 21 |
+
assert c.task_type == TaskType.EXTRACTION
|
| 22 |
+
assert c.has_source_text
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_creative_detected():
|
| 26 |
+
c = classify(samples.CREATIVE_PROMPT, samples.CREATIVE_CLICHED)
|
| 27 |
+
assert c.task_type == TaskType.CREATIVE_WRITING
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_open_qa_detected():
|
| 31 |
+
c = classify(samples.QA_OPEN_PROMPT, samples.QA_OPEN_RISKY)
|
| 32 |
+
assert c.task_type == TaskType.QA_OPEN
|
| 33 |
+
assert not c.has_source_text
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_grounded_qa_upgrade():
|
| 37 |
+
c = classify(samples.QA_MULTI_PROMPT, samples.QA_MULTI_PARTIAL)
|
| 38 |
+
assert c.task_type == TaskType.QA_GROUNDED
|
| 39 |
+
assert c.has_source_text
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_source_split_keeps_instruction():
|
| 43 |
+
instruction, source = split_instruction_and_source(samples.SUMM_PROMPT)
|
| 44 |
+
assert "at most 60 words" in instruction
|
| 45 |
+
assert source is not None and "Solheim" in source
|
tests/test_constraints.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic constraint extraction + checking. No models needed."""
|
| 2 |
+
|
| 3 |
+
from rubriq.constraints import extract_constraints, run_checks
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def kinds(instruction):
|
| 7 |
+
return {c.kind for c in extract_constraints(instruction)}
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TestExtraction:
|
| 11 |
+
def test_max_words(self):
|
| 12 |
+
assert "max_word" in kinds("Summarize this in at most 150 words.")
|
| 13 |
+
assert "max_word" in kinds("Keep it under 50 words please")
|
| 14 |
+
assert "max_word" in kinds("no more than 100 words")
|
| 15 |
+
|
| 16 |
+
def test_min_and_exact_and_approx(self):
|
| 17 |
+
assert "min_word" in kinds("Write at least 300 words about dogs.")
|
| 18 |
+
assert "exact_sentence" in kinds("Answer in exactly two sentences.")
|
| 19 |
+
assert "approx_word" in kinds("Write about 200 words on this topic.")
|
| 20 |
+
|
| 21 |
+
def test_bullets_and_format(self):
|
| 22 |
+
assert "max_bullet" in kinds("Give me at most 5 bullet points.")
|
| 23 |
+
assert "format_json" in kinds("Return the result as JSON.")
|
| 24 |
+
assert "format_json" in kinds("Output valid JSON only")
|
| 25 |
+
assert "format_table" in kinds("Present this as a markdown table.")
|
| 26 |
+
assert "single_paragraph" in kinds("Explain it in a single paragraph.")
|
| 27 |
+
|
| 28 |
+
def test_include_exclude(self):
|
| 29 |
+
ks = extract_constraints('Include the word "sustainability" and don\'t use the word "synergy".')
|
| 30 |
+
got = {(c.kind, c.params.get("term")) for c in ks}
|
| 31 |
+
assert ("must_include", "sustainability") in got
|
| 32 |
+
assert ("must_exclude", "synergy") in got
|
| 33 |
+
|
| 34 |
+
def test_no_false_positives_on_plain_prompt(self):
|
| 35 |
+
assert kinds("Tell me about the history of Rome.") == set()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class TestChecking:
|
| 39 |
+
def test_word_limit_violation_detected(self):
|
| 40 |
+
results = run_checks("Summarize in at most 10 words.", "This output has quite a few more than ten words in it, clearly.")
|
| 41 |
+
r = next(r for r in results if r.constraint.kind == "max_word")
|
| 42 |
+
assert not r.passed
|
| 43 |
+
|
| 44 |
+
def test_word_limit_pass(self):
|
| 45 |
+
results = run_checks("Summarize in at most 10 words.", "Seven words are in this sentence, exactly.")
|
| 46 |
+
r = next(r for r in results if r.constraint.kind == "max_word")
|
| 47 |
+
assert r.passed
|
| 48 |
+
|
| 49 |
+
def test_json_validity(self):
|
| 50 |
+
ok = run_checks("Return as JSON.", '{"a": 1}')
|
| 51 |
+
assert ok[0].passed
|
| 52 |
+
bad = run_checks("Return as JSON.", '{"a": 1,,}')
|
| 53 |
+
assert not bad[0].passed
|
| 54 |
+
assert "position" in bad[0].detail
|
| 55 |
+
|
| 56 |
+
def test_json_in_fence_ok(self):
|
| 57 |
+
ok = run_checks("Return as JSON.", '```json\n{"a": 1}\n```')
|
| 58 |
+
assert ok[0].passed
|
| 59 |
+
|
| 60 |
+
def test_must_exclude_quotes_evidence(self):
|
| 61 |
+
results = run_checks('Avoid using the word "leverage".', "We can leverage this approach.")
|
| 62 |
+
r = next(r for r in results if r.constraint.kind == "must_exclude")
|
| 63 |
+
assert not r.passed
|
| 64 |
+
assert r.evidence and "leverage" in r.evidence[0].quote.lower()
|
| 65 |
+
|
| 66 |
+
def test_code_fence_excluded_from_word_count(self):
|
| 67 |
+
prompt = "Explain in at most 20 words."
|
| 68 |
+
output = "Short answer here, well within limits.\n```python\n" + "x = 1\n" * 200 + "```"
|
| 69 |
+
r = next(r for r in run_checks(prompt, output) if r.constraint.kind == "max_word")
|
| 70 |
+
assert r.passed
|
tests/test_prompts.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The flagship contract: improvement prompts must name the diagnosed
|
| 2 |
+
problem, quote evidence, be paste-ready, and use the named model family's
|
| 3 |
+
mechanism. Generic advice is a test failure by design."""
|
| 4 |
+
|
| 5 |
+
import samples
|
| 6 |
+
from rubriq.schemas import EvalRequest, FailureMode, ModelFamily
|
| 7 |
+
from rubriq.scoring import LocalScorer
|
| 8 |
+
|
| 9 |
+
scorer = LocalScorer()
|
| 10 |
+
|
| 11 |
+
GENERIC_PHRASES = [
|
| 12 |
+
"be more specific", "add more detail", "improve your prompt",
|
| 13 |
+
"be clearer", "provide more context",
|
| 14 |
+
]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def run(prompt, output, model=""):
|
| 18 |
+
return scorer.evaluate(EvalRequest(prompt=prompt, output=output, model_name=model))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_bad_summary_generates_prompts():
|
| 22 |
+
report = run(samples.SUMM_PROMPT, samples.SUMM_BAD, model="gpt-4o")
|
| 23 |
+
assert report.improvement_prompts, "a failing output must generate repairs"
|
| 24 |
+
modes = {p.failure_mode for p in report.improvement_prompts}
|
| 25 |
+
assert FailureMode.UNFAITHFUL_CONTENT in modes
|
| 26 |
+
assert FailureMode.MISSED_CONSTRAINT in modes or FailureMode.VERBOSITY in modes
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_no_generic_advice():
|
| 30 |
+
for pair in [(samples.SUMM_PROMPT, samples.SUMM_BAD), (samples.CODE_PROMPT, samples.CODE_BAD)]:
|
| 31 |
+
report = run(*pair, model="gpt-4o")
|
| 32 |
+
for ip in report.improvement_prompts:
|
| 33 |
+
low = (ip.diagnosis + " " + ip.prompt_text).lower()
|
| 34 |
+
for phrase in GENERIC_PHRASES:
|
| 35 |
+
assert phrase not in low, f"generic advice '{phrase}' in {ip.title}"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_diagnosis_names_the_evidence():
|
| 39 |
+
report = run(samples.SUMM_PROMPT, samples.SUMM_BAD, model="gpt-4o")
|
| 40 |
+
unfaithful = next(p for p in report.improvement_prompts
|
| 41 |
+
if p.failure_mode == FailureMode.UNFAITHFUL_CONTENT)
|
| 42 |
+
# The repair prompt must carry the actual invented claims back to the model.
|
| 43 |
+
assert '"' in unfaithful.prompt_text
|
| 44 |
+
assert "previous attempt" in unfaithful.prompt_text.lower()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_prompt_contains_original_instruction():
|
| 48 |
+
report = run(samples.SUMM_PROMPT, samples.SUMM_BAD, model="gpt-4o")
|
| 49 |
+
for ip in report.improvement_prompts:
|
| 50 |
+
assert "60 words" in ip.prompt_text or "Summarize" in ip.prompt_text, \
|
| 51 |
+
"repair prompts must be complete replacements, not advice"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_claude_gets_xml_mechanism():
|
| 55 |
+
report = run(samples.SUMM_PROMPT, samples.SUMM_BAD, model="claude sonnet")
|
| 56 |
+
unfaithful = next(p for p in report.improvement_prompts
|
| 57 |
+
if p.failure_mode == FailureMode.UNFAITHFUL_CONTENT)
|
| 58 |
+
assert unfaithful.model_family == ModelFamily.CLAUDE
|
| 59 |
+
assert "<quotes>" in unfaithful.prompt_text or "<source>" in unfaithful.prompt_text
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_gpt_gets_delimiters_and_tail_emphasis():
|
| 63 |
+
report = run(samples.SUMM_PROMPT, samples.SUMM_BAD, model="gpt-4o")
|
| 64 |
+
unfaithful = next(p for p in report.improvement_prompts
|
| 65 |
+
if p.failure_mode == FailureMode.UNFAITHFUL_CONTENT)
|
| 66 |
+
assert unfaithful.model_family == ModelFamily.GPT
|
| 67 |
+
assert "IMPORTANT" in unfaithful.prompt_text or "###" in unfaithful.prompt_text
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_gemini_gets_fewshot_for_format():
|
| 71 |
+
report = run(samples.SUMM_PROMPT, samples.SUMM_BAD, model="gemini-1.5-pro")
|
| 72 |
+
constraint = next((p for p in report.improvement_prompts
|
| 73 |
+
if p.failure_mode in (FailureMode.MISSED_CONSTRAINT, FailureMode.FORMAT_VIOLATION)), None)
|
| 74 |
+
if constraint:
|
| 75 |
+
assert "example" in constraint.prompt_text.lower()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_code_repair_names_defects():
|
| 79 |
+
report = run(samples.CODE_PROMPT, samples.CODE_BAD)
|
| 80 |
+
code = next(p for p in report.improvement_prompts if p.failure_mode == FailureMode.INVALID_CODE)
|
| 81 |
+
assert "parse_log_line" in code.prompt_text # the missing requested name travels into the repair
|
| 82 |
+
assert "fix each" in code.prompt_text.lower()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_technique_sources_cited():
|
| 86 |
+
report = run(samples.SUMM_PROMPT, samples.SUMM_BAD, model="gpt-4o")
|
| 87 |
+
for ip in report.improvement_prompts:
|
| 88 |
+
assert ip.technique_source.strip(), "every template must cite its source"
|
tests/test_scoring.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end: LocalScorer must behave sensibly on the curated pairs.
|
| 2 |
+
"Sensibly" is concrete: good outputs beat bad outputs, known flaws are
|
| 3 |
+
found and quoted, and improvement prompts obey the no-generic-advice
|
| 4 |
+
contract. Requires the two local models (cached by warmup)."""
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
import samples
|
| 9 |
+
from rubriq.schemas import EvalRequest, FailureMode
|
| 10 |
+
from rubriq.scoring import LocalScorer
|
| 11 |
+
|
| 12 |
+
scorer = LocalScorer()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def run(prompt, output, model=""):
|
| 16 |
+
return scorer.evaluate(EvalRequest(prompt=prompt, output=output, model_name=model))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.fixture(scope="module")
|
| 20 |
+
def summ_good():
|
| 21 |
+
return run(samples.SUMM_PROMPT, samples.SUMM_GOOD)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@pytest.fixture(scope="module")
|
| 25 |
+
def summ_bad():
|
| 26 |
+
return run(samples.SUMM_PROMPT, samples.SUMM_BAD, model="gpt-4o")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class TestSummarization:
|
| 30 |
+
def test_good_beats_bad_overall(self, summ_good, summ_bad):
|
| 31 |
+
assert summ_good.overall.score > summ_bad.overall.score + 10
|
| 32 |
+
|
| 33 |
+
def test_bad_fails_word_limit(self, summ_bad):
|
| 34 |
+
instr = next(p for p in summ_bad.parameters if p.parameter == "instruction_following")
|
| 35 |
+
assert any(f.failure_mode in (FailureMode.MISSED_CONSTRAINT, FailureMode.FORMAT_VIOLATION)
|
| 36 |
+
for f in instr.findings)
|
| 37 |
+
|
| 38 |
+
def test_good_passes_word_limit(self, summ_good):
|
| 39 |
+
instr = next(p for p in summ_good.parameters if p.parameter == "instruction_following")
|
| 40 |
+
assert instr.score == 100.0
|
| 41 |
+
|
| 42 |
+
def test_bad_flagged_unfaithful(self, summ_bad):
|
| 43 |
+
faith = next(p for p in summ_bad.parameters if p.parameter == "faithfulness")
|
| 44 |
+
assert faith.score is not None
|
| 45 |
+
assert any(f.failure_mode == FailureMode.UNFAITHFUL_CONTENT for f in faith.findings)
|
| 46 |
+
|
| 47 |
+
def test_good_faithfulness_beats_bad(self, summ_good, summ_bad):
|
| 48 |
+
fg = next(p for p in summ_good.parameters if p.parameter == "faithfulness")
|
| 49 |
+
fb = next(p for p in summ_bad.parameters if p.parameter == "faithfulness")
|
| 50 |
+
assert fg.score > fb.score
|
| 51 |
+
|
| 52 |
+
def test_findings_quote_the_output(self, summ_bad):
|
| 53 |
+
faith = next(p for p in summ_bad.parameters if p.parameter == "faithfulness")
|
| 54 |
+
quoted = [e.quote for f in faith.findings for e in f.evidence if e.source == "output"]
|
| 55 |
+
assert quoted, "unfaithfulness findings must quote the offending sentences"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class TestCode:
|
| 59 |
+
def test_bad_code_flagged(self):
|
| 60 |
+
report = run(samples.CODE_PROMPT, samples.CODE_BAD)
|
| 61 |
+
code = next(p for p in report.parameters if p.parameter == "code_validity")
|
| 62 |
+
details = " ".join(f.detail for f in code.findings)
|
| 63 |
+
assert "parse" in details.lower()
|
| 64 |
+
assert "parse_log_line" in details # requested name missing
|
| 65 |
+
|
| 66 |
+
def test_good_code_clean(self):
|
| 67 |
+
report = run(samples.CODE_PROMPT, samples.CODE_GOOD)
|
| 68 |
+
code = next(p for p in report.parameters if p.parameter == "code_validity")
|
| 69 |
+
assert code.score >= 80
|
| 70 |
+
|
| 71 |
+
def test_honesty_note_static_only(self):
|
| 72 |
+
report = run(samples.CODE_PROMPT, samples.CODE_GOOD)
|
| 73 |
+
assert any("never executed" in n for n in report.honesty_notes)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class TestExtraction:
|
| 77 |
+
def test_invented_values_caught(self):
|
| 78 |
+
report = run(samples.EXTRACT_PROMPT, samples.EXTRACT_BAD)
|
| 79 |
+
g = next(p for p in report.parameters if p.parameter == "extraction_grounding")
|
| 80 |
+
invented = [e.quote for f in g.findings for e in f.evidence]
|
| 81 |
+
assert any("Erik Larsen" in q for q in invented)
|
| 82 |
+
assert any("Oslo Climate Center" in q for q in invented)
|
| 83 |
+
|
| 84 |
+
def test_clean_extraction_scores_full(self):
|
| 85 |
+
report = run(samples.EXTRACT_PROMPT, samples.EXTRACT_GOOD)
|
| 86 |
+
g = next(p for p in report.parameters if p.parameter == "extraction_grounding")
|
| 87 |
+
assert g.score == 100.0
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class TestOpenQA:
|
| 91 |
+
def test_risk_signals_found(self):
|
| 92 |
+
report = run(samples.QA_OPEN_PROMPT, samples.QA_OPEN_RISKY)
|
| 93 |
+
risk = next(p for p in report.parameters if p.parameter == "hallucination_risk")
|
| 94 |
+
kinds = {f.data.get("kind") for f in risk.findings}
|
| 95 |
+
assert "citation_pattern" in kinds
|
| 96 |
+
assert "precise_statistic" in kinds
|
| 97 |
+
assert "url" in kinds
|
| 98 |
+
|
| 99 |
+
def test_factual_accuracy_declared_out_of_scope(self):
|
| 100 |
+
report = run(samples.QA_OPEN_PROMPT, samples.QA_OPEN_RISKY)
|
| 101 |
+
fact = next(p for p in report.parameters if p.parameter == "factual_accuracy")
|
| 102 |
+
assert fact.score is None and fact.requires_judge
|
| 103 |
+
|
| 104 |
+
def test_carveout_honesty_note_present(self):
|
| 105 |
+
report = run(samples.QA_OPEN_PROMPT, samples.QA_OPEN_RISKY)
|
| 106 |
+
assert any("judge" in n.lower() for n in report.honesty_notes)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class TestCreative:
|
| 110 |
+
def test_cliches_flagged_as_proxy(self):
|
| 111 |
+
report = run(samples.CREATIVE_PROMPT, samples.CREATIVE_CLICHED)
|
| 112 |
+
style = next(p for p in report.parameters if p.parameter == "creative_proxies")
|
| 113 |
+
assert style.is_proxy
|
| 114 |
+
assert any(f.failure_mode == FailureMode.LOW_DIVERSITY for f in style.findings)
|
| 115 |
+
quoted = " ".join(e.quote for f in style.findings for e in f.evidence)
|
| 116 |
+
assert "at the end of the day" in quoted or "heart skipped a beat" in quoted
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class TestCoverage:
|
| 120 |
+
def test_unanswered_part_found(self):
|
| 121 |
+
report = run(samples.QA_MULTI_PROMPT, samples.QA_MULTI_PARTIAL)
|
| 122 |
+
cov = next(p for p in report.parameters if p.parameter == "question_coverage")
|
| 123 |
+
assert cov.score is not None and cov.score < 100
|
| 124 |
+
assert cov.is_proxy
|
| 125 |
+
quoted = " ".join(e.quote for f in cov.findings for e in f.evidence)
|
| 126 |
+
assert "funded" in quoted.lower() # the ignored funding question
|
| 127 |
+
|
| 128 |
+
def test_known_limitation_topic_mentioned_passes(self):
|
| 129 |
+
"""Documents the proxy's honest limit: a question whose topic appears
|
| 130 |
+
in the answer (ice cores) passes the topical check even though it was
|
| 131 |
+
not truly answered. Judge mode is the fix, not a stricter threshold."""
|
| 132 |
+
report = run(samples.QA_MULTI_TOPIC_MENTIONED, samples.QA_MULTI_PARTIAL)
|
| 133 |
+
cov = next(p for p in report.parameters if p.parameter == "question_coverage")
|
| 134 |
+
assert cov.score == 100.0
|
| 135 |
+
assert "topical" in cov.verdict.lower()
|