Decisions & Scoring
How BlazeRules turns matched rules into a decision, a score, a risk band, and a winning rule per record.
For every record, BlazeRules can produce a categorical decision selected by precedence, a numeric score accumulated from rule weights, a risk band, and the winning rule. These outputs support direct routing and audit attribution.
Actions
Each rule declares an action, the categorical verdict it votes for when it matches:
approve · flag · review · block · score
The precedence ladder
When several rules match one record, the record's decision is the strongest matched action, chosen by a precedence ladder. The default ladder, from strongest to weakest:
block > review > flag > score > approve
Ties are broken by severity, then by priority.
Higher severity wins over lower severity; if severity is also tied, the higher numeric priority wins.
Override the precedence ladder and the no-match default with a top-level decisions: block:
decisions:
default: approve
precedence: [approve, score, flag, review, block]Here default: approve is the verdict for unmatched records, and precedence reorders the ladder (listed weakest to strongest in this example).
precedence is ordered weakest to strongest. A later label in the list outranks an earlier label.
Custom decision labels
A rule's action must stay one of the five built-ins (it drives scoring and risk bands), but an optional label lets the rule emit a custom decision string instead of the action name:
decisions:
precedence: [approve, score, flag, review, bot_block, block]
rules:
- id: bot_traffic
action: block
label: bot_block
conditions: {field: bot_score, op: gt, value: 0.9}bot_block then appears as the decision in result.decisions, grouped_decision_indices(), the agent decision log, and the dashboard. Add custom labels to precedence to place them on the ladder; a custom label not listed there ranks above every built-in.
See E-commerce Rule Example for a complete precedence ladder containing PROMO_ABUSE_FLAG, CHARGEBACK_REVIEW, ACCOUNT_TAKEOVER, and BOT_BLOCK.
Scoring and risk bands
Independently of the decision, every non-shadow matched rule's weight is added into the record's score. If a matching rule has no weight and its action is not approve, the engine contributes a severity-derived default score. A positive score_cap caps the accumulated score after that rule contributes.
The result exposes per-record scores, and each score maps to a risk_bands value:
| Risk band | Rule |
|---|---|
HIGH | decision is BLOCK, or score is >= 80 |
MEDIUM | decision is REVIEW, or score is >= 40 |
LOW | decision is FLAG, score is positive, or no higher band matched |
Decision vs score
These are two separate outputs derived from the same matches:
Decision — a categorical verdict (approve / flag / review / block). Chosen by the precedence ladder among matched actions.
Score — a number, the sum of the weights of all matched rules. Mapped to a risk band (HIGH / MEDIUM / LOW).
Shadow rules
A rule marked shadow: true is evaluated and counted, but excluded from the verdict. Use shadow rules to measure a candidate rule's match rate against live traffic before letting it affect decisions — a safe rollout path.
Shadow matches still appear in match_counts / rule_match_counts. They do not add score, do not change decision_codes, and cannot become the winning_rule_id.
Routing outputs
The result object exposes the following routing fields:
n_records, n_matched,
decisions, decision_codes,
scores, risk_bands,
winning_rule_ids, match_counts,
matched_indices, timing,
messages_processed, messages_skipped,
error_counts, error_samplesRoute with the result's helper methods:
approved = result.indices_for_decision("APPROVE")
not_approved = result.indices_for_not_decision("APPROVE")
by_decision = result.grouped_decision_indices()Output detail: COUNTS, CODES, DECISIONS, and BITMASKS
COUNTS, CODES, DECISIONS, and BITMASKSEngineConfig.output_detail controls how much per-record detail the engine
materializes:
COUNTS | CODES | DECISIONS | BITMASKS | |
|---|---|---|---|---|
| Batch totals, ingest counters, timings | ✅ | ✅ | ✅ | ✅ |
match_counts — records matched by each rule | ✅ | ✅ | ✅ | ✅ |
Compact decision_codes and label map | ❌ | ✅ | ✅ | ✅ |
| Per-record verdict, score, risk band, winning rule | ❌ | ❌ | ✅ | ✅ |
Routing groups (indices_for_decision, grouped_decision_indices, …) | ❌ | ❌ | ✅ | ✅ |
| Model score output channels | ❌ | ❌ | ✅ | ✅ |
Per-rule, per-record match mask (result[rule_id], indices_for_rule) | ❌ | ❌ | ❌ raises KeyError | ✅ |
| Relative cost | Lowest result cost | Compact row-level verdicts | Normal routing output | Adds one ⌈n/8⌉-byte mask per rule |
The build default isOutputDetail.BITMASKSA freshly constructed
EngineConfig()usesBITMASKS. Set the detail level
explicitly in production:COUNTSfor aggregate-only benchmark/monitoring
paths,CODESfor compact routing,DECISIONSfor normal row-level decisions,
andBITMASKSonly for per-rule, per-record attribution.
records (audit, rule-overlap analysis, debugging a decision).
ReferenceOutputDetailby name — never by raw integer
COUNTSandCODESwere inserted beforeDECISIONSandBITMASKSin the
enum, so the underlying integer values shifted:DECISIONSandBITMASKS
used to be0and1and are now2and3. Anything that stored
output_detailas a raw integer — a hardcoded0/1in C++ instead of
EngineConfig::OUTPUT_DECISIONS, orblazerules.OutputDetail(1)in Python
instead ofblazerules.OutputDetail.DECISIONS— will silently resolve to a
different, cheaper tier after upgrading. Always reference the enum by name,
never by number.
The match masks are computed internally in both modes (scores and the winning rule
depend on them), so BITMASKS does not recompute anything — it only persists the
masks. The extra cost is memory and copy traffic that scales with
records × rules: e.g. 1M records × 100 rules ≈ 12.5 MB of extra buffers per batch.
From the CLI
blazerules eval selects the detail level with --output-detail counts|codes|decisions|bitmasks
(the shorthand --output bitmasks emits the per-rule bitmask output too):
# Aggregate-only benchmark/evaluation path
blazerules eval --rules rules.yaml --path events.ndjson --output none
# Routing-only: decisions, scores, risk bands, winning rules
blazerules eval --rules rules.yaml --path events.ndjson
# Materialize per-rule bitmasks for attribution/audit
blazerules eval --rules rules.yaml --path events.ndjson --output-detail bitmasks
The CLI and library have different detail defaults
blazerules evaldefaults toDECISIONSdetail (routing-only), except
aggregate output modes such asnone,summary, andrule-countsuse
COUNTS. A freshly
constructedEngineConfig()in the library defaults toBITMASKS. Set the value
explicitly to override an interface default.
Using the outputs in Python
Normal row-level routing requires DECISIONS or BITMASKS:
import blazerules
config = blazerules.EngineConfig()
config.output_detail = blazerules.OutputDetail.DECISIONS # routing-only, lighter
engine = blazerules.RuleEngine(config)
engine.load_rules("rules.yaml")
result = engine.evaluate_ndjson(payload)
# Per-record outputs
result.decisions # list[str] — the verdict per record, e.g. "BLOCK"
result.scores # list[float]
result.risk_bands # list[str] — "LOW" / "MEDIUM" / "HIGH"
result.winning_rule_ids # list[str]
result.match_counts # dict[str, int] — matches per rule id (aggregate)
result.n_records, result.n_matched
# Route by decision
block_rows = result.indices_for_decision("BLOCK") # numpy int32 array of row indices
non_approved = result.indices_for_not_decision("APPROVE")
by_decision = result.grouped_decision_indices() # dict[str, np.ndarray]Per-rule attribution requires BITMASKS:
config = blazerules.EngineConfig()
config.output_detail = blazerules.OutputDetail.BITMASKS
engine = blazerules.RuleEngine(config)
engine.load_rules("rules.yaml")
result = engine.evaluate_ndjson(payload)
mask = result["velocity_rule"] # np.ndarray[bool], one entry per record
rows = result.indices_for_rule("velocity_rule") # row indices where that rule fired
# Under OutputDetail.DECISIONS these raise KeyError, because per-rule masks
# are not materialized:
# result["velocity_rule"] -> KeyError
# result.indices_for_rule(...) -> KeyErrorUse OutputDetail.COUNTS when only aggregate counts are required.
It returns result.match_counts and batch counters without materializing row-level
decision strings, grouped indices, model output channels, or per-rule bitmasks.
Related documentation
Updated about 1 month ago