Skip to content

Decision

The decision block controls how match scores are calculated, how pair decisions (match, review, reject) are made, and what happens when conflicts arise. This is where the engine translates rule evaluations into actionable outcomes.

Overview

Record Pair ─→ Evaluate Rules ─→ Calculate Score ─→ Apply Thresholds ─→ Decision

                                                         ┌────────────────┼────────────────┐
                                                         │                │                │
                                                       MATCH           REVIEW           REJECT
                                                    (auto-merge)    (human queue)    (not a match)

Every candidate pair is scored, and the score determines the decision. The decision block gives you precise control over this pipeline.

Scoring

Cannon supports three scoring methods: weighted_sum (default), ml_ensemble, and custom. Each method takes the raw rule evaluation results and produces a normalized score between 0.0 and 1.0 that feeds into the threshold-based decision.

weighted_sum (default)

The default and most commonly used scoring method. Each rule contributes its weight multiplied by its match result to the final score.

yaml
decision:
  scoring: weighted_sum

For each rule that fires:

  • Exact rules contribute either weight * 1.0 (match) or weight * 0.0 (no match)
  • Similarity rules contribute weight * similarity_score, where the similarity score is a float between 0.0 and 1.0 from the algorithm (e.g., Jaro-Winkler, Levenshtein)
  • Range rules contribute weight * 1.0 if the values are within the specified range, otherwise weight * 0.0

The raw score is the sum of all rule contributions. The normalized score divides by the maximum possible score (the sum of all weights).

Scoring Math Walkthrough

Let's trace through a concrete example. Given these rules:

yaml
rules:
  - name: email_exact
    type: exact
    field: email
    weight: 1.0

  - name: name_fuzzy
    type: similarity
    field: name
    algorithm: jaro_winkler
    threshold: 0.88
    weight: 0.6

  - name: phone_exact
    type: exact
    field: phone
    weight: 0.9

Pair under evaluation:

FieldRecord ARecord B
email[email protected][email protected]
nameJane SmithJayne Smyth
phone+1-555-0100+1-555-9999

Step 1: Evaluate each rule

RuleTypeResultRaw Score
email_exactexactEmails match exactly1.0
name_fuzzysimilarityJaro-Winkler("Jane Smith", "Jayne Smyth") = 0.920.92
phone_exactexactPhone numbers differ0.0

Step 2: Calculate weighted contributions

RuleWeightRaw ScoreContribution
email_exact1.01.01.0 * 1.0 = 1.000
name_fuzzy0.60.920.6 * 0.92 = 0.552
phone_exact0.90.00.9 * 0.0 = 0.000

Step 3: Sum and normalize

Total raw score   = 1.000 + 0.552 + 0.000 = 1.552
Max possible      = 1.0   + 0.6   + 0.9   = 2.500
Normalized score  = 1.552 / 2.500          = 0.621

Step 4: Apply thresholds

With the following thresholds:

yaml
decision:
  thresholds:
    match: 0.9
    review: 0.5

The score of 0.621 is:

  • Below match (0.9), so not an automatic match
  • Above review (0.5), so goes to the review queue

Decision: REVIEW

Why Normalization Matters

Without normalization, adding more rules would inflate scores, making thresholds meaningless across different specs. Normalization ensures the score always falls between 0.0 and 1.0, regardless of how many rules you define or how you set their weights.

ScenarioRaw ScoreMax PossibleNormalized
2 rules, both match1.61.61.0
5 rules, 3 match2.13.50.6
10 rules, 8 match7.28.80.818

ml_ensemble

ML ensemble uses logistic regression to score pairs. Instead of a simple linear weighted sum, it applies a sigmoid function to produce a match probability. You provide per-rule coefficients and a bias term, typically learned from labeled training data.

Formula: score = σ(bias + Σ(coefficient_i × score_i))

Where σ is the sigmoid function: σ(x) = 1 / (1 + e^(-x))

yaml
decision:
  scoring:
    method: ml_ensemble
    ml_ensemble:
      coefficients:
        email_exact: 2.5
        name_fuzzy: 1.8
        phone_exact: 1.2
      bias: -3.0

How it works:

  • coefficients maps rule names to learned weights. These can be negative; a negative coefficient treats the rule as an anti-signal (match on that rule lowers confidence).
  • bias is the intercept term. A negative bias means the model defaults to non-match unless enough positive evidence accumulates.
  • The sigmoid output is naturally between 0.0 and 1.0, so no normalization step is needed.
  • Rules not listed in coefficients are ignored (their coefficient defaults to 0.0).

When to use: You have labeled match/non-match training data and can train a logistic regression model offline. Export the model's coefficients and intercept directly into the spec. This method captures inter-rule relationships better than independent weights.

ML Ensemble Scoring Walkthrough

Using the same pair from the weighted_sum walkthrough (email matches, name is similar at 0.92, phone differs):

RuleRaw ScoreCoefficientProduct
email_exact1.02.52.5
name_fuzzy0.921.81.656
phone_exact0.01.20.0
Linear combination = bias + Σ(coefficient_i × score_i)
                   = -3.0 + 2.5 + 1.656 + 0.0
                   = 1.156

Score = σ(1.156) = 1 / (1 + e^(-1.156))
      = 1 / (1 + 0.3146)
      = 0.761

With thresholds match: 0.9, review: 0.5, the score of 0.761 falls in the REVIEW zone.

Note how the sigmoid compresses the output: even though the linear combination is positive, the score is pulled toward the center. If the phone had also matched, the linear combination would jump to 2.356 and σ(2.356) = 0.913, pushing it into MATCH territory.

custom

Custom scoring lets you define a mathematical expression that references rule names as variables. Each rule name resolves to its raw score (0.0-1.0), giving you full control over how scores combine.

yaml
decision:
  scoring:
    method: custom
    custom:
      expression: "(email_exact * 0.5) + (name_fuzzy * phone_exact * 0.3)"

Expression syntax:

  • Operators: +, -, *, /, parentheses for grouping
  • Functions: min(a, b), max(a, b)
  • Variables: rule names, resolving to their raw match score (0.0-1.0)

Behavior:

  • Rules not referenced in the expression are ignored.
  • The expression is parsed once at validation time. Syntax errors (unknown variables, unbalanced parentheses, invalid operators) are caught before any records are processed.
  • The result should be between 0.0 and 1.0. It is your responsibility to design expressions that produce valid scores in this range.

When to use: Domain-specific non-linear scoring logic. For example, "only count the name if the email also matched":

yaml
decision:
  scoring:
    method: custom
    custom:
      expression: "email_exact * 0.7 + (email_exact * name_fuzzy * 0.3)"

In this expression, the name_fuzzy term is multiplied by email_exact, so it only contributes when the email matches (email_exact = 1.0). If the email doesn't match (email_exact = 0.0), the entire expression evaluates to 0.0 regardless of the name similarity.

Another example, using the best of two fuzzy rules rather than summing them:

yaml
decision:
  scoring:
    method: custom
    custom:
      expression: "email_exact * 0.6 + max(name_fuzzy, alias_fuzzy) * 0.4"

Scoring Method Comparison

MethodBest ForComplexityRequires Training Data
weighted_sumMost use cases; simple, interpretableLowNo
ml_ensembleHigh-accuracy matching with labeled dataMediumYes
customDomain-specific non-linear scoring logicMediumNo

Thresholds

Thresholds divide the score range into decision zones.

Batch thresholds (weighted_sum)

For weighted_sum scoring, thresholds operate on the 0.0-1.0 normalized score range:

yaml
decision:
  thresholds:
    match: 0.9       # >= 0.9: automatic match (merge records)
    review: 0.7      # >= 0.7 and < 0.9: send to review queue
    reject: 0.3      # < 0.3: definite non-match (discard pair)

Visual scale:

0.0          0.3          0.7          0.9          1.0
 |            |            |            |            |
 |   REJECT   |   NO-OP    |   REVIEW   |   MATCH   |
 |            |            |            |            |
 └────────────┴────────────┴────────────┴────────────┘

Fellegi-Sunter thresholds

For fellegi_sunter scoring, thresholds operate on log-likelihood ratio scores (unbounded, typically -20 to +20):

yaml
scoring:
  method: fellegi_sunter
  thresholds:
    match: 8.0        # above this: automatic match
    possible: 4.0     # between non_match and match: uncertain
    non_match: -4.0   # below this: definite non-match
    merge: 12.0       # real-time entity merge threshold (optional)

The merge threshold is used by the POST /v1/resolve/realtime endpoint. When a new record matches multiple existing canonical entities above merge_threshold, the endpoint merges the losing entities into the highest-scoring winner with a full audit trail. This prevents the real-time path from creating duplicate canonical entities when a bridging record arrives between batch runs.

  • merge is optional and defaults to match * 1.5
  • The higher merge bar prevents accidental entity merges from borderline scores
  • Only applies to the real-time resolve path; batch reconciliation uses its own clustering logic

Zone definitions:

ZoneScore RangeBehavior
MATCH>= matchRecords are automatically merged into a single canonical entity
REVIEW>= review and < matchPair is queued for human review. No automatic action taken
NO-OP>= reject and < reviewPair is recorded but no action taken. Available for analysis
REJECT< rejectPair is discarded. Not stored unless audit logging is enabled

Threshold rules:

  • match must be greater than review
  • review must be greater than reject
  • For weighted_sum, all values must be between 0.0 and 1.0
  • For fellegi_sunter, values are log-likelihood ratios (no 0-1 constraint)
  • reject is optional and defaults to 0.0 (nothing is explicitly rejected)
  • merge is optional and defaults to match * 1.5 (only used by real-time resolve)

Choosing Thresholds

Start conservative: set match high (0.9+) and review moderate (0.6-0.7). Review the pairs that land in the review queue to understand your data. Then adjust thresholds based on what you observe. It's safer to review too many pairs than to auto-merge incorrect matches.

Threshold Tuning Guide

Data QualityRecommended MatchRecommended Review
High (clean, standardized)0.85 - 0.900.60 - 0.70
Medium (some inconsistency)0.90 - 0.950.70 - 0.80
Low (messy, unstandardized)0.95 - 0.990.80 - 0.90

The worse your data quality, the higher your thresholds should be. Messy data produces more false positives, so you need stricter thresholds to filter them out.

Conflict Strategy

When a single record matches multiple candidates (e.g., Record A scores highly against both Record B and Record C), the conflict strategy determines what happens.

yaml
decision:
  conflict_strategy: prefer_high_confidence

prefer_high_confidence (default)

Pick the candidate with the highest score. The record is merged into the best match only.

Record A ──┬── 0.95 ──→ Record B  ← Winner (highest score)
           └── 0.91 ──→ Record C  ← Discarded

When to use: Most common choice. Works well when you trust the scoring to differentiate between candidates.

prefer_recent

When a record matches multiple candidates, prefer the candidate with the most recent data. This uses the record's timestamp or the source's last-updated time to break ties.

Record A ──┬── 0.93 ──→ Record B  (updated 2 days ago)
           └── 0.91 ──→ Record C  (updated today) <- Winner (most recent)

When to use: Workloads where recency is a strong signal of accuracy, e.g., CRM data where the latest update is most likely correct. Useful when multiple candidates score similarly and the freshest record is the best bet.

manual_review

Send all conflicting matches to the review queue. No automatic merge happens when there are multiple candidates above the match threshold.

Record A ──┬── 0.95 ──→ Record B  ← Both sent to review
           └── 0.91 ──→ Record C  ← Both sent to review

When to use: High-stakes reconciliation (financial, healthcare) where false merges are costly. You accept slower throughput in exchange for human verification of ambiguous cases.

Comparison

StrategySpeedAccuracyHuman Effort
prefer_high_confidenceFastHighLow
prefer_recentFastGood (recency-biased)Low
manual_reviewSlowHighestHigh

Review Queue

The review queue holds pairs that scored between the review and match thresholds, plus any pairs routed there by the manual_review conflict strategy.

yaml
decision:
  review_queue:
    enabled: true
    webhook: https://hooks.slack.com/services/T00/B00/xxxx
    sla_hours: 72
FieldTypeRequiredDefaultDescription
enabledboolNofalseEnable/disable the review queue
webhookstringNoN/AWebhook URL for new review items
sla_hoursintegerNoN/AHours before unreviewed pairs are considered SLA-breached

Review queue lifecycle:

Pair enters queue ─→ Notification sent (webhook) ─→ Human reviews

                                              ┌──────────┼──────────┐
                                              │          │          │
                                            Approve    Reject    SLA breach
                                           (merge)   (discard)  (after N hours)

WARNING

When sla_hours is reached, unreviewed pairs are flagged as SLA-breached. Set this value high enough to allow your team to process the queue. If the queue consistently breaches SLA, your review threshold may be too low, and too many pairs are being flagged.

Audit Config (Cloud)

Audit logging captures decision metadata for compliance and debugging. Available on the Cloud tier.

yaml
decision:
  audit:
    log_all_comparisons: false
    log_decisions: true
    log_conflicts: true
FieldTypeDefaultDescription
log_all_comparisonsboolfalseLog every pairwise comparison, including NoMerge (non-match) decisions. Must be explicitly enabled.
log_decisionsbooltrueLog Merge and Review decisions. Enabled by default; set to false to suppress decision audit entries.
log_conflictsboolfalseLog when a record matches multiple candidates (survivorship conflict events)

Default Audit Behavior

When no audit block is present, log_decisions defaults to true, so Merge and Review decisions are audited automatically. NoMerge (non-match) decisions are not logged unless log_all_comparisons is enabled. When compliance.audit_required is true, all decisions are force-audited regardless of these flags.

PII Warning

Enabling log_all_comparisons may write a high volume of audit entries. Ensure your audit storage meets your compliance requirements (encryption at rest, access controls, retention policies). Consider using this only in conjunction with the compliance.pii_fields configuration to ensure proper handling.

Audit log entry example (with log_decisions: true):

json
{
  "pair_id": "pair_a1b2c3",
  "record_a": "crm:cust_001",
  "record_b": "billing:stripe_042",
  "decision": "match",
  "score": 0.934,
  "rule_scores": {
    "email_exact": { "weight": 1.0, "raw": 1.0, "contribution": 1.0 },
    "name_fuzzy": { "weight": 0.6, "raw": 0.89, "contribution": 0.534 },
    "phone_exact": { "weight": 0.9, "raw": 1.0, "contribution": 0.9 }
  },
  "timestamp": "2026-01-18T14:32:01Z",
  "spec_version": "2.1.0"
}

Clustering

After match decisions are made, records are grouped into clusters (entities). The clustering method controls how transitive matches are resolved.

yaml
decision:
  clustering:
    method: graph          # or "simple" (default)
    min_edge_weight: 0.7
    min_cluster_coherence: 0.6
    max_cluster_size: 100
FieldTypeDefaultDescription
methodstringsimplesimple (union-find, transitive closure) or graph (weighted graph with coherence checking)
min_edge_weightfloat0.7Graph only. Drop match edges with confidence below this value before clustering
min_cluster_coherencefloat0.6Graph only. Split clusters whose average internal edge weight is below this threshold
max_cluster_sizeinteger100Graph only. Force a coherence check on clusters larger than this, even if they pass the coherence threshold

simple (default)

Union-find with transitive closure. If A matches B and B matches C, all three are in the same cluster. This is fast and works well when your match threshold is high.

graph

Builds a weighted graph from match decisions, drops weak edges, and checks cluster coherence. This prevents weak transitive chains from merging unrelated records. See the Graph-based Clustering guide for a detailed walkthrough.

When to switch to graph clustering

If you see suspiciously large clusters, or if records with no direct match are ending up in the same entity through transitive chains, switch from simple to graph. Start with min_edge_weight set 0.10-0.15 below your match threshold.

Complete Example

A production-ready decision block with all options configured:

yaml
decision:
  # Scoring
  scoring: weighted_sum

  # Thresholds
  thresholds:
    match: 0.90
    review: 0.65
    reject: 0.25

  # Clustering
  clustering:
    method: graph
    min_edge_weight: 0.75
    min_cluster_coherence: 0.65
    max_cluster_size: 100

  # Conflict resolution
  conflict_strategy: prefer_high_confidence

  # Review queue
  review_queue:
    enabled: true
    webhook: https://hooks.slack.com/services/T00/B00/xxxx
    sla_hours: 336

  # Audit logging (Cloud)
  audit:
    log_all_comparisons: false
    log_decisions: true
    log_conflicts: true

With matching rules for context:

yaml
rules:
  - name: email_exact
    type: exact
    field: email
    weight: 1.0

  - name: name_fuzzy
    type: similarity
    field: name
    algorithm: jaro_winkler
    threshold: 0.88
    weight: 0.6

  - name: phone_exact
    type: exact
    field: phone
    weight: 0.9

  - name: address_fuzzy
    type: similarity
    field: address
    algorithm: levenshtein
    threshold: 0.85
    weight: 0.4

  - name: dob_exact
    type: exact
    field: date_of_birth
    weight: 0.8

decision:
  scoring: weighted_sum
  thresholds:
    match: 0.90
    review: 0.65
    reject: 0.25
  conflict_strategy: prefer_high_confidence
  review_queue:
    enabled: true
    sla_hours: 336

Score scenarios with these rules:

Scenarioemailname (JW)phoneaddress (Lev)dobRawMaxNormalizedDecision
Perfect match1.00.981.00.961.03.4883.70.943MATCH
Email + name only1.00.910.00.00.01.5463.70.418NO-OP
Email + name + phone1.00.911.00.00.02.4463.70.661REVIEW
Strong fuzzy all1.00.921.00.901.03.4123.70.922MATCH
Name + DOB only0.00.950.00.01.01.3703.70.370NO-OP
Nothing matches0.00.00.00.00.00.0003.70.000REJECT
Score calculation for "Email + name + phone"
  • email_exact: 1.0 * 1.0 = 1.000
  • name_fuzzy: 0.6 * 0.91 = 0.546
  • phone_exact: 0.9 * 1.0 = 0.900
  • address_fuzzy: 0.4 * 0.0 = 0.000
  • dob_exact: 0.8 * 0.0 = 0.000
  • Raw total: 1.000 + 0.546 + 0.900 + 0.000 + 0.000 = 2.446
  • Max possible: 1.0 + 0.6 + 0.9 + 0.4 + 0.8 = 3.7
  • Normalized: 2.446 / 3.7 = 0.661
  • 0.661 >= 0.65 (review) and < 0.90 (match) = REVIEW

Tips

  • Set match high for initial runs. It's much easier to lower the match threshold after reviewing results than to undo incorrect merges. Start at 0.90 or above.
  • Use the review queue. The gap between review and match is where you learn about your data. Pairs in this zone reveal edge cases, data quality issues, and rules that need tuning.
  • Prefer prefer_high_confidence for conflicts. Unless you have a specific compliance requirement, this strategy gives the best balance of accuracy and throughput.
  • Enable audit logging early. Even before production, audit logs help you debug scoring and understand why specific pairs were matched or rejected.
  • Weight rules by discriminative power. A unique identifier like email deserves weight 1.0. A common field like city might only warrant 0.2. The weights should reflect how much each rule tells you about whether two records are the same entity.

The identity and delegation layer for AI agents.