[NOTICKET] test: planner eval harness (rule-compliance, --selfcheck/--rescore)
Browse filesAdd 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 +54 -0
- eval/planner/__init__.py +0 -0
- eval/planner/catalog_fixture.py +96 -0
- eval/planner/planner_dataset.json +234 -0
- eval/planner/run_eval.py +391 -0
|
@@ -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.
|
|
File without changes
|
|
@@ -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))
|
|
@@ -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 |
+
}
|
|
@@ -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())
|