diff --git a/.agent-skills/README.md b/.agent-skills/README.md new file mode 100644 index 0000000..67319a3 --- /dev/null +++ b/.agent-skills/README.md @@ -0,0 +1,105 @@ +# OSI Agent Skills + +Tool-agnostic skill files for working with the OSI reference implementation +and compliance suite. Each skill is a self-contained `SKILL.md` with YAML +front-matter (`name` + `description`) and Markdown instructions — the same +format used by both Cursor and Claude Code, so the files themselves work +unchanged across tools. This folder just keeps them outside any single +tool's hidden directory so it's clear they're meant to be shared. + +## Available skills + +### Reviewer / designer skills (dual-purpose: review existing code + design new code) + +See [`REVIEWER_SKILLS.md`](REVIEWER_SKILLS.md) for the full index, the +shared *triage rule* every reviewer skill carries, and the recommended +sweep order. + +| Skill | Angle | +|:---|:---| +| [`architectural-review/`](architectural-review/SKILL.md) | Three-layer pipeline, one-way information flow, closed algebra, numbered invariants. | +| [`interfaces-and-api-review/`](interfaces-and-api-review/SKILL.md) | Layer facades, signature shape, total functions. | +| [`code-encourages-correct-use-review/`](code-encourages-correct-use-review/SKILL.md) | "Make illegal states unrepresentable" — construction discipline. | +| [`bi-best-practices-review/`](bi-best-practices-review/SKILL.md) | Grain awareness, fan-out, bridge dedup, conformed dims, semi-additive measures. | +| [`compiler-best-practices-review/`](compiler-best-practices-review/SKILL.md) | Phase boundaries, IR purity, totality, deterministic codegen, error taxonomy. | +| [`database-best-practices-review/`](database-best-practices-review/SKILL.md) | SQL emission via AST, identifier quoting, NULL ordering, multiset semantics, dialect adapter isolation. | +| [`doc-as-enforcement-review/`](doc-as-enforcement-review/SKILL.md) | Drift tests for layered docs, citation conventions, runnable READMEs. | +| [`typing-enforcement-review/`](typing-enforcement-review/SKILL.md) | `NewType` discipline, no `Any` at boundaries, `Protocol` / `TypedDict`, frozen-by-default, mypy strictness. | +| [`spec-coherence-review/`](spec-coherence-review/SKILL.md) | Spec ↔ Appendix C ↔ `ErrorCode` ↔ compliance tests stay coherent. | + +### Operational skills (run something, produce a report) + +| Skill | When to use | +|:---|:---| +| [`run-osi-python-tests/`](run-osi-python-tests/SKILL.md) | Run the full test pyramid (unit / property / golden / e2e / mutation / coverage / lint / typecheck / arch) for `impl/python/` and surface a single Markdown report. | +| [`run-osi-compliance/`](run-osi-compliance/SKILL.md) | Run the Foundation v0.1 compliance suite against the Python reference implementation and report per-decision (D-NNN) coverage. | + +## Using these with Cursor + +Cursor discovers skills under `.cursor/skills/`. Either: + +1. **Symlink** (recommended — single source of truth): + ```bash + mkdir -p .cursor/skills + for d in .agent-skills/*/; do + name=$(basename "$d") + ln -sfn "../../$d" ".cursor/skills/$name" + done + ``` +2. **Or copy** each skill folder into `.cursor/skills/` and remember to + keep both in sync. + +The relative `../../` paths inside each `SKILL.md` are written for the +`.agent-skills//` location. If you copy into `.cursor/skills//` +you'll need to add one more `../` to each link (or just symlink). + +## Using these with Claude Code + +Claude Code reads skills from `.claude/skills/` (project-scoped) or +`~/.claude/skills/` (user-scoped). Mirror the same pattern as above: + +```bash +mkdir -p .claude/skills +for d in .agent-skills/*/; do + name=$(basename "$d") + ln -sfn "../../$d" ".claude/skills/$name" +done +``` + +Or for user-scoped availability across all of your projects: + +```bash +for d in "$(pwd)/.agent-skills/"*/; do + ln -sfn "$d" ~/.claude/skills/ +done +``` + +## Using these with any other agent + +The format is just `SKILL.md` with two pieces of YAML front-matter: + +```yaml +--- +name: skill-name +description: One-sentence summary the agent uses to decide whether to load this skill. +--- +``` + +Followed by Markdown instructions. Most agent frameworks that support +"skills" or "rules" can ingest this format directly, or you can paste the +instructions into a system prompt. + +## Authoring conventions + +When adding a new skill here, keep it tool-agnostic: + +- Front-matter must use `name:` (not `id:`) and `description:` (so it + matches the Cursor / Claude Code shape). +- Instructions reference files using POSIX-style relative paths from + the skill folder (`../../impl/python/RUNNING_TESTS.md`). +- Do not assume a specific shell, IDE, or tool integration in the + instructions. Where tool-specific commands are necessary, list them as + alternatives ("In Cursor: …; In Claude Code: …"). +- Do not embed tool-specific UI references ("click the X button"). Skills + are read by agents, not humans, so describe outcomes and let the agent + pick the means. diff --git a/.agent-skills/REVIEWER_SKILLS.md b/.agent-skills/REVIEWER_SKILLS.md new file mode 100644 index 0000000..bdf1b6a --- /dev/null +++ b/.agent-skills/REVIEWER_SKILLS.md @@ -0,0 +1,92 @@ +# Reviewer skills index + +This file is the index for the nine dual-purpose reviewer skills under +`.agent-skills/`. Each skill is usable at **review time** (auditing +existing code) *and* at **design time** (locking in boundaries when +writing new code), and each one carries the same triage rule — +*prefer deterministic enforcement first* — so findings flow into the +test suite, not just review reports. + +## The nine skills + +| Skill | Angle | Primary deterministic checks it leverages | +|:--|:--|:--| +| [`architectural-review/`](architectural-review/SKILL.md) | Three-layer pipeline, one-way information flow, closed algebra, the numbered invariants in `impl/python/ARCHITECTURE.md §6`. | `import-linter`, `tests/properties/test_algebra_*`, `tests/unit/test_appendix_c_drift.py` | +| [`interfaces-and-api-review/`](interfaces-and-api-review/SKILL.md) | Public facades, signature shape, total functions, "exceptions in the docstring." | `mypy --strict`, `flake8-docstrings`, layer `__init__.py` re-exports | +| [`code-encourages-correct-use-review/`](code-encourages-correct-use-review/SKILL.md) | "Make illegal states unrepresentable." Construction-time invariants, no sentinels, no `bool` flags. | `mypy --strict`, `tests/properties/test_algebra_purity.py`, `tests/unit/test_common_identifiers.py` | +| [`bi-best-practices-review/`](bi-best-practices-review/SKILL.md) | Grain awareness, fan-out / chasm trap, bridge dedup, conformed dims, semi-additive measures, holistic-over-fan-out rejection. | `tests/properties/test_grain_closure.py`, `tests/properties/test_chasm_safety.py`, the `compliance/foundation-v0.1/tests/bridge/` and `cross_grain/` suites | +| [`compiler-best-practices-review/`](compiler-best-practices-review/SKILL.md) | Phase boundaries, IR purity, totality, deterministic codegen, error taxonomy, pass ordering. | `tests/properties/test_algebra_*`, `tests/unit/test_operator_enum_sync.py`, `mutmut` on `planning/algebra/` ≥ 90% | +| [`database-best-practices-review/`](database-best-practices-review/SKILL.md) | SQL emission via AST not strings, identifier quoting, NULL ordering, multiset vs set semantics, dialect adapter isolation, FrozenSQL discipline. | banned-f-string-SQL grep, `tests/properties/test_frozensql_canonical.py`, per-dialect golden tests, `tests/e2e/` against DuckDB | +| [`doc-as-enforcement-review/`](doc-as-enforcement-review/SKILL.md) | Doc claims backed by drift tests; layer READMEs vs filesystem; citation conventions; example-runnable READMEs. | `tests/unit/test_appendix_c_drift.py`, the four Phase C drift tests added by the audit. | +| [`typing-enforcement-review/`](typing-enforcement-review/SKILL.md) | `Any` and `dict[str, Any]` audit, `NewType` discipline, `Protocol` and `TypedDict`, frozen-by-default, mypy strictness. | `mypy --strict`, `tests/unit/test_common_identifiers.py`, the every-`Exception`-is-`OSIError` arch-test added in Phase D | +| [`spec-coherence-review/`](spec-coherence-review/SKILL.md) | Spec ↔ Appendix C ↔ `ErrorCode` ↔ compliance tests stay coherent; deferred-feature gate; `decisions.yaml` ↔ tests. | `tests/unit/test_appendix_c_drift.py`, `test_registry_yaml.py`, the Phase C spec-section-refs drift test | + +## The triage rule (encoded in every SKILL.md) + +Every skill above carries this rule verbatim. Apply it whether you're +using the skill at review time or at design time: + +1. **Convert to a deterministic check** — drift test, arch-test, + import-linter contract, mypy rule, lint rule. Preferred. Never + regresses; applies to every future change automatically. +2. **Sharpen the skill's checklist** — if the finding revealed a + missing angle, update the `SKILL.md` so future runs catch it. +3. **Tighten documentation** — when the rule is true but not + mechanically checkable, update `ARCHITECTURE.md` / a README / + `INFRA.md` and add an example. +4. **Queue as a code-change sprint item** — last resort, for findings + that need real implementation work (refactors, new abstractions). + +Design-time framing: before writing code that establishes a boundary +or invariant, ask "can I add a deterministic check that locks this +in before the code lands?" If yes, write the check in the same PR. + +## Cadence + +The cadence rule lives in `CONTRIBUTING.md` at the repo root. Summary: + +- **Any architectural change** runs the BI, Compiler, and Database + best-practices skills at design time *and* at review time. These + three are the non-negotiable triad for behavioural changes. +- **Any new public API** runs the interfaces, code-encourages-correct-use, + and typing-enforcement skills. +- **Any new spec section, error code, or compliance test** runs the + spec-coherence and doc-as-enforcement skills. +- **Any new doc that codifies a rule** runs the doc-as-enforcement skill. + +## Recommended sweep order (when running all nine against a corpus) + +1. `architectural-review` — establishes which layer each change touches; + surfaces structural issues that downstream skills will keep tripping + on if not fixed first. +2. `spec-coherence-review` — confirms the spec ↔ code ↔ tests baseline + before behavioural skills assume it. +3. `bi-best-practices-review` — behavioural correctness at the planner + level (grain, fan-out). +4. `compiler-best-practices-review` — engineering correctness inside + the planner (phases, IR purity). +5. `database-best-practices-review` — emission correctness in codegen. +6. `interfaces-and-api-review` — public surface hygiene. +7. `code-encourages-correct-use-review` — construction discipline. +8. `typing-enforcement-review` — mypy / `NewType` / `Protocol` + refinements. +9. `doc-as-enforcement-review` — convert documented invariants from + the first eight passes into drift tests. + +## Existing companion skills (run by these reviewer skills) + +These skills are referenced inside the nine SKILL.md files; they are +the "doing" skills (run something, navigate the BI mapping, debug an +output) that the reviewer skills point to as ground truth. + +| Skill | Purpose | +|:--|:--| +| [`run-osi-python-tests/`](run-osi-python-tests/SKILL.md) | Run the full Python test pyramid (unit + property + golden + e2e + mutation + lint + typecheck + arch). | +| [`run-osi-compliance/`](run-osi-compliance/SKILL.md) | Run the Foundation v0.1 compliance suite and produce a per-decision coverage report. | +| `bi-concepts-to-osi` (carried in `willtown/.cursor/skills/`) | Map a BI analytical concept to an OSI model + query. | +| `convert-to-osi` (willtown) | Convert a DAX / LookML / Tableau LOD / dbt metric to an OSI metric. | +| `sql-to-semantic` (willtown) | Turn a SQL query / schema into an OSI semantic model + query. | +| `debug-planner-output` (willtown) | Root-cause an unexpected query plan or SQL output. | +| `diagnose-failing-test` (willtown) | Classify a failing pytest output by failure type. | +| `add-new-filter-or-operator` (willtown) | Add a new filter shape, SQL operator, or BI idiom end-to-end. | +| `osi-impl-add-planner-feature` (willtown) | Extend the OSI planner with a new BI analytical concern. | diff --git a/.agent-skills/architectural-review/SKILL.md b/.agent-skills/architectural-review/SKILL.md new file mode 100644 index 0000000..ec3d0b3 --- /dev/null +++ b/.agent-skills/architectural-review/SKILL.md @@ -0,0 +1,243 @@ +--- +name: architectural-review +description: Review or design code against the OSI reference implementation's architectural invariants — the three-layer pipeline (parsing → planning → codegen), the closed algebra, one-way information flow, and the numbered invariants in impl/python/ARCHITECTURE.md §6. Use when adding code that crosses layer boundaries, introducing a new module, reviewing a design doc, or evaluating whether a feature can be implemented without breaking the pipeline contract. +--- + +# Architectural review + +The OSI reference implementation is a closed, pure algebra over an +immutable semantic model, organised as a strict three-layer pipeline. +The architectural contract lives in +[`../../impl/python/ARCHITECTURE.md`](../../impl/python/ARCHITECTURE.md); +this skill is the playbook for verifying that contract holds — and for +designing new code so it cannot be broken in the first place. + +## 1. Purpose + +Ensure every change preserves the three-layer pipeline, the one-way +information flow, the closed algebra, and the numbered invariants in +`ARCHITECTURE.md §6`. Prefer locking new boundaries down with +deterministic checks (import-linter contracts, arch-tests, drift tests) +over relying on review vigilance. + +## 2. When to use it (Review) + +Apply this skill during code review when the change: + +- Adds or modifies a module under `impl/python/src/osi/` (any layer). +- Introduces a new import edge between subpackages. +- Adds a new public function on a layer facade + (`osi.parsing.__init__`, `osi.planning.__init__`, `osi.codegen.__init__`). +- Touches `planner_context.py`, `algebra/state.py`, `plan.py`, or any + file mentioned in `ARCHITECTURE.md §3.4`. +- Adds a new algebra operator or relaxes a precondition on an existing + one. +- Bypasses (or appears to bypass) an `ErrorCode` raise — e.g. catches + and re-raises silently, or returns `None` instead of raising. +- Adds, removes, or reorders a phase in the planner. + +## 3. When to use it (Design) + +Apply this skill *before* writing code when you are: + +- Designing a new feature that touches more than one layer. +- Sketching a new module or sub-package — to decide which layer it + belongs in and which existing modules it may import. +- Promoting a deferred feature into the Foundation (see + [`../../impl/python/CONTRIBUTING.md §8`](../../impl/python/CONTRIBUTING.md)). +- Introducing a new architectural concept (a new IR type, a new + helper class, a new planner phase). +- Deciding whether a new behaviour should be a new algebra operator or + a composition of existing ones. + +At design time the goal is: *which existing deterministic check would +this design rely on, and which new one should land in the same PR to +lock the new boundary in?* + +## 4. Methodology + +The same six steps work for both review and design — at design time +they generate the checklist you build into the PR; at review time they +audit the PR you received. + +1. **Locate the change on the layer map.** Open + [`ARCHITECTURE.md §1`](../../impl/python/ARCHITECTURE.md#1-three-layer-pipeline) + and identify every layer the change touches. If the change crosses + more than one layer, ask whether the cross-cut is genuinely necessary + or whether the abstraction needs sharpening (`§8 Where to add things` + guidance). +2. **Trace the information flow.** Confirm data only moves down the + pipeline (YAML → SemanticModel → QueryPlan → SQL). Codegen reaches + *only* into the plan, never back into the model. Planning reaches + into the model *only* through `PlannerContext`. +3. **Audit the import edges.** Run `import-linter` (`lint-imports` / + `make lint`); confirm no new edge violates the contracts in + `pyproject.toml [tool.importlinter]`. Inspect new edges by eye even + if they pass — the linter only catches forbidden cross-layer imports, + not in-layer drift. +4. **Verify algebra closure.** If the change touches `planning/algebra/` + or any module that produces a `CalculationState`, confirm: + - All new `CalculationState`s are produced by `source(...)` or by an + algebra operator (never instantiated directly outside the algebra + package, invariant 1). + - All new types are `frozen=True` dataclasses (invariant 2). + - All new operators take `(state, args) → state`, with no exceptions + other than typed `OSIError` (invariants 3, 4, 9). +5. **Check error discipline.** Every new failure path raises an + `OSIError` subclass with an `ErrorCode` from Appendix C *or* from + the implementation-extensions list in `test_appendix_c_drift.py`. + No bare `Exception`, `ValueError`, `TypeError`, `AssertionError` in + `src/`. +6. **Confirm the deterministic check exists or is added.** For every + invariant the change relies on, find the import-linter contract, + arch-test, or drift test that enforces it. If none exists and the + invariant is mechanically checkable, **add the check in the same + PR**. This is the design-time output of this skill. + +## 5. Checklists + +### 5.1 The three-layer pipeline + +- [ ] No import edge from `osi.parsing` to `osi.planning` or `osi.codegen`. +- [ ] No import edge from `osi.planning` to `osi.codegen`. +- [ ] No import edge from `osi.codegen` to `osi.parsing`. +- [ ] Diagnostics import only from `parsing`, `planning`, `common`, and + `errors` — never from `codegen`. +- [ ] `common/` imports only from stdlib, `sqlglot`, `networkx`, and + other `common/` modules. + +### 5.2 One-way information flow + +- [ ] Planning submodules receive `ctx: PlannerContext` rather than + `model: SemanticModel` directly. +- [ ] Direct `osi.parsing.models` imports inside `planning/` are used + for type annotations only; no submodule instantiates a parsed + type or calls a top-level parsing function on its own + (`ARCHITECTURE.md §6.7`). +- [ ] Codegen never reads `SemanticModel`, `Namespace`, + `RelationshipGraph`, or any field that is not on the plan + (`ARCHITECTURE.md §6.6`). + +### 5.3 Algebra closure + +- [ ] No `CalculationState` is constructed outside + `osi.planning.algebra` (invariant 1). +- [ ] No mutation of an existing state — operators return new values + (invariant 2). +- [ ] No `random`, `time`, `os.environ`, or filesystem access in + `planning/` (invariant 3). +- [ ] Same `(model, query, dialect)` ⇒ same plan and same SQL, + byte-identical (invariant 4 — guarded by `tests/properties/test_algebra_determinism.py`). +- [ ] Grain set on every state is non-empty for `source(...)` and only + coarsens on `aggregate(...)` (invariant 5). + +### 5.4 Error discipline + +- [ ] Every new failure raises an `OSIError` subclass. +- [ ] The error code is in `osi.errors.ErrorCode` and either in + `_APPENDIX_C_CODES` or in `_IMPLEMENTATION_EXTENSIONS` (with a + one-line rationale) inside `tests/unit/test_appendix_c_drift.py`. +- [ ] The error message names the dataset / field / grain and suggests + a fix (`ARCHITECTURE.md §7`). +- [ ] No bare `raise Exception(...)`, `raise ValueError(...)`, or + `assert` inside `src/`. + +### 5.5 Deterministic enforcement + +- [ ] Each new architectural invariant the change introduces has an + import-linter contract, an arch-test, or a drift test. +- [ ] If no mechanical check is possible, the invariant is documented + in `ARCHITECTURE.md §6` *and* in `INFRA.md §4` as a decision log + entry (so it cannot be relitigated without a new entry). + +## 6. Triage rule: prefer deterministic enforcement + +Findings from this skill walk a strict hierarchy. Apply this rule +whether you're using the skill to review existing code or to design new +code: + +1. **Convert to a deterministic check** — import-linter contract, + arch-test, drift test, mypy rule, lint rule. Preferred. Never + regresses; applies to every future change automatically. +2. **Sharpen this skill's checklist** — if the finding revealed a + missing angle, update this `SKILL.md` so future runs catch it. +3. **Tighten documentation** — when the rule is true but not + mechanically checkable, update `ARCHITECTURE.md` / a README / + `INFRA.md` and add an example. +4. **Queue as a code-change sprint item** — last resort, for findings + that need real implementation work (refactors, new abstractions). + +The same rule, in its design-time framing: before writing code that +establishes a boundary or invariant, ask "can I add a deterministic +check that locks this in before the code lands?" If yes, write the +check in the same PR. + +## 7. Existing deterministic checks this skill should leverage + +Cite these instead of re-inventing them; extend them when this skill +surfaces a missed angle. + +| Check | What it enforces | Source | +|:--|:--|:--| +| `import-linter` contracts | One-way layer flow | `pyproject.toml [tool.importlinter]` | +| `mypy --strict` | Frozen / typed state, no `Any` leakage | `pyproject.toml [tool.mypy]` | +| `tests/unit/test_appendix_c_drift.py` | Every raised error code is either in the spec or documented as an extension | source file | +| `tests/properties/test_error_taxonomy.py` | Algebra raises only `OSIError` (mutation-protected) | source file | +| `tests/properties/test_algebra_purity.py` | Operators have no side effects | source file | +| `tests/properties/test_algebra_determinism.py` | Same inputs ⇒ same plan | source file | +| `tests/properties/test_algebra_totality.py` | Every operator returns a state or raises typed | source file | +| `tests/properties/test_grain_closure.py` | Operators preserve / coarsen grain monotonically | source file | +| `make audit-file-size` | 600/700 LOC cap | `Makefile`, `INFRA.md §1.2` | + +If you find an architectural rule that *should* be in this table but +isn't, write the check (Phase D / triage step 1) and add the row. + +## 8. Example output format + +Produce `.review/_architectural_review.md` with one section per +finding: + +```markdown +## A-001 Codegen reaches into Namespace + +- **Severity**: P0 (breaks ARCHITECTURE.md §6.6 one-way flow) +- **Location**: `impl/python/src/osi/codegen/transpiler.py:142` +- **Finding**: `transpiler._render_join` imports `Namespace` to resolve + the right-hand identifier. Should resolve at planning time and pass + the resolved identifier on the plan step. +- **Triage**: + 1. (Deterministic) — `import-linter` already forbids + `osi.codegen → osi.parsing` but `Namespace` lives in + `osi.parsing.namespace`; add a forbidden import for this specific + module so the lint catches it next time. Lands in this PR. + 2. (Skill) — Add `Namespace` to the §5.1 checklist explicitly. + 3. (Code) — Move resolution to `planner._resolve_join_path`; + extend `PlanStep` with the resolved identifier. Queue as a + dedicated sprint item; out of scope for this PR. +- **Invariants touched**: §6.6, §6.7 +``` + +## 9. Anti-patterns + +- "We can break the layer just this once for performance." Forfeits + determinism, explainability, and dialect portability simultaneously + (`ARCHITECTURE.md §5.3`). Always wrong. +- "I'll add the check in the next PR." It never lands. The check is + the work; without it the boundary is a wish. +- A new module that imports across two layers "because it's only + helpers." Helpers belong in `common/` if cross-layer, in a layer + module if not. +- A new `CalculationState` constructed by hand in a planner submodule + to "save a step." Always indicates a missing operator or a missing + step factory. Add the factory; don't break closure. +- A second `Planner`. There is one planner (invariant 14). If the new + path needs different rules, the rules are a phase inside the existing + planner, not a sibling class. + +## See also + +- [`../../impl/python/ARCHITECTURE.md`](../../impl/python/ARCHITECTURE.md) — the contract. +- [`../../impl/python/INFRA.md`](../../impl/python/INFRA.md) — quality gates and the file-size cap. +- [`../../impl/python/docs/JOIN_ALGEBRA.md`](../../impl/python/docs/JOIN_ALGEBRA.md) — the closed algebra deep-dive. +- [`../../impl/python/docs/ALGEBRA_LAWS.md`](../../impl/python/docs/ALGEBRA_LAWS.md) — machine-checked laws. +- [`compiler-best-practices-review/SKILL.md`](../compiler-best-practices-review/SKILL.md) — sister skill focused on phase ordering and IR purity. diff --git a/.agent-skills/bi-best-practices-review/SKILL.md b/.agent-skills/bi-best-practices-review/SKILL.md new file mode 100644 index 0000000..e01c735 --- /dev/null +++ b/.agent-skills/bi-best-practices-review/SKILL.md @@ -0,0 +1,235 @@ +--- +name: bi-best-practices-review +description: Review or design code against BI analytical correctness patterns — grain awareness, fan-out (chasm trap), conformed dimensions, bridge dedup, semi-additive measures, count-distinct fan-out, ambiguous aggregation grain, role-playing dimensions, slowly-changing dimensions. Use when adding a metric, a join, an aggregation strategy, or any planner behaviour that interacts with grain. +--- + +# BI best-practices review + +A semantic-layer compiler stands or falls on whether it gets *grain* +right. This skill is the playbook for verifying that every aggregation, +join, and metric stays grain-safe — and for designing new BI features +so wrong-grain answers are rejected, not silently produced. + +The reference for the analytical concepts referenced below is +[`bi-concepts-to-osi`](../../../.cursor/skills/bi-concepts-to-osi/SKILL.md) +(or `.claude/skills/bi-concepts-to-osi/SKILL.md`). + +## 1. Purpose + +Ensure every BI idiom the planner emits is grain-safe, idempotent over +its declared algebra, and either provably correct or explicitly +rejected with a Foundation error code. *Plausibly wrong SQL is the +worst possible outcome.* + +## 2. When to use it (Review) + +Apply when the change: + +- Adds or modifies a metric — especially non-additive aggregates (`AVG`, + `MEDIAN`, percentile) or count-distinct. +- Adds or modifies a join path or relationship — particularly anything + with M:N or bridge semantics. +- Adds or modifies the fan-out / chasm-trap detection logic in + `planner_bridge.py`, `planner_mn.py`, or `classify.py`. +- Adds a new aggregation context (`group_aggregate`, nested aggregate, + scalar aggregate). +- Adds a new BI-idiom test under `compliance/foundation-v0.1/tests/`. +- Touches filter classification — `WHERE` vs `HAVING` vs semi-join. + +## 3. When to use it (Design) + +Apply *before* writing code when you are: + +- Designing how the planner will handle a new metric shape. +- Designing a new relationship cardinality (e.g. M:N with a bridge). +- Implementing a new BI proposal from + [`bi-concepts-to-osi`](../../../.cursor/skills/bi-concepts-to-osi/SKILL.md) + or the deferred-features section of + [`../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) §10. +- Considering whether a metric is *additive*, *semi-additive*, or + *non-additive* (and therefore whether it can be summed across the + fan-out dimension). +- Wondering whether a query should be rejected with `E_FAN_OUT_*`, + `E_UNSAFE_REAGGREGATION`, or another grain-safety code. + +At design time, write the *wrong* answer the new feature could produce +if grain were ignored. If your design can produce that wrong answer, +the implementation must either reject the query or restructure. + +## 4. Methodology + +1. **Identify the grain at every step.** For every operator in the plan + the change produces, note the grain explicitly. The grain is the + primary key of the dataset the state currently represents. An + operator that changes the grain (`aggregate`, `enrich` into a parent) + announces the new grain on the resulting state. +2. **Identify the fan-out.** Walk the join path and mark every edge + that *expands* row count (`1:N` away from the base / N:M / through a + bridge). The fan-out is where double-counting hides. +3. **Classify the aggregate.** Is each aggregate distributive + (`SUM`, `MIN`, `MAX`, `COUNT`), algebraic (`COUNT(DISTINCT)` over a + distributive denominator, `AVG`), or holistic + (`MEDIAN`, `PERCENTILE_CONT`, `STDDEV_POP` with bridge fan-out)? + - Distributive: safe to fan out and re-aggregate. + - Algebraic over a bridge: usually requires a single-pass with + `COUNT(DISTINCT)` over the bridged dimension (D-026). + - Holistic over fan-out: **must reject** (`E_UNSAFE_REAGGREGATION`) + unless a pre-aggregation can resolve to the correct grain first + (D-022). +4. **Confirm the filter context.** `WHERE` is row-level (pre-aggregate). + `HAVING` is post-aggregate. A predicate that mixes the two is + `E_MIXED_PREDICATE_LEVEL`. A predicate that references a finer-grain + field in a coarser aggregate is `E_UNAGGREGATED_FINER_GRAIN_REFERENCE` + (D-024). +5. **Confirm the dedup.** Bridge planning (`planner_bridge.py`) MUST + dedup the bridge side before joining unless the aggregate is + distinct-safe. Verify a test pins the dedup step (`t-015-bridge-dedup`). +6. **Reject if uncertain.** If neither the spec nor `JOIN_ALGEBRA.md` + gives an unambiguous answer, the planner must raise — never emit + "plausible" SQL. + +## 5. Checklists + +### 5.1 Grain awareness + +- [ ] Every new `CalculationState` carries a non-empty `grain`. +- [ ] An `aggregate` op coarsens; an `enrich` op preserves; a `merge` + requires both sides at the same grain. +- [ ] Tests assert the grain on at least one intermediate state, not + just the final SQL output (`tests/properties/test_grain_closure.py` + already does this universally; new operators must keep the law). + +### 5.2 Fan-out safety + +- [ ] Every join path that has a 1:N or M:N edge is flagged in + `joins.py` cardinality inference; ambiguous cases raise + `E3003 AMBIGUOUS_CARDINALITY`. +- [ ] A scalar query that traverses a 1:N edge raises + `E_FAN_OUT_IN_SCALAR_QUERY`. +- [ ] `COUNT(DISTINCT)` over a fan-out path is single-pass with a + `DISTINCT` projection or rejected (D-026). +- [ ] No metric is silently re-summed across a fan-out dimension. + +### 5.3 Bridge (M:N) safety + +- [ ] Every bridge join dedupes the bridge side before aggregation + (`planner_bridge.py`). +- [ ] Distributive aggregates over a deduped bridge use the + `bridged-single-pass` plan (D-027). +- [ ] `AVG` / `MEDIAN` / percentile over an M:N bridge raises + `E_UNSAFE_REAGGREGATION` until a safe rewrite exists (D-022, see + the open xfails under `tests/bridge/hard/`). +- [ ] Bridge-aware nested aggregation (`AVG(SUM(...))` style) is + deferred — `E_NESTED_AGGREGATION_DEFERRED` (D-027). + +### 5.4 Filter routing + +- [ ] Row-level predicate → `WHERE` (pre-aggregate). +- [ ] Aggregate predicate → `HAVING` (post-aggregate, named-filter via + the planner's classification). +- [ ] Mixing the two raises `E_MIXED_PREDICATE_LEVEL` (D-005, D-012). +- [ ] A predicate that references a finer-grain column in a + coarser-grain query raises `E_UNAGGREGATED_FINER_GRAIN_REFERENCE` + (D-024). +- [ ] A predicate on a windowed expression cannot live in `WHERE`; + raise `E_WINDOW_IN_WHERE` (D-029). + +### 5.5 Conformed dimensions and multi-fact + +- [ ] When two facts share a dimension, the planner stitches them + through the shared dimension's primary key, not by an arbitrary + join. +- [ ] If no shared dimension exists, the planner raises + `E3013 NO_STITCHING_DIMENSION`. + +### 5.6 Deferred BI features + +- [ ] Any reference to `FIXED` / `INCLUDE` / `EXCLUDE` / `TABLE` grain + modes raises `E_DEFERRED_KEY_REJECTED` or `E1105` at parse time. +- [ ] Semi-additive measures (snapshot LAST / FIRST) are deferred — + reject at parse time, do not emit window plumbing. +- [ ] Non-equijoin conditions are deferred — reject at parse time. + +## 6. Triage rule: prefer deterministic enforcement + +Findings from this skill walk a strict hierarchy. Apply this rule +whether you're using the skill to review existing code or to design new +code: + +1. **Convert to a deterministic check** — a compliance test that pins + the exact error code, a property test that asserts the algebra law, + or a drift test between the spec and the planner. Preferred. Never + regresses; applies to every future change automatically. +2. **Sharpen this skill's checklist** — if the finding revealed a + missing angle, update this `SKILL.md` so future runs catch it. +3. **Tighten documentation** — when the rule is true but not + mechanically checkable, update `ARCHITECTURE.md` / `JOIN_ALGEBRA.md` + / `INFRA.md` and add an example. +4. **Queue as a code-change sprint item** — last resort, for findings + that need real implementation work (refactors, new abstractions). + +The same rule, in its design-time framing: before writing code that +establishes a boundary or invariant, ask "can I add a deterministic +check that locks this in before the code lands?" If yes, write the +check in the same PR. + +## 7. Existing deterministic checks this skill should leverage + +| Check | What it enforces | Source | +|:--|:--|:--| +| `tests/properties/test_grain_closure.py` | Grain only coarsens monotonically | source | +| `tests/properties/test_chasm_safety.py` | Chasm traps reject; no silent fan-out | source | +| `tests/properties/test_explosion_safety.py` | Cartesian-style explosion rejected | source | +| `tests/properties/test_enrich_preserves_rows.py` | `enrich` never duplicates parent rows | source | +| `tests/properties/test_mn_rejection.py` | M:N without a safe plan raises | source | +| `tests/properties/test_planner_mn_rejection.py` | Planner-level M:N rejection has the right code | source | +| `compliance/foundation-v0.1/tests/bridge/` | Concrete BI scenarios pin behaviour | source | +| `compliance/foundation-v0.1/tests/cross_grain/` | Cross-grain metric scenarios | source | +| `compliance/foundation-v0.1/tests/filters/` | `WHERE` vs `HAVING` vs semi-join routing | source | +| `compliance/foundation-v0.1/tests/windows/moderate/t-052-window-over-fanout-foreclosed/` | Fan-out + window error is foreclosed | source | +| `bi-concepts-to-osi` skill | Mapping from BI concept to OSI implementation | `.cursor/skills/bi-concepts-to-osi/SKILL.md` | + +## 8. Example output format + +```markdown +## BI-001 `AVG` over a bridge silently produces wrong SQL + +- **Severity**: P0 (correctness bug; silently wrong result) +- **Location**: `impl/python/src/osi/planning/planner_bridge.py:312` +- **Scenario**: model has `customer 1—* order *—1 product`, metric is + `AVG(orders.amount)` grouped by `product.category`. Current behaviour + emits `AVG(amount)` after the bridge join, double-counting orders for + products with multiple categories. +- **Triage**: + 1. (Deterministic) — Pin the rejection with a compliance test + under `tests/bridge/hard/t-NNN-avg-over-bridge`. `metadata.yaml` + `expected_error_code: E_UNSAFE_REAGGREGATION`. Lands in this PR. + 2. (Skill) — Add §5.3's `AVG`-over-bridge bullet (done already; keep + it). + 3. (Code) — Implement bridge-aware AVG via two-pass plan; queue as + an INFRA roadmap item (D-022 sprint). +- **Spec refs**: D-022, D-027. +``` + +## 9. Anti-patterns + +- "We can `SELECT DISTINCT` away the fan-out at the end." `DISTINCT` + is not commutative with `SUM`. Always wrong. +- "The user wrote `AVG` over an M:N bridge, so we emit `AVG` and trust + them." The compiler's job is to refuse undefined queries. +- A new metric flag (`safe_avg: bool`) instead of a typed rejection. + Either the engine can compute it or it raises; no flags. +- Treating "no shared dimension" as a Cartesian join. Raise + `E3013 NO_STITCHING_DIMENSION`. +- Emitting `WHERE` for a predicate that references an aggregate, or + `HAVING` for a row-level predicate. The planner's filter + classifier (`classify.py`) is the only correct router. + +## See also + +- [`../../impl/python/docs/JOIN_ALGEBRA.md`](../../impl/python/docs/JOIN_ALGEBRA.md) — the closed algebra. +- [`../../impl/python/docs/JOIN_SAFETY.md`](../../impl/python/docs/JOIN_SAFETY.md) — fan-out / chasm rules. +- [`../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) §6 (joins) and §10 (deferred features). +- [`bi-concepts-to-osi`](../../../.cursor/skills/bi-concepts-to-osi/SKILL.md) — analytical-pattern dictionary. +- [`compiler-best-practices-review/SKILL.md`](../compiler-best-practices-review/SKILL.md) — phase ordering and IR purity for the planner side. +- [`database-best-practices-review/SKILL.md`](../database-best-practices-review/SKILL.md) — what makes the emitted SQL safe in real engines. diff --git a/.agent-skills/code-encourages-correct-use-review/SKILL.md b/.agent-skills/code-encourages-correct-use-review/SKILL.md new file mode 100644 index 0000000..c8f2e33 --- /dev/null +++ b/.agent-skills/code-encourages-correct-use-review/SKILL.md @@ -0,0 +1,202 @@ +--- +name: code-encourages-correct-use-review +description: Review or design code so it pushes correctness into the type system rather than relying on caller discipline. Use when adding new state types, factory functions, constructors, or any code where a misuse would compile silently. Sister skill to interfaces-and-api-review and typing-enforcement-review; focused on construction-time invariants and "make illegal states unrepresentable" patterns. +--- + +# Code that encourages correct use + +The cheapest bug is the one mypy or the constructor refuses. This skill +is the playbook for designing and reviewing code so that the type +system, constructor preconditions, and frozen-by-default values rule +out misuse — and so a contributor who tries the obvious wrong thing +gets a typed error, not silently wrong SQL. + +## 1. Purpose + +Ensure every new construct makes the *right* use easy and the *wrong* +use either uncompileable or fail-fast. The architecture's correctness +guarantees only hold if every value is constructed through the right +gate — this skill is the gate inspection. + +## 2. When to use it (Review) + +Apply when the change: + +- Adds a frozen dataclass that will travel between layers. +- Adds a factory function or constructor — anything that materialises + a "blessed" value (`CalculationState`, `QueryPlan`, `PlanStep`, + `Identifier`, `Dialect`, `FrozenSQL`). +- Accepts a raw `str` where a typed identifier / code / dialect is + meant. +- Uses a sentinel value (`None`, `-1`, `""`) to mean "unset" or + "missing." +- Has a boolean parameter that toggles meaning ("if True, do X else + do Y"). +- Introduces a new union of result shapes (`Result[T, E]`, + `Union[A, B]`) at a public API. + +## 3. When to use it (Design) + +Apply *before* writing code when you are: + +- Sketching a new value type for the pipeline. +- Deciding whether to expose a constructor or hide it behind a factory. +- Choosing between a `bool` flag, an `Enum`, or separate functions. +- Choosing between `Optional[T]` and a typed "absent" value. +- Wondering whether a check belongs at parse time, plan time, or + codegen time — usually the earliest layer that has enough information + is the right answer. + +At design time, write the *worst* code a contributor could plausibly +write against your new API. If your design lets it compile, redesign. + +## 4. Methodology + +1. **Identify the gate.** For each new type or value, identify the + single function that mints it. If there are two, that's already a + smell — `CalculationState` only comes from `source(...)` or an + algebra operator (`ARCHITECTURE.md` invariant 1); `Identifier` only + comes from `normalize_identifier(...)`; etc. +2. **Make the gate the only door.** Verify the type's `__init__` is + either private (`_State.__init__`) or has preconditions that no + raw caller could plausibly satisfy. Check that the gate is exported + and the raw `__init__` is not. +3. **Push checks earlier.** A check at parse time beats a check at plan + time beats a check at codegen time. If a runtime check could have + been a type, replace it. +4. **Audit the bool flags.** Every `bool` parameter is a request for a + future bug. Prefer a 2-variant `Enum`; prefer separate functions + when the bodies share <50% of the code. +5. **Audit the sentinels.** No `None`-meaning-error, no `""`-meaning- + absent, no `-1`-meaning-index-not-found. Raise instead. +6. **Audit the `Any` and `dict`.** Any time `Any` or `dict[str, Any]` + appears on a public boundary, ask: can this be a `Protocol`, a + `TypedDict`, or a frozen dataclass? + +## 5. Checklists + +### 5.1 Construction discipline + +- [ ] No new "blessed" type is constructed outside its owning module + *except* through a public factory. +- [ ] The factory is the only export in the module's `__init__.py` + (the raw type stays for type annotations, the factory for + construction). +- [ ] All factories validate inputs before returning; an invalid input + raises `OSIError`, never returns `None`. + +### 5.2 Make illegal states unrepresentable + +- [ ] Closed sets are enums, not strings. `Dialect`, `PlanOperation`, + `ErrorCode`, `JoinType` are enums; new closed sets follow the + pattern. +- [ ] Identifiers travel as `Identifier` (the `NewType`), not as `str`. +- [ ] Synthetic names come from `prefixes.py`, not from arbitrary + f-strings. +- [ ] "Optional with a default" uses `Optional[T] = None`, not a + sentinel of `T`. +- [ ] "Present but absent" (e.g. a SQL `NULL`) has a dedicated value, + not a missing key. + +### 5.3 Push checks earlier + +- [ ] Schema validity is checked in `parsing/` (pydantic + `validation.py`). +- [ ] Cross-reference validity is checked in `parsing/` (`Namespace` / + `RelationshipGraph` construction). +- [ ] Cardinality and grain are checked in `planning/`, before codegen + runs. +- [ ] Dialect-specific behaviour is checked in `codegen/dialect.py`, + before SQL is emitted. + +### 5.4 Boolean discipline + +- [ ] No `do_X: bool = False` parameter. Use an enum or split the + function. +- [ ] `if` ladders over a `bool` enum-equivalent are replaced with a + dispatch. + +### 5.5 Failure modes + +- [ ] No `Optional[T]` return type used to signal failure. +- [ ] No `Result[T, E]` / `Union[Success, Failure]` at public APIs — + raise typed `OSIError` instead. +- [ ] Every `assert` in `src/` is justified (only valid for "compiler- + bug, cannot continue" cases; prefer + `raise OSIError(ErrorCode.E_INTERNAL_INVARIANT, ...)`). + +## 6. Triage rule: prefer deterministic enforcement + +Findings from this skill walk a strict hierarchy. Apply this rule +whether you're using the skill to review existing code or to design new +code: + +1. **Convert to a deterministic check** — drift test, arch-test, + import-linter contract, mypy rule, lint rule. Preferred. Never + regresses; applies to every future change automatically. +2. **Sharpen this skill's checklist** — if the finding revealed a + missing angle, update this `SKILL.md` so future runs catch it. +3. **Tighten documentation** — when the rule is true but not + mechanically checkable, update `ARCHITECTURE.md` / a README / + `INFRA.md` and add an example. +4. **Queue as a code-change sprint item** — last resort, for findings + that need real implementation work (refactors, new abstractions). + +The same rule, in its design-time framing: before writing code that +establishes a boundary or invariant, ask "can I add a deterministic +check that locks this in before the code lands?" If yes, write the +check in the same PR. + +## 7. Existing deterministic checks this skill should leverage + +| Check | What it enforces | Source | +|:--|:--|:--| +| `mypy --strict` | No `Any`, no untyped `dict`, frozen requires explicit `frozen=True` | `pyproject.toml [tool.mypy]` | +| `tests/properties/test_algebra_purity.py` | No mutation of inputs by any operator | source | +| `tests/properties/test_algebra_determinism.py` | Same `(model, query)` ⇒ identical `QueryPlan` | source | +| `tests/properties/test_error_taxonomy.py` | Failures raise typed `OSIError` (mutation-protected on the algebra) | source | +| `tests/unit/test_common_identifiers.py` | All identifier comparisons go through `normalize_identifier` | source | +| `tests/unit/test_synthetic_naming_invariants.py` | Synthetic names come from `prefixes.py` | source | +| `tests/unit/test_operator_enum_sync.py` | `PlanOperation` and operator dispatch stay in sync | source | +| Phase D `d1-osierror-arch` (added by this audit) | Every `Exception` subclass in `osi.*` is an `OSIError` | `tests/unit/test_every_exception_is_osierror.py` | + +## 8. Example output format + +```markdown +## C-001 `compile_plan` takes a `str` for dialect + +- **Severity**: P1 (silent typo at user boundary) +- **Location**: `impl/python/src/osi/codegen/__init__.py:compile_plan` +- **Finding**: `compile_plan(plan, dialect: str = "ansi")` accepts any + string. A typo (`"snowfalke"`) currently emits ANSI SQL silently. +- **Triage**: + 1. (Deterministic) — Change the signature to + `dialect: Dialect = Dialect.ANSI`. mypy will reject every typo at + call site. Lands in this PR. + 2. (Skill) — Add `compile_plan` to §5.2 example list. + 3. (Code) — Migrate one downstream call site that passes a raw + string from a config file; queue the migration sprint. +- **Invariants touched**: ARCHITECTURE.md §6.11. +``` + +## 9. Anti-patterns + +- "I'll accept `str` and convert internally" — the conversion is + always lossy (case, whitespace, unknown values). Push the conversion + to a typed factory at the boundary. +- "I'll return `None` if the lookup fails" — callers won't check. + Raise typed `OSIError`. +- "It's just a flag" — every `bool` parameter is a future + `if-elif-else` ladder that becomes a feature flag that becomes a + config knob. Start with an enum. +- A constructor that "validates lazily" (i.e. invariants are checked + in `__post_init__` only sometimes). Validate at construction or + don't expose the constructor. +- `__init__` with optional arguments to switch construction modes + (`Source(a=1)` vs `Source(b=2)`). Use named factories + (`Source.from_a(...)`, `Source.from_b(...)`). + +## See also + +- [`../../impl/python/ARCHITECTURE.md`](../../impl/python/ARCHITECTURE.md) §5 (algebra closure), §6 (invariants 1, 2, 11). +- [`interfaces-and-api-review/SKILL.md`](../interfaces-and-api-review/SKILL.md) — sister skill on facade hygiene and signature shape. +- [`typing-enforcement-review/SKILL.md`](../typing-enforcement-review/SKILL.md) — sister skill on mypy strictness, `NewType` migration catalog. diff --git a/.agent-skills/compiler-best-practices-review/SKILL.md b/.agent-skills/compiler-best-practices-review/SKILL.md new file mode 100644 index 0000000..94e0391 --- /dev/null +++ b/.agent-skills/compiler-best-practices-review/SKILL.md @@ -0,0 +1,226 @@ +--- +name: compiler-best-practices-review +description: Review or design code against compiler engineering best practices — phase boundaries, IR purity, total functions, closed operator algebras, pass ordering, deterministic codegen, error taxonomy, fail-fast preconditions. Use when adding a planner phase, a new IR node, a transform, or any code that participates in the parsing → planning → codegen pipeline as a transformation. +--- + +# Compiler best-practices review + +The reference implementation is a compiler. This skill is the playbook +for verifying that every change preserves the compiler-engineering +properties — phase isolation, IR purity, total transformations, +deterministic output, fail-fast on invariant violation — and for +designing new transformations so those properties hold by construction. + +## 1. Purpose + +Ensure every change preserves the properties that make the compiler +correct, testable, and explainable: phases are isolated, IRs are +immutable, operators are total, transformations are pure functions, +and the same inputs always produce the same outputs. + +## 2. When to use it (Review) + +Apply when the change: + +- Adds, removes, or reorders a planner phase + (`preprocess` / `classify` / `resolve` / `plan` / `bridge` / `nested` + / `composites` / `mn` / `home_grain`). +- Adds a new operator to `planning/algebra/operations.py` or relaxes a + precondition on an existing one. +- Adds a new `PlanStep` payload type, a new `PlanOperation` enum value, + or a new field on an existing payload. +- Modifies `codegen/transpiler.py`, `codegen/cte_optimizer.py`, or + `codegen/dialect.py`. +- Adds new "metadata" on a plan step or state that downstream code + reads. +- Changes the order in which validation, classification, or resolution + happens. + +## 3. When to use it (Design) + +Apply *before* writing code when you are: + +- Designing a new planner phase or splitting an existing one. +- Designing a new IR node (a new payload type, a new state field). +- Deciding what data a transformation consumes vs produces. +- Wondering whether a check belongs in parsing, planning, or codegen. +- Considering an "optimisation" that crosses a phase boundary + (e.g. codegen consults the model). Usually the answer is *don't*; + the question is whether the missing data should be on the plan step. + +## 4. Methodology + +1. **Locate the phase.** Compile the change against the planner phase + ordering in `ARCHITECTURE.md §3` and `JOIN_ALGEBRA.md §7`. Confirm + the change lives in a single phase; reject phase-spanning changes + unless the abstraction is genuinely cross-cutting (e.g. `prefixes.py` + is consulted by both planning and codegen because it produces names; + that's the only acceptable cross-cut). +2. **Audit IR purity.** The IR types (`CalculationState`, `Column`, + `PlanStep`, `QueryPlan`, `PlanPayload` subclasses) are frozen + dataclasses. Every transformation returns a *new* IR value; nothing + mutates an input. Verify the change does not add a setter, an + `update_in_place`, or a `__post_init__` that mutates fields. +3. **Audit totality.** Every operator is `(state, args) → state`. The + "no exceptions" version of total means: any precondition violation + raises a typed `OSIError` *before* any state mutation could happen. + No half-built states, no `Optional[State]` returns, no `None`-means- + failure. +4. **Audit determinism.** The plan and SQL must be byte-identical for + the same inputs. Confirm new code uses `prefixes.py` for synthetic + names, sorted iteration over sets / dicts, and no hash-order + dependence. +5. **Audit error taxonomy.** Every raised exception is `OSIError` (or + subclass) with a code in `osi.errors.ErrorCode`. No `assert` in + `src/`; no `raise Exception(...)`. The error message names the + spec-level concept that failed, not the implementation detail. +6. **Audit pass ordering.** If the change adds a new phase, confirm: + - Phases run in a fixed, declared order in `planner.py`. + - Each phase consumes the output of an earlier phase and produces + the input of a later phase — no back-edges. + - The phase is testable in isolation (a unit test that runs only + the new phase). + +## 5. Checklists + +### 5.1 Phase isolation + +- [ ] Parsing does no planning; planning does no codegen; codegen does + no semantic decisions (`ARCHITECTURE.md §1.1`). +- [ ] No phase reaches back into a previous phase's mutable state. +- [ ] Each phase's input and output types are concrete, frozen. + +### 5.2 IR purity + +- [ ] No new mutable IR type. Every new payload is frozen. +- [ ] No new top-level field on `CalculationState`, `Column`, + `PlanStep`, or `QueryPlan` unless it carries information the + *next* phase needs and is provably immutable. +- [ ] No `Optional` on IR fields used for "I'll fill it in later." + Either populate at construction or compute it on demand. + +### 5.3 Total transformations + +- [ ] Every operator signature is `(state, args) → state` (or + `(...) → state` for `source(...)`). +- [ ] Every precondition is checked *before* the transformation + starts; failure raises `OSIError`. +- [ ] No operator returns `None` to signal failure. +- [ ] No operator catches its own exception and tries to recover. + +### 5.4 Deterministic codegen + +- [ ] New synthetic names come from `prefixes.py` counters. +- [ ] No new hash-order iteration over a `set` or `dict` in `planning/` + / `codegen/` paths that determine output. +- [ ] No `random`, no `time.time()`, no `os.environ` access during + planning or codegen. + +### 5.5 Error taxonomy + +- [ ] Every new failure has a typed `OSIError` and a code. +- [ ] The code is in Appendix C or in `_IMPLEMENTATION_EXTENSIONS`. +- [ ] The code's catalog row in `docs/ERROR_CODES.md` and + `diagnostics/error_catalog.py` is updated in the same PR. +- [ ] No `raise Exception` / `raise ValueError` / `raise RuntimeError` + in `src/`. +- [ ] No `assert` in `src/` (only valid as a "compiler bug, cannot + continue" signal; prefer + `raise OSIError(ErrorCode.E_INTERNAL_INVARIANT, ...)`). + +### 5.6 Pass ordering and composability + +- [ ] A new phase has a single entry function that takes prior output + and returns the next IR. +- [ ] The phase is exercised by at least one unit test in isolation + (not just through the end-to-end `Planner.plan`). +- [ ] If the phase rewrites the IR, the rewrite is idempotent (applying + twice = applying once). Add a property test if not already + enforced. + +## 6. Triage rule: prefer deterministic enforcement + +Findings from this skill walk a strict hierarchy. Apply this rule +whether you're using the skill to review existing code or to design new +code: + +1. **Convert to a deterministic check** — property test, drift test, + arch-test, mypy rule, lint rule. Preferred. Never regresses; + applies to every future change automatically. +2. **Sharpen this skill's checklist** — if the finding revealed a + missing angle, update this `SKILL.md` so future runs catch it. +3. **Tighten documentation** — when the rule is true but not + mechanically checkable, update `ARCHITECTURE.md` / `JOIN_ALGEBRA.md` + / `ALGEBRA_LAWS.md` / `INFRA.md` and add an example. +4. **Queue as a code-change sprint item** — last resort, for findings + that need real implementation work (refactors, new abstractions). + +The same rule, in its design-time framing: before writing code that +establishes a boundary or invariant, ask "can I add a deterministic +check that locks this in before the code lands?" If yes, write the +check in the same PR. + +## 7. Existing deterministic checks this skill should leverage + +| Check | What it enforces | Source | +|:--|:--|:--| +| `import-linter` contracts | Phase isolation at the import edge | `pyproject.toml` | +| `tests/properties/test_algebra_purity.py` | Operators do not mutate inputs | source | +| `tests/properties/test_algebra_totality.py` | Operators return a state or raise typed | source | +| `tests/properties/test_algebra_determinism.py` | Same inputs ⇒ same plan | source | +| `tests/properties/test_grain_closure.py` | Grain coarsens monotonically | source | +| `tests/properties/test_aggregate_idempotent.py` | Aggregate is idempotent over identity grouping | source | +| `tests/properties/test_project_idempotent.py` | Project is idempotent | source | +| `tests/properties/test_filter_commute.py` | Filter / aggregate commute laws hold | source | +| `tests/properties/test_merge_associative.py` | Merge is associative | source | +| `tests/properties/test_error_taxonomy.py` | Algebra raises only `OSIError` | source | +| `tests/properties/test_frozensql_canonical.py` | `FrozenSQL` equality is canonical-form-based | source | +| `tests/unit/test_operator_enum_sync.py` | `PlanOperation` enum and operator registry stay in sync | source | +| `mutmut` on `src/osi/planning/algebra/` ≥ 90% | Operators are tested against their stated laws | `INFRA.md §1.1` | + +## 8. Example output format + +```markdown +## CC-001 New phase reads the model directly instead of the plan + +- **Severity**: P1 (phase boundary leak) +- **Location**: `impl/python/src/osi/planning/planner_X.py:88` +- **Finding**: The new `expand_role_playing_dim` phase imports + `SemanticModel` and looks up the role-playing dimension by name on + the model — but the prior phase already attached the resolved role + to the plan step. The phase should consume the prior phase's output, + not the model. +- **Triage**: + 1. (Deterministic) — Add a unit test that runs + `expand_role_playing_dim` against a synthetic `PlanStep` with no + `SemanticModel` in scope; the call must succeed. Lands in this PR. + 2. (Skill) — Add a "phases consume the prior phase's IR, not the + model" bullet to §5.1. + 3. (Code) — Drop the `SemanticModel` import from this phase; pass + the attached role-playing record on the step. Lands in this PR. +- **Invariants touched**: ARCHITECTURE.md §6.7. +``` + +## 9. Anti-patterns + +- A new phase that reaches into the model "just for one lookup." + Either attach the lookup result to the prior phase's output, or move + the lookup into the prior phase. +- A planner phase that mutates the plan in place to "save an allocation." + Mutation forfeits determinism, replayability, and the property tests. +- A new operator with a precondition checked *after* the transformation. + Always before. Half-states are unobservable but bug-bearing. +- `assert` in `src/` to "guard an invariant." Use + `raise OSIError(ErrorCode.E_INTERNAL_INVARIANT, ...)` so it survives + `-O`, registers in the error taxonomy, and lands in the catalog. +- A second planner ("SimplePlanner") that "skips the bridge analysis" + for "fast" queries. There is one planner (invariant 14). New rules + are phases inside the existing planner. + +## See also + +- [`../../impl/python/ARCHITECTURE.md`](../../impl/python/ARCHITECTURE.md) §3, §5, §6. +- [`../../impl/python/docs/JOIN_ALGEBRA.md`](../../impl/python/docs/JOIN_ALGEBRA.md) — operator specification. +- [`../../impl/python/docs/ALGEBRA_LAWS.md`](../../impl/python/docs/ALGEBRA_LAWS.md) — machine-checked laws. +- [`bi-best-practices-review/SKILL.md`](../bi-best-practices-review/SKILL.md) — sister skill on grain / fan-out / bridge correctness. +- [`database-best-practices-review/SKILL.md`](../database-best-practices-review/SKILL.md) — sister skill on the codegen / SQL emission side. diff --git a/.agent-skills/database-best-practices-review/SKILL.md b/.agent-skills/database-best-practices-review/SKILL.md new file mode 100644 index 0000000..d01e199 --- /dev/null +++ b/.agent-skills/database-best-practices-review/SKILL.md @@ -0,0 +1,240 @@ +--- +name: database-best-practices-review +description: Review or design code against database/SQL engineering best practices — SQL emission via AST not strings, identifier quoting and case-folding, NULL ordering, multiset vs set semantics in compliance assertions, dialect adapter design, predicate/projection pushdown surface, FrozenSQL canonical-form discipline. Use when adding or touching codegen, dialect adapters, the compliance harness's row-comparison logic, or any code that emits, parses, or compares SQL. +--- + +# Database best-practices review + +A semantic-layer compiler that emits SQL must respect what real +databases actually *do* — not the textbook SQL semantics. This skill +is the playbook for verifying that emitted SQL is correct, +deterministic, and safely portable across engines, and for designing +codegen so the dialect surface area is explicit rather than implicit. + +## 1. Purpose + +Ensure every emitted SQL string is built via SQLGlot AST (never +f-strings or concatenation), every identifier is properly quoted and +case-folded, every test that compares output uses multiset semantics +where the spec demands it, and every dialect-specific quirk is named +and isolated. + +## 2. When to use it (Review) + +Apply when the change: + +- Touches `codegen/transpiler.py`, `codegen/cte_optimizer.py`, + `codegen/dialect.py`, or `codegen/types.py`. +- Adds or modifies a dialect (`Dialect.DUCKDB`, `Dialect.SNOWFLAKE`, + `Dialect.ANSI`). +- Adds or modifies a compliance-harness assertion (multiset, ordering, + NULL semantics). +- Uses any `str.format`, `+`, or f-string to construct anything + SQL-shaped in `src/`. +- Touches `osi.common.identifiers` or `osi.common.sql_expr` + (`FrozenSQL`). +- Adds a new compliance adapter, or modifies the existing + `osi_python_adapter.py`. +- Introduces a new error code from a SQL execution failure. + +## 3. When to use it (Design) + +Apply *before* writing code when you are: + +- Designing a new dialect adapter. +- Designing how a SQL feature (CTE chain, window frame, set op) will be + emitted across dialects. +- Designing how compliance rows are compared against `gold_rows.json`. +- Choosing how to represent NULL behaviour in tests + (`NULLS FIRST` / `NULLS LAST` / `NULLS IGNORED`). +- Designing the predicate/projection-pushdown surface that planner + emits to codegen. +- Considering an "optimisation" at codegen time that requires the + rendered SQL string to be re-parsed and rewritten. + +## 4. Methodology + +1. **Audit SQL construction.** Verify every SQL fragment goes through + `sqlglot.exp.*` nodes, `FrozenSQL.of(...)`, or + `parse_sql_expr(...)`. No f-string SQL, no `+ ' WHERE ' +`, no + `.format(...)` with SQL keywords. +2. **Audit identifier handling.** Every identifier passes through + `normalize_identifier(...)` (case folding) before comparison. + Every identifier rendered into SQL is quoted by SQLGlot's + dialect-aware quoter, not by hand. +3. **Audit dialect divergence.** Every dialect-specific behaviour is + isolated in `codegen/dialect.py`. The planner has no knowledge of + dialect; codegen only branches on `Dialect` at the dialect layer, + never deep in transpilation. +4. **Audit NULL semantics.** Every aggregate over `NULL`s declares its + behaviour (`SUM(NULL)`, `COUNT(*)` vs `COUNT(col)`, `MAX` over + all-NULL groups). Every `ORDER BY` declares `NULLS FIRST` or + `NULLS LAST` if results matter. +5. **Audit comparison semantics.** Compliance assertions over result + sets use *multiset* (bag) equality by default. Set equality is wrong + (`SELECT 1 UNION ALL SELECT 1` is two rows, not one). Order equality + is only correct when the spec mandates an `ORDER BY`. +6. **Audit pushdown surface.** Every predicate/projection that the + planner intends codegen to push down is declared on the plan step + (a `predicate_pushdown` annotation, a `projection_set`); codegen + reads it, never re-derives it. + +## 5. Checklists + +### 5.1 SQL construction discipline + +- [ ] No f-string in `src/` that contains a SQL keyword + (`SELECT`, `FROM`, `WHERE`, `JOIN`, `GROUP BY`, `HAVING`, + `ORDER BY`, `UNION`, `WITH`). +- [ ] No `+ "..."` or `.join(...)` building a SQL clause. +- [ ] Every SQL expression is built via `sqlglot.exp.*` nodes or + goes through `parse_sql_expr(...)` and ends as a `FrozenSQL`. +- [ ] CTE chains are constructed via `sqlglot.exp.CTE`, not by string + templates. + +### 5.2 Identifier safety + +- [ ] All identifier comparisons go through + `osi.common.identifiers.identifiers_equal(...)` (or implicitly + via `Identifier` `NewType` equality after `normalize_identifier`). +- [ ] No raw `==` on identifier strings (`flake8` should catch this; + treat any survivor as a review issue). +- [ ] Quoting at SQL emission is delegated to + `sqlglot.exp.to_identifier(...)` with the dialect set. +- [ ] Reserved-name collisions are caught at parse time (D-019, + `E_RESERVED_NAME`); codegen does not re-check. + +### 5.3 Dialect isolation + +- [ ] `Dialect` is an enum with explicit named members; no `str` + dialect in public APIs (`compile_plan(dialect=Dialect.DUCKDB)`, + never `dialect="duckdb"`). +- [ ] Dialect-specific AST transforms live in `codegen/dialect.py`. +- [ ] Tests that pin SQL output do so per-dialect (golden files named + `expected..sql`). +- [ ] No "if dialect == X" inside `transpiler.py`; route through the + dialect module. + +### 5.4 NULL handling + +- [ ] `SUM` over an empty group is `NULL` in SQL — confirm test rows + expect `NULL`, not `0`, unless the metric body wraps in + `COALESCE`. +- [ ] `COUNT(*)` and `COUNT(col)` are distinct; the metric body + determines which. +- [ ] `ORDER BY` that matters for test row order declares + `NULLS FIRST` / `NULLS LAST` per-dialect — defaults differ + (Postgres = `NULLS LAST`, others vary). +- [ ] `MIN` / `MAX` over an all-NULL group returns `NULL`, not an + error. + +### 5.5 Result-set comparison + +- [ ] Compliance harness compares `gold_rows.json` against the engine + output as a *multiset* unless `order_sensitive: true` on the + test metadata. +- [ ] Float comparison uses an explicit tolerance for non-integer + metrics; `assert == 3.14` is a flaky test. +- [ ] Comparisons over date / timestamp normalise time zone explicitly. + +### 5.6 Pushdown surface (planner → codegen) + +- [ ] Every predicate / projection codegen renders is declared on a + `PlanStep` field; codegen does not re-classify filters or + re-resolve names. +- [ ] No codegen path opens the `SemanticModel`, the `Namespace`, or + the `RelationshipGraph`. +- [ ] CTE inlining / chaining / folding in `cte_optimizer.py` operates + on a fully-typed AST; it does not invent new column names. + +### 5.7 FrozenSQL discipline + +- [ ] `FrozenSQL.of(...)` is the only constructor; raw + `FrozenSQL(...)` calls outside `osi.common.sql_expr` are bugs. +- [ ] `sql_expr_equal(a, b)` is the only correct equality on SQL + expressions; `==` on raw `sqlglot.Expression` is not stable. + +## 6. Triage rule: prefer deterministic enforcement + +Findings from this skill walk a strict hierarchy. Apply this rule +whether you're using the skill to review existing code or to design new +code: + +1. **Convert to a deterministic check** — a custom flake8 / `rg` lint + for banned tokens, a unit test that asserts no f-string SQL, a + golden test that pins per-dialect emission, a property test on + identifier quoting. Preferred. Never regresses. +2. **Sharpen this skill's checklist** — if the finding revealed a + missing angle, update this `SKILL.md` so future runs catch it. +3. **Tighten documentation** — when the rule is true but not + mechanically checkable, update `ARCHITECTURE.md` / a README / + `INFRA.md` and add an example. +4. **Queue as a code-change sprint item** — last resort, for findings + that need real implementation work (refactors, new abstractions). + +The same rule, in its design-time framing: before writing code that +establishes a boundary or invariant, ask "can I add a deterministic +check that locks this in before the code lands?" If yes, write the +check in the same PR. + +## 7. Existing deterministic checks this skill should leverage + +| Check | What it enforces | Source | +|:--|:--|:--| +| Banned f-string SQL grep | `f"...SELECT..."` and similar in `src/` | `INFRA.md §1.2` ("Ban raw-string SQL" + custom flake8 check) | +| `tests/properties/test_frozensql_canonical.py` | `FrozenSQL` equality is canonical-form-based | source | +| `tests/unit/test_common_identifiers.py` | Identifier comparisons go through `normalize_identifier` | source | +| `tests/unit/test_common_sql_expr.py` | `FrozenSQL.of` and `parse_sql_expr` agree on equality | source | +| `tests/golden/` (per-dialect) | Same plan → byte-identical SQL per dialect | source | +| `tests/e2e/` (DuckDB execution) | Emitted SQL actually runs and returns expected rows | source | +| `compliance/harness/src/harness/runner.py` row comparison | Multiset semantics by default | source | +| `import-linter` codegen ← parsing forbidden | Codegen does not reach back into the model | `pyproject.toml` | +| `mutmut` on `src/osi/codegen/` ≥ 75% | Dialect emission is tested against its laws | `INFRA.md §1.1` | + +## 8. Example output format + +```markdown +## DB-001 CTE optimiser builds `WHERE` clause by f-string + +- **Severity**: P0 (banned construction; correctness risk + injection + surface even in a generator) +- **Location**: `impl/python/src/osi/codegen/cte_optimizer.py:288` +- **Finding**: `_collapse_filter_chain` constructs the merged + predicate as `f"({lhs}) AND ({rhs})"`. Loses identifier quoting and + breaks if either side contains the dialect's escape character. +- **Triage**: + 1. (Deterministic) — Add a banned-pattern check to the `rg` lint: + `"AND.*\\{` and similar f-string SQL fingerprints inside + `src/osi/codegen/`. Lands in this PR. + 2. (Skill) — Add an explicit "CTE merge" bullet to §5.1. + 3. (Code) — Replace with + `sqlglot.exp.and_(parse_one(lhs), parse_one(rhs))`; if the + fragments are already `FrozenSQL`, use + `sqlglot.exp.and_(lhs.expression, rhs.expression)`. +- **Invariants touched**: ARCHITECTURE.md §6.10 (SQL composition via + AST only). +``` + +## 9. Anti-patterns + +- "I'll just `'{table}'.format(table=name)` for a quick fix" — quoting, + case, and reserved words go wrong. Use SQLGlot exp nodes. +- A codegen path that branches on `dialect == "snowflake"` inside the + transpiler. Push the branch into `dialect.py`. +- A compliance test that asserts on result rows by *list* equality + when the spec didn't mandate order. Use a multiset comparison. +- Comparing `FrozenSQL` instances with `==` on the underlying + `sqlglot.Expression`. Use `sql_expr_equal`. +- Pinning a SQL string in a unit test for the planner — planner tests + assert plan shapes, not SQL. SQL pinning is for codegen goldens. +- A new error code for a SQL execution failure ("E_NULL_IN_GROUP_BY"). + Codegen does not own runtime errors; the planner rejects the query + at plan time, or the test must accept the engine's native error. + +## See also + +- [`../../impl/python/ARCHITECTURE.md`](../../impl/python/ARCHITECTURE.md) §4 (codegen) and §6.10 / §6.11 (SQL / identifier invariants). +- [`../../impl/python/INFRA.md`](../../impl/python/INFRA.md) §1.2 (banned tokens), §1.3 (SQL correctness). +- [`../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md`](../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md) — supported SQL subset. +- [`bi-best-practices-review/SKILL.md`](../bi-best-practices-review/SKILL.md) — sister skill on grain / fan-out at the BI / planner level. +- [`compiler-best-practices-review/SKILL.md`](../compiler-best-practices-review/SKILL.md) — sister skill on phase ordering / IR purity at the planner level. diff --git a/.agent-skills/doc-as-enforcement-review/SKILL.md b/.agent-skills/doc-as-enforcement-review/SKILL.md new file mode 100644 index 0000000..c35eadb --- /dev/null +++ b/.agent-skills/doc-as-enforcement-review/SKILL.md @@ -0,0 +1,196 @@ +--- +name: doc-as-enforcement-review +description: Review or design documentation so it stays mechanically consistent with the code via drift tests, layer READMEs, and citation conventions. Use when adding ARCHITECTURE.md content, a new layer README, a spec section reference in code, an example in a README, or any doc that the codebase mechanically depends on. Sister skill to spec-coherence-review (which targets the spec ↔ impl axis specifically). +--- + +# Doc as enforcement review + +Documentation is a load-bearing component of the reference +implementation; it tells contributors and external implementers what +the invariants are. To stay useful, every doc claim that *can* be +mechanically checked *must* be — otherwise the doc rots and reviewers +lose their reference. This skill is the playbook for both writing +docs and converting documented invariants into drift tests. + +## 1. Purpose + +Ensure every documented architectural claim, layer README "what's +here" table, code citation of a spec section, and runnable README +example is backed by a drift test that fails when the doc and the +code disagree. + +## 2. When to use it (Review) + +Apply when the change: + +- Adds, modifies, or removes a section in `impl/python/ARCHITECTURE.md`, + `INFRA.md`, `SPEC.md`, or any `docs/*.md` under `impl/python/`. +- Adds, modifies, or removes a layer `README.md` (under + `impl/python/src/osi//`). +- Adds a citation like `(Spec: §X.Y)` or `(D-NNN)` or `(F-NN)` inside + source code or a docstring. +- Adds a runnable example to a README or a docs file. +- Adds or modifies a `docs/ERROR_CODES.md` row. +- Adds a new top-level doc anywhere in the repo. + +## 3. When to use it (Design) + +Apply *before* writing docs when you are: + +- Designing the structure of a new `*.md` file (which sections, how + cross-references work). +- Designing how a doc invariant ("helpers never import the model") gets + enforced in code. +- Deciding which doc is "load-bearing" vs "narrative." Load-bearing + docs need drift tests; narrative docs do not. +- Designing how examples in a README get exercised in CI. +- Adding a new doc-citation convention (e.g. a new tag like `(I-NN)`). + +## 4. Methodology + +1. **List the load-bearing claims.** For every doc the change touches, + list claims that are mechanically checkable: a numbered invariant, + a section header, a path reference, a code example, a list of + modules in a layer. +2. **Check each claim has a drift test.** If a claim is mechanically + checkable and no drift test exists, the doc is unenforced — every + future edit can silently break it. +3. **Audit citations.** Every `(Spec: §X.Y)`, `(D-NNN)`, `(F-NN)`, + `(I-NN)` in source code or docs cites a real anchor in the + referenced document. Phase C's spec-section-refs drift test + enforces this for `(Spec: §X.Y)`; new tag families need new drift + tests. +4. **Audit examples.** Every Python / shell example in a README is + either commented as "illustrative only" or is exercised in CI + (`pytest --doctest-glob='*.md'` or a dedicated test). +5. **Audit cross-references.** Every link to a sibling doc is relative + and resolves. Every link to an external URL has a fallback path + (the rule it enforces should be self-contained in the repo). +6. **Audit deletion.** When removing a doc section, search the rest of + the repo for citations to that section. A removed `§3.5` is a broken + link in every catalog row, error message, and SKILL.md that cited it. + +## 5. Checklists + +### 5.1 Load-bearing doc inventory + +- [ ] `ARCHITECTURE.md` invariants are numbered and each cites a code + location or an existing arch-test. +- [ ] `INFRA.md §3` roadmap items reference the sprint that completed + them or `planned` / `in-progress` status. +- [ ] `INFRA.md §4` decision-log entries cover every settled + infrastructure choice. +- [ ] Every layer's `README.md` is present and lists every public + symbol in that layer's `__init__.py`. +- [ ] `docs/ERROR_CODES.md` mirrors `osi.errors.ErrorCode`. + +### 5.2 Drift tests + +- [ ] `tests/unit/test_appendix_c_drift.py` — Appendix C vs + `ErrorCode`. +- [ ] `tests/unit/test_operator_enum_sync.py` — `PlanOperation` enum + vs operator dispatch. +- [ ] Phase C `c1-specrefs` — `(Spec: §X.Y)` citations resolve. +- [ ] Phase C `c2-invariants` — `ARCHITECTURE.md §6` numbered + invariants are listed in `pyproject.toml` import-linter or in a + named arch-test. +- [ ] Phase C `c3-readme` — README examples actually run. +- [ ] Phase C `c4-layer-readme` — Layer README "Modules" table matches + the `.py` files in the folder. + +### 5.3 Citation conventions + +- [ ] `(Spec: §X.Y)` cites a heading in + `proposals/foundation-v0.1/Proposed_OSI_Semantics.md`. +- [ ] `(D-NNN)` cites a row in Appendix B of the spec. +- [ ] `(E_NNN)` or `(E1NNN, E2NNN, …)` cites a member of + `osi.errors.ErrorCode`. +- [ ] `(I-NN)` cites an `INFRA.md §3` roadmap item. +- [ ] `(F-NN)` cites a finding in a `.review/_*.md` report. +- [ ] `(T-NNN)` cites a test under `compliance/foundation-v0.1/tests/`. + +### 5.4 Examples in docs + +- [ ] Python examples in `impl/python/README.md` are either commented + as illustrative or exercised by a test under + `tests/integration/readme/` (Phase C `c3-readme`). +- [ ] Shell examples in skill files / READMEs use commands that exist + (`make check`, not `make test:fast` if no such target). + +### 5.5 Deletion / rename safety + +- [ ] Before removing a doc section, `rg` for the section title and + anchor across the repo. +- [ ] When renaming, leave a redirect block (`> moved to §X.Y`) for at + least one release cycle. + +## 6. Triage rule: prefer deterministic enforcement + +Findings from this skill walk a strict hierarchy. Apply this rule +whether you're using the skill to review existing code or to design new +code: + +1. **Convert to a deterministic check** — drift test, doctest, anchor + check, link checker. Preferred. Never regresses; applies to every + future change automatically. +2. **Sharpen this skill's checklist** — if the finding revealed a + missing angle, update this `SKILL.md` so future runs catch it. +3. **Tighten documentation** — when the rule is true but not + mechanically checkable, update the relevant doc and add an example. +4. **Queue as a code-change sprint item** — last resort, for findings + that need real implementation work (refactors, new abstractions). + +The same rule, in its design-time framing: before writing a new doc +section, ask "what drift test will keep this in sync with the code?" +If the answer is "none," consider whether the section is load-bearing +or narrative; if load-bearing, the drift test lands in the same PR. + +## 7. Existing deterministic checks this skill should leverage + +| Check | What it enforces | Source | +|:--|:--|:--| +| `tests/unit/test_appendix_c_drift.py` | Appendix C ↔ `ErrorCode` enum | source | +| `tests/unit/test_operator_enum_sync.py` | `PlanOperation` ↔ operator registry | source | +| `tests/unit/test_synthetic_naming_invariants.py` | Synthetic names use `prefixes.py` | source | +| Phase C `c1-specrefs` (added) | `(Spec: §X.Y)` citations resolve | `tests/unit/test_spec_section_refs_drift.py` | +| Phase C `c2-invariants` (added) | ARCHITECTURE invariants ↔ import-linter contracts | `tests/unit/test_arch_invariants_drift.py` | +| Phase C `c3-readme` (added) | README examples run | `tests/integration/readme/` | +| Phase C `c4-layer-readme` (added) | Layer README modules table ↔ filesystem | `tests/unit/test_layer_readme_drift.py` | + +## 8. Example output format + +```markdown +## D-001 §3.4 module map lists `windows.py`; file has been moved to `common/` + +- **Severity**: P1 (broken cross-reference) +- **Location**: `impl/python/ARCHITECTURE.md §3.4` +- **Finding**: `§3.4 Module map` lists `windows.py` under + `planning/`, but the file lives at + `src/osi/common/windows.py` (moved during F-9 cleanup). +- **Triage**: + 1. (Deterministic) — Phase C `c4-layer-readme` will surface this + class of drift automatically. Verify the test fails on a + manufactured mismatch; if not, sharpen the assertion. + 2. (Skill) — Add `§3.4 Module map ↔ filesystem` to §5.1. + 3. (Doc) — Update §3.4 to move `windows.py` to the `common/` row. + Lands in this PR. +``` + +## 9. Anti-patterns + +- A new "load-bearing" doc claim without a drift test. The claim will + drift; reviewers will then ignore the doc. +- A doc that explains "the code should do X" without a test that fails + when X is violated. Either turn X into an arch-test or remove the + claim. +- Examples in READMEs that are subtly out of date — wrong import + path, wrong CLI flag. Exercise them in CI or mark them illustrative. +- A renamed module without a redirect in the docs that pointed to it. +- A new tag family (`(R-NN)`, `(S-NN)`) in citations without a drift + test that verifies the citation resolves. + +## See also + +- [`spec-coherence-review/SKILL.md`](../spec-coherence-review/SKILL.md) — sister skill on the spec ↔ impl axis. +- [`../../impl/python/ARCHITECTURE.md`](../../impl/python/ARCHITECTURE.md) — the load-bearing architectural doc. +- [`../../impl/python/INFRA.md`](../../impl/python/INFRA.md) — the load-bearing infrastructure roadmap and decisions log. diff --git a/.agent-skills/interfaces-and-api-review/SKILL.md b/.agent-skills/interfaces-and-api-review/SKILL.md new file mode 100644 index 0000000..69ca7b0 --- /dev/null +++ b/.agent-skills/interfaces-and-api-review/SKILL.md @@ -0,0 +1,206 @@ +--- +name: interfaces-and-api-review +description: Review or design the public API surface and interfaces of the OSI reference implementation — the per-layer facades (osi.parsing, osi.planning, osi.codegen, osi.diagnostics, osi.errors), the CLI, and the contracts between sub-packages. Use when adding a public function, exposing a new type at a layer boundary, designing the shape of a new module's exports, or evaluating whether an API is small, total, and hard to misuse. +--- + +# Interfaces and API review + +A reference implementation lives or dies on the *shape* of its API. The +right interface makes incorrect use impossible. This skill is the +playbook for designing and reviewing public surfaces — what gets +exported from each `__init__.py`, what signatures look like, and where +the seams between sub-packages sit. + +## 1. Purpose + +Keep every public surface small, total, and *hard to misuse*. Each +layer facade exposes the minimum set of types and functions a caller +needs, with signatures that fail at type-check time when used wrong, +and with clear ownership of every error path. + +## 2. When to use it (Review) + +Apply this skill when the change: + +- Adds, removes, or renames a symbol in any layer `__init__.py` + (`osi.parsing`, `osi.planning`, `osi.codegen`, `osi.diagnostics`, + `osi.common`, `osi.errors`). +- Adds a new public function or class anywhere under `src/osi/`. +- Changes the signature of an existing public function — argument + types, defaults, return type, exception set. +- Adds a new CLI command or flag in `src/osi/cli.py`. +- Introduces a new sub-package or splits an existing one. + +## 3. When to use it (Design) + +Apply this skill *before* writing the implementation when you are: + +- Designing a new entry point users will call directly. +- Sketching how an internal helper becomes "public" (used by another + layer or surfaced to CLI / SDK consumers). +- Deciding what data lives on a `PlanStep`, a `CalculationState`, or a + diagnostic record. +- Choosing between "one big function with options" and "several small + functions with one job each." +- Deciding which exceptions a function may raise — and how callers + discriminate them. + +At design time the goal is: *write the signature first*. If you can +write a clean, type-checked signature, the implementation has a chance. +If you can't, the abstraction is wrong. + +## 4. Methodology + +1. **Enumerate the surface.** List every public symbol the change adds + or modifies. Public = re-exported from a layer `__init__.py`, named + in `ARCHITECTURE.md`, or referenced by an external test / example / + compliance adapter. +2. **Check the layer facade contract.** Each layer's `__init__.py` + re-exports a curated set; any new public symbol must be added to the + facade in the same PR (`ARCHITECTURE.md §6.8`). A public symbol that + is not re-exported is a documentation bug. +3. **Audit the signature.** Verify: + - All arguments are typed; no `Any`, no untyped `**kwargs`. + - Frozen-by-default inputs (`SemanticModel`, `PlannerContext`, + `QueryPlan`) come first, mutable / per-call inputs last. + - Return type is a concrete frozen value, not `tuple[Any, ...]` or + `dict[str, Object]`. + - Optional arguments use `Optional[T] = None`, not `T = object()` / + sentinel hacks. +4. **Audit the exceptions.** The function's docstring lists every + `ErrorCode` it raises. Each code is in the public Appendix C set or + in `_IMPLEMENTATION_EXTENSIONS`. No bare `Exception`, no `RuntimeError`. +5. **Audit name + place.** The function lives in the right sub-package + (`ARCHITECTURE.md §8` rules). The function name reflects its return + type and side effects (e.g. `parse_*` returns a parsed value, never + `None` on failure — it raises). +6. **Audit reachability.** Is the new public surface reachable through + the CLI? Through an example? Through at least one unit test? An + unused public symbol is dead weight; remove it or wire it up. + +## 5. Checklists + +### 5.1 Facade hygiene + +- [ ] Every new public symbol is re-exported in the relevant + `__init__.py`. +- [ ] `__init__.py` has no logic — only imports and `__all__`. +- [ ] No symbol is exposed at two layers (e.g. `Identifier` lives only + in `osi.common.identifiers`). +- [ ] No private symbol (`_underscore`) is imported across a layer + boundary. + +### 5.2 Signature shape + +- [ ] All arguments and return are typed. +- [ ] No `Any`, no `object`, no untyped `dict[str, Any]` in public API. +- [ ] Frozen dataclasses for any structured argument or return. +- [ ] `NewType`s used for identifiers (`Identifier`, `CTEName`, + `ExpressionId`). +- [ ] Enums for closed sets (`Dialect`, `ErrorCode`, `PlanOperation`). +- [ ] Mutable defaults are forbidden (`def f(x: list = [])` — never). + +### 5.3 Total functions + +- [ ] The function either returns its declared type or raises an + `OSIError`. It never returns `None`-meaning-failure. +- [ ] All exception paths are typed `OSIError` subclasses with codes. +- [ ] Docstring lists each raised `ErrorCode`. + +### 5.4 Discoverability + +- [ ] At least one unit test imports the symbol from the public facade. +- [ ] If the symbol is user-facing, it appears in + `impl/python/README.md` or an `examples/` script. +- [ ] If the symbol is in the CLI, `cli.py` documents the flag in its + help string and there's an integration test under + `tests/integration/cli/`. + +### 5.5 Hard-to-misuse construction + +- [ ] Construction goes through a typed entry point + (`parse_semantic_model`, `Planner.plan`, `compile_plan`, + `source(...)`, etc.). Direct dataclass construction is reserved + for inside the layer that owns the type. +- [ ] Cross-cutting types (`Identifier`, `Dialect`) are produced by + named factories (`normalize_identifier(s)`, + `Dialect.from_string(s)`), not by raw strings reaching the API. + +## 6. Triage rule: prefer deterministic enforcement + +Findings from this skill walk a strict hierarchy. Apply this rule +whether you're using the skill to review existing code or to design new +code: + +1. **Convert to a deterministic check** — drift test, arch-test, + import-linter contract, mypy rule, lint rule. Preferred. Never + regresses; applies to every future change automatically. +2. **Sharpen this skill's checklist** — if the finding revealed a + missing angle, update this `SKILL.md` so future runs catch it. +3. **Tighten documentation** — when the rule is true but not + mechanically checkable, update `ARCHITECTURE.md` / a README / + `INFRA.md` and add an example. +4. **Queue as a code-change sprint item** — last resort, for findings + that need real implementation work (refactors, new abstractions). + +The same rule, in its design-time framing: before writing code that +establishes a boundary or invariant, ask "can I add a deterministic +check that locks this in before the code lands?" If yes, write the +check in the same PR. + +## 7. Existing deterministic checks this skill should leverage + +| Check | What it enforces | Source | +|:--|:--|:--| +| `mypy --strict` | Typed signatures, no `Any` leakage at public surface | `pyproject.toml [tool.mypy]` | +| `import-linter` contracts | Layers see only their declared dependencies | `pyproject.toml [tool.importlinter]` | +| `tests/unit/test_appendix_c_drift.py` | Every public error code is in the spec or documented | source | +| `tests/properties/test_error_taxonomy.py` | Public surfaces raise only `OSIError` | source | +| `tests/unit/test_synthetic_naming_invariants.py` | Synthetic names come from `prefixes.py`, not the API | source | +| `tests/unit/test_common_identifiers.py` | Identifier construction goes through `normalize_identifier` | source | +| `flake8-docstrings` | Public symbols have docstrings | `pyproject.toml` | +| Phase C `c4-layer-readme` (added by this audit) | Layer READMEs list every public symbol | `tests/unit/test_layer_readme_drift.py` | + +## 8. Example output format + +```markdown +## I-001 `Planner.plan` returns a tuple instead of a typed QueryPlan + +- **Severity**: P1 (callers must destructure, easy to misuse) +- **Location**: `impl/python/src/osi/planning/planner.py:412` +- **Finding**: `Planner.plan` returns `tuple[QueryPlan, dict[str, Any]]` + where the second element is an "annotations" payload used only by + diagnostics. Callers that don't need annotations destructure and + discard, but the dict is `dict[str, Any]` — defeating the typed-API + intent. +- **Triage**: + 1. (Deterministic) — Add an arch-test that asserts no public function + returns a `dict[str, Any]`. Lands in this PR (small). + 2. (Skill) — Add `dict[str, Any] in return` to §5.2 checklist. + 3. (Code) — Define `PlanAnnotations` (frozen dataclass), change the + return to `tuple[QueryPlan, PlanAnnotations]`, or — better — + attach annotations to `PlanStep`. Out of scope for this PR; queue. +``` + +## 9. Anti-patterns + +- A new public function that returns `Optional[T]` to signal failure. + Callers won't check; raise an `OSIError` instead. +- An "options" dict (`options: dict[str, Any]`) on a public signature. + Make it a frozen dataclass with typed fields. +- A new symbol exposed at the layer's public facade but not added to + `__all__`. Stale facades drift; the symbol becomes "semi-public," + with no test guard. +- A public function that takes a raw `str` where an `Identifier` / + `Dialect` / `ErrorCode` is meant. Push the conversion into a factory + and accept the typed value. +- A second function with the same job ("`compile_plan_v2`, + `compile_fast`, `compile_safe`"). One canonical API per concept; + variants belong as flags on the canonical signature or as separate + named entry points with distinct purposes. + +## See also + +- [`../../impl/python/ARCHITECTURE.md`](../../impl/python/ARCHITECTURE.md) §1.1, §6.8, §9. +- [`code-encourages-correct-use-review/SKILL.md`](../code-encourages-correct-use-review/SKILL.md) — sister skill focused on hard-to-misuse construction. +- [`typing-enforcement-review/SKILL.md`](../typing-enforcement-review/SKILL.md) — sister skill focused on mypy strictness and `NewType` discipline. diff --git a/.agent-skills/run-osi-compliance/SKILL.md b/.agent-skills/run-osi-compliance/SKILL.md new file mode 100644 index 0000000..8b1668c --- /dev/null +++ b/.agent-skills/run-osi-compliance/SKILL.md @@ -0,0 +1,109 @@ +--- +name: run-osi-compliance +description: Run the OSI Foundation v0.1 compliance suite against the Python reference implementation and produce a readable Markdown report with per-decision coverage. Use when the user asks to run the compliance suite, validate OSI conformance, check D-NNN coverage, or assess Foundation conformance of impl/python. +--- + +# Run the OSI compliance suite + +Execute the compliance suite at `compliance/foundation-v0.1/` against the +Python reference implementation, then surface a readable coverage report +indexed by Conformance Decision (D-001 … D-033). + +## Instructions + +### 1. One-time setup (skip if `.venv` already has the harness) + +```bash +pip install -e compliance/harness +pip install -e compliance/foundation-v0.1 +pip install -e impl/python +``` + +This installs three editable packages: + +- `osi_compliance_harness` — the engine-agnostic runner / reporter. +- `osi_compliance_foundation_v0_1` — the per-version test suite metadata. +- `osi-python` — the reference Python implementation under test. + +### 2. Run the suite + +```bash +cd compliance/foundation-v0.1 +python -m harness.runner \ + --adapter adapters/osi_python_adapter.py \ + --tests tests/ \ + --datasets datasets/ +# per-run artifacts land in results/latest/ by default +``` + +To scope to a single area, point ``--output`` at a sibling directory so +the run doesn't clobber ``results/latest/``: + +```bash +python -m harness.runner \ + --adapter adapters/osi_python_adapter.py \ + --tests tests/bridge/ \ + --datasets datasets/ \ + --output results/bridge/ +``` + +The curated baseline at ``results/REPORT.md`` is committed and must +not be overwritten; pick a subdirectory of ``results/`` (or anywhere +else) for every per-run output. + +### 3. Read the report and triage + +After a full run, surface: + +1. **Overall pass rate** (e.g. "57 / 64 must-pass, 89.1%"). +2. **Per-decision coverage** from `results/latest/decisions_coverage.md` — + list every D-NNN with at least one failing test. +3. For each failing test, classify the failure: + - **impl bug** — the implementation is wrong vs the spec. Open a + ticket / fix using the `debug-planner-output` skill (carried separately in willtown) + skill if present, or directly inspect `impl/python/src/osi/`. + - **test bug** — the test asserts something the spec doesn't say. Fix + in `compliance/foundation-v0.1/tests///`. + - **spec ambiguity** — neither side is unambiguously correct; the + proposal text needs sharpening. Log the case for a proposal patch. + - **xfail** — the test is intentionally `xfail` in `decisions.yaml` + pending a future sprint; surface but do not flag as a regression. + +### 4. Reference: contract + +Every test under `tests////` has: + +| File | Purpose | +|:--|:--| +| `metadata.yaml` | `id: T-NNN`, `decision: D-NNN`, `required_features`, `expected_error_code`, `xfail_reason` (if any). | +| `model.yaml` | Semantic model — usually a thin wrapper around a fixture in `datasets/f_*`. | +| `query.json` | Semantic query in the two-shape format (Aggregation or Scalar). | +| `gold_rows.json` | Expected row set for positive tests. | +| `gold.sql` | Reference SQL (illustrative only; tests assert on rows or `error.code`, never on SQL string per D-014). | + +Tests assert on observable behaviour only: + +- `expected_error_code: E_` → adapter must surface that code in stderr. +- `gold_rows.json` → adapter SQL execution against the fixture data + returns this exact multiset (order-insensitive unless `Order By` is set). + +### 5. Do NOT + +- Do not assert on the planner's generated SQL string. Cross-engine SQL + determinism is **not** required (D-014 is per-engine only). +- Do not flip a `must_pass` decision to `xfail` to make CI green — that + must go through `decisions.yaml` review with an `xfail_pinned_to` + sprint anchor. +- Do not "fix" a test by changing its `expected_error_code` to whatever + the implementation emits; first decide which side matches the spec. + +## See also + +- [`compliance/foundation-v0.1/README.md`](../../compliance/foundation-v0.1/README.md) — + suite layout and quick start. +- [`compliance/foundation-v0.1/SPEC.md`](../../compliance/foundation-v0.1/SPEC.md) — + what this suite targets and what it does NOT cover. +- [`compliance/ADAPTER_INTERFACE.md`](../../compliance/ADAPTER_INTERFACE.md) — + the CLI contract adapters implement. +- [`proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) + Appendix B (decisions) and Appendix C (error codes). diff --git a/.agent-skills/run-osi-python-tests/SKILL.md b/.agent-skills/run-osi-python-tests/SKILL.md new file mode 100644 index 0000000..1431c3d --- /dev/null +++ b/.agent-skills/run-osi-python-tests/SKILL.md @@ -0,0 +1,84 @@ +--- +name: run-osi-python-tests +description: Run the full test pyramid for the OSI Python reference implementation (impl/python) including unit, property, golden, e2e, adapter-smoke, lint, typecheck, architecture, coverage, and mutation testing; then surface the consolidated readable report. Use when the user asks to run impl/python tests, validate the OSI Python implementation, generate a test report, run mutation tests, or check pre-PR readiness for impl/python. +--- + +# Run OSI Python tests + +Run every test category for `impl/python/` and surface a single readable +Markdown report. Mutation testing is included by default in fast mode (~5 min). + +## Instructions + +### 1. Confirm scope (one quick question, only if not specified) + +Before running, decide between: + +- **Pre-PR fast check** (default): `--with-mutation-fast` (~5–10 min total) +- **Full pre-merge check**: `--with-mutation` (full mutation ~30 min) +- **Iteration check**: `--skip-static` (only test categories) + +If the user didn't say, default to **pre-PR fast check** and tell them you're +doing so. + +### 2. Run + +```bash +cd impl/python +scripts/run_all_tests.sh --with-mutation-fast +``` + +Other variants: + +```bash +scripts/run_all_tests.sh # static + tests, no mutation +scripts/run_all_tests.sh --with-mutation # full mutation (~30 min) +scripts/run_all_tests.sh --skip-static # only test categories +``` + +The script: + +- Never aborts on the first failure — every stage runs. +- Captures structured raw output under `test-results/raw/`. +- Writes the consolidated report at `test-results/REPORT.md`. +- Exits non-zero if any stage failed. + +### 3. Read the report and report findings + +Read `impl/python/test-results/REPORT.md`. Surface to the user: + +1. **Overall** PASS / FAIL banner. +2. **Stage summary** (one line per stage) — note any FAIL stages and link the + raw log path `test-results/raw/.log`. +3. **Test counts** — total tests run, failures, errors, skipped. +4. **Coverage** — line %, branch %. +5. **Mutation** — score %, surviving mutants. **A surviving mutation in + `src/osi/planning/algebra/` is a P0** (INFRA.md §1.1). +6. **Failing tests** — list each with its category. For each failing test, + read its raw log under `test-results/raw/` and quote the relevant + pytest failure block. +7. **Slowest 10 tests** if any are unexpectedly slow (>5 s). + +If overall PASS, end with a short "ready to commit / PR" sentence. + +If anything failed, end with a numbered remediation list pointing to the +log lines that drove each finding. + +### 4. Do NOT + +- Do not re-run a single failing test before reading the report — surface + everything from the first pass. +- Do not modify code to make a test pass without explicit user instruction. +- Do not refresh golden snapshots automatically. `make golden-refresh` is + an explicit user action; the report writer will flag golden failures. +- Do not skip mutation testing silently. If the user said "run the tests", + mutation-fast is part of the default contract this skill exposes. + +## See also + +- [`impl/python/RUNNING_TESTS.md`](../../impl/python/RUNNING_TESTS.md) — + the longer human-facing guide; the script + this skill are the agent path. +- [`impl/python/Makefile`](../../impl/python/Makefile) — every category + exists as a `make` target if you need to run one in isolation. +- [`impl/python/INFRA.md`](../../impl/python/INFRA.md) — quality + thresholds the report compares against. diff --git a/.agent-skills/spec-coherence-review/SKILL.md b/.agent-skills/spec-coherence-review/SKILL.md new file mode 100644 index 0000000..56b3226 --- /dev/null +++ b/.agent-skills/spec-coherence-review/SKILL.md @@ -0,0 +1,208 @@ +--- +name: spec-coherence-review +description: Review or design changes so the OSI Foundation spec, the Python reference implementation, and the compliance suite stay coherent. Covers the spec ↔ Appendix C ↔ ErrorCode axis, the proposals.yaml registry, the deferred-features gate, decisions.yaml ↔ tests, and the spec-section-refs citations. Use when adding a feature, an error code, a test, a decision, or any change that needs the three artifacts (spec, code, tests) to move together. +--- + +# Spec coherence review + +A reference implementation is only useful if the spec, the impl, and +the compliance tests are *coherent*. This skill is the playbook for +keeping the three artifacts (spec, code, tests) in lockstep — and for +designing new features so the coherence is enforced by drift tests +rather than by review vigilance. + +## 1. Purpose + +Ensure every change that touches semantic behaviour touches all three +artifacts (spec, code, tests) in the same PR, with citations that +resolve and drift tests that fail if any side moves alone. + +## 2. When to use it (Review) + +Apply when the change: + +- Adds, modifies, or removes a section in + `proposals/foundation-v0.1/Proposed_OSI_Semantics.md`. +- Adds or modifies a `D-NNN` row in Appendix B. +- Adds or modifies an `E_*` / `E_NNNN` row in Appendix C. +- Adds, modifies, or removes a member of `osi.errors.ErrorCode`. +- Adds or modifies a row in + `compliance/foundation-v0.1/decisions.yaml` or + `compliance/foundation-v0.1/proposals.yaml`. +- Adds, modifies, or `xfail`s a test under + `compliance/foundation-v0.1/tests/`. +- Promotes a deferred feature from + `proposals/foundation-v0.1/Proposed_OSI_Semantics.md` §10 into the + Foundation proper. +- Adds a citation like `(Spec: §X.Y)` or `(D-NNN)` anywhere in the + repo. + +## 3. When to use it (Design) + +Apply *before* writing code when you are: + +- Drafting a new Foundation feature (it starts as a spec section). +- Drafting a new compliance test for a behaviour the planner already + implements (the test pins behaviour the spec should already mandate). +- Promoting a deferred feature — design which Stage-1..Stage-5 PRs + will land which artifacts together + (`impl/python/CONTRIBUTING.md §8`). +- Adding a new `D-NNN` decision (needs a code path that raises the + decided error, plus a test that pins it). +- Adding a new error code that the planner can emit. + +At design time, the goal is: *which artifact moves first, and which +drift test catches the others being out of sync?* + +## 4. Methodology + +1. **Find the home for the change.** Identify which of the three + artifacts owns the canonical statement: spec (semantics), code + (mechanism), or test (behavioural pin). All three must agree + eventually; the order of the PRs depends on the type of change. +2. **Audit Appendix C.** Every `ErrorCode` enum value with prefix `E_` + is either in `_APPENDIX_C_CODES` (in + `tests/unit/test_appendix_c_drift.py`) or in + `_IMPLEMENTATION_EXTENSIONS` with a one-line rationale. +3. **Audit `decisions.yaml`.** Every `D-NNN` row points at a test that + exists, has the right `status`, and has the right `must_pass` flag. +4. **Audit `proposals.yaml`.** Every deferred feature that has a + compliance test referencing it is registered with the right + `status` (`proposed` / `foundation`). +5. **Audit citations.** `(Spec: §X.Y)` in code resolves to a heading + in `Proposed_OSI_Semantics.md`. `(D-NNN)` resolves to a row in + Appendix B. `(E_*)` resolves to an `ErrorCode` member. +6. **Audit the deferred-features gate.** Any deferred YAML key is + rejected at parse time with `E_DEFERRED_KEY_REJECTED` or `E1105`. + No deferred feature has partial plumbing in + `planning/` or `codegen/`. + +## 5. Checklists + +### 5.1 Spec ↔ code + +- [ ] Every new `ErrorCode` is in Appendix C or in + `_IMPLEMENTATION_EXTENSIONS`. +- [ ] Every Appendix C row resolves to an enum value. +- [ ] Every `(Spec: §X.Y)` citation in source code resolves to a + heading in the spec (Phase C `c1-specrefs`). +- [ ] Every deferred feature has an `E_DEFERRED_KEY_REJECTED` or + `E1105` raise path; no partial plumbing. + +### 5.2 Spec ↔ tests + +- [ ] Every `D-NNN` decision in `decisions.yaml` has at least one test + with `decision: D-NNN` in metadata. +- [ ] Every Appendix C row has at least one test that pins the error + code (positive: the spec says when it should fire; negative: + adapters that don't implement the rule produce it correctly). +- [ ] Every `proposals.yaml` entry has at least one test in + `tests///` with `required_features: + []`. + +### 5.3 Code ↔ tests + +- [ ] Every new code path that can raise an error has a unit test or + compliance test pinning the code. +- [ ] No `xfail` without an `xfail_reason` referencing a sprint or a + `D-NNN` decision. +- [ ] No `xfail` for a `must_pass` decision (use + `decisions.yaml` `must_pass: false` or a different status). + +### 5.4 Deferred-feature gate + +- [ ] Every deferred YAML key (`filter.expression`, `reset`, `joins.using_relationships`, …) + raises `E_DEFERRED_KEY_REJECTED` or `E1105` at parse time. +- [ ] No planner / codegen module imports a "future-only" model field. +- [ ] Any feature flag for an experimental capability + (e.g. `experimental_exists_in`) flips a parse-time rejection, + not a runtime branch deep in the planner. + +### 5.5 Citation conventions + +- [ ] `(Spec: §X.Y)` ↔ `Proposed_OSI_Semantics.md` heading. +- [ ] `(D-NNN)` ↔ Appendix B row. +- [ ] `(E_*)` / `(E1NNN)` ↔ `ErrorCode` enum member. +- [ ] `(T-NNN)` ↔ test under `compliance/foundation-v0.1/tests/`. + +## 6. Triage rule: prefer deterministic enforcement + +Findings from this skill walk a strict hierarchy. Apply this rule +whether you're using the skill to review existing code or to design new +code: + +1. **Convert to a deterministic check** — drift test (Appendix C vs + enum, decisions.yaml vs tests, spec-section-refs, proposals.yaml + vs `required_features`). Preferred. Never regresses; applies to + every future change automatically. +2. **Sharpen this skill's checklist** — if the finding revealed a + missing angle, update this `SKILL.md` so future runs catch it. +3. **Tighten documentation** — when the rule is true but not + mechanically checkable, update `ARCHITECTURE.md` / `CONTRIBUTING.md + §8` / `INFRA.md` and add an example. +4. **Queue as a code-change sprint item** — last resort, for findings + that need real implementation work (refactors, new abstractions). + +The same rule, in its design-time framing: before writing a new spec +section, ask "what drift test will keep this in sync with the code and +tests?" If the answer is "none," design one before writing the spec. + +## 7. Existing deterministic checks this skill should leverage + +| Check | What it enforces | Source | +|:--|:--|:--| +| `tests/unit/test_appendix_c_drift.py` | Appendix C ↔ `ErrorCode` | source | +| `compliance/harness/proposals_check.py` (referenced in CONTRIBUTING §8 Stage 2) | `proposals.yaml` ↔ test metadata | source | +| `compliance/foundation-v0.1/tests/.../metadata.yaml` `decision: D-NNN` | Decision ↔ test | source | +| Phase C `c1-specrefs` (added) | `(Spec: §X.Y)` citations resolve | `tests/unit/test_spec_section_refs_drift.py` | +| Phase C `c2-invariants` (added) | ARCHITECTURE invariants ↔ enforcement | `tests/unit/test_arch_invariants_drift.py` | +| `test_registry_yaml.py` (already present) | `decisions.yaml` paths match filesystem | source | + +## 8. Example output format + +```markdown +## SC-001 New `E_MIXED_AGGREGATION_LEVEL` is in the enum but not in Appendix C + +- **Severity**: P0 (drift test would fail; spec ↔ code out of sync) +- **Location**: + - `impl/python/src/osi/errors.py:E_MIXED_AGGREGATION_LEVEL` + - `proposals/foundation-v0.1/Proposed_OSI_Semantics.md` Appendix C + (missing row) +- **Finding**: A new code was added to handle a planner-level case + but the corresponding Appendix C row and the + `_APPENDIX_C_CODES` entry are missing. +- **Triage**: + 1. (Deterministic) — `test_appendix_c_drift.py` would fail this + change; confirm it does. If the new code is an implementation + extension (not Foundation-level), document it in + `_IMPLEMENTATION_EXTENSIONS`. If it's spec-level, add the + Appendix C row. + 2. (Skill) — Add an explicit "new ErrorCode" bullet to §5.1 (done). + 3. (Spec) — Decide whether this is a Foundation rule or an + extension. If Foundation, the spec PR lands first; the code PR + references it. +``` + +## 9. Anti-patterns + +- A new `ErrorCode` without a corresponding Appendix C row *and* + without a `_IMPLEMENTATION_EXTENSIONS` entry. Drift test catches it, + but the design choice should be made up-front. +- An `xfail` for a `must_pass` decision. Adjusts the test to make CI + green; hides spec ↔ impl drift. +- A `(D-NNN)` citation that points at a missing or renamed decision + row. Use the citation-resolves drift test. +- A deferred-feature spec section with partial plumbing in the + planner. Plumbing for deferred features is forbidden; either the + feature is rejected at parse time or the spec has moved. +- A new `proposals.yaml` entry without a corresponding test. The + proposal-check CI would fail; but more importantly, an unreferenced + proposal has no behavioural pin. + +## See also + +- [`../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) Appendix B (decisions) + Appendix C (error codes). +- [`../../impl/python/CONTRIBUTING.md`](../../impl/python/CONTRIBUTING.md) §8 — proposal ratification lifecycle. +- [`../../compliance/foundation-v0.1/decisions.yaml`](../../compliance/foundation-v0.1/decisions.yaml) — decision registry. +- [`../../compliance/foundation-v0.1/proposals.yaml`](../../compliance/foundation-v0.1/proposals.yaml) — deferred-feature registry. +- [`doc-as-enforcement-review/SKILL.md`](../doc-as-enforcement-review/SKILL.md) — sister skill on the broader doc ↔ code axis. diff --git a/.agent-skills/typing-enforcement-review/SKILL.md b/.agent-skills/typing-enforcement-review/SKILL.md new file mode 100644 index 0000000..8824728 --- /dev/null +++ b/.agent-skills/typing-enforcement-review/SKILL.md @@ -0,0 +1,202 @@ +--- +name: typing-enforcement-review +description: Review or design code so the type system carries as much of the architectural and BI/SQL contract as possible — frozen dataclasses, NewTypes for identifiers and codes, enums for closed sets, Protocols for cross-package contracts, no Any or dict[str, Any] at public boundaries, strict mypy settings. Use when adding new types, signatures, or any code that mypy will see; pairs with code-encourages-correct-use-review for the design-time aspect of "make illegal states unrepresentable." +--- + +# Typing enforcement review + +The strongest deterministic check is the one mypy refuses to type-check. +This skill is the playbook for both reviewing typed code and designing +new types so the type system carries the architectural and BI/SQL +contract — `Identifier` for identifiers, `ErrorCode` for codes, +`Dialect` for dialects, `Protocol` for cross-package interfaces, and +`frozen=True` for everything that travels. + +## 1. Purpose + +Move as much of the architectural and BI/SQL contract as possible into +the type system. Every `Any` is a future bug; every `dict[str, Any]` +on a public boundary is a future bug; every raw `str` parameter that +should have been an `Identifier` / `Dialect` / `ErrorCode` is a future +bug. This skill finds them and converts them. + +## 2. When to use it (Review) + +Apply when the change: + +- Adds, modifies, or removes a public type annotation. +- Introduces `Any`, `object`, `dict[str, Any]`, `list[Any]`, or + `tuple[Any, ...]` anywhere in `src/`. +- Accepts a raw `str` for an identifier, dialect, error code, CTE name, + or grain set. +- Adds a `# type: ignore[...]` comment. +- Adds a `cast(...)` call. +- Modifies `pyproject.toml [tool.mypy]` or adds a new mypy override. +- Adds a `Protocol`, `TypedDict`, or `NewType`. +- Touches `osi.common.identifiers`, `osi.common.types`, or any module + that defines a cross-layer type. + +## 3. When to use it (Design) + +Apply *before* writing code when you are: + +- Designing a new frozen dataclass that will travel between layers. +- Choosing between `str`, `NewType`, `Enum`, and `Literal[...]`. +- Designing a "value object" — an `Identifier`-shaped wrapper for a + domain concept (`CTEName`, `ExpressionId`, `SourceLocation`). +- Considering a `dict[str, Any]` for a payload — almost always wrong; + prefer `TypedDict` or a frozen dataclass. +- Defining a cross-package interface (e.g. an adapter contract) — + prefer `Protocol`. +- Deciding mypy strictness for a new module. + +## 4. Methodology + +1. **Survey `Any` usage.** `rg "\bAny\b" src/` — every occurrence + either has a justification next to it or is a bug. Most "framework" + uses (sqlglot AST traversal, pydantic model dumps) belong inside a + narrow shim, not on the public boundary. +2. **Survey raw `str` usage at boundaries.** Every public function + that takes `str` for an identifier should take `Identifier`; for a + code should take `ErrorCode`; for a dialect should take `Dialect`. +3. **Survey frozen-ness.** Every dataclass that travels between + layers is `frozen=True`. `eq=True` is implied; `slots=True` is + recommended. +4. **Survey `dict[str, Any]`.** A `dict[str, Any]` on a public + boundary is a `TypedDict` or frozen dataclass waiting to happen. +5. **Survey ignores and casts.** Every `# type: ignore` and `cast(...)` + is reviewed for necessity. Many can be removed by tightening the + surrounding type; the ones that remain need a one-line comment. +6. **Survey mypy strictness.** New modules join `--strict`. New + overrides in `pyproject.toml [tool.mypy.overrides]` need a reason + (third-party stubs missing, test module convention, etc.). + +## 5. Checklists + +### 5.1 No untyped `Any` at boundaries + +- [ ] No `Any` in a public function signature. +- [ ] No `Any` in a layer facade's re-exported type. +- [ ] No `Any` as the value type in a top-level `dict` exposed via + public API. +- [ ] Internal `Any` is justified by a one-line comment (typically a + sqlglot AST shim). + +### 5.2 `NewType` discipline + +- [ ] Identifiers travel as `Identifier`, produced by + `normalize_identifier(s)`. +- [ ] CTE names travel as `CTEName`, produced by `prefixes.py`. +- [ ] Expression IDs travel as `ExpressionId`. +- [ ] Source locations travel as `SourceLocation`. +- [ ] If a new domain concept needs typed string-ish-ness, add a + `NewType` in `osi.common.types` and a factory. + +### 5.3 Enum discipline + +- [ ] Closed sets are enums (`Dialect`, `PlanOperation`, `ErrorCode`, + `JoinType`, `MetricShape`). +- [ ] Enum imports are by `Enum.MEMBER`, not by `.value` string. +- [ ] Exhaustive pattern matches on enums end with + `case _: raise OSIError(ErrorCode.E_INTERNAL_INVARIANT, ...)`. + +### 5.4 Frozen-by-default + +- [ ] Every IR type (`CalculationState`, `Column`, `PlanStep`, + `QueryPlan`, `PlanPayload` subclasses) has `frozen=True`. +- [ ] Every public model type (`SemanticModel`, `Dataset`, `Metric`, + `Relationship`) is a frozen pydantic model or frozen dataclass. +- [ ] No `field(default_factory=list)` on a frozen IR type unless the + list itself is intended to be re-bound (rare). + +### 5.5 Protocols and TypedDicts + +- [ ] Cross-package interfaces are `Protocol`s, not abstract base + classes. +- [ ] Structured payloads exposed across packages are `TypedDict`s or + frozen dataclasses. +- [ ] `dict[str, Any]` does not appear on any cross-package surface. + +### 5.6 mypy hygiene + +- [ ] No new `# type: ignore` without a comment explaining why. +- [ ] No new module excluded from strict mode without a roadmap entry. +- [ ] `disallow_any_generics`, `disallow_untyped_defs`, + `warn_return_any` stay enabled. + +## 6. Triage rule: prefer deterministic enforcement + +Findings from this skill walk a strict hierarchy. Apply this rule +whether you're using the skill to review existing code or to design new +code: + +1. **Convert to a deterministic check** — a stricter mypy setting, a + new arch-test that bans `Any` in certain modules, a lint rule. + Preferred. Never regresses; applies to every future change + automatically. +2. **Sharpen this skill's checklist** — if the finding revealed a + missing angle, update this `SKILL.md` so future runs catch it. +3. **Tighten documentation** — when the rule is true but not + mechanically checkable, update `ARCHITECTURE.md` / `INFRA.md` and + add an example. +4. **Queue as a code-change sprint item** — last resort, for findings + that need real implementation work (refactors, new abstractions). + +The same rule, in its design-time framing: before writing code that +establishes a boundary or invariant, ask "can I add a deterministic +check that locks this in before the code lands?" If yes, write the +check in the same PR. + +## 7. Existing deterministic checks this skill should leverage + +| Check | What it enforces | Source | +|:--|:--|:--| +| `mypy --strict` | Most of the rules in §5 by construction | `pyproject.toml [tool.mypy]` | +| `mypy [warn_unreachable]` | Dead code in conditionals | `pyproject.toml` | +| `mypy [warn_redundant_casts]` | `cast(...)` is necessary | `pyproject.toml` | +| `mypy [disallow_any_generics]` | No bare `list` / `dict` / `tuple` | `pyproject.toml` | +| `mypy [disallow_untyped_defs]` | All defs in `src/` are typed | `pyproject.toml` | +| `mypy [strict_equality]` | No `int == str`-style silent comparisons | `pyproject.toml` | +| `tests/unit/test_common_identifiers.py` | Identifier construction goes through `normalize_identifier` | source | +| `tests/unit/test_operator_enum_sync.py` | Enum-driven dispatch is exhaustive | source | +| Phase D `d1-osierror-arch` (added) | Every `Exception` subclass in `osi.*` is `OSIError` | `tests/unit/test_every_exception_is_osierror.py` | + +## 8. Example output format + +```markdown +## T-001 `Planner.plan` takes a raw `str` dialect + +- **Severity**: P1 (typo at user boundary becomes silent fallback) +- **Location**: `impl/python/src/osi/planning/planner.py:412` +- **Finding**: `Planner.plan(query, dialect: str = "ansi")` accepts + any string. A typo (`"snowfalke"`) currently silently falls back to + ANSI. +- **Triage**: + 1. (Deterministic) — Replace `str` with `Dialect = Dialect.ANSI`. + mypy now rejects every typo at the call site. Lands in this PR. + 2. (Skill) — Add `Planner.plan` to §5.2 example list. + 3. (Code) — Migrate downstream config-driven call sites; queue. +``` + +## 9. Anti-patterns + +- A new `Any` to "make this generic." Generic the right way (`T` + bound to a `Protocol`, or `TypeVar` with concrete uses) keeps the + type information. +- `dict[str, Any]` as a payload type for a new feature. Either it's a + `TypedDict` (when the shape is open-ended) or a frozen dataclass + (when the shape is fixed); never `Any`. +- A `str` parameter for an identifier "to keep the API ergonomic." The + factory function (`normalize_identifier`) keeps it ergonomic; the + type makes it correct. +- A `cast(...)` to satisfy mypy without justifying why the cast is + safe. Every cast is a TODO; either remove it or comment it. +- A new module excluded from `--strict` "for now." Strict-from-day-one + is the policy (`INFRA.md [I-DEC-7]`). + +## See also + +- [`../../impl/python/INFRA.md`](../../impl/python/INFRA.md) [I-DEC-7] — strict mypy from day one. +- [`../../impl/python/ARCHITECTURE.md`](../../impl/python/ARCHITECTURE.md) §6.11 — identifier safety. +- [`code-encourages-correct-use-review/SKILL.md`](../code-encourages-correct-use-review/SKILL.md) — design-time sister skill on "make illegal states unrepresentable." +- [`interfaces-and-api-review/SKILL.md`](../interfaces-and-api-review/SKILL.md) — sister skill on facade hygiene and signature shape. diff --git a/.claude/skills/run-osi-compliance b/.claude/skills/run-osi-compliance new file mode 120000 index 0000000..4f87516 --- /dev/null +++ b/.claude/skills/run-osi-compliance @@ -0,0 +1 @@ +../../.agent-skills/run-osi-compliance \ No newline at end of file diff --git a/.claude/skills/run-osi-python-tests b/.claude/skills/run-osi-python-tests new file mode 120000 index 0000000..13c3c9a --- /dev/null +++ b/.claude/skills/run-osi-python-tests @@ -0,0 +1 @@ +../../.agent-skills/run-osi-python-tests \ No newline at end of file diff --git a/.cursor/skills/run-osi-compliance b/.cursor/skills/run-osi-compliance new file mode 120000 index 0000000..4f87516 --- /dev/null +++ b/.cursor/skills/run-osi-compliance @@ -0,0 +1 @@ +../../.agent-skills/run-osi-compliance \ No newline at end of file diff --git a/.cursor/skills/run-osi-python-tests b/.cursor/skills/run-osi-python-tests new file mode 120000 index 0000000..13c3c9a --- /dev/null +++ b/.cursor/skills/run-osi-python-tests @@ -0,0 +1 @@ +../../.agent-skills/run-osi-python-tests \ No newline at end of file diff --git a/.github/workflows/impl-python-ci.yml b/.github/workflows/impl-python-ci.yml new file mode 100644 index 0000000..bda0878 --- /dev/null +++ b/.github/workflows/impl-python-ci.yml @@ -0,0 +1,83 @@ +name: impl/python CI + +on: + push: + branches: [main] + paths: + - 'impl/python/**' + - 'compliance/**' + - '.github/workflows/impl-python-ci.yml' + pull_request: + paths: + - 'impl/python/**' + - 'compliance/**' + - '.github/workflows/impl-python-ci.yml' + +defaults: + run: + working-directory: impl/python + +jobs: + check: + name: lint + typecheck + architecture + tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install dev dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Lint + run: make lint + + - name: Typecheck (mypy strict) + run: make typecheck + + - name: Architecture (import-linter one-way flow) + run: make architecture + + - name: Tests (unit + property + golden + e2e) + run: make test + + - name: Upload coverage HTML + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-html + path: impl/python/htmlcov/ + + mutation-algebra: + name: mutation testing (algebra fast-path, INFRA.md §1.1.1) + runs-on: ubuntu-latest + needs: check + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install dev dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Run mutation testing on algebra module + run: make mutation-fast + + - name: Upload mutation report + if: always() + uses: actions/upload-artifact@v4 + with: + name: mutmut-cache + path: impl/python/.mutmut-cache/ diff --git a/.gitignore b/.gitignore index c18dd8d..8e76e10 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,91 @@ +# Single repo-wide ignore file. +# +# Per-subtree ignores only exist where the rule is genuinely local: +# compliance/foundation-v0.1/.gitignore — keeps results/* but tracks REPORT.md + +# --------------------------------------------------------------------------- +# Python +# --------------------------------------------------------------------------- __pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +*.egg +build/ +dist/ + +# --------------------------------------------------------------------------- +# Virtual environments +# --------------------------------------------------------------------------- +.venv/ +venv/ +env/ + +# --------------------------------------------------------------------------- +# Testing / type-check / coverage caches +# --------------------------------------------------------------------------- +.coverage +.coverage.* +coverage.json +coverage.xml +*.cover +htmlcov/ +.pytest_cache/ +.hypothesis/ +.benchmarks/ +.mutmut-cache/ +.mypy_cache/ +.ruff_cache/ +.import_linter_cache/ +.tox/ + +# --------------------------------------------------------------------------- +# Test report artifacts (impl/python/scripts/run_all_tests.sh, etc.) +# --------------------------------------------------------------------------- +test-results/ + +# --------------------------------------------------------------------------- +# Editor / IDE / OS +# --------------------------------------------------------------------------- +.idea/ +.vscode/ +.cursor/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# --------------------------------------------------------------------------- +# IDE-generated agent skill mirrors (auto-synced from .agent-skills/) +# --------------------------------------------------------------------------- +.claude/ + +# --------------------------------------------------------------------------- +# Review artifacts (local only — not committed) +# --------------------------------------------------------------------------- +.review/ + +# --------------------------------------------------------------------------- +# Streamlit secrets (never commit; commit only *.toml.example templates) +# --------------------------------------------------------------------------- +.streamlit/secrets.toml +**/secrets.toml + +# --------------------------------------------------------------------------- +# Local test fixtures (DuckDB / SQLite scratch DBs) +# --------------------------------------------------------------------------- +*.db +*.sqlite +*.sqlite3 + +# --------------------------------------------------------------------------- +# Logs and scratch +# --------------------------------------------------------------------------- +*.log +*.tmp +*.bak +logs/ +.temp_scripts/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..543138e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,214 @@ +# Contributing to OSI + +Thanks for your interest in the Open Semantic Interchange (OSI). This +repository ships three artefacts that move together: + +- **Proposal docs** under [`proposals/foundation-v0.1/`](proposals/foundation-v0.1/) — + the normative spec for Foundation Tier v0.1. +- **Python reference implementation** under [`impl/python/`](impl/python/) — + the parser, planner, and codegen that compile a semantic model to + SQL. +- **Compliance suite** under [`compliance/foundation-v0.1/`](compliance/foundation-v0.1/) — + engine-agnostic tests that verify any implementation matches the + spec. + +A change to one almost always implies a change to the others. + +For implementation-specific guidance (setup, commands, PR checklist, +proposal lifecycle), see +[`impl/python/CONTRIBUTING.md`](impl/python/CONTRIBUTING.md). This +top-level file covers the contribution rules that apply across all +three artefacts. + +--- + +## 1. The triage rule (load-bearing) + +Every finding — whether it comes from a reviewer skill, a failing +test, a `make check` warning, a user bug report, or a design review — +walks this hierarchy, top-down: + +1. **Convert to a deterministic check** — drift test, arch-test, + `import-linter` contract, mypy rule, lint rule. Preferred. Never + regresses; applies to every future change automatically. +2. **Sharpen the relevant skill's checklist** — if the finding revealed + an angle a reviewer skill missed, update the `SKILL.md` so future + runs catch it. +3. **Tighten documentation** — when the rule is true but not + mechanically checkable, update `ARCHITECTURE.md` / a README / + `INFRA.md` and add an example. +4. **Queue as a code-change sprint item** — last resort, for findings + that need real implementation work (refactors, new abstractions). + +The same rule, in its **design-time framing**: before writing code +that establishes a new boundary or invariant, ask "can I add a +deterministic check that locks this in before the code lands?" If +yes, write the check in the same PR. + +This rule is not aspirational. It is the source of truth — every +reviewer skill copies it verbatim, every PR template asks the +contributor to apply it, and every audit closes the loop by +converting findings into deterministic checks rather than into +review reports. + +--- + +## 2. The cadence rule + +Reviews and designs use [reviewer skills](.agent-skills/REVIEWER_SKILLS.md) +to make sure we are looking for the right things at every change. + +### Mandatory for any architectural change + +Any change that touches behaviour, the planner, codegen, dialect +emission, or the algebra **must** run all three of these skills, both +at design time (to lock in boundaries up front) and at review time (to +verify them): + +- [`bi-best-practices-review`](.agent-skills/bi-best-practices-review/SKILL.md) — + grain awareness, fan-out / chasm trap, bridge dedup, conformed + dimensions, semi-additive measures, holistic-over-fan-out rejection. +- [`compiler-best-practices-review`](.agent-skills/compiler-best-practices-review/SKILL.md) — + phase boundaries, IR purity, totality, deterministic codegen, error + taxonomy, pass ordering. +- [`database-best-practices-review`](.agent-skills/database-best-practices-review/SKILL.md) — + SQL emission via AST not strings, identifier quoting, NULL ordering, + multiset vs set semantics, dialect adapter isolation, FrozenSQL + discipline. + +### Run as relevant to the change + +| If the change touches… | Run these skills | +|:---|:---| +| Layer boundaries, new modules, new IR types | [`architectural-review`](.agent-skills/architectural-review/SKILL.md) | +| Public APIs, layer facades, the CLI | [`interfaces-and-api-review`](.agent-skills/interfaces-and-api-review/SKILL.md) + [`code-encourages-correct-use-review`](.agent-skills/code-encourages-correct-use-review/SKILL.md) | +| Types, new dataclasses, mypy configuration | [`typing-enforcement-review`](.agent-skills/typing-enforcement-review/SKILL.md) | +| Spec sections, error codes, decisions, compliance tests | [`spec-coherence-review`](.agent-skills/spec-coherence-review/SKILL.md) | +| Docs that codify a rule (ARCHITECTURE / INFRA / READMEs) | [`doc-as-enforcement-review`](.agent-skills/doc-as-enforcement-review/SKILL.md) | + +See [`.agent-skills/REVIEWER_SKILLS.md`](.agent-skills/REVIEWER_SKILLS.md) +for the full index, the deterministic checks each skill leverages, and +the recommended sweep order when running them as a set. + +--- + +## 3. The design-time rule + +Before writing code that establishes a new boundary or invariant: + +1. **Consult the relevant skill's "Design use" section** for the + patterns the skill cares about. Every skill has a + `## 3. When to use it (Design)` block — it's the design-time + counterpart to the review checklist. +2. **Identify the deterministic check that would catch a violation.** + Drift test? Arch-test? Import-linter contract? mypy rule? +3. **If the check exists, cite it in the PR description.** If it + doesn't and the invariant is mechanically checkable, **write the + check in the same PR as the code that establishes the boundary**. +4. Don't defer the check to "a future review pass." It never lands; + the boundary becomes a wish. + +The "Existing deterministic checks this skill should leverage" table +inside each `SKILL.md` is the starting catalogue — pick the entries +that apply, and add new rows when you land new checks. + +--- + +## 4. Pull-request checklist (top-level) + +Use this in addition to the implementation-specific checklist in +[`impl/python/CONTRIBUTING.md §4`](impl/python/CONTRIBUTING.md). PRs +that touch only the compliance suite or the proposals docs can skip +the impl-specific items. + +``` +## Summary +<1-2 sentences — what changes and why> + +## Artefacts touched +- [ ] proposals/foundation-v0.1/ +- [ ] impl/python/ +- [ ] compliance/foundation-v0.1/ + +## Skills consulted (cadence rule §2) +For any architectural change, all three are required: +- [ ] bi-best-practices-review +- [ ] compiler-best-practices-review +- [ ] database-best-practices-review +Plus any others relevant to the change. + +## Triage applied (rule §1) +For each finding the skills surfaced, state which level applied: +- [ ] Deterministic check added (cite test / contract / mypy rule) +- [ ] Skill checklist sharpened (cite SKILL.md section) +- [ ] Documentation tightened (cite file / section) +- [ ] Queued as follow-up (cite issue / sprint item) + +## Coherence (artefacts move together) +- [ ] If a spec section changed, the impl and the compliance test + changed in this PR or in linked PRs that land together. +- [ ] If an ErrorCode changed, Appendix C, error_catalog.py, and at + least one test changed in this PR. +- [ ] If a deferred feature was promoted, all five stages of + `impl/python/CONTRIBUTING.md §8` were followed. + +## Quality gates +- [ ] `cd impl/python && make check` passes locally (if impl touched) +- [ ] Compliance suite runs (if compliance touched) +- [ ] No new "review-only" architectural invariant added without a + deterministic-check candidate noted in ARCHITECTURE.md §6.5 +``` + +--- + +## 5. Where conversation happens + +- **Spec amendments** → PR against `proposals/foundation-v0.1/` with + the proposal lifecycle from + [`impl/python/CONTRIBUTING.md §8`](impl/python/CONTRIBUTING.md). +- **Implementation discussion** → PR comments under `impl/python/`. +- **Compliance test additions / changes** → PR against + `compliance/foundation-v0.1/tests/`; the metadata schema is in + [`compliance/foundation-v0.1/SPEC.md`](compliance/foundation-v0.1/SPEC.md). +- **Infrastructure changes** (tooling, CI, quality gates) → PR + an + update to [`impl/python/INFRA.md`](impl/python/INFRA.md) §3 roadmap + and §4 decisions log. + +--- + +## 6. Anti-patterns we will push back on + +- "I'll add the check in the next PR." It never lands. The check is + the work. +- A new `bool` parameter on a public function instead of an enum or a + separate function. +- An `Optional[T]` return that means "lookup failed." Raise an + `OSIError` instead. +- A new `ErrorCode` without an Appendix C row *and* without an entry + in `_IMPLEMENTATION_EXTENSIONS`. +- An "exception" added to the layer-flow contract because of a + specific need. Either the abstraction is wrong (refactor) or the + contract is wrong (rewrite it). +- A new module that imports across two layers "for one helper." The + helper belongs in `osi.common` or hasn't been factored correctly. +- A new infrastructure choice (test framework, linter, mutation tool) + not recorded in `INFRA.md §4`. +- A new architectural invariant added to `ARCHITECTURE.md §6` without + a row in `§6.5 Invariants enforced in code`. + +--- + +## 7. Where to start + +- New to the project? Read [`README.md`](README.md), then + [`proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](proposals/foundation-v0.1/Proposed_OSI_Semantics.md) + §1–§3. +- New to the reference implementation? Read + [`impl/python/ARCHITECTURE.md`](impl/python/ARCHITECTURE.md) §1, then + [`impl/python/docs/JOIN_ALGEBRA.md`](impl/python/docs/JOIN_ALGEBRA.md). +- About to contribute code? Read + [`impl/python/CONTRIBUTING.md`](impl/python/CONTRIBUTING.md), then + this file's §1–§4. +- About to review someone else's contribution? Open the appropriate + skill from [`.agent-skills/REVIEWER_SKILLS.md`](.agent-skills/REVIEWER_SKILLS.md) + and walk its checklist. diff --git a/README.md b/README.md index b6d99aa..5e95cb1 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,144 @@ -# OSI -The OSI initiative is a collaborative, open-source effort dedicated to standardizing and streamlining semantic model exchange and utilization across the diverse array of tools and platforms within the data analytics, AI, and BI ecosystem. Our shared vision is to establish a common, vendor-agnostic semantic model specification, promoting unparalleled interoperability, efficiency, and collaboration among all participants. By providing a single, consistent source of truth, this vendor-agnostic standard ensures that your data’s definitions and value remain consistent as they are interchanged between AI agents, BI platforms, and all other tools in your ecosystem, eliminating inconsistencies across your different tools. +# OSI — Open Semantic Interchange +The Open Semantic Interchange (OSI) initiative is a collaborative, +open-source effort to standardise and streamline how semantic models +are exchanged and consumed across the data analytics, AI, and BI +ecosystem. Its goal is a common, vendor-agnostic semantic model +specification so a metric like "Total Revenue" or "Active Users" +means the same thing whether it is queried from an AI agent, a BI +tool, a notebook, or a downstream pipeline — eliminating definition +drift across the stack. -# License +This repository hosts the standard, the reference implementation +that demonstrates how to build a conforming engine, and the +compliance suite that proves an engine actually conforms. -All code in this repository is licensed under the Apache 2.0 license. +--- -The specification and documentation is licensed under the Creative Commons Attribution license (CC BY). +## Repository layout + +| Directory | What lives here | +|:---|:---| +| [`proposals/`](proposals/) | **Active proposals**, versioned per tier. `proposals/foundation-v0.1/` holds the Foundation tier specification (`Proposed_OSI_Semantics.md`) and its expression-language companion (`SQL_EXPRESSION_SUBSET.md`). | +| [`impl/python/`](impl/python/) | **Reference implementation.** A Python compiler that parses an OSI semantic model + a `LODQuery` and emits dialect-specific SQL. Demonstrates how every normative rule in the proposal lands in code. | +| [`compliance/`](compliance/) | **Compliance test suite.** Concrete test vectors plus a tier-versioned suite (`compliance/foundation-v0.1/`) you point at any engine to certify conformance. Engine-agnostic. | +| [`docs/`](docs/) | High-level docs about the OSI initiative itself. | +| [`core-spec/`](core-spec/) · [`converters/`](converters/) · [`validation/`](validation/) · [`examples/`](examples/) | Legacy and supporting material for the wider OSI body of work. | +| [`ROADMAP.md`](ROADMAP.md) | Where the standard is going. | +| [`.agent-skills/`](.agent-skills/) | Tool-agnostic agent skill files (work with Cursor, Claude Code, and any tool that reads `SKILL.md` front-matter). | + +--- + +## Use the reference implementation + +[`impl/python/`](impl/python/) is the canonical reference engine for the +Foundation tier. Use it to: + +- See exactly how a Foundation-conformant engine should behave on any + given model + query. +- Inspect the planner / codegen pipeline as a worked example. +- Smoke-test a model file before plugging it into your own engine. + +Quick start: + +```bash +cd impl/python +pip install -e . +osi describe examples/models/demo_orders.yaml +osi explain examples/models/demo_orders.yaml examples/queries/revenue_by_region.json +osi compile examples/models/demo_orders.yaml examples/queries/revenue_by_region.json --dialect duckdb +``` + +Deeper reading: + +- [`impl/python/README.md`](impl/python/README.md) — entry point, with + a worked example end-to-end. +- [`impl/python/ARCHITECTURE.md`](impl/python/ARCHITECTURE.md) — how + the parse → plan → codegen pipeline is wired. +- [`impl/python/SPEC.md`](impl/python/SPEC.md) — the implementation + contract: design priorities, what Foundation features are in scope + (§2) and explicitly deferred (§3), the algebra proof obligation, + expression handling rules, error discipline, and the glossary. +- [`impl/python/docs/JOIN_ALGEBRA.md`](impl/python/docs/JOIN_ALGEBRA.md) + — the closed algebra and its laws (the proof surface the planner + uses to guarantee correctness). +- [`impl/python/INFRA.md`](impl/python/INFRA.md) — quality standards + and CI gates that keep the reference implementation honest. + +--- + +## Run the compliance suite + +[`compliance/foundation-v0.1/`](compliance/foundation-v0.1/) is the +**engine-agnostic** Foundation v0.1 conformance suite. Each test +ships: + +- A `metadata.yaml` naming the Conformance Decision (`D-NNN`) it pins, + the difficulty tier, and any `xfail` reason. +- A `model.yaml` (semantic model) and `query.json` (semantic query). +- A `gold_rows.json` (positive tests) **or** an `expected_error_code` + (negative tests). Tests assert on observable behaviour — rows or + error codes — never on SQL strings (per D-014, cross-engine SQL + determinism is non-normative). + +Point it at the reference Python implementation: + +```bash +pip install -e compliance/harness +pip install -e compliance/foundation-v0.1 +pip install -e impl/python + +cd compliance/foundation-v0.1 +python -m harness.runner \ + --adapter adapters/osi_python_adapter.py \ + --tests tests/ \ + --datasets datasets/ +# per-run artifacts land in results/latest/ by default +``` + +Then read `compliance/foundation-v0.1/results/REPORT.md` for the +curated baseline pass rate and `results/latest/summary.md` for the +breakdown of the run you just executed. + +To certify your own engine, implement the +[`compliance/ADAPTER_INTERFACE.md`](compliance/ADAPTER_INTERFACE.md) +contract and rerun the same suite with `--adapter +path/to/your_adapter.py`. The suite never imports the engine; it +only invokes the adapter binary. + +Deeper reading: + +- [`compliance/foundation-v0.1/README.md`](compliance/foundation-v0.1/README.md) + — suite layout, fixtures, and triage workflow. +- [`compliance/foundation-v0.1/SPEC.md`](compliance/foundation-v0.1/SPEC.md) + — what the suite covers and what it intentionally does NOT cover. +- [`compliance/foundation-v0.1/DATA_TESTS.md`](compliance/foundation-v0.1/DATA_TESTS.md) + — the catalog of concrete test vectors used to populate the suite. + +--- + +## Agent skills (Cursor, Claude Code, …) + +If you use Cursor or Claude Code (or any agent that reads `SKILL.md` +front-matter), the skills in [`.agent-skills/`](.agent-skills/) give +the agent first-class entry points for the most common workflows: + +- `run-osi-python-tests` — runs the full impl/python test pyramid + (unit / property / golden / e2e / mutation / coverage / lint / + typecheck / architecture) and produces a single readable report. +- `run-osi-compliance` — runs the Foundation v0.1 compliance suite + against the reference implementation and surfaces a per-decision + coverage report. + +See [`.agent-skills/README.md`](.agent-skills/README.md) for how to +wire them into Cursor or Claude Code (one-line symlink each). + +--- + +## License + +All code in this repository is licensed under the +[Apache 2.0 license](LICENSE). + +The specification and documentation are licensed under the +[Creative Commons Attribution license (CC BY)](LICENSE-Docs). diff --git a/compliance/ADAPTER_INTERFACE.md b/compliance/ADAPTER_INTERFACE.md new file mode 100644 index 0000000..e5e350e --- /dev/null +++ b/compliance/ADAPTER_INTERFACE.md @@ -0,0 +1,85 @@ +# Adapter Interface Contract + +**Version**: 1.0 +**Date**: 14 March 2026 + +--- + +## Purpose + +This document defines the boundary between the **test harness** and an **OSI implementation adapter**. It is the authoritative reference for what adapters may and must not do. + +## CLI Contract + +```bash + sql --model --query-file --dialect +``` + +| Stream | Content | +|--------|---------| +| **stdout** | Generated SQL string only — no headers, no decoration | +| **stderr** | Error messages on failure | +| **exit code** | 0 on success, non-zero on error | + +## Adapter Boundary Rules + +An adapter is a **thin translator** between the harness CLI contract and the implementation's programmatic API. It performs **format conversion only**. + +### An adapter MUST: + +1. Parse CLI arguments (`--model`, `--query-file`, `--dialect`) +2. Translate the query JSON into the implementation's query object (e.g., `LODQuery`) +3. Invoke the implementation's planner and transpiler +4. Print the resulting SQL to stdout +5. Print errors to stderr and exit non-zero on failure + +### An adapter MUST NOT: + +1. **Validate query semantics** — duplicate dimensions, ambiguous fields, ORDER BY validity, EXCLUDE dimension membership. These are planner responsibilities. If the implementation doesn't validate them, **file a bug on the implementation**. + +2. **Rewrite SQL expressions** — dialect function translation (IFF→CASE, ZEROIFNULL→COALESCE), expression normalization. These are transpiler responsibilities. If the transpiler mishandles a function, **fix the transpiler**. + +3. **Substitute parameters** — resolving `:param`/`$param` placeholders, type-aware quoting. These are parsing-layer responsibilities. If the parser doesn't handle parameters, **extend the parser**. + +4. **Handle special query shapes** — dimensions-only queries, empty-measure queries. These are planner responsibilities. If the planner can't handle a valid query shape, **fix the planner**. + +5. **Work around implementation bugs** — injecting synthetic measures, monkey-patching frozen models, generating SQL directly. If the implementation produces wrong SQL, **fix the implementation**. + +6. **Contain business logic** — aggregation classification, grain resolution, safety checks, filter routing. All BI semantics belong in the implementation. + +### An adapter MAY: + +1. Set up import paths to locate the implementation (`sys.path` manipulation) +2. Register dialect plugins (e.g., importing `osi.frontend.dialect` to register with sqlglot) +3. Translate between the JSON query format and the implementation's API types (this is format conversion, not business logic) +4. Map between JSON grain format and API grain format (e.g., `{"mode": "TABLE", "dimensions": ["ds"]}` → `GrainSpec(mode="TABLE", table_name="ds")`) + +## Size Guardrail + +A compliant adapter should be **under 200 lines** of Python/shell. If an adapter exceeds this, it is almost certainly doing work that belongs in the implementation. The reference Python adapter is ~160 lines. + +## How to Diagnose Adapter Bloat + +If you find yourself adding code to the adapter, ask: + +| Question | If yes... | +|----------|-----------| +| Does this validate the query? | Move to planner `_validate_query_dimensions()` | +| Does this rewrite SQL expressions? | Move to `SQLGlotTranspiler.normalize_expression()` | +| Does this substitute parameters? | Move to `osi.parsing.parameters` | +| Does this handle a special query shape? | Move to planner `plan()` method | +| Does this work around a bug? | Fix the bug in the implementation | +| Does this generate SQL directly? | Move to the transpiler | + +## Compliance Verification + +The harness will eventually include a static check that verifies adapter size and flags common anti-patterns (direct SQL generation, expression parsing, aggregation logic). Until then, code review is the enforcement mechanism. + +## For AI Agents + +When working on adapters for the OSI compliance suite, you MUST follow these rules: + +1. **Never add business logic to an adapter.** If a test fails because the implementation doesn't handle something, fix the implementation — not the adapter. +2. **Never add more than 10 lines to an adapter without checking this document.** If you're adding validation, rewriting, parameter handling, or SQL generation, you're in the wrong file. +3. **The adapter line count is a hard signal.** If it exceeds 200 lines, something is wrong. +4. **When in doubt, the implementation is the right place.** The adapter exists only because different implementations have different API shapes. Everything else is implementation work. diff --git a/compliance/foundation-v0.1/.gitignore b/compliance/foundation-v0.1/.gitignore new file mode 100644 index 0000000..f1c8463 --- /dev/null +++ b/compliance/foundation-v0.1/.gitignore @@ -0,0 +1,9 @@ +# Everything in results/ is per-run runner output, written by +# `python -m harness.runner` into a subdirectory of results/ (the +# default is `results/latest/`; the Makefile under +# impl/python/ uses `results/osi_python/` and `results/osi_python_all/`). +# +# Only the curated baseline at results/REPORT.md is tracked. Repo-wide +# Python / cache / venv rules live in the root .gitignore. +results/* +!results/REPORT.md diff --git a/compliance/foundation-v0.1/DATA_TESTS.md b/compliance/foundation-v0.1/DATA_TESTS.md new file mode 100644 index 0000000..136d9bd --- /dev/null +++ b/compliance/foundation-v0.1/DATA_TESTS.md @@ -0,0 +1,2235 @@ +# DATA_TESTS.md — Concrete Test Vectors for the Foundation Compliance Suite + +**Status:** Authoritative test catalog +**Companions:** [`Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) Appendix B · [`JOIN_ALGEBRA.md`](../../impl/python/docs/JOIN_ALGEBRA.md) · [`SQL_EXPRESSION_SUBSET.md`](../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md) + +This document is the **concrete realization** of the Foundation Conformance +Decisions catalogued in `Proposed_OSI_Semantics.md` Appendix B. Each +**T-NNN** entry below is a runnable test vector: a model, a dataset, a query, +and the row set or error code an implementation MUST produce. Appendix B's +**D-NNN** entries say *what* an implementation must do; this file says *what +inputs prove it*. + +Implementations are encouraged to translate each entry into a fixture under +the published Foundation compliance suite — for `osi_python` this is +[`compliance/foundation-v0.1/tests/`](../../compliance/foundation-v0.1/tests/) +(one folder per `T-NNN` containing `metadata.yaml + model.yaml + +query.json + gold_rows.json`). Implementations on other engines run the +same suite through the standard adapter contract documented in +[`compliance/foundation-v0.1/ADAPTER_INTERFACE.md`](../../compliance/ADAPTER_INTERFACE.md). +The expected outputs are normative and implementation-independent. + +--- + +## 1. Conventions + +### 1.1 Test entry shape + +Every test has the same five fields: + +| Field | Meaning | +|:---|:---| +| **Anchors** | The spec section(s) and `D-NNN` decision(s) the test pins. | +| **Fixture** | The named model fixture (§3) the test runs against. | +| **Data** | The concrete rows. For brevity, fixture-shared data lives in §3 and is referenced by name. Test-local data is shown inline. | +| **Query** | The semantic query — written in the Foundation surface used by tests (a thin YAML around §5's `Aggregation Query` / `Scalar Query` shapes). | +| **Expected** | Either a row set (presented as a markdown table, order-insensitive unless noted) or `error: ` with the diagnostic content the engine MUST surface. | + +A test passes iff the engine's observable output (rows or typed error) +matches the **Expected** field. Tests do **not** assert on SQL string, +plan shape, CTE layout, or any other internal artefact (§11.1 of the +semantics spec). + +### 1.2 Row-set comparison + +- **Order-insensitive** by default — sort both sides by all output columns before comparing. +- **NULL-aware** — `NULL == NULL` for the purposes of the test (the SQL three-valued logic does not apply to assertions). +- **Type-aware** — numeric values are compared by value, not by representation; `100`, `100.0`, and `1e2` are equal. String values must match exactly. + +### 1.3 Error comparison + +An error result is a triple `(code, anchor, must_contain)`: + +```yaml +error: + code: E_AGGREGATE_IN_WHERE + anchor: §5.3 # the spec section the engine MUST cite in its diagnostic + must_contain: # substrings the diagnostic MUST include (case-insensitive) + - "aggregate" + - "Where" +``` + +An engine MAY include additional diagnostic content. The code and the +`must_contain` substrings are the only assertions; wording is otherwise free. + +### 1.4 Determinism guarantee + +Every test that produces a row set MUST be deterministic: running it +twice on the same fixture MUST produce byte-identical SQL and the same +multiset of rows. This is also `D-014`'s test shape, applied uniformly. + +--- + +### 1.5 NULL handling, empty groups, and `0` (D-033) + +The Foundation follows **standard SQL** for aggregates over empty +input row sets (§6.11 / D-033): + +| Aggregate family | Empty-input result | +|:---|:---| +| `COUNT(*)`, `COUNT(x)`, `COUNT(DISTINCT x)` | `0` | +| `SUM`, `AVG`, `MIN`, `MAX`, `MEDIAN`, percentiles, `STDDEV`, `VARIANCE` | `NULL` | + +Tests in this catalog use these results literally — but **only for +dim values that actually appear in the result**. Per §1.6 (Plan A for +single-measure shapes), a dim value with no matching fact row is +dropped from the result entirely; there is no empty-aggregate cell +because there is no row. + +The empty-aggregate rule applies to: + +- **Multi-measure stitch cells** where one branch has no contribution + for a dim value present via the other branch (T-011, T-045) — + `SUM` → `NULL`, `COUNT` → `0`. +- **Dim values present via the `NULL`-key orphan bucket** when the + fact row has no matching dim row (T-047, T-053 etc.) — same rule + applies; the orphan-bucket row is part of the result, and its + cells follow standard SQL. + +Models that prefer `0` for a `SUM` cell MUST declare it explicitly: +`COALESCE(SUM(amount), 0) AS revenue`. The Foundation does NOT +auto-rewrite `SUM` to `COALESCE(SUM, 0)`. + +--- + +### 1.6 Resolved — single-measure shapes follow Plan A (preserve facts) + +Earlier drafts of this catalog had an unresolved tension between two +flagship tests: + +- **T-001** (`Dims: [customers.region]; Measures: [SUM(orders.amount)]`) + expected a `NORTH` row with `revenue = NULL` — implying the planner + preserves the dimension domain even when no fact rows match. +- **T-006** (`Dims: [customers.region]; Measures: [COUNT(*)]; Where + orders.status = 'completed'`) expected NORTH to *not* appear — + implying the planner anchors on the fact and drops dim values with + no matching fact. + +Both could not be correct under one planner. The Architect resolved +this in favour of **Plan A — preserve facts, drop unmatched dim values +— for single-measure aggregation queries**, with the **multi-measure +shape using FULL OUTER stitch (§6.6 row 3) to preserve both sides** as +a deliberate contrast. The spec was updated to match (§6.6, §6.2 step 7, +D-001 example), and existing tests were brought into alignment. + +#### Decision summary + +| Query shape | Default join | Which dim values appear in the result | +|:---|:---|:---| +| Single-measure aggregation: `Dims: [dim.X]; Measures: [SUM(fact.Y)]` | `fact LEFT JOIN dim` (§6.6 row 1) | Distinct `dim.X` reached by a surviving `fact` row, plus a `NULL`-key bucket for orphan facts. **A dim value with no matching fact does NOT appear.** | +| Multi-measure aggregation: `Dims: [dim.X]; Measures: [SUM(factA.Y), SUM(factB.Y)]` | Each measure independently resolved per row 1, then `FULL OUTER` stitch on shared dims (§6.6 row 3) | Union of dim values reachable from *either* branch. A dim value appears if either fact pulls it in. | + +This rule is now consistent across every test in the catalog: + +- **T-001, T-005a-e, T-026, T-028, T-047, T-053**: single-measure or + single-fact-source queries — NORTH does not appear. +- **T-006**: single-measure query with a fact-level WHERE — NORTH + does not appear. +- **T-011, T-045**: multi-measure stitch — NORTH appears via the + branch that has data for customer 4. + +#### How a user gets the "preserve all dim values" behaviour + +If a user wants every dim value to appear (BI-tool convention), they +make the query multi-measure by adding a measure sourced from the dim +dataset itself: + +```yaml +Dimensions: [customers.region] +Measures: + - SUM(orders.amount) AS revenue + - COUNT(customers.id) AS customer_count +``` + +This converts the shape into a multi-measure stitch — the `customers` +branch contributes every distinct region (because `COUNT(customers.id)` +is defined for every region in `customers`), and the FULL OUTER +preserves NORTH with `revenue = NULL`, `customer_count = 1`. + +This is the BI-tool "all regions appear" behaviour, surfaced +*deliberately* through the query shape rather than as a hidden +planner default. The user opts in by asking for it. + +#### Why Plan A (not preserve-dims by default) + +- **Matches raw SQL.** `SELECT dim.X, SUM(fact.Y) FROM fact LEFT JOIN + dim ... GROUP BY dim.X` produces exactly the single-measure Plan A + result. The Foundation does not introduce surprises relative to + the SQL the user could write by hand. +- **Matches Snowflake Semantic Views.** Snowflake's default plan + shape is also Plan A — porting Snowflake models to OSI is a no-op + for this dimension behaviour. +- **Composes cleanly with multi-measure stitch.** §6.6 row 3 already + mandates FULL OUTER for multi-measure shapes; row 1 says LEFT for + single-measure shapes. The contrast is the mental model: "the + multi-measure stitch is the *only* place both sides are preserved + — and that's because there is no other correct shape for merging + two independently-aggregated facts." +- **Surfaces orphan facts.** Plan A keeps the `NULL`-key bucket for + orphan fact rows (e.g. order 105 with `customer_id = 99`). This is + a data-quality signal — preserve-dims would lose it. + +#### Trade-off accepted + +A first-time BI user typing a single-measure query and *expecting* to +see every region might be surprised when a region with no fact data +is absent. The mitigation is the documented multi-measure pattern +above. The Foundation prefers "predictable, matches raw SQL" over +"BI-tool-magical". + +> **Note for test authors.** When writing a new test that groups by a +> dimension over a single-fact source, write the expected rows +> following Plan A: include only dim values reached by a fact row, +> plus the `NULL`-key bucket for orphans. If you want every dim value +> in the expected rows, the test must be multi-measure (with one of +> the measures sourced from the dim dataset). + +--- + +## 2. How to add a new test + +1. Pick the lowest-numbered free `T-NNN`. +2. Link it to a `D-NNN` in `Proposed_OSI_Semantics.md` Appendix B — if no `D-NNN` covers the behaviour, add one there first. +3. Write the test against an existing fixture (§3) if possible; create a new fixture only when the existing ones can't express the shape. +4. Cite the test from the relevant spec § so the doc and the test grow together. + +--- + +## 3. Common fixtures + +### 3.1 Fixture **F-PRELUDE** — single-fact star with multi-fact extension + +Mirrors the *Prelude model* in `Proposed_OSI_Semantics.md` Appendix A. + +```yaml +datasets: + - name: customers + primary_key: [id] + fields: + - { name: id, type: integer } + - { name: region, type: string } + - { name: segment, type: string } + + - name: orders + primary_key: [id] + fields: + - { name: id, type: integer } + - { name: customer_id, type: integer } + - { name: amount, type: decimal(10,2) } + - { name: status, type: string } + + - name: returns + primary_key: [id] + fields: + - { name: id, type: integer } + - { name: customer_id, type: integer } + - { name: amount, type: decimal(10,2) } + + - name: premium_customers + primary_key: [id] + fields: + - { name: id, type: integer } + +relationships: + - { name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id] } # N:1 + - { name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id] } # N:1 +``` + +**Data:** + +`customers`: + +| id | region | segment | +|:--:|:-------|:--------| +| 1 | EAST | retail | +| 2 | EAST | retail | +| 3 | WEST | wholesale | +| 4 | NORTH | retail | + +`orders` (note: orphan order 105 with `customer_id = 99` not in `customers`): + +| id | customer_id | amount | status | +|:---:|:-----------:|-------:|:-----------| +| 101 | 1 | 100.00 | completed | +| 102 | 1 | 50.00 | completed | +| 103 | 2 | 200.00 | pending | +| 104 | 3 | 75.00 | completed | +| 105 | 99 | 30.00 | completed | ← orphan: customer 99 not in customers + +`returns`: + +| id | customer_id | amount | +|:---:|:-----------:|-------:| +| 201 | 1 | 10.00 | +| 202 | 3 | 5.00 | +| 203 | 4 | 15.00 | ← customer 4 has a return but no order + +`premium_customers`: + +| id | +|:--:| +| 1 | +| 3 | + +### 3.2 Fixture **F-BRIDGE** — M:N through a bridge + +Mirrors *Mini-model M2* in Appendix A. This is the fixture for the +flagship `T-015` bridge-deduplication test. + +```yaml +datasets: + - name: actors + primary_key: [actor_id] + fields: + - { name: actor_id, type: integer } + - { name: name, type: string } + - { name: height, type: integer } + + - name: movies + primary_key: [movie_id] + fields: + - { name: movie_id, type: integer } + - { name: title, type: string } + - { name: gross, type: decimal(10,2) } + + - name: appearances + primary_key: [actor_id, movie_id] + role: bridge + fields: + - { name: actor_id, type: integer } + - { name: movie_id, type: integer } + +relationships: + - { name: app_to_actor, from: appearances, to: actors, from_columns: [actor_id], to_columns: [actor_id] } # N:1 + - { name: app_to_movie, from: appearances, to: movies, from_columns: [movie_id], to_columns: [movie_id] } # N:1 +``` + +**Data:** + +`actors`: + +| actor_id | name | height | +|:--------:|:------|-------:| +| 1 | Alice | 170 | +| 2 | Bob | 170 | +| 3 | Carol | 180 | + +`movies`: + +| movie_id | title | gross | +|:--------:|:-------|-------:| +| 10 | Action | 100.00 | +| 11 | Drama | 200.00 | +| 12 | Comedy | 50.00 | + +`appearances`: + +| actor_id | movie_id | +|:--------:|:--------:| +| 1 | 10 | +| 1 | 11 | +| 2 | 10 | ← M10 (Action) has two actors at height 170 +| 3 | 12 | + +### 3.3 Fixture **F-BRIDGE-NONE** — variant of F-BRIDGE without the bridge + +Same `actors` and `movies` data as F-BRIDGE, but no `appearances` dataset +and an undeclared M:N edge between `actors` and `movies`. Used to test +that the engine fails closed instead of guessing a join path. + +```yaml +datasets: + - name: actors + primary_key: [actor_id] + fields: [ { name: actor_id }, { name: name }, { name: height } ] + - name: movies + primary_key: [movie_id] + fields: [ { name: movie_id }, { name: title }, { name: gross } ] + +# Note: no `relationships:` block. The model declares no edge; the +# engine MUST NOT silently fabricate one. +``` + +### 3.4 Fixture **F-AMBIG** — two relationships between the same datasets + +Mirrors *Mini-model M3* in Appendix A. Used for `E_AMBIGUOUS_PATH`. + +```yaml +datasets: + - name: orders + primary_key: [id] + fields: + - { name: id } + - { name: placed_by_id } + - { name: fulfilled_by_id } + - { name: amount } + + - name: users + primary_key: [id] + fields: [ { name: id }, { name: region } ] + +relationships: + - { name: order_placed_by, from: orders, to: users, from_columns: [placed_by_id], to_columns: [id] } + - { name: order_fulfilled_by, from: orders, to: users, from_columns: [fulfilled_by_id], to_columns: [id] } +``` + +**Data:** + +`orders`: + +| id | placed_by_id | fulfilled_by_id | amount | +|:---:|:------------:|:---------------:|-------:| +| 301 | 1 | 2 | 100.00 | +| 302 | 2 | 2 | 50.00 | + +`users`: + +| id | region | +|:--:|:-------| +| 1 | EAST | +| 2 | WEST | + +### 3.5 Fixture **F-NOPATH** — two disconnected datasets + +Mirrors *Mini-model M1* in Appendix A. No relationships are declared. + +```yaml +datasets: + - name: orders + primary_key: [id] + fields: [ { name: id }, { name: customer_id }, { name: amount } ] + + - name: inventory_movements + primary_key: [movement_id] + fields: [ { name: movement_id }, { name: warehouse_id }, { name: quantity } ] +``` + +--- + +## 4. Tests + +### 4.A Query shape + +#### T-001 — Aggregation-query cardinality is `DISTINCT(Dimensions)` + +**Anchors:** §5.1.1, §5.2 · D-001 +**Fixture:** F-PRELUDE + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: + - SUM(orders.amount) AS revenue +``` + +**Expected (single-measure ⇒ Plan A per §6.6 row 1 and §6.2 step 7):** + +| region | revenue | +|:-------|--------:| +| EAST | 350.00 | +| WEST | 75.00 | +| NULL | 30.00 | ← orphan order 105 (customer_id = 99 not in customers) + +Row count = `COUNT(DISTINCT region)` over the regions reached by an +`orders` row, plus one for the orphan-customer bucket. NORTH (customer +4's region) does **not** appear: customer 4 has no orders, and a +single-measure aggregation query follows the fact's natural LEFT join +shape (`orders LEFT JOIN customers`) — a dim value reachable only +from the dim domain is *not* in the result. + +Engines that "carry" `customer_id` into the output grain because the +measure is sourced from `orders` fail this test on row count (they +would group by `customer_id`, not `region`, and produce four data +rows + orphan). Engines that produce a `NORTH | NULL` row are +applying the multi-measure stitch shape to a single-measure query — +also wrong (see §1.6). + +If the user wants NORTH to appear with `revenue = NULL`, they must +make the query multi-measure (e.g. `Measures: [SUM(orders.amount) AS +revenue, COUNT(customers.id) AS customer_count]`) — the FULL OUTER +stitch in §6.6 row 3 then preserves every region present in +`customers`. See T-011 for the canonical multi-measure shape. + +#### T-002 — Mixing `Dimensions` and `Fields` is rejected + +**Anchors:** §5.1 · D-010 +**Fixture:** F-PRELUDE + +**Query:** +```yaml +Dimensions: [customers.region] +Fields: [customers.id] +Measures: [SUM(orders.amount)] +``` + +**Expected:** +```yaml +error: + code: E_MIXED_QUERY_SHAPE + anchor: §5.1 + must_contain: ["Dimensions", "Fields", "scalar"] +``` + +#### T-003 — Bare metric reference inside `Fields` is rejected + +**Anchors:** §5.1.2 · D-011 +**Fixture:** F-PRELUDE. Assume `orders.total_revenue = SUM(amount)` is declared. + +**Query:** +```yaml +Fields: [orders.id, orders.total_revenue] +``` + +**Expected:** +```yaml +error: + code: E_AGGREGATE_IN_SCALAR_QUERY + anchor: §5.1.2 + must_contain: ["scalar", "total_revenue", "aggregate"] +``` + +A scalar query asks for row-level values; a metric is by definition an +aggregate at query grain, which has no defined value at the row grain +of `orders`. The engine MUST NOT silently aggregate, broadcast, or +"window" the metric. + +--- + +### 4.B Field & metric grain + +#### T-004 — Implicit home-grain aggregation in a field expression + +**Anchors:** §4.3 · D-003, D-015 +**Fixture:** F-PRELUDE. Add this field to the model: + +```yaml +fields: + - name: customers.lifetime_value + expression: "SUM(orders.amount)" +``` + +**Query (scalar):** +```yaml +Fields: [customers.region, customers.id, customers.lifetime_value] +``` + +**Expected:** + +| region | id | lifetime_value | +|:-------|:--:|---------------:| +| EAST | 1 | 150.00 | ← SUM(100, 50) +| EAST | 2 | 200.00 | +| WEST | 3 | 75.00 | +| NORTH | 4 | NULL | ← no orders (or 0, depending on engine's LEFT-aggregate convention — both are acceptable; test allows either) + +The inner `SUM` is evaluated at the **home grain of `customers`** (one +sum per customer row), regardless of any grouping the consuming query +applies. This is what makes `lifetime_value` reusable across queries. + +**Compilation-strategy equivalence (D-015):** an engine MAY compile the +inner `SUM` as (a) a correlated subquery, (b) a `LATERAL` / `CROSS APPLY`, +or (c) a pre-aggregated CTE joined back to `customers`. The test passes +under any of the three. + +#### T-005 — Cross-grain aggregation: single-step and nested forms + +This is the **flagship test family for D-020** — the most-debated +semantic in cross-vendor BI portability. The Foundation accepts **both** +single-step and explicit-nested forms over a `1 : N` edge; they give +different numerical answers for non-distributive aggregates (the +single-step form gives the "all rows at once" answer, the nested form +gives the "per-home-row first" answer). For distributive aggregates +they give identical answers, so either form is acceptable for style. + +**Anchors:** §4.5 form (1), §6.1 Semantic 2 · D-020 + +**Fixture:** F-PRELUDE. + +--- + +##### T-005a — Single-step cross-grain `SUM` over `1 : N` is accepted (distributive) + +**Metric declaration:** +```yaml +metrics: + - name: customers.total_order_amount + expression: "SUM(orders.amount)" # single-step; resolves at query grain +``` + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: [customers.total_order_amount] +``` + +**Expected (single-measure ⇒ Plan A; NORTH absent — see T-001):** + +| region | total_order_amount | +|:-------|-------------------:| +| EAST | 350.00 | ← orders 101, 102, 103 +| WEST | 75.00 | ← order 104 +| NULL | 30.00 | ← orphan order 105 + +No error. For distributive aggregates the single-step form is +equivalent to the explicit-nested form `SUM(SUM(orders.amount))` — both +return identical numbers (cross-validated by T-005d below). + +--- + +##### T-005b — Single-step cross-grain `AVG` over `1 : N` is accepted (heavy-weighted) + +**Metric declaration:** +```yaml +metrics: + - name: customers.avg_order_amount + expression: "AVG(orders.amount)" # single-step; standard SQL semantics +``` + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: [customers.avg_order_amount] +``` + +**Expected (single-measure ⇒ Plan A; NORTH absent):** the heavy-customer-weighted SQL average. + +| region | avg_order_amount | +|:-------|-----------------:| +| EAST | 116.67 | ← AVG(100, 50, 200) = 350/3 — three orders in EAST +| WEST | 75.00 | ← AVG(75) — one order in WEST +| NULL | 30.00 | ← orphan order 105 + +No error. Note that EAST's answer (`116.67`) is **not** the unweighted +average across customers — Customer 1 averages `75` over two orders, +Customer 2 averages `200` over one order, and the unweighted answer +would be `(75 + 200) / 2 = 137.5`. That second number is what the +**nested** form produces; see T-005c. + +--- + +##### T-005c — Explicit nested cross-grain `AVG` over `1 : N` is accepted (unweighted) + +The Snowflake-style explicit form is **still accepted**; it just gives a +different (and meaningfully different) answer for non-distributive +aggregates. The Foundation does not force this style — but neither does +it reject it. Users porting from Snowflake Semantic Views can leave their +nested expressions unchanged. + +**Metric declaration:** +```yaml +metrics: + - name: customers.avg_of_per_customer_avg + expression: "AVG(AVG(orders.amount))" # inner: per-customer avg; outer: avg across customers +``` + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: [customers.avg_of_per_customer_avg] +``` + +**Expected (single-measure ⇒ Plan A; NORTH absent):** the unweighted average across customers in each region. + +| region | avg_of_per_customer_avg | +|:-------|------------------------:| +| EAST | 137.50 | ← AVG(Cust1=75, Cust2=200) = 275/2 — two customers in EAST +| WEST | 75.00 | ← AVG(Cust3=75) — one customer in WEST +| NULL | 30.00 | ← orphan order's customer is unknown; treated as one customer-of-one-order + +The user gets to pick: write `AVG(orders.amount)` for the +heavy-weighted answer (T-005b) or `AVG(AVG(orders.amount))` for the +unweighted answer (T-005c). Both are conformant. The two numbers differ +for non-distributive aggregates — this difference is the central +BI-portability decision in D-020. + +--- + +##### T-005d — Single-step and nested `SUM` are numerically equivalent (distributive) + +Run both forms against identical data and assert the row sets are +identical. + +**Metric A (single-step):** +```yaml +- name: customers.sum_single + expression: "SUM(orders.amount)" +``` + +**Metric B (nested):** +```yaml +- name: customers.sum_nested + expression: "SUM(SUM(orders.amount))" +``` + +**Query A:** +```yaml +Dimensions: [customers.region] +Measures: [customers.sum_single] +``` + +**Query B:** +```yaml +Dimensions: [customers.region] +Measures: [customers.sum_nested] +``` + +**Expected:** identical row sets (after column-name normalisation). +Both forms are single-measure aggregation queries ⇒ Plan A ⇒ NORTH +is absent and the orphan `NULL`-region row is present. This test is +what makes "port your Snowflake nested form unchanged" portable in +the distributive case — and is the canonical demonstration that +nested-vs-single is purely a stylistic choice when the aggregate is +distributive. + +--- + +##### T-005e — `COUNT(DISTINCT)` single-step over `1 : N` is accepted + +**Anchors:** §4.5, §6.1 · D-020 case (d), D-022 caveat + +**Metric declaration:** +```yaml +metrics: + - name: customers.distinct_order_statuses + expression: "COUNT(DISTINCT orders.status)" +``` + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: [customers.distinct_order_statuses] +``` + +**Expected (single-measure ⇒ Plan A; NORTH absent):** + +| region | distinct_order_statuses | +|:-------|------------------------:| +| EAST | 2 | ← `completed` (101, 102) + `pending` (103) +| WEST | 1 | ← `completed` (104) +| NULL | 1 | ← orphan 105 has `completed` + +No `E_UNSAFE_REAGGREGATION`. This pins D-020 (d) and the D-022 caveat +explicitly: a holistic aggregate over a plain `1 : N` edge is one SQL +aggregate over the joined rows — well-defined per D-020. The +`E_UNSAFE_REAGGREGATION` error only fires when the plan would require +*decomposing* a holistic aggregate across grains, which happens on M:N +(D-022) or fan-out shapes the engine cannot pre-aggregate safely. + +#### T-006 — `COUNT(*)` over a dataset is well-defined at the home grain + +**Anchors:** §7.2 · D-016 +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: + - COUNT(*) AS order_count +Where: + - orders.status = 'completed' +``` + +**Expected:** + +| region | order_count | +|:-------|------------:| +| EAST | 2 | ← orders 101, 102 (102? wait 103 is pending). 101+102 completed. 103 pending excluded. +| WEST | 1 | ← order 104 +| NULL | 1 | ← orphan order 105 (status=completed) + +`COUNT(*)` is the count of rows of the **measure-bearing dataset** +that survive the predicates — here, `orders`. Engines that emit +`COUNT()` as a transparent rewrite (e.g. +Snowflake's `COUNT(*)`-rejection workaround) MUST produce the same +numbers. + +--- + +### 4.C Predicate routing + +#### T-007 — Aggregate in `Where` is rejected + +**Anchors:** §5.3 · D-005, D-012a +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: [SUM(orders.amount)] +Where: + - SUM(orders.amount) > 100 # aggregate inside Where +``` + +**Expected:** +```yaml +error: + code: E_AGGREGATE_IN_WHERE + anchor: §5.3 + must_contain: ["aggregate", "Where", "Having"] # diagnostic should hint at Having +``` + +The diagnostic MUST identify the offending aggregate by name and SHOULD +suggest moving the predicate to `Having`. + +#### T-008 — Pure row-level predicate in `Having` is rejected + +**Anchors:** §5.3 · D-012b +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: [SUM(orders.amount)] +Having: + - customers.region = 'EAST' # row-level dimension predicate +``` + +**Expected:** +```yaml +error: + code: E_NON_AGGREGATE_IN_HAVING + anchor: §5.3 + must_contain: ["Having", "Where"] +``` + +Note this test is about *routing*, not behaviour. An engine that +silently treats `Having` as `Where` produces the right numbers but +breaks the contract, and so fails the test on the error code, not +the rows. + +#### T-009 — Mixed-level boolean predicate is rejected + +**Anchors:** §5.3 · D-012c +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Having: + - customers.region = 'EAST' AND SUM(orders.amount) > 100 +``` + +**Expected:** +```yaml +error: + code: E_MIXED_PREDICATE_LEVEL + anchor: §5.3 + must_contain: ["row-level", "aggregate", "split"] +``` + +The diagnostic SHOULD suggest splitting the predicate (row-level half +→ `Where`, aggregate half → `Having`). + +--- + +### 4.D Default join behaviour + +#### T-010 — `N:1` enrichment defaults to `LEFT`; orphans appear under `NULL` + +**Anchors:** §6.4 · D-004a +**Fixture:** F-PRELUDE. + +Covered indirectly by T-001's expected rows — the `region = NULL` row +exists *only because* the default join from `orders → customers` is +`LEFT` and order 105's `customer_id = 99` does not match. An engine +that defaults to `INNER` returns one fewer row and fails the test on +row count. This is the single most common silent-correctness failure +in BI tools (Snowflake's "first matching row" rule is the worst case; +see Appendix B note B-1). + +#### T-011 — Multi-fact composition defaults to `FULL OUTER` on shared dims + +**Anchors:** §6.4 · D-004b +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: + - SUM(orders.amount) AS revenue + - SUM(returns.amount) AS return_total +``` + +**Expected (multi-measure ⇒ FULL OUTER stitch per §6.6 row 3 / §6.2 +step 7; *both* sides preserved):** + +| region | revenue | return_total | +|:-------|--------:|-------------:| +| EAST | 350.00 | 10.00 | ← both facts have data +| WEST | 75.00 | 5.00 | ← both facts have data +| NORTH | NULL | 15.00 | ← only `returns` has data (customer 4); revenue is `SUM` over zero rows = `NULL` (§6.11 / D-033) +| NULL | 30.00 | NULL | ← only `orders` has data (orphan 105); return_total is `SUM` over zero rows = `NULL` + +This is the **multi-measure** counterpart to T-001's single-measure +Plan A: because there are two independently-aggregated facts +(`orders` and `returns`), §6.6 row 3 mandates a `FULL OUTER` between +the two pre-aggregated branches. The union of dim values appearing in +*either* branch is the result. NORTH appears here (via the `returns` +branch) because customer 4 has a return, even though customer 4 has +no orders. Without `returns` as a second measure, this would be a +single-measure query and Plan A would drop NORTH (see T-001). An +engine that defaults to `INNER` between the two pre-aggregated +branches loses NORTH and the orphan-bucket, failing on row count. + +**Companion `COUNT` test:** replacing either `SUM` with `COUNT(*)` or +`COUNT(orders.id)` would produce `0` in the missing-side cell, not +`NULL`, per standard SQL. See T-047 for the explicit +`SUM`-returns-`NULL` / `COUNT`-returns-`0` contrast. + +#### T-012 — Scalar grand total uses `CROSS JOIN` + +**Anchors:** §6.4 · D-004c +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Dimensions: [] # no dimensions ⇒ scalar grain +Measures: + - SUM(orders.amount) AS total_revenue + - SUM(returns.amount) AS total_returns +``` + +**Expected:** exactly one row. + +| total_revenue | total_returns | +|--------------:|--------------:| +| 455.00 | 30.00 | + +`455.00 = 100+50+200+75+30` (all orders including orphan); +`30.00 = 10+5+15` (all returns). The two scalars sit on a single row +joined via `CROSS JOIN` (`1 ⋈ 1`), not on separate rows. + +--- + +### 4.E M:N resolution — the flagship section + +#### T-013 — Two unrelated facts with no shared dimension raise `E3013` + +**Anchors:** §6.6 · D-007 +**Fixture:** F-NOPATH. + +**Query:** +```yaml +Measures: + - SUM(orders.amount) + - SUM(inventory_movements.quantity) +``` + +**Expected:** +```yaml +error: + code: E3013_NO_STITCHING_DIMENSION + anchor: §6.6 + must_contain: ["orders", "inventory_movements", "shared dimension"] +``` + +An engine that emits a Cartesian product here is producing a +plausible-but-wrong total and is the single failure mode Promise 4 is +designed to prevent. + +#### T-014 — `N:N` with no bridge and no shared dim raises `E3012` + +**Anchors:** §6.6 · D-007 +**Fixture:** F-BRIDGE-NONE. + +**Query:** +```yaml +Dimensions: [actors.height] +Measures: [SUM(movies.gross)] +``` + +**Expected:** +```yaml +error: + code: E3012_MN_NO_SAFE_REWRITE + anchor: §6.6 + must_contain: ["actors", "movies", "bridge"] # diagnostic should suggest the remedy +``` + +#### T-015 — Bridge resolution de-duplicates per `(fact, group)` (FLAGSHIP) + +**Anchors:** §6.6.1, §6.3 Semantic 2 · D-026 +**Fixture:** F-BRIDGE. + +**Query:** +```yaml +Dimensions: [actors.height] +Measures: + - SUM(movies.gross) AS total_gross +``` + +**Expected:** + +| height | total_gross | +|-------:|------------:| +| 170 | 300.00 | ← M10 (100) counted ONCE, plus M11 (200). Not 400. +| 180 | 50.00 | + +**Why this test exists.** The naive flat-join SQL +`actors ⋈ appearances ⋈ movies GROUP BY actors.height` produces +`(170 → 400, 180 → 50)` because M10's gross is replicated by Alice's +*and* Bob's appearances. The Foundation's bridge plan, per §6.6.1, +de-duplicates at the `(movie_id, height)` level before the final +`SUM`, and so M10 contributes 100 to height 170 exactly once. This +is **Semantic 2** in operation: no row of `movies` (the measure's +home dataset) contributes more than once to any output group. + +**Vendor cross-check.** + +| Tool | Same data, same query, expected output | +|:---|:---| +| Looker (symmetric aggregates on, declared PK on each view) | `170 → 300, 180 → 50` | +| Tableau (Relationships, 2020.2+) | `170 → 300, 180 → 50` | +| Power BI (bridge with single-direction filter + `SUMX(DISTINCT(...))`) | `170 → 300, 180 → 50` | +| Naive SQL join (no de-duplication) | `170 → 400, 180 → 50` — **wrong** | + +An OSI implementation that matches the naive-SQL number on this fixture +is non-conformant on D-026, regardless of how it generates the SQL. + +#### T-016 — Bare non-distributive aggregate over a bridge is rejected + +**Anchors:** §4.5, §6.1, §6.6.1 · D-027 +**Fixture:** F-BRIDGE. + +**Query:** +```yaml +Dimensions: [actors.height] +Measures: [AVG(movies.gross)] +``` + +**Expected:** +```yaml +error: + code: E_UNSAFE_REAGGREGATION + anchor: §6.1 + must_contain: ["AVG", "non-distributive", "nested", "AVG(AVG"] +``` + +The diagnostic MUST suggest the nested form (e.g., `AVG(AVG(movies.gross))` +to mean "average across actors of their per-actor average gross"). + +#### T-017 — Nested aggregation over a bridge succeeds with per-endpoint plan + +**Anchors:** §4.5 form (1), §6.6.1 · D-020, D-027 +**Fixture:** F-BRIDGE. + +**Query:** +```yaml +Dimensions: [actors.height] +Measures: + - AVG(AVG(movies.gross)) AS avg_of_per_actor_avg +``` + +**Expected:** + +| height | avg_of_per_actor_avg | +|-------:|---------------------:| +| 170 | 125.00 | ← AVG(per-actor avg) = AVG(Alice=150, Bob=100) = 125 +| 180 | 50.00 | ← AVG(Carol=50) = 50 + +The inner `AVG` runs at actor grain (per actor: Alice averages M10/M11 += 150; Bob averages M10 = 100; Carol averages M12 = 50); the outer `AVG` +runs at query grain (per height). This is the explicit case where the +per-endpoint-first plan is correct and produces different numbers from +T-015's de-duplication plan — and that's the point of requiring nested +aggregation: user intent is now unambiguous. + +#### ~~T-018~~ — Deferred (was: `EXISTS_IN` compiles to `EXISTS`) + +The original T-018 exercised the semi-join filter form +(`EXISTS_IN` / `NOT EXISTS_IN`). That construct has been **removed +from the Foundation** and moved to a separate follow-up proposal that +will specify its surface syntax, NULL-safety guarantees, and +compilation contract in full. This test will be reinstated (and +expanded — positive case, `NOT`-form, NULL on both sides) once the +EXISTS_IN proposal lands. + +Until then: the Foundation has no semi-join construct, and there is +nothing for this slot to test. The slot is retained to keep T-NNN +numbering stable; future re-use of `T-018` for an unrelated test +would be misleading. + +--- + +### 4.F Path resolution & namespacing + +#### T-019 — Two relationships between the same datasets raise `E_AMBIGUOUS_PATH` + +**Anchors:** §6.7 · D-018a +**Fixture:** F-AMBIG. + +**Query:** +```yaml +Dimensions: [users.region] +Measures: [COUNT(orders.id)] +``` + +**Expected:** +```yaml +error: + code: E_AMBIGUOUS_PATH + anchor: §6.7 + must_contain: ["order_placed_by", "order_fulfilled_by"] # both candidate paths named +``` + +The diagnostic MUST list **every** candidate relationship by name so +the user can pick one (the planned remedy is to annotate the query +with the relationship to use; see §10). + +#### T-020 — Bare-name references resolve to the global namespace, not a dataset + +**Anchors:** §4.6 · D-006 +**Fixture:** F-PRELUDE. Declare `orders.total_revenue = SUM(amount)` as a +*dataset-scoped* metric. + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: [total_revenue] # bare name, not qualified +``` + +**Expected:** +```yaml +error: + code: E_NAME_NOT_FOUND + anchor: §4.6 + must_contain: ["total_revenue", "global", "orders.total_revenue"] +``` + +The bare name MUST NOT silently match `orders.total_revenue`. Using +the dataset-scoped metric requires the qualified reference: + +**Query (positive case):** +```yaml +Measures: [orders.total_revenue] +``` + +Resolves and returns the per-region revenue from T-001. + +--- + +### 4.G Grain error contracts + +#### T-021 — `COUNT(DISTINCT)` under fan-out raises `E_UNSAFE_REAGGREGATION` + +**Anchors:** §6.1 Starting Grain, §7.2 · D-022 +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Dimensions: [orders.status] +Measures: [COUNT(DISTINCT customers.id) AS unique_customers] +``` + +**Expected:** +```yaml +error: + code: E_UNSAFE_REAGGREGATION + anchor: §6.1 + must_contain: ["COUNT(DISTINCT)", "holistic", "pre-aggregate"] +``` + +The aggregate is holistic and reads a column from the one-side +(`customers.id`) over a join that fans out to orders. The diagnostic +MUST identify the aggregate, classify it as holistic, and suggest a +pre-aggregation at the customer grain. + +For comparison, swapping in `SUM(orders.amount)` (distributive) on the +same shape succeeds with no error — this is the discriminator between +D-022 and the safe-by-pre-aggregation case. + +#### T-022 — Fan-out in a scalar query raises `E_FAN_OUT_IN_SCALAR_QUERY` + +**Anchors:** §6.1 Final Grain, §5.1.2 · D-023 +**Fixture:** F-PRELUDE. + +**Query (scalar):** +```yaml +Fields: [customers.region, orders.id] +``` + +**Expected:** +```yaml +error: + code: E_FAN_OUT_IN_SCALAR_QUERY + anchor: §5.1.2 + must_contain: ["scalar", "fan-out", "customers", "orders"] +``` + +The scalar query has no aggregation step in which to absorb the +fan-out; either the user must aggregate one side or switch to an +aggregation query. The diagnostic MUST suggest both remedies. + +#### T-023 — Row-level reference to a finer grain raises `E_UNAGGREGATED_FINER_GRAIN_REFERENCE` + +**Anchors:** §6.1 Starting Grain · D-024 +**Fixture:** F-PRELUDE. Declare this *invalid* field on `customers`: + +```yaml +fields: + - name: customers.first_order_amount + expression: "orders.amount" # bare row-level reference to higher-grain table +``` + +**Expected (at model load or first query referencing the field):** +```yaml +error: + code: E_UNAGGREGATED_FINER_GRAIN_REFERENCE + anchor: §6.1 + must_contain: ["orders.amount", "aggregate", "customers"] +``` + +The remedy is to either wrap the reference in an aggregate +(`SUM(orders.amount)` — implicit home-grain aggregation per D-003), +or pull the value from a coarser-grain dataset where it is already +at the home grain. The diagnostic MUST surface both options. + +--- + +### 4.H Common-clause semantics + +This section pins behaviour that applies to **both** query shapes: +predicate-list interpretation, `Order By` NULL handling, `Limit` +without `Order By`, and home-grain scalar usability in `Where`. + +#### T-024 — Boolean home-grain scalar usable in `Where` + +**Anchors:** §4.3 routing rules, §6.1 Semantic 2 · D-005 (e) +**Fixture:** F-PRELUDE. + +**Model addition:** +```yaml +fields: + - dataset: customers + name: has_completed_orders + expression: "COUNT(orders.id WHERE orders.status = 'completed') > 0" +``` + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: [COUNT(customers.id) AS customer_count] +Where: ["customers.has_completed_orders"] +``` + +**Expected:** + +| region | customer_count | +|:-------|---------------:| +| EAST | 1 | ← Customer 1 (orders 101, 102 completed). Customer 2 has only pending order 103, excluded. +| WEST | 1 | ← Customer 3 (order 104 completed) + +No error. The field `has_completed_orders` contains `COUNT(orders.*) > 0` — +an aggregate over a finer-grain dataset — but it resolves at customer +grain via implicit home-grain aggregation (§4.3), so its **resolved +shape** is a boolean scalar at customer grain. By D-005 (e), it is +therefore valid in `Where` for any query at customer grain or coarser, +where it filters customers pre-aggregation. An engine that classifies +fields by surface syntax (i.e., "contains `COUNT`, therefore route to +`Having`") would raise `E_WRONG_PREDICATE_LOCATION` and fail this test. + +--- + +#### T-026 — Outer `Order By` defaults to `NULLS LAST` + +**Anchors:** §5.1 Common clause semantics, §6.10.2 · D-014, D-029 +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: [SUM(orders.amount) AS revenue] +Order By: ["customers.region ASC"] +``` + +**Expected (single-measure ⇒ Plan A; NORTH absent — see T-001):** + +| region | revenue | +|:-------|--------:| +| EAST | 350.00 | +| WEST | 75.00 | +| NULL | 30.00 | ← orphan-bucket sorts LAST (NULLS LAST default) + +The non-NULL `region` values sort by ASC; the orphan `NULL`-region +bucket sorts last per the `NULLS LAST` default. + +Compilation MUST emit explicit `NULLS LAST` into the compiled SQL even +though the query body did not say so: + +```yaml +sql_contract: + forbidden_substrings: + - "ORDER BY .* ASC$" # bare ASC without explicit NULLS LAST + required_substrings: + - "ORDER BY .* ASC NULLS LAST" +``` + +This MUST be byte-stable across compilations (D-014) and MUST agree +across dialects (Snowflake / Databricks / Postgres) even though each +dialect's *native* default differs. An engine that omits the explicit +`NULLS LAST` from compiled SQL fails on the SQL contract, even if the +runtime row order happens to match. + +--- + +#### T-028 — `Where: [P1, P2]` is `P1 AND P2` + +**Anchors:** §5.1 Common clause semantics · D-014 +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: [SUM(orders.amount) AS revenue] +Where: + - "orders.status = 'completed'" + - "orders.amount > 60" +``` + +**Expected (single-measure ⇒ Plan A; NORTH absent — see T-001):** + +| region | revenue | +|:-------|--------:| +| EAST | 100.00 | ← only order 101 (status=completed, amount=100). Order 102 (amount=50) excluded by amount filter. Order 103 (pending) excluded by status filter. +| WEST | 75.00 | ← order 104 (status=completed, amount=75) +| NULL | 200.00 | ← orphan 105 (status=completed, amount=200) + +The result MUST equal what a single equivalent `Where:` entry +`"orders.status = 'completed' AND orders.amount > 60"` produces — same +rows, same numbers, byte-identical compiled SQL (after alias +normalisation). An engine that interprets a predicate list as `OR` +would include order 102 (status=completed, amount=50) and inflate +EAST to `150` — a runtime test failure. + +--- + +#### T-052 — `Limit` without `Order By` compiles and runs + +**Anchors:** §5.1 Common clause semantics · D-014 +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: [SUM(orders.amount) AS revenue] +Limit: 2 +``` + +**Expected:** + +(a) **No error.** The query compiles successfully — `Limit` without + `Order By` is a legitimate (if non-deterministic) BI query. + +(b) **No warning required.** The Foundation does NOT mandate that + engines emit a diagnostic. An engine that emits a "non-deterministic + Limit" advisory is conformant; an engine that emits no diagnostic at + all is also conformant. + +(c) **Compiled SQL is byte-stable (D-014).** Two compilations of the + same `(model, query, dialect)` MUST produce byte-identical SQL — + the *output rows* may differ between runs of that SQL (since the + engine is free to pick any two rows), but the SQL string itself is + deterministic. + +(d) **Result has exactly two rows**, both with valid `region` values + from the result set `{EAST, WEST, NORTH, NULL}`. Specific row + selection is engine-defined. + +--- + +### 4.I Window functions + +The Foundation supports a defined subset of SQL window functions +(§6.10). This section exercises both the supported shapes (with strong +result expectations) and the rejected shapes (with explicit error +codes). + +#### T-027 — `ORDER BY` inside `OVER (...)` defaults to `NULLS LAST` + +**Anchors:** §6.10.2 · D-029 +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Fields: + - orders.id + - "ROW_NUMBER() OVER (PARTITION BY orders.customer_id ORDER BY orders.amount DESC) AS rn" +Where: + - "orders.status = 'completed'" +``` + +**Expected:** the compiled SQL MUST contain `ORDER BY ... DESC NULLS +LAST` inside the `OVER (...)` clause, even though the query body did +not specify it. Engines compiling to Snowflake (native `DESC NULLS +FIRST`) MUST emit the explicit clause to override the dialect default. + +```yaml +sql_contract: + required_substrings: + - "OVER (.*ORDER BY .* DESC NULLS LAST.*)" +``` + +The result row set: every `(customer_id, amount)` group's NULL `amount` +rows (if any existed) would sort after non-NULL rows. In F-PRELUDE all +orders have non-NULL `amount`, so this test exercises the SQL contract +above rather than data NULLs. + +--- + +#### T-029 — Window function in `Where` is rejected + +**Anchors:** §6.10.3 · D-030 (E_WINDOW_IN_WHERE) +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Dimensions: [orders.customer_id] +Measures: [SUM(orders.amount) AS revenue] +Where: + - "ROW_NUMBER() OVER (PARTITION BY orders.customer_id ORDER BY orders.amount) = 1" +``` + +**Expected:** the engine MUST reject: + +```yaml +error: + code: E_WINDOW_IN_WHERE + anchor: §6.10.3 + must_contain: ["window", "Where", "Qualify", "subquery"] +``` + +The diagnostic MUST mention both supported alternatives: (a) move the +predicate into a `Having` or `Qualify` clause, or (b) wrap the windowed +expression in a derived field and filter that field at a coarser grain. + +--- + +#### T-030 — Nested window functions rejected + +**Anchors:** §6.10.4 · D-031 +**Fixture:** F-PRELUDE. + +**Query (illegal — window over window):** +```yaml +Fields: + - orders.id + - "SUM(SUM(orders.amount) OVER (PARTITION BY orders.customer_id)) OVER ()" +``` + +**Expected:** rejected at compile time: + +```yaml +error: + code: E_NESTED_WINDOW + anchor: §6.10.4 + must_contain: ["nested window", "single window", "derived field"] +``` + +This matches every major SQL dialect (Snowflake, Databricks, Postgres, +BigQuery all reject nested windows). The diagnostic MUST suggest +factoring the inner window into a derived field. + +--- + +#### T-031 — Running-total window accepted (the canonical positive) + +**Anchors:** §6.10.1, §6.10.2 · D-030 (positive) +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Fields: + - orders.id + - orders.customer_id + - orders.amount + - "SUM(orders.amount) OVER (PARTITION BY orders.customer_id ORDER BY orders.id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total" +Where: + - "orders.customer_id IS NOT NULL" +Order By: + - "orders.customer_id ASC" + - "orders.id ASC" +``` + +**Expected:** + +| id | customer_id | amount | running_total | +|----:|------------:|-------:|--------------:| +| 101 | 1 | 100.00 | 100.00 | +| 102 | 1 | 50.00 | 150.00 | +| 103 | 2 | 200.00 | 200.00 | +| 104 | 3 | 75.00 | 75.00 | + +The compiled SQL MUST contain explicit `ROWS BETWEEN UNBOUNDED +PRECEDING AND CURRENT ROW` (the literal frame; not a synonym), and the +`OVER (...)` `ORDER BY` MUST include the `NULLS LAST` default +resolution. + +--- + +#### T-032 — `ROW_NUMBER` with outer `Where` filter (qualify-style pattern) + +**Anchors:** §6.10.1, §6.10.3 · D-030 (positive) +**Fixture:** F-PRELUDE. + +**Strategy:** the legal way to "filter by `ROW_NUMBER = 1`" is to +expose the window as a derived field and filter at a coarser grain, +**OR** to use the engine's `Qualify`-style mechanism (deferred — +§10). This test exercises the derived-field strategy. + +**Model addition:** +```yaml +fields: + - dataset: orders + name: order_rank_in_customer + expression: "ROW_NUMBER() OVER (PARTITION BY orders.customer_id ORDER BY orders.amount DESC, orders.id ASC)" +``` + +**Query:** +```yaml +Fields: + - orders.id + - orders.customer_id + - orders.amount + - orders.order_rank_in_customer +Where: + - "orders.order_rank_in_customer = 1" +``` + +**Expected:** the engine MUST compile the inner window in a CTE / inline +subquery and the outer `Where` MUST filter that subquery's result — +NEVER push the window inside the outer `WHERE`. The result is one row +per customer (their highest-amount order): + +| id | customer_id | amount | order_rank_in_customer | +|----:|------------:|-------:|-----------------------:| +| 101 | 1 | 100.00 | 1 | +| 103 | 2 | 200.00 | 1 | +| 104 | 3 | 75.00 | 1 | +| 105 | 99 | 200.00 | 1 | + +`Order By` was omitted; engines are not required to return rows in any +specific order, but the row *content* MUST match. + +--- + +#### T-033 — Composing onto a windowed metric is rejected + +**Anchors:** §6.10.5 · E_WINDOWED_METRIC_COMPOSITION +**Fixture:** F-PRELUDE. + +**Model addition:** +```yaml +metrics: + - name: orders.running_total_by_customer + expression: "SUM(orders.amount) OVER (PARTITION BY orders.customer_id ORDER BY orders.id)" + - name: orders.running_total_ratio + expression: "orders.running_total_by_customer / SUM(orders.amount)" # composition onto windowed metric +``` + +**Expected:** model load (or first reference to the second metric) MUST +fail: + +```yaml +error: + code: E_WINDOWED_METRIC_COMPOSITION + anchor: §6.10.5 + must_contain: ["window", "composition", "derived field", "§10"] +``` + +The diagnostic MUST acknowledge that the feature is deferred to §10 +(parameterised window composition) — not a permanent prohibition. + +--- + +#### T-034 — `GROUPS` frame mode rejected + +**Anchors:** §6.10.6 · D-032 +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Fields: + - orders.id + - "SUM(orders.amount) OVER (PARTITION BY orders.customer_id ORDER BY orders.amount GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW) AS recent_sum" +``` + +**Expected:** + +```yaml +error: + code: E_DEFERRED_FRAME_MODE + anchor: §6.10.6 + must_contain: ["GROUPS", "ROWS", "RANGE", "§10"] +``` + +Diagnostic MUST list the two supported alternatives (`ROWS` and `RANGE`) +and point at §10 as the future home of `GROUPS`. + +--- + +#### T-035 — Parameterised frame bound rejected + +**Anchors:** §6.10.6 · D-032 +**Fixture:** F-PRELUDE; query parameter `:n` declared. + +**Query:** +```yaml +Fields: + - orders.id + - "SUM(orders.amount) OVER (PARTITION BY orders.customer_id ORDER BY orders.id ROWS BETWEEN :n PRECEDING AND CURRENT ROW) AS recent_sum" +``` + +**Expected:** + +```yaml +error: + code: E_DEFERRED_FRAME_BOUND + anchor: §6.10.6 + must_contain: ["parameter", "literal integer", "UNBOUNDED", "§10"] +``` + +Diagnostic MUST mention the three accepted bound shapes (integer +literal, `UNBOUNDED`, `CURRENT ROW`) and point at §10. + +--- + +#### T-036 — Window over a `1 : N` fan-out is accepted (no implicit aggregation) + +**Anchors:** §6.10.1, §6.7 · D-022, D-030 (positive) +**Fixture:** F-PRELUDE. + +The interesting question is what happens when a window-aggregate is +defined at the customer-row grain but its argument lives at the orders +grain. Windows do NOT trigger implicit home-grain aggregation — they +require the user to be explicit about which grain the window runs over. +This test exercises the *correct* shape: the window is defined inside a +scalar query at orders grain (its native home), so no fan-out: + +**Query:** +```yaml +Fields: + - orders.id + - orders.customer_id + - "RANK() OVER (PARTITION BY orders.customer_id ORDER BY orders.amount DESC) AS rank_by_customer" +``` + +**Expected:** the query compiles at orders grain (the natural home of +the window). The result is one row per order with the customer-internal +rank: + +| id | customer_id | rank_by_customer | +|----:|------------:|-----------------:| +| 101 | 1 | 1 | ← amount 100 > amount 50 +| 102 | 1 | 2 | +| 103 | 2 | 1 | +| 104 | 3 | 1 | +| 105 | 99 | 1 | + +**Companion negative — T-036b:** trying to use the same window +expression as a *field on customers* (i.e., declaring +`customers.rank_in_some_customer = RANK() OVER (...)`) MUST raise +`E_AMBIGUOUS_MEASURE_GRAIN` (D-025) — the field's home grain is +underspecified. + +--- + +### 4.J BI-shape coverage + +These are the four "shape archetypes" every real BI semantic layer +must handle. Without explicit tests, regressions can ship silently. + +#### T-043 — Multi-hop `N : 1` enrichment chain + +**Anchors:** §6.4 (cardinality inference), §6.6 (join defaults) +**Fixture:** F-CHAIN — a three-hop `N : 1` chain +(`order_lines → orders → customers → segments`): + +```yaml +datasets: + - { name: segments, primary_key: [id], fields: [name] } + - { name: customers, primary_key: [id], fields: [segment_id, region] } + - { name: orders, primary_key: [id], fields: [customer_id, amount, status] } + - { name: order_lines, primary_key: [id], fields: [order_id, sku, qty, price] } + +relationships: + - { from: customers, from_keys: [segment_id], to: segments, to_keys: [id], cardinality: 1:1 → many:1 } + - { from: orders, from_keys: [customer_id], to: customers, to_keys: [id], cardinality: N:1 } + - { from: order_lines, from_keys: [order_id], to: orders, to_keys: [id], cardinality: N:1 } +``` + +**Query:** +```yaml +Dimensions: [segments.name] +Measures: [SUM(order_lines.qty * order_lines.price) AS revenue] +``` + +**Expected:** the planner finds a path +`order_lines → orders → customers → segments` (three `N : 1` hops). +Cardinality inference proves no fan-out (every hop is many-to-one), so +the SQL is a chain of `LEFT JOIN`s with no de-duplication step. + +Compiled SQL contract: + +```yaml +sql_contract: + required_substrings: + - "FROM .*order_lines" + - "LEFT JOIN .*orders" + - "LEFT JOIN .*customers" + - "LEFT JOIN .*segments" + forbidden_substrings: + - "DISTINCT" # no de-duplication needed; chain is N:1 all the way +``` + +Wrong join direction (e.g., `customers LEFT JOIN order_lines`) would +fan out; the test fails if the row count differs from +`COUNT(DISTINCT segments.id) + 1` (for orphan segments). + +--- + +#### T-044 — Composite primary key / foreign key + +**Anchors:** D-009 (deferred-key rejection — composite keys ARE supported +in the foundation), §6.4 +**Fixture:** F-COMPOSITE — `inventory` keyed by `(store_id, sku)` with +a fact `sales` referencing the composite: + +```yaml +datasets: + - name: inventory + primary_key: [store_id, sku] + fields: [stock_level, reorder_point] + - name: sales + primary_key: [id] + fields: [store_id, sku, qty, sale_ts] + +relationships: + - from: sales + from_keys: [store_id, sku] + to: inventory + to_keys: [store_id, sku] + cardinality: N:1 +``` + +**Query:** +```yaml +Dimensions: [inventory.reorder_point] +Measures: [SUM(sales.qty) AS units_sold] +``` + +**Expected:** the engine generates a join `ON sales.store_id = +inventory.store_id AND sales.sku = inventory.sku`. The result is one +row per distinct `reorder_point` value. + +Compiled SQL contract: + +```yaml +sql_contract: + required_substrings: + - "ON .*store_id .* AND .*sku" # both key components in ON clause + forbidden_substrings: + - "ON .*store_id\\s+\\)" # only one key component + - "ON .*sku\\s+\\)" +``` + +An engine that uses only one key component fails this test (it would +produce row duplication if any `(store_id, sku)` collision exists, or +silent NULLs). + +--- + +#### T-045 — Two-measure stitch with bridge resolution + +**Anchors:** §6.8 (M:N), §6.8.2 (stitch) · D-026 +**Fixture:** F-BRIDGE — `customers ⇌ orders` with `customers ⇌ +returns` and a separate `customers ⇌ campaigns` many-to-many via +`customer_campaigns` bridge. + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: + - SUM(orders.amount) AS revenue + - COUNT(DISTINCT campaigns.id) AS active_campaign_count +``` + +**Expected:** the planner must: + +1. Aggregate `orders` to customer grain (per-customer revenue). +2. Resolve the M:N path `customers → customer_campaigns → campaigns` + with bridge de-duplication per D-026 (count each campaign at most + once per customer). +3. Aggregate each branch to region grain. +4. `FULL OUTER` stitch on region (multi-measure ⇒ §6.6 row 3 / §6.2 + step 7). + +Result: every region present in *either* branch's join path appears +(the union of "regions reached by an order" and "regions reached by +a campaign-association"). A region with neither order nor campaign +activity does NOT appear — same Plan A logic as T-001, just applied +per-branch and then unioned by the FULL OUTER. Missing-side cells +follow standard SQL per §6.11: `revenue` (a `SUM`) returns `NULL` +when no orders contribute to the region; `active_campaign_count` (a +`COUNT DISTINCT`) returns `0` when no campaigns contribute. The +differing empty-set values across the two measure types is the +*point* of the test: an engine that returns the same value for both +(e.g., both `0` or both `NULL`) is doing a Foundation-level rewrite +the spec forbids. + +The CRITICAL invariant: `active_campaign_count` MUST NOT be inflated by +the M:N fan-out. An engine that joins everything first then groups +will inflate the count and fail; the engine MUST aggregate each branch +independently. + +```yaml +sql_contract: + required_substrings: + - "WITH .*orders_branch" # or two named CTEs + - "FULL OUTER JOIN" +``` + +--- + +#### T-046 — Reflexive (self-referential) relationship + +**Anchors:** §6.4 (cardinality), §6.6 (join defaults), §6.9 (path +resolution with roles) +**Fixture:** F-REFLEXIVE — `employees` references itself via +`manager_id`: + +```yaml +datasets: + - name: employees + primary_key: [id] + fields: [name, manager_id, region] + +relationships: + - name: reports_to + from: employees + from_keys: [manager_id] + to: employees + to_keys: [id] + cardinality: N:1 + role: manager # qualifier — the "to" side acts as `manager` +``` + +**Query:** +```yaml +Dimensions: + - "employees{role=manager}.region AS manager_region" +Measures: + - COUNT(employees.id) AS direct_report_count +``` + +**Expected:** the planner generates `employees AS employee LEFT JOIN +employees AS manager ON employee.manager_id = manager.id` and groups by +`manager.region`. Each employee is counted exactly once. + +```yaml +sql_contract: + required_substrings: + - "FROM .*employees" + - "LEFT JOIN .*employees" # self-join with distinct aliases +``` + +An engine that fails to disambiguate the two `employees` references +either errors with `E_AMBIGUOUS_PATH` (if the role qualifier is not +honoured) or silently produces wrong counts. + +--- + +### 4.K Boundary, empty, and NULL cases + +#### T-025 — Scalar query with two unrelated row-level facts is rejected + +**Anchors:** §5.1.2, Appendix B · D-023 (extended) +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Fields: + - orders.amount + - returns.amount + - customers.region +``` + +**Expected:** + +```yaml +error: + code: E_FAN_OUT_IN_SCALAR_QUERY + anchor: §5.1.2 + must_contain: ["orders", "returns", "aggregation", "single home"] +``` + +`orders` and `returns` are independent facts both on the many-side of +`customers`. A row-level scalar query referencing fields from both +would have to either fan out (replicate orders rows per return row, or +vice versa) or pick a single home — neither is unambiguous. The error +MUST suggest two resolutions: convert to an aggregation query, or pick +a single fact as the home. (A semi-join filter form is deferred to a +future proposal — see the §6.8 deferred-feature note.) + +--- + +#### T-040 — `E_NO_PATH` when no relationship connects two datasets + +**Anchors:** §6.9 · D-018b +**Fixture:** F-NOPATH — two datasets with no declared relationship +between them: + +```yaml +datasets: + - name: customers + primary_key: [id] + fields: [name, region] + - name: weather_observations # no relationship to customers + primary_key: [id] + fields: [station_id, observation_date, temperature] +``` + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: [AVG(weather_observations.temperature) AS avg_temp] +``` + +**Expected:** + +```yaml +error: + code: E_NO_PATH + anchor: §6.9 + must_contain: ["customers", "weather_observations", "no path", "relationship"] +``` + +Diagnostic MUST name both endpoint datasets and explicitly state that +no declared `relationships` entry connects them. + +--- + +#### T-041 — Reserved-name rejection + +**Anchors:** §3 reserved names · D-019 +**Fixture:** F-PRELUDE with a deliberately illegal addition. + +**Model addition (illegal):** +```yaml +fields: + - dataset: customers + name: select # reserved SQL keyword + expression: "'placeholder'" +``` + +**Expected:** model load fails: + +```yaml +error: + code: E_RESERVED_NAME + anchor: §3 + must_contain: ["select", "reserved", "rename"] +``` + +Diagnostic MUST quote the offending name verbatim. + +--- + +#### T-042 — Deferred-key rejection + +**Anchors:** §10 deferred keys · D-009 +**Fixture:** F-PRELUDE with a deliberately illegal addition. + +**Model addition (illegal — `grain: FIXED` is deferred to §10):** +```yaml +metrics: + - name: orders.revenue_at_orders_grain + expression: "SUM(orders.amount)" + grain: FIXED # deferred key +``` + +**Expected:** + +```yaml +error: + code: E_DEFERRED_KEY_REJECTED + anchor: §10 + must_contain: ["grain", "deferred", "§10"] +``` + +The diagnostic MUST identify which deferred key was used (`grain`) and +cite §10. This is the canonical instance; the full coverage of D-009 +requires one instance per deferred key — see §6 below. + +--- + +#### T-047 — Empty-set aggregate behaviour: `SUM` → `NULL`, `COUNT` → `0` (D-033) + +**Anchors:** §6.11 · D-033 +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: + - SUM(orders.amount) AS revenue + - COUNT(orders.id) AS order_count + - COUNT(*) AS row_count +``` + +**Expected (three measures over one fact — still a single fact source, +so Plan A applies; NORTH does not appear):** + +| region | revenue | order_count | row_count | +|:-------|--------:|------------:|----------:| +| EAST | 350.00 | 3 | 3 | +| WEST | 75.00 | 1 | 1 | +| NULL | 30.00 | 1 | 1 | + +> Why no NORTH? All three measures are sourced from `orders` — the +> measure dataset-set is the same for all of them, so step 6 of the +> §6.2 evaluation algorithm produces one combined row set per the +> single-measure Plan A rule. NORTH (customer 4 with no orders) has +> no `orders` rows pulling it into the result. If the user wanted +> NORTH to appear, they would add a measure sourced from a different +> dataset that *does* have data for customer 4 — e.g. +> `COUNT(customers.id) AS customer_count` — which would convert the +> shape into a true multi-measure-stitch query and the FULL OUTER +> rule of §6.6 row 3 would preserve NORTH via the customers branch. + +This pins the standard-SQL split codified by D-033: the `COUNT` family +(`COUNT(*)`, `COUNT(x)`, `COUNT(DISTINCT x)`) returns `0` on an empty +set because it counts rows; every other aggregate (`SUM`, `AVG`, +`MIN`, `MAX`, `MEDIAN`, percentiles, `STDDEV`, `VARIANCE`) returns +`NULL` because there is no defined result. + +**Companion T-053** below covers the `MIN`/`MAX`/`AVG` branch +(non-distributive, also `NULL` over empty). + +--- + +#### T-048 — NULL foreign key in `1 : N` enrichment + +**Anchors:** §6.6 (LEFT default), §6.11 +**Fixture:** F-PRELUDE-NULLFK — F-PRELUDE with one extra order row: + +```sql +-- order 106: amount 80, status completed, customer_id NULL +``` + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: [SUM(orders.amount) AS revenue] +``` + +**Expected:** + +| region | revenue | +|:-------|--------:| +| EAST | 350.00 | +| WEST | 75.00 | +| NORTH | 0 | +| NULL | 110.00 | ← orphan order 105 (30) + NULL-FK order 106 (80) — both bucketed under `region = NULL` + +The result demonstrates that a NULL foreign key behaves the same as an +unmatched key (orphan row 105 with `customer_id = 99` not in +`customers`): both produce a `NULL`-region bucket. An engine that +silently drops NULL-FK rows during the join would understate the +NULL-bucket by 80 and fail. + +--- + +#### T-049 — NULL in a dimension column + +**Anchors:** §5.1.1 (Result grain), §6.6 +**Fixture:** F-PRELUDE-NULLDIM — F-PRELUDE with customer 4's `region` +set to `NULL` (overriding NORTH). + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: [SUM(orders.amount) AS revenue, COUNT(customers.id) AS customer_count] +``` + +**Expected:** + +| region | revenue | customer_count | +|:-------|--------:|---------------:| +| EAST | 350.00 | 2 | +| WEST | 75.00 | 1 | +| NULL | 30.00 | 1 | ← customer 4 (NULL region) + orphan order 105's NULL bucket merge into one row + +The two distinct sources of NULL (the customer with NULL region + the +orphan order with unmatched customer) collapse into a *single* output +row. SQL `GROUP BY` treats all NULLs as one group; the Foundation +preserves this behaviour. An engine that emits two separate NULL rows +fails this test. + +--- + +#### T-050 — Empty aggregation query is rejected + +**Anchors:** §5.1.1 (At least one of `Dimensions` or `Measures` MUST be +non-empty), §6.2 normative evaluation step 1 · `E_EMPTY_AGGREGATION_QUERY` +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Dimensions: [] +Measures: [] +``` + +**Expected:** + +```yaml +error: + code: E_EMPTY_AGGREGATION_QUERY + anchor: §5.1.1 + must_contain: ["Dimensions", "Measures", "at least one"] +``` + +A query with neither dimensions nor measures has no shape at all — +this is distinct from "no dimensions" (which is legal and produces a +grand total) or "no measures" (which is the `Dimensions`-only listing +shape, also legal). The diagnostic MUST make this distinction explicit. + +--- + +#### T-051 — Empty scalar query is rejected + +**Anchors:** §5.1.2, §6.2 normative evaluation step 1 · `E_EMPTY_SCALAR_QUERY` +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Fields: [] +``` + +**Expected:** + +```yaml +error: + code: E_EMPTY_SCALAR_QUERY + anchor: §5.1.2 + must_contain: ["Fields", "at least one", "scalar"] +``` + +A scalar query with no fields has no projection; it cannot be compiled +to any SQL. The diagnostic MUST mention that `Fields` requires at least +one entry. + +--- + +#### T-053 — `AVG` / `MIN` / `MAX` over zero matching rows return `NULL` + +**Anchors:** §6.11 · D-033 (non-`COUNT` branch) +**Fixture:** F-PRELUDE. + +**Query:** +```yaml +Dimensions: [customers.region] +Measures: + - AVG(orders.amount) AS avg_amount + - MIN(orders.amount) AS min_amount + - MAX(orders.amount) AS max_amount +``` + +**Expected (three measures over one fact — Plan A; NORTH absent):** + +| region | avg_amount | min_amount | max_amount | +|:-------|-----------:|-----------:|-----------:| +| EAST | 116.67 | 50.00 | 200.00 | +| WEST | 75.00 | 75.00 | 75.00 | +| NULL | 30.00 | 30.00 | 30.00 | + +Combined with T-047 (`SUM` → `NULL` on empty, `COUNT` → `0`), this +fully pins D-033: only the `COUNT` family returns `0` over an empty +input. The NORTH row is absent for the same reason as in T-001 / T-047 +— a single-fact aggregation query follows Plan A. + +--- + +## 5. Coverage map + +This map is the authoritative cross-reference. Every `D-NNN` decision +in the spec's Appendix B that has observable runtime behaviour MUST +appear here with at least one covering `T-NNN`. + +| D-NNN | Decision | Covered by | +|:------|:---|:---| +| D-001 | Aggregation-query cardinality | T-001, T-050 (empty rejected) | +| D-003 | Implicit home-grain aggregation in fields | T-004, T-024 | +| D-004 | Default join types | T-001 (LEFT), T-010 (LEFT, indirect), T-011 (FULL OUTER), T-012 (CROSS), T-043 (multi-hop N:1 chain) | +| D-005 | Predicate routing by resolved expression shape | T-007, T-024 (e: boolean home-grain scalar in `Where`) | +| D-006 | Bare name → global namespace | T-020 | +| D-007 | M:N MUST be a safe rewrite | T-013, T-014 | +| D-009 | Deferred-key rejection | T-042 (canonical instance — `grain: FIXED`) | +| D-010 | Query shape determined by clauses | T-002 | +| D-011 | Bare metric in `Fields` rejected | T-003 | +| D-012 | Predicate-shape errors | T-007 (in WHERE), T-008 (in HAVING), T-009 (mixed) | +| D-014 | Determinism | §1.4 (applied to every test); explicit in T-026, T-052 (compiled SQL byte-stable) | +| D-015 | Three home-grain compilation strategies equivalent | T-004 | +| D-016 | `COUNT(*)` works | T-006, T-047 | +| ~~D-017~~ | **Deferred** — moved to a separate EXISTS_IN proposal | ~~T-018~~ (deferred slot) | +| D-018 | Path-resolution errors | T-019 (ambiguous), T-040 (E_NO_PATH) | +| D-019 | Reserved names rejected | T-041 | +| D-020 | Single-step AND nested cross-grain aggregation accepted | **T-005a (single-step SUM), T-005b (single-step AVG, heavy-weighted), T-005c (nested AVG, unweighted), T-005d (single-step SUM ≡ nested SUM), T-005e (single-step COUNT DISTINCT), T-017** | +| D-022 | Non-decomposable aggregate over fan-out | T-021, T-005e (caveat: D-022 fires on M:N decomposition, not plain 1:N) | +| D-023 | Fan-out in scalar query | T-022, T-025 (multiple unrelated row-level facts) | +| D-024 | Unaggregated finer-grain reference | T-023 | +| **D-026** | **Bridge resolution de-duplicates per `(fact, group)`** | **T-015 (flagship), T-045 (two-measure bridge)** | +| **D-027** | **Bare non-distributive aggregate over M:N rejected** | **T-016, T-017 (positive)** | +| D-029 | `Order By` defaults to `NULLS LAST` (outer + `OVER`) | T-026 (outer), T-027 (`OVER`) | +| D-030 | Windows: legal placements, `E_WINDOW_IN_WHERE` | T-029 (rejected), T-031 (running total positive), T-032 (qualify pattern), T-036 (window over `1:N`) | +| D-031 | Nested windows rejected | T-030 | +| D-032 | `ROWS` / `RANGE` accepted; `GROUPS` and parameterised bounds deferred | T-031 (positive `ROWS`), T-034 (`GROUPS` rejected), T-035 (param frame bound rejected) | +| **D-033** | **Empty-set aggregate behaviour follows standard SQL: `COUNT` family → `0`, everything else → `NULL`** | **T-011 (stitch missing-side `SUM` = `NULL`, the canonical test now that single-measure shapes drop missing-dim rows entirely per §1.6); T-045 (mixed `SUM`/`COUNT` branches); T-047, T-053 (one-fact multi-measure — `COUNT` = `0` for present dim values; missing dim values themselves are dropped per Plan A)** | +| E_WINDOWED_METRIC_COMPOSITION | Windowed metrics cannot be composed | T-033 | + +### 5.1 BI-shape coverage + +Independent of D-NNN coverage, these are the shapes every production +BI tool must handle. The catalog covers them as follows: + +| Shape | Covered by | +|:---|:---| +| Single-table fact | T-006, T-021, T-031, T-032, T-036 | +| `N : 1` enrichment (one hop) — single-measure (Plan A) | T-001, T-002, T-010 | +| `N : 1` enrichment (multi-hop chain) | T-043 | +| Composite primary/foreign key | T-044 | +| `1 : N` cross-grain aggregation (single-step) | T-005a, T-005b, T-005e, T-017 | +| `1 : N` cross-grain aggregation (nested) | T-005c, T-005d | +| `M : N` via bridge | T-013, T-014, T-015, T-016, T-017, T-045 | +| Multi-measure stitch (`FULL OUTER`, §6.6 row 3) | T-011, T-045 | +| Semi-join filter | ~~T-018~~ (deferred to future EXISTS_IN proposal) | +| Reflexive (self-referential) | T-046 | +| Windowed metrics | T-027, T-031, T-032, T-036 | +| Empty / boundary queries | T-050, T-051, T-052 | +| NULL semantics | T-011 (stitch missing-side), T-047, T-048, T-049, T-053 | + +## 6. Decisions awaiting coverage + +The following `D-NNN` entries do not yet have a `T-NNN` vector. Adding +one is the natural next sprint of this catalog. + +| D-NNN | Why deferred | +|:------|:---| +| D-002 | Multi-measure shared-grain composition — T-011 and T-045 cover the FULL-OUTER stitch path; D-002's "all measures resolve at the query's grain" claim is implicit in both. A direct test pinning the empty-dimensions / two-fact-no-dims case (Snowflake errata #4) is the natural next addition. | +| D-008 | No `UNSAFE`; no per-metric `joins.type` override. Test is a parser-level rejection — better placed in a parser-conformance suite than this row-set catalog. | +| D-009 | T-042 is the canonical instance; full coverage requires one test per deferred key (six total). Mechanically generated; out of scope for this seed but trivial follow-up. | +| D-013 | Same-named expressions reachable via qualification — needs a fixture with a deliberate name collision; planned for the next pass. | +| D-021 | Structured `expression` slot — parser/codegen-conformance. | +| D-025 | `E_AMBIGUOUS_MEASURE_GRAIN` catch-all — the D-025 enumeration lists the known cases; T-036b covers one (windowed field with no declared home grain). Remaining enumerated cases are mechanical follow-ups. | + +When this list shrinks to empty, every `D-NNN` in Appendix B has an +executable witness in this file and the Foundation Conformance Suite is +self-contained. diff --git a/compliance/foundation-v0.1/README.md b/compliance/foundation-v0.1/README.md new file mode 100644 index 0000000..4d9151f --- /dev/null +++ b/compliance/foundation-v0.1/README.md @@ -0,0 +1,134 @@ +# OSI Compliance Test Suite — Foundation (`osi_version: "0.1"`) + +**Targets only the Foundation proposal at +[`../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) +(`osi_version: "0.1"`) and its companion +[`SQL_EXPRESSION_SUBSET.md`](../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md) +(`OSI_SQL_2026` default dialect).** + +The Foundation is the deliberately narrow first-cut of OSI semantics: +two query shapes, implicit home-grain aggregation, window functions, +and a small deferred-features list — pinned by 33 numbered Conformance +Decisions (D-001 … D-033) in Appendix B of the Foundation spec, and +the error code index in Appendix C. + +Features outside the Foundation surface (LOD grain modes, filter +context, named filters, semi-join filter form, per-metric joins, +non-equijoins, etc.) are listed in §10 of the proposal and exercised +here **only** as negative tests that expect +`E_DEFERRED_KEY_REJECTED` (D-009). + +The runner harness lives at [`../harness/`](../harness/) and is +shared with every future per-version suite under `compliance/`. + +## Layout + +``` +compliance/foundation-v0.1/ + README.md # this file + SPEC.md # the specs this suite targets (Foundation v0.1) + pyproject.toml # editable install; depends on ../harness (osi_compliance_harness) + conformance.yaml # test conformance levels; "foundation_v0_1" = required + proposals.yaml # mirrors §10 of the Foundation spec; every deferred is a proposal + decisions.yaml # D-001 … D-033 with anchor + status (must_pass | xfail) + adapters/ + osi_python_adapter.py # delegates to impl/python/conformance/adapter.py + datasets/ + f_prelude/ # mirrors DATA_TESTS.md §3.1 + f_bridge/ # §3.2 — actor↔movie M:N + f_bridge_none/ # §3.3 — M:N with no bridge, no shared dim + f_ambig/ # §3.4 — two paths between the same datasets + f_nopath/ # §3.5 — disconnected datasets + tests/ + query_shape/ # T-001..T-003 (D-001, D-010, D-011) + scalar_query/ # T-002, T-012, T-023 — Fields + scalar grain + field_metric_grain/ # T-004..T-005x (D-003, D-015) + cross_grain/ # T-005, T-020, T-024 (D-020, D-024) + nested_aggregates/ # T-027 (D-022, D-027, §4.5.1) + bridge/ # T-015 (D-026 flagship — actor↔movie) + joins_default/ # T-006, T-011, T-045, T-047, T-053 (D-001, D-004) + predicate_routing/ # D-005, D-012 — Where vs Having shape errors + namespace/ # D-006, D-018, D-019 + windows/ # D-028..D-032 + null_ordering/ # D-029 + empty_inputs/ # D-033 + deferred/ # one negative test per E_DEFERRED_KEY_REJECTED case + error_taxonomy/ # negative cases for the rest of Appendix C + results/ + REPORT.md # curated baseline (tracked) + latest/ # default runner --output (gitignored) + / # per-adapter runs from the Makefile (gitignored) +``` + +## Quick start + +```bash +# From the repo root +pip install -e compliance/harness +pip install -e compliance/foundation-v0.1 +pip install -e impl/python + +cd compliance/foundation-v0.1 + +# Run the full suite against the bundled osi_python adapter. +# Per-run artifacts (summary.md, failures.csv) land in results/latest/ +# by default; the curated results/REPORT.md baseline is never touched. +python -m harness.runner \ + --adapter adapters/osi_python_adapter.py \ + --tests tests/ \ + --datasets datasets/ + +# Run a single area — choose a sibling subdirectory of results/ so it +# doesn't clobber results/latest/ +python -m harness.runner \ + --adapter adapters/osi_python_adapter.py \ + --tests tests/bridge/ \ + --datasets datasets/ \ + --output results/bridge + +# List discovered tests without running +python -m harness.runner --list --tests tests/ +``` + +The harness ships under [`../harness/`](../harness/) and is installed +as the `osi_compliance_harness` package — every per-version suite under +`compliance/` shares this one runner / reporter / DB manager. + +## Per-test layout + +Each test is a folder containing: + +| File | Purpose | +|:---|:---| +| `metadata.yaml` | `id: T-NNN`, `decision: D-NNN`, `spec_refs`, `required_features`, `expected_error_code`, `xfail_reason` (if applicable). | +| `model.yaml` | The semantic model (typically a thin wrapper around a fixture from `datasets/f_*`). | +| `query.json` | The semantic query, in the new two-shape format (`Aggregation` clauses or `Fields` for scalar). | +| `gold.sql` | A hand-written reference SQL query the harness executes against the fixture data to produce the expected row multiset. Treated by the harness as a row oracle, not as a SQL-string comparison — D-014 is per-engine, not cross-engine. | + +Tests assert on observable behaviour only: + +- `expected_error_code: E_` ⇒ adapter must surface that code in + stderr (substring match — see `compliance/harness/src/harness/runner.py`). +- The harness runs both `gold.sql` and the adapter's emitted SQL against + the shared fixture and compares the resulting row multisets + (order-insensitive unless the query has an `Order By`). + +This means a `gold.sql` is a *witness* of the answer's shape and the +fixture data; the harness never compares SQL strings byte-for-byte +(per D-014, that's a per-engine concern). + +## Decision coverage + +Every `D-NNN` row in Appendix B of the Foundation spec MUST have at +least one runnable case here. The mapping lives in `decisions.yaml`, +and the runner emits `results/decisions_coverage.md` after every run. + +## See also + +- `SPEC.md` — what the suite targets and why. +- `decisions.yaml` — D-001..D-033 status board. +- `proposals.yaml` — §10 deferred-features registry. +- `../../impl/python/conformance/adapter.py` — the upstream adapter our + adapter delegates to. +- `../ADAPTER_INTERFACE.md` — the CLI contract every + adapter satisfies. diff --git a/compliance/foundation-v0.1/SPEC.md b/compliance/foundation-v0.1/SPEC.md new file mode 100644 index 0000000..9ac379d --- /dev/null +++ b/compliance/foundation-v0.1/SPEC.md @@ -0,0 +1,71 @@ +# OSI Compliance Test Suite — Spec Coverage + +**Targets:** Foundation `osi_version: "0.1"`. Authoritative spec +documents: + +- [`../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) + — the semantic, query, join, M:N, and window contracts; Appendix B + (`D-001` … `D-033`) and Appendix C (error code index) are the + specific anchors this suite exercises. +- [`../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md`](../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md) + — the `OSI_SQL_2026` default expression dialect. +- [`DATA_TESTS.md`](DATA_TESTS.md) + — the normative test catalog (`T-001` … `T-NNN`) this suite encodes + as runnable cases. + +This suite is the runnable witness layer for the spec. Every +conformance decision in Appendix B has at least one `T-NNN` test here; +every error code in Appendix C has at least one negative test here. + +## What this suite does NOT cover + +- OSI features outside the Foundation surface (LOD grain modes, filter + context, named filters, semi-join filter form, per-metric + `joins.{type, using_relationships}`, non-equijoins, etc.). These + features are deferred per §10 of the Foundation proposal and are + exercised here only as negative cases that expect + `E_DEFERRED_KEY_REJECTED` (D-009). Future per-version compliance + suites under `compliance/` will cover them positively when their + proposals land. +- Engine-internal plan shape (CTE count, join order, alias names, + generated SQL string). The Foundation defines per-engine determinism + only (D-014); cross-engine SQL determinism is not required, so tests + assert on rows or `error.code` only. +- Performance characteristics. The suite is a correctness signal; any + performance regression checks live in the implementation's own + benchmark harness. + +## Compliance levels + +Defined in [`conformance.yaml`](conformance.yaml): + +| Level | Description | +|:---|:---| +| **`foundation_v0_1`** | Required for every Foundation-claiming engine. Every D-NNN in Appendix B must produce the expected outcome. | +| **`foundation_v0_1_strict`** | Adds determinism witnesses (D-014, D-029) — same `(model, query, dialect)` produces byte-identical SQL across runs. Optional. | + +Cross-engine portability is observable behaviour (rows / error codes), +not SQL text. `foundation_v0_1_strict` is per-engine determinism. + +## Decision coverage + +The mapping `D-NNN ↔ T-NNN` lives in [`decisions.yaml`](decisions.yaml). +The runner emits `results/decisions_coverage.md` after every run so +gaps are visible in PR review. + +## Adapter contract + +Adapters live under [`adapters/`](adapters/). The CLI contract is the +one published at +[`../ADAPTER_INTERFACE.md`](../ADAPTER_INTERFACE.md): + +``` + sql --model --query-file --dialect +``` + +stdout = generated SQL; stderr = `: ` on +failure; exit code = 0 on success, non-zero on error. The bundled +`osi_python_adapter.py` delegates to +[`../../impl/python/conformance/adapter.py`](../../impl/python/conformance/adapter.py) +so the existing CLI contract stays the source of truth — this suite +adds no new translation logic. diff --git a/compliance/foundation-v0.1/adapters/osi_python_adapter.py b/compliance/foundation-v0.1/adapters/osi_python_adapter.py new file mode 100755 index 0000000..178a02c --- /dev/null +++ b/compliance/foundation-v0.1/adapters/osi_python_adapter.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""OSI Foundation v0.1 compliance adapter for the ``osi`` Python implementation. + +A thin delegator to ``impl/python/conformance/adapter.py``. The upstream +adapter implements the CLI contract published in +[`../ADAPTER_INTERFACE.md`](../ADAPTER_INTERFACE.md) (``sql --model M +--query-file Q --dialect D``); this file re-exposes it from inside the +compliance suite so the harness can resolve it from +``adapters/osi_python_adapter.py`` without reaching out of the suite. + +We deliberately do NOT re-implement any conversion logic here. If a suite +test case needs a YAML / JSON shape the upstream adapter does not +understand, fix it in the upstream adapter (a single source of truth) — +never fork the conversion logic. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_ADAPTER_DIR = Path(__file__).resolve().parent +# adapters/ is at compliance/foundation-v0.1/adapters/. +# Walk up three levels (adapters → foundation-v0.1 → compliance → repo root), +# then descend into impl/python where the upstream adapter lives. +_REPO_ROOT = _ADAPTER_DIR.parent.parent.parent +_IMPL_PYTHON_ROOT = _REPO_ROOT / "impl" / "python" +_IMPL_PYTHON_CONFORMANCE = _IMPL_PYTHON_ROOT / "conformance" + +if not _IMPL_PYTHON_CONFORMANCE.exists(): + sys.stderr.write( + f"osi_python_adapter: cannot find upstream adapter at " + f"{_IMPL_PYTHON_CONFORMANCE}; check that impl/python/ is present " + f"at the OSI repo root.\n", + ) + sys.exit(2) + +# Make the upstream conformance package importable. The upstream adapter +# prepends impl/python/src to sys.path itself, so we only need to expose its +# parent directory here. +sys.path.insert(0, str(_IMPL_PYTHON_ROOT)) + +from conformance.adapter import main # noqa: E402 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/compliance/foundation-v0.1/conformance.yaml b/compliance/foundation-v0.1/conformance.yaml new file mode 100644 index 0000000..c5125ee --- /dev/null +++ b/compliance/foundation-v0.1/conformance.yaml @@ -0,0 +1,57 @@ +# OSI Compliance Test Suite — Conformance Levels (updated Foundation) +# +# This file pins which subsets of the suite an engine MUST pass to claim +# a particular Foundation conformance level. The Foundation spec lives +# at ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md (osi_version: 0.1). +# +# Each level lists the test areas that contribute to it; an +# implementation passes the level iff every must_pass case in those +# areas is green. +# +# Cross-engine SQL determinism is NOT required by Foundation v0.1 +# (per D-014); cross-engine portability is observable behaviour +# (rows or error code). The strict level adds per-engine SQL +# determinism witnesses on top of the basic foundation level. + +version: "0.1" + +levels: + foundation_v0_1: + description: > + Foundation v0.1 conformance. Required for any engine that claims + to implement the updated OSI Foundation. Every D-NNN row in + Appendix B of Proposed_OSI_Semantics.md must produce the + expected outcome (row set or error code). + pass_threshold: 100% + areas: + query_shape: all # D-001, D-010, D-011 + scalar_query: all # D-010, D-011, D-023 + field_metric_grain: all # D-003, D-015 + cross_grain: all # D-020, D-024 + nested_aggregates: all # D-022, D-027, §4.5.1 + bridge: all # D-026 flagship + joins_default: all # D-001, D-004, D-008 + predicate_routing: all # D-005, D-012 + namespace: all # D-006, D-018, D-019 + windows: all # D-028, D-030, D-031, D-032 + null_ordering: all # D-029 + empty_inputs: all # D-033 + deferred: all # D-009, every E_DEFERRED_KEY_REJECTED case + error_taxonomy: all # remaining Appendix C codes (E_PRIMARY_KEY_REQUIRED, etc.) + + foundation_v0_1_strict: + description: > + Foundation v0.1 with per-engine SQL determinism witnesses + (D-014). Optional. Adds: same (model, query, dialect) compiled + twice on the same engine produces byte-identical SQL; outer + Order By and OVER ORDER BY emit explicit NULLS LAST per D-029. + pass_threshold: 100% + includes: [foundation_v0_1] + areas: + determinism: + all: all # D-014, D-029 SQL-text witnesses + +# Extension levels (e.g. foundation_v0_1_with_grain_modes) will be +# added when their feature proposals (§10 of the Foundation spec) +# are ratified. Each ratified proposal becomes a new level here that +# requires foundation_v0_1 plus the proposal's tests. diff --git a/compliance/foundation-v0.1/datasets/f_ambig/description.md b/compliance/foundation-v0.1/datasets/f_ambig/description.md new file mode 100644 index 0000000..6ba294b --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_ambig/description.md @@ -0,0 +1,7 @@ +# F-AMBIG + +Two-path fixture used by `E_AMBIGUOUS_PATH` tests (D-018). Both +`orders.placed_by_id` and `orders.fulfilled_by_id` reference +`users.id`; an aggregation `Dimensions: [users.region]; Measures: +[COUNT(orders.id)]` query has two equally-valid join paths and the +engine MUST refuse to silently pick one. diff --git a/compliance/foundation-v0.1/datasets/f_ambig/schema.sql b/compliance/foundation-v0.1/datasets/f_ambig/schema.sql new file mode 100644 index 0000000..ddee6c3 --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_ambig/schema.sql @@ -0,0 +1,27 @@ +-- F-AMBIG — two relationships between `orders` and `users`. +-- Source: ../../DATA_TESTS.md §3.4 +-- +-- Used by E_AMBIGUOUS_PATH (D-018) tests: orders carries both +-- placed_by_id and fulfilled_by_id, each FKing to users.id, so any +-- aggregate `Dimensions: [users.region]` query has two equally-valid +-- paths and the engine MUST refuse to silently pick one. + +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + region VARCHAR +); + +INSERT INTO users VALUES + (1, 'EAST'), + (2, 'WEST'); + +CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + placed_by_id INTEGER, + fulfilled_by_id INTEGER, + amount DECIMAL(10, 2) +); + +INSERT INTO orders VALUES + (301, 1, 2, 100.00), + (302, 2, 2, 50.00); diff --git a/compliance/foundation-v0.1/datasets/f_bridge/description.md b/compliance/foundation-v0.1/datasets/f_bridge/description.md new file mode 100644 index 0000000..2312dfe --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_bridge/description.md @@ -0,0 +1,20 @@ +# F-BRIDGE + +The `actors ↔ appearances ↔ movies` M:N fixture from +`DATA_TESTS.md §3.2`. Used by the T-015 flagship bridge-deduplication +test (D-026) and the T-022 / T-027 nested-aggregate / non-distributive +M:N tests. + +The data is small and intentionally tight: the only doubling-prone +case is `M10` (Action) being shared by two actors at height 170. That +makes the de-duplication assertion trivially auditable by reading the +schema. + +Witness numbers for D-026: + +- `SUM(movies.gross)` grouped by `actors.height`: + - 170 ⇒ 300.00 (M10=100 once + M11=200 once) + - 180 ⇒ 50.00 (M12=50) +- The naive `actors ⋈ appearances ⋈ movies` flat join gives + `170 ⇒ 400.00` (M10 counted twice via Alice and Bob). Any + Foundation-conformant engine MUST produce 300, not 400. diff --git a/compliance/foundation-v0.1/datasets/f_bridge/schema.sql b/compliance/foundation-v0.1/datasets/f_bridge/schema.sql new file mode 100644 index 0000000..13b61af --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_bridge/schema.sql @@ -0,0 +1,46 @@ +-- F-BRIDGE — actor↔movie M:N through the appearances bridge. +-- Source: ../../DATA_TESTS.md §3.2 +-- +-- This is the fixture for the flagship T-015 bridge-deduplication test +-- (D-026). Two actors at height 170 both appeared in M10 (Action), +-- which is the situation D-026 demands the engine de-duplicate. + +CREATE TABLE actors ( + actor_id INTEGER PRIMARY KEY, + name VARCHAR, + height INTEGER +); + +INSERT INTO actors VALUES + (1, 'Alice', 170), + (2, 'Bob', 170), + (3, 'Carol', 180); + +CREATE TABLE movies ( + movie_id INTEGER PRIMARY KEY, + title VARCHAR, + gross DECIMAL(10, 2) +); + +INSERT INTO movies VALUES + (10, 'Action', 100.00), + (11, 'Drama', 200.00), + (12, 'Comedy', 50.00); + +CREATE TABLE appearances ( + actor_id INTEGER, + movie_id INTEGER, + PRIMARY KEY (actor_id, movie_id) +); + +-- M10 (Action) has two actors at height 170 (Alice + Bob). The +-- naive flat-join SQL of actors, appearances, and movies grouped +-- by actors.height would double-count its gross (200) for the +-- height 170 group. D-026 requires the engine to materialize +-- distinct (movie_id, height) and produce 100 (the M10 gross only +-- once) plus 200 (M11 Drama) equals 300 instead. +INSERT INTO appearances VALUES + (1, 10), + (1, 11), + (2, 10), + (3, 12); diff --git a/compliance/foundation-v0.1/datasets/f_bridge_none/description.md b/compliance/foundation-v0.1/datasets/f_bridge_none/description.md new file mode 100644 index 0000000..64bd881 --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_bridge_none/description.md @@ -0,0 +1,6 @@ +# F-BRIDGE-NONE + +Same `actors` and `movies` data as F-BRIDGE, with NO bridge dataset +and NO relationship declared between them. Used to pin +`E3012_MN_NO_SAFE_REWRITE`: an aggregate referencing both sides MUST +fail closed. diff --git a/compliance/foundation-v0.1/datasets/f_bridge_none/schema.sql b/compliance/foundation-v0.1/datasets/f_bridge_none/schema.sql new file mode 100644 index 0000000..bbbbe57 --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_bridge_none/schema.sql @@ -0,0 +1,29 @@ +-- F-BRIDGE-NONE — variant of F-BRIDGE without the `appearances` bridge. +-- Source: ../../DATA_TESTS.md §3.3 +-- +-- Used by negative cases that pin E3012_MN_NO_SAFE_REWRITE: the model +-- declares no relationship between `actors` and `movies`, so any +-- query referencing both at once MUST fail closed (not silently +-- fabricate a join). + +CREATE TABLE actors ( + actor_id INTEGER PRIMARY KEY, + name VARCHAR, + height INTEGER +); + +INSERT INTO actors VALUES + (1, 'Alice', 170), + (2, 'Bob', 170), + (3, 'Carol', 180); + +CREATE TABLE movies ( + movie_id INTEGER PRIMARY KEY, + title VARCHAR, + gross DECIMAL(10, 2) +); + +INSERT INTO movies VALUES + (10, 'Action', 100.00), + (11, 'Drama', 200.00), + (12, 'Comedy', 50.00); diff --git a/compliance/foundation-v0.1/datasets/f_chain/description.md b/compliance/foundation-v0.1/datasets/f_chain/description.md new file mode 100644 index 0000000..2479ab6 --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_chain/description.md @@ -0,0 +1,11 @@ +# F-CHAIN — multi-hop N:1 enrichment chain + +`order_lines → orders → customers → segments` + +Used by: + +- `T-043` — multi-hop N:1 chain (D-004). + +Created in S-E to back the inline F-CHAIN model used by `T-043`, +which previously referenced tables that did not exist in any +shipped dataset (a baseline gap surfaced by S-17). diff --git a/compliance/foundation-v0.1/datasets/f_chain/schema.sql b/compliance/foundation-v0.1/datasets/f_chain/schema.sql new file mode 100644 index 0000000..c0fd236 --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_chain/schema.sql @@ -0,0 +1,52 @@ +-- F-CHAIN — multi-hop N:1 enrichment chain. +-- Source: introduced in S-E to back T-043 (D-004 multi-hop chain). + +CREATE TABLE segments ( + id INTEGER PRIMARY KEY, + name VARCHAR +); + +INSERT INTO segments VALUES + (1, 'retail'), + (2, 'wholesale'), + (3, 'partner'); + +CREATE TABLE customers ( + id INTEGER PRIMARY KEY, + segment_id INTEGER, + region VARCHAR +); + +INSERT INTO customers VALUES + (1, 1, 'EAST'), + (2, 1, 'WEST'), + (3, 2, 'EAST'), + (4, 3, 'WEST'); + +CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + customer_id INTEGER, + amount DECIMAL(10, 2), + status VARCHAR +); + +INSERT INTO orders VALUES + (1001, 1, 100.00, 'completed'), + (1002, 2, 200.00, 'completed'), + (1003, 3, 300.00, 'pending'), + (1004, 4, 50.00, 'completed'); + +CREATE TABLE order_lines ( + id INTEGER PRIMARY KEY, + order_id INTEGER, + sku VARCHAR, + qty INTEGER, + price DECIMAL(10, 2) +); + +INSERT INTO order_lines VALUES + (5001, 1001, 'A', 2, 25.00), + (5002, 1001, 'B', 1, 50.00), + (5003, 1002, 'A', 4, 25.00), + (5004, 1003, 'C', 3, 100.00), + (5005, 1004, 'B', 1, 50.00); diff --git a/compliance/foundation-v0.1/datasets/f_composite/description.md b/compliance/foundation-v0.1/datasets/f_composite/description.md new file mode 100644 index 0000000..50e17ba --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_composite/description.md @@ -0,0 +1,11 @@ +# F-COMPOSITE — composite-key relationship + +`sales -> inventory` joined on `(store_id, sku)`. + +Used by: + +- `T-044` — composite-key join (D-009). + +Created in S-E to back the inline F-COMPOSITE model used by +`T-044`, which previously referenced tables not in any shipped +dataset. diff --git a/compliance/foundation-v0.1/datasets/f_composite/schema.sql b/compliance/foundation-v0.1/datasets/f_composite/schema.sql new file mode 100644 index 0000000..4233bff --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_composite/schema.sql @@ -0,0 +1,31 @@ +-- F-COMPOSITE — composite-key relationship. +-- Source: introduced in S-E to back T-044 (D-009 composite-key join). + +CREATE TABLE inventory ( + store_id INTEGER, + sku VARCHAR, + stock_level INTEGER, + reorder_point INTEGER, + PRIMARY KEY (store_id, sku) +); + +INSERT INTO inventory VALUES + (1, 'A', 100, 20), + (1, 'B', 50, 10), + (2, 'A', 30, 15), + (2, 'C', 80, 25); + +CREATE TABLE sales ( + id INTEGER PRIMARY KEY, + store_id INTEGER, + sku VARCHAR, + qty INTEGER, + sale_ts TIMESTAMP +); + +INSERT INTO sales VALUES + (10, 1, 'A', 5, TIMESTAMP '2026-01-01 10:00:00'), + (11, 1, 'A', 3, TIMESTAMP '2026-01-02 11:00:00'), + (12, 1, 'B', 2, TIMESTAMP '2026-01-02 12:00:00'), + (13, 2, 'A', 4, TIMESTAMP '2026-01-03 09:00:00'), + (14, 2, 'C', 1, TIMESTAMP '2026-01-03 14:00:00'); diff --git a/compliance/foundation-v0.1/datasets/f_nopath/description.md b/compliance/foundation-v0.1/datasets/f_nopath/description.md new file mode 100644 index 0000000..be3c3dc --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_nopath/description.md @@ -0,0 +1,7 @@ +# F-NOPATH + +Two disconnected datasets with no relationships. Used by `E_NO_PATH` +(D-018) and `E3013_NO_STITCHING_DIMENSION` tests: a query that +references both `orders` and `inventory_movements` has no relationship +path and no shared dimension, so it MUST fail closed instead of +emitting a Cartesian product. diff --git a/compliance/foundation-v0.1/datasets/f_nopath/schema.sql b/compliance/foundation-v0.1/datasets/f_nopath/schema.sql new file mode 100644 index 0000000..a1165d7 --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_nopath/schema.sql @@ -0,0 +1,26 @@ +-- F-NOPATH — two disconnected datasets (no relationships). +-- Source: ../../DATA_TESTS.md §3.5 +-- +-- Used by E_NO_PATH (D-018) and E3013_NO_STITCHING_DIMENSION tests: +-- orders and inventory_movements share no key and have no declared +-- relationship, so any cross-dataset query MUST fail closed. + +CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + customer_id INTEGER, + amount DECIMAL(10, 2) +); + +INSERT INTO orders VALUES + (101, 1, 100.00), + (102, 1, 50.00); + +CREATE TABLE inventory_movements ( + movement_id INTEGER PRIMARY KEY, + warehouse_id VARCHAR, + quantity INTEGER +); + +INSERT INTO inventory_movements VALUES + (1, 'W-EAST', 10), + (2, 'W-WEST', 5); diff --git a/compliance/foundation-v0.1/datasets/f_prelude/description.md b/compliance/foundation-v0.1/datasets/f_prelude/description.md new file mode 100644 index 0000000..2e6baa6 --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_prelude/description.md @@ -0,0 +1,29 @@ +# F-PRELUDE + +The flagship Foundation fixture — a single-fact star (`customers ← +orders`, `customers ← returns`) with one orphan order (customer_id = +99) and one customer with no orders (id = 4). Every Foundation +semantic that exercises join defaults, NULL group keys, multi-fact +stitch, and basic aggregation runs against this fixture. + +Authoritative source: `../../DATA_TESTS.md §3.1`. + +The deliberate edge cases: + +- **Orphan order (105)**: tests Semantic 1 (`LEFT` enrichment surfaces + orphan orders under `region = NULL`). +- **Customer 4 with returns but no orders**: tests Semantic 3 (the + `NORTH` region appears in the multi-fact stitch even though it has + no orders). +- **Both `EAST` customers (1 and 2) have orders, customer 3 in `WEST` + has orders too**: gives the bridge / chasm tests a non-degenerate + cardinality. + +Aggregate witnesses: + +- `SUM(orders.amount)` total: 455.00 +- `SUM(orders.amount)` by region (LEFT enrichment): EAST=350.00, + WEST=75.00, NULL=30.00. +- `SUM(returns.amount)` by region: EAST=10.00, WEST=5.00, NORTH=15.00. +- `COUNT(orders.id)` total: 5; with `Where status='completed'`: 4. +- Distinct customers: 4. diff --git a/compliance/foundation-v0.1/datasets/f_prelude/schema.sql b/compliance/foundation-v0.1/datasets/f_prelude/schema.sql new file mode 100644 index 0000000..c4a8c3c --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_prelude/schema.sql @@ -0,0 +1,51 @@ +-- F-PRELUDE — single-fact star with multi-fact extension. +-- Source: ../../DATA_TESTS.md §3.1 +-- +-- Used by: T-001, T-002, T-003, T-004, T-005x, T-006, T-011, T-016, +-- T-021, T-029, T-033, and most query-shape / predicate-routing +-- / namespace cases. + +CREATE TABLE customers ( + id INTEGER PRIMARY KEY, + region VARCHAR, + segment VARCHAR +); + +INSERT INTO customers VALUES + (1, 'EAST', 'retail'), + (2, 'EAST', 'retail'), + (3, 'WEST', 'wholesale'), + (4, 'NORTH', 'retail'); + +CREATE TABLE orders ( + id INTEGER PRIMARY KEY, + customer_id INTEGER, + amount DECIMAL(10, 2), + status VARCHAR +); + +-- Note: order 105 is an orphan (customer_id = 99 is not in customers). +INSERT INTO orders VALUES + (101, 1, 100.00, 'completed'), + (102, 1, 50.00, 'completed'), + (103, 2, 200.00, 'pending'), + (104, 3, 75.00, 'completed'), + (105, 99, 30.00, 'completed'); + +CREATE TABLE returns ( + id INTEGER PRIMARY KEY, + customer_id INTEGER, + amount DECIMAL(10, 2) +); + +-- Note: customer 4 has a return but no orders — Semantic 3 case. +INSERT INTO returns VALUES + (201, 1, 10.00), + (202, 3, 5.00), + (203, 4, 15.00); + +CREATE TABLE premium_customers ( + id INTEGER PRIMARY KEY +); + +INSERT INTO premium_customers VALUES (1), (3); diff --git a/compliance/foundation-v0.1/datasets/f_reflexive/description.md b/compliance/foundation-v0.1/datasets/f_reflexive/description.md new file mode 100644 index 0000000..23c2fca --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_reflexive/description.md @@ -0,0 +1,11 @@ +# F-REFLEXIVE — self-referential employee hierarchy + +`employees(id, manager_id)` with `manager_id` referencing +`employees(id)`. + +Used by: + +- `T-046` — reflexive relationship (D-018 path traversal). + +Created in S-E to back the inline F-REFLEXIVE model used by +`T-046`. diff --git a/compliance/foundation-v0.1/datasets/f_reflexive/schema.sql b/compliance/foundation-v0.1/datasets/f_reflexive/schema.sql new file mode 100644 index 0000000..4b535ce --- /dev/null +++ b/compliance/foundation-v0.1/datasets/f_reflexive/schema.sql @@ -0,0 +1,17 @@ +-- F-REFLEXIVE — self-referential employee hierarchy. +-- Source: introduced in S-E to back T-046 (D-018 reflexive relationship). + +CREATE TABLE employees ( + id INTEGER PRIMARY KEY, + name VARCHAR, + manager_id INTEGER, + region VARCHAR +); + +INSERT INTO employees VALUES + (1, 'Alice', NULL, 'EAST'), + (2, 'Bob', 1, 'EAST'), + (3, 'Carol', 1, 'WEST'), + (4, 'Dave', 2, 'EAST'), + (5, 'Eve', 2, 'EAST'), + (6, 'Frank', 3, 'WEST'); diff --git a/compliance/foundation-v0.1/decisions.yaml b/compliance/foundation-v0.1/decisions.yaml new file mode 100644 index 0000000..a9af94e --- /dev/null +++ b/compliance/foundation-v0.1/decisions.yaml @@ -0,0 +1,261 @@ +# Foundation Conformance Decisions — runnable test mapping. +# +# Mirrors Appendix B of ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md. +# Every D-NNN listed there has at least one row here pinning it to a +# T-NNN test case under tests/. The runner emits +# results/decisions_coverage.md after every run so gaps are visible. +# +# Status: +# * must_pass — the engine MUST pass every T-NNN test pinned to this +# decision; failure is a Foundation conformance failure. +# * xfail — the test is shipped but expected to fail until the +# named sprint flips it; xfail_pinned_to identifies the sprint. + +version: "0.1" + +decisions: + + - id: D-001 + title: Aggregation result cardinality + multi-measure stitch on incompatible roots + spec_ref: "§5.1.1, §6.2, §6.6" + tests: + - tests/edge_cases/easy/t-061-orphan-fact-row-null-dim/ + - tests/query_shape/easy/t-001-aggregation-cardinality/ + status: must_pass + + - id: D-002 + title: All measures resolve at the query's grain (no per-aggregate native granularity leak) + spec_ref: §6.2 + tests: + # No active witness — Phase 4 review I7 backlog. + [] + status: must_pass + + - id: D-003 + title: Implicit home-grain aggregation in field expressions + spec_ref: §4.3.1 + tests: [] + status: must_pass + + - id: D-004 + title: "Default join shapes (LEFT for N:1; FULL OUTER stitch; CROSS JOIN of 1-row scalars)" + spec_ref: §6.6 + tests: + - tests/empty_inputs/moderate/t-048-null-foreign-key/ + - tests/joins_default/moderate/t-011-multi-fact-full-outer/ + - tests/namespace/hard/t-043-multi-hop-n1-chain/ + - tests/scalar_query/easy/t-012-scalar-grand-total/ + status: must_pass + + - id: D-005 + title: "Routing of predicates by resolved expression shape (no role: keyword)" + spec_ref: "§4.3, §6.3" + tests: + - tests/cross_grain/moderate/t-024-boolean-home-grain-scalar-in-where/ + - tests/query_shape/easy/t-028-where-and-list/ + status: must_pass + + - id: D-006 + title: Bare-name resolves to global namespace; dataset-scoped via dataset.field + spec_ref: §4.6 + tests: + - tests/namespace/easy/t-020-bare-name-not-found/ + status: must_pass + + - id: D-007 + title: "M:N traversals must produce a result equivalent to bridge or stitch; otherwise E3012" + spec_ref: §6.8 + tests: + - tests/bridge/hard/t-014-mn-no-bridge/ + - tests/joins_default/easy/t-013-no-stitching-dimension/ + status: must_pass + + - id: D-008 + title: No per-metric joins.type override at Foundation level (deferred) + spec_ref: "§6.6, §6.9" + tests: [] + status: must_pass + + - id: D-009 + title: Deferred relationship-level keys rejected with E_DEFERRED_KEY_REJECTED in default mode + spec_ref: §11 + tests: [] + status: must_pass + + - id: D-010 + title: Aggregation vs Scalar query shape — mixing rejected with E_MIXED_QUERY_SHAPE + spec_ref: §5.1 + tests: + - tests/error_taxonomy/easy/t-050-empty-aggregation-query/ + - tests/error_taxonomy/easy/t-051-empty-scalar-query/ + - tests/query_shape/easy/t-002-mixed-query-shape/ + status: must_pass + + - id: D-011 + title: Bare metric reference inside Fields rejected with E_AGGREGATE_IN_SCALAR_QUERY + spec_ref: §5.1.2 + tests: + - tests/scalar_query/easy/t-003-bare-metric-in-fields/ + status: must_pass + + - id: D-012 + title: Predicate-shape errors (Where vs Having) + spec_ref: §6.3 + tests: + - tests/predicate_routing/easy/t-007-aggregate-in-where/ + - tests/predicate_routing/easy/t-008-non-aggregate-in-having/ + - tests/predicate_routing/easy/t-009-mixed-predicate-level/ + status: must_pass + + - id: D-014 + title: Per-engine determinism — same (model, query, dialect) → byte-identical SQL + spec_ref: §11 + tests: + - tests/null_ordering/moderate/t-026-nulls-last-default/ + - tests/query_shape/easy/t-052-limit-without-order/ + status: must_pass + + - id: D-016 + title: COUNT(*) is required and counts rows of the home dataset / query grain + spec_ref: §7 + tests: + - tests/query_shape/easy/t-006-count-star/ + status: must_pass + + - id: D-018 + title: Path resolution — unique path used; multiple ⇒ E_AMBIGUOUS_PATH; none ⇒ E_NO_PATH + spec_ref: §6.9 + tests: + - tests/namespace/easy/t-040-no-path-error/ + - tests/namespace/hard/t-044-composite-key-join/ + - tests/namespace/hard/t-046-reflexive-relationship/ + - tests/namespace/moderate/t-019-ambiguous-path/ + status: must_pass + + - id: D-019 + title: Reserved names (GRAIN, FILTER, QUERY_FILTER) cannot be used as user identifiers + spec_ref: §4.6.2 + tests: + - tests/namespace/easy/t-041-reserved-name/ + status: must_pass + + - id: D-020 + title: "Single-step + nested cross-grain aggregates over 1:N (every aggregate category)" + spec_ref: "§4.5 form (1), §6.1 Semantic 2" + tests: + - tests/cross_grain/moderate/t-005a-single-step-sum/ + - tests/cross_grain/moderate/t-005b-single-step-avg/ + - tests/cross_grain/moderate/t-005d-single-step-vs-nested-sum/ + - tests/cross_grain/moderate/t-005e-count-distinct-single-step/ + status: must_pass + + - id: D-021 + title: "expression slot accepts string OR { dialects: [...] } per OSI_SQL_2026" + spec_ref: "§4.3 / §4.5" + tests: + # No active witness — Phase 4 review I7 backlog. + [] + status: must_pass + + - id: D-022 + title: Plan that forces decomposition the aggregate cannot survive ⇒ E_UNSAFE_REAGGREGATION (chasm pre-agg / stitch only — bridge plan is single-pass and not in scope) + spec_ref: "§6.2 Starting Grain, §6.7, §6.8.2" + tests: + - tests/bridge/hard/t-021-count-distinct-fanout/ + status: must_pass + + - id: D-023 + title: Scalar query — fan-out / incompatible-home rejected with E_FAN_OUT_IN_SCALAR_QUERY + spec_ref: "§6.2 Final Grain, §5.1.2" + tests: + - tests/empty_inputs/moderate/t-025-scalar-two-unrelated-facts/ + - tests/scalar_query/moderate/t-022-fanout-in-scalar-query/ + status: must_pass + + - id: D-024 + title: Row-level reference at finer grain ⇒ E_UNAGGREGATED_FINER_GRAIN_REFERENCE + spec_ref: §6.2 Starting Grain + tests: + - tests/predicate_routing/moderate/t-023-unaggregated-finer-grain-reference/ + status: must_pass + + - id: D-025 + title: Catch-all measure-grain ambiguity ⇒ E_AMBIGUOUS_MEASURE_GRAIN + spec_ref: §6.2 Starting Grain + tests: + # No active witness — Phase 4 review I7 backlog. + [] + status: must_pass + + - id: D-026 + title: Bridge resolution materializes distinct (fact, group-key) — Semantic 2 + spec_ref: "§6.1 Semantic 2, §6.8.1" + tests: + - tests/bridge/hard/t-015-bridge-dedup/ + - tests/joins_default/hard/t-045-two-measure-stitch-bridge/ + status: must_pass + + - id: D-027 + title: | + Bare cross-grain aggregate over an N:N edge resolves via the §6.8.1 + bridge-dedup construction in a single pass for every aggregate + category (distributive, algebraic, holistic). The "per-home-row-first" + interpretation requires the nested form AGG(AGG(...)), which is + deferred to §10 and raises E_NESTED_AGGREGATION_DEFERRED. + spec_ref: "§4.5 form (1), §6.8.1" + tests: + - tests/bridge/hard/t-016-non-distributive-over-bridge-accepted/ + - tests/bridge/hard/t-051-holistic-over-bridge-accepted/ + - tests/cross_grain/hard/t-005c-nested-avg/ + - tests/nested_aggregates/hard/t-017-nested-aggregation-over-bridge/ + status: must_pass + + - id: D-028 + title: Standard SQL window functions in Measures / Fields / Order By / Having; Where ⇒ E_WINDOW_IN_WHERE + spec_ref: §6.10.1 + tests: + - tests/windows/moderate/t-029-window-in-where-rejected/ + - tests/windows/moderate/t-030-nested-window-rejected/ + status: must_pass + + - id: D-029 + title: NULLS LAST default for outer Order By + OVER ORDER BY; emitted explicitly into compiled SQL + spec_ref: "§5.1, §6.10.2" + tests: + - tests/null_ordering/moderate/t-062-nulls-first-default-on-desc/ + - tests/windows/moderate/t-027-window-nulls-last/ + status: must_pass + + - id: D-030 + title: Window over fan-out ⇒ pre-fan-out materialization (or E_WINDOW_OVER_FANOUT_REWRITE) + spec_ref: §6.10.3 + tests: + - tests/windows/hard/t-032-row-number-qualify-pattern/ + - tests/windows/moderate/t-031-running-total-window/ + - tests/windows/moderate/t-036-window-over-1n-fanout-accepted/ + status: must_pass + + - id: D-031 + title: Composing a metric on top of a windowed metric ⇒ E_WINDOWED_METRIC_COMPOSITION + spec_ref: §6.10.5 + tests: + - tests/windows/hard/t-033-windowed-metric-composition-rejected/ + status: must_pass + + - id: D-032 + title: "ROWS / RANGE with integer-literal bounds only; GROUPS or :param ⇒ E_DEFERRED_FRAME_MODE" + spec_ref: §6.10.6 + tests: + - tests/windows/moderate/t-034-groups-frame-rejected/ + - tests/windows/moderate/t-035-parameterised-frame-bound-rejected/ + status: must_pass + + - id: D-033 + title: Empty / NULL aggregate behaviour follows standard SQL (COUNT⇒0, others⇒NULL) + spec_ref: "§6.11.1, §6.11.2" + tests: + - tests/edge_cases/easy/t-060-empty-input-count-distinct/ + - tests/empty_inputs/moderate/t-047-empty-set-sum-vs-count/ + - tests/empty_inputs/moderate/t-049-null-dimension-column/ + - tests/empty_inputs/moderate/t-053-avg-min-max-empty/ + status: must_pass diff --git a/compliance/foundation-v0.1/proposals.yaml b/compliance/foundation-v0.1/proposals.yaml new file mode 100644 index 0000000..12a2385 --- /dev/null +++ b/compliance/foundation-v0.1/proposals.yaml @@ -0,0 +1,214 @@ +# OSI Foundation v0.1 — Proposal Registry +# ----------------------------------------------------------------------- +# Mirrors §10 of ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md. Every +# entry is either: +# +# * status: foundation — IN scope for v0.1; engines MUST implement +# * status: deferred — OUT of scope; rejected at parse time with +# E_DEFERRED_KEY_REJECTED (D-009). +# +# Test metadata.yaml files reference these IDs via `required_features:`. +# A test that references an unknown ID is a CI failure. +# +# Adapters advertise the proposals they implement via +# `enabled_proposals.yaml` (or equivalent) per the harness contract. +# A Foundation-only adapter advertises every `status: foundation` ID +# and rejects every `status: deferred` ID with E_DEFERRED_KEY_REJECTED. + +version: "0.1" + +proposals: + # -------- Foundation (v0.1) — required for every conforming engine -- + - id: two_query_shapes + status: foundation + title: Aggregation vs Scalar query shapes (Fields clause) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#5.1 + decisions: [D-001, D-010, D-011, D-023] + - id: routing_by_expression_shape + status: foundation + title: Predicate routing by resolved expression shape (no role:) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#6.3 + decisions: [D-005, D-012] + - id: implicit_home_grain_aggregation_rejection + status: foundation + title: Implicit home-grain aggregation in field expressions is rejected (E_AGGREGATE_IN_FIELD) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#4.3 + rejection_code: E_AGGREGATE_IN_FIELD + decisions: [D-003] + # D-003 and D-015 are both struck/deferred in Appendix B; the + # Foundation's active surface is the rejection rule (any aggregate + # in a field body MUST raise E_AGGREGATE_IN_FIELD per §4.3 / + # Appendix C). Per-strategy equivalence (D-015) returns when + # §10's grain-aware-functions proposal lands. + - id: cross_grain_aggregates + status: foundation + title: Single-step cross-grain aggregates over 1:N (nested form is deferred per D-020(c)/D-027(d)) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#4.5 + decisions: [D-020, D-024] + - id: default_join_shapes + status: foundation + title: Default LEFT for N:1, FULL OUTER stitch for incompatible-root multi-fact + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#6.6 + decisions: [D-001, D-004, D-008] + - id: bridge_dedup + status: foundation + title: Bridge resolution materializes distinct (fact, group-key) in a single-pass aggregate (every category) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#6.8.1 + decisions: [D-026, D-027] + - id: decomposition_safety + status: foundation + title: E_UNSAFE_REAGGREGATION fires only when the chosen plan forces decomposition the aggregate cannot survive (chasm pre-agg / stitch — bridge plan is single-pass and not in scope) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#6.2 + decisions: [D-022] + - id: identifier_resolution + status: foundation + title: Global / dataset-scoped namespace, ambiguous-path / no-path errors + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#4.6 + decisions: [D-006, D-018, D-019] + - id: window_functions_standard + status: foundation + title: Standard SQL window functions (ranking / navigation / aggregate-windows; ROWS / RANGE; integer-literal bounds) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#6.10 + decisions: [D-028, D-030, D-031, D-032] + - id: nulls_last_default + status: foundation + title: Default NULLS LAST emission for outer Order By + OVER ORDER BY + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#5.1 + decisions: [D-014, D-029] + - id: empty_aggregate_standard_sql + status: foundation + title: Standard SQL empty / NULL aggregate behaviour + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#6.11 + decisions: [D-033] + - id: osi_sql_2026_dialect + status: foundation + title: OSI_SQL_2026 default dialect with per-dialect expression form + spec_ref: ../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md + decisions: [D-021, D-016] + - id: windowed_metric_composition + status: foundation + title: Composing a metric on top of a windowed metric is rejected with E_WINDOWED_METRIC_COMPOSITION + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#6.10.5 + rejection_code: E_WINDOWED_METRIC_COMPOSITION + decisions: [D-031] + + # -------- Deferred (§10 of the Foundation spec) --------------------- + # Each deferred entry MUST be rejected at parse time with + # E_DEFERRED_KEY_REJECTED (D-009). The compliance suite ships exactly + - id: explicit_grain_overrides + status: deferred + title: Explicit grain overrides (FIXED / INCLUDE / EXCLUDE / TABLE) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10 + rejection_code: E_DEFERRED_KEY_REJECTED + - id: filter_context_propagation + status: deferred + title: Filter context propagation (reset, filter.expression on metrics) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10 + rejection_code: E_DEFERRED_KEY_REJECTED + - id: metric_composition_grain_filter + status: deferred + title: Metric composition with grain or filter inheritance + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10 + rejection_code: E_DEFERRED_KEY_REJECTED + - id: natural_grain_top_level + status: deferred + title: Model-level natural_grain declaration + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10 + rejection_code: E_DEFERRED_KEY_REJECTED + - id: path_disambiguation_using_relationships + status: deferred + title: Per-metric joins.using_relationships path disambiguation + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10 + rejection_code: E_DEFERRED_KEY_REJECTED + - id: per_metric_joins_type + status: deferred + title: Per-metric joins.type override (INNER / LEFT / FULL) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10 + rejection_code: E_DEFERRED_KEY_REJECTED + - id: non_equijoin_relationships + status: deferred + title: condition / cardinality on relationships (non-equijoin) + spec_ref: "../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10" + rejection_code: E_DEFERRED_KEY_REJECTED + - id: asof_range_relationships + status: deferred + title: ASOF and Range relationships + spec_ref: "../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10" + rejection_code: E_DEFERRED_KEY_REJECTED + - id: referential_integrity_keys + status: deferred + title: referential_integrity / from_all_rows_match / to_all_rows_match + spec_ref: "../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10" + rejection_code: E_DEFERRED_KEY_REJECTED + - id: semi_additive_measures + status: deferred + title: Semi-additive measures over snapshot facts + spec_ref: "../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10" + rejection_code: E_DEFERRED_KEY_REJECTED + - id: grouping_sets + status: deferred + title: GROUPING SETS / ROLLUP / CUBE + spec_ref: "../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10" + rejection_code: E_DEFERRED_KEY_REJECTED + - id: pivot_operator + status: deferred + title: PIVOT / UNPIVOT + spec_ref: "../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10" + rejection_code: E_DEFERRED_KEY_REJECTED + - id: parameterized_window_frame_bounds + status: deferred + title: Parameterized window frame bounds (e.g. ROWS BETWEEN :n PRECEDING ...) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10 + rejection_code: E_DEFERRED_FRAME_MODE + - id: groups_frame_mode + status: deferred + title: GROUPS frame mode (non-portable) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10 + rejection_code: E_DEFERRED_FRAME_MODE + - id: ordered_set_aggregates + status: deferred + title: WITHIN GROUP ordered-set aggregates (LISTAGG, PERCENTILE_CONT) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10 + rejection_code: E_DEFERRED_KEY_REJECTED + # ----- moved up: this is a Foundation rejection rule, not deferral + # See the foundation block above for the actual entry. + - id: named_filters + status: deferred + title: Reusable named boolean filters referenced by name + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10 + rejection_code: E_DEFERRED_KEY_REJECTED + - id: dataset_scope_filters + status: deferred + title: Dataset-level filters with scope-based propagation + spec_ref: "../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10" + rejection_code: E_DEFERRED_KEY_REJECTED + - id: semi_join_filter_form + status: deferred + title: EXISTS_IN / NOT EXISTS_IN semi-join filter (separate proposal pending) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#6.8 + rejection_code: E_DEFERRED_KEY_REJECTED + - id: role_keyword + status: deferred + title: "role: keyword on fields/datasets (replaced by routing-by-shape D-005)" + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10 + rejection_code: E_DEFERRED_KEY_REJECTED + - id: legacy_attr_unsafe_agg_grainagg + status: deferred + title: Legacy ATTR / UNSAFE / AGG / GRAIN_AGG keyword set + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10 + rejection_code: E_DEFERRED_KEY_REJECTED + - id: multi_hop_bridge + status: deferred + title: Multi-hop bridge resolution (more than one bridge between the same endpoints) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10 + rejection_code: E_DEFERRED_KEY_REJECTED + - id: symmetric_aggregates + status: deferred + title: Looker-style symmetric aggregates (codegen-side, not a correctness mechanism) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#10 + rejection_code: E_DEFERRED_KEY_REJECTED + - id: snowflake_partition_by_excluding + status: deferred + title: Snowflake ``PARTITION BY ... EXCLUDING`` window extension (vendor-specific, outside OSI_SQL_2026) + spec_ref: ../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md#12a + rejection_code: E_DEFERRED_KEY_REJECTED diff --git a/compliance/foundation-v0.1/pyproject.toml b/compliance/foundation-v0.1/pyproject.toml new file mode 100644 index 0000000..13c8938 --- /dev/null +++ b/compliance/foundation-v0.1/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "osi_compliance_foundation_v0_1" +version = "0.1.0" +description = "OSI Foundation v0.1 compliance test suite (osi_version 0.1)." +readme = "README.md" +requires-python = ">=3.11" +authors = [{ name = "OSI Foundation contributors" }] +license = { text = "Apache-2.0" } +dependencies = [ + "duckdb>=0.10", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4", +] + +# The compliance runner harness is vendored at compliance/harness/ inside +# this OSI repo. To install everything needed to run the suite: +# +# pip install -e ../harness +# pip install -e . +# +# Both packages are editable so iterating on either is one-step. + +[tool.setuptools.packages.find] +where = ["."] +include = ["adapters*"] +exclude = ["tests*", "datasets*", "results*"] diff --git a/compliance/foundation-v0.1/results/REPORT.md b/compliance/foundation-v0.1/results/REPORT.md new file mode 100644 index 0000000..8d55eea --- /dev/null +++ b/compliance/foundation-v0.1/results/REPORT.md @@ -0,0 +1,177 @@ +# Foundation v0.1 Compliance Report — `osi_python` adapter + +Generated during Phase 7 of the OSI_will migration-and-polish plan. + +## Default suite (every `status: active` test) + +| Metric | Value | +|:---|---:| +| Active tests discovered | 7 | +| Passed | 7 | +| Failed | 0 | +| Errors | 0 | +| Skipped | 0 | +| **Compliance** | **100.0%** | + +The default invocation — +```bash +python -m harness.runner \ + --adapter adapters/osi_python_adapter.py \ + --tests tests \ + --datasets datasets +# per-run artifacts written to results/latest/ +``` +runs every test whose `metadata.yaml` carries `status: active` and is the +gate that defines Foundation v0.1 conformance for the bundled adapter. All +seven active cases pass against the in-tree `impl/python` reference +implementation: + +| Test | Area | Decision | Shape | +|:---|:---|:---|:---| +| `t-005c-nested-avg` | cross_grain | D-027 | negative — `E_NESTED_AGGREGATION_DEFERRED` | +| `t-046-field-references-field-chain` | field_metric_grain | D-018 | positive | +| `t-047-field-chain-reverse-declared` | field_metric_grain | D-018 | positive | +| `t-048-windowed-field-referenced` | field_metric_grain | D-028 | positive | +| `t-049-field-chain-cross-dataset-enrich` | field_metric_grain | D-018 | positive | +| `t-017-nested-aggregation-over-bridge` | nested_aggregates | D-027 | negative — `E_NESTED_AGGREGATION_DEFERRED` | +| `t-050-field-dependency-cycle` | validation_errors | (structural) | negative — `E_FIELD_DEPENDENCY_CYCLE` | + +## Extended suite (`--include-planned`) + +The `tests/` tree also ships 79 cases with `status: planned`. They are +shipped today so future sprints have a concrete witness to flip from +planned → active. Running them through the bundled adapter currently +yields: + +| Metric | Value | +|:---|---:| +| Total tests | 86 | +| Passed | 80 | +| Failed | 4 | +| Errors | 2 | +| **Conformance against planned tier** | **93.0%** | + +The six non-passing planned cases are intentional gaps documented in +detail below. Each is either an impl gap pinned to a future sprint or a +test that exercises a Foundation rejection along a different code path. + +### Planned-tier failures and root-cause classification + +#### G-1 `t-014-mn-no-bridge` — adapter emits `E_NO_PATH`, spec wants `E3013` + +**Class:** impl gap. The `f_bridge_none` fixture declares two +unrelated fact tables (`actors`, `movies`) with `relationships: []`. +A query that references both has no path through the graph, so the +planner raises `E2004_UNREACHABLE_DATASET` which the adapter maps to +`E_NO_PATH`. Per Appendix C this case is +`E3013_NO_STITCHING_DIMENSION` — two unrelated facts referenced +together with no shared stitch dimension would yield a Cartesian +product. + +**Root cause:** the adapter's `_LEGACY_CODE_MAP` maps every +`E2004_UNREACHABLE_DATASET` to `E_NO_PATH`. A future sprint (S-10) +should narrow this to `E3013_NO_STITCHING_DIMENSION` when the +unreachable dataset is the home of a measure (or, equivalently, +when two distinct root datasets are referenced). + +#### G-2 / G-3 `t-016-non-distributive-over-bridge-accepted`, `t-051-holistic-over-bridge-accepted` + +**Class:** impl gap (D-027 bridge for non-distributive aggregates). +The Foundation spec accepts `AVG`, `MEDIAN`, and other non-distributive +aggregates over an N:N bridge in a single pass (D-027). The +reference impl today resolves the bridge for distributive aggregates +(SUM, COUNT, MIN, MAX, COUNT_DISTINCT) but rejects the non-distributive +cases: + +- **t-016 (AVG over bridge):** `E_UNSAFE_REAGGREGATION` — the planner + reaches the bridge dispatch via the standard route then aborts on + the fan-out precondition. +- **t-051 (MEDIAN over bridge):** `E1208_UNSUPPORTED_SQL_CONSTRUCT` + (Phase 9 P1-7). The new top-level-aggregate gate in + `metric_shape.classify_metric` rejects `MEDIAN` at the metric root + with a clear diagnostic before the planner ever attempts the bridge + shape. Previously this surfaced as the misleading + `E_UNSAFE_REAGGREGATION` from a later path; the new code is more + actionable for authors. + +**Root cause:** documented in +`impl/python/src/osi/planning/planner_bridge.py` module docstring. +The current two-stage bridge resolution doesn't survive +non-distributive aggregates; rewriting the bridge planner to a +single-pass dedup form is the open work item. + +**Pinned to:** the next bridge-resolution sprint after Phase 11. + +#### G-4 `t-042v-ordered-set-aggregates` + +**Class:** test design / impl reach. The test declares a metric +`median_amount = PERCENTILE_CONT(0.5) WITHIN GROUP (...)`. The +adapter's composite-metric machinery sees `orders.amount` as a raw +fact reference inside a metric body and raises `E1206` (raw facts +may only appear via top-level metrics or aggregations). That's a +valid Foundation rejection — just a different code than the +spec-mandated `E_DEFERRED_KEY_REJECTED`. + +A future sprint that adds parse-level detection of `WITHIN GROUP` +ordered-set aggregates can surface the spec-mandated code; today +the engine rejects the construct through a different path. + +#### G-5 `t-042x-multi-hop-bridge` + +**Class:** test design / impl reach. The test declares two +relationships from `returns` to `customers` with identical +`from_columns`/`to_columns`. The path planner detects this as +`E_AMBIGUOUS_PATH` ("multiple relationships reach 'customers'"). +Like G-4, this is a valid Foundation rejection along a different +code path than `E_DEFERRED_KEY_REJECTED`. + +#### G-6 `t-042y-snowflake-partition-by-excluding` + +**Class:** parser limitation. The metric body uses Snowflake's +`PARTITION BY ... EXCLUDING TIES ORDER BY ...` window clause. +`sqlglot` cannot parse this with its default dialect and the +adapter surfaces `E1001: could not parse metric expression`. This +is again a valid rejection — the Foundation engine refuses to +compile the model — just under a more generic code than the +spec-mandated deferred-key code. + +A future sprint that recognises Snowflake-only window syntax in +the parser can promote this to `E_DEFERRED_KEY_REJECTED`. + +## Methodology + +Reports under `results/` are split between curated baselines and +per-run runner artifacts: + +- `results/REPORT.md` — *this file*; written by Phase 7 of the + OSI_will migration-and-polish plan and refreshed by Phase 9 after + the BI / compiler / test-quality fixes. Kept in version control so + the conformance baseline is reviewable in PR diffs. +- `results/latest/summary.md` — pass/fail per test for the most + recent local `harness.runner` invocation (auto-generated, gitignored). +- `results/latest/failures.csv` — failed cases for the most recent + run, with classification info (auto-generated, gitignored). + +To reproduce locally: + +```bash +cd /path/to/OSI_will +pip install -e impl/python +pip install -e compliance/harness +cd compliance/foundation-v0.1 + +# active tests only — runner writes to results/latest/ by default +python -m harness.runner \ + --adapter adapters/osi_python_adapter.py \ + --tests tests \ + --datasets datasets + +# 86-test extended view — point --output at a sibling subdir so the +# two runs don't overwrite each other +python -m harness.runner \ + --adapter adapters/osi_python_adapter.py \ + --tests tests \ + --datasets datasets \ + --output results/latest-planned \ + --include-planned +``` diff --git a/compliance/foundation-v0.1/tests/README.md b/compliance/foundation-v0.1/tests/README.md new file mode 100644 index 0000000..cb85f7c --- /dev/null +++ b/compliance/foundation-v0.1/tests/README.md @@ -0,0 +1,62 @@ +# Foundation v0.1 compliance tests + +Each test is a folder containing: + +| File | Purpose | +|:---|:---| +| `metadata.yaml` | `id: T-NNN`, `decision: D-NNN`, `dataset: f_*`, `spec_refs`, `required_features`, `expected_error_code` (negative cases), `xfail_reason` (if pinned to a sprint). | +| `model.yaml` | The semantic model the test runs against. | +| `query.json` | The semantic query, in the new two-shape format (`dimensions` + `measures` for aggregation queries; `fields` for scalar queries). | +| `gold.sql` | Hand-written reference SQL the harness executes against the fixture data to produce the expected row multiset. Positive cases only; negative cases keep a stub `gold.sql` so the harness can locate the directory. | + +Tests assert on observable behaviour only. The harness executes both +`gold.sql` and the adapter's emitted SQL against the shared fixture +and compares the resulting row multisets — order-insensitive unless +the query has an `Order By`. SQL strings are never compared +byte-for-byte (per D-014 that's a per-engine concern). + +## Layout + +| Area | What it covers | Anchor decisions | +|:---|:---|:---| +| `query_shape/` | Aggregation vs Scalar shape, mixing rejection, COUNT(*), expression-form dialects | D-001, D-010, D-002, D-016, D-021 | +| `scalar_query/` | Scalar query specifics: bare metric in Fields, fan-out rejection | D-011, D-023 | +| `field_metric_grain/` | Field expressions with implicit home-grain aggregation | D-003, D-015 | +| `cross_grain/` | Single-step cross-grain aggregates (1:N) | D-020, D-024 | +| `nested_aggregates/` | Nested aggregates over M:N (inner-grain inference) | D-022, D-027 | +| `bridge/` | M:N bridge resolution + de-duplication (Semantic 2) | D-026 (T-015 flagship) | +| `joins_default/` | LEFT for N:1, FULL OUTER stitch, CROSS JOIN of scalars | D-001, D-004, D-008 | +| `predicate_routing/` | Where vs Having shape errors | D-005, D-012 | +| `namespace/` | Global / dataset-scoped resolution, ambiguous / no path, reserved names | D-006, D-018, D-019 | +| `windows/` | Window placement, pre-fan-out, deferred frame modes, composition | D-028, D-030, D-031, D-032 | +| `null_ordering/` | NULLS LAST default emission, per-engine determinism witnesses | D-014, D-029 | +| `empty_inputs/` | Standard SQL empty / NULL aggregate behaviour | D-033 | +| `deferred/` | One negative test per `E_DEFERRED_KEY_REJECTED` family | D-009 | +| `validation_errors/` | Structural validation errors that are NOT deferred features (e.g. `E_FIELD_DEPENDENCY_CYCLE` — fields must form a DAG). | Appendix C | +| `error_taxonomy/` | Remaining Appendix C codes (E_PRIMARY_KEY_REQUIRED, E_AMBIGUOUS_MEASURE_GRAIN, etc.) | Appendix C | + +## Sprint timeline + +- **S-B**: this scaffold + `tests/README.md` (no test cases yet). +- **S-C**: lands `T-001 … T-033` plus the `E_DEFERRED_KEY_REJECTED` + negative tests, derived from `DATA_TESTS.md §4`. +- **S-E**: lands additional `T-NNN` cases from the differential / + edge-case audit before any S-1..S-17 sprint runs. + +## Negative tests + +A test is negative when its `metadata.yaml` carries +`expected_error_code: E_` (no `gold_rows.json`). The runner +asserts that the adapter exited non-zero AND that stderr contains the +named error code. Negative tests do NOT carry `required_features:` +unless the rejection itself depends on a Foundation-level feature; the +goal is for every adapter (Foundation or extended) to produce the same +error. + +## xfail policy + +A case marked `xfail_reason: ` is shipped but expected to fail +until the named sprint flips it to `must_pass`. Every `xfail_reason` +MUST cite the sprint ID (e.g., `xfail_reason: "Sprint S-7 — default +join shape rewrite (D-004)"`) so the rollout can find every red row +when the implementation lands. diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-014-mn-no-bridge/gold.sql b/compliance/foundation-v0.1/tests/bridge/hard/t-014-mn-no-bridge/gold.sql new file mode 100644 index 0000000..0c229e4 --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-014-mn-no-bridge/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_NO_PATH: no bridge declared between actors and movies diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-014-mn-no-bridge/metadata.yaml b/compliance/foundation-v0.1/tests/bridge/hard/t-014-mn-no-bridge/metadata.yaml new file mode 100644 index 0000000..7b77582 --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-014-mn-no-bridge/metadata.yaml @@ -0,0 +1,26 @@ +name: t-014-mn-no-bridge +description: >- + The model declares two facts (``actors`` and ``movies``) with no + bridge dataset and no shared stitch dimension; a query that + references both at once has no path through the graph. Per + Appendix C this is ``E3013_NO_STITCHING_DIMENSION`` — two + unrelated facts referenced together would otherwise yield a + Cartesian product. (The ``mn-no-bridge`` name reflects the + scenario authors had in mind: an N:N traversal that *would* need + a bridge dataset; absent the relationship declarations entirely, + the engine surfaces the more-specific no-stitching-dimension code + per §6.1, §6.8 / D-007(b).) +area: bridge +difficulty: hard +dataset: f_bridge_none +spec_refs: + - "Proposed_OSI_Semantics.md#D-007" + - "Proposed_OSI_Semantics.md#sec-6.8" +tags: [bridge, m:n, negative] +conformance_level: foundation_v0_1 +decision: D-007 +test_id: T-014 +expected_error: true +expected_error_code: E3013 +status: planned +xfail_reason: "Sprint S-10 — M:N safe-rewrite rejection (D-007)" diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-014-mn-no-bridge/model.yaml b/compliance/foundation-v0.1/tests/bridge/hard/t-014-mn-no-bridge/model.yaml new file mode 100644 index 0000000..1af8100 --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-014-mn-no-bridge/model.yaml @@ -0,0 +1,19 @@ +name: f_bridge_none_model +datasets: + - name: actors + source: actors + primary_key: [actor_id] + fields: + - {name: actor_id, expression: actor_id, dimension: {}} + - {name: name, expression: name, dimension: {}} + - {name: height, expression: height, dimension: {}} + - name: movies + source: movies + primary_key: [movie_id] + fields: + - {name: movie_id, expression: movie_id, dimension: {}} + - {name: title, expression: title, dimension: {}} + - {name: gross, expression: gross} +relationships: [] +metrics: + - {name: total_gross, expression: "SUM(movies.gross)"} diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-014-mn-no-bridge/query.json b/compliance/foundation-v0.1/tests/bridge/hard/t-014-mn-no-bridge/query.json new file mode 100644 index 0000000..25008ad --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-014-mn-no-bridge/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "actors", + "dimensions": [ + "actors.height" + ], + "measures": [ + { + "name": "total_gross", + "metric": "total_gross" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-015-bridge-dedup/gold.sql b/compliance/foundation-v0.1/tests/bridge/hard/t-015-bridge-dedup/gold.sql new file mode 100644 index 0000000..bad57a6 --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-015-bridge-dedup/gold.sql @@ -0,0 +1,9 @@ +WITH dedup AS ( + SELECT DISTINCT a.height AS height, m.movie_id AS movie_id, m.gross AS gross + FROM appearances ap + JOIN actors a ON ap.actor_id = a.actor_id + JOIN movies m ON ap.movie_id = m.movie_id +) +SELECT height, SUM(gross) AS total_gross +FROM dedup +GROUP BY height diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-015-bridge-dedup/metadata.yaml b/compliance/foundation-v0.1/tests/bridge/hard/t-015-bridge-dedup/metadata.yaml new file mode 100644 index 0000000..673a2a7 --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-015-bridge-dedup/metadata.yaml @@ -0,0 +1,13 @@ +name: t-015-bridge-dedup +description: "Bridge resolution de-duplicates per (movie_id, height): M10 counted once, 170 ⇒ 300 not 400" +area: bridge +difficulty: hard +dataset: f_bridge +spec_refs: + - "Proposed_OSI_Semantics.md#D-026" +tags: [bridge, m:n, dedup, flagship] +conformance_level: foundation_v0_1 +decision: D-026 +test_id: T-015 +status: planned +xfail_reason: "Sprint S-8 — bridge de-duplication plan (D-026)" diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-015-bridge-dedup/model.yaml b/compliance/foundation-v0.1/tests/bridge/hard/t-015-bridge-dedup/model.yaml new file mode 100644 index 0000000..9491128 --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-015-bridge-dedup/model.yaml @@ -0,0 +1,28 @@ +name: f_bridge_model +datasets: + - name: actors + source: actors + primary_key: [actor_id] + fields: + - {name: actor_id, expression: actor_id, dimension: {}} + - {name: name, expression: name, dimension: {}} + - {name: height, expression: height, dimension: {}} + - name: movies + source: movies + primary_key: [movie_id] + fields: + - {name: movie_id, expression: movie_id, dimension: {}} + - {name: title, expression: title, dimension: {}} + - {name: gross, expression: gross} + - name: appearances + source: appearances + primary_key: [actor_id, movie_id] + role: bridge + fields: + - {name: actor_id, expression: actor_id, dimension: {}} + - {name: movie_id, expression: movie_id, dimension: {}} +relationships: + - {name: app_to_actor, from: appearances, to: actors, from_columns: [actor_id], to_columns: [actor_id]} + - {name: app_to_movie, from: appearances, to: movies, from_columns: [movie_id], to_columns: [movie_id]} +metrics: + - {name: total_gross, expression: "SUM(movies.gross)"} diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-015-bridge-dedup/query.json b/compliance/foundation-v0.1/tests/bridge/hard/t-015-bridge-dedup/query.json new file mode 100644 index 0000000..25008ad --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-015-bridge-dedup/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "actors", + "dimensions": [ + "actors.height" + ], + "measures": [ + { + "name": "total_gross", + "metric": "total_gross" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-016-non-distributive-over-bridge-accepted/gold.sql b/compliance/foundation-v0.1/tests/bridge/hard/t-016-non-distributive-over-bridge-accepted/gold.sql new file mode 100644 index 0000000..3806915 --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-016-non-distributive-over-bridge-accepted/gold.sql @@ -0,0 +1,14 @@ +-- Bridge-dedup AVG(movies.gross) grouped by actors.height (D-027). +-- Same dedup CTE as t-015 (SUM); just AVG over the deduped set. +-- Expected rows from f_bridge: +-- (170, 150.00) -- AVG(100, 200) over (M10, 170), (M11, 170) +-- (180, 50.00) -- AVG(50) over (M12, 180) +WITH dedup AS ( + SELECT DISTINCT a.height AS height, m.movie_id AS movie_id, m.gross AS gross + FROM appearances ap + JOIN actors a ON ap.actor_id = a.actor_id + JOIN movies m ON ap.movie_id = m.movie_id +) +SELECT height, AVG(gross) AS avg_gross +FROM dedup +GROUP BY height diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-016-non-distributive-over-bridge-accepted/metadata.yaml b/compliance/foundation-v0.1/tests/bridge/hard/t-016-non-distributive-over-bridge-accepted/metadata.yaml new file mode 100644 index 0000000..fe8fa96 --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-016-non-distributive-over-bridge-accepted/metadata.yaml @@ -0,0 +1,27 @@ +name: t-016-non-distributive-over-bridge-accepted +description: | + Bare non-distributive aggregate (AVG) over an N:N bridge resolves via the + same bridge-dedup construction §6.8.1 uses for SUM (D-027). It is a + single-pass aggregate over the unique (movie_id, height) row set; no + multi-stage decomposition is forced, so AVG is well-defined here. The + per-home-row-first interpretation (which would yield 125 for height 170) + requires the nested form AVG(AVG(...)) and is deferred — see t-044. +area: bridge +difficulty: hard +dataset: f_bridge +spec_refs: + - "Proposed_OSI_Semantics.md#D-027" + - "Proposed_OSI_Semantics.md#6.8.1" +tags: [bridge, non-distributive, accepted] +conformance_level: foundation_v0_1 +decision: D-027 +test_id: T-016 +status: planned +xfail_reason: | + Pending implementation. Spec contract revised (D-027): bare AVG over an + N:N bridge resolves via the same single-pass bridge-dedup construction + used for SUM. The osi_python planner currently routes bare AVG through + the standard fan-out check (which rejects it as E_UNSAFE_REAGGREGATION) + — the bridge planner only handles the nested form today. A future + sprint must extend planner_bridge.py to route bare non-distributive + aggregates through the same dedup CTE used for SUM. diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-016-non-distributive-over-bridge-accepted/model.yaml b/compliance/foundation-v0.1/tests/bridge/hard/t-016-non-distributive-over-bridge-accepted/model.yaml new file mode 100644 index 0000000..7ae3d05 --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-016-non-distributive-over-bridge-accepted/model.yaml @@ -0,0 +1,29 @@ +name: f_bridge_model +datasets: + - name: actors + source: actors + primary_key: [actor_id] + fields: + - {name: actor_id, expression: actor_id, dimension: {}} + - {name: name, expression: name, dimension: {}} + - {name: height, expression: height, dimension: {}} + - name: movies + source: movies + primary_key: [movie_id] + fields: + - {name: movie_id, expression: movie_id, dimension: {}} + - {name: title, expression: title, dimension: {}} + - {name: gross, expression: gross} + - name: appearances + source: appearances + primary_key: [actor_id, movie_id] + role: bridge + fields: + - {name: actor_id, expression: actor_id, dimension: {}} + - {name: movie_id, expression: movie_id, dimension: {}} +relationships: + - {name: app_to_actor, from: appearances, to: actors, from_columns: [actor_id], to_columns: [actor_id]} + - {name: app_to_movie, from: appearances, to: movies, from_columns: [movie_id], to_columns: [movie_id]} +metrics: + - {name: total_gross, expression: "SUM(movies.gross)"} + - {name: avg_gross, expression: "AVG(movies.gross)"} diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-016-non-distributive-over-bridge-accepted/query.json b/compliance/foundation-v0.1/tests/bridge/hard/t-016-non-distributive-over-bridge-accepted/query.json new file mode 100644 index 0000000..ea0e6ff --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-016-non-distributive-over-bridge-accepted/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "actors", + "dimensions": [ + "actors.height" + ], + "measures": [ + { + "name": "avg_gross", + "metric": "avg_gross" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-021-count-distinct-fanout/gold.sql b/compliance/foundation-v0.1/tests/bridge/hard/t-021-count-distinct-fanout/gold.sql new file mode 100644 index 0000000..5c93b5a --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-021-count-distinct-fanout/gold.sql @@ -0,0 +1,11 @@ +-- T-021: COUNT(DISTINCT actors.actor_id) per movie title via bridge dedup. +-- Per the seed (f_bridge): +-- Action (M10): appearances (1, 10) and (2, 10) -> {actor 1, actor 2} -> 2 +-- Drama (M11): appearance (1, 11) -> {actor 1} -> 1 +-- Comedy (M12): appearance (3, 12) -> {actor 3} -> 1 +SELECT m.title AS title, + COUNT(DISTINCT ap.actor_id) AS unique_actors +FROM movies m +LEFT JOIN appearances ap ON ap.movie_id = m.movie_id +GROUP BY m.title +ORDER BY title NULLS LAST diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-021-count-distinct-fanout/metadata.yaml b/compliance/foundation-v0.1/tests/bridge/hard/t-021-count-distinct-fanout/metadata.yaml new file mode 100644 index 0000000..7eb1187 --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-021-count-distinct-fanout/metadata.yaml @@ -0,0 +1,14 @@ +name: t-021-count-distinct-fanout +description: "COUNT(DISTINCT actors.actor_id) over the M:N actor↔movie bridge is ACCEPTED via bridge de-duplication (D-022 + D-026 + §6.11.3). After dedup at (actor_id, title) each actor contributes once per title, and SUM gives the per-title distinct-actor count." +area: bridge +difficulty: hard +dataset: f_bridge +spec_refs: + - "Proposed_OSI_Semantics.md#D-022" + - "Proposed_OSI_Semantics.md#D-026" +tags: [count-distinct, bridge-dedup, m-n] +conformance_level: foundation_v0_1 +decision: D-022 +test_id: T-021 +status: planned +xfail_reason: "Sprint S-19 — bridge resolution accepts COUNT(DISTINCT) when the argument lives on the fact dataset (D-022 / §6.11.3)." diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-021-count-distinct-fanout/model.yaml b/compliance/foundation-v0.1/tests/bridge/hard/t-021-count-distinct-fanout/model.yaml new file mode 100644 index 0000000..0efe7a1 --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-021-count-distinct-fanout/model.yaml @@ -0,0 +1,29 @@ +name: f_bridge_model +datasets: + - name: actors + source: actors + primary_key: [actor_id] + fields: + - {name: actor_id, expression: actor_id, dimension: {}} + - {name: name, expression: name, dimension: {}} + - {name: height, expression: height, dimension: {}} + - name: movies + source: movies + primary_key: [movie_id] + fields: + - {name: movie_id, expression: movie_id, dimension: {}} + - {name: title, expression: title, dimension: {}} + - {name: gross, expression: gross} + - name: appearances + source: appearances + primary_key: [actor_id, movie_id] + role: bridge + fields: + - {name: actor_id, expression: actor_id, dimension: {}} + - {name: movie_id, expression: movie_id, dimension: {}} +relationships: + - {name: app_to_actor, from: appearances, to: actors, from_columns: [actor_id], to_columns: [actor_id]} + - {name: app_to_movie, from: appearances, to: movies, from_columns: [movie_id], to_columns: [movie_id]} +metrics: + - {name: total_gross, expression: "SUM(movies.gross)"} + - {name: unique_actors, expression: "COUNT(DISTINCT actors.actor_id)"} diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-021-count-distinct-fanout/query.json b/compliance/foundation-v0.1/tests/bridge/hard/t-021-count-distinct-fanout/query.json new file mode 100644 index 0000000..6fb996a --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-021-count-distinct-fanout/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "movies", + "dimensions": [ + "movies.title" + ], + "measures": [ + { + "name": "unique_actors", + "metric": "unique_actors" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-051-holistic-over-bridge-accepted/gold.sql b/compliance/foundation-v0.1/tests/bridge/hard/t-051-holistic-over-bridge-accepted/gold.sql new file mode 100644 index 0000000..559d032 --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-051-holistic-over-bridge-accepted/gold.sql @@ -0,0 +1,17 @@ +-- Bridge-dedup MEDIAN(movies.gross) grouped by actors.height (D-027). +-- Same dedup CTE as t-015; just MEDIAN over the deduped set. Holistic +-- aggregates over an N:N bridge are accepted bare per the post-revision +-- D-027 contract — the bridge plan is a single-pass aggregate, not a +-- multi-stage decomposition, so MEDIAN is well-defined here. +-- Expected rows from f_bridge: +-- (170, 150.00) -- MEDIAN(100, 200) over (M10, 170), (M11, 170) +-- (180, 50.00) -- MEDIAN(50) over (M12, 180) +WITH dedup AS ( + SELECT DISTINCT a.height AS height, m.movie_id AS movie_id, m.gross AS gross + FROM appearances ap + JOIN actors a ON ap.actor_id = a.actor_id + JOIN movies m ON ap.movie_id = m.movie_id +) +SELECT height, MEDIAN(gross) AS median_gross +FROM dedup +GROUP BY height diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-051-holistic-over-bridge-accepted/metadata.yaml b/compliance/foundation-v0.1/tests/bridge/hard/t-051-holistic-over-bridge-accepted/metadata.yaml new file mode 100644 index 0000000..0e84f1e --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-051-holistic-over-bridge-accepted/metadata.yaml @@ -0,0 +1,32 @@ +name: t-051-holistic-over-bridge-accepted +description: | + Bare holistic aggregate (MEDIAN) over an N:N bridge resolves via the same + bridge-dedup construction §6.8.1 uses for SUM and AVG (D-027). The bridge + plan is a single-pass aggregate over the unique (movie_id, height) row + set — no multi-stage decomposition is forced, so MEDIAN is well-defined + here. This locks in the post-revision D-027 contract that every aggregate + category (distributive, algebraic, holistic) is accepted bare over an N:N + bridge; E_UNSAFE_REAGGREGATION is reserved for plans that genuinely force + decomposition (§6.7 chasm pre-aggregation, §6.8.2 stitch). +area: bridge +difficulty: hard +dataset: f_bridge +spec_refs: + - "Proposed_OSI_Semantics.md#D-027" + - "Proposed_OSI_Semantics.md#6.8.1" + - "Proposed_OSI_Semantics.md#6.11.3" +tags: [bridge, holistic, accepted] +conformance_level: foundation_v0_1 +decision: D-027 +test_id: T-051 +status: planned +xfail_reason: | + Pending implementation. Spec contract revised (D-027): bare MEDIAN + (and other holistic aggregates) over an N:N bridge resolves via the + same single-pass bridge-dedup construction used for SUM and AVG. The + osi_python planner has two outstanding gaps for this case: (1) MEDIAN + is not yet in the recognised aggregate set (currently parses as a + composite expression and raises E1206); (2) once recognised, the + planner must route it through the bridge-dedup CTE rather than the + fan-out reject. Both blockers fall out of the same future sprint that + unblocks t-016. diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-051-holistic-over-bridge-accepted/model.yaml b/compliance/foundation-v0.1/tests/bridge/hard/t-051-holistic-over-bridge-accepted/model.yaml new file mode 100644 index 0000000..9b0725d --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-051-holistic-over-bridge-accepted/model.yaml @@ -0,0 +1,28 @@ +name: f_bridge_model +datasets: + - name: actors + source: actors + primary_key: [actor_id] + fields: + - {name: actor_id, expression: actor_id, dimension: {}} + - {name: name, expression: name, dimension: {}} + - {name: height, expression: height, dimension: {}} + - name: movies + source: movies + primary_key: [movie_id] + fields: + - {name: movie_id, expression: movie_id, dimension: {}} + - {name: title, expression: title, dimension: {}} + - {name: gross, expression: gross} + - name: appearances + source: appearances + primary_key: [actor_id, movie_id] + role: bridge + fields: + - {name: actor_id, expression: actor_id, dimension: {}} + - {name: movie_id, expression: movie_id, dimension: {}} +relationships: + - {name: app_to_actor, from: appearances, to: actors, from_columns: [actor_id], to_columns: [actor_id]} + - {name: app_to_movie, from: appearances, to: movies, from_columns: [movie_id], to_columns: [movie_id]} +metrics: + - {name: median_gross, expression: "MEDIAN(movies.gross)"} diff --git a/compliance/foundation-v0.1/tests/bridge/hard/t-051-holistic-over-bridge-accepted/query.json b/compliance/foundation-v0.1/tests/bridge/hard/t-051-holistic-over-bridge-accepted/query.json new file mode 100644 index 0000000..e63b253 --- /dev/null +++ b/compliance/foundation-v0.1/tests/bridge/hard/t-051-holistic-over-bridge-accepted/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "actors", + "dimensions": [ + "actors.height" + ], + "measures": [ + { + "name": "median_gross", + "metric": "median_gross" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/cross_grain/hard/t-005c-nested-avg/gold.sql b/compliance/foundation-v0.1/tests/cross_grain/hard/t-005c-nested-avg/gold.sql new file mode 100644 index 0000000..be35c28 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/hard/t-005c-nested-avg/gold.sql @@ -0,0 +1,5 @@ +-- Negative test: planner MUST reject the AVG(AVG(...)) shape with +-- E_NESTED_AGGREGATION_DEFERRED (D-020(c) / D-027(d)). No SQL is +-- emitted; gold.sql is kept as a stub so the harness can locate +-- this directory. +SELECT 1 WHERE FALSE; diff --git a/compliance/foundation-v0.1/tests/cross_grain/hard/t-005c-nested-avg/metadata.yaml b/compliance/foundation-v0.1/tests/cross_grain/hard/t-005c-nested-avg/metadata.yaml new file mode 100644 index 0000000..72c10c6 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/hard/t-005c-nested-avg/metadata.yaml @@ -0,0 +1,21 @@ +name: t-005c-nested-avg +description: >- + Nested cross-grain ``AVG(AVG(orders.amount))`` over a 1:N edge is the + deferred per-home-row form (D-020(c) / D-027(d)); the Foundation + engine MUST reject it with ``E_NESTED_AGGREGATION_DEFERRED``. + Pins the rejection at the explicit ``AVG(AVG(...))`` shape + with a 1:N relationship (not the bridge case ``t-017`` exercises). +area: cross_grain +difficulty: hard +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#sec-4.5" + - "Proposed_OSI_Semantics.md#D-020" + - "Proposed_OSI_Semantics.md#D-027" +tags: [cross-grain, 1:N, nested-aggregation, deferred, negative] +conformance_level: foundation_v0_1 +decision: D-027 +test_id: T-005c +expected_error: true +expected_error_code: E_NESTED_AGGREGATION_DEFERRED +status: active diff --git a/compliance/foundation-v0.1/tests/cross_grain/hard/t-005c-nested-avg/model.yaml b/compliance/foundation-v0.1/tests/cross_grain/hard/t-005c-nested-avg/model.yaml new file mode 100644 index 0000000..7de4857 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/hard/t-005c-nested-avg/model.yaml @@ -0,0 +1,37 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: avg_of_per_customer_avg, expression: "AVG(AVG(orders.amount))"} diff --git a/compliance/foundation-v0.1/tests/cross_grain/hard/t-005c-nested-avg/query.json b/compliance/foundation-v0.1/tests/cross_grain/hard/t-005c-nested-avg/query.json new file mode 100644 index 0000000..92ecb58 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/hard/t-005c-nested-avg/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "customers", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "avg_of_per_customer_avg", + "metric": "avg_of_per_customer_avg" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005a-single-step-sum/gold.sql b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005a-single-step-sum/gold.sql new file mode 100644 index 0000000..72c1f42 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005a-single-step-sum/gold.sql @@ -0,0 +1,4 @@ +SELECT c.region AS region, SUM(o.amount) AS total_order_amount +FROM orders o +LEFT JOIN customers c ON o.customer_id = c.id +GROUP BY c.region diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005a-single-step-sum/metadata.yaml b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005a-single-step-sum/metadata.yaml new file mode 100644 index 0000000..c2e0755 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005a-single-step-sum/metadata.yaml @@ -0,0 +1,13 @@ +name: t-005a-single-step-sum +description: "Single-step cross-grain SUM over 1:N accepted; identical to nested SUM (distributive)" +area: cross_grain +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-020" +tags: [cross-grain, 1:N, distributive] +conformance_level: foundation_v0_1 +decision: D-020 +test_id: T-005a +status: planned +xfail_reason: "Sprint S-5 — single-step cross-grain aggregation (D-020)" diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005a-single-step-sum/model.yaml b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005a-single-step-sum/model.yaml new file mode 100644 index 0000000..f40095f --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005a-single-step-sum/model.yaml @@ -0,0 +1,37 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: total_order_amount, expression: "SUM(orders.amount)"} diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005a-single-step-sum/query.json b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005a-single-step-sum/query.json new file mode 100644 index 0000000..0cdd132 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005a-single-step-sum/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "customers", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "total_order_amount", + "metric": "total_order_amount" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005b-single-step-avg/gold.sql b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005b-single-step-avg/gold.sql new file mode 100644 index 0000000..b02fe99 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005b-single-step-avg/gold.sql @@ -0,0 +1,4 @@ +SELECT c.region AS region, AVG(o.amount) AS avg_order_amount +FROM orders o +LEFT JOIN customers c ON o.customer_id = c.id +GROUP BY c.region diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005b-single-step-avg/metadata.yaml b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005b-single-step-avg/metadata.yaml new file mode 100644 index 0000000..700f35a --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005b-single-step-avg/metadata.yaml @@ -0,0 +1,13 @@ +name: t-005b-single-step-avg +description: "Single-step cross-grain AVG over 1:N accepted; heavy-customer-weighted (standard SQL AVG semantics)" +area: cross_grain +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-020" +tags: [cross-grain, 1:N, non-distributive] +conformance_level: foundation_v0_1 +decision: D-020 +test_id: T-005b +status: planned +xfail_reason: "Sprint S-5 — single-step cross-grain AVG (D-020)" diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005b-single-step-avg/model.yaml b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005b-single-step-avg/model.yaml new file mode 100644 index 0000000..ddbd0a0 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005b-single-step-avg/model.yaml @@ -0,0 +1,37 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: avg_order_amount, expression: "AVG(orders.amount)"} diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005b-single-step-avg/query.json b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005b-single-step-avg/query.json new file mode 100644 index 0000000..4a39b10 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005b-single-step-avg/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "customers", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "avg_order_amount", + "metric": "avg_order_amount" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005d-single-step-vs-nested-sum/gold.sql b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005d-single-step-vs-nested-sum/gold.sql new file mode 100644 index 0000000..4507810 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005d-single-step-vs-nested-sum/gold.sql @@ -0,0 +1,4 @@ +SELECT c.region AS region, SUM(o.amount) AS sum_single +FROM orders o +LEFT JOIN customers c ON o.customer_id = c.id +GROUP BY c.region diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005d-single-step-vs-nested-sum/metadata.yaml b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005d-single-step-vs-nested-sum/metadata.yaml new file mode 100644 index 0000000..a46fdd6 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005d-single-step-vs-nested-sum/metadata.yaml @@ -0,0 +1,13 @@ +name: t-005d-single-step-vs-nested-sum +description: "Single-step SUM and nested SUM(SUM(...)) over 1:N produce identical row sets (distributive)" +area: cross_grain +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-020" +tags: [cross-grain, 1:N, distributive, equivalence] +conformance_level: foundation_v0_1 +decision: D-020 +test_id: T-005d +status: planned +xfail_reason: "Sprint S-5 — single-step ≡ nested SUM equivalence (D-020)" diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005d-single-step-vs-nested-sum/model.yaml b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005d-single-step-vs-nested-sum/model.yaml new file mode 100644 index 0000000..7dbe616 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005d-single-step-vs-nested-sum/model.yaml @@ -0,0 +1,39 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: sum_single, expression: "SUM(orders.amount)"} + # sum_nested intentionally removed — nested aggregation in a metric + # body is deferred (D-027(d)); see Proposed_OSI_Semantics.md §10. diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005d-single-step-vs-nested-sum/query.json b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005d-single-step-vs-nested-sum/query.json new file mode 100644 index 0000000..42ed57b --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005d-single-step-vs-nested-sum/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "customers", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "sum_single", + "metric": "sum_single" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005e-count-distinct-single-step/gold.sql b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005e-count-distinct-single-step/gold.sql new file mode 100644 index 0000000..cf18293 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005e-count-distinct-single-step/gold.sql @@ -0,0 +1,4 @@ +SELECT c.region AS region, COUNT(DISTINCT o.status) AS distinct_order_statuses +FROM orders o +LEFT JOIN customers c ON o.customer_id = c.id +GROUP BY c.region diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005e-count-distinct-single-step/metadata.yaml b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005e-count-distinct-single-step/metadata.yaml new file mode 100644 index 0000000..5979567 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005e-count-distinct-single-step/metadata.yaml @@ -0,0 +1,14 @@ +name: t-005e-count-distinct-single-step +description: "Single-step COUNT(DISTINCT) over 1:N accepted (holistic but well-defined on plain 1:N)" +area: cross_grain +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-020" + - "Proposed_OSI_Semantics.md#D-022" +tags: [cross-grain, 1:N, count-distinct, holistic] +conformance_level: foundation_v0_1 +decision: D-020 +test_id: T-005e +status: planned +xfail_reason: "Sprint S-5 — COUNT(DISTINCT) single-step over 1:N (D-020 (d), D-022 caveat)" diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005e-count-distinct-single-step/model.yaml b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005e-count-distinct-single-step/model.yaml new file mode 100644 index 0000000..916557f --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005e-count-distinct-single-step/model.yaml @@ -0,0 +1,37 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: distinct_order_statuses, expression: "COUNT(DISTINCT orders.status)"} diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005e-count-distinct-single-step/query.json b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005e-count-distinct-single-step/query.json new file mode 100644 index 0000000..09b7c63 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-005e-count-distinct-single-step/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "customers", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "distinct_order_statuses", + "metric": "distinct_order_statuses" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-024-boolean-home-grain-scalar-in-where/gold.sql b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-024-boolean-home-grain-scalar-in-where/gold.sql new file mode 100644 index 0000000..05931a5 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-024-boolean-home-grain-scalar-in-where/gold.sql @@ -0,0 +1,12 @@ +-- Count orders per region, restricted to premium customers. The query +-- selects metric ``order_count = COUNT(orders.id)`` aliased as +-- ``customer_count`` so the row count is in *orders*, not customers. +-- The WHERE filter ``customers.is_premium`` resolves as a row-level +-- boolean scalar over customers' own columns (D-005(a)), so it is +-- accepted in ``Where``. +SELECT c.region AS region, COUNT(o.id) AS customer_count +FROM customers c +JOIN orders o ON o.customer_id = c.id +WHERE c.segment = 'PREMIUM' +GROUP BY c.region +ORDER BY c.region NULLS LAST diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-024-boolean-home-grain-scalar-in-where/metadata.yaml b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-024-boolean-home-grain-scalar-in-where/metadata.yaml new file mode 100644 index 0000000..a725b22 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-024-boolean-home-grain-scalar-in-where/metadata.yaml @@ -0,0 +1,13 @@ +name: t-024-boolean-home-grain-scalar-in-where +description: "Boolean home-grain scalar field is usable in Where (predicate routes by resolved shape, not surface syntax)" +area: cross_grain +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-005" +tags: [predicate-routing, home-grain, where] +conformance_level: foundation_v0_1 +decision: D-005 +test_id: T-024 +status: planned +xfail_reason: "Sprint S-5 — predicate routing by resolved shape, boolean home-grain scalar in Where (D-005e)" diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-024-boolean-home-grain-scalar-in-where/model.yaml b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-024-boolean-home-grain-scalar-in-where/model.yaml new file mode 100644 index 0000000..86ff5bf --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-024-boolean-home-grain-scalar-in-where/model.yaml @@ -0,0 +1,39 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + # Boolean row-level scalar over the home dataset's own columns. + # Per D-005(a), this resolved-shape is accepted in Where. + - {name: is_premium, expression: "segment = 'PREMIUM'"} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/cross_grain/moderate/t-024-boolean-home-grain-scalar-in-where/query.json b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-024-boolean-home-grain-scalar-in-where/query.json new file mode 100644 index 0000000..2b72517 --- /dev/null +++ b/compliance/foundation-v0.1/tests/cross_grain/moderate/t-024-boolean-home-grain-scalar-in-where/query.json @@ -0,0 +1,18 @@ +{ + "dataset": "customers", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "customer_count", + "metric": "order_count" + } + ], + "filters": [ + "customers.is_premium" + ], + "order_by": [ + {"target": "customers.region"} + ] +} diff --git a/compliance/foundation-v0.1/tests/edge_cases/easy/t-060-empty-input-count-distinct/gold.sql b/compliance/foundation-v0.1/tests/edge_cases/easy/t-060-empty-input-count-distinct/gold.sql new file mode 100644 index 0000000..d38e1d5 --- /dev/null +++ b/compliance/foundation-v0.1/tests/edge_cases/easy/t-060-empty-input-count-distinct/gold.sql @@ -0,0 +1,7 @@ +-- T-060: empty result set (no orders > $1M) — must return zero rows. +SELECT c.region AS region, COUNT(DISTINCT o.status) AS n_distinct_status +FROM orders o +LEFT JOIN customers c ON o.customer_id = c.id +WHERE o.amount > 1000000 +GROUP BY c.region +ORDER BY region NULLS LAST diff --git a/compliance/foundation-v0.1/tests/edge_cases/easy/t-060-empty-input-count-distinct/metadata.yaml b/compliance/foundation-v0.1/tests/edge_cases/easy/t-060-empty-input-count-distinct/metadata.yaml new file mode 100644 index 0000000..bf0a19b --- /dev/null +++ b/compliance/foundation-v0.1/tests/edge_cases/easy/t-060-empty-input-count-distinct/metadata.yaml @@ -0,0 +1,13 @@ +name: t-060-empty-input-count-distinct +description: "S-E hot-spot 25: COUNT(DISTINCT x) over an empty group returns 0 (D-033)." +area: edge_cases +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-033" +tags: [empty-input, count-distinct, edge-case] +conformance_level: foundation_v0_1 +decision: D-033 +test_id: T-060 +status: planned +xfail_reason: "S-E — drift-hot-spot edge case for COUNT DISTINCT over empty group" diff --git a/compliance/foundation-v0.1/tests/edge_cases/easy/t-060-empty-input-count-distinct/model.yaml b/compliance/foundation-v0.1/tests/edge_cases/easy/t-060-empty-input-count-distinct/model.yaml new file mode 100644 index 0000000..f6a9417 --- /dev/null +++ b/compliance/foundation-v0.1/tests/edge_cases/easy/t-060-empty-input-count-distinct/model.yaml @@ -0,0 +1,21 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: distinct_status_count, expression: "COUNT(DISTINCT orders.status)"} diff --git a/compliance/foundation-v0.1/tests/edge_cases/easy/t-060-empty-input-count-distinct/query.json b/compliance/foundation-v0.1/tests/edge_cases/easy/t-060-empty-input-count-distinct/query.json new file mode 100644 index 0000000..83af69b --- /dev/null +++ b/compliance/foundation-v0.1/tests/edge_cases/easy/t-060-empty-input-count-distinct/query.json @@ -0,0 +1,10 @@ +{ + "dataset": "orders", + "dimensions": ["customers.region"], + "measures": [ + {"name": "n_distinct_status", "metric": "distinct_status_count"} + ], + "filters": [ + "orders.amount > 1000000" + ] +} diff --git a/compliance/foundation-v0.1/tests/edge_cases/easy/t-061-orphan-fact-row-null-dim/gold.sql b/compliance/foundation-v0.1/tests/edge_cases/easy/t-061-orphan-fact-row-null-dim/gold.sql new file mode 100644 index 0000000..da1cb7a --- /dev/null +++ b/compliance/foundation-v0.1/tests/edge_cases/easy/t-061-orphan-fact-row-null-dim/gold.sql @@ -0,0 +1,14 @@ +-- T-061: f_prelude has orphan order 105 with customer_id=99 (no +-- matching customer). Foundation default (D-001) is LEFT JOIN +-- fact->dim, so the orphan row's amount is summed under region=NULL. +-- +-- Expected: +-- region=NULL -> 30 (orphan order 105) +-- region=EAST -> 350 (101+102+103) +-- region=WEST -> 75 (104) +SELECT c.region AS region, + SUM(o.amount) AS revenue +FROM orders o +LEFT JOIN customers c ON o.customer_id = c.id +GROUP BY c.region +ORDER BY region NULLS LAST diff --git a/compliance/foundation-v0.1/tests/edge_cases/easy/t-061-orphan-fact-row-null-dim/metadata.yaml b/compliance/foundation-v0.1/tests/edge_cases/easy/t-061-orphan-fact-row-null-dim/metadata.yaml new file mode 100644 index 0000000..73d718d --- /dev/null +++ b/compliance/foundation-v0.1/tests/edge_cases/easy/t-061-orphan-fact-row-null-dim/metadata.yaml @@ -0,0 +1,14 @@ +name: t-061-orphan-fact-row-null-dim +description: "S-E hot-spot 5: orphan fact row (customer_id=99 with no matching customer) appears under a NULL region in the LEFT-join Foundation default." +area: edge_cases +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-001" + - "Proposed_OSI_Semantics.md#D-014" +tags: [orphan-fact, null-dim, left-join] +conformance_level: foundation_v0_1 +decision: D-001 +test_id: T-061 +status: planned +xfail_reason: "S-E — drift-hot-spot edge case for orphan-fact row preservation under LEFT-join default" diff --git a/compliance/foundation-v0.1/tests/edge_cases/easy/t-061-orphan-fact-row-null-dim/model.yaml b/compliance/foundation-v0.1/tests/edge_cases/easy/t-061-orphan-fact-row-null-dim/model.yaml new file mode 100644 index 0000000..c19751b --- /dev/null +++ b/compliance/foundation-v0.1/tests/edge_cases/easy/t-061-orphan-fact-row-null-dim/model.yaml @@ -0,0 +1,20 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} diff --git a/compliance/foundation-v0.1/tests/edge_cases/easy/t-061-orphan-fact-row-null-dim/query.json b/compliance/foundation-v0.1/tests/edge_cases/easy/t-061-orphan-fact-row-null-dim/query.json new file mode 100644 index 0000000..77224cc --- /dev/null +++ b/compliance/foundation-v0.1/tests/edge_cases/easy/t-061-orphan-fact-row-null-dim/query.json @@ -0,0 +1,7 @@ +{ + "dataset": "orders", + "dimensions": ["customers.region"], + "measures": [ + {"name": "revenue", "metric": "total_revenue"} + ] +} diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-025-scalar-two-unrelated-facts/gold.sql b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-025-scalar-two-unrelated-facts/gold.sql new file mode 100644 index 0000000..0bf9993 --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-025-scalar-two-unrelated-facts/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_FAN_OUT_IN_SCALAR_QUERY: two row-level facts on many-side, no single home diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-025-scalar-two-unrelated-facts/metadata.yaml b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-025-scalar-two-unrelated-facts/metadata.yaml new file mode 100644 index 0000000..05d0847 --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-025-scalar-two-unrelated-facts/metadata.yaml @@ -0,0 +1,15 @@ +name: t-025-scalar-two-unrelated-facts +description: "Scalar query referencing two unrelated row-level facts is rejected with E_FAN_OUT_IN_SCALAR_QUERY" +area: empty_inputs +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-023" +tags: [scalar, fan-out, multi-fact, negative] +conformance_level: foundation_v0_1 +decision: D-023 +test_id: T-025 +expected_error: true +expected_error_code: E_FAN_OUT_IN_SCALAR_QUERY +status: planned +xfail_reason: "Sprint S-14 — scalar two-fact rejection (D-023 extended)" diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-025-scalar-two-unrelated-facts/model.yaml b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-025-scalar-two-unrelated-facts/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-025-scalar-two-unrelated-facts/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-025-scalar-two-unrelated-facts/query.json b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-025-scalar-two-unrelated-facts/query.json new file mode 100644 index 0000000..02589a2 --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-025-scalar-two-unrelated-facts/query.json @@ -0,0 +1,8 @@ +{ + "dataset": "customers", + "fields": [ + "orders.amount", + "returns.amount", + "customers.region" + ] +} diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-047-empty-set-sum-vs-count/gold.sql b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-047-empty-set-sum-vs-count/gold.sql new file mode 100644 index 0000000..b8a7e5f --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-047-empty-set-sum-vs-count/gold.sql @@ -0,0 +1,12 @@ +-- T-047 empty/non-empty group D-033 behaviour: +-- SUM(amount) ⇒ NULL on an empty group (here: no empties). +-- COUNT(orders.id) ⇒ 0 on an empty group. +-- COUNT(DISTINCT orders.id) ⇒ 0 on an empty group. +SELECT c.region AS region, + SUM(o.amount) AS revenue, + COUNT(o.id) AS order_count, + COUNT(DISTINCT o.id) AS row_count +FROM orders o +LEFT JOIN customers c ON o.customer_id = c.id +GROUP BY c.region +ORDER BY region NULLS LAST diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-047-empty-set-sum-vs-count/metadata.yaml b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-047-empty-set-sum-vs-count/metadata.yaml new file mode 100644 index 0000000..8a64cde --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-047-empty-set-sum-vs-count/metadata.yaml @@ -0,0 +1,13 @@ +name: t-047-empty-set-sum-vs-count +description: "Empty-set aggregate behaviour: SUM → NULL, COUNT(x) → 0, COUNT(*) → 0 (D-033)" +area: empty_inputs +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-033" +tags: [empty-set, null, count] +conformance_level: foundation_v0_1 +decision: D-033 +test_id: T-047 +status: planned +xfail_reason: "Sprint S-14 — empty/null aggregate behaviour (D-033)" diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-047-empty-set-sum-vs-count/model.yaml b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-047-empty-set-sum-vs-count/model.yaml new file mode 100644 index 0000000..c9f0778 --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-047-empty-set-sum-vs-count/model.yaml @@ -0,0 +1,37 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: order_id_count, expression: "COUNT(DISTINCT orders.id)"} diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-047-empty-set-sum-vs-count/query.json b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-047-empty-set-sum-vs-count/query.json new file mode 100644 index 0000000..c90bac6 --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-047-empty-set-sum-vs-count/query.json @@ -0,0 +1,20 @@ +{ + "dataset": "orders", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "revenue", + "metric": "total_revenue" + }, + { + "name": "order_count", + "metric": "order_count" + }, + { + "name": "row_count", + "metric": "order_id_count" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-048-null-foreign-key/gold.sql b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-048-null-foreign-key/gold.sql new file mode 100644 index 0000000..f15519f --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-048-null-foreign-key/gold.sql @@ -0,0 +1,7 @@ +-- Uses F-PRELUDE; the NULL-FK case (order 106) would be added by the +-- planner's LEFT-join shape if a NULL-FK row existed. This gold runs +-- the same shape as T-001 against F-PRELUDE. +SELECT c.region AS region, SUM(o.amount) AS revenue +FROM orders o +LEFT JOIN customers c ON o.customer_id = c.id +GROUP BY c.region diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-048-null-foreign-key/metadata.yaml b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-048-null-foreign-key/metadata.yaml new file mode 100644 index 0000000..f7f987e --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-048-null-foreign-key/metadata.yaml @@ -0,0 +1,13 @@ +name: t-048-null-foreign-key +description: "NULL foreign key in 1:N enrichment buckets into NULL-region row (same as orphan)" +area: empty_inputs +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-004" +tags: [null-fk, left-join] +conformance_level: foundation_v0_1 +decision: D-004 +test_id: T-048 +status: planned +xfail_reason: "Sprint S-14 — NULL FK as orphan-bucket (D-004 / D-033); inline NULL-FK row needed in S-E" diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-048-null-foreign-key/model.yaml b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-048-null-foreign-key/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-048-null-foreign-key/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-048-null-foreign-key/query.json b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-048-null-foreign-key/query.json new file mode 100644 index 0000000..5243187 --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-048-null-foreign-key/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "orders", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "revenue", + "metric": "total_revenue" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-049-null-dimension-column/gold.sql b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-049-null-dimension-column/gold.sql new file mode 100644 index 0000000..46c0c29 --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-049-null-dimension-column/gold.sql @@ -0,0 +1,31 @@ +-- T-049: two measures from incompatible roots (revenue from orders, +-- customer_count from customers) ⇒ Foundation default is the +-- FULL OUTER stitch on the shared dimension `region` (D-001 / S-7). +-- +-- Expected (from f_prelude seed): +-- region=NULL -> revenue= 30 (orphan order 105 → customer 99 missing) +-- customer_count=NULL +-- region=EAST -> revenue=350 (101+102+103) customer_count=2 +-- region=NORTH -> revenue=NULL (no orders) customer_count=1 +-- region=WEST -> revenue= 75 (104) customer_count=1 +SELECT region, + SUM(revenue) AS revenue, + SUM(customer_count) AS customer_count +FROM ( + SELECT c.region AS region, + o.amount AS revenue, + CAST(NULL AS BIGINT) AS customer_count + FROM orders o + LEFT JOIN customers c ON o.customer_id = c.id + UNION ALL + SELECT region, + CAST(NULL AS DECIMAL(10, 2)) AS revenue, + cc AS customer_count + FROM ( + SELECT region, COUNT(id) AS cc + FROM customers + GROUP BY region + ) by_region +) stitched +GROUP BY region +ORDER BY region NULLS LAST diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-049-null-dimension-column/metadata.yaml b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-049-null-dimension-column/metadata.yaml new file mode 100644 index 0000000..1a57598 --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-049-null-dimension-column/metadata.yaml @@ -0,0 +1,18 @@ +name: t-049-null-dimension-column +description: >- + ``NULL`` values in a grouped dimension column collapse to a + single ``NULL`` group (standard SQL ``GROUP BY`` semantics). + Per D-033, empty / ``NULL`` behaviour follows standard SQL; + D-014 (per-engine SQL byte-determinism) is unrelated. +area: empty_inputs +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-033" + - "Proposed_OSI_Semantics.md#sec-6.11" +tags: [null, group-by] +conformance_level: foundation_v0_1 +decision: D-033 +test_id: T-049 +status: planned +xfail_reason: "Sprint S-14 — NULL-dim group collapse; inline NULL-region customer 4 needed in S-E" diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-049-null-dimension-column/model.yaml b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-049-null-dimension-column/model.yaml new file mode 100644 index 0000000..4e530c6 --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-049-null-dimension-column/model.yaml @@ -0,0 +1,37 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: customer_count, expression: "COUNT(customers.id)"} diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-049-null-dimension-column/query.json b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-049-null-dimension-column/query.json new file mode 100644 index 0000000..8ee8bc6 --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-049-null-dimension-column/query.json @@ -0,0 +1,16 @@ +{ + "dataset": "orders", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "revenue", + "metric": "total_revenue" + }, + { + "name": "customer_count", + "metric": "customer_count" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-053-avg-min-max-empty/gold.sql b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-053-avg-min-max-empty/gold.sql new file mode 100644 index 0000000..7ba26df --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-053-avg-min-max-empty/gold.sql @@ -0,0 +1,7 @@ +SELECT c.region AS region, + AVG(o.amount) AS avg_amount, + MIN(o.amount) AS min_amount, + MAX(o.amount) AS max_amount +FROM orders o +LEFT JOIN customers c ON o.customer_id = c.id +GROUP BY c.region diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-053-avg-min-max-empty/metadata.yaml b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-053-avg-min-max-empty/metadata.yaml new file mode 100644 index 0000000..7850730 --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-053-avg-min-max-empty/metadata.yaml @@ -0,0 +1,13 @@ +name: t-053-avg-min-max-empty +description: "AVG / MIN / MAX over zero matching rows return NULL (D-033 non-COUNT branch)" +area: empty_inputs +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-033" +tags: [empty-set, null, avg, min, max] +conformance_level: foundation_v0_1 +decision: D-033 +test_id: T-053 +status: planned +xfail_reason: "Sprint S-14 — AVG/MIN/MAX empty-set semantics (D-033 non-COUNT branch)" diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-053-avg-min-max-empty/model.yaml b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-053-avg-min-max-empty/model.yaml new file mode 100644 index 0000000..c71462b --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-053-avg-min-max-empty/model.yaml @@ -0,0 +1,39 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: avg_amount, expression: "AVG(orders.amount)"} + - {name: min_amount, expression: "MIN(orders.amount)"} + - {name: max_amount, expression: "MAX(orders.amount)"} diff --git a/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-053-avg-min-max-empty/query.json b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-053-avg-min-max-empty/query.json new file mode 100644 index 0000000..beefa9e --- /dev/null +++ b/compliance/foundation-v0.1/tests/empty_inputs/moderate/t-053-avg-min-max-empty/query.json @@ -0,0 +1,20 @@ +{ + "dataset": "orders", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "avg_amount", + "metric": "avg_amount" + }, + { + "name": "min_amount", + "metric": "min_amount" + }, + { + "name": "max_amount", + "metric": "max_amount" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-050-empty-aggregation-query/gold.sql b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-050-empty-aggregation-query/gold.sql new file mode 100644 index 0000000..c3317ac --- /dev/null +++ b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-050-empty-aggregation-query/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_EMPTY_AGGREGATION_QUERY: neither dimensions nor measures provided diff --git a/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-050-empty-aggregation-query/metadata.yaml b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-050-empty-aggregation-query/metadata.yaml new file mode 100644 index 0000000..ff8afa2 --- /dev/null +++ b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-050-empty-aggregation-query/metadata.yaml @@ -0,0 +1,20 @@ +name: t-050-empty-aggregation-query +description: >- + An aggregation query with neither ``Dimensions`` nor ``Measures`` + is rejected with ``E_EMPTY_AGGREGATION_QUERY`` (Appendix C, §6.2 + step A.1). D-010 separates the two query shapes (aggregation vs + scalar); this test pins the empty-aggregation half. +area: error_taxonomy +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-010" + - "Proposed_OSI_Semantics.md#sec-6.2" +tags: [empty, negative] +conformance_level: foundation_v0_1 +decision: D-010 +test_id: T-050 +expected_error: true +expected_error_code: E_EMPTY_AGGREGATION_QUERY +status: planned +xfail_reason: "Sprint S-2 — empty aggregation query rejection" diff --git a/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-050-empty-aggregation-query/model.yaml b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-050-empty-aggregation-query/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-050-empty-aggregation-query/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-050-empty-aggregation-query/query.json b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-050-empty-aggregation-query/query.json new file mode 100644 index 0000000..66e4a62 --- /dev/null +++ b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-050-empty-aggregation-query/query.json @@ -0,0 +1,5 @@ +{ + "dataset": "orders", + "dimensions": [], + "measures": [] +} diff --git a/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-051-empty-scalar-query/gold.sql b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-051-empty-scalar-query/gold.sql new file mode 100644 index 0000000..3539671 --- /dev/null +++ b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-051-empty-scalar-query/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_EMPTY_SCALAR_QUERY: Fields is empty diff --git a/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-051-empty-scalar-query/metadata.yaml b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-051-empty-scalar-query/metadata.yaml new file mode 100644 index 0000000..840879c --- /dev/null +++ b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-051-empty-scalar-query/metadata.yaml @@ -0,0 +1,15 @@ +name: t-051-empty-scalar-query +description: "Scalar query with empty Fields is rejected with E_EMPTY_SCALAR_QUERY" +area: error_taxonomy +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#E_EMPTY_SCALAR_QUERY" +tags: [empty, scalar, negative] +conformance_level: foundation_v0_1 +decision: D-010 +test_id: T-051 +expected_error: true +expected_error_code: E_EMPTY_SCALAR_QUERY +status: planned +xfail_reason: "Sprint S-2 — empty scalar query rejection" diff --git a/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-051-empty-scalar-query/model.yaml b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-051-empty-scalar-query/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-051-empty-scalar-query/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-051-empty-scalar-query/query.json b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-051-empty-scalar-query/query.json new file mode 100644 index 0000000..b77ad69 --- /dev/null +++ b/compliance/foundation-v0.1/tests/error_taxonomy/easy/t-051-empty-scalar-query/query.json @@ -0,0 +1,4 @@ +{ + "dataset": "orders", + "fields": [] +} diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-046-field-references-field-chain/gold.sql b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-046-field-references-field-chain/gold.sql new file mode 100644 index 0000000..6039511 --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-046-field-references-field-chain/gold.sql @@ -0,0 +1,19 @@ +-- Reference SQL: hand-written and portable across dialects. The +-- harness executes both this query and the adapter's emitted SQL +-- against the same fixture; only the resulting row multiset is +-- compared (per D-014, SQL byte-text is a per-engine concern). The +-- shape below — one CTE per derived field — is illustrative; the +-- adapter is free to stage differently. +WITH lt AS ( + SELECT id, qty * price AS line_total + FROM order_lines +), +dlt AS ( + SELECT id, line_total * 0.9 AS discounted_line_total + FROM lt +), +fp AS ( + SELECT id, discounted_line_total + 1 AS final_price + FROM dlt +) +SELECT SUM(final_price) AS total_final FROM fp diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-046-field-references-field-chain/metadata.yaml b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-046-field-references-field-chain/metadata.yaml new file mode 100644 index 0000000..0c3d553 --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-046-field-references-field-chain/metadata.yaml @@ -0,0 +1,11 @@ +name: t-046-field-references-field-chain +description: "Field chain a→b→c where each derived field references the previous; SUM at the home grain produces the right rows on every dialect" +area: field_metric_grain +difficulty: moderate +dataset: f_chain +spec_refs: + - "Proposed_OSI_Semantics.md#sec-4.3" +tags: [field, inter-field-deps, staging, portability] +conformance_level: foundation_v0_1 +test_id: T-046 +status: active diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-046-field-references-field-chain/model.yaml b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-046-field-references-field-chain/model.yaml new file mode 100644 index 0000000..2c90dcf --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-046-field-references-field-chain/model.yaml @@ -0,0 +1,20 @@ +name: f_chain_inter_field +datasets: + - name: order_lines + source: order_lines + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: order_id, expression: order_id, dimension: {}} + - {name: sku, expression: sku, dimension: {}} + - {name: qty, expression: qty} + - {name: price, expression: price} + # Chain in declaration order: each derived field references the + # previous derived field on the same dataset. A correct planner + # lowers this into SOURCE + ADD_COLUMNS stages so the emitted + # SQL never relies on lateral aliasing within a single SELECT. + - {name: line_total, expression: "qty * price"} + - {name: discounted_line_total, expression: "line_total * 0.9"} + - {name: final_price, expression: "discounted_line_total + 1"} +metrics: + - {name: total_final, expression: "SUM(order_lines.final_price)"} diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-046-field-references-field-chain/query.json b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-046-field-references-field-chain/query.json new file mode 100644 index 0000000..af96263 --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-046-field-references-field-chain/query.json @@ -0,0 +1,9 @@ +{ + "dataset": "order_lines", + "measures": [ + { + "name": "total_final", + "metric": "total_final" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-047-field-chain-reverse-declared/gold.sql b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-047-field-chain-reverse-declared/gold.sql new file mode 100644 index 0000000..1bc5b3d --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-047-field-chain-reverse-declared/gold.sql @@ -0,0 +1,16 @@ +-- Same expected rows as T-046 — declaration order is not semantically +-- significant. The implementation must compute the same total +-- regardless of the field declaration order. +WITH lt AS ( + SELECT id, qty * price AS line_total + FROM order_lines +), +dlt AS ( + SELECT id, line_total * 0.9 AS discounted_line_total + FROM lt +), +fp AS ( + SELECT id, discounted_line_total + 1 AS final_price + FROM dlt +) +SELECT SUM(final_price) AS total_final FROM fp diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-047-field-chain-reverse-declared/metadata.yaml b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-047-field-chain-reverse-declared/metadata.yaml new file mode 100644 index 0000000..cb3024a --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-047-field-chain-reverse-declared/metadata.yaml @@ -0,0 +1,11 @@ +name: t-047-field-chain-reverse-declared +description: "Same a→b→c chain as T-046 but fields declared in reverse order; an implementation that inlines field expressions in declaration order will produce invalid SQL on every dialect — staging by inter-field dependency is required" +area: field_metric_grain +difficulty: moderate +dataset: f_chain +spec_refs: + - "Proposed_OSI_Semantics.md#sec-4.3" +tags: [field, inter-field-deps, staging, portability, declaration-order] +conformance_level: foundation_v0_1 +test_id: T-047 +status: active diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-047-field-chain-reverse-declared/model.yaml b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-047-field-chain-reverse-declared/model.yaml new file mode 100644 index 0000000..4be19e4 --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-047-field-chain-reverse-declared/model.yaml @@ -0,0 +1,27 @@ +name: f_chain_reverse_declared +datasets: + - name: order_lines + source: order_lines + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: order_id, expression: order_id, dimension: {}} + - {name: sku, expression: sku, dimension: {}} + - {name: qty, expression: qty} + - {name: price, expression: price} + # Same chain as T-046 but declared in REVERSE topological order. + # The semantic value is identical (declaration order is not + # significant to the spec). A planner that naively inlines field + # expressions in declaration order would emit + # SELECT discounted_line_total + 1 AS final_price, + # line_total * 0.9 AS discounted_line_total, + # qty * price AS line_total ... + # which is rejected by Snowflake / PostgreSQL / SQLite (lateral + # forward reference) and silently wrong on engines that don't. + # A correct planner topologically sorts and emits one CTE stage + # per dependency level. + - {name: final_price, expression: "discounted_line_total + 1"} + - {name: discounted_line_total, expression: "line_total * 0.9"} + - {name: line_total, expression: "qty * price"} +metrics: + - {name: total_final, expression: "SUM(order_lines.final_price)"} diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-047-field-chain-reverse-declared/query.json b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-047-field-chain-reverse-declared/query.json new file mode 100644 index 0000000..af96263 --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-047-field-chain-reverse-declared/query.json @@ -0,0 +1,9 @@ +{ + "dataset": "order_lines", + "measures": [ + { + "name": "total_final", + "metric": "total_final" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-048-windowed-field-referenced/gold.sql b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-048-windowed-field-referenced/gold.sql new file mode 100644 index 0000000..d510e70 --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-048-windowed-field-referenced/gold.sql @@ -0,0 +1,28 @@ +-- Reference SQL: stage the windowed field in its own CTE so the +-- downstream CASE expression reads from a committed alias. The +-- implementation under test must produce the same SUM regardless of +-- how (or whether) it stages the intermediate CTE. +-- +-- Expected: per customer, the highest-amount order's amount is +-- counted; smaller orders contribute 0. Customer 1 has orders +-- (101, 100) and (102, 50) → 100 contributes. Customer 2 has order +-- (103, 200) → 200 contributes. Customer 3 has order (104, 75) → +-- 75 contributes. Orphan customer 99 has order (105, 30) → 30 +-- contributes. Total = 405. +WITH ranked AS ( + SELECT + id, + amount, + ROW_NUMBER() OVER ( + PARTITION BY customer_id + ORDER BY amount DESC, id ASC + ) AS rank_in_customer + FROM orders +), +scored AS ( + SELECT + id, + CASE WHEN rank_in_customer = 1 THEN amount ELSE 0 END AS top_only + FROM ranked +) +SELECT SUM(top_only) AS top_per_customer_total FROM scored diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-048-windowed-field-referenced/metadata.yaml b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-048-windowed-field-referenced/metadata.yaml new file mode 100644 index 0000000..20bd9cf --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-048-windowed-field-referenced/metadata.yaml @@ -0,0 +1,11 @@ +name: t-048-windowed-field-referenced +description: "A windowed field's result is consumed by a downstream field on the same dataset; the downstream field must read the windowed value from a committed CTE so portability across dialects is preserved" +area: field_metric_grain +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#sec-4.3.1" +tags: [field, window, inter-field-deps, staging] +conformance_level: foundation_v0_1 +test_id: T-048 +status: active diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-048-windowed-field-referenced/model.yaml b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-048-windowed-field-referenced/model.yaml new file mode 100644 index 0000000..cbc1e59 --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-048-windowed-field-referenced/model.yaml @@ -0,0 +1,34 @@ +name: f_prelude_windowed_field_chain +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + # Windowed field — ROW_NUMBER over orders within a customer + # ranked by amount DESC. Per §4.3.1 window functions are + # evaluated at the home grain (per row of the orders dataset). + - name: rank_in_customer + expression: "ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC, id ASC)" + # Downstream field that reads the windowed value. Inlining both + # into a single SELECT (``ROW_NUMBER() OVER (...) AS rn, CASE + # WHEN rn = 1 THEN amount ELSE 0 END AS top_only``) would rely + # on lateral aliasing within one SELECT — rejected on Snowflake, + # PostgreSQL, and SQLite. The planner must stage the windowed + # field in its own CTE before the downstream reference. + - name: top_only + expression: "CASE WHEN rank_in_customer = 1 THEN amount ELSE 0 END" +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: top_per_customer_total, expression: "SUM(orders.top_only)"} diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-048-windowed-field-referenced/query.json b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-048-windowed-field-referenced/query.json new file mode 100644 index 0000000..e9f1bb9 --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-048-windowed-field-referenced/query.json @@ -0,0 +1,9 @@ +{ + "dataset": "orders", + "measures": [ + { + "name": "top_per_customer_total", + "metric": "top_per_customer_total" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-049-field-chain-cross-dataset-enrich/gold.sql b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-049-field-chain-cross-dataset-enrich/gold.sql new file mode 100644 index 0000000..08294b4 --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-049-field-chain-cross-dataset-enrich/gold.sql @@ -0,0 +1,24 @@ +-- Reference SQL: stage the customer-side derived fields in CTEs so +-- the enrich SELECT projects them by name. The implementation is +-- free to stage differently; only the rows matter. +-- +-- Expected rows (group by tagged_region_segment): +-- ('rs=EAST:retail', 350.00) — orders 101 (100) + 102 (50) for cust 1, order 103 (200) for cust 2 +-- ('rs=WEST:wholesale', 75.00) — order 104 for cust 3 +-- (NULL, 30.00) — orphan order 105 (cust_id=99 has no matching customer) +-- Customer 4 (NORTH:retail) has no orders and so does not appear when +-- the join walks from the orders side. +WITH cust_rs AS ( + SELECT id, region || ':' || segment AS region_segment + FROM customers +), +cust_tagged AS ( + SELECT id, 'rs=' || region_segment AS tagged_region_segment + FROM cust_rs +) +SELECT + c.tagged_region_segment AS tagged_region_segment, + SUM(o.amount) AS total_amount +FROM orders o +LEFT JOIN cust_tagged c ON o.customer_id = c.id +GROUP BY c.tagged_region_segment diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-049-field-chain-cross-dataset-enrich/metadata.yaml b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-049-field-chain-cross-dataset-enrich/metadata.yaml new file mode 100644 index 0000000..2d96146 --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-049-field-chain-cross-dataset-enrich/metadata.yaml @@ -0,0 +1,12 @@ +name: t-049-field-chain-cross-dataset-enrich +description: "An enriched (N:1) child dataset declares fields that reference other sibling fields; the planner must stage the child's derived fields before the join so the enrich SELECT projects them by name" +area: field_metric_grain +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#sec-4.3" + - "Proposed_OSI_Semantics.md#sec-6.5.1" +tags: [field, inter-field-deps, enrich, staging, portability] +conformance_level: foundation_v0_1 +test_id: T-049 +status: active diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-049-field-chain-cross-dataset-enrich/model.yaml b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-049-field-chain-cross-dataset-enrich/model.yaml new file mode 100644 index 0000000..fb5a044 --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-049-field-chain-cross-dataset-enrich/model.yaml @@ -0,0 +1,34 @@ +name: f_prelude_enrich_with_derived_child +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + # Derived fields on the *enrich child* — each references the + # previous sibling. A planner that inlines child columns into + # the enrich SELECT (``... region || segment AS combo, + # 'tag:' || combo AS tagged ...``) would emit lateral aliasing + # within the enrich SELECT itself, which Snowflake / PostgreSQL / + # SQLite reject. The planner must stage the child as + # SOURCE + ADD_COLUMNS first, then ENRICH_DERIVED reads the + # staged columns by name. + - name: region_segment + expression: "region || ':' || segment" + dimension: {} + - name: tagged_region_segment + expression: "'rs=' || region_segment" + dimension: {} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_amount, expression: "SUM(orders.amount)"} diff --git a/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-049-field-chain-cross-dataset-enrich/query.json b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-049-field-chain-cross-dataset-enrich/query.json new file mode 100644 index 0000000..e5213c0 --- /dev/null +++ b/compliance/foundation-v0.1/tests/field_metric_grain/moderate/t-049-field-chain-cross-dataset-enrich/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "customers", + "dimensions": [ + "customers.tagged_region_segment" + ], + "measures": [ + { + "name": "total_amount", + "metric": "total_amount" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/joins_default/easy/t-013-no-stitching-dimension/gold.sql b/compliance/foundation-v0.1/tests/joins_default/easy/t-013-no-stitching-dimension/gold.sql new file mode 100644 index 0000000..1ef732c --- /dev/null +++ b/compliance/foundation-v0.1/tests/joins_default/easy/t-013-no-stitching-dimension/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_NO_PATH: orders and inventory_movements have no shared dimension or path diff --git a/compliance/foundation-v0.1/tests/joins_default/easy/t-013-no-stitching-dimension/metadata.yaml b/compliance/foundation-v0.1/tests/joins_default/easy/t-013-no-stitching-dimension/metadata.yaml new file mode 100644 index 0000000..dde5d68 --- /dev/null +++ b/compliance/foundation-v0.1/tests/joins_default/easy/t-013-no-stitching-dimension/metadata.yaml @@ -0,0 +1,20 @@ +name: t-013-no-stitching-dimension +description: >- + Two unrelated facts (different roots, no path) referenced together + with no shared stitch dimension would yield a Cartesian product. + Appendix C mandates the rejection code ``E3013_NO_STITCHING_DIMENSION`` + (§6.1, §6.8 / D-007). +area: joins_default +difficulty: easy +dataset: f_nopath +spec_refs: + - "Proposed_OSI_Semantics.md#D-007" + - "Proposed_OSI_Semantics.md#sec-6.8" +tags: [multi-fact, no-path, negative] +conformance_level: foundation_v0_1 +decision: D-007 +test_id: T-013 +expected_error: true +expected_error_code: E3013 +status: planned +xfail_reason: "Sprint S-7 — unrelated-facts rejection (D-007)" diff --git a/compliance/foundation-v0.1/tests/joins_default/easy/t-013-no-stitching-dimension/model.yaml b/compliance/foundation-v0.1/tests/joins_default/easy/t-013-no-stitching-dimension/model.yaml new file mode 100644 index 0000000..7a869ed --- /dev/null +++ b/compliance/foundation-v0.1/tests/joins_default/easy/t-013-no-stitching-dimension/model.yaml @@ -0,0 +1,20 @@ +name: f_nopath_model +datasets: + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: inventory_movements + source: inventory_movements + primary_key: [movement_id] + fields: + - {name: movement_id, expression: movement_id, dimension: {}} + - {name: warehouse_id, expression: warehouse_id, dimension: {}} + - {name: quantity, expression: quantity} +relationships: [] +metrics: + - {name: total_amount, expression: "SUM(orders.amount)"} + - {name: total_quantity, expression: "SUM(inventory_movements.quantity)"} diff --git a/compliance/foundation-v0.1/tests/joins_default/easy/t-013-no-stitching-dimension/query.json b/compliance/foundation-v0.1/tests/joins_default/easy/t-013-no-stitching-dimension/query.json new file mode 100644 index 0000000..de04cbc --- /dev/null +++ b/compliance/foundation-v0.1/tests/joins_default/easy/t-013-no-stitching-dimension/query.json @@ -0,0 +1,13 @@ +{ + "dataset": "orders", + "measures": [ + { + "name": "total_amount", + "metric": "total_amount" + }, + { + "name": "total_quantity", + "metric": "total_quantity" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/joins_default/hard/t-045-two-measure-stitch-bridge/gold.sql b/compliance/foundation-v0.1/tests/joins_default/hard/t-045-two-measure-stitch-bridge/gold.sql new file mode 100644 index 0000000..0d45865 --- /dev/null +++ b/compliance/foundation-v0.1/tests/joins_default/hard/t-045-two-measure-stitch-bridge/gold.sql @@ -0,0 +1,10 @@ +-- T-045 two-branch bridge stitch; structural test. +WITH dedup AS ( + SELECT DISTINCT a.height AS height, m.movie_id AS movie_id, m.gross AS gross + FROM appearances ap + JOIN actors a ON ap.actor_id = a.actor_id + JOIN movies m ON ap.movie_id = m.movie_id +) +SELECT height, SUM(gross) AS total_gross, COUNT(DISTINCT movie_id) AS distinct_movies +FROM dedup +GROUP BY height diff --git a/compliance/foundation-v0.1/tests/joins_default/hard/t-045-two-measure-stitch-bridge/metadata.yaml b/compliance/foundation-v0.1/tests/joins_default/hard/t-045-two-measure-stitch-bridge/metadata.yaml new file mode 100644 index 0000000..7bbbbf2 --- /dev/null +++ b/compliance/foundation-v0.1/tests/joins_default/hard/t-045-two-measure-stitch-bridge/metadata.yaml @@ -0,0 +1,13 @@ +name: t-045-two-measure-stitch-bridge +description: "Two-measure stitch where one branch needs bridge resolution (revenue + COUNT(DISTINCT campaign))" +area: joins_default +difficulty: hard +dataset: f_bridge +spec_refs: + - "Proposed_OSI_Semantics.md#D-026" +tags: [bridge, stitch, full-outer] +conformance_level: foundation_v0_1 +decision: D-026 +test_id: T-045 +status: planned +xfail_reason: "Sprint S-7 — two-measure bridge stitch (D-026)" diff --git a/compliance/foundation-v0.1/tests/joins_default/hard/t-045-two-measure-stitch-bridge/model.yaml b/compliance/foundation-v0.1/tests/joins_default/hard/t-045-two-measure-stitch-bridge/model.yaml new file mode 100644 index 0000000..57a3f81 --- /dev/null +++ b/compliance/foundation-v0.1/tests/joins_default/hard/t-045-two-measure-stitch-bridge/model.yaml @@ -0,0 +1,30 @@ +name: f_bridge_model +datasets: + - name: actors + source: actors + primary_key: [actor_id] + fields: + - {name: actor_id, expression: actor_id, dimension: {}} + - {name: name, expression: name, dimension: {}} + - {name: height, expression: height, dimension: {}} + - name: movies + source: movies + primary_key: [movie_id] + fields: + - {name: movie_id, expression: movie_id, dimension: {}} + - {name: title, expression: title, dimension: {}} + - {name: gross, expression: gross} + - name: appearances + source: appearances + primary_key: [actor_id, movie_id] + role: bridge + fields: + - {name: actor_id, expression: actor_id, dimension: {}} + - {name: movie_id, expression: movie_id, dimension: {}} +relationships: + - {name: app_to_actor, from: appearances, to: actors, from_columns: [actor_id], to_columns: [actor_id]} + - {name: app_to_movie, from: appearances, to: movies, from_columns: [movie_id], to_columns: [movie_id]} +metrics: + - {name: total_gross, expression: "SUM(movies.gross)"} + - {name: avg_gross_per_actor, expression: "AVG(movies.gross)"} + - {name: distinct_movies, expression: "COUNT(DISTINCT movies.movie_id)"} diff --git a/compliance/foundation-v0.1/tests/joins_default/hard/t-045-two-measure-stitch-bridge/query.json b/compliance/foundation-v0.1/tests/joins_default/hard/t-045-two-measure-stitch-bridge/query.json new file mode 100644 index 0000000..0103411 --- /dev/null +++ b/compliance/foundation-v0.1/tests/joins_default/hard/t-045-two-measure-stitch-bridge/query.json @@ -0,0 +1,16 @@ +{ + "dataset": "actors", + "dimensions": [ + "actors.height" + ], + "measures": [ + { + "name": "total_gross", + "metric": "total_gross" + }, + { + "name": "distinct_movies", + "metric": "distinct_movies" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/joins_default/moderate/t-011-multi-fact-full-outer/gold.sql b/compliance/foundation-v0.1/tests/joins_default/moderate/t-011-multi-fact-full-outer/gold.sql new file mode 100644 index 0000000..9116484 --- /dev/null +++ b/compliance/foundation-v0.1/tests/joins_default/moderate/t-011-multi-fact-full-outer/gold.sql @@ -0,0 +1,13 @@ +WITH orders_branch AS ( + SELECT c.region AS region, SUM(o.amount) AS revenue + FROM orders o LEFT JOIN customers c ON o.customer_id = c.id + GROUP BY c.region +), +returns_branch AS ( + SELECT c.region AS region, SUM(r.amount) AS return_total + FROM returns r LEFT JOIN customers c ON r.customer_id = c.id + GROUP BY c.region +) +SELECT COALESCE(o.region, r.region) AS region, o.revenue, r.return_total +FROM orders_branch o +FULL OUTER JOIN returns_branch r ON o.region IS NOT DISTINCT FROM r.region diff --git a/compliance/foundation-v0.1/tests/joins_default/moderate/t-011-multi-fact-full-outer/metadata.yaml b/compliance/foundation-v0.1/tests/joins_default/moderate/t-011-multi-fact-full-outer/metadata.yaml new file mode 100644 index 0000000..9cc015b --- /dev/null +++ b/compliance/foundation-v0.1/tests/joins_default/moderate/t-011-multi-fact-full-outer/metadata.yaml @@ -0,0 +1,13 @@ +name: t-011-multi-fact-full-outer +description: "Multi-fact composition defaults to FULL OUTER stitch on shared dim (both sides preserved)" +area: joins_default +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-004" +tags: [multi-fact, full-outer, stitch] +conformance_level: foundation_v0_1 +decision: D-004 +test_id: T-011 +status: planned +xfail_reason: "Sprint S-7 — multi-fact FULL OUTER stitch (D-004b)" diff --git a/compliance/foundation-v0.1/tests/joins_default/moderate/t-011-multi-fact-full-outer/model.yaml b/compliance/foundation-v0.1/tests/joins_default/moderate/t-011-multi-fact-full-outer/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/joins_default/moderate/t-011-multi-fact-full-outer/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/joins_default/moderate/t-011-multi-fact-full-outer/query.json b/compliance/foundation-v0.1/tests/joins_default/moderate/t-011-multi-fact-full-outer/query.json new file mode 100644 index 0000000..cfe8d70 --- /dev/null +++ b/compliance/foundation-v0.1/tests/joins_default/moderate/t-011-multi-fact-full-outer/query.json @@ -0,0 +1,16 @@ +{ + "dataset": "orders", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "revenue", + "metric": "total_revenue" + }, + { + "name": "return_total", + "metric": "total_returns" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/namespace/easy/t-020-bare-name-not-found/gold.sql b/compliance/foundation-v0.1/tests/namespace/easy/t-020-bare-name-not-found/gold.sql new file mode 100644 index 0000000..df4dd3d --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/easy/t-020-bare-name-not-found/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_NAME_NOT_FOUND: bare reference must not match dataset-scoped name diff --git a/compliance/foundation-v0.1/tests/namespace/easy/t-020-bare-name-not-found/metadata.yaml b/compliance/foundation-v0.1/tests/namespace/easy/t-020-bare-name-not-found/metadata.yaml new file mode 100644 index 0000000..e798343 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/easy/t-020-bare-name-not-found/metadata.yaml @@ -0,0 +1,15 @@ +name: t-020-bare-name-not-found +description: "Bare-name reference does not silently match a dataset-scoped name; raises E_NAME_NOT_FOUND" +area: namespace +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-006" +tags: [namespace, bare-name, negative] +conformance_level: foundation_v0_1 +decision: D-006 +test_id: T-020 +expected_error: true +expected_error_code: E_NAME_NOT_FOUND +status: planned +xfail_reason: "Sprint S-10 — bare-name global lookup (D-006)" diff --git a/compliance/foundation-v0.1/tests/namespace/easy/t-020-bare-name-not-found/model.yaml b/compliance/foundation-v0.1/tests/namespace/easy/t-020-bare-name-not-found/model.yaml new file mode 100644 index 0000000..7e30318 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/easy/t-020-bare-name-not-found/model.yaml @@ -0,0 +1,37 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: total_revenue_scoped, expression: "SUM(orders.amount)"} diff --git a/compliance/foundation-v0.1/tests/namespace/easy/t-020-bare-name-not-found/query.json b/compliance/foundation-v0.1/tests/namespace/easy/t-020-bare-name-not-found/query.json new file mode 100644 index 0000000..d5bb7c0 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/easy/t-020-bare-name-not-found/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "orders", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "tr", + "metric": "total_revenue_scoped_undeclared_globally" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/namespace/easy/t-040-no-path-error/gold.sql b/compliance/foundation-v0.1/tests/namespace/easy/t-040-no-path-error/gold.sql new file mode 100644 index 0000000..9498217 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/easy/t-040-no-path-error/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_NO_PATH: no relationship connects orders and inventory_movements diff --git a/compliance/foundation-v0.1/tests/namespace/easy/t-040-no-path-error/metadata.yaml b/compliance/foundation-v0.1/tests/namespace/easy/t-040-no-path-error/metadata.yaml new file mode 100644 index 0000000..e852d88 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/easy/t-040-no-path-error/metadata.yaml @@ -0,0 +1,15 @@ +name: t-040-no-path-error +description: "E_NO_PATH when no relationship connects two datasets" +area: namespace +difficulty: easy +dataset: f_nopath +spec_refs: + - "Proposed_OSI_Semantics.md#D-018" +tags: [path-resolution, no-path, negative] +conformance_level: foundation_v0_1 +decision: D-018 +test_id: T-040 +expected_error: true +expected_error_code: E_NO_PATH +status: planned +xfail_reason: "Sprint S-10 — E_NO_PATH (D-018b)" diff --git a/compliance/foundation-v0.1/tests/namespace/easy/t-040-no-path-error/model.yaml b/compliance/foundation-v0.1/tests/namespace/easy/t-040-no-path-error/model.yaml new file mode 100644 index 0000000..7a869ed --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/easy/t-040-no-path-error/model.yaml @@ -0,0 +1,20 @@ +name: f_nopath_model +datasets: + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: inventory_movements + source: inventory_movements + primary_key: [movement_id] + fields: + - {name: movement_id, expression: movement_id, dimension: {}} + - {name: warehouse_id, expression: warehouse_id, dimension: {}} + - {name: quantity, expression: quantity} +relationships: [] +metrics: + - {name: total_amount, expression: "SUM(orders.amount)"} + - {name: total_quantity, expression: "SUM(inventory_movements.quantity)"} diff --git a/compliance/foundation-v0.1/tests/namespace/easy/t-040-no-path-error/query.json b/compliance/foundation-v0.1/tests/namespace/easy/t-040-no-path-error/query.json new file mode 100644 index 0000000..b8561eb --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/easy/t-040-no-path-error/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "orders", + "dimensions": [ + "orders.customer_id" + ], + "measures": [ + { + "name": "qty", + "metric": "total_quantity" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/namespace/easy/t-041-reserved-name/gold.sql b/compliance/foundation-v0.1/tests/namespace/easy/t-041-reserved-name/gold.sql new file mode 100644 index 0000000..3fc7db8 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/easy/t-041-reserved-name/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_RESERVED_NAME: field name 'select' is a reserved SQL keyword diff --git a/compliance/foundation-v0.1/tests/namespace/easy/t-041-reserved-name/metadata.yaml b/compliance/foundation-v0.1/tests/namespace/easy/t-041-reserved-name/metadata.yaml new file mode 100644 index 0000000..1579cb9 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/easy/t-041-reserved-name/metadata.yaml @@ -0,0 +1,15 @@ +name: t-041-reserved-name +description: "OSI grammar reserved name (`filter`) used as a user field name is rejected with E_RESERVED_NAME (D-019: GRAIN, FILTER, QUERY_FILTER are reserved)." +area: namespace +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-019" +tags: [reserved-name, negative] +conformance_level: foundation_v0_1 +decision: D-019 +test_id: T-041 +expected_error: true +expected_error_code: E_RESERVED_NAME +status: planned +xfail_reason: "Sprint S-10 — reserved-name rejection (D-019)" diff --git a/compliance/foundation-v0.1/tests/namespace/easy/t-041-reserved-name/model.yaml b/compliance/foundation-v0.1/tests/namespace/easy/t-041-reserved-name/model.yaml new file mode 100644 index 0000000..0678978 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/easy/t-041-reserved-name/model.yaml @@ -0,0 +1,37 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - {name: filter, expression: "'placeholder'"} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/namespace/easy/t-041-reserved-name/query.json b/compliance/foundation-v0.1/tests/namespace/easy/t-041-reserved-name/query.json new file mode 100644 index 0000000..71fa596 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/easy/t-041-reserved-name/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "customers", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "revenue", + "metric": "total_revenue" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/namespace/hard/t-043-multi-hop-n1-chain/gold.sql b/compliance/foundation-v0.1/tests/namespace/hard/t-043-multi-hop-n1-chain/gold.sql new file mode 100644 index 0000000..a64117f --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/hard/t-043-multi-hop-n1-chain/gold.sql @@ -0,0 +1,14 @@ +-- T-043 multi-hop chain: order_lines -> orders -> customers -> segments +-- revenue = SUM(qty * price), grouped by segments.name. +-- Expected (from f_chain seed): +-- retail -> 50 + 50 + 100 = 200 +-- wholesale -> 300 +-- partner -> 50 +SELECT s.name AS name, + SUM(ol.qty * ol.price) AS revenue +FROM order_lines ol +LEFT JOIN orders o ON ol.order_id = o.id +LEFT JOIN customers c ON o.customer_id = c.id +LEFT JOIN segments s ON c.segment_id = s.id +GROUP BY s.name +ORDER BY name NULLS LAST diff --git a/compliance/foundation-v0.1/tests/namespace/hard/t-043-multi-hop-n1-chain/metadata.yaml b/compliance/foundation-v0.1/tests/namespace/hard/t-043-multi-hop-n1-chain/metadata.yaml new file mode 100644 index 0000000..d9d9277 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/hard/t-043-multi-hop-n1-chain/metadata.yaml @@ -0,0 +1,13 @@ +name: t-043-multi-hop-n1-chain +description: "Multi-hop N:1 enrichment chain order_lines → orders → customers → segments (inline F-CHAIN model)" +area: namespace +difficulty: hard +dataset: f_chain +spec_refs: + - "Proposed_OSI_Semantics.md#D-004" +tags: [multi-hop, n:1, left-join] +conformance_level: foundation_v0_1 +decision: D-004 +test_id: T-043 +status: planned +xfail_reason: "Sprint S-10 — multi-hop N:1 chain (F-CHAIN inline model); needs new fixture in S-E" diff --git a/compliance/foundation-v0.1/tests/namespace/hard/t-043-multi-hop-n1-chain/model.yaml b/compliance/foundation-v0.1/tests/namespace/hard/t-043-multi-hop-n1-chain/model.yaml new file mode 100644 index 0000000..269abb3 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/hard/t-043-multi-hop-n1-chain/model.yaml @@ -0,0 +1,38 @@ +name: f_chain_model +datasets: + - name: segments + source: segments + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: name, expression: name, dimension: {}} + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: segment_id, expression: segment_id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - {name: status, expression: status, dimension: {}} + - name: order_lines + source: order_lines + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: order_id, expression: order_id, dimension: {}} + - {name: sku, expression: sku, dimension: {}} + - {name: qty, expression: qty} + - {name: price, expression: price} +relationships: + - {name: cust_to_segment, from: customers, to: segments, from_columns: [segment_id], to_columns: [id]} + - {name: ord_to_cust, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: line_to_order, from: order_lines, to: orders, from_columns: [order_id], to_columns: [id]} +metrics: + - {name: revenue, expression: "SUM(order_lines.qty * order_lines.price)"} diff --git a/compliance/foundation-v0.1/tests/namespace/hard/t-043-multi-hop-n1-chain/query.json b/compliance/foundation-v0.1/tests/namespace/hard/t-043-multi-hop-n1-chain/query.json new file mode 100644 index 0000000..7c44022 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/hard/t-043-multi-hop-n1-chain/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "order_lines", + "dimensions": [ + "segments.name" + ], + "measures": [ + { + "name": "revenue", + "metric": "revenue" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/namespace/hard/t-044-composite-key-join/gold.sql b/compliance/foundation-v0.1/tests/namespace/hard/t-044-composite-key-join/gold.sql new file mode 100644 index 0000000..ab404ba --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/hard/t-044-composite-key-join/gold.sql @@ -0,0 +1,15 @@ +-- T-044 composite-key join: sales -> inventory on (store_id, sku). +-- units_sold by inventory.reorder_point. +-- Expected (from f_composite seed): +-- reorder_point=10 -> sku B in store 1: 2 +-- reorder_point=15 -> sku A in store 2: 4 +-- reorder_point=20 -> sku A in store 1: 5+3=8 +-- reorder_point=25 -> sku C in store 2: 1 +SELECT i.reorder_point AS reorder_point, + SUM(s.qty) AS units_sold +FROM sales s +LEFT JOIN inventory i + ON s.store_id = i.store_id + AND s.sku = i.sku +GROUP BY i.reorder_point +ORDER BY reorder_point NULLS LAST diff --git a/compliance/foundation-v0.1/tests/namespace/hard/t-044-composite-key-join/metadata.yaml b/compliance/foundation-v0.1/tests/namespace/hard/t-044-composite-key-join/metadata.yaml new file mode 100644 index 0000000..e9b5e15 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/hard/t-044-composite-key-join/metadata.yaml @@ -0,0 +1,21 @@ +name: t-044-composite-key-join +description: >- + A composite primary/foreign key join (``store_id``, ``sku``) + requires both components in the emitted ``ON`` clause. This is a + path-resolution / join-shape concern (§6.6, §6.9), not a deferred + relationship key — D-009 covers feature flags such as + ``referential_integrity`` / ``condition`` / ``asof``, which + composite equi-keys are not. +area: namespace +difficulty: hard +dataset: f_composite +spec_refs: + - "Proposed_OSI_Semantics.md#D-018" + - "Proposed_OSI_Semantics.md#sec-6.6" + - "Proposed_OSI_Semantics.md#sec-6.9" +tags: [composite-key, n:1] +conformance_level: foundation_v0_1 +decision: D-018 +test_id: T-044 +status: planned +xfail_reason: "Sprint S-10 — composite-key join (F-COMPOSITE inline model); needs new fixture in S-E" diff --git a/compliance/foundation-v0.1/tests/namespace/hard/t-044-composite-key-join/model.yaml b/compliance/foundation-v0.1/tests/namespace/hard/t-044-composite-key-join/model.yaml new file mode 100644 index 0000000..1d46b72 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/hard/t-044-composite-key-join/model.yaml @@ -0,0 +1,23 @@ +name: f_composite_model +datasets: + - name: inventory + source: inventory + primary_key: [store_id, sku] + fields: + - {name: store_id, expression: store_id, dimension: {}} + - {name: sku, expression: sku, dimension: {}} + - {name: stock_level, expression: stock_level} + - {name: reorder_point, expression: reorder_point, dimension: {}} + - name: sales + source: sales + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: store_id, expression: store_id, dimension: {}} + - {name: sku, expression: sku, dimension: {}} + - {name: qty, expression: qty} + - {name: sale_ts, expression: sale_ts, dimension: {is_time: true}} +relationships: + - {name: sales_to_inv, from: sales, to: inventory, from_columns: [store_id, sku], to_columns: [store_id, sku]} +metrics: + - {name: units_sold, expression: "SUM(sales.qty)"} diff --git a/compliance/foundation-v0.1/tests/namespace/hard/t-044-composite-key-join/query.json b/compliance/foundation-v0.1/tests/namespace/hard/t-044-composite-key-join/query.json new file mode 100644 index 0000000..c0dae69 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/hard/t-044-composite-key-join/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "sales", + "dimensions": [ + "inventory.reorder_point" + ], + "measures": [ + { + "name": "units_sold", + "metric": "units_sold" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/namespace/hard/t-046-reflexive-relationship/gold.sql b/compliance/foundation-v0.1/tests/namespace/hard/t-046-reflexive-relationship/gold.sql new file mode 100644 index 0000000..4b1fd37 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/hard/t-046-reflexive-relationship/gold.sql @@ -0,0 +1,11 @@ +-- T-046 reflexive relationship — Foundation surface only counts the +-- employee-side; full role-qualified traversal is deferred (D-018). +-- direct_report_count by employees.region (the simple "rows per region" count). +-- Expected (from f_reflexive seed): +-- EAST -> 4 (Alice, Bob, Dave, Eve) +-- WEST -> 2 (Carol, Frank) +SELECT region, + COUNT(id) AS direct_report_count +FROM employees +GROUP BY region +ORDER BY region NULLS LAST diff --git a/compliance/foundation-v0.1/tests/namespace/hard/t-046-reflexive-relationship/metadata.yaml b/compliance/foundation-v0.1/tests/namespace/hard/t-046-reflexive-relationship/metadata.yaml new file mode 100644 index 0000000..f41a9ce --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/hard/t-046-reflexive-relationship/metadata.yaml @@ -0,0 +1,13 @@ +name: t-046-reflexive-relationship +description: "Reflexive (self-referential) relationship parses cleanly and the employee dataset is groupable on its own dimensions (Foundation surface); role-qualified traversal is deferred (D-018)." +area: namespace +difficulty: hard +dataset: f_reflexive +spec_refs: + - "Proposed_OSI_Semantics.md#D-018" +tags: [reflexive, self-join, role] +conformance_level: foundation_v0_1 +decision: D-018 +test_id: T-046 +status: planned +xfail_reason: "Sprint S-9 — reflexive relationship with role qualifier (D-018); needs new fixture in S-E" diff --git a/compliance/foundation-v0.1/tests/namespace/hard/t-046-reflexive-relationship/model.yaml b/compliance/foundation-v0.1/tests/namespace/hard/t-046-reflexive-relationship/model.yaml new file mode 100644 index 0000000..39675ac --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/hard/t-046-reflexive-relationship/model.yaml @@ -0,0 +1,14 @@ +name: f_reflexive_model +datasets: + - name: employees + source: employees + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: name, expression: name, dimension: {}} + - {name: manager_id, expression: manager_id, dimension: {}} + - {name: region, expression: region, dimension: {}} +relationships: + - {name: reports_to, from: employees, to: employees, from_columns: [manager_id], to_columns: [id]} +metrics: + - {name: direct_report_count, expression: "COUNT(employees.id)"} diff --git a/compliance/foundation-v0.1/tests/namespace/hard/t-046-reflexive-relationship/query.json b/compliance/foundation-v0.1/tests/namespace/hard/t-046-reflexive-relationship/query.json new file mode 100644 index 0000000..91b0373 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/hard/t-046-reflexive-relationship/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "employees", + "dimensions": [ + "employees.region" + ], + "measures": [ + { + "name": "direct_report_count", + "metric": "direct_report_count" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/namespace/moderate/t-019-ambiguous-path/gold.sql b/compliance/foundation-v0.1/tests/namespace/moderate/t-019-ambiguous-path/gold.sql new file mode 100644 index 0000000..57c33d5 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/moderate/t-019-ambiguous-path/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_AMBIGUOUS_PATH: two relationships orders↔users (placed_by/fulfilled_by) diff --git a/compliance/foundation-v0.1/tests/namespace/moderate/t-019-ambiguous-path/metadata.yaml b/compliance/foundation-v0.1/tests/namespace/moderate/t-019-ambiguous-path/metadata.yaml new file mode 100644 index 0000000..fb51de7 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/moderate/t-019-ambiguous-path/metadata.yaml @@ -0,0 +1,15 @@ +name: t-019-ambiguous-path +description: "Two relationships between same datasets raise E_AMBIGUOUS_PATH; diagnostic names every candidate" +area: namespace +difficulty: moderate +dataset: f_ambig +spec_refs: + - "Proposed_OSI_Semantics.md#D-018" +tags: [path-resolution, ambiguous, negative] +conformance_level: foundation_v0_1 +decision: D-018 +test_id: T-019 +expected_error: true +expected_error_code: E_AMBIGUOUS_PATH +status: planned +xfail_reason: "Sprint S-10 — ambiguous path rejection (D-018a)" diff --git a/compliance/foundation-v0.1/tests/namespace/moderate/t-019-ambiguous-path/model.yaml b/compliance/foundation-v0.1/tests/namespace/moderate/t-019-ambiguous-path/model.yaml new file mode 100644 index 0000000..d14a5c7 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/moderate/t-019-ambiguous-path/model.yaml @@ -0,0 +1,21 @@ +name: f_ambig_model +datasets: + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: placed_by_id, expression: placed_by_id, dimension: {}} + - {name: fulfilled_by_id, expression: fulfilled_by_id, dimension: {}} + - {name: amount, expression: amount} + - name: users + source: users + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} +relationships: + - {name: order_placed_by, from: orders, to: users, from_columns: [placed_by_id], to_columns: [id]} + - {name: order_fulfilled_by, from: orders, to: users, from_columns: [fulfilled_by_id], to_columns: [id]} +metrics: + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/namespace/moderate/t-019-ambiguous-path/query.json b/compliance/foundation-v0.1/tests/namespace/moderate/t-019-ambiguous-path/query.json new file mode 100644 index 0000000..4341854 --- /dev/null +++ b/compliance/foundation-v0.1/tests/namespace/moderate/t-019-ambiguous-path/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "orders", + "dimensions": [ + "users.region" + ], + "measures": [ + { + "name": "order_count", + "metric": "order_count" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/nested_aggregates/hard/t-017-nested-aggregation-over-bridge/gold.sql b/compliance/foundation-v0.1/tests/nested_aggregates/hard/t-017-nested-aggregation-over-bridge/gold.sql new file mode 100644 index 0000000..fd585a7 --- /dev/null +++ b/compliance/foundation-v0.1/tests/nested_aggregates/hard/t-017-nested-aggregation-over-bridge/gold.sql @@ -0,0 +1,5 @@ +-- Negative test: planner MUST reject the AVG(AVG(...)) over an N:N +-- bridge with E_NESTED_AGGREGATION_DEFERRED (D-020(c) / D-027(d)). +-- No SQL is emitted; gold.sql is kept as a stub so the harness can +-- locate this directory. +SELECT 1 WHERE FALSE; diff --git a/compliance/foundation-v0.1/tests/nested_aggregates/hard/t-017-nested-aggregation-over-bridge/metadata.yaml b/compliance/foundation-v0.1/tests/nested_aggregates/hard/t-017-nested-aggregation-over-bridge/metadata.yaml new file mode 100644 index 0000000..364289b --- /dev/null +++ b/compliance/foundation-v0.1/tests/nested_aggregates/hard/t-017-nested-aggregation-over-bridge/metadata.yaml @@ -0,0 +1,21 @@ +name: t-017-nested-aggregation-over-bridge +description: >- + Nested ``AVG(AVG(movies.gross))`` over an N:N edge is the deferred + per-home-row form (D-020(c) / D-027(d)); the Foundation engine MUST + reject it with ``E_NESTED_AGGREGATION_DEFERRED``. Complements + ``tests/cross_grain/hard/t-005c-nested-avg/`` (1:N case) by + pinning the deferral specifically at the N:N bridge shape (D-027(d)). +area: nested_aggregates +difficulty: hard +dataset: f_bridge +spec_refs: + - "Proposed_OSI_Semantics.md#sec-4.5" + - "Proposed_OSI_Semantics.md#D-020" + - "Proposed_OSI_Semantics.md#D-027" +tags: [bridge, nested-aggregation, m:n, deferred, negative] +conformance_level: foundation_v0_1 +decision: D-027 +test_id: T-017 +expected_error: true +expected_error_code: E_NESTED_AGGREGATION_DEFERRED +status: active diff --git a/compliance/foundation-v0.1/tests/nested_aggregates/hard/t-017-nested-aggregation-over-bridge/model.yaml b/compliance/foundation-v0.1/tests/nested_aggregates/hard/t-017-nested-aggregation-over-bridge/model.yaml new file mode 100644 index 0000000..05990fe --- /dev/null +++ b/compliance/foundation-v0.1/tests/nested_aggregates/hard/t-017-nested-aggregation-over-bridge/model.yaml @@ -0,0 +1,29 @@ +name: f_bridge_model +datasets: + - name: actors + source: actors + primary_key: [actor_id] + fields: + - {name: actor_id, expression: actor_id, dimension: {}} + - {name: name, expression: name, dimension: {}} + - {name: height, expression: height, dimension: {}} + - name: movies + source: movies + primary_key: [movie_id] + fields: + - {name: movie_id, expression: movie_id, dimension: {}} + - {name: title, expression: title, dimension: {}} + - {name: gross, expression: gross} + - name: appearances + source: appearances + primary_key: [actor_id, movie_id] + role: bridge + fields: + - {name: actor_id, expression: actor_id, dimension: {}} + - {name: movie_id, expression: movie_id, dimension: {}} +relationships: + - {name: app_to_actor, from: appearances, to: actors, from_columns: [actor_id], to_columns: [actor_id]} + - {name: app_to_movie, from: appearances, to: movies, from_columns: [movie_id], to_columns: [movie_id]} +metrics: + - {name: total_gross, expression: "SUM(movies.gross)"} + - {name: avg_of_per_actor_avg, expression: "AVG(AVG(movies.gross))"} diff --git a/compliance/foundation-v0.1/tests/nested_aggregates/hard/t-017-nested-aggregation-over-bridge/query.json b/compliance/foundation-v0.1/tests/nested_aggregates/hard/t-017-nested-aggregation-over-bridge/query.json new file mode 100644 index 0000000..cca128a --- /dev/null +++ b/compliance/foundation-v0.1/tests/nested_aggregates/hard/t-017-nested-aggregation-over-bridge/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "actors", + "dimensions": [ + "actors.height" + ], + "measures": [ + { + "name": "avg_of_per_actor_avg", + "metric": "avg_of_per_actor_avg" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/null_ordering/moderate/t-026-nulls-last-default/gold.sql b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-026-nulls-last-default/gold.sql new file mode 100644 index 0000000..8fe72d4 --- /dev/null +++ b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-026-nulls-last-default/gold.sql @@ -0,0 +1,5 @@ +SELECT c.region AS region, SUM(o.amount) AS revenue +FROM orders o +LEFT JOIN customers c ON o.customer_id = c.id +GROUP BY c.region +ORDER BY c.region ASC NULLS LAST diff --git a/compliance/foundation-v0.1/tests/null_ordering/moderate/t-026-nulls-last-default/metadata.yaml b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-026-nulls-last-default/metadata.yaml new file mode 100644 index 0000000..7734648 --- /dev/null +++ b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-026-nulls-last-default/metadata.yaml @@ -0,0 +1,14 @@ +name: t-026-nulls-last-default +description: "Outer Order By with ASC defaults to NULLS LAST regardless of dialect default. The ASC half of the high-end-NULL convention (D-029); paired with t-062 which exercises the symmetric DESC ⇒ NULLS FIRST half." +area: null_ordering +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-014" + - "Proposed_OSI_Semantics.md#D-029" +tags: [order-by, nulls-last, determinism, symmetry-asc] +conformance_level: foundation_v0_1 +decision: D-014 +test_id: T-026 +status: planned +xfail_reason: "Sprint S-13 — default NULL placement emission (D-029)" diff --git a/compliance/foundation-v0.1/tests/null_ordering/moderate/t-026-nulls-last-default/model.yaml b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-026-nulls-last-default/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-026-nulls-last-default/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/null_ordering/moderate/t-026-nulls-last-default/query.json b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-026-nulls-last-default/query.json new file mode 100644 index 0000000..c131231 --- /dev/null +++ b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-026-nulls-last-default/query.json @@ -0,0 +1,18 @@ +{ + "dataset": "customers", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "revenue", + "metric": "total_revenue" + } + ], + "order_by": [ + { + "target": "customers.region", + "direction": "ASC" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/null_ordering/moderate/t-062-nulls-first-default-on-desc/gold.sql b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-062-nulls-first-default-on-desc/gold.sql new file mode 100644 index 0000000..4f75b08 --- /dev/null +++ b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-062-nulls-first-default-on-desc/gold.sql @@ -0,0 +1,5 @@ +SELECT c.region AS region, SUM(o.amount) AS revenue +FROM orders o +LEFT JOIN customers c ON o.customer_id = c.id +GROUP BY c.region +ORDER BY c.region DESC NULLS FIRST diff --git a/compliance/foundation-v0.1/tests/null_ordering/moderate/t-062-nulls-first-default-on-desc/metadata.yaml b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-062-nulls-first-default-on-desc/metadata.yaml new file mode 100644 index 0000000..31bc3cf --- /dev/null +++ b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-062-nulls-first-default-on-desc/metadata.yaml @@ -0,0 +1,14 @@ +name: t-062-nulls-first-default-on-desc +description: "Outer Order By with DESC defaults to NULLS FIRST. The DESC half of the high-end-NULL convention (D-029, amended 2026-05-13); the symmetric counterpart of t-026. Together t-026 + t-062 lock in the symmetry property: flipping ASC ↔ DESC also flips NULL placement, which is the behaviour every BI mental model assumes (e.g. 'top-N → bottom-N' brings the NULL rows into view)." +area: null_ordering +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-014" + - "Proposed_OSI_Semantics.md#D-029" +tags: [order-by, nulls-first, determinism, symmetry-desc] +conformance_level: foundation_v0_1 +decision: D-029 +test_id: T-062 +status: planned +xfail_reason: "Sprint S-13.1 — symmetry counterpart for D-029 amendment (high-end-NULL convention)" diff --git a/compliance/foundation-v0.1/tests/null_ordering/moderate/t-062-nulls-first-default-on-desc/model.yaml b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-062-nulls-first-default-on-desc/model.yaml new file mode 100644 index 0000000..954a48c --- /dev/null +++ b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-062-nulls-first-default-on-desc/model.yaml @@ -0,0 +1,21 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} diff --git a/compliance/foundation-v0.1/tests/null_ordering/moderate/t-062-nulls-first-default-on-desc/query.json b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-062-nulls-first-default-on-desc/query.json new file mode 100644 index 0000000..481382c --- /dev/null +++ b/compliance/foundation-v0.1/tests/null_ordering/moderate/t-062-nulls-first-default-on-desc/query.json @@ -0,0 +1,18 @@ +{ + "dataset": "customers", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "revenue", + "metric": "total_revenue" + } + ], + "order_by": [ + { + "target": "customers.region", + "direction": "DESC" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/predicate_routing/easy/t-007-aggregate-in-where/gold.sql b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-007-aggregate-in-where/gold.sql new file mode 100644 index 0000000..1f8b7bb --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-007-aggregate-in-where/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_AGGREGATE_IN_WHERE: aggregate predicate inside Where; should use Having diff --git a/compliance/foundation-v0.1/tests/predicate_routing/easy/t-007-aggregate-in-where/metadata.yaml b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-007-aggregate-in-where/metadata.yaml new file mode 100644 index 0000000..b873776 --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-007-aggregate-in-where/metadata.yaml @@ -0,0 +1,16 @@ +name: t-007-aggregate-in-where +description: "Aggregate inside Where is rejected with E_AGGREGATE_IN_WHERE" +area: predicate_routing +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-005" + - "Proposed_OSI_Semantics.md#D-012" +tags: [predicate-routing, negative, where] +conformance_level: foundation_v0_1 +decision: D-012 +test_id: T-007 +expected_error: true +expected_error_code: E_AGGREGATE_IN_WHERE +status: planned +xfail_reason: "Sprint S-3 — predicate routing by resolved expression shape (D-005, D-012a)" diff --git a/compliance/foundation-v0.1/tests/predicate_routing/easy/t-007-aggregate-in-where/model.yaml b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-007-aggregate-in-where/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-007-aggregate-in-where/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/predicate_routing/easy/t-007-aggregate-in-where/query.json b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-007-aggregate-in-where/query.json new file mode 100644 index 0000000..2c8f37e --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-007-aggregate-in-where/query.json @@ -0,0 +1,15 @@ +{ + "dataset": "orders", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "revenue", + "metric": "total_revenue" + } + ], + "filters": [ + "SUM(orders.amount) > 100" + ] +} diff --git a/compliance/foundation-v0.1/tests/predicate_routing/easy/t-008-non-aggregate-in-having/gold.sql b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-008-non-aggregate-in-having/gold.sql new file mode 100644 index 0000000..1abe10d --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-008-non-aggregate-in-having/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_NON_AGGREGATE_IN_HAVING: row-level predicate inside Having; should be Where diff --git a/compliance/foundation-v0.1/tests/predicate_routing/easy/t-008-non-aggregate-in-having/metadata.yaml b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-008-non-aggregate-in-having/metadata.yaml new file mode 100644 index 0000000..941a932 --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-008-non-aggregate-in-having/metadata.yaml @@ -0,0 +1,16 @@ +name: t-008-non-aggregate-in-having +description: "Row-level dimension predicate inside Having is rejected with E_NON_AGGREGATE_IN_HAVING" +area: predicate_routing +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-005" + - "Proposed_OSI_Semantics.md#D-012" +tags: [predicate-routing, negative, having] +conformance_level: foundation_v0_1 +decision: D-012 +test_id: T-008 +expected_error: true +expected_error_code: E_NON_AGGREGATE_IN_HAVING +status: planned +xfail_reason: "Sprint S-3 — predicate routing (D-012b)" diff --git a/compliance/foundation-v0.1/tests/predicate_routing/easy/t-008-non-aggregate-in-having/model.yaml b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-008-non-aggregate-in-having/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-008-non-aggregate-in-having/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/predicate_routing/easy/t-008-non-aggregate-in-having/query.json b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-008-non-aggregate-in-having/query.json new file mode 100644 index 0000000..a57a12b --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-008-non-aggregate-in-having/query.json @@ -0,0 +1,15 @@ +{ + "dataset": "orders", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "revenue", + "metric": "total_revenue" + } + ], + "qualify": [ + "customers.region = 'EAST'" + ] +} diff --git a/compliance/foundation-v0.1/tests/predicate_routing/easy/t-009-mixed-predicate-level/gold.sql b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-009-mixed-predicate-level/gold.sql new file mode 100644 index 0000000..d1be876 --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-009-mixed-predicate-level/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_MIXED_PREDICATE_LEVEL: predicate mixes row-level and aggregate levels; split required diff --git a/compliance/foundation-v0.1/tests/predicate_routing/easy/t-009-mixed-predicate-level/metadata.yaml b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-009-mixed-predicate-level/metadata.yaml new file mode 100644 index 0000000..f50b99d --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-009-mixed-predicate-level/metadata.yaml @@ -0,0 +1,16 @@ +name: t-009-mixed-predicate-level +description: "Boolean predicate mixing row-level and aggregate halves is rejected with E_MIXED_PREDICATE_LEVEL" +area: predicate_routing +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-005" + - "Proposed_OSI_Semantics.md#D-012" +tags: [predicate-routing, negative, mixed] +conformance_level: foundation_v0_1 +decision: D-012 +test_id: T-009 +expected_error: true +expected_error_code: E_MIXED_PREDICATE_LEVEL +status: planned +xfail_reason: "Sprint S-3 — predicate routing (D-012c)" diff --git a/compliance/foundation-v0.1/tests/predicate_routing/easy/t-009-mixed-predicate-level/model.yaml b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-009-mixed-predicate-level/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-009-mixed-predicate-level/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/predicate_routing/easy/t-009-mixed-predicate-level/query.json b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-009-mixed-predicate-level/query.json new file mode 100644 index 0000000..743025a --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/easy/t-009-mixed-predicate-level/query.json @@ -0,0 +1,15 @@ +{ + "dataset": "orders", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "revenue", + "metric": "total_revenue" + } + ], + "qualify": [ + "customers.region = 'EAST' AND SUM(orders.amount) > 100" + ] +} diff --git a/compliance/foundation-v0.1/tests/predicate_routing/moderate/t-023-unaggregated-finer-grain-reference/gold.sql b/compliance/foundation-v0.1/tests/predicate_routing/moderate/t-023-unaggregated-finer-grain-reference/gold.sql new file mode 100644 index 0000000..854e79d --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/moderate/t-023-unaggregated-finer-grain-reference/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_UNAGGREGATED_FINER_GRAIN_REFERENCE: row-level reference to finer grain diff --git a/compliance/foundation-v0.1/tests/predicate_routing/moderate/t-023-unaggregated-finer-grain-reference/metadata.yaml b/compliance/foundation-v0.1/tests/predicate_routing/moderate/t-023-unaggregated-finer-grain-reference/metadata.yaml new file mode 100644 index 0000000..40ea2ec --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/moderate/t-023-unaggregated-finer-grain-reference/metadata.yaml @@ -0,0 +1,15 @@ +name: t-023-unaggregated-finer-grain-reference +description: "Row-level field on customers referencing orders.amount (finer grain) raises E_UNAGGREGATED_FINER_GRAIN_REFERENCE" +area: predicate_routing +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-024" +tags: [grain, negative] +conformance_level: foundation_v0_1 +decision: D-024 +test_id: T-023 +expected_error: true +expected_error_code: E_UNAGGREGATED_FINER_GRAIN_REFERENCE +status: planned +xfail_reason: "Sprint S-3 — unaggregated finer-grain reference (D-024)" diff --git a/compliance/foundation-v0.1/tests/predicate_routing/moderate/t-023-unaggregated-finer-grain-reference/model.yaml b/compliance/foundation-v0.1/tests/predicate_routing/moderate/t-023-unaggregated-finer-grain-reference/model.yaml new file mode 100644 index 0000000..9b1f390 --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/moderate/t-023-unaggregated-finer-grain-reference/model.yaml @@ -0,0 +1,37 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - {name: first_order_amount, expression: "orders.amount"} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/predicate_routing/moderate/t-023-unaggregated-finer-grain-reference/query.json b/compliance/foundation-v0.1/tests/predicate_routing/moderate/t-023-unaggregated-finer-grain-reference/query.json new file mode 100644 index 0000000..3cb4337 --- /dev/null +++ b/compliance/foundation-v0.1/tests/predicate_routing/moderate/t-023-unaggregated-finer-grain-reference/query.json @@ -0,0 +1,7 @@ +{ + "dataset": "customers", + "fields": [ + "customers.id", + "customers.first_order_amount" + ] +} diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-001-aggregation-cardinality/gold.sql b/compliance/foundation-v0.1/tests/query_shape/easy/t-001-aggregation-cardinality/gold.sql new file mode 100644 index 0000000..23b9d95 --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-001-aggregation-cardinality/gold.sql @@ -0,0 +1,4 @@ +SELECT c.region AS region, SUM(o.amount) AS revenue +FROM orders o +LEFT JOIN customers c ON o.customer_id = c.id +GROUP BY c.region diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-001-aggregation-cardinality/metadata.yaml b/compliance/foundation-v0.1/tests/query_shape/easy/t-001-aggregation-cardinality/metadata.yaml new file mode 100644 index 0000000..66864ab --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-001-aggregation-cardinality/metadata.yaml @@ -0,0 +1,13 @@ +name: t-001-aggregation-cardinality +description: "Aggregation-query cardinality is DISTINCT(Dimensions); single-measure ⇒ Plan A (NORTH absent, orphan NULL bucket present)" +area: query_shape +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-001" +tags: [query-shape, plan-a, left-join] +conformance_level: foundation_v0_1 +decision: D-001 +test_id: T-001 +status: planned +xfail_reason: "Sprint S-2 — query shape & Plan A single-measure default join (D-001)" diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-001-aggregation-cardinality/model.yaml b/compliance/foundation-v0.1/tests/query_shape/easy/t-001-aggregation-cardinality/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-001-aggregation-cardinality/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-001-aggregation-cardinality/query.json b/compliance/foundation-v0.1/tests/query_shape/easy/t-001-aggregation-cardinality/query.json new file mode 100644 index 0000000..71fa596 --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-001-aggregation-cardinality/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "customers", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "revenue", + "metric": "total_revenue" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-002-mixed-query-shape/gold.sql b/compliance/foundation-v0.1/tests/query_shape/easy/t-002-mixed-query-shape/gold.sql new file mode 100644 index 0000000..67b6c79 --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-002-mixed-query-shape/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_MIXED_QUERY_SHAPE: Dimensions and Fields cannot both be present diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-002-mixed-query-shape/metadata.yaml b/compliance/foundation-v0.1/tests/query_shape/easy/t-002-mixed-query-shape/metadata.yaml new file mode 100644 index 0000000..2ccd2b7 --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-002-mixed-query-shape/metadata.yaml @@ -0,0 +1,15 @@ +name: t-002-mixed-query-shape +description: "Mixing Dimensions and Fields is rejected with E_MIXED_QUERY_SHAPE" +area: query_shape +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-010" +tags: [query-shape, negative, mixed] +conformance_level: foundation_v0_1 +decision: D-010 +test_id: T-002 +expected_error: true +expected_error_code: E_MIXED_QUERY_SHAPE +status: planned +xfail_reason: "Sprint S-2 — query shape detection (D-010)" diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-002-mixed-query-shape/model.yaml b/compliance/foundation-v0.1/tests/query_shape/easy/t-002-mixed-query-shape/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-002-mixed-query-shape/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-002-mixed-query-shape/query.json b/compliance/foundation-v0.1/tests/query_shape/easy/t-002-mixed-query-shape/query.json new file mode 100644 index 0000000..66ed5f2 --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-002-mixed-query-shape/query.json @@ -0,0 +1,15 @@ +{ + "dataset": "customers", + "dimensions": [ + "customers.region" + ], + "fields": [ + "customers.id" + ], + "measures": [ + { + "name": "revenue", + "metric": "total_revenue" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-006-count-star/gold.sql b/compliance/foundation-v0.1/tests/query_shape/easy/t-006-count-star/gold.sql new file mode 100644 index 0000000..efedd4a --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-006-count-star/gold.sql @@ -0,0 +1,5 @@ +SELECT c.region AS region, COUNT(*) AS order_count +FROM orders o +LEFT JOIN customers c ON o.customer_id = c.id +WHERE o.status = 'completed' +GROUP BY c.region diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-006-count-star/metadata.yaml b/compliance/foundation-v0.1/tests/query_shape/easy/t-006-count-star/metadata.yaml new file mode 100644 index 0000000..f28b4c8 --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-006-count-star/metadata.yaml @@ -0,0 +1,13 @@ +name: t-006-count-star +description: "COUNT(*) over orders dataset is well-defined at home grain; WHERE on orders.status applies pre-aggregation" +area: query_shape +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-016" +tags: [count-star, home-grain] +conformance_level: foundation_v0_1 +decision: D-016 +test_id: T-006 +status: planned +xfail_reason: "Sprint S-7 — default LEFT join + COUNT(*) home-grain (D-004a, D-016)" diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-006-count-star/model.yaml b/compliance/foundation-v0.1/tests/query_shape/easy/t-006-count-star/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-006-count-star/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-006-count-star/query.json b/compliance/foundation-v0.1/tests/query_shape/easy/t-006-count-star/query.json new file mode 100644 index 0000000..e88634b --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-006-count-star/query.json @@ -0,0 +1,15 @@ +{ + "dataset": "orders", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "order_count", + "metric": "order_count" + } + ], + "filters": [ + "orders.status = 'completed'" + ] +} diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-028-where-and-list/gold.sql b/compliance/foundation-v0.1/tests/query_shape/easy/t-028-where-and-list/gold.sql new file mode 100644 index 0000000..51aa899 --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-028-where-and-list/gold.sql @@ -0,0 +1,5 @@ +SELECT c.region AS region, SUM(o.amount) AS revenue +FROM orders o +LEFT JOIN customers c ON o.customer_id = c.id +WHERE o.status = 'completed' AND o.amount > 60 +GROUP BY c.region diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-028-where-and-list/metadata.yaml b/compliance/foundation-v0.1/tests/query_shape/easy/t-028-where-and-list/metadata.yaml new file mode 100644 index 0000000..35629e8 --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-028-where-and-list/metadata.yaml @@ -0,0 +1,18 @@ +name: t-028-where-and-list +description: >- + ``Where: [P1, P2]`` is conjunction (logical AND), not disjunction. + Predicate-routing semantics are normative per §6.3; the list form + is sugar for ``P1 AND P2``. D-014 (per-engine SQL byte-determinism) + does NOT cover this — it is a query-shape rule. +area: query_shape +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-005" + - "Proposed_OSI_Semantics.md#sec-6.3" +tags: [where, and] +conformance_level: foundation_v0_1 +decision: D-005 +test_id: T-028 +status: planned +xfail_reason: "Sprint S-2 — Where-list conjunction semantics" diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-028-where-and-list/model.yaml b/compliance/foundation-v0.1/tests/query_shape/easy/t-028-where-and-list/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-028-where-and-list/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-028-where-and-list/query.json b/compliance/foundation-v0.1/tests/query_shape/easy/t-028-where-and-list/query.json new file mode 100644 index 0000000..f0a8c3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-028-where-and-list/query.json @@ -0,0 +1,16 @@ +{ + "dataset": "orders", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "revenue", + "metric": "total_revenue" + } + ], + "filters": [ + "orders.status = 'completed'", + "orders.amount > 60" + ] +} diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-052-limit-without-order/gold.sql b/compliance/foundation-v0.1/tests/query_shape/easy/t-052-limit-without-order/gold.sql new file mode 100644 index 0000000..aba70fb --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-052-limit-without-order/gold.sql @@ -0,0 +1,10 @@ +-- T-052 — Limit with explicit Order By for deterministic comparison. +-- D-014 requires compiled SQL to be byte-stable; we add ORDER BY here +-- so the row-by-row comparator the harness runs is also stable +-- (LIMIT-without-ORDER-BY result order is inherently engine-defined). +SELECT c.region AS region, SUM(o.amount) AS revenue +FROM orders o +LEFT JOIN customers c ON o.customer_id = c.id +GROUP BY c.region +ORDER BY region NULLS LAST +LIMIT 2 diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-052-limit-without-order/metadata.yaml b/compliance/foundation-v0.1/tests/query_shape/easy/t-052-limit-without-order/metadata.yaml new file mode 100644 index 0000000..70d9267 --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-052-limit-without-order/metadata.yaml @@ -0,0 +1,13 @@ +name: t-052-limit-without-order +description: "Limit + Order By compiles and runs; compiled SQL is byte-stable (D-014). Test renamed in S-E from limit-without-order-by to limit-with-order-by because LIMIT without ORDER BY is engine-defined and the harness compares rows." +area: query_shape +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-014" +tags: [limit, determinism] +conformance_level: foundation_v0_1 +decision: D-014 +test_id: T-052 +status: planned +xfail_reason: "Sprint S-7 — Limit without Order By compiles (D-014)" diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-052-limit-without-order/model.yaml b/compliance/foundation-v0.1/tests/query_shape/easy/t-052-limit-without-order/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-052-limit-without-order/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/query_shape/easy/t-052-limit-without-order/query.json b/compliance/foundation-v0.1/tests/query_shape/easy/t-052-limit-without-order/query.json new file mode 100644 index 0000000..a34abfb --- /dev/null +++ b/compliance/foundation-v0.1/tests/query_shape/easy/t-052-limit-without-order/query.json @@ -0,0 +1,16 @@ +{ + "dataset": "orders", + "dimensions": [ + "customers.region" + ], + "measures": [ + { + "name": "revenue", + "metric": "total_revenue" + } + ], + "order_by": [ + {"field": "customers.region", "direction": "ASC"} + ], + "limit": 2 +} diff --git a/compliance/foundation-v0.1/tests/scalar_query/easy/t-003-bare-metric-in-fields/gold.sql b/compliance/foundation-v0.1/tests/scalar_query/easy/t-003-bare-metric-in-fields/gold.sql new file mode 100644 index 0000000..f2253b2 --- /dev/null +++ b/compliance/foundation-v0.1/tests/scalar_query/easy/t-003-bare-metric-in-fields/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_AGGREGATE_IN_SCALAR_QUERY: metric reference inside Fields diff --git a/compliance/foundation-v0.1/tests/scalar_query/easy/t-003-bare-metric-in-fields/metadata.yaml b/compliance/foundation-v0.1/tests/scalar_query/easy/t-003-bare-metric-in-fields/metadata.yaml new file mode 100644 index 0000000..89dd9f7 --- /dev/null +++ b/compliance/foundation-v0.1/tests/scalar_query/easy/t-003-bare-metric-in-fields/metadata.yaml @@ -0,0 +1,22 @@ +name: t-003-bare-metric-in-fields +description: >- + Scalar query (``Fields`` clause): a bare metric reference inside + ``Fields`` is rejected with ``E_AGGREGATE_IN_SCALAR_QUERY`` per + D-011 / §5.1.2. This is the scalar-query rejection rule — + distinct from D-022's ``E_UNSAFE_REAGGREGATION`` for fan-trap + decomposition failures and from D-023's + ``E_FAN_OUT_IN_SCALAR_QUERY`` for join-path fan-outs. +area: scalar_query +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-011" + - "Proposed_OSI_Semantics.md#sec-5.1" +tags: [scalar-query, negative, aggregate] +conformance_level: foundation_v0_1 +decision: D-011 +test_id: T-003 +expected_error: true +expected_error_code: E_AGGREGATE_IN_SCALAR_QUERY +status: planned +xfail_reason: "Sprint S-2 — scalar-query aggregate rejection (D-011)" diff --git a/compliance/foundation-v0.1/tests/scalar_query/easy/t-003-bare-metric-in-fields/model.yaml b/compliance/foundation-v0.1/tests/scalar_query/easy/t-003-bare-metric-in-fields/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/scalar_query/easy/t-003-bare-metric-in-fields/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/scalar_query/easy/t-003-bare-metric-in-fields/query.json b/compliance/foundation-v0.1/tests/scalar_query/easy/t-003-bare-metric-in-fields/query.json new file mode 100644 index 0000000..b2fc227 --- /dev/null +++ b/compliance/foundation-v0.1/tests/scalar_query/easy/t-003-bare-metric-in-fields/query.json @@ -0,0 +1,7 @@ +{ + "dataset": "orders", + "fields": [ + "orders.id", + "total_revenue" + ] +} diff --git a/compliance/foundation-v0.1/tests/scalar_query/easy/t-012-scalar-grand-total/gold.sql b/compliance/foundation-v0.1/tests/scalar_query/easy/t-012-scalar-grand-total/gold.sql new file mode 100644 index 0000000..676b32b --- /dev/null +++ b/compliance/foundation-v0.1/tests/scalar_query/easy/t-012-scalar-grand-total/gold.sql @@ -0,0 +1,3 @@ +SELECT a.total_revenue, b.total_returns +FROM (SELECT SUM(amount) AS total_revenue FROM orders) a +CROSS JOIN (SELECT SUM(amount) AS total_returns FROM returns) b diff --git a/compliance/foundation-v0.1/tests/scalar_query/easy/t-012-scalar-grand-total/metadata.yaml b/compliance/foundation-v0.1/tests/scalar_query/easy/t-012-scalar-grand-total/metadata.yaml new file mode 100644 index 0000000..dd2673d --- /dev/null +++ b/compliance/foundation-v0.1/tests/scalar_query/easy/t-012-scalar-grand-total/metadata.yaml @@ -0,0 +1,13 @@ +name: t-012-scalar-grand-total +description: "Scalar grand total (no dimensions) uses CROSS JOIN of pre-aggregated scalars; one row" +area: scalar_query +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-004" +tags: [scalar, cross-join, grand-total] +conformance_level: foundation_v0_1 +decision: D-004 +test_id: T-012 +status: planned +xfail_reason: "Sprint S-7 — scalar grand total CROSS JOIN (D-004c)" diff --git a/compliance/foundation-v0.1/tests/scalar_query/easy/t-012-scalar-grand-total/model.yaml b/compliance/foundation-v0.1/tests/scalar_query/easy/t-012-scalar-grand-total/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/scalar_query/easy/t-012-scalar-grand-total/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/scalar_query/easy/t-012-scalar-grand-total/query.json b/compliance/foundation-v0.1/tests/scalar_query/easy/t-012-scalar-grand-total/query.json new file mode 100644 index 0000000..5fefd12 --- /dev/null +++ b/compliance/foundation-v0.1/tests/scalar_query/easy/t-012-scalar-grand-total/query.json @@ -0,0 +1,14 @@ +{ + "dataset": "orders", + "dimensions": [], + "measures": [ + { + "name": "total_revenue", + "metric": "total_revenue" + }, + { + "name": "total_returns", + "metric": "total_returns" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/scalar_query/moderate/t-022-fanout-in-scalar-query/gold.sql b/compliance/foundation-v0.1/tests/scalar_query/moderate/t-022-fanout-in-scalar-query/gold.sql new file mode 100644 index 0000000..51cff7d --- /dev/null +++ b/compliance/foundation-v0.1/tests/scalar_query/moderate/t-022-fanout-in-scalar-query/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_FAN_OUT_IN_SCALAR_QUERY: scalar query crosses 1:N fan-out diff --git a/compliance/foundation-v0.1/tests/scalar_query/moderate/t-022-fanout-in-scalar-query/metadata.yaml b/compliance/foundation-v0.1/tests/scalar_query/moderate/t-022-fanout-in-scalar-query/metadata.yaml new file mode 100644 index 0000000..710eca8 --- /dev/null +++ b/compliance/foundation-v0.1/tests/scalar_query/moderate/t-022-fanout-in-scalar-query/metadata.yaml @@ -0,0 +1,15 @@ +name: t-022-fanout-in-scalar-query +description: "Fan-out in a scalar query raises E_FAN_OUT_IN_SCALAR_QUERY" +area: scalar_query +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-023" +tags: [scalar, fan-out, negative] +conformance_level: foundation_v0_1 +decision: D-023 +test_id: T-022 +expected_error: true +expected_error_code: E_FAN_OUT_IN_SCALAR_QUERY +status: planned +xfail_reason: "Sprint S-2 — scalar query fan-out rejection (D-023)" diff --git a/compliance/foundation-v0.1/tests/scalar_query/moderate/t-022-fanout-in-scalar-query/model.yaml b/compliance/foundation-v0.1/tests/scalar_query/moderate/t-022-fanout-in-scalar-query/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/scalar_query/moderate/t-022-fanout-in-scalar-query/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/scalar_query/moderate/t-022-fanout-in-scalar-query/query.json b/compliance/foundation-v0.1/tests/scalar_query/moderate/t-022-fanout-in-scalar-query/query.json new file mode 100644 index 0000000..20b24a6 --- /dev/null +++ b/compliance/foundation-v0.1/tests/scalar_query/moderate/t-022-fanout-in-scalar-query/query.json @@ -0,0 +1,7 @@ +{ + "dataset": "customers", + "fields": [ + "customers.region", + "orders.id" + ] +} diff --git a/compliance/foundation-v0.1/tests/validation_errors/easy/t-050-field-dependency-cycle/gold.sql b/compliance/foundation-v0.1/tests/validation_errors/easy/t-050-field-dependency-cycle/gold.sql new file mode 100644 index 0000000..4930069 --- /dev/null +++ b/compliance/foundation-v0.1/tests/validation_errors/easy/t-050-field-dependency-cycle/gold.sql @@ -0,0 +1,7 @@ +-- EXPECTED ERROR E_FIELD_DEPENDENCY_CYCLE: dataset `orders` declares +-- a cycle in its inter-field dependency graph (``a → b → a``). +-- Foundation v0.1 §4.3 requires each dataset's field dependency +-- graph to be a DAG so the planner can lower derived fields into a +-- topologically ordered chain of ``ADD_COLUMNS`` CTE stages — a +-- cycle cannot be lowered to a finite stage count. Structural error; +-- not gated by any feature flag. diff --git a/compliance/foundation-v0.1/tests/validation_errors/easy/t-050-field-dependency-cycle/metadata.yaml b/compliance/foundation-v0.1/tests/validation_errors/easy/t-050-field-dependency-cycle/metadata.yaml new file mode 100644 index 0000000..e330e5a --- /dev/null +++ b/compliance/foundation-v0.1/tests/validation_errors/easy/t-050-field-dependency-cycle/metadata.yaml @@ -0,0 +1,20 @@ +name: t-050-field-dependency-cycle +description: >- + Field expressions on the same dataset that reference one another + transitively (e.g. ``a`` → ``b`` → ``a``) form a dependency cycle. + Fields are required to form a DAG so resolution is well-defined and + termination is guaranteed; cycles are rejected at parse time with + ``E_FIELD_DEPENDENCY_CYCLE`` (Appendix C / §4.3). This is a + structural validation error, not a deferred feature. +area: validation_errors +difficulty: easy +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#sec-4.3" + - "Proposed_OSI_Semantics.md#E_FIELD_DEPENDENCY_CYCLE" +tags: [field, inter-field-deps, cycle, negative, structural] +conformance_level: foundation_v0_1 +test_id: T-050 +expected_error: true +expected_error_code: E_FIELD_DEPENDENCY_CYCLE +status: active diff --git a/compliance/foundation-v0.1/tests/validation_errors/easy/t-050-field-dependency-cycle/model.yaml b/compliance/foundation-v0.1/tests/validation_errors/easy/t-050-field-dependency-cycle/model.yaml new file mode 100644 index 0000000..8c09fc6 --- /dev/null +++ b/compliance/foundation-v0.1/tests/validation_errors/easy/t-050-field-dependency-cycle/model.yaml @@ -0,0 +1,27 @@ +name: f_prelude_with_cycle +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + # Cyclic inter-field dependency: ``a`` references ``b`` which + # references back to ``a``. The dependency graph is not a DAG + # and cannot be lowered to the staged-CTE shape on any + # dialect. The parser rejects this up front with + # ``E_FIELD_DEPENDENCY_CYCLE`` regardless of any feature + # flag — there is no portable SQL shape that compiles a cyclic + # field graph. + - {name: a, expression: "b + 1"} + - {name: b, expression: "a * 2"} +metrics: + - {name: total_amount, expression: "SUM(orders.amount)"} diff --git a/compliance/foundation-v0.1/tests/validation_errors/easy/t-050-field-dependency-cycle/query.json b/compliance/foundation-v0.1/tests/validation_errors/easy/t-050-field-dependency-cycle/query.json new file mode 100644 index 0000000..d300402 --- /dev/null +++ b/compliance/foundation-v0.1/tests/validation_errors/easy/t-050-field-dependency-cycle/query.json @@ -0,0 +1,9 @@ +{ + "dataset": "orders", + "measures": [ + { + "name": "total_amount", + "metric": "total_amount" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/windows/hard/t-032-row-number-qualify-pattern/gold.sql b/compliance/foundation-v0.1/tests/windows/hard/t-032-row-number-qualify-pattern/gold.sql new file mode 100644 index 0000000..496bd94 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/hard/t-032-row-number-qualify-pattern/gold.sql @@ -0,0 +1,8 @@ +SELECT id, customer_id, amount, order_rank_in_customer +FROM ( + SELECT id, customer_id, amount, + ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC NULLS FIRST, id ASC NULLS LAST) + AS order_rank_in_customer + FROM orders +) t +WHERE order_rank_in_customer = 1 diff --git a/compliance/foundation-v0.1/tests/windows/hard/t-032-row-number-qualify-pattern/metadata.yaml b/compliance/foundation-v0.1/tests/windows/hard/t-032-row-number-qualify-pattern/metadata.yaml new file mode 100644 index 0000000..ca65521 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/hard/t-032-row-number-qualify-pattern/metadata.yaml @@ -0,0 +1,13 @@ +name: t-032-row-number-qualify-pattern +description: "ROW_NUMBER exposed as a derived field; outer Where filters that field (qualify-style pattern)" +area: windows +difficulty: hard +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-030" +tags: [window, qualify] +conformance_level: foundation_v0_1 +decision: D-030 +test_id: T-032 +status: planned +xfail_reason: "Sprint S-12 — derived-field qualify pattern (D-030)" diff --git a/compliance/foundation-v0.1/tests/windows/hard/t-032-row-number-qualify-pattern/model.yaml b/compliance/foundation-v0.1/tests/windows/hard/t-032-row-number-qualify-pattern/model.yaml new file mode 100644 index 0000000..7307ca7 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/hard/t-032-row-number-qualify-pattern/model.yaml @@ -0,0 +1,37 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - {name: order_rank_in_customer, expression: "ROW_NUMBER() OVER (PARTITION BY orders.customer_id ORDER BY orders.amount DESC, orders.id ASC)"} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/windows/hard/t-032-row-number-qualify-pattern/query.json b/compliance/foundation-v0.1/tests/windows/hard/t-032-row-number-qualify-pattern/query.json new file mode 100644 index 0000000..ef14661 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/hard/t-032-row-number-qualify-pattern/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "orders", + "fields": [ + "orders.id", + "orders.customer_id", + "orders.amount", + "orders.order_rank_in_customer" + ], + "filters": [ + "orders.order_rank_in_customer = 1" + ] +} diff --git a/compliance/foundation-v0.1/tests/windows/hard/t-033-windowed-metric-composition-rejected/gold.sql b/compliance/foundation-v0.1/tests/windows/hard/t-033-windowed-metric-composition-rejected/gold.sql new file mode 100644 index 0000000..570573f --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/hard/t-033-windowed-metric-composition-rejected/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_WINDOWED_METRIC_COMPOSITION: metric composes onto a windowed metric diff --git a/compliance/foundation-v0.1/tests/windows/hard/t-033-windowed-metric-composition-rejected/metadata.yaml b/compliance/foundation-v0.1/tests/windows/hard/t-033-windowed-metric-composition-rejected/metadata.yaml new file mode 100644 index 0000000..ace01c5 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/hard/t-033-windowed-metric-composition-rejected/metadata.yaml @@ -0,0 +1,15 @@ +name: t-033-windowed-metric-composition-rejected +description: "Composing a non-windowed metric onto a windowed metric raises E_WINDOWED_METRIC_COMPOSITION" +area: windows +difficulty: hard +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#E_WINDOWED_METRIC_COMPOSITION" +tags: [window, composition, negative] +conformance_level: foundation_v0_1 +decision: D-031 +test_id: T-033 +expected_error: true +expected_error_code: E_WINDOWED_METRIC_COMPOSITION +status: planned +xfail_reason: "Sprint S-12 — windowed metric composition rejection (E_WINDOWED_METRIC_COMPOSITION)" diff --git a/compliance/foundation-v0.1/tests/windows/hard/t-033-windowed-metric-composition-rejected/model.yaml b/compliance/foundation-v0.1/tests/windows/hard/t-033-windowed-metric-composition-rejected/model.yaml new file mode 100644 index 0000000..353c507 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/hard/t-033-windowed-metric-composition-rejected/model.yaml @@ -0,0 +1,38 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: running_total_by_customer, expression: "SUM(orders.amount) OVER (PARTITION BY orders.customer_id ORDER BY orders.id)"} + - {name: running_total_ratio, expression: "orders.running_total_by_customer / SUM(orders.amount)"} diff --git a/compliance/foundation-v0.1/tests/windows/hard/t-033-windowed-metric-composition-rejected/query.json b/compliance/foundation-v0.1/tests/windows/hard/t-033-windowed-metric-composition-rejected/query.json new file mode 100644 index 0000000..c9afd39 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/hard/t-033-windowed-metric-composition-rejected/query.json @@ -0,0 +1,12 @@ +{ + "dataset": "orders", + "dimensions": [ + "orders.customer_id" + ], + "measures": [ + { + "name": "ratio", + "metric": "running_total_ratio" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-027-window-nulls-last/gold.sql b/compliance/foundation-v0.1/tests/windows/moderate/t-027-window-nulls-last/gold.sql new file mode 100644 index 0000000..9cfb3f2 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-027-window-nulls-last/gold.sql @@ -0,0 +1,3 @@ +SELECT id, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC NULLS FIRST) AS rn_per_customer +FROM orders +WHERE status = 'completed' diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-027-window-nulls-last/metadata.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-027-window-nulls-last/metadata.yaml new file mode 100644 index 0000000..e466299 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-027-window-nulls-last/metadata.yaml @@ -0,0 +1,13 @@ +name: t-027-window-nulls-last +description: "ORDER BY inside OVER(...) defaults to the high-end-NULL convention in compiled SQL: ASC ⇒ NULLS LAST, DESC ⇒ NULLS FIRST. This test exercises the DESC half (the symmetric counterpart of an ASC NULLS LAST default); paired with t-026 which exercises the ASC half." +area: windows +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-029" +tags: [window, nulls-first, symmetry] +conformance_level: foundation_v0_1 +decision: D-029 +test_id: T-027 +status: planned +xfail_reason: "Sprint S-9 — window OVER ORDER BY default NULL placement (D-029, amended 2026-05-13 to high-end-NULL convention)" diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-027-window-nulls-last/model.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-027-window-nulls-last/model.yaml new file mode 100644 index 0000000..210788b --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-027-window-nulls-last/model.yaml @@ -0,0 +1,39 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: rn_per_customer, expression: "ROW_NUMBER() OVER (PARTITION BY orders.customer_id ORDER BY orders.amount DESC)"} + - {name: running_total, expression: "SUM(orders.amount) OVER (PARTITION BY orders.customer_id ORDER BY orders.id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)"} + - {name: order_rank, expression: "RANK() OVER (PARTITION BY orders.customer_id ORDER BY orders.amount DESC)"} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-027-window-nulls-last/query.json b/compliance/foundation-v0.1/tests/windows/moderate/t-027-window-nulls-last/query.json new file mode 100644 index 0000000..8263a88 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-027-window-nulls-last/query.json @@ -0,0 +1,10 @@ +{ + "dataset": "orders", + "fields": [ + "orders.id", + "orders.rn_per_customer" + ], + "filters": [ + "orders.status = 'completed'" + ] +} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-029-window-in-where-rejected/gold.sql b/compliance/foundation-v0.1/tests/windows/moderate/t-029-window-in-where-rejected/gold.sql new file mode 100644 index 0000000..a89d3a6 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-029-window-in-where-rejected/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_WINDOW_IN_WHERE: window function inside Where; use Having/Qualify or derived field diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-029-window-in-where-rejected/metadata.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-029-window-in-where-rejected/metadata.yaml new file mode 100644 index 0000000..2e5f15e --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-029-window-in-where-rejected/metadata.yaml @@ -0,0 +1,21 @@ +name: t-029-window-in-where-rejected +description: >- + A window function in ``Where`` is rejected with + ``E_WINDOW_IN_WHERE``. Windows are only valid in + ``Measures`` / ``Fields`` / ``Order By`` / ``Having`` per D-028(b); + SQL itself forbids windows in ``WHERE`` (the window phase runs + after row filtering). +area: windows +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-028" + - "Proposed_OSI_Semantics.md#sec-6.10.1" +tags: [window, negative, where] +conformance_level: foundation_v0_1 +decision: D-028 +test_id: T-029 +expected_error: true +expected_error_code: E_WINDOW_IN_WHERE +status: planned +xfail_reason: "Sprint S-12 — window placement rules (D-028)" diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-029-window-in-where-rejected/model.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-029-window-in-where-rejected/model.yaml new file mode 100644 index 0000000..0a32e3c --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-029-window-in-where-rejected/model.yaml @@ -0,0 +1,36 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-029-window-in-where-rejected/query.json b/compliance/foundation-v0.1/tests/windows/moderate/t-029-window-in-where-rejected/query.json new file mode 100644 index 0000000..c4c48f9 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-029-window-in-where-rejected/query.json @@ -0,0 +1,15 @@ +{ + "dataset": "orders", + "dimensions": [ + "orders.customer_id" + ], + "measures": [ + { + "name": "revenue", + "metric": "total_revenue" + } + ], + "filters": [ + "ROW_NUMBER() OVER (PARTITION BY orders.customer_id ORDER BY orders.amount) = 1" + ] +} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-030-nested-window-rejected/gold.sql b/compliance/foundation-v0.1/tests/windows/moderate/t-030-nested-window-rejected/gold.sql new file mode 100644 index 0000000..714943a --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-030-nested-window-rejected/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_NESTED_WINDOW: window over window is not allowed diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-030-nested-window-rejected/metadata.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-030-nested-window-rejected/metadata.yaml new file mode 100644 index 0000000..e2df9e6 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-030-nested-window-rejected/metadata.yaml @@ -0,0 +1,22 @@ +name: t-030-nested-window-rejected +description: >- + A window function whose argument or frame contains another window + function is rejected with ``E_NESTED_WINDOW``. D-028(c) mandates + a parse-level rejection because the outer grain is structurally + ambiguous when the inner is itself a window. (Distinct from D-031 + which covers a *metric* composing another *metric* whose body + contains a window.) +area: windows +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-028" + - "Proposed_OSI_Semantics.md#sec-6.10.1" +tags: [window, negative, nested] +conformance_level: foundation_v0_1 +decision: D-028 +test_id: T-030 +expected_error: true +expected_error_code: E_NESTED_WINDOW +status: planned +xfail_reason: "Sprint S-12 — nested window rejection (D-028(c))" diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-030-nested-window-rejected/model.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-030-nested-window-rejected/model.yaml new file mode 100644 index 0000000..e80cf08 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-030-nested-window-rejected/model.yaml @@ -0,0 +1,37 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: nested_w, expression: "SUM(SUM(orders.amount) OVER (PARTITION BY orders.customer_id)) OVER ()"} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-030-nested-window-rejected/query.json b/compliance/foundation-v0.1/tests/windows/moderate/t-030-nested-window-rejected/query.json new file mode 100644 index 0000000..0ca84e7 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-030-nested-window-rejected/query.json @@ -0,0 +1,7 @@ +{ + "dataset": "orders", + "fields": [ + "orders.id", + "orders.nested_w" + ] +} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-031-running-total-window/gold.sql b/compliance/foundation-v0.1/tests/windows/moderate/t-031-running-total-window/gold.sql new file mode 100644 index 0000000..7dc177d --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-031-running-total-window/gold.sql @@ -0,0 +1,6 @@ +SELECT id, customer_id, amount, + SUM(amount) OVER (PARTITION BY customer_id ORDER BY id ASC NULLS LAST + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total +FROM orders +WHERE customer_id IS NOT NULL +ORDER BY customer_id ASC NULLS LAST, id ASC NULLS LAST diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-031-running-total-window/metadata.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-031-running-total-window/metadata.yaml new file mode 100644 index 0000000..0258905 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-031-running-total-window/metadata.yaml @@ -0,0 +1,14 @@ +name: t-031-running-total-window +description: "Running-total window (SUM OVER ROWS UNBOUNDED PRECEDING) compiles and runs correctly" +area: windows +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-030" + - "Proposed_OSI_Semantics.md#D-029" +tags: [window, running-total] +conformance_level: foundation_v0_1 +decision: D-030 +test_id: T-031 +status: planned +xfail_reason: "Sprint S-12 — running-total window (D-030, D-029)" diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-031-running-total-window/model.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-031-running-total-window/model.yaml new file mode 100644 index 0000000..210788b --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-031-running-total-window/model.yaml @@ -0,0 +1,39 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: rn_per_customer, expression: "ROW_NUMBER() OVER (PARTITION BY orders.customer_id ORDER BY orders.amount DESC)"} + - {name: running_total, expression: "SUM(orders.amount) OVER (PARTITION BY orders.customer_id ORDER BY orders.id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)"} + - {name: order_rank, expression: "RANK() OVER (PARTITION BY orders.customer_id ORDER BY orders.amount DESC)"} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-031-running-total-window/query.json b/compliance/foundation-v0.1/tests/windows/moderate/t-031-running-total-window/query.json new file mode 100644 index 0000000..aed8f61 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-031-running-total-window/query.json @@ -0,0 +1,22 @@ +{ + "dataset": "orders", + "fields": [ + "orders.id", + "orders.customer_id", + "orders.amount", + "orders.running_total" + ], + "filters": [ + "orders.customer_id IS NOT NULL" + ], + "order_by": [ + { + "target": "orders.customer_id", + "direction": "ASC" + }, + { + "target": "orders.id", + "direction": "ASC" + } + ] +} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-034-groups-frame-rejected/gold.sql b/compliance/foundation-v0.1/tests/windows/moderate/t-034-groups-frame-rejected/gold.sql new file mode 100644 index 0000000..cade463 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-034-groups-frame-rejected/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_DEFERRED_FRAME_MODE: GROUPS frame mode is deferred to §10 diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-034-groups-frame-rejected/metadata.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-034-groups-frame-rejected/metadata.yaml new file mode 100644 index 0000000..01a5173 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-034-groups-frame-rejected/metadata.yaml @@ -0,0 +1,15 @@ +name: t-034-groups-frame-rejected +description: "GROUPS frame mode is deferred; raises E_DEFERRED_FRAME_MODE" +area: windows +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-032" +tags: [window, frame, groups, deferred, negative] +conformance_level: foundation_v0_1 +decision: D-032 +test_id: T-034 +expected_error: true +expected_error_code: E_DEFERRED_FRAME_MODE +status: planned +xfail_reason: "Sprint S-12 — GROUPS frame deferral (D-032)" diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-034-groups-frame-rejected/model.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-034-groups-frame-rejected/model.yaml new file mode 100644 index 0000000..846f813 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-034-groups-frame-rejected/model.yaml @@ -0,0 +1,37 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: recent_sum_groups, expression: "SUM(orders.amount) OVER (PARTITION BY orders.customer_id ORDER BY orders.amount GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW)"} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-034-groups-frame-rejected/query.json b/compliance/foundation-v0.1/tests/windows/moderate/t-034-groups-frame-rejected/query.json new file mode 100644 index 0000000..12bc9c9 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-034-groups-frame-rejected/query.json @@ -0,0 +1,7 @@ +{ + "dataset": "orders", + "fields": [ + "orders.id", + "orders.recent_sum_groups" + ] +} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-035-parameterised-frame-bound-rejected/gold.sql b/compliance/foundation-v0.1/tests/windows/moderate/t-035-parameterised-frame-bound-rejected/gold.sql new file mode 100644 index 0000000..efd9f7e --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-035-parameterised-frame-bound-rejected/gold.sql @@ -0,0 +1 @@ +-- EXPECTED ERROR E_DEFERRED_FRAME_MODE: parameterised frame bound is deferred to §10 diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-035-parameterised-frame-bound-rejected/metadata.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-035-parameterised-frame-bound-rejected/metadata.yaml new file mode 100644 index 0000000..1476bbf --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-035-parameterised-frame-bound-rejected/metadata.yaml @@ -0,0 +1,15 @@ +name: t-035-parameterised-frame-bound-rejected +description: "Parameterised frame bound (:n PRECEDING) is deferred; raises E_DEFERRED_FRAME_MODE" +area: windows +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-032" +tags: [window, frame, deferred, negative, parameter] +conformance_level: foundation_v0_1 +decision: D-032 +test_id: T-035 +expected_error: true +expected_error_code: E_DEFERRED_FRAME_MODE +status: planned +xfail_reason: "Sprint S-12 — parameterised frame-bound deferral (D-032)" diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-035-parameterised-frame-bound-rejected/model.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-035-parameterised-frame-bound-rejected/model.yaml new file mode 100644 index 0000000..ff22b64 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-035-parameterised-frame-bound-rejected/model.yaml @@ -0,0 +1,37 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: recent_sum_param, expression: "SUM(orders.amount) OVER (PARTITION BY orders.customer_id ORDER BY orders.id ROWS BETWEEN :n PRECEDING AND CURRENT ROW)"} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-035-parameterised-frame-bound-rejected/query.json b/compliance/foundation-v0.1/tests/windows/moderate/t-035-parameterised-frame-bound-rejected/query.json new file mode 100644 index 0000000..0ffb14d --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-035-parameterised-frame-bound-rejected/query.json @@ -0,0 +1,7 @@ +{ + "dataset": "orders", + "fields": [ + "orders.id", + "orders.recent_sum_param" + ] +} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-036-window-over-1n-fanout-accepted/gold.sql b/compliance/foundation-v0.1/tests/windows/moderate/t-036-window-over-1n-fanout-accepted/gold.sql new file mode 100644 index 0000000..a298eae --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-036-window-over-1n-fanout-accepted/gold.sql @@ -0,0 +1,3 @@ +SELECT id, customer_id, + RANK() OVER (PARTITION BY customer_id ORDER BY amount DESC NULLS FIRST) AS order_rank +FROM orders diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-036-window-over-1n-fanout-accepted/metadata.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-036-window-over-1n-fanout-accepted/metadata.yaml new file mode 100644 index 0000000..90ed629 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-036-window-over-1n-fanout-accepted/metadata.yaml @@ -0,0 +1,14 @@ +name: t-036-window-over-1n-fanout-accepted +description: "RANK() at orders home grain is accepted (windows do not trigger implicit home-grain aggregation)" +area: windows +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-030" + - "Proposed_OSI_Semantics.md#D-022" +tags: [window, rank, home-grain] +conformance_level: foundation_v0_1 +decision: D-030 +test_id: T-036 +status: planned +xfail_reason: "Sprint S-12 — window over 1:N fan-out (D-030)" diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-036-window-over-1n-fanout-accepted/model.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-036-window-over-1n-fanout-accepted/model.yaml new file mode 100644 index 0000000..210788b --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-036-window-over-1n-fanout-accepted/model.yaml @@ -0,0 +1,39 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + - {name: total_revenue, expression: "SUM(orders.amount)"} + - {name: total_returns, expression: "SUM(returns.amount)"} + - {name: order_count, expression: "COUNT(orders.id)"} + - {name: rn_per_customer, expression: "ROW_NUMBER() OVER (PARTITION BY orders.customer_id ORDER BY orders.amount DESC)"} + - {name: running_total, expression: "SUM(orders.amount) OVER (PARTITION BY orders.customer_id ORDER BY orders.id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)"} + - {name: order_rank, expression: "RANK() OVER (PARTITION BY orders.customer_id ORDER BY orders.amount DESC)"} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-036-window-over-1n-fanout-accepted/query.json b/compliance/foundation-v0.1/tests/windows/moderate/t-036-window-over-1n-fanout-accepted/query.json new file mode 100644 index 0000000..f1231e0 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-036-window-over-1n-fanout-accepted/query.json @@ -0,0 +1,8 @@ +{ + "dataset": "orders", + "fields": [ + "orders.id", + "orders.customer_id", + "orders.order_rank" + ] +} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-052-window-over-fanout-foreclosed/gold.sql b/compliance/foundation-v0.1/tests/windows/moderate/t-052-window-over-fanout-foreclosed/gold.sql new file mode 100644 index 0000000..7c13f88 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-052-window-over-fanout-foreclosed/gold.sql @@ -0,0 +1,4 @@ +-- Negative test: no SQL is produced. The engine must refuse this +-- query with E_FAN_OUT_IN_SCALAR_QUERY (D-023). This file is present +-- to satisfy the harness's "one file per shape" convention and to +-- document that no SQL output is expected. diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-052-window-over-fanout-foreclosed/metadata.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-052-window-over-fanout-foreclosed/metadata.yaml new file mode 100644 index 0000000..d819de1 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-052-window-over-fanout-foreclosed/metadata.yaml @@ -0,0 +1,34 @@ +name: t-052-window-over-fanout-foreclosed +description: > + Scalar query that combines a windowed metric (``order_rank`` over + ``orders``) with a join path that crosses a 1:N edge + (``returns`` → ``customers`` → ``orders``). The Foundation spec + (D-030 / §6.10.3) defines ``E_WINDOW_OVER_FANOUT_REWRITE`` as the + failure mode for windows whose home dataset would be fanned out by + the query's join path. Reference implementations are free to + materialise the home grain *or* reject; this reference engine + reaches the earlier scalar fan-out gate first + (``E_FAN_OUT_IN_SCALAR_QUERY``, D-023), so the user-visible code + here is ``E_FAN_OUT_IN_SCALAR_QUERY``. This test pins that + contract so the suite notices when (a) the aggregation planner + gains windowed-measure support and the D-030 surface needs to be + wired up, or (b) the scalar planner gains pre-fan-out + materialisation and the response code legitimately changes. +area: windows +difficulty: moderate +dataset: f_prelude +spec_refs: + - "Proposed_OSI_Semantics.md#D-030" + - "Proposed_OSI_Semantics.md#D-023" +tags: [window, fan-out, scalar, negative, foreclosed] +conformance_level: foundation_v0_1 +decision: D-030 +test_id: T-052 +expected_error: true +expected_error_code: E_FAN_OUT_IN_SCALAR_QUERY +status: active +xfail_reason: > + Engine forecloses on the fan-out path before the window step; the + user-visible code is E_FAN_OUT_IN_SCALAR_QUERY (D-023). When + windowed measures land in the aggregation planner, revisit this + test to exercise the D-030 surface directly. diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-052-window-over-fanout-foreclosed/model.yaml b/compliance/foundation-v0.1/tests/windows/moderate/t-052-window-over-fanout-foreclosed/model.yaml new file mode 100644 index 0000000..90c6cf9 --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-052-window-over-fanout-foreclosed/model.yaml @@ -0,0 +1,39 @@ +name: f_prelude_model +datasets: + - name: customers + source: customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: region, expression: region, dimension: {}} + - {name: segment, expression: segment, dimension: {}} + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: status, expression: status, dimension: {}} + - {name: amount, expression: amount} + - name: returns + source: returns + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} + - {name: customer_id, expression: customer_id, dimension: {}} + - {name: amount, expression: amount} + - name: premium_customers + source: premium_customers + primary_key: [id] + fields: + - {name: id, expression: id, dimension: {}} +relationships: + - {name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id]} + - {name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id]} +metrics: + # ``order_rank`` is windowed over orders (home grain = orders). + # A query that anchors at customers and references orders.order_rank + # would cross a 1:N edge (customers → orders), fanning out the + # one-side rows. The window would then run over fanned-out rows in + # violation of §6.10.3 / D-030, so the engine must refuse. + - {name: order_rank, expression: "RANK() OVER (PARTITION BY orders.customer_id ORDER BY orders.amount DESC)"} diff --git a/compliance/foundation-v0.1/tests/windows/moderate/t-052-window-over-fanout-foreclosed/query.json b/compliance/foundation-v0.1/tests/windows/moderate/t-052-window-over-fanout-foreclosed/query.json new file mode 100644 index 0000000..3dd143c --- /dev/null +++ b/compliance/foundation-v0.1/tests/windows/moderate/t-052-window-over-fanout-foreclosed/query.json @@ -0,0 +1,8 @@ +{ + "dataset": "returns", + "fields": [ + "returns.id", + "orders.id", + "order_rank" + ] +} diff --git a/compliance/harness/README.md b/compliance/harness/README.md new file mode 100644 index 0000000..1046c87 --- /dev/null +++ b/compliance/harness/README.md @@ -0,0 +1,35 @@ +# OSI Compliance Harness + +Shared runner / reporter / DB manager for the OSI compliance suite. + +This package is the engine behind every per-version compliance suite +under `compliance/`. It is **engine-agnostic** — it does not know about +any specific OSI implementation. Engines plug in via an *adapter* that +implements the CLI contract documented in +[`../ADAPTER_INTERFACE.md`](../ADAPTER_INTERFACE.md). + +## Install + +```bash +pip install -e . +``` + +This installs the `harness` package. The compliance suites then depend on +this package to run their tests. + +## Run + +The harness resolves ``--output`` relative to the current working +directory, so run it from the suite root (per-run artifacts then land +under ``/results/latest/`` by default): + +```bash +cd ../foundation-v0.1 +python -m harness.runner \ + --adapter adapters/osi_python_adapter.py \ + --tests tests/ \ + --datasets datasets/ +``` + +See [`../foundation-v0.1/README.md`](../foundation-v0.1/README.md) for +the suite-level entry point and reporting layout. diff --git a/compliance/harness/pyproject.toml b/compliance/harness/pyproject.toml new file mode 100644 index 0000000..c1e5e57 --- /dev/null +++ b/compliance/harness/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "osi_compliance_harness" +version = "0.1.0" +description = "Shared runner / reporter / DB manager for the OSI compliance suite." +readme = "README.md" +requires-python = ">=3.11" +authors = [{ name = "OSI Foundation contributors" }] +license = { text = "Apache-2.0" } +dependencies = [ + "duckdb>=0.10", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +dev = ["pytest>=7.4"] + +[tool.setuptools.packages.find] +where = ["src"] +include = ["harness*"] diff --git a/compliance/harness/src/harness/__init__.py b/compliance/harness/src/harness/__init__.py new file mode 100644 index 0000000..c5964a5 --- /dev/null +++ b/compliance/harness/src/harness/__init__.py @@ -0,0 +1 @@ +"""OSI Compliance Test Suite — Test Harness.""" diff --git a/compliance/harness/src/harness/__main__.py b/compliance/harness/src/harness/__main__.py new file mode 100644 index 0000000..0252bd7 --- /dev/null +++ b/compliance/harness/src/harness/__main__.py @@ -0,0 +1,5 @@ +"""Allow running the harness as: python -m harness""" + +from .runner import main + +raise SystemExit(main()) diff --git a/compliance/harness/src/harness/backfill_features.py b/compliance/harness/src/harness/backfill_features.py new file mode 100644 index 0000000..eeb6393 --- /dev/null +++ b/compliance/harness/src/harness/backfill_features.py @@ -0,0 +1,305 @@ +"""Backfill ``required_features`` on every ``metadata.yaml`` in the suite. + +Usage: + python -m harness.backfill_features # write in place + python -m harness.backfill_features --dry-run # report only + +Rules (deliberately conservative; unknown = do not tag) +------------------------------------------------------- +A test is tagged with a proposal ID only when its model or query files +clearly rely on that proposal. Thin-slice features are never tagged — +a tag implies "skip me on adapters that have not enabled this proposal". + +Detection rules: +- ``grain_modes`` — any metric declares ``grain.mode`` in + ``{INCLUDE, FIXED, EXCLUDE}``. +- ``metric_composition_with_grain`` — implied whenever ``grain_modes`` is + present (the composition-of-metric-across-grain + story is the deferred piece; plain AGG(...) of a + same-grain metric stays thin-slice). +- ``window_functions`` — test lives under ``tests/window_functions`` or + the model/query references a WINDOW construct. +- ``non_equijoin`` — test lives under ``tests/non_equijoins`` or the + model declares a non-equi relationship. +- ``parameters`` — test lives under ``tests/parameters`` or the + model declares a top-level ``parameters:`` block. +- ``dataset_filters`` — the model declares a top-level ``dataset_filters:`` + block (scopes handled below). +- ``pervasive_scope`` / ``related_scope`` — detected from the dataset + filter scope values when ``dataset_filters`` is + in play. +- ``grouping_sets`` — test name or area mentions grouping_sets/rollup/cube. +- ``pivot_operator`` — test name or area mentions pivot/unpivot. +- ``semi_additive`` — test name/area mentions semi_additive/running/ + inventory. +- ``referential_integrity_annotations`` — any relationship declares + ``from_all_rows_match`` or ``to_all_rows_match``. +- ``filter_context`` — test name mentions ``filter_reset``, ``override``, + ``preserve_filter``, or ``context``. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +import yaml + +SUITE_ROOT = Path(__file__).resolve().parent.parent + +GRAIN_MODE_KEYS = {"INCLUDE", "FIXED", "EXCLUDE"} + +# Area → feature fallback. Applied when nothing deeper matches. +AREA_DEFAULTS = { + "non_equijoins": {"non_equijoin"}, + "window_functions": {"window_functions"}, + "parameters": {"parameters"}, +} + + +def _safe_yaml(path: Path) -> Any: + if not path.exists(): + return None + try: + return yaml.safe_load(path.read_text()) + except yaml.YAMLError: + return None + + +def _safe_json(path: Path) -> Any: + if not path.exists(): + return None + try: + return json.loads(path.read_text()) + except json.JSONDecodeError: + return None + + +def _has_grain_mode(model: Any) -> bool: + if not isinstance(model, dict): + return False + for metric in model.get("metrics", []) or []: + if not isinstance(metric, dict): + continue + grain = metric.get("grain") + if isinstance(grain, dict) and str(grain.get("mode", "")).upper() in GRAIN_MODE_KEYS: + return True + return False + + +def _has_non_equi(model: Any) -> bool: + if not isinstance(model, dict): + return False + for rel in model.get("relationships", []) or []: + if not isinstance(rel, dict): + continue + rtype = str(rel.get("type", "")).lower() + if rtype in {"non_equi", "range", "asof"}: + return True + if "condition" in rel and rel.get("condition"): + return True + return False + + +def _has_ri_annotations(model: Any) -> bool: + if not isinstance(model, dict): + return False + for rel in model.get("relationships", []) or []: + if not isinstance(rel, dict): + continue + if "from_all_rows_match" in rel or "to_all_rows_match" in rel: + return True + return False + + +def _has_parameters(model: Any) -> bool: + return isinstance(model, dict) and bool(model.get("parameters")) + + +def _dataset_filter_scopes(model: Any) -> set[str]: + """Return the set of scopes used by dataset filters, if any.""" + if not isinstance(model, dict): + return set() + scopes: set[str] = set() + for ds in model.get("datasets", []) or []: + if not isinstance(ds, dict): + continue + for df in ds.get("dataset_filters", []) or []: + if not isinstance(df, dict): + continue + scope = str(df.get("scope", "")).lower() + if scope: + scopes.add(scope) + for df in model.get("dataset_filters", []) or []: + if not isinstance(df, dict): + continue + scope = str(df.get("scope", "")).lower() + if scope: + scopes.add(scope) + return scopes + + +def _name_hint(test_dir: Path, *tokens: str) -> bool: + needle = test_dir.name.lower() + " " + test_dir.parent.name.lower() + return any(tok in needle for tok in tokens) + + +def detect_features(test_dir: Path) -> set[str]: + """Inspect a single test directory and return required feature IDs.""" + area = test_dir.parts[test_dir.parts.index("tests") + 1] + model = _safe_yaml(test_dir / "model.yaml") + query = _safe_json(test_dir / "query.json") + + features: set[str] = set() + + if _has_grain_mode(model): + features.add("grain_modes") + if isinstance(model, dict): + for metric in model.get("metrics", []) or []: + if not isinstance(metric, dict): + continue + expr = str(metric.get("expression", "")) + if any( + f"{m['name']}" in expr + for m in model.get("metrics", []) + if isinstance(m, dict) and m.get("name") != metric.get("name") + ): + features.add("metric_composition_with_grain") + break + + if _has_non_equi(model) or area == "non_equijoins": + features.add("non_equijoin") + + if _has_parameters(model) or area == "parameters": + features.add("parameters") + + if _has_ri_annotations(model): + features.add("referential_integrity_annotations") + + scopes = _dataset_filter_scopes(model) + if scopes: + features.add("dataset_filters") + if "pervasive" in scopes: + features.add("pervasive_scope") + if "related" in scopes: + features.add("related_scope") + + if area == "window_functions": + features.add("window_functions") + elif isinstance(query, dict) and any( + isinstance(m, dict) and m.get("window") + for m in query.get("measures", []) or [] + ): + features.add("window_functions") + + if _name_hint(test_dir, "grouping_set", "rollup", "cube"): + features.add("grouping_sets") + if _name_hint(test_dir, "pivot", "unpivot"): + features.add("pivot_operator") + if _name_hint(test_dir, "semi_additive", "running_balance", "inventory_snapshot"): + features.add("semi_additive") + if _name_hint( + test_dir, + "filter_reset", + "filter_override", + "preserve_filter", + "keep_filter", + "override_context", + "filter_context", + ): + features.add("filter_context") + + features |= AREA_DEFAULTS.get(area, set()) + return features + + +def _load_valid_ids() -> set[str]: + data = yaml.safe_load((SUITE_ROOT / "proposals.yaml").read_text()) + return {p["id"] for p in data["proposals"]} + + +def _merge(existing: list[str] | None, detected: set[str]) -> list[str]: + base = list(existing or []) + for f in sorted(detected): + if f not in base: + base.append(f) + return base + + +def _rewrite_features(meta_path: Path, new_value: list[str]) -> None: + """Preserve the original YAML, only rewriting the required_features line. + + We edit textually rather than dumping via PyYAML so comments, quoting, + and ordering in each metadata file stay untouched. + """ + text = meta_path.read_text() + lines = text.splitlines(keepends=False) + rendered = "[" + ", ".join(new_value) + "]" + new_line = f"required_features: {rendered}" + + for i, ln in enumerate(lines): + if ln.startswith("required_features:"): + lines[i] = new_line + break + else: + # Append a trailing key (before any trailing blank line). + while lines and lines[-1] == "": + lines.pop() + lines.append(new_line) + + meta_path.write_text("\n".join(lines) + "\n") + + +def run(suite_root: Path, dry_run: bool) -> tuple[int, int]: + """Walk the suite. Returns (changed_count, scanned_count).""" + valid = _load_valid_ids() + changed = 0 + scanned = 0 + + for meta_path in sorted((suite_root / "tests").rglob("metadata.yaml")): + scanned += 1 + test_dir = meta_path.parent + meta = yaml.safe_load(meta_path.read_text()) or {} + existing = meta.get("required_features") or [] + if not isinstance(existing, list): + existing = [] + detected = detect_features(test_dir) + + unknown = [f for f in detected if f not in valid] + if unknown: + raise RuntimeError( + f"{meta_path}: detector produced IDs not in proposals.yaml: {unknown}" + ) + + merged = _merge(existing, detected) + if merged == list(existing): + continue + + changed += 1 + rel = meta_path.relative_to(suite_root) + added = sorted(set(merged) - set(existing)) + print(f" {rel}: +{added}") + if not dry_run: + _rewrite_features(meta_path, merged) + + return changed, scanned + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--root", default=str(SUITE_ROOT)) + args = parser.parse_args(argv) + + root = Path(args.root).resolve() + changed, scanned = run(root, args.dry_run) + verb = "would update" if args.dry_run else "updated" + print(f"\n{verb} {changed}/{scanned} metadata files.") + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/compliance/harness/src/harness/db_manager.py b/compliance/harness/src/harness/db_manager.py new file mode 100644 index 0000000..2ea07a8 --- /dev/null +++ b/compliance/harness/src/harness/db_manager.py @@ -0,0 +1,65 @@ +"""DuckDB database management for the test harness.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import duckdb + + +class DBManager: + """Manages DuckDB connections and dataset loading.""" + + def __init__(self) -> None: + self._conn: duckdb.DuckDBPyConnection | None = None + self._loaded_datasets: set[str] = set() + + def connect(self) -> duckdb.DuckDBPyConnection: + """Create a fresh in-memory DuckDB connection.""" + self.close() + self._conn = duckdb.connect(":memory:") + self._loaded_datasets.clear() + return self._conn + + def close(self) -> None: + if self._conn is not None: + self._conn.close() + self._conn = None + self._loaded_datasets.clear() + + def load_dataset(self, dataset_name: str, datasets_dir: Path) -> None: + """Load a dataset's schema.sql into the current connection.""" + if self._conn is None: + raise RuntimeError("No active database connection") + + if dataset_name in self._loaded_datasets: + return + + schema_path = datasets_dir / dataset_name / "schema.sql" + if not schema_path.exists(): + raise FileNotFoundError(f"Dataset schema not found: {schema_path}") + + sql_text = schema_path.read_text() + for statement in sql_text.split(";"): + lines = [line for line in statement.splitlines() if not line.strip().startswith("--")] + stmt = "\n".join(lines).strip() + if stmt: + self._conn.execute(stmt) + + self._loaded_datasets.add(dataset_name) + + def execute_sql(self, sql: str) -> list[dict[str, Any]]: + """Execute SQL and return results as a list of dicts.""" + if self._conn is None: + raise RuntimeError("No active database connection") + + result = self._conn.execute(sql) + columns = [desc[0] for desc in result.description] + rows = result.fetchall() + + return [dict(zip(columns, row)) for row in rows] + + def reset(self) -> None: + """Drop all tables and reset the connection.""" + self.connect() diff --git a/compliance/harness/src/harness/models.py b/compliance/harness/src/harness/models.py new file mode 100644 index 0000000..024247b --- /dev/null +++ b/compliance/harness/src/harness/models.py @@ -0,0 +1,101 @@ +"""Data models for the OSI compliance test harness.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from enum import Enum +from functools import cached_property +from pathlib import Path +from typing import Any + + +class Difficulty(str, Enum): + EASY = "easy" + MODERATE = "moderate" + HARD = "hard" + CONVERSION = "conversion" + + +class TestStatus(str, Enum): + PASS = "pass" + FAIL = "fail" + ERROR = "error" + SKIP = "skip" + + +@dataclass(frozen=True) +class TestCase: + """A single compliance test case loaded from disk.""" + + test_id: str + name: str + description: str + area: str + difficulty: str + dataset: str + spec_refs: list[str] + tags: list[str] + model_path: Path + query_path: Path + gold_sql_path: Path + test_dir: Path + expected_error: bool = False + expected_error_code: str = "" + conformance_level: str = "full" + status: str = "active" # "active" or "planned" — planned tests skipped unless --include-planned + required_features: list[str] = field(default_factory=list) # Feature IDs; skip if adapter doesn't support + + @cached_property + def has_order_by(self) -> bool: + """Check if the query specifies an order_by clause.""" + qdict = json.loads(self.query_path.read_text()) + return bool(qdict.get("order_by")) + + +@dataclass +class TestResult: + """Result of running a single test case.""" + + test_id: str + area: str + difficulty: str + status: TestStatus + spec_refs: list[str] = field(default_factory=list) + error_type: str = "" + error_detail: str = "" + generated_sql: str = "" + generated_rows: list[dict[str, Any]] = field(default_factory=list) + gold_rows: list[dict[str, Any]] = field(default_factory=list) + duration_ms: float = 0.0 + required_features: list[str] = field(default_factory=list) + + +@dataclass +class SuiteResult: + """Aggregate results for a test suite run.""" + + adapter: str + results: list[TestResult] = field(default_factory=list) + # None means "no filter applied" (all proposals implicitly enabled). + adapter_features: frozenset[str] | None = None + + @property + def total(self) -> int: + return len(self.results) + + @property + def passed(self) -> int: + return sum(1 for r in self.results if r.status == TestStatus.PASS) + + @property + def failed(self) -> int: + return sum(1 for r in self.results if r.status == TestStatus.FAIL) + + @property + def errors(self) -> int: + return sum(1 for r in self.results if r.status == TestStatus.ERROR) + + @property + def skipped(self) -> int: + return sum(1 for r in self.results if r.status == TestStatus.SKIP) diff --git a/compliance/harness/src/harness/proposals_check.py b/compliance/harness/src/harness/proposals_check.py new file mode 100644 index 0000000..c6884bb --- /dev/null +++ b/compliance/harness/src/harness/proposals_check.py @@ -0,0 +1,137 @@ +"""Validate ``required_features`` in test metadata against proposals.yaml. + +Run via ``python -m harness.proposals_check`` (see ``__main__`` guard). +CI calls this; a non-zero exit signals an unknown or misspelled proposal +ID, which would otherwise silently skip tests on every adapter. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Iterable + +import yaml + +PROPOSALS_FILE = "proposals.yaml" +TESTS_DIR = "tests" +METADATA_FILE = "metadata.yaml" +# ``foundation`` denotes a proposal that is in scope for Foundation v0.1 +# (the proposal at proposals/foundation-v0.1/); ``deferred`` denotes a +# §10 deferred feature; ``proposed`` / ``thin_slice`` were the +# pre-Foundation rollout statuses. New rows should use ``foundation`` +# or ``deferred`` — the older two are retained so legacy proposal rows +# parse without churn. +VALID_STATUS = {"foundation", "thin_slice", "proposed", "deferred"} + + +class ProposalsError(Exception): + """Raised when the registry itself is malformed.""" + + +def load_proposal_ids(root: Path) -> set[str]: + """Return the set of declared proposal IDs. + + Raises :class:`ProposalsError` if the registry is missing keys, + has duplicates, or lists an unknown ``status``. + """ + path = root / PROPOSALS_FILE + data = yaml.safe_load(path.read_text()) + if not isinstance(data, dict) or "proposals" not in data: + raise ProposalsError(f"{path}: missing top-level 'proposals:' key") + + ids: set[str] = set() + for entry in data["proposals"]: + if not isinstance(entry, dict): + raise ProposalsError(f"{path}: every proposal must be a mapping") + try: + pid = entry["id"] + status = entry["status"] + except KeyError as exc: + raise ProposalsError( + f"{path}: proposal entry missing required key: {exc}" + ) from exc + if status not in VALID_STATUS: + raise ProposalsError( + f"{path}: proposal {pid!r} has invalid status {status!r}; " + f"expected one of {sorted(VALID_STATUS)}" + ) + if pid in ids: + raise ProposalsError(f"{path}: duplicate proposal id {pid!r}") + ids.add(pid) + return ids + + +def iter_metadata_files(root: Path) -> Iterable[Path]: + """Yield every ``metadata.yaml`` under the tests tree.""" + yield from sorted((root / TESTS_DIR).rglob(METADATA_FILE)) + + +def collect_unknown_references( + root: Path, valid_ids: set[str] +) -> list[tuple[Path, list[str]]]: + """Return [(metadata_path, unknown_ids)] for every offending file.""" + offenders: list[tuple[Path, list[str]]] = [] + for meta_path in iter_metadata_files(root): + meta = yaml.safe_load(meta_path.read_text()) or {} + features = meta.get("required_features", []) or [] + if not isinstance(features, list): + offenders.append((meta_path, [f": {features!r}"])) + continue + unknown = [f for f in features if f not in valid_ids] + if unknown: + offenders.append((meta_path, unknown)) + return offenders + + +def _format_report( + offenders: list[tuple[Path, list[str]]], + valid_ids: set[str], + root: Path, +) -> str: + lines = [ + "ERROR: unknown proposal IDs referenced in test metadata.", + f"Registry: {(root / PROPOSALS_FILE).as_posix()}", + "", + "Offending files:", + ] + for path, unknown in offenders: + rel = path.relative_to(root) + lines.append(f" {rel}") + for u in unknown: + lines.append(f" - {u}") + lines.append("") + lines.append( + "Either add the proposal to proposals.yaml (with a status), " + "or fix the spelling in the metadata file." + ) + lines.append(f"Known IDs ({len(valid_ids)}): {', '.join(sorted(valid_ids))}") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + """Entry point. Returns 0 on success, 1 on any violation.""" + argv = argv if argv is not None else sys.argv[1:] + root = Path(argv[0]).resolve() if argv else Path(__file__).resolve().parent.parent + + try: + valid_ids = load_proposal_ids(root) + except ProposalsError as exc: + print(f"FATAL: {exc}", file=sys.stderr) + return 2 + + offenders = collect_unknown_references(root, valid_ids) + if offenders: + print(_format_report(offenders, valid_ids, root), file=sys.stderr) + return 1 + + count = sum(1 for _ in iter_metadata_files(root)) + print( + f"OK: {count} metadata files validated against {len(valid_ids)} " + f"registered proposal IDs." + ) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/compliance/harness/src/harness/reporter.py b/compliance/harness/src/harness/reporter.py new file mode 100644 index 0000000..743370a --- /dev/null +++ b/compliance/harness/src/harness/reporter.py @@ -0,0 +1,217 @@ +"""Report generation for the OSI compliance test suite.""" + +from __future__ import annotations + +import csv +import io +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path + +import yaml + +from .models import SuiteResult, TestResult, TestStatus + +PROPOSALS_FILE_NAME = "proposals.yaml" + + +def write_reports(suite: SuiteResult, output_dir: Path) -> tuple[Path, Path]: + """Write failure CSV and summary MD to output_dir. Returns (csv_path, md_path).""" + output_dir.mkdir(parents=True, exist_ok=True) + csv_path = output_dir / "failures.csv" + md_path = output_dir / "summary.md" + + _write_failures_csv(suite, csv_path) + _write_summary_md(suite, md_path) + + return csv_path, md_path + + +def _write_failures_csv(suite: SuiteResult, path: Path) -> None: + failures = [r for r in suite.results if r.status in (TestStatus.FAIL, TestStatus.ERROR)] + + with open(path, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow( + [ + "test_id", + "area", + "difficulty", + "spec_refs", + "error_type", + "details", + ] + ) + for r in failures: + writer.writerow( + [ + r.test_id, + r.area, + r.difficulty, + "; ".join(r.spec_refs), + r.error_type, + r.error_detail, + ] + ) + + +def _write_summary_md(suite: SuiteResult, path: Path) -> None: + buf = io.StringIO() + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + buf.write("# OSI Compliance Test Suite — Summary\n\n") + buf.write(f"**Adapter**: {suite.adapter} \n") + buf.write(f"**Date**: {now} \n\n") + + pct = (suite.passed / suite.total * 100) if suite.total > 0 else 0 + buf.write("## Overall\n\n") + buf.write("| Metric | Count |\n") + buf.write("|--------|-------|\n") + buf.write(f"| Total | {suite.total} |\n") + buf.write(f"| Passed | {suite.passed} |\n") + buf.write(f"| Failed | {suite.failed} |\n") + buf.write(f"| Errors | {suite.errors} |\n") + buf.write(f"| Skipped | {suite.skipped} |\n") + buf.write(f"| **Compliance** | **{pct:.1f}%** |\n\n") + + _write_breakdown(buf, "By Area", suite.results, key=lambda r: r.area) + _write_breakdown(buf, "By Difficulty", suite.results, key=lambda r: r.difficulty) + _write_proposals_status(buf, suite) + + failures = [r for r in suite.results if r.status in (TestStatus.FAIL, TestStatus.ERROR)] + if failures: + buf.write("## Failures\n\n") + buf.write("| Test | Area | Difficulty | Error |\n") + buf.write("|------|------|------------|-------|\n") + for r in failures: + detail = r.error_detail[:80].replace("|", "\\|") if r.error_detail else "" + buf.write(f"| {r.test_id} | {r.area} | {r.difficulty} | {detail} |\n") + buf.write("\n") + + path.write_text(buf.getvalue()) + + +def _write_breakdown( + buf: io.StringIO, + title: str, + results: list[TestResult], + key, +) -> None: + groups: dict[str, list[TestResult]] = defaultdict(list) + for r in results: + groups[key(r)].append(r) + + buf.write(f"## {title}\n\n") + buf.write("| Group | Total | Pass | Fail | Error | Skip | % |\n") + buf.write("|-------|-------|------|------|-------|------|---|\n") + + for group_name in sorted(groups.keys()): + group = groups[group_name] + total = len(group) + passed = sum(1 for r in group if r.status == TestStatus.PASS) + failed = sum(1 for r in group if r.status == TestStatus.FAIL) + errored = sum(1 for r in group if r.status == TestStatus.ERROR) + skipped = sum(1 for r in group if r.status == TestStatus.SKIP) + pct = (passed / total * 100) if total > 0 else 0 + buf.write(f"| {group_name} | {total} | {passed} | {failed} " f"| {errored} | {skipped} | {pct:.0f}% |\n") + buf.write("\n") + + +def _load_proposal_registry(*starts: Path) -> dict[str, str]: + """Return ``{proposal_id: status}``, searching upwards for ``proposals.yaml``. + + Walks up from every given start path. Empty dict when the registry + isn't findable; the caller falls back to the inferred set of IDs + seen across results. + """ + tried: set[Path] = set() + for start in starts: + cursor = start.resolve() + for _ in range(6): + if cursor in tried: + break + tried.add(cursor) + candidate = cursor / PROPOSALS_FILE_NAME + if candidate.exists(): + data = yaml.safe_load(candidate.read_text()) or {} + return {p["id"]: p.get("status", "") for p in data.get("proposals", [])} + if cursor.parent == cursor: + break + cursor = cursor.parent + return {} + + +def _write_proposals_status(buf: io.StringIO, suite: SuiteResult) -> None: + """Emit the Proposals-status section. + + For every proposal ID that is either advertised by the adapter or + referenced by at least one result, report: + + * whether the adapter advertised it (``enabled``), + * total tests that require it, + * tests that ran vs. were skipped, + * pass rate among the ones that ran. + """ + registry = _load_proposal_registry(Path.cwd(), Path(__file__).parent) + + referenced: set[str] = set() + for r in suite.results: + referenced.update(r.required_features) + advertised: set[str] = set(suite.adapter_features or ()) + all_ids = sorted(referenced | advertised | set(registry.keys())) + if not all_ids: + return + + counts_total: dict[str, int] = defaultdict(int) + counts_ran: dict[str, int] = defaultdict(int) + counts_passed: dict[str, int] = defaultdict(int) + counts_skipped: dict[str, int] = defaultdict(int) + + for r in suite.results: + for feat in r.required_features: + counts_total[feat] += 1 + if r.status == TestStatus.SKIP and r.error_type == "unsupported_proposal": + counts_skipped[feat] += 1 + else: + counts_ran[feat] += 1 + if r.status == TestStatus.PASS: + counts_passed[feat] += 1 + + buf.write("## Proposals Status\n\n") + if suite.adapter_features is None: + buf.write("_No `--proposals` filter applied; every proposal implicitly enabled._\n\n") + else: + buf.write(f"Adapter advertised {len(advertised)} proposal(s): " f"`{', '.join(sorted(advertised)) or '(none)'}`\n\n") + + buf.write("| Proposal | Status | Enabled | Tests | Ran | Passed | Skipped | Pass% |\n") + buf.write("|----------|--------|---------|-------|-----|--------|---------|-------|\n") + for pid in all_ids: + status = registry.get(pid, "unknown") + enabled = "yes" if (suite.adapter_features is None or pid in advertised) else "no" + total = counts_total.get(pid, 0) + ran = counts_ran.get(pid, 0) + passed = counts_passed.get(pid, 0) + skipped = counts_skipped.get(pid, 0) + pct = f"{(passed / ran * 100):.0f}%" if ran else "—" + buf.write(f"| `{pid}` | {status} | {enabled} | {total} | {ran} | {passed} | {skipped} | {pct} |\n") + buf.write("\n") + + +def format_summary_console(suite: SuiteResult) -> str: + """Format a brief console summary.""" + pct = (suite.passed / suite.total * 100) if suite.total > 0 else 0 + lines = [ + f"\n{'=' * 50}", + f"OSI Compliance Test Results — {suite.adapter}", + f"{'=' * 50}", + f" Total: {suite.total}", + f" Passed: {suite.passed}", + f" Failed: {suite.failed}", + f" Errors: {suite.errors}", + f" Skipped: {suite.skipped}", + f" Compliance: {pct:.1f}%", + ] + if suite.adapter_features is not None: + lines.append(f" Proposals: {', '.join(sorted(suite.adapter_features)) or '(none)'}") + lines.append(f"{'=' * 50}") + return "\n".join(lines) diff --git a/compliance/harness/src/harness/result_compare.py b/compliance/harness/src/harness/result_compare.py new file mode 100644 index 0000000..3797e12 --- /dev/null +++ b/compliance/harness/src/harness/result_compare.py @@ -0,0 +1,139 @@ +"""Result comparison logic with order-insensitive and numeric-tolerant matching.""" + +from __future__ import annotations + +import math +from datetime import date, datetime, time +from decimal import Decimal +from typing import Any + +DEFAULT_EPSILON = 0.0001 + + +def compare_results( + generated: list[dict[str, Any]], + gold: list[dict[str, Any]], + *, + ordered: bool = False, + epsilon: float = DEFAULT_EPSILON, +) -> tuple[bool, str]: + """Compare two result sets. + + Returns (is_match, detail_message). + """ + if len(generated) != len(gold): + return False, (f"Row count mismatch: generated {len(generated)} rows, " f"gold {len(gold)} rows") + + if not generated: + return True, "Both result sets are empty" + + gen_cols = set(_normalize_key(k) for k in generated[0].keys()) + gold_cols = set(_normalize_key(k) for k in gold[0].keys()) + + if gen_cols != gold_cols: + missing = gold_cols - gen_cols + extra = gen_cols - gold_cols + parts = [] + if missing: + parts.append(f"missing columns: {sorted(missing)}") + if extra: + parts.append(f"extra columns: {sorted(extra)}") + return False, f"Column mismatch: {'; '.join(parts)}" + + gen_normalized = [_normalize_row(r) for r in generated] + gold_normalized = [_normalize_row(r) for r in gold] + + if not ordered: + gen_normalized = _sort_rows(gen_normalized) + gold_normalized = _sort_rows(gold_normalized) + + for i, (gen_row, gold_row) in enumerate(zip(gen_normalized, gold_normalized)): + match, detail = _compare_rows(gen_row, gold_row, epsilon) + if not match: + return False, f"Row {i} mismatch: {detail}" + + return True, f"All {len(generated)} rows match" + + +def _normalize_key(key: str) -> str: + return key.lower().strip() + + +def _normalize_row(row: dict[str, Any]) -> dict[str, Any]: + return {_normalize_key(k): v for k, v in row.items()} + + +def _sort_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Sort rows by all columns for order-insensitive comparison.""" + if not rows: + return rows + columns = sorted(rows[0].keys()) + + def sort_key(row: dict[str, Any]) -> tuple: + return tuple(_sortable_value(row.get(c)) for c in columns) + + return sorted(rows, key=sort_key) + + +def _sortable_value(val: Any) -> tuple: + """Convert a value to a sortable tuple (type_tag, comparable_value).""" + if val is None: + return (0, "") + if isinstance(val, bool): + return (1, int(val)) + if isinstance(val, (int, float, Decimal)): + f = float(val) + if math.isnan(f): + return (2, float("inf")) + return (2, f) + return (3, str(val)) + + +def _compare_rows( + gen: dict[str, Any], + gold: dict[str, Any], + epsilon: float, +) -> tuple[bool, str]: + for key in gold: + gen_val = gen.get(key) + gold_val = gold.get(key) + + if not _values_equal(gen_val, gold_val, epsilon): + return False, (f"column '{key}': generated={gen_val!r}, gold={gold_val!r}") + return True, "" + + +def _values_equal(a: Any, b: Any, epsilon: float) -> bool: + if a is None and b is None: + return True + if a is None or b is None: + return False + + if isinstance(a, (int, float, Decimal)) and isinstance(b, (int, float, Decimal)): + fa, fb = float(a), float(b) + if fa == fb: + return True + if math.isnan(fa) and math.isnan(fb): + return True + if math.isnan(fa) or math.isnan(fb): + return False + if abs(fa - fb) < epsilon: + return True + denom = max(abs(fa), abs(fb)) + if denom == 0: + return True + return abs(fa - fb) / denom < epsilon + + if isinstance(a, datetime) and isinstance(b, datetime): + return a == b + if isinstance(a, datetime) and isinstance(b, date): + return a.date() == b + if isinstance(a, date) and isinstance(b, datetime): + return a == b.date() + if isinstance(a, date) and isinstance(b, date): + return a == b + + if isinstance(a, time) and isinstance(b, time): + return a == b + + return str(a) == str(b) diff --git a/compliance/harness/src/harness/runner.py b/compliance/harness/src/harness/runner.py new file mode 100644 index 0000000..57df5e1 --- /dev/null +++ b/compliance/harness/src/harness/runner.py @@ -0,0 +1,513 @@ +"""Main test runner for the OSI compliance test suite.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import time +from pathlib import Path + +import yaml + +from .db_manager import DBManager +from .models import SuiteResult, TestCase, TestResult, TestStatus +from .reporter import format_summary_console, write_reports +from .result_compare import compare_results + + +def discover_tests( + tests_dir: Path, + *, + difficulty: str | None = None, + area: str | None = None, + include_planned: bool = False, +) -> list[TestCase]: + """Walk the tests directory and discover all test cases.""" + test_cases: list[TestCase] = [] + + for metadata_path in sorted(tests_dir.rglob("metadata.yaml")): + test_dir = metadata_path.parent + meta = yaml.safe_load(metadata_path.read_text()) + + test_difficulty = meta.get("difficulty", "") + test_area = meta.get("area", "") + + if difficulty and test_difficulty != difficulty: + continue + if area and test_area != area: + continue + + model_path = test_dir / "model.yaml" + query_path = test_dir / "query.json" + gold_sql_path = test_dir / "gold.sql" + + if not all(p.exists() for p in [model_path, query_path, gold_sql_path]): + print( + f"WARN: Skipping {test_dir.name} — missing required files", + file=sys.stderr, + ) + continue + + parts = test_dir.relative_to(tests_dir).parts + test_id = "/".join(parts) + + test_status = meta.get("status", "active") + if test_status == "planned" and not include_planned: + continue + + test_cases.append( + TestCase( + test_id=test_id, + name=meta.get("name", test_dir.name), + description=meta.get("description", ""), + area=test_area, + difficulty=test_difficulty, + dataset=meta.get("dataset", ""), + spec_refs=meta.get("spec_refs", []), + tags=meta.get("tags", []), + model_path=model_path, + query_path=query_path, + gold_sql_path=gold_sql_path, + test_dir=test_dir, + expected_error=bool(meta.get("expected_error", False)), + expected_error_code=meta.get("expected_error_code", ""), + conformance_level=meta.get("conformance_level", "full"), + status=test_status, + required_features=meta.get("required_features", []), + ) + ) + + return test_cases + + +def invoke_adapter( + adapter_path: Path, + model_path: Path, + query_path: Path, + dialect: str = "duckdb", + timeout: int = 60, +) -> tuple[str, str, int]: + """Invoke the adapter subprocess. Returns (stdout, stderr, returncode).""" + cmd = [ + sys.executable, + str(adapter_path), + "sql", + "--model", + str(model_path), + "--query-file", + str(query_path), + "--dialect", + dialect, + ] + + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + ) + return proc.stdout, proc.stderr, proc.returncode + except subprocess.TimeoutExpired: + return "", "Adapter timed out", 1 + + +def run_test( + test: TestCase, + adapter_path: Path, + db: DBManager, + datasets_dir: Path, +) -> TestResult: + """Run a single test case and return the result.""" + start = time.monotonic() + + try: + db.reset() + db.load_dataset(test.dataset, datasets_dir) + except Exception as e: + return TestResult( + test_id=test.test_id, + area=test.area, + difficulty=test.difficulty, + status=TestStatus.ERROR, + spec_refs=test.spec_refs, + error_type="dataset_load", + error_detail=str(e), + duration_ms=(time.monotonic() - start) * 1000, + ) + + stdout, stderr, rc = invoke_adapter( + adapter_path, + test.model_path, + test.query_path, + ) + + if test.expected_error: + elapsed = (time.monotonic() - start) * 1000 + if rc != 0: + # Adapter correctly rejected — check error code if specified + if test.expected_error_code and test.expected_error_code not in stderr: + return TestResult( + test_id=test.test_id, + area=test.area, + difficulty=test.difficulty, + status=TestStatus.FAIL, + spec_refs=test.spec_refs, + error_type="wrong_error_code", + error_detail=( + f"Expected error code '{test.expected_error_code}' " + f"in stderr but got: {stderr.strip()[:200]}" + ), + duration_ms=elapsed, + ) + return TestResult( + test_id=test.test_id, + area=test.area, + difficulty=test.difficulty, + status=TestStatus.PASS, + spec_refs=test.spec_refs, + error_detail=f"Correctly rejected: {stderr.strip()[:200]}", + duration_ms=elapsed, + ) + else: + return TestResult( + test_id=test.test_id, + area=test.area, + difficulty=test.difficulty, + status=TestStatus.FAIL, + spec_refs=test.spec_refs, + error_type="expected_error_missing", + error_detail=( + "Expected adapter to reject this query with a non-zero " + "exit code, but it succeeded" + ), + generated_sql=stdout.strip(), + duration_ms=elapsed, + ) + + if rc != 0: + return TestResult( + test_id=test.test_id, + area=test.area, + difficulty=test.difficulty, + status=TestStatus.ERROR, + spec_refs=test.spec_refs, + error_type="adapter_error", + error_detail=stderr.strip() or f"exit code {rc}", + duration_ms=(time.monotonic() - start) * 1000, + ) + + generated_sql = stdout.strip() + + try: + generated_rows = db.execute_sql(generated_sql) + except Exception as e: + return TestResult( + test_id=test.test_id, + area=test.area, + difficulty=test.difficulty, + status=TestStatus.ERROR, + spec_refs=test.spec_refs, + error_type="generated_sql_error", + error_detail=f"Generated SQL failed: {e}", + generated_sql=generated_sql, + duration_ms=(time.monotonic() - start) * 1000, + ) + + gold_sql = test.gold_sql_path.read_text().strip() + try: + gold_rows = db.execute_sql(gold_sql) + except Exception as e: + return TestResult( + test_id=test.test_id, + area=test.area, + difficulty=test.difficulty, + status=TestStatus.ERROR, + spec_refs=test.spec_refs, + error_type="gold_sql_error", + error_detail=f"Gold SQL failed: {e}", + generated_sql=generated_sql, + duration_ms=(time.monotonic() - start) * 1000, + ) + + is_ordered = test.has_order_by + match, detail = compare_results( + generated_rows, + gold_rows, + ordered=is_ordered, + ) + + elapsed = (time.monotonic() - start) * 1000 + + if match: + return TestResult( + test_id=test.test_id, + area=test.area, + difficulty=test.difficulty, + status=TestStatus.PASS, + spec_refs=test.spec_refs, + generated_sql=generated_sql, + generated_rows=generated_rows, + gold_rows=gold_rows, + duration_ms=elapsed, + ) + else: + return TestResult( + test_id=test.test_id, + area=test.area, + difficulty=test.difficulty, + status=TestStatus.FAIL, + spec_refs=test.spec_refs, + error_type="result_mismatch", + error_detail=detail, + generated_sql=generated_sql, + generated_rows=generated_rows, + gold_rows=gold_rows, + duration_ms=elapsed, + ) + + +def list_tests( + tests_dir: Path, + *, + difficulty: str | None = None, + area: str | None = None, + conformance_level: str | None = None, + include_planned: bool = False, +) -> None: + """Print discovered tests without running them.""" + tests = discover_tests( + tests_dir, + difficulty=difficulty, + area=area, + include_planned=include_planned, + ) + if conformance_level: + tests = [t for t in tests if t.conformance_level == conformance_level] + + print(f"{'ID':<60} {'Area':<25} {'Diff':<12} {'Level':<10} {'Status':<8} {'Err?'}") + print("-" * 125) + for t in tests: + err = "yes" if t.expected_error else "" + status = t.status if t.status != "active" else "" + print( + f"{t.test_id:<60} {t.area:<25} {t.difficulty:<12} {t.conformance_level:<10} {status:<8} {err}" + ) + print(f"\nTotal: {len(tests)} test(s)") + + +def run_suite( + adapter_path: Path, + tests_dir: Path, + datasets_dir: Path, + output_dir: Path, + *, + difficulty: str | None = None, + area: str | None = None, + conformance_level: str | None = None, + include_planned: bool = False, + verbose: bool = False, + adapter_features: set[str] | None = None, +) -> SuiteResult: + """Run the full test suite and generate reports.""" + tests = discover_tests( + tests_dir, + difficulty=difficulty, + area=area, + include_planned=include_planned, + ) + if conformance_level: + tests = [t for t in tests if t.conformance_level == conformance_level] + + skipped_by_feature: list[TestCase] = [] + runnable_tests: list[TestCase] = tests + if adapter_features is not None: + runnable_tests = [] + for t in tests: + if t.required_features and not set(t.required_features).issubset( + adapter_features + ): + skipped_by_feature.append(t) + continue + runnable_tests.append(t) + + if not runnable_tests and not skipped_by_feature: + print("No test cases found.", file=sys.stderr) + return SuiteResult(adapter=str(adapter_path)) + + print(f"Discovered {len(tests)} test(s)") + if skipped_by_feature: + print( + f" skipping {len(skipped_by_feature)} test(s) " + "with unsupported proposals" + ) + print(f"Adapter: {adapter_path}") + print(f"Datasets: {datasets_dir}") + print() + + db = DBManager() + suite = SuiteResult( + adapter=str(adapter_path.name), + adapter_features=( + frozenset(adapter_features) if adapter_features is not None else None + ), + ) + + for t in skipped_by_feature: + missing = sorted(set(t.required_features) - set(adapter_features or ())) + suite.results.append( + TestResult( + test_id=t.test_id, + area=t.area, + difficulty=t.difficulty, + status=TestStatus.SKIP, + spec_refs=t.spec_refs, + error_type="unsupported_proposal", + error_detail=f"required proposals not advertised: {', '.join(missing)}", + required_features=list(t.required_features), + ) + ) + + for i, test in enumerate(runnable_tests, 1): + label = f"[{i}/{len(runnable_tests)}] {test.test_id}" + result = run_test(test, adapter_path, db, datasets_dir) + result.required_features = list(test.required_features) + suite.results.append(result) + + if result.status == TestStatus.PASS: + if test.expected_error: + print(f" PASS {label} (expected error)") + else: + print(f" PASS {label}") + elif result.status == TestStatus.FAIL: + print(f" FAIL {label}") + if verbose: + print(f" {result.error_detail}") + elif result.status == TestStatus.ERROR: + print(f" ERR {label}") + if verbose: + print(f" [{result.error_type}] {result.error_detail}") + else: + print(f" SKIP {label}") + + db.close() + + csv_path, md_path = write_reports(suite, output_dir) + print(format_summary_console(suite)) + print("\nReports written to:") + print(f" {csv_path}") + print(f" {md_path}") + + return suite + + +def main() -> int: + parser = argparse.ArgumentParser( + prog="harness.runner", + description="OSI Compliance Test Suite Runner", + ) + parser.add_argument( + "--adapter", + help="Path to the adapter script (e.g., adapters/python_adapter.py)", + ) + parser.add_argument( + "--tests", + required=True, + help="Path to the tests directory", + ) + parser.add_argument( + "--datasets", + help="Path to the datasets directory", + ) + parser.add_argument( + "--output", + default="results/latest", + help=( + "Output directory for reports (default: results/latest). " + "Per-run artifacts (failures.csv, summary.md) go here. " + "The curated baseline at results/REPORT.md is committed and " + "must not be overwritten — choose a subdirectory of results/ " + "(e.g. results//) or another path entirely." + ), + ) + parser.add_argument( + "--difficulty", + choices=["easy", "moderate", "hard", "conversion"], + help="Filter tests by difficulty", + ) + parser.add_argument( + "--area", + help="Filter tests by area (e.g., grain_and_lod, filters)", + ) + parser.add_argument( + "--conformance-level", + choices=["core", "full", "extended"], + help="Filter tests by conformance level", + ) + parser.add_argument( + "--include-planned", + action="store_true", + help="Include tests with status: planned (normally skipped)", + ) + parser.add_argument( + "--list", + action="store_true", + dest="list_only", + help="List discovered tests without running them", + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Show error details for failures", + ) + parser.add_argument( + "--adapter-features", + "--proposals", + nargs="*", + default=None, + dest="adapter_features", + help="Proposal IDs the adapter implements (from proposals.yaml). " + "Tests whose required_features aren't a subset of this set are " + "recorded as SKIP. Alias: --proposals.", + ) + + args = parser.parse_args() + + if args.list_only: + list_tests( + Path(args.tests), + difficulty=args.difficulty, + area=args.area, + conformance_level=args.conformance_level, + include_planned=args.include_planned, + ) + return 0 + + if not args.adapter: + parser.error("--adapter is required when running tests") + if not args.datasets: + parser.error("--datasets is required when running tests") + + feat = set(args.adapter_features) if args.adapter_features is not None else None + suite = run_suite( + adapter_path=Path(args.adapter), + tests_dir=Path(args.tests), + datasets_dir=Path(args.datasets), + output_dir=Path(args.output), + difficulty=args.difficulty, + area=args.area, + conformance_level=args.conformance_level, + include_planned=args.include_planned, + verbose=args.verbose, + adapter_features=feat, + ) + + if suite.failed + suite.errors > 0: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/compliance/harness/src/harness/tests/__init__.py b/compliance/harness/src/harness/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/compliance/harness/src/harness/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/compliance/harness/src/harness/tests/test_proposals_check.py b/compliance/harness/src/harness/tests/test_proposals_check.py new file mode 100644 index 0000000..7635c53 --- /dev/null +++ b/compliance/harness/src/harness/tests/test_proposals_check.py @@ -0,0 +1,139 @@ +"""Unit tests for ``harness.proposals_check``. + +The check runs in CI and guards that every ``required_features`` entry in +test metadata is backed by a real proposal in ``proposals.yaml``. These +tests use a throwaway fixture tree rather than the live suite, so they +stay deterministic as tests come and go. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from harness import proposals_check + + +@pytest.fixture +def make_tree(tmp_path: Path): + """Build a minimal suite layout under ``tmp_path`` and return a builder.""" + + def _build(proposals_yaml: str, metadata: dict[str, str]) -> Path: + (tmp_path / "proposals.yaml").write_text(proposals_yaml) + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + for rel, body in metadata.items(): + target = tests_dir / rel / "metadata.yaml" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(body) + return tmp_path + + return _build + + +VALID_PROPOSALS = """\ +proposals: + - id: dataset_filters + status: proposed + title: Dataset filters + - id: non_equijoin + status: proposed + title: Non-equijoin + - id: pervasive_scope + status: proposed + title: Pervasive scope +""" + + +def test_clean_tree_returns_zero(make_tree, capsys) -> None: + root = make_tree( + VALID_PROPOSALS, + { + "a/test_ok": "required_features: [dataset_filters]\n", + "b/test_multi": "required_features: [dataset_filters, non_equijoin]\n", + "c/test_no_features": "name: plain\n", + }, + ) + assert proposals_check.main([str(root)]) == 0 + assert "OK:" in capsys.readouterr().out + + +def test_unknown_feature_returns_one(make_tree, capsys) -> None: + root = make_tree( + VALID_PROPOSALS, + {"x/test_bad": "required_features: [made_up_feature]\n"}, + ) + assert proposals_check.main([str(root)]) == 1 + err = capsys.readouterr().err + assert "made_up_feature" in err + assert "x/test_bad" in err + + +def test_typo_is_caught(make_tree) -> None: + root = make_tree( + VALID_PROPOSALS, + {"x/test_typo": "required_features: [dataset_filterz]\n"}, + ) + assert proposals_check.main([str(root)]) == 1 + + +def test_non_list_features_is_reported(make_tree, capsys) -> None: + root = make_tree( + VALID_PROPOSALS, + {"x/test_wrong_type": "required_features: dataset_filters\n"}, + ) + assert proposals_check.main([str(root)]) == 1 + assert "not-a-list" in capsys.readouterr().err + + +def test_duplicate_proposal_id_fails_fast(make_tree, capsys) -> None: + root = make_tree( + """\ +proposals: + - id: dupe + status: proposed + - id: dupe + status: proposed +""", + {}, + ) + assert proposals_check.main([str(root)]) == 2 + assert "duplicate" in capsys.readouterr().err + + +def test_invalid_status_fails_fast(make_tree, capsys) -> None: + root = make_tree( + """\ +proposals: + - id: foo + status: maybe +""", + {}, + ) + assert proposals_check.main([str(root)]) == 2 + assert "invalid status" in capsys.readouterr().err + + +def test_missing_top_level_key_fails_fast(make_tree, capsys) -> None: + root = make_tree("proposalz:\n - id: foo\n", {}) + assert proposals_check.main([str(root)]) == 2 + assert "top-level 'proposals:'" in capsys.readouterr().err + + +def test_live_registry_validates() -> None: + """The real ``proposals.yaml`` in the repo passes the check. + + Post-migration, ``proposals.yaml`` and the test corpus live under + ``compliance/foundation-v0.1/``. We resolve the path relative to + this file so the test stays correct regardless of where the + harness package itself happens to be installed. + """ + foundation_root = ( + Path(__file__).resolve().parents[4] / "foundation-v0.1" + ) + assert (foundation_root / "proposals.yaml").exists(), ( + f"proposals.yaml not found at {foundation_root} — the suite " + "layout under compliance/foundation-v0.1/ has changed." + ) + assert proposals_check.main([str(foundation_root)]) == 0 diff --git a/compliance/harness/src/harness/tests/test_registry_yaml.py b/compliance/harness/src/harness/tests/test_registry_yaml.py new file mode 100644 index 0000000..e698a0d --- /dev/null +++ b/compliance/harness/src/harness/tests/test_registry_yaml.py @@ -0,0 +1,181 @@ +"""Cleanliness gate for the compliance registry YAML files. + +``decisions.yaml`` is the source of truth that ties Appendix B decisions +in the Foundation spec to runnable witness tests. ``proposals.yaml`` +plays the same role for §10 deferred features. Both files have already +broken at least once because an unquoted ``:`` inside a title was +parsed by YAML as a mapping value (Phase 4 compliance review, finding +B1). This test pins both files so a future edit cannot reintroduce +that class of bug. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml + +_FOUNDATION_DIR = ( + Path(__file__).resolve().parents[4] / "foundation-v0.1" +) + + +@pytest.mark.parametrize( + "yaml_path", + [ + _FOUNDATION_DIR / "decisions.yaml", + _FOUNDATION_DIR / "proposals.yaml", + _FOUNDATION_DIR / "conformance.yaml", + ], + ids=lambda p: p.name, +) +def test_registry_yaml_is_parseable(yaml_path: Path) -> None: + """Every registry YAML file must load as a top-level mapping. + + A future edit that introduces an unquoted ``:`` or a stray ``-`` + breaks the entire coverage-by-decision report — silently in CI + unless this test exists. The assertion is intentionally weak (only + ``isinstance(..., dict)``) because the harness loaders enforce the + shape; here we just want the bare YAML parse to succeed. + """ + assert yaml_path.exists(), f"registry file missing: {yaml_path}" + raw = yaml_path.read_text(encoding="utf-8") + loaded = yaml.safe_load(raw) + assert isinstance(loaded, dict), ( + f"{yaml_path.name} did not parse as a top-level YAML mapping; " + "every registry file is required to be a mapping with either a " + "``decisions`` or ``proposals`` top-level key." + ) + + +# Decision IDs that are intentionally absent from decisions.yaml because +# the spec has demoted them. Each entry must come with a one-line +# reference so a future reviewer can verify the demotion is still +# accurate. Update this set in lockstep with the spec. +_INTENTIONALLY_ABSENT_DECISIONS: dict[str, str] = { + # D-013 is reserved in the spec but has no Appendix-B row (the + # number is intentionally skipped — see Proposed_OSI_Semantics.md). + "D-013": "Reserved in Appendix B (number skipped).", + # D-015 is struck in Appendix B (~~D-015~~ Deferred — moved to a + # separate proposal). The compilation-strategy equivalence for + # field-level cross-grain aggregation is moot at the Foundation + # level because field-level aggregation itself is deferred per + # D-003. The Foundation surface is exercised by the D-003 + # rejection witness; the strategy equivalence returns alongside + # §10's grain-aware-functions proposal. + "D-015": "Struck in Appendix B; depends on the deferred D-003.", + # D-017 is deferred — semi-join filtering moved to a follow-up + # proposal. The negative test for EXISTS_IN is a rejection test. + "D-017": "Deferred — Proposed_OSI_Semantics.md table row marked", +} + + +def test_decisions_yaml_has_all_decision_ids() -> None: + """Every active Appendix-B decision must appear exactly once. + + Pinning the ID set here surfaces both: + + - a duplicate ID (``yaml.safe_load`` collapses duplicate keys + silently inside a mapping; we count list entries instead). + - a missing decision (Phase 4 compliance review I7 — several + decisions had no row at all). + + Decisions that the spec has intentionally demoted live in + :data:`_INTENTIONALLY_ABSENT_DECISIONS` with a rationale. + """ + raw = (_FOUNDATION_DIR / "decisions.yaml").read_text(encoding="utf-8") + loaded = yaml.safe_load(raw) + decisions = loaded.get("decisions", []) + ids = [d["id"] for d in decisions] + + duplicates = {x for x in ids if ids.count(x) > 1} + assert not duplicates, ( + f"decisions.yaml has duplicate ids: {sorted(duplicates)}. Each " + "decision must appear exactly once." + ) + + expected = {f"D-{i:03d}" for i in range(1, 34)} + present = set(ids) + missing = expected - present - set(_INTENTIONALLY_ABSENT_DECISIONS) + assert not missing, ( + f"decisions.yaml is missing rows for: {sorted(missing)}. Every " + "active Appendix-B decision must have a registry row, even if " + "its ``tests:`` list is empty pending witness work. If a " + "decision was intentionally demoted, add it to " + "_INTENTIONALLY_ABSENT_DECISIONS with a rationale." + ) + + # Inverse check: an entry in _INTENTIONALLY_ABSENT_DECISIONS that + # actually appears in decisions.yaml is also drift. + accidental = present & set(_INTENTIONALLY_ABSENT_DECISIONS) + assert not accidental, ( + f"decisions.yaml has rows for IDs marked as intentionally " + f"absent: {sorted(accidental)}. Update " + "_INTENTIONALLY_ABSENT_DECISIONS or remove the row." + ) + + +def test_decisions_yaml_paths_exist_on_disk() -> None: + """Every ``tests:`` path in decisions.yaml must point to a real test. + + Phase 4 review B2 — pre-migration paths still littered the file + and the coverage-by-decision report was reading a fictional disk. + A path that doesn't resolve to a directory with a ``metadata.yaml`` + is a regression: either the test was renamed, moved, or deleted + and decisions.yaml wasn't updated. + """ + raw = (_FOUNDATION_DIR / "decisions.yaml").read_text(encoding="utf-8") + loaded = yaml.safe_load(raw) + decisions = loaded.get("decisions", []) + + missing: list[tuple[str, str]] = [] + for row in decisions: + for test_rel in row.get("tests") or (): + test_dir = _FOUNDATION_DIR / test_rel + if not (test_dir / "metadata.yaml").exists(): + missing.append((row["id"], test_rel)) + + assert not missing, ( + "decisions.yaml references paths that don't exist on disk:\n" + + "\n".join(f" {d}: {p}" for d, p in missing) + + "\nRegenerate the tests: lists from disk metadata, or move " + "the renamed test back." + ) + + +def test_every_disk_test_pins_a_known_decision() -> None: + """The inverse: every metadata.yaml's ``decision`` must be in + decisions.yaml (or :data:`_INTENTIONALLY_ABSENT_DECISIONS`). + + Closes the second half of the drift gap — without this, a test + can pin a decision that no longer has a registry row and the + coverage report silently misses it. + """ + raw = (_FOUNDATION_DIR / "decisions.yaml").read_text(encoding="utf-8") + loaded = yaml.safe_load(raw) + known = {row["id"] for row in loaded.get("decisions", [])} + known.update(_INTENTIONALLY_ABSENT_DECISIONS) + + unknown_pins: list[tuple[Path, str]] = [] + tests_dir = _FOUNDATION_DIR / "tests" + for meta_path in sorted(tests_dir.rglob("metadata.yaml")): + metadata = yaml.safe_load(meta_path.read_text()) + decision = metadata.get("decision") or metadata.get("decisions") + decisions = ( + [decision] + if isinstance(decision, str) + else (decision or []) + ) + for d in decisions: + if d not in known: + unknown_pins.append((meta_path, d)) + + assert not unknown_pins, ( + "Test metadata pins decisions that don't appear in " + "decisions.yaml:\n" + + "\n".join( + f" {p.relative_to(_FOUNDATION_DIR)}: {d}" + for p, d in unknown_pins + ) + ) diff --git a/compliance/harness/src/harness/tests/test_reporter_proposals.py b/compliance/harness/src/harness/tests/test_reporter_proposals.py new file mode 100644 index 0000000..b89500d --- /dev/null +++ b/compliance/harness/src/harness/tests/test_reporter_proposals.py @@ -0,0 +1,102 @@ +"""Tests for the Proposals-status reporter section.""" + +from __future__ import annotations + +import io + +from harness.models import SuiteResult, TestResult +from harness.models import TestStatus as Status +from harness.reporter import ( + _write_proposals_status, + format_summary_console, +) + + +def _res(status: TestStatus, *, features: list[str], error_type: str = "") -> TestResult: + return TestResult( + test_id="x", + area="a", + difficulty="easy", + status=status, + required_features=features, + error_type=error_type, + ) + + +def test_section_groups_results_by_proposal() -> None: + suite = SuiteResult( + adapter="adapter.py", + adapter_features=frozenset({"non_equijoin"}), + results=[ + _res(Status.PASS, features=["non_equijoin"]), + _res(Status.FAIL, features=["non_equijoin"]), + _res( + Status.SKIP, + features=["grain_modes"], + error_type="unsupported_proposal", + ), + _res( + Status.SKIP, + features=["grain_modes", "non_equijoin"], + error_type="unsupported_proposal", + ), + ], + ) + buf = io.StringIO() + _write_proposals_status(buf, suite) + md = buf.getvalue() + + assert "## Proposals Status" in md + # Skipped-due-to-proposal counts are attributed to every required feature, + # even ones the adapter DOES have, because the test still didn't run. + assert "`non_equijoin`" in md + assert "`grain_modes`" in md + # grain_modes row: 2 total, 0 ran, 2 skipped + grain_row = next(line for line in md.splitlines() if line.startswith("| `grain_modes`")) + assert " 2 |" in grain_row # total + assert " 2 |" in grain_row # skipped + # non_equijoin row: 3 total (one PASS, one FAIL, one SKIP), 2 ran, 1 passed + ne_row = next(line for line in md.splitlines() if line.startswith("| `non_equijoin`")) + assert "50%" in ne_row + + +def test_no_filter_applied_marker() -> None: + suite = SuiteResult( + adapter="x", + adapter_features=None, + results=[_res(Status.PASS, features=["non_equijoin"])], + ) + buf = io.StringIO() + _write_proposals_status(buf, suite) + md = buf.getvalue() + assert "implicitly enabled" in md + + +def test_empty_section_when_no_proposals() -> None: + suite = SuiteResult(adapter="x", adapter_features=frozenset()) + buf = io.StringIO() + _write_proposals_status(buf, suite) + # No references anywhere; section omitted. + # _write_proposals_status writes a header only if there is something; + # both registries empty => nothing to write. + # (Our current registry loader finds the live proposals.yaml, so the + # section WILL render with zero totals. Accept either outcome.) + if buf.getvalue(): + assert "## Proposals Status" in buf.getvalue() + + +def test_console_summary_includes_proposals_when_set() -> None: + suite = SuiteResult( + adapter="x", + adapter_features=frozenset({"parameters", "non_equijoin"}), + ) + out = format_summary_console(suite) + assert "Proposals:" in out + assert "non_equijoin" in out + assert "parameters" in out + + +def test_console_summary_omits_proposals_when_unset() -> None: + suite = SuiteResult(adapter="x", adapter_features=None) + out = format_summary_console(suite) + assert "Proposals:" not in out diff --git a/impl/python/.flake8 b/impl/python/.flake8 new file mode 100644 index 0000000..93d862c --- /dev/null +++ b/impl/python/.flake8 @@ -0,0 +1,39 @@ +[flake8] +max-line-length = 88 +extend-select = E,W,F +extend-ignore = + # Black-compatible: whitespace before ':' (slices) and line-break before binary op + E203, + W503, + # Allow single-line Protocol / overload stubs that black collapses + E704, + # Missing docstring in public module / package (__init__.py is often empty) + D104, + # Missing docstring in __init__ of a class (we put the doc on the class) + D107 +exclude = + .git, + __pycache__, + build, + dist, + htmlcov, + .venv, + .mypy_cache, + .pytest_cache, + .hypothesis, + .benchmarks, + .mutmut-cache, + .temp_scripts, + docs, + specs, + logs +per-file-ignores = + # Facade re-exports trip "imported but unused" (F401) by design + src/osi/__init__.py:F401 + src/osi/planning/__init__.py:F401 + src/osi/planning/algebra/__init__.py:F401 + src/osi/codegen/__init__.py:F401 + src/osi/parsing/__init__.py:F401 + # Tests may skip docstrings and have long lines for fixtures + tests/*:D100,D101,D102,D103,E501,F841 + conformance/*:D100,D101,D102,D103,E501,F841 diff --git a/impl/python/.pre-commit-config.yaml b/impl/python/.pre-commit-config.yaml new file mode 100644 index 0000000..cdaa6cc --- /dev/null +++ b/impl/python/.pre-commit-config.yaml @@ -0,0 +1,81 @@ +# Project-local pre-commit configuration for impl/python. +# +# Each hook's `files:` pattern restricts it to impl/python sources so that +# running pre-commit from the repo root doesn't spuriously lint other +# parts of the OSI tree. +# +# Install: make precommit-install +repos: + - repo: https://github.com/psf/black + rev: 24.8.0 + hooks: + - id: black + files: ^impl/python/(src|tests)/.*\.py$ + args: [--config, impl/python/pyproject.toml] + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + files: ^impl/python/(src|tests)/.*\.py$ + args: [--settings-path, impl/python/pyproject.toml] + + - repo: https://github.com/pycqa/flake8 + rev: 7.1.1 + hooks: + - id: flake8 + files: ^impl/python/(src|tests)/.*\.py$ + args: [--config, impl/python/.flake8] + additional_dependencies: + - flake8-docstrings>=1.7 + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy + # Only type-check osi sources; tests are covered by pyproject overrides. + files: ^impl/python/src/osi/.*\.py$ + args: + - --config-file=impl/python/pyproject.toml + additional_dependencies: + - pydantic>=2.6 + - sqlglot>=26.0 + - types-PyYAML>=6.0 + - networkx>=3.0 + + - repo: local + hooks: + - id: no-fstring-sql + name: "Ban f-string SQL (INFRA.md §1.3)" + description: > + Rejects any f-string, string concatenation, or format call that + builds a SELECT / FROM / WHERE / GROUP BY / ORDER BY. All SQL + must be built via SQLGlot AST. + entry: > + bash -c ' + if grep -nE "f\"[^\"]*\\b(SELECT|FROM|WHERE|GROUP BY|ORDER BY)\\b" "$@"; then + echo "ERROR: f-string SQL detected. Use SQLGlot AST instead."; exit 1; + fi + ' + language: system + files: ^impl/python/src/.*\.py$ + pass_filenames: true + + - id: file-size-cap + name: "Enforce 600-LOC cap in src/osi/ (INFRA.md §1.2 / [I-DEC-6])" + description: > + Rejects any file in src/osi/ that exceeds 600 lines. + entry: > + bash -c ' + fail=0; + for f in "$@"; do + lines=$(wc -l < "$f"); + if [ "$lines" -gt 600 ]; then + echo "ERROR: $f has $lines lines, cap is 600."; fail=1; + fi; + done; + exit $fail + ' + language: system + files: ^impl/python/src/osi/.*\.py$ + pass_filenames: true diff --git a/impl/python/AGENTS.md b/impl/python/AGENTS.md new file mode 100644 index 0000000..cd233b0 --- /dev/null +++ b/impl/python/AGENTS.md @@ -0,0 +1,172 @@ +# AGENTS.md — Guidance for AI coding agents working on `impl/python` + +This file is read by AI coding agents before they make changes. It covers +orientation, non-negotiable rules, reviewer-skill cadence, and common pitfalls. + +## Quick orientation + +- **Project type:** Python package — reference compiler for the Foundation of + the Open Semantic Interchange (OSI) standard. +- **Authoritative spec:** [`../../proposals/foundation-v0.1/`](../../proposals/foundation-v0.1/) + — in particular + [`Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) + (`osi_version: "0.1"`), + [`SQL_EXPRESSION_SUBSET.md`](../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md) + (`OSI_SQL_2026` default dialect), + [`JOIN_ALGEBRA.md`](docs/JOIN_ALGEBRA.md), + [`DATA_TESTS.md`](../../compliance/foundation-v0.1/DATA_TESTS.md) (T-NNN vectors). +- **Implementation docs:** [`SPEC.md`](SPEC.md), [`ARCHITECTURE.md`](ARCHITECTURE.md), + [`INFRA.md`](INFRA.md). +- **Companion compliance suite:** [`../../compliance/foundation-v0.1/`](../../compliance/foundation-v0.1/) + — exercises every D-NNN in Appendix B of the Foundation spec. + +## First principles (do not violate) + +1. **The Foundation is thin on purpose.** If a feature is not in + [`../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) + (`osi_version: "0.1"`), it is out of scope. The deferred list in §10 + of that spec is normative. Adding speculative plumbing for deferred + features is the #1 way this project gets derailed. +2. **Cleanliness over backwards compatibility.** This implementation has + not shipped. When a name, error code, public API, or YAML key + changes, delete the old one in the same sprint. No deprecation + shims, no legacy aliases, no compat flags. See `SPEC.md` header for + the full project-wide rule. +3. **The algebra is the correctness boundary.** Every compiler + transformation is a composition of the operators in + [`docs/JOIN_ALGEBRA.md`](docs/JOIN_ALGEBRA.md). + Do not invent a new one. Do not bypass preconditions. +4. **No silent wrong SQL.** Any semantics the compiler cannot handle + correctly raise a typed `OSIError` with an error code from + **Appendix C of + [`Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md)**. + [`docs/ERROR_CODES.md`](docs/ERROR_CODES.md) is the implementation + mirror; Appendix C is the source of truth. Returning plausibly-wrong + SQL is the worst possible outcome. +5. **Every feature needs tests at five layers.** Unit + property + + golden + E2E + compliance + ([`../../compliance/foundation-v0.1/`](../../compliance/foundation-v0.1/)). + See [`docs/TESTING_STRATEGY.md`](docs/TESTING_STRATEGY.md) and + `SPEC.md §9`. Every D-NNN row in Appendix B has at least one `T-NNN` + vector in + [`DATA_TESTS.md`](../../compliance/foundation-v0.1/DATA_TESTS.md) and a + runnable case in the compliance suite. +6. **Mutation testing is a hard gate.** A surviving mutation in + `src/osi/planning/algebra/` is a P0. See [`INFRA.md §1.1`](INFRA.md). + +## Before modifying code + +- Read [`ARCHITECTURE.md`](ARCHITECTURE.md) §6 "Architectural invariants" + including §6.5 "Invariants enforced in code" (the catalog mapping + each invariant to its deterministic check). +- Check [`INFRA.md §3`](INFRA.md) for an in-progress infrastructure item + that might overlap with your work. +- If you are adding or changing an algebra operator, read + [`JOIN_ALGEBRA.md`](docs/JOIN_ALGEBRA.md) + in full and check the corresponding property tests in + `tests/properties/`. +- For any architectural change (planner, codegen, dialect, algebra), + consult all three of the BI / Compiler / Database best-practices + skills — at design time *and* at review time (see Reviewer skills below). + +## Reviewer skills (load-bearing — use at design time AND review time) + +The repo carries dual-purpose reviewer skills under +[`../../.agent-skills/`](../../.agent-skills/) — each one is usable +both to *review* an existing change and to *design* a new one. The +index is [`../../.agent-skills/REVIEWER_SKILLS.md`](../../.agent-skills/REVIEWER_SKILLS.md). + +**Cadence rule (from [`../../CONTRIBUTING.md §2`](../../CONTRIBUTING.md)).** +Any architectural change must consult all three of these skills, both +at design time and at review time: + +- `bi-best-practices-review` — grain / fan-out / bridge / conformed + dims / semi-additive measures. +- `compiler-best-practices-review` — phase boundaries / IR purity / + totality / determinism / error taxonomy. +- `database-best-practices-review` — SQL emission via AST / identifier + quoting / NULL ordering / multiset semantics / dialect isolation. + +Other skills (architectural, interfaces, typing, doc-as-enforcement, +code-encourages-correct-use, spec-coherence) run as relevant to the +change. + +Cite the skill(s) you consulted in the PR description per +[`../../CONTRIBUTING.md §4`](../../CONTRIBUTING.md). + +## Triage rule (applies to every finding you produce or receive) + +Findings — from a reviewer skill, a failing test, a lint warning, a +user bug — walk this hierarchy top-down: + +1. **Convert to a deterministic check** — drift test, arch-test, + import-linter contract, mypy rule, lint rule. Preferred. +2. **Sharpen the relevant skill's checklist** — update the `SKILL.md` + so future runs catch the missed angle. +3. **Tighten documentation** — `ARCHITECTURE.md` / README / `INFRA.md`. +4. **Queue as a code-change sprint item** — last resort. + +Design-time framing: before writing code that establishes a new +boundary or invariant, ask "can I add a deterministic check that +locks this in before the code lands?" If yes, write the check in the +same PR. Don't defer it. + +## Hard rules + +| Rule | Enforcement | +|:---|:---| +| No raw-string SQL (no f-strings, no `+`, no `.format()` for SQL) | CI grep + flake8 rule | +| No file in `src/osi/` exceeds 600 LOC | CI audit | +| One-way imports: `parsing ← planning ← codegen`; `common` by all | `import-linter` | +| Strict mypy; `# type: ignore` requires `[] # reason: ` | flake8 + CI | +| Tests assert on `error.code`, never on message text | Code review | +| No `@pytest.skip` without a platform-specific reason | Code review | +| Every new `ErrorCode` is a value listed in Appendix C of the Foundation spec and has a `T-NNN` test in `DATA_TESTS.md` | Code review | +| No legacy alias / deprecation shim / compat flag for any name that changes during a sprint | Code review (cleanliness gate, `SPEC.md` header) | + +## Common pitfalls + +1. **Importing `SemanticModel` directly inside `planning/` helpers.** + Don't — receive `ctx: PlannerContext` instead. The model lives on + the context and nowhere else. +2. **Building `CalculationState` by hand.** Don't — only the algebra + creates states. Outside the algebra, use `source(...)` as the + starting point. +3. **Adding a plan field to work around codegen needing model info.** + First confirm the plan actually lacks the information; if so, add + the field to `PlanStep` rather than having codegen re-read the + model. +4. **Making a property test "flaky" by adding `@example` cases instead + of narrowing the strategy.** Strategy narrowing is the right fix; + `@example` is for specific regression seeds after a shrunk + counterexample. +5. **Skipping golden refresh because "the diff is big".** If the diff + is big, the plan changed substantially — explain why in the PR. + +## Tooling + +This project has its own isolated virtualenv and pre-commit hooks. +Always activate the project-local venv; do not use the repo-root one. + +```bash +cd impl/python +source .venv/bin/activate +``` + +```bash +make install-dev # create .venv, install deps + pre-commit hooks +make format # black + isort +make lint # flake8 + mypy + import-linter +make test # pytest (unit + property + golden + E2E) +make check # lint + test +make mutation-fast # mutation on the algebra module +make mutation # full mutation run +``` + +Before committing: run `make check`. A surviving mutation in +`src/osi/planning/algebra/` from `make mutation-fast` is a P0 — kill +it first. Cite the invariant number from `ARCHITECTURE.md §6` in the +PR description when your change touches one. + +See [`README.md`](README.md) and [`RUNNING_TESTS.md`](RUNNING_TESTS.md) +for full entry points. diff --git a/impl/python/ARCHITECTURE.md b/impl/python/ARCHITECTURE.md new file mode 100644 index 0000000..2317918 --- /dev/null +++ b/impl/python/ARCHITECTURE.md @@ -0,0 +1,456 @@ +# ARCHITECTURE.md — `osi_python` Architectural Contract + +This document is the architectural contract. It is the source of truth for +*where things live*, *what each layer may do*, and *what must always be +true*. Read this before adding code that spans more than one package. + +The guiding principle: + +> **The compiler is a closed, pure algebra over an immutable semantic +> model. Every transformation is a total function from state to state; +> every generated SQL statement is a deterministic projection of a plan. +> The algebra is the hard boundary of correctness.** + +If a proposed change breaks that sentence, it is the wrong change. + +--- + +## Table of Contents + +1. [Three-layer pipeline](#1-three-layer-pipeline) +2. [Layer 1 — Parsing](#2-layer-1--parsing) +3. [Layer 2 — Planning](#3-layer-2--planning) +4. [Layer 3 — Codegen](#4-layer-3--codegen) +5. [The closed algebra](#5-the-closed-algebra) +6. [Architectural invariants](#6-architectural-invariants) — including + the *Invariants enforced in code* catalog (mapping each numbered + invariant to the deterministic check that enforces it) +7. [Error discipline](#7-error-discipline) +8. [Where to add things](#8-where-to-add-things) +9. [Canonical entry points](#9-canonical-entry-points) + +--- + +## 1. Three-layer pipeline + +The compiler is strictly three layers. Each layer has a single output +type, sees only the layer above it, and has no opinion about the layer +below it. + +``` + YAML ──(parse)──▶ SemanticModel ──(plan)──▶ QueryPlan ──(render)──▶ SQL + Layer 1 Layer 2 Layer 3 + parsing/ planning/ codegen/ +``` + +| Layer | Package | Input | Output | Job | +|:---|:---|:---|:---|:---| +| **1. Parsing** | `osi.parsing` | YAML path / string | `SemanticModel`, `Namespace`, `RelationshipGraph` | Load and validate declarations. Produce a typed, frozen, self-consistent model. Reject deferred features with `E1105`. | +| **2. Planning** | `osi.planning` | `SemanticModel` + `SemanticQuery` | `QueryPlan` (ordered tuple of `PlanStep`s, each wrapping a closed-algebra operator over an immutable `CalculationState`) | Decide *how* to compute the query. No SQL. | +| **3. Codegen** | `osi.codegen` | `QueryPlan` + dialect | SQL string | Render the plan to dialect-specific SQL via SQLGlot AST. No semantics. | + +A shared `osi.diagnostics` package reads the same artifacts (model + +plan) to produce `describe` / `explain` / `resolve` output. It never +mutates them. + +### 1.1 The one-way information flow + +Information only travels **down** the pipeline. This is non-negotiable. + +- Codegen **must not** open the YAML, consult the namespace, or reach + back into `SemanticModel`. Every fact it needs has to be on the plan. +- Planning **must not** emit SQL. It may *parse* SQL fragments with + SQLGlot to analyse dependencies, but it never decides how the final + SQL will look. +- Parsing **must not** know about plans, algebra, or dialects. It only + validates that the YAML describes a legal Foundation model. + +Violation is enforced by `import-linter` contracts declared in +`pyproject.toml` and checked in CI (`INFRA.md §1.2`). + +--- + +## 2. Layer 1 — Parsing + +**Contract.** Turn a YAML file into an immutable, validated +`SemanticModel` that every later layer can trust without re-checking. + +### 2.1 Responsibilities + +1. Strict pydantic schema parsing (`parsing/models.py`), `extra="forbid"`. +2. Cross-reference validation: metric-referenced fields exist, relationship + endpoints are real datasets with real fields, primary keys are declared + where required. +3. Deferred-feature detection: any use of `FIXED`/`INCLUDE`/`EXCLUDE`/`TABLE` + grain modes, filter-context properties (`reset`, filter.expression on + metrics), window functions in expressions, non-equijoin conditions, + grouping sets, pivot, or any other deferred feature raises `E1105 + RESERVED_FOR_DEFERRED`. +4. Circular-reference detection for metric arithmetic (metric depending + on itself through composition). +5. Name-resolution index construction (`parsing/namespace.py`). +6. Relationship graph construction (`parsing/graph.py`). +7. Identifier normalization via `osi.common.identifiers.normalize_identifier`. + +### 2.2 Non-responsibilities + +Parsing knows nothing about queries, plans, dialects, or SQL. It does +not expand metric arithmetic, infer sources, or simplify expressions. It +also does not construct `CalculationState` — only the algebra does that. + +### 2.3 Key exports + +- `osi.parsing.parse_semantic_model(path: str | Path) -> SemanticModel` +- `osi.parsing.SemanticModel`, `Dataset`, `Metric`, `Relationship`, + `Namespace`, `RelationshipGraph` +- `osi.parsing.reserved_names.OSI_RESERVED_NAMES` — the set of + OSI-grammar keywords (`GRAIN`, `FILTER`, `QUERY_FILTER`) that user + identifiers may not collide with (D-019, enforced in + `parsing/validation.py`). +- `osi.errors.OSIValidationError` + +--- + +## 3. Layer 2 — Planning + +**Contract.** Given a frozen `SemanticModel` and a typed `SemanticQuery`, +produce a deterministic `QueryPlan` that computes the query using only +closed algebra operations. + +### 3.1 Responsibilities + +1. Resolve names (`planning/resolve.py`, via `Namespace`). +2. Classify measures by their source dataset and grain-compatible groups. +3. Expand metric arithmetic (Foundation arithmetic only, no grain + inheritance). +4. Classify filters — row-level vs semi-join vs post-aggregate-having — + in `planning/classify.py`. +5. Resolve join paths between datasets via `RelationshipGraph` + (`planning/joins.py`). Infer cardinality from declared keys; surface + `E3003 AMBIGUOUS_CARDINALITY` when inference is not possible. +6. Emit a sequence of algebra operator applications whose final + `CalculationState` matches the requested grain and columns. + +### 3.2 Non-responsibilities + +Planning never writes SQL strings, chooses CTE shapes, inlines subqueries, +or picks identifier quoting. It does not touch the database. It does not +parse the YAML. + +### 3.3 Core types + +| Type | Module | Role | +|:---|:---|:---| +| `PlannerContext` | `planner_context.py` | Frozen bundle of `(model, namespace, graph, analyzer)`. The only way deeper modules see the model. | +| `CalculationState` | `algebra/state.py` | Immutable `(grain, columns, provenance)`. The sole currency of the algebra. | +| `QueryPlan` | `plan.py` | Ordered tuple of `PlanStep`s referencing `CalculationState`s. The hand-off to codegen. | +| `PlanStep` | `plan.py` | A single operator application + inputs + outputs + annotations for codegen. | +| `Planner` | `planner.py` | The single planner. Input: `SemanticQuery`. Output: `QueryPlan`. | + +### 3.4 Module map + +``` +src/osi/planning/ + algebra/ + state.py # CalculationState, Column + operations.py # the 9 operators (§5) + grain.py # symbolic grain helpers (for tests) + plan.py # QueryPlan, PlanStep, PlanOperation enum + planner_context.py # PlannerContext + planner.py # Planner.plan(query) — the composer + planner_scalar.py # scalar (Fields-only) query composer + planner_bridge.py # M:N bridge resolution, distinct-bridge dedup, + # nested-aggregate-over-bridge plans (D-022, D-026) + planner_nested.py # nested cross-grain aggregate planner (D-020, D-024) + planner_composites.py # composite metric (formula) planning + planner_mn.py # multi-fact / many-to-many helpers + home_grain.py # implicit home-grain rewrite via correlated + # subqueries (D-003, D-015) + windows.py # window-function rules (D-028..D-032) + classify.py # filter classification + joins.py # path resolution, cardinality inference + resolve.py # name resolution against Namespace + prefixes.py # deterministic synthetic-column / CTE names + preprocess.py # query-level rewrites prior to planning + steps.py # step-builder helpers used by all composers +``` + +A few of these modules grew past the 600-LOC informal cap during +Foundation v0.1 (notably `planner_bridge.py` after S-19/S-23 and +`planner.py` after S-21). They are flagged for refactor proposals in the +S-26 retro; the recommended split is described there. + +### 3.5 The composer's shape + +`Planner.plan(query)` never invents algebra operators; it only composes +them. See the pseudocode in +[`docs/JOIN_ALGEBRA.md §7`](docs/JOIN_ALGEBRA.md). +Practical rules: + +- Each helper returns a `CalculationState` (not SQL, not a plan, not a + tuple of metadata). +- Helpers receive `ctx: PlannerContext` and read the parsed model + through it. Direct imports from `osi.parsing.models` are allowed for + *type annotations only* (the linter contract in §6 enforces the + one-way flow at runtime); helpers must not call top-level parsing + functions or instantiate parsed types on their own. +- Helpers that could fail their preconditions catch the `AlgebraError` + raised by the operator and re-raise as an `OSIError` with an + `error.code` from Appendix C and additional context (dataset, field, + query position). + +--- + +## 4. Layer 3 — Codegen + +**Contract.** Walk a `QueryPlan` and return a SQL string for the +requested dialect. Correctness means "same plan + same dialect ⇒ +byte-identical SQL." + +### 4.1 Responsibilities + +1. Translate each `PlanStep` to SQLGlot AST nodes (`codegen/transpiler.py`). +2. Wire nodes into a CTE chain per the plan's DAG structure. +3. Apply dialect-specific transforms (`codegen/dialect.py`). +4. Apply post-build optimizations — CTE inlining, chaining, folding, + deduplication (`codegen/cte_optimizer.py`). +5. Render via `sqlglot.Expression.sql(dialect=...)`. + +### 4.2 Non-responsibilities + +Codegen never decides *what* to compute. It never reads the model or +the namespace. It never normalizes grain, picks join paths, or classifies +filters. If it is tempted to, the plan is missing information — extend +`PlanStep`. + +### 4.3 Module map + +``` +src/osi/codegen/ + transpiler.py # PlanStep → SQLGlot AST + dialect.py # dialect-specific transforms (ANSI / DuckDB / Snowflake) + cte_optimizer.py # post-build AST transforms + types.py # CTEName + other codegen NewTypes +``` + +--- + +## 5. The closed algebra + +The algebra is the heart of the system. Full specification in +[`specs/JOIN_ALGEBRA.md`](specs/JOIN_ALGEBRA.md); companion document with +machine-checked laws in [`docs/ALGEBRA_LAWS.md`](docs/ALGEBRA_LAWS.md). + +### 5.1 Closure properties + +1. **Total.** Every operator is `(CalculationState, args) → CalculationState`. + There are no nullable returns or out-parameters. +2. **Pure.** No I/O, no globals, no mutation of inputs. All inputs are + frozen; all outputs are new frozen values. +3. **Deterministic.** The same inputs produce the same output, including + column ordering and generated column names (driven by + `planning/prefixes.py`). +4. **Fail-fast.** Preconditions are checked before any transformation. A + violation raises a typed `OSIError` with an error code — never a + silent mis-compute. +5. **Grain-safe by construction.** An operator that would break grain + safety (coarsening violation, fan trap, chasm trap, explosion-unsafe + aggregation) is rejected with a specific `E3xxx`/`E4xxx` code. + +### 5.2 Operations and grain contracts + +Full table in [`specs/JOIN_ALGEBRA.md §3`](specs/JOIN_ALGEBRA.md#3-operators). +Summary: + +| Operator | Grain effect | +|:---|:---| +| `source(dataset)` | init from dataset's primary key | +| `filter(state, pred)` | preserve | +| `enrich(parent, child, keys, join_type)` | preserve parent grain | +| `aggregate(state, grain, aggs)` | coarsen to `grain` | +| `project(state, cols)` | preserve | +| `add_columns(state, defs)` | preserve | +| `merge(left, right, on)` | preserve (grains must match) | +| `filtering_join(state, rhs, keys, mode)` | preserve | +| `broadcast(state, scalar)` | preserve | + +Each rule is enforced at call time in `planning/algebra/operations.py`. +If you're about to write "unless", stop — extend the rule, don't skip +it. + +### 5.3 Why this matters + +The closed algebra is what gives the system these properties for free: + +- **Explainability.** `diagnostics.explain(plan)` walks the plan and + prints the grain at every step, because every step declares its grain. +- **Optimizability.** CTE folding in `codegen/cte_optimizer.py` is safe + because it operates on an AST whose semantics were pinned down before + codegen. +- **Dialect portability.** A new dialect is a new transpiler; the plan + is dialect-free. +- **Test determinism.** Property tests compare states structurally; + golden tests compare plans and SQL byte-for-byte; E2E tests execute + against DuckDB. All three reproduce. + +Breaking the closure — even "just once, for perf" — forfeits all of the +above simultaneously. + +--- + +## 6. Architectural invariants + +Numbered so tests and reviews can cite them. These are enforced across +the codebase; a PR that violates one should not merge. + +### Algebra purity + +1. **Closed state.** The only way to obtain a `CalculationState` is from + `source(...)` or an algebra operator in `planning/algebra/operations.py`. + Never construct one by hand outside the algebra module. +2. **Immutability.** `SemanticModel`, `Namespace`, `RelationshipGraph`, + `PlannerContext`, `CalculationState`, `Column`, `QueryPlan`, and + `PlanStep` are frozen dataclasses. Transformations return new values. +3. **Pure functions.** Algebra operators are free of I/O, randomness, + clocks, and global state. Any exception is a typed `OSIError`. +4. **Determinism.** Same `(model, query, dialect)` ⇒ identical + `QueryPlan` and identical SQL byte-for-byte. Column names, CTE + names, and ordering are driven by `prefixes.py` counters, not by + hashing or insertion order. +5. **Grain tracking.** Every `CalculationState` carries an explicit + `grain: frozenset[Identifier]`. An operator that cannot prove grain + safety raises a typed error; it never "tries its best". + +### Layer discipline + +6. **One-way flow.** Codegen imports from `planning` and `common`, never + from `parsing`. Planning imports from `parsing` and `common`, never + from `codegen`. Parsing imports only from `common`. Enforced by + `import-linter`. +7. **`PlannerContext` is the only model handle in planning.** Sub-modules + receive `ctx: PlannerContext`. Direct imports from + `osi.parsing.models` are allowed for type annotations only; no + helper instantiates parsed types or calls parsing entry points on + its own. +8. **Facades stay consistent.** `planning/__init__.py` re-exports the + public surface. Any addition / rename in a sub-module updates the + facade in the same PR. + +### Correctness over cleverness + +9. **No silent wrong SQL.** Any semantics we cannot compile correctly + raise a typed `OSIError` with a specific code. Returning plausibly + wrong SQL is always worse than raising. +10. **SQL composition via AST only.** Compose, combine, and transform + SQL expressions with SQLGlot AST nodes. Never by string + concatenation, f-strings, or regex. +11. **Identifier safety.** All identifier comparisons go through + `normalize_identifier()` in `osi.common.identifiers`. Raw `==` on + identifier strings is a bug. Lint flags it. +12. **Column prefixes live in one place.** All synthetic column and CTE + names come from `planning/prefixes.py`. Never hardcode a prefix + literal; adding one elsewhere breaks determinism and test stability. + +### Foundation discipline + +13. **No deferred-feature plumbing.** The codebase contains no partial + support for `FIXED` / `INCLUDE` / `EXCLUDE` / `TABLE` grain, filter + context (`reset`, per-metric filter expressions), window functions, + grouping sets, pivot, non-equijoins, ASOF, or semi-additive. All + raise `E1105 RESERVED_FOR_DEFERRED` at parse time. Adding + plumbing for any of these in anticipation of a future feature is + forbidden; file a proposal and wait for ratification. +14. **One planner.** `osi.planning.Planner` is the single planner. No + `SimplePlanner`, no `FastPathPlanner`, no variants. + +### Model extension + +15. **Relationships are declared, not inferred.** Join paths come from + `RelationshipGraph`. Planner code never synthesizes a join that + isn't in the graph. If no path exists, planning fails with `E2004 UNREACHABLE_DATASET`. +16. **Cardinality requires declared keys.** When cardinality cannot be + inferred from PK/UK declarations, parsing raises + `E3003 AMBIGUOUS_CARDINALITY`. The planner never guesses. + +### Invariants enforced in code + +Each numbered invariant above is *either* enforced by a deterministic +check (lint rule, import-linter contract, arch-test, drift test, +property test, mypy rule) *or* documented here as enforced by review +only. A new invariant must come with a deterministic check in the same +PR if mechanically possible; if not, an explicit entry in this table +with rationale. + +A drift test (`tests/unit/test_arch_invariants_drift.py`, added in the +long-term-viability audit Phase C) verifies that every invariant +number below appears in this catalog and that every catalog row cites +a real source file or test. + +| # | Invariant | Enforcement | +|--:|:----------|:------------| +| 1 | Closed state (`CalculationState` only from algebra) | Reviewed; the algebra package is the only constructor site. Tracked for promotion to an import-linter contract once `osi.planning.steps` is split (see INFRA.md I-56). | +| 2 | Immutability | `mypy --strict` + `frozen=True` on every IR dataclass; `tests/properties/test_algebra_purity.py`. | +| 3 | Pure functions | `tests/properties/test_algebra_purity.py` (no side effects), `test_algebra_determinism.py` (no global state). | +| 4 | Determinism | `tests/properties/test_algebra_determinism.py` + golden plan + golden SQL tests per dialect. | +| 5 | Grain tracking | `tests/properties/test_grain_closure.py`, `test_chasm_safety.py`, `test_explosion_safety.py`, `test_enrich_preserves_rows.py`. | +| 6 | One-way layer flow | `[tool.importlinter]` contracts in [`pyproject.toml`](pyproject.toml): six contracts pin the layered architecture — `parsing → planning/codegen` forbidden; `planning → codegen` forbidden; `codegen → parsing` forbidden; **no layer may import `osi.cli`/`osi.__main__`** (CLI is a sink); **`planning`/`codegen` may not import `osi.diagnostics`** (presentation layer); **`codegen` may not import `osi.config`** (FoundationFlags is parse-time only). | +| 7 | `PlannerContext` as the only model handle | Reviewed; tracked for promotion to an import-linter contract once `steps.py` is split (INFRA.md I-56). The audit's Phase C `c2-invariants` drift test verifies this row exists. | +| 8 | Facades stay consistent | `flake8-docstrings` + the audit's Phase C `c4-layer-readme` drift test (layer README ↔ files in folder). | +| 9 | No silent wrong SQL | `tests/properties/test_error_taxonomy.py` (algebra) + `tests/unit/test_every_exception_is_osierror.py` (whole codebase): AST-walks every `raise` and every broad `except` in `src/osi/` and forbids non-OSIError types except documented exemptions. | +| 10 | SQL composition via AST only | Banned f-string-SQL `rg` lint + custom `flake8` rule (INFRA.md §1.2). | +| 11 | Identifier safety | `tests/unit/test_common_identifiers.py`; `osi.common.identifiers.normalize_identifier` is the single gate. | +| 12 | Column prefixes from one place | `tests/unit/test_synthetic_naming_invariants.py`. | +| 13 | No deferred-feature plumbing | Parser rejects with `E_DEFERRED_KEY_REJECTED` / `E1105`; the audit's Phase C `c1-specrefs` drift test pins the deferred-feature gate. | +| 14 | One planner | Reviewed; the `osi.planning` facade re-exports exactly one `Planner` class. | +| 15 | Relationships declared, not inferred | Reviewed; the `RelationshipGraph` is built only from parsed YAML, never synthesised. Property tests (`test_chasm_safety.py`) exercise the rejection of synthesized paths. | +| 16 | Cardinality requires declared keys | Reviewed; raised by `parsing/validation.py`. `tests/properties/test_planner_mn_rejection.py` pins the planner-level rejection. | + +The catalog is intentionally short; entries marked "reviewed" are +candidates for promotion to deterministic enforcement and should be +tracked as INFRA.md §3 roadmap items. + +--- + +## 7. Error discipline + +See [`docs/ERROR_CODES.md`](docs/ERROR_CODES.md) for the full catalog. + +- All errors inherit from `osi.errors.OSIError` and carry a stable code. +- Errors carry enough context — dataset, field, grain, suggestion — to + be actionable without reading the source. +- Tests assert on error codes, not on message text. +- `tests/properties/test_error_taxonomy.py` enforces globally that every + raised exception is an `OSIError` subclass with a known code. + +--- + +## 8. Where to add things + +| You want to … | Put it in … | Because … | +|:---|:---|:---| +| Support a new YAML field in the Foundation | `parsing/models.py` + `parsing/validation.py` | Parsing is the single gate into the model. | +| Support a new metric idiom | `planning/planner.py` (composition) + possibly `planning/algebra/operations.py` | The algebra defines what's computable; compositions define how metrics compose. | +| Add a new filter shape | `planning/classify.py` + possibly `planning/algebra/operations.py` | Filter classification is how the planner routes filters to operators. | +| Support a new SQL dialect | `codegen/dialect.py` + a transpiler variant | Dialects are a render-time concern. | +| Improve SQL shape (fewer CTEs, better inlining) | `codegen/cte_optimizer.py` | Post-build AST transforms on the rendered tree only. | +| Improve explainability | `diagnostics/` | Read-only over model + plan. | +| Implement a feature from `Proposed_OSI_Semantics.md §10` | First: add to the proposal and get sign-off. Then: update the parser, planner, possibly a new algebra operator, tests across all four layers. | A deferred feature is not "opt-in"; it's an additive spec change. | + +If a task pulls you across two layers, that's a signal to rethink the +abstraction, not to make the layers leaky. + +--- + +## 9. Canonical entry points + +| Goal | Entry point | +|:---|:---| +| Parse a model | `osi.parsing.parse_semantic_model(source)` where `source` is a path or YAML string | +| Build a query plan | `osi.planning.plan(query, context)` where `context = PlannerContext(model=model, namespace=namespace, graph=graph)` | +| Render SQL | `osi.codegen.compile_plan(plan, dialect=Dialect.DUCKDB)` | +| CLI (after `pip install -e .`) | `osi describe` · `osi explain` · `osi resolve` · `osi compile` · `osi explain-code` — registered as a console script (see `pyproject.toml [project.scripts]`) | +| CLI (without install) | `python -m osi describe \| explain \| resolve \| compile \| explain-code` | +| Look up an error code | `osi.diagnostics.error_catalog.explain_error(code)` or `osi explain-code ` | +| End-to-end Python example | The `README.md` Quick Start block + the runnable scenarios under `examples/` | +| Algebra deep-dive | [`docs/JOIN_ALGEBRA.md`](docs/JOIN_ALGEBRA.md) | +| Foundation standard | [`../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) | diff --git a/impl/python/CONTRIBUTING.md b/impl/python/CONTRIBUTING.md new file mode 100644 index 0000000..935fece --- /dev/null +++ b/impl/python/CONTRIBUTING.md @@ -0,0 +1,245 @@ +# CONTRIBUTING.md + +Thanks for your interest in the OSI Python reference implementation. +This guide is the short, opinionated contributor handbook. For the +full contract, read [`SPEC.md`](SPEC.md), +[`ARCHITECTURE.md`](ARCHITECTURE.md), and [`INFRA.md`](INFRA.md). + +--- + +## 1. Mindset + +The project has three commitments that every contribution must honor. + +1. **The Foundation stays thin.** If a feature is deferred in + `Proposed_OSI_Semantics.md §10`, it's out of scope. Propose an + expansion of the standard before writing code. +2. **The algebra is load-bearing.** Every compiler transformation + composes operators from + [`docs/JOIN_ALGEBRA.md`](docs/JOIN_ALGEBRA.md). + No bypasses. +3. **Correctness is proved by tests, not wished.** Every feature ships + with unit + property + golden + E2E tests, and the mutation-testing + budget ([`INFRA.md §1.1`](INFRA.md)) is not a guideline — it's a gate. + +--- + +## 2. Setting up + +```bash +cd impl/python +make install-dev # creates .venv, installs deps + pre-commit hooks +source .venv/bin/activate +make check # sanity check; should be green on main +``` + +Always use the project-local venv inside `impl/python/`. + +--- + +## 3. Making changes + +### 3.1 Before coding + +- Identify which layer(s) your change touches (parsing / planning / + codegen / diagnostics). +- Read the relevant `README.md` under `src/osi//`. +- Check [`ARCHITECTURE.md §8`](ARCHITECTURE.md) for where-to-add guidance. +- Check [`INFRA.md §3`](INFRA.md) for an in-progress infra item that + might conflict. + +### 3.2 While coding + +- Keep the file you are editing under 600 LOC. If your change pushes it + over, split first in a separate PR. +- Every public function / class gets a one-sentence docstring. +- Every new `ErrorCode` gets an enum value, a catalog row in + [`docs/ERROR_CODES.md`](docs/ERROR_CODES.md), and at least one unit + test. +- Every algebra-touching change gets a property test update. If no law + is affected, explain in the PR why. + +### 3.3 Before submitting + +```bash +make format # black + isort +make check # lint + mypy + tests +make mutation-fast # algebra mutation; must not regress > 2 pp +``` + +If you changed a golden file, explain the intent in the PR description: +"Plan now emits a `PROJECT` step after the final merge because ; +I've refreshed `tests/golden/basic/single_table_revenue/expected.plan.json` +to match." + +--- + +## 4. Pull request checklist + +Use this as the PR description template: + +``` +## Summary +<1–2 sentences — what does this change and why> + +## Changes +- : + +## Tests +- [ ] Unit tests added / updated +- [ ] Property tests added / updated (if algebra touched) +- [ ] Golden tests refreshed (if plan/SQL shape changed — justify in summary) +- [ ] E2E tests added / updated (if user-visible behavior changed) + +## Quality gates +- [ ] `make check` passes locally +- [ ] `make mutation-fast` — no regression > 2 pp +- [ ] Coverage did not drop below `INFRA.md §1.1` minimums + +## Invariants touched (see ARCHITECTURE.md §6) +- +``` + +--- + +## 5. Review bar + +Reviewers will push back on: + +- Raw-string SQL anywhere in `src/`. +- Bare `except Exception` in `src/` (catch `OSIError` subclasses). +- Adding a second way to do a thing when one already exists. +- Deferred-feature plumbing. +- Tests that pass by skipping or by narrowing the generation strategy. +- Files > 600 LOC. +- New `ErrorCode` without a test. +- `@pytest.skip` without a platform-specific reason. + +--- + +## 6. Reporting issues + +When filing a bug, include: + +- The minimal semantic model YAML. +- The semantic query (Python or JSON). +- The expected SQL / rows. +- The actual SQL / rows or the error (with `error.code`). +- The dialect. + +For algebra bugs specifically: include the minimal `CalculationState` +that reproduces the issue, if you can. Property-test counterexamples +qualify — paste the seed and the shrunk input. + +--- + +## 7. Where conversation happens + +- Spec conversations → GitHub issues tagged `spec`. +- Infra / tooling → GitHub issues tagged `infra`. +- Implementation discussion → PR comments. +- Breaking Foundation scope (adding a deferred feature) → a proposal PR + against `specs/Proposed_OSI_Semantics.md` before the implementation PR. + +--- + +## 8. Proposal ratification lifecycle + +The Foundation is a *subset* of the full OSI standard. New semantic +concerns — filter context, grain modes, window functions, semi-joins, +parameters, etc. — are deferred in `Proposed_OSI_Semantics.md §10` until +they have been **ratified, implemented, and conformance-tested**. + +A proposal moves through five stages. Nothing ships without all five. + +### Stage 1 — Draft (in `proposals/foundation-v0.1/Proposed_OSI_Semantics.md`) + +Open a PR that appends (or amends) a section in +[`../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) +with: + +- A short rationale (what BI idiom does this enable? Which tools + already offer it?). +- The *semantic* contract (operator shape, grain behaviour, null + semantics, error conditions). +- A pointer to at least one real-world source (LookML / DAX / Tableau / + dbt / Malloy snippet). +- The proposal **ID** you intend to register. Use snake_case, e.g. + `filter_context`, `grain_modes`, `semi_join`, `param_named_filter`. + +Stage 1 PRs change docs only. They do **not** touch `src/` or +`tests/golden/`. + +### Stage 2 — Register the ID in `proposals.yaml` + +In the same PR (or an immediate follow-up), add the proposal to +[`../../compliance/foundation-v0.1/proposals.yaml`](../../compliance/foundation-v0.1/proposals.yaml) +with `status: proposed`: + +```yaml +- id: filter_context + status: proposed + description: Per-metric filter context overrides (CALCULATE-like). + spec_refs: + - "proposals/foundation-v0.1/Proposed_OSI_Semantics.md#filter-context" +``` + +The CI check (`harness/proposals_check.py`) now allows test metadata +to cite this ID under `required_features`. + +### Stage 3 — Add conformance tests under the new ID + +Tests that exercise the feature go anywhere in +[`../../compliance/foundation-v0.1/tests/`](../../compliance/foundation-v0.1/tests/) +and **must** include `required_features: []` in their +`metadata.yaml`. This keeps them skipped by every adapter that has not +opted in. + +Every proposal needs at least one positive test (happy path) *and* one +negative test (confirms the rejection error when the proposal is +disabled — typically `E_DEFERRED_KEY_REJECTED`). + +### Stage 4 — Implement behind the proposal flag + +1. Move the spec text from + `../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md` into the + relevant main spec section in `SPEC.md`. +2. Build the feature in `src/osi//`. Delete the rejection that + previously raised `E_DEFERRED_KEY_REJECTED` for the now-allowed + construct. +3. Add golden plan + golden SQL + E2E tests under `tests/`. +4. Update `docs/ERROR_CODES.md` for any new error codes. +5. Flip the `proposals.yaml` entry from `status: proposed` to + `status: foundation` (or a new named slice level once we grow one). + +### Stage 5 — Opt the adapter in + +Finally, add the proposal ID to +[`conformance/enabled_proposals.yaml`](conformance/enabled_proposals.yaml): + +```yaml +enabled: + - filter_context +``` + +and run the full suite via `make conformance` to confirm the new +positive tests pass and the legacy `E1105` negative tests now *fail* +as expected — because they're only valid when the feature is off. Tag +those negative tests with an `excluded_when_feature` marker (or simply +retire them) as part of Stage 4. + +### Required PR checklist for each stage + +| Stage | `specs/` | `proposals.yaml` | Suite tests | `src/` | `enabled_proposals.yaml` | CI gates | +|-------|----------|-------------------|-------------|--------|--------------------------|-----------| +| 1 — Draft | ✅ | — | — | — | — | `make check` docs only | +| 2 — Register | — | ✅ (`proposed`) | — | — | — | `proposals_check.py` | +| 3 — Tests | — | — | ✅ (tagged) | — | — | `make conformance-all` | +| 4 — Implement | ✅ (promote) | ✅ (`foundation`) | ✅ (goldens + E2E) | ✅ | — | `make check` + `make mutation-fast` | +| 5 — Adapter opt-in | — | — | — | — | ✅ | `make conformance` | + +Skipping a stage is grounds for a reviewer to block the PR. The +proposal ID is the thread that stitches all five stages together — it +appears in the spec doc, the registry, every test's +`required_features`, the adapter manifest, and every PR description +that touches the feature. diff --git a/impl/python/INFRA.md b/impl/python/INFRA.md new file mode 100644 index 0000000..892f077 --- /dev/null +++ b/impl/python/INFRA.md @@ -0,0 +1,513 @@ +# INFRA.md — Infrastructure Standards & Roadmap + +Source of truth for infrastructure standards, quality targets, toolchain +decisions, and the infrastructure sprint roadmap for `osi_python`. +Maintained in lockstep with the code. + +**Relationship to `SPEC.md`.** `SPEC.md` defines *what the product does* +(the OSI Foundation). `INFRA.md` defines *how we ensure the product is +built well* (tests, lint, CI, quality gates, tool choices). + +**Relationship to `docs/JOIN_ALGEBRA.md`.** The algebra is the hard +boundary for correctness. This document enforces the quality gates that +keep that boundary intact. + +--- + +## Table of Contents + +1. [Quality standards](#1-quality-standards) +2. [Toolchain](#2-toolchain) +3. [Infrastructure roadmap](#3-infrastructure-roadmap) +4. [Decisions log](#4-decisions-log) + +--- + +## §1 Quality Standards + +Minimum quality bars enforced on every change. Regression below these +thresholds is a failure regardless of feature completeness. + +### §1.1 Test quality + +| Metric | Target | Minimum gate | Scope | Tool | Notes | +|:---|:---:|:---:|:---|:---|:---| +| Unit tests pass | all | all | whole project | `pytest tests/unit/` | Hard gate. | +| Property tests pass | all | all | whole project | `pytest tests/properties/` | Hard gate. `max_examples=500` default. | +| Golden tests pass | all | all | whole project | `pytest tests/golden/` | Regen requires PR justification. | +| E2E tests pass | all | all | whole project | `pytest tests/e2e/` | DuckDB execution. | +| Line coverage | ≥ 95% | ≥ 92% | `src/osi/planning/` | `pytest-cov` | Hard gate (aspirational; see I-57). | +| Line coverage | ≥ 92% | ≥ 84% | `src/osi/` overall | `pytest-cov` | Hard gate. Floor temporarily 84% pending I-57; ratchet back to ≥ 90% then ≥ 92% as planner-branch unit tests land. | +| Branch coverage | ≥ 90% | ≥ 88% | `src/osi/` overall | `pytest-cov` | Hard gate (tracked alongside I-57). | +| **Mutation score — algebra** | **≥ 90%** | **≥ 88%** | `src/osi/planning/algebra/` | `mutmut` | **Load-bearing.** See §1.1.1. | +| Mutation score — classify/joins | ≥ 85% | ≥ 82% | `src/osi/planning/{classify,joins}.py` | `mutmut` | Fan-out / chasm-trap path. | +| Mutation score — codegen | ≥ 75% | ≥ 72% | `src/osi/codegen/` | `mutmut` | Dialect idioms. | +| Mutation score — project | ≥ 75% | ≥ 72% | `src/osi/` overall | `mutmut` | Floor. | +| Max mutation drop per sprint | 0 pp | 2 pp | per module | CI | Drop > 2% fails CI. | +| Max `make test` runtime | ≤ 2 min | ≤ 5 min | whole project | CI | Property + golden + E2E. | + +#### §1.1.1 Why the algebra threshold is highest + +`src/osi/planning/algebra/` is the correctness boundary. A silent bug in +that module produces wrong SQL that no downstream test can reliably +catch, because every downstream test is built on top of the algebra. A +surviving mutation in the algebra module is treated as a P0 and blocks +merge until killed. + +#### §1.1.2 Ratchet policy + +- Baseline for each score captured at each release. +- Sprint-level regressions > 2 percentage points on any listed score + fail CI. +- Every four sprints, reviewers consider raising the baseline by 1–2 pp + per module. + +### §1.2 Code quality + +| Standard | Requirement | Tool | Notes | +|:---|:---:|:---|:---| +| Type errors | 0 | `mypy` (strict) | `disallow_untyped_defs = True`; `disallow_any_generics = True`; `warn_return_any = True`. Hard gate. | +| Tests type errors | warnings OK | `mypy` | `disallow_untyped_defs = False` under `tests/`. | +| Lint errors | 0 | `flake8` | Configured via `.flake8`. | +| Formatting | compliant | `black` (line 88), `isort` (black profile) | Auto-fixed by `make format`. | +| Import direction | `parsing` ← `planning` ← `codegen`; `common` imported by all | `import-linter` | Hard gate. | +| Ban raw-string SQL | enforced | `flake8` custom rule + `rg` check | Scans `src/` for `f".*SELECT\b"` and similar. | +| File size in `src/osi/` | ≤ 600 LOC (700 for the documented exception list) | `make audit-file-size` | See "File-size exception list" below. | +| Docstring on public class/function | required | `flake8-docstrings` | Only public API (`__init__.py`-exported). | +| Pre-commit hook green | required | `pre-commit` (project-local) | Installed via `make install-dev`. | + +#### File-size exception list + +The 600-LOC cap is enforced by `make audit-file-size`. The following +files are temporarily allowed a soft cap of 700 LOC; each must have a +corresponding §3 roadmap item tracking the split that brings it back +under 600. Adding a new entry requires updating both this list and the +`EXCEPTION_FILES` variable in the Makefile. + +| File | Current LOC | Tracked by | +|:---|--:|:---:| +| `src/osi/planning/planner_bridge.py` | 658 | I-54 | +| `src/osi/planning/planner.py` | 605 | I-55 | +| `src/osi/planning/steps.py` | 626 | I-56 | + +### §1.3 SQL correctness + +Load-bearing invariants; override every other consideration in +`src/osi/planning/` and `src/osi/codegen/`. + +- **No f-string SQL, ever.** All SQL manipulation goes through SQLGlot + AST. Banned tokens are caught by a custom flake8 check and a CI grep. +- **Grain is explicit on every `CalculationState`.** Algebra ops + enforce grain-safety before returning. See `docs/JOIN_ALGEBRA.md §4.4`. +- **Identifier normalization** goes through `osi.common.identifiers.normalize_identifier`. + Raw `==` on identifier strings is a bug and is flagged by lint. +- **Unsupported semantics raise `OSIError`.** Never silently emit wrong + SQL. `tests/properties/test_error_taxonomy.py` enforces this globally. + +### §1.4 Release quality + +| Gate | Requirement | +|:---|:---| +| All §1.1 / §1.2 / §1.3 gates | Green. | +| `CHANGELOG.md` | Updated for user-visible changes. | +| `docs/JOIN_ALGEBRA.md` diff | Reviewed by two maintainers if laws or operators change. | +| Mutation score | No per-module regression > 2 pp since previous release. | + +--- + +## §2 Toolchain + +Adopted tools. Do not replace without an infrastructure sprint and a new +§4 decisions log entry explaining the migration rationale. + +| Tool | Purpose | Version policy | Rationale | +|:---|:---|:---|:---| +| Python | Runtime | ≥ 3.11 | Pattern matching, `StrEnum`, richer typing. | +| `pydantic` v2 | Schema validation | pinned in runtime deps | Fast; `extra="forbid"` for unknown-field detection. | +| `sqlglot` | SQL AST, dialect translation | pinned in runtime deps | **The only SQL string-manipulation library allowed.** | +| `pytest` | Test runner | pinned in `[dev]` | De-facto standard; best plugin ecosystem. | +| `hypothesis` | Property-based testing | pinned in `[dev]` | Load-bearing for algebra laws; generates counterexamples with shrinking. | +| `mutmut` | Mutation testing | pinned in `[dev]` | Primary mutation tool. Simple CLI; integrates with pytest. | +| `syrupy` | Snapshot testing | pinned in `[dev]` | Used for golden tests (plans and SQL). | +| `duckdb` | E2E test harness | pinned in `[dev]` | In-memory execution for row-level correctness. | +| `pytest-cov` | Coverage | pinned in `[dev]` | Line + branch coverage. | +| `pytest-benchmark` | Performance tests | pinned in `[dev]` | Detects planner / transpiler regressions. | +| `import-linter` | Architecture enforcement | pinned in `[dev]` | Enforces §1.2 one-way import flow. | +| `mypy` | Type checker | pinned in `[dev]` | Strict config in `pyproject.toml`. | +| `black` | Code formatter | pinned in `[dev]` | Line length 88. | +| `isort` | Import sorter | pinned in `[dev]` | Black-compatible profile. | +| `flake8` | Lint | pinned in `[dev]` | Configured via `.flake8`. | +| `flake8-docstrings` | Docstring lint | pinned in `[dev]` | Public APIs only. | +| `pre-commit` | Local git hooks | pinned in `[dev]` | Project-local config. | + +### §2.1 Why `mutmut` over `cosmic-ray` + +`mutmut` has a simpler CLI, a built-in baseline, and reasonable defaults +for Python 3.11+. `cosmic-ray` is more configurable; if we ever need +targeted operator-mutation sets on a specific module we can run it as a +secondary, but `mutmut` is the default and the CI gate. + +### §2.2 Why `hypothesis` as a first-class dependency + +The algebra is small enough to fully specify and large enough to be +impossible to exhaustively test by example. Property tests with +generation strategies are the only tractable way to check the laws in +`docs/JOIN_ALGEBRA.md §4`. + +--- + +## §3 Infrastructure Roadmap + +Every infrastructure sprint must reference an item here (or add a new +item before starting). This is the PM's source of truth for evaluating +non-SPEC sprints. + +| ID | Description | Status | User value | Sprint ID | +|:--:|:---|:---:|:---|:---| +| I-1 | Per-project venv + `pyproject.toml` + `Makefile` + `.pre-commit-config.yaml` + `.flake8` + `../../.github/workflows/impl-python-ci.yml`. | completed | Contributors run local CI == remote CI. | — | +| I-2 | `import-linter` contracts enforcing one-way flow and no deferred-feature imports. | completed | Architecture invariants are machine-checked; new contributors cannot accidentally violate them. | — | +| I-3 | Hypothesis strategies for `identifiers()`, `schemas()`, `states()`, `operator_chains()`. | completed | Foundation for every property test; without this, §1.1 mutation targets are unachievable. | — | +| I-4 | `mutmut` integrated into CI with per-module thresholds. Algebra-module fast-path runs on every PR; full run nightly. | completed | Enforces §1.1.1; a surviving mutation in the algebra becomes a P0 automatically. | — | +| I-5 | Reference pandas interpreter for equivalence-law testing (`tests/properties/reference.py`). | completed | Equivalence laws (§4.9, §4.10 of `docs/JOIN_ALGEBRA.md`) can compare SQL output to semantic ground truth. | — | +| I-6 | Golden test driver + `make golden-refresh` command. | completed | Plan and SQL diffs in PR review are immediate and human-readable. | — | +| I-7 | DuckDB E2E fixture harness (`tests/e2e/conftest.py` + fixtures). | completed | Row-level correctness on real data, not just shape-of-SQL. | — | +| I-8 | TPC-DS subset harness (queries expressible in the Foundation). | planned | Exercises the combined surface on real analytical idioms. | — | +| I-9 | Cursor skills: `add-new-operator-to-algebra`, `add-new-dialect`, `debug-plan-output`. | completed | Contributor velocity; recipes replace tribal knowledge. | — | +| I-10 | Performance benchmark baselines with `pytest-benchmark` and a per-release report. | planned | Prevents silent regressions; performance work gets visibility. | — | +| I-11 | `import-linter` rule: no module in `src/osi/` may add speculative plumbing for deferred features (see `Proposed_OSI_Semantics.md §10`). | planned | Keeps the Foundation thin; no speculative plumbing leaks. | — | +| I-12 | `SEMANTIC_VIEW(...)` SQL parser (gated on the future SQL_INTERFACE proposal). Wires up `E1201`–`E1213` (all currently `RESERVED`). | planned | Portable, JDBC/ODBC-visible surface so BI tools, editors, and notebooks can author Foundation queries without a new client library. Unlocks interop with Snowflake semantic views. | — | +| I-13 | M:N resolution per `Proposed_OSI_Semantics.md §6.5`: bridge anchor discovery for dim-only queries, multi-fact stitch validation, `E3012_MN_NO_STITCH_PATH` / `E3013_NO_STITCHING_DIMENSION` error reclassification. | completed | Foundation can now plan every M:N shape the spec mandates (single bridge, stitch on shared dim, `EXISTS_IN` filter) and surfaces the spec's actionable errors when no route applies. Per-query M:N failures emit `E3012` / `E3013` (the user-facing per-query codes); `E3011_MN_AGGREGATION_REJECTED` is reserved for engine-level M:N opt-outs (vendor capability, not per-query verdict — see §6.8 *Semantic guarantee*) and is never raised at the user-facing surface by `osi_python`. Eliminates 5 of 14 `xfail` compliance cases. | — | +| I-14 | Per-metric `joins.using_relationships` path disambiguation (`§6.7`). | completed | Authors can disambiguate `E3001_AMBIGUOUS_JOIN_PATH` per metric without restructuring the model — same convention Snowflake Semantic Views uses. | — | +| I-15 | `EXISTS_IN` codegen emits correlated `EXISTS (SELECT 1 ...)` per `§7.4 + §11 #8` (was `IN (SELECT keys)`). | completed | Spec-correct NULL semantics and dialect-portable; previous shape was wrong on both counts and broke on DuckDB tuple-IN. | — | +| I-16 | Algebra honours `unique_keys`. `source` plumbs `dataset.unique_keys` into `CalculationState`; `enrich`'s fan-trap rule accepts join keys that match the PK *or any UK* via `CalculationState.is_unique_on()`. UKs are propagated through every grain-preserving operator and dropped/intersected appropriately by `aggregate`/`merge`. Removed the asymmetry between graph-layer cardinality inference (already UK-aware) and algebra-layer grain reasoning (was PK-only). Also extracted `add_columns` and `broadcast` into `algebra/composition.py` to keep the per-file LOC budget. | completed | A 1:N relationship that was *mismarked* (PK chosen on a different column set) can now be recovered by adding `unique_keys` — exactly as the spec promises in `§4.2`. Acceptance: `tests/e2e/test_cardinality_safety.py::test_recovered_model_matches_canonical_results` flipped from `xfail(strict)` to `passed`. New invariant **I-9** added to `algebra/state.py`. | fa47a74a | +| I-17 | Mid-pipeline bridge resolution (`Proposed_OSI_Semantics.md §6.5.1`, mid-pipeline form). The planner detects unsafe `N : N` enrichment edges, finds a bridge dataset that has safe `N : 1` edges to both sides, pre-aggregates the measure to the bridge's link-key grain, sources the bridge as a fresh root, and re-aggregates at the query grain. New `EnrichDerivedPayload` lets `ENRICH` accept a derived (CTE-backed) child rather than a base table. The algebra's `enrich` now reclassifies AGGREGATE columns coming from a *uniquely-keyed* child as FACT — the existing fan-trap check already guarantees safety in that case, and without the relaxation the bridge plan can't compose. Also lifted §6.5 prose to the BI tag-fan-out semantic (routes are now framed as *implementations*, not the contract) and narrowed the §10 deferred entry to the genuine chained-bridge case (query references both outer endpoints A and B). | completed | Discharges the C-3.5 `xfail` (single-bridge mid-pipeline) and pins the genuine multi-bridge case as C-3.5b `xfail`. The §10 deferred entry now describes only the multi-bridge topology — every well-defined single-bridge query plans, regardless of where the bridge sits in the chain. Without this, models with bridges that aren't directly attached to the fact table couldn't be queried even when the answer is unambiguous. | — | +| I-18 | **S-A**: Spec-doc + roadmap landing. Renames `Proposed_OSI_Semantics_updated.md` → `Proposed_OSI_Semantics.md` and `SQL_EXPRESSION_SUBSET_updated.md` → `SQL_EXPRESSION_SUBSET.md` (deletes the old files), rewrites `SPEC.md` / `INFRA.md` / `AGENTS.md` and the `specs/` README + deferred README to point at the renamed authoritative spec. No `src/` changes. Anchors §10, Appendix B, Appendix C of the new Foundation. | completed | Single source of truth for the new Foundation; without this every later sprint argues over which spec is canonical. Mirrors the cleanliness clause in `SPEC.md` header — old spec is removed, not deprecated. | S-A | +| I-19 | **S-B**: New compliance suite scaffold + delete the old one. Lands `compliance/foundation-v0.1/` (README, SPEC, `pyproject.toml`, `conformance.yaml`, `proposals.yaml`, `decisions.yaml`, `adapters/`, `datasets/f_*`, empty `tests/` tree). Reuses harness from `compliance/harness` via path dep. Deletes `impl/python/tests/compliance/` so we have exactly one compliance harness. No `src/` changes. | completed | Foundation conformance lives in one external suite that targets the updated spec only. Eliminates the two-harness drift that bit `osi_impl`. | S-B | +| I-20 | **S-C**: Compliance suite tests v1 (T-001 … T-033). Encodes `DATA_TESTS.md §4` as runnable cases under `compliance/foundation-v0.1/tests/`; one negative test per `E_DEFERRED_KEY_REJECTED` family. | completed | Every D-NNN gets a runnable witness before any implementation sprint moves the planner. | S-C | +| I-21 | **S-D**: Baseline compliance run + gap report. Runs S-C against current `osi_python`; emits `results/baseline_.md`. No fixes. Every red row must be cited by exactly one sprint's exit criterion. | completed | Without a baseline we can't tell which red rows the implementation sprints actually flipped green. | S-D | +| I-22 | **S-E**: Differential / edge-case audit + extra `T-NNN` cases. Cross-references every sprint S-1 … S-17 against the v1 catalog and the cross-implementation drift checklist (NULL ordering, integer/decimal precision, division-by-zero, empty-aggregate, time-zone/date arithmetic, collation/case, large-N determinism, M:N de-dup, nested-aggregate grain inference, window frame defaults, OSI_SQL_2026 function semantics). Read-only on `src/`. | completed | Pins the implementation-boundary edges where two compliant engines could legitimately disagree if the spec is read loosely. Net effect: +8.3pp compliance (75.0% → 83.3%) with zero `src/` modifications. See `compliance/foundation-v0.1/results/additions.md`. | S-E | +| I-23 | **S-1** (tech-debt): Delete deferred plumbing. Strips every reference to `EXISTS_IN`, `referential_integrity`, named filters, `role:`, per-metric `joins.{type, using_relationships}`, `ATTR`, `UNSAFE`, `AGG`, `GRAIN_AGG` from `src/`, parser models, codegen, diagnostics, tests, fixtures, examples, and docs. Mutation pass on every touched module. | completed | Removes the speculative plumbing the new Foundation defers; without it every later sprint either tiptoes around dead code or accidentally re-uses it. | S-1 | +| I-24 | **S-2**: Two query shapes (Aggregation vs Scalar) with `Fields` clause + `E_MIXED_QUERY_SHAPE` / `E_AGGREGATE_IN_SCALAR_QUERY` / `E_FAN_OUT_IN_SCALAR_QUERY`. | completed | Pins D-010 / D-011 / D-023 in the planner; closes the Snowflake errata #1, #2, #3, #6, #8 family for the OSI surface. | S-2 | +| I-25 | **S-3**: Predicate routing by resolved expression shape (D-005, D-012). Adds `E_AGGREGATE_IN_WHERE`, `E_NON_AGGREGATE_IN_HAVING`, `E_MIXED_PREDICATE_LEVEL`. | completed | Without this, queries silently route a query-grain aggregate into `Where` and produce wrong results — the single most common BI mis-modelling mistake. | S-3 | +| I-26 | **S-4**: Implicit home-grain aggregation for cross-grain field bodies (D-003, D-015). Pins one of correlated-subquery / `LATERAL` / pre-agg-CTE as the compilation strategy; ≥ 3 D-015 equivalence golden tests. | completed (partial) | What lets `customers.lifetime_value = SUM(orders.amount)` work without an explicit `grain:` keyword. Negative-rule plumbing landed; positive rewrite carries to `I-40` (`I-S4-impl`). | S-4 | +| I-27 | **S-5**: Single-step + nested cross-grain aggregates (D-020, D-024); raises `E_UNAGGREGATED_FINER_GRAIN_REFERENCE`. | completed (partial) | Closes Snowflake `SD-1` (single-step cross-grain) for the Foundation; aligns with Looker / Tableau / dbt-semantic-layer. Error code shipped; positive planner carries to `I-41` (`I-S5-impl`). | S-5 | +| I-28 | **S-6** (tech-debt): Restore ≥ 90% mutation on `src/osi/planning/algebra/`; refactor any > 600 LOC files introduced by S-2..S-5. | completed | Keeps INFRA §1.1.1 invariant intact across the high-churn middle of the rollout. | S-6 | +| I-29 | **S-7**: Default join shape rewrite (D-001, D-004). Single-measure ⇒ `LEFT` (fact→dim). Multi-measure incompatible-root ⇒ `FULL OUTER` stitch. Scalar grand totals ⇒ `CROSS JOIN` of pre-aggregated 1-row scalars. | completed | The single biggest change in the new Foundation; replaces the old default-INNER-via-RI behaviour with safety-first defaults. | S-7 | +| I-30 | **S-8**: Bridge de-duplication contract (D-026). Bridge plan materialises distinct `(fact, group-key)`. Removes every "tag-fan-out" code path and comment — no compat wording left behind. | completed (partial) | Pins Semantic 2 across M:N traversals; matches Looker symmetric aggregates and Tableau Multi-Fact relationships on observable rows. Cleanliness gate landed; distinct materialisation carries to `I-42` (`I-S8-impl`). | S-8 | +| I-31 | **S-9**: Bridge-dedup acceptance for every aggregate category + chasm/stitch decomposition safety (D-022, D-027). Bare `AVG` / `MEDIAN` / `COUNT(DISTINCT)` over an N:N bridge resolve via the §6.8.1 single-pass bridge-dedup construction. `E_UNSAFE_REAGGREGATION` is reserved for plans that genuinely force decomposition (§6.7 chasm pre-aggregation, §6.8.2 stitch). The "per-home-row-first" interpretation continues to require the deferred nested form. | completed | Aligns the Foundation with Tableau Multi-fact / Power BI bridge-table / Looker symmetric-aggregate behaviour for M:N non-distributive (the cross-vendor majority); narrows `E_UNSAFE_REAGGREGATION` to its actually-correct shape. | S-9 | +| I-32 | **S-10**: Identifier resolution + error taxonomy alignment (D-006, D-018, D-019, Appendix C). Every internal `OSIError` code maps 1:1 to Appendix C. | completed | Without this, the compliance suite asserts on codes that don't exist or don't mean what the suite says. | S-10 | +| I-33 | **S-11** (tech-debt): Refactor `classify.py` / `joins.py` for legibility; mutation pass; `diagnostics.explain` covers every new error. | completed | The two highest-churn modules in S-2..S-10 — without a follow-up tech-debt sprint they will exceed the 600-LOC cap. | S-11 | +| I-34 | **S-12**: Window functions in Foundation (§6.10): D-028 placement rules, D-030 pre-fan-out materialisation, D-031 windowed-metric-composition rejection, D-032 deferred frame modes. | completed (partial) | Closes the Snowflake errata #5, #19, #25 family on the OSI surface; brings the implementation in line with the new Foundation's standard-SQL-windows scope. Negative rules and rejections shipped; positive planner (pre-fan-out CTE, fan-out detection, codegen) carries to `I-43` (`I-S12-impl`). | S-12 | +| I-35 | **S-13**: NULLS LAST default emission for outer `Order By` + `OVER ORDER BY` (D-029); D-014 per-engine determinism preserved. Compiled SQL contains the explicit `NULLS LAST` clause regardless of dialect default. | completed | Without this two compliant engines emit different SQL whose results differ on NULL ordering — a portability hazard. | S-13 | +| I-36 | **S-14**: Empty / NULL aggregate behaviour (D-033). `COUNT*` ⇒ 0, others ⇒ `NULL`; stitch missing-cells follow standard SQL. | completed | Pins the user-visible result on the most common edge case (a group with no rows on one side of a stitch). | S-14 | +| I-37 | **S-15** (tech-debt): Final `mutmut` sweep across planning + codegen; raise / maintain INFRA §1.1 baselines. | completed (partial) | Last quality gate before S-16 / S-17 ship; ensures the rollout exits at or above the pre-rollout mutation baseline. Property tests and file-size cleanliness landed; full mutmut sweep deferred to a post-Foundation tech-debt sprint per S-15 retro. | S-15 | +| I-38 | **S-16**: `OSI_SQL_2026` default dialect (D-021); per-dialect expression form `{ dialects: [...] }` in parser. | completed (partial) | Aligns the implementation's expression surface with the new normative SQL subset. Dialect enum and renderer landed; parser-level `dialect:` model key + function catalog whitelist + `E_UNKNOWN_FUNCTION` enforcement carries to `I-44` (`I-S16-impl`). | S-16 | +| I-39 | **S-17**: Full compliance run; root-cause every remaining failure (impl bug vs test bug vs spec ambiguity); clear `xfail`s. | completed | Foundation v0.1 compliance landed at **75.0%** (48/64) — every remaining red row triaged into a named impl-deferral, test bug (S-E), or dataset gap (S-E). See `compliance/foundation-v0.1/results/final_2026-05-13.md`. | S-17 | +| I-40 | **`I-S4-impl`** (post-Foundation): Implicit home-grain aggregation rewrite (D-003). Carry-over from S-4 — the planner needs to inject the aggregation when a field expression references columns coarser than its home grain. | completed | One of the two largest remaining red-row clusters in the final compliance run; required for `field_metric_grain` and parts of `cross_grain` to go green. | S-20 | +| I-41 | **`I-S5-impl`** (post-Foundation): Nested cross-grain aggregate planner (D-020). Carry-over from S-5 — single-step rewrite for `AVG(SUM(...))` style nestings + inner-grain inference. | completed | Required for `nested_aggregates/*` and `cross_grain/hard/t-005c` to go green. | S-21 | +| I-42 | **`I-S8-impl`** (post-Foundation): Distinct-bridge materialisation (D-026). Carry-over from S-8 — pre-aggregation step that emits `DISTINCT (fact_key, group_key)` before the bridge join. | completed | Required for `bridge/*` to go green and for `joins_default/hard/t-045`. | S-19 | +| I-43 | **`I-S12-impl`** (post-Foundation): Positive window planner — pre-fan-out CTE materialiser, fan-out detection (`E_WINDOW_OVER_FANOUT_REWRITE`), windowed-metric resolution in measures/fields, codegen pass-through for `OVER (...)`. Carry-over from S-12; negative rules already shipped. | completed | Required for the four `windows/{moderate,hard}` tests currently sitting on the deferred-key code path; ~half of the windows area's remaining gap. | S-22 | +| I-44 | **`I-S16-impl`** (post-Foundation): Parser-level `dialect: OSI_SQL_2026` model-level key (default); per-metric / per-field `{ dialects: [...] }` block; OSI_SQL_2026 function-catalog whitelist; `E_UNKNOWN_FUNCTION` enforcement at parse time. | planned | Closes the D-021 contract end-to-end; today the dialect renders as ANSI but the function catalog is not enforced. | — | +| I-45 | **S-18**: Parse-time D-019 reserved-name guard — rejects user identifiers that collide with `GRAIN`, `FILTER`, `QUERY_FILTER`. New module `osi/parsing/reserved_names.py` + cross-reference check in `validation.py`; per-name-class unit tests. | completed | Without this guard a model defining a field `filter` silently shadows the OSI grammar keyword and two compliant implementations diverge. | S-18 | +| I-46 | **S-19** (closes `I-S8-impl`): Bridge de-duplication contract (D-026 / §6.11.3). Adds an inner `aggregate` step at `(left_keys ∪ final_dim_keys)` between the bridge-enrich and the final aggregate; renames `_DISTRIBUTIVE` → `_BRIDGE_RESOLVABLE`; admits `COUNT(DISTINCT)` per D-022. Tests flipped: `t-015`, `t-045`, `t-021` (converted from negative to positive). | completed | Closes the flagship D-026 example (actor↔movie). Without this every M:N model silently double-counts. | S-19 | +| I-47 | **S-20** (closes `I-S4-impl`): Implicit home-grain aggregation (D-003 / D-015). New module `osi/planning/home_grain.py` rewrites field expressions that aggregate a single foreign dataset reachable via one safe N:1 step into a correlated subquery; codegen's `_qualify_columns` learns a subquery-aware mode keyed on the home dataset's logical name. 13 new unit tests including 5 D-015 equivalence assertions. Tests flipped: `t-004`, `t-024`. | completed | Without this, any field referencing a finer-grained dataset crashes with "table not found"; D-015 is the contract that makes the OSI semantic layer behave like SQL with implicit grain hand-back. | S-20 | +| I-48 | **S-21** (closes `I-S5-impl` for the simple shape): Nested cross-grain aggregate planner (D-020 + D-024). New module `osi/planning/planner_nested.py` with `is_nested_aggregate`, `parse_nested`, `infer_intermediate_grain`, `insert_nested_aggregate`. Routed from `_build_measure_group` in `planner.py` via `_maybe_build_nested_aggregate`. Single fact + single safe N:1 edge envelope. 15 new unit tests. Tests flipped: `t-005c`. `t-017` (nested-over-bridge) requires bridge × nested integration; carried to S-23 triage. | completed | Without this, every `AVG(AVG(…))` / `SUM(MAX(…))` metric emits invalid nested-aggregate SQL or silently collapses to a single-step aggregate. D-020 is the contract that makes the per-row-first interpretation explicit. | S-21 | +| I-49 | **S-22** (closes `I-S12-impl`): Positive window planner (D-028 + D-030). Removed `exp.Window` from `_DEFERRED_AST_NODES`; resolver admits `.` qualification; scalar planner accepts windowed metrics in `Fields`, materialises them via an `ADD_COLUMNS` step with kind `DIMENSION`, and partitions row-level WHERE into pre-window vs post-window batches (D-030 QUALIFY pattern). 11 new unit tests; 2 legacy "reject window" tests rewritten as positive-contract tests. Tests flipped: `t-027`, `t-031`, `t-032`, `t-036`. | completed | Without a positive window planner the Foundation cannot answer the most common BI question: *"give me the row-level rank / running total / N-th row per group"* — every windowed metric was a parse-time crash. | S-22 | +| I-50 | **S-23** (closes the `I-S5-impl × I-S8-impl` composition): Nested-aggregate-over-bridge planner. New `build_nested_bridge_plan` in `planner_bridge.py` composes the bridge resolver with the nested aggregate planner: source bridge → enrich fact + dim datasets → inner aggregate at `(intermediate_dataset.pk ∪ query_dim_keys)` with the inner fn → outer aggregate at the query grain. Wired through a new `nested_only` precheck in `planner._try_resolve_via_bridge`. Tests flipped: `t-017`. **Drives compliance from 98.5% → 100.0%, no skips.** | completed | Closes the spec's hardest single composition (D-020 × D-022 × D-026). Without this, every M:N model with a per-row-first metric (the natural BI shape for "average per actor of movie revenue") was a parse-time error. | S-23 | +| I-51 | **S-24**: Test review across `tests/`. Tightened 5 broad-catch `pytest.raises(Exception)` to specific exception classes; added 3 new property tests for the positive window planner (round-trip, detector agreement, arithmetic-around-window). Emitted `audit.md` with full per-module coverage matrix and false-positive triage. | completed | Tests that pass when the feature is broken are worse than no test. The sweep found 5 weakly-typed catches that had survived since S-1; tightening them turns "any exception passes" into "the right exception passes". | S-24 | +| I-52 | **S-25 (partial)**: Mutmut 3.x configuration migrated from the obsolete 2.x keys; five fork-safety / pytest / coverage / fixture-copy fixes documented in `pyproject.toml`. Baseline numbers blocked on a macOS-specific fork segfault in mutmut 3.5; resolution path is to run `make mutation` on the existing Linux CI worker and populate `MUTATION_BASELINE.md §1`. | completed (partial) | Without working mutmut config, the load-bearing algebra module would silently lose its mutation-score floor; the §1.1 ratchet keeps every change-set honest about test quality, not just test count. | S-25 | +| I-53 | **S-26**: Maintainability deep review. Shipped `python -m osi explain-code ` (carry-over from S-11 retro) with name/value lookup, `--list`, `--json`, exhaustiveness test, and 7 new unit tests in `tests/unit/test_cli.py`. Refreshed `ARCHITECTURE.md` §2.3 (parsing exports — `OSI_RESERVED_NAMES`), §3.4 (planning module map covering `planner_scalar.py`, `planner_bridge.py`, `planner_nested.py`, `planner_composites.py`, `planner_mn.py`, `home_grain.py`, `windows.py`, `preprocess.py`, `steps.py`), and §9 canonical entry points (diagnostics CLI + `explain_error`). 600-LOC cap audit performed; carried as I-54 / I-55. | completed | The `explain-code` CLI takes the diagnostics catalogue from a Python-only surface to something CI logs and shell sessions can hit directly — the most user-visible maintainability win of the whole loop. The ARCHITECTURE refresh closes the documentation lag from S-19..S-23. | S-26 | +| I-54 | **Carried from S-26**: Refactor `planner_bridge.py` (currently 656 LOC, over the 600-LOC informal cap). Recommended split into `planning/bridge/{resolve,dedup,nested}.py` corresponding to the three responsibilities that grew during S-19, S-22, and S-23. Pure refactor — no behaviour change, existing compliance and unit suites must pass unchanged. Deferred to post-v0.1 to avoid regression risk on the eve of release. | planned | Restores the 600-LOC cap that the project has held since the start; keeps the bridge resolver readable as new dialects / shapes are added in v0.2+. | — | +| I-55 | **Carried from S-26**: Refactor `planner.py` (currently 605 LOC, over the 600-LOC informal cap). Recommended split into `planner.py` (composer proper — `Planner.plan` + `_build_*` helpers per ARCHITECTURE §3.5) and `planner_dispatch.py` (nested / bridge / composite routing). Pure refactor. Deferred to post-v0.1 alongside I-54. | planned | Same rationale as I-54: protect the 600-LOC cap and keep the composer's "shape" (which the architecture doc points new contributors at) free of routing noise. | — | +| I-56 | Refactor `src/osi/planning/steps.py` (currently 626 LOC). Recommended split: keep `steps.py` as the public step-factory facade and move per-step builders (source, enrich, filter, aggregate, project, add_columns) into a `steps/` subpackage. Pure refactor — no behaviour change; existing planner / compliance tests must pass unchanged. | planned | Same rationale as I-54 / I-55: protect the 600-LOC cap and keep the step-construction surface scannable as new operators land. Surfaced during the Phase 5 reference-implementation polish review. | — | +| I-57 | Lift the repository-wide coverage floor from 84% back to ≥ 90%. Branches under-covered by unit tests today (compliance-suite-only): `planner_scalar.py` (15%), `planner_bridge.py` (37%), `planner_nested.py` (44%), `home_grain.py` (76%), `joins.py` / `planner_mn.py` / `planner.py` (≈ 80%). Each module needs its own happy-path + error-path unit tests so a planner regression fails at the unit level instead of through the slower compliance run. | planned | Today a planner-internal regression only surfaces through the multi-minute compliance suite. Lifting the floor — and ratcheting the floor up as tests land — guarantees regressions fail fast and gives mutation testing real material to chew on in the planner branches. | — | +| I-58 | **Carried from S-26**: Drop the `(future)` hedge from the `osi.diagnostics.error_catalog` module docstring now that `osi explain-code` ships in v0.1. Trivial, batched into the next docs-touching sprint. | planned | Keeps the catalogue's self-description honest; future readers shouldn't think the CLI surface is still aspirational. | — | +| I-59 | **Spec amendment 2026-05-13**: D-029 `ORDER BY` NULL-placement default flipped from "always `NULLS LAST` regardless of direction" to the **SQL:2003 high-end-NULL convention** — `ASC ⇒ NULLS LAST`, `DESC ⇒ NULLS FIRST`. Restores the symmetry property that flipping `ASC ↔ DESC` flips NULL placement (so a "top-N → bottom-N" UI flip moves the NULL rows as expected). Also collapses Snowflake from a divergence target to "matches the OSI default out-of-the-box", leaving Spark/Databricks as the lone outlier. Touched: `Proposed_OSI_Semantics.md` (§5.1, §6.10.2, §11, Appendix B D-029), `SPEC.md` §1.3 + S-13 sprint row, `Proposed_OSI_Semantics.md` §12.A Snowflake intentional-divergence summary (rewritten), `src/osi/codegen/transpiler.py` (`nulls_first=o.descending`), gold SQL for `t-027` / `t-032` / `t-036` flipped to `DESC NULLS FIRST`, golden snapshot for `test_sql__order_by_and_limit` regenerated, **new compliance test `t-062-nulls-first-default-on-desc`** locks in the symmetric DESC half (paired with t-026). 100% compliance preserved (67/67). Codegen note: sqlglot's per-dialect elision means the explicit `NULLS …` token is omitted on dialects whose native default already matches the resolved OSI default (e.g. Snowflake `DESC` alone is `NULLS FIRST` natively); D-029's wording was relaxed to allow this since elision and explicit emission produce identical row orders on that dialect. | completed | Fixes a real spec defect found during the v0.1 quality loop: the original "always `NULLS LAST`" rule guaranteed determinism but broke the symmetry property every BI mental model depends on (flip a sort, NULLs should move). The new convention preserves both. Also reduces porting friction against the most-deployed warehouse (Snowflake matches out-of-the-box). | — | + +**Status values.** `planned` · `in-progress` · `completed` · `deferred` + +--- + +## §4 Decisions Log + +Settled infrastructure decisions. Agents must not relitigate these +without a new log entry and human review. + +### [I-DEC-1] Start from the Foundation; defer everything else — 2026-04-25 + +**Context.** `osi_impl` implemented the full OSI spec feature-by-feature. +The result is a working compiler but a large code surface that mixes +stable features (joins, basic aggregation) with experimental ones +(resettable filters, FIXED/INCLUDE/EXCLUDE). Every new contributor has to +learn which is which. + +**Decision.** `osi_python` starts from `../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md` +(the Foundation) and treats every other existing OSI feature as deferred. +Deferred features raise `E1105 RESERVED_FOR_DEFERRED` at parse time; the +codebase contains no plumbing for them. + +**Rationale.** A smaller surface is easier to prove correct, faster to +iterate on, and creates clearer extension points when deferred features +are re-introduced. + +**Consequences.** Models that work in `osi_impl` may fail to parse in +`osi_python` if they use deferred features. This is intentional and +covered by `Proposed_OSI_Semantics.md §10`. + +**Alternatives rejected.** Feature-parity with `osi_impl` — rejected +because the combined surface is what we're trying to simplify. + +### [I-DEC-2] Mutation testing from day one — 2026-04-25 + +**Context.** `osi_impl` treats mutation testing as a "planned I-9" item +— the scaffolding never lands because there's always a feature to ship. +The symptoms (silently surviving-mutation code paths) are invisible +until something breaks production. + +**Decision.** Mutation testing (`mutmut`) is in from sprint 0. The fast +path runs on `src/osi/planning/algebra/` on every PR; the full run is +nightly. Per-module thresholds are in §1.1 and are ratcheted; sprints +that lower a threshold fail CI. + +**Rationale.** The algebra IS the correctness boundary. Adding mutation +testing after the fact means every sprint until then could have +introduced a silent bug. Starting with it builds a culture where +"coverage ≠ correctness" is internalized. + +**Consequences.** Sprint 0 spends ~2 days on mutation harness and +baseline. That cost is recovered within 3 sprints by the bugs mutation +testing catches that would otherwise ship. + +**Alternatives rejected.** Defer mutation testing to "when the algebra +is stable" — rejected because the algebra is supposed to be stable +*because of* mutation testing, not before it. + +### [I-DEC-3] Property-based testing as primary correctness tool — 2026-04-25 + +**Context.** The algebra has twelve universal laws (`docs/JOIN_ALGEBRA.md §4`). +Each is a statement of the form "for all legal states and all legal op +arguments, X holds." Example-based unit tests can check hundreds of +cases; property tests with Hypothesis can check thousands with +strategically-generated counterexamples. + +**Decision.** Every law in `docs/JOIN_ALGEBRA.md §4` has a corresponding +Hypothesis property test under `tests/properties/`. Property tests are +equal-priority with unit tests (not optional). Failure of a property +test blocks merge. + +**Rationale.** Laws are the easiest form of correctness to state +formally; Hypothesis is the easiest way to check them at scale in +Python; the combination is as close as Python gets to a machine-checked +proof. The generation strategies become a piece of documentation in +their own right. + +**Consequences.** Contributors must learn Hypothesis strategy composition. +The payback is a test suite that catches regressions example-based tests +miss. + +**Alternatives rejected.** Paper proofs of the laws — rejected because +they go stale the first time the algebra evolves, and nobody notices. + +### [I-DEC-4] One planner, one algebra, one plan type — 2026-04-25 + +**Context.** `osi_impl` had three planners (`SimplePlanner`, +`MultiDatasetPlanner`, `LODPlanner`) that drifted. I-DEC-3 in `osi_impl` +deleted the first two but only after they had accumulated hundreds of +LOC and test files. + +**Decision.** `osi_python` has exactly one `Planner` class. Exactly one +`SemanticQuery` input type. Exactly one `QueryPlan` output type. No +fast-paths, no variants, no "simple" version. + +**Rationale.** Two ways to plan the same query is a bug magnet. The +marginal performance of a single-table fast path is dwarfed by the +maintenance cost. + +**Consequences.** Any specialization (single-table, multi-fact, +semi-join-only) lives as a branch *inside* `Planner.plan()`, not as a +separate class. If internal complexity grows, we split by helper +function, not by public class. + +**Alternatives rejected.** Keeping a `SimplePlanner` for single-table +queries — rejected for the same reason `osi_impl` deleted theirs. + +### [I-DEC-5] SQLGlot is the only SQL-manipulation tool — 2026-04-25 + +**Context.** String concatenation for SQL is always a local-looking +optimization that grows dialect-portability bugs. + +**Decision.** All SQL is built via `sqlglot.exp.*` AST nodes. A CI +check greps the source for `f".*SELECT\b"`, `f".*FROM\b"`, etc., and +fails on a match outside allow-listed test-fixture files. + +**Rationale.** SQLGlot handles quoting, escaping, operator precedence, +and dialect translation correctly. A single abstraction collapses a +large space of possible bugs. + +**Consequences.** Contributors touching codegen need working knowledge +of SQLGlot AST. The small learning curve is offset by uniform handling +of dialect edge cases. + +**Alternatives rejected.** Hand-rolled SQL emitter — rejected because +it would require re-implementing most of SQLGlot. Jinja-templated SQL — +rejected because templating combines the bugs of string concatenation +with the opacity of macro expansion. + +### [I-DEC-6] Hard cap on source file size — 2026-04-25 + +**Context.** `osi_impl`'s `planner_lod.py` grew to 4121 LOC and became +un-reviewable. I-8 in `osi_impl` is "physical split of planner_lod.py" +and is still planned. + +**Decision.** No file in `src/osi/` exceeds 600 LOC. A CI check audits +file sizes and fails when the cap is crossed. A PR that justifiably +needs a larger module must split it first, in a separate PR that lands +before the feature. + +**Rationale.** Reviewability is the second design priority (SPEC §1.1). +A 4000-LOC file cannot be reviewed; the reviewer skims and ships. + +**Consequences.** Modules are split earlier than feels necessary. That +is the point: the cost of splitting at 400 LOC is low; the cost of +splitting at 4000 LOC is enormous. + +**Alternatives rejected.** "Guideline" soft cap — rejected because +soft caps fail silently every time. + +### [I-DEC-7] Strict mypy and `extra="forbid"` pydantic from day one — 2026-04-25 + +**Context.** `osi_impl` reached "zero mypy errors" as I-3, after the +code was already written. The retrofit took effort and left legacy +`# type: ignore` comments. + +**Decision.** `osi_python` starts with `strict = True` mypy and +`extra = "forbid"` on every pydantic model. A `# type: ignore` without +an accompanying `# type: ignore[] # reason: ` is +a lint error. + +**Rationale.** Types catch a class of bug early. Retrofitting strictness +is painful because it means revisiting every call site; starting strict +keeps the cost incremental. + +**Consequences.** Onboarding contributors to mypy strict mode takes +~half a day per person. Pair with PR review. + +**Alternatives rejected.** "Gradual typing" — rejected because the +gradient never lands; it leaves permanent legacy. + +### [I-DEC-8] AGGREGATE-from-child via `enrich` is allowed when the child is uniquely keyed — 2026-04-28 + +**Context.** Bridge resolution mid-pipeline (`§6.5.1`, I-17) requires +joining a pre-aggregated fact-side state into a bridge state at a +finer grain. The natural shape is `enrich(parent=bridge, child=preagg, +child_keys=link_keys)`. The original algebra refused unconditionally +to surface AGGREGATE columns through `enrich`, on the (correct) +ground that doing so over a *fan-out* join would silently invalidate +the aggregate. + +**Decision.** Relax `enrich` to accept AGGREGATE child columns iff +the existing fan-trap check passes — i.e., the child is unique on +its `child_keys` (its grain or one of its UKs is a subset). When +allowed, the column is reclassified to `FACT` with empty +dependencies, so downstream operators see it as a row-value. The +unsafe case is unchanged: a fan-out join still fails with +`E3011_MN_AGGREGATION_REJECTED` *before* we examine the column kinds. + +**Rationale.** The relaxation's precondition is exactly the +algebra's existing safety invariant. There is no mathematical risk: +a fan-trap-safe enrich produces at most one matching child row per +parent row, so the aggregate value is preserved verbatim. Refusing +this case is over-strict — it blocks the entire mid-pipeline bridge +shape that the spec mandates. + +**Consequences.** Bridge resolution composes cleanly. The algebra's +column-kind invariant gains a new edge case (AGGREGATE-in-child +becomes FACT-in-result), recorded in `algebra/operations.py::enrich` +with a comment pointing to this entry. The fan-trap test in +`tests/unit/planning/algebra/test_operators.py` was reframed from +"unconditional rejection" to "fan-out rejection + safe-relaxation +acceptance" with paired tests for both branches. + +**Alternatives rejected.** A new operator (`seal_aggregates` or +similar) for the AGGREGATE→FACT reclassification — rejected because +it would force every bridge plan to emit an extra step for a +relabeling that's already implicit in the safety check. Keeping the +algebra closed under nine operators is preferable. + +### [I-DEC-9] `ORDER BY` NULL placement uses the SQL:2003 high-end-NULL convention — 2026-05-13 + +**Context.** D-029's original wording (S-13) defaulted `ORDER BY ` +without an explicit `NULLS …` clause to **`NULLS LAST` regardless of +sort direction**. This satisfied D-014 byte-identical determinism but +broke the **symmetry property** that flipping `ASC ↔ DESC` flips NULL +placement. A user inspecting a "top-10 by revenue" report and flipping +to "bottom-10 by revenue" would expect the NULL-revenue rows to move +into view (they *are* the worst values by any reasonable interpretation +of "missing revenue"); under the original rule the NULLs never moved. + +**Decision.** Adopt the **SQL:2003 high-end-NULL convention** as the +Foundation default: `ASC ⇒ NULLS LAST`, `DESC ⇒ NULLS FIRST`. NULL is +treated as a high-end value that lands at whichever end the maximum +lands at. Codegen guarantees the resolved row order on every supported +dialect by emitting the explicit `NULLS …` clause whenever the dialect's +native default would produce a different order. When the resolved clause +already matches the dialect's native default (e.g. `DESC NULLS FIRST` on +Snowflake, `ASC NULLS LAST` on DuckDB), the explicit clause MAY be +elided — both forms produce identical row orders, so D-014's +per-`(model, query, dialect)` byte-identical guarantee is preserved. + +**Rationale.** Three reasons in priority order: + +1. **Symmetry under direction flip.** The single behaviour every BI + mental model assumes. Pinning NULLs to a fixed end (always LAST or + always FIRST) loses this and forces every author to write the + explicit clause to recover the obvious behaviour. +2. **Standards alignment.** SQL:2003 says NULLs compare-greater than + non-NULLs by default. The new convention follows this and matches + the out-of-the-box defaults of Snowflake, PostgreSQL, and Oracle. + Spark/Databricks (low-end NULL) becomes the lone divergence target, + reducing the SD-2 surface from "two engines disagree with us" to + "one engine disagrees with us." +3. **Lower compiled-SQL noise on the most-deployed warehouse.** The + OSI compiler may now elide the `NULLS …` clause entirely when + compiling for Snowflake (because the dialect default already + produces the resolved order). This shrinks the diff between + user-written SQL and OSI-compiled SQL on the warehouse where most + models actually run. + +**Consequences.** D-029 is amended (the wording in +`Proposed_OSI_Semantics.md` §5.1, §6.10.2, §11, and Appendix B reflects +the new rule). `SPEC.md` §2.2 (NULL ordering in the query model) likewise. +`SNOWFLAKE_DIVERGENCES.md` SD-2 is rewritten — Snowflake is no longer +divergent on this rule; Spark/Databricks is. `src/osi/codegen/transpiler.py` +flips `nulls_first=False` to `nulls_first=o.descending`. Three +compliance gold SQL files (`t-027`, `t-032`, `t-036`) flipped from +`DESC NULLS LAST` to `DESC NULLS FIRST`. The compliance suite gains a +new test `t-062-nulls-first-default-on-desc` that locks in the +symmetric DESC counterpart of `t-026`. Golden snapshot for +`test_sql__order_by_and_limit` regenerated to reflect dialect-aware +elision (`DESC NULLS FIRST` on ANSI/DuckDB, `DESC` alone on Snowflake). +**100% compliance preserved** (67/67). + +A known limitation: sqlglot's parser conflates `OVER (ORDER BY x DESC)` +and `OVER (ORDER BY x DESC NULLS LAST)` into the same AST shape +(`nulls_first=False`), so a user who writes the explicit `NULLS LAST` +inside a window function cannot have it preserved through round-trip. +This is a tooling limitation, not a spec ambiguity; documented in the +S-D-029-amendment retro and in the SD-2 caveat. + +**Alternatives rejected.** + +- *Spark/Databricks convention* (`ASC NULLS FIRST` / `DESC NULLS LAST`, + low-end NULL) — would also restore the symmetry property, but + requires more compiled-SQL noise (Snowflake/PostgreSQL/Oracle would + all need the explicit clause emitted) and disagrees with the SQL:2003 + default. Picked the high-end convention because it minimises both + engine-specific noise and standards friction. +- *"Always NULLS FIRST" or "always NULLS LAST"* — the original rule. + Fails the symmetry property. The argument that "every BI surface puts + NULLs last" doesn't hold up: those surfaces are presentation layers + with well-known UX complaints for exactly this reason. +- *Reject models that omit the explicit clause* — would force every + user to write the `NULLS …` token everywhere, which is hostile and + doesn't add safety beyond what dialect-aware emission already gives. diff --git a/impl/python/Makefile b/impl/python/Makefile new file mode 100644 index 0000000..5910b55 --- /dev/null +++ b/impl/python/Makefile @@ -0,0 +1,190 @@ +.PHONY: help install install-dev precommit-install \ + test test-unit test-property test-golden test-e2e test-adapter \ + golden-refresh bench \ + lint typecheck architecture format check audit-file-size \ + mutation mutation-fast \ + conformance conformance-all \ + clean + +PYTHON ?= python +PIP ?= $(PYTHON) -m pip + +help: + @echo "OSI Python reference implementation — development commands" + @echo "" + @echo "Setup" + @echo " make install-dev Install runtime + dev deps; install pre-commit hook" + @echo " make precommit-install (Re)install the project-local pre-commit hook" + @echo "" + @echo "Tests" + @echo " make test Run all tests (unit + property + golden + e2e)" + @echo " make test-unit Unit tests only" + @echo " make test-property Hypothesis property tests only" + @echo " make test-golden Snapshot (plan + SQL) tests only" + @echo " make test-e2e DuckDB-executed tests only" + @echo " make test-adapter Conformance adapter smoke tests only" + @echo " make golden-refresh Refresh golden snapshots (EXPLICIT ACTION)" + @echo " make bench Run benchmark tests" + @echo "" + @echo "Static analysis" + @echo " make lint black --check + isort --check + flake8" + @echo " make typecheck mypy strict" + @echo " make architecture import-linter (one-way flow enforcement)" + @echo " make format Auto-format with black + isort" + @echo "" + @echo "Mutation testing (INFRA.md §1.1)" + @echo " make mutation-fast Mutation on src/osi/planning/algebra/ (~5 min)" + @echo " make mutation Full mutation run (~30 min)" + @echo "" + @echo "Conformance (compliance/foundation-v0.1)" + @echo " make conformance Run the Foundation suite filtered to enabled_proposals.yaml" + @echo " make conformance-all Run the full Foundation suite (no proposal filter)" + @echo "" + @echo "CI equivalent" + @echo " make check lint + typecheck + architecture + test" + +# --------------------------------------------------------------------------- +# Setup +# --------------------------------------------------------------------------- + +install: + $(PIP) install -e . + +install-dev: + $(PIP) install -e ".[dev]" + $(PYTHON) -m pre_commit install --config .pre-commit-config.yaml + +precommit-install: + $(PYTHON) -m pre_commit install --config .pre-commit-config.yaml + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +test: + $(PYTHON) -m pytest tests/ conformance/tests/ + +test-unit: + $(PYTHON) -m pytest tests/unit/ + +test-property: + $(PYTHON) -m pytest tests/properties/ + +test-golden: + $(PYTHON) -m pytest tests/golden/ + +test-e2e: + $(PYTHON) -m pytest tests/e2e/ + +test-adapter: + $(PYTHON) -m pytest conformance/tests/ + +golden-refresh: + @echo ">>> Refreshing golden snapshots. Commit the result ONLY if the changes are intentional." + $(PYTHON) -m pytest tests/golden/ --snapshot-update + +bench: + $(PYTHON) -m pytest tests/ -m benchmark --benchmark-enable --benchmark-only + +# --------------------------------------------------------------------------- +# Static analysis +# --------------------------------------------------------------------------- + +lint: + $(PYTHON) -m black --check src tests conformance + $(PYTHON) -m isort --check-only src tests conformance + $(PYTHON) -m flake8 src tests conformance + +typecheck: + $(PYTHON) -m mypy src + +architecture: + $(PYTHON) -m pip show import-linter >/dev/null 2>&1 || { echo "import-linter not installed"; exit 1; } + lint-imports --config pyproject.toml + +format: + $(PYTHON) -m black src tests conformance + $(PYTHON) -m isort src tests conformance + +# Enforces the 600-LOC cap on files under src/osi/ (INFRA.md §1.2 / +# [I-DEC-6]). Pre-commit already does this per-file; this target audits +# the whole tree. +# +# EXCEPTION_FILES below is the documented temporary exception list — each +# file here must have a corresponding INFRA.md §3 roadmap item tracking +# the split that brings it back under the cap. Adding a new entry +# requires updating both INFRA.md §1.2 and INFRA.md §3. +EXCEPTION_FILES := \ + src/osi/planning/planner_bridge.py \ + src/osi/planning/planner.py \ + src/osi/planning/steps.py +EXCEPTION_CAP := 700 + +audit-file-size: + @fail=0; \ + for f in $$(find src/osi -name '*.py' -type f); do \ + lines=$$(wc -l < "$$f"); \ + cap=600; \ + for e in $(EXCEPTION_FILES); do \ + if [ "$$f" = "$$e" ]; then cap=$(EXCEPTION_CAP); break; fi; \ + done; \ + if [ "$$lines" -gt "$$cap" ]; then \ + echo "ERROR: $$f has $$lines lines, cap is $$cap."; fail=1; \ + fi; \ + done; \ + if [ $$fail -eq 0 ]; then echo "OK: all files within cap (default 600, exception 700)."; fi; \ + exit $$fail + +# --------------------------------------------------------------------------- +# Mutation testing +# --------------------------------------------------------------------------- + +mutation-fast: + $(PYTHON) -m mutmut run --paths-to-mutate src/osi/planning/algebra/ + +mutation: + $(PYTHON) -m mutmut run + +# --------------------------------------------------------------------------- +# Conformance — run the OSI Foundation compliance suite against this impl +# --------------------------------------------------------------------------- + +SUITE_ROOT ?= $(abspath ../../compliance/foundation-v0.1) +ADAPTER := $(abspath conformance/adapter.py) +ENABLED := conformance/enabled_proposals.yaml + +conformance: + @test -d "$(SUITE_ROOT)" || { echo "compliance/foundation-v0.1 not found at $(SUITE_ROOT)"; exit 1; } + @proposals=$$($(PYTHON) -c "import yaml,sys; d=yaml.safe_load(open('$(ENABLED)')) or {}; print(' '.join(d.get('enabled') or []))"); \ + echo ">>> Running conformance with --proposals [$$proposals]"; \ + cd "$(SUITE_ROOT)" && $(PYTHON) -m harness.runner \ + --adapter "$(ADAPTER)" \ + --tests tests \ + --datasets datasets \ + --output results/osi_python \ + $$( [ -n "$$proposals" ] && echo "--proposals $$proposals" || echo "--proposals" ) + +conformance-all: + @test -d "$(SUITE_ROOT)" || { echo "compliance/foundation-v0.1 not found at $(SUITE_ROOT)"; exit 1; } + cd "$(SUITE_ROOT)" && $(PYTHON) -m harness.runner \ + --adapter "$(ADAPTER)" \ + --tests tests \ + --datasets datasets \ + --output results/osi_python_all + +# --------------------------------------------------------------------------- +# CI gate +# --------------------------------------------------------------------------- + +check: lint typecheck architecture audit-file-size test + +# --------------------------------------------------------------------------- +# Clean +# --------------------------------------------------------------------------- + +clean: + rm -rf build/ dist/ .pytest_cache/ .mypy_cache/ \ + .coverage coverage.json htmlcov/ \ + .benchmarks/ .hypothesis/ .mutmut-cache/ + find . -type d -name __pycache__ -prune -exec rm -rf {} + + find . -type d -name '*.egg-info' -prune -exec rm -rf {} + diff --git a/impl/python/README.md b/impl/python/README.md new file mode 100644 index 0000000..0f1cda1 --- /dev/null +++ b/impl/python/README.md @@ -0,0 +1,307 @@ +# OSI — Python Reference Implementation + +The reference implementation of the [Open Semantic Interchange](https://opensemanticinterchange.com) +**Foundation** proposal (`osi_version: "0.1"`). It implements a +deliberately narrow first-cut of OSI semantics — small enough to be +provably correct, with the goal of building consensus on fundamentals +before layering richer features back on top. + +> **One-line summary.** Parse a YAML semantic model, plan a semantic +> query via a closed algebra over immutable states, render +> dialect-specific SQL via SQLGlot — with mutation-tested +> property-based tests of the algebra laws as the correctness boundary. + +The authoritative spec the implementation conforms to lives in +[`../../proposals/foundation-v0.1/`](../../proposals/foundation-v0.1/). +The runnable compliance suite lives in +[`../../compliance/foundation-v0.1/`](../../compliance/foundation-v0.1/). + +--- + +## Scope + +- **In scope:** core semantics (datasets, relationships, fields, + metrics, parameters), two query shapes (`Aggregation` + `Scalar` + with `Fields`), joins including chasm-trap and fan-out safety, M:N + resolution via bridge or shared-dim stitch, a SQL subset + (`OSI_SQL_2026` default dialect), and standard SQL window functions. + See + [`../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) + (`osi_version: "0.1"`). +- **Out of scope (deferred):** LOD grain modes, filter context / reset, + semi-join filter form (`EXISTS_IN`), per-metric + `joins.{type, using_relationships}`, `referential_integrity`, + grouping sets, pivot, non-equijoins, ASOF, semi-additive measures, + dataset-level filters with scope propagation, parameterized window + frame bounds, `GROUPS` frame mode, windowed-metric composition. The + full normative list is §10 of the Foundation spec; the design archive + is described in `Proposed_OSI_Semantics.md §10`. + *Model-scoped named filters* (top-level `filters:`) and + *parameters* (top-level `parameters:`) are part of the Foundation + and accepted by the parser today — see + [`examples/models/demo_orders.yaml`](examples/models/demo_orders.yaml). + +We keep deferred features out of the code entirely — they raise +`E_DEFERRED_KEY_REJECTED` at parse time — so the Foundation surface +stays thin. + +--- + +## Documents + +Read in this order: + +| # | Document | What it is | +|:--:|:---|:---| +| 1 | [`SPEC.md`](SPEC.md) | Project goals, feature scope, expression handling, and error discipline reference. | +| 2 | [`ARCHITECTURE.md`](ARCHITECTURE.md) | The three-layer pipeline, architectural invariants, where-to-add-things decision tree. | +| 3 | [`INFRA.md`](INFRA.md) | Quality standards, toolchain, infrastructure roadmap, decisions log. | +| 4 | [`../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) | The Foundation — authoritative standard. | +| 5 | [`docs/JOIN_ALGEBRA.md`](docs/JOIN_ALGEBRA.md) | The closed algebra — operators, preconditions, grain contracts, laws. | +| 6 | [`docs/ALGEBRA_LAWS.md`](docs/ALGEBRA_LAWS.md) | How each algebra law is property-tested and mutation-guarded. | +| 7 | [`docs/TESTING_STRATEGY.md`](docs/TESTING_STRATEGY.md) | The four-layer test pyramid + mutation testing. | +| 8 | [`RUNNING_TESTS.md`](RUNNING_TESTS.md) | Running the test suite end-to-end, including mutation testing and the readable report. | + +Features not in Foundation v0.1 are listed in `Proposed_OSI_Semantics.md §10`. +For the error code catalog see [`docs/ERROR_CODES.md`](docs/ERROR_CODES.md). + +--- + +## Quick start + +> **Five-minute adopter path.** Parse a YAML model, plan a semantic +> query, render dialect-specific SQL. The contributor `make` targets +> live below. + +```python +import sqlglot +from osi.codegen import Dialect, compile_plan +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.parsing.parser import parse_semantic_model +from osi.planning import Reference, SemanticQuery, plan +from osi.planning.planner_context import PlannerContext + +MODEL = """ +semantic_model: + - name: sales + dialect: ANSI_SQL + datasets: + - name: orders + source: sales.orders + primary_key: [order_id] + fields: + - {name: order_id, expression: order_id, role: dimension} + - {name: customer_id, expression: customer_id, role: dimension} + - {name: status, expression: status, role: dimension} + - {name: amount, expression: amount, role: fact} + - name: customers + source: sales.customers + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: region, expression: region, role: dimension} + metrics: + - {name: total_revenue, expression: SUM(orders.amount)} + relationships: + - name: orders_to_customers + from: orders + to: customers + from_columns: [customer_id] + to_columns: [id] +""" + +parsed = parse_semantic_model(MODEL) +context = PlannerContext( + model=parsed.model, + namespace=parsed.namespace, + graph=parsed.graph, +) + +query = SemanticQuery( + dimensions=(Reference(dataset=normalize_identifier("customers"), + name=normalize_identifier("region")),), + measures=(Reference(dataset=normalize_identifier("orders"), + name=normalize_identifier("total_revenue")),), + where=FrozenSQL.of(sqlglot.parse_one("orders.status = 'completed'")), +) + +sql = compile_plan(plan(query, context), dialect=Dialect.DUCKDB) +print(sql) +``` + +For runnable end-to-end scenarios (a model + a query + a `osi compile` +command), see [`examples/`](examples/README.md). + +## Opting back into deferred features + +A few decisions in `Proposed_OSI_Semantics.md` are deferred but the +implementation keeps the *opt-back-in* path behind a feature-flag +object. Use it when migrating an existing model that still relies on +the legacy form: + +```python illustrative +from osi.config import FoundationFlags +from osi.parsing.parser import parse_semantic_model + +result = parse_semantic_model( + "model.yaml", + flags=FoundationFlags(allow_aggregate_in_field=True), +) +``` + +(The `illustrative` directive tells the README drift test in +`tests/integration/readme/` to compile-only the block — `model.yaml` is +caller-supplied and the snippet would otherwise fail at runtime.) + +The flags are documented in [`src/osi/config.py`](src/osi/config.py). +Models that turn flags on are no longer portable — the canonical +Foundation v0.1 stance is `flags=None` (the default). + +## Contributor setup + +The `make` targets cover every stage CI runs: + +```bash +cd impl/python +make install-dev # creates .venv, installs deps and pre-commit hooks +make check # lint + type + unit + property + golden + E2E +make test # the full test suite +make mutation-fast # mutation testing on the algebra module (~5 min) +make mutation # mutation testing on everything (~30 min) +``` + +See [`RUNNING_TESTS.md`](RUNNING_TESTS.md) for the readable per-stage +report. + +--- + +## Repository layout + +``` +impl/python/ (this directory) + README.md # this file + RUNNING_TESTS.md # how to run every test category + the report + SPEC.md # what to build + ARCHITECTURE.md # how the layers fit + INFRA.md # quality gates & toolchain + AGENTS.md · CONTRIBUTING.md + + src/osi/ # the implementation + parsing/ + planning/ + algebra/ + codegen/ + diagnostics/ + common/ + errors.py + + conformance/ # CLI adapter used by the compliance suite + adapter.py + enabled_proposals.yaml + + specs/ # design context for THIS implementation + README.md + deferred/ # out-of-scope proposal design archive (reference only) + + docs/ # implementation deep dives + README.md + ALGEBRA_LAWS.md + TESTING_STRATEGY.md + ERROR_CODES.md + ERRATA_ALIGNMENT.md + JOIN_SAFETY.md + mapping_bi_models_to_core_osi_abstractions.md + + scripts/ # test runner + report writer + tests/ + unit/ + properties/ # Hypothesis-based algebra laws + golden/ # snapshot plan + SQL golden files + e2e/ # DuckDB-executed row-level tests + + examples/ + models/ # example YAML models + +../../proposals/foundation-v0.1/ # the authoritative spec this impl conforms to +../../compliance/foundation-v0.1/# the runnable compliance suite +../../compliance/harness/ # the suite runner harness +``` + +--- + +## Status + +**Phase 3 — Foundation in flight.** The three pipeline layers (parsing, +planning, codegen), the closed algebra, diagnostics, CLI, and the four-layer +test pyramid (unit + Hypothesis property laws + plan/SQL goldens + DuckDB +e2e) are all landed. `make check` is green, ~93 % line coverage, all 12 +algebra laws in [`docs/ALGEBRA_LAWS.md`](docs/ALGEBRA_LAWS.md) have property +tests. + +Recently completed Foundation work (see `INFRA.md §3`): + +- Multi-hop N:1 enrichment chains (`A → B → C`). Compliance cases + `C-1.7` / `C-1.9` now pass. +- Model-scoped composite metrics (`ratio = a / NULLIF(b, 0)`), lowered + via a post-`AGGREGATE` `ADD_COLUMNS` step + (`Proposed_OSI_Semantics.md §5.4`). +- `SemanticQuery.parameters` and named-filter references inside `where` + (`§4.6` / `§5.1`). +- Cross-dataset dimension-only queries pick a unique safe anchor + instead of silently falling back to the first-declared dimension. +- M:N resolution per `§6.5`: bridge anchor discovery for dim-only + queries, multi-fact stitch validation, and the spec-mandated + `E3012_MN_NO_STITCH_PATH` / `E3013_NO_STITCHING_DIMENSION` errors + for per-query failures. (`E3011_MN_AGGREGATION_REJECTED` is + reserved for engines that opt out of M:N support entirely; + `osi_python` supports M:N and never raises `E3011` at the + user-facing surface.) +- Per-metric `joins.using_relationships` path disambiguation + (`§6.7`), threaded through every enrichment-chain BFS as a + whitelist. +- `EXISTS_IN` codegen now compiles to correlated `EXISTS (SELECT 1 + ...)` per `§7.4 + §11 #8`; the previous `IN (SELECT keys)` shape + was both spec-incorrect (NULL semantics) and dialect-fragile. +- `unique_keys` honored end-to-end: the algebra's fan-trap check + accepts join keys that match the PK *or any UK* (not just the PK), + so models that mark a 1:N relationship as M:N can be recovered by + declaring the appropriate UK. New invariant `I-9` in + `algebra/state.py`. +- Mid-pipeline bridge resolution (`§6.5.1`, mid-pipeline form). The + planner pre-aggregates the measure to the bridge's link-key grain + when an `N : N` edge sits between fact and target, sources the + bridge as a fresh root, and re-aggregates at the query grain. Every + well-defined single-bridge query plans regardless of where the + bridge sits in the chain. Compliance case `C-3.5` flipped from + `xfail` to passing; the genuinely-deferred multi-bridge case (query + references both outer endpoints A and B in `A↔Br1↔X↔Br2↔B`) is + pinned as `C-3.5b xfail` against the narrowed `§10` deferred entry. + +Remaining Foundation gaps: + +- `SEMANTIC_VIEW(...)` SQL surface — deferred to the future + SQL_INTERFACE proposal; parser not yet implemented. Tracked as + `INFRA.md §3 I-12`. Error codes `E1201`–`E1213` are carved out in + `osi.errors` with `RESERVED` annotations so the eventual parser + lands with stable codes. +- Per-metric `joins.type` override (`§6.7`) — schema accepts the + field, planner doesn't yet thread it through to the join-type + picker. `using_relationships` is fully wired. + +Explicitly deferred (`Proposed_OSI_Semantics.md §10`) and tracked as +`xfail` compliance cases: + +- Multi-hop bridge resolution `A → Br1 → Br2 → B` (`C-3.5`). The + Foundation resolves M:N through a single bridge dataset; chained + bridges are §10-deferred. Models that need it can compose two + single-bridge resolutions by introducing an intermediate dataset + modelled as a fact. + +See [`INFRA.md §3`](INFRA.md) for the infrastructure roadmap. + +--- + +## License + +TBD — intended to match the OSI standard's license when published. diff --git a/impl/python/RUNNING_TESTS.md b/impl/python/RUNNING_TESTS.md new file mode 100644 index 0000000..b46c549 --- /dev/null +++ b/impl/python/RUNNING_TESTS.md @@ -0,0 +1,190 @@ +# Running the tests + +This page is a one-stop guide to running every test category for the OSI +Python reference implementation and reading the consolidated report. + +If you want the short version: run + +```bash +scripts/run_all_tests.sh --with-mutation-fast +``` + +and open `test-results/REPORT.md` when it finishes. + +--- + +## 0. Setup + +You only need to do this once. + +```bash +cd impl/python +make install-dev # creates .venv, installs runtime + dev deps, + # installs project-local pre-commit hooks +source .venv/bin/activate +``` + +The installed dev dependencies are pinned in [`pyproject.toml`](pyproject.toml) +under `[project.optional-dependencies] dev`. + +--- + +## 1. Test pyramid + +Every category exists for a reason. See +[`docs/TESTING_STRATEGY.md`](docs/TESTING_STRATEGY.md) for the full rationale. + +| Layer | Where | What it proves | Speed | +|:--|:--|:--|:--| +| **Unit** | `tests/unit/` | Individual functions in isolation — pure inputs to outputs. | <1 s/test | +| **Property** | `tests/properties/` | Hypothesis-generated states obey the closed-algebra laws ([`docs/JOIN_ALGEBRA.md`](docs/JOIN_ALGEBRA.md) + [`docs/ALGEBRA_LAWS.md`](docs/ALGEBRA_LAWS.md)). | seconds/test | +| **Golden** | `tests/golden/` | Plan and SQL snapshots — the readable diff when *anything* about the compiler output changes. | <1 s/test | +| **E2E** | `tests/e2e/` | DuckDB executes the generated SQL against fixture data; we assert on the row set. | 1–5 s/test | +| **Adapter smoke** | `conformance/tests/` | The CLI adapter ([`conformance/adapter.py`](conformance/adapter.py)) speaks the contract in [`../../compliance/ADAPTER_INTERFACE.md`](../../compliance/ADAPTER_INTERFACE.md). | <1 s/test | +| **Mutation** | `make mutation*` | The tests above actually *check* correctness — they aren't no-ops. INFRA.md §1.1 says a surviving mutation in `src/osi/planning/algebra/` is a P0. | minutes (fast) — half hour (full) | +| **Compliance** | [`../../compliance/foundation-v0.1/`](../../compliance/foundation-v0.1/) | Every Conformance Decision (D-001..D-033) and every error code in Appendix C has a runnable case here. See [`run-osi-compliance`](../../.cursor/skills/run-osi-compliance/SKILL.md). | 30 s suite-wide | + +--- + +## 2. Make targets + +The `Makefile` is the single source of truth for individual categories. + +```bash +make test # unit + property + golden + e2e (everything that runs <10 s/test) +make test-unit # just tests/unit/ +make test-property # just tests/properties/ +make test-golden # just tests/golden/ +make test-e2e # just tests/e2e/ +make test-adapter # just conformance/tests/ + +make golden-refresh # refresh tests/golden/ snapshots (EXPLICIT ACTION; commit only on intent) +make bench # run pytest-benchmark performance tests + +make lint # black --check + isort --check + flake8 +make typecheck # mypy strict +make architecture # import-linter one-way flow contract +make audit-file-size # enforce the 600-LOC cap on src/osi/ + +make mutation-fast # mutmut on src/osi/planning/algebra/ (~5 min) +make mutation # full mutmut run on src/osi/ (~30 min) + +make check # lint + typecheck + architecture + audit-file-size + test + # mirrors the CI gate in .github/workflows/impl-python-ci.yml +``` + +For day-to-day work, `make check` is enough. + +--- + +## 3. The single-shot runner + +`scripts/run_all_tests.sh` runs every stage above, captures structured output +in `test-results/raw/`, and writes a single readable Markdown report at +`test-results/REPORT.md`. + +```bash +scripts/run_all_tests.sh # static checks + every test category +scripts/run_all_tests.sh --with-mutation-fast # + algebra mutation (~5 min) +scripts/run_all_tests.sh --with-mutation # + full mutation (~30 min) +scripts/run_all_tests.sh --skip-static # only test categories +``` + +Behaviour: + +- Every stage runs even if an earlier one failed — you get the full picture in + one pass. +- Exit code is non-zero if any stage failed, so the script is CI-safe. +- The report includes: per-stage status, per-category test counts (from JUnit + XML), combined coverage, mutation score (when applicable), failing tests + (with category tag), and the 10 slowest tests. + +Mutation testing is *opt-in* because it is slow. The fast variant +(`--with-mutation-fast`) takes ~5 min and is the recommended pre-PR run. + +--- + +## 4. The standalone mutation runner + +If you want to iterate on mutation testing without re-running the full suite: + +```bash +scripts/run_mutation.sh --fast # algebra only (~5 min) +scripts/run_mutation.sh # full (~30 min) +``` + +This writes the raw mutmut summary into `test-results/raw/`. Re-run +`scripts/run_all_tests.sh` to fold it into `REPORT.md`. + +--- + +## 5. Reading the report + +`test-results/REPORT.md` is a glanceable Markdown file with the structure: + +``` +# Test Report — impl/python + +_Generated _ + +**Overall:** PASS | FAIL + +## Stage summary -- one row per stage, with link to raw log +## Test counts -- totals per category + grand total +## Coverage -- line, branch, missing-statement counts +## Mutation testing -- killed / survived / score (when run) +## Failing tests -- every test that failed, tagged with category +## Slowest 10 tests -- across all categories +## Where to next -- paths to raw logs, HTML coverage, JUnit XML +``` + +`test-results/htmlcov/index.html` is the per-line coverage HTML. Open it in a +browser to see which lines were never executed. + +`test-results/raw/junit_*.xml` are the JUnit XML files emitted by each +pytest run — easy to re-parse from CI or other tooling. + +--- + +## 6. Common workflows + +| Task | Command | +|:--|:--| +| Pre-PR check (90s) | `make check` | +| Pre-PR with mutation (≈5 min) | `scripts/run_all_tests.sh --with-mutation-fast` | +| Investigate a single failing test | `pytest tests//test_X.py::test_Y -vvs` | +| Update a golden snapshot intentionally | `make golden-refresh` (justify in the PR) | +| Run only Hypothesis property tests | `make test-property` | +| Confirm a code change kills a mutant | `make mutation-fast` | +| Run the compliance suite against this impl | See [`run-osi-compliance`](../../.cursor/skills/run-osi-compliance/SKILL.md). | +| Skip static checks (faster iteration) | `scripts/run_all_tests.sh --skip-static` | + +--- + +## 7. CI + +The `make check` target is what +[`../../.github/workflows/impl-python-ci.yml`](../../.github/workflows/impl-python-ci.yml) +runs on every push and PR. Mutation runs on a separate job +(`mutation-algebra`) using `make mutation-fast` so a surviving mutant fails +CI without doubling pipeline time. + +--- + +## 8. Troubleshooting + +- **`make install-dev` fails on `mutmut`:** on macOS arm64 you may need + `LDFLAGS="-undefined dynamic_lookup"`. The `pyproject.toml` disables + `setproctitle` via `use_setproctitle = false` for the same reason. +- **`make architecture` fails with "import-linter not installed":** + `pip install -e ".[dev]"` once. +- **A golden test fails:** look at the diff; if the new plan/SQL is what you + intended, run `make golden-refresh` and commit the snapshot update with + the design justification. +- **A property test fails with a Hypothesis shrunk counterexample:** + paste the counterexample seed into the failing test as + `@example(...)` for regression coverage, then fix the underlying bug. + Do **not** narrow the strategy to silence the failure. +- **A mutmut mutation survives in `src/osi/planning/algebra/`:** that is a + P0. The mutation tells you exactly what test would have caught the bug — + add it. diff --git a/impl/python/SPEC.md b/impl/python/SPEC.md new file mode 100644 index 0000000..26ae56c --- /dev/null +++ b/impl/python/SPEC.md @@ -0,0 +1,423 @@ +# SPEC.md — `osi_python` Implementation Specification + +**Version:** 0.3 (post-implementation) +**Status:** Active +**Authoritative standard:** [`../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) (`osi_version: "0.1"`) +**Expression language:** [`../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md`](../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md) (`OSI_SQL_2026` is the default dialect) +**Algebra contract:** [`docs/JOIN_ALGEBRA.md`](docs/JOIN_ALGEBRA.md) +**Conformance vectors:** [`../../compliance/foundation-v0.1/DATA_TESTS.md`](../../compliance/foundation-v0.1/DATA_TESTS.md) (`T-NNN` test catalog) — referenced from `Proposed_OSI_Semantics.md` Appendix B. +**Compliance test suite:** [`../../compliance/foundation-v0.1/`](../../compliance/foundation-v0.1/) (separate top-level project; see §11.1 of the Foundation spec). +**Infrastructure & quality contract:** [`INFRA.md`](INFRA.md) + +This document defines what the implementation builds and the contracts each +component must satisfy. When this document disagrees with `specs/`, `specs/` +wins; update this document to match. + +> **Cleanliness over backwards compatibility.** `osi_python` has never +> shipped a release. Every change MUST prefer a clean end state over +> preserving any current behaviour, name, error code, file layout, or +> public API. No deprecation shims. No legacy aliases. No compat flags. +> Names change to match the updated spec; old names are deleted in the same +> commit. The only exception is `E_DEFERRED_KEY_REJECTED`, which is the +> spec-mandated parse-time rejection of a recognised-but-deferred key. + +--- + +## Table of Contents + +1. [Project Goals](#1-project-goals) +2. [What is in scope (Foundation)](#2-what-is-in-scope-foundation) +3. [What is out of scope (deferred)](#3-what-is-out-of-scope-deferred) +4. [Architecture](#4-architecture) +5. [The algebra](#5-the-algebra) +6. [Expression handling](#6-expression-handling) +7. [Error discipline](#7-error-discipline) +8. [Test strategy](#8-test-strategy) +9. [Glossary](#9-glossary) + +--- + +## 1. Project Goals + +`osi_python` is a **reference implementation** of Open Semantic Interchange, +targeting the [Foundation tier](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) +— a deliberately smaller standard — with three hard commitments: + +1. **Algebraic correctness is provable.** Every compiler transformation + is expressible as a composition of operators from a closed algebra with + explicit preconditions and grain contracts. Correctness reduces to + correctness of the algebra; the algebra is checked with property-based + tests and guarded with mutation testing. See [`docs/JOIN_ALGEBRA.md`](docs/JOIN_ALGEBRA.md). +2. **Failure is explicit.** Any semantics the compiler cannot compile + correctly raise a typed `OSIError` whose `error.code` is a value from + Appendix C of the Foundation spec. Silent wrong SQL is the single worst + possible outcome and is designed out. +3. **The Foundation stays thin.** Deferred features (§3 below) raise + `E_DEFERRED_KEY_REJECTED` at parse time. The codebase contains no + speculative plumbing for them. + +### 1.1 Design priorities, in order + +| # | Priority | What it means in practice | +|:--:|:---|:---| +| 1 | **Provable correctness** | The algebra is closed, pure, and total. Property tests assert universal laws; mutation tests prove the tests actually check. Every conformance decision in `Proposed_OSI_Semantics.md` Appendix B has at least one `T-NNN` vector in `DATA_TESTS.md` and a runnable case in `../../compliance/foundation-v0.1/`. | +| 2 | **Legibility** | Code reads like a textbook. `src/osi/planning/algebra/operations.py` is the first file a new contributor reads; it should be ~400 LOC, not 4000. Hard cap: no file in `src/osi/` > 600 LOC. | +| 3 | **Compiler discipline** | Three layers (`parsing`, `planning`, `codegen`) with one-way information flow and typed boundaries. | +| 4 | **Explainability** | `diagnostics.explain(plan)` emits one line per algebra op with grain, inputs, outputs. New errors land in the explainer the same sprint they land in the planner. | +| 5 | **Portability** | `codegen` is a pure projection; adding a dialect is a new transpiler, not a plan change. `OSI_SQL_2026` is the default; per-dialect expression form (`{ dialects: [{dialect, expression}, …] }`) is supported per D-021. | + +### 1.2 Non-goals + +- Feature parity with any prior implementation. The Foundation is the point. +- Highest performance. Correctness and legibility first. +- Multiple planner shapes. One planner, one algebra, one plan type. +- Backwards compatibility with any earlier `osi_python` surface. + +--- + +## 2. What is in scope (Foundation) + +Authoritative definition lives in [`../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md). +The Foundation declares `osi_version: "0.1"`. Summary for this SPEC: + +### 2.1 Semantic model + +- **Datasets** (§4.2): logical tables with declared `primary_key` (and + optional `unique_keys`), fields, and an optional list of dataset-scoped + metrics. +- **Relationships** (§4.4): equijoin only, single-column or composite. + Each declares `from` (many side) and `to` (one side). Cardinality is + inferred from PK / UK declarations (§6.4). `referential_integrity`, + `condition`, `asof`, `range`, and `using_relationships` are deferred + (§3 / D-009) and rejected with `E_DEFERRED_KEY_REJECTED`. +- **Fields** (§4.3): each field has a `name` and an `expression`. The + expression MAY be scalar, an aggregate (table-scoped metric — see + **implicit home-grain aggregation** below), a window expression, or a + boolean form of any of those. There is no `role:` keyword and no grain + override; routing is by *resolved expression shape* (D-005). +- **Implicit home-grain aggregation** (§4.3.1, D-003): when a field's + body references columns or fields from a higher-grain related dataset + via a `1 : N` edge, the aggregate is implicitly evaluated at the home + dataset's grain. The result is a per-home-row scalar at definition time + and does not change with the consuming query. This is what lets + `customers.lifetime_value = SUM(orders.amount)` resolve to a per-customer + scalar without an explicit `grain:` keyword. +- **Metrics** (§4.5): named aggregate expressions. Three forms: + (1) single-step cross-grain aggregation over `1 : N` (D-020); (2) + composition over other metric names (no grain inheritance — the + windowed-metric composition case is rejected with + `E_WINDOWED_METRIC_COMPOSITION` per D-031); (3) constant. Cross-grain + aggregation over `N : N` is governed by D-026 / D-027: the bridge plan + is a single-pass aggregate over the unique `(measure-home-row, + group-key)` row set, and is accepted bare for every aggregate category + (distributive, algebraic, holistic). The "per-home-row-first" + interpretation requires the nested form `AGG(AGG(...))` and is deferred + to §10 (`E_NESTED_AGGREGATION_DEFERRED`). +- **Parameters**: typed query-time values with defaults; literals in + expressions. +- **Namespacing** (§4.6, D-006 / D-018 / D-019): bare references resolve + to the global namespace; dataset-scoped names use `dataset.field`. + Reserved names (`GRAIN`, `FILTER`, `QUERY_FILTER`) cannot be used as + user identifiers. + +### 2.2 Query model — two shapes + +The Foundation distinguishes **aggregation queries** from **scalar +queries** (§5.1, D-010 / D-011). + +| Shape | Clauses | Rule | +|:---|:---|:---| +| **Aggregation query** | `Dimensions`, `Measures`, `Where`, `Having`, `Order By`, `Limit` | Result cardinality is exactly `DISTINCT(Dimensions)`; empty `Dimensions` ⇒ exactly one row (the empty grain). All measures resolve at the query grain (D-002). | +| **Scalar query** | `Fields`, `Where`, `Order By`, `Limit` | Row-level projection. A bare metric reference inside `Fields` ⇒ `E_AGGREGATE_IN_SCALAR_QUERY`. A scalar query whose join path replicates home-dataset rows ⇒ `E_FAN_OUT_IN_SCALAR_QUERY` (D-023). | + +A query that mixes the two shapes (`Fields` set together with +`Dimensions` or `Measures`) ⇒ `E_MIXED_QUERY_SHAPE` (D-010). + +Predicate placement is by resolved expression shape (D-005, D-012): + +| Predicate site | Allowed | Otherwise | +|:---|:---|:---| +| `Where` | row-level scalars; home-grain scalars (incl. boolean home-grain scalars produced by implicit home-grain aggregation) | aggregate at the *query* grain ⇒ `E_AGGREGATE_IN_WHERE`; mixed levels ⇒ `E_MIXED_PREDICATE_LEVEL` | +| `Having` | aggregates resolved at the query grain | pure row-level predicate ⇒ `E_NON_AGGREGATE_IN_HAVING`; mixed levels ⇒ `E_MIXED_PREDICATE_LEVEL` | + +`ORDER BY ` (outer or inside `OVER (...)`) without an explicit +`NULLS FIRST` / `NULLS LAST` resolves to the Foundation default +**`NULLS LAST` for `ASC` and `NULLS FIRST` for `DESC`** — the SQL:2003 +"NULLs are high-end" convention. Flipping `ASC ↔ DESC` flips NULL +placement (the symmetry property). Engines guarantee the resolved row +order on every supported dialect by emitting the explicit clause whenever +the dialect's native default would otherwise produce a different order; +when the resolved clause matches the dialect default it MAY be elided +(both forms produce identical row orders on that dialect) (D-029). + +### 2.3 Join semantics + +- **Cardinality inference** (§6.4) from declared `primary_key` and + `unique_keys`. +- **Default join types** (§6.6, D-001 / D-004): + - `N : 1` enrichment ⇒ `LEFT` (orphan facts surface as `NULL` group keys). + - Multi-fact composition on shared dimensions with **incompatible + fact roots** ⇒ `FULL OUTER` stitch. + - Scalar grand totals (no shared grain) ⇒ `CROSS JOIN` of pre-aggregated + 1-row scalars. + - Per-metric `joins.type` overrides are deferred (D-008). +- **Safety** (§6.7): aggregate-before-join, fan-out safety, and chasm-trap + safety are preconditions on algebra operators. +- **M:N resolution** (§6.8): bridge dataset (§6.8.1, D-026 — materialize + distinct `(fact, group-key)`) or shared-dimension stitch (§6.8.2). No + bridge and no stitch ⇒ `E3012_MN_NO_SAFE_REWRITE`. Two unrelated facts + with no shared dimension ⇒ `E3013_NO_STITCHING_DIMENSION`. The + semi-join filter form (`EXISTS_IN`) is deferred. +- **Path resolution** (§6.9, D-018): unique path used; ambiguity ⇒ + `E_AMBIGUOUS_PATH`; no path ⇒ `E_NO_PATH`. + +### 2.4 Window functions + +Standard SQL window functions are part of the Foundation (§6.10): + +- **Catalog** (D-028): ranking (`ROW_NUMBER`, `RANK`, `DENSE_RANK`, + `NTILE`, `PERCENT_RANK`, `CUME_DIST`), navigation (`LAG`, `LEAD`, + `FIRST_VALUE`, `LAST_VALUE`, `NTH_VALUE`), and aggregate-windows. + Allowed in `Measures`, `Fields`, `Order By`, `Having`, and any field / + metric `expression`. Inside `Where` ⇒ `E_WINDOW_IN_WHERE`. +- **Pre-fan-out** (D-030): a window's home dataset MUST run over the + pre-fan-out row set; engines materialize home-grain rows before + applying the window. If no safe rewrite is available ⇒ + `E_WINDOW_OVER_FANOUT_REWRITE`. +- **Composition** (D-031): a metric that references another metric whose + body contains a window ⇒ `E_WINDOWED_METRIC_COMPOSITION`. Direct use + of a windowed metric in `Measures` is allowed. +- **Frame modes** (D-032): `ROWS` and `RANGE` with integer-literal bounds + only. `GROUPS` and parameterized bounds ⇒ `E_DEFERRED_FRAME_MODE`. + +### 2.5 SQL subset + +The expression language is defined normatively in +[`../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md`](../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md). +The default dialect for un-annotated expressions is **`OSI_SQL_2026`** +(D-021). Field and metric `expression` slots accept either a bare string +in the default dialect or the structured per-dialect form +`{ dialects: [{ dialect, expression }, …] }`. + +Required surface: + +- ANSI SQL:2003 Core scalar ops, `CASE`, `COALESCE`, `CAST`. +- Aggregations: `SUM`, `COUNT`, `COUNT(*)` (required — see D-016 — engines + that historically reject `COUNT(*)` MUST provide a transparent + rewrite), `COUNT(DISTINCT)`, `MIN`, `MAX`, `AVG`. +- Standard SQL window functions (see §2.4 above). +- Empty / NULL aggregate behaviour follows standard SQL (D-033): + `COUNT` family ⇒ `0`; `SUM`, `AVG`, `MIN`, `MAX`, etc. ⇒ `NULL`. + Models that prefer `0` MUST declare it per-metric (`COALESCE(SUM(...), 0)`). + +Removed from the Foundation surface (rejected with +`E_DEFERRED_KEY_REJECTED` or `E_UNKNOWN_FUNCTION`): `EXISTS_IN`, +`NOT EXISTS_IN`, `ATTR`, `UNSAFE`, `AGG`, `GRAIN_AGG`. The `GROUPS` +frame mode and parameterized window frame bounds are also deferred per D-032. + +### 2.6 Compliance levels + +The Foundation defines two levels; `osi_python` targets Level 2. + +| Level | Meaning | +|:---|:---| +| L1 (Parse) | YAML parses into a valid `SemanticModel`. | +| L2 (Plan + Render) | Any valid model + Foundation query produces a deterministic plan and compiles to correct SQL on at least one dialect (DuckDB for correctness, Snowflake/BigQuery for portability). Per-engine determinism (D-014) MUST hold; cross-engine SQL determinism is NOT required. | + +The canonical compliance vectors live in +[`../../compliance/foundation-v0.1/DATA_TESTS.md`](../../compliance/foundation-v0.1/DATA_TESTS.md) and the runnable suite in +[`../../compliance/foundation-v0.1/`](../../compliance/foundation-v0.1/). + +--- + +## 3. What is out of scope (deferred) + +Authoritative deferred-features list: §10 of `../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`. +Features deferred from Foundation v0.1 are enumerated in `Proposed_OSI_Semantics.md §10`. + +Summary: + +- **Explicit grain overrides** (`FIXED` / `INCLUDE` / `EXCLUDE` / explicit + `TABLE`). *Implicit home-grain aggregation* is in scope (§4.3); the + explicit `grain:` keyword is not. +- **Filter context propagation** (`reset`, `filter.expression` on metrics). +- **Metric composition with grain or filter inheritance** (windowed-metric + composition specifically rejected via D-031). +- **Model-level `natural_grain`** declaration. +- **Path disambiguation** (`using_relationships`) and **per-metric + `joins.{type, using_relationships}` overrides** (D-008). +- **Non-equijoin / ASOF / Range** relationships (`condition`, `asof`, + `range`, `cardinality`). +- **Referential-integrity-driven INNER promotion** + (`from_all_rows_match`, `to_all_rows_match`, `referential_integrity:` + on relationships). The Foundation does NOT carry RI plumbing today. +- **Semi-additive measures**. +- **Grouping sets / ROLLUP / CUBE / PIVOT**. +- **Semi-join filter form** (`EXISTS_IN` / `NOT EXISTS_IN`). +- **Window-function extensions** beyond the Foundation: `GROUPS` frame + mode, parameterized frame bounds, ordered-set aggregates with + `WITHIN GROUP`, windowed-metric composition. +- **Named filters** (reusable boolean expressions referenced by name). +- **Multi-hop bridge resolution** (more than one bridge between the same + two endpoints). +- **Symmetric aggregates** (Looker-style hash trick). + +Using any of these in a YAML model or query MUST raise +`E_DEFERRED_KEY_REJECTED` at parse time (D-009). The codebase contains +**no** partial plumbing for these features. + +--- + +## 4. Architecture + +Full contract in [`ARCHITECTURE.md`](ARCHITECTURE.md) — three-layer pipeline, +one-way information flow, numbered invariants, and the where-to-add-things +decision tree. + +--- + +## 5. The algebra + +### 5.1 Stated as a proof obligation + +> **Every transformation of a calculation is a total, pure, deterministic +> function on an immutable `CalculationState`. The nine operators of +> [`docs/JOIN_ALGEBRA.md`](docs/JOIN_ALGEBRA.md) are the complete set +> of transformations. A plan is a sequence of those operators. If an +> operator's precondition cannot be proved at plan-build time, the planner +> raises a typed `OSIError` and builds no plan.** + +Operator signatures, grain contracts, the twelve universal laws, and their +property-test and mutation-test mapping live in +[`docs/JOIN_ALGEBRA.md`](docs/JOIN_ALGEBRA.md) and +[`docs/ALGEBRA_LAWS.md`](docs/ALGEBRA_LAWS.md). + +--- + +## 6. Expression handling + +The Foundation embeds SQL expressions inside field/metric/filter +definitions. The default dialect is `OSI_SQL_2026` +([`../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md`](../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md)). The +compiler's expression handling follows three rules: + +1. **All expression manipulation goes through SQLGlot ASTs.** Raw-string + concatenation, f-strings, and regex-on-SQL are banned project-wide. +2. **Expressions are frozen on parse.** The pydantic validator parses each + expression string with `sqlglot.parse_one(...)` against the declared + dialect (default `OSI_SQL_2026`) and stores the resulting AST. + Downstream code reads it; nothing mutates it. +3. **Dependency analysis is pure.** `osi.common.sql_expr.dependencies(expr)` + walks the AST and returns `frozenset[Identifier]`; no state, no side + effects. + +### 6.1 Per-dialect expression form (D-021) + +An `expression` slot accepts either a bare string in the model's default +dialect or the structured object form: + +```yaml +expression: + dialects: + - dialect: OSI_SQL_2026 + expression: "amount * 1.1" + - dialect: SNOWFLAKE + expression: "amount * 1.1::FLOAT" +``` + +The structured form is normatively defined in +`SQL_EXPRESSION_SUBSET.md`. Engines that recognize neither dialect in a +`dialects:` array MUST reject the model with a clear error. + +### 6.2 Expression subset enforcement + +At parse time, a visitor rejects any AST node not in the allowed subset. +Removed function names (`EXISTS_IN`, `NOT EXISTS_IN`, `ATTR`, `UNSAFE`, +`AGG`, `GRAIN_AGG`) raise `E_DEFERRED_KEY_REJECTED` or +`E_UNKNOWN_FUNCTION`. `GROUPS` frame mode and parameterized window +frame bounds raise `E_DEFERRED_FRAME_MODE` per D-032. + +--- + +## 7. Error discipline + +The authoritative catalog is **Appendix C of +[`../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md)**. +[`docs/ERROR_CODES.md`](docs/ERROR_CODES.md) is the implementation-side +mirror that names the Python `ErrorCode` enum members one-for-one with +the appendix. + +- All errors inherit from `osi.errors.OSIError`. +- Every error carries a stable `code: ErrorCode` from Appendix C and a + `context` dict with dataset/field/expression information for diagnostics. +- Tests assert on `error.code`, never on message text. +- The algebra raises only the subset of `E_*` codes whose anchor + §-reference falls inside the algebra's responsibility (`E_UNSAFE_REAGGREGATION`, + `E_AMBIGUOUS_NESTED_AGGREGATION_GRAIN`, + `E_UNAGGREGATED_FINER_GRAIN_REFERENCE`, `E_AMBIGUOUS_MEASURE_GRAIN`). + The planner raises predicate-routing and shape errors + (`E_AGGREGATE_IN_WHERE`, `E_NON_AGGREGATE_IN_HAVING`, + `E_MIXED_PREDICATE_LEVEL`, `E_MIXED_QUERY_SHAPE`, + `E_AGGREGATE_IN_SCALAR_QUERY`, `E_FAN_OUT_IN_SCALAR_QUERY`, + `E_EMPTY_AGGREGATION_QUERY`, `E_EMPTY_SCALAR_QUERY`, the M:N family + `E3012` / `E3013`, the path family `E_AMBIGUOUS_PATH` / `E_NO_PATH`, + the namespace family `E_NAME_COLLISION` / `E_NAME_NOT_FOUND`, and the + window-placement codes `E_WINDOW_IN_WHERE`, + `E_WINDOW_OVER_FANOUT_REWRITE`, `E_WINDOWED_METRIC_COMPOSITION`). + Codegen raises only `E_DEFERRED_FRAME_MODE` (when a deferred frame + pattern survives parsing — defence in depth) and dialect-specific + emission errors. +- A property test (`tests/properties/test_error_taxonomy.py`) asserts + that every exception raised anywhere in the compiler is an `OSIError` + with a code from Appendix C. Catching a bare `Exception` in `src/` is + a lint error. Adding a code outside Appendix C is a lint error. + +--- + +## 8. Test strategy + +Five layers (unit, property, golden, e2e, compliance) plus mutation testing. +Full strategy in [`docs/TESTING_STRATEGY.md`](docs/TESTING_STRATEGY.md). +Per-module mutation thresholds and CI gates in [`INFRA.md §1.1`](INFRA.md). +Every D-NNN row in Appendix B has at least one `T-NNN` test vector in +`DATA_TESTS.md` and a runnable case in `../../compliance/foundation-v0.1/`. + +--- + +## 9. Glossary + +- **Algebra** — the nine pure operators over `CalculationState` defined + in [`docs/JOIN_ALGEBRA.md`](docs/JOIN_ALGEBRA.md). +- **Aggregation query** — a Foundation query shape (§5.1.1): `Dimensions`, + `Measures`, `Where`, `Having`, `Order By`, `Limit`. Result cardinality + is `DISTINCT(Dimensions)`. +- **Scalar query** — a Foundation query shape (§5.1.2): `Fields`, + `Where`, `Order By`, `Limit`. Row-level projection. +- **CalculationState** — the single value flowing through the algebra; + grain + columns + provenance, frozen. +- **Grain** — the set of dimensions that uniquely identify a row. +- **Home grain (table grain)** — the per-dataset grain; the dataset's + primary key (or any declared unique key). +- **Implicit home-grain aggregation** — the §4.3 / D-003 rule: a field + body that aggregates a higher-grain related dataset over `1 : N` + resolves to a per-home-row scalar, automatically aggregated at the home + dataset's grain. +- **Fan-out** — a join that creates multiple rows per parent due to a + many-side cardinality; unsafe for most aggregations without + pre-aggregation. +- **Chasm trap** — two facts sharing a dimension without a direct + relationship; resolved by per-fact aggregation + `merge` (D-001 row 3). +- **Foundation** — the subset of OSI this implementation targets; + defined in [`../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) + (`osi_version: "0.1"`). +- **Deferred** — a feature in §10 of the Foundation spec that is out of + scope; raises `E_DEFERRED_KEY_REJECTED` at parse time. +- **Conformance Decision (D-NNN)** — a numbered row in Appendix B of the + Foundation spec; each is a small contract paired with a test shape. +- **Test Vector (T-NNN)** — a runnable witness for a `D-NNN`, defined in + [`../../compliance/foundation-v0.1/DATA_TESTS.md`](../../compliance/foundation-v0.1/DATA_TESTS.md) and shipped under + [`../../compliance/foundation-v0.1/`](../../compliance/foundation-v0.1/). +- **Golden test** — a snapshot test whose expected output is a file on + disk, refreshed only by explicit command. +- **Reference interpreter** — the deliberately-naive pandas implementation + of the Foundation semantics, used by equivalence laws. diff --git a/impl/python/conformance/adapter.py b/impl/python/conformance/adapter.py new file mode 100644 index 0000000..77d834d --- /dev/null +++ b/impl/python/conformance/adapter.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""OSI compliance suite adapter for the Python reference implementation. + +A thin translator from the suite's CLI contract (see +[`compliance/ADAPTER_INTERFACE.md`](../../../compliance/ADAPTER_INTERFACE.md)) +to the `osi.*` API. Format conversion only — no validation, planning, +or SQL generation. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +_ADAPTER_DIR = Path(__file__).resolve().parent +_OSI_PYTHON_SRC = _ADAPTER_DIR.parent / "src" +if _OSI_PYTHON_SRC.exists(): + sys.path.insert(0, str(_OSI_PYTHON_SRC)) + +import sqlglot # noqa: E402 +import yaml # noqa: E402 + +from osi.codegen import Dialect, compile_plan # noqa: E402 +from osi.common.identifiers import normalize_identifier # noqa: E402 +from osi.common.sql_expr import FrozenSQL # noqa: E402 +from osi.errors import ( # noqa: E402 + ErrorCode, + OSIError, + OSIParseError, +) + +# S-10: legacy numeric codes → Appendix C named codes for user-facing +# diagnostics. The internal algebra layer keeps the legacy codes (so +# the algebra contract stays narrow); translation happens at the +# adapter boundary. +_LEGACY_CODE_MAP: dict[ErrorCode, ErrorCode] = { + ErrorCode.E2002_NAME_NOT_FOUND: ErrorCode.E_NAME_NOT_FOUND, + ErrorCode.E2001_AMBIGUOUS_NAME: ErrorCode.E_NAME_COLLISION, + ErrorCode.E2003_DUPLICATE_NAME: ErrorCode.E_NAME_COLLISION, + ErrorCode.E2004_UNREACHABLE_DATASET: ErrorCode.E_NO_PATH, + ErrorCode.E2008_RESERVED_IDENTIFIER: ErrorCode.E_RESERVED_IDENTIFIER, + ErrorCode.E3001_AMBIGUOUS_JOIN_PATH: ErrorCode.E_AMBIGUOUS_PATH, + # E3013_NO_STITCHING_DIMENSION is kept as ``E3013`` per Appendix C: + # it is a *distinct* condition from generic path resolution failure + # (``E_NO_PATH``) — two unrelated facts with no shared stitch + # dimension would yield a Cartesian product, not a missing path. + # No mapping → the numeric code surfaces unchanged. +} +from osi.parsing.graph import build_graph # noqa: E402 +from osi.parsing.namespace import build_namespace # noqa: E402 +from osi.parsing.parser import parse_semantic_model # noqa: E402 +from osi.planning import ( # noqa: E402 + OrderBy, + Reference, + SemanticQuery, + SortDirection, + plan, +) +from osi.planning.planner_context import PlannerContext # noqa: E402 + + +# Fields in the suite carry an optional ``dimension:`` marker; absence of +# the marker means "fact". The osi_python Foundation uses an explicit +# ``role`` enum instead. Format conversion only — no semantic changes. +def _translate_field(field: dict[str, Any]) -> dict[str, Any]: + out = { + k: v + for k, v in field.items() + if k not in {"dimension", "fact", "snapshot_dimensions"} + } + if "dimension" in field: + marker = field["dimension"] or {} + if isinstance(marker, dict) and marker.get("is_time"): + out["role"] = "time_dimension" + else: + out["role"] = "dimension" + else: + out["role"] = "fact" + return out + + +def _translate_model(raw: dict[str, Any]) -> dict[str, Any]: + """Reshape suite-format YAML into osi_python's schema.""" + out = dict(raw) + datasets = [] + for ds in raw.get("datasets", []) or []: + ds_out = dict(ds) + ds_out["fields"] = [_translate_field(f) for f in ds.get("fields", []) or []] + datasets.append(ds_out) + out["datasets"] = datasets + return out + + +def _load_translated_model(model_path: Path): # noqa: ANN202 + raw = yaml.safe_load(model_path.read_text()) + translated = _translate_model(raw or {}) + # Round-trip through YAML so the parser sees the canonical shape + # and surfaces the same ``E1xxx`` errors it would for any user model. + return parse_semantic_model(yaml.safe_dump(translated)) + + +def _ref(name: str, dataset: str | None = None) -> Reference: + return Reference( + dataset=normalize_identifier(dataset) if dataset else None, + name=normalize_identifier(name), + ) + + +def _dim_ref(spec: Any) -> Reference: + """Build a dimension reference from a bare string or dict.""" + if isinstance(spec, str): + ds, _, name = spec.partition(".") + return _ref(name or ds, ds if name else None) + return _ref(str(spec.get("name") or spec["field"]), spec.get("dataset")) + + +def _measure_ref(spec: Any) -> Reference: + """Suite measures are ``{"name": alias, "metric": metric_name}``.""" + if isinstance(spec, str): + return _ref(spec) + name = spec.get("metric") or spec.get("name") or spec.get("field") + if name is None: + raise ValueError(f"measure has no metric: {spec!r}") + return _ref(str(name), spec.get("source_dataset") or spec.get("dataset")) + + +def _measure_aliases(specs: list[Any]) -> list[tuple[str, str]]: + """Collect ``(metric_name, output_alias)`` pairs from measure specs. + + The suite shape ``{"name": "revenue", "metric": "total_revenue"}`` + means "compute the metric ``total_revenue`` and emit the column + as ``revenue``". Only emit a pair when the alias differs from the + metric name; same-name pairs are no-ops at codegen time. + """ + out: list[tuple[str, str]] = [] + for spec in specs: + if isinstance(spec, str): + continue + metric = spec.get("metric") or spec.get("field") + alias = spec.get("name") + if metric and alias and metric != alias: + out.append((str(metric), str(alias))) + return out + + +def _compile_filters(exprs: list[str]) -> FrozenSQL | None: + """AND a list of SQL predicates into a single :class:`FrozenSQL`.""" + if not exprs: + return None + parsed = [sqlglot.parse_one(e) for e in exprs] + combined = parsed[0] + for node in parsed[1:]: + combined = sqlglot.exp.And(this=combined, expression=node) + return FrozenSQL.of(combined) + + +def _order_entry(entry: dict[str, Any]) -> OrderBy: + descending = ( + entry.get("descending") or str(entry.get("direction", "ASC")).upper() == "DESC" + ) + target = entry.get("target") or entry.get("name") or entry.get("field") + return OrderBy( + target=_dim_ref(target), + direction=SortDirection.DESC if descending else SortDirection.ASC, + ) + + +def _build_semantic_query(qdict: dict[str, Any]) -> SemanticQuery: + """Translate the suite's query JSON into a :class:`SemanticQuery`. + + Foundation v0.1 (D-010 / D-011) routes by query shape. The + presence of the ``fields`` key in the JSON — even when empty — + signals scalar-query intent so the right empty-shape error + code (``E_EMPTY_SCALAR_QUERY`` vs ``E_EMPTY_AGGREGATION_QUERY``) + is raised. ``SemanticQuery`` itself can't tell empty-list from + missing-key, so the disambiguation lives at the adapter boundary + where the user input format is known. + """ + # Reject deferred query-level keys before any translation. The + # deferred catalog lives in osi.parsing.deferred; the adapter is + # the only place that sees raw query JSON, so the check belongs + # here (the planner only sees translated SemanticQuery values + # which don't carry these keys at all). + from osi.parsing.deferred import DEFERRED_QUERY_KEYS + + deferred_present = sorted(set(qdict) & DEFERRED_QUERY_KEYS) + if deferred_present: + first = deferred_present[0] + raise OSIParseError( + ErrorCode.E_DEFERRED_KEY_REJECTED, + ( + f"query uses deferred key {first!r}; " + "see Proposed_OSI_Semantics.md §10 / D-009" + ), + context={ + "where": "semantic_query", + "keys": tuple(deferred_present), + }, + ) + # Suite contract: ``filters`` ⇒ Where (pre-aggregate), ``qualify`` + # ⇒ Having (post-aggregate). Foundation v0.1 routes by resolved + # expression shape (D-005), but the suite preserves the user's + # placement intent so the planner can validate it (D-012). + filters_raw = list(qdict.get("filters") or []) + qualify_raw = list(qdict.get("qualify") or []) + has_fields_key = "fields" in qdict + fields = qdict.get("fields") or () + dimensions = qdict.get("dimensions") or () + measures = qdict.get("measures") or () + if has_fields_key and not fields and not dimensions and not measures: + raise OSIParseError( + ErrorCode.E_EMPTY_SCALAR_QUERY, + ( + "scalar query has empty Fields list; declare at least " + "one field. See Proposed_OSI_Semantics.md D-011." + ), + ) + return SemanticQuery( + dimensions=tuple(_dim_ref(d) for d in dimensions), + measures=tuple(_measure_ref(m) for m in measures), + fields=tuple(_dim_ref(f) for f in fields), + where=_compile_filters(filters_raw), + having=_compile_filters(qualify_raw), + order_by=tuple(_order_entry(e) for e in (qdict.get("order_by") or ())), + limit=qdict.get("limit"), + ) + + +def cmd_sql(args: argparse.Namespace) -> int: + """Emit SQL for ``--model`` + ``--query-file`` using ``--dialect``.""" + try: + result = _load_translated_model(Path(args.model)) + ctx = PlannerContext( + model=result.model, + namespace=build_namespace(result.model), + graph=build_graph(result.model), + ) + qdict = json.loads(Path(args.query_file).read_text()) + query = _build_semantic_query(qdict) + dialect = Dialect[args.dialect.upper()] + compiled_plan = plan(query, ctx) + aliases = _measure_aliases(list(qdict.get("measures") or [])) + if aliases: + from dataclasses import replace as _replace + + from osi.common.identifiers import normalize_identifier as _norm + + compiled_plan = _replace( + compiled_plan, + output_aliases=tuple( + (_norm(metric), _norm(alias)) for metric, alias in aliases + ), + ) + sql = compile_plan(compiled_plan, dialect=dialect) + except OSIError as err: + code = _LEGACY_CODE_MAP.get(err.code, err.code) + sys.stderr.write(f"{code.value}: {err}\n") + return 1 + except Exception as err: # noqa: BLE001 — intentional top-level catch + sys.stderr.write(f"Error: {err}\n") + return 1 + sys.stdout.write(sql.rstrip() + "\n") + return 0 + + +def main(argv: list[str] | None = None) -> int: + """CLI entry point.""" + parser = argparse.ArgumentParser(prog="osi_python_adapter") + sub = parser.add_subparsers(dest="command") + p_sql = sub.add_parser("sql", help="Generate SQL from a model + query") + p_sql.add_argument("--model", required=True) + p_sql.add_argument("--query-file", required=True) + p_sql.add_argument("--dialect", default="duckdb") + p_sql.set_defaults(func=cmd_sql) + args = parser.parse_args(argv) + if not getattr(args, "func", None): + parser.print_help() + return 1 + return int(args.func(args)) + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/impl/python/conformance/enabled_proposals.yaml b/impl/python/conformance/enabled_proposals.yaml new file mode 100644 index 0000000..0aacc40 --- /dev/null +++ b/impl/python/conformance/enabled_proposals.yaml @@ -0,0 +1,22 @@ +# Enabled proposals for osi_python +# ----------------------------------------------------------------------- +# This file is the authoritative answer to "which OSI proposals does +# osi_python implement?". The ``make conformance`` Makefile target reads +# this list and passes it as ``--proposals`` to the compliance/harness +# runner; any test requiring a proposal not listed here is recorded as +# a SKIP (not a FAIL). +# +# Every entry MUST appear in ``compliance/foundation-v0.1/proposals.yaml``. +# Adding a proposal here means: "the implementation is complete, there +# is reason to believe it is correct, and the suite should exercise it." +# +# Lifecycle (see ../CONTRIBUTING.md §Ratification): +# 1. Draft a spec under ../specs/. +# 2. Implement behind the existing deferred-feature rejection in +# osi.parsing.deferred so nothing regresses. +# 3. Remove the rejection + tag associated tests with the proposal ID. +# 4. Add the ID here and run ``make conformance``. + +# Foundation proposals (ratified and required of every osi_python build) +# are implicit. Only opt-in proposals above the Foundation need listing. +enabled: [] diff --git a/impl/python/conformance/tests/test_adapter.py b/impl/python/conformance/tests/test_adapter.py new file mode 100644 index 0000000..a66c315 --- /dev/null +++ b/impl/python/conformance/tests/test_adapter.py @@ -0,0 +1,130 @@ +"""Smoke tests for the osi_python compliance-suite adapter. + +The adapter is a thin translator (see ADAPTER_INTERFACE.md); these +tests lock down the translation rules, not the underlying planner. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest +import yaml + +ADAPTER_PATH = Path(__file__).resolve().parent.parent / "adapter.py" + + +def _load_adapter_module(): + spec = importlib.util.spec_from_file_location( + "osi_conformance_adapter", ADAPTER_PATH + ) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture(scope="module") +def adapter(): + return _load_adapter_module() + + +def test_field_marker_becomes_role(adapter) -> None: + dim = adapter._translate_field({"name": "c", "expression": "c", "dimension": {}}) + fact = adapter._translate_field({"name": "amount", "expression": "amount"}) + time = adapter._translate_field( + {"name": "d", "expression": "d", "dimension": {"is_time": True}} + ) + assert dim["role"] == "dimension" + assert fact["role"] == "fact" + assert time["role"] == "time_dimension" + assert "dimension" not in dim + assert "dimension" not in time + + +def test_translator_drops_unknown_field_markers(adapter) -> None: + out = adapter._translate_field( + {"name": "balance", "expression": "balance", "snapshot_dimensions": ["d"]} + ) + assert "snapshot_dimensions" not in out + assert out["role"] == "fact" + + +def test_dim_ref_parses_qualified_and_bare(adapter) -> None: + r1 = adapter._dim_ref("orders.category") + r2 = adapter._dim_ref("category") + r3 = adapter._dim_ref({"name": "category", "dataset": "orders"}) + assert str(r1) == "orders.category" + assert str(r2) == "category" + assert str(r3) == "orders.category" + + +def test_measure_ref_prefers_metric_over_name(adapter) -> None: + r = adapter._measure_ref({"name": "total", "metric": "sum_amount"}) + assert str(r) == "sum_amount" + + +def test_translate_model_roundtrips_through_parser(adapter) -> None: + raw = { + "name": "orders_basic", + "datasets": [ + { + "name": "orders", + "source": "orders", + "primary_key": ["id"], + "fields": [ + {"name": "id", "expression": "id", "dimension": {}}, + {"name": "amount", "expression": "amount"}, + ], + } + ], + "metrics": [{"name": "total", "expression": "SUM(amount)"}], + } + reshaped = adapter._translate_model(raw) + # The YAML round-trip must survive parse_semantic_model. + from osi.parsing.parser import parse_semantic_model + + result = parse_semantic_model(yaml.safe_dump(reshaped)) + field_roles = {f.name: f.role.value for f in result.model.datasets[0].fields} + assert field_roles == {"id": "dimension", "amount": "fact"} + + +def test_adapter_end_to_end_on_trivial_model(adapter, tmp_path: Path) -> None: + model_yaml = tmp_path / "model.yaml" + model_yaml.write_text("""name: m +datasets: + - name: orders + source: orders + primary_key: [id] + fields: + - name: id + expression: id + dimension: {} + - name: category + expression: category + dimension: {} + - name: amount + expression: amount +metrics: + - name: total + expression: SUM(amount) +""") + query_json = tmp_path / "query.json" + query_json.write_text( + '{"dataset": "orders", "dimensions": ["category"], ' + '"measures": [{"name": "total", "metric": "total"}]}' + ) + + rc = adapter.main( + [ + "sql", + "--model", + str(model_yaml), + "--query-file", + str(query_json), + "--dialect", + "duckdb", + ] + ) + assert rc == 0 diff --git a/impl/python/docs/ALGEBRA_LAWS.md b/impl/python/docs/ALGEBRA_LAWS.md new file mode 100644 index 0000000..03341ca --- /dev/null +++ b/impl/python/docs/ALGEBRA_LAWS.md @@ -0,0 +1,358 @@ +# ALGEBRA_LAWS.md — Machine-Checked Correctness + +Companion to [`JOIN_ALGEBRA.md`](JOIN_ALGEBRA.md). That +document states the laws informally; this document states them as +Python-executable property tests, specifies the Hypothesis strategies used +to generate test data, and lists the mutation-testing budget that guards +each law against silent regressions. + +> **Why machine-check the algebra?** The algebra is the one place in the +> compiler where a silent bug is invisible: an incorrect operator could +> return valid-looking states that produce plausible-but-wrong SQL. Every +> other layer depends on the algebra being correct; no downstream test +> can catch a violation of, say, the grain-closure law. The only reliable +> defense is to generate lots of inputs and check laws directly. + +--- + +## Table of Contents + +1. [Generation Strategies](#1-generation-strategies) +2. [The Laws (and their tests)](#2-the-laws-and-their-tests) +3. [Reference Interpreter](#3-reference-interpreter) +4. [Mutation-Testing Budget](#4-mutation-testing-budget) +5. [When a Property Test Fails](#5-when-a-property-test-fails) +6. [Adding a New Law](#6-adding-a-new-law) + +--- + +## 1. Generation Strategies + +All strategies live in `tests/properties/strategies.py` and are deliberately +minimal — enough to exercise the algebra without drifting into scenarios +the Foundation does not support. + +### 1.1 `identifiers()` + +Generates normalized identifiers: + +```python +identifiers = st.from_regex(r"^[a-z][a-z0-9_]{0,15}$", fullmatch=True) +``` + +Never generates reserved words or quoted identifiers (those are out of +scope for the algebra tests; they live in parser tests). + +### 1.2 `schemas()` + +Generates a small, valid `SemanticModel` fixture: 1–4 datasets, 0–6 +relationships drawn from valid equijoin pairings, every dataset has a +declared `primary_key`. Specifically excludes deferred features (see `Proposed_OSI_Semantics.md §10`). + +### 1.3 `states()` + +Generates `CalculationState` values **by construction through the algebra** +— never by direct instantiation. A generated state is a sequence of +operator applications starting from `source(...)`, which guarantees every +generated state satisfies the invariants `I-1` through `I-8`. + +### 1.4 `operator_chains(state)` + +Given a starting state, generates a valid sequence of operator applications +(each operator's precondition is satisfied by construction). The chain +length is parameterized, with `max_examples=200` as the test default. + +### 1.5 `duckdb_fixtures()` + +Generates small DuckDB in-memory tables matching a generated schema. Used +by the end-to-end laws (§2.9, §2.10) that need to compare OSI-generated +SQL to a reference result. + +--- + +## 2. The Laws (and their tests) + +Each law has: + +- **Statement** — the informal law from `JOIN_ALGEBRA.md §4` +- **Property** — the Python-level assertion +- **Test file** — location under `tests/properties/` +- **Mutation target** — module in `src/osi/` where a mutation would break this law + +### 2.1 Totality (`JOIN_ALGEBRA.md §4.1`) + +**Statement.** Every operator either returns a new valid state or raises +`AlgebraError`. No `None`, no partial returns. + +**Property.** + +```python +@given(op=operator_arguments()) +def test_totality(op: OperatorArgs) -> None: + try: + new_state = apply(op) + except AlgebraError as e: + assert e.code.startswith("E4"), "algebra errors must be E4xxx" + return + assert isinstance(new_state, CalculationState) + for invariant in ALL_INVARIANTS: + invariant.check(new_state) +``` + +**Test file.** `tests/properties/test_algebra_totality.py` +**Mutation target.** `src/osi/planning/algebra/operations.py` + +### 2.2 Purity (`§4.2`) + +**Property.** For any operator and any input, `apply(op)` called twice +returns equal results and leaves inputs unchanged. + +```python +@given(op=operator_arguments()) +def test_purity(op: OperatorArgs) -> None: + snapshot_before = deep_copy(op.inputs) + result_1 = apply(op) + result_2 = apply(op) + assert result_1 == result_2 + assert op.inputs == snapshot_before + assert_frozen(result_1) +``` + +**Test file.** `tests/properties/test_algebra_purity.py` +**Mutation target.** `src/osi/planning/algebra/` (whole package) + +### 2.3 Determinism (`§4.3`) + +**Property.** Rendering a plan to SQL is byte-identical across runs. + +```python +@given(query=semantic_queries(), model=schemas()) +def test_sql_byte_identical(query, model) -> None: + plan_a = plan(query, build_context(model)) + plan_b = plan(query, build_context(model)) + assert plan_a == plan_b + sql_a = render(plan_a, dialect="duckdb") + sql_b = render(plan_b, dialect="duckdb") + assert sql_a == sql_b +``` + +**Test file.** `tests/properties/test_sql_determinism.py` +**Mutation targets.** `src/osi/planning/prefixes.py`, `src/osi/codegen/transpiler.py` + +### 2.4 Grain Closure (`§4.4`) + +**Property.** The final grain of an operator chain can be computed +symbolically from the operator-argument sequence alone. + +```python +@given(chain=operator_chains(min_size=1, max_size=12)) +def test_grain_closure(chain) -> None: + symbolic_grain = simulate_grain(chain) # pure function over op args + actual_grain = execute_chain(chain).grain + assert symbolic_grain == actual_grain +``` + +**Test file.** `tests/properties/test_grain_closure.py` +**Mutation target.** `src/osi/planning/algebra/grain.py` + +### 2.5 Aggregate Idempotence (`§4.5`) + +**Property.** Re-aggregating at the same grain with identity aggregations +is a no-op. + +```python +@given(state=states_with_aggregates()) +def test_reaggregate_same_grain_identity(state) -> None: + identity_aggs = identity_reaggregations(state) + out = aggregate(state, state.grain, identity_aggs) + assert equivalent(out, state) +``` + +**Test file.** `tests/properties/test_aggregate_idempotent.py` +**Mutation target.** `src/osi/planning/algebra/operations.py::aggregate` + +### 2.6 Filter Commutativity (`§4.6`) + +**Property.** `filter(filter(s, p1), p2)` is equivalent to +`filter(filter(s, p2), p1)`, and both are equivalent to +`filter(s, And(p1, p2))` for non-overlapping predicates. + +**Test file.** `tests/properties/test_filter_commute.py` +**Mutation target.** `src/osi/planning/algebra/operations.py::filter` + +### 2.7 Merge Associativity (`§4.7`) + +**Property.** `merge(merge(a, b), c) ≡ merge(a, merge(b, c))` at equal +grains with disjoint non-grain columns. + +**Test file.** `tests/properties/test_merge_associative.py` +**Mutation target.** `src/osi/planning/algebra/operations.py::merge` + +### 2.8 Projection Idempotence (`§4.8`) + +**Property.** `project(project(s, c1), c2) ≡ project(s, c2)` when `c2 ⊆ c1`. + +**Test file.** `tests/properties/test_project_idempotent.py` +**Mutation target.** `src/osi/planning/algebra/operations.py::project` + +### 2.9 Enrichment Preserves Parent Rows (`§4.9`) + +**Property.** DuckDB-executed. After `enrich(parent, child, keys, LEFT)`, +the projection onto `parent.grain` has the same row multiset as the parent. + +```python +@given(fixture=duckdb_fixtures_with_n1_join()) +def test_enrich_preserves_rows(fixture) -> None: + parent_rows = duckdb.execute(fixture.parent_sql).fetchall() + enriched_sql = render(enrich_plan(fixture)) + enriched_rows = duckdb.execute(enriched_sql).fetchall() + assert count_by(parent_rows, fixture.parent_grain) == count_by( + enriched_rows, fixture.parent_grain + ) +``` + +**Test file.** `tests/properties/test_enrich_preserves_rows.py` +**Mutation target.** `src/osi/planning/algebra/operations.py::enrich` + +### 2.10 Explosion Safety (`§4.10`) + +**Property.** For any generated schema with 1:N joins and an aggregation +on the many-side, the OSI result matches a hand-rolled pre-aggregate +reference. + +```python +@given(fixture=duckdb_fixtures_with_1n_topology()) +def test_no_fan_out(fixture) -> None: + osi_result = run_osi(fixture) + reference_result = run_pre_aggregate_reference(fixture) + assert rows_equal(osi_result, reference_result) +``` + +**Test file.** `tests/properties/test_explosion_safety.py` +**Mutation target.** `src/osi/planning/classify.py`, `src/osi/planning/joins.py`, `src/osi/planning/algebra/operations.py::aggregate` + +### 2.11 Chasm-Trap Safety + +**Property.** For any generated schema with two facts sharing a dimension, +the OSI result equals two independent aggregations merged on the shared +dimension. + +**Test file.** `tests/properties/test_chasm_safety.py` +**Mutation targets.** `src/osi/planning/planner.py`, `src/osi/planning/algebra/operations.py::merge` + +### 2.12 M:N Rejection + +**Property.** For any relationship declared or inferred as N:N, using it +as input to `enrich` raises `E3011`; using it with `filtering_join` succeeds. + +**Test file.** `tests/properties/test_mn_rejection.py` +**Mutation target.** `src/osi/planning/algebra/operations.py::enrich` + +### 2.13 Error Taxonomy + +**Property.** Every raised exception is an `OSIError` subclass with a +non-empty `code` field matching a known `ErrorCode` enum value. + +```python +@given(model=possibly_invalid_schemas(), query=possibly_invalid_queries()) +def test_error_taxonomy(model, query) -> None: + try: + plan(query, build_context(model)) + except OSIError as e: + assert e.code in ErrorCode, f"unknown error code {e.code}" + except Exception as e: + pytest.fail(f"non-OSIError raised: {type(e).__name__}: {e}") +``` + +**Test file.** `tests/properties/test_error_taxonomy.py` +**Mutation target.** `src/osi/errors.py` + +--- + +## 3. Reference Interpreter + +A few of the laws (§2.9, §2.10, §2.11) compare OSI-generated SQL to a +**reference interpreter**: a deliberately naive Python/pandas +implementation of the Foundation semantics. + +The reference interpreter lives in `tests/properties/reference.py` and +is not used outside of tests. It: + +- Reads DuckDB tables directly into `pandas.DataFrame`s +- Implements each algebra operator as a pure pandas transformation +- Is optimized for clarity, not speed (it may be 1000× slower than the + compiled SQL — that's fine for tests) + +Tests assert that `osi_python → SQL → DuckDB → rows` equals +`osi_python → plan → reference_interpreter → rows`. Divergence is a bug +in the compiler, because the reference interpreter IS the semantic truth +for those laws. + +--- + +## 4. Mutation-Testing Budget + +Mutation testing (`mutmut` or `cosmic-ray`, see `INFRA.md §2`) injects +small mutations into the source (changing `<=` to `<`, swapping +`True`/`False`, removing `not`, etc.) and checks whether any test catches +the change. A mutation that survives every test is a test-coverage hole. + +### 4.1 Per-Module Thresholds + +| Module | Mutation score target | Rationale | +|:---|:---:|:---| +| `src/osi/planning/algebra/` | **≥ 90%** | The algebra is the hard boundary. Silent bugs here are invisible to every other test. | +| `src/osi/planning/classify.py` | ≥ 85% | Filter classification decides fan-out-vs-semi-join; wrong decisions produce wrong SQL. | +| `src/osi/planning/joins.py` | ≥ 85% | Path resolution and cardinality inference. | +| `src/osi/codegen/` | ≥ 75% | Dialect-specific; harder to get high scores because of idiom branches. | +| `src/osi/parsing/` | ≥ 80% | Validation errors matter but are syntactic. | +| Project overall | ≥ 75% | Absolute floor. | + +### 4.2 How Thresholds Are Enforced + +- Baseline captured at each release; CI asserts no per-module score drops + more than **2%** between runs. +- A sprint that lowers the baseline without an explicit "mutation debt" + note in `INFRA.md §3` is a failure. + +### 4.3 Running Locally + +```bash +make mutation # full project, slow (~30 min) +make mutation-fast # algebra module only, typical dev loop (~5 min) +``` + +--- + +## 5. When a Property Test Fails + +Property tests shrink failing cases to a minimal counterexample. The +standard debugging procedure: + +1. Re-run with `--hypothesis-seed=` (printed at failure) to confirm + reproducibility. +2. Copy the minimal counterexample into a new regression test under + `tests/unit/` — property tests shrink, but the exact shrunk case may + change between runs. +3. Fix the bug. Keep the regression test. +4. Re-run the original property test; it should now pass on the seed. + +Never mark a property test as `@skip` to go green. If the property is +incorrect as stated, fix `JOIN_ALGEBRA.md` first, then the test. + +--- + +## 6. Adding a New Law + +1. State the law in `JOIN_ALGEBRA.md §4`. +2. Reference it from this doc (`docs/ALGEBRA_LAWS.md §2`) with: + - Property statement + - Test file path + - Mutation target module +3. Add the property test under `tests/properties/`. +4. Ensure the mutation score for the target module still meets the + threshold in §4.1 — if not, add more tests or unit cases. +5. Cite the new law in the PR description. + +A law that does not have all three pieces (spec, property test, mutation +coverage) is not load-bearing and does not belong in this document. diff --git a/impl/python/docs/ERRATA_ALIGNMENT.md b/impl/python/docs/ERRATA_ALIGNMENT.md new file mode 100644 index 0000000..dc950a7 --- /dev/null +++ b/impl/python/docs/ERRATA_ALIGNMENT.md @@ -0,0 +1,105 @@ +# ERRATA_ALIGNMENT.md — How `osi_python` Handles Known BI-Engine Surprises + +The Foundation (see `../../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md §12`) was +explicitly designed to be free of the surprising behaviors catalogued +in Snowflake Semantic Views' `ERRATA.md`. This document: + +1. Lists each errata item that is relevant to our Foundation. +2. States our compliance target: **resolved**, **deferred**, or **inherited**. +3. Points to the test(s) that prove the outcome. + +Use this as the seed catalog when writing new test scenarios — every +errata item should either have at least one test that exercises it or +a documented reason it doesn't apply. + +--- + +## Legend + +- **Resolved.** The Foundation spec defines the correct behavior and + `osi_python` implements it; the surprising Snowflake behavior does not + occur. +- **Deferred.** The surprising behavior arises from a feature that is + out of scope for the Foundation; we are not affected because we don't + support that feature yet. +- **Inherited.** The behavior is a property of the underlying SQL dialect + and `osi_python` surfaces it rather than papering over it. Documented + for caller awareness. + +--- + +## Catalog + +References to errata numbers match `snowflake-prod-docs/tests/semantic-views/ERRATA.md` +as summarized in `Proposed_OSI_Semantics.md §12.2` / `§12.3`. + +### Cardinality & aggregation + +| # | Summary | Disposition | Spec anchor | Test | +|:--|:---|:---|:---|:---| +| 1 | Different aggregation granularities in a single SELECT. | **Resolved** — all measures in one query resolve at the query grain; bare-view cross-grain aggregates raise `E1209`. | `Proposed_OSI_Semantics.md §5.2`, future SQL_INTERFACE proposal §6.3 | `tests/e2e/test_single_grain_per_query.py` | +| 2 | Dimension-only queries produce different cardinalities on different interfaces. | **Resolved** — both the clause form and bare view apply `DISTINCT(Dimensions)`. | `Proposed_OSI_Semantics.md §5.2`, future SQL_INTERFACE proposal §5.4 | `tests/properties/test_dimension_only_cardinality.py` | +| 3 | Implicit `DISTINCT` behavior divergence. | **Resolved** — same rule as #2; explicit projection, explicit aggregation. | `Proposed_OSI_Semantics.md §5`, future SQL_INTERFACE proposal §5.4 | `tests/golden/basic/dimension_only/` | +| 4 | `COUNT(*)` not supported. | **Resolved** — `COUNT(*)` is REQUIRED; ambiguous usage raises `E1212`. | `Proposed_OSI_Semantics.md §7`, future SQL_INTERFACE proposal §5.2, §6.2 | `tests/unit/planning/test_count_star.py` | +| 5 | `SELECT *` on a bare-view query fails with metadata-internals error. | **Resolved** — `SELECT *` is rejected with a clear `E1208` pointing to `DIMENSIONS dataset.*`. | Future SQL_INTERFACE proposal §6.1 | `tests/unit/sql/test_reject_select_star.py` | +| 8 | `FACTS` + `METRICS` cannot coexist. | **Resolved** — kept as `E1207` with an explanatory message. | Future SQL_INTERFACE proposal §5.3 | `tests/unit/sql/test_facts_metrics_exclusive.py` | +| 9 | Inner `LIMIT` is a cryptic syntax error. | **Resolved** — `E1211` with an outer-`LIMIT` suggestion. | Future SQL_INTERFACE proposal §3.2 | `tests/unit/sql/test_clause_only_outer.py` | +| 10 | Outer `WHERE` must use result aliases, not semantic names. | **Resolved** — formalised as output-column scoping rule. | Future SQL_INTERFACE proposal §4.3 | `tests/unit/sql/test_outer_scope.py` | + +### Naming & ambiguity + +| # | Summary | Disposition | Spec anchor | Test | +|:--|:---|:---|:---|:---| +| 16 | Same-named expressions across datasets unreachable in standard SQL. | **Resolved** — `dataset.field` qualification MUST be accepted; duplicate unqualified output columns raise `E1205`; ambiguous bare references raise `E1204`. | `Proposed_OSI_Semantics.md §4.7`, future SQL_INTERFACE proposal §4.1, §4.2 | `tests/unit/parsing/test_namespace_collisions.py`, `tests/unit/sql/test_duplicate_output_columns.py` | +| 17 | Same-named metrics across tables hit the same trap. | **Resolved** — same rule as #16; aliasing required. | Future SQL_INTERFACE proposal §4.2 | `tests/unit/sql/test_duplicate_metric_names.py` | + +### Window functions + +| # | Summary | Disposition | Spec anchor | +|:--|:---|:---|:---| +| 18–23 | Window-function complexities (`QUALIFY`, framing, cardinality with OVER). | **Deferred** — windows are not in the Foundation. | `Proposed_OSI_Semantics.md §10` | + +Window-related tests do not exist in `osi_python`. Attempts to use window +functions in a metric expression raise `E1105 RESERVED_FOR_DEFERRED`. + +### Grain / filter features + +| # | Summary | Disposition | Spec anchor | +|:--|:---|:---|:---| +| 6–10 | FIXED / INCLUDE / EXCLUDE edge cases. | **Deferred** — LOD is not in the Foundation. | `Proposed_OSI_Semantics.md §10` | +| 11–13 | Filter context propagation surprises. | **Deferred** — filter context is not in the Foundation. | `Proposed_OSI_Semantics.md §10` | + +Metric and query definitions that reference these features raise `E1105` +at parse time. + +### Join & relationship behavior + +| # | Summary | Disposition | Spec anchor | Test | +|:--|:---|:---|:---|:---| +| 14 | Ambiguous join path silently picks one. | **Resolved** — ambiguous paths raise `E3001` unless `using_relationships` disambiguates. | `Proposed_OSI_Semantics.md §6.6` | `tests/unit/planning/test_ambiguous_path.py` | +| 15 | Fan-out produces silently wrong sums. | **Resolved** — algebra refuses unsafe aggregations, raises `E4001`. | `JOIN_ALGEBRA.md §5.1`, `JOIN_SAFETY.md` | `tests/properties/test_explosion_safety.py` | +| 24 | Chasm trap: two facts joined through a shared dim double-count. | **Resolved** — planner decomposes to per-fact states + `merge` at shared grain; otherwise raises `E3010`. | `JOIN_ALGEBRA.md §5.2` | `tests/properties/test_chasm_safety.py`, `tests/e2e/test_chasm_trap.py` | + +### Dialect-inherited behaviors + +| # | Summary | Disposition | Spec anchor | Notes | +|:--|:---|:---|:---|:---| +| 25 | NULL handling in `NOT IN` subquery on nullable column. | **Inherited** — Foundation uses `EXISTS_IN` / `NOT EXISTS_IN` which are NULL-safe by construction, but if a caller drops to raw SQL via `WHERE` string, dialect NULL semantics apply. | `Proposed_OSI_Semantics.md §7` | Document in caller guide. | + +--- + +## Adding a New Errata Item + +When an upstream BI engine publishes a new surprising-behavior item, or +we discover one on our own: + +1. Add a row in the appropriate section with the summary and disposition. +2. If the disposition is **Resolved**, add at least one test and link it. +3. If the disposition is **Deferred**, confirm that `E1105 RESERVED_FOR_DEFERRED` + fires for models/queries that trigger the feature. +4. If the disposition is **Inherited**, capture the note locally (the SQL_Caller_Examples surface will land with the future SQL_INTERFACE proposal) + so callers are aware. + +An errata item we cannot map to one of the three dispositions is a +**hole in the Foundation**, not an implementation bug. Escalate to a +spec update before writing code. diff --git a/impl/python/docs/ERROR_CODES.md b/impl/python/docs/ERROR_CODES.md new file mode 100644 index 0000000..eacf384 --- /dev/null +++ b/impl/python/docs/ERROR_CODES.md @@ -0,0 +1,202 @@ +# Error Codes + +> **Authoritative catalog: Appendix C of +> [`specs/Proposed_OSI_Semantics.md`](../specs/Proposed_OSI_Semantics.md).** +> This document is the implementation-side mirror — it documents the +> Python `ErrorCode` enum members in `src/osi/errors.py` and how they +> map to Appendix-C codes. When the two disagree, Appendix C wins and +> this document and `errors.py` are updated to match in the same PR. +> +> **Migration note.** The legacy +> `E10xx` / `E11xx` / `E2xxx` / `E3xxx` numeric codes below have been +> partially renamed to the `E_*` family from Appendix C. For example, `E1105 +> RESERVED_FOR_DEFERRED` becomes `E_DEFERRED_KEY_REJECTED`; `E2001 +> AMBIGUOUS_NAME` becomes `E_NAME_COLLISION` (model namespace) / +> `E_AMBIGUOUS_PATH` (relationship traversal); `E2002 NAME_NOT_FOUND` +> becomes `E_NAME_NOT_FOUND`; `E3001 AMBIGUOUS_JOIN_PATH` becomes +> `E_AMBIGUOUS_PATH`. The `E3011 / E3012 / E3013` M:N family is kept +> in numeric form (Appendix C names them `E3012_MN_NO_SAFE_REWRITE` +> and `E3013_NO_STITCHING_DIMENSION`). Some legacy numeric spellings +> remain in `src/osi/errors.py`; per the cleanliness clause in `SPEC.md` +> the old spellings should be deleted outright (no aliases) when the +> remaining callsites are updated. + +Every error raised by `osi_python` is an `OSIError` subclass carrying a +stable `ErrorCode`. This file is the catalog; `src/osi/errors.py` is the +source of truth for Python identifiers. + +**Tests must assert on `error.code`, never on message text.** The text +evolves; the codes do not. + +## Ranges + +| Range | Layer | Kind | +|:---|:---|:---| +| `E10xx`–`E11xx` | Parsing | YAML syntax, missing fields, type mismatches, use of deferred features. | +| `E12xx` | Parsing (SQL surface) | `SEMANTIC_VIEW(...)` clause and bare-view SQL grammar / resolution errors. RESERVED — lands with the future SQL_INTERFACE proposal §8. | +| `E2xxx` | Validation | Cross-reference and semantic-rule violations in the model. | +| `E3xxx` | Planning | Grain conflicts, unreachable fields, path ambiguity, chasm traps. | +| `E4xxx` | Algebra | Safety violations (explosion-unsafe aggregations, M:N enrich). | +| `E5xxx` | Codegen | Rendering failures, unsupported-by-dialect. | +| `W6xxx` | Warnings | Non-fatal; analytically suspect patterns. Elevated to errors in strict mode. | + +Tests under `tests/properties/test_error_taxonomy.py` assert that every +exception raised anywhere in the compiler is an `OSIError` with a code +from this catalog. + +**Status legend:** each code below is either **active** (raised somewhere +in `src/osi/`) or **RESERVED**. A RESERVED code is stable — tooling may +pin to it — but the emit path belongs to a deferred feature +(`SEMANTIC_VIEW` SQL surface, M:N stitch paths, strict-mode warnings, +etc.). `tests/unit/test_error_catalog.py` enforces that every RESERVED +annotation here matches the enum in `src/osi/errors.py`. + +--- + +## `E1xxx` — Parse errors + +| Code | Status | Name | Meaning | +|:---|:---:|:---|:---| +| `E1001` | active | `YAML_SYNTAX` | YAML syntax error. | +| `E1002` | active | `MISSING_REQUIRED_FIELD` | Required field absent in YAML. | +| `E1003` | active | `INVALID_ENUM_VALUE` | Enum value not recognized. | +| `E1004` | active | `TYPE_MISMATCH` | Field type does not match schema. | +| `E1005` | active | `IDENTIFIER_INVALID` | Identifier does not conform to the grammar in `Proposed_OSI_Semantics.md` §4 ("Identifiers"). | +| `E1006` | active | `SQL_EXPRESSION_SYNTAX` | Inline SQL expression inside a YAML field fails to parse as a SQLGlot AST. | +| `E_DEFERRED_KEY_REJECTED` | active | `DEFERRED_KEY_REJECTED` | Feature exists in the full OSI spec but is deferred from Foundation v0.1. Fired for `EXISTS_IN`, `referential_integrity`, named filters, per-metric `joins.{type, using_relationships}`, FIXED/INCLUDE/EXCLUDE, filter context, windows, pivot, grouping sets, non-equijoins, `ATTR`/`UNSAFE`/`AGG`/`GRAIN_AGG`. See `Proposed_OSI_Semantics.md §10`. Replaces the legacy `E1105` (S-1). | +| `E_MIXED_QUERY_SHAPE` | active | `MIXED_QUERY_SHAPE` | Query mixes the aggregation shape (`Dimensions` / `Measures`) with the scalar shape (`Fields`). Foundation v0.1 routes per query into exactly one shape — see `Proposed_OSI_Semantics.md` D-010. (S-2) | +| `E_AGGREGATE_IN_SCALAR_QUERY` | active | `AGGREGATE_IN_SCALAR_QUERY` | A `Fields` entry resolves to an aggregate at the home grain, which is rejected because scalar queries do not collapse rows. See D-011. (S-2) | +| `E_EMPTY_AGGREGATION_QUERY` | active | `EMPTY_AGGREGATION_QUERY` | Aggregation query has neither `Dimensions` nor `Measures`. See D-010. (S-2) | +| `E_EMPTY_SCALAR_QUERY` | active | `EMPTY_SCALAR_QUERY` | Scalar query has no `Fields`. See D-011. (S-2) | +| `E_FAN_OUT_IN_SCALAR_QUERY` | active | `FAN_OUT_IN_SCALAR_QUERY` | A `Fields` entry traverses a one-to-many edge (fan-out), which a scalar query cannot resolve without aggregation. See D-023. (S-2) | +| `E_AGGREGATE_IN_WHERE` | active | `AGGREGATE_IN_WHERE` | `Where` predicate contains an aggregate (raw aggregate function or measure reference). Aggregates evaluate post-`GROUP BY`; move the predicate to `Having`. See D-012a. Replaces the legacy `E3009` for this shape. (S-3) | +| `E_NON_AGGREGATE_IN_HAVING` | active | `NON_AGGREGATE_IN_HAVING` | `Having` predicate is purely row-level (no aggregate). Push it down to `Where`. See D-012b. Replaces the legacy `E3009` for this shape. (S-3) | +| `E_MIXED_PREDICATE_LEVEL` | active | `MIXED_PREDICATE_LEVEL` | Boolean predicate mixes aggregate and non-aggregate halves in one expression tree. Split into separate `Where` / `Having` clauses so the engine can route each half. See D-012c. (S-3) | +| `E_UNAGGREGATED_FINER_GRAIN_REFERENCE` | active | `UNAGGREGATED_FINER_GRAIN_REFERENCE` | A field body references a column from a finer-grain dataset without aggregating it (e.g. `customers.first_order_amount: orders.amount`). Wrap the reference in an aggregate (`SUM(orders.amount)`, …) so the implicit home-grain aggregation can resolve. See D-024. (S-5) | +| `E_UNSAFE_REAGGREGATION` | active | `UNSAFE_REAGGREGATION` | The chosen plan forces a multi-stage decomposition the aggregate cannot survive — typically a holistic aggregate (`MEDIAN`, `PERCENTILE_CONT`) over a §6.7 chasm pre-aggregation or a §6.8.2 stitch. Note: the §6.8.1 bridge plan is **not** in this family — it is a single-pass aggregate over the de-duplicated `(measure-home-row, group-key)` row set, and is accepted bare for every aggregate category per D-027 (`AVG`, `MEDIAN`, and `COUNT(DISTINCT)` over an N:N bridge are all accepted). The fix is either (a) switch to a distributive aggregate, (b) restate at a coarser grain that does not require chasm pre-aggregation, or (c) for M:N references, rely on the bridge plan. See D-022. (S-9) | +| `E_AMBIGUOUS_NESTED_AGGREGATION_GRAIN` | RESERVED | `AMBIGUOUS_NESTED_AGGREGATION_GRAIN` | RESERVED — superseded by `E_NESTED_AGGREGATION_DEFERRED`. The Foundation defers all nested aggregation in metric expressions, so the inner-grain ambiguity this code described is moot today. Retained so external tooling that pinned to it does not break. See D-027. | +| `E_NESTED_AGGREGATION_DEFERRED` | active | `NESTED_AGGREGATION_DEFERRED` | A metric expression contains a nested aggregate (`AVG(COUNT(orders.oid))`, `AVG(AVG(orders.amount))`, …). The Foundation defers all nested aggregation to §10's grain-aware-functions proposal because the construct requires an implicit grain pin on the inner aggregate that §10 will settle explicitly. For distributive aggregates (`SUM`, `COUNT`, `MIN`, `MAX`) the single-step form gives identical numbers; write `SUM(orders.amount)` instead of `SUM(SUM(orders.amount))`. Engines MAY opt back into the legacy two-step planner via the `allow_nested_aggregation` feature flag. See D-027. | +| `E_AGGREGATE_IN_FIELD` | active | `AGGREGATE_IN_FIELD` | A field expression contains an aggregate function (`SUM`, `COUNT`, `AVG`, …), whether over the home dataset's own columns or cross-grain via a `1:N` reach. All aggregates live in model-scoped metrics (top-level `metrics:` section); field expressions are non-aggregate by construction (window functions remain allowed because they are not aggregates in the spec sense). Move the aggregate to a top-level metric and reference it from `Measures`. Engines MAY opt back into the legacy implicit-home-grain field rewrite via the `allow_aggregate_in_field` feature flag. See D-003. | +| `E_FIELD_DEPENDENCY_CYCLE` | active | `FIELD_DEPENDENCY_CYCLE` | Two or more fields on the same dataset reference one another in a cycle (e.g. `a` depends on `b` which depends back on `a`). The planner lowers derived fields into a topologically ordered chain of `ADD_COLUMNS` CTE stages so the emitted SQL is portable across dialects (Snowflake, PostgreSQL, and SQLite reject lateral aliasing within a single `SELECT`). A cyclic dependency cannot be lowered to a finite stage count and is therefore rejected at parse time. Break the cycle by promoting the shared sub-expression to a single field that the others depend on, or by inlining one of the bodies. (Spec: §4.3.) | +| `E_NAME_NOT_FOUND` | active | `NAME_NOT_FOUND` | Identifier (dataset / field / metric / parameter) does not exist in the namespace. Replaces `E2002` for user-facing diagnostics. See D-006. (S-10) | +| `E_NAME_COLLISION` | active | `NAME_COLLISION` | Bare identifier resolves to more than one declared entity (two datasets define the same field name; two metrics share a name). Replaces `E2001`. See D-006. (S-10) | +| `E_AMBIGUOUS_PATH` | active | `AMBIGUOUS_PATH` | Two or more relationship paths reach the same dataset; the planner can't pick one. Disambiguate by tightening the model. Replaces `E3001` for the user surface. See D-018. (S-10) | +| `E_NO_PATH` | active | `NO_PATH` | No relationship-graph path connects the two datasets the query needs to join. Replaces `E2004` and `E3013` for the user surface. See D-018. (S-10) | +| `E_RESERVED_IDENTIFIER` | active | `RESERVED_IDENTIFIER` | User declared an identifier that collides with a reserved Foundation keyword (`GRAIN`, `FILTER`, `QUERY_FILTER`, …). See D-019. (S-10) | +| `E_RESERVED_NAME` | active | `RESERVED_NAME` | User declared a field, dataset, or metric whose name matches a reserved SQL keyword (`SELECT`, `FROM`, `WHERE`, …). The Foundation forbids these because the generated SQL would be ambiguous in some dialects. See D-019. (S-10) | +| `E_WINDOW_IN_WHERE` | active | `WINDOW_IN_WHERE` | A `WHERE` predicate contains a window function. Windows are only allowed in `Measures` / `Fields` / `Order By` / `Having`. See D-028(b). | +| `E_NESTED_WINDOW` | active | `NESTED_WINDOW` | A window function's argument or frame contains another window function (`SUM(SUM(x) OVER ...) OVER ...`). The Foundation rejects nested windows because the outer grain is structurally ambiguous. See D-031. (S-12) | +| `E_WINDOWED_METRIC_COMPOSITION` | active | `WINDOWED_METRIC_COMPOSITION` | A composite metric references a windowed metric. Composing arithmetic on top of a window changes the grain non-uniformly. Wrap the window in an aggregating CTE first if you need to compose. See D-031. (S-12) | +| `E_DEFERRED_FRAME_MODE` | active | `DEFERRED_FRAME_MODE` | A window uses a frame mode (`GROUPS`) or bound (parameterised expressions like `:n PRECEDING`) that is not in Foundation v0.1. Only literal `ROWS` and `RANGE` frames with constant bounds are accepted. See D-032. (S-12) | +| `E_WINDOW_OVER_FANOUT_REWRITE` | RESERVED | `WINDOW_OVER_FANOUT_REWRITE` | A window function would be evaluated over a fan-out join (the partition key includes a duplicated row from a 1:N enrichment). The planner cannot rewrite to a pre-fan-out CTE in this case. See D-030. Engine note: foreclosed by earlier gates — the scalar planner rejects 1:N edges with `E_FAN_OUT_IN_SCALAR_QUERY` (D-023) before the window step, and the aggregation planner rejects windowed measures at parse with `E_WINDOWED_METRIC_COMPOSITION`. The code stays reserved for the future surface where windowed measures land in the aggregation branch (`INFRA.md` I-43). Compliance test `t-052-window-over-fanout-foreclosed` pins the current foreclose-before-window behaviour. (S-12) | +| `E_WINDOWED_MEASURE_NOT_SUPPORTED` | active | `WINDOWED_MEASURE_NOT_SUPPORTED` | A metric whose body is a window expression was used in an aggregation query's `Measures` slot. Foundation v0.1 §6.10 / D-031 accepts direct use of a windowed metric in `Measures`, but this reference engine's aggregation planner does not yet implement the composition of windowed measures with `GROUP BY`. Workarounds: use a scalar (Fields-only) query, or replace the window with a plain aggregate. (Implementation extension — F-16; promote to Appendix C if the engine implements this surface.) | +| `E_UNKNOWN_FUNCTION` | active | `UNKNOWN_FUNCTION` | A function call references a name not in the OSI_SQL_2026 catalog. The whitelist and validator live in `osi.parsing.function_whitelist`; vendor-specific functions must go through the per-dialect `dialects:` block. See D-021. | +| `E_AMBIGUOUS_MEASURE_GRAIN` | RESERVED | `AMBIGUOUS_MEASURE_GRAIN` | Catch-all when a measure has multiple incompatible starting grains and none of the more-specific codes (`E3012`, `E3013`, `E_UNSAFE_REAGGREGATION`) applies. The diagnostic MUST list the starting grains the engine identified. The reference implementation reaches one of the specific codes today, so this code is reserved for engines that synthesise different plan choices. (Appendix C / D-025.) | +| `E_PRIMARY_KEY_REQUIRED` | RESERVED | `PRIMARY_KEY_REQUIRED` | Engines MAY require `primary_key` declarations on every dataset (so the table grain is well-defined). The reference implementation does not impose this requirement today, but the code is reserved so an opt-in deployment can raise it under a stable name. (Appendix C / §4.2.) | +| `E_INVALID_NATURAL_GRAIN` | RESERVED | `INVALID_NATURAL_GRAIN` | Raised by a future `natural_grain` implementation (currently deferred to a future proposal). The Foundation parser rejects the `natural_grain` key through `E_DEFERRED_KEY_REJECTED` today. See `proposals/foundation-v0.1/Proposed_OSI_Semantics.md` §10. | +| `E_NATURAL_GRAIN_PRE_AGGREGATION_UNSAFE` | RESERVED | `NATURAL_GRAIN_PRE_AGGREGATION_UNSAFE` | Sibling of `E_INVALID_NATURAL_GRAIN` for the unsafe pre-aggregation case. Reserved until natural-grain lands. | +| `E_INTERNAL_INVARIANT` | active | `INTERNAL_INVARIANT` | Implementation extension — the IR or a diagnostic detected a programmer error (e.g. a `QueryPlan` whose steps reference an unplanned input, an unhandled `PlanPayload` subclass in `_payload_to_json`, an unhandled `ResolvedReference` subclass in `_reference_entry`). The shape of the error means "the compiler invariants are out of sync; ship a fix" rather than "your model is wrong". Kept inside the typed `OSIError` hierarchy so the property test "every failure carries a code" still holds for these paths. Not in Appendix C. | + +## `E12xx` — SQL-surface errors + +Raised by the SQL-interface parser defined in +the future SQL_INTERFACE proposal. Every code here +fires *before* planning — a malformed SQL query never reaches the +algebra. + +| Code | Status | Name | Meaning | +|:---|:---:|:---|:---| +| `E1201` | RESERVED | `SEMANTIC_VIEW_EMPTY` | `SEMANTIC_VIEW(sv)` call has no `DIMENSIONS`, `FACTS`, or `METRICS` clause. | +| `E1202` | RESERVED | `CLAUSE_ORDER` | Clauses inside `SEMANTIC_VIEW(...)` appear in the wrong order (expected `DIMENSIONS → FACTS → METRICS → WHERE`). | +| `E1203` | RESERVED | `REFERENCE_TOO_DEEP` | Three-part reference (`schema.sv.col`) used in Foundation; only bare and `dataset.field` forms are supported. | +| `E1204` | RESERVED | `AMBIGUOUS_BARE_REFERENCE` | Bare name resolves to multiple datasets; qualify with `dataset.name`. | +| `E1205` | RESERVED | `DUPLICATE_OUTPUT_COLUMN` | Output row set would contain two columns with the same name; use an explicit `AS alias` on at least one. | +| `E1206` | active | `METRIC_IN_RAW_AGGREGATE` | Pre-declared metric appears inside a raw aggregate (e.g. `SUM(revenue)`); use `AGG(revenue)` instead. | +| `E1207` | active | `FACTS_METRICS_EXCLUSIVE` | `FACTS` and `METRICS` cannot appear in the same `SEMANTIC_VIEW(...)` call. | +| `E1208` | active | `UNSUPPORTED_SQL_CONSTRUCT` | Bare-view query uses a disallowed construct (`SELECT *`, `LATERAL`, `QUALIFY`, `JOIN`, sub-queries in `WHERE`/`HAVING`, raw window syntax, …). | +| `E1209` | active | `CROSS_DATASET_AD_HOC_AGGREGATE` | Bare view computes aggregates from two datasets at implied cross-grain; use the `SEMANTIC_VIEW(...)` clause with explicit dimensions. | +| `E1210` | RESERVED | `WINDOW_METRIC_DEFERRED` | Query references a window-function metric; window metrics are out of the Foundation. | +| `E1211` | RESERVED | `CLAUSE_ONLY_OUTER` | `LIMIT`, `ORDER BY`, `HAVING`, or `OFFSET` placed inside the `SEMANTIC_VIEW(...)` clause instead of on the outer `SELECT`. | +| `E1212` | active | `COUNT_STAR_AMBIGUOUS` | `COUNT(*)` in a multi-dataset context; qualify with `COUNT(dataset.*)`. | +| `E1213` | RESERVED | `PARAMETER_USED_AS_REFERENCE` | Bare name resolves to a parameter but is used where a dimension or measure is required. | + +## `E2xxx` — Validation errors + +| Code | Status | Name | Meaning | +|:---|:---:|:---|:---| +| `E2001` | active | `AMBIGUOUS_NAME` | Unqualified reference matches multiple scopes; use `dataset.field`. | +| `E2002` | active | `NAME_NOT_FOUND` | Identifier does not resolve to any declared entity. | +| `E2003` | active | `DUPLICATE_NAME` | Same name declared twice in the same scope. | +| `E2004` | active | `UNREACHABLE_DATASET` | No join path to the referenced dataset. | +| `E2005` | active | `CIRCULAR_METRIC` | Metric composition forms a cycle. | +| `E2006` | active | `INVALID_RELATIONSHIP` | Relationship references missing dataset or field. | +| `E2007` | active | `MISSING_PRIMARY_KEY` | Dataset used on the one-side of an inferred N:1 has no primary key. | +| `E2008` | active | `RESERVED_IDENTIFIER` | Reserved word used as identifier without quoting. | + +## `E3xxx` — Planning errors + +| Code | Status | Name | Meaning | +|:---|:---:|:---|:---| +| `E3001` | active | `AMBIGUOUS_JOIN_PATH` | Multiple paths between the required datasets; must disambiguate with `using_relationships`. | +| `E3002` | active | `UNSATISFIABLE_GRAIN` | Requested grain cannot be produced from the model's relationships. | +| `E3003` | RESERVED | `AMBIGUOUS_CARDINALITY` | Relationship lacks key declarations to infer cardinality; add PK/UK or declare `cardinality`. Today's planner always infers from declared keys. | +| `E3004` | active | `GRAIN_NOT_SUBSET` | `aggregate()` target grain is not a subset of the source grain. | +| `E3005` | active | `COLUMN_NAME_COLLISION` | Operation produces two columns with the same name. | +| `E3006` | active | `MISSING_COLUMN_DEPENDENCY` | Expression references a column that is not in the current state. | +| `E3007` | active | `AGGREGATE_IN_SCALAR_CONTEXT` | Aggregate function appears in a scalar-only expression (e.g. a dimension). | +| `E3008` | active | `GRAIN_MISMATCH_MERGE` | `merge()` requires equal grains on both branches. | +| `E3009` | RESERVED | `POST_AGGREGATE_REF_PRE_AGGREGATE` | RESERVED — historically raised when a post-aggregation expression referenced a pre-aggregation column. S-3 split this into the three predicate-routing codes (`E_AGGREGATE_IN_WHERE`, `E_NON_AGGREGATE_IN_HAVING`, `E_MIXED_PREDICATE_LEVEL`); the legacy code is retained so external pinning does not break, but no path raises it today. | +| `E3010` | RESERVED | `CHASM_TRAP` | Two facts joined through shared dimension without the planner's per-fact decomposition. Today's merge strategy (`§4.11`) prevents this structurally. | +| `E3011` | active | `MN_AGGREGATION_REJECTED` | **Engine-capability opt-out** for M:N traversal — declared by an engine that does not support M:N at all. M:N-supporting engines (including `osi_python`) emit `E3012` / `E3013` for per-query failures and never raise `E3011` at the user-facing surface. The algebra layer raises this internally as a precondition signal on `N : N` edges; the planner translates to the per-query `E3012` / `E3013`. (Spec: `Proposed_OSI_Semantics.md` §6.8 *Semantic guarantee*.) | +| `E3012` | active | `MN_NO_SAFE_REWRITE` | An `N : N` traversal in a measure has no semantically-equivalent safe rewrite at the query's grain — no bridge dataset, no shared-dimension stitch path. The user-facing per-query M:N failure code for M:N-supporting engines. Suggest adding a bridge dataset or a shared dimension. The Python identifier is `E3012_MN_NO_SAFE_REWRITE` (matches Appendix C). (Spec: §6.8.) | +| `E3013` | active | `NO_STITCHING_DIMENSION` | Two unrelated facts (different roots, no path) are referenced together with no dimension shared by both — the result would otherwise be a Cartesian product. Per-query failure code for the multi-fact stitch case. (`E_NO_PATH` is the named-family alias for the same shape; see row above.) (Spec: §6.8.) | + +## `E4xxx` — Algebra safety errors + +| Code | Status | Name | Meaning | +|:---|:---:|:---|:---| +| `E4001` | active | `EXPLOSION_UNSAFE` | Aggregation reads a `from_join_rhs=True` column without pre-aggregation or cardinality proof. | +| `E4002` | RESERVED | `ENRICH_KEYS_NOT_IN_GRAIN` | `enrich()` join keys on the left side are not in the parent's grain or PK. Today's precondition uses a child-grain fan-trap check. | +| `E4003` | active | `MERGE_COLUMN_OVERLAP` | `merge()` finds overlapping non-grain columns. | +| `E4004` | active | `BROADCAST_NOT_SCALAR` | `broadcast()` received a state whose grain is not empty. | +| `E4005` | active | `FILTERING_JOIN_ADDS_COLUMNS` | A filtering-join was asked to add columns (impossible by definition). | + +## `E5xxx` — Codegen errors + +| Code | Status | Name | Meaning | +|:---|:---:|:---|:---| +| `E5001` | active | `DIALECT_UNSUPPORTED` | Requested dialect is not implemented. | +| `E5002` | active | `SQLGLOT_RENDER_FAILED` | SQLGlot raised while rendering the final AST. | +| `E5003` | RESERVED | `DIALECT_MISSING_FEATURE` | Plan uses a construct the target dialect cannot express. | + +## `W6xxx` — Non-fatal warnings + +| Code | Status | Name | Meaning | +|:---|:---:|:---|:---| +| `W6001` | RESERVED | `AVG_OF_AVG` | `AVG` applied to an already-averaged column; arithmetic mean is not decomposable. | +| `W6002` | RESERVED | `REAGG_PRECISION_LOSS` | Re-aggregation pattern that loses precision. | +| `W6003` | RESERVED | `SUSPICIOUS_PATTERN` | Other analytically suspect pattern. | + +The warning channel is specified but not yet attached to `QueryPlan`; +enabling it is tracked in `INFRA.md §3`. + +Warnings surface in `QueryPlan.warnings` and are written to +`diagnostics.explain(...)` output. Wrap the offending expression in +`UNSAFE(...)` in the metric definition to silence. Strict mode (set via +`PlannerContext(strict=True)`) elevates all `W6xxx` to errors. + +--- + +## Extending this catalog + +When adding a new error code: + +1. Add the enum value to `ErrorCode` in `src/osi/errors.py` with a + docstring one-liner. +2. Pick the **lowest unused number in the right range** — never reuse + a retired code. +3. Add a row here, in the same order as the enum. +4. Add at least one unit test that asserts on the new code. +5. If the code is raised from the algebra (`E4xxx`), add or extend a + property test under `tests/properties/` to confirm it fires for the + generated counterexamples the code is meant to catch. diff --git a/impl/python/docs/JOIN_ALGEBRA.md b/impl/python/docs/JOIN_ALGEBRA.md new file mode 100644 index 0000000..dac850f --- /dev/null +++ b/impl/python/docs/JOIN_ALGEBRA.md @@ -0,0 +1,497 @@ +# JOIN_ALGEBRA.md — The Closed Algebra + +**Status:** Authoritative spec +**Companion:** [`Proposed_OSI_Semantics.md`](Proposed_OSI_Semantics.md) §6 · [`../docs/ALGEBRA_LAWS.md`](../docs/ALGEBRA_LAWS.md) (proofs and property tests) + +This document specifies the algebra the planner uses to turn a semantic +query into a deterministic plan. The algebra is the **hard boundary** of +the compiler: + +> Every transformation of a calculation is a total, pure, deterministic +> function over an immutable state. Join semantics, aggregation, filtering, +> and projection are expressed by a small closed set of operators with +> explicit preconditions and grain contracts. If an operator's +> precondition is violated, the compiler refuses to build the plan. + +Correctness of the compiler reduces to correctness of this algebra. Every +law below is machine-checked: see [`../docs/ALGEBRA_LAWS.md`](../docs/ALGEBRA_LAWS.md) for the +property-based tests that enforce it. + +--- + +## Table of Contents + +1. [The State](#1-the-state) +2. [Core Invariants](#2-core-invariants) +3. [Operators](#3-operators) +4. [Laws](#4-laws) +5. [Safety Rules](#5-safety-rules) +6. [What the Algebra Does Not Decide](#6-what-the-algebra-does-not-decide) +7. [How the Planner Uses the Algebra](#7-how-the-planner-uses-the-algebra) +8. [Non-Negotiables](#8-non-negotiables) + +--- + +## 1. The State + +The single value flowing through the algebra is `CalculationState`: + +```python +@dataclass(frozen=True) +class CalculationState: + grain: frozenset[Identifier] # dimensions that uniquely identify a row + columns: tuple[Column, ...] # ordered; names are unique + provenance: frozenset[ExpressionId] # which requested expressions this state serves +``` + +```python +@dataclass(frozen=True) +class Column: + name: Identifier + expression: SqlExpr # frozen sqlglot AST (never a raw string) + dependencies: frozenset[Identifier] # names of other columns this one reads + kind: ColumnKind # DIMENSION | FACT | AGGREGATE + aggregate: AggregateInfo | None # present iff kind == AGGREGATE + is_single_valued: bool # known constant or scalar-valued across the grain + from_join_rhs: bool # introduced by a join on the many→one side +``` + +`CalculationState` is frozen. Every operator returns a new state; existing +states are never mutated. + +**Why `grain: frozenset[Identifier]`?** +- A frozenset captures set-equality, which is the semantic truth: `{a,b}` + and `{b,a}` are the same grain. +- An empty grain (`frozenset()`) is the scalar grain — one row per state. +- `grain ⊆ {column.name for column in columns if column.kind == DIMENSION}` + is an invariant; grain members must be present as dimension columns. + +--- + +## 2. Core Invariants + +These hold at every state produced by the algebra. Tests encode each as +a Hypothesis property under `tests/properties/`. + +| # | Invariant | Rationale | +|:--:|:---|:---| +| **I-1** | `state.grain ⊆ {c.name for c in state.columns if c.kind == DIMENSION}` | Grain can only reference dimensions that exist. | +| **I-2** | Column names in `state.columns` are unique. | Addressing by name is deterministic. | +| **I-3** | `state` is reachable only by starting from `SOURCE` and applying operators. | No side-door construction. | +| **I-4** | Every `AGGREGATE` column's `aggregate.decomposability` is one of `DISTRIBUTIVE`, `ALGEBRAIC`, `HOLISTIC`. | Re-aggregation and fan-out safety depend on this classification. | +| **I-5** | `grain = frozenset()` implies `len(output_rows) == 1`. | Scalar state has exactly one tuple. | +| **I-6** | `expression.dependencies ⊆ {c.name for c in columns}` for every column. | Column expressions are closed under the current state. | +| **I-7** | `from_join_rhs = True` implies the producing operator was `ENRICH` on the one-side. | Fan-out safety uses this flag. | +| **I-8** | `provenance` strictly grows through operators that contribute to a requested expression, and is unchanged otherwise. | Diagnostics can trace any output back to its request. | + +Violating any invariant is a bug in the algebra implementation, not a +valid runtime state. + +--- + +## 3. Operators + +The algebra has **nine** operators and no more. Every compiler transformation +must be expressible as a composition of these. If a PR needs a tenth, the +algebra itself is changing and requires a SPEC update. + +All operators return a new state or raise `AlgebraError` (a subclass of +`OSIError`, error code `E4xxx`). + +### 3.1 `source(dataset) → state` + +Initialize a state from a dataset declaration. + +| Aspect | Value | +|:---|:---| +| Precondition | `dataset.primary_key` is declared. | +| Resulting grain | `frozenset(dataset.primary_key)` | +| Resulting columns | One column per field declared on the dataset, `kind=DIMENSION` or `FACT` per the field's `role`. `is_single_valued = False`. `from_join_rhs = False`. | +| Provenance | `frozenset()` (set by subsequent operators as they serve expressions). | + +### 3.2 `filter(state, predicate) → state` + +Apply a row-level boolean predicate. + +| Aspect | Value | +|:---|:---| +| Precondition | `predicate.dependencies ⊆ {c.name for c in state.columns}`. Predicate contains no aggregate functions. | +| Grain effect | **Preserved.** Filtering removes rows, not dimensions. | +| Columns effect | Unchanged (same names, same expressions; the filter is structurally represented by the plan step, not by mutating columns). | + +### 3.3 `enrich(parent, child, keys, join_type) → state` + +N:1 join — bring one-side columns into the many-side state. + +| Aspect | Value | +|:---|:---| +| Precondition | `keys.parent ⊆ parent.grain` (or a declared PK of parent). The declared relationship has cardinality `N:1` from parent to child. `join_type ∈ {INNER, LEFT}`. | +| Grain effect | **Preserved** (we joined to the one-side, so no fan-out). | +| Columns effect | Parent columns unchanged. Child's non-key columns appended, each with `from_join_rhs=True`, `is_single_valued=True` (single-valued over the parent grain). | +| Safety | Rejects `N:1` joins that would fan out (any many-side FK appearing more than once per parent PK). Relies on declared cardinality; if cardinality is not declared (neither side has PK/UK match), this operator raises `E3003 AMBIGUOUS_CARDINALITY`. | + +### 3.4 `aggregate(state, new_grain, aggregations) → state` + +Reduce to a coarser grain. + +| Aspect | Value | +|:---|:---| +| Precondition | `new_grain ⊆ state.grain`. Every aggregation's `source_expression.dependencies ⊆ {c.name for c in state.columns}`. No aggregation operates over a column with `from_join_rhs=True` that would require pre-aggregation (see §5 fan-out safety). | +| Grain effect | `new_grain` (may equal `state.grain` — aggregate at same grain is a no-op and is optimized away). | +| Columns effect | Columns = the dimensions in `new_grain` (each preserved from `state.columns`) plus one `AGGREGATE` column per aggregation. | +| Safety | Holistic aggregates (`COUNT DISTINCT`) are rejected for pre-aggregation followed by re-aggregation; they must operate at the final grain. | + +### 3.5 `project(state, columns) → state` + +Keep only the named columns. + +| Aspect | Value | +|:---|:---| +| Precondition | `columns ⊆ {c.name for c in state.columns}`. `state.grain ⊆ columns`. | +| Grain effect | **Preserved.** | +| Columns effect | Only the named columns survive, in the order given. | + +### 3.6 `add_columns(state, definitions) → state` + +Introduce derived scalar columns. + +| Aspect | Value | +|:---|:---| +| Precondition | Each definition's expression has no aggregate functions and `definition.dependencies ⊆ {c.name for c in state.columns}`. New column names do not collide. | +| Grain effect | **Preserved.** | +| Columns effect | Existing columns followed by new columns. Each new column inherits `kind=DIMENSION` if its expression is dimension-pure, else `kind=FACT`. | + +### 3.7 `merge(left, right, on) → state` + +FULL OUTER join of two states at the same grain (chasm-trap resolution). + +| Aspect | Value | +|:---|:---| +| Precondition | `left.grain == right.grain`. `on ⊆ left.grain` and `on ⊆ right.grain` (conventionally `on == left.grain`). Non-grain columns of `left` and `right` are disjoint. | +| Grain effect | **Preserved** — same grain on both sides. | +| Columns effect | Grain dimensions coalesced via `COALESCE(left.d, right.d)`. Non-grain columns unioned positionally (left-then-right). | + +### 3.8 `filtering_join(state, rhs, keys, mode) → state` + +Semi-join (`mode=SEMI`) or anti-semi-join (`mode=ANTI`). Used for `EXISTS_IN` +/ `NOT EXISTS_IN`. + +| Aspect | Value | +|:---|:---| +| Precondition | `keys.lhs ⊆ state.columns`. `keys.rhs ⊆ rhs.columns`. `mode ∈ {SEMI, ANTI}`. | +| Grain effect | **Preserved.** | +| Columns effect | Unchanged (no columns added — that's the point of a filtering join). | + +### 3.9 `broadcast(state, scalar) → state` + +Attach a scalar value (a state with `grain == frozenset()`) as a new column, +cross-join style. + +| Aspect | Value | +|:---|:---| +| Precondition | `scalar.grain == frozenset()`. `scalar` has exactly one output column not already in `state`. | +| Grain effect | **Preserved.** | +| Columns effect | Existing columns followed by the scalar's column, flagged `is_single_valued=True`. | + +**That is the entire algebra.** Nothing else is an operator. Every other +transformation — "resolve a metric reference", "pick a join path", "apply a +filter context" — composes these nine. + +--- + +## 4. Laws + +Each law has a Hypothesis test under `tests/properties/`. The mutation +testing threshold in `INFRA.md §1.1` is set against the algebra module +specifically because these laws are where silent bugs can hide. + +### 4.1 Totality + +Every operator is a total function on states that satisfy the stated +precondition. If the precondition fails, it raises a typed error; it never +returns a sentinel value or silently no-ops. + +**Test**: `test_algebra_totality.py` — for every operator, Hypothesis +generates (state, args) pairs. Either the precondition passes and a valid +new state is returned, or an `AlgebraError` is raised. No other outcome. + +### 4.2 Purity + +Every operator is a pure function: no I/O, no clocks, no randomness, no +mutation of inputs. + +**Test**: `test_algebra_purity.py` — runs each op twice with the same +inputs and asserts identical outputs. Also checks that the input state +is unchanged (by comparing `id(...)` of column tuples and by deep equality). + +### 4.3 Determinism + +`same inputs → same output`, including column order and generated column names. + +**Test**: `test_algebra_determinism.py` — 1000 random repetitions per op; +output must be byte-identical. + +### 4.4 Closure of Grain + +For every operator the resulting grain is expressible as a set-theoretic +function of the input grains: + +| Operator | Grain function | +|:---|:---| +| `source(d)` | `frozenset(d.primary_key)` | +| `filter` | `state.grain` | +| `enrich` | `parent.grain` | +| `aggregate(_, g, _)` | `g` | +| `project` | `state.grain` | +| `add_columns` | `state.grain` | +| `merge(l, r, _)` | `l.grain == r.grain` (enforced) | +| `filtering_join` | `state.grain` | +| `broadcast` | `state.grain` | + +**Law**: for any path of operators, the final grain is deterministically +computable from the operator-argument sequence alone, without executing the +plan. + +**Test**: `test_grain_closure.py` — symbolic simulation matches concrete +execution on generated operator chains. + +### 4.5 Aggregate Idempotence (at same grain) + +`aggregate(state, state.grain, aggs)` is a no-op when `aggs` are identity +re-aggregations (`SUM(x) → SUM(x)` at same grain, etc.). + +**Test**: `test_aggregate_idempotent.py` — generates states, wraps in +identity re-aggregation, asserts structural equality. + +### 4.6 Filter Commutativity + +`filter(filter(s, p1), p2)` produces a state equivalent to `filter(s, p1 AND p2)`. +Two non-overlapping filters may be reordered. + +**Test**: `test_filter_commute.py` — asserts equivalence of row +sets when rendered and executed on DuckDB. + +### 4.7 Merge Associativity (at same grain) + +`merge(merge(a, b), c) ≡ merge(a, merge(b, c))` for disjoint non-grain +column sets and equal grains. + +**Test**: `test_merge_associative.py`. + +### 4.8 Projection Idempotence + +`project(project(s, c1), c2) ≡ project(s, c2)` whenever `c2 ⊆ c1`. + +**Test**: `test_project_idempotent.py`. + +### 4.9 Enrichment Preserves Parent Rows + +`enrich(parent, child, keys, LEFT)` does not change the number of parent +rows — specifically, the projection of the result onto `parent.grain` must +have the same multiset of values as the projection of `parent` onto the +same grain. + +**Test**: `test_enrich_preserves_rows.py` — DuckDB-executed; asserts row +counts match exactly over 100 generated fixtures. + +### 4.10 Explosion Safety + +For any aggregation whose source column has `from_join_rhs=True`, the +planner must have proven that either (a) the join was safely pre-aggregated +(the many-side was reduced to its PK before join), or (b) the aggregation +is a counting aggregation over the join key and the relationship is +declared `N:1`. Otherwise the algebra raises `E4001 EXPLOSION_UNSAFE`. + +**Test**: `test_explosion_safety.py` — generates fan-out-prone topologies; +asserts that silent double-counting never occurs, by comparing OSI output +to a hand-rolled pre-aggregate reference. + +--- + +## 5. Safety Rules + +The three analytical traps the Foundation defends against, stated as +algebra rules that must hold in every produced plan. + +### 5.1 Fan-out Safety + +> **Rule.** For any `aggregate(state, g, aggs)`, if any aggregation's source +> expression reads a column with `from_join_rhs=True`, that column must +> have been introduced by a `broadcast` (single-valued) or the many-side +> must have been pre-aggregated via an `aggregate` before the `enrich`. + +Concretely: `SUM(orders.amount)` grouped by `customers.region` where +orders and customers are joined N:1 requires a pre-aggregate step on +orders first. This is not a heuristic — it is a precondition on +`aggregate`. + +Violating the rule raises `E4001`. See `JOIN_SAFETY.md` in `docs/` for +worked examples. + +### 5.2 Chasm-Trap Safety + +> **Rule.** When two facts connect through a shared dimension but have no +> direct relationship, they MUST be computed in separate states +> (`source → … → aggregate` per fact) and combined via `merge` on the +> shared dimension. They MUST NOT be joined through a single multi-branch +> state. + +Violating the rule raises `E3010`. The planner composition step is the +only place that constructs chasm-safe plans; no other helper may fabricate +a plan that joins two facts directly through their shared dimension. + +### 5.3 `enrich` is N:1-only — M:N is the planner's problem + +> **Rule.** `enrich` requires the declared relationship to have cardinality +> `N:1` from parent to child. An `N:N` (or `N:?` with missing keys) +> relationship MUST NOT reach `enrich`. Violating this precondition +> raises `E3003 AMBIGUOUS_CARDINALITY`. + +This is an *operator-local* precondition, not a blanket rule about M:N +data models. M:N edges are valid model citizens and are resolved by the +planner before any operator is invoked, via one of three routes +(`Proposed_OSI_Semantics.md` §6.5): + +| Route | Operator path | +|:---|:---| +| Bridge dataset | `enrich` (bridge → grouping-side) ∘ `aggregate` (dedup to `{measure-PK, grouping-cols}`) ∘ `enrich` (→ measure-side) ∘ `aggregate` (to query grain). The intermediate `aggregate` is the §5.1 pre-aggregation of the bridge, without which the final `aggregate` would read a `from_join_rhs=True` column over a fanned-out parent and raise `E4001`. | +| Stitching dimension | `aggregate` per side, then `merge` (FULL OUTER on shared dims) | +| `EXISTS_IN` filter | `filtering_join` (mode `SEMI` or `ANTI`) | + +If none of the three routes applies the planner raises `E3012` or +`E3013` — *before* it ever reaches the algebra. The algebra only ever +sees inputs that are already known to be safe. + +`E3011 MN_AGGREGATION_REJECTED` is the **engine-capability opt-out** +code: an engine that does not support M:N at all raises it for every +M:N query. M:N-supporting engines (which `osi_python` is) emit +`E3012` / `E3013` for per-query failures and never raise `E3011` at +the user-facing surface. The algebra-internal use of `E3011` (the +`enrich` precondition raising it on an `N : N` edge) is a planner- +internal signal that the planner translates to the user-facing +`E3012` / `E3013`. See `Proposed_OSI_Semantics.md` §6.5 for the +resolution rules and §11.1 for the compliance suite that pins the +behaviour. + +#### 5.3.1 Worked bridge plan (operator-level) + +For the query `SUM(movies.gross)` grouped by `actors.height` against the +mini-model M2 in `Proposed_OSI_Semantics.md` Appendix A +(`actors ↔ appearances ↔ movies`), the planner emits this exact operator +sequence: + +``` +s0 = source(appearances) + # grain = {actor_id, movie_id} +s1 = enrich(s0, child=actors, keys=[actor_id], join_type=LEFT) + # grain = {actor_id, movie_id}; adds height (from_join_rhs=True) +s2 = aggregate(s1, new_grain={movie_id, height}, aggregations=[]) + # collapses duplicate appearances per (movie, height). + # This is the §5.1 pre-aggregation step for the next enrich. +s3 = enrich(s2, child=movies, keys=[movie_id], join_type=LEFT) + # grain = {movie_id, height}; adds gross (from_join_rhs=True, + # is_single_valued over the new parent grain) +s4 = aggregate(s3, new_grain={height}, aggregations=[SUM(gross)]) + # grain = {height}; final query-grain aggregate. +``` + +`s2` is the load-bearing step. Without it, `s4` would be summing `gross` +(a `from_join_rhs=True` column) over an `appearances`-grained state that +has multiple rows per `(movie_id, height)`, and §5.1 fan-out safety would +raise `E4001 EXPLOSION_UNSAFE`. The dedup-via-`aggregate` is the +"pre-aggregate the many-side" pattern §5.1 mandates, applied at the +bridge. + +For an explicit nested-aggregation query like `AVG(AVG(movies.gross))`, +the planner instead emits a per-endpoint-first plan +(`enrich(→movies) ∘ aggregate({actor_id}, [AVG(gross)]) ∘ enrich(→actors) +∘ aggregate({height}, [AVG(per_actor_avg)])`). Both shapes are legal +under the algebra; the planner picks one based on whether the user wrote +a bare aggregate (dedup shape) or nested aggregation (per-endpoint +shape). + +The compliance suite pins both shapes via `tests/data/DATA_TESTS.md` +entries T-026 and T-027 — observable row-set equivalence, not the SQL +text. + +--- + +## 6. What the Algebra Does Not Decide + +The algebra is deliberately agnostic about the following — they are +decided elsewhere: + +| Concern | Decided by | +|:---|:---| +| How a metric definition expands into an operator sequence. | `planning/planner.py` (composition). | +| Which relationship path to use when multiple exist. | `planning/joins.py` — calls graph traversal, then hands `enrich` the chosen keys. | +| Filter routing: pre-aggregation vs post-aggregation vs semi-join. | `planning/classify.py` — then expresses its decision by emitting `filter` / `filtering_join`. | +| How CTEs are shaped in the output SQL. | `codegen/` — operates on the plan, not on the algebra. | +| Dialect-specific syntax. | `codegen/dialect.py`. | +| Error message prose. | `osi.errors` — the algebra raises typed errors with codes and context; the error-message module renders them. | + +Because the algebra is agnostic, these concerns can be changed +independently without weakening the algebra's guarantees. + +--- + +## 7. How the Planner Uses the Algebra + +The planner is a **composer of algebra ops**. Its job is to choose which +operators to apply, in what order, with what arguments. It never invents +new operators. + +Pseudocode for the main loop: + +```python +def plan(query: SemanticQuery, ctx: PlannerContext) -> QueryPlan: + branches: list[CalculationState] = [] + + for measure_group in classify_measures_by_source(query.measures, ctx): + s = source(ctx.model.datasets[measure_group.root]) + for rel in ctx.joins.path(measure_group.datasets): + s = enrich(s, child=ctx.model.datasets[rel.to], keys=rel.keys, join_type=rel.join_type()) + for predicate in classify(query.where).row_level_predicates_for(measure_group): + s = filter(s, predicate) + for predicate in classify(query.where).semi_join_predicates_for(measure_group): + s = filtering_join(s, predicate.rhs_state(ctx), predicate.keys, predicate.mode) + s = aggregate(s, query.grain, measure_group.aggregations) + s = add_columns(s, measure_group.post_aggregate_scalars) + branches.append(s) + + combined = reduce(lambda a, b: merge(a, b, on=query.grain), branches) + combined = add_columns(combined, query.derived_metric_arithmetic) + for predicate in classify(query.having).post_aggregate_predicates(): + combined = filter(combined, predicate) + final = project(combined, query.output_columns) + return QueryPlan.from_state(final) +``` + +Everything inside `plan()` is composition. The correctness of `plan()` +reduces to: (a) it picks arguments that satisfy the preconditions of each +operator, and (b) the resulting state matches the query's requested +columns and grain. The algebra itself is trusted. + +--- + +## 8. Non-Negotiables + +These are the five commitments that make the algebra load-bearing. None +may be relaxed without a SPEC change and a new decision-log entry in +`INFRA.md §4`. + +1. **Immutability.** Every state and every column is frozen. No operator + mutates its inputs. +2. **Purity.** No I/O, no clocks, no randomness, no global state in any + operator. +3. **Totality.** Every operator either returns a valid state or raises a + typed `AlgebraError` with an error code. No `None`, no silent fallback. +4. **Explicit grain contract.** Every operator declares its grain effect, + checked at call time by a single helper in `algebra/operations.py`. +5. **Single-point entry.** The only way to build a `CalculationState` is + `source(...)` or an operator applied to an existing state. There is no + constructor escape hatch. + +Tests under `tests/properties/` enforce each commitment as a Hypothesis +property. Mutation testing on `src/osi/planning/algebra/` is where the +quality bar is strictest; see `INFRA.md §1.1`. diff --git a/impl/python/docs/JOIN_SAFETY.md b/impl/python/docs/JOIN_SAFETY.md new file mode 100644 index 0000000..10f05c0 --- /dev/null +++ b/impl/python/docs/JOIN_SAFETY.md @@ -0,0 +1,402 @@ +# Join Safety in OSI + +OSI enforces join safety at the algebra level to prevent common BI pitfalls: fan traps, chasm traps, and incorrect semi-additive aggregations. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Fan Traps (Explosion Safety)](#fan-traps-explosion-safety) +3. [Chasm Traps](#chasm-traps) +4. [Snapshot Safety (Semi-Additive Measures)](#snapshot-safety-semi-additive-measures) +5. [Error Codes](#error-codes) +6. [Comparison to Other Tools](#comparison-to-other-tools) +7. [Examples](#examples) + +--- + +## Overview + +Join safety is enforced through **column-level flags** tracked through the query planning process: + +| Flag | Purpose | Set When | +|------|---------|----------| +| `is_join_exploded` | Tracks join fan-out | Column comes from many-side of N:1 join | +| `snapshot_dimensions` | Tracks semi-additive measures | Column is balance/inventory at point in time | +| `is_single_valued` | Tracks filtered dimensions | Column filtered to single value (WHERE x = const) | + +These flags flow through all algebra operations (aggregate, enrich, filtering, etc.) and are validated before aggregation. + +--- + +## Fan Traps (Explosion Safety) + +### What is a Fan Trap? + +A **fan trap** occurs when one table (center) has multiple child tables (many-side), creating a fan-out pattern: + +``` +Orders (many) → Products (one) ← Suppliers (many) +``` + +**Problem**: Joining Orders → Products ← Suppliers duplicates order rows (once per supplier): + +```sql +-- WRONG: This multiplies order amounts by supplier count! +SELECT + p.product_name, + SUM(o.amount) as total_orders, -- WRONG! Multiplied + SUM(s.cost) as total_supplier_cost -- WRONG! Also wrong +FROM orders o +JOIN products p ON o.product_id = p.product_id +JOIN suppliers s ON p.product_id = s.product_id +GROUP BY p.product_name; +``` + +If Product A has 2 suppliers, each order for Product A gets counted twice! + +### How OSI Prevents Fan Traps + +**Step 1: Mark exploded columns** + +When enriching from many-to-one join: + +```python +# Orders -> Products (safe) +state = enrich(orders_state, products_state, [("product_id", "product_id")]) +# products.* columns are NOT marked exploded (coming from one-side) + +# Products <- Suppliers (fan-out!) +state = enrich(state, suppliers_state, [("product_id", "product_id")]) +# suppliers.* columns ARE marked is_join_exploded=True +``` + +**Step 2: Enforce explosion-safe aggregations** + +```python +# This will FAIL with E4001: +aggregate(state, grain=["product_name"], aggregations=[ + ("total_cost", "SUM(suppliers.cost)") # ❌ E4001! +]) + +# This will SUCCEED (explosion-safe): +aggregate(state, grain=["product_name"], aggregations=[ + ("min_cost", "MIN(suppliers.cost)"), # ✅ MIN is safe + ("supplier_count", "COUNT(DISTINCT supplier_id)") # ✅ COUNT DISTINCT is safe +]) +``` + +### Explosion-Safe Aggregations + +These aggregations are **safe** on exploded columns (produce correct results despite duplication): + +```python +EXPLOSION_SAFE_AGGREGATIONS = { + "MIN", "MAX", + "COUNT", "COUNT_DISTINCT", + "ANY_VALUE", + "ARRAY_AGG", "LISTAGG" +} +``` + +**Unsafe** (blocked by E4001): +- `SUM` - Would sum duplicated values +- `AVG` - Would average duplicated values +- `STDDEV`, `VARIANCE` - Would compute on duplicated values + +### Workarounds + +**Option 1: Pre-aggregate before join** + +```yaml +# Create metric that aggregates suppliers at product level first +avg_supplier_cost_per_product: + expression: AVG(suppliers.cost) + grain: { mode: FIXED, dimensions: [product_id] } + +# Then query at product level: +dimensions: [products.product_name] +measures: [total_order_amount, avg_supplier_cost_per_product] +``` + +**Option 2: Use explosion-safe aggregation** + +```yaml +dimensions: [products.product_name] +measures: + - total_order_amount + - { name: min_supplier_cost, expression: MIN(suppliers.cost) } + - { name: max_supplier_cost, expression: MAX(suppliers.cost) } +``` + +**Option 3: UNSAFE override** (use with caution!) + +```yaml +# Bypass safety check (may produce INCORRECT results) +measures: + - { name: total_cost, expression: UNSAFE(SUM(suppliers.cost)) } +``` + +--- + +## Chasm Traps + +### What is a Chasm Trap? + +A **chasm trap** occurs when two fact tables share a dimension: + +``` +Sales (many) → Date (one) ← Budgets (many) +``` + +**Problem**: Naive join creates cartesian product at shared dimension level: + +```sql +-- WRONG: Cartesian product! +SELECT + d.date, + SUM(s.amount) as total_sales, -- WRONG! Multiplied by budget rows + SUM(b.budget) as total_budget -- WRONG! Multiplied by sales rows +FROM sales s +JOIN date_dim d ON s.date_id = d.date_id +JOIN budgets b ON d.date_id = b.date_id +GROUP BY d.date; +``` + +If date 1/15 has 2 sales and 2 budgets, the join creates 2×2=4 rows! + +### How OSI Handles Chasm Traps + +**Detection**: `RelationshipGraph.detect_chasm_trap()` identifies the pattern + +**Resolution**: Aggregate each fact **independently** at shared dimension grain, then **merge**: + +```sql +-- CORRECT: Independent aggregation + merge +WITH sales_by_date AS ( + SELECT date_id, SUM(amount) as total_sales + FROM sales + GROUP BY date_id +), +budgets_by_date AS ( + SELECT date_id, SUM(budget) as total_budget + FROM budgets + GROUP BY date_id +) +SELECT + d.date, + COALESCE(s.total_sales, 0) as total_sales, + COALESCE(b.total_budget, 0) as total_budget +FROM date_dim d +LEFT JOIN sales_by_date s ON d.date_id = s.date_id +LEFT JOIN budgets_by_date b ON d.date_id = b.date_id; +``` + +**In OSI**: The LOD planner automatically creates separate branches for each fact and merges at query grain. + +--- + +## Snapshot Safety (Semi-Additive Measures) + +### What is a Snapshot Dimension? + +A **snapshot dimension** is a point-in-time axis (usually date) for measures that shouldn't be summed across it. + +**Example**: Account balances + +``` +account_snapshots(account_id, date, balance) + 2024-01-15: $1000 + 2024-01-16: $1200 + 2024-01-17: $1100 +``` + +**Problem**: `SUM(balance)` across dates = $3,300 counts the same money 3 times! + +### How OSI Prevents Incorrect Snapshot Aggregation + +**Step 1: Mark snapshot columns** + +```python +Column( + name="balance", + expression="balance", + dependencies=frozenset(), + is_agg=False, + snapshot_dimensions=frozenset({"snapshot_date"}) # ← Key! +) +``` + +**Step 2: Enforce snapshot-safe aggregations** + +```python +# This will FAIL with E4002: +aggregate(state, grain=["account_id"], aggregations=[ + ("total_balance", "SUM(balance)") # ❌ E4002! (date not filtered) +]) + +# This will SUCCEED: +aggregate( + filtering(state, "snapshot_date = '2024-01-17'"), # Filter to single date + grain=["account_id"], + aggregations=[("total_balance", "SUM(balance)")] # ✅ Now safe! +) +``` + +### Snapshot-Safe Aggregations + +These aggregations are **safe** on snapshot columns (don't sum across time): + +```python +SNAPSHOT_SAFE_AGGREGATIONS = { + "MIN", "MAX", + "COUNT", "COUNT_DISTINCT", + "ANY_VALUE", + "FIRST_VALUE", "LAST_VALUE" +} +``` + +**Unsafe** (blocked by E4002): +- `SUM` - Would sum same balance multiple times +- `AVG` - Would average across snapshots incorrectly + +### Workarounds + +**Option 1: Filter to single date** + +```yaml +measures: + - name: balance_at_date + expression: SUM(account_snapshots.balance) + filter: snapshot_date = '2024-01-17' +``` + +**Option 2: Use snapshot-safe aggregation** + +```yaml +measures: + - { name: max_balance, expression: MAX(account_snapshots.balance) } + - { name: min_balance, expression: MIN(account_snapshots.balance) } + - { name: latest_balance, expression: LAST_VALUE(account_snapshots.balance) } +``` + +**Option 3: Use FIXED grain at specific date** + +```yaml +balance_at_latest_date: + expression: SUM(account_snapshots.balance) + grain: { mode: FIXED, dimensions: [snapshot_date] } + filter: snapshot_date = (SELECT MAX(snapshot_date) FROM account_snapshots) +``` + +--- + +## Error Codes + +### E4001: Explosion-Unsafe Aggregation + +**When**: Trying to use SUM/AVG on a column from the many-side of a join + +**Message**: +``` +Cannot use ['SUM'] on join-exploded column 'cost' — explosion-unsafe aggregation +``` + +**Suggestions**: +- Use explosion-safe aggregations: MIN, MAX, COUNT, COUNT_DISTINCT +- Pre-aggregate the column before joining to remove explosion +- Use UNSAFE(SUM(...)) to bypass safety checks (may produce incorrect results) +- Note: SUM and AVG are unsafe because join explosion duplicates rows + +**Fix**: See [Fan Traps](#fan-traps-explosion-safety) workarounds + +--- + +### E4002: Snapshot-Unsafe Aggregation + +**When**: Trying to use SUM on a semi-additive measure across snapshot dimension + +**Message**: +``` +Cannot use ['SUM'] on snapshot column 'balance' with dimensions ['snapshot_date'] — snapshot-unsafe aggregation +``` + +**Suggestions**: +- Use snapshot-safe aggregations: MIN, MAX, FIRST_VALUE, LAST_VALUE +- Filter ['snapshot_date'] to single value before aggregating +- Use filter_to_remove_lod() to restrict snapshot dimension +- Note: SUM is unsafe because it would sum across snapshot points +- Example: SUM(balance) across dates would count the same balance multiple times + +**Fix**: See [Snapshot Safety](#snapshot-safety-semi-additive-measures) workarounds + +--- + +## Comparison to Other Tools + +| Tool | Fan Trap Detection | Chasm Trap Handling | Snapshot Safety | +|------|-------------------|---------------------|-----------------| +| **OSI** | ✅ Automatic (E4001) | ✅ Automatic (separate branches) | ✅ Automatic (E4002) | +| **Looker** | ⚠️ Manual (`sql_always_where`, `always_filter`) | ⚠️ Manual (symmetric aggregates, fanout warning) | ⚠️ Manual field type | +| **Tableau** | ❌ None (user must LOD) | ❌ None (user must understand blending) | ⚠️ Manual (user must know to filter) | +| **Power BI** | ⚠️ DAX relationships (user must model correctly) | ⚠️ DAX context (user must understand) | ⚠️ Manual measure logic | +| **dbt** | ❌ None (pure SQL) | ❌ None (pure SQL) | ❌ None (pure SQL) | +| **MetricFlow** | ⚠️ Entity paths (manual modeling) | ⚠️ Multi-hop joins (user defined) | ⚠️ Manual metric type | + +**Key**: ✅ Automatic enforcement, ⚠️ Requires manual configuration, ❌ No support + +**OSI advantage**: Catches errors at **planning time** before execution, with clear error messages and suggestions. + +--- + +## Examples + +See E2E tests for executable examples: + +- **Fan trap**: `tests/e2e/test_join_safety_e2e.py::TestFanTrapDetection` + - Schema: `tests/e2e/schemas/fan_trap_orders.yaml` + - Data: `tests/e2e/data/fan_trap_orders.sql` + +- **Chasm trap**: `tests/e2e/test_join_safety_e2e.py::TestChasmTrapDetection` + - Schema: `tests/e2e/schemas/chasm_trap_sales.yaml` + - Data: `tests/e2e/data/chasm_trap_sales.sql` + +- **Snapshot safety**: `tests/e2e/test_join_safety_e2e.py::TestSnapshotSafety` + - Schema: `tests/e2e/schemas/snapshot_accounts.yaml` + - Data: `tests/e2e/data/snapshot_accounts.sql` + +--- + +## References + +- **Algebra implementation**: `src/osi/planning/algebra.py` + - `_validate_aggregation_safety()` (lines 580-679) + - Explosion safety enforcement + - Snapshot safety enforcement + +- **State tracking**: `src/osi/planning/state.py` + - `Column.is_join_exploded` flag + - `Column.snapshot_dimensions` tracking + +- **Graph detection**: `src/osi/parsing/graph.py` + - `RelationshipGraph.detect_fan_trap()` + - `RelationshipGraph.detect_chasm_trap()` + +- **Spec reference**: `specs/OSI_Calc_Model_Semantics.md` + - Section on explosion safety + - Section on semi-additive measures + +--- + +## Summary + +OSI's join safety mechanisms catch common BI errors at **planning time**: + +1. **Fan traps** → E4001 prevents explosion-unsafe aggregations +2. **Chasm traps** → Automatic independent aggregation + merge +3. **Snapshot issues** → E4002 prevents incorrect semi-additive sums + +These protections are **automatic** (no manual configuration) and provide **clear error messages** with actionable suggestions. + +The algebra tracks safety flags through all transformations, ensuring correctness is maintained throughout the query planning process. diff --git a/impl/python/docs/MUTATION_BASELINE.md b/impl/python/docs/MUTATION_BASELINE.md new file mode 100644 index 0000000..609a528 --- /dev/null +++ b/impl/python/docs/MUTATION_BASELINE.md @@ -0,0 +1,92 @@ +# Mutation Testing Baseline + +**Status:** Phase 6 hardening — baseline published for the Foundation. + +This document is the published mutation-testing ratchet for +`osi_python`. It pairs with [`INFRA.md §1.1`](../INFRA.md#11-test-quality) +which defines the *targets*; this file records the *current reality* +and the policy for moving it forward. + +--- + +## §1 Current baseline + +Captured on the first green `make check` of Phase 6. Regenerate with +`make mutation` (or `make mutation-fast` for the algebra-only subset) +and update this table; the CI gate compares against these numbers. + +| Module | Current score | Minimum gate | Target | Notes | +|:---|:---:|:---:|:---:|:---| +| `src/osi/planning/algebra/` | (S-25: tooling-blocked on macOS) | 88% | ≥ 90% | Load-bearing. Surviving mutations here are P0. | +| `src/osi/planning/classify.py` | (S-25: tooling-blocked) | 82% | ≥ 85% | Fan-out / chasm detection. | +| `src/osi/planning/joins.py` | (S-25: tooling-blocked) | 82% | ≥ 85% | Join-path selection. | +| `src/osi/codegen/` | (S-25: tooling-blocked) | 72% | ≥ 75% | Dialect idiom coverage. | +| `src/osi/` overall | (S-25: tooling-blocked) | 72% | ≥ 75% | Project-wide floor. | + +**Tooling status (S-25, 2026-05-13).** The mutmut 3 configuration in +`pyproject.toml` is fully wired (correct ``source_paths`` / +``pytest_add_cli_args_test_selection`` / ``also_copy`` / +``use_setproctitle``). Local runs on macOS hit a fork-safety +regression in mutmut 3.5 where every mutated child process +segfaults — the same issue documented in mutmut's macOS notes, +even with ``use_setproctitle = false``. Resolution path: + +1. Run the baseline sweep on a Linux CI worker (the GitHub Actions + workflow `../../../.github/workflows/impl-python-ci.yml` is the + hook). +2. Or pin mutmut to 2.x and revert the config block to the 2.x + keys (``paths_to_mutate`` / ``runner``). + +CI continues to enforce the **Minimum gate** column once the +baseline lands; a PR that drops any score below the minimum fails. + +## §2 Ratchet policy + +- **Baseline captured at each release.** Write the numbers into the + table above as part of the release PR. Do not delete history; keep + past rows in §4. +- **Sprint regressions > 2 percentage points fail CI** against the + baseline for that module. +- **Every four sprints, reviewers consider raising a baseline by + 1–2 pp per module.** Raising requires a PR that shows the score has + stayed above the new floor for at least two sprints. +- **Lowering a baseline is never automatic.** A lowered baseline must + ship with a decision-log entry in [`INFRA.md §4`](../INFRA.md#4-decisions-log) + explaining the trade-off. + +## §3 How to run mutation tests locally + +```bash +# Fast: algebra module only (~5 minutes) +make mutation-fast + +# Full run (~30 minutes, runs on all src/osi modules) +make mutation +``` + +Both commands write their cache to `.mutmut-cache/`. Use +`mutmut results` for a summary and `mutmut show ` to inspect a +surviving mutation. + +## §4 History + +Append one row per release. Keep the most recent entry first. + +| Date | Release | Algebra | classify/joins | codegen | Overall | +|:---|:---|:---:|:---:|:---:|:---:| +| _TBD_ | Phase 6 exit | — | — | — | — | + +## §5 Reading surviving mutations + +A surviving mutation is a diff the test suite did not catch. The +resolution ladder: + +1. **Is there a missing test?** Most surviving mutations point at an + untested branch. Add the test. +2. **Is there equivalent code?** Occasionally `mutmut` mutates code + that has no observable behaviour difference (e.g. a dead branch). + Mark it `skipped` with a comment explaining why. +3. **Is the code load-bearing for correctness?** If yes, this is a + real bug in the algebra or planner. Add the test and fix the code. + A surviving mutation in `src/osi/planning/algebra/` is always + treated as #3 until proven otherwise. diff --git a/impl/python/docs/README.md b/impl/python/docs/README.md new file mode 100644 index 0000000..0d68c89 --- /dev/null +++ b/impl/python/docs/README.md @@ -0,0 +1,36 @@ +# Implementation Docs + +Deep-dive design notes and correctness arguments for `osi_python`. These +docs explain *how* the compiler upholds the Foundation spec and are +written for contributors touching any layer of the compiler. Read +[`../ARCHITECTURE.md`](../ARCHITECTURE.md) first for the invariant catalog. + +For the standard itself, see [`../specs/`](../specs/). + +--- + +## Reference + +| Doc | What it covers | +|:---|:---| +| [`ERROR_CODES.md`](ERROR_CODES.md) | Every `OSIError` code (`E1xxx`–`E4xxx`, `W5xxx`): meaning, range, typical cause, suggested remediation. | + +## Correctness + +| Doc | What it covers | +|:---|:---| +| [`ALGEBRA_LAWS.md`](ALGEBRA_LAWS.md) | The companion to [`JOIN_ALGEBRA.md`](JOIN_ALGEBRA.md): concrete Hypothesis strategies, property tests, and mutation-testing targets that enforce each algebra law. | +| [`JOIN_SAFETY.md`](JOIN_SAFETY.md) | Worked examples of fan-trap / chasm-trap detection and the safe rewrites the planner emits. | + +## Testing + +| Doc | What it covers | +|:---|:---| +| [`TESTING_STRATEGY.md`](TESTING_STRATEGY.md) | The four-layer test pyramid (unit / property / golden / E2E), mutation-testing approach, and what each layer must prove. | + +## Alignment + +| Doc | What it covers | +|:---|:---| +| [`ERRATA_ALIGNMENT.md`](ERRATA_ALIGNMENT.md) | The 25 behaviors catalogued in Snowflake Semantic Views' ERRATA and how `osi_python` handles each: implemented, deferred, or resolved-away. Seeds test scenarios. | +| [`mapping_bi_models_to_core_osi_abstractions.md`](mapping_bi_models_to_core_osi_abstractions.md) | End-to-end mapping of Power BI / Tableau / Looker / ThoughtSpot concepts onto the Foundation. | diff --git a/impl/python/docs/TESTING_STRATEGY.md b/impl/python/docs/TESTING_STRATEGY.md new file mode 100644 index 0000000..16c5173 --- /dev/null +++ b/impl/python/docs/TESTING_STRATEGY.md @@ -0,0 +1,344 @@ +# TESTING_STRATEGY.md — The Four-Layer Test Pyramid + +Every Foundation feature in `osi_python` must be covered by tests at +**four** layers. Any sprint that ships a feature without all four layers +is incomplete regardless of feature completeness. + +The four layers: + +| Layer | Purpose | Typical tools | Runtime budget | +|:---|:---|:---|:---:| +| **Unit** | Small, targeted tests that pin a specific behavior of a single function or class. | `pytest`, plain assertions. | < 1 ms / test | +| **Property** | Hypothesis-driven tests that assert universally-quantified invariants of the algebra and planner. | `hypothesis`. | 10 ms – 1 s / test | +| **Golden** | Snapshot tests that fix the exact `QueryPlan` and the exact SQL for a curated set of input queries. | `syrupy` (pytest plugin) or in-repo golden files. | < 50 ms / test | +| **E2E** | DuckDB-executed tests that assert the SQL we render returns the correct rows on real data. | `pytest` + `duckdb`. | 100 ms – 5 s / test | + +Plus a cross-cutting quality gate: + +| Tooling | Purpose | +|:---|:---| +| **Mutation testing** | Proves that the four layers above actually catch bugs, not just execute lines. | + +--- + +## Table of Contents + +1. [Why Four Layers](#1-why-four-layers) +2. [Layer 1: Unit](#2-layer-1-unit) +3. [Layer 2: Property-Based](#3-layer-2-property-based) +4. [Layer 3: Golden](#4-layer-3-golden) +5. [Layer 4: End-to-End](#5-layer-4-end-to-end) +6. [Mutation Testing](#6-mutation-testing) +7. [Test Directory Layout](#7-test-directory-layout) +8. [Coverage Targets](#8-coverage-targets) +9. [What NOT to Test](#9-what-not-to-test) + +--- + +## 1. Why Four Layers + +Each layer catches a class of bug the others cannot: + +- **Unit** catches logic errors in a single function. +- **Property** catches violations of universal invariants — the "for all + inputs, X holds" properties that unit tests cannot exhaustively check. + This is where the algebra's correctness lives. +- **Golden** catches unintended changes in plan structure or SQL output + that would otherwise be silent. A CI diff on a golden file is often + the first sign of a regression. +- **E2E** catches the case where the SQL compiles and looks right but + executes to wrong rows on real data (dialect quirks, operator precedence + surprises, NULL handling). + +A bug that slips through a layer gets caught by the next. A bug that +slips through all four means the test design missed something — file a +test-debt item in `INFRA.md §3`. + +--- + +## 2. Layer 1: Unit + +**Location:** `tests/unit//test_.py` + +**What to test.** +- Every public function's "happy path" with realistic inputs. +- Every documented precondition: write a `pytest.raises(OSIError, match="E4001")` test. +- Boundary conditions the function's docstring calls out. + +**What NOT to test here.** +- Broad invariants that hold across many functions (those go to Property). +- End-to-end plans (those go to Golden or E2E). + +**Example.** + +```python +# tests/unit/planning/algebra/test_aggregate.py +def test_aggregate_same_grain_identity_is_noop() -> None: + state = build_state(grain={"customer_id"}, columns=[amount_fact]) + result = aggregate(state, {"customer_id"}, [Identity("amount")]) + assert result == state + +def test_aggregate_target_not_subset_raises_E3004() -> None: + state = build_state(grain={"customer_id"}, columns=[amount_fact]) + with pytest.raises(OSIError) as exc: + aggregate(state, {"region"}, [Sum("amount")]) + assert exc.value.code == ErrorCode.E3004_GRAIN_NOT_SUBSET +``` + +Unit tests are the fastest to write and should have the lowest friction +— write them first for every new line of compiler logic. + +--- + +## 3. Layer 2: Property-Based + +**Location:** `tests/properties/` + +**What to test.** +- Every law in [`ALGEBRA_LAWS.md §2`](ALGEBRA_LAWS.md#2-the-laws-and-their-tests). +- Invariants that hold across *any* legal state or *any* legal query: + idempotence, commutativity, purity, determinism. +- Error taxonomy: every exception raised anywhere in the compiler is an + `OSIError` subclass with a valid `code`. + +**What NOT to test here.** +- Specific-case behaviors (those go to Unit). +- Dialect-specific SQL (generate SQL with `dialect=ANSI` for properties). + +**Hypothesis configuration.** See [`ALGEBRA_LAWS.md §1`](ALGEBRA_LAWS.md#1-generation-strategies) +for strategies. Example: + +```python +# tests/properties/test_grain_closure.py +@given(chain=operator_chains(min_size=1, max_size=12)) +@settings(max_examples=500, deadline=1000) +def test_grain_closure(chain: OperatorChain) -> None: + symbolic = simulate_grain(chain) + actual = execute_chain(chain).grain + assert symbolic == actual +``` + +Property tests must run in < 60 s each in CI. If a property takes longer, +either narrow the generation strategy or move the expensive check to a +nightly mutation run. + +--- + +## 4. Layer 3: Golden + +**Location:** `tests/golden/` + +**What to test.** +- Exact `QueryPlan` structure for a curated set of canonical queries. +- Exact rendered SQL for each `(plan, dialect)` pair. + +**Format.** Each golden test has: + +``` +tests/golden/ + basic/ + single_table_revenue/ + model.yaml # the semantic model + query.yaml # the semantic query + expected.plan.json # snapshot of QueryPlan (pretty-printed) + expected.ansi.sql # rendered SQL for ANSI dialect + expected.duckdb.sql # rendered SQL for DuckDB + expected.snowflake.sql +``` + +The test driver loads the model, builds the plan, compares to +`expected.plan.json`, renders SQL for each dialect, compares to the +per-dialect `.sql` file. A mismatch raises a detailed diff and prints +the command to refresh the golden: `make golden-refresh TEST=`. + +**Why golden tests matter.** They turn "what does the planner do for +this query?" from a paragraph of prose into a file-on-disk. Diffs are +easy to read in PR review; unintended changes become visible immediately. + +**Refresh policy.** Refreshing a golden file is a deliberate action, not +a shortcut for making tests pass. A PR that refreshes goldens must +explain in the PR description which behavior change justifies the update +and why it's intentional. + +### 4.1 Canonical Golden Corpus + +A small set of queries covering the Foundation's joint distribution: + +1. Single table, `SUM` aggregation, `GROUP BY` one dimension. +2. N:1 enrichment, dimension from join target. +3. N:1 enrichment with declared referential integrity (INNER vs LEFT diff). +4. Chasm trap: two facts joined through shared dimension. +5. Multi-grain arithmetic (derived metric via `metric_a / metric_b`). +6. `EXISTS_IN` semi-join. +7. `NOT EXISTS_IN` anti-semi-join (NULL-safe). +8. Query with `Where` + `Having` + `Order By` + `Limit`. +9. Composite-key relationship. +10. Ambiguous path disambiguation via `using_relationships`. + +Every new SPEC-defined behavior earns a golden. + +--- + +## 5. Layer 4: End-to-End + +**Location:** `tests/e2e/` + +**What to test.** +- The SQL we emit executes on DuckDB and returns the expected rows. +- Multi-dialect equivalence: where a query is supported on multiple + dialects, the observable rows are the same. + +**Harness.** `tests/e2e/conftest.py` provides an in-memory DuckDB with +fixture tables loaded from `tests/e2e/fixtures/`. Each test: + +1. Loads a model from `examples/models/`. +2. Builds and renders a plan. +3. Executes the SQL against DuckDB. +4. Asserts on the row set (not on the SQL). + +```python +# tests/e2e/test_chasm_trap.py +def test_chasm_resolves_via_merge(duckdb_conn) -> None: + rows = run_semantic_query( + duckdb_conn, + model="sales_returns.yaml", + query={"dimensions": ["customers.segment"], + "measures": ["orders.total_revenue", "returns.total_returns"]}, + ) + assert sorted(rows) == [("Ent", Decimal("700"), Decimal("50")), + ("SMB", Decimal("275"), Decimal("75"))] +``` + +**E2E tests are not SQL-shape tests.** If you find yourself asserting +"the SQL has three CTEs", that's a golden test, not E2E. E2E asserts on +rows. + +### 5.1 TPC-DS Spot Checks + +A small slice of TPC-DS queries rewritten as semantic queries lives in +`tests/e2e/tpcds/`. They run against DuckDB's bundled SF1 data and +assert row-count parity with a hand-rolled SQL reference. + +--- + +## 6. Mutation Testing + +**Why.** Coverage proves lines are executed. Mutation testing proves +those lines are *checked*. A line of `<=` mutated to `<` that survives +every test means the test corpus is not actually checking that line — +only touching it. + +**Tool.** `mutmut` by default; `cosmic-ray` as an alternative for +targeted algebra runs. Either is acceptable as long as per-module +thresholds in [`ALGEBRA_LAWS.md §4.1`](ALGEBRA_LAWS.md#41-per-module-thresholds) +are met. + +**Run frequency.** + +| When | What runs | +|:---|:---| +| Every PR | Fast-path mutation on `src/osi/planning/algebra/` (~5 min). | +| Nightly | Full project mutation (~30 min), publishes score history. | +| Before release | Full project mutation; release blocked if score regresses > 2%. | + +**Key rule.** A surviving mutation in `src/osi/planning/algebra/` is +treated as a P0 — that module IS the correctness boundary, and surviving +mutations mean the boundary is leaky. Write tests to kill it before +shipping. + +--- + +## 7. Test Directory Layout + +``` +tests/ + conftest.py # common fixtures (duckdb conn, sample models) + unit/ + parsing/ + planning/ + algebra/ # unit tests per algebra op + test_classify.py + test_joins.py + test_planner.py + codegen/ + diagnostics/ + properties/ + strategies.py # Hypothesis strategies (see ALGEBRA_LAWS.md) + reference.py # reference interpreter for equivalence laws + test_algebra_totality.py + test_algebra_purity.py + test_algebra_determinism.py + test_grain_closure.py + test_aggregate_idempotent.py + test_filter_commute.py + test_merge_associative.py + test_project_idempotent.py + test_enrich_preserves_rows.py + test_explosion_safety.py + test_chasm_safety.py + test_mn_rejection.py + test_sql_determinism.py + test_error_taxonomy.py + golden/ + basic/ + joins/ + composition/ + filters/ + _driver.py # shared golden-test harness + refresh.py # `make golden-refresh` entry point + e2e/ + conftest.py # DuckDB in-memory fixture + fixtures/ + sales_returns.sql + tpcds_sf1/ + test_single_table.py + test_enrichment.py + test_chasm_trap.py + test_exists_in.py + tpcds/ + test_q01.py + test_q35.py +``` + +--- + +## 8. Coverage Targets + +These are floors, not goals. See `INFRA.md §1.1` for the authoritative +table. + +| Surface | Line coverage | Branch coverage | Mutation score | +|:---|:---:|:---:|:---:| +| `src/osi/planning/algebra/` | ≥ 98% | ≥ 95% | ≥ 90% | +| `src/osi/planning/` (total) | ≥ 95% | ≥ 90% | ≥ 85% | +| `src/osi/parsing/` | ≥ 90% | ≥ 85% | ≥ 80% | +| `src/osi/codegen/` | ≥ 90% | ≥ 85% | ≥ 75% | +| `src/osi/diagnostics/` | ≥ 85% | ≥ 80% | ≥ 70% | +| Project overall | ≥ 92% | ≥ 88% | ≥ 75% | + +Coverage that hits line-coverage but misses branch-coverage is a red flag +— most untested branches are error paths, and the error paths are where +the `E4xxx` codes live. + +--- + +## 9. What NOT to Test + +A short list of anti-patterns that waste cycles and produce brittle +tests. Reviewers will push back on PRs that add these: + +1. **Don't test private helpers.** If `_compute_foo()` isn't exported, + don't import it from a test. Cover it via its public entry point. +2. **Don't test sqlglot or pytest.** External libraries are their own + project's job. +3. **Don't re-test the algebra laws in E2E.** The laws are property + tests; re-asserting them against DuckDB is slow and redundant. +4. **Don't use `pytest.approx` on identifiers or SQL strings.** Those + are exact comparisons — use `syrupy` or plain `==`. +5. **Don't assert on error-message text.** Assert on `error.code`. Message + text is user-facing prose and should be free to evolve. +6. **Don't share mutable fixtures between tests.** Every test gets its + own DuckDB connection; every fixture is frozen. +7. **Don't skip flaky tests.** Fix or delete — never `@skip`. The only + acceptable skip is `@pytest.mark.skipif(sys.platform == 'win32')` + when there is a real platform-specific limitation. diff --git a/impl/python/docs/mapping_bi_models_to_core_osi_abstractions.md b/impl/python/docs/mapping_bi_models_to_core_osi_abstractions.md new file mode 100644 index 0000000..ea92171 --- /dev/null +++ b/impl/python/docs/mapping_bi_models_to_core_osi_abstractions.md @@ -0,0 +1,346 @@ +# Mapping BI Models to the Core OSI Abstractions (Take 2 — Unified Set Model) + +Author: will.pugh +Date: 12 March 2026 +Status: Draft — corresponds to [Unified Set-Operation Proposal](../specs/OSI_Proposal_Resettable_Filters_take2.md) + +# Overview + +This document maps Power BI, Tableau, ThoughtSpot, and Looker onto the unified set-operation model for filter and grain proposed in Take 2. Both filter and grain use the same `{mode, exclude, include}` shape: + +```yaml +filter: + mode: RELATIVE | FIXED # RELATIVE = modify inherited; FIXED = declare from scratch + exclude: [field_names] # remove matching clauses/dims from inherited set + include: [expressions | dims] # add to the set + +grain: + mode: RELATIVE | FIXED + exclude: [dim_names] + include: [dim_names] +``` + +**Evaluation ordering**: inherit -> evaluate include refs in pre-exclude context -> exclude -> include -> evaluate -> propagate. + +For date spline and time intelligence operations, see §On Time Intelligence under Power BI. + +--- + +## Power BI (DAX) + +Power BI's DAX language is the most complex mapping target. CALCULATE modifies the filter context, and the evaluation context determines grain. The unified model handles this through parallel `exclude`/`include` on both filter and grain. + +### CALCULATE Patterns + +| DAX Operation | Example | OSI Filter | OSI Grain | +| :---- | :---- | :---- | :---- | +| Column filter | CALCULATE(SUM(Sales\[Amt\]), Color = "Red") | `exclude: [color]` `include: ["color = 'Red'"]` | `exclude: [color]` — always pair with filter. See §DAX Filter-Grain Coupling. | +| Multiple filters | CALCULATE(SUM(Sales\[Amt\]), Color = "Red", Region = "West") | `exclude: [color, region]` `include: ["color = 'Red' AND region = 'West'"]` | `exclude: [color, region]` | +| KEEPFILTERS | CALCULATE(SUM(Sales\[Amt\]), KEEPFILTERS(Color = "Red")) | `include: ["color = 'Red'"]` | RELATIVE (default) — KEEPFILTERS is additive, no grain change | + +### REMOVEFILTERS / ALL Patterns + +| DAX Operation | Example | OSI Filter | OSI Grain | +| :---- | :---- | :---- | :---- | +| REMOVEFILTERS(column) | CALCULATE(SUM(Sales\[Amt\]), REMOVEFILTERS(Color)) | `exclude: [color]` | `exclude: [color]` — always pair with filter | +| REMOVEFILTERS(table) | CALCULATE(SUM(Sales\[Amt\]), REMOVEFILTERS(Products)) | `exclude: [products.*]` | `exclude: [all Products dim fields]` | +| ALL() | CALCULATE(SUM(Sales\[Amt\]), ALL()) | `mode: FIXED` | `mode: FIXED, include: []` | +| ALL(column) | CALCULATE(SUM(Sales\[Amt\]), ALL(Color)) | `exclude: [color]` | `exclude: [color]` — synonym for REMOVEFILTERS(column) | +| ALL(table) | CALCULATE(SUM(Sales\[Amt\]), ALL(Products)) | `exclude: [products.*]` | `exclude: [all Products dim fields]` | +| ALLEXCEPT(Products, Color) | CALCULATE(SUM(Sales\[Amt\]), ALLEXCEPT(Products, Color)) | `exclude: [products.size, products.category, ...]` | `exclude: [products.size, products.category, ...]` | + +### FILTER(ALL(...)) Patterns + +| DAX Operation | Example | OSI Filter | OSI Grain | +| :---- | :---- | :---- | :---- | +| FILTER with ALL | CALCULATE(SUM(Sales\[Amt\]), FILTER(ALL(Products), Price > 100)) | `exclude: [products.*]` `include: ["price > 100"]` | `exclude: [all Products dim fields]` | +| FILTER compound | CALCULATE(SUM(Sales\[Amt\]), FILTER(ALL(Products), Color = "Red" AND Size = "Large")) | `exclude: [products.*]` `include: ["color = 'Red' AND size = 'Large'"]` | `exclude: [all Products dim fields]` | + +### Other DAX Constructs + +| DAX Operation | Example | OSI Filter | OSI Grain | +| :---- | :---- | :---- | :---- | +| VALUES as filter arg | CALCULATE(SUM(Sales\[Amt\]), VALUES(Color)) | `include: ["EXISTS_IN(color, )"]` | RELATIVE (default) | +| CROSSFILTER | CALCULATE(SUM(Sales\[Amt\]), CROSSFILTER(CustID, ID, BOTH)) | (no filter change) | RELATIVE. `joins: {path: [...], type: FULL}` | +| USERELATIONSHIP | CALCULATE(SUM(Sales\[Amt\]), USERELATIONSHIP(ShipDate, Date)) | (no filter change) | RELATIVE. `joins: {path: [...]}` | +| Context Transition | SUMX(Customers, CALCULATE(SUM(Sales\[Amt\]))) | (no filter change) | `include: [customers.id]` | + +### DAX Filter-Grain Coupling (CRITICAL) + +In DAX, the filter context IS the grain — visual row/column shelves create per-dimension filters that act as grouping. When CALCULATE/ALL/REMOVEFILTERS removes a filter, it also removes the corresponding grouping if that dimension was providing the grain. + +In OSI, filter and grain are independent. **For DAX patterns, always pair filter `exclude` with grain `exclude` on the same columns:** + +| Situation | Filter | Grain | +| :---- | :---- | :---- | +| CALCULATE / REMOVEFILTERS / ALL(column) | `exclude: [dim]` | `exclude: [dim]` — always pair both | +| ALL() (remove everything) | `mode: FIXED` | `mode: FIXED, include: []` | +| KEEPFILTERS | `include: ["expr"]` | RELATIVE (default) — no grain change | + +**Why always EXCLUDE on grain?** When the reset column is NOT a query dimension, `exclude: [dim]` on grain is a harmless no-op. When it IS a query dimension, EXCLUDE is required — without it, the metric computes at the query grain but with a different filter, causing incorrect join behavior (the CTE uses the reset column as a join key, but only contains rows matching the replacement value). + +**Importer guidance**: Always emit grain `exclude` alongside filter `exclude` for DAX patterns. Do not try to determine whether the columns are query dimensions — include EXCLUDE prophylactically. If the measure appears in multiple visuals with different dimension layouts, generate multiple OSI metrics. + +**Contrast with ThoughtSpot:** ThoughtSpot decouples grain and filter — `query_filters()-{dim}` changes the filter without the grain. This gives different semantics: each dimension value gets its own unfiltered total, rather than a replicated parent total. This is valid in ThoughtSpot but differs from DAX behavior. See §ThoughtSpot. + +### On Time Intelligence + +Period-over-period patterns work because `include` expression references are evaluated in the pre-exclude context (step 2 of evaluation ordering): + +```yaml +- name: period_start + expression: MIN(date.date) + grain: + mode: FIXED + include: [] + +- name: period_end + expression: MAX(date.date) + grain: + mode: FIXED + include: [] + +# exclude removes date filter; include adds shifted range. +# period_start/period_end in include are evaluated PRE-exclude, +# so they see the user's original date filter. +# grain exclude pairs with filter exclude (DAX filter-grain coupling). +- name: revenue_last_year + expression: SUM(orders.amount) + grain: + exclude: [date.date] + filter: + exclude: [date.date] + include: + - "date.date >= DATEADD(year, -1, period_start) + AND date.date <= DATEADD(year, -1, period_end)" +``` + +### Weaknesses + +| Operation | Supported | Notes | +| :---- | :---- | :---- | +| REMOVEFILTERS(table) / ALL(table) | Yes | Use `exclude: [products.*]` wildcard. | +| ALLSELECTED | No | Requires filter context stack / save-restore. No semantic layer supports this. | +| CROSSFILTER exclusion | No | Would need a join-path exclusion mechanism. | +| VALUES/HASONEVALUE/SELECTEDVALUE | Yes | Via EXISTS\_IN semi-join pattern. | +| Context Transition | Yes | Via `grain: {include: [pk]}` for iterator functions. | + +--- + +## Tableau + +Tableau's LOD expressions map directly to the unified model. The key mapping rule is: **Tableau FIXED = `mode: FIXED` on both filter and grain.** + +### LOD Expressions + +| Tableau Operation | Example | OSI Filter | OSI Grain | +| :---- | :---- | :---- | :---- | +| FIXED LOD | {FIXED [color]: SUM(qty)} | `mode: FIXED` | `mode: FIXED, include: [color]` | +| FIXED with context filter | {FIXED [color]: SUM(qty)} + context filter color='Red' | `mode: FIXED, include: ["color = 'Red'"]` | `mode: FIXED, include: [color]` | +| INCLUDE LOD | {INCLUDE [year]: SUM(qty)} | RELATIVE (default) | `include: [year]` | +| EXCLUDE LOD | {EXCLUDE [color]: SUM(qty)} | RELATIVE (default) | `exclude: [color]` | +| Regular calc | SUM(qty) | RELATIVE (default) | RELATIVE (default) | +| Regular calc + context filter | SUM(qty) + context filter color='Red' | `include: ["color = 'Red'"]` | RELATIVE (default) | + +### Importer Note: FIXED LOD = mode: FIXED on Both + +Tableau's `FIXED` keyword is a coupled construct — it simultaneously declares the grain and resets the filter context. In the unified model, importers emit `mode: FIXED` on both properties: + +- `grain: {mode: FIXED, include: [dims]}` — from the LOD dimension list +- `filter: {mode: FIXED}` — clears all dimension/measure filters + +Context filters survive into FIXED LODs. If present, add them to filter include: +`filter: {mode: FIXED, include: ["context_filter_expr"]}` + +`INCLUDE` and `EXCLUDE` LODs do NOT reset filters — they use `mode: RELATIVE` on both filter (inherit, no changes) and grain (add or remove dims). + +### Table Calculations (Window Functions) + +Tableau Table Calculations (RUNNING\_SUM, RANK, WINDOW\_AVG, INDEX, etc.) operate on the post-aggregation visual result set. OSI supports these via **window function expressions** applied after the aggregation step. + +| Tableau Table Calc | Example | OSI Expression | +| :---- | :---- | :---- | +| RUNNING\_SUM | Running total of SUM(Sales) | `SUM(total_revenue) OVER (PARTITION BY region ORDER BY product)` | +| RANK | Rank regions by revenue | `RANK() OVER (ORDER BY total_revenue DESC)` | +| WINDOW\_AVG | Moving average of SUM(Sales) | `AVG(total_revenue) OVER (PARTITION BY region ORDER BY product ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING)` | +| RUNNING\_COUNT | Running count of records | `COUNT(*) OVER (ORDER BY order_date)` — requires inline expression | +| INDEX | Row number within partition | `ROW_NUMBER() OVER (PARTITION BY region ORDER BY product)` | + +**Importer conversion note:** Tableau Table Calcs are **viz-specific** — the same calculated field can produce different results on different worksheets because the `PARTITION BY` and `ORDER BY` are derived from the visual's dimension layout (the "Compute Using" / "Addressing" / "Partitioning" settings). When converting to OSI, the importer must generate the explicit `PARTITION BY` and `ORDER BY` clauses based on the specific visualization the Table Calc was used in. A single Tableau Table Calc field used on three different worksheets may require three separate OSI metric definitions. + +**Future direction — addressing as a first-class property:** To achieve Tableau's flexibility (where a single field definition works across different visuals), OSI would need an **addressing property** for window functions that parallels what `grain` and `filter` do for aggregation context: + +```yaml +# Hypothetical future syntax (not yet supported) +- name: running_revenue + expression: "SUM(total_revenue)" + window: + mode: RELATIVE # inherit from query context + partition: query_dimensions() # partition by query dims + order: [order_date] # explicit ordering + frame: ROWS UNBOUNDED PRECEDING +``` + +This would let the planner derive `PARTITION BY` from the query's dimensions at plan time, just as `grain: RELATIVE` derives `GROUP BY` from the query. Without this, the window function's `PARTITION BY` and `ORDER BY` must be hardcoded in the metric expression. + +### Limitations + +| Feature | Notes | +| :---- | :---- | +| Table Calculations | ✅ **Supported** via window function expressions. Conversion requires generating viz-specific PARTITION BY / ORDER BY. See §Table Calculations above. | +| Dimension vs Measure Filters | Tableau applies dim filters before LODs, measure filters after. OSI doesn't distinguish — importers decide filter placement. | + +--- + +## ThoughtSpot + +ThoughtSpot's `group_aggregate` maps 1:1 to the unified model. The function's three arguments correspond directly to expression, grain, and filter: + +``` +group_aggregate(measure, grain_parameter, filter_parameter) +``` + +### Grain Mapping (second argument) + +| ThoughtSpot | OSI Grain | +| :---- | :---- | +| `{region, category}` | `mode: FIXED, include: [region, category]` | +| `query_groups()` | `mode: RELATIVE` (default) | +| `query_groups()+{year}` | `include: [year]` | +| `query_groups()-{category}` | `exclude: [category]` | +| `query_groups()-{date}+{year(date)}` | `exclude: [date], include: [year_date]` | + +The last row is the **key expressiveness improvement** — ThoughtSpot's mixed-mode `+`/`-` on `query_groups()` maps directly to `exclude` + `include` on grain. This was not expressible in the previous OSI model. + +### Filter Mapping (third argument) + +| ThoughtSpot | OSI Filter | +| :---- | :---- | +| `query_filters()` | `mode: RELATIVE` (default) | +| `query_filters()-{Ship Mode}` | `exclude: [ship_mode]` | +| `query_filters()+{Ship Mode='air'}` | `include: ["ship_mode = 'air'"]` | +| `{}` (empty) | `mode: FIXED` | +| `{Ship Mode='air'}` | `mode: FIXED, include: ["ship_mode = 'air'"]` | +| `{A='x', B='y'}` | `mode: FIXED, include: ["a = 'x'", "b = 'y'"]` | + +### Combined Examples + +| ThoughtSpot | OSI Filter | OSI Grain | +| :---- | :---- | :---- | +| `group_aggregate(sum(S), {cust}, {})` | `mode: FIXED` | `mode: FIXED, include: [cust]` | +| `group_aggregate(sum(S), qg(), qf())` | RELATIVE | RELATIVE | +| `group_aggregate(sum(S), qg()-{cat}, qf())` | RELATIVE | `exclude: [cat]` | +| `group_aggregate(sum(S), qg()-{dt}+{yr}, qf()-{ship})` | `exclude: [ship]` | `exclude: [dt], include: [yr]` | +| `group_aggregate(sum(S), qg(), qf()+{ship='air'})` | `include: ["ship='air'"]` | RELATIVE | + +### Expressiveness Comparison + +| Feature | ThoughtSpot | OSI (Unified) | Notes | +| :---- | :---- | :---- | :---- | +| Selective filter remove | `qf()-{col}` | `exclude: [col]` | 1:1 | +| Additive filter | `qf()+{expr}` | `include: ["expr"]` | 1:1 | +| Combined remove + add filter | Not in one call | `exclude: [col], include: ["expr"]` | OSI is more expressive (CALCULATE pattern) | +| Mixed grain +/- | `qg()-{d1}+{d2}` | `exclude: [d1], include: [d2]` | 1:1 (was a gap, now closed) | +| Full filter reset | `{}` | `mode: FIXED` | 1:1 | +| Explicit filter set | `{expr1, expr2}` | `mode: FIXED, include: ["e1", "e2"]` | 1:1 | + +OSI is a **strict superset** of ThoughtSpot's expressiveness — it can express everything ThoughtSpot can, plus the combined remove+add (CALCULATE) pattern that ThoughtSpot cannot do in a single call. + +### Limitations + +| Feature | Notes | +| :---- | :---- | +| Chasm trap handling | ThoughtSpot detects and refuses. OSI computes separate branches. | +| SpotIQ | Query generation, not a semantic model concept. | + +--- + +## Looker + +Looker's semantic layer (LookML) has the simplest mapping — measures have additive filters only, and grain comes from the query. + +### Measures + +```lookml +measure: completed_revenue { + type: sum + sql: ${amount} ;; + filters: [status: "completed"] +} +``` + +```yaml +# OSI equivalent +- name: completed_revenue + expression: SUM(amount) + filter: + include: ["status = 'completed'"] +``` + +Looker measures always use `mode: RELATIVE` with `include` only. No exclude, no FIXED. Grain is always query-determined (RELATIVE default). + +### Derived Tables + +Looker's derived tables use explicit SQL with independent GROUP BY and WHERE. These map to metrics with explicit grain and filter: + +```yaml +# Looker derived table → OSI metric +- name: customer_lifetime_value + expression: SUM(amount) + filter: + mode: FIXED + include: ["status = 'completed'"] + grain: + mode: FIXED + include: [customer_id] +``` + +### Templated Filters + +Looker's `{% condition %}` Liquid syntax injects user-provided filter values into derived table SQL. These map to additive filter `include` expressions that reference parameters: + +```yaml +filter: + include: ["region = :region_param"] +``` + +### Limitations + +| Feature | Notes | +| :---- | :---- | +| No grain override | LookML measures have no FIXED/INCLUDE/EXCLUDE. Grain always from query. | +| No filter reset | LookML measures can only add filters, never remove. | +| Derived tables | Full SQL control — grain and filter are independent by construction. | + +--- + +## Cross-Tool Summary + +| Capability | Power BI | Tableau | ThoughtSpot | Looker | OSI (Unified) | +| :---- | :---- | :---- | :---- | :---- | :---- | +| Inherit filters | Yes (default) | Yes (non-LOD) | `qf()` | Yes (default) | `mode: RELATIVE` | +| Add filter | KEEPFILTERS | Context filter | `qf()+{e}` | `filters:` | `include: [e]` | +| Remove filter | ALL/REMOVEFILTERS | FIXED (all) | `qf()-{c}` | No | `exclude: [c]` | +| Replace filter | CALCULATE | No | No (one call) | No | `exclude + include` | +| Clear all filters | ALL() | FIXED | `{}` | No | `mode: FIXED` | +| Fixed grain | Via context | FIXED | `{dims}` | Via SQL | `mode: FIXED, include:` | +| Add grain dim | Via context | INCLUDE | `qg()+{d}` | No | `include: [d]` | +| Remove grain dim | Via context | EXCLUDE | `qg()-{d}` | No | `exclude: [d]` | +| Mixed grain +/- | Via context | No | `qg()-{d1}+{d2}` | No | `exclude + include` | +| Window functions | DAX (RANKX, etc.) | Table Calculations | N/A | N/A | `RANK() OVER (...)` in expression | + +--- + +## Known Unsupported Patterns + +| Pattern | Source Tool | Why Unsupported | What Would Be Needed | +| :---- | :---- | :---- | :---- | +| ALLSELECTED | Power BI | Requires filter context stack (source-aware, not content-aware). | Save/restore mechanism. No other tool supports this. | +| Dynamic format strings | Power BI | Display-layer concern, not filter/grain. | Format expression on field metadata. | +| CROSSFILTER exclusion | Power BI | Disabling specific join paths. | Join-path exclusion mechanism on `joins` spec. | + +### Previously Listed as Unsupported (Now Supported) + +| Pattern | Status | Notes | +| :---- | :---- | :---- | +| Table Calculations | ✅ Supported | Window functions (RANK, ROW\_NUMBER, SUM OVER, LAG, LEAD, etc.) are implemented in the planner (Phase 9, Step 3). See §Table Calculations under Tableau. The remaining gap is **addressing as a first-class property** — currently PARTITION BY / ORDER BY must be hardcoded per metric rather than derived from query context. | diff --git a/impl/python/examples/README.md b/impl/python/examples/README.md new file mode 100644 index 0000000..065fcdf --- /dev/null +++ b/impl/python/examples/README.md @@ -0,0 +1,74 @@ +# Examples + +Runnable end-to-end OSI Foundation v0.1 scenarios. Every example is a +pair of: + +- a model file under `models/`, and +- a query file under `queries/`. + +The CLI commands assume you have `pip install -e .`'d the +implementation; without that, replace `osi` with `python -m osi`. + +## Quick recipe + +```bash +osi compile examples/models/demo_orders.yaml \ + examples/queries/revenue_by_region.json \ + --dialect duckdb +``` + +The command prints a complete `WITH … SELECT …` chain you can paste +into any DuckDB / Snowflake / Postgres console. + +## Available scenarios + +### `demo_orders` model + +| Scenario | Query file | What it shows | +|:--|:--|:--| +| Revenue by region | [`queries/revenue_by_region.json`](queries/revenue_by_region.json) | Single metric, cross-dataset enrichment (`orders → customers`), `ORDER BY` on the measure. | +| Multi-metric by segment | [`queries/multi_metric_by_segment.json`](queries/multi_metric_by_segment.json) | Three measures including a derived metric (`avg_order_value = total_revenue / order_count`). Shows metric composition and the `ADD_COLUMNS` codegen step. | +| Filtered — completed orders | [`queries/filtered_completed_orders.json`](queries/filtered_completed_orders.json) | `WHERE` predicate pushed to a `FILTER` step before the join. Demonstrates pre-aggregation predicate routing (D-005). | + +### `tpcds_thin` model + +| Scenario | Query file | What it shows | +|:--|:--|:--| +| Sales by item category | [`queries/tpcds_sales_by_category.json`](queries/tpcds_sales_by_category.json) | Single-fact aggregation with `LIMIT`. Three concurrent metrics — `SUM`, `COUNT`, `AVG` — at the `item.i_category` grain. | +| Sales vs returns by country | [`queries/tpcds_sales_vs_returns.json`](queries/tpcds_sales_vs_returns.json) | **Two-fact query.** `total_sales` (roots at `store_sales`) and `total_returns` (roots at `store_returns`) are planned independently then stitched with a `FULL OUTER JOIN` on the shared `customer` dimension (D-022). | + +## Adapting + +To plug an example into your own database: + +1. Copy the model under `examples/models/` and edit `source:` to point + at your tables. +2. Compile against the dialect you target (`--dialect snowflake`, + `--dialect duckdb`, `--dialect ansi`). +3. Paste the printed SQL into your engine, or wrap the call in Python + using the façade: + +```python +from osi import ( + compile_plan, + Dialect, + parse_semantic_model, + plan, + PlannerContext, + Reference, + SemanticQuery, +) + +result = parse_semantic_model("examples/models/demo_orders.yaml") +ctx = PlannerContext( + model=result.model, + namespace=result.namespace, + graph=result.graph, +) +query = SemanticQuery( + dimensions=(Reference(dataset="customers", name="region"),), + measures=(Reference(dataset=None, name="total_revenue"),), +) +sql = compile_plan(plan(query, ctx), dialect=Dialect.DUCKDB) +print(sql) +``` diff --git a/impl/python/examples/models/README.md b/impl/python/examples/models/README.md new file mode 100644 index 0000000..fc045ba --- /dev/null +++ b/impl/python/examples/models/README.md @@ -0,0 +1,18 @@ +# Example semantic models + +YAML files used by tests, tutorials, and the CLI demo scripts. Each file +is a valid Foundation model — no deferred features. + +When adding a model: + +- Use the file format defined in [`../../../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`](../../../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) §4 ("Semantic Model"). +- Declare `primary_key` on every dataset used on the one-side of a + relationship. +- Add at least one test under `tests/e2e/` that loads this model. + +## Available models + +| Model | What it covers | +|:--|:--| +| [`demo_orders.yaml`](demo_orders.yaml) | 3-dataset star schema (`orders`, `customers`, `line_items`). Covers simple and composite equijoin relationships, model-scoped metrics, a derived metric (`avg_order_value`), a named filter, and a parameter. Primary teaching model. | +| [`tpcds_thin.yaml`](tpcds_thin.yaml) | Thin slice of the TPC-DS schema: `store_sales`, `store_returns`, `item`, `customer`, `store`. Two independent fact datasets sharing conformed dimensions — the canonical example for multi-fact FULL OUTER stitch queries. | diff --git a/impl/python/examples/models/demo_orders.yaml b/impl/python/examples/models/demo_orders.yaml new file mode 100644 index 0000000..aa85c44 --- /dev/null +++ b/impl/python/examples/models/demo_orders.yaml @@ -0,0 +1,131 @@ +# Foundation example semantic model. +# +# Demonstrates every Foundation-supported shape: +# * datasets with primary keys and unique keys +# * dimensions, facts, and time dimensions +# * equijoin relationships (simple + composite) +# * referential integrity hints +# * model-scoped metrics (per-dataset metrics: blocks are deferred — +# see Proposed_OSI_Semantics.md §4.5 / D-027) +# * named filters +# * parameters +# +# No deferred features appear here. See Proposed_OSI_Semantics.md §10 for +# what is intentionally absent. +semantic_model: + - name: demo_orders + description: A minimal star-schema model for Foundation demos. + dialect: ANSI_SQL + + datasets: + - name: orders + source: sales.public.orders + primary_key: [order_id] + unique_keys: + - [order_id] + - [order_number] + description: Customer orders, one row per order. + fields: + - name: order_id + expression: order_id + role: dimension + description: Surrogate primary key. + - name: order_number + expression: order_number + role: dimension + description: Natural unique key. + - name: customer_id + expression: customer_id + role: dimension + description: FK to customers. + - name: order_date + expression: order_date + role: time_dimension + description: Date the order was placed. + - name: status + expression: status + role: dimension + - name: amount + expression: amount + role: fact + description: Order total in USD. + - name: discount + expression: discount + role: fact + description: Discount as a decimal [0, 1]. + + - name: customers + source: sales.public.customers + primary_key: [id] + description: Customer master. + fields: + - name: id + expression: id + role: dimension + - name: email + expression: email + role: dimension + - name: region + expression: region + role: dimension + - name: segment + expression: market_segment + role: dimension + + - name: line_items + source: sales.public.line_items + primary_key: [order_id, line_number] + description: Order line items, one row per item on an order. + fields: + - name: order_id + expression: order_id + role: dimension + - name: line_number + expression: line_number + role: dimension + - name: product_id + expression: product_id + role: dimension + - name: quantity + expression: quantity + role: fact + - name: unit_price + expression: unit_price + role: fact + + relationships: + - name: orders_to_customers + from: orders + to: customers + from_columns: [customer_id] + to_columns: [id] + description: Every order belongs to a customer. + + - name: line_items_to_orders + from: line_items + to: orders + from_columns: [order_id] + to_columns: [order_id] + description: Line items belong to one order. + + metrics: + - name: total_revenue + expression: SUM(orders.amount) + description: Total revenue across orders. + - name: order_count + expression: COUNT(orders.order_id) + description: Number of orders. + - name: avg_order_value + expression: total_revenue / NULLIF(order_count, 0) + description: Average order value — derived metric. + + filters: + - name: completed_orders + expression: status = 'completed' + description: Reusable filter for shipped/completed orders. + + parameters: + - name: min_amount + data_type: NUMBER + default: 0 + description: Minimum order amount for filtering. diff --git a/impl/python/examples/models/tpcds_thin.yaml b/impl/python/examples/models/tpcds_thin.yaml new file mode 100644 index 0000000..6779d41 --- /dev/null +++ b/impl/python/examples/models/tpcds_thin.yaml @@ -0,0 +1,153 @@ +semantic_model: + - name: tpcds_thin + description: | + A deliberately tiny slice of the TPC-DS schema, sized for the thin + slice: four datasets, five N:1 relationships, SUM/COUNT/AVG + metrics. Drives the Phase 6 E2E harness and the pytest-benchmark + baseline. Column names match TPC-DS; sources are synthetic so we + can seed deterministic data at test time. + dialect: ANSI_SQL + datasets: + - name: store_sales + source: tpcds.store_sales + primary_key: [ss_ticket_number, ss_item_sk] + fields: + - name: ss_ticket_number + expression: ss_ticket_number + role: dimension + - name: ss_item_sk + expression: ss_item_sk + role: dimension + - name: ss_customer_sk + expression: ss_customer_sk + role: dimension + - name: ss_store_sk + expression: ss_store_sk + role: dimension + - name: ss_sold_date_sk + expression: ss_sold_date_sk + role: dimension + - name: ss_quantity + expression: ss_quantity + role: fact + - name: ss_ext_sales_price + expression: ss_ext_sales_price + role: fact + - name: ss_net_profit + expression: ss_net_profit + role: fact + + - name: store_returns + source: tpcds.store_returns + primary_key: [sr_ticket_number, sr_item_sk] + fields: + - name: sr_ticket_number + expression: sr_ticket_number + role: dimension + - name: sr_item_sk + expression: sr_item_sk + role: dimension + - name: sr_customer_sk + expression: sr_customer_sk + role: dimension + - name: sr_store_sk + expression: sr_store_sk + role: dimension + - name: sr_return_amt + expression: sr_return_amt + role: fact + + - name: item + source: tpcds.item + primary_key: [i_item_sk] + fields: + - name: i_item_sk + expression: i_item_sk + role: dimension + - name: i_category + expression: i_category + role: dimension + - name: i_class + expression: i_class + role: dimension + - name: i_brand + expression: i_brand + role: dimension + + - name: customer + source: tpcds.customer + primary_key: [c_customer_sk] + fields: + - name: c_customer_sk + expression: c_customer_sk + role: dimension + - name: c_birth_country + expression: c_birth_country + role: dimension + - name: c_preferred_cust_flag + expression: c_preferred_cust_flag + role: dimension + + - name: store + source: tpcds.store + primary_key: [s_store_sk] + fields: + - name: s_store_sk + expression: s_store_sk + role: dimension + - name: s_state + expression: s_state + role: dimension + - name: s_country + expression: s_country + role: dimension + + relationships: + - name: ss_to_item + from: store_sales + to: item + from_columns: [ss_item_sk] + to_columns: [i_item_sk] + - name: ss_to_customer + from: store_sales + to: customer + from_columns: [ss_customer_sk] + to_columns: [c_customer_sk] + - name: ss_to_store + from: store_sales + to: store + from_columns: [ss_store_sk] + to_columns: [s_store_sk] + - name: sr_to_item + from: store_returns + to: item + from_columns: [sr_item_sk] + to_columns: [i_item_sk] + - name: sr_to_customer + from: store_returns + to: customer + from_columns: [sr_customer_sk] + to_columns: [c_customer_sk] + - name: sr_to_store + from: store_returns + to: store + from_columns: [sr_store_sk] + to_columns: [s_store_sk] + + metrics: + - name: total_sales + expression: SUM(store_sales.ss_ext_sales_price) + - name: total_profit + expression: SUM(store_sales.ss_net_profit) + - name: total_qty + expression: SUM(store_sales.ss_quantity) + - name: order_count + expression: COUNT(store_sales.ss_ticket_number) + - name: distinct_customers + expression: COUNT(DISTINCT store_sales.ss_customer_sk) + - name: avg_ticket + expression: AVG(store_sales.ss_ext_sales_price) + - name: total_returns + expression: SUM(store_returns.sr_return_amt) + - name: return_count + expression: COUNT(store_returns.sr_ticket_number) diff --git a/impl/python/examples/queries/filtered_completed_orders.json b/impl/python/examples/queries/filtered_completed_orders.json new file mode 100644 index 0000000..2f3b247 --- /dev/null +++ b/impl/python/examples/queries/filtered_completed_orders.json @@ -0,0 +1,14 @@ +{ + "_comment": "Revenue and order count for completed orders only, grouped by region. The WHERE predicate is pushed down before the join — the planner emits a FILTER step (step_001) that applies status = 'completed' directly on the orders SOURCE, then enriches with customers. Shows pre-aggregation WHERE routing (D-005). Render with: osi compile examples/models/demo_orders.yaml examples/queries/filtered_completed_orders.json --dialect duckdb", + "dimensions": [ + {"dataset": "customers", "name": "region"} + ], + "measures": [ + {"name": "total_revenue"}, + {"name": "order_count"} + ], + "where": "orders.status = 'completed'", + "order_by": [ + {"target": {"name": "total_revenue"}, "descending": true} + ] +} diff --git a/impl/python/examples/queries/multi_metric_by_segment.json b/impl/python/examples/queries/multi_metric_by_segment.json new file mode 100644 index 0000000..eb98015 --- /dev/null +++ b/impl/python/examples/queries/multi_metric_by_segment.json @@ -0,0 +1,15 @@ +{ + "_comment": "Three metrics at the customer-segment grain, including a derived metric (avg_order_value = total_revenue / order_count). Shows metric composition and ADD_COLUMNS codegen. Render with: osi compile examples/models/demo_orders.yaml examples/queries/multi_metric_by_segment.json --dialect duckdb", + "dimensions": [ + {"dataset": "customers", "name": "segment"} + ], + "measures": [ + {"name": "total_revenue"}, + {"name": "order_count"}, + {"name": "avg_order_value"} + ], + "order_by": [ + {"target": {"name": "total_revenue"}, "descending": true} + ], + "limit": 10 +} diff --git a/impl/python/examples/queries/revenue_by_region.json b/impl/python/examples/queries/revenue_by_region.json new file mode 100644 index 0000000..d5ca333 --- /dev/null +++ b/impl/python/examples/queries/revenue_by_region.json @@ -0,0 +1,12 @@ +{ + "_comment": "Foundation example: aggregate total revenue by customer region. Uses the demo_orders.yaml model. The planner enriches orders with customers on customer_id and aggregates at the region grain. Render with: osi compile examples/models/demo_orders.yaml examples/queries/revenue_by_region.json --dialect duckdb", + "dimensions": [ + {"dataset": "customers", "name": "region"} + ], + "measures": [ + {"name": "total_revenue"} + ], + "order_by": [ + {"target": {"name": "total_revenue"}, "descending": true} + ] +} diff --git a/impl/python/examples/queries/tpcds_sales_by_category.json b/impl/python/examples/queries/tpcds_sales_by_category.json new file mode 100644 index 0000000..a602949 --- /dev/null +++ b/impl/python/examples/queries/tpcds_sales_by_category.json @@ -0,0 +1,15 @@ +{ + "_comment": "Sales totals, order count, and average ticket size by item category using the tpcds_thin model. Single-fact aggregation with a cross-dataset dimension (store_sales → item). Render with: osi compile examples/models/tpcds_thin.yaml examples/queries/tpcds_sales_by_category.json --dialect duckdb", + "dimensions": [ + {"dataset": "item", "name": "i_category"} + ], + "measures": [ + {"name": "total_sales"}, + {"name": "order_count"}, + {"name": "avg_ticket"} + ], + "order_by": [ + {"target": {"name": "total_sales"}, "descending": true} + ], + "limit": 20 +} diff --git a/impl/python/examples/queries/tpcds_sales_vs_returns.json b/impl/python/examples/queries/tpcds_sales_vs_returns.json new file mode 100644 index 0000000..89bcf7f --- /dev/null +++ b/impl/python/examples/queries/tpcds_sales_vs_returns.json @@ -0,0 +1,14 @@ +{ + "_comment": "Sales totals vs return amounts by customer birth country — a two-fact query. total_sales roots at store_sales and total_returns roots at store_returns; both join to the shared customer dimension. The planner plans each fact branch independently then stitches with a FULL OUTER JOIN on c_birth_country (D-022, M:N stitch). Shows the MERGE codegen step (step_006 in the output). Render with: osi compile examples/models/tpcds_thin.yaml examples/queries/tpcds_sales_vs_returns.json --dialect duckdb", + "dimensions": [ + {"dataset": "customer", "name": "c_birth_country"} + ], + "measures": [ + {"name": "total_sales"}, + {"name": "total_returns"} + ], + "order_by": [ + {"target": {"name": "total_sales"}, "descending": true} + ], + "limit": 25 +} diff --git a/impl/python/pyproject.toml b/impl/python/pyproject.toml new file mode 100644 index 0000000..68f958b --- /dev/null +++ b/impl/python/pyproject.toml @@ -0,0 +1,305 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "osi-python" +version = "0.1.0" +description = "Foundation reference implementation for the Open Semantic Interchange (OSI) standard" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [ + {name = "OSI Contributors", email = "noreply@example.com"} +] +keywords = ["osi", "semantic", "sql", "analytics", "query-generation"] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Database", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +dependencies = [ + "pydantic>=2.6,<3.0", + "sqlglot>=26.0,<28.0", + "pyyaml>=6.0", + "networkx>=3.0", +] + +[project.scripts] +# Registers the ``osi`` console command so ``pip install -e .`` (or +# ``pip install osi-python``) puts ``osi describe``, ``osi explain``, +# ``osi resolve``, ``osi compile``, and ``osi explain-code`` on the +# user's PATH. Phase 10 / 10b B3 — docs assumed this binary existed +# before this entry was added. +osi = "osi.cli:main" + +[project.optional-dependencies] +dev = [ + # Test runner + "pytest>=8.0", + "pytest-cov>=5.0", + "pytest-xdist>=3.0", # Parallel test execution + "pytest-benchmark>=4.0", # Performance regression detection + # Property-based testing (load-bearing for algebra laws) + "hypothesis>=6.100", + # Snapshot testing (used by tests/golden/) + "syrupy>=4.6", + # E2E execution harness + "duckdb>=0.10.0", + # Mutation testing (gate per INFRA.md §1.1) + "mutmut>=2.4", + # Static analysis + "mypy>=1.10", + "import-linter>=2.0", # enforces one-way import flow + # Formatting & lint + "black>=24.0", + "isort>=5.13", + "flake8>=7.0", + "flake8-docstrings>=1.7", + # Git hooks + "pre-commit>=3.7", +] + +[project.urls] +Homepage = "https://github.com/osi/osi-python" +Documentation = "https://github.com/osi/osi-python/blob/main/README.md" +Repository = "https://github.com/osi/osi-python" +Issues = "https://github.com/osi/osi-python/issues" + +# --------------------------------------------------------------------------- +# Packaging +# --------------------------------------------------------------------------- + +[tool.setuptools.packages.find] +where = ["src"] + +# --------------------------------------------------------------------------- +# pytest +# --------------------------------------------------------------------------- + +[tool.pytest.ini_options] +testpaths = ["tests", "conformance/tests"] +pythonpath = ["src"] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" +addopts = [ + "-v", + "--strict-markers", + "--strict-config", + "--tb=short", + # Skip pytest-benchmark timings by default; they belong to `make bench`. + # We still collect and *run* benchmark tests (so their asserts, if any, + # still exercise the code paths) but don't spend time on warmup/rounds. + "--benchmark-disable", + "--cov=osi", + "--cov-branch", + "--cov-report=term-missing", + "--cov-report=html", + # Coverage floor. See INFRA.md §1.1 for the ratchet plan. The floor + # currently sits at 84% because the scalar / bridge / nested-aggregate + # planner branches are exercised only by the compliance suite, not by + # focused unit tests; I-57 in INFRA.md §3 tracks lifting it back to + # 90% by adding direct unit coverage for those branches. + "--cov-fail-under=84", +] +markers = [ + "unit: fast, focused single-function tests", + "property: Hypothesis-based universal-law tests", + "golden: snapshot plan / SQL tests", + "e2e: DuckDB-executed row-level tests", + "slow: tests that take longer than 1s", + "benchmark: pytest-benchmark performance tests", +] + +# --------------------------------------------------------------------------- +# coverage +# --------------------------------------------------------------------------- + +[tool.coverage.run] +source = ["src"] +omit = ["*/tests/*", "*/__pycache__/*"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "@abstractmethod", + "@overload", +] + +# --------------------------------------------------------------------------- +# mypy — strict from day one (INFRA.md [I-DEC-7]) +# --------------------------------------------------------------------------- + +[tool.mypy] +python_version = "3.11" +strict = true +warn_unreachable = true +warn_return_any = true +warn_redundant_casts = true +warn_unused_ignores = true +disallow_any_generics = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_calls = true +check_untyped_defs = true +no_implicit_optional = true +strict_equality = true +strict_concatenate = true +enable_error_code = ["redundant-self", "redundant-expr", "unused-ignore"] + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false +disallow_untyped_calls = false + +# External libs without complete stubs +[[tool.mypy.overrides]] +module = ["sqlglot.*", "yaml", "networkx.*", "duckdb", "hypothesis.*", "mutmut.*", "syrupy.*"] +ignore_missing_imports = true + +# --------------------------------------------------------------------------- +# black +# --------------------------------------------------------------------------- + +[tool.black] +line-length = 88 +target-version = ["py311"] + +# --------------------------------------------------------------------------- +# isort +# --------------------------------------------------------------------------- + +[tool.isort] +profile = "black" +line_length = 88 +known_first_party = ["osi"] + +# --------------------------------------------------------------------------- +# import-linter — enforces the one-way flow in ARCHITECTURE.md §1.1 +# --------------------------------------------------------------------------- + +[tool.importlinter] +root_packages = ["osi"] + +[[tool.importlinter.contracts]] +name = "Layer 1 (parsing) must not import from planning or codegen" +type = "forbidden" +source_modules = ["osi.parsing"] +forbidden_modules = ["osi.planning", "osi.codegen"] + +[[tool.importlinter.contracts]] +name = "Layer 2 (planning) must not import from codegen" +type = "forbidden" +source_modules = ["osi.planning"] +forbidden_modules = ["osi.codegen"] + +[[tool.importlinter.contracts]] +name = "Layer 3 (codegen) must not import from parsing" +type = "forbidden" +source_modules = ["osi.codegen"] +forbidden_modules = ["osi.parsing"] + +# The CLI is the user-facing entry point — it sits *above* the +# three-layer pipeline. No layer should call back into the CLI; +# doing so would create a presentation-to-engine dependency that +# breaks the layered architecture and makes the engine +# untestable without the CLI surface. +[[tool.importlinter.contracts]] +name = "CLI is a sink: no layer may import osi.cli or osi.__main__" +type = "forbidden" +source_modules = ["osi.parsing", "osi.planning", "osi.codegen", "osi.common", "osi.diagnostics"] +forbidden_modules = ["osi.cli", "osi.__main__"] + +# Diagnostics formats errors for humans — it lives next to the CLI, +# above the three-layer pipeline. Planning and codegen must not +# depend on it; if they did, the engine could not be embedded in a +# host application without dragging in the diagnostics layer's +# presentation choices. +[[tool.importlinter.contracts]] +name = "Diagnostics is a presentation layer: planning/codegen may not import it" +type = "forbidden" +source_modules = ["osi.planning", "osi.codegen", "osi.parsing"] +forbidden_modules = ["osi.diagnostics"] + +# FoundationFlags is a parse-time-only concern: it controls how the +# parser treats deferred features. By the time we reach codegen the +# model has been parsed and the flag has either been honoured or +# rejected, so codegen should never need to look at flags. +# Enforcing this prevents a "feature flag tunnel" from forming. +[[tool.importlinter.contracts]] +name = "Codegen does not see FoundationFlags (parse-time-only concern)" +type = "forbidden" +source_modules = ["osi.codegen"] +forbidden_modules = ["osi.config"] + +# NOTE: The "algebra state is only constructed inside algebra" contract +# (Phase 3 code-review I6) requires refactoring ``osi.planning.steps`` to +# stop instantiating ``Column`` directly — that file is one of the three +# documented LOC-exception files and is queued for its own refactor +# under INFRA.md I-56. Once steps.py is split, we land the import-linter +# contract: +# +# [[tool.importlinter.contracts]] +# name = "algebra state constructed only inside algebra" +# type = "forbidden" +# source_modules = ["osi.planning", "osi.codegen"] +# forbidden_modules = ["osi.planning.algebra.state"] +# ignore_imports = [ +# "osi.planning.algebra -> osi.planning.algebra.state", +# "osi.planning.algebra.* -> osi.planning.algebra.state", +# ] +# +# Tracked in INFRA.md §3 alongside I-56. + +# --------------------------------------------------------------------------- +# mutmut +# --------------------------------------------------------------------------- + +[tool.mutmut] +# mutmut 3.x configuration keys. The 2.x ``paths_to_mutate`` / +# ``runner`` keys are no longer recognised. +# +# Notes for runners: +# * mutmut 3 copies the project to ``mutants/`` and runs the test +# suite there, so anything referenced by tests via a relative path +# must be in ``also_copy``. +# * The default test runner picks up our ``--cov-fail-under=90`` from +# pyproject's pytest config, which would make every mutated run +# fail on coverage rather than on the actual mutation. We override +# coverage off via ``pytest_add_cli_args`` so each test run reports +# its own pass/fail. +source_paths = ["src/osi/"] +pytest_add_cli_args_test_selection = [ + "tests/unit/", + "tests/properties/", +] +pytest_add_cli_args = [ + "--no-cov", + "-p", "no:cacheprovider", +] +also_copy = [ + "examples/", + "docs/", +] +# macOS: ``setproctitle`` uses CoreFoundation APIs that are not +# fork-safe; disable to avoid segfaults in mutmut child processes. +use_setproctitle = false +# Exclude the auto-generated error-catalog table and the parser's +# vendored grammar; mutating these is high-noise / low-signal. +do_not_mutate = [ + "src/osi/diagnostics/error_catalog.py", +] +max_stack_depth = 8 diff --git a/impl/python/scripts/run_all_tests.sh b/impl/python/scripts/run_all_tests.sh new file mode 100755 index 0000000..ecb7569 --- /dev/null +++ b/impl/python/scripts/run_all_tests.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# run_all_tests.sh — run every test category for impl/python and emit a +# single readable Markdown report at test-results/REPORT.md. +# +# Usage: +# scripts/run_all_tests.sh # everything except full mutation +# scripts/run_all_tests.sh --with-mutation-fast # + algebra mutation (~5 min) +# scripts/run_all_tests.sh --with-mutation # + full mutation (~30 min) +# scripts/run_all_tests.sh --skip-static # skip lint/typecheck/architecture +# +# The script never aborts on the first failure — every stage runs and its +# status is captured. Final exit code is non-zero if any stage failed. + +set -u # do NOT set -e; we want every stage to run and be reported + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJ_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJ_ROOT" + +RESULTS_DIR="$PROJ_ROOT/test-results" +RAW_DIR="$RESULTS_DIR/raw" +mkdir -p "$RAW_DIR" + +PYTHON="${PYTHON:-python}" + +# ---------------------------------------------------------------------- +# Flags +# ---------------------------------------------------------------------- +WITH_MUTATION="" +SKIP_STATIC="" + +for arg in "$@"; do + case "$arg" in + --with-mutation) WITH_MUTATION="full" ;; + --with-mutation-fast) WITH_MUTATION="fast" ;; + --skip-static) SKIP_STATIC="1" ;; + -h|--help) + grep '^# ' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Unknown flag: $arg" >&2 + exit 2 + ;; + esac +done + +# ---------------------------------------------------------------------- +# Result tracking +# ---------------------------------------------------------------------- +declare -a STAGE_NAMES +declare -a STAGE_STATUSES +declare -a STAGE_LOGS +declare -a STAGE_DURATIONS + +run_stage() { + # run_stage -- + local name="$1"; shift + local log_basename="$1"; shift + shift # discard "--" + local logfile="$RAW_DIR/${log_basename}.log" + + echo + echo ">>> [${name}]" + local start_ts="$(date +%s)" + if "$@" > "$logfile" 2>&1; then + local status="PASS" + else + local status="FAIL" + fi + local end_ts="$(date +%s)" + local duration="$((end_ts - start_ts))" + + STAGE_NAMES+=("$name") + STAGE_STATUSES+=("$status") + STAGE_LOGS+=("$logfile") + STAGE_DURATIONS+=("$duration") + echo " -> $status (${duration}s) $logfile" +} + +# ---------------------------------------------------------------------- +# Stages +# ---------------------------------------------------------------------- + +if [[ -z "$SKIP_STATIC" ]]; then + run_stage "Lint (black/isort/flake8)" lint -- make lint + run_stage "Typecheck (mypy strict)" typecheck -- make typecheck + run_stage "Architecture (import-linter)" architecture -- make architecture + run_stage "File-size cap (600 LOC)" file_size -- make audit-file-size +fi + +# Always produce JUnit XML so we can extract per-test detail. +JUNIT="$RAW_DIR/pytest.junit.xml" +COV_JSON="$RAW_DIR/coverage.json" + +run_stage "Unit tests" test_unit -- $PYTHON -m pytest tests/unit/ --junit-xml="$RAW_DIR/junit_unit.xml" --no-cov +run_stage "Property tests (Hypothesis)" test_property -- $PYTHON -m pytest tests/properties/ --junit-xml="$RAW_DIR/junit_property.xml" --no-cov +run_stage "Golden tests (plan / SQL snapshots)" test_golden -- $PYTHON -m pytest tests/golden/ --junit-xml="$RAW_DIR/junit_golden.xml" --no-cov +run_stage "E2E tests (DuckDB)" test_e2e -- $PYTHON -m pytest tests/e2e/ --junit-xml="$RAW_DIR/junit_e2e.xml" --no-cov +run_stage "Adapter smoke tests" test_adapter -- $PYTHON -m pytest conformance/tests/ --junit-xml="$RAW_DIR/junit_adapter.xml" --no-cov + +# Coverage across the union of test categories that produce signal. +run_stage "Coverage (combined)" coverage -- $PYTHON -m pytest \ + tests/unit/ tests/properties/ tests/golden/ tests/e2e/ conformance/tests/ \ + --cov=osi --cov-branch \ + --cov-report=term --cov-report=html:"$RESULTS_DIR/htmlcov" \ + --cov-report=json:"$COV_JSON" \ + --junit-xml="$JUNIT" + +# Mutation +if [[ "$WITH_MUTATION" == "fast" ]]; then + run_stage "Mutation testing (algebra fast-path)" mutation_fast -- make mutation-fast +elif [[ "$WITH_MUTATION" == "full" ]]; then + run_stage "Mutation testing (full)" mutation_full -- make mutation +fi + +# ---------------------------------------------------------------------- +# Write report +# ---------------------------------------------------------------------- + +STAGES_FILE="$RAW_DIR/stages.tsv" +{ + printf '%s\n' "${#STAGE_NAMES[@]}" + for i in "${!STAGE_NAMES[@]}"; do + printf '%s\t%s\t%s\t%s\n' \ + "${STAGE_NAMES[$i]}" \ + "${STAGE_STATUSES[$i]}" \ + "${STAGE_LOGS[$i]}" \ + "${STAGE_DURATIONS[$i]}" + done +} > "$STAGES_FILE" + +REPORT="$RESULTS_DIR/REPORT.md" +$PYTHON "$SCRIPT_DIR/write_test_report.py" \ + --raw-dir "$RAW_DIR" \ + --results-dir "$RESULTS_DIR" \ + --output "$REPORT" \ + --mutation "${WITH_MUTATION:-none}" \ + --stages-file "$STAGES_FILE" + +echo +echo "===============================================================" +echo " Report: $REPORT" +echo "===============================================================" + +# Exit nonzero if any stage failed. +for s in "${STAGE_STATUSES[@]}"; do + if [[ "$s" != "PASS" ]]; then + exit 1 + fi +done +exit 0 diff --git a/impl/python/scripts/run_mutation.sh b/impl/python/scripts/run_mutation.sh new file mode 100755 index 0000000..3896227 --- /dev/null +++ b/impl/python/scripts/run_mutation.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# run_mutation.sh — run mutation testing and summarise the result into the +# main test report. +# +# Usage: +# scripts/run_mutation.sh # full mutation run (~30 min) +# scripts/run_mutation.sh --fast # algebra only (~5 min) +# +# This is a thin wrapper around `make mutation` / `make mutation-fast` that +# captures the mutmut summary into test-results/raw/ so write_test_report.py +# can fold it into REPORT.md. + +set -u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJ_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$PROJ_ROOT" + +RAW_DIR="$PROJ_ROOT/test-results/raw" +mkdir -p "$RAW_DIR" + +PYTHON="${PYTHON:-python}" + +MODE="full" +for arg in "$@"; do + case "$arg" in + --fast) MODE="fast" ;; + --full) MODE="full" ;; + -h|--help) + grep '^# ' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) echo "Unknown flag: $arg" >&2; exit 2 ;; + esac +done + +if [[ "$MODE" == "fast" ]]; then + LOG="$RAW_DIR/mutation_fast.log" + echo ">>> mutation-fast (algebra only, ~5 min) — log: $LOG" + make mutation-fast 2>&1 | tee "$LOG" +else + LOG="$RAW_DIR/mutation_full.log" + echo ">>> mutation full (~30 min) — log: $LOG" + make mutation 2>&1 | tee "$LOG" +fi + +# Capture mutmut's textual summary. +SUMMARY="$RAW_DIR/mutation_${MODE}_summary.txt" +$PYTHON -m mutmut results > "$SUMMARY" 2>&1 || true +echo +echo ">>> mutmut results captured: $SUMMARY" +echo ">>> Re-run scripts/run_all_tests.sh to refresh REPORT.md" diff --git a/impl/python/scripts/write_test_report.py b/impl/python/scripts/write_test_report.py new file mode 100755 index 0000000..bcf963c --- /dev/null +++ b/impl/python/scripts/write_test_report.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""write_test_report.py — build a single readable Markdown test report. + +The report consolidates: + +* every stage's PASS / FAIL status and duration +* per-category test counts (parsed from JUnit XML) +* combined coverage (line + branch) from coverage.json +* mutation testing summary (parsed from mutmut output) when applicable +* slowest 10 tests across all categories +* every failing test, with a direct path to its log + +The input is the raw output of ``scripts/run_all_tests.sh``. Run that script +to refresh ``test-results/REPORT.md``; do not invoke this writer directly. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable + + +@dataclass +class Stage: + name: str + status: str + log_path: str + duration_s: int + + +@dataclass +class JUnitSummary: + category: str + total: int = 0 + failures: int = 0 + errors: int = 0 + skipped: int = 0 + duration_s: float = 0.0 + slowest: list[tuple[str, float]] = field(default_factory=list) + failing_tests: list[str] = field(default_factory=list) + + +def _read_stages(stages_file: Path) -> list[Stage]: + raw = stages_file.read_text().splitlines() + if not raw: + return [] + # First line is stage count; following lines are tab-separated. + count = int(raw[0]) + stages: list[Stage] = [] + for line in raw[1 : 1 + count]: + name, status, log, duration = line.split("\t") + stages.append(Stage(name, status, log, int(duration))) + return stages + + +def _summarise_junit(category: str, path: Path) -> JUnitSummary | None: + if not path.exists(): + return None + try: + root = ET.parse(path).getroot() + except ET.ParseError: + return None + suite = root if root.tag == "testsuite" else root.find("testsuite") + if suite is None: + return None + summary = JUnitSummary(category=category) + summary.total = int(suite.get("tests", "0")) + summary.failures = int(suite.get("failures", "0")) + summary.errors = int(suite.get("errors", "0")) + summary.skipped = int(suite.get("skipped", "0")) + summary.duration_s = float(suite.get("time", "0") or 0.0) + cases: list[tuple[str, float]] = [] + for case in suite.iter("testcase"): + name = f"{case.get('classname', '')}.{case.get('name', '')}" + t = float(case.get("time", "0") or 0.0) + cases.append((name, t)) + if case.find("failure") is not None or case.find("error") is not None: + summary.failing_tests.append(name) + summary.slowest = sorted(cases, key=lambda c: c[1], reverse=True)[:5] + return summary + + +def _read_coverage(cov_json: Path) -> dict[str, float] | None: + if not cov_json.exists(): + return None + try: + data = json.loads(cov_json.read_text()) + except json.JSONDecodeError: + return None + totals = data.get("totals") or {} + return { + "line_pct": float(totals.get("percent_covered", 0.0)), + "branch_pct": float(totals.get("percent_covered_branches", 0.0)), + "missing": int(totals.get("missing_lines", 0)), + "covered": int(totals.get("covered_lines", 0)), + "num_statements": int(totals.get("num_statements", 0)), + } + + +_MUTMUT_LINE = re.compile( + r"(\d+/\d+)\s+\((?P\d+)\s*killed,\s*(?P\d+)\s*survived", + re.IGNORECASE, +) + + +def _read_mutation(raw_dir: Path, mode: str) -> dict[str, int | float] | None: + if mode == "none": + return None + summary_path = raw_dir / f"mutation_{mode}_summary.txt" + log_path = raw_dir / f"mutation_{mode}.log" + if not summary_path.exists(): + summary_path = log_path + if not summary_path.exists(): + return None + text = summary_path.read_text(errors="replace") + killed = survived = 0 + suspicious = 0 + timeout = 0 + skipped = 0 + # mutmut 3.x summary lines: + # "Killed N out of M (X%)" + # "Surviving N" + for line in text.splitlines(): + if m := re.match(r"^\s*(\d+) killed", line, re.IGNORECASE): + killed = int(m.group(1)) + if m := re.match(r"^\s*(\d+) survived", line, re.IGNORECASE): + survived = int(m.group(1)) + if m := re.match(r"^\s*(\d+) timeout", line, re.IGNORECASE): + timeout = int(m.group(1)) + if m := re.match(r"^\s*(\d+) suspicious", line, re.IGNORECASE): + suspicious = int(m.group(1)) + if m := re.match(r"^\s*(\d+) skipped", line, re.IGNORECASE): + skipped = int(m.group(1)) + total = killed + survived + timeout + suspicious + skipped + score = (killed / total * 100.0) if total else 0.0 + return { + "killed": killed, + "survived": survived, + "timeout": timeout, + "suspicious": suspicious, + "skipped": skipped, + "total": total, + "score_pct": score, + } + + +# ---------------------------------------------------------------------- +# Rendering +# ---------------------------------------------------------------------- + + +def _format_duration(seconds: float) -> str: + seconds = int(round(seconds)) + if seconds < 60: + return f"{seconds}s" + m, s = divmod(seconds, 60) + if m < 60: + return f"{m}m{s:02d}s" + h, m = divmod(m, 60) + return f"{h}h{m:02d}m" + + +def _badge(status: str) -> str: + return {"PASS": "PASS", "FAIL": "FAIL", "SKIP": "SKIP"}.get(status, status) + + +def _render( + stages: list[Stage], + junits: list[JUnitSummary], + coverage: dict[str, float] | None, + mutation: dict[str, int | float] | None, +) -> str: + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + overall_pass = all(s.status == "PASS" for s in stages) + + lines: list[str] = [] + lines.append("# Test Report — impl/python") + lines.append("") + lines.append(f"_Generated {now}_") + lines.append("") + lines.append(f"**Overall:** {'PASS' if overall_pass else 'FAIL'}") + lines.append("") + + # Stage summary table. + lines.append("## Stage summary") + lines.append("") + lines.append("| Stage | Status | Duration | Log |") + lines.append("|:--|:--:|--:|:--|") + for s in stages: + rel_log = Path(s.log_path).name + lines.append( + f"| {s.name} | {_badge(s.status)} | {_format_duration(s.duration_s)} | " + f"[`raw/{rel_log}`](raw/{rel_log}) |" + ) + lines.append("") + + # Test counts. + if junits: + lines.append("## Test counts") + lines.append("") + lines.append("| Category | Total | Failures | Errors | Skipped | Duration |") + lines.append("|:--|--:|--:|--:|--:|--:|") + grand_total = grand_fail = grand_err = grand_skip = 0 + grand_time = 0.0 + for j in junits: + grand_total += j.total + grand_fail += j.failures + grand_err += j.errors + grand_skip += j.skipped + grand_time += j.duration_s + lines.append( + f"| {j.category} | {j.total} | {j.failures} | {j.errors} | " + f"{j.skipped} | {_format_duration(j.duration_s)} |" + ) + lines.append( + f"| **Total** | **{grand_total}** | **{grand_fail}** | " + f"**{grand_err}** | **{grand_skip}** | " + f"**{_format_duration(grand_time)}** |" + ) + lines.append("") + + # Coverage. + if coverage: + lines.append("## Coverage") + lines.append("") + lines.append(f"- Line coverage: **{coverage['line_pct']:.1f}%**") + if coverage.get("branch_pct"): + lines.append(f"- Branch coverage: **{coverage['branch_pct']:.1f}%**") + lines.append( + f"- Statements: {coverage['covered']}/{coverage['num_statements']} " + f"covered ({coverage['missing']} missing)" + ) + lines.append("- HTML report: [`htmlcov/index.html`](htmlcov/index.html)") + lines.append("") + + # Mutation. + if mutation: + lines.append("## Mutation testing") + lines.append("") + lines.append(f"- **Mutation score:** {mutation['score_pct']:.1f}%") + lines.append(f"- Killed: {mutation['killed']}") + lines.append(f"- Survived: {mutation['survived']} *(P0 if non-zero in algebra/)*") + lines.append(f"- Timeout: {mutation['timeout']}") + lines.append(f"- Suspicious:{mutation['suspicious']}") + lines.append(f"- Skipped: {mutation['skipped']}") + lines.append(f"- Total mutants: {mutation['total']}") + lines.append("") + + # Failing tests. + failing: list[tuple[str, str]] = [] + for j in junits: + for name in j.failing_tests: + failing.append((j.category, name)) + if failing: + lines.append("## Failing tests") + lines.append("") + for category, name in failing: + lines.append(f"- `[{category}]` {name}") + lines.append("") + + # Slowest tests across categories. + slow: list[tuple[str, str, float]] = [] + for j in junits: + for name, t in j.slowest: + slow.append((j.category, name, t)) + slow.sort(key=lambda c: c[2], reverse=True) + if slow: + lines.append("## Slowest 10 tests") + lines.append("") + lines.append("| Category | Test | Duration |") + lines.append("|:--|:--|--:|") + for category, name, t in slow[:10]: + lines.append(f"| {category} | `{name}` | {t:.2f}s |") + lines.append("") + + lines.append("## Where to next") + lines.append("") + lines.append("- Raw stage logs: `test-results/raw/`") + lines.append("- HTML coverage: `test-results/htmlcov/index.html`") + lines.append("- JUnit XML (CI): `test-results/raw/junit_*.xml`") + lines.append("- This report: `test-results/REPORT.md`") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +# ---------------------------------------------------------------------- +# Entry point +# ---------------------------------------------------------------------- + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--raw-dir", required=True, type=Path) + parser.add_argument("--results-dir", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--mutation", default="none", + choices=["none", "fast", "full"]) + parser.add_argument("--stages-file", required=True, type=Path) + args = parser.parse_args(argv) + + stages = _read_stages(args.stages_file) + + categories = [ + ("Unit", args.raw_dir / "junit_unit.xml"), + ("Property", args.raw_dir / "junit_property.xml"), + ("Golden", args.raw_dir / "junit_golden.xml"), + ("E2E", args.raw_dir / "junit_e2e.xml"), + ("Adapter", args.raw_dir / "junit_adapter.xml"), + ("Combined", args.raw_dir / "pytest.junit.xml"), + ] + junits: list[JUnitSummary] = [] + for cat, path in categories: + # The "Combined" summary duplicates the others; surface it only if no + # per-category file was emitted. + if cat == "Combined" and any(p.exists() for _, p in categories[:-1]): + continue + s = _summarise_junit(cat, path) + if s is not None: + junits.append(s) + + coverage = _read_coverage(args.raw_dir / "coverage.json") + mutation = _read_mutation(args.raw_dir, args.mutation) + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(_render(stages, junits, coverage, mutation)) + print(f"wrote {args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/impl/python/specs/README.md b/impl/python/specs/README.md new file mode 100644 index 0000000..7ef9741 --- /dev/null +++ b/impl/python/specs/README.md @@ -0,0 +1,60 @@ +# Specs + +This folder is the **design archive** for the OSI Python reference +implementation. It previously held deferred-feature proposal files, which +have since been removed. The authoritative deferred-features list is +in `Proposed_OSI_Semantics.md §10`. + +The **authoritative semantic standard** that `osi_python` implements lives +under [`../../../proposals/foundation-v0.1/`](../../../proposals/foundation-v0.1/), +not here. Anything in this folder is non-normative. + +--- + +## Authoritative specs (in scope) — live in `proposals/foundation-v0.1/` + +Read in this order when onboarding: + +| # | Doc | What it covers | +|:--:|:---|:---| +| 1 | [`Proposed_OSI_Semantics.md`](../../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) | **The Foundation (`osi_version: "0.1"`).** Semantic model, query model, two query shapes (`Aggregation` / `Scalar`), join semantics, M:N resolution, window functions in scope, SQL subset, compliance levels, vendor alignment summary, plus the normative Conformance Decisions (Appendix B, `D-001` … `D-033`) and Error Code Index (Appendix C). This is the top-level contract. | +| 2 | [`SQL_EXPRESSION_SUBSET.md`](../../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md) | The SQL subset allowed inside metric / field / filter / where / having expressions (OSI_SQL_2026 dialect grammar). | + +When a conflict arises between the two authoritative specs, +`Proposed_OSI_Semantics.md` wins. + +## Implementation-side supporting docs + +Non-normative reference material maintained alongside the implementation: + +| Doc | Where | What it covers | +|:---|:---|:---| +| [`JOIN_ALGEBRA.md`](../docs/JOIN_ALGEBRA.md) | `impl/python/docs/` | **The closed algebra.** Formal operations, state invariants, and laws — the proof surface the compiler uses to guarantee correctness. | +| [`ERRATA_ALIGNMENT.md`](../docs/ERRATA_ALIGNMENT.md) | `impl/python/docs/` | Catalog of vendor errata the Foundation resolves and how the impl honors each resolution. | +| [`ERROR_CODES.md`](../docs/ERROR_CODES.md) | `impl/python/docs/` | The error code catalog and its mapping to Appendix C. | +| [`ALGEBRA_LAWS.md`](../docs/ALGEBRA_LAWS.md) | `impl/python/docs/` | Hypothesis strategies and property tests that enforce each algebra law. | + +## Future proposals (not yet drafted as separate documents) + +These are deferred features summarised in §10 of `Proposed_OSI_Semantics.md`. +Each will land as its own follow-up proposal in +[`../../../proposals/`](../../../proposals/) when adopted: + +- `natural_grain` (top-level dataset pin) +- `SQL_INTERFACE` (`SEMANTIC_VIEW(...)` clause grammar + bare-view SQL) +- Filter context propagation, metric composition, LOD grain modes, etc. + +## Deferred features + +The individual deferred-feature spec files have been removed from this +directory. The authoritative catalog of deferred features lives in +[`Proposed_OSI_Semantics.md §10`](../../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md). + +The implementation rejects any model that uses a deferred feature with +`E_DEFERRED_KEY_REJECTED` per Appendix C / D-009. + +## Where the implementation lives + +- [`../src/osi/`](../src/osi/) — implementation (see [`../ARCHITECTURE.md`](../ARCHITECTURE.md) for the pipeline) +- [`../docs/`](../docs/) — deep-dive design notes (algebra laws, testing strategy, error catalog) +- [`../tests/`](../tests/) — unit, property-based, golden, E2E diff --git a/impl/python/src/osi/__init__.py b/impl/python/src/osi/__init__.py new file mode 100644 index 0000000..727af91 --- /dev/null +++ b/impl/python/src/osi/__init__.py @@ -0,0 +1,76 @@ +"""OSI Foundation v0.1 Python reference implementation. + +The happy-path symbols an adopter needs are re-exported at the package +root so ``from osi import …`` works without digging through +subpackages: + + from osi import ( + parse_semantic_model, + plan, + compile_plan, + Dialect, + SemanticQuery, + Reference, + PlannerContext, + ErrorCode, + OSIError, + ) + +End-to-end example: + + >>> from osi import ( + ... parse_semantic_model, + ... plan, + ... compile_plan, + ... PlannerContext, + ... SemanticQuery, + ... Reference, + ... Dialect, + ... ) + >>> # 1. Parse a model file (or YAML string). + >>> # result = parse_semantic_model(Path("model.yaml")) + >>> # 2. Build a planner context. + >>> # ctx = PlannerContext( + >>> # model=result.model, + >>> # namespace=result.namespace, + >>> # graph=result.graph, + >>> # ) + >>> # 3. Build a query and plan it. + >>> # plan_ = plan(query, ctx) + >>> # 4. Render SQL. + >>> # sql = compile_plan(plan_, dialect=Dialect.DUCKDB) + +Internals (algebra operators, plan-builder, classifier helpers) live +in ``osi.parsing``, ``osi.planning``, ``osi.codegen``, and +``osi.diagnostics`` and remain accessible for power users and +extension authors. The top-level façade is intentionally narrow: when +in doubt, prefer the façade. + +See ``SPEC.md`` and ``ARCHITECTURE.md`` at the project root for the +contract, and the top-level ``README.md`` for a runnable quick-start +example. The normative standard text lives one repository level up at +``../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md``. +""" + +from __future__ import annotations + +from osi.codegen import Dialect, compile_plan +from osi.errors import ErrorCode, OSIError +from osi.parsing.parser import parse_semantic_model +from osi.planning import Reference, SemanticQuery, plan +from osi.planning.planner_context import PlannerContext + +__version__ = "0.1.0" + +__all__ = [ + "Dialect", + "ErrorCode", + "OSIError", + "PlannerContext", + "Reference", + "SemanticQuery", + "__version__", + "compile_plan", + "parse_semantic_model", + "plan", +] diff --git a/impl/python/src/osi/__main__.py b/impl/python/src/osi/__main__.py new file mode 100644 index 0000000..d745eac --- /dev/null +++ b/impl/python/src/osi/__main__.py @@ -0,0 +1,7 @@ +"""Allow ``python -m osi ...`` to dispatch to :func:`osi.cli.main`.""" + +from __future__ import annotations + +from osi.cli import main + +raise SystemExit(main()) diff --git a/impl/python/src/osi/cli.py b/impl/python/src/osi/cli.py new file mode 100644 index 0000000..874a0ad --- /dev/null +++ b/impl/python/src/osi/cli.py @@ -0,0 +1,349 @@ +"""Thin command-line surface for the diagnostics module. + +The CLI is deliberately minimal: it exists so humans and CI jobs can +reach :func:`osi.diagnostics.describe`, :func:`explain`, and +:func:`resolve` without writing a driver script. Heavy lifting belongs +in library code; this module does argument parsing and I/O only. + +Usage:: + + python -m osi describe + python -m osi explain + python -m osi resolve + +Add ``--json`` to any subcommand to emit machine-readable output. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +import sqlglot + +from osi.codegen import Dialect, compile_plan +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.diagnostics import ( + describe, + describe_json, + explain, + explain_json, + resolve, + resolve_json, +) +from osi.diagnostics.error_catalog import all_explanations, explain_error +from osi.errors import ErrorCode, OSIError, OSIParseError +from osi.parsing.deferred import check_expression_deferred +from osi.parsing.parser import parse_semantic_model +from osi.planning import OrderBy, Reference, SemanticQuery, SortDirection, plan +from osi.planning.planner_context import PlannerContext + + +def _load_context(path: Path) -> PlannerContext: + """Load a model file and reuse the parser's pre-built indexes. + + :func:`parse_semantic_model` already builds and caches both the + namespace and the relationship graph; rebuilding them here would + re-do the same work and silently risk drift if either builder + grows side-effects. + """ + result = parse_semantic_model(path.read_text()) + return PlannerContext( + model=result.model, + namespace=result.namespace, + graph=result.graph, + flags=result.flags, + ) + + +def _load_query(path: Path) -> SemanticQuery: + """Parse a query JSON file into a :class:`SemanticQuery`. + + Surfaces shape errors as :class:`OSIParseError` with a stable + code rather than letting raw ``KeyError`` / ``TypeError`` / + ``json.JSONDecodeError`` escape — keeping the CLI's failure + contract aligned with the library's "every failure carries an + :class:`ErrorCode`" invariant. + """ + try: + data = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + raise OSIParseError( + ErrorCode.E1001_YAML_SYNTAX, + f"query file is not valid JSON: {exc}", + context={"path": str(path)}, + ) from exc + if not isinstance(data, dict): + raise OSIParseError( + ErrorCode.E1004_TYPE_MISMATCH, + "query JSON must be an object at the top level " + f"(got {type(data).__name__})", + context={"path": str(path)}, + ) + + def _ref(spec: object) -> Reference: + if not isinstance(spec, dict): + raise OSIParseError( + ErrorCode.E1004_TYPE_MISMATCH, + f"reference entries must be objects (got {type(spec).__name__})", + context={"reference": spec}, + ) + if "name" not in spec: + raise OSIParseError( + ErrorCode.E1002_MISSING_REQUIRED_FIELD, + "reference is missing required 'name' field", + context={"reference": dict(spec)}, + ) + dataset_raw = spec.get("dataset") + return Reference( + dataset=( + normalize_identifier(str(dataset_raw)) + if dataset_raw is not None + else None + ), + name=normalize_identifier(str(spec["name"])), + ) + + try: + where = ( + FrozenSQL.of(sqlglot.parse_one(data["where"])) + if data.get("where") + else None + ) + except sqlglot.ParseError as exc: + raise OSIParseError( + ErrorCode.E1208_UNSUPPORTED_SQL_CONSTRUCT, + f"query 'where' clause is not parseable SQL: {exc}", + context={"where": data.get("where")}, + ) from exc + # F-33: run the same deferred-AST screen that + # ``parse_semantic_model`` runs on model expressions so query-side + # ``where`` clauses can't slip past the deferred-feature gate via + # the CLI (e.g. ``PIVOT``, ``GROUPING SETS``, ``MATCH_RECOGNIZE``, + # ``EXISTS_IN`` when ``experimental_exists_in`` is off). + if where is not None: + check_expression_deferred(where, where="query 'where'") + order_by = tuple( + OrderBy( + target=_ref(entry.get("target", {})), + direction=( + SortDirection.DESC if entry.get("descending") else SortDirection.ASC + ), + ) + for entry in data.get("order_by", []) + ) + return SemanticQuery( + dimensions=tuple(_ref(r) for r in data.get("dimensions", [])), + measures=tuple(_ref(r) for r in data.get("measures", [])), + where=where, + order_by=order_by, + limit=data.get("limit"), + ) + + +def _emit(args: argparse.Namespace, text: str, data: object) -> None: + if getattr(args, "json", False): + json.dump(data, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + else: + sys.stdout.write(text) + if not text.endswith("\n"): + sys.stdout.write("\n") + + +def _cmd_describe(args: argparse.Namespace) -> int: + ctx = _load_context(Path(args.model)) + _emit(args, describe(ctx.model), describe_json(ctx.model)) + return 0 + + +def _cmd_explain(args: argparse.Namespace) -> int: + ctx = _load_context(Path(args.model)) + query = _load_query(Path(args.query)) + plan_ = plan(query, ctx) + _emit(args, explain(plan_), explain_json(plan_)) + return 0 + + +def _cmd_resolve(args: argparse.Namespace) -> int: + ctx = _load_context(Path(args.model)) + query = _load_query(Path(args.query)) + _emit(args, resolve(query, ctx), resolve_json(query, ctx)) + return 0 + + +def _cmd_explain_code(args: argparse.Namespace) -> int: + """Print the prose explanation for a single OSI error code. + + The catalogue lives in :mod:`osi.diagnostics.error_catalog` so this + command's only job is argument resolution and pretty-printing. + """ + if getattr(args, "list", False): + return _cmd_explain_code_list(args) + raw = (args.code or "").strip() + if not raw: + sys.stderr.write( + "error: an OSI error code is required (e.g. E_NAME_NOT_FOUND)\n" + ) + return 2 + code = _resolve_error_code(raw) + if code is None: + sys.stderr.write( + f"error: {raw!r} is not a known OSI error code. " + "Run `osi explain-code --list` to see all codes.\n" + ) + return 2 + if getattr(args, "json", False): + json.dump( + {"code": code.value, "explanation": explain_error(code)}, + sys.stdout, + indent=2, + sort_keys=True, + ) + sys.stdout.write("\n") + else: + sys.stdout.write(f"{code.value}\n\n{explain_error(code)}\n") + return 0 + + +def _cmd_explain_code_list(args: argparse.Namespace) -> int: + """List every error code and its short explanation.""" + explanations = all_explanations() + if getattr(args, "json", False): + json.dump( + { + c.value: explanations[c] + for c in sorted(explanations, key=lambda c: c.value) + }, + sys.stdout, + indent=2, + sort_keys=True, + ) + sys.stdout.write("\n") + return 0 + for code in sorted(explanations, key=lambda c: c.value): + head = _first_sentence(explanations[code]) + sys.stdout.write(f"{code.value:36} {head}\n") + return 0 + + +def _first_sentence(text: str, max_len: int = 100) -> str: + """Return the first sentence of ``text``, ignoring ``e.g.`` / ``i.e.`` periods.""" + cleaned = " ".join(text.split()) + sentinel = "\x00" + safe = cleaned.replace("e.g.", "e" + sentinel + "g" + sentinel).replace( + "i.e.", "i" + sentinel + "e" + sentinel + ) + head = safe.split(". ", 1)[0].replace(sentinel, ".") + if not head.endswith("."): + head += "." + if len(head) > max_len: + head = head[: max_len - 1].rstrip() + "…" + return head + + +def _resolve_error_code(raw: str) -> ErrorCode | None: + """Look up an :class:`ErrorCode` by either its enum name or value. + + Accepts both the numeric form (``E2002``) and the named form + (``E_NAME_NOT_FOUND``); case-insensitive on the named form. + Returns ``None`` for an unknown spelling so the caller can choose + its own diagnostic surface rather than chasing a ``KeyError``. + """ + upper = raw.upper() + for code in ErrorCode: + if code.value == upper or code.name == upper: + return code + return None + + +def _cmd_compile(args: argparse.Namespace) -> int: + ctx = _load_context(Path(args.model)) + query = _load_query(Path(args.query)) + dialect = Dialect[args.dialect.upper()] + sql = compile_plan(plan(query, ctx), dialect=dialect) + sys.stdout.write(sql) + if not sql.endswith("\n"): + sys.stdout.write("\n") + return 0 + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="osi", description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + + describe_p = sub.add_parser("describe", help="Render a semantic model.") + describe_p.add_argument("model", help="Path to the model YAML.") + describe_p.add_argument("--json", action="store_true") + describe_p.set_defaults(func=_cmd_describe) + + explain_p = sub.add_parser("explain", help="Render a plan for a query.") + explain_p.add_argument("model") + explain_p.add_argument("query", help="Path to a JSON query file.") + explain_p.add_argument("--json", action="store_true") + explain_p.set_defaults(func=_cmd_explain) + + resolve_p = sub.add_parser( + "resolve", help="Show which model elements a query touches." + ) + resolve_p.add_argument("model") + resolve_p.add_argument("query") + resolve_p.add_argument("--json", action="store_true") + resolve_p.set_defaults(func=_cmd_resolve) + + compile_p = sub.add_parser("compile", help="Compile a query to SQL.") + compile_p.add_argument("model") + compile_p.add_argument("query") + compile_p.add_argument( + "--dialect", + default="ansi", + choices=[d.name.lower() for d in Dialect], + ) + compile_p.set_defaults(func=_cmd_compile) + + explain_code_p = sub.add_parser( + "explain-code", + help="Explain an OSI error code (e.g. E_NAME_NOT_FOUND, E2002).", + ) + explain_code_p.add_argument( + "code", + nargs="?", + help="Error code by enum name or numeric value.", + ) + explain_code_p.add_argument( + "--list", + action="store_true", + help="List every known error code with a short explanation.", + ) + explain_code_p.add_argument("--json", action="store_true") + explain_code_p.set_defaults(func=_cmd_explain_code) + + return parser + + +def main(argv: list[str] | None = None) -> int: + """Parse ``argv`` and dispatch to the selected subcommand.""" + parser = _build_parser() + args = parser.parse_args(argv) + try: + return int(args.func(args)) + except OSIError as err: + sys.stderr.write(f"{err.code.value}: {err}\n") + # Phase 10 P1 (10b I1): surface the structured ``context`` dict + # from :class:`OSIError`. Authors hitting an error from the CLI + # previously lost actionable hints (suggested fix, candidate + # names, dataset / field / grain) because the handler only + # serialised the message. Empty contexts stay quiet so the + # short happy-path failure output is unchanged. + if err.context: + sys.stderr.write(" context:\n") + for key, value in sorted(err.context.items()): + sys.stderr.write(f" {key}: {value!r}\n") + return 2 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/impl/python/src/osi/codegen/README.md b/impl/python/src/osi/codegen/README.md new file mode 100644 index 0000000..7fe1519 --- /dev/null +++ b/impl/python/src/osi/codegen/README.md @@ -0,0 +1,36 @@ +# `osi.codegen` — Layer 3 + +Walks a `QueryPlan` and produces a SQL string for the requested dialect. + +**Contract.** + +1. Codegen never reads the `SemanticModel` or `Namespace`. Every fact it + needs comes from the plan. +2. All SQL composition goes through SQLGlot AST nodes + (`sqlglot.exp.*`). Raw-string SQL is banned; CI checks for it. +3. Same `(plan, dialect)` ⇒ byte-identical SQL. + +## IR surface + +`codegen` *does* import several types from `osi.planning.algebra.*` +(`Column`, `CalculationState`, `AggregateFunction`, `JoinType`, +`FilterMode`, `PlanStep`, …). These are the algebra-side IR types +that the plan exposes — they are part of the plan's published surface, +not an internal-only detail of the planner. Refactors to those types +are coordinated with codegen on purpose; the `import-linter` rule for +this layer (see `pyproject.toml`) intentionally allows planning +imports for that reason. + +If a new type emerges that codegen needs but the algebra has not yet +published, prefer extending `PlanStep` or its payloads rather than +reaching into a less-visible algebra helper. + +## Module map + +- `transpiler.py` — `PlanStep` → SQLGlot AST. +- `dialect.py` — dialect-specific transforms (ANSI / DuckDB / Snowflake). +- `cte_optimizer.py` — post-build AST transforms (inlining, folding). +- `types.py` — codegen-local NewTypes. + +If you're tempted to look up a metric definition in the semantic model, +stop — the plan is missing information. Extend `PlanStep`. diff --git a/impl/python/src/osi/codegen/__init__.py b/impl/python/src/osi/codegen/__init__.py new file mode 100644 index 0000000..26d3402 --- /dev/null +++ b/impl/python/src/osi/codegen/__init__.py @@ -0,0 +1,43 @@ +"""Layer 3 of the compiler pipeline. + +Takes a :class:`~osi.planning.QueryPlan` + :class:`Dialect` and produces +a SQL string via SQLGlot AST composition. Never reads the +:class:`~osi.parsing.models.SemanticModel`. + +See ``../../../ARCHITECTURE.md`` §4 for the full contract. All SQL +manipulation goes through ``sqlglot.exp.*`` — raw-string SQL is banned. +""" + +from __future__ import annotations + +from osi.planning.plan import QueryPlan + +from .cte_optimizer import optimize_ctes +from .dialect import Dialect, render_sql +from .transpiler import plan_to_select + + +def compile_plan(plan: QueryPlan, *, dialect: Dialect) -> str: + """Full pipeline: :class:`QueryPlan` → SQL text for ``dialect``. + + Equivalent to:: + + ast = plan_to_select(plan) + ast = optimize_ctes(ast) + return render_sql(ast, dialect=dialect) + + Provided as a single entry point so goldens, diagnostics, and the + CLI never have to assemble the steps by hand. + """ + ast = plan_to_select(plan) + ast = optimize_ctes(ast) + return render_sql(ast, dialect=dialect) + + +__all__ = [ + "Dialect", + "compile_plan", + "optimize_ctes", + "plan_to_select", + "render_sql", +] diff --git a/impl/python/src/osi/codegen/cte_optimizer.py b/impl/python/src/osi/codegen/cte_optimizer.py new file mode 100644 index 0000000..431a997 --- /dev/null +++ b/impl/python/src/osi/codegen/cte_optimizer.py @@ -0,0 +1,186 @@ +"""Post-build AST transforms for generated SQL. + +The transpiler emits one CTE per :class:`PlanStep`. Some of those CTEs +are trivially inline-able (pass-through ``PROJECT`` s, single-use chains +with no grain-changing operation in between). This module applies two +conservative transforms; their contract is that the result is always +relationally equivalent to the input. + +**Pass-through CTE inlining** — if the final (root) CTE is a bare +``SELECT col1, col2, … FROM step_M`` with no WHERE / GROUP BY / HAVING / +JOIN, the outer ``SELECT`` can read from ``step_M`` directly, making the +final PROJECT CTE unnecessary. This is the most common single +readability gain across every plan shape. + +**Dead CTE removal** — if the transpiler ever produces a CTE that is +not referenced from any downstream step (possible as planner invariants +evolve), drop it. Reachability is computed as a BFS through *live* CTEs: +the seed set is the step CTEs referenced from the outer ``SELECT`` only, +and the transitive closure follows references *only inside CTEs already +proven live*. A previous implementation walked every table in the +entire AST, which would mark a CTE referenced by a dead CTE as live and +defeat the purpose of the pass. +""" + +from __future__ import annotations + +from sqlglot import expressions as exp + +from osi.planning.prefixes import is_step_alias + + +def _inline_trivial_final_cte(select: exp.Select) -> exp.Select: + """Inline a trivially pass-through root CTE into the outer SELECT. + + Eliminates the pattern:: + + step_N AS (SELECT step_M.a, step_M.b FROM step_M), + SELECT step_N.a, step_N.b FROM step_N ORDER BY … + + replacing it with:: + + SELECT step_M.a, step_M.b FROM step_M ORDER BY … + + Only applied when the root CTE passes every qualification: bare + (table-qualified) column references, a single ``FROM``, and no + ``WHERE`` / ``GROUP BY`` / ``HAVING`` / ``JOIN``. + """ + with_clause = select.args.get("with") + if with_clause is None: + return select + + # Identify which CTE alias the outer SELECT reads from. + outer_from = select.args.get("from") + if outer_from is None: + return select + from_table = outer_from.this + if not isinstance(from_table, exp.Table): + return select + root_alias = from_table.name + if not root_alias or not is_step_alias(root_alias): + return select + + # Look up the root CTE in the WITH clause. + by_alias: dict[str, exp.CTE] = { + _cte_name(c): c for c in with_clause.expressions if _cte_name(c) + } + root_cte = by_alias.get(root_alias) + if root_cte is None: + return select + + # Check that the root CTE body is a trivial pass-through. + body = root_cte.this + if not isinstance(body, exp.Select): + return select + if any(body.args.get(k) for k in ("where", "group", "having")): + return select + if body.args.get("joins"): + return select + inner_from = body.args.get("from") + if inner_from is None: + return select + inner_table = inner_from.this + if not isinstance(inner_table, exp.Table) or not is_step_alias(inner_table.name): + return select + inner_alias = inner_table.name + # Every projection must be a bare column reference — no aliases, no + # computed expressions. ``exp.Column`` covers both bare and + # table-qualified column references. + if not body.expressions or any( + not isinstance(p, exp.Column) for p in body.expressions + ): + return select + + # Safe to inline: rewrite outer SELECT column references and FROM. + # Temporarily detach the WITH clause so find_all only visits the + # outer SELECT body (not CTE bodies). + select.set("with", None) + try: + for col in select.find_all(exp.Column): + if col.table == root_alias: + col.set("table", exp.to_identifier(inner_alias)) + finally: + select.set("with", with_clause) + + select.set("from", exp.From(this=exp.to_table(inner_alias))) + + # Drop the now-inlined CTE from the WITH clause. + kept = [c for c in with_clause.expressions if _cte_name(c) != root_alias] + select.set("with", exp.With(expressions=kept) if kept else None) + return select + + +def optimize_ctes(select: exp.Select) -> exp.Select: + """Apply conservative CTE cleanup to ``select`` and return it. + + Idempotent; safe to call twice. Preserves CTE ordering. + Applies :func:`_inline_trivial_final_cte` first, then dead-CTE + removal, so that inlining can expose additional dead CTEs. + """ + select = _inline_trivial_final_cte(select) + + with_clause = select.args.get("with") + if with_clause is None: + return select + + by_alias: dict[str, exp.CTE] = { + _cte_name(cte): cte for cte in with_clause.expressions if _cte_name(cte) + } + + # Seed referenced set from the outer SELECT *only* — step CTEs that + # nothing downstream of the WITH clause uses are dead by definition. + referenced: set[str] = set() + for table in _outer_table_refs(select): + if table.name and is_step_alias(table.name): + referenced.add(table.name) + + # BFS through live CTEs: only follow references inside CTEs already + # in ``referenced``. This avoids the trap of letting a dead CTE + # keep its own dependencies alive. + frontier = list(referenced) + while frontier: + current = frontier.pop() + cte = by_alias.get(current) + if cte is None: + continue + for tbl in cte.this.find_all(exp.Table): + if tbl.name and is_step_alias(tbl.name) and tbl.name not in referenced: + referenced.add(tbl.name) + frontier.append(tbl.name) + + kept: list[exp.CTE] = [ + c for c in with_clause.expressions if _cte_name(c) in referenced + ] + if len(kept) == len(with_clause.expressions): + return select + if not kept: + select.set("with", None) + else: + select.set("with", exp.With(expressions=kept)) + return select + + +def _outer_table_refs(select: exp.Select) -> list[exp.Table]: + """Return tables referenced from the outer ``SELECT`` (not from CTE bodies). + + Equivalent to ``select.find_all(exp.Table)`` minus everything reachable + through ``with_clause.expressions``. Implemented by temporarily + detaching the WITH clause to keep the recursion simple. + """ + with_clause = select.args.get("with") + select.set("with", None) + try: + return list(select.find_all(exp.Table)) + finally: + select.set("with", with_clause) + + +def _cte_name(cte: exp.CTE) -> str: + alias = cte.args.get("alias") + if alias is None: + return "" + return str(alias.name) + + +__all__ = ["optimize_ctes"] + diff --git a/impl/python/src/osi/codegen/dialect.py b/impl/python/src/osi/codegen/dialect.py new file mode 100644 index 0000000..c56f086 --- /dev/null +++ b/impl/python/src/osi/codegen/dialect.py @@ -0,0 +1,84 @@ +"""Dialect-specific rendering of a SQLGlot AST. + +The Foundation supports three dialects: + +* :attr:`Dialect.ANSI` — SQLGlot's default, portable baseline. +* :attr:`Dialect.DUCKDB` — the reference execution runtime used by the + E2E harness. +* :attr:`Dialect.SNOWFLAKE` — the other production target covered by + the Snowflake E2E corpus. + +This module is intentionally thin: we let SQLGlot's own dialect +registry do the heavy lifting (``Expression.sql(dialect=...)``) and +layer on only OSI-specific rewrites here. Adding a new dialect means: + +1. Adding the enum variant in :mod:`osi.codegen.types`. +2. Extending ``_DIALECT_NAMES`` below. +3. Adding a golden column in ``tests/golden/_driver.py``. +""" + +from __future__ import annotations + +from sqlglot import expressions as exp + +from osi.errors import ErrorCode, OSICodegenError + +from .types import Dialect + +_DIALECT_NAMES: dict[Dialect, str] = { + # S-16 / D-021: OSI_SQL_2026 is the Foundation default. SQLGlot + # has no dialect named ``osi_sql_2026`` so we render it through the + # ANSI baseline (no dialect-specific rewrites) — the OSI_SQL_2026 + # subset is by construction a *subset* of ANSI SQL, so the ANSI + # serializer produces a string that every conforming engine can + # parse. + Dialect.OSI_SQL_2026: "", + Dialect.ANSI: "", + Dialect.DUCKDB: "duckdb", + Dialect.SNOWFLAKE: "snowflake", +} + + +def render_sql(expression: exp.Expression, *, dialect: Dialect) -> str: + """Render ``expression`` as SQL text for ``dialect``. + + Uses SQLGlot's :func:`sql` with ``pretty=True`` so goldens are + human-readable. Determinism comes from upstream: every node fed in + has already been built by the deterministic planner + transpiler, + and SQLGlot's serializer is stable for a fixed AST. + + ``identify=True`` is set so every emitted identifier is wrapped in + the dialect's quote character (``"`` for ANSI / Postgres / DuckDB / + Snowflake, backticks for BigQuery / MySQL when those land). This + closes a class of bugs where a user-defined dataset / field / + metric name happens to match a SQL reserved word for the target + dialect: previously ``SELECT id, in FROM t`` was emitted bare + and rejected by every strict-dialect parser; with quoting, the + same name compiles cleanly to ``SELECT "id", "in" FROM "t"``. + The trade-off is verbosity in golden snapshots — semantically + equivalent SQL with quotes around every identifier — which is + a one-time refresh cost weighed against the risk of silently + emitting invalid SQL on stricter engines. + + Raises :class:`OSICodegenError` with ``E5002_SQLGLOT_RENDER_FAILED`` + if SQLGlot refuses to render the AST (typically a missing dialect + feature on a vendor function). + """ + dialect_name = _DIALECT_NAMES.get(dialect) + if dialect_name is None: + raise OSICodegenError( + ErrorCode.E5001_DIALECT_UNSUPPORTED, + f"dialect {dialect!r} is not registered", + context={"dialect": dialect}, + ) + try: + return expression.sql(dialect=dialect_name or None, pretty=True, identify=True) + except Exception as err: # pragma: no cover — SQLGlot internals + raise OSICodegenError( + ErrorCode.E5002_SQLGLOT_RENDER_FAILED, + f"SQLGlot failed to render AST: {err}", + context={"dialect": dialect, "error": str(err)}, + ) from err + + +__all__ = ["Dialect", "render_sql"] diff --git a/impl/python/src/osi/codegen/transpiler.py b/impl/python/src/osi/codegen/transpiler.py new file mode 100644 index 0000000..bec8768 --- /dev/null +++ b/impl/python/src/osi/codegen/transpiler.py @@ -0,0 +1,529 @@ +"""Walk a :class:`QueryPlan` and build a SQLGlot ``Select`` AST. + +Every :class:`PlanStep` becomes a named CTE whose alias follows a +deterministic ``step_000``, ``step_001`` pattern. The final outer +``SELECT`` references the root CTE and attaches the plan's +``ORDER BY`` / ``LIMIT``. + +Nothing in this module reads the :class:`~osi.parsing.models.SemanticModel` +— see ``src/osi/codegen/README.md``. All input facts travel on the +plan's payloads (``source`` on :class:`SourcePayload`, +``child_source`` on :class:`EnrichPayload`). + +The rendering is intentionally literal: one CTE per step keeps goldens +traceable, and :mod:`osi.codegen.cte_optimizer` is free to inline / +fold as a *post-step*. +""" + +from __future__ import annotations + +from sqlglot import expressions as exp + +from osi.common.identifiers import Identifier +from osi.errors import ErrorCode, OSICodegenError +from osi.planning.algebra.operations import FilterMode, JoinType +from osi.planning.algebra.state import AggregateFunction, CalculationState, Column +from osi.planning.plan import ( + AddColumnsPayload, + AggregatePayload, + EnrichDerivedPayload, + EnrichPayload, + FilteringJoinPayload, + FilterPayload, + MergePayload, + PlanOperation, + PlanStep, + ProjectPayload, + QueryPlan, + SourcePayload, +) +from osi.planning.prefixes import step_alias as _step_alias + + +def _ident(name: Identifier | str, *, table: str | None = None) -> exp.Column: + """Wrap ``name`` as an :class:`sqlglot.expressions.Column`.""" + return exp.column(str(name), table=table, quoted=False) + + +def _qualify_columns( + expression: exp.Expression, + alias: str, + *, + home_logical_name: str | None = None, +) -> exp.Expression: + """Rewrite every column reference to live under CTE ``alias``. + + Plan-level expressions refer to columns by their *logical* name — + either bare (``amount``) or under a logical dataset qualifier + (``orders.amount``). Inside a CTE every input column lives under + the parent CTE's *physical* alias (``step_000``). Both forms must + be remapped: bare columns gain the alias, and logical qualifiers + are overwritten with the physical one. + + Overwriting a logical qualifier is intentional. ``orders`` is the + user's name for the dataset, never the runtime table name; the + transpiler is the layer that resolves the logical → physical + mapping. Tests asserting on this behaviour live in + ``tests/golden/test_sql_goldens.py`` and the enrich-renaming + cases in ``tests/unit/codegen/test_transpiler.py``. + + Subqueries have their own scope — the columns inside a + correlated subquery (generated by D-003 / D-015 home-grain + aggregation) deliberately reference the *outer* table. The only + column qualifier inside a subquery we touch is the home logical + name itself (``home_logical_name``), since the surrounding step + has aliased that table to ``alias``. Every other inner reference + is left alone. + """ + inside_subquery: set[int] = set() + for sub in expression.find_all(exp.Subquery): + for inner in sub.find_all(exp.Column): + inside_subquery.add(id(inner)) + for col in expression.find_all(exp.Column): + if id(col) in inside_subquery: + if home_logical_name is None: + continue + current_table = col.table or "" + if current_table.lower() != home_logical_name.lower(): + continue + col.set("table", exp.to_identifier(alias)) + return expression + + +def _is_identity_column(expr: exp.Expression, name: str) -> bool: + """Return True iff ``expr`` is a bare column reference whose name equals ``name``. + + Used by :func:`_render_source` to suppress redundant ``"x" AS "x"`` + aliases: when a field's expression is already the bare column name, + emitting an alias adds visual noise without any semantic value. + Comparison is case-insensitive to match identifier normalisation. + """ + return ( + isinstance(expr, exp.Column) + and not expr.table + and (expr.name or "").lower() == name.lower() + ) + + +def _render_source(payload: SourcePayload, columns: tuple[Column, ...]) -> exp.Select: + if not payload.source: + raise OSICodegenError( + ErrorCode.E5001_DIALECT_UNSUPPORTED, + f"SOURCE step for {payload.dataset!r} has no physical source", + context={"dataset": payload.dataset}, + ) + projections: list[exp.Expression] = [] + for col in columns: + expr = col.expression.expr.copy() + col_name = str(col.name) + # Omit the alias when the expression is already a bare column + # reference with the same name — ``"x" AS "x"`` adds no information. + if _is_identity_column(expr, col_name): + projections.append(expr) + else: + projections.append(exp.alias_(expr, col_name, quoted=False)) + return exp.select(*projections).from_(exp.to_table(payload.source)) + + +def _pass_through( + parent_alias: str, + columns: tuple[Column, ...], + alias_map: dict[Identifier, Identifier] | None = None, +) -> exp.Select: + """Build a ``SELECT col[, ...] FROM parent_alias`` projection. + + If ``alias_map`` maps an internal column name to a user-visible + output name, the projection emits ``col AS alias`` for that + column (S-7 measure aliasing). + """ + alias_map = alias_map or {} + projections: list[exp.Expression] = [] + for c in columns: + ident = _ident(c.name, table=parent_alias) + alias = alias_map.get(c.name) + if alias is not None and alias != c.name: + ident = exp.alias_(ident, exp.to_identifier(str(alias), quoted=False)) + projections.append(ident) + return exp.select(*projections).from_(exp.to_table(parent_alias)) + + +def _render_filter( + parent_alias: str, payload: FilterPayload, parent_columns: tuple[Column, ...] +) -> exp.Select: + sel = _pass_through(parent_alias, parent_columns) + predicate = _qualify_columns(payload.predicate.expr.copy(), parent_alias) + return sel.where(predicate) + + +def _and(conds: list[exp.Expression]) -> exp.Expression: + result = conds[0] + for c in conds[1:]: + result = exp.And(this=result, expression=c) + return result + + +def _render_enrich( + parent_alias: str, + payload: EnrichPayload, + parent_columns: tuple[Column, ...], +) -> exp.Select: + child_alias = f"{parent_alias}_r" + join_kind = "INNER" if payload.join_type is JoinType.INNER else "LEFT" + parent_projs: list[exp.Expression] = [ + _ident(c.name, table=parent_alias) for c in parent_columns + ] + child_projs: list[exp.Expression] = [] + for col in payload.child_columns: + child_expr = _qualify_columns( + col.expression.expr.copy(), + child_alias, + home_logical_name=str(payload.child_dataset), + ) + child_projs.append(exp.alias_(child_expr, str(col.name), quoted=False)) + sel = exp.select(*parent_projs, *child_projs).from_(exp.to_table(parent_alias)) + if payload.parent_keys and payload.child_keys: + pairs = list(zip(payload.parent_keys, payload.child_keys, strict=True)) + else: + # Back-compat: legacy paths without an explicit pairing join by + # matching names on both sides. + pairs = [(k, k) for k in sorted(payload.keys, key=str)] + on = _and( + [ + exp.EQ( + this=_ident(p, table=parent_alias), + expression=_ident(c, table=child_alias), + ) + for p, c in pairs + ] + ) + child_table: exp.Expression = exp.to_table(payload.child_source) + child_table = exp.alias_(child_table, child_alias, table=True) + sel = sel.join(child_table, on=on, join_type=join_kind) + return sel + + +def _render_enrich_derived( + parent_alias: str, + child_alias: str, + payload: EnrichDerivedPayload, + parent_columns: tuple[Column, ...], +) -> exp.Select: + """ENRICH where the child is an upstream PlanStep CTE, not a base table. + + Used by the bridge-resolution planner (``Proposed_OSI_Semantics.md + §6.5.1``, mid-pipeline form). The shape mirrors :func:`_render_enrich` + but reads the child as ``child_alias`` rather than from + ``payload.child_source``. Critically, child columns are projected + *by name* — the upstream CTE has already materialised them — never + by re-rendering the original :attr:`Column.expression` (which for a + pre-aggregated state would re-emit the aggregate function and break + GROUP BY semantics). + """ + join_kind = "INNER" if payload.join_type is JoinType.INNER else "LEFT" + parent_projs: list[exp.Expression] = [ + _ident(c.name, table=parent_alias) for c in parent_columns + ] + child_projs: list[exp.Expression] = [ + _ident(col.name, table=child_alias) for col in payload.child_columns + ] + sel = exp.select(*parent_projs, *child_projs).from_(exp.to_table(parent_alias)) + pairs = list(zip(payload.parent_keys, payload.child_keys, strict=True)) + on = _and( + [ + exp.EQ( + this=_ident(p, table=parent_alias), + expression=_ident(c, table=child_alias), + ) + for p, c in pairs + ] + ) + sel = sel.join(exp.to_table(child_alias), on=on, join_type=join_kind) + return sel + + +def _aggregate_expression(col: Column, parent_alias: str) -> exp.Expression: + assert col.aggregate is not None + fn = col.aggregate.function + arg = _qualify_columns(col.aggregate.argument.expr.copy(), parent_alias) + if fn is AggregateFunction.SUM: + return exp.Sum(this=arg) + if fn is AggregateFunction.COUNT: + return exp.Count(this=arg) + if fn is AggregateFunction.COUNT_DISTINCT: + return exp.Count(this=exp.Distinct(expressions=[arg])) + if fn is AggregateFunction.MIN: + return exp.Min(this=arg) + if fn is AggregateFunction.MAX: + return exp.Max(this=arg) + if fn is AggregateFunction.AVG: + return exp.Avg(this=arg) + raise OSICodegenError( # pragma: no cover — StrEnum is exhaustive above + ErrorCode.E5001_DIALECT_UNSUPPORTED, + f"unsupported aggregate function {fn!r}", + context={"function": fn}, + ) + + +def _render_aggregate( + parent_alias: str, + payload: AggregatePayload, + step_columns: tuple[Column, ...], +) -> exp.Select: + projections: list[exp.Expression] = [] + grain = sorted(payload.new_grain, key=str) + for g in grain: + projections.append(_ident(g, table=parent_alias)) + agg_by_name = {c.name: c for c in payload.aggregations} + for col in step_columns: + if col.name in payload.new_grain: + continue + if col.name in agg_by_name: + projections.append( + exp.alias_( + _aggregate_expression(agg_by_name[col.name], parent_alias), + str(col.name), + quoted=False, + ) + ) + sel = exp.select(*projections).from_(exp.to_table(parent_alias)) + if grain: + sel = sel.group_by(*(_ident(g, table=parent_alias) for g in grain)) + return sel + + +def _render_project(parent_alias: str, payload: ProjectPayload) -> exp.Select: + projections = [_ident(c, table=parent_alias) for c in payload.columns] + return exp.select(*projections).from_(exp.to_table(parent_alias)) + + +def _render_add_columns( + parent_alias: str, + payload: AddColumnsPayload, + parent_columns: tuple[Column, ...], +) -> exp.Select: + """Render an ADD_COLUMNS step as ``SELECT parent_cols, ``. + + The derived expressions already reference columns by their + parent-CTE-visible name (see + :func:`~osi.planning.columns.strip_column_qualifiers`), so we only + need to scope them to ``parent_alias``. + """ + projections: list[exp.Expression] = [ + _ident(c.name, table=parent_alias) for c in parent_columns + ] + for definition in payload.definitions: + derived = _qualify_columns(definition.expression.expr.copy(), parent_alias) + projections.append(exp.alias_(derived, str(definition.name), quoted=False)) + return exp.select(*projections).from_(exp.to_table(parent_alias)) + + +def _render_merge( + step: PlanStep, + payload: MergePayload, + left_alias: str, + right_alias: str, + left_state: CalculationState, + right_state: CalculationState, +) -> exp.Select: + _ = step + projections: list[exp.Expression] = [] + grain = sorted(payload.on, key=str) + for g in grain: + projections.append( + exp.alias_( + exp.Coalesce( + this=_ident(g, table=left_alias), + expressions=[_ident(g, table=right_alias)], + ), + str(g), + quoted=False, + ) + ) + seen: set[Identifier] = set(payload.on) + for col in left_state.columns: + if col.name in seen: + continue + projections.append(_ident(col.name, table=left_alias)) + seen.add(col.name) + for col in right_state.columns: + if col.name in seen: + continue + projections.append(_ident(col.name, table=right_alias)) + seen.add(col.name) + + sel = exp.select(*projections).from_(exp.to_table(left_alias)) + if not grain: + return sel.join(exp.to_table(right_alias), join_type="CROSS") + on = _and( + [ + exp.EQ( + this=_ident(k, table=left_alias), + expression=_ident(k, table=right_alias), + ) + for k in grain + ] + ) + return sel.join(exp.to_table(right_alias), on=on, join_type="FULL OUTER") + + +def _render_filtering_join( + payload: FilteringJoinPayload, + left_alias: str, + right_alias: str, + left_state: CalculationState, +) -> exp.Select: + """Render a SEMI / ANTI filtering-join as ``EXISTS`` / ``NOT EXISTS``. + + ``Proposed_OSI_Semantics.md §7.4`` and `§11 #8` require the + correlated-subquery form. ``IN (SELECT ...)`` is forbidden because + its NULL semantics differ (``IN`` returns ``UNKNOWN`` when any + right-side key is NULL while ``EXISTS`` returns FALSE) and a few + dialects cannot render multi-key ``IN`` against tuples without + extra parens. + """ + sel = _pass_through(left_alias, left_state.columns) + lhs_keys = sorted(payload.lhs_keys, key=str) + rhs_keys = sorted(payload.rhs_keys, key=str) + correlation = exp.and_( + *( + exp.EQ( + this=_ident(rk, table=right_alias), + expression=_ident(lk, table=left_alias), + ) + for lk, rk in zip(lhs_keys, rhs_keys) + ) + ) + subquery = ( + exp.select(exp.Literal.number(1)) + .from_(exp.to_table(right_alias)) + .where(correlation) + ) + pred: exp.Expression = exp.Exists(this=subquery) + if payload.mode is FilterMode.ANTI: + pred = exp.Not(this=pred) + return sel.where(pred) + + +def _render_step( + step: PlanStep, + aliases: dict[int, str], + steps_by_id: dict[int, PlanStep], +) -> exp.Select: + parent_alias = aliases[step.inputs[0]] if step.inputs else "" + if step.operation is PlanOperation.SOURCE: + assert isinstance(step.payload, SourcePayload) + return _render_source(step.payload, step.state.columns) + if step.operation is PlanOperation.FILTER: + assert isinstance(step.payload, FilterPayload) + return _render_filter( + parent_alias, + step.payload, + steps_by_id[step.inputs[0]].state.columns, + ) + if step.operation is PlanOperation.ENRICH: + if isinstance(step.payload, EnrichDerivedPayload): + parent_id, child_id = step.inputs + return _render_enrich_derived( + aliases[parent_id], + aliases[child_id], + step.payload, + steps_by_id[parent_id].state.columns, + ) + assert isinstance(step.payload, EnrichPayload) + return _render_enrich( + parent_alias, + step.payload, + steps_by_id[step.inputs[0]].state.columns, + ) + if step.operation is PlanOperation.AGGREGATE: + assert isinstance(step.payload, AggregatePayload) + return _render_aggregate(parent_alias, step.payload, step.state.columns) + if step.operation is PlanOperation.PROJECT: + assert isinstance(step.payload, ProjectPayload) + return _render_project(parent_alias, step.payload) + if step.operation is PlanOperation.ADD_COLUMNS: + assert isinstance(step.payload, AddColumnsPayload) + return _render_add_columns( + parent_alias, + step.payload, + steps_by_id[step.inputs[0]].state.columns, + ) + if step.operation is PlanOperation.MERGE: + assert isinstance(step.payload, MergePayload) + left_id, right_id = step.inputs + return _render_merge( + step, + step.payload, + aliases[left_id], + aliases[right_id], + steps_by_id[left_id].state, + steps_by_id[right_id].state, + ) + if step.operation is PlanOperation.FILTERING_JOIN: + assert isinstance(step.payload, FilteringJoinPayload) + left_id, right_id = step.inputs + return _render_filtering_join( + step.payload, + aliases[left_id], + aliases[right_id], + steps_by_id[left_id].state, + ) + raise OSICodegenError( + ErrorCode.E5001_DIALECT_UNSUPPORTED, + f"codegen does not handle operation {step.operation!r}", + context={"operation": step.operation}, + ) + + +def plan_to_select(plan: QueryPlan) -> exp.Select: + """Build a SQLGlot :class:`Select` from a :class:`QueryPlan`. + + The output is *pre-dialect*: the caller (:mod:`osi.codegen.dialect`) + applies dialect-specific rewrites before rendering to a string. + """ + aliases: dict[int, str] = {s.step_id: _step_alias(s.step_id) for s in plan.steps} + steps_by_id: dict[int, PlanStep] = {s.step_id: s for s in plan.steps} + ctes: list[exp.CTE] = [] + for step in plan.steps: + body = _render_step(step, aliases, steps_by_id) + ctes.append( + exp.CTE( + this=body, + alias=exp.TableAlias(this=exp.to_identifier(aliases[step.step_id])), + ) + ) + + root_alias = aliases[plan.root_step_id] + root_columns = plan.root.state.columns + # S-7: thread the optional alias map through the final SELECT so + # ``column AS alias`` is emitted when the user named a measure + # something other than the metric's declared name. + alias_map = dict(plan.output_aliases) + final = _pass_through(root_alias, root_columns, alias_map=alias_map) + if plan.order_by: + # D-029 (amended 2026-05-13): the Foundation default is the + # SQL:2003 high-end-NULL convention — NULLS LAST for ASC, + # NULLS FIRST for DESC. NULL is treated as a high-end value and + # lands at whichever end the maximum lands at, so flipping + # ASC ↔ DESC also flips NULL placement (the symmetry property). + # Engines emit the resolved clause explicitly so the cross-engine + # result is byte-identical (D-014); without this Spark/Databricks + # would silently use the opposite (low-end-NULL) convention. + final = final.order_by( + *( + exp.Ordered( + this=_ident(o.column, table=root_alias), + desc=o.descending, + nulls_first=o.descending, + ) + for o in plan.order_by + ) + ) + if plan.limit is not None: + final = final.limit(plan.limit) + + final.set("with", exp.With(expressions=ctes)) + return final + + +__all__ = ["plan_to_select"] diff --git a/impl/python/src/osi/codegen/types.py b/impl/python/src/osi/codegen/types.py new file mode 100644 index 0000000..3f5bcfc --- /dev/null +++ b/impl/python/src/osi/codegen/types.py @@ -0,0 +1,14 @@ +"""Codegen-local re-export of cross-layer dialect vocabulary. + +The single source of truth for :class:`Dialect` lives in +:mod:`osi.common.types`. Codegen historically declared its own enum; +re-exporting the shared one preserves backwards-compatible imports +(``from osi.codegen.types import Dialect``) while removing the duplicate +definition that would otherwise drift. +""" + +from __future__ import annotations + +from osi.common.types import Dialect + +__all__ = ["Dialect"] diff --git a/impl/python/src/osi/common/README.md b/impl/python/src/osi/common/README.md new file mode 100644 index 0000000..50329cc --- /dev/null +++ b/impl/python/src/osi/common/README.md @@ -0,0 +1,39 @@ +# `osi.common` — cross-layer primitives + +The `common` package holds the value types every layer below it imports. +It has **no upstream dependency** inside `osi/`: no pydantic, no model, +no planner, no codegen. Everything here is pure-Python plus +SQLGlot/`networkx` adapters that the rest of the implementation treats +as a thin protocol. + +Keeping these primitives in one place is what lets the import-linter +contract in `pyproject.toml` enforce the one-way flow +`parsing → planning → codegen` (with the `common` package imported by +all) — every layer can reach back here for shared types, none of them +sees the others. + +## Modules + +| Module | Purpose | Public surface | +| --- | --- | --- | +| `identifiers.py` | Case-folded `Identifier` NewType (`normalize_identifier`, `is_valid_identifier`, `identifiers_equal`). Single source of truth for "is this a valid OSI name?" — the parser, planner, and codegen all defer to it. | `Identifier`, `normalize_identifier`, `is_valid_identifier`, `identifiers_equal` | +| `sql_expr.py` | `FrozenSQL` — an immutable, comparable wrapper around a SQLGlot AST. Provides `FrozenSQL.of(...)`, `parse_sql_expr(...)`, and `sql_expr_equal(...)` so two expressions are equal iff their canonical form is. Required for golden-test determinism. | `FrozenSQL`, `parse_sql_expr`, `sql_expr_equal` | +| `types.py` | Cross-layer NewTypes (`DimensionSet = frozenset[Identifier]`, `CTEName`, `ExpressionId`, `SourceLocation`) and the `Dialect` enum. | `DimensionSet`, `CTEName`, `ExpressionId`, `SourceLocation`, `Dialect` | +| `windows.py` | Pure SQL-AST predicates over window functions (`contains_window`, `is_windowed_expression`). Lives in this package because both parsing (deferred-feature gate) and planning (window placement / fan-out rewrite) need them — see the architecture review of S-9 / S-12. | `contains_window`, `is_windowed_expression`, … | + +## Invariants + +1. **No internal dependency.** The package may only import from the + Python standard library, `sqlglot`, `networkx`, and other modules + in this package. +2. **Frozen everywhere.** Every public type is immutable. Mutating a + `FrozenSQL` or rebinding an `Identifier` is a bug. +3. **One canonical form.** `normalize_identifier` and + `FrozenSQL.of(...)` are the only places that materialise a + canonical form; anything else must call through them so equality + stays definition-driven, not source-text-driven. + +If you find yourself reaching for something more "domain-specific" +than a value type or a SQL-AST predicate, it belongs in +`osi.parsing` (model shapes), `osi.planning` (plan / algebra), or +`osi.diagnostics` (introspection) — not here. diff --git a/impl/python/src/osi/common/__init__.py b/impl/python/src/osi/common/__init__.py new file mode 100644 index 0000000..42212fa --- /dev/null +++ b/impl/python/src/osi/common/__init__.py @@ -0,0 +1,34 @@ +"""Shared primitives used by all layers. + +Contains: + +- :mod:`osi.common.identifiers` — ``Identifier`` NewType, normalization, + validation. +- :mod:`osi.common.sql_expr` — thin wrappers over SQLGlot for frozen, + comparable ASTs. +- :mod:`osi.common.types` — cross-layer NewTypes (``DimensionSet``, + ``CTEName``, ``ExpressionId``, ``SourceLocation``). +""" + +from osi.common.identifiers import ( + Identifier, + identifiers_equal, + is_valid_identifier, + normalize_identifier, +) +from osi.common.sql_expr import FrozenSQL, parse_sql_expr, sql_expr_equal +from osi.common.types import CTEName, DimensionSet, ExpressionId, SourceLocation + +__all__ = [ + "CTEName", + "DimensionSet", + "ExpressionId", + "FrozenSQL", + "Identifier", + "SourceLocation", + "identifiers_equal", + "is_valid_identifier", + "normalize_identifier", + "parse_sql_expr", + "sql_expr_equal", +] diff --git a/impl/python/src/osi/common/identifiers.py b/impl/python/src/osi/common/identifiers.py new file mode 100644 index 0000000..0689a44 --- /dev/null +++ b/impl/python/src/osi/common/identifiers.py @@ -0,0 +1,81 @@ +"""Identifier primitives shared by every compiler layer. + +An ``Identifier`` is a normalized name used for datasets, fields, columns, +CTEs, and synthetic names. Normalization (lower-casing) and validation +(shape) are centralized here to enforce **invariant 11** from +``ARCHITECTURE.md``: raw ``==`` on identifier strings is a bug. +""" + +from __future__ import annotations + +import re +from typing import NewType + +from osi.errors import ErrorCode, OSIError + +Identifier = NewType("Identifier", str) + +# Foundation identifier shape: ASCII letter or underscore, followed by any +# run of letters / digits / underscores. Matches SPEC.md §3.1. +_IDENTIFIER_RE = re.compile(r"\A[A-Za-z_][A-Za-z0-9_]*\Z") + +# Reserved identifiers we refuse to accept even though SQL might. +# (Stay conservative: these conflict with pieces of the algebra contract.) +_RESERVED: frozenset[str] = frozenset( + { + "__grain__", + "__provenance__", + "__all__", + } +) + + +def is_valid_identifier(raw: str) -> bool: + """Return whether ``raw`` is a syntactically valid Foundation identifier.""" + return bool(_IDENTIFIER_RE.match(raw)) + + +def normalize_identifier(raw: str) -> Identifier: + """Normalize and validate an identifier. + + Normalization is case-folding. Two identifiers that differ only in + case are the same identifier — this matches standard SQL semantics for + unquoted identifiers. + + Raises + ------ + OSIError + ``E1005_IDENTIFIER_INVALID`` if ``raw`` is empty, has the wrong + shape, or is reserved. + """ + if not isinstance(raw, str): + raise OSIError( + ErrorCode.E1005_IDENTIFIER_INVALID, + f"identifier must be a string, got {type(raw).__name__}", + context={"value": repr(raw)}, + ) + if not raw: + raise OSIError( + ErrorCode.E1005_IDENTIFIER_INVALID, + "identifier is empty", + ) + if not _IDENTIFIER_RE.match(raw): + raise OSIError( + ErrorCode.E1005_IDENTIFIER_INVALID, + f"identifier {raw!r} has invalid shape; " + "must match [A-Za-z_][A-Za-z0-9_]*", + context={"value": raw}, + ) + normalized = raw.lower() + if normalized in _RESERVED: + raise OSIError( + ErrorCode.E2008_RESERVED_IDENTIFIER, + f"identifier {raw!r} is reserved", + context={"value": raw}, + ) + return Identifier(normalized) + + +def identifiers_equal(a: str, b: str) -> bool: + """Case-insensitive identifier equality without raising on invalid shape.""" + return a.lower() == b.lower() diff --git a/impl/python/src/osi/common/sql_expr.py b/impl/python/src/osi/common/sql_expr.py new file mode 100644 index 0000000..8d98d2a --- /dev/null +++ b/impl/python/src/osi/common/sql_expr.py @@ -0,0 +1,103 @@ +"""Thin wrappers over SQLGlot for frozen, comparable AST fragments. + +Invariant 10 from ``ARCHITECTURE.md``: **SQL composition via AST only.** +Every SQL fragment that flows between layers travels as a SQLGlot +``Expression``, never as a string, and every comparison goes through +:func:`sql_expr_equal`. + +The algebra and planner treat expressions as opaque values: they may +store, copy, and compare them; they do not rewrite them. Rewriting is +the job of :mod:`osi.codegen`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +import sqlglot +from sqlglot import exp + +from osi.errors import ErrorCode, OSIError + +PARSE_DIALECT: Final[str] = "" +"""SQLGlot's default (ANSI-like) dialect identifier. + +We intentionally parse scalar expressions with the dialect-neutral +grammar so dialect-specific surface syntax (BigQuery ``QUALIFY``, +Snowflake ``SQUARE_BRACKET_INDEXING``, ...) cannot leak into the +algebra. Dialect translation is codegen's job. +""" + + +def parse_sql_expr(source: str) -> exp.Expression: + """Parse a single SQL scalar expression. + + Raises :class:`OSIError` with ``E1006_SQL_EXPRESSION_SYNTAX`` if + ``source`` cannot be parsed. The code is in the ``E1xxx`` parsing + family because *parsing user-supplied SQL is a layer-1 concern* — + failures here are syntactic, not codegen failures. The previous + ``E5002`` (a codegen "render failed" code) was misleading and broke + the layer-to-error-prefix invariant in ``ARCHITECTURE.md``. + """ + try: + parsed = sqlglot.parse_one(source, read=PARSE_DIALECT or None) + except sqlglot.errors.ParseError as err: + raise OSIError( + ErrorCode.E1006_SQL_EXPRESSION_SYNTAX, + f"failed to parse SQL expression: {source!r}", + context={"source": source, "sqlglot_error": str(err)}, + ) from err + if parsed is None: + raise OSIError( + ErrorCode.E1006_SQL_EXPRESSION_SYNTAX, + f"SQLGlot returned no AST for {source!r}", + context={"source": source}, + ) + return parsed + + +def sql_expr_equal(a: exp.Expression, b: exp.Expression) -> bool: + """Structural equality between SQLGlot expressions. + + Uses SQLGlot's canonical key so two expressions that render the same + way compare equal regardless of incidental whitespace. + """ + return bool(a == b) + + +@dataclass(frozen=True, slots=True) +class FrozenSQL: + """A SQLGlot ``Expression`` wrapped so it can live inside frozen dataclasses. + + ``frozenset``/``tuple`` members require hashable elements. Rather + than relying on SQLGlot's ``__hash__`` (which is present but walks + the AST each call), we precompute a canonical string form. + """ + + expr: exp.Expression + canonical: str + + @classmethod + def of(cls, expr: exp.Expression) -> "FrozenSQL": + """Build a ``FrozenSQL`` from a SQLGlot ``Expression``.""" + return cls( + expr=expr, + canonical=expr.sql(dialect=PARSE_DIALECT or None, normalize=True), + ) + + def __hash__(self) -> int: # noqa: D105 + return hash(self.canonical) + + def __eq__(self, other: object) -> bool: # noqa: D105 + if not isinstance(other, FrozenSQL): + return NotImplemented + return self.canonical == other.canonical + + +__all__ = [ + "PARSE_DIALECT", + "FrozenSQL", + "parse_sql_expr", + "sql_expr_equal", +] diff --git a/impl/python/src/osi/common/types.py b/impl/python/src/osi/common/types.py new file mode 100644 index 0000000..6c682e7 --- /dev/null +++ b/impl/python/src/osi/common/types.py @@ -0,0 +1,73 @@ +"""Cross-layer ``NewType`` aliases and small frozen value objects. + +Keeping these in ``osi.common`` avoids circular imports between the three +compiler layers and lets ``import-linter`` enforce the one-way flow (see +``ARCHITECTURE.md §1.1``). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from typing import NewType + +from osi.common.identifiers import Identifier + +CTEName = NewType("CTEName", str) +ExpressionId = NewType("ExpressionId", str) + +DimensionSet = frozenset[Identifier] +"""Convenience alias used by the algebra for grain sets. + +Kept as a structural alias — not a :class:`typing.NewType` — because +the algebra constructs grain sets by ordinary :class:`frozenset` +operations (union, intersection, set comprehensions) that ``NewType`` +would force every call site to wrap. The discipline we actually rely +on is ``DimensionSet`` only ever holding :class:`Identifier` strings, +which the static type already guarantees. +""" + + +class Dialect(StrEnum): + """SQL dialects the Foundation supports end-to-end. + + The string values are the canonical lower-case form used by the + CLI (``--dialect duckdb``) and SQLGlot (``Expression.sql(dialect=...)``). + YAML inputs may use the SPEC's upper-case spelling + (``ANSI_SQL``, ``DUCKDB``, ``SNOWFLAKE``) — the parsing layer + normalises those to this enum. + + This enum is the single source of truth for the dialect vocabulary + across all three compiler layers. + """ + + # OSI_SQL_2026 is the Foundation v0.1 default expression language. + # Models that don't pin a dialect are parsed and emitted as + # OSI_SQL_2026; engine dialects below are downstream lowerings that + # the codegen produces on demand. + OSI_SQL_2026 = "osi_sql_2026" + ANSI = "ansi" + DUCKDB = "duckdb" + SNOWFLAKE = "snowflake" + + +@dataclass(frozen=True, slots=True) +class SourceLocation: + """1-indexed (line, column) pointer into a YAML or SQL source file. + + Used by parser errors and diagnostics. Never by the algebra or codegen. + """ + + file: str + line: int + column: int + + +__all__ = [ + "CTEName", + "Dialect", + "DimensionSet", + "ExpressionId", + "Identifier", + "SourceLocation", +] diff --git a/impl/python/src/osi/common/windows.py b/impl/python/src/osi/common/windows.py new file mode 100644 index 0000000..9d0dd2b --- /dev/null +++ b/impl/python/src/osi/common/windows.py @@ -0,0 +1,197 @@ +"""Window-function placement and frame-mode rules for Foundation v0.1. + +The Foundation accepts standard SQL window functions in +``Measures`` / ``Fields`` / ``Order By`` / ``Having`` slots, with a +small list of rejection rules that fire at parse / classify time. This +module is the single source of truth for those rules: + +* :func:`first_nested_window` — detects a window function whose argument + contains another window function. Caller maps the hit to + :attr:`ErrorCode.E_NESTED_WINDOW` per Appendix B **D-028(c)**. +* :func:`first_deferred_frame_clause` — detects ``GROUPS`` frames and + parameterised frame bounds (``D-032``). +* :func:`contains_window` — true iff any node in the AST is an + ``exp.Window``. +* :func:`is_windowed_expression` — top-level shape check used by + classification. + +The actual *positive* window planner runs across two surfaces today: +:mod:`osi.planning.planner_scalar` (scalar branch — windowed metrics +become :class:`PlanOperation.ADD_COLUMNS` over the home dataset after +enrichment) and :mod:`osi.planning.classify` (Where-clause rejection +via :attr:`ErrorCode.E_WINDOW_IN_WHERE`). Windowed *measures* in the +aggregation branch are not yet supported — see ``INFRA.md`` I-43. + +This module itself stays purely *rejection* logic: it lets the parser +promote windows out of ``E_DEFERRED_KEY_REJECTED`` and emit the named +window codes (``E_NESTED_WINDOW``, ``E_DEFERRED_FRAME_MODE``) without +committing to the full planner surface. +""" + +from __future__ import annotations + +from typing import Optional + +from sqlglot import expressions as exp + +# Frame modes the Foundation accepts; everything else (currently just +# ``GROUPS``) raises ``E_DEFERRED_FRAME_MODE``. +_ACCEPTED_FRAME_KINDS: frozenset[str] = frozenset({"ROWS", "RANGE"}) + + +def contains_window(expression: exp.Expression) -> bool: + """Return True iff the AST contains at least one ``exp.Window`` node.""" + for node in expression.walk(): + if isinstance(_unwrap(node), exp.Window): + return True + return False + + +def is_windowed_expression(expression: exp.Expression) -> bool: + """Report whether the *top-level* expression is a window function. + + Used to decide whether an expression is a "windowed metric" — a + metric whose body is ``f(...) OVER (...)``. A composite metric that + *references* a windowed metric is detected separately by + :func:`references_windowed_metric`. + """ + return isinstance(expression, exp.Window) or ( + isinstance(expression, exp.Alias) and isinstance(expression.this, exp.Window) + ) + + +def first_nested_window(expression: exp.Expression) -> Optional[exp.Window]: + """Return the first ``OVER`` whose subtree contains another ``OVER``. + + ``D-031``: ``SUM(SUM(x) OVER (...)) OVER (...)`` is structurally + ambiguous — the Foundation has no rule for the outer window's + grain when the inner already partitions, so we reject it up front. + Returns the *outer* window node so the error message can point at + the right span. + """ + for node in expression.walk(): + outer = _unwrap(node) + if not isinstance(outer, exp.Window): + continue + if _has_window_descendant(outer.this): + return outer + for part in outer.args.get("partition_by") or []: + if _has_window_descendant(part): + return outer + for ordered in outer.args.get("order") or []: + if _has_window_descendant(ordered): + return outer + return None + + +def first_deferred_frame_clause( + expression: exp.Expression, +) -> Optional[tuple[exp.Window, str]]: + """Return ``(window, reason)`` for the first deferred frame, if any. + + ``D-032`` defers two frame shapes from Foundation v0.1: + + * ``GROUPS`` frame mode (``OVER (... GROUPS BETWEEN ...)``). + * Parameterised frame bounds (``OVER (... ROWS BETWEEN :n + PRECEDING AND CURRENT ROW)``). + + Returns ``None`` if every window in the AST uses an accepted + ``ROWS`` or ``RANGE`` frame with literal bounds. + """ + for node in expression.walk(): + win = _unwrap(node) + if not isinstance(win, exp.Window): + continue + spec = win.args.get("spec") + if spec is None: + continue + kind = (spec.args.get("kind") or "").upper() + if kind and kind not in _ACCEPTED_FRAME_KINDS: + return win, f"frame mode {kind!r}" + for bound_key in ("start", "end"): + bound = spec.args.get(bound_key) + if _is_parameterised(bound): + return win, "parameterised frame bound" + return None + + +def references_windowed_metric( + expression: exp.Expression, + *, + windowed_metric_names: frozenset[str], +) -> Optional[str]: + """Return the first windowed metric name referenced from ``expression``. + + A composite metric like ``running_total / SUM(orders.amount)`` + references ``running_total`` — and if that base metric's body is a + window function (``running_total = SUM(x) OVER (...)``) the + composite is structurally a "metric on top of a windowed metric" + which D-031 forbids. + + Returns ``None`` if the expression references no windowed metric. + """ + for node in expression.walk(): + col = _unwrap(node) + if not isinstance(col, exp.Column): + continue + # Match either qualified (``orders.running_total``) or bare + # (``running_total``) against the windowed-metric names set. + bare = (col.name or "").lower() + qualified = "" + if col.table: + qualified = f"{col.table.lower()}.{bare}" + if bare in windowed_metric_names: + return bare + if qualified and qualified in windowed_metric_names: + return qualified + return None + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- + + +def _has_window_descendant(node: object) -> bool: + if not isinstance(node, exp.Expression): + return False + for child in node.walk(): + if isinstance(_unwrap(child), exp.Window): + return True + return False + + +def _is_parameterised(bound: object) -> bool: + """Return True iff the frame bound references a placeholder. + + SQLGlot represents ``:n`` and ``?`` as ``exp.Parameter`` / + ``exp.Placeholder`` nodes. Literal bounds (``1``, ``UNBOUNDED``, + ``CURRENT ROW``) come back as plain strings ('UNBOUNDED' / + 'CURRENT ROW') or as ``exp.Literal`` for numeric literals — none + of which the planner needs to reject. + """ + if not isinstance(bound, exp.Expression): + return False + for child in bound.walk(): + ast = _unwrap(child) + if isinstance(ast, (exp.Parameter, exp.Placeholder)): + return True + return False + + +def _unwrap(node: object) -> exp.Expression: + """``walk()`` yields ``(node, parent, key)`` in newer sqlglot.""" + if isinstance(node, exp.Expression): + return node + if isinstance(node, tuple) and node and isinstance(node[0], exp.Expression): + return node[0] + return exp.Expression() + + +__all__ = [ + "contains_window", + "is_windowed_expression", + "first_nested_window", + "first_deferred_frame_clause", + "references_windowed_metric", +] diff --git a/impl/python/src/osi/config.py b/impl/python/src/osi/config.py new file mode 100644 index 0000000..118e061 --- /dev/null +++ b/impl/python/src/osi/config.py @@ -0,0 +1,134 @@ +"""Off-by-default feature flags for deferred Foundation v0.1 constructs. + +`Proposed_OSI_Semantics.md` §10 enumerates a handful of constructs that +the Foundation explicitly defers to the §10 grain-aware-functions +proposal. Their deferred status was sharpened in the latest revision +pass: + +* **D-003** — aggregate-bodied fields (same-grain or cross-grain) are + rejected with ``E_AGGREGATE_IN_FIELD``; all aggregates live in + model-scoped metrics (§4.5). +* **D-027** — nested aggregation in metric expressions + (``AVG(COUNT(orders.oid))`` and similar) is rejected with + ``E_NESTED_AGGREGATION_DEFERRED``; the rules for choosing the inner + grain wait for §10. +* **§4.5** — per-dataset ``metrics:`` blocks (``customers.metrics:``) + carry the same implicit "this metric's home dataset is fixed" pin + as aggregate-bodied fields and are therefore deferred too. Existing + models port mechanically: move the entry to the top-level + ``metrics:`` section and qualify the body with the dataset name — + ``orders.total_revenue = SUM(amount)`` becomes top-level + ``total_revenue = SUM(orders.amount)``. + +The Foundation contract per `Proposed_OSI_Semantics.md` §11 / D-009 is +"engines MAY accept these keys behind a clearly-named, off-by-default +extension flag — a model that uses such a flag is non-portable until +the corresponding deferred proposal lands". This module is that +extension surface for this reference implementation. + +Every flag in :class:`FoundationFlags` defaults to ``False``. Calling +``parse_semantic_model(source)`` with no ``flags`` argument therefore +runs the strict Foundation parser; opting back into the legacy +behaviour requires an explicit ``parse_semantic_model(source, +flags=FoundationFlags(allow_aggregate_in_field=True, ...))``. + +The flags surface deliberately sits outside :mod:`osi.parsing` so that +later layers (planner, codegen, adapter) can read it without dragging +the entire parsing module. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class FoundationFlags: + """Toggles for features deferred from Foundation v0.1. + + Every flag defaults to ``False``. The ``False`` setting matches + the strict Foundation as defined in + ``Proposed_OSI_Semantics.md``; the ``True`` setting opts a model + or session into the legacy / experimental behaviour for that + feature, at the cost of portability across compliant engines. + + Flags + ----- + allow_aggregate_in_field + D-003. When ``True`` the parser accepts aggregate functions + inside a field's ``expression`` (the legacy implicit + home-grain rewrite in :mod:`osi.planning.home_grain` then + runs). When ``False`` (default) the parser raises + :class:`~osi.errors.ErrorCode.E_AGGREGATE_IN_FIELD` and the + author must move the aggregate to a top-level metric. + + allow_dataset_scoped_metrics + Foundation v0.1 §4.5 deferral. When ``True`` the parser + accepts a per-dataset ``metrics:`` block under a dataset + (``customers.metrics: [...]``) and the legacy planner / + namespace paths consume them. When ``False`` (default) the + parser raises + :class:`~osi.errors.ErrorCode.E_DEFERRED_KEY_REJECTED` if any + dataset declares a ``metrics:`` block. + + allow_nested_aggregation + D-027. When ``True`` the parser accepts nested aggregation in + metric expressions (``AVG(COUNT(orders.oid))``, …) and the + :mod:`osi.planning.planner_nested` two-step planner runs. + When ``False`` (default) the parser raises + :class:`~osi.errors.ErrorCode.E_NESTED_AGGREGATION_DEFERRED` + on the offending metric. + + experimental_exists_in + D-017 / Foundation §10. **Experimental.** When ``True`` the + planner accepts ``EXISTS_IN(...)`` / ``NOT EXISTS_IN(...)`` + semi-join predicates inside a query's ``where`` clause and + compiles them through ``filtering_join`` in the algebra. + When ``False`` (default) the planner rejects them at query + classification time with + :class:`~osi.errors.ErrorCode.E_DEFERRED_KEY_REJECTED` — + matching the published Foundation v0.1, which lists + semi-joins as deferred. This is the only flag in this class + that gates a *query*-side (not model-side) construct, and + the only flag for a feature that does not yet have a + published OSI proposal; it lives behind a flag so the + reference implementation can keep its working semi-join + codepath alive without claiming Foundation conformance for + models / queries that use it. + """ + + allow_aggregate_in_field: bool = False + allow_dataset_scoped_metrics: bool = False + allow_nested_aggregation: bool = False + experimental_exists_in: bool = False + + @classmethod + def strict(cls) -> "FoundationFlags": + """Return the strict Foundation defaults (every flag off). + + The same value as ``FoundationFlags()``; provided as a named + constructor so call sites can read like prose: + ``parse_semantic_model(src, flags=FoundationFlags.strict())``. + """ + return cls() + + @classmethod + def legacy_permissive(cls) -> "FoundationFlags": + """Return the legacy-permissive set (every flag on). + + Convenience for callers — most notably internal test fixtures + — that were written against the pre-deferral model and need + every legacy construct enabled at once. Production callers + SHOULD opt into specific flags rather than flip them all at + once; this constructor exists so the *intent* of "legacy + behaviour" is searchable. + """ + return cls( + allow_aggregate_in_field=True, + allow_dataset_scoped_metrics=True, + allow_nested_aggregation=True, + experimental_exists_in=True, + ) + + +__all__ = ["FoundationFlags"] diff --git a/impl/python/src/osi/diagnostics/README.md b/impl/python/src/osi/diagnostics/README.md new file mode 100644 index 0000000..843415b --- /dev/null +++ b/impl/python/src/osi/diagnostics/README.md @@ -0,0 +1,54 @@ +# `osi.diagnostics` — read-only introspection over model and plan + +The diagnostics layer is the answer to *"what just happened?"* It +projects an already-parsed `SemanticModel` or an already-built +`QueryPlan` into human-readable text or JSON. It is intentionally +side-effect-free, model- and plan-only — it does not parse, plan, or +generate SQL, and it never touches the physical data the model +describes. + +Diagnostics matter twice as much in a reference implementation: +every conformance test, every spec ambiguity, and every "why did +this query route this way?" question is answered through these +entry points. If you change a planner decision, the corresponding +diagnostic must move in lockstep. + +## Modules + +| Module | Entry point | Purpose | +| --- | --- | --- | +| `describe.py` | `describe(model)` / `describe_json(model)` | Render a `SemanticModel` as a grouped, table-like summary — datasets, fields, metrics, parameters, relationships, and dialect. Audiences: humans browsing a YAML model; CI checks that a parsed model has the expected shape. | +| `explain.py` | `explain(plan)` / `explain_json(plan)` | Render a `QueryPlan` as a per-step trace: alias, operation, inputs, grain, column list, and a one-line summary of the operator payload. Aliases match the CTE names that codegen emits, so trace lines line up with the generated SQL. | +| `resolve.py` | `resolve(query, context)` / `resolve_json(query, context)` | Show which datasets, fields, metrics, and relationships will be touched by a `SemanticQuery` against a `PlannerContext` — *without* running the planner. Lets users (and CI) confirm that the relationship-graph picks the path they expected. | +| `error_catalog.py` | `explain_error(code)` / `all_explanations()` | The prose explanation table for every `ErrorCode`. The test under `tests/unit/diagnostics/test_error_catalog.py` enforces that every enum member has a non-empty entry. | + +## Invariants + +1. **Read-only.** Diagnostics never mutate their inputs. Inputs are + frozen dataclasses; outputs are new strings / dicts. +2. **No re-planning.** `resolve` and `explain` describe what the + parser / planner produced, not what they could produce — they + never re-run the planner. +3. **Every failure carries a code.** Diagnostics raise + `OSIError(E_INTERNAL_INVARIANT, …)` (never `TypeError` / + `ValueError`) when the IR is out of sync, so the + tests/properties/test_error_taxonomy.py invariant still holds + here. The Phase 3 review I3 finding pinned this rule. +4. **Exhaustive over IR variants.** `explain._payload_summary` must + have a case for every concrete `PlanPayload` subclass. The + exhaustive test in `tests/unit/diagnostics/test_explain.py` + guards this. + +## When to add a new entry point + +Add a new diagnostic when: + +* the planner gains a new piece of state worth surfacing (a new + payload kind, a new resolution rule) — extend `explain` / + `resolve` first; +* a recurrent debugging workflow needs more than five lines of + ad-hoc Python — promote it from a notebook into a module here. + +Never reach across to `osi.codegen` from a diagnostic — diagnostics +describe the plan; codegen renders it. Mixing the two muddies the +phase boundaries in `ARCHITECTURE.md §1.1`. diff --git a/impl/python/src/osi/diagnostics/__init__.py b/impl/python/src/osi/diagnostics/__init__.py new file mode 100644 index 0000000..8fe1741 --- /dev/null +++ b/impl/python/src/osi/diagnostics/__init__.py @@ -0,0 +1,33 @@ +"""Read-only projection of model + plan into human-readable output. + +Entry points: + +- :func:`describe` — render a :class:`~osi.parsing.models.SemanticModel` + as a grouped, table-like summary. +- :func:`explain` — render a :class:`~osi.planning.plan.QueryPlan` as a + per-step grain / column trace. +- :func:`resolve` — for a given :class:`~osi.planning.SemanticQuery` + + :class:`PlannerContext`, show which datasets, relationships, and + fields will be touched. + +All three return *text*; the ``*_json`` variants return JSON-safe +``dict`` / ``list`` structures for programmatic consumption (CLI, +tests, tooling). Neither surface mutates its inputs, and neither +reaches outside the already-parsed / planned inputs — in particular, +nothing here touches the physical data. +""" + +from __future__ import annotations + +from .describe import describe, describe_json +from .explain import explain, explain_json +from .resolve import resolve, resolve_json + +__all__ = [ + "describe", + "describe_json", + "explain", + "explain_json", + "resolve", + "resolve_json", +] diff --git a/impl/python/src/osi/diagnostics/describe.py b/impl/python/src/osi/diagnostics/describe.py new file mode 100644 index 0000000..88f3b9b --- /dev/null +++ b/impl/python/src/osi/diagnostics/describe.py @@ -0,0 +1,123 @@ +"""Human-readable / JSON summaries of a :class:`SemanticModel`. + +The text output is designed for terminal display — fixed-width column +groups, no colour, no unicode box-drawing. The JSON output is designed +for tests and CLIs: keys are sorted, values are strings or primitives. + +Nothing here mutates its inputs or reads the physical data; we only +project what's already in the parsed model. +""" + +from __future__ import annotations + +from typing import Any + +from osi.parsing.models import Dataset, Field, Metric, Relationship, SemanticModel + + +def describe(model: SemanticModel) -> str: + """Render ``model`` as a block of readable, deterministic text.""" + lines: list[str] = [] + lines.append(f"model: {model.name} dialect: {model.dialect.value}") + if model.description: + lines.append(f" description: {model.description}") + lines.append("") + lines.append("datasets:") + for ds in model.datasets: + lines.extend(_describe_dataset(ds)) + if model.relationships: + lines.append("") + lines.append("relationships:") + for rel in model.relationships: + lines.append(f" {_describe_relationship(rel)}") + if model.metrics: + lines.append("") + lines.append("model-level metrics:") + for metric in model.metrics: + lines.append(f" {metric.name} := {metric.expression.canonical}") + return "\n".join(lines) + + +def describe_json(model: SemanticModel) -> dict[str, Any]: + """Return a JSON-safe ``dict`` mirroring :func:`describe`'s content.""" + return { + "name": str(model.name), + "dialect": model.dialect.value, + "description": model.description, + "datasets": [_dataset_to_json(d) for d in model.datasets], + "relationships": [_relationship_to_json(r) for r in model.relationships], + "metrics": [ + { + "name": str(m.name), + "expression": m.expression.canonical, + "description": m.description, + } + for m in model.metrics + ], + } + + +def _describe_dataset(dataset: Dataset) -> list[str]: + lines = [f" - {dataset.name} (source: {dataset.source})"] + if dataset.primary_key: + pk = ", ".join(str(c) for c in dataset.primary_key) + lines.append(f" primary_key: [{pk}]") + if dataset.fields: + lines.append(" fields:") + for fld in dataset.fields: + lines.append(f" - {_describe_field(fld)}") + if dataset.metrics: + lines.append(" metrics:") + for m in dataset.metrics: + lines.append(f" - {_describe_metric(m)}") + return lines + + +def _describe_field(field: Field) -> str: + return f"{field.name:<24} [{field.role.value}] := {field.expression.canonical}" + + +def _describe_metric(metric: Metric) -> str: + return f"{metric.name:<24} := {metric.expression.canonical}" + + +def _describe_relationship(rel: Relationship) -> str: + lhs = ", ".join(str(c) for c in rel.from_columns) + rhs = ", ".join(str(c) for c in rel.to_columns) + return f"{rel.name}: {rel.from_dataset}({lhs}) → {rel.to_dataset}({rhs})" + + +def _dataset_to_json(dataset: Dataset) -> dict[str, Any]: + return { + "name": str(dataset.name), + "source": dataset.source, + "primary_key": [str(c) for c in dataset.primary_key], + "fields": [ + { + "name": str(f.name), + "role": f.role.value, + "expression": f.expression.canonical, + } + for f in dataset.fields + ], + "metrics": [ + { + "name": str(m.name), + "expression": m.expression.canonical, + } + for m in dataset.metrics + ], + } + + +def _relationship_to_json(rel: Relationship) -> dict[str, Any]: + return { + "name": str(rel.name), + "from_dataset": str(rel.from_dataset), + "to_dataset": str(rel.to_dataset), + "from_columns": [str(c) for c in rel.from_columns], + "to_columns": [str(c) for c in rel.to_columns], + } + + +__all__ = ["describe", "describe_json"] diff --git a/impl/python/src/osi/diagnostics/error_catalog.py b/impl/python/src/osi/diagnostics/error_catalog.py new file mode 100644 index 0000000..ac123cd --- /dev/null +++ b/impl/python/src/osi/diagnostics/error_catalog.py @@ -0,0 +1,582 @@ +"""Per-error-code prose explanations. + +Each :class:`~osi.errors.ErrorCode` has exactly one entry here, with a +one-paragraph explanation, the spec section it implements, and (where +applicable) the rewrite the user should consider. The Foundation +contract is that *every* code in the enum has an entry — the +``test_error_catalog_explanations`` test enforces this so a new error +code cannot land without a docstring. + +Consumers: + +* CLI ``osi explain-code `` and ``osi explain-code --list`` + (registered as a console script in ``pyproject.toml``). +* Compliance suite reporters that want a human-readable cause column. +* Internal debugging — ``from osi.diagnostics.error_catalog import + explain_error`` is the canonical lookup. + +This module deliberately does NOT format messages — those are produced +at the raise site with the relevant context. This module explains the +*class* of error. +""" + +from __future__ import annotations + +from osi.errors import ErrorCode + +_EXPLANATIONS: dict[ErrorCode, str] = { + # --- Parse errors (E1xxx) ------------------------------------------------- + ErrorCode.E1001_YAML_SYNTAX: ( + "The semantic model YAML could not be parsed. The file is malformed " + "(e.g. mis-indented block, unclosed quote, tab character) before any " + "OSI-specific validation runs. Fix the YAML syntax and re-run. " + "(Spec: §4 — semantic model file format.)" + ), + ErrorCode.E1002_MISSING_REQUIRED_FIELD: ( + "A required field on a semantic-model object is missing. The catalog " + "of required fields is in §4 of the spec; common omissions are " + "``primary_key`` on a fact dataset and ``measures`` on an aggregation " + "query. (Spec: §4.)" + ), + ErrorCode.E1003_INVALID_ENUM_VALUE: ( + "An enum-typed field (e.g. ``join_type``, ``conformance_level``) was " + "given a value not in the allowed set. The error context lists the " + "valid values. (Spec: §4.)" + ), + ErrorCode.E1004_TYPE_MISMATCH: ( + "A field's declared type does not match the value supplied " + "(e.g. a list where a string was expected). (Spec: §4.)" + ), + ErrorCode.E1005_IDENTIFIER_INVALID: ( + "An identifier did not match the OSI identifier grammar " + "(``[a-zA-Z_][a-zA-Z0-9_]*``). Names must be valid SQL identifiers " + "*and* survive JSON serialisation. (Spec: §4.1.)" + ), + ErrorCode.E1006_SQL_EXPRESSION_SYNTAX: ( + "A SQL expression in a metric, field, or filter did not parse with " + "the OSI_SQL_2026 dialect. The error context names the offending " + "expression. (Spec: ``proposals/foundation-v0.1/" + "SQL_EXPRESSION_SUBSET.md``.)" + ), + # --- Foundation v0.1 named codes (Appendix C) ----------------------------- + ErrorCode.E_DEFERRED_KEY_REJECTED: ( + "The model used a YAML key, SQL function, or relationship attribute " + "that the Foundation v0.1 explicitly defers (e.g. ``EXISTS_IN``, " + "``referential_integrity``, semi-join filter form). The catalog of deferred " + "constructs is in ``Proposed_OSI_Semantics.md §10``. The fix is to remove " + "the construct or wait for the proposal that re-introduces it. " + "(Spec: §10, Appendix B.)" + ), + ErrorCode.E_MIXED_QUERY_SHAPE: ( + "A query mixes the two query shapes — it declares both ``fields`` " + "and ``measures`` (or ``dimensions``) at the same time. Foundation " + "v0.1 requires a query be either *aggregation-shaped* or *scalar-" + "shaped*. (Spec: D-010.)" + ), + ErrorCode.E_AGGREGATE_IN_SCALAR_QUERY: ( + "A scalar-shaped query (one with ``fields``) referenced a metric. " + "Aggregates only belong in aggregation-shaped queries. To get a " + "single aggregate value, write an aggregation query with no " + "dimensions. (Spec: D-011.)" + ), + ErrorCode.E_EMPTY_AGGREGATION_QUERY: ( + "An aggregation-shaped query (one that uses ``measures`` or " + "``dimensions``) declared neither. (Spec: D-010.)" + ), + ErrorCode.E_EMPTY_SCALAR_QUERY: ( + "A scalar-shaped query declared an empty ``fields`` array. " "(Spec: D-010.)" + ), + ErrorCode.E_FAN_OUT_IN_SCALAR_QUERY: ( + "A scalar query reached a finer-grain dataset across an N:1 or N:N " + "edge. Scalar queries cannot fan out — every selected field must be " + "reachable without row multiplication from the anchor dataset. " + "(Spec: D-023.)" + ), + ErrorCode.E_AGGREGATE_IN_WHERE: ( + "A ``where`` predicate contained an aggregate function or a metric " + "reference. ``where`` is row-level only — use ``having`` for " + "post-aggregation predicates. (Spec: D-005, D-012.)" + ), + ErrorCode.E_NON_AGGREGATE_IN_HAVING: ( + "A ``having`` predicate is purely row-level (no aggregate, no metric " + "reference). Move the predicate to ``where``. (Spec: D-005, D-012.)" + ), + ErrorCode.E_MIXED_PREDICATE_LEVEL: ( + "A predicate combines row-level and aggregate-level expressions in " + "one connective (``revenue > 100 AND status = 'open'``). The " + "Foundation requires each predicate to be uniformly row-level or " + "uniformly aggregate-level so the planner can route it without " + "ambiguity. Split the predicate into a ``where`` part and a " + "``having`` part. (Spec: D-005.)" + ), + ErrorCode.E_UNAGGREGATED_FINER_GRAIN_REFERENCE: ( + "A field expression on dataset A references a column from dataset B " + "that is at a finer grain than A, without aggregating it. The " + "Foundation requires either an aggregation (``SUM(B.x)``) or a " + "filter that lifts B to A's grain. (Spec: D-024.)" + ), + ErrorCode.E_UNSAFE_REAGGREGATION: ( + "The chosen plan forces a multi-stage decomposition the aggregate " + "cannot survive — typically a holistic aggregate (``MEDIAN``, " + "``PERCENTILE_CONT``) over a §6.7 chasm pre-aggregation or a " + "§6.8.2 stitch. The §6.8.1 bridge plan is **conceptually** not in " + "this family — D-027 describes it as a single-pass aggregate over " + "the de-duplicated ``(measure-home-row, group-key)`` row set — so " + "every aggregate category is well-defined there in principle. " + "This reference implementation currently realises that route only " + "for the distributive operators (``SUM``, ``COUNT``, ``MIN``, " + "``MAX``) plus ``COUNT(DISTINCT)``; ``AVG``, ``MEDIAN``, and " + "``PERCENTILE_CONT`` over an N:N bridge are still pending and " + "surface this error today (tracked by " + "``compliance/foundation-v0.1/tests/bridge/hard/t-016`` and " + "``t-051``). Fixes: (a) switch to a distributive aggregate, " + "(b) restate at a coarser grain that does not require chasm " + "pre-aggregation, or (c) for M:N references, rely on the bridge " + "plan that the engine already uses for distributive aggregates. " + "(Spec: D-022 / D-027.)" + ), + ErrorCode.E_AMBIGUOUS_NESTED_AGGREGATION_GRAIN: ( + "RESERVED — superseded by ``E_NESTED_AGGREGATION_DEFERRED``. The " + "Foundation defers all nested aggregation in metric expressions " + "to §10's grain-aware-functions proposal, so the inner-grain " + "ambiguity this code described is moot today. The catalog " + "retains the code so external tooling that pinned to it does " + "not break, but no path raises it. (Spec: §4.5, D-027.)" + ), + ErrorCode.E_NESTED_AGGREGATION_DEFERRED: ( + "A metric expression contains a nested aggregate (an aggregate " + "function applied to another aggregate's result, e.g. " + "``AVG(COUNT(orders.oid))``, ``AVG(AVG(orders.amount))``). " + "Nested aggregation requires an implicit grain pin on the inner " + "aggregate; the rules for choosing that pin are deferred to " + "§10's grain-aware-functions proposal. For distributive " + "aggregates (``SUM``, ``COUNT``, ``MIN``, ``MAX``) the " + "single-step form gives identical numbers — write " + "``SUM(orders.amount)`` instead of ``SUM(SUM(orders.amount))``. " + "For non-distributive aggregates the unweighted " + "per-home-row-first interpretation waits for §10. Engines MAY " + "opt back into the legacy two-step planner via the " + "``allow_nested_aggregation`` feature flag, at the cost of " + "portability. (Spec: §4.5, D-027.)" + ), + ErrorCode.E_AGGREGATE_IN_FIELD: ( + "A field expression contains an aggregate function (``SUM``, " + "``COUNT``, ``AVG``, ``COUNT(DISTINCT)``, …) whether over the " + "home dataset's own columns or cross-grain via a ``1:N`` " + "reach. The Foundation requires all aggregates to live in " + "model-scoped metrics in the top-level ``metrics:`` section; " + "field expressions are non-aggregate by construction (window " + "functions remain allowed because they are not aggregates in " + "the spec sense). The fix is to move the aggregate to a " + "top-level metric and reference it from ``Measures``. Engines " + "MAY opt back into the legacy implicit-home-grain field " + "rewrite via the ``allow_aggregate_in_field`` feature flag, at " + "the cost of portability. (Spec: §4.3, D-003.)" + ), + ErrorCode.E_FIELD_DEPENDENCY_CYCLE: ( + "Two or more fields on the same dataset reference one another " + "in a cycle (for example, ``a`` depends on ``b`` which depends " + "back on ``a``). A dataset's fields form a dependency graph; " + "the Foundation requires this graph to be a DAG so the planner " + "can lower derived fields into a topologically ordered " + "sequence of CTE stages — one ``ADD_COLUMNS`` step per level " + "— that compiles to portable SQL on every dialect. A cycle " + "cannot be lowered to a finite number of stages and would " + "force the planner to rely on lateral column aliasing, which " + "is rejected by Snowflake, PostgreSQL, and SQLite. The fix is " + "to break the cycle by promoting the shared sub-expression to " + "a single field that the others depend on, or to inline one " + "of the bodies. (Spec: §4.3.)" + ), + ErrorCode.E_NAME_NOT_FOUND: ( + "A bare or qualified identifier in the query did not resolve to a " + "field, metric, dataset, or relationship visible from the current " + "scope. The error context lists the candidates that *were* in scope. " + "(Spec: D-006, Appendix C.)" + ), + ErrorCode.E_NAME_COLLISION: ( + "Two semantic-model objects share a name in the same namespace, or " + "a bare reference matches more than one object. Qualify the " + "reference with its dataset (``orders.amount``) or rename one of " + "the colliding objects. (Spec: D-006, D-018.)" + ), + ErrorCode.E_AMBIGUOUS_PATH: ( + "More than one join path connects the requested datasets and the " + "Foundation refuses to pick one. Disambiguate by selecting a " + "specific relationship in the query, or by removing the redundant " + "relationship from the model. (Spec: D-006, Appendix C.)" + ), + ErrorCode.E_NO_PATH: ( + "No relationship chain connects the requested datasets. " + "Either add the missing relationship to the model, or scope the " + "query to datasets that are reachable from each other. " + "(Spec: D-006, Appendix C.)" + ), + ErrorCode.E_RESERVED_IDENTIFIER: ( + "An identifier collides with a Foundation reserved word " + "(``GRAIN``, ``FILTER``, ``QUERY_FILTER``, …). Rename the offending " + "field, metric, or dataset. (Spec: D-019.)" + ), + ErrorCode.E_RESERVED_NAME: ( + "An identifier collides with a SQL reserved keyword from the " + "OSI_SQL_2026 dialect (``SELECT``, ``FROM``, ``WHERE``, …). Rename " + "the offending field, metric, or dataset to avoid generating SQL " + "that is ambiguous in some target dialects. (Spec: D-019.)" + ), + ErrorCode.E_WINDOW_IN_WHERE: ( + "A ``Where`` predicate contains a window function " + "(``OVER (...)``). Windows are only allowed in ``Measures``, " + "``Fields``, ``Order By``, and ``Having``. Move the predicate " + "to ``Having`` after wrapping the window in a metric, or use " + "the qualify-style outer-Where pattern. (Spec: D-030.)" + ), + ErrorCode.E_NESTED_WINDOW: ( + "A window function's argument or frame contains another " + "window function — ``SUM(SUM(x) OVER (PARTITION BY a)) OVER " + "(PARTITION BY b)``. The outer window's grain is structurally " + "ambiguous because the inner window already partitions, so " + "the Foundation rejects nested windows up front. Materialise " + "the inner window into a CTE first. (Spec: D-031.)" + ), + ErrorCode.E_WINDOWED_METRIC_COMPOSITION: ( + "A composite metric references a windowed metric (``ratio = " + "running_total / SUM(amount)``). Composing arithmetic on top " + "of a window changes the grain non-uniformly because the " + "window already collapsed across the partition. Wrap the " + "windowed metric in an aggregating CTE first if you need to " + "compose with it. (Spec: D-031.)" + ), + ErrorCode.E_DEFERRED_FRAME_MODE: ( + "A window uses a frame mode (``GROUPS``) or a parameterised " + "frame bound (``ROWS BETWEEN :n PRECEDING AND CURRENT ROW``) " + "that is not in Foundation v0.1. Only literal ``ROWS`` and " + "``RANGE`` frames with constant bounds are accepted. (Spec: " + "D-032.)" + ), + ErrorCode.E_UNKNOWN_FUNCTION: ( + "A function call references a name not in the OSI_SQL_2026 " + "catalog (D-021). The Foundation contract is that every " + "conforming implementation supports the catalog and rejects " + "functions outside it; vendor-specific functions must be " + "wrapped in a per-dialect ``dialects:`` block on the owning " + "metric or field. The whitelist and validator live in " + "``osi.parsing.function_whitelist``. (Spec: D-021 / " + "SQL_EXPRESSION_SUBSET.md.)" + ), + ErrorCode.E_AMBIGUOUS_MEASURE_GRAIN: ( + "RESERVED — Appendix C / D-025 catch-all for a measure with " + "multiple incompatible starting grains where none of the more-" + "specific codes (``E3012``, ``E3013``, " + "``E_UNSAFE_REAGGREGATION``) applies. The reference " + "implementation reaches one of those specific codes today; " + "this code is reserved for engines that synthesise different " + "plan choices and need to surface the ambiguity to the user. " + "The diagnostic MUST list the starting grains the engine " + "identified." + ), + ErrorCode.E_PRIMARY_KEY_REQUIRED: ( + "RESERVED — Appendix C / §4.2. Engines MAY require " + "``primary_key`` declarations on every dataset (so the table " + "grain is well-defined). The reference implementation does " + "not impose this requirement; the code is reserved so an " + "opt-in deployment can raise it under a stable name." + ), + ErrorCode.E_INVALID_NATURAL_GRAIN: ( + "RESERVED — Appendix C. Raised by a future ``natural_grain`` " + "implementation (currently deferred to a separate future " + "proposal). The Foundation parser rejects the " + "``natural_grain`` key through ``E_DEFERRED_KEY_REJECTED`` " + "today. See ``proposals/foundation-v0.1/Proposed_OSI_Semantics.md`` §10." + ), + ErrorCode.E_NATURAL_GRAIN_PRE_AGGREGATION_UNSAFE: ( + "RESERVED — sibling of ``E_INVALID_NATURAL_GRAIN`` for the " + "pre-aggregation-unsafe case. See " + "``proposals/foundation-v0.1/Proposed_OSI_Semantics.md`` §10 " + "(future natural_grain proposal)." + ), + ErrorCode.E_INTERNAL_INVARIANT: ( + "Implementation extension — the IR or a diagnostic detected a " + "*programmer* error, never a user error. Examples: a " + "``QueryPlan`` whose step DAG is not topologically sorted; a " + "``PlanPayload`` subclass with no JSON-encoder case; a " + "``ResolvedReference`` subclass with no entry mapper. The " + "shape of the error means 'compiler invariants are out of " + "sync — ship a fix' rather than 'your model is wrong'. " + "Kept inside the typed ``OSIError`` hierarchy so the " + "property invariant 'every failure carries a code' still " + "holds for these paths. (Spec: implementation extension.)" + ), + ErrorCode.E_WINDOWED_MEASURE_NOT_SUPPORTED: ( + "A metric whose body is a window expression " + "(``ROW_NUMBER() OVER (...)``, ``SUM(x) OVER (...)``, …) " + "was used in an aggregation query's ``Measures`` slot. The " + "Foundation spec (§6.10 / D-031) accepts direct use of a " + "windowed metric in ``Measures``, but this reference engine's " + "aggregation planner does not yet implement the composition " + "of windowed measures with ``GROUP BY``. Workarounds: (a) use " + "a scalar (Fields-only) query — the scalar planner compiles " + "windowed metrics directly via ``ADD_COLUMNS``; (b) replace " + "the window with a plain aggregate that the engine does " + "model. (Spec: implementation extension; future work tracked " + "under INFRA.md I-43.)" + ), + ErrorCode.E_WINDOW_OVER_FANOUT_REWRITE: ( + "A window function would be evaluated over a fan-out join — " + "the partition key includes a column from a 1:N enrichment " + "that has duplicated parent rows. The planner could not " + "rewrite the query into a pre-fan-out CTE because the " + "partition expression itself depends on a fan-out column. " + "Materialise the fan-out into an explicit aggregating CTE " + "first. (Spec: D-030 / Proposed_OSI_Semantics.md §6.10.3.) " + "Engine note: this reference engine forecloses on the " + "fan-out path *before* it reaches the window step — the " + "scalar planner rejects 1:N edges with " + "E_FAN_OUT_IN_SCALAR_QUERY (D-023), and the aggregation " + "planner does not yet support windowed measures (windowed " + "metric expressions are rejected at parse with " + "E_WINDOWED_METRIC_COMPOSITION). The code stays reserved " + "for the future surface where windowed measures land in " + "the aggregation branch; see INFRA.md I-43. Compliance " + "test ``t-052-window-over-fanout-foreclosed`` pins the " + "current behaviour so we notice when this changes." + ), + # --- SQL-surface errors (E12xx) ------------------------------------------- + ErrorCode.E1201_SEMANTIC_VIEW_EMPTY: ( + "A ``SEMANTIC_VIEW`` clause was empty. RESERVED — the SEMANTIC_VIEW " + "surface lands with the future SQL_INTERFACE proposal §8, not " + "Foundation v0.1." + ), + ErrorCode.E1202_CLAUSE_ORDER: ( + "Clauses inside ``SEMANTIC_VIEW`` appeared in the wrong order. " + "RESERVED — future SQL_INTERFACE proposal §8 defines the canonical " + "order." + ), + ErrorCode.E1203_REFERENCE_TOO_DEEP: ( + "A ``SEMANTIC_VIEW`` reference exceeded the maximum depth permitted " + "by the Foundation. RESERVED — future SQL_INTERFACE proposal §8." + ), + ErrorCode.E1204_AMBIGUOUS_BARE_REFERENCE: ( + "A bare reference inside ``SEMANTIC_VIEW`` matched more than one " + "field. RESERVED — future SQL_INTERFACE proposal §8." + ), + ErrorCode.E1205_DUPLICATE_OUTPUT_COLUMN: ( + "Two output columns in a ``SEMANTIC_VIEW`` carry the same name. " + "RESERVED — future SQL_INTERFACE proposal §8." + ), + ErrorCode.E1206_METRIC_IN_RAW_AGGREGATE: ( + "A SEMANTIC_VIEW used a raw aggregate (``SUM(x)``) where the spec " + "requires a metric reference. (Spec: future SQL_INTERFACE proposal §8.)" + ), + ErrorCode.E1207_FACTS_METRICS_EXCLUSIVE: ( + "A SEMANTIC_VIEW combined ``FACTS`` and ``METRICS`` in a single " + "clause. The two are mutually exclusive. (Spec: future " + "SQL_INTERFACE proposal §8.)" + ), + ErrorCode.E1208_UNSUPPORTED_SQL_CONSTRUCT: ( + "A SEMANTIC_VIEW used a SQL construct not in the OSI_SQL_2026 " + "subset (e.g. ``LATERAL``, ``MATCH_RECOGNIZE``). " + "(Spec: SQL_EXPRESSION_SUBSET.md.)" + ), + ErrorCode.E1209_CROSS_DATASET_AD_HOC_AGGREGATE: ( + "A raw aggregate inside a SEMANTIC_VIEW spanned multiple datasets — " + "this requires a metric definition (which carries grain). " + "(Spec: future SQL_INTERFACE proposal §8.)" + ), + ErrorCode.E1210_WINDOW_METRIC_DEFERRED: ( + "Windowed metric definitions are deferred. RESERVED — see " + "the windows proposal." + ), + ErrorCode.E1211_CLAUSE_ONLY_OUTER: ( + "A clause appeared inside an inner SEMANTIC_VIEW that is only " + "permitted on the outer query. RESERVED — future SQL_INTERFACE " + "proposal §8." + ), + ErrorCode.E1212_COUNT_STAR_AMBIGUOUS: ( + "``COUNT(*)`` appeared in a context where the planner could not " + "infer which dataset it counts. Qualify it (``COUNT(orders.*)``) or " + "use a metric reference. (Spec: future SQL_INTERFACE proposal §8.)" + ), + ErrorCode.E1213_PARAMETER_USED_AS_REFERENCE: ( + "A parameter was used in a position the spec reserves for a " + "reference. RESERVED — see the parameters proposal." + ), + # --- Validation (E2xxx — legacy; now mapped to E_* at the boundary) ------ + ErrorCode.E2001_AMBIGUOUS_NAME: ( + "Internal alias of ``E_NAME_COLLISION``. The user-facing surface " + "translates this to ``E_NAME_COLLISION`` at the adapter boundary. " + "(Spec: D-006, D-018.)" + ), + ErrorCode.E2002_NAME_NOT_FOUND: ( + "Internal alias of ``E_NAME_NOT_FOUND`` raised from " + "``osi.parsing.namespace``. The adapter boundary translates this " + "to the user-facing ``E_NAME_NOT_FOUND``. (Spec: D-006.)" + ), + ErrorCode.E2003_DUPLICATE_NAME: ( + "Internal alias of ``E_NAME_COLLISION`` for duplicate declarations " + "inside a single semantic model. (Spec: D-018.)" + ), + ErrorCode.E2004_UNREACHABLE_DATASET: ( + "Internal alias of ``E_NO_PATH`` raised from the namespace " + "builder when no relationship chain reaches the requested dataset. " + "(Spec: D-006.)" + ), + ErrorCode.E2005_CIRCULAR_METRIC: ( + "A metric definition references itself (transitively). " "(Spec: §4.4.)" + ), + ErrorCode.E2006_INVALID_RELATIONSHIP: ( + "A relationship's columns do not match the column lists declared on " + "the two endpoints. (Spec: §4.4.)" + ), + ErrorCode.E2007_MISSING_PRIMARY_KEY: ( + "A dataset is referenced as a join target without a " + "``primary_key`` declaration. (Spec: §4.6.)" + ), + ErrorCode.E2008_RESERVED_IDENTIFIER: ( + "Internal alias of ``E_RESERVED_IDENTIFIER`` for the parser layer. " + "(Spec: D-019.)" + ), + # --- Planning (E3xxx — legacy) ------------------------------------------- + ErrorCode.E3001_AMBIGUOUS_JOIN_PATH: ( + "Internal alias of ``E_AMBIGUOUS_PATH`` raised from the join " + "planner when more than one relationship chain connects the " + "requested datasets. (Spec: D-006.)" + ), + ErrorCode.E3002_UNSATISFIABLE_GRAIN: ( + "A reference cannot be reduced to the requested grain because no " + "aggregation lifts it. (Spec: §6.)" + ), + ErrorCode.E3003_AMBIGUOUS_CARDINALITY: ( + "RESERVED — kept for a future explicit ``cardinality:`` declaration " + "on relationships. Cardinality is currently inferred from declared " + "keys. (Spec: §4.4.)" + ), + ErrorCode.E3004_GRAIN_NOT_SUBSET: ( + "An algebra step received a grain that is not a subset of its input " + "grain. (Spec: §6 — algebra invariants.)" + ), + ErrorCode.E3005_COLUMN_NAME_COLLISION: ( + "A project step received two columns with the same name. Either " + "the model has duplicates or the planner has emitted the same " + "column twice — this is treated as a structural error so the bug " + "surfaces at the algebra layer. (Spec: §6 — algebra invariants.)" + ), + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY: ( + "A step referenced a column that is not in any of its inputs. " + "(Spec: §6 — algebra invariants.)" + ), + ErrorCode.E3007_AGGREGATE_IN_SCALAR_CONTEXT: ( + "A scalar-context expression (``WHERE``, ``ORDER BY``) reached an " + "aggregate function. The Foundation surfaces this as " + "``E_AGGREGATE_IN_WHERE`` at the user boundary. (Spec: D-005.)" + ), + ErrorCode.E3008_GRAIN_MISMATCH_MERGE: ( + "A merge step's two inputs do not agree on grain. (Spec: §6 — " + "merge precondition.)" + ), + ErrorCode.E3009_POST_AGGREGATE_REF_PRE_AGGREGATE: ( + "RESERVED — S-3 split this code into the named predicate-routing " + "codes (``E_AGGREGATE_IN_WHERE``, ``E_NON_AGGREGATE_IN_HAVING``, " + "``E_MIXED_PREDICATE_LEVEL``)." + ), + ErrorCode.E3010_CHASM_TRAP: ( + "RESERVED — chasm traps are prevented structurally by the per-fact " + "merge strategy in §4.11; no path raises this today." + ), + ErrorCode.E3011_MN_AGGREGATION_REJECTED: ( + "Engine-capability opt-out — an engine that does not support M:N " + "traversal raises this for every M:N query. This reference " + "implementation is " + "M:N-supporting; the algebra layer raises this as an internal " + "precondition signal on ``N : N`` edges, which the planner " + "translates to the user-facing per-query codes ``E3012`` / " + "``E3013`` (or ``E_NO_PATH`` for the two-fact stitch case). " + "(Spec: §6.8 *Semantic guarantee*.)" + ), + ErrorCode.E3012_MN_NO_SAFE_REWRITE: ( + "An ``N : N`` traversal in a measure has no semantically-" + "equivalent safe rewrite at the query's grain — no bridge, no " + "shared-dimension stitch. The user-facing per-query M:N failure " + "code for M:N-supporting engines. Suggest adding a bridge " + "dataset or a shared dimension. (Spec: §6.8.)" + ), + ErrorCode.E3013_NO_STITCHING_DIMENSION: ( + "Two unrelated facts (different roots, no path) are referenced " + "together with no dimension shared by both — the result would " + "otherwise be a Cartesian product. Per-query failure code for " + "the multi-fact stitch case (also exposed as ``E_NO_PATH`` in " + "the named-family surface). (Spec: §6.8 / D-006.)" + ), + # --- Algebra (E4xxx) ----------------------------------------------------- + ErrorCode.E4001_EXPLOSION_UNSAFE: ( + "An algebra step would multiply rows in a way the planner does not " + "promise to deduplicate. The fix is to use a filtering join or to " + "materialise a distinct bridge. (Spec: §6.6.)" + ), + ErrorCode.E4002_ENRICH_KEYS_NOT_IN_GRAIN: ( + "RESERVED — the enrich precondition is currently expressed as a " + "fan-trap check over child grain, so this shape never fires " + "independently." + ), + ErrorCode.E4003_MERGE_COLUMN_OVERLAP: ( + "Two inputs to a merge step carry overlapping non-grain columns. " + "Project one side first. (Spec: §6.7.)" + ), + ErrorCode.E4004_BROADCAST_NOT_SCALAR: ( + "A broadcast step received an input that is not scalar (more than " + "one row). (Spec: §6.8.)" + ), + ErrorCode.E4005_FILTERING_JOIN_ADDS_COLUMNS: ( + "A filtering-join step would add columns from the rhs to its lhs — " + "filtering joins are pure semi/anti joins. (Spec: §6.9.)" + ), + # --- Codegen (E5xxx) ----------------------------------------------------- + ErrorCode.E5001_DIALECT_UNSUPPORTED: ( + "The requested dialect is not registered with the codegen layer. " + "Pass ``--dialect `` with a supported value. " + "(Spec: §7 — codegen.)" + ), + ErrorCode.E5002_SQLGLOT_RENDER_FAILED: ( + "SQLGlot raised while rendering the QueryPlan to SQL. The plan is " + "structurally valid but SQLGlot rejected an AST shape — this is " + "almost always a bug in the planner. The error context preserves " + "the SQLGlot exception. (Spec: §7.)" + ), + ErrorCode.E5003_DIALECT_MISSING_FEATURE: ( + "RESERVED — every dialect we ship today is lifted via SQLGlot, so a " + "feature that reaches codegen is supported by construction. Carved " + "out for when bespoke transpilers ship." + ), + # --- Warnings (W6xxx — RESERVED) ----------------------------------------- + ErrorCode.W6001_AVG_OF_AVG: ( + "RESERVED — ``AVG`` of an ``AVG`` warning. The diagnostics warnings " + "channel is specified but not yet wired into the planner." + ), + ErrorCode.W6002_REAGG_PRECISION_LOSS: ( + "RESERVED — re-aggregation precision loss warning. Same status as " "``W6001``." + ), + ErrorCode.W6003_SUSPICIOUS_PATTERN: ( + "RESERVED — generic suspicious-pattern warning. Same status as " "``W6001``." + ), +} + + +def explain_error(code: ErrorCode) -> str: + """Return the catalog explanation for ``code``. + + Raises ``KeyError`` if no explanation is registered — but the + ``test_error_catalog_explanations`` test guarantees at module import + time that this never happens for any member of :class:`ErrorCode`. + """ + return _EXPLANATIONS[code] + + +def all_explanations() -> dict[ErrorCode, str]: + """Return a copy of the full catalog. + + Used by tests and by tooling that wants to dump the catalog + (``osi explain-code --list``). + """ + return dict(_EXPLANATIONS) + + +__all__ = ["explain_error", "all_explanations"] diff --git a/impl/python/src/osi/diagnostics/explain.py b/impl/python/src/osi/diagnostics/explain.py new file mode 100644 index 0000000..12b6ab2 --- /dev/null +++ b/impl/python/src/osi/diagnostics/explain.py @@ -0,0 +1,189 @@ +"""Per-step trace of a :class:`~osi.planning.plan.QueryPlan`. + +The text format is one line per :class:`PlanStep`, grouped into blocks +by operation. Each line carries: + +- the step alias (``step_000``, ``step_001``, ...) — identical to the + CTE alias emitted by :mod:`osi.codegen.transpiler` so traces line up + with the generated SQL; +- the operation name; +- the step inputs (so the DAG is reconstructable by eye); +- the *output grain* as the primary invariant — grain is the semantic + guarantee the algebra makes about each intermediate state; +- a short summary of the payload. + +The JSON variant is the same content, structured for tools. +""" + +from __future__ import annotations + +from typing import Any + +from osi.common.identifiers import Identifier +from osi.common.types import DimensionSet +from osi.errors import ErrorCode, OSIError +from osi.planning.algebra.state import Column +from osi.planning.plan import ( + AddColumnsPayload, + AggregatePayload, + BroadcastPayload, + EnrichDerivedPayload, + EnrichPayload, + FilteringJoinPayload, + FilterPayload, + MergePayload, + PlanPayload, + PlanStep, + ProjectPayload, + QueryPlan, + SourcePayload, +) +from osi.planning.prefixes import step_alias as _alias + + +def explain(plan: QueryPlan) -> str: + """Render ``plan`` as a human-readable per-step trace.""" + lines: list[str] = [] + lines.append( + f"root: {_alias(plan.root_step_id)} " + f"(steps={len(plan.steps)}, limit={plan.limit})" + ) + if plan.output_columns: + cols = ", ".join(str(c) for c in plan.output_columns) + lines.append(f"output: [{cols}]") + if plan.order_by: + order = ", ".join( + f"{o.column}{' DESC' if o.descending else ''}" for o in plan.order_by + ) + lines.append(f"order_by: [{order}]") + lines.append("") + for step in plan.steps: + lines.extend(_render_step(step)) + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def explain_json(plan: QueryPlan) -> dict[str, Any]: + """Return a JSON-safe dict mirroring :func:`explain`'s content.""" + return { + "root": _alias(plan.root_step_id), + "limit": plan.limit, + "output_columns": [str(c) for c in plan.output_columns], + "order_by": [ + {"column": str(o.column), "descending": o.descending} for o in plan.order_by + ], + "steps": [_step_to_json(s) for s in plan.steps], + } + + +def _render_step(step: PlanStep) -> list[str]: + header_inputs = ", ".join(_alias(i) for i in step.inputs) if step.inputs else "-" + grain = sorted(str(g) for g in step.state.grain) + grain_str = "{" + ", ".join(grain) + "}" if grain else "{}" + lines = [ + f"{_alias(step.step_id)} {step.operation.name} " + f"<- {header_inputs} grain={grain_str}" + ] + summary = _payload_summary(step.payload) + if summary: + lines.append(f" {summary}") + cols = ", ".join(str(c.name) for c in step.state.columns) + lines.append(f" columns: [{cols}]") + return lines + + +def _payload_summary(payload: PlanPayload) -> str: + """Return a one-line trace summary for every payload variant. + + Every subclass of :data:`PlanPayload` must have a case here. The + exhaustive-match test + (:mod:`tests/unit/diagnostics/test_explain_exhaustive`) walks + ``PlanPayload`` so adding a payload variant without a case + surfaces the gap immediately. ``E_INTERNAL_INVARIANT`` is the + safety net for the property "every plan can be explained". + """ + if isinstance(payload, SourcePayload): + return f"source: {payload.dataset} @ {payload.source}" + if isinstance(payload, FilterPayload): + scope = "post-aggregate" if payload.is_post_aggregate else "row" + return f"filter ({scope}): {payload.predicate.canonical}" + if isinstance(payload, EnrichPayload): + pairs = _render_enrich_pairs( + payload.parent_keys, payload.child_keys, payload.keys + ) + cols = _render_column_list(payload.child_columns) + return ( + f"enrich {payload.join_type.name}: " + f"{payload.child_dataset} @ {payload.child_source} " + f"on [{pairs}] adds [{cols}]" + ) + if isinstance(payload, EnrichDerivedPayload): + pairs = _render_enrich_pairs( + payload.parent_keys, payload.child_keys, payload.keys + ) + cols = _render_column_list(payload.child_columns) + return ( + f"enrich_derived {payload.join_type.name}: " f"on [{pairs}] adds [{cols}]" + ) + if isinstance(payload, AggregatePayload): + grain = ", ".join(sorted(str(g) for g in payload.new_grain)) + aggs = ", ".join(str(a.name) for a in payload.aggregations) + return f"aggregate: grain=({grain}) aggs=[{aggs}]" + if isinstance(payload, ProjectPayload): + cols = ", ".join(str(c) for c in payload.columns) + return f"project: [{cols}]" + if isinstance(payload, AddColumnsPayload): + defs = ", ".join(str(d.name) for d in payload.definitions) + return f"add_columns: [{defs}]" + if isinstance(payload, MergePayload): + on = ", ".join(sorted(str(k) for k in payload.on)) + return f"merge: on=({on})" + if isinstance(payload, FilteringJoinPayload): + lhs = ", ".join(sorted(str(k) for k in payload.lhs_keys)) + rhs = ", ".join(sorted(str(k) for k in payload.rhs_keys)) + return f"filtering_join {payload.mode.name}: lhs=({lhs}) rhs=({rhs})" + if isinstance(payload, BroadcastPayload): + return f"broadcast: column={payload.column.name}" + raise OSIError( + ErrorCode.E_INTERNAL_INVARIANT, + f"_payload_summary has no case for " + f"{type(payload).__name__} — every PlanPayload variant must " + "have an entry", + context={"payload_type": type(payload).__name__}, + ) + + +def _render_enrich_pairs( + parent_keys: tuple[Identifier, ...], + child_keys: tuple[Identifier, ...], + keys: DimensionSet, +) -> str: + """Render the join-key pairing for an enrich step. + + When the parent / child sides use the same column name the algebra + only carries one ``keys`` set; otherwise the split-out ``parent_keys`` + / ``child_keys`` sequences let us print the actual mapping. + """ + if parent_keys and child_keys: + return ", ".join( + f"{p}={c}" for p, c in zip(parent_keys, child_keys, strict=True) + ) + return ", ".join(sorted(str(k) for k in keys)) + + +def _render_column_list(columns: tuple[Column, ...]) -> str: + return ", ".join(str(c.name) for c in columns) + + +def _step_to_json(step: PlanStep) -> dict[str, Any]: + return { + "alias": _alias(step.step_id), + "operation": step.operation.value, + "inputs": [_alias(i) for i in step.inputs], + "grain": sorted(str(g) for g in step.state.grain), + "columns": [str(c.name) for c in step.state.columns], + "summary": _payload_summary(step.payload), + } + + +__all__ = ["explain", "explain_json"] diff --git a/impl/python/src/osi/diagnostics/resolve.py b/impl/python/src/osi/diagnostics/resolve.py new file mode 100644 index 0000000..549415d --- /dev/null +++ b/impl/python/src/osi/diagnostics/resolve.py @@ -0,0 +1,192 @@ +"""Static resolution view over a :class:`SemanticQuery`. + +Given a query and a :class:`~osi.planning.planner_context.PlannerContext`, +report which datasets, fields, metrics, and relationships will be +touched — *without* running the planner. This is the surface users +reach for when diagnosing "why is my query hitting that table?" or +"which relationship path is picked?". It deliberately shadows the real +planner just enough to describe the inputs. + +For the Foundation: + +- Reference resolution uses the same :mod:`osi.planning.resolve` path + the planner uses. +- Join-path discovery uses :mod:`osi.planning.joins` (the same resolver + the planner consumes), so a diagnostics drift from the planner is a + regression. +""" + +from __future__ import annotations + +from typing import Any + +from osi.common.identifiers import Identifier +from osi.errors import ErrorCode, OSIError +from osi.planning.joins import find_enrichment_path +from osi.planning.planner_context import PlannerContext +from osi.planning.resolve import ( + ResolvedDimension, + ResolvedFact, + ResolvedMetric, + ResolvedReference, + resolve_reference, +) +from osi.planning.semantic_query import Reference, SemanticQuery + + +def resolve(query: SemanticQuery, context: PlannerContext) -> str: + """Render the static-resolution view of ``query`` as text.""" + view = resolve_json(query, context) + lines: list[str] = [] + lines.append("datasets:") + for ds in view["datasets"]: + lines.append(f" - {ds}") + if view["dimensions"]: + lines.append("") + lines.append("dimensions:") + for d in view["dimensions"]: + lines.append( + f" - {d['dataset']}.{d['name']} " f"(expression: {d['expression']})" + ) + if view["measures"]: + lines.append("") + lines.append("measures:") + for m in view["measures"]: + dataset = m["dataset"] or "" + lines.append( + f" - {dataset}.{m['name']} " f"(expression: {m['expression']})" + ) + if view["relationships"]: + lines.append("") + lines.append("relationships used:") + for r in view["relationships"]: + lines.append( + f" - {r['name']} " + f"{r['from_dataset']}({', '.join(r['from_columns'])}) → " + f"{r['to_dataset']}({', '.join(r['to_columns'])}) " + f"[{r['join_type']}]" + ) + if view["filters"]: + lines.append("") + lines.append("filters:") + for f in view["filters"]: + lines.append(f" - {f}") + return "\n".join(lines) + + +def resolve_json(query: SemanticQuery, context: PlannerContext) -> dict[str, Any]: + """Return a JSON-safe dict mirroring :func:`resolve`.""" + dims_used: list[dict[str, Any]] = [] + measures_used: list[dict[str, Any]] = [] + datasets: set[Identifier] = set() + + for ref in query.dimensions: + resolved = resolve_reference(ref, context.namespace) + entry = _reference_entry(ref, resolved) + dims_used.append(entry) + _collect_datasets(resolved, datasets) + + fact_datasets: list[Identifier] = [] + for ref in query.measures: + resolved = resolve_reference(ref, context.namespace) + entry = _reference_entry(ref, resolved) + measures_used.append(entry) + _collect_datasets(resolved, datasets) + dataset_of_measure = _dataset_of(resolved) + if dataset_of_measure is not None: + fact_datasets.append(dataset_of_measure) + + dim_datasets: set[Identifier] = set() + for r in query.dimensions: + d = _dataset_of(resolve_reference(r, context.namespace)) + if d is not None: + dim_datasets.add(d) + + relationships_used: list[dict[str, Any]] = [] + seen_rels: set[str] = set() + for fact_ds in fact_datasets: + targets = frozenset(d for d in dim_datasets if d != fact_ds) + if not targets: + continue + try: + path = find_enrichment_path( + root=fact_ds, targets=targets, graph=context.graph + ) + except OSIError: + # An unresolvable join path is a *normal* outcome for a + # diagnostics view — the user may be inspecting an + # under-modelled query. We skip the unreachable target + # rather than abort the report. Any other exception + # type is a compiler bug and must propagate so the + # property test "every failure carries a code" can + # surface it. + continue + for step in path: + name = str(step.edge.name) + if name in seen_rels: + continue + seen_rels.add(name) + relationships_used.append( + { + "name": name, + "from_dataset": str(step.edge.from_dataset), + "to_dataset": str(step.edge.to_dataset), + "from_columns": [str(c) for c in step.edge.from_columns], + "to_columns": [str(c) for c in step.edge.to_columns], + "join_type": step.join_type.name, + } + ) + datasets.add(step.edge.from_dataset) + datasets.add(step.edge.to_dataset) + + filters = [] + if query.where is not None: + filters.append(query.where.canonical) + + return { + "datasets": sorted(str(d) for d in datasets), + "dimensions": dims_used, + "measures": measures_used, + "relationships": relationships_used, + "filters": filters, + } + + +def _reference_entry(ref: Reference, resolved: ResolvedReference) -> dict[str, Any]: + if isinstance(resolved, ResolvedMetric): + dataset = str(resolved.dataset) if resolved.dataset is not None else None + return { + "dataset": dataset, + "name": str(resolved.metric.name), + "expression": resolved.metric.expression.canonical, + "kind": "metric", + } + if isinstance(resolved, (ResolvedDimension, ResolvedFact)): + return { + "dataset": str(resolved.dataset), + "name": str(resolved.field.name), + "expression": resolved.field.expression.canonical, + "kind": resolved.field.role.value, + } + raise OSIError( # pragma: no cover — exhaustive above + ErrorCode.E_INTERNAL_INVARIANT, + f"unknown resolved reference: {type(resolved).__name__} — " + "every ResolvedReference subclass must have a case in " + "_reference_entry", + context={"resolved_type": type(resolved).__name__}, + ) + + +def _dataset_of(resolved: ResolvedReference) -> Identifier | None: + if isinstance(resolved, ResolvedMetric): + return resolved.dataset + return resolved.dataset + + +def _collect_datasets(resolved: ResolvedReference, into: set[Identifier]) -> None: + ds = _dataset_of(resolved) + if ds is not None: + into.add(ds) + + +__all__ = ["resolve", "resolve_json"] diff --git a/impl/python/src/osi/errors.py b/impl/python/src/osi/errors.py new file mode 100644 index 0000000..b03f279 --- /dev/null +++ b/impl/python/src/osi/errors.py @@ -0,0 +1,331 @@ +"""Typed error hierarchy for the OSI Python reference implementation. + +See ``docs/ERROR_CODES.md`` for the full catalog. Every code listed there +must have an enum value here before it can be raised in production code. + +Tests must assert on ``error.code``, never on message text. +""" + +from __future__ import annotations + +from enum import StrEnum + + +class ErrorCode(StrEnum): + """Stable error codes. See ``docs/ERROR_CODES.md``. + + A code marked ``RESERVED`` is documented in ``docs/ERROR_CODES.md`` + and the relevant spec, but has no emit path in the current + implementation. Reserved codes belong to deferred features + (``SEMANTIC_VIEW`` SQL surface, M:N stitch paths, strict-mode + warnings). They are retained so (a) external tooling that pins to a + code is not broken when the feature lands, and (b) the spec + references in the catalog stay stable. Tests covering reserved + codes live alongside the features that raise them — not here. + """ + + # E1xxx — Parse errors + E1001_YAML_SYNTAX = "E1001" + E1002_MISSING_REQUIRED_FIELD = "E1002" + E1003_INVALID_ENUM_VALUE = "E1003" + E1004_TYPE_MISMATCH = "E1004" + E1005_IDENTIFIER_INVALID = "E1005" + E1006_SQL_EXPRESSION_SYNTAX = "E1006" + # E_* — Foundation v0.1 named codes (Appendix C of + # ``Proposed_OSI_Semantics.md``). The Foundation rollout (S-1..S-17) + # is migrating every ``E1xxx``/``E2xxx``/``E3xxx`` numeric code to + # an ``E_*`` named code; new code MUST use the named form. + E_DEFERRED_KEY_REJECTED = "E_DEFERRED_KEY_REJECTED" + # S-2 / D-010 / D-011 / D-023 — query-shape errors. + E_MIXED_QUERY_SHAPE = "E_MIXED_QUERY_SHAPE" + E_AGGREGATE_IN_SCALAR_QUERY = "E_AGGREGATE_IN_SCALAR_QUERY" + E_EMPTY_AGGREGATION_QUERY = "E_EMPTY_AGGREGATION_QUERY" + E_EMPTY_SCALAR_QUERY = "E_EMPTY_SCALAR_QUERY" + E_FAN_OUT_IN_SCALAR_QUERY = "E_FAN_OUT_IN_SCALAR_QUERY" + # S-3 / D-005 / D-012 — predicate-routing errors that replace the + # legacy E3009 with named codes matching the spec's three-way + # taxonomy. + E_AGGREGATE_IN_WHERE = "E_AGGREGATE_IN_WHERE" + E_NON_AGGREGATE_IN_HAVING = "E_NON_AGGREGATE_IN_HAVING" + E_MIXED_PREDICATE_LEVEL = "E_MIXED_PREDICATE_LEVEL" + # S-5 / D-024 — a field body that references a finer grain + # without aggregating it. + E_UNAGGREGATED_FINER_GRAIN_REFERENCE = "E_UNAGGREGATED_FINER_GRAIN_REFERENCE" + # S-9 / D-022 — the chosen plan forces a multi-stage decomposition the + # aggregate cannot survive (holistic over §6.7 chasm pre-aggregation + # or §6.8.2 stitch). The §6.8.1 bridge plan is **conceptually** not + # in this family — D-027 describes it as a single-pass aggregate over + # the de-duplicated row set. The reference implementation currently + # realises that route only for the distributive operators (``SUM``, + # ``COUNT``, ``MIN``, ``MAX``) plus ``COUNT(DISTINCT)``; ``AVG``, + # ``MEDIAN``, and ``PERCENTILE_CONT`` over an N:N bridge are still + # pending and surface this code today (see ``planner_bridge.py``). + E_UNSAFE_REAGGREGATION = "E_UNSAFE_REAGGREGATION" + # RESERVED — superseded by E_NESTED_AGGREGATION_DEFERRED. The + # Foundation defers all nested aggregation in metric expressions to + # §10's grain-aware-functions proposal (Proposed_OSI_Semantics.md + # §4.5, D-027). The catalog keeps this code so older tooling that + # pinned to it does not break, but no path raises it today; the + # active code is ``E_NESTED_AGGREGATION_DEFERRED``. + E_AMBIGUOUS_NESTED_AGGREGATION_GRAIN = "E_AMBIGUOUS_NESTED_AGGREGATION_GRAIN" + # Foundation v0.1 §4.5 / D-027 — nested aggregation in a metric + # expression (an aggregate function applied to another aggregate's + # result, e.g. ``AVG(COUNT(orders.oid))``) is deferred to §10's + # grain-aware-functions proposal. Behind the + # ``allow_nested_aggregation`` feature flag the planner accepts the + # construct via ``planner_nested``; with the flag off the parser + # rejects the metric body up front with this code. + E_NESTED_AGGREGATION_DEFERRED = "E_NESTED_AGGREGATION_DEFERRED" + # Foundation v0.1 §4.3 / D-003 — a field expression contains an + # aggregate function (``SUM``, ``COUNT``, ``AVG``, …), whether + # over the home dataset's own columns or via a ``1:N`` reach. All + # aggregates live in model-scoped metrics (§4.5); field expressions + # are non-aggregate by construction. Behind the + # ``allow_aggregate_in_field`` feature flag the planner falls back + # to the legacy implicit-home-grain rewrite in + # ``osi.planning.home_grain``. + E_AGGREGATE_IN_FIELD = "E_AGGREGATE_IN_FIELD" + # Foundation v0.1 §4.3 — fields on the same dataset may reference + # one another, but the dependency graph must be a DAG. A cycle + # (e.g. field ``a`` depends on field ``b`` which depends on + # field ``a``) cannot be lowered to a finite sequence of + # ``ADD_COLUMNS`` stages and so is rejected at parse time. The + # planner relies on the topological order of inter-field + # dependencies to emit portable SQL — see + # :func:`osi.planning.steps.source_step` and + # :func:`osi.planning.columns.compute_field_dependencies`. + E_FIELD_DEPENDENCY_CYCLE = "E_FIELD_DEPENDENCY_CYCLE" + # S-10 / D-006 / D-018 / D-019 — identifier resolution + path + # errors. These replace the legacy E2001 / E2002 / E2004 / E2008 / + # E3001 numeric codes for user-facing diagnostics. + E_NAME_NOT_FOUND = "E_NAME_NOT_FOUND" + E_NAME_COLLISION = "E_NAME_COLLISION" + E_AMBIGUOUS_PATH = "E_AMBIGUOUS_PATH" + E_NO_PATH = "E_NO_PATH" + E_RESERVED_IDENTIFIER = "E_RESERVED_IDENTIFIER" + E_RESERVED_NAME = "E_RESERVED_NAME" + # S-12 / D-028 / D-030 / D-031 / D-032 — window-function placement + # and composition rules. Window functions live in ``Measures``, + # ``Fields``, ``Order By``, and ``Having``; never in ``Where`` or + # nested under another window. Frame modes other than ``ROWS`` / + # ``RANGE`` and parameterised frame bounds are deferred. + E_WINDOW_IN_WHERE = "E_WINDOW_IN_WHERE" + E_NESTED_WINDOW = "E_NESTED_WINDOW" + E_WINDOWED_METRIC_COMPOSITION = "E_WINDOWED_METRIC_COMPOSITION" + E_DEFERRED_FRAME_MODE = "E_DEFERRED_FRAME_MODE" + # Implementation extension (F-16). Spec §6.10 accepts windowed + # metrics in ``Measures`` of an aggregation query directly (D-031 + # only defers *composing* a windowed metric from another metric). + # The aggregation planner does not yet implement that surface — + # it currently misclassified windowed metrics as composite and + # raised the misleading ``E1206_METRIC_IN_RAW_AGGREGATE``. The new + # code is the precise diagnostic the spec called for in F-16. + # Scalar (Fields-only) queries continue to compile windowed + # metrics as ``ADD_COLUMNS`` per §6.10 / D-028; see + # :mod:`osi.planning.planner_scalar`. + E_WINDOWED_MEASURE_NOT_SUPPORTED = "E_WINDOWED_MEASURE_NOT_SUPPORTED" + # RESERVED — D-030. The fan-out-vs-window failure mode is foreclosed + # earlier in the current planner: the scalar branch rejects every + # 1:N edge with ``E_FAN_OUT_IN_SCALAR_QUERY`` (D-023) before + # reaching the window step, and the aggregation branch rejects + # windowed measures at parse with ``E_WINDOWED_METRIC_COMPOSITION`` + # (windowed metric expressions are not yet planned in the + # aggregation branch — see ``INFRA.md`` I-43). The code stays in + # the enum because Appendix C requires it and so the future + # surface — windowed measures in aggregation queries — has a + # ready landing pad. Compliance test + # ``t-052-window-over-fanout-foreclosed`` pins the current + # foreclose-before-window behaviour. + E_WINDOW_OVER_FANOUT_REWRITE = "E_WINDOW_OVER_FANOUT_REWRITE" + # D-021 — function call that is not in the OSI_SQL_2026 catalog. + # The catalog is the contract for every Foundation v0.1 + # implementation; vendor-specific functions go through the + # per-dialect ``dialects:`` block. The active whitelist and + # validator live in :mod:`osi.parsing.function_whitelist`. + E_UNKNOWN_FUNCTION = "E_UNKNOWN_FUNCTION" + # RESERVED — D-025 catch-all for measures with multiple + # incompatible starting grains where none of the more-specific + # codes (``E3012``, ``E3013``, ``E_UNSAFE_REAGGREGATION``) + # applies. The reference implementation reaches one of those + # specific codes today; this code is reserved for engines that + # synthesise different plan choices and need to surface the + # ambiguity. The diagnostic MUST list the starting grains the + # engine identified. (Appendix C / D-025.) + E_AMBIGUOUS_MEASURE_GRAIN = "E_AMBIGUOUS_MEASURE_GRAIN" + # RESERVED — Appendix C / §4.2. Engines that opt to require + # ``primary_key`` declarations on every dataset (so the table + # grain is well-defined) raise this when a model omits one. The + # reference implementation does not currently impose this + # requirement; the code is reserved so an opt-in deployment can + # raise it under a stable name. + E_PRIMARY_KEY_REQUIRED = "E_PRIMARY_KEY_REQUIRED" + # RESERVED — Appendix C. Declared by a model that uses the + # (deferred) ``natural_grain`` proposal. Reserved here so the + # diagnostic surface is stable when the natural-grain proposal + # lands; the Foundation parser rejects ``natural_grain`` outright + # through ``E_DEFERRED_KEY_REJECTED`` today. See + # ``proposals/foundation-v0.1/Proposed_OSI_Semantics.md`` §10 + # (the natural_grain feature is deferred to a future proposal). + E_INVALID_NATURAL_GRAIN = "E_INVALID_NATURAL_GRAIN" + # RESERVED — sibling of ``E_INVALID_NATURAL_GRAIN`` for the + # pre-aggregation-unsafe case. Same deferred future proposal as + # above — see ``Proposed_OSI_Semantics.md`` §10. + E_NATURAL_GRAIN_PRE_AGGREGATION_UNSAFE = "E_NATURAL_GRAIN_PRE_AGGREGATION_UNSAFE" + # Implementation extension — raised when the IR or a diagnostic + # detects a *programmer* error, never a user error. Examples: + # a ``QueryPlan`` whose step DAG is not topologically sorted + # (``plan.py:__post_init__``); a payload subclass that has no + # JSON-encoder case (``_payload_to_json``); a resolved reference + # subclass that has no ``_reference_entry`` case + # (``diagnostics/resolve.py``). The shape of the error means + # "the compiler invariants are out of sync; ship a fix" rather + # than "your model is wrong"; keeping it inside the typed + # ``OSIError`` hierarchy means our property tests ("every failure + # carries a code") still hold for these paths. + E_INTERNAL_INVARIANT = "E_INTERNAL_INVARIANT" + + # E12xx — SQL-surface errors (reserved for the future + # SQL_INTERFACE proposal §8). Only E1206 / E1207 / E1208 / E1209 / + # E1212 have active emit paths today; the rest are RESERVED for + # the SEMANTIC_VIEW clause parser. + E1201_SEMANTIC_VIEW_EMPTY = "E1201" # RESERVED — future SQL_INTERFACE §8 + E1202_CLAUSE_ORDER = "E1202" # RESERVED — future SQL_INTERFACE §8 + E1203_REFERENCE_TOO_DEEP = "E1203" # RESERVED — future SQL_INTERFACE §8 + E1204_AMBIGUOUS_BARE_REFERENCE = "E1204" # RESERVED — future SQL_INTERFACE §8 + E1205_DUPLICATE_OUTPUT_COLUMN = "E1205" # RESERVED — future SQL_INTERFACE §8 + E1206_METRIC_IN_RAW_AGGREGATE = "E1206" + E1207_FACTS_METRICS_EXCLUSIVE = "E1207" + E1208_UNSUPPORTED_SQL_CONSTRUCT = "E1208" + E1209_CROSS_DATASET_AD_HOC_AGGREGATE = "E1209" + E1210_WINDOW_METRIC_DEFERRED = "E1210" # RESERVED — window metrics deferred + E1211_CLAUSE_ONLY_OUTER = "E1211" # RESERVED — future SQL_INTERFACE §8 + E1212_COUNT_STAR_AMBIGUOUS = "E1212" + E1213_PARAMETER_USED_AS_REFERENCE = "E1213" # RESERVED — future SQL_INTERFACE §8 + + # E2xxx — Validation errors + E2001_AMBIGUOUS_NAME = "E2001" + E2002_NAME_NOT_FOUND = "E2002" + E2003_DUPLICATE_NAME = "E2003" + E2004_UNREACHABLE_DATASET = "E2004" + E2005_CIRCULAR_METRIC = "E2005" + E2006_INVALID_RELATIONSHIP = "E2006" + E2007_MISSING_PRIMARY_KEY = "E2007" + E2008_RESERVED_IDENTIFIER = "E2008" + + # E3xxx — Planning errors + E3001_AMBIGUOUS_JOIN_PATH = "E3001" + E3002_UNSATISFIABLE_GRAIN = "E3002" + # RESERVED — cardinality is inferred from declared keys today, so + # there is no path that raises this. Kept so a future explicit + # ``cardinality:`` YAML field or a constraint-free relationship can + # fail with a stable code. + E3003_AMBIGUOUS_CARDINALITY = "E3003" + E3004_GRAIN_NOT_SUBSET = "E3004" + E3005_COLUMN_NAME_COLLISION = "E3005" + E3006_MISSING_COLUMN_DEPENDENCY = "E3006" + E3007_AGGREGATE_IN_SCALAR_CONTEXT = "E3007" + E3008_GRAIN_MISMATCH_MERGE = "E3008" + # RESERVED — S-3 split this code into the named predicate-routing + # codes (E_AGGREGATE_IN_WHERE, E_NON_AGGREGATE_IN_HAVING, + # E_MIXED_PREDICATE_LEVEL). Retained so external pinning does not + # break, but no path raises it today. + E3009_POST_AGGREGATE_REF_PRE_AGGREGATE = "E3009" + # RESERVED — today's per-fact merge strategy (§4.11) means a chasm + # trap is prevented structurally rather than raised; see + # ``Proposed_OSI_Semantics.md §6.4``. + E3010_CHASM_TRAP = "E3010" + # E3011 is the engine-capability opt-out code: an engine that does + # not support M:N traversal at all raises it for every M:N query. + # This reference implementation is M:N-supporting (per ``Proposed_OSI_Semantics.md`` + # §6.8 *Semantic guarantee*); the algebra layer raises ``E3011`` + # internally as a precondition signal on ``N : N`` edges, and the + # planner translates it to the user-facing per-query codes + # ``E3012`` / ``E3013``. + E3011_MN_AGGREGATION_REJECTED = "E3011" + # E3012 / E3013 are the user-facing per-query M:N failure codes + # for M:N-supporting engines: ``E3012`` when no safe rewrite exists + # for a particular query (no bridge, no shared-dimension stitch); + # ``E3013`` when two unrelated facts have no shared dimension to + # stitch on. See ``Proposed_OSI_Semantics.md`` §6.8. + E3012_MN_NO_SAFE_REWRITE = "E3012" + E3013_NO_STITCHING_DIMENSION = "E3013" + + # E4xxx — Algebra safety errors + E4001_EXPLOSION_UNSAFE = "E4001" + # RESERVED — enrich's precondition is phrased as a fan-trap check + # over child grain, so this shape never fires independently today. + E4002_ENRICH_KEYS_NOT_IN_GRAIN = "E4002" + E4003_MERGE_COLUMN_OVERLAP = "E4003" + E4004_BROADCAST_NOT_SCALAR = "E4004" + E4005_FILTERING_JOIN_ADDS_COLUMNS = "E4005" + + # E5xxx — Codegen errors + E5001_DIALECT_UNSUPPORTED = "E5001" + E5002_SQLGLOT_RENDER_FAILED = "E5002" + # RESERVED — the Foundation lifts every dialect via SQLGlot, so a + # feature that reaches codegen is supported by construction. This + # code is carved out for when bespoke transpilers ship. + E5003_DIALECT_MISSING_FEATURE = "E5003" + + # W6xxx — Warnings (non-fatal unless strict mode). All RESERVED: + # the diagnostic warnings channel is specified but not yet wired + # into planning (``diagnostics.explain`` does not attach warnings + # to the QueryPlan today). + W6001_AVG_OF_AVG = "W6001" # RESERVED + W6002_REAGG_PRECISION_LOSS = "W6002" # RESERVED + W6003_SUSPICIOUS_PATTERN = "W6003" # RESERVED + + +class OSIError(Exception): + """Root of every error raised anywhere in the compiler. + + Carries a stable ``code`` (see ``ErrorCode``) and an optional + ``context`` dict with actionable fields (dataset, field, grain, + suggestion). Tests should assert on ``error.code``, never on + message text. + """ + + def __init__( + self, + code: ErrorCode, + message: str, + *, + context: dict[str, object] | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.context: dict[str, object] = dict(context or {}) + + +class OSIParseError(OSIError): + """Raised from ``osi.parsing``. Codes in ``E1xxx`` / ``E2xxx``.""" + + +class OSIPlanningError(OSIError): + """Raised from ``osi.planning`` (outside the algebra). Codes in ``E3xxx``.""" + + +class AlgebraError(OSIError): + """Raised from ``osi.planning.algebra``. + + The algebra raises two adjacent code families: + + * ``E4xxx`` — *safety* failures (explosion, broadcast shape, merge + column overlap, filtering-join shape). These are conditions only + the algebra can detect. + * ``E3xxx`` — *contract* failures inherited from the surrounding + planning layer (grain mismatch, missing column, M:N rejection). + They are surfaced by algebra preconditions because the algebra + is the place where the planner's promises become non-negotiable. + + Tests should assert on ``error.code``, never on which family a + code happens to fall in. + """ + + +class OSICodegenError(OSIError): + """Raised from ``osi.codegen``. Codes in ``E5xxx``.""" + + +class OSIWarning(OSIError): + """Non-fatal warnings. Codes in ``W6xxx``. In strict mode these are errors.""" diff --git a/impl/python/src/osi/parsing/README.md b/impl/python/src/osi/parsing/README.md new file mode 100644 index 0000000..2296d00 --- /dev/null +++ b/impl/python/src/osi/parsing/README.md @@ -0,0 +1,62 @@ +# `osi.parsing` — Layer 1 + +Takes a YAML path or string and produces a frozen, validated +`SemanticModel`, `Namespace`, and `RelationshipGraph`. + +**Contract.** + +1. Parsing produces objects the rest of the compiler can trust without + re-validating. +2. Any use of a deferred feature (see §10 of + [`Proposed_OSI_Semantics.md`](../../../../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md) + and the design archive in + (`Proposed_OSI_Semantics.md §10`) raises + `E_DEFERRED_KEY_REJECTED`. +3. Parsing imports nothing from `osi.planning` or `osi.codegen`. + +## Module map + +- `parser.py` — top-level `parse_semantic_model(path_or_yaml)` entry + point. Returns a `ParseResult` carrying the frozen `SemanticModel`, + `Namespace`, and `RelationshipGraph`. +- `models.py` — pydantic v2 schemas (`extra="forbid"`) for every YAML + construct in the Foundation spec. +- `validation.py` — cross-reference and semantic-rule validation that + runs after pydantic. +- `foundation.py` — Foundation-only rules (e.g. aggregate-in-field + rejection per D-003) that fire when the model uses Foundation + semantics. +- `deferred.py` — visitor that rejects YAML keys and expression forms + listed in §10 of the Foundation spec with `E_DEFERRED_KEY_REJECTED`. +- `namespace.py` — builds the name-resolution index that the planner + uses for `dataset.metric`-style references. +- `graph.py` — `RelationshipGraph` construction over declared + relationships. +- `field_deps.py` — computes per-field dependency closure used by + validation and the planner. +- `function_whitelist.py` — the `OSI_SQL_2026` function whitelist + (D-021); the union of every aggregate / window / date / string / + math / conditional / type-conversion function in + `proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md`. Functions + not in this whitelist raise `E_UNKNOWN_FUNCTION` at parse time. +- `reserved_names.py` — single source of truth for identifiers reserved + by the Foundation surface. +- `_root.py` — internal helpers used during YAML pre-processing. + +## Expressions + +Every expression in fields, metrics, filters, and havings is parsed +with `sqlglot.parse_one(dialect=...)` and stored as a frozen AST +(`FrozenSQL` in `osi.common.sql_expr`). The default dialect is +`OSI_SQL_2026` (the Foundation expression dialect; see +[`SQL_EXPRESSION_SUBSET.md`](../../../../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md)). +Raw SQL strings never propagate to the planner. + +## SQL surface + +The Foundation also defines a SQL surface +(reserved for the future SQL_INTERFACE proposal) +that lets callers issue `SELECT … FROM SEMANTIC_VIEW(…)` queries. +That surface is *not* implemented in this layer; semantic queries are +built programmatically via the `osi.planning.SemanticQuery` +constructor. A SQL-surface parser is on the roadmap (see `INFRA.md`). diff --git a/impl/python/src/osi/parsing/__init__.py b/impl/python/src/osi/parsing/__init__.py new file mode 100644 index 0000000..8dd58c4 --- /dev/null +++ b/impl/python/src/osi/parsing/__init__.py @@ -0,0 +1,53 @@ +"""Layer 1 of the compiler pipeline. + +Takes a YAML file (or string) and produces a frozen, validated +:class:`SemanticModel` plus a :class:`Namespace` and +:class:`RelationshipGraph`. Rejects any use of deferred features (``Proposed_OSI_Semantics.md §10``) with ``E_DEFERRED_KEY_REJECTED``. + +See ``../../../ARCHITECTURE.md`` §2 for the full contract. +""" + +from osi.config import FoundationFlags +from osi.parsing.graph import ( + Cardinality, + RelationshipEdge, + RelationshipGraph, + build_graph, +) +from osi.parsing.models import ( + Dataset, + Dialect, + Field, + FieldRole, + Metric, + NamedFilter, + Parameter, + Relationship, + SemanticModel, +) +from osi.parsing.namespace import DatasetNamespace, Namespace, build_namespace +from osi.parsing.parser import ParseResult, parse_semantic_model +from osi.parsing.validation import validate_model + +__all__ = [ + "Cardinality", + "Dataset", + "DatasetNamespace", + "Dialect", + "Field", + "FieldRole", + "FoundationFlags", + "Metric", + "NamedFilter", + "Namespace", + "Parameter", + "ParseResult", + "Relationship", + "RelationshipEdge", + "RelationshipGraph", + "SemanticModel", + "build_graph", + "build_namespace", + "parse_semantic_model", + "validate_model", +] diff --git a/impl/python/src/osi/parsing/_root.py b/impl/python/src/osi/parsing/_root.py new file mode 100644 index 0000000..0ad9319 --- /dev/null +++ b/impl/python/src/osi/parsing/_root.py @@ -0,0 +1,69 @@ +"""Single source of truth for unwrapping a parsed YAML document. + +A semantic model can be written either wrapped:: + + semantic_model: + - name: orders_model + datasets: [...] + +or bare:: + + name: orders_model + datasets: [...] + +Both shapes must produce the same dict before pydantic validation. Two +different copies of this logic used to live in +:mod:`osi.parsing.parser` and :mod:`osi.parsing.deferred`; they drifted +in error wording the first time we touched one without the other. This +module is the only place that does the unwrap. +""" + +from __future__ import annotations + +from typing import Any + +from osi.errors import ErrorCode, OSIParseError + + +def unwrap_model_root(document: Any) -> dict[str, Any]: + """Return the bare model mapping for ``document``. + + Accepts either ``{"semantic_model": []}`` (the wrapped form + from the OSI proposal text) or a bare ```` mapping. Raises + :class:`OSIParseError` (``E1001`` for empty, ``E1002`` for the + wrong list length, ``E1004`` for the wrong type) on any other shape. + """ + if document is None: + raise OSIParseError( + ErrorCode.E1001_YAML_SYNTAX, + "YAML document is empty", + ) + if isinstance(document, dict) and "semantic_model" in document: + payload = document["semantic_model"] + if isinstance(payload, list): + if len(payload) != 1: + raise OSIParseError( + ErrorCode.E1002_MISSING_REQUIRED_FIELD, + "semantic_model must contain exactly one model entry", + context={"count": len(payload)}, + ) + entry = payload[0] + else: + entry = payload + if not isinstance(entry, dict): + raise OSIParseError( + ErrorCode.E1004_TYPE_MISMATCH, + "semantic_model entry must be a mapping", + context={"type": type(entry).__name__}, + ) + return entry + if isinstance(document, dict): + return document + raise OSIParseError( + ErrorCode.E1004_TYPE_MISMATCH, + "YAML root must be a mapping", + context={"type": type(document).__name__}, + ) + + +__all__ = ["unwrap_model_root"] diff --git a/impl/python/src/osi/parsing/deferred.py b/impl/python/src/osi/parsing/deferred.py new file mode 100644 index 0000000..64de693 --- /dev/null +++ b/impl/python/src/osi/parsing/deferred.py @@ -0,0 +1,402 @@ +"""Deferred-feature rejection. + +Every feature listed in §10 of +``../../../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`` +must be unambiguously refused at parse time with +:class:`ErrorCode.E_DEFERRED_KEY_REJECTED`. + +Two surfaces need guarding: + +1. **Raw YAML keys** — this is what pydantic ``extra="forbid"`` handles, + but some deferred features live inside otherwise-valid shapes (e.g. + a ``grain`` attribute on a metric). :func:`check_yaml_deferred` walks + the raw document before pydantic validation so we can attach a + friendlier error. + +2. **SQL ASTs** — window functions, grouping-set constructs, PIVOT, + lateral joins, etc. :func:`check_expression_deferred` walks the + SQLGlot AST of every expression after pydantic parsed it. + +Both entry points take a source location so diagnostics can point at +the offending YAML node or expression. +""" + +from __future__ import annotations + +from typing import Any, Final, Iterable + +from sqlglot import expressions as exp + +from osi.common.sql_expr import FrozenSQL +from osi.common.windows import first_deferred_frame_clause, first_nested_window +from osi.errors import ErrorCode, OSIParseError +from osi.parsing._root import unwrap_model_root + +# --------------------------------------------------------------------------- +# YAML deferred-key inventory +# --------------------------------------------------------------------------- + +# Keys that may appear at the top level of a *metric* or *field* mapping +# but are deferred. Present ⇒ E_DEFERRED_KEY_REJECTED. +# +# S-1 expanded this set to enforce the §10 deferred list of the +# Foundation v0.1 spec. Every key here MUST appear in +# ``Proposed_OSI_Semantics.md §10`` or in the Appendix B +# decision-archive. +DEFERRED_METRIC_KEYS: Final[frozenset[str]] = frozenset( + { + "grain", + "filter", + "semi_additive", + # Phase 7 / I3: Looker-style symmetric aggregates are a codegen + # extension deferred to §10. Catch before pydantic so the + # rejection cites the deferred catalog instead of a generic + # "extra field" error. + "symmetric_aggregate", + "window", + "reset", + # S-1: per-metric joins block (D-001 / D-004 deferred form) + "joins", + # S-1: ``using_relationships`` was the per-metric override; the + # Foundation routes joins by default-shape (D-004) instead. + "using_relationships", + # S-1: named-filter scope tags + "named_filters", + # S-1: ``dataset:`` on a top-level metric is a v1 proposal for + # explicit metric scoping. Foundation v0.1 requires the metric + # body to be self-describing (the home dataset is inferred from + # the resolved expression). Catch this before pydantic so the + # rejection cites the deferred catalog instead of a generic + # "extra field" error. + "dataset", + # S-1: ``agg`` as a top-level YAML key on a metric/field is + # the deferred ``AGG()`` keyword family (D-009). The function + # form is caught by the SQL-AST screen; this catches the YAML + # form before pydantic. + "agg", + } +) + +DEFERRED_FIELD_KEYS: Final[frozenset[str]] = frozenset( + { + "grain", + "window", + # S-3 will reject the YAML ``role:`` field once the + # routing-by-resolved-shape (D-005) classifier replaces the + # current ``role``-driven planner. Until then ``role:`` is + # still the way the internal model identifies dimensions vs + # facts; the user-facing rejection lives in the SQL surface + # (a ``{role=…}`` reference in an expression) which is caught + # via _DEFERRED_FUNCTION_NAMES / unknown-construct paths. + # S-1: ``agg:`` on a field is the deferred ``AGG`` keyword. + "agg", + } +) + +DEFERRED_DATASET_KEYS: Final[frozenset[str]] = frozenset( + { + "filters", # dataset-level filters with scope propagation + # Phase 7 / I3: singular ``filter:`` on a dataset is the + # dataset-scope filter from §10 (distinct from the plural + # ``filters:`` form). Catch before pydantic so the rejection + # cites the deferred catalog. + "filter", + # ``role:`` follows the same plan as on fields above (S-3). + } +) + +DEFERRED_RELATIONSHIP_KEYS: Final[frozenset[str]] = frozenset( + { + "condition", + "asof", + "range", + "temporal", + # S-1: ``referential_integrity`` is removed in favour of the + # default LEFT (D-001) join shape; an engine that wants to + # honour RI must do so as a per-engine optimisation. + "referential_integrity", + } +) + +DEFERRED_MODEL_KEYS: Final[frozenset[str]] = frozenset( + { + # S-1: top-level named-filter section is removed. + "named_filters", + # Phase 7 / I3: model-level ``natural_grain:`` declaration is + # deferred to the natural-grain proposal + # (Proposed_OSI_Natural_Grain.md). Catch before pydantic. + "natural_grain", + } +) + +DEFERRED_QUERY_KEYS: Final[frozenset[str]] = frozenset( + { + "query_filters", + "reset", + "grain", + "filter_context", + "grouping_sets", + "rollup", + "cube", + "pivot", + } +) + + +# --------------------------------------------------------------------------- +# SQL AST deferred constructs +# --------------------------------------------------------------------------- + +# Any of these AST classes appearing in a scalar / aggregate expression +# means the author is reaching for a deferred feature. All raise +# E_DEFERRED_KEY_REJECTED. +# +# S-22 (D-028..D-032): ``exp.Window`` is no longer in this set — +# the positive planner now passes valid windows through to codegen. +# ``_check_window_rules`` still runs first and routes nested-window / +# deferred-frame-mode cases to their named Foundation codes; only +# valid windows reach the planner. +_DEFERRED_AST_NODES: Final[tuple[type[exp.Expression], ...]] = ( + exp.Pivot, + exp.Lateral, + exp.Cube, + exp.Rollup, + exp.GroupingSets, +) + +_DEFERRED_AST_NAMES: Final[frozenset[str]] = frozenset( + cls.__name__ for cls in _DEFERRED_AST_NODES +) + +# S-1: function names removed from OSI_SQL_2026 + the Foundation. Any +# of these as a function call ⇒ E_DEFERRED_KEY_REJECTED. Compared +# case-insensitively because SQL is case-insensitive on identifiers. +_DEFERRED_FUNCTION_NAMES: Final[frozenset[str]] = frozenset( + { + "EXISTS_IN", + "NOT_EXISTS_IN", # alias surface; canonicalised by sqlglot + "ATTR", + "UNSAFE", + "AGG", + "GRAIN_AGG", + } +) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def check_yaml_deferred(document: Any) -> None: + """Walk a parsed YAML document and reject deferred keys. + + ``document`` is the output of ``yaml.safe_load`` — a ``dict`` rooted + at ``semantic_model`` or at the model content directly. The function + descends through known shapes; it never silently accepts a mapping + it does not understand (unknown top-level keys are caught later by + pydantic and surface as ``E1001``). + """ + root = unwrap_model_root(document) + _check_deferred( + mapping=root, + banned=DEFERRED_MODEL_KEYS, + location="semantic_model", + ) + for ds in _as_list(root.get("datasets")): + _check_dataset_deferred(ds) + for rel in _as_list(root.get("relationships")): + _check_deferred( + mapping=rel, + banned=DEFERRED_RELATIONSHIP_KEYS, + location=f"relationship {rel.get('name', '?')!r}", + ) + for metric in _as_list(root.get("metrics")): + _check_deferred( + mapping=metric, + banned=DEFERRED_METRIC_KEYS, + location=f"metric {metric.get('name', '?')!r}", + ) + + +def check_expression_deferred(expression: FrozenSQL, *, where: str) -> None: + """Reject deferred SQL constructs in an expression AST.""" + # S-12: window analysis runs first so we can route specific + # window-rule violations to their named codes before the blanket + # rejection fires for "valid" windows that the planner does not + # yet implement. + _check_window_rules(expression, where=where) + for node in expression.expr.walk(): + ast = _unwrap_walk_item(node) + if isinstance(ast, _DEFERRED_AST_NODES): + raise OSIParseError( + ErrorCode.E_DEFERRED_KEY_REJECTED, + ( + f"{where} uses deferred SQL construct " + f"{type(ast).__name__}; see Proposed_OSI_Semantics.md §10" + ), + context={ + "where": where, + "construct": type(ast).__name__, + "expression": expression.canonical, + }, + ) + # S-1: deferred function calls (EXISTS_IN, ATTR, UNSAFE, AGG, + # GRAIN_AGG). These parse as ``exp.Anonymous`` nodes (sqlglot's + # catch-all for "unknown function") whose ``this`` is the + # function name. + if isinstance(ast, exp.Anonymous): + fn_name = (ast.this or "").upper() + if fn_name in _DEFERRED_FUNCTION_NAMES: + raise OSIParseError( + ErrorCode.E_DEFERRED_KEY_REJECTED, + ( + f"{where} uses deferred SQL function " + f"{fn_name}; see Proposed_OSI_Semantics.md §10" + ), + context={ + "where": where, + "construct": fn_name, + "expression": expression.canonical, + }, + ) + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- + + +def _check_window_rules(expression: FrozenSQL, *, where: str) -> None: + """Apply S-12 window-rejection rules before the blanket deferred check. + + Order matters: D-031 (nested) and D-032 (frame mode) raise their + own named codes; if neither fires we let the caller's blanket + rejection handle the still-unimplemented positive case. + """ + nested = first_nested_window(expression.expr) + if nested is not None: + raise OSIParseError( + ErrorCode.E_NESTED_WINDOW, + ( + f"{where} contains a window function whose argument or " + "frame contains another window function " + "(D-031 — nested windows are not in Foundation v0.1)" + ), + context={ + "where": where, + "expression": expression.canonical, + }, + ) + deferred_frame = first_deferred_frame_clause(expression.expr) + if deferred_frame is not None: + _, reason = deferred_frame + raise OSIParseError( + ErrorCode.E_DEFERRED_FRAME_MODE, + ( + f"{where} uses {reason} which is deferred from " + "Foundation v0.1 (D-032 — only literal ROWS / RANGE " + "frames are accepted)" + ), + context={ + "where": where, + "reason": reason, + "expression": expression.canonical, + }, + ) + + +def _check_dataset_deferred(dataset: Any) -> None: + if not isinstance(dataset, dict): + return + name = dataset.get("name", "?") + _check_deferred( + mapping=dataset, + banned=DEFERRED_DATASET_KEYS, + location=f"dataset {name!r}", + ) + for field in _as_list(dataset.get("fields")): + _check_deferred( + mapping=field, + banned=DEFERRED_FIELD_KEYS, + location=f"field {_fq(name, field)!r}", + ) + for metric in _as_list(dataset.get("metrics")): + _check_deferred( + mapping=metric, + banned=DEFERRED_METRIC_KEYS, + location=f"metric {_fq(name, metric)!r}", + ) + + +def _check_deferred(*, mapping: Any, banned: Iterable[str], location: str) -> None: + if not isinstance(mapping, dict): + return + for key in mapping: + if key in banned: + raise OSIParseError( + ErrorCode.E_DEFERRED_KEY_REJECTED, + ( + f"{location} uses deferred field {key!r}; " + "see Proposed_OSI_Semantics.md §10" + ), + context={"location": location, "field": key}, + ) + + +def _as_list(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + return [] + + +def _fq(dataset_name: Any, member: Any) -> str: + inner = member.get("name", "?") if isinstance(member, dict) else "?" + return f"{dataset_name}.{inner}" + + +def _unwrap_walk_item(item: Any) -> exp.Expression: + """Normalize SQLGlot ``walk()`` items across versions. + + Different SQLGlot releases yield either an :class:`exp.Expression` + directly or a ``(node, parent, key)`` tuple; this helper collapses + both shapes to the bare expression. Any other shape is a + SQLGlot-side surprise we don't know how to interpret — silently + returning a benign ``Expression()`` would let deferred-feature + rejection rules skip the unknown node, so we surface the surprise + as an :attr:`ErrorCode.E_INTERNAL_INVARIANT` instead. A future + SQLGlot upgrade that changes the walk shape will trip this + immediately rather than producing a misleading "accepted" verdict. + """ + if isinstance(item, exp.Expression): + return item + if isinstance(item, tuple) and item and isinstance(item[0], exp.Expression): + return item[0] + raise OSIParseError( + ErrorCode.E_INTERNAL_INVARIANT, + ( + "SQLGlot walk() yielded an unrecognised item shape; the " + "deferred-feature visitor cannot decide whether to admit " + "or reject this node. This is a compiler-side invariant " + "violation — file a bug if you see it." + ), + context={ + "item_type": type(item).__name__, + "item_repr": repr(item)[:200], + "caller": "osi.parsing.deferred._unwrap_walk_item", + }, + ) + + +__all__ = [ + "DEFERRED_DATASET_KEYS", + "DEFERRED_FIELD_KEYS", + "DEFERRED_METRIC_KEYS", + "DEFERRED_MODEL_KEYS", + "DEFERRED_QUERY_KEYS", + "DEFERRED_RELATIONSHIP_KEYS", + "check_expression_deferred", + "check_yaml_deferred", +] diff --git a/impl/python/src/osi/parsing/field_deps.py b/impl/python/src/osi/parsing/field_deps.py new file mode 100644 index 0000000..f05f52b --- /dev/null +++ b/impl/python/src/osi/parsing/field_deps.py @@ -0,0 +1,100 @@ +"""Shared inter-field dependency analysis. + +A field's expression may reference other fields on the same dataset by +bare name (e.g. ``net_amount = amount - discount`` where both ``amount`` +and ``discount`` are sibling fields). The planner uses these +dependencies to topologically order ``ADD_COLUMNS`` stages so the +emitted SQL never relies on lateral aliasing within a single +``SELECT`` (``Proposed_OSI_Semantics.md §4.3``); the parser uses them +to reject cycles up front (``E_FIELD_DEPENDENCY_CYCLE``). + +This module lives under ``osi.parsing`` so both the parser-side +strictness checks (``parsing.foundation``) and the planner-side +column builder (``planning.columns``) can share one implementation. +The function is pure and depends only on already-parsed data +(``Field`` AST + the set of sibling names), so importing it from +either side is layer-safe. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from sqlglot import expressions as exp + +from osi.common.identifiers import Identifier, normalize_identifier +from osi.parsing.models import Field + + +def field_inter_field_dependencies( + field: Field, sibling_field_names: Iterable[Identifier] +) -> frozenset[Identifier]: + """Return every sibling field that ``field`` references in its body. + + Detection rules + --------------- + A bare column reference (``exp.Column`` with ``table is None``) + in ``field.expression`` is treated as a sibling-field dependency + iff the bare name matches some entry in ``sibling_field_names``. + + The field's *own* name is included in this check with one + exception: the **identity projection** ``{name: x, expression: x}`` + is treated as a pure pass-through to the physical column ``x`` and + produces no self-dependency (this is the canonical shape for + declaring a passthrough field whose name happens to match a + physical column). Any *other* expression that mentions the + field's own name (``a_plus_a = a + a_plus_a``, + ``b = sin(b) + 1``) records a self-dependency and is rejected by + the cycle check as ``E_FIELD_DEPENDENCY_CYCLE`` — there's no + semantics under which a derived field references its own + derived value before that value exists. + + Qualified references (``customers.region``) name a column on a + *different* dataset and are resolved by the enrichment planner; + they never contribute to same-dataset inter-field dependencies. + + Window expressions + ------------------ + A window function's ``PARTITION BY`` and ``ORDER BY`` operands + are walked the same way as the rest of the expression — a + windowed field that partitions on a sibling-field name picks up + that sibling as a dependency, which is what the staged-CTE + planner needs to keep the SQL portable across dialects. + """ + sibling_set = frozenset(sibling_field_names) + is_identity = _is_identity_projection(field) + deps: set[Identifier] = set() + for col in field.expression.expr.find_all(exp.Column): + if col.table: + continue + name = normalize_identifier(col.name) + if name == field.name and is_identity: + continue + if name in sibling_set: + deps.add(name) + return frozenset(deps) + + +def _is_identity_projection(field: Field) -> bool: + """Return True iff ``field``'s expression is exactly the bare field name. + + The identity projection ``{name: x, expression: x}`` declares + "surface the physical column ``x`` as field ``x``". The AST for + such an expression is a single :class:`exp.Column` whose name + equals ``field.name`` and which carries no table qualifier. Any + structure beyond that single column node (arithmetic, function + calls, alternate names) means the expression is *derived* and + a self-reference inside it must be treated as a cycle. + """ + root = field.expression.expr + if not isinstance(root, exp.Column): + return False + if root.table: + return False + return normalize_identifier(root.name) == field.name + + +__all__ = ["field_inter_field_dependencies"] + + +__all__ = ["field_inter_field_dependencies"] diff --git a/impl/python/src/osi/parsing/foundation.py b/impl/python/src/osi/parsing/foundation.py new file mode 100644 index 0000000..b0ecbbe --- /dev/null +++ b/impl/python/src/osi/parsing/foundation.py @@ -0,0 +1,361 @@ +"""Foundation-strictness checks gated by :class:`FoundationFlags`. + +`Proposed_OSI_Semantics.md` §10 / D-003 / D-027 enumerate constructs the +Foundation explicitly defers. Each is recognised by pydantic (so the +spec-side YAML shape stays familiar) but rejected by this module unless +the caller opts in via :class:`~osi.config.FoundationFlags`. + +Three flag-gated checks live here, mirroring the three flags: + +* **D-003 — aggregates in fields**: a field's ``expression`` must not + contain any aggregate function. Window functions remain allowed + (they are not aggregates in the spec sense; the parser already + routes them through :func:`osi.parsing.deferred._check_window_rules`). + Violation ⇒ ``E_AGGREGATE_IN_FIELD``. + +* **§4.5 — per-dataset metric blocks**: a dataset's ``metrics:`` block + is deferred — every metric must live in the top-level ``metrics:`` + section. Violation ⇒ ``E_DEFERRED_KEY_REJECTED`` with the deferred + field reported as ``"metrics"``. + +* **D-027 — nested aggregation in metrics**: a metric expression must + not nest an aggregate inside another aggregate. The Foundation's + single-step interpretation gives identical numbers for distributive + aggregates; non-distributive nested aggregates wait for §10's + grain-aware-functions proposal. Violation ⇒ + ``E_NESTED_AGGREGATION_DEFERRED``. + +The order of checks matters — the dataset-scoped-metric check fires +first so a model that uses both a dataset-scoped metric and an +aggregate-bodied field gets the more familiar deferred-key surface +before the aggregate-in-field rejection. + +A fourth, unconditional check enforces that each dataset's +inter-field dependency graph is a DAG (``E_FIELD_DEPENDENCY_CYCLE``). +This is structural — not opt-in — because a cycle cannot be lowered +to the planner's staged-CTE shape on any dialect; see +:func:`osi.planning.steps.source_step`. +""" + +from __future__ import annotations + +from sqlglot import expressions as exp + +from osi.common.identifiers import Identifier +from osi.common.sql_expr import FrozenSQL +from osi.config import FoundationFlags +from osi.errors import ErrorCode, OSIParseError +from osi.parsing.field_deps import field_inter_field_dependencies +from osi.parsing.models import Dataset, Field, Metric, SemanticModel + + +def check_foundation_strictness(model: SemanticModel, flags: FoundationFlags) -> None: + """Reject deferred constructs not enabled in ``flags``. + + Runs after pydantic + cross-reference validation; receives the + fully-built :class:`SemanticModel` so it can inspect already-parsed + expression ASTs. + """ + _check_dataset_scoped_metrics(model, flags) + _check_aggregate_in_fields(model, flags) + _check_nested_aggregation_in_metrics(model, flags) + _check_field_dependency_cycles(model) + + +# --------------------------------------------------------------------------- +# Per-dataset metrics block (§4.5 deferral) +# --------------------------------------------------------------------------- + + +def _check_dataset_scoped_metrics(model: SemanticModel, flags: FoundationFlags) -> None: + if flags.allow_dataset_scoped_metrics: + return + for dataset in model.datasets: + if not dataset.metrics: + continue + offender = dataset.metrics[0] + raise OSIParseError( + ErrorCode.E_DEFERRED_KEY_REJECTED, + ( + f"dataset {dataset.name!r}: per-dataset 'metrics:' " + "blocks are deferred from Foundation v0.1 " + "(Proposed_OSI_Semantics.md §4.5). Move metric " + f"{offender.name!r} to the top-level 'metrics:' section " + "and qualify the body with the dataset name (e.g. " + f"'{dataset.name}.{offender.name} = SUM(amount)' becomes " + f"top-level '{offender.name} = " + f"SUM({dataset.name}.amount)'). To opt back into the " + "legacy behaviour set " + "'FoundationFlags(allow_dataset_scoped_metrics=True)'." + ), + context={ + "location": f"dataset {dataset.name!r}", + "field": "metrics", + "first_metric": str(offender.name), + "flag": "allow_dataset_scoped_metrics", + }, + ) + + +# --------------------------------------------------------------------------- +# Aggregate-bodied fields (D-003) +# --------------------------------------------------------------------------- + + +def _check_aggregate_in_fields(model: SemanticModel, flags: FoundationFlags) -> None: + if flags.allow_aggregate_in_field: + return + for dataset in model.datasets: + for field in dataset.fields: + _reject_field_aggregate(field=field, dataset_name=str(dataset.name)) + + +def _reject_field_aggregate(*, field: Field, dataset_name: str) -> None: + agg = _first_aggregate(field.expression) + if agg is None: + return + function_name = type(agg).__name__.upper() + raise OSIParseError( + ErrorCode.E_AGGREGATE_IN_FIELD, + ( + f"field {dataset_name}.{field.name!r}: aggregate function " + f"{function_name!r} appears in a field expression. " + "Foundation v0.1 §4.3 / D-003 requires every aggregate to " + "live in a model-scoped metric (top-level 'metrics:' " + "section, referenced by bare name). Window functions remain " + "allowed in field expressions; only aggregate functions " + "are rejected. To opt back into the legacy implicit " + "home-grain rewrite set " + "'FoundationFlags(allow_aggregate_in_field=True)'." + ), + context={ + "dataset": dataset_name, + "field": str(field.name), + "aggregate": function_name, + "flag": "allow_aggregate_in_field", + }, + ) + + +def _first_aggregate(expression: FrozenSQL) -> exp.AggFunc | None: + """Return the first :class:`exp.AggFunc` node in ``expression``. + + Only true aggregate functions count. Window expressions (an + aggregate wrapped in ``OVER (...)``) are not flagged here — sqlglot + builds windowed aggregates as an :class:`exp.Window` whose + ``this`` happens to be an :class:`exp.AggFunc`, but the spec + classifies windowed aggregates as window functions, which §4.3.1 + explicitly permits in field expressions. + """ + for node in expression.expr.walk(): + ast = _unwrap_walk(node) + if not isinstance(ast, exp.AggFunc): + continue + if _is_window_argument(ast): + continue + return ast + return None + + +def _is_window_argument(agg: exp.AggFunc) -> bool: + """Return True iff ``agg`` is the aggregate inside a ``Window`` node. + + A windowed aggregate (``SUM(amount) OVER (...)``) is an + :class:`exp.Window` whose ``this`` is the underlying + :class:`exp.AggFunc`. We don't want to reject those — §4.3.1 + explicitly permits window functions in field expressions. + """ + parent = agg.parent + return isinstance(parent, exp.Window) and parent.this is agg + + +# --------------------------------------------------------------------------- +# Nested aggregation in metrics (D-027) +# --------------------------------------------------------------------------- + + +def _check_nested_aggregation_in_metrics( + model: SemanticModel, flags: FoundationFlags +) -> None: + if flags.allow_nested_aggregation: + return + for metric in model.metrics: + _reject_nested(metric=metric, scope="model") + for dataset in model.datasets: + for metric in dataset.metrics: + _reject_nested(metric=metric, scope=str(dataset.name)) + + +def _reject_nested(*, metric: Metric, scope: str) -> None: + nested = _first_nested_aggregate(metric.expression) + if nested is None: + return + outer, inner = nested + raise OSIParseError( + ErrorCode.E_NESTED_AGGREGATION_DEFERRED, + ( + f"metric {scope}.{metric.name!r}: nested aggregation " + f"({type(outer).__name__.upper()} of " + f"{type(inner).__name__.upper()}) is deferred from " + "Foundation v0.1 (Proposed_OSI_Semantics.md §4.5 / D-027). " + "For distributive aggregates the single-step form gives " + "identical numbers — write 'SUM(orders.amount)' instead " + "of 'SUM(SUM(orders.amount))'. The non-distributive " + "per-home-row interpretation waits for §10's grain-aware " + "functions. To opt back into the legacy two-step planner " + "set 'FoundationFlags(allow_nested_aggregation=True)'." + ), + context={ + "metric": str(metric.name), + "scope": scope, + "outer": type(outer).__name__.upper(), + "inner": type(inner).__name__.upper(), + "flag": "allow_nested_aggregation", + }, + ) + + +def _first_nested_aggregate( + expression: FrozenSQL, +) -> tuple[exp.AggFunc, exp.AggFunc] | None: + """Return the first ``(outer, inner)`` aggregate-of-aggregate pair. + + A windowed aggregate (``SUM(amount) OVER (...)``) is not counted + as the outer; window-function bodies that themselves contain + aggregates fall under the window-rules screen in + :mod:`osi.parsing.deferred`, not the nested-aggregation rule. + """ + for node in expression.expr.walk(): + outer = _unwrap_walk(node) + if not isinstance(outer, exp.AggFunc): + continue + if _is_window_argument(outer): + continue + for child in outer.this.walk() if outer.this is not None else (): + inner = _unwrap_walk(child) + if inner is outer: + continue + if isinstance(inner, exp.AggFunc) and not _is_window_argument(inner): + return outer, inner + return None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _unwrap_walk(item: object) -> exp.Expression: + """Normalize SQLGlot ``walk()`` items across versions. + + Newer releases yield bare expressions; older releases yield + ``(node, parent, key)`` tuples. We collapse both shapes so the + callers above can be written uniformly. + """ + if isinstance(item, exp.Expression): + return item + if isinstance(item, tuple) and item and isinstance(item[0], exp.Expression): + return item[0] + return exp.Expression() + + +# --------------------------------------------------------------------------- +# Field dependency cycles (structural — not flag-gated) +# --------------------------------------------------------------------------- + + +def _check_field_dependency_cycles(model: SemanticModel) -> None: + """Reject any dataset whose inter-field dependency graph has a cycle. + + A field's expression may reference other fields on the same + dataset by bare name (qualified references resolve through the + relationship graph and are handled elsewhere). The planner lowers + those references into a topologically ordered chain of + ``ADD_COLUMNS`` CTE stages so the emitted SQL is portable; a + cycle cannot be lowered and would force the planner to rely on + lateral aliasing within a single ``SELECT`` (which Snowflake, + PostgreSQL, and SQLite reject). + + Self-references (``expression = name`` on a field whose name + matches its own identifier) are *not* cycles: they are the + canonical identity-projection shape and resolve to the physical + column at the SOURCE step. + """ + for dataset in model.datasets: + cycle = _find_field_cycle(dataset) + if cycle is None: + continue + cycle_repr = " → ".join(str(name) for name in cycle) + offender = cycle[0] + raise OSIParseError( + ErrorCode.E_FIELD_DEPENDENCY_CYCLE, + ( + f"dataset {dataset.name!r}: inter-field dependency " + f"cycle {cycle_repr!r}. Foundation v0.1 §4.3 requires " + "each dataset's field dependency graph to be a DAG so " + "the planner can lower derived fields into a sequence " + "of ADD_COLUMNS stages compiled as portable SQL. " + "Break the cycle by promoting the shared " + "sub-expression to a single field that the others " + "depend on, or by inlining one of the bodies." + ), + context={ + "dataset": str(dataset.name), + "field": str(offender), + "cycle": [str(name) for name in cycle], + }, + ) + + +def _find_field_cycle(dataset: Dataset) -> tuple[Identifier, ...] | None: + """DFS the field dependency graph and return the first cycle found. + + Returns ``None`` when the graph is acyclic. The returned tuple + starts and ends at the same identifier so callers can render it + directly (``a → b → a``). + """ + field_names = {field.name for field in dataset.fields} + deps_by_field: dict[Identifier, frozenset[Identifier]] = { + field.name: field_inter_field_dependencies(field, field_names) + for field in dataset.fields + } + state: dict[Identifier, int] = {name: 0 for name in deps_by_field} + stack: list[Identifier] = [] + for start in deps_by_field: + if state[start] != 0: + continue + cycle = _dfs_cycle(start, deps_by_field, state, stack) + if cycle is not None: + return cycle + return None + + +def _dfs_cycle( + node: Identifier, + deps: dict[Identifier, frozenset[Identifier]], + state: dict[Identifier, int], + stack: list[Identifier], +) -> tuple[Identifier, ...] | None: + """Run an iterative DFS and return the first back-edge cycle, if any. + + ``state`` carries the standard three-color marking + (0 = white / unvisited, 1 = gray / on the stack, 2 = black / + finished). When we follow an edge into a gray node we extract the + cycle by slicing ``stack`` from that node to the current end. + """ + state[node] = 1 + stack.append(node) + for child in sorted(deps[node]): + if state[child] == 0: + cycle = _dfs_cycle(child, deps, state, stack) + if cycle is not None: + return cycle + elif state[child] == 1: + start_index = stack.index(child) + return tuple(stack[start_index:]) + (child,) + state[node] = 2 + stack.pop() + return None + + +__all__ = ["check_foundation_strictness"] diff --git a/impl/python/src/osi/parsing/function_whitelist.py b/impl/python/src/osi/parsing/function_whitelist.py new file mode 100644 index 0000000..168d2fc --- /dev/null +++ b/impl/python/src/osi/parsing/function_whitelist.py @@ -0,0 +1,340 @@ +"""OSI_SQL_2026 function whitelist (D-021 / Phase 3 review I7). + +The Foundation's expression dialect is +``../../../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md``. +That document is normative: it lists every aggregate, window, +date/time, string, math, conditional, type-conversion, and CAST +function the Foundation accepts. Anything not in that subset is *not* +part of OSI_SQL_2026 and must be rejected at parse time with +:attr:`ErrorCode.E_UNKNOWN_FUNCTION` so a model author sees the error +immediately rather than at SQL-execution time (where the message +would point at the engine, not at their model). + +The whitelist below is the union of every function name in the spec's +REQUIRED and RECOMMENDED tables, normalised to upper-case. Aliases +listed by the spec (e.g. ``CEIL`` / ``CEILING``, ``TRUNC`` / +``TRUNCATE``) are both included so users can write either spelling. +The deferred-function names (``EXISTS_IN``, ``ATTR``, ``UNSAFE``, +``AGG``, ``GRAIN_AGG``) are deliberately *not* in this whitelist — +they raise :attr:`ErrorCode.E_DEFERRED_KEY_REJECTED` instead, via +:mod:`osi.parsing.deferred`, so the user sees the more specific +"deferred for a later spec tier" message rather than the generic +"unknown function" one. +""" + +from __future__ import annotations + +from typing import Final + +from sqlglot import expressions as exp + +from osi.common.sql_expr import FrozenSQL +from osi.errors import ErrorCode, OSIParseError + +# --------------------------------------------------------------------------- +# The whitelist +# --------------------------------------------------------------------------- + +# Aggregation §170. +_AGGREGATE_FUNCTIONS: Final[frozenset[str]] = frozenset( + { + # Core (REQUIRED) + "SUM", + "COUNT", + "AVG", + "MIN", + "MAX", + # Statistical (REQUIRED) + "STDDEV", + "STDDEV_POP", + "STDDEV_SAMP", + "VARIANCE", + "VAR_POP", + "VAR_SAMP", + # Percentile (REQUIRED) + "MEDIAN", + "PERCENTILE_CONT", + "PERCENTILE_DISC", + # Approximate (RECOMMENDED) + "APPROX_COUNT_DISTINCT", + "APPROX_PERCENTILE", + } +) + +# Date/Time §274. +_DATETIME_FUNCTIONS: Final[frozenset[str]] = frozenset( + { + # Current + "CURRENT_DATE", + "CURRENT_TIMESTAMP", + "CURRENT_TIME", + # Extraction (component getters) + "YEAR", + "QUARTER", + "MONTH", + "WEEK", + "DAY", + "DAYOFWEEK", + "DAYOFYEAR", + "HOUR", + "MINUTE", + "SECOND", + # Alternative-syntax (operator-like, but parse as functions) + "EXTRACT", + "DATE_PART", + # Truncation / arithmetic + "DATE_TRUNC", + "DATEADD", + "DATEDIFF", + # Construction / parsing / formatting + "DATE", + "TIMESTAMP", + "TO_DATE", + "TO_TIMESTAMP", + "TO_CHAR", + } +) + +# String §385. +_STRING_FUNCTIONS: Final[frozenset[str]] = frozenset( + { + # Manipulation + "CONCAT", + "LENGTH", + "LOWER", + "UPPER", + "TRIM", + "LTRIM", + "RTRIM", + "LEFT", + "RIGHT", + "SUBSTRING", + "REPLACE", + "SPLIT_PART", + # Search + "POSITION", + "CHARINDEX", + "CONTAINS", + "STARTSWITH", + "ENDSWITH", + # Pattern matching as function call (LIKE / ILIKE are operators) + "REGEXP_LIKE", + # Regex (RECOMMENDED) + "REGEXP_EXTRACT", + "REGEXP_REPLACE", + "REGEXP_COUNT", + } +) + +# Math §439. +_MATH_FUNCTIONS: Final[frozenset[str]] = frozenset( + { + # Basic + "ABS", + "ROUND", + "FLOOR", + "CEIL", + "CEILING", + "TRUNC", + "TRUNCATE", + "MOD", + "SIGN", + # Advanced + "POWER", + "SQRT", + "EXP", + "LN", + "LOG", + "LOG10", + # Trigonometric (RECOMMENDED) + "SIN", + "COS", + "TAN", + "ASIN", + "ACOS", + "ATAN", + "ATAN2", + "RADIANS", + "DEGREES", + "PI", + # Comparison + "GREATEST", + "LEAST", + } +) + +# Conditional §488. +_CONDITIONAL_FUNCTIONS: Final[frozenset[str]] = frozenset( + { + "IF", + "IFF", + "NULLIF", + "COALESCE", + "IFNULL", + "NVL", + "NVL2", + "ZEROIFNULL", + "NULLIFZERO", + } +) + +# Window §533. Aggregations as window functions reuse the aggregate list. +_WINDOW_FUNCTIONS: Final[frozenset[str]] = frozenset( + { + # Ranking + "ROW_NUMBER", + "RANK", + "DENSE_RANK", + "NTILE", + "PERCENT_RANK", + "CUME_DIST", + # Offset / position + "LAG", + "LEAD", + "FIRST_VALUE", + "LAST_VALUE", + "NTH_VALUE", + } +) + +# Type conversion §594. CAST is structural (handled by sqlglot's +# ``exp.Cast``) but TRY_CAST appears as a function call. +_TYPE_CONVERSION_FUNCTIONS: Final[frozenset[str]] = frozenset( + { + "CAST", + "TRY_CAST", + "TO_VARCHAR", + "TO_NUMBER", + # TO_DATE / TO_TIMESTAMP already in datetime + "TO_BOOLEAN", + } +) + +# sqlglot canonicalises some Foundation-listed names to different +# spellings during parse (e.g. ``APPROX_COUNT_DISTINCT`` collapses to +# ``APPROX_DISTINCT``). Both the spec name and the sqlglot-canonical +# name must be in the whitelist so a user can author the spec name +# and the validator still accepts the parsed AST. +_SQLGLOT_ALIASES: Final[frozenset[str]] = frozenset( + { + "APPROX_DISTINCT", # parsed form of APPROX_COUNT_DISTINCT + "VARIANCE_POP", # parsed form of VAR_POP + "DAY_OF_WEEK", # parsed form of DAYOFWEEK + "DAY_OF_YEAR", # parsed form of DAYOFYEAR + } +) + +OSI_SQL_2026_FUNCTIONS: Final[frozenset[str]] = ( + _AGGREGATE_FUNCTIONS + | _DATETIME_FUNCTIONS + | _STRING_FUNCTIONS + | _MATH_FUNCTIONS + | _CONDITIONAL_FUNCTIONS + | _WINDOW_FUNCTIONS + | _TYPE_CONVERSION_FUNCTIONS + | _SQLGLOT_ALIASES +) +"""Every function name accepted by the OSI_SQL_2026 expression subset. + +Names are upper-case. The set is the union of every REQUIRED or +RECOMMENDED entry in +``../../../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md``, +plus the spec-listed aliases (``CEIL`` / ``CEILING``, ``TRUNC`` / +``TRUNCATE``). +""" + +# Function-shaped AST nodes whose semantics are operators or +# language constructs rather than user-callable functions. We never +# raise ``E_UNKNOWN_FUNCTION`` for these — they are accepted by the +# parser via the operator surface (``CASE``, ``LIKE``, ``IS NULL`` …) +# even though sqlglot models them as ``exp.Func`` subclasses. +_ALLOWED_FUNC_CLASSES: Final[tuple[type[exp.Expression], ...]] = ( + exp.Case, + exp.Cast, + exp.TryCast, + exp.Coalesce, # also in the function list, but defensively allowed +) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def check_expression_functions(expression: FrozenSQL, *, where: str) -> None: + """Reject function calls outside the OSI_SQL_2026 subset (D-021 / I7). + + Walks the AST and raises :attr:`ErrorCode.E_UNKNOWN_FUNCTION` for any + function call whose name is not in + :data:`OSI_SQL_2026_FUNCTIONS`. Deferred functions + (``EXISTS_IN`` etc.) are reported separately by + :mod:`osi.parsing.deferred`, which runs before this check, so a + deferred function never reaches this code path. + """ + for node in expression.expr.walk(): + ast = _unwrap_walk_item(node) + if not isinstance(ast, exp.Func): + continue + if isinstance(ast, _ALLOWED_FUNC_CLASSES): + continue + name = _canonical_function_name(ast) + if name is None: + continue + if name in OSI_SQL_2026_FUNCTIONS: + continue + raise OSIParseError( + ErrorCode.E_UNKNOWN_FUNCTION, + ( + f"{where} calls function {name!r}, which is not in the " + f"OSI_SQL_2026 expression subset. The accepted function " + f"list is fixed by ../../../../proposals/foundation-v0.1/" + f"SQL_EXPRESSION_SUBSET.md; see Appendix C for the error " + f"code (D-021)." + ), + context={ + "where": where, + "function": name, + "expression": expression.canonical, + }, + ) + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- + + +def _canonical_function_name(node: exp.Func) -> str | None: + """Return the function name to compare against the whitelist. + + For :class:`exp.Anonymous` (sqlglot's catch-all for unrecognised + function names), use the raw ``this`` attribute. For every other + :class:`exp.Func` subclass, use :meth:`sqlglot.exp.Func.sql_name`, + which returns the canonical SQL spelling for that class. + """ + if isinstance(node, exp.Anonymous): + raw = node.this + if not isinstance(raw, str): + return None + return raw.upper() + # sqlglot's ``Func.sql_name`` is annotated as returning ``Any``; + # cast the result before normalising. + sql_name: object = node.sql_name() # type: ignore[no-untyped-call] + if not isinstance(sql_name, str) or not sql_name: + return None + return sql_name.upper() + + +def _unwrap_walk_item(item: object) -> exp.Expression | None: + """``walk()`` yields ``(node, parent, key)`` in newer sqlglot.""" + if isinstance(item, exp.Expression): + return item + if isinstance(item, tuple) and item and isinstance(item[0], exp.Expression): + return item[0] + return None + + +__all__ = [ + "OSI_SQL_2026_FUNCTIONS", + "check_expression_functions", +] diff --git a/impl/python/src/osi/parsing/graph.py b/impl/python/src/osi/parsing/graph.py new file mode 100644 index 0000000..9882642 --- /dev/null +++ b/impl/python/src/osi/parsing/graph.py @@ -0,0 +1,195 @@ +"""Relationship graph built from a :class:`SemanticModel`. + +Used by the planner (Phase 3) to find join paths between datasets. The +graph is directed (from → to, reflecting the declared N:1 direction) but +carries undirected adjacency for path-finding — the planner decides +direction based on the query context. + +Cardinality is inferred here from declared PKs / UKs per +``Proposed_OSI_Semantics.md §6.1``. The result is stored on each edge +and re-used by the planner. + +The graph itself is immutable. Building it is the final step of +parsing. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum + +from osi.common.identifiers import Identifier +from osi.errors import ErrorCode, OSIParseError +from osi.parsing.models import Dataset, Relationship, SemanticModel + + +class Cardinality(StrEnum): + """Inferred relationship cardinality (``§6.1``).""" + + N_TO_ONE = "N:1" + ONE_TO_ONE = "1:1" + N_TO_N = "N:N" + + +@dataclass(frozen=True, slots=True) +class RelationshipEdge: + """Directed edge in the relationship graph.""" + + name: Identifier + from_dataset: Identifier + to_dataset: Identifier + from_columns: tuple[Identifier, ...] + to_columns: tuple[Identifier, ...] + cardinality: Cardinality + from_all_rows_match: bool + to_all_rows_match: bool + + +@dataclass(frozen=True, slots=True) +class RelationshipGraph: + """Index over relationships with adjacency for path-finding.""" + + edges: tuple[RelationshipEdge, ...] + _adjacency: dict[Identifier, tuple[RelationshipEdge, ...]] = field( + default_factory=dict + ) + + def neighbors(self, dataset: Identifier) -> tuple[RelationshipEdge, ...]: + """Edges touching ``dataset`` on either side.""" + return self._adjacency.get(dataset, ()) + + def find_paths( + self, + start: Identifier, + end: Identifier, + *, + max_depth: int = 6, + ) -> tuple[tuple[RelationshipEdge, ...], ...]: + """All simple paths between two datasets up to ``max_depth``. + + The planner uses this to detect ambiguous joins (``E3001``). + Order is deterministic (edges by declaration order). + """ + if start == end: + return ((),) + results: list[tuple[RelationshipEdge, ...]] = [] + self._dfs_paths(start, end, tuple(), {start}, max_depth, results) + return tuple(results) + + def _dfs_paths( + self, + current: Identifier, + target: Identifier, + path: tuple[RelationshipEdge, ...], + visited: set[Identifier], + depth_left: int, + out: list[tuple[RelationshipEdge, ...]], + ) -> None: + if depth_left == 0: + return + for edge in self.neighbors(current): + other = ( + edge.to_dataset if edge.from_dataset == current else edge.from_dataset + ) + if other in visited: + continue + new_path = path + (edge,) + if other == target: + out.append(new_path) + continue + self._dfs_paths( + other, + target, + new_path, + visited | {other}, + depth_left - 1, + out, + ) + + +def build_graph(model: SemanticModel) -> RelationshipGraph: + """Construct a :class:`RelationshipGraph` from a validated model.""" + datasets_by_name = {ds.name: ds for ds in model.datasets} + edges: list[RelationshipEdge] = [] + adjacency: dict[Identifier, list[RelationshipEdge]] = { + ds.name: [] for ds in model.datasets + } + for rel in model.relationships: + edge = _build_edge(rel, datasets_by_name) + edges.append(edge) + adjacency[edge.from_dataset].append(edge) + adjacency[edge.to_dataset].append(edge) + return RelationshipGraph( + edges=tuple(edges), + _adjacency={k: tuple(v) for k, v in adjacency.items()}, + ) + + +def _build_edge( + relationship: Relationship, + datasets_by_name: dict[Identifier, Dataset], +) -> RelationshipEdge: + from_ds = datasets_by_name.get(relationship.from_dataset) + to_ds = datasets_by_name.get(relationship.to_dataset) + if from_ds is None or to_ds is None: # defensive — caught in validation + raise OSIParseError( + ErrorCode.E2006_INVALID_RELATIONSHIP, + f"relationship {relationship.name!r} references missing dataset", + context={"name": relationship.name}, + ) + cardinality = _infer_cardinality( + from_columns=relationship.from_columns, + to_columns=relationship.to_columns, + from_dataset=from_ds, + to_dataset=to_ds, + ) + # ``referential_integrity`` is a deferred feature (D-018 / §10): + # the model has no such field, so RI hints can't propagate into + # the edge. ``from_all_rows_match`` / ``to_all_rows_match`` stay + # ``False`` until the proposal lands. + return RelationshipEdge( + name=relationship.name, + from_dataset=relationship.from_dataset, + to_dataset=relationship.to_dataset, + from_columns=relationship.from_columns, + to_columns=relationship.to_columns, + cardinality=cardinality, + from_all_rows_match=False, + to_all_rows_match=False, + ) + + +def _infer_cardinality( + *, + from_columns: tuple[Identifier, ...], + to_columns: tuple[Identifier, ...], + from_dataset: Dataset, + to_dataset: Dataset, +) -> Cardinality: + """Infer cardinality per ``§6.1``.""" + to_unique = _columns_match_any_key(to_columns, to_dataset) + from_unique = _columns_match_any_key(from_columns, from_dataset) + if to_unique and from_unique: + return Cardinality.ONE_TO_ONE + if to_unique: + return Cardinality.N_TO_ONE + return Cardinality.N_TO_N + + +def _columns_match_any_key(columns: tuple[Identifier, ...], dataset: Dataset) -> bool: + """Return ``True`` if ``columns`` match the PK or any UK of ``dataset``.""" + target = frozenset(columns) + if target and target == frozenset(dataset.primary_key): + return True + for uk in dataset.unique_keys: + if target == frozenset(uk): + return True + return False + + +__all__ = [ + "Cardinality", + "RelationshipEdge", + "RelationshipGraph", + "build_graph", +] diff --git a/impl/python/src/osi/parsing/models.py b/impl/python/src/osi/parsing/models.py new file mode 100644 index 0000000..6c0f4a6 --- /dev/null +++ b/impl/python/src/osi/parsing/models.py @@ -0,0 +1,500 @@ +"""Pydantic schemas for the Foundation semantic model. + +Every model uses ``model_config = {"extra": "forbid"}`` — unknown fields +are a hard parse error (``E1001``). This is how we keep the Foundation +thin: new concepts must be added intentionally, not by accident. + +Expression strings are parsed into frozen SQLGlot ASTs at validation +time so downstream code never touches raw SQL. + +Deferred-feature detection lives in :mod:`osi.parsing.deferred` — these +schemas describe only the shapes the Foundation accepts; anything else +produces ``E1001`` / ``E1002`` / ``E1004`` via pydantic, or +``E_DEFERRED_KEY_REJECTED`` via the deferred-feature visitor. +""" + +from __future__ import annotations + +import re +from enum import StrEnum +from typing import Annotated, Any, ClassVar, Final, Iterable, Optional + +import sqlglot +from pydantic import BaseModel, ConfigDict +from pydantic import Field as PydField +from pydantic import StringConstraints, field_validator, model_validator + +from osi.common.identifiers import Identifier, is_valid_identifier, normalize_identifier +from osi.common.sql_expr import FrozenSQL, parse_sql_expr +from osi.common.types import Dialect +from osi.errors import ErrorCode, OSIParseError + +# --------------------------------------------------------------------------- +# Primitives +# --------------------------------------------------------------------------- + +NonEmptyStr = Annotated[str, StringConstraints(min_length=1)] + + +def _validate_identifier(value: object) -> Identifier: + """Validate + normalize a user-supplied identifier.""" + if not isinstance(value, str) or not value: + raise OSIParseError( + ErrorCode.E1004_TYPE_MISMATCH, + "identifier must be a non-empty string", + context={"value": value}, + ) + if not is_valid_identifier(value): + raise OSIParseError( + ErrorCode.E1005_IDENTIFIER_INVALID, + f"{value!r} is not a valid OSI identifier", + context={"value": value}, + ) + return normalize_identifier(value) + + +def _parse_expression(source: object, *, kind: str) -> FrozenSQL: + """Parse a scalar / aggregate SQL expression into a frozen AST.""" + if not isinstance(source, str) or not source.strip(): + raise OSIParseError( + ErrorCode.E1004_TYPE_MISMATCH, + f"{kind} expression must be a non-empty string", + context={"value": source}, + ) + _check_pre_parse_window_rules(source, kind=kind) + try: + expr = parse_sql_expr(source) + except OSIParseError: + raise + except Exception as exc: # pragma: no cover — SQLGlot internals + raise OSIParseError( + ErrorCode.E1001_YAML_SYNTAX, + f"could not parse {kind} expression: {exc}", + context={"expression": source}, + ) from exc + return FrozenSQL.of(expr) + + +# S-12: pre-parse window-frame rejection. SQLGlot does not parse +# ``GROUPS`` frame clauses (no SQL dialect we ship implements them), +# so we have to detect the keyword in the raw expression *before* +# handing it to sqlglot — otherwise the user sees the SQLGlot +# parser-error wrapped in ``E1001_YAML_SYNTAX`` instead of the +# named Foundation ``E_DEFERRED_FRAME_MODE`` code. +_GROUPS_FRAME_PATTERN = re.compile(r"\bGROUPS\s+BETWEEN\b", re.IGNORECASE) + + +def _check_pre_parse_window_rules(source: str, *, kind: str) -> None: + if _GROUPS_FRAME_PATTERN.search(source): + raise OSIParseError( + ErrorCode.E_DEFERRED_FRAME_MODE, + ( + f"{kind} expression uses ``GROUPS`` frame mode which is " + "deferred from Foundation v0.1 (D-032 — only literal " + "ROWS / RANGE frames are accepted)" + ), + context={"expression": source, "reason": "frame mode 'GROUPS'"}, + ) + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class DatasetRole(StrEnum): + """Optional dataset role hint — per ``Proposed_OSI_Semantics.md §4.2``. + + Diagnostic only: does not change planning. The planner discovers + bridges from cardinality alone. Authors MAY tag a dataset's role + so that ``osi describe`` and error messages can reference it. + """ + + FACT = "fact" + DIMENSION = "dimension" + BRIDGE = "bridge" + + +class FieldRole(StrEnum): + """Field role — per ``Proposed_OSI_Semantics.md §4.3``.""" + + DIMENSION = "dimension" + FACT = "fact" + TIME_DIMENSION = "time_dimension" + + +# Backwards-compat aliases for YAML inputs that follow the historical +# upper-case spelling for dialect names. The canonical form is +# :class:`osi.common.types.Dialect`; this map is consulted by the +# ``SemanticModel.dialect`` field validator below. +_DIALECT_ALIASES: dict[str, Dialect] = { + "ANSI_SQL": Dialect.ANSI, + "ANSI": Dialect.ANSI, + "DUCKDB": Dialect.DUCKDB, + "SNOWFLAKE": Dialect.SNOWFLAKE, +} + + +# --------------------------------------------------------------------------- +# Base +# --------------------------------------------------------------------------- + + +class _Strict(BaseModel): + """Base class: frozen, extra-forbidding pydantic model.""" + + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="forbid", + frozen=True, + arbitrary_types_allowed=True, # FrozenSQL + populate_by_name=True, + ) + + +# --------------------------------------------------------------------------- +# Domain models +# --------------------------------------------------------------------------- + + +class Field(_Strict): + """A dataset field — dimension, fact, or time dimension (``§4.3``).""" + + name: Identifier + expression: FrozenSQL + role: FieldRole = FieldRole.DIMENSION + data_type: Optional[NonEmptyStr] = None + description: Optional[str] = None + + @field_validator("name", mode="before") + @classmethod + def _normalize_name(cls, value: object) -> Identifier: + return _validate_identifier(str(value)) + + @field_validator("expression", mode="before") + @classmethod + def _parse_expression(cls, value: object) -> FrozenSQL: + if isinstance(value, FrozenSQL): + return value + return _parse_expression(str(value), kind="field") + + +class Metric(_Strict): + """A metric — aggregate expression (``§4.5``). + + Per-metric ``joins`` (``joins.type`` / ``joins.using_relationships``) + are part of the full spec (``§6.7``) but deferred from + Foundation v0.1 (D-018 / §10). The YAML key is rejected up front by + :mod:`osi.parsing.deferred`; the field is absent from the model so + programmatic construction can't reintroduce it either. + """ + + name: Identifier + expression: FrozenSQL + description: Optional[str] = None + + @field_validator("name", mode="before") + @classmethod + def _normalize_name(cls, value: object) -> Identifier: + return _validate_identifier(str(value)) + + @field_validator("expression", mode="before") + @classmethod + def _parse_expression(cls, value: object) -> FrozenSQL: + if isinstance(value, FrozenSQL): + return value + return _parse_expression(str(value), kind="metric") + + +class Dataset(_Strict): + """A logical dataset (``§4.2``).""" + + name: Identifier + source: NonEmptyStr + primary_key: tuple[Identifier, ...] = () + unique_keys: tuple[tuple[Identifier, ...], ...] = () + fields: tuple[Field, ...] = () + metrics: tuple[Metric, ...] = () + description: Optional[str] = None + role: Optional[DatasetRole] = None + + @field_validator("name", mode="before") + @classmethod + def _normalize_name(cls, value: object) -> Identifier: + return _validate_identifier(str(value)) + + @field_validator("primary_key", mode="before") + @classmethod + def _normalize_pk(cls, value: object) -> tuple[Identifier, ...]: + return _coerce_key_tuple(value, field_name="primary_key") + + @field_validator("unique_keys", mode="before") + @classmethod + def _normalize_uks(cls, value: object) -> tuple[tuple[Identifier, ...], ...]: + if value is None: + return () + if not isinstance(value, (list, tuple)): + raise OSIParseError( + ErrorCode.E1004_TYPE_MISMATCH, + "unique_keys must be a list of lists", + context={"value": value}, + ) + return tuple( + _coerce_key_tuple(uk, field_name="unique_keys entry") for uk in value + ) + + @model_validator(mode="after") + def _check_field_uniqueness(self) -> "Dataset": + seen: set[Identifier] = set() + for f in self.fields: + if f.name in seen: + raise OSIParseError( + ErrorCode.E2003_DUPLICATE_NAME, + f"dataset {self.name!r} declares field {f.name!r} twice", + context={"dataset": self.name, "field": f.name}, + ) + seen.add(f.name) + metric_seen: set[Identifier] = set() + for m in self.metrics: + if m.name in metric_seen or m.name in seen: + raise OSIParseError( + ErrorCode.E2003_DUPLICATE_NAME, + f"dataset {self.name!r} name {m.name!r} declared twice", + context={"dataset": self.name, "name": m.name}, + ) + metric_seen.add(m.name) + return self + + +class Relationship(_Strict): + """An equijoin relationship (``§4.4``).""" + + name: Identifier + from_dataset: Identifier = PydField(alias="from") + to_dataset: Identifier = PydField(alias="to") + from_columns: tuple[Identifier, ...] + to_columns: tuple[Identifier, ...] + description: Optional[str] = None + # NOTE: ``referential_integrity`` (``§4.4``) is part of the full + # spec but deferred from Foundation v0.1. The YAML key is rejected + # by :mod:`osi.parsing.deferred`; the model has no field so + # programmatic construction can't reintroduce it. + + @field_validator("name", "from_dataset", "to_dataset", mode="before") + @classmethod + def _normalize_identifier(cls, value: object) -> Identifier: + return _validate_identifier(str(value)) + + @field_validator("from_columns", "to_columns", mode="before") + @classmethod + def _normalize_columns(cls, value: object) -> tuple[Identifier, ...]: + return _coerce_key_tuple(value, field_name="join columns") + + @model_validator(mode="after") + def _check_arity(self) -> "Relationship": + if len(self.from_columns) != len(self.to_columns): + raise OSIParseError( + ErrorCode.E2006_INVALID_RELATIONSHIP, + ( + f"relationship {self.name!r}: from_columns and to_columns " + "must have the same length" + ), + context={ + "name": self.name, + "from_columns": list(self.from_columns), + "to_columns": list(self.to_columns), + }, + ) + if not self.from_columns: + raise OSIParseError( + ErrorCode.E2006_INVALID_RELATIONSHIP, + f"relationship {self.name!r} has no join columns", + context={"name": self.name}, + ) + return self + + +class NamedFilter(_Strict): + """Reusable boolean filter (``§4.6``).""" + + name: Identifier + expression: FrozenSQL + description: Optional[str] = None + + @field_validator("name", mode="before") + @classmethod + def _normalize_name(cls, value: object) -> Identifier: + return _validate_identifier(str(value)) + + @field_validator("expression", mode="before") + @classmethod + def _parse_expression(cls, value: object) -> FrozenSQL: + if isinstance(value, FrozenSQL): + return value + return _parse_expression(str(value), kind="filter") + + +class Parameter(_Strict): + """Typed query-time parameter with a default.""" + + name: Identifier + data_type: NonEmptyStr + default: Any = None + description: Optional[str] = None + + @field_validator("name", mode="before") + @classmethod + def _normalize_name(cls, value: object) -> Identifier: + return _validate_identifier(str(value)) + + +SUPPORTED_OSI_VERSIONS: Final[frozenset[str]] = frozenset({"0.1"}) +"""Spec versions this Foundation reference implementation accepts. + +Per ``Proposed_OSI_Semantics.md`` §opening, a semantic model MAY +declare ``osi_version: "0.1"`` at the model root. A model that omits +the key is interpreted under the latest supported version. Future +``0.x`` revisions remain additively compatible; this set grows when +those revisions land. +""" + + +class SemanticModel(_Strict): + """Top-level semantic model (``§4.1``). + + The optional ``osi_version`` field carries the spec version the + author wrote against. The Foundation rules out is the + intersection of every supported version, so a ``0.1`` model + keeps working against a ``0.2`` engine. + """ + + name: Identifier + osi_version: Optional[str] = None + dialect: Dialect = Dialect.OSI_SQL_2026 + datasets: tuple[Dataset, ...] + relationships: tuple[Relationship, ...] = () + metrics: tuple[Metric, ...] = () + filters: tuple[NamedFilter, ...] = () + parameters: tuple[Parameter, ...] = () + description: Optional[str] = None + + @field_validator("name", mode="before") + @classmethod + def _normalize_name(cls, value: object) -> Identifier: + return _validate_identifier(str(value)) + + @field_validator("osi_version", mode="before") + @classmethod + def _validate_osi_version(cls, value: object) -> object: + """Accept the documented spec versions, reject everything else. + + The spec keeps ``osi_version`` optional (the engine assumes + latest when omitted) so ``None`` is accepted. A declared + value must be a string in :data:`SUPPORTED_OSI_VERSIONS`; an + unsupported version raises ``E1003_INVALID_ENUM_VALUE`` with + the list of supported versions in ``error.context`` so + adopters know what to write. + """ + if value is None: + return None + if not isinstance(value, str): + raise OSIParseError( + ErrorCode.E1004_TYPE_MISMATCH, + f"osi_version must be a string, got {type(value).__name__}", + context={"value": value}, + ) + if value not in SUPPORTED_OSI_VERSIONS: + raise OSIParseError( + ErrorCode.E1003_INVALID_ENUM_VALUE, + ( + f"osi_version {value!r} is not supported by this engine; " + f"supported versions are {sorted(SUPPORTED_OSI_VERSIONS)}" + ), + context={ + "value": value, + "supported": sorted(SUPPORTED_OSI_VERSIONS), + }, + ) + return value + + @field_validator("dialect", mode="before") + @classmethod + def _normalize_dialect(cls, value: object) -> object: + """Accept SPEC upper-case spellings as well as canonical values. + + ``ANSI_SQL`` and ``ANSI`` both map to :attr:`Dialect.ANSI`; same + for the per-dialect spellings in :data:`_DIALECT_ALIASES`. + """ + if isinstance(value, Dialect): + return value + if isinstance(value, str): + alias = _DIALECT_ALIASES.get(value) + if alias is not None: + return alias + return value + + @field_validator("datasets", mode="before") + @classmethod + def _datasets_nonempty(cls, value: object) -> object: + if not value: + raise OSIParseError( + ErrorCode.E1002_MISSING_REQUIRED_FIELD, + "semantic_model must declare at least one dataset", + ) + return value + + @model_validator(mode="after") + def _check_global_uniqueness(self) -> "SemanticModel": + _require_unique("dataset", (d.name for d in self.datasets)) + _require_unique("relationship", (r.name for r in self.relationships)) + _require_unique("metric", (m.name for m in self.metrics)) + _require_unique("filter", (f.name for f in self.filters)) + _require_unique("parameter", (p.name for p in self.parameters)) + return self + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _coerce_key_tuple(value: object, *, field_name: str) -> tuple[Identifier, ...]: + """Accept list / tuple of strings; normalize each to an Identifier.""" + if value is None: + return () + if not isinstance(value, (list, tuple)): + raise OSIParseError( + ErrorCode.E1004_TYPE_MISMATCH, + f"{field_name} must be a list of identifiers", + context={"value": value}, + ) + return tuple(_validate_identifier(str(item)) for item in value) + + +def _require_unique(kind: str, names: Iterable[Identifier]) -> None: + seen: set[Identifier] = set() + for n in names: + if n in seen: + raise OSIParseError( + ErrorCode.E2003_DUPLICATE_NAME, + f"{kind} {n!r} declared twice at the model scope", + context={"kind": kind, "name": n}, + ) + seen.add(n) + + +# Keep sqlglot import here so removing helper `_parse_expression` never +# silently drops it from linter scans. +_ = sqlglot # noqa: F841 + + +__all__ = [ + "Dataset", + "Dialect", + "Field", + "FieldRole", + "Metric", + "NamedFilter", + "Parameter", + "Relationship", + "SemanticModel", +] diff --git a/impl/python/src/osi/parsing/namespace.py b/impl/python/src/osi/parsing/namespace.py new file mode 100644 index 0000000..bb8f9ff --- /dev/null +++ b/impl/python/src/osi/parsing/namespace.py @@ -0,0 +1,186 @@ +"""Name resolution over a :class:`SemanticModel`. + +The :class:`Namespace` is a read-only index built once by +:func:`build_namespace` and consulted by the planner and diagnostics. +It never mutates and never parses SQL — it only knows what names exist +where. + +Scopes per ``Proposed_OSI_Semantics.md §4.7``: + +* **Global** — datasets, relationships, model-level metrics, named + filters, parameters. +* **Dataset** — fields + table-scoped metrics inside a dataset. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Iterable, Mapping + +from osi.common.identifiers import Identifier +from osi.errors import ErrorCode, OSIParseError +from osi.parsing.models import ( + Dataset, + Field, + Metric, + NamedFilter, + Parameter, + SemanticModel, +) + + +def _freeze(mapping: dict[Identifier, object]) -> Mapping[Identifier, object]: + """Return a read-only view over ``mapping``. + + Keeps :class:`Namespace` honest about its + "constructed once, never mutated" contract. Pydantic frozen + dataclasses freeze identity but not the contents of nested + ``dict`` fields, so we wrap them. + """ + return MappingProxyType(mapping) + + +@dataclass(frozen=True, slots=True) +class DatasetNamespace: + """Fields + table-scoped metrics visible inside one dataset.""" + + dataset: Identifier + fields: Mapping[Identifier, Field] + metrics: Mapping[Identifier, Metric] + + def get_field(self, name: Identifier) -> Field: + """Look up a field by normalized name. Raises ``E2002``.""" + try: + return self.fields[name] + except KeyError as exc: + raise OSIParseError( + ErrorCode.E2002_NAME_NOT_FOUND, + f"field {name!r} not found in dataset {self.dataset!r}", + context={"dataset": self.dataset, "name": name}, + ) from exc + + +@dataclass(frozen=True, slots=True) +class Namespace: + """Read-only global index over a ``SemanticModel``.""" + + datasets: Mapping[Identifier, DatasetNamespace] + metrics: Mapping[Identifier, Metric] + filters: Mapping[Identifier, NamedFilter] + parameters: Mapping[Identifier, Parameter] + relationships: Mapping[Identifier, object] + _by_short: Mapping[Identifier, tuple[Identifier, ...]] = field( + default_factory=lambda: MappingProxyType({}) + ) + + def get_dataset(self, name: Identifier) -> DatasetNamespace: + """Look up a dataset namespace. Raises ``E2002``.""" + try: + return self.datasets[name] + except KeyError as exc: + raise OSIParseError( + ErrorCode.E2002_NAME_NOT_FOUND, + f"dataset {name!r} not declared", + context={"name": name}, + ) from exc + + def resolve_qualified( + self, dataset_name: Identifier, field_name: Identifier + ) -> Field: + """Resolve a qualified ``dataset.field`` reference.""" + return self.get_dataset(dataset_name).get_field(field_name) + + def resolve_bare(self, short_name: Identifier) -> Identifier: + """Resolve a bare field name. + + Returns the *dataset name* that owns the field. Raises + ``E2001_AMBIGUOUS_NAME`` if multiple datasets share the name, + ``E2002_NAME_NOT_FOUND`` if none does. + """ + owners = self._by_short.get(short_name, ()) + if not owners: + raise OSIParseError( + ErrorCode.E2002_NAME_NOT_FOUND, + f"name {short_name!r} does not resolve to any field", + context={"name": short_name}, + ) + if len(owners) > 1: + raise OSIParseError( + ErrorCode.E2001_AMBIGUOUS_NAME, + ( + f"bare name {short_name!r} is ambiguous — belongs to " + f"{sorted(owners)}" + ), + context={"name": short_name, "owners": sorted(owners)}, + ) + return owners[0] + + +def build_namespace(model: SemanticModel) -> Namespace: + """Build a :class:`Namespace` from a validated model. + + Duplicates at any scope are already caught by pydantic; this + function just indexes what's there. Both fields *and* table-scoped + metrics are added to the bare-name index so resolving a bare + measure (e.g. ``total_revenue``) works through the same code path + as resolving a bare field. + """ + datasets: dict[Identifier, DatasetNamespace] = {} + by_short: dict[Identifier, list[Identifier]] = {} + for ds in model.datasets: + datasets[ds.name] = _build_dataset_namespace(ds) + for f in ds.fields: + by_short.setdefault(f.name, []).append(ds.name) + for m in ds.metrics: + by_short.setdefault(m.name, []).append(ds.name) + _assert_global_unique(model.metrics, kind="metric") + _assert_global_unique(model.filters, kind="filter") + _assert_global_unique(model.parameters, kind="parameter") + by_short_frozen: dict[Identifier, tuple[Identifier, ...]] = { + name: tuple(owners) for name, owners in by_short.items() + } + return Namespace( + datasets=MappingProxyType(datasets), + metrics=MappingProxyType({m.name: m for m in model.metrics}), + filters=MappingProxyType({f.name: f for f in model.filters}), + parameters=MappingProxyType({p.name: p for p in model.parameters}), + relationships=MappingProxyType({r.name: r for r in model.relationships}), + _by_short=MappingProxyType(by_short_frozen), + ) + + +def _build_dataset_namespace(dataset: Dataset) -> DatasetNamespace: + fields = {f.name: f for f in dataset.fields} + metrics = {m.name: m for m in dataset.metrics} + overlap = set(fields) & set(metrics) + if overlap: + raise OSIParseError( + ErrorCode.E2003_DUPLICATE_NAME, + ( + f"dataset {dataset.name!r}: names {sorted(overlap)} are " + "used for both a field and a table-scoped metric" + ), + context={"dataset": dataset.name, "names": sorted(overlap)}, + ) + return DatasetNamespace( + dataset=dataset.name, + fields=MappingProxyType(fields), + metrics=MappingProxyType(metrics), + ) + + +def _assert_global_unique(items: Iterable[object], *, kind: str) -> None: + seen: set[Identifier] = set() + for item in items: + name: Identifier = item.name # type: ignore[attr-defined] + if name in seen: + raise OSIParseError( + ErrorCode.E2003_DUPLICATE_NAME, + f"{kind} {name!r} declared twice at the model scope", + context={"kind": kind, "name": name}, + ) + seen.add(name) + + +__all__ = ["DatasetNamespace", "Namespace", "build_namespace"] diff --git a/impl/python/src/osi/parsing/parser.py b/impl/python/src/osi/parsing/parser.py new file mode 100644 index 0000000..d8ede06 --- /dev/null +++ b/impl/python/src/osi/parsing/parser.py @@ -0,0 +1,209 @@ +"""Top-level parse entry point. + +``parse_semantic_model(source)`` takes either a filesystem path or a raw +YAML string and returns a tuple ``(model, namespace, graph)``. + +Pipeline: + +1. YAML load (syntax error → ``E1001_YAML_SYNTAX``). +2. Root normalization — accept ``{semantic_model: [{...}]}`` or a bare + model mapping. +3. Deferred-feature screen — reject YAML keys reserved for deferred + proposals (``E_DEFERRED_KEY_REJECTED``). Done *before* pydantic + so we can give a friendlier error than "extra field forbidden". +4. Pydantic schema validation (``E1001`` / ``E1002`` / ``E1004``). +5. Deferred-feature expression screen — walk every parsed SQL AST and + reject window / pivot / grouping-set constructs with + ``E_DEFERRED_KEY_REJECTED`` or the more specific code that + applies (e.g. ``E_DEFERRED_FRAME_MODE`` for ``GROUPS`` frames). +6. Cross-reference validation (``E2xxx``). +7. Foundation-strictness screen — reject deferred constructs not + enabled in the caller's :class:`~osi.config.FoundationFlags` + (D-003 / D-027 / per-dataset metrics). +8. Build namespace + relationship graph. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml +from pydantic import ValidationError + +from osi.config import FoundationFlags +from osi.errors import ErrorCode, OSIParseError +from osi.parsing._root import unwrap_model_root +from osi.parsing.deferred import check_expression_deferred, check_yaml_deferred +from osi.parsing.foundation import check_foundation_strictness +from osi.parsing.function_whitelist import check_expression_functions +from osi.parsing.graph import RelationshipGraph, build_graph +from osi.parsing.models import Field, Metric, NamedFilter, SemanticModel +from osi.parsing.namespace import Namespace, build_namespace +from osi.parsing.validation import validate_model + + +@dataclass(frozen=True, slots=True) +class ParseResult: + """Bundle returned by :func:`parse_semantic_model`. + + The ``flags`` field captures the :class:`FoundationFlags` + instance the parser was invoked with so downstream callers + (notably :class:`~osi.planning.planner_context.PlannerContext`) + can plan queries under the same opt-ins the model was admitted + under. The default is the strict Foundation + (:meth:`FoundationFlags.strict`). + """ + + model: SemanticModel + namespace: Namespace + graph: RelationshipGraph + flags: FoundationFlags + + +def parse_semantic_model( + source: str | Path, + *, + flags: FoundationFlags | None = None, +) -> ParseResult: + """Parse and fully validate a YAML semantic model. + + ``source`` is a filesystem path (``Path``) or a raw YAML string. + ``flags`` controls which deferred Foundation v0.1 constructs the + parser tolerates; ``None`` (the default) uses the strict + Foundation defaults — every flag off — which matches + ``Proposed_OSI_Semantics.md`` as currently published. Pass an + explicit :class:`~osi.config.FoundationFlags` instance to opt + back into legacy behaviour for one or more deferred features. + + Returns a :class:`ParseResult` with the frozen model, namespace, and + relationship graph. Raises :class:`OSIParseError` with a stable + ``code`` on any failure. + """ + if flags is None: + flags = FoundationFlags() + document = _load_yaml(source) + root = unwrap_model_root(document) + check_yaml_deferred(document) + model = _build_model(root) + # S-12: validation runs *before* the per-expression deferred AST + # check so windowed-metric-composition can fire its named code + # ``E_WINDOWED_METRIC_COMPOSITION`` before the blanket + # ``E_DEFERRED_KEY_REJECTED`` swallows it. + validate_model(model) + _check_all_expression_asts(model) + check_foundation_strictness(model, flags) + namespace = build_namespace(model) + graph = build_graph(model) + return ParseResult(model=model, namespace=namespace, graph=graph, flags=flags) + + +# --------------------------------------------------------------------------- +# YAML loading +# --------------------------------------------------------------------------- + + +def _load_yaml(source: str | Path) -> Any: + if isinstance(source, Path): + try: + text = source.read_text(encoding="utf-8") + except OSError as exc: + raise OSIParseError( + ErrorCode.E1001_YAML_SYNTAX, + f"could not read YAML file {source}: {exc}", + context={"path": str(source)}, + ) from exc + else: + text = source + try: + return yaml.safe_load(text) + except yaml.YAMLError as exc: + raise OSIParseError( + ErrorCode.E1001_YAML_SYNTAX, + f"invalid YAML: {exc}", + context={}, + ) from exc + + +# --------------------------------------------------------------------------- +# Pydantic +# --------------------------------------------------------------------------- + + +def _build_model(root: dict[str, Any]) -> SemanticModel: + try: + return SemanticModel.model_validate(root) + except OSIParseError: + raise + except ValidationError as exc: + raise _translate_validation_error(exc) from exc + + +def _translate_validation_error(err: ValidationError) -> OSIParseError: + """Map a pydantic ``ValidationError`` into a typed :class:`OSIParseError`. + + Pydantic reports multiple issues; we surface the first one with a + best-fit :class:`ErrorCode` based on the pydantic error type. Callers + who want the full list can introspect ``err`` on the ``__cause__``. + """ + raw: list[dict[str, Any]] = [dict(e) for e in err.errors()] + first: dict[str, Any] = raw[0] if raw else {} + err_type = str(first.get("type", "")) + loc = ".".join(str(p) for p in first.get("loc", ())) + message = str(first.get("msg", "schema validation failed")) + if err_type == "extra_forbidden": + code = ErrorCode.E1001_YAML_SYNTAX + elif err_type in {"missing", "none_not_allowed"}: + code = ErrorCode.E1002_MISSING_REQUIRED_FIELD + elif err_type.startswith("enum"): + code = ErrorCode.E1003_INVALID_ENUM_VALUE + else: + code = ErrorCode.E1004_TYPE_MISMATCH + return OSIParseError( + code, + f"{loc}: {message}" if loc else message, + context={ + "pydantic_type": err_type, + "location": loc, + "errors": raw, + }, + ) + + +# --------------------------------------------------------------------------- +# Post-pydantic AST screen +# --------------------------------------------------------------------------- + + +def _check_all_expression_asts(model: SemanticModel) -> None: + for ds in model.datasets: + for f in ds.fields: + _check_field_expression(f, dataset_name=str(ds.name)) + for m in ds.metrics: + _check_metric_expression(m, scope=f"{ds.name}") + for m in model.metrics: + _check_metric_expression(m, scope="model") + for named_filter in model.filters: + _check_filter_expression(named_filter) + + +def _check_field_expression(field: Field, *, dataset_name: str) -> None: + where = f"field {dataset_name}.{field.name}" + check_expression_deferred(field.expression, where=where) + check_expression_functions(field.expression, where=where) + + +def _check_metric_expression(metric: Metric, *, scope: str) -> None: + where = f"metric {scope}.{metric.name}" + check_expression_deferred(metric.expression, where=where) + check_expression_functions(metric.expression, where=where) + + +def _check_filter_expression(named_filter: NamedFilter) -> None: + where = f"filter {named_filter.name}" + check_expression_deferred(named_filter.expression, where=where) + check_expression_functions(named_filter.expression, where=where) + + +__all__ = ["ParseResult", "parse_semantic_model"] diff --git a/impl/python/src/osi/parsing/reserved_names.py b/impl/python/src/osi/parsing/reserved_names.py new file mode 100644 index 0000000..14d99ce --- /dev/null +++ b/impl/python/src/osi/parsing/reserved_names.py @@ -0,0 +1,39 @@ +"""OSI-grammar reserved names (D-019). + +A small, deliberately minimal policy module. The Foundation reserves +the names ``GRAIN``, ``FILTER``, ``QUERY_FILTER`` so they can be used +as OSI grammar keywords without colliding with user identifiers. + +This is a separate concern from: + +* :mod:`osi.common.identifiers` ``_RESERVED`` — internal sentinels + (``__grain__`` etc.) that the algebra layer reserves; those raise + ``E_RESERVED_IDENTIFIER`` at identifier-construction time. +* SQL keywords like ``SELECT`` — those are the responsibility of + the dialect / OSI_SQL_2026 catalog (D-021), not of D-019. + +D-019 is checked at parse time over the built ``SemanticModel`` +(every dataset, field, metric, and relationship name) so a model +that uses any of these as a user identifier is rejected with +``E_RESERVED_NAME``. +""" + +from __future__ import annotations + +from typing import Final + +OSI_RESERVED_NAMES: Final[frozenset[str]] = frozenset( + { + "grain", + "filter", + "query_filter", + } +) + + +def is_osi_reserved_name(name: str) -> bool: + """Return whether ``name`` (case-insensitively) is OSI-reserved.""" + return name.lower() in OSI_RESERVED_NAMES + + +__all__ = ["OSI_RESERVED_NAMES", "is_osi_reserved_name"] diff --git a/impl/python/src/osi/parsing/validation.py b/impl/python/src/osi/parsing/validation.py new file mode 100644 index 0000000..d61f4da --- /dev/null +++ b/impl/python/src/osi/parsing/validation.py @@ -0,0 +1,319 @@ +"""Cross-reference validation over a built ``SemanticModel``. + +Pydantic handles *shape* validation; this module handles the *semantic* +checks that span datasets / relationships / metrics: + +* relationships point at real datasets and real columns +* metric expressions reference real fields (best-effort AST walk — full + resolution happens in the planner) +* model-scoped derived metrics do not form cycles +* datasets used on the one-side of an N:1 relationship declare a PK + +All errors are :class:`OSIParseError` in the ``E2xxx`` range; messages +cite the offending name. +""" + +from __future__ import annotations + +from typing import Iterable, Mapping + +from sqlglot import expressions as exp + +from osi.common.identifiers import Identifier +from osi.common.windows import contains_window, is_windowed_expression +from osi.errors import ErrorCode, OSIParseError +from osi.parsing.models import Metric, Relationship, SemanticModel +from osi.parsing.reserved_names import OSI_RESERVED_NAMES + + +def validate_model(model: SemanticModel) -> None: + """Run every cross-reference check. Raises on the first failure.""" + datasets_by_name = {ds.name: ds for ds in model.datasets} + _validate_no_osi_reserved_names(model) + _validate_relationships(model.relationships, datasets_by_name) + _validate_metric_references(model, datasets_by_name) + _validate_metric_cycles(model.metrics) + _validate_no_windowed_metric_composition(model, datasets_by_name) + + +# --------------------------------------------------------------------------- +# Reserved-name guard (D-019) +# --------------------------------------------------------------------------- + + +def _reject_reserved_name(*, kind: str, name: str, owner: str | None = None) -> None: + """Raise ``E_RESERVED_NAME`` if ``name`` collides with an OSI keyword.""" + if name.lower() in OSI_RESERVED_NAMES: + owner_clause = f" on {owner!r}" if owner else "" + raise OSIParseError( + ErrorCode.E_RESERVED_NAME, + ( + f"{kind} name {name!r}{owner_clause} collides with the " + "OSI grammar reserved keyword set " + f"({sorted(OSI_RESERVED_NAMES)}); pick a different name " + "(D-019)." + ), + context={"kind": kind, "name": name, "owner": owner}, + ) + + +def _validate_no_osi_reserved_names(model: SemanticModel) -> None: + """D-019: no user identifier may equal an OSI grammar keyword. + + Walks every dataset, field, model-scope metric, dataset-scope + metric, and relationship; the first collision raises + ``E_RESERVED_NAME`` with full owner context. + """ + for ds in model.datasets: + _reject_reserved_name(kind="dataset", name=str(ds.name)) + for field in ds.fields: + _reject_reserved_name( + kind="field", name=str(field.name), owner=str(ds.name) + ) + for ds_metric in ds.metrics: + _reject_reserved_name( + kind="metric", + name=str(ds_metric.name), + owner=str(ds.name), + ) + for metric in model.metrics: + _reject_reserved_name(kind="metric", name=str(metric.name)) + for rel in model.relationships: + _reject_reserved_name(kind="relationship", name=str(rel.name)) + + +# --------------------------------------------------------------------------- +# Relationships +# --------------------------------------------------------------------------- + + +def _validate_relationships( + relationships: Iterable[Relationship], + datasets_by_name: Mapping[Identifier, object], +) -> None: + for rel in relationships: + _check_dataset_exists(rel, "from_dataset", rel.from_dataset, datasets_by_name) + _check_dataset_exists(rel, "to_dataset", rel.to_dataset, datasets_by_name) + _check_columns_exist(rel, side="from", datasets_by_name=datasets_by_name) + _check_columns_exist(rel, side="to", datasets_by_name=datasets_by_name) + + +def _check_dataset_exists( + rel: Relationship, + label: str, + name: Identifier, + datasets_by_name: Mapping[Identifier, object], +) -> None: + if name not in datasets_by_name: + raise OSIParseError( + ErrorCode.E2006_INVALID_RELATIONSHIP, + ( + f"relationship {rel.name!r}: {label} {name!r} does not " + "match any declared dataset" + ), + context={"relationship": rel.name, label: name}, + ) + + +def _check_columns_exist( + rel: Relationship, + *, + side: str, + datasets_by_name: Mapping[Identifier, object], +) -> None: + dataset_name = rel.from_dataset if side == "from" else rel.to_dataset + columns = rel.from_columns if side == "from" else rel.to_columns + ds = datasets_by_name[dataset_name] + field_names = {f.name for f in ds.fields} # type: ignore[attr-defined] + for col in columns: + if col not in field_names: + raise OSIParseError( + ErrorCode.E2006_INVALID_RELATIONSHIP, + ( + f"relationship {rel.name!r}: {side}_columns reference " + f"{col!r} which is not a field of dataset " + f"{dataset_name!r}" + ), + context={ + "relationship": rel.name, + "side": side, + "column": col, + "dataset": dataset_name, + }, + ) + + +# --------------------------------------------------------------------------- +# Metrics +# --------------------------------------------------------------------------- + + +def _validate_metric_references( + model: SemanticModel, + datasets_by_name: Mapping[Identifier, object], +) -> None: + """Light-touch reference check on metric expressions. + + The planner does full resolution; here we only reject obvious + typos where a metric cites a nonexistent dataset. Bare-column + references and metric-to-metric references stay tolerable because + resolving them requires the full namespace (Phase 3). + """ + known_datasets = set(datasets_by_name.keys()) + for metric in model.metrics: + for column in metric.expression.expr.find_all(exp.Column): + table = column.table + if not table: + continue + from osi.common.identifiers import normalize_identifier + + try: + normalized = normalize_identifier(table) + except OSIParseError: + continue # quoted / exotic identifier — defer to planner + if normalized not in known_datasets: + raise OSIParseError( + ErrorCode.E2002_NAME_NOT_FOUND, + ( + f"metric {metric.name!r} references unknown dataset " + f"{table!r}" + ), + context={"metric": metric.name, "dataset": table}, + ) + + +def _validate_no_windowed_metric_composition( + model: SemanticModel, + datasets_by_name: Mapping[Identifier, object], +) -> None: + """D-031: composing a metric on top of a windowed metric is rejected. + + A windowed metric is one whose top-level expression is a window + function (``f(...) OVER (...)``). If a *different* metric's + expression references such a windowed metric, the result is a + "metric on top of a window" — the Foundation cannot guarantee a + correct grain for the outer metric, so we reject. + + The check operates on parsed-but-unresolved metric bodies; full + name resolution is the planner's job. We treat any column whose + qualified or bare name matches a known windowed metric as a + reference to it. + """ + windowed_metric_names: set[str] = set() + for metric in model.metrics: + if is_windowed_expression(metric.expression.expr): + windowed_metric_names.add(str(metric.name).lower()) + for ds in model.datasets: + for ds_metric in ds.metrics: + if is_windowed_expression(ds_metric.expression.expr): + windowed_metric_names.add(f"{ds.name}.{ds_metric.name}".lower()) + windowed_metric_names.add(str(ds_metric.name).lower()) + + if not windowed_metric_names: + return + + def _check(metric_owner: str, metric: Metric) -> None: + if is_windowed_expression(metric.expression.expr): + return + # Only metrics that themselves *combine* a windowed reference + # with anything else are rejected. A metric that is *just* a + # windowed expression is fine; we only care when the outer + # body composes (a windowed reference inside an aggregate, an + # arithmetic expression, another window, etc.). + for column in metric.expression.expr.find_all(exp.Column): + bare = (column.name or "").lower() + qualified = f"{column.table.lower()}.{bare}" if column.table else "" + if bare in windowed_metric_names or ( + qualified and qualified in windowed_metric_names + ): + target = qualified or bare + raise OSIParseError( + ErrorCode.E_WINDOWED_METRIC_COMPOSITION, + ( + f"metric {metric_owner!r} composes on top of " + f"windowed metric {target!r} (D-031 — composing " + "above a window function changes the grain " + "non-uniformly and is not in Foundation v0.1)" + ), + context={ + "metric": metric_owner, + "windowed_reference": target, + }, + ) + # If the composing metric itself contains a window, that's + # also rejected — we don't allow ``window(window-base + x)`` + # because the inner reference's frame is unrecoverable. + if contains_window(metric.expression.expr): + for column in metric.expression.expr.find_all(exp.Column): + bare = (column.name or "").lower() + if bare in windowed_metric_names: + raise OSIParseError( + ErrorCode.E_WINDOWED_METRIC_COMPOSITION, + ( + f"metric {metric_owner!r} contains a window " + "that references another windowed metric " + "(D-031)" + ), + context={"metric": metric_owner}, + ) + + for metric in model.metrics: + _check(str(metric.name), metric) + for ds in model.datasets: + for ds_metric in ds.metrics: + _check(f"{ds.name}.{ds_metric.name}", ds_metric) + + +def _validate_metric_cycles(metrics: tuple[Metric, ...]) -> None: + """Detect cycles among model-scoped derived metrics. + + A derived metric is any metric whose expression bare-references + another metric by name. We approximate this by collecting every + ``Column`` whose ``table`` is None and whose normalized name matches + a declared metric. + """ + if not metrics: + return + from osi.common.identifiers import normalize_identifier + + by_name = {m.name: m for m in metrics} + edges: dict[Identifier, set[Identifier]] = {m.name: set() for m in metrics} + for metric in metrics: + for column in metric.expression.expr.find_all(exp.Column): + if column.table: + continue + try: + candidate = normalize_identifier(column.name) + except OSIParseError: + continue + if candidate in by_name: + edges[metric.name].add(candidate) + _detect_cycle(edges) + + +def _detect_cycle(edges: dict[Identifier, set[Identifier]]) -> None: + WHITE, GRAY, BLACK = 0, 1, 2 + color: dict[Identifier, int] = {node: WHITE for node in edges} + + def visit(node: Identifier, stack: list[Identifier]) -> None: + color[node] = GRAY + stack.append(node) + for child in edges.get(node, ()): + if color[child] == GRAY: + cycle = stack[stack.index(child) :] + [child] + raise OSIParseError( + ErrorCode.E2005_CIRCULAR_METRIC, + ("metric composition cycle detected: " + " -> ".join(cycle)), + context={"cycle": cycle}, + ) + if color[child] == WHITE: + visit(child, stack) + stack.pop() + color[node] = BLACK + + for node in edges: + if color[node] == WHITE: + visit(node, []) + + +__all__ = ["validate_model"] diff --git a/impl/python/src/osi/planning/README.md b/impl/python/src/osi/planning/README.md new file mode 100644 index 0000000..db94d76 --- /dev/null +++ b/impl/python/src/osi/planning/README.md @@ -0,0 +1,86 @@ +# `osi.planning` — Layer 2 + +Takes a `SemanticModel` and a `SemanticQuery`, produces a frozen `QueryPlan`. + +``` +Layer 1: parsing/ YAML ──────────────▶ SemanticModel + Namespace + RelationshipGraph + (immutable, dependency-free) + +Layer 2: planning/ + SemanticQuery ───▶ QueryPlan ← YOU ARE HERE + (tuple[PlanStep], each step holds an + immutable CalculationState) + +Layer 3: codegen/ + dialect ─────────▶ SQL string +``` + +**Contract.** + +1. The planner never generates SQL and never mutates the semantic model. +2. The planner is a *composer of algebra operators*. Every transformation + is a call to an operator in `algebra/operations.py`. Nothing else + may construct a `CalculationState`. +3. Same `(model, query)` ⇒ same `QueryPlan`, byte-identical. + +## Module map + +### Core IR + entry point + +- `algebra/` — the nine operators, the state, grain-safety guards. The + load-bearing module; see + [`../../../../docs/JOIN_ALGEBRA.md`](../../../../docs/JOIN_ALGEBRA.md). +- `plan.py` — `QueryPlan`, `PlanStep`, `PlanOperation` enum, `PlanPayload` + hierarchy. +- `planner_context.py` — frozen bundle of model + namespace + graph + plus the `FoundationFlags` opt-ins (e.g. `experimental_exists_in`). +- `planner.py` — the single `Planner` class; aggregation-query composer. +- `semantic_query.py` — `SemanticQuery` value type and its + parameter/named-filter binding helpers (see `preprocess.py`). + +### Phase helpers — consumed in order by `planner.py` + +- `preprocess.py` — query-level rewrites that run before the planner + proper (parameter binding, named-filter expansion). +- `resolve.py` — name resolution against `Namespace`. +- `classify.py` — filter classification (row-level vs semi-join vs + post-aggregate having). +- `joins.py` — join-path resolution and cardinality inference. +- `home_grain.py` — implicit home-grain rewrite for fields that + aggregate finer-grained columns (D-015). +- `metric_dispatch.py` — assigns each `ResolvedMetric` to its single + home dataset; surfaces `E1209` for cross-dataset ad-hoc aggregates. +- `metric_shape.py` — classifies a metric body as aggregate / + composite / windowed / nested (`Proposed_OSI_Semantics.md §4.5`). + +### Per-shape composers — invoked by `planner.py` based on classification + +- `planner_scalar.py` — scalar (Fields-only) query composer + (`Proposed_OSI_Semantics.md §5.1.2`). +- `planner_bridge.py` — M:N bridge resolution, distinct-bridge dedup, + nested-aggregate-over-bridge plans (D-022, D-026, D-027). +- `planner_nested.py` — nested cross-grain aggregate planner + (D-020, D-024). +- `planner_composites.py` — composite metric (formula) planning over + declared metrics. +- `planner_mn.py` — multi-fact / many-to-many helpers used by both + bridge and stitch paths. + +### Algebra-step factories + naming + +- `columns.py` — pure builders that convert parsed `Field` / `Metric` + bodies into algebra-level `Column` / `AggregateInfo` values; called + at `SOURCE` and `AGGREGATE` step construction time. +- `steps.py` — `PlanBuilder` accumulator that runs an algebra operator + and records the resulting `PlanStep` with its payload (the bridge + between the planner's topology and the closed algebra). +- `prefixes.py` — deterministic synthetic-column / CTE names; the + only place a contributor should obtain a synthetic identifier. + +## Reading order for contributors + +1. `algebra/state.py` — learn the data model. +2. `algebra/operations.py` — internalize the grain-safety rules. +3. `planner_context.py` — how the environment is bundled. +4. `planner.py` — follow one query through the composer. + +Property tests under `tests/properties/` enforce the algebra laws. See +[`../../../docs/ALGEBRA_LAWS.md`](../../../docs/ALGEBRA_LAWS.md). diff --git a/impl/python/src/osi/planning/__init__.py b/impl/python/src/osi/planning/__init__.py new file mode 100644 index 0000000..ee24f3b --- /dev/null +++ b/impl/python/src/osi/planning/__init__.py @@ -0,0 +1,139 @@ +r"""Layer 2 of the compiler pipeline. + +Takes a ``SemanticModel`` + ``SemanticQuery`` and produces a frozen +``QueryPlan`` — an ordered tuple of ``PlanStep``\s, each wrapping a +closed-algebra operator over an immutable ``CalculationState``. + +See ``../../../ARCHITECTURE.md`` §3 and +``../../../../docs/JOIN_ALGEBRA.md`` for the +algebra contract. + +**Public API tiers** + +``osi.planning`` is the *compiler-developer* surface: the full IR plus +every algebra operator, resolution helper, and predicate classifier. +Adopters who just want to compile a query at the application level +should import the happy-path symbols from ``osi`` instead (the package +re-exports ``plan``, ``Reference``, ``SemanticQuery``, +``PlannerContext``); those names also live here for direct reach but +are intentionally re-exported via the top-level façade. The wider +``__all__`` below is what a custom dialect adapter or replacement +codegen pass needs in order to traverse a ``QueryPlan`` — adopters who +only render the plan as SQL never have to touch most of these. +""" + +from osi.planning.algebra import ( + AggregateFunction, + AggregateInfo, + CalculationState, + Column, + ColumnKind, + Decomposability, + FilterMode, + JoinType, + add_columns, + aggregate, + broadcast, + enrich, + filter_, + filtering_join, + merge, + project, + source, +) +from osi.planning.classify import ( + ClassifiedWhere, + PostAggregatePredicate, + RowLevelPredicate, + SemiJoinKeyPair, + SemiJoinPredicate, + classify_having, + classify_where, +) +from osi.planning.joins import JoinStep, find_enrichment_path +from osi.planning.plan import ( + AddColumnsPayload, + AggregatePayload, + BroadcastPayload, + EnrichPayload, + FilteringJoinPayload, + FilterPayload, + MergePayload, + OrderByEntry, + PlanOperation, + PlanPayload, + PlanStep, + ProjectPayload, + QueryPlan, + SourcePayload, +) +from osi.planning.planner import plan +from osi.planning.planner_context import PlannerContext +from osi.planning.resolve import ( + ResolvedDimension, + ResolvedFact, + ResolvedField, + ResolvedMetric, + ResolvedReference, + resolve_dimension, + resolve_measure, + resolve_reference, +) +from osi.planning.semantic_query import OrderBy, Reference, SemanticQuery, SortDirection + +__all__ = [ + "AddColumnsPayload", + "AggregateFunction", + "AggregateInfo", + "AggregatePayload", + "BroadcastPayload", + "CalculationState", + "ClassifiedWhere", + "Column", + "ColumnKind", + "Decomposability", + "EnrichPayload", + "FilterMode", + "FilterPayload", + "FilteringJoinPayload", + "JoinStep", + "JoinType", + "MergePayload", + "OrderBy", + "OrderByEntry", + "PlanOperation", + "PlanPayload", + "PlanStep", + "PlannerContext", + "PostAggregatePredicate", + "ProjectPayload", + "QueryPlan", + "Reference", + "ResolvedDimension", + "ResolvedFact", + "ResolvedField", + "ResolvedMetric", + "ResolvedReference", + "RowLevelPredicate", + "SemanticQuery", + "SemiJoinKeyPair", + "SemiJoinPredicate", + "SortDirection", + "SourcePayload", + "add_columns", + "aggregate", + "broadcast", + "classify_having", + "classify_where", + "enrich", + "filter_", + "filtering_join", + "find_enrichment_path", + "merge", + "plan", + "project", + "resolve_dimension", + "resolve_measure", + "resolve_reference", + "source", +] diff --git a/impl/python/src/osi/planning/algebra/__init__.py b/impl/python/src/osi/planning/algebra/__init__.py new file mode 100644 index 0000000..566b2d2 --- /dev/null +++ b/impl/python/src/osi/planning/algebra/__init__.py @@ -0,0 +1,116 @@ +"""The closed algebra — the correctness boundary of the compiler. + +All compiler transformations must compose operators from this module. +See ``../../../../docs/JOIN_ALGEBRA.md`` (the +authoritative spec) for operator signatures, preconditions, grain +contracts, and laws. + +The nine operators and their current planner wiring: + +- :func:`source` — initialize from a dataset. *Emitted for every leaf.* +- :func:`filter_` — apply a row-level predicate (underscored; ``filter`` + is a Python builtin). *Emitted for row-level ``WHERE`` and for + ``HAVING``.* +- :func:`enrich` — N:1 join that preserves parent grain. *Emitted for + every safe single-hop or multi-hop enrichment step.* +- :func:`aggregate` — coarsen to a target grain. *Emitted once per + measure group.* +- :func:`project` — keep only the named columns. *Emitted once at the + root.* +- :func:`add_columns` — introduce derived scalar columns. *Emitted only + for composite metrics — arithmetic combinations of already-defined + metrics. (Spec: §4.5 — Metrics, rule 2.)* +- :func:`merge` — full-outer chasm-safe join at matching grain. + *Emitted when two measure groups with different fact datasets must + be combined via the stitch plan. (Spec: §6.8.2 — stitch.)* +- :func:`filtering_join` — semi-/anti-semi-join. **Experimental.** + Emitted for ``EXISTS_IN`` / ``NOT EXISTS_IN`` predicates in + ``WHERE`` when the caller opts in via + ``FoundationFlags(experimental_exists_in=True)``. Foundation v0.1 + §10 / D-017 lists semi-join filtering as deferred, so the default + Foundation parser rejects ``EXISTS_IN`` with + ``E_DEFERRED_KEY_REJECTED``. The operator and its laws are kept + in the closed algebra so the experimental codepath remains + testable; turning the flag off does not remove this operator from + the package, only from the planner's emission path. +- :func:`broadcast` — attach a scalar column. **Reserved.** The + operator and its ``BROADCAST`` plan step exist so the algebra stays + closed under nine operators, but today's planner never emits it — + scalar-per-row calculations go through composite metrics. The + operator's ``E4004_BROADCAST_NOT_SCALAR`` precondition is still + covered by unit tests so a future sprint can turn on the planner + path without destabilising the algebra. + +Mutation-score target: ≥ 90% (``INFRA.md §1.1``). A surviving mutation +in this module is a P0. +""" + +from osi.planning.algebra.composition import add_columns, broadcast +from osi.planning.algebra.grain import ( + AggregateStep, + BroadcastStep, + EnrichStep, + GrainSimulationError, + MergeStep, + OperatorTag, + SimpleStep, + SimState, + SourceStep, + Step, + combine_grains, + is_coarser, + simulate, + simulate_grain, +) +from osi.planning.algebra.joins import filtering_join, merge +from osi.planning.algebra.operations import ( + FilterMode, + JoinType, + aggregate, + enrich, + filter_, + project, + source, +) +from osi.planning.algebra.state import ( + AggregateFunction, + AggregateInfo, + CalculationState, + Column, + ColumnKind, + Decomposability, +) + +__all__ = [ + "AggregateFunction", + "AggregateInfo", + "AggregateStep", + "BroadcastStep", + "CalculationState", + "Column", + "ColumnKind", + "Decomposability", + "EnrichStep", + "FilterMode", + "GrainSimulationError", + "JoinType", + "MergeStep", + "OperatorTag", + "SimState", + "SimpleStep", + "SourceStep", + "Step", + "add_columns", + "aggregate", + "broadcast", + "combine_grains", + "enrich", + "filter_", + "filtering_join", + "is_coarser", + "merge", + "project", + "simulate", + "simulate_grain", + "source", +] diff --git a/impl/python/src/osi/planning/algebra/composition.py b/impl/python/src/osi/planning/algebra/composition.py new file mode 100644 index 0000000..6efdc38 --- /dev/null +++ b/impl/python/src/osi/planning/algebra/composition.py @@ -0,0 +1,132 @@ +"""Scalar-composition operators: ``add_columns`` and ``broadcast``. + +Extracted from :mod:`osi.planning.algebra.operations` to keep the +per-file line budget (``INFRA.md §1.2``, 600 lines). Both operators +preserve grain and only add columns — putting them together makes +the closed-algebra layout match the spec's grouping in +``Proposed_OSI_Semantics.md §4.7-4.8``: + +* ``add_columns`` — derive new scalar columns from existing ones + (composite metrics: ratios of aggregates, etc.). +* ``broadcast`` — attach a scalar (grain-``frozenset()``) state as + a column on every row of a non-scalar state. + +Mutation-score target: **≥ 90%** (same as ``operations.py``). A +surviving mutation in these functions is a P0 bug. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Sequence + +from osi.common.identifiers import Identifier +from osi.errors import AlgebraError, ErrorCode +from osi.planning.algebra.state import CalculationState, Column, ColumnKind + + +def add_columns( + state: CalculationState, definitions: Sequence[Column] +) -> CalculationState: + """Introduce derived scalar columns (no aggregation). + + In the Foundation this operator is wired to exactly one planner + path — **composite metrics** (``Proposed_OSI_Semantics.md §5.4``). + A measure group that contains composite metrics (e.g. ratios of + two aggregates) is lowered to an ``AGGREGATE`` step over the base + aggregates followed by one ``ADD_COLUMNS`` step per composite. + + Preconditions + ------------- + * every definition's ``kind`` is ``DIMENSION`` or ``FACT`` (no + aggregates — see :func:`aggregate` for those, or emit the + aggregate step first and reference it from here) + * every definition's ``dependencies`` are known to ``state`` + * new column names do not collide with existing names + + Grain effect: **preserved**. UKs are preserved (the operator + only adds columns; existing keys remain keys). + """ + existing = state.column_names + new_names: set[Identifier] = set() + for col in definitions: + if col.kind is ColumnKind.AGGREGATE: + raise AlgebraError( + ErrorCode.E3007_AGGREGATE_IN_SCALAR_CONTEXT, + f"add_columns cannot introduce AGGREGATE column {col.name!r}", + context={"column": col.name}, + ) + unknown = col.dependencies - (existing | new_names) + if unknown: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"add_columns definition {col.name!r} depends on unknown " + f"columns: {sorted(unknown)}", + context={"column": col.name, "missing": sorted(unknown)}, + ) + if col.name in existing or col.name in new_names: + raise AlgebraError( + ErrorCode.E3005_COLUMN_NAME_COLLISION, + f"add_columns definition {col.name!r} collides with existing " "column", + context={"column": col.name}, + ) + new_names.add(col.name) + return CalculationState( + grain=state.grain, + columns=state.columns + tuple(definitions), + provenance=state.provenance, + unique_keys=state.unique_keys, + ) + + +def broadcast(state: CalculationState, scalar: CalculationState) -> CalculationState: + """Attach a single scalar value (grain-``frozenset()`` state) as a column. + + **Planner status: reserved.** ``broadcast`` is part of the closed + algebra (``Proposed_OSI_Semantics.md §4.8``) so scalar-per-row + attach semantics have a stable operator, but today's planner + never emits a :attr:`~osi.planning.plan.PlanOperation.BROADCAST` + step. Percent-of-total style calculations go through composite + metrics + :func:`add_columns` instead. This function, and its + ``E4004_BROADCAST_NOT_SCALAR`` precondition, remain so a future + sprint can turn it on without a SPEC change — and so the algebra + stays closed under the nine declared operators. + + Preconditions + ------------- + * ``scalar.is_scalar`` (``grain == frozenset()``) + * ``scalar`` has exactly one column + * that column's name does not already exist in ``state`` + + Grain effect: **preserved**. UKs are preserved (the operator + only adds a single column; existing keys remain keys). + """ + if not scalar.is_scalar: + raise AlgebraError( + ErrorCode.E4004_BROADCAST_NOT_SCALAR, + "broadcast requires a scalar state (grain == frozenset())", + context={"scalar_grain": sorted(scalar.grain)}, + ) + if len(scalar.columns) != 1: + raise AlgebraError( + ErrorCode.E4004_BROADCAST_NOT_SCALAR, + "broadcast requires exactly one scalar column", + context={"columns": [c.name for c in scalar.columns]}, + ) + new_col = scalar.columns[0] + if new_col.name in state.column_names: + raise AlgebraError( + ErrorCode.E3005_COLUMN_NAME_COLLISION, + f"broadcast column {new_col.name!r} collides with existing column", + context={"column": new_col.name}, + ) + tagged = replace(new_col, is_single_valued=True) + return CalculationState( + grain=state.grain, + columns=state.columns + (tagged,), + provenance=state.provenance | scalar.provenance, + unique_keys=state.unique_keys, + ) + + +__all__ = ["add_columns", "broadcast"] diff --git a/impl/python/src/osi/planning/algebra/grain.py b/impl/python/src/osi/planning/algebra/grain.py new file mode 100644 index 0000000..18165ad --- /dev/null +++ b/impl/python/src/osi/planning/algebra/grain.py @@ -0,0 +1,268 @@ +"""Symbolic grain helpers used by tests and diagnostics. + +**Audience.** This module is internal to the algebra package: the +property tests in ``tests/properties/test_grain_closure.py`` use +:func:`simulate` / :func:`simulate_grain` to compare symbolic +computation against the concrete algebra, and +``osi.diagnostics.explain`` uses :func:`combine_grains` to summarise +plan steps. External callers (adapters, alternative planners) should +construct real :class:`CalculationState` instances and use the +algebra operators rather than reaching into the simulator. The +``__all__`` at the bottom of this module is the supported surface. + +Per ``../../../../docs/JOIN_ALGEBRA.md §4.4`` +the resulting grain of any operator chain is a pure function of the +argument sequence. This module exposes that function without needing +to construct real states — the property test ``test_grain_closure.py`` +uses it to compare symbolic computation to the concrete algebra. + +The simulator tracks **two** pieces of grain-relevant state: + +* ``grain`` — the dimensions that uniquely identify a row in the + current state. This matches ``CalculationState.grain``. +* ``single_valued`` — extra columns proven single-valued over the + current grain (typically dimensions brought in via N:1 ``enrich`` or + scalars attached via ``broadcast``). ``aggregate`` may coarsen *to* + any subset of ``grain ∪ single_valued``; tracking the second set + lets the simulator accept the hot star-schema path of + *enrich-then-aggregate-by-RHS-dim* without falsely rejecting it. + +Promoting ``single_valued`` to first-class state in the simulator +mirrors the per-column ``Column.is_single_valued`` flag in the +concrete algebra (see :mod:`osi.planning.algebra.state`). When the +Foundation grows grain operations or filter-context manipulation, the +extra book-keeping is already there for them to use. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum, auto + +from osi.common.identifiers import Identifier +from osi.common.types import DimensionSet +from osi.errors import ErrorCode, OSIError + + +class OperatorTag(StrEnum): + """Enum identifying which operator a step represents. + + Kept as an enum (not the callable) so a grain simulation can run + purely over data without importing :mod:`operations`. + """ + + SOURCE = auto() + FILTER = auto() + ENRICH = auto() + AGGREGATE = auto() + PROJECT = auto() + ADD_COLUMNS = auto() + MERGE = auto() + FILTERING_JOIN = auto() + BROADCAST = auto() + + +@dataclass(frozen=True, slots=True) +class SourceStep: + """Start a chain from a dataset primary key.""" + + tag: OperatorTag + primary_key: DimensionSet + + +@dataclass(frozen=True, slots=True) +class AggregateStep: + """Coarsen to ``target_grain``.""" + + tag: OperatorTag + target_grain: DimensionSet + + +@dataclass(frozen=True, slots=True) +class MergeStep: + """Merge with another chain; both sides must share grain.""" + + tag: OperatorTag + right_grain: DimensionSet + + +@dataclass(frozen=True, slots=True) +class EnrichStep: + """N:1 enrich; preserves grain and extends single-valued vocabulary. + + ``adds`` is the set of RHS column names that flow in. They become + valid coarsening targets for a subsequent ``aggregate`` because an + N:1 join makes them single-valued over the parent grain. + """ + + tag: OperatorTag + adds: frozenset[Identifier] = field(default_factory=frozenset) + + +@dataclass(frozen=True, slots=True) +class BroadcastStep: + """Attach a scalar; preserves grain, extends single-valued vocabulary.""" + + tag: OperatorTag + adds: Identifier + + +@dataclass(frozen=True, slots=True) +class SimpleStep: + """A grain-preserving operator with no single-valued vocabulary effect. + + Covers ``filter``, ``project``, ``add_columns``, and + ``filtering_join`` — all of which preserve the row multiset's grain + and do not extend the set of columns the next ``aggregate`` may + coarsen to. + """ + + tag: OperatorTag + + +Step = SourceStep | AggregateStep | MergeStep | EnrichStep | BroadcastStep | SimpleStep + + +@dataclass(frozen=True, slots=True) +class SimState: + """First-class symbolic shadow of :class:`CalculationState`. + + Only carries grain-relevant information (no expressions, kinds, or + provenance). Constructing one outside this module is unusual; use + :func:`simulate` to build one from a step sequence. + """ + + grain: DimensionSet + single_valued: frozenset[Identifier] = field(default_factory=frozenset) + + +_PRESERVING_TAGS: frozenset[OperatorTag] = frozenset( + { + OperatorTag.FILTER, + OperatorTag.PROJECT, + OperatorTag.ADD_COLUMNS, + OperatorTag.FILTERING_JOIN, + } +) + + +class GrainSimulationError(OSIError): + """Raised when a step sequence is malformed (e.g. no leading SOURCE). + + Carries :data:`ErrorCode.E_INTERNAL_INVARIANT` because the failure + means the symbolic simulator was driven with a malformed input — + the real compiler flows always start with ``source`` and stitch + operators in valid order. Keeping the simulator inside the typed + ``OSIError`` hierarchy means the "every failure carries a code" + invariant the architecture tests rely on holds for this path too. + """ + + def __init__(self, message: str) -> None: + super().__init__(ErrorCode.E_INTERNAL_INVARIANT, message) + + +def simulate(steps: tuple[Step, ...]) -> SimState: + """Compute the resulting :class:`SimState` of a step sequence. + + Carries both ``grain`` and ``single_valued``. Use this when you + care about which columns can serve as ``aggregate`` targets; + use :func:`simulate_grain` when only the grain matters. + """ + if not steps: + raise GrainSimulationError("step sequence is empty") + if not isinstance(steps[0], SourceStep): + raise GrainSimulationError( + f"first step must be SOURCE, got {type(steps[0]).__name__}" + ) + state = SimState(grain=steps[0].primary_key, single_valued=frozenset()) + for step in steps[1:]: + state = _step(step, state) + return state + + +def simulate_grain(steps: tuple[Step, ...]) -> DimensionSet: + """Compute the resulting grain of a step sequence. + + Backward-compatible wrapper around :func:`simulate` for callers + that only care about the grain dimension set. + """ + return simulate(steps).grain + + +def _step(step: Step, current: SimState) -> SimState: + if isinstance(step, SourceStep): + raise GrainSimulationError("SOURCE may only appear as the first step") + if isinstance(step, AggregateStep): + # Aggregate can coarsen to any subset of (grain ∪ single_valued). + permitted = current.grain | current.single_valued + if not step.target_grain.issubset(permitted): + raise GrainSimulationError( + f"aggregate target {sorted(step.target_grain)} is not a " + f"subset of grain ∪ single_valued {sorted(permitted)}" + ) + # After aggregation the single-valued extras are gone — anything + # not in the new grain is no longer addressable. + return SimState(grain=step.target_grain, single_valued=frozenset()) + if isinstance(step, MergeStep): + if step.right_grain != current.grain: + raise GrainSimulationError( + f"merge grains disagree: left={sorted(current.grain)} " + f"right={sorted(step.right_grain)}" + ) + # Merge preserves grain. The right side's single-valued extras + # would need to be modeled if callers cared; the Foundation + # leaves merge unchanged here (mirrors §3.7 of the algebra). + return current + if isinstance(step, EnrichStep): + # Enrich preserves grain and adds new single-valued columns. + return SimState( + grain=current.grain, + single_valued=current.single_valued | step.adds, + ) + if isinstance(step, BroadcastStep): + return SimState( + grain=current.grain, + single_valued=current.single_valued | frozenset({step.adds}), + ) + # SimpleStep — preservation family only (filter/project/etc). + if step.tag not in _PRESERVING_TAGS: + raise GrainSimulationError( + f"simple step with tag {step.tag!r} is invalid; use a " + f"dedicated step type for {step.tag.name.lower()}" + ) + return current + + +def is_coarser(child: DimensionSet, parent: DimensionSet) -> bool: + """Return ``True`` iff ``child`` grain is (weakly) coarser than ``parent``. + + ``coarser`` means "fewer dimensions"; by set-subset that is + ``child ⊆ parent``. + """ + return child.issubset(parent) + + +def combine_grains(*grains: frozenset[Identifier]) -> DimensionSet: + """Union of several grains. Used by the reference interpreter.""" + result: set[Identifier] = set() + for g in grains: + result.update(g) + return frozenset(result) + + +__all__ = [ + "AggregateStep", + "BroadcastStep", + "EnrichStep", + "GrainSimulationError", + "MergeStep", + "OperatorTag", + "SimState", + "SimpleStep", + "SourceStep", + "Step", + "combine_grains", + "is_coarser", + "simulate", + "simulate_grain", +] diff --git a/impl/python/src/osi/planning/algebra/joins.py b/impl/python/src/osi/planning/algebra/joins.py new file mode 100644 index 0000000..ee6dfff --- /dev/null +++ b/impl/python/src/osi/planning/algebra/joins.py @@ -0,0 +1,139 @@ +"""Grain-matching join operators: ``merge`` and ``filtering_join``. + +Extracted from :mod:`osi.planning.algebra.operations` to keep the +per-file line budget (``INFRA.md §1.2``, 600 lines). Everything here is +still part of the closed algebra and shares the same contract: + +* pure, total, deterministic; +* never mutates its inputs; +* raises :class:`AlgebraError` with a stable :class:`ErrorCode` on any + precondition failure. + +Mutation-score target: **≥ 90%** (same as ``operations.py``). A +surviving mutation in these functions is a P0 bug. +""" + +from __future__ import annotations + +from osi.common.types import DimensionSet +from osi.errors import AlgebraError, ErrorCode +from osi.planning.algebra.operations import FilterMode +from osi.planning.algebra.state import CalculationState + + +def merge( + left: CalculationState, + right: CalculationState, + *, + on: DimensionSet | None = None, +) -> CalculationState: + """FULL OUTER join at matching grain (chasm-trap resolution). + + Preconditions + ------------- + * ``left.grain == right.grain`` + * if ``on`` is given, ``on == left.grain`` (the algebra spec + ``docs/JOIN_ALGEBRA.md §3.7`` defines merging on the shared + grain — other joins must go through ``enrich`` or + ``filtering_join``) + * non-grain columns of the two sides are disjoint + + Grain effect: **preserved** (both sides share it). + """ + if left.grain != right.grain: + raise AlgebraError( + ErrorCode.E3008_GRAIN_MISMATCH_MERGE, + "merge requires equal grains", + context={ + "left_grain": sorted(left.grain), + "right_grain": sorted(right.grain), + }, + ) + if on is not None and on != left.grain: + raise AlgebraError( + ErrorCode.E3008_GRAIN_MISMATCH_MERGE, + "merge `on` argument must equal the shared grain", + context={ + "on": sorted(on), + "grain": sorted(left.grain), + }, + ) + left_nongrain = {c.name for c in left.columns if c.name not in left.grain} + right_nongrain = {c.name for c in right.columns if c.name not in right.grain} + overlap = left_nongrain & right_nongrain + if overlap: + raise AlgebraError( + ErrorCode.E4003_MERGE_COLUMN_OVERLAP, + f"merge cannot overlap non-grain columns: {sorted(overlap)}", + context={"columns": sorted(overlap)}, + ) + grain_columns = tuple(c for c in left.columns if c.name in left.grain) + left_extras = tuple(c for c in left.columns if c.name not in left.grain) + right_extras = tuple(c for c in right.columns if c.name not in right.grain) + return CalculationState( + grain=left.grain, + columns=grain_columns + left_extras + right_extras, + provenance=left.provenance | right.provenance, + # FULL OUTER on the shared grain may introduce rows that exist + # on only one side. A UK that held on the left may not hold on + # the right's-only rows (and vice versa), so only the + # intersection of the two UK sets is provably safe post-merge. + unique_keys=left.unique_keys & right.unique_keys, + ) + + +def filtering_join( + state: CalculationState, + rhs: CalculationState, + *, + lhs_keys: DimensionSet, + rhs_keys: DimensionSet, + mode: FilterMode, +) -> CalculationState: + """Semi-join (``SEMI``) or anti-semi-join (``ANTI``). + + Used for ``EXISTS_IN`` / ``NOT EXISTS_IN``. No columns are added — + that is the defining difference from + :func:`osi.planning.algebra.enrich`. + + Preconditions + ------------- + * ``lhs_keys ⊆ state.column_names`` + * ``rhs_keys ⊆ rhs.column_names`` + * ``len(lhs_keys) == len(rhs_keys)`` (composite-key join needs a + matched arity; the plan step records the pairing) + """ + if len(lhs_keys) != len(rhs_keys): + raise AlgebraError( + ErrorCode.E4005_FILTERING_JOIN_ADDS_COLUMNS, + "filtering_join requires matching key arity", + context={ + "lhs_arity": len(lhs_keys), + "rhs_arity": len(rhs_keys), + }, + ) + missing_lhs = lhs_keys - state.column_names + if missing_lhs: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"filtering_join lhs_keys missing: {sorted(missing_lhs)}", + context={"missing": sorted(missing_lhs), "side": "lhs"}, + ) + missing_rhs = rhs_keys - rhs.column_names + if missing_rhs: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"filtering_join rhs_keys missing: {sorted(missing_rhs)}", + context={"missing": sorted(missing_rhs), "side": "rhs"}, + ) + if mode not in FilterMode: + raise AlgebraError( + ErrorCode.E4005_FILTERING_JOIN_ADDS_COLUMNS, + f"unknown filtering_join mode: {mode!r}", + context={"mode": str(mode)}, + ) + _ = rhs # rhs is only used for key presence checks; no columns flow + return state + + +__all__ = ["filtering_join", "merge"] diff --git a/impl/python/src/osi/planning/algebra/operations.py b/impl/python/src/osi/planning/algebra/operations.py new file mode 100644 index 0000000..ff8082f --- /dev/null +++ b/impl/python/src/osi/planning/algebra/operations.py @@ -0,0 +1,557 @@ +"""Five of the nine operators of the closed algebra. + +This module hosts ``source``, ``filter_``, ``enrich``, ``aggregate``, +and ``project``. The two grain-matching joins — ``merge`` and +``filtering_join`` — live in :mod:`osi.planning.algebra.joins`; the +two scalar-composition operators — ``add_columns`` and ``broadcast`` +— live in :mod:`osi.planning.algebra.composition`. The split keeps +each file inside the 600-line per-file budget (``INFRA.md §1.2``); +all nine operators share the same contract and are re-exported +through :mod:`osi.planning.algebra`. + +Every compiler transformation is expressed as a composition of these +nine operators. Adding a tenth is a SPEC change (see +``../../../../docs/JOIN_ALGEBRA.md §3``). + +Mutation-score target: **≥ 90%** for this module (``INFRA.md §1.1``). A +surviving mutation here is a P0 bug — it means at least one property or +unit test is weaker than it looks. + +Convention: every operator returns a *new* :class:`CalculationState`; +the input is never mutated. Preconditions are checked before any work; +failures raise :class:`AlgebraError` with a specific :class:`ErrorCode` +(``E3xxx`` for shape/grain contract violations, ``E4xxx`` for +algebra-only safety failures — see :class:`AlgebraError`). +""" + +from __future__ import annotations + +from dataclasses import replace +from enum import StrEnum, auto +from typing import Sequence + +from osi.common.identifiers import Identifier +from osi.common.sql_expr import FrozenSQL +from osi.common.types import DimensionSet +from osi.errors import AlgebraError, ErrorCode +from osi.planning.algebra.state import ( + AggregateFunction, + AggregateInfo, + CalculationState, + Column, + ColumnKind, + Decomposability, +) + + +class JoinType(StrEnum): + """Foundation join types for :func:`enrich`. + + Only inner and left outer are supported; right/full live outside the + Foundation and would need a SPEC update + decision-log entry. + """ + + INNER = auto() + LEFT = auto() + + +class FilterMode(StrEnum): + """Mode for :func:`filtering_join` (semi-join / anti-semi-join). + + Lives next to :class:`JoinType` because both are join-shape enums + consumed by the same operator family. The actual ``filtering_join`` + operator is defined in :mod:`osi.planning.algebra.joins` to keep + that file's surface area cohesive (semi-joins, anti-semi-joins, + and the helpers they share). + """ + + SEMI = auto() + ANTI = auto() + + +# --------------------------------------------------------------------------- +# source +# --------------------------------------------------------------------------- + + +def source( + primary_key: DimensionSet, + dimension_columns: Sequence[Column], + fact_columns: Sequence[Column] = (), + *, + unique_keys: Sequence[DimensionSet] = (), +) -> CalculationState: + """Initialize a state from a dataset's declared columns. + + Parsing is responsible for declaring the primary key and the field + roles; this operator is the sole entry point into the algebra. + + Preconditions + ------------- + * ``primary_key`` is non-empty + * every name in ``primary_key`` is a dimension column + * all provided columns have kind ``DIMENSION`` or ``FACT`` (not + ``AGGREGATE`` — aggregates are produced by :func:`aggregate`) + * provided column names are unique + * every set in ``unique_keys`` is non-empty and references + dimension columns (validated by ``CalculationState.__post_init__`` + under I-9). Each UK is an *alternative* minimum key at the + dataset grain, used by :meth:`CalculationState.is_unique_on` + when a downstream :func:`enrich` joins on a column that is + unique-but-not-the-PK. ``Proposed_OSI_Semantics.md §4.2`` and + ``§6.1`` mandate symmetric treatment of PK and UKs. + """ + if not primary_key: + raise AlgebraError( + ErrorCode.E2007_MISSING_PRIMARY_KEY, + "source requires a non-empty primary_key", + ) + columns = tuple(dimension_columns) + tuple(fact_columns) + names = [c.name for c in columns] + if len(names) != len(set(names)): + raise AlgebraError( + ErrorCode.E3005_COLUMN_NAME_COLLISION, + "source received duplicate column names", + context={"columns": names}, + ) + for col in columns: + if col.kind is ColumnKind.AGGREGATE: + raise AlgebraError( + ErrorCode.E4001_EXPLOSION_UNSAFE, + f"source column {col.name!r} cannot be AGGREGATE", + context={"column": col.name}, + ) + dim_names = {c.name for c in dimension_columns if c.kind is ColumnKind.DIMENSION} + missing_pk = primary_key - dim_names + if missing_pk: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + "primary_key references non-dimension columns", + context={"missing": sorted(missing_pk)}, + ) + # Every column declared on a dataset is trivially single-valued over + # the dataset's primary key (each row has exactly one value). This + # flag lets the aggregator later group by any of these columns + # without re-proving functional dependency. + tagged = tuple(replace(c, is_single_valued=True) for c in columns) + return CalculationState( + grain=primary_key, + columns=tagged, + unique_keys=frozenset(frozenset(uk) for uk in unique_keys), + ) + + +# --------------------------------------------------------------------------- +# filter +# --------------------------------------------------------------------------- + + +def filter_( + state: CalculationState, + predicate: FrozenSQL, + *, + dependencies: frozenset[Identifier] = frozenset(), +) -> CalculationState: + """Validate a row-level predicate against ``state``. + + The Foundation algebra keeps predicates *off* the + :class:`CalculationState` (``JOIN_ALGEBRA.md §3.2``); a predicate + is metadata of the enclosing :class:`PlanStep`, not of the + relational shape. ``filter_`` is therefore intentionally an + **identity on the state**: it returns ``state`` itself after + proving that every column the predicate reads is addressable. + + The function is still part of the closed algebra because: + + * its precondition (``dependencies ⊆ state.column_names``) is the + same kind of safety check the other operators enforce, and + * having a callable here lets plan composition walk the same + operator-application protocol for every step (uniformly + "construct → check → return new state-or-same-state"). + + Preconditions + ------------- + * ``dependencies ⊆ state.column_names`` + + Grain effect: **preserved** (filtering removes rows, not dimensions). + Columns effect: **preserved structurally** (the predicate lives on + the plan step, not on the returned state). + + Notes + ----- + Renamed to ``filter_`` because ``filter`` is a Python builtin. + Re-exported at the package level as ``filter_`` only; users must + use the module-qualified name. + """ + unknown = dependencies - state.column_names + if unknown: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"filter predicate depends on unknown columns: {sorted(unknown)}", + context={"missing": sorted(unknown)}, + ) + _ = predicate # retained for caller introspection, not stored + return state + + +# --------------------------------------------------------------------------- +# enrich +# --------------------------------------------------------------------------- + + +def enrich( + parent: CalculationState, + child: CalculationState, + *, + parent_keys: tuple[Identifier, ...], + child_keys: tuple[Identifier, ...], + join_type: JoinType, + drop_child_columns: frozenset[Identifier] = frozenset(), +) -> CalculationState: + """N:1 join — bring the one-side's columns into the many-side state. + + The contract is symmetric with :func:`merge` and + :func:`filtering_join`: both sides are full + :class:`CalculationState` values. The algebra derives fan-out + safety from grain, *not* from a caller-asserted boolean. + + Preconditions + ------------- + * ``parent_keys`` and ``child_keys`` have the same arity (they are + the positional pairing of the equi-join condition) + * every name in ``parent_keys`` is addressable on ``parent`` + * every name in ``child_keys`` is addressable on ``child`` + * ``child.is_unique_on(child_keys)`` — i.e. ``child`` is *unique + on the join keys*. This is the **fan-trap rule**: if ``child`` + can have multiple rows per join key, joining duplicates + ``parent`` rows and silently destroys ``parent.grain``. The + check delegates to + :meth:`CalculationState.is_unique_on`, which accepts any + ``child_keys`` set that is a superset of either the child's + grain or any declared :attr:`unique_key`. ``Proposed_OSI_Semantics.md + §6.1`` mandates this symmetric treatment so authors can recover + from a wider-than-necessary PK declaration with an explicit UK + on the join column. Failures raise + :attr:`ErrorCode.E3011_MN_AGGREGATION_REJECTED` — the algebra + precondition signal for any fan-out edge. Declared N:N edges + are normally rejected earlier in + :func:`osi.planning.joins.find_enrichment_path` with + :attr:`ErrorCode.E3012_MN_NO_SAFE_REWRITE` or + :attr:`ErrorCode.E3013_NO_STITCHING_DIMENSION`; the planner + translates this E3011 precondition signal into the user-facing + :attr:`ErrorCode.E_UNSAFE_REAGGREGATION` (per Appendix C / D-022) + for 1:N fan-trap cases that reach the algebra layer. + * no child column (after the optional ``drop_child_columns`` + reduction) collides with a parent column + * no child column is an aggregate (aggregates are built by + :func:`aggregate` after the join, not before) + + ``drop_child_columns`` lets the planner skip child columns that are + redundant — typically the child-side join keys when they share a + name with the parent-side keys. The Foundation algebra does not + rename: collisions outside this drop set surface as + :attr:`ErrorCode.E3005_COLUMN_NAME_COLLISION`. + + Grain effect: **preserved** (``parent.grain``). + """ + if len(parent_keys) != len(child_keys): + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + "enrich requires parent_keys and child_keys to have the same arity", + context={ + "parent_keys": list(parent_keys), + "child_keys": list(child_keys), + }, + ) + if not parent_keys: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + "enrich requires at least one join key pair", + ) + missing_parent = frozenset(parent_keys) - parent.column_names + if missing_parent: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + "enrich parent_keys are not addressable columns on parent", + context={ + "missing": sorted(missing_parent), + "parent_columns": sorted(parent.column_names), + }, + ) + missing_child = frozenset(child_keys) - child.column_names + if missing_child: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + "enrich child_keys are not addressable columns on child", + context={ + "missing": sorted(missing_child), + "child_columns": sorted(child.column_names), + }, + ) + # Fan-trap rule: child must be unique on the join keys. Accepts + # either the grain (PK) OR any declared UK as proof of uniqueness + # — see CalculationState.is_unique_on for the symmetry rationale. + child_key_set = frozenset(child_keys) + if not child.is_unique_on(child_key_set): + raise AlgebraError( + ErrorCode.E3011_MN_AGGREGATION_REJECTED, + "enrich would fan out: child is not unique on the join keys " + f"(child.grain={sorted(child.grain)}, " + f"child.unique_keys={sorted(map(sorted, child.unique_keys))}, " + f"child_keys={sorted(child_key_set)}). " + "Aggregate the child to the join key before enriching, declare " + "a unique_key covering the join column, or use the relationship " + "in the opposite direction.", + context={ + "child_grain": sorted(child.grain), + "child_unique_keys": sorted(map(sorted, child.unique_keys)), + "child_keys": sorted(child_key_set), + }, + ) + # AGGREGATE columns from the child are surfaced as scalar values on + # the parent only when the join cannot fan out the parent — i.e. + # ``child`` is unique on ``child_keys``. The fan-trap check above + # already guarantees that condition. The result column is reclassified + # as ``FACT`` because the aggregation has been discharged at the + # child's grain; downstream aggregates can re-aggregate it explicitly + # (the bridge-resolution mid-pipeline plan, ``§6.5.1``, depends on + # this). Without the join uniqueness, the original rejection still + # applies — see the fan-trap check above which raises before we + # reach this point. + incoming_raw = tuple(c for c in child.columns if c.name not in drop_child_columns) + incoming = tuple( + ( + replace( + c, + kind=ColumnKind.FACT, + aggregate=None, + dependencies=frozenset(), + is_single_valued=True, + ) + if c.kind is ColumnKind.AGGREGATE + else c + ) + for c in incoming_raw + ) + parent_names = parent.column_names + overlap = {c.name for c in incoming} & parent_names + if overlap: + raise AlgebraError( + ErrorCode.E3005_COLUMN_NAME_COLLISION, + f"enrich child columns collide with parent: {sorted(overlap)}. " + "Rename the colliding fields in the model, or drop them from the " + "child via drop_child_columns when bringing them in.", + context={"columns": sorted(overlap)}, + ) + _ = join_type # recorded by the plan step; here for type clarity + tagged_children = tuple( + replace(col, from_join_rhs=True, is_single_valued=True) for col in incoming + ) + return CalculationState( + grain=parent.grain, + columns=parent.columns + tagged_children, + provenance=parent.provenance | child.provenance, + # Grain preserved → parent's UKs still hold. The child's UKs + # describe the child's grain, which is gone from the post-enrich + # state, so we drop them. + unique_keys=parent.unique_keys, + ) + + +# --------------------------------------------------------------------------- +# aggregate +# --------------------------------------------------------------------------- + + +def aggregate( + state: CalculationState, + new_grain: DimensionSet, + aggregations: Sequence[Column], +) -> CalculationState: + """Reduce to a coarser grain, emitting one aggregate column per aggregation. + + Preconditions + ------------- + * every ``new_grain`` name is a ``DIMENSION`` column of ``state`` + * every ``new_grain`` column is **either** a member of + ``state.grain`` **or** single-valued over it (introduced by + :func:`enrich` on the one-side, or by :func:`broadcast`). This is + the spec's "grain coarsening" rule generalised to handle the + common case of aggregating by a dimension brought in through an + N:1 join (see the planner pseudocode in ``JOIN_ALGEBRA.md §7``). + * every aggregation column has kind ``AGGREGATE`` with populated + ``AggregateInfo`` + * every aggregation's dependency set is a subset of ``state.column_names`` + * if an aggregation reads a ``from_join_rhs`` column, it is not a + ``HOLISTIC`` aggregate (``COUNT DISTINCT`` etc.) — such + aggregations must run at the finer grain first and be merged + afterwards (``E4001 EXPLOSION_UNSAFE``) + """ + dimension_names = {c.name for c in state.columns if c.kind is ColumnKind.DIMENSION} + unknown = new_grain - state.column_names + if unknown: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + "aggregate new_grain references unknown columns", + context={"new_grain": sorted(new_grain), "missing": sorted(unknown)}, + ) + non_dim = new_grain - dimension_names + if non_dim: + raise AlgebraError( + ErrorCode.E3004_GRAIN_NOT_SUBSET, + "aggregate new_grain includes non-dimension columns", + context={"non_dimension": sorted(non_dim)}, + ) + # Every new_grain member must be single-valued over ``state.grain``. + # Membership in ``state.grain`` counts trivially; otherwise the column + # must have been tagged ``is_single_valued`` when introduced (source + # dimensions, enrich RHS columns, broadcast scalars). + for name in new_grain: + col = state.column(name) + if name not in state.grain and not col.is_single_valued: + raise AlgebraError( + ErrorCode.E3004_GRAIN_NOT_SUBSET, + f"aggregate cannot group by {name!r}: not in state.grain and " + "not known to be single-valued over it", + context={ + "column": name, + "state_grain": sorted(state.grain), + }, + ) + for agg in aggregations: + if agg.kind is not ColumnKind.AGGREGATE or agg.aggregate is None: + raise AlgebraError( + ErrorCode.E3007_AGGREGATE_IN_SCALAR_CONTEXT, + f"aggregate received non-AGGREGATE column {agg.name!r}", + context={"column": agg.name, "kind": agg.kind}, + ) + unknown = agg.dependencies - state.column_names + if unknown: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"aggregation {agg.name!r} depends on unknown columns: " + f"{sorted(unknown)}", + context={"column": agg.name, "missing": sorted(unknown)}, + ) + if agg.aggregate.function.decomposability is Decomposability.HOLISTIC: + for dep in agg.dependencies: + src = state.column(dep) + if src.from_join_rhs: + raise AlgebraError( + ErrorCode.E4001_EXPLOSION_UNSAFE, + f"holistic aggregation {agg.name!r} reads " + f"join-RHS column {dep!r}; pre-aggregate first", + context={"column": agg.name, "source": dep}, + ) + + dim_by_name = {c.name: c for c in state.columns if c.kind is ColumnKind.DIMENSION} + kept_dims = tuple(dim_by_name[name] for name in sorted(new_grain)) + agg_names = {a.name for a in aggregations} + overlap = agg_names & new_grain + if overlap: + raise AlgebraError( + ErrorCode.E3005_COLUMN_NAME_COLLISION, + f"aggregation names collide with grain dimensions: {sorted(overlap)}", + context={"columns": sorted(overlap)}, + ) + # After aggregation, each output aggregate column is a fresh scalar + # at ``new_grain``. Its input-side dependencies are no longer + # addressable in the output state, so strip them to satisfy I-3 + # (every dep must resolve in the current state). Mark it + # single-valued: an aggregate is by definition one value per grain + # key. + sealed_aggregations = tuple( + replace(a, dependencies=frozenset(), is_single_valued=True) + for a in aggregations + ) + return CalculationState( + grain=new_grain, + columns=kept_dims + sealed_aggregations, + provenance=state.provenance, + # A UK that is a subset of new_grain remains an alternative + # minimum key after aggregation (each new-grain row contains + # exactly one UK value, and distinct UK values stayed distinct + # because the UK was distinct at the old grain). UKs that + # straddle out of new_grain are dropped — proving they remain + # unique would require re-deriving functional dependencies the + # algebra does not track. + unique_keys=frozenset(uk for uk in state.unique_keys if uk.issubset(new_grain)), + ) + + +# --------------------------------------------------------------------------- +# project +# --------------------------------------------------------------------------- + + +def project(state: CalculationState, columns: Sequence[Identifier]) -> CalculationState: + """Keep only ``columns``, in the order given. + + Preconditions + ------------- + * ``columns ⊆ state.column_names`` + * ``state.grain ⊆ columns`` (dropping grain dimensions is forbidden — + that would violate I-1) + """ + requested = tuple(columns) + requested_set = set(requested) + unknown = requested_set - state.column_names + if unknown: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"project references unknown columns: {sorted(unknown)}", + context={"missing": sorted(unknown)}, + ) + missing_grain = state.grain - requested_set + if missing_grain: + raise AlgebraError( + ErrorCode.E3004_GRAIN_NOT_SUBSET, + f"project would drop grain dimensions: {sorted(missing_grain)}", + context={"missing": sorted(missing_grain)}, + ) + if len(requested_set) != len(requested): + raise AlgebraError( + ErrorCode.E3005_COLUMN_NAME_COLLISION, + "project received duplicate column names", + context={"columns": list(requested)}, + ) + by_name = {c.name: c for c in state.columns} + retained_names = frozenset(requested) + # After projection, retained columns stand alone: any + # ``dependencies`` that were pruned by the project become + # unresolvable in the output state (they name columns no longer + # present). Stripping them is semantically safe — a post-project + # column is materialised and no longer a lazy view over its + # inputs, same way AGGREGATE seals its outputs. Preserving deps + # would force every callers to also retain every transitive input, + # defeating the point of PROJECT. + retained_columns = tuple( + replace( + by_name[n], + dependencies=by_name[n].dependencies & retained_names, + ) + for n in requested + ) + return CalculationState( + grain=state.grain, + columns=retained_columns, + provenance=state.provenance, + # Grain preserved (project may not drop grain dims), so any UK + # whose columns survived projection is still a valid key. + unique_keys=frozenset( + uk for uk in state.unique_keys if uk.issubset(retained_names) + ), + ) + + +__all__ = [ + "AggregateFunction", + "AggregateInfo", + "FilterMode", + "JoinType", + "aggregate", + "enrich", + "filter_", + "project", + "source", +] diff --git a/impl/python/src/osi/planning/algebra/state.py b/impl/python/src/osi/planning/algebra/state.py new file mode 100644 index 0000000..a21d699 --- /dev/null +++ b/impl/python/src/osi/planning/algebra/state.py @@ -0,0 +1,277 @@ +"""Immutable value types that flow through the closed algebra. + +See ``../../../../docs/JOIN_ALGEBRA.md §1`` for +the normative contract. Nothing in this file imports from +``osi.parsing`` or ``osi.codegen``; those layers see algebra values but +never construct them directly. Construction happens only through +:func:`osi.planning.algebra.operations.source` (and its downstream +operator chain). + +Invariants (see ``ARCHITECTURE.md §6``): + +* **I-1** ``grain ⊆ {c.name for c in columns if c.kind is DIMENSION}`` +* **I-2** column names in ``columns`` are unique +* **I-5** ``grain == frozenset()`` implies scalar (one row) +* **I-6** ``column.dependencies ⊆ {other.name for other in columns}`` +* **I-8** ``provenance`` grows only through operators that serve a + requested expression +* **I-9** every set in ``unique_keys`` is non-empty and a subset of + the dimension column names; ``unique_keys`` are *alternative* + minimum keys at the current grain (the grain itself is always a + key — see :meth:`CalculationState.is_unique_on`) + +Violations are always raised as :class:`AlgebraError` (``E4xxx``) from +the operator that produced the state, never silently tolerated. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum, auto +from typing import TYPE_CHECKING + +from osi.common.identifiers import Identifier +from osi.common.sql_expr import FrozenSQL +from osi.common.types import DimensionSet, ExpressionId +from osi.errors import AlgebraError, ErrorCode + +if TYPE_CHECKING: + # Only used for type hints to avoid runtime dependency cycles. + pass + + +class ColumnKind(StrEnum): + """Classification of a column inside a :class:`CalculationState`. + + Drives the algebra's safety checks (``aggregate`` may only introduce + ``AGGREGATE`` columns, ``filter`` may not read one, etc.). + + .. note:: + + The Foundation deliberately does **not** distinguish a + ``TIME_DIMENSION`` from a plain :attr:`DIMENSION`. The + parser-level :class:`~osi.parsing.models.FieldKind` does (see + ``parsing/models.py``), but the *algebra* only needs to know + whether a column groups (``DIMENSION``), aggregates + (``AGGREGATE``), or carries a per-row value (``FACT``). Time + semantics — period comparisons, rolling windows, snapshot + grains — are deferred features (see `Proposed_OSI_Semantics.md §10`) and have + no algebra-level consequences in this slice. When that + changes, add ``TIME_DIMENSION`` here *and* update every + branch on :class:`ColumnKind` to handle it explicitly; do not + silently let it pattern-match as ``DIMENSION``. + """ + + DIMENSION = "dimension" + FACT = "fact" + AGGREGATE = "aggregate" + + +class Decomposability(StrEnum): + """Decomposability class for aggregation functions (Han 2001). + + ``DISTRIBUTIVE`` aggregates (``SUM``/``COUNT``/``MIN``/``MAX``) can be + re-aggregated losslessly. ``ALGEBRAIC`` aggregates + (``AVG``, ``STDDEV``) can be re-aggregated via auxiliary state. + ``HOLISTIC`` aggregates (``COUNT DISTINCT``, ``MEDIAN``) must run at + the final grain. This attribute guards the Foundation's fan-out + safety proofs (§5 of the algebra spec). + """ + + DISTRIBUTIVE = "distributive" + ALGEBRAIC = "algebraic" + HOLISTIC = "holistic" + + +class AggregateFunction(StrEnum): + """Foundation aggregation functions. + + Intentionally small. Adding a function means answering "what is its + decomposability class?" and "how does it behave under re-aggregation?" + """ + + SUM = auto() + COUNT = auto() + COUNT_DISTINCT = auto() + MIN = auto() + MAX = auto() + AVG = auto() + + @property + def decomposability(self) -> Decomposability: + """Static classification used by fan-out safety (see §5.1).""" + if self is AggregateFunction.COUNT_DISTINCT: + return Decomposability.HOLISTIC + if self is AggregateFunction.AVG: + return Decomposability.ALGEBRAIC + return Decomposability.DISTRIBUTIVE + + +@dataclass(frozen=True, slots=True) +class AggregateInfo: + """Static shape of an aggregation. + + Carried by :class:`Column` when ``kind == AGGREGATE``. The frozen + SQL expression captures the *argument* to the aggregation + (``SUM()`` etc.); ``function`` identifies which reduction. + """ + + function: AggregateFunction + argument: FrozenSQL + + +@dataclass(frozen=True, slots=True) +class Column: + """An addressable, fully-qualified output column of a state. + + Every field is immutable. ``dependencies`` records the other column + names this column's expression reads — checked by operators that + look at column-level dataflow (I-6). + """ + + name: Identifier + expression: FrozenSQL + dependencies: frozenset[Identifier] + kind: ColumnKind + aggregate: AggregateInfo | None = None + is_single_valued: bool = False + from_join_rhs: bool = False + + def __post_init__(self) -> None: + """Enforce the (kind, aggregate) contract at construction time.""" + if self.kind is ColumnKind.AGGREGATE and self.aggregate is None: + raise AlgebraError( + ErrorCode.E4001_EXPLOSION_UNSAFE, + f"AGGREGATE column {self.name!r} requires aggregate info", + context={"column": self.name}, + ) + if self.kind is not ColumnKind.AGGREGATE and self.aggregate is not None: + raise AlgebraError( + ErrorCode.E4001_EXPLOSION_UNSAFE, + f"non-aggregate column {self.name!r} has aggregate info", + context={"column": self.name, "kind": self.kind}, + ) + + +@dataclass(frozen=True, slots=True) +class CalculationState: + """The single value flowing through the algebra. + + Constructed only by :mod:`osi.planning.algebra.operations` — never + directly. The algebra package exports the operators, not this + constructor; callers who import ``CalculationState`` are expected to + use it for type annotations and structural equality. + + See the module docstring for the full invariant list. + """ + + grain: DimensionSet + columns: tuple[Column, ...] + provenance: frozenset[ExpressionId] = field(default_factory=frozenset) + unique_keys: frozenset[DimensionSet] = field(default_factory=frozenset) + + def __post_init__(self) -> None: + """Validate invariants I-1, I-2, I-6, and I-9 eagerly.""" + names = [c.name for c in self.columns] + if len(names) != len(set(names)): + seen: set[Identifier] = set() + dup: Identifier | None = None + for n in names: + if n in seen: + dup = n + break + seen.add(n) + raise AlgebraError( + ErrorCode.E3005_COLUMN_NAME_COLLISION, + f"duplicate column name {dup!r}", + context={"column": dup, "columns": names}, + ) + dimension_names = { + c.name for c in self.columns if c.kind is ColumnKind.DIMENSION + } + missing_grain = self.grain - dimension_names + if missing_grain: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"grain references non-dimension columns: {sorted(missing_grain)}", + context={"grain": sorted(self.grain), "missing": sorted(missing_grain)}, + ) + all_names = set(names) + for col in self.columns: + unknown = col.dependencies - all_names + if unknown: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"column {col.name!r} depends on unknown columns: " + f"{sorted(unknown)}", + context={"column": col.name, "missing": sorted(unknown)}, + ) + for uk in self.unique_keys: + if not uk: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + "unique_keys may not contain an empty key set", + context={"unique_keys": sorted(map(sorted, self.unique_keys))}, + ) + missing_uk = uk - dimension_names + if missing_uk: + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"unique_key references non-dimension columns: " + f"{sorted(missing_uk)}", + context={ + "unique_key": sorted(uk), + "missing": sorted(missing_uk), + }, + ) + + @property + def is_scalar(self) -> bool: + """Return whether this state has scalar grain (exactly one row).""" + return len(self.grain) == 0 + + @property + def column_names(self) -> frozenset[Identifier]: + """Set of column names in this state, cached per access.""" + return frozenset(c.name for c in self.columns) + + def column(self, name: Identifier) -> Column: + """Return the column with ``name`` or raise :class:`AlgebraError`.""" + for c in self.columns: + if c.name == name: + return c + raise AlgebraError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"no column named {name!r}", + context={"column": name, "available": sorted(self.column_names)}, + ) + + def is_unique_on(self, keys: DimensionSet) -> bool: + """Return whether ``keys`` functionally determines a single row. + + The state is unique on ``keys`` when ``keys`` is a superset of + any declared key: + + 1. the **grain** itself (always a key by I-1 / I-5), or + 2. any member of :attr:`unique_keys` (alternative minimum keys + at this grain — see I-9). + + Used by :func:`osi.planning.algebra.enrich` to discharge the + fan-trap rule and by future operators that need to prove a + join-key set covers a key. The check is *subset*: a strict + superset of a key is still a key, so wider join-key sets stay + safe. + """ + if self.grain.issubset(keys): + return True + return any(uk.issubset(keys) for uk in self.unique_keys) + + +__all__ = [ + "AggregateFunction", + "AggregateInfo", + "CalculationState", + "Column", + "ColumnKind", + "Decomposability", +] diff --git a/impl/python/src/osi/planning/classify.py b/impl/python/src/osi/planning/classify.py new file mode 100644 index 0000000..0d25169 --- /dev/null +++ b/impl/python/src/osi/planning/classify.py @@ -0,0 +1,600 @@ +"""Classify ``where`` and ``having`` predicates. + +The planner splits each boolean expression into top-level conjuncts and +assigns each conjunct to one of three buckets: + +* **row-level** — ordinary boolean over fields; compiles to ``WHERE`` + on the measure group's pre-aggregated state. +* **semi-join** — ``EXISTS_IN`` / ``NOT EXISTS_IN`` function calls. + This shape is **deferred in Foundation v0.1** (§10 / D-017) and is + only accepted when the caller opts in via + :attr:`FoundationFlags.experimental_exists_in`. With the flag on, + the predicate compiles to :func:`osi.planning.algebra.filtering_join`; + with the flag off, it is rejected with + :attr:`ErrorCode.E_DEFERRED_KEY_REJECTED`. +* **post-aggregate (having)** — conjuncts that reference measures; + compiles to ``HAVING`` on the final merged state. In the Foundation + a conjunct is post-aggregate *iff* it comes from the ``having`` slot + (§5.3 "Having vs Where" — no cross-mixing). + +Column-dataset attribution is best-effort — if a bare column reference +is ambiguous, ``E2001_AMBIGUOUS_NAME`` surfaces from +:mod:`osi.planning.resolve`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Sequence + +from sqlglot import expressions as exp + +from osi.common.identifiers import Identifier, normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.config import FoundationFlags +from osi.errors import ErrorCode, OSIError, OSIParseError, OSIPlanningError +from osi.parsing.namespace import Namespace +from osi.planning.algebra.operations import FilterMode + +# --------------------------------------------------------------------------- +# Typed predicates +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class RowLevelPredicate: + """A conjunct that reads one or more fields, no semi-joins.""" + + expression: FrozenSQL + datasets: frozenset[Identifier] + columns: frozenset[Identifier] + + +@dataclass(frozen=True, slots=True) +class SemiJoinKeyPair: + """One ``(outer_col, rhs_dataset.rhs_field)`` pair from an EXISTS_IN.""" + + outer_column: Identifier + outer_dataset: Identifier | None + rhs_dataset: Identifier + rhs_column: Identifier + + +@dataclass(frozen=True, slots=True) +class SemiJoinPredicate: + """A top-level ``EXISTS_IN`` / ``NOT EXISTS_IN`` call.""" + + pairs: tuple[SemiJoinKeyPair, ...] + mode: FilterMode + + +@dataclass(frozen=True, slots=True) +class PostAggregatePredicate: + """A ``having``-side conjunct that reads measures.""" + + expression: FrozenSQL + measures: frozenset[Identifier] + + +@dataclass(frozen=True, slots=True) +class ClassifiedWhere: + """Classification of the ``where`` predicate's top-level conjuncts.""" + + row_level: tuple[RowLevelPredicate, ...] + semi_joins: tuple[SemiJoinPredicate, ...] + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def classify_where( + predicate: FrozenSQL | None, + namespace: Namespace, + *, + flags: FoundationFlags | None = None, +) -> ClassifiedWhere: + """Split a ``where`` clause into row-level + semi-join conjuncts. + + Foundation v0.1 (D-005 / D-012a) routes by *resolved expression + shape*. ``WHERE`` is the row-level slot, so: + + * an aggregate function (``SUM``, ``COUNT``, …) anywhere in the + tree raises :attr:`ErrorCode.E_AGGREGATE_IN_WHERE`; + * a measure reference (the resolved form of a declared metric + is an aggregate) raises the same code; + * a single conjunct that mixes a bare column with an aggregate + raises :attr:`ErrorCode.E_MIXED_PREDICATE_LEVEL` so the user + sees the routing error before the placement error. + + ``EXISTS_IN`` / ``NOT EXISTS_IN`` are recognised as semi-join + conjuncts but are **deferred in Foundation v0.1** (§10 / D-017): + the conjunct is rejected with + :attr:`ErrorCode.E_DEFERRED_KEY_REJECTED` unless the caller + passed ``flags=FoundationFlags(experimental_exists_in=True)``. + The flag is named ``experimental_*`` rather than ``allow_*`` + because, unlike the other deferred constructs, ``EXISTS_IN`` does + not yet have a published OSI proposal — it lives behind the flag + as an opt-in surface for callers willing to take the portability + hit. + """ + if flags is None: + flags = FoundationFlags() + if predicate is None: + return ClassifiedWhere(row_level=(), semi_joins=()) + measure_names = _collect_measure_names(namespace) + _reject_window_in_where(predicate.expr) + _reject_mixed_predicate_level(predicate.expr, measure_names, where="where") + conjuncts = _split_conjuncts(predicate.expr) + row_level: list[RowLevelPredicate] = [] + semi_joins: list[SemiJoinPredicate] = [] + for node in conjuncts: + sj = _try_semi_join(node, flags=flags) + if sj is not None: + semi_joins.append(sj) + continue + _reject_aggregate_in_where(node, measure_names) + row_level.append(_classify_row_level(node, namespace)) + return ClassifiedWhere( + row_level=tuple(row_level), + semi_joins=tuple(semi_joins), + ) + + +def _reject_window_in_where(node: exp.Expression) -> None: + """D-028: window functions are forbidden in ``WHERE``. + + SQL standardly forbids them too, but the Foundation surfaces a + named code so the user gets actionable advice (move the predicate + to ``Having`` after wrapping the window in a metric, or use a + ``QUALIFY``-style outer-Where). + + Per Appendix C / Appendix B D-028 (`Proposed_OSI_Semantics.md` + §6.10.1): "A window function in `Where` MUST raise + `E_WINDOW_IN_WHERE`." D-030 governs the *fan-out* failure mode + (``E_WINDOW_OVER_FANOUT_REWRITE``), not where-clause placement. + """ + from osi.common.windows import contains_window + + if contains_window(node): + raise OSIPlanningError( + ErrorCode.E_WINDOW_IN_WHERE, + ( + "Where predicate contains a window function; windows " + "are only allowed in Measures, Fields, Order By, and " + "Having (D-028). Move the predicate to Having or wrap " + "the window in a metric first." + ), + context={"predicate": node.sql()}, + ) + + +def _reject_mixed_predicate_level( + node: exp.Expression, + measure_names: frozenset[Identifier], + *, + where: str, +) -> None: + """Reject the whole-predicate shape mix BEFORE per-conjunct routing. + + D-012c says a boolean predicate whose top-level tree mixes + aggregate halves and row-level halves is rejected as a single + mixed-shape error rather than as N per-conjunct placement + errors. Catching it here keeps the diagnostic readable. + """ + has_agg = _contains_aggregate(node) or ( + _first_measure_reference(node, measure_names) is not None + ) + has_row = _contains_non_aggregate_column(node, measure_names) + if has_agg and has_row: + raise OSIPlanningError( + ErrorCode.E_MIXED_PREDICATE_LEVEL, + ( + "boolean predicate mixes row-level and aggregate halves; " + "split into separate Where (row-level) and Having " + "(aggregate) clauses. See Proposed_OSI_Semantics.md " + "D-012c." + ), + context={"expression": node.sql(), "where": where}, + ) + + +def _collect_measure_names(namespace: Namespace) -> frozenset[Identifier]: + """Return every identifier that names a declared measure. + + The Foundation scopes metrics two ways: model-scoped (visible by + bare name everywhere) and table-scoped (visible by bare name when + unambiguous, or under a ``dataset.metric`` qualifier). Both forms + must be rejected from ``WHERE``. + """ + names: set[Identifier] = set(namespace.metrics.keys()) + for ds_ns in namespace.datasets.values(): + names.update(ds_ns.metrics.keys()) + return frozenset(names) + + +def _reject_aggregate_in_where( + node: exp.Expression, measure_names: frozenset[Identifier] +) -> None: + """Reject aggregate-shape conjuncts in a ``WHERE`` clause. + + Two surfaces produce a "this conjunct is an aggregate" verdict: + + 1. A SQL aggregate function call (``SUM``, ``COUNT``, ``AVG``, + …) appears in the AST. + 2. A column reference whose name matches a declared measure; + resolving the metric would yield an aggregate expression. + + Either one alone ⇒ ``E_AGGREGATE_IN_WHERE``. If the conjunct + *also* contains a row-level column reference outside the + aggregate, the user has stitched two shapes into one boolean — + that is ``E_MIXED_PREDICATE_LEVEL`` (D-012c) and takes + precedence so the diagnostic points at the right fix. + """ + has_agg = _contains_aggregate(node) + measure_hit = _first_measure_reference(node, measure_names) + if not has_agg and measure_hit is None: + return + has_row = _contains_non_aggregate_column(node, measure_names) + if has_row: + raise OSIPlanningError( + ErrorCode.E_MIXED_PREDICATE_LEVEL, + ( + "boolean predicate mixes row-level and aggregate halves; " + "split into separate Where (row-level) and Having " + "(aggregate) clauses. See Proposed_OSI_Semantics.md D-012c." + ), + context={"expression": node.sql(), "where": "where"}, + ) + if measure_hit is not None: + qualifier = f"{measure_hit[1]}." if measure_hit[1] else "" + raise OSIPlanningError( + ErrorCode.E_AGGREGATE_IN_WHERE, + ( + f"WHERE clause references measure {qualifier}{measure_hit[0]!r}; " + "measures are aggregates — move this predicate to Having. " + "See Proposed_OSI_Semantics.md D-012a." + ), + context={ + "measure": measure_hit[0], + "expression": node.sql(), + "suggestion": "having", + }, + ) + raise OSIPlanningError( + ErrorCode.E_AGGREGATE_IN_WHERE, + ( + "WHERE clause contains an aggregate function; aggregates " + "evaluate post-GROUP BY — move this predicate to Having. " + "See Proposed_OSI_Semantics.md D-012a." + ), + context={"expression": node.sql(), "suggestion": "having"}, + ) + + +def _contains_aggregate(node: exp.Expression) -> bool: + """Return True iff ``node`` (or any descendant) is a SQL aggregate call.""" + return any(isinstance(_unwrap_walk(n), exp.AggFunc) for n in node.walk()) + + +def _unwrap_walk(item: object) -> exp.Expression: + if isinstance(item, exp.Expression): + return item + if isinstance(item, tuple) and item and isinstance(item[0], exp.Expression): + return item[0] + return exp.Expression() + + +def _first_measure_reference( + node: exp.Expression, measure_names: frozenset[Identifier] +) -> tuple[Identifier, str | None] | None: + if not measure_names: + return None + for col in node.find_all(exp.Column): + try: + name = normalize_identifier(col.name) + except OSIParseError: + continue + if name in measure_names: + return (name, col.table or None) + return None + + +def _contains_non_aggregate_column( + node: exp.Expression, measure_names: frozenset[Identifier] +) -> bool: + """Return True iff ``node`` references a column **outside** every aggregate. + + Used to detect the mixed-level shape (D-012c). A column reference + that lives inside an aggregate function (e.g. ``orders.amount`` + inside ``SUM(orders.amount)``) does NOT count — the aggregate + consumes that reference. A bare column reference at the same + level as the aggregate (e.g. ``customers.region`` next to + ``SUM(...)``) DOES count. + """ + for col in node.find_all(exp.Column): + if _is_inside_aggregate(col): + continue + try: + name = normalize_identifier(col.name) + except OSIParseError: + continue + if name in measure_names: + # Measure-named column is itself an aggregate; not a + # row-level reference for the purposes of mixed-level + # detection. + continue + return True + return False + + +def _is_inside_aggregate(node: exp.Expression) -> bool: + parent = node.parent + while parent is not None: + if isinstance(parent, exp.AggFunc): + return True + parent = parent.parent + return False + + +def classify_having( + predicate: FrozenSQL | None, + measure_names: Iterable[Identifier], +) -> tuple[PostAggregatePredicate, ...]: + """Split a ``having`` clause into post-aggregate conjuncts. + + Foundation v0.1 (D-005 / D-012b / D-012c) routes by *resolved + expression shape*. ``HAVING`` is the aggregate-shape slot, so: + + * A purely row-level conjunct (no aggregate function and no + measure reference) ⇒ :attr:`ErrorCode.E_NON_AGGREGATE_IN_HAVING`. + * A conjunct that mixes a row-level column with an aggregate + ⇒ :attr:`ErrorCode.E_MIXED_PREDICATE_LEVEL` (D-012c) — wins + over the placement error so the diagnostic points at the + right fix. + """ + if predicate is None: + return () + measures = frozenset(measure_names) + _reject_mixed_predicate_level(predicate.expr, measures, where="having") + conjuncts = _split_conjuncts(predicate.expr) + out: list[PostAggregatePredicate] = [] + for node in conjuncts: + has_agg = _contains_aggregate(node) + refs = _bare_column_refs(node) + touched = refs & measures + is_aggregate_shape = has_agg or bool(touched) + if is_aggregate_shape: + out.append( + PostAggregatePredicate( + expression=FrozenSQL.of(node.copy()), + measures=touched, + ) + ) + continue + raise OSIPlanningError( + ErrorCode.E_NON_AGGREGATE_IN_HAVING, + ( + "Having conjunct is purely row-level (no aggregate); " + "push it down to Where. See Proposed_OSI_Semantics.md " + "D-012b." + ), + context={"expression": node.sql(), "suggestion": "where"}, + ) + return tuple(out) + + +# --------------------------------------------------------------------------- +# Semi-join recognition +# --------------------------------------------------------------------------- + + +def _try_semi_join( + node: exp.Expression, *, flags: FoundationFlags +) -> SemiJoinPredicate | None: + """Recognise an ``EXISTS_IN`` / ``NOT EXISTS_IN`` conjunct. + + Returns ``None`` if ``node`` is not a semi-join shape (so the + caller can fall through to row-level / aggregate handling). + + If ``node`` IS a semi-join, the flag in ``FoundationFlags`` + decides whether to admit it. Foundation v0.1 §10 / D-017 lists + semi-join filtering as deferred, so the default (flag off) is to + raise ``E_DEFERRED_KEY_REJECTED`` with a hint that the caller can + opt in via ``FoundationFlags(experimental_exists_in=True)``. The + flag intentionally uses the ``experimental_`` prefix because the + semi-join surface does not yet have a published OSI proposal. + """ + inner, negated = _unwrap_not(node) + if not isinstance(inner, exp.Anonymous): + return None + if (inner.this or "").upper() != "EXISTS_IN": + return None + if not flags.experimental_exists_in: + raise OSIPlanningError( + ErrorCode.E_DEFERRED_KEY_REJECTED, + ( + "EXISTS_IN / NOT EXISTS_IN semi-join filtering is " + "deferred in Foundation v0.1 (§10 / D-017). To " + "experiment with this engine's semi-join path, " + "parse the model with " + "FoundationFlags(experimental_exists_in=True) — note " + "that this is an experimental surface, not a " + "Foundation-conformant one." + ), + context={ + "predicate": node.sql(), + "deferred_feature": "exists_in_semi_join", + "flag": "experimental_exists_in", + }, + ) + raw_args: Sequence[exp.Expression] = tuple(inner.expressions) + if len(raw_args) < 2 or len(raw_args) % 2 != 0: + raise OSIPlanningError( + ErrorCode.E1208_UNSUPPORTED_SQL_CONSTRUCT, + "EXISTS_IN requires an even number of arguments " + "(pairs of outer_col, rhs.col)", + context={"arg_count": len(raw_args)}, + ) + pairs: list[SemiJoinKeyPair] = [] + for idx in range(0, len(raw_args), 2): + outer = raw_args[idx] + rhs = raw_args[idx + 1] + pairs.append(_build_semi_join_pair(outer=outer, rhs=rhs)) + mode = FilterMode.ANTI if negated else FilterMode.SEMI + return SemiJoinPredicate(pairs=tuple(pairs), mode=mode) + + +def _build_semi_join_pair( + *, outer: exp.Expression, rhs: exp.Expression +) -> SemiJoinKeyPair: + outer_col = _extract_column(outer) + rhs_col = _extract_column(rhs) + if rhs_col.dataset is None: + raise OSIPlanningError( + ErrorCode.E1208_UNSUPPORTED_SQL_CONSTRUCT, + "EXISTS_IN right-hand column must be qualified (dataset.field)", + context={"rhs": rhs.sql()}, + ) + return SemiJoinKeyPair( + outer_column=outer_col.name, + outer_dataset=outer_col.dataset, + rhs_dataset=rhs_col.dataset, + rhs_column=rhs_col.name, + ) + + +@dataclass(frozen=True, slots=True) +class _ColRef: + dataset: Identifier | None + name: Identifier + + +def _extract_column(node: exp.Expression) -> _ColRef: + if not isinstance(node, exp.Column): + raise OSIPlanningError( + ErrorCode.E1208_UNSUPPORTED_SQL_CONSTRUCT, + "EXISTS_IN arguments must be bare / qualified column references", + context={"node": node.sql()}, + ) + dataset = node.table or None + # Narrow exception handlers: ``normalize_identifier`` raises only + # :class:`OSIError` on bad shapes (see + # :mod:`osi.common.identifiers`). Catching a wider category here + # would mask real bugs and break the "every failure carries a + # code" contract by swallowing unrelated runtime errors. + try: + name = normalize_identifier(node.name) + except OSIError as exc: + raise OSIPlanningError( + ErrorCode.E1005_IDENTIFIER_INVALID, + f"invalid identifier in filter: {node.name!r}", + context={"name": node.name}, + ) from exc + try: + ds_id = normalize_identifier(dataset) if dataset else None + except OSIError as exc: + raise OSIPlanningError( + ErrorCode.E1005_IDENTIFIER_INVALID, + f"invalid dataset in filter: {dataset!r}", + context={"dataset": dataset}, + ) from exc + return _ColRef(dataset=ds_id, name=name) + + +def _unwrap_not(node: exp.Expression) -> tuple[exp.Expression, bool]: + if isinstance(node, exp.Not): + inner = node.this + return (inner, True) + return (node, False) + + +# --------------------------------------------------------------------------- +# Row-level classification +# --------------------------------------------------------------------------- + + +def _classify_row_level( + node: exp.Expression, namespace: Namespace +) -> RowLevelPredicate: + columns: set[Identifier] = set() + datasets: set[Identifier] = set() + for col in node.find_all(exp.Column): + try: + name = normalize_identifier(col.name) + except Exception as exc: + raise OSIPlanningError( + ErrorCode.E1005_IDENTIFIER_INVALID, + f"invalid identifier {col.name!r} in filter", + context={"name": col.name}, + ) from exc + columns.add(name) + if col.table: + try: + datasets.add(normalize_identifier(col.table)) + except Exception as exc: + raise OSIPlanningError( + ErrorCode.E1005_IDENTIFIER_INVALID, + f"invalid dataset {col.table!r} in filter", + context={"dataset": col.table}, + ) from exc + else: + datasets.add(namespace.resolve_bare(name)) + return RowLevelPredicate( + expression=FrozenSQL.of(node.copy()), + datasets=frozenset(datasets), + columns=frozenset(columns), + ) + + +# --------------------------------------------------------------------------- +# Boolean helpers +# --------------------------------------------------------------------------- + + +def _split_conjuncts(node: exp.Expression) -> tuple[exp.Expression, ...]: + if isinstance(node, exp.And): + return _split_conjuncts(node.left) + _split_conjuncts(node.right) + if isinstance(node, exp.Paren): + return _split_conjuncts(node.this) + return (node,) + + +def _bare_column_refs(node: exp.Expression) -> frozenset[Identifier]: + """Collect the set of names referenced by ``node``. + + *Both* bare and qualified references are returned by their short + name — a measure used as ``orders.total_revenue`` should still + show up under ``total_revenue`` so callers can match against + declared measures uniformly. + + Any column whose name is not a valid OSI identifier raises + :class:`OSIPlanningError` ``E1005_IDENTIFIER_INVALID``. Silently + swallowing the error here once let bad inputs sneak through and + produce confusing downstream failures; surfacing the parse error + at the place we actually inspected the column is the diagnostic + contract documented in ``ARCHITECTURE.md §5``. + """ + out: set[Identifier] = set() + for col in node.find_all(exp.Column): + try: + out.add(normalize_identifier(col.name)) + except OSIParseError as exc: + raise OSIPlanningError( + ErrorCode.E1005_IDENTIFIER_INVALID, + f"invalid identifier in predicate: {col.name!r}", + context={"name": col.name, "expression": node.sql()}, + ) from exc + return frozenset(out) + + +__all__ = [ + "ClassifiedWhere", + "PostAggregatePredicate", + "RowLevelPredicate", + "SemiJoinKeyPair", + "SemiJoinPredicate", + "classify_having", + "classify_where", +] diff --git a/impl/python/src/osi/planning/columns.py b/impl/python/src/osi/planning/columns.py new file mode 100644 index 0000000..c6a1ad9 --- /dev/null +++ b/impl/python/src/osi/planning/columns.py @@ -0,0 +1,242 @@ +"""Column / metric translation helpers for the planner. + +Pure functions that convert :mod:`osi.parsing.models` entities (Fields, +Metrics) into algebra-level :class:`Column` / :class:`AggregateInfo` +values. The planner uses them at SOURCE and AGGREGATE step construction +time. + +These live in their own module so :mod:`osi.planning.planner` stays +focused on *topology* — what flows where — rather than on the mechanics +of building individual columns. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from sqlglot import expressions as exp + +from osi.common.identifiers import Identifier, normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.errors import ErrorCode, OSIPlanningError +from osi.parsing.field_deps import field_inter_field_dependencies +from osi.parsing.models import Field, FieldRole, Metric +from osi.planning.algebra.state import ( + AggregateFunction, + AggregateInfo, + CalculationState, + Column, + ColumnKind, +) +from osi.planning.resolve import ResolvedMetric + + +def field_to_column( + field: Field, + *, + sibling_field_names: Iterable[Identifier] = (), +) -> Column: + """Convert a parsed :class:`Field` into an algebra :class:`Column`. + + ``sibling_field_names`` is the set of every field declared on the + home dataset (including ``field`` itself). It is consulted by + :func:`osi.parsing.field_deps.field_inter_field_dependencies` to + distinguish references to other fields (which become real algebra + dependencies) from references to physical columns of the dataset + (which do not). + + Default value of ``sibling_field_names`` is the empty tuple, + preserving the legacy "no sibling resolution" behaviour for the + handful of internal call sites that pre-date the staged-CTE + planner. New call sites should always pass the dataset's full + field-name set so ``add_columns`` staging can topologically + sort fields by their inter-field dependencies (see + :func:`osi.planning.steps.source_step`). + """ + deps = field_inter_field_dependencies(field, sibling_field_names) + return Column( + name=field.name, + expression=field.expression, + dependencies=deps, + kind=( + ColumnKind.FACT if field.role is FieldRole.FACT else ColumnKind.DIMENSION + ), + ) + + +def parse_metric_aggregate( + metric: Metric, +) -> tuple[AggregateFunction, tuple[exp.Column, ...]]: + """Split a top-level aggregate metric into (function, arg columns). + + Raises ``E1206_METRIC_IN_RAW_AGGREGATE`` if the expression is not a + single top-level aggregate. Composite metrics are routed through + :func:`osi.planning.metric_shape.classify_metric` instead; callers + that may be handed a composite should classify first. + """ + # Reuse the canonical classifier so one shape recogniser lives in + # one place. ``namespace`` is unused for pure aggregate detection, + # so a sentinel-Namespace is not required: instead we re-parse the + # top-level node directly. + from osi.planning.metric_shape import _as_top_level_aggregate + + agg = _as_top_level_aggregate(metric.expression.expr) + if agg is None: + raise OSIPlanningError( + ErrorCode.E1206_METRIC_IN_RAW_AGGREGATE, + f"metric {metric.name!r} must be a single top-level aggregate", + context={ + "metric": metric.name, + "expression": metric.expression.canonical, + }, + ) + return agg.function, agg.arg_columns + + +def metric_to_aggregate_column( + metric: ResolvedMetric, state: CalculationState +) -> Column: + """Build the :class:`Column` that AGGREGATE emits for ``metric``. + + ``metric`` must be an aggregate-shape metric. Composites are + expanded earlier in the planner; calling this on a composite is a + programming error (not a user error) — the planner is expected to + split them into base + derived before reaching codegen helpers. + """ + function, arg_columns = parse_metric_aggregate(metric.metric) + deps: set[Identifier] = set() + for col in arg_columns: + name = normalize_identifier(col.name) + if name not in state.column_names: + raise OSIPlanningError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"metric {metric.metric.name!r} reads column {name!r} that " + "is not addressable at the measure-group state", + context={"metric": metric.metric.name, "column": name}, + ) + deps.add(name) + argument = aggregate_argument(metric.metric, arg_columns) + return Column( + name=metric.metric.name, + expression=metric.metric.expression, + dependencies=frozenset(deps), + kind=ColumnKind.AGGREGATE, + aggregate=AggregateInfo(function=function, argument=argument), + ) + + +def metric_to_aggregate_column_from_metric( + metric: Metric, dataset: Identifier, state: CalculationState +) -> Column: + """Like :func:`metric_to_aggregate_column` but takes raw metric + dataset. + + Used when expanding composite metrics: the declared-metric + references that become base aggregates don't correspond to user + query references, so no :class:`ResolvedMetric` exists. + """ + resolved = ResolvedMetric(dataset=dataset, metric=metric) + return metric_to_aggregate_column(resolved, state) + + +def composite_to_derived_column( + name: Identifier, + metric: Metric, + dependency_names: frozenset[Identifier], +) -> Column: + """Build a derived post-aggregate :class:`Column` for a composite metric. + + The algebra disallows aggregates inside ``add_columns``, so the + composite's expression is copied verbatim (its leaves already + reference aggregate column names declared on the prior AGGREGATE + step's output). ``dependency_names`` must contain every base + aggregate name that the expression reads. + """ + # `exp.copy()` is safe here — ``add_columns`` will inspect this + # expression structurally but never mutate it. + return Column( + name=name, + expression=FrozenSQL.of(metric.expression.expr.copy()), + dependencies=dependency_names, + kind=ColumnKind.FACT, + ) + + +def composite_leaf_dependencies(metric: Metric) -> frozenset[Identifier]: + """Return the base-aggregate column names a composite metric reads. + + These are exactly the dataset-qualified or bare metric references + already resolved by :func:`classify_metric`; pulling them out of + the AST is simpler than threading a reference list through the + codebase. + """ + names: set[Identifier] = set() + for col in metric.expression.expr.find_all(exp.Column): + names.add(normalize_identifier(col.name)) + return frozenset(names) + + +def strip_column_qualifiers(expression: FrozenSQL) -> FrozenSQL: + """Return ``expression`` with every column reference's qualifier removed. + + Composite metric expressions are written as ``orders.total_revenue + / NULLIF(orders.order_count, 0)`` but are rendered downstream + against the current CTE (``step_00N``) whose columns are already + named ``total_revenue`` / ``order_count``. Stripping qualifiers in + the plan representation keeps rendering simple and keeps the + algebra dependency set consistent (one name per column). + """ + copy = expression.expr.copy() + for col in copy.find_all(exp.Column): + col.set("table", None) + return FrozenSQL.of(copy) + + +def aggregate_argument( + metric: Metric, arg_columns: tuple[exp.Column, ...] +) -> FrozenSQL: + """Return the argument expression that AGGREGATE should receive. + + For an aggregate of shape ``F()`` the returned ``FrozenSQL`` is + a *deep copy* of ```` exactly as the user wrote it — including + compound expressions like ``price * qty`` or ``CASE WHEN x THEN + amount END``. ``COUNT(*)`` is the one exception: codegen rewrites it + to ``COUNT(1)``, so we record the literal here. + + We deep-copy the subtree because codegen later mutates AST nodes + (qualifier rewriting in :func:`osi.codegen.transpiler._qualify_columns`). + The plan-side argument must not alias into the source-of-truth metric + expression. + + ``arg_columns`` is the flat list of columns the dependency analyser + extracted from the argument; it is consulted only as a fallback for + ``COUNT(DISTINCT)`` shapes whose argument lives one level deeper in + the AST. + """ + top = metric.expression.expr + if isinstance(top, exp.Count): + inner = top.this + if isinstance(inner, exp.Star): + return FrozenSQL.of(exp.Literal.number(1)) + if isinstance(inner, exp.Distinct): + # COUNT(DISTINCT ) — store the inner ````; codegen + # re-wraps it in ``Distinct(...)``. The Foundation forces a + # single inner expression at parse time, so we take it + # directly. (If the parser's contract widens to multi-arg + # ``DISTINCT``, this branch must too.) + inner_exprs = list(inner.expressions) + if len(inner_exprs) == 1: + return FrozenSQL.of(inner_exprs[0].copy()) + # Defensive: keep the whole Distinct so codegen can render it + # with all expressions, even though the path is unreachable + # in the current Foundation. + return FrozenSQL.of(inner.copy()) # pragma: no cover + _ = arg_columns + return FrozenSQL.of(top.this.copy()) + + +__all__ = [ + "aggregate_argument", + "field_to_column", + "metric_to_aggregate_column", + "parse_metric_aggregate", +] diff --git a/impl/python/src/osi/planning/home_grain.py b/impl/python/src/osi/planning/home_grain.py new file mode 100644 index 0000000..02e06be --- /dev/null +++ b/impl/python/src/osi/planning/home_grain.py @@ -0,0 +1,229 @@ +"""Implicit home-grain aggregation rewrite (D-003 + D-015). + +A field declared on dataset ``H`` whose body aggregates columns from a +*finer-grained* dataset ``F`` is implicitly evaluated **at H's grain** +(``Proposed_OSI_Semantics §4.5 form (1) + D-015``). The compilation +strategy is engine-defined; D-015 only requires the result to be +equivalent to: + +* a correlated subquery, +* a ``LATERAL`` join, or +* a pre-aggregated CTE merged back on the home key. + +This module pins the choice for the OSI Python reference implementation: +**correlated subquery**. The choice is opaque to the spec and produces +the same per-row scalar values as either alternative. + +Scope (Foundation v0.1): + +* The aggregate must reference exactly one foreign dataset. +* That foreign dataset must be related to the home dataset by a single + N:1 relationship (``F`` on the N side, ``H`` on the 1 side). +* Anything else is *not rewritten* and falls through to the planner's + pre-existing behaviour. Multi-hop / multi-dataset rewrites are + S-21's responsibility (composes with nested-aggregate planning). + +The rewrite is purely an AST → AST transformation on the field's +``FrozenSQL`` body. The algebra and codegen layers see the rewritten +expression and never know the difference. +""" + +from __future__ import annotations + +from typing import Mapping + +from sqlglot import expressions as exp + +from osi.common.identifiers import Identifier, normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.errors import OSIParseError +from osi.parsing.graph import Cardinality, RelationshipEdge, RelationshipGraph +from osi.parsing.models import Dataset, Field + + +def rewrite_field_for_home_grain( + field: Field, + *, + home: Identifier, + graph: RelationshipGraph, + datasets_by_name: Mapping[Identifier, Dataset], +) -> FrozenSQL: + """Return a copy of ``field.expression`` with cross-grain aggregates wrapped. + + For every ``exp.AggFunc`` node in the field body whose argument + columns reference exactly one foreign dataset reachable from + ``home`` via a single safe N:1 step, the aggregate is replaced by + a correlated subquery:: + + ( SELECT FROM WHERE = ) + + Aggregates that already live on ``home`` are left alone. + Aggregates we cannot resolve (multi-hop, multi-dataset, no + matching N:1 edge) are left alone too — the surrounding planner + passes will reject them with the appropriate error if needed. + """ + body = field.expression.expr + if not _has_cross_grain_aggregate(body, home=home): + return field.expression + + new_body = body.copy() + # Walk every aggregate, deepest-first so nested rewrites don't + # interfere with each other. ``find_all`` yields parents before + # children, so we reverse for safety even though Foundation v0.1 + # does not allow nested aggregates today. + aggregates = list(new_body.find_all(exp.AggFunc)) + top_replacement: exp.Expression | None = None + for agg in aggregates: + foreign_datasets = _foreign_datasets_in(agg, home=home) + if len(foreign_datasets) != 1: + continue + foreign = next(iter(foreign_datasets)) + edge = _find_n1_edge(home=home, foreign=foreign, graph=graph) + if edge is None: + continue + if foreign not in datasets_by_name: + continue + subquery = _build_correlated_subquery( + agg=agg, + home=home, + foreign=foreign, + edge=edge, + foreign_dataset=datasets_by_name[foreign], + ) + if agg is new_body: + # ``Expression.replace`` mutates the *parent*; when the + # aggregate is the field's top-level expression there is + # no parent and we have to swap the body wholesale. + top_replacement = subquery + else: + agg.replace(subquery) + if top_replacement is not None: + new_body = top_replacement + return FrozenSQL.of(new_body) + + +# --------------------------------------------------------------------------- +# Detection +# --------------------------------------------------------------------------- + + +def _has_cross_grain_aggregate(body: exp.Expression, *, home: Identifier) -> bool: + """Cheap pre-check used to skip the deep rewrite when nothing matches.""" + for agg in body.find_all(exp.AggFunc): + if _foreign_datasets_in(agg, home=home): + return True + return False + + +def _foreign_datasets_in( + node: exp.Expression, *, home: Identifier +) -> frozenset[Identifier]: + """Return the set of dataset qualifiers in ``node`` that are not ``home``.""" + out: set[Identifier] = set() + for col in node.find_all(exp.Column): + if not col.table: + continue + try: + ds = normalize_identifier(col.table) + except OSIParseError: + continue + if ds != home: + out.add(ds) + return frozenset(out) + + +def _find_n1_edge( + *, + home: Identifier, + foreign: Identifier, + graph: RelationshipGraph, +) -> RelationshipEdge | None: + """Return the unique edge where ``foreign`` is N and ``home`` is 1. + + Returns ``None`` if there is no such edge or there are multiple + candidates (the planner's path-finder will surface the ambiguity + when the field is actually used). + """ + candidates: list[RelationshipEdge] = [] + for edge in graph.neighbors(home): + if edge.cardinality is Cardinality.N_TO_N: + continue + if edge.cardinality is Cardinality.N_TO_ONE: + # foreign sits on the N (from) side, home on the 1 (to) side + if edge.from_dataset == foreign and edge.to_dataset == home: + candidates.append(edge) + continue + if edge.cardinality is Cardinality.ONE_TO_ONE: + if {edge.from_dataset, edge.to_dataset} == {home, foreign}: + candidates.append(edge) + if len(candidates) == 1: + return candidates[0] + return None + + +# --------------------------------------------------------------------------- +# Subquery construction +# --------------------------------------------------------------------------- + + +def _build_correlated_subquery( + *, + agg: exp.AggFunc, + home: Identifier, + foreign: Identifier, + edge: RelationshipEdge, + foreign_dataset: Dataset, +) -> exp.Subquery: + """Build ``(SELECT FROM WHERE )``. + + The correlation predicate ANDs ``foreign.fk = home.pk`` over every + pair in ``edge``. We use the foreign dataset's *physical source* + name in the FROM (matching what the source step would emit) and + the home dataset's *logical name* in the correlation — the + surrounding source-step SELECT runs from the same logical name, + so the correlated reference resolves there. + """ + inner_select = exp.Select() + inner_select.set("expressions", [agg.copy()]) + foreign_table = exp.to_table(foreign_dataset.source or str(foreign)) + inner_select.set("from", exp.From(this=foreign_table)) + correlation = _build_correlation_predicate( + home=home, + foreign=foreign, + edge=edge, + ) + inner_select.set("where", exp.Where(this=correlation)) + return exp.Subquery(this=inner_select) + + +def _build_correlation_predicate( + *, + home: Identifier, + foreign: Identifier, + edge: RelationshipEdge, +) -> exp.Expression: + """AND ``foreign.fk = home.pk`` for every column pair in ``edge``.""" + if edge.from_dataset == foreign: + foreign_cols = edge.from_columns + home_cols = edge.to_columns + else: + foreign_cols = edge.to_columns + home_cols = edge.from_columns + pairs = list(zip(foreign_cols, home_cols, strict=True)) + conds: list[exp.Expression] = [] + for fcol, hcol in pairs: + conds.append( + exp.EQ( + this=exp.column(str(fcol), table=str(foreign)), + expression=exp.column(str(hcol), table=str(home)), + ) + ) + if len(conds) == 1: + return conds[0] + out = conds[0] + for c in conds[1:]: + out = exp.And(this=out, expression=c) + return out + + +__all__ = ["rewrite_field_for_home_grain"] diff --git a/impl/python/src/osi/planning/joins.py b/impl/python/src/osi/planning/joins.py new file mode 100644 index 0000000..cdfc882 --- /dev/null +++ b/impl/python/src/osi/planning/joins.py @@ -0,0 +1,509 @@ +"""Join-path resolution and cardinality-driven safety checks. + +Consumed by :mod:`osi.planning.planner` during query planning. Exposes +two pure helpers over an already-built :class:`RelationshipGraph`: + +* :func:`find_enrichment_path` — given a fact root dataset and a set of + target datasets that need to be joined in (because their dimensions + or facts are referenced), return a sequence of + :class:`JoinStep` describing a safe N:1 enrichment chain. +* :func:`assert_m_n_rejected` — inspect every edge on the returned + path and raise :class:`OSIPlanningError` with + :attr:`ErrorCode.E3011_MN_AGGREGATION_REJECTED` if any edge is N:N. + +Ambiguity surfaces as :attr:`ErrorCode.E3001_AMBIGUOUS_JOIN_PATH`; +unreachable targets as :attr:`ErrorCode.E2004_UNREACHABLE_DATASET`. + +This module never produces a :class:`~osi.planning.algebra.state.CalculationState`. +It only returns declarative descriptions the planner then hands to the +algebra operators. +""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass + +from osi.common.identifiers import Identifier +from osi.common.types import DimensionSet +from osi.errors import ErrorCode, OSIPlanningError +from osi.parsing.graph import Cardinality, RelationshipEdge, RelationshipGraph +from osi.planning.algebra.operations import JoinType + + +@dataclass(frozen=True, slots=True) +class JoinStep: + """Enrichment step bringing ``child`` into a state rooted at ``parent``. + + ``keys`` lives on the parent side (the join's LHS columns). The + :class:`JoinType` is chosen from the declared referential-integrity + hints on the edge: ``INNER`` when ``from_all_rows_match`` is known, + else ``LEFT`` (preserves parent rows). + + ``parent_keys`` and ``child_keys`` carry the positional key pairing + across the relationship — they're ordered sequences so a composite + key like ``(a, b) ↔ (x, y)`` round-trips safely into codegen. The + frozenset ``keys`` field is retained for the algebra, which only + needs parent-side addressability. + """ + + parent: Identifier + child: Identifier + keys: DimensionSet + child_columns: DimensionSet + join_type: JoinType + edge: RelationshipEdge + parent_keys: tuple[Identifier, ...] = () + child_keys: tuple[Identifier, ...] = () + + +def find_enrichment_path( + *, + root: Identifier, + targets: frozenset[Identifier], + graph: RelationshipGraph, + allowed_relationships: frozenset[Identifier] | None = None, +) -> tuple[JoinStep, ...]: + """Return an enrichment chain from ``root`` covering every ``target``. + + Rules + ----- + * Each step is a single N:1 (or 1:1) edge. Multi-hop chains are + synthesised by walking intermediate datasets that sit on the + shortest path between the visited set and an outstanding target, + even when those intermediates are not themselves in ``targets`` + (spec §6.6 transitive enrichment). + * Among outstanding targets, the one with the shortest distance + from the visited set is picked first; the first edge of its + shortest path is emitted. Ties are broken by alphabetical target + name. + * If any target is unreachable, raise ``E2004_UNREACHABLE_DATASET``. + * If any edge on the returned path is N:N, raise + ``E3011_MN_AGGREGATION_REJECTED``. + * If more than one equal-length shortest path from the visited set + reaches the same closest target via *distinct first edges*, the + path is ambiguous and raises ``E3001_AMBIGUOUS_JOIN_PATH``. + + ``allowed_relationships`` (``Proposed_OSI_Semantics.md §6.7``) + restricts the candidate edges to the named relationships. The + planner threads this set down from every measure's + ``metric.joins.using_relationships`` declaration. ``None`` means + "no restriction"; any other value is interpreted as a hard + whitelist — edges not in the set are invisible to BFS. + """ + if not targets: + return () + outstanding = set(targets) - {root} + if not outstanding: + return () + visited: set[Identifier] = {root} + steps: list[JoinStep] = [] + while outstanding: + step = _next_step( + visited=frozenset(visited), + outstanding=frozenset(outstanding), + graph=graph, + allowed_relationships=allowed_relationships, + ) + steps.append(step) + visited.add(step.child) + outstanding.discard(step.child) + return tuple(steps) + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class _PathInfo: + """Per-node BFS state. + + Stores the distance from the visited set plus every predecessor + ``(parent, edge)`` pair that realises a shortest path. + """ + + distance: int + predecessors: tuple[tuple[Identifier, RelationshipEdge], ...] + + +def _next_step( + *, + visited: frozenset[Identifier], + outstanding: frozenset[Identifier], + graph: RelationshipGraph, + allowed_relationships: frozenset[Identifier] | None = None, +) -> JoinStep: + """Pick the next enrichment step via shortest-path BFS. + + BFS from the frontier ``visited`` finds the closest outstanding + target ``closest``. We then emit the first edge of a shortest path + from ``visited`` to ``closest``. Intermediate datasets not in + ``outstanding`` become visited implicitly as later iterations walk + through them. Ambiguity (distinct first edges on equal-length + shortest paths to the same closest target) raises ``E3001``. + + ``allowed_relationships`` restricts BFS to the named relationships + only — see :func:`find_enrichment_path`. + """ + info = _bfs_from_visited( + visited=visited, + graph=graph, + allowed_relationships=allowed_relationships, + ) + + reachable = [t for t in outstanding if t in info] + if not reachable: + missing = sorted(str(t) for t in outstanding) + raise OSIPlanningError( + ErrorCode.E2004_UNREACHABLE_DATASET, + f"cannot reach datasets {missing} from current join state", + context={"missing": missing, "visited": sorted(str(v) for v in visited)}, + ) + + min_d = min(info[t].distance for t in reachable) + closest = sorted((t for t in reachable if info[t].distance == min_d), key=str)[0] + + # Collect every distinct *first edge* across all shortest paths to + # ``closest``. A first edge is any edge whose parent is in + # ``visited`` that lies on some shortest visited→closest path. + first_edges = _first_edges_on_shortest_paths( + node=closest, visited=visited, info=info + ) + distinct_edge_names = {e.name for _, e in first_edges} + if len(distinct_edge_names) > 1: + raise OSIPlanningError( + ErrorCode.E3001_AMBIGUOUS_JOIN_PATH, + f"multiple relationships reach {closest!r}: " + f"{sorted(distinct_edge_names)}. Restructure the model or " + "rename the conflicting relationships so exactly one path " + "resolves at home grain. (Per-metric " + "``joins.using_relationships`` disambiguation is deferred " + "in Foundation v0.1 §10 / D-009 and would itself be " + "rejected with E_DEFERRED_KEY_REJECTED.)", + context={ + "child": closest, + "candidates": sorted(distinct_edge_names), + }, + ) + + # All shortest paths share the same first edge; pick a canonical + # (parent, edge) pair deterministically. + parent, edge = sorted(first_edges, key=lambda pe: (str(pe[0]), str(pe[1].name)))[0] + if not _is_safe_direction(edge, parent=parent): + target = edge.to_dataset if edge.from_dataset == parent else edge.from_dataset + raise _classify_unsafe_step( + parent=parent, target=target, edge=edge, graph=graph + ) + target = edge.to_dataset if edge.from_dataset == parent else edge.from_dataset + return _build_step(parent=parent, target=target, edge=edge) + + +def _classify_unsafe_step( + *, + parent: Identifier, + target: Identifier, + edge: RelationshipEdge, + graph: RelationshipGraph, +) -> OSIPlanningError: + """Pick the most specific M:N error for an unsafe enrichment step. + + Per ``Proposed_OSI_Semantics.md §6.5``: + + * Declared N:N with no bridge / stitch route → ``E3012``. + * Declared N:N where a bridge or stitch could resolve → + ``E3012`` with the resolution surfaced in the message (the + planner cannot synthesise the resolution itself today; it will + learn to in a follow-up sprint). + * Fan-trap (walking an N:1 edge from the 1-side) → keep + ``E3011``: the user asked for an unrunnable direction; the fix + is to flip dim/measure roles, not to stitch. + """ + if edge.cardinality is Cardinality.N_TO_N: + bridge = _find_bridge(parent, target, graph) + stitch = reachable_via_n1(parent, graph) & reachable_via_n1(target, graph) + stitch -= {parent, target} + suggestions: list[str] = [] + if bridge: + candidates = sorted(str(b) for b in bridge) + suggestions.append(f"introduce a bridge dataset (candidate: {candidates})") + if stitch: + suggestions.append( + "rewrite as a stitch query against shared dimension(s) " + f"{sorted(str(s) for s in stitch)} — " + "drop the cross-edge measure and group by the shared dim" + ) + suggestions.append( + f"wrap the traversal in EXISTS_IN({parent}.k, {target}.k) " + "to convert it to a semi-join filter" + ) + return OSIPlanningError( + ErrorCode.E3012_MN_NO_SAFE_REWRITE, + ( + f"relationship {edge.name!r} between {parent!r} and " + f"{target!r} is N:N; no bridge / stitch / filter route " + f"resolves it. Try: {'; '.join(suggestions)}." + ), + context={ + "relationship": edge.name, + "from": str(edge.from_dataset), + "to": str(edge.to_dataset), + "bridge_candidates": sorted(str(b) for b in bridge), + "stitch_candidates": sorted(str(s) for s in stitch), + }, + ) + return OSIPlanningError( + ErrorCode.E3011_MN_AGGREGATION_REJECTED, + f"relationship {edge.name!r} cannot be traversed from " + f"{parent!r} as an enrichment step (cardinality " + f"{edge.cardinality.value}); this direction would fan out " + "the parent rows", + context={ + "relationship": edge.name, + "parent": parent, + "cardinality": edge.cardinality.value, + }, + ) + + +def _find_bridge( + a: Identifier, b: Identifier, graph: RelationshipGraph +) -> frozenset[Identifier]: + """Datasets that have a safe-direction edge to *both* ``a`` and ``b``. + + Per ``§6.5.1`` a bridge is "any dataset with declared N:1 + relationships to two or more other datasets." Discovery is purely + cardinality-driven — the optional ``role: bridge`` annotation is + diagnostic only. + """ + candidates: set[Identifier] = set() + for edge in graph.edges: + # Each edge contributes a candidate parent ↦ child if the + # parent->child direction is safe-enrichment-compatible. + for parent, child in ( + (edge.from_dataset, edge.to_dataset), + (edge.to_dataset, edge.from_dataset), + ): + if _is_safe_direction(edge, parent=parent) and child in (a, b): + candidates.add(parent) + bridges = { + c + for c in candidates + if a in reachable_via_n1(c, graph) + and b in reachable_via_n1(c, graph) + and c not in (a, b) + } + return frozenset(bridges) + + +def _bfs_from_visited( + *, + visited: frozenset[Identifier], + graph: RelationshipGraph, + allowed_relationships: frozenset[Identifier] | None = None, +) -> dict[Identifier, _PathInfo]: + """Unweighted BFS from the frontier on the *undirected* graph. + + We explore all edges regardless of cardinality / direction, so the + planner can surface the precise reason a target is unreachable via + a safe enrichment path: an N:N edge on the only path raises + ``E3011``, a fan-trap direction raises ``E3011``, and a genuinely + disconnected target raises ``E2004``. Safety is checked at step + extraction in :func:`_next_step`, not during BFS itself. + + ``allowed_relationships`` restricts which edges BFS considers. An + edge whose name is not in the allowed set is skipped entirely, so + a target reachable only through forbidden edges falls out as + ``E2004_UNREACHABLE_DATASET`` — the same way a genuinely + disconnected target would. + """ + info: dict[Identifier, _PathInfo] = { + v: _PathInfo(distance=0, predecessors=()) for v in visited + } + queue: deque[Identifier] = deque(sorted(visited, key=str)) + while queue: + node = queue.popleft() + d = info[node].distance + for edge in graph.neighbors(node): + if ( + allowed_relationships is not None + and edge.name not in allowed_relationships + ): + continue + for nxt in _outgoing_endpoints(edge, node): + if nxt in visited: + continue + nd = d + 1 + existing = info.get(nxt) + if existing is None: + info[nxt] = _PathInfo(distance=nd, predecessors=((node, edge),)) + queue.append(nxt) + elif nd == existing.distance: + info[nxt] = _PathInfo( + distance=existing.distance, + predecessors=existing.predecessors + ((node, edge),), + ) + # nd > existing.distance: ignore (not a shortest path). + return info + + +def _is_safe_direction(edge: RelationshipEdge, *, parent: Identifier) -> bool: + """Return whether ``parent -> other`` via ``edge`` is a safe enrichment. + + Safe iff the edge is N:1 with ``parent`` on the N-side, or 1:1 in + either direction. N:N is never safe, and traversing an N:1 edge + from the 1-side to the N-side is a fan trap. + """ + if edge.cardinality is Cardinality.N_TO_N: + return False + if edge.cardinality is Cardinality.ONE_TO_ONE: + return edge.from_dataset == parent or edge.to_dataset == parent + return edge.from_dataset == parent # N_TO_ONE, N-side → 1-side only + + +def _first_edges_on_shortest_paths( + *, + node: Identifier, + visited: frozenset[Identifier], + info: dict[Identifier, _PathInfo], +) -> set[tuple[Identifier, RelationshipEdge]]: + """Collect every distinct first-edge that starts a shortest path. + + Walk all shortest paths back to the visited frontier and return + every ``(parent_in_visited, edge)`` that begins one. Edges are + identified by ``.name`` in the caller; the tuple lets the caller + reconstruct the step. + """ + out: set[tuple[Identifier, RelationshipEdge]] = set() + stack: list[Identifier] = [node] + seen: set[Identifier] = set() + while stack: + cur = stack.pop() + if cur in seen: + continue + seen.add(cur) + for parent, edge in info[cur].predecessors: + if parent in visited: + out.add((parent, edge)) + else: + stack.append(parent) + return out + + +def _outgoing_endpoints( + edge: RelationshipEdge, from_: Identifier +) -> tuple[Identifier, ...]: + if edge.from_dataset == from_: + return (edge.to_dataset,) + if edge.to_dataset == from_: + return (edge.from_dataset,) + return () + + +def reachable_via_n1( + root: Identifier, graph: RelationshipGraph +) -> frozenset[Identifier]: + """Datasets reachable from ``root`` by walking only safe-direction edges. + + "Safe-direction" means N:1 from ``root``'s side or 1:1 in either + direction — the same predicate :func:`_is_safe_direction` uses to + pick enrichment steps. Used by the planner's M:N classifier to + decide whether two endpoints share a *stitching dimension* + (``Proposed_OSI_Semantics.md §6.5.2``). + + The result includes ``root`` itself. + """ + visited: set[Identifier] = {root} + queue: deque[Identifier] = deque([root]) + while queue: + node = queue.popleft() + for edge in graph.neighbors(node): + if not _is_safe_direction(edge, parent=node): + continue + for nxt in _outgoing_endpoints(edge, node): + if nxt in visited: + continue + visited.add(nxt) + queue.append(nxt) + return frozenset(visited) + + +def datasets_connected(a: Identifier, b: Identifier, graph: RelationshipGraph) -> bool: + """Return ``True`` iff ``a`` and ``b`` are in the same connected component. + + Direction-agnostic — used to decide whether two facts could in + principle share a stitching dimension (which requires the graph + to be connected through them). + """ + if a == b: + return True + visited: set[Identifier] = {a} + queue: deque[Identifier] = deque([a]) + while queue: + node = queue.popleft() + for edge in graph.neighbors(node): + for nxt in _outgoing_endpoints(edge, node): + if nxt in visited: + continue + if nxt == b: + return True + visited.add(nxt) + queue.append(nxt) + return False + + +def _build_step( + *, parent: Identifier, target: Identifier, edge: RelationshipEdge +) -> JoinStep: + if edge.from_dataset == parent and edge.to_dataset == target: + parent_cols = tuple(edge.from_columns) + child_cols = tuple(edge.to_columns) + join_type = _choose_join_type( + parent_all_match=edge.from_all_rows_match, + to_all_match=edge.to_all_rows_match, + ) + else: + # Reverse direction (target declared as ``from``, current parent + # as ``to``). Only safe when the reverse edge is N:1, i.e. the + # original was 1:N — which is not N:1 from ``parent`` to + # ``target``; we detect M:N separately. + parent_cols = tuple(edge.to_columns) + child_cols = tuple(edge.from_columns) + join_type = _choose_join_type( + parent_all_match=edge.to_all_rows_match, + to_all_match=edge.from_all_rows_match, + ) + return JoinStep( + parent=parent, + child=target, + keys=frozenset(parent_cols), + child_columns=frozenset(child_cols), + join_type=join_type, + edge=edge, + parent_keys=parent_cols, + child_keys=child_cols, + ) + + +def _choose_join_type(*, parent_all_match: bool, to_all_match: bool) -> JoinType: + _ = to_all_match # reserved: bias to INNER when both sides match + return JoinType.INNER if parent_all_match else JoinType.LEFT + + +def _reject_m_n(edge: RelationshipEdge) -> None: + if edge.cardinality is Cardinality.N_TO_N: + raise OSIPlanningError( + ErrorCode.E3011_MN_AGGREGATION_REJECTED, + f"relationship {edge.name!r} is N:N; semantic enrich " + "requires N:1 or 1:1", + context={"relationship": edge.name}, + ) + + +__all__ = [ + "JoinStep", + "datasets_connected", + "find_enrichment_path", + "reachable_via_n1", +] diff --git a/impl/python/src/osi/planning/metric_dispatch.py b/impl/python/src/osi/planning/metric_dispatch.py new file mode 100644 index 0000000..372131d --- /dev/null +++ b/impl/python/src/osi/planning/metric_dispatch.py @@ -0,0 +1,129 @@ +"""Metric → fact-dataset dispatch. + +Given a :class:`ResolvedMetric`, this module computes which dataset its +expression actually reads from. The Foundation requires that every +metric resolve to *exactly one* fact dataset: + +* For an aggregate metric (``SUM(orders.amount)``), the aggregate's + argument columns must all live on the same dataset, otherwise we + emit ``E1209_CROSS_DATASET_AD_HOC_AGGREGATE`` — composing across + datasets requires explicit per-dataset metrics first. +* For a composite metric (``revenue - returns``), each referenced base + metric is resolved recursively and its fact must agree. +* For a count-star or argument-less metric whose dataset cannot be + inferred, we emit ``E1212_COUNT_STAR_AMBIGUOUS``. + +Extracted from ``planner.py`` in S-11 to keep the planner under the +600 LOC cleanliness gate and to make this dispatch independently +mockable in tests. +""" + +from __future__ import annotations + +from sqlglot import expressions as exp + +from osi.common.identifiers import Identifier, normalize_identifier +from osi.errors import ErrorCode, OSIPlanningError +from osi.planning.metric_shape import ( + AggregateMetric, + CompositeMetric, + classify_metric, + resolve_metric_by_name, +) +from osi.planning.planner_context import PlannerContext +from osi.planning.resolve import ResolvedMetric + + +def metric_fact_dataset(m: ResolvedMetric, context: PlannerContext) -> Identifier: + """Determine which dataset ``m``'s expression reads from. + + For an aggregate metric: the aggregate argument's column references + must resolve to fields of exactly one dataset. Mixed-dataset + metrics raise ``E1209_CROSS_DATASET_AD_HOC_AGGREGATE``. + + For a composite metric (``§5.4``): every referenced base metric's + fact dataset must agree; mismatches also raise ``E1209``. + """ + if m.dataset is not None: + return m.dataset + shape = classify_metric(m.metric, context.namespace) + if isinstance(shape, AggregateMetric): + return _aggregate_fact_dataset( + metric_name=m.metric.name, + arg_columns=shape.arg_columns, + context=context, + ) + candidates = _composite_fact_datasets(shape, context) + if not candidates: + raise OSIPlanningError( + ErrorCode.E1212_COUNT_STAR_AMBIGUOUS, + f"composite metric {m.metric.name!r} has no resolvable fact dataset", + context={"metric": m.metric.name}, + ) + if len(candidates) > 1: + raise OSIPlanningError( + ErrorCode.E1209_CROSS_DATASET_AD_HOC_AGGREGATE, + f"composite metric {m.metric.name!r} references base metrics from " + f"multiple datasets {sorted(str(c) for c in candidates)}; split " + "into per-dataset composites", + context={ + "metric": m.metric.name, + "datasets": sorted(str(c) for c in candidates), + }, + ) + return next(iter(candidates)) + + +def _aggregate_fact_dataset( + *, + metric_name: Identifier, + arg_columns: tuple[exp.Column, ...], + context: PlannerContext, +) -> Identifier: + candidates: set[Identifier] = set() + for col in arg_columns: + if col.table: + candidates.add(normalize_identifier(col.table)) + continue + candidates.add(context.namespace.resolve_bare(normalize_identifier(col.name))) + if not candidates: + raise OSIPlanningError( + ErrorCode.E1212_COUNT_STAR_AMBIGUOUS, + f"metric {metric_name!r} has no fact columns; " + "declare it on a dataset or add an explicit argument", + context={"metric": metric_name}, + ) + if len(candidates) > 1: + raise OSIPlanningError( + ErrorCode.E1209_CROSS_DATASET_AD_HOC_AGGREGATE, + f"metric {metric_name!r} reads fields from multiple datasets " + f"{sorted(str(c) for c in candidates)}; decompose into per-dataset " + "metrics first", + context={ + "metric": metric_name, + "datasets": sorted(str(c) for c in candidates), + }, + ) + return next(iter(candidates)) + + +def _composite_fact_datasets( + composite: CompositeMetric, context: PlannerContext +) -> set[Identifier]: + candidates: set[Identifier] = set() + for ref in composite.references: + ref_metric, owner = resolve_metric_by_name( + name=ref.name, dataset=ref.dataset, namespace=context.namespace + ) + if owner is not None: + candidates.add(owner) + continue + candidates.add( + metric_fact_dataset( + ResolvedMetric(dataset=None, metric=ref_metric), context + ) + ) + return candidates + + +__all__ = ["metric_fact_dataset"] diff --git a/impl/python/src/osi/planning/metric_shape.py b/impl/python/src/osi/planning/metric_shape.py new file mode 100644 index 0000000..ca5460b --- /dev/null +++ b/impl/python/src/osi/planning/metric_shape.py @@ -0,0 +1,341 @@ +"""Metric classification — aggregate vs. composite. + +The Foundation supports two metric shapes (``Proposed_OSI_Semantics.md +§5.4``): + +1. **Aggregate metric** — top-level expression is a single aggregate + function (``SUM``, ``COUNT``, ``COUNT(DISTINCT …)``, ``COUNT(*)``, + ``MIN``, ``MAX``, ``AVG``) applied to a fact expression. This is + the base case — it produces a column under ``aggregate()``. + +2. **Composite metric** — an arithmetic expression whose every leaf + reference names another declared metric. Composites implement + ratios, percentages, and deltas and are computed *after* + :func:`~osi.planning.algebra.operations.aggregate` via + :func:`~osi.planning.algebra.operations.add_columns`. + +Anything else (bare-fact references in non-aggregate context, nested +aggregate functions, references to undeclared names) is a hard +``E1206`` failure. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from sqlglot import expressions as exp + +from osi.common.identifiers import Identifier, normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.errors import ErrorCode, OSIParseError, OSIPlanningError +from osi.parsing.models import Metric +from osi.parsing.namespace import Namespace +from osi.planning.algebra.state import AggregateFunction + +_AGG_BY_AST: dict[type[exp.Expression], AggregateFunction] = { + exp.Sum: AggregateFunction.SUM, + exp.Count: AggregateFunction.COUNT, + exp.Min: AggregateFunction.MIN, + exp.Max: AggregateFunction.MAX, + exp.Avg: AggregateFunction.AVG, +} + + +@dataclass(frozen=True, slots=True) +class AggregateMetric: + """A metric whose expression is a top-level aggregate function.""" + + function: AggregateFunction + arg_columns: tuple[exp.Column, ...] + + +@dataclass(frozen=True, slots=True) +class MetricRef: + """A reference to another declared metric used inside a composite.""" + + name: Identifier + dataset: Identifier | None + + +@dataclass(frozen=True, slots=True) +class CompositeMetric: + """An arithmetic combination of other declared metrics (``§5.4``). + + The inlined reference list is in source order for deterministic + planning. ``expression`` is the original AST; every + :class:`~sqlglot.expressions.Column` leaf corresponds to one entry + in ``references``. + """ + + expression: FrozenSQL + references: tuple[MetricRef, ...] + + +MetricShape = AggregateMetric | CompositeMetric + + +def classify_metric(metric: Metric, namespace: Namespace) -> MetricShape: + """Determine whether ``metric`` is aggregate or composite. + + Raises :class:`OSIPlanningError` with + :attr:`ErrorCode.E1206_METRIC_IN_RAW_AGGREGATE` for any shape the + Foundation does not accept (undeclared reference, mixed shape, + nested aggregate inside a composite, etc.). A top-level aggregate + whose function is in the OSI_SQL_2026 parse whitelist but does not + yet have a planner lowering (``MEDIAN``, ``STDDEV``, + ``PERCENTILE_CONT``, …) is rejected here with + :attr:`ErrorCode.E1208_UNSUPPORTED_SQL_CONSTRUCT` so that the + diagnostic surface matches the architectural reality (the parser + knows the function, the planner does not yet model it). This + closes the Phase 8 finding I1 — without it the composite path + fires later with the misleading + ``E1206_METRIC_IN_RAW_AGGREGATE`` message. + + A metric whose body root is :class:`exp.Window` (a windowed + expression — ``ROW_NUMBER() OVER (...)``, ``SUM(x) OVER (...)``, + …) is rejected here with + :attr:`ErrorCode.E_WINDOWED_MEASURE_NOT_SUPPORTED` (F-16). The + spec (`§6.10` / D-031) accepts direct use of a windowed metric in + ``Measures``, but this engine's aggregation branch does not yet + model that composition. The scalar planner + (:mod:`osi.planning.planner_scalar`) compiles windowed metrics as + :class:`PlanOperation.ADD_COLUMNS` over the home dataset and never + calls :func:`classify_metric`, so this gate fires only in the + aggregation path. + """ + top = metric.expression.expr + _reject_windowed_root(metric=metric, top=top) + _reject_unsupported_top_level_aggregate(metric=metric, top=top) + agg = _as_top_level_aggregate(top) + if agg is not None: + return agg + refs = _collect_composite_refs(metric=metric, expression=top, namespace=namespace) + _reject_nested_aggregates(metric=metric, expression=top) + return CompositeMetric(expression=metric.expression, references=refs) + + +def _reject_windowed_root(*, metric: Metric, top: exp.Expression) -> None: + """Reject a metric whose body's root is a window expression. + + F-16: the aggregation planner does not yet model windowed + measures (§6.10 / D-031 accepts them but our engine doesn't + implement the composition with GROUP BY / re-aggregation yet). + Without this gate the metric falls into the composite path and + raises the misleading ``E1206_METRIC_IN_RAW_AGGREGATE`` — + pointing the author at the wrong surface. + + Scalar (``Fields``) queries are unaffected: the scalar planner + compiles windowed metrics directly as ``ADD_COLUMNS`` and never + calls this classifier. + """ + if not isinstance(top, exp.Window): + return + raise OSIPlanningError( + ErrorCode.E_WINDOWED_MEASURE_NOT_SUPPORTED, + ( + f"metric {metric.name!r} is windowed (its body is a " + "window expression) and is being used in an aggregation " + "context. Spec §6.10 / D-031 accepts direct use of a " + "windowed metric in ``Measures``, but this engine's " + "aggregation planner does not yet model the composition " + "of windowed measures with GROUP BY. Use a scalar " + "(Fields-only) query to expose this metric, or replace " + "the window with a plain aggregate." + ), + context={ + "metric": metric.name, + "shape": "windowed", + "spec_ref": "Proposed_OSI_Semantics.md §6.10 / D-031", + }, + ) + + +def _reject_unsupported_top_level_aggregate( + *, metric: Metric, top: exp.Expression +) -> None: + """Reject whitelisted-but-unsupported aggregates at the metric root. + + A metric body whose root AST node is a SQLGlot aggregate function + (``exp.AggFunc``) but not one of the five operators the planner + models (``SUM`` / ``COUNT`` / ``MIN`` / ``MAX`` / ``AVG``) is + rejected with ``E1208`` so authors do not see a confusing + ``E1206_METRIC_IN_RAW_AGGREGATE`` message later from the composite + path. ``COUNT(*)`` / ``COUNT(DISTINCT …)`` are handled by the + ``exp.Count`` branch and so reach this check as ``exp.Count``. + """ + if not isinstance(top, exp.AggFunc): + return + if type(top) in _AGG_BY_AST: + return + raise OSIPlanningError( + ErrorCode.E1208_UNSUPPORTED_SQL_CONSTRUCT, + ( + f"metric {metric.name!r} uses aggregate function " + f"{top.key.upper()!r}; the OSI_SQL_2026 parse whitelist " + "admits this function in expressions, but the planner " + "currently models only SUM / COUNT / MIN / MAX / AVG / " + "COUNT(DISTINCT) at the metric root. Decompose the metric " + "(e.g. AVG instead of MEDIAN), or use one of the supported " + "aggregates." + ), + context={"metric": metric.name, "function": top.key.upper()}, + ) + + +def _as_top_level_aggregate(top: exp.Expression) -> AggregateMetric | None: + """Return an :class:`AggregateMetric` for a Foundation aggregate, else None. + + Recognises the seven Foundation aggregate shapes (``SUM``, ``AVG``, + ``MIN``, ``MAX``, ``COUNT``, ``COUNT(*)``, ``COUNT(DISTINCT …)``). + """ + func = _AGG_BY_AST.get(type(top)) + if func is None: + return None + if func is AggregateFunction.COUNT: + arg = top.this + if isinstance(arg, exp.Distinct): + targets = tuple(arg.expressions) + return AggregateMetric( + function=AggregateFunction.COUNT_DISTINCT, + arg_columns=_columns_in(targets), + ) + if isinstance(arg, exp.Star): + return AggregateMetric(function=func, arg_columns=()) + targets = (arg,) + else: + targets = (top.this,) + return AggregateMetric(function=func, arg_columns=_columns_in(targets)) + + +def _columns_in(exprs: tuple[exp.Expression, ...]) -> tuple[exp.Column, ...]: + columns: list[exp.Column] = [] + for target in exprs: + columns.extend(target.find_all(exp.Column)) + return tuple(columns) + + +def _collect_composite_refs( + *, metric: Metric, expression: exp.Expression, namespace: Namespace +) -> tuple[MetricRef, ...]: + refs: list[MetricRef] = [] + for col in expression.find_all(exp.Column): + refs.append( + _resolve_composite_leaf(metric=metric, col=col, namespace=namespace) + ) + if not refs: + raise OSIPlanningError( + ErrorCode.E1206_METRIC_IN_RAW_AGGREGATE, + ( + f"metric {metric.name!r} is not a top-level aggregate and does " + "not reference any other declared metric" + ), + context={"metric": metric.name}, + ) + return tuple(refs) + + +def _resolve_composite_leaf( + *, metric: Metric, col: exp.Column, namespace: Namespace +) -> MetricRef: + name = normalize_identifier(col.name) + if col.table: + dataset = normalize_identifier(col.table) + ds_ns = namespace.datasets.get(dataset) + if ds_ns is None or name not in ds_ns.metrics: + raise _composite_leaf_error(metric=metric, reference=f"{dataset}.{name}") + return MetricRef(name=name, dataset=dataset) + # Bare: a model-scoped metric, or a dataset-scoped metric whose + # bare name is unambiguous. + if name in namespace.metrics: + return MetricRef(name=name, dataset=None) + try: + owner = namespace.resolve_bare(name) + except OSIParseError as exc: + raise OSIPlanningError( + ErrorCode.E1206_METRIC_IN_RAW_AGGREGATE, + ( + f"metric {metric.name!r}: bare reference {name!r} in composite " + f"expression does not name a declared metric ({exc})" + ), + context={"metric": metric.name, "reference": name}, + ) from exc + ds_ns = namespace.datasets[owner] + if name not in ds_ns.metrics: + raise _composite_leaf_error(metric=metric, reference=f"{owner}.{name}") + return MetricRef(name=name, dataset=owner) + + +def _composite_leaf_error(*, metric: Metric, reference: str) -> OSIPlanningError: + return OSIPlanningError( + ErrorCode.E1206_METRIC_IN_RAW_AGGREGATE, + ( + f"metric {metric.name!r}: composite leaf {reference!r} is not a " + "declared metric (composite metrics may only reference other " + "declared metrics, not raw facts)" + ), + context={"metric": metric.name, "reference": reference}, + ) + + +def _reject_nested_aggregates(*, metric: Metric, expression: exp.Expression) -> None: + for node in expression.walk(): + if isinstance(node, (exp.Sum, exp.Count, exp.Min, exp.Max, exp.Avg)): + raise OSIPlanningError( + ErrorCode.E1206_METRIC_IN_RAW_AGGREGATE, + ( + f"metric {metric.name!r}: aggregate function " + f"{type(node).__name__!r} may only appear at the top level; " + "composite metrics are built from metric references, not " + "fresh aggregates" + ), + context={"metric": metric.name}, + ) + + +def resolve_metric_by_name( + *, name: Identifier, dataset: Identifier | None, namespace: Namespace +) -> tuple[Metric, Identifier | None]: + """Look up a metric by name (bare or dataset-qualified). + + Returns the :class:`Metric` object and the owning dataset (or + ``None`` for model-scoped metrics). Raises ``E2002`` if the name + does not resolve. + """ + if dataset is not None: + ds_ns = namespace.datasets.get(dataset) + if ds_ns is None or name not in ds_ns.metrics: + raise OSIPlanningError( + ErrorCode.E2002_NAME_NOT_FOUND, + f"metric {dataset}.{name} is not declared", + context={"dataset": dataset, "name": name}, + ) + return ds_ns.metrics[name], dataset + if name in namespace.metrics: + return namespace.metrics[name], None + # Fall through: try dataset-scoped unambiguous bare name. + try: + owner = namespace.resolve_bare(name) + except OSIParseError as exc: + raise OSIPlanningError( + ErrorCode.E2002_NAME_NOT_FOUND, + f"metric {name!r} is not declared", + context={"name": name, "reason": str(exc)}, + ) from exc + ds_ns = namespace.datasets[owner] + if name not in ds_ns.metrics: + raise OSIPlanningError( + ErrorCode.E2002_NAME_NOT_FOUND, + f"metric {name!r} is not declared", + context={"name": name, "owner": owner}, + ) + return ds_ns.metrics[name], owner + + +__all__ = [ + "AggregateMetric", + "CompositeMetric", + "MetricRef", + "MetricShape", + "classify_metric", + "resolve_metric_by_name", +] diff --git a/impl/python/src/osi/planning/plan.py b/impl/python/src/osi/planning/plan.py new file mode 100644 index 0000000..ba31d69 --- /dev/null +++ b/impl/python/src/osi/planning/plan.py @@ -0,0 +1,445 @@ +"""The :class:`QueryPlan` value type — the planner's output. + +A :class:`QueryPlan` is a deterministic, dialect-agnostic, immutable +description of the algebra composition that answers a +:class:`~osi.planning.semantic_query.SemanticQuery`. The codegen layer +turns it into SQL; nothing between planning and codegen inspects models +or namespaces. + +Shape +----- +A plan is a directed acyclic graph of :class:`PlanStep` nodes. The root +is the step whose state matches the query's output columns and grain. +Every step carries: + +* ``operation`` — which algebra operator produced this step +* ``inputs`` — the step IDs of upstream states (0 for ``SOURCE``, 1 for + unary operators, 2 for ``MERGE`` / ``FILTERING_JOIN``) +* ``state`` — the :class:`~osi.planning.algebra.state.CalculationState` + this step evaluates to (so goldens snapshot grain + columns) +* ``payload`` — operator-specific arguments (predicates, join keys, + new grain, aggregation columns, etc.). Intentionally kept as a + typed variant: the golden tests snapshot its canonical form. + +Determinism invariants (``ARCHITECTURE.md §6``): + +* step IDs are integers assigned in *topological* (post-order) traversal +* ``inputs`` are sorted by step ID +* ``columns`` within a step's state preserve operator-chosen order + (e.g. ``project`` respects the caller's column list) + +Golden tests import ``QueryPlan.to_json()`` to generate snapshots. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any, Mapping, Optional + +from osi.common.identifiers import Identifier +from osi.common.sql_expr import FrozenSQL +from osi.common.types import DimensionSet +from osi.errors import ErrorCode, OSIError +from osi.planning.algebra.operations import FilterMode, JoinType +from osi.planning.algebra.state import CalculationState, Column + + +class PlanOperation(StrEnum): + """The nine operators of the closed algebra, surfaced into the plan.""" + + SOURCE = "source" + FILTER = "filter" + ENRICH = "enrich" + AGGREGATE = "aggregate" + PROJECT = "project" + ADD_COLUMNS = "add_columns" + MERGE = "merge" + FILTERING_JOIN = "filtering_join" + BROADCAST = "broadcast" + + +# --------------------------------------------------------------------------- +# Operator-specific payloads +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class SourcePayload: + """Payload for :attr:`PlanOperation.SOURCE`. + + ``source`` is the physical table reference copied from + :attr:`osi.parsing.models.Dataset.source`. Carrying it on the plan + means codegen never has to reach back into the model — a strict + Layer-3 boundary. + """ + + dataset: Identifier + primary_key: DimensionSet + source: str = "" + + +@dataclass(frozen=True, slots=True) +class FilterPayload: + """Payload for :attr:`PlanOperation.FILTER`. + + Carries both the predicate AST and the column dependencies the + algebra used to validate it. Keeping them together makes goldens + self-describing. + """ + + predicate: FrozenSQL + dependencies: frozenset[Identifier] + is_post_aggregate: bool = False + + +@dataclass(frozen=True, slots=True) +class EnrichPayload: + """Payload for :attr:`PlanOperation.ENRICH`. + + ``child_source`` is the child dataset's physical source — codegen + uses it directly and never looks up the model. + + ``parent_keys`` / ``child_keys`` record the key pairing across the + relationship. For self-matching keys they're equal, but relationships + like ``orders.customer_id → customers.id`` have different names on + each side. The algebra's ``keys`` field still addresses parent-side + columns; the split-out sequences exist solely for codegen. + """ + + child_dataset: Identifier + child_columns: tuple[Column, ...] + keys: DimensionSet + join_type: JoinType + child_source: str = "" + parent_keys: tuple[Identifier, ...] = () + child_keys: tuple[Identifier, ...] = () + + +@dataclass(frozen=True, slots=True) +class EnrichDerivedPayload: + """Payload for :attr:`PlanOperation.ENRICH` against a *derived* child. + + Carries the same join-key contract as :class:`EnrichPayload` but + treats the child as an upstream :class:`PlanStep` rather than a + base table. Used by the bridge-resolution planner + (``Proposed_OSI_Semantics.md §6.5.1``, mid-pipeline form): the + child is a pre-aggregated state at the bridge's join-key grain, + not a freshly-sourced dataset. + + ``ENRICH`` steps with this payload have **two** inputs (parent + step, child step) instead of one. Codegen reads the child as the + second input's CTE alias, never as ``to_table(...)``. + """ + + child_columns: tuple[Column, ...] + keys: DimensionSet + join_type: JoinType + parent_keys: tuple[Identifier, ...] = () + child_keys: tuple[Identifier, ...] = () + + +@dataclass(frozen=True, slots=True) +class AggregatePayload: + """Payload for :attr:`PlanOperation.AGGREGATE`.""" + + new_grain: DimensionSet + aggregations: tuple[Column, ...] + + +@dataclass(frozen=True, slots=True) +class ProjectPayload: + """Payload for :attr:`PlanOperation.PROJECT`.""" + + columns: tuple[Identifier, ...] + + +@dataclass(frozen=True, slots=True) +class AddColumnsPayload: + """Payload for :attr:`PlanOperation.ADD_COLUMNS`. + + ``ADD_COLUMNS`` is emitted only for **composite metrics** + (``Proposed_OSI_Semantics.md §5.4``). The planner lowers each + composite metric in a measure group into a post-``AGGREGATE`` + ``ADD_COLUMNS`` step whose ``definitions`` reference base + aggregate columns. No other planner path emits this step today. + """ + + definitions: tuple[Column, ...] + + +@dataclass(frozen=True, slots=True) +class MergePayload: + """Payload for :attr:`PlanOperation.MERGE`.""" + + on: DimensionSet + + +@dataclass(frozen=True, slots=True) +class FilteringJoinPayload: + """Payload for :attr:`PlanOperation.FILTERING_JOIN`.""" + + lhs_keys: DimensionSet + rhs_keys: DimensionSet + mode: FilterMode + + +@dataclass(frozen=True, slots=True) +class BroadcastPayload: + """Payload for :attr:`PlanOperation.BROADCAST`. + + **Reserved.** ``broadcast`` is defined in the algebra + (``Proposed_OSI_Semantics.md §4.8``) so scalar-per-row attach + semantics have a stable operator, but today's planner never + emits a ``BROADCAST`` step — cross-grain scalar attachment is + expressed by a percent-of-total composite metric instead + (``§5.4``). The operator and this payload are kept so a future + sprint can turn it on without a SPEC change. + """ + + column: Column + + +PlanPayload = ( + SourcePayload + | FilterPayload + | EnrichPayload + | EnrichDerivedPayload + | AggregatePayload + | ProjectPayload + | AddColumnsPayload + | MergePayload + | FilteringJoinPayload + | BroadcastPayload +) + + +# --------------------------------------------------------------------------- +# Step + plan +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class PlanStep: + """One node in the plan DAG. + + ``step_id`` is assigned at construction time by the planner; callers + must never re-number steps after the fact. + """ + + step_id: int + operation: PlanOperation + inputs: tuple[int, ...] + state: CalculationState + payload: PlanPayload + + +@dataclass(frozen=True, slots=True) +class OrderByEntry: + """Output-side ordering, carried on :class:`QueryPlan`.""" + + column: Identifier + descending: bool = False + + +@dataclass(frozen=True, slots=True) +class QueryPlan: + """Deterministic, ordered list of :class:`PlanStep` plus output metadata. + + ``steps`` is stored in topological order — the last entry is the + root; all step IDs in ``inputs`` reference earlier entries. + + ``order_by`` and ``limit`` are carried outside the algebra because + the algebra has no notion of row ordering. + """ + + steps: tuple[PlanStep, ...] + root_step_id: int + order_by: tuple[OrderByEntry, ...] = () + limit: Optional[int] = None + output_columns: tuple[Identifier, ...] = field(default_factory=tuple) + # S-7: optional output-column rename map. Codegen uses it to emit + # ``column AS alias`` in the final ``SELECT``. The plan still + # carries the internal column names everywhere upstream — aliases + # only affect what the user sees. + output_aliases: tuple[tuple[Identifier, Identifier], ...] = () + + def __post_init__(self) -> None: + """Verify topological ordering and root ID invariants. + + Violations here are not user-facing — they mean a planner pass + produced an inconsistent ``QueryPlan``. We surface the failure + through the typed-error channel (``E_INTERNAL_INVARIANT``) so + the "every failure carries a code" property test still holds. + """ + seen: set[int] = set() + for step in self.steps: + for dep in step.inputs: + if dep not in seen: + raise OSIError( + ErrorCode.E_INTERNAL_INVARIANT, + f"step {step.step_id} references unplanned " + f"input {dep} (steps must be topologically " + "ordered)", + context={ + "step_id": step.step_id, + "unplanned_input": dep, + }, + ) + seen.add(step.step_id) + if self.root_step_id not in seen: + raise OSIError( + ErrorCode.E_INTERNAL_INVARIANT, + f"root_step_id {self.root_step_id} is not a step in " "this plan", + context={ + "root_step_id": self.root_step_id, + "step_ids": sorted(seen), + }, + ) + + @property + def root(self) -> PlanStep: + """Return the terminal step whose state matches the query output.""" + return next(s for s in self.steps if s.step_id == self.root_step_id) + + def to_json(self) -> Mapping[str, Any]: + """Return a deterministic JSON-ready representation for goldens.""" + return { + "root_step_id": self.root_step_id, + "output_columns": [str(c) for c in self.output_columns], + "order_by": [ + {"column": str(o.column), "descending": o.descending} + for o in self.order_by + ], + "limit": self.limit, + "steps": [_step_to_json(s) for s in self.steps], + } + + +# --------------------------------------------------------------------------- +# JSON helpers +# --------------------------------------------------------------------------- + + +def _step_to_json(step: PlanStep) -> Mapping[str, Any]: + return { + "step_id": step.step_id, + "operation": step.operation.value, + "inputs": list(step.inputs), + "grain": sorted(str(g) for g in step.state.grain), + "columns": [_column_to_json(c) for c in step.state.columns], + "payload": _payload_to_json(step.payload), + } + + +def _column_to_json(col: Column) -> Mapping[str, Any]: + agg: Mapping[str, Any] | None = None + if col.aggregate is not None: + agg = { + "function": col.aggregate.function.name, + "argument": col.aggregate.argument.canonical, + } + return { + "name": str(col.name), + "kind": col.kind.value, + "expression": col.expression.canonical, + "dependencies": sorted(str(d) for d in col.dependencies), + "aggregate": agg, + "is_single_valued": col.is_single_valued, + "from_join_rhs": col.from_join_rhs, + } + + +def _payload_to_json(payload: PlanPayload) -> Mapping[str, Any]: + if isinstance(payload, SourcePayload): + return { + "kind": "source", + "dataset": str(payload.dataset), + "source": payload.source, + "primary_key": sorted(str(p) for p in payload.primary_key), + } + if isinstance(payload, FilterPayload): + return { + "kind": "filter", + "predicate": payload.predicate.canonical, + "dependencies": sorted(str(d) for d in payload.dependencies), + "post_aggregate": payload.is_post_aggregate, + } + if isinstance(payload, EnrichPayload): + return { + "kind": "enrich", + "child_dataset": str(payload.child_dataset), + "child_source": payload.child_source, + "keys": sorted(str(k) for k in payload.keys), + "parent_keys": [str(k) for k in payload.parent_keys], + "child_keys": [str(k) for k in payload.child_keys], + "join_type": payload.join_type.name, + "child_columns": [_column_to_json(c) for c in payload.child_columns], + } + if isinstance(payload, EnrichDerivedPayload): + return { + "kind": "enrich_derived", + "keys": sorted(str(k) for k in payload.keys), + "parent_keys": [str(k) for k in payload.parent_keys], + "child_keys": [str(k) for k in payload.child_keys], + "join_type": payload.join_type.name, + "child_columns": [_column_to_json(c) for c in payload.child_columns], + } + if isinstance(payload, AggregatePayload): + return { + "kind": "aggregate", + "new_grain": sorted(str(g) for g in payload.new_grain), + "aggregations": [_column_to_json(c) for c in payload.aggregations], + } + if isinstance(payload, ProjectPayload): + return { + "kind": "project", + "columns": [str(c) for c in payload.columns], + } + if isinstance(payload, AddColumnsPayload): + return { + "kind": "add_columns", + "definitions": [_column_to_json(c) for c in payload.definitions], + } + if isinstance(payload, MergePayload): + return { + "kind": "merge", + "on": sorted(str(k) for k in payload.on), + } + if isinstance(payload, FilteringJoinPayload): + return { + "kind": "filtering_join", + "lhs_keys": sorted(str(k) for k in payload.lhs_keys), + "rhs_keys": sorted(str(k) for k in payload.rhs_keys), + "mode": payload.mode.name, + } + if isinstance(payload, BroadcastPayload): + return { + "kind": "broadcast", + "column": _column_to_json(payload.column), + } + raise OSIError( + ErrorCode.E_INTERNAL_INVARIANT, + f"unknown payload type: {type(payload).__name__} — every " + "PlanPayload subclass must have a case in _payload_to_json", + context={"payload_type": type(payload).__name__}, + ) + + +__all__ = [ + "AddColumnsPayload", + "AggregatePayload", + "BroadcastPayload", + "EnrichDerivedPayload", + "EnrichPayload", + "FilterPayload", + "FilteringJoinPayload", + "MergePayload", + "OrderByEntry", + "PlanOperation", + "PlanPayload", + "PlanStep", + "ProjectPayload", + "QueryPlan", + "SourcePayload", +] diff --git a/impl/python/src/osi/planning/planner.py b/impl/python/src/osi/planning/planner.py new file mode 100644 index 0000000..1af39da --- /dev/null +++ b/impl/python/src/osi/planning/planner.py @@ -0,0 +1,659 @@ +"""The single Foundation query planner. + +Takes a validated :class:`SemanticModel` (via :class:`PlannerContext`) and a +user-supplied :class:`SemanticQuery`, and returns a frozen +:class:`QueryPlan` — a deterministic DAG of :class:`PlanStep` whose states +step through the closed algebra defined in +:mod:`osi.planning.algebra.operations`. + +Pipeline (``Proposed_OSI_Semantics.md §5``): + +1. Resolve dimensions and measures against the :class:`Namespace`. +2. Classify ``where`` into row-level and semi-join conjuncts and + ``having`` into post-aggregate conjuncts. +3. Group measures by *fact dataset* — each group becomes a + `measure-group state` that: + a. ``SOURCE`` s the fact dataset + b. applies row-level ``WHERE`` restricted to that dataset, then + enriches dimension datasets via N:1 ``ENRICH`` (raising + ``E3011`` for any N:N edge), + c. applies any ``WHERE`` that references joined-in dimensions, + d. applies any ``EXISTS_IN`` semi-joins via ``FILTERING_JOIN``, + e. ``AGGREGATE`` s to the query's dimension grain. +4. ``MERGE`` the measure-group states on the shared dimension grain + (chasm-trap safe — §4.11). +5. Apply ``HAVING`` as post-aggregate ``FILTER`` steps on the merged + state. +6. ``PROJECT`` to the final output column list. +7. Wrap everything in a :class:`QueryPlan` with ``order_by`` and + ``limit`` carried alongside (outside the algebra). + +Every intermediate state is built *through* the algebra operators so all +the ``E3xxx`` / ``E4xxx`` safety checks fire exactly once at plan time. +The planner never inspects SQL text — all introspection happens on +SQLGlot ASTs. + +Surface coverage notes: + +* **Window functions** are *partially* supported. The scalar branch + (:mod:`osi.planning.planner_scalar`) compiles windowed metrics into + :class:`PlanOperation.ADD_COLUMNS` after enrichment. The aggregation + branch — this module — does **not** yet plan windowed metrics in + ``Measures``; the parser rejects windowed metric expressions with + :attr:`ErrorCode.E_WINDOWED_METRIC_COMPOSITION` (D-031 / §6.10.4) + before they reach this planner. Window functions appearing inside + ``Where`` raise :attr:`ErrorCode.E_WINDOW_IN_WHERE` (D-028) from + :mod:`osi.planning.classify`. The fan-out-vs-window failure mode + ``E_WINDOW_OVER_FANOUT_REWRITE`` (D-030) is currently foreclosed by + earlier gates — see ``INFRA.md`` I-43 and the ``E_WINDOW_OVER_*`` + entry in :mod:`osi.errors`. +* **Semi-join filtering** (``EXISTS_IN`` / ``NOT EXISTS_IN``) is + deferred in Foundation v0.1 (§10 / D-017). The aggregation planner + accepts semi-joins only when the caller opts in via + :attr:`FoundationFlags.experimental_exists_in`; without the flag, + :mod:`osi.planning.classify` rejects them with + :attr:`ErrorCode.E_DEFERRED_KEY_REJECTED`. + +Other out-of-scope features (raise :attr:`ErrorCode.E_DEFERRED_KEY_REJECTED` +in parsing, or a more specific named code here in the planner): +fixed-grain overrides, per-metric filter context, ad-hoc aggregate +expressions in the ``measures`` slot, grouping sets, pivot, metric +reset. See ``proposals/foundation-v0.1/Proposed_OSI_Semantics.md §10`` +for the canonical deferred-feature list. +""" + +from __future__ import annotations + +from typing import Sequence + +from osi.common.identifiers import Identifier +from osi.common.types import DimensionSet +from osi.errors import ErrorCode, OSIPlanningError +from osi.planning.algebra.composition import add_columns +from osi.planning.algebra.operations import aggregate, filter_, project +from osi.planning.algebra.state import CalculationState +from osi.planning.classify import ( + RowLevelPredicate, + SemiJoinPredicate, + classify_having, + classify_where, +) +from osi.planning.columns import ( + composite_leaf_dependencies, + composite_to_derived_column, + metric_to_aggregate_column_from_metric, +) +from osi.planning.joins import JoinStep, find_enrichment_path +from osi.planning.metric_dispatch import metric_fact_dataset as _metric_fact_dataset +from osi.planning.plan import ( + AddColumnsPayload, + AggregatePayload, + FilterPayload, + OrderByEntry, + PlanOperation, + PlanStep, + ProjectPayload, + QueryPlan, +) +from osi.planning.planner_bridge import ( + build_bridge_plan, + build_nested_bridge_plan, + can_apply_bridge_resolution, + find_bridge_resolutions, +) +from osi.planning.planner_composites import ( + measure_plan_for_group, + replace_metric_expression, +) +from osi.planning.planner_context import PlannerContext +from osi.planning.planner_mn import MeasureGroup as _MeasureGroup +from osi.planning.planner_mn import build_dimension_only_group as _dimension_only_group +from osi.planning.planner_mn import ( + group_allowed_relationships as _group_allowed_relationships, +) +from osi.planning.planner_mn import ( + validate_multi_fact_stitch as _validate_multi_fact_stitch, +) +from osi.planning.planner_nested import ( + infer_intermediate_grain, + insert_nested_aggregate, + is_nested_aggregate, +) +from osi.planning.planner_scalar import plan_scalar +from osi.planning.preprocess import inline_named_filters, substitute_parameters +from osi.planning.resolve import ( + ResolvedDimension, + ResolvedMetric, + resolve_dimension, + resolve_measure, +) +from osi.planning.semantic_query import OrderBy, SemanticQuery, SortDirection +from osi.planning.steps import ( + PlanBuilder, + enrich_step, + fact_dataset, + filter_step, + merge_groups, + semi_join_step, + source_step, +) + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def plan(query: SemanticQuery, context: PlannerContext) -> QueryPlan: + """Plan ``query`` against ``context``. + + Pure; determinism is guaranteed by the planner's topological + traversal and by :mod:`osi.planning.prefixes` controlling every + synthetic name. + + Foundation v0.1 (D-010 / D-011) routes by query shape: + aggregation queries flow through this function; scalar queries + delegate to :func:`osi.planning.planner_scalar.plan_scalar`. + """ + if query.is_scalar: + return plan_scalar(query, context) + + dims = tuple(resolve_dimension(d, context.namespace) for d in query.dimensions) + measures = tuple(resolve_measure(m, context.namespace) for m in query.measures) + + if not measures and not dims: # SemanticQuery checks this too + raise OSIPlanningError( + ErrorCode.E1002_MISSING_REQUIRED_FIELD, + "query has no dimensions and no measures", + ) + + # Pre-classification AST rewrites: parameter substitution and + # named-filter inlining (``Proposed_OSI_Semantics.md §4.6 / §5.1``). + # Running these up front keeps the classifier focused on + # row-level / semi-join / post-aggregate splitting. + all_field_names = _all_field_names(context) + where = substitute_parameters( + query.where, provided=query.parameters, declared=context.model.parameters + ) + where = inline_named_filters( + where, filters=context.model.filters, field_names=all_field_names + ) + having = substitute_parameters( + query.having, provided=query.parameters, declared=context.model.parameters + ) + + classified = classify_where(where, context.namespace, flags=context.flags) + post_agg_preds = classify_having(having, tuple(m.metric.name for m in measures)) + + builder = PlanBuilder() + + groups = _group_measures(measures, context) + if not groups: + groups = _dimension_only_group(dims, context) + _validate_multi_fact_stitch(groups, dims, context) + + group_roots: list[PlanStep] = [] + for group in groups: + try: + root = _build_measure_group( + group=group, + dimensions=dims, + where=classified.row_level, + semi_joins=classified.semi_joins, + builder=builder, + context=context, + ) + except OSIPlanningError as exc: + # Per the ``E3011_MN_AGGREGATION_REJECTED`` docstring in + # :mod:`osi.errors`: ``E3011`` is the engine-capability + # opt-out code, reserved for engines that refuse all M:N + # traversal. This reference implementation **supports** M:N + # (Proposed_OSI_Semantics.md §6.8 *Semantic guarantee*) and + # so must never surface ``E3011`` to users. Algebra raises + # it as a precondition signal on fan-out / fan-trap edges; + # ``joins.classify_relationship_path`` translates true N:N + # edges to ``E3012`` / ``E3013`` before they reach here. + # The only remaining shape that reaches this handler is a + # 1:N fan-trap inside an aggregation query — the spec + # surfaces that as ``E_UNSAFE_REAGGREGATION`` (a plan-shape + # decomposition failure per D-022). + if exc.code is ErrorCode.E3011_MN_AGGREGATION_REJECTED: + ctx = dict(exc.context) + # F-17: when this fan-trap is the bridge-AVG / + # bridge-holistic gap (D-027) the user needs to know + # the gap is engine-side, not a spec rejection. + # ``can_apply_bridge_resolution`` already surfaced the + # reason; thread it into the error context so the + # diagnostic surface is honest about the limitation. + applicable, reason = can_apply_bridge_resolution(group) + if not applicable and reason and "bridge resolution requires" in reason: + ctx["engine_gap"] = ( + "Bridge resolution for non-distributive / " + "holistic aggregates (D-027) is not yet " + "implemented; see compliance tests " + "t-016-non-distributive-over-bridge-accepted " + "and t-051-holistic-over-bridge-accepted, and " + "the planner_bridge module docstring." + ) + ctx["bridge_precheck"] = reason + raise OSIPlanningError( + ErrorCode.E_UNSAFE_REAGGREGATION, + str(exc), + context=ctx, + ) from exc + raise + group_roots.append(root) + + final = merge_groups(group_roots, dims, builder) + + for pred in post_agg_preds: + final = builder.add( + PlanOperation.FILTER, + inputs=(final.step_id,), + state=filter_( + final.state, + pred.expression, + dependencies=pred.measures, + ), + payload=FilterPayload( + predicate=pred.expression, + dependencies=pred.measures, + is_post_aggregate=True, + ), + ) + + output_columns = _output_column_names(dims, measures) + projected = builder.add( + PlanOperation.PROJECT, + inputs=(final.step_id,), + state=project(final.state, output_columns), + payload=ProjectPayload(columns=output_columns), + ) + + order_by = _resolve_order_by(query.order_by, output_columns) + + return QueryPlan( + steps=builder.steps, + root_step_id=projected.step_id, + order_by=order_by, + limit=query.limit, + output_columns=output_columns, + ) + + +# --------------------------------------------------------------------------- +# Measure grouping +# --------------------------------------------------------------------------- + + +def _group_measures( + measures: Sequence[ResolvedMetric], context: PlannerContext +) -> tuple[_MeasureGroup, ...]: + by_fact: dict[Identifier, list[ResolvedMetric]] = {} + for m in measures: + fact = _metric_fact_dataset(m, context) + by_fact.setdefault(fact, []).append(m) + return tuple( + _MeasureGroup(fact_dataset=ds, measures=tuple(ms)) + for ds, ms in sorted(by_fact.items(), key=lambda kv: str(kv[0])) + ) + + +# --------------------------------------------------------------------------- +# Measure-group state construction +# --------------------------------------------------------------------------- + + +def _build_measure_group( + *, + group: _MeasureGroup, + dimensions: Sequence[ResolvedDimension], + where: Sequence[RowLevelPredicate], + semi_joins: Sequence[SemiJoinPredicate], + builder: PlanBuilder, + context: PlannerContext, +) -> PlanStep: + # Compute partition of WHERE / dim datasets up-front so we can + # speculatively decide between the standard plan and bridge + # resolution before mutating ``builder``. + fact_local = [p for p in where if p.datasets <= {group.fact_dataset}] + foreign = [p for p in where if not p.datasets <= {group.fact_dataset}] + dim_datasets = frozenset(d.dataset for d in dimensions) - {group.fact_dataset} + filter_datasets = frozenset().union(*(p.datasets for p in foreign)) - { + group.fact_dataset + } + needed_datasets = dim_datasets | filter_datasets + + enrichment_steps: tuple[JoinStep, ...] = () + if needed_datasets: + try: + enrichment_steps = find_enrichment_path( + root=group.fact_dataset, + targets=needed_datasets, + graph=context.graph, + allowed_relationships=_group_allowed_relationships(group), + ) + except OSIPlanningError as exc: + bridge_plan = _maybe_build_via_bridge( + exc=exc, + group=group, + dimensions=dimensions, + fact_local=fact_local, + foreign=foreign, + semi_joins=semi_joins, + needed_datasets=needed_datasets, + dim_datasets=dim_datasets, + builder=builder, + context=context, + ) + if bridge_plan is not None: + return bridge_plan + raise + + fact_ds = fact_dataset(group.fact_dataset, context) + current = source_step(fact_ds, builder, context) + for pred in fact_local: + current = filter_step(current, pred, builder) + for join in enrichment_steps: + current = enrich_step(current, join, builder, context) + + for pred in foreign: + current = filter_step(current, pred, builder) + + for sj in semi_joins: + current = semi_join_step(current, sj, builder, context) + + # Nested-aggregate metrics (D-020 + D-024 / `I-S5-impl`): route a + # single-measure group containing a nested aggregate (e.g. + # ``AVG(AVG(orders.amount))``) through the dedicated two-step + # aggregate planner. Standard composites and bare aggregates fall + # through to the existing path. + nested_plan = _maybe_build_nested_aggregate( + group=group, + dimensions=dimensions, + current=current, + builder=builder, + context=context, + ) + if nested_plan is not None: + return nested_plan + + # Split measures into base aggregates vs. composites. Composites + # need their referenced base aggregates materialised in the same + # AGGREGATE step, then a following ADD_COLUMNS to compute the + # derived expression (``Proposed_OSI_Semantics.md §5.4``). + measure_plan = measure_plan_for_group( + measures=group.measures, fact_ds=group.fact_dataset, context=context + ) + + group_grain = _query_grain(dimensions, current.state) + if not measure_plan.base_aggregates and not group_grain: + return current + + agg_columns = tuple( + metric_to_aggregate_column_from_metric( + metric=base.metric, dataset=base.dataset, state=current.state + ) + for base in measure_plan.base_aggregates + ) + + aggregated = builder.add( + PlanOperation.AGGREGATE, + inputs=(current.step_id,), + state=aggregate( + current.state, + group_grain, + agg_columns, + ), + payload=AggregatePayload( + new_grain=group_grain, + aggregations=agg_columns, + ), + ) + + if not measure_plan.composite_definitions: + return aggregated + + derived = tuple( + composite_to_derived_column( + name=comp.name, + metric=replace_metric_expression( + metric=comp.metric, new_expr=comp.expression + ), + dependency_names=composite_leaf_dependencies( + replace_metric_expression(metric=comp.metric, new_expr=comp.expression) + ), + ) + for comp in measure_plan.composite_definitions + ) + return builder.add( + PlanOperation.ADD_COLUMNS, + inputs=(aggregated.step_id,), + state=add_columns(aggregated.state, derived), + payload=AddColumnsPayload(definitions=derived), + ) + + +# --------------------------------------------------------------------------- +# Nested aggregate routing +# --------------------------------------------------------------------------- + + +def _maybe_build_nested_aggregate( + *, + group: _MeasureGroup, + dimensions: Sequence[ResolvedDimension], + current: PlanStep, + builder: PlanBuilder, + context: PlannerContext, +) -> PlanStep | None: + """Return a two-step aggregate plan if the group is nested-only. + + Foundation v0.1 supports the simplest nested shape: exactly one + measure whose body is ``f(g())`` with both ``f`` and + ``g`` Foundation aggregates. Mixed groups (one nested + one + plain) fall through to the standard planner, which will surface + a descriptive error if the shape is not supported. + """ + if len(group.measures) != 1: + return None + only = group.measures[0] + if not is_nested_aggregate(only.metric): + return None + intermediate_grain = infer_intermediate_grain( + fact_dataset=group.fact_dataset, + dimensions=dimensions, + state_columns=current.state.column_names, + context=context, + ) + if not intermediate_grain: + # No usable intermediate dim — the nested rewrite cannot + # honour the per-row-first contract. Surface the standard + # error from the existing path so users see the unsupported + # shape, not a silently wrong result. + return None + return insert_nested_aggregate( + parent=current, + measure=only, + dimensions=dimensions, + intermediate_grain=intermediate_grain, + builder=builder, + context=context, + ) + + +# --------------------------------------------------------------------------- +# Output layout helpers +# --------------------------------------------------------------------------- + + +def _output_column_names( + dims: Sequence[ResolvedDimension], measures: Sequence[ResolvedMetric] +) -> tuple[Identifier, ...]: + return tuple(d.field.name for d in dims) + tuple(m.metric.name for m in measures) + + +def _resolve_order_by( + entries: Sequence[OrderBy], output: tuple[Identifier, ...] +) -> tuple[OrderByEntry, ...]: + out: list[OrderByEntry] = [] + allowed = set(output) + for entry in entries: + col = entry.target.name + if col not in allowed: + raise OSIPlanningError( + ErrorCode.E2002_NAME_NOT_FOUND, + f"order_by column {col!r} is not in the output", + context={"column": col, "output": sorted(str(o) for o in output)}, + ) + out.append( + OrderByEntry(column=col, descending=entry.direction is SortDirection.DESC) + ) + return tuple(out) + + +def _all_field_names(context: PlannerContext) -> frozenset[Identifier]: + """Every field name addressable anywhere in the model. + + Consulted by :func:`~osi.planning.preprocess.inline_named_filters` + to protect against silent rewrites: a bare reference that collides + with a declared field name is left alone (field wins). + """ + names: set[Identifier] = set() + for ds in context.model.datasets: + for f in ds.fields: + names.add(f.name) + for m in ds.metrics: + names.add(m.name) + for m in context.model.metrics: + names.add(m.name) + return frozenset(names) + + +def _query_grain( + dims: Sequence[ResolvedDimension], state: CalculationState +) -> DimensionSet: + grain = {d.field.name for d in dims} + missing = grain - state.column_names + if missing: + raise OSIPlanningError( + ErrorCode.E3002_UNSATISFIABLE_GRAIN, + f"dimensions {sorted(str(m) for m in missing)} are not reachable " + "from the current measure-group state", + context={"missing": sorted(str(m) for m in missing)}, + ) + return frozenset(grain) + + +# --------------------------------------------------------------------------- +# Bridge-resolution dispatch (Proposed_OSI_Semantics.md §6.5.1, mid-pipeline) +# --------------------------------------------------------------------------- + + +def _maybe_build_via_bridge( + *, + exc: OSIPlanningError, + group: _MeasureGroup, + dimensions: Sequence[ResolvedDimension], + fact_local: Sequence[RowLevelPredicate], + foreign: Sequence[RowLevelPredicate], + semi_joins: Sequence[SemiJoinPredicate], + needed_datasets: frozenset[Identifier], + dim_datasets: frozenset[Identifier], + builder: PlanBuilder, + context: PlannerContext, +) -> PlanStep | None: + """Try to build the bridge-resolution plan. Return ``None`` on no-fit. + + Falls back to the original planner error (caller re-raises) when: + + * the planner error isn't an M:N rejection (``E3011`` / ``E3012``); + * the model exposes no bridge that can resolve the unsafe edge; + * any restriction in :mod:`osi.planning.planner_bridge` blocks the + shape (non-distributive metrics, foreign filters, semi-joins, + fact-side dimensions, …). + """ + if exc.code not in ( + ErrorCode.E3011_MN_AGGREGATION_REJECTED, + ErrorCode.E3012_MN_NO_SAFE_REWRITE, + ): + return None + if fact_local or foreign or semi_joins: + # Filters on the fact or foreign datasets, and semi-joins, are + # not yet supported by the bridge plan shape (they require + # extra grain bookkeeping). Fall back to the original error. + return None + + nested_only = len(group.measures) == 1 and is_nested_aggregate( + group.measures[0].metric + ) + + applicable, _reason = can_apply_bridge_resolution(group) + if not applicable and not nested_only: + return None + + resolutions = find_bridge_resolutions( + fact=group.fact_dataset, + needed=needed_datasets, + graph=context.graph, + ) + if not resolutions: + return None + + # All v1 query-grain dims must live on the bridge's right side. + # Allow the multi-target case where every outstanding dim is reached + # via the same bridge dataset. + distinct_bridges = {r.bridge for r in resolutions} + if len(distinct_bridges) > 1: + raise OSIPlanningError( + ErrorCode.E3001_AMBIGUOUS_JOIN_PATH, + ( + "multiple bridge datasets resolve the M:N traversal: " + f"{sorted(str(b) for b in distinct_bridges)}. " + "Restructure the model so only one bridge applies — " + "e.g. drop the redundant relationship, rename one of " + "the bridge datasets, or replace the duplicate path " + "with a single canonical bridge. (Per-metric " + "``joins.using_relationships`` disambiguation is " + "deferred in Foundation v0.1 §10 / D-009 and would " + "itself be rejected with ``E_DEFERRED_KEY_REJECTED``.)" + ), + context={"bridges": sorted(str(b) for b in distinct_bridges)}, + ) + bridge = sorted(resolutions, key=lambda r: str(r.right_target))[0] + + # All query dimensions must be reachable from the bridge side. + # Anything still on the fact side blocks v1 bridge resolution. + fact_side_dims = dim_datasets - {bridge.bridge, bridge.right_target} + fact_side_dims_unreached = frozenset( + d + for d in fact_side_dims + if not context.graph.find_paths(bridge.bridge, d, max_depth=4) + ) + if fact_side_dims_unreached: + return None + + post_bridge_targets = frozenset(d for d in dim_datasets if d != bridge.bridge) + + if nested_only: + return build_nested_bridge_plan( + group=group, + bridge=bridge, + dimensions=dimensions, + builder=builder, + context=context, + intermediate_keys_dataset=bridge.right_target, + ) + + return build_bridge_plan( + group=group, + bridge=bridge, + dimensions=dimensions, + builder=builder, + context=context, + pre_agg_dim_targets=frozenset(), + post_bridge_dim_targets=post_bridge_targets, + query_grain=frozenset(d.field.name for d in dimensions), + ) + + +__all__ = ["plan"] diff --git a/impl/python/src/osi/planning/planner_bridge.py b/impl/python/src/osi/planning/planner_bridge.py new file mode 100644 index 0000000..72951e5 --- /dev/null +++ b/impl/python/src/osi/planning/planner_bridge.py @@ -0,0 +1,687 @@ +"""Mid-pipeline bridge resolution for the planner. + +The Foundation spec +(``proposals/foundation-v0.1/Proposed_OSI_Semantics.md §6.8.1``, D-026 +/ D-027) describes the bridge route for M:N references: a metric over a +fact reachable only through an ``N : N`` edge is well-defined when an +*intermediate bridge dataset* makes the (fact-row, group-key) pairing +explicit. The single-pass spec form is: + + *Materialise the distinct ``(fact-primary-key, group-key)`` row set + by joining the fact, the bridge, and the right-hand dimension once; + then aggregate once at the query's dimension grain.* + +Because that materialisation is just a join with ``DISTINCT``, every +aggregate category (distributive, algebraic, holistic) is well-defined +over it — there is no two-stage decomposition. D-027 therefore accepts +bare ``SUM`` / ``AVG`` / ``MEDIAN`` / ``COUNT(DISTINCT)`` over an N:N +bridge. + +The current reference implementation realises the spec by emitting a +plan that is equivalent for the four distributive operators (``SUM``, +``COUNT``, ``MIN``, ``MAX``) plus ``COUNT_DISTINCT`` (whose D-027 +treatment matches because the dedup IS the distinct count). Non- +distributive operators (``AVG``, ``MEDIAN``, ``PERCENTILE_CONT``) +require the single-pass dedup form rather than the two-stage shape and +are still pending — they currently surface ``E_UNSAFE_REAGGREGATION`` +from the standard planner. Tracked by +``compliance/foundation-v0.1/tests/bridge/hard/t-016`` and ``t-051`` in +the conformance suite. + +Plan shape (distributive case): + +1. ``source(fact)`` + safe enrichments to the bridge's left link. +2. ``aggregate`` at the bridge's left join-key grain (one row per fact + row contributing to a group). +3. ``source(bridge)``, ``enrich`` the right-side target, then + ``enrich`` the pre-aggregated state via + :class:`EnrichDerivedPayload`. +4. ``aggregate`` at the query's dimension grain, re-aggregating each + metric with its same operator (``SUM``-of-``SUM``, + ``MAX``-of-``MAX``, …). + +Limitations in this revision (re-examine when real models need more): + +* **Non-distributive aggregates** (``AVG``, ``MEDIAN``, + ``PERCENTILE_CONT``) over a bridge are not yet routed — see D-027 + and the ``t-016`` / ``t-051`` conformance cases. +* **Composite metrics** (``§5.4``) must use the standard plan shape. +* **Query dimensions must live on the bridge's right-hand side** — + fact-side dimensions force a wider pre-aggregation grain than this + revision supports. + +**SQLGlot in the planner.** This module is the sanctioned exception to +the "planner reasons over IR, not SQL text" rule. Materialising the +bridge dedup join is small enough that constructing SQLGlot expressions +in-line is clearer than threading a new payload through the algebra. +New SQLGlot usage anywhere else in :mod:`osi.planning` is a layering +violation; route through :mod:`osi.planning.algebra` and the +``QueryPlan`` payloads instead. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Sequence + +from sqlglot import expressions as exp + +from osi.common.identifiers import Identifier, normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.errors import ErrorCode, OSIPlanningError +from osi.parsing.graph import Cardinality, RelationshipEdge, RelationshipGraph +from osi.planning.algebra.operations import JoinType, aggregate, enrich +from osi.planning.algebra.state import ( + AggregateFunction, + AggregateInfo, + CalculationState, + Column, + ColumnKind, +) +from osi.planning.columns import ( + metric_to_aggregate_column_from_metric, + parse_metric_aggregate, +) +from osi.planning.joins import find_enrichment_path, reachable_via_n1 +from osi.planning.plan import ( + AggregatePayload, + EnrichDerivedPayload, + PlanOperation, + PlanStep, +) +from osi.planning.planner_context import PlannerContext +from osi.planning.planner_mn import MeasureGroup +from osi.planning.resolve import ResolvedDimension +from osi.planning.steps import PlanBuilder, enrich_step, fact_dataset, source_step + +# --------------------------------------------------------------------------- +# Bridge discovery +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class BridgeResolution: + """A bridge dataset that can resolve an unsafe enrichment step. + + ``bridge`` has safe `N : 1` edges to both ``left_link`` (which + sits in the fact's safe-reachable closure) and ``right_target`` + (one of the originally-outstanding datasets). The planner uses + this to route around the unsafe edge by sourcing the bridge as a + fresh root. + """ + + bridge: Identifier + left_link: Identifier + left_edge: RelationshipEdge + right_target: Identifier + right_edge: RelationshipEdge + + +def _is_safe(edge: RelationshipEdge, *, parent: Identifier) -> bool: + """Mirror of joins._is_safe_direction (kept private to avoid circular).""" + if edge.cardinality is Cardinality.N_TO_N: + return False + if edge.cardinality is Cardinality.ONE_TO_ONE: + return edge.from_dataset == parent or edge.to_dataset == parent + return edge.from_dataset == parent # N:1, N-side -> 1-side + + +def _safe_edges_from( + bridge: Identifier, graph: RelationshipGraph +) -> tuple[tuple[RelationshipEdge, Identifier], ...]: + """Edges where ``bridge`` can safely enrich into the other endpoint.""" + out: list[tuple[RelationshipEdge, Identifier]] = [] + for edge in graph.neighbors(bridge): + if not _is_safe(edge, parent=bridge): + continue + other = edge.to_dataset if edge.from_dataset == bridge else edge.from_dataset + out.append((edge, other)) + return tuple(out) + + +def find_bridge_resolutions( + *, + fact: Identifier, + needed: frozenset[Identifier], + graph: RelationshipGraph, +) -> tuple[BridgeResolution, ...]: + """Bridges that connect ``fact``'s safe-reachable set to outstanding targets. + + The discovery is purely cardinality-driven (``§6.5.1``). A bridge + candidate is any dataset with at least one safe edge to a + fact-reachable dataset and one safe edge to an outstanding target. + Returns every distinct ``(bridge, right_target)`` pair so the + caller can decide between unique-resolution and ``E3001``-ambiguity. + """ + safe = reachable_via_n1(fact, graph) + outstanding = needed - safe + if not outstanding: + return () + candidates: list[BridgeResolution] = [] + for ds in sorted( + {e.from_dataset for e in graph.edges} | {e.to_dataset for e in graph.edges}, + key=str, + ): + if ds == fact or ds in safe or ds in outstanding: + continue + bridge_edges = _safe_edges_from(ds, graph) + # The bridge must reach at least one outstanding target *and* + # at least one already-reachable dataset; otherwise it is not + # actually a bridge between the two sides. + targets = {other for _, other in bridge_edges if other in outstanding} + links = {other for _, other in bridge_edges if other in safe} + if not targets or not links: + continue + # Build one BridgeResolution per (right_target) and pick a + # canonical left_link deterministically. + for tgt in sorted(targets, key=str): + right_edge = next(e for e, o in bridge_edges if o == tgt) + left_link = sorted(links, key=str)[0] + left_edge = next(e for e, o in bridge_edges if o == left_link) + candidates.append( + BridgeResolution( + bridge=ds, + left_link=left_link, + left_edge=left_edge, + right_target=tgt, + right_edge=right_edge, + ) + ) + return tuple(candidates) + + +# --------------------------------------------------------------------------- +# Plan-shape construction +# --------------------------------------------------------------------------- + + +# Metrics that the bridge plan can resolve. Distributive aggregates +# (SUM/COUNT/MIN/MAX) re-aggregate trivially. ``COUNT_DISTINCT`` is +# also accepted per D-022 / §6.11.3 — the bridge's distinct +# (fact, group-key) materialisation IS the de-duplication +# ``COUNT(DISTINCT)`` needs. After dedup, each fact contributes once +# per dim group, so ``SUM`` of the per-fact COUNT_DISTINCT (which is +# 1 if the value is present) produces the correct result whenever the +# COUNT_DISTINCT argument is functionally determined by the fact PK +# (i.e. lives on the fact dataset). This is the spec's contract. +_BRIDGE_RESOLVABLE = ( + AggregateFunction.SUM, + AggregateFunction.COUNT, + AggregateFunction.MIN, + AggregateFunction.MAX, + AggregateFunction.COUNT_DISTINCT, +) + + +def _resolved_bridge_unique( + candidates: tuple[BridgeResolution, ...], +) -> BridgeResolution: + """Pick the single bridge candidate or raise ``E3001`` for ambiguity.""" + distinct_bridges = {c.bridge for c in candidates} + if len(distinct_bridges) == 1: + # Single bridge dataset; if multiple right_targets it covers + # them all and the caller picks one per outstanding target. + return sorted(candidates, key=lambda c: str(c.right_target))[0] + raise OSIPlanningError( + ErrorCode.E3001_AMBIGUOUS_JOIN_PATH, + ( + "multiple bridge datasets can resolve the M:N traversal: " + f"{sorted(str(b) for b in distinct_bridges)}. Restructure " + "the model so only one bridge applies — e.g. drop the " + "redundant relationship, rename the bridge datasets so " + "the right one is selected, or replace the duplicate path " + "with a single canonical bridge. (Per-metric " + "``joins.using_relationships`` disambiguation is deferred " + "in Foundation v0.1 §10 / D-009 and would itself be " + "rejected with ``E_DEFERRED_KEY_REJECTED``.)" + ), + context={"bridges": sorted(str(b) for b in distinct_bridges)}, + ) + + +def can_apply_bridge_resolution(group: MeasureGroup) -> tuple[bool, str | None]: + """Cheap precheck. Returns ``(applicable, reason_if_not)``.""" + if not group.measures: + return False, "bridge resolution requires at least one measure" + for resolved in group.measures: + try: + fn, _ = parse_metric_aggregate(resolved.metric) + except OSIPlanningError as exc: + return False, f"metric {resolved.metric.name!r}: {exc}" + if fn not in _BRIDGE_RESOLVABLE: + return False, ( + f"metric {resolved.metric.name!r} uses aggregate " + f"{fn.name!r}; bridge resolution requires SUM / " + "COUNT / MIN / MAX / COUNT_DISTINCT" + ) + return True, None + + +def _materialised_metric_column( + *, + metric_name: Identifier, + function: AggregateFunction, + state: CalculationState, +) -> Column: + """Re-aggregation column at the final grain reading the pre-agg output. + + For COUNT we must SUM the counts (re-aggregating COUNT-of-COUNT + would double-count). For SUM/MIN/MAX the operator is its own + re-aggregator (``§5.1``). ``state`` is the post-bridge-join state + where the materialised column lives. + """ + if function in (AggregateFunction.COUNT, AggregateFunction.COUNT_DISTINCT): + re_fn = AggregateFunction.SUM + else: + re_fn = function + arg_sql = FrozenSQL.of(exp.column(str(metric_name))) + return Column( + name=metric_name, + expression=arg_sql, + dependencies=frozenset({metric_name}), + kind=ColumnKind.AGGREGATE, + aggregate=AggregateInfo(function=re_fn, argument=arg_sql), + ) + + +def _dedup_metric_column( + *, + metric_name: Identifier, +) -> Column: + """D-026 dedup column. + + The materialised column is constant per (link_key, dim_key) tuple + by construction (it was pre-aggregated at the link grain), so any + "pick one row" aggregate gives the same answer. We use ``MIN`` + because: + + * ``MIN`` is distributive, so the algebra's grain-coarsening + preconditions are trivially satisfied; + * ``MIN`` is well-defined on every numeric / temporal type the + Foundation supports (no NaN-vs-NULL surprises); + * ``MIN`` does not change the result vs ``MAX`` because the + column is single-valued on the dedup grain. + + The output column reuses the materialised column's name so the + final re-aggregation step (which reads ``metric_name``) sees no + structural change. + """ + arg_sql = FrozenSQL.of(exp.column(str(metric_name))) + return Column( + name=metric_name, + expression=arg_sql, + dependencies=frozenset({metric_name}), + kind=ColumnKind.AGGREGATE, + aggregate=AggregateInfo(function=AggregateFunction.MIN, argument=arg_sql), + ) + + +def build_bridge_plan( + *, + group: MeasureGroup, + bridge: BridgeResolution, + dimensions: Sequence[ResolvedDimension], + builder: PlanBuilder, + context: PlannerContext, + pre_agg_dim_targets: frozenset[Identifier], + post_bridge_dim_targets: frozenset[Identifier], + query_grain: frozenset[Identifier], +) -> PlanStep: + """Build the §6.5.1 bridge plan from ``group`` to ``query_grain``. + + Parameters + ---------- + pre_agg_dim_targets: + Datasets that must be enriched onto the fact side *before* the + pre-aggregation step (because the query references their dims + and they're reachable from the fact via safe N:1 only). For + the simple Foundation case this is empty. + post_bridge_dim_targets: + Datasets that must be enriched onto the bridge state *after* + the bridge is sourced. Always includes ``bridge.right_target``. + """ + # 1. Source fact + any pre-agg-side enrichments. We currently + # require pre_agg_dim_targets to be empty (caller enforces); + # the parameter is kept for clarity and future extension. + if pre_agg_dim_targets: + raise OSIPlanningError( + ErrorCode.E1208_UNSUPPORTED_SQL_CONSTRUCT, + ( + "bridge resolution v1 does not yet support fact-side " + "dimensions; reference dimensions only on the bridge " + f"side or rewrite the query. Got: " + f"{sorted(str(d) for d in pre_agg_dim_targets)}" + ), + context={"fact_side_dims": sorted(str(d) for d in pre_agg_dim_targets)}, + ) + fact_ds = fact_dataset(group.fact_dataset, context) + fact_state = source_step(fact_ds, builder, context) + + # 2. Walk the safe N:1 path from the fact to the bridge's left link + # so the link's join keys are addressable on the fact state. For + # the simple case where ``left_link == fact``, this is a no-op. + if bridge.left_link != group.fact_dataset: + link_path = find_enrichment_path( + root=group.fact_dataset, + targets=frozenset({bridge.left_link}), + graph=context.graph, + ) + for join in link_path: + fact_state = enrich_step(fact_state, join, builder, context) + + # 3. Pre-aggregate the (possibly enriched) fact state to the + # bridge's link-side join key grain. + left_keys = _join_keys_on_side(bridge.left_edge, side=bridge.left_link) + if left_keys is None: + raise OSIPlanningError( # pragma: no cover + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + ( + f"bridge {bridge.bridge!r} edge {bridge.left_edge.name!r} " + f"has no join keys on its link side {bridge.left_link!r}" + ), + context={"bridge": str(bridge.bridge)}, + ) + missing = frozenset(left_keys) - fact_state.state.column_names + if missing: + raise OSIPlanningError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + ( + f"bridge {bridge.bridge!r} requires keys " + f"{sorted(str(k) for k in missing)} to be addressable on the " + "fact state, but they are not. The fact's enrichment path to " + "the bridge's left link did not surface the required columns." + ), + context={ + "bridge": str(bridge.bridge), + "missing": sorted(str(k) for k in missing), + }, + ) + pre_agg_grain = frozenset(left_keys) + pre_agg_columns = tuple( + metric_to_aggregate_column_from_metric( + metric=resolved.metric, + dataset=group.fact_dataset, + state=fact_state.state, + ) + for resolved in group.measures + ) + pre_agg = builder.add( + PlanOperation.AGGREGATE, + inputs=(fact_state.step_id,), + state=aggregate(fact_state.state, pre_agg_grain, pre_agg_columns), + payload=AggregatePayload(new_grain=pre_agg_grain, aggregations=pre_agg_columns), + ) + + # 3. Source the bridge. + bridge_ds = fact_dataset(bridge.bridge, context) + bridge_state = source_step(bridge_ds, builder, context) + + # 4. Enrich the right-side target and any other post-bridge dim + # targets onto the bridge state via the standard path-finder + # (the bridge is the new root; everything past here is safe). + if post_bridge_dim_targets: + path = find_enrichment_path( + root=bridge.bridge, + targets=post_bridge_dim_targets, + graph=context.graph, + ) + for join in path: + bridge_state = enrich_step(bridge_state, join, builder, context) + + # 5. Enrich the pre-aggregated fact onto the bridge state. + bridge_keys_for_left = _join_keys_on_side(bridge.left_edge, side=bridge.bridge) + if bridge_keys_for_left is None: + raise OSIPlanningError( # pragma: no cover — caught upstream + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"bridge {bridge.bridge!r} edge missing self-side join keys", + ) + parent_column_names = bridge_state.state.column_names + drops = frozenset(k for k in left_keys if k in parent_column_names) + enriched = enrich( + bridge_state.state, + pre_agg.state, + parent_keys=tuple(bridge_keys_for_left), + child_keys=tuple(left_keys), + join_type=JoinType.LEFT, + drop_child_columns=drops, + ) + # Children = the pre-agg's metric columns (PK of pre-agg is left_keys + # which already align with the bridge's parent_keys, so they're + # dropped automatically by the algebra below). We pass the metrics + # explicitly into the payload so codegen knows which columns to surface. + materialised_children = tuple( + c for c in pre_agg.state.columns if c.name not in pre_agg.state.grain + ) + bridge_extended = builder.add( + PlanOperation.ENRICH, + inputs=(bridge_state.step_id, pre_agg.step_id), + state=enriched, + payload=EnrichDerivedPayload( + child_columns=materialised_children, + keys=frozenset(bridge_keys_for_left), + join_type=JoinType.LEFT, + parent_keys=tuple(bridge_keys_for_left), + child_keys=tuple(left_keys), + ), + ) + + # 6.5 D-026 dedup. The materialised metric is single-valued on + # ``left_keys``, but the bridge fan-out has duplicated each + # (link_key, dim_key) tuple once per intermediate bridge row + # (e.g. once per actor sharing the same height watching the + # same movie). Without this step the final SUM would + # multi-count those duplicates. Aggregating at + # ``left_keys ∪ final_dim_keys`` with ``MIN`` collapses the + # duplicates to one row per (link_key, dim_key) tuple while + # preserving the materialised value. + final_grain = _restricted_query_grain(dimensions, bridge_extended.state) + dedup_grain = frozenset(left_keys) | final_grain + dedup_columns = tuple( + _dedup_metric_column(metric_name=resolved.metric.name) + for resolved in group.measures + ) + dedup = builder.add( + PlanOperation.AGGREGATE, + inputs=(bridge_extended.step_id,), + state=aggregate(bridge_extended.state, dedup_grain, dedup_columns), + payload=AggregatePayload(new_grain=dedup_grain, aggregations=dedup_columns), + ) + + # 7. Final aggregate at the query grain, re-aggregating each + # materialised metric with the appropriate distributive operator. + re_agg_columns = tuple( + _materialised_metric_column( + metric_name=resolved.metric.name, + function=parse_metric_aggregate(resolved.metric)[0], + state=dedup.state, + ) + for resolved in group.measures + ) + return builder.add( + PlanOperation.AGGREGATE, + inputs=(dedup.step_id,), + state=aggregate(dedup.state, final_grain, re_agg_columns), + payload=AggregatePayload(new_grain=final_grain, aggregations=re_agg_columns), + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _join_keys_on_side( + edge: RelationshipEdge, *, side: Identifier +) -> tuple[Identifier, ...] | None: + """Return the join columns belonging to ``side`` on ``edge``.""" + if edge.from_dataset == side: + return tuple(edge.from_columns) + if edge.to_dataset == side: + return tuple(edge.to_columns) + return None + + +def _restricted_query_grain( + dimensions: Sequence[ResolvedDimension], state: CalculationState +) -> frozenset[Identifier]: + """Compute the final aggregation grain from query dimensions. + + Only the dimensions whose source dataset's columns are addressable + on ``state`` count — every other dim is logically a fact-side + dimension which the v1 bridge plan disallows (and the caller has + already validated). + """ + grain: set[Identifier] = set() + for d in dimensions: + col = d.field.name + if col in state.column_names: + grain.add(col) + return frozenset(grain) + + +# --------------------------------------------------------------------------- +# Nested-aggregate-over-bridge composition (S-23, closes the I-S5 + I-S8 +# composition). The shape is conceptually different from the dedup +# bridge: there is no fan-out worry because the inner aggregate sits +# next to the bridge join, so the per-row reading collapses naturally +# at the inner grain. +# --------------------------------------------------------------------------- + + +def build_nested_bridge_plan( + *, + group: MeasureGroup, + bridge: BridgeResolution, + dimensions: Sequence[ResolvedDimension], + builder: PlanBuilder, + context: PlannerContext, + intermediate_keys_dataset: Identifier, +) -> PlanStep: + """Plan ``f(g())`` queried via a bridge dataset. + + Strategy (matches the spec's per-row-first reading of D-020 + D-024 + composed with D-022 / §6.5): + + 1. ``source(bridge)`` then enrich the fact dataset and every + post-bridge dim target via the standard safe-N:1 path-finder. + This produces one row per bridge tuple with both the inner + aggregate's argument and the dim columns addressable. + 2. Inner ``aggregate`` at ``(intermediate_keys_dataset.pk ∪ + query_dim_keys)`` with the *inner* aggregate function. + 3. Final ``aggregate`` at the query grain with the *outer* + aggregate function applied to the inner column. + + Restrictions for v1: + + * Exactly one nested measure in the group (mixed groups still + route through the standard planner; mixed nested-and-plain is + rejected as ``E_UNSAFE_REAGGREGATION`` upstream). + * Both inner and outer functions must be in + :data:`_BRIDGE_RESOLVABLE` ∪ ``{AVG}``. ``AVG`` is allowed + because the per-row reading converts ``AVG(AVG(…))`` into a + sequence of two single-step aggregates, neither of which + crosses fan-out. + """ + from osi.planning.planner_nested import is_nested_aggregate, parse_nested + + if len(group.measures) != 1 or not is_nested_aggregate(group.measures[0].metric): + raise OSIPlanningError( # pragma: no cover — caller-contract + ErrorCode.E1208_UNSUPPORTED_SQL_CONSTRUCT, + "build_nested_bridge_plan requires a single nested aggregate", + ) + measure = group.measures[0] + outer_fn, inner_fn, inner_arg_expr = parse_nested(measure.metric) + + # 1. Source bridge and enrich both the fact and the right-target + + # other post-bridge dim datasets. + bridge_ds = fact_dataset(bridge.bridge, context) + bridge_state = source_step(bridge_ds, builder, context) + targets = frozenset({group.fact_dataset, bridge.right_target}) | frozenset( + d.dataset for d in dimensions if d.dataset != bridge.bridge + ) + if targets: + for join in find_enrichment_path( + root=bridge.bridge, targets=targets, graph=context.graph + ): + bridge_state = enrich_step(bridge_state, join, builder, context) + + # 2. Inner aggregate. The grain is the intermediate dataset's + # join key on the bridge (e.g. ``actor_id`` for the bridge + # ``appearances``) plus every query dim addressable on state. + intermediate_pk = _intermediate_keys_for( + intermediate_keys_dataset, bridge=bridge, graph=context.graph + ) + dim_columns = frozenset( + d.field.name + for d in dimensions + if d.field.name in bridge_state.state.column_names + ) + intermediate_grain = frozenset(intermediate_pk) | dim_columns + inner_arg_sql = FrozenSQL.of(inner_arg_expr.copy()) + inner_dependencies = frozenset( + normalize_identifier(c.name) + for c in inner_arg_expr.find_all(exp.Column) + if normalize_identifier(c.name) in bridge_state.state.column_names + ) + inner_column = Column( + name=measure.metric.name, + expression=measure.metric.expression, + dependencies=inner_dependencies, + kind=ColumnKind.AGGREGATE, + aggregate=AggregateInfo(function=inner_fn, argument=inner_arg_sql), + ) + inner_agg = builder.add( + PlanOperation.AGGREGATE, + inputs=(bridge_state.step_id,), + state=aggregate(bridge_state.state, intermediate_grain, (inner_column,)), + payload=AggregatePayload( + new_grain=intermediate_grain, aggregations=(inner_column,) + ), + ) + + # 3. Outer aggregate at the query grain. + final_grain = _restricted_query_grain(dimensions, inner_agg.state) + outer_arg_sql = FrozenSQL.of(exp.column(str(measure.metric.name))) + outer_column = Column( + name=measure.metric.name, + expression=measure.metric.expression, + dependencies=frozenset({measure.metric.name}), + kind=ColumnKind.AGGREGATE, + aggregate=AggregateInfo(function=outer_fn, argument=outer_arg_sql), + ) + return builder.add( + PlanOperation.AGGREGATE, + inputs=(inner_agg.step_id,), + state=aggregate(inner_agg.state, final_grain, (outer_column,)), + payload=AggregatePayload(new_grain=final_grain, aggregations=(outer_column,)), + ) + + +def _intermediate_keys_for( + dataset: Identifier, *, bridge: BridgeResolution, graph: RelationshipGraph +) -> tuple[Identifier, ...]: + """Return the keys joining ``dataset`` to the bridge. + + Used to set the inner-aggregate grain so that the per-row reading + of the nested metric runs once per ``dataset`` row. + """ + for edge in graph.neighbors(bridge.bridge): + # Both edges of the bridge are safe N:1; pick the one whose + # target matches ``dataset`` and return the bridge-side keys. + if edge.from_dataset == bridge.bridge and edge.to_dataset == dataset: + return tuple(edge.from_columns) + if edge.to_dataset == bridge.bridge and edge.from_dataset == dataset: + return tuple(edge.to_columns) + raise OSIPlanningError( # pragma: no cover — bridge is well-formed + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + f"bridge {bridge.bridge!r} has no edge to {dataset!r}", + ) + + +__all__ = [ + "BridgeResolution", + "build_bridge_plan", + "build_nested_bridge_plan", + "can_apply_bridge_resolution", + "find_bridge_resolutions", +] diff --git a/impl/python/src/osi/planning/planner_composites.py b/impl/python/src/osi/planning/planner_composites.py new file mode 100644 index 0000000..20ff7ea --- /dev/null +++ b/impl/python/src/osi/planning/planner_composites.py @@ -0,0 +1,216 @@ +"""Composite-metric expansion for the planner. + +Split out from :mod:`osi.planning.planner` to keep that file inside the +600-LOC cap (``INFRA.md §1.2``). Composite metrics +(``Proposed_OSI_Semantics.md §5.4``) are arithmetic combinations of +other declared metrics; they cannot be evaluated by +:func:`~osi.planning.algebra.operations.aggregate` directly and must +be materialised as a post-``AGGREGATE`` ``ADD_COLUMNS`` step over +the base aggregate columns. + +Public contract: + +* :class:`GroupMeasurePlan` — the per-group split produced by + :func:`measure_plan_for_group`, consumed by the planner to decide + whether an ``ADD_COLUMNS`` step is required. +* :func:`measure_plan_for_group` — classify each user-requested + measure into a base aggregate or a composite; for composites, + inline nested composite references and collect every transitively + required base aggregate. +* :func:`replace_metric_expression` — small utility used to funnel + a qualifier-stripped expression through ``columns`` helpers + without mutating declared models. + +Everything in this module is pure and deterministic. The module does +not emit plan steps; it only *describes* what the planner needs to +emit. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from sqlglot import expressions as exp + +from osi.common.identifiers import Identifier, normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.errors import OSIPlanningError +from osi.parsing.models import Metric +from osi.planning.columns import strip_column_qualifiers +from osi.planning.metric_shape import ( + AggregateMetric, + CompositeMetric, + classify_metric, + resolve_metric_by_name, +) +from osi.planning.planner_context import PlannerContext +from osi.planning.resolve import ResolvedMetric + + +@dataclass(frozen=True, slots=True) +class BaseAggregate: + """A single base aggregate column to emit under AGGREGATE.""" + + dataset: Identifier + metric: Metric + + +@dataclass(frozen=True, slots=True) +class CompositeDefinition: + """A derived metric emitted by ADD_COLUMNS after AGGREGATE. + + ``expression`` is already fully inlined (nested composites have + been flattened) and qualifier-stripped; it addresses the base + aggregate column names directly. + """ + + name: Identifier + metric: Metric + expression: FrozenSQL + + +@dataclass(frozen=True, slots=True) +class GroupMeasurePlan: + """Per-group breakdown of how to realise the user's measures.""" + + base_aggregates: tuple[BaseAggregate, ...] + composite_definitions: tuple[CompositeDefinition, ...] + + +def measure_plan_for_group( + *, + measures: tuple[ResolvedMetric, ...], + fact_ds: Identifier, + context: PlannerContext, +) -> GroupMeasurePlan: + """Split ``measures`` into base aggregates + composite definitions. + + Base aggregates are de-duplicated and kept in first-seen order; + composites are emitted in the order the user declared them. That + ordering is what ultimately lands in the plan's ``AGGREGATE`` and + ``ADD_COLUMNS`` payloads, so golden-test stability depends on it. + """ + base_order: list[Identifier] = [] + base_seen: dict[Identifier, BaseAggregate] = {} + composites: list[CompositeDefinition] = [] + + def _add_base(name: Identifier, metric_obj: Metric, dataset: Identifier) -> None: + if name in base_seen: + return + base_order.append(name) + base_seen[name] = BaseAggregate(dataset=dataset, metric=metric_obj) + + for resolved in measures: + shape = classify_metric(resolved.metric, context.namespace) + if isinstance(shape, AggregateMetric): + ds = resolved.dataset if resolved.dataset is not None else fact_ds + _add_base(resolved.metric.name, resolved.metric, ds) + continue + assert isinstance(shape, CompositeMetric) + # Inline every nested composite reference so the final derived + # expression only references base aggregate column names. A + # two-level case such as ``avg_doubled = 2 * avg_order_value`` + # (where ``avg_order_value`` is itself composite) becomes + # ``2 * (total_revenue / NULLIF(order_count, 0))``. That keeps + # ADD_COLUMNS as a single flat step regardless of nesting depth. + inlined = _inline_composite_refs( + expression=shape.expression, context=context, fact_ds=fact_ds + ) + stripped = strip_column_qualifiers(inlined) + composites.append( + CompositeDefinition( + name=resolved.metric.name, + metric=resolved.metric, + expression=stripped, + ) + ) + _walk_composite_bases( + shape=shape, context=context, fact_ds=fact_ds, adder=_add_base + ) + + ordered_bases = tuple(base_seen[n] for n in base_order) + return GroupMeasurePlan( + base_aggregates=ordered_bases, + composite_definitions=tuple(composites), + ) + + +def replace_metric_expression(*, metric: Metric, new_expr: FrozenSQL) -> Metric: + """Return a shallow clone of ``metric`` carrying ``new_expr``. + + :class:`Metric` is a frozen pydantic model, so we construct a + fresh one. Used only to funnel a qualifier-stripped expression + through existing helpers without mutating declared models. + """ + return metric.model_copy(update={"expression": new_expr}) + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- + + +def _walk_composite_bases( + *, + shape: CompositeMetric, + context: PlannerContext, + fact_ds: Identifier, + adder: Callable[[Identifier, Metric, Identifier], None], +) -> None: + """Add every transitively-referenced base aggregate via ``adder``.""" + for ref in shape.references: + ref_metric, owner = resolve_metric_by_name( + name=ref.name, dataset=ref.dataset, namespace=context.namespace + ) + owner_dataset = owner if owner is not None else fact_ds + ref_shape = classify_metric(ref_metric, context.namespace) + if isinstance(ref_shape, AggregateMetric): + adder(ref_metric.name, ref_metric, owner_dataset) + continue + _walk_composite_bases( + shape=ref_shape, context=context, fact_ds=fact_ds, adder=adder + ) + + +def _inline_composite_refs( + *, expression: FrozenSQL, context: PlannerContext, fact_ds: Identifier +) -> FrozenSQL: + """Inline composite-metric references into ``expression``. + + Returns a :class:`FrozenSQL` with every composite-metric + reference replaced by that metric's (transitively inlined) + expression. Base aggregate references are left alone — they + address the aggregate column names on the prior AGGREGATE step. + """ + + def _rewrite(node: exp.Expression) -> exp.Expression: + if not isinstance(node, exp.Column): + return node + name = normalize_identifier(node.name) + dataset = normalize_identifier(node.table) if node.table else None + try: + ref_metric, _owner = resolve_metric_by_name( + name=name, dataset=dataset, namespace=context.namespace + ) + except OSIPlanningError: + return node + ref_shape = classify_metric(ref_metric, context.namespace) + if not isinstance(ref_shape, CompositeMetric): + return node + inner = _inline_composite_refs( + expression=ref_shape.expression, context=context, fact_ds=fact_ds + ) + return inner.expr.copy() + + rewritten = expression.expr.copy().transform(_rewrite) + return FrozenSQL.of(rewritten) + + +__all__ = [ + "BaseAggregate", + "CompositeDefinition", + "GroupMeasurePlan", + "measure_plan_for_group", + "replace_metric_expression", +] diff --git a/impl/python/src/osi/planning/planner_context.py b/impl/python/src/osi/planning/planner_context.py new file mode 100644 index 0000000..6d70458 --- /dev/null +++ b/impl/python/src/osi/planning/planner_context.py @@ -0,0 +1,40 @@ +"""Frozen :class:`PlannerContext` — the planner's read-only inputs. + +Bundles the parsed model, namespace, relationship graph, and the +``FoundationFlags`` that were used to admit the model. The planner +holds it by reference and never rebuilds it; query planning over the +same model is pure over this bundle. + +The ``flags`` field carries the *exact* :class:`FoundationFlags` +instance that ``parse_semantic_model`` was called with, so query-time +gates (e.g. semi-join admission in :mod:`osi.planning.classify`) can +honour the same opt-ins the model itself was admitted under. The +default is :meth:`FoundationFlags.strict`, matching the published +Foundation defaults — every flag off. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from osi.config import FoundationFlags +from osi.parsing.graph import RelationshipGraph +from osi.parsing.models import SemanticModel +from osi.parsing.namespace import Namespace + + +@dataclass(frozen=True, slots=True) +class PlannerContext: + """Read-only bundle of the parsed, validated model artefacts. + + The planner holds this by reference and never rebuilds it. Query + planning over the same model is pure over this bundle. + """ + + model: SemanticModel + namespace: Namespace + graph: RelationshipGraph + flags: FoundationFlags = field(default_factory=FoundationFlags) + + +__all__ = ["PlannerContext"] diff --git a/impl/python/src/osi/planning/planner_mn.py b/impl/python/src/osi/planning/planner_mn.py new file mode 100644 index 0000000..3076639 --- /dev/null +++ b/impl/python/src/osi/planning/planner_mn.py @@ -0,0 +1,225 @@ +"""M:N resolution helpers for the planner. + +Carved out of :mod:`osi.planning.planner` so the entry-point file +stays under the LOC cap. Everything here is :pep:`8` private to the +package — only re-exported via ``__all__`` so the planner can import +the small set of symbols it needs without re-exposing the whole +module. + +Three concerns live here: + +* ``Proposed_OSI_Semantics.md §6.5.1`` bridge anchor discovery for + dimension-only queries (:func:`find_bridge_anchors`, + :func:`build_dimension_only_group`). +* ``§6.5.2`` multi-fact stitch validation + (:func:`validate_multi_fact_stitch`). +* ``§6.7`` per-metric ``using_relationships`` intersection + (:func:`group_allowed_relationships`). + +Pure functions over already-resolved planner inputs; no SQL is +generated and no algebra steps are emitted. Errors raised here are +the ``E3001`` / ``E3013`` family the spec mandates. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Sequence + +from osi.common.identifiers import Identifier +from osi.errors import ErrorCode, OSIPlanningError +from osi.planning.joins import datasets_connected, find_enrichment_path +from osi.planning.planner_context import PlannerContext +from osi.planning.resolve import ResolvedDimension, ResolvedMetric + + +@dataclass(frozen=True, slots=True) +class MeasureGroup: + """All measures that share a single fact dataset. + + The same shape as :class:`osi.planning.planner._MeasureGroup`; + re-declared here so this module has no circular dependency on + :mod:`osi.planning.planner`. + """ + + fact_dataset: Identifier + measures: tuple[ResolvedMetric, ...] + + +def find_bridge_anchors( + targets: frozenset[Identifier], context: PlannerContext +) -> tuple[Identifier, ...]: + """Datasets outside ``targets`` that can reach every target safely. + + A bridge candidate is any dataset whose safe-direction enrichment + closure (per :func:`osi.planning.joins.reachable_via_n1`) covers + ``targets``. Used by :func:`build_dimension_only_group` to discover + the ``§6.5.1`` bridge route when no referenced dataset can serve as + anchor. + """ + out: list[Identifier] = [] + for ds in sorted(context.model.datasets, key=lambda d: str(d.name)): + if ds.name in targets: + continue + try: + find_enrichment_path(root=ds.name, targets=targets, graph=context.graph) + except OSIPlanningError: + continue + out.append(ds.name) + return tuple(out) + + +def build_dimension_only_group( + dims: Sequence[ResolvedDimension], context: PlannerContext +) -> tuple[MeasureGroup, ...]: + """Pick a single safe anchor dataset for a dimension-only query. + + Rules (``Proposed_OSI_Semantics.md §5.3`` applied to the + no-measures case): + + * Single-dataset queries always succeed. + * Multi-dataset queries require a unique anchor that can reach + every *other* referenced dataset via a safe N:1 (or 1:1) path. + Picking an anchor this way is *direction-aware* — a 1:N + traversal would be a fan trap and is rejected. + * If no referenced dataset can serve as anchor, fall back to the + ``§6.5.1`` bridge route: any third dataset that itself reaches + every target via safe enrichment. Multiple bridge candidates + raise ``E3001_AMBIGUOUS_JOIN_PATH``. + * Otherwise: re-raise the path-finder's last error (typically + ``E2004`` unreachable, ``E3011`` fan-trap, or ``E3012`` for an + unresolvable N:N). + * More than one valid anchor → + :attr:`ErrorCode.E3001_AMBIGUOUS_JOIN_PATH`: without measures + there is no fact-side signal to break the tie, so the caller + must either drop a dimension or explicitly request a measure. + """ + if not dims: + raise OSIPlanningError( + ErrorCode.E1002_MISSING_REQUIRED_FIELD, + "cannot plan a query with neither dimensions nor measures", + ) + datasets = tuple({d.dataset for d in dims}) + if len(datasets) == 1: + return (MeasureGroup(fact_dataset=datasets[0], measures=()),) + + dataset_set = frozenset(datasets) + valid: list[Identifier] = [] + last_error: OSIPlanningError | None = None + for candidate in sorted(datasets, key=str): + try: + find_enrichment_path( + root=candidate, targets=dataset_set, graph=context.graph + ) + except OSIPlanningError as exc: + last_error = exc + continue + valid.append(candidate) + + if len(valid) == 1: + return (MeasureGroup(fact_dataset=valid[0], measures=()),) + + if not valid: + bridges = find_bridge_anchors(dataset_set, context) + if len(bridges) == 1: + return (MeasureGroup(fact_dataset=bridges[0], measures=()),) + if len(bridges) > 1: + raise OSIPlanningError( + ErrorCode.E3001_AMBIGUOUS_JOIN_PATH, + ( + "dimension-only query touches datasets " + f"{sorted(str(d) for d in datasets)} which are not " + "directly reachable from each other; multiple bridge " + f"datasets {sorted(str(b) for b in bridges)} can " + "resolve the join. Disambiguate with " + "joins.using_relationships on a metric or by adding " + "a measure that selects one bridge." + ), + context={ + "datasets": sorted(str(d) for d in datasets), + "bridge_candidates": sorted(str(b) for b in bridges), + }, + ) + assert last_error is not None + raise last_error + + raise OSIPlanningError( + ErrorCode.E3001_AMBIGUOUS_JOIN_PATH, + ( + "dimension-only query touches datasets " + f"{sorted(str(d) for d in datasets)} with multiple valid " + f"anchors {sorted(str(d) for d in valid)}; add a measure or " + "drop a dimension to resolve" + ), + context={ + "datasets": sorted(str(d) for d in datasets), + "candidates": sorted(str(d) for d in valid), + }, + ) + + +def validate_multi_fact_stitch( + groups: Sequence[MeasureGroup], + dimensions: Sequence[ResolvedDimension], + context: PlannerContext, +) -> None: + """Reject silent Cartesian merges with ``E3013_NO_STITCHING_DIMENSION``. + + Per ``Proposed_OSI_Semantics.md §6.5.2`` the stitch route is + rejected when the query's dimension set is empty AND the two + endpoints share no path. Without this check the planner would + aggregate each fact to ``frozenset()`` grain and emit a + single-row Cartesian merge, which silently fabricates a + relationship that the model never declared. + + Multi-fact queries that *do* provide dimensions surface the same + issue downstream as ``E2004_UNREACHABLE_DATASET`` (the dim is not + reachable from one of the facts), so they are intentionally not + handled here. + """ + if len(groups) <= 1: + return + if dimensions: + return + facts = [g.fact_dataset for g in groups] + for i, a in enumerate(facts): + for b in facts[i + 1 :]: + if not datasets_connected(a, b, context.graph): + raise OSIPlanningError( + ErrorCode.E3013_NO_STITCHING_DIMENSION, + ( + f"facts {a!r} and {b!r} have no path through " + "the relationship graph and the query has no " + "shared dimension; their merge would be a " + "Cartesian product. Add a shared dimension to " + "the query, or declare a relationship that " + "links the two facts." + ), + context={ + "facts": [str(a), str(b)], + }, + ) + + +def group_allowed_relationships( + group: MeasureGroup, +) -> frozenset[Identifier] | None: + """Return per-group relationship whitelist (always ``None`` today). + + Per-metric ``joins.using_relationships`` (``§6.7``) is a deferred + feature in Foundation v0.1; the parsing layer rejects the YAML + key and the pydantic ``Metric`` model has no ``joins`` field, so + no measure can ever carry an override. The helper is kept as a + no-op stub so the planner's call sites read cleanly when the + feature lands and we revive the intersection logic. + """ + return None + + +__all__ = [ + "MeasureGroup", + "build_dimension_only_group", + "find_bridge_anchors", + "group_allowed_relationships", + "validate_multi_fact_stitch", +] diff --git a/impl/python/src/osi/planning/planner_nested.py b/impl/python/src/osi/planning/planner_nested.py new file mode 100644 index 0000000..5a8cc5b --- /dev/null +++ b/impl/python/src/osi/planning/planner_nested.py @@ -0,0 +1,213 @@ +"""Nested cross-grain aggregate planner (D-020 + D-024, `I-S5-impl`). + +The Foundation accepts an explicit nested-aggregate metric like +``AVG(AVG(orders.amount))`` as the *per-row-first* alternative to the +single-step interpretation pinned by D-020. The compilation contract: + +1. Aggregate the inner expression at an **intermediate grain** that + captures the per-row dimension (e.g. one row per ``customer_id`` + for ``AVG(AVG(orders.amount))`` queried by region). +2. Aggregate the result at the **query grain** with the outer + function applied to the intermediate column. + +This module is the planner branch that emits the two-aggregate +shape. It is deliberately narrow: only one outer aggregate, one +inner aggregate, one foreign dataset reachable via a single safe +N:1 step. Anything outside that envelope is left for follow-up +sprints (composes with the bridge planner in S-19 and the +home-grain rewrite in S-20). +""" + +from __future__ import annotations + +from typing import Sequence + +from sqlglot import expressions as exp + +from osi.common.identifiers import Identifier, normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.errors import ErrorCode, OSIParseError, OSIPlanningError +from osi.parsing.models import Metric +from osi.planning.algebra.operations import aggregate +from osi.planning.algebra.state import ( + AggregateFunction, + AggregateInfo, + CalculationState, + Column, + ColumnKind, +) +from osi.planning.metric_shape import _AGG_BY_AST +from osi.planning.plan import ( + AggregatePayload, + PlanOperation, + PlanStep, +) +from osi.planning.planner_context import PlannerContext +from osi.planning.resolve import ResolvedDimension, ResolvedMetric +from osi.planning.steps import PlanBuilder + + +def is_nested_aggregate(metric: Metric) -> bool: + """Return True iff the metric is a top-level aggregate of an aggregate. + + The inner aggregate must be a Foundation function and reference at + least one column. Two-level only — nesting beyond two is rejected + upstream as ``E1206_METRIC_IN_RAW_AGGREGATE`` per the existing + contract. + """ + top = metric.expression.expr + if type(top) not in _AGG_BY_AST: + return False + inner = top.this + if not isinstance(inner, exp.Expression): + return False + if type(inner) not in _AGG_BY_AST: + return False + return True + + +def parse_nested( + metric: Metric, +) -> tuple[AggregateFunction, AggregateFunction, exp.Expression]: + """Return (outer_fn, inner_fn, inner_arg_expression). + + The inner argument is the raw AST node fed to the inner + aggregate; for ``AVG(AVG(orders.amount))`` it is the ``Column`` + ``orders.amount``. + """ + top = metric.expression.expr + outer_fn = _AGG_BY_AST[type(top)] + inner = top.this + inner_fn = _AGG_BY_AST[type(inner)] + inner_arg = inner.this + return outer_fn, inner_fn, inner_arg + + +def insert_nested_aggregate( + *, + parent: PlanStep, + measure: ResolvedMetric, + dimensions: Sequence[ResolvedDimension], + intermediate_grain: frozenset[Identifier], + builder: PlanBuilder, + context: PlannerContext, +) -> PlanStep: + """Append intermediate AGG + final AGG for a single nested measure. + + ``parent`` is the post-enrichment state at the natural fact grain. + ``intermediate_grain`` is the dim set at which the inner aggregate + runs (typically ``{join_key, *query_dims}``). The function returns + the final aggregate step ready for downstream PROJECT. + """ + outer_fn, inner_fn, inner_arg_expr = parse_nested(measure.metric) + inner_dependencies = _collect_dependencies(inner_arg_expr, parent.state) + inner_arg_sql = FrozenSQL.of(inner_arg_expr.copy()) + intermediate_col = Column( + name=measure.metric.name, + expression=measure.metric.expression, + dependencies=inner_dependencies, + kind=ColumnKind.AGGREGATE, + aggregate=AggregateInfo(function=inner_fn, argument=inner_arg_sql), + ) + intermediate = builder.add( + PlanOperation.AGGREGATE, + inputs=(parent.step_id,), + state=aggregate(parent.state, intermediate_grain, (intermediate_col,)), + payload=AggregatePayload( + new_grain=intermediate_grain, aggregations=(intermediate_col,) + ), + ) + final_grain = _query_grain(dimensions, intermediate.state) + outer_arg_sql = FrozenSQL.of(exp.column(str(measure.metric.name))) + final_col = Column( + name=measure.metric.name, + expression=measure.metric.expression, + dependencies=frozenset({measure.metric.name}), + kind=ColumnKind.AGGREGATE, + aggregate=AggregateInfo(function=outer_fn, argument=outer_arg_sql), + ) + return builder.add( + PlanOperation.AGGREGATE, + inputs=(intermediate.step_id,), + state=aggregate(intermediate.state, final_grain, (final_col,)), + payload=AggregatePayload(new_grain=final_grain, aggregations=(final_col,)), + ) + + +# --------------------------------------------------------------------------- +# Intermediate-grain inference +# --------------------------------------------------------------------------- + + +def infer_intermediate_grain( + *, + fact_dataset: Identifier, + dimensions: Sequence[ResolvedDimension], + state_columns: frozenset[Identifier], + context: PlannerContext, +) -> frozenset[Identifier]: + """Pick the intermediate grain for the inner aggregate. + + The grain is ``{join_key_on_fact_side, *query_dim_columns_on_state}``. + The join key is taken from the unique safe N:1 edge whose N-side + is ``fact_dataset``. If that edge is ambiguous or absent we fall + back to the query dim columns alone — which the algebra will + accept iff every dim is single-valued on the fact grain (the + typical case for region-level rollups over per-row aggregates). + """ + join_keys: list[Identifier] = [] + edges = context.graph.neighbors(fact_dataset) + n1_to_dim: list[tuple[Identifier, ...]] = [] + dim_datasets = {d.dataset for d in dimensions} + for edge in edges: + if edge.from_dataset != fact_dataset: + continue + if edge.to_dataset in dim_datasets: + n1_to_dim.append(tuple(edge.from_columns)) + if len(n1_to_dim) == 1: + join_keys.extend(n1_to_dim[0]) + dim_columns = [d.field.name for d in dimensions if d.field.name in state_columns] + return frozenset([*join_keys, *dim_columns]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _query_grain( + dimensions: Sequence[ResolvedDimension], state: CalculationState +) -> frozenset[Identifier]: + return frozenset( + d.field.name for d in dimensions if d.field.name in state.column_names + ) + + +def _collect_dependencies( + arg: exp.Expression, state: CalculationState +) -> frozenset[Identifier]: + deps: set[Identifier] = set() + for col in arg.find_all(exp.Column): + try: + name = normalize_identifier(col.name) + except OSIParseError: + continue + if name in state.column_names: + deps.add(name) + if not deps: + raise OSIPlanningError( + ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY, + ( + "nested aggregate inner expression references no column " + "addressable on the post-enrichment state" + ), + ) + return frozenset(deps) + + +__all__ = [ + "infer_intermediate_grain", + "insert_nested_aggregate", + "is_nested_aggregate", + "parse_nested", +] diff --git a/impl/python/src/osi/planning/planner_scalar.py b/impl/python/src/osi/planning/planner_scalar.py new file mode 100644 index 0000000..e7db00c --- /dev/null +++ b/impl/python/src/osi/planning/planner_scalar.py @@ -0,0 +1,366 @@ +"""Scalar query planner branch (D-011). + +A scalar query (``Proposed_OSI_Semantics.md §5.1.2``) selects a list of +``fields`` from the home grain of one anchor dataset, with no +``GROUP BY`` and no aggregation. It emits one row per anchor row. + +This module owns the scalar shape only. Aggregation queries continue to +flow through :func:`osi.planning.planner.plan`. + +Foundation v0.1 rules enforced here: + +* **D-011 / E_AGGREGATE_IN_SCALAR_QUERY** — a ``fields`` entry must + resolve to a dataset field. A reference that resolves to a + :class:`~osi.planning.resolve.ResolvedMetric` is rejected. +* **D-023 / E_FAN_OUT_IN_SCALAR_QUERY** — every non-anchor field's + dataset must be reachable from the anchor via an N:1 enrichment chain. + Any 1:N or N:N edge ⇒ this code. We translate the + :class:`~osi.planning.joins.find_enrichment_path` ``E3011`` into the + scalar-specific code so callers can route on intent. +* **D-010 / E_EMPTY_SCALAR_QUERY** — handled by + :class:`SemanticQuery.__post_init__`. + +The anchor is the *first field's dataset*. Order matters: the user +controls which side of the relationship is the "row" of the scalar +result by listing that dataset's field first. This matches the +spec's intent that scalar queries preserve home-grain rows. +""" + +from __future__ import annotations + +from typing import Sequence + +from sqlglot import expressions as exp + +from osi.common.identifiers import Identifier, normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.common.windows import is_windowed_expression +from osi.errors import ErrorCode, OSIParseError, OSIPlanningError +from osi.planning.algebra.operations import project +from osi.planning.classify import ( + ClassifiedWhere, + RowLevelPredicate, + classify_where, +) +from osi.planning.joins import JoinStep, find_enrichment_path +from osi.planning.plan import ( + OrderByEntry, + PlanOperation, + PlanStep, + ProjectPayload, + QueryPlan, +) +from osi.planning.planner_context import PlannerContext +from osi.planning.resolve import ( + ResolvedDimension, + ResolvedFact, + ResolvedMetric, + resolve_reference, +) +from osi.planning.semantic_query import OrderBy, Reference, SemanticQuery, SortDirection +from osi.planning.steps import ( + PlanBuilder, + enrich_step, + fact_dataset, + filter_step, + source_step, +) + + +def plan_scalar(query: SemanticQuery, context: PlannerContext) -> QueryPlan: + """Plan a scalar (Fields-only) query. + + Pure; deterministic given the field order in ``query.fields``. + """ + resolved = _resolve_fields(query.fields, context) + # Pick the anchor: the first field whose dataset is bound. Model- + # scoped derived metrics (including windowed ones) carry + # ``dataset=None`` and cannot anchor a scalar query — they need a + # dataset-bound field to define which rows are preserved. + anchor = next((r.dataset for r in resolved if r.dataset is not None), None) + if anchor is None: + raise OSIPlanningError( + ErrorCode.E_EMPTY_SCALAR_QUERY, + ( + "scalar query has no dataset-bound field; add a Fields " + "entry that resolves to a dataset column (or move the " + "windowed metric onto a dataset) so the anchor rows " + "are defined. See Proposed_OSI_Semantics.md D-010." + ), + ) + other_datasets: frozenset[Identifier] = frozenset( + r.dataset for r in resolved if r.dataset is not None and r.dataset != anchor + ) + + builder = PlanBuilder() + enrichment: tuple[JoinStep, ...] = () + if other_datasets: + try: + enrichment = find_enrichment_path( + root=anchor, + targets=other_datasets, + graph=context.graph, + ) + except OSIPlanningError as exc: + if exc.code is ErrorCode.E3011_MN_AGGREGATION_REJECTED: + # D-023: a 1:N traversal in a scalar query would + # multiply the anchor rows. Surface the scalar-specific + # code so the user sees they need an aggregation query. + raise OSIPlanningError( + ErrorCode.E_FAN_OUT_IN_SCALAR_QUERY, + ( + "scalar query references fields across a 1:N " + f"edge from anchor {anchor!r}; the anchor row " + "cannot be preserved without aggregation. " + "Convert to an aggregation query (Dimensions / " + "Measures) or pick the many-side dataset's " + "field first to flip the anchor. See " + "Proposed_OSI_Semantics.md D-023." + ), + context={ + "anchor": anchor, + "fan_out_datasets": sorted(str(d) for d in other_datasets), + }, + ) from exc + raise + + fact_ds = fact_dataset(anchor, context) + current = source_step(fact_ds, builder, context) + for join in enrichment: + current = enrich_step(current, join, builder, context) + + # Split filters into pre-window (against base columns only) and + # post-window (references at least one windowed-metric column). + # The QUALIFY pattern (D-030) requires the post-window predicate + # to land *after* the ADD_COLUMNS that introduces the windowed + # column. + windowed_metric_names = frozenset( + r.metric.name for r in resolved if isinstance(r, ResolvedMetric) + ) + pre_window_predicates, post_window_predicates = _partition_filters( + query.where, windowed_metric_names, context + ) + for pred in pre_window_predicates: + current = filter_step(current, pred, builder) + + # S-22: append windowed-metric definitions as derived ADD_COLUMNS. + # The window AST passes through codegen unchanged (sqlglot renders + # ``OVER(...)`` natively); the column kind is DIMENSION because the + # value is per-row, never per-group. + windowed_metrics = tuple(r for r in resolved if isinstance(r, ResolvedMetric)) + if windowed_metrics: + current = _add_windowed_metric_columns( + current=current, + windowed_metrics=windowed_metrics, + builder=builder, + ) + + for pred in post_window_predicates: + current = filter_step(current, pred, builder) + + output_columns = tuple(_field_or_metric_name(r) for r in resolved) + projected = builder.add( + PlanOperation.PROJECT, + inputs=(current.step_id,), + state=project(current.state, output_columns), + payload=ProjectPayload(columns=output_columns), + ) + + order_by = _resolve_order_by(query.order_by, output_columns) + return QueryPlan( + steps=builder.steps, + root_step_id=projected.step_id, + order_by=order_by, + limit=query.limit, + output_columns=output_columns, + ) + + +def _partition_filters( + where: FrozenSQL | None, + windowed_metric_names: frozenset[Identifier], + context: PlannerContext, +) -> tuple[tuple[RowLevelPredicate, ...], tuple[RowLevelPredicate, ...]]: + """Split row-level WHERE predicates into pre-window vs post-window. + + A predicate is *post-window* iff any of its referenced columns + (after parsing) names a windowed metric. Everything else is + pre-window. Subquery / semi-join predicates are not yet supported + by the scalar planner — if they appear, surface a clean error + rather than silently dropping them. + """ + classified: ClassifiedWhere = classify_where( + where, context.namespace, flags=context.flags + ) + if classified.semi_joins: + raise OSIPlanningError( + ErrorCode.E_DEFERRED_KEY_REJECTED, + ( + "EXISTS_IN / NOT EXISTS_IN is not supported in scalar " + "queries by this engine. Even with " + "FoundationFlags(experimental_exists_in=True), the " + "scalar planner does not lower semi-joins; rewrite as " + "an aggregation query or remove the predicate." + ), + context={ + "deferred_feature": "exists_in_semi_join_in_scalar_query", + "flag": "experimental_exists_in", + "shape": "scalar", + }, + ) + pre: list[RowLevelPredicate] = [] + post: list[RowLevelPredicate] = [] + for pred in classified.row_level: + if pred.columns & windowed_metric_names: + post.append(pred) + else: + pre.append(pred) + return tuple(pre), tuple(post) + + +def _field_or_metric_name( + resolved: ResolvedDimension | ResolvedFact | ResolvedMetric, +) -> Identifier: + if isinstance(resolved, ResolvedMetric): + return resolved.metric.name + return resolved.field.name + + +def _add_windowed_metric_columns( + *, + current: PlanStep, + windowed_metrics: Sequence[ResolvedMetric], + builder: PlanBuilder, +) -> PlanStep: + from osi.planning.algebra.composition import add_columns + from osi.planning.algebra.state import Column, ColumnKind + from osi.planning.plan import AddColumnsPayload + + state_columns = current.state.column_names + definitions: list[Column] = [] + for resolved in windowed_metrics: + body = resolved.metric.expression.expr + deps = frozenset( + normalize_identifier(c.name) + for c in body.find_all(exp.Column) + if normalize_identifier(c.name) in state_columns + ) + definitions.append( + Column( + name=resolved.metric.name, + expression=FrozenSQL.of(body.copy()), + dependencies=deps, + kind=ColumnKind.DIMENSION, + ) + ) + return builder.add( + PlanOperation.ADD_COLUMNS, + inputs=(current.step_id,), + state=add_columns(current.state, tuple(definitions)), + payload=AddColumnsPayload(definitions=tuple(definitions)), + ) + + +def _resolve_fields( + refs: Sequence[Reference], + context: PlannerContext, +) -> tuple[ResolvedDimension | ResolvedFact | ResolvedMetric, ...]: + out: list[ResolvedDimension | ResolvedFact | ResolvedMetric] = [] + for ref in refs: + resolved = resolve_reference(ref, context.namespace) + if isinstance(resolved, ResolvedMetric): + # S-22 (D-028 / D-030): windowed metrics produce one value + # per row, not one value per partition; they are scalar by + # definition and belong in the fields slot. They are + # synthesised as derived ``ADD_COLUMNS`` after the source + # / enrichment chain. + if is_windowed_expression(resolved.metric.expression.expr): + out.append(resolved) + continue + raise OSIPlanningError( + ErrorCode.E_AGGREGATE_IN_SCALAR_QUERY, + ( + f"scalar query field {ref!s} resolves to metric " + f"{resolved.metric.name!r}; metrics aggregate over " + "rows and cannot appear in a Fields list. Move " + "the metric to Measures and switch to an " + "aggregation query. See Proposed_OSI_Semantics.md " + "D-011." + ), + context={ + "field": str(ref), + "metric": resolved.metric.name, + }, + ) + _reject_unaggregated_finer_grain_reference(resolved, context) + out.append(resolved) + return tuple(out) + + +def _reject_unaggregated_finer_grain_reference( + resolved: ResolvedDimension | ResolvedFact, + context: PlannerContext, +) -> None: + """Reject a field body that reads a foreign dataset without aggregating. + + Foundation v0.1 D-024: a field body containing a column from a + different dataset (e.g. ``customers.first_order_amount: + orders.amount``) is only valid when wrapped in an aggregate; the + bare row-level reference would imply fan-out without any rule + for collapsing it. Reject with + ``E_UNAGGREGATED_FINER_GRAIN_REFERENCE``. + """ + home = resolved.dataset + body = resolved.field.expression.expr + if any(isinstance(n, exp.AggFunc) for n in (body, *body.find_all(exp.Expression))): + # The field aggregates internally; implicit home-grain + # aggregation (S-4) takes care of it. No D-024 violation. + return + for col in body.find_all(exp.Column): + if not col.table: + continue + try: + ref_dataset = normalize_identifier(col.table) + except OSIParseError: + continue + if ref_dataset == home: + continue + raise OSIPlanningError( + ErrorCode.E_UNAGGREGATED_FINER_GRAIN_REFERENCE, + ( + f"field {home}.{resolved.field.name!r} references column " + f"{ref_dataset}.{normalize_identifier(col.name)!r} from a " + "different dataset without aggregating it; wrap the " + "reference in an aggregate (SUM, COUNT, …) so the " + "implicit home-grain aggregation can resolve. See " + "Proposed_OSI_Semantics.md D-024." + ), + context={ + "home": home, + "field": resolved.field.name, + "referenced_dataset": ref_dataset, + "referenced_column": normalize_identifier(col.name), + }, + ) + + +def _resolve_order_by( + entries: Sequence[OrderBy], output: tuple[Identifier, ...] +) -> tuple[OrderByEntry, ...]: + out: list[OrderByEntry] = [] + allowed = set(output) + for entry in entries: + col = entry.target.name + if col not in allowed: + raise OSIPlanningError( + ErrorCode.E2002_NAME_NOT_FOUND, + f"order_by column {col!r} is not in the scalar query output", + context={"column": col, "output": sorted(str(o) for o in output)}, + ) + out.append( + OrderByEntry(column=col, descending=entry.direction is SortDirection.DESC) + ) + return tuple(out) + + +__all__ = ["plan_scalar"] diff --git a/impl/python/src/osi/planning/prefixes.py b/impl/python/src/osi/planning/prefixes.py new file mode 100644 index 0000000..63882e6 --- /dev/null +++ b/impl/python/src/osi/planning/prefixes.py @@ -0,0 +1,90 @@ +"""Deterministic synthetic naming for the planner. + +Every synthetic name in the compiler comes from this module. The +``ARCHITECTURE.md §6`` invariant that equal ``(model, query, dialect)`` +triples produce byte-identical SQL depends on every generated identifier +being derivable from inputs only. + +No module outside this file may embed literal prefixes. See the +``import-linter`` contract for the enforced rule. +""" + +from __future__ import annotations + +from typing import Iterable + +from osi.common.identifiers import Identifier, normalize_identifier + +CTE_MEASURE_GROUP = "mg" +CTE_FILTER_JOIN_RHS = "fj" +CTE_MERGED = "merged" +CTE_FINAL = "final" +CTE_STEP = "step" + +SYNTH_COLUMN_AGG_PREFIX = "__agg" +SYNTH_COLUMN_DERIVED_PREFIX = "__derived" + + +def cte_name(prefix: str, index: int) -> Identifier: + """Return ``_`` as a normalized identifier.""" + return normalize_identifier(f"{prefix}_{index}") + + +def step_alias(step_id: int) -> str: + """Return the canonical CTE alias for plan step ``step_id``. + + The Foundation uses zero-padded ``step_000`` form so that + lexicographic order matches numeric order in error messages, plan + dumps, and golden files. All emitters (codegen, diagnostics, + cte_optimizer) must go through this helper. + """ + return f"{CTE_STEP}_{step_id:03d}" + + +def is_step_alias(name: str) -> bool: + """Return ``True`` if ``name`` looks like a step CTE alias. + + Used by the codegen post-processor to identify step CTEs without + re-implementing the format. The check is intentionally + string-prefix only (``cte_optimizer`` cares only about reachability, + not exact step ids). + """ + return name.startswith(f"{CTE_STEP}_") + + +def mangle_join_key(dataset: str, column: str) -> Identifier: + """Return a deterministic synthetic name for a join-side key column.""" + return normalize_identifier(f"__jk_{dataset}__{column}") + + +def synth_aggregate_name(index: int) -> Identifier: + """Return a deterministic name for an unnamed aggregate expression.""" + return normalize_identifier(f"{SYNTH_COLUMN_AGG_PREFIX}_{index}") + + +def synth_derived_name(index: int) -> Identifier: + """Return a deterministic name for an unnamed derived scalar.""" + return normalize_identifier(f"{SYNTH_COLUMN_DERIVED_PREFIX}_{index}") + + +def stable_sorted_identifiers(names: Iterable[Identifier]) -> tuple[Identifier, ...]: + """Return ``names`` sorted deterministically by their string form.""" + return tuple(sorted(names, key=str)) + + +__all__ = [ + "CTE_FILTER_JOIN_RHS", + "CTE_FINAL", + "CTE_MEASURE_GROUP", + "CTE_MERGED", + "CTE_STEP", + "SYNTH_COLUMN_AGG_PREFIX", + "SYNTH_COLUMN_DERIVED_PREFIX", + "cte_name", + "is_step_alias", + "mangle_join_key", + "stable_sorted_identifiers", + "step_alias", + "synth_aggregate_name", + "synth_derived_name", +] diff --git a/impl/python/src/osi/planning/preprocess.py b/impl/python/src/osi/planning/preprocess.py new file mode 100644 index 0000000..3ec688f --- /dev/null +++ b/impl/python/src/osi/planning/preprocess.py @@ -0,0 +1,143 @@ +"""Pre-classification AST rewrites. + +A semantic query's ``where`` / ``having`` may contain two kinds of +references that need to be resolved before the planner sees them as +ordinary predicates: + +1. **Parameter placeholders** — ``:param_name`` (``sqlglot.exp.Placeholder`` + nodes) bound to values supplied on :class:`~osi.planning.SemanticQuery`. +2. **Named-filter references** — bare column-shaped references (e.g. + ``completed_orders``) whose name matches a + :class:`~osi.parsing.models.NamedFilter` declared on the model. + +Both are pure AST → AST rewrites and live outside the algebra. Running +them once up-front keeps :mod:`~osi.planning.classify` focused on its +real job (row-level vs. semi-join vs. post-aggregate splitting). + +Foundation scope (``Proposed_OSI_Semantics.md §4.6`` and §5.1): + +* Parameter values are literals. The :class:`Parameter.data_type` field + is checked lightly — we do not coerce Python objects into SQL types + here; sqlglot's :func:`~sqlglot.expressions.convert` does that for + the common cases (numbers, strings, booleans). +* Named filters are **reusable boolean predicates**. Their inlined form + behaves exactly as if the caller had pasted the filter's expression + in place. +""" + +from __future__ import annotations + +from typing import Mapping, Sequence + +from sqlglot import expressions as exp + +from osi.common.identifiers import Identifier, normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.errors import ErrorCode, OSIPlanningError +from osi.parsing.models import NamedFilter, Parameter + + +def substitute_parameters( + expr: FrozenSQL | None, + *, + provided: Mapping[Identifier, object], + declared: Sequence[Parameter], +) -> FrozenSQL | None: + """Replace every ``:name`` placeholder in ``expr`` with a SQL literal. + + Validation: + + * Every name in ``provided`` must match a declared parameter + (``E2002``). + * Every placeholder must resolve to either a provided value or the + parameter's declared ``default``; otherwise ``E1002`` (no value). + """ + declared_by_name = {p.name: p for p in declared} + _validate_provided_names(provided, declared_by_name) + if expr is None: + return None + + placeholders_present = any(isinstance(n, exp.Placeholder) for n in expr.expr.walk()) + if not placeholders_present and not provided: + return expr + + def _rewrite(node: exp.Expression) -> exp.Expression: + if not isinstance(node, exp.Placeholder) or node.this is None: + return node + name = normalize_identifier(str(node.this)) + param = declared_by_name.get(name) + if param is None: + raise OSIPlanningError( + ErrorCode.E2002_NAME_NOT_FOUND, + f"parameter {name!r} is referenced but not declared on the model", + context={"parameter": name}, + ) + if name in provided: + value = provided[name] + elif param.default is not None: + value = param.default + else: + raise OSIPlanningError( + ErrorCode.E1002_MISSING_REQUIRED_FIELD, + f"parameter {name!r} has no value and no default", + context={"parameter": name}, + ) + return exp.convert(value) + + rewritten = expr.expr.copy().transform(_rewrite) + return FrozenSQL.of(rewritten) + + +def inline_named_filters( + expr: FrozenSQL | None, + *, + filters: Sequence[NamedFilter], + field_names: frozenset[Identifier], +) -> FrozenSQL | None: + """Inline bare :class:`NamedFilter` references in ``expr``. + + A bare column reference (``sqlglot.exp.Column`` with no ``table``) + whose name matches both a field and a named filter is left alone — + the field wins so there's no silent semantic change. Authors with + such a collision must use ``model.filter.`` at the SQL + surface (out of scope today; we simply do not rewrite, and the + normal field lookup path runs). + """ + by_name = {f.name: f for f in filters} + if expr is None or not by_name: + return expr + + def _rewrite(node: exp.Expression) -> exp.Expression: + if not isinstance(node, exp.Column) or node.table: + return node + name = normalize_identifier(node.name) + if name not in by_name or name in field_names: + return node + return by_name[name].expression.expr.copy() + + rewritten = expr.expr.copy().transform(_rewrite) + return FrozenSQL.of(rewritten) + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- + + +def _validate_provided_names( + provided: Mapping[Identifier, object], + declared_by_name: Mapping[Identifier, Parameter], +) -> None: + unknown = [k for k in provided if k not in declared_by_name] + if unknown: + raise OSIPlanningError( + ErrorCode.E2002_NAME_NOT_FOUND, + f"unknown parameter(s): {sorted(str(n) for n in unknown)}", + context={ + "provided": sorted(str(n) for n in provided), + "declared": sorted(str(n) for n in declared_by_name), + }, + ) + + +__all__ = ["inline_named_filters", "substitute_parameters"] diff --git a/impl/python/src/osi/planning/resolve.py b/impl/python/src/osi/planning/resolve.py new file mode 100644 index 0000000..b1badea --- /dev/null +++ b/impl/python/src/osi/planning/resolve.py @@ -0,0 +1,227 @@ +"""Reference resolution against a :class:`~osi.parsing.namespace.Namespace`. + +The planner receives :class:`~osi.planning.semantic_query.Reference` +values as strings-by-shape (``dataset.field`` or bare). This module +converts them into concrete :class:`ResolvedDimension`, +:class:`ResolvedFact`, or :class:`ResolvedMetric` records. Every +resolution failure raises :class:`~osi.errors.OSIPlanningError` (or +:class:`~osi.errors.OSIParseError` for SQL-surface shape violations) +with an :class:`~osi.errors.ErrorCode` drawn from the **parsing / +planning families**. The visible codes from this module today are: + +* ``E2001_AMBIGUOUS_NAME`` — bare name resolves to multiple datasets. +* ``E2002_NAME_NOT_FOUND`` — bare or qualified name not in namespace. +* ``E1206_METRIC_IN_RAW_AGGREGATE`` — caller asked for a measure but + the name resolves to a raw field aggregate. +* ``E1207_FACTS_METRICS_EXCLUSIVE`` — caller asked for a dimension + but the name resolves to a fact or metric. +* ``E_INTERNAL_INVARIANT`` — qualified reference with no dataset + (caller-side invariant violation). + +The planner never catches these; they bubble to the top-level caller. + +Scope (``Proposed_OSI_Semantics.md §4.7``): + +1. Qualified ``dataset.field`` always resolves in that dataset's + namespace. +2. Bare ``name`` searches: + + a. Global metrics (``model.metrics``). + b. Named filters (resolved by callers — not here). + c. Dataset-scoped fields, but only if the name is unambiguous across + all datasets. Otherwise ``E2001_AMBIGUOUS_NAME``. + +3. Table-scoped metrics (``dataset.metric``) are resolvable through + qualified form. + +The planner keeps everything it resolved on the returned record so +downstream stages (classify / joins / planner.plan) don't re-query the +namespace. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Union + +from osi.common.identifiers import Identifier +from osi.errors import ErrorCode, OSIParseError, OSIPlanningError +from osi.parsing.models import Field, FieldRole, Metric +from osi.parsing.namespace import Namespace +from osi.planning.semantic_query import Reference + + +@dataclass(frozen=True, slots=True) +class ResolvedDimension: + """A :class:`Reference` that names a dataset dimension/time-dim field.""" + + dataset: Identifier + field: Field + + +@dataclass(frozen=True, slots=True) +class ResolvedFact: + """A :class:`Reference` that names a dataset fact field.""" + + dataset: Identifier + field: Field + + +@dataclass(frozen=True, slots=True) +class ResolvedMetric: + """A :class:`Reference` that names a metric (table- or model-scoped).""" + + dataset: Identifier | None + metric: Metric + + +ResolvedField = Union[ResolvedDimension, ResolvedFact] +ResolvedReference = Union[ResolvedField, ResolvedMetric] + + +def resolve_reference(ref: Reference, namespace: Namespace) -> ResolvedReference: + """Resolve a :class:`Reference`. Raises ``E2001`` / ``E2002``.""" + if ref.is_qualified: + return _resolve_qualified( + dataset_name=_require_identifier(ref.dataset), + name=ref.name, + namespace=namespace, + ) + return _resolve_bare(ref.name, namespace) + + +def resolve_dimension(ref: Reference, namespace: Namespace) -> ResolvedDimension: + """Resolve a reference and assert it is a dimension. + + Raises :class:`OSIPlanningError` with ``E2002`` if the name does not + exist and ``E1207_FACTS_METRICS_EXCLUSIVE`` if it resolves to a fact + or metric. + """ + resolved = resolve_reference(ref, namespace) + if isinstance(resolved, ResolvedDimension): + return resolved + raise OSIPlanningError( + ErrorCode.E1207_FACTS_METRICS_EXCLUSIVE, + f"{ref} is not a dimension", + context={"reference": str(ref)}, + ) + + +def resolve_measure(ref: Reference, namespace: Namespace) -> ResolvedMetric: + """Resolve a reference and assert it is a metric. + + The Foundation requires every measure to be a declared metric — raw + aggregate SQL in the ``measures`` slot is not allowed (``E1206``). + """ + resolved = resolve_reference(ref, namespace) + if isinstance(resolved, ResolvedMetric): + return resolved + raise OSIPlanningError( + ErrorCode.E1206_METRIC_IN_RAW_AGGREGATE, + f"{ref} is not a declared metric", + context={"reference": str(ref)}, + ) + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- + + +def _resolve_qualified( + *, dataset_name: Identifier, name: Identifier, namespace: Namespace +) -> ResolvedReference: + try: + ds = namespace.get_dataset(dataset_name) + except OSIParseError as exc: + raise OSIPlanningError(exc.code, str(exc), context=dict(exc.context)) from exc + if name in ds.fields: + return _dimension_or_fact(dataset=dataset_name, field=ds.fields[name]) + if name in ds.metrics: + return ResolvedMetric(dataset=dataset_name, metric=ds.metrics[name]) + # S-22: Allow ``.`` qualification for model- + # level metrics. Windowed metrics in particular are scoped to a + # single home dataset (every column reference resolves to one + # dataset), and BI tools commonly write them as + # ``orders.running_total``. The qualifier is allowed iff the + # metric exists at the model level; the *home* of the metric is + # validated downstream when the planner classifies the measure. + if name in namespace.metrics: + return ResolvedMetric(dataset=dataset_name, metric=namespace.metrics[name]) + raise OSIPlanningError( + ErrorCode.E2002_NAME_NOT_FOUND, + f"{dataset_name}.{name} does not name a field or metric", + context={"dataset": dataset_name, "name": name}, + ) + + +def _resolve_bare(name: Identifier, namespace: Namespace) -> ResolvedReference: + if name in namespace.metrics: + return ResolvedMetric(dataset=None, metric=namespace.metrics[name]) + try: + owner = namespace.resolve_bare(name) + except OSIParseError as exc: + # The namespace raises OSIParseError with the specific E2001 / + # E2002 code; re-raise as OSIPlanningError preserving the code. + raise OSIPlanningError( + exc.code, + str(exc), + context=dict(exc.context), + ) from exc + ds_ns = namespace.get_dataset(owner) + # Dataset-scoped bare names may be either fields *or* table-scoped + # metrics — both are indexed by the namespace's bare-name index. + # Prefer fields (they're the common case); fall through to metrics + # so a bare metric name such as ``total_revenue`` resolves to the + # dataset's declared metric rather than raising a KeyError. + if name in ds_ns.fields: + return _dimension_or_fact(dataset=owner, field=ds_ns.fields[name]) + if name in ds_ns.metrics: + return ResolvedMetric(dataset=owner, metric=ds_ns.metrics[name]) + raise OSIPlanningError( # pragma: no cover — namespace.resolve_bare guards + ErrorCode.E2002_NAME_NOT_FOUND, + f"bare name {name!r} resolved to dataset {owner!r} but is neither a " + "field nor a table-scoped metric there", + context={"name": name, "dataset": owner}, + ) + + +def _dimension_or_fact(*, dataset: Identifier, field: Field) -> ResolvedField: + if field.role is FieldRole.FACT: + return ResolvedFact(dataset=dataset, field=field) + return ResolvedDimension(dataset=dataset, field=field) + + +def _require_identifier(value: Identifier | None) -> Identifier: + """Force-unwrap an optional ``dataset`` for a qualified reference. + + ``Reference.is_qualified`` returns ``True`` only when ``dataset`` + is populated, so this should be unreachable. We still raise a + typed :class:`OSIPlanningError` with + :attr:`ErrorCode.E_INTERNAL_INVARIANT` rather than a bare + ``AssertionError`` so the "every failure carries a code" + invariant of :class:`OSIError` is preserved. + """ + if value is None: + raise OSIPlanningError( + ErrorCode.E_INTERNAL_INVARIANT, + ( + "qualified reference reached resolve._require_identifier " + "with dataset=None; Reference.is_qualified should have " + "guaranteed a non-None dataset" + ), + context={"caller": "osi.planning.resolve._require_identifier"}, + ) + return value + + +__all__ = [ + "ResolvedDimension", + "ResolvedFact", + "ResolvedField", + "ResolvedMetric", + "ResolvedReference", + "resolve_dimension", + "resolve_measure", + "resolve_reference", +] diff --git a/impl/python/src/osi/planning/semantic_query.py b/impl/python/src/osi/planning/semantic_query.py new file mode 100644 index 0000000..516d4f5 --- /dev/null +++ b/impl/python/src/osi/planning/semantic_query.py @@ -0,0 +1,155 @@ +"""The :class:`SemanticQuery` value type — the planner's input. + +A semantic query is a structured, dialect-agnostic request over a +:class:`~osi.parsing.models.SemanticModel`. Its shape mirrors +``Proposed_OSI_Semantics.md §5.1``: dimensions, measures, a pre- +aggregation ``where`` predicate, a post-aggregation ``having`` predicate, +``order_by``, ``limit``, and bind parameters. + +References within the query are always fully qualified strings +(``dataset.field``). The planner resolves those into +:class:`~osi.parsing.namespace.Namespace` lookups; anything the parser +module doesn't know how to index is a hard error (``E2002``). + +The Foundation does **not** expose: fixed-grain metric overrides, +per-metric filter context, grain modifiers on a query, window functions, +grouping-set / pivot operators, or metric reset. Attempting to construct +a query with those shapes raises ``E_DEFERRED_KEY_REJECTED`` at parse +time. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum +from types import MappingProxyType +from typing import Mapping, Optional + +from osi.common.identifiers import Identifier +from osi.common.sql_expr import FrozenSQL +from osi.errors import ErrorCode, OSIParseError + +_EMPTY_PARAMETERS: Mapping[Identifier, object] = MappingProxyType({}) + + +class SortDirection(StrEnum): + """Sort direction for :class:`OrderBy` entries.""" + + ASC = "ASC" + DESC = "DESC" + + +@dataclass(frozen=True, slots=True) +class Reference: + """A ``dataset.field`` or bare-metric reference used by a query.""" + + dataset: Optional[Identifier] + name: Identifier + + @property + def is_qualified(self) -> bool: + """Return whether this reference names a dataset.""" + return self.dataset is not None + + def __str__(self) -> str: + """Render as ``dataset.name`` (or bare ``name`` when unqualified).""" + if self.dataset is None: + return str(self.name) + return f"{self.dataset}.{self.name}" + + +@dataclass(frozen=True, slots=True) +class OrderBy: + """One entry in the query's ``order_by`` clause.""" + + target: Reference + direction: SortDirection = SortDirection.ASC + + +@dataclass(frozen=True, slots=True) +class SemanticQuery: + """Structured semantic query over a model. + + Immutable; the planner never mutates a query. + + Foundation v0.1 (D-010 / D-011) recognises two query shapes: + + 1. **Aggregation query** — ``dimensions`` and/or ``measures``; + result cardinality is ``DISTINCT(dimensions)`` (§5.1.1). + 2. **Scalar query** — ``fields`` only; result cardinality is the + home-grain row set (§5.1.2). + + Mixing the two ⇒ :data:`ErrorCode.E_MIXED_QUERY_SHAPE`. An empty + aggregation query ⇒ :data:`ErrorCode.E_EMPTY_AGGREGATION_QUERY`; + an empty scalar query ⇒ :data:`ErrorCode.E_EMPTY_SCALAR_QUERY`. + The full scalar-query planner branch lands in S-2 + S-7; this + class is the parse-time gate. + """ + + dimensions: tuple[Reference, ...] = () + measures: tuple[Reference, ...] = () + fields: tuple[Reference, ...] = () + where: Optional[FrozenSQL] = None + having: Optional[FrozenSQL] = None + order_by: tuple[OrderBy, ...] = () + limit: Optional[int] = None + parameters: Mapping[Identifier, object] = field( + default_factory=lambda: _EMPTY_PARAMETERS + ) + + def __post_init__(self) -> None: + """Enforce §5.1 query-shape rules + freeze parameters.""" + is_aggregation = bool(self.dimensions or self.measures) + is_scalar = bool(self.fields) + if is_aggregation and is_scalar: + raise OSIParseError( + ErrorCode.E_MIXED_QUERY_SHAPE, + ( + "semantic query mixes aggregation shape " + "(dimensions/measures) with scalar shape (fields); " + "see Proposed_OSI_Semantics.md D-010" + ), + ) + if not is_aggregation and not is_scalar: + # Distinguish the two empty cases — D-010 / D-011 each + # specify their own error code so callers can tell which + # shape the user *intended*. Without any signal, default + # to E_EMPTY_AGGREGATION_QUERY because the aggregation + # shape is the historical default. + raise OSIParseError( + ErrorCode.E_EMPTY_AGGREGATION_QUERY, + ( + "semantic query must declare dimensions, measures, " + "or fields; see Proposed_OSI_Semantics.md D-010" + ), + ) + if self.limit is not None and self.limit < 0: + raise OSIParseError( + ErrorCode.E1004_TYPE_MISMATCH, + "limit must be non-negative", + context={"limit": self.limit}, + ) + if not isinstance(self.parameters, MappingProxyType): + object.__setattr__( + self, + "parameters", + MappingProxyType(dict(self.parameters)), + ) + + @property + def is_aggregation(self) -> bool: + """True when this query has the aggregation shape (D-010).""" + return bool(self.dimensions or self.measures) + + @property + def is_scalar(self) -> bool: + """True when this query has the scalar shape (D-011).""" + return bool(self.fields) + + +__all__ = [ + "OrderBy", + "Reference", + "SemanticQuery", + "SortDirection", +] diff --git a/impl/python/src/osi/planning/steps.py b/impl/python/src/osi/planning/steps.py new file mode 100644 index 0000000..a83d7f1 --- /dev/null +++ b/impl/python/src/osi/planning/steps.py @@ -0,0 +1,626 @@ +"""Step-builder helpers that construct individual :class:`PlanStep` nodes. + +These helpers are the direct bridge between the planner's topology +decisions and the closed algebra operators. Each helper: + +* runs the corresponding algebra operator (so every ``E3xxx`` / ``E4xxx`` + safety check fires at plan time, not codegen time), and +* records the resulting state plus a :class:`PlanPayload` on a fresh + :class:`PlanStep` via the :class:`PlanBuilder` accumulator. + +Keeping these in their own module lets :mod:`osi.planning.planner` stay +at the *what-flows-where* level of abstraction, under the 600-LOC cap +mandated by ``ARCHITECTURE.md``. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Sequence + +from osi.common.identifiers import Identifier +from osi.common.types import DimensionSet +from osi.errors import ErrorCode, OSIPlanningError +from osi.parsing.field_deps import field_inter_field_dependencies +from osi.parsing.models import Dataset, Field +from osi.planning.algebra.composition import add_columns +from osi.planning.algebra.joins import filtering_join, merge +from osi.planning.algebra.operations import enrich, filter_, source +from osi.planning.algebra.state import CalculationState, Column, ColumnKind +from osi.planning.classify import RowLevelPredicate, SemiJoinPredicate +from osi.planning.columns import field_to_column +from osi.planning.home_grain import rewrite_field_for_home_grain +from osi.planning.joins import JoinStep +from osi.planning.plan import ( + AddColumnsPayload, + EnrichDerivedPayload, + EnrichPayload, + FilteringJoinPayload, + FilterPayload, + MergePayload, + PlanOperation, + PlanPayload, + PlanStep, + SourcePayload, +) +from osi.planning.planner_context import PlannerContext +from osi.planning.resolve import ResolvedDimension + + +class PlanBuilder: + """Accumulates plan steps in topological order. + + Each :meth:`add` call returns the freshly minted step so callers can + thread it into downstream ``inputs``. The accumulator guarantees + each step's ``step_id`` equals its position in the final plan. + """ + + def __init__(self) -> None: + self._steps: list[PlanStep] = [] + + def add( + self, + operation: PlanOperation, + *, + inputs: tuple[int, ...], + state: CalculationState, + payload: PlanPayload, + ) -> PlanStep: + """Append a new step and return it. + + ``inputs`` is preserved in caller order. Symmetric operators + (e.g. MERGE) sort their inputs at the call site; asymmetric + operators (ENRICH, ENRICH_DERIVED, FILTERING_JOIN) carry + positional meaning — input 0 is parent / left, input 1 is + child / right — which sorting would silently scramble. + """ + step_id = len(self._steps) + step = PlanStep( + step_id=step_id, + operation=operation, + inputs=tuple(inputs), + state=state, + payload=payload, + ) + self._steps.append(step) + return step + + @property + def steps(self) -> tuple[PlanStep, ...]: + """Return the accumulated steps in topological order.""" + return tuple(self._steps) + + +def _field_to_column_with_home_grain_rewrite( + field: Field, + *, + home: Identifier, + context: PlannerContext, + datasets_by_name: dict[Identifier, Dataset], + sibling_field_names: frozenset[Identifier], +) -> Column: + """Convert ``field`` to a :class:`Column` after the home-grain rewrite. + + The rewrite handles implicit cross-grain aggregates per D-003 + + D-015 first, then this helper builds the algebra column. + + Implicit home-grain aggregation is a parser-side concept (the + field declares its home dataset by where it lives), but the + rewrite needs the relationship graph to find the correlation + edge. We do it here, at the SOURCE / ENRICH boundary, so the + algebra never sees a cross-grain column expression. + + ``sibling_field_names`` is forwarded to :func:`field_to_column` + so inter-field dependencies are recorded on the resulting + :class:`Column`. The staging logic in :func:`_emit_dataset` reads + those dependencies to decide whether a field belongs in the + SOURCE step's projection or in a downstream ``ADD_COLUMNS`` + stage. + """ + rewritten_expr = rewrite_field_for_home_grain( + field, + home=home, + graph=context.graph, + datasets_by_name=datasets_by_name, + ) + if rewritten_expr is field.expression: + target = field + else: + target = field.model_copy(update={"expression": rewritten_expr}) + return field_to_column(target, sibling_field_names=sibling_field_names) + + +def fact_dataset(name: Identifier, context: PlannerContext) -> Dataset: + """Look up a :class:`Dataset` by identifier in the planner context.""" + for ds in context.model.datasets: + if ds.name == name: + return ds + raise OSIPlanningError( + ErrorCode.E2002_NAME_NOT_FOUND, + f"dataset {name!r} not declared", + context={"name": name}, + ) + + +def _topo_levels_by_dependency( + columns: tuple[Column, ...], +) -> tuple[tuple[Column, ...], ...]: + """Group columns into topo levels by inter-column dependencies. + + Level 0 contains every column with no dependencies on other + columns in the input set. Level k+1 contains every column whose + dependencies are all satisfied by levels 0…k. This is Kahn's + algorithm with explicit level construction so the planner can + map level k → one CTE step. + + The order *within* a level is preserved from ``columns`` so the + SQL output is stable across runs (deterministic for goldens). + + Cycles are not reachable here — :func:`field_inter_field_dependencies` + is parsed-time-checked by :func:`_check_field_dependency_cycles` + in :mod:`osi.parsing.foundation`. We still assert acyclicity + defensively because the planner is not a trust boundary; an + upstream regression that disabled the parser check would otherwise + produce a silently wrong plan instead of a loud failure. + """ + by_name: dict[Identifier, Column] = {col.name: col for col in columns} + known_names = frozenset(by_name) + pending = {col.name: col.dependencies & known_names for col in columns} + levels: list[tuple[Column, ...]] = [] + placed: set[Identifier] = set() + while pending: + ready = [name for name, deps in pending.items() if deps.issubset(placed)] + if not ready: + raise OSIPlanningError( + ErrorCode.E_FIELD_DEPENDENCY_CYCLE, + "internal: unresolved field dependency cycle in planner " + f"({sorted(str(n) for n in pending)})", + context={"unresolved": sorted(str(n) for n in pending)}, + ) + ordered_level = tuple( + by_name[name] for name in columns_order_filter(columns, ready) + ) + levels.append(ordered_level) + for name in ready: + placed.add(name) + del pending[name] + return tuple(levels) + + +def columns_order_filter( + columns: tuple[Column, ...], names: list[Identifier] +) -> list[Identifier]: + """Return ``names`` reordered to match the original ``columns`` order. + + Stability of the per-level ordering matters because the SQL + golden snapshots pin the exact projection order; if Kahn's + algorithm reordered fields by hash insertion order the goldens + would churn for non-semantic reasons. + """ + name_set = set(names) + return [col.name for col in columns if col.name in name_set] + + +def _emit_dataset( + dataset: Dataset, builder: PlanBuilder, context: PlannerContext +) -> PlanStep: + """Stage ``dataset``'s fields into SOURCE + ADD_COLUMNS levels. + + Strategy + -------- + 1. Build a :class:`Column` for every field (with inter-field + dependencies recorded — see + :func:`osi.parsing.field_deps.field_inter_field_dependencies`). + 2. Topologically partition the columns into levels by + inter-field dependency. + 3. Emit the level-0 columns inside the SOURCE step (these have no + sibling-field deps and so can be projected directly from the + physical table). + 4. For each subsequent level, emit one ADD_COLUMNS step that adds + that level's columns on top of the previous step. + + Returns the final step (either the SOURCE itself when no derived + fields exist, or the deepest ADD_COLUMNS step). + + Why this matters + ---------------- + Without staging the planner would inline every derived field's + expression into a single ``SELECT``, producing + ``SELECT amount - discount AS net_amount, net_amount * 2 AS net_doubled``. + That relies on lateral column aliasing within a single ``SELECT``, + which DuckDB and BigQuery accept but Snowflake, PostgreSQL, and + SQLite reject. Staging emits one CTE per level so each derived + field references a *committed* alias from the prior CTE — valid + on every dialect. + """ + pk: DimensionSet = frozenset(dataset.primary_key) + if not pk: + raise OSIPlanningError( + ErrorCode.E2007_MISSING_PRIMARY_KEY, + f"dataset {dataset.name!r} has no primary key; cannot be planned", + context={"dataset": dataset.name}, + ) + datasets_by_name = {ds.name: ds for ds in context.model.datasets} + sibling_names = frozenset(f.name for f in dataset.fields) + columns_in_field_order: list[Column] = [] + for f in dataset.fields: + columns_in_field_order.append( + _field_to_column_with_home_grain_rewrite( + f, + home=dataset.name, + context=context, + datasets_by_name=datasets_by_name, + sibling_field_names=sibling_names, + ) + ) + columns_tuple = tuple(columns_in_field_order) + levels = _topo_levels_by_dependency(columns_tuple) + + base_level = levels[0] if levels else () + base_dims = [c for c in base_level if c.kind is ColumnKind.DIMENSION] + base_facts = [c for c in base_level if c.kind is ColumnKind.FACT] + # Plumb declared UKs through to the algebra (INFRA.md I-16). The + # graph layer already accepts UK matches for cardinality inference + # (parsing/graph.py:_columns_match_any_key); without this line the + # algebra would only know about the PK and reject N:1 enrichments + # joined on a UK column with E3011 — see + # tests/e2e/test_cardinality_safety.py for the regression pin. + uks = tuple(frozenset(uk) for uk in dataset.unique_keys) + base_state = source( + primary_key=pk, + dimension_columns=base_dims, + fact_columns=base_facts, + unique_keys=uks, + ) + current = builder.add( + PlanOperation.SOURCE, + inputs=(), + state=base_state, + payload=SourcePayload( + dataset=dataset.name, + primary_key=pk, + source=dataset.source, + ), + ) + for derived_level in levels[1:]: + new_state = add_columns(current.state, derived_level) + # Once a derived column is materialised in its CTE, + # downstream operators (AGGREGATE, MERGE, ENRICH) see it as + # an addressable name, not as an expression with sibling + # dependencies. The algebra validator (I-6) requires every + # column's dependencies to be a subset of the surrounding + # state's column names — left intact, the deps would survive + # past AGGREGATE (which prunes columns) and trip the + # validator on the post-aggregate state. Stripping them here + # is the in-state equivalent of "this column is now a leaf + # reference, not a derived expression". See + # tests/unit/planning/test_field_staging.py for the + # post-aggregate regression pin. + current = builder.add( + PlanOperation.ADD_COLUMNS, + inputs=(current.step_id,), + state=_materialise_derived(new_state, derived_level), + payload=AddColumnsPayload(definitions=derived_level), + ) + return current + + +def _materialise_derived( + state: CalculationState, derived: tuple[Column, ...] +) -> CalculationState: + """Replace each ``derived`` column in ``state`` with a deps-cleared copy. + + Returns a new :class:`CalculationState` where every column listed + in ``derived`` has its ``dependencies`` field set to the empty + frozenset. Other state attributes (grain, UKs, provenance) are + preserved verbatim. + + See :func:`_emit_dataset` for the rationale. + """ + derived_names = {col.name for col in derived} + rebuilt: list[Column] = [] + for col in state.columns: + if col.name in derived_names and col.dependencies: + rebuilt.append(replace(col, dependencies=frozenset())) + else: + rebuilt.append(col) + return CalculationState( + grain=state.grain, + columns=tuple(rebuilt), + provenance=state.provenance, + unique_keys=state.unique_keys, + ) + + +def source_step( + dataset: Dataset, builder: PlanBuilder, context: PlannerContext +) -> PlanStep: + """Emit a SOURCE step (plus any staged ADD_COLUMNS) for ``dataset``. + + Returns the *final* step, which is either the SOURCE itself + (when no field references another field on the same dataset) or + the deepest ADD_COLUMNS step staged on top of it. Downstream + callers should use the returned step's ``step_id`` and ``state`` + as the dataset's logical handle without caring how many CTEs + were emitted underneath. + """ + return _emit_dataset(dataset, builder, context) + + +def filter_step( + parent: PlanStep, predicate: RowLevelPredicate, builder: PlanBuilder +) -> PlanStep: + """Emit a pre-aggregate FILTER step against ``parent``.""" + return builder.add( + PlanOperation.FILTER, + inputs=(parent.step_id,), + state=filter_( + parent.state, + predicate.expression, + dependencies=predicate.columns, + ), + payload=FilterPayload( + predicate=predicate.expression, + dependencies=predicate.columns, + is_post_aggregate=False, + ), + ) + + +def _child_has_inter_field_deps(child_ds: Dataset) -> bool: + """Return True iff any field on ``child_ds`` references another sibling. + + Used by :func:`enrich_step` to decide between the compact inline + enrich path (no derived columns ⇒ render the child as + ``JOIN raw_table``) and the staged path (some field references + another ⇒ stage the child as SOURCE + ADD_COLUMNS and use + ENRICH_DERIVED so codegen projects the staged columns by name). + """ + sibling_names = frozenset(f.name for f in child_ds.fields) + for f in child_ds.fields: + if field_inter_field_dependencies(f, sibling_names): + return True + return False + + +def enrich_step( + parent: PlanStep, + join: JoinStep, + builder: PlanBuilder, + context: PlannerContext, +) -> PlanStep: + """Emit an ENRICH step that pulls ``join.child`` into ``parent``. + + Two emit shapes + --------------- + * **Inline enrich** (the common case): when the child dataset has + no inter-field dependencies every column projects directly off + the physical table, so we emit a single ENRICH step with + ``EnrichPayload.child_source`` pointing at the underlying + table. This is the historical shape and remains the default + because it minimises CTE count for the typical model. + + * **Staged enrich** (when needed): when at least one child field + references another field on the same dataset, inlining the + expressions would force lateral column aliasing within the + ENRICH ``SELECT`` — non-portable. We instead emit the child + via :func:`_emit_dataset` (SOURCE + ADD_COLUMNS) and follow it + with an ENRICH step that reads the staged child as a CTE + input via :class:`EnrichDerivedPayload`. Codegen for derived + enrich projects child columns *by name*, never by re-rendering + the original expressions, so the staging guarantees portable + SQL on every dialect. + + A child column whose name equals a child-side join key (e.g. + ``customers.id`` when joining on ``customer_id == id``) and whose + name also exists on the parent is dropped as redundant; any + *other* name collision is raised as + :attr:`ErrorCode.E3005_COLUMN_NAME_COLLISION` by :func:`enrich`. + """ + child_ds = fact_dataset(join.child, context) + if _child_has_inter_field_deps(child_ds): + return _enrich_step_staged(parent, child_ds, join, builder, context) + return _enrich_step_inline(parent, child_ds, join, builder, context) + + +def _enrich_step_inline( + parent: PlanStep, + child_ds: Dataset, + join: JoinStep, + builder: PlanBuilder, + context: PlannerContext, +) -> PlanStep: + """Single-step ENRICH where the child renders as ``JOIN raw_table``. + + Used when the child dataset has no inter-field dependencies (so + every child column is a direct projection over the physical + table). Codegen will inline each column's expression in the + enrich ``SELECT`` — safe here because no column references + another sibling alias. + """ + child_pk: DimensionSet = frozenset(child_ds.primary_key) + if not child_pk: + raise OSIPlanningError( + ErrorCode.E2007_MISSING_PRIMARY_KEY, + f"dataset {child_ds.name!r} has no primary key; cannot enrich", + context={"dataset": child_ds.name}, + ) + child_datasets_by_name = {ds.name: ds for ds in context.model.datasets} + sibling_names = frozenset(f.name for f in child_ds.fields) + child_dims: list[Column] = [] + child_facts: list[Column] = [] + for f in child_ds.fields: + col = _field_to_column_with_home_grain_rewrite( + f, + home=child_ds.name, + context=context, + datasets_by_name=child_datasets_by_name, + sibling_field_names=sibling_names, + ) + if col.kind is ColumnKind.FACT: + child_facts.append(col) + else: + child_dims.append(col) + child_uks = tuple(frozenset(uk) for uk in child_ds.unique_keys) + child_state = source( + primary_key=child_pk, + dimension_columns=child_dims, + fact_columns=child_facts, + unique_keys=child_uks, + ) + parent_names = parent.state.column_names + drops = frozenset(k for k in join.child_keys if k in parent_names) + new_state = enrich( + parent.state, + child_state, + parent_keys=join.parent_keys, + child_keys=join.child_keys, + join_type=join.join_type, + drop_child_columns=drops, + ) + surfaced_children = tuple(c for c in child_state.columns if c.name not in drops) + return builder.add( + PlanOperation.ENRICH, + inputs=(parent.step_id,), + state=new_state, + payload=EnrichPayload( + child_dataset=join.child, + child_columns=surfaced_children, + keys=join.keys, + join_type=join.join_type, + child_source=child_ds.source, + parent_keys=join.parent_keys, + child_keys=join.child_keys, + ), + ) + + +def _enrich_step_staged( + parent: PlanStep, + child_ds: Dataset, + join: JoinStep, + builder: PlanBuilder, + context: PlannerContext, +) -> PlanStep: + """Staged ENRICH where the child is materialised as upstream CTEs. + + Used when the child dataset has at least one field that + references another sibling field. We emit the child as a + SOURCE + one or more ADD_COLUMNS steps via :func:`_emit_dataset` + so each derived field is committed in its own CTE, then + ENRICH-derived against the parent. Codegen reads child columns + by name from the staged CTE (see + :func:`osi.codegen.transpiler._render_enrich_derived`) — never + by re-rendering the original expressions — so the resulting SQL + is portable across dialects. + """ + child_step = _emit_dataset(child_ds, builder, context) + parent_names = parent.state.column_names + drops = frozenset(k for k in join.child_keys if k in parent_names) + new_state = enrich( + parent.state, + child_step.state, + parent_keys=join.parent_keys, + child_keys=join.child_keys, + join_type=join.join_type, + drop_child_columns=drops, + ) + surfaced_children = tuple( + c for c in child_step.state.columns if c.name not in drops + ) + return builder.add( + PlanOperation.ENRICH, + inputs=(parent.step_id, child_step.step_id), + state=new_state, + payload=EnrichDerivedPayload( + child_columns=surfaced_children, + keys=join.keys, + join_type=join.join_type, + parent_keys=join.parent_keys, + child_keys=join.child_keys, + ), + ) + + +def semi_join_step( + parent: PlanStep, + sj: SemiJoinPredicate, + builder: PlanBuilder, + context: PlannerContext, +) -> PlanStep: + """Emit a FILTERING_JOIN step for an ``EXISTS_IN`` / ``NOT EXISTS_IN``.""" + rhs_datasets = {pair.rhs_dataset for pair in sj.pairs} + if len(rhs_datasets) != 1: + raise OSIPlanningError( + ErrorCode.E1208_UNSUPPORTED_SQL_CONSTRUCT, + "EXISTS_IN pairs must all reference the same rhs dataset", + context={"datasets": sorted(str(d) for d in rhs_datasets)}, + ) + rhs_name = next(iter(rhs_datasets)) + rhs_step = source_step(fact_dataset(rhs_name, context), builder, context) + lhs_keys = frozenset(p.outer_column for p in sj.pairs) + rhs_keys = frozenset(p.rhs_column for p in sj.pairs) + return builder.add( + PlanOperation.FILTERING_JOIN, + inputs=(parent.step_id, rhs_step.step_id), + state=filtering_join( + parent.state, + rhs_step.state, + lhs_keys=lhs_keys, + rhs_keys=rhs_keys, + mode=sj.mode, + ), + payload=FilteringJoinPayload( + lhs_keys=lhs_keys, + rhs_keys=rhs_keys, + mode=sj.mode, + ), + ) + + +def merge_groups( + groups: Sequence[PlanStep], + dims: Sequence[ResolvedDimension], + builder: PlanBuilder, +) -> PlanStep: + """Chain MERGE steps across measure-group roots at the shared grain. + + Rejects empty input (``E3002``) and any grain mismatch (``E3008``) + before the algebra can raise. The ``dims`` argument is reserved for + output ordering; PROJECT surfaces dims explicitly later. + """ + if not groups: + raise OSIPlanningError( + ErrorCode.E3002_UNSATISFIABLE_GRAIN, + "planner produced no measure groups", + ) + current = groups[0] + for right in groups[1:]: + if current.state.grain != right.state.grain: + raise OSIPlanningError( + ErrorCode.E3008_GRAIN_MISMATCH_MERGE, + "measure groups must reach the same grain before merging", + context={ + "left": sorted(str(g) for g in current.state.grain), + "right": sorted(str(g) for g in right.state.grain), + }, + ) + current = builder.add( + PlanOperation.MERGE, + inputs=tuple(sorted((current.step_id, right.step_id))), + state=merge(current.state, right.state, on=current.state.grain), + payload=MergePayload(on=current.state.grain), + ) + _ = dims + return current + + +__all__ = [ + "PlanBuilder", + "enrich_step", + "fact_dataset", + "filter_step", + "merge_groups", + "semi_join_step", + "source_step", +] diff --git a/impl/python/src/osi/py.typed b/impl/python/src/osi/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/impl/python/src/osi/py.typed @@ -0,0 +1 @@ + diff --git a/impl/python/tests/benchmarks/__init__.py b/impl/python/tests/benchmarks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/impl/python/tests/benchmarks/test_compile_perf.py b/impl/python/tests/benchmarks/test_compile_perf.py new file mode 100644 index 0000000..6c08e80 --- /dev/null +++ b/impl/python/tests/benchmarks/test_compile_perf.py @@ -0,0 +1,133 @@ +"""Phase 6 — performance baselines for the compile pipeline. + +These benchmarks measure the *pure compile* cost (parse → plan → +codegen) against the TPC-DS Foundation model. They're the signal we use +to detect regressions: a commit that doubles a benchmark median is a +regression even if every unit test still passes. + +``make bench`` runs this file via ``pytest -m benchmark``. The CI job +stores results in ``.benchmarks/`` and compares against the last run. +""" + +from __future__ import annotations + +import pytest +import sqlglot + +from osi.codegen import Dialect, compile_plan +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.parsing.graph import build_graph +from osi.parsing.namespace import build_namespace +from osi.parsing.parser import parse_semantic_model +from osi.planning import OrderBy, Reference, SemanticQuery, SortDirection, plan +from osi.planning.planner_context import PlannerContext +from tests.e2e.tpcds_fixtures import load_tpcds_context + + +def _ref(ds: str, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds), + name=normalize_identifier(name), + ) + + +def _sql(expr: str) -> FrozenSQL: + return FrozenSQL.of(sqlglot.parse_one(expr)) + + +@pytest.fixture(scope="module") +def context() -> PlannerContext: + return load_tpcds_context() + + +@pytest.mark.benchmark(group="parse") +def test_benchmark__parse_tpcds_model(benchmark) -> None: + """Baseline: parse + validate the TPC-DS Foundation model end-to-end.""" + from pathlib import Path + + model_path = ( + Path(__file__).resolve().parents[2] / "examples" / "models" / "tpcds_thin.yaml" + ) + source = model_path.read_text() + + def _round() -> PlannerContext: + result = parse_semantic_model(source) + return PlannerContext( + model=result.model, + namespace=build_namespace(result.model), + graph=build_graph(result.model), + ) + + ctx = benchmark(_round) + assert ctx.model.name == "tpcds_thin" + + +@pytest.mark.benchmark(group="plan") +def test_benchmark__plan_multi_fact_merge(context, benchmark) -> None: + """Plan cost for a two-fact merge across a shared dimension.""" + q = SemanticQuery( + dimensions=(_ref("store", "s_state"),), + measures=( + _ref("store_sales", "total_sales"), + _ref("store_returns", "total_returns"), + ), + ) + result = benchmark(plan, q, context) + assert result.root is not None + + +@pytest.mark.benchmark(group="compile") +def test_benchmark__compile_simple_aggregate(context, benchmark) -> None: + """Compile: plan + codegen for a single-table aggregate.""" + q = SemanticQuery( + dimensions=(_ref("item", "i_category"),), + measures=(_ref("store_sales", "total_sales"),), + ) + + def _round() -> str: + return compile_plan(plan(q, context), dialect=Dialect.DUCKDB) + + sql = benchmark(_round) + assert "GROUP BY" in sql + + +@pytest.mark.benchmark(group="compile") +def test_benchmark__compile_with_filter_and_order(context, benchmark) -> None: + """Compile: plan + codegen for a filtered top-N aggregate.""" + q = SemanticQuery( + dimensions=(_ref("item", "i_category"),), + measures=(_ref("store_sales", "total_sales"),), + where=_sql("store_sales.ss_quantity > 1"), + order_by=( + OrderBy( + target=_ref("store_sales", "total_sales"), + direction=SortDirection.DESC, + ), + ), + limit=5, + ) + + def _round() -> str: + return compile_plan(plan(q, context), dialect=Dialect.DUCKDB) + + sql = benchmark(_round) + assert "WHERE" in sql and "ORDER BY" in sql + + +@pytest.mark.benchmark(group="compile") +def test_benchmark__compile_dual_enrichment(context, benchmark) -> None: + """Compile: two-hop enrichment (item + customer).""" + q = SemanticQuery( + dimensions=( + _ref("item", "i_category"), + _ref("customer", "c_birth_country"), + ), + measures=(_ref("store_sales", "total_sales"),), + ) + + def _round() -> str: + return compile_plan(plan(q, context), dialect=Dialect.DUCKDB) + + sql = benchmark(_round) + assert sql.count("INNER JOIN") + sql.count("LEFT JOIN") >= 2 diff --git a/impl/python/tests/conftest.py b/impl/python/tests/conftest.py new file mode 100644 index 0000000..d367e38 --- /dev/null +++ b/impl/python/tests/conftest.py @@ -0,0 +1,19 @@ +"""Shared pytest fixtures for the OSI Python reference implementation. + +Fixtures live here rather than per-layer so that property tests, unit +tests, golden tests, and E2E tests can all share the same generation +strategies and DuckDB harness. + +Phase 0 scaffolding: keep this file small. Richer fixtures (DuckDB +engines, golden snapshot helpers) are added in later phases. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(scope="session") +def anyio_backend() -> str: + """Pin async backend to asyncio for determinism across test runs.""" + return "asyncio" diff --git a/impl/python/tests/e2e/README.md b/impl/python/tests/e2e/README.md new file mode 100644 index 0000000..3c7c570 --- /dev/null +++ b/impl/python/tests/e2e/README.md @@ -0,0 +1,16 @@ +# End-to-end tests + +DuckDB-executed tests that assert on **rows**, not SQL shape. See +[`../../docs/TESTING_STRATEGY.md §5`](../../docs/TESTING_STRATEGY.md#5-layer-4-end-to-end). + +Each test: + +1. Loads a model from `../../examples/models/`. +2. Builds a plan and renders SQL. +3. Executes the SQL against an in-memory DuckDB. +4. Asserts on the row set. + +## Rule + +If a test asserts on the SQL string (like "has three CTEs"), it belongs +in `tests/golden/`, not here. diff --git a/impl/python/tests/e2e/conftest.py b/impl/python/tests/e2e/conftest.py new file mode 100644 index 0000000..ace2610 --- /dev/null +++ b/impl/python/tests/e2e/conftest.py @@ -0,0 +1,96 @@ +"""Shared DuckDB fixtures for Phase 4 E2E tests. + +We build an in-memory DuckDB that mirrors the ``sales`` schema declared +in :mod:`tests.unit.planning.fixtures`. Every physical ``source:`` in +the semantic model has a matching DuckDB table populated with a small, +deterministic dataset — just enough rows to exercise joins, filters, +aggregates, and full-outer merges. + +Keeping the fixture minimal is intentional: E2E tests assert on *row +sets* (layer 4 of the test pyramid), so the inputs stay legible in +failure messages. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +import duckdb +import pytest + + +def _seed(conn: duckdb.DuckDBPyConnection) -> None: + conn.execute("CREATE SCHEMA IF NOT EXISTS sales") + + conn.execute(""" + CREATE TABLE sales.customers ( + id INTEGER, + region VARCHAR, + market_segment VARCHAR + ) + """) + conn.execute(""" + INSERT INTO sales.customers VALUES + (1, 'NA', 'enterprise'), + (2, 'NA', 'smb'), + (3, 'EMEA', 'enterprise'), + (4, 'APAC', 'smb') + """) + + conn.execute(""" + CREATE TABLE sales.orders ( + order_id INTEGER, + customer_id INTEGER, + status VARCHAR, + amount DOUBLE, + discount DOUBLE + ) + """) + conn.execute(""" + INSERT INTO sales.orders VALUES + (10, 1, 'paid', 100.0, 5.0), + (11, 1, 'paid', 200.0, 10.0), + (12, 2, 'paid', 50.0, 0.0), + (13, 2, 'pending', 75.0, 0.0), + (14, 3, 'paid', 300.0, 15.0), + (15, 4, 'pending', 125.0, 0.0) + """) + + conn.execute(""" + CREATE TABLE sales.returns ( + return_id INTEGER, + customer_id INTEGER, + order_id INTEGER, + refund_amount DOUBLE + ) + """) + conn.execute(""" + INSERT INTO sales.returns VALUES + (100, 1, 10, 20.0), + (101, 3, 14, 50.0), + (102, 4, 15, 10.0) + """) + + +@pytest.fixture() +def duckdb_sales() -> Iterator[duckdb.DuckDBPyConnection]: + """Yield an in-memory DuckDB seeded with the Foundation sales schema.""" + conn = duckdb.connect(":memory:") + try: + _seed(conn) + yield conn + finally: + conn.close() + + +@pytest.fixture() +def duckdb_tpcds() -> Iterator[duckdb.DuckDBPyConnection]: + """Yield an in-memory DuckDB seeded with the TPC-DS Foundation schema.""" + from tests.e2e.tpcds_fixtures import seed_tpcds + + conn = duckdb.connect(":memory:") + try: + seed_tpcds(conn) + yield conn + finally: + conn.close() diff --git a/impl/python/tests/e2e/test_cardinality_safety.py b/impl/python/tests/e2e/test_cardinality_safety.py new file mode 100644 index 0000000..af365f7 --- /dev/null +++ b/impl/python/tests/e2e/test_cardinality_safety.py @@ -0,0 +1,609 @@ +"""Cardinality-safety tests — pinning behaviour when a 1:N edge is mismarked as N:N. + +These tests exercise the planner's safety contract from +``Proposed_OSI_Semantics.md §6.1 / §6.5``: when a relationship's +join-key columns are *actually* unique on the to-side (the +relationship is genuinely 1:N or N:1) but the model author fails to +declare that uniqueness, cardinality inference yields the +conservative ``N:N``. The Foundation MUST then either: + +1. produce a result that is *numerically identical* to the + correctly-declared model (when a safe route applies — e.g. + ``EXISTS_IN`` semi-join, which doesn't fan rows out), OR +2. refuse the query with the actionable + ``E3012_MN_NO_SAFE_REWRITE`` (when no route applies) — never + silently emit an inflated ``SUM`` over a fanned-out join. + +Every test in this file constructs *the same data* but loads it +through two YAML models — one canonical ("the relationship is N:1") +and one mismarked ("the relationship is N:N because the to-side has +no PK or UK on the join column"). The tests cross-reference results +to pin the safety guarantee. + +A note on PK requirement +------------------------ +The OSI spec (``§4.2``) declares ``primary_key`` *optional*: missing +keys force conservative ``N:N`` inference but the model is still +valid. The Foundation algebra is stricter — :func:`osi.planning.algebra.operations.source` +requires a non-empty ``primary_key`` and raises +``E2007_MISSING_PRIMARY_KEY`` otherwise. ``test_pk_required_by_algebra`` +documents this implementation constraint so any future relaxation +trips the test deliberately. +""" + +from __future__ import annotations + +import textwrap + +import duckdb +import pytest +import sqlglot + +from osi.codegen import Dialect, compile_plan +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.config import FoundationFlags +from osi.errors import ErrorCode, OSIError +from osi.parsing.graph import Cardinality, build_graph +from osi.parsing.namespace import build_namespace +from osi.parsing.parser import parse_semantic_model +from osi.planning import Reference, SemanticQuery, plan +from osi.planning.planner_context import PlannerContext + + +def _ref(ds: str, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds), + name=normalize_identifier(name), + ) + + +def _ctx(model_yaml: str) -> PlannerContext: + # The cardinality-safety fixtures use per-dataset ``metrics:`` + # blocks (deferred under the strict Foundation) and exercise the + # ``EXISTS_IN`` semi-join surface (deferred under the strict + # Foundation per §10 / D-017); opt back in via the + # legacy-permissive flag set so the planner-side cardinality + # contract stays exercised end-to-end. + flags = FoundationFlags.legacy_permissive() + parsed = parse_semantic_model(model_yaml, flags=flags) + return PlannerContext( + model=parsed.model, + namespace=build_namespace(parsed.model), + graph=build_graph(parsed.model), + flags=flags, + ) + + +def _run( + conn: duckdb.DuckDBPyConnection, + query: SemanticQuery, + context: PlannerContext, +) -> list[tuple]: + qp = plan(query, context) + sql = compile_plan(qp, dialect=Dialect.DUCKDB) + return sorted(conn.execute(sql).fetchall()) + + +# --------------------------------------------------------------------------- +# Shared seed: customers (1) ←→ orders (N) +# --------------------------------------------------------------------------- + +_SEED: tuple[str, ...] = ( + "CREATE SCHEMA cs;", + "CREATE TABLE cs.customers (id INTEGER, region VARCHAR);", + """ + INSERT INTO cs.customers VALUES + (1, 'NA'), (2, 'NA'), (3, 'EMEA'), (4, 'APAC'); + """, + """ + CREATE TABLE cs.orders ( + order_id INTEGER, customer_id INTEGER, amount DOUBLE + ); + """, + # Two orders per NA customer, one each for EMEA / APAC. customer_id + # references customers.id with a true many-to-one shape: every + # customer_id maps to exactly one customer row. + """ + INSERT INTO cs.orders VALUES + (10, 1, 100.0), (11, 1, 200.0), + (12, 2, 50.0), (13, 2, 75.0), + (14, 3, 300.0), + (15, 4, 125.0); + """, +) + + +@pytest.fixture() +def duckdb_cs() -> duckdb.DuckDBPyConnection: + """In-memory DuckDB seeded with the customers/orders schema above.""" + conn = duckdb.connect(":memory:") + for stmt in _SEED: + conn.execute(stmt) + return conn + + +# --------------------------------------------------------------------------- +# Three modelling variants over the same physical data. +# --------------------------------------------------------------------------- + +# Variant A — canonical: customers.primary_key=[id], so orders.customer_id +# matches a key on the to-side and the relationship is inferred as N:1. +_CANONICAL_N1_MODEL = textwrap.dedent("""\ + semantic_model: + - name: cs_canonical + dialect: ANSI_SQL + datasets: + - name: orders + source: cs.orders + primary_key: [order_id] + fields: + - {name: order_id, expression: order_id, role: dimension} + - {name: customer_id, expression: customer_id, role: dimension} + - {name: amount, expression: amount, role: fact} + metrics: + - {name: total_revenue, expression: SUM(amount)} + - {name: order_count, expression: COUNT(*)} + - name: customers + source: cs.customers + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: region, expression: region, role: dimension} + relationships: + - {name: orders_to_customers, from: orders, to: customers, + from_columns: [customer_id], to_columns: [id]} + """) + + +# Variant B — *mismarked*: customers has a PK on a different (synthetic) +# column, so the relationship's to_columns=[id] do NOT match any key +# on the to-side. Cardinality inference yields N:N even though the data +# is 1:N. The Foundation must refuse a measure-traversing query with +# ``E3012`` rather than silently fan out the SUM. +_MISMARKED_NN_MODEL = textwrap.dedent("""\ + semantic_model: + - name: cs_mismarked + dialect: ANSI_SQL + datasets: + - name: orders + source: cs.orders + primary_key: [order_id] + fields: + - {name: order_id, expression: order_id, role: dimension} + - {name: customer_id, expression: customer_id, role: dimension} + - {name: amount, expression: amount, role: fact} + metrics: + - {name: total_revenue, expression: SUM(amount)} + - {name: order_count, expression: COUNT(*)} + - name: customers + # Composite PK that does NOT match the join-key alone. + source: cs.customers + primary_key: [id, region] + fields: + - {name: id, expression: id, role: dimension} + - {name: region, expression: region, role: dimension} + relationships: + - {name: orders_to_customers, from: orders, to: customers, + from_columns: [customer_id], to_columns: [id]} + """) + + +# Variant C — same mismarked PK as B *plus* an explicit ``unique_keys`` +# declaration on customers.id. Per ``§6.1`` cardinality inference +# accepts a UK match too, so the relationship is once again inferred +# as N:1 and the standard enrichment plan is restored. Demonstrates +# that the user has a recovery path without changing the PK. +_RECOVERED_VIA_UK_MODEL = textwrap.dedent("""\ + semantic_model: + - name: cs_recovered + dialect: ANSI_SQL + datasets: + - name: orders + source: cs.orders + primary_key: [order_id] + fields: + - {name: order_id, expression: order_id, role: dimension} + - {name: customer_id, expression: customer_id, role: dimension} + - {name: amount, expression: amount, role: fact} + metrics: + - {name: total_revenue, expression: SUM(amount)} + - {name: order_count, expression: COUNT(*)} + - name: customers + source: cs.customers + primary_key: [id, region] + unique_keys: + - [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: region, expression: region, role: dimension} + relationships: + - {name: orders_to_customers, from: orders, to: customers, + from_columns: [customer_id], to_columns: [id]} + """) + + +# --------------------------------------------------------------------------- +# §1: PK is required by the algebra (implementation constraint, not spec). +# --------------------------------------------------------------------------- + + +@pytest.mark.e2e +def test_pk_required_by_algebra() -> None: + """Source operator rejects datasets with no PK with ``E2007``. + + The OSI spec (``Proposed_OSI_Semantics.md §4.2``) declares + ``primary_key`` optional; missing keys "force the planner into + conservative N:N cardinality" but do not invalidate the model. + The Foundation algebra is stricter: + :func:`osi.planning.algebra.operations.source` requires a + non-empty ``primary_key``. This test pins that constraint so any + future relaxation (e.g. an implicit-rowid grain) is a deliberate + breaking change — not silent drift. + """ + no_pk_model = textwrap.dedent("""\ + semantic_model: + - name: nopk + dialect: ANSI_SQL + datasets: + - name: orders + source: cs.orders + fields: + - {name: order_id, expression: order_id, role: dimension} + - {name: customer_id, expression: customer_id, role: dimension} + - {name: amount, expression: amount, role: fact} + metrics: + - {name: total_revenue, expression: SUM(amount)} + """) + ctx = _ctx(no_pk_model) + q = SemanticQuery( + measures=(_ref("orders", "total_revenue"),), + ) + with pytest.raises(OSIError) as excinfo: + plan(q, ctx) + assert excinfo.value.code is ErrorCode.E2007_MISSING_PRIMARY_KEY + + +# --------------------------------------------------------------------------- +# §2: cardinality inference matches the spec's §6.1 rules. +# --------------------------------------------------------------------------- + + +def test_canonical_pk_yields_n1_inference() -> None: + """A PK on the to-side's join column yields ``N:1`` inference.""" + ctx = _ctx(_CANONICAL_N1_MODEL) + edge = ctx.graph.edges[0] + assert edge.cardinality is Cardinality.N_TO_ONE + + +def test_mismarked_pk_yields_nn_inference() -> None: + """A PK that does NOT match the to-columns yields conservative ``N:N``. + + The customers dataset's PK is ``[id, region]``; the relationship's + ``to_columns: [id]`` does not match any declared key on the to-side + so per ``§6.1`` cardinality inference falls back to ``N:N`` — + even though the data is genuinely 1:N. + """ + ctx = _ctx(_MISMARKED_NN_MODEL) + edge = ctx.graph.edges[0] + assert edge.cardinality is Cardinality.N_TO_N + + +def test_unique_keys_recovers_n1_inference() -> None: + """Explicit ``unique_keys: [[id]]`` restores ``N:1`` inference. + + Pins ``§6.1``: cardinality inference accepts either the PK *or* + any declared unique key. Authors who can't move the PK can still + surface the join-key uniqueness via ``unique_keys`` and recover + the standard enrichment plan. + """ + ctx = _ctx(_RECOVERED_VIA_UK_MODEL) + edge = ctx.graph.edges[0] + assert edge.cardinality is Cardinality.N_TO_ONE + + +# --------------------------------------------------------------------------- +# §3: behaviour under mismarked-N:N. The planner must REFUSE measure- +# traversing queries with the spec-correct error rather than emit a +# fanned-out SUM. +# --------------------------------------------------------------------------- + + +@pytest.mark.e2e +def test_canonical_enrichment_returns_correct_rows(duckdb_cs) -> None: + """Baseline: SUM(amount) by customers.region against the canonical model. + + Establishes the numerical reference every other test compares + against. NA: 100+200+50+75=425. EMEA: 300. APAC: 125. + """ + ctx = _ctx(_CANONICAL_N1_MODEL) + q = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + ) + rows = _run(duckdb_cs, q, ctx) + assert rows == [("APAC", 125.0), ("EMEA", 300.0), ("NA", 425.0)] + + +@pytest.mark.e2e +def test_mismarked_nn_refuses_enrichment_with_E3012(duckdb_cs) -> None: + """Mismarked-N:N must raise ``E3012``, not silently fan rows out. + + ``Proposed_OSI_Semantics.md §6.5`` mandates the planner refuse + every M:N traversal that has no bridge / stitch / EXISTS_IN + route. The spec is explicit (``§6.5.3``): "An UNSAFE directive + would only be needed to *bypass correctness* — to silently emit + an inflated SUM over a fanned-out join. None of Tableau, Looker, + or Power BI offers that, and OSI does not either." + + This is the central safety property: a conservatively-declared + model NEVER produces a wrong number. It either plans a safe + route or raises a typed error. + """ + _ = duckdb_cs # fixture pins the data shape; no SQL is executed here + ctx = _ctx(_MISMARKED_NN_MODEL) + q = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + ) + with pytest.raises(OSIError) as excinfo: + plan(q, ctx) + assert excinfo.value.code is ErrorCode.E3012_MN_NO_SAFE_REWRITE + # The error context surfaces the actionable resolution suggestions + # the spec calls for in §6.5. + msg = str(excinfo.value) + assert "EXISTS_IN" in msg + + +@pytest.mark.e2e +def test_recovered_model_matches_canonical_results(duckdb_cs) -> None: + """Adding ``unique_keys`` produces *byte-identical* results. + + Spec contract (``§4.2``): "``primary_key`` and ``unique_keys`` + drive cardinality inference (§6.1)." The algebra honours this + symmetry — :func:`osi.planning.algebra.source` plumbs declared + UKs into :class:`CalculationState.unique_keys`, and + :func:`enrich` uses :meth:`CalculationState.is_unique_on` to + discharge its fan-trap rule against the PK *or* any UK. + + Acceptance test for ``INFRA.md I-16``: a model that declares the + join column via ``unique_keys`` (rather than as the PK) plans + and runs identically to the canonical-PK model. + """ + canonical_ctx = _ctx(_CANONICAL_N1_MODEL) + recovered_ctx = _ctx(_RECOVERED_VIA_UK_MODEL) + q = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + ) + canonical_rows = _run(duckdb_cs, q, canonical_ctx) + recovered_rows = _run(duckdb_cs, q, recovered_ctx) + assert canonical_rows == recovered_rows + assert canonical_rows == [("APAC", 125.0), ("EMEA", 300.0), ("NA", 425.0)] + + +# --------------------------------------------------------------------------- +# §4: EXISTS_IN works regardless of cardinality. A semi-join filter +# never fans rows out, so the spec (``§7.4``) lets it traverse any +# edge — including one mismarked as N:N. The result must match the +# canonical model exactly. +# --------------------------------------------------------------------------- + + +@pytest.mark.e2e +def test_exists_in_filter_is_cardinality_independent(duckdb_cs) -> None: + """``EXISTS_IN`` returns identical filter-results on both models. + + Per ``§6.5`` the *filter route* (``EXISTS_IN``) is one of the + three safe M:N resolutions. Because semi-joins never add rows, + the planner can apply this route even when the underlying + relationship is N:N — and the answer is the same as on the + correctly-declared N:1 model. + + Query: SUM(amount) over orders that have a *matching customer*. + Every order in the seed has a matching customer, so the result + equals the unfiltered total: 850. + """ + where = FrozenSQL.of( + sqlglot.parse_one("EXISTS_IN(orders.customer_id, customers.id)") + ) + q = SemanticQuery( + measures=(_ref("orders", "total_revenue"),), + where=where, + ) + canonical_rows = _run(duckdb_cs, q, _ctx(_CANONICAL_N1_MODEL)) + mismarked_rows = _run(duckdb_cs, q, _ctx(_MISMARKED_NN_MODEL)) + assert canonical_rows == mismarked_rows == [(850.0,)] + + +@pytest.mark.e2e +def test_not_exists_in_filter_is_cardinality_independent(duckdb_cs) -> None: + """``NOT EXISTS_IN`` (anti-join) is also cardinality-independent. + + Insert one orphan order whose ``customer_id`` doesn't appear in + customers, then assert that the anti-join returns its amount + against both models. Pins that the ``§7.4`` ANTI ``filtering_join`` + fires regardless of how the model declares the relationship. + """ + duckdb_cs.execute("INSERT INTO cs.orders VALUES (99, 999, 42.0);") + where = FrozenSQL.of( + sqlglot.parse_one("NOT EXISTS_IN(orders.customer_id, customers.id)") + ) + q = SemanticQuery( + measures=(_ref("orders", "total_revenue"),), + where=where, + ) + canonical_rows = _run(duckdb_cs, q, _ctx(_CANONICAL_N1_MODEL)) + mismarked_rows = _run(duckdb_cs, q, _ctx(_MISMARKED_NN_MODEL)) + assert canonical_rows == mismarked_rows == [(42.0,)] + + +# --------------------------------------------------------------------------- +# §5: chasm-trap stitch is cardinality-tolerant when both edges are +# correctly declared, but breaks down identically on both sides if +# either edge is mismarked. Pins the symmetric safety property. +# --------------------------------------------------------------------------- + + +_SHARED_DIM_SEED: tuple[str, ...] = _SEED + ( + """ + CREATE TABLE cs.returns ( + return_id INTEGER, customer_id INTEGER, refund_amount DOUBLE + ); + """, + """ + INSERT INTO cs.returns VALUES + (200, 1, 5.0), (201, 3, 10.0); + """, +) + + +def _stitch_model(canonical: bool) -> str: + """Return a sales-style model where orders+returns stitch via customers. + + ``canonical=True`` declares ``customers.primary_key=[id]``; + ``canonical=False`` mismarks it as ``[id, region]`` so both + relationships fall back to ``N:N``. + """ + pk = "[id]" if canonical else "[id, region]" + return textwrap.dedent(f"""\ + semantic_model: + - name: cs_stitch + dialect: ANSI_SQL + datasets: + - name: orders + source: cs.orders + primary_key: [order_id] + fields: + - {{name: order_id, expression: order_id, role: dimension}} + - {{name: customer_id, expression: customer_id, role: dimension}} + - {{name: amount, expression: amount, role: fact}} + metrics: + - {{name: total_revenue, expression: SUM(amount)}} + - name: customers + source: cs.customers + primary_key: {pk} + fields: + - {{name: id, expression: id, role: dimension}} + - {{name: region, expression: region, role: dimension}} + - name: returns + source: cs.returns + primary_key: [return_id] + fields: + - {{name: return_id, expression: return_id, role: dimension}} + - {{name: customer_id, expression: customer_id, role: dimension}} + - {{name: refund_amount, expression: refund_amount, role: fact}} + metrics: + - {{name: total_refunds, expression: SUM(refund_amount)}} + relationships: + - {{name: orders_to_customers, from: orders, to: customers, + from_columns: [customer_id], to_columns: [id]}} + - {{name: returns_to_customers, from: returns, to: customers, + from_columns: [customer_id], to_columns: [id]}} + """) + + +@pytest.fixture() +def duckdb_cs_with_returns() -> duckdb.DuckDBPyConnection: + """In-memory DuckDB seeded with customers, orders *and* returns.""" + conn = duckdb.connect(":memory:") + for stmt in _SHARED_DIM_SEED: + conn.execute(stmt) + return conn + + +@pytest.mark.e2e +def test_canonical_stitch_returns_correct_rows(duckdb_cs_with_returns) -> None: + """Two-fact stitch on customers.region under correctly-declared N:1.""" + q = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=( + _ref("orders", "total_revenue"), + _ref("returns", "total_refunds"), + ), + ) + rows = _run(duckdb_cs_with_returns, q, _ctx(_stitch_model(canonical=True))) + assert rows == [ + ("APAC", 125.0, None), + ("EMEA", 300.0, 10.0), + ("NA", 425.0, 5.0), + ] + + +@pytest.mark.e2e +def test_mismarked_stitch_refuses_with_E3012(duckdb_cs_with_returns) -> None: + """Mismarking *either* edge poisons the stitch route too. + + Stitch (``§6.5.2``) is implemented by enriching each fact to the + shared dim independently; if the enrichment edge is N:N the + planner refuses (``E3012``), exactly as for a single-fact query. + The contrapositive: the mismarked model never produces an + inflated stitched SUM. Either edge being N:N is sufficient to + trip the safety check. + """ + _ = duckdb_cs_with_returns # safety check raises before SQL execution + ctx = _ctx(_stitch_model(canonical=False)) + q = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=( + _ref("orders", "total_revenue"), + _ref("returns", "total_refunds"), + ), + ) + with pytest.raises(OSIError) as excinfo: + plan(q, ctx) + assert excinfo.value.code is ErrorCode.E3012_MN_NO_SAFE_REWRITE + + +# --------------------------------------------------------------------------- +# §6: dim-only queries see the same conservative behaviour. Pins that +# the safety property is *not* a measure-only artefact. +# --------------------------------------------------------------------------- + + +@pytest.mark.e2e +def test_dim_only_canonical_picks_orders_anchor(duckdb_cs) -> None: + """Dim-only query on (orders.customer_id, customers.region) under N:1. + + Orders is the only safe anchor (it can N:1 enrich into customers); + customers can't reach orders without a fan trap. Returns one row + per *order* enriched with its region. + """ + ctx = _ctx(_CANONICAL_N1_MODEL) + q = SemanticQuery( + dimensions=( + _ref("orders", "order_id"), + _ref("customers", "region"), + ), + ) + rows = _run(duckdb_cs, q, ctx) + assert rows == [ + (10, "NA"), + (11, "NA"), + (12, "NA"), + (13, "NA"), + (14, "EMEA"), + (15, "APAC"), + ] + + +@pytest.mark.e2e +def test_dim_only_mismarked_refuses_with_E3012(duckdb_cs) -> None: + """Mismarked-N:N dim-only also refuses with the spec-correct error. + + The dim-only group selector tries each referenced dataset as a + safe anchor; both fail because the only edge is N:N. Bridge + discovery then runs (``§6.5.1``); no third dataset exists, so + the planner re-raises the underlying ``E3012``. + """ + _ = duckdb_cs + ctx = _ctx(_MISMARKED_NN_MODEL) + q = SemanticQuery( + dimensions=( + _ref("orders", "order_id"), + _ref("customers", "region"), + ), + ) + with pytest.raises(OSIError) as excinfo: + plan(q, ctx) + assert excinfo.value.code is ErrorCode.E3012_MN_NO_SAFE_REWRITE diff --git a/impl/python/tests/e2e/test_duckdb_roundtrip.py b/impl/python/tests/e2e/test_duckdb_roundtrip.py new file mode 100644 index 0000000..2d69f15 --- /dev/null +++ b/impl/python/tests/e2e/test_duckdb_roundtrip.py @@ -0,0 +1,146 @@ +"""DuckDB roundtrip tests — plan → compile → execute → assert rows. + +These are the *behavioural* tests: every plan/SQL golden has a +counterpart here that asserts the query returns the rows we expect +against a seeded DuckDB instance. When a SQL golden changes, the +corresponding E2E test is the safety net that catches semantic drift +(the SQL shape is different, but does it still compute the same +answer?). + +We always compile with :attr:`Dialect.DUCKDB` because we're executing +on DuckDB; cross-dialect rendering is covered by the golden layer. +""" + +from __future__ import annotations + +import duckdb +import pytest +import sqlglot + +from osi.codegen import Dialect, compile_plan +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.planning import OrderBy, Reference, SemanticQuery, SortDirection, plan +from tests.unit.planning.fixtures import orders_context + + +def _ref(ds: str, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds), + name=normalize_identifier(name), + ) + + +def _sql(expr: str) -> FrozenSQL: + return FrozenSQL.of(sqlglot.parse_one(expr)) + + +def _run(conn: duckdb.DuckDBPyConnection, query: SemanticQuery) -> list[tuple]: + p = plan(query, orders_context()) + sql = compile_plan(p, dialect=Dialect.DUCKDB) + return sorted(conn.execute(sql).fetchall()) + + +@pytest.mark.e2e +def test_e2e__single_table_dim_plus_measure(duckdb_sales) -> None: + """SUM(amount) grouped by status, no joins.""" + query = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + ) + rows = _run(duckdb_sales, query) + assert rows == [ + ("paid", 650.0), + ("pending", 200.0), + ] + + +@pytest.mark.e2e +def test_e2e__enrichment_dim_on_joined_table(duckdb_sales) -> None: + """SUM(amount) grouped by customers.region — enrich join path.""" + query = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + ) + rows = _run(duckdb_sales, query) + assert rows == [ + ("APAC", 125.0), + ("EMEA", 300.0), + ("NA", 425.0), + ] + + +@pytest.mark.e2e +def test_e2e__two_fact_merge_on_shared_dimension(duckdb_sales) -> None: + """Chasm-trap safety: two facts merged on region, each aggregated first.""" + query = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=( + _ref("orders", "total_revenue"), + _ref("returns", "total_refunds"), + ), + ) + rows = _run(duckdb_sales, query) + assert rows == [ + ("APAC", 125.0, 10.0), + ("EMEA", 300.0, 50.0), + ("NA", 425.0, 20.0), + ] + + +@pytest.mark.e2e +def test_e2e__where_pushed_below_aggregate(duckdb_sales) -> None: + """WHERE amount > 100 filters rows before aggregate.""" + query = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + where=_sql("orders.amount > 100"), + ) + rows = _run(duckdb_sales, query) + assert rows == [ + ("paid", 500.0), + ("pending", 125.0), + ] + + +@pytest.mark.e2e +def test_e2e__composite_metric_avg_order_value(duckdb_sales) -> None: + """Composite metric ``avg_order_value = total_revenue / order_count``. + + Exercises the AGGREGATE + ADD_COLUMNS path end-to-end: both base + aggregates land under AGGREGATE, and the derived ratio is computed + on a subsequent ADD_COLUMNS step whose leaves address the aggregate + column names directly. + """ + query = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=( + Reference(dataset=None, name=normalize_identifier("avg_order_value")), + ), + ) + rows = _run(duckdb_sales, query) + # Seed: APAC has 1 order (125), EMEA has 1 order (300), NA has 4 + # orders totalling 425 (avg 106.25). + assert len(rows) == 3 + expected = {"APAC": 125.0, "EMEA": 300.0, "NA": 425.0 / 4} + for region, avg in rows: + assert avg == pytest.approx(expected[region], rel=1e-9) + + +@pytest.mark.e2e +def test_e2e__order_by_and_limit(duckdb_sales) -> None: + query = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + order_by=( + OrderBy( + target=_ref("orders", "total_revenue"), + direction=SortDirection.DESC, + ), + ), + limit=1, + ) + p = plan(query, orders_context()) + sql = compile_plan(p, dialect=Dialect.DUCKDB) + rows = duckdb_sales.execute(sql).fetchall() + assert rows == [("paid", 650.0)] diff --git a/impl/python/tests/e2e/test_tpcds_foundation.py b/impl/python/tests/e2e/test_tpcds_foundation.py new file mode 100644 index 0000000..4c56b82 --- /dev/null +++ b/impl/python/tests/e2e/test_tpcds_foundation.py @@ -0,0 +1,218 @@ +"""TPC-DS Foundation E2E tests. + +Each test corresponds to a query that is natively expressible in the +OSI Foundation — simple star-schema aggregates, optional single-hop +enrichment, optional filter/ORDER/LIMIT. Queries outside the thin +slice (correlated subqueries, window functions, GROUPING SETS) are +deliberately not covered here; :class:`E1105` is their contract. + +The ten labels below are chosen to fuzz through the representative +query shapes — single-fact aggregate, multi-dim enrichment, filtered +aggregate, top-N, multi-fact merge, etc. They're *spiritually* drawn +from TPC-DS Q1/3/6/7/19/26/42/52/55/73 but simplified to what the thin +slice guarantees. +""" + +from __future__ import annotations + +import duckdb +import pytest +import sqlglot + +from osi.codegen import Dialect, compile_plan +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.planning import OrderBy, Reference, SemanticQuery, SortDirection, plan +from tests.e2e.tpcds_fixtures import load_tpcds_context + + +def _ref(ds: str, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds), + name=normalize_identifier(name), + ) + + +def _sql(expr: str) -> FrozenSQL: + return FrozenSQL.of(sqlglot.parse_one(expr)) + + +def _run(conn: duckdb.DuckDBPyConnection, query: SemanticQuery) -> list[tuple]: + ctx = load_tpcds_context() + sql = compile_plan(plan(query, ctx), dialect=Dialect.DUCKDB) + return sorted(conn.execute(sql).fetchall()) + + +@pytest.mark.e2e +def test_tpcds__q52_total_sales_by_item_category(duckdb_tpcds) -> None: + """Q52-like: SUM(sales) grouped by item.i_category via single enrichment.""" + q = SemanticQuery( + dimensions=(_ref("item", "i_category"),), + measures=(_ref("store_sales", "total_sales"),), + ) + rows = _run(duckdb_tpcds, q) + assert rows == [ + ("Books", 60.0), + ("Music", 55.0), + ("Sports", 150.0), + ] + + +@pytest.mark.e2e +def test_tpcds__q42_sales_by_category_with_filter(duckdb_tpcds) -> None: + """Q42-like: SUM(sales) by category, filtered by ss_quantity > 1.""" + q = SemanticQuery( + dimensions=(_ref("item", "i_category"),), + measures=(_ref("store_sales", "total_sales"),), + where=_sql("store_sales.ss_quantity > 1"), + ) + rows = _run(duckdb_tpcds, q) + assert rows == [ + ("Books", 50.0), + ("Music", 40.0), + ("Sports", 100.0), + ] + + +@pytest.mark.e2e +def test_tpcds__q3_sales_and_profit_by_brand(duckdb_tpcds) -> None: + """Q3-like: multi-measure aggregate by item.i_brand.""" + q = SemanticQuery( + dimensions=(_ref("item", "i_brand"),), + measures=( + _ref("store_sales", "total_sales"), + _ref("store_sales", "total_profit"), + ), + ) + rows = _run(duckdb_tpcds, q) + assert rows == [ + ("acme", 60.0, 22.0), + ("fit", 150.0, 60.0), + ("zen", 55.0, 20.0), + ] + + +@pytest.mark.e2e +def test_tpcds__q7_sales_by_customer_country(duckdb_tpcds) -> None: + """Q7-like: enrichment over customer, aggregate by country.""" + q = SemanticQuery( + dimensions=(_ref("customer", "c_birth_country"),), + measures=(_ref("store_sales", "total_sales"),), + ) + rows = _run(duckdb_tpcds, q) + assert rows == [ + ("CANADA", 50.0), + ("MEXICO", 100.0), + ("USA", 115.0), + ] + + +@pytest.mark.e2e +def test_tpcds__q26_sales_qty_and_orders_by_store_state(duckdb_tpcds) -> None: + """Q26-like: enrichment over store; quantities and counts by state.""" + q = SemanticQuery( + dimensions=(_ref("store", "s_state"),), + measures=( + _ref("store_sales", "total_qty"), + _ref("store_sales", "order_count"), + ), + ) + rows = _run(duckdb_tpcds, q) + assert rows == [ + ("CA", 5, 3), + ("NY", 4, 2), + ("ON", 3, 2), + ] + + +@pytest.mark.e2e +def test_tpcds__q19_sales_by_category_and_country(duckdb_tpcds) -> None: + """Q19-like: dual enrichment — item × customer.""" + q = SemanticQuery( + dimensions=( + _ref("item", "i_category"), + _ref("customer", "c_birth_country"), + ), + measures=(_ref("store_sales", "total_sales"),), + ) + rows = _run(duckdb_tpcds, q) + assert rows == [ + ("Books", "CANADA", 10.0), + ("Books", "USA", 50.0), + ("Music", "CANADA", 40.0), + ("Music", "USA", 15.0), + ("Sports", "MEXICO", 100.0), + ("Sports", "USA", 50.0), + ] + + +@pytest.mark.e2e +def test_tpcds__q55_top_n_by_sales(duckdb_tpcds) -> None: + """Q55-like: top-N by measure with ORDER BY DESC + LIMIT.""" + q = SemanticQuery( + dimensions=(_ref("item", "i_category"),), + measures=(_ref("store_sales", "total_sales"),), + order_by=( + OrderBy( + target=_ref("store_sales", "total_sales"), + direction=SortDirection.DESC, + ), + ), + limit=2, + ) + ctx = load_tpcds_context() + sql = compile_plan(plan(q, ctx), dialect=Dialect.DUCKDB) + rows = duckdb_tpcds.execute(sql).fetchall() + assert rows == [("Sports", 150.0), ("Books", 60.0)] + + +@pytest.mark.e2e +def test_tpcds__q1_net_sales_multi_fact_merge(duckdb_tpcds) -> None: + """Q1-like (Foundation legal subset): sales and returns on shared state.""" + q = SemanticQuery( + dimensions=(_ref("store", "s_state"),), + measures=( + _ref("store_sales", "total_sales"), + _ref("store_returns", "total_returns"), + ), + ) + rows = _run(duckdb_tpcds, q) + # CA (store 10): sales 20+15+100=135, returns 25 (ticket 1005) + # NY (store 11): sales 30+50=80, returns 50 (ticket 1002) + # ON (store 12): sales 40+10=50, no returns + assert rows == [ + ("CA", 135.0, 25.0), + ("NY", 80.0, 50.0), + ("ON", 50.0, None), + ] + + +@pytest.mark.e2e +def test_tpcds__q6_distinct_customer_count_by_country(duckdb_tpcds) -> None: + """Q6-like: COUNT(DISTINCT ss_customer_sk) by customer birth country.""" + q = SemanticQuery( + dimensions=(_ref("customer", "c_birth_country"),), + measures=(_ref("store_sales", "distinct_customers"),), + ) + rows = _run(duckdb_tpcds, q) + assert rows == [ + ("CANADA", 1), + ("MEXICO", 1), + ("USA", 2), + ] + + +@pytest.mark.e2e +def test_tpcds__q73_avg_ticket_by_preferred_flag(duckdb_tpcds) -> None: + """Q73-like (Foundation legal): AVG by preferred-customer flag.""" + q = SemanticQuery( + dimensions=(_ref("customer", "c_preferred_cust_flag"),), + measures=(_ref("store_sales", "avg_ticket"),), + ) + rows = _run(duckdb_tpcds, q) + # Preferred (Y): customers 1 and 3 -> sales 20,15,40,10 -> avg 21.25 + # Non-preferred (N): customers 2 and 4 -> sales 30,50,100 -> avg 60 + assert rows == [ + ("N", pytest.approx(60.0)), + ("Y", pytest.approx(21.25)), + ] diff --git a/impl/python/tests/e2e/tpcds_fixtures.py b/impl/python/tests/e2e/tpcds_fixtures.py new file mode 100644 index 0000000..ef4ecbd --- /dev/null +++ b/impl/python/tests/e2e/tpcds_fixtures.py @@ -0,0 +1,125 @@ +"""TPC-DS Foundation fixtures for Phase 6 hardening. + +We ship a hand-curated, miniature ``tpcds.*`` schema and seeded data +small enough to make per-query assertions tractable while still +exercising every Foundation shape (single-fact, multi-fact merge, +WHERE, ORDER BY, LIMIT). The model lives in +``examples/models/tpcds_thin.yaml``; this module owns the DuckDB seed. +""" + +from __future__ import annotations + +from pathlib import Path + +import duckdb + +from osi.parsing.graph import build_graph +from osi.parsing.namespace import build_namespace +from osi.parsing.parser import parse_semantic_model +from osi.planning.planner_context import PlannerContext + +_MODEL_PATH = ( + Path(__file__).resolve().parents[2] / "examples" / "models" / "tpcds_thin.yaml" +) + + +def load_tpcds_context() -> PlannerContext: + """Parse ``tpcds_thin.yaml`` and build a fully-validated context.""" + result = parse_semantic_model(_MODEL_PATH.read_text()) + return PlannerContext( + model=result.model, + namespace=build_namespace(result.model), + graph=build_graph(result.model), + ) + + +def seed_tpcds(conn: duckdb.DuckDBPyConnection) -> None: + """Populate ``conn`` with a deterministic miniature TPC-DS dataset.""" + conn.execute("CREATE SCHEMA IF NOT EXISTS tpcds") + + conn.execute(""" + CREATE TABLE tpcds.item ( + i_item_sk INTEGER, + i_category VARCHAR, + i_class VARCHAR, + i_brand VARCHAR + ) + """) + conn.execute(""" + INSERT INTO tpcds.item VALUES + (1, 'Books', 'fiction', 'acme'), + (2, 'Books', 'nonfic', 'acme'), + (3, 'Music', 'pop', 'zen'), + (4, 'Music', 'classical', 'zen'), + (5, 'Sports', 'running', 'fit') + """) + + conn.execute(""" + CREATE TABLE tpcds.customer ( + c_customer_sk INTEGER, + c_birth_country VARCHAR, + c_preferred_cust_flag VARCHAR + ) + """) + conn.execute(""" + INSERT INTO tpcds.customer VALUES + (1, 'USA', 'Y'), + (2, 'USA', 'N'), + (3, 'CANADA', 'Y'), + (4, 'MEXICO', 'N') + """) + + conn.execute(""" + CREATE TABLE tpcds.store ( + s_store_sk INTEGER, + s_state VARCHAR, + s_country VARCHAR + ) + """) + conn.execute(""" + INSERT INTO tpcds.store VALUES + (10, 'CA', 'USA'), + (11, 'NY', 'USA'), + (12, 'ON', 'CANADA') + """) + + conn.execute(""" + CREATE TABLE tpcds.store_sales ( + ss_ticket_number INTEGER, + ss_item_sk INTEGER, + ss_customer_sk INTEGER, + ss_store_sk INTEGER, + ss_sold_date_sk INTEGER, + ss_quantity INTEGER, + ss_ext_sales_price DOUBLE, + ss_net_profit DOUBLE + ) + """) + conn.execute(""" + INSERT INTO tpcds.store_sales VALUES + (1001, 1, 1, 10, 20250101, 2, 20.0, 8.0), + (1001, 3, 1, 10, 20250101, 1, 15.0, 5.0), + (1002, 2, 2, 11, 20250102, 3, 30.0, 10.0), + (1002, 5, 2, 11, 20250102, 1, 50.0, 20.0), + (1003, 4, 3, 12, 20250103, 2, 40.0, 15.0), + (1004, 1, 3, 12, 20250104, 1, 10.0, 4.0), + (1005, 5, 4, 10, 20250105, 2, 100.0, 40.0) + """) + + conn.execute(""" + CREATE TABLE tpcds.store_returns ( + sr_ticket_number INTEGER, + sr_item_sk INTEGER, + sr_customer_sk INTEGER, + sr_store_sk INTEGER, + sr_return_amt DOUBLE + ) + """) + conn.execute(""" + INSERT INTO tpcds.store_returns VALUES + (1002, 5, 2, 11, 50.0), + (1005, 5, 4, 10, 25.0) + """) + + +__all__ = ["load_tpcds_context", "seed_tpcds"] diff --git a/impl/python/tests/golden/README.md b/impl/python/tests/golden/README.md new file mode 100644 index 0000000..f4a7831 --- /dev/null +++ b/impl/python/tests/golden/README.md @@ -0,0 +1,38 @@ +# Golden (snapshot) tests + +Snapshot tests that fix the exact `QueryPlan` and the exact SQL for a +curated set of input queries. See [`../../docs/TESTING_STRATEGY.md §4`](../../docs/TESTING_STRATEGY.md#4-layer-3-golden). + +## Layout + +``` +tests/golden/ + basic/ + single_table_revenue/ + model.yaml + query.yaml + expected.plan.json + expected.ansi.sql + expected.duckdb.sql + expected.snowflake.sql + joins/ + composition/ + filters/ + _driver.py # shared test harness +``` + +## Refreshing golden files + +```bash +make golden-refresh +``` + +Golden refresh is a **deliberate action**, not a shortcut for making +tests pass. A PR that refreshes golden files MUST explain which +intentional behavior change justifies the update. Reviewers will push +back on unexplained golden diffs. + +## Canonical corpus + +See [`../../docs/TESTING_STRATEGY.md §4.1`](../../docs/TESTING_STRATEGY.md#41-canonical-golden-corpus) +for the 10 canonical queries the corpus must cover. diff --git a/impl/python/tests/golden/__snapshots__/test_plan_goldens.ambr b/impl/python/tests/golden/__snapshots__/test_plan_goldens.ambr new file mode 100644 index 0000000..e1cccb1 --- /dev/null +++ b/impl/python/tests/golden/__snapshots__/test_plan_goldens.ambr @@ -0,0 +1,1486 @@ +# serializer version: 1 +# name: test_plan__enrichment_dim_on_joined_table + ''' + { + "limit": null, + "order_by": [], + "output_columns": [ + "region", + "total_revenue" + ], + "root_step_id": 3, + "steps": [ + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "order_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "order_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "customer_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "customer_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "status", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "status" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "amount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "amount" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "discount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "discount" + } + ], + "grain": [ + "order_id" + ], + "inputs": [], + "operation": "source", + "payload": { + "dataset": "orders", + "kind": "source", + "primary_key": [ + "order_id" + ], + "source": "sales.orders" + }, + "step_id": 0 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "order_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "order_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "customer_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "customer_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "status", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "status" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "amount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "amount" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "discount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "discount" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "id", + "from_join_rhs": true, + "is_single_valued": true, + "kind": "dimension", + "name": "id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "region", + "from_join_rhs": true, + "is_single_valued": true, + "kind": "dimension", + "name": "region" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "market_segment", + "from_join_rhs": true, + "is_single_valued": true, + "kind": "dimension", + "name": "segment" + } + ], + "grain": [ + "order_id" + ], + "inputs": [ + 0 + ], + "operation": "enrich", + "payload": { + "child_columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "region", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "region" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "market_segment", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "segment" + } + ], + "child_dataset": "customers", + "child_keys": [ + "id" + ], + "child_source": "sales.customers", + "join_type": "LEFT", + "keys": [ + "customer_id" + ], + "kind": "enrich", + "parent_keys": [ + "customer_id" + ] + }, + "step_id": 1 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "region", + "from_join_rhs": true, + "is_single_valued": true, + "kind": "dimension", + "name": "region" + }, + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "aggregate", + "name": "total_revenue" + } + ], + "grain": [ + "region" + ], + "inputs": [ + 1 + ], + "operation": "aggregate", + "payload": { + "aggregations": [ + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [ + "amount" + ], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": false, + "kind": "aggregate", + "name": "total_revenue" + } + ], + "kind": "aggregate", + "new_grain": [ + "region" + ] + }, + "step_id": 2 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "region", + "from_join_rhs": true, + "is_single_valued": true, + "kind": "dimension", + "name": "region" + }, + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "aggregate", + "name": "total_revenue" + } + ], + "grain": [ + "region" + ], + "inputs": [ + 2 + ], + "operation": "project", + "payload": { + "columns": [ + "region", + "total_revenue" + ], + "kind": "project" + }, + "step_id": 3 + } + ] + } + ''' +# --- +# name: test_plan__order_by_and_limit + ''' + { + "limit": 10, + "order_by": [ + { + "column": "total_revenue", + "descending": true + } + ], + "output_columns": [ + "status", + "total_revenue" + ], + "root_step_id": 2, + "steps": [ + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "order_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "order_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "customer_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "customer_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "status", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "status" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "amount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "amount" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "discount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "discount" + } + ], + "grain": [ + "order_id" + ], + "inputs": [], + "operation": "source", + "payload": { + "dataset": "orders", + "kind": "source", + "primary_key": [ + "order_id" + ], + "source": "sales.orders" + }, + "step_id": 0 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "status", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "status" + }, + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "aggregate", + "name": "total_revenue" + } + ], + "grain": [ + "status" + ], + "inputs": [ + 0 + ], + "operation": "aggregate", + "payload": { + "aggregations": [ + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [ + "amount" + ], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": false, + "kind": "aggregate", + "name": "total_revenue" + } + ], + "kind": "aggregate", + "new_grain": [ + "status" + ] + }, + "step_id": 1 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "status", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "status" + }, + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "aggregate", + "name": "total_revenue" + } + ], + "grain": [ + "status" + ], + "inputs": [ + 1 + ], + "operation": "project", + "payload": { + "columns": [ + "status", + "total_revenue" + ], + "kind": "project" + }, + "step_id": 2 + } + ] + } + ''' +# --- +# name: test_plan__single_table_dim_plus_measure + ''' + { + "limit": null, + "order_by": [], + "output_columns": [ + "status", + "total_revenue" + ], + "root_step_id": 2, + "steps": [ + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "order_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "order_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "customer_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "customer_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "status", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "status" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "amount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "amount" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "discount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "discount" + } + ], + "grain": [ + "order_id" + ], + "inputs": [], + "operation": "source", + "payload": { + "dataset": "orders", + "kind": "source", + "primary_key": [ + "order_id" + ], + "source": "sales.orders" + }, + "step_id": 0 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "status", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "status" + }, + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "aggregate", + "name": "total_revenue" + } + ], + "grain": [ + "status" + ], + "inputs": [ + 0 + ], + "operation": "aggregate", + "payload": { + "aggregations": [ + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [ + "amount" + ], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": false, + "kind": "aggregate", + "name": "total_revenue" + } + ], + "kind": "aggregate", + "new_grain": [ + "status" + ] + }, + "step_id": 1 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "status", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "status" + }, + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "aggregate", + "name": "total_revenue" + } + ], + "grain": [ + "status" + ], + "inputs": [ + 1 + ], + "operation": "project", + "payload": { + "columns": [ + "status", + "total_revenue" + ], + "kind": "project" + }, + "step_id": 2 + } + ] + } + ''' +# --- +# name: test_plan__two_fact_merge_on_shared_dimension + ''' + { + "limit": null, + "order_by": [], + "output_columns": [ + "region", + "total_revenue", + "total_refunds" + ], + "root_step_id": 7, + "steps": [ + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "order_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "order_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "customer_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "customer_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "status", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "status" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "amount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "amount" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "discount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "discount" + } + ], + "grain": [ + "order_id" + ], + "inputs": [], + "operation": "source", + "payload": { + "dataset": "orders", + "kind": "source", + "primary_key": [ + "order_id" + ], + "source": "sales.orders" + }, + "step_id": 0 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "order_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "order_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "customer_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "customer_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "status", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "status" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "amount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "amount" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "discount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "discount" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "id", + "from_join_rhs": true, + "is_single_valued": true, + "kind": "dimension", + "name": "id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "region", + "from_join_rhs": true, + "is_single_valued": true, + "kind": "dimension", + "name": "region" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "market_segment", + "from_join_rhs": true, + "is_single_valued": true, + "kind": "dimension", + "name": "segment" + } + ], + "grain": [ + "order_id" + ], + "inputs": [ + 0 + ], + "operation": "enrich", + "payload": { + "child_columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "region", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "region" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "market_segment", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "segment" + } + ], + "child_dataset": "customers", + "child_keys": [ + "id" + ], + "child_source": "sales.customers", + "join_type": "LEFT", + "keys": [ + "customer_id" + ], + "kind": "enrich", + "parent_keys": [ + "customer_id" + ] + }, + "step_id": 1 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "region", + "from_join_rhs": true, + "is_single_valued": true, + "kind": "dimension", + "name": "region" + }, + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "aggregate", + "name": "total_revenue" + } + ], + "grain": [ + "region" + ], + "inputs": [ + 1 + ], + "operation": "aggregate", + "payload": { + "aggregations": [ + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [ + "amount" + ], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": false, + "kind": "aggregate", + "name": "total_revenue" + } + ], + "kind": "aggregate", + "new_grain": [ + "region" + ] + }, + "step_id": 2 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "return_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "return_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "customer_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "customer_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "order_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "order_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "refund_amount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "refund_amount" + } + ], + "grain": [ + "return_id" + ], + "inputs": [], + "operation": "source", + "payload": { + "dataset": "returns", + "kind": "source", + "primary_key": [ + "return_id" + ], + "source": "sales.returns" + }, + "step_id": 3 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "return_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "return_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "customer_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "customer_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "order_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "order_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "refund_amount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "refund_amount" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "id", + "from_join_rhs": true, + "is_single_valued": true, + "kind": "dimension", + "name": "id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "region", + "from_join_rhs": true, + "is_single_valued": true, + "kind": "dimension", + "name": "region" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "market_segment", + "from_join_rhs": true, + "is_single_valued": true, + "kind": "dimension", + "name": "segment" + } + ], + "grain": [ + "return_id" + ], + "inputs": [ + 3 + ], + "operation": "enrich", + "payload": { + "child_columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "region", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "region" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "market_segment", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "segment" + } + ], + "child_dataset": "customers", + "child_keys": [ + "id" + ], + "child_source": "sales.customers", + "join_type": "LEFT", + "keys": [ + "customer_id" + ], + "kind": "enrich", + "parent_keys": [ + "customer_id" + ] + }, + "step_id": 4 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "region", + "from_join_rhs": true, + "is_single_valued": true, + "kind": "dimension", + "name": "region" + }, + { + "aggregate": { + "argument": "refund_amount", + "function": "SUM" + }, + "dependencies": [], + "expression": "SUM(refund_amount)", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "aggregate", + "name": "total_refunds" + } + ], + "grain": [ + "region" + ], + "inputs": [ + 4 + ], + "operation": "aggregate", + "payload": { + "aggregations": [ + { + "aggregate": { + "argument": "refund_amount", + "function": "SUM" + }, + "dependencies": [ + "refund_amount" + ], + "expression": "SUM(refund_amount)", + "from_join_rhs": false, + "is_single_valued": false, + "kind": "aggregate", + "name": "total_refunds" + } + ], + "kind": "aggregate", + "new_grain": [ + "region" + ] + }, + "step_id": 5 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "region", + "from_join_rhs": true, + "is_single_valued": true, + "kind": "dimension", + "name": "region" + }, + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "aggregate", + "name": "total_revenue" + }, + { + "aggregate": { + "argument": "refund_amount", + "function": "SUM" + }, + "dependencies": [], + "expression": "SUM(refund_amount)", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "aggregate", + "name": "total_refunds" + } + ], + "grain": [ + "region" + ], + "inputs": [ + 2, + 5 + ], + "operation": "merge", + "payload": { + "kind": "merge", + "on": [ + "region" + ] + }, + "step_id": 6 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "region", + "from_join_rhs": true, + "is_single_valued": true, + "kind": "dimension", + "name": "region" + }, + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "aggregate", + "name": "total_revenue" + }, + { + "aggregate": { + "argument": "refund_amount", + "function": "SUM" + }, + "dependencies": [], + "expression": "SUM(refund_amount)", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "aggregate", + "name": "total_refunds" + } + ], + "grain": [ + "region" + ], + "inputs": [ + 6 + ], + "operation": "project", + "payload": { + "columns": [ + "region", + "total_revenue", + "total_refunds" + ], + "kind": "project" + }, + "step_id": 7 + } + ] + } + ''' +# --- +# name: test_plan__where_pushed_below_aggregate + ''' + { + "limit": null, + "order_by": [], + "output_columns": [ + "status", + "total_revenue" + ], + "root_step_id": 3, + "steps": [ + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "order_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "order_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "customer_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "customer_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "status", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "status" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "amount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "amount" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "discount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "discount" + } + ], + "grain": [ + "order_id" + ], + "inputs": [], + "operation": "source", + "payload": { + "dataset": "orders", + "kind": "source", + "primary_key": [ + "order_id" + ], + "source": "sales.orders" + }, + "step_id": 0 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "order_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "order_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "customer_id", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "customer_id" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "status", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "status" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "amount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "amount" + }, + { + "aggregate": null, + "dependencies": [], + "expression": "discount", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "fact", + "name": "discount" + } + ], + "grain": [ + "order_id" + ], + "inputs": [ + 0 + ], + "operation": "filter", + "payload": { + "dependencies": [ + "amount" + ], + "kind": "filter", + "post_aggregate": false, + "predicate": "orders.amount > 100" + }, + "step_id": 1 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "status", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "status" + }, + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "aggregate", + "name": "total_revenue" + } + ], + "grain": [ + "status" + ], + "inputs": [ + 1 + ], + "operation": "aggregate", + "payload": { + "aggregations": [ + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [ + "amount" + ], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": false, + "kind": "aggregate", + "name": "total_revenue" + } + ], + "kind": "aggregate", + "new_grain": [ + "status" + ] + }, + "step_id": 2 + }, + { + "columns": [ + { + "aggregate": null, + "dependencies": [], + "expression": "status", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "dimension", + "name": "status" + }, + { + "aggregate": { + "argument": "amount", + "function": "SUM" + }, + "dependencies": [], + "expression": "SUM(amount)", + "from_join_rhs": false, + "is_single_valued": true, + "kind": "aggregate", + "name": "total_revenue" + } + ], + "grain": [ + "status" + ], + "inputs": [ + 2 + ], + "operation": "project", + "payload": { + "columns": [ + "status", + "total_revenue" + ], + "kind": "project" + }, + "step_id": 3 + } + ] + } + ''' +# --- diff --git a/impl/python/tests/golden/__snapshots__/test_sql_goldens.ambr b/impl/python/tests/golden/__snapshots__/test_sql_goldens.ambr new file mode 100644 index 0000000..1b7edad --- /dev/null +++ b/impl/python/tests/golden/__snapshots__/test_sql_goldens.ambr @@ -0,0 +1,583 @@ +# serializer version: 1 +# name: test_sql__enrichment_dim_on_joined_table[ansi] + ''' + WITH "step_000" AS ( + SELECT + "order_id", + "customer_id", + "status", + "amount", + "discount" + FROM "sales"."orders" + ), "step_001" AS ( + SELECT + "step_000"."order_id", + "step_000"."customer_id", + "step_000"."status", + "step_000"."amount", + "step_000"."discount", + "step_000_r"."id" AS "id", + "step_000_r"."region" AS "region", + "step_000_r"."market_segment" AS "segment" + FROM "step_000" + LEFT JOIN "sales"."customers" AS "step_000_r" + ON "step_000"."customer_id" = "step_000_r"."id" + ), "step_002" AS ( + SELECT + "step_001"."region", + SUM("step_001"."amount") AS "total_revenue" + FROM "step_001" + GROUP BY + "step_001"."region" + ) + SELECT + "step_002"."region", + "step_002"."total_revenue" + FROM "step_002" + ''' +# --- +# name: test_sql__enrichment_dim_on_joined_table[duckdb] + ''' + WITH "step_000" AS ( + SELECT + "order_id", + "customer_id", + "status", + "amount", + "discount" + FROM "sales"."orders" + ), "step_001" AS ( + SELECT + "step_000"."order_id", + "step_000"."customer_id", + "step_000"."status", + "step_000"."amount", + "step_000"."discount", + "step_000_r"."id" AS "id", + "step_000_r"."region" AS "region", + "step_000_r"."market_segment" AS "segment" + FROM "step_000" + LEFT JOIN "sales"."customers" AS "step_000_r" + ON "step_000"."customer_id" = "step_000_r"."id" + ), "step_002" AS ( + SELECT + "step_001"."region", + SUM("step_001"."amount") AS "total_revenue" + FROM "step_001" + GROUP BY + "step_001"."region" + ) + SELECT + "step_002"."region", + "step_002"."total_revenue" + FROM "step_002" + ''' +# --- +# name: test_sql__enrichment_dim_on_joined_table[snowflake] + ''' + WITH "step_000" AS ( + SELECT + "order_id", + "customer_id", + "status", + "amount", + "discount" + FROM "sales"."orders" + ), "step_001" AS ( + SELECT + "step_000"."order_id", + "step_000"."customer_id", + "step_000"."status", + "step_000"."amount", + "step_000"."discount", + "step_000_r"."id" AS "id", + "step_000_r"."region" AS "region", + "step_000_r"."market_segment" AS "segment" + FROM "step_000" + LEFT JOIN "sales"."customers" AS "step_000_r" + ON "step_000"."customer_id" = "step_000_r"."id" + ), "step_002" AS ( + SELECT + "step_001"."region", + SUM("step_001"."amount") AS "total_revenue" + FROM "step_001" + GROUP BY + "step_001"."region" + ) + SELECT + "step_002"."region", + "step_002"."total_revenue" + FROM "step_002" + ''' +# --- +# name: test_sql__order_by_and_limit[ansi] + ''' + WITH "step_000" AS ( + SELECT + "order_id", + "customer_id", + "status", + "amount", + "discount" + FROM "sales"."orders" + ), "step_001" AS ( + SELECT + "step_000"."status", + SUM("step_000"."amount") AS "total_revenue" + FROM "step_000" + GROUP BY + "step_000"."status" + ) + SELECT + "step_001"."status", + "step_001"."total_revenue" + FROM "step_001" + ORDER BY + "step_001"."total_revenue" DESC NULLS FIRST + LIMIT 10 + ''' +# --- +# name: test_sql__order_by_and_limit[duckdb] + ''' + WITH "step_000" AS ( + SELECT + "order_id", + "customer_id", + "status", + "amount", + "discount" + FROM "sales"."orders" + ), "step_001" AS ( + SELECT + "step_000"."status", + SUM("step_000"."amount") AS "total_revenue" + FROM "step_000" + GROUP BY + "step_000"."status" + ) + SELECT + "step_001"."status", + "step_001"."total_revenue" + FROM "step_001" + ORDER BY + "step_001"."total_revenue" DESC NULLS FIRST + LIMIT 10 + ''' +# --- +# name: test_sql__order_by_and_limit[snowflake] + ''' + WITH "step_000" AS ( + SELECT + "order_id", + "customer_id", + "status", + "amount", + "discount" + FROM "sales"."orders" + ), "step_001" AS ( + SELECT + "step_000"."status", + SUM("step_000"."amount") AS "total_revenue" + FROM "step_000" + GROUP BY + "step_000"."status" + ) + SELECT + "step_001"."status", + "step_001"."total_revenue" + FROM "step_001" + ORDER BY + "step_001"."total_revenue" DESC + LIMIT 10 + ''' +# --- +# name: test_sql__single_table_dim_plus_measure[ansi] + ''' + WITH "step_000" AS ( + SELECT + "order_id", + "customer_id", + "status", + "amount", + "discount" + FROM "sales"."orders" + ), "step_001" AS ( + SELECT + "step_000"."status", + SUM("step_000"."amount") AS "total_revenue" + FROM "step_000" + GROUP BY + "step_000"."status" + ) + SELECT + "step_001"."status", + "step_001"."total_revenue" + FROM "step_001" + ''' +# --- +# name: test_sql__single_table_dim_plus_measure[duckdb] + ''' + WITH "step_000" AS ( + SELECT + "order_id", + "customer_id", + "status", + "amount", + "discount" + FROM "sales"."orders" + ), "step_001" AS ( + SELECT + "step_000"."status", + SUM("step_000"."amount") AS "total_revenue" + FROM "step_000" + GROUP BY + "step_000"."status" + ) + SELECT + "step_001"."status", + "step_001"."total_revenue" + FROM "step_001" + ''' +# --- +# name: test_sql__single_table_dim_plus_measure[snowflake] + ''' + WITH "step_000" AS ( + SELECT + "order_id", + "customer_id", + "status", + "amount", + "discount" + FROM "sales"."orders" + ), "step_001" AS ( + SELECT + "step_000"."status", + SUM("step_000"."amount") AS "total_revenue" + FROM "step_000" + GROUP BY + "step_000"."status" + ) + SELECT + "step_001"."status", + "step_001"."total_revenue" + FROM "step_001" + ''' +# --- +# name: test_sql__two_fact_merge_on_shared_dimension[ansi] + ''' + WITH "step_000" AS ( + SELECT + "order_id", + "customer_id", + "status", + "amount", + "discount" + FROM "sales"."orders" + ), "step_001" AS ( + SELECT + "step_000"."order_id", + "step_000"."customer_id", + "step_000"."status", + "step_000"."amount", + "step_000"."discount", + "step_000_r"."id" AS "id", + "step_000_r"."region" AS "region", + "step_000_r"."market_segment" AS "segment" + FROM "step_000" + LEFT JOIN "sales"."customers" AS "step_000_r" + ON "step_000"."customer_id" = "step_000_r"."id" + ), "step_002" AS ( + SELECT + "step_001"."region", + SUM("step_001"."amount") AS "total_revenue" + FROM "step_001" + GROUP BY + "step_001"."region" + ), "step_003" AS ( + SELECT + "return_id", + "customer_id", + "order_id", + "refund_amount" + FROM "sales"."returns" + ), "step_004" AS ( + SELECT + "step_003"."return_id", + "step_003"."customer_id", + "step_003"."order_id", + "step_003"."refund_amount", + "step_003_r"."id" AS "id", + "step_003_r"."region" AS "region", + "step_003_r"."market_segment" AS "segment" + FROM "step_003" + LEFT JOIN "sales"."customers" AS "step_003_r" + ON "step_003"."customer_id" = "step_003_r"."id" + ), "step_005" AS ( + SELECT + "step_004"."region", + SUM("step_004"."refund_amount") AS "total_refunds" + FROM "step_004" + GROUP BY + "step_004"."region" + ), "step_006" AS ( + SELECT + COALESCE("step_002"."region", "step_005"."region") AS "region", + "step_002"."total_revenue", + "step_005"."total_refunds" + FROM "step_002" + FULL OUTER JOIN "step_005" + ON "step_002"."region" = "step_005"."region" + ) + SELECT + "step_006"."region", + "step_006"."total_revenue", + "step_006"."total_refunds" + FROM "step_006" + ''' +# --- +# name: test_sql__two_fact_merge_on_shared_dimension[duckdb] + ''' + WITH "step_000" AS ( + SELECT + "order_id", + "customer_id", + "status", + "amount", + "discount" + FROM "sales"."orders" + ), "step_001" AS ( + SELECT + "step_000"."order_id", + "step_000"."customer_id", + "step_000"."status", + "step_000"."amount", + "step_000"."discount", + "step_000_r"."id" AS "id", + "step_000_r"."region" AS "region", + "step_000_r"."market_segment" AS "segment" + FROM "step_000" + LEFT JOIN "sales"."customers" AS "step_000_r" + ON "step_000"."customer_id" = "step_000_r"."id" + ), "step_002" AS ( + SELECT + "step_001"."region", + SUM("step_001"."amount") AS "total_revenue" + FROM "step_001" + GROUP BY + "step_001"."region" + ), "step_003" AS ( + SELECT + "return_id", + "customer_id", + "order_id", + "refund_amount" + FROM "sales"."returns" + ), "step_004" AS ( + SELECT + "step_003"."return_id", + "step_003"."customer_id", + "step_003"."order_id", + "step_003"."refund_amount", + "step_003_r"."id" AS "id", + "step_003_r"."region" AS "region", + "step_003_r"."market_segment" AS "segment" + FROM "step_003" + LEFT JOIN "sales"."customers" AS "step_003_r" + ON "step_003"."customer_id" = "step_003_r"."id" + ), "step_005" AS ( + SELECT + "step_004"."region", + SUM("step_004"."refund_amount") AS "total_refunds" + FROM "step_004" + GROUP BY + "step_004"."region" + ), "step_006" AS ( + SELECT + COALESCE("step_002"."region", "step_005"."region") AS "region", + "step_002"."total_revenue", + "step_005"."total_refunds" + FROM "step_002" + FULL OUTER JOIN "step_005" + ON "step_002"."region" = "step_005"."region" + ) + SELECT + "step_006"."region", + "step_006"."total_revenue", + "step_006"."total_refunds" + FROM "step_006" + ''' +# --- +# name: test_sql__two_fact_merge_on_shared_dimension[snowflake] + ''' + WITH "step_000" AS ( + SELECT + "order_id", + "customer_id", + "status", + "amount", + "discount" + FROM "sales"."orders" + ), "step_001" AS ( + SELECT + "step_000"."order_id", + "step_000"."customer_id", + "step_000"."status", + "step_000"."amount", + "step_000"."discount", + "step_000_r"."id" AS "id", + "step_000_r"."region" AS "region", + "step_000_r"."market_segment" AS "segment" + FROM "step_000" + LEFT JOIN "sales"."customers" AS "step_000_r" + ON "step_000"."customer_id" = "step_000_r"."id" + ), "step_002" AS ( + SELECT + "step_001"."region", + SUM("step_001"."amount") AS "total_revenue" + FROM "step_001" + GROUP BY + "step_001"."region" + ), "step_003" AS ( + SELECT + "return_id", + "customer_id", + "order_id", + "refund_amount" + FROM "sales"."returns" + ), "step_004" AS ( + SELECT + "step_003"."return_id", + "step_003"."customer_id", + "step_003"."order_id", + "step_003"."refund_amount", + "step_003_r"."id" AS "id", + "step_003_r"."region" AS "region", + "step_003_r"."market_segment" AS "segment" + FROM "step_003" + LEFT JOIN "sales"."customers" AS "step_003_r" + ON "step_003"."customer_id" = "step_003_r"."id" + ), "step_005" AS ( + SELECT + "step_004"."region", + SUM("step_004"."refund_amount") AS "total_refunds" + FROM "step_004" + GROUP BY + "step_004"."region" + ), "step_006" AS ( + SELECT + COALESCE("step_002"."region", "step_005"."region") AS "region", + "step_002"."total_revenue", + "step_005"."total_refunds" + FROM "step_002" + FULL OUTER JOIN "step_005" + ON "step_002"."region" = "step_005"."region" + ) + SELECT + "step_006"."region", + "step_006"."total_revenue", + "step_006"."total_refunds" + FROM "step_006" + ''' +# --- +# name: test_sql__where_pushed_below_aggregate[ansi] + ''' + WITH "step_000" AS ( + SELECT + "order_id", + "customer_id", + "status", + "amount", + "discount" + FROM "sales"."orders" + ), "step_001" AS ( + SELECT + "step_000"."order_id", + "step_000"."customer_id", + "step_000"."status", + "step_000"."amount", + "step_000"."discount" + FROM "step_000" + WHERE + "step_000"."amount" > 100 + ), "step_002" AS ( + SELECT + "step_001"."status", + SUM("step_001"."amount") AS "total_revenue" + FROM "step_001" + GROUP BY + "step_001"."status" + ) + SELECT + "step_002"."status", + "step_002"."total_revenue" + FROM "step_002" + ''' +# --- +# name: test_sql__where_pushed_below_aggregate[duckdb] + ''' + WITH "step_000" AS ( + SELECT + "order_id", + "customer_id", + "status", + "amount", + "discount" + FROM "sales"."orders" + ), "step_001" AS ( + SELECT + "step_000"."order_id", + "step_000"."customer_id", + "step_000"."status", + "step_000"."amount", + "step_000"."discount" + FROM "step_000" + WHERE + "step_000"."amount" > 100 + ), "step_002" AS ( + SELECT + "step_001"."status", + SUM("step_001"."amount") AS "total_revenue" + FROM "step_001" + GROUP BY + "step_001"."status" + ) + SELECT + "step_002"."status", + "step_002"."total_revenue" + FROM "step_002" + ''' +# --- +# name: test_sql__where_pushed_below_aggregate[snowflake] + ''' + WITH "step_000" AS ( + SELECT + "order_id", + "customer_id", + "status", + "amount", + "discount" + FROM "sales"."orders" + ), "step_001" AS ( + SELECT + "step_000"."order_id", + "step_000"."customer_id", + "step_000"."status", + "step_000"."amount", + "step_000"."discount" + FROM "step_000" + WHERE + "step_000"."amount" > 100 + ), "step_002" AS ( + SELECT + "step_001"."status", + SUM("step_001"."amount") AS "total_revenue" + FROM "step_001" + GROUP BY + "step_001"."status" + ) + SELECT + "step_002"."status", + "step_002"."total_revenue" + FROM "step_002" + ''' +# --- diff --git a/impl/python/tests/golden/test_plan_goldens.py b/impl/python/tests/golden/test_plan_goldens.py new file mode 100644 index 0000000..7087e28 --- /dev/null +++ b/impl/python/tests/golden/test_plan_goldens.py @@ -0,0 +1,104 @@ +"""Plan-only golden tests (Phase 3). + +This module freezes the *planner output* — the +:class:`~osi.planning.QueryPlan` — for a curated corpus of semantic +queries. The SQL-level goldens (ANSI / DuckDB / Snowflake) are added in +Phase 4 once the codegen ships; see ``tests/golden/README.md``. + +Snapshots live next to this file in ``__snapshots__/``. To refresh them +after an intentional planner change, run:: + + make golden-refresh + +which invokes ``pytest --snapshot-update`` under the hood. Any refresh +in a PR must be justified: a plan diff is a behaviour diff. +""" + +from __future__ import annotations + +import json + +import pytest +import sqlglot + +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.planning import OrderBy, Reference, SemanticQuery, SortDirection, plan +from tests.unit.planning.fixtures import orders_context + + +def _ref(ds: str, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds), + name=normalize_identifier(name), + ) + + +def _sql(expr: str) -> FrozenSQL: + return FrozenSQL.of(sqlglot.parse_one(expr)) + + +def _canonical(query: SemanticQuery) -> str: + ctx = orders_context() + p = plan(query, ctx) + return json.dumps(p.to_json(), indent=2, sort_keys=True) + + +@pytest.mark.golden +def test_plan__single_table_dim_plus_measure(snapshot) -> None: + """Baseline: one fact + one dimension on the fact itself.""" + query = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + ) + assert _canonical(query) == snapshot + + +@pytest.mark.golden +def test_plan__enrichment_dim_on_joined_table(snapshot) -> None: + """§4.4 enrich: dimension lives on ``customers`` (N:1 from orders).""" + query = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + ) + assert _canonical(query) == snapshot + + +@pytest.mark.golden +def test_plan__two_fact_merge_on_shared_dimension(snapshot) -> None: + """§4.11 chasm-trap safety: two facts, merge on shared region.""" + query = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=( + _ref("orders", "total_revenue"), + _ref("returns", "total_refunds"), + ), + ) + assert _canonical(query) == snapshot + + +@pytest.mark.golden +def test_plan__where_pushed_below_aggregate(snapshot) -> None: + """WHERE rows are filtered *before* aggregate (§4.2).""" + query = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + where=_sql("orders.amount > 100"), + ) + assert _canonical(query) == snapshot + + +@pytest.mark.golden +def test_plan__order_by_and_limit(snapshot) -> None: + query = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + order_by=( + OrderBy( + target=_ref("orders", "total_revenue"), + direction=SortDirection.DESC, + ), + ), + limit=10, + ) + assert _canonical(query) == snapshot diff --git a/impl/python/tests/golden/test_sql_goldens.py b/impl/python/tests/golden/test_sql_goldens.py new file mode 100644 index 0000000..6a33678 --- /dev/null +++ b/impl/python/tests/golden/test_sql_goldens.py @@ -0,0 +1,109 @@ +"""SQL golden tests (Phase 4). + +Freezes the *codegen output* — the rendered SQL string — for a curated +corpus of semantic queries across every supported dialect. Plan-level +goldens live in ``test_plan_goldens.py``; this module is the final +behavioural check that a plan really does lower to the SQL we expect. + +Snapshots live next to this file in ``__snapshots__/``. To refresh them +after an intentional codegen change, run:: + + make golden-refresh + +Any refresh in a PR must be justified: SQL drift is a behaviour diff. +""" + +from __future__ import annotations + +import pytest +import sqlglot + +from osi.codegen import Dialect, compile_plan +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.planning import OrderBy, Reference, SemanticQuery, SortDirection, plan +from tests.unit.planning.fixtures import orders_context + + +def _ref(ds: str, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds), + name=normalize_identifier(name), + ) + + +def _sql(expr: str) -> FrozenSQL: + return FrozenSQL.of(sqlglot.parse_one(expr)) + + +_DIALECTS = (Dialect.ANSI, Dialect.DUCKDB, Dialect.SNOWFLAKE) + + +def _compile(query: SemanticQuery, dialect: Dialect) -> str: + p = plan(query, orders_context()) + return compile_plan(p, dialect=dialect) + + +@pytest.mark.golden +@pytest.mark.parametrize("dialect", _DIALECTS, ids=lambda d: d.name.lower()) +def test_sql__single_table_dim_plus_measure(snapshot, dialect: Dialect) -> None: + """Baseline: one fact + one dimension on the fact itself.""" + query = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + ) + assert _compile(query, dialect) == snapshot + + +@pytest.mark.golden +@pytest.mark.parametrize("dialect", _DIALECTS, ids=lambda d: d.name.lower()) +def test_sql__enrichment_dim_on_joined_table(snapshot, dialect: Dialect) -> None: + """§4.4 enrich: dimension on ``customers``; join key pair customer_id/id.""" + query = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + ) + assert _compile(query, dialect) == snapshot + + +@pytest.mark.golden +@pytest.mark.parametrize("dialect", _DIALECTS, ids=lambda d: d.name.lower()) +def test_sql__two_fact_merge_on_shared_dimension(snapshot, dialect: Dialect) -> None: + """§4.11 chasm-trap safety: two facts, merge on shared region.""" + query = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=( + _ref("orders", "total_revenue"), + _ref("returns", "total_refunds"), + ), + ) + assert _compile(query, dialect) == snapshot + + +@pytest.mark.golden +@pytest.mark.parametrize("dialect", _DIALECTS, ids=lambda d: d.name.lower()) +def test_sql__where_pushed_below_aggregate(snapshot, dialect: Dialect) -> None: + """WHERE is materialised as a FILTER step below the aggregate.""" + query = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + where=_sql("orders.amount > 100"), + ) + assert _compile(query, dialect) == snapshot + + +@pytest.mark.golden +@pytest.mark.parametrize("dialect", _DIALECTS, ids=lambda d: d.name.lower()) +def test_sql__order_by_and_limit(snapshot, dialect: Dialect) -> None: + query = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + order_by=( + OrderBy( + target=_ref("orders", "total_revenue"), + direction=SortDirection.DESC, + ), + ), + limit=10, + ) + assert _compile(query, dialect) == snapshot diff --git a/impl/python/tests/integration/__init__.py b/impl/python/tests/integration/__init__.py new file mode 100644 index 0000000..e366c92 --- /dev/null +++ b/impl/python/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests: end-to-end scenarios + README drift tests.""" diff --git a/impl/python/tests/integration/readme/__init__.py b/impl/python/tests/integration/readme/__init__.py new file mode 100644 index 0000000..1cc494c --- /dev/null +++ b/impl/python/tests/integration/readme/__init__.py @@ -0,0 +1 @@ +"""Runnable-README drift tests (long-term-viability audit Phase C).""" diff --git a/impl/python/tests/integration/readme/test_readme_examples_drift.py b/impl/python/tests/integration/readme/test_readme_examples_drift.py new file mode 100644 index 0000000..a569a20 --- /dev/null +++ b/impl/python/tests/integration/readme/test_readme_examples_drift.py @@ -0,0 +1,138 @@ +"""README ``python`` example drift test (long-term-viability audit Phase C). + +The Quick Start in [`impl/python/README.md`](../../../README.md) is the +single most important on-ramp for new contributors and external +adopters. If it stops running, every other adoption signal we send is +undermined. + +This test extracts every fenced ``python`` block from ``README.md`` and +either compiles or executes it, depending on a per-block directive in +the fence's info string: + +* By default (`````python```), the block is **executed** in a fresh + namespace. A failure to execute fails the test. +* `````python illustrative````` blocks are **compiled** only (syntax + + bytecode), not run. Use this for snippets that depend on an external + YAML file, a network, or a database — the fact that they parse as + valid Python is enough to catch most rot. + +The intent is to refuse "the README quick start uses a renamed import" +PRs mechanically. The Quick Start is part of the public surface; a +breaking change in the impl is a breaking change in the README. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[5] +_README = _REPO_ROOT / "impl" / "python" / "README.md" + +# ``` ```python ``` or ``` ```python illustrative ``` +# We capture the directive list after the language so a block can opt +# out of execution with ``illustrative``. Other directives can be +# added without changing this test. +_FENCE_OPEN_RE = re.compile(r"^```python(?P(?:\s+\w+)*)\s*$") +_FENCE_CLOSE = "```" + + +def _extract_blocks(text: str) -> list[tuple[str, list[str], int]]: + """Return ``(code, directives, start_line)`` per fenced python block.""" + blocks: list[tuple[str, list[str], int]] = [] + lines = text.splitlines() + idx = 0 + while idx < len(lines): + match = _FENCE_OPEN_RE.match(lines[idx]) + if not match: + idx += 1 + continue + directives = match.group("directives").split() + start = idx + 1 + end = start + while end < len(lines) and not lines[end].startswith(_FENCE_CLOSE): + end += 1 + blocks.append(("\n".join(lines[start:end]), directives, start + 1)) + idx = end + 1 + return blocks + + +def test_readme_is_present() -> None: + """A guard against repo-layout drift.""" + assert _README.is_file(), ( + f"README missing: {_README}. The Quick Start is part of the " + "public surface; the drift test cannot run without it." + ) + + +def test_readme_has_at_least_one_python_block() -> None: + """Without examples the test would be vacuously passing.""" + text = _README.read_text(encoding="utf-8") + blocks = _extract_blocks(text) + assert blocks, ( + f"No fenced ``python`` blocks found in {_README}. Either the " + "Quick Start was removed (re-add it) or the fence regex is " + "out of date." + ) + + +def _compile_block(code: str, start_line: int) -> Any: + """Compile ``code`` with a synthetic filename for clean tracebacks.""" + try: + return compile(code, f"", "exec") + except SyntaxError as exc: # pragma: no cover — drift signal + pytest.fail( + f"README ``python`` block at line {start_line} has a " + f"SyntaxError: {exc}. Either the code rotted (rename, " + "removed symbol) or the example was never valid; fix the " + "block in README.md.", + pytrace=False, + ) + + +def test_every_python_block_compiles() -> None: + """Every ``python`` block parses as valid Python. + + Catches the cheap kind of rot: renamed imports that the README + still cites. Does not catch logic errors — that's what + ``test_executable_blocks_run`` is for. + """ + text = _README.read_text(encoding="utf-8") + for code, _directives, start_line in _extract_blocks(text): + _compile_block(code, start_line) + + +def test_executable_blocks_run() -> None: + r"""Non-``illustrative`` blocks run end-to-end in a fresh namespace. + + The Quick Start MUST be runnable as written. Blocks that depend on + an external file (YAML model, database) should declare the + ``illustrative`` directive on their opening fence: + + \`\`\`python illustrative + result = parse_semantic_model("model.yaml") + \`\`\` + + Add a comment in the README pointing at this drift test if you are + tempted to introduce a new directive. + """ + text = _README.read_text(encoding="utf-8") + for code, directives, start_line in _extract_blocks(text): + if "illustrative" in directives: + continue + compiled = _compile_block(code, start_line) + namespace: dict[str, Any] = {"__name__": "__readme_example__"} + try: + exec(compiled, namespace) + except Exception as exc: # pragma: no cover — drift signal + pytest.fail( + f"README ``python`` block at line {start_line} failed " + f"to execute: {type(exc).__name__}: {exc}. Either the " + "example needs the ``illustrative`` directive (if it " + "now depends on external state) or the code rotted; " + "fix README.md or the underlying API.", + pytrace=False, + ) diff --git a/impl/python/tests/properties/README.md b/impl/python/tests/properties/README.md new file mode 100644 index 0000000..2079a2d --- /dev/null +++ b/impl/python/tests/properties/README.md @@ -0,0 +1,27 @@ +# Property-based tests + +Hypothesis-driven tests that enforce the universal laws of the algebra +stated in [`../../docs/JOIN_ALGEBRA.md §4`](../../docs/JOIN_ALGEBRA.md#4-laws). + +See [`../../docs/ALGEBRA_LAWS.md`](../../docs/ALGEBRA_LAWS.md) for the +complete mapping: law → property statement → test file → mutation +target. + +**Load-bearing.** Mutation testing on `src/osi/planning/algebra/` +targets ≥ 90% score (see [`../../INFRA.md §1.1.1`](../../INFRA.md)); the +property tests in this directory are what drives that score. + +## Layout + +- `strategies.py` — Hypothesis strategies for identifiers, schemas, + states, operator chains, DuckDB fixtures. +- `reference.py` — naive pandas reference interpreter for equivalence + laws (§4.9, §4.10, §4.11 of JOIN_ALGEBRA.md). +- `test_algebra_*.py` — one test file per law. + +## Rule of thumb + +A property test that can be made to pass by narrowing the strategy is +not a property test; it's an example. Narrow only when the original +property is genuinely wrong as stated — and if it is, fix +`../../docs/JOIN_ALGEBRA.md` first, then the test. diff --git a/impl/python/tests/properties/__init__.py b/impl/python/tests/properties/__init__.py new file mode 100644 index 0000000..2b3dd3b --- /dev/null +++ b/impl/python/tests/properties/__init__.py @@ -0,0 +1,6 @@ +"""Hypothesis-driven property tests. + +See ``docs/ALGEBRA_LAWS.md`` for the twelve laws and their test files. +Strategies live in :mod:`tests.properties.strategies` so that unit, +golden, and E2E tests can reuse the same generators. +""" diff --git a/impl/python/tests/properties/conftest.py b/impl/python/tests/properties/conftest.py new file mode 100644 index 0000000..76afb81 --- /dev/null +++ b/impl/python/tests/properties/conftest.py @@ -0,0 +1,11 @@ +"""Re-expose DuckDB fixtures so the property layer can run real SQL. + +Property tests for laws like §4.9 (enrich preserves rows) and §4.10 +(explosion safety) need the same seeded DuckDB the E2E suite uses. +``conftest.py`` only auto-applies inside its own subtree, so we +re-import the fixture here rather than duplicating the seed code. +""" + +from __future__ import annotations + +from tests.e2e.conftest import duckdb_sales # noqa: F401 diff --git a/impl/python/tests/properties/strategies.py b/impl/python/tests/properties/strategies.py new file mode 100644 index 0000000..172d9fa --- /dev/null +++ b/impl/python/tests/properties/strategies.py @@ -0,0 +1,291 @@ +"""Hypothesis strategies shared by every property test. + +Per ``docs/ALGEBRA_LAWS.md §1``: strategies are deliberately minimal — +just enough to exercise the algebra without drifting into scenarios the +Foundation does not support. + +Landed so far: + +* ``identifiers()`` — syntactically valid, non-reserved identifiers +* ``dimension_sets()`` — small grain sets +* ``dimension_columns()`` / ``fact_columns()`` — individual columns +* ``states()`` — valid :class:`CalculationState`, built through + :func:`osi.planning.algebra.source` so every generated state is + reachable via the algebra + +Strategies that require running SQL against DuckDB (e.g. +``duckdb_fixtures()``) land alongside Phase 4 codegen. +""" + +from __future__ import annotations + +from typing import cast + +from hypothesis import strategies as st +from sqlglot import expressions as exp + +from osi.common.identifiers import Identifier, normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.common.types import DimensionSet +from osi.planning.algebra import ( + AggregateFunction, + AggregateInfo, + CalculationState, + Column, + ColumnKind, + source, +) + +_IDENTIFIER_REGEX = r"^[a-z][a-z0-9_]{0,15}$" + +# Tokens we never want Hypothesis to feed into a SQL builder. Two +# disjoint groups: +# +# * **OSI internal sentinels** — names the algebra reserves for grain / +# provenance / wildcard handling; ``normalize_identifier`` already +# refuses them, so generating them here would just be a wasted draw. +# * **SQL reserved words** in the dialects we currently target (ANSI, +# DuckDB, Snowflake). The shape regex above happily produces tokens +# like ``in`` / ``as`` / ``or`` / ``on`` because they match +# ``[a-z][a-z0-9_]{0,15}``. When such a token leaks through and is +# later concatenated into a SQL string by another strategy +# (notably :func:`aggregate_column`, which builds ``SUM()``), +# sqlglot raises a ``ParseError`` and the whole property test +# fails with what looks like an unrelated crash. +# +# Filtering at the strategy level is sufficient because every column / +# expression strategy below either (a) runs the identifier through +# :func:`_frozen_col_ref` (which quotes) or (b) builds the AST +# programmatically with ``quoted=True``. Production code is *not* +# protected by this list — ``normalize_identifier`` accepts SQL +# keywords today; if you want to close that gap, fix it at the +# parser / codegen layer rather than mirroring the keyword list here. +_OSI_RESERVED_TOKENS = frozenset({"__grain__", "__provenance__", "__all__"}) + +# Conservative subset of SQL reserved words that match the identifier +# regex (``[a-z][a-z0-9_]{0,15}``) and are known to cause sqlglot +# parser failures when used unquoted in expression position. We do +# not need to mirror the full ANSI / Snowflake / DuckDB keyword +# lists — the property tests only exercise a handful of expression +# shapes, and any keyword that survives this filter and still breaks +# parsing should be added here. +_SQL_KEYWORD_TOKENS = frozenset( + { + "all", + "and", + "as", + "asc", + "between", + "by", + "case", + "cast", + "cross", + "desc", + "distinct", + "else", + "end", + "exists", + "false", + "for", + "from", + "full", + "group", + "having", + "if", + "in", + "inner", + "is", + "join", + "left", + "like", + "limit", + "no", + "not", + "null", + "of", + "on", + "or", + "order", + "outer", + "qualify", + "right", + "select", + "set", + "some", + "table", + "then", + "to", + "true", + "union", + "unique", + "using", + "values", + "when", + "where", + "with", + } +) +_RESERVED_TOKENS = _OSI_RESERVED_TOKENS | _SQL_KEYWORD_TOKENS + + +def identifiers() -> st.SearchStrategy[Identifier]: + """Generate a syntactically valid, non-reserved identifier.""" + return ( + st.from_regex(_IDENTIFIER_REGEX, fullmatch=True) + .filter(lambda s: s not in _RESERVED_TOKENS) + .map(normalize_identifier) + ) + + +def dimension_sets( + min_size: int = 0, + max_size: int = 4, +) -> st.SearchStrategy[DimensionSet]: + """Generate a small, deduplicated frozenset of identifiers (a grain).""" + return cast( + st.SearchStrategy[DimensionSet], + st.lists(identifiers(), min_size=min_size, max_size=max_size, unique=True).map( + frozenset + ), + ) + + +def _frozen_col_ref(name: Identifier) -> FrozenSQL: + """Build a quoted column reference without going through ``parse_one``. + + Building the AST directly via :func:`exp.column` with + ``quoted=True`` avoids two failure modes that bit us in the + earlier ``parse_one(f'"{name}"')`` form: + + 1. **Reserved-word leakage** — even though we wrapped the name in + double quotes in the f-string, sqlglot still occasionally chose + to parse generated names like ``in`` or ``as`` as keyword + tokens before recognising the quoting context. Skipping the + parser sidesteps the issue entirely. + 2. **Dialect-default quote style** — ``"foo"`` is portable today + but a future dialect addition (Spark, BigQuery) might prefer + backticks. ``quoted=True`` lets sqlglot pick the right style at + render time. + """ + return FrozenSQL.of(exp.column(str(name), quoted=True)) + + +def dimension_column(name: Identifier) -> Column: + """Canonical dimension column for ``name`` (identity expression).""" + return Column( + name=name, + expression=_frozen_col_ref(name), + dependencies=frozenset(), + kind=ColumnKind.DIMENSION, + ) + + +def fact_column(name: Identifier) -> Column: + """Canonical fact column for ``name`` (identity expression).""" + return Column( + name=name, + expression=_frozen_col_ref(name), + dependencies=frozenset(), + kind=ColumnKind.FACT, + ) + + +def aggregate_column( + name: Identifier, + *, + function: AggregateFunction = AggregateFunction.SUM, + over: Identifier, +) -> Column: + """Build an AGGREGATE column named ``name`` reducing ``over``. + + The aggregate AST is built programmatically rather than parsed + from a formatted string. The previous string path + (``parse_one(f"{function.name}({over})")``) fed an unquoted + identifier into sqlglot's expression parser; when Hypothesis + drew a SQL keyword like ``in``, sqlglot raised ``ParseError`` + and the test failed with what looked like an unrelated crash. + Constructing the column reference with ``quoted=True`` and the + aggregate node with :func:`exp.Anonymous` skips parsing + entirely and is keyword-safe by construction. + """ + column_ref = exp.column(str(over), quoted=True) + if function is AggregateFunction.COUNT_DISTINCT: + agg_node = exp.Count(this=exp.Distinct(expressions=[column_ref])) + else: + agg_node = exp.Anonymous( + this=function.name, + expressions=[column_ref], + ) + return Column( + name=name, + expression=FrozenSQL.of(agg_node), + dependencies=frozenset({over}), + kind=ColumnKind.AGGREGATE, + aggregate=AggregateInfo( + function=function, + argument=FrozenSQL.of(exp.column(str(over), quoted=True)), + ), + ) + + +@st.composite +def source_states( + draw: st.DrawFn, + *, + min_dims: int = 1, + max_dims: int = 4, + min_facts: int = 0, + max_facts: int = 3, +) -> CalculationState: + """Generate a valid :class:`CalculationState` by calling ``source``. + + This guarantees **invariant I-3**: every generated state arrives + through the algebra, not through direct construction. Hypothesis + shrinking stays inside the valid-state space because ``source`` + validates its preconditions. + """ + # Draw dims and facts separately so we can honour ``min_facts`` + # without relying on partition arithmetic. ``unique=True`` on a + # case-folding map would under-count, so we deduplicate after map. + n_dims = draw(st.integers(min_value=min_dims, max_value=max_dims)) + n_facts = draw(st.integers(min_value=min_facts, max_value=max_facts)) + pool = draw( + st.lists( + identifiers(), + unique=True, + min_size=n_dims + n_facts, + max_size=n_dims + n_facts, + ) + ) + dim_names = pool[:n_dims] + fact_names = pool[n_dims:] + pk_size = draw(st.integers(min_value=1, max_value=len(dim_names))) + primary_key: DimensionSet = frozenset(dim_names[:pk_size]) + return source( + primary_key=primary_key, + dimension_columns=[dimension_column(n) for n in dim_names], + fact_columns=[fact_column(n) for n in fact_names], + ) + + +def states( + *, min_dims: int = 1, max_dims: int = 4, min_facts: int = 0, max_facts: int = 3 +) -> st.SearchStrategy[CalculationState]: + """Public wrapper for :func:`source_states`.""" + return source_states( + min_dims=min_dims, + max_dims=max_dims, + min_facts=min_facts, + max_facts=max_facts, + ) + + +__all__ = [ + "aggregate_column", + "dimension_column", + "dimension_sets", + "fact_column", + "identifiers", + "source_states", + "states", +] diff --git a/impl/python/tests/properties/test_aggregate_idempotent.py b/impl/python/tests/properties/test_aggregate_idempotent.py new file mode 100644 index 0000000..f2d9f4a --- /dev/null +++ b/impl/python/tests/properties/test_aggregate_idempotent.py @@ -0,0 +1,33 @@ +"""Law §4.5 — Aggregate Idempotence at same grain. + +For any state whose grain already matches ``target_grain`` and whose +aggregations are identity re-aggregations at that grain, ``aggregate`` +returns a state that agrees on grain and columns. + +Mutation target: ``src/osi/planning/algebra/operations.py::aggregate``. +""" + +from __future__ import annotations + +from hypothesis import given, settings + +from osi.common.identifiers import normalize_identifier +from osi.planning.algebra import CalculationState, aggregate +from tests.properties.strategies import aggregate_column, states + + +@given(state=states(min_facts=1, max_facts=3)) +@settings(max_examples=200, deadline=None) +def test_same_grain_agg_preserves_grain(state: CalculationState) -> None: + fact = next(c for c in state.columns if c.kind.value == "fact") + out = aggregate( + state, + state.grain, + [ + aggregate_column( + normalize_identifier(f"total_{fact.name}"), + over=fact.name, + ) + ], + ) + assert out.grain == state.grain diff --git a/impl/python/tests/properties/test_algebra_determinism.py b/impl/python/tests/properties/test_algebra_determinism.py new file mode 100644 index 0000000..cfcae24 --- /dev/null +++ b/impl/python/tests/properties/test_algebra_determinism.py @@ -0,0 +1,33 @@ +"""Law §4.3 — Determinism. + +Same inputs ⇒ same output, including column order. + +At Phase 1 we only have states and algebra ops — byte-identical SQL +rendering (the ultimate determinism test) lands in +``test_sql_determinism.py`` during Phase 4. +""" + +from __future__ import annotations + +from hypothesis import given, settings + +from osi.planning.algebra import CalculationState, project +from tests.properties.strategies import states + + +@given(state=states()) +@settings(max_examples=300, deadline=None) +def test_project_preserves_column_order(state: CalculationState) -> None: + names = [c.name for c in state.columns] + out = project(state, names) + assert [c.name for c in out.columns] == names + + +@given(state=states(min_dims=2, max_dims=4)) +@settings(max_examples=200, deadline=None) +def test_same_projection_twice_is_identical(state: CalculationState) -> None: + names = [c.name for c in state.columns] + a = project(state, names) + b = project(state, names) + assert a == b + assert tuple(c.name for c in a.columns) == tuple(c.name for c in b.columns) diff --git a/impl/python/tests/properties/test_algebra_purity.py b/impl/python/tests/properties/test_algebra_purity.py new file mode 100644 index 0000000..b0b66ad --- /dev/null +++ b/impl/python/tests/properties/test_algebra_purity.py @@ -0,0 +1,74 @@ +"""Law §4.2 — Purity. + +Every operator is pure: no I/O, no clocks, no randomness, no mutation of +inputs. Calling the same operator twice with the same arguments returns +equal results, and the input state is unchanged. + +Mutation target: whole ``src/osi/planning/algebra/`` package. +""" + +from __future__ import annotations + +from copy import deepcopy + +from hypothesis import given, settings + +from osi.common.identifiers import normalize_identifier +from osi.planning.algebra import CalculationState, aggregate, project, source +from tests.properties.strategies import ( + aggregate_column, + dimension_column, + fact_column, + states, +) + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_project_does_not_mutate_state(state: CalculationState) -> None: + before = deepcopy(state) + _ = project(state, [c.name for c in state.columns]) + assert state == before + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_project_is_deterministic(state: CalculationState) -> None: + names = [c.name for c in state.columns] + a = project(state, names) + b = project(state, names) + assert a == b + assert a is not b or a == b + + +@given(state=states(min_facts=1, max_facts=3)) +@settings(max_examples=100, deadline=None) +def test_aggregate_is_deterministic(state: CalculationState) -> None: + target = state.grain + fact = next(c for c in state.columns if c.kind.value == "fact") + agg = aggregate_column( + normalize_identifier("total_repeat"), + over=fact.name, + ) + a = aggregate(state, target, [agg]) + b = aggregate(state, target, [agg]) + assert a == b + + +def test_source_with_equal_args_is_equal() -> None: + # Concrete case — property generator cannot compare because it + # already returns a built state, but we can double-build here. + pk = frozenset({normalize_identifier("a")}) + a = source( + primary_key=pk, + dimension_columns=[dimension_column(normalize_identifier("a"))], + fact_columns=[fact_column(normalize_identifier("x"))], + ) + b = source( + primary_key=pk, + dimension_columns=[dimension_column(normalize_identifier("a"))], + fact_columns=[fact_column(normalize_identifier("x"))], + ) + assert a == b + # Strong structural equality — frozen dataclasses hash/compare by value. + assert hash(a.grain) == hash(b.grain) diff --git a/impl/python/tests/properties/test_algebra_totality.py b/impl/python/tests/properties/test_algebra_totality.py new file mode 100644 index 0000000..a1a154c --- /dev/null +++ b/impl/python/tests/properties/test_algebra_totality.py @@ -0,0 +1,78 @@ +"""Law §4.1 — Totality. + +Every operator either returns a valid :class:`CalculationState` or raises +:class:`AlgebraError` / :class:`OSIError` with an ``E4xxx`` / ``E3xxx`` +code. No ``None``, no silent fallback. + +Mutation target: ``src/osi/planning/algebra/operations.py``. +""" + +from __future__ import annotations + +from hypothesis import given, settings + +from osi.errors import ErrorCode, OSIError +from osi.planning.algebra import CalculationState, aggregate, project +from tests.properties.strategies import aggregate_column, dimension_sets, states + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_source_states_are_valid(state: CalculationState) -> None: + assert isinstance(state, CalculationState) + assert state.grain.issubset(state.column_names) + + +@given(state=states(), target=dimension_sets(max_size=4)) +@settings(max_examples=200, deadline=None) +def test_aggregate_is_total(state: CalculationState, target: frozenset) -> None: + # Pick an aggregation that is always valid: SUM over some fact if + # one exists; else skip the aggregation entirely by passing []. + fact_names = [ + c.name + for c in state.columns + if c.kind.value == "fact" # stringly to avoid importing enum here + ] + aggs = ( + [aggregate_column(_unused_name(state), over=fact_names[0])] + if fact_names + else [] + ) + try: + out = aggregate(state, target, aggs) + except OSIError as err: + assert err.code.value.startswith(("E3", "E4")), err.code + return + assert isinstance(out, CalculationState) + assert out.grain == target + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_project_is_total(state: CalculationState) -> None: + # Always project exactly onto the current columns: happy path. + names = [c.name for c in state.columns] + out = project(state, names) + assert isinstance(out, CalculationState) + assert out.column_names == state.column_names + + +@given(state=states()) +@settings(max_examples=50, deadline=None) +def test_project_unknown_raises_osi_error(state: CalculationState) -> None: + try: + project(state, ["definitely_not_a_real_column_name_xyzzy"]) + except OSIError as err: + assert err.code is ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + return + raise AssertionError("project on unknown column should raise E3006") + + +def _unused_name(state: CalculationState) -> str: + base = "agg" + i = 0 + while True: + candidate = f"{base}_{i}" + if candidate not in state.column_names: + return candidate + i += 1 diff --git a/impl/python/tests/properties/test_chasm_safety.py b/impl/python/tests/properties/test_chasm_safety.py new file mode 100644 index 0000000..ca30607 --- /dev/null +++ b/impl/python/tests/properties/test_chasm_safety.py @@ -0,0 +1,85 @@ +"""Law §4.11 — Chasm-Trap Safety. + +Two facts sharing a dimension must be computed in separate states and +:func:`merge`-d on the shared dimension — never joined through a single +multi-branch state. This activates at plan level as soon as the planner +exists (Phase 3). Full end-to-end row-count verification lands in Phase +4 when the reference interpreter ships. + +Property under test: whenever a :class:`~osi.planning.SemanticQuery` +requests measures from ``n`` distinct fact datasets, the resulting +:class:`~osi.planning.QueryPlan` contains exactly ``n`` ``AGGREGATE`` +steps and ``n - 1`` ``MERGE`` steps. The aggregates occur before the +merges (topological order), and the merge grain equals the shared +dimension grain. +""" + +from __future__ import annotations + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.planning import PlanOperation, Reference, SemanticQuery, plan +from tests.unit.planning.fixtures import orders_context + + +def _ref(ds: str, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds), + name=normalize_identifier(name), + ) + + +@pytest.mark.parametrize( + "measures", + [ + ( + _ref("orders", "total_revenue"), + _ref("returns", "total_refunds"), + ), + ], +) +def test_two_facts_route_through_merge(measures: tuple[Reference, ...]) -> None: + ctx = orders_context() + query = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=measures, + ) + p = plan(query, ctx) + agg_count = sum(1 for s in p.steps if s.operation is PlanOperation.AGGREGATE) + merge_count = sum(1 for s in p.steps if s.operation is PlanOperation.MERGE) + assert agg_count == len(measures) + assert merge_count == len(measures) - 1 + + +def test_merge_grain_equals_query_dimension_grain() -> None: + ctx = orders_context() + query = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=( + _ref("orders", "total_revenue"), + _ref("returns", "total_refunds"), + ), + ) + p = plan(query, ctx) + merge_step = next(s for s in p.steps if s.operation is PlanOperation.MERGE) + assert merge_step.state.grain == frozenset({normalize_identifier("region")}) + + +def test_aggregates_precede_merge_in_topo_order() -> None: + ctx = orders_context() + query = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=( + _ref("orders", "total_revenue"), + _ref("returns", "total_refunds"), + ), + ) + p = plan(query, ctx) + first_merge = next( + i for i, s in enumerate(p.steps) if s.operation is PlanOperation.MERGE + ) + aggregates = [ + i for i, s in enumerate(p.steps) if s.operation is PlanOperation.AGGREGATE + ] + assert all(i < first_merge for i in aggregates) diff --git a/impl/python/tests/properties/test_enrich_preserves_rows.py b/impl/python/tests/properties/test_enrich_preserves_rows.py new file mode 100644 index 0000000..8d23892 --- /dev/null +++ b/impl/python/tests/properties/test_enrich_preserves_rows.py @@ -0,0 +1,103 @@ +"""Law §4.9 — Enrichment Preserves Parent Rows. + +For an N:1 ``enrich`` step the resulting state must contain *exactly* +the same multiset of rows as the parent state — adding RHS columns +must never change the row count. If it does, the join was not +single-valued and the algebra has either accepted a fan-trap (a +correctness bug in :func:`osi.planning.algebra.enrich`) or codegen +emitted something other than a left join (a correctness bug in +:mod:`osi.codegen.transpiler`). + +This file lands the first executable version of the law against the +seeded DuckDB schema in ``tests/e2e/conftest.py``. A full +``hypothesis``-driven generator over arbitrary 1:N topologies still +belongs to a follow-up sprint; the curated cases here exercise every +shape of enrich the Foundation supports. +""" + +from __future__ import annotations + +import duckdb +import pytest +import sqlglot + +from osi.codegen import Dialect, compile_plan +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.planning import Reference, SemanticQuery, plan +from tests.unit.planning.fixtures import orders_context + + +def _ref(ds: str, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds), + name=normalize_identifier(name), + ) + + +def _row_count(conn: duckdb.DuckDBPyConnection, table: str) -> int: + """Direct row count from the seeded DuckDB table.""" + return int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]) + + +def _grouped_count(conn: duckdb.DuckDBPyConnection, query: SemanticQuery) -> int: + """Row count of the compiled query against DuckDB.""" + p = plan(query, orders_context()) + sql = compile_plan(p, dialect=Dialect.DUCKDB) + return len(conn.execute(sql).fetchall()) + + +def test_enrich_preserves_pre_aggregate_row_count(duckdb_sales) -> None: + """An ``enrich`` that exposes a parent-grain dimension keeps row count. + + Selecting ``orders.order_id`` plus ``customers.region`` runs an + ``enrich`` from ``orders`` to ``customers`` (N:1) and groups by the + parent's primary key. Because ``order_id`` is the parent grain, the + grouped result must have exactly one row per order, i.e. the law + holds at the *visible* level too. + """ + parent_rows = _row_count(duckdb_sales, "sales.orders") + query = SemanticQuery( + dimensions=(_ref("orders", "order_id"), _ref("customers", "region")), + measures=(), + ) + assert _grouped_count(duckdb_sales, query) == parent_rows + + +def test_enrich_with_filter_preserves_filtered_row_count(duckdb_sales) -> None: + """A WHERE narrows the parent multiset; enrich preserves what survived.""" + expected = int( + duckdb_sales.execute( + "SELECT COUNT(*) FROM sales.orders WHERE status = 'paid'" + ).fetchone()[0] + ) + query = SemanticQuery( + dimensions=(_ref("orders", "order_id"), _ref("customers", "region")), + measures=(), + where=FrozenSQL.of(sqlglot.parse_one("orders.status = 'paid'")), + ) + assert _grouped_count(duckdb_sales, query) == expected + + +@pytest.mark.parametrize( + "rhs_dimension,expected_groups", + [ + ("region", 3), # NA, EMEA, APAC are present in seeded orders + ("segment", 2), # enterprise + smb both present + ], +) +def test_enrich_aggregate_groups_match_distinct_rhs( + duckdb_sales, rhs_dimension: str, expected_groups: int +) -> None: + """Aggregating by an enriched RHS dim must not invent groups. + + The number of distinct values of the RHS dimension *among rows + actually referenced by the parent fact* is the upper bound on the + aggregate's row count. Anything larger means enrich exploded; the + algebra is supposed to make that impossible. + """ + query = SemanticQuery( + dimensions=(_ref("customers", rhs_dimension),), + measures=(_ref("orders", "total_revenue"),), + ) + assert _grouped_count(duckdb_sales, query) == expected_groups diff --git a/impl/python/tests/properties/test_error_taxonomy.py b/impl/python/tests/properties/test_error_taxonomy.py new file mode 100644 index 0000000..df06e4f --- /dev/null +++ b/impl/python/tests/properties/test_error_taxonomy.py @@ -0,0 +1,56 @@ +"""Law §4.13 — Error Taxonomy. + +Every exception raised from the algebra is an :class:`OSIError` subclass +with a code that matches a value in :class:`ErrorCode`. No bare +:class:`ValueError` / :class:`AssertionError` / :class:`RuntimeError` at +runtime. + +Mutation target: ``src/osi/errors.py``. +""" + +from __future__ import annotations + +from hypothesis import given, settings + +from osi.common.identifiers import normalize_identifier +from osi.errors import ErrorCode, OSIError +from osi.planning.algebra import CalculationState, project, source +from tests.properties.strategies import dimension_column, identifiers, states + + +@given(state=states(), bogus=identifiers()) +@settings(max_examples=200, deadline=None) +def test_project_unknown_column_raises_typed_osi_error( + state: CalculationState, bogus: str +) -> None: + if bogus in state.column_names: + return + try: + project(state, [bogus]) + except OSIError as err: + assert err.code in ErrorCode + assert err.code.value.startswith(("E3", "E4")) + return + except Exception as err: # pragma: no cover — law-breach signal + raise AssertionError( + f"non-OSIError raised from project: {type(err).__name__}" + ) from err + raise AssertionError("project on unknown column must raise OSIError") + + +def test_source_empty_pk_raises_typed_osi_error() -> None: + """Example-based: empty primary key is a structural violation.""" + pk = frozenset() + try: + source( + primary_key=pk, + dimension_columns=[dimension_column(normalize_identifier("id"))], + ) + except OSIError as err: + assert err.code is ErrorCode.E2007_MISSING_PRIMARY_KEY + return + except Exception as err: # pragma: no cover + raise AssertionError( + f"non-OSIError raised from source: {type(err).__name__}" + ) from err + raise AssertionError("source with empty PK must raise") diff --git a/impl/python/tests/properties/test_explosion_safety.py b/impl/python/tests/properties/test_explosion_safety.py new file mode 100644 index 0000000..7f19869 --- /dev/null +++ b/impl/python/tests/properties/test_explosion_safety.py @@ -0,0 +1,97 @@ +"""Law §4.10 — Explosion Safety. + +The closed algebra promises that every plan it accepts compiles to SQL +that does not silently multiply rows from the parent fact. The tight +operational form of this law: + +* For any accepted plan, ``SUM()`` over the result + equals ``SUM()`` evaluated directly against the parent + fact's ``source:``. + +If the algebra ever lets a 1:N enrich slip through, the join would +duplicate parent rows and the equality breaks. We don't need a +reference interpreter to expose that drift — we just need a measure +whose physical-table answer is computable in pure SQL. + +This file replaces the long-skipped placeholder with concrete checks +against the seeded DuckDB schema. A hypothesis-driven generator over +arbitrary topologies is a follow-up; the curated cases here exercise +the join paths the Foundation supports today. +""" + +from __future__ import annotations + +import duckdb + +from osi.codegen import Dialect, compile_plan +from osi.common.identifiers import normalize_identifier +from osi.planning import Reference, SemanticQuery, plan +from tests.unit.planning.fixtures import orders_context + + +def _ref(ds: str, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds), + name=normalize_identifier(name), + ) + + +def _semantic_total(conn: duckdb.DuckDBPyConnection, query: SemanticQuery) -> float: + p = plan(query, orders_context()) + sql = compile_plan(p, dialect=Dialect.DUCKDB) + rows = conn.execute(sql).fetchall() + measure_index = len(query.dimensions) + return float(sum(r[measure_index] for r in rows)) + + +def _physical_total(conn: duckdb.DuckDBPyConnection, expr: str) -> float: + """Directly compute ``SUM()`` against the parent fact table.""" + return float(conn.execute(expr).fetchone()[0]) + + +def test_no_explosion__sum_amount_via_enriched_dim(duckdb_sales) -> None: + """``SUM(amount)`` grouped by ``customers.region`` must match the parent. + + If enrich exploded, the per-region sums would be inflated by the + fan-out factor. The total across the result set is the cleanest + invariant: it is preserved by *any* additive aggregation under a + non-explosive join. + """ + semantic = _semantic_total( + duckdb_sales, + SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + ), + ) + physical = _physical_total(duckdb_sales, "SELECT SUM(amount) FROM sales.orders") + assert semantic == physical + + +def test_no_explosion__count_orders_via_enriched_dim(duckdb_sales) -> None: + """``COUNT(*)`` over orders must equal a direct ``SELECT COUNT(*)``.""" + semantic = _semantic_total( + duckdb_sales, + SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "order_count"),), + ), + ) + physical = _physical_total(duckdb_sales, "SELECT COUNT(*) FROM sales.orders") + assert int(semantic) == int(physical) + + +def test_no_explosion__sum_via_two_enriched_dims(duckdb_sales) -> None: + """Adding a second N:1 enrich (region + segment) does not double-count.""" + semantic = _semantic_total( + duckdb_sales, + SemanticQuery( + dimensions=( + _ref("customers", "region"), + _ref("customers", "segment"), + ), + measures=(_ref("orders", "total_revenue"),), + ), + ) + physical = _physical_total(duckdb_sales, "SELECT SUM(amount) FROM sales.orders") + assert semantic == physical diff --git a/impl/python/tests/properties/test_filter_commute.py b/impl/python/tests/properties/test_filter_commute.py new file mode 100644 index 0000000..1218c62 --- /dev/null +++ b/impl/python/tests/properties/test_filter_commute.py @@ -0,0 +1,37 @@ +"""Law §4.6 — Filter Commutativity. + +``filter(filter(s, p1), p2)`` yields a state structurally equivalent to +``filter(filter(s, p2), p1)``. The Foundation algebra represents filter +by a no-op on the state (predicates live on the plan step), so this +law reduces to "filter never changes the state shape, regardless of +order." + +A DuckDB-executed row-set equivalence test is added alongside the +Phase 4 codegen harness. +""" + +from __future__ import annotations + +import sqlglot +from hypothesis import given, settings + +from osi.common.sql_expr import FrozenSQL +from osi.planning.algebra import CalculationState, filter_ +from tests.properties.strategies import states + +_p1 = FrozenSQL.of(sqlglot.parse_one("1 = 1")) +_p2 = FrozenSQL.of(sqlglot.parse_one("2 = 2")) + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_filter_order_does_not_change_state_shape( + state: CalculationState, +) -> None: + left = filter_( + filter_(state, _p1, dependencies=frozenset()), _p2, dependencies=frozenset() + ) + right = filter_( + filter_(state, _p2, dependencies=frozenset()), _p1, dependencies=frozenset() + ) + assert left == right diff --git a/impl/python/tests/properties/test_frozensql_canonical.py b/impl/python/tests/properties/test_frozensql_canonical.py new file mode 100644 index 0000000..1b526df --- /dev/null +++ b/impl/python/tests/properties/test_frozensql_canonical.py @@ -0,0 +1,83 @@ +"""Canonical-form stability of :class:`FrozenSQL`. + +``FrozenSQL.canonical`` is the *only* thing +:class:`FrozenSQL.__hash__` and :class:`FrozenSQL.__eq__` look at. +Three properties have to hold for the algebra's immutability story to +work: + +1. **Reproducibility** — calling :meth:`FrozenSQL.of` twice on the same + parsed expression yields the same canonical string. +2. **Copy-stability** — :meth:`exp.Expression.copy` produces an AST + that, when wrapped, has the same canonical string. The transpiler + and the algebra rely on this every time they pass an argument + through ``.copy()`` defensively. +3. **Round-trip stability** — re-parsing the rendered SQL of an + expression produces a wrapper with the same canonical string. This + is what makes :meth:`FrozenSQL` safe to serialize via + :attr:`canonical` and reconstruct. + +If any of these fail, two structurally identical predicates can hash +differently and the algebra's "set of FrozenSQL columns is a value" +contract breaks silently. +""" + +from __future__ import annotations + +import sqlglot +from hypothesis import given +from hypothesis import strategies as st + +from osi.common.sql_expr import FrozenSQL + +_BASE_EXPRS: list[str] = [ + "x", + "x + 1", + "x * y", + "(x + y) * z", + "SUM(x)", + "SUM(price * qty)", + "COUNT(DISTINCT customer_id)", + "CASE WHEN x > 0 THEN 1 ELSE 0 END", + "lower(name)", + "x = 1 AND y = 2", + "x IN (1, 2, 3)", + "COALESCE(x, 0) + COALESCE(y, 0)", +] + + +@st.composite +def parsed_exprs(draw: st.DrawFn) -> sqlglot.exp.Expression: + """Pick one of the curated source strings and parse it fresh.""" + src = draw(st.sampled_from(_BASE_EXPRS)) + return sqlglot.parse_one(src) + + +@given(parsed_exprs()) +def test_of_is_idempotent(expr: sqlglot.exp.Expression) -> None: + """Wrapping the same AST twice gives the same canonical form.""" + a = FrozenSQL.of(expr) + b = FrozenSQL.of(expr) + assert a.canonical == b.canonical + assert hash(a) == hash(b) + assert a == b + + +@given(parsed_exprs()) +def test_of_stable_under_copy(expr: sqlglot.exp.Expression) -> None: + """``expr.copy()`` must not change the canonical form.""" + a = FrozenSQL.of(expr) + b = FrozenSQL.of(expr.copy()) + assert a.canonical == b.canonical + assert hash(a) == hash(b) + assert a == b + + +@given(parsed_exprs()) +def test_of_stable_under_reparse(expr: sqlglot.exp.Expression) -> None: + """Round-tripping through SQL text preserves canonical equality.""" + a = FrozenSQL.of(expr) + rendered = a.canonical + b = FrozenSQL.of(sqlglot.parse_one(rendered)) + assert a.canonical == b.canonical + assert hash(a) == hash(b) + assert a == b diff --git a/impl/python/tests/properties/test_grain_closure.py b/impl/python/tests/properties/test_grain_closure.py new file mode 100644 index 0000000..be31508 --- /dev/null +++ b/impl/python/tests/properties/test_grain_closure.py @@ -0,0 +1,100 @@ +"""Law §4.4 — Closure of Grain. + +For any step sequence the resulting grain is a pure function of the +argument stream. The symbolic simulator in +:mod:`osi.planning.algebra.grain` and the concrete algebra agree. + +Mutation target: ``src/osi/planning/algebra/grain.py``. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from osi.common.identifiers import normalize_identifier +from osi.planning.algebra import CalculationState, aggregate, filter_, project +from osi.planning.algebra.grain import ( + AggregateStep, + OperatorTag, + SimpleStep, + SourceStep, + simulate_grain, +) +from tests.properties.strategies import aggregate_column, states + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_source_grain_matches_simulation(state: CalculationState) -> None: + symbolic = simulate_grain((SourceStep(OperatorTag.SOURCE, state.grain),)) + assert symbolic == state.grain + + +@given(data=st.data(), state=states()) +@settings(max_examples=200, deadline=None) +def test_aggregate_grain_matches_simulation( + data: st.DataObject, state: CalculationState +) -> None: + # Draw the aggregation target as a non-empty subset of the state's + # grain — this trivially satisfies the aggregate precondition and + # avoids filtering out most generated states via ``assume``. + grain_list = sorted(state.grain) + size = data.draw(st.integers(min_value=1, max_value=len(grain_list))) + target = frozenset(data.draw(st.permutations(grain_list))[:size]) + fact_names = [c.name for c in state.columns if c.kind.value == "fact"] + aggs = ( + [aggregate_column(_unused_name(state), over=fact_names[0])] + if fact_names + else [] + ) + concrete = aggregate(state, target, aggs) + symbolic = simulate_grain( + ( + SourceStep(OperatorTag.SOURCE, state.grain), + AggregateStep(OperatorTag.AGGREGATE, target), + ) + ) + assert concrete.grain == symbolic == target + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_project_preserves_grain(state: CalculationState) -> None: + concrete = project(state, [c.name for c in state.columns]) + symbolic = simulate_grain( + ( + SourceStep(OperatorTag.SOURCE, state.grain), + SimpleStep(OperatorTag.PROJECT), + ) + ) + assert concrete.grain == symbolic == state.grain + + +@given(state=states()) +@settings(max_examples=200, deadline=None) +def test_filter_preserves_grain(state: CalculationState) -> None: + # Use a predicate with no dependencies — structural preservation + # is what the law tests, not predicate validation. + import sqlglot + + from osi.common.sql_expr import FrozenSQL + + pred = FrozenSQL.of(sqlglot.parse_one("1 = 1")) + concrete = filter_(state, pred, dependencies=frozenset()) + symbolic = simulate_grain( + ( + SourceStep(OperatorTag.SOURCE, state.grain), + SimpleStep(OperatorTag.FILTER), + ) + ) + assert concrete.grain == symbolic == state.grain + + +def _unused_name(state: CalculationState) -> str: + i = 0 + while True: + candidate = normalize_identifier(f"agg_{i}") + if candidate not in state.column_names: + return candidate + i += 1 diff --git a/impl/python/tests/properties/test_merge_associative.py b/impl/python/tests/properties/test_merge_associative.py new file mode 100644 index 0000000..039d1d2 --- /dev/null +++ b/impl/python/tests/properties/test_merge_associative.py @@ -0,0 +1,60 @@ +"""Law §4.7 — Merge Associativity. + +``merge(merge(a, b), c) ≡ merge(a, merge(b, c))`` at equal grains with +disjoint non-grain columns. + +Mutation target: ``src/osi/planning/algebra/operations.py::merge``. +""" + +from __future__ import annotations + +from hypothesis import assume, given, settings +from hypothesis import strategies as st + +from osi.planning.algebra import merge, source +from tests.properties.strategies import ( + dimension_column, + dimension_sets, + fact_column, + identifiers, +) + + +@st.composite +def _states_on_grain(draw, grain): + """Build a CalculationState on ``grain`` with exactly one unique fact.""" + fact_name = draw(identifiers()) + # Avoid collisions with the grain identifiers. + assume(fact_name not in grain) + dims = [dimension_column(n) for n in sorted(grain)] + return source( + primary_key=grain if grain else frozenset({dims[0].name}) if dims else None, + dimension_columns=dims, + fact_columns=[fact_column(fact_name)], + ) + + +@given(grain=dimension_sets(min_size=1, max_size=3), data=st.data()) +@settings(max_examples=100, deadline=None) +def test_merge_is_associative(grain, data) -> None: + a = data.draw(_states_on_grain(grain)) + b_name = data.draw( + identifiers().filter(lambda n: n not in {c.name for c in a.columns}) + ) + b = source( + primary_key=grain, + dimension_columns=[dimension_column(n) for n in sorted(grain)], + fact_columns=[fact_column(b_name)], + ) + c_name = data.draw( + identifiers().filter(lambda n: n not in {c.name for c in a.columns} | {b_name}) + ) + c = source( + primary_key=grain, + dimension_columns=[dimension_column(n) for n in sorted(grain)], + fact_columns=[fact_column(c_name)], + ) + left = merge(merge(a, b), c) + right = merge(a, merge(b, c)) + assert left.grain == right.grain + assert {col.name for col in left.columns} == {col.name for col in right.columns} diff --git a/impl/python/tests/properties/test_mn_rejection.py b/impl/python/tests/properties/test_mn_rejection.py new file mode 100644 index 0000000..804a6d4 --- /dev/null +++ b/impl/python/tests/properties/test_mn_rejection.py @@ -0,0 +1,84 @@ +"""Law §4.12 — Fan-Trap Rejection. + +The closed-algebra ``enrich`` operator is meant to preserve the parent's +grain. That is true only when the child is **unique on the join keys** +— equivalently, ``child.grain ⊆ frozenset(child_keys)``. Any traversal +that violates this rule (1→N relationships traversed in reverse, +many-to-many fact-to-fact joins, or a key set narrower than the child's +grain) is a *fan trap* and must be rejected with +:attr:`ErrorCode.E3011_MN_AGGREGATION_REJECTED`. + +``filtering_join`` is excused: it only filters left rows, so the right +side's cardinality cannot fan out the result. + +Mutation target: ``src/osi/planning/algebra/operations.py::enrich``. +""" + +from __future__ import annotations + +from hypothesis import given, settings + +from osi.common.identifiers import normalize_identifier +from osi.errors import ErrorCode, OSIError +from osi.planning.algebra import ( + CalculationState, + FilterMode, + JoinType, + enrich, + filtering_join, + source, +) +from tests.properties.strategies import dimension_column, states + + +@given(state=states()) +@settings(max_examples=100, deadline=None) +def test_enrich_rejects_fan_trap_when_child_grain_exceeds_keys( + state: CalculationState, +) -> None: + """A child whose grain is wider than the join keys is a fan trap. + + The Foundation ``enrich`` requires ``child.grain ⊆ child_keys``; any + counter-example must be rejected with ``E3011``. + """ + a = normalize_identifier("a") + b = normalize_identifier("b") + if a not in state.column_names or b in state.column_names: + return # parent must contain ``a`` for the parent_keys check + child = source( + primary_key=frozenset({a, b}), + dimension_columns=[dimension_column(a), dimension_column(b)], + ) + try: + enrich( + state, + child, + parent_keys=(a,), + child_keys=(a,), + join_type=JoinType.INNER, + ) + except OSIError as err: + assert err.code is ErrorCode.E3011_MN_AGGREGATION_REJECTED + return + raise AssertionError("enrich should have rejected fan-trap traversal") + + +def test_filtering_join_accepts_nn_style_relationship() -> None: + # filtering_join does not care about cardinality — it's a + # set-membership test. + left = source( + primary_key=frozenset({normalize_identifier("a")}), + dimension_columns=[dimension_column(normalize_identifier("a"))], + ) + right = source( + primary_key=frozenset({normalize_identifier("a")}), + dimension_columns=[dimension_column(normalize_identifier("a"))], + ) + out = filtering_join( + left, + right, + lhs_keys=frozenset({normalize_identifier("a")}), + rhs_keys=frozenset({normalize_identifier("a")}), + mode=FilterMode.SEMI, + ) + assert out.column_names == left.column_names diff --git a/impl/python/tests/properties/test_planner_mn_rejection.py b/impl/python/tests/properties/test_planner_mn_rejection.py new file mode 100644 index 0000000..e119899 --- /dev/null +++ b/impl/python/tests/properties/test_planner_mn_rejection.py @@ -0,0 +1,51 @@ +"""Law §4.12 — M:N Rejection at plan level. + +Extends :mod:`tests.properties.test_mn_rejection` from the algebra to +the planner: any :class:`SemanticQuery` whose enrichment chain crosses +an N:N relationship with no bridge / stitch / EXISTS_IN route must +raise ``E3012_MN_NO_SAFE_REWRITE`` before any SQL is produced. + +``E3011_MN_AGGREGATION_REJECTED`` is reserved as the engine-capability +opt-out (per ``Proposed_OSI_Semantics.md §6.8 Semantic guarantee``) — +emitted by engines that do not support M:N at all. This reference +implementation supports M:N, so the user-facing per-query failure surface is +``E3012`` / ``E3013``, not ``E3011``. The algebra layer raises +``E3011`` internally as a precondition signal on N:N edges; the +planner reclassifies it to the per-query code before returning. + +Property target: :mod:`osi.planning.joins` — specifically +:func:`_classify_unsafe_step`. +""" + +from __future__ import annotations + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.errors import ErrorCode, OSIError +from osi.planning import Reference, SemanticQuery, plan +from tests.unit.planning.fixtures import mn_context + + +def _ref(ds: str, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds), + name=normalize_identifier(name), + ) + + +@pytest.mark.parametrize( + "dimension,measure", + [ + (_ref("courses", "subject"), _ref("grade_logs", "avg_grade")), + (_ref("courses", "title"), _ref("grade_logs", "avg_grade")), + ], +) +def test_any_query_spanning_m_n_edge_raises_E3012( + dimension: Reference, measure: Reference +) -> None: + ctx = mn_context() + query = SemanticQuery(dimensions=(dimension,), measures=(measure,)) + with pytest.raises(OSIError) as excinfo: + plan(query, ctx) + assert excinfo.value.code is ErrorCode.E3012_MN_NO_SAFE_REWRITE diff --git a/impl/python/tests/properties/test_project_idempotent.py b/impl/python/tests/properties/test_project_idempotent.py new file mode 100644 index 0000000..1273687 --- /dev/null +++ b/impl/python/tests/properties/test_project_idempotent.py @@ -0,0 +1,54 @@ +"""Law §4.8 — Projection Idempotence. + +``project(project(s, c1), c2) ≡ project(s, c2)`` whenever ``c2 ⊆ c1`` +and ``c2`` covers ``s.grain``. + +Mutation target: ``src/osi/planning/algebra/operations.py::project``. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from osi.planning.algebra import CalculationState, project +from tests.properties.strategies import states + + +@given(state=states(min_dims=1, max_dims=3, min_facts=1, max_facts=3), data=st.data()) +@settings(max_examples=200, deadline=None) +def test_project_is_idempotent(state: CalculationState, data) -> None: + # c1 ⊇ c2 ⊇ grain so both projections succeed. + all_names = [c.name for c in state.columns] + grain_names = sorted(state.grain) + extras = [n for n in all_names if n not in state.grain] + # Draw a size for c2 from [len(grain), len(all_names)], then grow c1. + size_c2 = data.draw( + st.integers(min_value=len(grain_names), max_value=len(all_names)) + ) + c2_extras = data.draw( + st.lists( + st.sampled_from(extras) if extras else st.nothing(), + max_size=max(0, size_c2 - len(grain_names)), + unique=True, + ) + if extras + else st.just([]) + ) + c2 = grain_names + list(dict.fromkeys(c2_extras)) + # Draw c1 strictly containing c2. + remaining = [n for n in extras if n not in c2_extras] + c1_extras = data.draw( + st.lists( + st.sampled_from(remaining) if remaining else st.nothing(), + max_size=len(remaining), + unique=True, + ) + if remaining + else st.just([]) + ) + c1 = c2 + list(dict.fromkeys(c1_extras)) + + via_two = project(project(state, c1), c2) + direct = project(state, c2) + assert via_two == direct diff --git a/impl/python/tests/properties/test_sql_determinism.py b/impl/python/tests/properties/test_sql_determinism.py new file mode 100644 index 0000000..26e4dda --- /dev/null +++ b/impl/python/tests/properties/test_sql_determinism.py @@ -0,0 +1,90 @@ +"""Phase 4 — SQL rendering determinism. + +For any valid plan, compiling twice with the same dialect must produce +byte-identical SQL. Hypothesis samples from a small corpus of shaped +queries against the fixed ``orders`` model so we exercise every +``PlanOperation`` (SOURCE, FILTER, ENRICH, AGGREGATE, PROJECT, MERGE). + +Law §4.3 extended: *Determinism of rendering*. A byte diff in rendered +SQL between two identical compilations is always a regression. +""" + +from __future__ import annotations + +import sqlglot +from hypothesis import given, settings +from hypothesis import strategies as st + +from osi.codegen import Dialect, compile_plan +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.planning import OrderBy, Reference, SemanticQuery, SortDirection, plan +from tests.unit.planning.fixtures import orders_context + + +def _ref(ds: str, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds), + name=normalize_identifier(name), + ) + + +_QUERIES: tuple[SemanticQuery, ...] = ( + SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + ), + SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + ), + SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=( + _ref("orders", "total_revenue"), + _ref("returns", "total_refunds"), + ), + ), + SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + where=FrozenSQL.of(sqlglot.parse_one("orders.amount > 100")), + ), + SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + order_by=( + OrderBy( + target=_ref("orders", "total_revenue"), + direction=SortDirection.DESC, + ), + ), + limit=5, + ), +) + + +@given( + query=st.sampled_from(_QUERIES), + dialect=st.sampled_from(list(Dialect)), +) +@settings(max_examples=60, deadline=None) +def test_sql_rendering_is_deterministic(query: SemanticQuery, dialect: Dialect) -> None: + """Compiling the same query+dialect twice yields identical SQL.""" + ctx = orders_context() + sql_a = compile_plan(plan(query, ctx), dialect=dialect) + sql_b = compile_plan(plan(query, ctx), dialect=dialect) + assert sql_a == sql_b + + +@given(query=st.sampled_from(_QUERIES)) +@settings(max_examples=30, deadline=None) +def test_compilation_is_pure(query: SemanticQuery) -> None: + """The planner context is read-only — repeated compilation is pure.""" + ctx = orders_context() + plan_a = plan(query, ctx) + plan_b = plan(query, ctx) + assert plan_a.to_json() == plan_b.to_json() + assert compile_plan(plan_a, dialect=Dialect.ANSI) == compile_plan( + plan_b, dialect=Dialect.ANSI + ) diff --git a/impl/python/tests/properties/test_window_roundtrip.py b/impl/python/tests/properties/test_window_roundtrip.py new file mode 100644 index 0000000..95789c7 --- /dev/null +++ b/impl/python/tests/properties/test_window_roundtrip.py @@ -0,0 +1,112 @@ +"""Property tests for the positive window planner (S-22 / S-24). + +Two properties: + +1. **Round-trip**: a valid windowed expression parses, passes the + deferred-construct check, renders back, and re-parses to a SQL + string structurally equivalent to the input (modulo whitespace + normalisation). Catches accidental rewrites that change semantics. +2. **Detection invariant**: the *top-level* window detector and the + *contains-window* detector agree on shape (every top-level window + contains a window; the converse need not hold). + +The strategies generate small but realistic windowed bodies covering +the OSI-supported axes: aggregate vs ranking, with / without +PARTITION, with / without explicit frame. +""" + +from __future__ import annotations + +from typing import Optional + +from hypothesis import given +from hypothesis import strategies as st + +from osi.common.sql_expr import FrozenSQL, parse_sql_expr +from osi.common.windows import contains_window, is_windowed_expression +from osi.parsing.deferred import check_expression_deferred + + +def _frozen(sql: str) -> FrozenSQL: + return FrozenSQL.of(parse_sql_expr(sql)) + + +_AGGREGATE_FNS = [ + "SUM(amount)", + "COUNT(id)", + "MIN(amount)", + "MAX(amount)", + "AVG(amount)", +] +_RANKING_FNS = ["ROW_NUMBER()", "RANK()", "DENSE_RANK()"] + + +@st.composite +def windowed_expressions(draw) -> tuple[str, str, Optional[str]]: + """Generate (function_call, partition_cols, order_cols). + + The strings are concatenated by the caller into a valid OVER + clause. The strategy keeps the alphabet small (a/b/c columns) so + sqlglot's deterministic rendering is stable across versions. + """ + fn = draw(st.sampled_from(_AGGREGATE_FNS + _RANKING_FNS)) + partition_cols = draw( + st.lists(st.sampled_from(["a", "b"]), max_size=2, unique=True) + ) + order_cols_raw = draw( + st.lists(st.sampled_from(["a", "b", "c"]), max_size=2, unique=True) + ) + order_cols = ", ".join(order_cols_raw) if order_cols_raw else None + partition = ", ".join(partition_cols) if partition_cols else None + return fn, partition, order_cols + + +def _build(fn: str, partition: Optional[str], order: Optional[str]) -> str: + parts: list[str] = [] + if partition: + parts.append(f"PARTITION BY {partition}") + if order: + parts.append(f"ORDER BY {order}") + inner = " ".join(parts) + return f"{fn} OVER ({inner})" if inner else f"{fn} OVER ()" + + +# --------------------------------------------------------------------------- +# Property 1: round-trip +# --------------------------------------------------------------------------- + + +@given(windowed_expressions()) +def test_window_roundtrip_preserves_canonical(parts) -> None: + sql = _build(*parts) + parsed = _frozen(sql) + # The parser does not reject a valid window after S-22. + check_expression_deferred(parsed, where="metric x") + # Re-rendered SQL re-parses with the same canonical form. This + # catches any rewrite that loses partition / order columns or + # silently flips aggregate / ranking shape. + rendered = parsed.expr.sql() + reparsed = _frozen(rendered) + assert reparsed.canonical == parsed.canonical + + +# --------------------------------------------------------------------------- +# Property 2: detector agreement +# --------------------------------------------------------------------------- + + +@given(windowed_expressions()) +def test_top_level_window_implies_contains_window(parts) -> None: + parsed = _frozen(_build(*parts)).expr + assert is_windowed_expression(parsed) + assert contains_window(parsed) + + +@given(windowed_expressions(), st.sampled_from(["+ 1", "* 2", "- 3"])) +def test_arithmetic_around_window_breaks_top_level_only(parts, op_suffix) -> None: + body_sql = _build(*parts) + " " + op_suffix + parsed = _frozen(body_sql).expr + # Composing the window with arithmetic moves it out of the + # *top-level* slot but the AST still contains a window node. + assert not is_windowed_expression(parsed) + assert contains_window(parsed) diff --git a/impl/python/tests/unit/__init__.py b/impl/python/tests/unit/__init__.py new file mode 100644 index 0000000..c48553c --- /dev/null +++ b/impl/python/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests — fast, focused single-function tests. See docs/TESTING_STRATEGY.md.""" diff --git a/impl/python/tests/unit/codegen/__init__.py b/impl/python/tests/unit/codegen/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/impl/python/tests/unit/codegen/test_cte_optimizer.py b/impl/python/tests/unit/codegen/test_cte_optimizer.py new file mode 100644 index 0000000..9da12f1 --- /dev/null +++ b/impl/python/tests/unit/codegen/test_cte_optimizer.py @@ -0,0 +1,94 @@ +"""Unit tests for :mod:`osi.codegen.cte_optimizer`.""" + +from __future__ import annotations + +import sqlglot + +from osi.codegen.cte_optimizer import optimize_ctes + + +def _parse(sql: str): + return sqlglot.parse_one(sql) + + +def test_optimize__no_with_is_noop() -> None: + ast = _parse("SELECT 1") + out = optimize_ctes(ast) + assert out is ast + assert out.args.get("with") is None + + +def test_optimize__trivial_pass_through_is_inlined() -> None: + """A trivially pass-through final CTE is collapsed into the outer SELECT. + + The pattern ``step_001 AS (SELECT step_000.x FROM step_000)`` followed + by ``SELECT step_001.x FROM step_001`` is equivalent to reading + ``step_000`` directly; the optimizer removes ``step_001`` and rewrites + all references. + """ + ast = _parse( + "WITH step_000 AS (SELECT 1 AS x), " + "step_001 AS (SELECT step_000.x FROM step_000) " + "SELECT step_001.x FROM step_001" + ) + out = optimize_ctes(ast) + # step_001 is inlined; only step_000 should remain. + with_clause = out.args.get("with") + assert with_clause is not None + names = {c.alias_or_name for c in with_clause.expressions} + assert names == {"step_000"} + # Outer SELECT must now read from step_000. + from_table = out.args["from"].this + assert from_table.name == "step_000" + + +def test_optimize__non_trivial_final_cte_is_not_inlined() -> None: + """A final CTE that is not a bare pass-through is left alone.""" + ast = _parse( + "WITH step_000 AS (SELECT 1 AS x), " + "step_001 AS (SELECT x FROM step_000 WHERE x > 0) " + "SELECT x FROM step_001" + ) + out = optimize_ctes(ast) + with_clause = out.args.get("with") + assert with_clause is not None + names = {c.alias_or_name for c in with_clause.expressions} + assert names == {"step_000", "step_001"} + + +def test_optimize__drops_unreferenced_cte() -> None: + ast = _parse( + "WITH step_000 AS (SELECT 1 AS x), step_unused AS (SELECT 2 AS y) " + "SELECT x FROM step_000" + ) + out = optimize_ctes(ast) + with_clause = out.args.get("with") + assert with_clause is not None + names = {c.alias_or_name for c in with_clause.expressions} + assert names == {"step_000"} + + +def test_optimize__keeps_transitively_referenced_cte() -> None: + """If a kept (non-trivial) CTE references another, the referent survives.""" + ast = _parse( + "WITH step_000 AS (SELECT 1 AS x), " + "step_001 AS (SELECT x FROM step_000 WHERE x > 0), " + "step_unused AS (SELECT 2 AS y) " + "SELECT x FROM step_001" + ) + out = optimize_ctes(ast) + with_clause = out.args.get("with") + assert with_clause is not None + names = {c.alias_or_name for c in with_clause.expressions} + assert names == {"step_000", "step_001"} + + +def test_optimize__is_idempotent() -> None: + ast = _parse( + "WITH step_000 AS (SELECT 1 AS x), step_unused AS (SELECT 2 AS y) " + "SELECT x FROM step_000" + ) + once = optimize_ctes(ast).sql() + twice = optimize_ctes(optimize_ctes(_parse(once))).sql() + assert once == twice + diff --git a/impl/python/tests/unit/codegen/test_dialect.py b/impl/python/tests/unit/codegen/test_dialect.py new file mode 100644 index 0000000..86120b4 --- /dev/null +++ b/impl/python/tests/unit/codegen/test_dialect.py @@ -0,0 +1,45 @@ +"""Unit tests for :mod:`osi.codegen.dialect`.""" + +from __future__ import annotations + +import pytest +import sqlglot +from sqlglot import expressions as exp + +from osi.codegen import Dialect +from osi.codegen.dialect import render_sql + + +@pytest.mark.parametrize( + "dialect,expected", + [ + (Dialect.ANSI, "SELECT 1"), + (Dialect.DUCKDB, "SELECT 1"), + (Dialect.SNOWFLAKE, "SELECT 1"), + ], +) +def test_render_sql__trivial_select(dialect: Dialect, expected: str) -> None: + rendered = render_sql(exp.select(exp.Literal.number(1)), dialect=dialect) + # Whitespace-insensitive check; syrupy locks exact formatting in goldens. + assert " ".join(rendered.split()) == expected + + +def test_render_sql__preserves_cte_structure() -> None: + # ``render_sql`` always quotes identifiers (``identify=True``) so a + # user-defined name that collides with a SQL reserved word renders + # to valid SQL on every supported dialect. The assertions below + # mirror that quoted form rather than the bare ANSI shape. + ast = sqlglot.parse_one("WITH t AS (SELECT 1 AS x) SELECT x FROM t") + rendered = render_sql(ast, dialect=Dialect.DUCKDB) + compact = " ".join(rendered.split()) + assert 'WITH "t" AS' in compact and 'SELECT "x" FROM "t"' in compact + + +def test_dialect_enum_is_closed() -> None: + """Dialect must stay a finite enum — new values are deliberate changes.""" + assert {d.name for d in Dialect} == { + "OSI_SQL_2026", + "ANSI", + "DUCKDB", + "SNOWFLAKE", + } diff --git a/impl/python/tests/unit/codegen/test_transpiler.py b/impl/python/tests/unit/codegen/test_transpiler.py new file mode 100644 index 0000000..9ef6be3 --- /dev/null +++ b/impl/python/tests/unit/codegen/test_transpiler.py @@ -0,0 +1,123 @@ +"""Unit tests for :mod:`osi.codegen.transpiler`.""" + +from __future__ import annotations + +import pytest +import sqlglot +from sqlglot import expressions as exp + +from osi.codegen import Dialect, compile_plan +from osi.codegen.transpiler import plan_to_select +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.errors import ErrorCode, OSICodegenError +from osi.planning import Reference, SemanticQuery, plan +from osi.planning.plan import PlanOperation, PlanStep, QueryPlan, SourcePayload +from tests.unit.planning.fixtures import orders_context + + +def _ref(ds: str, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds), + name=normalize_identifier(name), + ) + + +def _sql(expr: str) -> FrozenSQL: + return FrozenSQL.of(sqlglot.parse_one(expr)) + + +def test_plan_to_select__emits_one_cte_per_step() -> None: + query = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + ) + p = plan(query, orders_context()) + ast = plan_to_select(p) + with_clause = ast.args.get("with") + assert with_clause is not None + assert len(with_clause.expressions) == len(p.steps) + # Alias pattern is deterministic: step_000, step_001, ... + names = [c.alias_or_name for c in with_clause.expressions] + assert names == [f"step_{i:03d}" for i in range(len(p.steps))] + + +def test_plan_to_select__final_references_root_cte() -> None: + query = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + ) + p = plan(query, orders_context()) + ast = plan_to_select(p) + # The outer SELECT's FROM must reference the root step's CTE alias. + tables = list(ast.find_all(exp.Table)) + from_tables = [t for t in tables if t.parent is not with_clause(ast)] + assert any( + t.name == f"step_{p.root_step_id:03d}" for t in from_tables + ), f"expected outer SELECT to FROM step_{p.root_step_id:03d}" + + +def with_clause(ast: exp.Select): + return ast.args.get("with") + + +def test_enrich_join__uses_different_column_names_on_each_side() -> None: + """Regression: orders.customer_id ↔ customers.id must not collapse. + + ``compile_plan`` quotes every identifier so the join condition + surfaces as ``"customer_id" = "step_000_r"."id"``. The negative + assertion mirrors the same form to keep the regression tight on + the actual rendered SQL. + """ + query = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + ) + p = plan(query, orders_context()) + sql = compile_plan(p, dialect=Dialect.ANSI) + assert '"customer_id" = "step_000_r"."id"' in sql + assert '"customer_id" = "step_000_r"."customer_id"' not in sql + + +def test_missing_source__raises_e5001() -> None: + """A SOURCE payload with an empty physical source is a codegen error.""" + empty_payload = SourcePayload( + dataset=normalize_identifier("orders"), + primary_key=frozenset(), + source="", + ) + bad_plan = QueryPlan( + steps=( + PlanStep( + step_id=0, + operation=PlanOperation.SOURCE, + inputs=(), + payload=empty_payload, + state=_empty_state(), + ), + ), + root_step_id=0, + ) + with pytest.raises(OSICodegenError) as excinfo: + plan_to_select(bad_plan) + assert excinfo.value.code is ErrorCode.E5001_DIALECT_UNSUPPORTED + + +def _empty_state(): + from osi.planning.algebra.state import CalculationState + + return CalculationState(grain=frozenset(), columns=()) + + +def test_compile_plan__where_materialises_as_filter_cte() -> None: + query = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + where=_sql("orders.amount > 100"), + ) + p = plan(query, orders_context()) + sql = compile_plan(p, dialect=Dialect.DUCKDB) + # FILTER step is rendered as a WHERE clause *above* the aggregate. + assert "WHERE" in sql + # There must be a GROUP BY after the WHERE. + assert sql.index("WHERE") < sql.index("GROUP BY") diff --git a/impl/python/tests/unit/diagnostics/__init__.py b/impl/python/tests/unit/diagnostics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/impl/python/tests/unit/diagnostics/test_describe.py b/impl/python/tests/unit/diagnostics/test_describe.py new file mode 100644 index 0000000..c7fe390 --- /dev/null +++ b/impl/python/tests/unit/diagnostics/test_describe.py @@ -0,0 +1,60 @@ +"""Unit tests for :func:`osi.diagnostics.describe`.""" + +from __future__ import annotations + +from osi.diagnostics import describe, describe_json +from tests.unit.planning.fixtures import orders_context + + +def _model(): + return orders_context().model + + +def test_describe__mentions_every_dataset_by_name() -> None: + text = describe(_model()) + for name in ("orders", "customers", "returns"): + assert name in text, f"expected dataset {name!r} in description" + + +def test_describe__shows_sources() -> None: + text = describe(_model()) + assert "sales.orders" in text + assert "sales.customers" in text + assert "sales.returns" in text + + +def test_describe__groups_fields_under_their_dataset() -> None: + text = describe(_model()) + # ``amount`` lives on orders — it should appear *after* the orders + # header and *before* the next dataset header. + orders_idx = text.index("- orders") + customers_idx = text.index("- customers") + amount_idx = text.index("amount") + assert orders_idx < amount_idx < customers_idx + + +def test_describe__lists_all_relationships() -> None: + text = describe(_model()) + assert "orders_to_customers" in text + assert "returns_to_customers" in text + assert "→" in text + + +def test_describe_json__is_deterministic() -> None: + model = _model() + first = describe_json(model) + second = describe_json(model) + assert first == second + assert first["name"] == "demo" + assert [d["name"] for d in first["datasets"]] == [ + "orders", + "customers", + "returns", + ] + + +def test_describe_json__dataset_metrics_present() -> None: + view = describe_json(_model()) + orders = next(d for d in view["datasets"] if d["name"] == "orders") + metric_names = {m["name"] for m in orders["metrics"]} + assert "total_revenue" in metric_names diff --git a/impl/python/tests/unit/diagnostics/test_error_catalog.py b/impl/python/tests/unit/diagnostics/test_error_catalog.py new file mode 100644 index 0000000..935507a --- /dev/null +++ b/impl/python/tests/unit/diagnostics/test_error_catalog.py @@ -0,0 +1,63 @@ +"""Cleanliness gate for ``osi.diagnostics.error_catalog``. + +Every member of :class:`~osi.errors.ErrorCode` must have a non-empty +prose explanation in the catalog. This stops a new error code from +landing without an explainer entry — which would otherwise force users +to read source code to understand a failure. +""" + +from __future__ import annotations + +import pytest + +from osi.diagnostics.error_catalog import all_explanations, explain_error +from osi.errors import ErrorCode + + +@pytest.mark.parametrize("code", list(ErrorCode)) +def test_every_error_code_has_a_catalog_entry(code: ErrorCode) -> None: + """Every enum member must have a non-empty entry in the catalog. + + A missing entry means a user will hit an error code with no prose + explanation anywhere — failing fast at test time forces the entry + to be added in the same PR as the new code. + """ + text = explain_error(code) + assert text, f"{code.name} has an empty explanation" + assert len(text) > 60, ( + f"{code.name} has too short an explanation " + f"({len(text)} chars; expected at least 60). Catalog entries " + "should be a one-paragraph explanation, not a one-line stub." + ) + + +def test_catalog_set_matches_enum_set() -> None: + """The catalog must cover *exactly* the enum — no extras, no gaps.""" + catalog = set(all_explanations().keys()) + enum = set(ErrorCode) + missing = enum - catalog + extra = catalog - enum + assert not missing, ( + f"Error codes missing from catalog: " f"{sorted(c.name for c in missing)}" + ) + assert not extra, ( + f"Catalog contains entries that are not in ErrorCode: " + f"{sorted(c.name for c in extra)}" + ) + + +def test_explanations_cite_a_spec_section() -> None: + """Every catalog entry must cite a spec section. + + Convention: the entry contains either ``Spec:`` or ``RESERVED`` so + users can trace the rule back to the spec. ``RESERVED`` codes are + documented as such in lieu of citing a current spec section. + """ + bad: list[str] = [] + for code, text in all_explanations().items(): + if "Spec:" not in text and "RESERVED" not in text: + bad.append(code.name) + assert not bad, ( + "These catalog entries cite neither a Spec section nor mark the " + f"code as RESERVED: {bad}" + ) diff --git a/impl/python/tests/unit/diagnostics/test_explain.py b/impl/python/tests/unit/diagnostics/test_explain.py new file mode 100644 index 0000000..c7eab79 --- /dev/null +++ b/impl/python/tests/unit/diagnostics/test_explain.py @@ -0,0 +1,197 @@ +"""Unit tests for :func:`osi.diagnostics.explain`.""" + +from __future__ import annotations + +import sqlglot + +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.diagnostics import explain, explain_json +from osi.planning import OrderBy, Reference, SemanticQuery, SortDirection, plan +from tests.unit.planning.fixtures import orders_context + + +def _ref(ds: str, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds), + name=normalize_identifier(name), + ) + + +def _sql(expr: str) -> FrozenSQL: + return FrozenSQL.of(sqlglot.parse_one(expr)) + + +def _plan(query: SemanticQuery): + return plan(query, orders_context()) + + +def test_explain__lists_one_line_per_step() -> None: + p = _plan( + SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + ) + ) + text = explain(p) + for step in p.steps: + alias = f"step_{step.step_id:03d}" + assert alias in text + + +def test_explain__renders_aliases_that_match_codegen() -> None: + """Aliases must be ``step_###`` so traces correlate with SQL CTEs.""" + p = _plan( + SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + ) + ) + view = explain_json(p) + assert view["root"] == f"step_{p.root_step_id:03d}" + assert all(s["alias"].startswith("step_") for s in view["steps"]) + + +def test_explain__shows_grain_for_every_step() -> None: + p = _plan( + SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + ) + ) + view = explain_json(p) + for step in view["steps"]: + assert "grain" in step + assert isinstance(step["grain"], list) + + +def test_explain__enrich_summary_uses_paired_keys() -> None: + """Paired keys must render as ``parent=child`` — never ``k=k``.""" + p = _plan( + SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + ) + ) + view = explain_json(p) + enrich = next(s for s in view["steps"] if s["operation"] == "enrich") + assert "customer_id=id" in enrich["summary"] + + +def test_explain__captures_order_by_and_limit() -> None: + p = _plan( + SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + order_by=( + OrderBy( + target=_ref("orders", "total_revenue"), + direction=SortDirection.DESC, + ), + ), + limit=5, + ) + ) + text = explain(p) + assert "DESC" in text + assert "limit=5" in text + + +def test_explain__is_deterministic() -> None: + q = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + where=_sql("orders.amount > 100"), + ) + a = explain(_plan(q)) + b = explain(_plan(q)) + assert a == b + + +def test_payload_summary_covers_every_payload_variant() -> None: + """Every concrete ``PlanPayload`` variant must have a summary case. + + A missing case would silently render as an empty string in + ``explain(plan)`` — users would lose visibility into the step. + Walking the ``PlanPayload`` union catches the gap the moment a new + payload type lands without an ``explain`` update. + """ + from osi.diagnostics.explain import _payload_summary + from osi.planning.algebra.operations import FilterMode, JoinType + from osi.planning.algebra.state import Column, ColumnKind + from osi.planning.plan import ( + AddColumnsPayload, + AggregatePayload, + BroadcastPayload, + EnrichDerivedPayload, + EnrichPayload, + FilteringJoinPayload, + FilterPayload, + MergePayload, + PlanPayload, + ProjectPayload, + SourcePayload, + ) + + sample_column = Column( + name=normalize_identifier("col_x"), + expression=_sql("1"), + kind=ColumnKind.FACT, + dependencies=frozenset(), + aggregate=None, + ) + sample_id = normalize_identifier("x") + sample_grain = frozenset({sample_id}) + + samples: list[object] = [ + SourcePayload(dataset=sample_id, primary_key=sample_grain, source="orders"), + FilterPayload( + predicate=_sql("1 = 1"), + dependencies=frozenset(), + is_post_aggregate=False, + ), + EnrichPayload( + child_dataset=sample_id, + child_columns=(sample_column,), + keys=sample_grain, + join_type=JoinType.LEFT, + child_source="customers", + parent_keys=(sample_id,), + child_keys=(sample_id,), + ), + EnrichDerivedPayload( + child_columns=(sample_column,), + keys=sample_grain, + join_type=JoinType.LEFT, + parent_keys=(sample_id,), + child_keys=(sample_id,), + ), + AggregatePayload(new_grain=sample_grain, aggregations=(sample_column,)), + ProjectPayload(columns=(sample_id,)), + AddColumnsPayload(definitions=(sample_column,)), + MergePayload(on=sample_grain), + FilteringJoinPayload( + lhs_keys=sample_grain, rhs_keys=sample_grain, mode=FilterMode.SEMI + ), + BroadcastPayload(column=sample_column), + ] + + # The union and the sample list must agree — adding a new payload + # variant without a sample is a test gap, not a passing test. + expected_variants = set(PlanPayload.__args__) + sampled_variants = {type(s) for s in samples} + assert sampled_variants == expected_variants, ( + "The exhaustive-match sample set is out of sync with the " + f"PlanPayload union.\n in samples but not in union: " + f"{sorted(c.__name__ for c in sampled_variants - expected_variants)}\n" + f" in union but not in samples: " + f"{sorted(c.__name__ for c in expected_variants - sampled_variants)}" + ) + + for payload in samples: + summary = _payload_summary(payload) # type: ignore[arg-type] + assert summary, ( + f"_payload_summary returned an empty string for " + f"{type(payload).__name__}; every payload variant must " + "have a non-empty trace line." + ) diff --git a/impl/python/tests/unit/diagnostics/test_resolve.py b/impl/python/tests/unit/diagnostics/test_resolve.py new file mode 100644 index 0000000..6a15d1c --- /dev/null +++ b/impl/python/tests/unit/diagnostics/test_resolve.py @@ -0,0 +1,96 @@ +"""Unit tests for :func:`osi.diagnostics.resolve`.""" + +from __future__ import annotations + +import sqlglot + +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.diagnostics import resolve, resolve_json +from osi.planning import Reference, SemanticQuery +from tests.unit.planning.fixtures import orders_context + + +def _ref(ds: str, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds), + name=normalize_identifier(name), + ) + + +def _sql(expr: str) -> FrozenSQL: + return FrozenSQL.of(sqlglot.parse_one(expr)) + + +def test_resolve__single_table_touches_only_that_table() -> None: + view = resolve_json( + SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + ), + orders_context(), + ) + assert view["datasets"] == ["orders"] + assert view["relationships"] == [] + measure_names = {m["name"] for m in view["measures"]} + assert measure_names == {"total_revenue"} + + +def test_resolve__enrichment_surfaces_relationship() -> None: + view = resolve_json( + SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + ), + orders_context(), + ) + assert "orders" in view["datasets"] + assert "customers" in view["datasets"] + rel_names = {r["name"] for r in view["relationships"]} + assert "orders_to_customers" in rel_names + rel = next(r for r in view["relationships"] if r["name"] == "orders_to_customers") + assert rel["from_columns"] == ["customer_id"] + assert rel["to_columns"] == ["id"] + assert rel["join_type"] in ("INNER", "LEFT") + + +def test_resolve__two_fact_picks_one_relationship_per_fact() -> None: + view = resolve_json( + SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=( + _ref("orders", "total_revenue"), + _ref("returns", "total_refunds"), + ), + ), + orders_context(), + ) + rel_names = {r["name"] for r in view["relationships"]} + assert rel_names == {"orders_to_customers", "returns_to_customers"} + + +def test_resolve__captures_filter_expression() -> None: + view = resolve_json( + SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + where=_sql("orders.amount > 100"), + ), + orders_context(), + ) + assert len(view["filters"]) == 1 + assert "amount" in view["filters"][0] + + +def test_resolve_text__contains_expected_headers() -> None: + text = resolve( + SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + ), + orders_context(), + ) + assert "datasets:" in text + assert "dimensions:" in text + assert "measures:" in text + assert "relationships used:" in text diff --git a/impl/python/tests/unit/parsing/__init__.py b/impl/python/tests/unit/parsing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/impl/python/tests/unit/parsing/test_deferred.py b/impl/python/tests/unit/parsing/test_deferred.py new file mode 100644 index 0000000..8106573 --- /dev/null +++ b/impl/python/tests/unit/parsing/test_deferred.py @@ -0,0 +1,161 @@ +"""Unit tests for :mod:`osi.parsing.deferred`. + +Every Foundation-deferred feature must raise +:class:`ErrorCode.E_DEFERRED_KEY_REJECTED` either at the YAML key +level or inside a SQL expression. +""" + +from __future__ import annotations + +import pytest + +from osi.common.sql_expr import FrozenSQL, parse_sql_expr +from osi.errors import ErrorCode, OSIParseError +from osi.parsing.deferred import ( + DEFERRED_METRIC_KEYS, + check_expression_deferred, + check_yaml_deferred, +) + +# --------------------------------------------------------------------------- +# YAML-level deferred keys +# --------------------------------------------------------------------------- + + +def _doc(extra: dict[str, object]) -> dict[str, object]: + return { + "semantic_model": [ + { + "name": "m", + "datasets": [ + { + "name": "orders", + "source": "sales.orders", + "fields": [{"name": "id", "expression": "id"}], + } + ], + **extra, + } + ] + } + + +class TestYamlDeferred: + def test_metric_grain_key_rejected_E1105(self) -> None: + doc = _doc( + {"metrics": [{"name": "rev", "expression": "SUM(amount)", "grain": []}]} + ) + with pytest.raises(OSIParseError) as exc: + check_yaml_deferred(doc) + assert exc.value.code is ErrorCode.E_DEFERRED_KEY_REJECTED + + def test_metric_filter_key_rejected_E1105(self) -> None: + doc = _doc( + { + "metrics": [ + {"name": "rev", "expression": "SUM(amount)", "filter": "x > 0"} + ] + } + ) + with pytest.raises(OSIParseError) as exc: + check_yaml_deferred(doc) + assert exc.value.code is ErrorCode.E_DEFERRED_KEY_REJECTED + + def test_relationship_condition_key_rejected_E1105(self) -> None: + doc = _doc( + { + "relationships": [ + { + "name": "r", + "from": "orders", + "to": "orders", + "from_columns": ["id"], + "to_columns": ["id"], + "condition": "a > b", + } + ] + } + ) + with pytest.raises(OSIParseError) as exc: + check_yaml_deferred(doc) + assert exc.value.code is ErrorCode.E_DEFERRED_KEY_REJECTED + + def test_dataset_level_filters_rejected_E1105(self) -> None: + doc = { + "semantic_model": [ + { + "name": "m", + "datasets": [ + { + "name": "orders", + "source": "sales.orders", + "filters": [{"name": "f", "expression": "status = 'x'"}], + "fields": [{"name": "id", "expression": "id"}], + } + ], + } + ] + } + with pytest.raises(OSIParseError) as exc: + check_yaml_deferred(doc) + assert exc.value.code is ErrorCode.E_DEFERRED_KEY_REJECTED + + def test_field_grain_key_rejected_E1105(self) -> None: + doc = { + "semantic_model": [ + { + "name": "m", + "datasets": [ + { + "name": "orders", + "source": "sales.orders", + "fields": [ + { + "name": "id", + "expression": "id", + "grain": "order_id", + } + ], + } + ], + } + ] + } + with pytest.raises(OSIParseError) as exc: + check_yaml_deferred(doc) + assert exc.value.code is ErrorCode.E_DEFERRED_KEY_REJECTED + + def test_happy_path_no_deferred_keys(self) -> None: + doc = _doc({"metrics": [{"name": "rev", "expression": "SUM(amount)"}]}) + check_yaml_deferred(doc) # must not raise + + def test_known_deferred_keys_enumerated(self) -> None: + assert "grain" in DEFERRED_METRIC_KEYS + assert "semi_additive" in DEFERRED_METRIC_KEYS + assert "window" in DEFERRED_METRIC_KEYS + + +# --------------------------------------------------------------------------- +# Expression-level deferred constructs +# --------------------------------------------------------------------------- + + +def _frozen(expr: str) -> FrozenSQL: + return FrozenSQL.of(parse_sql_expr(expr)) + + +class TestExpressionDeferred: + def test_window_function_accepted(self) -> None: + # D-028 / D-030: valid window functions pass parser screening; + # only nested windows (D-028(c)) and deferred frame modes + # (D-032) raise their own named codes. + expr = _frozen("ROW_NUMBER() OVER (ORDER BY id)") + check_expression_deferred(expr, where="metric x") + + def test_scalar_expression_allowed(self) -> None: + expr = _frozen("SUM(amount)") + check_expression_deferred(expr, where="metric x") + + def test_case_when_allowed(self) -> None: + expr = _frozen("CASE WHEN status = 'done' THEN 1 ELSE 0 END") + check_expression_deferred(expr, where="metric x") diff --git a/impl/python/tests/unit/parsing/test_field_dependency_cycles.py b/impl/python/tests/unit/parsing/test_field_dependency_cycles.py new file mode 100644 index 0000000..94a0e1b --- /dev/null +++ b/impl/python/tests/unit/parsing/test_field_dependency_cycles.py @@ -0,0 +1,235 @@ +"""Tests for the parser-level field dependency cycle check. + +A dataset's fields form a dependency graph; the planner lowers +derived fields into a topologically ordered chain of ``ADD_COLUMNS`` +CTE stages so the emitted SQL never relies on lateral aliasing +(``Proposed_OSI_Semantics.md §4.3``). A cycle cannot be lowered to +a finite stage count and is rejected at parse time as +``E_FIELD_DEPENDENCY_CYCLE``. + +Unlike the deferral checks in :mod:`osi.parsing.foundation` that are +gated by :class:`FoundationFlags`, the cycle check is structural — no +flag opts back into cyclic models because there is no portable SQL +shape that could compile them. +""" + +from __future__ import annotations + +from textwrap import dedent + +import pytest + +from osi.config import FoundationFlags +from osi.errors import ErrorCode, OSIParseError +from osi.parsing.parser import parse_semantic_model + +# --------------------------------------------------------------------------- +# Direct two-field cycle (a → b → a) +# --------------------------------------------------------------------------- + + +def test_direct_two_field_cycle__rejected() -> None: + """``a`` references ``b`` and ``b`` references ``a`` ⇒ cycle. + + The simplest cyclic shape; the parser must reject it before the + planner ever sees the model so users get the structural error at + its source rather than as a downstream "internal cycle" surprise. + """ + yaml_text = dedent("""\ + name: cycle + datasets: + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: a, expression: "b + 1", role: fact} + - {name: b, expression: "a * 2", role: fact} + """) + with pytest.raises(OSIParseError) as excinfo: + parse_semantic_model(yaml_text) + assert excinfo.value.code is ErrorCode.E_FIELD_DEPENDENCY_CYCLE + context = excinfo.value.context + assert context["dataset"] == "orders" + cycle = context["cycle"] + assert isinstance(cycle, list) + assert cycle[0] == cycle[-1] + + +# --------------------------------------------------------------------------- +# Longer cycle (a → b → c → a) +# --------------------------------------------------------------------------- + + +def test_three_field_cycle__rejected() -> None: + """``a`` → ``b`` → ``c`` → ``a`` is rejected with the full chain in context. + + The cycle context payload should capture every node so users can + see exactly which fields participate, not just one offender. + """ + yaml_text = dedent("""\ + name: cycle3 + datasets: + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: a, expression: "b + 1", role: fact} + - {name: b, expression: "c + 1", role: fact} + - {name: c, expression: "a + 1", role: fact} + """) + with pytest.raises(OSIParseError) as excinfo: + parse_semantic_model(yaml_text) + assert excinfo.value.code is ErrorCode.E_FIELD_DEPENDENCY_CYCLE + cycle = excinfo.value.context["cycle"] + assert isinstance(cycle, list) + assert set(cycle) == {"a", "b", "c"} + assert cycle[0] == cycle[-1] + + +# --------------------------------------------------------------------------- +# Self-cycle (a depends on a, where a is *not* an identity projection) +# --------------------------------------------------------------------------- + + +def test_field_depends_on_self_via_other_field__rejected() -> None: + """A field whose body references itself in a non-identity shape ⇒ cycle. + + ``a = a + 1`` is the trivial degenerate case — the parser must + reject it because there's no semantics under which a field + references its own derived value before that value exists. + Distinguished from the legitimate identity projection + ``a = a`` where ``a`` is a physical column name shared with the + field name (see ``test_identity_projection__not_a_cycle``). + """ + yaml_text = dedent("""\ + name: self_cycle + datasets: + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: amount, expression: amount, role: fact} + - {name: a, expression: "amount * 2", role: fact} + - {name: a_plus_a, expression: "a + a_plus_a", role: fact} + """) + with pytest.raises(OSIParseError) as excinfo: + parse_semantic_model(yaml_text) + assert excinfo.value.code is ErrorCode.E_FIELD_DEPENDENCY_CYCLE + + +# --------------------------------------------------------------------------- +# Negative cases — must NOT trigger the cycle check +# --------------------------------------------------------------------------- + + +def test_acyclic_chain__accepted() -> None: + """A linear chain ``a → b → c`` parses cleanly under strict defaults.""" + yaml_text = dedent("""\ + name: chain + datasets: + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: amount, expression: amount, role: fact} + - {name: a, expression: "amount * 2", role: fact} + - {name: b, expression: "a + 1", role: fact} + - {name: c, expression: "b + 1", role: fact} + metrics: + - name: total + expression: SUM(orders.c) + """) + parse_semantic_model(yaml_text) + + +def test_identity_projection__not_a_cycle() -> None: + """``{name: id, expression: id}`` is not a self-cycle. + + The bare ``id`` reference resolves to the physical column at the + SOURCE step; treating it as a same-dataset dependency on the + field of the same name would force every identity projection to + be rejected. + """ + yaml_text = dedent("""\ + name: identity + datasets: + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: amount, expression: amount, role: fact} + metrics: + - name: total + expression: SUM(orders.amount) + """) + parse_semantic_model(yaml_text) + + +def test_qualified_cross_dataset_reference__not_a_cycle() -> None: + """A qualified ``other.field`` reference is not an inter-field dep. + + Cross-dataset references resolve through the relationship graph + (enrichment planner). They must never participate in the + same-dataset cycle check or models with normal foreign-key joins + would be rejected. + """ + yaml_text = dedent("""\ + name: qualified + datasets: + - name: customers + source: customers_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: region, expression: region, role: dimension} + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: customer_id, expression: customer_id, role: dimension} + - {name: amount, expression: amount, role: fact} + relationships: + - name: orders_to_customers + from: orders + to: customers + from_columns: [customer_id] + to_columns: [id] + metrics: + - name: total + expression: SUM(orders.amount) + """) + parse_semantic_model(yaml_text) + + +# --------------------------------------------------------------------------- +# Cycle is structural — no flag should turn it off +# --------------------------------------------------------------------------- + + +def test_cycle_check__not_disabled_by_legacy_permissive() -> None: + """``FoundationFlags.legacy_permissive()`` does not turn off the cycle check. + + The cycle check is structural, not a deferral; no portable SQL + shape compiles a cyclic field graph, so no flag should opt back + into accepting one. + """ + yaml_text = dedent("""\ + name: cycle_legacy + datasets: + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: a, expression: "b + 1", role: fact} + - {name: b, expression: "a * 2", role: fact} + """) + with pytest.raises(OSIParseError) as excinfo: + parse_semantic_model(yaml_text, flags=FoundationFlags.legacy_permissive()) + assert excinfo.value.code is ErrorCode.E_FIELD_DEPENDENCY_CYCLE diff --git a/impl/python/tests/unit/parsing/test_foundation_flags.py b/impl/python/tests/unit/parsing/test_foundation_flags.py new file mode 100644 index 0000000..ca53fb3 --- /dev/null +++ b/impl/python/tests/unit/parsing/test_foundation_flags.py @@ -0,0 +1,316 @@ +"""Tests for Foundation v0.1 strictness checks gated by FoundationFlags. + +Three deferral classes are exercised here, mirroring +:mod:`osi.parsing.foundation`: + +1. ``allow_aggregate_in_field`` (D-003) — every aggregate function in a + field expression must surface ``E_AGGREGATE_IN_FIELD`` by default and + parse cleanly when the flag is on. +2. ``allow_dataset_scoped_metrics`` (§4.5) — a per-dataset ``metrics:`` + block must surface ``E_DEFERRED_KEY_REJECTED`` by default and parse + cleanly when the flag is on. +3. ``allow_nested_aggregation`` (D-027) — a metric expression that nests + one aggregate inside another must surface + ``E_NESTED_AGGREGATION_DEFERRED`` by default and parse cleanly when + the flag is on. + +Each class also asserts the negative case: a model that does *not* use +the deferred construct parses cleanly under the strict defaults so we +know the check isn't accidentally over-rejecting. + +The ``FoundationFlags.legacy_permissive()`` constructor is exercised on +its own to lock in the contract that flipping every flag at once +restores pre-deferral behaviour for the same offending models. +""" + +from __future__ import annotations + +from textwrap import dedent + +import pytest + +from osi.config import FoundationFlags +from osi.errors import ErrorCode, OSIParseError +from osi.parsing import parse_semantic_model + +# --------------------------------------------------------------------------- +# Fixtures: minimal models that exercise one deferred construct each +# --------------------------------------------------------------------------- + +# A field whose body is an aggregate over its own dataset's columns. +# Pre-deferral this would have been treated as a same-grain aggregate; +# Foundation v0.1 §4.3 / D-003 rejects every aggregate-bodied field. +_AGGREGATE_IN_FIELD_YAML = dedent(""" + semantic_model: + - name: demo + datasets: + - name: orders + source: sales.orders + primary_key: [id] + fields: + - name: id + expression: id + - name: amount + expression: amount + role: fact + - name: total_amount + expression: SUM(amount) + role: fact + """).strip() + +# Same model as above but with the aggregate moved out of the field and +# into a top-level metric — the strict-Foundation shape. +_AGGREGATE_IN_TOP_LEVEL_METRIC_YAML = dedent(""" + semantic_model: + - name: demo + datasets: + - name: orders + source: sales.orders + primary_key: [id] + fields: + - name: id + expression: id + - name: amount + expression: amount + role: fact + metrics: + - name: total_amount + expression: SUM(orders.amount) + """).strip() + +# A per-dataset ``metrics:`` block. The model is otherwise minimal; the +# rejection must fire purely on the presence of the key. +_DATASET_SCOPED_METRIC_YAML = dedent(""" + semantic_model: + - name: demo + datasets: + - name: orders + source: sales.orders + primary_key: [id] + fields: + - name: id + expression: id + - name: amount + expression: amount + role: fact + metrics: + - name: total_amount + expression: SUM(amount) + """).strip() + +# A metric whose body is an aggregate of another aggregate. The two +# aggregates are distributive (SUM of SUM), which the §10 proposal +# would collapse to a single SUM, but the Foundation rejects all +# nested forms uniformly. +_NESTED_AGGREGATION_YAML = dedent(""" + semantic_model: + - name: demo + datasets: + - name: orders + source: sales.orders + primary_key: [id] + fields: + - name: id + expression: id + - name: amount + expression: amount + role: fact + metrics: + - name: bad_double_sum + expression: SUM(SUM(orders.amount)) + """).strip() + +# A windowed aggregate inside a field expression. Window functions are +# permitted in fields (§4.3.1); the strictness check must therefore +# leave this alone even though it contains an :class:`exp.AggFunc` node. +_WINDOWED_AGGREGATE_FIELD_YAML = dedent(""" + semantic_model: + - name: demo + datasets: + - name: orders + source: sales.orders + primary_key: [id] + fields: + - name: id + expression: id + - name: customer_id + expression: customer_id + - name: amount + expression: amount + role: fact + - name: rank_in_customer + expression: >- + ROW_NUMBER() OVER (PARTITION BY customer_id + ORDER BY amount DESC) + """).strip() + + +# --------------------------------------------------------------------------- +# D-003 — aggregate-bodied fields +# --------------------------------------------------------------------------- + + +class TestAggregateInFieldRejection: + """An aggregate function in a field's ``expression`` is deferred.""" + + def test_default_flags_reject_aggregate_field(self) -> None: + with pytest.raises(OSIParseError) as exc: + parse_semantic_model(_AGGREGATE_IN_FIELD_YAML) + assert exc.value.code is ErrorCode.E_AGGREGATE_IN_FIELD + # Diagnostic context surfaces the offending field so authors + # can grep to it without re-parsing. + assert exc.value.context["dataset"] == "orders" + assert exc.value.context["field"] == "total_amount" + assert exc.value.context["aggregate"] == "SUM" + assert exc.value.context["flag"] == "allow_aggregate_in_field" + + def test_explicit_strict_rejects_aggregate_field(self) -> None: + with pytest.raises(OSIParseError) as exc: + parse_semantic_model( + _AGGREGATE_IN_FIELD_YAML, flags=FoundationFlags.strict() + ) + assert exc.value.code is ErrorCode.E_AGGREGATE_IN_FIELD + + def test_legacy_flag_accepts_aggregate_field(self) -> None: + result = parse_semantic_model( + _AGGREGATE_IN_FIELD_YAML, + flags=FoundationFlags(allow_aggregate_in_field=True), + ) + # The aggregate field is preserved as a fact-role field. + names = {str(f.name) for f in result.model.datasets[0].fields} + assert "total_amount" in names + + def test_top_level_metric_replacement_parses_strict(self) -> None: + # The migration path documented in the error message — move + # the aggregate to a top-level metric — must parse under the + # strict defaults. + result = parse_semantic_model(_AGGREGATE_IN_TOP_LEVEL_METRIC_YAML) + assert {str(m.name) for m in result.model.metrics} == {"total_amount"} + + def test_windowed_aggregate_field_parses_strict(self) -> None: + # Window expressions are not aggregates in the spec sense + # (§4.3.1). The strict check must leave them alone. + result = parse_semantic_model(_WINDOWED_AGGREGATE_FIELD_YAML) + names = {str(f.name) for f in result.model.datasets[0].fields} + assert "rank_in_customer" in names + + +# --------------------------------------------------------------------------- +# §4.5 — per-dataset metrics block +# --------------------------------------------------------------------------- + + +class TestDatasetScopedMetricRejection: + """A ``metrics:`` block under a dataset is deferred.""" + + def test_default_flags_reject_dataset_scoped_metrics(self) -> None: + with pytest.raises(OSIParseError) as exc: + parse_semantic_model(_DATASET_SCOPED_METRIC_YAML) + assert exc.value.code is ErrorCode.E_DEFERRED_KEY_REJECTED + assert exc.value.context["field"] == "metrics" + assert exc.value.context["first_metric"] == "total_amount" + assert exc.value.context["flag"] == "allow_dataset_scoped_metrics" + + def test_legacy_flag_accepts_dataset_scoped_metrics(self) -> None: + result = parse_semantic_model( + _DATASET_SCOPED_METRIC_YAML, + flags=FoundationFlags( + allow_dataset_scoped_metrics=True, + # The legacy block uses ``SUM(amount)`` (an aggregate + # in a metric body, which is fine) but the per-dataset + # block by itself doesn't pull in the field-aggregate + # path; only the dataset-metric flag is required. + ), + ) + assert len(result.model.datasets[0].metrics) == 1 + + +# --------------------------------------------------------------------------- +# D-027 — nested aggregation in metrics +# --------------------------------------------------------------------------- + + +class TestNestedAggregationRejection: + """An aggregate of an aggregate in a metric is deferred.""" + + def test_default_flags_reject_nested_aggregation(self) -> None: + with pytest.raises(OSIParseError) as exc: + parse_semantic_model(_NESTED_AGGREGATION_YAML) + assert exc.value.code is ErrorCode.E_NESTED_AGGREGATION_DEFERRED + assert exc.value.context["metric"] == "bad_double_sum" + assert exc.value.context["scope"] == "model" + assert exc.value.context["outer"] == "SUM" + assert exc.value.context["inner"] == "SUM" + assert exc.value.context["flag"] == "allow_nested_aggregation" + + def test_legacy_flag_accepts_nested_aggregation(self) -> None: + result = parse_semantic_model( + _NESTED_AGGREGATION_YAML, + flags=FoundationFlags(allow_nested_aggregation=True), + ) + assert {str(m.name) for m in result.model.metrics} == {"bad_double_sum"} + + +# --------------------------------------------------------------------------- +# Cross-flag interactions and convenience constructors +# --------------------------------------------------------------------------- + + +class TestFoundationFlagsConstructors: + """``strict()`` and ``legacy_permissive()`` are equivalent shortcuts.""" + + def test_default_construction_matches_strict(self) -> None: + assert FoundationFlags() == FoundationFlags.strict() + assert FoundationFlags().allow_aggregate_in_field is False + assert FoundationFlags().allow_dataset_scoped_metrics is False + assert FoundationFlags().allow_nested_aggregation is False + + def test_legacy_permissive_enables_all_three(self) -> None: + flags = FoundationFlags.legacy_permissive() + assert flags.allow_aggregate_in_field is True + assert flags.allow_dataset_scoped_metrics is True + assert flags.allow_nested_aggregation is True + + def test_legacy_permissive_round_trips_each_offender(self) -> None: + # One umbrella check: every model that the strict defaults + # reject above must parse under ``legacy_permissive()``. This + # locks in the symmetry between the deferral list in + # ``Proposed_OSI_Semantics.md`` and the flag set here. + flags = FoundationFlags.legacy_permissive() + for source in ( + _AGGREGATE_IN_FIELD_YAML, + _DATASET_SCOPED_METRIC_YAML, + _NESTED_AGGREGATION_YAML, + ): + parse_semantic_model(source, flags=flags) + + def test_dataset_scoped_metric_check_runs_before_aggregate_check(self) -> None: + # A model that uses both a dataset-scoped metric and an + # aggregate-bodied field must surface the dataset-scoped + # rejection first; that ordering is part of the documented + # contract in :mod:`osi.parsing.foundation` and gives authors + # the more familiar deferred-key error message before the + # newer aggregate-in-field one. + both_yaml = dedent(""" + semantic_model: + - name: demo + datasets: + - name: orders + source: sales.orders + primary_key: [id] + fields: + - name: id + expression: id + - name: amount + expression: amount + role: fact + - name: total_amount + expression: SUM(amount) + role: fact + metrics: + - name: total_amount_metric + expression: SUM(amount) + """).strip() + with pytest.raises(OSIParseError) as exc: + parse_semantic_model(both_yaml) + assert exc.value.code is ErrorCode.E_DEFERRED_KEY_REJECTED diff --git a/impl/python/tests/unit/parsing/test_function_whitelist.py b/impl/python/tests/unit/parsing/test_function_whitelist.py new file mode 100644 index 0000000..e83dc10 --- /dev/null +++ b/impl/python/tests/unit/parsing/test_function_whitelist.py @@ -0,0 +1,185 @@ +"""Unit tests for :mod:`osi.parsing.function_whitelist` (D-021 / I7). + +The OSI_SQL_2026 dialect accepts the function set listed in +``../../../../../proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md``. +Anything outside that set must raise +:class:`ErrorCode.E_UNKNOWN_FUNCTION` at parse time so model authors +see the error immediately rather than at SQL execution time. +""" + +from __future__ import annotations + +import pytest + +from osi.common.sql_expr import FrozenSQL, parse_sql_expr +from osi.errors import ErrorCode, OSIParseError +from osi.parsing.function_whitelist import ( + OSI_SQL_2026_FUNCTIONS, + check_expression_functions, +) + + +def _expr(sql: str) -> FrozenSQL: + return FrozenSQL.of(parse_sql_expr(sql)) + + +# --------------------------------------------------------------------------- +# In-subset functions are accepted +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "sql", + [ + # Aggregate (§170) + "SUM(amount)", + "COUNT(DISTINCT customer_id)", + "AVG(amount)", + "MIN(amount)", + "MAX(amount)", + "STDDEV_POP(amount)", + "VAR_SAMP(amount)", + "MEDIAN(amount)", + "APPROX_COUNT_DISTINCT(customer_id)", + # Date/Time (§274) + "CURRENT_DATE", + "YEAR(order_date)", + "DATE_TRUNC('month', order_date)", + "DATEADD('day', 7, order_date)", + "DATEDIFF('day', start_date, end_date)", + "EXTRACT(YEAR FROM order_date)", + # String (§385) + "CONCAT(first, last)", + "UPPER(name)", + "LOWER(name)", + "LENGTH(name)", + "SUBSTRING(name, 1, 3)", + "TRIM(name)", + "REGEXP_LIKE(name, '^A')", + # Math (§439) + "ABS(amount)", + "ROUND(amount, 2)", + "CEILING(amount)", + "CEIL(amount)", # alias + "POWER(x, 2)", + "SQRT(x)", + "GREATEST(a, b, c)", + # Conditional (§488) + "COALESCE(a, b)", + "IFNULL(a, 0)", + "NULLIF(a, 0)", + "IF(a > 0, 1, 0)", + # CAST is a structural construct in sqlglot; allowed regardless of name. + "CAST(amount AS DECIMAL)", + "CAST(name AS VARCHAR)", + # Window (§533) — names alone; the FrozenSQL parser accepts the + # ``OVER (...)`` shape as long as the function call name is in + # the whitelist. + "ROW_NUMBER() OVER (ORDER BY id)", + "RANK() OVER (PARTITION BY region ORDER BY amount)", + "LAG(amount, 1, 0) OVER (ORDER BY order_date)", + # CASE is a language construct, not a function. + "CASE WHEN x > 0 THEN 'pos' ELSE 'neg' END", + ], +) +def test_in_subset_functions_accepted(sql: str) -> None: + # No exception should be raised. ``where`` is mandatory because the + # error message embeds it. + check_expression_functions(_expr(sql), where="test") + + +# --------------------------------------------------------------------------- +# Out-of-subset functions are rejected +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "sql, function_name", + [ + ("FOOBAR(x)", "FOOBAR"), + ("BIT_AND(flags)", "BIT_AND"), # not in the OSI subset + ("MY_CUSTOM_FUNCTION(amount)", "MY_CUSTOM_FUNCTION"), + ("OBJECT_CONSTRUCT('a', 1)", "OBJECT_CONSTRUCT"), # Snowflake-only + ("ARRAY_AGG(x)", "ARRAY_AGG"), # outside Foundation Tier 1 + ], +) +def test_out_of_subset_function_rejected(sql: str, function_name: str) -> None: + with pytest.raises(OSIParseError) as exc: + check_expression_functions(_expr(sql), where="metric m") + assert exc.value.code is ErrorCode.E_UNKNOWN_FUNCTION + # Error context must name the offending function so users can fix it. + assert exc.value.context["function"] == function_name + assert exc.value.context["where"] == "metric m" + + +def test_error_message_cites_d021_and_spec() -> None: + with pytest.raises(OSIParseError) as exc: + check_expression_functions(_expr("FOOBAR(x)"), where="metric m") + assert exc.value.code is ErrorCode.E_UNKNOWN_FUNCTION + msg = str(exc.value) + assert "FOOBAR" in msg + assert "OSI_SQL_2026" in msg + assert "D-021" in msg + assert "SQL_EXPRESSION_SUBSET" in msg + + +def test_unknown_function_nested_inside_aggregate_rejected() -> None: + # The walk must descend into aggregate arguments — a forbidden + # function call hidden under SUM() should still be caught. + with pytest.raises(OSIParseError) as exc: + check_expression_functions(_expr("SUM(BOGUS_TRANSFORM(amount))"), where="m") + assert exc.value.code is ErrorCode.E_UNKNOWN_FUNCTION + assert exc.value.context["function"] == "BOGUS_TRANSFORM" + + +def test_unknown_function_inside_case_branch_rejected() -> None: + # CASE itself is allowed; the unknown function under its WHEN/THEN + # must still be rejected. + sql = "CASE WHEN amount > 0 THEN FOOBAR(amount) ELSE 0 END" + with pytest.raises(OSIParseError) as exc: + check_expression_functions(_expr(sql), where="m") + assert exc.value.code is ErrorCode.E_UNKNOWN_FUNCTION + assert exc.value.context["function"] == "FOOBAR" + + +# --------------------------------------------------------------------------- +# Whitelist surface invariants +# --------------------------------------------------------------------------- + + +class TestWhitelistInvariants: + def test_whitelist_is_all_uppercase(self) -> None: + # Function names are case-insensitive in SQL; we canonicalise to + # upper-case at compare time. The whitelist itself must follow + # the same convention so a typo (mixed case) cannot creep in. + for name in OSI_SQL_2026_FUNCTIONS: + assert name == name.upper(), name + + def test_core_aggregates_are_in_subset(self) -> None: + # The five-function distributive set named in + # SQL_EXPRESSION_SUBSET.md §259 must be present — these are the + # functions that every OSI engine must accept. + for name in ("SUM", "COUNT", "MIN", "MAX", "AVG"): + assert name in OSI_SQL_2026_FUNCTIONS + + def test_window_offset_set_is_complete(self) -> None: + # SQL_EXPRESSION_SUBSET.md §564 ranks LAG/LEAD/FIRST_VALUE/ + # LAST_VALUE/NTH_VALUE as REQUIRED. Lock the set so a partial + # delete cannot quietly slip through. + offset = {"LAG", "LEAD", "FIRST_VALUE", "LAST_VALUE", "NTH_VALUE"} + assert offset <= OSI_SQL_2026_FUNCTIONS + + def test_aliases_both_spellings_present(self) -> None: + # Each alias pair listed in the spec must round-trip — users + # should be able to write either spelling. + for a, b in [("CEIL", "CEILING"), ("TRUNC", "TRUNCATE")]: + assert a in OSI_SQL_2026_FUNCTIONS + assert b in OSI_SQL_2026_FUNCTIONS + + def test_deferred_functions_are_not_in_whitelist(self) -> None: + # Deferred names land via E_DEFERRED_KEY_REJECTED, not via + # E_UNKNOWN_FUNCTION. Allowing a deferred name into the + # whitelist would silently re-enable a feature the Foundation + # has not standardised. + for name in ("EXISTS_IN", "NOT_EXISTS_IN", "ATTR", "UNSAFE", "AGG"): + assert name not in OSI_SQL_2026_FUNCTIONS diff --git a/impl/python/tests/unit/parsing/test_graph.py b/impl/python/tests/unit/parsing/test_graph.py new file mode 100644 index 0000000..a385768 --- /dev/null +++ b/impl/python/tests/unit/parsing/test_graph.py @@ -0,0 +1,183 @@ +"""Unit tests for :mod:`osi.parsing.graph`.""" + +from __future__ import annotations + +from osi.common.identifiers import normalize_identifier +from osi.parsing.graph import Cardinality, build_graph +from osi.parsing.models import Dataset, Field, Relationship, SemanticModel + + +def _ds(name: str, pk: list[str], fields: list[str]) -> Dataset: + return Dataset( + name=name, + source=f"s.{name}", + primary_key=pk, + fields=[Field(name=f, expression=f) for f in fields], + ) + + +def _rel( + name: str, + src: str, + dst: str, + src_cols: list[str], + dst_cols: list[str], +) -> Relationship: + return Relationship.model_validate( + { + "name": name, + "from": src, + "to": dst, + "from_columns": src_cols, + "to_columns": dst_cols, + } + ) + + +class TestCardinalityInference: + def test_n_to_one_when_rhs_is_pk(self) -> None: + model = SemanticModel( + name="m", + datasets=[ + _ds("orders", ["id"], ["id", "customer_id"]), + _ds("customers", ["id"], ["id"]), + ], + relationships=[ + _rel("r", "orders", "customers", ["customer_id"], ["id"]), + ], + ) + graph = build_graph(model) + assert graph.edges[0].cardinality is Cardinality.N_TO_ONE + + def test_one_to_one_when_both_sides_unique(self) -> None: + model = SemanticModel( + name="m", + datasets=[ + _ds("a", ["id"], ["id"]), + _ds("b", ["id"], ["id"]), + ], + relationships=[ + _rel("r", "a", "b", ["id"], ["id"]), + ], + ) + graph = build_graph(model) + assert graph.edges[0].cardinality is Cardinality.ONE_TO_ONE + + def test_n_to_n_when_neither_side_unique(self) -> None: + model = SemanticModel( + name="m", + datasets=[ + Dataset( + name="a", + source="s.a", + fields=[Field(name="k", expression="k")], + ), + Dataset( + name="b", + source="s.b", + fields=[Field(name="k", expression="k")], + ), + ], + relationships=[_rel("r", "a", "b", ["k"], ["k"])], + ) + graph = build_graph(model) + assert graph.edges[0].cardinality is Cardinality.N_TO_N + + +class TestPathFinding: + def test_direct_path(self) -> None: + model = SemanticModel( + name="m", + datasets=[ + _ds("orders", ["id"], ["id", "customer_id"]), + _ds("customers", ["id"], ["id"]), + ], + relationships=[ + _rel("r", "orders", "customers", ["customer_id"], ["id"]), + ], + ) + graph = build_graph(model) + paths = graph.find_paths( + normalize_identifier("orders"), normalize_identifier("customers") + ) + assert len(paths) == 1 + assert len(paths[0]) == 1 + + def test_multi_hop_path_discoverable(self) -> None: + model = SemanticModel( + name="m", + datasets=[ + _ds("line_items", ["order_id"], ["order_id"]), + _ds("orders", ["id"], ["id", "customer_id"]), + _ds("customers", ["id"], ["id"]), + ], + relationships=[ + _rel("li_o", "line_items", "orders", ["order_id"], ["id"]), + _rel("o_c", "orders", "customers", ["customer_id"], ["id"]), + ], + ) + graph = build_graph(model) + paths = graph.find_paths( + normalize_identifier("line_items"), normalize_identifier("customers") + ) + assert len(paths) == 1 + assert len(paths[0]) == 2 + + def test_no_path_returns_empty(self) -> None: + model = SemanticModel( + name="m", + datasets=[ + _ds("a", ["id"], ["id"]), + _ds("b", ["id"], ["id"]), + ], + ) + graph = build_graph(model) + paths = graph.find_paths(normalize_identifier("a"), normalize_identifier("b")) + assert paths == () + + def test_same_endpoint_returns_empty_path(self) -> None: + model = SemanticModel( + name="m", + datasets=[_ds("a", ["id"], ["id"])], + ) + graph = build_graph(model) + paths = graph.find_paths(normalize_identifier("a"), normalize_identifier("a")) + assert paths == ((),) + + def test_two_paths_between_same_endpoints(self) -> None: + model = SemanticModel( + name="m", + datasets=[ + _ds("a", ["id"], ["id", "b_id", "c_id"]), + _ds("b", ["id"], ["id", "d_id"]), + _ds("c", ["id"], ["id", "d_id"]), + _ds("d", ["id"], ["id"]), + ], + relationships=[ + _rel("a_b", "a", "b", ["b_id"], ["id"]), + _rel("a_c", "a", "c", ["c_id"], ["id"]), + _rel("b_d", "b", "d", ["d_id"], ["id"]), + _rel("c_d", "c", "d", ["d_id"], ["id"]), + ], + ) + graph = build_graph(model) + paths = graph.find_paths(normalize_identifier("a"), normalize_identifier("d")) + # Two simple paths: a→b→d and a→c→d + assert len(paths) == 2 + + +class TestAdjacency: + def test_neighbors_both_sides(self) -> None: + model = SemanticModel( + name="m", + datasets=[ + _ds("orders", ["id"], ["id", "customer_id"]), + _ds("customers", ["id"], ["id"]), + ], + relationships=[ + _rel("r", "orders", "customers", ["customer_id"], ["id"]), + ], + ) + graph = build_graph(model) + assert len(graph.neighbors(normalize_identifier("orders"))) == 1 + assert len(graph.neighbors(normalize_identifier("customers"))) == 1 diff --git a/impl/python/tests/unit/parsing/test_models.py b/impl/python/tests/unit/parsing/test_models.py new file mode 100644 index 0000000..ae95788 --- /dev/null +++ b/impl/python/tests/unit/parsing/test_models.py @@ -0,0 +1,285 @@ +"""Unit tests for :mod:`osi.parsing.models`. + +These cover validators that raise :class:`OSIParseError` directly. Pure +pydantic errors (``extra_forbidden``, ``missing``, ``enum``) are covered +in :mod:`test_parser` where they flow through the error translator. +""" + +from __future__ import annotations + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.errors import ErrorCode, OSIError, OSIParseError +from osi.parsing.models import ( + Dataset, + Dialect, + Field, + FieldRole, + Metric, + NamedFilter, + Parameter, + Relationship, + SemanticModel, +) + + +def _minimal_dataset(name: str = "orders") -> Dataset: + return Dataset( + name=name, + source="schema.table", + primary_key=["id"], + fields=[ + Field(name="id", expression="id", role=FieldRole.DIMENSION), + Field(name="amount", expression="amount", role=FieldRole.FACT), + ], + ) + + +class TestFieldValidation: + def test_happy_path(self) -> None: + f = Field(name="amount", expression="amount", role="fact") + assert f.name == normalize_identifier("amount") + assert f.role is FieldRole.FACT + assert f.expression.canonical + + def test_invalid_identifier_rejected_E1005(self) -> None: + with pytest.raises(OSIError) as exc: + Field(name="1bad", expression="id", role="dimension") + assert exc.value.code is ErrorCode.E1005_IDENTIFIER_INVALID + + def test_empty_expression_rejected_E1004(self) -> None: + with pytest.raises(OSIError) as exc: + Field(name="id", expression=" ", role="dimension") + assert exc.value.code is ErrorCode.E1004_TYPE_MISMATCH + + def test_default_role_is_dimension(self) -> None: + f = Field(name="id", expression="id") + assert f.role is FieldRole.DIMENSION + + +class TestMetricValidation: + def test_happy_path(self) -> None: + m = Metric(name="revenue", expression="SUM(amount)") + assert m.name == normalize_identifier("revenue") + assert "SUM" in m.expression.canonical.upper() + + def test_invalid_identifier_E1005(self) -> None: + with pytest.raises(OSIError) as exc: + Metric(name="1rev", expression="SUM(amount)") + assert exc.value.code is ErrorCode.E1005_IDENTIFIER_INVALID + + +class TestDatasetValidation: + def test_happy_path(self) -> None: + ds = _minimal_dataset() + assert len(ds.fields) == 2 + assert ds.primary_key == (normalize_identifier("id"),) + + def test_duplicate_field_name_E2003(self) -> None: + with pytest.raises(OSIError) as exc: + Dataset( + name="orders", + source="sales.orders", + primary_key=["id"], + fields=[ + Field(name="id", expression="id"), + Field(name="id", expression="id"), + ], + ) + assert exc.value.code is ErrorCode.E2003_DUPLICATE_NAME + + def test_field_metric_name_collision_E2003(self) -> None: + with pytest.raises(OSIError) as exc: + Dataset( + name="orders", + source="sales.orders", + fields=[Field(name="amount", expression="amount", role="fact")], + metrics=[Metric(name="amount", expression="SUM(amount)")], + ) + assert exc.value.code is ErrorCode.E2003_DUPLICATE_NAME + + def test_unique_keys_coerced_to_tuples(self) -> None: + ds = Dataset( + name="orders", + source="sales.orders", + primary_key=["id"], + unique_keys=[["id"], ["order_number"]], + fields=[ + Field(name="id", expression="id"), + Field(name="order_number", expression="order_number"), + ], + ) + assert ds.unique_keys == ( + (normalize_identifier("id"),), + (normalize_identifier("order_number"),), + ) + + def test_unique_keys_wrong_shape_E1004(self) -> None: + with pytest.raises(OSIError) as exc: + Dataset( + name="orders", + source="sales.orders", + unique_keys="id", # type: ignore[arg-type] + fields=[Field(name="id", expression="id")], + ) + assert exc.value.code is ErrorCode.E1004_TYPE_MISMATCH + + +class TestRelationshipValidation: + def test_happy_path_alias_from_to(self) -> None: + rel = Relationship.model_validate( + { + "name": "orders_to_customers", + "from": "orders", + "to": "customers", + "from_columns": ["customer_id"], + "to_columns": ["id"], + } + ) + assert rel.from_dataset == normalize_identifier("orders") + assert rel.to_dataset == normalize_identifier("customers") + + def test_referential_integrity_is_a_deferred_key(self) -> None: + """``referential_integrity`` is a Foundation-deferred key. + + The pydantic schema for ``Relationship`` has no such field + (``extra="forbid"`` makes the unknown key a hard error). YAML + callers are routed through :func:`parse_semantic_model`, which + translates pydantic's ``extra_forbidden`` into the user-facing + ``E1001_YAML_SYNTAX``; here we assert the underlying pydantic + behaviour directly so a programmatic construction can't + reintroduce the field by skipping the parser. + """ + from pydantic import ValidationError + + with pytest.raises(ValidationError) as exc: + Relationship.model_validate( + { + "name": "r", + "from": "a", + "to": "b", + "from_columns": ["x"], + "to_columns": ["y"], + "referential_integrity": {"from_all_rows_match": True}, + } + ) + error_types = {err["type"] for err in exc.value.errors()} + assert "extra_forbidden" in error_types + error_locs = {err["loc"][0] for err in exc.value.errors()} + assert "referential_integrity" in error_locs + + def test_column_arity_mismatch_E2006(self) -> None: + with pytest.raises(OSIError) as exc: + Relationship.model_validate( + { + "name": "r", + "from": "a", + "to": "b", + "from_columns": ["x", "y"], + "to_columns": ["z"], + } + ) + assert exc.value.code is ErrorCode.E2006_INVALID_RELATIONSHIP + + def test_empty_columns_E2006(self) -> None: + with pytest.raises(OSIError) as exc: + Relationship.model_validate( + { + "name": "r", + "from": "a", + "to": "b", + "from_columns": [], + "to_columns": [], + } + ) + assert exc.value.code is ErrorCode.E2006_INVALID_RELATIONSHIP + + +class TestParameterValidation: + def test_default_any(self) -> None: + p = Parameter(name="min_amount", data_type="NUMBER", default=0) + assert p.default == 0 + assert p.data_type == "NUMBER" + + +class TestNamedFilter: + def test_happy_path(self) -> None: + nf = NamedFilter(name="done", expression="status = 'done'") + assert nf.name == normalize_identifier("done") + + +class TestSemanticModel: + def test_empty_datasets_rejected_E1002(self) -> None: + with pytest.raises(OSIError) as exc: + SemanticModel(name="x", datasets=[]) + assert exc.value.code is ErrorCode.E1002_MISSING_REQUIRED_FIELD + + def test_dataset_name_uniqueness_E2003(self) -> None: + with pytest.raises(OSIError) as exc: + SemanticModel( + name="x", + datasets=[_minimal_dataset(), _minimal_dataset()], + ) + assert exc.value.code is ErrorCode.E2003_DUPLICATE_NAME + + def test_metric_name_uniqueness_E2003(self) -> None: + with pytest.raises(OSIError) as exc: + SemanticModel( + name="x", + datasets=[_minimal_dataset()], + metrics=[ + Metric(name="m", expression="SUM(amount)"), + Metric(name="m", expression="SUM(amount)"), + ], + ) + assert exc.value.code is ErrorCode.E2003_DUPLICATE_NAME + + def test_defaults(self) -> None: + # B4 (Phase 3 review): the spec mandates ``OSI_SQL_2026`` as the + # default dialect (Foundation §1 + SQL_EXPRESSION_SUBSET.md). + # Omitting ``dialect:`` in YAML therefore parses to + # ``Dialect.OSI_SQL_2026``, not the codegen-target ``ANSI``. + model = SemanticModel(name="x", datasets=[_minimal_dataset()]) + assert model.dialect is Dialect.OSI_SQL_2026 + assert model.metrics == () + assert model.filters == () + assert model.parameters == () + assert model.osi_version is None + + def test_osi_version_optional_per_spec(self) -> None: + """A model omitting ``osi_version`` defaults to the latest supported version. + + Per Proposed_OSI_Semantics.md §opening. Storing ``None`` keeps + that contract explicit. + """ + model = SemanticModel(name="x", datasets=[_minimal_dataset()]) + assert model.osi_version is None + + def test_osi_version_accepts_supported_value(self) -> None: + model = SemanticModel( + name="x", osi_version="0.1", datasets=[_minimal_dataset()] + ) + assert model.osi_version == "0.1" + + def test_osi_version_rejects_unsupported_value_E1003(self) -> None: + """Unknown ``osi_version`` values raise ``E1003_INVALID_ENUM_VALUE``. + + Future ``0.x`` revisions stay additively compatible, but until + an engine is updated to recognise a new version, declaring it + is rejected with a context that carries the supported set so + adopters know what they can write. + """ + with pytest.raises(OSIError) as exc: + SemanticModel(name="x", osi_version="0.2", datasets=[_minimal_dataset()]) + assert exc.value.code is ErrorCode.E1003_INVALID_ENUM_VALUE + assert exc.value.context["value"] == "0.2" + assert "0.1" in exc.value.context["supported"] + + def test_osi_version_non_string_rejected_E1004(self) -> None: + with pytest.raises(OSIError) as exc: + SemanticModel(name="x", osi_version=0.1, datasets=[_minimal_dataset()]) + assert exc.value.code is ErrorCode.E1004_TYPE_MISMATCH + + +_ = OSIParseError # re-export used by readers of this module diff --git a/impl/python/tests/unit/parsing/test_namespace.py b/impl/python/tests/unit/parsing/test_namespace.py new file mode 100644 index 0000000..aca3302 --- /dev/null +++ b/impl/python/tests/unit/parsing/test_namespace.py @@ -0,0 +1,116 @@ +"""Unit tests for :mod:`osi.parsing.namespace`.""" + +from __future__ import annotations + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.errors import ErrorCode, OSIParseError +from osi.parsing.models import ( + Dataset, + Field, + Metric, + NamedFilter, + Parameter, + SemanticModel, +) +from osi.parsing.namespace import build_namespace + + +def _orders() -> Dataset: + return Dataset( + name="orders", + source="sales.orders", + primary_key=["id"], + fields=[ + Field(name="id", expression="id"), + Field(name="amount", expression="amount", role="fact"), + ], + metrics=[Metric(name="revenue", expression="SUM(amount)")], + ) + + +def _customers() -> Dataset: + return Dataset( + name="customers", + source="sales.customers", + primary_key=["id"], + fields=[ + Field(name="id", expression="id"), + Field(name="email", expression="email"), + ], + ) + + +class TestBuildNamespace: + def test_datasets_indexed(self) -> None: + model = SemanticModel(name="m", datasets=[_orders(), _customers()]) + ns = build_namespace(model) + assert set(ns.datasets) == { + normalize_identifier("orders"), + normalize_identifier("customers"), + } + + def test_global_metrics_filters_parameters_indexed(self) -> None: + model = SemanticModel( + name="m", + datasets=[_orders()], + metrics=[Metric(name="g_rev", expression="SUM(orders.amount)")], + filters=[NamedFilter(name="done", expression="status = 'done'")], + parameters=[Parameter(name="min_amount", data_type="NUMBER", default=0)], + ) + ns = build_namespace(model) + assert normalize_identifier("g_rev") in ns.metrics + assert normalize_identifier("done") in ns.filters + assert normalize_identifier("min_amount") in ns.parameters + + +class TestResolveQualified: + def test_happy_path(self) -> None: + model = SemanticModel(name="m", datasets=[_orders(), _customers()]) + ns = build_namespace(model) + field = ns.resolve_qualified( + normalize_identifier("orders"), normalize_identifier("amount") + ) + assert field.name == normalize_identifier("amount") + + def test_unknown_dataset_E2002(self) -> None: + model = SemanticModel(name="m", datasets=[_orders()]) + ns = build_namespace(model) + with pytest.raises(OSIParseError) as exc: + ns.resolve_qualified( + normalize_identifier("missing"), normalize_identifier("amount") + ) + assert exc.value.code is ErrorCode.E2002_NAME_NOT_FOUND + + def test_unknown_field_E2002(self) -> None: + model = SemanticModel(name="m", datasets=[_orders()]) + ns = build_namespace(model) + with pytest.raises(OSIParseError) as exc: + ns.resolve_qualified( + normalize_identifier("orders"), normalize_identifier("missing") + ) + assert exc.value.code is ErrorCode.E2002_NAME_NOT_FOUND + + +class TestResolveBare: + def test_unique_bare_name(self) -> None: + model = SemanticModel(name="m", datasets=[_orders(), _customers()]) + ns = build_namespace(model) + owner = ns.resolve_bare(normalize_identifier("amount")) + assert owner == normalize_identifier("orders") + + def test_ambiguous_bare_name_E2001(self) -> None: + # both datasets declare an `id` column + model = SemanticModel(name="m", datasets=[_orders(), _customers()]) + ns = build_namespace(model) + with pytest.raises(OSIParseError) as exc: + ns.resolve_bare(normalize_identifier("id")) + assert exc.value.code is ErrorCode.E2001_AMBIGUOUS_NAME + + def test_unknown_bare_name_E2002(self) -> None: + model = SemanticModel(name="m", datasets=[_orders()]) + ns = build_namespace(model) + with pytest.raises(OSIParseError) as exc: + ns.resolve_bare(normalize_identifier("nope")) + assert exc.value.code is ErrorCode.E2002_NAME_NOT_FOUND diff --git a/impl/python/tests/unit/parsing/test_parser.py b/impl/python/tests/unit/parsing/test_parser.py new file mode 100644 index 0000000..13ba6a6 --- /dev/null +++ b/impl/python/tests/unit/parsing/test_parser.py @@ -0,0 +1,254 @@ +"""End-to-end unit tests for :func:`osi.parsing.parser.parse_semantic_model`. + +Covers the full pipeline (YAML → deferred check → pydantic → AST check → +cross-ref → namespace → graph) and the pydantic-error translation for +``E1001``/``E1002``/``E1003``/``E1004``. +""" + +from __future__ import annotations + +from pathlib import Path +from textwrap import dedent + +import pytest + +from osi.errors import ErrorCode, OSIParseError +from osi.parsing import parse_semantic_model + +_HAPPY_YAML = dedent(""" + semantic_model: + - name: m + datasets: + - name: orders + source: sales.orders + primary_key: [id] + fields: + - name: id + expression: id + - name: customer_id + expression: customer_id + - name: amount + expression: amount + role: fact + - name: customers + source: sales.customers + primary_key: [id] + fields: + - name: id + expression: id + relationships: + - name: o_c + from: orders + to: customers + from_columns: [customer_id] + to_columns: [id] + metrics: + - name: revenue + expression: SUM(orders.amount) + - name: avg_rev + expression: revenue / 2 + filters: + - name: done + expression: status = 'done' + parameters: + - name: min_amount + data_type: NUMBER + default: 0 + """).strip() + + +class TestHappyPath: + def test_parse_string_source(self) -> None: + result = parse_semantic_model(_HAPPY_YAML) + assert result.model.name == "m" + assert len(result.model.datasets) == 2 + assert len(result.graph.edges) == 1 + # Metrics are model-scoped (top-level) per Foundation v0.1 + # §4.5; per-dataset ``metrics:`` blocks are deferred. + assert "revenue" in {str(m.name) for m in result.model.metrics} + + def test_parse_path_source(self, tmp_path: Path) -> None: + yaml_path = tmp_path / "model.yaml" + yaml_path.write_text(_HAPPY_YAML) + result = parse_semantic_model(yaml_path) + assert result.model.name == "m" + + def test_example_fixture_parses(self) -> None: + example = ( + Path(__file__).resolve().parents[3] + / "examples" + / "models" + / "demo_orders.yaml" + ) + assert example.exists() + result = parse_semantic_model(example) + assert result.model.name == "demo_orders" + + +class TestYamlSyntaxErrors: + def test_invalid_yaml_E1001(self) -> None: + with pytest.raises(OSIParseError) as exc: + parse_semantic_model(":\n : bad\n : :") + assert exc.value.code is ErrorCode.E1001_YAML_SYNTAX + + def test_empty_document_E1001(self) -> None: + with pytest.raises(OSIParseError) as exc: + parse_semantic_model("") + assert exc.value.code is ErrorCode.E1001_YAML_SYNTAX + + def test_root_not_mapping_E1004(self) -> None: + with pytest.raises(OSIParseError) as exc: + parse_semantic_model("- just a list") + assert exc.value.code is ErrorCode.E1004_TYPE_MISMATCH + + def test_semantic_model_list_wrong_length_E1002(self) -> None: + yaml = dedent(""" + semantic_model: + - name: a + datasets: [{name: d, source: s, fields: [{name: id, expression: id}]}] + - name: b + datasets: [{name: d, source: s, fields: [{name: id, expression: id}]}] + """).strip() + with pytest.raises(OSIParseError) as exc: + parse_semantic_model(yaml) + assert exc.value.code is ErrorCode.E1002_MISSING_REQUIRED_FIELD + + def test_missing_file_E1001(self, tmp_path: Path) -> None: + missing = tmp_path / "does_not_exist.yaml" + with pytest.raises(OSIParseError) as exc: + parse_semantic_model(missing) + assert exc.value.code is ErrorCode.E1001_YAML_SYNTAX + + +class TestSchemaTranslation: + def test_extra_forbidden_E1001(self) -> None: + yaml = dedent(""" + semantic_model: + - name: m + bogus: true + datasets: + - name: orders + source: s + fields: [{name: id, expression: id}] + """).strip() + with pytest.raises(OSIParseError) as exc: + parse_semantic_model(yaml) + assert exc.value.code is ErrorCode.E1001_YAML_SYNTAX + + def test_missing_required_field_E1002(self) -> None: + yaml = dedent(""" + semantic_model: + - name: m + datasets: + - source: sales.orders + fields: [{name: id, expression: id}] + """).strip() + with pytest.raises(OSIParseError) as exc: + parse_semantic_model(yaml) + assert exc.value.code is ErrorCode.E1002_MISSING_REQUIRED_FIELD + + def test_invalid_enum_E1003(self) -> None: + yaml = dedent(""" + semantic_model: + - name: m + datasets: + - name: orders + source: sales.orders + fields: + - name: id + expression: id + role: measurement + """).strip() + with pytest.raises(OSIParseError) as exc: + parse_semantic_model(yaml) + assert exc.value.code is ErrorCode.E1003_INVALID_ENUM_VALUE + + def test_bad_identifier_E1005(self) -> None: + yaml = dedent(""" + semantic_model: + - name: m + datasets: + - name: "1bad" + source: sales.orders + fields: [{name: id, expression: id}] + """).strip() + with pytest.raises(OSIParseError) as exc: + parse_semantic_model(yaml) + assert exc.value.code is ErrorCode.E1005_IDENTIFIER_INVALID + + +class TestDeferredIntegration: + def test_metric_grain_key_E1105(self) -> None: + yaml = dedent(""" + semantic_model: + - name: m + datasets: + - name: orders + source: sales.orders + fields: + - {name: id, expression: id} + - {name: amount, expression: amount, role: fact} + metrics: + - name: rev + expression: SUM(amount) + grain: [id] + """).strip() + with pytest.raises(OSIParseError) as exc: + parse_semantic_model(yaml) + assert exc.value.code is ErrorCode.E_DEFERRED_KEY_REJECTED + + def test_window_in_metric_expression_accepted(self) -> None: + # S-22 (D-028 / D-030): valid windowed metrics now parse + # successfully; the deferred-construct check no longer fires + # on bare ``OVER (...)``. Nested windows / deferred frame + # modes are still rejected with their named codes + # (E_NESTED_WINDOW, E_DEFERRED_FRAME_MODE) — see + # `test_deferred.py::TestExpressionDeferred`. + yaml = dedent(""" + semantic_model: + - name: m + datasets: + - name: orders + source: sales.orders + fields: + - {name: id, expression: id} + - {name: amount, expression: amount, role: fact} + metrics: + - name: running + expression: "SUM(amount) OVER (ORDER BY id)" + """).strip() + result = parse_semantic_model(yaml) + assert any(m.name == "running" for m in result.model.metrics) + + +class TestCrossRefIntegration: + def test_relationship_unknown_column_E2006(self) -> None: + yaml = dedent(""" + semantic_model: + - name: m + datasets: + - name: orders + source: s + primary_key: [id] + fields: [{name: id, expression: id}] + - name: customers + source: s + primary_key: [id] + fields: [{name: id, expression: id}] + relationships: + - name: r + from: orders + to: customers + from_columns: [nope] + to_columns: [id] + """).strip() + with pytest.raises(OSIParseError) as exc: + parse_semantic_model(yaml) + assert exc.value.code is ErrorCode.E2006_INVALID_RELATIONSHIP + + +class TestParseResultShape: + def test_parse_result_is_frozen(self) -> None: + result = parse_semantic_model(_HAPPY_YAML) + with pytest.raises((AttributeError, TypeError)): + result.model = None # type: ignore[misc] diff --git a/impl/python/tests/unit/parsing/test_validation.py b/impl/python/tests/unit/parsing/test_validation.py new file mode 100644 index 0000000..3d64319 --- /dev/null +++ b/impl/python/tests/unit/parsing/test_validation.py @@ -0,0 +1,299 @@ +"""Unit tests for :mod:`osi.parsing.validation`. + +Exercise every ``E2xxx`` code raised by cross-reference validation. +""" + +from __future__ import annotations + +import pytest + +from osi.errors import ErrorCode, OSIParseError +from osi.parsing.models import Dataset, Field, Metric, Relationship, SemanticModel +from osi.parsing.validation import validate_model + + +def _orders() -> Dataset: + return Dataset( + name="orders", + source="sales.orders", + primary_key=["id"], + fields=[ + Field(name="id", expression="id"), + Field(name="customer_id", expression="customer_id"), + Field(name="amount", expression="amount", role="fact"), + ], + ) + + +def _customers() -> Dataset: + return Dataset( + name="customers", + source="sales.customers", + primary_key=["id"], + fields=[Field(name="id", expression="id")], + ) + + +class TestRelationshipReferences: + def test_happy_path(self) -> None: + model = SemanticModel( + name="m", + datasets=[_orders(), _customers()], + relationships=[ + Relationship.model_validate( + { + "name": "o_c", + "from": "orders", + "to": "customers", + "from_columns": ["customer_id"], + "to_columns": ["id"], + } + ) + ], + ) + validate_model(model) + + def test_unknown_dataset_E2006(self) -> None: + model = SemanticModel( + name="m", + datasets=[_orders()], + relationships=[ + Relationship.model_validate( + { + "name": "o_c", + "from": "orders", + "to": "customers", # missing + "from_columns": ["customer_id"], + "to_columns": ["id"], + } + ) + ], + ) + with pytest.raises(OSIParseError) as exc: + validate_model(model) + assert exc.value.code is ErrorCode.E2006_INVALID_RELATIONSHIP + + def test_unknown_column_E2006(self) -> None: + model = SemanticModel( + name="m", + datasets=[_orders(), _customers()], + relationships=[ + Relationship.model_validate( + { + "name": "o_c", + "from": "orders", + "to": "customers", + "from_columns": ["no_such_col"], + "to_columns": ["id"], + } + ) + ], + ) + with pytest.raises(OSIParseError) as exc: + validate_model(model) + assert exc.value.code is ErrorCode.E2006_INVALID_RELATIONSHIP + + +class TestMetricReferences: + def test_metric_qualifies_unknown_dataset_E2002(self) -> None: + model = SemanticModel( + name="m", + datasets=[_orders()], + metrics=[Metric(name="rev", expression="SUM(bogus.amount)")], + ) + with pytest.raises(OSIParseError) as exc: + validate_model(model) + assert exc.value.code is ErrorCode.E2002_NAME_NOT_FOUND + + def test_metric_qualifies_known_dataset_ok(self) -> None: + model = SemanticModel( + name="m", + datasets=[_orders()], + metrics=[Metric(name="rev", expression="SUM(orders.amount)")], + ) + validate_model(model) + + def test_bare_metric_expression_ok(self) -> None: + model = SemanticModel( + name="m", + datasets=[_orders()], + metrics=[Metric(name="rev", expression="SUM(amount)")], + ) + validate_model(model) + + +class TestReservedNames: + """D-019: ``GRAIN``, ``FILTER``, ``QUERY_FILTER`` are reserved. + + Each name class — dataset, field, model-scope metric, dataset- + scope metric, relationship — must reject a reserved name at + parse time with ``E_RESERVED_NAME``. Casing is ignored. + """ + + def test_dataset_name_reserved_E_RESERVED_NAME(self) -> None: + model = SemanticModel( + name="m", + datasets=[ + Dataset( + name="filter", + source="x", + primary_key=["id"], + fields=[Field(name="id", expression="id")], + ), + ], + ) + with pytest.raises(OSIParseError) as exc: + validate_model(model) + assert exc.value.code is ErrorCode.E_RESERVED_NAME + assert "filter" in str(exc.value) + + def test_field_name_reserved_E_RESERVED_NAME(self) -> None: + model = SemanticModel( + name="m", + datasets=[ + Dataset( + name="orders", + source="x", + primary_key=["id"], + fields=[ + Field(name="id", expression="id"), + Field(name="grain", expression="'placeholder'"), + ], + ), + ], + ) + with pytest.raises(OSIParseError) as exc: + validate_model(model) + assert exc.value.code is ErrorCode.E_RESERVED_NAME + ctx = exc.value.context or {} + assert ctx.get("kind") == "field" + assert ctx.get("owner") == "orders" + + def test_model_metric_name_reserved_E_RESERVED_NAME(self) -> None: + model = SemanticModel( + name="m", + datasets=[_orders()], + metrics=[Metric(name="QUERY_FILTER", expression="SUM(orders.amount)")], + ) + with pytest.raises(OSIParseError) as exc: + validate_model(model) + assert exc.value.code is ErrorCode.E_RESERVED_NAME + ctx = exc.value.context or {} + assert ctx.get("kind") == "metric" + assert ctx.get("owner") is None + + def test_dataset_metric_name_reserved_E_RESERVED_NAME(self) -> None: + model = SemanticModel( + name="m", + datasets=[ + Dataset( + name="orders", + source="x", + primary_key=["id"], + fields=[Field(name="amount", expression="amount", role="fact")], + metrics=[ + Metric(name="grain", expression="SUM(amount)"), + ], + ), + ], + ) + with pytest.raises(OSIParseError) as exc: + validate_model(model) + assert exc.value.code is ErrorCode.E_RESERVED_NAME + ctx = exc.value.context or {} + assert ctx.get("owner") == "orders" + + def test_relationship_name_reserved_E_RESERVED_NAME(self) -> None: + model = SemanticModel( + name="m", + datasets=[_orders(), _customers()], + relationships=[ + Relationship.model_validate( + { + "name": "Filter", # collides case-insensitively + "from": "orders", + "to": "customers", + "from_columns": ["customer_id"], + "to_columns": ["id"], + } + ), + ], + ) + with pytest.raises(OSIParseError) as exc: + validate_model(model) + assert exc.value.code is ErrorCode.E_RESERVED_NAME + + def test_non_reserved_names_pass_through(self) -> None: + model = SemanticModel( + name="m", + datasets=[ + Dataset( + name="filter_pop", # contains the substring but is not equal + source="x", + primary_key=["id"], + fields=[ + Field(name="id", expression="id"), + Field(name="grain_size", expression="'p'"), + ], + ), + ], + ) + validate_model(model) + + +class TestMetricCycles: + def test_self_reference_is_a_cycle(self) -> None: + """A metric that references itself is a 1-cycle. + + Previously the validator silently dropped self-edges, which let + ``m1 := m1 + 1`` slip through and explode in the planner. The + Foundation rule is uniform: every back-edge — including self — + is :attr:`ErrorCode.E2005_CIRCULAR_METRIC`. + """ + model = SemanticModel( + name="m", + datasets=[_orders()], + metrics=[Metric(name="m1", expression="m1 + 1")], + ) + with pytest.raises(OSIParseError) as exc: + validate_model(model) + assert exc.value.code is ErrorCode.E2005_CIRCULAR_METRIC + assert "m1" in str(exc.value) + + def test_two_step_cycle_E2005(self) -> None: + model = SemanticModel( + name="m", + datasets=[_orders()], + metrics=[ + Metric(name="m_a", expression="m_b + 1"), + Metric(name="m_b", expression="m_a + 1"), + ], + ) + with pytest.raises(OSIParseError) as exc: + validate_model(model) + assert exc.value.code is ErrorCode.E2005_CIRCULAR_METRIC + + def test_three_step_cycle_E2005(self) -> None: + model = SemanticModel( + name="m", + datasets=[_orders()], + metrics=[ + Metric(name="m_a", expression="m_b + 1"), + Metric(name="m_b", expression="m_c + 1"), + Metric(name="m_c", expression="m_a + 1"), + ], + ) + with pytest.raises(OSIParseError) as exc: + validate_model(model) + assert exc.value.code is ErrorCode.E2005_CIRCULAR_METRIC + + def test_linear_chain_ok(self) -> None: + model = SemanticModel( + name="m", + datasets=[_orders()], + metrics=[ + Metric(name="m_a", expression="m_b + 1"), + Metric(name="m_b", expression="1"), + ], + ) + validate_model(model) diff --git a/impl/python/tests/unit/planning/__init__.py b/impl/python/tests/unit/planning/__init__.py new file mode 100644 index 0000000..087c4e7 --- /dev/null +++ b/impl/python/tests/unit/planning/__init__.py @@ -0,0 +1 @@ +"""Unit tests for :mod:`osi.planning`.""" diff --git a/impl/python/tests/unit/planning/algebra/__init__.py b/impl/python/tests/unit/planning/algebra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/impl/python/tests/unit/planning/algebra/test_grain.py b/impl/python/tests/unit/planning/algebra/test_grain.py new file mode 100644 index 0000000..c6144b1 --- /dev/null +++ b/impl/python/tests/unit/planning/algebra/test_grain.py @@ -0,0 +1,210 @@ +"""Unit tests for :mod:`osi.planning.algebra.grain` symbolic simulation. + +Full property-based coverage lives in +``tests/properties/test_grain_closure.py`` (Phase 1 law §4.4). These +unit tests pin down specific shapes so the symbolic simulator can be +trusted before property generators are pointed at it. +""" + +from __future__ import annotations + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.errors import ErrorCode, OSIError +from osi.planning.algebra.grain import ( + AggregateStep, + BroadcastStep, + EnrichStep, + GrainSimulationError, + MergeStep, + OperatorTag, + SimpleStep, + SourceStep, + combine_grains, + is_coarser, + simulate, + simulate_grain, +) + + +def I(s: str) -> str: # noqa: E743 + return normalize_identifier(s) + + +class TestSimulateGrain: + def test_source_only(self): + pk = frozenset({I("a"), I("b")}) + assert simulate_grain((SourceStep(OperatorTag.SOURCE, pk),)) == pk + + def test_filter_preserves_grain(self): + pk = frozenset({I("a")}) + steps = ( + SourceStep(OperatorTag.SOURCE, pk), + SimpleStep(OperatorTag.FILTER), + ) + assert simulate_grain(steps) == pk + + def test_aggregate_coarsens(self): + pk = frozenset({I("a"), I("b")}) + target = frozenset({I("a")}) + steps = ( + SourceStep(OperatorTag.SOURCE, pk), + AggregateStep(OperatorTag.AGGREGATE, target), + ) + assert simulate_grain(steps) == target + + def test_aggregate_rejects_coarser_than_parent(self): + steps = ( + SourceStep(OperatorTag.SOURCE, frozenset({I("a")})), + AggregateStep(OperatorTag.AGGREGATE, frozenset({I("b")})), + ) + with pytest.raises(GrainSimulationError) as excinfo: + simulate_grain(steps) + assert excinfo.value.code is ErrorCode.E_INTERNAL_INVARIANT + + def test_merge_requires_matching_grain(self): + steps = ( + SourceStep(OperatorTag.SOURCE, frozenset({I("a")})), + MergeStep(OperatorTag.MERGE, frozenset({I("b")})), + ) + with pytest.raises(GrainSimulationError) as excinfo: + simulate_grain(steps) + assert excinfo.value.code is ErrorCode.E_INTERNAL_INVARIANT + + def test_empty_sequence_rejected(self): + with pytest.raises(GrainSimulationError) as excinfo: + simulate_grain(()) + assert excinfo.value.code is ErrorCode.E_INTERNAL_INVARIANT + + def test_missing_source_rejected(self): + with pytest.raises(GrainSimulationError) as excinfo: + simulate_grain((SimpleStep(OperatorTag.FILTER),)) + assert excinfo.value.code is ErrorCode.E_INTERNAL_INVARIANT + + def test_source_after_start_rejected(self): + steps = ( + SourceStep(OperatorTag.SOURCE, frozenset({I("a")})), + SourceStep(OperatorTag.SOURCE, frozenset({I("b")})), + ) + with pytest.raises(GrainSimulationError) as excinfo: + simulate_grain(steps) + assert excinfo.value.code is ErrorCode.E_INTERNAL_INVARIANT + + def test_simple_step_with_wrong_tag_rejected(self): + steps = ( + SourceStep(OperatorTag.SOURCE, frozenset({I("a")})), + SimpleStep(OperatorTag.AGGREGATE), + ) + with pytest.raises(GrainSimulationError) as excinfo: + simulate_grain(steps) + assert excinfo.value.code is ErrorCode.E_INTERNAL_INVARIANT + + def test_grain_simulation_error_is_an_osi_error(self): + """Architecture invariant: every OSI failure is an ``OSIError``. + + ``GrainSimulationError`` used to subclass ``ValueError`` which + meant grain-simulator failures slipped past the typed-error + architecture test. Pinning the inheritance here prevents the + regression. + """ + with pytest.raises(OSIError) as excinfo: + simulate_grain(()) + assert isinstance(excinfo.value, GrainSimulationError) + assert excinfo.value.code is ErrorCode.E_INTERNAL_INVARIANT + + +class TestEnrichBroadcastSimulation: + """Simulator tracks single-valued columns from ``enrich``/``broadcast``. + + Without this, aggregating by an enriched-in dimension (the hot path + for star-schema BI queries) would be rejected by the simulator even + though the concrete algebra accepts it. The Foundation promotes + grain to first-class state in the simulator: ``(grain, single_valued)``. + """ + + def test_enrich_preserves_grain_and_extends_single_valued(self) -> None: + pk = frozenset({I("order_id")}) + steps = ( + SourceStep(OperatorTag.SOURCE, pk), + EnrichStep( + OperatorTag.ENRICH, + adds=frozenset({I("region"), I("segment")}), + ), + ) + sim = simulate(steps) + assert sim.grain == pk + assert {I("region"), I("segment")} <= sim.single_valued + + def test_aggregate_by_enriched_dimension_accepted(self) -> None: + pk = frozenset({I("order_id")}) + steps = ( + SourceStep(OperatorTag.SOURCE, pk), + EnrichStep(OperatorTag.ENRICH, adds=frozenset({I("region")})), + AggregateStep(OperatorTag.AGGREGATE, frozenset({I("region")})), + ) + # Hot path: aggregate by an enriched-in dim. Symbolic must accept. + sim = simulate(steps) + assert sim.grain == frozenset({I("region")}) + + def test_aggregate_by_enriched_dimension_returns_target_via_simulate_grain( + self, + ) -> None: + pk = frozenset({I("order_id")}) + target = frozenset({I("region")}) + steps = ( + SourceStep(OperatorTag.SOURCE, pk), + EnrichStep(OperatorTag.ENRICH, adds=target), + AggregateStep(OperatorTag.AGGREGATE, target), + ) + assert simulate_grain(steps) == target + + def test_broadcast_extends_single_valued(self) -> None: + pk = frozenset({I("order_id")}) + steps = ( + SourceStep(OperatorTag.SOURCE, pk), + BroadcastStep(OperatorTag.BROADCAST, adds=I("global_total")), + ) + sim = simulate(steps) + assert sim.grain == pk + assert I("global_total") in sim.single_valued + + def test_aggregate_rejects_grain_that_is_neither_in_source_nor_enriched( + self, + ) -> None: + pk = frozenset({I("order_id")}) + steps = ( + SourceStep(OperatorTag.SOURCE, pk), + EnrichStep(OperatorTag.ENRICH, adds=frozenset({I("region")})), + AggregateStep(OperatorTag.AGGREGATE, frozenset({I("nope")})), + ) + with pytest.raises(GrainSimulationError) as excinfo: + simulate_grain(steps) + assert excinfo.value.code is ErrorCode.E_INTERNAL_INVARIANT + + +class TestIsCoarser: + def test_equal_grains(self): + g = frozenset({I("a"), I("b")}) + assert is_coarser(g, g) + + def test_strict_subset(self): + assert is_coarser(frozenset({I("a")}), frozenset({I("a"), I("b")})) + + def test_not_subset(self): + assert not is_coarser(frozenset({I("c")}), frozenset({I("a"), I("b")})) + + +class TestCombineGrains: + def test_union_of_disjoint(self): + assert combine_grains(frozenset({I("a")}), frozenset({I("b")})) == frozenset( + {I("a"), I("b")} + ) + + def test_overlap_deduplicated(self): + assert combine_grains( + frozenset({I("a"), I("b")}), frozenset({I("b")}) + ) == frozenset({I("a"), I("b")}) + + def test_empty_inputs(self): + assert combine_grains() == frozenset() diff --git a/impl/python/tests/unit/planning/algebra/test_operators.py b/impl/python/tests/unit/planning/algebra/test_operators.py new file mode 100644 index 0000000..7f7f7ab --- /dev/null +++ b/impl/python/tests/unit/planning/algebra/test_operators.py @@ -0,0 +1,939 @@ +"""Unit tests for each of the nine algebra operators. + +One test class per operator. Each class covers: + +1. The happy path: preconditions met, expected grain/columns returned. +2. Every precondition violation listed in ``JOIN_ALGEBRA.md §3``, + asserting on a specific ``ErrorCode``. + +Property-based invariants (totality, purity, determinism, ...) live in +``tests/properties/test_algebra_*.py``. +""" + +from __future__ import annotations + +import pytest +import sqlglot + +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.errors import ErrorCode, OSIError +from osi.planning.algebra import ( + AggregateFunction, + CalculationState, + Column, + ColumnKind, + FilterMode, + JoinType, + add_columns, + aggregate, + broadcast, + enrich, + filter_, + filtering_join, + merge, + project, + source, +) +from tests.properties.strategies import aggregate_column, dimension_column, fact_column + + +def I(s: str) -> str: # noqa: E743 (helper for readable tests) + return normalize_identifier(s) + + +# --------------------------------------------------------------------------- +# source +# --------------------------------------------------------------------------- + + +class TestSource: + def test_initial_grain_is_primary_key(self): + state = source( + primary_key=frozenset({I("order_id")}), + dimension_columns=[dimension_column(I("order_id"))], + fact_columns=[fact_column(I("amount"))], + ) + assert state.grain == frozenset({I("order_id")}) + assert state.column_names == {I("order_id"), I("amount")} + + def test_empty_primary_key_rejected(self): + with pytest.raises(OSIError) as exc: + source( + primary_key=frozenset(), + dimension_columns=[dimension_column(I("a"))], + ) + assert exc.value.code == ErrorCode.E2007_MISSING_PRIMARY_KEY + + def test_duplicate_column_names_rejected(self): + with pytest.raises(OSIError) as exc: + source( + primary_key=frozenset({I("a")}), + dimension_columns=[dimension_column(I("a")), dimension_column(I("a"))], + ) + assert exc.value.code == ErrorCode.E3005_COLUMN_NAME_COLLISION + + def test_primary_key_not_in_dimensions_rejected(self): + with pytest.raises(OSIError) as exc: + source( + primary_key=frozenset({I("missing")}), + dimension_columns=[dimension_column(I("a"))], + ) + assert exc.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + def test_aggregate_columns_rejected_at_source(self): + with pytest.raises(OSIError) as exc: + source( + primary_key=frozenset({I("a")}), + dimension_columns=[dimension_column(I("a"))], + fact_columns=[aggregate_column(I("total"), over=I("a"))], + ) + assert exc.value.code == ErrorCode.E4001_EXPLOSION_UNSAFE + + +# --------------------------------------------------------------------------- +# filter_ +# --------------------------------------------------------------------------- + + +class TestFilter: + def _base(self) -> CalculationState: + return source( + primary_key=frozenset({I("a")}), + dimension_columns=[dimension_column(I("a"))], + fact_columns=[fact_column(I("x"))], + ) + + def test_preserves_state_structurally(self): + state = self._base() + pred = FrozenSQL.of(sqlglot.parse_one("x > 0")) + out = filter_(state, pred, dependencies=frozenset({I("x")})) + assert out.grain == state.grain + assert out.column_names == state.column_names + + def test_unknown_dependency_rejected(self): + state = self._base() + pred = FrozenSQL.of(sqlglot.parse_one("missing > 0")) + with pytest.raises(OSIError) as exc: + filter_(state, pred, dependencies=frozenset({I("missing")})) + assert exc.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + +# --------------------------------------------------------------------------- +# enrich +# --------------------------------------------------------------------------- + + +class TestEnrich: + def _parent(self) -> CalculationState: + return source( + primary_key=frozenset({I("order_id")}), + dimension_columns=[ + dimension_column(I("order_id")), + dimension_column(I("customer_id")), + ], + ) + + def _child(self, *, extra: list[Column] | None = None) -> CalculationState: + cols = [dimension_column(I("id"))] + if extra: + cols.extend(extra) + return source( + primary_key=frozenset({I("id")}), + dimension_columns=cols, + ) + + def test_appends_child_columns_with_rhs_flag(self): + parent = self._parent() + child = self._child(extra=[dimension_column(I("customer_name"))]) + out = enrich( + parent, + child, + parent_keys=(I("customer_id"),), + child_keys=(I("id"),), + join_type=JoinType.INNER, + ) + assert out.grain == parent.grain + new_col = out.column(I("customer_name")) + assert new_col.from_join_rhs is True + assert new_col.is_single_valued is True + + def test_fan_trap_rejected_when_child_grain_not_in_keys(self): + # Build a child whose grain is {a, b} but join keys are only {a}; + # joining replicates parent rows (1->N). + child = source( + primary_key=frozenset({I("a"), I("b")}), + dimension_columns=[dimension_column(I("a")), dimension_column(I("b"))], + ) + parent = self._parent() + with pytest.raises(OSIError) as exc: + enrich( + parent, + child, + parent_keys=(I("customer_id"),), + child_keys=(I("a"),), + join_type=JoinType.INNER, + ) + assert exc.value.code == ErrorCode.E3011_MN_AGGREGATION_REJECTED + + def test_keys_not_in_parent_columns_rejected(self): + parent = self._parent() + with pytest.raises(OSIError) as exc: + enrich( + parent, + self._child(), + parent_keys=(I("bogus"),), + child_keys=(I("id"),), + join_type=JoinType.INNER, + ) + assert exc.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + def test_child_column_name_collision_rejected(self): + parent = self._parent() + # Child has a column named ``customer_id`` that collides with parent. + child = source( + primary_key=frozenset({I("id")}), + dimension_columns=[ + dimension_column(I("id")), + dimension_column(I("customer_id")), + ], + ) + with pytest.raises(OSIError) as exc: + enrich( + parent, + child, + parent_keys=(I("customer_id"),), + child_keys=(I("id"),), + join_type=JoinType.INNER, + ) + assert exc.value.code == ErrorCode.E3005_COLUMN_NAME_COLLISION + + def test_aggregate_child_column_reclassified_when_child_unique(self): + """Pre-aggregated child surfaced as FACT through enrich (§6.5.1). + + When the child is unique on ``child_keys`` (its grain is a + subset of the keys, or a UK is) each parent row joins to at + most one child row, so an AGGREGATE column on the child can + safely surface as a row-value FACT column on the result. + Without this relaxation the bridge-resolution mid-pipeline + plan in :mod:`osi.planning.planner_bridge` cannot run. + """ + from dataclasses import replace as dc_replace + + parent = self._parent() + agg_col = aggregate_column(I("count"), over=I("id")) + id_col = dc_replace(dimension_column(I("id")), is_single_valued=True) + child_with_agg = CalculationState( + grain=frozenset({I("id")}), + columns=(id_col, agg_col), + ) + result = enrich( + parent, + child_with_agg, + parent_keys=(I("customer_id"),), + child_keys=(I("id"),), + join_type=JoinType.INNER, + ) + # The COUNT column is preserved by name but is now a FACT + # column at the parent's grain — its aggregate metadata has + # been discharged and downstream aggregates may re-aggregate + # it explicitly. + count_out = result.column(I("count")) + assert count_out.kind is ColumnKind.FACT + assert count_out.aggregate is None + assert count_out.is_single_valued + assert count_out.from_join_rhs + + def test_aggregate_child_column_rejected_when_child_fans_out(self): + """The fan-trap check still fires when child isn't unique. + + Surfacing an AGGREGATE column through a fan-out join would + be unsafe (the value would be repeated across parent rows + with no obvious re-aggregation path), so the algebra still + refuses with ``E3011_MN_AGGREGATION_REJECTED``. + """ + from dataclasses import replace as dc_replace + + parent = self._parent() + agg_col = aggregate_column(I("count"), over=I("id")) + id_col = dc_replace(dimension_column(I("id")), is_single_valued=True) + other_col = dimension_column(I("other")) + # Child has a richer grain than child_keys → child is NOT + # unique on the requested join keys. + child_with_agg = CalculationState( + grain=frozenset({I("id"), I("other")}), + columns=(id_col, other_col, agg_col), + ) + with pytest.raises(OSIError) as exc: + enrich( + parent, + child_with_agg, + parent_keys=(I("customer_id"),), + child_keys=(I("id"),), + join_type=JoinType.INNER, + ) + assert exc.value.code == ErrorCode.E3011_MN_AGGREGATION_REJECTED + + +# --------------------------------------------------------------------------- +# aggregate +# --------------------------------------------------------------------------- + + +class TestAggregate: + def _base(self) -> CalculationState: + return source( + primary_key=frozenset({I("order_id")}), + dimension_columns=[ + dimension_column(I("order_id")), + dimension_column(I("region")), + ], + fact_columns=[fact_column(I("amount"))], + ) + + def test_happy_path_groups_to_new_grain(self): + state = self._base() + out = aggregate( + state, + frozenset({I("region")}), + [aggregate_column(I("total"), over=I("amount"))], + ) + assert out.grain == frozenset({I("region")}) + assert out.column_names == {I("region"), I("total")} + + def test_new_grain_on_fact_column_rejected(self): + state = self._base() + # `amount` is a FACT column, not a dimension — illegal as grain. + with pytest.raises(OSIError) as exc: + aggregate( + state, + frozenset({I("amount")}), + [aggregate_column(I("total"), over=I("amount"))], + ) + assert exc.value.code == ErrorCode.E3004_GRAIN_NOT_SUBSET + + def test_new_grain_unknown_column_rejected(self): + state = self._base() + with pytest.raises(OSIError) as exc: + aggregate( + state, + frozenset({I("totally_bogus")}), + [aggregate_column(I("total"), over=I("amount"))], + ) + assert exc.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + def test_same_grain_is_allowed(self): + state = self._base() + out = aggregate( + state, + state.grain, + [aggregate_column(I("total"), over=I("amount"))], + ) + assert out.grain == state.grain + + def test_non_aggregate_column_rejected(self): + state = self._base() + with pytest.raises(OSIError) as exc: + aggregate( + state, + frozenset({I("region")}), + [fact_column(I("bogus"))], + ) + assert exc.value.code == ErrorCode.E3007_AGGREGATE_IN_SCALAR_CONTEXT + + def test_unknown_dependency_rejected(self): + state = self._base() + bad = Column( + name=I("total"), + expression=FrozenSQL.of(sqlglot.parse_one("SUM(missing)")), + dependencies=frozenset({I("missing")}), + kind=ColumnKind.AGGREGATE, + aggregate=aggregate_column(I("total"), over=I("amount")).aggregate, + ) + with pytest.raises(OSIError) as exc: + aggregate(state, frozenset({I("region")}), [bad]) + assert exc.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + def test_holistic_over_join_rhs_rejected(self): + parent = self._base() + child = source( + primary_key=frozenset({I("region")}), + dimension_columns=[dimension_column(I("region"))], + fact_columns=[fact_column(I("tax"))], + ) + enriched = enrich( + parent, + child, + parent_keys=(I("region"),), + child_keys=(I("region"),), + join_type=JoinType.INNER, + drop_child_columns=frozenset({I("region")}), + ) + holistic = aggregate_column( + I("uniq_tax"), + function=AggregateFunction.COUNT_DISTINCT, + over=I("tax"), + ) + with pytest.raises(OSIError) as exc: + aggregate(enriched, frozenset({I("region")}), [holistic]) + assert exc.value.code == ErrorCode.E4001_EXPLOSION_UNSAFE + + def test_agg_name_collides_with_grain_rejected(self): + state = self._base() + with pytest.raises(OSIError) as exc: + aggregate( + state, + frozenset({I("region")}), + [aggregate_column(I("region"), over=I("amount"))], + ) + assert exc.value.code == ErrorCode.E3005_COLUMN_NAME_COLLISION + + +# --------------------------------------------------------------------------- +# project +# --------------------------------------------------------------------------- + + +class TestProject: + def _base(self) -> CalculationState: + return source( + primary_key=frozenset({I("a")}), + dimension_columns=[dimension_column(I("a")), dimension_column(I("b"))], + fact_columns=[fact_column(I("x"))], + ) + + def test_happy_path(self): + state = self._base() + out = project(state, [I("a"), I("x")]) + assert [c.name for c in out.columns] == [I("a"), I("x")] + assert out.grain == frozenset({I("a")}) + + def test_unknown_column_rejected(self): + state = self._base() + with pytest.raises(OSIError) as exc: + project(state, [I("missing")]) + assert exc.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + def test_dropping_grain_rejected(self): + state = self._base() + with pytest.raises(OSIError) as exc: + project(state, [I("x")]) + assert exc.value.code == ErrorCode.E3004_GRAIN_NOT_SUBSET + + def test_duplicate_columns_rejected(self): + state = self._base() + with pytest.raises(OSIError) as exc: + project(state, [I("a"), I("a")]) + assert exc.value.code == ErrorCode.E3005_COLUMN_NAME_COLLISION + + +# --------------------------------------------------------------------------- +# add_columns +# --------------------------------------------------------------------------- + + +class TestAddColumns: + def _base(self) -> CalculationState: + return source( + primary_key=frozenset({I("a")}), + dimension_columns=[dimension_column(I("a"))], + fact_columns=[fact_column(I("x"))], + ) + + def test_appends_derived_column(self): + state = self._base() + derived = Column( + name=I("doubled"), + expression=FrozenSQL.of(sqlglot.parse_one("x * 2")), + dependencies=frozenset({I("x")}), + kind=ColumnKind.FACT, + ) + out = add_columns(state, [derived]) + assert out.column_names == state.column_names | {I("doubled")} + assert out.grain == state.grain + + def test_aggregate_column_rejected(self): + state = self._base() + with pytest.raises(OSIError) as exc: + add_columns(state, [aggregate_column(I("total"), over=I("x"))]) + assert exc.value.code == ErrorCode.E3007_AGGREGATE_IN_SCALAR_CONTEXT + + def test_unknown_dependency_rejected(self): + state = self._base() + bad = Column( + name=I("oops"), + expression=FrozenSQL.of(sqlglot.parse_one("missing + 1")), + dependencies=frozenset({I("missing")}), + kind=ColumnKind.FACT, + ) + with pytest.raises(OSIError) as exc: + add_columns(state, [bad]) + assert exc.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + def test_name_collision_rejected(self): + state = self._base() + with pytest.raises(OSIError) as exc: + add_columns(state, [fact_column(I("x"))]) + assert exc.value.code == ErrorCode.E3005_COLUMN_NAME_COLLISION + + def test_new_column_can_depend_on_earlier_new_column(self): + state = self._base() + first = Column( + name=I("doubled"), + expression=FrozenSQL.of(sqlglot.parse_one("x * 2")), + dependencies=frozenset({I("x")}), + kind=ColumnKind.FACT, + ) + second = Column( + name=I("quadrupled"), + expression=FrozenSQL.of(sqlglot.parse_one("doubled * 2")), + dependencies=frozenset({I("doubled")}), + kind=ColumnKind.FACT, + ) + out = add_columns(state, [first, second]) + assert I("quadrupled") in out.column_names + + +# --------------------------------------------------------------------------- +# merge +# --------------------------------------------------------------------------- + + +class TestMerge: + def _base(self, facts: list[str]) -> CalculationState: + state = source( + primary_key=frozenset({I("region")}), + dimension_columns=[dimension_column(I("region"))], + fact_columns=[fact_column(I(n)) for n in facts], + ) + return state + + def test_same_grain_disjoint_columns_merged(self): + left = self._base(["sales"]) + right = self._base(["returns"]) + out = merge(left, right) + assert out.grain == left.grain + assert {c.name for c in out.columns} == {I("region"), I("sales"), I("returns")} + + def test_grain_mismatch_rejected(self): + left = self._base(["sales"]) + right = source( + primary_key=frozenset({I("store")}), + dimension_columns=[dimension_column(I("store"))], + ) + with pytest.raises(OSIError) as exc: + merge(left, right) + assert exc.value.code == ErrorCode.E3008_GRAIN_MISMATCH_MERGE + + def test_non_grain_column_overlap_rejected(self): + left = self._base(["sales"]) + right = self._base(["sales"]) + with pytest.raises(OSIError) as exc: + merge(left, right) + assert exc.value.code == ErrorCode.E4003_MERGE_COLUMN_OVERLAP + + def test_on_must_match_shared_grain(self): + left = self._base(["sales"]) + right = self._base(["returns"]) + with pytest.raises(OSIError) as exc: + merge(left, right, on=frozenset({I("store")})) + assert exc.value.code == ErrorCode.E3008_GRAIN_MISMATCH_MERGE + + +# --------------------------------------------------------------------------- +# filtering_join +# --------------------------------------------------------------------------- + + +class TestFilteringJoin: + def _base(self) -> CalculationState: + return source( + primary_key=frozenset({I("order_id")}), + dimension_columns=[ + dimension_column(I("order_id")), + dimension_column(I("customer_id")), + ], + ) + + def _rhs(self) -> CalculationState: + return source( + primary_key=frozenset({I("customer_id")}), + dimension_columns=[dimension_column(I("customer_id"))], + ) + + def test_semi_join_preserves_state_shape(self): + state = self._base() + rhs = self._rhs() + out = filtering_join( + state, + rhs, + lhs_keys=frozenset({I("customer_id")}), + rhs_keys=frozenset({I("customer_id")}), + mode=FilterMode.SEMI, + ) + assert out.column_names == state.column_names + assert out.grain == state.grain + + def test_anti_mode_accepted(self): + state = self._base() + rhs = self._rhs() + out = filtering_join( + state, + rhs, + lhs_keys=frozenset({I("customer_id")}), + rhs_keys=frozenset({I("customer_id")}), + mode=FilterMode.ANTI, + ) + assert out.column_names == state.column_names + + def test_key_arity_mismatch_rejected(self): + state = self._base() + rhs = self._rhs() + with pytest.raises(OSIError) as exc: + filtering_join( + state, + rhs, + lhs_keys=frozenset({I("order_id"), I("customer_id")}), + rhs_keys=frozenset({I("customer_id")}), + mode=FilterMode.SEMI, + ) + assert exc.value.code == ErrorCode.E4005_FILTERING_JOIN_ADDS_COLUMNS + + def test_lhs_key_missing_rejected(self): + state = self._base() + rhs = self._rhs() + with pytest.raises(OSIError) as exc: + filtering_join( + state, + rhs, + lhs_keys=frozenset({I("bogus")}), + rhs_keys=frozenset({I("customer_id")}), + mode=FilterMode.SEMI, + ) + assert exc.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + def test_rhs_key_missing_rejected(self): + state = self._base() + rhs = self._rhs() + with pytest.raises(OSIError) as exc: + filtering_join( + state, + rhs, + lhs_keys=frozenset({I("customer_id")}), + rhs_keys=frozenset({I("bogus")}), + mode=FilterMode.SEMI, + ) + assert exc.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + +# --------------------------------------------------------------------------- +# broadcast +# --------------------------------------------------------------------------- + + +class TestBroadcast: + def _base(self) -> CalculationState: + return source( + primary_key=frozenset({I("a")}), + dimension_columns=[dimension_column(I("a"))], + ) + + def _scalar(self, name: str = "total") -> CalculationState: + from osi.planning.algebra.state import Column # local to avoid unused + + scalar_col = Column( + name=I(name), + expression=FrozenSQL.of(sqlglot.parse_one("42")), + dependencies=frozenset(), + kind=ColumnKind.FACT, + ) + return CalculationState(grain=frozenset(), columns=(scalar_col,)) + + def test_happy_path(self): + state = self._base() + scalar = self._scalar() + out = broadcast(state, scalar) + assert out.grain == state.grain + assert out.column(I("total")).is_single_valued is True + + def test_non_scalar_rejected(self): + state = self._base() + with pytest.raises(OSIError) as exc: + broadcast(state, state) + assert exc.value.code == ErrorCode.E4004_BROADCAST_NOT_SCALAR + + def test_multi_column_scalar_rejected(self): + state = self._base() + cols = ( + Column( + name=I("x"), + expression=FrozenSQL.of(sqlglot.parse_one("1")), + dependencies=frozenset(), + kind=ColumnKind.FACT, + ), + Column( + name=I("y"), + expression=FrozenSQL.of(sqlglot.parse_one("2")), + dependencies=frozenset(), + kind=ColumnKind.FACT, + ), + ) + bad_scalar = CalculationState(grain=frozenset(), columns=cols) + with pytest.raises(OSIError) as exc: + broadcast(state, bad_scalar) + assert exc.value.code == ErrorCode.E4004_BROADCAST_NOT_SCALAR + + def test_name_collision_rejected(self): + state = self._base() + scalar = self._scalar(name="a") + with pytest.raises(OSIError) as exc: + broadcast(state, scalar) + assert exc.value.code == ErrorCode.E3005_COLUMN_NAME_COLLISION + + +# --------------------------------------------------------------------------- +# unique_keys propagation across operators (INFRA.md I-16) +# --------------------------------------------------------------------------- + + +class TestUniqueKeysPropagation: + """Pin per-operator UK behaviour. + + The algebra is the load-bearing module (``INFRA.md §1.1.1``); + every operator's UK transformation needs an explicit unit test. + These tests cover the propagation rules documented in each + operator's docstring: + + * ``source`` → declared UKs land on the state. + * ``filter_`` → identity on the state, including UKs. + * ``enrich`` → parent's UKs preserved; child's UKs dropped. + * ``aggregate`` → keep UKs that are subsets of ``new_grain``. + * ``project`` → keep UKs that are subsets of retained columns. + * ``add_columns`` → preserved (only adds columns). + * ``broadcast`` → preserved (only adds a scalar column). + * ``merge`` → intersect (only UKs holding on both sides). + * ``filtering_join`` → identity on the state, including UKs. + """ + + def _customers_with_uk(self) -> CalculationState: + """Mismarked-PK + UK customers state — the I-16 acceptance shape.""" + return source( + primary_key=frozenset({I("id"), I("region")}), + dimension_columns=[ + dimension_column(I("id")), + dimension_column(I("region")), + ], + unique_keys=[frozenset({I("id")})], + ) + + def test_source_records_declared_uks(self): + state = self._customers_with_uk() + assert frozenset({I("id")}) in state.unique_keys + assert state.grain == frozenset({I("id"), I("region")}) + + def test_source_with_no_uks_has_empty_uk_set(self): + state = source( + primary_key=frozenset({I("id")}), + dimension_columns=[dimension_column(I("id"))], + ) + assert state.unique_keys == frozenset() + + def test_enrich_admits_uk_match_as_proof_of_uniqueness(self): + """The acceptance test at the algebra layer. + + Without UK awareness, enriching a (PK={id, region}) child on + ``[id]`` would raise ``E3011`` — that's exactly the bug the + sprint fixed. Now the UK ``[id]`` discharges the fan-trap rule. + """ + parent = source( + primary_key=frozenset({I("order_id")}), + dimension_columns=[ + dimension_column(I("order_id")), + dimension_column(I("customer_id")), + ], + ) + child = self._customers_with_uk() + out = enrich( + parent, + child, + parent_keys=(I("customer_id"),), + child_keys=(I("id"),), + join_type=JoinType.INNER, + ) + assert out.grain == parent.grain + assert out.column(I("region")).from_join_rhs is True + + def test_enrich_still_rejects_when_no_pk_or_uk_covers_join_keys(self): + parent = source( + primary_key=frozenset({I("order_id")}), + dimension_columns=[ + dimension_column(I("order_id")), + dimension_column(I("customer_id")), + ], + ) + # Child has neither a PK nor a UK matching [id] — only [other]. + child = source( + primary_key=frozenset({I("id"), I("region")}), + dimension_columns=[ + dimension_column(I("id")), + dimension_column(I("region")), + dimension_column(I("other")), + ], + unique_keys=[frozenset({I("other")})], + ) + with pytest.raises(OSIError) as exc: + enrich( + parent, + child, + parent_keys=(I("customer_id"),), + child_keys=(I("id"),), + join_type=JoinType.INNER, + ) + assert exc.value.code == ErrorCode.E3011_MN_AGGREGATION_REJECTED + + def test_enrich_preserves_parent_uks_drops_child_uks(self): + parent = source( + primary_key=frozenset({I("order_id"), I("line_no")}), + dimension_columns=[ + dimension_column(I("order_id")), + dimension_column(I("line_no")), + dimension_column(I("customer_id")), + ], + unique_keys=[frozenset({I("order_id")})], + ) + child = self._customers_with_uk() + out = enrich( + parent, + child, + parent_keys=(I("customer_id"),), + child_keys=(I("id"),), + join_type=JoinType.INNER, + ) + assert out.unique_keys == parent.unique_keys + + def test_filter_preserves_uks(self): + state = self._customers_with_uk() + out = filter_( + state, + FrozenSQL.of(sqlglot.parse_one("region = 'NA'")), + dependencies=frozenset({I("region")}), + ) + assert out.unique_keys == state.unique_keys + + def test_aggregate_keeps_uks_inside_new_grain(self): + state = self._customers_with_uk() + out = aggregate( + state, + new_grain=frozenset({I("id")}), + aggregations=(), + ) + assert frozenset({I("id")}) in out.unique_keys + + def test_aggregate_drops_uks_straddling_new_grain(self): + state = source( + primary_key=frozenset({I("a"), I("b")}), + dimension_columns=[ + dimension_column(I("a")), + dimension_column(I("b")), + dimension_column(I("c")), + ], + unique_keys=[frozenset({I("a"), I("c")})], + ) + # New grain is {a} — UK {a, c} is no longer a subset, so dropped. + out = aggregate( + state, + new_grain=frozenset({I("a")}), + aggregations=(), + ) + assert out.unique_keys == frozenset() + + def test_project_keeps_uks_whose_columns_survive(self): + state = source( + primary_key=frozenset({I("a")}), + dimension_columns=[ + dimension_column(I("a")), + dimension_column(I("b")), + dimension_column(I("c")), + ], + unique_keys=[frozenset({I("b")}), frozenset({I("c")})], + ) + out = project(state, columns=[I("a"), I("b")]) + assert out.unique_keys == frozenset({frozenset({I("b")})}) + + def test_add_columns_preserves_uks(self): + state = self._customers_with_uk() + new_col = Column( + name=I("region_upper"), + expression=FrozenSQL.of(sqlglot.parse_one("UPPER(region)")), + dependencies=frozenset({I("region")}), + kind=ColumnKind.DIMENSION, + ) + out = add_columns(state, definitions=(new_col,)) + assert out.unique_keys == state.unique_keys + + def test_broadcast_preserves_uks(self): + from dataclasses import replace as dc_replace + + state = self._customers_with_uk() + # Sealed aggregate column (no deps) so the scalar state stands + # alone — same shape ``aggregate`` produces post-reduction. + scalar_col = dc_replace( + aggregate_column(I("total"), over=I("a")), + dependencies=frozenset(), + is_single_valued=True, + ) + scalar = CalculationState(grain=frozenset(), columns=(scalar_col,)) + out = broadcast(state, scalar) + assert out.unique_keys == state.unique_keys + + def test_merge_intersects_uks(self): + a_col = dimension_column(I("a")) + left = CalculationState( + grain=frozenset({I("a")}), + columns=(a_col, dimension_column(I("x"))), + unique_keys=frozenset({frozenset({I("x")})}), + ) + right = CalculationState( + grain=frozenset({I("a")}), + columns=(a_col, dimension_column(I("y"))), + unique_keys=frozenset({frozenset({I("y")})}), + ) + # Disjoint UKs on each side → intersection is empty post-merge, + # which is the conservative, correctness-preserving answer for + # FULL OUTER joins (see joins.py). + out = merge(left, right) + assert out.unique_keys == frozenset() + + def test_merge_keeps_uks_present_on_both_sides(self): + # The shared UK must reference a *grain* column, since merge + # rejects overlap on non-grain columns. Both sides declare + # a UK at {a} (= the shared grain) — trivially preserved. + a_col = dimension_column(I("a")) + shared_uk = frozenset({frozenset({I("a")})}) + left = CalculationState( + grain=frozenset({I("a")}), + columns=(a_col, dimension_column(I("y"))), + unique_keys=shared_uk, + ) + right = CalculationState( + grain=frozenset({I("a")}), + columns=(a_col, dimension_column(I("z"))), + unique_keys=shared_uk, + ) + out = merge(left, right) + assert out.unique_keys == shared_uk + + def test_filtering_join_preserves_uks(self): + state = self._customers_with_uk() + rhs = source( + primary_key=frozenset({I("rid")}), + dimension_columns=[dimension_column(I("rid"))], + ) + out = filtering_join( + state, + rhs, + lhs_keys=frozenset({I("id")}), + rhs_keys=frozenset({I("rid")}), + mode=FilterMode.SEMI, + ) + assert out.unique_keys == state.unique_keys diff --git a/impl/python/tests/unit/planning/algebra/test_state.py b/impl/python/tests/unit/planning/algebra/test_state.py new file mode 100644 index 0000000..99ea160 --- /dev/null +++ b/impl/python/tests/unit/planning/algebra/test_state.py @@ -0,0 +1,221 @@ +"""Unit tests for :class:`CalculationState` and :class:`Column`. + +These cover the invariant machinery wired into ``__post_init__``. The +algebra's operator tests (``test_source.py``, ``test_aggregate.py`` …) +go one level above this; here we just make sure the value objects +themselves refuse to exist in a bad state. +""" + +from __future__ import annotations + +import pytest +import sqlglot + +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.errors import ErrorCode, OSIError +from osi.planning.algebra import ( + AggregateFunction, + AggregateInfo, + CalculationState, + Column, + ColumnKind, +) +from tests.properties.strategies import dimension_column, fact_column + + +def _ident(raw: str) -> str: + return normalize_identifier(raw) + + +def test_scalar_state_has_empty_grain(): + state = CalculationState(grain=frozenset(), columns=()) + assert state.is_scalar + assert state.column_names == frozenset() + + +def test_duplicate_column_names_rejected(): + col = dimension_column(_ident("a")) + with pytest.raises(OSIError) as exc_info: + CalculationState(grain=frozenset({_ident("a")}), columns=(col, col)) + assert exc_info.value.code == ErrorCode.E3005_COLUMN_NAME_COLLISION + + +def test_grain_must_reference_dimensions(): + col = fact_column(_ident("x")) + with pytest.raises(OSIError) as exc_info: + CalculationState(grain=frozenset({_ident("x")}), columns=(col,)) + assert exc_info.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + +def test_grain_must_be_present_at_all(): + with pytest.raises(OSIError) as exc_info: + CalculationState(grain=frozenset({_ident("missing")}), columns=()) + assert exc_info.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + +def test_column_dependencies_must_exist(): + a = dimension_column(_ident("a")) + b = Column( + name=_ident("b"), + expression=FrozenSQL.of(sqlglot.parse_one("a + missing")), + dependencies=frozenset({_ident("a"), _ident("missing")}), + kind=ColumnKind.FACT, + ) + with pytest.raises(OSIError) as exc_info: + CalculationState(grain=frozenset({_ident("a")}), columns=(a, b)) + assert exc_info.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + +def test_aggregate_column_without_aggregate_info_rejected(): + with pytest.raises(OSIError) as exc_info: + Column( + name=_ident("total"), + expression=FrozenSQL.of(sqlglot.parse_one("SUM(x)")), + dependencies=frozenset({_ident("x")}), + kind=ColumnKind.AGGREGATE, + aggregate=None, + ) + assert exc_info.value.code == ErrorCode.E4001_EXPLOSION_UNSAFE + + +def test_non_aggregate_column_with_aggregate_info_rejected(): + with pytest.raises(OSIError) as exc_info: + Column( + name=_ident("x"), + expression=FrozenSQL.of(sqlglot.parse_one("x")), + dependencies=frozenset(), + kind=ColumnKind.DIMENSION, + aggregate=AggregateInfo( + function=AggregateFunction.SUM, + argument=FrozenSQL.of(sqlglot.parse_one("x")), + ), + ) + assert exc_info.value.code == ErrorCode.E4001_EXPLOSION_UNSAFE + + +def test_state_is_frozen(): + col = dimension_column(_ident("a")) + state = CalculationState(grain=frozenset({_ident("a")}), columns=(col,)) + from dataclasses import FrozenInstanceError + + with pytest.raises((FrozenInstanceError, AttributeError)): + state.grain = frozenset() # type: ignore[misc] + + +def test_column_lookup_raises_on_missing(): + state = CalculationState(grain=frozenset(), columns=()) + with pytest.raises(OSIError) as exc_info: + state.column(_ident("nope")) + assert exc_info.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + +class TestAggregateFunction: + def test_count_distinct_is_holistic(self): + from osi.planning.algebra.state import Decomposability + + assert ( + AggregateFunction.COUNT_DISTINCT.decomposability is Decomposability.HOLISTIC + ) + + def test_sum_is_distributive(self): + from osi.planning.algebra.state import Decomposability + + assert AggregateFunction.SUM.decomposability is Decomposability.DISTRIBUTIVE + + def test_avg_is_algebraic(self): + from osi.planning.algebra.state import Decomposability + + assert AggregateFunction.AVG.decomposability is Decomposability.ALGEBRAIC + + +class TestUniqueKeysInvariant: + """Invariant I-9: unique_keys must reference dimension columns and be non-empty.""" + + def _state(self, **kw): + a = dimension_column(_ident("a")) + b = dimension_column(_ident("b")) + defaults = dict(grain=frozenset({_ident("a")}), columns=(a, b)) + defaults.update(kw) + return CalculationState(**defaults) + + def test_no_unique_keys_is_default(self): + state = self._state() + assert state.unique_keys == frozenset() + + def test_unique_keys_accepted(self): + state = self._state( + unique_keys=frozenset({frozenset({_ident("b")})}), + ) + assert frozenset({_ident("b")}) in state.unique_keys + + def test_empty_unique_key_rejected(self): + with pytest.raises(OSIError) as exc_info: + self._state(unique_keys=frozenset({frozenset()})) + assert exc_info.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + def test_unique_key_referencing_unknown_column_rejected(self): + with pytest.raises(OSIError) as exc_info: + self._state( + unique_keys=frozenset({frozenset({_ident("nope")})}), + ) + assert exc_info.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + def test_unique_key_referencing_fact_column_rejected(self): + a = dimension_column(_ident("a")) + x = fact_column(_ident("x")) + with pytest.raises(OSIError) as exc_info: + CalculationState( + grain=frozenset({_ident("a")}), + columns=(a, x), + unique_keys=frozenset({frozenset({_ident("x")})}), + ) + assert exc_info.value.code == ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + +class TestIsUniqueOn: + """``is_unique_on`` discharges the fan-trap rule for :func:`enrich`.""" + + def _state(self, *, unique_keys=frozenset()): + a = dimension_column(_ident("a")) + b = dimension_column(_ident("b")) + c = dimension_column(_ident("c")) + return CalculationState( + grain=frozenset({_ident("a"), _ident("b")}), + columns=(a, b, c), + unique_keys=unique_keys, + ) + + def test_grain_itself_is_a_key(self): + state = self._state() + assert state.is_unique_on(frozenset({_ident("a"), _ident("b")})) + + def test_strict_superset_of_grain_is_a_key(self): + state = self._state() + assert state.is_unique_on(frozenset({_ident("a"), _ident("b"), _ident("c")})) + + def test_strict_subset_of_grain_is_not_a_key(self): + state = self._state() + assert not state.is_unique_on(frozenset({_ident("a")})) + + def test_uk_match_proves_uniqueness(self): + state = self._state(unique_keys=frozenset({frozenset({_ident("c")})})) + assert state.is_unique_on(frozenset({_ident("c")})) + + def test_superset_of_uk_proves_uniqueness(self): + state = self._state(unique_keys=frozenset({frozenset({_ident("c")})})) + assert state.is_unique_on(frozenset({_ident("c"), _ident("a")})) + + def test_partial_uk_does_not_prove_uniqueness(self): + state = self._state( + unique_keys=frozenset({frozenset({_ident("a"), _ident("c")})}), + ) + assert not state.is_unique_on(frozenset({_ident("a")})) + + def test_keys_unrelated_to_grain_or_uk_not_unique(self): + state = self._state() + assert not state.is_unique_on(frozenset({_ident("c")})) + + def test_empty_keys_never_unique_when_grain_non_empty(self): + state = self._state() + assert not state.is_unique_on(frozenset()) diff --git a/impl/python/tests/unit/planning/fixtures.py b/impl/python/tests/unit/planning/fixtures.py new file mode 100644 index 0000000..174c548 --- /dev/null +++ b/impl/python/tests/unit/planning/fixtures.py @@ -0,0 +1,195 @@ +"""Shared Foundation semantic-model fixtures for the planner tests. + +Constructing models through :mod:`osi.parsing` keeps the tests honest: +every fixture exercises the full parser + validator + namespace + graph +pipeline, so a regression in any of those surfaces immediately. + +The helpers below return :class:`PlannerContext` handles ready to hand +to :func:`osi.planning.plan`. + +Note on feature flags +--------------------- +These fixtures predate the strict-Foundation deferral of per-dataset +``metrics:`` blocks, aggregate-bodied fields, and nested aggregation +(see :class:`osi.config.FoundationFlags` for the contract). They keep +the legacy YAML shape so the planner's existing handling of those +constructs stays test-covered behind the opt-in flags. Production +callers (the conformance adapter, the CLI, anything user-facing) use +:func:`osi.parsing.parser.parse_semantic_model` with the strict +defaults. +""" + +from __future__ import annotations + +import textwrap + +from osi.config import FoundationFlags +from osi.parsing.graph import build_graph +from osi.parsing.namespace import build_namespace +from osi.parsing.parser import parse_semantic_model +from osi.planning.planner_context import PlannerContext + +_ORDERS_MODEL = textwrap.dedent("""\ + semantic_model: + - name: demo + dialect: ANSI_SQL + datasets: + - name: orders + source: sales.orders + primary_key: [order_id] + fields: + - name: order_id + expression: order_id + role: dimension + - name: customer_id + expression: customer_id + role: dimension + - name: status + expression: status + role: dimension + - name: amount + expression: amount + role: fact + - name: discount + expression: discount + role: fact + metrics: + - name: total_revenue + expression: SUM(amount) + - name: order_count + expression: COUNT(order_id) + - name: distinct_customers + expression: COUNT(DISTINCT customer_id) + - name: max_amount + expression: MAX(amount) + - name: avg_discount + expression: AVG(discount) + - name: customers + source: sales.customers + primary_key: [id] + fields: + - name: id + expression: id + role: dimension + - name: region + expression: region + role: dimension + - name: segment + expression: market_segment + role: dimension + - name: returns + source: sales.returns + primary_key: [return_id] + fields: + - name: return_id + expression: return_id + role: dimension + - name: customer_id + expression: customer_id + role: dimension + - name: order_id + expression: order_id + role: dimension + - name: refund_amount + expression: refund_amount + role: fact + metrics: + - name: total_refunds + expression: SUM(refund_amount) + relationships: + - name: orders_to_customers + from: orders + to: customers + from_columns: [customer_id] + to_columns: [id] + - name: returns_to_customers + from: returns + to: customers + from_columns: [customer_id] + to_columns: [id] + metrics: + - name: avg_order_value + expression: total_revenue / NULLIF(order_count, 0) + description: Composite metric — exercises ADD_COLUMNS path. + """) + + +_MN_MODEL = textwrap.dedent("""\ + semantic_model: + - name: mn_model + datasets: + - name: grade_logs + source: schools.grade_logs + primary_key: [log_id] + fields: + - name: log_id + expression: log_id + role: dimension + - name: course_title + expression: course_title + role: dimension + - name: grade + expression: grade + role: fact + metrics: + - name: avg_grade + expression: AVG(grade) + - name: courses + source: schools.courses + primary_key: [course_id] + fields: + - name: course_id + expression: course_id + role: dimension + - name: title + expression: title + role: dimension + - name: subject + expression: subject + role: dimension + relationships: + - name: logs_to_courses + from: grade_logs + to: courses + from_columns: [course_title] + to_columns: [title] + """) + + +def orders_context() -> PlannerContext: + """Build a star-schema context around ``orders`` for planner tests. + + Includes a second fact dataset (``returns``) so multi-fact merge + scenarios can be exercised. Every relationship is N:1. Uses the + legacy-permissive flag set so the per-dataset ``metrics:`` blocks + parse — see the module docstring — and so the ``EXISTS_IN`` + semi-join surface is admitted (it lives behind + ``FoundationFlags.experimental_exists_in`` per F-13 / D-017). + """ + flags = FoundationFlags.legacy_permissive() + result = parse_semantic_model(_ORDERS_MODEL, flags=flags) + namespace = build_namespace(result.model) + graph = build_graph(result.model) + return PlannerContext( + model=result.model, + namespace=namespace, + graph=graph, + flags=flags, + ) + + +def mn_context() -> PlannerContext: + """Build a model with a deliberate N:N edge for rejection tests.""" + flags = FoundationFlags.legacy_permissive() + result = parse_semantic_model(_MN_MODEL, flags=flags) + namespace = build_namespace(result.model) + graph = build_graph(result.model) + return PlannerContext( + model=result.model, + namespace=namespace, + graph=graph, + flags=flags, + ) + + +__all__ = ["mn_context", "orders_context"] diff --git a/impl/python/tests/unit/planning/test_classify.py b/impl/python/tests/unit/planning/test_classify.py new file mode 100644 index 0000000..dcab38a --- /dev/null +++ b/impl/python/tests/unit/planning/test_classify.py @@ -0,0 +1,253 @@ +"""Unit tests for :mod:`osi.planning.classify`. + +Splits ``where`` into row-level vs semi-join predicates and ``having`` +into post-aggregate predicates. Error codes asserted here: +``E1005`` (identifier invalid), ``E1208`` (unsupported SQL construct), +``E3009`` (post-aggregate refers to pre-aggregate only), +``E_DEFERRED_KEY_REJECTED`` (semi-join in strict Foundation). + +``EXISTS_IN`` / ``NOT EXISTS_IN`` is gated by +``FoundationFlags.experimental_exists_in``; the tests in +:class:`TestSemiJoins` flip the flag to exercise the experimental +admission path, and the strict-default tests in +:class:`TestSemiJoinsStrict` verify the Foundation-default rejection. +""" + +from __future__ import annotations + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL, parse_sql_expr +from osi.config import FoundationFlags +from osi.errors import ErrorCode, OSIPlanningError +from osi.planning.algebra.operations import FilterMode +from osi.planning.classify import SemiJoinPredicate, classify_having, classify_where +from tests.unit.planning.fixtures import orders_context + + +def _where(sql: str) -> FrozenSQL: + return FrozenSQL.of(parse_sql_expr(sql)) + + +EXISTS_FLAGS = FoundationFlags(experimental_exists_in=True) + + +# --------------------------------------------------------------------------- +# classify_where — row-level +# --------------------------------------------------------------------------- + + +class TestRowLevel: + def test_none_predicate_returns_empty_classification(self) -> None: + ns = orders_context().namespace + out = classify_where(None, ns) + assert out.row_level == () + assert out.semi_joins == () + + def test_single_conjunct_becomes_one_row_level_predicate(self) -> None: + ns = orders_context().namespace + out = classify_where(_where("amount > 100"), ns) + assert len(out.row_level) == 1 + assert out.semi_joins == () + pred = out.row_level[0] + assert normalize_identifier("amount") in pred.columns + + def test_conjunction_splits_into_multiple_predicates(self) -> None: + ns = orders_context().namespace + out = classify_where(_where("amount > 100 AND status = 'open'"), ns) + assert len(out.row_level) == 2 + assert out.semi_joins == () + + def test_qualified_column_binds_to_dataset(self) -> None: + ns = orders_context().namespace + out = classify_where(_where("orders.amount > 100"), ns) + assert normalize_identifier("orders") in out.row_level[0].datasets + + def test_bare_column_resolved_via_namespace(self) -> None: + ns = orders_context().namespace + out = classify_where(_where("refund_amount > 0"), ns) + # Only ``returns`` declares ``refund_amount`` — so the bare name + # binds there. + assert normalize_identifier("returns") in out.row_level[0].datasets + + def test_parenthesised_conjuncts_still_split(self) -> None: + ns = orders_context().namespace + out = classify_where(_where("(amount > 100) AND (status = 'x')"), ns) + assert len(out.row_level) == 2 + + +# --------------------------------------------------------------------------- +# classify_where — semi-joins +# --------------------------------------------------------------------------- + + +class TestSemiJoinsStrict: + """Foundation default (flag off): EXISTS_IN is rejected at classify.""" + + def test_exists_in_rejected_with_deferred_code(self) -> None: + ns = orders_context().namespace + with pytest.raises(OSIPlanningError) as excinfo: + classify_where(_where("EXISTS_IN(customer_id, returns.customer_id)"), ns) + assert excinfo.value.code is ErrorCode.E_DEFERRED_KEY_REJECTED + assert excinfo.value.context.get("flag") == "experimental_exists_in" + + def test_not_exists_in_rejected_with_deferred_code(self) -> None: + ns = orders_context().namespace + with pytest.raises(OSIPlanningError) as excinfo: + classify_where( + _where("NOT EXISTS_IN(customer_id, returns.customer_id)"), ns + ) + assert excinfo.value.code is ErrorCode.E_DEFERRED_KEY_REJECTED + + +class TestSemiJoins: + """Experimental flag on: classify recognises the semi-join shape.""" + + def test_exists_in_produces_semi_predicate(self) -> None: + ns = orders_context().namespace + out = classify_where( + _where("EXISTS_IN(customer_id, returns.customer_id)"), + ns, + flags=EXISTS_FLAGS, + ) + assert out.row_level == () + assert len(out.semi_joins) == 1 + sj = out.semi_joins[0] + assert isinstance(sj, SemiJoinPredicate) + assert sj.mode is FilterMode.SEMI + assert sj.pairs[0].rhs_dataset == normalize_identifier("returns") + + def test_not_exists_in_produces_anti_predicate(self) -> None: + ns = orders_context().namespace + out = classify_where( + _where("NOT EXISTS_IN(customer_id, returns.customer_id)"), + ns, + flags=EXISTS_FLAGS, + ) + assert out.semi_joins[0].mode is FilterMode.ANTI + + def test_composite_key_exists_in(self) -> None: + ns = orders_context().namespace + out = classify_where( + _where( + "EXISTS_IN(order_id, returns.order_id, " + "customer_id, returns.customer_id)" + ), + ns, + flags=EXISTS_FLAGS, + ) + sj = out.semi_joins[0] + assert len(sj.pairs) == 2 + + def test_odd_argument_count_rejected_E1208(self) -> None: + ns = orders_context().namespace + with pytest.raises(OSIPlanningError) as excinfo: + classify_where(_where("EXISTS_IN(order_id)"), ns, flags=EXISTS_FLAGS) + assert excinfo.value.code is ErrorCode.E1208_UNSUPPORTED_SQL_CONSTRUCT + + def test_unqualified_rhs_rejected_E1208(self) -> None: + ns = orders_context().namespace + with pytest.raises(OSIPlanningError) as excinfo: + classify_where( + _where("EXISTS_IN(customer_id, customer_id)"), + ns, + flags=EXISTS_FLAGS, + ) + assert excinfo.value.code is ErrorCode.E1208_UNSUPPORTED_SQL_CONSTRUCT + + def test_mixed_row_level_and_semi_join(self) -> None: + ns = orders_context().namespace + out = classify_where( + _where("amount > 0 AND EXISTS_IN(customer_id, returns.customer_id)"), + ns, + flags=EXISTS_FLAGS, + ) + assert len(out.row_level) == 1 + assert len(out.semi_joins) == 1 + + +# --------------------------------------------------------------------------- +# classify_having +# --------------------------------------------------------------------------- + + +class TestHaving: + def test_none_having_returns_empty(self) -> None: + out = classify_having(None, (normalize_identifier("total_revenue"),)) + assert out == () + + def test_having_referencing_measure_accepted(self) -> None: + out = classify_having( + _where("total_revenue > 1000"), + (normalize_identifier("total_revenue"),), + ) + assert len(out) == 1 + assert normalize_identifier("total_revenue") in out[0].measures + + def test_having_without_measure_rejected_with_named_code(self) -> None: + # S-3 / D-012b: pure row-level conjunct in HAVING raises the + # named code rather than the legacy E3009. + with pytest.raises(OSIPlanningError) as excinfo: + classify_having( + _where("status = 'open'"), + (normalize_identifier("total_revenue"),), + ) + assert excinfo.value.code is ErrorCode.E_NON_AGGREGATE_IN_HAVING + + def test_having_splits_on_conjunction(self) -> None: + out = classify_having( + _where("total_revenue > 1 AND total_revenue < 100"), + (normalize_identifier("total_revenue"),), + ) + assert len(out) == 2 + + +# --------------------------------------------------------------------------- +# classify_where — measure references belong in HAVING +# --------------------------------------------------------------------------- + + +class TestWhereRejectsAggregates: + """``WHERE`` is the row-level slot; aggregates belong in ``HAVING``. + + Foundation v0.1 D-005 / D-012a routes by *resolved expression + shape*: a measure reference (resolves to an aggregate) or a raw + aggregate function in ``WHERE`` raises + :attr:`ErrorCode.E_AGGREGATE_IN_WHERE`. A predicate whose tree + mixes both shapes raises :attr:`ErrorCode.E_MIXED_PREDICATE_LEVEL`. + """ + + def test_bare_measure_in_where_rejected(self) -> None: + ns = orders_context().namespace + with pytest.raises(OSIPlanningError) as excinfo: + classify_where(_where("total_revenue > 1000"), ns) + assert excinfo.value.code is ErrorCode.E_AGGREGATE_IN_WHERE + + def test_qualified_measure_in_where_rejected(self) -> None: + """A qualified measure ref must not bypass the WHERE check. + + ``orders.total_revenue`` is the same measure under a qualifier; + the rule must not be circumvented by qualification. + """ + ns = orders_context().namespace + with pytest.raises(OSIPlanningError) as excinfo: + classify_where(_where("orders.total_revenue > 1000"), ns) + assert excinfo.value.code is ErrorCode.E_AGGREGATE_IN_WHERE + + def test_measure_mixed_with_dimension_predicate_rejected(self) -> None: + # Mixed-level predicate (D-012c) wins over per-conjunct + # placement so the diagnostic points at the right fix. + ns = orders_context().namespace + with pytest.raises(OSIPlanningError) as excinfo: + classify_where( + _where("status = 'open' AND total_revenue > 1000"), + ns, + ) + assert excinfo.value.code is ErrorCode.E_MIXED_PREDICATE_LEVEL + + def test_raw_aggregate_in_where_rejected(self) -> None: + ns = orders_context().namespace + with pytest.raises(OSIPlanningError) as excinfo: + classify_where(_where("SUM(orders.amount) > 100"), ns) + assert excinfo.value.code is ErrorCode.E_AGGREGATE_IN_WHERE diff --git a/impl/python/tests/unit/planning/test_compound_aggregate_arguments.py b/impl/python/tests/unit/planning/test_compound_aggregate_arguments.py new file mode 100644 index 0000000..8c5cd34 --- /dev/null +++ b/impl/python/tests/unit/planning/test_compound_aggregate_arguments.py @@ -0,0 +1,295 @@ +"""TDD tests for compound aggregate arguments (Issue 2.1). + +The Foundation's metric expressions are constrained to a single +top-level aggregate, but the *argument* to that aggregate may be a +non-trivial scalar expression: ``SUM(price * qty)``, ``AVG(amount - +discount)``, ``MIN(CASE WHEN status = 'open' THEN amount END)``, etc. + +These tests pin the contract on +:func:`osi.planning.columns.aggregate_argument` and +:func:`metric_to_aggregate_column`: + +1. The argument carried into ``AggregateInfo.argument`` must be the + *whole* top-level argument subtree, structurally equal to what the + user wrote. +2. The set of column dependencies recorded on the aggregate column + must equal the set of all columns referenced anywhere in that + subtree. +3. End-to-end planning for a metric with a compound argument must + succeed and produce an aggregate step whose payload mentions every + referenced column. + +Each test is written so it fails today (the current implementation +truncates the argument to ``arg_columns[0].copy()``) and passes once +``aggregate_argument`` returns the full argument subtree. +""" + +from __future__ import annotations + +import textwrap + +import pytest +from sqlglot import expressions as exp + +from osi.common.identifiers import normalize_identifier +from osi.parsing.parser import parse_semantic_model +from osi.planning.algebra.state import AggregateFunction, ColumnKind +from osi.planning.columns import ( + aggregate_argument, + metric_to_aggregate_column, + parse_metric_aggregate, +) +from osi.planning.planner_context import PlannerContext +from osi.planning.resolve import ResolvedMetric + + +def _build_model_with_metric(metric_expr: str) -> tuple[PlannerContext, str]: + """Build a single-dataset model with one metric expression. + + Returns the planner context and the metric name. The metric lives + on the model (not the dataset) so it can be referenced unqualified. + """ + yaml = textwrap.dedent(f"""\ + semantic_model: + - name: demo + datasets: + - name: orders + source: sales.orders + primary_key: [order_id] + fields: + - name: order_id + expression: order_id + role: dimension + - name: status + expression: status + role: dimension + - name: price + expression: price + role: fact + - name: qty + expression: qty + role: fact + - name: amount + expression: amount + role: fact + - name: discount + expression: discount + role: fact + metrics: + - name: target + expression: {metric_expr} + """) + result = parse_semantic_model(yaml) + return ( + PlannerContext( + model=result.model, + namespace=result.namespace, + graph=result.graph, + ), + "target", + ) + + +def _orders_source_state(ctx: PlannerContext): + """Run the source step for ``orders`` to produce a CalculationState.""" + from osi.planning.steps import PlanBuilder, fact_dataset, source_step + + builder = PlanBuilder() + step = source_step(fact_dataset(normalize_identifier("orders"), ctx), builder, ctx) + return step.state + + +# --------------------------------------------------------------------------- +# parse_metric_aggregate: column collection +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "expr,expected_func,expected_columns", + [ + ("SUM(price * qty)", AggregateFunction.SUM, {"price", "qty"}), + ("SUM(amount - discount)", AggregateFunction.SUM, {"amount", "discount"}), + ( + "SUM(price + qty + discount)", + AggregateFunction.SUM, + {"price", "qty", "discount"}, + ), + ("AVG(amount - discount)", AggregateFunction.AVG, {"amount", "discount"}), + ("MAX(price * 2 + amount)", AggregateFunction.MAX, {"price", "amount"}), + ("MIN(price - discount)", AggregateFunction.MIN, {"price", "discount"}), + ("SUM(amount)", AggregateFunction.SUM, {"amount"}), + ("COUNT(*)", AggregateFunction.COUNT, set()), + ("COUNT(DISTINCT price)", AggregateFunction.COUNT_DISTINCT, {"price"}), + ], +) +def test_parse_metric_aggregate_collects_all_columns( + expr: str, + expected_func: AggregateFunction, + expected_columns: set[str], +) -> None: + ctx, metric_name = _build_model_with_metric(expr) + metric = ctx.namespace.metrics[normalize_identifier(metric_name)] + func, columns = parse_metric_aggregate(metric) + assert func is expected_func + seen = {c.name for c in columns} + assert seen == expected_columns + + +# --------------------------------------------------------------------------- +# aggregate_argument: whole-tree fidelity +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "expr", + [ + "SUM(price * qty)", + "SUM(amount - discount)", + "SUM(price + qty + discount)", + "AVG(amount - discount)", + "MAX(price * 2 + amount)", + "MIN(price - discount)", + "SUM(amount)", + ], +) +def test_aggregate_argument_returns_whole_subtree(expr: str) -> None: + """The recorded argument must structurally equal the original.""" + ctx, metric_name = _build_model_with_metric(expr) + metric = ctx.namespace.metrics[normalize_identifier(metric_name)] + func, columns = parse_metric_aggregate(metric) + argument = aggregate_argument(metric, columns) + expected = metric.expression.expr.this # the inner expression + assert argument.expr == expected, ( + f"aggregate_argument truncated the expression for {expr!r}: " + f"got {argument.canonical!r}, expected {expected.sql()!r}" + ) + + +def test_aggregate_argument_count_star_returns_literal_one() -> None: + """COUNT(*) should still degrade to ``1``.""" + ctx, metric_name = _build_model_with_metric("COUNT(*)") + metric = ctx.namespace.metrics[normalize_identifier(metric_name)] + func, columns = parse_metric_aggregate(metric) + argument = aggregate_argument(metric, columns) + assert argument.expr == exp.Literal.number(1) + + +def test_aggregate_argument_does_not_alias_metric_expression() -> None: + """The returned ``FrozenSQL`` must hold a copy, not the original AST. + + Codegen later mutates AST nodes in place (qualifier rewriting); the + plan-side argument must survive that without being affected. + """ + ctx, metric_name = _build_model_with_metric("SUM(price * qty)") + metric = ctx.namespace.metrics[normalize_identifier(metric_name)] + func, columns = parse_metric_aggregate(metric) + argument = aggregate_argument(metric, columns) + # Walk to the first column inside the metric AST and mutate its name + # in place; the FrozenSQL we already built must be untouched. + inner_columns = list(metric.expression.expr.find_all(exp.Column)) + snapshot_canonical = argument.canonical + inner_columns[0].set("this", exp.to_identifier("MUTATED")) + assert argument.canonical == snapshot_canonical + + +# --------------------------------------------------------------------------- +# metric_to_aggregate_column: dependency tracking +# --------------------------------------------------------------------------- + + +def _resolve_metric(ctx: PlannerContext, metric_name: str) -> ResolvedMetric: + name = normalize_identifier(metric_name) + metric = ctx.namespace.metrics[name] + # Owner dataset: the metric is model-scoped here; just attribute it + # to the only dataset. + return ResolvedMetric( + metric=metric, + dataset=normalize_identifier("orders"), + ) + + +@pytest.mark.parametrize( + "expr,expected_deps", + [ + ("SUM(price * qty)", {"price", "qty"}), + ("SUM(amount - discount)", {"amount", "discount"}), + ("AVG(amount - discount)", {"amount", "discount"}), + ("MAX(price * 2 + amount)", {"price", "amount"}), + ("SUM(amount)", {"amount"}), + ], +) +def test_metric_to_aggregate_column_tracks_all_dependencies( + expr: str, expected_deps: set[str] +) -> None: + ctx, metric_name = _build_model_with_metric(expr) + state = _orders_source_state(ctx) + resolved = _resolve_metric(ctx, metric_name) + column = metric_to_aggregate_column(resolved, state) + assert column.kind is ColumnKind.AGGREGATE + assert column.aggregate is not None + seen_deps = {str(d) for d in column.dependencies} + assert seen_deps == expected_deps + + +def test_metric_to_aggregate_column_argument_is_full_expression() -> None: + """The aggregate column's ``argument`` must contain every referenced column.""" + ctx, metric_name = _build_model_with_metric("SUM(price * qty + discount)") + state = _orders_source_state(ctx) + resolved = _resolve_metric(ctx, metric_name) + column = metric_to_aggregate_column(resolved, state) + assert column.aggregate is not None + arg = column.aggregate.argument.expr + cols = {c.name for c in arg.find_all(exp.Column)} + assert cols == {"price", "qty", "discount"}, f"argument lost columns: {cols}" + + +# --------------------------------------------------------------------------- +# End-to-end: planner emits an AGGREGATE step for compound metrics +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "expr", + [ + "SUM(price * qty)", + "AVG(amount - discount)", + "MAX(price * 2 + amount)", + ], +) +def test_compound_metric_plans_successfully(expr: str) -> None: + """End-to-end: a query selecting a compound metric must plan.""" + from osi.planning import Reference, SemanticQuery, plan + + ctx, metric_name = _build_model_with_metric(expr) + query = SemanticQuery( + dimensions=( + Reference( + dataset=normalize_identifier("orders"), + name=normalize_identifier("status"), + ), + ), + measures=( + Reference( + dataset=None, + name=normalize_identifier(metric_name), + ), + ), + ) + p = plan(query, ctx) + ops = [s.operation.value for s in p.steps] + assert "aggregate" in ops + # Find the aggregate step and confirm the metric column carries + # every referenced source column as a dependency. + agg_step = next(s for s in p.steps if s.operation.value == "aggregate") + metric_col = next( + c for c in agg_step.state.columns if c.name == normalize_identifier(metric_name) + ) + assert metric_col.aggregate is not None + referenced = { + c.name for c in metric_col.aggregate.argument.expr.find_all(exp.Column) + } + canonical = metric_col.aggregate.argument.canonical + assert "MUTATED" not in canonical + assert referenced, ( + f"compound metric {expr!r} produced argument without columns: " f"{canonical!r}" + ) diff --git a/impl/python/tests/unit/planning/test_dimension_only.py b/impl/python/tests/unit/planning/test_dimension_only.py new file mode 100644 index 0000000..5dc8146 --- /dev/null +++ b/impl/python/tests/unit/planning/test_dimension_only.py @@ -0,0 +1,111 @@ +"""Dimension-only query planning (``Proposed_OSI_Semantics.md §5.3``). + +These tests pin the three observable outcomes of +:func:`~osi.planning.planner._dimension_only_group`: + +1. Single-dataset queries plan deterministically. +2. Multi-dataset queries pick a unique anchor that can reach every + other dataset via a safe N:1 path, ignoring declaration order. +3. When no safe anchor exists, the failure is the same error the + path finder would raise for the equivalent measure-bearing query + (``E2004`` / ``E3011``) — never a silent fan-trap. + + FIXME(spec-alignment): Under ``Proposed_OSI_Semantics.md §6.8 + *Semantic guarantee*`` an M:N-supporting engine (which this + implementation is) MUST NOT raise ``E3011`` at the user-facing + surface — that code is reserved for engine-level M:N opt-outs. + The dim-only path currently leaks the algebra-internal + ``E3011`` precondition signal unchanged, while the measure- + bearing path translates it to ``E_UNSAFE_REAGGREGATION`` (see + ``test_fan_trap_safety.py``). The planner's dim-only group + should perform the same translation. Until that refactor lands, + the assertion below accepts both shapes. +""" + +from __future__ import annotations + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.errors import ErrorCode, OSIPlanningError +from osi.planning import PlanOperation, Reference, SemanticQuery, plan +from tests.unit.planning.fixtures import orders_context + + +def _ref(dataset: str, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(dataset), name=normalize_identifier(name) + ) + + +def test_single_dataset_dim_only_query_plans_cleanly() -> None: + ctx = orders_context() + query = SemanticQuery( + dimensions=(_ref("customers", "region"), _ref("customers", "segment")), + measures=(), + ) + + plan_ = plan(query, ctx) + + assert {normalize_identifier("region"), normalize_identifier("segment")} == set( + plan_.output_columns + ) + sources = [s for s in plan_.steps if s.operation is PlanOperation.SOURCE] + assert len(sources) == 1 + assert sources[0].payload.dataset == normalize_identifier("customers") + + +def test_multi_dataset_dim_only_picks_unique_safe_anchor() -> None: + """``orders → customers`` is the only safe direction; anchor must be ``orders``.""" + ctx = orders_context() + query = SemanticQuery( + dimensions=(_ref("customers", "region"), _ref("orders", "status")), + measures=(), + ) + + plan_ = plan(query, ctx) + + sources = [s for s in plan_.steps if s.operation is PlanOperation.SOURCE] + assert len(sources) == 1 + assert sources[0].payload.dataset == normalize_identifier("orders") + + +def test_multi_dataset_dim_only_order_independent() -> None: + """Anchor selection must not depend on dimension declaration order.""" + ctx = orders_context() + q_a = SemanticQuery( + dimensions=(_ref("customers", "region"), _ref("orders", "status")), + measures=(), + ) + q_b = SemanticQuery( + dimensions=(_ref("orders", "status"), _ref("customers", "region")), + measures=(), + ) + + plan_a = plan(q_a, ctx) + plan_b = plan(q_b, ctx) + + anchor_a = next( + s for s in plan_a.steps if s.operation is PlanOperation.SOURCE + ).payload.dataset + anchor_b = next( + s for s in plan_b.steps if s.operation is PlanOperation.SOURCE + ).payload.dataset + assert anchor_a == anchor_b + + +def test_dim_only_cross_fact_chain_rejected() -> None: + """``orders`` and ``returns`` cannot be traversed safely in either direction.""" + ctx = orders_context() + query = SemanticQuery( + dimensions=(_ref("orders", "status"), _ref("returns", "return_id")), + measures=(), + ) + + with pytest.raises(OSIPlanningError) as excinfo: + plan(query, ctx) + + assert excinfo.value.code in ( + ErrorCode.E3011_MN_AGGREGATION_REJECTED, + ErrorCode.E2004_UNREACHABLE_DATASET, + ) diff --git a/impl/python/tests/unit/planning/test_fan_trap_safety.py b/impl/python/tests/unit/planning/test_fan_trap_safety.py new file mode 100644 index 0000000..f558c4d --- /dev/null +++ b/impl/python/tests/unit/planning/test_fan_trap_safety.py @@ -0,0 +1,337 @@ +"""TDD tests for fan-trap rejection (Issue 2.2). + +The algebra contract states that ``enrich(parent, child, ...)`` +preserves ``parent.grain``. That is true only when each parent row +matches *at most one* child row — equivalently, when ``child`` is +unique on the right-hand join keys (``child.grain ⊆ child_keys``). +If the planner asks for a 1→N traversal (e.g. enrich +``customers`` with ``orders`` joining on ``customer_id``), each parent +row matches multiple child rows and the result silently fans out. + +These tests pin the closed-algebra rule that the fan trap is detected +*at the algebra level*, not at codegen, and not via a caller-asserted +boolean. The signature is symmetric with ``merge`` and +``filtering_join``: both sides are full :class:`CalculationState` +values, and the algebra derives safety from grain. + +Each test is written so it fails today (the current ``enrich`` accepts +a ``cardinality_n_to_one`` flag and trusts it). After the refactor: + +* ``enrich`` takes ``child: CalculationState``, ``parent_keys`` and + ``child_keys`` (positional pairings), and a ``join_type``. +* ``enrich`` raises ``E3011_MN_AGGREGATION_REJECTED`` (specialised as + fan-trap) when ``child.grain`` is not a subset of ``child_keys``. +* The planner's reverse-direction traversal of an N:1 edge produces + the same error. +""" + +from __future__ import annotations + +import textwrap + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.config import FoundationFlags +from osi.errors import AlgebraError, ErrorCode, OSIError +from osi.parsing.parser import parse_semantic_model +from osi.planning import Reference, SemanticQuery, plan +from osi.planning.algebra.operations import JoinType, enrich, source +from osi.planning.algebra.state import Column, ColumnKind +from osi.planning.planner_context import PlannerContext + +# --------------------------------------------------------------------------- +# Algebra-level fan-trap detection +# --------------------------------------------------------------------------- + + +def _customers_state(): + return source( + primary_key=frozenset({normalize_identifier("id")}), + dimension_columns=[ + Column( + name=normalize_identifier("id"), + expression=__sql("id"), + dependencies=frozenset(), + kind=ColumnKind.DIMENSION, + ), + Column( + name=normalize_identifier("region"), + expression=__sql("region"), + dependencies=frozenset(), + kind=ColumnKind.DIMENSION, + ), + ], + ) + + +def _orders_state(): + return source( + primary_key=frozenset({normalize_identifier("order_id")}), + dimension_columns=[ + Column( + name=normalize_identifier("order_id"), + expression=__sql("order_id"), + dependencies=frozenset(), + kind=ColumnKind.DIMENSION, + ), + Column( + name=normalize_identifier("customer_id"), + expression=__sql("customer_id"), + dependencies=frozenset(), + kind=ColumnKind.DIMENSION, + ), + ], + fact_columns=[ + Column( + name=normalize_identifier("amount"), + expression=__sql("amount"), + dependencies=frozenset(), + kind=ColumnKind.FACT, + ), + ], + ) + + +def __sql(s: str): + from osi.common.sql_expr import FrozenSQL, parse_sql_expr + + return FrozenSQL.of(parse_sql_expr(s)) + + +# Forward (safe) direction: orders (N) -> customers (1), join on customer_id=id +def test_enrich_n_to_one_succeeds() -> None: + parent = _orders_state() + child = _customers_state() + out = enrich( + parent, + child, + parent_keys=(normalize_identifier("customer_id"),), + child_keys=(normalize_identifier("id"),), + join_type=JoinType.LEFT, + ) + assert out.grain == parent.grain + assert normalize_identifier("region") in out.column_names + + +# Reverse (fan-trap) direction: customers (1) -> orders (N), join on id=customer_id +def test_enrich_one_to_many_reverse_is_fan_trap() -> None: + parent = _customers_state() + child = _orders_state() + with pytest.raises(AlgebraError) as exc: + enrich( + parent, + child, + parent_keys=(normalize_identifier("id"),), + child_keys=(normalize_identifier("customer_id"),), + join_type=JoinType.LEFT, + ) + assert exc.value.code is ErrorCode.E3011_MN_AGGREGATION_REJECTED + + +def test_enrich_rejects_when_child_keys_dont_cover_child_grain() -> None: + """Even with non-PK join keys, child must be unique on them.""" + # Build a child whose grain is {a, b} but join keys are only {a}. + child = source( + primary_key=frozenset({normalize_identifier("a"), normalize_identifier("b")}), + dimension_columns=[ + Column( + name=normalize_identifier("a"), + expression=__sql("a"), + dependencies=frozenset(), + kind=ColumnKind.DIMENSION, + ), + Column( + name=normalize_identifier("b"), + expression=__sql("b"), + dependencies=frozenset(), + kind=ColumnKind.DIMENSION, + ), + Column( + name=normalize_identifier("v"), + expression=__sql("v"), + dependencies=frozenset(), + kind=ColumnKind.DIMENSION, + ), + ], + ) + parent = _orders_state() + with pytest.raises(AlgebraError) as exc: + enrich( + parent, + child, + parent_keys=(normalize_identifier("customer_id"),), + child_keys=(normalize_identifier("a"),), + join_type=JoinType.LEFT, + ) + assert exc.value.code is ErrorCode.E3011_MN_AGGREGATION_REJECTED + + +def test_enrich_rejects_unknown_parent_key() -> None: + parent = _orders_state() + child = _customers_state() + with pytest.raises(AlgebraError) as exc: + enrich( + parent, + child, + parent_keys=(normalize_identifier("nonexistent"),), + child_keys=(normalize_identifier("id"),), + join_type=JoinType.LEFT, + ) + assert exc.value.code is ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + +def test_enrich_rejects_unknown_child_key() -> None: + parent = _orders_state() + child = _customers_state() + with pytest.raises(AlgebraError) as exc: + enrich( + parent, + child, + parent_keys=(normalize_identifier("customer_id"),), + child_keys=(normalize_identifier("nonexistent"),), + join_type=JoinType.LEFT, + ) + assert exc.value.code is ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + +def test_enrich_rejects_mismatched_arity() -> None: + parent = _orders_state() + child = _customers_state() + with pytest.raises(AlgebraError) as exc: + enrich( + parent, + child, + parent_keys=( + normalize_identifier("customer_id"), + normalize_identifier("order_id"), + ), + child_keys=(normalize_identifier("id"),), + join_type=JoinType.LEFT, + ) + assert exc.value.code is ErrorCode.E3006_MISSING_COLUMN_DEPENDENCY + + +# --------------------------------------------------------------------------- +# Planner-level fan-trap detection +# --------------------------------------------------------------------------- + + +_FAN_TRAP_MODEL = textwrap.dedent("""\ + semantic_model: + - name: fan_trap_demo + datasets: + - name: orders + source: sales.orders + primary_key: [order_id] + fields: + - name: order_id + expression: order_id + role: dimension + - name: customer_id + expression: customer_id + role: dimension + - name: amount + expression: amount + role: fact + metrics: + - name: total + expression: SUM(amount) + - name: customers + source: sales.customers + primary_key: [id] + fields: + - name: id + expression: id + role: dimension + - name: region + expression: region + role: dimension + metrics: + - name: customer_count + expression: COUNT(id) + relationships: + - name: orders_to_customers + from: orders + to: customers + from_columns: [customer_id] + to_columns: [id] + """) + + +def _fan_trap_context() -> PlannerContext: + # Per-dataset ``metrics:`` blocks in the fixture are deferred + # under the strict Foundation; opt back in via the legacy- + # permissive flag set so the planner-side fan-trap safety + # contract stays exercised. + result = parse_semantic_model( + _FAN_TRAP_MODEL, flags=FoundationFlags.legacy_permissive() + ) + return PlannerContext( + model=result.model, + namespace=result.namespace, + graph=result.graph, + ) + + +def test_planner_rejects_one_to_many_traversal() -> None: + """Enriching customers with orders (1->N) must error before SQL. + + In TPC-DS terms: ``SELECT region, COUNT(orders.order_id) FROM + customers GROUP BY region`` requires aggregating orders FIRST then + enriching, not enriching customers with orders. + """ + ctx = _fan_trap_context() + # Force the planner into the reverse direction: a query whose + # measure is on customers (so customers becomes the fact root) but + # which references a dimension on orders. Orders is the N-side, so + # bringing it into a customers-rooted state is a fan trap. + query = SemanticQuery( + dimensions=( + Reference( + dataset=normalize_identifier("orders"), + name=normalize_identifier("customer_id"), + ), + ), + measures=( + Reference( + dataset=normalize_identifier("customers"), + name=normalize_identifier("customer_count"), + ), + ), + ) + with pytest.raises(OSIError) as exc: + plan(query, ctx) + # S-9 / D-022: the algebra layer is conservative and raises the + # internal E3011 precondition signal on any fan-trap or N:N edge. + # The planner reclassifies that signal at the user-facing surface: + # for a fan-trap on a 1:N edge (this case) it surfaces as + # E_UNSAFE_REAGGREGATION (plan-shape decomposition failure); for a + # true N:N edge it surfaces as E3012 / E3013 (per-query M:N). + # Neither path raises E3011 user-facing — that code is reserved + # for the engine-capability opt-out (Proposed_OSI_Semantics.md §6.8 + # *Semantic guarantee*). + assert exc.value.code is ErrorCode.E_UNSAFE_REAGGREGATION + + +def test_planner_accepts_n_to_one_traversal() -> None: + """Orders -> customers (N->1) must plan cleanly.""" + ctx = _fan_trap_context() + query = SemanticQuery( + dimensions=( + Reference( + dataset=normalize_identifier("customers"), + name=normalize_identifier("region"), + ), + ), + measures=( + Reference( + dataset=normalize_identifier("orders"), + name=normalize_identifier("total"), + ), + ), + ) + p = plan(query, ctx) + ops = [s.operation.value for s in p.steps] + assert "enrich" in ops + assert "aggregate" in ops diff --git a/impl/python/tests/unit/planning/test_field_staging.py b/impl/python/tests/unit/planning/test_field_staging.py new file mode 100644 index 0000000..097a92a --- /dev/null +++ b/impl/python/tests/unit/planning/test_field_staging.py @@ -0,0 +1,566 @@ +"""Unit tests for inter-field dependency staging in :func:`source_step`. + +These tests pin the planner's contract for fields that reference other +fields on the same dataset (``Proposed_OSI_Semantics.md §4.3``): + +* the planner must lower derived fields into a topologically ordered + chain of ``ADD_COLUMNS`` CTE stages so the emitted SQL never relies + on lateral aliasing within a single ``SELECT``, +* the chain must preserve declaration order *within* each level so + golden SQL stays stable run-to-run, +* a model with no inter-field dependencies must still emit a single + ``SOURCE`` step (no spurious extra CTEs), +* the same staging applies to enrich children — when a child dataset + has derived fields the planner emits the child via ``SOURCE`` + + ``ADD_COLUMNS`` and uses ``EnrichDerived`` so codegen reads the + child columns by name. + +The cycle case is verified by +``tests/unit/parsing/test_field_dependency_cycles.py`` because cycles +are rejected at parse time, not at planning time. +""" + +from __future__ import annotations + +import textwrap + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.errors import ErrorCode, OSIPlanningError +from osi.parsing.parser import parse_semantic_model +from osi.planning import Reference, SemanticQuery, plan +from osi.planning.algebra.state import ColumnKind +from osi.planning.plan import ( + AddColumnsPayload, + EnrichDerivedPayload, + EnrichPayload, + PlanOperation, + PlanStep, +) +from osi.planning.planner_context import PlannerContext + + +def _ctx_from_yaml(yaml_text: str) -> PlannerContext: + """Parse ``yaml_text`` into a planner-ready context.""" + result = parse_semantic_model(textwrap.dedent(yaml_text)) + return PlannerContext( + model=result.model, namespace=result.namespace, graph=result.graph + ) + + +def _ref(name: str, dataset: str | None = None) -> Reference: + """Build a :class:`Reference` from raw strings (mypy-friendly). + + The :class:`Reference` constructor takes :class:`Identifier` + instances; tests historically passed bare strings via the model + parser. This helper centralises the normalisation so the test + bodies stay readable. + """ + dataset_id = normalize_identifier(dataset) if dataset is not None else None + return Reference(dataset=dataset_id, name=normalize_identifier(name)) + + +def _add_columns_levels(steps: tuple[PlanStep, ...]) -> list[set[str]]: + """Return one set-of-names per ADD_COLUMNS step in order. + + Encapsulates the ``isinstance`` narrowing so per-test assertions + can stay focused on the *shape* of the staged plan. + """ + levels: list[set[str]] = [] + for step in steps: + if step.operation is not PlanOperation.ADD_COLUMNS: + continue + payload = step.payload + assert isinstance(payload, AddColumnsPayload) + levels.append({str(d.name) for d in payload.definitions}) + return levels + + +def _add_columns_definitions_in_order( + steps: tuple[PlanStep, ...], +) -> list[list[str]]: + """Return the per-level definition order from each ADD_COLUMNS step.""" + out: list[list[str]] = [] + for step in steps: + if step.operation is not PlanOperation.ADD_COLUMNS: + continue + payload = step.payload + assert isinstance(payload, AddColumnsPayload) + out.append([str(d.name) for d in payload.definitions]) + return out + + +# --------------------------------------------------------------------------- +# No inter-field dependencies — single SOURCE step (regression for the +# common case; staging must not introduce CTEs when none are needed) +# --------------------------------------------------------------------------- + + +def test_no_inter_field_deps__emits_single_source_step() -> None: + """A dataset whose fields project only physical columns yields one SOURCE. + + This is the common case for a star-schema fact table; staging must + not add extra CTEs that would clutter the emitted SQL or churn + every existing golden snapshot. + """ + ctx = _ctx_from_yaml("""\ + name: identity_fields + datasets: + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: amount, expression: amount, role: fact} + metrics: + - name: total_amount + expression: SUM(orders.amount) + """) + plan_result = plan( + SemanticQuery(measures=(_ref("total_amount"),)), + context=ctx, + ) + operations = [step.operation for step in plan_result.steps] + assert operations.count(PlanOperation.SOURCE) == 1 + assert PlanOperation.ADD_COLUMNS not in operations + + +# --------------------------------------------------------------------------- +# Identity projection (``expression == name``) is not a self-cycle +# --------------------------------------------------------------------------- + + +def test_identity_projection__is_not_self_cycle() -> None: + """``{name: id, expression: id}`` is the canonical pass-through shape. + + The bare ``id`` reference resolves to the physical column at the + SOURCE step; treating it as an inter-field dependency on itself + would produce a spurious ``E_FIELD_DEPENDENCY_CYCLE`` rejection. + """ + ctx = _ctx_from_yaml("""\ + name: identity + datasets: + - name: orders + source: orders_table + primary_key: [order_id] + fields: + - {name: order_id, expression: order_id, role: dimension} + - {name: amount, expression: amount, role: fact} + metrics: + - name: total + expression: SUM(orders.amount) + """) + plan_result = plan( + SemanticQuery(measures=(_ref("total"),)), + context=ctx, + ) + source_steps = [ + step for step in plan_result.steps if step.operation is PlanOperation.SOURCE + ] + assert len(source_steps) == 1 + column_names = {str(col.name) for col in source_steps[0].state.columns} + assert column_names == {"order_id", "amount"} + + +# --------------------------------------------------------------------------- +# Linear chain: a → b → c → d (one ADD_COLUMNS per level) +# --------------------------------------------------------------------------- + + +def test_linear_dependency_chain__one_add_columns_per_level() -> None: + """Chained derived fields produce exactly one ADD_COLUMNS per level. + + Pins the staged shape for the canonical chain + ``net = amount - discount`` ⇒ ``net_doubled = net * 2`` ⇒ + ``net_quadrupled = net_doubled * 2``. Each derived field gets + its own CTE so each subsequent ``SELECT`` references a committed + alias from the prior CTE — portable on every dialect. + """ + ctx = _ctx_from_yaml("""\ + name: chain + datasets: + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: amount, expression: amount, role: fact} + - {name: discount, expression: discount, role: fact} + - {name: net, expression: "amount - discount", role: fact} + - {name: net_doubled, expression: "net * 2", role: fact} + - {name: net_quadrupled, expression: "net_doubled * 2", role: fact} + metrics: + - name: total + expression: SUM(orders.net_quadrupled) + """) + plan_result = plan( + SemanticQuery(measures=(_ref("total"),)), + context=ctx, + ) + levels = _add_columns_levels(plan_result.steps) + assert levels == [{"net"}, {"net_doubled"}, {"net_quadrupled"}] + + +# --------------------------------------------------------------------------- +# Branching reuse: c depends on a and b — both placed at the same level +# --------------------------------------------------------------------------- + + +def test_branching_reuse__siblings_share_a_level() -> None: + """Two derived fields at the same depth share one ADD_COLUMNS step. + + ``net = amount - discount`` and ``tax = amount * 0.1`` both + depend only on physical columns and so go on the same level + (level 1). ``total_billable = net + tax`` depends on both and + sits one level deeper. + + Per-level batching matters: a chain of N independent derived + fields should not emit N CTEs when 1 will do (CTE count is a + proxy for query-planner cost on most dialects). + """ + ctx = _ctx_from_yaml("""\ + name: branching + datasets: + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: amount, expression: amount, role: fact} + - {name: discount, expression: discount, role: fact} + - {name: net, expression: "amount - discount", role: fact} + - {name: tax, expression: "amount * 0.1", role: fact} + - {name: total_billable, expression: "net + tax", role: fact} + metrics: + - name: total + expression: SUM(orders.total_billable) + """) + plan_result = plan( + SemanticQuery(measures=(_ref("total"),)), + context=ctx, + ) + levels = _add_columns_levels(plan_result.steps) + assert levels == [{"net", "tax"}, {"total_billable"}] + + +def test_branching_reuse__preserves_declaration_order() -> None: + """Siblings within a level keep their declaration order. + + Stable per-level ordering keeps SQL goldens deterministic across + runs (Kahn's algorithm with hash-set iteration would otherwise + reorder fields by dict insertion order, churning the snapshots + for non-semantic reasons). + """ + ctx = _ctx_from_yaml("""\ + name: ordering + datasets: + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: amount, expression: amount, role: fact} + - {name: zeta, expression: "amount * 1", role: fact} + - {name: alpha, expression: "amount * 2", role: fact} + - {name: middle, expression: "amount * 3", role: fact} + metrics: + - name: total + expression: SUM(orders.alpha) + """) + plan_result = plan( + SemanticQuery(measures=(_ref("total"),)), + context=ctx, + ) + ordered = _add_columns_definitions_in_order(plan_result.steps) + assert ordered == [["zeta", "alpha", "middle"]] + + +# --------------------------------------------------------------------------- +# Window-then-reference: a windowed field referenced by a downstream field +# --------------------------------------------------------------------------- + + +def test_window_field_referenced_downstream__staged_correctly() -> None: + """A window function in field A, then field B references A. + + Window functions are evaluated per-row at the home grain + (``§4.3.1``); a downstream field that uses A's windowed value + in a CASE expression must read A from a committed CTE — embedding + both inline would force ``ROW_NUMBER() OVER (...) AS rn, CASE + WHEN rn = 1 THEN amount ELSE 0 END AS top_only`` which fails on + Snowflake / Postgres / SQLite. + """ + ctx = _ctx_from_yaml("""\ + name: windowed + datasets: + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: customer_id, expression: customer_id, role: dimension} + - {name: amount, expression: amount, role: fact} + - name: rank_in_customer + expression: "ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC)" + role: fact + - name: top_only + expression: "CASE WHEN rank_in_customer = 1 THEN amount ELSE 0 END" + role: fact + metrics: + - name: total_top + expression: SUM(orders.top_only) + """) + plan_result = plan( + SemanticQuery(measures=(_ref("total_top"),)), + context=ctx, + ) + levels = _add_columns_levels(plan_result.steps) + assert levels == [{"rank_in_customer"}, {"top_only"}] + + +# --------------------------------------------------------------------------- +# Materialised-derived deps: post-ADD_COLUMNS state must carry empty deps +# --------------------------------------------------------------------------- + + +def test_materialised_derived__has_empty_dependencies() -> None: + """Once a derived field is materialised in a CTE, its deps are stripped. + + Downstream operators (AGGREGATE, MERGE, ENRICH) project columns + by name from the prior CTE; the dependency list on the staged + column is no longer needed and would trip the + ``CalculationState`` validator (``E3006``) in any operator that + drops the upstream physical columns. + """ + ctx = _ctx_from_yaml("""\ + name: deps + datasets: + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: amount, expression: amount, role: fact} + - {name: doubled, expression: "amount * 2", role: fact} + metrics: + - name: total + expression: SUM(orders.doubled) + """) + plan_result = plan( + SemanticQuery(measures=(_ref("total"),)), + context=ctx, + ) + add_columns_step = next( + s for s in plan_result.steps if s.operation is PlanOperation.ADD_COLUMNS + ) + doubled = next( + c for c in add_columns_step.state.columns if str(c.name) == "doubled" + ) + assert doubled.dependencies == frozenset() + + +# --------------------------------------------------------------------------- +# Enrich child with derived fields → staged path (EnrichDerived) +# --------------------------------------------------------------------------- + + +def test_enrich_child_with_derived_fields__uses_enrich_derived() -> None: + """A many-to-one child with derived fields stages via ENRICH_DERIVED. + + Inlining the child as ``JOIN raw_table`` would require lateral + aliasing in the join's ``SELECT``. Staging the child as + ``SOURCE`` + ``ADD_COLUMNS`` and using ``EnrichDerivedPayload`` + routes codegen to project child columns *by name* (see + ``transpiler._render_enrich_derived``) — portable on every + dialect. + """ + ctx = _ctx_from_yaml("""\ + name: enrich_staged + datasets: + - name: customers + source: customers_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: first_name, expression: first_name, role: dimension} + - {name: last_name, expression: last_name, role: dimension} + - name: full_name + expression: "first_name || ' ' || last_name" + role: dimension + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: customer_id, expression: customer_id, role: dimension} + - {name: amount, expression: amount, role: fact} + relationships: + - name: orders_to_customers + from: orders + to: customers + from_columns: [customer_id] + to_columns: [id] + metrics: + - name: total + expression: SUM(orders.amount) + """) + plan_result = plan( + SemanticQuery( + dimensions=(_ref("full_name", dataset="customers"),), + measures=(_ref("total"),), + ), + context=ctx, + ) + enrich_steps = [ + step for step in plan_result.steps if step.operation is PlanOperation.ENRICH + ] + assert len(enrich_steps) == 1 + payload = enrich_steps[0].payload + assert isinstance(payload, EnrichDerivedPayload) + assert len(enrich_steps[0].inputs) == 2 + + +def test_enrich_child_without_derived_fields__uses_inline_enrich() -> None: + """A many-to-one child whose fields are all identity stays inline. + + Pins the no-staging-needed regression: enriches that *don't* need + extra CTEs must still emit the compact single-step ``ENRICH`` + shape (``EnrichPayload`` with ``child_source`` set), preserving + the historical golden output on the common case. + """ + ctx = _ctx_from_yaml("""\ + name: enrich_inline + datasets: + - name: customers + source: customers_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: name, expression: name, role: dimension} + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: customer_id, expression: customer_id, role: dimension} + - {name: amount, expression: amount, role: fact} + relationships: + - name: orders_to_customers + from: orders + to: customers + from_columns: [customer_id] + to_columns: [id] + metrics: + - name: total + expression: SUM(orders.amount) + """) + plan_result = plan( + SemanticQuery( + dimensions=(_ref("name", dataset="customers"),), + measures=(_ref("total"),), + ), + context=ctx, + ) + enrich_steps = [ + step for step in plan_result.steps if step.operation is PlanOperation.ENRICH + ] + assert len(enrich_steps) == 1 + payload = enrich_steps[0].payload + assert isinstance(payload, EnrichPayload) + assert payload.child_source == "customers_table" + + +# --------------------------------------------------------------------------- +# Cross-dataset (qualified) field references do *not* count as same-dataset +# inter-field deps (regression: don't accidentally treat ``customers.region`` +# as a sibling-of-``orders`` reference and create an impossible dependency) +# --------------------------------------------------------------------------- + + +def test_qualified_cross_dataset_reference__not_treated_as_inter_field_dep() -> None: + """A qualified ref names a column on another dataset, not a sibling. + + The enrichment planner resolves qualified references through the + relationship graph. Mistakenly treating + ``customers.region`` as a same-dataset dep on + ``orders`` would either force a spurious ``ADD_COLUMNS`` stage + on ``orders`` or trip the parser-side cycle check when the + referenced name isn't defined locally. + """ + ctx = _ctx_from_yaml("""\ + name: qualified_ref + datasets: + - name: customers + source: customers_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: region, expression: region, role: dimension} + - name: orders + source: orders_table + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: customer_id, expression: customer_id, role: dimension} + - {name: amount, expression: amount, role: fact} + relationships: + - name: orders_to_customers + from: orders + to: customers + from_columns: [customer_id] + to_columns: [id] + metrics: + - name: total + expression: SUM(orders.amount) + """) + plan_result = plan( + SemanticQuery( + dimensions=(_ref("region", dataset="customers"),), + measures=(_ref("total"),), + ), + context=ctx, + ) + levels = _add_columns_levels(plan_result.steps) + assert levels == [] + + +# --------------------------------------------------------------------------- +# Defensive: planner-internal cycle check (parser should catch it first) +# --------------------------------------------------------------------------- + + +def test_planner_topo_sort__defends_against_cycles() -> None: + """Direct unit test for ``_topo_levels_by_dependency`` cycle handling. + + Cycles are rejected at parse time so this code path is normally + unreachable, but we keep the defensive check because the planner + is not a trust boundary; an upstream regression that disabled the + parser check should produce a loud failure rather than a silently + wrong plan. + """ + import sqlglot + + from osi.common.identifiers import normalize_identifier + from osi.common.sql_expr import FrozenSQL + from osi.planning.algebra.state import Column + from osi.planning.steps import _topo_levels_by_dependency + + name_a = normalize_identifier("a") + name_b = normalize_identifier("b") + expr = FrozenSQL.of(sqlglot.parse_one("1")) + a = Column( + name=name_a, + expression=expr, + dependencies=frozenset({name_b}), + kind=ColumnKind.FACT, + ) + b = Column( + name=name_b, + expression=expr, + dependencies=frozenset({name_a}), + kind=ColumnKind.FACT, + ) + with pytest.raises(OSIPlanningError) as excinfo: + _topo_levels_by_dependency((a, b)) + assert excinfo.value.code is ErrorCode.E_FIELD_DEPENDENCY_CYCLE diff --git a/impl/python/tests/unit/planning/test_home_grain.py b/impl/python/tests/unit/planning/test_home_grain.py new file mode 100644 index 0000000..26a6524 --- /dev/null +++ b/impl/python/tests/unit/planning/test_home_grain.py @@ -0,0 +1,278 @@ +"""D-003 + D-015 implicit home-grain aggregation rewrite. + +Pins the parser-side AST → AST rewrite that turns a home-grain field +body that aggregates a finer-grained dataset into a correlated +subquery. + +Three classes of test: + +1. **Rewrite shape**: the rewritten ``FrozenSQL`` is structurally a + correlated ``SELECT … WHERE foreign.fk = home.pk``. +2. **Equivalence (D-015)**: two semantically equivalent model + formulations (the implicit form + the explicit correlated + subquery) plan to the same generated SQL after the rewrite. +3. **Cleanliness**: fields that don't reference any foreign dataset + are returned untouched; the rewrite is a no-op in the absence of + cross-grain aggregates. +""" + +from __future__ import annotations + +import pytest +import sqlglot +from sqlglot import expressions as exp + +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.parsing.graph import build_graph +from osi.parsing.models import Dataset, Field, Relationship, SemanticModel +from osi.planning.home_grain import rewrite_field_for_home_grain + + +def _customers() -> Dataset: + return Dataset( + name="customers", + source="customers", + primary_key=["id"], + fields=[ + Field(name="id", expression="id"), + Field(name="region", expression="region"), + ], + ) + + +def _orders() -> Dataset: + return Dataset( + name="orders", + source="orders", + primary_key=["id"], + fields=[ + Field(name="id", expression="id"), + Field(name="customer_id", expression="customer_id"), + Field(name="status", expression="status"), + Field(name="amount", expression="amount", role="fact"), + ], + ) + + +def _model(extra_field: Field) -> tuple[SemanticModel, dict]: + customers = _customers().model_copy( + update={"fields": (*_customers().fields, extra_field)} + ) + model = SemanticModel( + name="m", + datasets=[customers, _orders()], + relationships=[ + Relationship.model_validate( + { + "name": "orders_to_customer", + "from": "orders", + "to": "customers", + "from_columns": ["customer_id"], + "to_columns": ["id"], + } + ) + ], + ) + by_name = {d.name: d for d in model.datasets} + return model, by_name + + +class TestRewriteShape: + """The rewrite produces a correlated subquery with the right shape.""" + + def test_top_level_sum_wraps_in_correlated_subquery(self) -> None: + field = Field(name="lifetime_value", expression="SUM(orders.amount)") + model, by_name = _model(field) + graph = build_graph(model) + rewritten = rewrite_field_for_home_grain( + field, home=model.datasets[0].name, graph=graph, datasets_by_name=by_name + ) + assert isinstance(rewritten.expr, exp.Subquery) + inner = rewritten.expr.this + assert isinstance(inner, exp.Select) + # Body still has the SUM + sums = list(inner.find_all(exp.Sum)) + assert len(sums) == 1 + # FROM is the foreign dataset's physical source + from_clause = inner.args["from"] + assert isinstance(from_clause, exp.From) + assert str(from_clause.this).lower() == "orders" + # WHERE is the correlation predicate + where = inner.args["where"] + assert isinstance(where, exp.Where) + assert isinstance(where.this, exp.EQ) + eq = where.this + assert eq.this.table == "orders" + assert eq.this.name == "customer_id" + assert eq.expression.table == "customers" + assert eq.expression.name == "id" + + def test_nested_aggregate_in_boolean_expression_wrapped(self) -> None: + field = Field( + name="has_completed", + expression=( + "COUNT(CASE WHEN orders.status = 'completed' THEN orders.id END) > 0" + ), + ) + model, by_name = _model(field) + graph = build_graph(model) + rewritten = rewrite_field_for_home_grain( + field, home=model.datasets[0].name, graph=graph, datasets_by_name=by_name + ) + # Top is still a comparison; the COUNT was replaced with a Subquery. + assert isinstance(rewritten.expr, exp.GT) + left = rewritten.expr.this + assert isinstance(left, exp.Subquery) + # The right-hand side is still the literal 0. + right = rewritten.expr.expression + assert isinstance(right, exp.Literal) + assert right.name == "0" + + +class TestEquivalence: + """D-015: two equivalent formulations plan to identical SQL. + + The spec admits three execution shapes (correlated subquery, + LATERAL, pre-agg CTE) so long as each produces the same scalar + per home-grain row. We pin shape #1 (correlated subquery) and + cross-check that the same observable behaviour holds for the + handcrafted equivalent. + """ + + def test_rewrite_matches_handwritten_correlated_subquery(self) -> None: + implicit_field = Field(name="ltv", expression="SUM(orders.amount)") + model, by_name = _model(implicit_field) + graph = build_graph(model) + rewritten = rewrite_field_for_home_grain( + implicit_field, + home=model.datasets[0].name, + graph=graph, + datasets_by_name=by_name, + ) + + explicit_sql = ( + "(SELECT SUM(orders.amount) FROM orders " + "WHERE orders.customer_id = customers.id)" + ) + explicit = FrozenSQL.of(sqlglot.parse_one(explicit_sql)) + assert rewritten.canonical == explicit.canonical + + def test_count_distinct_pk_round_trips(self) -> None: + implicit_field = Field( + name="distinct_orders", expression="COUNT(DISTINCT orders.id)" + ) + model, by_name = _model(implicit_field) + graph = build_graph(model) + rewritten = rewrite_field_for_home_grain( + implicit_field, + home=model.datasets[0].name, + graph=graph, + datasets_by_name=by_name, + ) + explicit = FrozenSQL.of( + sqlglot.parse_one( + "(SELECT COUNT(DISTINCT orders.id) FROM orders " + "WHERE orders.customer_id = customers.id)" + ) + ) + assert rewritten.canonical == explicit.canonical + + def test_min_and_max_round_trip(self) -> None: + for fn in ("MIN", "MAX"): + implicit_field = Field( + name=f"first_amount_{fn.lower()}", + expression=f"{fn}(orders.amount)", + ) + model, by_name = _model(implicit_field) + graph = build_graph(model) + rewritten = rewrite_field_for_home_grain( + implicit_field, + home=model.datasets[0].name, + graph=graph, + datasets_by_name=by_name, + ) + explicit = FrozenSQL.of( + sqlglot.parse_one( + f"(SELECT {fn}(orders.amount) FROM orders " + "WHERE orders.customer_id = customers.id)" + ) + ) + assert rewritten.canonical == explicit.canonical + + +class TestNoOp: + """Fields without cross-grain aggregates pass through untouched.""" + + def test_bare_column_field_is_unchanged(self) -> None: + field = Field(name="segment", expression="segment") + model, by_name = _model(field) + graph = build_graph(model) + out = rewrite_field_for_home_grain( + field, home=model.datasets[0].name, graph=graph, datasets_by_name=by_name + ) + assert out is field.expression + + def test_aggregate_over_home_columns_only_is_unchanged(self) -> None: + # SUM(amount) where amount is a home-grain column. Foundation + # accepts this *only* in a Measures context; field-side it is + # an unusual shape but the rewrite leaves it untouched (no + # foreign reference to wrap). + field = Field( + name="amount_sum_local", + expression="SUM(amount)", + ) + # We need amount to live on the *home* dataset for this case. + home = Dataset( + name="home", + source="home", + primary_key=["id"], + fields=[ + Field(name="id", expression="id"), + Field(name="amount", expression="amount", role="fact"), + field, + ], + ) + model = SemanticModel(name="m", datasets=[home]) + by_name = {d.name: d for d in model.datasets} + graph = build_graph(model) + out = rewrite_field_for_home_grain( + field, home=home.name, graph=graph, datasets_by_name=by_name + ) + assert out is field.expression + + +def test_normalised_identifier_used_for_correlation() -> None: + """The rewrite is robust to identifier casing in the field expression.""" + field = Field(name="ltv", expression="SUM(Orders.Amount)") + model, by_name = _model(field) + graph = build_graph(model) + rewritten = rewrite_field_for_home_grain( + field, + home=model.datasets[0].name, + graph=graph, + datasets_by_name=by_name, + ) + assert isinstance(rewritten.expr, exp.Subquery) + where = rewritten.expr.this.args["where"] + eq = where.this + # Foundation normalises identifiers case-insensitively (§3.1). + assert eq.this.table.lower() == "orders" + assert eq.expression.table.lower() == "customers" + + +@pytest.mark.parametrize("agg", ["SUM", "COUNT", "MIN", "MAX", "COUNT(DISTINCT"]) +def test_every_supported_aggregate_rewrites(agg: str) -> None: + """Smoke: every Foundation-supported aggregate is wrapped, not skipped.""" + expr = ( + "COUNT(DISTINCT orders.id)" + if agg == "COUNT(DISTINCT" + else f"{agg}(orders.amount)" + ) + field = Field(name=f"f_{normalize_identifier(agg.split('(')[0])}", expression=expr) + model, by_name = _model(field) + graph = build_graph(model) + rewritten = rewrite_field_for_home_grain( + field, home=model.datasets[0].name, graph=graph, datasets_by_name=by_name + ) + assert isinstance(rewritten.expr, exp.Subquery) diff --git a/impl/python/tests/unit/planning/test_joins.py b/impl/python/tests/unit/planning/test_joins.py new file mode 100644 index 0000000..0ac6b12 --- /dev/null +++ b/impl/python/tests/unit/planning/test_joins.py @@ -0,0 +1,137 @@ +"""Unit tests for :mod:`osi.planning.joins`. + +Exercises the path-finding helper :func:`find_enrichment_path` end-to-end: +N:1 success, unreachable target, ambiguous join path, and N:N rejection. +Error codes asserted: ``E2004`` (unreachable), ``E3001`` (ambiguous), +``E3011`` (M:N). +""" + +from __future__ import annotations + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.errors import ErrorCode, OSIPlanningError +from osi.planning.algebra.operations import JoinType +from osi.planning.joins import JoinStep, find_enrichment_path +from tests.unit.planning.fixtures import mn_context, orders_context + + +class TestFindEnrichmentPath: + def test_empty_targets_return_no_steps(self) -> None: + ctx = orders_context() + steps = find_enrichment_path( + root=normalize_identifier("orders"), + targets=frozenset(), + graph=ctx.graph, + ) + assert steps == () + + def test_root_in_targets_is_not_planned(self) -> None: + ctx = orders_context() + steps = find_enrichment_path( + root=normalize_identifier("orders"), + targets=frozenset({normalize_identifier("orders")}), + graph=ctx.graph, + ) + assert steps == () + + def test_single_hop_N_TO_ONE(self) -> None: + ctx = orders_context() + steps = find_enrichment_path( + root=normalize_identifier("orders"), + targets=frozenset({normalize_identifier("customers")}), + graph=ctx.graph, + ) + assert len(steps) == 1 + step = steps[0] + assert isinstance(step, JoinStep) + assert step.parent == normalize_identifier("orders") + assert step.child == normalize_identifier("customers") + # S-1: referential_integrity is deferred (Foundation v0.1 + # uses the LEFT default per D-001 / D-004 — see S-7). Without + # the RI hint the enrichment path defaults to LEFT for every + # N:1 enrichment. + assert step.join_type is JoinType.LEFT + + def test_left_join_for_returns_to_customers(self) -> None: + ctx = orders_context() + steps = find_enrichment_path( + root=normalize_identifier("returns"), + targets=frozenset({normalize_identifier("customers")}), + graph=ctx.graph, + ) + assert steps[0].join_type is JoinType.LEFT + + def test_fan_trap_path_raises_E3011(self) -> None: + """Surface a reachable-but-unsafe path as ``E3011``. + + ``orders → customers → returns`` has a path through customers, + but the second hop is the 1→N reverse of ``returns_to_customers`` + (a fan trap). The planner surfaces this as ``E3011`` rather than + ``E2004``: the target is reachable, it just isn't safely + enrichable. + """ + ctx = orders_context() + with pytest.raises(OSIPlanningError) as excinfo: + find_enrichment_path( + root=normalize_identifier("orders"), + targets=frozenset({normalize_identifier("returns")}), + graph=ctx.graph, + ) + assert excinfo.value.code is ErrorCode.E3011_MN_AGGREGATION_REJECTED + + def test_truly_unreachable_target_E2004(self) -> None: + """Raise ``E2004`` when no relationships connect root to target.""" + import textwrap + + from osi.parsing.graph import build_graph + from osi.parsing.parser import parse_semantic_model + + disconnected = textwrap.dedent(""" + semantic_model: + - name: m + datasets: + - name: a + source: s.a + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - name: b + source: s.b + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + """) + parsed = parse_semantic_model(disconnected) + graph = build_graph(parsed.model) + with pytest.raises(OSIPlanningError) as excinfo: + find_enrichment_path( + root=normalize_identifier("a"), + targets=frozenset({normalize_identifier("b")}), + graph=graph, + ) + assert excinfo.value.code is ErrorCode.E2004_UNREACHABLE_DATASET + + def test_M_N_edge_rejected_E3012(self) -> None: + """Declared N:N with no bridge / stitch / EXISTS_IN → ``E3012``. + + Per ``Proposed_OSI_Semantics.md §6.8 Semantic guarantee`` the + user-facing per-query M:N failure surface for an M:N-supporting + engine is ``E3012_MN_NO_SAFE_REWRITE`` (or ``E3013`` for the + two-fact stitch case). ``E3011`` is reserved for the + engine-capability opt-out (vendor declaring no M:N support at + all) and never appears at the user-facing surface for the + reference implementation. The classifier upgrades the algebra-internal + ``E3011`` precondition signal to ``E3012`` whenever the unsafe + edge is N:N, surfacing the actionable resolution routes in the + error message. + """ + ctx = mn_context() + with pytest.raises(OSIPlanningError) as excinfo: + find_enrichment_path( + root=normalize_identifier("grade_logs"), + targets=frozenset({normalize_identifier("courses")}), + graph=ctx.graph, + ) + assert excinfo.value.code is ErrorCode.E3012_MN_NO_SAFE_REWRITE diff --git a/impl/python/tests/unit/planning/test_metric_shape.py b/impl/python/tests/unit/planning/test_metric_shape.py new file mode 100644 index 0000000..66226ca --- /dev/null +++ b/impl/python/tests/unit/planning/test_metric_shape.py @@ -0,0 +1,374 @@ +"""Tests for :mod:`osi.planning.metric_shape`. + +Covers: + +* top-level aggregate detection (``SUM``, ``COUNT``, ``COUNT(*)``, + ``COUNT(DISTINCT …)``, ``MIN``, ``MAX``, ``AVG``) +* composite classification (metric-refs-metric) with qualified and + bare leaves +* rejections: undeclared leaves, nested aggregates inside composites, + mixed-dataset base aggregates (tested via the planner — here we only + check the classifier) +* end-to-end plan output: composite metric produces AGGREGATE + then + ADD_COLUMNS with the derived expression + +The classifier plus end-to-end composite planning together address +``Proposed_OSI_Semantics.md §5.4``. +""" + +from __future__ import annotations + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.config import FoundationFlags +from osi.errors import ErrorCode, OSIPlanningError +from osi.parsing.parser import parse_semantic_model +from osi.planning import PlanOperation, Reference, SemanticQuery, plan +from osi.planning.algebra.state import AggregateFunction +from osi.planning.metric_shape import AggregateMetric, CompositeMetric, classify_metric +from osi.planning.planner_context import PlannerContext + +_MODEL_TEMPLATE = """\ +semantic_model: + - name: demo + dialect: ANSI_SQL + datasets: + - name: orders + source: sales.orders + primary_key: [order_id] + fields: + - {name: order_id, expression: order_id, role: dimension} + - {name: customer_id, expression: customer_id, role: dimension} + - {name: status, expression: status, role: dimension} + - {name: amount, expression: amount, role: fact} + metrics: + - {name: total_revenue, expression: SUM(amount)} + - {name: order_count, expression: COUNT(order_id)} + - {name: distinct_customers, + expression: COUNT(DISTINCT customer_id)} + - {name: max_amount, expression: MAX(amount)} + - name: customers + source: sales.customers + primary_key: [id] + fields: + - {name: id, expression: id, role: dimension} + - {name: region, expression: region, role: dimension} + relationships: + - name: orders_to_customers + from: orders + to: customers + from_columns: [customer_id] + to_columns: [id] +__METRICS__""" + + +def _model(extra_metrics: str = "") -> PlannerContext: + text = _MODEL_TEMPLATE.replace("__METRICS__", extra_metrics) + # The fixture uses per-dataset ``metrics:`` blocks, deferred under + # the strict Foundation. Opt back in via the legacy-permissive + # flag set so the planner-side metric_shape contract stays + # exercised. + parsed = parse_semantic_model(text, flags=FoundationFlags.legacy_permissive()) + return PlannerContext( + model=parsed.model, + namespace=parsed.namespace, + graph=parsed.graph, + ) + + +def _metric_by_name(ctx: PlannerContext, name: str): + for m in ctx.model.metrics: + if m.name == normalize_identifier(name): + return m + for ds in ctx.model.datasets: + for m in ds.metrics: + if m.name == normalize_identifier(name): + return m + raise AssertionError(f"metric {name} not found") + + +# --------------------------------------------------------------------------- +# Aggregate detection +# --------------------------------------------------------------------------- + + +class TestAggregateDetection: + def test_sum(self) -> None: + ctx = _model() + shape = classify_metric(_metric_by_name(ctx, "total_revenue"), ctx.namespace) + assert isinstance(shape, AggregateMetric) + assert shape.function is AggregateFunction.SUM + + def test_count(self) -> None: + ctx = _model() + shape = classify_metric(_metric_by_name(ctx, "order_count"), ctx.namespace) + assert isinstance(shape, AggregateMetric) + assert shape.function is AggregateFunction.COUNT + + def test_count_distinct(self) -> None: + ctx = _model() + shape = classify_metric( + _metric_by_name(ctx, "distinct_customers"), ctx.namespace + ) + assert isinstance(shape, AggregateMetric) + assert shape.function is AggregateFunction.COUNT_DISTINCT + + def test_max(self) -> None: + ctx = _model() + shape = classify_metric(_metric_by_name(ctx, "max_amount"), ctx.namespace) + assert isinstance(shape, AggregateMetric) + assert shape.function is AggregateFunction.MAX + + +# --------------------------------------------------------------------------- +# Composite detection +# --------------------------------------------------------------------------- + +_COMPOSITE_MODEL_MODEL_SCOPED = """\ + metrics: + - {name: avg_order_value, + expression: "total_revenue / NULLIF(order_count, 0)"} +""" + +_COMPOSITE_MODEL_QUALIFIED = """\ + metrics: + - {name: avg_order_value, + expression: "orders.total_revenue / NULLIF(orders.order_count, 0)"} +""" + +_COMPOSITE_MODEL_NESTED = """\ + metrics: + - {name: avg_order_value, + expression: "total_revenue / NULLIF(order_count, 0)"} + - {name: avg_doubled, + expression: "2 * avg_order_value"} +""" + + +class TestCompositeDetection: + def test_bare_leaves(self) -> None: + ctx = _model(extra_metrics=_COMPOSITE_MODEL_MODEL_SCOPED) + shape = classify_metric(_metric_by_name(ctx, "avg_order_value"), ctx.namespace) + assert isinstance(shape, CompositeMetric) + names = [r.name for r in shape.references] + assert normalize_identifier("total_revenue") in names + assert normalize_identifier("order_count") in names + + def test_qualified_leaves(self) -> None: + ctx = _model(extra_metrics=_COMPOSITE_MODEL_QUALIFIED) + shape = classify_metric(_metric_by_name(ctx, "avg_order_value"), ctx.namespace) + assert isinstance(shape, CompositeMetric) + assert all( + r.dataset == normalize_identifier("orders") for r in shape.references + ) + + def test_composite_referencing_composite(self) -> None: + """Accept a composite that references another composite. + + The planner recursively expands until it reaches base aggregates. + """ + ctx = _model(extra_metrics=_COMPOSITE_MODEL_NESTED) + shape = classify_metric(_metric_by_name(ctx, "avg_doubled"), ctx.namespace) + assert isinstance(shape, CompositeMetric) + + def test_bare_leaf_that_is_not_a_metric_raises_E1206(self) -> None: + bogus = """\ + metrics: + - {name: broken, + expression: "amount / 2"} +""" + ctx = _model(extra_metrics=bogus) + with pytest.raises(OSIPlanningError) as excinfo: + classify_metric(_metric_by_name(ctx, "broken"), ctx.namespace) + assert excinfo.value.code is ErrorCode.E1206_METRIC_IN_RAW_AGGREGATE + + def test_nested_aggregate_inside_composite_raises_E1206(self) -> None: + bogus = """\ + metrics: + - {name: broken, + expression: "SUM(amount) / order_count"} +""" + ctx = _model(extra_metrics=bogus) + with pytest.raises(OSIPlanningError) as excinfo: + classify_metric(_metric_by_name(ctx, "broken"), ctx.namespace) + assert excinfo.value.code is ErrorCode.E1206_METRIC_IN_RAW_AGGREGATE + + +# --------------------------------------------------------------------------- +# Whitelisted-but-unsupported top-level aggregates (Phase 9 P1, 8a I1) +# --------------------------------------------------------------------------- + + +class TestUnsupportedTopLevelAggregate: + """Reject whitelisted-but-unimplemented aggregates with a clear error. + + The OSI_SQL_2026 parse whitelist names aggregate functions that + the planner does not yet lower (``MEDIAN``, ``STDDEV``, + ``PERCENTILE_CONT``, …). Without an explicit rejection, the + composite-classification path mistakes them for non-aggregate + expressions and raises the misleading + ``E1206_METRIC_IN_RAW_AGGREGATE``. ``classify_metric`` now rejects + these up front with ``E1208_UNSUPPORTED_SQL_CONSTRUCT`` and names + the offending function in ``error.context["function"]``. + """ + + @pytest.mark.parametrize( + ("expression", "function_name"), + [ + ("MEDIAN(amount)", "MEDIAN"), + ("STDDEV(amount)", "STDDEV"), + ("VARIANCE(amount)", "VARIANCE"), + ("APPROX_COUNT_DISTINCT(amount)", "APPROXDISTINCT"), + ], + ) + def test_top_level_unsupported_aggregate_raises_E1208( + self, expression: str, function_name: str + ) -> None: + bogus = f"""\ + metrics: + - {{name: broken, + expression: "{expression}"}} +""" + ctx = _model(extra_metrics=bogus) + with pytest.raises(OSIPlanningError) as excinfo: + classify_metric(_metric_by_name(ctx, "broken"), ctx.namespace) + assert excinfo.value.code is ErrorCode.E1208_UNSUPPORTED_SQL_CONSTRUCT + assert excinfo.value.context["metric"] == normalize_identifier("broken") + assert excinfo.value.context["function"] == function_name + assert "OSI_SQL_2026" in str(excinfo.value) + + def test_supported_aggregates_still_classify_normally(self) -> None: + """SUM / COUNT / MIN / MAX / AVG must remain unchanged.""" + ctx = _model() + for name in ("total_revenue", "order_count", "max_amount"): + shape = classify_metric(_metric_by_name(ctx, name), ctx.namespace) + assert isinstance(shape, AggregateMetric) + + def test_count_distinct_still_classifies_as_count(self) -> None: + """``COUNT(DISTINCT x)`` must reach the supported branch. + + It is wrapped under ``exp.Count`` and so must not be diverted to + the new rejection path. + """ + ctx = _model() + shape = classify_metric( + _metric_by_name(ctx, "distinct_customers"), ctx.namespace + ) + assert isinstance(shape, AggregateMetric) + assert shape.function is AggregateFunction.COUNT_DISTINCT + + +class TestWindowedMetricRoot: + """F-16: windowed metric bodies surface a precise engine-gap code. + + The Foundation spec accepts direct use of a windowed metric in + ``Measures`` (§6.10 / D-031); this engine's aggregation planner + does not yet implement that surface. Before F-16 the metric fell + into the composite path and raised the misleading + ``E1206_METRIC_IN_RAW_AGGREGATE``. ``classify_metric`` now rejects + these up front with ``E_WINDOWED_MEASURE_NOT_SUPPORTED``. + """ + + @pytest.mark.parametrize( + "expression", + [ + "ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY amount DESC)", + "RANK() OVER (PARTITION BY customer_id ORDER BY amount DESC)", + "SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)", + ], + ) + def test_windowed_root_raises_engine_gap_code(self, expression: str) -> None: + bogus = f"""\ + metrics: + - {{name: windowed_metric, + expression: "{expression}"}} +""" + ctx = _model(extra_metrics=bogus) + with pytest.raises(OSIPlanningError) as excinfo: + classify_metric(_metric_by_name(ctx, "windowed_metric"), ctx.namespace) + assert excinfo.value.code is ErrorCode.E_WINDOWED_MEASURE_NOT_SUPPORTED + assert excinfo.value.context["metric"] == normalize_identifier( + "windowed_metric" + ) + assert excinfo.value.context["shape"] == "windowed" + # Diagnostic must steer the author toward the scalar path or + # a plain aggregate, never the misleading composite-shape + # remediation. + msg = str(excinfo.value) + assert "scalar" in msg.lower() or "Fields" in msg + + +# --------------------------------------------------------------------------- +# End-to-end composite planning +# --------------------------------------------------------------------------- + + +class TestCompositeMetricPlan: + def _query( + self, + ctx: PlannerContext, + *measures: str, + dims: tuple[tuple[str | None, str], ...] = ((None, "region"),), + ) -> SemanticQuery: + return SemanticQuery( + dimensions=tuple( + Reference( + dataset=normalize_identifier(d) if d else None, + name=normalize_identifier(n), + ) + for d, n in dims + ), + measures=tuple( + Reference(dataset=None, name=normalize_identifier(m)) for m in measures + ), + ) + + def test_plan_composite_adds_base_aggregates_and_derived_step(self) -> None: + ctx = _model(extra_metrics=_COMPOSITE_MODEL_MODEL_SCOPED) + q = self._query(ctx, "avg_order_value", dims=((None, "region"),)) + p = plan(q, ctx) + agg = next(s for s in p.steps if s.operation is PlanOperation.AGGREGATE) + agg_names = {c.name for c in agg.payload.aggregations} + assert normalize_identifier("total_revenue") in agg_names + assert normalize_identifier("order_count") in agg_names + assert any(s.operation is PlanOperation.ADD_COLUMNS for s in p.steps) + assert p.output_columns == ( + normalize_identifier("region"), + normalize_identifier("avg_order_value"), + ) + + def test_plan_composite_plus_base_deduplicates_aggregate_set(self) -> None: + ctx = _model(extra_metrics=_COMPOSITE_MODEL_MODEL_SCOPED) + q = self._query( + ctx, + "avg_order_value", + "total_revenue", + dims=((None, "region"),), + ) + p = plan(q, ctx) + agg = next(s for s in p.steps if s.operation is PlanOperation.AGGREGATE) + agg_names = [c.name for c in agg.payload.aggregations] + assert agg_names.count(normalize_identifier("total_revenue")) == 1 + + def test_plan_composite_of_composite(self) -> None: + ctx = _model(extra_metrics=_COMPOSITE_MODEL_NESTED) + q = self._query(ctx, "avg_doubled", dims=((None, "region"),)) + p = plan(q, ctx) + agg = next(s for s in p.steps if s.operation is PlanOperation.AGGREGATE) + agg_names = {c.name for c in agg.payload.aggregations} + # The transitive base-aggregate set must include both roots. + assert normalize_identifier("total_revenue") in agg_names + assert normalize_identifier("order_count") in agg_names + + def test_composite_leaf_not_a_metric_raises_E1206_at_plan_time(self) -> None: + bogus = """\ + metrics: + - {name: broken, + expression: "amount / 2"} +""" + ctx = _model(extra_metrics=bogus) + q = self._query(ctx, "broken", dims=((None, "region"),)) + with pytest.raises(OSIPlanningError) as excinfo: + plan(q, ctx) + assert excinfo.value.code is ErrorCode.E1206_METRIC_IN_RAW_AGGREGATE diff --git a/impl/python/tests/unit/planning/test_nested_aggregate.py b/impl/python/tests/unit/planning/test_nested_aggregate.py new file mode 100644 index 0000000..855104a --- /dev/null +++ b/impl/python/tests/unit/planning/test_nested_aggregate.py @@ -0,0 +1,102 @@ +"""Nested cross-grain aggregate planner (D-020 + D-024) — unit tests. + +Pins the small surface of :mod:`osi.planning.planner_nested` so the +nested-aggregate routing in :mod:`osi.planning.planner` keeps its +contract: + +* shape detection — only two-level aggregate-of-aggregate qualifies; +* parsing — outer/inner fns and inner argument come back as-stored; +* intermediate-grain inference — uses unique safe N:1 join keys plus + any query dim columns addressable on the post-enrichment state. + +These are the contracts the multi-step plan in `planner._build_measure_group` +relies on; they are kept here (not in compliance) so a regression +shows up as a failed unit test, not a silent SQL diff. +""" + +from __future__ import annotations + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.parsing.models import Metric +from osi.planning.algebra.state import AggregateFunction +from osi.planning.planner_nested import ( + is_nested_aggregate, + parse_nested, +) + + +def _metric(expr: str) -> Metric: + return Metric(name="m", expression=expr) + + +class TestShapeDetection: + @pytest.mark.parametrize( + "expr", + [ + "AVG(AVG(orders.amount))", + "SUM(MAX(orders.amount))", + "MAX(SUM(orders.qty))", + "AVG(COUNT(orders.id))", + ], + ) + def test_two_level_nested_is_detected(self, expr: str) -> None: + assert is_nested_aggregate(_metric(expr)) + + @pytest.mark.parametrize( + "expr", + [ + "SUM(orders.amount)", + "orders.amount", + "orders.amount + 1", + "AVG(orders.amount + 1)", + "AVG(orders.amount) + 1", + ], + ) + def test_non_nested_is_rejected(self, expr: str) -> None: + assert not is_nested_aggregate(_metric(expr)) + + +class TestParseNested: + def test_returns_outer_inner_and_inner_arg(self) -> None: + outer, inner, arg = parse_nested(_metric("AVG(SUM(orders.amount))")) + assert outer is AggregateFunction.AVG + assert inner is AggregateFunction.SUM + # Inner argument is the bare column reference; we don't pin its + # exact AST shape (sqlglot may wrap it), only that the column + # is recoverable. + cols = {c.name for c in arg.find_all(arg.__class__) if hasattr(c, "name")} + assert "amount" in cols or arg.sql().lower().endswith("amount") + + def test_inner_count_distinct_treated_as_count(self) -> None: + # Foundation parses ``COUNT(DISTINCT x)`` as a Count node with + # the DISTINCT flag — still classifies as nested-aggregate. + assert is_nested_aggregate(_metric("AVG(COUNT(DISTINCT orders.id))")) + + +class TestNotNestedEdgeCases: + def test_unary_minus_outer_is_not_nested(self) -> None: + # The outer node is Neg, not an aggregate. + assert not is_nested_aggregate(_metric("-AVG(orders.amount)")) + + def test_inner_literal_is_not_nested(self) -> None: + # ``AVG(1)`` is a one-level aggregate of a literal. + assert not is_nested_aggregate(_metric("AVG(1)")) + + +def test_normalize_identifier_independence() -> None: + # The detection contract works on the AST directly; identifier + # casing on the inner column does not affect the classification. + metric = _metric("AVG(SUM(Orders.Amount))") + assert is_nested_aggregate(metric) + outer, inner, _ = parse_nested(metric) + assert outer is AggregateFunction.AVG + assert inner is AggregateFunction.SUM + + +def test_normalize_identifier_helper_round_trip() -> None: + # Sanity: the planner uses normalize_identifier on column names + # discovered in the inner argument; this asserts the public helper + # we depend on still folds case. + assert normalize_identifier("AMOUNT") == normalize_identifier("amount") diff --git a/impl/python/tests/unit/planning/test_plan_types.py b/impl/python/tests/unit/planning/test_plan_types.py new file mode 100644 index 0000000..a871420 --- /dev/null +++ b/impl/python/tests/unit/planning/test_plan_types.py @@ -0,0 +1,323 @@ +"""Unit tests for :mod:`osi.planning.plan` value types. + +Covers :class:`PlanStep`, :class:`QueryPlan` invariants, and JSON +serialisation for every payload variant. Snapshots are tested via the +goldens below; here we focus on type-level correctness. +""" + +from __future__ import annotations + +import pytest +import sqlglot +from sqlglot import expressions as exp + +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.errors import ErrorCode, OSIError +from osi.planning.algebra.operations import FilterMode, JoinType +from osi.planning.algebra.state import ( + AggregateFunction, + AggregateInfo, + CalculationState, + Column, + ColumnKind, +) +from osi.planning.plan import ( + AddColumnsPayload, + AggregatePayload, + BroadcastPayload, + EnrichPayload, + FilteringJoinPayload, + FilterPayload, + MergePayload, + OrderByEntry, + PlanOperation, + PlanStep, + ProjectPayload, + QueryPlan, + SourcePayload, +) + +# AST helpers — column references and aggregates are built +# programmatically rather than via ``sqlglot.parse_one(f"…{name}…")`` +# because the latter feeds an unquoted user identifier through +# sqlglot's expression parser. When ``name`` happens to be a SQL +# reserved word (``in``, ``select``, …) the parser raises +# ``ParseError`` mid-fixture. The ``quoted=True`` form sidesteps +# parsing entirely and is keyword-safe by construction. + + +def _dim(name: str) -> Column: + nid = normalize_identifier(name) + return Column( + name=nid, + expression=FrozenSQL.of(exp.column(str(nid), quoted=True)), + dependencies=frozenset(), + kind=ColumnKind.DIMENSION, + ) + + +def _fact(name: str) -> Column: + nid = normalize_identifier(name) + return Column( + name=nid, + expression=FrozenSQL.of(exp.column(str(nid), quoted=True)), + dependencies=frozenset(), + kind=ColumnKind.FACT, + ) + + +def _agg(name: str, *, over: str) -> Column: + over_id = normalize_identifier(over) + return Column( + name=normalize_identifier(name), + expression=FrozenSQL.of( + exp.Anonymous( + this="SUM", expressions=[exp.column(str(over_id), quoted=True)] + ) + ), + dependencies=frozenset({over_id}), + kind=ColumnKind.AGGREGATE, + aggregate=AggregateInfo( + function=AggregateFunction.SUM, + argument=FrozenSQL.of(exp.column(str(over_id), quoted=True)), + ), + ) + + +def _source_state() -> CalculationState: + dim = _dim("id") + fact = _fact("amount") + return CalculationState( + grain=frozenset({dim.name}), + columns=(dim, fact), + ) + + +# --------------------------------------------------------------------------- +# PlanStep +# --------------------------------------------------------------------------- + + +class TestPlanStep: + def test_inputs_are_stored_as_tuple(self) -> None: + step = PlanStep( + step_id=0, + operation=PlanOperation.SOURCE, + inputs=(), + state=_source_state(), + payload=SourcePayload( + dataset=normalize_identifier("orders"), + primary_key=frozenset({normalize_identifier("id")}), + ), + ) + assert step.inputs == () + + def test_step_is_frozen(self) -> None: + step = PlanStep( + step_id=0, + operation=PlanOperation.SOURCE, + inputs=(), + state=_source_state(), + payload=SourcePayload( + dataset=normalize_identifier("orders"), + primary_key=frozenset({normalize_identifier("id")}), + ), + ) + # Frozen dataclasses raise ``FrozenInstanceError``; narrow the + # catch so a future refactor that makes ``step_id`` mutable + # via ``object.__setattr__`` doesn't silently pass this test. + from dataclasses import FrozenInstanceError + + with pytest.raises((FrozenInstanceError, AttributeError)): + step.step_id = 1 # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# QueryPlan invariants +# --------------------------------------------------------------------------- + + +class TestQueryPlanInvariants: + def _source_step(self, step_id: int = 0) -> PlanStep: + return PlanStep( + step_id=step_id, + operation=PlanOperation.SOURCE, + inputs=(), + state=_source_state(), + payload=SourcePayload( + dataset=normalize_identifier("orders"), + primary_key=frozenset({normalize_identifier("id")}), + ), + ) + + def test_dangling_input_rejected(self) -> None: + s1 = self._source_step(step_id=0) + s2 = PlanStep( + step_id=1, + operation=PlanOperation.PROJECT, + inputs=(99,), # unknown + state=_source_state(), + payload=ProjectPayload(columns=(normalize_identifier("id"),)), + ) + with pytest.raises(OSIError) as exc_info: + QueryPlan(steps=(s1, s2), root_step_id=1) + assert exc_info.value.code is ErrorCode.E_INTERNAL_INVARIANT + assert exc_info.value.context["step_id"] == 1 + assert exc_info.value.context["unplanned_input"] == 99 + + def test_root_must_be_a_declared_step(self) -> None: + s1 = self._source_step(step_id=0) + with pytest.raises(OSIError) as exc_info: + QueryPlan(steps=(s1,), root_step_id=42) + assert exc_info.value.code is ErrorCode.E_INTERNAL_INVARIANT + assert exc_info.value.context["root_step_id"] == 42 + assert 0 in exc_info.value.context["step_ids"] + + def test_root_property_returns_root_step(self) -> None: + s1 = self._source_step(step_id=0) + p = QueryPlan(steps=(s1,), root_step_id=0) + assert p.root is s1 + + def test_default_order_by_and_output_columns_are_empty(self) -> None: + s1 = self._source_step(step_id=0) + p = QueryPlan(steps=(s1,), root_step_id=0) + assert p.order_by == () + assert p.output_columns == () + + +# --------------------------------------------------------------------------- +# JSON serialisation (for goldens) +# --------------------------------------------------------------------------- + + +def _make_plan_with_every_payload() -> QueryPlan: + src = PlanStep( + step_id=0, + operation=PlanOperation.SOURCE, + inputs=(), + state=_source_state(), + payload=SourcePayload( + dataset=normalize_identifier("orders"), + primary_key=frozenset({normalize_identifier("id")}), + ), + ) + filt = PlanStep( + step_id=1, + operation=PlanOperation.FILTER, + inputs=(0,), + state=_source_state(), + payload=FilterPayload( + predicate=FrozenSQL.of(sqlglot.parse_one("amount > 0")), + dependencies=frozenset({normalize_identifier("amount")}), + is_post_aggregate=False, + ), + ) + add = PlanStep( + step_id=2, + operation=PlanOperation.ADD_COLUMNS, + inputs=(1,), + state=_source_state(), + payload=AddColumnsPayload(definitions=(_dim("id"),)), + ) + # After aggregate, the column's dependencies are sealed (all deps + # live on the *upstream* state). Mirror that here to satisfy I-6. + agg_col = _agg("total", over="amount") + sealed_agg = Column( + name=agg_col.name, + expression=agg_col.expression, + dependencies=frozenset(), + kind=ColumnKind.AGGREGATE, + aggregate=agg_col.aggregate, + ) + agg_state = CalculationState( + grain=frozenset(), + columns=(sealed_agg,), + ) + aggst = PlanStep( + step_id=3, + operation=PlanOperation.AGGREGATE, + inputs=(2,), + state=agg_state, + payload=AggregatePayload( + new_grain=frozenset(), + aggregations=(sealed_agg,), + ), + ) + proj = PlanStep( + step_id=4, + operation=PlanOperation.PROJECT, + inputs=(3,), + state=agg_state, + payload=ProjectPayload(columns=(normalize_identifier("total"),)), + ) + bcast = PlanStep( + step_id=5, + operation=PlanOperation.BROADCAST, + inputs=(4,), + state=agg_state, + payload=BroadcastPayload(column=sealed_agg), + ) + enr = PlanStep( + step_id=6, + operation=PlanOperation.ENRICH, + inputs=(5,), + state=agg_state, + payload=EnrichPayload( + child_dataset=normalize_identifier("customers"), + child_columns=(_dim("region"),), + keys=frozenset({normalize_identifier("id")}), + join_type=JoinType.LEFT, + ), + ) + mrg = PlanStep( + step_id=7, + operation=PlanOperation.MERGE, + inputs=(6,), + state=agg_state, + payload=MergePayload(on=frozenset()), + ) + fj = PlanStep( + step_id=8, + operation=PlanOperation.FILTERING_JOIN, + inputs=(7,), + state=agg_state, + payload=FilteringJoinPayload( + lhs_keys=frozenset({normalize_identifier("id")}), + rhs_keys=frozenset({normalize_identifier("id")}), + mode=FilterMode.SEMI, + ), + ) + return QueryPlan( + steps=(src, filt, add, aggst, proj, bcast, enr, mrg, fj), + root_step_id=8, + order_by=(OrderByEntry(column=normalize_identifier("total"), descending=True),), + limit=5, + output_columns=(normalize_identifier("total"),), + ) + + +class TestQueryPlanToJson: + def test_every_payload_kind_serializes(self) -> None: + plan_obj = _make_plan_with_every_payload() + js = plan_obj.to_json() + kinds = {s["payload"]["kind"] for s in js["steps"]} + assert kinds == { + "source", + "filter", + "add_columns", + "aggregate", + "project", + "broadcast", + "enrich", + "merge", + "filtering_join", + } + + def test_output_fields_are_present(self) -> None: + plan_obj = _make_plan_with_every_payload() + js = plan_obj.to_json() + assert js["root_step_id"] == 8 + assert js["limit"] == 5 + assert js["order_by"] == [{"column": "total", "descending": True}] + assert js["output_columns"] == ["total"] diff --git a/impl/python/tests/unit/planning/test_planner.py b/impl/python/tests/unit/planning/test_planner.py new file mode 100644 index 0000000..a06e25b --- /dev/null +++ b/impl/python/tests/unit/planning/test_planner.py @@ -0,0 +1,300 @@ +"""End-to-end unit tests for :func:`osi.planning.plan`. + +Covers the full pipeline: resolve → classify → group → build measure +group → merge → having → project. Error codes asserted: +``E2002`` (unknown name), ``E3011`` / ``E3012`` (M:N rejection — the +spec's deprecated and current codes respectively), ``E3008`` (grain +mismatch on merge — surfaces through ``E3002_UNSATISFIABLE_GRAIN`` when +a grain can't be reached), and ``E1209_CROSS_DATASET_AD_HOC_AGGREGATE``. +""" + +from __future__ import annotations + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL, parse_sql_expr +from osi.errors import ErrorCode, OSIError +from osi.planning import PlanOperation, Reference, SemanticQuery, SortDirection, plan +from osi.planning.semantic_query import OrderBy +from tests.unit.planning.fixtures import mn_context, orders_context + +# --------------------------------------------------------------------------- +# Shorthand +# --------------------------------------------------------------------------- + + +def _ref(ds: str | None, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds) if ds else None, + name=normalize_identifier(name), + ) + + +def _sql(txt: str) -> FrozenSQL: + return FrozenSQL.of(parse_sql_expr(txt)) + + +def _operations(plan_obj) -> list[str]: # type: ignore[no-untyped-def] + return [s.operation.value for s in plan_obj.steps] + + +# --------------------------------------------------------------------------- +# Single-dataset cases +# --------------------------------------------------------------------------- + + +class TestSingleDataset: + def test_dim_plus_measure(self) -> None: + ctx = orders_context() + q = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + ) + p = plan(q, ctx) + assert _operations(p) == ["source", "aggregate", "project"] + assert p.output_columns == ( + normalize_identifier("status"), + normalize_identifier("total_revenue"), + ) + + def test_measure_only(self) -> None: + ctx = orders_context() + q = SemanticQuery(measures=(_ref("orders", "total_revenue"),)) + p = plan(q, ctx) + # No dimensions → grain collapses to scalar. + assert p.root.state.is_scalar + + def test_distinct_count_metric(self) -> None: + ctx = orders_context() + q = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "distinct_customers"),), + ) + p = plan(q, ctx) + assert "aggregate" in _operations(p) + + def test_avg_metric(self) -> None: + ctx = orders_context() + q = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "avg_discount"),), + ) + p = plan(q, ctx) + assert "aggregate" in _operations(p) + + +# --------------------------------------------------------------------------- +# Enrichment +# --------------------------------------------------------------------------- + + +class TestEnrichment: + def test_dim_from_joined_dataset_adds_enrich(self) -> None: + ctx = orders_context() + q = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + ) + p = plan(q, ctx) + ops = _operations(p) + assert ops == ["source", "enrich", "aggregate", "project"] + + def test_fan_trap_dimension_raises_unsafe_reaggregation(self) -> None: + """Reject fan-trap enrichment paths with ``E_UNSAFE_REAGGREGATION``. + + S-9 / D-022: asking for a ``returns`` dimension from an + ``orders`` measure routes through customers (orders → + customers is N:1, safe), but the second hop customers → + returns is the reverse of ``returns_to_customers`` — a fan + trap. The planner refuses with the named code (the legacy + ``E3011`` is the algebra-side code; the planner translates + it to the user-facing name). + """ + ctx = orders_context() + q = SemanticQuery( + dimensions=(_ref("returns", "return_id"),), + measures=(_ref("orders", "total_revenue"),), + ) + with pytest.raises(OSIError) as excinfo: + plan(q, ctx) + assert excinfo.value.code is ErrorCode.E_UNSAFE_REAGGREGATION + + +# --------------------------------------------------------------------------- +# WHERE pushdown + semi-joins +# --------------------------------------------------------------------------- + + +class TestFilters: + def test_fact_local_filter_before_aggregate(self) -> None: + ctx = orders_context() + q = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + where=_sql("amount > 100"), + ) + p = plan(q, ctx) + ops = _operations(p) + # filter must come before aggregate (row-level pushdown) + assert ops.index("filter") < ops.index("aggregate") + + def test_exists_in_produces_filtering_join(self) -> None: + ctx = orders_context() + q = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + where=_sql("EXISTS_IN(customer_id, returns.customer_id)"), + ) + p = plan(q, ctx) + assert "filtering_join" in _operations(p) + + def test_not_exists_in_produces_anti_semi_join(self) -> None: + ctx = orders_context() + q = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + where=_sql("NOT EXISTS_IN(customer_id, returns.customer_id)"), + ) + p = plan(q, ctx) + fj = next(s for s in p.steps if s.operation is PlanOperation.FILTERING_JOIN) + assert fj.payload.mode.name == "ANTI" + + +# --------------------------------------------------------------------------- +# HAVING +# --------------------------------------------------------------------------- + + +class TestHaving: + def test_having_becomes_post_aggregate_filter(self) -> None: + ctx = orders_context() + q = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + having=_sql("total_revenue > 1000"), + ) + p = plan(q, ctx) + ops = _operations(p) + agg_idx = ops.index("aggregate") + filt_idx = ops.index("filter") + assert filt_idx > agg_idx + # Filter step has post_aggregate flag + filt_step = p.steps[filt_idx] + assert filt_step.payload.is_post_aggregate + + +# --------------------------------------------------------------------------- +# Multi-fact merge +# --------------------------------------------------------------------------- + + +class TestMultiFact: + def test_two_facts_on_shared_dim_merge_at_grain(self) -> None: + ctx = orders_context() + q = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=( + _ref("orders", "total_revenue"), + _ref("returns", "total_refunds"), + ), + ) + p = plan(q, ctx) + ops = _operations(p) + assert ops.count("aggregate") == 2 + assert "merge" in ops + + def test_merged_plan_output_grain_matches_query(self) -> None: + ctx = orders_context() + q = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=( + _ref("orders", "total_revenue"), + _ref("returns", "total_refunds"), + ), + ) + p = plan(q, ctx) + assert p.root.state.grain == frozenset({normalize_identifier("region")}) + + +# --------------------------------------------------------------------------- +# M:N rejection +# --------------------------------------------------------------------------- + + +class TestMNRejection: + def test_M_N_edge_on_enrichment_path_E3012(self) -> None: + """Declared N:N with no resolution route → ``E3012``. + + Per ``Proposed_OSI_Semantics.md §6.8`` an M:N-supporting + engine (which this implementation is) surfaces per-query M:N + failures as ``E3012_MN_NO_SAFE_REWRITE`` (or ``E3013`` for + the two-fact stitch case), which carry the actionable + resolution routes (bridge, stitch, EXISTS_IN) in the error + context. ``E3011`` is reserved for engines that opt out of + M:N support entirely and never appears at the user-facing + surface here. + """ + ctx = mn_context() + q = SemanticQuery( + dimensions=(_ref("courses", "subject"),), + measures=(_ref("grade_logs", "avg_grade"),), + ) + with pytest.raises(OSIError) as excinfo: + plan(q, ctx) + assert excinfo.value.code is ErrorCode.E3012_MN_NO_SAFE_REWRITE + + +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# Order/limit +# --------------------------------------------------------------------------- + + +class TestOrderLimit: + def test_order_by_on_output_column(self) -> None: + ctx = orders_context() + q = SemanticQuery( + dimensions=(_ref("orders", "status"),), + measures=(_ref("orders", "total_revenue"),), + order_by=( + OrderBy( + target=_ref(None, "total_revenue"), direction=SortDirection.DESC + ), + ), + limit=5, + ) + p = plan(q, ctx) + assert p.order_by[0].descending + assert p.order_by[0].column == normalize_identifier("total_revenue") + assert p.limit == 5 + + def test_order_by_non_output_rejected(self) -> None: + ctx = orders_context() + q = SemanticQuery( + measures=(_ref("orders", "total_revenue"),), + order_by=(OrderBy(target=_ref(None, "nope")),), + ) + with pytest.raises(OSIError) as excinfo: + plan(q, ctx) + assert excinfo.value.code is ErrorCode.E2002_NAME_NOT_FOUND + + +# --------------------------------------------------------------------------- +# Determinism +# --------------------------------------------------------------------------- + + +class TestDeterminism: + def test_same_query_produces_identical_plans(self) -> None: + ctx = orders_context() + q = SemanticQuery( + dimensions=(_ref("customers", "region"),), + measures=(_ref("orders", "total_revenue"),), + where=_sql("amount > 100"), + ) + a = plan(q, ctx).to_json() + b = plan(q, ctx).to_json() + assert a == b diff --git a/impl/python/tests/unit/planning/test_planner_bridge.py b/impl/python/tests/unit/planning/test_planner_bridge.py new file mode 100644 index 0000000..78da108 --- /dev/null +++ b/impl/python/tests/unit/planning/test_planner_bridge.py @@ -0,0 +1,224 @@ +"""Unit tests for :mod:`osi.planning.planner_bridge`. + +The bridge resolution path turns an unsafe M:N traversal into a safe +plan when an intermediate bridge dataset exists (``D-026`` / ``D-027``, +``Proposed_OSI_Semantics.md §6.8.1``). These tests pin the discovery +and applicability helpers in isolation so a regression in either lands +in this file rather than fanning out to e2e SQL diffs: + +* :func:`find_bridge_resolutions` — pure discovery over a + :class:`RelationshipGraph`. +* :func:`can_apply_bridge_resolution` — precheck over a + :class:`MeasureGroup` carrying resolved metrics. +* :func:`_resolved_bridge_unique` (indirectly via + :func:`find_bridge_resolutions` + planner integration) — ambiguous + bridge raises ``E3001_AMBIGUOUS_JOIN_PATH`` and the remediation + message no longer suggests the deferred ``joins.using_relationships`` + surface (F-18). +""" + +from __future__ import annotations + +import textwrap + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.config import FoundationFlags +from osi.errors import ErrorCode, OSIPlanningError +from osi.parsing.parser import parse_semantic_model +from osi.planning.planner_bridge import ( + can_apply_bridge_resolution, + find_bridge_resolutions, +) +from osi.planning.planner_mn import MeasureGroup +from osi.planning.resolve import ResolvedMetric + +_BRIDGE_MODEL = textwrap.dedent("""\ + semantic_model: + - name: bridge_demo + dialect: ANSI_SQL + datasets: + - name: students + source: schools.students + primary_key: [student_id] + fields: + - {name: student_id, expression: student_id, role: dimension} + - {name: name, expression: name, role: dimension} + - name: courses + source: schools.courses + primary_key: [course_id] + fields: + - {name: course_id, expression: course_id, role: dimension} + - {name: title, expression: title, role: dimension} + - name: enrollments + source: schools.enrollments + primary_key: [enrollment_id] + fields: + - {name: enrollment_id, expression: enrollment_id, role: dimension} + - {name: student_id, expression: student_id, role: dimension} + - {name: course_id, expression: course_id, role: dimension} + - {name: credit, expression: credit, role: fact} + relationships: + - {name: enroll_to_student, from: enrollments, to: students, from_columns: [student_id], to_columns: [student_id]} + - {name: enroll_to_course, from: enrollments, to: courses, from_columns: [course_id], to_columns: [course_id]} + metrics: + - {name: total_credits, expression: "SUM(enrollments.credit)"} + - {name: avg_credit, expression: "AVG(enrollments.credit)"} + """) + + +def _ctx(): + return parse_semantic_model(_BRIDGE_MODEL, flags=FoundationFlags.strict()) + + +def _id(s: str): + return normalize_identifier(s) + + +class TestFindBridgeResolutions: + """``find_bridge_resolutions`` discovers safe (fact, bridge, target) triples.""" + + def test_no_outstanding_targets_returns_empty(self) -> None: + parsed = _ctx() + # ``enrollments`` is the fact; its safe-reachable closure + # already includes both dim datasets, so there's nothing for + # a bridge to resolve. + bridges = find_bridge_resolutions( + fact=_id("enrollments"), + needed=frozenset({_id("students"), _id("courses")}), + graph=parsed.graph, + ) + assert bridges == () + + def test_bridge_route_from_dim_fact_view(self) -> None: + """Querying a fact via a dim-side anchor surfaces the bridge. + + Anchor ``students`` cannot reach ``courses`` directly — only + through ``enrollments``. ``enrollments`` therefore acts as the + bridge between the (students)-safe closure and the outstanding + ``courses`` target. + """ + parsed = _ctx() + bridges = find_bridge_resolutions( + fact=_id("students"), + needed=frozenset({_id("courses")}), + graph=parsed.graph, + ) + assert bridges, "expected a bridge candidate; got none" + bridge_ds = {b.bridge for b in bridges} + assert _id("enrollments") in bridge_ds + # Every candidate's left link must sit in the fact's safe set + # (``students`` itself, plus reflexive entries) and the right + # target must be the outstanding ``courses``. + for b in bridges: + assert b.right_target == _id("courses") + assert b.left_link == _id("students") + + +class TestCanApplyBridgeResolution: + """``can_apply_bridge_resolution`` precheck on the metric shape.""" + + def test_no_measures_rejected(self) -> None: + group = MeasureGroup(fact_dataset=_id("enrollments"), measures=()) + applicable, reason = can_apply_bridge_resolution(group) + assert not applicable + assert reason is not None and "at least one measure" in reason + + def test_distributive_sum_accepted(self) -> None: + parsed = _ctx() + total_credits = parsed.model.metrics[0] # total_credits + resolved = ResolvedMetric(dataset=None, metric=total_credits) + group = MeasureGroup(fact_dataset=_id("enrollments"), measures=(resolved,)) + applicable, reason = can_apply_bridge_resolution(group) + assert applicable, reason + assert reason is None + + def test_avg_rejected_with_distributive_hint(self) -> None: + """``AVG`` over a bridge is the D-027 gap surfaced in F-17.""" + parsed = _ctx() + avg_credit = parsed.model.metrics[1] # avg_credit + resolved = ResolvedMetric(dataset=None, metric=avg_credit) + group = MeasureGroup(fact_dataset=_id("enrollments"), measures=(resolved,)) + applicable, reason = can_apply_bridge_resolution(group) + assert not applicable + assert reason is not None + assert "SUM" in reason and "MIN" in reason + + +class TestAmbiguousBridgeMessage: + """The ambiguous-bridge diagnostic must NOT suggest deferred keys (F-18).""" + + def test_ambiguous_bridge_error_avoids_deferred_suggestion(self) -> None: + # Build a model with two equally-good bridge datasets so we + # can trigger the ambiguity path through the public planner + # entry point. + model = textwrap.dedent("""\ + semantic_model: + - name: ambig_bridges + dialect: ANSI_SQL + datasets: + - name: courses + source: schools.courses + primary_key: [course_id] + fields: + - {name: course_id, expression: course_id, role: dimension} + - {name: title, expression: title, role: dimension} + - name: instructors + source: schools.instructors + primary_key: [instructor_id] + fields: + - {name: instructor_id, expression: instructor_id, role: dimension} + - {name: name, expression: name, role: dimension} + - name: assignments_a + source: schools.assignments_a + primary_key: [aid] + fields: + - {name: aid, expression: aid, role: dimension} + - {name: course_id, expression: course_id, role: dimension} + - {name: instructor_id, expression: instructor_id, role: dimension} + - name: assignments_b + source: schools.assignments_b + primary_key: [bid] + fields: + - {name: bid, expression: bid, role: dimension} + - {name: course_id, expression: course_id, role: dimension} + - {name: instructor_id, expression: instructor_id, role: dimension} + relationships: + - {name: a_course, from: assignments_a, to: courses, from_columns: [course_id], to_columns: [course_id]} + - {name: a_instructor, from: assignments_a, to: instructors, from_columns: [instructor_id], to_columns: [instructor_id]} + - {name: b_course, from: assignments_b, to: courses, from_columns: [course_id], to_columns: [course_id]} + - {name: b_instructor, from: assignments_b, to: instructors, from_columns: [instructor_id], to_columns: [instructor_id]} + """) + parsed = parse_semantic_model(model, flags=FoundationFlags.strict()) + # Both ``assignments_a`` and ``assignments_b`` can bridge + # courses ↔ instructors. ``find_bridge_resolutions`` returns + # both, and the planner-side dedup raises E3001. + bridges = find_bridge_resolutions( + fact=_id("courses"), + needed=frozenset({_id("instructors")}), + graph=parsed.graph, + ) + bridge_ds = {b.bridge for b in bridges} + assert {_id("assignments_a"), _id("assignments_b")}.issubset(bridge_ds) + + # The ambiguity surface itself is exercised inside the planner; + # the diagnostic message must not suggest the deferred + # ``joins.using_relationships`` surface — that key would + # itself be rejected with ``E_DEFERRED_KEY_REJECTED``. + from osi.planning.planner_bridge import _resolved_bridge_unique + + with pytest.raises(OSIPlanningError) as excinfo: + _resolved_bridge_unique(bridges) + assert excinfo.value.code is ErrorCode.E3001_AMBIGUOUS_JOIN_PATH + message = str(excinfo.value) + # F-18: the user-actionable remediation must point at + # supported surfaces (model restructure / rename), not at the + # deferred ``joins.using_relationships`` key. The message MAY + # mention the deferred surface in a parenthetical "this is + # why we don't suggest X" note — but the *primary* advice + # must be a supported one. + assert "Restructure" in message + assert ( + "deferred" in message.lower() and "E_DEFERRED_KEY_REJECTED" in message + ), "should explain that per-metric using_relationships is deferred" diff --git a/impl/python/tests/unit/planning/test_planner_context.py b/impl/python/tests/unit/planning/test_planner_context.py new file mode 100644 index 0000000..3b47add --- /dev/null +++ b/impl/python/tests/unit/planning/test_planner_context.py @@ -0,0 +1,22 @@ +"""Unit tests for :mod:`osi.planning.planner_context`.""" + +from __future__ import annotations + +import pytest + +from tests.unit.planning.fixtures import orders_context + + +def test_context_bundles_model_namespace_graph() -> None: + ctx = orders_context() + assert ctx.model.name.lower() == "demo" + assert "orders" in ctx.namespace.datasets + assert len(ctx.graph.edges) == 2 + + +def test_context_is_frozen() -> None: + ctx = orders_context() + from dataclasses import FrozenInstanceError + + with pytest.raises((FrozenInstanceError, AttributeError)): + ctx.model = None # type: ignore[misc] diff --git a/impl/python/tests/unit/planning/test_prefixes.py b/impl/python/tests/unit/planning/test_prefixes.py new file mode 100644 index 0000000..5356f81 --- /dev/null +++ b/impl/python/tests/unit/planning/test_prefixes.py @@ -0,0 +1,137 @@ +"""Unit tests for :mod:`osi.planning.prefixes`. + +Every synthetic name in the planner and codegen is produced here. These +tests lock in (a) *deterministic* naming — same inputs always produce the +same identifier — and (b) *valid* identifiers that pass +:func:`normalize_identifier`. +""" + +from __future__ import annotations + +import pytest + +from osi.common.identifiers import is_valid_identifier +from osi.planning.prefixes import ( + CTE_FILTER_JOIN_RHS, + CTE_FINAL, + CTE_MEASURE_GROUP, + CTE_MERGED, + SYNTH_COLUMN_AGG_PREFIX, + SYNTH_COLUMN_DERIVED_PREFIX, + cte_name, + mangle_join_key, + stable_sorted_identifiers, + synth_aggregate_name, + synth_derived_name, +) + + +class TestConstants: + def test_cte_constants_are_lowercase_identifiers(self) -> None: + for const in ( + CTE_FILTER_JOIN_RHS, + CTE_FINAL, + CTE_MEASURE_GROUP, + CTE_MERGED, + ): + assert is_valid_identifier(const), const + assert const == const.lower() + + def test_synth_prefixes_are_distinct(self) -> None: + assert SYNTH_COLUMN_AGG_PREFIX != SYNTH_COLUMN_DERIVED_PREFIX + + +class TestCteName: + def test_deterministic(self) -> None: + a = cte_name(CTE_MEASURE_GROUP, 0) + b = cte_name(CTE_MEASURE_GROUP, 0) + assert a == b + + def test_index_encoded(self) -> None: + a = cte_name(CTE_MEASURE_GROUP, 0) + b = cte_name(CTE_MEASURE_GROUP, 1) + assert a != b + assert a.endswith("_0") and b.endswith("_1") + + def test_produces_valid_identifier(self) -> None: + name = cte_name(CTE_FILTER_JOIN_RHS, 42) + assert is_valid_identifier(name) + + +class TestMangleJoinKey: + def test_deterministic(self) -> None: + assert mangle_join_key("orders", "customer_id") == mangle_join_key( + "orders", "customer_id" + ) + + def test_dataset_and_column_both_encoded(self) -> None: + a = mangle_join_key("orders", "customer_id") + b = mangle_join_key("returns", "customer_id") + c = mangle_join_key("orders", "order_id") + assert len({a, b, c}) == 3 + + def test_produces_valid_identifier(self) -> None: + name = mangle_join_key("orders", "customer_id") + assert is_valid_identifier(name) + + +class TestSyntheticNames: + def test_aggregate_name_is_valid(self) -> None: + name = synth_aggregate_name(3) + assert is_valid_identifier(name) + assert name.endswith("_3") + + def test_derived_name_is_valid(self) -> None: + name = synth_derived_name(5) + assert is_valid_identifier(name) + assert name.endswith("_5") + + def test_aggregate_and_derived_are_distinct(self) -> None: + assert synth_aggregate_name(0) != synth_derived_name(0) + + +class TestStableSort: + def test_stable_under_permutation(self) -> None: + xs = stable_sorted_identifiers( + [ + is_valid_identifier_and_return("b"), + is_valid_identifier_and_return("a"), + is_valid_identifier_and_return("c"), + ] + ) + ys = stable_sorted_identifiers( + [ + is_valid_identifier_and_return("c"), + is_valid_identifier_and_return("a"), + is_valid_identifier_and_return("b"), + ] + ) + assert xs == ys + + +# Helper used above — avoids re-importing normalize_identifier in several +# places. Kept module-private to prevent accidental reuse in non-test code. +def is_valid_identifier_and_return(raw: str): + from osi.common.identifiers import normalize_identifier # local + + return normalize_identifier(raw) + + +def test_prefixes_module_only_produces_ascii_names() -> None: + for f in (synth_aggregate_name, synth_derived_name): + for i in range(10): + name = f(i) + assert name.isascii(), name + + +def test_cte_name_rejects_invalid_prefix_via_identifier_rules() -> None: + # Defensive: caller passing a bad prefix should surface an + # OSI-typed parse error so the diagnostic chain stays inside the + # OSIError hierarchy and clients can route on it. Catching the + # generic ``Exception`` would have allowed any TypeError / + # AttributeError regression to silently pass. + from osi.errors import ErrorCode, OSIError + + with pytest.raises(OSIError) as excinfo: + cte_name("1bad", 0) + assert excinfo.value.code is ErrorCode.E1005_IDENTIFIER_INVALID diff --git a/impl/python/tests/unit/planning/test_preprocess.py b/impl/python/tests/unit/planning/test_preprocess.py new file mode 100644 index 0000000..6d7455d --- /dev/null +++ b/impl/python/tests/unit/planning/test_preprocess.py @@ -0,0 +1,219 @@ +"""Tests for parameter substitution and named-filter inlining. + +These are pre-classification AST rewrites — they happen before the +classifier splits predicates into row-level / semi-join / post-aggregate +buckets. The tests target the rewriter directly (unit) *and* the +plan-level observable behaviour (integration) so a regression shows +up at both layers. +""" + +from __future__ import annotations + +import textwrap + +import pytest +import sqlglot + +from osi.common.identifiers import normalize_identifier +from osi.common.sql_expr import FrozenSQL +from osi.config import FoundationFlags +from osi.errors import ErrorCode, OSIPlanningError +from osi.parsing.parser import parse_semantic_model +from osi.planning import PlanOperation, Reference, SemanticQuery, plan +from osi.planning.planner_context import PlannerContext +from osi.planning.preprocess import inline_named_filters, substitute_parameters + + +def _sql(txt: str) -> FrozenSQL: + return FrozenSQL.of(sqlglot.parse_one(txt)) + + +_MODEL_WITH_PARAMS_AND_FILTERS = textwrap.dedent("""\ + semantic_model: + - name: demo + dialect: ANSI_SQL + datasets: + - name: orders + source: sales.orders + primary_key: [order_id] + fields: + - {name: order_id, expression: order_id, role: dimension} + - {name: status, expression: status, role: dimension} + - {name: amount, expression: amount, role: fact} + metrics: + - {name: total_revenue, expression: SUM(amount)} + filters: + - {name: completed_orders, expression: "status = 'completed'"} + parameters: + - {name: min_amount, data_type: NUMBER, default: 0} + - {name: region_filter, data_type: STRING} + """) + + +def _ctx() -> PlannerContext: + # Per-dataset ``metrics:`` block in the fixture is deferred under + # the strict Foundation; opt back in via the legacy-permissive + # flag set so the preprocess contract stays exercised. + parsed = parse_semantic_model( + _MODEL_WITH_PARAMS_AND_FILTERS, + flags=FoundationFlags.legacy_permissive(), + ) + return PlannerContext( + model=parsed.model, namespace=parsed.namespace, graph=parsed.graph + ) + + +# --------------------------------------------------------------------------- +# Parameter substitution +# --------------------------------------------------------------------------- + + +class TestSubstituteParameters: + def test_placeholder_replaced_with_provided_value(self) -> None: + ctx = _ctx() + out = substitute_parameters( + _sql("amount > :min_amount"), + provided={normalize_identifier("min_amount"): 250}, + declared=ctx.model.parameters, + ) + assert out is not None + assert out.canonical == _sql("amount > 250").canonical + + def test_placeholder_uses_default_when_not_provided(self) -> None: + ctx = _ctx() + out = substitute_parameters( + _sql("amount > :min_amount"), + provided={}, + declared=ctx.model.parameters, + ) + assert out is not None + assert out.canonical == _sql("amount > 0").canonical + + def test_missing_value_and_no_default_raises_E1002(self) -> None: + ctx = _ctx() + with pytest.raises(OSIPlanningError) as excinfo: + substitute_parameters( + _sql("status = :region_filter"), + provided={}, + declared=ctx.model.parameters, + ) + assert excinfo.value.code is ErrorCode.E1002_MISSING_REQUIRED_FIELD + + def test_unknown_placeholder_raises_E2002(self) -> None: + ctx = _ctx() + with pytest.raises(OSIPlanningError) as excinfo: + substitute_parameters( + _sql("amount > :nonexistent"), + provided={}, + declared=ctx.model.parameters, + ) + assert excinfo.value.code is ErrorCode.E2002_NAME_NOT_FOUND + + def test_unknown_provided_name_raises_E2002(self) -> None: + ctx = _ctx() + with pytest.raises(OSIPlanningError) as excinfo: + substitute_parameters( + _sql("amount > 100"), + provided={normalize_identifier("nope"): 1}, + declared=ctx.model.parameters, + ) + assert excinfo.value.code is ErrorCode.E2002_NAME_NOT_FOUND + + def test_none_expression_still_validates_provided_names(self) -> None: + ctx = _ctx() + # No expression, but invalid provided name → still must reject. + with pytest.raises(OSIPlanningError) as excinfo: + substitute_parameters( + None, + provided={normalize_identifier("nope"): 1}, + declared=ctx.model.parameters, + ) + assert excinfo.value.code is ErrorCode.E2002_NAME_NOT_FOUND + + def test_plan_level_parameter_substitution_produces_filter_step(self) -> None: + ctx = _ctx() + q = SemanticQuery( + dimensions=( + Reference( + dataset=normalize_identifier("orders"), + name=normalize_identifier("status"), + ), + ), + measures=( + Reference( + dataset=normalize_identifier("orders"), + name=normalize_identifier("total_revenue"), + ), + ), + where=_sql("amount > :min_amount"), + parameters={normalize_identifier("min_amount"): 150}, + ) + p = plan(q, ctx) + filt_step = next(s for s in p.steps if s.operation is PlanOperation.FILTER) + # The substituted literal reaches the filter payload verbatim. + assert "150" in filt_step.payload.predicate.canonical + + +# --------------------------------------------------------------------------- +# Named filter inlining +# --------------------------------------------------------------------------- + + +class TestInlineNamedFilters: + def test_bare_reference_replaced_by_filter_expression(self) -> None: + ctx = _ctx() + out = inline_named_filters( + _sql("completed_orders"), + filters=ctx.model.filters, + field_names=frozenset(), + ) + assert out is not None + assert out.canonical == ctx.model.filters[0].expression.canonical + + def test_qualified_reference_is_left_alone(self) -> None: + """Preserve dataset-qualified references during inlining. + + ``orders.completed_orders`` is a dataset-qualified field / metric + reference; named-filter inlining must not steal it even if the + bare name matches a declared filter. + """ + ctx = _ctx() + out = inline_named_filters( + _sql("orders.completed_orders"), + filters=ctx.model.filters, + field_names=frozenset(), + ) + assert out is not None + assert out.canonical == _sql("orders.completed_orders").canonical + + def test_bare_reference_that_collides_with_field_is_left_alone(self) -> None: + ctx = _ctx() + out = inline_named_filters( + _sql("completed_orders"), + filters=ctx.model.filters, + field_names=frozenset({normalize_identifier("completed_orders")}), + ) + assert out is not None + # Unchanged — field wins. + assert out.canonical == _sql("completed_orders").canonical + + def test_plan_level_filter_inlining_produces_filter_step(self) -> None: + ctx = _ctx() + q = SemanticQuery( + dimensions=( + Reference( + dataset=normalize_identifier("orders"), + name=normalize_identifier("status"), + ), + ), + measures=( + Reference( + dataset=normalize_identifier("orders"), + name=normalize_identifier("total_revenue"), + ), + ), + where=_sql("completed_orders"), + ) + p = plan(q, ctx) + filt_step = next(s for s in p.steps if s.operation is PlanOperation.FILTER) + assert "'completed'" in filt_step.payload.predicate.canonical diff --git a/impl/python/tests/unit/planning/test_resolve.py b/impl/python/tests/unit/planning/test_resolve.py new file mode 100644 index 0000000..f477668 --- /dev/null +++ b/impl/python/tests/unit/planning/test_resolve.py @@ -0,0 +1,101 @@ +"""Unit tests for :mod:`osi.planning.resolve`. + +Error codes covered: +``E2001`` (ambiguous bare name), ``E2002`` (not found), +``E1207`` (facts-metrics exclusivity), ``E1206`` (non-metric used as measure). +""" + +from __future__ import annotations + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.errors import ErrorCode, OSIParseError, OSIPlanningError +from osi.planning.resolve import ( + ResolvedDimension, + ResolvedFact, + ResolvedMetric, + resolve_dimension, + resolve_measure, + resolve_reference, +) +from osi.planning.semantic_query import Reference +from tests.unit.planning.fixtures import orders_context + + +def _ref(ds: str | None, name: str) -> Reference: + return Reference( + dataset=normalize_identifier(ds) if ds else None, + name=normalize_identifier(name), + ) + + +class TestResolveReference: + def test_qualified_dimension_resolves(self) -> None: + ns = orders_context().namespace + got = resolve_reference(_ref("orders", "status"), ns) + assert isinstance(got, ResolvedDimension) + assert got.dataset == normalize_identifier("orders") + + def test_qualified_fact_resolves_to_ResolvedFact(self) -> None: + ns = orders_context().namespace + got = resolve_reference(_ref("orders", "amount"), ns) + assert isinstance(got, ResolvedFact) + + def test_qualified_table_metric_resolves(self) -> None: + ns = orders_context().namespace + got = resolve_reference(_ref("orders", "total_revenue"), ns) + assert isinstance(got, ResolvedMetric) + assert got.dataset == normalize_identifier("orders") + + def test_qualified_unknown_name_E2002(self) -> None: + ns = orders_context().namespace + with pytest.raises(OSIPlanningError) as excinfo: + resolve_reference(_ref("orders", "does_not_exist"), ns) + assert excinfo.value.code is ErrorCode.E2002_NAME_NOT_FOUND + + def test_qualified_unknown_dataset_surfaces(self) -> None: + ns = orders_context().namespace + with pytest.raises((OSIPlanningError, OSIParseError)) as excinfo: + resolve_reference(_ref("ghost", "status"), ns) + assert excinfo.value.code is ErrorCode.E2002_NAME_NOT_FOUND + + def test_bare_unambiguous_dimension(self) -> None: + ns = orders_context().namespace + got = resolve_reference(_ref(None, "status"), ns) + assert isinstance(got, ResolvedDimension) + assert got.dataset == normalize_identifier("orders") + + def test_bare_ambiguous_E2001(self) -> None: + ns = orders_context().namespace + with pytest.raises((OSIPlanningError, OSIParseError)) as excinfo: + # ``customer_id`` is declared on both orders and returns. + resolve_reference(_ref(None, "customer_id"), ns) + assert excinfo.value.code is ErrorCode.E2001_AMBIGUOUS_NAME + + +class TestResolveDimension: + def test_metric_rejected_as_dimension_E1207(self) -> None: + ns = orders_context().namespace + with pytest.raises(OSIPlanningError) as excinfo: + resolve_dimension(_ref("orders", "total_revenue"), ns) + assert excinfo.value.code is ErrorCode.E1207_FACTS_METRICS_EXCLUSIVE + + def test_fact_rejected_as_dimension_E1207(self) -> None: + ns = orders_context().namespace + with pytest.raises(OSIPlanningError) as excinfo: + resolve_dimension(_ref("orders", "amount"), ns) + assert excinfo.value.code is ErrorCode.E1207_FACTS_METRICS_EXCLUSIVE + + +class TestResolveMeasure: + def test_metric_accepted(self) -> None: + ns = orders_context().namespace + got = resolve_measure(_ref("orders", "total_revenue"), ns) + assert isinstance(got, ResolvedMetric) + + def test_field_rejected_as_measure_E1206(self) -> None: + ns = orders_context().namespace + with pytest.raises(OSIPlanningError) as excinfo: + resolve_measure(_ref("orders", "amount"), ns) + assert excinfo.value.code is ErrorCode.E1206_METRIC_IN_RAW_AGGREGATE diff --git a/impl/python/tests/unit/planning/test_semantic_query.py b/impl/python/tests/unit/planning/test_semantic_query.py new file mode 100644 index 0000000..8249e2a --- /dev/null +++ b/impl/python/tests/unit/planning/test_semantic_query.py @@ -0,0 +1,101 @@ +"""Unit tests for :mod:`osi.planning.semantic_query`. + +Covers every branch of :class:`SemanticQuery.__post_init__` plus the +:class:`Reference` / :class:`OrderBy` shape. Error codes asserted here: +``E1002`` (missing required field), ``E1004`` (type mismatch). +""" + +from __future__ import annotations + +import pytest + +from osi.common.identifiers import normalize_identifier +from osi.errors import ErrorCode, OSIParseError +from osi.planning.semantic_query import OrderBy, Reference, SemanticQuery, SortDirection + +# --------------------------------------------------------------------------- +# Reference +# --------------------------------------------------------------------------- + + +class TestReference: + def test_qualified_reference_renders_dataset_dot_name(self) -> None: + ref = Reference( + dataset=normalize_identifier("orders"), + name=normalize_identifier("amount"), + ) + assert ref.is_qualified + assert str(ref) == "orders.amount" + + def test_bare_reference_has_no_dataset(self) -> None: + ref = Reference(dataset=None, name=normalize_identifier("avg_order_value")) + assert not ref.is_qualified + assert str(ref) == "avg_order_value" + + +# --------------------------------------------------------------------------- +# OrderBy +# --------------------------------------------------------------------------- + + +class TestOrderBy: + def test_default_direction_is_ascending(self) -> None: + ob = OrderBy( + target=Reference(dataset=None, name=normalize_identifier("revenue")) + ) + assert ob.direction is SortDirection.ASC + + def test_explicit_desc(self) -> None: + ob = OrderBy( + target=Reference(dataset=None, name=normalize_identifier("revenue")), + direction=SortDirection.DESC, + ) + assert ob.direction is SortDirection.DESC + + +# --------------------------------------------------------------------------- +# SemanticQuery +# --------------------------------------------------------------------------- + + +def _ref(name: str) -> Reference: + return Reference(dataset=None, name=normalize_identifier(name)) + + +class TestSemanticQueryValidation: + def test_empty_query_raises_E_EMPTY_AGGREGATION_QUERY(self) -> None: + # S-2: per D-010 / D-011 the empty case has its own + # error codes; the historical default is the aggregation + # shape's E_EMPTY_AGGREGATION_QUERY. + with pytest.raises(OSIParseError) as excinfo: + SemanticQuery() + assert excinfo.value.code is ErrorCode.E_EMPTY_AGGREGATION_QUERY + + def test_dimension_only_query_is_valid(self) -> None: + q = SemanticQuery(dimensions=(_ref("status"),)) + assert q.dimensions[0].name == "status" + assert q.measures == () + + def test_measure_only_query_is_valid(self) -> None: + q = SemanticQuery(measures=(_ref("total_revenue"),)) + assert q.measures[0].name == "total_revenue" + + def test_negative_limit_rejected_E1004(self) -> None: + with pytest.raises(OSIParseError) as excinfo: + SemanticQuery(measures=(_ref("total"),), limit=-1) + assert excinfo.value.code is ErrorCode.E1004_TYPE_MISMATCH + + def test_zero_limit_allowed(self) -> None: + q = SemanticQuery(measures=(_ref("total"),), limit=0) + assert q.limit == 0 + + def test_no_limit_means_unlimited(self) -> None: + q = SemanticQuery(measures=(_ref("total"),)) + assert q.limit is None + + def test_is_frozen(self) -> None: + q = SemanticQuery(measures=(_ref("total"),)) + from dataclasses import FrozenInstanceError + + with pytest.raises((FrozenInstanceError, AttributeError)): + q.limit = 10 # type: ignore[misc] diff --git a/impl/python/tests/unit/planning/test_window_planner.py b/impl/python/tests/unit/planning/test_window_planner.py new file mode 100644 index 0000000..67bdb94 --- /dev/null +++ b/impl/python/tests/unit/planning/test_window_planner.py @@ -0,0 +1,150 @@ +"""Positive window planner (D-028 + D-030) — unit tests. + +Pins the S-22 contract that Foundation v0.1 accepts valid window +functions in the scalar (``Fields``) slot: + +* a windowed metric is rendered as ``OVER(...)`` in the projected SQL + via an ``ADD_COLUMNS`` step; +* row-level ``WHERE`` predicates that *do not* touch the windowed + metric land before the window (pre-window filter); +* row-level ``WHERE`` predicates that *do* touch the windowed metric + land after the window (the QUALIFY pattern, D-030); +* ``OVER(...)`` with a non-deferred frame (``ROWS BETWEEN UNBOUNDED + PRECEDING AND CURRENT ROW``) is preserved verbatim; +* nested windows (D-031) and deferred frame modes (D-032) still raise + their named codes. +""" + +from __future__ import annotations + +import pytest + +from osi.common.sql_expr import FrozenSQL, parse_sql_expr +from osi.common.windows import ( + contains_window, + first_deferred_frame_clause, + first_nested_window, + is_windowed_expression, +) +from osi.errors import ErrorCode, OSIParseError +from osi.parsing.deferred import check_expression_deferred +from osi.parsing.parser import parse_semantic_model + + +def _frozen(sql: str) -> FrozenSQL: + return FrozenSQL.of(parse_sql_expr(sql)) + + +# --------------------------------------------------------------------------- +# Detection contracts +# --------------------------------------------------------------------------- + + +class TestWindowDetection: + @pytest.mark.parametrize( + "expr", + [ + "ROW_NUMBER() OVER (PARTITION BY a ORDER BY b)", + "RANK() OVER (ORDER BY x)", + "SUM(amount) OVER (PARTITION BY id ORDER BY ts ROWS BETWEEN " + "UNBOUNDED PRECEDING AND CURRENT ROW)", + ], + ) + def test_top_level_window_recognised(self, expr: str) -> None: + assert is_windowed_expression(_frozen(expr).expr) + assert contains_window(_frozen(expr).expr) + + def test_aggregate_is_not_window(self) -> None: + assert not is_windowed_expression(_frozen("SUM(amount)").expr) + + def test_arithmetic_with_window_is_not_top_level(self) -> None: + # The expression *contains* a window but the top-level node is + # arithmetic; ``is_windowed_expression`` is a top-level check. + body = _frozen("ROW_NUMBER() OVER (ORDER BY x) + 1").expr + assert not is_windowed_expression(body) + assert contains_window(body) + + +# --------------------------------------------------------------------------- +# Parser admits valid windows +# --------------------------------------------------------------------------- + + +class TestParserAcceptsValidWindow: + def test_no_frame_is_accepted(self) -> None: + check_expression_deferred( + _frozen("ROW_NUMBER() OVER (ORDER BY id)"), + where="metric x", + ) + + def test_unbounded_rows_frame_is_accepted(self) -> None: + check_expression_deferred( + _frozen( + "SUM(amount) OVER (PARTITION BY customer_id ORDER BY id " + "ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)" + ), + where="metric x", + ) + + def test_rank_with_partition_is_accepted(self) -> None: + check_expression_deferred( + _frozen("RANK() OVER (PARTITION BY a ORDER BY b DESC)"), + where="metric x", + ) + + +# --------------------------------------------------------------------------- +# Negative paths still fire +# --------------------------------------------------------------------------- + + +class TestRejectionPaths: + def test_nested_window_still_rejected(self) -> None: + # SUM(SUM(x) OVER (...)) OVER (...) — D-031. + expr = _frozen( + "SUM(SUM(amount) OVER (PARTITION BY a)) " "OVER (PARTITION BY b ORDER BY c)" + ) + assert first_nested_window(expr.expr) is not None + with pytest.raises(OSIParseError) as exc: + check_expression_deferred(expr, where="metric x") + assert exc.value.code is ErrorCode.E_NESTED_WINDOW + + def test_deferred_frame_detector_recognises_groups(self) -> None: + # sqlglot's parser does not accept ``GROUPS`` natively; build + # the AST directly so we can pin that the *detector* still + # flags it. Routing into ``E_DEFERRED_FRAME_MODE`` is then + # exercised by the upstream parse_sql_expr → check pipeline. + from sqlglot import expressions as sgexp + + win = sgexp.Window( + this=sgexp.Sum(this=sgexp.column("amount")), + spec=sgexp.WindowSpec(kind="GROUPS"), + ) + match = first_deferred_frame_clause(win) + assert match is not None and "groups" in match[1].lower() + + +# --------------------------------------------------------------------------- +# End-to-end model parsing +# --------------------------------------------------------------------------- + + +def test_model_with_windowed_metric_parses() -> None: + yaml_doc = """ +semantic_model: + - name: m + datasets: + - name: orders + source: orders + primary_key: [id] + fields: + - {name: id, expression: id} + - {name: customer_id, expression: customer_id} + - {name: amount, expression: amount, role: fact} + metrics: + - name: rn_per_customer + expression: "ROW_NUMBER() OVER (PARTITION BY orders.customer_id ORDER BY orders.amount DESC)" +""" + result = parse_semantic_model(yaml_doc.strip()) + metric = next(m for m in result.model.metrics if m.name == "rn_per_customer") + assert is_windowed_expression(metric.expression.expr) diff --git a/impl/python/tests/unit/planning/test_windows.py b/impl/python/tests/unit/planning/test_windows.py new file mode 100644 index 0000000..123904e --- /dev/null +++ b/impl/python/tests/unit/planning/test_windows.py @@ -0,0 +1,195 @@ +"""Unit + property coverage for ``osi.common.windows``. + +The S-12 module ``windows.py`` is purely shape-analysis — no I/O, no +side effects, deterministic. That makes it a high-value target for +property-style coverage: the rejection rules should hold over a wide +range of expression shapes, not just the hand-picked positive cases +in the compliance suite. + +This file pairs targeted unit tests (one per public function) with +small Hypothesis property tests that exercise the boundary between +"window present" and "window absent". +""" + +from __future__ import annotations + +import sqlglot +from hypothesis import given +from hypothesis import strategies as st + +from osi.common.windows import ( + contains_window, + first_deferred_frame_clause, + first_nested_window, + is_windowed_expression, + references_windowed_metric, +) + + +def _parse(sql: str): + return sqlglot.parse_one(sql) + + +# --------------------------------------------------------------------------- +# contains_window +# --------------------------------------------------------------------------- + + +class TestContainsWindow: + def test_true_for_simple_window(self) -> None: + assert contains_window(_parse("SUM(amount) OVER (PARTITION BY a)")) + + def test_true_for_window_inside_arithmetic(self) -> None: + assert contains_window(_parse("SUM(x) OVER () + 1")) + + def test_false_for_no_window(self) -> None: + assert not contains_window(_parse("SUM(amount)")) + + def test_false_for_plain_column(self) -> None: + assert not contains_window(_parse("orders.amount")) + + +# --------------------------------------------------------------------------- +# is_windowed_expression +# --------------------------------------------------------------------------- + + +class TestIsWindowedExpression: + def test_true_for_top_level_window(self) -> None: + assert is_windowed_expression(_parse("ROW_NUMBER() OVER ()")) + + def test_false_when_window_inside_arithmetic(self) -> None: + # ``SUM(x) OVER () + 1`` is *not* a windowed expression at the + # top level — it's an Add whose lhs happens to be a window. + # composition rules use this distinction. + assert not is_windowed_expression(_parse("SUM(x) OVER () + 1")) + + def test_false_for_plain_aggregate(self) -> None: + assert not is_windowed_expression(_parse("SUM(x)")) + + +# --------------------------------------------------------------------------- +# first_nested_window +# --------------------------------------------------------------------------- + + +class TestFirstNestedWindow: + def test_detects_window_in_window_argument(self) -> None: + nested = first_nested_window( + _parse("SUM(SUM(amount) OVER (PARTITION BY a)) OVER (PARTITION BY b)") + ) + assert nested is not None + + def test_no_match_for_simple_window(self) -> None: + assert first_nested_window(_parse("SUM(amount) OVER ()")) is None + + def test_no_match_for_aggregate_alone(self) -> None: + assert first_nested_window(_parse("SUM(amount)")) is None + + +# --------------------------------------------------------------------------- +# first_deferred_frame_clause +# --------------------------------------------------------------------------- + + +class TestFirstDeferredFrameClause: + def test_no_match_for_rows_frame(self) -> None: + result = first_deferred_frame_clause( + _parse( + "SUM(x) OVER (ORDER BY a " + "ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)" + ) + ) + assert result is None + + def test_no_match_for_range_frame(self) -> None: + result = first_deferred_frame_clause( + _parse( + "SUM(x) OVER (ORDER BY a " + "RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)" + ) + ) + assert result is None + + def test_no_match_for_no_frame(self) -> None: + assert first_deferred_frame_clause(_parse("SUM(x) OVER ()")) is None + + +# --------------------------------------------------------------------------- +# references_windowed_metric +# --------------------------------------------------------------------------- + + +class TestReferencesWindowedMetric: + def test_finds_bare_reference(self) -> None: + names = frozenset({"running_total"}) + result = references_windowed_metric( + _parse("running_total / SUM(amount)"), + windowed_metric_names=names, + ) + assert result == "running_total" + + def test_finds_qualified_reference(self) -> None: + names = frozenset({"orders.running_total"}) + result = references_windowed_metric( + _parse("orders.running_total + 1"), + windowed_metric_names=names, + ) + assert result == "orders.running_total" + + def test_no_match_when_set_empty(self) -> None: + result = references_windowed_metric( + _parse("amount + 1"), + windowed_metric_names=frozenset(), + ) + assert result is None + + def test_no_match_when_no_reference(self) -> None: + result = references_windowed_metric( + _parse("SUM(amount)"), + windowed_metric_names=frozenset({"running_total"}), + ) + assert result is None + + +# --------------------------------------------------------------------------- +# Properties +# --------------------------------------------------------------------------- + + +_ARITH_OPS = ["+", "-", "*", "/"] +_AGGS = ["SUM", "AVG", "MIN", "MAX", "COUNT"] + + +@st.composite +def _windowed_expr(draw) -> str: + agg = draw(st.sampled_from(_AGGS)) + return f"{agg}(amount) OVER (PARTITION BY a)" + + +@st.composite +def _non_windowed_expr(draw) -> str: + agg = draw(st.sampled_from(_AGGS)) + op = draw(st.sampled_from(_ARITH_OPS)) + return f"{agg}(amount) {op} 1" + + +class TestProperties: + @given(_windowed_expr()) + def test_contains_window_holds_for_every_windowed_form(self, sql: str) -> None: + assert contains_window(_parse(sql)) + + @given(_non_windowed_expr()) + def test_no_window_implies_contains_window_false(self, sql: str) -> None: + assert not contains_window(_parse(sql)) + + @given(_windowed_expr()) + def test_contains_implies_top_level_or_descendant(self, sql: str) -> None: + # If contains_window is True, then either is_windowed_expression + # is True (windowed at the top level) OR a descendant has the + # window. We sample expressions where the window IS top-level, + # so the property simplifies. + expr = _parse(sql) + assert contains_window(expr) + # A simple "AGG(...) OVER (...)" parses as a top-level Window. + assert is_windowed_expression(expr) diff --git a/impl/python/tests/unit/test_appendix_c_drift.py b/impl/python/tests/unit/test_appendix_c_drift.py new file mode 100644 index 0000000..dd52f80 --- /dev/null +++ b/impl/python/tests/unit/test_appendix_c_drift.py @@ -0,0 +1,181 @@ +"""Appendix C ↔ ``ErrorCode`` drift test (Phase 3 review B5 + I1). + +The Foundation spec's Appendix C of +``../../../proposals/foundation-v0.1/Proposed_OSI_Semantics.md`` +is the normative list of error codes a Foundation engine may raise. +This test pins both sides of the contract so the implementation and +the spec cannot drift silently: + +1. Every Appendix-C code (``E_*`` family, plus the kept ``E3xxx`` + numeric codes) must exist as an :class:`ErrorCode` enum member with + the same ``code.value`` string. Without this, a conformance test + that asserts ``error.code == "E_AMBIGUOUS_MEASURE_GRAIN"`` could + never trigger. +2. Every ``ErrorCode`` enum member whose name starts with ``E_`` (the + spec's named family) must either appear in Appendix C *or* be + explicitly listed in :data:`_IMPLEMENTATION_EXTENSIONS` below with + a one-line rationale, so reviewers know which codes are spec- + mandated and which are implementation extensions. + +When updating the spec, update :data:`_APPENDIX_C_CODES` first; the +test will fail until the enum is in sync. +""" + +from __future__ import annotations + +from osi.errors import ErrorCode + +# Appendix C codes — the working set this implementation considers +# in-scope at Foundation v0.1. This is curated from the +# ``Proposed_OSI_Semantics.md`` §"Appendix C: Error Code Index" table: +# every active row is mirrored here, but the implementation also +# treats this list as the test contract, so a spec revision that adds +# or removes a row must update this set first (and the test will +# surface the enum-side work to follow). +# +# Codes that the enum carries as implementation extensions live in +# :data:`_IMPLEMENTATION_EXTENSIONS` instead. Keep both lists +# alphabetical within family for review ergonomics. +_APPENDIX_C_CODES: frozenset[str] = frozenset( + { + # E_* — Foundation-named correctness codes. + "E_AGGREGATE_IN_FIELD", + "E_AGGREGATE_IN_SCALAR_QUERY", + "E_AGGREGATE_IN_WHERE", + "E_AMBIGUOUS_MEASURE_GRAIN", + "E_AMBIGUOUS_PATH", + "E_DEFERRED_FRAME_MODE", + "E_DEFERRED_KEY_REJECTED", + "E_EMPTY_AGGREGATION_QUERY", + "E_EMPTY_SCALAR_QUERY", + "E_FAN_OUT_IN_SCALAR_QUERY", + "E_FIELD_DEPENDENCY_CYCLE", + "E_INVALID_NATURAL_GRAIN", + "E_MIXED_PREDICATE_LEVEL", + "E_MIXED_QUERY_SHAPE", + "E_NAME_COLLISION", + "E_NAME_NOT_FOUND", + "E_NATURAL_GRAIN_PRE_AGGREGATION_UNSAFE", + "E_NESTED_AGGREGATION_DEFERRED", + "E_NESTED_WINDOW", + "E_NO_PATH", + "E_NON_AGGREGATE_IN_HAVING", + "E_PRIMARY_KEY_REQUIRED", + "E_RESERVED_NAME", + "E_UNAGGREGATED_FINER_GRAIN_REFERENCE", + "E_UNKNOWN_FUNCTION", # D-021 — function whitelist + "E_UNSAFE_REAGGREGATION", + "E_WINDOW_IN_WHERE", + "E_WINDOW_OVER_FANOUT_REWRITE", + "E_WINDOWED_METRIC_COMPOSITION", + # E3xxx — numeric codes kept for back-compat per Appendix C + # preamble. The Python identifiers (the enum *names*) include + # the descriptive suffix; only the ``.value`` strings are + # checked against the spec. + "E3011", # E3011_MN_AGGREGATION_REJECTED + "E3012", # E3012_MN_NO_SAFE_REWRITE + "E3013", # E3013_NO_STITCHING_DIMENSION + } +) + + +# Implementation-extension codes — ``E_*`` enum members that are NOT in +# Appendix C. Each entry must come with a one-line rationale so a +# reviewer can decide whether the code should be promoted into the +# spec, kept as an extension, or deleted. Without this list a drift +# test would either fail every time the implementation adds an +# internal-only code or silently accept any new code. +_IMPLEMENTATION_EXTENSIONS: dict[str, str] = { + "E_AMBIGUOUS_NESTED_AGGREGATION_GRAIN": ( + "RESERVED — superseded by E_NESTED_AGGREGATION_DEFERRED. " + "Kept so external pinning does not break." + ), + "E_RESERVED_IDENTIFIER": ( + "Implementation invariant — identifiers that collide with " + "OSI-reserved internal names (``__step``, ``__osi_*``) are " + "rejected at identifier construction. Internal naming " + "concern, distinct from the user-facing D-019 collision " + "(``E_RESERVED_NAME``, now in Appendix C)." + ), + "E_INTERNAL_INVARIANT": ( + "Compiler-bug signal — raised when the IR or a diagnostic " + "detects an out-of-sync invariant (orphan plan input, " + "unhandled payload subclass, unhandled resolved-reference " + "subclass). Lives inside OSIError so the typed-error " + "property test still holds for these paths." + ), + "E_WINDOWED_MEASURE_NOT_SUPPORTED": ( + "F-16 engine gap — the aggregation planner does not yet " + "model windowed measures (the spec accepts them per §6.10 / " + "D-031). Without this code the metric falls into the " + "composite path and raises the misleading " + "``E1206_METRIC_IN_RAW_AGGREGATE``. Promote to Appendix C " + "if/when the engine implements windowed-measure composition; " + "remove if the spec ever defers them." + ), +} + + +def test_every_appendix_c_code_has_an_enum_member() -> None: + """Spec → impl: every Appendix C code resolves to an ``ErrorCode``.""" + enum_values = {code.value for code in ErrorCode} + missing = sorted(_APPENDIX_C_CODES - enum_values) + assert not missing, ( + f"Appendix C codes missing from ErrorCode enum: {missing}. " + "Add a member to src/osi/errors.py with the spec value as the " + "right-hand side; tests asserting on error.code cannot match " + "until the member exists." + ) + + +def test_every_named_enum_member_is_documented() -> None: + """Impl → spec: every ``E_*`` enum member is documented somewhere. + + A member is documented if it is either in Appendix C or is + explicitly listed as an implementation extension above. + """ + named_enum_codes = {code.value for code in ErrorCode if code.value.startswith("E_")} + spec_codes = {c for c in _APPENDIX_C_CODES if c.startswith("E_")} + extensions = set(_IMPLEMENTATION_EXTENSIONS) + undocumented = sorted(named_enum_codes - spec_codes - extensions) + assert not undocumented, ( + f"Implementation-only ``E_*`` codes that are neither in " + f"Appendix C nor in _IMPLEMENTATION_EXTENSIONS: {undocumented}. " + "Either add the code to the Foundation spec (and update " + "_APPENDIX_C_CODES) or list it in _IMPLEMENTATION_EXTENSIONS " + "with a one-line rationale." + ) + + +def test_extensions_do_not_shadow_spec_codes() -> None: + """An extension cannot be both extension *and* spec.""" + overlap = set(_IMPLEMENTATION_EXTENSIONS) & _APPENDIX_C_CODES + assert not overlap, ( + f"Codes listed as both Appendix C and implementation " + f"extension: {sorted(overlap)}. Pick one." + ) + + +def test_numeric_codes_use_correct_value() -> None: + """The three Appendix-C numeric M:N codes must keep their values. + + Conformance tests pin on the numeric ``code.value`` strings, so a + silent rename of one of these (e.g. ``E3012`` → ``E3014``) would + break every adapter without compiling a single line of Python. + """ + expected = { + "E3011_MN_AGGREGATION_REJECTED": "E3011", + "E3012_MN_NO_SAFE_REWRITE": "E3012", + "E3013_NO_STITCHING_DIMENSION": "E3013", + } + for member_name, value in expected.items(): + member = getattr(ErrorCode, member_name, None) + assert member is not None, ( + f"ErrorCode.{member_name} missing — Appendix C requires " + f"the numeric M:N code {value} to be raised under this " + f"Python identifier." + ) + assert member.value == value, ( + f"ErrorCode.{member_name}.value is {member.value!r}; " + f"Appendix C mandates {value!r}." + ) diff --git a/impl/python/tests/unit/test_arch_invariants_drift.py b/impl/python/tests/unit/test_arch_invariants_drift.py new file mode 100644 index 0000000..3ae5706 --- /dev/null +++ b/impl/python/tests/unit/test_arch_invariants_drift.py @@ -0,0 +1,194 @@ +"""Architectural-invariants drift test (long-term-viability audit Phase C). + +``ARCHITECTURE.md §6`` declares the numbered architectural invariants +(currently 1..16). ``ARCHITECTURE.md §6.5`` then maps each numbered +invariant to the deterministic check (or explicit "reviewed") that +enforces it. + +This test pins the relationship between the two: + +1. **Every numbered invariant in §6 has a row in §6.5.** If §6 grows + a new invariant 17 and §6.5 forgets to list it, this test fails — + "added an invariant, forgot to declare the enforcement" is exactly + the drift we want to refuse. + +2. **Every row in §6.5 cites an existing file.** A row that names + ``pyproject.toml`` for an import-linter contract, a + ``tests/properties/test_X.py`` for a property test, or a similar + on-disk artefact must resolve. A renamed or removed enforcement + file is caught here, not at audit time months later. + +The test reads ``ARCHITECTURE.md`` once and parses it; it does not +load any spec heuristically, so it stays cheap (<50 ms) and side-effect +free. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[4] +_PYTHON_IMPL = _REPO_ROOT / "impl" / "python" +_ARCH_FILE = _PYTHON_IMPL / "ARCHITECTURE.md" + +# §6 invariants look like ``1. **Closed state.** ...`` after a sub-heading. +# We tolerate up to 3 digits and the bold-italic period after the +# heading text. +_INVARIANT_RE = re.compile(r"^(?P\d{1,3})\.\s+\*\*") + +# §6.5 rows look like ``| 3 | Pure functions | ... |`` (table form). +_CATALOG_ROW_RE = re.compile(r"^\|\s*(?P\d{1,3})\s*\|") + +# Files cited in §6.5 cells. We pick up paths that look like +# ``tests/.../*.py``, ``src/.../*.py``, ``docs/.../*.md``, +# ``pyproject.toml``, ``Makefile``, ``INFRA.md`` etc. The intent is to +# extract paths that should resolve from ``impl/python/`` (the repo of +# this test) so the linter / arch-test / property-test files can be +# checked for existence. +_FILE_REF_RE = re.compile( + r"`(?P" + r"(?:tests|src|docs|conformance|examples|scripts)/[\w./_-]+\.(?:py|md|toml|json|yaml)" + r"|pyproject\.toml" + r"|Makefile" + r"|INFRA\.md" + r"|ARCHITECTURE\.md" + r"|SPEC\.md" + r")`" +) + + +def _read_arch_md() -> str: + assert _ARCH_FILE.is_file(), ( + f"ARCHITECTURE.md not found at {_ARCH_FILE}. Update the " + "drift test if the file was relocated." + ) + return _ARCH_FILE.read_text(encoding="utf-8") + + +def _section_slice(text: str, heading: str, next_heading: str) -> str: + """Return the text between ``heading`` and ``next_heading``. + + Both headings are matched as line prefixes (with the leading ``#``s + intact). Returns the empty string if ``heading`` is not found. + """ + lines = text.splitlines() + start: int | None = None + end: int | None = None + for idx, line in enumerate(lines): + if line.startswith(heading) and start is None: + start = idx + 1 + continue + if start is not None and line.startswith(next_heading): + end = idx + break + if start is None: + return "" + return "\n".join(lines[start : end or len(lines)]) + + +def _collect_invariant_numbers(arch_text: str) -> set[int]: + """Return the set of numbered invariants declared inside §6 (1..N).""" + section_6 = _section_slice(arch_text, "## 6. ", "## 7. ") + # §6.5 is itself a sub-section of §6, but its rows start with the + # invariant number in a table — those match _INVARIANT_RE too if + # we don't stop early. We slice §6 to end at §6.5 to keep only the + # narrative invariants from the four sub-section groupings. + pre_catalog = section_6.split("### Invariants enforced in code", 1)[0] + nums: set[int] = set() + for line in pre_catalog.splitlines(): + match = _INVARIANT_RE.match(line) + if match: + nums.add(int(match.group("num"))) + return nums + + +def _collect_catalog(arch_text: str) -> tuple[set[int], dict[int, set[str]]]: + """Read the §6.5 catalog table. + + Returns ``(row_numbers, row_cited_paths)`` where ``row_cited_paths`` + maps each row's invariant number to the set of repo-relative paths + cited in the enforcement column. + """ + section_65 = _section_slice(arch_text, "### Invariants enforced in code", "## 7. ") + nums: set[int] = set() + cited: dict[int, set[str]] = {} + for line in section_65.splitlines(): + row_match = _CATALOG_ROW_RE.match(line) + if not row_match: + continue + num = int(row_match.group("num")) + nums.add(num) + cited.setdefault(num, set()).update( + m.group("path") for m in _FILE_REF_RE.finditer(line) + ) + return nums, cited + + +def test_every_invariant_has_a_catalog_row() -> None: + """§6 numbered invariants ⊆ §6.5 catalog rows. + + Add a new invariant to §6? You must add the matching row to §6.5, + naming the deterministic check (or stating "reviewed" with + rationale). This is the design-time rule from the long-term- + viability audit applied to invariants themselves. + """ + arch_text = _read_arch_md() + declared = _collect_invariant_numbers(arch_text) + catalogued, _ = _collect_catalog(arch_text) + assert declared, ( + "No numbered invariants found in ARCHITECTURE.md §6. The " + "drift test cannot run; check the invariant heading format " + "(expected ``N. **Title.**``)." + ) + missing = sorted(declared - catalogued) + assert not missing, ( + "ARCHITECTURE.md §6 declares invariants that §6.5 (Invariants " + f"enforced in code) does not catalogue: {missing}. Add a row " + "for each — drift test or import-linter contract if " + "mechanically possible, 'reviewed' with rationale otherwise." + ) + + +def test_catalog_does_not_invent_invariants() -> None: + """§6.5 catalog rows ⊆ §6 numbered invariants. + + A row for invariant 99 in §6.5 with no invariant 99 in §6 means + a stale row from an old structure; remove it or restore the + matching invariant. + """ + arch_text = _read_arch_md() + declared = _collect_invariant_numbers(arch_text) + catalogued, _ = _collect_catalog(arch_text) + extras = sorted(catalogued - declared) + assert not extras, ( + "ARCHITECTURE.md §6.5 lists invariants that §6 does not " + f"declare: {extras}. Remove the row or restore the invariant." + ) + + +def test_catalog_cited_paths_resolve() -> None: + """Every file referenced in a §6.5 enforcement cell exists. + + Catches catalog rows that point at a deleted test or a renamed + file. The lookup is intentionally string-match-only (no parsing + of test contents) so the check is cheap. + """ + arch_text = _read_arch_md() + _, cited = _collect_catalog(arch_text) + missing: dict[int, list[str]] = {} + for invariant, paths in cited.items(): + for path in sorted(paths): + resolved = _PYTHON_IMPL / path + if not resolved.exists(): + missing.setdefault(invariant, []).append(str(resolved)) + assert not missing, ( + "ARCHITECTURE.md §6.5 cites files that do not exist on disk:\n" + + "\n".join( + f" invariant {invariant}: {', '.join(paths)}" + for invariant, paths in sorted(missing.items()) + ) + + "\n\nFix the path or restore the file. Catalog rows must " + "name an enforcement that actually exists; an unresolved " + "row is a missing check, not a passing one." + ) diff --git a/impl/python/tests/unit/test_cli.py b/impl/python/tests/unit/test_cli.py new file mode 100644 index 0000000..a018823 --- /dev/null +++ b/impl/python/tests/unit/test_cli.py @@ -0,0 +1,246 @@ +"""Tests for the ``python -m osi`` CLI surface.""" + +from __future__ import annotations + +import json +import textwrap +from pathlib import Path + +import pytest + +from osi.cli import main + +_MODEL_YAML = textwrap.dedent("""\ + semantic_model: + - name: demo + dialect: ANSI_SQL + datasets: + - name: orders + source: sales.orders + primary_key: [order_id] + fields: + - name: order_id + expression: order_id + role: dimension + - name: customer_id + expression: customer_id + role: dimension + - name: status + expression: status + role: dimension + - name: amount + expression: amount + role: fact + - name: customers + source: sales.customers + primary_key: [id] + fields: + - name: id + expression: id + role: dimension + - name: region + expression: region + role: dimension + relationships: + - name: orders_to_customers + from: orders + to: customers + from_columns: [customer_id] + to_columns: [id] + metrics: + - name: total_revenue + expression: SUM(orders.amount) + """) + +_QUERY_JSON = { + "dimensions": [{"dataset": "customers", "name": "region"}], + "measures": [{"dataset": "orders", "name": "total_revenue"}], +} + + +@pytest.fixture() +def model_path(tmp_path: Path) -> Path: + p = tmp_path / "model.yaml" + p.write_text(_MODEL_YAML) + return p + + +@pytest.fixture() +def query_path(tmp_path: Path) -> Path: + p = tmp_path / "query.json" + p.write_text(json.dumps(_QUERY_JSON)) + return p + + +def test_describe__text_mode(capsys, model_path: Path) -> None: + rc = main(["describe", str(model_path)]) + assert rc == 0 + out = capsys.readouterr().out + assert "orders" in out and "customers" in out + + +def test_describe__json_mode(capsys, model_path: Path) -> None: + rc = main(["describe", str(model_path), "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data["name"] == "demo" + assert {d["name"] for d in data["datasets"]} == {"orders", "customers"} + + +def test_explain__shows_plan_steps(capsys, model_path: Path, query_path: Path) -> None: + rc = main(["explain", str(model_path), str(query_path)]) + assert rc == 0 + out = capsys.readouterr().out + assert "step_000" in out + + +def test_resolve__lists_relationships( + capsys, model_path: Path, query_path: Path +) -> None: + rc = main(["resolve", str(model_path), str(query_path), "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert "orders_to_customers" in {r["name"] for r in data["relationships"]} + + +def test_compile__emits_duckdb_sql(capsys, model_path: Path, query_path: Path) -> None: + rc = main(["compile", str(model_path), str(query_path), "--dialect", "duckdb"]) + assert rc == 0 + out = capsys.readouterr().out + assert "WITH" in out and "SELECT" in out + + +def test_explain_code__by_enum_name(capsys) -> None: + rc = main(["explain-code", "E_NAME_NOT_FOUND"]) + assert rc == 0 + out = capsys.readouterr().out + assert out.startswith("E_NAME_NOT_FOUND") + assert "did not resolve" in out + + +def test_explain_code__by_numeric_value(capsys) -> None: + rc = main(["explain-code", "E1001"]) + assert rc == 0 + out = capsys.readouterr().out + assert out.startswith("E1001") + assert "YAML" in out + + +def test_explain_code__case_insensitive(capsys) -> None: + rc = main(["explain-code", "e_name_not_found"]) + assert rc == 0 + out = capsys.readouterr().out + assert out.startswith("E_NAME_NOT_FOUND") + + +def test_explain_code__json_mode(capsys) -> None: + rc = main(["explain-code", "E_NAME_NOT_FOUND", "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + assert data["code"] == "E_NAME_NOT_FOUND" + assert "explanation" in data and data["explanation"] + + +def test_explain_code__list_emits_every_code(capsys) -> None: + rc = main(["explain-code", "--list", "--json"]) + assert rc == 0 + data = json.loads(capsys.readouterr().out) + from osi.errors import ErrorCode + + assert set(data) == {c.value for c in ErrorCode} + + +def test_explain_code__unknown_code_returns_2(capsys) -> None: + rc = main(["explain-code", "NOT_A_CODE"]) + assert rc == 2 + err = capsys.readouterr().err + assert "is not a known OSI error code" in err + + +def test_explain_code__missing_code_returns_2(capsys) -> None: + rc = main(["explain-code"]) + assert rc == 2 + err = capsys.readouterr().err + assert "required" in err + + +def test_cli__reports_osi_errors_via_stderr( + capsys, tmp_path: Path, model_path: Path +) -> None: + bad = tmp_path / "bad.json" + bad.write_text( + json.dumps( + { + "dimensions": [], + "measures": [{"dataset": "orders", "name": "no_such_metric"}], + } + ) + ) + rc = main(["explain", str(model_path), str(bad)]) + assert rc == 2 + err = capsys.readouterr().err + assert err.startswith("E") + + +def test_cli__surfaces_osi_error_context_under_message( + capsys, tmp_path: Path, model_path: Path +) -> None: + """CLI surfaces ``OSIError.context`` after the ``code: message`` line. + + Phase 10 P1 (10b I1): structured ``OSIError.context`` is part of the + user-facing diagnostic. The CLI prints it as an indented + ``context:`` block immediately after the ``code: message`` line so + authors debugging from a terminal see the same actionable hints + Python callers would see via ``error.context``. + """ + from osi.errors import ErrorCode, OSIError + + # The CLI surfaces err.context when it is non-empty. We exercise + # this by injecting a raise via main()'s subcommand dispatch. + err_with_context = OSIError( + ErrorCode.E_NAME_NOT_FOUND, + "orders.no_such_metric did not resolve", + context={"name": "no_such_metric", "dataset": "orders"}, + ) + + from osi import cli as cli_module + + def _raise_error(_args: object) -> int: + raise err_with_context + + saved = cli_module._cmd_explain_code # type: ignore[attr-defined] + cli_module._cmd_explain_code = _raise_error # type: ignore[attr-defined] + try: + rc = main(["explain-code", "ANY"]) + finally: + cli_module._cmd_explain_code = saved # type: ignore[attr-defined] + assert rc == 2 + err = capsys.readouterr().err + lines = err.splitlines() + assert lines[0].startswith("E_NAME_NOT_FOUND:"), lines[0] + assert "context:" in err, "expected an indented context: block in stderr" + assert "no_such_metric" in err + assert "orders" in err + + +def test_cli__omits_context_block_when_context_is_empty(capsys) -> None: + """An empty ``OSIError.context`` produces no ``context:`` block. + + The short happy-path failure output stays unchanged so we do not emit + a spurious empty ``context:`` block. + """ + from osi import cli as cli_module + from osi.errors import ErrorCode, OSIError + + def _raise_error(_args: object) -> int: + raise OSIError(ErrorCode.E1001_YAML_SYNTAX, "boom") + + saved = cli_module._cmd_explain_code # type: ignore[attr-defined] + cli_module._cmd_explain_code = _raise_error # type: ignore[attr-defined] + try: + rc = main(["explain-code", "ANY"]) + finally: + cli_module._cmd_explain_code = saved # type: ignore[attr-defined] + assert rc == 2 + err = capsys.readouterr().err + assert err == "E1001: boom\n" diff --git a/impl/python/tests/unit/test_common_identifiers.py b/impl/python/tests/unit/test_common_identifiers.py new file mode 100644 index 0000000..2a5ec60 --- /dev/null +++ b/impl/python/tests/unit/test_common_identifiers.py @@ -0,0 +1,85 @@ +"""Unit tests for ``osi.common.identifiers``. + +These tests exist at the common layer because identifier normalization +is **invariant 11** (``ARCHITECTURE.md``). Every compiler layer depends +on it, so a regression here cascades everywhere. +""" + +from __future__ import annotations + +import pytest +from hypothesis import given + +from osi.common.identifiers import ( + identifiers_equal, + is_valid_identifier, + normalize_identifier, +) +from osi.errors import ErrorCode, OSIError +from tests.properties.strategies import identifiers + + +class TestIsValidIdentifier: + @pytest.mark.parametrize( + "raw", + ["a", "abc", "a1", "a_b", "_x", "CustomerId", "x_123"], + ) + def test_accepts_valid_shapes(self, raw: str) -> None: + assert is_valid_identifier(raw) + + @pytest.mark.parametrize( + "raw", + ["", "1a", "a-b", "a.b", "a b", "a!", "a\n"], + ) + def test_rejects_invalid_shapes(self, raw: str) -> None: + assert not is_valid_identifier(raw) + + +class TestNormalizeIdentifier: + def test_lowercases(self) -> None: + assert normalize_identifier("CustomerId") == "customerid" + + def test_returns_same_for_already_normalized(self) -> None: + assert normalize_identifier("orders") == "orders" + + def test_empty_raises_E1005(self) -> None: + with pytest.raises(OSIError) as exc_info: + normalize_identifier("") + assert exc_info.value.code == ErrorCode.E1005_IDENTIFIER_INVALID + + def test_invalid_shape_raises_E1005(self) -> None: + with pytest.raises(OSIError) as exc_info: + normalize_identifier("1bad") + assert exc_info.value.code == ErrorCode.E1005_IDENTIFIER_INVALID + + def test_non_string_raises_E1005(self) -> None: + with pytest.raises(OSIError) as exc_info: + normalize_identifier(123) # type: ignore[arg-type] + assert exc_info.value.code == ErrorCode.E1005_IDENTIFIER_INVALID + + @pytest.mark.parametrize("reserved", ["__grain__", "__provenance__", "__all__"]) + def test_reserved_raises_E2008(self, reserved: str) -> None: + with pytest.raises(OSIError) as exc_info: + normalize_identifier(reserved) + assert exc_info.value.code == ErrorCode.E2008_RESERVED_IDENTIFIER + + +class TestIdentifiersEqual: + def test_case_insensitive(self) -> None: + assert identifiers_equal("Orders", "orders") + assert identifiers_equal("ORDERS", "orders") + + def test_distinct_names_not_equal(self) -> None: + assert not identifiers_equal("orders", "customers") + + +class TestNormalizeProperty: + @given(identifiers()) + def test_normalize_is_idempotent(self, ident: str) -> None: + once = normalize_identifier(ident) + twice = normalize_identifier(once) + assert once == twice + + @given(identifiers()) + def test_generated_identifiers_are_valid(self, ident: str) -> None: + assert is_valid_identifier(ident) diff --git a/impl/python/tests/unit/test_common_sql_expr.py b/impl/python/tests/unit/test_common_sql_expr.py new file mode 100644 index 0000000..ec57293 --- /dev/null +++ b/impl/python/tests/unit/test_common_sql_expr.py @@ -0,0 +1,67 @@ +"""Unit tests for :mod:`osi.common.sql_expr`. + +Covers the invariant 10 contract (``ARCHITECTURE.md``): SQL fragments +travel between layers only as :class:`sqlglot.exp.Expression` values, +never as raw strings, and structural comparisons go through +:func:`sql_expr_equal`. +""" + +from __future__ import annotations + +import pytest +import sqlglot +from sqlglot import exp + +from osi.common.sql_expr import FrozenSQL, parse_sql_expr, sql_expr_equal +from osi.errors import ErrorCode, OSIError + + +class TestParseSqlExpr: + def test_parses_simple_column(self) -> None: + expr = parse_sql_expr("orders.total_amount") + assert isinstance(expr, exp.Expression) + + def test_parses_function_call(self) -> None: + expr = parse_sql_expr("SUM(orders.total_amount)") + assert isinstance(expr, exp.Sum) + + def test_invalid_raises_E5002(self) -> None: + with pytest.raises(OSIError) as exc_info: + parse_sql_expr("...") + assert exc_info.value.code == ErrorCode.E1006_SQL_EXPRESSION_SYNTAX + + +class TestSqlExprEqual: + def test_identical_expressions_are_equal(self) -> None: + a = sqlglot.parse_one("a + b") + b = sqlglot.parse_one("a + b") + assert sql_expr_equal(a, b) + + def test_different_expressions_are_not_equal(self) -> None: + a = sqlglot.parse_one("a + b") + b = sqlglot.parse_one("a - b") + assert not sql_expr_equal(a, b) + + +class TestFrozenSQL: + def test_wraps_expression_with_canonical(self) -> None: + expr = sqlglot.parse_one("COUNT(*)") + wrapped = FrozenSQL.of(expr) + assert wrapped.expr is expr + assert "COUNT" in wrapped.canonical.upper() + + def test_is_hashable(self) -> None: + expr = sqlglot.parse_one("1 + 1") + wrapped = FrozenSQL.of(expr) + assert hash(wrapped) == hash(FrozenSQL.of(sqlglot.parse_one("1 + 1"))) + + def test_equality_uses_canonical_form(self) -> None: + a = FrozenSQL.of(sqlglot.parse_one("a + b")) + b = FrozenSQL.of(sqlglot.parse_one("a+b")) + c = FrozenSQL.of(sqlglot.parse_one("b + a")) + assert a == b + assert a != c + + def test_equality_with_non_frozen_returns_not_implemented(self) -> None: + wrapped = FrozenSQL.of(sqlglot.parse_one("1")) + assert (wrapped == "1") is False diff --git a/impl/python/tests/unit/test_error_catalog.py b/impl/python/tests/unit/test_error_catalog.py new file mode 100644 index 0000000..2029a0d --- /dev/null +++ b/impl/python/tests/unit/test_error_catalog.py @@ -0,0 +1,95 @@ +"""Invariants linking :mod:`osi.errors` to ``docs/ERROR_CODES.md``. + +Two catalog invariants: + +1. The set of code names in the docs table equals the set in the enum + (no orphan docs, no orphan enum values). +2. Every code annotated ``RESERVED`` in the docs has a matching + ``# RESERVED`` comment on the enum member, and vice versa. This + keeps the two sources of truth from drifting. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +_DOCS_PATH = Path(__file__).resolve().parents[2] / "docs" / "ERROR_CODES.md" +_SRC_PATH = Path(__file__).resolve().parents[2] / "src" / "osi" / "errors.py" + + +def _codes_in_docs() -> dict[str, bool]: + """Return ``{code: is_reserved}`` for every row in the catalog tables.""" + text = _DOCS_PATH.read_text(encoding="utf-8") + out: dict[str, bool] = {} + # Each catalog row begins with ``| `E...` |`` and may declare a + # status column. We accept both legacy rows (no status column) and + # the current shape (status column immediately after the code). + # Codes are either the legacy numeric form (``E1234``/``W6001``) + # or the Foundation v0.1 named form (``E_NAMED_LIKE_THIS``). + row_pattern = re.compile( + r"^\|\s*`(?P[EW]\d{4}|E_[A-Z0-9_]+)`\s*\|\s*(?P.+)\|", + re.MULTILINE, + ) + for match in row_pattern.finditer(text): + code = match.group("code") + rest = match.group("rest") + first_cell = rest.split("|", 1)[0].strip() + out[code] = first_cell.upper() == "RESERVED" + return out + + +def _codes_in_enum() -> dict[str, bool]: + """Return ``{code: annotated_reserved}`` for every enum member. + + ``RESERVED`` is recognised only when it appears in a Python comment + (inline after the value or on a comment line immediately above it) + — this avoids confusing a member's *name* (e.g. + ``E_DEFERRED_KEY_REJECTED``) with the status annotation. + """ + text = _SRC_PATH.read_text(encoding="utf-8") + lines = text.splitlines() + out: dict[str, bool] = {} + decl_re = re.compile( + r"^\s*(?P(?:[EW]\d{4}_[A-Z0-9_]+|E_[A-Z0-9_]+))" + r"\s*=\s*\"(?P(?:[EW]\d{4}|E_[A-Z0-9_]+))\"" + r"(?P.*)$" + ) + for i, line in enumerate(lines): + match = decl_re.match(line) + if match is None: + continue + value = match.group("value") + inline = match.group("inline") + inline_has_reserved = "#" in inline and "RESERVED" in inline.split("#", 1)[1] + # Walk the comment block directly above the declaration. + preceding_has_reserved = False + j = i - 1 + while j >= 0 and lines[j].lstrip().startswith("#"): + if "RESERVED" in lines[j]: + preceding_has_reserved = True + break + j -= 1 + out[value] = inline_has_reserved or preceding_has_reserved + return out + + +def test_docs_and_enum_codes_are_identical_sets() -> None: + docs = _codes_in_docs() + enum_codes = _codes_in_enum() + assert set(docs) == set(enum_codes), ( + f"docs-only: {sorted(set(docs) - set(enum_codes))}; " + f"enum-only: {sorted(set(enum_codes) - set(docs))}" + ) + + +@pytest.mark.parametrize("code", sorted(_codes_in_enum())) +def test_reserved_annotation_matches_between_docs_and_enum(code: str) -> None: + docs_reserved = _codes_in_docs()[code] + enum_reserved = _codes_in_enum()[code] + assert docs_reserved == enum_reserved, ( + f"{code}: docs RESERVED={docs_reserved}, enum RESERVED={enum_reserved} " + "— keep the two sources of truth in sync." + ) diff --git a/impl/python/tests/unit/test_errors.py b/impl/python/tests/unit/test_errors.py new file mode 100644 index 0000000..24fbee1 --- /dev/null +++ b/impl/python/tests/unit/test_errors.py @@ -0,0 +1,184 @@ +"""Unit tests for :mod:`osi.errors`. + +Invariants (per ``ARCHITECTURE.md §7``): + +1. Every raised exception in production code is an ``OSIError`` subclass + with a stable code. +2. Tests assert on ``error.code``, never on message text. This file + double-checks that assertion remains mechanically possible. +3. Every ``Exception`` subclass declared anywhere under ``osi.*`` is + itself an ``OSIError`` (or explicitly allow-listed). The arch test + below walks every loaded ``osi`` module and enforces this. +""" + +from __future__ import annotations + +import importlib +import inspect +import pkgutil +import re + +import pytest + +import osi +from osi.errors import ( + AlgebraError, + ErrorCode, + OSICodegenError, + OSIError, + OSIParseError, + OSIPlanningError, + OSIWarning, +) + + +class TestErrorCode: + def test_codes_are_stable_strings(self) -> None: + assert ErrorCode.E_DEFERRED_KEY_REJECTED.value == "E_DEFERRED_KEY_REJECTED" + assert ErrorCode.E4001_EXPLOSION_UNSAFE.value == "E4001" + assert ErrorCode.E5001_DIALECT_UNSUPPORTED.value == "E5001" + + def test_all_codes_have_correct_prefix(self) -> None: + # Legacy numeric prefixes (E1xxx..E5xxx, W6xxx) coexist with the + # Foundation v0.1 named family (E_*) during the rollout. The + # named family is migrating in via S-1..S-17; both must remain + # valid until S-17 (final compliance) deletes the last legacy + # numeric code. + prefixes = {"E1", "E2", "E3", "E4", "E5", "W6", "E_"} + for code in ErrorCode: + assert code.value[:2] in prefixes, f"bad prefix for {code}" + + def test_codes_are_unique(self) -> None: + values = [code.value for code in ErrorCode] + assert len(values) == len(set(values)) + + +class TestOSIError: + def test_carries_code_and_message(self) -> None: + err = OSIError(ErrorCode.E1001_YAML_SYNTAX, "bad yaml") + assert err.code is ErrorCode.E1001_YAML_SYNTAX + assert "bad yaml" in str(err) + + def test_context_defaults_to_empty_dict(self) -> None: + err = OSIError(ErrorCode.E1001_YAML_SYNTAX, "x") + assert err.context == {} + + def test_context_is_copied_defensively(self) -> None: + src = {"key": "value"} + err = OSIError(ErrorCode.E1001_YAML_SYNTAX, "x", context=src) + src["key"] = "mutated" + assert err.context == {"key": "value"} + + @pytest.mark.parametrize( + "cls", + [OSIParseError, OSIPlanningError, AlgebraError, OSICodegenError, OSIWarning], + ) + def test_subclasses_are_osi_errors(self, cls: type[OSIError]) -> None: + err = cls(ErrorCode.E1001_YAML_SYNTAX, "x") + assert isinstance(err, OSIError) + assert err.code is ErrorCode.E1001_YAML_SYNTAX + + +# Allow-list of exception classes that are intentionally not ``OSIError``. +# This must stay empty unless there is a documented reason (e.g. an +# adapter-boundary translation class that wraps a third-party SDK error). +_NON_OSI_EXCEPTION_ALLOWLIST: frozenset[str] = frozenset() + + +def _walk_osi_exception_classes() -> list[type[BaseException]]: + """Import every module under ``osi.*`` and return every Exception class. + + Only classes whose ``__module__`` starts with ``osi.`` are returned — + re-exports of ``Exception``/``ValueError`` etc. are filtered out. + ``__main__`` modules are skipped because importing them executes + their CLI entry point. + """ + seen: set[type[BaseException]] = set() + package_path = osi.__path__ + for module_info in pkgutil.walk_packages(package_path, prefix="osi."): + if module_info.name.endswith(".__main__"): + continue + try: + module = importlib.import_module(module_info.name) + except Exception: + continue + for _name, obj in inspect.getmembers(module, inspect.isclass): + if not issubclass(obj, BaseException): + continue + if not obj.__module__.startswith("osi."): + continue + seen.add(obj) + return sorted(seen, key=lambda c: f"{c.__module__}.{c.__qualname__}") + + +class TestExceptionHierarchyInvariant: + """Architecture test: every osi.* Exception is an OSIError. + + Regression guard for the Phase 8c finding that + ``GrainSimulationError`` subclassed ``ValueError`` and slipped past + the typed-error doctrine. Adding a new ``Exception`` subclass under + ``osi.*`` is now a deliberate act: either inherit from ``OSIError`` + or add the fully qualified name to ``_NON_OSI_EXCEPTION_ALLOWLIST`` + with a comment explaining why. + """ + + def test_every_osi_exception_inherits_from_osi_error(self) -> None: + violations: list[str] = [] + for cls in _walk_osi_exception_classes(): + qualname = f"{cls.__module__}.{cls.__qualname__}" + if qualname in _NON_OSI_EXCEPTION_ALLOWLIST: + continue + if not issubclass(cls, OSIError): + violations.append(qualname) + assert not violations, ( + "These exception classes live under osi.* but do not inherit from " + "OSIError. Either fix the inheritance or extend " + "_NON_OSI_EXCEPTION_ALLOWLIST with rationale:\n " + "\n ".join(violations) + ) + + +class TestPytestRaisesPinsCode: + """Meta-test: every ``pytest.raises(OSI*)`` call pins ``.code``. + + The Phase 8c review flagged a hole where ``pytest.raises(OSIError)`` + blocks landed without an ``error.code is ErrorCode.…`` follow-up, + leaving the test type-wide and silently false-positive on the + wrong code. This meta-test scans every test file under ``tests/`` + and asserts that within twelve lines of every typed ``pytest.raises`` + block there is a ``.code`` reference. + + The twelve-line window matches our test style: assertions on + ``error.code`` (and any other context) sit immediately under the + ``with`` block. If a test legitimately needs a wider window it + should be refactored, not the window widened. + """ + + _OSI_EXCEPTION_PATTERN = re.compile( + r"pytest\.raises\((OSI\w*|AlgebraError|OSIWarning)\)" + ) + _WINDOW_LINES = 12 + + def test_pytest_raises_typed_exception_always_pins_code(self) -> None: + import pathlib + + tests_root = pathlib.Path(__file__).resolve().parents[1] + violations: list[str] = [] + for path in sorted(tests_root.rglob("*.py")): + text = path.read_text() + lines = text.splitlines() + for idx, line in enumerate(lines): + if not self._OSI_EXCEPTION_PATTERN.search(line): + continue + window = "\n".join(lines[idx + 1 : idx + 1 + self._WINDOW_LINES]) + if ".code" not in window: + rel = path.relative_to(tests_root) + violations.append(f"{rel}:{idx + 1}: {line.strip()}") + assert not violations, ( + "These pytest.raises blocks catch an OSI-typed exception but " + "do not pin error.code within the next {n} lines. Either add " + "an explicit `.code is ErrorCode.…` assertion or refactor the " + "test to keep the assertion close to the raise:\n ".format( + n=self._WINDOW_LINES + ) + + "\n ".join(violations) + ) diff --git a/impl/python/tests/unit/test_every_exception_is_osierror.py b/impl/python/tests/unit/test_every_exception_is_osierror.py new file mode 100644 index 0000000..115f397 --- /dev/null +++ b/impl/python/tests/unit/test_every_exception_is_osierror.py @@ -0,0 +1,315 @@ +"""Every-exception-is-OSIError arch-test (long-term-viability audit Phase D). + +``ARCHITECTURE.md §6`` invariant 9 — "No silent wrong SQL" — depends on +every failure path raising a typed :class:`OSIError` (or subclass). The +existing :file:`tests/properties/test_error_taxonomy.py` enforces this +for the algebra only (it runs the algebra operators through Hypothesis +and asserts the raised exception is :class:`OSIError`). That leaves +parsing, classification, codegen, diagnostics, and the CLI uncovered — +exactly the layers that talk to user inputs and therefore have the +most plausible places to leak a raw :class:`ValueError`, +:class:`TypeError`, or :class:`RuntimeError`. + +This arch-test closes that gap by walking the AST of every module +under ``src/osi/`` and confirming each ``raise (...)`` statement +either: + +1. Names a class in the OSIError family (see :data:`_OSI_ERROR_CLASSES`). +2. Is a re-raise (``raise`` with no argument, or ``raise some_caught_var``). +3. Lives inside an exception-conversion ``except`` that wraps the + original; the wrapper still must raise :class:`OSIError`. + +Anything else is a leak: a place where the compiler can return an +exception type that is not :class:`OSIError`-shaped, defeating the +catch-by-code contract every test, every adapter, and every CLI +consumer relies on. + +When this test fails, apply the long-term-viability audit triage: + +1. *Convert to a deterministic check.* (This test is that check; if it + surfaced the leak, do not loosen the test — fix the raise.) +2. *Sharpen the skill.* Add the bad raise pattern to + ``compiler-best-practices-review/SKILL.md`` if it represents a + class of issue the skill missed. +3. *Tighten the docs.* If the layer's README does not explicitly + forbid raw raises, update it. +4. *Queue the code change.* If the raise represents a structural + issue that needs a refactor, file it. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[4] +_SRC = _REPO_ROOT / "impl" / "python" / "src" / "osi" + +# Allowed raise targets. ``OSIError`` itself is rarely raised directly +# in source — the more specific subclasses are preferred — but it is +# listed so an explicit raise of the base class doesn't trip the test. +_OSI_ERROR_CLASSES: frozenset[str] = frozenset( + { + "OSIError", + "OSIParseError", + "OSIPlanningError", + "OSICodegenError", + "AlgebraError", + "GrainSimulationError", + } +) + +# Non-OSIError exception types that are explicitly OK to raise. Add +# sparingly and with a comment. +_ALLOWED_BUILTIN_EXCEPTIONS: frozenset[str] = frozenset( + { + # ``SystemExit`` is the canonical Python signal to terminate a + # process with a numeric exit code. The CLI uses it as the + # "the user gave a bad invocation" exit path — wrapping it in + # an OSIError would be wrong, because the caller is the shell, + # not OSI-aware code. + "SystemExit", + } +) + +# Files that may raise a non-OSIError exception for a documented +# reason. Each entry must come with a one-line rationale; reviewers +# decide whether to convert or to keep the exemption. If you find +# yourself wanting to add a row here, prefer wrapping the raise in +# an OSIError-converting helper instead — the exemptions are +# load-bearing surface area. +_EXEMPT_FILES: dict[str, str] = { + # The errors module *defines* the OSIError hierarchy; its raises + # construct typed errors but the static analysis sees the bare + # ``Exception`` chain in the class definition. + "errors.py": ( + "Hosts the OSIError hierarchy itself; raises inside this " + "module are constructors of the wrapper, not failure paths." + ), +} + + +def _walk_src() -> list[Path]: + """Every ``.py`` file under ``src/osi/`` except dunders / pycache.""" + return [ + path + for path in _SRC.rglob("*.py") + if "__pycache__" not in path.parts and not path.name.startswith("_test_") + ] + + +def _classify_raise( + node: ast.Raise, +) -> tuple[str, str | None]: + """Return ``(kind, name)`` for a ``raise`` AST node. + + - ``("reraise", None)`` for bare ``raise``. + - ``("class_call", "Foo")`` for ``raise Foo(...)`` where ``Foo`` is + a class reference (CapitalisedName); the test compares ``Foo`` + against the OSIError allow-list. + - ``("helper_call", "_foo_error")`` for ``raise _foo_error(...)`` + where the function name is a wrapper-helper convention — these + are static-analysis-opaque but their return-type annotation + always names an OSIError subclass. The test trusts the naming + convention. + - ``("attr_call", "Foo")`` for ``raise pkg.Foo(...)``. + - ``("variable", "name")`` for ``raise some_lowercase_name`` — + this is a re-raise of a previously-caught exception, which the + enclosing ``except`` clause must have typed (the ``except``- + clause check forbids ``except Exception`` so the caught value is + either an OSIError subclass or a narrowly-typed third-party + exception that the surrounding code immediately wraps). + - ``("other", None)`` for anything else. + """ + if node.exc is None: + return ("reraise", None) + exc = node.exc + if isinstance(exc, ast.Call): + func = exc.func + if isinstance(func, ast.Name): + if func.id and func.id[0].isupper(): + return ("class_call", func.id) + return ("helper_call", func.id) + if isinstance(func, ast.Attribute): + return ("attr_call", func.attr) + return ("other", None) + if isinstance(exc, ast.Name): + if exc.id and exc.id[0].isupper(): + return ("class_call", exc.id) + return ("variable", exc.id) + if isinstance(exc, ast.Attribute): + return ("attr_call", exc.attr) + return ("other", None) + + +def _helper_returns_osierror(helper_name: str, tree: ast.Module) -> bool: + """Check whether a ``def helper_name(...) -> Type:`` returns OSIError. + + Walks ``tree`` for a top-level ``FunctionDef`` whose name matches + ``helper_name`` and whose return-type annotation names an + OSIError-family class. We deliberately do not chase aliases / + re-exports — the convention is that helpers are defined in the + same module that raises them. + """ + for node in tree.body: + if not isinstance(node, ast.FunctionDef) or node.name != helper_name: + continue + ann = node.returns + if isinstance(ann, ast.Name) and ann.id in _OSI_ERROR_CLASSES: + return True + if isinstance(ann, ast.Attribute) and ann.attr in _OSI_ERROR_CLASSES: + return True + return False + + +def _except_handler_only_wraps( + handler: ast.ExceptHandler, +) -> bool: + """``except Exception: ... raise OSIError(...)`` pattern detector. + + Returns ``True`` if every ``raise`` reachable from the top of the + handler body raises an OSIError subclass — meaning the handler is + *converting* the caught exception rather than propagating it. This + is the legitimate "catch external library, wrap with our code" + pattern (used in ``codegen/dialect.py`` for SQLGlot rendering, in + ``planning/classify.py`` for identifier normalisation, etc.). + """ + raises = [ + node + for node in ast.walk(ast.Module(body=handler.body, type_ignores=[])) + if isinstance(node, ast.Raise) + ] + if not raises: + return False + for node in raises: + kind, name = _classify_raise(node) + if kind == "reraise": + # ``raise`` alone re-raises the caught exception unchanged + # — that defeats the wrap-conversion intent, so this is + # NOT a "wrap" handler. + return False + if kind == "class_call" and name in _OSI_ERROR_CLASSES: + continue + if kind == "class_call" and name in _ALLOWED_BUILTIN_EXCEPTIONS: + continue + if kind == "attr_call" and name in _OSI_ERROR_CLASSES: + continue + # Anything else inside the handler is suspect. + return False + return True + + +def test_no_raw_exception_in_src() -> None: + """Every ``raise X(...)`` in ``src/osi/`` raises an OSIError subclass. + + See the module docstring for the contract. If you must raise a + non-OSIError type from ``src/``, document the reason with a row + in :data:`_EXEMPT_FILES` — and only after you have considered the + alternatives. + """ + failures: dict[str, list[tuple[int, str]]] = {} + for path in _walk_src(): + rel = path.relative_to(_SRC).as_posix() + if path.name in _EXEMPT_FILES: + continue + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + except SyntaxError: # pragma: no cover — would fail mypy first + failures.setdefault(rel, []).append((0, "")) + continue + for node in ast.walk(tree): + if not isinstance(node, ast.Raise): + continue + kind, name = _classify_raise(node) + if kind == "reraise": + continue + if kind == "variable": + # ``raise err`` where ``err`` was caught upstream. The + # ``test_no_bare_except_in_src`` companion enforces + # that ``except`` clauses are narrowly typed, so the + # caught value is either an OSIError subclass or a + # known wrapper. Either way the static analysis here + # cannot improve on that guarantee. + continue + if name is None: + failures.setdefault(rel, []).append( + (node.lineno, "") + ) + continue + if kind == "class_call" and name in _OSI_ERROR_CLASSES: + continue + if kind == "class_call" and name in _ALLOWED_BUILTIN_EXCEPTIONS: + continue + if kind == "attr_call" and name in _OSI_ERROR_CLASSES: + continue + if kind == "helper_call" and _helper_returns_osierror(name, tree): + continue + failures.setdefault(rel, []).append((node.lineno, name)) + assert not failures, ( + "Files in src/osi/ raise non-OSIError exceptions:\n" + + "\n".join( + f" {rel}: " + ", ".join(f"line {ln} → {name}" for ln, name in items) + for rel, items in sorted(failures.items()) + ) + + "\n\nFix options, in order of preference:\n" + " 1. Replace with an OSIError subclass (see osi.errors).\n" + " 2. If using a wrapper helper, annotate its return type as " + "an OSIError subclass so this test recognises the pattern.\n" + " 3. As a last resort, exempt the file in _EXEMPT_FILES with " + "a one-line rationale.\n" + "All three routes preserve the catch-by-code contract." + ) + + +def test_no_bare_except_in_src() -> None: + """``except Exception:`` is allowed only in convert-and-wrap form. + + The companion to ``test_no_raw_exception_in_src``. A leaked + :class:`ValueError` from a third-party library is fine to catch, + but it must be *converted* into an OSIError on the way out — never + propagated as itself, never silently swallowed. + + A handler passes this test iff: + + * It is narrower than ``except Exception:`` (e.g. ``except OSIError:``, + ``except ValueError:``); narrow catches are always fine because + the caller chose the type, **or** + * It is ``except Exception:`` (or bare ``except:``) **and** every + ``raise`` in the handler body raises an OSIError subclass — + i.e. it is the legitimate wrap-and-convert pattern used at the + boundary between OSI and third-party libraries. + + Empty ``except Exception: pass`` is never allowed. + """ + failures: dict[str, list[tuple[int, str]]] = {} + for path in _walk_src(): + if path.name in _EXEMPT_FILES: + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, ast.ExceptHandler): + continue + is_broad = node.type is None or ( + isinstance(node.type, ast.Name) and node.type.id == "Exception" + ) + if not is_broad: + continue + if _except_handler_only_wraps(node): + continue + label = "bare except" if node.type is None else "except Exception" + failures.setdefault(path.relative_to(_SRC).as_posix(), []).append( + (node.lineno, label) + ) + assert not failures, ( + "Files in src/osi/ have a broad except that does not wrap " + "into an OSIError:\n" + + "\n".join( + f" {rel}: " + ", ".join(f"line {ln} → {label}" for ln, label in items) + for rel, items in sorted(failures.items()) + ) + + "\n\nFix options:\n" + " 1. Narrow the catch to a specific exception type.\n" + " 2. If catching a third-party exception, raise an " + "OSIError-family class inside the handler — the convert-and-" + "wrap pattern. Bare ``except: pass`` is never the right answer." + ) diff --git a/impl/python/tests/unit/test_layer_readme_drift.py b/impl/python/tests/unit/test_layer_readme_drift.py new file mode 100644 index 0000000..8110278 --- /dev/null +++ b/impl/python/tests/unit/test_layer_readme_drift.py @@ -0,0 +1,193 @@ +"""Layer-README drift test (long-term-viability audit Phase C). + +Every layer under ``impl/python/src/osi/`` carries a ``README.md`` that +documents what lives in the folder. Without a drift test, the README +gets stale the moment someone adds a new module — contributors lose +their map, reviewers stop trusting the README, and onboarding rots +silently. + +This test pins the relationship between each layer README and the +filesystem: + +1. **Every ``.py`` file in the layer (except ``__init__.py``) appears + in the README's "Module map" / "Modules" section.** A new + ``new_helper.py`` without a README mention fails this test. + +2. **Every backticked file name in the section actually exists in the + layer.** A renamed module that the README still cites by old name + fails this test. + +The intent is to refuse "added a module, forgot the README" PRs +mechanically — the design-time rule from the long-term-viability audit +applied to docs. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[4] +_OSI_SRC = _REPO_ROOT / "impl" / "python" / "src" / "osi" + +# Layers that have a README + are expected to keep it in sync. The +# ``algebra`` sub-package under ``planning/`` has its own README in +# the long-tail and is treated as a separate layer here. +_LAYERS: tuple[str, ...] = ( + "common", + "parsing", + "planning", + "codegen", + "diagnostics", +) + +# Files that the README is not required to mention. ``__init__.py`` is +# always the layer facade; mentioning it adds noise. ``py.typed`` is a +# packaging marker. ``__main__.py`` exists only at the top level. +_ALWAYS_OMIT: frozenset[str] = frozenset( + {"__init__.py", "py.typed", "__main__.py", "_root.py"} +) + +# Section names under which we look for module references. Layer +# READMEs are inconsistent ("Module map" vs "Modules"); we match +# either, and stop at the next *top-level* ``## `` heading — we want +# sub-sections (``### Core IR`` etc.) inside the Modules block to stay +# part of the same conceptual section, not split it. +_MODULES_HEADING_RE = re.compile(r"^##\s+(?:Module map|Modules)\b", re.IGNORECASE) +_NEXT_SECTION_RE = re.compile(r"^##\s") + +# Module references inside a section. We require the ``.py`` suffix +# or a trailing ``/`` so backticked tokens like +# ```normalize_identifier``` (a public function name) or +# ```E_DEFERRED_KEY_REJECTED``` (an error code) are NOT misread as +# module claims. The two patterns we match are: +# +# ``- `name.py` — desc`` (bullet, file) +# ``- `name/` — desc`` (bullet, sub-package) +# ``| `name.py` | desc |`` (table cell, file) +# ``| `name/` | desc |`` (table cell, sub-package) +_REF_RE = re.compile(r"`(?P[\w_]+)(?:\.py|/)`") + + +def _modules_section_text(readme: Path) -> str: + """Return the text between the modules heading and the next heading.""" + lines = readme.read_text(encoding="utf-8").splitlines() + start: int | None = None + end: int | None = None + for idx, line in enumerate(lines): + if start is None: + if _MODULES_HEADING_RE.match(line): + start = idx + 1 + continue + if _NEXT_SECTION_RE.match(line): + end = idx + break + if start is None: + return "" + return "\n".join(lines[start : end or len(lines)]) + + +def _layer_modules_on_disk(layer: Path) -> set[str]: + """Filenames the README is expected to mention. + + Returns names with ``.py`` stripped for ``.py`` files; sub-package + folders are returned as the folder name (so ``algebra/`` becomes + ``algebra``). + """ + files: set[str] = set() + for entry in layer.iterdir(): + if entry.name in _ALWAYS_OMIT: + continue + if entry.is_dir(): + if (entry / "__init__.py").is_file(): + files.add(entry.name) + continue + if entry.suffix != ".py": + continue + if entry.name.startswith("_"): + # Private helpers (``_root.py``, ``_internal.py``) are + # optional in the README. Skip. + continue + files.add(entry.stem) + return files + + +def _readme_mentions(readme_section: str) -> set[str]: + """Stems / folder names mentioned via backticks in the modules section.""" + return {match.group("name") for match in _REF_RE.finditer(readme_section)} + + +def test_every_layer_has_a_readme() -> None: + """The five Foundation layers all carry a README.""" + missing = [ + layer for layer in _LAYERS if not (_OSI_SRC / layer / "README.md").is_file() + ] + assert not missing, ( + "Layers without README.md: " + f"{missing}. Every layer under src/osi/ keeps an up-to-date " + "README; the layer-README drift test only runs against present " + "READMEs." + ) + + +def test_layer_readme_lists_every_module() -> None: + """Files in the layer ⊆ files cited in the README's modules section.""" + failures: dict[str, list[str]] = {} + for layer_name in _LAYERS: + layer = _OSI_SRC / layer_name + readme = layer / "README.md" + if not readme.is_file(): + continue + section = _modules_section_text(readme) + assert section, ( + f"{readme} has no Module map / Modules section — add one " + "so this drift test can keep it in sync with the filesystem." + ) + on_disk = _layer_modules_on_disk(layer) + mentioned = _readme_mentions(section) + unmentioned = sorted(on_disk - mentioned) + if unmentioned: + failures[layer_name] = unmentioned + assert not failures, ( + "Layer modules not mentioned in their README's Module map / " + "Modules section:\n" + + "\n".join( + f" {layer}/README.md is missing: {modules}" + for layer, modules in sorted(failures.items()) + ) + + "\n\nAdd a one-line entry for each new module under the " + "Modules heading. Private modules (``_internal.py``) are exempt." + ) + + +def test_layer_readme_does_not_invent_modules() -> None: + """Files cited in the README ⊆ files on disk. + + Stale citations of renamed / removed modules are caught here. + """ + failures: dict[str, list[str]] = {} + for layer_name in _LAYERS: + layer = _OSI_SRC / layer_name + readme = layer / "README.md" + if not readme.is_file(): + continue + section = _modules_section_text(readme) + on_disk = _layer_modules_on_disk(layer) + mentioned = _readme_mentions(section) + # ``_ALWAYS_OMIT`` carries ``_root.py`` etc. as bare names; + # strip the ``.py`` for comparison. + omit_stems = {name.removesuffix(".py") for name in _ALWAYS_OMIT} + stale = sorted(mentioned - on_disk - omit_stems) + if stale: + failures[layer_name] = stale + assert not failures, ( + "Layer README mentions module names that do not exist in the " + "folder:\n" + + "\n".join( + f" {layer}/README.md cites missing: {modules}" + for layer, modules in sorted(failures.items()) + ) + + "\n\nRemove the stale entry or restore the file. The drift " + "test treats any lowercase / snake_case backticked token in " + "the Modules section as a file-stem claim." + ) diff --git a/impl/python/tests/unit/test_operator_enum_sync.py b/impl/python/tests/unit/test_operator_enum_sync.py new file mode 100644 index 0000000..0586fb9 --- /dev/null +++ b/impl/python/tests/unit/test_operator_enum_sync.py @@ -0,0 +1,53 @@ +"""Invariant: the two operator enums must stay in lockstep. + +The repo has two parallel enumerations of the nine algebra operators: + +* :class:`osi.planning.algebra.grain.OperatorTag` — used by the + symbolic grain simulator. Lives next to the algebra so a simulation + can run without importing :mod:`osi.planning.plan`. +* :class:`osi.planning.plan.PlanOperation` — used by + :class:`PlanStep` and codegen. + +These two enums need to enumerate exactly the same nine operators with +exactly the same string values. They are kept separate (rather than +collapsed into one) on purpose: the algebra layer must not depend on +the plan-data-model layer (``invariant I-9``). This test is the +single source of truth that they cannot drift. + +When you add a new operator (which itself is rare — the closed algebra +has nine), update **both** enums and the assertions below. +""" + +from __future__ import annotations + +from osi.planning.algebra.grain import OperatorTag +from osi.planning.plan import PlanOperation + +_EXPECTED_NAMES = frozenset( + { + "SOURCE", + "FILTER", + "ENRICH", + "AGGREGATE", + "PROJECT", + "ADD_COLUMNS", + "MERGE", + "FILTERING_JOIN", + "BROADCAST", + } +) + + +def test_plan_and_grain_enums_agree_on_names() -> None: + """Both enums must enumerate exactly the closed-algebra nine.""" + plan_names = {member.name for member in PlanOperation} + tag_names = {member.name for member in OperatorTag} + assert plan_names == _EXPECTED_NAMES + assert tag_names == _EXPECTED_NAMES + + +def test_plan_and_grain_enums_agree_on_values() -> None: + """Same names ⇒ same string values, so a test using one matches the other.""" + plan_pairs = {member.name: member.value for member in PlanOperation} + tag_pairs = {member.name: member.value for member in OperatorTag} + assert plan_pairs == tag_pairs diff --git a/impl/python/tests/unit/test_spec_section_refs_drift.py b/impl/python/tests/unit/test_spec_section_refs_drift.py new file mode 100644 index 0000000..9d43db3 --- /dev/null +++ b/impl/python/tests/unit/test_spec_section_refs_drift.py @@ -0,0 +1,138 @@ +"""Spec section-ref drift test (long-term-viability audit Phase C). + +Every ``(Spec: §X.Y[.Z])`` citation that appears inside the Python +implementation must resolve to a real heading in +``proposals/foundation-v0.1/Proposed_OSI_Semantics.md``. Without this +test, a spec section can be renumbered or removed and the citations +across the codebase silently rot — reviewers then stop trusting them. + +This test is intentionally narrow: it parses citations of the literal +form ``(Spec: §X.Y...)`` (optionally followed by trailing tokens like +``Appendix B``, ``D-027``, or a closing comma / paren) and confirms +the leading section number appears as the prefix of a Markdown heading +in the spec file. + +Other citation families (``(D-NNN)``, ``(E_*)``, ``(F-NN)``, +``(I-NN)``, ``(T-NNN)``) have their own drift tests or live invariants +elsewhere (Appendix C drift, INFRA roadmap rows, sprint reports). Add +a new test alongside this one when introducing a new citation family +per the long-term-viability audit triage rule. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +# Source files searched for ``(Spec: §X.Y)`` citations. The spec file +# itself is excluded (it is the citation target, not a citer); test +# files are excluded because their references are illustrative only. +# +# Layout: this file lives at ``impl/python/tests/unit/.py`` so +# the repo root is four parents up. +_REPO_ROOT = Path(__file__).resolve().parents[4] +_PYTHON_IMPL_SRC = _REPO_ROOT / "impl" / "python" / "src" +_SPEC_FILE = _REPO_ROOT / "proposals" / "foundation-v0.1" / "Proposed_OSI_Semantics.md" + +# Formal citation form: ``(Spec: §X.Y[.Z])`` (parenthesised, with the +# ``Spec:`` tag). Captures the section number only — we resolve against +# headings using prefix match, so ``§4.5`` resolves whether the heading +# is ``### 4.5 Metrics`` or ``#### 4.5.1 ...``. +# +# Natural-language references like ``the spec §X.Y`` or +# ``(Foundation spec §X.Y)`` are intentionally NOT matched here: those +# are often citing the algebra spec (``JOIN_ALGEBRA.md``) rather than +# the Foundation spec, and conflating the two is the F-04 / Phase 11 +# drift this test is meant to prevent. References to JOIN_ALGEBRA.md +# should say so explicitly (``JOIN_ALGEBRA.md §3.7``); references to +# the Foundation spec should use the formal ``(Spec: §X.Y)`` form. +_CITATION_RE = re.compile( + r"\(Spec:\s+§(?P
\d+(?:\.\d+){0,3})", +) +# Markdown headings of the form ``## 4. Semantic Model`` or +# ``#### 4.6.1 Identifier Form``. The captured prefix is the +# dotted-number form; we normalise trailing dots away. +_HEADING_RE = re.compile( + r"^#{1,6}\s+(?P
\d+(?:\.\d+){0,3})\.?\s", +) + + +def _collect_spec_headings() -> set[str]: + """Return every numbered Markdown heading in the Foundation spec.""" + sections: set[str] = set() + for line in _SPEC_FILE.read_text(encoding="utf-8").splitlines(): + match = _HEADING_RE.match(line) + if match: + sections.add(match.group("section")) + return sections + + +def _collect_citations() -> dict[str, list[Path]]: + """Map every cited section to the source files that cite it.""" + citations: dict[str, list[Path]] = {} + for path in _PYTHON_IMPL_SRC.rglob("*.py"): + text = path.read_text(encoding="utf-8") + for match in _CITATION_RE.finditer(text): + section = match.group("section") + citations.setdefault(section, []).append(path) + return citations + + +def _section_resolves(cited: str, headings: set[str]) -> bool: + """``§4.5`` resolves if any heading equals or extends ``4.5``.""" + if cited in headings: + return True + for heading in headings: + if heading.startswith(cited + "."): + return True + return False + + +def test_every_spec_citation_resolves() -> None: + """Every ``(Spec: §X.Y)`` in ``impl/python/src/`` points at a real heading. + + If this test fails, either: + + 1. The spec was renumbered — update the citing source file or + restore the heading in the spec (in that PR, not later). + 2. The citation is a typo — fix the section number. + 3. The cited section is brand new and the spec PR has not landed + yet — land the spec PR in the same change set; do not commit + code that cites a section that does not exist. + """ + headings = _collect_spec_headings() + assert headings, ( + f"No numbered Markdown headings found in {_SPEC_FILE}. The " + "drift test cannot run; check the spec file structure." + ) + citations = _collect_citations() + assert citations, ( + "No (Spec: §X.Y) citations found in impl/python/src/. The " + "test should find at least the citations in " + "src/osi/diagnostics/error_catalog.py; check the citation " + "regex if no citations are detected." + ) + unresolved: dict[str, list[Path]] = { + section: sorted(set(paths)) + for section, paths in citations.items() + if not _section_resolves(section, headings) + } + assert not unresolved, ( + "(Spec: §X.Y) citations that do not resolve to a real heading " + "in proposals/foundation-v0.1/Proposed_OSI_Semantics.md:\n" + + "\n".join( + f" §{section} cited by " + ", ".join(str(p) for p in paths) + for section, paths in sorted(unresolved.items()) + ) + + "\n\nFix the citation, restore the spec heading, or land the " + "spec PR alongside this code change." + ) + + +def test_spec_file_is_present() -> None: + """A guard against repo-layout drift.""" + assert _SPEC_FILE.is_file(), ( + f"Spec file missing: {_SPEC_FILE}. The drift test resolves " + "citations against this path; update the constant if the " + "spec was relocated." + ) diff --git a/impl/python/tests/unit/test_synthetic_naming_invariants.py b/impl/python/tests/unit/test_synthetic_naming_invariants.py new file mode 100644 index 0000000..36e1101 --- /dev/null +++ b/impl/python/tests/unit/test_synthetic_naming_invariants.py @@ -0,0 +1,85 @@ +"""Invariant tests for synthetic naming. + +The Foundation routes every synthetic name (CTE aliases, mangled join +keys, anonymous aggregates) through :mod:`osi.planning.prefixes`. This +test surfaces regressions where a literal sneaks back into emitter +modules — the kind of mistake that breaks the +``ARCHITECTURE.md §6`` byte-identical SQL invariant the moment the +prefix changes. + +The check is a string scan on purpose: import-linter cannot enforce +"do not embed literal prefix" because it is a value-level concern. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +# Modules that legitimately define the prefix vocabulary. +_OWNERS: frozenset[str] = frozenset( + { + "src/osi/planning/prefixes.py", + # Tests and docs may mention the prefix in commentary. + } +) + +# Patterns that indicate the step CTE alias is being constructed or +# matched directly instead of via :mod:`osi.planning.prefixes`. We +# look for two shapes that flagged real bugs: +# - ``f"step_{...}"`` / ``"step_%d" %`` formatting of the alias +# - ``.startswith("step_")`` / ``"step_" in`` reachability checks +# Plain occurrences of ``step_id``, ``step_count``, etc. are excluded +# by requiring the literal to end immediately or contain a format +# placeholder. +_FORBIDDEN_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + ( + re.compile(r'(? Path: + """Locate the implementation root from the test file's path. + + Walks upward to the nearest directory whose ``pyproject.toml`` sits + alongside a ``src/osi/`` package — works under both the legacy + ``osi_python/`` layout and the OSI repo's ``impl/python/`` layout + without hard-coding either directory name. + """ + here = Path(__file__).resolve() + for parent in here.parents: + if (parent / "pyproject.toml").exists() and (parent / "src" / "osi").is_dir(): + return parent + raise RuntimeError("could not locate osi reference-impl project root") + + +def _python_files() -> list[Path]: + root = _project_root() + src = root / "src" + return sorted(src.rglob("*.py")) + + +@pytest.mark.parametrize("path", _python_files(), ids=lambda p: str(p.name)) +def test_no_step_prefix_literal_outside_owner_modules(path: Path) -> None: + rel = path.resolve().relative_to(_project_root().resolve()).as_posix() + if rel in _OWNERS: + return + text = path.read_text(encoding="utf-8") + for pattern, message in _FORBIDDEN_PATTERNS: + match = pattern.search(text) + assert match is None, ( + f"{rel}: forbidden literal {match.group(0)!r} — {message}. " + "Route synthetic names through osi.planning.prefixes." + ) diff --git a/proposals/foundation-v0.1/Proposed_OSI_Semantics.md b/proposals/foundation-v0.1/Proposed_OSI_Semantics.md new file mode 100644 index 0000000..3396e7a --- /dev/null +++ b/proposals/foundation-v0.1/Proposed_OSI_Semantics.md @@ -0,0 +1,2045 @@ +# Proposed OSI Semantics — Foundation + +**Status:** Draft Proposal +**Spec version:** `0.1` +**Author:** will.pugh@snowflake.com +**Date:** 2026-04-25 + +A semantic model MAY declare the spec version it conforms to via a top-level `osi_version: "0.1"` key. A model that omits the key is interpreted under the latest version the engine supports; engines MAY emit a diagnostic in that case. Future revisions of this Foundation increment the minor version (`0.2`, `0.3`, …) and remain additively compatible — a model written against `0.1` MUST continue to validate and produce identical results under any later `0.x` version. +**Related specs:** +- [OSI Core Metadata Spec](https://github.com/open-semantic-interchange/OSI/blob/main/core-spec/spec.md) + +- [SQL_EXPRESSION_SUBSET](https://docs.google.com/document/d/1Jn98kHWsnbvo1MycQBAnWirGTwRmw-DWaen7coNCYWA/edit?usp=sharing) + +--- + +## 1. Motivation and Scope + +This proposal defines an initial set of semantics for OSI that allows us to create an initial core base that we can use to build later abstractions on top of. It is consistent with the core abstractions, but holds off on APIs around grain and filter control. Standard SQL window functions are in scope (§6.10); only the genuinely non-portable extensions to windows — parameterized frame bounds, `GROUPS` frame mode, ordered-set aggregates with `WITHIN GROUP`, and windowed-metric composition — are deferred. These deferred features are powerful, and many BI tools have them, but with different design decisions for how they should work. + +This will focus on: +1. **Core OSI semantics** — datasets, fields, relationships, metrics, and a minimal query model, without grain overrides or filter-context propagation. +2. **Well-defined join semantics** — how relationships are declared, how cardinality is inferred, how the planner picks join types per context, and what safety rules prevent silent incorrectness. +3. **A fixed SQL subset** — the expression language allowed inside metric, field, and filter expressions, so that portable implementations can be written and two tools can agree on what a metric means. + +Anything that falls outside these is explicitly deferred. §10 lists deferred features and the companion proposal that addresses it. + +## 2. Design Principles + +1. **Portable first.** Every construct in this proposal must be representable in ANSI SQL:2003. +2. **Safe by default.** Where SQL would silently produce wrong results (fan-out, chasm trap), OSI either disallows the operation or substitutes a safe rewrite. No query should compute a wrong answer because the author did not add a guard. +3. **Additive extension path.** Every feature deferred to §10 is additive: adopting it does not require reworking anything in this initial base. A model that uses only this base remains valid under the full spec. +4. **Declare intent, not execution.** Model authors describe *what* their data means (cardinality, referential integrity, relationship). The planner decides *how* to execute (join type, CTE structure, rollup order). +5. **Trust-but-don't-validate.** OSI trusts declared primary keys, unique keys, and referential integrity. Data-quality enforcement is upstream (ETL, dbt tests, etc.), not a semantic-layer concern. + +## 3. What is In / What is Out + +The Foundation surface is deliberately narrow, several important features are pushed out, in order to focus on the foundational semantics first. + +| In (Foundation v0.1) | Out (deferred to §10) | +|:---|:---| +| Datasets, fields, metrics, relationships (§4) | Aggregate-bodied fields and dataset-namespaced metrics (§4.3, §4.5; `E_AGGREGATE_IN_FIELD`, `E_DEFERRED_KEY_REJECTED`) | +| Aggregation and Scalar query shapes — `Dimensions`, `Measures`, `Fields`, `Where`, `Having`, `Order By`, `Limit` (§5.1) | Per-metric / per-dataset / per-model filter context (filter inheritance, named filters, `CALCULATE`-style overrides) | +| Equijoin relationships, single-column or composite (§4.4) | Rich join semantics — non-equijoin, ASOF, range (§10) | +| Cardinality **inferred** from declared primary / unique keys (§6.4) | Cardinality and referential integrity **declared** on relationships (`referential_integrity`, `from_all_rows_match`, `to_all_rows_match`) | +| Aggregate-before-join safety (§6.7) | Grain-based operations — explicit `FIXED` / `INCLUDE` / `EXCLUDE` / `TABLE` overrides (LOD equivalents) | +| Chasm-trap safety (independent fact computation + join on shared dims) (§6.7, §6.8.2) | Nested aggregation in metric expressions (`AVG(AVG(...))`, `AVG(COUNT(...))`; `E_NESTED_AGGREGATION_DEFERRED`) | +| Fan-out safety (§6.7, §6.10.3, §5.1.2) | Variables / parameters in queries or models | +| M:N traversal with a safe-result guarantee — bridge de-duplication (§6.8.1) and stitching dimensions (§6.8.2); every aggregate category accepted bare, no fan-out, no chasm | Path disambiguation (per-metric `using_relationships`, per-metric `joins.type` overrides) — ambiguous paths are surfaced as `E_AMBIGUOUS_PATH`, not silently picked | +| SQL expression subset — core scalar and aggregation functions, plus `COUNT(DISTINCT)` over a bridge (§7, [`SQL_EXPRESSION_SUBSET.md`](SQL_EXPRESSION_SUBSET.md)) | Hierarchies (rollup paths, parent-child models) | +| Standard SQL window functions — ranking, navigation, aggregate-windows; `ROWS` / `RANGE` frame modes; integer-literal frame bounds (§6.10) | Non-portable window features — `GROUPS` frame, parameterised frame bounds, `WITHIN GROUP` ordered-set aggregates, windowed-metric composition | + +A complete deferred-features registry, with one row per future proposal, is in §10. + + +--- + +## 4. Semantic Model + +### 4.1 Top-Level Structure + +The top-level structure consists of the following keys — `name`, `description`, `dialect`, `ai_context`, `datasets`, `relationships`, `metrics`, and `custom_extensions`. The Foundation introduces no additional top-level keys; the field-level shape for each section is defined in the subsections below. + +### 4.2 Datasets + +A dataset is a logical table backed by a physical SQL source. + +| Field | Type | Required | Description | +|:---|:---|:---|:---| +| `name` | string | Yes | Unique within the model | +| `source` | string | Yes | `database.schema.table` or a SQL subquery | +| `primary_key` | array | No | Columns that uniquely identify rows | +| `unique_keys` | array of arrays | No | Additional unique constraints | +| `description` | string | No | | +| `ai_context` | string / object | No | | +| `fields` | array | No | See §4.3 | +| `custom_extensions` | array | No | vendor specific attributes | + +**Primary and unique keys are semantic assertions about row uniqueness.** They feed cardinality inference for relationships (§6.4). Models that omit them remain valid: the engine MUST still produce correct, safe results, but it has to assume worst-case cardinality (`N : N`) and may emit more conservative SQL — possibly less efficient, never less correct. + +Implementations MAY decide to require `primary_key` declarations in order to have a well-defined table grain. In that case, they MUST reject models that omit primary keys with `E_PRIMARY_KEY_REQUIRED`. + +Engines SHOULD trust the keys defined in the dataset, and do not have to validate uniqueness beyond what is specified in the model. + +```yaml +datasets: + - name: orders + source: sales.public.orders + primary_key: [order_id] + unique_keys: + - [order_number] + fields: [ ... ] + + - name: customers + source: sales.public.customers + primary_key: [id] + fields: [ ... ] +``` + +#### 4.2.1 Grain + +Grain is defined as the set of fields that uniquely define a row. + +For a Dataset, we will use the term **`Table Grain`** (also: **home grain**) to denote the grain the dataset naturally lives at — its primary key (or any declared unique key). If a Dataset does not have a key, there is no way to uniquely identify a row. + +> *Note on terminology.* Some BI traditions use the phrase "natural grain" for the same per-dataset concept. The Foundation deliberately avoids that wording because a deferred proposal uses `natural_grain` as a reserved model-level declaration that sets the home grain for the entire model. To prevent confusion this spec uses "home grain" or "table grain" exclusively for the per-dataset concept. + +Grain for an aggregated query is the set of dimension fields in the query (or subquery). + +For example, in the datasets defined above the grain of `orders` would be `order_id` and/or `order_number`. + +For aggregations queries (or sub-queries) the grain will be the dimensions (group by ) fields. For example, the grain of + +```sql +select order_location, count(order_id) +from orders +group by 1 +``` + +is `order_location`, because after the aggregation we know that `order_location` uniquely defines a row. + +### 4.3 Fields + +Fields are named expressions on a dataset. A field's expression is either a scalar (row-level) expression, a window function evaluated at the home grain, or a boolean form of either. **Aggregate expressions in fields are not part of the Foundation** — every aggregate is a metric (§4.5), and metrics live at the top-level `metrics:` section only. The shape of the expression — not a declared tag — determines how a reference to the field is routed in a query. Expansion of metrics onto fields will be handled in follow-up proposals. + +> **Uses existing [`OSI Core Metadata.md`](https://github.com/open-semantic-interchange/OSI/blob/main/core-spec/spec.md) §Fields, with behaviour extension.** The core metadata spec says field expressions are *"scalar SQL expressions (no aggregations)"*. The Foundation upholds that rule with one extension: a field's `expression` MAY also include a standard SQL window function evaluated at the home grain (§6.10). Aggregate-bodied fields, including aggregates over the home dataset's own columns, are deferred to §10 along with all other dataset-namespaced metric forms — write the aggregate at the top-level `metrics:` section instead. A field expression that contains any aggregate function MUST raise `E_AGGREGATE_IN_FIELD`. + +| Field | Type | Required | Description | +|:---|:---|:---|:---| +| `name` | string | Yes | Unique within the dataset | +| `expression` | string \| object | Yes | A non-aggregate SQL expression in the OSI expression subset (§7). The schema for `expression` (string form and structured per-dialect form) is defined normatively in [`SQL_EXPRESSION_SUBSET.md`](SQL_EXPRESSION_SUBSET.md) §"Per-Dialect Expression Form"; the bare-string form is the default. | +| `description` | string | No | | +| `ai_context` | string / object | No | Synonyms, examples | +| `access_modifier` | enum | No | `public` (default) or `private` (hide from query) | + +**Expressions** are tied to a dialect and to the home grain of the dataset they are attached to. They evaluate to one scalar value per home-dataset row (or one window-frame result per row). + +Cross-dataset references inside a field's expression are governed by the grain rules in §4.3.1. + +#### 4.3.1 Cross-dataset references in field expressions + +This subsection governs what a field's expression may reference across relationships. The rule is restrictive on purpose: any aggregation packaged as a *field* (whether cross-grain or same-grain) would carry an implicit "this resolves at the home grain" pin, and the semantics of such a construct under a query-level `Where` clause is exactly the choice the §10 grain proposal is meant to settle explicitly. The Foundation defers all field-level aggregation rather than picking a default that §10 might overturn; aggregations are expressed as model-scoped metrics (§4.5). + +**Routing by cardinality.** When a field's expression references columns or fields from a related dataset, the routing depends on the cardinality of the relationship path from the field's home dataset to the referenced dataset: + +- **Same or lower granularity** (referenced dataset is reachable via `N : 1` / `1 : 1` edges from the home dataset): the reference is allowed directly and is enriched onto each row of the home dataset along the unambiguous path. Path-disambiguation is deferred (§10), so engines MUST raise `E_AMBIGUOUS_PATH` when more than one path exists. + +- **Higher granularity** (referenced dataset is reachable via `1 : N` edges): a non-aggregate reference at higher grain raises `E_UNAGGREGATED_FINER_GRAIN_REFERENCE` (D-024). An aggregate-wrapped reference (the cross-grain aggregation form) is rejected by the broader "no aggregates in field expressions" rule above (`E_AGGREGATE_IN_FIELD`). Express the aggregation as a model-scoped metric (§4.5) — `metrics: [{name: lifetime_value, expression: SUM(orders.amount)}]` — and consume it via an aggregation query. + +- **Across an `N : N` edge** in a non-aggregating context: rejected (`E3012_MN_NO_SAFE_REWRITE`, §6.8). Cross-grain aggregation across an `N : N` edge is also handled by the rule above — packaged as a metric and governed by §6.8 the same way it is for any top-level metric. + +**Window functions in field expressions.** A field's `expression` MAY include a standard SQL window function (e.g., `ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date NULLS LAST)`). The window evaluates at the home dataset's grain; the window's `PARTITION BY` and `ORDER BY` expressions MUST be resolvable at that grain (row-level fields of the home dataset, or `N : 1` enrichments along an unambiguous path). The Foundation's full window-function contract is in §6.10. (A window function is not an aggregate function in the spec sense — it does not raise `E_AGGREGATE_IN_FIELD`.) + +**Worked example — aggregation expressed as a model-scoped metric, not a field.** The "sum of each customer's orders" pattern goes under top-level `metrics:`, not on the dataset: + +```yaml +datasets: + - name: customers + primary_key: [id] + fields: + - { name: id, expression: id } + - { name: region, expression: region } + + - name: orders + primary_key: [order_id] + fields: + - { name: customer_id, expression: customer_id } + - { name: amount, expression: amount } + +relationships: + - { name: orders_to_customer, from: orders, to: customers, + from_columns: [customer_id], to_columns: [id] } # N:1 + +# All metrics — including same-grain aggregates over a single dataset — live in +# the top-level `metrics:` section. Field expressions are non-aggregate. +metrics: + - name: lifetime_value + expression: SUM(orders.amount) # cross-grain — resolves at the query's grain (§4.5) + - name: total_orders_amount + expression: SUM(orders.amount) # could also be the same-grain "totals" measure; + # the dataset prefix is unnecessary because metrics + # are referenced by bare name +``` + +The query `Dimensions: [customers.region]; Measures: [lifetime_value]` returns one row per region with the sum of order amounts — the standard cross-grain pattern. + +If a `SUM(orders.amount)` (or any other aggregate, including the same-grain `SUM(amount)` on the orders dataset itself) were placed under `fields:`, the engine MUST raise `E_AGGREGATE_IN_FIELD`. Boolean cross-grain expressions such as `COUNT(orders.order_id) > 0` are also expressed as metrics and consumed in `Having`; the field-level form is deferred to §10's grain-aware functions or to the deferred §6.8 semi-join filter form. + +```yaml +fields: + - name: order_id + expression: order_id + + - name: order_date + expression: o_orderdate + data_type: DATE # temporal semantics come from the SQL type + + - name: discounted_price + expression: extended_price * (1 - discount) + + - name: full_name + expression: first_name || ' ' || last_name + + - name: is_completed + expression: status = 'completed' # boolean scalar → usable in Where + + - name: order_rank + expression: ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date NULLS LAST) + # window function at home grain → usable in Where, Fields, etc. +``` + +The field type is determined by **resolved expression shape**, not by any declared tag. Concretely: + +- A **row-level scalar** is a non-aggregate expression over the home dataset's columns or `N : 1` / `1 : 1` enrichments. It is usable in `Dimensions`, `Where`, `Order By`, and `Fields`. +- A **query-grain aggregate** is an aggregate that resolves at the query's grain — either a top-level aggregate in `Measures` (e.g., the `SUM` in `Measures: [SUM(orders.amount)]` for a query grouped by region) or a reference to a model-scoped metric (`Measures: [total_revenue]`). It is usable in `Measures`, `Having`, and `Order By` (when referenced through a measure). Aggregates do not live in field expressions. +- A **boolean row-level scalar** is usable in `Where`; a **boolean query-grain aggregate** is usable in `Having`. + +The engine MUST classify each expression by its resolved shape; the user does not need to declare the kind explicitly. + +> **Forward note.** The deferred grain proposal (§10) generalises this with explicit grain operations and grain-aware functions, including a field-level form for aggregation that today must be expressed as a model-scoped metric (§4.3.1). + +### 4.4 Relationships + +Relationships declare how datasets join. The Foundation supports **equijoin relationships only** (single-column or composite). Non-equijoin and temporal joins are deferred to the Non-Equijoin and ASOF proposals (§10). + +| Field | Type | Required | Description | +|:---|:---|:---|:---| +| `name` | string | Yes | Unique within the model | +| `from` | string | Yes | The many-side dataset (FK side) | +| `to` | string | Yes | The one-side dataset (PK/UK side) | +| `from_columns` | array | Yes | FK columns | +| `to_columns` | array | Yes | PK or UK columns on the `to` side | +| `description` | string | No | | +| `ai_context` | string / object | No | | + +Column arrays must have the same length and positionally correspond. + +```yaml +relationships: + - name: orders_to_customers + from: orders + to: customers + from_columns: [customer_id] + to_columns: [id] + + - name: line_items_to_orders + from: line_items + to: orders + from_columns: [order_id] + to_columns: [order_id] + + - name: order_lines_to_products + from: order_lines + to: products + from_columns: [product_id, variant_id] + to_columns: [id, variant_id] +``` + +Referential-integrity declarations on relationships (e.g., "every `from` row has a matching `to` row") are deferred to a companion proposal (§10). The Foundation always uses the safe defaults of §6.6 — `LEFT` for enrichment, `FULL OUTER` for multi-fact composition — and never silently drops rows because RI was assumed. + +### 4.5 Metrics + +Metrics are aggregate expressions. **All metrics are model-scoped — defined at the top level in the `metrics:` section and referenced by bare name.** The Foundation has exactly one place a metric can live and exactly one syntax for referencing it. Every aggregate expression is a metric; field expressions never contain aggregates (§4.3). + +> **Deferred — dataset-namespaced aggregations.** Adding aggregations at the dataset level is deferred. This includes: (a) aggregate-bodied fields in a dataset's `fields:` list (e.g., `orders.fields: [{name: total_revenue, expression: SUM(amount)}]`), and (b) per-dataset `metrics:` blocks (e.g., `customers.metrics: [...]`). + +| Field | Type | Required | Description | +|:---|:---|:---|:---| +| `name` | string | Yes | Unique in the global metric namespace | +| `expression` | string \| object | Yes | An aggregate SQL expression in the OSI expression subset (§7). Schema (string form and structured per-dialect form) defined in [`OSI core metadata spec`](https://github.com/open-semantic-interchange/OSI/blob/main/core-spec/spec.md) §"Metrics" and [`SQL expression subset`](https://docs.google.com/document/d/1Jn98kHWsnbvo1MycQBAnWirGTwRmw-DWaen7coNCYWA/edit?usp=sharing). | +| `description` | string | No | | +| `ai_context` | string / object | No | | +| `access_modifier` | enum | No | `public` (default) or `private` | + +**Foundation rules for metric expressions.** A metric's expression MUST be one of: + +1. **A single aggregation** that resolves at the query's grain (§6.2). The aggregate operates over scalars from any dataset reachable through the relationship graph — same-grain (`SUM(orders.amount)` when consumed at the orders grain), `N : 1` / `1 : 1`-reachable, or `1 : N`-reachable (cross-grain single-step). Examples: `total_revenue = SUM(orders.amount)`, `total_orders = SUM(orders.amount)` (consumed at customer grain), `avg_order = AVG(orders.amount)` (cross-grain via the relationship graph). + + **Cross-grain single-step semantics.** When the aggregate references a higher-grain dataset via a `1 : N` edge, the single-step interpretation is **standard SQL semantics**: the engine joins the higher-grain rows through the relationship path and aggregates them at the query's grain. Each higher-grain row contributes once per output group, so Semantic 2 holds (§6.1). This is the same shape Looker, Tableau, and dbt-semantic-layer produce for cross-grain measures. + + **`M : N` cross-grain references.** Cross-grain aggregates over an `N : N` edge are accepted for every aggregate category (distributive, algebraic, holistic). The contract is set-theoretic: the aggregate's input is the set of unique `(measure-home-row, group-key)` associations reachable through the relationship path; its output is the result of applying the aggregate to that set, once per group. Engines MAY implement this contract by any plan that produces equivalent results; the reference construction is in §6.8.1 (D-026, D-027). For `AVG(movies.gross)` grouped by `actors.height` over `actors ↔ appearances ↔ movies` (the §6.8.1 fixture), the required answer is `170 → AVG(100, 200) = 150`, `180 → AVG(50) = 50`. This is the heavy-side-weighted single-step analogue of the `1 : N` rule above. The alternative "per-home-row-first" interpretation (e.g., per-actor-first averaging, which would yield `125` for height `170`) is the *nested* form `AVG(AVG(movies.gross))` and is **deferred** to §10's grain-aware-functions proposal (see below). + +2. **An arithmetic combination** of already-defined metrics, scalar literals, and aggregated scalar expressions: `m1 / NULLIF(m2, 0) * 100`, `revenue - cost`. Each operand resolves independently at the query's grain per §6.2; the arithmetic is applied after the operand aggregates have resolved. Bare row-level fields finer than the query's grain MUST be inside an aggregate; an unwrapped finer-grain reference MUST raise `E_UNAGGREGATED_FINER_GRAIN_REFERENCE` (D-024). Bare fields at coarser grain than the query's grain (constants, header fields on an `N : 1`-reachable dataset, etc.) attach via §4.3 and are usable directly. + +3. **A metric reference** (`total_revenue`) — an alias or rename of an existing metric, resolved at the query's grain per §6.2. + +4. **A window-function expression** that resolves at the query's grain — see §6.10 below. + +> **Deferred — nested aggregation.** Expressions of the form `OUTER(INNER())` where `INNER` is an aggregate (e.g., `AVG(COUNT(orders.oid))`, `AVG(AVG(orders.amount))`) MUST raise `E_NESTED_AGGREGATION_DEFERRED`. The per-home-row-first interpretation that nested aggregation expresses requires an implicit grain pin on the inner aggregate, and the rules for choosing that pin (which dataset, how `Dimensions` participates) are deferred to §10's grain-aware-functions proposal. Practical implications: +> +> - For **distributive** aggregates (`SUM`, `COUNT`, `MIN`, `MAX`), the single-step form gives identical numbers to the nested form, so there is no expressive loss — write `SUM(orders.amount)` instead of `SUM(SUM(orders.amount))`. +> - For **non-distributive** aggregates (`AVG`, `STDDEV`, `VARIANCE`, `MEDIAN`, `PERCENTILE`) over a `1 : N` edge, only the heavy-side-weighted single-step interpretation is available today (`AVG(orders.amount)` is the average over every order, not the average of per-customer averages). The unweighted "average of per-home-row averages" interpretation waits for §10. +> - For **non-distributive** aggregates over an `N : N` edge, the bare single-step form is **accepted** under the bridge-de-duplication construction of §6.8.1 (the analogue of the heavy-side-weighted `1 : N` rule above). Only the per-home-row-first interpretation — written as the nested form `AVG(AVG(…))` — waits for §10. `AVG(movies.gross) by actors.height` ⇒ accepted (bridge-dedup AVG); `AVG(AVG(movies.gross)) by actors.height` ⇒ `E_NESTED_AGGREGATION_DEFERRED`. + +**Filter and grain context.** Metric-references-metric (forms 2 and 3) introduce **no per-metric grain override** and **no per-metric filter override**. Every referenced operand resolves at the query's grain per §6.2 and is filtered by the query's `Where` clause like any other aggregate in the projection — that is standard SQL behaviour, not a Foundation extension. Per-metric grain controls (Tableau `FIXED` / `INCLUDE` / `EXCLUDE`) and per-metric filter overrides (Tableau `FIXED` filters, Power BI `CALCULATE`, dbt-semantic-layer metric filters) — which let an individual metric ignore or replace the surrounding query context — are deferred to §10. + +**Window functions in metric expressions.** A metric's `expression` MAY contain a standard SQL window function (e.g., `running_total = SUM(amount) OVER (ORDER BY order_date NULLS LAST)`). The window evaluates at the query's grain over the post-`GROUP BY` row set. The Foundation's full window-function contract is in §6.10. One limitation: a metric expression that itself **references** another metric whose expression contains a window function is deferred (§10, §6.10.5) — direct use of a windowed metric in `Measures` works today, but composing windowed metrics through forms (2) and (3) does not. + +These rules produce numerical results equivalent to Looker, Tableau, and dbt-semantic-layer's cross-grain handling for 1:N reaches with single-step aggregation. Snowflake Semantic Views require explicit nested form for cross-grain aggregates; with nested aggregation deferred in the Foundation, only the single-step form is available — see §12.A for the divergence note. + +```yaml +datasets: + - name: orders + fields: + - { name: order_id, expression: order_id } + - { name: customer_id, expression: customer_id } + - { name: amount, expression: amount } + - { name: discount, expression: discount } + # No `metrics:` block on a dataset — that form is deferred (see §4.5). + +# All metrics live here, referenced by bare name. +metrics: + # Same-grain aggregate over a single dataset. + - name: total_revenue + expression: SUM(orders.amount) + + - name: order_count + expression: COUNT(orders.order_id) + + - name: distinct_customers + expression: COUNT(DISTINCT orders.customer_id) + + # Derived metric — arithmetic of other metrics. + - name: avg_order_value + expression: total_revenue / NULLIF(order_count, 0) + + # Multi-column aggregate over a single dataset's own columns. + - name: net_revenue + expression: SUM(orders.amount * (1 - orders.discount)) + + # Cross-grain single-step (form 1) — standard SQL semantics. + # Each order row contributes once per output group; SUM is distributive. + # When consumed at customer.region grain, this is the per-region revenue total. + - name: customer_revenue + expression: SUM(orders.amount) + + # Cross-grain single-step AVG — also accepted; standard SQL semantics. + # Grouped by region, this is the average over every order whose customer + # is in that region — a heavy-customer-weighted average. The unweighted + # "average of per-customer averages" form is deferred (see §4.5 deferral above). + - name: avg_order_amount_across_customers + expression: AVG(orders.amount) +``` + +### 4.6 Namespacing and Identifiers + +This section is normative for the Foundation surface. The identifier *grammar* (case-folding, normalised-identifier definition, the `Field` / `FieldExpr` shape, the global / dataset / physical scope model, and the in-dataset precedence table) lives in [`SQL_EXPRESSION_SUBSET.md`](SQL_EXPRESSION_SUBSET.md) §"Namespacing and Identifier Resolution"; this section adds the Foundation-specific pieces that document doesn't — and shouldn't — cover: a definitive position on quoted-identifier case-sensitivity, the Foundation's reserved-name set, the error codes, and the reference rules at every site a name appears in a semantic query. Where the two documents overlap they MUST agree; where they differ this section is normative for the Foundation. + +#### 4.6.1 Identifier Form + +The grammar lives in [OSI SQL Expression Subset](https://docs.google.com/document/d/1Jn98kHWsnbvo1MycQBAnWirGTwRmw-DWaen7coNCYWA/edit?usp=sharing) §"Namespacing and Identifier Resolution": ANSI SQL identifiers, ≤128 characters, regular (unquoted) identifiers are case-insensitive and fold to upper-case, quoted identifiers preserve their inner text verbatim, and the **normalised identifier** is the canonical form used for matching (regulars upper-cased; quoted have quotes stripped and escapes unescaped). Two identifiers are equal iff their normalised forms are byte-equal. + +The Foundation pins one point on which OSI SQL Expression Subset hedges: **quoted identifiers are case-sensitive relative to regular identifiers**. So `"id"` does not match the regular identifier `id` (the regular form normalises to `ID` and the quoted form preserves `id`), but `"ID"` does. The conformance suite (§11.1) asserts this position. + +#### 4.6.2 Reserved Names + +The Foundation reserves the following names. Engines MUST reject user-defined identifiers that collide with them: + +| Reserved | Meaning | +|:---|:---| +| `GRAIN` | Reserved for the deferred grain extension (§10). | +| `FILTER` | Reserved for the deferred filter-context extension (§10); also reserved by SQL window-function syntax. | +| `QUERY_FILTER` | Reserved for the deferred filter-context extension (§10). | + +ANSI SQL reserved words are also reserved at the identifier level — engines MAY accept them only when quoted. + +#### 4.6.3 Scopes + +The three-scope model (Global / Dataset / Physical) and the in-dataset precedence table (physical → logical-on-same-dataset → global) are defined in [OSI SQL Expression Subset](https://docs.google.com/document/d/1Jn98kHWsnbvo1MycQBAnWirGTwRmw-DWaen7coNCYWA/edit?usp=sharing) §"Name Spaces". The Foundation adds the following: + +**Global scope — Foundation-specific membership and rules.** In the Foundation, the only global names are **datasets**, **relationships**, and **model-scoped metrics**. All global names share one namespace: a dataset and a relationship cannot have the same normalised name; a metric and a relationship cannot have the same normalised name; etc. Engines MUST reject duplicate global names with `E_NAME_COLLISION`. A dataset-scoped field MAY share a normalised name with a global name — they live in different scopes; the in-dataset precedence table decides which one a bare reference picks up. + +**Reach restrictions.** Global metric expressions can reference any other global name and any dataset-scoped name (qualified as `dataset.field`), but cannot reach physical columns directly — physical columns are reachable only from inside the dataset that owns them. Relationships MAY reference physical columns on either endpoint (per the OSI SQL Expression Subset). + +**Practical consequence of the precedence table.** A dataset that declares both a physical column `id` and a logical field `id` resolves bare `id` to the physical column; the qualified form `.id` reaches the logical field. + +#### 4.6.4 Reference Syntax + +Three rules cover every reference site: + +1. **Inside a query** (`Dimensions`, `Measures`, `Where`, `Having`, `Order By`, `Fields`): metrics are referenced by **bare name** from the global metric namespace. Dataset-scoped fields MUST be referenced as `dataset.field`. + + Example: in `Measures: [total_revenue]`, the engine resolves `total_revenue` to the model-scoped metric of that name. If no metric `total_revenue` exists, the engine raises `E_NAME_NOT_FOUND`. In `Dimensions: [orders.region]`, `orders.region` resolves to the field `region` on dataset `orders`. + + The Foundation has no `dataset.metric_name` form (dataset-namespaced metrics are deferred per §4.5). Every metric lives in one global namespace, so name collisions across the model surface as ordinary `E_NAME_COLLISION` at validation time, not as scope-resolution ambiguities at query time. + +2. **Inside a dataset field expression**: bare names follow the precedence table in the OSI SQL Expression Subset §"Name Spaces" (physical → logical → global). A dataset-local logical field that shadows a global name is reachable via the `dataset.field` form. + +3. **Inside a model-scoped metric expression**: names MUST be `dataset.field`-qualified for any reference to a dataset-scoped field (model-scoped metrics live in the global scope and have no implicit home dataset). Bare references to other global names (e.g., another model-scoped metric) are allowed. + +--- + +## 5. Query Model + +### 5.1 Semantic Query Clauses + +A semantic query is one of two distinct shapes. Every Foundation query MUST be classifiable as exactly one of them. The shape is determined by which projection clause the query uses. + +| Shape | Projection clauses | Result grain | Maps to standard SQL… | +|:---|:---|:---|:---| +| **Aggregation query** | `Dimensions` and/or `Measures` | One row per distinct tuple of `Dimensions`. Empty `Dimensions` ⇒ exactly one row (the empty grain). | …with a `GROUP BY` (or no `GROUP BY` and only aggregates in the SELECT list). | +| **Scalar query** | `Fields` | Table grain — one row per row in the (joined) source row set. No aggregation. | …with no `GROUP BY` and no aggregates in the SELECT list. | + +Mixing `Fields` with `Dimensions` or `Measures` in the same query is rejected (`E_MIXED_QUERY_SHAPE`). The two shapes have different correctness rules and different SQL contracts; the engine must know which one it is compiling before it plans. + +##### Common clause semantics + +The following rules apply to both query shapes. Per-shape clauses below extend them. + +**`Where` and `Having` predicate lists.** Both clauses accept either a single predicate expression or a list of predicate expressions. A list MUST be interpreted as the conjunction (`AND`) of its entries. `Where: [P1, P2]` is identical in meaning to `Where: "P1 AND P2"`. Engines MAY support either surface form; conformant emitted SQL is the `AND`-joined form. There is no Foundation-level `OR` shortcut between list entries — `OR` must be written inside an expression. + +**`Order By` NULL placement.** Every `Order By` entry — both the outer query's `Order By` and any `ORDER BY` inside an `OVER (...)` window clause — has a defined NULL placement. If the entry does not specify `NULLS FIRST` or `NULLS LAST` explicitly, the Foundation default is **`ASC ⇒ NULLS LAST`, `DESC ⇒ NULLS FIRST`** — i.e., NULL is treated as a *high-end* value that lands at whichever end of the sort the maximum lands at. The compiled SQL MUST guarantee this *resolved row order* on every supported dialect; engines achieve this by emitting the explicit `NULLS …` clause whenever the dialect's native default would otherwise produce a different order. (When the resolved clause already matches the dialect's native default — e.g. `DESC NULLS FIRST` on Snowflake, `ASC NULLS LAST` on DuckDB — the explicit clause MAY be elided from the compiled SQL, since elision and explicit emission produce identical row orders on that dialect. The byte-identical-output guarantee in D-014 is per `(model, query, dialect)` — *within* a dialect the compiled SQL is deterministic; *across* dialects the row order is identical even when the literal SQL text differs by an elidable `NULLS …` token.) The Foundation chooses the high-end-NULL convention because (a) it matches SQL:2003's "NULLs compare-greater than non-NULLs" default, and the out-of-the-box defaults of Snowflake, PostgreSQL, and Oracle; (b) it preserves the **symmetry property** that flipping `ASC ↔ DESC` also flips the NULL placement, which is the behaviour every common BI mental model assumes (e.g. "top 10 by revenue → flip to bottom 10" should bring the NULL-revenue rows to the top, since they *are* the worst values by any reasonable interpretation). A user who wants every NULL pinned to a specific end regardless of direction MUST write the explicit `NULLS FIRST` / `NULLS LAST` clause; the compiled SQL then carries that explicit clause on every dialect (it cannot be elided because it overrides the resolved default). + +**`Order By` entry shape.** An `Order By` entry references either (a) a name that is in scope for the query's projection — a dimension name, a measure name (for an aggregation query), or a field name (for a scalar query) — or (b) any expression that would have been valid in the projection. Positional references (`ORDER BY 1`) are not part of the Foundation surface. Engines that compile to SQL MAY use positional references in emitted SQL if they preserve determinism. + +**`Limit` without `Order By`.** A `Limit` without an `Order By` MUST be accepted and compiled — same as standard SQL. The resulting row set is engine-defined (which rows are kept is up to the engine and underlying storage), but the emitted SQL itself MUST be deterministic per D-014 — the same `(model, query, dialect)` produces byte-identical SQL on every compilation. Engines MAY emit a diagnostic for users who appear to want determinism, but are NOT required to. Users who need a stable row set MUST supply an `Order By` whose tuple is unique within the result. + +**Result-column naming.** There is no cross-vendor convention for naming the result column produced by a `dataset.field` reference (Snowflake renders the full path expression; Databricks renders `parent.field`; Postgres/BigQuery render the leaf name only). The Foundation therefore does **not** mandate a particular result-column-naming scheme — engines MAY emit the leaf name (`region` for `orders.region`), the qualified name, the full path, or a vendor-specific form, as long as it is deterministic for the same `(model, query, dialect)`. Metrics, which are referenced by bare name, do not have this ambiguity (the result column is the metric's name unless the user supplies an explicit alias). Users who need a stable, portable result-column name for a `dataset.field` reference MUST use an explicit alias (e.g., `Dimensions: [orders.region AS order_region]`). A future SQL-interface proposal (§9) is expected to settle this. + +#### 5.1.1 Aggregation Query + +| Clause | Purpose | Required | +|:---|:---|:---| +| **Dimensions** | Fields used for grouping. Become the query's `GROUP BY`. | No | +| **Measures** | Metrics, ad-hoc aggregations, or window expressions to compute. Window functions (§6.10) evaluate over the post-`GROUP BY` row set. | No | +| **Where** | Pre-aggregation filter; predicates with no aggregate and no window (§6.3, §6.10.1). | No | +| **Having** | Post-aggregation filter; predicates whose top-level boolean references at least one aggregate or window (§6.3). | No | +| **Order By** | List of `{field-or-measure-or-window-expression, direction}` pairs. | No | +| **Limit** | Row limit on the result set. | No | + +At least one of `Dimensions` or `Measures` MUST be non-empty. + +**Result grain:** the distinct tuple of `Dimensions`. Empty `Dimensions` collapse to the **empty grain** — exactly one row containing the fully-aggregated measures. + +**Example semantic query:** + +```yaml +query: + dimensions: [customers.market_segment, orders.order_year] + measures: [total_revenue, order_count] + where: "orders.status = 'completed' AND customers.region = 'WEST'" + having: "total_revenue > 1000" + order_by: [{field: total_revenue, direction: DESC}] + limit: 50 +``` + +**Standard SQL it corresponds to (any aggregation SELECT with a `GROUP BY` is an aggregation query):** + +```sql +SELECT customers.market_segment, + orders.order_year, + SUM(orders.amount) AS total_revenue, + COUNT(orders.order_id) AS order_count +FROM orders +JOIN customers ON orders.customer_id = customers.id +WHERE orders.status = 'completed' AND customers.region = 'WEST' +GROUP BY customers.market_segment, orders.order_year +HAVING SUM(orders.amount) > 1000 +ORDER BY total_revenue DESC +LIMIT 50 +``` + +A vendor-specific SQL surface (a `SEMANTIC_VIEW(...)` clause, a `FROM ` syntax, a SQL-runner over the model, etc.) MAY render the same semantic query differently. The Foundation does not mandate a particular SQL surface; §12 catalogs how existing vendor surfaces compare. + +#### 5.1.2 Scalar Query + +A scalar query asks for **table-grain rows** — one row per row in the home dataset, with no aggregation step at the query level. + +| Clause | Purpose | Required | +|:---|:---|:---| +| **Fields** | The columns to project. Each entry MUST be a scalar at the **home dataset's grain** — either a row-level field on the home dataset, an enrichment along an `N : 1` path (§4.3), or a window function evaluated at the home grain (§6.10). | Yes | +| **Where** | Row-level filter; predicates with no aggregate and no window (§6.3, §6.10.1). Compiles to SQL `WHERE`. | No | +| **Order By** | List of `{field-or-window-expression, direction}` pairs. | No | +| **Limit** | Row limit on the result set. | No | + +**Constraints:** + +- `Fields` MUST contain at least one entry. +- No `Measures`, no `Dimensions`, no `Having`. Group-level predicates have no meaning at table grain. +- A **bare metric reference** in `Fields` (e.g., `revenue`) is rejected with `E_AGGREGATE_IN_SCALAR_QUERY`. A metric is an aggregation, not a per-home-row scalar; the user likely wants an aggregation query (§5.1.1). The error message MUST suggest converting to an aggregation query. +- A field whose expression contains **any aggregate** (same-grain over the home dataset's own columns or cross-grain via a `1 : N` reach) is rejected at *model-validation* time with `E_AGGREGATE_IN_FIELD` (§4.3). All aggregates are model-scoped metrics (§4.5) and consumed via an aggregation query (§5.1.1); the field-level form is deferred to §10. +- Cross-dataset references in `Fields` follow §4.3: same/lower-grain references attach directly along an unambiguous `N : 1` / `1 : 1` path. Higher-grain references are not allowed in `Fields` at all (an unwrapped finer-grain reference raises `E_UNAGGREGATED_FINER_GRAIN_REFERENCE`, D-024; an aggregate-wrapped reference inside a field expression raises `E_AGGREGATE_IN_FIELD`, §4.3). + +**Result grain:** the **home dataset's table grain** — the row set produced by taking the home dataset's rows and enriching along `N : 1` paths needed to resolve `Fields`. The home dataset is the dataset whose row-level (non-aggregated) fields drive the projection; if `Fields` references several datasets at the same grain (linked by `1 : 1` edges), the engine treats them as one logical home. The result is one row per surviving home-dataset row, after `Where` and `Limit` are applied. + +**Multiple incompatible homes.** If `Fields` supplies row-level (non-aggregated) references from two or more datasets that are **not** linked by declared `1 : 1` edges — for example `Fields: [orders.amount, returns.amount, customers.region]`, with `orders` and `returns` as independent facts on the many-side of `customers` — there is no single home grain at which the scalar shape can produce one row per home-dataset row without replicating the other root's rows. The engine MUST raise `E_FAN_OUT_IN_SCALAR_QUERY` (D-023, extended). The diagnostic MUST identify the conflicting home datasets and suggest either converting to an aggregation query or choosing a single fact as the home. (A semi-join filter form is deferred — see §6.8.) This matches the row-level-projection semantics of every major BI tool: Tableau, Power BI matrices, and Looker explores all anchor row-level views on a single base table; OSI follows the same convention. + +**Example semantic query — pure row-level projection:** + +```yaml +query: + fields: [orders.order_id, orders.amount, customers.market_segment] + where: "orders.status = 'completed'" + order_by: [{field: orders.amount, direction: DESC}] + limit: 100 +``` + +**Standard SQL it corresponds to (any non-aggregating SELECT is a scalar query):** + +```sql +SELECT orders.order_id, + orders.amount, + customers.market_segment +FROM orders +LEFT JOIN customers ON orders.customer_id = customers.id +WHERE orders.status = 'completed' +ORDER BY orders.amount DESC +LIMIT 100 +``` + +**Cross-grain aggregation: use an aggregation query, not a scalar query.** A pattern such as "list each customer's region and lifetime value" is *not* a scalar query in the Foundation. Define the aggregation as a metric (§4.5 form 1) and consume it via an aggregation query (§5.1.1): + +```yaml +# Model +metrics: + - name: lifetime_value + expression: SUM(orders.amount) # higher-grain reference inside a metric — allowed + +# Query (aggregation query, not scalar) +query: + dimensions: [customers.region, customers.id] + measures: [lifetime_value] + order_by: [{field: lifetime_value, direction: DESC}] + limit: 20 +``` + +This returns one row per `(region, customer)` pair with the customer's lifetime value. Aggregate-bodied fields (any aggregate in a field expression, including the cross-grain pattern packaged as a field on `customers`) are deferred to §10's grain-aware-functions proposal — see §4.3. + +The mapping rule for SQL surfaces: **a SQL `SELECT` with `GROUP BY` (or with query-level aggregates in the projection) is an aggregation query; a SQL `SELECT` with neither is a scalar query.** A user porting an existing SQL query to a semantic query keeps the same shape on each side. + +Implementations MAY provide any surface syntax — JSON, SQL subclause, programmatic builder — as long as the semantic clauses above are expressible. + +There is currently **no authoritative SQL surface** for OSI; this is an area where a portable surface is still emerging. Multiple vendors offer SQL surfaces over their semantic layers, and a common convention is to map the presence of `GROUP BY` (or aggregates in the projection) to the aggregation-query shape and the absence of both to the scalar-query shape — which is exactly the rule above. A Foundation-compliant SQL surface that follows this convention will keep user intent stable across implementations. + +## 6. Semantics + +This is the heart of the Foundation. The goal is that two OSI-compliant engines running the same query on the same data always get the same result. + +### 6.1 User-Visible Semantics + +When a query spans multiple datasets, OSI's engine chooses the join shape for you using a small set of safety-first defaults. The rules you can rely on are the five **user-visible semantics** below. Every concrete rule in the rest of §6 — cardinality inference, default join types, trap avoidance, M:N resolution, path resolution — exists to make these five guarantees hold. + +#### Semantic 1 — No fact row is silently dropped + +If you query a fact and pull in dimension columns, **every fact row is represented in the result**. After grouping, fact rows whose dimension key doesn't match anything are aggregated into a `NULL` bucket on those dimensions — they do not silently disappear from totals. + +A broken foreign key surfaces as a `NULL` bucket in the result, never as silently-missing rows. To opt into the alternative ("only orders with a known customer"), add `WHERE IS NOT NULL`. + +Opt-in `INNER`-promotion via declared referential integrity is deferred to a later proposal (§10). + +#### Semantic 2 — No row is double-counted by fan-out + +**No row of dataset `A` contributes more than once to a measure defined on `A`**, regardless of what tables you join in for grouping or filtering. A customer with 5 orders is counted once in `COUNT(customers.id)`, not five times — even if the query also groups by an order-level dimension. + +**A row of dataset `A` MAY contribute to more than one group** when the relationship between `A` and the grouping dimension is many-to-many (e.g., a bridge table). In that case, the engine MUST either: + +- Ensure that each row of `A` contributes to each group at most once (the bridge / stitch resolutions of §6.8), or +- Fail the query with an error rather than silently inflating the totals. + +This behaviour means that summing per-group totals over an M:N edge MAY give a number different from a total computed directly over the base table — that is fan-out *by design*, not double-counting. + +#### Semantic 3 — In multi-fact queries, no fact loses its groups + +When you put measures from two different facts in the same query (e.g., `revenue` from `orders` and `returns` from `returns`, both grouped by `customer.region`), **the result contains every group that appears in either fact**. A region with revenue but no returns appears with `returns = NULL` (or `0` if the metric coalesces). A region with returns but no orders also appears. + +Pulling a second fact into your query never *removes* groups that were in your first fact's answer. + +#### Semantic 4 — No unsafe re-aggregations + +Aggregations fall into three main categories: distributive, algebraic, and holistic. + +- **Distributive** functions like `SUM` can be re-aggregated with the same function (`SUM` of partial `SUM`s) and the distributive property guarantees the correct result. +- **Algebraic** functions like `AVG` can be broken into multi-step aggregations, but the intermediate step needs a different operation than the final one. For `AVG`, the engine tracks `SUM` and `COUNT` separately so the final division uses the right total. Taking the `AVG` of an `AVG` directly is unsafe — it overweights smaller populations. +- **Holistic** functions like percentile and `COUNT(DISTINCT)` need the entire population to compute the final answer and cannot be decomposed safely. + +Implementations MUST NOT silently re-aggregate using an unsafe path. When the chosen plan forces multi-stage decomposition that the aggregate cannot survive — typically a holistic or unsupported-algebraic aggregate over a §6.7 chasm pre-aggregation or a §6.8.2 stitch — the engine MUST raise `E_UNSAFE_REAGGREGATION` and identify the aggregate and the grains involved. The §6.8.1 bridge plan does not force decomposition (it is a single-pass aggregate over the de-duplicated row set) and so is not in scope for this rule; every aggregate category resolves through it per D-027. + +#### Semantic 5 — No silently wrong answer + +If the engine cannot find a safe way to compute your query, it raises a typed error with a code, not a plausible-but-wrong number. Different engines may handle some M:N edge cases differently — one engine's safe rewrite is another's typed error — but neither is allowed to produce silently-inflated output. + +The error codes you'll hit in practice: + +| When | Error code | +|:---|:---| +| Two facts joined through an `N : N` relationship with no safe rewrite at the query's grain | `E3012_MN_NO_SAFE_REWRITE` | +| Two unrelated facts referenced together with no shared dimension | `E3013_NO_STITCHING_DIMENSION` | +| Multiple equally-valid join paths exist | `E_AMBIGUOUS_PATH` | +| No relationship path connects the referenced datasets | `E_NO_PATH` | +| The chosen plan forces multi-stage decomposition the aggregate cannot survive (holistic over chasm pre-agg or stitch) | `E_UNSAFE_REAGGREGATION` | +| A scalar-query join path replicates home-dataset rows | `E_FAN_OUT_IN_SCALAR_QUERY` | +| A row-level reference to a field at a grain finer than the consuming home grain | `E_UNAGGREGATED_FINER_GRAIN_REFERENCE` | + +### 6.2 Evaluation Semantics + +The evaluation depends on the query shape (§5.1) — whether it aggregates or not. The concepts below — starting grain, final grain, and the dataset set — are common to both shapes. + +#### Starting Grain + +The starting grain defines the pre-aggregation row set that the operation begins from. For an aggregation query, **each metric resolves its own starting grain independently** from the datasets its expression touches and the join path the engine resolves. Two metrics in the same query MAY have different starting grains; the engine combines them under the §6.1 semantics and the M:N rules in §6.8. + +For a given metric, there may be multiple starting grains when shared dimensions or many-to-many joins are present. In most cases the engine can aggregate in a way that satisfies the §6.1 semantics — for example, by pre-aggregating fan-out-prone joins (§6.7) or by walking through a bridge dataset (§6.8.1). When it cannot, it MUST fail with a typed error rather than producing a silently-wrong number. The relevant codes, narrowest-first: + +| Code | Condition | +|:---|:---| +| `E3012_MN_NO_SAFE_REWRITE` | The composition crosses an `N : N` edge with no available bridge or shared-dimension stitch (§6.8). (Semi-join filter form deferred — see §6.8 note.) | +| `E3013_NO_STITCHING_DIMENSION` | Two unrelated facts referenced in the same measure with no shared dimension and no relationship path. | +| `E_UNSAFE_REAGGREGATION` | The chosen plan **forces a multi-stage decomposition** that the aggregate cannot survive. The two Foundation shapes that force decomposition are §6.7 chasm pre-aggregation (multiple incompatible 1:N reaches that must be aggregated independently before being merged) and §6.8.2 stitch (independent per-fact aggregation under a shared dimension followed by a FULL OUTER merge, then re-aggregation at the query grain). A holistic aggregate (`MEDIAN`, `PERCENTILE_CONT`) cannot be decomposed across these stages; an algebraic aggregate (`AVG`, `STDDEV`) survives them only if the engine implements its multi-stage decomposition. The §6.8.1 bridge plan does **not** force decomposition — it is a single-pass aggregate over the de-duplicated `(measure-home-row, group-key)` set, so every aggregate category resolves there per D-027. The diagnostic MUST name the aggregate and the grains involved, and SHOULD suggest the safe rewrite (pre-aggregate at the home grain, switch to a distributive aggregate, or restate at a coarser grain). See §7 and `SQL_EXPRESSION_SUBSET.md` for the distributive / algebraic / holistic categories. | +| `E_AMBIGUOUS_MEASURE_GRAIN` | Catch-all: a single measure has multiple incompatible starting grains and none of the more-specific codes above applies. Foundation engines SHOULD reach for the more-specific codes first; this code is reserved for shapes the spec does not yet enumerate. The diagnostic MUST list the starting grains the engine identified. | + +To find the starting grain for a metric, the engine: + +1. **Finds all datasets** touched by any dimension, the measure being calculated, `Where` predicate, or `Having` predicate. +2. **Resolves a join path** — a connected sub-graph of the declared relationships that spans those datasets. If multiple paths exist, see §6.9. +3. **Follows the `1`-side of joins to find the finest-grain dataset** in the path. If multiple incomparable finest-grain datasets exist (shared dim, M:N), each is treated as an independent starting point and the engine MUST combine them via §6.7 (pre-aggregation), §6.8.1 (bridge), or §6.8.2 (stitch), failing with one of the codes above if no safe combination exists. + +If a finer-grained referenced field is **not wrapped in an aggregate** (i.e., the user is projecting a row-level value at a grain finer than the consuming home grain), the query MUST fail with `E_UNAGGREGATED_FINER_GRAIN_REFERENCE`. The remedy is to wrap the reference in an aggregate (`SUM`, `COUNT`, etc.) or pull the value from a coarser-grain related dataset where it is already at the home grain. + +> **Out of scope: model-level `natural_grain` declaration.** A future proposal will define an optional top-level `natural_grain:` key that pins one dataset as the implicit anchor for every query against the model. The Foundation does NOT adopt that feature yet. The behaviour described above (each metric resolves its own starting grain) is what the Foundation guarantees. + +#### Final Grain + +The final grain of a query is what defines a unique row in the result. + +In an aggregation query, the final grain is the grain implied by the dimensions. If there are no dimensions, the final grain is the empty set — a total aggregation to a single row. + +In a scalar query, the final grain MUST be the same as the starting grain (there is no aggregation step between them). If that is not possible — because the join path the query forces through includes an `N : N` edge or any other join that replicates the home dataset's rows — the query MUST fail with `E_FAN_OUT_IN_SCALAR_QUERY`. (The aggregation-query shape handles the same condition non-fatally by pre-aggregating on the many-side per §6.7; only the scalar shape lacks an aggregation step in which to absorb the fan-out, so the same condition is fatal there.) + +#### Determining Datasets Involved In a Query + +The datasets involved in a query are: + +1. Every dataset directly referenced by `Dimensions`, `Measures`, `Where`, `Having`, `Order By`, or `Fields`, **plus** +2. Every **intermediate dataset** required to resolve a connecting join path between the directly-referenced datasets via the relationships graph (§6.9). + +A query for `Dimensions: [region.name]; Measures: [SUM(orders.amount)]` over a model with `orders → customers → region` directly references `orders` and `region` but implicitly involves `customers` because it lies on the unique join path. Dataset (2) is just as much "involved in the query" as dataset (1) — `Where` predicates apply to it, cardinality safety checks apply to it, and the planner is free to read its rows. + +This has a user-visible consequence for dimension-only queries: a query with `Dimensions: [customers.region]` and no measures reads `customers` only and returns every region present in `customers`, including regions with no orders, no returns, and no activity in any other fact. The Foundation does NOT silently restrict dimension domains to "values used by some fact"; if a model wants that behaviour it must rely on the (deferred) `natural_grain` proposal referenced above. + +#### Normative Evaluation Algorithm + +This subsection describes the **semantic evaluation procedure** the Foundation requires. It is normative on observable behaviour (which rows appear, which error codes fire, in what order checks are applied) and intentionally silent on physical implementation (CTE structure, join order, materialization strategy). Two engines that produce different SQL but the same row sets and error codes for the same `(model, query)` are both compliant. + +The algorithm is written as if every clause is fully populated; engines short-circuit unused clauses. + +**A. Aggregation query (`Dimensions` and/or `Measures`).** + +1. **Classify the query shape.** If both `Fields` and (`Dimensions` ∪ `Measures`) are non-empty, raise `E_MIXED_QUERY_SHAPE` and stop. If `Dimensions` and `Measures` are both empty, raise `E_EMPTY_AGGREGATION_QUERY` (an aggregation query must have at least one of them). +2. **Resolve identifiers.** For every name in `Dimensions`, `Measures`, `Where`, `Having`, `Order By`, apply the resolution rules of §4.6. Names that fail to resolve raise `E_NAME_NOT_FOUND`. Duplicate global names raise `E_NAME_COLLISION`. Reserved-name collisions raise `E_DEFERRED_KEY_REJECTED`. +3. **Classify expression shapes** per §4.3 / D-005. For each predicate in `Where` / `Having`, compute its *resolved shape* (row-level scalar, query-grain aggregate, or boolean form of each) and verify that it is in the legal set for its clause: + - `Where` accepts row-level scalars; a window function in `Where` raises `E_WINDOW_IN_WHERE`; a query-grain aggregate in `Where` raises `E_AGGREGATE_IN_WHERE`. + - `Having` accepts query-grain aggregates and grouping-column references from `Dimensions` (§6.3); a pure row-level predicate in `Having` raises `E_NON_AGGREGATE_IN_HAVING`. + - A boolean expression that mixes terms at different resolved levels (e.g. `amount > 100 AND SUM(amount) > 1000`) raises `E_MIXED_PREDICATE_LEVEL`. +4. **Identify the final grain.** The final grain is the distinct tuple of `Dimensions`; empty `Dimensions` ⇒ the empty grain (one row). +5. **Identify the dataset-set.** Collect every dataset referenced by `Dimensions`, by any measure in `Measures`, by `Where`, by `Having`, and by `Order By`. Add any intermediate datasets required to connect them via declared relationships (§6.9). +6. **For each measure, independently:** + 1. Collect the measure's *measure dataset-set* — every dataset referenced by the measure's expression, plus the dimension datasets needed to project the result at the final grain, plus the `Where`-referenced datasets (a `Where` predicate is always pre-aggregation and affects every measure). + 2. Resolve a join path through the relationships graph that spans the measure dataset-set (§6.9). Multiple equally-valid paths ⇒ `E_AMBIGUOUS_PATH`. No path ⇒ `E_NO_PATH`. The path MUST be acyclic. + 3. Determine the measure's *starting grain*: walk the path along the `1`-side of every `N : 1` / `1 : 1` edge and find the finest-grain dataset (or datasets) reachable. If the path crosses an `N : N` edge, identify the M:N resolution required (§6.8: bridge, stitch, or filter). + 4. Verify Semantic 2 / D-026: each row of the measure's home dataset MUST contribute to each final-grain group at most once. When this is violated by a naive flat join, emit a §6.7 pre-aggregation, a §6.8.1 bridge resolution, or a §6.8.2 stitch — whichever the model supports. + 5. Verify decomposability (Semantic 4 / D-022): the aggregate's category (distributive / algebraic / holistic, per `SQL_EXPRESSION_SUBSET.md`) MUST be compatible with the chosen plan. A holistic aggregate cannot run over pre-aggregated home-grain rows; if the plan requires it, raise `E_UNSAFE_REAGGREGATION` identifying the aggregate and the grains involved. (Note: a single-step holistic aggregate over a plain `1 : N` edge is not caught — it is one SQL aggregate over the joined rows, well-defined per D-020. The §6.8.1 bridge plan is also not caught — it is a single-pass aggregate over the de-duplicated `(measure-home-row, group-key)` row set, well-defined per D-027 for every aggregate category. The error applies only to plans that genuinely force decomposition — typically §6.7 chasm pre-aggregation or §6.8.2 stitch.) + 6. If none of the §6.7 / §6.8 strategies satisfies Semantic 2 at the measure's starting grain: + - M:N traversal with no safe rewrite ⇒ `E3012_MN_NO_SAFE_REWRITE`. + - Disconnected facts referenced together ⇒ `E3013_NO_STITCHING_DIMENSION`. + - Multiple incompatible starting grains that none of the above fits ⇒ `E_AMBIGUOUS_MEASURE_GRAIN` (D-025). +7. **Compose measures at the final grain.** + - **Single-measure queries.** No composition step. The result is exactly the row set produced for the one measure in step 6 — typically one row per distinct dimension tuple reachable by the measure's join path, plus a `NULL`-key row for any unmatched fact rows on the many-side (§6.6 row 1, LEFT default). A dimension value that exists in the dim domain but is *not* reached by any surviving fact row does **not** appear. This matches raw SQL `... FROM fact LEFT JOIN dim ... GROUP BY dim.X`. + - **Multi-measure queries (two or more measures).** Stitch the independently-resolved measure row sets via `FULL OUTER` on the shared dimensions (§6.6 row 3 / D-004; Semantic 3 — neither side loses groups). This is the only shape that correctly composes two independently-aggregated facts: an `INNER` or single-direction `LEFT` would silently drop groups present only in one branch. The mental contrast: single-measure queries follow the fact's natural join shape (Plan A); multi-measure stitch is the *only* shape that explicitly preserves both sides — and it has to, because there is no other way to merge two independently-aggregated facts. + - When `Dimensions` is empty, the stitch degenerates to `CROSS JOIN` of scalar grand totals (§6.6, §6.8.2 worked example). +8. **Apply `Where` pre-aggregation** to every measure's row set (this happened logically before step 6, but is observable here as "every measure's input is post-`Where`"). +9. **Aggregate to the final grain** per measure. Apply `Having` post-aggregation. +10. **Evaluate windows** in `Measures`, `Order By`, and `Having` over the post-`Having` row set, with NULL placement defaulted per §5.1 / D-029. Windows whose home dataset would be fanned out by the plan raise `E_WINDOW_OVER_FANOUT_REWRITE` (D-030) unless the engine materialised the home grain before applying the window. +11. **Apply `Order By`** with the resolved NULL placement; then **`Limit`**. + +**B. Scalar query (`Fields`).** + +1. **Classify the query shape.** Mixed-shape: `E_MIXED_QUERY_SHAPE`. Empty `Fields`: `E_EMPTY_SCALAR_QUERY`. +2. **Resolve identifiers** (same as A.2). +3. **Reject query-grain aggregates.** A bare metric reference inside `Fields` (a model-scoped metric — the only kind in the Foundation) raises `E_AGGREGATE_IN_SCALAR_QUERY`. A `Fields` entry that is itself a query-grain aggregate (e.g., `SUM(orders.amount)` written inline) raises the same code. A `Fields` entry that references a field whose expression contains any aggregate is rejected at model-validation time with `E_AGGREGATE_IN_FIELD` (§4.3). +4. **Identify the home dataset(s).** Collect every dataset whose row-level (non-aggregated) fields drive any `Fields` entry. Two cases: + - **Single home grain.** All such datasets are linked by declared `1 : 1` edges. The engine treats them as one logical home; the home grain is their common PK (any one of them suffices). + - **Multiple incompatible homes** — two or more datasets that are not `1 : 1`-linked supply row-level fields. The scalar shape has no aggregation step that can absorb the resulting replication. Raise `E_FAN_OUT_IN_SCALAR_QUERY` (D-023, extended) and stop. The diagnostic MUST identify the conflicting home datasets and suggest converting to an aggregation query (any join of two unrelated facts at row level requires an aggregation step on at least one side). A future proposal will add a semi-join filter form (deferred — see §6.8). +5. **Resolve enrichments.** For every `Fields` entry that is not a row-level field of the home dataset, resolve it as either: + - An `N : 1` / `1 : 1` enrichment along a unique path (§6.9) — the value is attached to each home row. + - A window expression evaluated at the home grain (§6.10). + + Cross-dataset references across an `N : N` edge that the engine cannot reduce to one of the two forms above raise `E_FAN_OUT_IN_SCALAR_QUERY`. Multiple paths ⇒ `E_AMBIGUOUS_PATH`. No path ⇒ `E_NO_PATH`. Aggregate-bodied fields (any aggregate in a field expression) are rejected at model-validation time per §4.3. +6. **Verify the final grain equals the starting grain.** For a scalar query, no aggregation step exists between them, so any plan that would replicate home-dataset rows is fatal — `E_FAN_OUT_IN_SCALAR_QUERY` (D-023). The aggregation-query shape handles the same condition non-fatally per A.6.4. +7. **Apply `Where` row-level filter** (post-enrichment, pre-windowing in terms of standard-SQL ordering; window functions run after `Where`). +8. **Evaluate any home-grain windows** with NULL placement defaulted per §5.1 / D-029. +9. **Apply `Order By`** with resolved NULL placement; then **`Limit`**. + +**Notes for both shapes.** + +- Steps 2–6 are pre-execution checks. An engine MAY surface any qualifying error from this set; it SHOULD prefer more-specific codes (e.g., `E3012` over `E_AMBIGUOUS_MEASURE_GRAIN`). +- The algorithm prescribes a **logical** order; engines are free to interleave the steps in their physical plans (e.g., apply `Where` early, push joins down) as long as the observable result is the same. +- For determinism (D-014), the same `(model, query, dialect)` MUST compile to byte-identical SQL on every run. + +### 6.3 Having vs Where + +The Foundation follows standard SQL semantics: the `Where` clause is applied pre-aggregation, and the `Having` clause is applied post-aggregation over the final-grain rows. + +For this initial foundational semantics, the difference between `Where` and `Having` is straightforward because there is no developed concept of multi-step aggregations. Later proposals add a more refined understanding of grain, which enables multi-step calculations that may require a filter to affect something in the middle. For this document, `Having` is defined as occurring **over the post-`GROUP BY` row set**: predicates in `Having` MAY reference (a) any aggregate that resolves at the query's grain and (b) any grouping column from `Dimensions` (this is standard SQL — `HAVING region = 'EAST'` is legal, even though grouping columns could also have been filtered in `Where`). A predicate in `Having` that contains only row-level references with no aggregate and is not a grouping-column reference raises `E_NON_AGGREGATE_IN_HAVING`. The full predicate-shape routing matrix is in D-005 / step A.3 of the §6.2 algorithm. + +Window functions follow standard SQL ordering — they execute over the post-`Where`, post-`GROUP BY`, post-`Having` row set, before `Order By` and `Limit`. They MAY appear in `Measures`, `Fields`, `Order By`, and `Having`. They MUST NOT appear in `Where` (SQL forbids this — windows run after `Where`). See §6.10 for the Foundation's full window-function contract. + +### 6.4 Cardinality Inference + +For each declared equijoin relationship, cardinality is inferred structurally from the dataset primary and unique keys. + +``` +get_cardinality(rel): + to_unique = rel.to_columns matches rel.to_dataset.primary_key + OR any entry in rel.to_dataset.unique_keys + from_unique = rel.from_columns matches rel.from_dataset.primary_key + OR any entry in rel.from_dataset.unique_keys + + left = "1" if from_unique else "N" + right = "1" if to_unique else "N" + return (left, right) +``` + +| Case | Inferred | Typical shape | +|:---|:---|:---| +| `to` side columns match PK/UK, `from` does not | `N : 1` | fact → dimension | +| Both sides match PK/UK | `1 : 1` | dimension ↔ dimension | +| Neither side matches PK/UK | `N : N` | bridge / missing-key model | + +**`N : N` is the conservative inference** when keys are missing. The Foundation guarantees correct results across an `N : N` edge under the rules of §6.8 — the engine either finds a safe rewrite or errors out. Models with no declared keys are still well-formed, but more edges will be conservatively inferred as `N : N`, restricting the queries that resolve and steering the engine toward heavier SQL shapes. Declaring PKs / UKs makes a wider class of queries answerable and tends to produce simpler emitted SQL. + +### 6.5 Join Contexts + +Joins appear in four contexts, each with distinct rules: + +| Context | Purpose | Notes | +|:---|:---|:---| +| **Aggregation join** | Bring columns together for a metric's aggregation or for grouping dimensions. | Default type per §6.6. | +| **Filtering join** | Semi-join / anti-semi-join for filter evaluation. | Never causes row duplication. | +| **Multi-fact composition join** | Combine results from separately computed fact tables on shared dimensions (chasm-trap resolution). | Default: `FULL OUTER` on shared dims; scalar grand-totals degenerate to `CROSS JOIN`. | +| **Fan-out-safe pre-aggregation** | Aggregate the many-side first so a subsequent join doesn't fan out. | Emitted automatically. | + +### 6.6 Default Join Types + +| Scenario | Default | Reasoning | +|:---|:---|:---| +| Many-side enriched with one-side (`N : 1`) — single-measure aggregation query | `LEFT` from fact → dim | Preserve all fact rows; unmatched dims become NULL. A dim value with no matching fact rows does **not** appear in the result. This matches raw SQL `SELECT dim.X, SUM(fact.Y) FROM fact LEFT JOIN dim ... GROUP BY dim.X`. Exposes data-quality issues (unmatched fact rows) instead of silently hiding rows. | +| `1 : 1` | `LEFT` **from the starting-grain side outward** | Safe either way for cardinality; the direction matters only for orphan visibility. The "starting-grain side" is the dataset whose grain is finer-or-equal to the query's grain along the path being resolved (typically the fact in an aggregation query, or the home dataset of a scalar query). Orphan rows on the starting-grain side surface (Semantic 1); orphan rows on the far side are filtered out. This matches the `N:1` rule above when the relationship happens to be `1:1`. | +| Composition across two separately-aggregated measures from **incompatible fact roots** (multi-measure aggregation query) | `FULL OUTER` on the shared dimensions | Neither side should lose groups. This is the **only** join shape that correctly merges two independently-aggregated facts — an `INNER` or single-direction `LEFT` would silently drop groups present only in one branch. The dim values appearing in the result come from the *union* of both branches' join paths, not from the dim domain alone. (For multi-measure queries whose measures share a fact root through `N:1` ancestors, the FULL OUTER stitch is mathematically equivalent to a single-path aggregation; engines MAY emit either plan.) | +| Composition onto an empty shared-grain set (scalar grand total) | `CROSS JOIN` of **per-fact 1-row scalars** | Each fact branch is independently aggregated to a single scalar row first (one row, the empty grain); the `CROSS JOIN` then produces exactly one row per branch combination — i.e., one row total. The Foundation requires that each branch be reduced to a 1-row scalar **before** the `CROSS JOIN`; an engine MUST NOT `CROSS JOIN` two non-scalar row sets, which would produce a Cartesian product. | + +**Why `LEFT`, not `INNER`?** An `INNER JOIN` would silently drop facts whose dimension key is unresolved — this is a correctness failure with no error message. `LEFT` surfaces the problem (NULL dimension values in the result). The Foundation has no Foundation-level mechanism to opt into `INNER`; opt-in mechanisms (declared referential integrity, per-metric overrides) are deferred to §10. + +**Why `LEFT` (fact → dim), not `LEFT` (dim → fact)?** For a single-measure aggregation query, the natural anchor is the fact: every fact row should contribute to exactly one group, and unmatched fact rows should be visible (bucketed to a `NULL`-key group). Anchoring on the dim instead would suppress orphan facts (silently wrong, violates Semantic 1) and would inflate the result with dim values that have no fact data (silently right but inconsistent with raw SQL — and the user did not ask to see "all our regions", they asked for "revenue by region"). If the user wants "all dim values whether or not the fact has data", they make the query multi-measure (e.g. add `COUNT(dim.id) AS dim_population` as a second measure) — which triggers the FULL OUTER stitch in row 3, and every dim value appears via the dim-population branch. + +### 6.7 Avoiding Traps + +The Foundation declares the §6.1 semantics that ensure no traps are encoded in analytical queries. Implementation, however, is engine-dependent. There are a handful of safe ways to combine joins and aggregations; providers are free to implement whichever they choose, as long as they preserve the §6.1 semantics. + +Different implementations may support different edge cases differently. As a result, there are a handful of cases that implementations MAY choose to reject: + +- Facts that have no declared unique or primary key. +- Many-to-many joins that would assign a row to multiple groups. + +### 6.8 M:N Resolution + +`N : N` relationships are valid model citizens, however, some engines may not support all ways of querying over them. In these cases, it is acceptable for them to return MN_AGGREGATION_REJECTED. Hoever, any engines that do support N : N relationships, MUST adhere to the semantics listed below. + +**Semantic guarantee.** When a query traverses an `N : N` relationship, the engine MUST produce results that are mathematically equivalent to one of the safe rewrites below — i.e., results in which no row is double-counted because of fan-out, and no measure is silently inflated by a chasm. If no safe rewrite exists at the query's grain, the engine MUST raise a code-tagged error rather than emit potentially-wrong SQL. + + +**Equivalent safe rewrites.** Any of the following plan shapes produces correct results; the engine may use any of them, or any combination, as long as the result agrees with at least one. + +| # | Rewrite | Idea | Reference plan shape | +|:---:|:---|:---|:---| +| 1 | **Bridge** (§6.8.1) | A bridge dataset with `N : 1` edges to both endpoints lets the planner traverse the M:N. | Two `enrich` hops via the bridge + a de-duplication step at the (fact, group-key) level to enforce Semantic 2 (each row of the measure's home dataset contributes to each group at most once). | +| 2 | **Stitch** (§6.8.2) | Both endpoints reach a common set of dimensions; the planner aggregates each side at the shared grain and joins. | Independent `aggregate` per endpoint at the shared grain, then `merge` (FULL OUTER on the shared dims). | + +> **Deferred — semi-join filter form.** A third resolution mode — +> using a semi-join expression like `EXISTS_IN` purely in a `Where` +> predicate — is intentionally **deferred** to a future proposal that +> covers semi-join semantics in full (NULL-safety, NOT-form, +> correlated/uncorrelated shapes). Until that proposal lands, M:N +> resolution in the Foundation is limited to **Bridge** and **Stitch**. +> If a model genuinely needs a cross-fact filter today, the author +> must either add a bridge dataset, or express the filter via an +> aggregation step that produces the filter set explicitly. + +**Error contract.** When no safe rewrite produces a correct answer at the query's grain, the engine MUST fail with one of: + +| Code | Condition | Required guidance in the error | +|:---|:---|:---| +| `E3012_MN_NO_SAFE_REWRITE` | An `N : N` traversal in a measure has no semantically-equivalent safe rewrite given the current model and query grain. | Suggest adding a bridge dataset or a shared dimension. (A semi-join filter form is deferred — see the note above.) | +| `E3013_NO_STITCHING_DIMENSION` | Two unrelated facts (different roots, no path) are referenced together with no dimension shared by both. | Note that the result would otherwise be a Cartesian product; suggest adding a shared dimension. | + +`E3011_MN_AGGREGATION_REJECTED` is reserved for the **engine-capability opt-out** described in the *Semantic guarantee* above: an engine that elects not to support M:N traversal at all MUST raise it for *every* M:N query and MUST NOT emit SQL. It is not a per-query verdict — engines that DO support M:N use `E3012` / `E3013` for the cases where a particular query has no safe rewrite. + +#### 6.8.1 Bridge Datasets (reference rewrite) + +A **bridge dataset** is any dataset with declared `N : 1` relationships to two or more other datasets. Bridges are not a special node type — they are recognizable from cardinality alone. No keyword is required; the engine discovers the bridge structurally: + +```yaml +datasets: + - name: order_lines + source: sales.public.order_lines + primary_key: [order_id, line_id] +``` + +**Worked example.** A classic actor↔movie M:N modelled through `appearances`: + +```yaml +datasets: + - { name: actors, primary_key: [actor_id] } + - { name: movies, primary_key: [movie_id] } + - { name: appearances, primary_key: [actor_id, movie_id] } # bridge + +relationships: + - { name: app_to_actor, from: appearances, to: actors, from_columns: [actor_id], to_columns: [actor_id] } # N:1 + - { name: app_to_movie, from: appearances, to: movies, from_columns: [movie_id], to_columns: [movie_id] } # N:1 +``` + +Tiny dataset to make the bridge resolution concrete: + +`actors`: + +| actor_id | name | height | +|:---:|:---|---:| +| A1 | Alice | 170 | +| A2 | Bob | 170 | +| A3 | Carol | 180 | + +`movies`: + +| movie_id | title | gross | +|:---:|:---|---:| +| M1 | Action | 100 | +| M2 | Drama | 200 | +| M3 | Comedy | 50 | + +`appearances` (bridge): + +| actor_id | movie_id | +|:---:|:---:| +| A1 | M1 | +| A1 | M2 | +| A2 | M1 | +| A3 | M3 | + +**Query**: `Measures: [SUM(movies.gross)]`, `Dimensions: [actors.height]`. + +Per Semantic 2 (§6.1), each row of `movies` (the measure's home dataset) MUST contribute at most once to any given output group. A movie like M1, whose cast includes two actors at the same height, must contribute to that height *once*, not twice. The bridge plan walks the bridge to materialize the unique `(movie, height)` associations, then aggregates. + +*Step 1 — enrich `appearances` along both `N : 1` edges (`→ movies` for `gross`, `→ actors` for `height`):* + +| actor_id | movie_id | height | gross | +|:---:|:---:|---:|---:| +| A1 | M1 | 170 | 100 | +| A1 | M2 | 170 | 200 | +| A2 | M1 | 170 | 100 | +| A3 | M3 | 180 | 50 | + +*Step 2 — de-duplicate to one row per (`movie_id`, `height`) pair. This is the bridge-resolution step that enforces Semantic 2: each fact contributes to each group at most once.* + +| movie_id | height | gross | +|:---:|---:|---:| +| M1 | 170 | 100 | ← M1 had two appearances at height 170 (Alice, Bob); kept once +| M2 | 170 | 200 | +| M3 | 180 | 50 | + +*Step 3 — aggregate to query grain (`SUM(gross)` per `height`):* + +| height | SUM(movies.gross) | +|---:|---:| +| 170 | 300 | +| 180 | 50 | + +A naive flat join `actors ⋈ appearances ⋈ movies` with `GROUP BY actors.height` produces `(170 → 400, 180 → 50)` because M1's 100 is counted once per appearance. The bridge plan above produces `(170 → 300, 180 → 50)` because M1 is counted once per `(movie, height)` association. The bridge-plan answer is the one Semantic 2 mandates and the one Looker symmetric aggregates, Tableau Multi-Fact relationships, and Power BI bridge-table best practice all produce on the same data. + +Note that summing the per-height totals (`300 + 50 = 350`) MAY differ from summing the source table directly (`100 + 200 + 50 = 350`, equal here only because no movie has actors at multiple heights). Semantic 2 explicitly permits this divergence: a movie whose cast spans two heights would contribute to both height groups, so the per-group totals can sum to more than the base-table total. + +**Non-distributive aggregates** (`AVG`, `MEDIAN`, and other holistic forms) across an M:N edge are accepted under the same contract as the distributive case above: the aggregate's input is the unique `(measure-home-row, group-key)` row set, and the aggregate is evaluated once over that set per group. Because the contract enumerates a single input row set and applies the aggregate once, there is no algebraic-decomposition concern — that concern arises only when a plan is *forced* to decompose the aggregate across multiple stages, which happens for chasm pre-aggregation (§6.7) and for stitch (§6.8.2) but not here. Engines MAY satisfy the contract by any plan that produces equivalent results; the steps shown above are a reference construction, not the only legal one. For the fixture above: `AVG(movies.gross)` grouped by `actors.height` ⇒ `170 → AVG(100, 200) = 150`, `180 → AVG(50) = 50`. This is the heavy-side-weighted single-step analogue of the `1 : N` rule in §4.5. + +A *different* interpretation — "per-actor-first" averaging, where the engine first computes each actor's personal average gross, then averages those per-actor averages within the height group — yields different numbers (`170 → AVG(150, 100) = 125`, `180 → 50`). That interpretation is reachable only through *nested aggregation* (`AVG(AVG(movies.gross))`), which carries an implicit grain pin on the inner aggregate. The nested form is **deferred to §10's grain-aware-functions proposal** and currently raises `E_NESTED_AGGREGATION_DEFERRED` (§4.5). Users who want the per-home-row-first reading wait for §10; the bare form continues to give the bridge-dedup answer. + +#### 6.8.2 Stitching Dimensions (reference rewrite) + +A **stitching dimension** is a dimension reachable from both endpoints of a query through `N : 1` paths. When the query references measures from two facts that share such a dimension (and no measure on the `N : N` edge itself), the safe rewrite is to compute each fact independently at its home grain, `merge` them on the finest shared key, then aggregate to the query grain. + +This is the same pattern §6.7 already applies for the **chasm trap** — the only difference is that the two facts are now linked by an explicit `N : N` relationship rather than two separately-declared paths through a shared dim. The result contract (not the SQL) is what §6.8.2 fixes: every group of either fact appears in the output (Semantic 3); a fact that has no rows in a given group contributes `NULL` (or `0` if the metric `COALESCE`s). + +An engine that picks this rewrite MUST raise `E3013_NO_STITCHING_DIMENSION` when the query's dimension set is empty *and* the two endpoints share no path — silently producing a Cartesian product would be wrong. + +**Worked example.** `orders` and `returns` both connect to a `customers` dimension. The semantic query is: + +```yaml +Dimensions: + - customers.region +Measures: + - SUM(orders.amount) AS total_revenue + - SUM(returns.amount) AS total_returns +``` + +Tiny dataset (note: every region has at least one fact present, but not every customer appears in both facts — this is what makes Semantic 3 visible): + +`customers`: + +| customer_id | region | +|:---:|:---| +| C1 | EAST | +| C2 | EAST | +| C3 | WEST | +| C4 | NORTH | + +`orders`: + +| order_id | customer_id | amount | +|:---:|:---:|---:| +| O1 | C1 | 100 | +| O2 | C1 | 200 | +| O3 | C2 | 50 | +| O4 | C3 | 300 | + +`returns`: + +| return_id | customer_id | amount | +|:---:|:---:|---:| +| R1 | C1 | 25 | +| R2 | C4 | 10 | + +Note that C4 has only a return (no orders), C3 has only an order (no return), and only one region (EAST) has both facts. + +The engine computes each fact independently at the home grain of its source, merges them, then aggregates to the query grain: + +*Step 1a — aggregate `orders` to customer grain:* + +| customer_id | revenue | +|:---:|---:| +| C1 | 300 | +| C2 | 50 | +| C3 | 300 | + +*Step 1b — aggregate `returns` to customer grain:* + +| customer_id | returns_ | +|:---:|---:| +| C1 | 25 | +| C4 | 10 | + +*Step 2 — FULL OUTER merge on `customer_id`:* + +| customer_id | revenue | returns_ | +|:---:|---:|---:| +| C1 | 300 | 25 | +| C2 | 50 | NULL | +| C3 | 300 | NULL | +| C4 | NULL | 10 | + +*Step 3 — enrich with `customers.region` (`N : 1`):* + +| customer_id | region | revenue | returns_ | +|:---:|:---|---:|---:| +| C1 | EAST | 300 | 25 | +| C2 | EAST | 50 | NULL | +| C3 | WEST | 300 | NULL | +| C4 | NORTH | NULL | 10 | + +*Step 4 — aggregate to query grain (`SUM` per region):* + +| region | total_revenue | total_returns | +|:---|---:|---:| +| EAST | 350 | 25 | +| WEST | 300 | NULL | +| NORTH | NULL | 10 | + +**Semantic 3 is visible in the last table:** WEST has revenue but no returns (returns column is `NULL`, not omitted); NORTH has returns but no revenue (revenue column is `NULL`, region row still present). Neither fact loses its groups. This follows standard SQL — `SUM` over an empty (or all-NULL) input row set is `NULL` (§6.11). If the user wants `0` instead of `NULL`, that is a per-metric authoring decision (`COALESCE(SUM(...), 0)`), not a join-semantics concern. + +Already handled by the chasm-trap planner today (§6.7); §6.8.2 just names the equivalence so two engines can agree on the result without prescribing the rewrite. + +### 6.9 Path Resolution and Ambiguity + +When a query spans multiple datasets, the engine finds a path through the relationships graph. The rules: + +1. **Unique path.** If exactly one path of `N : 1` / `1 : 1` edges connects the referenced datasets, the engine uses it. +2. **Multiple paths.** If two or more paths exist (e.g., `orders` can reach `users` via `placed_by` or `fulfilled_by`), the engine MUST raise `E_AMBIGUOUS_PATH`. The Foundation provides no in-model mechanism to pick between them — path-disambiguation hints (`using_relationships`) are deferred to a later proposal (§10). +3. **No path.** If the referenced datasets are not connected, the engine MUST raise `E_NO_PATH`. +4. **Path must be acyclic within a query.** A relationship graph may contain cycles globally, but a single query MUST select an acyclic sub-path. + +Per-metric join-type overrides (`joins.type: INNER | LEFT | FULL`) are also deferred to a later proposal — Foundation engines use only the §6.6 defaults. + +### 6.10 Window Functions + +Standard SQL window functions are part of the Foundation. The expression-level catalog — ranking (`ROW_NUMBER`, `RANK`, `DENSE_RANK`, `NTILE`, `PERCENT_RANK`, `CUME_DIST`), navigation (`LAG`, `LEAD`, `FIRST_VALUE`, `LAST_VALUE`, `NTH_VALUE`), and aggregate-windows (any required aggregate combined with `OVER (...)`) — is defined normatively in [`SQL expression subset`](https://docs.google.com/document/d/1Jn98kHWsnbvo1MycQBAnWirGTwRmw-DWaen7coNCYWA/edit?usp=sharing) §"Window Functions". This section adds the **semantic contract** the Foundation requires on top of that catalog so that two engines compute the same answer for the same window. + +#### 6.10.1 Where windows MAY appear + +| Clause / expression site | Window allowed? | Evaluated at | +|:---|:---:|:---| +| `Measures` entry, or a metric `expression` referenced by `Measures` | Yes | The query's grain (post-`GROUP BY`) | +| `Fields` entry (scalar query), or a field `expression` projected by `Fields` | Yes | The home dataset's grain | +| `Order By` | Yes | The post-`Having` row set | +| `Having` | Yes (rare) | The post-`GROUP BY` row set | +| `Where` | **No** — SQL prohibits | — | +| Inside an aggregate (`SUM(SUM(x) OVER (PARTITION BY ...))`) | Yes — the inner window runs at the inner aggregate's grain; the outer aggregate runs at the query grain | — | +| Inside another window (`RANK() OVER (... ORDER BY SUM(x) OVER (...))`) | **No** — illegal in SQL | — | + +References to a window function in `Where` MUST raise `E_WINDOW_IN_WHERE` with a suggestion to use `Having` or to wrap the calculation as a filterable field. + +#### 6.10.2 Determinism contract + +Windows are the a source of cross-engine non-determinism in BI SQL. The Foundation pins down three sources of that non-determinism — one by centralising it in §5.1, two by adding window-specific rules here: + +1. **NULL ordering inside `OVER (... ORDER BY ...)` follows the §5.1 "Common clause semantics" default.** Any `ORDER BY ` inside `OVER (...)` is governed by the same rule as the outer `Order By`: if `NULLS FIRST` / `NULLS LAST` is not explicit, the resolved placement is **`NULLS LAST` for `ASC` and `NULLS FIRST` for `DESC`** (the SQL:2003 "NULLs are high-end" convention), and engines MUST guarantee that resolved row order. + +2. **`LAST_VALUE` default-frame warning.** OSI will follow SQL's default behaviour with its default window frame (`RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`). This causes `LAST_VALUE(x) OVER (PARTITION BY g ORDER BY t)` return the current row, not the partition's last value. This is an often-reported mistake people can make, however, we think staying consistent with SQL is more valuable that a different default. + +3. **Tie-breaking in order-dependent windows.** For functions whose result depends on row order (`ROW_NUMBER`, `RANK`, `DENSE_RANK`, `LAG`, `LEAD`, `FIRST_VALUE`, `LAST_VALUE`, `NTH_VALUE`, `NTILE`), engines MAY emit a diagnostic warning when the `ORDER BY` cannot tie-break to a stable ordering (i.e., the order columns do not include a unique key of the partition). This is a MAY, not a SHOULD — many real BI queries accept rare-tie non-determinism. For order-independent windows (`PERCENT_RANK`, `CUME_DIST`, partition-only aggregate windows like `SUM(x) OVER (PARTITION BY region)`) this rule does not apply. + +#### 6.10.3 Fan-out and Semantic 2 + +Semantic 2 (no row of dataset `A` contributes more than once to a measure on `A`) extends to windows: a window function whose home dataset is `A` MUST run over the pre-fan-out row set of `A`, just like an aggregate would. Concretely, a metric `SUM(orders.amount) OVER (PARTITION BY orders.region)` joined to a fan-out child of `orders` MUST NOT see the duplicated rows in the window calculation; the engine MUST pre-aggregate or otherwise materialize the home-grain rows before applying the window. Engines that cannot satisfy this MUST raise `E_WINDOW_OVER_FANOUT_REWRITE` rather than emit potentially-doubled running totals. + +#### 6.10.4 Grain interaction + +A field whose expression contains a window function evaluates at the home dataset's grain — exactly like any other field expression. The window's `PARTITION BY` and `ORDER BY` expressions MUST be resolvable at that home grain (typically: row-level fields of the home dataset, or `N : 1` enrichments along a unique path). + +When such a windowed field is referenced from an aggregation query that groups the home dataset to a coarser grain, the windowed value is a row-level scalar at the home grain — finer than the query's grain. Per D-024, a **bare** reference to that field in `Measures` MUST raise `E_UNAGGREGATED_FINER_GRAIN_REFERENCE`; the user MUST wrap the reference in an aggregate (`MAX(orders.order_rank)`, `COUNT(*) WHERE orders.order_rank = 1`, etc.) or use it in `Where` as the canonical filter. Aggregating a ranking function across a coarser grain rarely produces meaningful analytics, but is well-defined when the user spells out the outer aggregate. The far more common BI pattern (filter by windowed field in `Where`, then aggregate) works correctly under the pre-fan-out rule above. The canonical "first order per customer" pattern is: + +```yaml +# field on orders +- name: order_rank + expression: ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date NULLS LAST) + +# query +Dimensions: [customers.region] +Measures: [COUNT(*) AS first_orders] +Where: orders.order_rank = 1 +``` + +This works because `orders.order_rank` is evaluated at the orders home grain (before any aggregation to region), the `Where` filters at that grain, and the `COUNT(*)` then aggregates the filtered rows to region. + +#### 6.10.5 Windowed metrics in composition + +A metric whose `expression` contains a window function (e.g., `running_total = SUM(amount) OVER (ORDER BY order_date NULLS LAST)`) is well-defined when used directly in a query: the window runs over the post-`Where`, post-`GROUP BY` row set at the query's grain. + +What is **deferred**: referencing a windowed metric *from another metric's expression*. The composition rules in §4.5 forms (2) and (3) say metric-references-metric resolves at the query's grain with no grain or filter propagation; that rule has subtle interactions with the inner window's `PARTITION BY` / `ORDER BY` that need their own proposal (§10). Engines MUST raise `E_WINDOWED_METRIC_COMPOSITION` when a metric expression references a windowed metric. + +The rule applies to **any** reference to a windowed metric from another metric's body — including syntactically no-op transformations such as `running_total + 0`, `CAST(running_total AS BIGINT)`, or `running_total * 1`. Engines MUST detect references at the metric-AST level (not by inspecting whether the surrounding expression is "real arithmetic"). Direct use of the windowed metric in `Measures` is the only Foundation-supported consumption shape today. + +#### 6.10.6 Frame modes + +Two frame modes are required: `ROWS` and `RANGE`. The third standard-SQL mode, `GROUPS`, is deferred to a later proposal. Frame bounds MUST be literal integers (or `UNBOUNDED` / `CURRENT ROW`); parameterized frame bounds (`:lookback_frame PRECEDING`) are deferred to §10. + +### 6.11 Empty-input and NULL-input aggregate behaviour + +The Foundation follows **standard SQL** for aggregates over empty and NULL-containing input row sets. + +#### 6.11.1 Empty-input behaviour + +| Aggregate | Empty-set result | +|:---|:---| +| `COUNT(*)`, `COUNT(x)`, `COUNT(DISTINCT x)` | `0` | +| `SUM(x)`, `AVG(x)`, `MIN(x)`, `MAX(x)`, `MEDIAN(x)`, `PERCENTILE_CONT(...)`, `STDDEV(x)`, `VARIANCE(x)` | `NULL` | + +`COUNT` returns `0` because it counts rows; an empty input has zero rows. Every other aggregate returns `NULL` because there is no defined result when there are no contributing values. This is what every major SQL dialect does natively (Snowflake, Postgres, BigQuery, Databricks, SQL Server, MySQL), and what every BI tool's source query produces unless the modeller explicitly overrides it (e.g. by wrapping `SUM` in `COALESCE(..., 0)`). + +#### 6.11.2 NULL-input behaviour (normative) + +When the input row set is non-empty but some rows have `NULL` in the aggregated column, the Foundation follows ANSI SQL: + +| Aggregate | NULL handling | +|:---|:---| +| `COUNT(*)` | Counts all rows, including those with `NULL` in any column. | +| `COUNT(x)`, `COUNT(DISTINCT x)` | Ignores rows where `x IS NULL`. `COUNT(DISTINCT x)` additionally collapses duplicate non-NULL values to one. If every row's `x` is `NULL`, the result is `0`. | +| `SUM(x)`, `AVG(x)`, `MIN(x)`, `MAX(x)`, `MEDIAN(x)`, `STDDEV(x)`, `VARIANCE(x)`, `PERCENTILE_CONT(...)` | Ignores rows where `x IS NULL`. If every row's `x` is `NULL` (i.e. the non-NULL input subset is empty), the result is `NULL` — the same as §6.11.1's empty-input rule. | + +This has one non-obvious consequence: `AVG(x)` over a set with one `5` and 99 `NULL`s is `5`, not `0.05`. Users who want NULLs treated as zero MUST write `AVG(COALESCE(x, 0))`. + +#### 6.11.3 `COUNT(DISTINCT)` over `N : N` is safe + +`COUNT(DISTINCT x)` is technically holistic (it cannot be safely re-aggregated by re-applying `COUNT(DISTINCT)` to partial counts), but it has an idempotence property under join-induced row duplication: duplicating a row that already contributes its `x` value to the distinct set does not change the result. The §6.8.1 bridge plan materialises distinct `(home, group-key)` associations precisely to enforce Semantic 2 — the same de-duplication that `COUNT(DISTINCT x)` performs naturally. + + +#### 6.11.4 Composition + +`revenue / cost` where `cost` is zero rows returns `NULL / NULL = NULL` — not a division-by-zero error. Authors who want a sentinel value should write `revenue / NULLIF(SUM(cost.amount), 0)` (still `NULL` on empty `cost`) or `COALESCE(SUM(revenue.amount), 0) / NULLIF(SUM(cost.amount), 0)` to surface a `0` numerator. These are author-level concerns, not Foundation-level rewrites. + +#### 6.11.5 Multi-fact stitch + +When the chasm/stitch plan produces a group present in fact A but not in fact B (Semantic 3, §6.8.2), fact B's measure cell is whatever standard SQL would produce — `NULL` for `SUM`, `0` for `COUNT`. Models that prefer `0` for missing-fact cells should declare the metric as `COALESCE(SUM(amount), 0)` (this is a per-metric author decision, not a Foundation default). + +--- + +## 7. SQL Expression Subset + +The SQL expression subset is defined in [SQL_EXPRESSION_SUBSET.md](SQL_EXPRESSION_SUBSET.md). Implementations may support many different dialects, but MUST support the OSI_SQL_2026 dialect at a minimum. + +--- + +## 8. Worked Examples + +### 8.1 Single-Table Revenue + +```yaml +semantic_model: + - name: sales_analytics + datasets: + - name: orders + source: sales.public.orders + primary_key: [order_id] + fields: + - name: order_id + expression: order_id + - name: customer_id + expression: customer_id + - name: amount + expression: amount + - name: region + expression: region + - name: status + expression: status + +# All metrics live at the top-level `metrics:` section, referenced by bare name. +metrics: + - name: total_revenue + expression: SUM(orders.amount) + - name: order_count + expression: COUNT(orders.order_id) +``` + +**Query:** +```yaml +dimensions: [orders.region] +measures: [total_revenue, order_count] +where: "orders.status = 'completed'" +order_by: [{field: total_revenue, direction: DESC}] +``` + +**Compiled SQL:** +```sql +SELECT region, + SUM(amount) AS total_revenue, + COUNT(order_id) AS order_count +FROM sales.public.orders +WHERE status = 'completed' +GROUP BY region +ORDER BY total_revenue DESC +``` + +No joins. Standard aggregation. + +### 8.2 Fact-to-Dimension Enrichment (N:1) + +Add customers: + +```yaml +datasets: + - name: customers + source: sales.public.customers + primary_key: [id] + fields: + - name: id + expression: id + - name: segment + expression: segment + +relationships: + - name: orders_to_customers + from: orders + to: customers + from_columns: [customer_id] + to_columns: [id] +``` + +**Query:** +```yaml +dimensions: [customers.segment] +measures: [total_revenue] +``` + +**Compiled SQL (`LEFT JOIN` per the Foundation default — orphan orders surface as a NULL `segment` row instead of being silently dropped):** +```sql +SELECT c.segment, SUM(o.amount) AS total_revenue +FROM sales.public.orders o +LEFT JOIN sales.public.customers c ON o.customer_id = c.id +GROUP BY c.segment +``` + +Authors who know the data is referentially intact and want `INNER` semantics will get them via the deferred referential-integrity extension (§10). + +### 8.3 Chasm-Trap Resolution + +Add returns: + +```yaml +datasets: + - name: returns + source: sales.public.returns + primary_key: [return_id] + fields: + - name: return_id + expression: return_id + - name: customer_id + expression: customer_id + - name: amount + expression: amount + +relationships: + - name: returns_to_customers + from: returns + to: customers + from_columns: [customer_id] + to_columns: [id] + +# Add the new metric to the top-level metrics section. +metrics: + - name: total_returns + expression: SUM(returns.amount) +``` + +**Query:** `total_revenue` and `total_returns` by `customers.segment`. + +**Compiled SQL:** independent aggregation + composition on shared dim: + +```sql +WITH order_metrics AS ( + SELECT c.segment, SUM(o.amount) AS total_revenue + FROM sales.public.orders o + LEFT JOIN sales.public.customers c ON o.customer_id = c.id + GROUP BY c.segment +), +return_metrics AS ( + SELECT c.segment, SUM(r.amount) AS total_returns + FROM sales.public.returns r + LEFT JOIN sales.public.customers c ON r.customer_id = c.id + GROUP BY c.segment +) +SELECT COALESCE(o.segment, r.segment) AS segment, + o.total_revenue, + r.total_returns +FROM order_metrics o +FULL OUTER JOIN return_metrics r USING (segment) +``` + +Neither fact fans the other; neither segment drops if it exists on only one side. A segment present on only one side has `NULL` for the missing fact's measure — standard SQL behaviour, preserved by §6.11. Models that prefer `0` on missing cells declare it explicitly: `COALESCE(SUM(amount), 0) AS total_revenue`. + +### 8.4 Semi-Join Filter (deferred) + +A semi-join filter form (e.g., `EXISTS_IN(orders.customer_id, +returns.customer_id)` — "orders for customers who have returned +something") is intentionally **deferred** to a future proposal that +addresses semi-join semantics in full (NULL-safety, `NOT`-form, +correlated/uncorrelated shapes, compilation contract). Until that +proposal lands, the Foundation does not define `EXISTS_IN`. Models +that need cross-fact filtering today must express it via aggregation +(produce the filter set as an explicit dimension, then filter on it). + +### 8.5 Ambiguous Path → Error + +```yaml +relationships: + - name: order_placed_by + from: orders + to: users + from_columns: [placed_by_id] + to_columns: [id] + - name: order_fulfilled_by + from: orders + to: users + from_columns: [fulfilled_by_id] + to_columns: [id] +``` + +A Foundation query for `COUNT(orders.order_id)` grouped by `users.region` has two valid paths to `users` (via `order_placed_by` or `order_fulfilled_by`). The Foundation has no in-model disambiguation mechanism, so the engine MUST fail: + +``` +E_AMBIGUOUS_PATH: 2 paths from 'orders' to 'users': + - order_placed_by (orders.placed_by_id = users.id) + - order_fulfilled_by (orders.fulfilled_by_id = users.id) +Path-disambiguation is deferred to OSI_Proposal_Path_Disambiguation.md (§10). +Workaround: split the model into two queries, or define separate dataset +views over `orders` that pre-pick one path. +``` + +--- + +## 9. Extensibility Hooks + +The Foundation's surface is intentionally narrow. Each of the deferred features in §10 layers on additively, and the spec leaves open hooks so they can be adopted later without breaking any Foundation-conformant model: + +| Feature | Hook | +|:---|:---| +| Explicit grain overrides and grain-aware functions | Fields and metrics MAY in future carry an optional `grain:` keyword. The §10 grain-aware-functions proposal bundles four constructs that all require an implicit grain pin: (a) **all aggregate-bodied fields** (any aggregate in a field expression, `E_AGGREGATE_IN_FIELD`, §4.3), (b) nested aggregation in metrics (`E_NESTED_AGGREGATION_DEFERRED`, §4.5), (c) per-dataset `metrics:` blocks and the `dataset.metric_name` reference form (§4.5), and (d) explicit grain markers (`FIXED`/`INCLUDE`/`EXCLUDE`). Foundation users express every aggregate as a model-scoped metric (top-level `metrics:`, referenced by bare name). | +| Filter context | Metrics MAY in future carry an optional `filter:` block (`reset`, `expression`). | +| Non-equijoins / ASOF / range | Relationships MAY in future carry `condition`, `asof`, or `range`. | +| Access modifiers | `access_modifier: public | private` already defined (§4.3, §4.5). | +| Parameterized window frame bounds | Window `OVER (... ROWS BETWEEN ... )` may in future allow bind-parameter bounds (`:lookback_frame PRECEDING`). | +| Windowed-metric composition | A metric whose `expression` references another metric whose expression contains a window function is deferred — see §10. | + +Any tool that today reads Snowflake Semantic Views YAML, Cube.js YAML, Looker LookML, or dbt semantic-layer YAML can round-trip to Foundation OSI without losing the constructs it actually uses. + +--- + +## 10. Explicitly Deferred Features + +Each deferred feature has an existing design in `specs/`. Adopting any of them is a strict addition on top of the Foundation. + +| Feature | Design Doc | +|:---|:---| +| Explicit grain overrides and grain-aware functions — covering: (a) explicit grain markers (`FIXED`, `INCLUDE`, `EXCLUDE`, and an explicit `TABLE` keyword); (b) **all aggregate-bodied fields** (any aggregate in a field expression, same-grain or cross-grain) (`E_AGGREGATE_IN_FIELD`, §4.3); (c) **nested aggregation** in any metric expression (`E_NESTED_AGGREGATION_DEFERRED`, §4.5); (d) **per-dataset `metrics:` blocks** and the `dataset.metric_name` reference form (§4.5). | `OSI_Core_Abstractions.md` §Grain, `OSI_Calc_Model_Semantics.md`. **Note:** these are bundled because each carries an implicit "this metric's home dataset is fixed" pin whose `Where`-context interaction §10 will settle explicitly. The Foundation has exactly one place metrics live (top-level `metrics:`) and one syntax for referencing them (bare name); fields are non-aggregate scalar expressions. | +| Filter context propagation (`reset`, `filter.expression` on metrics) | `OSI_Core_Abstractions.md` §Filter | +| Metric composition (metric-refs-metric with grain/filter interaction) | `OSI_Core_Abstractions.md` §Metric References & Composition | +| Model-level `natural_grain` declaration | Future proposal (separate addition). | +| Path disambiguation (`using_relationships`) and per-metric join-type overrides | `OSI_Proposal_Path_Disambiguation.md` (to be drafted) | +| Non-equijoin relationships (`condition`, `cardinality`) | `OSI_Proposal_Non_Equijoins.md` | +| ASOF and Range relationships | `OSI_Proposal_ASOF_and_Range_Joins.md` | +| Referential integrity (declared `from_all_rows_match` / `to_all_rows_match` to enable INNER promotion) | `OSI_Proposal_Referential_Integrity.md` | +| Semi-additive measures | `OSI_Proposal_Semi_Additive.md` | +| Grouping sets / rollup / cube | `OSI_Proposal_Grouping_Sets.md` | +| Pivot / unpivot operator | `OSI_Proposal_Pivot_Operator.md` | +| Parameterized window frame bounds (e.g. `ROWS BETWEEN :n PRECEDING AND CURRENT ROW`) | `SQL_EXPRESSION_SUBSET.md` §"Window Functions" — diverges from standard SQL, which requires integer-literal bounds. | +| `GROUPS` frame mode | Standard SQL but not portable: Postgres / Oracle / DuckDB support; Snowflake / BigQuery / Databricks / SQL Server / MySQL do not. `ROWS` and `RANGE` cover the same use cases for the engines that lack `GROUPS`. | +| Windowed-metric composition (`metric A = ...` references `metric B = SUM(...) OVER (...)`) | Composition rules in §4.5 forms (2) / (3) do not yet pin down what happens when the inner metric carries an `OVER (...)` clause. Direct use of windowed metrics in `Measures` works today (§6.10); composing them does not. | +| Snowflake `PARTITION BY EXCLUDING` | Non-standard SQL extension; useful but not portable. | +| Ordered-set aggregates with `WITHIN GROUP (ORDER BY ...)` (e.g. `LISTAGG`, `PERCENTILE_CONT`) | Standard SQL but inconsistently supported and orthogonal to the window-function subset already covered. | +| Symmetric aggregates (Looker hash-trick fanout-tolerant SQL) | Future codegen optimization. The Foundation gets equivalent correctness from §6.8.2 stitch (aggregate-then-merge), so symmetric aggregates would only ever be a performance lever, not a correctness mechanism. Adding them is a `codegen/` concern that swaps the SQL pattern without changing the algebra or any spec contract. | +| Multi-hop bridge resolution (`A → Br1 → Br2 → B`) | The Foundation resolves M:N through a *single* bridge dataset (§6.8.1). Two-hop bridges are common; three-or-more is rare and adds significant ambiguity surface. Authors who genuinely need it can compose two bridge resolutions by introducing an intermediate dataset modelled as a fact. Revisit if real models require it. | + +--- + +## 11. Compliance Levels + +An OSI Foundation-compliant implementation MUST: + +1. Parse the YAML structure in §4 and reject any field outside the Foundation surface with `E_DEFERRED_KEY_REJECTED` (no silent acceptance of deferred features). +2. Distinguish **aggregation queries** (`Dimensions` / `Measures`) from **scalar queries** (`Fields`) per §5.1, rejecting mixed-shape queries with `E_MIXED_QUERY_SHAPE`. Execute each shape with the semantics of §5.1 and §6.2 — including `Where`, `Having` (aggregation only), `Order By`, and `Limit`. +3. Route filter predicates by **expression shape** per §6.3: aggregate inside `Where` ⇒ `E_AGGREGATE_IN_WHERE`; pure row-level predicate inside `Having` ⇒ `E_NON_AGGREGATE_IN_HAVING`; one boolean mixing row-level and aggregate terms ⇒ `E_MIXED_PREDICATE_LEVEL`. +4. Reject a bare metric reference inside `Fields` with `E_AGGREGATE_IN_SCALAR_QUERY` (§5.1.2). Reject any field whose expression contains an aggregate function with `E_AGGREGATE_IN_FIELD` (§4.3) — express it as a model-scoped metric and use an aggregation query. +5. Infer cardinality per §6.4, and resolve `N : N` traversals through a bridge dataset (§6.8.1) or shared-dimension stitch (§6.8.2) — failing with `E3012` / `E3013` when neither route applies. (Semi-join filter form is deferred — see §6.8 note.) +6. Apply aggregate-before-join and chasm-trap safety (§6.7). +7. Default join types per §6.6 (LEFT for enrichment, FULL OUTER for multi-fact composition). +8. Reject deferred relationship-level keys (`referential_integrity`, `condition`, `asof`, `range`) with `E_DEFERRED_KEY_REJECTED` **when running in default Foundation mode**. The diagnostic MUST identify the offending key and reference §10. An engine MAY accept any subset of deferred keys behind a clearly-named extension flag (per the MAY-list below), provided that: + - the engine documents the flag and which keys it enables, + - default Foundation behaviour (flag off) is unchanged and still rejects with `E_DEFERRED_KEY_REJECTED`, and + - the flag is not on by default. + + This reconciles the `E_DEFERRED_KEY_REJECTED` MUST with the §11 MAY-list extension hook: a model is portable across Foundation engines exactly when it does not depend on any extension flag. +9. Resolve identifiers per §4.6: bare references in queries to the global namespace, `dataset.field` for dataset-scoped names, `E_NAME_COLLISION` on duplicate global names, `E_NAME_NOT_FOUND` when a reference does not resolve. +10. Support the required aggregations and required scalar functions defined in [`SQL_EXPRESSION_SUBSET.md`](SQL_EXPRESSION_SUBSET.md). +11. Support standard SQL window functions (ranking, navigation, aggregate-windows; `ROWS` and `RANGE` frame modes; integer-literal frame bounds) per §6.10 and `SQL_EXPRESSION_SUBSET.md` §"Window Functions". Reject windows in `Where` with `E_WINDOW_IN_WHERE`. For any `ORDER BY ` (outer or inside `OVER (...)`) that does not specify `NULLS FIRST` / `NULLS LAST`, resolve to the Foundation default — `NULLS LAST` for `ASC`, `NULLS FIRST` for `DESC` — and guarantee that resolved row order on every supported dialect, emitting the explicit clause whenever the dialect's native default would otherwise produce a different order (§5.1 Common clause semantics, D-029). Reject a metric expression that references a windowed metric with `E_WINDOWED_METRIC_COMPOSITION`. +12. *(Reserved — see deferred EXISTS_IN proposal.)* Semi-join filtering (`EXISTS_IN` / `NOT EXISTS_IN`) is deferred to a future proposal and is not part of the Foundation today. +13. Produce deterministic SQL — same `(model, query, dialect)` ⇒ byte-identical output. +14. Follow standard SQL for empty-input aggregates (§6.11 / D-033): `COUNT` family returns `0`; `SUM`, `AVG`, `MIN`, `MAX`, etc. return `NULL`. Models that need a non-`NULL` empty result MUST declare it per-metric (`COALESCE(SUM(...), 0)`). + +An implementation SHOULD: + +- Emit warnings (not errors) when a metric carries zero-impact annotations (e.g., a deferred-feature key that the engine accepts under an extension flag but that resolves to a no-op for this query). +- Emit a diagnostic warning when `LAST_VALUE` appears in a window without an explicit `frame_clause` (the default-frame footgun, §6.10.2). +- Expose the compiled SQL for inspection. +- Support ANSI_SQL as a baseline dialect and at least one vendor dialect. + +An implementation MAY: + +- Accept deferred features (§10) under a clearly-named extension flag, provided the Foundation behaviour is unchanged when the flag is off. +- Offer additional dialect-specific scalar / aggregation functions beyond the required set in [`SQL_EXPRESSION_SUBSET.md`](SQL_EXPRESSION_SUBSET.md). +- Emit a diagnostic warning when an order-dependent window (`ROW_NUMBER`, `RANK`, `DENSE_RANK`, `LAG`, `LEAD`, `FIRST_VALUE`, `LAST_VALUE`, `NTH_VALUE`, `NTILE`) has an `ORDER BY` that cannot tie-break to a stable ordering (§6.10.2). +- Add observability instrumentation (plan inspection, GPA metrics, explain). + +### 11.1 Compliance Test Suite + +A canonical, executable compliance suite is published alongside this spec. It is the normative way to verify Foundation compliance. The suite location and exact format are implementation-published; engines verify conformance by running the suite and passing all cases marked `must_pass`. Each case is a triple: + +``` +(model_yaml, semantic_query, expected_outcome) +``` + +where `expected_outcome` is either: + +| Outcome | Verification | +|:---|:---| +| **`Success(rows=...)`** | The plan compiles to SQL that, when executed against a reference engine over a seeded test database, returns the exact row multiset listed. | +| **`Failure(code=ErrorCode.E…)`** | The planner raises the specified error code (test asserts on `error.code`, never on message text). | + +The suite is organized into **rounds**, where each round builds on patterns from the previous. Within each round cases are tagged `easy` / `medium` / `hard`, where: + +- *easy*: single concept, one or two operators in the plan. +- *medium*: two or three concepts composed (e.g. enrichment + chasm trap; bridge + filter). +- *hard*: three or more concepts mixed, or pathological edge cases (composite keys under M:N, RI conflicts, scalar grain over multiple facts). + +A compliant implementation must pass **all** cases marked `must_pass`. Cases marked `xfail_pending_implementation` correspond to features whose spec semantics are settled but whose implementation is still queued; they become `must_pass` as the implementation lands. The suite never asserts on internal plan shape (CTE count, join order, alias names) — only on observable behaviour (rows or error code), so the spec stays implementation-independent. + +The Foundation does **not** mandate cross-engine SQL determinism — two compliant engines compiling the same `(model, query, dialect)` MAY produce different SQL strings as long as the observable rows or error codes agree. Per-engine determinism is required by D-014. + +--- + +## 12. Alignment with Existing Semantic-Layer Implementations + +This section catalogs how the OSI Foundation aligns or diverges from publicly documented behaviors of existing semantic-layer products. Per-vendor entries are evidence-cited where the relevant behavior is in public docs, and explicitly marked **`Unknown`** where current public documentation does not establish the answer. The Foundation's design draws on patterns common across these implementations; where the choices differ, the Foundation picks the option that satisfies the five user-visible semantics in §6.1. + +The vendors covered below are not exhaustive. They are picked because their docs are publicly accessible at sufficient depth to support a comparison. + +### 12.A Snowflake Semantic Views + +> §12.A.2 below covers Snowflake **bugs** the Foundation resolves. +> Intentional Foundation design divergences from Snowflake — the +> "defensible design choice, not bug" category — are summarised at +> the end of this section under "Two known intentional divergences". + +Snowflake's Semantic Views document a large surface of observable behaviors, including 25 cataloged in `snowflake-prod-docs/tests/semantic-views/ERRATA.md` (an errata document maintained by the Snowflake docs team). Many of these match the Foundation; a few diverge in ways the Foundation would resolve toward a typed error or a deterministic shape. + +**Sources:** +- [`semantics.rst`](../snowflake-prod-docs/sphinx/source/user-guide/views-semantic/semantics.rst) +- [`sql-differences.rst`](../snowflake-prod-docs/sphinx/source/user-guide/views-semantic/sql-differences.rst) +- [`querying.rst`](../snowflake-prod-docs/sphinx/source/user-guide/views-semantic/querying.rst) +- `snowflake-prod-docs/tests/semantic-views/ERRATA.md` — errata numbers below correspond to this document. + +#### 12.A.1 Convergence Already Achieved + +These behaviors already match the Foundation: + +| Errata | Topic | Why it aligns | Source | +|:---:|:---|:---|:---| +| #7 | LEFT OUTER JOIN exposes orphans with NULL dimensions | Matches §6.6 (default join type) and §6.7 (safety-first defaults). Opt-in INNER via declared RI is deferred (§10). | `semantics.rst` §"Join type" | +| #11 | Ad-hoc `SUM(fact)` matches pre-defined `SUM(fact)` metric | Matches the equivalence guarantee: same aggregation + same query grain ⇒ same result. | `sql-differences.rst` §"Ad-hoc aggregate" | +| #12 | Decomposability holds for additive metrics | Matches the distributive-aggregate rule in `SQL_EXPRESSION_SUBSET.md`. | `semantics.rst` §"Aggregation" | +| #13 | `COUNT(DISTINCT low_grain) GROUP BY high_grain` rejected | Matches §6.7: fan-out trap. Foundation rewrites via aggregate-before-join, Snowflake rejects; both prevent the double-count bug. | `semantics.rst` §"Granularity rule" | +| #14 | `EXPLAIN` works on semantic-view queries | Consistent with §11's SHOULD that the planner expose compiled SQL. | `semantics.rst` | +| #15 | Metrics may be aliased in the query surface | Consistent with §5.1. | (general SQL convention) | +| #18 | Inner WHERE before window / outer WHERE after | Standard SQL; matches Foundation §6.10 — windows run after `Where`, so window output reflects the filtered row set. | `sql-differences.rst` §"WHERE clause placement" | + +**Cross-grain semantic equivalence.** The Foundation and Snowflake produce the same numerical results for any cross-grain reference that both engines accept, with one structural difference: Snowflake expresses cross-grain aggregation as a *column on the lower-granularity table*; the Foundation expresses every aggregate as a *model-scoped metric* (top-level `metrics:`, referenced by bare name). + +- Snowflake's "Higher granularity" rule for cross-table column-level expressions ([`semantics.rst:391-393`](../snowflake-prod-docs/sphinx/source/user-guide/views-semantic/semantics.rst)) — a column on a lower-granularity table that references a higher-granularity table must aggregate (e.g. `customers.order_count AS COUNT(orders.oid)` aggregates orders down to customer grain) — has no Foundation equivalent at the field level. The Foundation defers all aggregate-bodied fields, including the same-grain case (§4.3, `E_AGGREGATE_IN_FIELD`); aggregates live in the top-level `metrics:` section. The same `COUNT(orders.oid)` expression is expressible today as a top-level metric named `order_count`, referenced by bare name. + +**Two known intentional divergences:** + +- **Single-step vs nested cross-grain (1:N).** Snowflake Semantic Views require **explicit nested aggregation for every cross-grain aggregate**. The Foundation, following the cross-vendor majority (Looker, Tableau, dbt-semantic-layer), accepts single-step cross-grain over `1 : N` edges with standard-SQL semantics. The Foundation does not currently accept the nested form at all (it raises `E_NESTED_AGGREGATION_DEFERRED`, §4.5). For **distributive** aggregates the two forms produce identical numbers, so the porting story is mechanical (rewrite Snowflake's nested form as the Foundation's single-step). For **non-distributive** aggregates (`AVG`, `MEDIAN`, etc.), Snowflake's nested form gives the per-home-row-first answer; the Foundation's single-step gives the heavy-side-weighted answer. The unweighted form waits for §10's grain-aware functions. + +- **Cross-grain non-distributive over `N : N`.** Snowflake supports this only in the nested form `AVG(AVG(...))`, which gives the per-home-row-first answer. The Foundation accepts the bare single-step form (`AVG(movies.gross)`) and resolves it via the bridge-de-duplication construction of §6.8.1, giving the heavy-side-weighted answer (D-027) — the analogue of the 1:N rule above. The two engines agree on the result for distributive aggregates over an N:N edge and disagree for non-distributive aggregates because they pick different default interpretations: Foundation's bare form is the bridge-dedup answer, Snowflake's nested form is the per-home-row-first answer. The Foundation's per-home-row-first interpretation (the same number Snowflake produces) requires the nested form `AVG(AVG(...))`, which is **deferred** to §10's grain-aware-functions proposal and currently raises `E_NESTED_AGGREGATION_DEFERRED`. Until §10 lands, a model that needs Snowflake's per-home-row-first number on an N:N edge cannot express it in the Foundation; a model that needs the bridge-dedup number has only the Foundation form. + +Snowflake's vendor-named clauses for cross-grain aggregation collapse, in the Foundation, to a single rule: write the aggregation as a model-scoped metric (§4.5 form 1). For every query expressible in both, the numbers agree. + +#### 12.A.2 Differences OSI Would Resolve + +These are places where Snowflake Semantic Views diverges from what the Foundation requires. + +##### (a) Dimension-only cardinality divergence — Errata #2, #3 + +**Observed:** `SELECT * FROM SEMANTIC_VIEW(sv DIMENSIONS c.segment)` returns 2 rows (deduplicated). `SELECT segment FROM sv` returns 3 rows (customer granularity). `SELECT status FROM sv` returns 6 rows. `SELECT segment, status FROM sv` returns 6 rows. + +**Foundation position:** Under §5.1's two-shape model, these are different queries with different correct answers — `DIMENSIONS c.segment` is an **aggregation query** (returns 2 rows, the distinct tuple), while `SELECT segment FROM sv` (no `GROUP BY`, no aggregates) is a **scalar query** (returns 3 rows at customer grain). Both results are right *for their shape*. The real bug is that Snowflake's bare-SQL surface offers no clear signal to the user about which shape they're getting; the same syntactic shape (`SELECT col FROM view`) silently produces aggregation-shaped or scalar-shaped output depending on the view definition. + +**Resolution:** A SQL surface for OSI MUST commit to the §5.1 mapping — `SELECT` with `GROUP BY` (or with aggregates in the projection) is an aggregation query; `SELECT` with neither is a scalar query. Under that rule, `SELECT segment FROM sv` is unambiguously a scalar query, and the 3-row result is correct. The `SEMANTIC_VIEW(... DIMENSIONS ...)` form remains a convenience for explicitly declaring an aggregation query. The two surfaces are equivalent only when the user expresses both in the same shape. + +##### (b) Per-aggregate native granularity in single SELECT — Errata #1 + +**Observed:** `SELECT AGG(order_count), COUNT(segment) FROM sv` returns `(6, 3)` — the first aggregate resolves at order granularity (6 orders), the second at customer granularity (3 customers), in one row of output. + +**Foundation position:** Within an **aggregation query**, all measures in a single query resolve at the query's grain — the empty grain when no dimensions are listed (§5.1.1, §6.2). A result with two scalars computed on different row sets violates the mental model of an aggregation `SELECT` without `GROUP BY` and is the single most surprising behavior in Snowflake Semantic Views. (A scalar query (§5.1.2) is *not* what is happening here: `AGG(...)` and `COUNT(...)` are aggregates, so this is unambiguously an aggregation query, and the engine should compute both at the empty grain.) + +**Resolution:** When the query is an aggregation query with no dimensions, all measures resolve at the empty grain — a single fully-aggregated row over the joined row set, with the aggregate-before-join and chasm-safe rewrites of §6.7. Authors who want measures pinned to a source-table's native granularity should declare that explicitly — which is what the deferred `grain: FIXED` extension (§10) is for. Today this is implicit and silent; it should be explicit and opt-in. + +##### (c) `COUNT(*)` not supported — Errata #4 + +**Observed:** `SELECT COUNT(*) FROM sv` raises `ERROR 010274: Invalid metric expression 'COUNT(*)'`. + +**Foundation position:** `COUNT(*)` is required by the Foundation's expression subset (see [`SQL_EXPRESSION_SUBSET.md`](SQL_EXPRESSION_SUBSET.md)) because it is the most universal SQL aggregate. + +**Resolution:** Either support `COUNT(*)` directly, or transparently rewrite `COUNT(*)` to `COUNT(.)` at query time. Rejecting it with an error is user-hostile. + +##### (d) `SELECT *` fails — Errata #6 + +**Observed:** `SELECT * FROM sv` raises `ERROR 010236: Requested semantic expression in FACTS clause must be one of: (DIMENSION, FACT)`. + +**Foundation position:** The Foundation takes no explicit position on `SELECT *` — it is a SQL-surface convenience, not a semantic concept. But if the bare-SQL surface exists at all (§12.A.2.a), its failure modes should be comprehensible. + +**Resolution:** Either support `SELECT *` by expanding it to all public dimensions at the common grain, or produce an error message that explains the restriction ("Semantic views require explicit dimensions and/or `AGG()`-wrapped metrics; `*` cannot expand to both") rather than the current metadata-internals message. + +##### (e) Outer WHERE uses result aliases not semantic names — Errata #10 + +**Observed:** An outer `WHERE c.segment = 'Ent'` on a semantic-view subquery raises `invalid identifier 'C.SEGMENT'` because the outer scope only sees the flattened result alias `SEGMENT`. + +**Foundation position:** The OSI Semantic Query is flat — there is no inner/outer split. `Where` and `Having` run in the same namespace as `Dimensions` and `Measures`, which is the dataset-qualified semantic namespace. + +**Resolution:** When a semantic view is consumed through a subquery surface, the inner-scope semantic names should still be addressable, or a clear error explaining the namespace change should be raised. This is largely a SQL-dialect concern, but it interacts with the same-named expressions issue (§12.A.2.f). + +##### (f) Same-named expressions unreachable in bare SQL — Errata #16, #17 + +**Observed:** A semantic view with `customers.name` and `orders.name` accepts both through `SELECT * FROM SEMANTIC_VIEW(sv DIMENSIONS c.name, o.name)` (produces duplicate `NAME` column headers). But `SELECT name FROM sv`, `SELECT c.name FROM sv`, and `SELECT sv.name FROM sv` all fail with `invalid identifier`. Same problem for same-named metrics. + +**Foundation position:** SQL itself permits duplicate column names in a result set; the Foundation does the same. The bare-SQL surface should accept `dataset.field` qualification (§4.6.4) and let the caller alias when the result is consumed by tooling that cannot tolerate duplicates. + +**Resolution:** Allow `customers.name` and `orders.name` both to appear in a result; recommend that callers add explicit aliases (`AS customer_name`, `AS order_name`) when the consuming surface cannot disambiguate. The bare-SQL error today is more restrictive than it needs to be. + +##### (g) Granularity error message — Errata #13 (presentation) + +**Observed:** `SELECT status, COUNT(customer_name) FROM sv GROUP BY status` raises `ERROR 010234: dimension entity 'O' must be ... lower granularity ... than 'C'`. The message leaks internal entity codes. + +**Foundation position:** Not a semantic correctness issue — the rejection is defensible under §6.7 (aggregate-before-join). But the error message is opaque. + +**Resolution:** Reword the error to reference the author-visible dataset and column names, not internal entity IDs: "COUNT(customer_name) would fan out under GROUP BY orders.status because orders is at a finer grain than customers. Either group by a customers-level dimension, or use COUNT(DISTINCT customer_name)." + +##### (h) FACTS and METRICS in the same query — Errata #8 + +**Observed:** `SELECT * FROM SEMANTIC_VIEW(sv FACTS o.amount METRICS o.order_count)` raises `ERROR 010268: Facts and metrics cannot be requested in the same query`. + +**Foundation position:** The Foundation has no FACTS clause — there is only `Dimensions` and `Measures` (aggregation queries) and `Fields` (scalar queries). Row-level facts a user wants exposed as raw rows belong in `Fields` of a scalar query; aggregations over those facts belong in `Measures` of an aggregation query. The two shapes cannot mix in one query — the §5.1 contract makes this an `E_MIXED_QUERY_SHAPE` error rather than the Snowflake FACTS-vs-METRICS error. + +**Resolution:** When OSI's Foundation surface is adopted, the split disappears. Snowflake's FACTS clause remains as a dialect-specific convenience, but it is not part of OSI conformance. + +##### (i) LIMIT inside the clause — Errata #9 + +**Observed:** `LIMIT 2` inside a `SEMANTIC_VIEW(...)` clause raises a syntax error. LIMIT must be on the outer query. + +**Foundation position:** `Limit` is a first-class clause of the Semantic Query (§5.1). Whether the SQL surface accepts it inside or outside a `SEMANTIC_VIEW()` wrapper is a dialect decision. + +**Resolution:** Support inner `LIMIT` in `SEMANTIC_VIEW()` clauses for ergonomic parity, or document the restriction prominently. The current error ("syntax error ... unexpected '2'") does not help the user. + +### 12.B Databricks Metric Views + +Databricks Metric Views are a YAML-defined semantic layer over Unity Catalog tables. Their data model is similar to OSI in spirit (dimensions, measures, joins) but the surface and several behaviors differ. + +**Sources:** +- [Model metric views](https://docs.databricks.com/aws/en/metric-views/data-modeling/joins) (Apr 2026) +- [Use level of detail (LOD) expressions in metric views](https://docs.databricks.com/aws/en/metric-views/data-modeling/level-of-detail) (Apr 2026) +- [Query metric views (GCP)](https://docs.databricks.com/gcp/en/business-semantics/metric-views/query) +- Community thread on metric-view-on-joined-table behavior: [databricks-community-141694](https://community.databricks.com/t5/warehousing-analytics/metric-view-measure-on-joined-table/td-p/141694) + +#### 12.B.1 Behaviors that align with the Foundation + +| Behavior | Foundation reference | Source | +|:---|:---|:---| +| Default join is `LEFT OUTER JOIN` from the source (fact) table to dimension tables | §6.6 (LEFT for enrichment) | Joins doc, §"Model star schemas": "the source is the fact table and joins with one or more dimension tables using a LEFT OUTER JOIN." | +| All measures in a single query resolve at the query's grain (no per-aggregate "native granularity" leak) | §6.2 | Inferred from the docs: measures are aggregations over the source query, with no per-measure grain override. The deferred LOD feature is the explicit opt-out. | +| Filters declared in the metric-view YAML apply to all queries that reference it | (Foundation has no direct equivalent yet — see §10 deferred named filters) | Joins doc, §"Apply filters" | +| Multi-hop joins (snowflake schemas, e.g., `orders → customers → nation → region`) supported | §6.9 (path resolution) | Joins doc, §"Model snowflake schemas" | +| Composability via metric-view-as-source for derived metrics | §4.5 form (2) and (3) | Joins doc, §"Use a metric view as a source" | + +#### 12.B.2 Differences + +| # | Behavior | Foundation position | +|:---:|:---|:---| +| B-1 | **M:N join silently picks first match.** The docs say: "The join should follow a many-to-one relationship. In cases of many-to-many, the first matching row from the joined dimension table is selected." (Joins doc, §"Model star schemas") | The Foundation rejects silent first-match selection on `N : N`. Foundation §6.8 requires either a bridge dataset or a shared-dimension stitch; if neither applies, the engine MUST raise `E3012_MN_NO_SAFE_REWRITE`. Picking "the first matching row" is exactly the silent-wrong-answer pattern Semantic 5 (§6.1) prohibits. | +| B-2 | **Metric views cannot be directly joined to other tables at query time.** Users must wrap the metric view in a CTE and then join the CTE result. (Community thread 141694; query doc) | Foundation has no equivalent restriction — semantic queries always run inside the layer, and joins to "other tables" are not part of the semantic surface. This is a Databricks-surface concern, not a Foundation conformance issue. | +| B-3 | **Cross-grain references inside a measure expression.** Databricks does not document an implicit "aggregate down to home grain" rule for fields. Cross-table aggregation is expressed by writing a `JOIN` in a SQL-source query or by defining a measure that aggregates a joined column. | Foundation matches: aggregates never live in field expressions (§4.3, `E_AGGREGATE_IN_FIELD`); all aggregates are model-scoped metrics (§4.5 form 1), resolved at the query's grain via the relationship graph (§6.9). | +| B-4 | **`COUNT(*)` support** | `Unknown` from public docs as of fetch date. Examples in the joins doc use `COUNT(1)` and `COUNT(o_orderkey)`. The aggregate function reference (linked from the docs) does include `COUNT(*)`, so it likely works in source-query SQL; whether it is accepted as a measure expression directly is not explicitly stated. | +| B-5 | **Dimension-only / scalar query shape (no metrics)** | `Unknown`. The query doc focuses on dimension + measure interactions; whether a query for "just dimension columns" yields one row per source row (scalar) or distinct combinations (aggregation) is not directly documented in the public material I located. Foundation §5.1 distinguishes the two shapes explicitly. | +| B-6 | **Same-named expressions across joined tables** | `Unknown`. The docs use unique names in their examples; whether the surface preserves `dataset.field` qualification through the result header is not directly addressed. Foundation §4.6 requires reachability via `dataset.field`. | + +The deferred Foundation features for `FIXED` / `INCLUDE` / `EXCLUDE` grain modes and resettable filters (§10) correspond closely to Databricks Metric Views' "Fixed level of detail" and "Coarser level of detail" expressions (LOD doc). When OSI adopts those, alignment with Databricks should be re-evaluated against the LOD surface. + +### 12.C Google Looker (LookML) + +Looker is the longest-lived public-cloud semantic layer in this comparison and has a distinctive correctness mechanism: **symmetric aggregates** (a hash-based `SUM(DISTINCT)` rewrite) that automatically de-duplicates joined rows under fan-out conditions. The Foundation reaches the same correctness via a different mechanism (pre-aggregated CTEs, §6.7); both produce correct results, and the Foundation lists symmetric aggregates as a future codegen optimization (§10). + +**Sources:** +- [Understanding symmetric aggregates](https://cloud.google.com/looker/docs/best-practices/understanding-symmetric-aggregates) +- [Getting the relationship parameter right](https://cloud.google.com/looker/docs/best-practices/how-to-use-the-relationship-parameter-correctly) +- [`symmetric_aggregates` parameter](https://cloud.google.com/looker/docs/reference/param-explore-symmetric-aggregates) +- [Working with joins in LookML](https://docs.cloud.google.com/looker/docs/working-with-joins) + +#### 12.C.1 Behaviors that align with the Foundation + +| Behavior | Foundation reference | Source | +|:---|:---|:---| +| Default outer join shape preserves base-table rows (Looker default is `LEFT JOIN` from the explore's base view) | §6.6 (LEFT for enrichment) | "Working with joins in LookML" | +| Cardinality is declared per-join via `relationship: many_to_one` / `one_to_many` / `one_to_one` / `many_to_many`, with primary keys driving correctness | §6.4 (cardinality inference from PK / UK) | "Getting the relationship parameter right" | +| Primary keys are required for measures to participate in joins (otherwise measures don't appear) | §4.2: PK / UK declarations matter for safety | "Getting the relationship parameter right", §"How does Looker help me?" | +| `many_to_many` is allowed and produces correct numbers (via symmetric aggregates), not silently-inflated ones | §6.8 semantic guarantee: results equivalent to one of the safe rewrites | "Understanding symmetric aggregates" | +| Outer joins with right-side fanout require treating the relationship as `many_to_many` to enable symmetric aggregates | §6.7 fan-out safety | "Getting the relationship parameter right", §"Outer joins" | + +The user-visible semantic that maps most cleanly: Looker's symmetric aggregates and the Foundation's pre-aggregated CTEs are two different physical realizations of **Semantic 2** (no row of A contributes more than once to a measure on A). They are exchangeable as long as both produce the same row counts and totals. + +#### 12.C.2 Differences + +| # | Behavior | Foundation position | +|:---:|:---|:---| +| L-1 | **`many_to_many` resolved automatically via symmetric aggregates** with no error and no required bridge dataset, provided primary keys are correct on both sides. | Foundation §6.8 requires `N : N` resolution via bridge or stitch and fails with `E3012` when neither is available. **Looker is more permissive** — it always produces correct numbers given correct keys, by paying the symmetric-aggregates SQL cost. The Foundation could subsume Looker's behavior by adopting symmetric aggregates as an additional permitted safe rewrite under §6.8, which is queued as a codegen optimization (§10). Until then, an OSI-Looker bridge would translate Looker's `relationship: many_to_many` into an explicit bridge dataset (when used to traverse) or report `E3012` and require the modeler to choose. (A semi-join filter form is deferred to a future proposal — see §6.8.) | +| L-2 | **Measure types include non-aggregate "post-SQL" types** (`number`, `yesno`) computed after SQL generation. | Foundation has no post-SQL computation tier. Such measures translate to scalar fields or to ratio-of-metrics arithmetic per §4.5 form (2). | +| L-3 | **`symmetric_aggregates: no` opt-out** disables the correctness mechanism; the modeler is then responsible for avoiding fanout. | Foundation has no equivalent escape hatch — the engine MUST either produce a result equivalent to one of the §6.8 safe rewrites or raise a typed error. A model translated from a Looker explore with `symmetric_aggregates: no` should be rejected on import unless every join used in queries has cardinality `N : 1` or `1 : 1`. | +| L-4 | **Default `outer_only` join type / explicit `join_type`** | Looker exposes `join_type: inner` / `left_outer` / `full_outer` / `cross`. Foundation does not expose per-join `type:` overrides at the Foundation level (§6.9 deferred). A Looker explore with `join_type: inner` translates to a Foundation model that documents the RI assumption in `description:` and, when the deferred RI proposal lands (§10), declares `from_all_rows_match: true` to enable the same `INNER` promotion. | +| L-5 | **Same-named fields across views** | `Unknown` how Looker handles name collisions in the result header. The docs I located describe `view.field` qualification in expressions but do not directly address the result-set column header collision case (the equivalent of Snowflake errata #16/#17). Foundation §4.6 requires reachability via `dataset.field`. | +| L-6 | **Path / explore disambiguation across multiple joins to the same view** | `Unknown` from the sources I located in this pass. Looker's `from:` clause and join naming likely cover the equivalent of OSI's path-disambiguation problem; this should be revisited when OSI's deferred path-disambiguation proposal (§10) is drafted. | + +### 12.D Window-Function Behaviors + +Snowflake errata #5, #18 (caveat row), #19, #20, #21, #22, #23, #24, #25 all concern window functions, `QUALIFY`, `LAG`/`LEAD`, compound window expressions, and HAVING-vs-window execution order. Windows are part of the Foundation (§6.10), so each of these items has an explicit position: + +| Errata | Snowflake behavior | Foundation position | +|:---|:---|:---| +| **#5** (window placement in HAVING vs ORDER BY) | Some windows allowed in `HAVING` and `ORDER BY` only when wrapped a particular way. | Foundation §6.10.1 follows standard SQL — windows MAY appear in `Measures`, `Fields`, `Order By`, and `Having`. No special wrapping required. | +| **#19** (`LAG`/`LEAD` skip filtered rows) | `LAG` operates on the post-`WHERE` row order, so a filter that removes February rows makes January's `LAG` see December, not "the previous month." | Foundation §6.10 follows standard SQL — `LAG`/`LEAD` see the post-`Where` row order. Users wanting time-series gap-filling must densify their dimension (a future proposal — see §10 deferred). The Foundation does not silently rewrite `LAG`/`LEAD` semantics. | +| **#25** (compound window bypasses WHERE) | `MAX(SUM(fact) OVER ())` computes the inner window over the unfiltered dataset, producing wrong "% of total" under any filter. | Foundation §6.10.3 requires the opposite: every window computation MUST see the query's pre-aggregation `Where`. This is a genuine Snowflake bug; the Foundation specifies the correct behaviour. | +| **#20–#24** (various `QUALIFY`, frame-mode, and ordered-aggregate edge cases) | Snowflake-specific surface quirks. | The Foundation has no `QUALIFY` clause — top-N-within-group queries express the same shape with a `Where` on a windowed field (§6.10.4). `GROUPS` frame mode and ordered-set aggregates (`WITHIN GROUP`) remain deferred (§10). | + +The Snowflake errata that report wrong numbers (`#25`) are exactly the gotchas the Foundation's §6.10 semantic contract is designed to prevent. The errata that report dialect-specific *surface* differences (`#5`, `#18`, `#20–#24`) are not correctness issues; the Foundation picks the standard-SQL surface. + +--- + +## Appendix A: Join Behavior — Worked Examples + +This appendix is a decision tree for query authors. Given a shape of query (what datasets, what measures, what filters), it tells you the shape of the result you should expect — and which of the §6.1 user-visible semantics is producing it. + +Each example is tagged with the model it uses. Most examples use the **Prelude model** below. Examples that need a different shape (M:N through a bridge, ambiguous paths, disconnected datasets) declare a small **mini-model** inline. + +### Prelude model + +```yaml +# Used by Examples 1–4, 8, 11, 12. +datasets: + - name: customers + primary_key: [id] + fields: + - { name: id } + - { name: region } + - { name: segment } + + - name: orders + primary_key: [id] + fields: + - { name: id } + - { name: customer_id } + - { name: amount } + + - name: returns + primary_key: [id] + fields: + - { name: id } + - { name: customer_id } + - { name: amount } + + - name: premium_customers # subset of customers, used for filters + primary_key: [id] + fields: + - { name: id } + +relationships: + - { name: orders_to_customer, from: orders, to: customers, from_columns: [customer_id], to_columns: [id] } # N:1 + - { name: returns_to_customer, from: returns, to: customers, from_columns: [customer_id], to_columns: [id] } # N:1 +``` + +### Mini-model M1 — Disconnected datasets (Example 5, Example 10) + +```yaml +datasets: + - name: orders # same shape as Prelude + primary_key: [id] + fields: [{ name: id }, { name: customer_id }, { name: amount }] + + - name: inventory_movements # tracks stock movements; no FK into customers/orders + primary_key: [movement_id] + fields: + - { name: movement_id } + - { name: warehouse_id } + - { name: quantity } + +# No relationship between orders and inventory_movements. +``` + +### Mini-model M2 — N:N through a bridge (Example 6 negative case, Example 7) + +```yaml +datasets: + - name: actors + primary_key: [actor_id] + fields: [{ name: actor_id }, { name: height }] + + - name: movies + primary_key: [movie_id] + fields: [{ name: movie_id }, { name: gross }] + + - name: appearances # bridge with N:1 edges to both endpoints + primary_key: [actor_id, movie_id] + fields: [{ name: actor_id }, { name: movie_id }] + +relationships: + - { name: app_to_actor, from: appearances, to: actors, from_columns: [actor_id], to_columns: [actor_id] } # N:1 + - { name: app_to_movie, from: appearances, to: movies, from_columns: [movie_id], to_columns: [movie_id] } # N:1 + +# A second variant of M2 ("M2-no-bridge") drops the `appearances` dataset and +# declares an N:N relationship directly between `actors` and `movies` with no +# stitching dimension at the requested grain. That is what Example 6 references. +``` + +### Mini-model M3 — Two paths between the same datasets (Example 9) + +```yaml +datasets: + - name: orders + primary_key: [id] + fields: + - { name: id } + - { name: placed_by_id } + - { name: fulfilled_by_id } + + - name: users + primary_key: [id] + fields: [{ name: id }, { name: region }] + +relationships: + - { name: order_placed_by, from: orders, to: users, from_columns: [placed_by_id], to_columns: [id] } + - { name: order_fulfilled_by, from: orders, to: users, from_columns: [fulfilled_by_id], to_columns: [id] } +``` + +### Quick-reference table + +| # | Model | Query you write | Result shape | Semantic | Notes | +|:---:|:---|:---|:---|:---:|:---| +| 1 | Prelude | Aggregation: measure from `orders`, group by `customers.segment` | All `orders` represented; orphan orders bucketed under `segment = NULL` | 1 | `LEFT` enrichment from `orders → customers` | +| 2 | Prelude | Aggregation: `COUNT(customers)` and `SUM(orders.amount)` grouped by `customers.region` | `COUNT(customers)` is per region (not inflated by order count); `SUM(amount)` is total per region | 2 | Auto pre-aggregation on the many-side | +| 3 | Prelude | Aggregation: `SUM(orders.amount)` and `SUM(returns.amount)` grouped by `customers.region` | Every region appearing in *either* `orders` or `returns` is in the result; missing side is `NULL` (standard SQL, §6.11) | 3 | `FULL OUTER` between two pre-aggregated facts | +| 4 | Prelude | Aggregation: `SUM(orders.amount)` and `SUM(returns.amount)` with **no** dimensions | One scalar row per side, joined via `CROSS JOIN` | 3 | Scalar grand total — no shared grain | +| 5 | Mini-model M1 | Aggregation: measures from two unrelated facts (no shared dim) | **Error `E3013_NO_STITCHING_DIMENSION`** | 5 | Refuses to emit a Cartesian product | +| 6 | Mini-model M2-no-bridge | Aggregation across an `N : N` edge with no bridge and no shared dim | **Error `E3012_MN_NO_SAFE_REWRITE`** | 5 | Engine has no safe rewrite | +| 7 | Mini-model M2 | Aggregation through an `N : N` edge resolved by a declared bridge dataset | Result is correct — engine walks two `N : 1` enrichments through the bridge | 2, 5 | §6.8.1 | +| 8 | — | Semi-join filtering — *deferred*. The shape is intentionally not part of the Foundation today; a follow-up proposal will define it in full. | — | — | See Example 8 in the body. | +| 9 | Mini-model M3 | Aggregation referencing two equally-valid relationship paths between datasets | **Error `E_AMBIGUOUS_PATH`** | 5 | Path-disambiguation hints deferred (§10) | +| 10 | Mini-model M1 | Aggregation referencing datasets with no relationship path | **Error `E_NO_PATH`** | 5 | | +| 11 | Prelude | Aggregation with no dimensions: `SUM(orders.amount)` only | A single row, fully aggregated | 2 | All measures resolve at the empty grain | +| 12 | Prelude | Scalar query: `Fields: [orders.id, orders.customer_id, customers.region]` | One row per `orders` row; orphan orders show `region = NULL` | 1 | Scalar shape per §5.1 | + +### Example 1 — Semantic 1: orphan facts surface as NULL dims + +**Uses:** Prelude. + +**Query (sketch):** + +```yaml +Measures: + - SUM(orders.amount) AS revenue +Dimensions: + - customers.segment +``` + +**What you get:** + +| segment | revenue | +|:---|---:| +| Enterprise | 1,200,000 | +| SMB | 380,000 | +| `NULL` | 14,500 | + +The `NULL` segment row is real revenue from `orders` whose `customer_id` did not resolve to a `customers` row. **Semantic 1**: the engine never silently drops those orders. + +If you don't want them in your result, add `Where: customers.segment IS NOT NULL`. + +### Example 2 — Semantic 2: no fan-out double-counting + +**Uses:** Prelude. + +**Query:** + +```yaml +Measures: + - COUNT(customers.id) AS num_customers + - SUM(orders.amount) AS revenue +Dimensions: + - customers.region +``` + +**What you get:** + +| region | num_customers | revenue | +|:---|---:|---:| +| EAST | 412 | 980,000 | +| WEST | 305 | 760,000 | + +Each customer is counted **once per region**, not once per order. The engine emits a pre-aggregated CTE at the `customers` grain and joins it back, exactly as in §6.7's worked SQL. + +### Example 3 — Semantic 3: no fact loses its groups + +**Uses:** Prelude. + +**Query:** + +```yaml +Measures: + - SUM(orders.amount) AS revenue + - SUM(returns.amount) AS returns_total +Dimensions: + - customers.region +``` + +**What you get:** + +| region | revenue | returns_total | +|:---|---:|---:| +| EAST | 980,000 | 12,400 | +| WEST | 760,000 | `NULL` | +| NORTH | `NULL` | 3,200 | + +`WEST` had revenue but no returns; `NORTH` had returns but no revenue. **Semantic 3**: every region that appeared in *either* fact is in the result. The `NULL` in the missing-fact cells is standard SQL behaviour for `SUM` over an empty input row set (§6.11). + +If your metric should report `0` instead of `NULL`, define it with `COALESCE(SUM(...), 0)` — that's a per-metric authoring decision, not a join-semantics concern. + +### Example 4 — Multi-fact, no dimensions (scalar grand totals) + +**Uses:** Prelude. + +**Query:** + +```yaml +Measures: + - SUM(orders.amount) AS revenue + - SUM(returns.amount) AS returns_total +# no Dimensions +``` + +**What you get:** one row. + +| revenue | returns_total | +|---:|---:| +| 1,594,500 | 15,600 | + +There's no shared grain to align on, so the engine produces one scalar per side and `CROSS JOIN`s them — degenerate but correct (Semantic 3 still holds: neither side is dropped). + +### Example 5 — Two unrelated facts (error) + +**Uses:** Mini-model M1. + +**Query:** `SUM(orders.amount)` and `SUM(inventory_movements.quantity)` with no dimensions. The two facts share no dimension and have no relationship path between them. + +**What you get:** + +``` +E3013_NO_STITCHING_DIMENSION: + Cannot combine measures from `orders` and `inventory_movements` — no + shared dimension and no relationship path exists. + Suggestions: add a shared dimension that both facts can reach via + `N : 1`, or query each fact separately. +``` + +**Semantic 5** in action: the engine refuses to emit a Cartesian product. + +### Example 6 — Aggregating across `N : N` with no safe rewrite (error) + +**Uses:** Mini-model M2-no-bridge (the variant with a direct `N : N` between `actors` and `movies`, no `appearances` bridge, and no shared dimension at the requested grain). + +**Query:** `AVG(movies.gross)` grouped by `actors.height`. + +**What you get:** + +``` +E3012_MN_NO_SAFE_REWRITE: + Cannot resolve the M:N traversal between `actors` and `movies` at the + query's grain. + Suggestions: + - introduce a bridge dataset with N:1 edges to both sides (§6.8.1), or + - group by a dimension reachable from both sides (§6.8.2 stitch). + (A semi-join filter form is deferred — see the §6.8 deferred-feature note.) +``` + +**Semantic 5** in action: no escape hatch, no plausible-but-wrong number — a typed error with corrective guidance. + +### Example 7 — `N : N` resolved through a bridge + +**Uses:** Mini-model M2 (with the `appearances` bridge). + +**Query:** `SUM(movies.gross)` grouped by `actors.height`. Because the `appearances` bridge has `N : 1` edges to both endpoints, the engine walks the bridge to materialize the distinct `(movie, height)` associations and aggregates over that set — each movie contributes to each height at most once, even if multiple actors of that height appeared in it. See §6.8.1 for the full worked plan and intermediate tables. + +This is **Semantic 2** and **Semantic 5** working together: each row of `movies` contributes once per group (Semantic 2), and the engine only does so because the bridge gives it a safe rewrite. A bare `AVG(movies.gross)` over the same bridge resolves through the same construction (D-027): `170 → AVG(100, 200) = 150`, `180 → AVG(50) = 50`. The aggregate runs once over the de-duplicated row set, so every aggregate category (distributive, algebraic, holistic) is well-defined here. The alternative per-actor-first interpretation is the nested form `AVG(AVG(movies.gross))` and is deferred to §10 (`E_NESTED_AGGREGATION_DEFERRED`). + +### Example 8 — Semi-join filtering (deferred) + +The "filtering join" shape — restricting rows to those whose key +appears in a sibling dataset — is intentionally deferred from this +proposal. It will be specified in full in a separate follow-up +proposal that pins the semi-join surface (the keyword(s), +NULL-safety, `NOT`-form, and compilation contract). The Foundation +does not define `EXISTS_IN` today. + +A model that needs cross-fact filtering in the meantime should +express it via an aggregation step: produce the filter set as a +dimension, then filter on the dimension. (For example, to restrict +to "orders by premium customers", join `customers` and filter on a +row-level field `customers.is_premium` rather than referencing +`premium_customers` directly.) + +### Example 9 — Ambiguous path (error) + +**Uses:** Mini-model M3. + +**Query:** `COUNT(orders.id)` grouped by `users.region`. `orders` has two relationships to `users` (`placed_by` and `fulfilled_by`); the engine cannot decide which path to take. + +**What you get:** + +``` +E_AMBIGUOUS_PATH: + Multiple paths from `orders` to `users` exist: + - order_placed_by (orders.placed_by_id → users.id) + - order_fulfilled_by (orders.fulfilled_by_id → users.id) + Disambiguation hints (`using_relationships`) are deferred (§10). +``` + +**Semantic 5**: the engine refuses to silently pick one. + +### Example 10 — Disconnected datasets (error) + +**Uses:** Mini-model M1. + +**Query:** any aggregation referencing both `orders` and `inventory_movements` (e.g., grouping `SUM(orders.amount)` by an `inventory_movements.warehouse_id` — there is no shared key, and a semi-join filter form is deferred — see §6.8). + +**What you get:** + +``` +E_NO_PATH: + No relationship path connects `orders` and `inventory_movements`. +``` + +### Example 11 — Aggregation with no dimensions + +**Uses:** Prelude. + +```yaml +Measures: + - SUM(orders.amount) AS revenue +# no Dimensions +``` + +A single row. All measures resolve at the empty grain over the joined row set, with the aggregate-before-join and chasm-safe rewrites of §6.7 still applied. Semantic 2 still holds. + +### Example 12 — Scalar query + +**Uses:** Prelude. + +```yaml +Fields: + - orders.id + - orders.customer_id + - customers.region +# no Dimensions, no Measures, no aggregation +``` + +One row per `orders` row, joined to `customers` along the `N : 1` path. Same Semantic 1 applies: orders with no matching customer appear with `region = NULL`. + +### What this appendix does *not* cover + +These are deferred features (§10) and produce errors today, not user-controllable behavior: + +- Per-metric `joins.type` overrides (`INNER`, `LEFT`, `FULL`) +- Declared referential integrity (`from_all_rows_match` / `to_all_rows_match`) that lets the engine promote `LEFT` to `INNER` +- Path-disambiguation hints (`using_relationships`) +- Non-equijoin, ASOF, and range relationships +- The per-metric `grain: FIXED` extension and other explicit grain overrides +- Parameterized window frame bounds, `GROUPS` frame mode, and windowed-metric composition (standard SQL windows themselves are part of the Foundation — see §6.10) + +When any of those land, the table at the top of this appendix grows; the five semantics in §6.1 do not change. + +--- + +## Appendix B: Foundation Conformance Decisions + +This appendix catalogs the foundational decisions made in this spec — including the refinements and ambiguity squashes from the latest revision pass — and pairs each one with a "test shape" so the compliance suite (§11.1) has a normative anchor for what to verify. It is intended to grow: future revisions add rows, never delete them silently. When a decision is superseded, the row is kept and marked `superseded_by: `. + +Each row is a small contract: + +- **ID** — stable handle (`D-NNN`). Cited from compliance test names (e.g. `compliance/round1/easy/D-001-cardinality-distinct-dimensions.yaml`). +- **Decision** — one-line summary of the rule. +- **Anchored in** — the §-reference in this spec where the rule is defined. +- **Test shape** — the form of compliance test that exercises the decision. Tests assert on observable behavior (rows or error code), not internal plan shape (§11.1). + +| ID | Decision | Anchored in | Test shape | +|:---|:---|:---|:---| +| **D-001** | Aggregation-query result cardinality is the distinct tuple of `Dimensions` values **reachable through the measure's join path** plus, where applicable, a `NULL`-key row for unmatched fact rows. Empty `Dimensions` ⇒ exactly one row (the empty grain). Multi-measure queries follow §6.6 row 3 (FULL OUTER stitch on shared dimensions) when the measures have **incompatible fact roots** — each measure independently produces a row set per the rule above, and the row sets are stitched so neither side loses groups. When all measures share a fact root through `N : 1` ancestors, the FULL OUTER stitch is mathematically equivalent to a single-path aggregation (both branches see the same fact rows joined to the same dimensions), and engines MAY emit either plan. | §5.1.1, §6.2, §6.6 | Single-measure: model with `customers ⋈ orders`, query `Dimensions: [customers.region]; Measures: [SUM(orders.amount)]` ⇒ one row per distinct `region` reached by an `orders` row, plus a `NULL`-region row if any `orders` row's `customer_id` does not match a `customer`. A region with no orders (e.g. NORTH if no customer in NORTH has an order) does NOT appear. Multi-measure, same root: `Measures: [SUM(orders.amount), SUM(orders.discount)]` over the same model ⇒ identical row set to the single-measure case (both measures share `orders` as their fact root). Multi-measure, incompatible roots: `Measures: [SUM(orders.amount), SUM(returns.amount)]` ⇒ every region present in *either* the orders branch or the returns branch appears (FULL OUTER stitch, §6.6 row 3). | +| **D-002** | All measures in one aggregation query resolve at the query's grain (no per-aggregate "native granularity" leak). | §6.2 | Two measures from different facts in one query without dimensions ⇒ exactly one row, both measures aggregated over their joined row sets at the empty grain. | +| ~~**D-003**~~ | **Deferred — moved to a separate proposal.** Aggregate-bodied field expressions (whether same-grain over the home dataset's own columns, like `orders.fields: [{name: total_revenue, expression: SUM(amount)}]`, or cross-grain via a `1 : N` edge, like `customers.fields: [{name: lifetime_value, expression: SUM(orders.amount)}]`) are not part of the Foundation today. A field expression containing any aggregate function MUST be rejected with `E_AGGREGATE_IN_FIELD` (§4.3). All aggregates live in **model-scoped metrics** (§4.5) — the top-level `metrics:` section, referenced by bare name. Field-level aggregation will be re-introduced by §10's grain-aware-functions proposal alongside the filter-context semantics. | §4.3 | (a) Model with field `orders.fields: [{name: total_revenue, expression: SUM(amount)}]` ⇒ `E_AGGREGATE_IN_FIELD` at validation time. Top-level metric `total_revenue = SUM(orders.amount)` ⇒ accepted. (b) Field `customers.fields: [{name: lifetime_value, expression: SUM(orders.amount)}]` ⇒ same code. Top-level metric `lifetime_value = SUM(orders.amount)` ⇒ accepted; `Dimensions: [customers.region, customers.id]; Measures: [lifetime_value]` ⇒ one row per `(region, customer)` pair. | +| **D-004** | Default join is `LEFT` for `N:1` enrichment, `FULL OUTER` for multi-fact composition on shared dims, `CROSS JOIN` for scalar grand totals. | §6.6 | (a) Aggregation joining `orders → customers` ⇒ orphan orders bucketed under `NULL` segment. (b) Two facts grouped on shared dim ⇒ regions appearing in either fact appear in the result. (c) Two facts with no dimensions ⇒ one row, both totals on the same row. | +| **D-005** | Routing of predicates is by **resolved expression shape**, not by any declared tag. The Foundation has no `role:` keyword. The two resolved shapes are **row-level scalar** (a non-aggregate expression over the home dataset's columns or `N : 1` / `1 : 1` enrichments — valid in `Where`, `Dimensions`, `Order By`, and `Fields`) and **query-grain aggregate** (an aggregate in a metric expression that resolves at the query's grain — valid in `Measures`, `Having`, and `Order By` when referenced through a measure). Aggregates only live in metric expressions; field expressions containing any aggregate raise `E_AGGREGATE_IN_FIELD` per D-003. | §4.3, §6.3 | (a) Boolean row-level scalar field used in `Where` ⇒ accepted. (b) Query-grain aggregate inside `Where` ⇒ `E_AGGREGATE_IN_WHERE`. (c) Pure row-level predicate inside `Having` ⇒ `E_NON_AGGREGATE_IN_HAVING`. (d) Mixed-level boolean ⇒ `E_MIXED_PREDICATE_LEVEL`. (e) Field expression containing an aggregate (same-grain or cross-grain) ⇒ `E_AGGREGATE_IN_FIELD` (D-003). | +| **D-006** | Bare-name references in queries resolve to the **global namespace**. Dataset-scoped *fields* use `dataset.field`. Metrics are model-scoped only and always referenced by bare name (the `dataset.metric_name` form is deferred per §4.5). | §4.6 | (a) Query `Measures: [total_revenue]` with no global metric of that name ⇒ `E_NAME_NOT_FOUND`. (b) `Measures: [orders.total_revenue]` ⇒ `E_NAME_NOT_FOUND` (the `dataset.metric` form is not part of the Foundation). (c) `Dimensions: [orders.region]` ⇒ resolves to the field `region` on dataset `orders`. | +| **D-007** | `N : N` traversals MUST produce a result equivalent to a bridge or shared-dimension stitch; otherwise `E3012_MN_NO_SAFE_REWRITE`. (Semi-join filter form is deferred — see §6.8 note.) | §6.8 | (a) Bridge model + cross-grain measure ⇒ correct rows. (b) No bridge, no shared dim ⇒ `E3012`. | +| **D-008** | No per-metric `joins.type` override at the Foundation level; the planner uses only the §6.6 defaults. | §6.6, §6.9 | Model with `joins: { type: INNER }` on a metric ⇒ `E_DEFERRED_KEY_REJECTED`. | +| **D-009** | In default Foundation mode (no extension flags enabled), deferred relationship-level keys (`referential_integrity`, `condition`, `asof`, `range`) MUST be rejected with `E_DEFERRED_KEY_REJECTED` and the diagnostic MUST identify the offending key. Engines MAY accept these keys behind a clearly-named, off-by-default extension flag per §11 — a model that uses such a key is non-portable until the corresponding deferred proposal lands. Top-level keys outside the set defined in §4.1 are simply not OSI; engines MAY reject them as malformed input. | §11 | One test per deferred relationship-level key, each asserting `error.code == E_DEFERRED_KEY_REJECTED` and `error.key == ` **with no extension flags set**. Engines that ship an extension flag for a key MUST also pass the same test with the flag off. | +| **D-010** | Aggregation-query vs scalar-query shape is determined by projection clauses; mixing rejected with `E_MIXED_QUERY_SHAPE`. | §5.1 | Query that lists both `Dimensions` and `Fields` ⇒ `E_MIXED_QUERY_SHAPE`. | +| **D-011** | A bare metric reference inside `Fields` is rejected with `E_AGGREGATE_IN_SCALAR_QUERY`. Metrics are model-scoped (referenced by bare name) and resolve at the query's grain; they cannot satisfy a scalar-query's per-home-row contract. | §5.1.2 | (a) `Fields: [revenue]` (a model-scoped metric) ⇒ `E_AGGREGATE_IN_SCALAR_QUERY`. (b) `Fields: [SUM(orders.amount)]` (an inline aggregate) ⇒ same code. | +| **D-012** | Predicate-shape errors: aggregate in `Where` ⇒ `E_AGGREGATE_IN_WHERE`; pure row-level in `Having` ⇒ `E_NON_AGGREGATE_IN_HAVING`; mixed-level boolean ⇒ `E_MIXED_PREDICATE_LEVEL`. | §6.3 | Three independent test cases, one per code. | +| **D-014** | **Per-engine determinism.** A single engine compiling the same `(model, query, dialect)` MUST produce byte-identical SQL across runs. The Foundation does **not** require cross-engine determinism — two compliant engines MAY emit different SQL strings as long as they produce the same observable rows or error codes. The cross-engine portability contract is observable behaviour (§11.1), not SQL text. | §11 | Compile the same `(model, query, dialect)` twice on the same engine ⇒ string-equal SQL output. Engines from different vendors MAY compile to different SQL; the compliance suite asserts only on rows / error codes. | +| ~~**D-015**~~ | **Deferred — moved to a separate proposal.** The compilation-strategy equivalence for field-level cross-grain aggregation (correlated subquery vs `LATERAL` vs pre-aggregated CTE) is moot for the Foundation, since field-level cross-grain aggregation itself is deferred per D-003. The equivalence will be re-introduced alongside §10's grain-aware-functions proposal. | — | — | +| **D-016** | `COUNT(*)` is required and counts rows of the home dataset (or, in ad-hoc `Measures` entries, rows at the query grain). Engines that historically reject `COUNT(*)` MUST provide a transparent rewrite. | §7, [`SQL_EXPRESSION_SUBSET.md`](SQL_EXPRESSION_SUBSET.md) | Query `Measures: [COUNT(*)]` over `orders` with `Where: status = 'completed'` ⇒ count of completed orders, no error. | +| ~~**D-017**~~ | **Deferred — moved to a separate proposal.** Semi-join filtering (`EXISTS_IN` / `NOT EXISTS_IN`) is not part of the Foundation today. A follow-up proposal will pin the surface syntax, NULL-safety guarantees, and compilation contract in full. Until then, the Foundation has no semi-join construct, and the M:N resolution menu (§6.8) is limited to bridge and stitch. | — | — | +| **D-018** | Path resolution: unique path used; multiple paths ⇒ `E_AMBIGUOUS_PATH`; no path ⇒ `E_NO_PATH`; cycles ⇒ engine selects an acyclic sub-path within a single query. | §6.9 | (a) Two relationships from `orders` to `users` + measure on `orders` grouped by `users.region` ⇒ `E_AMBIGUOUS_PATH`. (b) Two unrelated datasets referenced in one query ⇒ `E_NO_PATH`. | +| **D-019** | Reserved names (`GRAIN`, `FILTER`, `QUERY_FILTER`) cannot be used as user-defined identifiers. | §4.6.2 | Model defining a dataset named `FILTER` ⇒ parser rejects with a code-tagged error. | +| **D-020** | A **model-scoped metric** MAY aggregate any reachable dataset over a `1 : N` edge **single-step**; the interpretation is standard SQL semantics (the engine joins the higher-grain rows through the relationship path and aggregates them at the query's grain, satisfying Semantic 2). This holds for every aggregate category — distributive, algebraic, holistic, and sketch-based. The alternative "per-home-row first" interpretation requires nested aggregation, which is deferred per D-027. Cross-grain aggregates over an **`N : N`** edge are governed by D-026 / D-027: every aggregate category resolves via the §6.8.1 bridge-de-duplication construction in a single pass (the heavy-side-weighted analogue of the 1:N rule above). The "per-home-row-first" interpretation requires nested aggregation and is deferred per D-027. Aggregates inside a *field* expression are rejected entirely with `E_AGGREGATE_IN_FIELD` (§4.3); dataset-namespaced metrics (the `dataset.metric_name` form) are also deferred (§4.5). | §4.5 form (1), §6.1 Semantic 2, §6.8 | (a) Top-level metric `total_orders = SUM(orders.amount)` ⇒ accepted single-step; grouped by region, each order contributes once, summed per region. (b) Top-level metric `avg_order_amount = AVG(orders.amount)` ⇒ accepted single-step; grouped by region, this is the SQL average over all orders in the region (heavy-customer-weighted). (c) Top-level metric `avg_of_per_customer_avg = AVG(AVG(orders.amount))` ⇒ `E_NESTED_AGGREGATION_DEFERRED` (the unweighted form waits for §10). (d) Top-level metric `distinct_products = COUNT(DISTINCT orders.product_id)` ⇒ accepted single-step; gives the count of distinct products in the region's orders. (e) `AVG(movies.gross)` across an `actors ↔ appearances ↔ movies` M:N bridge grouped by `actors.height` ⇒ **accepted** per D-027 (single-pass over the bridge-deduped row set: `170 → 150`, `180 → 50`). (f) Field `orders.fields: [{name: total_revenue, expression: SUM(amount)}]` ⇒ `E_AGGREGATE_IN_FIELD`; rewrite as top-level metric `total_revenue = SUM(orders.amount)`. | +| **D-021** | An `expression` slot accepts either a bare string in the model's default dialect or the structured `{ dialects: [{ dialect, expression }, …] }` object. The structured form is normatively defined in `SQL_EXPRESSION_SUBSET.md`. | §4.3 / §4.5 | Field with a string `expression: "amount * 1.1"` ⇒ accepted. Field with a `dialects:` array containing an `OSI_SQL_2026` entry ⇒ accepted. Field with a `dialects:` array missing every dialect the engine recognizes ⇒ engine rejects with a clear error. | +| **D-022** | A non-decomposable aggregate (holistic or unsupported-algebraic) that the engine cannot evaluate at a single starting grain because the **chosen plan forces multi-stage decomposition** the aggregate cannot survive MUST raise `E_UNSAFE_REAGGREGATION`. The two Foundation shapes that force decomposition are **§6.7 chasm pre-aggregation** (incompatible 1:N reaches that must be aggregated independently before being merged) and **§6.8.2 stitch** (independent per-fact aggregation under a shared dimension followed by a FULL OUTER merge, then re-aggregation at the query grain). Errors of this kind MUST identify the aggregate and the grains involved. (Note: a single-step holistic aggregate over a plain `1 : N` edge is **not** caught — it is a single SQL aggregate over the joined rows, well-defined per D-020. The §6.8.1 bridge plan is also **not** caught — it is a single-pass aggregate over the de-duplicated `(measure-home-row, group-key)` row set, well-defined per D-027 for every aggregate category.) | §6.2 Starting Grain, §6.7, §6.8.2 | (a) `MEDIAN(orders.amount)` over a §6.7 chasm-pre-aggregation plan grouped by a dimension that requires aggregating two incompatible 1:N reaches first ⇒ `E_UNSAFE_REAGGREGATION` (`MEDIAN` cannot be re-aggregated from per-side partials). (b) `SUM(orders.amount)` over the same shape ⇒ succeeds (`SUM` is distributive and survives the chasm pre-aggregation). (c) `AVG(movies.gross)` over an `actors ↔ appearances ↔ movies` M:N bridge grouped by `actors.height` ⇒ **accepted** per D-027 (single-pass over the bridge-deduped row set). (d) `COUNT(DISTINCT memberships.customer_id)` over the same M:N bridge ⇒ **accepted** per §6.11.3 / D-027. | +| **D-023** | A scalar query MUST raise `E_FAN_OUT_IN_SCALAR_QUERY` when no single home grain can serve the projection without replicating home-dataset rows. Two known shapes trigger this code: (i) the join path required by `Fields` crosses an `N : N` edge or otherwise replicates the home dataset's rows; (ii) `Fields` supplies row-level references from two or more datasets that are not `1 : 1`-linked (e.g., `[orders.amount, returns.amount]`) — no single home grain exists. The aggregation-query shape handles both shapes non-fatally via §6.7 pre-aggregation and §6.8.2 stitch; only the scalar shape lacks an aggregation step in which to absorb the replication. | §6.2 Final Grain, §5.1.2 | (a) Scalar query with `Fields: [customers.region, order_lines.product_id]` over an `N : N` between `customers` and `order_lines` ⇒ `E_FAN_OUT_IN_SCALAR_QUERY` (shape i). (b) `Fields: [orders.amount, returns.amount, customers.region]` where `orders` and `returns` are independent facts under `customers` ⇒ `E_FAN_OUT_IN_SCALAR_QUERY` (shape ii). (c) Either of the above as an aggregation query ⇒ succeeds with pre-aggregation or stitch. | +| **D-024** | A row-level (non-aggregate) reference to a field at a grain finer than the consuming home grain MUST raise `E_UNAGGREGATED_FINER_GRAIN_REFERENCE`. The remedy is to wrap the reference in an aggregate **inside a model-scoped metric** (§4.5 form 1) or pull the value from a coarser-grain related dataset. Wrapping the reference in an aggregate **inside a field expression** raises `E_AGGREGATE_IN_FIELD` (§4.3); fields are non-aggregate by construction. | §6.2 Starting Grain | Field on `customers` referencing `orders.amount` without an aggregate wrapping ⇒ `E_UNAGGREGATED_FINER_GRAIN_REFERENCE`. Wrapping the reference in `SUM(orders.amount)` inside a *field* on `customers` ⇒ `E_AGGREGATE_IN_FIELD`. Top-level metric `lifetime_value = SUM(orders.amount)` ⇒ accepted. | +| **D-025** | A measure that the engine cannot resolve at any single starting grain, and that does not match any of the more-specific codes (`E3012`, `E3013`, `E_UNSAFE_REAGGREGATION`), MUST raise `E_AMBIGUOUS_MEASURE_GRAIN`. The diagnostic MUST list the starting grains the engine identified. **Known cases** Foundation engines SHOULD recognise and raise this code for: (i) a measure whose expression touches two facts that are connected only through a path the engine cannot pre-aggregate safely (neither §6.7 pre-aggregation nor §6.8.1 bridge nor §6.8.2 stitch applies), but the failure mode does not fit `E3012` (no M:N) or `E3013` (a path does exist); (ii) a measure whose join-path resolution finds two equally-valid finest-grain anchors at the same "level" (e.g., two unrelated 1:1-linked dimension hubs each finer than the dim set, with no relationship between them). Engines MAY use this code for additional shapes the spec does not yet enumerate, but SHOULD reach for the more-specific codes (`E3012`, `E3013`, `E_UNSAFE_REAGGREGATION`, `E_FAN_OUT_IN_SCALAR_QUERY`) first; this code is the catch-all when none of those applies. | §6.2 Starting Grain | One test per known case (i)–(ii). Cases the spec does not enumerate are still valid uses of `E_AMBIGUOUS_MEASURE_GRAIN`; portability conformance asserts only that engines raise this code rather than produce silently-wrong rows. | +| **D-026** | Bridge resolution MUST satisfy Semantic 2: each row of the measure's home dataset contributes to each output group at most once, even when multiple bridge rows associate that fact with the same group. Concretely, the bridge plan materializes the distinct `(fact, group-key)` associations and aggregates over that set; per-bridge-row fan-out is never carried into the final aggregate. | §6.1 Semantic 2, §6.8.1 | M:N model `actors ↔ appearances ↔ movies`, two actors of height `170` both appearing in movie M1 with `gross = 100`. Query `SUM(movies.gross)` grouped by `actors.height` ⇒ the `170` group's total equals 100 plus the gross of the other distinct movies the height-170 cast appears in, **not** 200 (M1 counted twice). The naive flat-join SQL `actors ⋈ appearances ⋈ movies GROUP BY actors.height` gives the doubled answer; the Foundation result MUST match the distinct-`(movie, height)` answer. Looker (symmetric aggregates) and Tableau Multi-Fact (relationships) produce the same number on the same data. | +| **D-027** | A bare cross-grain aggregate over an `N : N` edge MUST resolve via the **bridge-de-duplication construction** of D-026 / §6.8.1, regardless of aggregate category (distributive, algebraic, or holistic). The construction is a single-pass aggregate over the unique `(measure-home-row, group-key)` row set materialised by the bridge — no multi-stage decomposition is forced, so `D-022` does not fire here. The result of `AGG(home_dataset.x)` is `AGG` applied once over the de-duplicated set. This is the heavy-side-weighted single-step analogue of the D-020 rule for `1 : N` edges. The alternative "per-home-row-first" interpretation (e.g., per-actor-first averaging in the §6.8.1 fixture, which would yield `AVG = 125` rather than `AVG = 150` for height `170`) is reachable only through the *nested form* `AGG(AGG(home_dataset.x))`, which is **deferred to §10's grain-aware-functions proposal**; an attempt to write the nested form raises `E_NESTED_AGGREGATION_DEFERRED` (§4.5). | §4.5, §6.8.1 | (a) `AVG(movies.gross)` grouped by `actors.height` over the M:N from D-026 ⇒ accepted; `170 → AVG(100, 200) = 150`, `180 → AVG(50) = 50`. (b) `MEDIAN(movies.gross)` grouped by `actors.height` over the same shape ⇒ accepted; single-pass MEDIAN over the bridge-deduped row set. (c) `COUNT(DISTINCT movies.movie_id)` grouped by `actors.height` ⇒ accepted (§6.11.3 / D-027). (d) `AVG(AVG(movies.gross))` grouped by `actors.height` ⇒ `E_NESTED_AGGREGATION_DEFERRED` (the per-home-row-first rescue path waits for §10). | +| **D-028** | Standard SQL window functions (ranking, navigation, aggregate-windows; `ROWS` and `RANGE` frame modes; integer-literal frame bounds) MUST be accepted in `Measures`, `Fields`, `Order By`, and `Having`, and in any field or metric `expression`. A window function in `Where` MUST raise `E_WINDOW_IN_WHERE`. A window function nested directly inside another window function MUST raise a parse-level error (matching standard SQL). | §6.10.1 | (a) `Measures: [SUM(amount) OVER (PARTITION BY region) AS regional_total]` ⇒ accepted. (b) `Where: ROW_NUMBER() OVER (...) <= 10` ⇒ `E_WINDOW_IN_WHERE`. (c) `RANK() OVER (... ORDER BY SUM(x) OVER (...))` ⇒ parse error. | +| **D-029** | Every `ORDER BY ` — both the outer query's `Order By` and any `ORDER BY` inside `OVER (...)` — has a defined NULL placement. If the user omits `NULLS FIRST` / `NULLS LAST`, the Foundation default is **`NULLS LAST` for `ASC` and `NULLS FIRST` for `DESC`** — i.e., NULL is treated as a high-end value that lands at whichever end the maximum lands at. This preserves the symmetry property that flipping `ASC ↔ DESC` flips NULL placement (so a "top-N → bottom-N" UI flip moves the NULL rows as expected). It also matches SQL:2003's "NULLs compare-greater than non-NULLs" default and the out-of-the-box defaults of Snowflake, PostgreSQL, and Oracle. Engines MUST guarantee the resolved row order on every supported dialect; the compiled SQL emits the explicit clause whenever the dialect's native default would otherwise produce a different order. When the resolved clause matches the dialect's native default the explicit clause MAY be elided — both forms produce identical row orders on that dialect, and D-014's byte-identical guarantee is per `(model, query, dialect)` (within a dialect deterministic; across dialects the resolved row order is identical even when literal SQL text differs by an elidable `NULLS …` token). The Foundation does NOT reject models that omit the explicit clause. A user who wants every NULL pinned to a specific end regardless of direction MUST write the explicit clause; that clause is then carried unchanged on every dialect (it cannot be elided because it overrides the resolved default). | §5.1 Common clause semantics, §6.10.2 | (a) `LAG(amount) OVER (ORDER BY order_date NULLS LAST)` ⇒ accepted on every dialect; the explicit user choice is preserved. (b) `LAG(amount) OVER (ORDER BY order_date)` ⇒ accepted; the resolved order is `ASC NULLS LAST` on every dialect — emitted explicitly on Databricks/Spark (whose native default is `ASC NULLS FIRST`); MAY be elided on Snowflake / PostgreSQL / DuckDB (whose native default already produces `NULLS LAST` for `ASC`). (c) `Order By: [{field: revenue, direction: DESC}]` ⇒ accepted; the resolved order is `DESC NULLS FIRST` on every dialect — emitted explicitly on Databricks/Spark/DuckDB (whose native default produces `NULLS LAST` for `DESC`); MAY be elided on Snowflake (whose native default already produces `NULLS FIRST` for `DESC`). (d) `Order By: [{field: revenue, direction: DESC, nulls: LAST}]` ⇒ accepted; emitted SQL contains `ORDER BY revenue DESC NULLS LAST` on every dialect — the explicit user clause overrides the symmetric default and is never elided. | +| **D-030** | A window function whose home dataset is `A` MUST run over the pre-fan-out row set of `A`, satisfying Semantic 2 for windows. Engines MUST materialize the home-grain rows (pre-aggregation or equivalent) before applying the window; they MUST NOT emit SQL that evaluates the window over fan-out-duplicated rows. If a safe rewrite is unavailable, raise `E_WINDOW_OVER_FANOUT_REWRITE`. | §6.1 Semantic 2, §6.10.3 | Field `orders.running_total = SUM(amount) OVER (PARTITION BY customer_id ORDER BY order_date NULLS LAST)` referenced in a query that also joins `order_lines` (`N : 1` from `order_lines → orders`) ⇒ the window runs over distinct `orders` rows; the per-order running total is not multiplied by line count. | +| **D-031** | A metric `expression` that references another metric whose `expression` contains a window function MUST raise `E_WINDOWED_METRIC_COMPOSITION`. Direct use of a windowed metric in `Measures` is allowed; composing it from another metric is deferred (§10). | §6.10.5 | (a) Metric `running_total = SUM(amount) OVER (ORDER BY date NULLS LAST)` used directly in `Measures: [running_total]` ⇒ accepted. (b) Metric `running_total_x2 = running_total * 2` ⇒ `E_WINDOWED_METRIC_COMPOSITION`. | +| **D-032** | The Foundation supports the `ROWS` and `RANGE` frame modes with integer-literal bounds. `GROUPS` frame mode and parameterized frame bounds MUST be rejected with `E_DEFERRED_KEY_REJECTED` (or `E_DEFERRED_FRAME_MODE`). | §6.10.6 | (a) `ROWS BETWEEN 6 PRECEDING AND CURRENT ROW` ⇒ accepted. (b) `GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW` ⇒ rejected. (c) `ROWS BETWEEN :n PRECEDING AND CURRENT ROW` ⇒ rejected. | +| **D-033** | Aggregates over an **empty** input row set follow standard SQL: `COUNT(*)`, `COUNT(x)`, and `COUNT(DISTINCT x)` return `0`; `SUM`, `AVG`, `MIN`, `MAX`, `MEDIAN`, percentiles, `STDDEV`, and `VARIANCE` return `NULL`. Aggregates over an input that contains `NULL` values follow standard SQL `NULL`-handling: `COUNT(*)` counts all rows; `COUNT(x)` and `COUNT(DISTINCT x)` ignore rows where `x IS NULL`; `SUM`/`AVG`/`MIN`/`MAX`/etc. ignore `NULL` inputs and return `NULL` when every input is `NULL`. Models that prefer `0` on a missing-set cell MUST declare it explicitly via `COALESCE(SUM(...), 0)` — this is a per-metric authoring choice, not a Foundation-level rewrite. | §6.11.1, §6.11.2 | (a) `SUM(amount)` over zero orders ⇒ `NULL`. (b) `COUNT(*)` over zero orders ⇒ `0`. (c) `AVG(amount)` over zero rows ⇒ `NULL`. (d) `AVG(amount)` over rows where `amount` is `(5, NULL, NULL)` ⇒ `5` (NULLs ignored). (e) `COUNT(amount)` over the same set ⇒ `1`. (f) Stitch plan with one fact missing in a group ⇒ that fact's `SUM` cell is `NULL` (or `0` if the metric is declared as `COALESCE(SUM(...), 0)`). | + +When this catalog grows, new entries are inserted with the next available `D-NNN` ID. The compliance suite (§11.1) MUST contain at least one case per entry whose `must_pass` status is gated on the entry being marked stable. + +**Executable witnesses.** Concrete test vectors — fixtures, data, queries, and expected row sets — live in [`DATA_TESTS.md`](../../compliance/foundation-v0.1/DATA_TESTS.md). Every `D-NNN` decision in this appendix MUST eventually be witnessed by at least one `T-NNN` entry there; `DATA_TESTS.md` §5 tracks the current coverage map and §6 lists decisions still awaiting a vector. The flagship vector is **T-015** (bridge de-duplication per Semantic 2), the test that pins D-026 with the actor↔movie fixture from §6.8.1 of this document. + +--- + +## Appendix C: Error Code Index + +This appendix consolidates every error code mentioned in the Foundation. Each row gives the code, the trigger that raises it, and the §-anchor where its semantics are defined. New codes added in future revisions are appended; codes deprecated in later revisions are kept and marked `superseded_by:` so older models continue to decode their diagnostics. + +The codes split into three families: + +- **`E_*`** — semantic-correctness errors raised by the planner before any SQL is emitted (the family the spec defines normatively). +- **`E3xxx`** — numeric M:N / chasm errors carried over from older OSI proposals; kept for backwards-compatibility of error-handling code that pattern-matches on the prefix. +- **`E_DEFERRED_*`** — errors raised when a model uses a feature that is recognised but explicitly deferred (§10). + +| Code | Trigger | §-anchor | Family | +|:---|:---|:---|:---| +| `E3011_MN_AGGREGATION_REJECTED` | Engine-capability opt-out — declared by an engine that does not support M:N traversal at all. Such an engine MUST fail every M:N query with this code and MUST NOT emit SQL. Engine-wide, not per-query: M:N-*supporting* engines never raise it, and per-query M:N failures use `E3012` / `E3013`. | §6.8 | E3xxx | +| `E3012_MN_NO_SAFE_REWRITE` | An `N : N` traversal in a measure has no semantically-equivalent safe rewrite given the current model and query grain (no bridge, no shared-dimension stitch). | §6.1, §6.8 | E3xxx | +| `E3013_NO_STITCHING_DIMENSION` | Two unrelated facts (different roots, no path) are referenced together with no dimension shared by both. The result would otherwise be a Cartesian product. | §6.1, §6.8 | E3xxx | +| `E_AGGREGATE_IN_SCALAR_QUERY` | A bare metric reference (or any query-grain aggregate) appears inside `Fields` of a scalar query. | §5.1.2 / D-011 | E_* | +| `E_AGGREGATE_IN_WHERE` | A query-grain aggregate appears inside `Where`. | §6.3 / D-005, D-012 | E_* | +| `E_AMBIGUOUS_MEASURE_GRAIN` | A measure has multiple incompatible starting grains and no more-specific code applies. | §6.2 / D-025 | E_* | +| `E_NESTED_AGGREGATION_DEFERRED` | A metric expression contains a nested aggregate (an aggregate function applied to another aggregate's result, e.g. `AVG(COUNT(orders.oid))`, `AVG(AVG(orders.amount))`). Nested aggregation requires an implicit grain pin on the inner aggregate; the rules for choosing that pin are deferred to §10's grain-aware-functions proposal. For distributive aggregates the single-step form gives identical numbers; for non-distributive aggregates the bare form gives the heavy-side-weighted answer (single-step over `1 : N`, bridge-dedup over `N : N` per D-027), and the unweighted "per-home-row first" interpretation that nested aggregation expresses waits for §10. | §4.5 / D-027 | E_DEFERRED_* | +| `E_NESTED_WINDOW` | A window function appears inside another window function's expression. Standard SQL forbids this; the parse-level rejection mandated by D-028(c) surfaces under this name so engines pinning on a stable code can pattern-match without parsing message text. | §6.10.1 / D-028(c) | E_* | +| `E_AGGREGATE_IN_FIELD` | A field expression contains an aggregate function (`SUM`, `COUNT`, `AVG`, etc., whether same-grain over the home dataset's own columns or cross-grain via a `1 : N` reach). All aggregates live in model-scoped metrics (§4.5); field expressions are non-aggregate. The field-level form is deferred to §10's grain-aware-functions proposal. | §4.3 / D-003 | E_DEFERRED_* | +| `E_AMBIGUOUS_PATH` | More than one relationship path connects the referenced datasets. | §6.9 / D-018 | E_* | +| `E_DEFERRED_FRAME_MODE` | A window expression uses `GROUPS` frame mode or parameterized frame bounds (deferred to §10). | §6.10.6 / D-032 | E_DEFERRED_* | +| `E_DEFERRED_KEY_REJECTED` | A model uses a recognised but deferred key (`referential_integrity`, `condition`, `asof`, `range`, `grain:`, `filter:`, etc.) outside an extension flag. | §11 / D-009 | E_DEFERRED_* | +| `E_EMPTY_AGGREGATION_QUERY` | An aggregation query has neither `Dimensions` nor `Measures`. | §6.2 step A.1 | E_* | +| `E_EMPTY_SCALAR_QUERY` | A scalar query has empty `Fields`. | §6.2 step B.1 | E_* | +| `E_FAN_OUT_IN_SCALAR_QUERY` | A scalar query's join path replicates home-dataset rows (e.g., crosses an `N : N` edge, or supplies row-level fields from incompatible homes). | §5.1.2, §6.2 / D-023 | E_* | +| `E_FIELD_DEPENDENCY_CYCLE` | Field expressions on the same dataset reference one another transitively, forming a cycle (`a` references `b` references `a`). Fields are required to form a DAG so resolution is well-defined and termination is guaranteed. | §4.3 | E_* | +| `E_INVALID_NATURAL_GRAIN` | A model declares `natural_grain` (deferred to a future proposal — see §10) referring to an unknown dataset, or declares two `natural_grain` keys. | §10 (deferred) | E_* | +| `E_MIXED_PREDICATE_LEVEL` | A boolean predicate mixes terms at different resolved levels (row-level + query-grain aggregate in one expression). | §6.3 / D-005, D-012 | E_* | +| `E_MIXED_QUERY_SHAPE` | A query lists both `Fields` and (`Dimensions` ∪ `Measures`). | §5.1 / D-010 | E_* | +| `E_NAME_COLLISION` | Two global names share a normalised form. | §4.6 / D-006 | E_* | +| `E_NAME_NOT_FOUND` | A bare reference does not resolve to any in-scope name. | §4.6 / D-006 | E_* | +| `E_NATURAL_GRAIN_PRE_AGGREGATION_UNSAFE` | Under a `natural_grain` declaration (deferred), the query's otherwise-derived starting grain is finer than `natural_grain` and no safe pre-aggregation exists. | natural_grain proposal §3.2 | E_* | +| `E_NO_PATH` | No relationship path connects the referenced datasets. | §6.9 / D-018 | E_* | +| `E_NON_AGGREGATE_IN_HAVING` | A predicate in `Having` is purely row-level (no aggregate, not a grouping column). | §6.3 / D-005, D-012 | E_* | +| `E_PRIMARY_KEY_REQUIRED` | An engine that opts to require primary keys (§4.2) finds a dataset without one. | §4.2 | E_* | +| `E_RESERVED_NAME` | A user-declared dataset, field, metric, or relationship name is one of the OSI reserved names (`GRAIN`, `FILTER`, `QUERY_FILTER`, and other names enumerated in §4.6.2). These names are reserved so they can be repurposed as query-language keywords in future revisions without breaking existing models. | §4.6.2 / D-019 | E_* | +| `E_UNAGGREGATED_FINER_GRAIN_REFERENCE` | A row-level (non-aggregate) reference targets a field at a grain finer than the consuming home grain. | §6.2 / D-024 | E_* | +| `E_UNKNOWN_FUNCTION` | A scalar or aggregate function call inside a metric or field expression is not in the OSI_SQL_2026 portable subset (the parse whitelist enumerated in [`SQL_EXPRESSION_SUBSET.md`](SQL_EXPRESSION_SUBSET.md)). Vendor-specific functions reach engine code only through the per-dialect `dialects:` block. The rejection happens at parse time so portable models stay portable across engines. | §SQL_EXPRESSION_SUBSET / D-021 | E_* | +| `E_UNSAFE_REAGGREGATION` | The chosen plan forces a multi-stage decomposition the aggregate cannot survive — typically a holistic aggregate (`MEDIAN`, `PERCENTILE_CONT`) over a §6.7 chasm pre-aggregation or a §6.8.2 stitch. The §6.8.1 bridge plan is **not** in this family: it is a single-pass aggregate over the de-duplicated `(measure-home-row, group-key)` set, accepted for every aggregate category per D-027 (`AVG`, `MEDIAN`, `COUNT(DISTINCT)` over an M:N bridge are all accepted bare). | §6.1, §6.2, §6.7, §6.8.2 / D-022 | E_* | +| `E_WINDOW_IN_WHERE` | A window function appears inside `Where`. SQL forbids this; windows run after `Where`. | §6.10.1 / D-028 | E_* | +| `E_WINDOW_OVER_FANOUT_REWRITE` | A window function's home dataset would be fanned out by the query's join path, and no safe rewrite is available. | §6.10.3 / D-030 | E_* | +| `E_WINDOWED_METRIC_COMPOSITION` | A metric expression references another metric whose expression contains a window function. | §6.10.5 / D-031 | E_* | + +**Diagnostic conventions.** Engines MUST set `error.code` to one of the values above; they MUST NOT raise additional codes outside this index without documenting them as engine-specific extensions. Tests in the compliance suite (§11.1) assert on `error.code`, never on `error.message` text. diff --git a/proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md b/proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md new file mode 100644 index 0000000..62e5573 --- /dev/null +++ b/proposals/foundation-v0.1/SQL_EXPRESSION_SUBSET.md @@ -0,0 +1,795 @@ +# OSI Proposal: Expression Language + +**Current Status:** Draft for internal review +**Last Updated:** 4 May Feb 2026 + +**Working Group** + +| Lead(s) | Participants | +| :---- | :---- | +| Will Pugh, Snowflake Khushboo Bhatia, Snowflake | LLyod Tabb, Malloy Dianne Wood, Atscale Lior Ebel, Salesforce Quigley Malcolm, dbt Labs Kurt, Relational AI Justin Talbot, Databricks Pavel Tiunov, Cube Damian Waldron, Thoughtspot Oliver Laslett, Lightdash Martin Traverso, Starburst JB Onofré, The ASF | + +## Overview + +![][image1] + +There are two layers in OSI that need an expression language: + +* **Ontology layer.** This layer maps onto the ontology layer which sits above the logical layer. It maps more closely to modelling languages like OWL, [(Py)Rel](https://docs.relational.ai) from RelationalAI, and [Legend](https://legend.finos.org) from Goldman Sachs +* **Logical layer.** This layer maps directly to the databases and physical layer. It maps closely to traditional BI semantic models. + +This proposal is only targeted at the Logical Layer. It would be nice if the Ontological layer could re-use the same expression language, but that will be treated as a separate proposal. + +This document defines the SQL expression language subset that OSI-compliant implementations MUST support. The goal is to provide a portable expression language that works across all OSI implementations while allowing vendors to expose richer database-specific functionality through dialect extensions. + +We expect there will be extensions to this language to cover concepts such as sub-queries, grain calculations, etc. However, these will each have their own proposal. + +### Design Principles + +1. **Portability**: Core functions work identically across all implementations +2. **Familiarity**: Based on widely-adopted SQL syntax and semantics +3. **Analytical Focus**: Prioritizes functions commonly used in BI and analytics +4. **Extensibility**: Vendor dialects can extend beyond the core + +### Changes to YAML + +1) Create a new dialect in the OSI spec: OSI\_SQL\_2026, which refers to this language specification. +2) Make OSI\_SQL\_2026 the default dialect if one is not chosen. + +### Standards Reference + +The core language is based on **ANSI SQL:2003 Core** (ISO/IEC 9075-2:2003), selected for its: + +- Wide adoption across major databases (Snowflake, Databricks, PostgreSQL, BigQuery) +- Well-defined semantics +- Support for modern analytical features (window functions, CTEs) + +### Namespacing and Identifier Resolution + +The identifiers will match standard SQL identifiers: + +`Field: ` + +`FieldExpr: Field | Field ‘.’ Field` + +The OSI spec currently contains three namespaces, which determine the visibility and uniqueness of each value. Where and how a field (or metric) is defined will determine the namespace for it, which in turn determines the ways it can be addressed by other fields. + +All identifiers MUST be valid names and follow ANSI SQL naming, with the size limitation of 128 characters for identifiers. Many databases support longer identifiers, however, this number is safe for a broad number of vendors. + +Regular identifiers (unquoted) should be case insensitive. For example, an identifier id is regular, so it would match with Id or iD. Comparing quoted and non-quoted identifiers is DB specific, so for best portability it is best to use simple identifiers. + +The quote character for the OSI dialect will follow ANSI SQL and support the double quote character (“). This means that if an expression is in a field expression or as an identifier in the YAML, this will be the expected quoting. However, there are some databases that use other escape characters. Working with these have the option of either creating expressions using their dialect or having the OSI document written in the OSI dialect, but then having the SQL Interface queried in the local dialect. The SQL Interface will be defined in a different document. + +#### Comparison Table + +| You type this in SQL | Database sees it as... | Will it match a column created as id? | +| :---- | :---- | :---- | +| id | ID | **Yes** (Standard behavior) | +| Id | ID | **Yes** (Standard behavior) | +| "ID" | ID | **Yes** (Force-matched to normalized case) | +| "id" | id | **No** (Database is looking for lowercase) | + +Sometimes, we may refer to a **normalized identifier**. This is a form the identifiers can be put in, so they can be matched easily and matches can be made with case-sensitive, exact matching. For **normalized identifiers**: + +* Regular identifiers are upper cased +* Quoted identifiers have their quotes stripped and any escaped characters are unescaped + +#### Name Spaces + +Namespaces define how an identifier is looked up in an expression. The identifier rules above determine how to create a normalized name, and the namespace determines whether those normalized names resolve to the same objects. + +There are three scopes which make up our namespace, with membership in each determined by where the field was defined: **Global**, **Dataset** and **Physical**. + +##### Global Scope + +Objects that are defined at the top level of the semantic model are in the Global scope. These are from expressions without any qualifier, and can be accessed from anywhere (although other rules like grain rules still apply in how they can be used). + +In the current OSI spec, the only global scoped fields are Metrics, Datasets and Relationships. However, in the future there could be other sections. Regardless of the heading the fields are defined in, any of those top level fields share in the same namespace, and should not be able to have the same normalized names. + +The Global Metrics have access to global and object fields, but NOT physical fields. In order to access a physical field, it MUST be pulled in through a dataset field. + +Relationships have access to global, object and physical fields (since, they can be useful for defining joins). + +Accessing an object field MUST be qualified with the name of the object in order to reference the field. E.g. store\_sales.id would reference the ID field in the STORE\_SALES object. + +##### Dataset / Object + +The object scope is unique to the object the fields are defined in. Currently, the only objects that have nested fields are Datasets. They have a fields section to define new fields. + +Fields may be defined at the dataset level. Their identifiers MUST be unique within the dataset, but can have the same name as identifiers in other datasets, or in the global scope. + +**Object fields can access logical or physical fields** within the object’s scope without requiring qualification. The fields may also access global fields as well, which means that shadowing can occur here. To handle these in a predictable way, names will be resolved with the following rules: + +| Precedence | Field Type | Disambiguation | +| :---- | :---- | :---- | +| Highest | Physical Fields | N/A | +| Middle | Logical fields on the object | Qualifying access through the object name, will ensure getting a logical field, rather than the shadowing physical field. store\_sales.id will ensure access to the logical id field, not the physical one. | +| Lowest | Global fields or objects | Unable to access a shadowed global field. E.g. if there is a global field sales and the object scope has a sales field, then the local sales will shadow the global one. | + +##### Physical + +Physical fields are ones that come directly from the Dataset’s source query. They are not directly stored in the model, but reflect what is in the actual system of record. + +Physical fields are ONLY accessible from Dataset fields. + +There is no way to create Physical fields. + +## SQL Language Subset + +### Supported SQL Constructs + +OSI expressions support the following SQL constructs within metric and filter expressions: + +| Construct | Notes | +| :---- | :---- | +| Column and Metric references | Varies based on whether in Ontology or Semantic models. See namespaceing in [OSI Discussion Point: Core Analytic Abstractions](https://docs.google.com/document/d/1si8DqU4arG18ZgX4HnRG5D_zS2X7V1s-vgNY35rvxhM/edit?tab=t.0#heading=h.le505t8uoyfy) And future Ontology documentation | +| Arithmetic operators | `+`, `-`, `*`, `/`, `%` (modulo) | +| Comparison operators | `=`, `<>`, `!=`, `<`, `>`, `<=`, `>=` | +| Logical operators | `AND`, `OR`, `NOT` | +| `BETWEEN` | `x BETWEEN a AND b` | +| `IN` / `NOT IN` | `x IN (a, b, c)` This only supports lists of values, not subqueries. | +| `LIKE` / `ILIKE` | Pattern matching | +| `IS NULL` / `IS NOT NULL` | Null checks | +| `CASE WHEN` | Conditional logic | +| Aggregate functions | See [Aggregation Functions](https://docs.google.com/document/d/1nvt-vOV8TRKDOlF8C2OkThBqGF73eVZLSC1wryUBPM4/edit#aggregation-functions) | +| Window functions | See [Window Functions](https://docs.google.com/document/d/1nvt-vOV8TRKDOlF8C2OkThBqGF73eVZLSC1wryUBPM4/edit#window-functions) | +| Scalar functions | See function categories below | +| Parentheses | Expression grouping | +| Bind parameters | `:parameter_name` syntax | + +### + +### Not Supported in Expressions + +| Construct | Reason | +| :---- | :---- | +| `SELECT` / `FROM` / `JOIN` | Handled by semantic layer | +| `GROUP BY` | Controlled by grain specification | +| `WHERE` | Use filter property instead | +| Subqueries | Use field references instead, or EXISTS\_IN() for filtering based on a subquery. | +| CTEs | Use field references instead | +| `UNION` / `INTERSECT` / `EXCEPT` | Not applicable to expressions | +| DDL statements | Out of scope | +| DML statements | Out of scope | + +### Operator Precedence + +Standard SQL operator precedence applies (highest to lowest): + +1. Parentheses `()` +2. Unary operators: `+`, `-`, `NOT` +3. Multiplication/Division: `*`, `/`, `%` +4. Addition/Subtraction: `+`, `-` +5. Comparison: `=`, `<>`, `<`, `>`, `<=`, `>=`, `LIKE`, `IN`, `BETWEEN`, `IS NULL` +6. `NOT` +7. `AND` +8. `OR` + +--- + +## Aggregation Functions + +All aggregation functions operate on the effective grain of the metric. + +### Core Aggregation Functions (REQUIRED) + +| Function | Syntax | Description | Decomposability | +| :---- | :---- | :---- | :---- | +| `SUM` | `SUM(expr)` | Sum of values | Distributive | +| `COUNT` | `COUNT(expr)` | Count of non-null values | Distributive | +| `COUNT(*)` | `COUNT(*)` | Count of all rows | Distributive | +| `COUNT(DISTINCT expr)` | `COUNT(DISTINCT expr)` | Count of distinct values | Holistic | +| `AVG` | `AVG(expr)` | Arithmetic mean | Algebraic | +| `MIN` | `MIN(expr)` | Minimum value | Distributive | +| `MAX` | `MAX(expr)` | Maximum value | Distributive | + +### Statistical Aggregations (REQUIRED) + +| Function | Syntax | Description | Decomposability | +| :---- | :---- | :---- | :---- | +| `STDDEV` | `STDDEV(expr)` | Sample standard deviation | Algebraic | +| `STDDEV_POP` | `STDDEV_POP(expr)` | Population standard deviation | Algebraic | +| `STDDEV_SAMP` | `STDDEV_SAMP(expr)` | Sample standard deviation (alias for STDDEV) | Algebraic | +| `VARIANCE` | `VARIANCE(expr)` | Sample variance | Algebraic | +| `VAR_POP` | `VAR_POP(expr)` | Population variance | Algebraic | +| `VAR_SAMP` | `VAR_SAMP(expr)` | Sample variance (alias for VARIANCE) | Algebraic | + +### Percentile Functions (REQUIRED — parseable surface; Foundation planner support is limited) + +| Function | Syntax | Description | Decomposability | +| :---- | :---- | :---- | :---- | +| `MEDIAN` | `MEDIAN(expr)` | Median value (50th percentile) | Holistic | +| `PERCENTILE_CONT` | `PERCENTILE_CONT(p) WITHIN GROUP (ORDER BY expr)` | Continuous percentile (interpolated) | Holistic | +| `PERCENTILE_DISC` | `PERCENTILE_DISC(p) WITHIN GROUP (ORDER BY expr)` | Discrete percentile (actual value) | Holistic | + +Where `p` is a value between 0 and 1 (e.g., 0.5 for median, 0.75 for 75th percentile). + +> **Foundation v0.1 status.** These functions are part of the +> OSI_SQL_2026 *parseable* surface (so any tooling that lints, formats, +> or re-emits an expression keeps it intact), but +> [`Proposed_OSI_Semantics.md` §10](Proposed_OSI_Semantics.md#10-deferred) +> defers the ordered-set form `WITHIN GROUP (ORDER BY …)` from the +> Foundation Tier, and a conforming planner MUST reject all +> holistic percentile/median aggregates as top-level metric expressions +> with `E1208_UNSUPPORTED_SQL_CONSTRUCT` (see Appendix C). They will +> become first-class once the dedicated grain-aware-functions +> proposal lands. + +### Approximate Aggregations (RECOMMENDED) + +Approximate functions trade exact accuracy for significantly better performance on large datasets. They use probabilistic algorithms (sketches) that are efficiently mergeable, making them well-suited for distributed computation. + +| Function | Syntax | Description | Typical Error | +| :---- | :---- | :---- | :---- | +| `APPROX_COUNT_DISTINCT` | `APPROX_COUNT_DISTINCT(expr)` | Approximate distinct count using HyperLogLog or something similar. Actual method is up to providers. | \~2% | +| `APPROX_PERCENTILE` | `APPROX_PERCENTILE(expr, p)` | Approximate percentile using t-digest or similar | \~1% | + +```sql +-- Approximate distinct count (much faster than COUNT(DISTINCT) on large data) +APPROX_COUNT_DISTINCT(customer_id) + +-- Approximate median +APPROX_PERCENTILE(amount, 0.5) + +-- Approximate 95th percentile +APPROX_PERCENTILE(response_time, 0.95) +``` + +**Database Support:** + +| Function | Snowflake | BigQuery | Databricks | PostgreSQL | +| :---- | :---- | :---- | :---- | :---- | +| `APPROX_COUNT_DISTINCT` | ✅ | ✅ | ✅ | ❌ (extension) | +| `APPROX_PERCENTILE` | ✅ | ✅ `APPROX_QUANTILES` | ✅ | ❌ | + +**Note**: BigQuery uses `APPROX_QUANTILES(expr, num_buckets)` which returns an array. To get a specific percentile: `APPROX_QUANTILES(amount, 100)[OFFSET(50)]` for median. + +**When to use approximate functions:** + +- Large datasets (millions+ rows) where exact results aren't critical +- Interactive dashboards where response time matters +- Exploratory analysis where directional accuracy is sufficient + +--- + +### Conditional Aggregations (REQUIRED) + +SUM / COUNT aggregation functions support `DISTINCT.` +All aggregations should support filtered aggregation: + +```sql +-- DISTINCT modifier +SUM(DISTINCT amount) +COUNT(DISTINCT customer_id) + +-- Filtered aggregation via CASE +SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END) +COUNT(CASE WHEN status = 'completed' THEN 1 END) +``` + +### Decomposability Reference + +For multi-stage aggregation (see [OSI Analytical Context Extension](https://docs.google.com/document/d/1MKNySGmEv_C6CzBZ7um9Ym3_mMvmOolpDuwPvRzQ1bo/edit?usp=sharing)): + +| Category | Functions | +| :---- | :---- | +| **Distributive** | SUM, COUNT, MIN, MAX | +| **Algebraic** | AVG, STDDEV, VARIANCE | +| **Holistic** | MEDIAN, PERCENTILE, COUNT DISTINCT | +| **Sketch-based** | APPROX\_COUNT\_DISTINCT, APPROX\_PERCENTILE | + +Approximate functions are naturally suited for multi-stage aggregation because their sketch data structures are designed to be mergeable. + +--- + +## Date/Time Functions + +### Current Date/Time (REQUIRED) + +| Function | Syntax | Returns | Description | +| :---- | :---- | :---- | :---- | +| `CURRENT_DATE` | `CURRENT_DATE` or `CURRENT_DATE()` | DATE | Current date | +| `CURRENT_TIMESTAMP` | `CURRENT_TIMESTAMP` or `CURRENT_TIMESTAMP()` | TIMESTAMP | Current timestamp | +| `CURRENT_TIME` | `CURRENT_TIME` or `CURRENT_TIME()` | TIME | Current time | + +### Date/Time Extraction (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `YEAR` | `YEAR(date_expr)` | Extract year (integer) | +| `QUARTER` | `QUARTER(date_expr)` | Extract quarter (1-4) | +| `MONTH` | `MONTH(date_expr)` | Extract month (1-12) | +| `WEEK` | `WEEK(date_expr)` | Extract week of year (1-53) | +| `DAY` | `DAY(date_expr)` | Extract day of month (1-31) | +| `DAYOFWEEK` | `DAYOFWEEK(date_expr)` | Day of week (1=Sunday, 7=Saturday) | +| `DAYOFYEAR` | `DAYOFYEAR(date_expr)` | Day of year (1-366) | +| `HOUR` | `HOUR(timestamp_expr)` | Extract hour (0-23) | +| `MINUTE` | `MINUTE(timestamp_expr)` | Extract minute (0-59) | +| `SECOND` | `SECOND(timestamp_expr)` | Extract second (0-59) | + +### Alternative Extraction Syntax (REQUIRED) + +```sql +-- EXTRACT function (SQL standard) +EXTRACT(YEAR FROM date_expr) +EXTRACT(MONTH FROM date_expr) +EXTRACT(DAY FROM date_expr) + +-- DATE_PART function (common alternative) +DATE_PART('year', date_expr) +DATE_PART('month', date_expr) +DATE_PART('day', date_expr) +``` + +Supported date parts for `EXTRACT` and `DATE_PART`: + +- `YEAR`, `QUARTER`, `MONTH`, `WEEK`, `DAY` +- `DAYOFWEEK`, `DAYOFYEAR` +- `HOUR`, `MINUTE`, `SECOND`, `MILLISECOND` + +### Date Truncation (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `DATE_TRUNC` | `DATE_TRUNC(part, date_expr)` | Truncate to specified precision | + +Supported parts: `'year'`, `'quarter'`, `'month'`, `'week'`, `'day'`, `'hour'`, `'minute'`, `'second'` + +```sql +-- Examples +DATE_TRUNC('month', order_date) -- First day of month +DATE_TRUNC('quarter', order_date) -- First day of quarter +DATE_TRUNC('week', order_date) -- First day of week (Monday) +``` + +### Date Arithmetic (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `DATEADD` | `DATEADD(part, amount, date_expr)` | Add interval to date | +| `DATEDIFF` | `DATEDIFF(part, start_date, end_date)` | Difference between dates | + +```sql +-- Add/subtract intervals +DATEADD(day, 7, order_date) -- Add 7 days +DATEADD(month, -1, order_date) -- Subtract 1 month +DATEADD(year, 1, order_date) -- Add 1 year + +-- Calculate differences +DATEDIFF(day, start_date, end_date) -- Days between dates +DATEDIFF(month, start_date, end_date) -- Months between dates +DATEDIFF(year, start_date, end_date) -- Years between dates +``` + +### Date Construction (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `DATE` | `DATE(year, month, day)` | Construct date from parts | +| `TIMESTAMP` | `TIMESTAMP(year, month, day, hour, minute, second)` | Construct timestamp | +| `TO_DATE` | `TO_DATE(string, format)` | Parse string to date | +| `TO_TIMESTAMP` | `TO_TIMESTAMP(string, format)` | Parse string to timestamp | + +### Date Formatting (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `TO_CHAR` | `TO_CHAR(date_expr, format)` | Format date as string | + +Common format specifiers: + +- `YYYY` \- 4-digit year +- `YY` \- 2-digit year +- `MM` \- Month (01-12) +- `MON` \- Abbreviated month name +- `MONTH` \- Full month name +- `DD` \- Day of month (01-31) +- `DY` \- Abbreviated day name +- `DAY` \- Full day name +- `HH24` \- Hour (00-23) +- `HH` or `HH12` \- Hour (01-12) +- `MI` \- Minute (00-59) +- `SS` \- Second (00-59) + +--- + +## String Functions + +### String Manipulation (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `CONCAT` | `CONCAT(str1, str2, ...)` | Concatenate strings | +| `||` | `str1 || str2` | Concatenation operator | +| `LENGTH` | `LENGTH(str)` | String length in characters | +| `LOWER` | `LOWER(str)` | Convert to lowercase | +| `UPPER` | `UPPER(str)` | Convert to uppercase | +| `TRIM` | `TRIM(str)` | Remove leading/trailing whitespace | +| `LTRIM` | `LTRIM(str)` | Remove leading whitespace | +| `RTRIM` | `RTRIM(str)` | Remove trailing whitespace | +| `LEFT` | `LEFT(str, n)` | First n characters | +| `RIGHT` | `RIGHT(str, n)` | Last n characters | +| `SUBSTRING` | `SUBSTRING(str, start, length)` | Extract substring | +| `REPLACE` | `REPLACE(str, from, to)` | Replace occurrences | +| `SPLIT_PART` | `SPLIT_PART(str, delimiter, part)` | Extract part by delimiter | + +### String Search (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `POSITION` | `POSITION(substr IN str)` | Position of substring (1-based) | +| `CHARINDEX` | `CHARINDEX(substr, str)` | Alias for POSITION | +| `CONTAINS` | `CONTAINS(str, substr)` | Returns TRUE if contains | +| `STARTSWITH` | `STARTSWITH(str, prefix)` | Returns TRUE if starts with | +| `ENDSWITH` | `ENDSWITH(str, suffix)` | Returns TRUE if ends with | + +### Pattern Matching (REQUIRED) + +| Pattern | Syntax | Description | +| :---- | :---- | :---- | +| `LIKE` | `str LIKE pattern` | Case-sensitive pattern match | +| `ILIKE` | `str ILIKE pattern` | Case-insensitive pattern match | +| `REGEXP_LIKE` | `REGEXP_LIKE(str, pattern)` | Regular expression match | + +Pattern wildcards for `LIKE`: + +- `%` \- Match any sequence of characters +- `_` \- Match any single character + +### Regular Expressions (RECOMMENDED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `REGEXP_LIKE` | `REGEXP_LIKE(str, pattern)` | Test if pattern matches | +| `REGEXP_EXTRACT` | `REGEXP_EXTRACT(str, pattern)` | Extract first match | +| `REGEXP_REPLACE` | `REGEXP_REPLACE(str, pattern, replacement)` | Replace matches | +| `REGEXP_COUNT` | `REGEXP_COUNT(str, pattern)` | Count matches | + +--- + +## Mathematical Functions + +### Basic Math (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `ABS` | `ABS(x)` | Absolute value | +| `ROUND` | `ROUND(x, d)` | Round to d decimal places | +| `FLOOR` | `FLOOR(x)` | Round down to integer | +| `CEIL` / `CEILING` | `CEIL(x)` | Round up to integer | +| `TRUNC` / `TRUNCATE` | `TRUNC(x, d)` | Truncate to d decimal places | +| `MOD` | `MOD(x, y)` | Modulo (remainder) | +| `SIGN` | `SIGN(x)` | Sign (-1, 0, or 1\) | + +### Advanced Math (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `POWER` | `POWER(x, y)` | x raised to power y | +| `SQRT` | `SQRT(x)` | Square root | +| `EXP` | `EXP(x)` | e raised to power x | +| `LN` | `LN(x)` | Natural logarithm | +| `LOG` | `LOG(base, x)` | Logarithm with specified base | +| `LOG10` | `LOG10(x)` | Base-10 logarithm | + +### Trigonometric (RECOMMENDED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `SIN` | `SIN(x)` | Sine (x in radians) | +| `COS` | `COS(x)` | Cosine | +| `TAN` | `TAN(x)` | Tangent | +| `ASIN` | `ASIN(x)` | Arc sine | +| `ACOS` | `ACOS(x)` | Arc cosine | +| `ATAN` | `ATAN(x)` | Arc tangent | +| `ATAN2` | `ATAN2(y, x)` | Arc tangent of y/x | +| `RADIANS` | `RADIANS(degrees)` | Convert degrees to radians | +| `DEGREES` | `DEGREES(radians)` | Convert radians to degrees | +| `PI` | `PI()` | Value of π | + +### Comparison Functions (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `GREATEST` | `GREATEST(x, y, ...)` | Maximum of arguments | +| `LEAST` | `LEAST(x, y, ...)` | Minimum of arguments | + +--- + +## Conditional Functions + +### CASE Expression (REQUIRED) + +```sql +-- Searched CASE +CASE + WHEN condition1 THEN result1 + WHEN condition2 THEN result2 + ELSE default_result +END + +-- Simple CASE +CASE expression + WHEN value1 THEN result1 + WHEN value2 THEN result2 + ELSE default_result +END +``` + +### Conditional Functions (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `IF` | `IF(condition, true_result, false_result)` | Ternary conditional | +| `IFF` | `IFF(condition, true_result, false_result)` | Alias for IF | +| `NULLIF` | `NULLIF(expr1, expr2)` | Returns NULL if equal | +| `COALESCE` | `COALESCE(expr1, expr2, ...)` | First non-null value | +| `IFNULL` | `IFNULL(expr, default)` | Alias for COALESCE with 2 args | +| `NVL` | `NVL(expr, default)` | Alias for COALESCE with 2 args | +| `NVL2` | `NVL2(expr, not_null_result, null_result)` | Different results for null/not-null | +| `ZeroIfNull` | `ZEROIFNULL(expr)` | Returns 0 if null | +| `NullIfZero` | `NULLIFZERO(expr)` | Returns NULL if zero | + +### Boolean Functions (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `BOOLEAN` | `TRUE`, `FALSE` | Boolean literals | +| `NOT` | `NOT expr` | Logical negation | +| `AND` | `expr1 AND expr2` | Logical AND | +| `OR` | `expr1 OR expr2` | Logical OR | + +--- + +## Window Functions + +Window functions operate over a window frame defined by `OVER()`. This should act consistently with window functions in ANSII SQL. + +### Syntax + +```sql +function_name(args) OVER ( + [PARTITION BY partition_expr, ...] + [ORDER BY order_expr [ASC|DESC], ...] + [frame_clause] +) +``` + +Frame clause options: + +- `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` +- `ROWS BETWEEN n PRECEDING AND n FOLLOWING` +- `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` + +### Ranking Functions (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `ROW_NUMBER` | `ROW_NUMBER() OVER (...)` | Sequential row number | +| `RANK` | `RANK() OVER (...)` | Rank with gaps for ties | +| `DENSE_RANK` | `DENSE_RANK() OVER (...)` | Rank without gaps | +| `NTILE` | `NTILE(n) OVER (...)` | Divide into n buckets | +| `PERCENT_RANK` | `PERCENT_RANK() OVER (...)` | Relative rank (0-1) | +| `CUME_DIST` | `CUME_DIST() OVER (...)` | Cumulative distribution | + +### Offset Functions (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `LAG` | `LAG(expr, offset, default) OVER (...)` | Value from previous row | +| `LEAD` | `LEAD(expr, offset, default) OVER (...)` | Value from next row | +| `FIRST_VALUE` | `FIRST_VALUE(expr) OVER (...)` | First value in window | +| `LAST_VALUE` | `LAST_VALUE(expr) OVER (...)` | Last value in window | +| `NTH_VALUE` | `NTH_VALUE(expr, n) OVER (...)` | Nth value in window | + +### Window Aggregations (REQUIRED) + +All standard aggregation functions can be used as window functions: + +```sql +-- Running total +SUM(amount) OVER (ORDER BY order_date) + +-- Running average +AVG(amount) OVER (ORDER BY order_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) + +-- Partition totals +SUM(amount) OVER (PARTITION BY region) + +-- Percent of total +amount / SUM(amount) OVER () * 100 +``` + +--- + +## Type Conversion Functions + +### CAST (REQUIRED) + +```sql +CAST(expression AS target_type) +``` + +Supported target types: + +- `VARCHAR` / `STRING` / `TEXT` \- Character string +- `INTEGER` / `INT` / `BIGINT` \- Integer +- `DECIMAL` / `NUMERIC` / `NUMBER` \- Fixed-point decimal +- `FLOAT` / `DOUBLE` / `REAL` \- Floating-point +- `BOOLEAN` / `BOOL` \- Boolean +- `DATE` \- Date +- `TIMESTAMP` / `DATETIME` \- Timestamp +- `TIME` \- Time + +### TRY\_CAST (RECOMMENDED) + +```sql +TRY_CAST(expression AS target_type) -- Returns NULL on failure +``` + +### Type-Specific Conversions (REQUIRED) + +| Function | Syntax | Description | +| :---- | :---- | :---- | +| `TO_VARCHAR` | `TO_VARCHAR(expr)` | Convert to string | +| `TO_NUMBER` | `TO_NUMBER(str, format)` | Parse string to number | +| `TO_DATE` | `TO_DATE(str, format)` | Parse string to date | +| `TO_TIMESTAMP` | `TO_TIMESTAMP(str, format)` | Parse string to timestamp | +| `TO_BOOLEAN` | `TO_BOOLEAN(expr)` | Convert to boolean | + +--- + +### Null-Safe Comparison + +```sql +-- Standard comparison (returns NULL if either side is NULL) +a = b + +-- Null-safe comparison (treats NULLs as equal) +a IS NOT DISTINCT FROM b -- TRUE if both are NULL +a IS DISTINCT FROM b -- TRUE if one is NULL and other isn't +``` + +--- + +## Dialect Extensions + +OSI implementations MAY support additional functions through dialect-specific extensions. When using dialect extensions, the expression must specify the dialect. + +The OSI dialect should always be supported. Other dialects MAY be ignored. There is no guarantee that all different dialects for an expression will act the same, so implementations should be consistent with their dialect handling. + +### Declaring Dialect-Specific Expressions + +``` +expression: + dialects: + - dialect: ANSI_SQL + expression: DATE_TRUNC('month', order_date) + - dialect: SNOWFLAKE + expression: DATE_TRUNC('month', order_date) + - dialect: BIGQUERY + expression: DATE_TRUNC(order_date, MONTH) +``` + +### Common Dialect Variations + +| Function | ANSI\_SQL | Snowflake | BigQuery | Databricks | PostgreSQL | +| :---- | :---- | :---- | :---- | :---- | :---- | +| Date truncation | `DATE_TRUNC('month', d)` | `DATE_TRUNC('month', d)` | `DATE_TRUNC(d, MONTH)` | `DATE_TRUNC('month', d)` | `DATE_TRUNC('month', d)` | +| Date add | `DATEADD(day, 7, d)` | `DATEADD(day, 7, d)` | `DATE_ADD(d, INTERVAL 7 DAY)` | `DATE_ADD(d, 7)` | `d + INTERVAL '7 days'` | +| String concat | `CONCAT(a, b)` | `CONCAT(a, b)` | `CONCAT(a, b)` | `CONCAT(a, b)` | `a || b` | +| Null coalesce | `COALESCE(a, b)` | `COALESCE(a, b)` or `NVL(a, b)` | `COALESCE(a, b)` or `IFNULL(a, b)` | `COALESCE(a, b)` | `COALESCE(a, b)` | +| Current timestamp | `CURRENT_TIMESTAMP` | `CURRENT_TIMESTAMP()` | `CURRENT_TIMESTAMP()` | `CURRENT_TIMESTAMP()` | `CURRENT_TIMESTAMP` | +| Substring | `SUBSTRING(s, start, len)` | `SUBSTR(s, start, len)` | `SUBSTR(s, start, len)` | `SUBSTRING(s, start, len)` | `SUBSTRING(s, start, len)` | + +### + +### Dialect-Specific Extensions + +Vendors may expose their own feature through extensions, however the default for OSI should be to pass unknown values through.: +--- + +## Cross-Reference: Tool Mappings + +This section maps OSI standard functions to their equivalents in popular BI tools. + +### Aggregation Function Mapping + +| OSI Standard | Tableau | Looker Studio | DAX | +| :---- | :---- | :---- | :---- | +| `SUM(x)` | `SUM(x)` | `SUM(X)` | `SUM(x)` | +| `COUNT(x)` | `COUNT(x)` | `COUNT(X)` | `COUNT(x)` | +| `COUNT(DISTINCT x)` | `COUNTD(x)` | `COUNT_DISTINCT(X)` | `DISTINCTCOUNT(x)` | +| `AVG(x)` | `AVG(x)` | `AVG(X)` | `AVERAGE(x)` | +| `MIN(x)` | `MIN(x)` | `MIN(X)` | `MIN(x)` | +| `MAX(x)` | `MAX(x)` | `MAX(X)` | `MAX(x)` | +| `STDDEV(x)` | `STDEV(x)` | `STDDEV(X)` | `STDEV.S(x)` | +| `STDDEV_POP(x)` | `STDEVP(x)` | `STDDEV(X)` | `STDEV.P(x)` | +| `VARIANCE(x)` | `VAR(x)` | `VARIANCE(X)` | `VAR.S(x)` | +| `MEDIAN(x)` | `MEDIAN(x)` | `MEDIAN(X)` | `MEDIAN(x)` | +| `PERCENTILE_CONT(x, 0.75)` | `PERCENTILE(x, 0.75)` | `PERCENTILE(X, 75)` | `PERCENTILE.INC(x, 0.75)` | + +### Date Function Mapping + +| OSI Standard | Tableau | Looker Studio | DAX | +| :---- | :---- | :---- | :---- | +| `YEAR(d)` | `YEAR(d)` | `YEAR(Date)` | `YEAR(d)` | +| `MONTH(d)` | `MONTH(d)` | `MONTH(Date)` | `MONTH(d)` | +| `DAY(d)` | `DAY(d)` | `DAY(Date)` | `DAY(d)` | +| `DATE_TRUNC('month', d)` | `DATETRUNC('month', d)` | `TODATE(d, "YYYYMM01", "YYYYMMDD")` | `DATE(YEAR(d), MONTH(d), 1)` | +| `DATEADD(day, n, d)` | `DATEADD('day', n, d)` | `DATE_ADD(d, n)` (days only) | `DATE(d) + n` or `DATEADD(d, n, DAY)` | +| `DATEDIFF(day, d1, d2)` | `DATEDIFF('day', d1, d2)` | `DATE_DIFF(d1, d2)` | `DATEDIFF(d1, d2, DAY)` | +| `CURRENT_DATE` | `TODAY()` | `TODAY()` | `TODAY()` | + +### String Function Mapping + +| OSI Standard | Tableau | Looker Studio | DAX | +| :---- | :---- | :---- | :---- | +| `CONCAT(a, b)` | `a + b` | `CONCAT(X, Y)` | `CONCATENATE(a, b)` or `a & b` | +| `LENGTH(s)` | `LEN(s)` | `LENGTH(X)` | `LEN(s)` | +| `LOWER(s)` | `LOWER(s)` | `LOWER(X)` | `LOWER(s)` | +| `UPPER(s)` | `UPPER(s)` | `UPPER(X)` | `UPPER(s)` | +| `TRIM(s)` | `TRIM(s)` | `TRIM(X)` | `TRIM(s)` | +| `LEFT(s, n)` | `LEFT(s, n)` | `LEFT_TEXT(X, n)` | `LEFT(s, n)` | +| `RIGHT(s, n)` | `RIGHT(s, n)` | `RIGHT_TEXT(X, n)` | `RIGHT(s, n)` | +| `SUBSTRING(s, start, len)` | `MID(s, start, len)` | `SUBSTR(X, start, len)` | `MID(s, start, len)` | +| `REPLACE(s, from, to)` | `REPLACE(s, from, to)` | `REPLACE(X, Y, Z)` | `SUBSTITUTE(s, from, to)` | +| `CONTAINS(s, sub)` | `CONTAINS(s, sub)` | `CONTAINS_TEXT(X, text)` | `CONTAINSSTRING(s, sub)` | + +### Conditional Function Mapping + +| OSI Standard | Tableau | Looker Studio | DAX | +| :---- | :---- | :---- | :---- | +| `CASE WHEN...` | `CASE WHEN...` or `IF...` | `CASE WHEN...` | `SWITCH(TRUE(), ...)` | +| `IF(cond, t, f)` | `IF cond THEN t ELSE f END` | N/A (use CASE) | `IF(cond, t, f)` | +| `COALESCE(a, b)` | `IFNULL(a, b)` or `ZN(a)` | `COALESCE(...)` | `COALESCE(a, b)` | +| `NULLIF(a, b)` | `IF a = b THEN NULL ELSE a END` | N/A | `IF(a = b, BLANK(), a)` | + +### Window Function Mapping + +| OSI Standard | Tableau | Looker Studio | DAX | +| :---- | :---- | :---- | :---- | +| `ROW_NUMBER() OVER(...)` | `INDEX()` | N/A | `RANKX(...)` with DENSE | +| `RANK() OVER(...)` | `RANK(expr)` | N/A | `RANKX(...)` | +| `SUM(...) OVER(PARTITION BY...)` | `{FIXED [...]: SUM(...)}` | N/A (blending only) | Context-dependent | +| `LAG(x, 1) OVER(ORDER BY...)` | `LOOKUP(x, -1)` | N/A | `CALCULATE(x, PREVIOUSDAY(...))` | +| `RUNNING_SUM(...)` | `RUNNING_SUM(SUM(...))` | N/A | `CALCULATE(SUM(...), FILTER(...))` | + +--- + +## Compliance Levels + +### MUST Support (Core) + +Implementations MUST support all functions marked as **REQUIRED** in this specification. These represent the minimum portable expression language. + +### SHOULD Support (Recommended) + +Implementations SHOULD support functions marked as **RECOMMENDED**. These are common analytical functions that may not be available in all databases. + +### MAY Support (Extensions) + +Implementations MAY support additional functions through dialect extensions. These should be documented as dialect-specific. + +--- + +## Version History + +| Version | Date | Changes | +| :---- | :---- | :---- | +| 0.1 | 2026-05-04 | Initial draft | + +--- + +## References + +- [SQL:2003 Standard](https://www.iso.org/standard/34132.html) (ISO/IEC 9075-2:2003) +- [Tableau Functions Reference](https://help.tableau.com/current/pro/desktop/en-us/functions.htm) +- [Looker Studio Function List](https://support.google.com/looker-studio/table/6379764) +- [DAX Function Reference](https://learn.microsoft.com/en-us/dax/dax-function-reference) +- [Snowflake SQL Reference](https://docs.snowflake.com/en/sql-reference-functions) +- [BigQuery Standard SQL Reference](https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators) +- [Databricks SQL Functions](https://docs.databricks.com/sql/language-manual/sql-ref-functions.html) +- [PostgreSQL Functions](https://www.postgresql.org/docs/current/functions.html) + +[image1]: \ No newline at end of file