Loan Approval Engine
Build a complete loan approval engine using inference rules, guard constraints, multi-level backward chaining, forward chaining, NAF, and homoiconic introspection — where rules are data that can be queried and modified at runtime.
What you will learn:
- Modeling business rules as inference rules with guard constraints
- Multi-level backward chaining (rules that depend on other rules)
- Forward chaining to derive aggregate risk assessments
- Negation-as-failure (NAF) for denial logic
- Homoiconic introspection: querying and modifying rules at runtime
- Open-world reasoning for incomplete applications
- Production safety parameters (
maxDepth,timeoutMs,maxSolutions)
Prerequisites:
- Completion of the Clinical Trial Knowledge Graph tutorial (or familiarity with sorts, psi-terms, and basic inference)
- A running Reasoning Layer backend at
https://platform.ovh.reasoninglayer.ai - Node.js 18+ with
@kortexya/reasoninglayerinstalled
1. Initialize and Set Up the Domain
import { ReasoningLayerClient, SortBuilder, psi, constrained, guard,} from '@kortexya/reasoninglayer';
import type { SortDto, BackwardChainResponse, ForwardChainResponse, AddRuleResponse, PsiTermDto } from '@kortexya/reasoninglayer';import type { Inference } from '@kortexya/reasoninglayer';type NafProveResponse = Inference.NafProveResponse;type SolutionDto = Inference.SolutionDto;
const client = new ReasoningLayerClient({ baseUrl: 'https://platform.ovh.reasoninglayer.ai', tenantId: '550e8400-e29b-41d4-a716-446655440000', auth: { mode: 'cookie' },});2. Model the Sort Hierarchy
The loan approval domain has a natural sort hierarchy. Subsorts of approval_decision — approved and denied — carry different features, enforced by the lattice.
// Base sortsconst person = await client.sorts.createSort( SortBuilder.create('person') .feature({ name: 'name', required: true, constraint: { type: 'String' } }) .feature({ name: 'age', required: false, constraint: { type: 'IntegerRange', value: { min: 0, max: 150 } } }) .build());
const applicant = await client.sorts.createSort( SortBuilder.create('applicant') .parent(person.id) .feature({ name: 'annual_income', required: true }) .feature({ name: 'credit_score', required: false }) .feature({ name: 'employment_status', required: true }) .build());
// Financial product hierarchyconst financialProduct = await client.sorts.createSort( SortBuilder.create('financial_product') .feature({ name: 'product_name', required: true }) .build());
const loan = await client.sorts.createSort( SortBuilder.create('loan') .parent(financialProduct.id) .feature({ name: 'applicant_name', required: true }) .feature({ name: 'amount', required: true }) .feature({ name: 'term_months', required: true }) .feature({ name: 'purpose', required: false }) .build());
const mortgage = await client.sorts.createSort( SortBuilder.create('mortgage') .parent(loan.id) .feature({ name: 'property_value', required: true }) .feature({ name: 'down_payment', required: true }) .build());
const personalLoan = await client.sorts.createSort( SortBuilder.create('personal_loan') .parent(loan.id) .build());
// Assessment sortsconst creditCheck = await client.sorts.createSort( SortBuilder.create('credit_check') .feature({ name: 'applicant_name', required: true }) .feature({ name: 'score', required: true }) .feature({ name: 'history_length', required: false }) .feature({ name: 'defaults', required: false }) .build());
const incomeVerification = await client.sorts.createSort( SortBuilder.create('income_verification') .feature({ name: 'applicant_name', required: true }) .feature({ name: 'verified_income', required: true }) .feature({ name: 'debt_to_income_ratio', required: true }) .build());
// Decision sorts — subsorts carry different featuresconst approvalDecision = await client.sorts.createSort( SortBuilder.create('approval_decision') .feature({ name: 'applicant_name', required: true }) .feature({ name: 'loan_type', required: false }) .build());
const approved = await client.sorts.createSort( SortBuilder.create('approved') .parent(approvalDecision.id) .feature({ name: 'approved_amount', required: false }) .build());
const denied = await client.sorts.createSort( SortBuilder.create('denied') .parent(approvalDecision.id) .feature({ name: 'denial_reason', required: true }) .build());3. Load Facts
Add applicant data, credit checks, and income verifications as inference facts:
// Applicantsawait client.inference.bulkAddFacts({ facts: [ psi('applicant', { name: 'Alice Chen', age: 35, annual_income: 120000, credit_score: 750, employment_status: 'full_time', }), psi('applicant', { name: 'Bob Martinez', age: 28, annual_income: 65000, credit_score: 580, employment_status: 'full_time', }), psi('applicant', { name: 'Carol Wu', age: 42, annual_income: 95000, credit_score: 710, employment_status: 'self_employed', }), // Incomplete application — no credit score yet psi('applicant', { name: 'Dana Park', age: 31, annual_income: 88000, employment_status: 'full_time', // credit_score: intentionally omitted — will demonstrate residuation }), ],});
// Credit checksawait client.inference.bulkAddFacts({ facts: [ psi('credit_check', { applicant_name: 'Alice Chen', score: 750, history_length: 12, defaults: 0, }), psi('credit_check', { applicant_name: 'Bob Martinez', score: 580, history_length: 4, defaults: 2, }), psi('credit_check', { applicant_name: 'Carol Wu', score: 710, history_length: 8, defaults: 0, }), // No credit check for Dana Park — residuation will surface this ],});
// Income verificationsawait client.inference.bulkAddFacts({ facts: [ psi('income_verification', { applicant_name: 'Alice Chen', verified_income: 120000, debt_to_income_ratio: 0.28, }), psi('income_verification', { applicant_name: 'Bob Martinez', verified_income: 65000, debt_to_income_ratio: 0.52, }), psi('income_verification', { applicant_name: 'Carol Wu', verified_income: 95000, debt_to_income_ratio: 0.35, }), ],});
// Loan applicationsawait client.inference.bulkAddFacts({ facts: [ psi('loan', { applicant_name: 'Alice Chen', amount: 350000, term_months: 360, purpose: 'home_purchase', }), psi('loan', { applicant_name: 'Bob Martinez', amount: 25000, term_months: 60, purpose: 'car_purchase', }), psi('loan', { applicant_name: 'Carol Wu', amount: 150000, term_months: 180, purpose: 'business_expansion', }), ],});4. Write Rules with Guard Constraints
Rules in the Reasoning Layer use guard constraints — arithmetic conditions on feature values that participate directly in unification. Unlike SQL WHERE clauses, guards are part of the structural pattern, not a post-filter.
4.1 Credit Assessment Rule
// Rule: good_credit(?Name) :-// credit_check(?Name, score >= 680, defaults = 0)const creditRule: AddRuleResponse = await client.inference.addRule({ term: psi('good_credit', { applicant_name: '?Name', }), antecedents: [ psi('credit_check', { applicant_name: '?Name', score: constrained('?Score', guard('gte', 680)), defaults: constrained('?Defaults', guard('eq', 0)), }), ], certainty: 0.95,});console.log('Credit rule ID:', creditRule.term.termId);4.2 Income Adequacy Rule
// Rule: income_adequate(?Name) :-// income_verification(?Name, debt_to_income_ratio < 0.43)await client.inference.addRule({ term: psi('income_adequate', { applicant_name: '?Name', }), antecedents: [ psi('income_verification', { applicant_name: '?Name', debt_to_income_ratio: constrained('?DTI', guard('lt', 0.43)), }), ], certainty: 0.9,});4.3 Multi-Antecedent Approval Rule
This rule depends on two other rules (good_credit and income_adequate), creating a multi-level inference chain. The engine automatically chains through all levels:
// Rule: approved(?Name) :-// applicant(?Name, age >= 18),// good_credit(?Name),// income_adequate(?Name),// loan(?Name, amount: ?Amount)await client.inference.addRule({ term: psi('approved', { applicant_name: '?Name', }), antecedents: [ psi('applicant', { name: '?Name', age: constrained('?Age', guard('gte', 18)), }), psi('good_credit', { applicant_name: '?Name', }), psi('income_adequate', { applicant_name: '?Name', }), psi('loan', { applicant_name: '?Name', amount: '?Amount', }), ], certainty: 0.9,});5. Backward Chaining: “Who is Approved?”
Run backward chaining to find all approved applicants. The engine chains through three levels: approved -> good_credit/income_adequate -> credit_check/income_verification.
const approvalResult: BackwardChainResponse = await client.inference.backwardChain({ goal: psi('approved', { applicant_name: '?Who', }), maxSolutions: 10, maxDepth: 50, timeoutMs: 5000,});
console.log(`\n=== Approval Results ===`);console.log(`Found ${approvalResult.solutions.length} approved applicant(s)`);console.log(`Query time: ${approvalResult.queryTimeMs}ms`);
for (const sol of approvalResult.solutions) { const bindings = new Map( sol.substitution.bindings.map(b => [b.variableName, b.boundToDisplay]) );
console.log(`\n Approved: ${bindings.get('?Who')}`); console.log(` Certainty: ${sol.certainty}`);
// Walk the proof tree to see how the engine chained through rules if (sol.proof) { console.log(' Proof chain:'); printProof(sol.proof, 2); }}
function printProof( proof: { goalTermId?: string; ruleTermId?: string | null; certainty?: number; subproofs?: Array<typeof proof> }, indent: number,): void { const pad = ' '.repeat(indent); console.log(`${pad}→ Goal: ${proof.goalTermId} (certainty: ${proof.certainty})`); if (proof.ruleTermId) { console.log(`${pad} via rule: ${proof.ruleTermId}`); } if (proof.subproofs) { for (const sub of proof.subproofs) { printProof(sub, indent + 1); } }}Expected output: Alice Chen is approved (credit score 750 >= 680, DTI 0.28 < 0.43, age 35 >= 18). Carol Wu is approved (score 710, DTI 0.35). Bob Martinez is not approved (score 580 < 680).
6. Negation-as-Failure: “Who is Denied?”
NAF allows you to express “if good credit cannot be proved, deny the application.” This is logically different from “credit is explicitly bad” — it captures the open-world distinction.
import { toTermInputDto } from '@kortexya/reasoninglayer';
// Find applicants who are NOT good_creditconst nafResult: NafProveResponse = await client.inference.nafProve({ literals: [ { term: toTermInputDto(psi('applicant', { name: '?Name', })), negated: false, // There IS an applicant }, { term: toTermInputDto(psi('good_credit', { applicant_name: '?Name', })), negated: true, // who does NOT have good credit }, ], maxSolutions: 20,});
console.log(`\n=== Denial Candidates (NAF) ===`);console.log(`${nafResult.solutions.length} applicant(s) without provable good credit`);
for (const sol of nafResult.solutions) { const bindings = new Map( sol.substitution.bindings.map(b => [b.variableName, b.boundToDisplay]) ); console.log(` Denied: ${bindings.get('?Name')}`);}Expected output: Bob Martinez (score 580 < 680) and Dana Park (no credit check on file) are denied. Note that Dana is denied under NAF because good_credit cannot be proved — but see Section 9 for how open-world reasoning handles her differently.
7. Forward Chaining: Risk Level Derivation
Forward chaining applies all rules exhaustively and derives everything that can be derived. Use it for computing aggregate assessments.
// Add risk-level classification rulesawait client.inference.bulkAddRules({ rules: [ { term: psi('risk_level', { applicant_name: '?Name', level: 'low', }), antecedents: [ psi('good_credit', { applicant_name: '?Name', }), psi('income_adequate', { applicant_name: '?Name', }), ], certainty: 0.95, }, { term: psi('risk_level', { applicant_name: '?Name', level: 'high', }), antecedents: [ psi('credit_check', { applicant_name: '?Name', score: constrained('?Score', guard('lt', 680)), }), ], certainty: 0.85, }, { term: psi('risk_level', { applicant_name: '?Name', level: 'medium', }), antecedents: [ psi('credit_check', { applicant_name: '?Name', score: constrained('?Score', guard('gte', 680)), }), psi('income_verification', { applicant_name: '?Name', debt_to_income_ratio: constrained('?DTI', guard('gte', 0.35)), }), ], certainty: 0.9, }, ],});
// Run forward chainingconst fcResult: ForwardChainResponse = await client.inference.forwardChain({ persistDerived: true, enableProvenanceTags: true, maxIterations: 100, maxFacts: 1000,});
console.log(`\n=== Forward Chaining Results ===`);console.log(`Derived ${fcResult.derivedCount} facts in ${fcResult.iterations} iterations`);console.log(`Total facts: ${fcResult.totalFacts}`);
for (const fact of fcResult.derivedFacts) { console.log(` ${fact.sortName}: ${fact.display}`);}8. Homoiconic Introspection: Querying Your Rules
This is the capability that no traditional rule engine offers. In the Reasoning Layer, rules are psi-terms. They exist in the same knowledge base as facts. You can query them, inspect them, and modify them — because data = code = constraint.
8.1 List All Rules as Psi-Terms
const allFactsResult = await client.inference.getFacts();
console.log(`\n=== Homoiconic Knowledge Base ===`);console.log(`Total entries: ${allFactsResult.count}`);
// Rules and facts are all psi-terms in the same collectionfor (const entry of allFactsResult.facts) { console.log(` [${entry.sortName}] ${entry.display}`);}8.2 Modify a Rule at Runtime
Suppose regulators lower the credit score threshold from 680 to 650. You can modify the rule without restarting:
// Add updated credit rule with lower thresholdawait client.inference.addRule({ term: psi('good_credit', { applicant_name: '?Name', }), antecedents: [ psi('credit_check', { applicant_name: '?Name', score: constrained('?Score', guard('gte', 650)), // Changed from 680 defaults: constrained('?Defaults', guard('eq', 0)), }), ], certainty: 0.95,});
// Re-run inference to see the changed behaviorconst newResult = await client.inference.backwardChain({ goal: psi('approved', { applicant_name: '?Who', }), maxSolutions: 10,});
console.log(`\nAfter threshold change: ${newResult.solutions.length} approved`);// Bob (score 580) is still denied, but the threshold is now more permissive9. Open-World Reasoning: Incomplete Applications
Remember Dana Park — she has an application but no credit check. NAF treated her as “not good credit.” Open-world reasoning provides a richer answer.
const openResult = await client.inference.backwardChain({ goal: psi('approved', { applicant_name: 'Dana Park', }), openWorld: true, minCertainty: 0.3,});
console.log(`\n=== Open-World: Dana Park ===`);for (const sol of openResult.solutions) { console.log(`Certainty: ${sol.certainty}`);
if (sol.evidenceRatio !== null && sol.evidenceRatio !== undefined) { const matched = sol.evidenceMatched ?? 0; const total = sol.evidenceRatio > 0 ? Math.round(matched / sol.evidenceRatio) : 0; console.log(`Evidence: ${matched}/${total} antecedents satisfied`); }
if (sol.residuatedSorts && sol.residuatedSorts.length > 0) { console.log(`Missing data for sorts: ${sol.residuatedSorts.join(', ')}`); // The engine tells you: "I need credit_check data for Dana Park to complete this proof" }}10. Production Considerations
10.1 Safety Parameters
Always set bounds on inference in production:
const prodResult = await client.inference.backwardChain({ goal: psi('approved', { applicant_name: '?Who', }), maxSolutions: 100, // Don't return unbounded results maxDepth: 50, // Prevent infinite recursion timeoutMs: 5000, // 5-second hard limit minCertainty: 0.5, // Only return confident results});10.2 Saved Goals for Repeated Queries
// Create a saved goal for the approval queryconst savedGoal = await client.inference.createGoal({ clauses: [ psi('approved', { applicant_name: '?Name', }), ], minDegree: 0.5,});
// Re-use the saved goal — faster than re-parsingconst savedResult = await client.inference.backwardChain({ goalId: savedGoal.goalId, maxSolutions: 100,});
// List all saved goalsconst goals = await client.inference.listGoals();console.log(`Saved goals: ${goals.count}`);10.3 Cleanup Between Sessions
const cleared = await client.inference.clearFacts();console.log(`Cleared ${cleared.factsCleared} facts`);What You Learned
| Concept | What It Means | Traditional Alternative |
|---|---|---|
| Multi-level backward chaining | Rules depend on other rules; the engine chains automatically | Drools: explicit salience ordering |
| Guard constraints | guard('gte', 680) constrains a variable during unification | SQL WHERE clause (separate from data model) |
| Variable coreference | ?Name in multiple antecedents means “the same value” | SQL JOIN conditions |
| Forward + backward composition | Forward chaining derives facts that backward chaining queries | Most engines support only one direction |
| NAF | ”If X cannot be proved, assume not-X” | SQL NOT IN (no logical semantics) |
| Homoiconic introspection | Rules are psi-terms — queryable and modifiable at runtime | Rule base is opaque configuration |
| Open-world + residuation | ”I need more data” vs “this is false” | NULL semantics |
| Certainty propagation | Product of rule certainties through the chain | Binary true/false |
Next Steps
- Incident Response Agent — BDI cognitive agents with beliefs, goals, episodic memory, and real-time events