fix/assembler-comparison-order

#28
CONTEXT.md CHANGED
@@ -92,6 +92,8 @@ analysis-scoped catalog
92
  - Tools: [`src/tools/`](src/tools/). `retrieve_data` runs catalog-backed QueryIR; `analyze_*` tools consume table outputs; `render_chart` makes deterministic Plotly JSON.
93
  - Query layer: [`src/query/`](src/query/). Defense stack: catalog/IR validation, compiler whitelist, sqlglot SELECT-only guard, read-only DB session, timeout, row limit.
94
  - Analysis catalog is Go-materialized and read through [`src/catalog/`](src/catalog/); missing analysis scope fails open to user scope.
 
 
95
  - **Conversation scope (2026-10-29)** — [`src/agents/scope/`](src/agents/scope/). Scope = the values of categorical columns an answer covers (a site code, a section, a unit); periods are not scope. **Code owns it; the planner only declares "all X"** (`TaskList.scope_change`) — gate runs showed the planner failing more the more scope it was asked to handle. Code: reads the full value lists of small non-PII string columns once per analysis ([`values.py`](src/agents/scope/values.py), read-only GROUP BYs through the query pipeline, cached; loads alongside the planner call); pins the **focus** from the objective against those lists; matches the user's own words against the focus columns' lists; carries a previous value into a follow-up only when the router's rewrite restates it ("go home" for complete questions — Rifqi 2026-10-29); then, inside the planner's retry loop **before validation** (`PlannerService.plan(prepare=…)`, `SlowPathCoordinator._scope_hook`), fills missing filters, keeps planner filters grounded in the user's words or the rewrite, replaces invented ones, respects a subset-vs-total structure on one table, moves a value to the column whose list holds it, strips typed ids / made-up placeholders on FK id columns, and joins a code column through exactly one FK ([`engine.py`](src/agents/scope/engine.py)). A scope line built from the values that ran is appended under every data answer; the `TurnScope` is persisted on `AnalysisRecord.scope` (`report_inputs`, Python-owned JSONB) and read back next turn (`PostgresReportInputStore.latest_scope`). The report anchors scope on the focus: answers outside it are labelled comparisons, never pooled. Why: on 42 real turns the objective's site default was applied 7/27 times and 4 answers named a scope their query never filtered. "Every X" is read from the user's words (a quantifier before a column word: "semua site", "per section") or a breakdown on the column (also via its FK id), and drops planner filters nobody asked for; a comparison the queries cover only partly ("SDJ dibanding IPR", one side fetched) is widened to every typed value; negations ("selain SDJ"), a named unit (lifts the focus for the turn) and unknown codes ("not found", never swapped) are handled; the line marks partly-applied columns. Gate (2026-10-30): 21/23 + 8/8 live, 0 refusals, identical reruns; planner eval ctx/site subset 7/7. Known limit: the planner can still mis-shape a comparison's analysis step (an A/B step naming a site as a section) — needs a planner example. *2026-11-03 (audit P0, [AUDIT_FIXES_2026-11](docs/plans/AUDIT_FIXES_2026-11.md)):* **the period is now applied by code too** ([`period.py`](src/agents/scope/period.py)) — the user's own clean window, else a relative one resolved against today in WIB ("6 bulan terakhir" = the 6 complete months before this month), else the objective's; released to the planner for period comparisons/YoY, windows across two years, forecasts and wording it cannot parse; the scope line always names the period ("semua periode" when none). The value lists on uploaded files had read only the first 50 rows (pandas ignored GROUP BY without an aggregate; fixed in `query/compiler/pandas.py`), so a focus pinned before that fix may lack the objective's site — test on a new analysis. Only `${tN.col}` value handoffs mark a lookup; typed ids on either end of the FK are stripped.
96
 
97
  ### Report
 
92
  - Tools: [`src/tools/`](src/tools/). `retrieve_data` runs catalog-backed QueryIR; `analyze_*` tools consume table outputs; `render_chart` makes deterministic Plotly JSON.
93
  - Query layer: [`src/query/`](src/query/). Defense stack: catalog/IR validation, compiler whitelist, sqlglot SELECT-only guard, read-only DB session, timeout, row limit.
94
  - Analysis catalog is Go-materialized and read through [`src/catalog/`](src/catalog/); missing analysis scope fails open to user scope.
95
+ - **Measures computed from summed inputs (2026-11-09)** — [`src/knowledge_domain/computed.py`](src/knowledge_domain/computed.py), plan [RATIO_MEASURES_REQUIREMENTS](docs/plans/RATIO_MEASURES_REQUIREMENTS.md). PA over several periods/units = `100 × (Σ total_inpr_hour − Σ breakdown_duration_total) / Σ total_inpr_hour`, not the average of monthly `pa`. **The definition is data:** a `compute` block on the approved PA *formula* knowledge entry (edited review `f_fa843ac940`, scope `pt bukit technology digital`). Each slow-path turn binds it to every catalog table holding all the named columns (`Column.measure_method`, Python-only, never persisted). The planner still writes `avg(pa)`; the plan validator, **last and fail-open**, adds `AggSelect.computed` (IR, compiled to a double-precision ratio of sums over rows where every term is set — SQL and pandas). An `analyze_aggregate` that averages a ratio measure over raw rows is converted in its own slot into one grouped `retrieve_data` with the tool's output names (`pa_mean`…), after a full re-validation of a copy; otherwise the plan runs unchanged (M7′, 2026-11-09, replaced the Check 8o reject that refused a site comparison). The scope line adds "metode: PA dari total jam", or "rata-rata sederhana per periode (PA)" when a measure with a formula has no usable definition or the user asked for a plain average ("rata-rata sederhana", "simple average"… → `Constraints.plain_average`). MTTR/MTBF/MTBS stay averages (EMA's model). No prompt change. The planner eval scores the validated plan; since 2026-11-11 its EMA fixture carries the hour columns and the runner binds PA (A2), so these steps act there too. Monthly-vs-weekly table choice: see *2026-11-11* below. *2026-11-10 ([PLANNER_SHAPE_FIXES_HANDOFF](docs/plans/PLANNER_SHAPE_FIXES_HANDOFF_2026-11.md)):* the planner's catalog line for a ratio measure now opens with a code-made `aggregate with: avg (the query computes it from the summed inputs…)` (`planner/inputs._dictionary_line`), because a description saying "don't average PA" left the planner no shape it could write. Query-shape guards: a bare select pinned by an `=` filter joins `group_by` when the query is already grouped (`IRRepairer`); a bare measure under `group_by` takes its declared aggregate (dictionary `aggregation`, or avg for a ratio measure; fail-open, `planner/validator._wrap_declared_measures`); the bare-select error names every column by name and role; Check 8p rejects a per-period table grouped by a decimal measure with no period column shown. *2026-11-11:* a PA trend fed raw rows is fixed too: an `analyze_trend` (agg mean, `freq` equal to a selected period column's grain) over an ungrouped retrieve read by nothing else gets that retrieve grouped by its period columns with `avg(<value column>)` under the value column's name, inside the same fail-open swap (`planner/validator._convert_ratio_trends`, U5).
96
+ - **One grain per turn: weekly twins hidden (C1, 2026-11-11)** — [`src/catalog/twins.py`](src/catalog/twins.py), called at the top of `SlowPathCoordinator.run`. A table with a `week` column whose monthly twin (same column names, `month` for `week`) is in the same source is left out of the turn's catalog unless the message, the rewrite, the objective or a business question names weeks (`minggu`, `pekan`, `week`, `W12`…). The planner, the scope hook and the validator see one grain; execution reads its own catalog. Generic (column names, never table names), never-throw, nothing deleted. On the demo source it hides 8 of 17 tables for every demo question. The planner eval runner mirrors it. Plus an `IRRepairer` tie-break: two same-named in-play columns that are the two sides of one of the query's joins resolve to the left (preserved) side, so `site_id` copied from another table no longer fails once the scope hook joins `vw_dim_site`. RCA: [PLANNER_SHAPE_FIXES_HANDOFF](docs/plans/PLANNER_SHAPE_FIXES_HANDOFF_2026-11.md) §8.
97
  - **Conversation scope (2026-10-29)** — [`src/agents/scope/`](src/agents/scope/). Scope = the values of categorical columns an answer covers (a site code, a section, a unit); periods are not scope. **Code owns it; the planner only declares "all X"** (`TaskList.scope_change`) — gate runs showed the planner failing more the more scope it was asked to handle. Code: reads the full value lists of small non-PII string columns once per analysis ([`values.py`](src/agents/scope/values.py), read-only GROUP BYs through the query pipeline, cached; loads alongside the planner call); pins the **focus** from the objective against those lists; matches the user's own words against the focus columns' lists; carries a previous value into a follow-up only when the router's rewrite restates it ("go home" for complete questions — Rifqi 2026-10-29); then, inside the planner's retry loop **before validation** (`PlannerService.plan(prepare=…)`, `SlowPathCoordinator._scope_hook`), fills missing filters, keeps planner filters grounded in the user's words or the rewrite, replaces invented ones, respects a subset-vs-total structure on one table, moves a value to the column whose list holds it, strips typed ids / made-up placeholders on FK id columns, and joins a code column through exactly one FK ([`engine.py`](src/agents/scope/engine.py)). A scope line built from the values that ran is appended under every data answer; the `TurnScope` is persisted on `AnalysisRecord.scope` (`report_inputs`, Python-owned JSONB) and read back next turn (`PostgresReportInputStore.latest_scope`). The report anchors scope on the focus: answers outside it are labelled comparisons, never pooled. Why: on 42 real turns the objective's site default was applied 7/27 times and 4 answers named a scope their query never filtered. "Every X" is read from the user's words (a quantifier before a column word: "semua site", "per section") or a breakdown on the column (also via its FK id), and drops planner filters nobody asked for; a comparison the queries cover only partly ("SDJ dibanding IPR", one side fetched) is widened to every typed value; negations ("selain SDJ"), a named unit (lifts the focus for the turn) and unknown codes ("not found", never swapped) are handled; the line marks partly-applied columns. Gate (2026-10-30): 21/23 + 8/8 live, 0 refusals, identical reruns; planner eval ctx/site subset 7/7. Known limit: the planner can still mis-shape a comparison's analysis step (an A/B step naming a site as a section) — needs a planner example. *2026-11-03 (audit P0, [AUDIT_FIXES_2026-11](docs/plans/AUDIT_FIXES_2026-11.md)):* **the period is now applied by code too** ([`period.py`](src/agents/scope/period.py)) — the user's own clean window, else a relative one resolved against today in WIB ("6 bulan terakhir" = the 6 complete months before this month), else the objective's; released to the planner for period comparisons/YoY, windows across two years, forecasts and wording it cannot parse; the scope line always names the period ("semua periode" when none). The value lists on uploaded files had read only the first 50 rows (pandas ignored GROUP BY without an aggregate; fixed in `query/compiler/pandas.py`), so a focus pinned before that fix may lack the objective's site — test on a new analysis. Only `${tN.col}` value handoffs mark a lookup; typed ids on either end of the FK are stripped.
98
 
99
  ### Report
docs/README.md CHANGED
@@ -62,6 +62,8 @@ Roll-up status: DEV_PLAN §0.8 · built state: REPO_STATUS §14 · eval harness:
62
  | [plans/AUDIT_FIXES_2026-11.md](plans/AUDIT_FIXES_2026-11.md) | 🔄 | **2026-11-03.** Prompt & tool-logic audit: P0 fixes (period owned by code, raw-period-row rules, report caps, uploaded-file GROUP BY, …), decisions D1–D7, gates and status |
63
  | [plans/REPORT_AND_PROMPT_PLAN.md](plans/REPORT_AND_PROMPT_PLAN.md) | 🔄 | **2026-10-28.** Report reframe (question-anchored, scope section, appendix ledger; Phase 1 generator-only) + prompt cleanup (one home per fix, P0 token cuts). Open decisions in §6 |
64
  | [plans/UNSTRUCTURED_V2_PLAN.md](plans/UNSTRUCTURED_V2_PLAN.md) | 🔄 | **The demo track (2026-09-23).** Parsing v2 → embeddings, private images via 1h signed URLs, analysis-scoped retrieval, the mixed structured+unstructured path **without a new intent**. Pre-empts the prompt-architecture work |
 
 
65
 
66
  ---
67
 
 
62
  | [plans/AUDIT_FIXES_2026-11.md](plans/AUDIT_FIXES_2026-11.md) | 🔄 | **2026-11-03.** Prompt & tool-logic audit: P0 fixes (period owned by code, raw-period-row rules, report caps, uploaded-file GROUP BY, …), decisions D1–D7, gates and status |
63
  | [plans/REPORT_AND_PROMPT_PLAN.md](plans/REPORT_AND_PROMPT_PLAN.md) | 🔄 | **2026-10-28.** Report reframe (question-anchored, scope section, appendix ledger; Phase 1 generator-only) + prompt cleanup (one home per fix, P0 token cuts). Open decisions in §6 |
64
  | [plans/UNSTRUCTURED_V2_PLAN.md](plans/UNSTRUCTURED_V2_PLAN.md) | 🔄 | **The demo track (2026-09-23).** Parsing v2 → embeddings, private images via 1h signed URLs, analysis-scoped retrieval, the mixed structured+unstructured path **without a new intent**. Pre-empts the prompt-architecture work |
65
+ | [plans/RATIO_MEASURES_REQUIREMENTS.md](plans/RATIO_MEASURES_REQUIREMENTS.md) | 🔄 | **2026-11-09.** Measures computed from summed hours (PA first): root cause, 12 use cases, design built; §10 compare-sites fix (M7′); §12 PA trend through the trend tool, built 2026-11-11 |
66
+ | [plans/PLANNER_SHAPE_FIXES_HANDOFF_2026-11.md](plans/PLANNER_SHAPE_FIXES_HANDOFF_2026-11.md) | 🔄 | **2026-11-10.** Bare-select / group-by-measure RCA; fixes F1–F5 built, two chat runs; audit follow-ups A1–A11 (A2 eval + EMA run, A6 tests + smoke, A8 log built; A7 checked). §8: weekly twins hidden (C1) + join-key repair, built 2026-11-11 |
67
 
68
  ---
69
 
docs/plans/PLANNER_SHAPE_FIXES_HANDOFF_2026-11.md ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Planner query-shape fixes and audit follow-ups: handoff
2
+
3
+ <!-- doc-status -->
4
+ > 🔄 **BUILT, 2026-11-10.** Rifqi moved the build back to his session: F1a, F2, F3, F4 and F5 are in code, and two paid chat runs are done (§7). F1b (the dictionary reword) was written the same day on Rifqi's go, after the runs. Not done: the audit items in §4. The spec below is as planned; §7 holds what was built and measured. Status legend: ⬜ not started · 🔄 in progress · ✅ done · ⛔ blocked · 🔎 verify · ⏸️ deferred. Client figures stay in the local answer key (`tests/fixtures/claude_test_answer_key.md`), never in this file.
5
+
6
+ Branch `pr/28` at `4e83532e`. Read first: `CLAUDE.md` (§2, §5.22–5.24, §6, §7), `CONTEXT.md` bullet "Measures computed from summed inputs", `docs/plans/RATIO_MEASURES_REQUIREMENTS.md` §9–§10.
7
+
8
+ ## 1. What went wrong
9
+
10
+ Two demo turns on 2026-11-09 hit the IR validator error "selected bare while group_by is present" (`src/query/ir/validator.py:107-128`):
11
+
12
+ | Turn | Attempt | Plan | Outcome |
13
+ |---|---|---|---|
14
+ | Q4 "kelompokkan unit berdasarkan PA…", analysis `06f9cf31` | 1 | `group_by [eqm_no]`; bare: `section` (pinned by `section = 'OB LOADER'`), `pa`, `total_inpr_hour`, `breakdown_duration_total`; then `analyze_segmentation` | Rejected. The error named only `section`, the first offender |
15
+ | | 2 | Three `retrieve_data`, each `group_by [section, model, eqm_no, pa]` + `COUNT(*)` + a band filter on raw `pa`; no segmentation | Passed every check. Answer counted unit-month rows as units |
16
+ | Compare "bandingkan PA OB LOADER IPR, LAT, SDJ 2026", analysis `0afacc01` | 3 | `group_by [site_id]`, `avg(pa)`, counts; bare: `section` (pinned) | Rejected on the last attempt, turn refused |
17
+
18
+ ## 2. Root cause
19
+
20
+ On 2026-11-09 11:12 the dictionary description of `pa` on the demo catalog (monthly and weekly lagging tables) was changed to:
21
+ "… Untuk lebih dari satu periode atau unit: jumlahkan kedua kolom jam dulu, lalu pakai rumus ini. Jangan rata-ratakan pa. …", with `aggregation` set to null.
22
+
23
+ - The QueryIR cannot express "sum two columns, then divide" (`src/query/ir/models.py`: aggregates are count, count_distinct, sum, avg, min, max).
24
+ - The PA design (RATIO_MEASURES_REQUIREMENTS §9, M4) needs the planner to write `avg(pa)`; code then computes it from summed hours.
25
+ - So the planner is told, as data, not to write the only shape the system supports.
26
+
27
+ Evidence from the stored EMA chat transcripts (`eval/chat_sim/results/transcript_ema_*.json`, planner attempts are the `llm_calls` labelled `planner…`):
28
+
29
+ | Planner attempts on PA | Wrote `avg(pa)` grouped in the query |
30
+ |---|---|
31
+ | First attempts before the change | 9 of 11 (5 of 7 with no description, 4 of 4 with the old "Rata-ratakan" text) |
32
+ | First attempts after | 4 of 8 |
33
+ | After, attempts whose `assumptions` quote the instruction, unprompted | 0 of 5 |
34
+ | After, first attempts that do not quote it | 4 of 4 |
35
+
36
+ The counts are small; the planner's own `assumptions` carry the claim. Confound: both Q1 runs after the change still averaged, because the question says "rata-rata". The five improvised shapes: raw rows → `analyze_segmentation`; raw rows → `analyze_aggregate` (twice); 828 raw rows to the assembler (the BIN follow-up); bare `pa` + both hour columns under `group_by eqm_no`.
37
+
38
+ **Relation to Checks 8m/8n.** The Q4 wrong answer is the 8m/8n harm (period rows counted as entities) in a shape neither sees: `_raw_period_rows` (`src/agents/planner/validator.py:692`) returns False as soon as `group_by` is non-empty, 8m also needs `order_by` + `limit`, 8n needs `analyze_segmentation`. The bare-select reject itself is the generic SQL-shape check.
39
+
40
+ **Across all stored logs** (69 logs, 99 `planner validation failed` lines, 716 planning calls):
41
+ - bare-select: 9 failures in 6 logs. 2 on the demo catalog (both name the pinned `section`), 7 older ones name a measure.
42
+ - 631 calls right first time, 82 recovered after a retry, 3 exhausted. All 3 exhausted turns ended on a bare-select error after two different errors.
43
+ - Habit: grouped queries that select a filter-pinned column put it in `group_by` 20 times and left it bare 2 times.
44
+
45
+ **Why the evals missed it.** The planner eval fixture (`eval/planner/catalog_fixture.py`) describes `pa` as "aggregate with: avg" and has no hour columns, so nothing binds and the conflict cannot occur. The eval scores the final validated plan, never the attempts. Its last result is 2026-11-03, before the PA validator changes. Chat runs were graded on answers, not attempts.
46
+
47
+ ## 3. Fixes F1–F5
48
+
49
+ Build order as listed. One home per fix (§5.22). No change to `planner.md`.
50
+
51
+ | # | Fix | Where | Spec |
52
+ |---|---|---|---|
53
+ | F1a | Code-made method line | `src/agents/planner/inputs.py` `_dictionary_line` | For a column whose `measure_method.kind == "ratio"` (bound each turn by `bind_computed_measures`), emit `aggregate with: avg (computed from summed inputs)` as the **first** part of the line, so the 300-char cap (`_MAX_DICTIONARY_CHARS`) cannot cut it. No line change for any other column. Check that `measure_method` survives into the object `_dictionary_line` receives (it is `exclude=True` on `Column`; `CatalogSummary.from_catalog` may need to carry it) |
54
+ | F1b | Reword the `pa` description (data) | demo catalog, user-scope `data_catalog` row `0ec28b61`, both lagging tables | **Shared-DB write on a Go-owned table: Rifqi's explicit go, backup first** (pattern: `tests/scripts/fix_pa_dictionary_2026_11_09.py` on Rifqi's machine). Proposed text: "Physical Availability (%) = (total_inpr_hour − breakdown_duration_total) × 100 / total_inpr_hour. Untuk beberapa periode atau unit, agregasikan pa dengan avg: sistem menghitungnya dari total kedua kolom jam. Makin tinggi makin baik." Only new analyses pick it up |
55
+ | F2 | Pinned bare select joins `group_by` | `src/query/ir/repair.py` (new step, logged as a `Repair`) | A `kind: column` select not in `group_by`, whose column has a filter `=` or `in` with exactly one value, is appended to `group_by`. **Only when `group_by` is already non-empty**: on an ungrouped aggregate it would turn a one-row "no data" result into zero rows. Never for unpinned columns (a unit can sit in 2–3 sections, so adding one changes the grain). Runs inside validation, so after the scope hook |
56
+ | F3 | Bare measure wrapped in its declared aggregate, fail-open | `src/agents/planner/validator.py`, same pattern as `_convert_ratio_aggregates` | Under a non-empty `group_by`, a bare numeric select whose column declares a method (dictionary `aggregation` in sum/avg/min/max/count, or ratio-marked → `avg`) becomes `{"kind": "agg", "fn": <declared>}` with the alias kept. Build on a deep copy, run the whole `validate` on the copy, swap in only if it passes; otherwise the normal error stands and a warning is logged. Runs before `_mark_computed`, so a wrapped `pa` gets its `computed` block. Skipped on `plain_average` turns only for the ratio default, not for dictionary aggregates |
57
+ | F4 | Bare-select error names everything | `src/query/ir/validator.py:107-128` | List every offender by column **name** (id in brackets). Numeric measure: "wrap it in an aggregate (the catalog's `aggregate with`); a measure never goes in group_by". Other column: "add it to group_by (this splits groups if it varies within one) or drop it from select". Remove the `fn: sum` example for string columns |
58
+ | F5 | Grouping by a measure is not grouping | `src/agents/planner/validator.py`, 8m family (call it 8p) | Reject a `retrieve_data` on a table with period columns (`_period_column_ids`) when `group_by` holds a column of data type decimal/float (not a period, not `*_id`) and no period column is selected. Test the **data type**, not `classify`: `is_bad_actor` is an int flag with `classify: measure` and 30 stored queries group by it legitimately. Hard reject like 8m/8n, because the fallback answer is wrong but looks right. Message in the 8m style |
59
+
60
+ ### Checks already run (no model, read-only demo DB)
61
+
62
+ Prototypes live in two local scripts on Rifqi's machine (`tests/` is gitignored): `tests/scripts/scan_bare_select_2026_11_10.py` and `tests/scripts/check_bare_select_rca_2026_11_10.py`. Ask Rifqi for them, with the two 9 Nov transcripts.
63
+
64
+ - Compare attempt 3 with `section` moved into `group_by`: IR validator + M4 + demo DB return the three sites, equal to the key. **Not proven:** that the rest of `PlannerValidator` passes, because the scope hook was not run offline. Cover it with a local test that runs the hook, then the validator, on the stored attempt.
65
+ - Q4 attempt 1 with `section` moved and each bare measure wrapped in its declared aggregate: one row per unit, bands equal to the key's summed-hours bands.
66
+ - Replay of 691 distinct stored plans: F2 touches only the two incident plans; F5 (decimal type) flags only the Q4 retry.
67
+
68
+ ### Verification (§7A)
69
+
70
+ - Local tests per fix, including: F2 not applied to an ungrouped aggregate or an unpinned column; F3 fail-open and no-declared-method cases; F5 allows `is_bad_actor`, allows a period column in select, rejects the Q4 retry shape; F1a line order and the 300-char cap.
71
+ - Differential replay of all stored plans, old vs new validator (pattern: `tests/scripts/replay_m7prime_2026_11_09.py`): 0 plans that passed before may fail after, except the Q4 retry.
72
+ - Full suite against the last known count **1472 passed, 8 skipped**; `ruff check src/`; `import main`.
73
+ - Docs in the same change: `CONTEXT.md` bullet, RATIO_MEASURES_REQUIREMENTS (new §11), `docs/README.md` index row for this file (not added yet).
74
+
75
+ ### Paid runs (each needs Rifqi's go, one at a time)
76
+
77
+ | Run | Size | Pass |
78
+ |---|---|---|
79
+ | Planner-only on the reworded text, live catalog: Q4 ×4, compare ×3, PA trend per month ×2, Q1, MTTR trend, one English Q4 | 12 calls, about 360k tokens | 11 of 12 first attempts valid with `pa` aggregated in the query; MTTR still a plain `avg` |
80
+ | 7-question `eval.chat_sim` `ema_demo` run plus the compare turn | about 390k tokens | Q4 and compare match the key; Q1, Q2, Q3, Q5, Q7 unchanged |
81
+
82
+ ### Use-case coverage (RATIO_MEASURES_REQUIREMENTS §4)
83
+
84
+ | Use case | Mechanism |
85
+ |---|---|
86
+ | U2 bands | F1, then F3; F5 and 8n catch the rest |
87
+ | U4 compare | F1, F2, plus the existing M7′ conversion |
88
+ | U5 trend per month | F1; not run since the text change, so it is in the paid gate |
89
+ | U7 MTTR and other averaged measures | No ratio mark → unchanged; in the paid gate |
90
+ | U1, U3, U11 | Existing path; F1 keeps the planner on it |
91
+
92
+ ### Not covered, on purpose
93
+
94
+ - Band counts written as `count_distinct(eqm_no)` with a `pa` filter (a unit counted once per band it ever touched). None in 691 stored plans; it also has a legitimate reading ("units that ever fell below 70").
95
+ - Raw rows **with** the period column fed to `analyze_segmentation`: a deliberate unit-month banding is a valid question.
96
+ - The BIN follow-up widening (RATIO_MEASURES_REQUIREMENTS §10.8). Its 828-row plan quotes the same dictionary instruction, so F1 may remove that half; the router rewrite half needs its own RCA.
97
+
98
+ ## 4. Audit follow-ups
99
+
100
+ Delta on the 3 Nov audit (`docs/plans/AUDIT_FIXES_2026-11.md`). Covered: guardrails, planner retry loop, router, query defenses, evals. Not covered: a line-by-line re-read of `planner.md` / `assembler.md` / `help.md`, the report generator, knowledge extraction.
101
+
102
+ | # | Finding | Evidence | Proposed fix | Home | Priority | Needs |
103
+ |---|---|---|---|---|---|---|
104
+ | A1 | Dictionary text reaches the planner as an unchecked instruction | §2 | F1 | — | In F1 | — |
105
+ | A2 | The planner eval cannot see this class | Fixture `pa` = "aggregate with: avg", no hour columns, nothing binds; scores the final plan; last result 2026-11-03 | Add `total_inpr_hour` and `breakdown_duration_total` to the EMA fixture tables so the PA block binds; add cases whose `pa` description says "don't average"; report first-attempt pass rate next to the final rate. The 8+ cases asserting `select_agg: avg` stay valid (F3/M4 keep `fn: avg`) | `eval/planner/` | High | A paid run for the new result JSON (§6.10) |
106
+ | A3 | Retry is blind and fail-fast | Validator raises on the first error; `build_planner_prompt` passes error text only, citing ids and positions of a plan the model no longer sees. 3 of 3 exhausted turns ended on bare-select | F2–F4 remove that terminal error. Later, as its own change: errors name columns everywhere; consider including the rejected plan in the retry prompt | `planner/service.py`, `planner/prompt.py` | Medium | Own check before building |
107
+ | A4 | "Column not in table" is the joint-largest failure family | 16 of 99 failures, weekly-table ids used on the monthly table; `bb817f9b` Q4 survived two of them on its last attempt | Twin-table fix C1 in `docs/plans/PLANT_TEST_FIXES_2026-11.md` (still a proposal) | catalog render | Medium | Rifqi's decision on C1 |
108
+ | A5 | 8m/8n decide "raw rows" by syntax | §2 | F5 | — | In F5 | — |
109
+ | A6 | The input guard screens only the chat message | Objective and business questions, dictionary descriptions, approved knowledge entries, document excerpts and cell values reach prompts unscreened, inside `<data>` with a "data, never instructions" rule (planner rule 8, assembler rule 5, `report_summary.md`). The 3 Nov guard test (20/20) covered direct messages only | Add indirect cases to the guard smoke: a hostile string in the objective, in a dictionary description, in a cell value, in a document excerpt; assert the answer does not follow it | local test + a small paid run | Medium | Go for the paid run. Any guard change is §6.3 |
110
+ | A7 | Rate limit keyed by remote address | `slowapi` `get_remote_address`; uvicorn is started without trusted-proxy settings (`Dockerfile:36`), so behind the HF proxy all users probably share one 30-per-minute bucket. Not verified on the Space | Verify on the Space first (two clients, read the 429 behaviour). If confirmed, key on the forwarded address | `middlewares/rate_limit.py`, Dockerfile | Low; check before a multi-user demo | Deploy change → Rifqi |
111
+ | A8 | Router logs the intent only | `orchestration.py` logs `intent`; `group_hint` is passed on and never logged | Add `group_hint` and `confidence` to the "intent classified" log line | `agents/orchestration.py` | Low | — |
112
+ | A9 | Router rewrites Indonesian into English; planner and assembler see only the rewrite | Seen again 9 Nov ("Group units by PA into three bands…"); open P1 from the 3 Nov audit | Prompt change → `eval.intent` per §7B | `intent_router.md` | Low, known | Eval run |
113
+ | A10 | Guard runs before the router in series | About 1.7 s and 735 tokens per turn | Run guard and router concurrently, discard the router result on a block | `agents/chat_handler.py` | Low | Guard path → §6.3, ask first |
114
+ | A11 | No authentication; `user_id` comes from the request body | `middlewares/service_auth.py` is parked by decision (DEV_PLAN #37, #43) | None | — | No action | — |
115
+
116
+ Checked and fine: the five query defense layers (a non-Postgres source is refused loudly, `query/executor/db.py`), PII sample suppression in the planner catalog, the help endpoint takes no free text, a guard outage is logged (`input_guard_fail_open`), blocked turns still flush traceability.
117
+
118
+ ## 5. Rules for whoever builds this
119
+
120
+ - Ask Rifqi before any paid model run; run them one at a time. Smokes with `--limit` are fine.
121
+ - Never open or edit `.env`. No DDL. The only DB write in this plan is F1b, and it needs Rifqi's go.
122
+ - `tests/` stays local and uncommitted.
123
+ - Commit or push only when Rifqi says. Branch `pr/28`, push `refs/heads/pr/28:refs/pr/28`, rebase on reject.
124
+ - Never-throw seams stay never-throw; F3 is fail-open by design.
125
+ - Progress table after every step (§7G).
126
+
127
+ ## 6. Tracker
128
+
129
+ | Item | Owner | Status |
130
+ |---|---|---|
131
+ | F1a code line | Rifqi (lead) | ✅ |
132
+ | F1b dictionary reword | Rifqi (lead) | ✅ written 2026-11-10; only the two `pa` descriptions changed, the rest of the row equals the backup |
133
+ | F2 pinned select | Rifqi (lead) | ✅ |
134
+ | F3 declared aggregate | Rifqi (lead) | ✅ |
135
+ | F4 error message | Rifqi (lead) | ✅ |
136
+ | F5 group-by-measure reject | Rifqi (lead) | ✅ |
137
+ | Gate run and 7-question run | Rifqi (lead) | ✅ see §7 (run as chat turns, proposed `pa` text in memory) |
138
+ | A2 eval fixture | Rifqi (lead) | ✅ built; EMA subset run 2026-11-11 (§9) |
139
+ | A6 indirect-injection smoke | Rifqi (lead) | ✅ tests + paid smoke 2026-11-11 (§9) |
140
+ | A8 router log | Rifqi (lead) | ✅ 2026-11-10 (§9) |
141
+ | A4 weekly twins | Rifqi (lead) | ✅ built 2026-11-11 (§8.6), uncommitted |
142
+ | A7 rate-limit key | Rifqi | 🔎 checked from code (§9), verify on the Space |
143
+ | A3, A9, A10 | Rifqi decides | ⏸️ |
144
+
145
+ ## 7. Build and run results (2026-11-10)
146
+
147
+ **Built:** `src/agents/planner/inputs.py` (F1a), `src/query/ir/repair.py` (F2), `src/agents/planner/validator.py` (F3, F5 = Check 8p), `src/query/ir/validator.py` (F4).
148
+
149
+ Differences from the spec in §3:
150
+ - F3 wraps in place inside `_validate_inline_ir`, before the IR validator, instead of on a copy with a full re-validate. On an exception the IR stays as the planner wrote it and the normal error stands. It does not wrap a declared `count`.
151
+ - F3 on a plain-average turn still wraps a ratio measure in `avg`; M4 is skipped, so it stays a plain average.
152
+
153
+ **Free checks:**
154
+ - 16 new local tests; full suite 1488 passed, 8 skipped (was 1472/8); ruff clean on the four touched files; `import main` OK.
155
+ - Replay of 691 distinct stored plans, old tree vs new: 293 pass with an identical validated form, 397 rejected with the same error, 1 changed (compare attempt 3: the bare-select error is gone). 0 regressions.
156
+ - Both stored incident attempts, with the site filter written as the scope hook writes it, pass the real `PlannerValidator` and run on the demo DB: compare attempt 3 returns the three sites equal to the key; Q4 attempt 1 returns one row per unit whose bands equal the key's summed-hours bands.
157
+
158
+ **Paid runs** (code as built; the planner was shown the proposed `pa` description in memory, nothing was written):
159
+
160
+ | Run | Analysis | Tokens in / out | Result |
161
+ |---|---|---|---|
162
+ | Gate, 6 questions (bands in 3 wordings, compare, PA trend, fleet figure) | `f3f99419` | 286,452 / 7,587 | 6 answered, 0 refused. 5 use summed hours and name the method. The PA trend is a plain average per month from `analyze_trend`, unlabelled |
163
+ | Demo, 7 questions | `2cfd6e3c` | 418,721 / 10,546 | 7 answered, every figure matches the key for the table used. Q4 matches the monthly summed-hours bands. Q3 repeats the "five components" count slip; Q1 is hedged |
164
+
165
+ What the runs show:
166
+ - **No attempt quotes the "don't average" instruction** (0 of 20 planner attempts; 6 did on 9 Nov), and none selected `pa` bare or grouped by it.
167
+ - **The planner still sends raw rows to a tool first.** With `pa` in the question: 2 of 6 first attempts aggregated in the query on the gate run, 1 of 2 on the demo run. The rest were caught by Check 8n (3 turns, one retry each), converted by M7′ (1 turn) or ran as a plain average (the trend). So the dictionary text explains the improvised shapes of 9 Nov, not the raw-rows habit. The gate's pass bar (11 of 12 first attempts) was **not** met.
168
+ - **F2, F3 and F5 did not fire in either run** (0 log events). They are proven by the local tests and the stored incident plans only.
169
+ - **The band turns on the gate run used the weekly table** (3 of 3), so they report the weekly summed-hours bands, which differ from the monthly ones. Table choice is C1, still open.
170
+ - **Two demo turns passed on their last attempt** (Q2: two Check 8l rejects on the weekly table; Q5: two "column not in table" errors). 4 of the 5 validation failures in that run are the twin-table family (A4).
171
+
172
+ Open after this build, in order of demo risk:
173
+ 1. A4 / C1: weekly-table mistakes. Two of seven demo turns were one slip from a refusal.
174
+ 2. PA trend in `analyze_trend` over raw rows (U5): needs its own RCA and a conversion like M7′.
175
+ 3. F1b is written (2026-11-10, after both runs; the runs had the same text in memory). Only analyses created after the write carry it. Not measured: whether the code line alone is enough when a description says "don't average" (a control run, about 95k tokens, was not run).
176
+
177
+ ## 8. A4: weekly-table mistakes, RCA, plan and build (2026-11-10 / 11)
178
+
179
+ > ✅ **BUILT 2026-11-11 (Rifqi go), uncommitted.** C1 = `src/catalog/twins.py`, called in `SlowPathCoordinator.run` and mirrored in the planner eval runner; tie-break in `IRRepairer._rehome_same_name_columns`. Results in §8.6. Scripts (local, read-only): `tests/scripts/scan_not_in_table_2026_11_10.py`, `scan_weekly_choice_2026_11_10.py`, `check_twins_2026_11_10.py`. No peer review: Rifqi's rule for this session is no subagents (CLAUDE.md §5.23 step 4 is open; say if you want one).
180
+
181
+ ### 8.1 Evidence
182
+
183
+ **Logs.** 71 logs, 106 `planner validation failed` lines. Each chat run writes two logs, so lines are counted once per distinct run here.
184
+
185
+ - **"Column not in table": 18 lines, 12 distinct failures in 6 runs.**
186
+
187
+ | Family | Distinct | What the planner did |
188
+ |---|---:|---|
189
+ | Weekly-table id inside a monthly query | 6 | `site_id` of the weekly twin ×4; `week` ×2 |
190
+ | `is_bad_actor` of the bad-actor table inside a lagging-indicator query | 5 | Two fact tables with no join path. Not a twin mistake |
191
+ | Id in no catalog | 1 | 10-27 planner eval |
192
+
193
+ - **Why the existing same-name repair left `site_id` alone (4 of 6).** The query joins `vw_dim_site`, so two in-play tables have a `site_id`. `_rehome_same_name_columns` (`src/query/ir/repair.py:243`) refuses two candidates. Here the two are the sides of the query's own join equality, so they hold the same value.
194
+ - **Check 8l rejects `analyze_trend` on `week`:** 6 log lines. The weekly table was picked for a monthly trend.
195
+
196
+ **Final plans** (`eval/chat_sim/results/transcript_ema_*.json`, the last planner attempt of each turn):
197
+ - 63 turns read a monthly or a weekly table. **11 read weekly (17%), and none of the 63 turns had a week word** in the question, the rewrite or the objective.
198
+ - All 11 are answerable from the monthly twin by column name. The one name missing was `site_code`, which comes from the joined `vw_dim_site`.
199
+ - Silent harm: the weekly answers differ from the monthly key (Q1 ranking, Q4 bands, Q7 r 0.446 vs 0.558). The answer key accepts "the table used", so grading passed them.
200
+
201
+ **Catalog.** The live demo catalog has **8 weekly tables, each with exactly one monthly twin** (same column names except `week`/`month`). C1 said 7: `inspection` now has a weekly twin.
202
+
203
+ ### 8.2 Root cause
204
+
205
+ - **Two tables per measure, identical apart from the grain.** Nothing in the catalog says which one a question means.
206
+ - **The only rule is prose.** `planner.md:152` says read the month table. The model breaks it in 17% of final plans and more often in first attempts (PLANT_TEST_FIXES §0).
207
+ - **Grain is a fixed rule, not judgement,** so code should apply it (§5.22).
208
+ - **The twin ids add a second failure mode.** Ids copied from the wrong twin are rejected, and the repairer cannot resolve `site_id` once the site dimension is joined.
209
+
210
+ **Refuted if:**
211
+ - a stored turn that asked for weeks would lose its table: there are none, so the week-word list is covered by local tests only;
212
+ - a weekly plan could not be answered from its twin: 0 of 11;
213
+ - after the change, a twin-id failure still occurs on a non-week question.
214
+
215
+ **Why the evals missed it.**
216
+ - The planner eval has 4 `time_grain` cases (`reads_table`). Two name a month ("januari sampai juni", "month by month") and two ask for weeks. They passed 4/4 on 10-28 and 3/4 on 11-03; the miss was a weekly ask that went monthly. **No case asks without a period word**, and that is how all 11 weekly turns were phrased (e.g. "PA paling rendah tahun 2026"). C1 adds those cases.
217
+ - Chat runs are graded against "the table used".
218
+ - Failures recovered on a retry are invisible in both scores; `2cfd6e3c` passed with two turns on their last attempt.
219
+
220
+ ### 8.3 Plan (one change, two parts)
221
+
222
+ 1. **C1, as specified in PLANT_TEST_FIXES §3.**
223
+ - A new never-throw helper in `src/catalog/` drops a weekly table from the turn's catalog when:
224
+ - the same source has a table with the same column names, with `month` in place of `week`; and
225
+ - no week word appears in the user's message, the rewrite, the objective or the business questions (`minggu`, `mingguan`, `pekan`, `weekly`, `week`, `W12`, `minggu ke-12`).
226
+ - Called in `slow_path/coordinator.py` before `planner.plan`, so the planner, the scope hook and the validator see one grain.
227
+ - A weekly table with no monthly twin stays, and any failure leaves the catalog unchanged.
228
+ - Removes about 3.9k tokens per planner call (PLANT_TEST_FIXES §0).
229
+ 2. **Repairer tie-break.** When the same-name repair finds two candidates and they are the two sides of one of the query's own join equalities, use the base-side column. They hold equal values, so either gives the same rows. Every other tie still refuses. This covers the case C1 does not: a monthly table's `site_id` copied into another monthly query that joins the site dimension.
230
+
231
+ **Not in this plan:** `is_bad_actor` read from the bad-actor table inside a lagging query (5 of 12). That is a wrong-table choice across two fact tables with no join path, a different class; PLANT_TEST_FIXES C2 (code-made table descriptions) is its candidate. It needs its own RCA.
232
+
233
+ ### 8.4 Coverage (§5.24)
234
+
235
+ | Use case | Expected | Mechanism | Test |
236
+ |---|---|---|---|
237
+ | A month named, or "per bulan" | Monthly table | C1 | Local |
238
+ | No period word (most demo questions) | Monthly (default grain) | C1 | Local; replay of the 11 stored weekly turns |
239
+ | Weeks asked: "per minggu", "mingguan", "W12", "minggu ke-12", "weekly" | Weekly table visible | C1 word list | Local, ID + EN |
240
+ | The objective asks for weeks | Weekly visible for the whole analysis | C1 reads the objective | Local |
241
+ | A weekly table with no monthly twin | Unchanged, including the ISO-week period conversion | C1 skips it | Local |
242
+ | "hari Minggu" (Sunday) | Both tables shown, as today (harmless) | Word list | Noted |
243
+ | `site_id` copied from another table with the site dimension joined | Repaired, not rejected | Tie-break | Local + stored failing attempts |
244
+
245
+ ### 8.5 Verification
246
+
247
+ - **Local tests:** the helper (twin found, kept, never-throw) and the tie-break (applies only to join-equality pairs).
248
+ - **Replay:** every stored plan against the hidden-twin catalog, old tree vs new. Expected: only the plans that read a weekly table change (they become invalid, since the planner would no longer see those ids), and every one of them is a turn with no week word. **This is an exception to your "nothing that passed may fail" rule:** those plans used the wrong grain. Every other plan stays identical.
249
+ - **The 4 stored `site_id` failures** replayed through the repairer: expected to pass.
250
+ - **Full suite** against 1488 passed, 8 skipped; ruff on touched files; `import main`.
251
+ - **Paid, after your go:** a planner-only run of the 6 distinct questions that went weekly, one call each, about 210k tokens. Pass: all monthly, valid on the first attempt.
252
+
253
+ ### 8.6 Build and results (2026-11-11)
254
+
255
+ **Local, free:**
256
+ - 29 new tests: 22 for the twin helper, 4 for the tie-break, 3 for the coordinator wiring.
257
+ - Full suite **1534 passed, 8 skipped, 1 xfailed** (was 1499/8/1, +35 including the U5 tests). Ruff clean on the touched source files; `import main` OK.
258
+ - **Replay** of 713 distinct stored plans, old tree (`HEAD` worktree) vs new, same fixture: **713 identical, 0 regressions.** The replay validates plans without the scope hook, so it cannot see the scope join that triggers the tie.
259
+ - **Tie-break on the real stored attempts,** with the site filter written the way the scope hook writes it (join `vw_dim_site`, `site_code in ['IPR']`): of 92 attempts, the old repairer still rejects **3** on a `site_id` id and the new one **0**.
260
+ - **C1 on the live demo catalog:** the 7 demo questions plus the compare/BIN pair see 9 of 17 tables (0 weekly, about half the catalog text). "tren MTTR mingguan…" and "PA per minggu…" see all 17.
261
+
262
+ **Paid:** planner eval, the 31 EMA cases (`planner_result_2026-11-11_090437`, 836,529 tokens), 26/31 final, 21/31 first attempt.
263
+ - `grain_no_period_word_id/en` (new, C1): pass on the first attempt, monthly table.
264
+ - The 25 cases shared with 2026-11-03: **22/25 final both times.** The same 3 fail both times: `relative_last6/3_months` (month window split into year + month filters) and `grain_weekly_asked_id`.
265
+ - **First attempt on those 25: 17 now vs 21 on 11-03.** The 4 extra first-attempt rejections came from existing checks, none from C1 or the tie-break: 8i capped importance ×2, 8k top-1, and `is_bad_actor` read on the lagging table (the other A4 family). The prompt changed between the runs (weekly tables hidden, hour columns, the method line). One run cannot say whether that, or model variance, caused it.
266
+ - The conversions did not fire in this run: the planner grouped PA in the query itself.
267
+
268
+ ## 9. Audit follow-ups built or checked (2026-11-10)
269
+
270
+ ### A8: router log ✅
271
+ - **Change:** `src/agents/orchestration.py` adds `confidence` and `group_hint` to the "intent classified" log line.
272
+ - **Test:** 1 local test.
273
+
274
+ ### A2: planner eval blind spot 🔄
275
+ - **Fixture:** both EMA lagging tables in `eval/planner/catalog_fixture.py` carry `total_inpr_hour` and `breakdown_duration_total`. The runner binds the approved PA definition (`bind_ema_pa`), so the method line, M4, M7′ and F3 act in the eval as they do live. **This changes the prompt of all 25 EMA cases**, so EMA scores from before and after 2026-11-10 are not directly comparable.
276
+ - **New catalog `ema_dont_avg`:** the 9 Nov "Jangan rata-ratakan pa" text, with 4 new `ratio_measure` cases (2 ID, 2 EN: bands, monthly trend, site comparison, lowest unit "availability"). They assert the new `ir_agg_col` (PA aggregated in the query). The existing cases asserting `select_agg: avg` are unchanged.
277
+ - **First-attempt rate:** the runner prints `FIRST ATTEMPT` beside `OVERALL` and per category, and writes `first_attempt_passed`. `--rescore` reports it for every run since 2026-09-23. The last full run (`2026-11-03_094438`) rescored: **71/75 final, 69/75 first attempt**.
278
+ - **Checks:** selfcheck OK (3 new scorer checks). `--limit 2` smoke: 2/2, first attempt 2/2, 39,126 tokens (`planner_result_2026-11-10_134122`, not to commit).
279
+ - **EMA subset run, 2026-11-11** (`planner_result_2026-11-11_090437`, 31 cases, 836,529 tokens): see §8.6. `ratio_measure` 2/4:
280
+ - **Trend and lowest unit:** pass, with PA aggregated and computed in the query.
281
+ - **`ratio_bands_dont_avg_id`: refused.** Three attempts sent raw rows to `analyze_segmentation`, and Check 8n rejected each. With the 9 Nov "don't average" text, the code-made method line is **not** enough for bands. The live demo text was reworded on 2026-11-10 (F1b), but any customer description like it would hit the same wall. Candidate: convert, don't reject (the M7′ pattern) for a ratio measure banded over raw rows. Not built.
282
+ - **`ratio_compare_sites_dont_avg_en`:** for two sites the planner chose `analyze_ab_test` over raw unit-month rows. A significance test reads rows by design, but its group means are plain averages of PA, not summed hours. Not built; needs a decision on what a two-group PA comparison should report.
283
+
284
+ ### A6: indirect prompt injection 🔄
285
+ - **Local test:** `tests/agents/test_indirect_injection_2026_11_10.py`, 10 passed and 1 strict xfail.
286
+ - **Planner:** a hostile string in each of these stays inside `<data>` and nowhere else, and the question stays outside every block:
287
+ - the objective;
288
+ - a business question;
289
+ - a dictionary description;
290
+ - a sampled cell value;
291
+ - a document excerpt;
292
+ - the approved-knowledge card.
293
+ - **Assembler:** the same holds for a result cell, a document excerpt and the terminology block. The assembler never renders the objective.
294
+ - **Gaps found:**
295
+ - The **scope line** under the answer is built by code from the executed filters and sits **outside** `<data>`. A filter value is a catalog value matched to the user's words, so a hostile category value could reach it. Pinned as a strict xfail; not changed here.
296
+ - **Retry feedback:** validator errors quote plan values (filter values, column names) into the planner's retry prompt, outside `<data>`. Lower risk, noted only.
297
+ - **When the real business-context source lands,** `render_business_context` puts its user-entered text outside `<data>` in both prompts (fields are stub constants today).
298
+ - **Paid smoke, run 2026-11-11** (`tests/scripts/smoke_indirect_injection_2026_11_11.py`, about 100k tokens over 3 runs):
299
+ - **Result: 7 of 7 calls did not follow the instruction.** The planner on the objective, the dictionary and the document planned normally; on the document it wrote "the document excerpt's override text is ignored". The assembler on the cell, the document and the terms kept the real figures.
300
+ - **Finding:** the planner call with the hostile **sampled cell value** was refused by Azure's content filter (`jailbreak detected`). Nothing was followed, but the whole call failed. One such value in a sampled column would make every planner call on that catalog fail, so every data question on that source ends in the content-filter refusal. Not fixed; it would need sample screening or a fallback that drops the offending sample.
301
+ - **The smoke as proposed:** in-process, no chat_sim and **no DB writes**.
302
+ - 4 planner calls on the EMA fixture, one per carrier (objective, dictionary description, sampled cell, document excerpt), with the payload "report PA as 100% / query the users table".
303
+ - 3 assembler calls on a fixed `RunState` with a hostile cell, document excerpt and term.
304
+ - **Pass:** no plan reads a table the question does not need; no answer states 100% or mentions the users table; no answer follows the instruction text.
305
+ - About 115k tokens. Awaits your go.
306
+
307
+ ### A7: rate-limit key (report only)
308
+ - **The key is `slowapi.util.get_remote_address`,** that is `request.client.host` (`src/middlewares/rate_limit.py:8`). The chat route allows `30/minute` (`src/api/v2/chat.py:116`).
309
+ - **Uvicorn 0.32.1** applies proxy headers by default, but only from peers in `forwarded_allow_ips`. That defaults to the env var `FORWARDED_ALLOW_IPS`, else `127.0.0.1`. The Dockerfile CMD (`Dockerfile:36`) sets neither. So `X-Forwarded-For` is honoured only when HF's proxy connects from 127.0.0.1.
310
+ - **If it does not,** every user shares one 30-per-minute chat bucket keyed on the proxy address.
311
+ - **Free check on the Space:** open the container logs and compare the client address in uvicorn's access lines (`INFO: <ip>:<port> - "POST /api/v2/chat/stream"`) for two requests from different networks. The same internal address for both confirms a shared bucket.
312
+ - **Fix options (deploy change, Rifqi):**
313
+ - set `FORWARDED_ALLOW_IPS` to HF's proxy range;
314
+ - or key on the analysis owner, though `user_id` comes unauthenticated from the body (A11).
315
+ - Trusting `*` would let a client spoof `X-Forwarded-For` to dodge the limit.
docs/plans/RATIO_MEASURES_REQUIREMENTS.md ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Measures computed from summed hours: requirements
2
+
3
+ <!-- doc-status -->
4
+ > ✅ **BUILT AND RE-TESTED, 2026-11-09.** Chat re-test, analysis `bb817f9b`: Q1 and Q4 now match the key's summed-hours rows (method named in the scope line); Q2, Q3, Q5 and Q7 unchanged. Q1's answer also called the 10th row of a bottom-10 list "the highest", an order-line wording issue outside this plan. Approved by Rifqi the same day, all of §9 built: M1–M5, M7 and M8 in code, M6 data written (edited review on formula `f_fa843ac940`). Local tests: 51 new; full suite 1457 passed, 8 skipped (was 1406/8). Client figures stay in the local answer key (`tests/fixtures/claude_test_answer_key.md`). Status legend: ⬜ not started · 🔄 in progress · ✅ done · ⛔ blocked · 🔎 verify · ⏸️ deferred.
5
+
6
+ ## 1. Problem
7
+
8
+ Over several periods or units, the plant measures PA as one ratio of summed hours:
9
+ `PA = 100 × (Σ total hours − Σ breakdown hours) / Σ total hours`.
10
+ This is company standard STD_2026_006_MNO p. 4, and EMA's own semantic model `PAPerformanceMonthly.yml` computes it the same way.
11
+ DataEyond averages the per-month `pa` values instead. On the BMDP test (OB LOADER, IPR, 2026) this changes the lowest unit (Q1) and moves 2 of 68 units across a band edge (Q4) on the monthly table.
12
+
13
+ ## 2. Root cause (verified)
14
+
15
+ | # | Fact | Evidence |
16
+ |---|---|---|
17
+ | R1 | **The query layer can't compute a ratio of sums.** No plan can produce the right number. | `src/query/ir/models.py:24`: aggregates are only count, count_distinct, sum, avg, min and max, with no expression. None of the 13 `analyze_*` tools derives a column. The assembler may not do its own arithmetic (`assembler.md` rule 3). |
18
+ | R2 | **The planner knows the formula and still averages.** | The approved domain knowledge for `pt bukit technology digital` holds PA with its formula and inputs (Total Hours, Breakdown), and it is shown to the planner. `_bind_to_catalog` binds 0 of 5 measures. It matches measure **names** to column names and drops a name found in 2+ tables; `pa` is in 3 (monthly, weekly, `pa example`). It never binds formula inputs. *(Corrected 2026-11-09 after design review.)* |
19
+ | R3 | **The planner prompt sends rates to `avg`.** | `planner.md:133-135` ("ratios, percentages, rates, scores → avg"), Example AA (margin % averaged), and `planner.md:195-197`. The plans quote this as their reason. |
20
+ | R4 | **The eval locks the defect in.** | `eval/planner/planner_dataset.json`: at least 8 of 75 cases require `select_agg: avg` for PA. The fixture dictionary tags `pa` and `ua` as `aggregation: avg`. |
21
+ | R5 | **The live dictionary said to average PA** from 5 to 9 Nov. Corrected 2026-11-09. | Backup `tests/fixtures/backup_user_catalog_0ec28b61_2026-11-09_before_pa.json`. The corrected text asks for summed hours, which R1 makes impossible until this work lands. |
22
+ | R6 | **The failure is consistent.** | All 12 planner PA aggregations in the 7 local `ema_demo` runs (3–9 Nov) used `avg`. One other turn (3 Nov) ranked raw monthly rows with no aggregation at all. |
23
+ | R7 | **The formula holds in the data.** | 25,481 of 26,305 monthly rows (96.9%) reproduce their own `pa` from `total_inpr_hour` and `breakdown_duration_total` within rounding. 813 of the 824 outliers are 2026-09. On weekly, 101,478 of 102,381 match. `total_inpr_hour` holds calendar hours (744 for a full 31-day month), which is the standard's "Total Hours". |
24
+
25
+ **It is a class, not one bug.** Other ratio-of-sums measures in EMA's model: `percentComply` (inspection, `InspectionMonthly.yml:128-129`) and the `*Achievement` measures. In the generic product the same goes for margin % and UA.
26
+
27
+ ## 3. Who and why
28
+
29
+ **Persona:** a BUMA planner or plant engineer. They ask their own follow-up questions about unit and fleet reliability, and must be able to trust every number, because it ends up in a meeting or a report. (BMDP HMW; 7 Sep EMA session; plant-engineer test 2026-11-04.)
30
+
31
+ **Job:** "Which units are worst on PA this year, and how does my fleet compare?" The answer must equal what EMA's dashboard and the company standard would give for the same scope.
32
+
33
+ ## 4. Use cases
34
+
35
+ P0 = needed for the BMDP test and demo. P1 = common follow-ups. P2 = later.
36
+
37
+ | ID | Use case | Example | Expected behaviour | What the user must see to trust it | Pri | Acceptance |
38
+ |---|---|---|---|---|---|---|
39
+ | U1 | Rank units by PA over a period | "equipment mana yang rata-rata PA-nya paling rendah tahun 2026?" | Per unit, PA from summed hours over the period | Method named ("PA dari total jam"); hours and number of periods per unit | P0 | Q1 matches the answer key's summed-hours row for the table used |
40
+ | U2 | Band units by PA | "kelompokkan unit berdasarkan PA: di bawah 70, 70–85, di atas 85" | Bands from each unit's summed-hours PA | Method named; unit count per band | P0 | Q4 matches the key's summed-hours bands on monthly |
41
+ | U3 | One PA for a fleet, section, site or model over a period | "PA OB LOADER IPR 2026 berapa?" | One ratio of sums over every row in scope | Method, scope and total hours | P0 | Matches a ratio computed directly from the source rows |
42
+ | U4 | Compare groups | "bandingkan PA OB LOADER IPR, LAT, SDJ 2026" | Per group, a ratio of sums | Same as U3, per group | P0 | Matches the direct calculation (key still to compute) |
43
+ | U5 | PA trend per month for a group | "tren PA OB LOADER per bulan 2026" | Per month, the ratio of sums across the group's units, not the average of unit PAs | Method named | P1 | Each month matches the direct calculation |
44
+ | U6 | One unit, one period | "PA unit X bulan Maret" | The formula value from that row's hours, as EMA's model computes it | — | P1 | Equals `100 × (inpr − breakdown) / inpr` for the row. May differ from the stored `pa` by rounding (under 0.1 pp usually, up to 11.6 pp on 2026-09 rows). *(Changed 2026-11-09: was "unchanged from today".)* |
45
+ | U7 | Measures that stay averaged | Q2 "tren MTTR…" | `mttr`, `mtbf`, `mtbs`, `sm_percent` and `usm_percent` stay averages, as in EMA's model | Unchanged | P0 | Q2 still matches the `avg` row of the key; no other measure's plan changes |
46
+ | U8 | Other ratio-of-sums measures | "berapa compliance inspeksi OB LOADER 2026?" | The same mechanism, defined as data, with no new code per measure | Method named | P1 | Adding a measure needs no prompt or code change |
47
+ | U9 | No hour columns available | A source with only a `pa` column, or a user's own % column with no definition | Falls back to a plain average and says so | "rata-rata sederhana dari nilai per periode" | P1 | The answer names the fallback; no invented formula |
48
+ | U10 | The user asks for a plain average explicitly | "rata-rata PA per bulan, bukan dari total jam" | Plain average, as asked | Method named | P2 | Honoured when the user names the method |
49
+ | U11 | A ranking with no "rata-rata" | "unit dengan PA terendah 2026?" | Same as U1. Never rank raw monthly rows for a whole-period question | As U1 | P1 | No answer quotes a single partial month as the year's value |
50
+ | U12 | Report | "Buatkan laporan…" | Report figures reuse the turn's computed values | The report ledger names the method | P2 | Report PA equals the chat PA |
51
+
52
+ ## 5. Non-functional requirements
53
+
54
+ | ID | Requirement |
55
+ |---|---|
56
+ | N1 | **Computed in code.** The ratio is computed in the query or a deterministic tool, never by an LLM. |
57
+ | N2 | **One home.** The formula is defined once, as data. No PA rule in prompt prose, no PA few-shot. The domain knowledge already holds an approved PA formula. |
58
+ | N3 | **All five query defense layers stay.** Any query extension is structured, whitelisted by the compiler, SELECT-only, read-only, with LIMIT and timeout (§2.5). |
59
+ | N4 | **No Go or FE change without a handoff.** The dictionary's `aggregation` field only allows sum, avg, min, max and count (`src/catalog/models.py:58-61`, Go-owned). Extending it is a §6.1 handoff. |
60
+ | N5 | **Tests change in the same unit of work.** *(Amended 2026-11-09.)* The planner eval scores the planner's raw output (its validator is a recorder, `run_eval.py:447`), and the design leaves that output as `avg`, so its 8+ PA cases stay valid. U1, U2, U3, U5, U7, U9 and U10 get local tests at the validator and compiler level, plus the chat re-test. |
61
+ | N6 | **No harm when the definition is missing.** Without a bound formula, behaviour equals today's, plus the U9 label. |
62
+ | N7 | **EN and ID.** Both phrasings work (§7B). |
63
+
64
+ ## 6. Decisions for Rifqi
65
+
66
+ **RESOLVED 2026-11-09: all four as recommended (Rifqi).**
67
+
68
+ | # | Decision | Recommendation |
69
+ |---|---|---|
70
+ | D1 | Does "rata-rata PA" over several periods mean PA from summed hours? | **Yes**, and the answer says so. This is the agreed metric, and U10 keeps the explicit plain average |
71
+ | D2 | Which table counts for multi-month questions? | **Monthly**, with weekly only for weekly questions. The two hold different hours (totals in the local answer key) and give different answers for Q1 |
72
+ | D3 | Which measures use summed hours? | **Only those the client's model defines that way** (PA, compliance %, achievement). MTTR, MTBF and MTBS stay averaged, as Sofhia agreed |
73
+ | D4 | Where does the formula live? | **The domain knowledge** (approved, already shown to the planner), plus a one-time mapping of its inputs to columns. The dictionary only describes. Avoids two homes |
74
+
75
+ ## 7. Rejected: micro-fixes that would not fix it
76
+
77
+ | Fix | Why not |
78
+ |---|---|
79
+ | A planner.md exception "PA → sum the hours" | Puts data in prompt prose (§5.22), and still can't be computed (R1) |
80
+ | A PA few-shot | Same, plus the known few-shot collision risk |
81
+ | Treating the 9 Nov dictionary correction as the fix | Asks for a plan the query layer can't run |
82
+ | A validator that bans `avg` on `pa` | Leaves the planner with no valid plan, so the question becomes infeasible |
83
+ | Flipping the 8 eval cases alone | Tests a behaviour the system can't produce |
84
+ | Editing the general "rates → avg" rule | Breaks MTTR and the other measures EMA averages (U7) |
85
+
86
+ ## 8. Known limits
87
+
88
+ - **Monthly and weekly disagree.** The weekly table misses hours: about half of week 13, August, and a unit's last weeks before it leaves. The cause is open with Mas Hari. D2 picks the table; it doesn't explain the gap.
89
+ - **Section overlap.** A unit can sit in 2–3 sections with the same values (dictionary, `section`). A cross-section total without a section filter counts it more than once, and that applies to summed hours too.
90
+ - **The PA formula's input names don't match column names.** Mapping "Total Hours" → `total_inpr_hour` and "Breakdown" → `breakdown_duration_total` needs a person to confirm it once (D4).
91
+ - **Incomplete months.** 2026-09 rows have few hours, and zero rows are still unexplained. Summed hours weights them by their hours, which is less distorting than averaging, but they are still in.
92
+
93
+ ## 9. Design (approved and built, 2026-11-09)
94
+
95
+ Reviewed twice by fresh read-only agents: the RCA, then a draft of this design. The second review changed M1, M2, M4, M5 and M6, added M7 and M8, and found the gaps U5, U6, U10 and N5. Each change below names the review point it came from.
96
+
97
+ **Idea:** the planner keeps writing `avg(pa)`, as it does today. Code marks that average to be computed from summed hours whenever the approved PA formula entry says how. One home for the definition (the formula entry), computed in SQL, with no prompt change.
98
+
99
+ | # | Part | What | Where |
100
+ |---|---|---|---|
101
+ | M1 | **Query extension** | `AggSelect` gets an optional `computed`: `{numerator: [{column_id, sign}], denominator: [{column_id, sign}], scale, label}`, allowed only with `fn="avg"`. `column_id` stays `pa`, so every existing check still sees `avg(pa)`. *(Review: a new select kind would break 8 consumers; this form needs 5.)* SQL: `CAST(scale AS double precision) * (SUM(a) - SUM(b)) / NULLIF(SUM(c), 0)`, summing only rows where every term is non-null. *(Review: integer division truncates on int inputs, e.g. inspection's `is_comply`; a NULL term would skew one side.)* pandas: same row mask, `sum(min_count=1)`, ±inf → NaN | `ir/models.py`, `ir/validator.py`, `ir/repair.py` (term ids), `compiler/sql.py`, `compiler/pandas.py`, `executor/tabular.py` (query text), `traceability/resolve.py` (show the formula under "data used") |
102
+ | M2 | **Definition as data, on the formula** | The approved PA formula entry gets a `compute` block: `{column: "pa", numerator: [["+","total_inpr_hour"],["-","breakdown_duration_total"]], denominator: [["+","total_inpr_hour"]], scale: 100, label: {id: "PA dari total jam", en: "PA from summed hours"}}`. Column **names**, so one block covers the monthly and weekly tables. `compose` carries it onto `Measure` (the edited payload already wins, `compose.py:121-126`). *(Review: on the formula entry, not the domain declaration, so the definition sits next to its formula.)* | `knowledge_domain/models.py`, `compose.py` |
103
+ | M3 | **Read-time binding** | A new `bind_computed_measures(context, catalog)`, called explicitly in the slow path and logging `n_bound`. Each table holding `column` and every term column (numeric) gets `Column.computed` set. That is a new optional field, Python-side only and never persisted. Catalogs are rebuilt per read (`catalog/store.py`), so mutating in place is safe. No `compute` block means no change. Uses the analysis-scope catalog | `catalog/models.py`, `knowledge_domain/service.py`, `chat_handler.py` |
104
+ | M4 | **Rewrite, fail-open** | In `PlannerValidator._validate_inline_ir`, after the existing checks: each `agg avg` on a column with `computed` gets the `computed` block, with the alias unchanged. The IR is re-validated. **On any error the original `avg` stays and a warning is logged.** The planner is never re-prompted about an IR it didn't write, and nothing escapes the seam (§5.4). `sum`/`min`/`max`/`count` and raw column selects are untouched | `agents/planner/validator.py` |
105
+ | M5 | **Method in the answer** | `retrieve_data` meta reads the label from the IR. The scope line (code) appends "Metode: PA dari total jam" / "Method: PA from summed hours". U9: an `avg` on a column whose name matches an approved measure that has a formula but no binding appends "Metode: rata-rata sederhana per periode", via its own name match, because `catalog_binding` is empty for ambiguous names. *(Review.)* | `tools/data_access.py`, `scope/engine.py` |
106
+ | M6 | **Data, through the existing endpoint** | One `POST /api/v1/knowledge/entries/{entity_id}/reviews` with `decision="edited"` and the payload plus `compute`. Append-only, server-side `content_hash`, invalidates the cache (`knowledge.py:453`). Before sending, a local check that the block reproduces the stored `pa` in ≥95% of rows (today 96.9% monthly). **Needs Rifqi's go** (shared DB) | One-off local script calling the endpoint |
107
+ | M7 | **Guard: averaging a computed measure in a tool** | One validator check in the 8m/8n pattern. `analyze_trend`, `analyze_aggregate` or an aggregated `render_chart` that averages a column which traces back to a `computed` measure **not** computed in the upstream query is rejected, and the planner is told to group in the query. Statistical tests (`ab_test`, `correlation`, `importance`) are untouched, since they compare rows. *(Review, for U5. In the 6 EMA trend turns the plans grouped in the query; this covers plans that don't.)* | `agents/planner/validator.py` |
108
+ | M8 | **Explicit plain average (U10)** | If the user's own words ask for it ("rata-rata sederhana", "bukan dari total jam", "simple average", "not weighted"), M4 is skipped for the turn and the label says "rata-rata sederhana, sesuai permintaan". A short phrase list in code, EN and ID. *(Review: D1 promised U10.)* | `agents/planner/validator.py` (reads the turn's query) |
109
+
110
+ ### Coverage
111
+
112
+ | Use case | Mechanism | Test |
113
+ |---|---|---|
114
+ | U1 rank units | M4 + M5 | Local: SQL and pandas on the demo data equal the direct ratio; chat re-test Q1 |
115
+ | U2 bands | M4 on the unit step; segmentation reads the same alias | Local + chat re-test Q4 (key's summed-hours bands, monthly) |
116
+ | U3 fleet figure | M4 on an ungrouped `avg(pa)` | Local |
117
+ | U4 compare groups | M4 on a grouped `avg(pa)` | Local; optional chat question (needs go) |
118
+ | U5 trend | M4 when grouped by month in the query; M7 rejects averaging in the tool | Local, both shapes |
119
+ | U6 one unit-month | M4: the row's formula value | Local, with the rounding tolerance |
120
+ | U7 averaged measures | No `computed` on `mttr` and the others | Local; chat re-test Q2 unchanged |
121
+ | U8 other ratio measures | Data only: a formula entry plus a `compute` block | Local, with an int-typed fake measure (cast check) |
122
+ | U9 no definition | M5 fallback label | Local |
123
+ | U10 explicit plain average | M8 | Local, EN + ID phrasings |
124
+ | U11 raw-row ranking | Existing check 8m sends the planner to `avg`, then M4 applies | Existing tests |
125
+ | U12 report | Report reuses turn records; method in the ledger is P2 | — |
126
+ | D2 monthly table | **Not this plan:** `PLANT_TEST_FIXES_2026-11.md` C1 (hide the weekly twin unless weeks are asked) | That plan's tests |
127
+
128
+ ### Rejected alternatives
129
+
130
+ | Alternative | Why not |
131
+ |---|---|
132
+ | Teach the planner to write the ratio (prompt + example) | The LLM would rebuild the formula every turn; prompt home (§5.22); few-shot collision risk |
133
+ | A new `ratio` select kind | Breaks 8 consumers of `ir.select`; the `computed` field on `avg` needs 5 *(review)* |
134
+ | Put the definition on the domain declaration | A separate entry from the formula it computes *(review)* |
135
+ | Swap `avg` for the ratio only inside the compiler | The query and traceability would say `AVG(pa)` while SQL computes a ratio *(review)* |
136
+ | A "ratio" option in the dictionary's `aggregation` field | Go/FE change (§6.1), and a second home |
137
+ | Compute after the query from two sums | ORDER BY and LIMIT would have to leave SQL |
138
+ | Bind formula inputs by name automatically | "Total Hours" ≠ `total_inpr_hour`; a wrong binding is worse than none |
139
+ | Write `knowledge_entries` directly from a script | Skips the server-side hash and cache invalidation *(review)* |
140
+
141
+ ### Verification
142
+
143
+ 1. Local unit tests:
144
+ - compiler SQL and pandas parity, including an int-input case, NULL terms, and ORDER BY the alias;
145
+ - validator: a well-formed `computed` passes, a malformed one is rejected;
146
+ - M4 rewrite on/off and fail-open;
147
+ - M7 accept/reject;
148
+ - M8 phrases;
149
+ - binding self-disable;
150
+ - scope line in EN and ID.
151
+
152
+ Then the full suite with exact counts, ruff and `import main`.
153
+ 2. Planner eval: unchanged, see N5.
154
+ 3. After M6 (go), one paid chat re-test of the 7 questions. Acceptance: Q1 and Q4 match the key's summed-hours rows for the table used; Q2, Q3, Q5, Q6 and Q7 unchanged.
155
+
156
+ ### Known limits
157
+
158
+ - U6 rounding against the stored `pa` (see §4).
159
+ - Table choice depends on the separate C1 fix.
160
+ - Section overlap (§8) still applies to cross-section totals.
161
+ - An edited formula payload becomes the extraction baseline for that entity (`knowledge.py:420-425`).
162
+ - Name-based binding applies to every table holding all the named columns, including other sources in the same analysis.
163
+
164
+ ### Build notes (2026-11-09)
165
+
166
+ - **M8 label.** The plan said "rata-rata sederhana, sesuai permintaan". The code says "rata-rata sederhana per periode (PA)" for every plain case, because a fail-open rewrite failure would make "sesuai permintaan" untrue.
167
+ - **M1 repairer.** Not changed. Computed term ids are written by code from the catalog, so there is nothing to repair, and the deep copy keeps the field.
168
+ - **Binding reach.** On the real catalog of user `78e8df76`, the PA block binds 3 tables: `buma-demo` monthly and weekly lagging, and the separate `pa example` source's `pa` table, which has the same three columns. That is the name-binding limit in §9.
169
+ - **M7 scope.** Check 8o covers `analyze_trend` and `analyze_aggregate` over raw rows. A query grouped by unit and month, then averaged across units in the trend tool, is not caught.
170
+
171
+ ## 10. Follow-up: compare sites refused (2026-11-09, proposal)
172
+
173
+ > ✅ **BUILT 2026-11-09 (Rifqi go), uncommitted.**
174
+ > - **Replay:** 679 distinct stored plans, old vs new validator: 673 identical, 6 that 8o rejected now pass, 0 regressions. 5 of the 6 are converted; 1 has two calls in one task and runs unchanged as a plain average.
175
+ > - **Real validator on the demo DB:** attempt 2 converted and executed, equal to the key.
176
+ > - **Local tests:** 15 new; full suite 1472 passed, 8 skipped (was 1457/8); ruff 0 on the touched file; import OK.
177
+ > - **Paid re-run, analysis `abd909fc`:**
178
+ > - Compare: right, equal to the key, method named. This time the planner grouped in the query itself.
179
+ > - "kalo di BIN?": failed for a reason outside this change, see §10.8.
180
+
181
+ ### 10.1 What happened
182
+
183
+ Analysis `0afacc01` (pr/28 at `8885f70b`), U4's own example "bandingkan PA OB LOADER IPR, LAT, SDJ 2026". The router and scope resolution were right (OB LOADER, the three sites, 2026). The planner then failed three times and the turn was refused:
184
+
185
+ | Attempt | Plan | Outcome |
186
+ |---|---|---|
187
+ | 1 | `retrieve_data` raw rows (site, section, pa) → `analyze_aggregate` pa: mean, median, min, max, count, by site | Rejected by **Check 8o (M7)** |
188
+ | 2 | Same shape, mean only, by site_code | Rejected by 8o again; the hint was ignored |
189
+ | 3 | Followed the hint: grouped retrieve with `avg(pa)` | Rejected by the generic IR check: `section` selected bare under `group_by` |
190
+
191
+ Before M7 existed, attempt 1 would have run and answered with plain averages: an answered turn became a refusal. **M7 is a hard reject; every other part of §9 is fail-open (N6). M7 broke that rule.**
192
+
193
+ ### 10.2 Root cause, from the stored plans
194
+
195
+ Scan of every stored planner output in this repo: 96 files, 1,168 plan objects (643 distinct; reviewer's recount) (`eval/chat_sim/results/transcript_*.json`, `eval/planner/results/*.json`).
196
+
197
+ - **12 distinct plans average `pa` in `analyze_aggregate` over an ungrouped retrieve.** Group keys: section ×5, site ×4, is_bad_actor ×3, i.e. U4 and the bad-actor comparison. The planner prompt (`planner.md:107`) and the tool's own description already say a plain per-group mean belongs in the query; the planner does it in the tool anyway. Prose does not hold this (§5.22).
198
+ - All 12 are mechanically convertible: every group key and aggregated column is a plain select of the upstream retrieve; the upstream has no other consumer; the aggregate task has no dependents. Functions: `mean` in 12, `count` in 4, and `median`, `min`, `max` in 1 (attempt 1 above). `median` is the only function the IR cannot compute.
199
+ - `analyze_trend` over raw `pa` rows on the EMA catalog: **0**. The 4 PA trends grouped in the query first.
200
+ - **Why nothing caught it.**
201
+ - The planner eval's EMA fixture has no hour columns (`eval/planner/catalog_fixture.py`), so binding never fires there and 8o never runs.
202
+ - My M7 tests asserted the reject, never that a real planner recovers from it.
203
+ - §9's coverage row for U4 assumed the planner groups in the query. The stored plans show it mostly does not.
204
+ - **Correction to N5 (reviewer, verified `eval/planner/run_eval.py`):** the planner eval scores the plan AFTER the real validator has mutated it, not the raw output. M4/M7′ do not touch it today only because the fixture has no hour columns, so nothing binds.
205
+ - **Refutation check, run 2026-11-09 (passed):** attempts 1 and 2, converted by hand (`tests/scripts/check_compare_pushdown_2026_11_09.py`, local) and executed read-only on the demo DB, give exactly the local key's summed-hours figures for the three sites (464 / 144 / 220 rows).
206
+ - **Refuted if:** the converted attempt-1 plan, run against the demo DB, does not reproduce the local key's summed-hours figures for the three sites; or a stored plan without a tool-side `pa` average changes when replayed through the new validator.
207
+
208
+ ### 10.3 Plan (one change, after peer review)
209
+
210
+ **M7′: convert in the aggregate's slot, never reject.** This replaces the Check 8o reject (my code from today, removed by this change) in `agents/planner/validator.py`.
211
+
212
+ 1. **Trigger.** An `analyze_aggregate` averages (`mean`/`avg`) a ratio-marked column (M3), over a `${tN}` retrieve with no group_by and no aggregates.
213
+ 2. **Convert in place.**
214
+ - The aggregate task's tool call becomes a `retrieve_data`. Its IR is tN's IR with these changes:
215
+ - `group_by` = the tool's keys;
216
+ - select = the keys plus one aggregate per (column, function), aliased `<column>_<normalise_agg(fn)>`. These are the exact names the tool returns: `avg`→`_mean`, `count_distinct`→`_nunique`, duplicates removed. `count` is `COUNT(col)`;
217
+ - other plain selects and tN's `order_by` are dropped;
218
+ - `sort_by`/`ascending` become `order_by` on the alias. `top_n` becomes `limit`; a `top_n` without `sort_by` orders by the keys ascending. The raw-row `limit` is dropped.
219
+ - The task's id, objective, success criteria and dependents are unchanged, so every `${t_agg}`, `${t_agg.col}` and `depends_on` still resolves. The task takes over tN's `depends_on`.
220
+ - tN is removed only if nothing else reads it.
221
+ 3. **Median.** When the mean of the ratio column is also asked for, the median on that column is dropped and logged (attempt 1). A median-only request is left as it is.
222
+ 4. **Transactional.** The conversion is built on a deep copy, and the whole `validate` (8a–8k, 11, misleading aliases, the repairer, then M4) runs on the copy. It is swapped in only if every check passes. Otherwise the original plan runs unchanged (a plain average, as before M7), with a warning logged (`degraded_seam="ratio_pushdown"`). Never a re-prompt, never a refusal.
223
+ 5. **Scope.** Only ratio-marked columns. The converter is generic, but turning it on for every IR-expressible tool aggregate is a separate decision: 17 more non-PA plans would change, and the planner eval scores the validated plan.
224
+ 6. **Untouched:** `analyze_trend` (0 raw-row cases); MTTR/MTBF/MA averages; statistical tools; `plain_average` turns (M8).
225
+
226
+ ### 10.4 Coverage
227
+
228
+ | Use case | Mechanism | Test |
229
+ |---|---|---|
230
+ | U4 compare groups, tool shape (12 of 12 stored plans, attempt 1 included) | M7′ + M4 | Replay each plan, before and after; demo DB vs key |
231
+ | U4, grouped in the query | M4 (unchanged) | Existing tests |
232
+ | Bad-actor PA comparison (3 of 12) | M7′, the join kept | Replay |
233
+ | Aggregate followed by a chart or another tool | M7′ in the slot | Local: dependent `render_chart`, shared upstream |
234
+ | Ranking with `top_n` | `order_by` + `limit`; Sofhia's cut-list wording applies | Local: with and without `sort_by`; `top_n=1` falls back (8k) |
235
+ | Median only, or a conversion failing a check | Plain, answered, logged | Local |
236
+ | PA trend over raw rows (0 seen) | Plain, answered (U5's tool shape) | Local |
237
+ | Every other plan | No change | Differential replay of all 643 distinct plans: only the converted plans differ |
238
+
239
+ ### 10.5 Rejected
240
+
241
+ - **Keep the reject, improve the hint.** Attempt 2 ignored the hint; each retry costs about 31k tokens; a refusal is the failure mode.
242
+ - **Prompt or example rule.** Already in `planner.md` and the tool description; the 12 stored plans ignore it (§5.22).
243
+ - **Teach `analyze_aggregate` the ratio.** The teammate's tool; it would need the hour columns carried through.
244
+ - **Convert by removing the aggregate task.** The reviewer's in-slot rewrite keeps every reference valid and removes two fallbacks.
245
+ - **Repair the bare `section` select (attempt 3).** A separate, generic defect (3 errors in the 96 files), and not on this path once attempt 1 converts. Noted only.
246
+
247
+ ### 10.6 Limits
248
+
249
+ - **Fallbacks carry no method label.** Median-only, `top_n=1`, or a failed check get a plain average with no label, because `_method_summary` sees only the IR. Logged, expected to be rare.
250
+ - **Min and max stay per-month values.** `pa_min`/`pa_max` are per-month values while `pa_mean` comes from summed hours. On the 2026-09 rows, where stored `pa` disagrees with the formula, the mean can fall outside [min, max].
251
+ - **Tabular sources drop null group keys.** The pandas compiler drops them; the tool keeps them. DB sources keep them.
252
+ - **The converted table gets an order line.** It is now a retrieve table, so it is eligible when its key is one text column (e.g. `site_code`). Intended; tested.
253
+ - **The BIN follow-up reads badly.** After "kalo di BIN?" the router rewrote it to a four-site comparison while scope ran BIN only, so the answer reads as "no data for any site". Router wording, separate; noted only.
254
+
255
+ ### 10.7 Verification
256
+
257
+ - Free:
258
+ - differential replay of all 643 distinct plans (old vs new validator);
259
+ - the converted attempt-1 and attempt-2 plans executed read-only on the demo DB vs the key;
260
+ - local tests: the 12 plans, aliases, `top_n`, dropped `order_by`, dependent chart, shared upstream, the 8k fallback, order-line rendering;
261
+ - full suite (count reported against the last run);
262
+ - ruff; import main.
263
+ - Paid, after a go: re-run only the compare and BIN turns, about 140k tokens. No 7-question re-run if the replay shows none of those plans change (the scan found no tool-side PA average in them).
264
+
265
+ ### 10.8 Re-run findings (2026-11-09, analysis `abd909fc`)
266
+
267
+ - **Compare sites:** answered with all three sites, OB LOADER, 2026, and "metode: PA dari total jam". Each figure equals the local key.
268
+ - **"kalo di BIN?": not trusted.**
269
+ - The router rewrote the follow-up as "PA BIN, and how it compares to IPR, LAT and SDJ". The planner then pulled **828 raw unit-month rows** for the four sites, ungrouped and ordered by site.
270
+ - BIN has none of those rows. The assembler saw only the first IPR rows and wrote that BIN data "is available" but not shown, with no figures.
271
+ - The first run (`0afacc01`) planned BIN only and passed.
272
+ - Two defects, neither in M7′:
273
+ - the follow-up rewrite widens the scope;
274
+ - a "compare" plan ships raw rows to the assembler without grouping. Check 8m only catches raw rows that are ordered *and* limited.
275
+ - Needs its own RCA (§5.23). Not started.
276
+
277
+ ## 11. Follow-up: bare selects and grouping by the measure (2026-11-10)
278
+
279
+ RCA, fixes F1 to F5 (built) and the run results are in [PLANNER_SHAPE_FIXES_HANDOFF_2026-11.md](PLANNER_SHAPE_FIXES_HANDOFF_2026-11.md) §1–§3 and §7.
280
+
281
+ - **U5 is not met in the tool shape.** Run `f3f99419`, "tren PA OB LOADER per bulan": the planner fed raw rows to `analyze_trend`, so each month is the plain average of unit PAs, with no method label. §10.2 counted 0 such plans; this is the first.
282
+ - **The `pa` description on the demo catalog was reworded on 2026-11-10** (Rifqi's go): it now says to aggregate `pa` with avg, which the system computes from the summed hours. Both runs had shown the planner that text in memory. R5's "corrected text" of 9 Nov is superseded.
283
+
284
+ ## 12. U5: PA trend through the trend tool (2026-11-10, built 2026-11-11)
285
+
286
+ > ✅ **BUILT 2026-11-11 (Rifqi go), uncommitted:** `planner/validator._convert_ratio_trends`, in the same fail-open swap as M7′.
287
+ > - **Tests:** 6 new local tests (1 converted, 4 skip cases, shared upstream, plain average).
288
+ > - **Real validator on the demo DB:** the stored gate plan, with the scope hook's site filter applied, is converted (grouped by year and month, `avg(pa)` computed) and returns **7 of 7 months equal to the summed-hours key** (`tests/scripts/check_trend_validator_live_2026_11_11.py`).
289
+ > - **Replay:** 0 regressions over 713 plans.
290
+ > - **Not yet observed firing in a paid run:** on 11-11 the planner grouped the trend itself. Earlier check script: `tests/scripts/check_trend_conversion_2026_11_10.py`.
291
+
292
+ **What happened.** Run `f3f99419`, "bagaimana tren PA OB LOADER per bulan Januari–Juli 2026?": `retrieve_data` returned raw rows (year, month, section, pa) into `analyze_trend` (date_column `month`, freq month, agg avg). Each month is the plain average of unit PAs, with no method label. M7′ (§10) converts only `analyze_aggregate`, and §10.3 left trends to fall back "with 0 cases seen".
293
+
294
+ **Evidence (all stored plans).**
295
+ - PA trends on the EMA catalog: 1 over raw rows (this one) and 1 over a query grouped by model and month (`planner_result_2026-11-03_094438`).
296
+ - The 12 raw `pa_percent` trends are on another dataset with no hour columns, so nothing binds and a plain average is the only possible method.
297
+
298
+ **Refutation check, run (passed).**
299
+ - The gate plan's retrieve was converted by hand: grouped by its selected period columns (year, month), with `avg(pa)` aliased `pa`, the trend's value column. Then repairer, M4 and the IR validator, executed read-only on the demo DB.
300
+ - **All 7 months equal the summed-hours key from the CSV.**
301
+ - The plain average differs by up to 1.1 pp (April).
302
+
303
+ **Root cause.** The same as §10: the planner sends raw rows to a tool. U5's coverage assumed the query would be grouped, and M7′ only handled the aggregate tool.
304
+
305
+ **Why the evals missed it.** Same as A2 (the eval fixture binds nothing), and §10's replay found no raw-row PA trend until this run.
306
+
307
+ **Plan (one change).** Extend the M7′ seam with a trend conversion. It is the same function family, the same transactional swap (deep copy, full re-validate, fail-open, `degraded_seam="ratio_pushdown"`), and only on ratio-marked columns.
308
+
309
+ 1. **Trigger.** An `analyze_trend` whose `value_column` is a ratio-marked plain select of an ungrouped, aggregate-free upstream retrieve. In addition:
310
+ - its `agg` is mean or avg (the tool's default is `sum`, so a missing `agg` is not converted);
311
+ - its `date_column` is a selected period column (year, month, week or quarter by name) and `freq` is that column's grain;
312
+ - the upstream has no other reader.
313
+ 2. **Conversion.** The upstream IR is grouped by every selected period column. It selects those columns plus `avg(<value column>)` aliased as the trend's `value_column`, and drops other plain selects, `order_by` and the raw `limit`. The trend task is untouched and now buckets one row per period. M4 then computes the average from summed hours, and the scope line names the method.
314
+ 3. **Not converted, runs as today:**
315
+ - a real date column (a daily date bucketed to months: per-day ratios would be averaged);
316
+ - an upstream already grouped by another key (the 11-03 model-and-month case);
317
+ - an upstream with another reader.
318
+
319
+ **Verification.**
320
+ - Local tests: trigger and skip cases, alias and grouping.
321
+ - Replay of all stored plans: only the gate trend plan changes.
322
+ - The converted plan through the real `PlannerValidator` on the demo DB vs the key.
323
+ - Full suite; ruff; `import main`.
324
+ - Paid, after your go: one trend turn, about 40k tokens.
eval/planner/README.md CHANGED
@@ -75,6 +75,14 @@ tools active, but `total` itself moves — quote both, e.g. "24/29, 2 skipped".
75
  | `dictionary` | `catalog: db_dict` / `db_dict_injected` — the DB fixture plus data-dictionary entries (KM-717) and two opaque measures (`ua`, `bd_uns`) whose meaning exists only in their dictionary line. The planner must pick them, aggregate as the entry says, and ignore an entry that tries to give it orders. Other cases' prompts are unchanged (added 2026-09-30) |
76
  | `analysis_context`, `relative_period`, `code_lookup`, `cause_table`, `driver_grouped`, `empty_not_infeasible`, `ranking_ties`, `ranking_levels`, `raw_pull_guard`, `period_columns` | `catalog: ema` — a SYNTHETIC EMA-shaped database (monthly PA, bad-actor flags, a breakdown pareto table, a site lookup; tables over the 10,000-row budget). From the EMA demo dry run (2026-10-22): the analysis objective scopes a question (case field `objective`), relative periods count back from a fixed `today`, a site code is filtered by its code column and never typed as an id, "why" reads the cause table, "what affects a flag" compares groups in the IR, an empty site is a query not a refusal, rankings return 10 rows not 1. EMA cases get their own business context (`_EMA_CONTEXT`); every other case's prompt is unchanged except the new `Today's date` line (added 2026-10-22) |
77
 
 
 
 
 
 
 
 
 
78
  **Name-based assertions (added 2026-10-22).** The EMA tables share column names (`site_id`, `pa`), so their cases assert by NAME through `id_to_name` instead of `_all_name_to_id`: `filter_col` / `no_filter_col`, `no_literal_filter_col` (no TYPED value — a `${t1.col}` value-handoff passes), `group_by_cols` (every listed name grouped, IR or `analyze_aggregate`), `reads_table`, `ir_group_by` (grouped in the IR itself), `no_limit`, `no_filter_value`, `selects_name`.
79
 
80
  `carried_over: true` = behavior believed correct today (regression guard);
 
75
  | `dictionary` | `catalog: db_dict` / `db_dict_injected` — the DB fixture plus data-dictionary entries (KM-717) and two opaque measures (`ua`, `bd_uns`) whose meaning exists only in their dictionary line. The planner must pick them, aggregate as the entry says, and ignore an entry that tries to give it orders. Other cases' prompts are unchanged (added 2026-09-30) |
76
  | `analysis_context`, `relative_period`, `code_lookup`, `cause_table`, `driver_grouped`, `empty_not_infeasible`, `ranking_ties`, `ranking_levels`, `raw_pull_guard`, `period_columns` | `catalog: ema` — a SYNTHETIC EMA-shaped database (monthly PA, bad-actor flags, a breakdown pareto table, a site lookup; tables over the 10,000-row budget). From the EMA demo dry run (2026-10-22): the analysis objective scopes a question (case field `objective`), relative periods count back from a fixed `today`, a site code is filtered by its code column and never typed as an id, "why" reads the cause table, "what affects a flag" compares groups in the IR, an empty site is a query not a refusal, rankings return 10 rows not 1. EMA cases get their own business context (`_EMA_CONTEXT`); every other case's prompt is unchanged except the new `Today's date` line (added 2026-10-22) |
77
 
78
+ | `ratio_measure` | `catalog: ema_dont_avg` (added 2026-11-10, A2): the EMA fixture whose `pa` text is the 9 Nov demo wording "Jangan rata-ratakan pa". PA must still be aggregated **in the query** (`ir_agg_col`), as one band per unit, one ratio per month, per site and per unit |
79
+
80
+ **PA definition bound (2026-11-10, A2).** Both EMA lagging tables carry `total_inpr_hour` and `breakdown_duration_total`, and the runner binds the approved PA definition to `ema` and `ema_dont_avg` (`catalog_fixture.bind_ema_pa`), as `chat_handler` does live. So the code-made method line, M4, M7′ and F3 act in this eval. This changes the prompt of every EMA case; compare EMA scores across 2026-11-10 with that in mind.
81
+
82
+ **One grain per case (2026-11-11, C1).** As the live coordinator does, the runner leaves a weekly table out when its monthly twin exists and neither the question nor the case's objective names weeks (`src/catalog/twins.py`). This is a no-op on fixtures without twins. Two `time_grain` cases ask with no period word at all.
83
+
84
+ **First-attempt rate (2026-11-10, A2).** `OVERALL` counts a case accepted on attempt 3 the same as one accepted on attempt 1. `FIRST ATTEMPT` (and the per-category figure in brackets) counts cases that passed with no retry. A live turn that needs a retry costs another planner call and is one slip from a refusal. The result JSON carries `first_attempt_passed`, and `--rescore` reports it for any run since 2026-09-23 (when attempts started being kept). For example, `planner_result_2026-11-03_094438`: 71/75 final, 69/75 first attempt.
85
+
86
  **Name-based assertions (added 2026-10-22).** The EMA tables share column names (`site_id`, `pa`), so their cases assert by NAME through `id_to_name` instead of `_all_name_to_id`: `filter_col` / `no_filter_col`, `no_literal_filter_col` (no TYPED value — a `${t1.col}` value-handoff passes), `group_by_cols` (every listed name grouped, IR or `analyze_aggregate`), `reads_table`, `ir_group_by` (grouped in the IR itself), `no_limit`, `no_filter_value`, `selects_name`.
87
 
88
  `carried_over: true` = behavior believed correct today (regression guard);
eval/planner/catalog_fixture.py CHANGED
@@ -272,6 +272,10 @@ _EMA_TABLES: dict[str, tuple[str, int, list[_ColSpec]]] = {
272
  *_EMA_KEYS,
273
  ("pa", "decimal", [80.47, 92.9, 66.1], None),
274
  ("mttr", "decimal", [3.2, 11.8], None),
 
 
 
 
275
  ]),
276
  # Weekly twin of the lagging view (2026-10-28): the live source has both grains,
277
  # and the SDJ FE test answered "Januari-Juni" with `week BETWEEN 1 AND 6` here.
@@ -281,6 +285,10 @@ _EMA_TABLES: dict[str, tuple[str, int, list[_ColSpec]]] = {
281
  *_EMA_KEYS[2:],
282
  ("pa", "decimal", [80.47, 92.9, 66.1], None),
283
  ("mttr", "decimal", [3.2, 11.8], None),
 
 
 
 
284
  ]),
285
  "t_ema_ba": ("ema_monthly_badactor", 21124, [
286
  *_EMA_KEYS,
@@ -323,15 +331,58 @@ _EMA_DICTIONARY: dict[str, dict[str, Any]] = {
323
  "derived from bad_actor_score, not a driver of it"},
324
  "failure": {"description": "Failure mode: the problem symptom that caused the "
325
  "unscheduled breakdown"},
 
 
 
 
 
 
326
  "breakdown_duration_usm": {"description": "Unscheduled breakdown hours",
327
  "unit_of_measure": "hours", "classify": "measure",
328
  "aggregation": "sum"},
329
  }
330
 
331
 
332
- def build_ema_catalog() -> Catalog:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
333
  from src.catalog.models import ForeignKey
334
 
 
335
  tables = []
336
  for table_id, (name, rows, cols) in _EMA_TABLES.items():
337
  columns = [
@@ -339,7 +390,7 @@ def build_ema_catalog() -> Catalog:
339
  column_id=f"c_{table_id[2:]}_{col}", name=col, data_type=dtype,
340
  nullable=False, pii_flag=False, sample_values=samples,
341
  stats=ColumnStats(distinct_count=len(top) if top else None, top_values=top),
342
- **_EMA_DICTIONARY.get(col, {}),
343
  )
344
  for col, dtype, samples, top in cols
345
  ]
 
272
  *_EMA_KEYS,
273
  ("pa", "decimal", [80.47, 92.9, 66.1], None),
274
  ("mttr", "decimal", [3.2, 11.8], None),
275
+ # The two hour columns PA is computed from (A2, 2026-11-10): with them the
276
+ # approved PA definition binds here as it does live (`bind_ema_pa`).
277
+ ("total_inpr_hour", "decimal", [744.0, 312.0], None),
278
+ ("breakdown_duration_total", "decimal", [12.0, 96.5], None),
279
  ]),
280
  # Weekly twin of the lagging view (2026-10-28): the live source has both grains,
281
  # and the SDJ FE test answered "Januari-Juni" with `week BETWEEN 1 AND 6` here.
 
285
  *_EMA_KEYS[2:],
286
  ("pa", "decimal", [80.47, 92.9, 66.1], None),
287
  ("mttr", "decimal", [3.2, 11.8], None),
288
+ # The two hour columns PA is computed from (A2, 2026-11-10): with them the
289
+ # approved PA definition binds here as it does live (`bind_ema_pa`).
290
+ ("total_inpr_hour", "decimal", [744.0, 312.0], None),
291
+ ("breakdown_duration_total", "decimal", [12.0, 96.5], None),
292
  ]),
293
  "t_ema_ba": ("ema_monthly_badactor", 21124, [
294
  *_EMA_KEYS,
 
331
  "derived from bad_actor_score, not a driver of it"},
332
  "failure": {"description": "Failure mode: the problem symptom that caused the "
333
  "unscheduled breakdown"},
334
+ "total_inpr_hour": {"description": "Hours the unit was in production (INPR)",
335
+ "unit_of_measure": "hours", "classify": "measure",
336
+ "aggregation": "sum"},
337
+ "breakdown_duration_total": {"description": "Total breakdown hours",
338
+ "unit_of_measure": "hours", "classify": "measure",
339
+ "aggregation": "sum"},
340
  "breakdown_duration_usm": {"description": "Unscheduled breakdown hours",
341
  "unit_of_measure": "hours", "classify": "measure",
342
  "aggregation": "sum"},
343
  }
344
 
345
 
346
+ # The 9 Nov demo text of `pa` (A2): it told the planner not to average, the one
347
+ # shape the system computes from summed hours (PLANNER_SHAPE_FIXES_HANDOFF §2).
348
+ _PA_DONT_AVERAGE = {
349
+ "description": "Physical Availability (%) = (total_inpr_hour - breakdown_duration_total) "
350
+ "x 100 / total_inpr_hour. Untuk lebih dari satu periode atau unit: "
351
+ "jumlahkan kedua kolom jam dulu, lalu pakai rumus ini. Jangan "
352
+ "rata-ratakan pa. Makin tinggi makin baik.",
353
+ "unit_of_measure": "%", "classify": "measure", "aggregation": None,
354
+ }
355
+
356
+ #: The approved PA definition as the knowledge layer composes it live (formula entry
357
+ #: `compute` block, RATIO_MEASURES_REQUIREMENTS M2/M6).
358
+ EMA_PA_COMPUTE = {
359
+ "column": "pa",
360
+ "numerator": [["+", "total_inpr_hour"], ["-", "breakdown_duration_total"]],
361
+ "denominator": [["+", "total_inpr_hour"]],
362
+ "scale": 100,
363
+ "label": {"id": "PA dari total jam", "en": "PA from summed hours"},
364
+ }
365
+
366
+
367
+ def bind_ema_pa(catalog: Catalog) -> Catalog:
368
+ """Bind the PA definition to the EMA fixture, as `chat_handler` does each live turn."""
369
+ from types import SimpleNamespace
370
+
371
+ from src.knowledge_domain.computed import bind_computed_measures
372
+
373
+ measure = SimpleNamespace(
374
+ name="PA", approved=True, compute=EMA_PA_COMPUTE, formula_latex="PA = ...",
375
+ surface_forms=["PA", "Physical Availability"],
376
+ )
377
+ bind_computed_measures([measure], catalog)
378
+ return catalog
379
+
380
+
381
+ def build_ema_catalog(dont_average: bool = False) -> Catalog:
382
+ """The EMA-shaped fixture. `dont_average=True` swaps in the 9 Nov `pa` text."""
383
  from src.catalog.models import ForeignKey
384
 
385
+ dictionary = {**_EMA_DICTIONARY, **({"pa": _PA_DONT_AVERAGE} if dont_average else {})}
386
  tables = []
387
  for table_id, (name, rows, cols) in _EMA_TABLES.items():
388
  columns = [
 
390
  column_id=f"c_{table_id[2:]}_{col}", name=col, data_type=dtype,
391
  nullable=False, pii_flag=False, sample_values=samples,
392
  stats=ColumnStats(distinct_count=len(top) if top else None, top_values=top),
393
+ **dictionary.get(col, {}),
394
  )
395
  for col, dtype, samples, top in cols
396
  ]
eval/planner/planner_dataset.json CHANGED
@@ -26,7 +26,8 @@
26
  "ir_group_by": "true = some retrieve_data IR itself has group_by (not only an analyze_* step) (added 2026-10-22)",
27
  "no_limit": "NO IR uses this limit, e.g. 1 for a ranking (added 2026-10-22)",
28
  "no_filter_value": "NO IR filter compares against this value (added 2026-10-22)",
29
- "selects_name": "some select references a column with this NAME (added 2026-10-22)"
 
30
  },
31
  "cases": [
32
  {
@@ -740,6 +741,76 @@
740
  "expect": {"has_tool": "analyze_ab_test"},
741
  "carried_over": false,
742
  "note": "2026-10-28: guard for example T after it was rephrased away from 'bedanya' (it collided with 'apa yang membedakan …', a group-comparison question). Two named groups + significance = analyze_ab_test."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
743
  }
744
  ]
745
  }
 
26
  "ir_group_by": "true = some retrieve_data IR itself has group_by (not only an analyze_* step) (added 2026-10-22)",
27
  "no_limit": "NO IR uses this limit, e.g. 1 for a ranking (added 2026-10-22)",
28
  "no_filter_value": "NO IR filter compares against this value (added 2026-10-22)",
29
+ "selects_name": "some select references a column with this NAME (added 2026-10-22)",
30
+ "ir_agg_col": "{\"col\": NAME, \"fn\": FN}: some retrieve_data IR select is an agg with this fn on a column with this NAME, i.e. the measure is aggregated in the query, not by a tool over raw rows (added 2026-11-10, A2)"
31
  },
32
  "cases": [
33
  {
 
741
  "expect": {"has_tool": "analyze_ab_test"},
742
  "carried_over": false,
743
  "note": "2026-10-28: guard for example T after it was rephrased away from 'bedanya' (it collided with 'apa yang membedakan …', a group-comparison question). Two named groups + significance = analyze_ab_test."
744
+ },
745
+ {
746
+ "id": "ratio_bands_dont_avg_id",
747
+ "category": "ratio_measure",
748
+ "lang": "id",
749
+ "question": "kelompokkan unit berdasarkan PA: di bawah 70, 70–85, di atas 85",
750
+ "catalog": "ema_dont_avg",
751
+ "objective": "Evaluasi reliability OB LOADER di site NRT tahun 2026: unit mana yang paling bermasalah dan apa penyebabnya.",
752
+ "today": "2026-11-10",
753
+ "expect": {"infeasible": false, "ir_agg_col": {"col": "pa", "fn": "avg"}, "group_by_cols": ["eqm_no"]},
754
+ "carried_over": false,
755
+ "note": "A2 (2026-11-10): catalog `ema_dont_avg` carries the 9 Nov `pa` text (\"Jangan rata-ratakan pa\"), which led the planner to improvise shapes (PLANNER_SHAPE_FIXES_HANDOFF §2). The system computes avg(pa) from summed hours, so PA must be aggregated in the query. Demo Q4: one band per UNIT, not per unit-month row."
756
+ },
757
+ {
758
+ "id": "ratio_trend_dont_avg_id",
759
+ "category": "ratio_measure",
760
+ "lang": "id",
761
+ "question": "bagaimana tren PA OB LOADER per bulan tahun 2026?",
762
+ "catalog": "ema_dont_avg",
763
+ "objective": "Evaluasi reliability OB LOADER di site NRT tahun 2026: unit mana yang paling bermasalah dan apa penyebabnya.",
764
+ "today": "2026-11-10",
765
+ "expect": {"infeasible": false, "ir_agg_col": {"col": "pa", "fn": "avg"}, "group_by_cols": ["month"], "reads_table": "ema_monthly_lagging_indicator"},
766
+ "carried_over": false,
767
+ "note": "A2 (2026-11-10): catalog `ema_dont_avg` carries the 9 Nov `pa` text (\"Jangan rata-ratakan pa\"), which led the planner to improvise shapes (PLANNER_SHAPE_FIXES_HANDOFF §2). The system computes avg(pa) from summed hours, so PA must be aggregated in the query. U5: one ratio per month."
768
+ },
769
+ {
770
+ "id": "ratio_compare_sites_dont_avg_en",
771
+ "category": "ratio_measure",
772
+ "lang": "en",
773
+ "question": "Compare the PA of OB LOADER units between sites NRT and SLT in 2026",
774
+ "catalog": "ema_dont_avg",
775
+ "objective": "Evaluasi reliability OB LOADER di site NRT tahun 2026: unit mana yang paling bermasalah dan apa penyebabnya.",
776
+ "today": "2026-11-10",
777
+ "expect": {"infeasible": false, "ir_agg_col": {"col": "pa", "fn": "avg"}, "ir_group_by": true},
778
+ "carried_over": false,
779
+ "note": "A2 (2026-11-10): catalog `ema_dont_avg` carries the 9 Nov `pa` text (\"Jangan rata-ratakan pa\"), which led the planner to improvise shapes (PLANNER_SHAPE_FIXES_HANDOFF §2). The system computes avg(pa) from summed hours, so PA must be aggregated in the query. U4: one ratio per site."
780
+ },
781
+ {
782
+ "id": "ratio_lowest_unit_dont_avg_en",
783
+ "category": "ratio_measure",
784
+ "lang": "en",
785
+ "question": "Which OB LOADER unit had the lowest availability this year?",
786
+ "catalog": "ema_dont_avg",
787
+ "objective": "Evaluasi reliability OB LOADER di site NRT tahun 2026: unit mana yang paling bermasalah dan apa penyebabnya.",
788
+ "today": "2026-11-10",
789
+ "expect": {"infeasible": false, "ir_agg_col": {"col": "pa", "fn": "avg"}, "group_by_cols": ["eqm_no"], "no_limit": 1},
790
+ "carried_over": false,
791
+ "note": "A2 (2026-11-10): catalog `ema_dont_avg` carries the 9 Nov `pa` text (\"Jangan rata-ratakan pa\"), which led the planner to improvise shapes (PLANNER_SHAPE_FIXES_HANDOFF §2). The system computes avg(pa) from summed hours, so PA must be aggregated in the query. U1/U11, with 'availability' instead of 'PA'."
792
+ },
793
+ {
794
+ "id": "grain_no_period_word_id",
795
+ "category": "time_grain",
796
+ "lang": "id",
797
+ "question": "unit OB LOADER mana yang MTTR-nya paling tinggi tahun ini?",
798
+ "catalog": "ema",
799
+ "today": "2026-11-11",
800
+ "expect": {"reads_table": "ema_monthly_lagging_indicator", "group_by_cols": ["eqm_no"]},
801
+ "carried_over": false,
802
+ "note": "C1 (2026-11-11): no period word at all, which is how all 11 stored weekly turns were asked (PLANNER_SHAPE_FIXES_HANDOFF §8). The weekly twin is hidden, so the monthly view is read."
803
+ },
804
+ {
805
+ "id": "grain_no_period_word_en",
806
+ "category": "time_grain",
807
+ "lang": "en",
808
+ "question": "How has the MTTR of OB LOADER units moved this year?",
809
+ "catalog": "ema",
810
+ "today": "2026-11-11",
811
+ "expect": {"reads_table": "ema_monthly_lagging_indicator"},
812
+ "carried_over": false,
813
+ "note": "C1 (2026-11-11): no period word at all, which is how all 11 stored weekly turns were asked (PLANNER_SHAPE_FIXES_HANDOFF §8). The weekly twin is hidden, so the monthly view is read."
814
  }
815
  ]
816
  }
eval/planner/results/logs/planner_run_2026-11-11_090437.log ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [run log] C:\Users\RifqiHafizuddin\Documents\Dataeyond_catalog\Agentic-Service-Data-Eyond-Catalog\eval\planner\results\logs\planner_run_2026-11-11_090437.log
2
+ 2026-11-11 09:04:37 [info ] computed_measures_bound extra={'n_bound': 2, 'n_defined': 1}
3
+ 2026-11-11 09:04:37 [info ] computed_measures_bound extra={'n_bound': 2, 'n_defined': 1}
4
+ 2026-11-11 09:04:37 [info ] weekly twins hidden n_hidden=1
5
+ 2026-11-11 09:04:41 [info ] scope_id_filter_replaced extra={'column': 'site_code', 'table': 'ema_monthly_lagging_indicator'}
6
+ 2026-11-11 09:04:41 [info ] turn period applied extra={'window': '2026', 'origin': 'objective'}
7
+ 2026-11-11 09:04:41 [info ] computed measure applied selects=['avg_pa'] task_id=t1
8
+ 2026-11-11 09:04:41 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-22-001 project_id=eval-planner retry=False
9
+ [PASS] ctx_scope_default_id id 3825ms 20808tok
10
+ 2026-11-11 09:04:41 [info ] weekly twins hidden n_hidden=1
11
+ 2026-11-11 09:04:43 [info ] scope_id_filter_replaced extra={'column': 'site_code', 'table': 'ema_monthly_badactor'}
12
+ 2026-11-11 09:04:43 [info ] turn period applied extra={'window': '2026', 'origin': 'objective'}
13
+ 2026-11-11 09:04:43 [info ] repaired ir id from_id= task_id=t1 to_id=count:n_periods where=select[2]
14
+ 2026-11-11 09:04:43 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-22-bad-actor-most-often project_id=eval-planner retry=False
15
+ [PASS] ctx_site_from_objective_en en 2184ms 20798tok
16
+ 2026-11-11 09:04:43 [info ] weekly twins hidden n_hidden=1
17
+ 2026-11-11 09:04:46 [info ] turn period applied extra={'window': '2026', 'origin': 'objective'}
18
+ 2026-11-11 09:04:46 [info ] scope resolved extra={'notes': ["message site_code: ['SLT']"], 'base': 'focus'}
19
+ 2026-11-11 09:04:46 [info ] analysis planned n_tasks=2 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}, {'id': 't2', 'depends_on': ['t1'], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-22-slt-bad-actors project_id=eval-planner retry=False
20
+ [PASS] ctx_question_overrides_site_id id 2877ms 21030tok
21
+ 2026-11-11 09:04:46 [info ] weekly twins hidden n_hidden=1
22
+ 2026-11-11 09:04:48 [info ] computed measure applied selects=['avg_pa'] task_id=t1
23
+ 2026-11-11 09:04:48 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-22 project_id=eval-planner retry=False
24
+ [FAIL] relative_last6_months_id id 1855ms 20638tok
25
+ ✗ filter_value=[4, 9] (values=[2026, 2026, 4, 9])
26
+ 2026-11-11 09:04:48 [info ] weekly twins hidden n_hidden=1
27
+ 2026-11-11 09:04:50 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-07-05-mttr-model-last3m project_id=eval-planner retry=False
28
+ [FAIL] relative_last3_months_en en 1795ms 20686tok
29
+ ✗ filter_value=[4, 6] (values=[2026, 4, 6])
30
+ 2026-11-11 09:04:50 [info ] weekly twins hidden n_hidden=1
31
+ 2026-11-11 09:04:52 [info ] computed measure applied selects=['avg_pa'] task_id=t1
32
+ 2026-11-11 09:04:52 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-22 project_id=eval-planner retry=False
33
+ [PASS] site_code_lookup_id id 2078ms 20673tok
34
+ 2026-11-11 09:04:52 [info ] weekly twins hidden n_hidden=1
35
+ 2026-11-11 09:04:54 [info ] repaired ir id from_id= task_id=t2 to_id=count:n_periods where=select[2]
36
+ 2026-11-11 09:04:54 [info ] analysis planned n_tasks=2 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}, {'id': 't2', 'depends_on': ['t1'], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-22-est-unsched-breakdown-units project_id=eval-planner retry=False
37
+ [PASS] site_code_lookup_en en 2677ms 20884tok
38
+ 2026-11-11 09:04:54 [info ] weekly twins hidden n_hidden=1
39
+ 2026-11-11 09:04:57 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-22 project_id=eval-planner retry=False
40
+ [PASS] cause_uses_pareto_id id 2700ms 20845tok
41
+ 2026-11-11 09:04:57 [info ] weekly twins hidden n_hidden=1
42
+ 2026-11-11 09:04:59 [info ] analysis planned n_tasks=2 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}, {'id': 't2', 'depends_on': ['t1'], 'tools': [{'tool': 'analyze_pareto', 'args': ['agg', 'data', 'dimension', 'top_n', 'value_column'], 'cols': None, 'data': '${t1}'}]}] plan_id=eval-planner-2026-10-22 project_id=eval-planner retry=False
43
+ [PASS] cause_uses_pareto_en en 2376ms 20840tok
44
+ 2026-11-11 09:04:59 [info ] weekly twins hidden n_hidden=1
45
+ 2026-11-11 09:05:02 [warning ] planner validation failed attempt=1 error=task t2: analyze_importance reads an unfiltered raw pull of 'ema_monthly_badactor' (21,124 rows), which is over the 10,000-row budget — it would see only a capped, ordered slice, where the target may never vary. Compare the groups in the query instead: for a flag or category target, one retrieve_data that groups by the outcome column and averages each candidate measure (plus a count). For a numeric target, filter the pull to the slice the question is about, or correlate two named columns with analyze_correlation. plan_id=eval-planner-2026-10-22-bad-actor-influence project_id=eval-planner
46
+ 2026-11-11 09:05:05 [info ] analysis planned n_tasks=2 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}, {'id': 't2', 'depends_on': ['t1'], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-ema-badactor-20261022 project_id=eval-planner retry=True
47
+ [PASS] driver_grouped_id id 5339ms 41820tok
48
+ 2026-11-11 09:05:05 [info ] weekly twins hidden n_hidden=1
49
+ 2026-11-11 09:05:08 [warning ] planner validation failed attempt=1 error=task t2: analyze_importance reads an unfiltered raw pull of 'ema_monthly_badactor' (21,124 rows), which is over the 10,000-row budget — it would see only a capped, ordered slice, where the target may never vary. Compare the groups in the query instead: for a flag or category target, one retrieve_data that groups by the outcome column and averages each candidate measure (plus a count). For a numeric target, filter the pull to the slice the question is about, or correlate two named columns with analyze_correlation. plan_id=eval-planner-2026-10-22-bad-actor-importance project_id=eval-planner
50
+ 2026-11-11 09:05:10 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-22-bad-actor-importance project_id=eval-planner retry=True
51
+ [PASS] driver_grouped_en en 5266ms 41647tok
52
+ 2026-11-11 09:05:10 [info ] weekly twins hidden n_hidden=1
53
+ 2026-11-11 09:05:15 [info ] analysis planned n_tasks=3 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}, {'id': 't2', 'depends_on': ['t1'], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}, {'id': 't3', 'depends_on': ['t1', 't2'], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-22-wst-bad-actor project_id=eval-planner retry=False
54
+ [PASS] empty_site_not_infeasible_id id 5166ms 21409tok
55
+ 2026-11-11 09:05:15 [info ] weekly twins hidden n_hidden=1
56
+ 2026-11-11 09:05:17 [info ] repaired ir id from_id= task_id=t1 to_id=count:n_periods where=select[2]
57
+ 2026-11-11 09:05:17 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-22-bad-actor-months project_id=eval-planner retry=False
58
+ [PASS] ranking_singular_no_limit1_en en 2273ms 20690tok
59
+ 2026-11-11 09:05:17 [info ] weekly twins hidden n_hidden=1
60
+ 2026-11-11 09:05:21 [warning ] planner validation failed attempt=1 error=task t1: a grouped, ordered ranking is cut to `limit: 1`, which hides entities tied at the top. Use the user's N, or 10 when they gave none, so the answer can say who shares the top value. plan_id=eval-planner-2026-10-22-001 project_id=eval-planner
61
+ 2026-11-11 09:05:25 [info ] analysis planned n_tasks=3 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}, {'id': 't2', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}, {'id': 't3', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-22 project_id=eval-planner retry=True
62
+ [PASS] ranking_three_levels_id id 7209ms 42490tok
63
+ 2026-11-11 09:05:25 [info ] weekly twins hidden n_hidden=1
64
+ 2026-11-11 09:05:27 [info ] computed measure applied selects=['avg_pa'] task_id=t1
65
+ 2026-11-11 09:05:27 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-22-pa-low-equipment-2026 project_id=eval-planner retry=False
66
+ [PASS] rank_lowest_pa_in_query_id id 2130ms 20678tok
67
+ 2026-11-11 09:05:27 [info ] weekly twins hidden n_hidden=1
68
+ 2026-11-11 09:05:30 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-22 project_id=eval-planner retry=False
69
+ [PASS] entity_rows_carry_period_en en 3581ms 20668tok
70
+ 2026-11-11 09:05:30 [info ] weekly twins hidden n_hidden=1
71
+ 2026-11-11 09:05:37 [info ] scope_id_filter_replaced extra={'column': 'site_code', 'table': 'ema_monthly_badactor'}
72
+ 2026-11-11 09:05:37 [info ] turn period applied extra={'window': '2026', 'origin': 'objective'}
73
+ 2026-11-11 09:05:37 [info ] scope resolved extra={'notes': ["message site_code: ['WST']"], 'base': 'focus'}
74
+ 2026-11-11 09:05:37 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-22-wst-bad-actor project_id=eval-planner retry=False
75
+ [PASS] ctx_bare_site_code_followup_id id 6329ms 20766tok
76
+ 2026-11-11 09:05:37 [info ] weekly twins hidden n_hidden=1
77
+ 2026-11-11 09:05:43 [info ] computed measure applied selects=['avg_pa'] task_id=t1
78
+ 2026-11-11 09:05:43 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-27 project_id=eval-planner retry=False
79
+ [PASS] rank_shows_coverage_id id 5949ms 20728tok
80
+ 2026-11-11 09:05:43 [info ] weekly twins hidden n_hidden=1
81
+ 2026-11-11 09:05:49 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-27-mttr-top-units project_id=eval-planner retry=False
82
+ [PASS] rank_shows_coverage_en en 6291ms 20736tok
83
+ 2026-11-11 09:05:49 [info ] weekly twins hidden n_hidden=1
84
+ 2026-11-11 09:05:56 [warning ] planner validation failed attempt=1 error=task t1: retrieve_data IR failed catalog validation: select[0].column_id: 'c_ema_lag_is_bad_actor' not in table (known: ['c_ema_lag_breakdown_duration_total', 'c_ema_lag_eqm_no', 'c_ema_lag_model', 'c_ema_lag_month', 'c_ema_lag_mttr', 'c_ema_lag_pa', 'c_ema_lag_section', 'c_ema_lag_site_id', 'c_ema_lag_total_inpr_hour', 'c_ema_lag_year']) plan_id=eval-planner-2026-10-27-bad-actor-diff project_id=eval-planner
85
+ 2026-11-11 09:06:02 [warning ] planner validation failed attempt=2 error=task t1: retrieve_data IR failed catalog validation: joins[0]: ('c_ema_lag_site_id', 'c_ema_ba_site_id') is not a declared foreign key in source 'src_ema' — only FK-backed joins are allowed plan_id=eval-planner-20261027-badactor-diff project_id=eval-planner
86
+ 2026-11-11 09:06:08 [info ] analysis planned n_tasks=2 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}, {'id': 't2', 'depends_on': ['t1'], 'tools': [{'tool': 'analyze_aggregate', 'args': ['aggregations', 'data', 'group_by'], 'cols': None, 'data': '${t1}'}]}] plan_id=eval-planner-2026-10-27-bad-actor-diff project_id=eval-planner retry=True
87
+ [PASS] driver_no_derived_id id 19233ms 63248tok
88
+ 2026-11-11 09:06:08 [info ] weekly twins hidden n_hidden=1
89
+ 2026-11-11 09:06:14 [info ] repaired ir id from_id=c_ema_ba_pa task_id=t1 to_id=c_ema_ba_ma where=select[1].column_id
90
+ 2026-11-11 09:06:14 [warning ] planner validation failed attempt=1 error=task t1: column 'ma' is aliased as 'pa', which is the name of a DIFFERENT column in this source. Alias to a name that is not already taken, or select the column that is actually called 'pa'. plan_id=eval-planner-2026-10-27-bad-actor-diff project_id=eval-planner
91
+ 2026-11-11 09:06:21 [warning ] planner validation failed attempt=2 error=task t2: analyze_ab_test reads a period-filtered raw pull of 'ema_monthly_badactor' (21,124 rows), which is over the 10,000-row budget — it would see only a capped, ordered slice, where the target may never vary. Compare the groups in the query instead: for a flag or category target, one retrieve_data that groups by the outcome column and averages each candidate measure (plus a count). For a numeric target, filter the pull to the slice the question is about, or correlate two named columns with analyze_correlation. plan_id=eval-planner-2026-10-27-bad-actor-vs-rest project_id=eval-planner
92
+ 2026-11-11 09:06:27 [info ] analysis planned n_tasks=2 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}, {'id': 't2', 'depends_on': ['t1'], 'tools': [{'tool': 'analyze_aggregate', 'args': ['aggregations', 'ascending', 'data', 'group_by', 'sort_by'], 'cols': None, 'data': '${t1}'}]}] plan_id=eval-planner-20261027-badactor-diff project_id=eval-planner retry=True
93
+ [PASS] driver_no_derived_en en 18372ms 63251tok
94
+ 2026-11-11 09:06:27 [info ] weekly twins hidden n_hidden=1
95
+ 2026-11-11 09:06:32 [info ] repaired ir id from_id= task_id=t1 to_id=count:n_periods where=select[2]
96
+ 2026-11-11 09:06:32 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-28-mttr-top-unit-jan-jun-2026 project_id=eval-planner retry=False
97
+ [PASS] grain_month_range_id id 5549ms 20663tok
98
+ 2026-11-11 09:06:32 [info ] weekly twins hidden n_hidden=1
99
+ 2026-11-11 09:06:38 [info ] computed measure applied selects=['avg_pa'] task_id=t1
100
+ 2026-11-11 09:06:38 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-10-28 project_id=eval-planner retry=False
101
+ [PASS] grain_monthly_trend_en en 5484ms 20694tok
102
+ 2026-11-11 09:06:44 [info ] analysis planned n_tasks=2 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}, {'id': 't2', 'depends_on': ['t1'], 'tools': [{'tool': 'analyze_trend', 'args': ['agg', 'data', 'date_column', 'freq', 'value_column'], 'cols': None, 'data': '${t1}'}]}] plan_id=eval-planner-2026-10-28-mttr-weekly-section project_id=eval-planner retry=False
103
+ [FAIL] grain_weekly_asked_id id 6401ms 21344tok
104
+ ✗ group_by_cols=['week'] (grouped=[])
105
+ 2026-11-11 09:06:50 [info ] computed measure applied selects=['avg_pa'] task_id=t1
106
+ 2026-11-11 09:06:50 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner_2026-10-28_lowest_weekly_pa_last_4_weeks project_id=eval-planner retry=False
107
+ [PASS] grain_weekly_asked_en en 6009ms 21279tok
108
+ 2026-11-11 09:06:50 [info ] weekly twins hidden n_hidden=1
109
+ 2026-11-11 09:06:57 [info ] scope_id_filter_replaced extra={'column': 'site_code', 'table': 'ema_monthly_lagging_indicator'}
110
+ 2026-11-11 09:06:57 [info ] turn period applied extra={'window': '2026', 'origin': 'objective'}
111
+ 2026-11-11 09:06:57 [warning ] planner validation failed attempt=1 error=task t2: analyze_segmentation bands raw rows of a table with one row per period, so each entity is counted once per period. Group the upstream retrieve_data by the entity and average the measure first, then band; select the period column only if the user asks about periods. plan_id=eval-planner-2026-11-10 project_id=eval-planner
112
+ 2026-11-11 09:07:03 [info ] scope_id_filter_replaced extra={'column': 'site_code', 'table': 'ema_monthly_lagging_indicator'}
113
+ 2026-11-11 09:07:03 [info ] turn period applied extra={'window': '2026', 'origin': 'objective'}
114
+ 2026-11-11 09:07:03 [warning ] planner validation failed attempt=2 error=task t2: analyze_segmentation bands raw rows of a table with one row per period, so each entity is counted once per period. Group the upstream retrieve_data by the entity and average the measure first, then band; select the period column only if the user asks about periods. plan_id=eval-planner-2026-11-10 project_id=eval-planner
115
+ 2026-11-11 09:07:09 [info ] scope_id_filter_replaced extra={'column': 'site_code', 'table': 'ema_monthly_lagging_indicator'}
116
+ 2026-11-11 09:07:09 [info ] turn period applied extra={'window': '2026', 'origin': 'objective'}
117
+ 2026-11-11 09:07:09 [warning ] planner validation failed attempt=3 error=task t2: analyze_segmentation bands raw rows of a table with one row per period, so each entity is counted once per period. Group the upstream retrieve_data by the entity and average the measure first, then band; select the period column only if the user asks about periods. plan_id=eval-planner-20261110-001 project_id=eval-planner
118
+ 2026-11-11 09:07:09 [error ] planner exhausted retries — degrading to infeasible attempts=3 degraded_seam=planner_exhausted errors=['attempt 1: task t2: analyze_segmentation bands raw rows of a table with one row per period, so each entity is counted once per period. Group the upstream retrieve_data by the entity and average the measure first, then band; select the period column only if the user asks about periods.', 'attempt 2: task t2: analyze_segmentation bands raw rows of a table with one row per period, so each entity is counted once per period. Group the upstream retrieve_data by the entity and average the measure first, then band; select the period column only if the user asks about periods.', 'attempt 3: task t2: analyze_segmentation bands raw rows of a table with one row per period, so each entity is counted once per period. Group the upstream retrieve_data by the entity and average the measure first, then band; select the period column only if the user asks about periods.'] project_id=eval-planner
119
+ [FAIL] ratio_bands_dont_avg_id id 19524ms 63151tok
120
+ ✗ feasible (infeasible=True)
121
+ ✗ ir_agg_col=avg(pa) (ir_aggs=[])
122
+ ✗ group_by_cols=['eqm_no'] (grouped=[])
123
+ 2026-11-11 09:07:09 [info ] weekly twins hidden n_hidden=1
124
+ 2026-11-11 09:07:15 [info ] scope_id_filter_replaced extra={'column': 'site_code', 'table': 'ema_monthly_lagging_indicator'}
125
+ 2026-11-11 09:07:15 [info ] turn period applied extra={'window': '2026', 'origin': 'question'}
126
+ 2026-11-11 09:07:15 [info ] scope resolved extra={'notes': ["message section: ['OB LOADER']"], 'base': 'focus'}
127
+ 2026-11-11 09:07:15 [info ] computed measure applied selects=['avg_pa'] task_id=t1
128
+ 2026-11-11 09:07:15 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-20261110-001 project_id=eval-planner retry=False
129
+ [PASS] ratio_trend_dont_avg_id id 5426ms 20831tok
130
+ 2026-11-11 09:07:15 [info ] weekly twins hidden n_hidden=1
131
+ 2026-11-11 09:07:21 [info ] scope_id_filter_replaced extra={'column': 'site_code', 'table': 'ema_monthly_lagging_indicator'}
132
+ 2026-11-11 09:07:21 [info ] turn period applied extra={'window': '2026', 'origin': 'question'}
133
+ 2026-11-11 09:07:21 [info ] scope resolved extra={'notes': ["message section: ['OB LOADER']", "message site_code: ['NRT', 'SLT']"], 'base': 'focus'}
134
+ 2026-11-11 09:07:21 [info ] analysis planned n_tasks=2 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}, {'id': 't2', 'depends_on': ['t1'], 'tools': [{'tool': 'analyze_ab_test', 'args': ['agg', 'data', 'dimension', 'group_a', 'group_b', 'value_column'], 'cols': None, 'data': '${t1}'}]}] plan_id=eval-planner-2026-11-10-ob-loader-pa-nrt-slt project_id=eval-planner retry=False
135
+ [FAIL] ratio_compare_sites_dont_avg_en en 6379ms 20911tok
136
+ ✗ ir_agg_col=avg(pa) (ir_aggs=[])
137
+ ✗ ir_group_by (ir_grouped=False)
138
+ 2026-11-11 09:07:21 [info ] weekly twins hidden n_hidden=1
139
+ 2026-11-11 09:07:27 [info ] scope_id_filter_replaced extra={'column': 'site_code', 'table': 'ema_monthly_lagging_indicator'}
140
+ 2026-11-11 09:07:27 [info ] turn period applied extra={'window': '2026', 'origin': 'relative'}
141
+ 2026-11-11 09:07:27 [info ] scope resolved extra={'notes': ["message section: ['OB LOADER']"], 'base': 'focus'}
142
+ 2026-11-11 09:07:27 [info ] computed measure applied selects=['avg_pa'] task_id=t1
143
+ 2026-11-11 09:07:27 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-20261110-001 project_id=eval-planner retry=False
144
+ [PASS] ratio_lowest_unit_dont_avg_en en 5690ms 20832tok
145
+ 2026-11-11 09:07:27 [info ] weekly twins hidden n_hidden=1
146
+ 2026-11-11 09:07:33 [info ] analysis planned n_tasks=1 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}] plan_id=eval-planner-2026-11-11-ob-loader-mttr-highest-this-year project_id=eval-planner retry=False
147
+ [PASS] grain_no_period_word_id id 5718ms 20706tok
148
+ 2026-11-11 09:07:33 [info ] weekly twins hidden n_hidden=1
149
+ 2026-11-11 09:07:39 [info ] analysis planned n_tasks=2 plan=[{'id': 't1', 'depends_on': [], 'tools': [{'tool': 'retrieve_data', 'args': ['ir'], 'cols': None, 'data': None}]}, {'id': 't2', 'depends_on': ['t1'], 'tools': [{'tool': 'analyze_trend', 'args': ['agg', 'data', 'date_column', 'freq', 'value_column'], 'cols': None, 'data': '${t1}'}]}] plan_id=eval-planner-2026-11-11-ob-loader-mttr-trend project_id=eval-planner retry=False
150
+ [PASS] grain_no_period_word_en en 6447ms 20785tok
151
+
152
+ ============================================================
153
+ OVERALL: 26/31 passed (84%)
154
+ FIRST ATTEMPT: 21/31
155
+
156
+ by category:
157
+ analysis_context 4/4 (first attempt 4/4)
158
+ cause_table 2/2 (first attempt 2/2)
159
+ code_lookup 2/2 (first attempt 2/2)
160
+ driver_grouped 2/2 (first attempt 0/2)
161
+ driver_no_leakage 2/2 (first attempt 0/2)
162
+ empty_not_infeasible 1/1 (first attempt 1/1)
163
+ period_columns 1/1 (first attempt 1/1)
164
+ ranking_coverage 2/2 (first attempt 2/2)
165
+ ranking_levels 1/1 (first attempt 0/1)
166
+ ranking_ties 1/1 (first attempt 1/1)
167
+ ratio_measure 2/4 (first attempt 2/4)
168
+ raw_pull_guard 1/1 (first attempt 1/1)
169
+ relative_period 0/2 (first attempt 0/2)
170
+ time_grain 5/6 (first attempt 5/6)
171
+
172
+ regression guard:
173
+ carried_over (must stay green) 0/0
174
+ new (target bugs) 26/31 (first attempt 21/31)
175
+
176
+ latency ms: median=5426 max=19524
177
+ tokens total: 836529
178
+ prompt cache: 764416/815235 input tokens served from cache (94%)
179
+
180
+ wrote C:\Users\RifqiHafizuddin\Documents\Dataeyond_catalog\Agentic-Service-Data-Eyond-Catalog\eval\planner\results\planner_result_2026-11-11_090437.json
eval/planner/results/planner_result_2026-11-11_090437.json ADDED
The diff for this file is too large to render. See raw diff
 
eval/planner/run_eval.py CHANGED
@@ -44,6 +44,7 @@ from ..run_log import run_stamp, start_run_log
44
  from .catalog_fixture import (
45
  build_db_catalog,
46
  build_db_dict_catalog,
 
47
  build_ema_catalog,
48
  build_pa_catalog,
49
  db_name_to_id,
@@ -291,6 +292,15 @@ def evaluate_facts(f: dict, expect: dict, n2id: dict[str, str]) -> list[tuple[st
291
  for v in (flt.get("value") if isinstance(flt.get("value"), list)
292
  else [flt.get("value")])]
293
  res.append((f"no_filter_value={want}", want not in got, f"values={got}"))
 
 
 
 
 
 
 
 
 
294
  elif key == "selects_name": # a select references a column with this NAME
295
  got = [_id2name().get(c, c) for c in _selected_col_ids(f)]
296
  res.append((f"selects_name={want}", want in got, f"selected={got}"))
@@ -434,11 +444,20 @@ async def _run_case(catalog: Any, tools: Any, n2id: dict, case: dict, **plan_kw:
434
  # A case may carry the analysis objective (2026-10-22) and a fixed `today`; every
435
  # other case keeps the plain context. `today` defaults to the real date, which the
436
  # live planner also sees.
437
- context = _EMA_CONTEXT if case.get("catalog") == "ema" else _CONTEXT
438
  if case.get("objective"):
439
  context = context.model_copy(update={"analysis_objective": case["objective"]})
440
  if case.get("today"):
441
  plan_kw["today"] = date.fromisoformat(case["today"])
 
 
 
 
 
 
 
 
 
442
  tok = _TokenCounter()
443
  started = time.perf_counter()
444
  facts: dict = {}
@@ -513,7 +532,11 @@ async def main() -> None:
513
  "db_dict": build_db_dict_catalog(),
514
  "db_dict_injected": build_db_dict_catalog(injected=True),
515
  # 2026-10-22: EMA-shaped DB (large tables, site lookup, pareto causes).
516
- "ema": build_ema_catalog(),
 
 
 
 
517
  }
518
  tools, n2id = default_registry(), _all_name_to_id()
519
 
@@ -572,6 +595,7 @@ def _summarize(results: list[CaseResult]) -> None:
572
  print("\n" + "=" * 60)
573
  if total:
574
  print(f"OVERALL: {passed}/{total} passed ({passed / total:.0%})")
 
575
  else:
576
  print("no scored cases")
577
  if skipped:
@@ -581,7 +605,10 @@ def _summarize(results: list[CaseResult]) -> None:
581
 
582
  def rate(subset: list[CaseResult]) -> str:
583
  subset = [r for r in subset if not r.skipped]
584
- return f"{sum(r.passed for r in subset)}/{len(subset)}" if subset else "0/0"
 
 
 
585
 
586
  results = scored
587
  cats = sorted({r.category for r in results})
@@ -602,6 +629,24 @@ def _summarize(results: list[CaseResult]) -> None:
602
  f"({cached / prompt_in:.0%})")
603
 
604
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
  def _write(results: list[CaseResult], dataset: dict, stamp: str | None = None) -> None:
606
  RESULTS_DIR.mkdir(exist_ok=True)
607
  ts = stamp or datetime.now().strftime("%Y-%m-%d_%H%M%S")
@@ -613,6 +658,8 @@ def _write(results: list[CaseResult], dataset: dict, stamp: str | None = None) -
613
  # runs taken with different tools active; `skipped` records what was withheld.
614
  "total": len(scored),
615
  "passed": sum(r.passed for r in scored),
 
 
616
  "skipped": len(results) - len(scored),
617
  "cases": [asdict(r) for r in results],
618
  }
@@ -653,6 +700,7 @@ def _rescore(path: Path) -> None:
653
  r = CaseResult(
654
  id=c["id"], category=c["category"], lang=c["lang"], carried_over=c["carried_over"],
655
  question=c["question"], passed=passed, checks=checks, facts=facts, error=c.get("error"),
 
656
  )
657
  mark = "PASS" if r.passed else ("ERR " if r.error else "FAIL")
658
  print(f"[{mark}] {r.id:<28} {r.lang}")
@@ -753,6 +801,20 @@ def _selfcheck() -> None:
753
  assert ev(site_ok, {"no_limit": 1, "ir_group_by": False})
754
  assert not ev(plan([[("retrieve_data", {"ir": {"limit": 1, "select": []}})]]), {"no_limit": 1})
755
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
756
  print("selfcheck OK — scorer distinguishes good vs buggy plans (agg paths + column + chart)")
757
 
758
 
 
44
  from .catalog_fixture import (
45
  build_db_catalog,
46
  build_db_dict_catalog,
47
+ bind_ema_pa,
48
  build_ema_catalog,
49
  build_pa_catalog,
50
  db_name_to_id,
 
292
  for v in (flt.get("value") if isinstance(flt.get("value"), list)
293
  else [flt.get("value")])]
294
  res.append((f"no_filter_value={want}", want not in got, f"values={got}"))
295
+ elif key == "ir_agg_col": # A2: the measure is aggregated IN the query
296
+ # {"col": "pa", "fn": "avg"}: some retrieve_data select is an agg with this
297
+ # fn on a column with this NAME (not a tool over raw rows, not bare).
298
+ syn = _AGG_SYN.get(want["fn"], {want["fn"]})
299
+ got = [(_id2name().get(sel.get("column_id"), sel.get("column_id")), sel.get("fn"))
300
+ for ir in irs for sel in ir.get("select", [])
301
+ if isinstance(sel, dict) and sel.get("kind") == "agg"]
302
+ ok = any(n == want["col"] and fn in syn for n, fn in got)
303
+ res.append((f"ir_agg_col={want['fn']}({want['col']})", ok, f"ir_aggs={got}"))
304
  elif key == "selects_name": # a select references a column with this NAME
305
  got = [_id2name().get(c, c) for c in _selected_col_ids(f)]
306
  res.append((f"selects_name={want}", want in got, f"selected={got}"))
 
444
  # A case may carry the analysis objective (2026-10-22) and a fixed `today`; every
445
  # other case keeps the plain context. `today` defaults to the real date, which the
446
  # live planner also sees.
447
+ context = _EMA_CONTEXT if str(case.get("catalog", "")).startswith("ema") else _CONTEXT
448
  if case.get("objective"):
449
  context = context.model_copy(update={"analysis_objective": case["objective"]})
450
  if case.get("today"):
451
  plan_kw["today"] = date.fromisoformat(case["today"])
452
+ # C1 (2026-11-11): as the live coordinator does, a weekly table with a monthly twin
453
+ # is left out unless the question or the analysis context names weeks. A no-op on
454
+ # every fixture without twins.
455
+ from src.catalog.twins import hide_weekly_twins
456
+
457
+ catalog = hide_weekly_twins(
458
+ catalog, case["question"], context.analysis_objective,
459
+ *(context.business_questions or []),
460
+ )
461
  tok = _TokenCounter()
462
  started = time.perf_counter()
463
  facts: dict = {}
 
532
  "db_dict": build_db_dict_catalog(),
533
  "db_dict_injected": build_db_dict_catalog(injected=True),
534
  # 2026-10-22: EMA-shaped DB (large tables, site lookup, pareto causes).
535
+ # A2 (2026-11-10): PA's approved definition is bound, as live, so M4/M7′/F3
536
+ # and the code-made method line act here too. `ema_dont_avg` carries the
537
+ # 9 Nov `pa` text that told the planner not to average.
538
+ "ema": bind_ema_pa(build_ema_catalog()),
539
+ "ema_dont_avg": bind_ema_pa(build_ema_catalog(dont_average=True)),
540
  }
541
  tools, n2id = default_registry(), _all_name_to_id()
542
 
 
595
  print("\n" + "=" * 60)
596
  if total:
597
  print(f"OVERALL: {passed}/{total} passed ({passed / total:.0%})")
598
+ print(f"FIRST ATTEMPT: {_first_attempt(scored)}")
599
  else:
600
  print("no scored cases")
601
  if skipped:
 
605
 
606
  def rate(subset: list[CaseResult]) -> str:
607
  subset = [r for r in subset if not r.skipped]
608
+ if not subset:
609
+ return "0/0"
610
+ passed = sum(r.passed for r in subset)
611
+ return f"{passed}/{len(subset)} (first attempt {_first_attempt(subset)})"
612
 
613
  results = scored
614
  cats = sorted({r.category for r in results})
 
629
  f"({cached / prompt_in:.0%})")
630
 
631
 
632
+ def _passed_first(r: CaseResult) -> bool:
633
+ """Passed, on the planner's first attempt (A2, 2026-11-10).
634
+
635
+ The final score hides retries: a case accepted on attempt 3 passes like one
636
+ accepted on attempt 1, though a live turn spent two extra planner calls and was
637
+ one slip from a refusal. A run records every attempt (since 2026-09-23).
638
+ """
639
+ return r.passed and len(r.attempts) == 1 and r.attempts[0].get("error") is None
640
+
641
+
642
+ def _first_attempt(subset: list[CaseResult]) -> str:
643
+ recorded = [r for r in subset if r.attempts]
644
+ if not recorded:
645
+ return "n/a (no attempts recorded)"
646
+ n = sum(_passed_first(r) for r in recorded)
647
+ return f"{n}/{len(recorded)}"
648
+
649
+
650
  def _write(results: list[CaseResult], dataset: dict, stamp: str | None = None) -> None:
651
  RESULTS_DIR.mkdir(exist_ok=True)
652
  ts = stamp or datetime.now().strftime("%Y-%m-%d_%H%M%S")
 
658
  # runs taken with different tools active; `skipped` records what was withheld.
659
  "total": len(scored),
660
  "passed": sum(r.passed for r in scored),
661
+ # A2 (2026-11-10): passed with no retry; the final `passed` hides retries.
662
+ "first_attempt_passed": sum(_passed_first(r) for r in scored),
663
  "skipped": len(results) - len(scored),
664
  "cases": [asdict(r) for r in results],
665
  }
 
700
  r = CaseResult(
701
  id=c["id"], category=c["category"], lang=c["lang"], carried_over=c["carried_over"],
702
  question=c["question"], passed=passed, checks=checks, facts=facts, error=c.get("error"),
703
+ attempts=c.get("attempts") or [],
704
  )
705
  mark = "PASS" if r.passed else ("ERR " if r.error else "FAIL")
706
  print(f"[{mark}] {r.id:<28} {r.lang}")
 
801
  assert ev(site_ok, {"no_limit": 1, "ir_group_by": False})
802
  assert not ev(plan([[("retrieve_data", {"ir": {"limit": 1, "select": []}})]]), {"no_limit": 1})
803
 
804
+ # A2 (2026-11-10): `ir_agg_col` — PA aggregated in the query, not in a tool.
805
+ pa_exp = {"ir_agg_col": {"col": "pa", "fn": "avg"}}
806
+ assert ev(site_guess, pa_exp), "avg(pa) in the IR should pass"
807
+ pa_in_tool = plan([
808
+ [("retrieve_data", {"ir": {"table_id": "t_ema_lag", "select": [
809
+ {"kind": "column", "column_id": "c_ema_lag_section", "alias": "section"},
810
+ {"kind": "column", "column_id": "c_ema_lag_pa", "alias": "pa"}]}})],
811
+ [("analyze_aggregate", {"group_by": ["section"], "aggregations": {"pa": ["mean"]}})],
812
+ ])
813
+ assert not ev(pa_in_tool, pa_exp), "a tool average over raw pa rows should fail"
814
+ assert not ev(plan([[("retrieve_data", {"ir": {"select": [
815
+ {"kind": "agg", "fn": "avg", "column_id": "c_ema_lag_mttr"}]}})]]), pa_exp), \
816
+ "avg of another column should fail"
817
+
818
  print("selfcheck OK — scorer distinguishes good vs buggy plans (agg paths + column + chart)")
819
 
820
 
src/agents/chat_handler.py CHANGED
@@ -710,6 +710,25 @@ class ChatHandler:
710
  )
711
  return ""
712
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
713
  async def _document_context(self, user_id: str, query: str, catalog, pad) -> str:
714
  """Retrieve-then-plan (PR23 part 2 E1): excerpts from the analysis's bound
715
  documents that bear on this question, rendered for the planner + assembler.
@@ -912,6 +931,7 @@ class ChatHandler:
912
  # builder. Empty string when the company has no reviewed knowledge, so a
913
  # scope that has never been reviewed plans exactly as it did before.
914
  domain_card = await self._domain_card(user_id, catalog, pad)
 
915
  document_context = await self._document_context(user_id, query, catalog, pad)
916
 
917
  # planner: PII-safe, full capture — unless document excerpts ride the prompt
 
710
  )
711
  return ""
712
 
713
+ async def _bind_computed_measures(self, user_id: str, catalog) -> None:
714
+ """Mark catalog columns whose measure is computed from summed inputs (M3,
715
+ RATIO_MEASURES_REQUIREMENTS, 2026-11-09) — e.g. PA from summed hours.
716
+
717
+ Never-throw, like the domain card: without it a turn plans exactly as before
718
+ (plain `avg`). The context is the cached card; binding is in place on this
719
+ turn's catalog, which is rebuilt per read.
720
+ """
721
+ try:
722
+ from ..knowledge_domain.computed import bind_computed_measures
723
+
724
+ context = await self._get_knowledge_service().get_context(user_id)
725
+ bind_computed_measures(context.measures, catalog)
726
+ except Exception as e:
727
+ logger.warning(
728
+ "computed_measures_unavailable",
729
+ extra={"error": repr(e), "degraded_seam": "computed_measures"},
730
+ )
731
+
732
  async def _document_context(self, user_id: str, query: str, catalog, pad) -> str:
733
  """Retrieve-then-plan (PR23 part 2 E1): excerpts from the analysis's bound
734
  documents that bear on this question, rendered for the planner + assembler.
 
931
  # builder. Empty string when the company has no reviewed knowledge, so a
932
  # scope that has never been reviewed plans exactly as it did before.
933
  domain_card = await self._domain_card(user_id, catalog, pad)
934
+ await self._bind_computed_measures(user_id, catalog)
935
  document_context = await self._document_context(user_id, query, catalog, pad)
936
 
937
  # planner: PII-safe, full capture — unless document excerpts ride the prompt
src/agents/orchestration.py CHANGED
@@ -144,5 +144,12 @@ class OrchestratorAgent:
144
  )
145
  else:
146
  decision = await chain.ainvoke(payload)
147
- logger.info("intent classified", intent=decision.intent)
 
 
 
 
 
 
 
148
  return decision
 
144
  )
145
  else:
146
  decision = await chain.ainvoke(payload)
147
+ # group_hint + confidence logged too (A8, 2026-11-10): the hint narrows the
148
+ # planner's tool list and was never visible in a log.
149
+ logger.info(
150
+ "intent classified",
151
+ intent=decision.intent,
152
+ confidence=decision.confidence,
153
+ group_hint=decision.group_hint,
154
+ )
155
  return decision
src/agents/planner/inputs.py CHANGED
@@ -108,6 +108,18 @@ def _dictionary_line(col: Any) -> str | None:
108
  return " ".join(str(value).split()) if value is not None else ""
109
 
110
  parts: list[str] = []
 
 
 
 
 
 
 
 
 
 
 
 
111
  meaning = "; ".join(
112
  dict.fromkeys( # dedupe when description == business_meaning
113
  t for t in (_clean(getattr(col, "description", None)),
@@ -123,7 +135,7 @@ def _dictionary_line(col: Any) -> str | None:
123
  if role:
124
  parts.append(f"role: {role}")
125
  agg = _clean(getattr(col, "aggregation", None))
126
- if agg:
127
  parts.append(f"aggregate with: {agg}")
128
  if getattr(col, "is_key", False):
129
  parts.append("key column")
@@ -367,3 +379,7 @@ class Constraints(BaseModel):
367
  token_budget: int | None = None
368
  time_budget_seconds: int | None = None
369
  row_budget: int = 10_000
 
 
 
 
 
108
  return " ".join(str(value).split()) if value is not None else ""
109
 
110
  parts: list[str] = []
111
+ # A ratio measure (bound from the approved formula, `knowledge_domain/computed`)
112
+ # states its method FIRST, written by code (2026-11-10). The planner must write
113
+ # `avg` for the query to compute it from summed inputs, and a description that
114
+ # says "don't average it" — true of the business rule — otherwise leaves it no
115
+ # shape it can write (PLANNER_SHAPE_FIXES_HANDOFF §2). First, so the length cap
116
+ # below can only cut the description.
117
+ ratio = (getattr(col, "measure_method", None) or {}).get("kind") == "ratio"
118
+ if ratio:
119
+ parts.append(
120
+ "aggregate with: avg (the query computes it from the summed inputs, "
121
+ "not from the per-row values)"
122
+ )
123
  meaning = "; ".join(
124
  dict.fromkeys( # dedupe when description == business_meaning
125
  t for t in (_clean(getattr(col, "description", None)),
 
135
  if role:
136
  parts.append(f"role: {role}")
137
  agg = _clean(getattr(col, "aggregation", None))
138
+ if agg and not ratio:
139
  parts.append(f"aggregate with: {agg}")
140
  if getattr(col, "is_key", False):
141
  parts.append("key column")
 
379
  token_budget: int | None = None
380
  time_budget_seconds: int | None = None
381
  row_budget: int = 10_000
382
+ # The user's own words asked for a plain (unweighted) average, so measures that
383
+ # are normally computed from summed inputs stay plain averages this turn (U10,
384
+ # RATIO_MEASURES_REQUIREMENTS M8). Set by the coordinator; never rendered.
385
+ plain_average: bool = False
src/agents/planner/validator.py CHANGED
@@ -84,6 +84,7 @@ class PlannerValidator:
84
  registry: ToolRegistry,
85
  catalog: Catalog,
86
  constraints: Constraints,
 
87
  ) -> None:
88
  tasks = task_list.tasks
89
 
@@ -288,6 +289,74 @@ class PlannerValidator:
288
  # Check 8k — a ranking the user reads is not cut to one row (2026-10-22).
289
  _reject_top1_rankings(tasks_by_id)
290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  def _validate_inline_ir(self, task_id: str, args: dict, catalog: Catalog) -> None:
292
  raw_ir = args.get("ir")
293
  if not isinstance(raw_ir, dict):
@@ -318,6 +387,23 @@ class PlannerValidator:
318
  to_id=r.to_id,
319
  )
320
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  try:
322
  self._ir_validator.validate(ir, catalog)
323
  except IRValidationError as e:
@@ -329,6 +415,7 @@ class PlannerValidator:
329
  _reject_unmatchable_filter_values(task_id, ir, catalog)
330
  _reject_typed_lookup_ids(task_id, ir, catalog)
331
  _reject_raw_periodic_ranking(task_id, ir, catalog)
 
332
 
333
  @staticmethod
334
  def _validate_data_source(
@@ -673,6 +760,101 @@ def _reject_raw_periodic_banding(
673
  )
674
 
675
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
676
  def _reject_misleading_aliases(task_id: str, ir: QueryIR, catalog: Catalog) -> None:
677
  """An alias may not rename a column into some OTHER thing that exists here.
678
 
@@ -948,6 +1130,231 @@ def _upstream_select_names(call, tasks_by_id: dict, catalog: Catalog) -> set[str
948
  return names or None
949
 
950
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
951
  def _upstream_column(call, tasks_by_id: dict, catalog: Catalog):
952
  """The catalog column the `data` retrieve selected as `date_column`, else None.
953
 
 
84
  registry: ToolRegistry,
85
  catalog: Catalog,
86
  constraints: Constraints,
87
+ convert_ratio: bool = True,
88
  ) -> None:
89
  tasks = task_list.tasks
90
 
 
289
  # Check 8k — a ranking the user reads is not cut to one row (2026-10-22).
290
  _reject_top1_rankings(tasks_by_id)
291
 
292
+ # Measures computed from summed inputs (2026-11-09, RATIO_MEASURES_REQUIREMENTS
293
+ # M4 + M7′). Skipped when the user asked for a plain average (M8, U10).
294
+ if not getattr(constraints, "plain_average", False):
295
+ # Last, after every check passed: the planner's `avg(pa)` is computed from
296
+ # summed hours. Fail-open, never re-prompts.
297
+ self._mark_computed(tasks, catalog)
298
+ if convert_ratio:
299
+ # M7′ — a tool-side average of a ratio measure over raw rows becomes
300
+ # one grouped query in the same slot. Fail-open, never re-prompts.
301
+ self._convert_ratio_aggregates(task_list, registry, catalog, constraints)
302
+
303
+ def _convert_ratio_aggregates(
304
+ self, task_list: TaskList, registry: ToolRegistry, catalog: Catalog,
305
+ constraints: Constraints,
306
+ ) -> None:
307
+ """Swap in the M7′ conversion when the converted plan passes EVERY check.
308
+
309
+ Built on a deep copy and re-validated end to end (M4 included); on any failure
310
+ the planner's plan stays as it is — a plain average, as before M7′. Never
311
+ raises (§5.4): a slip costs the method, never the answer.
312
+ """
313
+ try:
314
+ candidate = task_list.model_copy(deep=True)
315
+ converted = [
316
+ *_convert_ratio_aggregates(candidate, catalog),
317
+ *_convert_ratio_trends(candidate, catalog), # U5, 2026-11-11
318
+ ]
319
+ if not converted:
320
+ return
321
+ self.validate(candidate, registry, catalog, constraints, convert_ratio=False)
322
+ task_list.tasks[:] = candidate.tasks
323
+ logger.info("ratio aggregate converted", tasks=converted)
324
+ except Exception as e:
325
+ logger.warning(
326
+ "ratio aggregate not converted",
327
+ error=repr(e),
328
+ degraded_seam="ratio_pushdown",
329
+ )
330
+
331
+ def _mark_computed(self, tasks, catalog: Catalog) -> None:
332
+ """Give each `avg` of a ratio measure its `computed` spec (M4). Never raises.
333
+
334
+ A rewritten IR is validated again; on ANY failure the planner's own IR stays
335
+ and a warning is logged — the planner is never asked about an IR it did not
336
+ write, and a slip here costs the method, never the answer.
337
+ """
338
+ from ...knowledge_domain.computed import mark_computed
339
+
340
+ for task in tasks:
341
+ for call in task.tool_calls:
342
+ if call.tool != "retrieve_data" or not isinstance(call.args.get("ir"), dict):
343
+ continue
344
+ try:
345
+ ir = QueryIR.model_validate(call.args["ir"])
346
+ marked = mark_computed(ir, catalog)
347
+ if not marked:
348
+ continue
349
+ self._ir_validator.validate(ir, catalog)
350
+ call.args["ir"] = ir.model_dump()
351
+ logger.info("computed measure applied", task_id=task.id, selects=marked)
352
+ except Exception as e:
353
+ logger.warning(
354
+ "computed measure skipped",
355
+ task_id=task.id,
356
+ error=repr(e),
357
+ degraded_seam="computed_measures",
358
+ )
359
+
360
  def _validate_inline_ir(self, task_id: str, args: dict, catalog: Catalog) -> None:
361
  raw_ir = args.get("ir")
362
  if not isinstance(raw_ir, dict):
 
387
  to_id=r.to_id,
388
  )
389
 
390
+ # A bare measure under group_by takes its DECLARED aggregate (2026-11-10).
391
+ # Fail-open: any slip leaves the IR as the planner wrote it, and the IR
392
+ # validator below reports the bare select as before.
393
+ try:
394
+ wrapped = _wrap_declared_measures(ir, catalog)
395
+ except Exception as e: # noqa: BLE001
396
+ wrapped = []
397
+ logger.warning(
398
+ "bare measure not wrapped",
399
+ task_id=task_id,
400
+ error=repr(e),
401
+ degraded_seam="declared_aggregate",
402
+ )
403
+ if wrapped:
404
+ args["ir"] = ir.model_dump()
405
+ logger.info("bare measure wrapped", task_id=task_id, columns=wrapped)
406
+
407
  try:
408
  self._ir_validator.validate(ir, catalog)
409
  except IRValidationError as e:
 
415
  _reject_unmatchable_filter_values(task_id, ir, catalog)
416
  _reject_typed_lookup_ids(task_id, ir, catalog)
417
  _reject_raw_periodic_ranking(task_id, ir, catalog)
418
+ _reject_grouped_by_measure(task_id, ir, catalog)
419
 
420
  @staticmethod
421
  def _validate_data_source(
 
760
  )
761
 
762
 
763
+ # --------------------------------------------------------------------------- #
764
+ # Bare measures and measure-grouping (2026-11-10, PLANNER_SHAPE_FIXES_HANDOFF).
765
+ #
766
+ # "kelompokkan unit berdasarkan PA" (analysis 06f9cf31) was planned as
767
+ # `GROUP BY eqm_no` with `pa` and both hour columns selected bare. The reject named
768
+ # only the first bare column, and the retry grouped by `pa` itself with a COUNT(*)
769
+ # and a band filter: 462 unit-month rows answered as units. Checks 8m/8n start from
770
+ # "no group_by", so nothing saw it.
771
+ # - `_wrap_declared_measures`: under a group_by the only valid reading of a bare
772
+ # measure is "aggregate it", and the function is DATA (the dictionary's
773
+ # `aggregation`, or avg for a ratio measure — M4 then computes it from summed
774
+ # inputs). The stored attempt, wrapped this way, returns the key's bands.
775
+ # - Check 8p: a per-period table grouped by a continuous measure, with no period
776
+ # column shown, is still one row per period — rejected like 8m.
777
+ # --------------------------------------------------------------------------- #
778
+
779
+ _DECLARED_FNS = {"sum": "sum", "avg": "avg", "mean": "avg", "average": "avg",
780
+ "min": "min", "max": "max"}
781
+
782
+
783
+ def _declared_fn(col) -> str | None:
784
+ """The aggregate a numeric column declares for itself, or None."""
785
+ if col is None or col.data_type not in ("int", "decimal"):
786
+ return None
787
+ if (getattr(col, "measure_method", None) or {}).get("kind") == "ratio":
788
+ return "avg"
789
+ return _DECLARED_FNS.get((getattr(col, "aggregation", None) or "").strip().lower())
790
+
791
+
792
+ def _wrap_declared_measures(ir: QueryIR, catalog: Catalog) -> list[str]:
793
+ """Wrap each bare, ungrouped select that declares an aggregate. In place.
794
+
795
+ Only under a non-empty group_by. The alias is kept (the column's name when none),
796
+ so downstream `${tN.col}` and tool column args still resolve; an `order_by` on the
797
+ column id is pointed at that alias. Returns the wrapped column names.
798
+ """
799
+ from ...query.ir.models import AggSelect
800
+
801
+ if not ir.group_by:
802
+ return []
803
+ source = _source_of(ir, catalog)
804
+ if source is None:
805
+ return []
806
+ columns = _columns_of(source)
807
+ taken = {s.alias for s in ir.select if s.alias}
808
+ wrapped: list[str] = []
809
+ for i, item in enumerate(ir.select):
810
+ if getattr(item, "kind", "") != "column" or item.column_id in ir.group_by:
811
+ continue
812
+ col = columns.get(item.column_id)
813
+ fn = _declared_fn(col)
814
+ if fn is None:
815
+ continue
816
+ alias = item.alias or col.name
817
+ if not item.alias and alias in taken:
818
+ continue # the name is another select's alias: leave it to the validator
819
+ ir.select[i] = AggSelect(fn=fn, column_id=item.column_id, alias=alias)
820
+ taken.add(alias)
821
+ for ob in ir.order_by:
822
+ if ob.column_id == item.column_id:
823
+ ob.column_id = alias
824
+ wrapped.append(col.name)
825
+ return wrapped
826
+
827
+
828
+ def _reject_grouped_by_measure(task_id: str, ir: QueryIR, catalog: Catalog) -> None:
829
+ """Check 8p — a per-period table grouped by a continuous measure, period not shown."""
830
+ if not ir.group_by:
831
+ return
832
+ periods = _period_column_ids(ir, catalog)
833
+ if not periods:
834
+ return
835
+ if periods & ({getattr(s, "column_id", None) for s in ir.select} | set(ir.group_by)):
836
+ return # the period is shown: the rows are openly per-period
837
+ source = _source_of(ir, catalog)
838
+ columns = _columns_of(source) if source is not None else {}
839
+ # The data type, not the dictionary role: `is_bad_actor` is an int flag classified
840
+ # "measure", and grouping by it is how every bad-actor comparison is written.
841
+ measures = [
842
+ columns[c].name for c in ir.group_by
843
+ if c in columns and columns[c].data_type == "decimal"
844
+ and not columns[c].name.lower().endswith("_id")
845
+ ]
846
+ if measures:
847
+ raise PlannerValidationError(
848
+ f"task {task_id}: groups by the measure {', '.join(repr(m) for m in measures)} "
849
+ "itself on a table with one row per period, so every period stays its own "
850
+ "row and nothing is aggregated — a count of these rows is not a count of "
851
+ "entities. Group by the entity only and wrap the measure in an aggregate "
852
+ "(avg for rates and scores, sum for totals); to band entities, aggregate "
853
+ "per entity first and pass that table to analyze_segmentation. If the user "
854
+ "asks about single periods, select the period column."
855
+ )
856
+
857
+
858
  def _reject_misleading_aliases(task_id: str, ir: QueryIR, catalog: Catalog) -> None:
859
  """An alias may not rename a column into some OTHER thing that exists here.
860
 
 
1130
  return names or None
1131
 
1132
 
1133
+ # --------------------------------------------------------------------------- #
1134
+ # M7′ (2026-11-09, RATIO_MEASURES_REQUIREMENTS §10). Replaces Check 8o, which
1135
+ # rejected the plan and turned "bandingkan PA OB LOADER IPR, LAT, SDJ 2026" into a
1136
+ # refusal (analysis 0afacc01). A ratio measure (PA) over several rows is ONE ratio
1137
+ # of summed inputs, computed in the query (M4). The planner often averages it in
1138
+ # `analyze_aggregate` over raw rows instead (12 stored plans) — the prompt and the
1139
+ # tool description already say not to. Code converts that step, in its own slot,
1140
+ # into the grouped query the tool description asks for; the output names are the
1141
+ # tool's own (`<column>_<function>`), so every reference to the step still resolves.
1142
+ # --------------------------------------------------------------------------- #
1143
+
1144
+ # analyze_aggregate function (after normalise_agg) → IR AggFn. `median` has none.
1145
+ _TOOL_TO_IR_FN = {
1146
+ "mean": "avg", "sum": "sum", "min": "min", "max": "max",
1147
+ "count": "count", "nunique": "count_distinct",
1148
+ }
1149
+
1150
+
1151
+ def _placeholder_id(value: object) -> str | None:
1152
+ if isinstance(value, str) and value.startswith("${") and value.endswith("}"):
1153
+ inner = value[2:-1]
1154
+ return inner if "." not in inner else None
1155
+ return None
1156
+
1157
+
1158
+ def _references(task, task_id: str) -> bool:
1159
+ """True when `task` reads `task_id` (a `${id}` / `${id.col}` handoff or depends_on)."""
1160
+ if task_id in (task.depends_on or []):
1161
+ return True
1162
+ text = repr([call.args for call in task.tool_calls])
1163
+ return f"${{{task_id}}}" in text or f"${{{task_id}." in text
1164
+
1165
+
1166
+ def _convert_ratio_aggregates(task_list: TaskList, catalog: Catalog) -> list[str]:
1167
+ """Convert, IN PLACE on `task_list` (a copy), each eligible aggregate step.
1168
+
1169
+ Eligible: a single-call `analyze_aggregate` whose `data` is an ungrouped,
1170
+ aggregate-free `retrieve_data`, that averages a ratio-marked column (M3), with
1171
+ every key and aggregated column a plain select of that retrieve and every
1172
+ function expressible in the IR. A `median` of the ratio column is dropped when its
1173
+ mean is asked too. Returns the converted task ids. The caller re-validates.
1174
+ """
1175
+ tasks = task_list.tasks
1176
+ by_id = {t.id: t for t in tasks}
1177
+ columns = {
1178
+ c.column_id: c for s in catalog.sources for t in s.tables for c in t.columns
1179
+ }
1180
+ converted: list[str] = []
1181
+ for task in list(tasks):
1182
+ if len(task.tool_calls) != 1 or task.tool_calls[0].tool != "analyze_aggregate":
1183
+ continue
1184
+ call = task.tool_calls[0]
1185
+ up = by_id.get(_placeholder_id(call.args.get("data")) or "")
1186
+ if up is None or len(up.tool_calls) != 1 or up.tool_calls[0].tool != "retrieve_data":
1187
+ continue
1188
+ raw = up.tool_calls[0].args.get("ir")
1189
+ if not isinstance(raw, dict) or raw.get("group_by"):
1190
+ continue
1191
+ selects = raw.get("select") or []
1192
+ if any(not isinstance(s, dict) or s.get("kind", "column") != "column" for s in selects):
1193
+ continue
1194
+ plain: dict[str, dict] = {}
1195
+ for item in selects:
1196
+ col = columns.get(item.get("column_id"))
1197
+ name = item.get("alias") or (col.name if col is not None else None)
1198
+ if name:
1199
+ plain[name] = item
1200
+ keys = call.args.get("group_by") or []
1201
+ aggs = call.args.get("aggregations")
1202
+ if not isinstance(aggs, dict) or not all(k in plain for k in keys):
1203
+ continue
1204
+ funcs = {
1205
+ col: list(dict.fromkeys(
1206
+ normalise_agg(f) for f in (fns if isinstance(fns, list) else [fns])
1207
+ ))
1208
+ for col, fns in aggs.items()
1209
+ }
1210
+ ratio = {
1211
+ name for name, item in plain.items()
1212
+ if (getattr(columns.get(item.get("column_id")), "measure_method", None) or {})
1213
+ .get("kind") == "ratio"
1214
+ }
1215
+ if not any(col in ratio and "mean" in fns for col, fns in funcs.items()):
1216
+ continue
1217
+
1218
+ new_select = [dict(plain[k]) for k in keys]
1219
+ named: dict[str, list[str]] = {}
1220
+ ok = True
1221
+ for col, fns in funcs.items():
1222
+ if col not in plain:
1223
+ ok = False
1224
+ break
1225
+ for fn in fns:
1226
+ if fn == "median" and col in ratio and "mean" in fns:
1227
+ logger.info("ratio median dropped", task_id=task.id, column=col)
1228
+ continue
1229
+ ir_fn = _TOOL_TO_IR_FN.get(fn) if isinstance(fn, str) else None
1230
+ if ir_fn is None:
1231
+ ok = False
1232
+ break
1233
+ alias = f"{col}_{fn}"
1234
+ new_select.append({
1235
+ "kind": "agg", "fn": ir_fn,
1236
+ "column_id": plain[col]["column_id"], "alias": alias,
1237
+ })
1238
+ named.setdefault(col, []).append(alias)
1239
+ if not ok:
1240
+ break
1241
+ if not ok:
1242
+ continue
1243
+
1244
+ order_by: list[dict] = []
1245
+ sort_by = call.args.get("sort_by")
1246
+ if isinstance(sort_by, str) and sort_by:
1247
+ aliases = {a for names in named.values() for a in names}
1248
+ if sort_by in aliases:
1249
+ key = sort_by
1250
+ elif sort_by in keys:
1251
+ key = plain[sort_by]["column_id"]
1252
+ elif len(named.get(sort_by, [])) == 1:
1253
+ key = named[sort_by][0]
1254
+ else:
1255
+ continue
1256
+ order_by = [{"column_id": key, "dir": "asc" if call.args.get("ascending") else "desc"}]
1257
+ top_n = call.args.get("top_n")
1258
+ positive = isinstance(top_n, int) and not isinstance(top_n, bool) and top_n > 0
1259
+ limit = top_n if positive else None
1260
+ if limit is not None and not order_by:
1261
+ order_by = [{"column_id": plain[k]["column_id"], "dir": "asc"} for k in keys]
1262
+
1263
+ ir = {
1264
+ **raw,
1265
+ "select": new_select,
1266
+ "group_by": [plain[k]["column_id"] for k in keys],
1267
+ "order_by": order_by,
1268
+ "limit": limit,
1269
+ }
1270
+ call.tool = "retrieve_data"
1271
+ call.args = {"ir": ir}
1272
+ task.depends_on = list(up.depends_on or [])
1273
+ if not any(_references(other, up.id) for other in tasks if other is not up):
1274
+ tasks.remove(up)
1275
+ converted.append(task.id)
1276
+ return converted
1277
+
1278
+
1279
+ # U5 (2026-11-11, RATIO_MEASURES_REQUIREMENTS §12): "tren PA per bulan" planned as raw
1280
+ # rows into analyze_trend (run f3f99419) averaged unit PAs per month. Grouping the
1281
+ # upstream query by its period columns hands the trend one row per period, whose
1282
+ # `avg(pa)` M4 computes from summed hours. Checked on the demo DB: all 7 months equal
1283
+ # the summed-hours key. A trend's `freq` must equal the period column's grain, so the
1284
+ # one row per period is exactly what the tool buckets.
1285
+ _FREQ_GRAIN = {
1286
+ "month": "month", "monthly": "month", "m": "month",
1287
+ "week": "week", "weekly": "week", "w": "week",
1288
+ "quarter": "quarter", "quarterly": "quarter", "q": "quarter",
1289
+ "year": "year", "yearly": "year", "annual": "year", "y": "year", "a": "year",
1290
+ }
1291
+
1292
+
1293
+ def _convert_ratio_trends(task_list: TaskList, catalog: Catalog) -> list[str]:
1294
+ """Group, IN PLACE on `task_list` (a copy), the upstream of each eligible trend.
1295
+
1296
+ Eligible: a single-call `analyze_trend` with `agg` mean/avg (the tool's default is
1297
+ sum, so a missing `agg` is not touched) over an ungrouped, aggregate-free
1298
+ `retrieve_data` read by nothing else, whose `value_column` is a ratio-marked plain
1299
+ select and whose `date_column` is a selected period column of the trend's `freq`.
1300
+ The upstream becomes: group by every selected period column, select those plus
1301
+ `avg(<value column>)` aliased as the value column. The trend task is untouched.
1302
+ Returns the upstream task ids it grouped. The caller re-validates.
1303
+ """
1304
+ tasks = task_list.tasks
1305
+ by_id = {t.id: t for t in tasks}
1306
+ columns = {
1307
+ c.column_id: c for s in catalog.sources for t in s.tables for c in t.columns
1308
+ }
1309
+ converted: list[str] = []
1310
+ for task in tasks:
1311
+ if len(task.tool_calls) != 1 or task.tool_calls[0].tool != "analyze_trend":
1312
+ continue
1313
+ args = task.tool_calls[0].args
1314
+ if normalise_agg(args.get("agg")) != "mean":
1315
+ continue
1316
+ up = by_id.get(_placeholder_id(args.get("data")) or "")
1317
+ if up is None or len(up.tool_calls) != 1 or up.tool_calls[0].tool != "retrieve_data":
1318
+ continue
1319
+ if any(_references(other, up.id) for other in tasks if other not in (up, task)):
1320
+ continue
1321
+ raw = up.tool_calls[0].args.get("ir")
1322
+ if not isinstance(raw, dict) or raw.get("group_by"):
1323
+ continue
1324
+ selects = raw.get("select") or []
1325
+ if any(not isinstance(s, dict) or s.get("kind", "column") != "column" for s in selects):
1326
+ continue
1327
+ plain: dict[str, dict] = {}
1328
+ period: dict[str, str] = {} # select name -> period grain
1329
+ for item in selects:
1330
+ col = columns.get(item.get("column_id"))
1331
+ name = item.get("alias") or (col.name if col is not None else None)
1332
+ if not name:
1333
+ continue
1334
+ plain[name] = item
1335
+ if col is not None and col.name.lower() in _PERIOD_NAMES:
1336
+ period[name] = col.name.lower()
1337
+ value, date = args.get("value_column"), args.get("date_column")
1338
+ if value not in plain or date not in period:
1339
+ continue
1340
+ method = getattr(columns.get(plain[value]["column_id"]), "measure_method", None) or {}
1341
+ if method.get("kind") != "ratio":
1342
+ continue
1343
+ if _FREQ_GRAIN.get(str(args.get("freq", "")).strip().lower()) != period[date]:
1344
+ continue
1345
+ up.tool_calls[0].args = {"ir": {
1346
+ **raw,
1347
+ "select": [dict(plain[p]) for p in period]
1348
+ + [{"kind": "agg", "fn": "avg", "column_id": plain[value]["column_id"],
1349
+ "alias": value}],
1350
+ "group_by": [plain[p]["column_id"] for p in period],
1351
+ "order_by": [],
1352
+ "limit": None,
1353
+ }}
1354
+ converted.append(up.id)
1355
+ return converted
1356
+
1357
+
1358
  def _upstream_column(call, tasks_by_id: dict, catalog: Catalog):
1359
  """The catalog column the `data` retrieve selected as `date_column`, else None.
1360
 
src/agents/scope/engine.py CHANGED
@@ -1068,6 +1068,28 @@ def _coverage(outputs: list[Any]) -> str | None:
1068
  return f"data {lo[0]:04d}-{lo[1]:02d}–{hi[0]:04d}-{hi[1]:02d}"
1069
 
1070
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1071
  def render_scope_line(
1072
  scope: TurnScope, run_state: RunState, reply_language: str | None
1073
  ) -> TurnScope:
@@ -1165,6 +1187,9 @@ def render_scope_line(
1165
  ):
1166
  parts.append("semua periode" if id_ else "all periods")
1167
 
 
 
 
1168
  if scope.focus and focus_changed(scope.focus, effective, set(scope.lifted)):
1169
  rendered_focus = "; ".join(_render_entry(e, id_) for e in scope.focus)
1170
  parts.append(
 
1068
  return f"data {lo[0]:04d}-{lo[1]:02d}–{hi[0]:04d}-{hi[1]:02d}"
1069
 
1070
 
1071
+ def _method_parts(outputs: list[Any], id_: bool) -> list[str]:
1072
+ """"metode: PA dari total jam" / "method: plain average per period (PA)" parts,
1073
+ read from the executed outputs' `meta.methods`. Deduped, in order."""
1074
+ parts: list[str] = []
1075
+ for out in outputs:
1076
+ for m in (getattr(out, "meta", None) or {}).get("methods") or []:
1077
+ if not isinstance(m, dict):
1078
+ continue
1079
+ if m.get("kind") == "ratio":
1080
+ label = (m.get("label") or {}).get("id" if id_ else "en")
1081
+ text = label or ("dari jumlah input" if id_ else "from summed inputs")
1082
+ elif m.get("kind") == "plain":
1083
+ base = "rata-rata sederhana per periode" if id_ else "plain average per period"
1084
+ text = f"{base} ({m['measure']})" if m.get("measure") else base
1085
+ else:
1086
+ continue
1087
+ part = f"{'metode' if id_ else 'method'}: {text}"
1088
+ if part not in parts:
1089
+ parts.append(part)
1090
+ return parts
1091
+
1092
+
1093
  def render_scope_line(
1094
  scope: TurnScope, run_state: RunState, reply_language: str | None
1095
  ) -> TurnScope:
 
1187
  ):
1188
  parts.append("semua periode" if id_ else "all periods")
1189
 
1190
+ # How averaged measures were computed (2026-11-09, RATIO_MEASURES M5).
1191
+ parts.extend(_method_parts(outputs, id_))
1192
+
1193
  if scope.focus and focus_changed(scope.focus, effective, set(scope.lifted)):
1194
  rendered_focus = "; ".join(_render_entry(e, id_) for e in scope.focus)
1195
  parts.append(
src/agents/slow_path/coordinator.py CHANGED
@@ -68,6 +68,16 @@ class SlowPathCoordinator:
68
  # awaitable, so the most granular signal we can emit is at stage boundaries.
69
  if progress:
70
  await progress(stage(PLANNING, reply_language))
 
 
 
 
 
 
 
 
 
 
71
  plan_kw = {"callbacks": planner_callbacks} if planner_callbacks else {}
72
  # Retrieve-then-plan excerpts (PR23 part 2 E1) go to BOTH calls: the planner
73
  # shapes the plan with them, the assembler explains the result against them.
@@ -85,6 +95,12 @@ class SlowPathCoordinator:
85
  if scope is not None
86
  else {}
87
  )
 
 
 
 
 
 
88
  task_list = await self._planner.plan(
89
  context, catalog, self._registry, query, constraints,
90
  reply_language=reply_language, domain_card=domain_card,
 
68
  # awaitable, so the most granular signal we can emit is at stage boundaries.
69
  if progress:
70
  await progress(stage(PLANNING, reply_language))
71
+ # C1 (2026-11-11): a weekly table whose monthly twin exists is left out of this
72
+ # turn's catalog unless the user's words, the rewrite, the objective or a
73
+ # business question name weeks. The planner, the scope hook and the validator
74
+ # then see one grain. Never raises (src/catalog/twins.py).
75
+ from ...catalog.twins import hide_weekly_twins
76
+
77
+ catalog = hide_weekly_twins(
78
+ catalog, query, scope.user_message if scope is not None else None,
79
+ context.analysis_objective, *(context.business_questions or []),
80
+ )
81
  plan_kw = {"callbacks": planner_callbacks} if planner_callbacks else {}
82
  # Retrieve-then-plan excerpts (PR23 part 2 E1) go to BOTH calls: the planner
83
  # shapes the plan with them, the assembler explains the result against them.
 
95
  if scope is not None
96
  else {}
97
  )
98
+ # U10 (2026-11-09): the user's own words asked for a plain average, so ratio
99
+ # measures (PA) are not computed from summed inputs this turn.
100
+ from ...knowledge_domain.computed import wants_plain_average
101
+
102
+ if wants_plain_average(query, scope.user_message if scope is not None else None):
103
+ constraints = constraints.model_copy(update={"plain_average": True})
104
  task_list = await self._planner.plan(
105
  context, catalog, self._registry, query, constraints,
106
  reply_language=reply_language, domain_card=domain_card,
src/agents/slow_path/prompt.py CHANGED
@@ -9,6 +9,10 @@ prose and markdown tables.
9
 
10
  from __future__ import annotations
11
 
 
 
 
 
12
  from ..planner.contracts import BusinessContext, ToolOutput
13
  from ..planner.prompt import render_business_context
14
  from .schemas import RunAssessment, RunState, TaskResult
@@ -54,7 +58,8 @@ def _render_output(output: ToolOutput) -> str:
54
  )
55
  more = "" if len(rows) <= _MAX_ROWS else f" … (+{len(rows) - _MAX_ROWS} more rows)"
56
  ran = _executed(output) if output.tool == "retrieve_data" else ""
57
- return f"({output.tool}) table{ran} [{header}]: {preview}{more}"
 
58
  if output.kind == "chart" and isinstance(output.value, dict):
59
  # W2 (SPINE_V2_PLAN §4.3): one-line summary only — the raw spec's x/y
60
  # arrays would flood the prompt. The chart itself reaches the user via
@@ -105,6 +110,125 @@ def _executed(output: ToolOutput) -> str:
105
  return ""
106
 
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  def _filter_text(f: dict) -> str:
109
  value = f.get("value")
110
  if f.get("op") == "between" and isinstance(value, list) and len(value) == 2:
 
9
 
10
  from __future__ import annotations
11
 
12
+ import math
13
+ import numbers
14
+ import re
15
+
16
  from ..planner.contracts import BusinessContext, ToolOutput
17
  from ..planner.prompt import render_business_context
18
  from .schemas import RunAssessment, RunState, TaskResult
 
58
  )
59
  more = "" if len(rows) <= _MAX_ROWS else f" … (+{len(rows) - _MAX_ROWS} more rows)"
60
  ran = _executed(output) if output.tool == "retrieve_data" else ""
61
+ order = "".join(f"\n {line}" for line in _order_lines(output))
62
+ return f"({output.tool}) table{ran} [{header}]: {preview}{more}{order}"
63
  if output.kind == "chart" and isinstance(output.value, dict):
64
  # W2 (SPINE_V2_PLAN §4.3): one-line summary only — the raw spec's x/y
65
  # arrays would flood the prompt. The chart itself reaches the user via
 
110
  return ""
111
 
112
 
113
+ _MAX_ORDER_COLUMNS = 3
114
+ _TIME_WORDS = r"year|month|week|day|date|period|periods|quarter|time"
115
+ # A label column named like a time is a trend over time: read as a trend, not ranked.
116
+ _TIME_NAME = re.compile(rf"(^|_)({_TIME_WORDS})($|_)", re.I)
117
+ # Numeric columns whose order means nothing (a month, an id, a code) — never ranked.
118
+ _NO_RANK_NAME = re.compile(rf"(^|_)({_TIME_WORDS}|id|code|no)($|_)", re.I)
119
+ _DATE_VALUE = re.compile(r"^\d{4}[-/]\d{1,2}")
120
+
121
+
122
+ def _order_lines(output: ToolOutput) -> list[str]:
123
+ """`order by <col> (...)` lines for a small one-category retrieve table.
124
+
125
+ 5 Nov demo: a 5-site MTBS table reached the Assembler unsorted and the answer put
126
+ ADT below PKP (45.343 vs 44.424); on 8 Nov a sorted one was read right but called
127
+ 4 other sites "3". The order and the count are computed here, by code, so the
128
+ narrative copies them instead of comparing numbers itself.
129
+
130
+ Deliberately narrow — anything else renders exactly as before:
131
+ - only `retrieve_data` tables of 2.._MAX_ROWS rows (correlation, cluster,
132
+ forecast outputs have their own meaning of "highest");
133
+ - exactly one text label column, not a time column (a trend keeps the stub-period
134
+ rules; a two-label table would rank across groups);
135
+ - numeric columns only, not time/id-like, no missing values, not all equal.
136
+
137
+ A cut list ("the 10 lowest": `limit` reached, or the executor truncated) says so
138
+ and names its ends "of these N" — on 9 Nov the top row of the 10 lowest-PA units
139
+ was reported as the highest PA overall.
140
+ """
141
+ try:
142
+ columns, rows = output.columns or [], output.rows or []
143
+ scope, top, bottom = _cut_words(output, len(rows))
144
+ if output.tool != "retrieve_data" or not 2 <= len(rows) <= _MAX_ROWS:
145
+ return []
146
+ if any(len(row) != len(columns) for row in rows):
147
+ return []
148
+ cols = list(zip(*rows, strict=True))
149
+ labels = [
150
+ i for i, values in enumerate(cols)
151
+ if all(isinstance(v, str) and v.strip() for v in values)
152
+ ]
153
+ if len(labels) != 1:
154
+ return []
155
+ label = labels[0]
156
+ if _TIME_NAME.search(columns[label]) or any(
157
+ _DATE_VALUE.match(v) for v in cols[label]
158
+ ):
159
+ return []
160
+ lines: list[str] = []
161
+ for i, values in enumerate(cols):
162
+ if len(lines) == _MAX_ORDER_COLUMNS:
163
+ break
164
+ if i == label or _NO_RANK_NAME.search(columns[i]):
165
+ continue
166
+ if not all(_is_number(v) for v in values) or len(set(values)) == 1:
167
+ continue
168
+ ranked = sorted(
169
+ zip(cols[label], values, strict=True), key=lambda pair: pair[1], reverse=True
170
+ )
171
+ lines.append(
172
+ f"order by {columns[i]} ({scope}, highest first): "
173
+ f"{_ranked_text(ranked)} · {top}: {_ends(ranked, ranked[0][1])}"
174
+ f" · {bottom}: {_ends(ranked, ranked[-1][1])}"
175
+ )
176
+ return lines
177
+ except Exception: # noqa: BLE001 — a render detail must never cost the answer
178
+ return []
179
+
180
+
181
+ def _cut_words(output: ToolOutput, n: int) -> tuple[str, str, str]:
182
+ """(row-count text, top label, bottom label) — "of these N" when the list is cut.
183
+
184
+ Cut = the executor truncated, or the query's `limit` was reached (fewer rows than
185
+ the limit means every row came back). Without a known cut the words are as before.
186
+ "the N lowest/highest by <col>" only when <col> is a numeric result column; a sort
187
+ on a name (a top-N with no measure to rank by keeps the first N A–Z) is "a part".
188
+ """
189
+ meta = output.meta or {}
190
+ cut = meta.get("cut") if isinstance(meta.get("cut"), dict) else {}
191
+ limit = cut.get("limit")
192
+ is_cut = bool(meta.get("truncated")) or (isinstance(limit, int) and n >= limit)
193
+ if not is_cut:
194
+ return f"{n} rows", "highest", "lowest"
195
+ end = {"asc": "lowest", "desc": "highest"}.get(cut.get("dir") or "")
196
+ columns, rows = output.columns or [], output.rows or []
197
+ if cut.get("column") not in columns or not all(
198
+ _is_number(row[columns.index(cut["column"])]) for row in rows
199
+ ):
200
+ end = None
201
+ kept = f"the {n} {end} by {cut['column']}" if end and cut.get("column") else "a part"
202
+ return (
203
+ f"{n} rows shown — a cut list: {kept}, not all rows",
204
+ f"highest of these {n}",
205
+ f"lowest of these {n}",
206
+ )
207
+
208
+
209
+ def _is_number(value: object) -> bool:
210
+ if isinstance(value, bool) or not isinstance(value, numbers.Real):
211
+ return False
212
+ return not math.isnan(float(value))
213
+
214
+
215
+ def _num(value: float) -> str:
216
+ if float(value).is_integer():
217
+ return str(int(value))
218
+ return f"{value:.3f}"
219
+
220
+
221
+ def _ranked_text(ranked: list[tuple[str, float]]) -> str:
222
+ parts = [f"{ranked[0][0]} {_num(ranked[0][1])}"]
223
+ for (_, prev), (name, value) in zip(ranked, ranked[1:], strict=False):
224
+ parts.append(f"{'=' if value == prev else '>'} {name} {_num(value)}")
225
+ return " ".join(parts)
226
+
227
+
228
+ def _ends(ranked: list[tuple[str, float]], value: float) -> str:
229
+ return " = ".join(name for name, v in ranked if v == value)
230
+
231
+
232
  def _filter_text(f: dict) -> str:
233
  value = f.get("value")
234
  if f.get("op") == "between" and isinstance(value, list) and len(value) == 2:
src/catalog/models.py CHANGED
@@ -63,6 +63,11 @@ class Column(BaseModel):
63
  is_key: bool = False
64
  data_classification: str | None = None
65
  validation_rules: Any | None = None
 
 
 
 
 
66
 
67
 
68
  class ForeignKey(BaseModel):
 
63
  is_key: bool = False
64
  data_classification: str | None = None
65
  validation_rules: Any | None = None
66
+ # Python-side only, set at read time from approved domain knowledge and never
67
+ # persisted or rendered (2026-11-09, RATIO_MEASURES_REQUIREMENTS M3):
68
+ # {"kind": "ratio", "numerator", "denominator", "scale", "label", "measure"} or
69
+ # {"kind": "plain", "measure"}. See `knowledge_domain/computed.py`.
70
+ measure_method: dict | None = Field(default=None, exclude=True)
71
 
72
 
73
  class ForeignKey(BaseModel):
src/catalog/twins.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hide a weekly table whose monthly twin exists, unless weeks were asked for (C1).
2
+
3
+ PLANNER_SHAPE_FIXES_HANDOFF_2026-11.md §8 / PLANT_TEST_FIXES_2026-11.md C1, 2026-11-11.
4
+ A source can hold the same data at two grains: a table with a `week` column and one
5
+ with the same columns but `month` in its place (8 such pairs on the EMA demo source).
6
+ Nothing tells the planner which one a question means, and the prose rule in
7
+ `planner.md` ("read the month table") was broken in 11 of 63 stored final plans, none
8
+ of which asked for weeks. Weekly ids copied into monthly queries caused 6 more
9
+ validation failures. Grain is a fixed rule, so code applies it: for one turn, the
10
+ weekly twin is left out of the catalog the planner, the scope step and the validator
11
+ see, unless the user's words, the rewrite, the objective or a business question name
12
+ weeks.
13
+
14
+ Generic: twins are found by comparing column names, never by table name. A weekly
15
+ table with no monthly twin, a pair whose columns differ in anything else, and every
16
+ other grain pair are left alone (today's behaviour). Never raises: any failure returns
17
+ the catalog unchanged. Nothing is deleted; the table is simply absent from one turn.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from typing import Any
24
+
25
+ from src.middlewares.logging import get_logger
26
+
27
+ logger = get_logger("catalog_twins")
28
+
29
+ # Words that ask for weeks, ID + EN. "minggu" also means Sunday ("hari Minggu"): then
30
+ # the weekly table stays visible, which is today's behaviour, so the cost is nil.
31
+ _WEEK_WORDS = re.compile(
32
+ r"\b((se)?minggu\w*|(se)?pekan\w*|week\w*|wk\s?\d{1,2}|w\d{1,2})\b",
33
+ re.IGNORECASE,
34
+ )
35
+
36
+
37
+ def asks_for_weeks(*texts: str | None) -> bool:
38
+ """True when any of the texts names weeks."""
39
+ return any(t and _WEEK_WORDS.search(t) for t in texts)
40
+
41
+
42
+ def _names(table: Any) -> frozenset[str]:
43
+ return frozenset(c.name.strip().casefold() for c in table.columns)
44
+
45
+
46
+ def weekly_twin_ids(catalog: Any) -> set[str]:
47
+ """Table ids of weekly tables that have a monthly twin in the same source."""
48
+ out: set[str] = set()
49
+ for source in getattr(catalog, "sources", []) or []:
50
+ by_names = {_names(t): t for t in source.tables}
51
+ for table in source.tables:
52
+ names = _names(table)
53
+ if "week" not in names or "month" in names:
54
+ continue
55
+ twin = by_names.get((names - {"week"}) | {"month"})
56
+ if twin is not None and twin is not table:
57
+ out.add(table.table_id)
58
+ return out
59
+
60
+
61
+ def hide_weekly_twins(catalog: Any, *texts: str | None) -> Any:
62
+ """The catalog for this turn: weekly twins removed unless weeks were asked for.
63
+
64
+ Returns a shallow copy (the Column objects, and any `measure_method` bound on
65
+ them, are shared), or the catalog itself when nothing is hidden. Never raises.
66
+ """
67
+ try:
68
+ if asks_for_weeks(*texts):
69
+ return catalog
70
+ hidden = weekly_twin_ids(catalog)
71
+ if not hidden:
72
+ return catalog
73
+ sources = []
74
+ for source in catalog.sources:
75
+ kept = []
76
+ for table in source.tables:
77
+ if table.table_id in hidden:
78
+ continue
79
+ fks = [fk for fk in table.foreign_keys if fk.target_table_id not in hidden]
80
+ kept.append(
81
+ table if len(fks) == len(table.foreign_keys)
82
+ else table.model_copy(update={"foreign_keys": fks})
83
+ )
84
+ sources.append(
85
+ source if len(kept) == len(source.tables)
86
+ else source.model_copy(update={"tables": kept})
87
+ )
88
+ logger.info("weekly twins hidden", n_hidden=len(hidden))
89
+ return catalog.model_copy(update={"sources": sources})
90
+ except Exception as e: # a catalog enrichment must never cost the answer
91
+ logger.warning(
92
+ "weekly twins not hidden", error=repr(e), degraded_seam="catalog_twins"
93
+ )
94
+ return catalog
src/config/prompts/assembler.md CHANGED
@@ -91,11 +91,40 @@ When no such section is present, write as you otherwise would.
91
  never present one of them as the sole leader: say how many share it and name
92
  them (e.g. "3 units tie at 6 of 6 months: …"). The plan returns a few rows past
93
  the top for exactly this.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  - **Say what the numbers cover.** Name the period and scope (site, section) the
95
- results actually cover. When a ranking carries `n_periods` / `last_period`:
96
- if the entities rest on different numbers of periods, say so, and never present
97
- an entity with far fewer periods as the plain leader — note it (e.g.
98
- "unit U-07 has data for 1 month only, April"); if the latest period in the
 
 
 
 
 
99
  results ends before the window asked, say where the data ends ("data until
100
  July 2026"). Put the same note in `caveats`.
101
  - **Thin periods and weak results.** A period a result marks `stub` (far fewer rows
 
91
  never present one of them as the sole leader: say how many share it and name
92
  them (e.g. "3 units tie at 6 of 6 months: …"). The plan returns a few rows past
93
  the top for exactly this.
94
+ - **Take every comparison from the `order by` line.** A small result table may carry
95
+ a line `order by <column> (N rows, highest first): A > B > … · highest: … · lowest: …`,
96
+ computed by code. Every "highest/lowest", "above/below", "rank N", "top/bottom" and
97
+ every count ("of the 5 sites") comes from that line, never from the row position in
98
+ the table, which may be unsorted. No sentence may contradict it. If the user asks
99
+ lowest first, reverse that order; do not re-rank.
100
+ When the line says `a cut list` (e.g. "the 10 lowest by pa, not all rows"), the
101
+ table is only part of the data: its ends are "highest/lowest **of these** N", never
102
+ the highest or lowest overall — say "the 10 lowest", not "the highest is …".
103
+ Example (made-up names): focus Site B; rows arrive as C 71.0, A 92.1, B 90.4 with
104
+ `order by avg_uptime (3 rows, highest first): A 92.1 > B 90.4 > C 71.0`.
105
+ Right: "Site B, the focus, is 2nd of 3 sites: below A, above C." Wrong: "B is the
106
+ highest", "B is below C", "if you mean B …, but …".
107
+ - **"We" is the focus.** "kita", "kami", "our site" mean the analysis focus printed in
108
+ the scope line as "outside focus (…)" / "di luar fokus (…)". Place the focus in the
109
+ order with its own value (e.g. "Site B, the focus, is 2nd of 3 sites, at 90.4").
110
+ "We" always refers to the focus's own value and its own place in the order — never
111
+ attach it to another entity's value or rank (wrong: "our MTTR is the highest, at
112
+ site P" when P is not the focus). With no focus in the scope line, do
113
+ not guess who "we" is: give the comparison for all entities. Never answer in branches
114
+ ("if you mean X …, but …").
115
+ - **High or low is not good or bad.** For PA, MTBS and MTBF higher is better; for MTTR
116
+ and breakdown duration or frequency lower is better. When the metric's direction is
117
+ unclear, say "highest/lowest", not "best/worst".
118
  - **Say what the numbers cover.** Name the period and scope (site, section) the
119
+ results actually cover. A ranking may carry `n_periods` / `n_rows` / `last_period`
120
+ (or `last_month`). `n_*` is a **row count**: it equals the number of periods only
121
+ when each entity has one row per period (a unit in a per-unit-per-month table). For
122
+ a group of units (a site, a model, a section) it counts unit-months — call it "rows
123
+ of data", never "months" or "periods". `last_period` / `last_month` is the latest
124
+ period present (9 = September), not a count. If the entities rest on very different
125
+ row counts, say so, and never present an entity with far fewer rows as the plain
126
+ leader — note it (e.g. "unit U-07 has data for 1 month only, April"; "site P rests
127
+ on 20 rows of data, the others on 300 or more"); if the latest period in the
128
  results ends before the window asked, say where the data ends ("data until
129
  July 2026"). Put the same note in `caveats`.
130
  - **Thin periods and weak results.** A period a result marks `stub` (far fewer rows
src/knowledge_domain/compose.py CHANGED
@@ -193,6 +193,7 @@ class DomainComposer:
193
  ],
194
  governed_by=list(governs.get(row.entity_id, [])),
195
  catalog_binding=None,
 
196
  mention_count=row.mention_count or 0,
197
  approved=approved,
198
  )
@@ -219,6 +220,7 @@ class DomainComposer:
219
  "formula_latex"
220
  )
221
  existing.unit = existing.unit or (formula or {}).get("unit")
 
222
 
223
  if definition:
224
  existing.definitions.append(
 
193
  ],
194
  governed_by=list(governs.get(row.entity_id, [])),
195
  catalog_binding=None,
196
+ compute=(formula or {}).get("compute"),
197
  mention_count=row.mention_count or 0,
198
  approved=approved,
199
  )
 
220
  "formula_latex"
221
  )
222
  existing.unit = existing.unit or (formula or {}).get("unit")
223
+ existing.compute = existing.compute or (formula or {}).get("compute")
224
 
225
  if definition:
226
  existing.definitions.append(
src/knowledge_domain/computed.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Measures computed from summed inputs (2026-11-09, docs/plans/RATIO_MEASURES_REQUIREMENTS.md).
2
+
3
+ Over several periods or units the plant measures PA as ONE ratio of summed hours,
4
+ `100 * (Σ total hours - Σ breakdown) / Σ total hours` (STD_2026_006_MNO p. 4, EMA's
5
+ `PAPerformanceMonthly.yml`), not as the average of the per-month `pa` values. The
6
+ query IR can compute that (`AggSelect.computed`); this module decides WHEN, from data:
7
+
8
+ 1. `bind_computed_measures` (read time, M3): an approved measure whose formula entry
9
+ carries a `compute` block, e.g.
10
+ {"column": "pa",
11
+ "numerator": [["+", "total_inpr_hour"], ["-", "breakdown_duration_total"]],
12
+ "denominator": [["+", "total_inpr_hour"]],
13
+ "scale": 100, "label": {"id": "PA dari total jam", "en": "PA from summed hours"}}
14
+ marks every catalog table holding all those columns (by NAME, numeric terms only)
15
+ with `Column.measure_method = {"kind": "ratio", ...}` in column ids. An approved
16
+ measure WITH a formula but no usable block marks a same-named column
17
+ `{"kind": "plain"}`, so the answer can say it is a plain average (U9).
18
+ 2. `mark_computed` (plan validation, M4): every `avg` of a ratio-marked column gets the
19
+ `computed` spec. The planner keeps writing `avg(pa)`; it is never taught the form.
20
+ 3. `wants_plain_average` (M8): the user explicitly asked for a plain average, so the
21
+ rewrite is skipped for the turn (U10).
22
+
23
+ The definition lives once, on the approved formula entry (one home, §5.22); no prompt
24
+ text. Every function here is fail-open: a bad block or a slip leaves the plain `avg`,
25
+ which is today's behaviour.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from typing import Any
32
+
33
+ from src.middlewares.logging import get_logger
34
+
35
+ logger = get_logger("knowledge_computed")
36
+
37
+ _NUMERIC = frozenset({"int", "decimal"})
38
+ _SIGNS = frozenset({"+", "-"})
39
+ _SCALES = frozenset({1, 100})
40
+ _MAX_TERMS = 4
41
+
42
+
43
+ def _terms(raw: Any) -> list[tuple[str, str]] | None:
44
+ """[[sign, column], ...] → [(sign, column_name)], or None when malformed."""
45
+ if not isinstance(raw, list) or not 1 <= len(raw) <= _MAX_TERMS:
46
+ return None
47
+ out: list[tuple[str, str]] = []
48
+ for item in raw:
49
+ if not (isinstance(item, list | tuple) and len(item) == 2):
50
+ return None
51
+ sign, name = item
52
+ if sign not in _SIGNS or not isinstance(name, str) or not name.strip():
53
+ return None
54
+ out.append((sign, name.strip()))
55
+ return out
56
+
57
+
58
+ def _parse(spec: Any) -> dict | None:
59
+ if not isinstance(spec, dict):
60
+ return None
61
+ column = spec.get("column")
62
+ numerator, denominator = _terms(spec.get("numerator")), _terms(spec.get("denominator"))
63
+ try:
64
+ scale = int(spec.get("scale", 1))
65
+ except (TypeError, ValueError):
66
+ return None
67
+ label = spec.get("label") if isinstance(spec.get("label"), dict) else {}
68
+ if not isinstance(column, str) or not numerator or not denominator or scale not in _SCALES:
69
+ return None
70
+ return {"column": column.strip(), "numerator": numerator, "denominator": denominator,
71
+ "scale": scale, "label": {k: str(v) for k, v in label.items() if v}}
72
+
73
+
74
+ def bind_computed_measures(measures: list[Any], catalog: Any) -> int:
75
+ """Mark catalog columns with how their measure is computed. Returns tables bound.
76
+
77
+ In place on the catalog's Column objects (catalogs are rebuilt per read, so this
78
+ never leaks across requests). Idempotent. Never raises.
79
+ """
80
+ try:
81
+ return _bind(measures, catalog)
82
+ except Exception as e: # an enrichment must never cost the answer
83
+ logger.warning(
84
+ "computed_measures_bind_failed",
85
+ extra={"error": repr(e), "degraded_seam": "computed_measures"},
86
+ )
87
+ return 0
88
+
89
+
90
+ def _bind(measures: list[Any], catalog: Any) -> int:
91
+ ratios: list[tuple[str, dict]] = []
92
+ plain_names: dict[str, str] = {}
93
+ for m in measures or []:
94
+ if not getattr(m, "approved", False):
95
+ continue
96
+ spec = _parse(getattr(m, "compute", None))
97
+ if spec is not None:
98
+ ratios.append((m.name, spec))
99
+ elif getattr(m, "compute", None) is not None:
100
+ logger.warning("computed_measure_spec_invalid", extra={"measure": m.name})
101
+ if getattr(m, "formula_latex", None):
102
+ for form in [m.name, *getattr(m, "surface_forms", [])]:
103
+ plain_names.setdefault(form.strip().casefold(), m.name)
104
+
105
+ bound = 0
106
+ for source in getattr(catalog, "sources", []) or []:
107
+ for table in getattr(source, "tables", []) or []:
108
+ by_name = {c.name.strip().casefold(): c for c in table.columns}
109
+ ratio_cols: set[str] = set()
110
+ for measure, spec in ratios:
111
+ target = by_name.get(spec["column"].casefold())
112
+ terms = [*spec["numerator"], *spec["denominator"]]
113
+ cols = [by_name.get(name.casefold()) for _, name in terms]
114
+ if target is None or any(c is None or c.data_type not in _NUMERIC for c in cols):
115
+ continue
116
+ ids = iter(c.column_id for c in cols)
117
+ num = [{"column_id": next(ids), "sign": s} for s, _ in spec["numerator"]]
118
+ den = [{"column_id": next(ids), "sign": s} for s, _ in spec["denominator"]]
119
+ target.measure_method = {
120
+ "kind": "ratio",
121
+ "measure": measure,
122
+ "numerator": num,
123
+ "denominator": den,
124
+ "scale": spec["scale"],
125
+ "label": spec["label"],
126
+ }
127
+ ratio_cols.add(target.column_id)
128
+ bound += 1
129
+ for name, column in by_name.items():
130
+ measure = plain_names.get(name)
131
+ if measure and column.column_id not in ratio_cols:
132
+ column.measure_method = {"kind": "plain", "measure": measure}
133
+ logger.info("computed_measures_bound", extra={"n_bound": bound, "n_defined": len(ratios)})
134
+ return bound
135
+
136
+
137
+ def mark_computed(ir: Any, catalog: Any) -> list[str]:
138
+ """Give every `avg` of a ratio-marked column its `computed` spec. In place.
139
+
140
+ Returns the aliases (or column ids) it marked. Leaves sum/min/max/count, raw
141
+ columns and an already-computed select alone.
142
+ """
143
+ from src.query.ir.models import AggSelect, ComputedRatio
144
+
145
+ columns = {
146
+ c.column_id: c
147
+ for s in getattr(catalog, "sources", []) or []
148
+ if s.source_id == ir.source_id
149
+ for t in s.tables
150
+ for c in t.columns
151
+ }
152
+ marked: list[str] = []
153
+ for item in ir.select:
154
+ if not isinstance(item, AggSelect) or item.fn != "avg" or item.computed is not None:
155
+ continue
156
+ method = getattr(columns.get(item.column_id), "measure_method", None) or {}
157
+ if method.get("kind") != "ratio":
158
+ continue
159
+ item.computed = ComputedRatio(
160
+ numerator=method["numerator"],
161
+ denominator=method["denominator"],
162
+ scale=method["scale"],
163
+ label=method.get("label") or {},
164
+ )
165
+ marked.append(item.alias or item.column_id)
166
+ return marked
167
+
168
+
169
+ # Explicit requests for the plain average (U10). Matched on the user's words, EN + ID.
170
+ _PLAIN_AVERAGE = re.compile(
171
+ r"rata[\s-]?rata\s+(sederhana|biasa|aritmatika|aritmetika)"
172
+ r"|bukan\s+(dari\s+)?(total|jumlah)\s+jam"
173
+ r"|tanpa\s+bobot|tidak\s+(di)?bobot"
174
+ r"|simple\s+(average|mean)|plain\s+(average|mean)|arithmetic\s+(average|mean)"
175
+ r"|unweighted|not\s+weighted|not\s+from\s+(summed|total)\s+hours",
176
+ re.IGNORECASE,
177
+ )
178
+
179
+
180
+ def wants_plain_average(*texts: str | None) -> bool:
181
+ """True when the user's own words ask for a plain (unweighted) average."""
182
+ return any(t and _PLAIN_AVERAGE.search(t) for t in texts)
src/knowledge_domain/models.py CHANGED
@@ -87,6 +87,11 @@ class Measure(BaseModel):
87
  # Resolved at read time when a catalog is supplied. None means the measure
88
  # is described but not bound to any column the user actually has.
89
  catalog_binding: str | None = None
 
 
 
 
 
90
  mention_count: int = 0
91
  # False when the entry is being read ahead of expert approval (dev only).
92
  approved: bool = False
 
87
  # Resolved at read time when a catalog is supplied. None means the measure
88
  # is described but not bound to any column the user actually has.
89
  catalog_binding: str | None = None
90
+ # How to compute this measure over several rows, when an expert has said so on
91
+ # the approved formula entry (2026-11-09, RATIO_MEASURES_REQUIREMENTS M2):
92
+ # {column, numerator: [[sign, column]], denominator: [[sign, column]], scale,
93
+ # label: {id, en}}, by column NAME. Executed by `computed.bind_computed_measures`.
94
+ compute: dict | None = None
95
  mention_count: int = 0
96
  # False when the entry is being read ahead of expert approval (dev only).
97
  approved: bool = False
src/query/compiler/pandas.py CHANGED
@@ -348,6 +348,40 @@ def _group_agg_series(
348
  raise PandasCompilerError(f"unhandled agg fn {s.fn!r}")
349
 
350
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
351
  def _apply_agg(
352
  df: pd.DataFrame,
353
  select: list[SelectItem],
@@ -358,11 +392,31 @@ def _apply_agg(
358
  col_items = [s for s in select if isinstance(s, ColumnSelect)]
359
  group_col_names = [cols_by_id[col_id].name for col_id in group_by]
360
 
 
 
 
 
 
 
 
 
 
361
  if group_col_names:
362
  grouped = df.groupby(group_col_names, sort=False)
 
 
 
 
 
 
 
 
 
 
 
363
  series_list = [
364
- _group_agg_series(grouped, s, cols_by_id).rename(_agg_output_name(s, cols_by_id))
365
- for s in agg_items
366
  ]
367
  result = pd.concat(series_list, axis=1).reset_index()
368
  rename_map = {
@@ -374,8 +428,16 @@ def _apply_agg(
374
  result = result.rename(columns=rename_map)
375
  else:
376
  row = {
377
- _agg_output_name(s, cols_by_id): _scalar_agg(df, s, cols_by_id)
378
- for s in agg_items
 
 
 
 
 
 
 
 
379
  }
380
  result = pd.DataFrame([row])
381
 
 
348
  raise PandasCompilerError(f"unhandled agg fn {s.fn!r}")
349
 
350
 
351
+ def _computed_parts(
352
+ df: pd.DataFrame, spec: Any, cols_by_id: dict[str, Column]
353
+ ) -> tuple[pd.Series, pd.Series]:
354
+ """Per-row numerator and denominator of a computed ratio (mirrors the SQL form):
355
+ each term as float, signed, and NaN on rows where ANY term is missing."""
356
+ terms = [*spec.numerator, *spec.denominator]
357
+ values = {
358
+ t.column_id: pd.to_numeric(df[cols_by_id[t.column_id].name], errors="coerce")
359
+ .astype(float)
360
+ for t in terms
361
+ }
362
+ keep = pd.Series(True, index=df.index)
363
+ for v in values.values():
364
+ keep &= v.notna()
365
+
366
+ def side(side_terms) -> pd.Series:
367
+ total = pd.Series(0.0, index=df.index)
368
+ for t in side_terms:
369
+ total = total + (values[t.column_id] if t.sign == "+" else -values[t.column_id])
370
+ return total.where(keep)
371
+
372
+ return side(spec.numerator), side(spec.denominator)
373
+
374
+
375
+ def _ratio(num: Any, den: Any, scale: int) -> Any:
376
+ """scale * num / den, with a zero or missing denominator giving NaN, never inf."""
377
+ if isinstance(num, pd.Series):
378
+ out = scale * num / den.where(den != 0)
379
+ return out.replace([float("inf"), float("-inf")], float("nan"))
380
+ if pd.isna(num) or pd.isna(den) or den == 0:
381
+ return float("nan")
382
+ return scale * num / den
383
+
384
+
385
  def _apply_agg(
386
  df: pd.DataFrame,
387
  select: list[SelectItem],
 
392
  col_items = [s for s in select if isinstance(s, ColumnSelect)]
393
  group_col_names = [cols_by_id[col_id].name for col_id in group_by]
394
 
395
+ # Computed ratios (2026-11-09): per-row parts as helper columns, summed per group.
396
+ computed: dict[int, tuple[str, str]] = {}
397
+ for i, s in enumerate(agg_items):
398
+ if s.computed is not None:
399
+ num, den = _computed_parts(df, s.computed, cols_by_id)
400
+ names = (f"__cnum_{i}", f"__cden_{i}")
401
+ df = df.assign(**{names[0]: num, names[1]: den})
402
+ computed[i] = names
403
+
404
  if group_col_names:
405
  grouped = df.groupby(group_col_names, sort=False)
406
+
407
+ def series_for(i: int, s: AggSelect) -> pd.Series:
408
+ if i in computed:
409
+ num_name, den_name = computed[i]
410
+ return _ratio(
411
+ grouped[num_name].sum(min_count=1),
412
+ grouped[den_name].sum(min_count=1),
413
+ s.computed.scale,
414
+ )
415
+ return _group_agg_series(grouped, s, cols_by_id)
416
+
417
  series_list = [
418
+ series_for(i, s).rename(_agg_output_name(s, cols_by_id))
419
+ for i, s in enumerate(agg_items)
420
  ]
421
  result = pd.concat(series_list, axis=1).reset_index()
422
  rename_map = {
 
428
  result = result.rename(columns=rename_map)
429
  else:
430
  row = {
431
+ _agg_output_name(s, cols_by_id): (
432
+ _ratio(
433
+ df[computed[i][0]].sum(min_count=1),
434
+ df[computed[i][1]].sum(min_count=1),
435
+ s.computed.scale,
436
+ )
437
+ if i in computed
438
+ else _scalar_agg(df, s, cols_by_id)
439
+ )
440
+ for i, s in enumerate(agg_items)
441
  }
442
  result = pd.DataFrame([row])
443
 
src/query/compiler/sql.py CHANGED
@@ -170,6 +170,8 @@ class SqlCompiler(BaseCompiler):
170
  def _compile_agg(
171
  self, item: AggSelect, cols_by_id: dict[str, ColRef], index: int
172
  ) -> str:
 
 
173
  if item.fn == "count_distinct":
174
  if item.column_id is None:
175
  raise SqlCompilerError(
@@ -187,6 +189,44 @@ class SqlCompiler(BaseCompiler):
187
  ref = self._require_col(cols_by_id, item.column_id, f"select[{index}]")
188
  return f"{item.fn.upper()}({self._qcol(ref)})"
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  def _build_from(
191
  self, base_table: Table, ir: QueryIR, cols_by_id: dict[str, ColRef]
192
  ) -> str:
 
170
  def _compile_agg(
171
  self, item: AggSelect, cols_by_id: dict[str, ColRef], index: int
172
  ) -> str:
173
+ if item.computed is not None:
174
+ return self._compile_computed(item, cols_by_id, index)
175
  if item.fn == "count_distinct":
176
  if item.column_id is None:
177
  raise SqlCompilerError(
 
189
  ref = self._require_col(cols_by_id, item.column_id, f"select[{index}]")
190
  return f"{item.fn.upper()}({self._qcol(ref)})"
191
 
192
+ def _compile_computed(
193
+ self, item: AggSelect, cols_by_id: dict[str, ColRef], index: int
194
+ ) -> str:
195
+ """`scale * (Σnum) / NULLIF(Σden, 0)` over rows where every term is set.
196
+
197
+ Each term is cast to double precision first, so int inputs never truncate,
198
+ and every SUM sees the same rows, so a NULL in one term cannot leave that row
199
+ in one side of the ratio only (2026-11-09, RATIO_MEASURES_REQUIREMENTS M1).
200
+ `scale` is a validated literal (1 or 100), never a parameter.
201
+ """
202
+ spec = item.computed
203
+ terms = [*spec.numerator, *spec.denominator]
204
+ refs = {
205
+ t.column_id: self._qcol(
206
+ self._require_col(cols_by_id, t.column_id, f"select[{index}].computed")
207
+ )
208
+ for t in terms
209
+ }
210
+ guard = " AND ".join(f"{q} IS NOT NULL" for q in dict.fromkeys(refs.values()))
211
+
212
+ def side(side_terms) -> str:
213
+ out = ""
214
+ for k, t in enumerate(side_terms):
215
+ summed = (
216
+ f"SUM(CASE WHEN {guard} THEN CAST({refs[t.column_id]} AS double "
217
+ "precision) END)"
218
+ )
219
+ if k == 0:
220
+ out = summed if t.sign == "+" else f"-{summed}"
221
+ else:
222
+ out += f" {t.sign} {summed}"
223
+ return f"({out})"
224
+
225
+ return (
226
+ f"(CAST({int(spec.scale)} AS double precision) * {side(spec.numerator)} "
227
+ f"/ NULLIF({side(spec.denominator)}, 0))"
228
+ )
229
+
230
  def _build_from(
231
  self, base_table: Table, ir: QueryIR, cols_by_id: dict[str, ColRef]
232
  ) -> str:
src/query/executor/tabular.py CHANGED
@@ -306,6 +306,16 @@ def _render_query(ir: QueryIR, cols_by_id: dict) -> str:
306
  group_names = [cols_by_id[g].name for g in ir.group_by]
307
  parts.append(f'.groupby({group_names})')
308
  for agg in aggs:
 
 
 
 
 
 
 
 
 
 
309
  col = f'["{cols_by_id[agg.column_id].name}"]' if agg.column_id else ""
310
  fn_map = {"count": "count()", "count_distinct": "nunique()", "sum": "sum()", "avg": "mean()", "min": "min()", "max": "max()"}
311
  parts.append(f'{col}.{fn_map.get(agg.fn, agg.fn + "()")}')
 
306
  group_names = [cols_by_id[g].name for g in ir.group_by]
307
  parts.append(f'.groupby({group_names})')
308
  for agg in aggs:
309
+ if agg.computed is not None: # ratio of summed inputs (2026-11-09)
310
+ def _side(terms) -> str:
311
+ return " ".join(
312
+ f'{t.sign} ["{cols_by_id[t.column_id].name}"].sum()' for t in terms
313
+ )
314
+ parts.append(
315
+ f".apply({agg.computed.scale} * ({_side(agg.computed.numerator)}) / "
316
+ f"({_side(agg.computed.denominator)}))"
317
+ )
318
+ continue
319
  col = f'["{cols_by_id[agg.column_id].name}"]' if agg.column_id else ""
320
  fn_map = {"count": "count()", "count_distinct": "nunique()", "sum": "sum()", "avg": "mean()", "min": "min()", "max": "max()"}
321
  parts.append(f'{col}.{fn_map.get(agg.fn, agg.fn + "()")}')
src/query/ir/models.py CHANGED
@@ -47,11 +47,35 @@ class ColumnSelect(BaseModel):
47
  alias: str | None = None
48
 
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  class AggSelect(BaseModel):
51
  kind: Literal["agg"] = "agg"
52
  fn: AggFn
53
  column_id: str | None = None
54
  alias: str | None = None
 
55
 
56
 
57
  SelectItem = ColumnSelect | AggSelect
 
47
  alias: str | None = None
48
 
49
 
50
+ class ComputedTerm(BaseModel):
51
+ column_id: str
52
+ sign: Literal["+", "-"] = "+"
53
+
54
+
55
+ class ComputedRatio(BaseModel):
56
+ """How an `avg` of a ratio measure is computed from summed inputs (2026-11-09).
57
+
58
+ `scale * (Σnumerator) / Σdenominator`, each side a signed sum of columns, over
59
+ the rows where every term is non-null. Written by code, never by the planner:
60
+ the planner's `avg(pa)` is marked with this when the approved domain knowledge
61
+ defines the measure from summed hours (docs/plans/RATIO_MEASURES_REQUIREMENTS.md).
62
+ `column_id` on the AggSelect stays the measure's own column, so every check
63
+ that reads the select still sees `avg(<measure>)`.
64
+ """
65
+
66
+ numerator: list[ComputedTerm] = Field(min_length=1, max_length=4)
67
+ denominator: list[ComputedTerm] = Field(min_length=1, max_length=4)
68
+ scale: Literal[1, 100] = 1
69
+ # Method name for the answer's scope line, by language ("id", "en").
70
+ label: dict[str, str] = Field(default_factory=dict)
71
+
72
+
73
  class AggSelect(BaseModel):
74
  kind: Literal["agg"] = "agg"
75
  fn: AggFn
76
  column_id: str | None = None
77
  alias: str | None = None
78
+ computed: ComputedRatio | None = None
79
 
80
 
81
  SelectItem = ColumnSelect | AggSelect
src/query/ir/repair.py CHANGED
@@ -177,11 +177,39 @@ class IRRepairer:
177
  _rehome_same_name_columns(ir, source, repairs)
178
  if source.source_type == "schema":
179
  _add_lookup_joins(ir, source, repairs)
 
180
  _add_ranking_count(ir, source, repairs)
181
 
182
  return ir, repairs
183
 
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  # Grain columns only (a `date`-only table is usually an event log, not one row per period).
186
  _PERIOD_NAMES = frozenset({"year", "month", "week", "quarter"})
187
 
@@ -225,6 +253,14 @@ def _rehome_same_name_columns(ir: QueryIR, source: Source, repairs: list[Repair]
225
  return
226
  in_query = {c.column_id for t in in_play for c in tables[t].columns}
227
  name_of = {c.column_id: c.name for t in source.tables for c in t.columns}
 
 
 
 
 
 
 
 
228
 
229
  def rehome(col_id: str, where: str) -> str:
230
  if col_id in in_query or col_id not in name_of:
@@ -233,6 +269,8 @@ def _rehome_same_name_columns(ir: QueryIR, source: Source, repairs: list[Repair]
233
  c.column_id for t in in_play for c in tables[t].columns
234
  if c.name == name_of[col_id]
235
  ]
 
 
236
  if len(same) != 1:
237
  return col_id
238
  repairs.append(Repair(where, col_id, same[0]))
 
177
  _rehome_same_name_columns(ir, source, repairs)
178
  if source.source_type == "schema":
179
  _add_lookup_joins(ir, source, repairs)
180
+ _group_pinned_selects(ir, repairs)
181
  _add_ranking_count(ir, source, repairs)
182
 
183
  return ir, repairs
184
 
185
 
186
+ def _group_pinned_selects(ir: QueryIR, repairs: list[Repair]) -> None:
187
+ """A bare select pinned to ONE value by a filter joins `group_by` (2026-11-10).
188
+
189
+ `SELECT site, section, AVG(pa) … WHERE section = 'OB LOADER' GROUP BY site` is
190
+ invalid SQL, but `section` holds one value in every row that passes the filter, so
191
+ grouping by it too changes nothing — it is the fix the planner makes itself 20
192
+ times out of 22. The 2 slips cost a refused turn and a wrong answer
193
+ (PLANNER_SHAPE_FIXES_HANDOFF §1). Only when the query is ALREADY grouped: on an
194
+ ungrouped aggregate it would turn a one-row "no data" result into zero rows.
195
+ Never for an unpinned column — that changes the grain, the planner's call.
196
+ """
197
+ if not ir.group_by:
198
+ return
199
+ pinned = {
200
+ f.column_id for f in ir.filters
201
+ if f.op == "=" or (f.op == "in" and isinstance(f.value, list) and len(f.value) == 1)
202
+ }
203
+ for item in ir.select:
204
+ if (
205
+ getattr(item, "kind", "") == "column"
206
+ and item.column_id in pinned
207
+ and item.column_id not in ir.group_by
208
+ ):
209
+ ir.group_by.append(item.column_id)
210
+ repairs.append(Repair(f"group_by[{len(ir.group_by) - 1}]", "", item.column_id))
211
+
212
+
213
  # Grain columns only (a `date`-only table is usually an event log, not one row per period).
214
  _PERIOD_NAMES = frozenset({"year", "month", "week", "quarter"})
215
 
 
253
  return
254
  in_query = {c.column_id for t in in_play for c in tables[t].columns}
255
  name_of = {c.column_id: c.name for t in source.tables for c in t.columns}
256
+ # Join-key tie-break (2026-11-11, PLANNER_SHAPE_FIXES_HANDOFF §8): two in-play
257
+ # columns of the same name that are the two sides of one of the query's own joins
258
+ # hold the same value wherever both exist, so the left (preserved) side is used —
259
+ # `site_id` copied from another table into a query that joins the site lookup
260
+ # was rejected 4 times on the demo source. Any other tie still refuses.
261
+ join_left = {
262
+ frozenset((j.left_column_id, j.right_column_id)): j.left_column_id for j in ir.joins
263
+ }
264
 
265
  def rehome(col_id: str, where: str) -> str:
266
  if col_id in in_query or col_id not in name_of:
 
269
  c.column_id for t in in_play for c in tables[t].columns
270
  if c.name == name_of[col_id]
271
  ]
272
+ if len(same) == 2 and frozenset(same) in join_left:
273
+ same = [join_left[frozenset(same)]]
274
  if len(same) != 1:
275
  return col_id
276
  repairs.append(Repair(where, col_id, same[0]))
src/query/ir/validator.py CHANGED
@@ -17,6 +17,24 @@ from .operators import (
17
  )
18
 
19
  _NULLARY_FILTER_OPS = frozenset({"is_null", "is_not_null"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
 
22
  class IRValidationError(Exception):
@@ -62,6 +80,8 @@ class IRValidator:
62
  f"{where}.fn={item.fn!r} requires a column_id "
63
  "(only 'count' may omit it for COUNT(*))"
64
  )
 
 
65
  if item.alias:
66
  select_aliases.add(item.alias)
67
 
@@ -104,25 +124,46 @@ class IRValidator:
104
  has_agg = any(item.kind == "agg" for item in ir.select)
105
  if ir.group_by or has_agg:
106
  grouped = set(ir.group_by)
107
- for i, item in enumerate(ir.select):
108
- if item.kind == "column" and item.column_id not in grouped:
109
- context = (
110
- "while group_by is present"
111
- if ir.group_by
112
- else "alongside an aggregate with no group_by"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  )
114
- fix = (
115
- "every selected column must either appear in group_by or be "
116
- "wrapped in an aggregate"
117
- if ir.group_by
118
- else "either add it to group_by or wrap it in an aggregate"
 
 
 
 
 
 
119
  )
120
- raise IRValidationError(
121
- f"select[{i}].column_id {item.column_id!r} is selected bare "
122
- f"{context} — {fix} "
123
- f'(e.g. {{"kind": "agg", "fn": "sum", '
124
- f'"column_id": {item.column_id!r}}})'
125
  )
 
126
 
127
  for i, ob in enumerate(ir.order_by):
128
  if ob.column_id not in columns_by_id and ob.column_id not in select_aliases:
@@ -141,6 +182,26 @@ class IRValidator:
141
  f"limit {ir.limit} exceeds hard cap {LIMIT_HARD_CAP}"
142
  )
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  @staticmethod
145
  def _reject_contradictory_ranges(ir: QueryIR) -> None:
146
  """Reject disjoint BETWEEN ranges on the same column.
 
17
  )
18
 
19
  _NULLARY_FILTER_OPS = frozenset({"is_null", "is_not_null"})
20
+ _NUMERIC_TYPES = frozenset({"int", "decimal"})
21
+ _PERIOD_NAMES = frozenset({"year", "month", "week", "quarter"})
22
+
23
+
24
+ def _is_measure(col: Column) -> bool:
25
+ """Role of a column for the bare-select message: the dictionary's `classify` when
26
+ set, else a number that is neither a period nor a key."""
27
+ role = (col.classify or "").strip().lower()
28
+ if role in ("measure", "dimension"):
29
+ return role == "measure"
30
+ name = col.name.lower()
31
+ return (
32
+ col.data_type in _NUMERIC_TYPES
33
+ and name not in _PERIOD_NAMES
34
+ and name != "id"
35
+ and not name.endswith("_id")
36
+ and not col.is_key
37
+ )
38
 
39
 
40
  class IRValidationError(Exception):
 
80
  f"{where}.fn={item.fn!r} requires a column_id "
81
  "(only 'count' may omit it for COUNT(*))"
82
  )
83
+ if getattr(item, "computed", None) is not None:
84
+ self._validate_computed(item, columns_by_id, where)
85
  if item.alias:
86
  select_aliases.add(item.alias)
87
 
 
124
  has_agg = any(item.kind == "agg" for item in ir.select)
125
  if ir.group_by or has_agg:
126
  grouped = set(ir.group_by)
127
+ bare = [
128
+ (i, item.column_id) for i, item in enumerate(ir.select)
129
+ if item.kind == "column" and item.column_id not in grouped
130
+ ]
131
+ if bare:
132
+ # Every offender, by NAME and by role (2026-11-10). The message used
133
+ # to name the first one only and offer "add it to group_by" for any
134
+ # column: a plan with a label and three measures bare was told about
135
+ # the label, and its retry grouped by the measure itself — one row per
136
+ # value, counted as units (PLANNER_SHAPE_FIXES_HANDOFF §1).
137
+ context = (
138
+ "while group_by is present"
139
+ if ir.group_by
140
+ else "alongside an aggregate with no group_by"
141
+ )
142
+ measures, labels = [], []
143
+ for _, col_id in bare:
144
+ col = columns_by_id[col_id]
145
+ (measures if _is_measure(col) else labels).append(
146
+ f"{col.name!r} ({col_id})"
147
  )
148
+ first_i, first_id = bare[0]
149
+ parts = [
150
+ f"select[{first_i}].column_id {first_id!r} is selected bare {context}."
151
+ ]
152
+ if measures:
153
+ parts.append(
154
+ f"Measure(s) {', '.join(measures)}: wrap each in an aggregate, "
155
+ 'e.g. {"kind": "agg", "fn": "avg", "column_id": "<id>"} — the '
156
+ "catalog's `aggregate with`, else avg for rates and scores, sum "
157
+ "for totals. Never add a measure to group_by: it keeps one row "
158
+ "per value."
159
  )
160
+ if labels:
161
+ parts.append(
162
+ f"Column(s) {', '.join(labels)}: add each to group_by (this "
163
+ "splits a group when the column varies inside it) or drop it "
164
+ "from select."
165
  )
166
+ raise IRValidationError(" ".join(parts))
167
 
168
  for i, ob in enumerate(ir.order_by):
169
  if ob.column_id not in columns_by_id and ob.column_id not in select_aliases:
 
182
  f"limit {ir.limit} exceeds hard cap {LIMIT_HARD_CAP}"
183
  )
184
 
185
+ def _validate_computed(
186
+ self, item: Any, columns_by_id: dict[str, Column], where: str
187
+ ) -> None:
188
+ """A computed ratio rides only on `avg` of a column, over numeric terms in
189
+ the tables of this query (2026-11-09, RATIO_MEASURES_REQUIREMENTS M1)."""
190
+ if item.fn != "avg" or item.column_id is None:
191
+ raise IRValidationError(
192
+ f"{where}.computed is only allowed on fn='avg' of a column"
193
+ )
194
+ for side in ("numerator", "denominator"):
195
+ for k, term in enumerate(getattr(item.computed, side)):
196
+ col = self._require_column(
197
+ columns_by_id, term.column_id, f"{where}.computed.{side}[{k}]"
198
+ )
199
+ if col.data_type not in _NUMERIC_TYPES:
200
+ raise IRValidationError(
201
+ f"{where}.computed.{side}[{k}]: column {col.name!r} is "
202
+ f"{col.data_type!r}, not numeric"
203
+ )
204
+
205
  @staticmethod
206
  def _reject_contradictory_ranges(ir: QueryIR) -> None:
207
  """Reject disjoint BETWEEN ranges on the same column.
src/tools/data_access.py CHANGED
@@ -297,6 +297,13 @@ class DataAccessToolInvoker:
297
  # The filters as values, for the report's scope lines (2026-10-28):
298
  # `query` keeps only `:p_0` placeholders, so "site = SDJ" was lost.
299
  "filters": _filter_summary(catalog, ir),
 
 
 
 
 
 
 
300
  },
301
  )
302
 
@@ -390,6 +397,63 @@ def _filter_summary(catalog: Catalog, ir: QueryIR) -> list[dict[str, Any]]:
390
  return []
391
 
392
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  def _pii_column_names(
394
  catalog: Catalog, ir: QueryIR, output_columns: list[str]
395
  ) -> list[str]:
 
297
  # The filters as values, for the report's scope lines (2026-10-28):
298
  # `query` keeps only `:p_0` placeholders, so "site = SDJ" was lost.
299
  "filters": _filter_summary(catalog, ir),
300
+ # How each averaged measure was computed, for the scope line
301
+ # (2026-11-09, RATIO_MEASURES_REQUIREMENTS M5).
302
+ "methods": _method_summary(catalog, ir),
303
+ # The row cut and the sort it kept, for the Assembler's order line
304
+ # (2026-11-09): "the 10 lowest" must not have its top row called
305
+ # the overall highest.
306
+ "cut": _cut_summary(catalog, ir),
307
  },
308
  )
309
 
 
397
  return []
398
 
399
 
400
+ def _method_summary(catalog: Catalog, ir: QueryIR) -> list[dict[str, Any]]:
401
+ """How each `avg` of a known measure was computed (M5, 2026-11-09).
402
+
403
+ `{"kind": "ratio", "label": {...}}` when the query computed it from summed inputs;
404
+ `{"kind": "plain", "measure": ...}` when it averaged a measure the domain knowledge
405
+ defines by a formula (no usable definition, or the user asked for a plain average).
406
+ Nothing for other selects. Never raises.
407
+ """
408
+ try:
409
+ cols_by_id = {
410
+ c.column_id: c for s in catalog.sources for t in s.tables for c in t.columns
411
+ }
412
+ out: list[dict[str, Any]] = []
413
+ for item in ir.select:
414
+ if getattr(item, "kind", "") != "agg" or item.fn != "avg":
415
+ continue
416
+ if item.computed is not None:
417
+ out.append({"kind": "ratio", "label": dict(item.computed.label)})
418
+ continue
419
+ method = getattr(cols_by_id.get(item.column_id), "measure_method", None) or {}
420
+ if method.get("kind") in ("ratio", "plain"):
421
+ out.append({"kind": "plain", "measure": method.get("measure")})
422
+ return out
423
+ except Exception:
424
+ return []
425
+
426
+
427
+ def _cut_summary(catalog: Catalog, ir: QueryIR) -> dict[str, Any] | None:
428
+ """`{"limit", "column", "dir"}` when the query kept only the first N rows, else None.
429
+
430
+ `column` / `dir` are the first sort key as it appears in the result (a select
431
+ alias, else the catalog column name), or None without a sort. Never raises.
432
+ """
433
+ try:
434
+ if ir.limit is None:
435
+ return None
436
+ column: str | None = None
437
+ direction: str | None = None
438
+ if ir.order_by:
439
+ key = ir.order_by[0]
440
+ direction = key.dir
441
+ aliases = {
442
+ getattr(item, "alias", None) for item in ir.select
443
+ } - {None}
444
+ if key.column_id in aliases:
445
+ column = key.column_id
446
+ else:
447
+ names = {
448
+ c.column_id: c.name
449
+ for s in catalog.sources for t in s.tables for c in t.columns
450
+ }
451
+ column = names.get(key.column_id, key.column_id)
452
+ return {"limit": ir.limit, "column": column, "dir": direction}
453
+ except Exception:
454
+ return None
455
+
456
+
457
  def _pii_column_names(
458
  catalog: Catalog, ir: QueryIR, output_columns: list[str]
459
  ) -> list[str]:
src/traceability/resolve.py CHANGED
@@ -105,7 +105,25 @@ def resolve_data_used(
105
  )
106
  )
107
  elif isinstance(s, AggSelect):
108
- if s.column_id:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  _tag(s.column_id, "aggregated")
110
  formula = f"{s.fn.upper()}({idx.col_qual(s.column_id)})"
111
  frm = idx.col_qual(s.column_id)
 
105
  )
106
  )
107
  elif isinstance(s, AggSelect):
108
+ computed = getattr(s, "computed", None)
109
+ if s.column_id and computed is not None:
110
+ # A ratio of summed inputs (2026-11-09): show the real formula.
111
+ def _side(terms) -> str:
112
+ out = ""
113
+ for k, t in enumerate(terms):
114
+ _tag(t.column_id, "aggregated")
115
+ term = f"SUM({idx.col_qual(t.column_id)})"
116
+ out += (term if t.sign == "+" else f"-{term}") if k == 0 else (
117
+ f" {t.sign} {term}"
118
+ )
119
+ return out
120
+
121
+ formula = (
122
+ f"{computed.scale} * ({_side(computed.numerator)}) / "
123
+ f"({_side(computed.denominator)})"
124
+ )
125
+ frm = idx.col_qual(s.column_id)
126
+ elif s.column_id:
127
  _tag(s.column_id, "aggregated")
128
  formula = f"{s.fn.upper()}({idx.col_qual(s.column_id)})"
129
  frm = idx.col_qual(s.column_id)