jameVee commited on
Commit
131239b
·
verified ·
1 Parent(s): 7890f60

Add trained hallucination-detector classifiers

Browse files
README.md ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language:
3
+ - en
4
+ license: mit
5
+ base_model:
6
+ - KRLabsOrg/lettucedect-base-modernbert-en-v1
7
+ - Qwen/Qwen2.5-0.5B
8
+ datasets:
9
+ - jameVee/ToolACE-Hallucination
10
+ tags:
11
+ - hallucination-detection
12
+ - tool-calling
13
+ - text-classification
14
+ - span-detection
15
+ - sklearn
16
+ - ensemble
17
+ metrics:
18
+ - f1
19
+ - roc_auc
20
+ pipeline_tag: text-classification
21
+ pretty_name: ToolACE Hallucination Detector
22
+ ---
23
+
24
+ # ToolACE Hallucination Detector
25
+
26
+ A lightweight ensemble classifier for detecting hallucinations in LLM responses that follow tool calls. It identifies three hallucination types — **missing tool reference**, **unsupported overgeneration**, and **tool-output contradiction** — and returns both a sample-level binary label and character-level span predictions.
27
+
28
+ ---
29
+
30
+ ## Background
31
+
32
+ When an LLM answers a user query using tool outputs, it can hallucinate in distinct ways:
33
+
34
+ | Type | Description |
35
+ |---|---|
36
+ | `missing_tool` | The assistant suggests using a tool that was not available |
37
+ | `overgeneration` | The answer contains plausible but unsupported extra facts |
38
+ | `tool_output_contradiction` | The answer contradicts specific facts returned by the tool |
39
+
40
+ This model was trained on **[jameVee/ToolACE-Hallucination](https://huggingface.co/datasets/jameVee/ToolACE-Hallucination)**, a benchmark of ~4 100 examples derived from [Team-ACE/ToolACE](https://huggingface.co/datasets/Team-ACE/ToolACE). Each of the three hallucination datasets was generated by corrupting ~50 % of clean ToolACE examples with a specific hallucination type (see dataset card for details).
41
+
42
+ ---
43
+
44
+ ## Architecture
45
+
46
+ The detector is a **three-branch soft-voting ensemble**. Each branch independently produces a hallucination probability, and the three scores are averaged to give the final prediction.
47
+
48
+ ```
49
+ ┌──────────────────────────────────┐
50
+ query + context ──────► │ Branch A: Lexical Verifier │ ──► P_A
51
+ + tool output │ (token overlap span detector │
52
+ │ → StandardScaler + LogReg) │
53
+ └──────────────────────────────────┘
54
+ ┌──────────────────────────────────┐
55
+ ───► │ Branch B: LettuceDetect │ ──► P_B
56
+ │ (ModernBERT span model │
57
+ │ → StandardScaler + LogReg) │
58
+ └──────────────────────────────────┘
59
+ ┌──────────────────────────────────┐
60
+ ───► │ Branch C: LookBack-style │ ──► P_C
61
+ │ (Qwen2.5-0.5B attention ratios │
62
+ │ → StandardScaler + LogReg) │
63
+ └──────────────────────────────────┘
64
+ │
65
+ avg(P_A, P_B, P_C) ≥ 0.5
66
+ │
67
+ hallucinated?
68
+ ```
69
+
70
+ ### Branch A — Lexical Span Verifier
71
+
72
+ Checks whether tokens in the assistant's answer are grounded in the (normalized) tool output using lexical overlap. Unsupported token sequences become candidate hallucination spans. Span-level features (count, coverage, max score) are fed into a logistic regression.
73
+
74
+ ### Branch B — LettuceDetect (supervised span model)
75
+
76
+ Uses [KRLabsOrg/lettucedect-base-modernbert-en-v1](https://huggingface.co/KRLabsOrg/lettucedect-base-modernbert-en-v1), a ModernBERT-based transformer fine-tuned for grounded hallucination detection. It produces character-level spans scored by confidence. A logistic regression trained on span features converts those into a sample-level probability. **Best single branch** (AUROC 0.721, Span-F1 0.208).
77
+
78
+ ### Branch C — LookBack-style Detector
79
+
80
+ Passes the concatenation of context and answer through [Qwen/Qwen2.5-0.5B](https://huggingface.co/Qwen/Qwen2.5-0.5B) and computes, for each answer token, the ratio of attention it pays to the context versus to previously generated tokens. Low-grounding tokens (ratio < 0.22) are merged into hallucination spans. Span and ratio features are fed to logistic regression.
81
+
82
+ ---
83
+
84
+ ## Files in this repository
85
+
86
+ | File | Description |
87
+ |---|---|
88
+ | `lexical_clf.joblib` | Branch A sklearn Pipeline (StandardScaler + LogisticRegression) |
89
+ | `lettuce_clf.joblib` | Branch B sklearn Pipeline (StandardScaler + LogisticRegression) |
90
+ | `lookback_clf.joblib` | Branch C sklearn Pipeline (StandardScaler + LogisticRegression) |
91
+ | `model_meta.json` | Feature column names, backbone IDs, train/test split sizes, test metrics |
92
+ | `evaluation_baselines_span_utils.py` | Span extraction helpers required at inference time |
93
+
94
+ ---
95
+
96
+ ## Performance (test set, 20 % held-out, grouped split by example_id)
97
+
98
+ | Method | Accuracy | F1 | AUROC | Span-F1 |
99
+ |---|---|---|---|---|
100
+ | Lexical span verifier | 0.566 | 0.523 | 0.603 | 0.053 |
101
+ | LettuceDetect (Branch B) | **0.678** | 0.632 | **0.721** | **0.208** |
102
+ | LookBack (Branch C) | 0.489 | 0.561 | 0.511 | 0.000 |
103
+ | **Soft-vote ensemble** | 0.676 | **0.659** | 0.721 | 0.179 |
104
+
105
+ Per-type F1 (ensemble):
106
+
107
+ | Corruption type | F1 |
108
+ |---|---|
109
+ | `missing_tool` | 0.617 |
110
+ | `overgeneration` | 0.693 |
111
+ | `tool_output_contradiction` | 0.779 |
112
+ | `clean` | — |
113
+
114
+ ---
115
+
116
+ ## How to use
117
+
118
+ ### 1. Install dependencies
119
+
120
+ ```bash
121
+ pip install joblib scikit-learn transformers lettucedetect torch
122
+ ```
123
+
124
+ ### 2. Load the classifiers
125
+
126
+ ```python
127
+ import joblib, json
128
+ from pathlib import Path
129
+
130
+ repo = Path("hallucination_detector") # or your local clone path
131
+
132
+ lex_clf = joblib.load(repo / "lexical_clf.joblib")
133
+ lettuce_clf = joblib.load(repo / "lettuce_clf.joblib")
134
+ lbl_clf = joblib.load(repo / "lookback_clf.joblib")
135
+
136
+ with open(repo / "model_meta.json") as f:
137
+ meta = json.load(f)
138
+ ```
139
+
140
+ ### 3. Load the backbone models
141
+
142
+ ```python
143
+ import torch
144
+ from lettucedetect.models.inference import HallucinationDetector
145
+ from transformers import AutoTokenizer, AutoModelForCausalLM
146
+
147
+ lettuce_detector = HallucinationDetector(
148
+ method="transformer",
149
+ model_path="KRLabsOrg/lettucedect-base-modernbert-en-v1",
150
+ )
151
+
152
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
153
+ lbl_tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B", use_fast=True)
154
+ lbl_model = AutoModelForCausalLM.from_pretrained(
155
+ "Qwen/Qwen2.5-0.5B", torch_dtype=torch.bfloat16, attn_implementation="eager"
156
+ ).to(DEVICE).eval()
157
+ ```
158
+
159
+ ### 4. Run inference
160
+
161
+ ```python
162
+ import sys
163
+ sys.path.insert(0, str(repo)) # make span utils importable
164
+ from evaluation_baselines_span_utils import (
165
+ add_normalized_context_columns,
166
+ aggregate_lookback_features,
167
+ aggregate_span_features,
168
+ lexical_hallucination_spans,
169
+ spans_from_lookback_ratios,
170
+ merge_spans,
171
+ )
172
+ import pandas as pd, numpy as np
173
+
174
+ def predict(query: str, context: str, output: str) -> dict:
175
+ row = pd.Series({"query": query, "context": context, "output": output})
176
+ # normalize context (converts raw tool JSON to readable text)
177
+ row_df = add_normalized_context_columns(pd.DataFrame([row]))
178
+ row = row_df.iloc[0]
179
+
180
+ # Branch A — lexical
181
+ lex_spans = lexical_hallucination_spans(row)
182
+ lex_feats = aggregate_span_features(lex_spans, len(output))
183
+ p_lex = lex_clf.predict_proba(pd.DataFrame([lex_feats]))[0, 1]
184
+
185
+ # Branch B — LettuceDetect
186
+ raw_spans = lettuce_detector.predict(
187
+ context=[row["normalized_context"]],
188
+ question=query, answer=output, output_format="spans",
189
+ )
190
+ lettuce_spans = merge_spans([
191
+ {"start": int(s["start"]), "end": int(s["end"]),
192
+ "text": output[int(s["start"]):int(s["end"])],
193
+ "type": "hallucination", "score": float(s.get("score", 0.0))}
194
+ for s in raw_spans if int(s.get("end", 0)) > int(s.get("start", 0))
195
+ ])
196
+ lettuce_feats = aggregate_span_features(lettuce_spans, len(output))
197
+ p_lettuce = lettuce_clf.predict_proba(pd.DataFrame([lettuce_feats]))[0, 1]
198
+
199
+ # Branch C — LookBack
200
+ from evaluation_baselines_span_utils import aggregate_lookback_features
201
+ # (reuse compute_lookback_ratios from the training notebook)
202
+ # p_lbl = lbl_clf.predict_proba(pd.DataFrame([lbl_feats]))[0, 1]
203
+ # For a self-contained example we skip Branch C and average A+B only:
204
+ p_ensemble = np.mean([p_lex, p_lettuce])
205
+
206
+ return {
207
+ "hallucinated": bool(p_ensemble >= 0.5),
208
+ "score": float(p_ensemble),
209
+ "lex_score": float(p_lex),
210
+ "lettuce_score": float(p_lettuce),
211
+ "lettuce_spans": lettuce_spans,
212
+ }
213
+
214
+ result = predict(
215
+ query="What is the current price of AAPL?",
216
+ context='Stock API: {"ticker": "AAPL", "price": 189.50, "change": "+1.2%"}',
217
+ output="The current price of AAPL is $189.50, up 1.2%. It also hit an all-time high last Tuesday.",
218
+ )
219
+ print(result)
220
+ ```
221
+
222
+ ---
223
+
224
+ ## Training details
225
+
226
+ - **Source dataset**: [jameVee/ToolACE-Hallucination](https://huggingface.co/datasets/jameVee/ToolACE-Hallucination) (1 034 base examples × 4 variants = 4 136 rows total)
227
+ - **Split**: 80 / 20 grouped by `example_id` (no leakage between clean and corrupted variants of the same query)
228
+ - **Classifiers**: scikit-learn `LogisticRegression(max_iter=1000)` wrapped in a `StandardScaler` pipeline
229
+ - **Random seed**: 1241
230
+
231
+ ### About the training dataset
232
+
233
+ `jameVee/ToolACE-Hallucination` contains three JSONL files, each derived from [Team-ACE/ToolACE](https://huggingface.co/datasets/Team-ACE/ToolACE):
234
+
235
+ - `missing_tool_dataset.jsonl` — generated with `unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit` (50 % corruption rate): a sentence is appended that refers to a non-existent tool.
236
+ - `overgeneration_dataset.jsonl` — same generator (50 % corruption rate): a plausible but unsupported sentence is appended.
237
+ - `tool_output_contradiction_dataset.jsonl` — generated with `openai/gpt-4o-mini` via OpenRouter (all entries attempted, strength 0.9): the answer is rewritten to contradict grounded facts from the tool output.
238
+
239
+ Each entry carries character-level `hallucination_labels` marking the corrupted span(s).
240
+
241
+ ---
242
+
243
+ ## Limitations
244
+
245
+ - The classifiers are trained on a relatively small dataset (~3 300 training rows). Performance may degrade on domains or tool schemas not represented in ToolACE.
246
+ - Branch C (LookBack) shows weak span-level performance at the default threshold (0.22); tuning this on a validation split is recommended.
247
+ - The ensemble does not produce type-specific labels — it only predicts binary hallucinated / clean at the sample level.
248
+
249
+ ---
250
+
251
+ ## Citation
252
+
253
+ If you use this model or the associated datasets, please cite:
254
+
255
+ ```bibtex
256
+ @misc{toolace_hallucination_detector,
257
+ author = {jameVee},
258
+ title = {ToolACE Hallucination Detector},
259
+ year = {2026},
260
+ publisher = {Hugging Face},
261
+ url = {https://huggingface.co/jameVee/ToolACE-Hallucination-Detector}
262
+ }
263
+
264
+ @dataset{toolace_hallucination,
265
+ author = {jameVee},
266
+ title = {ToolACE-Hallucination},
267
+ year = {2026},
268
+ publisher = {Hugging Face},
269
+ url = {https://huggingface.co/datasets/jameVee/ToolACE-Hallucination}
270
+ }
271
+
272
+ @dataset{toolace,
273
+ author = {Team-ACE},
274
+ title = {ToolACE},
275
+ year = {2024},
276
+ publisher = {Hugging Face},
277
+ url = {https://huggingface.co/datasets/Team-ACE/ToolACE}
278
+ }
279
+ ```
evaluation_baselines_span_utils.py ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ from typing import Dict, Iterable, List, Optional, Sequence, Tuple
4
+
5
+ import numpy as np
6
+
7
+
8
+ ACTION_PATTERNS = {
9
+ "email": [r"\bemail\b", r"\bmail\b", r"\bsend (an )?email\b"],
10
+ "calendar": [r"\bcalendar\b", r"\bschedule\b", r"\bbook\b", r"\bset up (a )?meeting\b"],
11
+ "phone": [r"\bcall\b", r"\bphone\b", r"\bdial\b"],
12
+ "message": [r"\bslack\b", r"\bmessage\b", r"\bping\b", r"\btext\b"],
13
+ }
14
+
15
+
16
+ def split_context_and_tools(context: str) -> Tuple[str, str]:
17
+ marker = "Available tools: "
18
+ if marker in context:
19
+ tool_output, tools_json = context.split(marker, 1)
20
+ return tool_output.strip(), tools_json.strip()
21
+ return context.strip(), ""
22
+
23
+
24
+ def parse_available_tools(context: str) -> List[Dict]:
25
+ _, tools_json = split_context_and_tools(context)
26
+ if not tools_json:
27
+ return []
28
+ try:
29
+ parsed = json.loads(tools_json)
30
+ if isinstance(parsed, list):
31
+ return parsed
32
+ except Exception:
33
+ pass
34
+ return []
35
+
36
+
37
+ def parse_tool_blocks(context: str) -> List[Tuple[str, object]]:
38
+ left, _ = split_context_and_tools(context)
39
+ s = left.strip()
40
+ blocks: List[Tuple[str, object]] = []
41
+ decoder = json.JSONDecoder()
42
+ i = 0
43
+ n = len(s)
44
+
45
+ while i < n:
46
+ while i < n and s[i] in " \n\t.":
47
+ i += 1
48
+ if i >= n:
49
+ break
50
+
51
+ colon = s.find(":", i)
52
+ if colon == -1:
53
+ break
54
+
55
+ name = s[i:colon].strip()
56
+ j = colon + 1
57
+ while j < n and s[j].isspace():
58
+ j += 1
59
+ if not name or j >= n:
60
+ break
61
+
62
+ try:
63
+ payload, end = decoder.raw_decode(s, j)
64
+ blocks.append((name, payload))
65
+ i = end
66
+ continue
67
+ except Exception:
68
+ match = re.search(r'(?:\n|\.\s+)(?=[^\n:]{1,80}:\s*[\[{\"])', s[j:])
69
+ end = j + (match.start() if match else len(s[j:]))
70
+ raw = s[j:end].strip()
71
+ blocks.append((name, raw))
72
+ i = end
73
+
74
+ return blocks
75
+
76
+
77
+ def humanize_key(key: str) -> str:
78
+ key = str(key).replace("_", " ").replace("-", " ")
79
+ key = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", key)
80
+ key = re.sub(r"\s+", " ", key).strip()
81
+ return key.lower()
82
+
83
+
84
+ def normalize_text(text: str) -> str:
85
+ return re.sub(r"\s+", " ", str(text).lower()).strip()
86
+
87
+
88
+ def content_tokens(text: str) -> List[str]:
89
+ toks = re.findall(r"[A-Za-z0-9_./%:-]+", str(text).lower())
90
+ return [t for t in toks if len(t) >= 3]
91
+
92
+
93
+ def is_large_blob(text: str) -> bool:
94
+ text = str(text)
95
+ if text.startswith("data:image/"):
96
+ return True
97
+ return len(text) > 160 and re.fullmatch(r"[A-Za-z0-9+/=._:-]+", text) is not None
98
+
99
+
100
+ def format_scalar(value: object) -> str:
101
+ if value is None:
102
+ return "null"
103
+ if isinstance(value, bool):
104
+ return "true" if value else "false"
105
+ if isinstance(value, (int, float)):
106
+ return str(value)
107
+
108
+ text = str(value).strip().replace("\n", " ")
109
+ if is_large_blob(text):
110
+ return "[large binary/string omitted]"
111
+ if len(text) > 220:
112
+ return text[:217] + "..."
113
+ return text
114
+
115
+
116
+ def render_json(value: object, indent: int = 0, max_items: int = 4) -> List[str]:
117
+ sp = " " * indent
118
+ lines: List[str] = []
119
+
120
+ if isinstance(value, dict):
121
+ for k, v in value.items():
122
+ key = humanize_key(k)
123
+ if isinstance(v, (dict, list)):
124
+ lines.append(f"{sp}{key}:")
125
+ lines.extend(render_json(v, indent + 1, max_items=max_items))
126
+ else:
127
+ lines.append(f"{sp}{key}: {format_scalar(v)}")
128
+ return lines
129
+
130
+ if isinstance(value, list):
131
+ for idx, item in enumerate(value[:max_items], 1):
132
+ if isinstance(item, (dict, list)):
133
+ lines.append(f"{sp}- item {idx}:")
134
+ lines.extend(render_json(item, indent + 1, max_items=max_items))
135
+ else:
136
+ lines.append(f"{sp}- item {idx}: {format_scalar(item)}")
137
+ if len(value) > max_items:
138
+ lines.append(f"{sp}- ... {len(value) - max_items} more items")
139
+ return lines
140
+
141
+ return [f"{sp}{format_scalar(value)}"]
142
+
143
+
144
+ def render_available_tools(context: str, max_tools: int = 8) -> List[str]:
145
+ tools = parse_available_tools(context)
146
+ if not tools:
147
+ return []
148
+
149
+ lines = ["Available tools:"]
150
+ for tool in tools[:max_tools]:
151
+ name = tool.get("name", "")
152
+ desc = tool.get("description", "")
153
+ if len(desc) > 180:
154
+ desc = desc[:177] + "..."
155
+ lines.append(f"- {name}: {desc}")
156
+ if len(tools) > max_tools:
157
+ lines.append(f"- ... {len(tools) - max_tools} more tools")
158
+ return lines
159
+
160
+
161
+ def normalize_tool_context(context: str, max_items: int = 4, max_tools: int = 8) -> str:
162
+ blocks = parse_tool_blocks(context)
163
+ lines: List[str] = []
164
+ for name, payload in blocks:
165
+ lines.append(f"Tool: {name}")
166
+ lines.extend(render_json(payload, indent=1, max_items=max_items))
167
+ lines.extend(render_available_tools(context, max_tools=max_tools))
168
+ return "\n".join(lines)
169
+
170
+
171
+ def add_normalized_context_columns(df):
172
+ df = df.copy()
173
+ df["normalized_context"] = df["context"].apply(normalize_tool_context)
174
+ df["normalized_tool_output"] = df["context"].apply(lambda x: split_context_and_tools(normalize_tool_context(x))[0])
175
+ return df
176
+
177
+
178
+ def extract_percentages(text: str) -> List[Tuple[int, int, str]]:
179
+ return [(m.start(), m.end(), m.group(0)) for m in re.finditer(r"[+-]?\d+(?:\.\d+)?%", text)]
180
+
181
+
182
+ def extract_dates(text: str) -> List[Tuple[int, int, str]]:
183
+ patterns = [
184
+ r"\b\d{4}-\d{2}-\d{2}\b",
185
+ r"\b\d{4}/\d{2}/\d{2}\b",
186
+ r"\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{1,2},\s+\d{4}\b",
187
+ ]
188
+ matches = []
189
+ for pat in patterns:
190
+ matches.extend((m.start(), m.end(), m.group(0)) for m in re.finditer(pat, text))
191
+ return matches
192
+
193
+
194
+ def extract_numbers(text: str) -> List[Tuple[int, int, str]]:
195
+ return [(m.start(), m.end(), m.group(0)) for m in re.finditer(r"\b\d+(?:\.\d+)?\b", text)]
196
+
197
+
198
+ def extract_quoted_strings(text: str) -> List[Tuple[int, int, str]]:
199
+ return [(m.start(), m.end(), m.group(0)) for m in re.finditer(r'"[^"\n]{3,}"', text)]
200
+
201
+
202
+ def extract_capitalized_phrases(text: str) -> List[Tuple[int, int, str]]:
203
+ return [
204
+ (m.start(), m.end(), m.group(0))
205
+ for m in re.finditer(r"\b(?:[A-Z][a-z]+(?:\s+[A-Z][A-Za-z0-9.&'-]+){0,4})\b", text)
206
+ ]
207
+
208
+
209
+ def merge_spans(spans: Sequence[Dict]) -> List[Dict]:
210
+ if not spans:
211
+ return []
212
+ spans = sorted(spans, key=lambda x: (x["start"], x["end"]))
213
+ merged = [dict(spans[0])]
214
+ for span in spans[1:]:
215
+ last = merged[-1]
216
+ if span["start"] <= last["end"]:
217
+ last["end"] = max(last["end"], span["end"])
218
+ if len(span.get("text", "")) > len(last.get("text", "")):
219
+ last["text"] = span.get("text", last.get("text", ""))
220
+ last["score"] = max(last.get("score", 0.0), span.get("score", 0.0))
221
+ last["type"] = last.get("type", span.get("type", "hallucination"))
222
+ else:
223
+ merged.append(dict(span))
224
+ return merged
225
+
226
+
227
+ def normalize_gold_labels(labels: Sequence, text: Optional[str] = None) -> List[Dict]:
228
+ normalized = []
229
+ for label in labels or []:
230
+ if isinstance(label, dict):
231
+ start = int(label.get("start", -1))
232
+ end = int(label.get("end", -1))
233
+ label_type = label.get("type", "hallucination")
234
+ snippet = label.get("text")
235
+ elif isinstance(label, (list, tuple)) and len(label) >= 3:
236
+ start = int(label[0])
237
+ end = int(label[1])
238
+ label_type = label[2]
239
+ snippet = text[start:end] if text and start >= 0 and end > start else None
240
+ else:
241
+ continue
242
+ if start < 0 or end <= start:
243
+ continue
244
+ normalized.append({
245
+ "start": start,
246
+ "end": end,
247
+ "type": label_type,
248
+ "text": snippet if snippet is not None else (text[start:end] if text else ""),
249
+ "score": 1.0,
250
+ })
251
+ return merge_spans(normalized)
252
+
253
+
254
+ def spans_to_char_set(spans: Sequence[Dict]) -> set:
255
+ chars = set()
256
+ for span in spans:
257
+ chars.update(range(int(span["start"]), int(span["end"])))
258
+ return chars
259
+
260
+
261
+ def evaluate_span_predictions(gold_spans_list: Sequence[Sequence[Dict]], pred_spans_list: Sequence[Sequence[Dict]]) -> Dict[str, float]:
262
+ tp = fp = fn = 0
263
+ exact_matches = 0
264
+ total = len(gold_spans_list)
265
+
266
+ for gold_spans, pred_spans in zip(gold_spans_list, pred_spans_list):
267
+ gold_chars = spans_to_char_set(gold_spans)
268
+ pred_chars = spans_to_char_set(pred_spans)
269
+ tp += len(gold_chars & pred_chars)
270
+ fp += len(pred_chars - gold_chars)
271
+ fn += len(gold_chars - pred_chars)
272
+
273
+ gold_exact = {(s["start"], s["end"], s.get("type", "hallucination")) for s in gold_spans}
274
+ pred_exact = {(s["start"], s["end"], s.get("type", "hallucination")) for s in pred_spans}
275
+ exact_matches += int(gold_exact == pred_exact)
276
+
277
+ precision = tp / (tp + fp) if tp + fp > 0 else 0.0
278
+ recall = tp / (tp + fn) if tp + fn > 0 else 0.0
279
+ f1 = 2 * precision * recall / (precision + recall) if precision + recall > 0 else 0.0
280
+ return {
281
+ "span_precision": precision,
282
+ "span_recall": recall,
283
+ "span_f1": f1,
284
+ "exact_match_rate": exact_matches / total if total else 0.0,
285
+ }
286
+
287
+
288
+ def build_span_eval_frame(df, pred_col: str) -> Dict[str, float]:
289
+ gold = [normalize_gold_labels(labels, text=output) for labels, output in zip(df["hallucination_labels"], df["output"])]
290
+ pred = df[pred_col].tolist()
291
+ return evaluate_span_predictions(gold, pred)
292
+
293
+
294
+ def lexical_hallucination_spans(row) -> List[Dict]:
295
+ output = row["output"]
296
+ tool_output = row.get("normalized_tool_output", row["context"])
297
+ available_tools = parse_available_tools(row["context"])
298
+ tool_norm = normalize_text(tool_output)
299
+ spans = []
300
+
301
+ candidate_extractors = [
302
+ ("percentage", extract_percentages),
303
+ ("date", extract_dates),
304
+ ("number", extract_numbers),
305
+ ("quote", extract_quoted_strings),
306
+ ("entity", extract_capitalized_phrases),
307
+ ]
308
+
309
+ for kind, extractor in candidate_extractors:
310
+ for start, end, value in extractor(output):
311
+ value_norm = normalize_text(value.strip('"'))
312
+ if len(value_norm) < 2:
313
+ continue
314
+ if value_norm not in tool_norm:
315
+ spans.append({
316
+ "start": start,
317
+ "end": end,
318
+ "text": output[start:end],
319
+ "type": "hallucination",
320
+ "score": 0.6 if kind in {"number", "entity"} else 0.8,
321
+ })
322
+
323
+ tool_blob = normalize_text(" ".join(
324
+ f"{tool.get('name', '')} {tool.get('description', '')}" for tool in available_tools
325
+ ))
326
+ for affordance, patterns in ACTION_PATTERNS.items():
327
+ supported = affordance in tool_blob
328
+ for pattern in patterns:
329
+ for match in re.finditer(pattern, output, flags=re.IGNORECASE):
330
+ if not supported:
331
+ spans.append({
332
+ "start": match.start(),
333
+ "end": match.end(),
334
+ "text": match.group(0),
335
+ "type": "missing_tool",
336
+ "score": 0.95,
337
+ })
338
+
339
+ return merge_spans(spans)
340
+
341
+
342
+ def longest_streak(flags: Sequence[bool]) -> int:
343
+ best = 0
344
+ cur = 0
345
+ for flag in flags:
346
+ cur = cur + 1 if flag else 0
347
+ best = max(best, cur)
348
+ return best
349
+
350
+
351
+ def aggregate_span_features(spans: Sequence[Dict], text_length: int) -> Dict[str, float]:
352
+ lengths = [max(0, span["end"] - span["start"]) for span in spans]
353
+ scores = [float(span.get("score", 0.0)) for span in spans]
354
+ coverage = sum(lengths) / max(1, text_length)
355
+ return {
356
+ "num_spans": len(spans),
357
+ "max_span_score": max(scores) if scores else 0.0,
358
+ "mean_span_score": float(np.mean(scores)) if scores else 0.0,
359
+ "span_char_fraction": coverage,
360
+ "max_span_len": max(lengths) if lengths else 0.0,
361
+ }
362
+
363
+
364
+ def aggregate_lookback_features(ratios: Sequence[Dict]) -> Dict[str, float]:
365
+ if not ratios:
366
+ return {
367
+ "mean_ratio": 0.5,
368
+ "min_ratio": 0.5,
369
+ "frac_low_03": 0.0,
370
+ "frac_low_02": 0.0,
371
+ "std_ratio": 0.0,
372
+ "bottom3_mean": 0.5,
373
+ "longest_low_streak": 0.0,
374
+ "mean_ratio_numeric": 0.5,
375
+ "frac_low_numeric": 0.0,
376
+ }
377
+
378
+ vals = np.array([r["lookback_ratio"] for r in ratios], dtype=float)
379
+ bottom3 = np.sort(vals)[: min(3, len(vals))]
380
+ numeric_mask = np.array([bool(re.search(r"\d", r["token"])) for r in ratios])
381
+
382
+ if numeric_mask.any():
383
+ numeric_vals = vals[numeric_mask]
384
+ mean_ratio_numeric = float(numeric_vals.mean())
385
+ frac_low_numeric = float((numeric_vals < 0.3).mean())
386
+ else:
387
+ mean_ratio_numeric = 0.5
388
+ frac_low_numeric = 0.0
389
+
390
+ low_flags = vals < 0.3
391
+ return {
392
+ "mean_ratio": float(vals.mean()),
393
+ "min_ratio": float(vals.min()),
394
+ "frac_low_03": float((vals < 0.3).mean()),
395
+ "frac_low_02": float((vals < 0.2).mean()),
396
+ "std_ratio": float(vals.std()),
397
+ "bottom3_mean": float(bottom3.mean()),
398
+ "longest_low_streak": float(longest_streak(low_flags.tolist())),
399
+ "mean_ratio_numeric": mean_ratio_numeric,
400
+ "frac_low_numeric": frac_low_numeric,
401
+ }
402
+
403
+
404
+ def spans_from_lookback_ratios(answer: str, ratios: Sequence[Dict], low_threshold: float = 0.22, min_chars: int = 3) -> List[Dict]:
405
+ spans = []
406
+ cur = None
407
+
408
+ for ratio in ratios:
409
+ start = ratio.get("start")
410
+ end = ratio.get("end")
411
+ if start is None or end is None or end <= start:
412
+ continue
413
+
414
+ text = answer[start:end]
415
+ is_content = bool(re.search(r"[A-Za-z0-9]", text))
416
+ low = ratio["lookback_ratio"] < low_threshold
417
+
418
+ if low and is_content:
419
+ if cur is None:
420
+ cur = {
421
+ "start": start,
422
+ "end": end,
423
+ "score_values": [1.0 - float(ratio["lookback_ratio"])],
424
+ }
425
+ else:
426
+ if start <= cur["end"] + 1:
427
+ cur["end"] = end
428
+ cur["score_values"].append(1.0 - float(ratio["lookback_ratio"]))
429
+ else:
430
+ spans.append(cur)
431
+ cur = {
432
+ "start": start,
433
+ "end": end,
434
+ "score_values": [1.0 - float(ratio["lookback_ratio"])],
435
+ }
436
+ elif cur is not None:
437
+ spans.append(cur)
438
+ cur = None
439
+
440
+ if cur is not None:
441
+ spans.append(cur)
442
+
443
+ normalized = []
444
+ for span in spans:
445
+ if span["end"] - span["start"] < min_chars:
446
+ continue
447
+ normalized.append({
448
+ "start": span["start"],
449
+ "end": span["end"],
450
+ "text": answer[span["start"]:span["end"]],
451
+ "type": "hallucination",
452
+ "score": float(np.mean(span["score_values"])) if span["score_values"] else 0.0,
453
+ })
454
+ return merge_spans(normalized)
lettuce_clf.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6277c21bdc49573d0408b83728d9f4e32b1597d58717f63a88d6c8d97b5c2dc3
3
+ size 1809
lexical_clf.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:21aab7c480d2eb013c14842564366953341c74f18cab59a2fc574dc8264829ed
3
+ size 1809
lookback_clf.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9ad283f9dbb6cacc9a453a61e64db751ba43153ff9dcfa4b7ad28bd74185f6b9
3
+ size 2241
model_meta.json ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "lexical_features": [
3
+ "num_spans",
4
+ "max_span_score",
5
+ "mean_span_score",
6
+ "span_char_fraction",
7
+ "max_span_len"
8
+ ],
9
+ "lettuce_features": [
10
+ "num_spans",
11
+ "max_span_score",
12
+ "mean_span_score",
13
+ "span_char_fraction",
14
+ "max_span_len"
15
+ ],
16
+ "lookback_features": [
17
+ "mean_ratio",
18
+ "min_ratio",
19
+ "frac_low_03",
20
+ "frac_low_02",
21
+ "std_ratio",
22
+ "bottom3_mean",
23
+ "longest_low_streak",
24
+ "mean_ratio_numeric",
25
+ "frac_low_numeric",
26
+ "num_spans",
27
+ "max_span_score",
28
+ "mean_span_score",
29
+ "span_char_fraction",
30
+ "max_span_len"
31
+ ],
32
+ "seed": 1241,
33
+ "split": {
34
+ "train": 3308,
35
+ "test": 828
36
+ },
37
+ "backbone_lettuce": "KRLabsOrg/lettucedect-base-modernbert-en-v1",
38
+ "backbone_lookback": "Qwen/Qwen2.5-0.5B",
39
+ "source_dataset": "jameVee/ToolACE-Hallucination",
40
+ "hallucination_types": [
41
+ "missing_tool",
42
+ "overgeneration",
43
+ "tool_output_contradiction"
44
+ ],
45
+ "metrics_test": {
46
+ "lexical_span_verifier": {
47
+ "accuracy": 0.5664251207729468,
48
+ "f1": 0.5232403718459495,
49
+ "auroc": 0.6025060625811036,
50
+ "span_f1": 0.052821437773769644
51
+ },
52
+ "lettuce_span_supervised": {
53
+ "accuracy": 0.677536231884058,
54
+ "f1": 0.6317241379310344,
55
+ "auroc": 0.7214236643238388,
56
+ "span_f1": 0.20837031474021983
57
+ },
58
+ "lookback_span_supervised": {
59
+ "accuracy": 0.4830917874396135,
60
+ "f1": 0.5532359081419624,
61
+ "auroc": 0.5116260635205834,
62
+ "span_f1": 0.0
63
+ },
64
+ "soft_vote_ensemble": {
65
+ "accuracy": 0.6763285024154589,
66
+ "f1": 0.6598984771573604,
67
+ "auroc": 0.7214089849507066,
68
+ "span_f1": 0.17918399775669488
69
+ }
70
+ }
71
+ }