Skip to content

Commit

Permalink
first drop
Browse files Browse the repository at this point in the history
  • Loading branch information
frankthelen committed Jan 2, 2018
1 parent ba38bd9 commit b9ef10d
Show file tree
Hide file tree
Showing 13 changed files with 197 additions and 74 deletions.
64 changes: 40 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ Rules are specified in pure JavaScript rather than in a separate, special-purpos

*Secondary design goal* was to provide RETE-like efficiency and optimization.

It uses some of the cool new ES6 stuff, e.g., Generators, `Proxy`, `Reflect`, `Set`. *JavaScript Rocks!*

## Install

```bash
Expand Down Expand Up @@ -67,7 +65,7 @@ const Rools = require('rools');

const rools = new Rools();
rools.register(ruleMoodGreat, ruleGoWalking);
const result = rools.evaluate(facts);
const result = await rools.evaluate(facts);
```
This is the result:
```js
Expand All @@ -83,8 +81,6 @@ This is the result:

The engine does forward-chaining and works in the usual match-resolve-act cycle.

Rule evaluation is non-blocking, i.e., each evaluation step is one execution block (using ES6 Generators).

### Conflict resolution

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 (in this order):
Expand All @@ -98,6 +94,33 @@ For optimization purposes, it might be desired to stop the engine as soon as a s
This can be achieved by settings the respective rules' property `final` to `true`.
Default, of course, is `false`.

### Async actions

While premises (`when`) are always working synchronously on the facts,
actions (`then`) can be synchronous or asynchronous.

Example: asynchronous action using async/await
```js
const rule = {
name: 'check availability',
when: facts => facts.user.address.country === 'germany',
then: async (facts) => {
facts.products = await availabilityCheck(facts.user.address);
},
};
```

Example: synchronous action using promises
```js
const rule = {
name: 'check availability',
when: facts => facts.user.address.country === 'germany',
then: facts =>
availabilityCheck(facts.user.address)
.then((result) => { facts.products = result; }),
};
```

### Optimization I

It is very common that different rules partially share the same premises.
Expand Down Expand Up @@ -187,7 +210,7 @@ const facts = {
...
};
...
rools.evaluate(facts);
await rools.evaluate(facts);
```

This optimization targets runtime performance.
Expand Down Expand Up @@ -216,16 +239,16 @@ Rules are plain JavaScript objects with the following properties:
| Property | Required | Default | Description |
|-------------|----------|---------|-------------|
| `name` | yes | - | A string value identifying the rule. This is used logging and debugging purposes only. |
| `when` | yes | - | A 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 JavaScript function to be executed when the rule fires. The function's interface is `(facts) => { ... }`. |
| `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. |

`register()` registers one or more rules to the rules engine.
It can be called multiple time.
New rules will become effective immediately.

`register()` may `throw` an exception, e.g., if a rule is formally incorrect.
`register()` is working synchronously and may `throw` an exception, e.g., if a rule is formally incorrect.
If an exception is thrown, the affected Rools instance becomes inconsistent and should no longer be used.

Example:
Expand All @@ -252,7 +275,7 @@ const rools = new Rools();
rools.register(ruleMoodGreat, ruleGoWalking);
```

### `evaluate()` -- evaluate facts
### `async evaluate()` -- evaluate facts

Facts are plain JavaScript or JSON objects. For example:
```js
Expand All @@ -269,7 +292,7 @@ const facts = {
};
const rools = new Rools();
rools.register(...);
rools.evaluate(facts);
await rools.evaluate(facts);
```

Sometimes, it is handy to combine facts using ES6 shorthand notation:
Expand All @@ -285,26 +308,19 @@ const weather = {
};
const rools = new Rools();
rools.register(...);
rools.evaluate({ user, weather });
await rools.evaluate({ user, weather });
```

*Important*: Please note that rules read the facts (`when`) as well as write to the facts (`then`). Please make sure you provide a fresh set of facts whenever you call `evaluate()`.
Please note that rules read the facts (`when`) as well as write to the facts (`then`).
Please make sure you provide a fresh set of facts whenever you call `evaluate()`.

If during `evaluate()`, firing actions (`then`) or evaluating premises (`when`) raise errors, `evaluate()` will *not* fail. However, the errors are passed to its logger, which you can hook into like this.

```js
const delegate = ({ message, rule, error }) => {
console.error(message, rule, error);
};
const rools = new Rools({ logging: { delegate } });
...
rools.evaluate(facts);
```
`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.

### Todos

Some of the features on my list are:
* Conflict resolution by specificity
* Asynchronous actions (`then`)
* Action/rule groups
* More unit tests
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"devDependencies": {
"chai": "^4.1.2",
"chai-as-promised": "^7.1.1",
"chai-shallow-deep-equal": "^1.4.6",
"coveralls": "^3.0.0",
"eslint": "^4.14.0",
"eslint-config-airbnb-base": "^12.1.0",
Expand Down
40 changes: 24 additions & 16 deletions src/Rools.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ class Rools {
assert(rule.then, `"rule.then" is required "${rule.name}"`);
}

evaluate(facts) {
async evaluate(facts) {
// init
this.logger.log({ type: 'debug', message: 'evaluate init' });
const memory = {}; // working memory
this.actions.forEach((action) => {
memory[action.id] = { ready: false, fired: false };
Expand All @@ -68,18 +69,15 @@ class Rools {
const activeSegments = new Set();
const premisesBySegment = {}; // hash
// match-resolve-act cycle
for (
let step = 0;
step < this.maxSteps &&
!this.evaluateStep(proxy, delegator, memory, activeSegments, premisesBySegment, step)
.next().done;
step += 1
) ;
// for convenience only
for (let step = 0; step < this.maxSteps; step += 1) {
const goOn = // eslint-disable-next-line no-await-in-loop
await this.evaluateStep(proxy, delegator, memory, activeSegments, premisesBySegment, step);
if (!goOn) break; // for
}
return facts;
}

* evaluateStep(facts, delegator, memory, activeSegments, premisesBySegment, step) {
async evaluateStep(facts, delegator, memory, activeSegments, premisesBySegment, step) {
this.logger.log({ type: 'debug', message: `evaluate step ${step}` });
// create agenda for premises
const premisesAgenda = step === 0 ? this.premises : new Set();
Expand All @@ -102,7 +100,7 @@ class Rools {
premises.add(premise); // might grow for "hidden" conditions
});
memory[premise.id].value = premise.when(facts); // >>> evaluate premise!
} catch (error) {
} catch (error) { // ignore error!
memory[premise.id].value = undefined;
this.logger.log({
type: 'error', message: 'error in premise (when)', rule: premise.name, error,
Expand Down Expand Up @@ -135,7 +133,7 @@ class Rools {
const action = this.evaluateSelect(conflictSet);
if (!action) {
this.logger.log({ type: 'debug', message: 'evaluation complete' });
return; // done
return false; // done
}
// fire action
this.logger.log({ type: 'debug', message: 'fire action', rule: action.name });
Expand All @@ -146,11 +144,12 @@ class Rools {
this.logger.log({ type: 'debug', message: `write "${segment}"`, rule: action.name });
activeSegments.add(segment);
});
action.then(facts); // >>> fire action!
} catch (error) {
await this.fire(action, facts); // >>> fire action!
} catch (error) { // re-throw error!
this.logger.log({
type: 'error', message: 'error in action (then)', rule: action.name, error,
});
throw new Error(`error in action (then): ${action.name}`, error);
} finally {
delegator.unset();
}
Expand All @@ -159,10 +158,10 @@ class Rools {
this.logger.log({
type: 'debug', message: 'evaluation stop after final rule', rule: action.name,
});
return; // done
return false; // done
}
// next step
yield; // not yet done
return true; // not yet done
}

evaluateSelect(actions) {
Expand All @@ -179,6 +178,15 @@ class Rools {
this.logger.log({ type: 'debug', message: 'conflict resolution by priority' });
return actionsWithPrio[0];
}

async fire(action, facts) { // eslint-disable-line class-methods-use-this
try {
const thenable = action.then(facts); // >>> fire action!
return thenable && thenable.then ? thenable : Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
}
}

module.exports = Rools;
22 changes: 22 additions & 0 deletions test/async.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const Rools = require('../src');
const { frank } = require('./facts/users')();
const { rule1, rule2 } = require('./rules/availability');
require('./setup');

describe('Rules.evaluate() / async', () => {
it('should call async action / action with async/await', async () => {
const rools = new Rools({ logging: { debug: true } });
rools.register(rule1);
const result = await rools.evaluate({ user: frank });
console.log(result); // eslint-disable-line no-console
expect(result.products).to.shallowDeepEqual(['dsl', 'm4g', 'm3g']);
});

it('should call async action / action with promises', async () => {
const rools = new Rools({ logging: { debug: true } });
rools.register(rule2);
const result = await rools.evaluate({ user: frank });
console.log(result); // eslint-disable-line no-console
expect(result.products).to.shallowDeepEqual(['dsl', 'm4g', 'm3g']);
});
});
20 changes: 14 additions & 6 deletions test/errors.spec.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const assert = require('assert');
const Rools = require('../src');
const { frank } = require('./facts/users')();
const { good } = require('./facts/weather')();
Expand All @@ -7,7 +8,7 @@ const {
require('./setup');

describe('Rules.evaluate()', () => {
it('should not fail if `when` throws error', () => {
it('should not fail if `when` throws error', async () => {
const brokenRule = {
name: 'broken rule #1',
when: facts => facts.bla.blub === 'blub', // TypeError: Cannot read property 'blub' of undefined
Expand All @@ -16,12 +17,16 @@ describe('Rules.evaluate()', () => {
const rools = new Rools({ logging: { error: false } });
const facts = { user: frank, weather: good };
rools.register(brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome);
expect(() => rools.evaluate(facts)).to.not.throw();
try {
await rools.evaluate(facts);
} catch (error) {
assert.fail();
}
expect(facts.user.mood).to.be.equal('great');
expect(facts.goWalking).to.be.equal(true);
});

it('should not fail if `then` throws error', () => {
it('should fail if `then` throws error', async () => {
const brokenRule = {
name: 'broken rule #2',
when: () => true, // fire immediately
Expand All @@ -32,8 +37,11 @@ describe('Rules.evaluate()', () => {
const rools = new Rools({ logging: { error: false } });
const facts = { user: frank, weather: good };
rools.register(brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome);
expect(() => rools.evaluate(facts)).to.not.throw();
expect(facts.user.mood).to.be.equal('great');
expect(facts.goWalking).to.be.equal(true);
try {
await rools.evaluate(facts);
assert.fail();
} catch (error) {
// ignore
}
});
});
16 changes: 14 additions & 2 deletions test/facts/users.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
const frank = {
name: 'frank',
stars: 347,
dateOfBirth: new Date('1995-01-01'),
address: {
city: 'hamburg',
street: 'redderkoppel',
country: 'germany',
},
};

const michael = {
name: 'michael',
stars: 156,
dateOfBirth: new Date('1999-08-08'),
address: {
city: 'san Francisco',
street: 'willard',
country: 'usa',
},
};

module.exports = () => ({
frank: { ...frank },
michael: { ...michael },
frank: JSON.parse(JSON.stringify(frank)),
michael: JSON.parse(JSON.stringify(michael)),
});
8 changes: 6 additions & 2 deletions test/facts/weather.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
const good = {
temperature: 20,
humidity: 39,
pressure: 1319,
windy: true,
rainy: false,
};

const bad = {
temperature: 9,
humidity: 89,
pressure: 1013,
windy: true,
rainy: true,
};

module.exports = () => ({
good: { ...good },
bad: { ...bad },
good: JSON.parse(JSON.stringify(good)),
bad: JSON.parse(JSON.stringify(bad)),
});
4 changes: 2 additions & 2 deletions test/final.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ describe('Rules.evaluate() / final rule', () => {
);
});

it('should terminate after final rule', () => {
const result = rools.evaluate({ user: frank, weather: good });
it('should terminate after final rule', async () => {
const result = await rools.evaluate({ user: frank, weather: good });
expect(result.user.mood).to.be.equal('great');
expect(result.goWalking).to.be.equal(undefined);
expect(result.stayAtHome).to.be.equal(undefined);
Expand Down
Loading

0 comments on commit b9ef10d

Please sign in to comment.