Business rules that live in code are slow to change — every update requires a code review, a deploy, and a release cycle. This engine moves the logic into metadata: rules are rows in a DataFrame (or a database table), and the engine evaluates them against a context without any rule-specific code. Change a rule, reload the DataFrame, and the new behaviour applies immediately. No redeploy. No code change. No branching logic to maintain.
The engine evaluates rules against a context and ranks matches by specificity. Define dimensions with match strategies (exact, range, regex, prefix, set membership, etc.), pass a context, and get back the most specific matching rules — ranked, explained, and ready to use.
Built on mountainash expressions for backend-agnostic evaluation. Rules compile once; contexts evaluate in a single vectorised pass. Supports Polars, Pandas, Ibis (DuckDB, SQLite, Polars), and Narwhals backends with zero code changes.
- Rules change faster than code. Pricing tiers, eligibility criteria, fraud thresholds — these are business decisions that shift weekly. When rules are data, a product owner can update them in a database and the engine picks up the change on the next evaluation. No PR, no deploy, no downtime.
- Rules are auditable. Every rule is a row with a name, dimensions, and match criteria. You can diff two rule sets, version them in a table, and explain exactly why a context matched — because the engine tracks per-dimension ternary results (match / unknown / non-match) for every rule.
- Rules scale without branching. A hand-coded rule system with 2,000 rules is unmaintainable. A DataFrame with 2,000 rows is just data. The engine evaluates all of them in one vectorised pass regardless of count.
import polars as pl
from mountainash_utils_rules import (
ExpressionRulesEngine, Dimension, DimensionsMetadata, MatchStrategy,
)
from pydantic import BaseModel
# 1. Define your rules as a DataFrame
rules = pl.DataFrame({
"rule_name": ["premium_au", "standard", "fallback"],
"region": ["AU", "AU", "<NA>"], # <NA> = wildcard
"spend_min": [1000, 0, -999999999], # -999999999 = wildcard
"spend_max": [9999, 999, -999999999],
})
# 2. Declare how each dimension matches
metadata = DimensionsMetadata(dimensions=[
Dimension(dimension_name="region", match_strategy=MatchStrategy.EXACT, data_type=str),
Dimension(
dimension_name="spend", match_strategy=MatchStrategy.RANGE, data_type=int,
range_min_field="spend_min", range_max_field="spend_max",
),
])
# 3. Build the engine (compiles expressions once)
engine = ExpressionRulesEngine(rules=rules, dimension_metadata=metadata)
# 4. Evaluate a context
class CustomerContext(BaseModel):
region: str
spend: int
result = engine.evaluate(CustomerContext(region="AU", spend=1500))
print(result.count) # 2 — premium_au and fallback survive
print(result.best_match) # premium_au (matches both dimensions)
print(result.explain("premium_au")) # {"region": 1, "spend": 1} — both match
print(result.explain("fallback")) # {"region": 0, "spend": 0} — both wildcard| Strategy | Rule Column | Description |
|---|---|---|
EXACT |
Scalar value | Context value equals rule value |
NOT_EQUAL |
Scalar value | Context value differs from rule value |
RANGE |
Two columns (min/max) | Context value within [min, max] |
GREATER_THAN |
Threshold | Context value > rule threshold |
LESS_THAN |
Threshold | Context value < rule threshold |
PREFIX |
String | Context value starts with rule value |
SUFFIX |
String | Context value ends with rule value |
CONTAINS |
Substring | Context value contains rule value |
REGEX |
Pattern | Context value matches rule pattern |
SET_MEMBERSHIP |
List | Context value is in rule's list |
SET_EXCLUSION |
List | Context value is not in rule's list |
Wildcard values (<NA> for strings, -999999999 for numerics) produce an UNKNOWN result — the rule is not eliminated but scores lower on specificity.
The engine is backend-agnostic. Pass any supported DataFrame type as rules:
| Backend | Type |
|---|---|
| Polars | pl.DataFrame |
| Pandas | pd.DataFrame |
| Narwhals (Polars) | nw.from_native(pl.DataFrame(...)) |
| Narwhals (Pandas) | nw.from_native(pd.DataFrame(...)) |
| Ibis (DuckDB) | ibis.duckdb.connect().create_table(...) |
| Ibis (Polars) | ibis.polars.connect().create_table(...) |
| Ibis (SQLite) | ibis.sqlite.connect().create_table(...) |
All backends produce identical results. Polars is recommended for performance.
# Development install with hatch
git clone https://github.com/mountainash-io/mountainash-utils-rules.git
cd mountainash-utils-rules
hatch env createRequires sibling checkouts of mountainash, mountainash-data, and mountainash-settings (see hatch.toml for path configuration).
The production textbook is
built from main; the development textbook
is built from develop. Each push to either branch builds both snapshots and
publishes them together. A failed build leaves the previous paired site live.
For initial activation, merge the textbook changes into both branches and
configure Pages, its environment, and the shared custom domain first. Then set
the repository Actions variable TEXTBOOK_PUBLISHING_ENABLED to true and
manually dispatch deploy-textbook.yml. Until enabled, publishing runs are
skipped; a one-sided bootstrap cannot deploy an incomplete site.
The source artifacts live together in this repository:
docs-site/profile/: package profile and source provenance.docs-site/learning-graph/: canonical graph and FAQ artifacts.docs-site/site/: MkDocs configuration, textbook Markdown, and refresh state.
Preview locally without installing the source package or sibling repositories:
uv run --no-project --with-requirements docs-site/requirements.txt \
python -m mkdocs serve --config-file docs-site/site/mkdocs.ymlRefreshes are manual. Load textbook-refresh from the central
hiivmind-documentation-profile tooling project and supply this repository's
absolute root as source_repo, starting with mode: check. For a separate
profile update, supply docs-site/profile/ as the profiler's explicit output.
Do not regenerate content merely to publish it or advance source baselines on
a directory move. Preserve the existing FAQ format; the marker-only FAQ
exporter does not support it and must not overwrite its JSON.
The inherited chapters include specifications for 32 simulations whose HTML implementations do not yet exist. Their missing embeds are an accepted pre-existing content gap for this migration, not a working simulation library.
| Command | Description |
|---|---|
hatch run test:test-quick |
Run tests (no coverage) |
hatch run test:test |
Run tests with coverage reports |
hatch run test:test-target-quick tests/path.py -v |
Run specific tests |
hatch run test:test-perf |
Run performance benchmarks |
hatch run test:test-perf-save |
Benchmarks + save JSON baseline |
hatch run ruff:check |
Lint |
hatch run ruff:fix |
Lint + auto-fix |
hatch run mypy:check |
Type check |
hatch run radon:radon-cc |
Cyclomatic complexity |
The engine uses signed-integer ternary logic (-1 = non-match, 0 = unknown, 1 = match) to evaluate each rule dimension independently, then combines results in a single vectorised pass:
- Compile —
DimensionCompilerconverts dimension metadata into backend-agnostic expression templates at construction time. - Bind — Context values are injected as literal columns alongside the rules DataFrame.
- Evaluate — All dimension expressions execute in one
with_columnscall, producing a ternary value per dimension per rule. - Rank — Rules with any -1 are eliminated. Survivors are ranked by specificity (count of 1s). More specific rules rank higher.
The engine and result layer use only mountainash.relations and mountainash.expressions — no direct backend imports. See CLAUDE.md for full architectural details.
This package is part of the Mountain Ash data framework ecosystem.
MIT — see LICENSE for details.