Download src/agents/planner/validator.py from DataEyond/Agentic-Service-Data-Eyond-Catalog: direct link, hf CLI and curl.
- Browser
- Download file 59.2 kB
-
https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/94de89b331583415f150d69415b964754bb481bc/src/agents/planner/validator.py
- Command line
-
hf download hf://spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog@94de89b331583415f150d69415b964754bb481bc/src/agents/planner/validator.py
-
curl -L -o validator.py https://huggingface.co/spaces/DataEyond/Agentic-Service-Data-Eyond-Catalog/resolve/94de89b331583415f150d69415b964754bb481bc/src/agents/planner/validator.py
59.2 kB
| """PlannerValidator β checks a TaskList before it reaches the TaskRunner. | |
| Runs the 8 checks from AGENT_ARCHITECTURE_CONTEXT_new.md Β§7.3. On failure it | |
| raises `PlannerValidationError` with a message specific enough that the planner | |
| can be re-prompted to self-correct (the retry loop lives in service.py). | |
| Check #1 (Pydantic parse) is enforced at the structured-output boundary β by the | |
| time a `TaskList` reaches here it has already parsed; this validator additionally | |
| rejects structurally-invalid plans (duplicate ids, dangling edges, cycles). | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from collections.abc import Iterator | |
| from pydantic import ValidationError | |
| from src.middlewares.logging import get_logger | |
| from ...catalog.models import Catalog | |
| from ...query.ir.models import QueryIR | |
| from ...query.ir.repair import IRRepairer | |
| from ...query.ir.validator import IRValidationError, IRValidator | |
| from ...tools.analytics.agg_vocab import normalise_agg | |
| from ...tools.analytics.aggregation import SUPPORTED_AGGS | |
| from .contracts import ToolRegistry | |
| from .errors import PlannerValidationError | |
| from .inputs import Constraints | |
| from .schemas import PLACEHOLDER_RE, TaskList | |
| logger = get_logger("ir_repair") | |
| # Heuristic: a checkable success_criteria mentions a measurable signal. | |
| # | |
| # BOTH LANGUAGES, because the planner writes this field in the language of the | |
| # question and the product is majority-Indonesian (A23 Bug 7). English-only tokens | |
| # made the check unpassable on an ID turn except by luck: every Indonesian turn in | |
| # the 2026-09-17 dry-run burned a retry here, and one degenerated further β told | |
| # "not checkable" twice, the model dropped `success_criteria` and `expected_output` | |
| # altogether and the turn died. | |
| # | |
| # This is parity, not loosening. Each Indonesian token mirrors an English one | |
| # already trusted ("menghasilkan" = "produced", "jumlah"/"baris" = "count", | |
| # "minimal"/"lebih"/"kurang" = "above"/"below"), so a criterion that would fail in | |
| # English still fails in Indonesian. | |
| _CHECKABLE_TOKENS = ( | |
| # English. "rows"/"returned"/"records" restore parity with the Indonesian | |
| # "baris"/"menghasilkan" below: without them an English criterion phrased | |
| # "Returned rows containing component and breakdown_duration_total" failed | |
| # while its Indonesian twin passed, costing a retry on English turns only. | |
| "rate", "count", "match", "produced", "above", "below", "equal", | |
| "rows", "returned", "records", | |
| # Indonesian | |
| "tingkat", "jumlah", "baris", "cocok", "menghasilkan", | |
| "minimal", "lebih", "kurang", "sama", "persen", | |
| ) | |
| # Tool categories whose output is NOT analyzable data rows: `catalog.introspection` | |
| # (check_data/check_knowledge β catalog metadata) and `retrieval.documents` | |
| # (retrieve_knowledge β prose chunks). An analyze_* `data` handoff must not come from | |
| # one of these β their output is kind="table"/documents so the structural checks pass, | |
| # but feeding metadata rows to analyze_* makes it fail to find the requested columns. | |
| # A denylist (not an allowlist) so any data/table-producing tool β retrieve_data AND a | |
| # table-producing analyze_* chained into another analyze_* β stays valid. | |
| _NON_DATA_SOURCE_CATEGORIES = frozenset({"catalog.introspection", "retrieval.documents"}) | |
| # DFS colors for cycle detection. | |
| _WHITE, _GREY, _BLACK = 0, 1, 2 | |
| class PlannerValidator: | |
| def __init__( | |
| self, | |
| ir_validator: IRValidator | None = None, | |
| ir_repairer: IRRepairer | None = None, | |
| ) -> None: | |
| self._ir_validator = ir_validator or IRValidator() | |
| self._ir_repairer = ir_repairer or IRRepairer() | |
| def validate( | |
| self, | |
| task_list: TaskList, | |
| registry: ToolRegistry, | |
| catalog: Catalog, | |
| constraints: Constraints, | |
| ) -> None: | |
| tasks = task_list.tasks | |
| # Infeasible sentinel (planner.md "When the catalog cannot answer"): an | |
| # empty plan carrying `infeasible_reason` is a VALID outcome β the | |
| # coordinator renders it as an honest data-gap answer instead of the | |
| # planner force-mapping the question onto unrelated columns. A non-empty | |
| # plan keeps normal validation and the reason is ignored (a real plan | |
| # wins over a hedge). | |
| if task_list.infeasible_reason and not tasks: | |
| return | |
| # Check 6 β plan non-empty and within the task cap. | |
| if not tasks: | |
| raise PlannerValidationError("plan is empty: at least one task is required") | |
| if len(tasks) > constraints.max_tasks: | |
| raise PlannerValidationError( | |
| f"plan has {len(tasks)} tasks, exceeds max_tasks={constraints.max_tasks}" | |
| ) | |
| ids = [t.id for t in tasks] | |
| if len(set(ids)) != len(ids): | |
| dupes = sorted({i for i in ids if ids.count(i) > 1}) | |
| raise PlannerValidationError(f"duplicate task id(s): {dupes}") | |
| id_set = set(ids) | |
| tasks_by_id = {t.id: t for t in tasks} | |
| known_tools = registry.names() | |
| known_sources = {s.source_id for s in catalog.sources} | |
| for task in tasks: | |
| for call in task.tool_calls: | |
| # Check 2 β every tool exists in the registry. | |
| if call.tool not in known_tools: | |
| raise PlannerValidationError( | |
| f"task {task.id}: tool {call.tool!r} not in registry " | |
| f"(known: {sorted(known_tools)})" | |
| ) | |
| spec = registry.get(call.tool) | |
| assert spec is not None # guaranteed by the membership check above | |
| # Check 8a β args carry the required keys and no unknown keys. | |
| required = set(spec.input_schema.get("required", [])) | |
| allowed = set(spec.input_schema.get("properties", {}).keys()) | required | |
| missing = required - set(call.args.keys()) | |
| if missing: | |
| raise PlannerValidationError( | |
| f"task {task.id}: tool {call.tool!r} missing required arg(s): " | |
| f"{sorted(missing)}" | |
| ) | |
| unknown = set(call.args.keys()) - allowed | |
| if unknown: | |
| raise PlannerValidationError( | |
| f"task {task.id}: tool {call.tool!r} has unknown arg(s): " | |
| f"{sorted(unknown)} (allowed: {sorted(allowed)})" | |
| ) | |
| # Check 8e β analyze_importance: the target must actually VARY. | |
| # | |
| # Nothing can explain a number that never moves, and the tool says so | |
| # (ConstantTargetError) β but at execution time, which is too late for a | |
| # corrective retry, so the task reaches the Assembler as a dead end. The | |
| # 2026-09-17 dry-run lost its "what drives down hours" turn exactly that | |
| # way: the planner targeted `Total_Hours` (a fixed 24-hour day, and the | |
| # catalog knew `distinct_count=1`) instead of `Total_Down_Hour`. | |
| # | |
| # Catalog stats describe the FULL column, while the tool sees a filtered | |
| # subset β which is the safe direction and the reason this check has no | |
| # false positives: a column constant over every row is constant over any | |
| # subset of them. The converse is not true, so a subset that happens to | |
| # be constant still fails at the tool, as it must. | |
| # | |
| # Matched case-INSENSITIVELY on purpose: the planner writes column names | |
| # lowercased (the dry-run passed `total_hours` for `Total_Hours`), so an | |
| # exact match would silently never fire. | |
| # Check 8h β a bare month number needs its year retrieved with it. | |
| # | |
| # analyze_trend/analyze_forecast bucket on `date_column`. When that | |
| # column holds bare month numbers 1-12, _parse_date_column resolves | |
| # them against a sibling column literally named `year`, and raises | |
| # InvalidDateColumnError when none was retrieved -- correct, but at | |
| # EXECUTION, after the data has been fetched and too late for a | |
| # corrective retry. The 2026-09-17 pa_dummy run lost its trend turn | |
| # that way: `pa.year` exists and the plan simply did not select it. | |
| # The forecast turn on the same data DID select it, so this is the | |
| # planner being inconsistent, not incapable. | |
| if call.tool in ("analyze_trend", "analyze_forecast"): | |
| _require_year_with_bare_month( | |
| task.id, call, tasks_by_id, catalog | |
| ) | |
| # Check 8l β the date column must be one the tool can read. | |
| _reject_unreadable_period(task.id, call, tasks_by_id, catalog) | |
| if call.tool == "analyze_segmentation": | |
| # Check 8n β band entities, not their period rows (2026-11-03). | |
| _reject_raw_periodic_banding(task.id, call, tasks_by_id, catalog) | |
| if call.tool in _RAW_ROW_TOOLS: | |
| # Check 8i β importance / an A/B test must not read a capped slice | |
| # (2026-10-22; ab_test + period-only filters 2026-10-28). | |
| _reject_capped_importance( | |
| task.id, call, tasks_by_id, catalog, constraints | |
| ) | |
| if call.tool == "analyze_importance": | |
| target = call.args.get("target_column") | |
| if isinstance(target, str) and target.strip(): | |
| constant = _constant_columns(catalog) | |
| if target.strip().lower() in constant: | |
| raise PlannerValidationError( | |
| f"task {task.id}: analyze_importance targets " | |
| f"{target!r}, which holds one value in every row β " | |
| "nothing can explain a column that never varies. " | |
| "Target the measure the user actually asked about " | |
| "(re-read the catalog for a column that moves), or " | |
| "drop this task." | |
| ) | |
| # Check 8c β analyze_aggregate: every aggregation FUNCTION must be one | |
| # the tool supports. Check 8a only validates arg *names* (`aggregations` | |
| # is allowed); it never looks at the function *values* inside the dict, | |
| # so an unsupported func like `std` otherwise passes validation and only | |
| # fails at execution β too late for a corrective retry, so the task | |
| # reaches the Assembler as a silent failure. Catch it here so the planner | |
| # is re-prompted to degrade to a supported function (e.g. `mean`). | |
| if call.tool == "analyze_aggregate": | |
| aggs = call.args.get("aggregations") | |
| if isinstance(aggs, dict): | |
| bad = sorted( | |
| { | |
| f | |
| for funcs in aggs.values() | |
| for f in ([funcs] if isinstance(funcs, str) else funcs or []) | |
| # `avg`/`count_distinct` are the QueryIR's names for | |
| # `mean`/`nunique`, and planner.md teaches them for the | |
| # IR β so one plan legitimately carries both spellings. | |
| # Rejecting the synonym here would fail a task the tool | |
| # now runs fine. This narrows nothing: `std`, `var` and | |
| # every other unsupported function are still rejected. | |
| # (A23 Bug 2 -- see tools/analytics/agg_vocab.) | |
| if normalise_agg(f) not in SUPPORTED_AGGS | |
| } | |
| ) | |
| if bad: | |
| raise PlannerValidationError( | |
| f"task {task.id}: analyze_aggregate has unsupported " | |
| f"aggregation function(s) {bad} (supported: " | |
| f"{sorted(SUPPORTED_AGGS)}). Use a supported function " | |
| "(e.g. mean/median); for the spread of a whole column " | |
| "use analyze_descriptive instead." | |
| ) | |
| # Check 8d β group_by keys must be plain column-name strings. A | |
| # derived grouping (a CASE/binning expression emitted as a dict) | |
| # is unhashable and crashes df.groupby at execution ("unhashable | |
| # type: 'dict'"). Reject it here so the planner is re-prompted; | |
| # bucketing a numeric column into ranges belongs to | |
| # analyze_segment, not smuggled through analyze_aggregate. | |
| group_by = call.args.get("group_by") | |
| if isinstance(group_by, list): | |
| bad_keys = [g for g in group_by if not isinstance(g, str)] | |
| if bad_keys: | |
| raise PlannerValidationError( | |
| f"task {task.id}: analyze_aggregate group_by must be " | |
| f"column names (strings); got non-string entr(ies) " | |
| f"{bad_keys}. Derived groupings (CASE/binning) are not " | |
| "supported β group by an existing column." | |
| ) | |
| # Check 3 β concrete source_id args must exist in the catalog. | |
| src = call.args.get("source_id") | |
| if isinstance(src, str) and not _is_placeholder(src): | |
| if src not in known_sources: | |
| raise PlannerValidationError( | |
| f"task {task.id}: tool {call.tool!r} references unknown " | |
| f"source_id {src!r} (known: {sorted(known_sources)})" | |
| ) | |
| # Check 8b β inline retrieve_data IR validates against the catalog. | |
| if call.tool == "retrieve_data": | |
| self._validate_inline_ir(task.id, call.args, catalog) | |
| # Check 9 β a `data` handoff (Pattern A) must reference a task that | |
| # produces analyzable data rows, not one producing catalog metadata | |
| # (check_data/check_knowledge) or documents (retrieve_knowledge). | |
| self._validate_data_source(task.id, call, tasks_by_id, registry) | |
| # Check 7 β success_criteria is checkable. | |
| if not _is_checkable(task.success_criteria): | |
| raise PlannerValidationError( | |
| f"task {task.id}: success_criteria is not checkable β include a " | |
| f"measurable signal (one of {list(_CHECKABLE_TOKENS)}); " | |
| f"got {task.success_criteria!r}" | |
| ) | |
| # Check 4 β DAG: edges resolve, placeholders resolve, no cycles. | |
| self._validate_dag(tasks_by_id, id_set) | |
| # Check 11 β a value-handoff `${t<id>.<column>}` must name a column its | |
| # upstream step actually outputs (D-5, 2026-09-23). After Check 4, so every | |
| # ref already resolves to a task. | |
| _reject_unknown_handoff_columns(tasks_by_id, catalog, registry) | |
| # Check 8k β a ranking the user reads is not cut to one row (2026-10-22). | |
| _reject_top1_rankings(tasks_by_id) | |
| def _validate_inline_ir(self, task_id: str, args: dict, catalog: Catalog) -> None: | |
| raw_ir = args.get("ir") | |
| if not isinstance(raw_ir, dict): | |
| raise PlannerValidationError( | |
| f"task {task_id}: retrieve_data.args.ir must be an inline QueryIR " | |
| f"object, got {type(raw_ir).__name__}" | |
| ) | |
| try: | |
| ir = QueryIR.model_validate(raw_ir) | |
| except ValidationError as e: | |
| raise PlannerValidationError( | |
| f"task {task_id}: retrieve_data.args.ir is not a valid QueryIR: {e}" | |
| ) from e | |
| # Canonicalize near-miss ids (LLM dropped/mutated a char in an opaque | |
| # catalog id) before validating. On a successful repair, write the fixed | |
| # IR back into the tool call so the downstream executor runs the | |
| # corrected IR β not just the validator. | |
| ir, repairs = self._ir_repairer.repair(ir, catalog) | |
| if repairs: | |
| args["ir"] = ir.model_dump() | |
| for r in repairs: | |
| logger.info( | |
| "repaired ir id", | |
| task_id=task_id, | |
| where=r.where, | |
| from_id=r.from_id, | |
| to_id=r.to_id, | |
| ) | |
| try: | |
| self._ir_validator.validate(ir, catalog) | |
| except IRValidationError as e: | |
| raise PlannerValidationError( | |
| f"task {task_id}: retrieve_data IR failed catalog validation: {e}" | |
| ) from e | |
| _reject_misleading_aliases(task_id, ir, catalog) | |
| _reject_unmatchable_filter_values(task_id, ir, catalog) | |
| _reject_typed_lookup_ids(task_id, ir, catalog) | |
| _reject_raw_periodic_ranking(task_id, ir, catalog) | |
| def _validate_data_source( | |
| task_id: str, call, tasks_by_id: dict, registry: ToolRegistry | |
| ) -> None: | |
| """The `data`/`data_right` handoff (Pattern A) must be a '${t<id>}' | |
| placeholder pointing at an upstream data-producing task. | |
| Two failure modes are rejected here: | |
| 1. The arg is present but is NOT a placeholder β e.g. the planner inlined a | |
| {table_id, source_id} table reference (or any literal) instead of | |
| chaining a retrieve_data output. analyze_* tools never self-fetch by | |
| source_id; they only consume an already-retrieved table, so such a plan | |
| can only blow up at execution ("unsupported 'data' type: dict"). Reject | |
| it here so the retry loop re-prompts the planner to add a retrieve_data | |
| task and pass its placeholder. | |
| 2. The placeholder resolves to a metadata (check_data/check_knowledge) or | |
| documents (retrieve_knowledge) task. Those pass the structural checks | |
| (check_* also returns kind="table"), but their rows are catalog schema, | |
| so a downstream analyze_* fails to find the requested columns. Resolving | |
| points at the referenced task's representative output β its last tool | |
| call (matches TaskRunner's `outputs[-1]`). | |
| `data_right` is analyze_merge's second table input (KM-703) β same Pattern A | |
| handoff, so it gets the same guard. An arg that is absent is left to Check 8a | |
| (required-arg presence). | |
| """ | |
| for arg_name in ("data", "data_right"): | |
| if arg_name not in call.args: | |
| continue | |
| data_arg = call.args[arg_name] | |
| match = ( | |
| PLACEHOLDER_RE.fullmatch(data_arg.strip()) | |
| if isinstance(data_arg, str) | |
| else None | |
| ) | |
| if match is None: | |
| raise PlannerValidationError( | |
| f"task {task_id}: tool {call.tool!r} arg {arg_name!r} must be a " | |
| f"'${{t<id>}}' placeholder referencing a retrieve_data output " | |
| f"(Pattern A), got {data_arg!r}. analyze_* tools do not fetch data " | |
| "themselves β add a retrieve_data task and pass its output." | |
| ) | |
| ref_task = tasks_by_id.get(match.group(1)) | |
| if ref_task is None or not ref_task.tool_calls: | |
| continue # a dangling placeholder is reported by the DAG check | |
| ref_tool = ref_task.tool_calls[-1].tool | |
| ref_spec = registry.get(ref_tool) | |
| if ref_spec is not None and ref_spec.category in _NON_DATA_SOURCE_CATEGORIES: | |
| raise PlannerValidationError( | |
| f"task {task_id}: tool {call.tool!r} takes its {arg_name!r} from " | |
| f"task {match.group(1)} ({ref_tool!r}, category " | |
| f"{ref_spec.category!r}), which produces metadata/documents β not " | |
| "analyzable data rows. Feed analyze_* from a data-producing tool " | |
| "(e.g. retrieve_data)." | |
| ) | |
| # Check 10 β render_chart path shape (SPINE_V2_PLAN Β§4.3): the chart's | |
| # `data` must come from a task whose tool yields a TABLE. A stats- or | |
| # series-kind upstream passes the category denylist above but cannot | |
| # be materialized into a DataFrame at execution, so the chart could | |
| # only fail late; reject pre-run so the retry re-prompts toward a | |
| # grouped retrieve_data or a table-producing analyze_*. | |
| if ( | |
| call.tool == "render_chart" | |
| and ref_spec is not None | |
| and ref_spec.output_kind != "table" | |
| ): | |
| raise PlannerValidationError( | |
| f"task {task_id}: render_chart takes its {arg_name!r} from task " | |
| f"{match.group(1)} ({ref_tool!r}), which yields " | |
| f"{ref_spec.output_kind!r} output β a chart is drawn from a TABLE. " | |
| "Feed it a table-producing task (a grouped retrieve_data, " | |
| "analyze_aggregate, analyze_merge, ...)." | |
| ) | |
| def _validate_dag(tasks_by_id: dict, id_set: set[str]) -> None: | |
| for task in tasks_by_id.values(): | |
| for dep in task.depends_on: | |
| if dep not in id_set: | |
| raise PlannerValidationError( | |
| f"task {task.id}: depends_on references unknown task {dep!r}" | |
| ) | |
| if dep == task.id: | |
| raise PlannerValidationError( | |
| f"task {task.id}: depends_on includes itself" | |
| ) | |
| cycle = _find_cycle(tasks_by_id) | |
| if cycle: | |
| raise PlannerValidationError(f"cycle detected in depends_on: {' -> '.join(cycle)}") | |
| # A `${...}` that is not a task reference (`${sdj_site_id}`) is never | |
| # resolved by the TaskRunner: it reached the database as a literal string and | |
| # the step died there (SDJ FE test 2026-10-27, "invalid input syntax for type | |
| # bigint"). Reject it here, where the planner can still correct it. | |
| for task in tasks_by_id.values(): | |
| for call in task.tool_calls: | |
| for value in _iter_arg_strings(call.args): | |
| for inner in _ANY_PLACEHOLDER_RE.findall(value): | |
| if not PLACEHOLDER_RE.fullmatch("${" + inner + "}"): | |
| raise PlannerValidationError( | |
| f"task {task.id}: '${{{inner}}}' is not a task reference " | |
| "β a placeholder is '${t<id>}' or '${t<id>.<alias>}' of " | |
| "an earlier task; to use a looked-up value, select it in " | |
| "that task and reference it by its alias" | |
| ) | |
| # A placeholder to an existing task that is not upstream is the planner | |
| # writing the handoff and forgetting its depends_on edge (EMA replay | |
| # 2026-10-27: "${t3.site_id}" with no "t3" dependency β a whole retry spent | |
| # on bookkeeping). Add the edge in place when it creates no cycle; the | |
| # target then runs first, which is all the edge means. A cycle reverts it, | |
| # and the check below rejects as before. | |
| for task in tasks_by_id.values(): | |
| for ref in sorted(_placeholder_refs(task)): | |
| if ref not in id_set or ref == task.id: | |
| continue | |
| if ref in _all_ancestors(tasks_by_id)[task.id]: | |
| continue | |
| task.depends_on.append(ref) | |
| if _find_cycle(tasks_by_id): | |
| task.depends_on.remove(ref) | |
| else: | |
| logger.info("added missing depends_on", task_id=task.id, ref=ref) | |
| # On an acyclic graph, a placeholder is safe iff its target is a | |
| # transitive ancestor β i.e. guaranteed to have completed before this | |
| # task runs. Requiring a *direct* depends_on would wrongly reject valid | |
| # plans that depend on the target through an intermediate task. | |
| ancestors = _all_ancestors(tasks_by_id) | |
| for task in tasks_by_id.values(): | |
| for ref in _placeholder_refs(task): | |
| if ref not in id_set: | |
| raise PlannerValidationError( | |
| f"task {task.id}: placeholder '${{{ref}}}' references unknown task" | |
| ) | |
| if ref not in ancestors[task.id]: | |
| raise PlannerValidationError( | |
| f"task {task.id}: placeholder '${{{ref}}}' used but {ref!r} is " | |
| f"not a (transitive) dependency β add it to depends_on" | |
| ) | |
| # Any `${...}`, task reference or not β the first is checked against PLACEHOLDER_RE. | |
| _ANY_PLACEHOLDER_RE = re.compile(r"\$\{([^}]*)\}") | |
| def _is_placeholder(value: str) -> bool: | |
| return bool(PLACEHOLDER_RE.fullmatch(value.strip())) | |
| def _placeholder_refs(task) -> set[str]: | |
| """Task ids referenced by any placeholder in a task's args β including ones | |
| NESTED inside a retrieve_data IR (e.g. a '${t2.customer_id}' value-handoff | |
| filter). A '${t<id>.<col>}' ref contributes its TASK id ('t2'); the column | |
| suffix is stripped so the DAG check still enforces the dependency (task ids | |
| contain no '.').""" | |
| refs: set[str] = set() | |
| for call in task.tool_calls: | |
| for value in _iter_arg_strings(call.args): | |
| for ref in PLACEHOLDER_RE.findall(value): | |
| refs.add(ref.split(".", 1)[0]) | |
| return refs | |
| def _iter_arg_strings(value: object) -> Iterator[str]: | |
| """Yield every string reachable in a tool-arg value, recursing into dicts and | |
| lists so placeholders nested inside an IR are seen β not just top-level string | |
| args (which is all `data`/`data_right` Pattern-A handoffs used to be).""" | |
| if isinstance(value, str): | |
| yield value | |
| elif isinstance(value, dict): | |
| for v in value.values(): | |
| yield from _iter_arg_strings(v) | |
| elif isinstance(value, list): | |
| for v in value: | |
| yield from _iter_arg_strings(v) | |
| def _is_checkable(text: str) -> bool: | |
| low = text.lower() | |
| return any(tok in low for tok in _CHECKABLE_TOKENS) | |
| def _find_cycle(tasks_by_id: dict) -> list[str] | None: | |
| color = {tid: _WHITE for tid in tasks_by_id} | |
| stack: list[str] = [] | |
| def dfs(node: str) -> list[str] | None: | |
| color[node] = _GREY | |
| stack.append(node) | |
| for dep in tasks_by_id[node].depends_on: | |
| if color.get(dep) == _GREY: | |
| idx = stack.index(dep) | |
| return stack[idx:] + [dep] | |
| if color.get(dep) == _WHITE: | |
| found = dfs(dep) | |
| if found: | |
| return found | |
| stack.pop() | |
| color[node] = _BLACK | |
| return None | |
| for tid in tasks_by_id: | |
| if color[tid] == _WHITE: | |
| found = dfs(tid) | |
| if found: | |
| return found | |
| return None | |
| def _all_ancestors(tasks_by_id: dict) -> dict[str, set[str]]: | |
| """ancestors[id] = all tasks reachable by following depends_on edges.""" | |
| cache: dict[str, set[str]] = {} | |
| def visit(node: str, seen: set[str]) -> set[str]: | |
| if node in cache: | |
| return cache[node] | |
| acc: set[str] = set() | |
| for dep in tasks_by_id[node].depends_on: | |
| if dep in seen or dep not in tasks_by_id: | |
| continue | |
| acc.add(dep) | |
| acc |= visit(dep, seen | {dep}) | |
| cache[node] = acc | |
| return acc | |
| return {tid: visit(tid, {tid}) for tid in tasks_by_id} | |
| def _constant_columns(catalog: Catalog) -> set[str]: | |
| """Lower-cased names of every column the catalog reports as single-valued. | |
| Names, not ids: tool args reference columns by the name they carry in the | |
| retrieved frame, never by `column_id`. A name colliding across two tables is | |
| harmless here β both would have to be constant for the check to fire. | |
| """ | |
| return { | |
| col.name.lower() | |
| for source in catalog.sources | |
| for table in source.tables | |
| for col in table.columns | |
| if col.stats is not None and col.stats.distinct_count == 1 | |
| } | |
| # --------------------------------------------------------------------------- # | |
| # Checks 8f/8g β the IR can lie about WHAT it selected (A23 Bugs 10 and 11). | |
| # | |
| # Both were found on the 2026-09-17 pa_dummy run, and both produced a confident | |
| # answer rather than an error, which is why neither existed before: nothing in the | |
| # pipeline was looking. | |
| # --------------------------------------------------------------------------- # | |
| def _source_of(ir: QueryIR, catalog: Catalog): | |
| return next((s for s in catalog.sources if s.source_id == ir.source_id), None) | |
| def _columns_of(source) -> dict: | |
| """column_id -> Column, across every table of the source.""" | |
| return {c.column_id: c for t in source.tables for c in t.columns} | |
| # --------------------------------------------------------------------------- # | |
| # Checks 8m/8n β rows of a per-period table are not entities (audit P0-2, 2026-11-03). | |
| # | |
| # "equipment mana yang PA-nya paling rendah tahun 2026?" was planned as | |
| # `SELECT eqm_no, pa β¦ ORDER BY pa LIMIT 10` on the monthly view: the top row was one | |
| # April month of 8 days (EXHC36007 6.36%), presented as the unit's 2026 PA. planner.md | |
| # says "aggregate per entity first" (Rankings) and the 10-28 cut deleted the sentence | |
| # against ranking raw rows; prose alone did not hold. An ungrouped query on a table with | |
| # period columns is about PERIODS β fine when it shows the period, wrong otherwise. | |
| # --------------------------------------------------------------------------- # | |
| # Grain columns only: a table with just a `date` is usually an event log (one row per | |
| # breakdown), where "10 terlama" ranks events legitimately (review 2026-11-03). | |
| _PERIOD_NAMES = frozenset({"year", "month", "week", "quarter"}) | |
| def _period_column_ids(ir: QueryIR, catalog: Catalog) -> set[str]: | |
| """Ids of the period columns (by name) of the tables the IR reads.""" | |
| source = _source_of(ir, catalog) | |
| if source is None: | |
| return set() | |
| in_play = {ir.table_id, *(j.target_table_id for j in ir.joins)} | |
| return { | |
| c.column_id for t in source.tables if t.table_id in in_play | |
| for c in t.columns if c.name.lower() in _PERIOD_NAMES | |
| } | |
| def _raw_period_rows(ir: QueryIR, catalog: Catalog) -> bool: | |
| """True when the IR returns ungrouped rows of a per-period table without a period column.""" | |
| if ir.group_by or any(getattr(s, "kind", "") == "agg" for s in ir.select): | |
| return False | |
| periods = _period_column_ids(ir, catalog) | |
| if not periods: | |
| return False | |
| return not any(getattr(s, "column_id", None) in periods for s in ir.select) | |
| def _reject_raw_periodic_ranking(task_id: str, ir: QueryIR, catalog: Catalog) -> None: | |
| """Check 8m β an ordered, limited query on raw rows of a per-period table.""" | |
| if not ir.order_by or ir.limit is None or not _raw_period_rows(ir, catalog): | |
| return | |
| raise PlannerValidationError( | |
| f"task {task_id}: ranks raw rows of a table with one row per period, so one " | |
| "entity can appear many times and a single period's value is read as its " | |
| "total. Group by the entity and aggregate (avg for rates and scores, sum for " | |
| "totals), with `count` as n_periods and `max` of the period as last_period. " | |
| "If the user means the top PERIODS, select the period column." | |
| ) | |
| def _reject_raw_periodic_banding( | |
| task_id: str, call, tasks_by_id: dict, catalog: Catalog | |
| ) -> None: | |
| """Check 8n β analyze_segmentation fed raw rows of a per-period table.""" | |
| data = call.args.get("data") | |
| if not isinstance(data, str) or not (data.startswith("${") and data.endswith("}")): | |
| return | |
| upstream = tasks_by_id.get(data[2:-1]) | |
| if upstream is None: | |
| return | |
| for up_call in upstream.tool_calls: | |
| raw = up_call.args.get("ir") | |
| if up_call.tool != "retrieve_data" or not isinstance(raw, dict): | |
| continue | |
| try: | |
| ir = QueryIR.model_validate(raw) | |
| except ValidationError: | |
| return # Check 8b reports it | |
| if _raw_period_rows(ir, catalog): | |
| raise PlannerValidationError( | |
| f"task {task_id}: 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." | |
| ) | |
| def _reject_misleading_aliases(task_id: str, ir: QueryIR, catalog: Catalog) -> None: | |
| """An alias may not rename a column into some OTHER thing that exists here. | |
| Asked whether *backlog* relates to bad actor score β two tables the schema | |
| declares no FK between, so unjoinable β the planner emitted | |
| SELECT badactor.bad_actor_score, badactor.ma AS "backlog" FROM badactor | |
| and the answer read "Korelasi Pearson antara backlog dan bad actor score adalah | |
| -0,029". `ma` is Mechanical Availability. It is not backlog, and `backlog` is a | |
| separate TABLE it could not reach. Rather than refuse, the plan renamed a | |
| different column to match the user's word, and the alias made the wrong answer | |
| read as the right one β every layer downstream, including the narrative, saw | |
| only "backlog". | |
| This is the force-mapping the feasibility gate (recipes R6/R15) exists to stop, | |
| taking a route the gate never watched: not a wrong COLUMN CHOICE, a wrong NAME | |
| on a defensible column. | |
| The rule is narrow on purpose. An alias is rejected only when it collides with | |
| something real in this source β another table's name, or another column's name β | |
| because that is when a reader is actively misled about which thing they are | |
| looking at. Aliases that merely tidy (`total_mo` -> `total_maintenance_orders`) | |
| collide with nothing and pass untouched. | |
| """ | |
| source = _source_of(ir, catalog) | |
| if source is None: # unknown source: the IR validator already rejects it | |
| return | |
| table_names = {t.name.lower() for t in source.tables} | |
| cols = _columns_of(source) | |
| col_names = {c.name.lower() for c in cols.values()} | |
| for item in ir.select: | |
| alias = getattr(item, "alias", None) | |
| column_id = getattr(item, "column_id", None) | |
| if not alias or not isinstance(alias, str): | |
| continue | |
| low = alias.strip().lower() | |
| own = cols.get(column_id) | |
| if own is not None and low == own.name.lower(): | |
| continue # aliasing a column to its own name is a no-op | |
| if low in table_names: | |
| raise PlannerValidationError( | |
| f"task {task_id}: column {own.name if own else column_id!r} is aliased " | |
| f"as {alias!r}, which is the name of a TABLE in this source. That " | |
| f"renames one thing into another and the answer would describe " | |
| f"{alias!r} while reporting " | |
| f"{own.name if own else 'a different column'}. If the question is " | |
| f"about {alias!r}, query that table; if it cannot be reached from " | |
| f"here, return an infeasible plan saying so." | |
| ) | |
| if low in col_names: | |
| raise PlannerValidationError( | |
| f"task {task_id}: column {own.name if own else column_id!r} is aliased " | |
| f"as {alias!r}, which is the name of a DIFFERENT column in this " | |
| f"source. Alias to a name that is not already taken, or select the " | |
| f"column that is actually called {alias!r}." | |
| ) | |
| # Test demo #16: `AVG(mtbf) AS avg_mtbs` passed the exact-name check above and | |
| # the answer reported MTBF as MTBS. Strip one aggregate affix and look again. | |
| stem = _alias_stem(low) | |
| if ( | |
| own is not None | |
| and stem is not None | |
| and stem != own.name.lower() | |
| and stem in col_names | |
| ): | |
| raise PlannerValidationError( | |
| f"task {task_id}: column {own.name!r} is aliased as {alias!r}, which " | |
| f"names the DIFFERENT column {stem!r} in this source. Select " | |
| f"{stem!r} if that is the measure asked for; otherwise alias " | |
| f"{own.name!r} by its own name." | |
| ) | |
| # Aggregate words a planner wraps around a column in an alias (`avg_mtbs`, `pa_mean`). | |
| _ALIAS_AGG_PREFIXES = ( | |
| "avg_", "average_", "mean_", "sum_", "total_", "min_", "max_", "median_", "count_", | |
| "rata_rata_", | |
| ) | |
| _ALIAS_AGG_SUFFIXES = ("_avg", "_mean", "_sum", "_total", "_min", "_max", "_median", "_count") | |
| def _alias_stem(alias: str) -> str | None: | |
| """The alias with one aggregate prefix or suffix removed, or None if it has none.""" | |
| for prefix in _ALIAS_AGG_PREFIXES: | |
| if alias.startswith(prefix) and len(alias) > len(prefix): | |
| return alias[len(prefix):] | |
| for suffix in _ALIAS_AGG_SUFFIXES: | |
| if alias.endswith(suffix) and len(alias) > len(suffix): | |
| return alias[: -len(suffix)] | |
| return None | |
| def _reject_unmatchable_filter_values(task_id: str, ir: QueryIR, catalog: Catalog) -> None: | |
| """Reject a filter value that CANNOT match, when the catalog can prove it. | |
| "Compare PA between the two sites" produced | |
| WHERE site_id IN (...) -> 0 rows | |
| even though the catalog carries `site_id` samples ['SITE_A', 'SITE_B'] and says | |
| distinct_count = 2. The planner had both real values in front of it and filtered | |
| on something else; the turn then reported that the comparison "cannot be | |
| completed", which reads like a data limitation rather than the planning slip it | |
| was. | |
| Only fires when the catalog's samples are PROVABLY the complete value set -- | |
| `distinct_count` equal to the number of samples -- so "not in the samples" really | |
| does mean "not in the column". For any column where the samples are a partial | |
| peek (the normal case) this stays silent, because absence from a sample is not | |
| evidence of absence from the data. | |
| PII columns are skipped: their samples are nulled by design, so an empty sample | |
| list there says nothing at all. | |
| """ | |
| source = _source_of(ir, catalog) | |
| if source is None: | |
| return | |
| cols = _columns_of(source) | |
| for f in ir.filters: | |
| if f.op not in ("=", "in"): | |
| continue | |
| col = cols.get(f.column_id) | |
| if col is None or col.pii_flag or col.stats is None: | |
| continue | |
| samples = col.sample_values | |
| distinct = col.stats.distinct_count | |
| if not samples or distinct is None or distinct != len(samples): | |
| continue # samples are not provably exhaustive -> say nothing | |
| known = {str(v).strip().lower() for v in samples} | |
| wanted = f.value if isinstance(f.value, list | tuple | set) else [f.value] | |
| missing = [v for v in wanted if str(v).strip().lower() not in known] | |
| if missing and len(missing) == len(list(wanted)): | |
| raise PlannerValidationError( | |
| f"task {task_id}: filter on {col.name!r} uses value(s) {missing!r}, " | |
| f"none of which exist in that column β its complete set of values is " | |
| f"{samples!r}. This filter matches nothing. Use the real values, or " | |
| f"drop the filter." | |
| ) | |
| def _is_bare_month(col) -> bool: | |
| """True when a column holds month NUMBERS rather than dates. | |
| Matches _parse_date_column's own test: whole numbers inside 1-12. The name is | |
| a secondary signal only -- a column called `month` holding real dates is fine, | |
| and one called `periode` holding 1-12 is not. | |
| """ | |
| if col is None or col.stats is None: | |
| return False | |
| lo, hi = col.stats.min, col.stats.max | |
| if lo is None or hi is None: | |
| return False | |
| try: | |
| return 1 <= float(lo) and float(hi) <= 12 and col.data_type in ("int", "integer") | |
| except (TypeError, ValueError): | |
| return False | |
| def _retrieve_output_names(task, catalog: Catalog) -> set[str] | None: | |
| """Exact column names a task's LAST call outputs, when that call is a | |
| `retrieve_data` whose every select item has a certain name (an alias, or a | |
| bare catalog column). None when unknown β an unaliased aggregate's output name | |
| is the compiler's choice, and a non-retrieve last step is not a table the | |
| runtime can read a column from by name.""" | |
| if not task.tool_calls or task.tool_calls[-1].tool != "retrieve_data": | |
| return None | |
| raw = task.tool_calls[-1].args.get("ir") | |
| if not isinstance(raw, dict): | |
| return None | |
| source = next((s for s in catalog.sources if s.source_id == raw.get("source_id")), None) | |
| if source is None: | |
| return None | |
| by_id = {c.column_id: c for t in source.tables for c in t.columns} | |
| names: set[str] = set() | |
| for item in raw.get("select") or []: | |
| if not isinstance(item, dict): | |
| return None | |
| if item.get("alias"): | |
| names.add(str(item["alias"])) | |
| elif item.get("fn") is None and item.get("column_id") in by_id: | |
| names.add(by_id[item["column_id"]].name) | |
| else: | |
| return None | |
| return names | |
| def _reject_unknown_handoff_columns( | |
| tasks_by_id: dict, catalog: Catalog, registry: ToolRegistry | None = None | |
| ) -> None: | |
| """Check 11 (D-5): `${t<id>.<column>}` must name a column t<id> outputs. | |
| At runtime an unknown column resolves to an EMPTY list (`task_runner. | |
| _column_values`), which is indistinguishable from a real empty result: `in` | |
| then matches nothing and `not_in` matches EVERYTHING β an anti-join with a | |
| typo'd column returns the whole table as "customers who never ordered". | |
| Caught here, the planner gets a retry naming the columns that exist. The name | |
| must match EXACTLY, as the runtime lookup does, so a case-only miss is | |
| rejected too, with the right spelling. Unresolvable upstreams are skipped β | |
| this never manufactures an error it cannot prove. | |
| """ | |
| for task in tasks_by_id.values(): | |
| for call in task.tool_calls: | |
| for value in _iter_arg_strings(call.args): | |
| match = PLACEHOLDER_RE.fullmatch(value.strip()) | |
| if not match: | |
| continue | |
| ref_id, _, column = match.group(1).partition(".") | |
| upstream = tasks_by_id.get(ref_id) | |
| if not column or upstream is None: | |
| continue | |
| # A handoff from a metadata/documents task (check_data, check_knowledge, | |
| # retrieve_knowledge) has no data column to read: `${t1.site_id}` off a | |
| # check_data step resolves to an EMPTY list, so `in` silently matches | |
| # nothing (EMA eval 2026-10-22: a site lookup that never happened). | |
| last = upstream.tool_calls[-1].tool if upstream.tool_calls else None | |
| spec = registry.get(last) if (registry is not None and last) else None | |
| if spec is not None and spec.category in _NON_DATA_SOURCE_CATEGORIES: | |
| raise PlannerValidationError( | |
| f"task {task.id}: value-handoff '${{{ref_id}.{column}}}' reads " | |
| f"from {ref_id}, a {last!r} step, which returns metadata or " | |
| "documents, not data rows. Look the value up with its own " | |
| "retrieve_data task (e.g. select the id where the code matches) " | |
| "and hand THAT task's column on." | |
| ) | |
| names = _retrieve_output_names(upstream, catalog) | |
| if names is None or column in names: | |
| continue | |
| near = [n for n in names if n.lower() == column.lower()] | |
| hint = f" (did you mean {near[0]!r}?)" if near else "" | |
| raise PlannerValidationError( | |
| f"task {task.id}: value-handoff '${{{ref_id}.{column}}}' names a " | |
| f"column {ref_id} does not output{hint}; {ref_id} outputs " | |
| f"{sorted(names)}. An unknown column resolves to an EMPTY list, " | |
| "so `in` would match nothing and `not_in` would match everything." | |
| ) | |
| def _upstream_select_names(call, tasks_by_id: dict, catalog: Catalog) -> set[str] | None: | |
| """Names (alias or catalog name) the retrieve feeding this call produced. | |
| Returns None when the upstream cannot be resolved -- an unknown shape must not | |
| manufacture a validation error. | |
| """ | |
| data = call.args.get("data") | |
| if not isinstance(data, str) or not (data.startswith("${") and data.endswith("}")): | |
| return None | |
| upstream = tasks_by_id.get(data[2:-1]) | |
| if upstream is None: | |
| return None | |
| names: set[str] = set() | |
| for up_call in upstream.tool_calls: | |
| raw = up_call.args.get("ir") | |
| if not isinstance(raw, dict): | |
| continue | |
| source = next( | |
| (s for s in catalog.sources if s.source_id == raw.get("source_id")), None | |
| ) | |
| if source is None: | |
| continue | |
| by_id = {c.column_id: c for t in source.tables for c in t.columns} | |
| for item in raw.get("select") or []: | |
| if not isinstance(item, dict): | |
| continue | |
| alias = item.get("alias") | |
| col = by_id.get(item.get("column_id")) | |
| if alias: | |
| names.add(str(alias).lower()) | |
| elif col is not None: | |
| names.add(col.name.lower()) | |
| return names or None | |
| def _upstream_column(call, tasks_by_id: dict, catalog: Catalog): | |
| """The catalog column the `data` retrieve selected as `date_column`, else None. | |
| Matched on the select item's alias, or its column name when it has none, among | |
| PLAIN columns only β an aggregate's output does not carry the column's stats. | |
| """ | |
| date_column = call.args.get("date_column") | |
| data = call.args.get("data") | |
| if not isinstance(date_column, str) or not isinstance(data, str): | |
| return None | |
| if not (data.startswith("${") and data.endswith("}")): | |
| return None | |
| upstream = tasks_by_id.get(data[2:-1]) | |
| if upstream is None: | |
| return None | |
| name = date_column.strip().lower() | |
| col = None | |
| for up_call in upstream.tool_calls: | |
| raw = up_call.args.get("ir") | |
| if not isinstance(raw, dict): | |
| continue | |
| source = next( | |
| (s for s in catalog.sources if s.source_id == raw.get("source_id")), None | |
| ) | |
| if source is None: | |
| continue | |
| by_id = {c.column_id: c for t in source.tables for c in t.columns} | |
| for item in raw.get("select") or []: | |
| if not isinstance(item, dict) or item.get("fn") is not None: | |
| continue | |
| c = by_id.get(item.get("column_id")) | |
| if c is not None and str(item.get("alias") or c.name).lower() == name: | |
| col = c | |
| return col | |
| def _require_year_with_bare_month( | |
| task_id: str, call, tasks_by_id: dict, catalog: Catalog | |
| ) -> None: | |
| date_column = call.args.get("date_column") | |
| if not isinstance(date_column, str): | |
| return | |
| names = _upstream_select_names(call, tasks_by_id, catalog) | |
| if names is None or date_column.strip().lower() not in names: | |
| return # can't resolve, or the column isn't from this retrieve | |
| # The exact column the retrieve selected. A lookup by NAME across the catalog | |
| # took the last `month` it met β on the EMA source a decimal one in another view | |
| # β so the check never fired and the trend failed at execution (2026-11-03). | |
| col = _upstream_column(call, tasks_by_id, catalog) | |
| if not _is_bare_month(col) or "year" in names: | |
| return | |
| raise PlannerValidationError( | |
| f"task {task_id}: {call.tool} buckets on {date_column!r}, which holds bare " | |
| "month numbers (1-12), but the upstream retrieve_data did not select a " | |
| "`year` column. Month numbers alone cannot be ordered across years. Add the " | |
| "year column to that retrieve's select list." | |
| ) | |
| def _reject_unreadable_period( | |
| task_id: str, call, tasks_by_id: dict, catalog: Catalog | |
| ) -> None: | |
| """Check 8l β trend/forecast on whole numbers that are no month and no year. | |
| `_parse_date_column` reads a numeric date column only as month numbers (1-12, | |
| with a year) or years (1900-2100); anything else β a week number 1-52, a | |
| `yyyyww` code β raises InvalidDateColumnError at EXECUTION, too late for a retry. | |
| The demo test of 2026-11-02 lost its monthly MTTR trend that way: the plan read | |
| the weekly view and bucketed on `week`. Decided from the catalog stats, which | |
| mirror the tool's own test; a column without stats is left to the tool. | |
| """ | |
| date_column = call.args.get("date_column") | |
| col = _upstream_column(call, tasks_by_id, catalog) | |
| if col is None or col.stats is None or col.data_type not in ("int", "integer"): | |
| return | |
| try: | |
| lo, hi = float(col.stats.min), float(col.stats.max) | |
| except (TypeError, ValueError): | |
| return | |
| if (1 <= lo and hi <= 12) or (1900 <= lo and hi <= 2100): | |
| return | |
| raise PlannerValidationError( | |
| f"task {task_id}: {call.tool} buckets on {date_column!r}, which holds whole " | |
| f"numbers from {lo:g} to {hi:g} β not a date, a month number (1-12) or a year, " | |
| "so the tool cannot place it on a calendar. For a trend by month, quarter or " | |
| "year, retrieve from the month-grain table and bucket on its `month` column " | |
| "(select `year` with it)." | |
| ) | |
| # Check 8i β analyze_importance must not rank a capped slice (EMA dry run 2026-10-22). | |
| # | |
| # An UNFILTERED raw retrieve of a table larger than `row_budget` is capped by | |
| # construction, and the compiler orders a capped raw pull by its selected columns | |
| # (D-2), so the slice is the first `row_budget` rows in that order β not a sample. | |
| # "bad actor di pengaruhi apa aja?" pulled 10,000 of 21,124 rows sorted by | |
| # `is_bad_actor`: every row was 0, and the tool failed at execution with | |
| # ConstantTargetError, too late for a retry. planner.md says to compare the groups | |
| # in the query instead; the model stated that rule in its assumptions and still | |
| # planned importance, so it is enforced here. | |
| # | |
| # Narrow on purpose, so it has no false positives: it fires only when the feeding | |
| # IR has NO aggregation, the table's catalog row count exceeds the budget, and its | |
| # filters (if any) are on PERIOD columns only. A filter on an entity or category may | |
| # shrink the pull below the budget (its size is unknown here), so it passes; a year or | |
| # month filter on a multi-year table still keeps most of it (2026-10-28: "apa yang | |
| # membedakan unit bad actor dengan unit lain di 2026" planned analyze_ab_test on a | |
| # `year = 2026` pull of a 21,124-row table, half the time, against every prompt | |
| # wording tried β so the rule lives here). Also covers analyze_ab_test, which reads | |
| # the raw rows the same way. | |
| _RAW_ROW_TOOLS = frozenset({"analyze_importance", "analyze_ab_test"}) | |
| _PERIOD_COLUMN_RE = re.compile( | |
| r"^(year|month|week|quarter|day|date|period)(_id)?$|_(date|year|month|week)$", | |
| re.IGNORECASE, | |
| ) | |
| def _period_only_filters(filters: list, catalog: Catalog) -> bool: | |
| names = {c.column_id: c.name for s in catalog.sources for t in s.tables for c in t.columns} | |
| for f in filters or []: | |
| col = f.get("column_id") if isinstance(f, dict) else None | |
| if not col or not _PERIOD_COLUMN_RE.search(names.get(col, "")): | |
| return False | |
| return True | |
| def _reject_capped_importance( | |
| task_id: str, call, tasks_by_id: dict, catalog: Catalog, constraints: Constraints | |
| ) -> None: | |
| data = call.args.get("data") | |
| if not isinstance(data, str) or not (data.startswith("${") and data.endswith("}")): | |
| return | |
| upstream = tasks_by_id.get(data[2:-1]) | |
| if upstream is None: | |
| return | |
| for up_call in upstream.tool_calls: | |
| raw = up_call.args.get("ir") | |
| if up_call.tool != "retrieve_data" or not isinstance(raw, dict): | |
| continue | |
| if not _period_only_filters(raw.get("filters"), catalog) or raw.get("group_by") or any( | |
| isinstance(s, dict) and s.get("kind") == "agg" for s in raw.get("select") or [] | |
| ): | |
| continue | |
| table = next( | |
| ( | |
| t | |
| for s in catalog.sources | |
| if s.source_id == raw.get("source_id") | |
| for t in s.tables | |
| if t.table_id == raw.get("table_id") | |
| ), | |
| None, | |
| ) | |
| if table is None or not table.row_count or table.row_count <= constraints.row_budget: | |
| continue | |
| scope = "a period-filtered" if raw.get("filters") else "an unfiltered" | |
| raise PlannerValidationError( | |
| f"task {task_id}: {call.tool} reads {scope} raw pull of " | |
| f"{table.name!r} ({table.row_count:,} rows), which is over the " | |
| f"{constraints.row_budget:,}-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." | |
| ) | |
| # Check 8j β a key that points at a lookup table is not filtered by a TYPED id | |
| # (EMA dry run 2026-10-22). | |
| # | |
| # Users name sites, plants and units by CODE ("SDJ", "IPR"); the measure tables hold | |
| # only the numeric key (`site_id`). With no lookup the planner fills the id in from | |
| # memory or from position: the replay filtered "kalo sdj?" as site 2001 (another | |
| # site) and labelled the result SDJ, and the eval saw EST -> 3003 guessed from the | |
| # order of sample values. A wrong id returns another site's data under the right | |
| # name, so a typed literal on such a key is rejected and the retry looks it up β by | |
| # joining the lookup and filtering its code column, or by a value-handoff | |
| # (`in "${t1.site_id}"`, which passes because it is not a literal). | |
| # | |
| # Fires only when the filtered column is a foreign key (declared or inferred) β or | |
| # the target's own key reached through a join β and the target table has a CODE | |
| # column (`code`, `*_code`, `kode*`): users name those rows by that code. A key into | |
| # a table with only descriptive text (orders.status, customers.email β review | |
| # 2026-10-22: "any string column" rejected `equipment_id = 11` and | |
| # `order_id = 1001`), a placeholder value, and `is_null`-style ops all pass. | |
| _CODE_COLUMN_RE = re.compile(r"(^|_)(code|kode)($|_)|^kode", re.IGNORECASE) | |
| def _reject_typed_lookup_ids(task_id: str, ir: QueryIR, catalog: Catalog) -> None: | |
| source = _source_of(ir, catalog) | |
| if source is None: | |
| return | |
| tables = {t.table_id: t for t in source.tables} | |
| in_query = [ir.table_id, *[j.target_table_id for j in ir.joins]] | |
| fk_target = { | |
| fk.column_id: fk.target_table_id | |
| for tid in in_query | |
| if tid in tables | |
| for fk in tables[tid].foreign_keys | |
| } | |
| # The lookup's OWN key is just as much a typed id once the lookup is joined: | |
| # `vw_dim_site.site_id = 2001` after a join is the same guess (review finding 2). | |
| for j in ir.joins: | |
| fk_target.setdefault(j.right_column_id, j.target_table_id) | |
| for flt in ir.filters: | |
| target = tables.get(fk_target.get(flt.column_id, "")) | |
| if target is None or flt.op not in ("=", "!=", "in", "not_in", "between"): | |
| continue | |
| values = flt.value if isinstance(flt.value, list) else [flt.value] | |
| if not values or any(isinstance(v, str) and v.startswith("${") for v in values): | |
| continue | |
| codes = [c.name for c in target.columns | |
| if c.data_type == "string" and _CODE_COLUMN_RE.search(c.name)] | |
| if not codes: | |
| continue | |
| col = next( | |
| (c.name for t in source.tables for c in t.columns if c.column_id == flt.column_id), | |
| flt.column_id, | |
| ) | |
| raise PlannerValidationError( | |
| f"task {task_id}: filters {col!r} by a typed id ({flt.value!r}), but " | |
| f"{col!r} points at {target.name!r}, whose rows carry a code " | |
| f"({', '.join(codes)}). Never type an id you did not look up: join " | |
| f"{target.name!r} and filter its code column, or look the id up in a " | |
| "separate retrieve_data and hand it on with `in \"${t<id>.<column>}\"`." | |
| ) | |
| # Check 8k β a ranking the user reads is not cut to one row (EMA dry run 2026-10-22). | |
| # | |
| # "Which unit had the most bad-actor months?" planned as group + order + `limit: 1` | |
| # returned one unit while 21 tied at 6 of 6 β the answer named a sole leader that | |
| # did not exist. planner.md asks for 10 rows when no number is given; the model | |
| # followed it in one run and not the next, so it is enforced here. | |
| # | |
| # `limit: 1` stays valid where it belongs: a lookup whose single value a later task | |
| # reads by value-handoff (`${t1.<column>}`), and an ungrouped / unordered pull. | |
| def _reject_top1_rankings(tasks_by_id: dict) -> None: | |
| handed_off = { | |
| match.group(1).partition(".")[0] | |
| for task in tasks_by_id.values() | |
| for call in task.tool_calls | |
| for value in _iter_arg_strings(call.args) | |
| if (match := PLACEHOLDER_RE.fullmatch(value.strip())) and "." in match.group(1) | |
| } | |
| for task in tasks_by_id.values(): | |
| if task.id in handed_off: | |
| continue | |
| for call in task.tool_calls: | |
| raw = call.args.get("ir") if call.tool == "retrieve_data" else None | |
| if not isinstance(raw, dict): | |
| continue | |
| if raw.get("limit") == 1 and raw.get("group_by") and raw.get("order_by"): | |
| raise PlannerValidationError( | |
| f"task {task.id}: 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." | |
| ) | |