Skip to content

perf: Add static Engine.evaluate() and shared operator registry for bulk evaluation#432

Open
mridulbarman027 wants to merge 2 commits intoCacheControl:masterfrom
mridulbarman027:optimize/bulk-evaluation-static-evaluate
Open

perf: Add static Engine.evaluate() and shared operator registry for bulk evaluation#432
mridulbarman027 wants to merge 2 commits intoCacheControl:masterfrom
mridulbarman027:optimize/bulk-evaluation-static-evaluate

Conversation

@mridulbarman027
Copy link
Copy Markdown

Problem

When evaluating thousands of different condition sets against different facts using the same
default operators, users must create a new Engine() per condition set. This is expensive due to:

  1. EventEmitter2 initialization per instance (Engine and Rule both extend EventEmitter)
  2. 8 default Operator + 6 OperatorDecorator objects created per instance
  3. OperatorMap with internal Maps allocated per instance
  4. Rule/Condition tree parsed per addRule() call
  5. Almanac with fact-caching Maps created per run() call

This results in ~20+ object allocations per evaluation. At scale (e.g. 250K evaluations),
this creates ~5M short-lived objects and significant GC pressure.

Solution

1. Shared default operator registry (singleton)

  • Default operators and decorators arrays are now Object.freeze()d — created once at module load
  • OperatorMap gains a static shared() method returning a singleton pre-loaded with all defaults
  • OperatorMap supports a parent option for prototype-chain-style delegation
  • Each Engine instance creates a lightweight child OperatorMap that inherits from the shared
    singleton instead of cloning all 16 operator/decorator objects

2. Static Engine.evaluate(conditions, facts, options?) method

A new lightweight evaluation path that bypasses all heavy infrastructure:

  • No EventEmitter initialization
  • No Rule construction or deep-cloning
  • No event emission overhead
  • Uses shared operator registry by default
  • Supports full all/any/not nesting
  • Returns API-compatible { results, failureResults, events, failureEvents }
  • Accepts options: allowUndefinedFacts, pathResolver, operatorMap

3. Optimized Engine constructor

  • Engine no longer creates 8 Operator + 6 OperatorDecorator objects per instance
  • Creates an empty OperatorMap with parent: OperatorMap.shared(), inheriting all defaults
    via delegation — zero per-instance operator allocations for the common case
  • Custom operators added to an Engine instance remain local and don't pollute the shared registry

Benchmark results (10K iterations)

new Engine() + addRule() + run() : 100.0ms (100,000 ops/sec)
Engine.evaluate() [static] : 34.4ms (290,908 ops/sec)
Reused engine.run() : 83.9ms (119,230 ops/sec)

Speedup (static vs new Engine) : ~2.9x

Changes

File Change
src/engine-default-operators.js Frozen singleton array
src/engine-default-operator-decorators.js Frozen singleton array
src/operator-map.js static shared(), parent delegation (_hasOperator, _getOperator, _getDecorator)
src/engine.js static evaluate(), constructor uses shared parent OperatorMap
test/engine-evaluate.test.js 30 new tests covering static evaluate, shared registry, all operators
benchmark/evaluate-benchmark.js Comparative benchmark (traditional vs static vs reused)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant