Skip to content

Expression-based rules engine rearchitecture - #38

Merged
discreteds merged 47 commits into
developfrom
feature/expression-based-engine
Apr 11, 2026
Merged

Expression-based rules engine rearchitecture#38
discreteds merged 47 commits into
developfrom
feature/expression-based-engine

Conversation

@discreteds

Copy link
Copy Markdown
Member

Summary

  • Replaces iterative dimension-by-dimension rule evaluation with single-pass expression-based architecture using mountainash-expressions
  • Clean break: removes all existing engines (RulesEngine, HybridRulesEngine, VectorizedRulesEngine) and supporting infrastructure (~12,000 lines deleted)
  • Single ExpressionRulesEngine with dual API: convenience (DataFrame + dimension metadata) and advanced (raw expressions)
  • Ternary logic via mountainash-expressions (-1/0/1) replaces custom prime-based flags (2/3/5)
  • Built-in observability via per-dimension ternary columns and specificity-based ranking for hierarchical rule fallbacks

Key changes

  • compiler.py — Translates dimension metadata into backend-agnostic expression templates (EXACT, RANGE, REGEX)
  • engine.pyExpressionRulesEngine with single-pass with_columns() evaluation pipeline
  • result.pyRuleResult wrapper with best_match, explain(), at_least(), specificity ranking
  • constants.py — Simplified: MatchStrategy enum + sentinel values only
  • dimension.py — Simplified: Dimension + DimensionsMetadata Pydantic models, no MetadataManager
  • context.py — Single extract_context_values() function replacing ContextHelper class

Test plan

  • 51 tests passing across 5 test modules
  • 91% code coverage
  • Compiler tests: EXACT, RANGE (inclusive/exclusive), REGEX (search semantics), unknown sentinel handling
  • Engine tests: survival, specificity ranking, top_n, min_specificity, dimension subset, observability toggle, custom expressions path
  • Integration tests: pricing carve-out hierarchy, entity pool fallbacks, no-match, tie handling, explain
  • Ruff lint clean

🤖 Generated with Claude Code

discreteds and others added 13 commits April 3, 2026 21:13
Replaces iterative dimension-by-dimension evaluation with single-pass
expression-based architecture using mountainash-expressions. Clean break
from existing engines — single ExpressionRulesEngine with dual API
(metadata convenience + raw expressions).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
13-task TDD plan covering: cleanup, constants, context, compiler
(EXACT/RANGE/REGEX), result, engine, advanced features, custom
expressions, integration tests, public API, and lint pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove all legacy engine implementations (RulesEngine strategies, observer,
rule_manager, vectorized_engine, deprecated/) and test files being replaced
by the new expression-based architecture. Update pyproject.toml to use
polars>=1.35.1, ibis-framework[polars,duckdb]>=11.0.0, narwhals>=1.0.0,
and mountainash. Add mountainash-expressions dependency to hatch.toml test
environments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ations

Rewrites constants.py with module-level sentinel constants and simplified
MatchStrategy enum. Rewrites dimension.py with Dimension/DimensionsMetadata
pydantic models including model_validator-based strategy validation and
resolved_context_field/resolved_rule_field properties. Updates __init__.py
to remove all stale references to deleted modules (rule_strategies, observer,
rule_manager, vectorized_engine, context) and export only the new symbols.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…s function

Rewrites context.py as a simple module-level function aligned with the new
constants.py (NOT_SET, NOT_SET_NUMERIC) from Task 2. Adds test_context.py
with TDD coverage for pydantic model, dict, missing fields, and None values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements DimensionCompiler translating Dimension metadata into
backend-agnostic mountainash-expressions templates. EXACT strategy
uses t_col with sentinel sets for ternary-aware equality matching
(TRUE=1, UNKNOWN=0, FALSE=-1). RANGE and REGEX raise NotImplementedError
as stubs for Tasks 5 and 6.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add _compile_range (Task 5) using ternary comparison operators with
inclusive/exclusive boundary support and sentinel-aware unknown propagation.

Add _compile_regex (Task 6) using ma.when/ma.native with polars
map_elements for column-based pattern matching and explicit sentinel
checking — required because the MA expressions polars backend extracts
regex patterns as literals, preventing dynamic column-based matching.

All 11 compiler tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…valuated rule DataFrames

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…le-pass evaluation

Replaces the old ibis-based RulesEngine with ExpressionRulesEngine that uses
mountainash-expressions for vectorized single-pass rule evaluation. The new
engine compiles dimension metadata into expression templates at init time,
binds context values as literal columns, and evaluates all dimensions in one
pass with survival filtering and specificity ranking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ssions tests

Appends TestTopN, TestMinSpecificity, TestDimensionsSubset, TestObservability,
and TestCustomExpressions to test_engine.py; adds mountainash.expressions and
CTX_PREFIX imports. All 16 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds integration tests covering pricing carve-out hierarchy, entity pool
with range/regex rules, no-match, tie handling, and explain breakdown.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Rewrite __init__.py with new public API (ExpressionRulesEngine, DimensionCompiler, RuleResult, etc.)
- Rewrite conftest.py with expression-based engine fixtures
- Remove 7 leftover old test files that weren't caught in Task 1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Apr 6, 2026

Copy link
Copy Markdown

⚠️ JUnit XML file not found

The CLI was unable to find any JUnit XML files to upload.
For more help, visit our troubleshooting guide.

discreteds and others added 16 commits April 7, 2026 10:25
Adds 8 new match strategies (NOT_EQUAL, GREATER_THAN, LESS_THAN, PREFIX,
SUFFIX, CONTAINS, SET_MEMBERSHIP, SET_EXCLUSION) and rewrites REGEX to
be backend-agnostic. All 11 strategies work uniformly across Polars,
Ibis, and Narwhals backends with per-row patterns/thresholds.

Depends on upstream mountainash-expressions fixes to string API
consistency and t_is_in column-reference support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
13-task TDD plan covering: enum extension, validation rules, 8 new
strategy compile methods (NOT_EQUAL, GT, LT, PREFIX, SUFFIX, CONTAINS,
SET_MEMBERSHIP, SET_EXCLUSION), REGEX rewrite, backend agnosticism
tests, integration scenario, and documentation updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extend _validate_strategy_fields to enforce type constraints for
GREATER_THAN/LESS_THAN (numeric only) and PREFIX/SUFFIX/CONTAINS
(string only), and add corresponding test coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…es (Tasks 3 & 4)

Implement three new compile methods in DimensionCompiler using the existing
ternary expression API (t_ne, t_gt, t_lt), with TDD tests covering true/false/
equal-boundary and unknown-sentinel cases for each strategy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Introduces `_compile_string_match` as a sentinel-aware ternary wrapper
for string operations (unknown → 0, match → 1, no-match → -1) and
implements the PREFIX strategy via `ctx_col.str.starts_with(rule_col)`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SUFFIX delegates to _compile_string_match("ends_with"). CONTAINS uses
count_substring > 0 rather than str.contains because the polars backend
stringifies column references in contains(), making it unusable for
per-row patterns; count_substring correctly resolves column refs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reverse-engineers sp_productpricingmatrix_discretion_combos.sql as the
basis for a second rules-engine pattern (accumulator) and contrasts it
with the existing filter engine. Frames them as composable stages of a
pricing pipeline rather than alternatives. Identifies the minimum gaps
in the shared metadata layer and lists open questions for an eventual
implementation phase.

Also corrects CLAUDE.md: the codebase uses signed-integer ternary
encoding (1/0/-1), not the prime-based scheme (PRIME_TRUE=2 etc.) that
older planning docs described but never shipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds test_regex_per_row_different_patterns to prove the existing
map_elements implementation supports per-row regex patterns.

Note: _compile_regex retains the ma.native/map_elements approach rather
than _compile_string_match("regex_contains") because the polars backend
currently compiles regex_contains with literal=True, causing column
references to be treated as literal strings rather than regex patterns.
The per-row test passes with the existing implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Targeted survey of rules-engine patterns adjacent to mountainash-utils-rules'
filter and accumulator engines, organised by pattern with systems as
exemplars. Identifies the closest precedents — Configit Virtual Tabulation
(commercial), DRSA / Greco & Słowiński (academic), Malouf's maximal
consistent subsets (vocabulary), skyline queries (filter algorithm) — and
argues that the accumulator engine is a novel synthesis of well-precedented
elements occupying a gap in the BRE field.

Includes a forward-looking roadmap of three candidate future engines
(temporal, inverse, probabilistic/learned-rules hybrid), each anchored in
a survey gap and validated against a Mountain Ash use case. All roadmap
items are EXPLORATORY.

Comprehensive references with URLs from exa-verified sources.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
With upstream mountainash-expressions fixes (regex_contains routed
through new mountainash extension key, contains supports column refs),
both CONTAINS and REGEX now use the shared _compile_string_match helper.

- CONTAINS: ctx.str.contains(rule_col) - was count_substring workaround
- REGEX: ctx.str.regex_contains(rule_col) - was pl.struct.map_elements
- Removed: import re, import polars as pl from compiler.py
- compiler.py is now pure expressions, fully backend-agnostic

Also remove unused mountainash dep from pyproject.toml (provided via
hatch.toml test env path; not yet on PyPI).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Four corrections after reading the full PMX_DB source:

1. Add a new §0 Framing that positions the accumulator engine as a
   production pattern from a 2016 Big 4 mortgage pricing system, not
   as a proposed novel design. Introduces the three-layer separation
   (data prep / rules engine / governance) so organisational quirks
   from the SQL precedent are not propagated into the engine layer.

2. §3.1: add a note on the two-tier (shape, cell) rule structure in
   PMX_DB, and generalise the dual-margin payload as an instance of
   "rules carry zero-or-more named monoids" rather than architecture.

3. §3.3: correct an embarrassing claim. Prime allocation is already
   build-scoped per partition in the SQL via ROW_NUMBER, exactly as
   the principles directory recommends. The Python engine inherits
   this pattern; it does not improve on it.

4. §7 Q2: add the empirical bound from sp_product_rule_profile — max
   combination depth was 20 rules in production, smallest 20 primes
   fit in int64, so int128 and Python-object tiers are safety nets
   not expected operating modes.

Also: tier engine noted as production precedent for the filter pattern
alongside the accumulator, confirming the two-engine pipeline shipped
in 2016 and was not invented during the brainstorming session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
One-page anonymised case study suitable for SMB lender outreach,
anchored in the PMX_DB production history and the FICO replacement
compression story. Headline: ~200 products, ~2,000 logical rules
vs the replacement system's ~500 million enumerated rules
(~250,000x compression). Bridges from the Big 4 story to the
needs of credit unions, mutuals, and non-bank mortgage lenders.

Includes a footnote placeholder for the direct engineer quote,
pending attribution decisions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements _compile_set_membership and _compile_set_exclusion in
DimensionCompiler using ma.native(pl.col.list.contains()) to handle
list-typed rule columns, with sentinel-aware ternary logic.
Adds 4 unit tests covering match, non-match, and unknown context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds TestMixedStrategyFraudDetection covering EXACT, SET_MEMBERSHIP,
GREATER_THAN, and PREFIX strategies together in a realistic fraud
rule scenario. Validates specificity scoring and best-match selection
across three test cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tests all 9 backend-agnostic match strategies (EXACT, NOT_EQUAL, RANGE,
GREATER_THAN, LESS_THAN, PREFIX, SUFFIX, CONTAINS, REGEX) compile cleanly
against both Polars and Ibis backends. SET_MEMBERSHIP and SET_EXCLUSION
are excluded as they use a Polars-native list.contains workaround.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
discreteds and others added 14 commits April 8, 2026 09:37
Documents all 11 strategies with column formats, data types, backend
support notes, and the SET_MEMBERSHIP/SET_EXCLUSION Polars-specific
workaround pending upstream t_is_in list-column support.

Also includes the pyproject.toml mountainash dependency removal from
the earlier compiler refactor (mountainash provided via hatch.toml
test env path, not yet on PyPI).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rewrites engine.py and result.py to use mountainash.relations.Relation
and mountainash.expressions exclusively. Removes Polars from the engine
source tree (with one documented exception for SET_MEMBERSHIP).

Updates representation-fits-host-language.md principle to reflect the
one-engine-many-backends reality. Promotes status to ENFORCED via a
new test_backend_purity.py import-check test.

Depends on upstream Relation.count_rows() and Relation.item() additions
landed 2026-04-08.

Cross-backend test parameterisation deferred to a follow-up spec.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7-task TDD plan: failing backend-purity test first, tag SET_MEMBERSHIP
exception in compiler, rewrite engine.py to use mountainash.relations,
rewrite result.py to use relation/count_rows/item, full test suite +
lint, principle document update, final verification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ption

Tasks 1+2 of backend-agnostic engine plan:
- New tests/test_backend_purity.py asserts engine.py, result.py, and
  compiler.py have no direct polars/ibis/narwhals imports (with allow
  comment exceptions)
- compiler.py polars import tagged with allow comment for SET_MEMBERSHIP
  workaround

Test currently fails for engine.py only (expected — fixed in Task 3).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…inash.relations.Relation

Replace direct polars imports (pl.col, pl.lit, pl.min_horizontal, pl.sum_horizontal)
with mountainash.expressions (ma.col, ma.lit, ma.least) and mountainash.relations.Relation
for the evaluation pipeline, eliminating the polars coupling that was blocking the
backend-purity test for engine.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces Polars-specific idioms (shape[0], df[col]==value, row[col][0])
with relation().count_rows() and relation().item() calls. Terminal
accessors (best_match, at_least) use .collect().collect() to match
engine.py's materialization pattern. RuleResult source has zero
direct DataFrame-library imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three source fixes exposed by cross-backend testing:

1. REGEX becomes a one-hot context validator. Pattern lives on
   Dimension.regex_pattern metadata rather than a per-rule column.
   Upstream mountainash-expressions mandates literal-only regex
   patterns; the old column-ref form only worked on Polars by
   accident. Multiple patterns = multiple dimensions.

2. Specificity sum cast to int in engine.py. Polars auto-promoted
   bool+bool to int; SQLite/DuckDB/pandas/narwhals didn't.

3. Drop double .collect().collect() now that
   mountainash.relations.Relation.collect() materializes in one call
   on every backend.

Adds tests/__init__.py so tests can import conftest helpers. Adds
pytest_collection_modifyitems in conftest to non-strict xfail tests
on backends with known upstream bugs (pandas/narwhals-pandas
literal-alias collision, ibis-polars missing WindowFunction).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Non-SET classes auto-parametrize via backend_name fixture. Fraud
detection class parametrizes over LIST_CAPABLE_BACKENDS with
strict xfail on non-polars backends pending
mountainash-io/mountainash#75.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds SET_MEMBERSHIP/SET_EXCLUSION cases with strict xfail on
non-Polars backends pending mountainash-io/mountainash#75.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
mountainash-io/mountainash#77 (narwhals-pandas literal alias)
mountainash-io/mountainash#78 (ibis-polars WindowFunction)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@discreteds

Copy link
Copy Markdown
Member Author

Cross-backend test parameterisation landed

Mirrors the mountainash-expressions conftest exemplar — tests now run across all 7 mountainash-supported backends. Also includes three source fixes surfaced by the migration.

Source fixes

  • REGEX redesign — pattern now lives on Dimension.regex_pattern metadata as a literal str, not a per-rule column. The old column-ref form only worked on Polars by accident; upstream mountainash-expressions mandates literal-only regex patterns. Model: one dimension = one literal pattern; for multiple patterns, use multiple dimensions (one-hot style). REGEX dimensions act as global context validators — every rule in the engine shares the same ternary outcome.
  • Specificity bool→int castengine.py now casts each eq(lit(1)) to int before summing. Polars auto-promoted bool+bool→int; SQLite/DuckDB/pandas/narwhals didn't, so __specificity came back as True/False.
  • Drop double .collect().collect()mountainash.relations.Relation.collect() now materializes in one call on every backend.

Test suite

  • tests/conftest.py rewritten with ALL_BACKENDS, LIST_CAPABLE_BACKENDS, backend_name/list_backend_name param fixtures, build_backend_df helper, backend_rules_df, basic_engine — all tests auto-parametrize transitively.
  • test_engine.py, test_result.py, test_integration.py: backend-agnostic reads via relation(df).to_dict() instead of Polars-specific assertions.
  • test_compiler.py::TestBackendAgnosticism extended from 2 → 7 backends with SET_MEMBERSHIP/SET_EXCLUSION cases under strict xfail on non-polars backends.
  • test_integration.py::TestMixedStrategyFraudDetection parametrized over LIST_CAPABLE_BACKENDS with strict xfail on non-polars.
  • tests/__init__.py added so tests can import helpers from conftest.

Upstream bugs filed and tracked

Two distinct upstream issues surfaced during migration, both marked non-strict xfail via pytest_collection_modifyitems in conftest:

SET_MEMBERSHIP upstream gap remains tracked as mountainash-io/mountainash#75.

Results

247 passed, 71 xfailed, 85 xpassed, 0 failed

The 85 xpasses are tests on the upstream-broken backends that happen to avoid the broken code paths — non-strict xfail lets them pass cleanly. Once #77, #78, and #75 land, removing entries from UPSTREAM_BROKEN_BACKENDS and dropping the SET xfail markers will flip those xpasses to passes and surface any lingering incompatibilities.

Commits in this batch

  • 5845023 test: rewrite conftest for cross-backend parameterisation
  • a5348fd fix: REGEX as one-hot; cast specificity; drop double collect
  • 9c8683a test(result): parametrize across all 7 backends
  • 21d2af2 test(integration): parametrize all classes across backends
  • 8ccf0d6 test(compiler): extend backend agnosticism smoke tests to 7 backends
  • 45a83b7 test: link upstream-broken backend xfails to issues

discreteds and others added 4 commits April 8, 2026 23:05
mountainash-io/mountainash#75 landed — t_is_in now
accepts list-typed column arguments polymorphically. The rules
engine's last direct polars import is gone, and every match strategy
compiles cleanly on every list-capable backend.

- compiler.py: _compile_set_membership and _compile_set_exclusion
  use ctx_col.t_is_in(rule_col) / t_is_not_in(rule_col) directly
- drop import polars as pl escape hatch
- test_compiler.py + test_integration.py: flip SET xfails to pass
- conftest.py: remove SET_MEMBERSHIP_XFAIL_REASON constant, drop
  narwhals-polars from LIST_CAPABLE_BACKENDS (narwhals 2.19.0
  list.contains gap), include list_backend in the UPSTREAM_BROKEN
  xfail hook param names

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The narwhals-pandas literal collision is NOT fixable with a local
workaround — mountainash.relations.Relation defers with_columns calls
and batches all pending projections into a single narwhals call at
collect() time. Per-expression calls in engine.py are cosmetic.

Adds test_upstream_regressions.py with a strict-xfail regression test
encoding the exact trigger (batched sentinel-aware ternary expressions
compiled by DimensionCompiler on narwhals-pandas). When narwhals fixes
their intermediate column naming, the xfail flips to xpass and forces
a cleanup.

Updated #77 with the real diagnosis and reproducer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Blanket non-strict xfails on pandas/narwhals-pandas/ibis-polars produced
91 xpasses (noise) alongside 53 real failures. Replace with curated
test×backend lists per upstream issue, using strict=True so CI flags
when upstream fixes land.

- #77 (pandas, narwhals-pandas): 13 tests that hit batched ternary expressions
- #78 (ibis-polars): 27 tests that reach with_row_index in the pipeline
- Result: 343 passed, 4 skipped, 54 xfailed, 0 xpassed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ails

mountainash-expressions 0.1.1 fixes #77 (narwhals-pandas batched literal
collision). Remove all pandas/narwhals-pandas xfails and convert the #77
regression test to a normal passing test.

ibis-polars #78 (WindowFunction translation) remains — restore surgical
xfails for the 27 affected tests.

Result: 370 passed, 4 skipped, 27 xfailed, 0 xpassed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@discreteds
discreteds merged commit b839c7b into develop Apr 11, 2026
2 of 4 checks passed
@discreteds
discreteds deleted the feature/expression-based-engine branch April 11, 2026 05:37
@discreteds discreteds mentioned this pull request Apr 28, 2026
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant