UNPKG

6.97 kBJavaScriptView Raw
1import _ from 'lodash';
2import { reduceDecisionRules } from './reducer';
3import { tzFromOffset } from './time';
4import { CraftAiDecisionError, CraftAiNullDecisionError, CraftAiUnknownError } from './errors';
5import { formatDecisionRules, formatProperty } from './formatter';
6import isTimezone, { getTimezoneKey } from './timezones';
7
8const DECISION_FORMAT_VERSION = '1.1.0';
9
10const OPERATORS = {
11 'is': (context, value) => context === value,
12 '>=': (context, value) => context * 1 >= value,
13 '<': (context, value) => context * 1 < value,
14 '[in[': (context, value) => {
15 let context_val = context * 1;
16 let from = value[0];
17 let to = value[1];
18 //the interval is not looping
19 if (from < to) {
20 return (context_val >= from && context_val < to);
21 }
22 //the interval IS looping
23 else {
24 return (context_val >= from || context_val < to);
25 }
26 }
27};
28
29const VALUE_VALIDATOR = {
30 continuous: (value) => _.isFinite(value),
31 enum: (value) => _.isString(value),
32 timezone: (value) => isTimezone(value),
33 time_of_day: (value) => _.isFinite(value) && value >= 0 && value < 24,
34 day_of_week: (value) => _.isInteger(value) && value >= 0 && value <= 6,
35 day_of_month: (value) => _.isInteger(value) && value >= 1 && value <= 31,
36 month_of_year: (value) => _.isInteger(value) && value >= 1 && value <= 12
37};
38
39function decideRecursion(node, context) {
40 // Leaf
41 if (!(node.children && node.children.length)) {
42 if (node.predicted_value == null) {
43 return {
44 predicted_value: undefined,
45 confidence: undefined,
46 decision_rules: [],
47 error: {
48 name: 'CraftAiNullDecisionError',
49 message: 'Unable to take decision: the decision tree has no valid predicted value for the given context.'
50 }
51 };
52 }
53
54 let leafNode = {
55 predicted_value: node.predicted_value,
56 confidence: node.confidence || 0,
57 decision_rules: []
58 };
59
60 if (!_.isUndefined(node.standard_deviation)) {
61 leafNode.standard_deviation = node.standard_deviation;
62 }
63
64 return leafNode;
65 }
66
67 // Regular node
68 const matchingChild = _.find(
69 node.children,
70 (child) => {
71 const decision_rule = child.decision_rule;
72 const property = decision_rule.property;
73 if (_.isUndefined(context[property])) {
74 // Should not happen
75 return {
76 predicted_value: undefined,
77 confidence: undefined,
78 error: {
79 name: 'CraftAiUnknownError',
80 message: `Unable to take decision: property '${property}' is missing from the given context.`
81 }
82 };
83 }
84
85 return OPERATORS[decision_rule.operator](context[property], decision_rule.operand);
86 }
87 );
88
89 // matching child property error
90 if (matchingChild && matchingChild.error) {
91 return matchingChild;
92 }
93
94 if (_.isUndefined(matchingChild)) {
95 // Should only happens when an unexpected value for an enum is encountered
96 const operandList = _.uniq(_.map(_.values(node.children), (child) => child.decision_rule.operand));
97 const property = _.head(node.children).decision_rule.property;
98 return {
99 predicted_value: undefined,
100 confidence: undefined,
101 decision_rules: [],
102 error: {
103 name: 'CraftAiNullDecisionError',
104 message: `Unable to take decision: value '${context[property]}' for property '${property}' doesn't validate any of the decision rules.`,
105 metadata: {
106 property: property,
107 value: context[property],
108 expected_values: operandList
109 }
110 }
111 };
112 }
113
114 // matching child found: recurse !
115 const result = decideRecursion(matchingChild, context);
116
117 let finalResult = _.extend(result, {
118 decision_rules: [matchingChild.decision_rule].concat(result.decision_rules)
119 });
120
121 return finalResult;
122}
123
124function checkContext(configuration) {
125 // Extract the required properties (i.e. those that are not the output)
126 const expectedProperties = _.difference(
127 _.keys(configuration.context),
128 configuration.output
129 );
130
131 // Build a context validator
132 const validators = _.map(expectedProperties, (property) => {
133 const otherValidator = () => {
134 console.warn(`WARNING: "${configuration.context[property].type}" is not a supported type. Please refer to the documention to see what type you can use`);
135 return true;
136 };
137 return {
138 property,
139 type: configuration.context[property].type,
140 validator: VALUE_VALIDATOR[configuration.context[property].type] || otherValidator
141 };
142 });
143
144 return (context) => {
145 const { badProperties, missingProperties } = _.reduce(
146 validators,
147 ({ badProperties, missingProperties }, { property, type, validator }) => {
148 const value = context[property];
149 if (value === undefined) {
150 missingProperties.push(property);
151 }
152 else if (!validator(value)) {
153 badProperties.push({ property, type, value });
154 }
155 return { badProperties, missingProperties };
156 },
157 { badProperties: [], missingProperties: [] }
158 );
159
160 if (missingProperties.length || badProperties.length) {
161 const messages = _.concat(
162 _.map(missingProperties, (property) => `expected property '${property}' is not defined`),
163 _.map(badProperties, ({ property, type, value }) => `'${value}' is not a valid value for property '${property}' of type '${type}'`)
164 );
165 throw new CraftAiDecisionError({
166 message: `Unable to take decision, the given context is not valid: ${messages.join(', ')}.`,
167 metadata: _.assign({}, missingProperties.length && { missingProperties }, badProperties.length && { badProperties })
168 });
169 }
170 };
171}
172
173function decide(configuration, trees, context) {
174 checkContext(configuration)(context);
175 // Convert timezones as integers to the standard +/-hh:mm format
176 // This should only happen when no Time() object is passed to the interpreter
177 const timezoneProperty = getTimezoneKey(configuration.context);
178 if (!_.isUndefined(timezoneProperty)) {
179 context[timezoneProperty] = tzFromOffset(context[timezoneProperty]);
180 }
181 return {
182 _version: DECISION_FORMAT_VERSION,
183 context,
184 output: _.assign(..._.map(configuration.output, (output) => {
185 let decision = decideRecursion(trees[output], context);
186 if (decision.error) {
187 switch (decision.error.name) {
188 case 'CraftAiNullDecisionError':
189 throw new CraftAiNullDecisionError({
190 message: decision.error.message,
191 metadata: _.extend(decision.error.metadata, {
192 decision_rules: decision.decision_rules
193 })
194 });
195 default:
196 throw new CraftAiUnknownError({
197 message: decision.error.message
198 });
199 }
200 }
201 return {
202 [output]: decision
203 };
204 }))
205 };
206}
207
208export { formatDecisionRules, formatProperty, reduceDecisionRules, decide };
\No newline at end of file