typevet 0.1.0.dev1__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.
Files changed (133) hide show
  1. typevet/__init__.py +67 -0
  2. typevet/_version.py +43 -0
  3. typevet/adapters/__init__.py +16 -0
  4. typevet/adapters/diagnostics/__init__.py +59 -0
  5. typevet/adapters/diagnostics/fields.py +108 -0
  6. typevet/adapters/diagnostics/generation_events.py +75 -0
  7. typevet/adapters/diagnostics/http_events.py +91 -0
  8. typevet/adapters/diagnostics/logs.py +116 -0
  9. typevet/adapters/diagnostics/redaction.py +94 -0
  10. typevet/adapters/diagnostics/settings.py +77 -0
  11. typevet/adapters/inbound/__init__.py +65 -0
  12. typevet/adapters/inbound/api.py +61 -0
  13. typevet/adapters/inbound/backend_settings.py +608 -0
  14. typevet/adapters/inbound/cord_semantic_acceptance_cli.py +135 -0
  15. typevet/adapters/inbound/helpers.py +67 -0
  16. typevet/adapters/inbound/settings.py +154 -0
  17. typevet/adapters/outbound/__init__.py +62 -0
  18. typevet/adapters/outbound/async_fake.py +123 -0
  19. typevet/adapters/outbound/async_llama_cpp.py +168 -0
  20. typevet/adapters/outbound/chat_completion.py +148 -0
  21. typevet/adapters/outbound/fake.py +119 -0
  22. typevet/adapters/outbound/gemma/__init__.py +84 -0
  23. typevet/adapters/outbound/gemma/answer_binding.py +290 -0
  24. typevet/adapters/outbound/gemma/scoring_prefix.py +102 -0
  25. typevet/adapters/outbound/gemma/served_template.py +131 -0
  26. typevet/adapters/outbound/gemma_native_vision_factory.py +286 -0
  27. typevet/adapters/outbound/generation_finite.py +50 -0
  28. typevet/adapters/outbound/http_errors.py +38 -0
  29. typevet/adapters/outbound/judgment_scoring.py +471 -0
  30. typevet/adapters/outbound/llama_cpp.py +187 -0
  31. typevet/adapters/outbound/llama_cpp_http.py +103 -0
  32. typevet/adapters/outbound/llama_cpp_multimodal.py +145 -0
  33. typevet/adapters/outbound/llama_cpp_scoring.py +333 -0
  34. typevet/adapters/outbound/vllm_content.py +63 -0
  35. typevet/adapters/outbound/vllm_generation.py +163 -0
  36. typevet/adapters/outbound/vllm_generation_async.py +257 -0
  37. typevet/adapters/outbound/vllm_http.py +121 -0
  38. typevet/adapters/outbound/vllm_judgment_factory.py +210 -0
  39. typevet/adapters/outbound/vllm_scoring.py +372 -0
  40. typevet/domain/__init__.py +193 -0
  41. typevet/domain/candidate_scoring_request.py +134 -0
  42. typevet/domain/candidate_scoring_response.py +96 -0
  43. typevet/domain/candidate_scoring_validate.py +197 -0
  44. typevet/domain/decision_compile.py +368 -0
  45. typevet/domain/decision_execute.py +229 -0
  46. typevet/domain/decisions.py +129 -0
  47. typevet/domain/errors.py +235 -0
  48. typevet/domain/field_instructions.py +134 -0
  49. typevet/domain/judgment_answers.py +198 -0
  50. typevet/domain/judgment_normalize.py +243 -0
  51. typevet/domain/judgment_questions.py +187 -0
  52. typevet/domain/judgment_response.py +102 -0
  53. typevet/domain/media.py +101 -0
  54. typevet/domain/models.py +115 -0
  55. typevet/domain/question_schema.py +254 -0
  56. typevet/domain/scoring_stage.py +34 -0
  57. typevet/evaluation/__init__.py +19 -0
  58. typevet/evaluation/consumer_http_accounting.py +135 -0
  59. typevet/evaluation/cord_expense_call_accounting.py +108 -0
  60. typevet/evaluation/cord_expense_live_harness.py +75 -0
  61. typevet/evaluation/cord_expense_receipt_requirement.py +212 -0
  62. typevet/evaluation/cord_expense_smoke.py +328 -0
  63. typevet/evaluation/cord_semantic_acceptance.py +463 -0
  64. typevet/evaluation/cord_semantic_acceptance_report.py +90 -0
  65. typevet/evaluation/cord_semantic_metrics.py +57 -0
  66. typevet/evaluation/datasets/__init__.py +30 -0
  67. typevet/evaluation/datasets/banking77.py +270 -0
  68. typevet/evaluation/datasets/boolq.py +550 -0
  69. typevet/evaluation/datasets/boolq_download.py +132 -0
  70. typevet/evaluation/datasets/civil_comments.py +450 -0
  71. typevet/evaluation/datasets/clinc.py +125 -0
  72. typevet/evaluation/datasets/clinc_domains.json +172 -0
  73. typevet/evaluation/datasets/clinc_download.py +103 -0
  74. typevet/evaluation/datasets/clinc_plus_intent_names.json +153 -0
  75. typevet/evaluation/datasets/clinc_rows.py +306 -0
  76. typevet/evaluation/datasets/clinc_shard.py +156 -0
  77. typevet/evaluation/datasets/cord_expense.py +381 -0
  78. typevet/evaluation/datasets/difraud.py +256 -0
  79. typevet/evaluation/datasets/go_emotions.py +368 -0
  80. typevet/evaluation/datasets/go_emotions_download.py +154 -0
  81. typevet/evaluation/datasets/hyperpartisan.py +562 -0
  82. typevet/evaluation/datasets/partner_guard.py +170 -0
  83. typevet/evaluation/datasets/psai.py +415 -0
  84. typevet/evaluation/datasets/psai_download.py +167 -0
  85. typevet/evaluation/datasets/psai_evidence_pilot.py +570 -0
  86. typevet/evaluation/datasets/psai_schema.py +157 -0
  87. typevet/evaluation/datasets/psai_stream.py +166 -0
  88. typevet/evaluation/datasets/psai_vision.py +534 -0
  89. typevet/evaluation/datasets/psai_vision_controls.py +502 -0
  90. typevet/evaluation/datasets/pubmedqa.py +420 -0
  91. typevet/evaluation/experiment_identity.py +677 -0
  92. typevet/evaluation/psai_vision_consumer_accounting.py +190 -0
  93. typevet/evaluation/psai_vision_consumer_dispatch.py +216 -0
  94. typevet/evaluation/psai_vision_consumer_harness.py +342 -0
  95. typevet/evaluation/psai_vision_consumer_live.py +332 -0
  96. typevet/evaluation/psai_vision_consumer_live_identity.py +156 -0
  97. typevet/evaluation/psai_vision_consumer_live_receipt.py +132 -0
  98. typevet/evaluation/psai_vision_consumer_live_router.py +162 -0
  99. typevet/evaluation/psai_vision_consumer_offline.py +418 -0
  100. typevet/evaluation/psai_vision_consumer_outcomes.py +224 -0
  101. typevet/evaluation/psai_vision_consumer_protocol.py +69 -0
  102. typevet/evaluation/psai_vision_consumer_receipt.py +161 -0
  103. typevet/evaluation/psai_vision_consumer_receipt_structure.py +263 -0
  104. typevet/evaluation/psai_vision_probability_evidence.py +336 -0
  105. typevet/evaluation/runner/__init__.py +56 -0
  106. typevet/evaluation/runner/core.py +104 -0
  107. typevet/evaluation/runner/datasets.py +186 -0
  108. typevet/evaluation/runner/live_gate.py +149 -0
  109. typevet/evaluation/runner/report.py +116 -0
  110. typevet/evaluation/tpjep/__init__.py +81 -0
  111. typevet/evaluation/tpjep/loader.py +237 -0
  112. typevet/evaluation/tpjep/outcome.py +101 -0
  113. typevet/evaluation/tpjep/records.py +428 -0
  114. typevet/evaluation/tpjep/runner.py +325 -0
  115. typevet/ports/__init__.py +39 -0
  116. typevet/ports/async_generation.py +57 -0
  117. typevet/ports/framing.py +59 -0
  118. typevet/ports/generation.py +58 -0
  119. typevet/ports/judgment.py +71 -0
  120. typevet/ports/scoring.py +64 -0
  121. typevet/py.typed +0 -0
  122. typevet/runtime/__init__.py +50 -0
  123. typevet/runtime/categorical.py +196 -0
  124. typevet/runtime/judgment.py +17 -0
  125. typevet/runtime/llama_cpp_gemma_vision.py +28 -0
  126. typevet/runtime/scoring_prefix.py +14 -0
  127. typevet/runtime/vllm_judgment.py +28 -0
  128. typevet/testing/__init__.py +22 -0
  129. typevet/testing/fakes.py +152 -0
  130. typevet-0.1.0.dev1.dist-info/METADATA +125 -0
  131. typevet-0.1.0.dev1.dist-info/RECORD +133 -0
  132. typevet-0.1.0.dev1.dist-info/WHEEL +4 -0
  133. typevet-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,502 @@
1
+ """Questions, states and image controls for the PSAI vision smoke ([#154][i154]).
2
+
3
+ Two legs share the fixture set.
4
+
5
+ The **annotation leg** asks ``category`` and ``requires_login`` over the PSAI
6
+ ``task_name`` state, exactly as the metadata loader does. Hub annotations back
7
+ both answers.
8
+
9
+ The **visual leg** asks the manual ``shows_fox_news_chrome`` Noul three ways for
10
+ every row: image present, image omitted, and image swapped with the opposite
11
+ visual family. It must not reuse ``task_name``. Every PSAI task text pins its
12
+ site (``Only use http://foxnews.com``), which hands the model the answer as
13
+ text and would void the controls. ``VISUAL_CONTROL_STATE`` is family-neutral
14
+ instead, so pixels are the only family signal in the prompt.
15
+
16
+ An omitted image is **never** a semantic hit. With no image the model still
17
+ answers from its prior, so a match there measures the prior, not the pixels.
18
+
19
+ Two rules read the visual leg, and they differ in what they assume.
20
+ ``semantic_hit`` reads one probability against ``NOUL_THRESHOLD``, which only
21
+ means something when the Noul is calibrated. ``paired_image_ordering`` compares
22
+ the two imaged controls of one row against each other. Their text is identical,
23
+ so the difference is the pixels, and no calibration assumption is needed. Prefer
24
+ the paired rule as the gate and keep the threshold reading as a diagnostic.
25
+
26
+ [i154]: https://github.com/Alberto-Codes/typevet/issues/154
27
+
28
+ Examples:
29
+ Build the control matrix for the vendored rows:
30
+
31
+ ```python
32
+ from pathlib import Path
33
+
34
+ from typevet.evaluation.datasets.psai_vision import load_vision_smoke
35
+ from typevet.evaluation.datasets.psai_vision_controls import control_matrix
36
+
37
+ root = Path("tests/fixtures/psai/vision_smoke")
38
+ fixture = load_vision_smoke((root / "manifest.json").read_text())
39
+ controls = control_matrix(fixture.examples)
40
+ assert len(controls) == len(fixture.examples) * 3
41
+ ```
42
+
43
+ See Also:
44
+ - [typevet.evaluation.datasets.psai_vision][]: fixture loading and provenance
45
+ - [typevet.ports.judgment][]: the ``judge`` surface these questions feed
46
+ - docs/how-to/run-the-psai-vision-smoke.md: the recipe
47
+
48
+ Attributes:
49
+ VISUAL_CONTROL_STATE (str): Family-neutral state for the visual leg.
50
+ CONDITION_PRESENT (str): Correct screenshot attached.
51
+ CONDITION_OMITTED (str): Same text, no image.
52
+ CONDITION_SWAPPED (str): Same text, opposite-family screenshot.
53
+ CONDITIONS (tuple[str, ...]): The three conditions in run order.
54
+ FAMILY_LEAK_TERMS (tuple[str, ...]): Terms a visual state must never hold.
55
+ NOUL_THRESHOLD (float): Probability at or above which a Noul reads true.
56
+ PAIRED_MARGIN_FLOOR (float): Smallest present-against-swapped margin that
57
+ still counts as image-conditioned.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ from collections.abc import Mapping, Sequence
63
+ from dataclasses import dataclass
64
+ from typing import Final
65
+
66
+ from typevet.domain.judgment_questions import Choice, Noul, Question
67
+ from typevet.evaluation.datasets.psai import psai_state, validate_task_state
68
+ from typevet.evaluation.datasets.psai_schema import (
69
+ CATEGORY_LABELS,
70
+ METADATA_DECISIONS_SCHEMA,
71
+ PRIMARY_NOUL_NAME,
72
+ )
73
+ from typevet.evaluation.datasets.psai_vision import (
74
+ ANNOTATION_PROVENANCE,
75
+ FOX_FAMILY,
76
+ MANUAL_VISUAL_PROVENANCE,
77
+ NON_FOX_FAMILY,
78
+ VISUAL_FAMILIES,
79
+ VISUAL_QUESTION_NAME,
80
+ PsaiVisionExample,
81
+ )
82
+
83
+ VISUAL_CONTROL_STATE: Final[str] = (
84
+ "A screen capture taken during a recorded desktop browsing session."
85
+ )
86
+ CONDITION_PRESENT: Final[str] = "present"
87
+ CONDITION_OMITTED: Final[str] = "omitted"
88
+ CONDITION_SWAPPED: Final[str] = "swapped"
89
+ CONDITIONS: Final[tuple[str, ...]] = (
90
+ CONDITION_PRESENT,
91
+ CONDITION_OMITTED,
92
+ CONDITION_SWAPPED,
93
+ )
94
+ FAMILY_LEAK_TERMS: Final[tuple[str, ...]] = (
95
+ "fox",
96
+ "news",
97
+ "home depot",
98
+ "homedepot",
99
+ "squarespace",
100
+ )
101
+ NOUL_THRESHOLD: Final[float] = 0.5
102
+ PAIRED_MARGIN_FLOOR: Final[float] = 0.10
103
+
104
+ _VISUAL_INSTRUCTIONS: Final[str] = "Is the website in this screenshot Fox News?"
105
+ _VISUAL_CRITERIA: Final[dict[str, str]] = {
106
+ "true": ("Fox News site chrome or branding is clearly visible in the screenshot."),
107
+ "false": "It is not.",
108
+ }
109
+
110
+
111
+ @dataclass(frozen=True, slots=True)
112
+ class VisualControl:
113
+ """One matched condition for the visually dependent question.
114
+
115
+ Attributes:
116
+ unique_data_id (str): Row under test.
117
+ visual_family (str): Visual family of that row.
118
+ condition (str): One of ``CONDITIONS``.
119
+ state (str): Prompt state; always the family-neutral constant.
120
+ image_file (str | None): Screenshot to attach, ``None`` when omitted.
121
+ image_unique_data_id (str | None): Row the screenshot came from.
122
+ expected (bool | None): Gold polarity, ``None`` when nothing is provable.
123
+ counts_as_semantic_hit (bool): Whether a match may score.
124
+
125
+ Examples:
126
+ ```python
127
+ from typevet.evaluation.datasets.psai_vision_controls import VisualControl
128
+
129
+ control = VisualControl(
130
+ unique_data_id="row",
131
+ visual_family="fox_news",
132
+ condition="omitted",
133
+ state="neutral",
134
+ image_file=None,
135
+ image_unique_data_id=None,
136
+ expected=None,
137
+ counts_as_semantic_hit=False,
138
+ )
139
+ assert control.expected is None
140
+ ```
141
+ """
142
+
143
+ unique_data_id: str
144
+ visual_family: str
145
+ condition: str
146
+ state: str
147
+ image_file: str | None
148
+ image_unique_data_id: str | None
149
+ expected: bool | None
150
+ counts_as_semantic_hit: bool
151
+
152
+
153
+ def visual_question() -> Noul:
154
+ """Return the manual visually dependent Noul.
155
+
156
+ The text names Fox News because that is the question. It never says which
157
+ row or family the attached screenshot belongs to.
158
+
159
+ Returns:
160
+ Noul with ``true`` and ``false`` criteria.
161
+ """
162
+ return Noul(instructions=_VISUAL_INSTRUCTIONS, criteria=dict(_VISUAL_CRITERIA))
163
+
164
+
165
+ def annotation_questions() -> dict[str, Question]:
166
+ """Return the Hub annotation-backed questions.
167
+
168
+ Returns:
169
+ ``category`` Choice and ``requires_login`` Noul, with Hub instructions.
170
+ """
171
+ props = METADATA_DECISIONS_SCHEMA["properties"]
172
+ return {
173
+ "category": Choice(
174
+ criteria=dict.fromkeys(CATEGORY_LABELS, None),
175
+ instructions=props["category"]["instructions"],
176
+ ),
177
+ PRIMARY_NOUL_NAME: Noul(instructions=props[PRIMARY_NOUL_NAME]["instructions"]),
178
+ }
179
+
180
+
181
+ def vision_questions() -> dict[str, Question]:
182
+ """Return every question the smoke scores.
183
+
184
+ Returns:
185
+ The annotation-backed pair plus the manual visual Noul.
186
+ """
187
+ questions = annotation_questions()
188
+ questions[VISUAL_QUESTION_NAME] = visual_question()
189
+ return questions
190
+
191
+
192
+ def question_provenance() -> dict[str, str]:
193
+ """Return the gold provenance of each question.
194
+
195
+ Returns:
196
+ Question name to ``annotation`` or ``manual_visual``.
197
+ """
198
+ return {
199
+ "category": ANNOTATION_PROVENANCE,
200
+ PRIMARY_NOUL_NAME: ANNOTATION_PROVENANCE,
201
+ VISUAL_QUESTION_NAME: MANUAL_VISUAL_PROVENANCE,
202
+ }
203
+
204
+
205
+ def annotation_state(example: PsaiVisionExample) -> dict[str, str]:
206
+ """Build the annotation-leg state for one row.
207
+
208
+ Args:
209
+ example: Row whose task text to evaluate.
210
+
211
+ Returns:
212
+ Mapping with a single ``task_name`` key.
213
+
214
+ Raises:
215
+ ValueError: When the state would carry a banned leakage key.
216
+ """
217
+ state = psai_state(example.task_name)
218
+ validate_task_state(state)
219
+ return state
220
+
221
+
222
+ def validate_no_family_leak(text: str) -> None:
223
+ """Reject prompt text that names a visual family.
224
+
225
+ Args:
226
+ text: Candidate state text for the visual leg.
227
+
228
+ Raises:
229
+ ValueError: When ``text`` holds a term from ``FAMILY_LEAK_TERMS``, which
230
+ would let the model answer the visual question without the image.
231
+ """
232
+ lowered = text.lower()
233
+ found = [term for term in FAMILY_LEAK_TERMS if term in lowered]
234
+ if found:
235
+ names = ", ".join(found)
236
+ msg = (
237
+ f"visual-leg text would leak the visual family; remove: {names}. "
238
+ "A text-derivable answer voids the image controls."
239
+ )
240
+ raise ValueError(msg)
241
+
242
+
243
+ def _swap_donors(
244
+ examples: Sequence[PsaiVisionExample],
245
+ ) -> dict[str, PsaiVisionExample]:
246
+ """Pair each row with a deterministic opposite-family donor.
247
+
248
+ Args:
249
+ examples: Rows to pair.
250
+
251
+ Returns:
252
+ ``unique_data_id`` to the opposite-family row that donates its image.
253
+
254
+ Raises:
255
+ ValueError: When either visual family has no row to donate.
256
+ """
257
+ by_family = {
258
+ family: sorted(
259
+ (e for e in examples if e.visual_family == family),
260
+ key=lambda e: e.stream_order,
261
+ )
262
+ for family in VISUAL_FAMILIES
263
+ }
264
+ for family, rows in by_family.items():
265
+ if not rows:
266
+ msg = (
267
+ f"the swap control needs at least one {family!r} row; "
268
+ f"got none among {len(examples)} rows"
269
+ )
270
+ raise ValueError(msg)
271
+ opposite = {FOX_FAMILY: NON_FOX_FAMILY, NON_FOX_FAMILY: FOX_FAMILY}
272
+ donors: dict[str, PsaiVisionExample] = {}
273
+ for family, rows in by_family.items():
274
+ pool = by_family[opposite[family]]
275
+ for position, row in enumerate(rows):
276
+ donors[row.unique_data_id] = pool[position % len(pool)]
277
+ return donors
278
+
279
+
280
+ def control_matrix(
281
+ examples: Sequence[PsaiVisionExample],
282
+ ) -> tuple[VisualControl, ...]:
283
+ """Build matched present / omitted / swapped controls for every row.
284
+
285
+ Args:
286
+ examples: Rows to evaluate; both visual families must appear.
287
+
288
+ Returns:
289
+ Controls grouped by row, in ``CONDITIONS`` order.
290
+
291
+ Raises:
292
+ ValueError: When either visual family has no row, so no swap donor
293
+ exists.
294
+ """
295
+ donors = _swap_donors(examples)
296
+ controls: list[VisualControl] = []
297
+ for example in examples:
298
+ donor = donors[example.unique_data_id]
299
+ controls.extend(
300
+ (
301
+ VisualControl(
302
+ unique_data_id=example.unique_data_id,
303
+ visual_family=example.visual_family,
304
+ condition=CONDITION_PRESENT,
305
+ state=VISUAL_CONTROL_STATE,
306
+ image_file=example.screenshot.file_name,
307
+ image_unique_data_id=example.unique_data_id,
308
+ expected=example.shows_fox_news_chrome,
309
+ counts_as_semantic_hit=True,
310
+ ),
311
+ VisualControl(
312
+ unique_data_id=example.unique_data_id,
313
+ visual_family=example.visual_family,
314
+ condition=CONDITION_OMITTED,
315
+ state=VISUAL_CONTROL_STATE,
316
+ image_file=None,
317
+ image_unique_data_id=None,
318
+ expected=None,
319
+ counts_as_semantic_hit=False,
320
+ ),
321
+ VisualControl(
322
+ unique_data_id=example.unique_data_id,
323
+ visual_family=example.visual_family,
324
+ condition=CONDITION_SWAPPED,
325
+ state=VISUAL_CONTROL_STATE,
326
+ image_file=donor.screenshot.file_name,
327
+ image_unique_data_id=donor.unique_data_id,
328
+ expected=donor.shows_fox_news_chrome,
329
+ counts_as_semantic_hit=True,
330
+ ),
331
+ )
332
+ )
333
+ return tuple(controls)
334
+
335
+
336
+ def noul_polarity(probability: float, threshold: float = NOUL_THRESHOLD) -> bool:
337
+ """Read a Noul probability as a boolean answer.
338
+
339
+ Args:
340
+ probability: Probability the scorer gave the ``true`` outcome.
341
+ threshold: Cut at or above which the answer reads true.
342
+
343
+ Returns:
344
+ ``True`` when ``probability >= threshold``.
345
+ """
346
+ return probability >= threshold
347
+
348
+
349
+ def semantic_hit(
350
+ control: VisualControl,
351
+ probability: float,
352
+ threshold: float = NOUL_THRESHOLD,
353
+ ) -> bool:
354
+ """Score one control against the contract semantic rule.
355
+
356
+ Present must match the row's own gold. Swapped must match the donor family,
357
+ so the polarity flips. Omitted never scores, whatever it answered.
358
+
359
+ Args:
360
+ control: Control row under test.
361
+ probability: Scored probability of the ``true`` outcome.
362
+ threshold: Polarity cut passed to ``noul_polarity``.
363
+
364
+ Returns:
365
+ ``True`` only for a counted control whose polarity matches ``expected``.
366
+ """
367
+ if not control.counts_as_semantic_hit or control.expected is None:
368
+ return False
369
+ return noul_polarity(probability, threshold) is control.expected
370
+
371
+
372
+ @dataclass(frozen=True, slots=True)
373
+ class PairedOrdering:
374
+ """The present/swapped pair for one row, compared image against image.
375
+
376
+ Both members carry byte-identical text, so the probability difference is
377
+ attributable to the pixels alone. This needs no calibration assumption,
378
+ unlike reading one probability against a fixed threshold.
379
+
380
+ Attributes:
381
+ unique_data_id (str): Row the pair belongs to.
382
+ fox_image_id (str): Row that supplied the Fox News screenshot.
383
+ fox_probability (float): Scored ``true`` probability for that image.
384
+ non_fox_image_id (str): Row that supplied the non-Fox screenshot.
385
+ non_fox_probability (float): Scored ``true`` probability for that image.
386
+ margin (float): ``fox_probability - non_fox_probability``.
387
+ ordered (bool): Whether ``margin`` clears the floor.
388
+
389
+ Examples:
390
+ ```python
391
+ from typevet.evaluation.datasets.psai_vision_controls import PairedOrdering
392
+
393
+ pair = PairedOrdering(
394
+ unique_data_id="row",
395
+ fox_image_id="fox",
396
+ fox_probability=0.92,
397
+ non_fox_image_id="shop",
398
+ non_fox_probability=0.09,
399
+ margin=0.83,
400
+ ordered=True,
401
+ )
402
+ assert pair.ordered
403
+ ```
404
+ """
405
+
406
+ unique_data_id: str
407
+ fox_image_id: str
408
+ fox_probability: float
409
+ non_fox_image_id: str
410
+ non_fox_probability: float
411
+ margin: float
412
+ ordered: bool
413
+
414
+
415
+ def paired_image_ordering(
416
+ controls: Sequence[VisualControl],
417
+ probabilities: Mapping[tuple[str, str], float],
418
+ floor: float = PAIRED_MARGIN_FLOOR,
419
+ ) -> tuple[PairedOrdering, ...]:
420
+ """Compare each row's two imaged controls against one another.
421
+
422
+ The present and swapped controls of one row always hold opposite families,
423
+ because the swap donor comes from the other family. Requiring the Fox image
424
+ to outscore the non-Fox image tests conditioning without assuming the model
425
+ is calibrated at any particular cut.
426
+
427
+ Args:
428
+ controls: Control rows that were run.
429
+ probabilities: ``(unique_data_id, condition)`` to scored probability.
430
+ floor: Smallest margin that still counts as ordered.
431
+
432
+ Returns:
433
+ One ``PairedOrdering`` per row, in row order.
434
+
435
+ Raises:
436
+ KeyError: When an imaged control has no scored probability.
437
+ ValueError: When a row does not hold one Fox and one non-Fox image.
438
+ """
439
+ pairs: list[PairedOrdering] = []
440
+ for uid in dict.fromkeys(control.unique_data_id for control in controls):
441
+ imaged = [
442
+ control
443
+ for control in controls
444
+ if control.unique_data_id == uid and control.image_unique_data_id
445
+ ]
446
+ fox = [control for control in imaged if control.expected is True]
447
+ non_fox = [control for control in imaged if control.expected is False]
448
+ if len(fox) != 1 or len(non_fox) != 1:
449
+ msg = (
450
+ f"row {uid!r} needs one Fox and one non-Fox image to pair; got "
451
+ f"{len(fox)} Fox and {len(non_fox)} non-Fox"
452
+ )
453
+ raise ValueError(msg)
454
+ fox_probability = probabilities[(uid, fox[0].condition)]
455
+ non_fox_probability = probabilities[(uid, non_fox[0].condition)]
456
+ margin = fox_probability - non_fox_probability
457
+ pairs.append(
458
+ PairedOrdering(
459
+ unique_data_id=uid,
460
+ fox_image_id=str(fox[0].image_unique_data_id),
461
+ fox_probability=fox_probability,
462
+ non_fox_image_id=str(non_fox[0].image_unique_data_id),
463
+ non_fox_probability=non_fox_probability,
464
+ margin=margin,
465
+ ordered=margin >= floor,
466
+ )
467
+ )
468
+ return tuple(pairs)
469
+
470
+
471
+ def control_summary(
472
+ controls: Sequence[VisualControl],
473
+ probabilities: Mapping[tuple[str, str], float],
474
+ threshold: float = NOUL_THRESHOLD,
475
+ ) -> dict[str, int]:
476
+ """Count counted controls and semantic hits for a receipt.
477
+
478
+ Args:
479
+ controls: Control rows that were run.
480
+ probabilities: ``(unique_data_id, condition)`` to scored probability.
481
+ threshold: Polarity cut passed to ``semantic_hit``.
482
+
483
+ Returns:
484
+ Counts under ``counted``, ``hits`` and ``uncounted``.
485
+
486
+ Raises:
487
+ KeyError: When a control has no scored probability.
488
+ """
489
+ counted = 0
490
+ hits = 0
491
+ for control in controls:
492
+ probability = probabilities[(control.unique_data_id, control.condition)]
493
+ if not control.counts_as_semantic_hit:
494
+ continue
495
+ counted += 1
496
+ if semantic_hit(control, probability, threshold):
497
+ hits += 1
498
+ return {
499
+ "counted": counted,
500
+ "hits": hits,
501
+ "uncounted": len(controls) - counted,
502
+ }