File size: 16,235 Bytes
40b1357
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
"""Provenance-safe annotations and controlled evaluation cases."""
from __future__ import annotations

import hashlib
import json
from enum import Enum
from pathlib import Path
from typing import Literal

from pydantic import Field, model_validator

from .schemas import ContractModel, FindingPolarity, Speaker

EVALUATION_DATASET_ID = "banking-evaluator-v2"
EVALUATION_DATASET_VERSION = "0.1.0"


class EvaluationSourceType(str, Enum):
    EXISTING_CALL = "existing_call"
    CONTROLLED_CHALLENGE = "controlled_challenge"


class AnnotatorType(str, Enum):
    HUMAN = "human"
    MODEL_ASSISTED_RESEARCHER_SEED = (
        "model_assisted_researcher_seed"
    )
    SYNTHETIC_AUTHOR = "synthetic_author"


class AnnotationStatus(str, Enum):
    DRAFT = "draft"
    REVIEWED = "reviewed"
    ADJUDICATED = "adjudicated"


class ExpectedAttention(str, Enum):
    REQUIRED = "required"
    NOT_REQUIRED = "not_required"
    UNCERTAIN = "uncertain"


class CounterfactualAxis(str, Enum):
    TRANSCRIPT = "transcript"
    DELIVERY = "delivery"
    OUTCOME = "outcome"
    CONTROL = "control"
    RECOVERY = "recovery"
    DURATION = "duration"


class ExpectedPairEffect(str, Enum):
    ATTENTION_CHANGES = "attention_changes"
    REASONS_CHANGE = "reasons_change"
    SUPPORT_CHANGES = "support_changes"
    RECOVERY_CHANGES = "recovery_changes"
    NO_DECISION_CHANGE = "no_decision_change"


class AcousticCondition(str, Enum):
    NOT_MODELED = "not_modeled"
    CALM = "calm"
    PERSISTENT_ELEVATION = "persistent_elevation"
    RECOVERED_ELEVATION = "recovered_elevation"
    UNRESOLVED_ELEVATION = "unresolved_elevation"


class AnnotationProvenance(ContractModel):
    annotator_id: str = Field(min_length=1)
    annotator_type: AnnotatorType
    status: AnnotationStatus
    method: str = Field(min_length=1)
    requires_human_review: bool
    ground_truth_basis: str | None = None

    @model_validator(mode="after")
    def validate_claim_strength(self):
        if (
            self.annotator_type
            == AnnotatorType.MODEL_ASSISTED_RESEARCHER_SEED
        ):
            if self.status != AnnotationStatus.DRAFT:
                raise ValueError(
                    "model-assisted seed annotations must remain draft"
                )
            if not self.requires_human_review:
                raise ValueError(
                    "model-assisted seed annotations require human review"
                )
            if self.ground_truth_basis:
                raise ValueError(
                    "model-assisted seed annotations are not ground truth"
                )
        if self.status == AnnotationStatus.ADJUDICATED:
            if (
                self.annotator_type == AnnotatorType.HUMAN
                and self.requires_human_review
            ):
                raise ValueError(
                    "human-adjudicated annotations cannot remain pending"
                )
            if (
                self.annotator_type == AnnotatorType.SYNTHETIC_AUTHOR
                and not self.ground_truth_basis
            ):
                raise ValueError(
                    "synthetic known answers require a controlled basis"
                )
        return self


class AnnotationEvidence(ContractModel):
    evidence_id: str = Field(
        min_length=1,
        pattern=r"^[a-zA-Z0-9_.:-]+$",
    )
    segment_id: str = Field(min_length=1)
    seq_id: int = Field(ge=0)
    speaker: Speaker
    start_seconds: float = Field(ge=0.0)
    end_seconds: float = Field(ge=0.0)
    quote: str = Field(min_length=1)
    purpose: str = Field(min_length=1)

    @model_validator(mode="after")
    def validate_time_range(self):
        if self.end_seconds < self.start_seconds:
            raise ValueError(
                "annotation evidence end must not precede start"
            )
        return self


class ExpectedFindingLabel(ContractModel):
    finding_type: str = Field(
        min_length=1,
        pattern=r"^[a-z0-9_.]+$",
    )
    polarity: FindingPolarity
    evidence_ids: list[str] = Field(default_factory=list)
    rationale: str = Field(min_length=1)


class CallAnnotation(ContractModel):
    annotation_id: str = Field(
        min_length=1,
        pattern=r"^[a-zA-Z0-9_.:-]+$",
    )
    call_id: str = Field(min_length=1)
    profile_id: str = Field(min_length=1)
    source_type: EvaluationSourceType
    expected_attention: ExpectedAttention
    expected_findings: list[ExpectedFindingLabel] = Field(
        default_factory=list
    )
    evidence: list[AnnotationEvidence] = Field(default_factory=list)
    uncertainty_notes: list[str] = Field(default_factory=list)
    provenance: AnnotationProvenance

    @model_validator(mode="after")
    def validate_annotation(self):
        evidence_ids = [item.evidence_id for item in self.evidence]
        if len(evidence_ids) != len(set(evidence_ids)):
            raise ValueError("annotation evidence ids must be unique")
        known_evidence = set(evidence_ids)
        finding_types = [
            finding.finding_type for finding in self.expected_findings
        ]
        if len(finding_types) != len(set(finding_types)):
            raise ValueError("expected finding types must be unique")
        for finding in self.expected_findings:
            if not set(finding.evidence_ids).issubset(known_evidence):
                raise ValueError(
                    "expected finding references unknown evidence"
                )
        negative = [
            item
            for item in self.expected_findings
            if item.polarity == FindingPolarity.NEGATIVE
        ]
        if (
            self.expected_attention == ExpectedAttention.REQUIRED
            and not negative
        ):
            raise ValueError(
                "required attention needs an expected negative finding"
            )
        if (
            self.expected_attention == ExpectedAttention.NOT_REQUIRED
            and negative
        ):
            raise ValueError(
                "not-required attention cannot include negative findings"
            )
        if (
            self.expected_attention == ExpectedAttention.UNCERTAIN
            and not self.uncertainty_notes
        ):
            raise ValueError(
                "uncertain attention requires an uncertainty note"
            )
        if (
            self.source_type == EvaluationSourceType.EXISTING_CALL
            and self.provenance.annotator_type
            == AnnotatorType.SYNTHETIC_AUTHOR
        ):
            raise ValueError(
                "existing calls cannot use synthetic-author provenance"
            )
        return self


class ExistingCallRecord(ContractModel):
    call_id: str = Field(min_length=1)
    transcript_path: str = Field(min_length=1)
    transcript_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
    annotation: CallAnnotation

    @model_validator(mode="after")
    def validate_call_id(self):
        if self.annotation.call_id != self.call_id:
            raise ValueError("existing annotation call id mismatch")
        if (
            self.annotation.source_type
            != EvaluationSourceType.EXISTING_CALL
        ):
            raise ValueError(
                "existing record requires existing-call annotation"
            )
        return self


class ChallengeSegment(ContractModel):
    segment_id: str = Field(min_length=1)
    seq_id: int = Field(ge=0)
    speaker: Speaker
    start_seconds: float = Field(ge=0.0)
    end_seconds: float = Field(ge=0.0)
    text: str = Field(min_length=1)

    @model_validator(mode="after")
    def validate_time_range(self):
        if self.end_seconds < self.start_seconds:
            raise ValueError(
                "challenge segment end must not precede start"
            )
        return self


class ChallengeCall(ContractModel):
    call_id: str = Field(min_length=1)
    title: str = Field(min_length=1)
    intent_ids: list[str] = Field(min_length=1)
    facts: dict[str, bool]
    acoustic_condition: AcousticCondition
    segments: list[ChallengeSegment] = Field(min_length=1)
    annotation: CallAnnotation

    @model_validator(mode="after")
    def validate_challenge(self):
        if self.annotation.call_id != self.call_id:
            raise ValueError("challenge annotation call id mismatch")
        if (
            self.annotation.source_type
            != EvaluationSourceType.CONTROLLED_CHALLENGE
        ):
            raise ValueError(
                "challenge requires controlled-challenge annotation"
            )
        segment_ids = [item.segment_id for item in self.segments]
        seq_ids = [item.seq_id for item in self.segments]
        if len(segment_ids) != len(set(segment_ids)):
            raise ValueError("challenge segment ids must be unique")
        if len(seq_ids) != len(set(seq_ids)):
            raise ValueError("challenge seq ids must be unique")
        return self


class CounterfactualPair(ContractModel):
    pair_id: str = Field(
        min_length=1,
        pattern=r"^[a-zA-Z0-9_.:-]+$",
    )
    axis: CounterfactualAxis
    baseline_call_id: str = Field(min_length=1)
    variant_call_id: str = Field(min_length=1)
    controlled_change: str = Field(min_length=1)
    invariant_dimensions: list[
        Literal[
            "intent_and_facts",
            "transcript",
            "acoustic_condition",
            "attention",
        ]
    ] = Field(min_length=1)
    expected_effect: ExpectedPairEffect

    @model_validator(mode="after")
    def validate_distinct_calls(self):
        if self.baseline_call_id == self.variant_call_id:
            raise ValueError(
                "counterfactual pair requires two distinct calls"
            )
        if len(self.invariant_dimensions) != len(
            set(self.invariant_dimensions)
        ):
            raise ValueError(
                "counterfactual invariant dimensions must be unique"
            )
        return self


class EvaluationDataset(ContractModel):
    schema_version: Literal["1.0"] = "1.0"
    dataset_id: Literal["banking-evaluator-v2"] = (
        EVALUATION_DATASET_ID
    )
    dataset_version: str = Field(min_length=1)
    profile_id: str = Field(min_length=1)
    existing_calls: list[ExistingCallRecord] = Field(
        default_factory=list
    )
    challenge_calls: list[ChallengeCall] = Field(
        default_factory=list
    )
    counterfactual_pairs: list[CounterfactualPair] = Field(
        default_factory=list
    )

    @model_validator(mode="after")
    def validate_dataset_references(self):
        all_calls = [
            item.call_id
            for item in self.existing_calls
        ] + [
            item.call_id
            for item in self.challenge_calls
        ]
        if len(all_calls) != len(set(all_calls)):
            raise ValueError("evaluation call ids must be unique")
        pair_ids = [item.pair_id for item in self.counterfactual_pairs]
        if len(pair_ids) != len(set(pair_ids)):
            raise ValueError("counterfactual pair ids must be unique")
        challenge_ids = {
            item.call_id for item in self.challenge_calls
        }
        for pair in self.counterfactual_pairs:
            if (
                pair.baseline_call_id not in challenge_ids
                or pair.variant_call_id not in challenge_ids
            ):
                raise ValueError(
                    "counterfactual pair references unknown challenge"
                )
        represented = {
            item.axis for item in self.counterfactual_pairs
        }
        if self.counterfactual_pairs and represented != set(
            CounterfactualAxis
        ):
            missing = sorted(
                item.value
                for item in set(CounterfactualAxis) - represented
            )
            raise ValueError(
                f"counterfactual axes are incomplete: {missing}"
            )
        return self


def file_sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def _transcript_segments(path: Path) -> tuple[str, dict[str, dict]]:
    payload = json.loads(path.read_text(encoding="utf-8"))
    return payload["call_id"], {
        item["id"]: item for item in payload["sentences"]
    }


def _validate_evidence(
    annotation: CallAnnotation,
    segments: dict[str, dict],
) -> None:
    for evidence in annotation.evidence:
        try:
            segment = segments[evidence.segment_id]
        except KeyError as exc:
            raise ValueError(
                f"{annotation.call_id}: unknown evidence segment "
                f"{evidence.segment_id!r}"
            ) from exc
        expected = {
            "seq_id": evidence.seq_id,
            "speaker": evidence.speaker.value.upper(),
            "start": evidence.start_seconds,
            "end": evidence.end_seconds,
            "text": evidence.quote,
        }
        actual = {
            "seq_id": segment["seq_id"],
            "speaker": segment["speaker"],
            "start": segment["start"],
            "end": segment["end"],
            "text": segment["text"],
        }
        if actual != expected:
            raise ValueError(
                f"{annotation.call_id}: evidence "
                f"{evidence.evidence_id!r} does not exactly match "
                "the transcript"
            )


def _challenge_segments(call: ChallengeCall) -> dict[str, dict]:
    return {
        item.segment_id: {
            "seq_id": item.seq_id,
            "speaker": item.speaker.value.upper(),
            "start": item.start_seconds,
            "end": item.end_seconds,
            "text": item.text,
        }
        for item in call.segments
    }


def _transcript_content(call: ChallengeCall) -> list[tuple[str, str]]:
    return [
        (item.speaker.value, item.text)
        for item in call.segments
    ]


def validate_evaluation_dataset(
    dataset: EvaluationDataset,
    repo_root: Path,
) -> None:
    """Validate source fidelity and declared pair invariants."""
    for record in dataset.existing_calls:
        path = repo_root / record.transcript_path
        if file_sha256(path) != record.transcript_sha256:
            raise ValueError(
                f"{record.call_id}: transcript sha256 changed"
            )
        call_id, segments = _transcript_segments(path)
        if call_id != record.call_id:
            raise ValueError(
                f"{record.call_id}: transcript call id mismatch"
            )
        _validate_evidence(record.annotation, segments)

    challenges = {
        item.call_id: item for item in dataset.challenge_calls
    }
    for call in challenges.values():
        _validate_evidence(
            call.annotation,
            _challenge_segments(call),
        )

    for pair in dataset.counterfactual_pairs:
        baseline = challenges[pair.baseline_call_id]
        variant = challenges[pair.variant_call_id]
        invariants = set(pair.invariant_dimensions)
        if "intent_and_facts" in invariants and (
            baseline.intent_ids != variant.intent_ids
            or baseline.facts != variant.facts
        ):
            raise ValueError(
                f"{pair.pair_id}: intent_and_facts invariant failed"
            )
        if (
            "transcript" in invariants
            and _transcript_content(baseline)
            != _transcript_content(variant)
        ):
            raise ValueError(
                f"{pair.pair_id}: transcript invariant failed"
            )
        if (
            "acoustic_condition" in invariants
            and baseline.acoustic_condition
            != variant.acoustic_condition
        ):
            raise ValueError(
                f"{pair.pair_id}: acoustic invariant failed"
            )
        if (
            "attention" in invariants
            and baseline.annotation.expected_attention
            != variant.annotation.expected_attention
        ):
            raise ValueError(
                f"{pair.pair_id}: attention invariant failed"
            )


def load_evaluation_dataset(path: Path) -> EvaluationDataset:
    return EvaluationDataset.model_validate_json(
        path.read_text(encoding="utf-8")
    )