Skip to content

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

Open
mridulbarman027 wants to merge 2 commits into
CacheControl: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 into
CacheControl: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

1 participant