Chapter 7: Expression Engine Results¶
Summary¶
This chapter covers the result objects returned by the ExpressionRulesEngine. You will learn how RuleResult exposes surviving rules, how ExplainResult preserves every scored rule for diagnostics, how engine-level explanation differs from per-rule explanation, and how to apply post-evaluation filters and hit-policy selection safely.
Why a Result Wrapper?¶
The evaluation pipeline (Chapter 5) produces a materialized DataFrame containing survived rules with their ternary columns, specificity scores, and ranks. While this DataFrame could be used directly, the RuleResult class provides a clean, backend-agnostic interface for the most common access patterns — retrieving the best match, counting survivors, and explaining individual rule outcomes.
The wrapper also decouples the engine's internal column naming conventions (prefixed with __) from the caller's code. Rather than remembering that specificity lives in __specificity and ternary values in __t_region, callers use named methods that hide these implementation details.
The RuleResult is also designed as a base class. The accumulator engine (Chapter 9) extends it with AccumulatorResult, adding aggregate and provenance accessors. This inheritance ensures that any code written against RuleResult also works transparently with AccumulatorResult — the accumulator workflow is a strict superset of the expression workflow.
RuleResult Class¶
The RuleResult class is constructed by the engine at the end of evaluation. It receives the materialized result DataFrame, the active dimension names, and optional selection metadata used by post-hoc policy selection:
dataframe: the materialized result DataFrame (survivors, ranked)active_dimensions: the list of dimension names that were evaluatedselection_info: optionalSelectionInfometadata describing policy-related fields and whether evaluation truncated the result
class RuleResult:
def __init__(
self,
dataframe,
active_dimensions: list[str],
selection_info: SelectionInfo | None = None,
) -> None:
self._df = dataframe
self._active_dimensions = active_dimensions
self._selection_info = selection_info
All accessors on RuleResult reach the underlying DataFrame through mountainash.relations.relation(), maintaining backend agnosticism. The only exception is the survivors property, which returns the raw DataFrame directly for callers who want backend-specific operations.
Survivors Accessor¶
The survivors property returns the complete result DataFrame — all rules that passed the survival filter, ordered according to the active hit policy (specificity descending with deterministic input-order tie-breaking by default), with rank assignments.
The returned DataFrame contains:
- All original rule columns (rule_name, payload fields, dimension columns)
__specificity: integer count of hard-match dimensions__rank: 1-based rank under the active hit-policy ordering__t_{dim_name}columns (if observability was enabled): per-dimension ternary values
The DataFrame is in the same backend as the input rules. If you passed a Polars DataFrame to the engine constructor, survivors returns a Polars DataFrame. This allows callers to chain backend-specific operations (filtering, aggregation, export) on the result.
Best Match Accessor¶
The best_match property returns the single surviving rule at rank 1 under the active hit-policy ordering. With the default COLLECT ordering, this is the most specific surviving rule. It returns a one-row DataFrame (not a scalar), preserving all columns.
Internally, this takes the first row via relation(self._df).head(1).collect(). If no rules survived (empty result), best_match returns an empty DataFrame rather than raising an error.
This accessor is the most common entry point for callers who need a single definitive answer: "given this context and hit policy, which rule is ranked first?"
Count Accessor¶
The count property returns the number of surviving rules as a plain integer:
This is useful for validation and branching logic:
count == 0: no rules matched the context (may indicate a gap in the rule set)count == 1: exactly one rule matched (unambiguous result)count > 1: multiple rules matched (caller may need to inspect specificity or apply additional filters)
Diagram: RuleResult API Overview¶
RuleResult API Overview
Type: diagram
sim-id: rule-result-api
Library: vis-network
Status: Specified
Purpose: Interactive class diagram showing all RuleResult properties and methods with their return types and relationships.
Components: - Central node: RuleResult class - Property nodes: survivors, best_match, count, active_dimensions (with return type labels) - Method nodes: explain(rule_name), at_least(n), select(policy, priority_field=None) (with parameter and return type labels) - Edge labels showing data flow from internal _df to each accessor
Interactions: Click any accessor node to see a code example and sample output. Hover for a tooltip with the docstring. Click "show internals" to reveal the relation() calls inside each accessor.
Learning objective: Navigate the RuleResult API to select the appropriate accessor for a given use case (Bloom: Apply)
Active Dimensions¶
The active_dimensions property returns the list of dimension names that were evaluated. This may be a subset of all configured dimensions if the caller passed a dimensions parameter to evaluate().
This property is primarily used by the explain() method to know which ternary columns to inspect, but it is also useful for logging and debugging — confirming which dimensions contributed to the evaluation.
RuleResult Explain Method¶
The RuleResult.explain() method provides per-dimension ternary values for a specific rule, identified by its rule_name column value. This is the primary debugging tool for understanding why an already-surviving rule received its specificity score.
explanation = result.explain("au_premium")
# {"region": 1, "tier": 1} — both dimensions matched
explanation = result.explain("base")
# {"region": 0, "tier": 0} — both dimensions were wildcards
The method:
- Filters the result DataFrame for rows where
rule_nameequals the argument - Extracts the
__t_{dim_name}column values for each active dimension - Returns a dictionary mapping dimension names to ternary integers (1, 0, or -1)
- Raises
KeyErrorif the rule name is not found in the survivors
This per-rule method is deliberately narrower than the engine-level explain(context) introduced below: it answers one question about one rule that has already survived, while the engine-level API scores and reports on the whole table.
The explain output provides immediate diagnostic value:
| Ternary Value | Meaning | Implication |
|---|---|---|
| 1 | Hard match | Dimension explicitly matched context — contributed to specificity |
| 0 | Unknown/wildcard | Dimension did not constrain — neither helped nor hurt |
| -1 | Non-match | Should not appear in a surviving RuleResult row |
A -1 value in a RuleResult.explain() result indicates an internal inconsistency — it should never appear because survival filtering removes all rules with any -1 dimension. The engine-level ExplainResult is different: it intentionally includes non-survivors, so -1 values are expected there.
ExplainResult Class¶
ExplainResult wraps every rule scored against one context. Its unfiltered frame retains the original rule columns plus one ternary column per active dimension (__t_<dimension>), the boolean __survived flag, and the integer __specificity score. The frame also retains __rule_index as a stable rule identity, but it has no __rank.
diagnostic = engine.explain({"region": "AU", "channel": "BROKER"})
all_scored_rules = diagnostic.frame
Every row remains in frame, including rules that contain a non-match. The convenience properties provide filtered views without changing that complete frame:
frame: all scored rules, whether they survived or notsurvivors: rows where__survivedisTruenon_survivors: rows where__survivedisFalsecount: the number of rows in the complete frameactive_dimensions: the dimensions used for scoring
For example, a two-dimension diagnostic can expose the exact failing dimension instead of silently dropping that rule:
| Rule | __t_region |
__t_channel |
__survived |
__specificity |
|---|---|---|---|---|
both_match |
1 | 1 | True |
2 |
one_miss |
1 | -1 | False |
1 |
wildcard |
0 | 0 | True |
0 |
ExplainResult does not perform a survival filter, ranking, hit-policy assertion, or cardinality selection. Those are RuleResult responsibilities after evaluation. Thus ExplainResult is a diagnostic scoring record for the whole table, whereas RuleResult is a selected result over surviving rows.
ExplainResult is intentionally not a RuleResult subclass: RuleResult's selection surface (best_match, select(), and selection metadata) assumes a ranked survivor frame, while ExplainResult must preserve the complete unranked diagnostic frame.
Engine-Level Explain¶
ExpressionRulesEngine.explain() answers the table-wide diagnostic question: why did or did not each rule match this context? It returns an ExplainResult, so a caller can inspect both survivors and non-survivors in one pass.
Its signature accepts the same context shape as evaluate() and an optional subset of dimensions:
def explain(
self,
context: BaseModel | dict,
dimensions: list[str] | None = None,
) -> ExplainResult:
With dimensions=None, all compiled dimensions are scored. Passing dimensions=["region", "channel"] limits both the ternary columns and the specificity calculation to that subset. Unknown dimension names raise KeyError; an engine with no active dimensions raises ValueError.
diagnostic = engine.explain(
{"region": "AU", "channel": "BROKER"},
dimensions=["region", "channel"],
)
for row in diagnostic.non_survivors.to_dicts():
print(row["rule_name"], row["__t_region"], row["__t_channel"])
The engine-level method shares the scoring work of evaluate() through the private _scored_relation(active_dims, context_values) helper. This helper performs the common early pipeline: it guards reserved columns and adds __rule_index, binds context literals as __ctx_<dimension> columns, computes all __t_<dimension> ternaries, and derives __survived plus __specificity. evaluate() then filters survivors, orders and ranks them, checks hit-policy assertions, applies cardinality and optional truncation, and drops temporary columns. explain() instead drops the temporary context columns and collects the complete scored relation immediately.
Sharing _scored_relation means explanation and evaluation cannot silently grow different matching or specificity logic: both report the same ternary values and the same survival calculation. Their difference is intentional and begins after scoring. RuleResult.explain(rule_name) is narrower still: it looks up one already-surviving row, while ExpressionRulesEngine.explain(context) explains every rule in the table, including the rows that failed.
RuleResult Select Method¶
RuleResult.select(policy, priority_field=None) re-applies a hit policy after evaluation and returns a new RuleResult. Use it on an untruncated result produced with the COLLECT policy, so the complete survivor set is still available for the new selection.
from mountainash_rules import HitPolicy
collected = engine.evaluate(context, hit_policy=HitPolicy.COLLECT)
selected = collected.select(
HitPolicy.PRIORITY,
priority_field="salience",
)
The method reorders the retained survivors, recomputes their 1-based __rank, runs the policy's assertions, and applies its cardinality rule. priority_field overrides the field recorded in the evaluation metadata when the selected policy needs one.
Selection cannot recover rows that were discarded earlier. If top_n or min_specificity truncated the original result, select() raises ValueError; re-evaluate without truncation instead. The same principle is why the complete source should be COLLECT: a prior non-collecting cardinality decision has already narrowed the rows available for post-hoc selection. See Chapter 6: Hit Policies for the full semantics of each policy; this method only controls when those semantics are re-applied.
At Least Filter¶
The at_least(n) method returns a filtered DataFrame containing only survivors with specificity >= n. This is a post-evaluation convenience that applies a floor to the specificity score.
The method constructs a relation, filters on __specificity >= n, and collects. The returned DataFrame is in the same backend as the original result.
Use cases for at_least:
- Excluding catch-all rules (specificity 0) that survived only because all their dimensions are wildcards
- Requiring a minimum level of context matching before accepting a rule
- Implementing tiered fallback: try
at_least(3), fall back toat_least(2), thenat_least(1)
Top N Filtering¶
The top_n parameter on evaluate() limits the result to the N highest-ranked survivors under the active hit-policy ordering. Unlike at_least, this is applied during evaluation (after ranking, before result construction) rather than as a post-evaluation filter.
The filtering happens via rel.head(top_n) after sorting by specificity descending. This means:
- Rank 1-3 are retained; rank 4+ are discarded
- The
__rankvalues in the result are contiguous (1, 2, 3) - If fewer than N rules survive, all survivors are returned
top_n is useful when the caller needs a fixed-size result regardless of how many rules match — for example, displaying the "top 5 applicable offers" to a user.
Min Specificity Filter¶
The min_specificity parameter on evaluate() excludes survivors whose specificity falls below a threshold. Like top_n, it is applied during evaluation after rank assignment.
# Only rules that match on at least 2 dimensions
result = engine.evaluate(context, min_specificity=2)
The filtering happens via rel.filter(ma.col("__specificity").ge(ma.lit(min_specificity))) after ranking but before top_n. This ordering means:
- All rules are ranked by specificity
- Rules below
min_specificityare removed - Then
top_nis applied to the remaining rules
This allows combining both filters: "give me the top 5 rules that match on at least 3 dimensions."
The key difference from at_least() is timing:
| Filter | Applied During | Affects __rank | Method |
|---|---|---|---|
| min_specificity | Evaluation | Yes (gaps in ranks) | evaluate() parameter |
| top_n | Evaluation | Yes (truncated) | evaluate() parameter |
| at_least | Post-evaluation | No (original ranks preserved) | RuleResult method |
Observability Columns¶
When include_observability=True (the default), the result DataFrame retains the per-dimension ternary columns (__t_region, __t_tier, etc.). These columns are the foundation for debugging and monitoring rule behavior in production.
The observability columns enable several workflows:
- Explain: the
explain()method reads these columns directly - Monitoring: aggregate ternary distributions across evaluations to detect dimension drift
- Debugging: inspect which dimensions produced UNKNOWN vs TRUE for a rule set, identifying overly broad wildcard rules
- Analytics: compute per-dimension match rates across a batch of contexts
When observability is disabled (include_observability=False), these columns are dropped before the result is returned, reducing memory usage and network transfer size for production deployments where debugging is not needed.
# Production: minimal result
result = engine.evaluate(context, include_observability=False)
# Result has: original columns + __specificity + __rank (no __t_* columns)
# Development: full observability
result = engine.evaluate(context, include_observability=True)
# Result has: original columns + __t_* + __specificity + __rank
Diagram: Result DataFrame Column Layout¶
Result DataFrame Column Layout
Type: infographic
sim-id: result-column-layout
Library: p5.js
Status: Specified
Purpose: Visual representation of the result DataFrame's column groups, showing which columns are always present, which are optional (observability), and which were dropped during evaluation.
Components: - Column header bar divided into colored sections: Original Columns (white), Ternary Columns (green, optional), Computed Columns (purple, always present) - Toggle switch for observability on/off showing columns appearing/disappearing - Sample data rows below the headers
Interactions: Toggle observability to show/hide __t_* columns. Hover over column headers for tooltips explaining purpose and value range. Click a section to see the list of columns in that group for a sample 3-dimension evaluation.
Learning objective: Identify which columns appear in a RuleResult DataFrame under different observability settings (Bloom: Remember)
Understanding Specificity in Results¶
The __specificity column in the result DataFrame is the foundation for ranking and filtering. To fully leverage the result API, it helps to understand how specificity values distribute across different rule set designs.
Consider a 4-dimension rule set (region, tier, category, channel) evaluated against a context that provides values for all four dimensions:
| Rule | region | tier | category | channel | __specificity | Interpretation |
|---|---|---|---|---|---|---|
| R1 | AU | premium | electronics | online | 4 | Fully specific — matches all 4 dimensions |
| R2 | AU | premium | \<NA> | \<NA> | 2 | Matches region and tier, wildcards the rest |
| R3 | AU | \<NA> | \<NA> | \<NA> | 1 | Matches only region |
| R4 | \<NA> | \<NA> | \<NA> | \<NA> | 0 | Global fallback — all wildcards |
All four rules survive (no FALSE values), but their specificity scores create a clear hierarchy. Using result.at_least(2) would return only R1 and R2. Using result.best_match returns R1.
A specificity of 0 is valid and common — it represents fallback rules that apply universally but should be overridden by more specific rules.
Diagram: Specificity Distribution Dashboard¶
Specificity Distribution Dashboard
Type: chart
sim-id: specificity-distribution
Library: Chart.js
Status: Specified
Purpose: Interactive histogram showing the distribution of specificity scores across survivors for a configurable rule set and context.
Components: - Bar chart with specificity values (0 to max dimensions) on x-axis, rule count on y-axis - Overlay lines showing at_least and min_specificity thresholds as vertical markers - Summary statistics: total survivors, mean specificity, best match specificity
Interactions: Draggable threshold markers for at_least and min_specificity that dynamically gray out bars below the threshold. Dropdown to select different sample rule sets (small/medium/large). Hover over bars for a list of rule names at that specificity level.
Learning objective: Analyze specificity distributions to choose appropriate filtering thresholds (Bloom: Evaluate)
Chaining Result Operations¶
The RuleResult API is designed for sequential use — accessors return DataFrames or values that can be further processed. Several common chaining patterns arise in practice:
Pattern 1: Best-match payload extraction. Retrieve the best match and extract a payload column:
best_df = result.best_match
# Extract the discount value from the single-row DataFrame
discount = best_df["discount"][0]
Pattern 2: Tiered fallback. Try progressively lower specificity thresholds:
for min_spec in [3, 2, 1, 0]:
filtered = result.at_least(min_spec)
if len(filtered) > 0:
return filtered
return None # No rules matched at all
Pattern 3: Explain all survivors. Generate a diagnostic report:
report = {}
for rule_name in result.survivors["rule_name"]:
try:
report[rule_name] = result.explain(rule_name)
except KeyError:
pass # Should not happen for survivors
Working with Results in Practice¶
A typical production workflow combines multiple result accessors:
result = engine.evaluate(context)
if result.count == 0:
# No matching rules — use system default
return default_configuration
if result.count == 1:
# Unambiguous match
return extract_payload(result.best_match)
# Multiple matches — use the most specific
best = result.best_match
# Optionally log the runner-up for monitoring
if result.count > 1:
all_survivors = result.survivors
log_ambiguity(context, all_survivors)
return extract_payload(best)
For debugging, the explain method provides immediate insight:
result = engine.evaluate(context)
for rule_name in ["premium_au", "standard_au", "fallback"]:
try:
dims = result.explain(rule_name)
print(f"{rule_name}: {dims}")
except KeyError:
print(f"{rule_name}: did not survive")
Empty Results and Edge Cases¶
Several edge cases merit attention when working with RuleResult:
No survivors. When no rules match the context (all are eliminated by FALSE dimensions), result.count is 0, result.survivors returns an empty DataFrame with the correct schema, and result.best_match returns an empty single-row-attempt that is also empty. Callers should always check count before assuming best_match contains data.
All wildcards. When a rule has sentinels in every dimension, it always survives with specificity 0. This is a valid and common pattern for fallback rules, but it means result.count is never 0 as long as a catch-all rule exists. If you need to distinguish "a specific rule matched" from "only the fallback matched," check specificity rather than count.
Tied specificity. Under the default COLLECT ordering, survivors with the same specificity score receive consecutive ranks in their original DataFrame order. Other hit policies may add policy-specific ordering keys before the stable __rule_index tie-breaker. This deterministic behavior means results are reproducible across runs with the same input.
Single dimension. When only one dimension is evaluated (via the dimensions parameter), specificity can only be 0 or 1, and survival is equivalent to "the dimension did not return FALSE." This degenerates the ranking to a binary classifier on that dimension.
Diagram: Result Edge Case Explorer¶
Result Edge Case Explorer
Type: microsim
sim-id: result-edge-cases
Library: p5.js
Status: Specified
Purpose: Interactive explorer showing how RuleResult behaves under various edge cases: empty results, all-wildcard rules, tied specificities, and single-dimension evaluation.
Controls: - Edge case selector (dropdown): "No survivors", "All wildcards", "Tied specificity", "Single dimension" - Each case pre-loads a sample rule set and context
Visual elements: - Rules table with ternary evaluation results - Result accessor outputs displayed as labeled cards (count, best_match, survivors) - Warning badges for potential gotchas (e.g., "count > 0 but only fallbacks survived") - Color-coded rows: eliminated (red), survived (green), tied (yellow border)
Behavior: Selecting an edge case loads the scenario, runs evaluation visually, and shows all accessor outputs. Callout boxes highlight the specific edge case behavior.
Learning objective: Handle edge cases in RuleResult correctly by testing accessor behavior under boundary conditions (Bloom: Apply)
Key Takeaways¶
- RuleResult wraps the evaluation output DataFrame with a backend-agnostic accessor API, hiding internal column naming conventions from callers.
- The survivors property returns the full result DataFrame for backend-specific operations; best_match returns just the rank-1 rule.
- count gives a quick integer for branching logic (0 = no match, 1 = unambiguous, >1 = ambiguous).
- active_dimensions records which dimensions participated in evaluation, supporting partial-evaluation scenarios.
- RuleResult.explain(rule_name) returns per-dimension ternary values for one named survivor; it does not diagnose rules that were filtered out.
- ExplainResult keeps every rule's ternaries,
__survived, and__specificityin an unfiltered frame, with separate survivor and non-survivor views. - ExpressionRulesEngine.explain(context) explains the whole table and shares scoring with
evaluate()through_scored_relation; it does not rank or apply hit policies. - at_least(n) is a post-evaluation filter on specificity; top_n and min_specificity are evaluation-time filters that affect rank assignment.
- RuleResult.select(policy, priority_field=None) re-applies policy semantics only when the source result retains the complete, untruncated COLLECT survivor set; see Chapter 6 for policy definitions.
- Observability columns (
__t_*) are retained by default for debugging but can be excluded in production for reduced footprint. - The filter application order is: rank all survivors, apply min_specificity, apply top_n — enabling combined filtering with clear semantics.