Skip to main content

Overview

Yggdrasil explanations are template-generated, not LLM-generated. Every violation includes:
  1. Policy Excerpt: The exact clause from the regulatory document
  2. Evidence Grid: Matched field values from your data
  3. Condition Summary: Human-readable breakdown of what matched
No AI calls in the enforcement loop means:
  • Deterministic: Same violation → same explanation
  • Audit-ready: No hallucinations or inconsistencies
  • Fast: No API latency for explanation generation

Implementation

Explanations are generated by explainability.ts using string templates:
// From explainability.ts:41-150
export function generateExplanation(
    rule: Rule,
    record: NormalizedRecord
): string {
    const recordId = record.account
        ? `${record.step}_${record.account}`
        : `record_${record.step}`;

    switch (rule.rule_id) {
        case 'CTR_THRESHOLD':
            return (
                `Transaction ${recordId} was flagged under CTR_THRESHOLD because:\n\n` +
                `- Amount: $${record.amount.toLocaleString()}\n` +
                `- Threshold: $10,000\n` +
                `- Transaction Type: ${record.type}\n` +
                `- Account: ${record.account}\n\n` +
                `Policy Reference: Section 1 - Currency Transaction Reporting\n` +
                `Severity: CRITICAL\n\n` +
                `This transaction exceeds the $10,000 CTR filing threshold and requires ` +
                `a Currency Transaction Report to be filed with FinCEN.`
            );
        // ... more templates
    }
}

Explanation Components

1. Policy Excerpt

Every rule includes the exact text from the regulatory document:
{
  "policy_excerpt": "Financial institutions must file a Currency Transaction Report (CTR) for each transaction in currency of more than $10,000.",
  "policy_section": "31 CFR 1020.310"
}
This is stored in the rules table and displayed in the violation detail.

2. Evidence Grid

The evidence object contains all relevant field values from the matched record:
// From in-memory-backend.ts:574-577
evidence: {
    ...record,
    ...(match.condition_summary ? { condition_summary: match.condition_summary } : {}),
}
Example evidence:
{
  "account": "C1234567",
  "amount": 15000,
  "transaction_type": "WIRE",
  "step": 42,
  "recipient": "R7890123",
  "condition_summary": "amount >= 10000 (actual: 15000)"
}

3. Condition Summary

For condition-based rules (GDPR, SOC2), the engine generates a human-readable summary:
// From explainability.ts:12-35
function summarizeConditions(cond: any, record: NormalizedRecord, depth = 0): string {
    if (!cond) return '';
    const indent = '  '.repeat(depth);

    if ('AND' in cond && Array.isArray(cond.AND)) {
        const parts = cond.AND.map((c: any) => summarizeConditions(c, record, depth + 1));
        return `${indent}ALL of:\n${parts.join('\n')}`;
    }
    if ('OR' in cond && Array.isArray(cond.OR)) {
        const parts = cond.OR.map((c: any) => summarizeConditions(c, record, depth + 1));
        return `${indent}ANY of:\n${parts.join('\n')}`;
    }
    if ('field' in cond) {
        const actualValue = record[cond.field];
        if (cond.operator === 'exists') {
            return `${indent}- ${cond.field} is present (value: ${JSON.stringify(actualValue)})`;
        }
        if (cond.operator === 'not_exists') {
            return `${indent}- ${cond.field} is missing or empty (value: ${JSON.stringify(actualValue)})`;
        }
        return `${indent}- ${cond.field} ${cond.operator} ${JSON.stringify(cond.value)} (actual: ${JSON.stringify(actualValue)})`;
    }
    return '';
}
Example output:
ALL of:
  - amount >= 10000 (actual: 15000)
  - transaction_type IN ["DEBIT", "WIRE"] (actual: "WIRE")

Single-Transaction Explanations

Prebuilt Rule Templates

For well-known rules (AML, FinCEN), the engine uses hardcoded templates:

CTR Threshold

case 'CTR_THRESHOLD':
    return (
        `Transaction ${recordId} was flagged under CTR_THRESHOLD because:\n\n` +
        `- Amount: $${record.amount.toLocaleString()}\n` +
        `- Threshold: $10,000\n` +
        `- Transaction Type: ${record.type}\n` +
        `- Account: ${record.account}\n\n` +
        `Policy Reference: Section 1 - Currency Transaction Reporting\n` +
        `Severity: CRITICAL\n\n` +
        `This transaction exceeds the $10,000 CTR filing threshold and requires ` +
        `a Currency Transaction Report to be filed with FinCEN.`
    );

Balance Mismatch

case 'BALANCE_MISMATCH': {
    const expected = record.oldbalanceOrg! - record.amount;
    const actual = record.newbalanceOrig!;
    const discrepancy = Math.abs(expected - actual);
    return (
        `Transaction ${recordId} was flagged under BALANCE_MISMATCH because:\n\n` +
        `- Transaction Amount: $${record.amount.toLocaleString()}\n` +
        `- Expected Balance Change: $${expected.toLocaleString()}\n` +
        `- Actual Balance Change: $${actual.toLocaleString()}\n` +
        `- Discrepancy: $${discrepancy.toLocaleString()}\n` +
        `- Account: ${record.account}\n\n` +
        `Policy Reference: Section 4 - Balance Verification\n` +
        `Severity: MEDIUM\n\n` +
        `The balance change does not match the transaction amount, indicating ` +
        `a potential data entry error or system issue.`
    );
}

Generic Condition-Based Explanation

For custom PDF-extracted rules, the engine uses the condition summarizer:
default: {
    const lines: string[] = [];
    lines.push(`Record ${recordId} was flagged under ${rule.rule_id} (${rule.name}) because:\n`);

    if (rule.conditions) {
        lines.push(summarizeConditions(rule.conditions, record));
        lines.push('');
    }

    if (rule.policy_excerpt) {
        lines.push(`Policy Reference: ${rule.policy_section || 'N/A'}`);
        lines.push(`Excerpt: "${rule.policy_excerpt}"`);
    }

    lines.push(`Severity: ${rule.severity}`);

    if (rule.description) {
        let descText = rule.description;
        try {
            const parsed = JSON.parse(rule.description);
            if (parsed.text) descText = parsed.text;
        } catch {
            // Not JSON, use raw string
        }
        lines.push(`\n${descText}`);
    }

    return lines.join('\n');
}
Example output:
Record 42_C1234567 was flagged under GDPR_CONSENT_MISSING (Consent Not Obtained) because:

ALL of:
  - consent_obtained == false (actual: false)
  - marketing_sent == true (actual: true)

Policy Reference: Article 6(1)(a)
Excerpt: "Processing shall be lawful only if the data subject has given consent."

Severity: HIGH

Marketing communications were sent without obtaining explicit consent from the data subject.

Windowed Explanations

Windowed rules (aggregations, velocity) generate context-aware explanations:
// From explainability.ts:155-259
export function generateWindowedExplanation(
    rule: Rule,
    account: string,
    records: NormalizedRecord[],
    extras: Record<string, any> = {}
): string {
    const total = records.reduce((sum, r) => sum + r.amount, 0);
    const amounts = records.map((r) => `$${r.amount.toLocaleString()}`).join(', ');

    switch (rule.rule_id) {
        case 'CTR_AGGREGATION':
            return (
                `Account pair ${account}${extras.recipient ?? 'multiple'} was flagged under CTR_AGGREGATION because:\n\n` +
                `- Aggregate Amount: $${total.toLocaleString()}\n` +
                `- Transaction Count: ${records.length}\n` +
                `- Time Window: 24 hours\n` +
                `- Individual Amounts: ${amounts}\n\n` +
                `Policy Reference: Section 1 - CTR Aggregation\n` +
                `Severity: CRITICAL\n\n` +
                `Multiple transactions to the same person within 24 hours exceeded the ` +
                `$10,000 aggregate CTR threshold.`
            );
        // ...
    }
}

Structuring Pattern Example

case 'STRUCTURING_PATTERN':
    return (
        `Account ${account} was flagged under STRUCTURING_PATTERN because:\n\n` +
        `- Transaction Count: ${records.length}\n` +
        `- Individual Amounts: ${amounts} (all between $8,000-$10,000)\n` +
        `- Total Amount: $${total.toLocaleString()}\n` +
        `- Time Window: 24 hours\n\n` +
        `Policy Reference: Section 2 - Structuring Detection\n` +
        `Severity: CRITICAL\n\n` +
        `This account conducted ${records.length} transactions just under the $10,000 ` +
        `CTR threshold within 24 hours, suggesting intentional structuring ` +
        `to avoid reporting requirements.`
    );
Output:
Account C1234567 was flagged under STRUCTURING_PATTERN because:

- Transaction Count: 5
- Individual Amounts: $8,500, $9,200, $8,800, $9,500, $9,000 (all between $8,000-$10,000)
- Total Amount: $45,000
- Time Window: 24 hours

Policy Reference: Section 2 - Structuring Detection
Severity: CRITICAL

This account conducted 5 transactions just under the $10,000 CTR threshold within 24 hours, suggesting intentional structuring to avoid reporting requirements.

Description Field Format

The description field in the rules table can be:
  1. Plain text: Used directly
  2. JSON with historical context:
{
  "text": "Processing of personal data without lawful basis",
  "historical_context": {
    "avg_fine": "€15M",
    "breach_example": "Google LLC (2019) - €50M for lack of transparency",
    "article_reference": "Article 6(1)"
  }
}
The explanation generator parses this and uses the text field.

Why Templates, Not LLMs?

Determinism

Template:
Input: { amount: 15000, type: "WIRE" }
Output: "Amount: $15,000, Type: WIRE"

Run 1: ✅ "Amount: $15,000, Type: WIRE"
Run 2: ✅ "Amount: $15,000, Type: WIRE"
Run 1000: ✅ "Amount: $15,000, Type: WIRE"
LLM:
Input: { amount: 15000, type: "WIRE" }

Run 1: "The transaction amount was $15,000 via wire transfer."
Run 2: "A wire transfer of fifteen thousand dollars was detected."
Run 3: "$15K WIRE transaction flagged."
❌ Not reproducible → Not audit-ready

Speed

MethodLatency
Template<1ms
LLM (Gemini)200-800ms
For 1,000 violations, templates take 1 second. LLMs would take 5+ minutes.

No Hallucinations

Templates only use:
  • Rule metadata (stored in DB)
  • Record fields (from CSV)
  • Hardcoded policy references
LLMs can hallucinate:
  • Fake policy section numbers
  • Incorrect thresholds
  • Misleading explanations

Cost

Templates are free. LLM calls cost money per violation.

Evidence Drawer

The violation detail page displays:
  1. Policy Excerpt (top)
  2. Evidence Grid (structured data)
  3. Explanation (formatted text)
Example UI structure:
┌─────────────────────────────────────────┐
│ Policy Excerpt                          │
│ "Financial institutions must file..."   │
│ Section: 31 CFR 1020.310                │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│ Evidence                                │
│ • Account: C1234567                     │
│ • Amount: $15,000                       │
│ • Type: WIRE                            │
│ • Timestamp: 2026-02-15 14:32:10        │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│ Explanation                             │
│ Transaction 42_C1234567 was flagged     │
│ under CTR_THRESHOLD because:            │
│                                         │
│ - Amount: $15,000                       │
│ - Threshold: $10,000                    │
│ - Transaction Type: WIRE                │
│ ...                                     │
└─────────────────────────────────────────┘

Extending Templates

To add a new rule template:
  1. Edit explainability.ts
  2. Add a new case to the switch statement:
case 'YOUR_RULE_ID':
    return (
        `Record ${recordId} was flagged under YOUR_RULE_ID because:\n\n` +
        `- Field1: ${record.field1}\n` +
        `- Field2: ${record.field2}\n\n` +
        `Policy Reference: ${rule.policy_section}\n` +
        `Severity: ${rule.severity}\n\n` +
        `Detailed explanation of why this matters.`
    );
  1. Fallback: If no template exists, the generic condition summarizer is used.

Next Steps

Confidence Scoring

Learn how violations are ranked

Bayesian Feedback

How user reviews improve explanations

Build docs developers (and LLMs) love