trace2eval-cli 0.2.1__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.
trace2eval/select.py ADDED
@@ -0,0 +1,709 @@
1
+ """Deduplication and case construction.
2
+
3
+ Two jobs live here.
4
+
5
+ **Collapse near-duplicates.** A real log asks the same question forty times. If
6
+ you promote all forty, you get a slow suite that nobody trusts. So we cluster the
7
+ whole log by input similarity and promote one case per cluster -- and because we
8
+ cluster the *log* rather than the candidates, a question that appeared 200 times
9
+ but only went wrong once still becomes a single case that is honestly labelled
10
+ "stands for 200 of the calls we recorded".
11
+
12
+ **Build the case.** This is where the interesting judgement sits, and the rule is
13
+ narrow on purpose: *a reference output is not ground truth*. Most of what we
14
+ select is selected precisely because something went wrong with it. So an expected
15
+ shape is inferred from the *clean* answers to the same question -- never from the
16
+ output that failed. When a question has no clean answer anywhere in the log,
17
+ there is nothing to infer from, and the case says so rather than inventing one.
18
+
19
+ Every case is then checked against its own reference, which is how the second job
20
+ grades itself: a failure seed that does not fail its own checks is a seed the
21
+ checks cannot detect, and that is worth knowing.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import importlib
27
+ import json
28
+ import re
29
+ import statistics
30
+ from dataclasses import dataclass, field
31
+ from typing import Any, Callable, Iterable
32
+
33
+ from .checks import run_checks
34
+ from .schema import Trace
35
+ from .signals import (
36
+ Signal,
37
+ TraceContext,
38
+ build_context,
39
+ compute_signals,
40
+ score,
41
+ suspiciously_short_boundary,
42
+ )
43
+
44
+ #: Signals that mean "something already went wrong on this row".
45
+ QUALITY_SIGNALS = frozenset(
46
+ {
47
+ "negative_feedback",
48
+ "user_retried",
49
+ "empty_output",
50
+ "fallback_phrase",
51
+ "expected_shape_violated",
52
+ "output_much_shorter",
53
+ }
54
+ )
55
+
56
+ #: Failures that left a mark on the output text, and so are checkable at all.
57
+ SHAPE_SIGNALS = frozenset(
58
+ {
59
+ "empty_output",
60
+ "fallback_phrase",
61
+ "expected_shape_violated",
62
+ "output_much_shorter",
63
+ "output_much_longer",
64
+ }
65
+ )
66
+
67
+ #: Failures about what happened *around* the call -- the user asked again, rated it
68
+ #: down, or it was slow and expensive. The output text can be perfectly fine, so no
69
+ #: deterministic check on it can reproduce these. Naming the distinction is the
70
+ #: difference between "this case is weak" and "this class of case cannot be checked
71
+ #: this way, and here is what it would take instead".
72
+ BEHAVIOUR_SIGNALS = frozenset(
73
+ {
74
+ "negative_feedback",
75
+ "user_retried",
76
+ "slow_response",
77
+ "expensive_call",
78
+ }
79
+ )
80
+
81
+ #: A similarity function: two raw inputs in, a score in [0, 1] out. The default is
82
+ #: :func:`shingle_overlap`; anything with this signature can be swapped in from the
83
+ #: command line with ``--similarity module:function``.
84
+ Matcher = Callable[[str, str], float]
85
+
86
+
87
+ class MatcherSpecError(ValueError):
88
+ """Raised when a ``--similarity`` spec cannot be resolved to a callable."""
89
+
90
+
91
+ #: Character n-grams, not word tokens: Chinese has no whitespace word boundaries,
92
+ #: so character n-grams are the only thing that works for both languages with one
93
+ #: code path. Bigrams rather than trigrams because user queries are short -- a
94
+ #: trigram over a five-character question leaves almost nothing to match on.
95
+ SHINGLE_SIZE = 2
96
+
97
+ #: Two inputs are only compared at all if they share at least this many n-grams.
98
+ #: Without it, two unrelated short queries that happen to share one character
99
+ #: pair can score 1.0 on the overlap coefficient below.
100
+ MIN_SHARED_SHINGLES = 2
101
+
102
+ #: Default similarity threshold.
103
+ #:
104
+ #: Chosen by sweeping the sample log and checking each merged group by hand:
105
+ #:
106
+ #: threshold merged groups collapsed verdict
107
+ #: 0.50 6 14 merges "支持哪些登录方式" with
108
+ #: "支持哪些支付方式" -- different questions
109
+ #: 0.55 4 11 still merges the same false pair
110
+ #: 0.60 3 9 no false pairs on the sample log
111
+ #: 0.70 2 5 starts missing real duplicates
112
+ #:
113
+ #: 0.6 is where precision comes out clean without losing the groups that matter.
114
+ #: It is tuned to one 42-row sample, so treat it as a starting point, not a
115
+ #: constant of nature -- which is why ``--dedup-threshold`` exists.
116
+ DEFAULT_DEDUP_THRESHOLD = 0.6
117
+
118
+ #: Safety valve on clustering cost. A cluster compares an incoming trace against
119
+ #: one fingerprint per *distinct* phrasing already in it (see ``Cluster``), so the
120
+ #: cost is bounded by distinct phrasings rather than by cluster size. This caps
121
+ #: that number anyway. Past the cap, a trace that only matches a phrasing beyond
122
+ #: the window starts its own cluster -- a duplicate rather than a miss, which is
123
+ #: the failure direction to prefer.
124
+ MAX_DISTINCT_FINGERPRINTS = 512
125
+
126
+ _STRIP_PATTERN = re.compile(r"[\s,。!?、;:,.!?;:\"'“”‘’()()\[\]【】]+")
127
+
128
+ #: Inferred minimum length is a fraction of the reference length. 0.4 is
129
+ #: deliberately loose: it catches *truncation*, not rewording.
130
+ _MIN_CHARS_RATIO = 0.4
131
+ _MIN_CHARS_FLOOR = 8
132
+
133
+ #: When a question has no clean answer to learn a shape from, the only thing we
134
+ #: still know for certain is that an empty answer was wrong. Asserting a
135
+ #: non-empty answer is not a guess about length, so it is safe to add.
136
+ _NON_EMPTY_MIN_CHARS = 1
137
+
138
+
139
+ @dataclass
140
+ class ScoredTrace:
141
+ trace: Trace
142
+ signals: list[Signal]
143
+ score: float
144
+
145
+ @property
146
+ def is_clean(self) -> bool:
147
+ """True when nothing about this row suggests it went wrong.
148
+
149
+ Only clean rows may be used as a shape reference: their output is the
150
+ closest thing to an answer that was actually fine in production.
151
+ """
152
+ if self.trace.output_chars == 0:
153
+ return False
154
+ return not ({signal.name for signal in self.signals} & QUALITY_SIGNALS)
155
+
156
+
157
+ @dataclass
158
+ class Cluster:
159
+ """One distinct question, plus every log line that asked it."""
160
+
161
+ representative: Trace
162
+ signals: list[Signal]
163
+ score: float
164
+ member_ids: list[str] = field(default_factory=list)
165
+
166
+ #: Outputs from members that look clean, in arrival order. This is the pool a
167
+ #: case's expected shape is inferred from.
168
+ clean_outputs: list[str] = field(default_factory=list)
169
+
170
+ #: One representative text per *distinct* input phrasing seen in this cluster,
171
+ #: with the matching fingerprints alongside. Deduplicating here is what keeps
172
+ #: a cluster of 5,000 identical questions from costing 5,000 comparisons when
173
+ #: question 5,001 arrives.
174
+ fingerprints: list[frozenset[str]] = field(default_factory=list)
175
+ sample_texts: list[str] = field(default_factory=list)
176
+
177
+ @property
178
+ def size(self) -> int:
179
+ return len(self.member_ids)
180
+
181
+ @property
182
+ def duplicate_count(self) -> int:
183
+ return max(0, self.size - 1)
184
+
185
+ @property
186
+ def duplicate_ids(self) -> list[str]:
187
+ return [mid for mid in self.member_ids if mid != self.representative.id]
188
+
189
+ @property
190
+ def has_usable_shape_reference(self) -> bool:
191
+ return bool(self.clean_outputs)
192
+
193
+
194
+ @dataclass
195
+ class SelectionResult:
196
+ cases: list[dict[str, Any]] = field(default_factory=list)
197
+ context: TraceContext | None = None
198
+ total_traces: int = 0
199
+ distinct_questions: int = 0
200
+ questions_without_signals: int = 0
201
+ dropped_below_min_score: int = 0
202
+ dropped_as_duplicate: int = 0
203
+ dropped_beyond_limit: int = 0
204
+
205
+ @property
206
+ def cases_with_weak_checks(self) -> list[dict[str, Any]]:
207
+ """Cases where the checks are known not to catch the failure they came from."""
208
+ return [
209
+ case
210
+ for case in self.cases
211
+ if case["self_check"]["verdict"] == "failure_not_reproduced"
212
+ ]
213
+
214
+ @property
215
+ def cases_whose_reference_fails(self) -> list[dict[str, Any]]:
216
+ """Trusted references that do not satisfy their own checks. A real defect."""
217
+ return [
218
+ case
219
+ for case in self.cases
220
+ if case["self_check"]["verdict"] == "reference_fails_own_checks"
221
+ ]
222
+
223
+ def stats(self) -> dict[str, Any]:
224
+ return {
225
+ "total_traces": self.total_traces,
226
+ "distinct_questions": self.distinct_questions,
227
+ "questions_without_signals": self.questions_without_signals,
228
+ "cases": len(self.cases),
229
+ "dropped_below_min_score": self.dropped_below_min_score,
230
+ "dropped_as_duplicate": self.dropped_as_duplicate,
231
+ "dropped_beyond_limit": self.dropped_beyond_limit,
232
+ "cases_with_weak_checks": len(self.cases_with_weak_checks),
233
+ "cases_whose_reference_fails": len(self.cases_whose_reference_fails),
234
+ }
235
+
236
+
237
+ def normalise(text: str) -> str:
238
+ """Fold case and strip punctuation so two phrasings of one question collapse."""
239
+ return _STRIP_PATTERN.sub("", text.strip().lower())
240
+
241
+
242
+ def shingles(text: str, size: int = SHINGLE_SIZE) -> set[str]:
243
+ folded = normalise(text)
244
+ if not folded:
245
+ return set()
246
+ if len(folded) <= size:
247
+ return {folded}
248
+ return {folded[i : i + size] for i in range(len(folded) - size + 1)}
249
+
250
+
251
+ def overlap_coefficient(
252
+ left: set[str], right: set[str], min_shared: int = MIN_SHARED_SHINGLES
253
+ ) -> float:
254
+ """Overlap coefficient -- not Jaccard.
255
+
256
+ This choice matters more than the threshold does, and it was made by
257
+ measuring rather than by habit. Measured on the sample log:
258
+
259
+ pair overlap jaccard same?
260
+ "你们的退款政策是什么?" vs "退款政策" 1.000 0.333 yes
261
+ "你们的退款政策是什么?" vs "退款政策是怎样的" 0.571 0.333 yes
262
+ "修改手机号" vs "我要改手机号" 0.750 0.500 yes
263
+ "支持哪些登录方式" vs "支持哪些支付方式" 0.571 0.400 NO
264
+ "登录不上" vs "支持哪些登录方式" 0.000 0.111 no
265
+
266
+ Read the third and fourth rows together. Jaccard ranks two genuinely
267
+ *different* questions (0.400) above two phrasings of the *same* question
268
+ (0.333). It cannot separate them, because it divides by the union and so
269
+ punishes any length difference -- and short user queries are almost always a
270
+ fragment of a longer phrasing.
271
+
272
+ Overlap divides by the *shorter* set, which asks the question we actually
273
+ care about: does the shorter query sit inside the longer one? It gets all
274
+ four rows right. The cost is higher false-positive pressure on very short
275
+ inputs, which ``min_shared`` guards against and which the threshold sweep in
276
+ ``DEFAULT_DEDUP_THRESHOLD`` was tuned against.
277
+
278
+ What this still cannot do is recognise a paraphrase that shares no
279
+ characters with the original. That is a scope boundary, not a bug -- see
280
+ ``trace2eval.matchers`` and ``--similarity`` for how to swap in something
281
+ semantic if recall matters more than staying dependency-free.
282
+ """
283
+ if not left or not right:
284
+ return 0.0
285
+ shared = len(left & right)
286
+ if shared < min_shared:
287
+ return 0.0
288
+ return shared / min(len(left), len(right))
289
+
290
+
291
+ def shingle_overlap(left: str, right: str) -> float:
292
+ """The default matcher: overlap coefficient over character n-grams."""
293
+ return overlap_coefficient(shingles(left), shingles(right))
294
+
295
+
296
+ def similarity(left: set[str], right: set[str], min_shared: int = MIN_SHARED_SHINGLES) -> float:
297
+ """Alias kept for readability at call sites that already hold shingle sets."""
298
+ return overlap_coefficient(left, right, min_shared)
299
+
300
+
301
+ def jaccard(left: set[str], right: set[str]) -> float:
302
+ """Plain Jaccard similarity.
303
+
304
+ Kept only as a reference point: it is the measure people reach for by default,
305
+ and the tests use it to demonstrate *why* we do not. Nothing in the selection
306
+ path calls this.
307
+ """
308
+ if not left or not right:
309
+ return 0.0
310
+ union = left | right
311
+ return len(left & right) / len(union) if union else 0.0
312
+
313
+
314
+ def load_matcher(spec: str) -> Matcher:
315
+ """Resolve ``"module:function"`` (or ``"module.function"``) into a matcher.
316
+
317
+ The point of this hook is that paraphrase recall costs a dependency, and this
318
+ tool's whole pitch is that it has none. So instead of bundling an embedding
319
+ model, it lets you point at one you already have:
320
+
321
+ trace2eval build traces.jsonl --similarity my_embeddings:cosine
322
+ """
323
+ if ":" in spec:
324
+ module_name, _, attr = spec.partition(":")
325
+ else:
326
+ module_name, _, attr = spec.rpartition(".")
327
+ if not module_name or not attr:
328
+ raise MatcherSpecError(
329
+ f"expected 'module:function', got {spec!r}"
330
+ )
331
+ try:
332
+ module = importlib.import_module(module_name)
333
+ except ImportError as exc:
334
+ raise MatcherSpecError(f"cannot import module {module_name!r}: {exc}") from exc
335
+ try:
336
+ candidate = getattr(module, attr)
337
+ except AttributeError as exc:
338
+ raise MatcherSpecError(
339
+ f"module {module_name!r} has no attribute {attr!r}"
340
+ ) from exc
341
+ if not callable(candidate):
342
+ raise MatcherSpecError(f"{spec!r} is not callable")
343
+ return candidate
344
+
345
+
346
+ def _record_phrasing(cluster: Cluster, fingerprint: set[str], text: str) -> None:
347
+ """Remember a phrasing once, so later duplicates cost nothing to compare."""
348
+ if len(cluster.fingerprints) >= MAX_DISTINCT_FINGERPRINTS:
349
+ return
350
+ frozen = frozenset(fingerprint)
351
+ if frozen in cluster.fingerprints:
352
+ return
353
+ cluster.fingerprints.append(frozen)
354
+ cluster.sample_texts.append(text)
355
+
356
+
357
+ def _cluster_matches(
358
+ cluster: Cluster,
359
+ fingerprint: set[str],
360
+ text: str,
361
+ threshold: float,
362
+ matcher: Matcher | None,
363
+ ) -> bool:
364
+ if matcher is None:
365
+ return any(
366
+ overlap_coefficient(fingerprint, known) >= threshold
367
+ for known in cluster.fingerprints
368
+ )
369
+ return any(matcher(text, known) >= threshold for known in cluster.sample_texts)
370
+
371
+
372
+ def cluster_scored(
373
+ scored: list[ScoredTrace],
374
+ fingerprints: dict[str, set[str]],
375
+ threshold: float = DEFAULT_DEDUP_THRESHOLD,
376
+ matcher: Matcher | None = None,
377
+ ) -> list[Cluster]:
378
+ """Single-linkage clustering over the whole log.
379
+
380
+ ``scored`` must arrive sorted by descending score. That ordering does the real
381
+ work: because we walk the worst rows first, the first row to claim a cluster is
382
+ the most interesting one, so it becomes the representative without any extra
383
+ bookkeeping.
384
+
385
+ Comparison is against every *distinct phrasing* in a cluster, not just the
386
+ representative, so that a chain like
387
+
388
+ 我想了解退款政策 ~ 退款政策 ~ 你们的退款政策是什么
389
+
390
+ holds together: the fragments in the middle never score high enough against a
391
+ long representative, and a centroid comparison would silently split the
392
+ cluster. Comparing against distinct phrasings rather than against every member
393
+ is what keeps that affordable -- five thousand identical questions produce one
394
+ phrasing to compare against, not five thousand.
395
+
396
+ With the default matcher, an inverted index on n-grams shortlists which
397
+ clusters are worth comparing at all. A custom matcher disables that index,
398
+ because a lexical shortlist would cap the recall the custom matcher was
399
+ brought in to provide.
400
+ """
401
+ clusters: list[Cluster] = []
402
+ postings: dict[str, set[int]] = {}
403
+
404
+ for item in scored:
405
+ fingerprint = fingerprints.get(item.trace.id, set())
406
+
407
+ if matcher is None:
408
+ candidates: set[int] = set()
409
+ for shingle in fingerprint:
410
+ candidates |= postings.get(shingle, set())
411
+ else:
412
+ candidates = set(range(len(clusters)))
413
+
414
+ target: int | None = None
415
+ for index in sorted(candidates):
416
+ if _cluster_matches(
417
+ clusters[index], fingerprint, item.trace.input, threshold, matcher
418
+ ):
419
+ target = index
420
+ break
421
+
422
+ if target is None:
423
+ cluster = Cluster(
424
+ representative=item.trace,
425
+ signals=item.signals,
426
+ score=item.score,
427
+ member_ids=[item.trace.id],
428
+ )
429
+ clusters.append(cluster)
430
+ target = len(clusters) - 1
431
+ else:
432
+ cluster = clusters[target]
433
+ cluster.member_ids.append(item.trace.id)
434
+
435
+ _record_phrasing(cluster, fingerprint, item.trace.input)
436
+ if item.is_clean:
437
+ cluster.clean_outputs.append(item.trace.output)
438
+
439
+ if matcher is None:
440
+ for shingle in fingerprint:
441
+ postings.setdefault(shingle, set()).add(target)
442
+
443
+ return clusters
444
+
445
+
446
+ def _checks_from_expect(expect: dict[str, Any]) -> list[dict[str, Any]]:
447
+ """Translate a trace's own ``expect`` block into check specs."""
448
+ checks: list[dict[str, Any]] = []
449
+ for key, value in expect.items():
450
+ if key in {"json", "required"}:
451
+ continue
452
+ if value is False or value is None:
453
+ continue
454
+ if key == "not_fallback":
455
+ checks.append({"type": "not_fallback"})
456
+ elif key in {"min_chars", "max_chars", "regex"}:
457
+ checks.append({"type": key, "value": value})
458
+ elif key in {"contains", "not_contains"}:
459
+ values = value if isinstance(value, list) else [value]
460
+ checks.append({"type": key, "value": values})
461
+
462
+ if expect.get("json"):
463
+ check: dict[str, Any] = {"type": "json"}
464
+ required = expect.get("required")
465
+ if required:
466
+ check["required"] = required if isinstance(required, list) else [required]
467
+ checks.append(check)
468
+
469
+ return checks
470
+
471
+
472
+ def _agreed_json_keys(outputs: list[str]) -> list[str]:
473
+ """Keys every clean answer shares, or an empty list if they disagree.
474
+
475
+ Intersecting rather than unioning is the conservative choice: requiring a key
476
+ that only one answer happened to include is how you get a check that fails on
477
+ correct output.
478
+ """
479
+ schemas: list[set[str]] = []
480
+ for output in outputs:
481
+ stripped = output.strip()
482
+ if not stripped.startswith("{"):
483
+ return []
484
+ try:
485
+ payload = json.loads(stripped)
486
+ except (json.JSONDecodeError, TypeError):
487
+ return []
488
+ if not isinstance(payload, dict):
489
+ return []
490
+ schemas.append(set(payload.keys()))
491
+ if not schemas:
492
+ return []
493
+ common = set.intersection(*schemas) if len(schemas) > 1 else schemas[0]
494
+ return sorted(common)
495
+
496
+
497
+ def _infer_checks(
498
+ trace: Trace,
499
+ is_failure_seed: bool,
500
+ references: list[str],
501
+ notes: list[str],
502
+ length_floor: int | None = None,
503
+ ) -> list[dict[str, Any]]:
504
+ """Infer an expected shape from clean answers, never from a failed one."""
505
+ checks: list[dict[str, Any]] = [{"type": "not_fallback"}]
506
+
507
+ usable = [output for output in references if output.strip()]
508
+ if not usable:
509
+ if is_failure_seed:
510
+ if length_floor is not None:
511
+ checks.append({"type": "min_chars", "value": length_floor})
512
+ notes.append(
513
+ f"failure seed with no clean sibling, but the failure itself was a "
514
+ f"length failure: the response fell below the log's short-answer "
515
+ f"line, so this case asserts min_chars={length_floor}. That is a "
516
+ f"weak reference -- it comes from the log-wide median rather than "
517
+ f"from this question's own answers, so check it first when tuning"
518
+ )
519
+ else:
520
+ checks.append({"type": "min_chars", "value": _NON_EMPTY_MIN_CHARS})
521
+ notes.append(
522
+ "failure seed with no clean sibling: no answer to this question in "
523
+ "the log was usable as a shape reference, so all this case asserts is "
524
+ "that the answer is not empty and does not fall back"
525
+ )
526
+ else:
527
+ notes.append("no shape inferred: the reference answer is empty")
528
+ return checks
529
+
530
+ lengths = [len(output.strip()) for output in usable]
531
+ reference_length = int(statistics.median(lengths))
532
+ minimum = max(_MIN_CHARS_FLOOR, int(reference_length * _MIN_CHARS_RATIO))
533
+ checks.append({"type": "min_chars", "value": minimum})
534
+
535
+ source = "clean answer" if len(usable) == 1 else f"{len(usable)} clean answers"
536
+ if is_failure_seed:
537
+ notes.append(
538
+ f"failure seed: the failing output is not a shape reference, so "
539
+ f"min_chars={minimum} was inferred from {source} to the same question "
540
+ f"(median {reference_length} chars)"
541
+ )
542
+ else:
543
+ notes.append(
544
+ f"clean trace: min_chars={minimum} from {source} to this question "
545
+ f"(median {reference_length} chars); catches truncation, tolerates rewording"
546
+ )
547
+
548
+ agreed_keys = _agreed_json_keys(usable)
549
+ if agreed_keys:
550
+ checks.append({"type": "json", "required": agreed_keys})
551
+ notes.append(f"clean answers agree on a JSON shape with keys {agreed_keys}")
552
+ elif len(usable) > 1 and all(output.strip().startswith("{") for output in usable):
553
+ notes.append(
554
+ "clean answers are all JSON but disagree on keys, so no JSON check was added"
555
+ )
556
+
557
+ return checks
558
+
559
+
560
+ def _self_check(checks: list[dict[str, Any]], output: str) -> dict[str, Any]:
561
+ """Run a case's checks against its own reference output.
562
+
563
+ This is the honest part. A failure seed whose checks *pass* on the bad output
564
+ is a case that cannot detect the thing it was created to detect, and the
565
+ verdict says so instead of letting the case look stronger than it is.
566
+ """
567
+ results = run_checks(checks, output)
568
+ failed = [result.type for result in results if not result.passed]
569
+ return {"passed": not failed, "failed_checks": failed}
570
+
571
+
572
+ def build_case(index: int, cluster: Cluster, context: TraceContext | None = None) -> dict[str, Any]:
573
+ trace = cluster.representative
574
+ names = {signal.name for signal in cluster.signals}
575
+ is_failure_seed = bool(names & QUALITY_SIGNALS)
576
+
577
+ shape_hit = names & SHAPE_SIGNALS
578
+ behaviour_hit = names & BEHAVIOUR_SIGNALS
579
+ if shape_hit and behaviour_hit:
580
+ failure_kind = "shape_and_behaviour"
581
+ elif shape_hit:
582
+ failure_kind = "shape"
583
+ elif behaviour_hit:
584
+ failure_kind = "behaviour"
585
+ else:
586
+ failure_kind = "none"
587
+
588
+ # When the failure was itself "this answer is too short", the line it fell
589
+ # below is evidence, even with no clean answer to compare against. Same
590
+ # function the signal used, so the two cannot drift apart.
591
+ length_floor: int | None = None
592
+ if is_failure_seed and "output_much_shorter" in names and context is not None:
593
+ length_floor = suspiciously_short_boundary(context.median_output_chars)
594
+
595
+ notes: list[str] = []
596
+ if trace.expect:
597
+ checks = _checks_from_expect(trace.expect)
598
+ notes.append("checks inherited from the trace's own expect block")
599
+ if not any(check["type"] == "not_fallback" for check in checks):
600
+ checks.insert(0, {"type": "not_fallback"})
601
+ shape_source = "the trace's own expect block"
602
+ else:
603
+ references = list(cluster.clean_outputs)
604
+ if not references and not is_failure_seed:
605
+ references = [trace.output]
606
+ checks = _infer_checks(trace, is_failure_seed, references, notes, length_floor)
607
+ usable = sum(1 for output in references if output.strip())
608
+ if usable > 1:
609
+ shape_source = f"{usable} clean answers to the same question"
610
+ elif usable == 1:
611
+ shape_source = "the one clean answer to this question"
612
+ elif length_floor is not None:
613
+ shape_source = "the log's short-answer line (weak reference)"
614
+ else:
615
+ shape_source = "none available -- only behaviour was asserted"
616
+
617
+ duplicates = cluster.duplicate_ids
618
+ if duplicates:
619
+ notes.append(
620
+ f"this question appeared {cluster.size} times in the log; "
621
+ f"addressed once, here"
622
+ )
623
+
624
+ self_check = _self_check(checks, trace.output)
625
+ if is_failure_seed:
626
+ verdict = "ok" if not self_check["passed"] else "failure_not_reproduced"
627
+ else:
628
+ verdict = "ok" if self_check["passed"] else "reference_fails_own_checks"
629
+ self_check["verdict"] = verdict
630
+
631
+ if verdict == "failure_not_reproduced":
632
+ notes.append(
633
+ "self-check: these checks pass on the very output that went wrong, so a "
634
+ "differently-wrong answer would also pass"
635
+ )
636
+ elif verdict == "reference_fails_own_checks":
637
+ notes.append(
638
+ "self-check: a trusted reference does not satisfy its own checks -- "
639
+ "either the reference is wrong or the checks are"
640
+ )
641
+
642
+ return {
643
+ "id": f"case-{index:03d}",
644
+ "input": trace.input,
645
+ "checks": checks,
646
+ "reference_output": trace.output,
647
+ "reference_is_trusted": not is_failure_seed,
648
+ "shape_source": shape_source,
649
+ "failure_kind": failure_kind,
650
+ "source_trace_id": trace.id,
651
+ "score": cluster.score,
652
+ "signals": [signal.to_dict() for signal in cluster.signals],
653
+ "occurrences_in_log": cluster.size,
654
+ "duplicate_count": cluster.duplicate_count,
655
+ "duplicate_trace_ids": duplicates,
656
+ "self_check": self_check,
657
+ "notes": notes,
658
+ }
659
+
660
+
661
+ def select_cases(
662
+ traces: Iterable[Trace],
663
+ max_cases: int = 50,
664
+ min_score: float = 1.0,
665
+ dedup_threshold: float = DEFAULT_DEDUP_THRESHOLD,
666
+ weights: dict[str, float] | None = None,
667
+ matcher: Matcher | None = None,
668
+ ) -> SelectionResult:
669
+ trace_list = list(traces)
670
+ result = SelectionResult(total_traces=len(trace_list))
671
+
672
+ context = build_context(trace_list)
673
+ result.context = context
674
+
675
+ fingerprints = {trace.id: shingles(trace.input) for trace in trace_list}
676
+
677
+ scored: list[ScoredTrace] = []
678
+ for trace in trace_list:
679
+ signals = compute_signals(trace, context, weights)
680
+ scored.append(ScoredTrace(trace, signals, score(signals)))
681
+ scored.sort(key=lambda item: (-item.score, item.trace.id))
682
+
683
+ clusters = cluster_scored(
684
+ scored, fingerprints, threshold=dedup_threshold, matcher=matcher
685
+ )
686
+ result.distinct_questions = len(clusters)
687
+
688
+ interesting: list[Cluster] = []
689
+ for cluster in clusters:
690
+ if cluster.score <= 0:
691
+ result.questions_without_signals += 1
692
+ continue
693
+ if cluster.score < min_score:
694
+ result.dropped_below_min_score += 1
695
+ continue
696
+ interesting.append(cluster)
697
+
698
+ # Clusters are already in descending score order because that is the order
699
+ # they were created in.
700
+ if len(interesting) > max_cases:
701
+ result.dropped_beyond_limit = len(interesting) - max_cases
702
+ interesting = interesting[:max_cases]
703
+
704
+ result.dropped_as_duplicate = sum(cluster.duplicate_count for cluster in interesting)
705
+ result.cases = [
706
+ build_case(index, cluster, context)
707
+ for index, cluster in enumerate(interesting, 1)
708
+ ]
709
+ return result