diff --git a/docs/reference/expression-coverage-scoped.md b/docs/reference/expression-coverage-scoped.md index f0d97929..46ccf6fb 100644 --- a/docs/reference/expression-coverage-scoped.md +++ b/docs/reference/expression-coverage-scoped.md @@ -5,7 +5,7 @@ Scoped deviations — dialect, parameter, option, value-class; function-level coverage and matrices live in [`expression-coverage.md`](expression-coverage.md). -Declarations: 43 · Facts: 1462 · Registered operations: 326 · Implementation records: 978 +Declarations: 43 · Facts: 1466 · Registered operations: 326 · Implementation records: 978 Legend — scoped deviations: @@ -141,6 +141,7 @@ Legend — scoped deviations: | Dialect | Param | Option values | Value class | Level | Enforcement | Boundary | Condition | Message | Workaround | Upstream | Since | Native errors | Probe-exempt | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | ibis-polars | unit | 1mo, 1q, 1y, month, quarter, year | — | unsupported | gate | build | — | ibis's polars sub-backend translates interval addition via polars.duration(), which has no months/years kwarg -- round/ceil cannot compute the next calendar boundary (truncate/floor, which only need FLOOR, are unaffected); verified 2026-08-16, ibis 12.0.0 | — | — | 2026-08-16 | — | — | +| ibis-sqlite | unit | — | duration_multiplier | unsupported | gate | build | — | ibis-sqlite has no TimestampBucket compilation rule -- a multiplied MA duration (e.g. dt.truncate('2d')) is unsupported there; verified 2026-08-18, ibis 12.0.0 | — | — | 2026-08-18 | — | — | | ibis-sqlite | unit | 1h, 1m, 1ms, 1q, 1s, 1us, hour, microsecond, millisecond, minute, quarter, second | — | unsupported | gate | build | — | ibis-sqlite has no TimestampTruncate support for units finer than DAY, and no TimestampBucket compilation rule (blocks multiple>1 bucketing, which quarter needs); verified 2026-08-16, ibis 12.0.0 | — | — | 2026-08-16 | — | — | ### `FLOOR` × narwhals (FKEY_MOUNTAINASH_SCALAR_DATETIME) @@ -156,6 +157,7 @@ Legend — scoped deviations: | Dialect | Param | Option values | Value class | Level | Enforcement | Boundary | Condition | Message | Workaround | Upstream | Since | Native errors | Probe-exempt | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| ibis-sqlite | unit | — | duration_multiplier | unsupported | gate | build | — | ibis-sqlite has no TimestampBucket compilation rule -- a multiplied MA duration (e.g. dt.truncate('2d')) is unsupported there; verified 2026-08-18, ibis 12.0.0 | — | — | 2026-08-18 | — | — | | ibis-sqlite | unit | 1h, 1m, 1ms, 1q, 1s, 1us, hour, microsecond, millisecond, minute, quarter, second | — | unsupported | gate | build | — | ibis-sqlite has no TimestampTruncate support for units finer than DAY, and no TimestampBucket compilation rule (blocks multiple>1 bucketing, which quarter needs); verified 2026-08-16, ibis 12.0.0 | — | — | 2026-08-16 | — | — | ### `IS_DST` × ibis (FKEY_MOUNTAINASH_SCALAR_DATETIME) @@ -179,6 +181,7 @@ Legend — scoped deviations: | Dialect | Param | Option values | Value class | Level | Enforcement | Boundary | Condition | Message | Workaround | Upstream | Since | Native errors | Probe-exempt | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | ibis-polars | unit | 1mo, 1q, 1y, month, quarter, year | — | unsupported | gate | build | — | ibis's polars sub-backend translates interval addition via polars.duration(), which has no months/years kwarg -- round/ceil cannot compute the next calendar boundary (truncate/floor, which only need FLOOR, are unaffected); verified 2026-08-16, ibis 12.0.0 | — | — | 2026-08-16 | — | — | +| ibis-sqlite | unit | — | duration_multiplier | unsupported | gate | build | — | ibis-sqlite has no TimestampBucket compilation rule -- a multiplied MA duration (e.g. dt.truncate('2d')) is unsupported there; verified 2026-08-18, ibis 12.0.0 | — | — | 2026-08-18 | — | — | | ibis-sqlite | unit | 1h, 1m, 1ms, 1q, 1s, 1us, hour, microsecond, millisecond, minute, quarter, second | — | unsupported | gate | build | — | ibis-sqlite has no TimestampTruncate support for units finer than DAY, and no TimestampBucket compilation rule (blocks multiple>1 bucketing, which quarter needs); verified 2026-08-16, ibis 12.0.0 | — | — | 2026-08-16 | — | — | ### `TO_TIMEZONE` × ibis (FKEY_MOUNTAINASH_SCALAR_DATETIME) @@ -201,6 +204,7 @@ Legend — scoped deviations: | Dialect | Param | Option values | Value class | Level | Enforcement | Boundary | Condition | Message | Workaround | Upstream | Since | Native errors | Probe-exempt | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| ibis-sqlite | unit | — | duration_multiplier | unsupported | gate | build | — | ibis-sqlite has no TimestampBucket compilation rule -- a multiplied MA duration (e.g. dt.truncate('2d')) is unsupported there; verified 2026-08-18, ibis 12.0.0 | — | — | 2026-08-18 | — | — | | ibis-sqlite | unit | 1h, 1m, 1ms, 1q, 1s, 1us, hour, microsecond, millisecond, minute, quarter, second | — | unsupported | gate | build | — | ibis-sqlite has no TimestampTruncate support for units finer than DAY, and no TimestampBucket compilation rule (blocks multiple>1 bucketing, which quarter needs); verified 2026-08-16, ibis 12.0.0 | — | — | 2026-08-16 | — | — | ### `CONTAINS` × narwhals (FKEY_MOUNTAINASH_SCALAR_LIST) diff --git a/docs/reference/expression-coverage.json b/docs/reference/expression-coverage.json index dc5cc1fa..281b8b79 100644 --- a/docs/reference/expression-coverage.json +++ b/docs/reference/expression-coverage.json @@ -1,7 +1,7 @@ { "stamp": { "declarations": 43, - "facts": 1462, + "facts": 1466, "operations": 326, "implementation_records": 978 }, @@ -50,19 +50,19 @@ "expr_capable": 149, "literal_only": 65, "polymorphic": 9, - "unsupported": 1239 + "unsupported": 1243 }, "facts_by_enforcement": { - "gate": 1455, + "gate": 1459, "router_metadata": 3, "materialize_residue": 4 }, "facts_by_backend": { "polars": 265, "narwhals": 586, - "ibis": 611 + "ibis": 615 }, - "facts_total": 1462 + "facts_total": 1466 }, "families": [ { @@ -1804,7 +1804,7 @@ "selector_counts": { "params": 1, "option_selectors": 16, - "value_classes": 0, + "value_classes": 1, "dialects": 2 }, "constraints": [ @@ -1904,6 +1904,22 @@ "native_errors": [], "probe_exempt": null }, + { + "dialect": "ibis-sqlite", + "param": "unit", + "option_value": null, + "value_class": "duration_multiplier", + "level": "unsupported", + "enforcement": "gate", + "boundary": "build", + "condition": null, + "message": "ibis-sqlite has no TimestampBucket compilation rule -- a multiplied MA duration (e.g. dt.truncate('2d')) is unsupported there; verified 2026-08-18, ibis 12.0.0", + "workaround": null, + "upstream_ref": null, + "since": "2026-08-18", + "native_errors": [], + "probe_exempt": null + }, { "dialect": "ibis-sqlite", "param": "unit", @@ -3848,10 +3864,26 @@ "selector_counts": { "params": 1, "option_selectors": 12, - "value_classes": 0, + "value_classes": 1, "dialects": 1 }, "constraints": [ + { + "dialect": "ibis-sqlite", + "param": "unit", + "option_value": null, + "value_class": "duration_multiplier", + "level": "unsupported", + "enforcement": "gate", + "boundary": "build", + "condition": null, + "message": "ibis-sqlite has no TimestampBucket compilation rule -- a multiplied MA duration (e.g. dt.truncate('2d')) is unsupported there; verified 2026-08-18, ibis 12.0.0", + "workaround": null, + "upstream_ref": null, + "since": "2026-08-18", + "native_errors": [], + "probe_exempt": null + }, { "dialect": "ibis-sqlite", "param": "unit", @@ -4594,7 +4626,7 @@ "selector_counts": { "params": 1, "option_selectors": 16, - "value_classes": 0, + "value_classes": 1, "dialects": 2 }, "constraints": [ @@ -4694,6 +4726,22 @@ "native_errors": [], "probe_exempt": null }, + { + "dialect": "ibis-sqlite", + "param": "unit", + "option_value": null, + "value_class": "duration_multiplier", + "level": "unsupported", + "enforcement": "gate", + "boundary": "build", + "condition": null, + "message": "ibis-sqlite has no TimestampBucket compilation rule -- a multiplied MA duration (e.g. dt.truncate('2d')) is unsupported there; verified 2026-08-18, ibis 12.0.0", + "workaround": null, + "upstream_ref": null, + "since": "2026-08-18", + "native_errors": [], + "probe_exempt": null + }, { "dialect": "ibis-sqlite", "param": "unit", @@ -5696,10 +5744,26 @@ "selector_counts": { "params": 1, "option_selectors": 12, - "value_classes": 0, + "value_classes": 1, "dialects": 1 }, "constraints": [ + { + "dialect": "ibis-sqlite", + "param": "unit", + "option_value": null, + "value_class": "duration_multiplier", + "level": "unsupported", + "enforcement": "gate", + "boundary": "build", + "condition": null, + "message": "ibis-sqlite has no TimestampBucket compilation rule -- a multiplied MA duration (e.g. dt.truncate('2d')) is unsupported there; verified 2026-08-18, ibis 12.0.0", + "workaround": null, + "upstream_ref": null, + "since": "2026-08-18", + "native_errors": [], + "probe_exempt": null + }, { "dialect": "ibis-sqlite", "param": "unit", @@ -45289,7 +45353,8 @@ "probe_date": "2026-08-16", "library_versions": [], "fixtures": [ - "ibis-duckdb" + "ibis-duckdb", + "ibis-sqlite" ] }, "facts": [ @@ -45356,6 +45421,70 @@ "since": "2026-08-16", "native_errors": [], "probe_exempt": null + }, + { + "dialect": "ibis-sqlite", + "param": "unit", + "option_value": null, + "value_class": "duration_multiplier", + "level": "unsupported", + "enforcement": "gate", + "boundary": "build", + "condition": null, + "message": "ibis-sqlite has no TimestampBucket compilation rule -- a multiplied MA duration (e.g. dt.truncate('2d')) is unsupported there; verified 2026-08-18, ibis 12.0.0", + "workaround": null, + "upstream_ref": null, + "since": "2026-08-18", + "native_errors": [], + "probe_exempt": null + }, + { + "dialect": "ibis-sqlite", + "param": "unit", + "option_value": null, + "value_class": "duration_multiplier", + "level": "unsupported", + "enforcement": "gate", + "boundary": "build", + "condition": null, + "message": "ibis-sqlite has no TimestampBucket compilation rule -- a multiplied MA duration (e.g. dt.truncate('2d')) is unsupported there; verified 2026-08-18, ibis 12.0.0", + "workaround": null, + "upstream_ref": null, + "since": "2026-08-18", + "native_errors": [], + "probe_exempt": null + }, + { + "dialect": "ibis-sqlite", + "param": "unit", + "option_value": null, + "value_class": "duration_multiplier", + "level": "unsupported", + "enforcement": "gate", + "boundary": "build", + "condition": null, + "message": "ibis-sqlite has no TimestampBucket compilation rule -- a multiplied MA duration (e.g. dt.truncate('2d')) is unsupported there; verified 2026-08-18, ibis 12.0.0", + "workaround": null, + "upstream_ref": null, + "since": "2026-08-18", + "native_errors": [], + "probe_exempt": null + }, + { + "dialect": "ibis-sqlite", + "param": "unit", + "option_value": null, + "value_class": "duration_multiplier", + "level": "unsupported", + "enforcement": "gate", + "boundary": "build", + "condition": null, + "message": "ibis-sqlite has no TimestampBucket compilation rule -- a multiplied MA duration (e.g. dt.truncate('2d')) is unsupported there; verified 2026-08-18, ibis 12.0.0", + "workaround": null, + "upstream_ref": null, + "since": "2026-08-18", + "native_errors": [], + "probe_exempt": null } ] }, @@ -71095,13 +71224,6 @@ "since": "2026-08-06" } ], - "gaps": [ - { - "gap_kind": "other", - "reason": "ibis-sqlite's TimestampBucket has no compilation rule, so a multi-digit MA-wrapper duration string (e.g. dt.truncate('2d')) on ibis-sqlite raises a raw native OperationNotDefinedError rather than a clean BackendCapabilityError. A DURATION_MULTIPLIER-class fact for this would need a corresponding class-backed OptionCell, which the 4-fixture argument-type matrix cannot instantiate for ibis-sqlite (same structural limit as test_option_fact_integrity.py's _MATRIX_UNREACHABLE_DIALECT_FACTS) -- see backlog item 99.", - "since": "2026-08-16", - "review_due": "2027-02-15" - } - ], + "gaps": [], "retired": [] } diff --git a/docs/reference/expression-coverage.md b/docs/reference/expression-coverage.md index 078d1aa7..66592ad3 100644 --- a/docs/reference/expression-coverage.md +++ b/docs/reference/expression-coverage.md @@ -3,7 +3,7 @@ -Declarations: 43 · Facts: 1462 · Registered operations: 326 · Implementation records: 978 +Declarations: 43 · Facts: 1466 · Registered operations: 326 · Implementation records: 978 Scoped deviations (dialect/param/option/value-class) live in [`expression-coverage-scoped.md`](expression-coverage-scoped.md). @@ -61,9 +61,9 @@ audited_unknown: 0 | Axis | Breakdown | | --- | --- | -| Level | expr_capable 149, literal_only 65, polymorphic 9, unsupported 1239 | -| Enforcement | gate 1455, router_metadata 3, materialize_residue 4 | -| Backend | polars 265, narwhals 586, ibis 611 | +| Level | expr_capable 149, literal_only 65, polymorphic 9, unsupported 1243 | +| Enforcement | gate 1459, router_metadata 3, materialize_residue 4 | +| Backend | polars 265, narwhals 586, ibis 615 | `pandas` / `pyarrow` are routed input types (they execute via the narwhals path) and are not independent coverage columns. @@ -72,7 +72,7 @@ audited_unknown: 0 | Backend | Source | Domain | Probe date | Library versions | Fixtures | | --- | --- | --- | --- | --- | --- | | ibis | mountainash | datetime | 2026-07-05 | | | -| ibis | mountainash | datetime | 2026-08-16 | | ibis-duckdb | +| ibis | mountainash | datetime | 2026-08-16 | | ibis-duckdb, ibis-sqlite | | ibis | mountainash | datetime | 2026-08-16 | | ibis-sqlite, ibis-polars, narwhals-polars, narwhals-pandas | | ibis | mountainash | relation | 2026-07-05 | | | | ibis | mountainash | set | — | — | — | @@ -135,7 +135,7 @@ audited_unknown: 0 | `ADD_MONTHS` | ✓ | ◐ partial (1 params, 0 option-selectors, 0 value-classes, 0 dialects) | ◐ partial (1 params, 0 option-selectors, 0 value-classes, 0 dialects) | | `ADD_SECONDS` | ✓ | ◐ partial (1 params, 0 option-selectors, 0 value-classes, 0 dialects) | ◐ partial (1 params, 0 option-selectors, 0 value-classes, 0 dialects) | | `ADD_YEARS` | ✓ | ◐ partial (1 params, 0 option-selectors, 0 value-classes, 0 dialects) | ◐ partial (1 params, 0 option-selectors, 0 value-classes, 0 dialects) | -| `CEIL` | ✓ | ◐ partial (1 params, 2 option-selectors, 0 value-classes, 2 dialects) | ◐ partial (1 params, 16 option-selectors, 0 value-classes, 2 dialects) | +| `CEIL` | ✓ | ◐ partial (1 params, 2 option-selectors, 0 value-classes, 2 dialects) | ◐ partial (1 params, 16 option-selectors, 1 value-classes, 2 dialects) | | `DATE` | ✓ | ✓ audited | ✓ audited | | `DAYS_IN_MONTH` | ✓ | ✓ audited | ✓ audited | | `DIFF_DAYS` | ✓ | ✓ audited | ✓ audited | @@ -161,14 +161,14 @@ audited_unknown: 0 | `EXTRACT_WEEK` | ✓ | ✓ audited | ✓ audited | | `EXTRACT_WEEKDAY` | ✓ | ✓ audited | ✓ audited | | `EXTRACT_YEAR` | ✓ | ✓ audited | ✓ audited | -| `FLOOR` | ✓ | ◐ partial (1 params, 2 option-selectors, 0 value-classes, 2 dialects) | ◐ partial (1 params, 12 option-selectors, 0 value-classes, 1 dialects) | +| `FLOOR` | ✓ | ◐ partial (1 params, 2 option-selectors, 0 value-classes, 2 dialects) | ◐ partial (1 params, 12 option-selectors, 1 value-classes, 1 dialects) | | `IS_DST` | ✓ | ✓ audited | ◐ partial (1 params, 0 option-selectors, 1 value-classes, 1 dialects) | | `IS_LEAP_YEAR` | ✓ | ✓ audited | ✓ audited | | `MONTH_END` | ✓ | ✓ audited | ✓ audited | | `MONTH_START` | ✓ | ✓ audited | ✓ audited | | `NOW` | ✓ | ✓ audited | ✓ audited | | `OFFSET_BY` | ✓ | ✓ audited | ✓ audited | -| `ROUND` | ✓ | ◐ partial (1 params, 2 option-selectors, 0 value-classes, 2 dialects) | ◐ partial (1 params, 16 option-selectors, 0 value-classes, 2 dialects) | +| `ROUND` | ✓ | ◐ partial (1 params, 2 option-selectors, 0 value-classes, 2 dialects) | ◐ partial (1 params, 16 option-selectors, 1 value-classes, 2 dialects) | | `TIME` | ✓ | ✓ audited | ✓ audited | | `TODAY` | ✓ | ✓ audited | ✓ audited | | `TOTAL_DAYS` | ✓ | ✓ audited | ✓ audited | @@ -179,7 +179,7 @@ audited_unknown: 0 | `TOTAL_NANOSECONDS` | ✓ | ✓ audited | ✓ audited | | `TOTAL_SECONDS` | ✓ | ✓ audited | ✓ audited | | `TO_TIMEZONE` | ✓ | ✓ audited | ◐ partial (1 params, 0 option-selectors, 1 value-classes, 1 dialects) | -| `TRUNCATE` | ✓ | ◐ partial (1 params, 2 option-selectors, 0 value-classes, 2 dialects) | ◐ partial (1 params, 12 option-selectors, 0 value-classes, 1 dialects) | +| `TRUNCATE` | ✓ | ◐ partial (1 params, 2 option-selectors, 0 value-classes, 2 dialects) | ◐ partial (1 params, 12 option-selectors, 1 value-classes, 1 dialects) | ### `FKEY_MOUNTAINASH_SCALAR_LIST` (mountainash / list) @@ -591,9 +591,7 @@ Cells whose facts are all scoped (dialect / parameter / option / value-class) ha ## Known gaps -| Kind | Reason | Since | Review due | -| --- | --- | --- | --- | -| other | ibis-sqlite's TimestampBucket has no compilation rule, so a multi-digit MA-wrapper duration string (e.g. dt.truncate('2d')) on ibis-sqlite raises a raw native OperationNotDefinedError rather than a clean BackendCapabilityError. A DURATION_MULTIPLIER-class fact for this would need a corresponding class-backed OptionCell, which the 4-fixture argument-type matrix cannot instantiate for ibis-sqlite (same structural limit as test_option_fact_integrity.py's _MATRIX_UNREACHABLE_DIALECT_FACTS) -- see backlog item 99. | 2026-08-16 | 2027-02-15 | +None recorded. ## Retirement changelog diff --git a/src/mountainash/core/capabilities/gaps.py b/src/mountainash/core/capabilities/gaps.py index 41d5889b..b575f921 100644 --- a/src/mountainash/core/capabilities/gaps.py +++ b/src/mountainash/core/capabilities/gaps.py @@ -6,22 +6,9 @@ """ from __future__ import annotations -from mountainash.core.capabilities.schema import GapKind, KnownGap +from typing import TYPE_CHECKING -KNOWN_GAPS: tuple[KnownGap, ...] = ( - KnownGap( - gap_kind=GapKind.OTHER, - reason=( - "ibis-sqlite's TimestampBucket has no compilation rule, so a " - "multi-digit MA-wrapper duration string (e.g. dt.truncate('2d')) " - "on ibis-sqlite raises a raw native OperationNotDefinedError " - "rather than a clean BackendCapabilityError. A " - "DURATION_MULTIPLIER-class fact for this would need a " - "corresponding class-backed OptionCell, which the 4-fixture " - "argument-type matrix cannot instantiate for ibis-sqlite (same " - "structural limit as test_option_fact_integrity.py's " - "_MATRIX_UNREACHABLE_DIALECT_FACTS) -- see backlog item 99." - ), - since="2026-08-16", - ), -) +if TYPE_CHECKING: + from mountainash.core.capabilities.schema import KnownGap + +KNOWN_GAPS: tuple[KnownGap, ...] = () diff --git a/src/mountainash/expressions/backends/capabilities/datetime/value_classes_ma.py b/src/mountainash/expressions/backends/capabilities/datetime/value_classes_ma.py index 6801c239..3b109601 100644 --- a/src/mountainash/expressions/backends/capabilities/datetime/value_classes_ma.py +++ b/src/mountainash/expressions/backends/capabilities/datetime/value_classes_ma.py @@ -65,17 +65,21 @@ ) _SINCE = "2026-08-16" - -# Known residual gap (documented, not enforced by a fact -- see module -# docstring): ibis-sqlite's TimestampBucket has no compilation rule, so a -# multi-digit MA-wrapper duration string (e.g. dt.truncate("2d")) on -# ibis-sqlite raises a raw native OperationNotDefinedError rather than a -# clean BackendCapabilityError. A DURATION_MULTIPLIER-class fact for this -# would need a corresponding class-backed OptionCell, which the 4-fixture -# argument-type matrix cannot instantiate for ibis-sqlite (same structural -# limit documented in test_option_fact_integrity.py's -# _MATRIX_UNREACHABLE_DIALECT_FACTS) -- tracked as a backlog follow-up -# rather than adding an untested, unexercised fact here. +_MULTIPLIER_SINCE = "2026-08-18" + +# ibis-sqlite multiplier gap (backlog item 99, enforced since 2026-08-18): +# ibis's TimestampBucket (multiple > 1) has no sqlite compilation rule, so a +# multiplied MA-wrapper duration string (e.g. dt.truncate("2d")) raises a raw +# native OperationNotDefinedError unless gated. The DURATION_MULTIPLIER +# value-class facts below declare it UNSUPPORTED so the visitor option gate +# raises a clean BackendCapabilityError at build time. A value-class fact is +# sound here because the api-builder validates `unit` to exactly this +# predicate's domain (MA_OPTION_DOMAINS / validate_open_value, spec §3.2). +# The 4-fixture argument-type matrix cannot instantiate ibis-sqlite, so the +# facts are verified by the dedicated TestMaMultiplierIbisSqliteGate gate +# tests (tests/expressions/cross_backend/test_datetime_rounding.py) and are +# exempted from the matrix-exercise guard in test_option_fact_integrity.py's +# class arm. _TO_TIMEZONE_MSG = ( "to_timezone is correct only at the materialization boundary -- the " @@ -117,13 +121,36 @@ def _is_dst_fact(backend, dialect: str | None, message: str) -> CapabilityFact: ) +_MA_MULTIPLIER_MSG = ( + "ibis-sqlite has no TimestampBucket compilation rule -- a multiplied MA " + "duration (e.g. dt.truncate('2d')) is unsupported there; verified " + "2026-08-18, ibis 12.0.0" +) + + +def _ma_multiplier_facts() -> tuple[CapabilityFact, ...]: + return tuple( + CapabilityFact( + operation_key=fkey, + param="unit", + value_class=ValueClass.DURATION_MULTIPLIER, + level=CapabilityLevel.UNSUPPORTED, + backend=CONST_BACKEND.IBIS, + dialect="ibis-sqlite", + message=_MA_MULTIPLIER_MSG, + since=_MULTIPLIER_SINCE, + ) + for fkey in (FK_DT.TRUNCATE, FK_DT.ROUND, FK_DT.CEIL, FK_DT.FLOOR) + ) + + _IBIS_FACTS = tuple( _tz_fact(CONST_BACKEND.IBIS, dialect, _TO_TIMEZONE_MSG) for dialect in (None, "ibis-duckdb") ) + tuple( _is_dst_fact(CONST_BACKEND.IBIS, dialect, _IS_DST_MSG) for dialect in (None, "ibis-duckdb") -) +) + _ma_multiplier_facts() # narwhals honors every multiplied unit on all four ops now -- no facts. _NARWHALS_FACTS: tuple[CapabilityFact, ...] = () @@ -138,7 +165,7 @@ def _is_dst_fact(backend, dialect: str | None, message: str) -> CapabilityFact: _EVIDENCE = ProbeEvidence( probe_date=_SINCE, library_versions=(), - fixtures=("ibis-duckdb",), + fixtures=("ibis-duckdb", "ibis-sqlite"), ) _NARWHALS_EVIDENCE = ProbeEvidence( probe_date=_SINCE, diff --git a/tests/expressions/argument_types/test_option_fact_integrity.py b/tests/expressions/argument_types/test_option_fact_integrity.py index 44efa6fa..e230df50 100644 --- a/tests/expressions/argument_types/test_option_fact_integrity.py +++ b/tests/expressions/argument_types/test_option_fact_integrity.py @@ -273,6 +273,20 @@ def test_no_op_level_fact_is_left_unbacked() -> None: } +# Value-class facts whose (backend, dialect) the 4-fixture matrix cannot +# instantiate either (same structural limit as _MATRIX_UNREACHABLE_DIALECT_FACTS +# above, but for class-keyed facts: no option_value means they cannot enter that +# exact-value set). Verified instead by dedicated gate tests that build a real +# ibis-sqlite backend -- TestMaMultiplierIbisSqliteGate in +# test_datetime_rounding.py (backlog item 99: the MA wrapper multiplier +# durations dt.truncate('2d') etc. hit ibis-sqlite's missing TimestampBucket +# compilation rule). +_UNREACHABLE_DIALECT_CLASS_FACTS = { + (fkey, "unit", CONST_BACKEND.IBIS, "ibis-sqlite") + for fkey in (FK_MA_DT.TRUNCATE, FK_MA_DT.ROUND, FK_MA_DT.CEIL, FK_MA_DT.FLOOR) +} + + def test_no_stale_matrix_unreachable_dialect_fact_entries() -> None: """Every _MATRIX_UNREACHABLE_DIALECT_FACTS entry must still be a genuine, registered fact whose dialect the matrix's 4 fixtures cannot reach — if @@ -295,6 +309,31 @@ def test_no_stale_matrix_unreachable_dialect_fact_entries() -> None: ) +def test_no_stale_unreachable_dialect_class_fact_entries() -> None: + """Every _UNREACHABLE_DIALECT_CLASS_FACTS entry must still be a genuine, + registered value-class fact whose dialect the matrix's 4 fixtures cannot + reach — mirror of test_no_stale_matrix_unreachable_dialect_fact_entries + for the class arm (backlog item 99).""" + registered_class = { + (fact.operation_key, fact.param, fact.backend, fact.dialect) + for fact in CapabilityRegistry.facts() + if fact.value_class is not None and fact.level in _GATING + } + matrix_dialects = { + dialect for _family, dialect in disposition._FIXTURE_IDENTITY.values() + } + for key in _UNREACHABLE_DIALECT_CLASS_FACTS: + assert key in registered_class, ( + f"Stale _UNREACHABLE_DIALECT_CLASS_FACTS entry: {key} is no " + "longer a registered value-class fact — remove it." + ) + assert key[3] not in matrix_dialects, ( + f"Stale _UNREACHABLE_DIALECT_CLASS_FACTS entry: {key} — its " + f"dialect {key[3]!r} is now reachable by the matrix's fixtures; " + "remove this entry and let the class arm cover it directly." + ) + + def test_declared_cells_and_option_facts_are_mutually_backed() -> None: # 1. Exact Arm: exact-backed declared cells <-> exact value-scoped facts. # Op-level cells are backed by WILDCARD_PARAM facts (Task 4 / PR-B), not @@ -332,6 +371,8 @@ def test_declared_cells_and_option_facts_are_mutually_backed() -> None: if fact.value_class is not None and fact.level in _GATING and fact.dialect is not None + and (fact.operation_key, fact.param, fact.backend, fact.dialect) + not in _UNREACHABLE_DIALECT_CLASS_FACTS } resolved_class_facts = { disposition.resolve_cell_class_fact(cell) diff --git a/tests/expressions/cross_backend/test_datetime_rounding.py b/tests/expressions/cross_backend/test_datetime_rounding.py index 3416becc..9e996468 100644 --- a/tests/expressions/cross_backend/test_datetime_rounding.py +++ b/tests/expressions/cross_backend/test_datetime_rounding.py @@ -281,3 +281,40 @@ def test_raises_capability_error(self, backend_name, backend_factory, collect_ex expr = ma.col("ts").dt.round_calendar(rounding="FLOOR", unit="MONTH", multiple=3) with pytest.raises(BackendCapabilityError, match="TimestampBucket"): collect_expr(df, expr) + + +@pytest.mark.cross_backend +@pytest.mark.parametrize("backend_name", ["ibis-sqlite"]) +class TestMaMultiplierIbisSqliteGate: + """Backlog item 99: the MA wrappers (dt.truncate/round/ceil/floor) accept + multiplier durations ("2d"/"3h"/"12mo") and on ibis-sqlite those reach + TimestampBucket, which has no sqlite compilation rule. The + DURATION_MULTIPLIER value-class fact must raise a clean + BackendCapabilityError at build time, never a raw + OperationNotDefinedError. Single-unit durations ("1d") are unaffected.""" + + @pytest.mark.parametrize( + ("op", "unit"), + [ + ("truncate", "2d"), + ("round", "3h"), + ("ceil", "12mo"), + ("floor", "2w"), + ], + ) + def test_multiplier_unit_raises_capability_error( + self, backend_name, op, unit, backend_factory, collect_expr + ): + df = backend_factory.create(_MULTI_DATA, backend_name) + expr = getattr(ma.col("ts").dt, op)(unit) + with pytest.raises(BackendCapabilityError, match="TimestampBucket"): + collect_expr(df, expr) + + @pytest.mark.parametrize("op", ["truncate", "round", "ceil", "floor"]) + def test_single_unit_still_compiles( + self, backend_name, op, backend_factory, collect_expr + ): + df = backend_factory.create(_MULTI_DATA, backend_name) + expr = getattr(ma.col("ts").dt, op)("1d") + actual = collect_expr(df, expr) + assert len(actual) == 1 diff --git a/tests/expressions/cross_backend/test_datetime_value_class_dispatch.py b/tests/expressions/cross_backend/test_datetime_value_class_dispatch.py index e4911ae9..1a61da93 100644 --- a/tests/expressions/cross_backend/test_datetime_value_class_dispatch.py +++ b/tests/expressions/cross_backend/test_datetime_value_class_dispatch.py @@ -20,12 +20,18 @@ are honored on every fixture — no facts. DURATION_MULTIPLIER (integer unit multipliers >= 2, e.g. "2d"/"3h"/"12mo") -facts and this file's former `test_multiplier_gate` regression were RETIRED -by item 74: truncate/round_dt/ceil_dt/floor_dt now redirect through the real +facts for the cross-backend fixtures and this file's former +`test_multiplier_gate` regression were RETIRED by item 74: +truncate/round_dt/ceil_dt/floor_dt now redirect through the real round_temporal/round_calendar implementation instead of a silent-wrong truncate fallback, so every fixture honors every multiplier value (see `src/mountainash/expressions/backends/capabilities/datetime/value_classes_ma.py` -module docstring for the re-probed disposition table). +module docstring for the re-probed disposition table). The one exception is +ibis-sqlite (missing TimestampBucket compilation rule) — its +DURATION_MULTIPLIER class facts were re-registered by backlog item 99 and +are gated by TestMaMultiplierIbisSqliteGate in +`tests/expressions/cross_backend/test_datetime_rounding.py`, outside this +file's 4-fixture matrix (which has no ibis-sqlite fixture). """ from __future__ import annotations