const infernalUtils = require("./infernalUtils");
const RuleContext = require("./RuleContext");
const Fact = require("./Fact");
/**
* This is the inference engine class.
*/
class InfernalEngine {
/**
* Create a new InfernalEngine instance.
* @param {Number} [maxGen=50] The maximum number of agenda generation
* when executing inference.
* @param {Function=} trace A tracing function that will be called with a trace
* object parameter.
*/
constructor(maxGen, trace) {
this._maxGen = maxGen || 50;
this._trace = trace;
this._busy = false;
this._facts = new Map();
this._rules = new Map();
this._relations = new Map();
this._agenda = new Map();
this._changes = new Set();
}
/**
* Returns the fact value for a given path.
* @param {String} path The full path to the desired fact.
* @return {Promise<*>} The fact value.
*/
async peek(path) {
let factpath = path.startsWith("/") ? path : `/${path}`;
let compiledpath = infernalUtils.compilePath(factpath);
return this._facts.get(compiledpath);
}
/**
* Asserts a new fact or update an existing fact for the given path with
* the provided value. Asserting a fact with an undefined value will
* retract the fact if it exists.
* @param {String} path The full path to the desired fact.
* @param {*} value The fact value to set, must be a scalar.
*/
async assert(path, value) {
let factpath = path.startsWith("/") ? path : `/${path}`;
let compiledpath = infernalUtils.compilePath(factpath);
var oldValue = this._facts.get(compiledpath);
if (!(value instanceof Array) && infernalUtils.equals(oldValue, value)) {
// GTFO if the value is not an array and the received scalar value does not change
// the fact value.
return;
}
let action = "assert";
if (value !== undefined) {
this._facts.set(compiledpath, value);
}
else {
if (!this._facts.has(compiledpath)) {
// the fact does not exist.
if (this._trace) {
this._trace({
action: "retract",
warning: `Cannot retract undefined fact '${compiledpath}'.`
});
}
return;
}
action = "retract";
this._facts.delete(compiledpath);
this._relations.delete(compiledpath);
}
if (this._trace) {
this._trace({
action: action,
fact: compiledpath,
oldValue: oldValue,
newValue: value
});
}
// If the path do not reference a meta-fact
if (!compiledpath.startsWith("/$")) {
this._changes.add(compiledpath);
_addToAgenda.call(this, compiledpath);
if (!this._busy) {
await _infer.call(this);
}
}
}
/**
* Asserts all recieved facts.
* @param {Array<Fact>} facts A list of facts to assert at in one go.
*/
async assertAll(facts) {
if (!(facts instanceof Array)) {
throw new Error("The 'facts' parameter must be an Array.");
}
if (facts.length === 0) {
return;
}
if (this._trace) {
this._trace({
action: "assertAll",
factCount: facts.length
});
}
this._busy = true;
try {
for (const fact of facts) {
if (!(fact instanceof Fact)) {
throw new Error("The asserted array must contains objects of class Fact only.");
}
await this.assert(fact.path, fact.value);
}
}
finally {
this.busy = false;
}
await _infer.call(this);
}
/**
* Retracts a fact or multiple facts recursively if the path ends with '/*'.
* @param {String} path The path to the fact to retract.
*/
async retract(path) {
if (!path.endsWith("/*")) {
await this.assert(path, undefined);
return;
}
let factpath = path.startsWith("/") ? path : `/${path}`;
let compiledPath = infernalUtils.compilePath(factpath);
let pathPrefix = compiledPath.substr(0, compiledPath.length - 1);
for (const [factPath, _] of this._facts) {
if (!factPath.startsWith(pathPrefix)) continue;
await this.assert(factPath, undefined);
}
}
/**
* @deprecated Use {@link InfernalEngine#def} instead.
* Add a rule to the engine's ruleset and launche the inference.
* @param {String} path The path where to save the rule at.
* @param {Function} rule The rule to add. Must be async.
*/
async defRule(path, rule) {
await this.def(path, rule);
}
/**
* Add a rule to the engine's ruleset and launche the inference.
* @param {String} path The path where to save the rule at.
* @param {Function} rule The rule to add. Must be async.
*/
async def(path, rule) {
let prefix = rule && rule.toString().substring(0,5);
if (prefix !== "async") {
throw new Error("The rule parameter must be an async function.");
}
let rulepath = path.startsWith("/") ? path : `/${path}`;
let compiledRulepath = infernalUtils.compilePath(rulepath);
let context = infernalUtils.getContext(compiledRulepath);
if (this._rules.has(compiledRulepath)) {
throw new Error(`Can not define the rule '${compiledRulepath}' because it ` +
"already exist. Call 'undef' or change the rule path.");
}
let ruleContext = new RuleContext(this, rule, compiledRulepath)
let parameters = infernalUtils.parseParameters(rule);
for (const param of parameters) {
let factpath = param.startsWith("/") ? param : context + param;
let compiledFactpath = infernalUtils.compilePath(factpath);
if (!this._relations.has(compiledFactpath))
this._relations.set(compiledFactpath, new Set());
this._relations.get(compiledFactpath).add(compiledRulepath);
ruleContext.facts.push(compiledFactpath);
}
this._rules.set(compiledRulepath, ruleContext);
if (this._trace) {
this._trace({
action: "defRule",
rule: compiledRulepath,
inputFacts: ruleContext.facts.slice()
});
}
this._agenda.set(compiledRulepath, ruleContext);
if (this._trace) {
this._trace({
action: "addToAgenda",
rule: path
});
}
if (!this._busy) {
await _infer.call(this);
}
}
/**
* @deprecated Use {@link InfernalEngine#undef} instead.
* Undefine a rule at the given path or a group of rules if the path ends with '/*'.
* @param {String} path The path to the rule to be undefined.
*/
async undefRule(path) {
await this.undef(path);
}
/**
* Undefine a rule at the given path or a group of rules if the path ends with '/*'.
* @param {String} path The path to the rule to be undefined.
*/
async undef(path) {
let rulepath = path.startsWith("/") ? path : `/${path}`;
let compiledRulepath = infernalUtils.compilePath(rulepath);
if (!compiledRulepath.endsWith("/*")) {
_deleteRule.call(this, compiledRulepath);
return;
}
let pathPrefix = compiledRulepath.substr(0, compiledRulepath.length - 1);
for (const [path, _] of this._rules) {
if (!path.startsWith(pathPrefix)) continue;
_deleteRule.call(this, path);
}
}
/**
* Import the given Javascript object into the engine. Scalar values and arrays as facts,
* functions as rules. Launches the inference on any new rules and any existing rules
* triggered by importing the object facts. Infers only when eveything have been imported.
* @param {Object} obj The object to import.
* @param {String} context The path where the object will be imported.
*/
async import(obj, context) {
if (this._trace) {
this._trace({
action: "import",
object: obj
});
}
let superBusy = this._busy; // true when called while infering.
this._busy = true;
try {
await _import.call(this, obj, context || "");
if (!superBusy) {
// not already infering, start the inference.
await _infer.call(this);
}
}
finally {
if (!superBusy) {
// Not already infering, reset the busy state.
this._busy = false;
}
}
}
/**
* Export internal facts from the given optional path as a JSON object. Do not export rules.
* @param {String} [context="/"] The context to export as an object.
* @return {object} a JSON object representation of the engine internal state.
*/
async export(context) {
let targetContext = context || "/";
if (!targetContext.startsWith("/")) {
targetContext = `/${targetContext}`;
}
let obj = {};
for (const [key, value] of this._facts) {
if (key.startsWith(targetContext)) {
let subkeys = key
.substring(targetContext.length)
.replace(/\//g, " ")
.trim()
.split(" ");
_deepSet(obj, subkeys, value);
}
}
return obj;
}
/**
* Exports all changed facts since the last call to exportChanges or
* [reset]{@link InfernalEngine#reset} as a Javascript object. Reset the change tracker.
* @return a JSON object containing the cumulative changes since last call.
*/
async exportChanges() {
let obj = {};
for (const key of this._changes) {
let subkeys = key
.replace(/\//g, " ")
.trim()
.split(" ");
_deepSet(obj, subkeys, this._facts.get(key));
}
this._changes.clear();
return obj;
}
/**
* Resets the change tracker.
*/
async reset() {
this._changes.clear();
}
/**
* Create a new Fact with the given path and value.
* @param {string} path Mandatory path of the fact.
* @param {any} value Value of the fact, can be 'undefined' to retract the given fact.
*/
static fact(path, value) {
return new Fact(path, value);
}
}
// Private
function _deleteRule(path) {
if (this._trace) {
this._trace({
action: "undefRule",
rule: path
});
}
this._rules.delete(path);
// TODO: Target relations using the rule's parameter instead of looping blindly.
for (const [_, rules] of this._relations) {
rules.delete(path);
}
}
// Execute inference and return a promise. At the begining of the inference, sets the
// '/$/maxGen' fact to make it available to rules that would be interested in this value.
// Upon each loop, the engine sets the '/$/gen' value indicating the agenda generation the
// inference is currently at.
async function _infer() {
if (this._trace) {
this._trace({
action: "infer",
maxGen: this._maxGen
});
}
this._busy = true;
let gen = 0;
this._facts.set("/$/maxGen", this._maxGen); //metafacts do not trigger rules
try {
while (gen < this._maxGen && this._agenda.size > 0) {
gen++;
this._facts.set("/$/gen", gen); // metafacts do not trigger rules
if (this._trace) {
this._trace({
action: "executeAgenda",
gen: gen,
ruleCount: this._agenda.size
});
}
let currentAgenda = this._agenda;
this._agenda = new Map();
for (const [_, rulectx] of currentAgenda) {
await rulectx.execute();
}
}
}
finally {
this._busy = false;
}
if (gen == this._maxGen) {
throw new Error("Inference not completed because maximum depth " +
`reached (${this._maxGen}). Please review for infinite loop or set the ` +
"maxDepth property to a larger value.");
}
}
async function _import(obj, context) {
let targetContext = context;
if (context.endsWith("/")) {
targetContext = context.substring(0, context.length-1);
}
// Set an object that needs to be handled like scalar value.
if (obj instanceof Date || obj instanceof Array) {
return await this.assert(targetContext, obj);
}
const objtype = typeof obj;
// Handle rules as they come by.
if (objtype === "function") {
return await this.defRule(targetContext, obj);
}
// Set scalar value
if (objtype !== "object") {
return await this.assert(targetContext, obj);
}
// Drill down into the object to add other facts and rules.
for (let member in obj) {
await _import.call(this,
obj[member],
`${targetContext}/${member}`);
}
}
function _addToAgenda(factName) {
if (this._relations.has(factName)) {
let rules = this._relations.get(factName);
rules.forEach(ruleName => {
this._agenda.set(ruleName, this._rules.get(ruleName));
if (this._trace) {
this._trace({
action: "addToAgenda",
rule: ruleName
});
}
});
}
}
function _deepSet(target, keys, value) {
let key = keys[0];
if (keys.length === 1) {
target[key] = value;
return;
}
if (typeof target[key] === "undefined") {
target[key] = {};
}
_deepSet(target[key], keys.slice(1), value)
}
module.exports = InfernalEngine;