A small rule engine for Node.
Primary goal was to provide a nice and state-of-the-art interface for modern JavaScript (ES6). Facts are plain JavaScript or JSON objects or objects from ES6 classes with getters and setters. Rules are specified in pure JavaScript rather than in a separate, special-purpose language like DSL.
Secondary goal was to provide RETE-like efficiency and optimization.
Mission accomplished! JavaScript rocks!
See migration info for breaking changes between major versions 1.x.x and 2.x.x.
npm install rools
This is a basic example.
// import
const { Rools, Rule } = require('rools');
// facts
const facts = {
user: {
name: 'frank',
stars: 347,
},
weather: {
temperature: 20,
windy: true,
rainy: false,
},
};
// rules
const ruleMoodGreat = new Rule({
name: 'mood is great if 200 stars or more',
when: (facts) => facts.user.stars >= 200,
then: (facts) => {
facts.user.mood = 'great';
},
});
const ruleGoWalking = new Rule({
name: 'go for a walk if mood is great and the weather is fine',
when: [
(facts) => facts.user.mood === 'great',
(facts) => facts.weather.temperature >= 20,
(facts) => !facts.weather.rainy,
],
then: (facts) => {
facts.goWalking = true;
},
});
// evaluation
const rools = new Rools();
await rools.register([ruleMoodGreat, ruleGoWalking]);
await rools.evaluate(facts);
These are the resulting facts:
{ user: { name: 'frank', stars: 347, mood: 'great' },
weather: { temperature: 20, windy: true, rainy: false },
goWalking: true,
}
The engine does forward-chaining and works in the usual match-resolve-act cycle. It tries to deduce as much knowledge as possible from the given facts and rules. If there is no further knowledge to gain, it stops.
Facts are plain JavaScript or JSON objects or objects from ES6 classes with getters and setters.
Rules are specified in pure JavaScript via new Rule()
.
They have premises (when
) and actions (then
).
Both are JavaScript functions, i.e., classic functions or ES6 arrow functions.
Actions can also be asynchronous.
Rules access the facts in both, premises (when
) and actions (then
).
They can access properties directly, e.g., facts.user.salary
,
or through getters and setters if applicable, e.g., facts.user.getSalary()
.
If there is more than one rule ready to fire, i.e., the conflict set is greater 1, the following conflict resolution strategies are applied (by default, in this order):
- Refraction -- Each rule will fire only once, at most, during any one match-resolve-act cycle.
- Priority -- Rules with higher priority will fire first. Set the rule's property
priority
to an integer value. Default priority is0
. Negative values are supported. - Specificity -- Rules which are more specific will fire first. For example, there is rule R1 with premises P1 and P2, and rule R2 with premises P1, P2 and P3. R2 is more specific than R1 and will fire first. R2 is more specific than R1 because it has all premises of R1 and additional ones.
- Order of rules -- The rules that were registered first will fire first.
For optimization purposes, it can be useful to stop the engine as soon as a specific rule has fired.
This can be achieved by settings the respective rules' property final
to true
.
Default, of course, is false
.
While premises (when
) are always working synchronously on the facts,
actions (then
) can be synchronous or asynchronous.
Example: asynchronous action using async/await
const rule = new Rule({
name: 'check availability',
when: (facts) => facts.user.address.country === 'germany',
then: async (facts) => {
facts.products = await availabilityCheck(facts.user.address);
},
});
Example: asynchronous action using promises
const rule = new Rule({
name: 'check availability',
when: (facts) => facts.user.address.country === 'germany',
then: (facts) =>
availabilityCheck(facts.user.address)
.then((result) => {
facts.products = result;
}),
});
If a rule is more specific than another rule, you can extend it rather than having to repeat its premises.
The extended rule simply inherits all the premises from its parents (and their parents).
Use the rule's extend
property to set its parents.
Example: extended rule
const baseRule = new Rule({
name: 'user lives in Germany',
when: (facts) => facts.user.address.country === 'germany',
...
});
const extendedRule = new Rule({
name: 'user lives in Hamburg, Germany',
extend: baseRule, // can also be an array of rules
when: (facts) => facts.user.address.city === 'hamburg',
...
});
Only one rule within an activation group will fire during a match-resolve-act cycle, i.e.,
the first one to fire discards all other rules within the same activation group.
Use the rule's activationGroup
property to set its activation group.
Besides activation groups, Rools has currently no other concept of grouping rules such as agenda groups or rule flow groups which you might know from other rule engines. And there are currently no plans to support such features.
However, if that solves your needs, you can consecutively run different sets of rules against the same facts. Rules in different instances of Rools are perfectly isolated and can, of course, run against the same facts.
Example: evaluate different sets of rules on the same facts
const facts = {...};
const rools1 = new Rools();
const rools2 = new Rools();
await rools1.register(...); // rule set 1
await rools2.register(...); // rule set 2
await rools1.evaluate(facts);
await rools2.evaluate(facts);
It is very common that different rules partially share the same premises. Rools will automatically merge identical premises into one. You are free to use references or just to repeat the same premise. Both options are working fine.
Example 1: by reference
const isApplicable = (facts) => facts.user.salary >= 2000;
const rule1 = new Rule({
when: [
isApplicable,
...
],
...
});
const rule2 = new Rule({
when: [
isApplicable,
...
],
...
});
Example 2: repeat premise
const rule1 = new Rule({
when: [
(facts) => facts.user.salary >= 2000,
...
],
...
});
const rule2 = new Rule({
when: [
(facts) => facts.user.salary >= 2000,
...
],
...
});
Furthermore, it is recommended to de-compose premises with AND relations (&&
).
For example:
// this version works...
const rule = new Rule({
when: (facts) => facts.user.salary >= 2000 && facts.user.age > 25,
...
});
// however, it's better to write it like this...
const rule = new Rule({
when: [
(facts) => facts.user.salary >= 2000,
(facts) => facts.user.age > 25,
],
...
});
When actions fire, changes are made to the facts. This requires re-evaluation of the premises. Which may lead to further actions becoming ready to fire.
To avoid complete re-evaluation of all premises each time changes are made to the facts, Rools detects the parts of the facts (segments) that were actually changed and re-evaluates only those premises affected.
Change detection is based on level 1 of the facts. In the example below, detected changes are based on user
, weather
, posts
and so on. So, whenever a user
detail changes, all premises and actions that rely on user
are re-evaluated. But only those.
const facts = {
user: { ... },
weather: { ... },
posts: { ... },
...
};
...
await rools.evaluate(facts);
This optimization targets runtime performance. It unfolds its full potential with a growing number of rules and fact segments.
Ideally, premises (when
) are "pure functions" referring to facts
only.
They should not refer to any other non-local variables.
If they do so, however, please note that non-local variables are resolved at
evaluation time (evaluate()
) and not at registration time (register()
).
Furthermore, please make sure that non-local variables are constant/stable during evaluation. Otherwise, premises are not working deterministically.
In the example below, Rools will treat the two premises as identical
assuming that both rules are referring to the exact same value
.
let value = 2000;
const rule1 = new Rule({
when: (facts) => facts.user.salary >= value,
...
});
value = 3000;
const rule2 = new Rule({
when: (facts) => facts.user.salary >= value,
...
});
The example below does not work!
Rools would treat all premises (when
) as identical
assuming that all rules are referring to the exact same value
.
const createRule = (value) => {
rules.push(new Rule({
name: `Rule evaluating ${value}`,
when: (facts) => facts.foo >= value,
then: (facts) => // ...
}));
};
Make sure not to mix premises and actions.
In the example below, the if condition should be in when
, not in then
.
If you have such cases, think about splitting rules or extending rules.
const rule = new Rule({
name: "rule",
when: // ...
then: (facts) => {
if (facts.foo < 0) { // not good here!
// ...
}
},
});
Calling new Rools()
creates a new Rools instance, i.e., a new rule engine.
You usually do this once for a given set of rules.
Example:
const { Rools } = require('rools');
const rools = new Rools();
...
Rules are created through new Rule()
with the following properties:
Property | Required | Default | Description |
---|---|---|---|
name |
yes | - | A string value identifying the rule. This is used for logging and debugging purposes only. |
when |
yes | - | A synchronous JavaScript function or an array of functions. These are the premises of your rule. The functions' interface is (facts) => { ... } . They must return a boolean value. |
then |
yes | - | A synchronous or asynchronous JavaScript function to be executed when the rule fires. The function's interface is (facts) => { ... } or async (facts) => { ... } . |
priority |
no | 0 |
If during evaluate() there is more than one rule ready to fire, i.e., the conflict set is greater 1, rules with higher priority will fire first. Negative values are supported. |
final |
no | false |
Marks a rule as final. If during evaluate() a final rule fires, the engine will stop the evaluation. |
extend |
no | [] | A reference to a rule or an array of rules. The new rule will inherit all premises from its parents (and their parents). |
activationGroup |
no | - | A string identifying an activation group. Only one rule within an activation group will fire. |
Rules access the facts in both, premises (when
) and actions (then
).
They can access properties directly, e.g., facts.user.salary
,
or through getters and setters if applicable, e.g., facts.user.getSalary()
.
register()
registers one or more rules to the rule engine.
It can be called multiple time.
New rules will become effective immediately.
register()
is working asynchronously, i.e., it returns a promise.
If this promise is rejected, the affected Rools instance is inconsistent and should no longer be used.
Example:
const { Rools, Rule } = require('rools');
const ruleMoodGreat = new Rule({
name: 'mood is great if 200 stars or more',
when: (facts) => facts.user.stars >= 200,
then: (facts) => {
facts.user.mood = 'great';
},
});
const ruleGoWalking = new Rule({
name: 'go for a walk if mood is great and the weather is fine',
when: [
(facts) => facts.user.mood === 'great',
(facts) => facts.weather.temperature >= 20,
(facts) => !facts.weather.rainy,
],
then: (facts) => {
facts.goWalking = true;
},
});
const rools = new Rools();
await rools.register([ruleMoodGreat, ruleGoWalking]);
Facts are plain JavaScript or JSON objects or objects from ES6 classes with getters and setters. For example:
const user = {
name: 'frank',
stars: 347,
};
const weather = {
temperature: 20,
windy: true,
rainy: false,
};
const rools = new Rools();
await rools.register(...);
await rools.evaluate({ user, weather });
Please note that Rools reads the facts (when
) as well as writes to the facts (then
) during evaluation.
Please make sure you provide a fresh set of facts whenever you call evaluate()
.
evaluate()
is working asynchronously, i.e., it returns a promise.
If a premise (when
) fails, evaluate()
will still not fail (for robustness reasons).
If an action (then
) fails, evaluate()
will reject its promise.
If there is more than one rule ready to fire, Rools applies a conflict resolution strategy to decide which rule/action to fire first. The default conflict resolution strategy is 'ps'.
- 'ps' -- (1) priority, (2) specificity, (3) order of registration
- 'sp' -- (1) specificity, (2) priority, (3) order of registration
If you don't like the default 'ps', you can change the conflict resolution strategy like this:
await rools.evaluate(facts, { strategy: 'sp' });
evaluate()
returns an object providing some debug information about the past evaluation run:
fired
-- the number of rules that were fired.elapsed
-- the number of milliseconds needed.accessedByPremises
-- the fact segments that were accessed by premises (when
).accessedByActions
-- the fact segments that were accessed by actions (then
). Formerlyupdated
but renamed for clarification (but still provided for backward compatibility).
const { accessedByActions, fired, elapsed } = await rools.evaluate(facts);
console.log(accessedByActions, fired, elapsed); // e.g., ["user"] 26 187
By default, Rools is logging errors to the JavaScript console
.
This can be configured like this.
const delegate = ({ level, message, rule, error }) => {
console.error(level, message, rule, error);
};
const rools = new Rools({
logging: { error: true, debug: false, delegate },
});
...
level
is either debug
or error
.
The error log reports failed actions or premises.
The debug log reports the entire evaluation process for debugging purposes.
This package provides types for TypeScript.
import { Rools, Rule } from "rools";
// ...
For this module to work, your TypeScript compiler options must include
"target": "ES2015"
(or later), "moduleResolution": "node"
, and
"esModuleInterop": true
.
There are a few breaking changes that require changes to your code.
Rools exposes now two classes, Rools
and Rule
.
// Version 1.x.x
const Rools = require('rools');
// Version 2.x.x
const { Rools, Rule } = require('rools');
Rules must now be created with new Rule()
.
// Version 1.x.x
const rule = {
name: 'my rule',
...
};
// Version 2.x.x
const rule = new Rule({
name: 'my rule',
...
});
register()
takes the rules to be registered as an array now.
Reason is to allow a second options parameter in future releases.
const rools = new Rools();
...
// Version 1.x.x
await rools.register(rule1, rule2, rule3);
// Version 2.x.x
await rools.register([rule1, rule2, rule3]);
evaluate()
does not return the facts anymore - which was only for convenience anyway.
Instead, it returns an object with some useful information.
const rools = new Rools();
...
// Version 1.x.x
const facts = await rools.evaluate({ user, weather });
// Version 2.x.x
const { updated, fired, elapsed } = await rools.evaluate({ user, weather });
console.log(updated, fired, elapsed); // e.g., ["user"] 26 187