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/__init__.py +65 -0
- trace2eval/__main__.py +10 -0
- trace2eval/checks.py +149 -0
- trace2eval/cli.py +307 -0
- trace2eval/matchers.py +101 -0
- trace2eval/report.py +221 -0
- trace2eval/runner.py +251 -0
- trace2eval/schema.py +233 -0
- trace2eval/select.py +709 -0
- trace2eval/signals.py +207 -0
- trace2eval_cli-0.2.1.dist-info/METADATA +227 -0
- trace2eval_cli-0.2.1.dist-info/RECORD +16 -0
- trace2eval_cli-0.2.1.dist-info/WHEEL +5 -0
- trace2eval_cli-0.2.1.dist-info/entry_points.txt +2 -0
- trace2eval_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
- trace2eval_cli-0.2.1.dist-info/top_level.txt +1 -0
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
|