hallustream 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ """HalluStream: flag the facts an open LLM is probably making up, from its hidden states."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from hallustream.detector import Detector, Entity, Event, Report
7
+
8
+ __version__ = "0.2.0"
9
+ __all__ = ["Detector", "Entity", "Event", "Report", "__version__"]
10
+
11
+
12
+ def __getattr__(name: str):
13
+ # Loaded on first use, so the research code can import hallustream.* without the library
14
+ if name in ("Detector", "Entity", "Event", "Report"):
15
+ from hallustream import detector
16
+
17
+ return getattr(detector, name)
18
+ raise AttributeError(name)
hallustream/cli.py ADDED
@@ -0,0 +1,220 @@
1
+ """The hallustream command: answers with the details the model is probably making up highlighted.
2
+
3
+ hallustream "Tell me about Lake Tahoe." ask one question (the default)
4
+ hallustream chat have a conversation
5
+ hallustream score "Tell me about Lake Tahoe." a.txt check an answer it already wrote
6
+ hallustream batch prompts.jsonl -o results.jsonl check many prompts or answers
7
+
8
+ Answers appear sentence by sentence, once each sentence has been checked.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from hallustream import __version__
19
+ from hallustream.detector import BUNDLED, Detector, Entity, Report, _weights_dir, highlight
20
+
21
+ COMMANDS = ("ask", "chat", "score", "batch")
22
+ DIM, RESET = "\033[2m", "\033[0m"
23
+
24
+
25
+ def _parser() -> argparse.ArgumentParser:
26
+ shared = argparse.ArgumentParser(add_help=False)
27
+ shared.add_argument("--threshold", default="balanced",
28
+ help="balanced (default), strict, or a risk between 0 and 1") # fmt: skip
29
+ shared.add_argument("--weights", default="qwen3-4b", help="bundled name or weights folder")
30
+ shared.add_argument("--device", choices=["cuda", "mps", "cpu"],
31
+ help="default: the fastest available") # fmt: skip
32
+ p = argparse.ArgumentParser(
33
+ prog="hallustream",
34
+ description="Answers from Qwen3-4B with the details it is probably making up highlighted."
35
+ " Without a command, the arguments go to 'ask'.",
36
+ epilog='Example: hallustream "Tell me about Lake Tahoe." (see "hallustream ask -h" for'
37
+ " --threshold, --device, --json and the other options)",
38
+ )
39
+ p.add_argument("--version", action="version", version=f"hallustream {__version__}")
40
+ sub = p.add_subparsers(dest="command", required=True)
41
+ ask = sub.add_parser("ask", parents=[shared], help="answer one question (the default)")
42
+ ask.add_argument("prompt")
43
+ chat = sub.add_parser("chat", parents=[shared], help="have a conversation")
44
+ score = sub.add_parser("score", parents=[shared], help="check an answer it already wrote")
45
+ score.add_argument("prompt", help="the question the answer was written for")
46
+ score.add_argument("file", nargs="?", default="-", help="the answer (default: stdin)")
47
+ batch = sub.add_parser("batch", parents=[shared], help="check a file of prompts or answers")
48
+ batch.add_argument("input", help='JSONL with "prompt" (and "answer" to check one already '
49
+ 'written), or one prompt per line; - for stdin') # fmt: skip
50
+ batch.add_argument("-o", "--output", default="-", help="JSONL results (default: stdout)")
51
+ for s in (ask, chat, batch):
52
+ s.add_argument("--max-new-tokens", type=int, default=512)
53
+ for s in (ask, chat, score):
54
+ s.add_argument("--no-color", action="store_true")
55
+ for s in (ask, score):
56
+ s.add_argument("--json", action="store_true", help="print one JSON object instead")
57
+ return p
58
+
59
+
60
+ def _load(weights: str, device: str | None) -> Detector:
61
+ import logging as pylogging
62
+ import warnings
63
+
64
+ import torch
65
+ from huggingface_hub import try_to_load_from_cache
66
+ from transformers.utils import logging
67
+
68
+ if device == "cuda" and not torch.cuda.is_available():
69
+ sys.exit("hallustream: --device cuda, but no CUDA GPU is available")
70
+ if device == "mps" and not torch.backends.mps.is_available():
71
+ sys.exit("hallustream: --device mps, but this machine has no Apple GPU")
72
+ # This is a command for reading answers: library warnings (GLiNER, transformers, torch's
73
+ # macOS note about redirects) are turned off for the whole process.
74
+ warnings.simplefilter("ignore")
75
+ pylogging.getLogger("torch.distributed.elastic.multiprocessing.redirects").setLevel("ERROR")
76
+
77
+ hf_id = json.loads((_weights_dir(weights) / "model.json").read_text())["hf_id"]
78
+ if isinstance(try_to_load_from_cache(hf_id, "model.safetensors.index.json"), str):
79
+ logging.disable_progress_bar() # already downloaded: keep the terminal for the answer
80
+ else:
81
+ print(f"Downloading {hf_id} (about 8 GB, once)...", file=sys.stderr)
82
+ logging.set_verbosity_error()
83
+ return Detector.from_pretrained(weights, device=device)
84
+
85
+
86
+ def _segment(text: str, lo: int, hi: int, flagged: list[Entity], color: bool) -> str:
87
+ inside = [Entity(e.text, e.start - lo, e.end - lo, e.risk, True)
88
+ for e in flagged if lo <= e.start and e.end <= hi] # fmt: skip
89
+ return highlight(text[lo:hi], inside, "ansi" if color else "plain")
90
+
91
+
92
+ def _stream(det: Detector, messages: list[dict], max_new_tokens: int, threshold, color: bool,
93
+ ) -> Report: # fmt: skip
94
+ """Print each sentence once it has been checked; return the whole answer's report."""
95
+ text, printed, found, n = "", 0, [], 0
96
+ for ev in det.stream(messages=messages, max_new_tokens=max_new_tokens, threshold=threshold):
97
+ text += ev.text
98
+ found += ev.entities
99
+ n = len(ev.ids)
100
+ if ev.checked > printed:
101
+ print(_segment(text, printed, ev.checked, [e for e in found if e.flagged], color),
102
+ end="", flush=True) # fmt: skip
103
+ printed = ev.checked
104
+ cut = f"\n(cut off at {max_new_tokens} tokens; raise --max-new-tokens for more)"
105
+ print(_dim(cut, color) if n >= max_new_tokens else "")
106
+ return Report(text, found, det._threshold(threshold))
107
+
108
+
109
+ def _dim(text: str, color: bool) -> str:
110
+ return f"{DIM}{text}{RESET}" if color else text
111
+
112
+
113
+ def _legend(threshold: float, color: bool) -> str:
114
+ return _dim(f"Risk is the estimated chance a detail is made up; flagged from {threshold:.0%}.",
115
+ color) # fmt: skip
116
+
117
+
118
+ def _ask(det: Detector, a, threshold, color: bool) -> int:
119
+ if a.json:
120
+ report = det.generate(a.prompt, max_new_tokens=a.max_new_tokens, threshold=threshold)
121
+ print(json.dumps({"prompt": a.prompt, **report.to_dict()}, ensure_ascii=False))
122
+ return 0
123
+ msgs = [{"role": "user", "content": a.prompt}]
124
+ report = _stream(det, msgs, a.max_new_tokens, threshold, color)
125
+ print(f"\n{report.summary()}\n{_legend(report.threshold, color)}")
126
+ return 0
127
+
128
+
129
+ def _chat(det: Detector, a, threshold, color: bool) -> int:
130
+ legend = _legend(det._threshold(threshold), color)
131
+ print(f"Chat with the model; likely made-up details are highlighted.\n{legend}\n"
132
+ "Type exit or press Ctrl-D to quit.") # fmt: skip
133
+ messages: list[dict] = []
134
+ while True:
135
+ try:
136
+ line = input("\n> ").strip()
137
+ except (EOFError, KeyboardInterrupt):
138
+ print()
139
+ return 0
140
+ if line.lower() in ("exit", "quit"):
141
+ return 0
142
+ if not line:
143
+ continue
144
+ messages.append({"role": "user", "content": line})
145
+ report = _stream(det, messages, a.max_new_tokens, threshold, color)
146
+ messages.append({"role": "assistant", "content": report.text})
147
+ print(f"\n{report.summary()}")
148
+
149
+
150
+ def _score(det: Detector, a, threshold, color: bool) -> int:
151
+ text = (sys.stdin.read() if a.file == "-" else Path(a.file).read_text()).rstrip()
152
+ report = det.score(a.prompt, text, threshold=threshold)
153
+ if a.json:
154
+ print(json.dumps({"prompt": a.prompt, **report.to_dict()}, ensure_ascii=False))
155
+ return 0
156
+ print(report.highlight("ansi" if color else "plain"))
157
+ print(f"\n{report.summary()}\n{_legend(report.threshold, color)}")
158
+ return 0
159
+
160
+
161
+ def _batch(det: Detector, a, threshold, color: bool) -> int:
162
+ raw = sys.stdin.read() if a.input == "-" else Path(a.input).read_text()
163
+ lines = [x for x in raw.splitlines() if x.strip()]
164
+ out = sys.stdout if a.output == "-" else open(a.output, "w")
165
+ for i, line in enumerate(lines, 1):
166
+ try:
167
+ item: dict = (json.loads(line) if line.lstrip().startswith("{")
168
+ else {"prompt": line.strip()}) # fmt: skip
169
+ except json.JSONDecodeError as e:
170
+ sys.exit(f"{a.input} line {i}: not valid JSON ({e.msg})")
171
+ prompt, msgs = item.get("prompt"), item.get("messages")
172
+ if prompt is None and msgs is None:
173
+ sys.exit(f'{a.input} line {i}: needs a "prompt" or "messages"')
174
+ if "answer" in item:
175
+ report = det.score(prompt, item["answer"], messages=msgs, threshold=threshold)
176
+ else:
177
+ report = det.generate(prompt, messages=msgs, max_new_tokens=a.max_new_tokens,
178
+ threshold=threshold) # fmt: skip
179
+ out.write(json.dumps({**item, **report.to_dict()}, ensure_ascii=False) + "\n")
180
+ out.flush()
181
+ print(f"{i}/{len(lines)}: {len(report.flagged)} flagged", file=sys.stderr)
182
+ if out is not sys.stdout:
183
+ out.close()
184
+ return 0
185
+
186
+
187
+ def main(argv: list[str] | None = None, detector: Detector | None = None) -> int:
188
+ argv = list(sys.argv[1:] if argv is None else argv)
189
+ if argv and argv[0] not in (*COMMANDS, "-h", "--help", "--version"):
190
+ argv.insert(0, "ask")
191
+ p = _parser()
192
+ a = p.parse_args(argv)
193
+ if detector is None and not (_weights_dir(a.weights) / "model.json").exists():
194
+ p.error(f"--weights {a.weights}: not a bundled name {list(BUNDLED)} or a weights folder")
195
+ try:
196
+ threshold: str | float = float(a.threshold)
197
+ if not 0 <= threshold <= 1:
198
+ p.error(f"--threshold {a.threshold}: a risk must be between 0 and 1")
199
+ except ValueError:
200
+ threshold = a.threshold
201
+ file = _weights_dir(a.weights) / "thresholds.json"
202
+ presets = detector.thresholds if detector else json.loads(file.read_text())
203
+ if not isinstance(presets.get(threshold), dict):
204
+ names = [k for k, v in presets.items() if isinstance(v, dict)]
205
+ p.error(f"unknown threshold {threshold!r}; use one of {names} or a number")
206
+ path = getattr(a, "file", None) or getattr(a, "input", None)
207
+ if path and path != "-" and not Path(path).is_file():
208
+ p.error(f"{path}: no such file")
209
+ color = not getattr(a, "no_color", True) and sys.stdout.isatty()
210
+ run = {"ask": _ask, "chat": _chat, "score": _score, "batch": _batch}[a.command]
211
+ try:
212
+ det = detector or _load(a.weights, a.device)
213
+ return run(det, a, threshold, color)
214
+ except KeyboardInterrupt:
215
+ print(file=sys.stderr)
216
+ return 130
217
+
218
+
219
+ if __name__ == "__main__":
220
+ sys.exit(main())
@@ -0,0 +1,315 @@
1
+ """Flag the facts a model is probably making up, after it writes or while it writes.
2
+
3
+ det = Detector.from_pretrained("qwen3-4b")
4
+ report = det.generate("Tell me about Lake Tahoe.") # write an answer and check it
5
+ report = det.score("Tell me about Lake Tahoe.", answer) # or check one it already wrote
6
+ print(report.highlight("ansi"), report.summary(), sep="\n\n")
7
+ for event in det.stream("Tell me about Lake Tahoe."): # flags as each sentence ends
8
+ ...
9
+
10
+ The probe only knows what its own model knows: score text written by the model it was
11
+ trained for (Qwen3-4B-Instruct-2507 for the bundled weights), not other models' text.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import html
17
+ import json
18
+ import math
19
+ from collections.abc import Callable, Iterator
20
+ from dataclasses import asdict, dataclass, field
21
+ from functools import partial
22
+ from importlib.resources import files
23
+ from pathlib import Path
24
+
25
+ import numpy as np
26
+ import torch
27
+
28
+ from hallustream import entities as _entities
29
+ from hallustream.probe import ADAPTER, Forward, Probe, char_to_tokens, forward
30
+ from hallustream.text import PRONOUNS, is_refusal, sentences
31
+
32
+ BUNDLED = ("qwen3-4b",)
33
+ SENTENCE_END = ".!?"
34
+ EntityFinder = Callable[[str, list[tuple[int, int]]], list[tuple[int, int]]]
35
+
36
+
37
+ @dataclass
38
+ class Entity:
39
+ text: str
40
+ start: int # character offsets in the answer
41
+ end: int
42
+ risk: float # probability that the entity is made up (sigmoid of the adapted probe's score)
43
+ flagged: bool
44
+
45
+
46
+ @dataclass
47
+ class Report:
48
+ text: str
49
+ entities: list[Entity]
50
+ threshold: float
51
+
52
+ @property
53
+ def flagged(self) -> list[Entity]:
54
+ return [e for e in self.entities if e.flagged]
55
+
56
+ def highlight(self, style: str = "plain") -> str:
57
+ """The text with flagged entities marked: "plain" [[...]], "ansi" colors, or "html"."""
58
+ return highlight(self.text, self.flagged, style)
59
+
60
+ def summary(self) -> str:
61
+ """The flagged details, riskiest first, each listed once."""
62
+ checked = {e.text for e in self.entities}
63
+ risky: dict[str, float] = {}
64
+ for e in self.flagged:
65
+ risky[e.text] = max(risky.get(e.text, 0.0), e.risk)
66
+ if not checked:
67
+ return "No specific details to check."
68
+ if not risky:
69
+ return f"No details flagged ({len(checked)} checked)."
70
+ lines = [f"{len(risky)} of {len(checked)} details are likely made up:"]
71
+ lines += [f" {r:4.0%} {t}" for t, r in sorted(risky.items(), key=lambda x: -x[1])]
72
+ return "\n".join(lines)
73
+
74
+ def to_dict(self) -> dict:
75
+ return {
76
+ "text": self.text,
77
+ "threshold": self.threshold,
78
+ "n_flagged": len(self.flagged),
79
+ "entities": [{**asdict(e), "risk": round(e.risk, 4)} for e in self.entities],
80
+ }
81
+
82
+ def __str__(self) -> str:
83
+ return self.highlight()
84
+
85
+ def _repr_html_(self) -> str: # notebooks show the highlighted text and the summary
86
+ return (
87
+ f"<div style='white-space:pre-wrap;line-height:1.6'>{self.highlight('html')}"
88
+ f"</div><pre>{html.escape(self.summary())}</pre>"
89
+ )
90
+
91
+
92
+ @dataclass
93
+ class Event:
94
+ """One step of stream(): new text, and the entities of sentences that just finished."""
95
+
96
+ text: str
97
+ entities: list[Entity] = field(default_factory=list)
98
+ done: bool = False
99
+ checked: int = 0 # the text up to this character has been checked
100
+ ids: list[int] = field(default_factory=list) # on the last event: every generated token
101
+
102
+
103
+ def highlight(text: str, flagged: list[Entity], style: str = "plain") -> str:
104
+ spans: list[list] = []
105
+ for e in sorted(flagged, key=lambda e: e.start):
106
+ if spans and e.start < spans[-1][1]:
107
+ spans[-1][1], spans[-1][2] = max(spans[-1][1], e.end), max(spans[-1][2], e.risk)
108
+ else:
109
+ spans.append([e.start, e.end, e.risk])
110
+ esc = html.escape if style == "html" else (lambda s: s)
111
+ out, pos = [], 0
112
+ for lo, hi, risk in spans:
113
+ out.append(esc(text[pos:lo]))
114
+ inner = esc(text[lo:hi])
115
+ if style == "html":
116
+ out.append(
117
+ f'<mark class="hs-risk" style="background:#ffcdd2;color:#000" '
118
+ f'title="risk {risk:.0%}">{inner}</mark>'
119
+ )
120
+ elif style == "ansi":
121
+ out.append(f"\033[1;97;41m{inner}\033[0m")
122
+ else:
123
+ out.append(f"[[{inner}]]")
124
+ pos = hi
125
+ out.append(esc(text[pos:]))
126
+ return "".join(out)
127
+
128
+
129
+ def _default_device() -> str:
130
+ if torch.cuda.is_available():
131
+ return "cuda"
132
+ if torch.backends.mps.is_available():
133
+ return "mps"
134
+ return "cpu"
135
+
136
+
137
+ def _weights_dir(weights: str | Path) -> Path:
138
+ if str(weights) in BUNDLED:
139
+ return Path(str(files("hallustream") / "weights" / str(weights)))
140
+ return Path(weights)
141
+
142
+
143
+ def _check_weights(d: Path, weights: str | Path) -> None:
144
+ if not (d / "model.json").exists():
145
+ raise FileNotFoundError(
146
+ f"{weights!r} is not a bundled probe {list(BUNDLED)} or a folder with model.json"
147
+ )
148
+
149
+
150
+ class Detector:
151
+ """A model, its probe, and the thresholds for flagging an entity."""
152
+
153
+ def __init__(self, model, tokenizer, probe: Probe, threshold: str | float = 0.5,
154
+ chat_template_kwargs: dict | None = None, thresholds: dict | None = None,
155
+ entity_finder: EntityFinder | None = None): # fmt: skip
156
+ self.model, self.tokenizer, self.probe = model, tokenizer, probe
157
+ self.chat = chat_template_kwargs or {}
158
+ self.thresholds = thresholds or {}
159
+ self.threshold = self._threshold(threshold)
160
+ # GLiNER runs on the model's GPU when there is one (its MPS support is uneven)
161
+ gliner_device = "cuda" if str(getattr(model, "device", "cpu")).startswith("cuda") else "cpu"
162
+ self.find_entities = entity_finder or partial(_entities.find, device=gliner_device)
163
+
164
+ @classmethod
165
+ def from_pretrained(cls, weights: str | Path = "qwen3-4b", model=None, tokenizer=None,
166
+ device: str | None = None, dtype: torch.dtype = torch.bfloat16,
167
+ threshold: str | float = "balanced",
168
+ entity_finder: EntityFinder | None = None) -> Detector: # fmt: skip
169
+ """Load bundled weights by name ("qwen3-4b") or a weights folder, and the model they
170
+ were trained for (unless model and tokenizer are given)."""
171
+ from transformers import AutoModelForCausalLM, AutoTokenizer
172
+
173
+ d = _weights_dir(weights)
174
+ _check_weights(d, weights)
175
+ meta = json.loads((d / "model.json").read_text())
176
+ if model is None:
177
+ model = AutoModelForCausalLM.from_pretrained(meta["hf_id"], dtype=dtype)
178
+ model.to(device or _default_device()) # pyright: ignore[reportArgumentType]
179
+ model.eval()
180
+ tokenizer = tokenizer or AutoTokenizer.from_pretrained(meta["hf_id"])
181
+ seeds = sorted(d.glob("probe_seed*.safetensors"))
182
+ if not seeds:
183
+ raise FileNotFoundError(f"no probe weights (probe_seed*.safetensors) in {d}")
184
+ probe = Probe(seeds, d / ADAPTER, device=str(model.device))
185
+ if probe.weight.shape[0] != model.config.hidden_size:
186
+ raise ValueError(f"probe width {probe.weight.shape[0]} does not match the model")
187
+ th = d / "thresholds.json"
188
+ return cls(model, tokenizer, probe, threshold, meta.get("chat_template_kwargs"),
189
+ json.loads(th.read_text()) if th.exists() else {}, entity_finder) # fmt: skip
190
+
191
+ def _threshold(self, threshold: str | float | None) -> float:
192
+ if threshold is None:
193
+ return self.threshold
194
+ if isinstance(threshold, str):
195
+ presets = [k for k, v in self.thresholds.items() if isinstance(v, dict)]
196
+ if threshold not in presets:
197
+ hint = f"use one of {presets}" if presets else "no presets loaded; give a number"
198
+ raise ValueError(f"unknown threshold {threshold!r}: {hint}")
199
+ return float(self.thresholds[threshold]["threshold"])
200
+ if not 0 <= float(threshold) <= 1:
201
+ raise ValueError(f"threshold {threshold} is not a risk between 0 and 1")
202
+ return float(threshold)
203
+
204
+ def _messages(self, prompt: str | None, messages: list[dict] | None) -> list[dict]:
205
+ if messages is None:
206
+ if prompt is None:
207
+ raise ValueError("give a prompt or messages")
208
+ messages = [{"role": "user", "content": prompt}]
209
+ return messages
210
+
211
+ def _find(self, text: str, sents: list[tuple[int, int]]) -> list[tuple[int, int]]:
212
+ """Entities to score: none in sentences where the model says it does not know, and
213
+ never a bare pronoun."""
214
+ sents = [(lo, hi) for lo, hi in sents if not is_refusal(text[lo:hi])]
215
+ spans = self.find_entities(text, sents) if sents else []
216
+ return [(lo, hi) for lo, hi in spans if text[lo:hi].strip().lower() not in PRONOUNS]
217
+
218
+ def _entities(self, text: str, fw: Forward, spans, threshold: float) -> list[Entity]:
219
+ keep, tok_spans = [], []
220
+ for lo, hi in sorted(set(spans)):
221
+ if (t := char_to_tokens(fw, lo, hi)) is not None:
222
+ keep.append((lo, hi))
223
+ tok_spans.append(t)
224
+ _, adapted = self.probe.span_scores(fw, tok_spans)
225
+ risk = [1 / (1 + math.exp(-float(z))) for z in adapted]
226
+ return [Entity(text[lo:hi], lo, hi, r, r >= threshold)
227
+ for (lo, hi), r in zip(keep, risk, strict=True)] # fmt: skip
228
+
229
+ def score(self, prompt: str | None = None, text: str = "", *, messages: list[dict] | None
230
+ = None, spans: list[tuple[int, int]] | None = None,
231
+ threshold: str | float | None = None) -> Report: # fmt: skip
232
+ """Risk of every entity in `text`, an answer the model wrote to `prompt` (or after
233
+ `messages`). Entities are found automatically unless `spans` are given."""
234
+ th = self._threshold(threshold)
235
+ fw = forward(self.model, self.tokenizer, self.probe, self._messages(prompt, messages),
236
+ text, **self.chat) # fmt: skip
237
+ if spans is None:
238
+ spans = self._find(text, sentences(text))
239
+ return Report(text, self._entities(text, fw, spans, th), th)
240
+
241
+ def generate(self, prompt: str | None = None, *, messages: list[dict] | None = None,
242
+ max_new_tokens: int = 512, threshold: str | float | None = None,
243
+ temperature: float = 0.0) -> Report: # fmt: skip
244
+ """Write an answer and check it: stream() collected into one Report."""
245
+ text, found = "", []
246
+ for ev in self.stream(prompt, messages=messages, max_new_tokens=max_new_tokens,
247
+ threshold=threshold, temperature=temperature): # fmt: skip
248
+ text += ev.text
249
+ found += ev.entities
250
+ return Report(text, sorted(found, key=lambda e: e.start), self._threshold(threshold))
251
+
252
+ @torch.no_grad()
253
+ def stream(self, prompt: str | None = None, *, messages: list[dict] | None = None,
254
+ max_new_tokens: int = 512, threshold: str | float | None = None,
255
+ temperature: float = 0.0) -> Iterator[Event]: # fmt: skip
256
+ """Generate an answer token by token; when a sentence ends, its entities are scored
257
+ from the hidden states already computed for generation (no second pass)."""
258
+ from transformers import DynamicCache
259
+
260
+ th = self._threshold(threshold)
261
+ tok, lm = self.tokenizer, self.model
262
+ msgs = self._messages(prompt, messages)
263
+ prefix = tok.apply_chat_template(
264
+ msgs, add_generation_prompt=True, tokenize=False, **self.chat
265
+ )
266
+ ids = tok(prefix, add_special_tokens=False, return_tensors="pt").input_ids.to(lm.device)
267
+ eos = lm.generation_config.eos_token_id
268
+ stop = set(eos if isinstance(eos, list) else [eos]) | {tok.eos_token_id}
269
+ cache = DynamicCache()
270
+ out = lm(ids, past_key_values=cache, use_cache=True)
271
+ gen: list[int] = []
272
+ dec = [0] # dec[i]: length of the text decoded from the first i tokens
273
+ hidden: list[np.ndarray] = []
274
+ probs: list[np.ndarray] = []
275
+ text, scored, seen = "", set(), set() # sentences and entity spans already scored
276
+ for _ in range(max_new_tokens):
277
+ logits = out.logits[0, -1].float()
278
+ if temperature > 0:
279
+ nxt = int(torch.multinomial(torch.softmax(logits / temperature, -1), 1))
280
+ else:
281
+ nxt = int(logits.argmax())
282
+ if nxt in stop:
283
+ break
284
+ gen.append(nxt)
285
+ out = lm(torch.tensor([[nxt]], device=lm.device), past_key_values=cache,
286
+ use_cache=True, output_hidden_states=True) # fmt: skip
287
+ h = out.hidden_states[self.probe.layer + 1][0, -1]
288
+ hidden.append(h.half().cpu().numpy())
289
+ probs.append(self.probe.token_probs(h[None])[0])
290
+ ended = text.rstrip()[-1:] in SENTENCE_END or "\n" in text[-1:]
291
+ new = tok.decode(gen, skip_special_tokens=True)
292
+ dec.append(len(new))
293
+ delta = new[len(text) :] if new.startswith(text) else new
294
+ text = new
295
+ found = []
296
+ if ended:
297
+ found = self._finished(text, gen, dec, hidden, probs, scored, seen, th, False)
298
+ yield Event(delta, found, checked=max((hi for _, hi in scored), default=0))
299
+ last = self._finished(text, gen, dec, hidden, probs, scored, seen, th, True)
300
+ yield Event("", last, True, len(text), gen)
301
+
302
+ def _finished(self, text, gen, dec, hidden, probs, scored, seen, th, final) -> list[Entity]:
303
+ """Entities of sentences that are complete and not scored yet (each span once, even if
304
+ sentence boundaries shift as more text arrives)."""
305
+ sents = sentences(text)
306
+ todo = [s for s in (sents if final else sents[:-1]) if s not in scored]
307
+ if not todo or not gen:
308
+ return []
309
+ scored.update(todo)
310
+ n = len(gen)
311
+ fw = Forward(gen, list(zip(dec[:-1], dec[1:], strict=True)), 0, np.stack(probs),
312
+ np.stack(hidden), np.full(n, np.nan), np.full(n, np.nan)) # fmt: skip
313
+ spans = [s for s in self._find(text, todo) if s not in seen]
314
+ seen.update(spans)
315
+ return self._entities(text, fw, spans, th)
@@ -0,0 +1,75 @@
1
+ """Find the entities to score: names, places, dates, numbers, titles. Uses GLiNER, chosen on dev
2
+ biographies for finding the most false entities; install with
3
+ `pip install "hallustream[entities]"`, or pass your own spans."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from functools import lru_cache
8
+
9
+ # Entity types the judge labels (EVAL_PROTOCOL.md): people, organizations, places, dates,
10
+ # quantities, titles of works, awards; plus the descriptive roles it often includes.
11
+ GLINER_LABELS = [
12
+ "person", "organization", "location", "date", "number", "nationality",
13
+ "occupation", "title of work", "award", "event",
14
+ ] # fmt: skip
15
+ GLINER_MODEL = "urchade/gliner_medium-v2.1" # Apache-2.0
16
+ # Generic lowercase words ("region", "book", "team") are skipped: the fact checker almost never
17
+ # labels them, and on the library-check texts they drew 16-19% of all flags while dropping them
18
+ # kept 96-99% of the false entities found (research/entity_rule.py). Jobs and roles ("actress")
19
+ # stay: in biographies they are claims, and the probe ranks them well (0.92 AUROC).
20
+ ROLES = {"occupation"}
21
+ TITLES = {"mr", "mrs", "ms", "miss", "dr", "prof", "sir", "dame", "st"} # alone, not a detail
22
+ # Words that are capitalized only because they start a sentence ("The death of ...")
23
+ STARTERS = {
24
+ "the", "a", "an", "his", "her", "their", "its", "this", "that", "these", "those",
25
+ "in", "on", "at", "after", "before", "during", "by", "for", "with", "from", "as",
26
+ } # fmt: skip
27
+
28
+
29
+ def available() -> bool:
30
+ try:
31
+ import gliner # noqa: F401 # pyright: ignore[reportMissingImports]
32
+ except ImportError:
33
+ return False
34
+ return True
35
+
36
+
37
+ def is_specific(text: str, label: str, sentence_start: bool = False) -> bool:
38
+ """A name, place, title, date or number (it has a capital letter or a digit), or a role.
39
+
40
+ At the start of a sentence, a capitalized function word ("The death of ...") doesn't
41
+ count, and a lone title ("Mr.") is skipped (EVAL_PROTOCOL.md, follow-ups C)."""
42
+ words = text.split()
43
+ if not words or text.strip().rstrip(".").lower() in TITLES:
44
+ return False
45
+ if label in ROLES:
46
+ return True
47
+ if sentence_start and words[0].lower() in STARTERS:
48
+ text = " ".join(words[1:])
49
+ return any(c.isupper() or c.isdigit() for c in text)
50
+
51
+
52
+ @lru_cache(maxsize=1)
53
+ def _gliner(device: str = "cpu"):
54
+ from gliner import GLiNER # pyright: ignore[reportMissingImports]
55
+
56
+ return GLiNER.from_pretrained(GLINER_MODEL).to(device)
57
+
58
+
59
+ def find(text: str, sents: list[tuple[int, int]], threshold: float = 0.4,
60
+ device: str = "cpu") -> list[tuple]: # fmt: skip
61
+ """(start, end) character spans of specific entities, found sentence by sentence."""
62
+ if not available():
63
+ raise ImportError(
64
+ "finding entities needs GLiNER: pip install 'hallustream[entities]',"
65
+ " or pass spans=[(start, end), ...] yourself"
66
+ )
67
+ model = _gliner(device)
68
+ out = []
69
+ for lo, hi in sents:
70
+ for e in model.predict_entities(text[lo:hi], GLINER_LABELS, threshold=threshold):
71
+ a, b = lo + e["start"], lo + e["end"]
72
+ start = not text[lo:a].strip() # nothing but spaces before it in the sentence
73
+ if is_specific(text[a:b], e["label"], start):
74
+ out.append((a, b))
75
+ return out
File without changes