devadit15 Claude Fable 5 commited on
Commit
4b5b8fc
·
1 Parent(s): e184df7

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 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. **Step 1 underway**: research →
126
- RESEARCH.md → classifier → taxonomy → scoring engine → prompt generator →
127
- tests. Next stop: Checkpoint 2 (user review + Supabase credentials).
 
 
 
 
 
 
 
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))\s*[:.]?\s*\n"
 
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) >= 40:
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) >= 150 and _looks_instructional(head):
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"(?:use|mention|include|say)(?:\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})[\"”']")
 
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-]+(?: (?:et al\.?|&|and) [A-Z][A-Za-z-]+)?,? (?: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"),
 
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()