Skip to content

Commit c4cd3d1

Browse files
perf: Add static Engine.evaluate() and shared operator registry for bulk evaluation
1 parent e1f1fda commit c4cd3d1

4 files changed

Lines changed: 183 additions & 28 deletions

File tree

src/engine-default-operator-decorators.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
import OperatorDecorator from './operator-decorator'
44

5-
const OperatorDecorators = []
6-
7-
OperatorDecorators.push(new OperatorDecorator('someFact', (factValue, jsonValue, next) => factValue.some(fv => next(fv, jsonValue)), Array.isArray))
8-
OperatorDecorators.push(new OperatorDecorator('someValue', (factValue, jsonValue, next) => jsonValue.some(jv => next(factValue, jv))))
9-
OperatorDecorators.push(new OperatorDecorator('everyFact', (factValue, jsonValue, next) => factValue.every(fv => next(fv, jsonValue)), Array.isArray))
10-
OperatorDecorators.push(new OperatorDecorator('everyValue', (factValue, jsonValue, next) => jsonValue.every(jv => next(factValue, jv))))
11-
OperatorDecorators.push(new OperatorDecorator('swap', (factValue, jsonValue, next) => next(jsonValue, factValue)))
12-
OperatorDecorators.push(new OperatorDecorator('not', (factValue, jsonValue, next) => !next(factValue, jsonValue)))
5+
const OperatorDecorators = Object.freeze([
6+
new OperatorDecorator('someFact', (factValue, jsonValue, next) => factValue.some(fv => next(fv, jsonValue)), Array.isArray),
7+
new OperatorDecorator('someValue', (factValue, jsonValue, next) => jsonValue.some(jv => next(factValue, jv))),
8+
new OperatorDecorator('everyFact', (factValue, jsonValue, next) => factValue.every(fv => next(fv, jsonValue)), Array.isArray),
9+
new OperatorDecorator('everyValue', (factValue, jsonValue, next) => jsonValue.every(jv => next(factValue, jv))),
10+
new OperatorDecorator('swap', (factValue, jsonValue, next) => next(jsonValue, factValue)),
11+
new OperatorDecorator('not', (factValue, jsonValue, next) => !next(factValue, jsonValue))
12+
])
1313

1414
export default OperatorDecorators

src/engine-default-operators.js

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,21 @@
22

33
import Operator from './operator'
44

5-
const Operators = []
6-
Operators.push(new Operator('equal', (a, b) => a === b))
7-
Operators.push(new Operator('notEqual', (a, b) => a !== b))
8-
Operators.push(new Operator('in', (a, b) => b.indexOf(a) > -1))
9-
Operators.push(new Operator('notIn', (a, b) => b.indexOf(a) === -1))
10-
11-
Operators.push(new Operator('contains', (a, b) => a.indexOf(b) > -1, Array.isArray))
12-
Operators.push(new Operator('doesNotContain', (a, b) => a.indexOf(b) === -1, Array.isArray))
13-
145
function numberValidator (factValue) {
156
return Number.parseFloat(factValue).toString() !== 'NaN'
167
}
17-
Operators.push(new Operator('lessThan', (a, b) => a < b, numberValidator))
18-
Operators.push(new Operator('lessThanInclusive', (a, b) => a <= b, numberValidator))
19-
Operators.push(new Operator('greaterThan', (a, b) => a > b, numberValidator))
20-
Operators.push(new Operator('greaterThanInclusive', (a, b) => a >= b, numberValidator))
8+
9+
const Operators = Object.freeze([
10+
new Operator('equal', (a, b) => a === b),
11+
new Operator('notEqual', (a, b) => a !== b),
12+
new Operator('in', (a, b) => b.indexOf(a) > -1),
13+
new Operator('notIn', (a, b) => b.indexOf(a) === -1),
14+
new Operator('contains', (a, b) => a.indexOf(b) > -1, Array.isArray),
15+
new Operator('doesNotContain', (a, b) => a.indexOf(b) === -1, Array.isArray),
16+
new Operator('lessThan', (a, b) => a < b, numberValidator),
17+
new Operator('lessThanInclusive', (a, b) => a <= b, numberValidator),
18+
new Operator('greaterThan', (a, b) => a > b, numberValidator),
19+
new Operator('greaterThanInclusive', (a, b) => a >= b, numberValidator)
20+
])
2121

2222
export default Operators

src/engine.js

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,11 @@ class Engine extends EventEmitter {
2626
this.allowUndefinedConditions = options.allowUndefinedConditions || false
2727
this.replaceFactsInEventParams = options.replaceFactsInEventParams || false
2828
this.pathResolver = options.pathResolver
29-
this.operators = new OperatorMap()
29+
this.operators = new OperatorMap({ parent: OperatorMap.shared() })
3030
this.facts = new Map()
3131
this.conditions = new Map()
3232
this.status = READY
3333
rules.map(r => this.addRule(r))
34-
defaultOperators.map(o => this.addOperator(o))
35-
defaultDecorators.map(d => this.addOperatorDecorator(d))
3634
}
3735

3836
/**
@@ -319,6 +317,112 @@ class Engine extends EventEmitter {
319317
}).catch(reject)
320318
})
321319
}
320+
321+
/**
322+
* Lightweight static evaluation of conditions against facts using the shared operator registry.
323+
* Avoids EventEmitter, Rule, and full Almanac overhead for high-throughput bulk evaluation.
324+
*
325+
* @param {Object} conditions - condition tree with all/any/not structure
326+
* @param {Object} facts - plain object of fact key-value pairs
327+
* @param {Object} [options] - evaluation options
328+
* @param {boolean} [options.allowUndefinedFacts=false] - allow undefined fact references
329+
* @param {Function} [options.pathResolver] - custom path resolver for nested properties
330+
* @param {OperatorMap} [options.operatorMap] - custom operator map (defaults to shared)
331+
* @returns {Promise<{results: Array, failureResults: Array, events: Array, failureEvents: Array}>}
332+
*/
333+
static evaluate (conditions, facts, options = {}) {
334+
const operatorMap = options.operatorMap || OperatorMap.shared()
335+
336+
const almanac = new Almanac({
337+
allowUndefinedFacts: options.allowUndefinedFacts || false,
338+
pathResolver: options.pathResolver
339+
})
340+
341+
for (const factId in facts) {
342+
almanac.addFact(new Fact(factId, facts[factId]))
343+
}
344+
345+
const rootCondition = new Condition(conditions)
346+
347+
const evaluateCondition = (condition) => {
348+
if (condition.isBooleanOperator()) {
349+
const subConditions = condition[condition.operator]
350+
let comparisonPromise
351+
if (condition.operator === 'all') {
352+
comparisonPromise = all(subConditions)
353+
} else if (condition.operator === 'any') {
354+
comparisonPromise = any(subConditions)
355+
} else {
356+
comparisonPromise = notOp(subConditions)
357+
}
358+
return comparisonPromise.then((comparisonValue) => {
359+
condition.result = comparisonValue === true
360+
return condition.result
361+
})
362+
} else {
363+
return condition
364+
.evaluate(almanac, operatorMap)
365+
.then((evaluationResult) => {
366+
condition.factResult = evaluationResult.leftHandSideValue
367+
condition.valueResult = evaluationResult.rightHandSideValue
368+
condition.result = evaluationResult.result
369+
return evaluationResult.result
370+
})
371+
}
372+
}
373+
374+
const evaluateConditions = (conds, method) => {
375+
if (!Array.isArray(conds)) conds = [conds]
376+
return Promise.all(
377+
conds.map((c) => evaluateCondition(c))
378+
).then((results) => {
379+
return method.call(results, (r) => r === true)
380+
})
381+
}
382+
383+
const prioritizeAndRun = (conds, operator) => {
384+
if (conds.length === 0) return Promise.resolve(operator === 'all')
385+
if (conds.length === 1) return evaluateCondition(conds[0])
386+
// No priority sorting in static evaluate — evaluate all at once
387+
return operator === 'any'
388+
? evaluateConditions(conds, Array.prototype.some)
389+
: evaluateConditions(conds, Array.prototype.every)
390+
}
391+
392+
const any = (conds) => prioritizeAndRun(conds, 'any')
393+
const all = (conds) => prioritizeAndRun(conds, 'all')
394+
const notOp = (cond) => prioritizeAndRun([cond], 'not').then((r) => !r)
395+
396+
let rootPromise
397+
if (rootCondition.any) {
398+
rootPromise = any(rootCondition.any)
399+
} else if (rootCondition.all) {
400+
rootPromise = all(rootCondition.all)
401+
} else if (rootCondition.not) {
402+
rootPromise = notOp(rootCondition.not)
403+
} else {
404+
rootPromise = evaluateCondition(rootCondition)
405+
}
406+
407+
return rootPromise.then((result) => {
408+
const event = { type: 'evaluate' }
409+
if (result) {
410+
return {
411+
results: [{ conditions: rootCondition, event, result: true }],
412+
failureResults: [],
413+
events: [event],
414+
failureEvents: []
415+
}
416+
} else {
417+
return {
418+
results: [],
419+
failureResults: [{ conditions: rootCondition, event, result: false }],
420+
events: [],
421+
failureEvents: [event]
422+
}
423+
}
424+
})
425+
}
322426
}
323427

324428
export default Engine

src/operator-map.js

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,31 @@
22

33
import Operator from './operator'
44
import OperatorDecorator from './operator-decorator'
5+
import defaultOperators from './engine-default-operators'
6+
import defaultDecorators from './engine-default-operator-decorators'
57
import debug from './debug'
68

9+
let sharedInstance = null
10+
711
export default class OperatorMap {
8-
constructor () {
12+
constructor (options = {}) {
913
this.operators = new Map()
1014
this.decorators = new Map()
15+
this.parent = options.parent || null
16+
}
17+
18+
/**
19+
* Returns a shared OperatorMap pre-populated with default operators and decorators.
20+
* This singleton is reused across Engine instances to avoid redundant allocations.
21+
* @returns {OperatorMap}
22+
*/
23+
static shared () {
24+
if (!sharedInstance) {
25+
sharedInstance = new OperatorMap()
26+
defaultOperators.forEach(o => sharedInstance.addOperator(o))
27+
defaultDecorators.forEach(d => sharedInstance.addOperatorDecorator(d))
28+
}
29+
return sharedInstance
1130
}
1231

1332
/**
@@ -95,20 +114,31 @@ export default class OperatorMap {
95114

96115
/**
97116
* Get the Operator, or null applies decorators as needed
117+
* Checks local operators first, then falls back to parent if set.
98118
* @param {string} name - the name of the operator including any decorators
99119
* @returns an operator or null
100120
*/
101121
get (name) {
122+
// Fast path: check local cache first
123+
if (this.operators.has(name)) {
124+
return this.operators.get(name)
125+
}
126+
127+
// Check parent for cached decorated operators
128+
if (this.parent && this.parent.operators.has(name)) {
129+
return this.parent.operators.get(name)
130+
}
131+
102132
const decorators = []
103133
let opName = name
104134
// while we don't already have this operator
105-
while (!this.operators.has(opName)) {
135+
while (!this._hasOperator(opName)) {
106136
// try splitting on the decorator symbol (:)
107137
const firstDecoratorIndex = opName.indexOf(':')
108138
if (firstDecoratorIndex > 0) {
109139
// if there is a decorator, and it's a valid decorator
110140
const decoratorName = opName.slice(0, firstDecoratorIndex)
111-
const decorator = this.decorators.get(decoratorName)
141+
const decorator = this._getDecorator(decoratorName)
112142
if (!decorator) {
113143
debug('operatorMap::get invalid decorator', { name: decoratorName })
114144
return null
@@ -123,7 +153,7 @@ export default class OperatorMap {
123153
}
124154
}
125155

126-
let op = this.operators.get(opName)
156+
let op = this._getOperator(opName)
127157
// apply all the decorators
128158
for (let i = 0; i < decorators.length; i++) {
129159
op = decorators[i].decorate(op)
@@ -134,4 +164,25 @@ export default class OperatorMap {
134164
// return the operation
135165
return op
136166
}
167+
168+
/**
169+
* Check if operator exists locally or in parent
170+
*/
171+
_hasOperator (name) {
172+
return this.operators.has(name) || (this.parent && this.parent.operators.has(name))
173+
}
174+
175+
/**
176+
* Get operator from local map or parent
177+
*/
178+
_getOperator (name) {
179+
return this.operators.get(name) || (this.parent && this.parent.operators.get(name))
180+
}
181+
182+
/**
183+
* Get decorator from local map or parent
184+
*/
185+
_getDecorator (name) {
186+
return this.decorators.get(name) || (this.parent && this.parent.decorators.get(name))
187+
}
137188
}

0 commit comments

Comments
 (0)