devadit15 Claude Fable 5 commited on
Commit
e184df7
·
1 Parent(s): 945f4a0

Step 1: package scaffold, classifier, constraints, metric modules

Browse files

- schemas: EvalRequest/EvalReport contracts with is_proxy/requires_judge
honesty flags baked into the type layer
- classify: rule votes + bge prototype referee + instruction/source split
- constraints: IFEval-style extraction and deterministic checkers
- metrics: readability (Flesch/FK bands), lexical (MTLD, cliches, rhythm),
relevance (bge, per-paragraph drift), faithfulness (SummaC-ZS over
nli-deberta-v3-small), hallucination risk signals, static code ladder

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

pyproject.toml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "rubriq"
3
+ version = "0.1.0"
4
+ description = "Score any LLM's output locally - no API calls - and get research-grounded prompts that fix what failed."
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "fastapi>=0.110",
8
+ "uvicorn>=0.29",
9
+ "pydantic>=2.6",
10
+ "numpy>=1.26",
11
+ "sentence-transformers>=3.0",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "pytest>=8.0",
17
+ "httpx>=0.27",
18
+ ]
19
+
20
+ [tool.setuptools.packages.find]
21
+ include = ["rubriq*"]
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
rubriq/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Rubriq: local, research-grounded LLM output quality evaluation."""
2
+
3
+ __version__ = "0.1.0"
rubriq/classify.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task classification: rule signals first, embedding prototypes as referee.
2
+
3
+ Design (RESEARCH.md 1.8 adaptation + standard practice):
4
+ - High-precision regex/structure rules vote on task type. A confident rule
5
+ vote decides directly — "Summarize the following" needs no neural network.
6
+ - When rules disagree or stay silent, the instruction is embedded and
7
+ compared against prototype sentences per task type (bge-small).
8
+ - Separately, the prompt is split into instruction vs. pasted source
9
+ material; source presence turns QA into QA_GROUNDED and unlocks the
10
+ NLI faithfulness parameter.
11
+
12
+ Every fired signal is recorded and surfaced in the UI, so a misclassification
13
+ is diagnosable by the user (and overridable in a later UI iteration).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+
20
+ from .models import get_embedder
21
+ from .schemas import TaskClassification, TaskType
22
+ from .textutils import word_count
23
+
24
+ # ---------------------------------------------------------------- source split
25
+
26
+ _DELIM_BLOCK = re.compile(
27
+ r"(?s)(?:\"{3}|'{3}|`{3}|<{2,}|={3,}|-{3,}|###)\s*\n?(.+?)(?:\"{3}|'{3}|`{3}|>{2,}|={3,}|-{3,}|###|\Z)"
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
+
35
+ def split_instruction_and_source(prompt: str) -> tuple[str, str | None]:
36
+ """Separate the user's instruction from pasted source material.
37
+
38
+ Order of attempts: explicit delimiter blocks, then "the following text:"
39
+ intros, then a length heuristic (one short leading/trailing instruction
40
+ line attached to a long body). Conservative: when unsure, the whole
41
+ prompt is instruction and no faithfulness checking happens — a false
42
+ source is worse than a missed one because it would generate false
43
+ unfaithfulness findings.
44
+ """
45
+ m = _DELIM_BLOCK.search(prompt)
46
+ if m and word_count(m.group(1)) >= 40:
47
+ source = m.group(1).strip()
48
+ instruction = (prompt[:m.start()] + " " + prompt[m.end():]).strip()
49
+ if instruction:
50
+ return instruction, source
51
+
52
+ m = _SOURCE_INTRO.search(prompt)
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
67
+
68
+
69
+ def _looks_instructional(line: str) -> bool:
70
+ return bool(re.search(
71
+ r"(?i)\b(summari[sz]e|rewrite|translate|extract|answer|explain|list|analy[sz]e|"
72
+ r"convert|classify|describe|edit|fix|review|compare|based on|according to)\b",
73
+ line,
74
+ )) or line.rstrip().endswith("?")
75
+
76
+
77
+ # ---------------------------------------------------------------- rule signals
78
+
79
+ _RULES: list[tuple[TaskType, str, re.Pattern]] = [
80
+ (TaskType.SUMMARIZATION, "prompt asks to summarize",
81
+ re.compile(r"(?i)\b(summari[sz]e|summary|tl;?dr|condense|key points? of|main points? of|abstract of)\b")),
82
+ (TaskType.CODE_GENERATION, "prompt asks for code",
83
+ re.compile(r"(?i)\b(write|create|implement|generate|fix|debug|refactor)\b.{0,60}\b(function|class|script|code|program|method|module|regex|query|endpoint|component)\b")),
84
+ (TaskType.CODE_GENERATION, "prompt names a programming language",
85
+ re.compile(r"(?i)\bin (python|javascript|typescript|java|c\+\+|c#|go|rust|sql|html|css|bash|powershell)\b")),
86
+ (TaskType.CREATIVE_WRITING, "prompt asks for creative writing",
87
+ re.compile(r"(?i)\b(write|compose|craft)\b.{0,50}\b(story|poem|song|haiku|novel|fiction|tale|screenplay|lyrics|limerick)\b")),
88
+ (TaskType.EXTRACTION, "prompt asks to extract structured data",
89
+ re.compile(r"(?i)\b(extract|pull out|identify all|list (?:all|every))\b.{0,80}\b(names?|dates?|entities|emails?|numbers?|fields?|values?|keywords?|items)\b")),
90
+ (TaskType.EXTRACTION, "prompt requests JSON/CSV/table output from text",
91
+ re.compile(r"(?i)\b(as|in|into|to) (?:a |valid )?(json|csv|yaml|xml|table)\b")),
92
+ (TaskType.REASONING, "prompt poses a math/logic problem",
93
+ re.compile(r"(?i)\b(calculate|compute|solve|prove|how many|what is the (?:probability|total|sum|difference|product)|step[- ]by[- ]step|logic puzzle|riddle)\b")),
94
+ (TaskType.REWRITING, "prompt asks to rewrite/edit existing text",
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"```")
101
+
102
+
103
+ def _rule_votes(instruction: str, output: str) -> list[tuple[TaskType, str]]:
104
+ votes: list[tuple[TaskType, str]] = []
105
+ for task, why, pat in _RULES:
106
+ if pat.search(instruction):
107
+ votes.append((task, why))
108
+ if _CODE_FENCE.search(output) and word_count(re.sub(r"```.*?```", "", output, flags=re.S)) < word_count(output) * 0.5:
109
+ votes.append((TaskType.CODE_GENERATION, "output is mostly code blocks"))
110
+ return votes
111
+
112
+
113
+ # ------------------------------------------------------------ prototype backup
114
+
115
+ _PROTOTYPES: dict[TaskType, list[str]] = {
116
+ TaskType.SUMMARIZATION: [
117
+ "Summarize this article in a few sentences.",
118
+ "Give me the key points of the following document.",
119
+ ],
120
+ TaskType.CODE_GENERATION: [
121
+ "Write a Python function that parses a CSV file.",
122
+ "Implement a REST endpoint that returns user data.",
123
+ ],
124
+ TaskType.CREATIVE_WRITING: [
125
+ "Write a short story about a lighthouse keeper.",
126
+ "Compose a poem about autumn in free verse.",
127
+ ],
128
+ TaskType.EXTRACTION: [
129
+ "Extract all person names and dates from this text as JSON.",
130
+ "List every product mentioned in the reviews below.",
131
+ ],
132
+ TaskType.REASONING: [
133
+ "If a train leaves at 3pm traveling 60 mph, when does it arrive?",
134
+ "Solve this logic puzzle and show your reasoning.",
135
+ ],
136
+ TaskType.REWRITING: [
137
+ "Rewrite this paragraph in a more formal tone.",
138
+ "Paraphrase the following text for a younger audience.",
139
+ ],
140
+ TaskType.QA_OPEN: [
141
+ "What are the main causes of inflation?",
142
+ "Explain how photosynthesis works.",
143
+ ],
144
+ TaskType.GENERAL: [
145
+ "Help me draft an email to my landlord.",
146
+ "Give me advice on preparing for a job interview.",
147
+ ],
148
+ }
149
+
150
+ _proto_cache: dict | None = None
151
+
152
+
153
+ def _prototype_scores(instruction: str) -> dict[TaskType, float] | None:
154
+ global _proto_cache
155
+ embedder = get_embedder()
156
+ if embedder is None:
157
+ return None
158
+ import numpy as np
159
+ if _proto_cache is None:
160
+ flat, owners = [], []
161
+ for task, examples in _PROTOTYPES.items():
162
+ flat.extend(examples)
163
+ owners.extend([task] * len(examples))
164
+ vecs = embedder.encode(flat, normalize_embeddings=True)
165
+ _proto_cache = (np.asarray(vecs), owners)
166
+ vecs, owners = _proto_cache
167
+ q = embedder.encode([instruction], normalize_embeddings=True)[0]
168
+ sims = vecs @ q
169
+ best: dict[TaskType, float] = {}
170
+ for owner, sim in zip(owners, sims):
171
+ best[owner] = max(best.get(owner, -1.0), float(sim))
172
+ return best
173
+
174
+
175
+ # ------------------------------------------------------------------- classifier
176
+
177
+ def classify(prompt: str, output: str) -> TaskClassification:
178
+ instruction, source = split_instruction_and_source(prompt)
179
+ votes = _rule_votes(instruction, output)
180
+ signals = [why for _, why in votes]
181
+
182
+ vote_counts: dict[TaskType, int] = {}
183
+ for task, _ in votes:
184
+ vote_counts[task] = vote_counts.get(task, 0) + 1
185
+
186
+ task: TaskType
187
+ confidence: float
188
+
189
+ if vote_counts:
190
+ ranked = sorted(vote_counts.items(), key=lambda kv: -kv[1])
191
+ if len(ranked) == 1 or ranked[0][1] > ranked[1][1]:
192
+ task, confidence = ranked[0][0], min(0.95, 0.7 + 0.1 * ranked[0][1])
193
+ else:
194
+ # Tie -> embeddings referee among tied candidates.
195
+ tied = {t for t, c in ranked if c == ranked[0][1]}
196
+ proto = _prototype_scores(instruction)
197
+ if proto:
198
+ task = max(tied, key=lambda t: proto.get(t, -1.0))
199
+ confidence = 0.6
200
+ signals.append("embedding similarity broke a rule tie")
201
+ else:
202
+ task, confidence = ranked[0][0], 0.5
203
+ signals.append("rule tie, embeddings unavailable; first match kept")
204
+ else:
205
+ proto = _prototype_scores(instruction)
206
+ if proto:
207
+ task = max(proto, key=proto.get) # type: ignore[arg-type]
208
+ top = proto[task]
209
+ task = task if top >= 0.45 else TaskType.GENERAL
210
+ confidence = round(min(0.85, max(0.35, top)), 2)
211
+ signals.append(f"embedding prototype match ({task.value}, sim={top:.2f})")
212
+ else:
213
+ task, confidence = TaskType.GENERAL, 0.3
214
+ signals.append("no rule matched, embeddings unavailable")
215
+
216
+ # Source text upgrades/refines the task.
217
+ if source is not None:
218
+ signals.append("prompt contains pasted source text")
219
+ if task == TaskType.QA_OPEN:
220
+ task = TaskType.QA_GROUNDED
221
+ signals.append("question + source text -> grounded Q&A")
222
+
223
+ return TaskClassification(
224
+ task_type=task,
225
+ confidence=confidence,
226
+ signals=signals,
227
+ has_source_text=source is not None,
228
+ instruction=instruction,
229
+ source_text=source,
230
+ )
rubriq/constraints.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Instruction-following: extract verifiable constraints from the prompt,
2
+ check each with code (IFEval approach, RESEARCH.md 1.8).
3
+
4
+ Extraction is deliberately conservative. A constraint only becomes a check
5
+ when the pattern is unambiguous, and every extracted constraint is reported
6
+ to the user whether it passed or failed — so a wrong extraction is visible,
7
+ not a hidden scoring error. Counting checks like these are the case where
8
+ deterministic code beats LLM judges (MT-Bench bias findings, RESEARCH.md 1.6).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ from dataclasses import dataclass, field
16
+
17
+ from .schemas import Evidence, FailureMode, Finding
18
+ from .textutils import snippet, split_paragraphs, split_sentences, word_count
19
+
20
+ _NUM_WORDS = {
21
+ "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6,
22
+ "seven": 7, "eight": 8, "nine": 9, "ten": 10, "a single": 1, "single": 1,
23
+ }
24
+
25
+
26
+ def _num(tok: str) -> int:
27
+ tok = tok.strip().lower()
28
+ return int(tok) if tok.isdigit() else _NUM_WORDS[tok]
29
+
30
+
31
+ _NUM_PAT = r"(\d+|one|two|three|four|five|six|seven|eight|nine|ten|a single|single)"
32
+
33
+
34
+ @dataclass
35
+ class Constraint:
36
+ kind: str # e.g. "max_words", "format_json"
37
+ description: str # human-readable, e.g. "at most 150 words"
38
+ source_quote: str # the prompt text that produced it
39
+ params: dict = field(default_factory=dict)
40
+
41
+
42
+ @dataclass
43
+ class CheckResult:
44
+ constraint: Constraint
45
+ passed: bool
46
+ detail: str # e.g. "212 words (limit 150)"
47
+ evidence: list[Evidence] = field(default_factory=list)
48
+
49
+
50
+ # ---------------------------------------------------------------- extraction
51
+
52
+ _UNIT = r"(words?|sentences?|paragraphs?|bullet ?points?|bullets?|items?|characters?|lines?|sections?)"
53
+
54
+ _LIMIT_PATTERNS = [
55
+ # (regex, bound kind); bound in {"max","min","exact","approx"}
56
+ (re.compile(rf"(?i)\b(?:in|within|under|at most|no more than|not more than|maximum(?: of)?|max\.?|up to|fewer than|less than|no longer than)\s+{_NUM_PAT}\s+{_UNIT}"), "max"),
57
+ (re.compile(rf"(?i)\b(?:at least|minimum(?: of)?|min\.?|no fewer than|more than|over)\s+{_NUM_PAT}\s+{_UNIT}"), "min"),
58
+ (re.compile(rf"(?i)\bexactly\s+{_NUM_PAT}\s+{_UNIT}"), "exact"),
59
+ (re.compile(rf"(?i)\b(?:about|around|approximately|roughly|~)\s*{_NUM_PAT}\s+{_UNIT}"), "approx"),
60
+ (re.compile(rf"(?i)\b{_NUM_PAT}[- ]{_UNIT}\b(?:\s+(?:summary|answer|response|list|essay|description|overview|explanation))"), "approx"),
61
+ ]
62
+
63
+ _STRUCT_PATTERNS: list[tuple[re.Pattern, str, str]] = [
64
+ (re.compile(r"(?i)\b(?:as|in|into|to|return|output|respond(?: only)? (?:in|with)|format(?:ted)? as)\s+(?:a |an |valid |pure |raw )?json\b"), "format_json", "output should be valid JSON"),
65
+ (re.compile(r"(?i)\bas (?:a |an )?(?:markdown )?table\b"), "format_table", "output should be a table"),
66
+ (re.compile(r"(?i)\b(?:as|in|using) (?:a )?(?:bullet(?:ed)?|bulleted) (?:points?|list)\b|\bbullet points? only\b"), "format_bullets", "output should be a bulleted list"),
67
+ (re.compile(r"(?i)\bnumbered list\b"), "format_numbered", "output should be a numbered list"),
68
+ (re.compile(r"(?i)\b(?:in |as )(?:a )?single paragraph\b|\bone paragraph\b"), "single_paragraph", "output should be one paragraph"),
69
+ (re.compile(r"(?i)\bin (?:a )?single sentence\b|\bone sentence\b"), "single_sentence", "output should be one sentence"),
70
+ (re.compile(r"(?i)\ball (?:in )?lower ?case\b|\bin lowercase only\b"), "all_lowercase", "output should be all lowercase"),
71
+ (re.compile(r"(?i)\ball (?:in )?(?:upper ?case|caps)\b"), "all_uppercase", "output should be all uppercase"),
72
+ (re.compile(r"(?i)\bno bullet points?\b|\bwithout (?:any )?bullets?\b|\bdo not use bullet\b"), "no_bullets", "output should not use bullet points"),
73
+ (re.compile(r"(?i)\bin plain (?:text|prose)\b|\bno markdown\b|\bwithout (?:any )?(?:markdown|formatting)\b"), "no_markdown", "output should be plain prose without markdown"),
74
+ ]
75
+
76
+ _INCLUDE_PAT = re.compile(
77
+ r"(?i)\b(?:must (?:include|mention|contain)|be sure to (?:include|mention)|"
78
+ r"include|mention|don'?t forget to (?:include|mention))\s+"
79
+ r"(?:the (?:word|term|phrase)s?\s+)?[\"“']([^\"”']{2,60})[\"”']"
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})[\"”']")
87
+
88
+ _BULLET_LINE = re.compile(r"(?m)^\s*(?:[-*•]|\d+[.)])\s+\S")
89
+ _NUMBERED_LINE = re.compile(r"(?m)^\s*\d+[.)]\s+\S")
90
+ _MD_MARKUP = re.compile(r"(?m)(^#{1,6}\s)|(\*\*)|(^\s*[-*]\s)|(```)|(^\s*>\s)|(\|.+\|)")
91
+
92
+
93
+ def extract_constraints(instruction: str) -> list[Constraint]:
94
+ found: list[Constraint] = []
95
+ seen: set[tuple] = set()
96
+
97
+ for pat, bound in _LIMIT_PATTERNS:
98
+ for m in pat.finditer(instruction):
99
+ n, unit = _num(m.group(1)), m.group(2).lower()
100
+ unit_key = re.sub(r"\s|s$", "", unit)
101
+ unit_key = {"bulletpoint": "bullet", "item": "bullet"}.get(unit_key, unit_key)
102
+ key = (bound, unit_key, n)
103
+ if key in seen:
104
+ continue
105
+ seen.add(key)
106
+ found.append(Constraint(
107
+ kind=f"{bound}_{unit_key}",
108
+ description=f"{ {'max':'at most','min':'at least','exact':'exactly','approx':'about'}[bound] } {n} {unit}",
109
+ source_quote=m.group(0),
110
+ params={"n": n, "bound": bound, "unit": unit_key},
111
+ ))
112
+
113
+ for pat, kind, desc in _STRUCT_PATTERNS:
114
+ m = pat.search(instruction)
115
+ if m and ("struct", kind) not in seen:
116
+ seen.add(("struct", kind))
117
+ found.append(Constraint(kind=kind, description=desc, source_quote=m.group(0)))
118
+
119
+ for m in _INCLUDE_PAT.finditer(instruction):
120
+ term = m.group(1).strip()
121
+ if ("include", term.lower()) not in seen:
122
+ seen.add(("include", term.lower()))
123
+ found.append(Constraint(kind="must_include", description=f'must include "{term}"',
124
+ source_quote=m.group(0), params={"term": term}))
125
+ for m in _EXCLUDE_PAT.finditer(instruction):
126
+ term = m.group(1).strip()
127
+ if ("exclude", term.lower()) not in seen:
128
+ seen.add(("exclude", term.lower()))
129
+ found.append(Constraint(kind="must_exclude", description=f'must not use "{term}"',
130
+ source_quote=m.group(0), params={"term": term}))
131
+ m = _START_PAT.search(instruction)
132
+ if m:
133
+ found.append(Constraint(kind="starts_with", description=f'must start with "{m.group(1)}"',
134
+ source_quote=m.group(0), params={"term": m.group(1)}))
135
+ m = _END_PAT.search(instruction)
136
+ if m:
137
+ found.append(Constraint(kind="ends_with", description=f'must end with "{m.group(1)}"',
138
+ source_quote=m.group(0), params={"term": m.group(1)}))
139
+ return found
140
+
141
+
142
+ # ------------------------------------------------------------------- checking
143
+
144
+ def _count_units(output: str, unit: str) -> int:
145
+ if unit == "word":
146
+ return word_count(output)
147
+ if unit == "sentence":
148
+ return len(split_sentences(output))
149
+ if unit == "paragraph":
150
+ return len(split_paragraphs(output))
151
+ if unit == "character":
152
+ return len(output.strip())
153
+ if unit == "line":
154
+ return len([ln for ln in output.splitlines() if ln.strip()])
155
+ if unit in ("bullet",):
156
+ return len(_BULLET_LINE.findall(output))
157
+ if unit == "section":
158
+ return len(re.findall(r"(?m)^#{1,6}\s+\S", output)) or len(split_paragraphs(output))
159
+ return word_count(output)
160
+
161
+
162
+ def _strip_code_blocks(output: str) -> str:
163
+ return re.sub(r"```.*?```", "", output, flags=re.S)
164
+
165
+
166
+ def check_constraint(c: Constraint, output: str) -> CheckResult:
167
+ kind = c.kind
168
+ if kind.startswith(("max_", "min_", "exact_", "approx_")):
169
+ bound, unit = c.params["bound"], c.params["unit"]
170
+ n = c.params["n"]
171
+ actual = _count_units(output, unit)
172
+ if bound == "max":
173
+ ok = actual <= n
174
+ elif bound == "min":
175
+ ok = actual >= n
176
+ elif bound == "exact":
177
+ ok = actual == n
178
+ else: # approx: ±20% with a floor of ±2
179
+ tol = max(2, round(n * 0.2))
180
+ ok = abs(actual - n) <= tol
181
+ return CheckResult(c, ok, f"{actual} {unit}{'' if actual == 1 else 's'} (asked: {c.description})")
182
+
183
+ if kind == "format_json":
184
+ text = output.strip()
185
+ fence = re.search(r"```(?:json)?\s*(.+?)```", text, flags=re.S)
186
+ candidate = fence.group(1).strip() if fence else text
187
+ try:
188
+ json.loads(candidate)
189
+ extra = text != candidate and not fence
190
+ return CheckResult(c, not extra, "valid JSON" if not extra else "JSON present but wrapped in extra prose")
191
+ except json.JSONDecodeError as e:
192
+ return CheckResult(c, False, f"not valid JSON ({e.msg} at position {e.pos})",
193
+ [Evidence(quote=snippet(candidate[max(0, e.pos - 60):e.pos + 60]), note="around the parse error")])
194
+
195
+ if kind == "format_table":
196
+ ok = bool(re.search(r"(?m)^\|.+\|\s*$", output)) or bool(re.search(r"(?m)^.+\t.+$", output))
197
+ return CheckResult(c, ok, "table detected" if ok else "no table structure found in output")
198
+
199
+ if kind == "format_bullets":
200
+ n = len(_BULLET_LINE.findall(output))
201
+ return CheckResult(c, n >= 2, f"{n} bullet lines found")
202
+
203
+ if kind == "format_numbered":
204
+ n = len(_NUMBERED_LINE.findall(output))
205
+ return CheckResult(c, n >= 2, f"{n} numbered lines found")
206
+
207
+ if kind == "single_paragraph":
208
+ n = len(split_paragraphs(output))
209
+ return CheckResult(c, n == 1, f"{n} paragraphs")
210
+
211
+ if kind == "single_sentence":
212
+ n = len(split_sentences(output))
213
+ return CheckResult(c, n == 1, f"{n} sentences")
214
+
215
+ if kind == "all_lowercase":
216
+ letters = re.sub(r"[^A-Za-z]", "", output)
217
+ ok = letters.islower() if letters else True
218
+ bad = re.search(r"[A-Z][a-zA-Z]*", output)
219
+ ev = [Evidence(quote=snippet(bad.group(0)), note="uppercase found")] if bad else []
220
+ return CheckResult(c, ok, "all lowercase" if ok else "contains uppercase", ev)
221
+
222
+ if kind == "all_uppercase":
223
+ letters = re.sub(r"[^A-Za-z]", "", output)
224
+ ok = letters.isupper() if letters else True
225
+ return CheckResult(c, ok, "all caps" if ok else "contains lowercase")
226
+
227
+ if kind == "no_bullets":
228
+ n = len(_BULLET_LINE.findall(output))
229
+ return CheckResult(c, n == 0, "no bullets" if n == 0 else f"{n} bullet lines present")
230
+
231
+ if kind == "no_markdown":
232
+ m = _MD_MARKUP.search(output)
233
+ return CheckResult(c, m is None, "plain prose" if m is None else "markdown markup present",
234
+ [Evidence(quote=snippet(m.group(0)))] if m else [])
235
+
236
+ if kind == "must_include":
237
+ term = c.params["term"]
238
+ ok = term.lower() in output.lower()
239
+ return CheckResult(c, ok, f'"{term}" {"present" if ok else "missing from output"}')
240
+
241
+ if kind == "must_exclude":
242
+ term = c.params["term"]
243
+ i = output.lower().find(term.lower())
244
+ ok = i < 0
245
+ ev = [] if ok else [Evidence(quote=snippet(output[max(0, i - 50):i + len(term) + 50]))]
246
+ return CheckResult(c, ok, f'"{term}" {"absent" if ok else "appears in output"}', ev)
247
+
248
+ if kind == "starts_with":
249
+ term = c.params["term"]
250
+ ok = output.strip().lower().startswith(term.lower())
251
+ return CheckResult(c, ok, "starts as required" if ok else f'output starts with "{snippet(output.strip(), 50)}"')
252
+
253
+ if kind == "ends_with":
254
+ term = c.params["term"]
255
+ ok = output.strip().rstrip(".!?").lower().endswith(term.lower().rstrip(".!?"))
256
+ return CheckResult(c, ok, "ends as required" if ok else f'output ends with "…{output.strip()[-50:]}"')
257
+
258
+ return CheckResult(c, True, "constraint recognized but no checker bound (reported, not scored)")
259
+
260
+
261
+ def run_checks(instruction: str, output: str) -> list[CheckResult]:
262
+ prose = output
263
+ constraints = extract_constraints(instruction)
264
+ # Word/sentence limits should not count code inside fences for mixed outputs.
265
+ results = []
266
+ for c in constraints:
267
+ target = prose
268
+ if c.kind.split("_")[-1] in ("word", "sentence") and "```" in output:
269
+ target = _strip_code_blocks(output)
270
+ results.append(check_constraint(c, target))
271
+ return results
272
+
273
+
274
+ def to_findings(results: list[CheckResult]) -> list[Finding]:
275
+ findings = []
276
+ for r in results:
277
+ if r.passed:
278
+ continue
279
+ findings.append(Finding(
280
+ failure_mode=FailureMode.FORMAT_VIOLATION if r.constraint.kind.startswith(("format", "single", "no_", "all_"))
281
+ else FailureMode.MISSED_CONSTRAINT,
282
+ detail=f"Prompt asked for {r.constraint.description} — output has {r.detail}.",
283
+ evidence=r.evidence + [Evidence(quote=r.constraint.source_quote, source="prompt", note="the instruction that set this constraint")],
284
+ data={"kind": r.constraint.kind, **r.constraint.params, "actual": r.detail},
285
+ ))
286
+ return findings
rubriq/metrics/__init__.py ADDED
File without changes
rubriq/metrics/code_static.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Static code checks (RESEARCH.md 1.11). No execution in v1 — the ladder:
2
+ does it parse, is requested API surface present, are there placeholder stubs.
3
+ Honesty note attached by the scorer: static analysis cannot prove the code
4
+ runs or is correct; that is HumanEval-style territory reserved for judge mode.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ import re
11
+ from dataclasses import dataclass, field
12
+
13
+ from ..textutils import snippet
14
+
15
+ _FENCE = re.compile(r"```(\w+)?\s*\n(.*?)```", re.S)
16
+ _PLACEHOLDERS = re.compile(
17
+ r"(?i)(?:#|//|/\*)?\s*(TODO|FIXME|your[_ ](?:api[_ ]key|code|logic)[_ ]here|"
18
+ r"implement (?:this|me)|\.\.\.\s*$|<[A-Z_]+>|pass\s*#\s*placeholder)", re.M)
19
+ _REQUESTED_NAME = re.compile(
20
+ r"(?i)\b(?:function|method|class)\s+(?:called|named)\s+[`'\"]?([A-Za-z_][A-Za-z0-9_]*)")
21
+
22
+ _BRACKETS = {"(": ")", "[": "]", "{": "}"}
23
+
24
+
25
+ @dataclass
26
+ class CodeBlock:
27
+ language: str
28
+ code: str
29
+ parses: bool | None # None = no parser for this language, balance-checked only
30
+ parse_error: str = ""
31
+
32
+
33
+ @dataclass
34
+ class CodeStaticResult:
35
+ blocks: list[CodeBlock] = field(default_factory=list)
36
+ has_code: bool = False
37
+ placeholders: list[str] = field(default_factory=list)
38
+ requested_names_missing: list[str] = field(default_factory=list)
39
+ prose_only: bool = False # code was requested but none found
40
+
41
+
42
+ def _balanced(code: str) -> bool:
43
+ """Bracket balance ignoring string literals (rough, for non-Python)."""
44
+ stack = []
45
+ in_str: str | None = None
46
+ prev = ""
47
+ for ch in code:
48
+ if in_str:
49
+ if ch == in_str and prev != "\\":
50
+ in_str = None
51
+ elif ch in "\"'":
52
+ in_str = ch
53
+ elif ch in _BRACKETS:
54
+ stack.append(_BRACKETS[ch])
55
+ elif ch in _BRACKETS.values():
56
+ if not stack or stack.pop() != ch:
57
+ return False
58
+ prev = ch
59
+ return not stack
60
+
61
+
62
+ def compute(instruction: str, output: str) -> CodeStaticResult:
63
+ result = CodeStaticResult()
64
+ fences = _FENCE.findall(output)
65
+
66
+ # Unfenced fallback: output that is obviously bare code.
67
+ if not fences and re.search(r"(?m)^(def |class |function |import |from \w+ import |const |let |public )", output):
68
+ fences = [("", output)]
69
+
70
+ for lang, code in fences:
71
+ lang = (lang or "").lower()
72
+ code = code.strip()
73
+ if not code:
74
+ continue
75
+ block = CodeBlock(language=lang or "unknown", code=code, parses=None)
76
+ if lang in ("python", "py", "") and re.search(r"(?m)^(def |class |import |from )", code):
77
+ block.language = "python"
78
+ try:
79
+ ast.parse(code)
80
+ block.parses = True
81
+ except SyntaxError as e:
82
+ block.parses = False
83
+ block.parse_error = f"line {e.lineno}: {e.msg}"
84
+ elif lang == "json":
85
+ import json
86
+ try:
87
+ json.loads(code)
88
+ block.parses = True
89
+ except json.JSONDecodeError as e:
90
+ block.parses = False
91
+ block.parse_error = e.msg
92
+ else:
93
+ block.parses = None if _balanced(code) else False
94
+ if block.parses is False:
95
+ block.parse_error = "unbalanced brackets/braces"
96
+ result.blocks.append(block)
97
+
98
+ result.has_code = bool(result.blocks)
99
+ result.prose_only = not result.has_code
100
+
101
+ all_code = "\n".join(b.code for b in result.blocks)
102
+ result.placeholders = [snippet(m.group(0), 60) for m in _PLACEHOLDERS.finditer(all_code)][:5]
103
+
104
+ for m in _REQUESTED_NAME.finditer(instruction):
105
+ name = m.group(1)
106
+ if not re.search(rf"\b{re.escape(name)}\b", all_code):
107
+ result.requested_names_missing.append(name)
108
+
109
+ return result
rubriq/metrics/faithfulness.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NLI faithfulness — SummaC-ZS (Laban et al. 2022), RESEARCH.md 1.2.
2
+
3
+ Output sentences are scored for entailment against source chunks with
4
+ cross-encoder/nli-deberta-v3-small. Per output sentence we take the max
5
+ entailment over source chunks ("is this sentence supported anywhere?");
6
+ the document score is the mean; the least-supported sentences become quoted
7
+ evidence for unfaithful_content findings.
8
+
9
+ The cross-encoder returns logits over (contradiction, entailment, neutral)
10
+ — sbert's documented label order for the nli-deberta-v3 family.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+
17
+ from ..models import get_nli
18
+ from ..textutils import snippet, split_sentences, word_count
19
+
20
+ _LABELS = ("contradiction", "entailment", "neutral")
21
+
22
+
23
+ @dataclass
24
+ class SentenceSupport:
25
+ sentence: str
26
+ entailment: float # max over source chunks
27
+ contradiction: float # at the chunk where entailment peaked
28
+ best_chunk: str
29
+
30
+
31
+ @dataclass
32
+ class FaithfulnessResult:
33
+ mean_entailment: float
34
+ sentences: list[SentenceSupport]
35
+ unsupported: list[SentenceSupport] # entailment below threshold
36
+ contradicted: list[SentenceSupport] # contradiction dominant somewhere
37
+ threshold: float
38
+
39
+
40
+ def _chunks(source: str, max_words: int = 90) -> list[str]:
41
+ """Sliding sentence windows so support spanning two sentences isn't missed."""
42
+ sents = split_sentences(source)
43
+ if not sents:
44
+ return [source]
45
+ chunks, cur, cur_w = [], [], 0
46
+ for s in sents:
47
+ w = word_count(s)
48
+ cur.append(s)
49
+ cur_w += w
50
+ if cur_w >= max_words:
51
+ chunks.append(" ".join(cur))
52
+ cur, cur_w = cur[-1:], word_count(cur[-1]) if cur else 0
53
+ if cur:
54
+ chunks.append(" ".join(cur))
55
+ return chunks or [source]
56
+
57
+
58
+ def _score_sentences(sents: list[str]) -> list[str]:
59
+ """Filter out non-claims: headers, tiny fragments, questions."""
60
+ return [s for s in sents if word_count(s) >= 4 and not s.strip().endswith("?")
61
+ and not s.strip().startswith("#")]
62
+
63
+
64
+ def compute(source: str, output: str, threshold: float = 0.5) -> FaithfulnessResult | None:
65
+ nli = get_nli()
66
+ if nli is None:
67
+ return None
68
+ import numpy as np
69
+
70
+ out_sents = _score_sentences(split_sentences(output))
71
+ if not out_sents:
72
+ return None
73
+ chunks = _chunks(source)
74
+
75
+ pairs = [(c, s) for s in out_sents for c in chunks]
76
+ logits = nli.predict(pairs, batch_size=16)
77
+ probs = np.exp(logits) / np.exp(logits).sum(axis=1, keepdims=True)
78
+ probs = probs.reshape(len(out_sents), len(chunks), 3)
79
+
80
+ ent_idx = _LABELS.index("entailment")
81
+ con_idx = _LABELS.index("contradiction")
82
+
83
+ supports: list[SentenceSupport] = []
84
+ for i, s in enumerate(out_sents):
85
+ best_j = int(probs[i, :, ent_idx].argmax())
86
+ supports.append(SentenceSupport(
87
+ sentence=s,
88
+ entailment=round(float(probs[i, best_j, ent_idx]), 3),
89
+ contradiction=round(float(probs[i, :, con_idx].max()), 3),
90
+ best_chunk=snippet(chunks[best_j]),
91
+ ))
92
+
93
+ unsupported = [s for s in supports if s.entailment < threshold]
94
+ contradicted = [s for s in supports if s.contradiction > 0.5]
95
+ mean_ent = round(sum(s.entailment for s in supports) / len(supports), 3)
96
+
97
+ return FaithfulnessResult(
98
+ mean_entailment=mean_ent,
99
+ sentences=supports,
100
+ unsupported=sorted(unsupported, key=lambda s: s.entailment)[:5],
101
+ contradicted=contradicted[:5],
102
+ threshold=threshold,
103
+ )
rubriq/metrics/hallucination_risk.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hallucination *risk signals* for ungrounded prompts (RESEARCH.md 1.9).
2
+
3
+ Without a source or knowledge base, no local method can verify claims
4
+ (FActScore's precondition). What we CAN do is flag the patterns that
5
+ correlate with fabrication: citation-shaped strings, precise unattributed
6
+ statistics, vague-authority claims, and confident framing around
7
+ unverifiable specifics. Wording contract: every finding says "unverifiable
8
+ here", never "false".
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass, field
15
+
16
+ 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"),
24
+ "legal citation pattern — a documented fabrication hotspot (Mata v. Avianca, 2023)"),
25
+ ("precise_statistic",
26
+ re.compile(r"\b\d{1,3}(?:\.\d{1,2})?%|\b\d+(?:\.\d+)? (?:million|billion|trillion)\b"),
27
+ "precise statistic with no source in the prompt to check it against"),
28
+ ("vague_authority",
29
+ re.compile(r"(?i)\b(?:studies|research|experts|scientists|reports?) (?:show|shows|suggest|indicate|have (?:shown|found)|found|say|agree|confirm)\b"),
30
+ "appeal to unnamed studies/experts — no way to trace the claim"),
31
+ ("url",
32
+ re.compile(r"https?://\S+"),
33
+ "URL in output — LLMs generate dead or wrong links; verify before trusting"),
34
+ ("specific_date_event",
35
+ re.compile(r"(?i)\b(?:on|in) (?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2},? (?:19|20)\d{2}\b"),
36
+ "specific dated event — unverifiable without a source"),
37
+ ("named_figure_quote",
38
+ re.compile(r"(?i)(?:said|stated|according to|wrote|declared)[,:]? ?[\"“]"),
39
+ "attributed quotation — fabricated quotes are a known failure pattern"),
40
+ ]
41
+
42
+ _HEDGES = re.compile(r"(?i)\b(?:reportedly|allegedly|approximately|around|roughly|estimated|as of my knowledge|may|might|possibly|it is believed)\b")
43
+
44
+
45
+ @dataclass
46
+ class RiskSignal:
47
+ kind: str
48
+ quote: str
49
+ why: str
50
+
51
+
52
+ @dataclass
53
+ class HallucinationRiskResult:
54
+ signals: list[RiskSignal] = field(default_factory=list)
55
+ hedge_count: int = 0
56
+ density_per_100w: float = 0.0
57
+
58
+
59
+ def compute(output: str, n_words: int) -> HallucinationRiskResult:
60
+ signals: list[RiskSignal] = []
61
+ seen: set[str] = set()
62
+ for kind, pat, why in _SIGNALS:
63
+ for m in pat.finditer(output):
64
+ start = max(0, m.start() - 60)
65
+ quote = snippet(output[start:m.end() + 60])
66
+ key = f"{kind}:{m.group(0)[:40]}"
67
+ if key in seen:
68
+ continue
69
+ seen.add(key)
70
+ signals.append(RiskSignal(kind=kind, quote=quote, why=why))
71
+ if sum(1 for s in signals if s.kind == kind) >= 3:
72
+ break # cap per kind; the report needs examples, not a census
73
+
74
+ return HallucinationRiskResult(
75
+ signals=signals,
76
+ hedge_count=len(_HEDGES.findall(output)),
77
+ density_per_100w=round(100 * len(signals) / max(1, n_words), 2),
78
+ )
rubriq/metrics/lexical.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lexical diversity and repetition (RESEARCH.md 1.10).
2
+
3
+ MTLD (McCarthy & Jarvis 2010) for length-robust diversity; cliché density
4
+ against a fixed list; sentence-length variance as the rhythm proxy; repeated
5
+ n-grams for mechanical repetition. All of these are labeled proxies in the
6
+ creative-writing bundle (carve-out #2).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections import Counter
13
+ from dataclasses import dataclass
14
+
15
+ from ..textutils import split_sentences, word_count, words
16
+
17
+ _CLICHES = [
18
+ "at the end of the day", "in this day and age", "the calm before the storm",
19
+ "a whirlwind of emotions", "time stood still", "heart skipped a beat",
20
+ "sent shivers down", "a rollercoaster of", "little did they know",
21
+ "in the blink of an eye", "against all odds", "a testament to",
22
+ "the fabric of", "a tapestry of", "nestled in", "bustling", "myriad of",
23
+ "delve into", "embark on a journey", "unlock the secrets", "hidden gem",
24
+ "breathtaking", "picturesque", "last but not least", "needless to say",
25
+ "it goes without saying", "each and every", "first and foremost",
26
+ "stood the test of time", "beacon of hope", "double-edged sword",
27
+ ]
28
+
29
+
30
+ def mtld(tokens: list[str], threshold: float = 0.72) -> float | None:
31
+ """Measure of Textual Lexical Diversity, bidirectional mean."""
32
+ if len(tokens) < 50:
33
+ return None
34
+
35
+ def _pass(seq: list[str]) -> float:
36
+ factors, types, count = 0.0, set(), 0
37
+ for tok in seq:
38
+ count += 1
39
+ types.add(tok)
40
+ if len(types) / count <= threshold:
41
+ factors += 1
42
+ types, count = set(), 0
43
+ if count:
44
+ ttr = len(types) / count
45
+ factors += (1 - ttr) / (1 - threshold) if ttr > threshold else 1
46
+ return len(seq) / factors if factors else float(len(seq))
47
+
48
+ return round((_pass(tokens) + _pass(tokens[::-1])) / 2, 1)
49
+
50
+
51
+ @dataclass
52
+ class LexicalStats:
53
+ mtld: float | None
54
+ cliche_hits: list[str]
55
+ cliches_per_100w: float
56
+ sentence_len_cv: float | None # coefficient of variation of sentence lengths
57
+ repeated_ngrams: list[str] # 4-grams appearing 3+ times
58
+
59
+
60
+ def compute(text: str) -> LexicalStats:
61
+ toks = words(text)
62
+ n_w = max(1, len(toks))
63
+ low = text.lower()
64
+ hits = [c for c in _CLICHES if c in low]
65
+
66
+ sents = split_sentences(text)
67
+ lens = [word_count(s) for s in sents]
68
+ cv = None
69
+ if len(lens) >= 3:
70
+ mean = sum(lens) / len(lens)
71
+ var = sum((x - mean) ** 2 for x in lens) / len(lens)
72
+ cv = round((var ** 0.5) / mean, 2) if mean else None
73
+
74
+ grams = Counter(
75
+ " ".join(toks[i:i + 4]) for i in range(len(toks) - 3)
76
+ )
77
+ repeated = [g for g, c in grams.most_common(5) if c >= 3 and not re.fullmatch(r"(\w+ )*(the|of|and|a|to|in)( \w+)*", g)]
78
+
79
+ return LexicalStats(
80
+ mtld=mtld(toks),
81
+ cliche_hits=hits,
82
+ cliches_per_100w=round(100 * len(hits) / n_w, 2),
83
+ sentence_len_cv=cv,
84
+ repeated_ngrams=repeated,
85
+ )
rubriq/metrics/readability.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Readability and fluency statistics (RESEARCH.md 1.10).
2
+
3
+ Flesch Reading Ease / Flesch-Kincaid grade are audience-fit signals, not
4
+ quality scores: the score maps to a target band derived from the stated (or
5
+ inferred) audience, and distance from the band is what counts against the
6
+ output.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from ..textutils import count_syllables, split_sentences, words
14
+
15
+
16
+ @dataclass
17
+ class ReadabilityStats:
18
+ flesch_reading_ease: float
19
+ fk_grade: float
20
+ avg_sentence_words: float
21
+ n_sentences: int
22
+ n_words: int
23
+
24
+
25
+ def compute(text: str) -> ReadabilityStats | None:
26
+ sents = split_sentences(text)
27
+ toks = words(text)
28
+ if not sents or len(toks) < 10:
29
+ return None
30
+ syllables = sum(count_syllables(w) for w in toks)
31
+ n_w, n_s = len(toks), len(sents)
32
+ fre = 206.835 - 1.015 * (n_w / n_s) - 84.6 * (syllables / n_w)
33
+ fk = 0.39 * (n_w / n_s) + 11.8 * (syllables / n_w) - 15.59
34
+ return ReadabilityStats(
35
+ flesch_reading_ease=round(fre, 1),
36
+ fk_grade=round(fk, 1),
37
+ avg_sentence_words=round(n_w / n_s, 1),
38
+ n_sentences=n_s,
39
+ n_words=n_w,
40
+ )
41
+
42
+
43
+ # Audience keyword -> acceptable FK grade band (inclusive).
44
+ _AUDIENCE_BANDS: list[tuple[tuple[str, ...], tuple[float, float], str]] = [
45
+ (("child", "kid", "5 year", "five year", "eli5", "young"), (0.0, 6.0), "young readers"),
46
+ (("teen", "high school", "student", "beginner", "layman", "non-technical", "general public", "simple"), (5.0, 10.0), "general readers"),
47
+ (("professional", "business", "executive",), (8.0, 14.0), "professional readers"),
48
+ (("expert", "academic", "technical", "clinician", "researcher", "phd", "specialist"), (10.0, 18.0), "expert readers"),
49
+ ]
50
+
51
+ DEFAULT_BAND = (6.0, 14.0)
52
+
53
+
54
+ def audience_band(audience_text: str, instruction: str) -> tuple[tuple[float, float], str]:
55
+ """Pick the FK grade band from the advanced-options audience field,
56
+ falling back to audience words inside the instruction itself."""
57
+ for probe in (audience_text.lower(), instruction.lower()):
58
+ if not probe:
59
+ continue
60
+ for keys, band, label in _AUDIENCE_BANDS:
61
+ if any(k in probe for k in keys):
62
+ return band, label
63
+ return DEFAULT_BAND, "unspecified audience (default band)"
rubriq/metrics/relevance.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Answer relevance via embeddings (RESEARCH.md 1.3/1.4, RAGAS-style,
2
+ adapted to run without an LLM: cosine similarity between the instruction and
3
+ the output, plus per-paragraph drill-down to locate where drift happens).
4
+
5
+ bge models are asymmetric retrievers: queries get the documented
6
+ "Represent this sentence..." prefix, passages do not.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from ..models import get_embedder
14
+ from ..textutils import snippet, split_paragraphs
15
+
16
+ _BGE_QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
17
+
18
+
19
+ @dataclass
20
+ class RelevanceResult:
21
+ overall: float # cosine, roughly 0..1 for bge
22
+ worst_paragraph: str | None
23
+ worst_paragraph_sim: float | None
24
+ paragraph_sims: list[tuple[str, float]]
25
+
26
+
27
+ def compute(instruction: str, output: str) -> RelevanceResult | None:
28
+ embedder = get_embedder()
29
+ if embedder is None:
30
+ return None
31
+ paragraphs = split_paragraphs(output) or [output]
32
+ texts = [_BGE_QUERY_PREFIX + instruction, output] + paragraphs
33
+ vecs = embedder.encode(texts, normalize_embeddings=True)
34
+ q, whole, para_vecs = vecs[0], vecs[1], vecs[2:]
35
+ overall = float(q @ whole)
36
+ sims = [(p, float(q @ v)) for p, v in zip(paragraphs, para_vecs)]
37
+ worst = min(sims, key=lambda x: x[1]) if len(sims) > 1 else (None, None)
38
+ return RelevanceResult(
39
+ overall=round(overall, 3),
40
+ worst_paragraph=snippet(worst[0]) if worst[0] else None,
41
+ worst_paragraph_sim=round(worst[1], 3) if worst[1] is not None else None,
42
+ paragraph_sims=[(snippet(p, 80), round(s, 3)) for p, s in sims],
43
+ )
44
+
45
+
46
+ def similarity(a: str, b: str) -> float | None:
47
+ """Symmetric semantic similarity (used by rewriting's meaning check)."""
48
+ embedder = get_embedder()
49
+ if embedder is None:
50
+ return None
51
+ va, vb = embedder.encode([a, b], normalize_embeddings=True)
52
+ return round(float(va @ vb), 3)
rubriq/metrics/structure.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Structure, coherence proxy, and conciseness signals.
2
+
3
+ Coherence proxy per the Coh-Metrix tradition adapted to embeddings
4
+ (RESEARCH.md 1.10): adjacent-unit semantic similarity should sit in a
5
+ middle band — too low reads disjointed, too high reads repetitive.
6
+ Conciseness inverts MT-Bench's verbosity-bias finding (1.6).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ from ..models import get_embedder
14
+ from ..textutils import snippet, split_paragraphs, split_sentences, word_count
15
+
16
+
17
+ @dataclass
18
+ class CoherenceResult:
19
+ mean_adjacent_sim: float
20
+ flagged_low: list[tuple[str, str, float]] # (unit_a, unit_b, sim) — disjointed jumps
21
+ flagged_high: list[tuple[str, str, float]] # near-duplicate units
22
+
23
+
24
+ def coherence_proxy(text: str) -> CoherenceResult | None:
25
+ embedder = get_embedder()
26
+ if embedder is None:
27
+ return None
28
+ units = split_paragraphs(text)
29
+ if len(units) < 3:
30
+ units = split_sentences(text)
31
+ if len(units) < 3:
32
+ return None
33
+ vecs = embedder.encode(units, normalize_embeddings=True)
34
+ sims = [float(vecs[i] @ vecs[i + 1]) for i in range(len(vecs) - 1)]
35
+ low = [(snippet(units[i], 70), snippet(units[i + 1], 70), round(s, 2))
36
+ for i, s in enumerate(sims) if s < 0.35][:3]
37
+ high = [(snippet(units[i], 70), snippet(units[i + 1], 70), round(s, 2))
38
+ for i, s in enumerate(sims) if s > 0.92][:3]
39
+ return CoherenceResult(
40
+ mean_adjacent_sim=round(sum(sims) / len(sims), 3),
41
+ flagged_low=low,
42
+ flagged_high=high,
43
+ )
44
+
45
+
46
+ @dataclass
47
+ class ConcisenessResult:
48
+ output_words: int
49
+ expected_max: int | None # None when no expectation could be set
50
+ ratio: float | None
51
+ filler_hits: list[str]
52
+
53
+
54
+ _FILLERS = [
55
+ "it is important to note that", "it is worth noting", "as mentioned earlier",
56
+ "in order to", "due to the fact that", "at this point in time",
57
+ "it goes without saying", "needless to say", "in the world of",
58
+ "in today's", "when it comes to", "at the end of the day",
59
+ "i hope this helps", "certainly!", "great question",
60
+ ]
61
+
62
+
63
+ def conciseness(instruction: str, output: str, task_expected_words: int | None) -> ConcisenessResult:
64
+ n = word_count(output)
65
+ low_out = output.lower()
66
+ hits = [f for f in _FILLERS if f in low_out]
67
+ ratio = round(n / task_expected_words, 2) if task_expected_words else None
68
+ return ConcisenessResult(
69
+ output_words=n,
70
+ expected_max=task_expected_words,
71
+ ratio=ratio,
72
+ filler_hits=hits,
73
+ )
rubriq/models.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Lazy singletons for the two local models.
2
+
3
+ Checkpoints locked in RESEARCH.md:
4
+ - embeddings: BAAI/bge-small-en-v1.5
5
+ - NLI cross-encoder: cross-encoder/nli-deberta-v3-small
6
+
7
+ Both load on first use (first request pays the load; the API exposes a
8
+ warmup endpoint). If loading fails (no download yet, offline machine),
9
+ callers receive None and the affected metrics report skipped_reason instead
10
+ of fake numbers — degradation is visible, never silent.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import threading
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ EMBEDDING_MODEL = "BAAI/bge-small-en-v1.5"
21
+ NLI_MODEL = "cross-encoder/nli-deberta-v3-small"
22
+
23
+ _lock = threading.Lock()
24
+ _embedder = None
25
+ _nli = None
26
+ _embedder_failed = False
27
+ _nli_failed = False
28
+
29
+
30
+ def get_embedder():
31
+ global _embedder, _embedder_failed
32
+ if _embedder is not None or _embedder_failed:
33
+ return _embedder
34
+ with _lock:
35
+ if _embedder is None and not _embedder_failed:
36
+ try:
37
+ from sentence_transformers import SentenceTransformer
38
+ _embedder = SentenceTransformer(EMBEDDING_MODEL, device="cpu")
39
+ except Exception:
40
+ logger.exception("embedding model unavailable")
41
+ _embedder_failed = True
42
+ return _embedder
43
+
44
+
45
+ def get_nli():
46
+ global _nli, _nli_failed
47
+ if _nli is not None or _nli_failed:
48
+ return _nli
49
+ with _lock:
50
+ if _nli is None and not _nli_failed:
51
+ try:
52
+ from sentence_transformers import CrossEncoder
53
+ _nli = CrossEncoder(NLI_MODEL, device="cpu")
54
+ except Exception:
55
+ logger.exception("NLI model unavailable")
56
+ _nli_failed = True
57
+ return _nli
58
+
59
+
60
+ def availability() -> dict[str, bool]:
61
+ return {
62
+ "embeddings": get_embedder() is not None,
63
+ "nli": get_nli() is not None,
64
+ }
rubriq/schemas.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data contracts shared by every scorer implementation and the API.
2
+
3
+ The adapter rule: LocalScorer (v1) and LLMJudgeScorer (later) both consume
4
+ EvalRequest and produce EvalReport. Nothing downstream may depend on which
5
+ engine ran; honesty flags (is_proxy, requires_judge) live on the schema so
6
+ the UI cannot forget them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from enum import Enum
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+
16
+ class TaskType(str, Enum):
17
+ SUMMARIZATION = "summarization"
18
+ QA_GROUNDED = "qa_grounded" # question answered against source text in the prompt
19
+ QA_OPEN = "qa_open" # question with no source text to check against
20
+ CODE_GENERATION = "code_generation"
21
+ CREATIVE_WRITING = "creative_writing"
22
+ EXTRACTION = "extraction"
23
+ REASONING = "reasoning"
24
+ REWRITING = "rewriting"
25
+ GENERAL = "general"
26
+
27
+
28
+ class FailureMode(str, Enum):
29
+ UNFAITHFUL_CONTENT = "unfaithful_content"
30
+ HALLUCINATION_RISK = "hallucination_risk"
31
+ MISSED_CONSTRAINT = "missed_constraint"
32
+ INCOMPLETE_COVERAGE = "incomplete_coverage"
33
+ OFF_TOPIC_DRIFT = "off_topic_drift"
34
+ POOR_STRUCTURE = "poor_structure"
35
+ WRONG_REGISTER = "wrong_register"
36
+ VERBOSITY = "verbosity"
37
+ WEAK_REASONING = "weak_reasoning"
38
+ FORMAT_VIOLATION = "format_violation"
39
+ LOW_DIVERSITY = "low_diversity"
40
+ INVALID_CODE = "invalid_code"
41
+ INVENTED_FIELDS = "invented_fields"
42
+
43
+
44
+ class ModelFamily(str, Enum):
45
+ CLAUDE = "claude"
46
+ GPT = "gpt"
47
+ GEMINI = "gemini"
48
+ OPEN_WEIGHTS = "open_weights" # llama, mistral, qwen, ...
49
+ GENERIC = "generic"
50
+
51
+
52
+ class Evidence(BaseModel):
53
+ """A quote from the output (or prompt) backing a finding."""
54
+ quote: str
55
+ source: str = "output" # "output" | "prompt"
56
+ note: str = ""
57
+
58
+
59
+ class Finding(BaseModel):
60
+ """One diagnosed problem, specific enough to drive a repair prompt."""
61
+ failure_mode: FailureMode
62
+ detail: str # human-readable diagnosis naming the problem
63
+ evidence: list[Evidence] = Field(default_factory=list)
64
+ data: dict = Field(default_factory=dict) # machine-readable specifics (e.g. limit=150, actual=212)
65
+
66
+
67
+ class ParameterScore(BaseModel):
68
+ parameter: str # machine key, e.g. "faithfulness"
69
+ display_name: str
70
+ score: float | None # 0-100; None when the check could not run
71
+ verdict: str # one-line summary of how the output did
72
+ strengths: list[str] = Field(default_factory=list)
73
+ findings: list[Finding] = Field(default_factory=list)
74
+ is_proxy: bool = False # proxy metric (subjective-quality carve-out)
75
+ requires_judge: bool = False # cannot be judged locally at all; needs LLM-judge mode
76
+ skipped_reason: str = "" # set when score is None
77
+ rubric_source: str = "" # citation key into RESEARCH.md, e.g. "SummaC (Laban et al. 2022)"
78
+
79
+
80
+ class TaskClassification(BaseModel):
81
+ task_type: TaskType
82
+ confidence: float # 0-1
83
+ signals: list[str] = Field(default_factory=list) # why, shown in UI
84
+ has_source_text: bool = False
85
+ instruction: str = "" # prompt minus pasted source material
86
+ source_text: str | None = None
87
+
88
+
89
+ class ImprovementPrompt(BaseModel):
90
+ failure_mode: FailureMode
91
+ title: str
92
+ diagnosis: str # names the specific problem found, with evidence
93
+ prompt_text: str # ready to paste
94
+ model_family: ModelFamily
95
+ technique_source: str # citation key into RESEARCH.md
96
+
97
+
98
+ class OverallScore(BaseModel):
99
+ score: float # weighted 0-100 over scoreable parameters
100
+ note: str # what the number does and does not include
101
+
102
+
103
+ class EvalOptions(BaseModel):
104
+ """Advanced options; every field has a sensible default (per brief)."""
105
+ weights: dict[str, float] = Field(default_factory=dict) # parameter key -> weight override
106
+ audience: str = "" # e.g. "children", "expert clinicians"
107
+ tone: str = "" # expected register, free text
108
+
109
+
110
+ class EvalRequest(BaseModel):
111
+ prompt: str
112
+ output: str
113
+ model_name: str = "" # free text, e.g. "gpt-4o", "claude sonnet"
114
+ options: EvalOptions = Field(default_factory=EvalOptions)
115
+
116
+
117
+ class EvalReport(BaseModel):
118
+ task: TaskClassification
119
+ parameters: list[ParameterScore]
120
+ overall: OverallScore
121
+ improvement_prompts: list[ImprovementPrompt]
122
+ honesty_notes: list[str] = Field(default_factory=list) # carve-out disclosures for this run
123
+ engine: str = "local" # which Scorer produced this
rubriq/textutils.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared text primitives: tokenization-free splitting and counting.
2
+
3
+ Kept dependency-free (no nltk/spacy) so the deterministic checks stay
4
+ deterministic and the install stays light.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ _ABBREVIATIONS = {
12
+ "mr", "mrs", "ms", "dr", "prof", "sr", "jr", "st", "vs", "etc", "e.g",
13
+ "i.e", "fig", "no", "vol", "inc", "ltd", "co", "corp", "dept", "est",
14
+ "approx", "u.s", "u.k",
15
+ }
16
+
17
+ _SENT_BOUNDARY = re.compile(r"(?<=[.!?])[\"')\]]*\s+(?=[A-Z0-9\"'(\[])")
18
+
19
+
20
+ def split_sentences(text: str) -> list[str]:
21
+ """Regex sentence splitter with abbreviation guards. Good enough for
22
+ metric aggregation; not a linguistic gold standard and doesn't need to be.
23
+ """
24
+ text = text.strip()
25
+ if not text:
26
+ return []
27
+ parts: list[str] = []
28
+ for para in re.split(r"\n\s*\n|\n(?=[-*#\d])", text):
29
+ para = para.strip()
30
+ if not para:
31
+ continue
32
+ start = 0
33
+ for m in _SENT_BOUNDARY.finditer(para):
34
+ candidate = para[start:m.start() + 1]
35
+ last_word = re.findall(r"[\w.]+", candidate[-12:].lower())
36
+ if last_word and last_word[-1].rstrip(".") in _ABBREVIATIONS:
37
+ continue
38
+ if candidate.strip():
39
+ parts.append(candidate.strip())
40
+ start = m.end()
41
+ tail = para[start:].strip()
42
+ if tail:
43
+ parts.append(tail)
44
+ return parts
45
+
46
+
47
+ def split_paragraphs(text: str) -> list[str]:
48
+ return [p.strip() for p in re.split(r"\n\s*\n", text.strip()) if p.strip()]
49
+
50
+
51
+ def word_count(text: str) -> int:
52
+ return len(re.findall(r"[A-Za-z0-9'’-]+", text))
53
+
54
+
55
+ def words(text: str) -> list[str]:
56
+ return re.findall(r"[A-Za-z'’-]+", text.lower())
57
+
58
+
59
+ def count_syllables(word: str) -> int:
60
+ """Heuristic syllable count (vowel groups, silent-e adjustment)."""
61
+ w = word.lower().strip(".:;!?,'\"")
62
+ if not w:
63
+ return 0
64
+ groups = re.findall(r"[aeiouy]+", w)
65
+ n = len(groups)
66
+ if w.endswith("e") and n > 1 and not w.endswith(("le", "ee", "ye")):
67
+ n -= 1
68
+ return max(1, n)
69
+
70
+
71
+ def snippet(text: str, limit: int = 160) -> str:
72
+ """Shorten a quote for evidence display without cutting mid-word."""
73
+ text = " ".join(text.split())
74
+ if len(text) <= limit:
75
+ return text
76
+ cut = text[:limit].rsplit(" ", 1)[0]
77
+ return cut + "…"