From c4cd3d19c1e4f25d6be17c7758d73720d3e4b02f Mon Sep 17 00:00:00 2001 From: Mridul Barman Date: Wed, 15 Apr 2026 17:37:32 +0530 Subject: [PATCH 1/2] perf: Add static Engine.evaluate() and shared operator registry for bulk evaluation --- src/engine-default-operator-decorators.js | 16 ++-- src/engine-default-operators.js | 26 ++--- src/engine.js | 110 +++++++++++++++++++++- src/operator-map.js | 59 +++++++++++- 4 files changed, 183 insertions(+), 28 deletions(-) diff --git a/src/engine-default-operator-decorators.js b/src/engine-default-operator-decorators.js index 4bf83312..9fce7eff 100644 --- a/src/engine-default-operator-decorators.js +++ b/src/engine-default-operator-decorators.js @@ -2,13 +2,13 @@ import OperatorDecorator from './operator-decorator' -const OperatorDecorators = [] - -OperatorDecorators.push(new OperatorDecorator('someFact', (factValue, jsonValue, next) => factValue.some(fv => next(fv, jsonValue)), Array.isArray)) -OperatorDecorators.push(new OperatorDecorator('someValue', (factValue, jsonValue, next) => jsonValue.some(jv => next(factValue, jv)))) -OperatorDecorators.push(new OperatorDecorator('everyFact', (factValue, jsonValue, next) => factValue.every(fv => next(fv, jsonValue)), Array.isArray)) -OperatorDecorators.push(new OperatorDecorator('everyValue', (factValue, jsonValue, next) => jsonValue.every(jv => next(factValue, jv)))) -OperatorDecorators.push(new OperatorDecorator('swap', (factValue, jsonValue, next) => next(jsonValue, factValue))) -OperatorDecorators.push(new OperatorDecorator('not', (factValue, jsonValue, next) => !next(factValue, jsonValue))) +const OperatorDecorators = Object.freeze([ + new OperatorDecorator('someFact', (factValue, jsonValue, next) => factValue.some(fv => next(fv, jsonValue)), Array.isArray), + new OperatorDecorator('someValue', (factValue, jsonValue, next) => jsonValue.some(jv => next(factValue, jv))), + new OperatorDecorator('everyFact', (factValue, jsonValue, next) => factValue.every(fv => next(fv, jsonValue)), Array.isArray), + new OperatorDecorator('everyValue', (factValue, jsonValue, next) => jsonValue.every(jv => next(factValue, jv))), + new OperatorDecorator('swap', (factValue, jsonValue, next) => next(jsonValue, factValue)), + new OperatorDecorator('not', (factValue, jsonValue, next) => !next(factValue, jsonValue)) +]) export default OperatorDecorators diff --git a/src/engine-default-operators.js b/src/engine-default-operators.js index dfe33f29..f30e0aee 100644 --- a/src/engine-default-operators.js +++ b/src/engine-default-operators.js @@ -2,21 +2,21 @@ import Operator from './operator' -const Operators = [] -Operators.push(new Operator('equal', (a, b) => a === b)) -Operators.push(new Operator('notEqual', (a, b) => a !== b)) -Operators.push(new Operator('in', (a, b) => b.indexOf(a) > -1)) -Operators.push(new Operator('notIn', (a, b) => b.indexOf(a) === -1)) - -Operators.push(new Operator('contains', (a, b) => a.indexOf(b) > -1, Array.isArray)) -Operators.push(new Operator('doesNotContain', (a, b) => a.indexOf(b) === -1, Array.isArray)) - function numberValidator (factValue) { return Number.parseFloat(factValue).toString() !== 'NaN' } -Operators.push(new Operator('lessThan', (a, b) => a < b, numberValidator)) -Operators.push(new Operator('lessThanInclusive', (a, b) => a <= b, numberValidator)) -Operators.push(new Operator('greaterThan', (a, b) => a > b, numberValidator)) -Operators.push(new Operator('greaterThanInclusive', (a, b) => a >= b, numberValidator)) + +const Operators = Object.freeze([ + new Operator('equal', (a, b) => a === b), + new Operator('notEqual', (a, b) => a !== b), + new Operator('in', (a, b) => b.indexOf(a) > -1), + new Operator('notIn', (a, b) => b.indexOf(a) === -1), + new Operator('contains', (a, b) => a.indexOf(b) > -1, Array.isArray), + new Operator('doesNotContain', (a, b) => a.indexOf(b) === -1, Array.isArray), + new Operator('lessThan', (a, b) => a < b, numberValidator), + new Operator('lessThanInclusive', (a, b) => a <= b, numberValidator), + new Operator('greaterThan', (a, b) => a > b, numberValidator), + new Operator('greaterThanInclusive', (a, b) => a >= b, numberValidator) +]) export default Operators diff --git a/src/engine.js b/src/engine.js index 4cb8751e..1e79d010 100644 --- a/src/engine.js +++ b/src/engine.js @@ -26,13 +26,11 @@ class Engine extends EventEmitter { this.allowUndefinedConditions = options.allowUndefinedConditions || false this.replaceFactsInEventParams = options.replaceFactsInEventParams || false this.pathResolver = options.pathResolver - this.operators = new OperatorMap() + this.operators = new OperatorMap({ parent: OperatorMap.shared() }) this.facts = new Map() this.conditions = new Map() this.status = READY rules.map(r => this.addRule(r)) - defaultOperators.map(o => this.addOperator(o)) - defaultDecorators.map(d => this.addOperatorDecorator(d)) } /** @@ -319,6 +317,112 @@ class Engine extends EventEmitter { }).catch(reject) }) } + + /** + * Lightweight static evaluation of conditions against facts using the shared operator registry. + * Avoids EventEmitter, Rule, and full Almanac overhead for high-throughput bulk evaluation. + * + * @param {Object} conditions - condition tree with all/any/not structure + * @param {Object} facts - plain object of fact key-value pairs + * @param {Object} [options] - evaluation options + * @param {boolean} [options.allowUndefinedFacts=false] - allow undefined fact references + * @param {Function} [options.pathResolver] - custom path resolver for nested properties + * @param {OperatorMap} [options.operatorMap] - custom operator map (defaults to shared) + * @returns {Promise<{results: Array, failureResults: Array, events: Array, failureEvents: Array}>} + */ + static evaluate (conditions, facts, options = {}) { + const operatorMap = options.operatorMap || OperatorMap.shared() + + const almanac = new Almanac({ + allowUndefinedFacts: options.allowUndefinedFacts || false, + pathResolver: options.pathResolver + }) + + for (const factId in facts) { + almanac.addFact(new Fact(factId, facts[factId])) + } + + const rootCondition = new Condition(conditions) + + const evaluateCondition = (condition) => { + if (condition.isBooleanOperator()) { + const subConditions = condition[condition.operator] + let comparisonPromise + if (condition.operator === 'all') { + comparisonPromise = all(subConditions) + } else if (condition.operator === 'any') { + comparisonPromise = any(subConditions) + } else { + comparisonPromise = notOp(subConditions) + } + return comparisonPromise.then((comparisonValue) => { + condition.result = comparisonValue === true + return condition.result + }) + } else { + return condition + .evaluate(almanac, operatorMap) + .then((evaluationResult) => { + condition.factResult = evaluationResult.leftHandSideValue + condition.valueResult = evaluationResult.rightHandSideValue + condition.result = evaluationResult.result + return evaluationResult.result + }) + } + } + + const evaluateConditions = (conds, method) => { + if (!Array.isArray(conds)) conds = [conds] + return Promise.all( + conds.map((c) => evaluateCondition(c)) + ).then((results) => { + return method.call(results, (r) => r === true) + }) + } + + const prioritizeAndRun = (conds, operator) => { + if (conds.length === 0) return Promise.resolve(operator === 'all') + if (conds.length === 1) return evaluateCondition(conds[0]) + // No priority sorting in static evaluate — evaluate all at once + return operator === 'any' + ? evaluateConditions(conds, Array.prototype.some) + : evaluateConditions(conds, Array.prototype.every) + } + + const any = (conds) => prioritizeAndRun(conds, 'any') + const all = (conds) => prioritizeAndRun(conds, 'all') + const notOp = (cond) => prioritizeAndRun([cond], 'not').then((r) => !r) + + let rootPromise + if (rootCondition.any) { + rootPromise = any(rootCondition.any) + } else if (rootCondition.all) { + rootPromise = all(rootCondition.all) + } else if (rootCondition.not) { + rootPromise = notOp(rootCondition.not) + } else { + rootPromise = evaluateCondition(rootCondition) + } + + return rootPromise.then((result) => { + const event = { type: 'evaluate' } + if (result) { + return { + results: [{ conditions: rootCondition, event, result: true }], + failureResults: [], + events: [event], + failureEvents: [] + } + } else { + return { + results: [], + failureResults: [{ conditions: rootCondition, event, result: false }], + events: [], + failureEvents: [event] + } + } + }) + } } export default Engine diff --git a/src/operator-map.js b/src/operator-map.js index 741e302c..1f3bd621 100644 --- a/src/operator-map.js +++ b/src/operator-map.js @@ -2,12 +2,31 @@ import Operator from './operator' import OperatorDecorator from './operator-decorator' +import defaultOperators from './engine-default-operators' +import defaultDecorators from './engine-default-operator-decorators' import debug from './debug' +let sharedInstance = null + export default class OperatorMap { - constructor () { + constructor (options = {}) { this.operators = new Map() this.decorators = new Map() + this.parent = options.parent || null + } + + /** + * Returns a shared OperatorMap pre-populated with default operators and decorators. + * This singleton is reused across Engine instances to avoid redundant allocations. + * @returns {OperatorMap} + */ + static shared () { + if (!sharedInstance) { + sharedInstance = new OperatorMap() + defaultOperators.forEach(o => sharedInstance.addOperator(o)) + defaultDecorators.forEach(d => sharedInstance.addOperatorDecorator(d)) + } + return sharedInstance } /** @@ -95,20 +114,31 @@ export default class OperatorMap { /** * Get the Operator, or null applies decorators as needed + * Checks local operators first, then falls back to parent if set. * @param {string} name - the name of the operator including any decorators * @returns an operator or null */ get (name) { + // Fast path: check local cache first + if (this.operators.has(name)) { + return this.operators.get(name) + } + + // Check parent for cached decorated operators + if (this.parent && this.parent.operators.has(name)) { + return this.parent.operators.get(name) + } + const decorators = [] let opName = name // while we don't already have this operator - while (!this.operators.has(opName)) { + while (!this._hasOperator(opName)) { // try splitting on the decorator symbol (:) const firstDecoratorIndex = opName.indexOf(':') if (firstDecoratorIndex > 0) { // if there is a decorator, and it's a valid decorator const decoratorName = opName.slice(0, firstDecoratorIndex) - const decorator = this.decorators.get(decoratorName) + const decorator = this._getDecorator(decoratorName) if (!decorator) { debug('operatorMap::get invalid decorator', { name: decoratorName }) return null @@ -123,7 +153,7 @@ export default class OperatorMap { } } - let op = this.operators.get(opName) + let op = this._getOperator(opName) // apply all the decorators for (let i = 0; i < decorators.length; i++) { op = decorators[i].decorate(op) @@ -134,4 +164,25 @@ export default class OperatorMap { // return the operation return op } + + /** + * Check if operator exists locally or in parent + */ + _hasOperator (name) { + return this.operators.has(name) || (this.parent && this.parent.operators.has(name)) + } + + /** + * Get operator from local map or parent + */ + _getOperator (name) { + return this.operators.get(name) || (this.parent && this.parent.operators.get(name)) + } + + /** + * Get decorator from local map or parent + */ + _getDecorator (name) { + return this.decorators.get(name) || (this.parent && this.parent.decorators.get(name)) + } } From 826930f366a88eef48f926e3b4c618c5fa246d80 Mon Sep 17 00:00:00 2001 From: Mridul Barman Date: Wed, 15 Apr 2026 17:43:43 +0530 Subject: [PATCH 2/2] added tests --- test/engine-evaluate.test.js | 321 +++++++++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 test/engine-evaluate.test.js diff --git a/test/engine-evaluate.test.js b/test/engine-evaluate.test.js new file mode 100644 index 00000000..4ef3662d --- /dev/null +++ b/test/engine-evaluate.test.js @@ -0,0 +1,321 @@ +'use strict' + +import Engine from '../src/engine' +import OperatorMap from '../src/operator-map' + +describe('Engine.evaluate() - static lightweight evaluation', () => { + describe('basic condition evaluation', () => { + it('evaluates a simple "all" condition as true', async () => { + const conditions = { + all: [ + { fact: 'age', operator: 'greaterThanInclusive', value: 18 }, + { fact: 'status', operator: 'equal', value: 'active' } + ] + } + const facts = { age: 25, status: 'active' } + const result = await Engine.evaluate(conditions, facts) + expect(result.results).to.have.lengthOf(1) + expect(result.results[0].result).to.equal(true) + expect(result.failureResults).to.have.lengthOf(0) + expect(result.events).to.have.lengthOf(1) + expect(result.failureEvents).to.have.lengthOf(0) + }) + + it('evaluates a simple "all" condition as false', async () => { + const conditions = { + all: [ + { fact: 'age', operator: 'greaterThanInclusive', value: 18 }, + { fact: 'status', operator: 'equal', value: 'active' } + ] + } + const facts = { age: 16, status: 'active' } + const result = await Engine.evaluate(conditions, facts) + expect(result.results).to.have.lengthOf(0) + expect(result.failureResults).to.have.lengthOf(1) + expect(result.failureResults[0].result).to.equal(false) + expect(result.events).to.have.lengthOf(0) + expect(result.failureEvents).to.have.lengthOf(1) + }) + + it('evaluates a simple "any" condition as true', async () => { + const conditions = { + any: [ + { fact: 'role', operator: 'equal', value: 'admin' }, + { fact: 'role', operator: 'equal', value: 'moderator' } + ] + } + const facts = { role: 'moderator' } + const result = await Engine.evaluate(conditions, facts) + expect(result.results).to.have.lengthOf(1) + expect(result.results[0].result).to.equal(true) + }) + + it('evaluates a simple "any" condition as false', async () => { + const conditions = { + any: [ + { fact: 'role', operator: 'equal', value: 'admin' }, + { fact: 'role', operator: 'equal', value: 'moderator' } + ] + } + const facts = { role: 'user' } + const result = await Engine.evaluate(conditions, facts) + expect(result.failureResults).to.have.lengthOf(1) + expect(result.failureResults[0].result).to.equal(false) + }) + + it('evaluates a "not" condition as true', async () => { + const conditions = { + not: { fact: 'banned', operator: 'equal', value: true } + } + const facts = { banned: false } + const result = await Engine.evaluate(conditions, facts) + expect(result.results).to.have.lengthOf(1) + expect(result.results[0].result).to.equal(true) + }) + + it('evaluates a "not" condition as false', async () => { + const conditions = { + not: { fact: 'banned', operator: 'equal', value: true } + } + const facts = { banned: true } + const result = await Engine.evaluate(conditions, facts) + expect(result.failureResults).to.have.lengthOf(1) + }) + }) + + describe('nested conditions', () => { + it('evaluates nested all/any conditions', async () => { + const conditions = { + all: [ + { fact: 'age', operator: 'greaterThanInclusive', value: 18 }, + { + any: [ + { fact: 'role', operator: 'equal', value: 'admin' }, + { fact: 'verified', operator: 'equal', value: true } + ] + } + ] + } + const facts = { age: 25, role: 'user', verified: true } + const result = await Engine.evaluate(conditions, facts) + expect(result.results).to.have.lengthOf(1) + expect(result.results[0].result).to.equal(true) + }) + + it('evaluates deeply nested conditions', async () => { + const conditions = { + any: [ + { + all: [ + { fact: 'tier', operator: 'equal', value: 'premium' }, + { fact: 'balance', operator: 'greaterThan', value: 100 } + ] + }, + { + all: [ + { fact: 'tier', operator: 'equal', value: 'basic' }, + { fact: 'balance', operator: 'greaterThan', value: 1000 } + ] + } + ] + } + const facts = { tier: 'basic', balance: 1500 } + const result = await Engine.evaluate(conditions, facts) + expect(result.results).to.have.lengthOf(1) + expect(result.results[0].result).to.equal(true) + }) + }) + + describe('all default operators', () => { + it('supports equal', async () => { + const result = await Engine.evaluate( + { all: [{ fact: 'x', operator: 'equal', value: 5 }] }, + { x: 5 } + ) + expect(result.results).to.have.lengthOf(1) + }) + + it('supports notEqual', async () => { + const result = await Engine.evaluate( + { all: [{ fact: 'x', operator: 'notEqual', value: 5 }] }, + { x: 3 } + ) + expect(result.results).to.have.lengthOf(1) + }) + + it('supports in', async () => { + const result = await Engine.evaluate( + { all: [{ fact: 'x', operator: 'in', value: [1, 2, 3] }] }, + { x: 2 } + ) + expect(result.results).to.have.lengthOf(1) + }) + + it('supports notIn', async () => { + const result = await Engine.evaluate( + { all: [{ fact: 'x', operator: 'notIn', value: [1, 2, 3] }] }, + { x: 5 } + ) + expect(result.results).to.have.lengthOf(1) + }) + + it('supports contains', async () => { + const result = await Engine.evaluate( + { all: [{ fact: 'x', operator: 'contains', value: 3 }] }, + { x: [1, 2, 3] } + ) + expect(result.results).to.have.lengthOf(1) + }) + + it('supports doesNotContain', async () => { + const result = await Engine.evaluate( + { all: [{ fact: 'x', operator: 'doesNotContain', value: 5 }] }, + { x: [1, 2, 3] } + ) + expect(result.results).to.have.lengthOf(1) + }) + + it('supports lessThan', async () => { + const result = await Engine.evaluate( + { all: [{ fact: 'x', operator: 'lessThan', value: 10 }] }, + { x: 5 } + ) + expect(result.results).to.have.lengthOf(1) + }) + + it('supports greaterThan', async () => { + const result = await Engine.evaluate( + { all: [{ fact: 'x', operator: 'greaterThan', value: 10 }] }, + { x: 15 } + ) + expect(result.results).to.have.lengthOf(1) + }) + + it('supports lessThanInclusive', async () => { + const result = await Engine.evaluate( + { all: [{ fact: 'x', operator: 'lessThanInclusive', value: 10 }] }, + { x: 10 } + ) + expect(result.results).to.have.lengthOf(1) + }) + + it('supports greaterThanInclusive', async () => { + const result = await Engine.evaluate( + { all: [{ fact: 'x', operator: 'greaterThanInclusive', value: 10 }] }, + { x: 10 } + ) + expect(result.results).to.have.lengthOf(1) + }) + }) + + describe('options', () => { + it('supports allowUndefinedFacts', async () => { + const conditions = { + all: [{ fact: 'missing', operator: 'equal', value: undefined }] + } + const result = await Engine.evaluate(conditions, {}, { allowUndefinedFacts: true }) + expect(result.results).to.have.lengthOf(1) + }) + + it('rejects undefined facts by default', async () => { + const conditions = { + all: [{ fact: 'missing', operator: 'equal', value: 5 }] + } + try { + await Engine.evaluate(conditions, {}) + expect.fail('should have thrown') + } catch (e) { + expect(e.message).to.include('Undefined fact') + } + }) + + it('supports custom operatorMap', async () => { + const customMap = new OperatorMap({ parent: OperatorMap.shared() }) + customMap.addOperator('startsWith', (a, b) => a.startsWith(b)) + const conditions = { + all: [{ fact: 'name', operator: 'startsWith', value: 'Jo' }] + } + const result = await Engine.evaluate(conditions, { name: 'John' }, { operatorMap: customMap }) + expect(result.results).to.have.lengthOf(1) + }) + }) + + describe('return value structure', () => { + it('returns results, failureResults, events, and failureEvents', async () => { + const conditions = { + all: [{ fact: 'x', operator: 'equal', value: 1 }] + } + const result = await Engine.evaluate(conditions, { x: 1 }) + expect(result).to.have.property('results') + expect(result).to.have.property('failureResults') + expect(result).to.have.property('events') + expect(result).to.have.property('failureEvents') + }) + + it('returns event with type "evaluate"', async () => { + const result = await Engine.evaluate( + { all: [{ fact: 'x', operator: 'equal', value: 1 }] }, + { x: 1 } + ) + expect(result.events[0].type).to.equal('evaluate') + }) + }) + + describe('shared operator registry', () => { + it('OperatorMap.shared() returns the same instance', () => { + const a = OperatorMap.shared() + const b = OperatorMap.shared() + expect(a).to.equal(b) + }) + + it('shared instance contains all default operators', () => { + const shared = OperatorMap.shared() + expect(shared.get('equal')).to.not.be.null() + expect(shared.get('notEqual')).to.not.be.null() + expect(shared.get('greaterThan')).to.not.be.null() + expect(shared.get('lessThan')).to.not.be.null() + expect(shared.get('in')).to.not.be.null() + expect(shared.get('notIn')).to.not.be.null() + expect(shared.get('contains')).to.not.be.null() + expect(shared.get('doesNotContain')).to.not.be.null() + }) + + it('shared instance contains all default decorators', () => { + const shared = OperatorMap.shared() + expect(shared.get('someFact:equal')).to.not.be.null() + expect(shared.get('everyFact:equal')).to.not.be.null() + expect(shared.get('not:equal')).to.not.be.null() + expect(shared.get('swap:equal')).to.not.be.null() + }) + + it('Engine instances inherit shared operators without cloning', () => { + const engine = new Engine() + // Engine operators should have the shared instance as parent + expect(engine.operators.parent).to.equal(OperatorMap.shared()) + // Engine can still resolve default operators through the parent chain + expect(engine.operators.get('equal')).to.not.be.null() + expect(engine.operators.get('greaterThan')).to.not.be.null() + }) + + it('Engine instances can add custom operators without affecting shared', () => { + const engine = new Engine() + engine.addOperator('custom', (a, b) => a === b) + expect(engine.operators.get('custom')).to.not.be.null() + // shared should not have the custom operator + const shared = OperatorMap.shared() + expect(shared.get('custom')).to.be.null() + }) + }) + + describe('empty condition arrays', () => { + it('evaluates empty "all" as true', async () => { + const result = await Engine.evaluate({ all: [] }, {}) + expect(result.results).to.have.lengthOf(1) + }) + + it('evaluates empty "any" as false', async () => { + const result = await Engine.evaluate({ any: [] }, {}) + expect(result.failureResults).to.have.lengthOf(1) + }) + }) +})