Rules Engine
The rules engine is the heart of Presage. It evaluates declarative conditions against the current UserContext and returns the appropriate action.
How Rules Work
Section titled “How Rules Work”Each rule targets a specific adaptation point (identified by adaptationId). When a component resolves that adaptation point, the engine:
- Filters rules by
adaptationId - Sorts by
priority(highest first) - Evaluates each rule’s conditions against the
UserContext - Returns the action of the first matching rule
- Returns
nullif no rule matches (the component falls back to its default variant)
const rule: AdaptationRule = { id: 'enterprise-advanced-dashboard', adaptationId: 'dashboard', priority: 10, conditions: { all: [ { field: 'traits.plan', operator: 'eq', value: 'enterprise' }, { field: 'signals.sessionCount', operator: 'gte', value: 10 }, ], }, action: { type: 'show', variantId: 'advanced' },}All 14 Operators
Section titled “All 14 Operators”Equality
Section titled “Equality”| Operator | Description | Example |
|---|---|---|
eq | Strict equality (===) | { field: 'traits.role', operator: 'eq', value: 'admin' } |
neq | Strict inequality (!==) | { field: 'traits.plan', operator: 'neq', value: 'free' } |
Numeric Comparison
Section titled “Numeric Comparison”All numeric operators require both the field value and the comparison value to be numbers.
| Operator | Description | Example |
|---|---|---|
gt | Greater than | { field: 'signals.sessionCount', operator: 'gt', value: 5 } |
gte | Greater than or equal | { field: 'signals.totalEvents', operator: 'gte', value: 100 } |
lt | Less than | { field: 'signals.daysSinceSignup', operator: 'lt', value: 7 } |
lte | Less than or equal | { field: 'traits.companySize', operator: 'lte', value: 50 } |
Set Membership
Section titled “Set Membership”| Operator | Description | Example |
|---|---|---|
in | Value is in the provided array | { field: 'traits.plan', operator: 'in', value: ['pro', 'enterprise'] } |
notIn | Value is not in the provided array | { field: 'traits.role', operator: 'notIn', value: ['viewer', 'guest'] } |
String & Array
Section titled “String & Array”| Operator | Description | Example |
|---|---|---|
contains | String includes substring, or array includes element | { field: 'traits.company', operator: 'contains', value: 'Corp' } |
notContains | Inverse of contains | { field: 'traits.locale', operator: 'notContains', value: 'zh' } |
Existence
Section titled “Existence”| Operator | Description | Example |
|---|---|---|
exists | Value is not undefined or null | { field: 'traits.company', operator: 'exists', value: true } |
notExists | Value is undefined or null | { field: 'traits.signupDate', operator: 'notExists', value: true } |
| Operator | Description | Example |
|---|---|---|
between | Number is within [min, max] (inclusive) | { field: 'signals.sessionCount', operator: 'between', value: [5, 20] } |
Pattern Matching
Section titled “Pattern Matching”| Operator | Description | Example |
|---|---|---|
matches | String matches a regular expression | { field: 'traits.company', operator: 'matches', value: '^Acme.*' } |
Boolean Logic
Section titled “Boolean Logic”all — AND
Section titled “all — AND”Every condition in the array must match:
conditions: { all: [ { field: 'traits.role', operator: 'eq', value: 'admin' }, { field: 'traits.plan', operator: 'eq', value: 'enterprise' }, { field: 'signals.sessionCount', operator: 'gte', value: 5 }, ],}any — OR
Section titled “any — OR”At least one condition must match:
conditions: { any: [ { field: 'maturity', operator: 'eq', value: 'new' }, { field: 'maturity', operator: 'eq', value: 'onboarding' }, ],}not — Negation
Section titled “not — Negation”Inverts the result of a condition or group:
conditions: { not: { field: 'traits.plan', operator: 'eq', value: 'free' },}Nested Conditions
Section titled “Nested Conditions”Groups can be nested to express complex logic:
// (admin AND enterprise) OR (power user with 50+ sessions)conditions: { any: [ { all: [ { field: 'traits.role', operator: 'eq', value: 'admin' }, { field: 'traits.plan', operator: 'eq', value: 'enterprise' }, ], }, { all: [ { field: 'maturity', operator: 'eq', value: 'power' }, { field: 'signals.sessionCount', operator: 'gte', value: 50 }, ], }, ],}Priority Ordering
Section titled “Priority Ordering”Rules are evaluated from highest priority to lowest. The first match wins.
const rules = [ { id: 'vip-override', adaptationId: 'dashboard', priority: 100, // Evaluated first conditions: { all: [{ field: 'traits.role', operator: 'eq', value: 'vip' }] }, action: { type: 'show', variantId: 'vip-dashboard' }, }, { id: 'enterprise-dashboard', adaptationId: 'dashboard', priority: 50, // Evaluated second conditions: { all: [{ field: 'traits.plan', operator: 'eq', value: 'enterprise' }] }, action: { type: 'show', variantId: 'advanced' }, }, { id: 'default-dashboard', adaptationId: 'dashboard', priority: 1, // Catch-all conditions: {}, // Empty group matches everything action: { type: 'show', variantId: 'standard' }, },]Field Paths
Section titled “Field Paths”Fields use dot-notation to access nested values in the UserContext:
| Path | Resolves to |
|---|---|
maturity | context.maturity (a string) |
traits.role | context.traits.role |
traits.companySize | context.traits.companySize |
signals.sessionCount | context.signals.sessionCount |
signals.featureUsage.export | context.signals.featureUsage.export |
signals.clickMap.nav-settings | context.signals.clickMap['nav-settings'] |
Action Types
Section titled “Action Types”Selects a specific variant to render:
action: { type: 'show', variantId: 'guided-tour' }Hides the adaptation point entirely:
action: { type: 'hide' }reorder
Section titled “reorder”Reorders items (used with AdaptiveNav):
action: { type: 'reorder', order: ['analytics', 'settings', 'home'] }modify
Section titled “modify”Passes arbitrary props to the matched component:
action: { type: 'modify', props: { showBetaBadge: true, maxItems: 10 } }Best Practices
Section titled “Best Practices”- Name rules descriptively —
new-user-guided-touris clearer thanrule-1 - Use priority gaps — Leave room between priorities (10, 20, 30) so you can insert rules later without renumbering
- Keep conditions shallow — Deeply nested boolean trees are hard to debug; prefer flatter structures
- Test rules in isolation — The
RulesEngineclass can be instantiated directly for unit testing - Prefer
inover multipleeq—{ operator: 'in', value: ['pro', 'enterprise'] }is cleaner than twoanyconditions