Skip to main content

ADR-019: Risk Matrix Scoring Pipeline — Factor Scoring Methods & Ontology Data Flow

Status: Draft Date: 2026-04-03 Author: Atlas Architecture Supersedes (partially): ADR-008 §_evaluate_factor (indicator-centric scoring), ADR-008 ontology_mapping field structure Depends on: ADR-008 (EBA Risk Matrix Engine), ADR-008a (Reference Data Registry), ADR-010 (Ontology Lifecycle), ADR-016 (Port-and-Wire Interaction Model), ADR-018 (Evaluation Rework & Portfolio Scoring) Impacts: Risk Matrix Editor, Scoring Engine, Evaluations Page, Schema Mapping Designer, Reference Data Registry


Table of Contents

  1. Context & Problem Statement
  2. Decision Summary
  3. End-to-End Data Flow
  4. Factor Scoring Methods
  5. Scoring Method: REFERENCE_LOOKUP
  6. Scoring Method: BOOLEAN
  7. Scoring Method: THRESHOLD_RANGES
  8. Scoring Method: FORMULA (Future)
  9. Rule-Based Overrides (Escalation Rules)
  10. Factor Configuration Data Model
  11. Scoring Engine Changes
  12. Reference Data Integration
  13. Missing & Unknown Value Handling
  14. Multi-Value Fields
  15. UI Changes — Factor Editor
  16. Worked Example: Geographic Risk Dimension
  17. Proof of Concept Scope
  18. Migration from ADR-008 Indicator Model
  19. Rejected Alternatives
  20. Open Questions

1. Context & Problem Statement

ADR-008 introduced the EBA Risk Matrix Engine with a scoring algorithm that evaluates factors using an indicator-based threshold model. Each factor's ontology_mapping contains field paths with an indicator type (equals, greater_than, in, country_risk_list, etc.) and a list of thresholds. While this works for the initial implementation, it has three fundamental problems:

1. The scoring logic is implicit and coupled to indicator types. The _evaluate_factor method in scorer.py contains a _check_threshold function with a match/case block that handles seven different indicator types (equals, greater_than, less_than, in, intersects, country_risk_list, recency_days). Adding a new scoring pattern requires modifying the scorer's core loop. The indicator type serves double duty as both a comparison operator and a scoring strategy, which makes it hard to reason about.

2. The connection between ontology fields and factor scores is unclear. A compliance officer configuring a factor must understand the indicator type system (equals, in, country_risk_list) to set up scoring. This is a developer abstraction, not a compliance abstraction. When a compliance officer asks "how does country of incorporation become a risk score?", the answer should not involve explaining what indicator: "country_risk_list" means and how threshold matching works.

3. Reference data usage is interleaved with threshold definitions. The country_risk_list indicator type hardcodes a special path through self.reference_data, while other indicator types use inline threshold values. There's no unified model for "this factor's score comes from looking up the entity's value in a reference dataset." The Reference Data Registry (ADR-008a) defines the datasets but not how factors consume them.

4. The data binding is duplicated across two systems. The Schema Mapping Designer (ADR-016) already connects ontology fields to risk matrix factors via port-and-wire connections, stored as MatrixFactorMapping records in the mapping store ({ matrixSchemaKey, factorName, ontologyFieldPath }). But the OntologyMappingEditor inside the Risk Matrix Editor also lets the user select entity types and field paths — duplicating the "which field feeds this factor" question. This creates confusion about which system is the source of truth for the data binding.

5. There is no clear data flow from source → ontology → factor → score. The runtime path — how a resolved ontology value actually becomes a factor score — passes through multiple layers (wire mappings, ontology mapper, input data assembly, scorer) with no single place where the full pipeline is visible or testable.

This ADR replaces the indicator-type model with a small set of explicit scoring methods — each with a clear contract, a dedicated configuration schema, and a self-contained scoring function. It also clarifies the separation of concerns: the Schema Mapping Designer owns the data binding (which ontology field feeds which factor, via wires), and the Risk Matrix Editor owns the scoring method (how that field's value becomes a numeric score). The scoring method is the single concept that answers: "given this ontology field value, what is the factor score?"


2. Decision Summary

  1. Separate data binding from scoring logic. The Schema Mapping Designer (ADR-016) owns the data binding — which ontology field feeds which factor — via port-and-wire connections stored as MatrixFactorMapping records. The Risk Matrix Editor owns the scoring method — how that field's value becomes a numeric score. The current OntologyMappingEditor (which duplicates the field selection) is replaced by a scoring method configurator.

  2. Introduce four factor scoring methods: REFERENCE_LOOKUP, BOOLEAN, THRESHOLD_RANGES, and FORMULA (future). Each method is a self-contained scoring strategy with its own configuration schema and evaluation function.

  3. Each factor declares exactly one scoring method. The method replaces the current ontology_mapping.fields[].indicator + thresholds pattern. The factor does not store which ontology field it reads — that comes from the wire mapping at runtime.

  4. Reference datasets become first-class scoring inputs. A REFERENCE_LOOKUP factor explicitly names which Reference Data Registry dataset (ADR-008a) provides the value-to-score mapping. The compliance officer configures this in the Factor Editor UI, not in YAML thresholds.

  5. Add rule-based escalation overrides that can force a minimum risk tier regardless of calculated score. These handle absolute signals like sanctions hits or active investigations.

  6. Define the complete data flow from source data through ontology resolution to factor scoring to dimension and matrix aggregation, making the pipeline testable end-to-end.

  7. Build a proof of concept with one dimension (Geographic Risk), two factors (REFERENCE_LOOKUP + BOOLEAN), and one entity to validate the full pipeline before expanding to all five EBA dimensions.


3. End-to-End Data Flow

┌─────────────────────────────────────────────────────────────────────┐
│ DATA SOURCES │
│ KVK │ NorthData │ SPEPWS │ AMLRR │ Analyst Input │
└───┬───┴──────┬──────┴────┬─────┴────┬────┴──────────┬──────────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ SCHEMA MAPPING DESIGNER (ADR-016) │
│ │
│ Port-and-wire connections map source fields to ontology fields │
│ AND ontology fields to matrix factors. │
│ Merge strategies resolve multi-source conflicts (ADR-010/017). │
│ │
│ Source: kvk.country_code ──wire──▶ Ontology: country_of_incorp │
│ Source: nd.is_pep ──wire──▶ Ontology: is_pep │
│ Source: kvk.sbi_code ──wire──▶ Ontology: industry_codes │
│ │
│ Ontology: country_of_incorp ──wire──▶ Matrix: geographic. │
│ jurisdiction_risk │
│ Ontology: is_high_risk_jurisdiction ──wire──▶ Matrix: geographic. │
│ high_risk_jurisdiction │
│ │
│ These wire connections are stored as MatrixFactorMapping records: │
│ { matrixSchemaKey: "geographic", │
│ factorName: "jurisdiction_risk", │
│ ontologyFieldPath: "LegalEntity.country_of_incorporation" } │
└──────────────────────────────────┬──────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────┐
│ ONTOLOGY LAYER (resolved entity) │
│ │
│ Entity: "Acme B.V." │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ country_of_incorporation: "PA" │ │
│ │ is_pep: false │ │
│ │ is_high_risk_jurisdiction: true │ │
│ │ industry_codes: ["46.1", "real_estate"] │ │
│ │ annual_turnover: 850000 │ │
│ │ beneficial_owner_count: 4 │ │
│ │ has_sanctions_hit: false │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────┬──────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────┐
│ FACTOR SCORING PIPELINE (this ADR) │
│ │
│ For each dimension → for each factor: │
│ │
│ 1. Look up the wire mapping to find which ontology field feeds │
│ this factor (from MatrixFactorMapping, NOT from the factor def)│
│ 2. Read that field's value from the resolved entity │
│ 3. Apply the factor's scoring method (from the factor definition): │
│ │
│ ┌──────────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ REFERENCE_LOOKUP │ │ BOOLEAN │ │ THRESHOLD_RANGES │ │
│ │ │ │ │ │ │ │
│ │ "PA" → lookup in │ │ true → │ │ 850000 → │ │
│ │ country_risk │ │ score: 9 │ │ range 500k-1M │ │
│ │ dataset → 8 │ │ │ │ → score: 6 │ │
│ └──────────────────┘ └──────────────┘ └──────────────────┘ │
│ │
│ 4. Cap score to factor.max_score │
│ 5. Apply analyst override if present │
│ 6. Check escalation rules │
│ │
│ Factor scores: [8, 9, 6, ...] │
└──────────────────────────────────┬──────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────┐
│ DIMENSION AGGREGATION │
│ │
│ weighted_sum(factor_scores) → normalize to 0-100 → dimension_score│
│ │
│ Geographic Risk: (8×1.0 + 1×1.0) / (10+10) × 100 = 45 → Medium │
└──────────────────────────────────┬──────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────┐
│ MATRIX AGGREGATION │
│ │
│ weighted_max / weighted_average across dimensions → overall_score │
│ overall_score → risk_levels lookup → tier assignment │
│ │
│ Overall: 52 → Medium → Standard Due Diligence │
└──────────────────────────────────┬──────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────────┐
│ EVALUATION RESULT (ADR-008) │
│ │
│ Persisted to risk_matrix_evaluations with full factor breakdown, │
│ dimension scores, overall score, hashes, and audit trail. │
│ Fed into Portfolio Scoring (ADR-018) and Company Risk Dashboard. │
└─────────────────────────────────────────────────────────────────────┘

The critical insight is that the scoring method is the bridge between ontology data and risk scores. Everything above the scoring pipeline (sources, mappings, ontology) produces structured entity data. Everything below (aggregation, evaluation storage, portfolio scoring) consumes numeric scores. The scoring method is the translation layer.


4. Factor Scoring Methods

Each factor declares exactly one scoring method. The method determines how the factor's bound ontology field value is converted to a numeric score.

MethodInputOutputUse Case
REFERENCE_LOOKUPCategorical value (string)Score from reference datasetCountry risk, industry risk, PEP tier, product risk
BOOLEANBoolean valueOne of two configured scoresPEP flag, sanctions hit, virtual office detected
THRESHOLD_RANGESNumeric valueScore from matching rangeAnnual turnover, ownership layers, domain age
FORMULAMultiple field valuesComputed scoreComposite factors (future — Phase 2)

The first three methods cover approximately 90% of EBA risk factors. FORMULA is reserved for Phase 2 and is documented here for completeness but will not be implemented in the POC.


5. Scoring Method: REFERENCE_LOOKUP

Purpose

Translates a categorical ontology value into a risk score by looking it up in a Reference Data Registry dataset (ADR-008a). This is the primary method for any factor where the risk level depends on a classification maintained externally — countries, industries, PEP tiers, product types.

Configuration Schema

The scoring config does not specify which ontology field to read — that comes from the wire mapping in the Schema Mapping Designer. The config only describes how to turn the value into a score.

{
"scoring_method": "REFERENCE_LOOKUP",
"scoring_config": {
"reference_dataset": "country_risk",
"lookup_key_column": "country_code",
"score_column": "risk_score",
"default_score": 5,
"default_reason": "Country not found in reference dataset"
}
}

Evaluation Logic

def score_reference_lookup(entity_value, config, reference_data):
# entity_value was already resolved by the scorer from the wire mapping
dataset = reference_data[config["reference_dataset"]]
key_col = config["lookup_key_column"]
score_col = config["score_column"]

# Find matching row in reference dataset
for row in dataset:
if row[key_col] == entity_value:
return row[score_col], {"matched": entity_value, "dataset": config["reference_dataset"]}

# No match — return default
return config["default_score"], {"matched": None, "reason": config["default_reason"]}

How it connects to Risk Categories

The reference_dataset field names a dataset managed on the Risk Categories page (/studio/matrices/inputs). The datasets defined in ADR-008a — country_risk, high_risk_sectors, pep_tiers, product_risk — are the canonical sources. When a matrix is published, the reference dataset is snapshotted into reference_data_snapshot (per ADR-008), freezing the lookup table at publish time.

How it connects to the Schema Mapping Designer

The compliance officer connects the ontology field to the factor in the Schema Mapping Designer by dragging a wire from LegalEntity.country_of_incorporation to the geographic.jurisdiction_risk factor port. At runtime, the scorer reads this wire mapping to determine which field to fetch, then passes the value to the REFERENCE_LOOKUP scorer.

Example: Country of Incorporation → Geographic Risk Score

Entity value: country_of_incorporation = "PA" (Panama)

Reference dataset country_risk:

country_codecountry_namerisk_scorerisk_tiersource
NLNetherlands2lowCPI + FATF
PAPanama8highFATF grey list + CPI < 40
IRIran10criticalFATF black list + EU high-risk
DEGermany1clearCPI + FATF

Lookup: "PA" → risk_score = 8. Factor score = min(8, max_score).


6. Scoring Method: BOOLEAN

Purpose

Maps a true/false ontology value to one of two configured scores. The simplest scoring method, used for binary risk signals: PEP exposure, sanctions hits, virtual office detection, missing financial statements.

Configuration Schema

The config only defines the score mapping for true/false/null. Which ontology field provides the boolean value is determined by the wire mapping in the Schema Mapping Designer.

{
"scoring_method": "BOOLEAN",
"scoring_config": {
"score_true": 9,
"score_false": 1,
"score_null": 5,
"null_reason": "PEP status unknown — conservative score applied"
}
}

Evaluation Logic

def score_boolean(entity_value, config):
# entity_value was already resolved by the scorer from the wire mapping
if entity_value is True:
return config["score_true"], {"value": True}
elif entity_value is False:
return config["score_false"], {"value": False}
else:
return config["score_null"], {"value": None, "reason": config["null_reason"]}

Design Note: The Null Score

The score_null field is critical for compliance. When the ontology has no data for a boolean field (e.g., PEP check hasn't been run yet, or the source didn't provide this field), the compliance officer must decide whether missing data is treated conservatively (assume higher risk) or neutrally. This is a per-factor policy decision configured in the matrix, not a system default.


7. Scoring Method: THRESHOLD_RANGES

Purpose

Maps a numeric ontology value to a score based on which range it falls into. Used for continuous indicators: financial amounts, counts, durations, percentages.

Configuration Schema

The config only defines the ranges and scores. Which ontology field provides the numeric value is determined by the wire mapping in the Schema Mapping Designer.

{
"scoring_method": "THRESHOLD_RANGES",
"scoring_config": {
"ranges": [
{ "min": 0, "max": 100000, "score": 2, "label": "Low turnover" },
{ "min": 100001, "max": 500000, "score": 4, "label": "Moderate turnover" },
{ "min": 500001, "max": 1000000, "score": 6, "label": "Significant turnover" },
{ "min": 1000001, "max": null, "score": 8, "label": "High turnover" }
],
"default_score": 3,
"default_reason": "Turnover data not available"
}
}

Evaluation Logic

def score_threshold_ranges(entity_value, config):
if entity_value is None:
return config["default_score"], {"value": None, "reason": config["default_reason"]}

for range_def in config["ranges"]:
min_val = range_def["min"]
max_val = range_def.get("max") # null = unbounded upper
if entity_value >= min_val and (max_val is None or entity_value <= max_val):
return range_def["score"], {
"value": entity_value,
"range": range_def["label"],
}

return config["default_score"], {"value": entity_value, "reason": "No matching range"}

Design Note: Range Ordering

Ranges must be non-overlapping and ordered by min ascending. The Factor Editor UI enforces this — the compliance officer adds ranges in order and the UI prevents overlaps. The scoring engine validates range integrity at matrix publish time.


8. Scoring Method: FORMULA (Future)

Purpose

Reads multiple ontology fields and computes a score using a configurable expression. Reserved for composite factors that cannot be expressed as a single-field lookup, boolean, or range check.

This is the one scoring method that may require multiple wire connections to the same factor — the Schema Mapping Designer would need to support multiple wires into a single factor port. This is a design challenge deferred to Phase 2.

Example Use Case

"Ownership Complexity" factor that combines beneficial_owner_count and ownership_layers:

{
"scoring_method": "FORMULA",
"scoring_config": {
"expression": "min(beneficial_owner_count * 2 + ownership_layers * 3, max_score)",
"default_score": 0
}
}

Note: The field names in the expression (beneficial_owner_count, ownership_layers) would be resolved from the multiple wire mappings connected to this factor. The exact mechanism for multi-wire factors is deferred to Phase 2.

Status

Not in scope for Phase 1. The first three methods cover the vast majority of EBA risk factors. FORMULA will be designed in a follow-up ADR once the scoring pipeline is validated with the simpler methods.

Risk Consideration

Custom formulas introduce compliance risk — a misconfigured expression could produce unexpected scores. When FORMULA is implemented, it must include: expression validation at publish time, a preview mode that shows the formula's output for sample entities, and audit logging of formula changes.


9. Rule-Based Overrides (Escalation Rules)

Purpose

Certain signals should automatically escalate the risk tier regardless of the calculated score. Industry best practice (FATF, EBA) requires that specific absolute indicators bypass the weighted scoring model.

Configuration

Escalation rules are defined at the matrix level, not per-factor. Like factor scoring, escalation rules do not embed ontology field paths directly. Instead, each rule has an id that is wired to an ontology field in the Schema Mapping Designer using the port key convention escalation.{rule_id}:

{
"escalation_rules": [
{
"id": "sanctions_hit",
"label": "Active sanctions match",
"condition": { "equals": true },
"minimum_tier": "critical",
"reason": "Entity has an active sanctions match — automatic escalation to critical risk"
},
{
"id": "active_investigation",
"label": "Active law enforcement investigation",
"condition": { "equals": true },
"minimum_tier": "high",
"reason": "Entity is subject to an active investigation — minimum high risk"
}
]
}

The wire mappings for escalation rules follow the same pattern as factor mappings:

{
"escalation.sanctions_hit": "has_sanctions_hit",
"escalation.active_investigation": "has_active_investigation"
}

The MatrixColumn in the Schema Mapping Designer renders escalation rules as an additional section below the dimension factors, with PortDot connections for each rule. This keeps all data binding in one place.

Evaluation Logic

Escalation rules are evaluated after dimension aggregation but before final tier assignment. The scorer resolves the ontology field for each rule via self.wire_mappings.get(f"escalation.{rule['id']}"), reads the entity value, and evaluates the condition. If any rule triggers, the entity's tier is raised to at least the rule's minimum_tier (the numeric score is adjusted to the minimum of that tier's range). The escalation is recorded in the evaluation result for audit purposes.

Design Note

Escalation rules only raise risk, never lower it. If the calculated score already places the entity in a higher tier than the escalation rule's minimum, the rule has no effect. This is a one-way ratchet. Rules with no wire connection are skipped (logged as a warning).


10. Factor Configuration Data Model

Changes to matrix_definition JSONB

Each factor in the matrix_definition gains two new top-level fields: scoring_method and scoring_config. The existing ontology_mapping.fields[].indicator and ontology_mapping.fields[].thresholds structure is deprecated.

Before (ADR-008)

factors:
- id: jurisdiction_risk
label: "Jurisdiction of Registration"
max_score: 30
weight: 1.0
ontology_mapping:
entity_type: LegalEntity
fields:
- path: "jurisdiction"
indicator: "country_risk_list"
thresholds:
- { list: "eu_high_risk_third_countries", score: 30 }
- { list: "fatf_grey_list", score: 25 }
- { list: "cpi_below_40", score: 15 }

After (ADR-019)

factors:
- id: jurisdiction_risk
label: "Jurisdiction of Registration"
max_score: 10
weight: 1.0
scoring_method: REFERENCE_LOOKUP
scoring_config:
reference_dataset: "country_risk"
lookup_key_column: "country_code"
score_column: "risk_score"
default_score: 5
default_reason: "Country not found in reference data"
# NOTE: No ontology_field here. The data binding
# ("country_of_incorporation" → this factor) is stored in the
# Schema Mapping Designer as a MatrixFactorMapping wire connection.

Key Differences

  1. Data binding is separated from scoring logic. The factor definition no longer stores which ontology field it reads. That binding lives in the Schema Mapping Designer as a MatrixFactorMapping record (wire connection). The factor only stores how to score the value. This eliminates the duplication between the OntologyMappingEditor and the Schema Mapping Designer.

  2. One factor = one wire = one scoring method. The ADR-008 model allowed multiple fields per factor with independent indicator types. The new model requires one wire connection per factor (one ontology field) and one scoring method. If a compliance officer needs to score both jurisdiction and operational_countries, those become two separate factors in the same dimension — which is clearer for audit purposes and simpler to configure.

  3. Reference dataset is named, not inlined. Instead of listing multiple country_risk_list thresholds that name different lists with different scores, the factor points to a single reference dataset. The dataset itself contains the value-to-score mapping, maintained by the compliance team on the Risk Categories page.

  4. Scores live in the reference dataset, not in the matrix definition. This means updating country risk scores (e.g., adding a new FATF grey list country) only requires updating the reference dataset, not editing every matrix that uses geographic risk. When a matrix is published, the reference data is snapshotted, ensuring historical evaluations remain reproducible.

  5. The OntologyMappingEditor is replaced. The current three-section editor (Entity Mapping, Investigation Module, Risk Indicator Keys) is replaced by a single scoring method configurator. The entity/field selection moves to the Schema Mapping Designer where it belongs. The module mapping and risk indicator key sections are deprecated — factors receive their data exclusively through ontology field wire connections.


11. Scoring Engine Changes

New _evaluate_factor Implementation

The RiskMatrixScorer now accepts wire mappings (from the Schema Mapping Designer) alongside the matrix definition. The _evaluate_factor method is replaced with a dispatch to method-specific scoring functions:

class RiskMatrixScorer:

SCORING_METHODS = {
"REFERENCE_LOOKUP": "_score_reference_lookup",
"BOOLEAN": "_score_boolean",
"THRESHOLD_RANGES": "_score_threshold_ranges",
}

def __init__(
self,
matrix_definition: dict,
aggregation_config: dict,
reference_data_snapshot: dict | None = None,
wire_mappings: dict[str, str] | None = None,
# wire_mappings: { "geographic.jurisdiction_risk": "country_of_incorporation", ... }
# Keys: "dimensionKey.factorId", Values: ontology field path
# Source: MatrixFactorMapping records from the Schema Mapping Designer
):
self.matrix = matrix_definition
self.aggregation = aggregation_config
self.reference_data = reference_data_snapshot or {}
self.wire_mappings = wire_mappings or {}

def _evaluate_factor(
self, dim_id: str, factor_def: dict, entity_data: dict
) -> tuple[int, list[dict]]:
method = factor_def.get("scoring_method")
config = factor_def.get("scoring_config", {})

if method not in self.SCORING_METHODS:
# Fallback to legacy indicator model during migration
return self._evaluate_factor_legacy(factor_def, entity_data)

# Resolve the ontology field from the wire mapping (not from the factor def)
wire_key = f"{dim_id}.{factor_def['id']}"
ontology_field = self.wire_mappings.get(wire_key)

if ontology_field is None:
# Factor has no wire connected — no data to score
return config.get("default_score", 0), [{
"method": method,
"value": None,
"reason": "No ontology field mapped (no wire connection)",
}]

entity_value = entity_data.get(ontology_field)

scorer_fn = getattr(self, self.SCORING_METHODS[method])
score, indicator = scorer_fn(entity_value, config)
indicator["ontology_field"] = ontology_field # record which field was read
return score, [indicator] if indicator else []

def _score_reference_lookup(self, entity_value, config) -> tuple[int, dict]:
dataset_name = config["reference_dataset"]
dataset = self.reference_data.get(dataset_name, [])
key_col = config["lookup_key_column"]
score_col = config["score_column"]

if entity_value is None:
return config.get("default_score", 0), {
"method": "REFERENCE_LOOKUP",
"value": None,
"reason": config.get("default_reason", "No data"),
}

for row in dataset:
if row.get(key_col) == entity_value:
return row[score_col], {
"method": "REFERENCE_LOOKUP",
"value": entity_value,
"dataset": dataset_name,
"matched_score": row[score_col],
}

return config.get("default_score", 0), {
"method": "REFERENCE_LOOKUP",
"value": entity_value,
"dataset": dataset_name,
"reason": config.get("default_reason", "Value not in dataset"),
}

def _score_boolean(self, entity_value, config) -> tuple[int, dict]:
if entity_value is True:
return config["score_true"], {"method": "BOOLEAN", "value": True}
elif entity_value is False:
return config["score_false"], {"method": "BOOLEAN", "value": False}
else:
return config.get("score_null", 0), {
"method": "BOOLEAN",
"value": None,
"reason": config.get("null_reason", "Value not available"),
}

def _score_threshold_ranges(self, entity_value, config) -> tuple[int, dict]:
if entity_value is None:
return config.get("default_score", 0), {
"method": "THRESHOLD_RANGES",
"value": None,
"reason": config.get("default_reason", "No data"),
}

for r in config.get("ranges", []):
min_val = r["min"]
max_val = r.get("max")
if entity_value >= min_val and (max_val is None or entity_value <= max_val):
return r["score"], {
"method": "THRESHOLD_RANGES",
"value": entity_value,
"range_label": r.get("label", f"{min_val}-{max_val}"),
}

return config.get("default_score", 0), {
"method": "THRESHOLD_RANGES",
"value": entity_value,
"reason": "No matching range",
}

Input Data Assembly

The scoring engine receives two inputs:

  1. Entity data — a flat dict of ontology field names to resolved values, assembled from the ontology layer.
  2. Wire mappings — a dict mapping "dimensionKey.factorId" to "ontologyFieldPath", assembled from the MatrixFactorMapping records in the mapping store.
# Entity data from the ontology layer (resolved entity fields)
entity_data = {
"country_of_incorporation": "PA",
"is_pep": False,
"is_high_risk_jurisdiction": True,
"annual_turnover": 850000,
"industry_codes": ["46.1", "real_estate"],
"beneficial_owner_count": 4,
"has_sanctions_hit": False,
}

# Wire mappings from the Schema Mapping Designer (MatrixFactorMapping records)
wire_mappings = {
"geographic.jurisdiction_risk": "country_of_incorporation",
"geographic.high_risk_jurisdiction_flag": "is_high_risk_jurisdiction",
"customer.pep_exposure": "is_pep",
"transaction.financial_profile": "annual_turnover",
}

scorer = RiskMatrixScorer(
matrix_definition=matrix_def,
aggregation_config=agg_config,
reference_data_snapshot=reference_data,
wire_mappings=wire_mappings,
)

result = scorer.evaluate(entity_data, matrix_schema_id)

The scorer iterates dimensions and factors. For each factor, it looks up the wire mapping to find which ontology field to read, then passes the value to the appropriate scoring method. If a factor has no wire connected, it returns the default score — this allows partial configurations during drafting.


12. Reference Data Integration

Connection to ADR-008a

The Reference Data Registry (ADR-008a) manages versioned datasets: country risk lists, industry risk classifications, PEP tiers, product risk taxonomies. ADR-019 defines how these datasets are consumed by the scoring engine.

Dataset Format for Scoring

Each reference dataset used by REFERENCE_LOOKUP factors must have at minimum:

  • A key column — the value that will be matched against the ontology field (e.g., country_code, industry_code, pep_classification)
  • A score column — the numeric risk score assigned to that value (within the factor's max_score range)

Additional columns (tier label, source, notes) are informational and displayed in the Risk Categories UI but not used by the scorer.

Snapshot at Publish Time

When a risk matrix is published, all reference datasets used by its factors are snapshotted into the matrix's reference_data_snapshot JSONB column (per ADR-008). This ensures:

  • Published matrix evaluations are reproducible — the same entity always gets the same score under the same matrix version, even if the reference dataset is later updated.
  • Compliance teams can update reference data (e.g., new FATF grey list) without affecting existing published matrix evaluations.
  • New matrix versions pick up the latest reference data when published.

Validation at Publish Time

Before a matrix can be published, the system validates:

  1. Every REFERENCE_LOOKUP factor's reference_dataset exists in the Reference Data Registry.
  2. The named lookup_key_column and score_column exist in the dataset.
  3. All score values in the dataset are within the factor's max_score range (or will be capped).
  4. No dataset is empty (warning, not a hard block — the compliance officer may intend to publish with an empty dataset and populate later).

13. Missing & Unknown Value Handling

Missing data is a first-class concern in AML risk scoring. The EBA guidelines specifically require that firms consider the risk implications of incomplete data. Each scoring method handles missing values explicitly:

MethodMissing Value BehaviorConfiguration
REFERENCE_LOOKUPReturns default_scoredefault_score + default_reason
BOOLEANReturns score_nullscore_null + null_reason
THRESHOLD_RANGESReturns default_scoredefault_score + default_reason

Policy Guidance

The compliance officer must decide the default score for each factor based on their risk appetite:

  • Conservative (recommended for onboarding): missing data = higher score. If you don't know the country, assume it's risky.
  • Neutral: missing data = midpoint score. Used when the factor is supplementary and missing data shouldn't dominate the score.
  • Permissive: missing data = zero score. Used when the factor is optional and absence of data is not itself a risk signal.

The Factor Editor UI displays the default score policy prominently to ensure the compliance officer makes a conscious decision.


14. Multi-Value Fields

Some ontology fields contain arrays rather than single values (e.g., countries_of_operation, industry_codes, nationalities). Each scoring method handles arrays differently:

REFERENCE_LOOKUP with Arrays

When the ontology field value is an array, the scorer looks up each value in the reference dataset and applies an aggregation strategy configured on the factor:

{
"scoring_config": {
"reference_dataset": "country_risk",
"lookup_key_column": "country_code",
"score_column": "risk_score",
"multi_value_strategy": "max",
"default_score": 5
}
}

Note: The ontology field (countries_of_operation) is determined by the wire mapping, not the scoring config. The scorer detects that the resolved value is an array and applies the multi_value_strategy.

Strategies:

  • max — score = highest individual lookup score. "Score the riskiest country." Default.
  • avg — score = average of all lookup scores. "Score the average country risk."
  • any_above — score = max_score if any individual lookup exceeds a threshold, else 0. Used for binary "does any country hit a list?" checks.

BOOLEAN with Arrays

Not applicable — boolean fields should not be arrays. If encountered, treat as true if any element is true.

THRESHOLD_RANGES with Arrays

Aggregate the array first (sum, count, max, avg — configured), then apply ranges to the aggregate.


15. UI Changes — Factor Editor & OntologyMappingEditor

Separation of Concerns

The current OntologyMappingEditor component has three sections: Entity Mapping (entity type + field paths + indicators + thresholds), Investigation Module (module + fields), and Risk Indicator Keys (checkboxes). This component is replaced by a simpler Scoring Method Configurator that only handles how a value becomes a score. The "which field feeds this factor" question is answered in the Schema Mapping Designer, not here.

What the user configures where

ConcernWhereHow
Which ontology field feeds this factorSchema Mapping DesignerDrag a wire from an ontology field port to the factor's port in the MatrixColumn
How the field value becomes a scoreRisk Matrix Editor (Factor card)Select scoring method + configure method-specific settings
The reference data used for lookupsRisk Categories pageMaintain country risk, industry risk, PEP tier datasets

New Factor Card Layout

┌─────────────────────────────────────────────────────────────┐
│ ▶ Jurisdiction Risk Score: 10 Weight: 1.0│
├─────────────────────────────────────────────────────────────┤
│ Description: Country risk based on jurisdiction of incorp. │
│ │
│ Data Source: country_of_incorporation (via mapping) [→] │
│ (read-only — configured in Schema Mapping Designer) │
│ │
│ ┌─ Scoring Method ────────────────────────────────────────┐ │
│ │ Method: [REFERENCE_LOOKUP ▼] │ │
│ │ │ │
│ │ Reference Dataset: [country_risk ▼] [View dataset →] │ │
│ │ │ │
│ │ Default Score (unknown): [5] │ │
│ │ "Score applied when country is not in the dataset" │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ [Remove Factor] │
└─────────────────────────────────────────────────────────────┘

The "Data Source" line is read-only and shows which ontology field is wired to this factor (from the MatrixFactorMapping). The [→] link navigates to the Schema Mapping Designer to change the binding. If no wire is connected, it shows "Not mapped — connect in Schema Mapping Designer" with a link.

Scoring Method Section

The scoring method section renders different UIs depending on the selected method:

  • REFERENCE_LOOKUP: reference dataset picker (from Risk Categories) + default score + multi-value strategy (if applicable)
  • BOOLEAN: true score, false score, null score inputs
  • THRESHOLD_RANGES: range builder with add/remove rows + default score

What Happens to OntologyMappingEditor

The existing OntologyMappingEditor component (frontend/src/components/matrix/OntologyMappingEditor.tsx) is deprecated. During migration, it remains for factors using the legacy format. New factors use the Scoring Method Configurator. The "Configure" / "Mapped" button in the FactorEditor is replaced by a "Scoring" button that opens the scoring method section.


16. Worked Example: Geographic Risk Dimension

Matrix Configuration (stored in risk_matrix_schemas.matrix_definition)

dimensions:
geographic:
label: "Geographic Risk"
weight: 0.25
factors:
- id: jurisdiction_risk
label: "Jurisdiction of Registration"
max_score: 10
weight: 1.0
scoring_method: REFERENCE_LOOKUP
scoring_config:
reference_dataset: "country_risk"
lookup_key_column: "country_code"
score_column: "risk_score"
default_score: 5

- id: high_risk_jurisdiction_flag
label: "High-Risk Jurisdiction Flag"
max_score: 10
weight: 1.0
scoring_method: BOOLEAN
scoring_config:
score_true: 9
score_false: 1
score_null: 5

Wire Mappings (from Schema Mapping Designer, stored in schema version's matrix_mappings)

{
"geographic.jurisdiction_risk": "country_of_incorporation",
"geographic.high_risk_jurisdiction_flag": "is_high_risk_jurisdiction"
}

Entity Data (from ontology layer)

{
"country_of_incorporation": "PA",
"is_high_risk_jurisdiction": true
}

Scoring Walkthrough

Factor 1: jurisdiction_risk

  • Wire mapping: geographic.jurisdiction_riskcountry_of_incorporation
  • Entity value for country_of_incorporation: "PA"
  • Scoring method: REFERENCE_LOOKUP
  • Lookup "PA" in country_risk dataset → risk_score = 8
  • Capped to max_score (10): min(8, 10) = 8
  • Factor score: 8

Factor 2: high_risk_jurisdiction_flag

  • Wire mapping: geographic.high_risk_jurisdiction_flagis_high_risk_jurisdiction
  • Entity value for is_high_risk_jurisdiction: true
  • Scoring method: BOOLEAN
  • true → score_true = 9
  • Capped to max_score (10): min(9, 10) = 9
  • Factor score: 9

Dimension aggregation:

  • raw_total = 8 + 9 = 17
  • max_possible = 10 + 10 = 20
  • normalized = round(17 / 20 × 100) = 85
  • Geographic Risk score: 85High

Evaluation Result (stored)

{
"geographic": {
"score": 85,
"level": "high",
"raw_total": 17,
"max_possible": 20,
"factors": [
{
"factor_id": "jurisdiction_risk",
"raw_score": 8,
"capped_score": 8,
"max_score": 10,
"contributing_indicators": [{
"method": "REFERENCE_LOOKUP",
"value": "PA",
"dataset": "country_risk",
"matched_score": 8
}]
},
{
"factor_id": "high_risk_jurisdiction_flag",
"raw_score": 9,
"capped_score": 9,
"max_score": 10,
"contributing_indicators": [{
"method": "BOOLEAN",
"value": true
}]
}
]
}
}

17. Proof of Concept Scope

Goal

Validate the complete data flow from ontology fields through factor scoring to evaluation result, using the simplest possible configuration.

POC Scope

ComponentWhat to Build
Scoring engineNew _evaluate_factor with REFERENCE_LOOKUP + BOOLEAN methods
Entity data assemblyFunction that reads an entity's ontology fields into a flat dict
Reference dataUse existing country_risk dataset from Risk Categories
Matrix definitionOne dimension (Geographic Risk), two factors (as in §16)
TestScore one entity, verify the result matches the worked example
FrontendNot in POC scope — scoring method UI comes in Phase 2

What the POC Proves

  1. Ontology field values can be read and passed to the scoring engine
  2. REFERENCE_LOOKUP correctly resolves a categorical value against a reference dataset
  3. BOOLEAN correctly maps true/false/null to configured scores
  4. Factor scores aggregate into a dimension score
  5. The evaluation result contains full factor-level traceability

POC Test Case

def test_geographic_risk_poc():
matrix_def = {
"dimensions": {
"geographic": {
"label": "Geographic Risk",
"weight": 0.25,
"factors": [
{
"id": "jurisdiction_risk",
"label": "Jurisdiction of Registration",
"max_score": 10,
"weight": 1.0,
"scoring_method": "REFERENCE_LOOKUP",
"scoring_config": {
"reference_dataset": "country_risk",
"lookup_key_column": "country_code",
"score_column": "risk_score",
"default_score": 5,
},
},
{
"id": "high_risk_jurisdiction_flag",
"label": "High-Risk Jurisdiction Flag",
"max_score": 10,
"weight": 1.0,
"scoring_method": "BOOLEAN",
"scoring_config": {
"score_true": 9,
"score_false": 1,
"score_null": 5,
},
},
],
}
}
}

# Wire mappings from Schema Mapping Designer
wire_mappings = {
"geographic.jurisdiction_risk": "country_of_incorporation",
"geographic.high_risk_jurisdiction_flag": "is_high_risk_jurisdiction",
}

reference_data = {
"country_risk": [
{"country_code": "NL", "risk_score": 2},
{"country_code": "PA", "risk_score": 8},
{"country_code": "IR", "risk_score": 10},
]
}

entity_data = {
"country_of_incorporation": "PA",
"is_high_risk_jurisdiction": True,
}

scorer = RiskMatrixScorer(
matrix_definition=matrix_def,
aggregation_config={"method": "weighted_average", "dimension_weights": {"geographic": 1.0}, "risk_levels": {...}},
reference_data_snapshot=reference_data,
wire_mappings=wire_mappings,
)

result = scorer.evaluate(entity_data, matrix_schema_id="poc_test")

geo = result.dimension_results["geographic"]
assert geo.score == 85
assert geo.level == "high"
assert geo.factors[0].capped_score == 8 # jurisdiction_risk
assert geo.factors[1].capped_score == 9 # high_risk_jurisdiction_flag

18. Migration from ADR-008 Indicator Model

Backwards Compatibility

The scoring engine maintains a _evaluate_factor_legacy fallback that handles factors using the ADR-008 ontology_mapping.fields[].indicator + thresholds format. This ensures existing matrix definitions continue to work during migration.

Migration Strategy

  1. Phase 1 (POC): New factors use scoring_method + scoring_config. Legacy factors use the old format. The scorer dispatches based on presence of scoring_method field.

  2. Phase 2 (UI): The Factor Editor UI only creates factors with the new format. Existing legacy factors display a migration prompt: "This factor uses the legacy scoring format. Convert to [suggested method]?"

  3. Phase 3 (Cleanup): Remove _evaluate_factor_legacy and the _check_threshold match/case block. All factors must use the new format. Migration script converts legacy factors automatically:

    • indicator: "country_risk_list"REFERENCE_LOOKUP
    • indicator: "equals" with boolean thresholds → BOOLEAN
    • indicator: "greater_than" / "less_than"THRESHOLD_RANGES
    • indicator: "in" / "intersects"REFERENCE_LOOKUP (with multi-value strategy)
    • indicator: "recency_days"THRESHOLD_RANGES

19. Rejected Alternatives

Keep the indicator-type model and just add more types

Considered adding reference_lookup, boolean_flag, and range_check as new indicator types alongside the existing seven. Rejected because the indicator type abstraction was already overloaded — it serves as both a comparison operator and a scoring strategy. Adding more types would deepen the coupling and make the _check_threshold function even harder to maintain. Clean separation into scoring methods is worth the migration cost.

Allow multiple scoring methods per factor

Considered letting a factor combine methods (e.g., REFERENCE_LOOKUP for the primary score + BOOLEAN boost for a flag). Rejected because this reintroduces the complexity of ADR-008's multi-field factor model. If two different data points contribute to the same risk concept, they should be two factors in the same dimension — this is clearer for audit and easier to configure.

Put scoring configuration in the reference dataset rather than the factor

Considered having the reference dataset define not just values and scores but also the scoring method. Rejected because the scoring method is a matrix concern, not a data concern. The same reference dataset (e.g., country_risk) might be used by different factors with different max_scores and different default behaviors.

Build FORMULA first

Considered implementing all four scoring methods simultaneously. Rejected because FORMULA has significant design complexity (expression validation, security, preview) and the other three methods cover 90%+ of EBA factors. Better to validate the pipeline with simple methods first.


20. Open Questions

  1. Should factors support secondary scoring methods? For example, a REFERENCE_LOOKUP factor could have a BOOLEAN fallback: "if the country is not in the dataset AND the entity has is_eu_country = false, apply a higher default score." This adds flexibility but also complexity. Parking for Phase 2.

  2. How should the ontology field dropdown be populated? The Factor Editor needs to show available ontology fields. Should it read from the published ontology schema, the draft schema, or a separate field registry? This connects to ADR-010's schema lifecycle.

  3. Reference dataset row limits. Country risk datasets are small (~250 rows). Industry risk datasets could be larger (thousands of SBI/NACE codes). Should the scorer use an index/hash-map for large datasets instead of linear scan?

  4. Factor score normalization. Currently, factor scores are raw numbers (0 to max_score) and dimension scores are normalized to 0-100. Should factor scores also be normalized to 0-100 for consistency in the UI, or is the raw score more intuitive for compliance officers?

  5. Temporal dimension. The current model scores an entity at a point in time. Should the scoring engine support temporal comparison — "how did this entity's score change over the last 6 months?" — or should that be handled at the evaluation layer (compare two evaluation results)?

  6. Escalation rule interactions. If multiple escalation rules trigger, they should not stack (the highest minimum tier wins). But should the evaluation record show all triggered rules, or only the effective one?