llm-search-eval 0.2.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.
@@ -0,0 +1,33 @@
1
+ """Evaluate product search quality with IR metrics, LLM judges, and CI quality gates."""
2
+
3
+ from llm_search_eval.evaluate import EvaluationConfig, EvaluationReport, evaluate
4
+ from llm_search_eval.gate import GateResult, check_report
5
+ from llm_search_eval.html_report import render_html
6
+ from llm_search_eval.judges import Judge, Judgment, create_judge, register_judge
7
+ from llm_search_eval.judges.panel import JudgePanel
8
+ from llm_search_eval.models import Dataset, Product, Query, Run
9
+ from llm_search_eval.usage import Budget, CostTracker, PriceTable
10
+
11
+ __version__ = "0.2.0"
12
+
13
+ __all__ = [
14
+ "Budget",
15
+ "CostTracker",
16
+ "Dataset",
17
+ "EvaluationConfig",
18
+ "EvaluationReport",
19
+ "GateResult",
20
+ "Judge",
21
+ "JudgePanel",
22
+ "Judgment",
23
+ "PriceTable",
24
+ "Product",
25
+ "Query",
26
+ "Run",
27
+ "__version__",
28
+ "check_report",
29
+ "create_judge",
30
+ "evaluate",
31
+ "register_judge",
32
+ "render_html",
33
+ ]
@@ -0,0 +1,4 @@
1
+ from llm_search_eval.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,261 @@
1
+ """Agreement statistics for graded relevance labels.
2
+
3
+ Used to check whether several judges agree with each other (inter-rater
4
+ reliability) and with human gold labels (calibration). All functions take plain
5
+ integer grades, so they also work for labels that did not come from this package.
6
+
7
+ Undefined statistics, for example kappa when every label is identical, are
8
+ returned as ``None`` instead of an arbitrary number.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import itertools
14
+ from collections import Counter
15
+ from collections.abc import Mapping, Sequence
16
+ from typing import Any
17
+
18
+ from llm_search_eval.models import MAX_RELEVANCE, MIN_RELEVANCE
19
+
20
+ DEFAULT_CATEGORIES: tuple[int, ...] = tuple(range(MIN_RELEVANCE, MAX_RELEVANCE + 1))
21
+ WEIGHTS = (None, "linear", "quadratic")
22
+ LEVELS = ("nominal", "ordinal", "interval")
23
+
24
+
25
+ def _check_pairs(a: Sequence[int], b: Sequence[int]) -> None:
26
+ if len(a) != len(b):
27
+ raise ValueError("label sequences must have equal length")
28
+
29
+
30
+ def percent_agreement(a: Sequence[int], b: Sequence[int]) -> float | None:
31
+ """Share of items where both raters chose the same grade."""
32
+ _check_pairs(a, b)
33
+ if not a:
34
+ return None
35
+ return sum(1 for x, y in zip(a, b, strict=True) if x == y) / len(a)
36
+
37
+
38
+ def within_one(a: Sequence[int], b: Sequence[int]) -> float | None:
39
+ """Share of items where the grades differ by at most one step."""
40
+ _check_pairs(a, b)
41
+ if not a:
42
+ return None
43
+ return sum(1 for x, y in zip(a, b, strict=True) if abs(x - y) <= 1) / len(a)
44
+
45
+
46
+ def confusion_matrix(
47
+ a: Sequence[int], b: Sequence[int], categories: Sequence[int] = DEFAULT_CATEGORIES
48
+ ) -> list[list[int]]:
49
+ """Counts with rows for ``a`` and columns for ``b``, in ``categories`` order."""
50
+ _check_pairs(a, b)
51
+ index = {c: i for i, c in enumerate(categories)}
52
+ matrix = [[0] * len(categories) for _ in categories]
53
+ for x, y in zip(a, b, strict=True):
54
+ if x not in index or y not in index:
55
+ raise ValueError(f"grade outside categories {list(categories)}: {x}, {y}")
56
+ matrix[index[x]][index[y]] += 1
57
+ return matrix
58
+
59
+
60
+ def _weight(i: int, j: int, size: int, weights: str | None) -> float:
61
+ if weights is None:
62
+ return 0.0 if i == j else 1.0
63
+ span = max(1, size - 1)
64
+ if weights == "linear":
65
+ return abs(i - j) / span
66
+ if weights == "quadratic":
67
+ return ((i - j) / span) ** 2
68
+ raise ValueError(f"unknown weights {weights!r}; use None, 'linear', or 'quadratic'")
69
+
70
+
71
+ def cohen_kappa(
72
+ a: Sequence[int],
73
+ b: Sequence[int],
74
+ weights: str | None = None,
75
+ categories: Sequence[int] = DEFAULT_CATEGORIES,
76
+ ) -> float | None:
77
+ """Cohen's kappa for two raters, optionally linear or quadratic weighted.
78
+
79
+ Quadratic weighting suits ordinal relevance grades because a 0 versus 3
80
+ disagreement is penalized more than 2 versus 3.
81
+ """
82
+ matrix = confusion_matrix(a, b, categories)
83
+ n = len(a)
84
+ if n == 0:
85
+ return None
86
+ size = len(categories)
87
+ rows = [sum(r) for r in matrix]
88
+ cols = [sum(matrix[i][j] for i in range(size)) for j in range(size)]
89
+ observed = expected = 0.0
90
+ for i in range(size):
91
+ for j in range(size):
92
+ w = _weight(i, j, size, weights)
93
+ observed += w * matrix[i][j] / n
94
+ expected += w * rows[i] * cols[j] / (n * n)
95
+ if expected == 0:
96
+ return None
97
+ return 1.0 - observed / expected
98
+
99
+
100
+ def fleiss_kappa(
101
+ items: Sequence[Sequence[int]], categories: Sequence[int] = DEFAULT_CATEGORIES
102
+ ) -> float | None:
103
+ """Fleiss' kappa for any fixed number of raters per item.
104
+
105
+ ``items`` holds one sequence of grades per rated item. Every item needs the
106
+ same number of ratings (at least two); use :func:`krippendorff_alpha` when
107
+ some ratings are missing.
108
+ """
109
+ if not items:
110
+ return None
111
+ raters = len(items[0])
112
+ if raters < 2 or any(len(item) != raters for item in items):
113
+ raise ValueError("every item needs the same number of ratings, at least two")
114
+ index = {c: i for i, c in enumerate(categories)}
115
+ totals = [0] * len(categories)
116
+ agreement = 0.0
117
+ for item in items:
118
+ counts = [0] * len(categories)
119
+ for grade in item:
120
+ if grade not in index:
121
+ raise ValueError(f"grade {grade} outside categories {list(categories)}")
122
+ counts[index[grade]] += 1
123
+ for i, c in enumerate(counts):
124
+ totals[i] += c
125
+ agreement += (sum(c * c for c in counts) - raters) / (raters * (raters - 1))
126
+ p_bar = agreement / len(items)
127
+ shares = [t / (len(items) * raters) for t in totals]
128
+ p_e = sum(p * p for p in shares)
129
+ if p_e >= 1.0:
130
+ return None
131
+ return (p_bar - p_e) / (1.0 - p_e)
132
+
133
+
134
+ def krippendorff_alpha(
135
+ units: Sequence[Sequence[int | None]], level: str = "ordinal"
136
+ ) -> float | None:
137
+ """Krippendorff's alpha with missing ratings allowed.
138
+
139
+ ``units`` holds one sequence per item with each rater's grade or ``None``.
140
+ Items with fewer than two grades are ignored. ``level`` is ``nominal``,
141
+ ``ordinal``, or ``interval``.
142
+ """
143
+ if level not in LEVELS:
144
+ raise ValueError(f"unknown level {level!r}; use one of: {', '.join(LEVELS)}")
145
+ coincidence: Counter[tuple[int, int]] = Counter()
146
+ pairable = 0
147
+ for unit in units:
148
+ values = [v for v in unit if v is not None]
149
+ m = len(values)
150
+ if m < 2:
151
+ continue
152
+ pairable += m
153
+ for i, j in itertools.permutations(range(m), 2):
154
+ coincidence[(values[i], values[j])] += 1.0 / (m - 1) # type: ignore[assignment]
155
+ if pairable < 2:
156
+ return None
157
+ values_sorted = sorted({c for pair in coincidence for c in pair})
158
+ marginals = {c: sum(coincidence[(c, k)] for k in values_sorted) for c in values_sorted}
159
+ n = sum(marginals.values())
160
+
161
+ def delta(c: int, k: int) -> float:
162
+ if level == "nominal":
163
+ return 0.0 if c == k else 1.0
164
+ if level == "interval":
165
+ return float((c - k) ** 2)
166
+ low, high = min(c, k), max(c, k)
167
+ between = sum(marginals[g] for g in values_sorted if low <= g <= high)
168
+ return (between - (marginals[c] + marginals[k]) / 2) ** 2
169
+
170
+ observed = sum(coincidence[(c, k)] * delta(c, k) for c in values_sorted for k in values_sorted)
171
+ expected = sum(
172
+ marginals[c] * marginals[k] * delta(c, k) for c in values_sorted for k in values_sorted
173
+ )
174
+ if expected == 0:
175
+ return None
176
+ return 1.0 - (n - 1) * observed / expected
177
+
178
+
179
+ def pairwise_agreement(
180
+ ratings: Mapping[str, Sequence[int | None]],
181
+ ) -> list[dict[str, Any]]:
182
+ """Cohen's kappa (plain and quadratic) for every pair of raters.
183
+
184
+ ``ratings`` maps rater name to grades aligned by item; ``None`` marks a
185
+ missing grade and excludes the item for that pair only.
186
+ """
187
+ out = []
188
+ for first, second in itertools.combinations(ratings, 2):
189
+ pairs = [
190
+ (x, y)
191
+ for x, y in zip(ratings[first], ratings[second], strict=True)
192
+ if x is not None and y is not None
193
+ ]
194
+ a = [x for x, _ in pairs]
195
+ b = [y for _, y in pairs]
196
+ out.append(
197
+ {
198
+ "a": first,
199
+ "b": second,
200
+ "n": len(pairs),
201
+ "percent_agreement": percent_agreement(a, b),
202
+ "cohen_kappa": cohen_kappa(a, b),
203
+ "weighted_kappa": cohen_kappa(a, b, weights="quadratic"),
204
+ }
205
+ )
206
+ return out
207
+
208
+
209
+ def inter_judge_agreement(ratings: Mapping[str, Sequence[int | None]]) -> dict[str, Any]:
210
+ """Summary of agreement between several raters over the same items."""
211
+ names = list(ratings)
212
+ if len(names) < 2:
213
+ raise ValueError("agreement needs at least two raters")
214
+ length = len(ratings[names[0]])
215
+ if any(len(ratings[name]) != length for name in names):
216
+ raise ValueError("every rater needs one entry per item")
217
+ units = [[ratings[name][i] for name in names] for i in range(length)]
218
+ complete = [[v for v in unit if v is not None] for unit in units]
219
+ complete = [unit for unit in complete if len(unit) == len(names)]
220
+ unanimous = sum(1 for unit in complete if len(set(unit)) == 1)
221
+ return {
222
+ "raters": names,
223
+ "items": length,
224
+ "complete_items": len(complete),
225
+ "unanimous": None if not complete else unanimous / len(complete),
226
+ "fleiss_kappa": fleiss_kappa(complete) if complete else None,
227
+ "krippendorff_alpha": {
228
+ "nominal": krippendorff_alpha(units, "nominal"),
229
+ "ordinal": krippendorff_alpha(units, "ordinal"),
230
+ "interval": krippendorff_alpha(units, "interval"),
231
+ },
232
+ "pairwise": pairwise_agreement(ratings),
233
+ }
234
+
235
+
236
+ def gold_agreement(
237
+ predicted: Sequence[int], gold: Sequence[int], threshold: int = 2
238
+ ) -> dict[str, Any]:
239
+ """Compare one rater with gold labels: exact, within one, binary, and kappa."""
240
+ _check_pairs(predicted, gold)
241
+ binary_p = [int(x >= threshold) for x in predicted]
242
+ binary_g = [int(x >= threshold) for x in gold]
243
+ n = len(gold)
244
+ return {
245
+ "n": n,
246
+ "exact": percent_agreement(predicted, gold),
247
+ "within_one": within_one(predicted, gold),
248
+ "binary_agreement": percent_agreement(binary_p, binary_g),
249
+ "cohen_kappa": cohen_kappa(predicted, gold),
250
+ "weighted_kappa": cohen_kappa(predicted, gold, weights="quadratic"),
251
+ "binary_kappa": cohen_kappa(binary_p, binary_g, categories=(0, 1)),
252
+ "mean_bias": (
253
+ None if not n else sum(p - g for p, g in zip(predicted, gold, strict=True)) / n
254
+ ),
255
+ "confusion": {
256
+ "rows": "gold",
257
+ "columns": "judge",
258
+ "categories": list(DEFAULT_CATEGORIES),
259
+ "matrix": confusion_matrix(gold, predicted),
260
+ },
261
+ }
@@ -0,0 +1,51 @@
1
+ :root {
2
+ --bg: #f6f7f9; --card: #fff; --text: #1d2330; --muted: #5f6b7a; --line: #e2e6ec;
3
+ --accent: #2563eb; --good: #15803d; --bad: #b91c1c; --warn: #b45309;
4
+ --g0: #e5e7eb; --g1: #fde68a; --g2: #93c5fd; --g3: #86efac;
5
+ }
6
+ @media (prefers-color-scheme: dark) {
7
+ :root {
8
+ --bg: #11151c; --card: #1a2029; --text: #e6e9ef; --muted: #9aa5b4; --line: #2b3441;
9
+ --accent: #60a5fa; --good: #4ade80; --bad: #f87171; --warn: #fbbf24;
10
+ --g0: #374151; --g1: #854d0e; --g2: #1e40af; --g3: #166534;
11
+ }
12
+ }
13
+ * { box-sizing: border-box; }
14
+ body { margin: 0; font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, Vazirmatn, Tahoma, sans-serif; background: var(--bg); color: var(--text); }
15
+ header { padding: 24px 32px 8px; }
16
+ header h1 { margin: 0 0 4px; font-size: 22px; }
17
+ header .meta { color: var(--muted); }
18
+ nav { position: sticky; top: 0; z-index: 2; background: var(--bg); border-bottom: 1px solid var(--line); padding: 8px 32px; display: flex; gap: 16px; flex-wrap: wrap; }
19
+ nav a { color: var(--accent); text-decoration: none; font-weight: 500; }
20
+ main { padding: 16px 32px 48px; display: grid; gap: 20px; }
21
+ section { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 18px 20px; }
22
+ section h2 { margin: 0 0 12px; font-size: 17px; }
23
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }
24
+ .card { border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; }
25
+ .card .v { font-size: 20px; font-weight: 600; }
26
+ .card .k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
27
+ table { border-collapse: collapse; width: 100%; }
28
+ th, td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
29
+ th { font-weight: 600; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .03em; }
30
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
31
+ .best { font-weight: 700; color: var(--accent); }
32
+ .pos { color: var(--good); } .neg { color: var(--bad); } .muted { color: var(--muted); }
33
+ .row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
34
+ select, input[type=search] { font: inherit; padding: 4px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--card); color: var(--text); }
35
+ input[type=search] { min-width: 260px; }
36
+ svg text { fill: var(--text); font-size: 11px; }
37
+ svg .axis { stroke: var(--line); }
38
+ .bar { fill: var(--accent); }
39
+ .bar.pos { fill: var(--good); } .bar.neg { fill: var(--bad); }
40
+ .split { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px; }
41
+ .clickable tbody tr:hover, tr.selected { background: color-mix(in srgb, var(--accent) 10%, transparent); }
42
+ .lists { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; margin-top: 12px; }
43
+ .list h3 { margin: 0 0 6px; font-size: 14px; }
44
+ .list ol { margin: 0; padding-left: 26px; }
45
+ .list li { margin: 0 0 6px; }
46
+ .badge { display: inline-block; min-width: 20px; text-align: center; border-radius: 4px; padding: 0 5px; margin-right: 6px; font-weight: 600; font-size: 12px; color: #111; }
47
+ .g0 { background: var(--g0); } .g1 { background: var(--g1); } .g2 { background: var(--g2); } .g3 { background: var(--g3); } .gx { background: transparent; border: 1px dashed var(--muted); color: var(--muted); }
48
+ .src { color: var(--muted); font-size: 12px; }
49
+ .reason { color: var(--muted); font-size: 12px; display: block; }
50
+ .note { color: var(--muted); font-size: 13px; }
51
+ footer { color: var(--muted); padding: 0 32px 32px; font-size: 12px; }
@@ -0,0 +1,286 @@
1
+ (function () {
2
+ "use strict";
3
+ var DATA = JSON.parse(document.getElementById("report-data").textContent);
4
+ var SVG = "http://www.w3.org/2000/svg";
5
+ var cfg = DATA.config;
6
+ var primary = cfg.primary_metric;
7
+ var systems = DATA.systems;
8
+ var names = systems.map(function (s) { return s.name; });
9
+ var details = DATA.details || null;
10
+
11
+ function $(id) { return document.getElementById(id); }
12
+ function el(tag, attrs, children) {
13
+ var node = document.createElement(tag);
14
+ if (attrs) {
15
+ Object.keys(attrs).forEach(function (key) {
16
+ if (key === "text") node.textContent = attrs[key];
17
+ else if (key === "class") node.className = attrs[key];
18
+ else node.setAttribute(key, attrs[key]);
19
+ });
20
+ }
21
+ (children || []).forEach(function (child) { if (child) node.appendChild(child); });
22
+ return node;
23
+ }
24
+ function svg(tag, attrs) {
25
+ var node = document.createElementNS(SVG, tag);
26
+ Object.keys(attrs || {}).forEach(function (key) { node.setAttribute(key, attrs[key]); });
27
+ return node;
28
+ }
29
+ function fmt(v, d) { return v === null || v === undefined ? "n/a" : Number(v).toFixed(d === undefined ? 4 : d); }
30
+ function signed(v) { return v === null || v === undefined ? "n/a" : (v >= 0 ? "+" : "") + Number(v).toFixed(4); }
31
+ function pct(v) { return v === null || v === undefined ? "n/a" : (v >= 0 ? "+" : "") + (v * 100).toFixed(1) + "%"; }
32
+ function money(v) { return v === null || v === undefined ? "unknown" : "$" + Number(v).toFixed(4); }
33
+ function td(text, cls) { return el("td", { text: text, class: cls || "" }); }
34
+ function th(text, cls) { return el("th", { text: text, class: cls || "" }); }
35
+ function table(headers, rows, numeric) {
36
+ var head = el("tr", null, headers.map(function (h, i) { return th(h, i && numeric !== false ? "num" : ""); }));
37
+ var body = el("tbody", null, rows);
38
+ return el("table", null, [el("thead", null, [head]), body]);
39
+ }
40
+ function option(value, label, selected) {
41
+ var o = el("option", { value: value, text: label || value });
42
+ if (selected) o.selected = true;
43
+ return o;
44
+ }
45
+
46
+ function barChart(container, items, opts) {
47
+ opts = opts || {};
48
+ container.textContent = "";
49
+ var width = Math.max(320, container.clientWidth || 640);
50
+ var rowH = 22, labelW = opts.labelWidth || 140, valueW = 70;
51
+ var height = items.length * rowH + 10;
52
+ var chart = svg("svg", { width: width, height: height, role: "img", "aria-label": opts.label || "chart" });
53
+ var plotW = width - labelW - valueW;
54
+ var values = items.map(function (it) { return it.value || 0; });
55
+ var maxAbs = Math.max.apply(null, values.map(Math.abs).concat([opts.max || 0, 1e-9]));
56
+ var signedMode = values.some(function (v) { return v < 0; });
57
+ var zero = labelW + (signedMode ? plotW / 2 : 0);
58
+ var scale = (signedMode ? plotW / 2 : plotW) / maxAbs;
59
+ items.forEach(function (it, i) {
60
+ var y = 5 + i * rowH;
61
+ var v = it.value || 0;
62
+ var label = svg("text", { x: labelW - 6, y: y + 14, "text-anchor": "end" });
63
+ label.textContent = it.label.length > 22 ? it.label.slice(0, 21) + "\u2026" : it.label;
64
+ var title = svg("title", {});
65
+ title.textContent = it.label + ": " + (it.value === null ? "n/a" : fmt(it.value));
66
+ var w = Math.abs(v) * scale;
67
+ var bar = svg("rect", {
68
+ x: v >= 0 ? zero : zero - w, y: y + 3, width: Math.max(w, it.value === null ? 0 : 1), height: rowH - 8,
69
+ class: "bar" + (signedMode || opts.colorSign ? (v >= 0 ? " pos" : " neg") : ""), rx: 2
70
+ });
71
+ bar.appendChild(title);
72
+ var text = svg("text", { x: labelW + plotW + 6, y: y + 14 });
73
+ text.textContent = it.value === null ? "n/a" : (signedMode ? signed(v) : fmt(v));
74
+ chart.appendChild(label); chart.appendChild(bar); chart.appendChild(text);
75
+ });
76
+ if (signedMode) chart.appendChild(svg("line", { x1: zero, x2: zero, y1: 0, y2: height, class: "axis" }));
77
+ container.appendChild(chart);
78
+ }
79
+
80
+ function renderOverview() {
81
+ var ds = DATA.dataset, labels = DATA.labels, cost = DATA.cost || {};
82
+ var cards = [
83
+ ["Queries", ds.queries], ["Products", ds.products], ["Gold labels", ds.gold_labels],
84
+ ["Judged", labels.judged], ["From cache", labels.cached], ["Unlabeled", labels.unlabeled],
85
+ ["Tokens", cost.total_tokens === undefined ? "n/a" : cost.total_tokens.toLocaleString()],
86
+ ["Cost", cost.requests ? money(cost.cost_usd) : "$0"]
87
+ ];
88
+ var box = $("overview-cards");
89
+ cards.forEach(function (c) {
90
+ box.appendChild(el("div", { class: "card" }, [el("div", { class: "k", text: c[0] }), el("div", { class: "v", text: String(c[1]) })]));
91
+ });
92
+ $("meta").textContent = "Judge: " + (labels.judge || "none") + " \u00b7 depth " + cfg.depth +
93
+ " \u00b7 relevant when grade \u2265 " + cfg.threshold + " \u00b7 baseline " + cfg.baseline +
94
+ " \u00b7 generated " + DATA.generated_at + " by llm-search-eval " + DATA.tool.version;
95
+ }
96
+
97
+ function renderSummary() {
98
+ var metrics = cfg.metrics;
99
+ var best = {};
100
+ metrics.forEach(function (m) {
101
+ var vals = systems.map(function (s) { return s.metrics[m].mean; }).filter(function (v) { return v !== null; });
102
+ best[m] = vals.length ? Math.max.apply(null, vals) : null;
103
+ });
104
+ var rows = systems.map(function (s) {
105
+ return el("tr", null, [td(s.name)].concat(metrics.map(function (m) {
106
+ var v = s.metrics[m].mean;
107
+ var isBest = v !== null && systems.length > 1 && Math.abs(v - best[m]) < 1e-12;
108
+ return td(fmt(v), "num" + (isBest ? " best" : ""));
109
+ })));
110
+ });
111
+ $("summary-table").appendChild(table(["System"].concat(metrics), rows));
112
+ var select = $("metric-select");
113
+ metrics.forEach(function (m) { select.appendChild(option(m, m, m === primary)); });
114
+ function draw() {
115
+ var m = select.value;
116
+ barChart($("metric-chart"), systems.map(function (s) { return { label: s.name, value: s.metrics[m].mean }; }), { max: 1, label: m });
117
+ }
118
+ select.addEventListener("change", draw);
119
+ draw();
120
+ }
121
+
122
+ function renderComparison() {
123
+ var comp = DATA.comparison;
124
+ var section = $("comparison");
125
+ if (systems.length < 2) { section.hidden = true; return; }
126
+ var rows = comp.significance.map(function (t) {
127
+ var d = comp.deltas.filter(function (x) { return x.system === t.system && x.metric === t.metric; })[0] || {};
128
+ var cls = d.delta > 0 ? "num pos" : d.delta < 0 ? "num neg" : "num";
129
+ return el("tr", null, [td(t.system), td(signed(d.delta), cls), td(pct(d.relative), cls),
130
+ td(t.wins + " / " + t.ties + " / " + t.losses, "num"),
131
+ td(t.p_value === null ? "n/a" : t.p_value.toFixed(4) + (t.p_value < 0.05 ? " *" : ""), "num")]);
132
+ });
133
+ $("comparison-table").appendChild(table(["System", "\u0394 " + comp.primary_metric, "Relative", "Win / tie / loss", "p-value"], rows));
134
+ var select = $("compare-select");
135
+ names.filter(function (n) { return n !== comp.baseline; }).forEach(function (n, i) { select.appendChild(option(n, n, i === 0)); });
136
+ function draw() {
137
+ var a = systems.filter(function (s) { return s.name === select.value; })[0];
138
+ var b = systems.filter(function (s) { return s.name === comp.baseline; })[0];
139
+ var items = [];
140
+ Object.keys(b.per_query).forEach(function (q) {
141
+ var x = a.per_query[q][primary], y = b.per_query[q][primary];
142
+ if (x === null || y === null || Math.abs(x - y) < 1e-12) return;
143
+ var text = details && details.queries[q] ? q + " " + details.queries[q].text : q;
144
+ items.push({ label: text, value: x - y });
145
+ });
146
+ items.sort(function (p, q) { return q.value - p.value; });
147
+ $("delta-note").textContent = items.length ? "Per-query " + primary + " change of " + select.value + " versus " + comp.baseline + ", queries with no change omitted." : "No query changed between " + select.value + " and " + comp.baseline + ".";
148
+ barChart($("delta-chart"), items, { labelWidth: 220, label: "per-query delta" });
149
+ }
150
+ select.addEventListener("change", draw);
151
+ draw();
152
+ }
153
+
154
+ function renderLanguages() {
155
+ var by = DATA.by_language;
156
+ var langs = {};
157
+ Object.keys(by).forEach(function (s) { Object.keys(by[s]).forEach(function (l) { langs[l] = true; }); });
158
+ langs = Object.keys(langs).sort();
159
+ if (langs.length < 2) { $("languages").hidden = true; return; }
160
+ var rows = Object.keys(by).map(function (s) {
161
+ return el("tr", null, [td(s)].concat(langs.map(function (l) { return td(fmt(by[s][l].mean) + " (n=" + by[s][l].n + ")", "num"); })));
162
+ });
163
+ $("language-table").appendChild(table(["System"].concat(langs), rows));
164
+ }
165
+
166
+ function renderJudging() {
167
+ var j = DATA.judging;
168
+ if (!j) { $("judging").hidden = true; return; }
169
+ var dist = j.label_distribution;
170
+ barChart($("label-chart"), ["3", "2", "1", "0"].map(function (g) { return { label: "grade " + g, value: dist[g] || 0 }; }), { label: "label distribution" });
171
+ var info = $("judging-info");
172
+ info.appendChild(el("p", { text: "Judge: " + j.judge + ". " + j.pairs + " judged pair(s)." }));
173
+ if (j.agreement) {
174
+ var a = j.agreement;
175
+ var k = a.krippendorff_alpha;
176
+ info.appendChild(table(["Statistic", "Value"], [
177
+ el("tr", null, [td("Fleiss' kappa"), td(fmt(a.fleiss_kappa, 3), "num")]),
178
+ el("tr", null, [td("Krippendorff's alpha (ordinal)"), td(fmt(k.ordinal, 3), "num")]),
179
+ el("tr", null, [td("Krippendorff's alpha (nominal)"), td(fmt(k.nominal, 3), "num")]),
180
+ el("tr", null, [td("Unanimous items"), td(a.unanimous === null ? "n/a" : (a.unanimous * 100).toFixed(1) + "%", "num")])
181
+ ]));
182
+ info.appendChild(el("h3", { text: "Pairwise agreement" }));
183
+ info.appendChild(table(["Judges", "n", "Agreement", "Cohen's kappa", "Weighted kappa"], a.pairwise.map(function (p) {
184
+ return el("tr", null, [td(p.a + " / " + p.b), td(String(p.n), "num"), td(p.percent_agreement === null ? "n/a" : (p.percent_agreement * 100).toFixed(1) + "%", "num"), td(fmt(p.cohen_kappa, 3), "num"), td(fmt(p.weighted_kappa, 3), "num")]);
185
+ })));
186
+ }
187
+ var g = j.gold_agreement;
188
+ if (g) {
189
+ var box = $("gold-info");
190
+ box.appendChild(el("h3", { text: "Agreement with gold labels" }));
191
+ var raters = [[j.judge, g.judge]].concat(Object.keys(g.members).map(function (n) { return [n, g.members[n]]; }));
192
+ box.appendChild(table(["Rater", "n", "Exact", "Within one", "Binary", "Weighted kappa", "Bias"], raters.map(function (r) {
193
+ var s = r[1];
194
+ return el("tr", null, [td(r[0]), td(String(s.n), "num"), td((s.exact * 100).toFixed(1) + "%", "num"), td((s.within_one * 100).toFixed(1) + "%", "num"), td((s.binary_agreement * 100).toFixed(1) + "%", "num"), td(fmt(s.weighted_kappa, 3), "num"), td(signed(s.mean_bias), "num")]);
195
+ })));
196
+ var m = g.judge.confusion;
197
+ box.appendChild(el("p", { class: "note", text: "Confusion matrix: rows are gold grades, columns are judge grades." }));
198
+ box.appendChild(table(["gold \\ judge"].concat(m.categories.map(String)), m.matrix.map(function (row, i) {
199
+ return el("tr", null, [td(String(m.categories[i]))].concat(row.map(function (c) { return td(String(c), "num"); })));
200
+ })));
201
+ }
202
+ }
203
+
204
+ function renderCost() {
205
+ var c = DATA.cost;
206
+ if (!c || (!c.requests && !c.cache_hits && !c.budget.skipped_pairs)) { $("cost").hidden = true; return; }
207
+ var rows = Object.keys(c.models).map(function (name) {
208
+ var m = c.models[name];
209
+ return el("tr", null, [td(name), td(String(m.requests), "num"), td(m.prompt_tokens.toLocaleString(), "num"), td(m.completion_tokens.toLocaleString(), "num"), td(money(m.cost_usd), "num")]);
210
+ });
211
+ rows.push(el("tr", null, [td("Total"), td(String(c.requests), "num"), td(c.prompt_tokens.toLocaleString(), "num"), td(c.completion_tokens.toLocaleString(), "num"), td(money(c.cost_usd), "num best")]));
212
+ $("cost-table").appendChild(table(["Model", "Requests", "Input tokens", "Output tokens", "Cost"], rows));
213
+ var notes = [c.cache_hits + " judgment(s) served from cache at no cost."];
214
+ if (c.unpriced_models.length) notes.push("No price configured for: " + c.unpriced_models.join(", ") + ".");
215
+ if (c.budget.exhausted) notes.push("Budget reached (" + c.budget.reason + "); " + c.budget.skipped_pairs + " pair(s) were not judged.");
216
+ $("cost-note").textContent = notes.join(" ");
217
+ }
218
+
219
+ function renderQueries() {
220
+ if (!details) { $("queries").hidden = true; return; }
221
+ var qids = Object.keys(details.queries);
222
+ var body = el("tbody");
223
+ var rows = {};
224
+ qids.forEach(function (q) {
225
+ var info = details.queries[q];
226
+ var cells = [td(q), el("td", { dir: "auto", text: info.text }), td(info.lang)];
227
+ systems.forEach(function (s) { cells.push(td(fmt(s.per_query[q][primary], 3), "num")); });
228
+ var tr = el("tr", { "data-q": q }, cells);
229
+ tr.addEventListener("click", function () { showQuery(q); });
230
+ rows[q] = tr;
231
+ body.appendChild(tr);
232
+ });
233
+ var head = el("tr", null, [th("Id"), th("Query"), th("Lang")].concat(names.map(function (n) { return th(n + " " + primary, "num"); })));
234
+ $("query-table").appendChild(el("table", { class: "clickable" }, [el("thead", null, [head]), body]));
235
+ $("query-filter").addEventListener("input", function (e) {
236
+ var needle = e.target.value.toLowerCase();
237
+ qids.forEach(function (q) {
238
+ var hay = (q + " " + details.queries[q].text).toLowerCase();
239
+ rows[q].hidden = needle && hay.indexOf(needle) < 0;
240
+ });
241
+ });
242
+ function showQuery(q) {
243
+ Object.keys(rows).forEach(function (k) { rows[k].className = k === q ? "selected" : ""; });
244
+ var box = $("query-detail");
245
+ box.textContent = "";
246
+ var info = details.queries[q];
247
+ box.appendChild(el("h3", { dir: "auto", text: q + ": " + info.text }));
248
+ var labels = details.labels[q] || {};
249
+ var lists = el("div", { class: "lists" });
250
+ systems.forEach(function (s) {
251
+ var ranked = details.rankings[s.name][q] || [];
252
+ var ol = el("ol");
253
+ ranked.forEach(function (pid) {
254
+ var l = labels[pid];
255
+ var p = details.products[pid];
256
+ var grade = l ? l.grade : null;
257
+ var badge = el("span", { class: "badge " + (grade === null ? "gx" : "g" + grade), text: grade === null ? "?" : String(grade) });
258
+ var title = el("span", { dir: "auto", text: p ? p.title : pid + " (not in catalog)" });
259
+ var src = el("span", { class: "src", text: " " + pid + (l ? " \u00b7 " + l.source : " \u00b7 unlabeled") });
260
+ var parts = [badge, title, src];
261
+ if (l && l.reason) parts.push(el("span", { class: "reason", dir: "auto", text: l.reason }));
262
+ if (l && l.votes) parts.push(el("span", { class: "reason", text: "votes: " + Object.keys(l.votes).map(function (k) { return k + "=" + (l.votes[k] === null ? "x" : l.votes[k]); }).join(", ") }));
263
+ ol.appendChild(el("li", null, parts));
264
+ });
265
+ if (!ranked.length) ol.appendChild(el("li", { class: "muted", text: "no results" }));
266
+ var metrics = cfg.metrics.map(function (m) { return m + " " + fmt(s.per_query[q][m], 3); }).join(" \u00b7 ");
267
+ lists.appendChild(el("div", { class: "list" }, [el("h3", { text: s.name }), el("p", { class: "note", text: metrics }), ol]));
268
+ });
269
+ box.appendChild(lists);
270
+ var missing = Object.keys(labels).filter(function (pid) {
271
+ return labels[pid].grade >= cfg.threshold && !systems.some(function (s) { return (details.rankings[s.name][q] || []).indexOf(pid) >= 0; });
272
+ });
273
+ if (missing.length) box.appendChild(el("p", { class: "note", text: "Relevant products no system returned in the top " + cfg.depth + ": " + missing.join(", ") }));
274
+ box.scrollIntoView({ behavior: "smooth", block: "nearest" });
275
+ }
276
+ if (qids.length) showQuery(qids[0]);
277
+ }
278
+
279
+ renderOverview();
280
+ renderSummary();
281
+ renderComparison();
282
+ renderLanguages();
283
+ renderJudging();
284
+ renderCost();
285
+ renderQueries();
286
+ })();