Skip to content

Rule-Based Decision Engine

A Rule-Based System (RBS) is a deterministic decision-making engine that uses predefined domain knowledge encoded as “IF-THEN” rules to:

  1. Match incoming data against diagnosis or assessment rules
  2. Suggest appropriate actions or interventions based on matched rules
  3. Validate action safety (age, category, contraindications)
  4. Check inventory or resource availability
  5. Recommend alternatives when primary options are unavailable
  • Deterministic: Same input always produces same output
  • Transparent: Domain experts can understand and verify each decision
  • Auditable: Easier to trace than “black-box” AI models
  • Expert-Encoded: Rules created and maintained by domain professionals
  • Safe Fallback: Always requires human verification before execution
graph TB
    A[Input Data] --> B[Symptom / Condition Input<br/>Vital Parameters]
    B --> C{Rule Engine}

    C --> D[Assessment Rules]
    D --> E[Primary Assessment]

    E --> F[Action / Intervention Rules]
    F --> G[Suggested Actions]

    G --> H[Safety Validator]
    H --> I{Check Category}
    H --> J{Check Profile}
    H --> K{Check Contraindications}

    I --> L{All Safe?}
    J --> L
    K --> L

    L -->|Yes| M[Inventory / Availability Check]
    L -->|No| N[Safety Alert — Suggest Alternatives]

    M --> O{Resource Available?}
    O -->|Yes| P[Final Recommendation]
    O -->|No| Q[Find Alternatives in Same Class]

    Q --> M
    N --> R[Manual Review Required]
    P --> S[Display to Operator]

    style E fill:#e1f5ff
    style G fill:#e8f5e9
    style N fill:#ffebee
    style P fill:#c8e6c9
    style Q fill:#fff9c4

Domain knowledge encoded as structured rules stored in PostgreSQL:

-- Assessment Rules Table
CREATE TABLE assessment_rules (
id UUID PRIMARY KEY,
rule_code VARCHAR(50) UNIQUE NOT NULL,
assessment_code VARCHAR(10) NOT NULL,
assessment_name_en VARCHAR(255) NOT NULL,
priority INT DEFAULT 0,
conditions JSONB NOT NULL, -- Rule conditions
confidence_level DECIMAL(3,2), -- 0.00-1.00
created_by UUID REFERENCES lookup_users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
is_active BOOLEAN DEFAULT TRUE
);
-- Action / Intervention Rules Table
CREATE TABLE action_rules (
id UUID PRIMARY KEY,
rule_code VARCHAR(50) UNIQUE NOT NULL,
assessment_code VARCHAR(10) NOT NULL,
action_generic_name VARCHAR(255) NOT NULL,
action_display_name VARCHAR(255),
parameters VARCHAR(100) NOT NULL, -- dosage / configuration
frequency VARCHAR(100) NOT NULL,
duration VARCHAR(100) NOT NULL,
route VARCHAR(50) NOT NULL, -- delivery method
priority INT DEFAULT 0,
contraindications JSONB, -- Category, profile, conditions
alternatives JSONB, -- Alternative actions
created_at TIMESTAMPTZ DEFAULT NOW(),
is_active BOOLEAN DEFAULT TRUE
);
-- Resource Inventory Table
CREATE TABLE resource_inventory (
id UUID PRIMARY KEY,
generic_name VARCHAR(255) NOT NULL,
display_name VARCHAR(255),
item_code VARCHAR(50) UNIQUE NOT NULL,
quantity_available INT NOT NULL,
unit VARCHAR(50) NOT NULL,
expiry_date DATE,
reorder_level INT DEFAULT 0,
is_available BOOLEAN DEFAULT TRUE
);
-- Resource Contraindications Table
CREATE TABLE resource_contraindications (
id UUID PRIMARY KEY,
resource_id UUID REFERENCES resources(id),
contraindication_type VARCHAR(50) NOT NULL, -- CATEGORY, PROFILE, OTHER
contraindication_name VARCHAR(255) NOT NULL,
reaction VARCHAR(255),
severity VARCHAR(50), -- MILD, MODERATE, SEVERE, CRITICAL
recorded_date DATE NOT NULL,
recorded_by UUID REFERENCES lookup_users(id)
);

NestJS service that processes rules and makes decisions:

apps/data-owner-bc/src/modules/assessment/services/rule-engine.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BadRequestException, NotFoundException } from '@lib/common';
import { AppDatabases } from '@lib/common/enum/app-databases.enum';
import { AssessmentRule } from '../entities/assessment-rule.entity';
import { ActionRule } from '../entities/action-rule.entity';
import { ResourceInventory } from '../entities/resource-inventory.entity';
interface ResourceProfile {
age: number;
category: string; // e.g., 'A', 'B', 'C' or domain-specific category
conditions: string[]; // List of presenting conditions or symptoms
parameters: {
temperature?: number;
pressureSystolic?: number;
pressureDiastolic?: number;
heartRate?: number;
respiratoryRate?: number;
oxygenSaturation?: number;
};
contraindications: string[]; // Known contraindications for this resource
}
interface AssessmentResult {
assessmentCode: string;
assessmentNameEn: string;
confidenceLevel: number;
matchedRules: string[];
suggestedActions: ActionRecommendation[];
}
interface ActionRecommendation {
genericName: string;
displayName?: string;
parameters: string;
frequency: string;
duration: string;
route: string;
isAvailableInInventory: boolean;
quantityAvailable?: number;
safetyChecks: SafetyCheck;
alternatives?: AlternativeAction[];
}
interface SafetyCheck {
isSafe: boolean;
warnings: string[];
contraindications: string[];
}
interface AlternativeAction {
genericName: string;
displayName?: string;
reason: string;
isAvailableInInventory: boolean;
}
@Injectable()
export class RuleEngineService {
constructor(
@InjectRepository(AssessmentRule, AppDatabases.APP_CORE)
private assessmentRuleRepo: Repository<AssessmentRule>,
@InjectRepository(ActionRule, AppDatabases.APP_CORE)
private actionRuleRepo: Repository<ActionRule>,
@InjectRepository(ResourceInventory, AppDatabases.APP_CORE)
private resourceInventoryRepo: Repository<ResourceInventory>,
) {}
/**
* Main method: Assess and suggest actions
*/
async assessAndSuggestActions(
profile: ResourceProfile,
): Promise<AssessmentResult[]> {
// Step 1: Match assessment rules
const assessments = await this.matchAssessmentRules(profile);
if (assessments.length === 0) {
throw new NotFoundException(
'No matching assessment found. Manual review required.',
);
}
// Step 2: For each assessment, suggest actions
for (const assessment of assessments) {
assessment.suggestedActions = await this.suggestActions(
assessment.assessmentCode,
profile,
);
}
// Step 3: Sort by confidence level (descending)
return assessments.sort((a, b) => b.confidenceLevel - a.confidenceLevel);
}
/**
* Step 1: Match profile data against assessment rules
*/
private async matchAssessmentRules(
profile: ResourceProfile,
): Promise<AssessmentResult[]> {
const allRules = await this.assessmentRuleRepo.find({
where: { is_active: true },
order: { priority: 'DESC' },
});
const matched: AssessmentResult[] = [];
for (const rule of allRules) {
const conditions = rule.conditions as Record<string, unknown>;
const matchResult = this.evaluateConditions(conditions, profile);
if (matchResult.isMatch) {
matched.push({
assessmentCode: rule.assessment_code,
assessmentNameEn: rule.assessment_name_en,
confidenceLevel: rule.confidence_level,
matchedRules: [rule.rule_code],
suggestedActions: [], // Filled later
});
}
}
return matched;
}
/**
* Evaluate rule conditions against profile data
*/
private evaluateConditions(
conditions: Record<string, unknown>,
profile: ResourceProfile,
): { isMatch: boolean; matchedConditions: string[] } {
const matchedConditions: string[] = [];
let totalConditions = 0;
let matchedCount = 0;
// Check presenting conditions
const requiredConditions = conditions.conditions as string[] | undefined;
if (requiredConditions && requiredConditions.length > 0) {
for (const required of requiredConditions) {
totalConditions++;
if (profile.conditions.some((c) => c.toLowerCase().includes(required.toLowerCase()))) {
matchedCount++;
matchedConditions.push(`Condition: ${required}`);
}
}
}
// Check parameters
const params = conditions.parameters as Record<string, unknown> | undefined;
if (params !== undefined) {
if (params.temperature !== undefined) {
totalConditions++;
const temp = profile.parameters.temperature;
if (temp !== undefined && this.checkRange(temp, params.temperature as { min?: number; max?: number })) {
matchedCount++;
matchedConditions.push(`Temperature: ${temp}`);
}
}
if (params.pressure !== undefined) {
totalConditions++;
const systolic = profile.parameters.pressureSystolic;
const diastolic = profile.parameters.pressureDiastolic;
const pressureRange = params.pressure as { systolic?: { min?: number; max?: number }; diastolic?: { min?: number; max?: number } };
if (
systolic !== undefined &&
diastolic !== undefined &&
pressureRange.systolic !== undefined &&
this.checkRange(systolic, pressureRange.systolic) &&
pressureRange.diastolic !== undefined &&
this.checkRange(diastolic, pressureRange.diastolic)
) {
matchedCount++;
matchedConditions.push(`Pressure: ${systolic}/${diastolic}`);
}
}
if (params.heartRate !== undefined) {
totalConditions++;
const hr = profile.parameters.heartRate;
if (hr !== undefined && this.checkRange(hr, params.heartRate as { min?: number; max?: number })) {
matchedCount++;
matchedConditions.push(`Heart Rate: ${hr}`);
}
}
}
// Check age range
const ageRange = conditions.ageRange as { min?: number; max?: number } | undefined;
if (ageRange !== undefined) {
totalConditions++;
const age = profile.age;
const minAge = ageRange.min ?? 0;
const maxAge = ageRange.max ?? Infinity;
if (age >= minAge && age <= maxAge) {
matchedCount++;
matchedConditions.push(`Age: ${age}`);
}
}
// Check category
if (conditions.category !== undefined) {
totalConditions++;
if (profile.category === conditions.category) {
matchedCount++;
matchedConditions.push(`Category: ${profile.category}`);
}
}
// Require at least 70% match
const matchPercentage = totalConditions > 0 ? matchedCount / totalConditions : 0;
return { isMatch: matchPercentage >= 0.7, matchedConditions };
}
private checkRange(value: number, range: { min?: number; max?: number }): boolean {
if (range.min !== undefined && value < range.min) return false;
if (range.max !== undefined && value > range.max) return false;
return true;
}
/**
* Step 2: Suggest actions for an assessment
*/
private async suggestActions(
assessmentCode: string,
profile: ResourceProfile,
): Promise<ActionRecommendation[]> {
const actionRules = await this.actionRuleRepo.find({
where: { assessment_code: assessmentCode, is_active: true },
order: { priority: 'DESC' },
});
const recommendations: ActionRecommendation[] = [];
for (const rule of actionRules) {
// Step 2.1: Safety checks
const safetyCheck = this.performSafetyChecks(rule, profile);
// Step 2.2: Inventory / availability check
const inventoryCheck = await this.checkInventory(rule.action_generic_name);
// Step 2.3: Find alternatives if needed
let alternatives: AlternativeAction[] = [];
if (!safetyCheck.isSafe || !inventoryCheck.isAvailable) {
alternatives = await this.findAlternatives(rule, profile);
}
recommendations.push({
genericName: rule.action_generic_name,
displayName: rule.action_display_name,
parameters: rule.parameters,
frequency: rule.frequency,
duration: rule.duration,
route: rule.route,
isAvailableInInventory: inventoryCheck.isAvailable,
quantityAvailable: inventoryCheck.quantity,
safetyChecks: safetyCheck,
alternatives: alternatives.length > 0 ? alternatives : undefined,
});
}
return recommendations;
}
/**
* Step 3: Perform safety checks (age, category, contraindications)
*/
private performSafetyChecks(
rule: ActionRule,
profile: ResourceProfile,
): SafetyCheck {
const warnings: string[] = [];
const contraindications: string[] = [];
let isSafe = true;
const ci = rule.contraindications as Record<string, unknown> | null;
if (ci === null) {
return { isSafe: true, warnings: [], contraindications: [] };
}
// Check age restrictions
const ageRestrictions = ci.ageRestrictions as { minAge?: number; maxAge?: number } | undefined;
if (ageRestrictions !== undefined) {
if (ageRestrictions.minAge !== undefined && profile.age < ageRestrictions.minAge) {
isSafe = false;
contraindications.push(`Age (${profile.age}) is below minimum (${ageRestrictions.minAge})`);
}
if (ageRestrictions.maxAge !== undefined && profile.age > ageRestrictions.maxAge) {
isSafe = false;
contraindications.push(`Age (${profile.age}) is above maximum (${ageRestrictions.maxAge})`);
}
}
// Check category restrictions
const categoryRestrictions = ci.categoryRestrictions as string[] | undefined;
if (categoryRestrictions !== undefined && categoryRestrictions.length > 0) {
if (!categoryRestrictions.includes(profile.category)) {
isSafe = false;
contraindications.push(`Action not suitable for category: ${profile.category}`);
}
}
// Check known contraindications
if (profile.contraindications.length > 0) {
const actionName = rule.action_generic_name.toLowerCase();
for (const contra of profile.contraindications) {
if (actionName.includes(contra.toLowerCase())) {
isSafe = false;
contraindications.push(`Known contraindication: ${contra}`);
}
}
}
// Attach special warnings
const ciWarnings = ci.warnings as string[] | undefined;
if (ciWarnings !== undefined) {
warnings.push(...ciWarnings);
}
return { isSafe, warnings, contraindications };
}
/**
* Step 4: Check inventory / resource availability
*/
private async checkInventory(
genericName: string,
): Promise<{ isAvailable: boolean; quantity?: number }> {
const inventory = await this.resourceInventoryRepo.findOne({
where: { generic_name: genericName, is_available: true },
});
if (inventory === null) {
return { isAvailable: false };
}
const isAvailable = inventory.quantity_available > inventory.reorder_level;
return { isAvailable, quantity: inventory.quantity_available };
}
/**
* Step 5: Find alternative actions
*/
private async findAlternatives(
originalRule: ActionRule,
profile: ResourceProfile,
): Promise<AlternativeAction[]> {
const alternativesData = originalRule.alternatives as Array<{ genericName: string; displayName?: string; reason?: string }> | null;
if (alternativesData === null || alternativesData.length === 0) {
return [];
}
const alternatives: AlternativeAction[] = [];
for (const alt of alternativesData) {
const altRule = await this.actionRuleRepo.findOne({
where: { action_generic_name: alt.genericName, is_active: true },
});
if (altRule === null) continue;
const safetyCheck = this.performSafetyChecks(altRule, profile);
if (!safetyCheck.isSafe) continue;
const inventoryCheck = await this.checkInventory(alt.genericName);
alternatives.push({
genericName: alt.genericName,
displayName: alt.displayName,
reason: alt.reason ?? 'Alternative in same action class',
isAvailableInInventory: inventoryCheck.isAvailable,
});
}
return alternatives;
}
}

Scenario: 55-year-old male presenting with severe hypertension.

{
"age": 55,
"category": "M",
"conditions": ["severe headache", "blurred vision", "chest discomfort"],
"parameters": {
"temperature": 37.2,
"pressureSystolic": 195,
"pressureDiastolic": 115,
"heartRate": 92,
"respiratoryRate": 18,
"oxygenSaturation": 97
},
"contraindications": []
}
INSERT INTO assessment_rules (
rule_code, assessment_code, assessment_name_en,
priority, conditions, confidence_level
) VALUES (
'HYPERTENSIVE_CRISIS_01', 'I16.0', 'Hypertensive Crisis',
10,
'{
"conditions": ["headache", "blurred vision"],
"parameters": {
"pressure": {
"systolic": { "min": 180 },
"diastolic": { "min": 110 }
}
},
"ageRange": { "min": 18, "max": 120 }
}'::jsonb,
0.95
);

Primary Action:

INSERT INTO action_rules (
rule_code, assessment_code,
action_generic_name, action_display_name,
parameters, frequency, duration, route,
priority, contraindications, alternatives
) VALUES (
'ACT_HYPERTENSIVE_CRISIS_01', 'I16.0',
'Amlodipine', 'Norvasc',
'10 mg', 'Once daily', '30 days', 'Oral',
10,
'{
"ageRestrictions": { "minAge": 18 },
"categoryRestrictions": [],
"warnings": [
"Monitor pressure daily",
"Report any swelling in feet or ankles"
]
}'::jsonb,
'[
{ "genericName": "Losartan", "displayName": "Cozaar",
"reason": "Alternative ARB if calcium channel blocker not tolerated" },
{ "genericName": "Enalapril", "displayName": "Vasotec",
"reason": "Alternative ACE inhibitor for pressure control" }
]'::jsonb
);
INSERT INTO resource_inventory (
generic_name, display_name, item_code,
quantity_available, unit, expiry_date, reorder_level, is_available
) VALUES
('Amlodipine', 'Norvasc', 'DRG-AMLOD-10MG', 150, 'tablets', '2026-12-31', 50, true),
('Losartan', 'Cozaar', 'DRG-LOSAR-50MG', 200, 'tablets', '2026-06-30', 50, true),
('Enalapril', 'Vasotec', 'DRG-ENALA-5MG', 5, 'tablets', '2026-03-31', 50, false); -- Low stock!
POST /data-owner-bc/v1/assessment/suggest
Content-Type: application/json
Authorization: Bearer {JWT_TOKEN}
{
"resourceId": "uuid-resource-123",
"age": 55,
"category": "M",
"conditions": ["severe headache", "blurred vision", "chest discomfort"],
"parameters": {
"temperature": 37.2,
"pressureSystolic": 195,
"pressureDiastolic": 115,
"heartRate": 92,
"respiratoryRate": 18,
"oxygenSaturation": 97
},
"contraindications": []
}
{
"status": { "code": 200000, "message": "Request Succeeded" },
"data": {
"type": "assessment-suggestions",
"attributes": {
"assessments": [
{
"assessment_code": "I16.0",
"assessment_name_en": "Hypertensive Crisis",
"confidence_level": 0.95,
"matched_rules": ["HYPERTENSIVE_CRISIS_01"],
"suggested_actions": [
{
"generic_name": "Amlodipine",
"display_name": "Norvasc",
"parameters": "10 mg",
"frequency": "Once daily",
"duration": "30 days",
"route": "Oral",
"is_available_in_inventory": true,
"quantity_available": 150,
"safety_checks": {
"is_safe": true,
"warnings": ["Monitor pressure daily"],
"contraindications": []
}
},
{
"generic_name": "Losartan",
"display_name": "Cozaar",
"parameters": "50 mg",
"frequency": "Once daily",
"duration": "30 days",
"route": "Oral",
"is_available_in_inventory": true,
"quantity_available": 200,
"safety_checks": {
"is_safe": true,
"warnings": ["Check potassium levels regularly"],
"contraindications": []
}
}
]
}
]
}
}
}

Key Points:

  • ✅ Primary assessment matched with 95% confidence
  • ✅ Amlodipine available in inventory (150 units)
  • ✅ Losartan available as alternative (200 units)
  • ✅ No contraindications for this profile
  • ⚠️ Enalapril not suggested (low stock, below reorder level)

Scenario: 8-year-old in category F presenting with high fever. Aspirin is contraindicated due to Reye’s syndrome risk in children.

{
"age": 8,
"category": "F",
"conditions": ["high fever", "body aches", "headache"],
"parameters": {
"temperature": 39.5,
"pressureSystolic": 95,
"pressureDiastolic": 60,
"heartRate": 110,
"respiratoryRate": 22,
"oxygenSaturation": 98
},
"contraindications": []
}
INSERT INTO assessment_rules (
rule_code, assessment_code, assessment_name_en,
priority, conditions, confidence_level
) VALUES (
'FEVER_PEDIATRIC_01', 'R50.9', 'Fever of Unknown Origin',
8,
'{
"conditions": ["fever", "body aches"],
"parameters": { "temperature": { "min": 38.5 } },
"ageRange": { "min": 0, "max": 18 }
}'::jsonb,
0.85
);

Contraindicated action (Aspirin — for illustration):

INSERT INTO action_rules (
rule_code, assessment_code,
action_generic_name, action_display_name,
parameters, frequency, duration, route, priority,
contraindications, alternatives
) VALUES (
'ACT_FEVER_ASPIRIN', 'R50.9',
'Aspirin', 'Bayer',
'500 mg', 'Every 4-6 hours', '3 days', 'Oral', 5,
'{
"ageRestrictions": { "minAge": 18, "reason": "Risk of Reye syndrome in children" },
"categoryRestrictions": [],
"warnings": ["Do NOT use in resources under 18 years"]
}'::jsonb,
'[
{ "genericName": "Paracetamol", "displayName": "Tylenol", "reason": "Safe antipyretic for all ages" },
{ "genericName": "Ibuprofen", "displayName": "Advil", "reason": "Alternative safe for ages over 6 months" }
]'::jsonb
);

Safe action (Paracetamol):

INSERT INTO action_rules (
rule_code, assessment_code,
action_generic_name, action_display_name,
parameters, frequency, duration, route, priority,
contraindications, alternatives
) VALUES (
'ACT_FEVER_PARACETAMOL', 'R50.9',
'Paracetamol', 'Tylenol',
'250 mg', 'Every 4-6 hours', '3 days', 'Oral', 10,
'{
"ageRestrictions": { "minAge": 0 },
"categoryRestrictions": [],
"warnings": ["Do not exceed recommended daily dose", "Monitor if prolonged use"]
}'::jsonb,
'[
{ "genericName": "Ibuprofen", "displayName": "Advil", "reason": "Alternative for fever reduction" }
]'::jsonb
);

The rule engine detects the age contraindication for Aspirin and surfaces it, while recommending Paracetamol as the safe primary action:

{
"data": {
"type": "assessment-suggestions",
"attributes": {
"assessments": [{
"assessment_code": "R50.9",
"assessment_name_en": "Fever of Unknown Origin",
"confidence_level": 0.85,
"suggested_actions": [
{
"generic_name": "Paracetamol",
"is_available_in_inventory": true,
"quantity_available": 500,
"safety_checks": {
"is_safe": true,
"warnings": ["Do not exceed recommended daily dose"],
"contraindications": []
}
},
{
"generic_name": "Aspirin",
"is_available_in_inventory": true,
"quantity_available": 300,
"safety_checks": {
"is_safe": false,
"warnings": ["Do NOT use in resources under 18 years"],
"contraindications": [
"Age (8) is below minimum (18)",
"Risk of Reye syndrome in children"
]
},
"alternatives": [
{ "generic_name": "Paracetamol", "reason": "Safe antipyretic for all ages", "is_available_in_inventory": true },
{ "generic_name": "Ibuprofen", "reason": "Alternative safe for ages over 6 months", "is_available_in_inventory": true }
]
}
]
}]
}
}
}

Key Points:

  • ✅ Paracetamol recommended as safe primary action
  • ❌ Aspirin flagged with is_safe: false and explicit contraindication reason
  • ✅ Alternatives surfaced automatically (Paracetamol, Ibuprofen)
  • ✅ Human operator sees full transparency — no black-box AI decision

PrincipleImplementation
TransparencyEvery recommendation traces back to a named rule_code
Safety FirstContraindications block recommendations before inventory is checked
Graceful AlternativesWhen primary fails safety or stock check, alternatives are surfaced automatically
Human-in-the-LoopThe system suggests; humans approve before execution
Auditabilityrule_code, confidence_level, matched_rules all stored on every result