File size: 16,547 Bytes
fed954e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
"""
evaluator.py — Scores model output along three orthogonal axes:

  1. SCHEMA VALIDITY:  does it parse as JSON, and validate against the Pydantic schema?
  2. GROUNDING:        is every leaf string traceable to the input caption?
                       This is the hallucination metric. v0.2: per-slot rule
                       driven by `groundedness` in registry.SLOT_REGISTRY.
  3. COVERAGE:         did the model surface the obvious nouns/verbs from the input,
                       or did it drop information? (cheap recall signal)

Grounding rules (per slot, read from registry):
  - must_ground   : every leaf MUST trace to input. Otherwise hallucinated.
  - may_infer     : leaf is allowed regardless of input. Counted as grounded.
                    Closed-vocab values (e.g. "indoor") are also auto-grounded
                    because the grammar enforces the value space anyway.
  - derived_only  : leaf is expected to be inferred. Auto-grounded, never penalized.
"""

from __future__ import annotations
import json
import re
from dataclasses import dataclass, field, asdict
from typing import Any, List, Optional, Tuple

from pydantic import BaseModel, ValidationError

from .schema import Caption
from .registry import SLOT_REGISTRY, SubjectValue, all_closed_vocab


# Auto-grounded values — anything in any closed vocab is always counted as
# grounded since the grammar pins the value space.
CLOSED_VOCAB: set[str] = all_closed_vocab()


# ──────────────────────────────────────────────────────────────────────────────
# Parsing — recover JSON from messy model output (markdown fences, prose, etc.)
# ──────────────────────────────────────────────────────────────────────────────

_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)


def _strip_fences(text: str) -> str:
    """If the model wrapped output in ```json ... ```, peel it off. First fence wins."""
    m = _FENCE_RE.search(text)
    return m.group(1).strip() if m else text.strip()


def _extract_first_json_object(text: str) -> Optional[str]:
    """
    Walk the text and return the first balanced {...} substring.
    Tolerates leading prose. Returns None if no balanced object found.
    """
    text = _strip_fences(text)
    start = text.find("{")
    if start < 0:
        return None
    depth = 0
    in_str = False
    esc = False
    for i in range(start, len(text)):
        c = text[i]
        if esc:
            esc = False
            continue
        if c == "\\":
            esc = True
            continue
        if c == '"':
            in_str = not in_str
            continue
        if in_str:
            continue
        if c == "{":
            depth += 1
        elif c == "}":
            depth -= 1
            if depth == 0:
                return text[start:i + 1]
    return None


@dataclass
class GenericParseReport:
    """Result of recovering + validating JSON against an arbitrary Pydantic model.

    Two repair signals, because not all repair is equal:
      `needed_repair`            — any recovery was needed (not clean bare JSON).
      `needed_structural_repair` — recovery needed MORE than stripping a markdown
                                   code fence (prose prefix, trailing tokens,
                                   runaway generation). Fence-stripping is benign
                                   and 100% deterministic, so the vision
                                   `json_robustness` metric keys off the STRUCTURAL
                                   signal — a model that only wraps clean JSON in
                                   ```fences``` is still robust; one that buries it
                                   in prose is not.
    """
    parsed: Optional[Any]
    schema_valid: bool
    error: Optional[str]
    needed_repair: bool = False
    needed_structural_repair: bool = False


def parse_against(raw_text: str, model: type[BaseModel]) -> GenericParseReport:
    """Recover the first JSON object from raw model output and validate it against
    `model`. Never raises. Used by both the caption path (model=Caption) and the
    vision metrics (model=per-category schema)."""
    obj_str = _extract_first_json_object(raw_text)
    if obj_str is None:
        return GenericParseReport(None, False, "no JSON object found",
                                  needed_repair=True, needed_structural_repair=True)
    stripped = raw_text.strip()
    needed_repair = stripped != obj_str
    # If the ONLY thing between the raw text and the object was a code fence, the
    # fence-stripped text equals the object → benign (fence-only) repair.
    fenced_inner = _strip_fences(raw_text).strip()
    fence_only = needed_repair and (fenced_inner == obj_str)
    needed_structural_repair = needed_repair and not fence_only
    try:
        as_dict = json.loads(obj_str)
    except json.JSONDecodeError as e:
        return GenericParseReport(None, False, f"json decode: {e}",
                                  needed_repair=True, needed_structural_repair=True)
    try:
        parsed = model.model_validate(as_dict)
    except ValidationError as e:
        return GenericParseReport(None, False, f"schema: {e.errors()[:2]}",
                                  needed_repair=needed_repair,
                                  needed_structural_repair=needed_structural_repair)
    return GenericParseReport(parsed, True, None, needed_repair=needed_repair,
                              needed_structural_repair=needed_structural_repair)


@dataclass
class ParseReport:
    parsed: Optional[Caption]
    schema_valid: bool
    error: Optional[str]


def parse_safely(raw_text: str) -> ParseReport:
    """Try to recover a Caption object from raw model output. Never raises."""
    r = parse_against(raw_text, Caption)
    return ParseReport(r.parsed, r.schema_valid, r.error)


# ──────────────────────────────────────────────────────────────────────────────
# Grounding — the hallucination metric
# ──────────────────────────────────────────────────────────────────────────────

_TOKEN_RE = re.compile(r"[a-z0-9]+")


def _normalize(s: str) -> str:
    return s.lower().strip()


def _tokens(s: str) -> List[str]:
    return _TOKEN_RE.findall(s.lower())


def _depluralize(token: str) -> str:
    """Cheap singularization: drop trailing -s, -es, -ies. No NLTK dependency.

    LIMITATION: irregular plurals (children, mice, geese, men) are not handled.
    Those will surface as false-positive hallucinations. Upgrade to a real
    lemmatizer if irregular-plural FPs become a problem in production data.
    """
    if len(token) <= 3:
        return token
    if token.endswith("ies"):
        return token[:-3] + "y"
    if token.endswith("es"):
        return token[:-2]
    if token.endswith("s"):
        return token[:-1]
    return token


def _is_grounded(leaf: str, input_caption: str) -> bool:
    """Does `leaf` trace back to `input_caption`?"""
    leaf_norm = _normalize(leaf)
    if leaf_norm in CLOSED_VOCAB:
        return True

    cap_norm = _normalize(input_caption)
    # Direct substring (handles multi-word phrases like "blue car")
    if leaf_norm in cap_norm:
        return True

    # Token-level: every token of the leaf (after singularization) must appear in caption
    cap_tokens = {_depluralize(t) for t in _tokens(input_caption)}
    leaf_tokens = [_depluralize(t) for t in _tokens(leaf)]
    if leaf_tokens and all(t in cap_tokens for t in leaf_tokens):
        return True
    return False


@dataclass
class GroundingReport:
    leaves_total: int
    leaves_grounded: int
    hallucinated: List[Tuple[str, str]]  # (field_path, value)

    @property
    def grounding_rate(self) -> float:
        return self.leaves_grounded / self.leaves_total if self.leaves_total else 1.0


def _collect_leaves(caption: Caption) -> List[Tuple[str, str, str]]:
    """Walk the caption and return (path, value, groundedness) for every leaf.

    Closed-vocab single-value slots are NOT included — their value space is
    grammar-enforced, so they can't hallucinate by definition.
    """
    leaves: List[Tuple[str, str, str]] = []
    for slot_name, spec in SLOT_REGISTRY.items():
        val = getattr(caption, slot_name)

        if spec.cardinality == "list":
            if spec.nested_model is SubjectValue:
                for i, subj in enumerate(val):
                    leaves.append((f"{slot_name}[{i}].name", subj.name, spec.groundedness))
                    for j, attr in enumerate(subj.attributes):
                        leaves.append(
                            (f"{slot_name}[{i}].attributes[{j}]", attr, spec.groundedness)
                        )
            else:
                for i, item in enumerate(val):
                    leaves.append((f"{slot_name}[{i}]", item, spec.groundedness))
        else:
            if val is None:
                continue
            if spec.vocabulary == "closed":
                # Value space is grammar-enforced — auto-grounded, not a leaf.
                continue
            leaves.append((slot_name, val, spec.groundedness))
    return leaves


def ground_check(caption: Caption, input_text: str) -> GroundingReport:
    """Walk every leaf in the parsed caption; flag per the slot's groundedness rule.

      - must_ground: leaf must trace to input or it's hallucinated
      - may_infer:   leaf auto-counts as grounded (closed enums + soft slots)
      - derived_only: leaf auto-counts as grounded (model is expected to infer)
    """
    leaves = _collect_leaves(caption)
    grounded = 0
    halluc: List[Tuple[str, str]] = []

    for path, val, groundedness in leaves:
        if groundedness in ("may_infer", "derived_only"):
            grounded += 1
            continue
        # must_ground — strict check
        if _is_grounded(val, input_text):
            grounded += 1
        else:
            halluc.append((path, val))

    return GroundingReport(
        leaves_total=len(leaves),
        leaves_grounded=grounded,
        hallucinated=halluc,
    )


# ──────────────────────────────────────────────────────────────────────────────
# Coverage — did the model surface the obvious nouns from input? (cheap recall)
# ──────────────────────────────────────────────────────────────────────────────

# Common English stop-tokens we don't expect to appear as caption subjects/actions
_STOP = {
    "a", "an", "the", "of", "in", "on", "at", "to", "and", "or", "with",
    "is", "are", "was", "were", "be", "been", "being",
    "this", "that", "these", "those", "it", "its",
    "for", "from", "by", "as", "into", "onto", "over", "under",
}


def _content_tokens(text: str) -> set[str]:
    return {_depluralize(t) for t in _tokens(text) if t not in _STOP and len(t) > 2}


@dataclass
class CoverageReport:
    input_content_tokens: int
    output_coverage: int

    @property
    def coverage_rate(self) -> float:
        return self.output_coverage / self.input_content_tokens if self.input_content_tokens else 1.0


def _collect_output_strings(caption: Caption) -> list[str]:
    """All string content the model produced, for coverage / recall scoring.

    Iterates the registry so new slots automatically participate in coverage.
    Closed-vocab single-value slots are excluded — their values come from the
    enum, not from input content, so they're not informative for recall.
    """
    out: list[str] = []
    for slot_name, spec in SLOT_REGISTRY.items():
        val = getattr(caption, slot_name)
        if spec.cardinality == "list":
            if spec.nested_model is SubjectValue:
                for subj in val:
                    out.append(subj.name)
                    out.extend(subj.attributes)
            else:
                out.extend(val)
        else:
            if val is None:
                continue
            if spec.vocabulary == "closed":
                continue
            out.append(val)
    return out


def coverage_check(caption: Caption, input_text: str) -> CoverageReport:
    in_tokens = _content_tokens(input_text)
    out_blob = " ".join(_collect_output_strings(caption))
    out_tokens = _content_tokens(out_blob)
    overlap = in_tokens & out_tokens
    return CoverageReport(len(in_tokens), len(overlap))


# ──────────────────────────────────────────────────────────────────────────────
# Per-sample and per-run aggregation
# ──────────────────────────────────────────────────────────────────────────────

@dataclass
class SampleResult:
    input_caption: str
    mode: str
    raw_output: str
    schema_valid: bool
    parse_error: Optional[str]
    grounding_rate: float
    hallucinations: List[Tuple[str, str]]
    coverage_rate: float
    n_input_tokens: int
    n_output_tokens: int

    def to_dict(self) -> dict:
        return asdict(self)


def score_sample(
    input_caption: str,
    raw_output: str,
    mode: str,
    n_input_tokens: int = 0,
    n_output_tokens: int = 0,
) -> SampleResult:
    parse = parse_safely(raw_output)
    if not parse.schema_valid or parse.parsed is None:
        return SampleResult(
            input_caption=input_caption,
            mode=mode,
            raw_output=raw_output,
            schema_valid=False,
            parse_error=parse.error,
            grounding_rate=0.0,
            hallucinations=[],
            coverage_rate=0.0,
            n_input_tokens=n_input_tokens,
            n_output_tokens=n_output_tokens,
        )

    g = ground_check(parse.parsed, input_caption)
    c = coverage_check(parse.parsed, input_caption)
    return SampleResult(
        input_caption=input_caption,
        mode=mode,
        raw_output=raw_output,
        schema_valid=True,
        parse_error=None,
        grounding_rate=g.grounding_rate,
        hallucinations=g.hallucinated,
        coverage_rate=c.coverage_rate,
        n_input_tokens=n_input_tokens,
        n_output_tokens=n_output_tokens,
    )


@dataclass
class RunMetrics:
    mode: str
    n_samples: int
    schema_valid_rate: float
    mean_grounding_rate: float
    mean_coverage_rate: float
    total_hallucinations: int
    samples_with_zero_hallucinations: int

    def __str__(self) -> str:
        return (
            f"[{self.mode}] n={self.n_samples}  "
            f"schema_valid={self.schema_valid_rate:.1%}  "
            f"grounding={self.mean_grounding_rate:.1%}  "
            f"coverage={self.mean_coverage_rate:.1%}  "
            f"clean_samples={self.samples_with_zero_hallucinations}/{self.n_samples}  "
            f"halluc_total={self.total_hallucinations}"
        )


def score_run(results: List[SampleResult]) -> RunMetrics:
    if not results:
        return RunMetrics("empty", 0, 0.0, 0.0, 0.0, 0, 0)
    mode = results[0].mode
    n = len(results)
    valid = [r for r in results if r.schema_valid]
    return RunMetrics(
        mode=mode,
        n_samples=n,
        schema_valid_rate=len(valid) / n,
        mean_grounding_rate=sum(r.grounding_rate for r in valid) / len(valid) if valid else 0.0,
        mean_coverage_rate=sum(r.coverage_rate for r in valid) / len(valid) if valid else 0.0,
        total_hallucinations=sum(len(r.hallucinations) for r in results),
        samples_with_zero_hallucinations=sum(
            1 for r in results if r.schema_valid and not r.hallucinations
        ),
    )