Browse Source

Making Rools run for me so I don't have to submit a patch.

master
Zed A. Shaw 3 months ago
parent
commit
e97655299b
15 changed files with 521 additions and 51 deletions
  1. +1
    -1
      lib/builderator.js
  2. +24
    -0
      lib/rools/Action.js
  3. +72
    -0
      lib/rools/ConflictResolution.js
  4. +19
    -0
      lib/rools/Delegator.js
  5. +34
    -0
      lib/rools/Logger.js
  6. +16
    -0
      lib/rools/Premise.js
  7. +129
    -0
      lib/rools/Rools.js
  8. +66
    -0
      lib/rools/Rule.js
  9. +8
    -0
      lib/rools/RuleError.js
  10. +60
    -0
      lib/rools/RuleSet.js
  11. +55
    -0
      lib/rools/WorkingMemory.js
  12. +4
    -0
      lib/rools/index.js
  13. +19
    -0
      lib/rools/observe.js
  14. +12
    -49
      package-lock.json
  15. +2
    -1
      package.json

+ 1
- 1
lib/builderator.js View File

@@ -1,5 +1,5 @@
const glob = require('fast-glob').sync;
const { Rools, Rule } = require('rools');
const { Rools, Rule } = require('./rools');
const _ = require('lodash');
const fs = require('fs');
const assert = require('assert');


+ 24
- 0
lib/rools/Action.js View File

@@ -0,0 +1,24 @@
class Action {
constructor({
id, name, then, priority, final, activationGroup,
}) {
this.id = id;
this.name = name; // for logging only
this.then = then;
this.priority = priority;
this.final = final;
this.activationGroup = activationGroup;
this.premises = [];
}

add(premise) {
this.premises.push(premise);
}

async fire(facts) {
const thenable = this.then(facts); // >>> fire action!
return thenable && thenable.then ? thenable : undefined;
}
}

module.exports = Action;

+ 72
- 0
lib/rools/ConflictResolution.js View File

@@ -0,0 +1,72 @@
const intersection = require('lodash/intersection');

class ConflictResolution {
constructor({ strategy = 'ps', logger }) {
if (strategy === 'ps') {
this.strategy = [
this.resolveByPriority.bind(this),
this.resolveBySpecificity.bind(this),
this.resolveByOrderOfRegistration.bind(this),
];
} else if (strategy === 'sp') {
this.strategy = [
this.resolveBySpecificity.bind(this),
this.resolveByPriority.bind(this),
this.resolveByOrderOfRegistration.bind(this),
];
} else {
throw new Error('conflict resolution strategy must be "ps" or "sp"');
}
this.logger = logger;
this.logger.debug({ message: `conflict resolution strategy "${strategy}"` });
}

select(actions) {
if (actions.length === 0) {
return undefined; // none
}
if (actions.length === 1) {
return actions[0];
}
// conflict resolution
this.logger.debug({ message: `conflict resolution starting with ${actions.length}` });
let resolved = actions; // start with all actions
this.strategy.some((resolver) => {
resolved = resolver(resolved);
return resolved.length === 1; // break
});
return resolved[0];
}

resolveByPriority(actions) {
const prios = actions.map((action) => action.priority);
const highestPrio = Math.max(...prios);
const selected = actions.filter((action) => action.priority === highestPrio);
this.logger.debug({
message: `conflict resolution by priority ${actions.length} -> ${selected.length}`,
});
return selected;
}

resolveBySpecificity(actions) {
const isMoreSpecific = (action, rhs) => action.premises.length > rhs.premises.length
&& intersection(action.premises, rhs.premises).length === rhs.premises.length;
const isMostSpecific = (action, all) => all.reduce((acc, other) => acc
&& !isMoreSpecific(other, action), true);
const selected = actions.filter((action) => isMostSpecific(action, actions));
this.logger.debug({
message: `conflict resolution by specificity ${actions.length} -> ${selected.length}`,
});
return selected;
}

resolveByOrderOfRegistration(actions) {
const selected = [actions[0]];
this.logger.debug({
message: `conflict resolution by order of registration ${actions.length} -> 1`,
});
return selected;
}
}

module.exports = ConflictResolution;

+ 19
- 0
lib/rools/Delegator.js View File

@@ -0,0 +1,19 @@
class Delegator {
constructor() {
this.to = null;
}

delegate(...args) {
return this.to ? this.to(...args) : undefined;
}

set(to) {
this.to = to;
}

unset() {
this.to = null;
}
}

module.exports = Delegator;

+ 34
- 0
lib/rools/Logger.js View File

@@ -0,0 +1,34 @@
class Logger {
constructor({
error = true, debug = false, delegate = null,
} = { error: true, debug: false, delegate: null }) {
this.filter = { error, debug };
this.delegate = delegate;
}

debug(options) {
if (!this.filter.debug) return;
this.log({ ...options, level: 'debug' });
}

error(options) {
if (!this.filter.error) return;
this.log({ ...options, level: 'error' });
}

log(options) {
const out = this.delegate ? this.delegate : Logger.logDefault;
out(options);
}

static logDefault({ message, rule, error }) {
const msg = rule ? `# ${message} - "${rule}"` : `# ${message}`;
if (error) {
console.error(msg, error); // eslint-disable-line no-console
} else {
console.log(msg); // eslint-disable-line no-console
}
}
}

module.exports = Logger;

+ 16
- 0
lib/rools/Premise.js View File

@@ -0,0 +1,16 @@
class Premise {
constructor({
id, name, when,
}) {
this.id = id;
this.name = name; // for logging only
this.when = when;
this.actions = [];
}

add(action) {
this.actions.push(action);
}
}

module.exports = Premise;

+ 129
- 0
lib/rools/Rools.js View File

@@ -0,0 +1,129 @@
const RuleSet = require('./RuleSet');
const Logger = require('./Logger');
const Delegator = require('./Delegator');
const WorkingMemory = require('./WorkingMemory');
const ConflictResolution = require('./ConflictResolution');
const observe = require('./observe');
const RuleError = require('./RuleError');

class Rools {
constructor({ logging } = {}) {
this.rules = new RuleSet();
this.maxPasses = 1000; // emergency stop
this.logger = new Logger(logging);
}

async register(rules) {
rules.forEach((rule) => this.rules.register(rule));
}

async evaluate(facts, { strategy } = {}) {
const startDate = new Date();
// init
const memory = new WorkingMemory({
actions: this.rules.actions,
premises: this.rules.premises,
});
const conflictResolution = new ConflictResolution({ strategy, logger: this.logger });
const delegator = new Delegator();
const proxy = observe(facts, (segment) => delegator.delegate(segment));
// match-resolve-act cycle
let pass = 0; /* eslint-disable no-await-in-loop */
for (; pass < this.maxPasses; pass += 1) {
const next = await this.pass(proxy, delegator, memory, conflictResolution, pass);
if (!next) break; // for
} /* eslint-enable no-await-in-loop */
// return info
const endDate = new Date();
return {
updated: [...memory.updatedSegments],
fired: pass,
elapsed: endDate.getTime() - startDate.getTime(),
};
}

async pass(facts, delegator, memory, conflictResolution, pass) {
this.logger.debug({ message: `evaluate pass ${pass}` });
// create agenda for premises
const premisesAgenda = pass === 0 ? memory.premises : memory.getDirtyPremises();
this.logger.debug({ message: `premises agenda length ${premisesAgenda.length}` });
// evaluate premises
premisesAgenda.forEach((premise) => {
try {
delegator.set((segment) => { // listen to reading fact segments
const segmentName = (typeof segment === 'symbol') ? segment.toString() : segment;
this.logger.debug({ message: `read fact segment "${segmentName}"`, rule: premise.name });
memory.segmentRead(segment, premise);
});
memory.getState(premise).value = premise.when(facts); // >>> evaluate premise!
} catch (error) { // ignore error!
memory.getState(premise).value = undefined;
this.logger.error({ message: 'error in premise (when)', rule: premise.name, error });
} finally {
delegator.unset();
}
});
// create agenda for actions
const actionsAgenda = pass === 0 ? memory.actions : premisesAgenda
.reduce((acc, premise) => [...new Set([...acc, ...premise.actions])], [])
.filter((action) => {
const { fired, discarded } = memory.getState(action);
return !fired && !discarded;
});
this.logger.debug({ message: `actions agenda length ${actionsAgenda.length}` });
// evaluate actions
actionsAgenda.forEach((action) => {
memory.getState(action).ready = action.premises.reduce((acc, premise) => acc
&& memory.getState(premise).value, true);
});
// create conflict set
const conflictSet = memory.actions.filter((action) => { // all actions not only actionsAgenda!
const { fired, ready, discarded } = memory.getState(action);
return ready && !fired && !discarded;
});
this.logger.debug({ message: `conflict set length ${conflictSet.length}` });
// conflict resolution
const action = conflictResolution.select(conflictSet);
if (!action) {
this.logger.debug({ message: 'evaluation complete' });
return false; // done
}
// fire action
this.logger.debug({ message: 'fire action', rule: action.name });
memory.getState(action).fired = true; // mark fired first
try {
memory.clearDirtySegments();
delegator.set((segment) => { // listen to writing fact segments
const segmentName = (typeof segment === 'symbol') ? segment.toString() : segment;
this.logger.debug({ message: `write fact segment "${segmentName}"`, rule: action.name });
memory.segmentWrite(segment);
});
await action.fire(facts); // >>> fire action!
} catch (error) { // re-throw error!
this.logger.error({ message: 'error in action (then)', rule: action.name, error });
throw new RuleError(`error in action (then): ${action.name}`, error);
} finally {
delegator.unset();
}
// final rule
if (action.final) {
this.logger.debug({ message: 'evaluation stop after final rule', rule: action.name });
return false; // done
}
// activation group
if (action.activationGroup) {
this.logger.debug({
message: `activation group fired "${action.activationGroup}"`,
rule: action.name,
});
this.rules.actionsByActivationGroup[action.activationGroup].forEach((other) => {
const state = memory.getState(other);
state.discarded = !state.fired;
});
}
// continue with next pass
return true;
}
}

module.exports = Rools;

+ 66
- 0
lib/rools/Rule.js View File

@@ -0,0 +1,66 @@
const isBoolean = require('lodash/isBoolean');
const isFunction = require('lodash/isFunction');
const isInteger = require('lodash/isInteger');
const isString = require('lodash/isString');
const assert = require('assert');
const arrify = require('arrify');

class Rule {
constructor({
name, when, then, priority = 0, final = false, extend, activationGroup,
}) {
this.name = name;
this.when = arrify(when);
this.then = then;
this.priority = priority;
this.final = final;
this.extend = arrify(extend);
this.activationGroup = activationGroup;
this.assert();
}

assert() {
assert(
this.name,
'"name" is required',
);
assert(
isString(this.name),
'"name" must be a string',
);
assert(
this.when.length,
'"when" is required with at least one premise',
);
assert(
this.when.reduce((acc, premise) => acc && isFunction(premise), true),
'"when" must be a function or an array of functions',
);
assert(
this.then,
'"then" is required',
);
assert(
isFunction(this.then),
'"then" must be a function',
);
assert(
isInteger(this.priority),
'"priority" must be an integer',
);
assert(
isBoolean(this.final),
'"final" must be a boolean',
);
assert(
this.extend.reduce((acc, rule) => acc && (rule instanceof Rule), true),
'"extend" must be a Rule or an array of Rules',
);
assert(
!this.activationGroup || isString(this.activationGroup),
'"activationGroup" must be a string',
);
}
}

module.exports = Rule;

+ 8
- 0
lib/rools/RuleError.js View File

@@ -0,0 +1,8 @@
class RuleError extends Error {
constructor(message, error) {
super(message);
this.cause = error;
}
}

module.exports = RuleError;

+ 60
- 0
lib/rools/RuleSet.js View File

@@ -0,0 +1,60 @@
const assert = require('assert');
const uniqueid = require('uniqueid');
const Action = require('./Action');
const Premise = require('./Premise');
const Rule = require('./Rule');

class RuleSet {
constructor() {
this.actions = [];
this.premises = [];
this.premisesByHash = {};
this.nextActionId = uniqueid('a');
this.nextPremiseId = uniqueid('p');
this.actionsByActivationGroup = {}; // hash
}

register(rule) {
assert(rule instanceof Rule, 'rule must be an instance of "Rule"');
// action
const action = new Action({
...rule,
id: this.nextActionId(),
});
this.actions.push(action);
// extend
const walked = new Set(); // cycle check
const whens = new Set();
const walker = (node) => {
if (walked.has(node)) return; // cycle
walked.add(node);
node.when.forEach((w) => { whens.add(w); });
node.extend.forEach((r) => { walker(r); }); // recursion
};
walker(rule);
// premises
[...whens].forEach((when, index) => {
let premise = new Premise({
...rule,
id: this.nextPremiseId(),
name: `${rule.name} / ${index}`,
when,
});
this.premises.push(premise);
action.add(premise); // action ->> premises
premise.add(action); // premise ->> actions
});
// activation group
const { activationGroup } = rule;
if (activationGroup) {
let group = this.actionsByActivationGroup[activationGroup];
if (!group) {
group = [];
this.actionsByActivationGroup[activationGroup] = group;
}
group.push(action);
}
}
}

module.exports = RuleSet;

+ 55
- 0
lib/rools/WorkingMemory.js View File

@@ -0,0 +1,55 @@
const Action = require('./Action');

class WorkingMemory {
constructor({ actions, premises }) {
this.actions = actions;
this.premises = premises;
this.actionsById = {}; // hash
this.premisesById = {}; // hash
this.actions.forEach((action) => {
this.actionsById[action.id] = { ready: false, fired: false, discarded: false };
});
this.premises.forEach((premise) => {
this.premisesById[premise.id] = { value: undefined };
});
this.dirtySegments = new Set();
this.premisesBySegment = {}; // hash
this.updatedSegments = new Set(); // total
}

getState(object) {
const { id } = object;
return object instanceof Action ? this.actionsById[id] : this.premisesById[id];
}

clearDirtySegments() {
this.dirtySegments.clear();
}

getDirtyPremises() {
const premises = new Set();
this.dirtySegments.forEach((segment) => {
const dirtyPremises = this.premisesBySegment[segment] || [];
dirtyPremises.forEach((premise) => {
premises.add(premise);
});
});
return [...premises];
}

segmentWrite(segment) {
this.dirtySegments.add(segment);
this.updatedSegments.add(segment);
}

segmentRead(segment, premise) {
let premises = this.premisesBySegment[segment];
if (!premises) {
premises = new Set();
this.premisesBySegment[segment] = premises;
}
premises.add(premise); // might grow over time with "hidden" conditions
}
}

module.exports = WorkingMemory;

+ 4
- 0
lib/rools/index.js View File

@@ -0,0 +1,4 @@
const Rools = require('./Rools');
const Rule = require('./Rule');

module.exports = { Rools, Rule };

+ 19
- 0
lib/rools/observe.js View File

@@ -0,0 +1,19 @@
const observe = (object, onChange) => {
const handler = {
get(target, property, receiver) {
onChange(property);
return Reflect.get(target, property, receiver);
},
defineProperty(target, property, descriptor) {
onChange(property);
return Reflect.defineProperty(target, property, descriptor);
},
deleteProperty(target, property) {
onChange(property);
return Reflect.deleteProperty(target, property);
},
};
return new Proxy(object, handler);
};

module.exports = observe;

+ 12
- 49
package-lock.json View File

@@ -2749,10 +2749,9 @@
"dev": true
},
"arrify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz",
"integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=",
"dev": true
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
"integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug=="
},
"asn1": {
"version": "0.2.4",
@@ -3921,12 +3920,6 @@
}
}
},
"charenc": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
"integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=",
"dev": true
},
"charm": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/charm/-/charm-0.1.2.tgz",
@@ -4690,12 +4683,6 @@
}
}
},
"crypt": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
"integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=",
"dev": true
},
"crypto-random-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz",
@@ -11871,17 +11858,6 @@
}
}
},
"md5": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz",
"integrity": "sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=",
"dev": true,
"requires": {
"charenc": "~0.0.1",
"crypt": "~0.0.1",
"is-buffer": "~1.1.1"
}
},
"media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -13094,6 +13070,14 @@
"object-assign": "^4.0.1",
"pify": "^2.0.0",
"pinkie-promise": "^2.0.0"
},
"dependencies": {
"arrify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz",
"integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=",
"dev": true
}
}
},
"is-ci": {
@@ -16068,26 +16052,6 @@
}
}
},
"rools": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/rools/-/rools-2.2.7.tgz",
"integrity": "sha512-mgh7L0LI58+lef+bZCobp1KZOMKYr6TlZ0+fDtDtMFU+nj/ZIFlZZP1YaVuh7C+I8so/54KAcFzd0alBu6+R3w==",
"dev": true,
"requires": {
"arrify": "^2.0.1",
"lodash": "^4.17.15",
"md5": "^2.2.1",
"uniqueid": "^1.0.0"
},
"dependencies": {
"arrify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
"integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==",
"dev": true
}
}
},
"rsvp": {
"version": "4.8.5",
"resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
@@ -18373,8 +18337,7 @@
"uniqueid": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/uniqueid/-/uniqueid-1.0.0.tgz",
"integrity": "sha1-5O0Xg5XHaMi4CmZnO4HP4RZTwx0=",
"dev": true
"integrity": "sha1-5O0Xg5XHaMi4CmZnO4HP4RZTwx0="
},
"universalify": {
"version": "0.1.2",


+ 2
- 1
package.json View File

@@ -16,6 +16,7 @@
},
"dependencies": {
"Validator": "^1.1.1",
"arrify": "^2.0.1",
"bcrypt": "^5.0.0",
"body-parser": "^1.19.0",
"bull": "^3.13.0",
@@ -52,6 +53,7 @@
"simple-git": "^2.4.0",
"sirv": "^0.4.0",
"sqlite3": "^4.1.1",
"uniqueid": "^1.0.0",
"url-slug": "^2.3.1"
},
"devDependencies": {
@@ -100,7 +102,6 @@
"rollup-plugin-babel": "^4.4.0",
"rollup-plugin-svelte": "^5.0.1",
"rollup-plugin-terser": "^7.0.1",
"rools": "^2.2.7",
"sapper": "^0.27.16",
"sharp": "^0.25.2",
"svelte": "^3.20.1",


Loading…
Cancel
Save