Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 557ff54b2b435e5f1e789c6a8a4e1bebf2d7deb6 273 lines 12 kB view raw
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}