sofhiaazzhr Claude Opus 4.8 commited on
Commit
4292522
·
1 Parent(s): fc5200c

[NOTICKET] test: planner eval harness (rule-compliance, --selfcheck/--rescore)

Browse files

Add eval/planner/ — a regression net for the live planner, grounded in the
PA Data Dummy catalog. Scores each golden question by RULE-COMPLIANCE
assertions on the emitted plan/IR (count uses count(); entity ranking uses
group_by + agg + order + limit; fuzzy model filter never enumerates from
samples) instead of exact-IR match, since many IRs are valid. Includes
carried_over regression cases + counter-examples (raw-row listing,
exact-match filters) so a planner.md / examples.py change can be gated
before deploy.

Assertions are grouping-aware — they honor the analyze_aggregate tool
(group_by/aggregations args, mean==avg), not only IR group_by — and resolve
analyze_aggregate aliases back to columns via the retrieve_data select.

Runner modes: --selfcheck (validate the scorer offline against synthetic
good/buggy plans, no LLM) and --rescore RESULTS.json (re-score a saved run's
persisted facts after editing assertions, no LLM). Baseline on the current
planner: 27/27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

eval/planner/README.md ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Planner eval (E-planner)
2
+
3
+ Scores the **live planner** (`PlannerService.plan`) on golden questions against a
4
+ fixture catalog grounded in `PA Data Dummy.xlsx`. Purpose: a **regression net**
5
+ for changes to `planner.md` / `examples.py` — before/after any prompt tweak, run
6
+ this and confirm the target cases improve while `carried_over` cases stay green.
7
+
8
+ ## Why rule-compliance scoring (not exact-match)
9
+
10
+ A question has **many valid IRs**, so we don't compare IRs verbatim. Each case
11
+ pins only the **properties that matter** (`expect` assertions): does a count
12
+ question use a `count` aggregate? does entity ranking `group_by` the entity? does
13
+ a fuzzy model filter use `like` instead of an enumerated `in`? See `_expect_keys`
14
+ in `planner_dataset.json` for the full assertion vocabulary.
15
+
16
+ ## Run
17
+
18
+ ```bash
19
+ uv run python -m eval.planner.run_eval # full run (needs Azure creds)
20
+ uv run python -m eval.planner.run_eval --limit 6 # smoke test
21
+ uv run python -m eval.planner.run_eval --selfcheck # test the scorer, no LLM
22
+ ```
23
+
24
+ Each run writes `results/planner_result_<timestamp>.json` (never overwritten).
25
+ `id` is stable per case, so runs diff case-by-case over time.
26
+
27
+ ## What's covered
28
+
29
+ | category | targets |
30
+ |---|---|
31
+ | `count` | scalar count → `count` aggregate (shipped fix) |
32
+ | `ranking` | top/bottom-N entities → `group_by` + `avg` + `order_by` + `limit` (**Bug 1**) |
33
+ | `fuzzy_filter` | partial model ref → `like`, never enumerate from samples (**Bug 2**) |
34
+ | `aggregate`, `descriptive`, `correlation`, `trend`, `merge` | believed-correct baselines |
35
+ | `counter_raw_rows` | "show N records" must stay raw rows (guards Bug 1 fix from over-aggregating) |
36
+ | `counter_exact_filter` | exact filters stay exact (guards Bug 2 fix from over-`like`ing) |
37
+ | `infeasible` | measures absent from the catalog → `infeasible_reason` |
38
+
39
+ `carried_over: true` = behavior believed correct today (regression guard);
40
+ `false` = the known bugs. **Expected baseline (before the planner fixes):** the
41
+ `ranking` and `fuzzy_filter` (777) cases FAIL, everything else green — that gap is
42
+ exactly what the planner fixes should close, without turning any `carried_over`
43
+ case red.
44
+
45
+ ## Files
46
+
47
+ - `planner_dataset.json` — cases (question + `expect` assertions)
48
+ - `catalog_fixture.py` — the `PA Data Dummy` catalog the planner plans against
49
+ - `run_eval.py` — runner + deterministic scorer (`--selfcheck`)
50
+
51
+ > Date columns are typed `date` in the fixture (the *post-fix* catalog). The live
52
+ > system currently mis-types Excel date serials as `int` — an **ingest** bug, not
53
+ > a planner one — so the fixture types them correctly to keep this eval about
54
+ > planner logic.
eval/planner/__init__.py ADDED
File without changes
eval/planner/catalog_fixture.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fixture catalog for the planner eval — grounded in Sofhia's real test file
2
+ `PA Data Dummy.xlsx` (mining equipment physical-availability data, 9729 daily
3
+ rows, single site, April 2026).
4
+
5
+ Column ids are readable (`c_<snake_name>`) on purpose: the planner only echoes
6
+ whatever ids the summary gives it, and readable ids make the result files easy
7
+ to diff. `name_to_id()` maps a column NAME back to its id so the assertions in
8
+ `run_eval.py` can be written against names.
9
+
10
+ NOTE: date columns are typed `date` here — i.e. the *post-fix* catalog. The live
11
+ system currently mis-types Excel date serials as `int` (a Go-ingest bug, see the
12
+ date-handling note), which is an INGEST issue, not a planner one. Typing them
13
+ correctly here keeps this eval about planner logic, not the ingest bug.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from datetime import UTC, datetime
19
+ from typing import Any
20
+
21
+ from src.catalog.models import Catalog, Column, ColumnStats, DataType, Source, Table
22
+
23
+ SOURCE_ID = "src_pa"
24
+ TABLE_ID = "t_pa"
25
+ ROW_COUNT = 9729
26
+
27
+ # (name, data_type, sample_values, top_values)
28
+ _COLUMNS: list[tuple[str, DataType, list[Any] | None, list[Any] | None]] = [
29
+ ("KeyId", "int", [895272, 895245, 895285], None),
30
+ ("Month_ID", "int", [202604], [202604]),
31
+ ("Site_ID", "int", [2009], [2009]),
32
+ ("From_Date", "date", ["2026-04-06", "2026-04-25"], None),
33
+ ("To_Date", "date", ["2026-04-06", "2026-04-25"], None),
34
+ ("Time_Description", "string", ["Daily"], ["Daily"]),
35
+ # High-cardinality but NOT unique — a valid ranking/group dimension.
36
+ ("Model_Unit", "string", ["777E", "777D", "777", "785D", "789C", "HD785-7", "PC2000-8", "EX3600-6"], None),
37
+ # Per-unit identifier (high cardinality). Ranking BY unit must still group by it.
38
+ ("Equipment_Number", "string", ["HDCT77457", "HDCT77455", "EXHC36007"], None),
39
+ ("Equipment_Group_ID", "string", ["Main Hauler", "Main Loader"], ["Main Hauler", "Main Loader"]),
40
+ ("Unit_Status", "string", ["INPR"], ["INPR"]),
41
+ ("Total_Breakdown_Schedule_Hour", "decimal", [19.416, 0.0], None),
42
+ ("Total_Breakdown_Unschedule_Hour", "decimal", [0.0, 3.728], None),
43
+ ("Total_Adj_Breakdown_Schedule_Hour", "decimal", [19.416, 0.0], None),
44
+ ("Total_Adj_Breakdown_Unschedule_Hour", "decimal", [0.0, 2.982], None),
45
+ ("Total_MTC_Hour", "decimal", [19.4164, 0.0], None),
46
+ ("Total_Down_Hour", "decimal", [19.4164, 0.0], None),
47
+ ("Total_Frequency_Breakdown_Schedule", "int", [1, 0], None),
48
+ ("Total_Frequency_Breakdown_Unschedule", "int", [0, 3], None),
49
+ ("Total_Frequency_Maintenance", "int", [1, 3], None),
50
+ ("Total_Frequency_Tire", "int", [0], None),
51
+ ("Total_Frequency_Down", "int", [1, 3], None),
52
+ ("Total_Hours", "int", [24], [24]),
53
+ ("Total_INPR_Hour", "int", [24, 0], None),
54
+ ("Total_Record_HM_Hour", "decimal", [0.0], None),
55
+ ("Total_HM_Mtc_Down_Hour", "int", [0, 20], None),
56
+ ("Plan_PA_Percent", "decimal", [100.0, 91.46], None),
57
+ ("PA_Percent", "decimal", [19.0984, 100.0, 84.4676], None),
58
+ ("MTBS", "decimal", [0.0, 5.4667], None),
59
+ ("MTTR", "decimal", [19.4164, 1.2426, 0.0], None),
60
+ ("SM_Percent", "decimal", [100.0, 0.0], None),
61
+ ("Unschedule_SM_Percent", "decimal", [0.0, 100.0], None),
62
+ ("IsDeleted", "int", [0], [0]),
63
+ ("Section", "string", ["OB HAULER", "OB LOADER"], ["OB HAULER", "OB LOADER"]),
64
+ ("Week_ID", "int", [202617], None),
65
+ ("Plan_PA_Percent_2", "decimal", [88.0, 91.0], None),
66
+ ("Updated_Date", "datetime", ["2026-04-25T00:45:17"], None),
67
+ ]
68
+
69
+
70
+ def name_to_id() -> dict[str, str]:
71
+ return {name: f"c_{name.lower()}" for name, *_ in _COLUMNS}
72
+
73
+
74
+ def build_pa_catalog() -> Catalog:
75
+ columns = [
76
+ Column(
77
+ column_id=f"c_{name.lower()}",
78
+ name=name,
79
+ data_type=dtype,
80
+ nullable=False,
81
+ pii_flag=False,
82
+ sample_values=samples,
83
+ stats=ColumnStats(distinct_count=len(top) if top else None, top_values=top),
84
+ )
85
+ for name, dtype, samples, top in _COLUMNS
86
+ ]
87
+ table = Table(table_id=TABLE_ID, name="PA Data Dummy", row_count=ROW_COUNT, columns=columns, foreign_keys=[])
88
+ source = Source(
89
+ source_id=SOURCE_ID,
90
+ source_type="tabular",
91
+ name="PA Data Dummy.xlsx",
92
+ location_ref="object_storage://eval/pa",
93
+ updated_at=datetime.now(UTC),
94
+ tables=[table],
95
+ )
96
+ return Catalog(user_id="eval-user", sources=[source], generated_at=datetime.now(UTC))
eval/planner/planner_dataset.json ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_about": "Golden planner dataset (E-planner eval — runs against the LIVE planner LLM, PlannerService.plan). Each case is a question + `expect` = a set of IR/plan ASSERTIONS (rule-compliance scoring, NOT exact IR match — many IRs are valid, we only pin the properties that matter). Grounded in `PA Data Dummy.xlsx` via eval/planner/catalog_fixture.py. `id` is a stable per-case handle so timestamped runs (results/planner_result_<ts>.json) diff case-by-case. `carried_over`=true marks behavior believed CORRECT today (regression guard); the rest target the 3 known planner bugs. Balanced ID/EN because users code-switch.",
3
+ "_expect_keys": {
4
+ "has_tool": "some task tool_call uses this tool name",
5
+ "no_tool": "no task uses this tool",
6
+ "select_agg": "some retrieve_data IR select has an agg with this fn (count/sum/avg/min/max/count_distinct)",
7
+ "group_by": "true = some IR has a non-empty group_by; false = NO IR has group_by (raw-row guard)",
8
+ "group_by_col": "some IR group_by contains this column NAME",
9
+ "filter_op": "some IR filter uses this op",
10
+ "no_filter_op": "NO IR filter uses this op",
11
+ "has_filter": "true = at least one IR has a filter",
12
+ "order_dir": "some IR order_by uses this dir (asc/desc)",
13
+ "limit": "some IR has exactly this limit",
14
+ "infeasible": "true = plan has no tasks / an infeasible_reason (measure not in catalog)"
15
+ },
16
+ "cases": [
17
+ {
18
+ "id": "count_zero_pa",
19
+ "category": "count",
20
+ "lang": "en",
21
+ "question": "how many records have PA_Percent = 0?",
22
+ "expect": {"select_agg": "count", "has_filter": true, "group_by": false},
23
+ "carried_over": true
24
+ },
25
+ {
26
+ "id": "count_mttr_gt20_id",
27
+ "category": "count",
28
+ "lang": "id",
29
+ "question": "berapa banyak record dengan MTTR di atas 20?",
30
+ "expect": {"select_agg": "count", "has_filter": true},
31
+ "carried_over": true
32
+ },
33
+ {
34
+ "id": "count_section_hauler",
35
+ "category": "count",
36
+ "lang": "en",
37
+ "question": "how many rows are in section OB HAULER?",
38
+ "expect": {"select_agg": "count", "has_filter": true},
39
+ "carried_over": true
40
+ },
41
+ {
42
+ "id": "rank_units_worst_pa_id",
43
+ "category": "ranking",
44
+ "lang": "id",
45
+ "question": "5 unit dengan PA terburuk?",
46
+ "expect": {"group_by": true, "group_by_col": "Equipment_Number", "select_agg": "avg", "order_dir": "asc", "limit": 5},
47
+ "carried_over": false
48
+ },
49
+ {
50
+ "id": "rank_models_top_mttr_id",
51
+ "category": "ranking",
52
+ "lang": "id",
53
+ "question": "top 3 model dengan MTTR tertinggi?",
54
+ "expect": {"group_by": true, "group_by_col": "Model_Unit", "select_agg": "avg", "order_dir": "desc", "limit": 3},
55
+ "carried_over": true
56
+ },
57
+ {
58
+ "id": "rank_sections_lowest_pa_en",
59
+ "category": "ranking",
60
+ "lang": "en",
61
+ "question": "which section has the lowest average PA?",
62
+ "expect": {"group_by": true, "group_by_col": "Section", "select_agg": "avg"},
63
+ "carried_over": true
64
+ },
65
+ {
66
+ "id": "rank_units_most_breakdown_id",
67
+ "category": "ranking",
68
+ "lang": "id",
69
+ "question": "unit mana yang paling sering breakdown?",
70
+ "expect": {"group_by": true, "group_by_col": "Equipment_Number", "order_dir": "desc"},
71
+ "carried_over": false
72
+ },
73
+ {
74
+ "id": "rank_units_worst_pa_en",
75
+ "category": "ranking",
76
+ "lang": "en",
77
+ "question": "list the 10 worst units by availability",
78
+ "expect": {"group_by": true, "group_by_col": "Equipment_Number", "select_agg": "avg", "order_dir": "asc", "limit": 10},
79
+ "carried_over": false
80
+ },
81
+ {
82
+ "id": "fuzzy_model_777_id",
83
+ "category": "fuzzy_filter",
84
+ "lang": "id",
85
+ "question": "berapa banyak model 777?",
86
+ "expect": {"select_agg": "count", "no_filter_op": "in"},
87
+ "carried_over": false
88
+ },
89
+ {
90
+ "id": "fuzzy_model_hd785_id",
91
+ "category": "fuzzy_filter",
92
+ "lang": "id",
93
+ "question": "berapa banyak unit HD785?",
94
+ "expect": {"select_agg": "count", "no_filter_op": "in"},
95
+ "carried_over": true
96
+ },
97
+ {
98
+ "id": "fuzzy_model_ex_en",
99
+ "category": "fuzzy_filter",
100
+ "lang": "en",
101
+ "question": "how many EX excavator units are there?",
102
+ "expect": {"no_filter_op": "in"},
103
+ "carried_over": false
104
+ },
105
+ {
106
+ "id": "agg_pa_per_section_id",
107
+ "category": "aggregate",
108
+ "lang": "id",
109
+ "question": "berapa rata-rata PA per section?",
110
+ "expect": {"group_by": true, "group_by_col": "Section", "select_agg": "avg"},
111
+ "carried_over": true
112
+ },
113
+ {
114
+ "id": "agg_mttr_per_model_en",
115
+ "category": "aggregate",
116
+ "lang": "en",
117
+ "question": "what is the average MTTR per model unit?",
118
+ "expect": {"group_by": true, "group_by_col": "Model_Unit", "select_agg": "avg"},
119
+ "carried_over": true
120
+ },
121
+ {
122
+ "id": "agg_downhour_per_group_id",
123
+ "category": "aggregate",
124
+ "lang": "id",
125
+ "question": "total down hour per equipment group?",
126
+ "expect": {"group_by": true, "group_by_col": "Equipment_Group_ID", "select_agg": "sum"},
127
+ "carried_over": true
128
+ },
129
+ {
130
+ "id": "desc_mttr_stats_id",
131
+ "category": "descriptive",
132
+ "lang": "id",
133
+ "question": "berikan ringkasan statistik MTTR",
134
+ "expect": {"has_tool": "analyze_descriptive"},
135
+ "carried_over": true
136
+ },
137
+ {
138
+ "id": "desc_pa_stats_en",
139
+ "category": "descriptive",
140
+ "lang": "en",
141
+ "question": "give me the summary statistics for PA_Percent",
142
+ "expect": {"has_tool": "analyze_descriptive"},
143
+ "carried_over": true
144
+ },
145
+ {
146
+ "id": "corr_mttr_pa_id",
147
+ "category": "correlation",
148
+ "lang": "id",
149
+ "question": "apakah ada korelasi antara MTTR dan PA?",
150
+ "expect": {"has_tool": "analyze_correlation"},
151
+ "carried_over": true
152
+ },
153
+ {
154
+ "id": "corr_freq_pa_en",
155
+ "category": "correlation",
156
+ "lang": "en",
157
+ "question": "is breakdown frequency correlated with availability?",
158
+ "expect": {"has_tool": "analyze_correlation"},
159
+ "carried_over": true
160
+ },
161
+ {
162
+ "id": "trend_pa_daily_id",
163
+ "category": "trend",
164
+ "lang": "id",
165
+ "question": "bagaimana trend PA harian?",
166
+ "expect": {"has_tool": "analyze_trend"},
167
+ "carried_over": true
168
+ },
169
+ {
170
+ "id": "trend_downhour_en",
171
+ "category": "trend",
172
+ "lang": "en",
173
+ "question": "show the trend of total down hours over time",
174
+ "expect": {"has_tool": "analyze_trend"},
175
+ "carried_over": true
176
+ },
177
+ {
178
+ "id": "merge_worst_pa_and_mttr_id",
179
+ "category": "merge",
180
+ "lang": "id",
181
+ "question": "model mana yang PA-nya paling buruk sekaligus MTTR-nya paling tinggi?",
182
+ "expect": {"group_by": true, "group_by_col": "Model_Unit"},
183
+ "carried_over": true
184
+ },
185
+ {
186
+ "id": "raw_rows_low_pa_id",
187
+ "category": "counter_raw_rows",
188
+ "lang": "id",
189
+ "question": "tampilkan 10 record dengan PA di bawah 50",
190
+ "expect": {"group_by": false, "has_filter": true, "limit": 10},
191
+ "carried_over": true
192
+ },
193
+ {
194
+ "id": "raw_rows_head_en",
195
+ "category": "counter_raw_rows",
196
+ "lang": "en",
197
+ "question": "show me the first 5 rows of the data",
198
+ "expect": {"group_by": false},
199
+ "carried_over": true
200
+ },
201
+ {
202
+ "id": "exact_model_777d_id",
203
+ "category": "counter_exact_filter",
204
+ "lang": "id",
205
+ "question": "berapa banyak record untuk model 777D?",
206
+ "expect": {"select_agg": "count", "has_filter": true},
207
+ "carried_over": true
208
+ },
209
+ {
210
+ "id": "exact_section_loader_en",
211
+ "category": "counter_exact_filter",
212
+ "lang": "en",
213
+ "question": "how many records are in the OB LOADER section?",
214
+ "expect": {"select_agg": "count", "has_filter": true},
215
+ "carried_over": true
216
+ },
217
+ {
218
+ "id": "infeasible_churn_id",
219
+ "category": "infeasible",
220
+ "lang": "id",
221
+ "question": "berapa churn rate pelanggan?",
222
+ "expect": {"infeasible": true},
223
+ "carried_over": true
224
+ },
225
+ {
226
+ "id": "infeasible_profit_en",
227
+ "category": "infeasible",
228
+ "lang": "en",
229
+ "question": "what is the monthly profit margin?",
230
+ "expect": {"infeasible": true},
231
+ "carried_over": true
232
+ }
233
+ ]
234
+ }
eval/planner/run_eval.py ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Planner eval runner (E-planner).
2
+
3
+ Feeds each golden case in `planner_dataset.json` to the LIVE planner
4
+ (`PlannerService.plan`) against the `PA Data Dummy` fixture catalog, then scores
5
+ each case by RULE-COMPLIANCE assertions on the emitted plan/IR (not exact IR
6
+ match — many IRs are valid; we only pin the properties that matter). Records
7
+ latency + token usage, prints a per-case + aggregate summary, and writes a
8
+ timestamped JSON report under `results/` (never overwritten — diff runs over
9
+ time).
10
+
11
+ Run before any deploy that touches planner.md or examples.py:
12
+
13
+ uv run python -m eval.planner.run_eval
14
+ uv run python -m eval.planner.run_eval --limit 6 # quick smoke test
15
+
16
+ Needs Azure OpenAI creds in the env (same as the live planner). The scoring
17
+ layer is deterministic and unit-tested via `--selfcheck` (no LLM call).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import asyncio
24
+ import json
25
+ import statistics
26
+ import time
27
+ from dataclasses import asdict, dataclass, field
28
+ from datetime import datetime
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from langchain_core.callbacks import BaseCallbackHandler
33
+ from langchain_core.outputs import LLMResult
34
+
35
+ from src.agents.planner.contracts import BusinessContext
36
+ from src.agents.planner.inputs import Constraints
37
+ from src.agents.planner.registry import default_registry
38
+ from src.agents.planner.service import PlannerService
39
+
40
+ from .catalog_fixture import build_pa_catalog, name_to_id
41
+
42
+ _HERE = Path(__file__).resolve().parent
43
+ DATASET = _HERE / "planner_dataset.json"
44
+ RESULTS_DIR = _HERE / "results"
45
+
46
+ _CONTEXT = BusinessContext(
47
+ project_id="eval-planner",
48
+ industry="mining",
49
+ completeness="partial",
50
+ business_description=(
51
+ "Physical Availability (PA) analysis of heavy mining equipment — haulers "
52
+ "and loaders — from daily operational records."
53
+ ),
54
+ scale_and_scope="9,729 daily equipment records, single site, April 2026.",
55
+ )
56
+
57
+
58
+ # --------------------------------------------------------------------------- #
59
+ # Plan introspection + assertion scoring (deterministic — no LLM)
60
+ # --------------------------------------------------------------------------- #
61
+
62
+ # Grouping/agg can be expressed EITHER in the retrieve_data IR (group_by + agg
63
+ # select) OR via the analyze_aggregate tool (group_by + aggregations args) — both
64
+ # are valid (planner.md R2). Checks below look at both. IR uses fn "avg"; the
65
+ # aggregate tool uses "mean" — treat as synonyms.
66
+ _AGG_SYN = {
67
+ "avg": {"avg", "mean"}, "mean": {"avg", "mean"}, "sum": {"sum"},
68
+ "count": {"count"}, "min": {"min"}, "max": {"max"},
69
+ "count_distinct": {"count_distinct", "nunique"},
70
+ }
71
+
72
+
73
+ def extract_facts(task_list: Any) -> dict:
74
+ """Flatten a TaskList into the plain facts the scorer needs (JSON-safe, so a
75
+ run can be re-scored offline via --rescore without re-calling the LLM)."""
76
+ irs: list[dict] = []
77
+ agg_args: list[dict] = []
78
+ tools: set[str] = set()
79
+ for t in task_list.tasks:
80
+ for c in t.tool_calls:
81
+ tools.add(c.tool)
82
+ if c.tool == "retrieve_data" and isinstance(c.args.get("ir"), dict):
83
+ irs.append(c.args["ir"])
84
+ elif c.tool == "analyze_aggregate":
85
+ agg_args.append(c.args)
86
+ return {
87
+ "tools": sorted(tools),
88
+ "irs": irs,
89
+ "agg_args": agg_args,
90
+ "infeasible": (not task_list.tasks) or bool(getattr(task_list, "infeasible_reason", None)),
91
+ }
92
+
93
+
94
+ def _ir_agg_fns(ir: dict) -> list[str]:
95
+ return [s.get("fn") for s in ir.get("select", []) if isinstance(s, dict) and s.get("kind") == "agg"]
96
+
97
+
98
+ def _all_agg_fns(f: dict) -> list[str]:
99
+ fns = [fn for ir in f["irs"] for fn in _ir_agg_fns(ir)]
100
+ for a in f["agg_args"]:
101
+ for lst in (a.get("aggregations") or {}).values():
102
+ fns += lst if isinstance(lst, list) else [lst]
103
+ return fns
104
+
105
+
106
+ def _group_by_ids(f: dict) -> list[str]:
107
+ return [g for ir in f["irs"] for g in (ir.get("group_by") or [])]
108
+
109
+
110
+ def _group_by_aliases(f: dict) -> list[str]:
111
+ return [str(g) for a in f["agg_args"] for g in (a.get("group_by") or [])]
112
+
113
+
114
+ def _alias_to_id(f: dict) -> dict[str, str]:
115
+ """Map each retrieve_data SELECT alias -> its column_id, so an
116
+ analyze_aggregate group_by (which references aliases) resolves back to a
117
+ real column even when the planner aliases it differently from the name."""
118
+ m: dict[str, str] = {}
119
+ for ir in f["irs"]:
120
+ for s in ir.get("select", []):
121
+ if isinstance(s, dict) and s.get("alias") and s.get("column_id"):
122
+ m[s["alias"]] = s["column_id"]
123
+ return m
124
+
125
+
126
+ def _filter_ops(f: dict) -> list[str]:
127
+ return [flt.get("op") for ir in f["irs"] for flt in ir.get("filters", []) if isinstance(flt, dict)]
128
+
129
+
130
+ def evaluate_facts(f: dict, expect: dict, n2id: dict[str, str]) -> list[tuple[str, bool, str]]:
131
+ """Return [(check, passed, detail)] for every assertion. Grouping/agg checks
132
+ honor BOTH the IR and the analyze_aggregate tool."""
133
+ irs, tools = f["irs"], set(f["tools"])
134
+ grouped = bool(_group_by_ids(f) or _group_by_aliases(f))
135
+ res: list[tuple[str, bool, str]] = []
136
+
137
+ for key, want in expect.items():
138
+ if key == "has_tool":
139
+ res.append((f"has_tool={want}", want in tools, f"tools={sorted(tools)}"))
140
+ elif key == "no_tool":
141
+ res.append((f"no_tool={want}", want not in tools, f"tools={sorted(tools)}"))
142
+ elif key == "select_agg":
143
+ syn = _AGG_SYN.get(want, {want})
144
+ got = _all_agg_fns(f)
145
+ res.append((f"select_agg={want}", any(g in syn for g in got), f"aggs={got}"))
146
+ elif key == "group_by":
147
+ res.append(("group_by" if want else "no_group_by", grouped == want, f"grouped={grouped}"))
148
+ elif key == "group_by_col":
149
+ col_id = n2id.get(want, want)
150
+ a2id = _alias_to_id(f)
151
+ resolved = _group_by_ids(f) + [a2id.get(a) for a in _group_by_aliases(f)]
152
+ hit = col_id in resolved or want.lower() in [a.lower() for a in _group_by_aliases(f)]
153
+ res.append((f"group_by_col={want}", hit, f"ids={_group_by_ids(f)} aliases={_group_by_aliases(f)} resolved={[r for r in resolved if r]}"))
154
+ elif key == "filter_op":
155
+ got = _filter_ops(f)
156
+ res.append((f"filter_op={want}", want in got, f"ops={got}"))
157
+ elif key == "no_filter_op":
158
+ got = _filter_ops(f)
159
+ res.append((f"no_filter_op={want}", want not in got, f"ops={got}"))
160
+ elif key == "has_filter":
161
+ has = any(ir.get("filters") for ir in irs)
162
+ res.append(("has_filter", has == want, f"filter_present={has}"))
163
+ elif key == "order_dir":
164
+ got = [o.get("dir", "asc") for ir in irs for o in ir.get("order_by", []) if isinstance(o, dict)]
165
+ res.append((f"order_dir={want}", want in got, f"dirs={got}"))
166
+ elif key == "limit":
167
+ got = [ir.get("limit") for ir in irs]
168
+ res.append((f"limit={want}", want in got, f"limits={got}"))
169
+ elif key == "infeasible":
170
+ res.append(("infeasible" if want else "feasible", f["infeasible"] == want, f"infeasible={f['infeasible']}"))
171
+ else:
172
+ res.append((f"UNKNOWN:{key}", False, "unknown expect key"))
173
+ return res
174
+
175
+
176
+ # --------------------------------------------------------------------------- #
177
+ # Token callback (parity with intent eval)
178
+ # --------------------------------------------------------------------------- #
179
+
180
+ class _TokenCounter(BaseCallbackHandler):
181
+ def __init__(self) -> None:
182
+ self.total = 0
183
+
184
+ def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None:
185
+ for gen_list in response.generations:
186
+ for gen in gen_list:
187
+ msg = getattr(gen, "message", None)
188
+ usage = getattr(msg, "usage_metadata", None) if msg else None
189
+ if usage:
190
+ self.total += usage.get("total_tokens", 0)
191
+
192
+
193
+ # --------------------------------------------------------------------------- #
194
+ # Runner
195
+ # --------------------------------------------------------------------------- #
196
+
197
+ @dataclass
198
+ class CaseResult:
199
+ id: str
200
+ category: str
201
+ lang: str
202
+ carried_over: bool
203
+ question: str
204
+ passed: bool
205
+ checks: list[dict] = field(default_factory=list)
206
+ facts: dict = field(default_factory=dict) # extracted plan (for offline --rescore)
207
+ error: str | None = None
208
+ latency_ms: int = 0
209
+ tokens: int = 0
210
+
211
+
212
+ async def _run_case(planner: PlannerService, catalog: Any, tools: Any, n2id: dict, case: dict) -> CaseResult:
213
+ tok = _TokenCounter()
214
+ started = time.perf_counter()
215
+ facts: dict = {}
216
+ try:
217
+ task_list = await planner.plan(
218
+ _CONTEXT, catalog, tools, case["question"], Constraints(), callbacks=[tok]
219
+ )
220
+ facts = extract_facts(task_list)
221
+ checks = evaluate_facts(facts, case["expect"], n2id)
222
+ passed = all(ok for _, ok, _ in checks)
223
+ err = None
224
+ except Exception as e: # planner failure = case fails (record why)
225
+ checks, passed, err = [], False, f"{type(e).__name__}: {e}"
226
+ latency = int((time.perf_counter() - started) * 1000)
227
+ return CaseResult(
228
+ id=case["id"], category=case["category"], lang=case["lang"],
229
+ carried_over=case.get("carried_over", False), question=case["question"],
230
+ passed=passed, error=err, latency_ms=latency, tokens=tok.total, facts=facts,
231
+ checks=[{"check": c, "ok": ok, "detail": d} for c, ok, d in checks],
232
+ )
233
+
234
+
235
+ async def main() -> None:
236
+ ap = argparse.ArgumentParser()
237
+ ap.add_argument("--limit", type=int, default=None, help="run only the first N cases")
238
+ ap.add_argument("--selfcheck", action="store_true", help="test the scorer on a synthetic plan (no LLM)")
239
+ ap.add_argument("--rescore", metavar="RESULTS.json", help="re-score a saved run's facts with the current assertions (no LLM)")
240
+ args = ap.parse_args()
241
+
242
+ if args.selfcheck:
243
+ _selfcheck()
244
+ return
245
+ if args.rescore:
246
+ _rescore(Path(args.rescore))
247
+ return
248
+
249
+ data = json.loads(DATASET.read_text(encoding="utf-8"))
250
+ cases = data["cases"][: args.limit] if args.limit else data["cases"]
251
+ catalog, tools, n2id = build_pa_catalog(), default_registry(), name_to_id()
252
+ planner = PlannerService()
253
+
254
+ results: list[CaseResult] = []
255
+ for case in cases:
256
+ r = await _run_case(planner, catalog, tools, n2id, case)
257
+ mark = "PASS" if r.passed else ("ERR " if r.error else "FAIL")
258
+ print(f"[{mark}] {r.id:<28} {r.lang} {r.latency_ms:>5}ms {r.tokens:>5}tok")
259
+ if not r.passed:
260
+ if r.error:
261
+ print(f" error: {r.error}")
262
+ for c in r.checks:
263
+ if not c["ok"]:
264
+ print(f" ✗ {c['check']} ({c['detail']})")
265
+ results.append(r)
266
+
267
+ _summarize(results)
268
+ _write(results, data)
269
+
270
+
271
+ def _summarize(results: list[CaseResult]) -> None:
272
+ total = len(results)
273
+ passed = sum(r.passed for r in results)
274
+ print("\n" + "=" * 60)
275
+ print(f"OVERALL: {passed}/{total} passed ({passed / total:.0%})" if total else "no cases")
276
+
277
+ def rate(subset: list[CaseResult]) -> str:
278
+ return f"{sum(r.passed for r in subset)}/{len(subset)}" if subset else "0/0"
279
+
280
+ cats = sorted({r.category for r in results})
281
+ print("\nby category:")
282
+ for c in cats:
283
+ print(f" {c:<22} {rate([r for r in results if r.category == c])}")
284
+ print("\nregression guard:")
285
+ print(f" carried_over (must stay green) {rate([r for r in results if r.carried_over])}")
286
+ print(f" new (target bugs) {rate([r for r in results if not r.carried_over])}")
287
+ lat = [r.latency_ms for r in results if r.latency_ms]
288
+ if lat:
289
+ print(f"\nlatency ms: median={statistics.median(lat):.0f} max={max(lat)}")
290
+ print(f"tokens total: {sum(r.tokens for r in results)}")
291
+
292
+
293
+ def _write(results: list[CaseResult], dataset: dict) -> None:
294
+ RESULTS_DIR.mkdir(exist_ok=True)
295
+ ts = datetime.now().strftime("%Y-%m-%d_%H%M%S")
296
+ out = RESULTS_DIR / f"planner_result_{ts}.json"
297
+ payload = {
298
+ "timestamp": ts,
299
+ "total": len(results),
300
+ "passed": sum(r.passed for r in results),
301
+ "cases": [asdict(r) for r in results],
302
+ }
303
+ out.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
304
+ print(f"\nwrote {out}")
305
+
306
+
307
+ # --------------------------------------------------------------------------- #
308
+ # Selfcheck — verifies the scorer without an LLM call
309
+ # --------------------------------------------------------------------------- #
310
+
311
+ def _rescore(path: Path) -> None:
312
+ """Re-score a saved run's persisted `facts` with the CURRENT assertions —
313
+ iterate on assertions/dataset without spending another LLM run."""
314
+ saved = json.loads(path.read_text(encoding="utf-8"))
315
+ expect_by_id = {c["id"]: c["expect"] for c in json.loads(DATASET.read_text(encoding="utf-8"))["cases"]}
316
+ n2id = name_to_id()
317
+ results: list[CaseResult] = []
318
+ for c in saved["cases"]:
319
+ facts, exp = c.get("facts") or {}, expect_by_id.get(c["id"], {})
320
+ if c.get("error") or not facts:
321
+ checks, passed = [], False
322
+ else:
323
+ t = evaluate_facts(facts, exp, n2id)
324
+ checks = [{"check": ck, "ok": ok, "detail": d} for ck, ok, d in t]
325
+ passed = all(x["ok"] for x in checks)
326
+ r = CaseResult(
327
+ id=c["id"], category=c["category"], lang=c["lang"], carried_over=c["carried_over"],
328
+ question=c["question"], passed=passed, checks=checks, facts=facts, error=c.get("error"),
329
+ )
330
+ mark = "PASS" if r.passed else ("ERR " if r.error else "FAIL")
331
+ print(f"[{mark}] {r.id:<28} {r.lang}")
332
+ if not r.passed:
333
+ if r.error:
334
+ print(f" error: {r.error}")
335
+ for x in r.checks:
336
+ if not x["ok"]:
337
+ print(f" ✗ {x['check']} ({x['detail']})")
338
+ results.append(r)
339
+ _summarize(results)
340
+ print(f"\n(re-scored {path.name} — no LLM calls)")
341
+
342
+
343
+ def _selfcheck() -> None:
344
+ from types import SimpleNamespace as NS
345
+ n2id = name_to_id()
346
+
347
+ def plan(tasks_tcs: list[list[tuple[str, dict]]], infeasible: str | None = None):
348
+ tasks = [NS(tool_calls=[NS(tool=t, args=a) for t, a in tcs]) for tcs in tasks_tcs]
349
+ return NS(tasks=tasks, infeasible_reason=infeasible)
350
+
351
+ def ev(tl: Any, expect: dict) -> bool:
352
+ return all(ok for _, ok, _ in evaluate_facts(extract_facts(tl), expect, n2id))
353
+
354
+ exp_rank = {"group_by": True, "group_by_col": "Equipment_Number", "select_agg": "avg", "order_dir": "asc", "limit": 5}
355
+
356
+ # ranking via IR group_by — passes
357
+ good_ir = plan([[("retrieve_data", {"ir": {
358
+ "select": [{"kind": "agg", "fn": "avg", "column_id": "c_pa_percent"}],
359
+ "group_by": ["c_equipment_number"],
360
+ "order_by": [{"column_id": "avg_pa", "dir": "asc"}], "limit": 5}})]])
361
+ assert ev(good_ir, exp_rank), "IR-group ranking should pass"
362
+
363
+ # grouping via analyze_aggregate tool (aliases + 'mean') must ALSO count
364
+ agg_tool = plan([
365
+ [("retrieve_data", {"ir": {"select": [
366
+ {"kind": "column", "column_id": "c_section", "alias": "section"},
367
+ {"kind": "column", "column_id": "c_pa_percent", "alias": "pa"}]}})],
368
+ [("analyze_aggregate", {"group_by": ["section"], "aggregations": {"pa": ["mean"]}})],
369
+ ])
370
+ assert ev(agg_tool, {"group_by": True, "group_by_col": "Section", "select_agg": "avg"}), \
371
+ "analyze_aggregate grouping (mean==avg) should pass"
372
+
373
+ # buggy: raw rows, no grouping anywhere — must FAIL
374
+ bad = plan([[("retrieve_data", {"ir": {"select": [{"kind": "column", "column_id": "c_equipment_number"}],
375
+ "order_by": [{"column_id": "c_pa_percent", "dir": "asc"}], "limit": 5}})]])
376
+ assert not ev(bad, exp_rank), "raw-row ranking should fail"
377
+
378
+ # fuzzy: enumerated 'in' fails; non-enumerated (like/=) passes
379
+ in_ir = plan([[("retrieve_data", {"ir": {"select": [{"kind": "agg", "fn": "count"}],
380
+ "filters": [{"column_id": "c_model_unit", "op": "in", "value": ["777E", "777D"]}]}})]])
381
+ assert not ev(in_ir, {"no_filter_op": "in"}), "enumerated 'in' should fail"
382
+ ok_ir = plan([[("retrieve_data", {"ir": {"select": [{"kind": "agg", "fn": "count"}],
383
+ "filters": [{"column_id": "c_model_unit", "op": "like", "value": "777%"}]}})]])
384
+ assert ev(ok_ir, {"no_filter_op": "in"}), "non-enumerated filter should pass"
385
+
386
+ assert ev(NS(tasks=[], infeasible_reason="no churn data"), {"infeasible": True})
387
+ print("selfcheck OK — scorer distinguishes good vs buggy plans (IR + analyze_aggregate paths)")
388
+
389
+
390
+ if __name__ == "__main__":
391
+ asyncio.run(main())