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 produces four things from the rules that matched: a categorical decision (chosen by precedence), a numeric score (accumulated from weights), a risk band, and the winning rule. This page explains how each is derived and how you route on the results.
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.
You can override the ladder — and the default action for records that match nothing — 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.
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 carries everything you need to route records. The key 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 when you need to know which rules fired on which
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 when you need the opposite of a given interface's 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(...) -> KeyErrorIf you only need aggregate counts (not which records), use OutputDetail.COUNTS.
It returns result.match_counts and batch counters without materializing row-level
decision strings, grouped indices, model output channels, or per-rule bitmasks.
Where to go next
Updated about 12 hours ago