Clinical Trial Knowledge Graph
Build a clinical trial knowledge graph that models multi-classified, partially-known, and imprecise biomedical data using sort hierarchies, psi-terms, fuzzy values, and inference.
What you will learn:
- Modeling domain ontologies with sorts (multiple inheritance, feature appropriateness)
- Creating and querying psi-terms with typed features
- Unification-based queries that traverse the sort hierarchy automatically
- Residuation: reasoning with incomplete data without null semantics
- Fuzzy values for imprecise biomarker measurements
- GLB/LUB lattice operations for sort analysis
Prerequisites:
- A running Reasoning Layer backend at
https://platform.ovh.reasoninglayer.ai - Node.js 18+ with
@kortexya/reasoninglayerinstalled
1. Initialize the Client
Every interaction with the Reasoning Layer starts by creating a client with your backend URL and tenant ID.
import { ReasoningLayerClient, SortBuilder, Value, FuzzyShape, psi, constrained, guard,} from '@kortexya/reasoninglayer';
import type { SortDto, TermResponse, BackwardChainResponse, AddRuleResponse } from '@kortexya/reasoninglayer';import type { Inference } from '@kortexya/reasoninglayer';type SolutionDto = Inference.SolutionDto;type BindingDto = Inference.BindingDto;
const client = new ReasoningLayerClient({ baseUrl: 'https://platform.ovh.reasoninglayer.ai', tenantId: '550e8400-e29b-41d4-a716-446655440000', auth: { mode: 'cookie' },});2. Model the Sort Hierarchy
In the Reasoning Layer, sorts are types arranged in a lattice with multiple inheritance. Unlike relational schemas or class hierarchies, sorts form a mathematical lattice where every pair of sorts has a Greatest Lower Bound (GLB) and Least Upper Bound (LUB).
Features are declared on sorts with feature appropriateness: a feature declared on a sort is only valid for that sort and its descendants. This eliminates the “NULL column” problem of relational databases — you cannot accidentally query randomization_method on an observational study.
2.1 Base Sorts
Start with the top-level domain concepts:
// Base clinical entityconst clinicalEntity = await client.sorts.createSort( SortBuilder.create('clinical_entity') .description('Base sort for all clinical domain concepts') .build());
// Person hierarchyconst person = await client.sorts.createSort( SortBuilder.create('person') .parent(clinicalEntity.id) .feature({ name: 'name', required: true, constraint: { type: 'String' } }) .feature({ name: 'age', required: false, constraint: { type: 'IntegerRange', value: { min: 0, max: 150 } } }) .build());
const patient = await client.sorts.createSort( SortBuilder.create('patient') .parent(person.id) .feature({ name: 'medical_history', required: false }) .build());
const investigator = await client.sorts.createSort( SortBuilder.create('investigator') .parent(person.id) .feature({ name: 'institution', required: true }) .feature({ name: 'specialization', required: true }) .build());2.2 Trial Hierarchy with Multiple Inheritance
Here is where the sort lattice shines. A clinical trial can be classified along two independent axes: phase and therapeutic area. In a relational database, you would need join tables or duplicated columns. With the Reasoning Layer, you use multiple inheritance.
// Base trial sortconst trial = await client.sorts.createSort( SortBuilder.create('trial') .parent(clinicalEntity.id) .feature({ name: 'trial_id', required: true, constraint: { type: 'String' } }) .feature({ name: 'title', required: true }) .feature({ name: 'sponsor', required: true }) .feature({ name: 'status', required: false }) // May be uninstantiated (residuated) .feature({ name: 'start_date', required: false }) .build());
// Phase axisconst interventional = await client.sorts.createSort( SortBuilder.create('interventional_trial') .parent(trial.id) .feature({ name: 'randomization_method', required: true }) .feature({ name: 'blinding', required: true }) .feature({ name: 'control_type', required: false }) .description('Feature appropriateness: only interventional trials have blinding/randomization') .build());
const phaseIII = await client.sorts.createSort( SortBuilder.create('phase_iii_trial') .parent(interventional.id) .feature({ name: 'required_enrollment', required: true, constraint: { type: 'IntegerRange', value: { min: 1, max: 100000 } } }) .feature({ name: 'primary_endpoint', required: true }) .build());
// Therapeutic area axisconst oncologyTrial = await client.sorts.createSort( SortBuilder.create('oncology_trial') .parent(trial.id) .feature({ name: 'cancer_type', required: true }) .feature({ name: 'biomarker_panel', required: false }) .build());
// Multiple inheritance: a Phase III Oncology Trial inherits from BOTH axesconst phaseIIIOncology = await client.sorts.createSort( SortBuilder.create('phase_iii_oncology_trial') .parents([phaseIII.id, oncologyTrial.id]) .description('Inherits features from both phase_iii_trial and oncology_trial') .build());2.3 Supporting Sorts
const enrolledPatient = await client.sorts.createSort( SortBuilder.create('enrolled_patient') .parent(patient.id) .feature({ name: 'enrollment_date', required: true }) .feature({ name: 'treatment_arm', required: true }) .feature({ name: 'consent_status', required: true }) .build());
const adverseEvent = await client.sorts.createSort( SortBuilder.create('adverse_event') .parent(clinicalEntity.id) .feature({ name: 'patient_name', required: true }) .feature({ name: 'severity', required: true, constraint: { type: 'IntegerRange', value: { min: 1, max: 5 } } }) .feature({ name: 'event_type', required: true }) .feature({ name: 'onset_date', required: true }) .feature({ name: 'resolved', required: false }) // Uninstantiated until resolution is known .build());
const biomarkerReading = await client.sorts.createSort( SortBuilder.create('biomarker_reading') .parent(clinicalEntity.id) .feature({ name: 'patient_name', required: true }) .feature({ name: 'biomarker_name', required: true }) .feature({ name: 'value', required: true }) // Will hold fuzzy numbers .feature({ name: 'collection_date', required: true }) .build());2.4 Verify the Lattice
Use GLB and LUB to verify the lattice structure:
// GLB: What is the most specific sort covering both phase_iii_trial and oncology_trial?const glb = await client.sorts.computeGlb({ sort1Id: phaseIII.id, sort2Id: oncologyTrial.id,});console.log('GLB of phase_iii and oncology:', glb.glb);// → phase_iii_oncology_trial (or its ID)
// LUB: What is the most general sort that covers both?const lub = await client.sorts.computeLub({ sort1Id: phaseIII.id, sort2Id: oncologyTrial.id,});console.log('LUB of phase_iii and oncology:', lub.lub);// → trial (the common ancestor)
// Subtype checkconst isSub = await client.sorts.isSubtype(phaseIIIOncology.id, trial.id);console.log('phase_iii_oncology <: trial?', isSub);// → true
// Traverse the hierarchyconst descendants = await client.sorts.getDescendants(trial.id);console.log('All trial subsorts:', descendants.map(s => s.name));// → ['interventional_trial', 'phase_iii_trial', 'oncology_trial', 'phase_iii_oncology_trial']3. Populate with Psi-Terms
Psi-terms are typed feature structures — instances of sorts with named features. Unlike rows in a table, psi-terms carry their sort membership and participate in unification.
3.1 Persistent Terms (Tagged Format)
When creating persistent terms stored in the knowledge base, use the tagged Value.* format:
// Create a Phase III Oncology Trialconst breastTrial = await client.terms.createTerm({ sortId: phaseIIIOncology.id, ownerId: '550e8400-e29b-41d4-a716-446655440001', features: { trial_id: 'NCT-2024-BREAST-001', title: 'Adaptive Phase III Study of Novel CDK4/6 Inhibitor', sponsor: 'Kortexya Pharma', status: 'enrolling', randomization_method: 'stratified_block', blinding: 'double_blind', required_enrollment: 450, primary_endpoint: 'progression_free_survival', cancer_type: 'breast', },});console.log('Created trial term:', breastTrial.term.id);
// Create a patientconst patientAlice = await client.terms.createTerm({ sortId: enrolledPatient.id, ownerId: '550e8400-e29b-41d4-a716-446655440001', features: { name: 'Alice Chen', age: 52, enrollment_date: '2024-03-15', treatment_arm: 'experimental', consent_status: 'signed', },});
// Create an adverse event with UNINSTANTIATED resolution// This is NOT null — it means "resolution is unknown, not inapplicable"const ae = await client.terms.createTerm({ sortId: adverseEvent.id, ownerId: '550e8400-e29b-41d4-a716-446655440001', features: { patient_name: 'Alice Chen', severity: 3, event_type: 'neutropenia', onset_date: '2024-04-20', resolved: null, // Not yet resolved — will residuate },});3.2 Inference Facts (Untagged Format)
When adding facts to the inference engine, use the untagged format. This is a different serialization format used by the homoiconic inference endpoints:
// Same data, different format — for the inference engineawait client.inference.addFact({ term: psi('phase_iii_oncology_trial', { trial_id: 'NCT-2024-BREAST-001', title: 'Adaptive Phase III Study of Novel CDK4/6 Inhibitor', sponsor: 'Kortexya Pharma', status: 'enrolling', cancer_type: 'breast', required_enrollment: 450, primary_endpoint: 'progression_free_survival', }),});
await client.inference.addFact({ term: psi('enrolled_patient', { name: 'Alice Chen', age: 52, treatment_arm: 'experimental', consent_status: 'signed', }),});
// Bulk-add more patientsawait client.inference.bulkAddFacts({ facts: [ psi('enrolled_patient', { name: 'Bob Martinez', age: 67, treatment_arm: 'control', consent_status: 'signed', }), psi('patient', { name: 'Carol Wu', age: 15, // Minor — will be filtered by eligibility rules }), ],});
// Adverse event factawait client.inference.addFact({ term: psi('adverse_event', { patient_name: 'Alice Chen', severity: 3, event_type: 'neutropenia', }),});3.3 Fuzzy Biomarker Readings
Biomarker measurements are inherently imprecise. Rather than storing a single point value and losing the uncertainty, model them as fuzzy numbers:
// A biomarker reading modeled as a Gaussian fuzzy number// Mean: 145 pg/mL, standard deviation: 12 pg/mLawait client.terms.createTerm({ sortId: biomarkerReading.id, ownerId: '550e8400-e29b-41d4-a716-446655440001', features: { patient_name: 'Alice Chen', biomarker_name: 'HER2', value: Value.fuzzyNumber(FuzzyShape.gaussian(145, 12)), collection_date: '2024-04-01', },});
// A triangular fuzzy number for a less precise measurementawait client.terms.createTerm({ sortId: biomarkerReading.id, ownerId: '550e8400-e29b-41d4-a716-446655440001', features: { patient_name: 'Bob Martinez', biomarker_name: 'CA-125', value: Value.fuzzyNumber(FuzzyShape.triangular(30, 35, 42)), collection_date: '2024-04-05', },});4. Unification Queries
Unification is fundamentally different from SQL pattern matching. When you query for a sort, the Reasoning Layer automatically finds all psi-terms whose sort is a subsort of the query sort. You do not need explicit JOINs or UNIONs.
4.1 Find All Trials by Cancer Type
// Query: "Find all trials about breast cancer"// This unifies against the sort hierarchy — finding phase_iii_oncology_trial instances// even though we query the parent sort 'oncology_trial'const result: BackwardChainResponse = await client.inference.backwardChain({ goal: psi('oncology_trial', { cancer_type: 'breast', title: '?Title', }), maxSolutions: 10,});
console.log(`Found ${result.solutions.length} trial(s):`);for (const solution of result.solutions) { for (const binding of solution.substitution.bindings) { if (binding.variableName === '?Title') { console.log(` Trial: ${binding.boundToDisplay}`); } }}4.2 Variable Coreference
Variables that appear in multiple positions in a query mean “the same value”. This is called coreference and is a core feature of unification:
// Find patients enrolled in trials where their name matches an adverse event reportconst corefResult = await client.inference.backwardChain({ goal: psi('adverse_event', { patient_name: '?Patient', severity: '?Sev', event_type: '?Event', }), maxSolutions: 20,});
for (const sol of corefResult.solutions) { const bindings = new Map( sol.substitution.bindings.map(b => [b.variableName, b.boundToDisplay]) ); console.log(`Patient: ${bindings.get('?Patient')}, Event: ${bindings.get('?Event')}, Severity: ${bindings.get('?Sev')}`);}5. Inference Rules with Guard Constraints
Rules in the Reasoning Layer are psi-terms themselves — homoiconic representation means rules are data that can be queried and modified.
5.1 Patient Eligibility Rule
// Rule: eligible_patient(?Name) :- patient(?Name, age >= 18)// A patient is eligible if they are at least 18 years oldawait client.inference.addRule({ term: psi('eligible_patient', { name: '?Name', }), antecedents: [ psi('patient', { name: '?Name', age: constrained('?Age', guard('gte', 18)), }), ], certainty: 1.0,});5.2 Combine Guards with Negation-as-Failure (NAF)
import { toTermInputDto } from '@kortexya/reasoninglayer';
// Find eligible patients who are NOT yet enrolled in any trialconst nafResult = await client.inference.nafProve({ literals: [ { term: toTermInputDto(psi('eligible_patient', { name: '?Name', })), negated: false, }, { term: toTermInputDto(psi('enrolled_patient', { name: '?Name', })), negated: true, // NAF: succeeds if enrollment CANNOT be proved }, ], maxSolutions: 20,});
console.log(`${nafResult.solutions.length} eligible but not-yet-enrolled patient(s)`);5.3 Serious Adverse Event Detection
// Rule: serious_adverse_event(?Patient) :- adverse_event(?Patient, severity >= 3)await client.inference.addRule({ term: psi('serious_adverse_event', { patient_name: '?Patient', event_type: '?Event', }), antecedents: [ psi('adverse_event', { patient_name: '?Patient', severity: constrained('?Sev', guard('gte', 3)), event_type: '?Event', }), ], certainty: 0.95,});
// Query for serious adverse eventsconst saeResult = await client.inference.backwardChain({ goal: psi('serious_adverse_event', { patient_name: '?Who', event_type: '?What', }), maxSolutions: 50,});
console.log(`Serious adverse events: ${saeResult.solutions.length}`);for (const sol of saeResult.solutions) { console.log(` Certainty: ${sol.certainty}`); for (const b of sol.substitution.bindings) { console.log(` ${b.variableName} = ${b.boundToDisplay}`); }}6. Forward Chaining: Deriving Safety Signals
While backward chaining answers “can this goal be proved?”, forward chaining applies all known rules to all known facts and derives everything that can be derived. This is useful for computing aggregate signals like safety holds.
// Add a forward-chaining rule for safety monitoringawait client.inference.addRule({ term: psi('safety_flag', { patient_name: '?Patient', reason: 'serious_adverse_event_detected', }), antecedents: [ psi('serious_adverse_event', { patient_name: '?Patient', }), ], certainty: 0.9,});
// Run forward chaining to materialize all derivable factsconst fcResult = await client.inference.forwardChain({ persistDerived: true, // Store derived facts in the KB enableProvenanceTags: true, // Track which rule produced each fact maxIterations: 100, maxFacts: 1000,});
console.log(`Derived ${fcResult.derivedCount} new facts in ${fcResult.iterations} iterations`);console.log(`Materialization time: ${fcResult.materializationTimeMs}ms`);
// Inspect derived factsfor (const fact of fcResult.derivedFacts) { console.log(` ${fact.sortName}: ${fact.display}`);}
// Provenance: which rule produced each derived factif (fcResult.provenanceTags) { for (const tag of fcResult.provenanceTags) { console.log(` Fact #${tag.factIndex} derived with confidence ${tag.confidence}`); }}7. Open-World Reasoning and Residuation
One of the most distinctive features is residuation: when the inference engine encounters an uninstantiated feature that a rule depends on, it suspends rather than failing. This is fundamentally different from SQL’s three-valued logic (TRUE/FALSE/NULL).
// Add a patient without age informationawait client.inference.addFact({ term: psi('patient', { name: 'Dana Park', // age is NOT provided — it is uninstantiated }),});
// Open-world query: "Is Dana eligible?"const openResult = await client.inference.backwardChain({ goal: psi('eligible_patient', { name: 'Dana Park', }), openWorld: true,});
for (const sol of openResult.solutions) { console.log(`Certainty: ${sol.certainty}`); console.log(`Evidence ratio: ${sol.evidenceRatio}`); console.log(`Evidence matched: ${sol.evidenceMatched}`);
if (sol.residuatedSorts && sol.residuatedSorts.length > 0) { console.log(`Residuated on: ${sol.residuatedSorts.join(', ')}`); // The engine tells you WHAT data it needs to complete the proof }}8. Putting It Together: A Clinical Decision Query
Combine sort hierarchy traversal, guard constraints, NAF, and open-world reasoning in a single multi-step query:
// Multi-level rule: trial_concern(?Patient, ?Reason) :-// enrolled_patient(?Patient, treatment_arm: "experimental"),// serious_adverse_event(?Patient)await client.inference.addRule({ term: psi('trial_concern', { patient_name: '?Patient', reason: 'sae_in_experimental_arm', }), antecedents: [ psi('enrolled_patient', { name: '?Patient', treatment_arm: 'experimental', }), psi('serious_adverse_event', { patient_name: '?Patient', }), ], certainty: 0.85,});
// Run the query with production safety parametersconst concernResult = await client.inference.backwardChain({ goal: psi('trial_concern', { patient_name: '?Who', reason: '?Why', }), maxSolutions: 100, maxDepth: 50, timeoutMs: 5000, openWorld: true,});
console.log(`Trial concerns: ${concernResult.solutions.length}`);console.log(`Query time: ${concernResult.queryTimeMs}ms`);
for (const sol of concernResult.solutions) { const bindings = new Map( sol.substitution.bindings.map(b => [b.variableName, b.boundToDisplay]) ); console.log(` Patient: ${bindings.get('?Who')}`); console.log(` Reason: ${bindings.get('?Why')}`); console.log(` Certainty: ${sol.certainty}`);}9. Cleanup
// Clear inference facts between sessionsconst cleared = await client.inference.clearFacts();console.log(`Cleared ${cleared.factsCleared} facts`);What You Learned
| Concept | What It Replaced | Why It Matters |
|---|---|---|
| Sort lattice with multiple inheritance | Relational tables, class hierarchies | A Phase III Oncology Trial inherits features from both classification axes — no duplication |
| Feature appropriateness | Nullable columns | randomization_method only exists on interventional_trial and below — querying it elsewhere is a sort error |
| GLB/LUB | Custom recursive queries | ”What is the most specific common type?” is a first-class operation |
| Psi-term unification | SQL JOINs + UNIONs | Querying a parent sort automatically finds subsort instances |
| Residuation | NULL semantics | The engine suspends and tells you what data it needs, rather than returning false or NULL |
| Fuzzy values | Point estimates | Biomarker uncertainty is preserved and participates in inference |
| Guard constraints | WHERE clauses | Arithmetic constraints on feature values within unification, not as a separate filter step |
| Homoiconic rules | Separate rule engines | Rules are psi-terms — queryable, modifiable, and composable with data |
Next Steps
- Loan Approval Engine — Multi-level backward chaining, forward chaining, NAF, and homoiconic introspection
- Incident Response Agent — BDI cognitive agents with real-time WebSocket events