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.
decision:
scoring: weighted_sumFor each rule that fires:
- Exact rules contribute either
weight * 1.0(match) orweight * 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.0if the values are within the specified range, otherwiseweight * 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:
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.9Pair under evaluation:
| Field | Record A | Record B |
|---|---|---|
email | [email protected] | [email protected] |
name | Jane Smith | Jayne Smyth |
phone | +1-555-0100 | +1-555-9999 |
Step 1: Evaluate each rule
| Rule | Type | Result | Raw Score |
|---|---|---|---|
email_exact | exact | Emails match exactly | 1.0 |
name_fuzzy | similarity | Jaro-Winkler("Jane Smith", "Jayne Smyth") = 0.92 | 0.92 |
phone_exact | exact | Phone numbers differ | 0.0 |
Step 2: Calculate weighted contributions
| Rule | Weight | Raw Score | Contribution |
|---|---|---|---|
email_exact | 1.0 | 1.0 | 1.0 * 1.0 = 1.000 |
name_fuzzy | 0.6 | 0.92 | 0.6 * 0.92 = 0.552 |
phone_exact | 0.9 | 0.0 | 0.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.621Step 4: Apply thresholds
With the following thresholds:
decision:
thresholds:
match: 0.9
review: 0.5The 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.
| Scenario | Raw Score | Max Possible | Normalized |
|---|---|---|---|
| 2 rules, both match | 1.6 | 1.6 | 1.0 |
| 5 rules, 3 match | 2.1 | 3.5 | 0.6 |
| 10 rules, 8 match | 7.2 | 8.8 | 0.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))
decision:
scoring:
method: ml_ensemble
ml_ensemble:
coefficients:
email_exact: 2.5
name_fuzzy: 1.8
phone_exact: 1.2
bias: -3.0How it works:
coefficientsmaps 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).biasis 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
coefficientsare 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):
| Rule | Raw Score | Coefficient | Product |
|---|---|---|---|
email_exact | 1.0 | 2.5 | 2.5 |
name_fuzzy | 0.92 | 1.8 | 1.656 |
phone_exact | 0.0 | 1.2 | 0.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.761With 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.
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":
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:
decision:
scoring:
method: custom
custom:
expression: "email_exact * 0.6 + max(name_fuzzy, alias_fuzzy) * 0.4"Scoring Method Comparison
| Method | Best For | Complexity | Requires Training Data |
|---|---|---|---|
weighted_sum | Most use cases; simple, interpretable | Low | No |
ml_ensemble | High-accuracy matching with labeled data | Medium | Yes |
custom | Domain-specific non-linear scoring logic | Medium | No |
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:
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):
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.
mergeis optional and defaults tomatch * 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:
| Zone | Score Range | Behavior |
|---|---|---|
| MATCH | >= match | Records are automatically merged into a single canonical entity |
| REVIEW | >= review and < match | Pair is queued for human review. No automatic action taken |
| NO-OP | >= reject and < review | Pair is recorded but no action taken. Available for analysis |
| REJECT | < reject | Pair is discarded. Not stored unless audit logging is enabled |
Threshold rules:
matchmust be greater thanreviewreviewmust be greater thanreject- 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) rejectis optional and defaults to 0.0 (nothing is explicitly rejected)mergeis optional and defaults tomatch * 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 Quality | Recommended Match | Recommended Review |
|---|---|---|
| High (clean, standardized) | 0.85 - 0.90 | 0.60 - 0.70 |
| Medium (some inconsistency) | 0.90 - 0.95 | 0.70 - 0.80 |
| Low (messy, unstandardized) | 0.95 - 0.99 | 0.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.
decision:
conflict_strategy: prefer_high_confidenceprefer_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 ← DiscardedWhen 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 reviewWhen 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
| Strategy | Speed | Accuracy | Human Effort |
|---|---|---|---|
prefer_high_confidence | Fast | High | Low |
prefer_recent | Fast | Good (recency-biased) | Low |
manual_review | Slow | Highest | High |
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.
decision:
review_queue:
enabled: true
webhook: https://hooks.slack.com/services/T00/B00/xxxx
sla_hours: 72| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | bool | No | false | Enable/disable the review queue |
webhook | string | No | N/A | Webhook URL for new review items |
sla_hours | integer | No | N/A | Hours 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.
decision:
audit:
log_all_comparisons: false
log_decisions: true
log_conflicts: true| Field | Type | Default | Description |
|---|---|---|---|
log_all_comparisons | bool | false | Log every pairwise comparison, including NoMerge (non-match) decisions. Must be explicitly enabled. |
log_decisions | bool | true | Log Merge and Review decisions. Enabled by default; set to false to suppress decision audit entries. |
log_conflicts | bool | false | Log 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):
{
"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.
decision:
clustering:
method: graph # or "simple" (default)
min_edge_weight: 0.7
min_cluster_coherence: 0.6
max_cluster_size: 100| Field | Type | Default | Description |
|---|---|---|---|
method | string | simple | simple (union-find, transitive closure) or graph (weighted graph with coherence checking) |
min_edge_weight | float | 0.7 | Graph only. Drop match edges with confidence below this value before clustering |
min_cluster_coherence | float | 0.6 | Graph only. Split clusters whose average internal edge weight is below this threshold |
max_cluster_size | integer | 100 | Graph 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:
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: trueWith matching rules for context:
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: 336Score scenarios with these rules:
| Scenario | name (JW) | phone | address (Lev) | dob | Raw | Max | Normalized | Decision | |
|---|---|---|---|---|---|---|---|---|---|
| Perfect match | 1.0 | 0.98 | 1.0 | 0.96 | 1.0 | 3.488 | 3.7 | 0.943 | MATCH |
| Email + name only | 1.0 | 0.91 | 0.0 | 0.0 | 0.0 | 1.546 | 3.7 | 0.418 | NO-OP |
| Email + name + phone | 1.0 | 0.91 | 1.0 | 0.0 | 0.0 | 2.446 | 3.7 | 0.661 | REVIEW |
| Strong fuzzy all | 1.0 | 0.92 | 1.0 | 0.90 | 1.0 | 3.412 | 3.7 | 0.922 | MATCH |
| Name + DOB only | 0.0 | 0.95 | 0.0 | 0.0 | 1.0 | 1.370 | 3.7 | 0.370 | NO-OP |
| Nothing matches | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.000 | 3.7 | 0.000 | REJECT |
Score calculation for "Email + name + phone"
email_exact: 1.0 * 1.0 = 1.000name_fuzzy: 0.6 * 0.91 = 0.546phone_exact: 0.9 * 1.0 = 0.900address_fuzzy: 0.4 * 0.0 = 0.000dob_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
matchhigh 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
reviewandmatchis where you learn about your data. Pairs in this zone reveal edge cases, data quality issues, and rules that need tuning. - Prefer
prefer_high_confidencefor 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.
