Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import _ from 'lodash';
2import { type ReadonlyDeep } from 'type-fest';
3
4import {
5 ConditionCompletionOutcome,
6 ConditionFailureOutcome,
7 type ConditionOutcome,
8 type ConditionSetWithResult,
9 type ConditionWithResult,
10 type LeafConditionWithResult,
11} from '../models/rules/RuleModel.js';
12import { type RuleEvaluationContext } from '../rule_engine/RuleEvaluator.js';
13import { type AggregationClause } from '../services/aggregationsService/index.js';
14import {
15 ConditionConjunction,
16 type ConditionSet,
17 type LeafCondition,
18} from '../services/moderationConfigService/index.js';
19import { equalLengthZip } from '../utils/fp-helpers.js';
20import { assertUnreachable } from '../utils/misc.js';
21import type SafeTracer from '../utils/SafeTracer.js';
22import { type NonEmptyArray } from '../utils/typescript-types.js';
23import { getCost, isConditionSet, outcomeToNullableBool } from './condition.js';
24import { runLeafCondition as defaultRunLeafCondition } from './leafCondition.js';
25
26const { sortBy, groupBy } = _;
27
28/**
29 * This takes a ConditionSet, and piece of content, and recursively runs all
30 * the necessary conditions in the ConditionSet to get a final outcome for the
31 * condition set against that piece of content. It returns that outcomes, as
32 * part of a larger object tracing the whole evaluation.
33 *
34 * There are really a bunch of different things going on as part of this process.
35 * First, there's "running" the LeafConditions, which actually involves testing
36 * the content. Then, there's "running" the condition sets, which is really
37 * about choosing the order in which to run the child conditions, and trying to
38 * short circuit that when possible. Then, there's getting the result of a
39 * condition set, which just aggregates the outcomes of the already-run
40 * conditions, in a totally content-agnostic way. Finally, there's also the
41 * process of building the final result, which (in the implementation here) gets
42 * interleaved with the traversal and condition evaluation. It's tempting to see
43 * the traversal (i.e., recursively descending, down to all the leaf conditions)
44 * as it's own thing, which we're sorta duplicating here and in `getCost`, and
45 * which might be able to be centralized into its own function. Then, we'd have
46 * "visitors" or similar that, e.g., compute a condition's cost or build a
47 * condition's result object (as we're returning here). Although, that's not
48 * quite so not super straightforward, because the traversal logic to iterate
49 * through conditions needs to be customized in some cases (e.g., to evaluate
50 * lowest cost first), so that complicates the standard/simple visitor parttern.
51 *
52 * My point is really just that this function is mixing together a lot of
53 * different concerns that we might eventually want to separate. But that
54 * separation is not totally trivial, and our condition evaluation strategy is
55 * very likely to change (e.g., maybe to, at least sometimes/for some conditions,
56 * evaluate conditions in parallel; or, to get much smarter about how we order
57 * conditions to run). Those changes could all break any abstraction we try to
58 * come up with now to decouple these different pieces, so I've decided to
59 * tackle everything in one function for now.
60 *
61 * @returns An object describing whole path of the execution, i.e., the outcome
62 * of each leaf condition, the outcome of the condition sets aggregating
63 * individual leaf condition outcomes, and the outcome of the top-level
64 * condition set. This is ideal for logging and/or visualizing how the
65 * conditions applied to a piece of content.
66 */
67export async function getConditionSetResults(
68 conditionSet: ReadonlyDeep<ConditionSet>,
69 evaluationContext: RuleEvaluationContext,
70 tracer: SafeTracer,
71 runLeafCondition = defaultRunLeafCondition,
72): Promise<Required<ConditionSetWithResult>> {
73 const { conjunction } = conditionSet;
74 const result: ConditionSetWithResult = {
75 conjunction,
76 conditions: [] as unknown as
77 | NonEmptyArray<LeafConditionWithResult>
78 | NonEmptyArray<ConditionSetWithResult>,
79 };
80
81 // The conditions, sorted with lowest cost ones first.
82 const getSignalCost = evaluationContext.getSignalCost.bind(evaluationContext);
83 const conditionCosts = await Promise.all(
84 // We know the function passed to map will never throw synchronously
85 // (which is what the lint rule is trying to guard against), as all it
86 // does is call `getCost`, which is an asnyc function. So, making the
87 // map callback async just poinlessly allocates extra promises.
88 // eslint-disable-next-line @typescript-eslint/promise-function-async
89 conditionSet.conditions.map((c) => getCost(c, getSignalCost)),
90 );
91
92 const sortedConditions = sortBy(
93 equalLengthZip<
94 ReadonlyDeep<ConditionSet> | ReadonlyDeep<LeafCondition>,
95 number
96 >(conditionSet.conditions, conditionCosts),
97 (it) => it[1],
98 ).map((it) => it[0]);
99
100 // The final conditionset outcome. Might get set early
101 // if we can determine it before evaluating all conditions.
102 let finalOutcome: ConditionOutcome | undefined;
103
104 // This is basically mapping `conditions` to ConditionWithResult, and putting
105 // the mapped array in result.conditions. But we don't use `map` because we
106 // want to run the conditions in sequence (i.e., with an `await` on each loop
107 // iteration), and map would run them in parallel.
108 for (const condition of sortedConditions) {
109 // If we already know the final outcome for the condition set, then we can
110 // skip all subsequent conditions, and a condition that's skipped has an
111 // identical representation for its ConditionWithResult, so we just push
112 // the skipped condition into result.conditions.
113 if (finalOutcome !== undefined) {
114 result.conditions.push(condition as any);
115 continue;
116 }
117
118 const conditionWithResult: ReadonlyDeep<ConditionWithResult> &
119 Required<Pick<ReadonlyDeep<ConditionWithResult>, 'result'>> =
120 isConditionSet(condition)
121 ? await getConditionSetResults(condition, evaluationContext, tracer)
122 : {
123 ...condition,
124 result: await runLeafCondition(
125 condition,
126 evaluationContext,
127 // eslint-disable-next-line no-loop-func
128 ).catch((e) => {
129 // If evaluating a condition fails, we're eventually going to want
130 // to retry before we give up but, for now, we just mark the result
131 // as failed and move on.
132 const activeSpan = tracer.getActiveSpan();
133 if (activeSpan?.isRecording()) {
134 activeSpan.recordException(e);
135 }
136 return { outcome: ConditionFailureOutcome.ERRORED };
137 }),
138 };
139
140 result.conditions.push(conditionWithResult as any);
141 // console.log('RESULT ' + conditionWithResult.result.outcome);
142
143 // Attempt to determine the result for the whole condition set from the
144 // outcomes so far. If we can, save that result to skip running each
145 // condition for the rest of the loop
146 const conditionSetOutcome = tryGetOutcomeFromPartialOutcomes(
147 result.conditions.map((c) => c.result!.outcome),
148 conjunction,
149 );
150
151 if (conditionSetOutcome !== undefined) {
152 finalOutcome = conditionSetOutcome;
153 }
154 }
155
156 return {
157 ...result,
158 result: {
159 outcome:
160 finalOutcome ??
161 getConditionSetOutcome(
162 result.conditions.map((c) => c.result!.outcome),
163 conjunction,
164 ),
165 },
166 };
167}
168
169/**
170 * Aggregate the outcomes of child conditions to get a single outcome
171 * for a conditiion set, based on its conjunction.
172 */
173export function getConditionSetOutcome(
174 outcomes: ConditionOutcome[],
175 conjunction: ConditionConjunction,
176) {
177 const {
178 false: falseOutcomes,
179 true: trueOutcomes,
180 null: nullOutcomes,
181 } = groupBy(outcomes, outcomeToNullableBool) as {
182 true?: NonEmptyArray<ConditionOutcome>;
183 false?: NonEmptyArray<ConditionOutcome>;
184 null?: NonEmptyArray<ConditionOutcome>;
185 };
186
187 switch (conjunction) {
188 case ConditionConjunction.AND:
189 // With AND, a single FALSE swallows NULL. I.e., NULL && FALSE = FALSE.
190 // But no false + no null = passed.
191 return falseOutcomes && falseOutcomes.length > 0
192 ? falseOutcomes[0]
193 : nullOutcomes
194 ? nullOutcomes[0]
195 : ConditionCompletionOutcome.PASSED;
196 case ConditionConjunction.OR:
197 // With OR, it's TRUE that swallows null (TRUE || NULL = TRUE), while
198 // we have to propogate any NULLs when there's no true outcomes.
199 return trueOutcomes && trueOutcomes.length > 0
200 ? ConditionCompletionOutcome.PASSED
201 : nullOutcomes
202 ? nullOutcomes[0]
203 : ConditionCompletionOutcome.FAILED;
204 case ConditionConjunction.XOR:
205 // Similar to OR, but we must ensure there's one true with no NULLs.
206 return trueOutcomes?.length === 1 && !nullOutcomes
207 ? ConditionCompletionOutcome.PASSED
208 : (!trueOutcomes || trueOutcomes.length === 1) && nullOutcomes
209 ? nullOutcomes[0]
210 : ConditionCompletionOutcome.FAILED;
211 default:
212 return assertUnreachable(conjunction);
213 }
214}
215
216/**
217 * Sometimes, a subset of the conditions in a condition set can be evaluated,
218 * and that's enough to know the result of the whole set. E.g., if the set uses
219 * AND as its conjunction, then we know that the set FAILED upon encountering
220 * the first FAILED or INAPPLICABLE condition. Similarly, if the set uses OR, we
221 * know it PASSED upon encountering the first PASSED condition.
222 *
223 * This function takes some condition outcomes, and a condition set conjunction,
224 * and returns undefined if those outcomes aren't enough information to
225 * definitely determine the result of a condition set using that conjunction.
226 * If the given outcomes are enough info, though, it returns the ConditionOutcome
227 * that must apply to the set.
228 *
229 * For AND and OR, it's actually a bit wasteful to look at all the condition
230 * outcomes, as we could just look at each individual outcome as it's produced
231 * to decide whether to short circuit. But the overhead is truly negligible, and
232 * this design lets us support conjunctions like XOR, which isn't decided until
233 * we see a second passing condition.
234 */
235export function tryGetOutcomeFromPartialOutcomes(
236 conditionOutcomes: ConditionOutcome[],
237 conjunction: ConditionConjunction,
238) {
239 const { false: falseOutcomes, true: trueOutcomes } = groupBy(
240 conditionOutcomes,
241 outcomeToNullableBool,
242 ) as {
243 true?: NonEmptyArray<ConditionOutcome>;
244 false?: NonEmptyArray<ConditionOutcome>;
245 };
246
247 switch (conjunction) {
248 case ConditionConjunction.AND:
249 return falseOutcomes ? falseOutcomes[0] : undefined;
250 case ConditionConjunction.OR:
251 return trueOutcomes ? ConditionCompletionOutcome.PASSED : undefined;
252 case ConditionConjunction.XOR:
253 return (trueOutcomes?.length ?? 0) > 1
254 ? ConditionCompletionOutcome.FAILED
255 : undefined;
256 default:
257 return assertUnreachable(conjunction);
258 }
259}
260
261export function getAllAggregationsInConditionSet(
262 conditionSet: ReadonlyDeep<ConditionSet>,
263): ReadonlyDeep<AggregationClause>[] {
264 return conditionSet.conditions.flatMap((condition) => {
265 if (isConditionSet(condition)) {
266 return getAllAggregationsInConditionSet(condition);
267 }
268 const sig = condition.signal;
269 const args =
270 sig?.type === 'AGGREGATION' ? sig.args : undefined;
271 return args != null ? [args.aggregationClause] : [];
272 });
273}