From f12e9e1d47c0fd5e12a5970c3273d75306f938f7 Mon Sep 17 00:00:00 2001 From: samueltlg Date: Mon, 14 Jul 2025 11:10:56 +0100 Subject: [PATCH 1/6] fix: stop matching recursively against top-level expr. in 'applyRule' Aside from this previously being a needless operation (when 'recursive' was specified as 'true') - previously this sometimes erroneously return 'true' for a recursive/operand match (already-matched operand which has been replaced by an expression with the same structure). --- src/compute-engine/boxed-expression/rules.ts | 5 ++++- src/compute-engine/global-types.ts | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/compute-engine/boxed-expression/rules.ts b/src/compute-engine/boxed-expression/rules.ts index d94fdc80..844d317c 100755 --- a/src/compute-engine/boxed-expression/rules.ts +++ b/src/compute-engine/boxed-expression/rules.ts @@ -703,6 +703,8 @@ export function applyRule( return subExpr.value; }); + // At least one operand (directly or recursively) matched: but continue onwards to match against + // the top-level expr., test against any 'condition', et cetera. if (operandsMatched) expr = expr.engine.function(expr.operator, newOps, { canonical }); } @@ -728,7 +730,7 @@ export function applyRule( onBeforeMatch?.(rule, expr); const sub = match - ? expr.match(match, { substitution, ...options, useVariations }) + ? expr.match(match, { substitution, useVariations, recursive: false }) : {}; // If the `expr` does not match the pattern, the rule doesn't apply @@ -762,6 +764,7 @@ export function applyRule( } } + //@note: '.subs()' acts like an expr. 'clone' here (in case of an empty substitution) const result = typeof replace === 'function' ? replace(expr, sub) diff --git a/src/compute-engine/global-types.ts b/src/compute-engine/global-types.ts index 20254cbc..268bf499 100644 --- a/src/compute-engine/global-types.ts +++ b/src/compute-engine/global-types.ts @@ -1063,6 +1063,10 @@ export interface BoxedExpression { * * :::info[Note] * Applicable to canonical and non-canonical expressions. + * + * If this is a function, an empty substitution is given, and the computed value of `canonical` + * does not differ from that of this expr.: then a call this method is analagous to requesting a + * *clone*. * ::: * */ From 625ceb8a9768ccae817863437ca669ec991c2440 Mon Sep 17 00:00:00 2001 From: samueltlg Date: Mon, 14 Jul 2025 12:27:41 +0100 Subject: [PATCH 2/6] fix/refactor: accounting for 'canonical' during rule application - Also account for canonical-status of the *replacement*-expr. for successful replacements. - If 'canonical' is not specified as an option during replacement: - There is differentiation between canonicalizing any direct replacements (i.e. these may be recursive), and the overall/input expr. - ^In this case, each canonical-status, of sub-exprs. will generally be *preserved*: but will 'opportunistically' mark expressions as canonical (e.g., because replaced operands are now canonical) --- src/compute-engine/boxed-expression/rules.ts | 41 +++++++++++++++----- src/compute-engine/global-types.ts | 16 ++++++-- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/compute-engine/boxed-expression/rules.ts b/src/compute-engine/boxed-expression/rules.ts index 844d317c..45c25da8 100755 --- a/src/compute-engine/boxed-expression/rules.ts +++ b/src/compute-engine/boxed-expression/rules.ts @@ -1,5 +1,6 @@ import type { Expression } from '../../math-json/types'; +import { _BoxedExpression } from './abstract-boxed-expression'; import type { BoxedRule, BoxedRuleSet, @@ -353,7 +354,10 @@ function parseRulePart( ); return expr; } - return ce.box(rule, { canonical: options?.canonical ?? false }); + const canonical = + options?.canonical ?? + (rule instanceof _BoxedExpression ? rule.isCanonical : false); + return ce.box(rule, { canonical }); } /** A rule can be expressed as a string of the form @@ -689,8 +693,7 @@ export function applyRule( substitution: BoxedSubstitution, options?: Readonly> ): RuleStep | null { - const canonical = - options?.canonical ?? (expr.isCanonical || expr.isStructural); + let canonical = options?.canonical ?? (expr.isCanonical || expr.isStructural); let operandsMatched = false; @@ -705,8 +708,18 @@ export function applyRule( // At least one operand (directly or recursively) matched: but continue onwards to match against // the top-level expr., test against any 'condition', et cetera. - if (operandsMatched) + if (operandsMatched) { + // If new/replaced operands are all canonical, and options do not explicitly specify canonical + // status, then should be safe to mark as fully-canonical + if ( + !canonical && + options?.canonical === undefined && + newOps.every((x) => x.isCanonical) + ) + canonical = true; + expr = expr.engine.function(expr.operator, newOps, { canonical }); + } } // eslint-disable-next-line prefer-const @@ -715,12 +728,11 @@ export function applyRule( if (canonical && match) { const awc = getWildcards(match); - const originalMatch = match; - match = match.canonical; - const bwc = getWildcards(match); + const canonicalMatch = match.canonical; + const bwc = getWildcards(canonicalMatch); if (!awc.every((x) => bwc.includes(x))) throw new Error( - `\n| Invalid rule "${rule.id}"\n| The canonical form of ${dewildcard(originalMatch).toString()} is "${dewildcard(match).toString()}" and it does not contain all the wildcards of the original match.\n| This could indicate that the match expression in canonical form is already simplified and this rule may not be necessary` + `\n| Invalid rule "${rule.id}"\n| The canonical form of ${dewildcard(canonicalMatch).toString()} is "${dewildcard(match).toString()}" and it does not contain all the wildcards of the original match.\n| This could indicate that the match expression in canonical form is already simplified and this rule may not be necessary` ); } @@ -764,6 +776,15 @@ export function applyRule( } } + // Have a (direct) match: in this case, consider the canonical-status of the replacement, too. + if ( + !canonical && + options?.canonical === undefined && + replace instanceof _BoxedExpression && + replace.isCanonical + ) + canonical = true; + //@note: '.subs()' acts like an expr. 'clone' here (in case of an empty substitution) const result = typeof replace === 'function' @@ -778,6 +799,8 @@ export function applyRule( if (isRuleStep(result)) return canonical ? { ...result, value: result.value.canonical } : result; + // (Need to request the canonical variant to account for case of a custom replace: which may not + // have returned canonical.) return { value: canonical ? result.canonical : result, because }; } @@ -786,7 +809,7 @@ export function applyRule( * and the set of rules that were applied. * * The `replace` function can be used to apply a rule to a non-canonical - * expression. @fixme: account for options.canonical + * expression. * */ export function replace( diff --git a/src/compute-engine/global-types.ts b/src/compute-engine/global-types.ts index 268bf499..0cc5f1eb 100644 --- a/src/compute-engine/global-types.ts +++ b/src/compute-engine/global-types.ts @@ -1063,7 +1063,7 @@ export interface BoxedExpression { * * :::info[Note] * Applicable to canonical and non-canonical expressions. - * + * * If this is a function, an empty substitution is given, and the computed value of `canonical` * does not differ from that of this expr.: then a call this method is analagous to requesting a * *clone*. @@ -1107,8 +1107,18 @@ export interface BoxedExpression { * * See also `expr.subs()` for a simple substitution of symbols. * - * If `options.canonical` is not set, the result is canonical if `this` - * is canonical. + * Procedure for the determining the canonical-status of the input expression and replacements: + * + * - If `options.canonical` is set, the *entire expr.* is canonicalized to this degree: whether + * the replacement occurs at the top-level, or within/recursively. + * + * - If otherwise, the *direct replacement will be canonical* if either the 'replaced' expression + * is canonical, or the given replacement (- is a BoxedExpression and -) is canonical. + * Notably also, if this replacement takes place recursively (not at the top-level), then exprs. + * containing the replaced expr. will still however have their (previous) canonical-status + * *preserved*... unless this expr. was previously non-canonical, and *replacements have resulted + * in canonical operands*. In this case, an expr. meeting this criteria will be updated to + * canonical status. (Canonicalization is opportunistic here, in other words). * * :::info[Note] * Applicable to canonical and non-canonical expressions. From c3e14fedaf0e2ec8fcf8e09d39c14a2030a0e4b9 Mon Sep 17 00:00:00 2001 From: samueltlg Date: Wed, 16 Jul 2025 02:25:33 +0100 Subject: [PATCH 3/6] !fix: parsing of sequence-wildcard syntax in LaTeX pattern-matching --- src/compute-engine/boxed-expression/rules.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/compute-engine/boxed-expression/rules.ts b/src/compute-engine/boxed-expression/rules.ts index 45c25da8..385f672b 100755 --- a/src/compute-engine/boxed-expression/rules.ts +++ b/src/compute-engine/boxed-expression/rules.ts @@ -419,7 +419,7 @@ function parseRule( // Check for conditions const conditions = parseModifierExpression(parser); - if (conditions === null) return null; + if (conditions === null) return `${prefix}${id}`; if (!wildcardConditions[id]) wildcardConditions[id] = conditions; else wildcardConditions[id] += ',' + conditions; From 4eb640fdb2fbc461a2a131c60d8dc364896c4d44 Mon Sep 17 00:00:00 2001 From: samueltlg Date: Wed, 16 Jul 2025 02:27:33 +0100 Subject: [PATCH 4/6] refactor: clearer argument matching logic for pattern matching - easier to follow (at least from outsider's perspective) - removes unnecessary/duplicate stmts. --- src/compute-engine/boxed-expression/match.ts | 36 +++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/compute-engine/boxed-expression/match.ts b/src/compute-engine/boxed-expression/match.ts index cd6f6139..10b804a5 100644 --- a/src/compute-engine/boxed-expression/match.ts +++ b/src/compute-engine/boxed-expression/match.ts @@ -339,39 +339,41 @@ function matchArguments( if (argName !== null) { if (argName.startsWith('__')) { - // Match 0 or more expressions (__) or 1 or more (___) - let j = 0; // Index in subject + // Match 1 or more expressions (__) or 0 or more (___) + /** Qty. of operands consumed (of those remaining to be matched). */ + let j = 0; if (patterns[i + 1] === undefined) { // No more args in the pattern after, go till the end - j = ops.length + 1; + j = ops.length; } else { - // Capture till the next matching arg in the pattern + // Capture til' the first matching arg. of the next pattern let found = false; + const nextPat = patterns[i + 1]; while (!found && j < ops.length) { - found = - matchOnce(ops[j], patterns[i + 1], result, options) !== null; - j += 1; + found = matchOnce(ops[j], nextPat, result, options) !== null; + if (!found) j += 1; } - if (!found && argName.startsWith('___')) return null; + // The next pattern does not match against any of the remaining ops.. Unless the next + // match is optional, then can assume no overall match. + if (!found && !wildcardName(nextPat)?.startsWith('___')) return null; } - // Unless we had a optional wildcard (matching 0 or more), we must have - // found at least one match - if (!argName.startsWith('___') && j <= 1) return null; - // Determine the value to return for the wildcard let value: BoxedExpression; - if (j <= 1) { + if (j < 1) { + // A standard/non-optional Sequence Wildcard with no match... + if (!argName.startsWith('___') && j < 1) return null; + // Otherwise must be an optional-sequence match: this is permitted. if (expr.operator === 'Add') value = ce.Zero; else if (expr.operator === 'Multiply') value = ce.One; else value = ce.Nothing; - } else if (j === 2) { - // Capturing a single element - if (ops.length === 0) return null; + } else if (j === 1) { + // Capturing a single element/operand value = ops.shift()!; } else { + // >1 operands captured const def = ce.lookupDefinition(expr.operator); - const args = ops.splice(0, j - 1); + const args = ops.splice(0, j); if (def && isOperatorDef(def) && def.operator.associative) { value = ce.function(expr.operator, args, { canonical: false }); } else { From 03d7284648eb474f6940ac1f54ade63e670c72ac Mon Sep 17 00:00:00 2001 From: samueltlg Date: Thu, 24 Jul 2025 19:34:42 +0100 Subject: [PATCH 5/6] refactor: optimise pattern matching via reducing arg. permutations --- src/common/utils.ts | 39 +++++++++++++++--- .../boxed-expression/boxed-patterns.ts | 41 +++++++++++++++++++ src/compute-engine/boxed-expression/match.ts | 30 +++++++++++++- 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/src/common/utils.ts b/src/common/utils.ts index 9f5a3ee2..f1fa0d24 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,11 +1,39 @@ -export function permutations( - xs: ReadonlyArray +/** + * + * + * + * @export + * @template T + * @param xs + * @param [condition] + * @returns + */ +export function permutations( + xs: ReadonlyArray, + condition?: ( + xs: ReadonlyArray /* , generated: Set | Set<[Y,T]>? */ + ) => boolean + // cacheKey?: (T) => Y ): ReadonlyArray> { const result: ReadonlyArray[] = []; - const permute = (arr, m = []) => { + const permute = (arr: T[], m: T[] = []) => { if (arr.length === 0) { - result.push(m); + if (!condition || condition(m)) { + // Use spread operator to create a shallow copy of m + result.push([...m]); + } } else { for (let i = 0; i < arr.length; i++) { const curr = arr.slice(); @@ -15,7 +43,8 @@ export function permutations( } }; - permute(xs); + //@fix: (typing) + permute(xs as T[]); return result; } diff --git a/src/compute-engine/boxed-expression/boxed-patterns.ts b/src/compute-engine/boxed-expression/boxed-patterns.ts index 15fefbbc..c628e364 100755 --- a/src/compute-engine/boxed-expression/boxed-patterns.ts +++ b/src/compute-engine/boxed-expression/boxed-patterns.ts @@ -27,3 +27,44 @@ export function wildcardName(expr: BoxedExpression): string | null { return null; } + +/** + * + * + * + * @export + * @param expr + * @returns + */ +export function wildcardType( + expr: BoxedExpression | string +): 'Wildcard' | 'Sequence' | 'OptionalSequence' | null { + + if (typeof expr === 'string') { + if (expr.startsWith('_')) { + if (expr.startsWith('__')) { + if (expr.startsWith('___')) return 'Sequence'; + return 'OptionalSequence'; + } + return 'Wildcard'; + } + return null; + } + + if (expr.symbol !== null) { + const symbol = expr.symbol!; + if (!symbol.startsWith('_')) return null; + if (!symbol.startsWith('__')) return 'Wildcard'; + return symbol.startsWith('___') ? 'OptionalSequence' : 'Sequence'; + } + + if (expr.isFunctionExpression) { + if (expr.operator === 'Wildcard') return 'Wildcard'; + if (expr.operator === 'WildcardSequence') return 'Sequence'; + if (expr.operator === 'WildcardOptionalSequence') return 'OptionalSequence'; + } + + return null; +} diff --git a/src/compute-engine/boxed-expression/match.ts b/src/compute-engine/boxed-expression/match.ts index 10b804a5..cfdea8f3 100644 --- a/src/compute-engine/boxed-expression/match.ts +++ b/src/compute-engine/boxed-expression/match.ts @@ -6,7 +6,7 @@ import type { import { permutations } from '../../common/utils'; -import { isWildcard, wildcardName } from './boxed-patterns'; +import { isWildcard, wildcardName, wildcardType } from './boxed-patterns'; import { isOperatorDef } from './utils'; function hasWildcards(expr: string | BoxedExpression): boolean { @@ -306,7 +306,33 @@ function matchPermutation( options: PatternMatchOptions ): BoxedSubstitution | null { console.assert(expr.operator === pattern.operator); - const patterns = permutations(pattern.ops!); + + // Avoid redundant permutations: + // This condition ensures various wildcard sub-permutations - namely a sequence followed by + // another sequence or universal, are skipped. + // ›Consequently, a pattern such as `['Add', 1, 2, '__a', 4, '__b', 7, '_']`, notably with only 3 + // non-optional wildcards, yields only *2040* permutations, in contrast to *5040* (i.e. 7!). + //!@todo: + // - Further optimizations certainly possible here...: consider sub-sequences of expressions with + // same values (particularly number/symbol literals). At an extreme, consider ['Add', 1, 1, 1, + // 1, 1]. Such a check would reduce permutations from 120 to 1. + // - ^Another, may be the req. of a matching qty. of operands (with expr.), in the case of no + // sequence wildcards in pattern. + const cond = ( + xs: ReadonlyArray /* , generated: Set */ + ) => + !xs.some( + (x, index) => + isWildcard(x) && + wildcardType(x) === 'Sequence' && + xs[index + 1] && + isWildcard(xs[index + 1]) && + (wildcardType(xs[index + 1]) === 'Wildcard' || + wildcardType(xs[index + 1]) === 'Sequence') + ); + + const patterns = permutations(pattern.ops!, cond); + for (const pat of patterns) { const result = matchArguments(expr, pat, substitution, options); if (result !== null) return result; From 68f23c3c94f2d780f4f4a9aa83ed431320a08bfc Mon Sep 17 00:00:00 2001 From: samueltlg Date: Thu, 31 Jul 2025 14:58:22 +0100 Subject: [PATCH 6/6] !fix/optimise: patterns with sequence wildcard matching (various cases) - In essence, more-or-less 'fixes' expr. matching behaviour for patterns involving sequences. These changes result in the following variety of cases successfully matching where they would not have done prior: - Sequence wildcards matching >1 operand, e.g.: - ['Add', '__a', 'x'] will now match ['Add', 1, 2, 3, 'x'] - Multiple sequence wildcards (at the same level), e.g.: - ['Tuple', 1, '__a', 4, '__b', 7, '__c'] will now match ['Tuple', 1, 2, 3, 4, 5, 6, 7, 8] - Handles behaviour, and operand/expression capturing for cases of a regular sequence wildcard, followed by one+ optional-seq. wildcards, e.g.: - For ['Multiply', '__f', '___g', '___h'], 'f' will now match 'greedily' and essentially 'merge' with following sequences, such that 'g' and 'h' each capture '0' operands/each: this experientially being the preferred/expected behavior. (^note that sequences of (regular) sequences do not need to be accounted for since these are considered 'invalid' anyway (a subsequent commit set to account for this)) The fact of these cases failing to match has hitherto been obscured on account of absence of tests (these have now been added). Changes/fixes have been achieved by trying permutations of quantity of operands/expressions matched by sequence wildcards (this logic appears to _mistakenly_ have been absent prior), as well as special 'lookahead' checks for cases of 'regular/optional sequence' cards. 'matchArguments' - where the majority of these changes are situated, has (should) now also be refactored for readability. - 'patterns.test.ts': re-writes, almost entirely, the test cases & structure throughout, including addition of/more particular tests, increased qty. of matchers within each, & a healthy handful of controls. - Also: - Reverts/fixes the condition of argument matching skipping pattern permutations with sequences/sub-sequences consisting of a regular sequence wildcard followed by a universal ('_') wildcard (consider ['Add', '__a', '_b']. Whilst in some sensitive intuitive - if considering sequences as 'greedy'-matching - this sequence of wildcards clearly has matching utility for some cases (notably this was breaking some existing tests, too). --- src/api.md | 43 +- .../boxed-expression/boxed-patterns.ts | 12 +- src/compute-engine/boxed-expression/match.ts | 329 ++++++-- .../boxed-expression/validate.ts | 9 + src/compute-engine/global-types.ts | 19 +- test/compute-engine/patterns.test.ts | 761 +++++++++++++++--- 6 files changed, 990 insertions(+), 183 deletions(-) diff --git a/src/api.md b/src/api.md index 99fb7d03..10cb833d 100644 --- a/src/api.md +++ b/src/api.md @@ -1,4 +1,4 @@ - +# 01 ## Compute Engine @@ -1273,6 +1273,10 @@ is canonical. :::info[Note] Applicable to canonical and non-canonical expressions. + +If this is a function, an empty substitution is given, and the computed value of `canonical` +does not differ from that of this expr.: then a call this method is analagous to requesting a +*clone*. ::: ####### sub @@ -1344,11 +1348,24 @@ Transform the expression by applying one or more replacement rules: See also `expr.subs()` for a simple substitution of symbols. -If `options.canonical` is not set, the result is canonical if `this` -is canonical. +Procedure for the determining the canonical-status of the input expression and replacements: + +- If `options.canonical` is set, the *entire expr.* is canonicalized to this degree: whether +the replacement occurs at the top-level, or within/recursively. + +- If otherwise, the *direct replacement will be canonical* if either the 'replaced' expression +is canonical, or the given replacement (- is a BoxedExpression and -) is canonical. +Notably also, if this replacement takes place recursively (not at the top-level), then exprs. +containing the replaced expr. will still however have their (previous) canonical-status +*preserved*... unless this expr. was previously non-canonical, and *replacements have resulted +in canonical operands*. In this case, an expr. meeting this criteria will be updated to +canonical status. (Canonicalization is opportunistic here, in other words). :::info[Note] Applicable to canonical and non-canonical expressions. + +To specify a match for single symbol (non wildcard), it must be boxed (e.g. `{ match: +ce.box('x'), ... }`), but it is suggested to use method *'subs()'* for this. ::: ####### rules @@ -2647,9 +2664,9 @@ type PatternMatchOptions = { Control how a pattern is matched to an expression. -- `substitution`: if present, assumes these values for the named wildcards, - and ensure that subsequent occurrence of the same wildcard have the same - value. +- `substitution`: if present, assumes these values for a subset of + named wildcards, and ensure that subsequent occurrence of the same + wildcard have the same value. - `recursive`: if true, match recursively, otherwise match only the top level. - `useVariations`: if false, only match expressions that are structurally identical. @@ -2784,18 +2801,22 @@ type Rule = }; ``` -A rule describes how to modify an expressions that matches a pattern `match` +A rule describes how to modify an expression that matches a pattern `match` into a new expression `replace`. - `x-1` \( \to \) `1-x` - `(x+1)(x-1)` \( \to \) `x^2-1 -The patterns can be expressed as LaTeX strings or a MathJSON expressions. +The patterns can be expressed as LaTeX strings or `SemiBoxedExpression`'s. +Alternatively, match/replace logic may be specified by a `RuleFunction`, allowing both custom +logic/conditions for the match, and either a *BoxedExpression* (or `RuleStep` if being +descriptive) for the replacement. As a shortcut, a rule can be defined as a LaTeX string: `x-1 -> 1-x`. The expression to the left of `->` is the `match` and the expression to the right is the `replace`. When using LaTeX strings, single character variables -are assumed to be wildcards. +are assumed to be wildcards. The rule LHS ('match') and RHS ('replace') may also be supplied +separately: in this case following the same rules. When using MathJSON expressions, anonymous wildcards (`_`) will match any expression. Named wildcards (`_x`, `_a`, etc...) will match any expression @@ -8042,7 +8063,7 @@ valueOf(): string - +# 02 ## MathJSON @@ -8213,7 +8234,7 @@ The dictionary and function nodes can contain expressions themselves. - +# 03 ## Type diff --git a/src/compute-engine/boxed-expression/boxed-patterns.ts b/src/compute-engine/boxed-expression/boxed-patterns.ts index c628e364..ddd81e87 100755 --- a/src/compute-engine/boxed-expression/boxed-patterns.ts +++ b/src/compute-engine/boxed-expression/boxed-patterns.ts @@ -11,6 +11,14 @@ export function isWildcard(expr: BoxedExpression): expr is BoxedSymbol { ); } +/** + * Return the string representing this wildcard, including any optional (one-character) name, or + * `null` if not a wildcard expression. + * + * @export + * @param expr + * @returns + */ export function wildcardName(expr: BoxedExpression): string | null { if (expr.symbol?.startsWith('_')) return expr.symbol; @@ -31,7 +39,9 @@ export function wildcardName(expr: BoxedExpression): string | null { /** * * * * @export diff --git a/src/compute-engine/boxed-expression/match.ts b/src/compute-engine/boxed-expression/match.ts index cfdea8f3..7f934e5e 100644 --- a/src/compute-engine/boxed-expression/match.ts +++ b/src/compute-engine/boxed-expression/match.ts @@ -20,6 +20,20 @@ function hasWildcards(expr: string | BoxedExpression): boolean { return false; } +/** + * Return a new substitution based on arg. `substitution`, but with wildcard (of value *expr*) + * added. + * Returns given *substitution* unchanged if wildcard is a unnamed, or is already present in + * substitution. + * + * Returns `null` in cases either of attempting to an existing wildcard/substitution with a + * different value, or if the given *expr* is, or contains, a wildcard expression. + * + * @param wildcard + * @param expr + * @param substitution + * @returns + */ function captureWildcard( wildcard: string, expr: BoxedExpression, @@ -128,6 +142,10 @@ function matchOnce( // // 2. Both operator names match // + //?Arguably, matching permutations should be skipped, in the case of pattern being constituted + //by either (1) at least one optional-sequence wildcard, or 2) two or more sequence wildcards (optional or otherwise). + //^In both of these cases, the implication, it can be argued, is that their is requirement + //that operands should pertain to a *specific* permutation. result = pattern.operatorDefinition!.commutative ? matchPermutation(expr, pattern, substitution, options) : matchArguments(expr, pattern.ops, substitution, options); @@ -299,6 +317,29 @@ function matchVariations( return null; } +/** + * + * Try all needed permutations of the operands of a pattern expression, against the operands of a + * match target. Assumes that *expr* and *pattern* have the same operator. + * + * + * + * @param expr + * @param pattern + * @param substitution + * @param options + * @returns + */ function matchPermutation( expr: BoxedExpression, pattern: BoxedExpression, @@ -309,12 +350,12 @@ function matchPermutation( // Avoid redundant permutations: // This condition ensures various wildcard sub-permutations - namely a sequence followed by - // another sequence or universal, are skipped. + // another sequence, are skipped. // ›Consequently, a pattern such as `['Add', 1, 2, '__a', 4, '__b', 7, '_']`, notably with only 3 // non-optional wildcards, yields only *2040* permutations, in contrast to *5040* (i.e. 7!). //!@todo: // - Further optimizations certainly possible here...: consider sub-sequences of expressions with - // same values (particularly number/symbol literals). At an extreme, consider ['Add', 1, 1, 1, + // same values (particularly number/symbol literals : Consider ['Add', 1, 1, 1, // 1, 1]. Such a check would reduce permutations from 120 to 1. // - ^Another, may be the req. of a matching qty. of operands (with expr.), in the case of no // sequence wildcards in pattern. @@ -327,8 +368,7 @@ function matchPermutation( wildcardType(x) === 'Sequence' && xs[index + 1] && isWildcard(xs[index + 1]) && - (wildcardType(xs[index + 1]) === 'Wildcard' || - wildcardType(xs[index + 1]) === 'Sequence') + wildcardType(xs[index + 1]) === 'Sequence' ); const patterns = permutations(pattern.ops!, cond); @@ -340,6 +380,17 @@ function matchPermutation( return null; } +/** + * Match a list of patterns against operands of expression, appending wildcards (named) to + * *substitution* (along the way). If a successful match, returns the new substitution (or the same + * if no named wildcard), or *null* for no match. + * + * @param expr + * @param patterns + * @param substitution + * @param options + * @returns + */ function matchArguments( expr: BoxedExpression, patterns: ReadonlyArray, @@ -352,82 +403,217 @@ function matchArguments( } const ce = patterns[0].engine; - let result: BoxedSubstitution | null = { ...substitution }; // We're going to consume the ops array, so make a copy const ops = [...expr.ops!]; - let i = 0; // Index in pattern - - while (i < patterns.length) { - const pat = patterns[i]; - const argName = wildcardName(pat); - - if (argName !== null) { - if (argName.startsWith('__')) { - // Match 1 or more expressions (__) or 0 or more (___) - /** Qty. of operands consumed (of those remaining to be matched). */ - let j = 0; - if (patterns[i + 1] === undefined) { - // No more args in the pattern after, go till the end - j = ops.length; - } else { - // Capture til' the first matching arg. of the next pattern - let found = false; - const nextPat = patterns[i + 1]; - while (!found && j < ops.length) { - found = matchOnce(ops[j], nextPat, result, options) !== null; - if (!found) j += 1; - } - // The next pattern does not match against any of the remaining ops.. Unless the next - // match is optional, then can assume no overall match. - if (!found && !wildcardName(nextPat)?.startsWith('___')) return null; - } - - // Determine the value to return for the wildcard - let value: BoxedExpression; - if (j < 1) { - // A standard/non-optional Sequence Wildcard with no match... - if (!argName.startsWith('___') && j < 1) return null; - // Otherwise must be an optional-sequence match: this is permitted. - if (expr.operator === 'Add') value = ce.Zero; - else if (expr.operator === 'Multiply') value = ce.One; - else value = ce.Nothing; - } else if (j === 1) { - // Capturing a single element/operand - value = ops.shift()!; - } else { - // >1 operands captured - const def = ce.lookupDefinition(expr.operator); - const args = ops.splice(0, j); - if (def && isOperatorDef(def) && def.operator.associative) { - value = ce.function(expr.operator, args, { canonical: false }); + return matchRemaining(patterns, substitution); + + /* + * Local f(). + */ + /** + * Match a list of patterns against *remaining* (locally scoped) `ops` and consume this list along + * the way, whilst appending to `substitution`. If a complete/successful match, return this new + * substitution; else return `null`. + * + * @note: calls recursively (permutations) for sequence wildcards + * + * @param patterns + * @param substitution + * @returns + */ + function matchRemaining( + patterns: ReadonlyArray, + substitution: BoxedSubstitution + ): BoxedSubstitution | null { + let result: BoxedSubstitution | null = { ...substitution }; + + let i = 0; // Index in pattern + + while (i < patterns.length) { + const pat = patterns[i]; + const argName = wildcardName(pat); + + if (argName !== null) { + if (argName.startsWith('__')) { + // Match 1 or more expressions (__) or 0 or more (___) + const nextPattern = patterns[i + 1]; + + const isOptionalSeq = argName.startsWith('___'); + + if (nextPattern === undefined) { + // No more args in the pattern after, go till the end + if (ops.length === 0 && !isOptionalSeq) return null; + result = captureWildcard(argName, captureOps(ops.length), result); } else { - value = ce.function('Sequence', args, { canonical: false }); + /** Total qty. of operands to be consumed by this sequence (of those remaining). + * If a non-optional sequence, minimum must be 1. + */ + let j = isOptionalSeq ? 0 : 1; + + // The next pattern should not be another required sequence wildcard + // (^@todo?: validate beforehand; should never be permitted?) + console.assert( + !( + isWildcard(nextPattern) && + wildcardType(nextPattern) === 'Sequence' + ) + ); + + // The next 'applicable' pattern. + // If this sequence is qualified by one, or more optional-sequence wildcards, then these + // should effectively be 'merged' with this sequence, and hence be marked as capturing + // '0' operands. In this case, skip over these, arriving at the next applicable pattern, + // and capture the optional wildcards subsequently. + let nextAppPattern = nextPattern; + let nextAppPatternIndex = i + 1; + + while ( + isWildcard(nextAppPattern) && + wildcardType(nextAppPattern) === 'OptionalSequence' + ) { + if (!patterns[nextAppPatternIndex + 1]) break; + // @note: if pattern has been validated prior, the next should never be a '_' + // (universal) or '__' (regular sequence) wildcard + nextAppPattern = patterns[++nextAppPatternIndex]; + } + + // Set to `true` if there is at least one op. (remaining to be consumed) which matches + // the next pattern. + let found = false; + while (!found && j < ops.length) { + found = + matchOnce(ops[j], nextAppPattern, result, options) !== null; + if (!found) j += 1; + } + + // The next pattern does not match against any of the remaining ops. + if (!found) { + // If not an optional-seq., can assume no overall match. + if (!isOptionalSeq) return null; + // Capture a 0-length match wildcard + //(?Never?Must indicate being at end of ops. at this point...?) + result = captureWildcard(argName, captureOps(0), result); + } else { + // If have encountered optional-sequences following this sequence wildcard, capture + // these (i.e. as corresponding to 0 operands) + if (nextAppPattern !== nextPattern) { + // Index of pattern which is an optional-sequence Wildcard: + let wildcardIndex = i + 1; + while (wildcardIndex < nextAppPatternIndex) { + result = captureWildcard( + wildcardName(patterns[wildcardIndex++])!, + captureOps(0), + result! + ); + } + } + + // A sequence wildcard has matched up until the *first* operand that the next (valid) pattern matches. + // First try matching remaining ops. against remaining patterns with the sequence + // only capturing the operands leading up to this... + // Otherwise, continue to see if the next pattern matches (even) further ahead: and + // allow this sequence wildcard to capture even more operands. + // (^Necessary, for instance, if considering a pattern such as '...a + _n + b' matched + // against '3 + 4 + x + b'...: the sequence will have initially captured just '3', but + // this will result in a final overall no-match. In this case. allow the sequence to + // capture '3 + 4' (finally permitting a 'total' match) + while (j <= ops.length) { + // Attempt the match of remaining patterns against remaining ops. after considering + // the total capture by this seq.-wildcard for this iteration. + const capturedOps = ops.slice(0, j); + + result = matchRemaining( + patterns.slice(nextAppPatternIndex), + captureWildcard(argName, captureOps(j), result!) ?? result! + ); + + // A complete overall match with this sequence capturing 'j' operands. + if (result) break; + + // No match: reset the potential modified ops. array + ops.unshift(...capturedOps); + + j++; + if (j >= ops.length) break; + + // If the next pattern matches yet another/subsequent operand, move to the next + // iteration and try to match remaining patterns. + if (!matchOnce(ops[j - 1], nextAppPattern, result!, options)) + break; + } + + // If successful, have matched til' the end: else, the result will be 'null' + return result; + } } + /* + * End of seq. wildcard matching + */ + } else if (argName.startsWith('_')) { + // Match a single expression + if (ops.length === 0) return null; + result = captureWildcard(argName, ops.shift()!, result); + } else { + result = matchOnce(ops.shift()!, pat, result, options); } - result = captureWildcard(argName, value, result); - } else if (argName.startsWith('_')) { - // Match a single expression - if (ops.length === 0) return null; - result = captureWildcard(argName, ops.shift()!, result); + /* + * ↓Must be *non-wildcard* + */ } else { - result = matchOnce(ops.shift()!, pat, result, options); + const arg = ops.shift(); + if (!arg) return null; + result = matchOnce(arg, pat, result, options); } - } else { - const arg = ops.shift(); - if (!arg) return null; - result = matchOnce(arg, pat, result, options); + + if (result === null) return null; + i += 1; } + // If there are some arguments left in the subject that were not matched, it's + // not a match + if (ops.length > 0) return null; - if (result === null) return null; - i += 1; - } - // If there are some arguments left in the subject that were not matched, it's - // not a match - if (ops.length > 0) return null; + return result; - return result; + /* + * Local f. + */ + /** + * + * Capture *qty* of remaining expessions/operands from the beginning of `ops` & return an an + * appropriate expression, considering the containing expression operator. + * + * (Assumes that, given a `qty` of 0, the match *must* have been from an optional + * sequence-wildcard match.) + * + * @param qty + * @returns + */ + function captureOps(qty: number): BoxedExpression { + let value: BoxedExpression; + if (qty < 1) { + // Otherwise must be an optional-sequence match: this is permitted. + if (expr.operator === 'Add') value = ce.Zero; + else if (expr.operator === 'Multiply') value = ce.One; + else value = ce.Nothing; + } else if (qty === 1) { + // Capturing a single element/operand + value = ops.shift()!; + } else { + // >1 operands captured + const def = ce.lookupDefinition(expr.operator); + const args = ops.splice(0, qty); + if (def && isOperatorDef(def) && def.operator.associative) { + value = ce.function(expr.operator, args, { canonical: false }); + } else { + value = ce.function('Sequence', args, { canonical: false }); + } + } + + return value; + } + } } /** @@ -439,6 +625,13 @@ function matchArguments( * * If the expression does not match the pattern, it returns `null`. * + * + * */ export function match( subject: BoxedExpression, diff --git a/src/compute-engine/boxed-expression/validate.ts b/src/compute-engine/boxed-expression/validate.ts index 6e532f5b..1473c9be 100644 --- a/src/compute-engine/boxed-expression/validate.ts +++ b/src/compute-engine/boxed-expression/validate.ts @@ -225,6 +225,15 @@ export function checkPure( * * Otherwise return a list of expressions indicating the mismatched * arguments. + * + * * */ export function validateArguments( diff --git a/src/compute-engine/global-types.ts b/src/compute-engine/global-types.ts index 0cc5f1eb..c062efbd 100644 --- a/src/compute-engine/global-types.ts +++ b/src/compute-engine/global-types.ts @@ -1122,6 +1122,9 @@ export interface BoxedExpression { * * :::info[Note] * Applicable to canonical and non-canonical expressions. + * + * To specify a match for single symbol (non wildcard), it must be boxed (e.g. `{ match: + * ce.box('x'), ... }`), but it is suggested to use method *'subs()'* for this. * ::: */ replace( @@ -1929,9 +1932,9 @@ export type JsonSerializationOptions = { /** * Control how a pattern is matched to an expression. * - * - `substitution`: if present, assumes these values for the named wildcards, - * and ensure that subsequent occurrence of the same wildcard have the same - * value. + * - `substitution`: if present, assumes these values for a subset of + * named wildcards, and ensure that subsequent occurrence of the same + * wildcard have the same value. * - `recursive`: if true, match recursively, otherwise match only the top * level. * - `useVariations`: if false, only match expressions that are structurally identical. @@ -2953,18 +2956,22 @@ export type RuleStep = { export type RuleSteps = RuleStep[]; /** - * A rule describes how to modify an expressions that matches a pattern `match` + * A rule describes how to modify an expression that matches a pattern `match` * into a new expression `replace`. * * - `x-1` \( \to \) `1-x` * - `(x+1)(x-1)` \( \to \) `x^2-1 * - * The patterns can be expressed as LaTeX strings or a MathJSON expressions. + * The patterns can be expressed as LaTeX strings or `SemiBoxedExpression`'s. + * Alternatively, match/replace logic may be specified by a `RuleFunction`, allowing both custom + * logic/conditions for the match, and either a *BoxedExpression* (or `RuleStep` if being + * descriptive) for the replacement. * * As a shortcut, a rule can be defined as a LaTeX string: `x-1 -> 1-x`. * The expression to the left of `->` is the `match` and the expression to the * right is the `replace`. When using LaTeX strings, single character variables - * are assumed to be wildcards. + * are assumed to be wildcards. The rule LHS ('match') and RHS ('replace') may also be supplied + * separately: in this case following the same rules. * * When using MathJSON expressions, anonymous wildcards (`_`) will match any * expression. Named wildcards (`_x`, `_a`, etc...) will match any expression diff --git a/test/compute-engine/patterns.test.ts b/test/compute-engine/patterns.test.ts index 3f34d5e9..3fe9cb00 100755 --- a/test/compute-engine/patterns.test.ts +++ b/test/compute-engine/patterns.test.ts @@ -1,11 +1,10 @@ -// import { expression, latex } from './utils'; - import { BoxedExpression, PatternMatchOptions, SemiBoxedExpression, Substitution, } from '../../src/compute-engine'; +import { _BoxedExpression } from '../../src/compute-engine/boxed-expression/abstract-boxed-expression'; import { Expression } from '../../src/math-json/types'; import { engine, latex } from '../utils'; @@ -16,9 +15,18 @@ function match( expr: BoxedExpression | Expression, options?: PatternMatchOptions ): Substitution | null { - const result = engine - .box(expr) - .match(ce.box(pattern), { useVariations: true, ...options }); + // Avoid re-boxing both expr & pattern so as to preserve canonical-status + expr = + expr instanceof _BoxedExpression ? (expr as BoxedExpression) : ce.box(expr); + pattern = + pattern instanceof _BoxedExpression + ? (pattern as BoxedExpression) + : ce.box(pattern); + + const result = expr.match(pattern, { + useVariations: true, + ...options, + }); if (result === null) return null; const r = {}; for (const key of Object.keys(result)) r[key] = result[key]; @@ -82,104 +90,663 @@ describe('Examples from Patterns and Rules guide', () => { `)); }); +// Should match *single expr.* only describe('PATTERNS MATCH - Universal wildcard', () => { - // Return not null (i.e. `{}`) when there is a match - const pattern: Expression = ['Add', 1, '__']; - test('Simple match', () => - expect(match(pattern, ['Add', 1, 2])).toMatchInlineSnapshot(`{}`)); - test('Commutative', () => - expect(match(pattern, ['Add', 2, 1])).toMatchInlineSnapshot(`{}`)); - test('Commutative, multiple ops', () => - expect(match(pattern, ['Add', 2, 1, 3])).toMatchInlineSnapshot(`{}`)); - // Associative - test('Associative', () => - expect(match(pattern, ['Add', 1, ['Add', 2, 3]])).toMatchInlineSnapshot( - `{}` - )); -}); + let pattern: Expression; + + describe('Named', () => { + pattern = ['Add', 1, '_q']; + + test('Simple match (literals)', () => { + expect(match(pattern, ['Add', 1, 2])).toMatchInlineSnapshot(` + { + _q: 2, + } + `); + expect(match(pattern, ['Add', 1, 'GoldenRatio'])).toMatchInlineSnapshot(` + { + _q: GoldenRatio, + } + `); + }); -describe('PATTERNS MATCH - Named wildcard', () => { - const pattern: Expression = ['Add', 1, '__a']; - test('Commutative wildcards', () => { - expect(match(pattern, ['Add', 1, 2])).toMatchInlineSnapshot(` - { - __a: 2, - } - `); - // Commutative - expect(match(pattern, ['Add', 2, 1])).toMatchInlineSnapshot(` - { - __a: 2, - } - `); - }); - test('Associative wildcards', () => { - expect(match(pattern, ['Add', 2, 1, 3])).toMatchInlineSnapshot(` - { - __a: ["Add", 2, 3], - } - `); - expect(match(pattern, ['Add', 1, ['Add', 2, 3]])).toMatchInlineSnapshot(` - { - __a: ["Add", 2, 3], - } - `); - }); -}); + test('Matches functions, tensors...', () => { + // Associative + expect(match(pattern, ['Add', 1, ['Delimiter', ['List', 2, 3]]])) + .toMatchInlineSnapshot(` + { + _q: ["List", 2, 3], + } + `); + + expect(match(pattern, ['Add', 1, ['Multiply', 2, 'j']])) + .toMatchInlineSnapshot(` + { + _q: ["Multiply", 2, "j"], + } + `); + + expect(match(pattern, ['Add', 1, ['List', 5, 6]])).toMatchInlineSnapshot(` + { + _q: ["List", 5, 6], + } + `); + }); -describe('PATTERNS MATCH - Sequence wildcard', () => { - test('Sequence wildcard at the end', () => { - expect(match(['Add', 1, '__a'], ['Add', 1, 2, 3, 4])) - .toMatchInlineSnapshot(` - { - __a: ["Add", 2, 3, 4], - } - `); - }); - test('Sequence wildcard in the middle', () => { - expect(match(['Add', 1, '__a', 4], ['Add', 1, 2, 3, 4])) - .toMatchInlineSnapshot(` - { - __a: ["Add", 2, 3], - } - `); + test('Commutative (should match operand permutations', () => { + expect(match(pattern, ['Add', 2, 1])).toMatchInlineSnapshot(` + { + _q: 2, + } + `); + expect(match(pattern, ['Add', 'x', 1])).toMatchInlineSnapshot(` + { + _q: x, + } + `); + }); + + test('Multiple wildcards', () => { + /* + * Commutative FN. + * + */ + pattern = ['Add', '_a', 'n', '_c']; + expect(match(pattern, ['Add', 'n', 3, 3])).toMatchInlineSnapshot(` + { + _a: 3, + _c: 3, + } + `); + + expect(match(pattern, ['Add', ['Multiply', 6, 'm'], 'n', ['Sqrt', 6]])) + .toMatchInlineSnapshot(` + { + _a: ["Multiply", 6, "m"], + _c: ["Sqrt", 6], + } + `); + + // Control + // --------- + // Absence of 'n' operand + expect(match(pattern, ['Add', 'm', 3, 3])).toMatchInlineSnapshot(`null`); + + // Non-matching repeat-match + pattern = ['Add', '_a', 'n', '_a']; + expect(match(pattern, ['Add', 7, 9, 'n'])).toMatchInlineSnapshot(`null`); + + /* + * Function-name + Operand simultaneously + * + */ + pattern = ['_n', '_a', 'x']; + expect(match(pattern, ['Power', '1', 'x'])).toMatchInlineSnapshot(` + { + _a: 1, + _n: Power, + } + `); + + expect(match(pattern, ['Power', ['Divide', '1', 'y'], 'x'])) + .toMatchInlineSnapshot(` + { + _a: ["Divide", 1, "y"], + _n: Power, + } + `); + + expect(match(pattern, ['Divide', 'q', 'x'])).toMatchInlineSnapshot(` + { + _a: q, + _n: Divide, + } + `); + + // Control + // --------- + // Not enough operands + expect(match(pattern, ['Negate', 'x'])).toMatchInlineSnapshot(`null`); + + // No 'x' + expect( + match(pattern, ['Add', 'w', ['Negate', 'x']]) + ).toMatchInlineSnapshot(`null`); + }); + + // @todo?: Repeated-match cases, too? + // (^some examples already in 'Examples from Patterns and Rules guide') }); - test('Sequence wildcard in the middle, matching nothing', () => { + + // Should return not null (i.e. `{}`) when there is a match + test('Non-named', () => { + /* + * Selection of tests from 'Named' (replaced with unnamed cards) + * + */ + pattern = ['Add', 1, '_']; + expect(match(pattern, ['Add', 1, 2])).toMatchInlineSnapshot(`{}`); + expect(match(pattern, ['Add', 1, 'GoldenRatio'])).toMatchInlineSnapshot( + `{}` + ); + + // Associative expect( - match(['Add', 1, 2, '__a', 3], ['Add', 1, 2, 3]) - ).toMatchInlineSnapshot(`null`); - }); + match(pattern, ['Add', 1, ['Delimiter', ['List', 2, 3]]]) + ).toMatchInlineSnapshot(`{}`); + expect( + match(pattern, ['Add', 1, ['Multiply', 2, 'j']]) + ).toMatchInlineSnapshot(`{}`); + expect(match(pattern, ['Add', 1, ['List', 5, 6]])).toMatchInlineSnapshot( + `{}` + ); + expect(match(pattern, ['Add', 2, 1])).toMatchInlineSnapshot(`{}`); - test('Optional sequence wildcard mathching 0', () => { - expect(match(['Add', 1, 2, '___a', 3], ['Add', 1, 2, 3])) - .toMatchInlineSnapshot(` - { - ___a: 0, - } - `); - }); + // Multiple wildcards + pattern = ['_', '_', 'x']; + expect(match(pattern, ['Power', '1', 'x'])).toMatchInlineSnapshot(`{}`); - test('Optional sequence wildcard matching 1', () => { - expect(match(['Multiply', 1, 2, '___a', 3], ['Multiply', 1, 2, 3])) - .toMatchInlineSnapshot(` - { - ___a: 1, - } - `); + pattern = ['Add', '_', 'n', '_']; + expect( + match(pattern, ['Add', ['Multiply', 6, 'm'], 'n', ['Sqrt', 6]]) + ).toMatchInlineSnapshot(`{}`); + + /* + * Some extras... + */ + pattern = ['Factorial', '_']; + // (note: err. in terms of type/signature) + expect(match(pattern, ['Factorial', 'a'])).toMatchInlineSnapshot(`{}`); + // Nested + pattern = ['Power', Infinity, ['Power', 'Pi', '_']]; + expect( + match(pattern, ['Power', Infinity, ['Power', 'Pi', 'e']]) + ).toMatchInlineSnapshot(`{}`); + // Function-name + pattern = ['_', 'g']; + expect(match(pattern, ['Negate', 'g'])).toMatchInlineSnapshot(`{}`); }); +}); - test('Sequence wildcard in the middle', () => { - expect(match(['Add', 1, 2, '__a', 4], ['Add', 1, 2, 3, 4])) - .toMatchInlineSnapshot(` - { - __a: 3, - } - `); +describe('PATTERNS MATCH - Sequence wildcards', () => { + let pattern: Expression; + + describe('Regular/non-optional sequence', () => { + describe('Named wildcard', () => { + test('Matches commutative-function operands', () => { + pattern = ['Add', 1, '__a']; + /* + * 1 Operand + * + */ + expect(match(pattern, ['Add', 1, 2])).toMatchInlineSnapshot(` + { + __a: 2, + } + `); + + /* + * >1 Operand + * + */ + // !@fix: + // Does not match... + // (further cases throughout sequence-wildcard matching are present, too. See instances of + // '@fix: (commutative matching)') + // !The canonicalized expr. (['Add', 'x', 1, 2, 3, 5]) neither matches against pattern + // permuation ['Add', 1, '__a'] or ['Add', '__a', 1]. + // (In other words, using permutations of patterns here is not sufficient to match what + // would otherwise be considered a valid match: given the context of commutativity.) + + // expect(match(pattern, ['Add', 1, 2, 3, 'x', 5])).toMatchInlineSnapshot( + // `null` + // ); + + /* + * Function-expression operand/s + * + */ + expect(match(pattern, ['Add', 1, ['Multiply', 2, 'x']])) + .toMatchInlineSnapshot(` + { + __a: ["Multiply", 2, "x"], + } + `); + + /* + * Operand permutations + */ + // !@fix: (commutative matching) + // expect(match(pattern, ['Add', 'x', 1, 3, 'y'])).toMatchInlineSnapshot( + // `null` + // ); + + expect(match(pattern, ['Add', ['Square', 'x'], 1])) + .toMatchInlineSnapshot(` + { + __a: ["Square", "x"], + } + `); + }); + + test('Matches associative-function operands', () => { + pattern = ['Multiply', '__m', ['Sqrt', 'x']]; + + expect(match(pattern, ['Multiply', 7, ['Sqrt', 'x']])) + .toMatchInlineSnapshot(` + { + __m: 7, + } + `); + + // !@fix: (commutative matching) + // expect( + // match(pattern, ['Multiply', 2, ['Sqrt', 'x'], ['Sqrt', 'x']]) + // ).toMatchInlineSnapshot(`null`); + + // Match by commutative-permutation + expect(match(pattern, ['Multiply', ['Sqrt', 'x'], ['Add', 2, 3]])) + .toMatchInlineSnapshot(` + { + __m: ["Add", 2, 3], + } + `); + + // Control + // (√y, instead of √x) + expect( + match(pattern, ['Multiply', 3, ['Sqrt', 'y']]) + ).toMatchInlineSnapshot(`null`); + }); + + test('Matches other function/categories', () => { + /* + * Non associative/commutative cases + */ + //(@note: matching for this expr. ('Subtract') to be be done for *non-canonical* variants: + //so as to not have Subtract canonicalized as Add) + pattern = ['Subtract', '__a', 'y', '_b']; + + //(?Prettified MathJSON results in ["Square", "z"] here... ?) + expect( + match( + ce.box(pattern, { canonical: false }), + ce.box(['Subtract', 'x', 'y', ['Power', 'z', 2]], { + canonical: false, + }) + ) + ).toMatchInlineSnapshot(` + { + __a: x, + _b: ["Square", "z"], + } + `); + + // Control/should be no-match + // ------------------------- + // Too many operands RHS (right of 'y') + expect( + match( + ce.box(ce.box(pattern, { canonical: false }), { canonical: false }), + ce.box(['Subtract', 'x', 'y', ['Power', 'z', 2], 'w'], { + canonical: false, + }) + ) + ).toMatchInlineSnapshot(`null`); + + // Missing capture of '__a' + expect( + match(ce.box(pattern, { canonical: false }), [ + 'Subtract', + 'y', + ['Power', 'z', 2], + ]) + ).toMatchInlineSnapshot(`null`); + + pattern = ['Max', '__a', 10]; + expect(match(pattern, ['Max', 1, 10])).toMatchInlineSnapshot(` + { + __a: 1, + } + `); + expect(match(pattern, ['Max', 1, ['Range', 3, 7, 2], 9, 10])) + .toMatchInlineSnapshot(` + { + __a: ["Sequence", 1, ["Range", 3, 7, 2], 9], + } + `); + + // Should not match + expect( + match(pattern, ['Max', 10, 9, 8]) //Non-commutative + ).toMatchInlineSnapshot(`null`); + }); + }); + + describe('Non-named wildcard', () => { + test(`Matches, but without capturing (substitutions)`, () => { + /* + * (Selection of cases from 'Named wildcard': but unnamed sequence cards) + */ + + pattern = ['Add', 1, '__']; + + expect(match(pattern, ['Add', 1, 2])).toMatchInlineSnapshot(`{}`); + + // !@fix: (commutative matching; see prior cases) + // expect(match(pattern, ['Add', 1, 2, 3, 'x', 5])).toMatchInlineSnapshot( + // `null` + // ); + + // !@fix: (commutative matching; see prior cases) + // expect(match(pattern, ['Add', 'x', 1, 3, 'y'])).toMatchInlineSnapshot( + // `null` + // ); + + pattern = ['Multiply', '__', ['Sqrt', 'x']]; + + // !@fix: (commutative matching; see prior cases) + // expect( + // match(pattern, ['Multiply', 2, ['Sqrt', 'x'], ['Sqrt', 'x']]) + // ).toMatchInlineSnapshot(`null`); + + pattern = ['Subtract', '__', 'y', '_']; + + expect( + match( + ce.box(pattern, { canonical: false }), + ce.box(['Subtract', 'x', 'y', ['Power', 'z', 2]], { + canonical: false, + }) + ) + ).toMatchInlineSnapshot(`{}`); + }); + }); + + test(`Matches operands which match further wildcards`, () => { + /* + * Case 1 + * + */ + pattern = ['Add', ['Power', 'x', '_'], '__w']; + + expect( + match(pattern, ['Add', ['Power', 'x', 'ExponentialE'], 'ImaginaryUnit']) + ).toMatchInlineSnapshot(` + { + __w: ImaginaryUnit, + } + `); + expect(match(pattern, ['Add', ['Square', 'y'], ['Power', 'x', 2], 3])) + .toMatchInlineSnapshot(` + { + __w: ["Add", ["Square", "y"], 3], + } + `); + + // No match (non-matching Power) + expect( + match(pattern, ['Add', ['Power', 'y', 3], 'z']) + ).toMatchInlineSnapshot(`null`); + + // No match (well; additive identity) + expect(match(pattern, ['Add', ['Power', 'x', 2]])).toMatchInlineSnapshot(` + { + __w: 0, + } + `); + + /* + * Case 2 + * + */ + pattern = ['Multiply', ['Log', '__'], '_z', 10]; + + expect(match(pattern, ['Multiply', ['Log', 64, 8], 'y', 10])) + .toMatchInlineSnapshot(` + { + _z: y, + } + `); + expect(match(pattern, ['Multiply', 3, 10, ['Log', 100]])) + .toMatchInlineSnapshot(` + { + _z: 3, + } + `); + + // No match (Missing '_z' operand) + expect( + match(pattern, ['Multiply', ['Log', '__'], 10]) + ).toMatchInlineSnapshot(`null`); + }); + + test(`Varying wildcard (operand) positions`, () => { + /* + * + * Non-commutative FN's. + * + */ + // At end + expect(match(['Max', 1, '__a'], ['Max', 1, 2, 3, 4])) + .toMatchInlineSnapshot(` + { + __a: ["Sequence", 2, 3, 4], + } + `); + + // Placed in middle + // ('Sequence wildcard in the middle, full of sound and fury, signifying nothing' 😆) + expect( + match( + ['GCD', '_', '__a', 18], + ['GCD', ['Factorial', 6], ['Power', 6, 3], ['Subtract', 74, 2], 18] + ) + ).toMatchInlineSnapshot(` + { + __a: ["Sequence", ["Power", 6, 3], ["Subtract", 74, 2]], + } + `); + + // Placed at beginning + expect( + match( + //@note: non-canonical for both, because do not want Subtract to become 'Add' + ce.box(['Subtract', '__s', 5], { canonical: false }), + ce.box(['Subtract', 8, 7, 6, 5], { canonical: false }) + ) + ).toMatchInlineSnapshot(` + { + __s: ["Sequence", 8, 7, 6], + } + `); + + // Controls + // --------- + expect(match(['Max', 1, '__a'], ['Max', 2, 3, 4])).toMatchInlineSnapshot( + `null` + ); + + expect( + match(['LCM', '_', '__a', 18], ['LCM', ['Factorial', 6], 18]) + ).toMatchInlineSnapshot(`null`); + + expect(match(['Random', '__R'], ['Random'])).toMatchInlineSnapshot( + `null` + ); + }); + + test(`Multiple sequence wildcards (for one set of operands)`, () => { + /* + * Non-commutative. + * + */ + pattern = ['Tuple', '__t', ['_', '__'], '__q']; + + expect(match(pattern, ['Tuple', ['Sqrt', 'x'], ['Add', 2, 3], 'y'])) + .toMatchInlineSnapshot(` + { + __q: y, + __t: ["Sqrt", "x"], + } + `); + + // 3+ seq. + pattern = ['List', 1, '__a', 4, '__b', 7, '__c']; + expect(match(pattern, ['List', 1, 2, 3, 4, 5, 6, 7, 8])) + .toMatchInlineSnapshot(` + { + __a: ["Sequence", 2, 3], + __b: ["Sequence", 5, 6], + __c: 8, + } + `); // 👍 + + // With universal wildcards, too. + pattern = [ + 'Set', + "'some text'", + '_', + '__a', + ['Less', '_', '_'], + '__b', + 9, + ]; + expect( + match(pattern, [ + 'Set', + "'some text'", + 'x', + 'y', + ['Less', 'x', 'y'], + ['About', 'RandomExpression'], + 9, + ]) + ).toMatchInlineSnapshot(` + { + __a: y, + __b: ["About", "RandomExpression"], + } + `); // 👍 + + // Controls + // ---------- + //No match for '__q' + pattern = ['Tuple', '__t', ['_', '__'], '__q']; + expect( + match(pattern, ['Tuple', ['Sqrt', 'x'], ['Add', 2, 3]]) + ).toMatchInlineSnapshot(`null`); + + //Missing middle sequence ('_b_') + pattern = ['List', 1, '__a', 4, '__b', 7, '__c']; + expect( + match(pattern, ['List', 1, 2, 3, 5, 6, 7, 8]) + ).toMatchInlineSnapshot(`null`); + + /* + * Commutative. + * + */ + //!@feat?: this use-case (multiplt seq.-cards) illustrates the utility of a 'matchPermutations' + //!(Replace) option + + //@todo + }); }); - test('Sequence wildcard matching nothing', () => { - expect( - match(['Add', 1, 2, '__a', 3], ['Add', 1, 2, 3]) - ).toMatchInlineSnapshot(`null`); + + describe(`Optional sequence`, () => { + test('Matches nothing', () => { + /* + * Named optional-sequence + */ + expect(match(['List', 1, 2, '___a', 3], ['List', 1, 2, 3])) + .toMatchInlineSnapshot(` + { + ___a: Nothing, + } + `); + + /* + * Un-named optional-sequence + */ + expect(match(['Log', '_l', '___'], ['Log', ['Power', 10, 10]])) + .toMatchInlineSnapshot(` + { + _l: ["Power", 10, 10], + } + `); + + // Matches nothing, twice (because subsequent to regular/non-optional sequence, which should + // 'greedily' match) + expect( + match( + ['Tuple', 1, '__u', '___v', 4, '__w', '___x', 7], + ['Tuple', 1, 2, 3, 4, 5, 6, 7] + ) + ).toMatchInlineSnapshot(` + { + ___v: Nothing, + ___x: Nothing, + __u: ["Sequence", 2, 3], + __w: ["Sequence", 5, 6], + } + `); // 👍 + }); + + test('Matches >1 operands', () => { + //i.e. behaves like an ordinary sequence wildcard + expect( + match(['Add', '___', 3, '___'], ['Add', 'x', 3, 'Pi']) + ).toMatchInlineSnapshot(`{}`); + + expect( + match( + ['Matrix', ['List', '___m', ['List', 7.1]]], + [ + 'Matrix', + [ + 'List', + ['List', 9.3], + ['List', ['Complex', 6, 3.1]], + ['List', 7.1], + ], + ] + ) + ).toMatchInlineSnapshot(` + { + ___m: ["Sequence", ["List", 9.3], ["List", ["Complex", 6, 3.1]]], + } + `); + }); + + test("Special case: matches '0' (as additive identity)", () => { + expect(match(['Add', 1, 2, '___a', 3], ['Add', 1, 2, 3])) + .toMatchInlineSnapshot(` + { + ___a: 0, + } + `); + + // Control (Unnamed wildcard: so empty subst.) + expect( + match(['Add', 1, 2, '___', 3], ['Add', 1, 2, 3]) + ).toMatchInlineSnapshot(`{}`); + }); + + test("Special case: matches '1' (as multiplicative identity)", () => { + // (Two optional seqs., too...) + expect( + match( + ['Multiply', '___u', 'q', 'r', 's', '___v'], + ['Multiply', 'q', 'r', 's'] + ) + ).toMatchInlineSnapshot(` + { + ___u: 1, + ___v: 1, + } + `); + }); }); }); @@ -257,7 +824,7 @@ describe('NOT SAME', () => { }); describe('WILDCARDS', () => { - it('should match a wildcard', () => { + it('number should match a wildcard', () => { const result = match('_x', ce.box(1)); expect(result).toMatchInlineSnapshot(` { @@ -266,7 +833,7 @@ describe('WILDCARDS', () => { `); }); - it('should match a wildcard', () => { + it('symbol should match a wildcard', () => { const result = match('_x', ce.box('a')); expect(result).toMatchInlineSnapshot(` { @@ -275,7 +842,7 @@ describe('WILDCARDS', () => { `); }); - it('should match a wildcard', () => { + it('function should match a wildcard', () => { const result = match('_x', ce.box(['Add', 1, 'a'])); expect(result).toMatchInlineSnapshot(` { @@ -284,7 +851,7 @@ describe('WILDCARDS', () => { `); }); - it('should match a wildcard of a commutative function', () => { + it('wildcard matched as an argument of a commutative function', () => { const result = match(['Add', '_x', 1], ce.box(['Add', 1, 'a'])); expect(result).toMatchInlineSnapshot(` {