jevassert 0.1.0__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.
jevassert/compare.py ADDED
@@ -0,0 +1,108 @@
1
+ """Paired comparison of two recordings (same pack): accuracy deltas + McNemar.
2
+
3
+ Typical use: compare a rubric/wording change or a model-version bump.
4
+ Both recordings must cover the same (case, question) items; only items present
5
+ and answerable on both sides are compared.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ from .metrics import INPUT_USD_PER_MTOK, Item, build_items
15
+ from .packs import Pack
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class QuestionDelta:
20
+ qid: str
21
+ n: int
22
+ accuracy_a: float
23
+ accuracy_b: float
24
+
25
+ @property
26
+ def delta(self) -> float:
27
+ return self.accuracy_b - self.accuracy_a
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CompareResult:
32
+ n_items: int
33
+ accuracy_a: float
34
+ accuracy_b: float
35
+ wins: int # A correct, B wrong
36
+ losses: int # A wrong, B correct
37
+ p_value: float
38
+ cost_per_case_a_usd: float | None
39
+ cost_per_case_b_usd: float | None
40
+ per_question: dict[str, QuestionDelta] = field(default_factory=dict)
41
+
42
+ @property
43
+ def delta(self) -> float:
44
+ return self.accuracy_b - self.accuracy_a
45
+
46
+
47
+ def compare(
48
+ pack: Pack,
49
+ predictions_a: dict[str, dict[str, Any]],
50
+ predictions_b: dict[str, dict[str, Any]],
51
+ ) -> CompareResult:
52
+ items_a = _keyed(build_items(pack, predictions_a)[0])
53
+ items_b = _keyed(build_items(pack, predictions_b)[0])
54
+ shared = sorted(set(items_a) & set(items_b))
55
+ if not shared:
56
+ raise ValueError("no shared (case, question) items between the two recordings")
57
+
58
+ paired = [(items_a[key], items_b[key]) for key in shared]
59
+ wins = sum(1 for a, b in paired if a.correct and not b.correct)
60
+ losses = sum(1 for a, b in paired if not a.correct and b.correct)
61
+
62
+ per_question: dict[str, QuestionDelta] = {}
63
+ for qid in pack.questions:
64
+ q_pairs = [(a, b) for a, b in paired if a.qid == qid]
65
+ if not q_pairs:
66
+ continue
67
+ per_question[qid] = QuestionDelta(
68
+ qid=qid,
69
+ n=len(q_pairs),
70
+ accuracy_a=sum(1.0 for a, _ in q_pairs if a.correct) / len(q_pairs),
71
+ accuracy_b=sum(1.0 for _, b in q_pairs if b.correct) / len(q_pairs),
72
+ )
73
+
74
+ return CompareResult(
75
+ n_items=len(paired),
76
+ accuracy_a=sum(1.0 for a, _ in paired if a.correct) / len(paired),
77
+ accuracy_b=sum(1.0 for _, b in paired if b.correct) / len(paired),
78
+ wins=wins,
79
+ losses=losses,
80
+ p_value=mcnemar_exact(wins, losses),
81
+ cost_per_case_a_usd=_cost_per_case(pack, predictions_a),
82
+ cost_per_case_b_usd=_cost_per_case(pack, predictions_b),
83
+ per_question=per_question,
84
+ )
85
+
86
+
87
+ def _keyed(items: list[Item]) -> dict[tuple[str, str], Item]:
88
+ return {(item.case_id, item.qid): item for item in items}
89
+
90
+
91
+ def mcnemar_exact(wins: int, losses: int) -> float:
92
+ """Two-sided exact McNemar test (binomial, p=0.5) — no scipy needed."""
93
+ discordant = wins + losses
94
+ if discordant == 0:
95
+ return 1.0
96
+ tail = sum(math.comb(discordant, k) for k in range(min(wins, losses) + 1))
97
+ return min(1.0, 2 * tail / 2**discordant)
98
+
99
+
100
+ def _cost_per_case(pack: Pack, predictions: dict[str, dict[str, Any]]) -> float | None:
101
+ tokens = 0
102
+ saw_usage = False
103
+ for case in pack.cases:
104
+ usage = (predictions.get(case.id) or {}).get("usage")
105
+ if isinstance(usage, dict) and isinstance(usage.get("input_tokens"), int | float):
106
+ tokens += int(usage["input_tokens"])
107
+ saw_usage = True
108
+ return (tokens * INPUT_USD_PER_MTOK / 1_000_000 / len(pack.cases)) if saw_usage else None
jevassert/metrics.py ADDED
@@ -0,0 +1,526 @@
1
+ """Metrics for recorded predictions: accuracy, calibration, coverage, gates.
2
+
3
+ Definitions used throughout:
4
+
5
+ - ``decision`` — what code would act on: ``choice`` field, ``noul >= 0.5``,
6
+ or the rounded ``score``.
7
+ - ``decision_prob`` — the probability the model assigned to the decision it
8
+ made (for Noul, ``max(p, 1 - p)``). This is what coverage/ECE are computed on.
9
+ - ECE — expected calibration error over equal-mass bins of ``decision_prob``
10
+ against decision correctness. Brier is reported for Noul questions only.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import random
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass, field
19
+ from typing import Any
20
+
21
+ from .packs import Pack
22
+
23
+ INPUT_USD_PER_MTOK = 0.042 # TypeSafe early-access price; output tokens are free.
24
+ COVERAGE_THRESHOLDS = (0.5, 0.6, 0.7, 0.8, 0.9, 0.95)
25
+ SMALL_N_ITEMS = 50 # below this, report that CI/ECE are coarse
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Item:
30
+ case_id: str
31
+ qid: str
32
+ qtype: str
33
+ expected: Any
34
+ got: Any
35
+ correct: bool
36
+ decision_prob: float
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class QuestionMetrics:
41
+ qid: str
42
+ qtype: str
43
+ n: int
44
+ missing: int
45
+ accuracy: float
46
+ mean_decision_prob: float
47
+ ece: float
48
+ brier: float | None
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class CoverageRow:
53
+ threshold: float
54
+ coverage: float
55
+ precision: float | None
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ThresholdCoverage:
60
+ """Coverage when auto-accepting with the pack's own per-label thresholds."""
61
+
62
+ n_total: int
63
+ n_auto: int
64
+ coverage: float
65
+ precision: float | None
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class ThresholdSuggestion:
70
+ """Highest-coverage threshold that still reaches a target precision."""
71
+
72
+ threshold: float
73
+ coverage: float
74
+ precision: float
75
+ n_accepted: int
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class OverallMetrics:
80
+ n_cases: int
81
+ n_items: int
82
+ case_errors: int
83
+ missing_items: int
84
+ accuracy: float
85
+ ece: float
86
+ mean_decision_prob: float
87
+ total_cost_usd: float | None
88
+ cost_per_case_usd: float | None
89
+ p50_latency_ms: float | None
90
+ p95_latency_ms: float | None
91
+ per_question: dict[str, QuestionMetrics] = field(default_factory=dict)
92
+ coverage: tuple[CoverageRow, ...] = ()
93
+ threshold_coverage: ThresholdCoverage | None = None
94
+ accuracy_ci: tuple[float, float] | None = None
95
+ ece_ci: tuple[float, float] | None = None
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class GateResult:
100
+ gate: str
101
+ ok: bool | None # None = not computable from this recording
102
+ detail: str
103
+
104
+
105
+ def build_items(pack: Pack, predictions: dict[str, dict[str, Any]]) -> tuple[list[Item], int, int]:
106
+ """Turn predictions into comparable items.
107
+
108
+ Returns (items, case_errors, missing_items). Cases with an error record or
109
+ without answers count once as a case error; their expected questions count
110
+ as missing items.
111
+ """
112
+ items: list[Item] = []
113
+ case_errors = 0
114
+ missing = 0
115
+
116
+ for case in pack.cases:
117
+ record = predictions.get(case.id)
118
+ if record is None or record.get("error") or record.get("answers") is None:
119
+ case_errors += 1
120
+ missing += len(case.expect)
121
+ continue
122
+ answers = record["answers"]
123
+ for qid, expected in case.expect.items():
124
+ answer = answers.get(qid)
125
+ if not isinstance(answer, dict):
126
+ missing += 1
127
+ continue
128
+ item = _item_for(case.id, qid, pack.questions[qid], expected, answer)
129
+ if item is not None:
130
+ items.append(item)
131
+ else:
132
+ missing += 1
133
+ return items, case_errors, missing
134
+
135
+
136
+ def _item_for(
137
+ case_id: str, qid: str, question: dict[str, Any], expected: Any, answer: dict[str, Any]
138
+ ) -> Item | None:
139
+ qtype = question["type"]
140
+ if qtype == "noul":
141
+ if "noul" not in answer:
142
+ return None
143
+ probability = float(answer["noul"])
144
+ decision = probability >= 0.5
145
+ return Item(
146
+ case_id=case_id,
147
+ qid=qid,
148
+ qtype=qtype,
149
+ expected=expected,
150
+ got=decision,
151
+ correct=decision == expected,
152
+ decision_prob=max(probability, 1.0 - probability),
153
+ )
154
+
155
+ if qtype == "choice":
156
+ probabilities = _float_map(answer.get("probabilities"))
157
+ if not probabilities:
158
+ return None
159
+ decision = answer.get("choice") or max(probabilities, key=probabilities.get)
160
+ return Item(
161
+ case_id=case_id,
162
+ qid=qid,
163
+ qtype=qtype,
164
+ expected=expected,
165
+ got=decision,
166
+ correct=decision == expected,
167
+ decision_prob=probabilities.get(decision, max(probabilities.values())),
168
+ )
169
+
170
+ if qtype == "score":
171
+ probabilities = _float_map(answer.get("probabilities"))
172
+ if not probabilities:
173
+ return None
174
+ levels = list(question["levels"])
175
+ best_key = max(probabilities, key=probabilities.get)
176
+ try:
177
+ decision = levels[int(best_key)]
178
+ except (ValueError, IndexError):
179
+ return None
180
+ return Item(
181
+ case_id=case_id,
182
+ qid=qid,
183
+ qtype=qtype,
184
+ expected=expected,
185
+ got=decision,
186
+ correct=decision == expected,
187
+ decision_prob=probabilities[best_key],
188
+ )
189
+
190
+ return None # pragma: no cover - loader rejects unknown types
191
+
192
+
193
+ def _float_map(raw: Any) -> dict[str, float]:
194
+ if not isinstance(raw, dict):
195
+ return {}
196
+ parsed: dict[str, float] = {}
197
+ for key, value in raw.items():
198
+ try:
199
+ parsed[str(key)] = float(value)
200
+ except (TypeError, ValueError):
201
+ continue
202
+ return parsed
203
+
204
+
205
+ def compute(
206
+ pack: Pack,
207
+ predictions: dict[str, dict[str, Any]],
208
+ bootstrap: int = 1000,
209
+ seed: int = 0,
210
+ ) -> OverallMetrics:
211
+ items, case_errors, missing_items = build_items(pack, predictions)
212
+
213
+ per_question: dict[str, QuestionMetrics] = {}
214
+ for qid in pack.questions:
215
+ subset = [item for item in items if item.qid == qid]
216
+ expected_count = sum(1 for case in pack.cases if qid in case.expect)
217
+ q_missing = expected_count - len(subset)
218
+ if not subset:
219
+ per_question[qid] = QuestionMetrics(
220
+ qid=qid,
221
+ qtype=pack.questions[qid]["type"],
222
+ n=0,
223
+ missing=q_missing,
224
+ accuracy=0.0,
225
+ mean_decision_prob=0.0,
226
+ ece=0.0,
227
+ brier=None,
228
+ )
229
+ continue
230
+ corrects = [item.correct for item in subset]
231
+ probs = [item.decision_prob for item in subset]
232
+ brier = None
233
+ if pack.questions[qid]["type"] == "noul":
234
+ brier = sum(
235
+ (_noul_probability(item) - float(item.expected)) ** 2 for item in subset
236
+ ) / len(subset)
237
+ per_question[qid] = QuestionMetrics(
238
+ qid=qid,
239
+ qtype=pack.questions[qid]["type"],
240
+ n=len(subset),
241
+ missing=q_missing,
242
+ accuracy=_mean(corrects),
243
+ mean_decision_prob=_mean(probs),
244
+ ece=expected_calibration_error(probs, corrects),
245
+ brier=brier,
246
+ )
247
+
248
+ all_probs = [item.decision_prob for item in items]
249
+ all_correct = [item.correct for item in items]
250
+
251
+ total_input_tokens = 0
252
+ saw_usage = False
253
+ latencies: list[float] = []
254
+ for case in pack.cases:
255
+ record = predictions.get(case.id) or {}
256
+ usage = record.get("usage")
257
+ if isinstance(usage, dict) and isinstance(usage.get("input_tokens"), int | float):
258
+ total_input_tokens += int(usage["input_tokens"])
259
+ saw_usage = True
260
+ if isinstance(record.get("latency_ms"), int | float):
261
+ latencies.append(float(record["latency_ms"]))
262
+
263
+ total_cost = (total_input_tokens * INPUT_USD_PER_MTOK / 1_000_000) if saw_usage else None
264
+ cost_per_case = (total_cost / len(pack.cases)) if total_cost is not None else None
265
+
266
+ return OverallMetrics(
267
+ n_cases=len(pack.cases),
268
+ n_items=len(items),
269
+ case_errors=case_errors,
270
+ missing_items=missing_items,
271
+ accuracy=_mean(all_correct),
272
+ ece=expected_calibration_error(all_probs, all_correct),
273
+ mean_decision_prob=_mean(all_probs),
274
+ total_cost_usd=total_cost,
275
+ cost_per_case_usd=cost_per_case,
276
+ p50_latency_ms=percentile(latencies, 50),
277
+ p95_latency_ms=percentile(latencies, 95),
278
+ per_question=per_question,
279
+ coverage=coverage_rows(items),
280
+ threshold_coverage=_threshold_coverage(pack, items) if pack.thresholds else None,
281
+ accuracy_ci=bootstrap_interval(all_correct, _mean, bootstrap, seed),
282
+ ece_ci=bootstrap_interval(
283
+ list(zip(all_probs, all_correct, strict=True)),
284
+ lambda sample: expected_calibration_error(
285
+ [pair[0] for pair in sample], [pair[1] for pair in sample]
286
+ ),
287
+ bootstrap,
288
+ seed,
289
+ ),
290
+ )
291
+
292
+
293
+ def _threshold_coverage(pack: Pack, items: list[Item]) -> ThresholdCoverage:
294
+ """Auto-accept coverage under the pack's `thresholds` (SPEC semantics).
295
+
296
+ An item is auto-accepted when the label the model answered has a floor in
297
+ the pack thresholds and the decision probability clears it. Labels without
298
+ a floor (typically `unknown`) always route to review.
299
+ """
300
+ accepted: list[Item] = []
301
+ for item in items:
302
+ if item.qtype == "noul":
303
+ label = "true" if item.got else "false"
304
+ else:
305
+ label = str(item.got)
306
+ floor = pack.thresholds.get(item.qid, {}).get(label)
307
+ if floor is not None and item.decision_prob >= floor:
308
+ accepted.append(item)
309
+ return ThresholdCoverage(
310
+ n_total=len(items),
311
+ n_auto=len(accepted),
312
+ coverage=len(accepted) / len(items) if items else 0.0,
313
+ precision=_mean([item.correct for item in accepted]) if accepted else None,
314
+ )
315
+
316
+
317
+ def _noul_probability(item: Item) -> float:
318
+ return item.decision_prob if item.got else 1.0 - item.decision_prob
319
+
320
+
321
+ def _mean(values: list[Any]) -> float:
322
+ return sum(float(v) for v in values) / len(values) if values else 0.0
323
+
324
+
325
+ def expected_calibration_error(
326
+ probabilities: list[float], corrects: list[bool], bins: int = 10
327
+ ) -> float:
328
+ """ECE over equal-mass bins of decision probability vs correctness."""
329
+ if not probabilities:
330
+ return 0.0
331
+ pairs = sorted(zip(probabilities, corrects, strict=True))
332
+ n = len(pairs)
333
+ k = min(bins, n)
334
+ total = 0.0
335
+ for index in range(k):
336
+ chunk = pairs[index * n // k : (index + 1) * n // k]
337
+ if not chunk:
338
+ continue
339
+ mean_p = sum(p for p, _ in chunk) / len(chunk)
340
+ mean_c = sum(1.0 for _, correct in chunk if correct) / len(chunk)
341
+ total += len(chunk) / n * abs(mean_p - mean_c)
342
+ return total
343
+
344
+
345
+ def percentile(values: list[float], pct: float) -> float | None:
346
+ """Nearest-rank percentile; None when there are no values."""
347
+ if not values:
348
+ return None
349
+ ordered = sorted(values)
350
+ rank = max(1, math.ceil(pct / 100 * len(ordered)))
351
+ return ordered[rank - 1]
352
+
353
+
354
+ def bootstrap_interval(
355
+ values: list[Any],
356
+ statistic: Callable[[list[Any]], float],
357
+ n_resamples: int,
358
+ seed: int,
359
+ confidence: float = 0.95,
360
+ ) -> tuple[float, float] | None:
361
+ """Percentile bootstrap CI for a statistic; None when disabled or no data."""
362
+ if not values or n_resamples <= 0:
363
+ return None
364
+ rng = random.Random(seed)
365
+ n = len(values)
366
+ stats = sorted(
367
+ statistic([values[rng.randrange(n)] for _ in range(n)]) for _ in range(n_resamples)
368
+ )
369
+ low_index = max(0, int((1 - confidence) / 2 * n_resamples))
370
+ high_index = min(n_resamples - 1, int((1 + confidence) / 2 * n_resamples))
371
+ return (stats[low_index], stats[high_index])
372
+
373
+
374
+ def suggest_threshold(
375
+ items: list[Item], target_precision: float, min_accepted: int = 10
376
+ ) -> ThresholdSuggestion | None:
377
+ """Highest-coverage probability cut that still reaches the target precision.
378
+
379
+ ``min_accepted`` keeps a handful of lucky items from suggesting a cut that
380
+ no real workload could use.
381
+ """
382
+ if not items:
383
+ return None
384
+ best: ThresholdSuggestion | None = None
385
+ for step in range(50, 100):
386
+ threshold = step / 100
387
+ accepted = [item for item in items if item.decision_prob >= threshold]
388
+ if len(accepted) < min_accepted:
389
+ continue
390
+ precision = _mean([item.correct for item in accepted])
391
+ if precision < target_precision:
392
+ continue
393
+ coverage = len(accepted) / len(items)
394
+ if best is None or coverage > best.coverage:
395
+ best = ThresholdSuggestion(
396
+ threshold=threshold,
397
+ coverage=coverage,
398
+ precision=precision,
399
+ n_accepted=len(accepted),
400
+ )
401
+ return best
402
+
403
+
404
+ def coverage_rows(items: list[Item]) -> tuple[CoverageRow, ...]:
405
+ rows = []
406
+ for threshold in COVERAGE_THRESHOLDS:
407
+ accepted = [item for item in items if item.decision_prob >= threshold]
408
+ precision = _mean([item.correct for item in accepted]) if accepted else None
409
+ rows.append(
410
+ CoverageRow(
411
+ threshold=threshold,
412
+ coverage=len(accepted) / len(items) if items else 0.0,
413
+ precision=precision,
414
+ )
415
+ )
416
+ return tuple(rows)
417
+
418
+
419
+ def evaluate_gates(pack: Pack, overall: OverallMetrics) -> list[GateResult]:
420
+ results: list[GateResult] = []
421
+ gates = pack.gates
422
+
423
+ if "min_accuracy" in gates:
424
+ value = float(gates["min_accuracy"])
425
+ results.append(
426
+ GateResult(
427
+ "min_accuracy",
428
+ overall.accuracy >= value,
429
+ f"accuracy {overall.accuracy:.3f} >= {value:.3f}",
430
+ )
431
+ )
432
+ if "max_ece" in gates:
433
+ value = float(gates["max_ece"])
434
+ results.append(
435
+ GateResult("max_ece", overall.ece <= value, f"ece {overall.ece:.3f} <= {value:.3f}")
436
+ )
437
+ if "max_cost_per_case_usd" in gates:
438
+ value = float(gates["max_cost_per_case_usd"])
439
+ if overall.cost_per_case_usd is None:
440
+ results.append(GateResult("max_cost_per_case_usd", None, "no usage data in recording"))
441
+ else:
442
+ results.append(
443
+ GateResult(
444
+ "max_cost_per_case_usd",
445
+ overall.cost_per_case_usd <= value,
446
+ f"cost/case ${overall.cost_per_case_usd:.6f} <= ${value:.6f}",
447
+ )
448
+ )
449
+ if "max_p95_latency_ms" in gates:
450
+ value = float(gates["max_p95_latency_ms"])
451
+ if overall.p95_latency_ms is None:
452
+ results.append(GateResult("max_p95_latency_ms", None, "no latency data in recording"))
453
+ else:
454
+ results.append(
455
+ GateResult(
456
+ "max_p95_latency_ms",
457
+ overall.p95_latency_ms <= value,
458
+ f"p95 {overall.p95_latency_ms:.0f}ms <= {value:.0f}ms",
459
+ )
460
+ )
461
+ if "min_coverage_at_precision" in gates:
462
+ spec = gates["min_coverage_at_precision"]
463
+ required_precision = float(spec["precision"])
464
+ min_coverage = float(spec["min_coverage"])
465
+ best = None
466
+ for row in overall.coverage:
467
+ if row.precision is not None and row.precision >= required_precision:
468
+ best = row
469
+ if best is None:
470
+ results.append(
471
+ GateResult(
472
+ "min_coverage_at_precision",
473
+ False,
474
+ f"no threshold on the grid reaches precision >= {required_precision:.2f}",
475
+ )
476
+ )
477
+ else:
478
+ results.append(
479
+ GateResult(
480
+ "min_coverage_at_precision",
481
+ best.coverage >= min_coverage,
482
+ f"coverage {best.coverage:.2f} at precision >= {required_precision:.2f} "
483
+ f"(threshold {best.threshold:.2f}) >= {min_coverage:.2f}",
484
+ )
485
+ )
486
+ if "min_accuracy_ci_lower" in gates:
487
+ value = float(gates["min_accuracy_ci_lower"])
488
+ if overall.accuracy_ci is None:
489
+ results.append(
490
+ GateResult("min_accuracy_ci_lower", None, "bootstrap disabled (--bootstrap 0)")
491
+ )
492
+ else:
493
+ low = overall.accuracy_ci[0]
494
+ results.append(
495
+ GateResult(
496
+ "min_accuracy_ci_lower",
497
+ low >= value,
498
+ f"accuracy CI lower {low:.3f} >= {value:.3f}",
499
+ )
500
+ )
501
+ for qid, per_question_gates in (gates.get("per_question") or {}).items():
502
+ metrics = overall.per_question.get(qid)
503
+ where = f"per_question.{qid}"
504
+ if metrics is None or metrics.n == 0:
505
+ for gate_name in per_question_gates:
506
+ results.append(GateResult(f"{where}.{gate_name}", None, "no items in recording"))
507
+ continue
508
+ if "min_accuracy" in per_question_gates:
509
+ value = float(per_question_gates["min_accuracy"])
510
+ results.append(
511
+ GateResult(
512
+ f"{where}.min_accuracy",
513
+ metrics.accuracy >= value,
514
+ f"accuracy {metrics.accuracy:.3f} >= {value:.3f}",
515
+ )
516
+ )
517
+ if "max_ece" in per_question_gates:
518
+ value = float(per_question_gates["max_ece"])
519
+ results.append(
520
+ GateResult(
521
+ f"{where}.max_ece",
522
+ metrics.ece <= value,
523
+ f"ece {metrics.ece:.3f} <= {value:.3f}",
524
+ )
525
+ )
526
+ return results