Skip to content

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

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 entity
const clinicalEntity = await client.sorts.createSort(
SortBuilder.create('clinical_entity')
.description('Base sort for all clinical domain concepts')
.build()
);
// Person hierarchy
const 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 sort
const 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 axis
const 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 axis
const 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 axes
const 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 check
const isSub = await client.sorts.isSubtype(phaseIIIOncology.id, trial.id);
console.log('phase_iii_oncology <: trial?', isSub);
// → true
// Traverse the hierarchy
const 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 Trial
const 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 patient
const 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 engine
await 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 patients
await 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 fact
await 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/mL
await 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 measurement
await 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 report
const 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 old
await 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 trial
const 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 events
const 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 monitoring
await 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 facts
const 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 facts
for (const fact of fcResult.derivedFacts) {
console.log(` ${fact.sortName}: ${fact.display}`);
}
// Provenance: which rule produced each derived fact
if (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 information
await 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 parameters
const 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 sessions
const cleared = await client.inference.clearFacts();
console.log(`Cleared ${cleared.factsCleared} facts`);

What You Learned

ConceptWhat It ReplacedWhy It Matters
Sort lattice with multiple inheritanceRelational tables, class hierarchiesA Phase III Oncology Trial inherits features from both classification axes — no duplication
Feature appropriatenessNullable columnsrandomization_method only exists on interventional_trial and below — querying it elsewhere is a sort error
GLB/LUBCustom recursive queries”What is the most specific common type?” is a first-class operation
Psi-term unificationSQL JOINs + UNIONsQuerying a parent sort automatically finds subsort instances
ResiduationNULL semanticsThe engine suspends and tells you what data it needs, rather than returning false or NULL
Fuzzy valuesPoint estimatesBiomarker uncertainty is preserved and participates in inference
Guard constraintsWHERE clausesArithmetic constraints on feature values within unification, not as a separate filter step
Homoiconic rulesSeparate rule enginesRules are psi-terms — queryable, modifiable, and composable with data

Next Steps