agent-skill-description-optimizer 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,171 @@
1
+ """Pure trigger-decision logic over a ``claude -p`` stream-json event sequence.
2
+
3
+ Separated from subprocess transport so it can be tested exhaustively against
4
+ synthetic and recorded event streams. The decision is made from tool-call *intent*,
5
+ so it does not depend on tool execution.
6
+ """
7
+
8
+ import json
9
+ from collections.abc import Iterable, Mapping
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ # Tool names that, when invoked, may reference the injected candidate command.
14
+ _SKILL_TOOLS = ("Skill", "Read")
15
+
16
+
17
+ def _scan_assistant(message: Mapping[str, Any], cmd_name: str) -> bool | None:
18
+ """Decide from a non-streaming ``assistant`` message.
19
+
20
+ Args:
21
+ message: The assistant message object.
22
+ cmd_name: The injected command name to look for.
23
+
24
+ Returns:
25
+ ``True``/``False`` from the first ``tool_use`` item, or ``None`` if the
26
+ message contains no tool call.
27
+ """
28
+ tool_use = next(
29
+ (c for c in message.get("content", []) if c.get("type") == "tool_use"),
30
+ None,
31
+ )
32
+ if tool_use is None:
33
+ return None
34
+ if tool_use.get("name") not in _SKILL_TOOLS:
35
+ return False
36
+ return cmd_name in json.dumps(tool_use.get("input", {}))
37
+
38
+
39
+ @dataclass(slots=True)
40
+ class _ScanState:
41
+ """Mutable state for scanning a streamed tool call.
42
+
43
+ Attributes:
44
+ cmd_name: The injected command name to detect.
45
+ pending: Name of the in-progress skill tool, or ``None`` if none is open.
46
+ acc: Accumulated ``input_json_delta`` text for the pending tool.
47
+ """
48
+
49
+ cmd_name: str
50
+ pending: str | None = None
51
+ acc: str = ""
52
+
53
+ def feed_stream_event(self, stream: Mapping[str, Any]) -> bool | None:
54
+ """Advance the scan with one ``stream_event`` payload.
55
+
56
+ Args:
57
+ stream: The inner ``event`` object of a ``stream_event``.
58
+
59
+ Returns:
60
+ ``True``/``False`` once decided, or ``None`` to keep scanning.
61
+ """
62
+ stream_type = stream.get("type", "")
63
+ if stream_type == "content_block_start":
64
+ return self._on_block_start(stream.get("content_block", {}))
65
+ if stream_type == "content_block_delta":
66
+ return self._on_delta(stream.get("delta", {}))
67
+ if stream_type in ("content_block_stop", "message_stop"):
68
+ if self.pending is not None:
69
+ return self.cmd_name in self.acc
70
+ if stream_type == "message_stop":
71
+ return False
72
+ return None
73
+
74
+ def _on_block_start(self, block: Mapping[str, Any]) -> bool | None:
75
+ """Handle a ``content_block_start``.
76
+
77
+ Args:
78
+ block: The ``content_block`` object.
79
+
80
+ Returns:
81
+ ``False`` if a non-skill tool starts first, else ``None``.
82
+ """
83
+ if block.get("type") == "tool_use":
84
+ if block.get("name") in _SKILL_TOOLS:
85
+ self.pending = block.get("name")
86
+ self.acc = ""
87
+ else:
88
+ return False
89
+ return None
90
+
91
+ def _on_delta(self, delta: Mapping[str, Any]) -> bool | None:
92
+ """Handle a ``content_block_delta``, accumulating tool input.
93
+
94
+ Args:
95
+ delta: The ``delta`` object.
96
+
97
+ Returns:
98
+ ``True`` once ``cmd_name`` appears in the accumulated input, else
99
+ ``None``.
100
+ """
101
+ if self.pending is not None and delta.get("type") == "input_json_delta":
102
+ self.acc += delta.get("partial_json", "")
103
+ if self.cmd_name in self.acc:
104
+ return True
105
+ return None
106
+
107
+
108
+ def _interpret_events_status(
109
+ events: Iterable[Mapping[str, Any]], cmd_name: str
110
+ ) -> bool | None:
111
+ """Decide the trigger outcome, distinguishing a decisive result from an
112
+ exhausted stream.
113
+
114
+ Consumes events lazily and returns as soon as the outcome is decided, so a
115
+ generator backed by a live subprocess can be abandoned early. The decision rules:
116
+
117
+ - The first ``tool_use`` block that is **not** ``Skill``/``Read`` means the model
118
+ went elsewhere first: ``False`` (even if a skill is used later).
119
+ - A ``Skill``/``Read`` ``tool_use`` whose streamed input contains ``cmd_name``
120
+ (accumulated across ``input_json_delta`` chunks): ``True``.
121
+ - A block/message stop while a skill tool is pending resolves to whether
122
+ ``cmd_name`` was seen in that block's input.
123
+ - A non-streaming ``assistant`` ``tool_use`` is judged on its full input.
124
+ - ``message_stop`` with nothing pending, or a ``result`` event: ``False``.
125
+
126
+ ``thinking`` and ``text`` content blocks are ignored.
127
+
128
+ Unlike :func:`interpret_events`, this returns ``None`` when the stream is exhausted
129
+ with **no** decisive terminal event (a timeout-truncated, empty, or otherwise
130
+ incomplete stream), so the caller can treat an unjudgeable probe differently from a
131
+ genuine non-trigger.
132
+
133
+ Args:
134
+ events: Parsed stream-json events, in order.
135
+ cmd_name: The injected command name to look for in tool input.
136
+
137
+ Returns:
138
+ ``True`` (decisive trigger), ``False`` (decisive non-trigger), or ``None``
139
+ (no decisive terminal event was observed).
140
+ """
141
+ state = _ScanState(cmd_name)
142
+ for event in events:
143
+ event_type = event.get("type")
144
+ if event_type == "stream_event":
145
+ decision = state.feed_stream_event(event.get("event", {}))
146
+ elif event_type == "assistant":
147
+ decision = _scan_assistant(event.get("message", {}), cmd_name)
148
+ elif event_type == "result":
149
+ decision = False
150
+ else:
151
+ decision = None
152
+ if decision is not None:
153
+ return decision
154
+ return None
155
+
156
+
157
+ def interpret_events(events: Iterable[Mapping[str, Any]], cmd_name: str) -> bool:
158
+ """Decide whether a stream invokes the injected candidate command.
159
+
160
+ A thin, pure-bool wrapper over :func:`_interpret_events_status` that coerces the
161
+ "no decisive terminal event" case (``None``) to ``False``. See that function for
162
+ the full decision rules.
163
+
164
+ Args:
165
+ events: Parsed stream-json events, in order.
166
+ cmd_name: The injected command name to look for in tool input.
167
+
168
+ Returns:
169
+ ``True`` if the model invoked the candidate command, else ``False``.
170
+ """
171
+ return _interpret_events_status(events, cmd_name) is True
@@ -0,0 +1,155 @@
1
+ """Shared types and constants for the skill-description optimizer."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TypedDict
5
+
6
+ # Convenience aliases so callers can pass ``--models haiku,sonnet,opus``. These are
7
+ # source-pinned to current tier IDs (verified against Anthropic's official model table);
8
+ # any full model id also passes through unchanged. For reproducibility, pass an explicit
9
+ # full ``--model``/``--models`` id rather than relying on these moving-target defaults.
10
+ MODEL_ALIASES: dict[str, str] = {
11
+ "haiku": "claude-haiku-4-5-20251001",
12
+ "sonnet": "claude-sonnet-5",
13
+ "opus": "claude-opus-4-8",
14
+ }
15
+
16
+
17
+ class EvalQuery(TypedDict):
18
+ """A single evaluation query.
19
+
20
+ Attributes:
21
+ query: The natural-language task shown to the model.
22
+ should_trigger: Whether a correct skill description should fire for it.
23
+ """
24
+
25
+ query: str
26
+ should_trigger: bool
27
+
28
+
29
+ # ``pass`` is a reserved word, so this member needs the functional TypedDict syntax.
30
+ # ``pass`` is ``None`` when every probe for this ``(query, model)`` cell was
31
+ # unjudgeable (timeout/CLI error), so the cell is excluded from accuracy denominators
32
+ # rather than scored as a miss. ``triggers``/``runs`` are the judged-run counts (so a
33
+ # cell with 2 judged + 1 error is representable), and ``errors`` is the unjudged count.
34
+ ModelResult = TypedDict(
35
+ "ModelResult",
36
+ {
37
+ "trigger_rate": float,
38
+ "pass": bool | None,
39
+ "triggers": int,
40
+ "runs": int,
41
+ "errors": int,
42
+ },
43
+ )
44
+ """Per-model outcome for one query: trigger rate, pass/fail/unjudged, and counts."""
45
+
46
+
47
+ class PerQuery(TypedDict):
48
+ """Per-query roll-up across all evaluated models.
49
+
50
+ Attributes:
51
+ index: Original positional index into the full eval set (dedup-safe key used
52
+ by train/test slicing and the HTML report, never the query text).
53
+ query: The evaluated query text.
54
+ should_trigger: Whether a correct description should fire for it.
55
+ models: Per-model outcomes keyed by model id.
56
+ all_pass: ``True`` if every judged model passes, ``False`` if at least one
57
+ judged model fails, ``None`` if every model was unjudged for this query.
58
+ """
59
+
60
+ index: int
61
+ query: str
62
+ should_trigger: bool
63
+ models: dict[str, ModelResult]
64
+ all_pass: bool | None
65
+
66
+
67
+ class ImproverAttempt(TypedDict):
68
+ """One previously-tried description plus its **train-only** per-query results.
69
+
70
+ Held-out (test) results are deliberately excluded so the improver never sees the
71
+ selection set (blinding), which would otherwise invite overfitting.
72
+
73
+ Attributes:
74
+ description: The description that was tried.
75
+ train_results: The training-set :data:`PerQuery` roll-ups for that attempt.
76
+ """
77
+
78
+ description: str
79
+ train_results: list[PerQuery]
80
+
81
+
82
+ class ConfusionMatrix(TypedDict):
83
+ """Trigger confusion matrix over judged runs, with derived ratios.
84
+
85
+ Attributes:
86
+ tp: True positives (should-trigger runs that triggered).
87
+ fp: False positives (should-not-trigger runs that triggered).
88
+ tn: True negatives (should-not-trigger runs that stayed silent).
89
+ fn: False negatives (should-trigger runs that stayed silent).
90
+ precision: ``tp / (tp + fp)`` (``1.0`` when there are no positives predicted).
91
+ recall: ``tp / (tp + fn)`` (``1.0`` when there are no positives expected).
92
+ accuracy: ``(tp + tn) / total`` (``0.0`` when there are no judged runs).
93
+ """
94
+
95
+ tp: int
96
+ fp: int
97
+ tn: int
98
+ fn: int
99
+ precision: float
100
+ recall: float
101
+ accuracy: float
102
+
103
+
104
+ class EvalResult(TypedDict):
105
+ """Aggregated evaluation of one description across queries and models.
106
+
107
+ Attributes:
108
+ description: The description that was evaluated.
109
+ per_model_accuracy: Accuracy in ``[0, 1]`` keyed by model id; ``None`` for a
110
+ model with zero judged queries in the (sub)set.
111
+ mean_accuracy: Mean of the non-``None`` ``per_model_accuracy`` values.
112
+ min_accuracy: Minimum (weakest-model) non-``None`` accuracy.
113
+ per_query: One :data:`PerQuery` entry per evaluation query.
114
+ errors: Total unjudged probe count across all cells.
115
+ unjudged: Number of unjudged ``query x model`` cells.
116
+ score_valid: ``False`` when no model had any judged query (accuracy unusable
117
+ for selection); ``True`` otherwise.
118
+ confusion: Aggregate confusion matrix over all judged runs.
119
+ per_model_confusion: Per-model confusion matrix keyed by model id.
120
+ """
121
+
122
+ description: str
123
+ per_model_accuracy: dict[str, float | None]
124
+ mean_accuracy: float
125
+ min_accuracy: float
126
+ per_query: list[PerQuery]
127
+ errors: int
128
+ unjudged: int
129
+ score_valid: bool
130
+ confusion: ConfusionMatrix
131
+ per_model_confusion: dict[str, ConfusionMatrix]
132
+
133
+
134
+ @dataclass(frozen=True, slots=True)
135
+ class EvalConfig:
136
+ """Settings shared across every query in an evaluation run.
137
+
138
+ Bundling these avoids threading the same long positional-argument list through
139
+ ``evaluate`` and its callers.
140
+
141
+ Attributes:
142
+ models: Resolved model ids to evaluate against.
143
+ repeats: Number of runs per ``(query, model)`` pair.
144
+ timeout: Per-run wall-clock budget, in seconds.
145
+ workers: Maximum concurrent ``claude -p`` subprocesses.
146
+ threshold: Trigger-rate at or above which a query counts as triggered.
147
+ settings_json: Optional ``--settings`` JSON blob, or ``None`` to omit it.
148
+ """
149
+
150
+ models: tuple[str, ...]
151
+ repeats: int
152
+ timeout: int
153
+ workers: int
154
+ threshold: float
155
+ settings_json: str | None = None
File without changes
@@ -0,0 +1,297 @@
1
+ """HTML report generation, ported from skill-creator's ``generate_report.py``.
2
+
3
+ Adapted to this tool's multi-model, tri-state history: query cells are anchored by the
4
+ original positional ``index`` (dedup-safe), render ``pass`` as a tri-state
5
+ (green/red/neutral, never a red cross for an unjudged probe), carry per-model detail in
6
+ the cell ``title`` attribute, and highlight the winning row from the ``is_best`` flag
7
+ (never recomputed from a score).
8
+ """
9
+
10
+ import html
11
+ from typing import Any
12
+
13
+ _CSS = """
14
+ body {
15
+ font-family: 'Lora', Georgia, serif;
16
+ max-width: 100%;
17
+ margin: 0 auto;
18
+ padding: 20px;
19
+ background: #faf9f5;
20
+ color: #141413;
21
+ }
22
+ h1 { font-family: 'Poppins', sans-serif; color: #141413; }
23
+ .explainer, .summary {
24
+ background: white;
25
+ padding: 15px;
26
+ border-radius: 6px;
27
+ margin-bottom: 20px;
28
+ border: 1px solid #e8e6dc;
29
+ }
30
+ .explainer { color: #b0aea5; font-size: 0.875rem; line-height: 1.6; }
31
+ .summary p { margin: 5px 0; }
32
+ .best { color: #788c5d; font-weight: bold; }
33
+ .table-container { overflow-x: auto; width: 100%; }
34
+ table {
35
+ border-collapse: collapse;
36
+ background: white;
37
+ border: 1px solid #e8e6dc;
38
+ border-radius: 6px;
39
+ font-size: 12px;
40
+ min-width: 100%;
41
+ }
42
+ th, td {
43
+ padding: 8px;
44
+ text-align: left;
45
+ border: 1px solid #e8e6dc;
46
+ white-space: normal;
47
+ word-wrap: break-word;
48
+ }
49
+ th {
50
+ font-family: 'Poppins', sans-serif;
51
+ background: #141413;
52
+ color: #faf9f5;
53
+ font-weight: 500;
54
+ }
55
+ th.test-col { background: #6a9bcc; }
56
+ th.query-col { min-width: 200px; }
57
+ td.description {
58
+ font-family: monospace;
59
+ font-size: 11px;
60
+ word-wrap: break-word;
61
+ max-width: 400px;
62
+ }
63
+ td.result { text-align: center; font-size: 16px; min-width: 40px; }
64
+ td.test-result { background: #f0f6fc; }
65
+ .pass { color: #788c5d; }
66
+ .fail { color: #c44; }
67
+ .unjudged { color: #b0aea5; }
68
+ .rate { font-size: 9px; color: #b0aea5; display: block; }
69
+ tr:hover { background: #faf9f5; }
70
+ .score {
71
+ display: inline-block;
72
+ padding: 2px 6px;
73
+ border-radius: 4px;
74
+ font-weight: bold;
75
+ font-size: 11px;
76
+ }
77
+ .score-good { background: #eef2e8; color: #788c5d; }
78
+ .score-ok { background: #fef3c7; color: #d97706; }
79
+ .score-bad { background: #fceaea; color: #c44; }
80
+ .best-row { background: #f5f8f2; }
81
+ th.positive-col { border-bottom: 3px solid #788c5d; }
82
+ th.negative-col { border-bottom: 3px solid #c44; }
83
+ .legend {
84
+ font-family: 'Poppins', sans-serif;
85
+ display: flex;
86
+ gap: 20px;
87
+ margin-bottom: 10px;
88
+ font-size: 13px;
89
+ align-items: center;
90
+ }
91
+ .legend-item { display: flex; align-items: center; gap: 6px; }
92
+ .legend-swatch { width: 16px; height: 16px; border-radius: 3px; display: inline-block; }
93
+ .swatch-positive { background: #141413; border-bottom: 3px solid #788c5d; }
94
+ .swatch-negative { background: #141413; border-bottom: 3px solid #c44; }
95
+ .swatch-test { background: #6a9bcc; }
96
+ .swatch-train { background: #141413; }
97
+ """
98
+
99
+
100
+ def _column_headers(results: list[dict[str, Any]], test: bool) -> str:
101
+ """Render ``<th>`` column headers for a set of query results.
102
+
103
+ Args:
104
+ results: The ordered result entries (from ``history[0]``) to build columns for.
105
+ test: Whether these are held-out (test) columns (adds the test styling).
106
+
107
+ Returns:
108
+ The concatenated header cells.
109
+ """
110
+ parts: list[str] = []
111
+ for r in results:
112
+ polarity = "positive-col" if r.get("should_trigger", True) else "negative-col"
113
+ cls = f"test-col {polarity}" if test else polarity
114
+ parts.append(
115
+ f' <th class="{cls}">{html.escape(r["query"])}</th>\n'
116
+ )
117
+ return "".join(parts)
118
+
119
+
120
+ def _aggregate_runs(results: list[dict[str, Any]]) -> tuple[int, int]:
121
+ """Sum correct runs and total runs across all query results.
122
+
123
+ Args:
124
+ results: The result entries to aggregate.
125
+
126
+ Returns:
127
+ A ``(correct, total)`` tuple of run counts.
128
+ """
129
+ correct = 0
130
+ total = 0
131
+ for r in results:
132
+ runs = r.get("runs", 0)
133
+ triggers = r.get("triggers", 0)
134
+ total += runs
135
+ correct += triggers if r.get("should_trigger", True) else runs - triggers
136
+ return correct, total
137
+
138
+
139
+ def _score_class(correct: int, total: int) -> str:
140
+ """Map a correct/total ratio to a CSS score class.
141
+
142
+ Args:
143
+ correct: Correct run count.
144
+ total: Total run count.
145
+
146
+ Returns:
147
+ The CSS class name.
148
+ """
149
+ if total > 0:
150
+ ratio = correct / total
151
+ if ratio >= 0.8:
152
+ return "score-good"
153
+ if ratio >= 0.5:
154
+ return "score-ok"
155
+ return "score-bad"
156
+
157
+
158
+ def _result_cell(r: dict[str, Any], test: bool) -> str:
159
+ """Render one query result cell, tri-state and per-model annotated.
160
+
161
+ Args:
162
+ r: The result entry (may be empty if the column has no matching index).
163
+ test: Whether this is a held-out (test) cell.
164
+
165
+ Returns:
166
+ The ``<td>`` cell HTML.
167
+ """
168
+ did_pass = r.get("pass")
169
+ triggers = r.get("triggers", 0)
170
+ runs = r.get("runs", 0)
171
+ if did_pass is True:
172
+ icon, css = "✓", "pass"
173
+ elif did_pass is False:
174
+ icon, css = "✗", "fail"
175
+ else:
176
+ # Unjudged (all probes errored): a neutral marker, never a red cross.
177
+ icon, css = "–", "unjudged"
178
+ per_model = r.get("models", {})
179
+ title = ", ".join(
180
+ f"{m}: {d.get('triggers', 0)}/{d.get('runs', 0)}" for m, d in per_model.items()
181
+ )
182
+ cls = f"result test-result {css}" if test else f"result {css}"
183
+ return (
184
+ f' <td class="{cls}" title="{html.escape(title)}">{icon}'
185
+ f'<span class="rate">{triggers}/{runs}</span></td>\n'
186
+ )
187
+
188
+
189
+ def _row(
190
+ h: dict[str, Any],
191
+ train_cols: list[dict[str, Any]],
192
+ test_cols: list[dict[str, Any]],
193
+ ) -> str:
194
+ """Render one iteration row.
195
+
196
+ Args:
197
+ h: The history entry for this iteration.
198
+ train_cols: The ordered training column definitions (from ``history[0]``).
199
+ test_cols: The ordered held-out column definitions (from ``history[0]``).
200
+
201
+ Returns:
202
+ The ``<tr>`` row HTML.
203
+ """
204
+ train_results = h.get("train_results", h.get("results", []))
205
+ test_results = h.get("test_results", [])
206
+ # Anchor cells by original positional index, never query text (dedup-safe).
207
+ train_by_index = {r["index"]: r for r in train_results}
208
+ test_by_index = {r["index"]: r for r in test_results}
209
+ train_correct, train_runs = _aggregate_runs(train_results)
210
+ test_correct, test_runs = _aggregate_runs(test_results)
211
+ row_class = "best-row" if h.get("is_best") else ""
212
+ cells = [
213
+ f' <tr class="{row_class}">\n',
214
+ f" <td>{h.get('iteration', '?')}</td>\n",
215
+ f' <td><span class="score {_score_class(train_correct, train_runs)}">'
216
+ f"{train_correct}/{train_runs}</span></td>\n",
217
+ f' <td><span class="score {_score_class(test_correct, test_runs)}">'
218
+ f"{test_correct}/{test_runs}</span></td>\n",
219
+ f' <td class="description">{html.escape(h.get("description", ""))}</td>\n',
220
+ ]
221
+ cells.extend(
222
+ _result_cell(train_by_index.get(c["index"], {}), False) for c in train_cols
223
+ )
224
+ cells.extend(
225
+ _result_cell(test_by_index.get(c["index"], {}), True) for c in test_cols
226
+ )
227
+ cells.append(" </tr>\n")
228
+ return "".join(cells)
229
+
230
+
231
+ def generate_html(
232
+ data: dict[str, Any], auto_refresh: bool = False, skill_name: str = ""
233
+ ) -> str:
234
+ """Generate the HTML optimization report from loop output data.
235
+
236
+ Args:
237
+ data: The report dict (the same superset written to stdout / ``results.json``).
238
+ auto_refresh: When ``True``, add a 5-second meta-refresh (for the live report).
239
+ skill_name: Skill name for the report title.
240
+
241
+ Returns:
242
+ The rendered HTML document.
243
+ """
244
+ history: list[dict[str, Any]] = data.get("history", [])
245
+ title_prefix = html.escape(f"{skill_name} — ") if skill_name else ""
246
+ first = history[0] if history else {}
247
+ train_cols: list[dict[str, Any]] = first.get(
248
+ "train_results", first.get("results", [])
249
+ )
250
+ test_cols: list[dict[str, Any]] = first.get("test_results", [])
251
+ refresh_tag = (
252
+ ' <meta http-equiv="refresh" content="5">\n' if auto_refresh else ""
253
+ )
254
+ best_test_score = data.get("best_test_score")
255
+ parts: list[str] = [
256
+ '<!DOCTYPE html>\n<html>\n<head>\n <meta charset="utf-8">\n',
257
+ refresh_tag,
258
+ f" <title>{title_prefix}Skill Description Optimization</title>\n",
259
+ f" <style>{_CSS} </style>\n</head>\n<body>\n",
260
+ f" <h1>{title_prefix}Skill Description Optimization</h1>\n",
261
+ ' <div class="explainer"><strong>Optimizing your skill’s '
262
+ "description.</strong> Each row is a description attempt. Query columns show "
263
+ "test cases: a green check means the skill triggered correctly (or correctly "
264
+ "stayed silent), a red cross means it got it wrong, a grey dash means the probe "
265
+ "was unjudgeable. The best-performing row is highlighted.</div>\n",
266
+ ' <div class="summary">\n'
267
+ f" <p><strong>Original:</strong> {html.escape(str(data.get('original_description', 'N/A')))}</p>\n"
268
+ f' <p class="best"><strong>Best:</strong> {html.escape(str(data.get("best_description", "N/A")))}</p>\n'
269
+ f" <p><strong>Best Score:</strong> {data.get('best_score', 'N/A')} "
270
+ f"{'(test)' if best_test_score else '(train)'}</p>\n"
271
+ f" <p><strong>Iterations:</strong> {data.get('iterations_run', 0)} | "
272
+ f"<strong>Train:</strong> {data.get('train_size', '?')} | "
273
+ f"<strong>Test:</strong> {data.get('test_size', '?')}</p>\n"
274
+ " </div>\n",
275
+ ' <div class="legend">\n'
276
+ ' <span style="font-weight:600">Query columns:</span>\n'
277
+ ' <span class="legend-item"><span class="legend-swatch swatch-positive">'
278
+ "</span> Should trigger</span>\n"
279
+ ' <span class="legend-item"><span class="legend-swatch swatch-negative">'
280
+ "</span> Should NOT trigger</span>\n"
281
+ ' <span class="legend-item"><span class="legend-swatch swatch-train">'
282
+ "</span> Train</span>\n"
283
+ ' <span class="legend-item"><span class="legend-swatch swatch-test">'
284
+ "</span> Test</span>\n"
285
+ " </div>\n",
286
+ ' <div class="table-container">\n <table>\n <thead>\n'
287
+ " <tr>\n"
288
+ " <th>Iter</th>\n <th>Train</th>\n"
289
+ ' <th>Test</th>\n <th class="query-col">'
290
+ "Description</th>\n",
291
+ _column_headers(train_cols, False),
292
+ _column_headers(test_cols, True),
293
+ " </tr>\n </thead>\n <tbody>\n",
294
+ ]
295
+ parts.extend(_row(h, train_cols, test_cols) for h in history)
296
+ parts.append(" </tbody>\n </table>\n </div>\n</body>\n</html>\n")
297
+ return "".join(parts)