http://martinfowler.com/articles/refactoring-adaptive-model.html
When I'm looking to represent some imperative code with a data structure, my first task is to figure out what kind of model I should use to structure that data.
Arrange blocks of code in a data structure to implement an alternative computational model.
You do this by defining a model where the links between elements represent the behavioral relationships of the computational model. This model usually needs references to sections of imperative code. You then run the model either by executing code over it (procedural style) or by executing code within the model itself (object-oriented style).
One option would be to run the javascript version of the logic on each device and use the mechanisms to run code in web views. But another option is to refactor the recommendation logic to data - what I refer to as an Adaptive Model. This allows us to encode the logic in a JSON data structure, which can be easily moved around and loaded into different device software. Applications can check to see if the logic has been updated and download a new version quickly after every change.
Here's the sample of recommendation logic I'll use as the example for refactoring.
recommender.es6…
export default function (spec) { let result = []; if (spec.atNight) result.push("whispering death"); if (spec.seasons && spec.seasons.includes("winter")) result.push("beefy"); if (spec.seasons && spec.seasons.includes("summer")) { if (["sparta", "atlantis"].includes(spec.country)) result.push("white lightening"); } if (spec.minDuration >= 150) { if (spec.seasons && spec.seasons.includes("summer")) { if (spec.minDuration < 350) result.push("white lightening"); else if (spec.minDuration < 570) result.push("little master"); else result.push("wall"); } else { if (spec.minDuration < 450) result.push("white lightening"); else result.push("little master"); } } return _.uniq(result); }
A series of conditionals like this suggests using a Production Rule System, which is a particular computational model that's well suited to being represented in an adaptive model. A production rule system organizes computation through a collection of Production Rules, each of which is structure with two main elements: a condition and an action. The production rule system runs through all the rules, evaluates the condition for each rule, and if the condition returns true, executes the action.
recommender.es6…
if (spec.atNight) result.push("whispering death"); if (spec.seasons && spec.seasons.includes("winter")) result.push("beefy");
I can encode these using a JavaScript data structure of a list of two production rule objects and execute the model with a simple function.
recommendationModel.es6…
export default [ { condition: (spec) => spec.atNight, action: (result) => result.push("whispering death") }, { condition: (spec) => spec.seasons && spec.seasons.includes("winter"), action: (result) => result.push("beefy") } ];
recommender.es6…
import model from './recommendationModel.es6' function executeModel(spec) { let result = []; model .filter((r) => r.condition(spec)) .forEach((r) => r.action(result)); return result; }
recommender.es6…
if (spec.atNight) result.push("whispering death"); if (spec.seasons && spec.seasons.includes("winter")) result.push("beefy");
I can encode these using a JavaScript data structure of a list of two production rule objects and execute the model with a simple function.
recommendationModel.es6…
export default [ { condition: (spec) => spec.atNight, action: (result) => result.push("whispering death") }, { condition: (spec) => spec.seasons && spec.seasons.includes("winter"), action: (result) => result.push("beefy") } ];
recommender.es6…
import model from './recommendationModel.es6' function executeModel(spec) { let result = []; model .filter((r) => r.condition(spec)) .forEach((r) => r.action(result)); return result; }
Here you can see the general form of an adaptive model. We have a data structure that contains the particular logic that we need (
recommendationModel.es6
) together with an engine (executeModel
that takes that data structure and executes it.
This adaptive model is a general implementation of production rules. But our production rules are more constrained than that. For a start all of the actions just add the name of cricket breed to the result, so I can simplify to this.
recommendationModel.es6…
export default [ { condition: (spec) => spec.atNight, result: "whispering death" }, { condition: (spec) => spec.seasons && spec.seasons.includes("winter"), result: "beefy" } ];
recommender.es6…
import model from './recommendationModel.es6'
function executeModel(spec) {
let result = [];
model
.filter((r) => r.condition(spec))
.forEach((r) => result.push(r.result));
return result;
}
With that, I can further simplify the engine by removing the collecting variable.
recommender.es6…
import model from './recommendationModel.es6' function executeModel(spec) { let result = []; return model .filter((r) => r.condition(spec)) .map((r) => r.result); return result; }
That obvious simplification is nice, but the conditions are still JavaScript code, which won't fit our needs for running in a non JavaScript environment. I'll need to replace the condition code with data I can interpret.
Representing the night condition in JSON
recommender.es6…
if (spec.atNight) result.push("whispering death");
I'd like to represent that in JSON as
recommendationModel.json…
[{"condition": "atNight", "result": "whispering death"}]
The first part of making this work is to read the JSON file and make it available to the recommendation logic.
recommendationModel.es6…
import fs from 'fs' let model; export function loadJson() { model = JSON.parse(fs.readFileSync('recommendationModel.json', {encoding: 'utf8'})); } export default function getModel() { return model; }
into this json model
recommendationModel.json…
[ {"condition": "atNight", "result": "value", "resultArgs":["whispering death"]}, {"condition": "seasonIncludes", "conditionArgs": ["winter"], "result": "value", "resultArgs":["beefy"]}, { "condition": "and", "conditionArgs": [ {"condition": "seasonIncludes", "conditionArgs": ["summer"]}, {"condition": "countryIncludedIn", "conditionArgs": ["sparta", "atlantis"]} ], "result": "value", "resultArgs": ["white lightening"] }, { "condition":"seasonIncludes", "conditionArgs": ["summer"], "result": "pickMinDuration", "resultArgs": [[ [ 150, [] ], [ 350, "white lightening" ], [ 570, "little master" ], [ "Infinity", "wall" ] ]] }, { "condition":"not", "conditionArgs": [{"condition":"seasonIncludes", "conditionArgs": ["summer"]}], "result": "pickMinDuration", "resultArgs": [[ [150, [] ], [450, "white lightening" ], ["Infinity", "little master" ] ]] } ]
with the following engine to interpret the json model
recommender.es6…
export default function (spec) { return executeModel(spec, getModel()); } function pickMinDuration(spec, range) { return (spec.minDuration) ? pickFromRange(range, spec.minDuration) : []; } function countryIncludedIn(spec, anArray) { return anArray.includes(spec.country); } function seasonIncludes(spec, arg) { return spec.seasons && spec.seasons.includes(arg); } function executeModel(spec, model) { return _.chain(model) .filter((r) => isActive(r, spec)) .map((r) => result(r, spec)) .flatten() .uniq() .value() } function result(r, spec) { if (r.result === "value") return r.resultArgs[0]; if (r.result === 'pickMinDuration') return pickMinDuration(spec, r.resultArgs[0]); throw new Error("unknown result function: " + r.result) } function isActive(rule, spec) { if (rule.condition === 'atNight') return spec.atNight; if (rule.condition === 'seasonIncludes') return seasonIncludes(spec, rule.conditionArgs[0]); if (rule.condition === 'countryIncludedIn') return rule.conditionArgs.includes(spec.country); if (rule.condition === 'and') return rule.conditionArgs.every((arg) => isActive(arg, spec)); if (rule.condition === 'pickMinDuration') return true; if (rule.condition === 'not') return !isActive(rule.conditionArgs[0], spec); throw new Error("unable to handle " + rule.condition); }
There's another question here: whether the adaptive model is easier to modify than the imperative code. Although it's larger, it is more regular. With a larger set of rules, imperative code's flexibility can let it get more tangled easily, while the limited expressivity of an adaptive model can help keep logic easier to follow.
Reorganizing the model
As I look at the JSON model I think would prefer to reorganize its structure a tad, so that instead of
{ "condition": … "conditionArgs": … "result": … "resultArgs": … }
I'd have
{ "condition": { "name": … "args": … } "result": { "name": … "args": … } }http://martinfowler.com/dslCatalog/adaptiveModel.html
Arrange blocks of code in a data structure to implement an alternative computational model.
You do this by defining a model where the links between elements represent the behavioral relationships of the computational model. This model usually needs references to sections of imperative code. You then run the model either by executing code over it (procedural style) or by executing code within the model itself (object-oriented style).