Skip to content

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/reasoninglayer installed

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_decisionapproved and denied — carry different features, enforced by the lattice.

// Base sorts
const 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 hierarchy
const 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 sorts
const 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 features
const 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:

// Applicants
await 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 checks
await 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 verifications
await 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 applications
await 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_credit
const 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 rules
await 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 chaining
const 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 collection
for (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 threshold
await 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 behavior
const 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 permissive

9. 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 query
const savedGoal = await client.inference.createGoal({
clauses: [
psi('approved', {
applicant_name: '?Name',
}),
],
minDegree: 0.5,
});
// Re-use the saved goal — faster than re-parsing
const savedResult = await client.inference.backwardChain({
goalId: savedGoal.goalId,
maxSolutions: 100,
});
// List all saved goals
const 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

ConceptWhat It MeansTraditional Alternative
Multi-level backward chainingRules depend on other rules; the engine chains automaticallyDrools: explicit salience ordering
Guard constraintsguard('gte', 680) constrains a variable during unificationSQL WHERE clause (separate from data model)
Variable coreference?Name in multiple antecedents means “the same value”SQL JOIN conditions
Forward + backward compositionForward chaining derives facts that backward chaining queriesMost engines support only one direction
NAF”If X cannot be proved, assume not-X”SQL NOT IN (no logical semantics)
Homoiconic introspectionRules are psi-terms — queryable and modifiable at runtimeRule base is opaque configuration
Open-world + residuation”I need more data” vs “this is false”NULL semantics
Certainty propagationProduct of rule certainties through the chainBinary true/false

Next Steps