From 2d59563bc59f10d84d17888f7b169a2656c5ef65 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Sat, 15 Aug 2026 15:09:13 +1000 Subject: [PATCH 1/5] feat(capabilities): add predicate schema types + CapabilityFact.predicate (66b) --- src/mountainash/core/capabilities/schema.py | 114 +++++++++++++++++ tests/core/test_capability_predicates.py | 132 ++++++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 tests/core/test_capability_predicates.py diff --git a/src/mountainash/core/capabilities/schema.py b/src/mountainash/core/capabilities/schema.py index 98419b57..c6e6409b 100644 --- a/src/mountainash/core/capabilities/schema.py +++ b/src/mountainash/core/capabilities/schema.py @@ -99,6 +99,83 @@ class ValueClass(Enum): POLARS_OFFSET = "polars_offset" # signed Polars duration string +class ClauseOp(Enum): + """Closed predicate operator set (spec §4.2). Extending is a spec change.""" + EQ = "eq" # resolved value equals a scalar/enum operand + IN = "in" # resolved value is a member of a frozenset + IS_SET = "is_set" # resolved value is non-None + IS_NULL = "is_null" # resolved value is None + IS_LITERAL = "is_literal" # root param's bound value is a LiteralNode + MATCHES_CLASS = "matches_class" # value_classes.matches(operand, value) + + +# Closed, hashable operand union (spec §4.3). ValueClass is an Enum, so EQ +# validation must exclude it explicitly. +Operand = str | int | bool | Enum | frozenset[str | int] | ValueClass | None + + +def _operand_key(operand: Operand) -> tuple: + if operand is None: + return (0,) + if isinstance(operand, frozenset): + return (1, tuple(sorted(str(m) for m in operand))) + if isinstance(operand, ValueClass): + return (2, operand.value) + if isinstance(operand, Enum): + return (3, type(operand).__name__, operand.value) + return (4, operand) + + +def _clause_key(clause: "Clause") -> tuple: + return (clause.path, clause.op.name, _operand_key(clause.operand)) + + +def _validate_clause(clause: "Clause") -> None: + if not clause.path: + raise ValueError("Clause path must be non-empty") + op, operand = clause.op, clause.operand + if op in (ClauseOp.IS_SET, ClauseOp.IS_NULL, ClauseOp.IS_LITERAL): + if operand is not None: + raise ValueError(f"Clause {op.name} takes no operand, got {operand!r}") + elif op is ClauseOp.EQ: + if isinstance(operand, ValueClass) or not isinstance(operand, (str, int, bool, Enum)): + raise ValueError(f"Clause EQ operand must be a scalar/enum, got {operand!r}") + elif op is ClauseOp.IN: + if not isinstance(operand, frozenset) or not all( + isinstance(m, (str, int)) for m in operand + ): + raise ValueError(f"Clause IN operand must be frozenset[str|int], got {operand!r}") + elif op is ClauseOp.MATCHES_CLASS: + if not isinstance(operand, ValueClass): + raise ValueError(f"Clause MATCHES_CLASS operand must be a ValueClass, got {operand!r}") + else: + raise ValueError(f"unknown ClauseOp {op!r}") + + +@dataclass(frozen=True) +class Clause: + path: str + op: ClauseOp + operand: Operand = None + + def __post_init__(self) -> None: + _validate_clause(self) + + +@dataclass(frozen=True) +class Predicate: + """Immutable conjunction of clauses; canonical order, order-insensitive eq/hash.""" + clauses: tuple[Clause, ...] + + def __post_init__(self) -> None: + clauses = self.clauses + if not clauses: + raise ValueError("Predicate must have at least one clause") + if len(set(clauses)) != len(clauses): + raise ValueError("Predicate must not contain duplicate clauses") + object.__setattr__(self, "clauses", tuple(sorted(clauses, key=_clause_key))) + + def _validate_since(since: str, owner: str) -> None: if not _SINCE_RE.match(since): raise ValueError(f"{owner}: since must be YYYY-MM-DD, got {since!r}") @@ -126,6 +203,7 @@ class CapabilityFact: # (validated in register_backend — spec 2026-07-06) value_class: ValueClass | None = None # value-class fact; option_value MUST be None enforcement: Enforcement = Enforcement.GATE # what the system does; condition is prose only + predicate: Predicate | None = None # compound co-value limit (§4); None = param-keyed fact def __post_init__(self) -> None: _validate_since(self.since, f"CapabilityFact({self.operation_key}, {self.param})") @@ -186,6 +264,42 @@ def __post_init__(self) -> None: "enrichment; see the 66a compatibility table" ) + if self.predicate is not None: + if self.boundary is not Boundary.BUILD: + raise ValueError( + f"CapabilityFact({self.operation_key}, {self.param}): predicate " + "facts must use the BUILD boundary (§4.5)" + ) + if self.option_value is not None or self.value_class is not None: + raise ValueError( + f"CapabilityFact({self.operation_key}, {self.param}): a predicate " + "fact is value-agnostic — the predicate carries the value scoping; " + "option_value and value_class must be None" + ) + if self.param == WILDCARD_PARAM: + raise ValueError( + f"CapabilityFact({self.operation_key}, {self.param}): a predicate " + "fact cannot use WILDCARD_PARAM" + ) + if self.enforcement is not Enforcement.GATE: + raise ValueError( + f"CapabilityFact({self.operation_key}, {self.param}): a predicate " + "fact has no consuming path for non-GATE enforcement roles — " + "predicate facts gate" + ) + if self.level not in (CapabilityLevel.UNSUPPORTED, CapabilityLevel.EXPR_CAPABLE): + raise ValueError( + f"CapabilityFact({self.operation_key}, {self.param}): a predicate " + "fact must be UNSUPPORTED (blocking) or EXPR_CAPABLE (permitting " + "refinement) — LITERAL_ONLY/POLYMORPHIC have no predicate enforcement path" + ) + roots = {c.path.split(".")[0] for c in self.predicate.clauses} + if self.param not in roots: + raise ValueError( + f"CapabilityFact({self.operation_key}, {self.param!r}): param must " + f"be one of the predicate's clause roots {sorted(roots)}" + ) + class DivergenceKind(Enum): SEMANTICS = "semantics" diff --git a/tests/core/test_capability_predicates.py b/tests/core/test_capability_predicates.py new file mode 100644 index 00000000..4108c4f8 --- /dev/null +++ b/tests/core/test_capability_predicates.py @@ -0,0 +1,132 @@ +"""Predicate schema + engine tests (backlog 66b, spec 2026-07-28).""" +from __future__ import annotations + +import pytest + +from mountainash.core.capabilities.schema import ( + Boundary, CapabilityFact, CapabilityLevel, Clause, ClauseOp, Enforcement, + Predicate, ValueClass, +) +from mountainash.core.constants import CONST_BACKEND + + +def test_clause_eq_requires_scalar_operand(): + with pytest.raises(ValueError, match="EQ"): + Clause("unit", ClauseOp.EQ, frozenset({"WEEK"})) # frozenset is IN's operand + + +def test_clause_eq_rejects_value_class_as_scalar(): + # ValueClass is an Enum, so it must be explicitly excluded from EQ operands. + with pytest.raises(ValueError, match="EQ"): + Clause("unit", ClauseOp.EQ, ValueClass.DURATION_MULTIPLIER) + + +def test_clause_in_requires_frozenset_of_str_int(): + with pytest.raises(ValueError, match="IN"): + Clause("unit", ClauseOp.IN, "WEEK") + + +def test_clause_nullary_ops_take_no_operand(): + for op in (ClauseOp.IS_SET, ClauseOp.IS_NULL, ClauseOp.IS_LITERAL): + with pytest.raises(ValueError, match=op.name): + Clause("unit", op, "WEEK") + + +def test_clause_matches_class_requires_value_class(): + with pytest.raises(ValueError, match="MATCHES_CLASS"): + Clause("unit", ClauseOp.MATCHES_CLASS, "WEEK") + assert Clause("unit", ClauseOp.MATCHES_CLASS, ValueClass.DURATION_MULTIPLIER) is not None + + +def test_predicate_rejects_empty(): + with pytest.raises(ValueError, match="at least one"): + Predicate(()) + + +def test_predicate_rejects_duplicate_clauses(): + c = Clause("unit", ClauseOp.EQ, "WEEK") + with pytest.raises(ValueError, match="duplicate"): + Predicate((c, c)) + + +def test_predicate_canonical_order_is_order_insensitive(): + a = Clause("unit", ClauseOp.EQ, "WEEK") + b = Clause("origin", ClauseOp.IN, frozenset({"ISO"})) + assert Predicate((a, b)) == Predicate((b, a)) + assert hash(Predicate((a, b))) == hash(Predicate((b, a))) + + +def test_fact_predicate_must_be_build_boundary(): + with pytest.raises(ValueError, match="BUILD"): + CapabilityFact( + operation_key="TRUNCATE", param="unit", level=CapabilityLevel.UNSUPPORTED, + backend=CONST_BACKEND.IBIS, dialect="ibis-duckdb", message="x", + since="2026-08-15", + predicate=Predicate((Clause("unit", ClauseOp.EQ, "WEEK"),)), + boundary=Boundary.MATERIALIZE, + native_errors=(ValueError,), # satisfy the MATERIALIZE-native_errors check first + ) + + +def test_fact_predicate_is_value_agnostic(): + with pytest.raises(ValueError, match="value-agnostic"): + CapabilityFact( + operation_key="TRUNCATE", param="unit", level=CapabilityLevel.UNSUPPORTED, + backend=CONST_BACKEND.IBIS, dialect="ibis-duckdb", message="x", + since="2026-08-15", option_value="WEEK", + predicate=Predicate((Clause("unit", ClauseOp.EQ, "WEEK"),)), + ) + + +def test_fact_predicate_rejects_wildcard_param(): + with pytest.raises(ValueError, match="WILDCARD_PARAM"): + CapabilityFact( + operation_key="TRUNCATE", param="*", level=CapabilityLevel.UNSUPPORTED, + backend=CONST_BACKEND.IBIS, dialect="ibis-duckdb", message="x", + since="2026-08-15", + predicate=Predicate((Clause("unit", ClauseOp.EQ, "WEEK"),)), + ) + + +def test_fact_predicate_param_must_be_a_clause_root(): + with pytest.raises(ValueError, match="clause roots"): + CapabilityFact( + operation_key="TRUNCATE", param="other", level=CapabilityLevel.UNSUPPORTED, + backend=CONST_BACKEND.IBIS, dialect="ibis-duckdb", message="x", + since="2026-08-15", + predicate=Predicate((Clause("unit", ClauseOp.EQ, "WEEK"),)), + ) + + +def test_fact_predicate_requires_gate_enforcement(): + with pytest.raises(ValueError, match="GATE"): + CapabilityFact( + operation_key="TRUNCATE", param="unit", level=CapabilityLevel.UNSUPPORTED, + backend=CONST_BACKEND.IBIS, dialect="ibis-duckdb", message="x", + since="2026-08-15", + predicate=Predicate((Clause("unit", ClauseOp.EQ, "WEEK"),)), + enforcement=Enforcement.ROUTER_METADATA, + ) + + +def test_fact_predicate_rejects_literal_only_level(): + # LITERAL_ONLY/POLYMORPHIC semantics live in the per-param loop; a predicate + # fact at those levels would be silently unenforceable (review finding 3). + with pytest.raises(ValueError, match="UNSUPPORTED.*EXPR_CAPABLE"): + CapabilityFact( + operation_key="TRUNCATE", param="unit", level=CapabilityLevel.LITERAL_ONLY, + backend=CONST_BACKEND.IBIS, dialect="ibis-duckdb", message="x", + since="2026-08-15", + predicate=Predicate((Clause("unit", ClauseOp.EQ, "WEEK"),)), + ) + + +def test_valid_predicate_fact_constructs(): + f = CapabilityFact( + operation_key="TRUNCATE", param="unit", level=CapabilityLevel.UNSUPPORTED, + backend=CONST_BACKEND.IBIS, dialect="ibis-duckdb", message="x", + since="2026-08-15", + predicate=Predicate((Clause("unit", ClauseOp.EQ, "WEEK"),)), + ) + assert f.predicate is not None + assert f.option_value is None and f.value_class is None From b3a47e37c14b03ac3a6cf9b3536eae8a405b6dc3 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Sat, 15 Aug 2026 15:12:32 +1000 Subject: [PATCH 2/5] =?UTF-8?q?feat(capabilities):=20add=20predicate=20eng?= =?UTF-8?q?ine=20=E2=80=94=20BoundCall,=20evaluation,=20subsumption=20(66b?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mountainash/core/capabilities/__init__.py | 8 + .../core/capabilities/predicates.py | 214 ++++++++++++++++++ tests/core/test_capability_predicates.py | 134 +++++++++++ 3 files changed, 356 insertions(+) create mode 100644 src/mountainash/core/capabilities/predicates.py diff --git a/src/mountainash/core/capabilities/__init__.py b/src/mountainash/core/capabilities/__init__.py index 623dd0dd..8b64d1bf 100644 --- a/src/mountainash/core/capabilities/__init__.py +++ b/src/mountainash/core/capabilities/__init__.py @@ -10,18 +10,22 @@ classify_source, ) from mountainash.core.capabilities.identity import BackendIdentity, KNOWN_DIALECTS +from mountainash.core.capabilities.predicates import BoundCall from mountainash.core.capabilities.registry import CapabilityRegistry, CapabilityViolation from mountainash.core.capabilities.retired import RETIRED_FACTS, RetiredFact from mountainash.core.capabilities.schema import ( Boundary, CapabilityFact, CapabilityLevel, + Clause, + ClauseOp, DivergenceFact, DivergenceKind, Enforcement, Fidelity, GapKind, KnownGap, + Predicate, TargetKind, ValueClass, WILDCARD_PARAM, @@ -29,6 +33,7 @@ __all__ = [ "BackendIdentity", + "BoundCall", "Boundary", "CapabilityDeclaration", "CapabilityDeclarationModule", @@ -36,6 +41,8 @@ "CapabilityLevel", "CapabilityRegistry", "CapabilityViolation", + "Clause", + "ClauseOp", "DivergenceFact", "DivergenceKind", "Domain", @@ -45,6 +52,7 @@ "GapKind", "KNOWN_DIALECTS", "KnownGap", + "Predicate", "ProbeEvidence", "RETIRED_FACTS", "RetiredFact", diff --git a/src/mountainash/core/capabilities/predicates.py b/src/mountainash/core/capabilities/predicates.py new file mode 100644 index 00000000..33e714f8 --- /dev/null +++ b/src/mountainash/core/capabilities/predicates.py @@ -0,0 +1,214 @@ +"""Predicate engine for compound capability facts (spec 2026-07-28, backlog 66b). + +Bound-call interface (§5) and clause evaluation/subsumption/overlap (§4, §8). +Deliberately import-free of the expression visitor at module level: the lazy +import in bind_expression_call mirrors registry._definition_for and avoids a +core -> expressions import cycle. +""" +from __future__ import annotations + +import inspect +from collections.abc import Mapping +from dataclasses import dataclass, fields, is_dataclass +from enum import Enum +from typing import Any + +from mountainash.core.capabilities.schema import Clause, ClauseOp, Predicate + + +@dataclass(frozen=True) +class BoundCall: + """A bound operation call (§5). Not hashable (bindings is a Mapping).""" + operation_key: Any + backend: Any # CONST_BACKEND + dialect: str | None + bindings: Mapping[str, Any] # param name -> bound value or AST node + supplied: frozenset[str] # params the caller actually passed + + +def _unwrap_literal(value: Any) -> Any: + from mountainash.expressions.core.expression_nodes import LiteralNode + return value.value if isinstance(value, LiteralNode) else value + + +def bind_expression_call(*, operation_key, backend, dialect, protocol_method, + arguments, options) -> BoundCall: + from mountainash.expressions.core.unified_visitor.visitor import ( + _param_name_for, _protocol_sig_params, + ) + sig_params = _protocol_sig_params(protocol_method) + var_positional_name = next( + (p.name for p in sig_params if p.kind is inspect.Parameter.VAR_POSITIONAL), None + ) + bindings: dict[str, Any] = {} + supplied: set[str] = set() + for i, arg in enumerate(arguments): + name = _param_name_for(sig_params, i) + if name is None: + continue + supplied.add(name) + if name == var_positional_name: + bindings.setdefault(name, []) + bindings[name].append(arg) + else: + bindings[name] = arg + for option_name, option_value in (options or {}).items(): + bindings[option_name] = option_value + supplied.add(option_name) + # Defaults are applied to bindings (§5) but stay out of supplied. + for p in sig_params: + if p.name not in supplied and p.default is not inspect.Parameter.empty: + bindings[p.name] = p.default + if var_positional_name is not None and isinstance(bindings.get(var_positional_name), list): + bindings[var_positional_name] = tuple(bindings[var_positional_name]) + return BoundCall( + operation_key=operation_key, backend=backend, dialect=dialect, + bindings=bindings, supplied=frozenset(supplied), + ) + + +def _declared_fields(value: Any) -> set[str] | None: + if hasattr(value, "model_fields"): # Pydantic v2 + return set(value.model_fields) + if hasattr(value, "__fields__"): # Pydantic v1 + return set(value.__fields__) + if is_dataclass(value) and not isinstance(value, type): + return {f.name for f in fields(value)} + return None + + +def _resolve_segment(value: Any, seg: str, path: str) -> Any: + if isinstance(value, Mapping): + if seg in value: + return value[seg] + raise ValueError(f"predicate path {path!r}: mapping has no key {seg!r}") + if isinstance(value, Enum): + if seg in ("name", "value"): + return getattr(value, seg) + raise ValueError( + f"predicate path {path!r}: enum {type(value).__name__} has no attribute {seg!r}" + ) + declared = _declared_fields(value) + if declared is not None: + if seg not in declared: + raise ValueError( + f"predicate path {path!r}: {type(value).__name__} has no declared field {seg!r}" + ) + return getattr(value, seg) # declared field only: no user code runs + raise ValueError( + f"predicate path {path!r}: cannot traverse {seg!r} through " + f"{type(value).__name__} (not a mapping, enum, or declared model)" + ) + + +def resolve_path(bindings: Mapping[str, Any], path: str) -> Any: + segments = path.split(".") + head = segments[0] + if head not in bindings: + raise ValueError(f"predicate path {path!r}: parameter {head!r} is not bound") + value = _unwrap_literal(bindings[head]) # unwrap LiteralNode at the root + for seg in segments[1:]: + if value is None: + raise ValueError( + f"predicate path {path!r}: cannot traverse {seg!r} through None" + ) + value = _resolve_segment(value, seg, path) + return value + + +def evaluate_clause(clause: Clause, bindings: Mapping[str, Any], + supplied: frozenset[str]) -> bool: + from mountainash.expressions.core.expression_nodes import ExpressionNode, LiteralNode + + root = clause.path.split(".")[0] + if clause.op is ClauseOp.IS_LITERAL: + return isinstance(bindings.get(root), LiteralNode) + value = resolve_path(bindings, clause.path) # LiteralNode already unwrapped at root + if clause.op is ClauseOp.IS_NULL: + return value is None + if clause.op is ClauseOp.IS_SET: + return value is not None + # A dynamic (non-literal) expression makes value-comparing clauses False (§4.4). + if isinstance(value, ExpressionNode): + return False + if clause.op is ClauseOp.MATCHES_CLASS: + from mountainash.core.capabilities.value_classes import matches + return isinstance(value, str) and matches(clause.operand, value) + if clause.op is ClauseOp.EQ: + return value == clause.operand + if clause.op is ClauseOp.IN: + return value in clause.operand + raise ValueError(f"unknown ClauseOp {clause.op!r}") + + +def predicate_holds(predicate: Predicate, bindings: Mapping[str, Any], + supplied: frozenset[str]) -> bool: + return all(evaluate_clause(c, bindings, supplied) for c in predicate.clauses) + + +def clause_implies(a: Clause, b: Clause) -> bool: + """Sound: True only when a genuinely implies b (same path).""" + if a.path != b.path: + return False + if a.op is b.op and a.operand == b.operand: + return True + if a.op is ClauseOp.EQ: + x = a.operand + if b.op is ClauseOp.EQ: + return x == b.operand + if b.op is ClauseOp.IN: + return x in b.operand + if b.op is ClauseOp.IS_SET: + return x is not None + if b.op is ClauseOp.MATCHES_CLASS: + from mountainash.core.capabilities.value_classes import matches + return isinstance(x, str) and matches(b.operand, x) + return False + if a.op is ClauseOp.IN: + if b.op is ClauseOp.IN: + return a.operand <= b.operand + if b.op is ClauseOp.IS_SET: + return True # IN operands are non-None frozensets of str|int + if b.op is ClauseOp.MATCHES_CLASS: + from mountainash.core.capabilities.value_classes import matches + return all(isinstance(m, str) and matches(b.operand, m) for m in a.operand) + return False + if a.op is ClauseOp.MATCHES_CLASS: + return b.op is ClauseOp.IS_SET # a matching value is non-None + return False # IS_SET/IS_NULL/IS_LITERAL imply only themselves + + +def predicate_implies(a: Predicate, b: Predicate) -> bool: + return all( + any(clause_implies(ca, cb) for ca in a.clauses) for cb in b.clauses + ) + + +def _clauses_exclusive(a: Clause, b: Clause) -> bool: + """Conservative: True only when a and b are PROVABLY mutually exclusive.""" + if a.path != b.path: + return False + if a.op is ClauseOp.EQ and b.op is ClauseOp.EQ: + return a.operand != b.operand + if a.op is ClauseOp.EQ and b.op is ClauseOp.IN: + return a.operand not in b.operand + if b.op is ClauseOp.EQ and a.op is ClauseOp.IN: + return b.operand not in a.operand + if a.op is ClauseOp.IN and b.op is ClauseOp.IN: + return not (a.operand & b.operand) + if {a.op, b.op} == {ClauseOp.IS_SET, ClauseOp.IS_NULL}: + return True + if a.op is ClauseOp.IS_NULL and b.op is ClauseOp.IN: + return True + if b.op is ClauseOp.IS_NULL and a.op is ClauseOp.IN: + return True + if a.op is ClauseOp.IS_NULL and b.op is ClauseOp.MATCHES_CLASS: + return True + if b.op is ClauseOp.IS_NULL and a.op is ClauseOp.MATCHES_CLASS: + return True + return False + + +def predicates_overlap(a: Predicate, b: Predicate) -> bool: + """Conservative satisfiability: overlap unless some clause pair is exclusive.""" + return not any(_clauses_exclusive(ca, cb) for ca in a.clauses for cb in b.clauses) diff --git a/tests/core/test_capability_predicates.py b/tests/core/test_capability_predicates.py index 4108c4f8..287c12e7 100644 --- a/tests/core/test_capability_predicates.py +++ b/tests/core/test_capability_predicates.py @@ -130,3 +130,137 @@ def test_valid_predicate_fact_constructs(): ) assert f.predicate is not None assert f.option_value is None and f.value_class is None + + +from mountainash.core.capabilities.predicates import ( + BoundCall, bind_expression_call, clause_implies, evaluate_clause, + predicate_holds, predicate_implies, predicates_overlap, resolve_path, +) +from mountainash.expressions.core.expression_nodes import ExpressionNode, LiteralNode + + +class _DynamicExpr(ExpressionNode): + """Minimal concrete non-literal expression node for dynamic-arg tests.""" + def accept(self, visitor, **kwargs): + raise NotImplementedError + + +def _bound_call(**bindings): + return BoundCall( + operation_key="TRUNCATE", backend=CONST_BACKEND.IBIS, dialect="ibis-duckdb", + bindings=bindings, supplied=frozenset(bindings), + ) + + +def test_evaluate_eq_in_null_set_literal(): + bc = _bound_call(unit="WEEK", origin="ISO", multiple=LiteralNode(value=2)) + assert evaluate_clause(Clause("unit", ClauseOp.EQ, "WEEK"), bc.bindings, bc.supplied) + assert evaluate_clause(Clause("origin", ClauseOp.IN, frozenset({"ISO", "REGULAR"})), bc.bindings, bc.supplied) + assert evaluate_clause(Clause("unit", ClauseOp.IS_SET), bc.bindings, bc.supplied) + assert not evaluate_clause(Clause("unit", ClauseOp.IS_NULL), bc.bindings, bc.supplied) + assert evaluate_clause(Clause("multiple", ClauseOp.IS_LITERAL), bc.bindings, bc.supplied) + + +def test_dynamic_arg_makes_value_clauses_false_but_is_set_true(): + dynamic = _DynamicExpr() # a non-literal expression node + bc = _bound_call(unit=dynamic) + assert not evaluate_clause(Clause("unit", ClauseOp.EQ, "WEEK"), bc.bindings, bc.supplied) + assert not evaluate_clause(Clause("unit", ClauseOp.IN, frozenset({"WEEK"})), bc.bindings, bc.supplied) + assert evaluate_clause(Clause("unit", ClauseOp.IS_SET), bc.bindings, bc.supplied) + assert not evaluate_clause(Clause("unit", ClauseOp.IS_NULL), bc.bindings, bc.supplied) + assert not evaluate_clause(Clause("unit", ClauseOp.IS_LITERAL), bc.bindings, bc.supplied) + + +def test_literal_none_round_trips_is_null_is_set(): + bc = _bound_call(unit=LiteralNode(value=None)) + assert evaluate_clause(Clause("unit", ClauseOp.IS_NULL), bc.bindings, bc.supplied) + assert not evaluate_clause(Clause("unit", ClauseOp.IS_SET), bc.bindings, bc.supplied) + assert evaluate_clause(Clause("unit", ClauseOp.IS_LITERAL), bc.bindings, bc.supplied) + + +def test_unresolvable_path_raises_not_false(): + bc = _bound_call(unit="WEEK") + with pytest.raises(ValueError, match="not bound"): + evaluate_clause(Clause("typo_unit", ClauseOp.EQ, "WEEK"), bc.bindings, bc.supplied) + + +def test_none_final_value_evaluates_per_operator(): + bc = _bound_call(resource={"dialect": {"escape_char": None}}) + assert evaluate_clause(Clause("resource.dialect.escape_char", ClauseOp.IS_NULL), bc.bindings, bc.supplied) + assert not evaluate_clause(Clause("resource.dialect.escape_char", ClauseOp.IS_SET), bc.bindings, bc.supplied) + + +def test_none_intermediate_raises(): + bc = _bound_call(resource=None) + with pytest.raises(ValueError, match="through None"): + evaluate_clause(Clause("resource.dialect.escape_char", ClauseOp.EQ, "x"), bc.bindings, bc.supplied) + + +def test_matches_class_operand(): + bc = _bound_call(unit="2d") + assert evaluate_clause(Clause("unit", ClauseOp.MATCHES_CLASS, ValueClass.DURATION_MULTIPLIER), bc.bindings, bc.supplied) + + +def test_predicate_holds_is_conjunction(): + p = Predicate((Clause("unit", ClauseOp.EQ, "WEEK"), Clause("origin", ClauseOp.EQ, "ISO"))) + assert predicate_holds(p, _bound_call(unit="WEEK", origin="ISO").bindings, frozenset({"unit", "origin"})) + assert not predicate_holds(p, _bound_call(unit="WEEK", origin="REGULAR").bindings, frozenset({"unit", "origin"})) + + +def test_clause_implies_lattice(): + eq = Clause("unit", ClauseOp.EQ, "WEEK") + assert clause_implies(eq, Clause("unit", ClauseOp.EQ, "WEEK")) + assert clause_implies(eq, Clause("unit", ClauseOp.IN, frozenset({"WEEK", "DAY"}))) + assert clause_implies(eq, Clause("unit", ClauseOp.IS_SET)) + assert not clause_implies(eq, Clause("unit", ClauseOp.IS_NULL)) + assert not clause_implies(eq, Clause("other", ClauseOp.EQ, "WEEK")) + inn = Clause("unit", ClauseOp.IN, frozenset({"WEEK", "DAY"})) + assert clause_implies(inn, Clause("unit", ClauseOp.IN, frozenset({"WEEK", "DAY", "MO"}))) + assert clause_implies(inn, Clause("unit", ClauseOp.IS_SET)) + assert not clause_implies(Clause("unit", ClauseOp.IS_LITERAL), Clause("unit", ClauseOp.EQ, "WEEK")) + + + +def test_predicate_implies_subset_direction(): + a = Predicate((Clause("unit", ClauseOp.EQ, "WEEK"), Clause("origin", ClauseOp.EQ, "ISO"))) + b = Predicate((Clause("unit", ClauseOp.EQ, "WEEK"),)) + assert predicate_implies(a, b) + assert not predicate_implies(b, a) + + +def test_predicates_overlap_exclusive_eq(): + a = Predicate((Clause("unit", ClauseOp.EQ, "WEEK"),)) + b = Predicate((Clause("unit", ClauseOp.EQ, "DAY"),)) + assert not predicates_overlap(a, b) + + +def test_predicates_overlap_compatible(): + a = Predicate((Clause("unit", ClauseOp.EQ, "WEEK"),)) + b = Predicate((Clause("origin", ClauseOp.EQ, "ISO"),)) + assert predicates_overlap(a, b) + + +def test_bind_expression_call_aggregates_varargs(): + def protocol(self, input, /, a, *varargs, b=None, **kwargs): + pass + + bc = bind_expression_call( + operation_key="OP", backend=CONST_BACKEND.IBIS, dialect="ibis-duckdb", + protocol_method=protocol, arguments=["in", "A", "V1", "V2"], options={"b": "B"}, + ) + assert bc.bindings["a"] == "A" + assert bc.bindings["b"] == "B" + assert bc.bindings["varargs"] == ("V1", "V2") + assert "a" in bc.supplied and "b" in bc.supplied and "varargs" in bc.supplied + + +def test_bind_expression_call_applies_defaults_outside_supplied(): + def protocol(self, x, /, overflow=None): + pass + + bc = bind_expression_call( + operation_key="OP", backend=CONST_BACKEND.POLARS, dialect="polars", + protocol_method=protocol, arguments=[LiteralNode(value=7)], options={}, + ) + assert bc.bindings["overflow"] is None + assert "overflow" not in bc.supplied From 49f383b8ef79dc7f4cbb3c2d23a1e6419c8a8d52 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Sat, 15 Aug 2026 15:17:55 +1000 Subject: [PATCH 3/5] feat(capabilities): violations_for + predicate store + conflict check (66b) --- src/mountainash/core/capabilities/registry.py | 68 +++++++++- tests/core/test_capability_load_state.py | 1 + .../test_capability_predicate_registry.py | 118 ++++++++++++++++++ tests/core/test_capability_protocol_guard.py | 1 + 4 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 tests/core/test_capability_predicate_registry.py diff --git a/src/mountainash/core/capabilities/registry.py b/src/mountainash/core/capabilities/registry.py index 490f56a8..8ff9756a 100644 --- a/src/mountainash/core/capabilities/registry.py +++ b/src/mountainash/core/capabilities/registry.py @@ -27,6 +27,7 @@ if TYPE_CHECKING: from mountainash.core.capabilities.declarations import CapabilityDeclaration + from mountainash.core.capabilities.predicates import BoundCall # backend slot is CONST_BACKEND | str: str families arrive only via the # serialization workstream's register_target (spec 2026-07-06); register_backend @@ -223,6 +224,7 @@ class CapabilityRegistry: _facts: Dict[_Key, CapabilityFact] = {} _kinds: Dict[str, TargetKind] = {} # family name -> kind (spec 2026-07-06) _value_class_facts: Dict[_ValueClassBucketKey, Tuple[CapabilityFact, ...]] = {} + _predicate_facts: List[CapabilityFact] = [] _declarations: Tuple["CapabilityDeclaration", ...] = () _load_state: _LoadState = _LoadState.UNINITIALIZED _load_error: BaseException | None = None @@ -312,6 +314,10 @@ def register_backend( cls._register_identity(family.value, TargetKind.EXECUTE) for fact in facts: _validate_fact(family, fact) + if fact.predicate is not None: + cls._check_predicate_conflicts(fact) + cls._predicate_facts.append(fact) + continue if fact.value_class is not None: bkey: _ValueClassBucketKey = ( fact.operation_key, @@ -418,6 +424,7 @@ def facts( for fact in ( *cls._facts.values(), *(f for bucket in cls._value_class_facts.values() for f in bucket), + *cls._predicate_facts, ): if level is not None and fact.level is not level: continue @@ -488,6 +495,60 @@ def router_facts( ) ) + @classmethod + def _check_predicate_conflicts(cls, fact: CapabilityFact) -> None: + from mountainash.core.capabilities.predicates import ( + predicate_implies, predicates_overlap, + ) + blocking = fact.level is CapabilityLevel.UNSUPPORTED + for other in cls._predicate_facts: + if other.operation_key != fact.operation_key or other.backend is not fact.backend: + continue + # Compatible dialect scope: only skip when BOTH are dialect-scoped and DIFFER. + if ( + other.dialect is not None + and fact.dialect is not None + and other.dialect != fact.dialect + ): + continue + if (other.level is CapabilityLevel.UNSUPPORTED) == blocking: + continue # same disposition: two blockers or two refinements — no conflict + if not predicates_overlap(fact.predicate, other.predicate): + continue # disjoint predicates: no shared call + a_implies_b = predicate_implies(fact.predicate, other.predicate) + b_implies_a = predicate_implies(other.predicate, fact.predicate) + if a_implies_b != b_implies_a: + continue # exactly one strictly more specific — subsumption resolves + raise ValueError( + f"conflicting predicate facts for ({fact.operation_key}, " + f"{fact.backend}, {fact.dialect!r}): one blocks and one permits the " + "same call, and neither strictly subsumes the other" + ) + + @classmethod + def violations_for(cls, bound_call: "BoundCall") -> frozenset[CapabilityFact]: + """Collecting call-level API (§3): every blocking predicate fact that + holds for this bound call. `capability_for` is unchanged.""" + from mountainash.core.capabilities.predicates import predicate_holds + + cls.ensure_loaded() + out = set() + for fact in cls._predicate_facts: + if fact.operation_key != bound_call.operation_key: + continue + if fact.backend is not bound_call.backend: + continue + if fact.dialect is not None and fact.dialect != bound_call.dialect: + continue + if fact.enforcement is not Enforcement.GATE: + continue + if fact.level is not CapabilityLevel.UNSUPPORTED: + continue + if predicate_holds(fact.predicate, bound_call.bindings, bound_call.supplied): + out.add(fact) + return frozenset(out) + + @classmethod def validate_plan_capabilities( cls, @@ -521,6 +582,7 @@ def snapshot( Tuple["CapabilityDeclaration", ...], _LoadState, Optional[BaseException], + Tuple[CapabilityFact, ...], ]: """Opaque round-trip token for test isolation — captures BOTH _facts and _kinds so restore() is symmetric with reset(). Callers must treat @@ -528,6 +590,7 @@ def snapshot( return ( dict(cls._facts), dict(cls._kinds), dict(cls._value_class_facts), cls._declarations, cls._load_state, cls._load_error, + tuple(cls._predicate_facts), ) @classmethod @@ -540,12 +603,14 @@ def restore( Tuple["CapabilityDeclaration", ...], _LoadState, Optional[BaseException], + Tuple[CapabilityFact, ...], ], ) -> None: - facts, kinds, vclass, decls, state, err = snapshot + facts, kinds, vclass, decls, state, err, pred = snapshot cls._facts = dict(facts) cls._kinds = dict(kinds) cls._value_class_facts = dict(vclass) + cls._predicate_facts = list(pred) cls._declarations = decls cls._load_state = state cls._load_error = err @@ -560,6 +625,7 @@ def reset(cls) -> None: cls._facts = {} cls._kinds = {} cls._value_class_facts = {} + cls._predicate_facts = [] cls._declarations = () cls._load_state = _LoadState.ISOLATED cls._load_error = None diff --git a/tests/core/test_capability_load_state.py b/tests/core/test_capability_load_state.py index 0570e374..1b5eb3b9 100644 --- a/tests/core/test_capability_load_state.py +++ b/tests/core/test_capability_load_state.py @@ -51,6 +51,7 @@ def _reset_to_uninitialized(): CapabilityRegistry._facts = {} CapabilityRegistry._kinds = {} CapabilityRegistry._value_class_facts = {} + CapabilityRegistry._predicate_facts = [] CapabilityRegistry._declarations = () CapabilityRegistry._load_state = _LoadState.UNINITIALIZED CapabilityRegistry._load_error = None diff --git a/tests/core/test_capability_predicate_registry.py b/tests/core/test_capability_predicate_registry.py new file mode 100644 index 00000000..22c8be49 --- /dev/null +++ b/tests/core/test_capability_predicate_registry.py @@ -0,0 +1,118 @@ +"""Registry integration for predicate facts (backlog 66b).""" +from __future__ import annotations + +import pytest + +from mountainash.core.capabilities import CapabilityRegistry +from mountainash.core.capabilities.predicates import BoundCall +from mountainash.core.capabilities.schema import ( + CapabilityFact, CapabilityLevel, Clause, ClauseOp, Predicate, +) +from mountainash.core.constants import CONST_BACKEND +from mountainash.expressions.core.expression_system.function_keys.enums import ( + FKEY_SUBSTRAIT_SCALAR_ARITHMETIC as FK_ARITH, +) + +# abs(self, x, /, overflow=None) — params "x" (arg) and "overflow" (option). +_OP = FK_ARITH.ABS + + +def _fact(param, level, predicate, *, backend=CONST_BACKEND.POLARS, dialect="polars"): + return CapabilityFact( + operation_key=_OP, param=param, level=level, backend=backend, + dialect=dialect, message=f"{param} limitation", since="2026-08-15", + predicate=predicate, + ) + + +def _call(**bindings): + return BoundCall( + operation_key=_OP, backend=CONST_BACKEND.POLARS, dialect="polars", + bindings=bindings, supplied=frozenset(bindings), + ) + + +@pytest.fixture() +def isolated(): + snap = CapabilityRegistry.snapshot() + CapabilityRegistry.reset() + yield + CapabilityRegistry.restore(snap) + + +def test_register_backend_routes_predicate_facts(isolated): + f = _fact("x", CapabilityLevel.UNSUPPORTED, Predicate((Clause("x", ClauseOp.EQ, 7),))) + CapabilityRegistry.register_backend(CONST_BACKEND.POLARS, [f]) + assert f in CapabilityRegistry._predicate_facts + assert CapabilityRegistry.capability_for(_OP, "x", CONST_BACKEND.POLARS, "polars") is None + assert not any(x.predicate is not None for x in CapabilityRegistry._facts.values()) + + +def test_violations_for_collects_matching_blocking_fact(isolated): + f = _fact("x", CapabilityLevel.UNSUPPORTED, Predicate((Clause("x", ClauseOp.EQ, 7),))) + CapabilityRegistry.register_backend(CONST_BACKEND.POLARS, [f]) + assert CapabilityRegistry.violations_for(_call(x=7)) == frozenset({f}) + assert CapabilityRegistry.violations_for(_call(x=9)) == frozenset() + + +def test_violations_for_filters_backend_dialect_level(isolated): + f = _fact("x", CapabilityLevel.UNSUPPORTED, Predicate((Clause("x", ClauseOp.EQ, 7),))) + CapabilityRegistry.register_backend(CONST_BACKEND.POLARS, [f]) + ibis_call = BoundCall(_OP, CONST_BACKEND.IBIS, "ibis-duckdb", {"x": 7}, frozenset({"x"})) + assert CapabilityRegistry.violations_for(ibis_call) == frozenset() + fam = _fact("x", CapabilityLevel.UNSUPPORTED, Predicate((Clause("x", ClauseOp.EQ, 7),)), dialect=None) + CapabilityRegistry.register_backend(CONST_BACKEND.POLARS, [fam]) + assert fam in CapabilityRegistry.violations_for(_call(x=7)) + + +def test_violations_for_skips_non_blocking(isolated): + perm = _fact("x", CapabilityLevel.EXPR_CAPABLE, Predicate((Clause("x", ClauseOp.EQ, 7),))) + CapabilityRegistry.register_backend(CONST_BACKEND.POLARS, [perm]) + assert CapabilityRegistry.violations_for(_call(x=7)) == frozenset() + + +def test_conflict_raise_on_incomparable_block_and_permit(isolated): + block = _fact("x", CapabilityLevel.UNSUPPORTED, Predicate((Clause("x", ClauseOp.EQ, 7),))) + permit = _fact("overflow", CapabilityLevel.EXPR_CAPABLE, Predicate((Clause("overflow", ClauseOp.EQ, "saturating"),))) + CapabilityRegistry.register_backend(CONST_BACKEND.POLARS, [block]) + with pytest.raises(ValueError, match="conflict"): + CapabilityRegistry.register_backend(CONST_BACKEND.POLARS, [permit]) + + +def test_conflict_detected_across_different_params(isolated): + # review finding 1: param is a reporting label, not a conflict-scope key. + pred = Predicate((Clause("x", ClauseOp.EQ, 7), Clause("overflow", ClauseOp.EQ, "saturating"))) + block = _fact("x", CapabilityLevel.UNSUPPORTED, pred) + permit = _fact("overflow", CapabilityLevel.EXPR_CAPABLE, pred) + CapabilityRegistry.register_backend(CONST_BACKEND.POLARS, [block]) + with pytest.raises(ValueError, match="conflict"): + CapabilityRegistry.register_backend(CONST_BACKEND.POLARS, [permit]) + + +def test_no_conflict_when_strictly_subsumed(isolated): + block = _fact("x", CapabilityLevel.UNSUPPORTED, Predicate((Clause("x", ClauseOp.EQ, 7),))) + permit = _fact("x", CapabilityLevel.EXPR_CAPABLE, Predicate( + (Clause("x", ClauseOp.EQ, 7), Clause("overflow", ClauseOp.EQ, "saturating")))) + CapabilityRegistry.register_backend(CONST_BACKEND.POLARS, [block, permit]) # no raise + + +def test_no_conflict_when_disjoint(isolated): + block = _fact("x", CapabilityLevel.UNSUPPORTED, Predicate((Clause("x", ClauseOp.EQ, 7),))) + permit = _fact("x", CapabilityLevel.EXPR_CAPABLE, Predicate((Clause("x", ClauseOp.EQ, 9),))) + CapabilityRegistry.register_backend(CONST_BACKEND.POLARS, [block, permit]) # disjoint: no raise + + +def test_facts_includes_predicate_facts(isolated): + f = _fact("x", CapabilityLevel.UNSUPPORTED, Predicate((Clause("x", ClauseOp.EQ, 7),))) + CapabilityRegistry.register_backend(CONST_BACKEND.POLARS, [f]) + assert f in CapabilityRegistry.facts() + + +def test_snapshot_round_trips_predicate_facts(isolated): + f = _fact("x", CapabilityLevel.UNSUPPORTED, Predicate((Clause("x", ClauseOp.EQ, 7),))) + CapabilityRegistry.register_backend(CONST_BACKEND.POLARS, [f]) + snap = CapabilityRegistry.snapshot() + CapabilityRegistry.reset() + assert CapabilityRegistry.violations_for(_call(x=7)) == frozenset() + CapabilityRegistry.restore(snap) + assert CapabilityRegistry.violations_for(_call(x=7)) == frozenset({f}) diff --git a/tests/core/test_capability_protocol_guard.py b/tests/core/test_capability_protocol_guard.py index f8341a22..6c194e2b 100644 --- a/tests/core/test_capability_protocol_guard.py +++ b/tests/core/test_capability_protocol_guard.py @@ -212,6 +212,7 @@ def test_no_registration_side_effects_on_import(): importlib.import_module(name) assert CapabilityRegistry._facts == {}, "import side-effect registration" assert CapabilityRegistry._value_class_facts == {} + assert CapabilityRegistry._predicate_facts == [] assert CapabilityRegistry._kinds == {} print("OK") """) From 8d9d9063f23b49568a5294c9ef36c1dd9f32cf73 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Sat, 15 Aug 2026 15:19:40 +1000 Subject: [PATCH 4/5] feat(capabilities): wire violations_for into both visitor gates (66b) --- .../core/unified_visitor/visitor.py | 27 ++++++++++++++ .../core/unified_visitor/relation_visitor.py | 18 +++++++++ tests/core/test_capability_gate.py | 37 +++++++++++++++++++ tests/relations/test_rel_limitations.py | 26 +++++++++++++ 4 files changed, 108 insertions(+) diff --git a/src/mountainash/expressions/core/unified_visitor/visitor.py b/src/mountainash/expressions/core/unified_visitor/visitor.py index 2aea6809..ac9f38d2 100644 --- a/src/mountainash/expressions/core/unified_visitor/visitor.py +++ b/src/mountainash/expressions/core/unified_visitor/visitor.py @@ -290,6 +290,28 @@ def _gate_and_resolve_args(self, function_key, arguments, protocol_method): ) return resolved + def _gate_predicate_violations(self, function_key, protocol_method, arguments, options) -> None: + """Collecting call-level gate (§3): predicate facts, once per call.""" + if not self.enforce_capabilities: + return + from mountainash.core.capabilities import CapabilityRegistry + from mountainash.core.capabilities.predicates import bind_expression_call + from mountainash.core.types import BackendCapabilityError + bound = bind_expression_call( + operation_key=function_key, backend=self.backend.backend_type, + dialect=getattr(self.backend, "dialect", None), + protocol_method=protocol_method, arguments=arguments, options=options, + ) + violations = CapabilityRegistry.violations_for(bound) + if violations: + ordered = sorted(violations, key=lambda f: (f.param, f.message)) + combined = "; ".join(f.message for f in ordered) + raise BackendCapabilityError( + combined, backend=self.backend.BACKEND_NAME, + function_key=function_key, limitation=ordered[0], + ) + + def visit_scalar_function(self, node: ScalarFunctionNode) -> SupportedExpressions: """Compile a scalar function call to backend expression. @@ -315,6 +337,11 @@ def visit_scalar_function(self, node: ScalarFunctionNode) -> SupportedExpression # Get method name from protocol method method_name = protocol_method.__name__ + self._gate_predicate_violations( + node.function_key, protocol_method, node.arguments, node.options + ) + + if self.enforce_capabilities: from mountainash.core.capabilities import ( CapabilityLevel, CapabilityRegistry, Enforcement, WILDCARD_PARAM, diff --git a/src/mountainash/relations/core/unified_visitor/relation_visitor.py b/src/mountainash/relations/core/unified_visitor/relation_visitor.py index 4fcd8192..72964714 100644 --- a/src/mountainash/relations/core/unified_visitor/relation_visitor.py +++ b/src/mountainash/relations/core/unified_visitor/relation_visitor.py @@ -221,6 +221,24 @@ def _raise(fact): # field is sufficient evidence for a GATE fact to fire on a # handler-routed op. param_names = tuple(b.field for b in op.args) + tuple(op.options) + op.gate_params + + # Compound predicate gate (§3): collect blocking predicate facts once per call. + from mountainash.core.capabilities.predicates import BoundCall + bindings = {p: getattr(node, p, None) for p in param_names} + supplied = frozenset(p for p in param_names if getattr(node, p, None) is not None) + bound = BoundCall( + operation_key=op.operation_key, backend=family, dialect=dialect, + bindings=bindings, supplied=supplied, + ) + violations = CapabilityRegistry.violations_for(bound) + if violations: + ordered = sorted(violations, key=lambda f: (f.param, f.message)) + combined = "; ".join(f.message for f in ordered) + raise BackendCapabilityError( + combined, backend=self.backend.BACKEND_NAME, + function_key=op.operation_key, limitation=ordered[0], + ) + for param in param_names: fact = CapabilityRegistry.capability_for(op.operation_key, param, family, dialect) if fact is None or fact.level is not CapabilityLevel.UNSUPPORTED: diff --git a/tests/core/test_capability_gate.py b/tests/core/test_capability_gate.py index 029429dd..b759fbf8 100644 --- a/tests/core/test_capability_gate.py +++ b/tests/core/test_capability_gate.py @@ -231,3 +231,40 @@ def test_enforced_visitor_construction_bootstraps_declarations(): ) subprocess.run([sys.executable, "-c", code], check=True) + + +def test_predicate_fact_gates_expression_call(): + from mountainash.core.capabilities.schema import CapabilityFact, CapabilityLevel, Clause, ClauseOp, Predicate + from mountainash.expressions.core.expression_system.function_keys.enums import ( + FKEY_SUBSTRAIT_SCALAR_ARITHMETIC as FK_ARITH, + ) + # ABS protocol: def abs(self, x, /, overflow=None) — the literal arg maps to param "x". + CapabilityRegistry.register_backend(CONST_BACKEND.POLARS, [ + CapabilityFact( + operation_key=FK_ARITH.ABS, param="x", level=CapabilityLevel.UNSUPPORTED, + backend=CONST_BACKEND.POLARS, dialect="polars", + message="abs blocked when x==7", since="2026-08-15", + predicate=Predicate((Clause("x", ClauseOp.EQ, 7),)), + ), + ]) + with pytest.raises(BackendCapabilityError) as exc_info: + ma.lit(7).abs().compile(DF) + assert exc_info.value.limitation.predicate is not None + assert "abs blocked" in str(exc_info.value) + + +def test_predicate_fact_does_not_fire_when_predicate_false(): + from mountainash.core.capabilities.schema import CapabilityFact, CapabilityLevel, Clause, ClauseOp, Predicate + from mountainash.expressions.core.expression_system.function_keys.enums import ( + FKEY_SUBSTRAIT_SCALAR_ARITHMETIC as FK_ARITH, + ) + CapabilityRegistry.register_backend(CONST_BACKEND.POLARS, [ + CapabilityFact( + operation_key=FK_ARITH.ABS, param="x", level=CapabilityLevel.UNSUPPORTED, + backend=CONST_BACKEND.POLARS, dialect="polars", + message="abs blocked when x==7", since="2026-08-15", + predicate=Predicate((Clause("x", ClauseOp.EQ, 7),)), + ), + ]) + compiled = ma.lit(9).abs().compile(DF) # [x EQ 7] does not hold + assert compiled is not None diff --git a/tests/relations/test_rel_limitations.py b/tests/relations/test_rel_limitations.py index 4213978b..bf21dde0 100644 --- a/tests/relations/test_rel_limitations.py +++ b/tests/relations/test_rel_limitations.py @@ -216,3 +216,29 @@ def test_dag_collect_enriches_string_split_on_dependency_under_differing_anchor_ with pytest.raises(BackendCapabilityError) as exc_info: dag.collect("final") assert exc_info.value.limitation.upstream_ref == "NW-STR-22" + + +def test_predicate_fact_gates_relation_call(): + from mountainash.core.capabilities import CapabilityRegistry + from mountainash.core.capabilities.schema import ( + CapabilityFact, CapabilityLevel, Clause, ClauseOp, Predicate, + ) + from mountainash.core.constants import CONST_BACKEND + from mountainash.relations.core.relation_system.relation_keys.enums import RKEY_SUBSTRAIT_REL + + snap = CapabilityRegistry.snapshot() + try: + CapabilityRegistry.reset() + CapabilityRegistry.register_backend(CONST_BACKEND.NARWHALS, [ + CapabilityFact( + operation_key=RKEY_SUBSTRAIT_REL.FILTER, param="predicate", + level=CapabilityLevel.UNSUPPORTED, backend=CONST_BACKEND.NARWHALS, + message="filter blocked by predicate fact", since="2026-08-15", + predicate=Predicate((Clause("predicate", ClauseOp.IS_SET),)), + ), + ]) + df = _nw(pl.DataFrame({"a": [1, 2]})) + with pytest.raises(BackendCapabilityError, match="filter blocked"): + ma.relation(df).filter(ma.col("a").eq(1)).collect() + finally: + CapabilityRegistry.restore(snap) From afc2ef348b7afc0ff9ed7f1ac98e432846718bad Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Sat, 15 Aug 2026 15:22:31 +1000 Subject: [PATCH 5/5] test(capabilities): consumer audit for predicate facts (66b) --- src/mountainash/core/capabilities/coverage.py | 2 ++ tests/_spine_expectation_census.md | 2 +- tests/core/test_capability_predicate_probe.py | 34 +++++++++++++++++++ tests/core/test_expression_coverage_doc.py | 1 + tests/core/test_expression_coverage_render.py | 1 + .../argument_types/test_capability_probes.py | 1 + 6 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/core/test_capability_predicate_probe.py diff --git a/src/mountainash/core/capabilities/coverage.py b/src/mountainash/core/capabilities/coverage.py index 8f6dc8f5..34ff6f6d 100644 --- a/src/mountainash/core/capabilities/coverage.py +++ b/src/mountainash/core/capabilities/coverage.py @@ -26,6 +26,7 @@ Enforcement, KnownGap, WILDCARD_PARAM, + _clause_key, ) from mountainash.core.constants import CONST_BACKEND @@ -464,6 +465,7 @@ def fact_sort_key(f: CapabilityFact) -> tuple: f.upstream_ref or "", tuple(e.__name__ for e in f.native_errors), f.probe_exempt or "", + tuple(_clause_key(c) for c in f.predicate.clauses) if f.predicate is not None else (), ) diff --git a/tests/_spine_expectation_census.md b/tests/_spine_expectation_census.md index 0b15276b..eca47537 100644 --- a/tests/_spine_expectation_census.md +++ b/tests/_spine_expectation_census.md @@ -26,7 +26,7 @@ Buckets: `migrated` (derivable from the spine today), `retained` (a LITERAL_ONLY | tests/core/test_capability_gating.py:246 | static-marker | UNRESOLVED | polars | UNRESOLVED | None | spine-derived id-keyed divergence mark via xfail_divergence('IB-TYPE-02') — migrated | | tests/expressions/argument_types/_option_helpers.py:91 | static-marker | UNRESOLVED | UNRESOLVED | UNRESOLVED | None | spine-derived xfail marker (reason built from a CapabilityFact) — migrated | | tests/expressions/argument_types/_test_template.py:162 | static-marker | UNRESOLVED | UNRESOLVED | UNRESOLVED | None | spine-derived xfail marker (reason built from a CapabilityFact) — migrated | -| tests/expressions/argument_types/test_capability_probes.py:156 | static-marker | UNRESOLVED | UNRESOLVED | UNRESOLVED | None | spine-derived xfail marker (reason built from a CapabilityFact) — migrated | +| tests/expressions/argument_types/test_capability_probes.py:157 | static-marker | UNRESOLVED | UNRESOLVED | UNRESOLVED | None | spine-derived xfail marker (reason built from a CapabilityFact) — migrated | | tests/expressions/argument_types/test_op_level_gate_probes.py:46 | parametrized-case | CAPITALIZE | narwhals-pandas | op | None | spine gate fact (unsupported/gate) on narwhals/narwhals-pandas — derivable via capability_gate | | tests/expressions/argument_types/test_op_level_gate_probes.py:46 | parametrized-case | CAPITALIZE | narwhals-polars | op | None | spine gate fact (unsupported/gate) on narwhals/narwhals-polars — derivable via capability_gate | | tests/expressions/argument_types/test_op_level_gate_probes.py:46 | parametrized-case | CENTER | narwhals-pandas | op | None | spine gate fact (unsupported/gate) on narwhals/narwhals-pandas — derivable via capability_gate | diff --git a/tests/core/test_capability_predicate_probe.py b/tests/core/test_capability_predicate_probe.py new file mode 100644 index 00000000..0a8e829e --- /dev/null +++ b/tests/core/test_capability_predicate_probe.py @@ -0,0 +1,34 @@ +"""Consumer audit for predicate facts (backlog 66b).""" +from __future__ import annotations + +from mountainash.core.capabilities import CapabilityRegistry +from mountainash.core.capabilities.schema import ( + CapabilityFact, CapabilityLevel, Clause, ClauseOp, Predicate, +) +from mountainash.core.constants import CONST_BACKEND + + +def test_no_production_predicate_facts_yet(): + """Invariant: the mechanism ships with zero predicate facts (spec §7 — + no consumer has arrived). If one lands, it MUST be accompanied by the §6 + compound-cell probe (see plan Task 5 deferred note).""" + from mountainash.core.capabilities.bootstrap import load_all_capability_declarations + load_all_capability_declarations() + facts = [f for f in CapabilityRegistry.facts() if f.predicate is not None] + assert facts == [] + + +def test_fact_sort_key_is_total_over_predicate_facts(): + """Two predicate facts on the same key differing only in clause content + must not tie (review finding 8).""" + from mountainash.core.capabilities.coverage import fact_sort_key + + def _make(value): + return CapabilityFact( + operation_key="TRUNCATE", param="unit", level=CapabilityLevel.UNSUPPORTED, + backend=CONST_BACKEND.IBIS, dialect="ibis-duckdb", message="x", + since="2026-08-15", + predicate=Predicate((Clause("unit", ClauseOp.EQ, value),)), + ) + + assert fact_sort_key(_make("WEEK")) != fact_sort_key(_make("MONTH")) diff --git a/tests/core/test_expression_coverage_doc.py b/tests/core/test_expression_coverage_doc.py index 619aa910..83d07da3 100644 --- a/tests/core/test_expression_coverage_doc.py +++ b/tests/core/test_expression_coverage_doc.py @@ -95,6 +95,7 @@ def _json_fact_identity(f_dict: dict) -> tuple: f_dict["upstream_ref"] or "", tuple(f_dict["native_errors"]), f_dict["probe_exempt"] or "", + (), # predicate term — empty for synthetic facts (none carry a predicate) ) diff --git a/tests/core/test_expression_coverage_render.py b/tests/core/test_expression_coverage_render.py index c90c0263..0e90a123 100644 --- a/tests/core/test_expression_coverage_render.py +++ b/tests/core/test_expression_coverage_render.py @@ -576,6 +576,7 @@ def _json_fact_semantic_identity(f_dict: dict) -> tuple: f_dict["upstream_ref"] or "", tuple(f_dict["native_errors"]), f_dict["probe_exempt"] or "", + (), # predicate term — empty for synthetic facts (none carry a predicate) ) diff --git a/tests/expressions/argument_types/test_capability_probes.py b/tests/expressions/argument_types/test_capability_probes.py index 2f6e106a..10121ea2 100644 --- a/tests/expressions/argument_types/test_capability_probes.py +++ b/tests/expressions/argument_types/test_capability_probes.py @@ -83,6 +83,7 @@ def _gating_expression_facts(): if f.level in _GATING and f.option_value is None and f.value_class is None # value-class facts route to the class probe system, not the argument-probe guard (items 63/64, round-2 I-1) + and f.predicate is None # predicate facts carry option_value=None/value_class=None and route to the compound-cell guard (item 66b) and f.probe_exempt is None and _is_expression_fact(f) ]