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 bandRule
HIGHdecision is BLOCK, or score is >= 80
MEDIUMdecision is REVIEW, or score is >= 40
LOWdecision 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_samples

Route 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

EngineConfig.output_detail controls how much per-record detail the engine
materializes:

COUNTSCODESDECISIONSBITMASKS
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 costLowest result costCompact row-level verdictsNormal routing outputAdds one ⌈n/8⌉-byte mask per rule
📘

The build default is OutputDetail.BITMASKS

A freshly constructed EngineConfig() uses BITMASKS. Set the detail level
explicitly in production: COUNTS for aggregate-only benchmark/monitoring
paths, CODES for compact routing, DECISIONS for normal row-level decisions,
and BITMASKS only when you need to know which rules fired on which
records (audit, rule-overlap analysis, debugging a decision).

🚧

Reference OutputDetail by name — never by raw integer

COUNTS and CODES were inserted before DECISIONS and BITMASKS in the
enum, so the underlying integer values shifted: DECISIONS and BITMASKS
used to be 0 and 1 and are now 2 and 3. Anything that stored
output_detail as a raw integer — a hardcoded 0/1 in C++ instead of
EngineConfig::OUTPUT_DECISIONS, or blazerules.OutputDetail(1) in Python
instead of blazerules.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 eval defaults to DECISIONS detail (routing-only), except
aggregate output modes such as none, summary, and rule-counts use
COUNTS. A freshly
constructed EngineConfig() in the library defaults to BITMASKS. 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(...)   -> KeyError

If 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


Did this page help you?