Skip to content

Commit 91150cb

Browse files
committed
follow up to PR#305
1 parent 0918fc5 commit 91150cb

7 files changed

Lines changed: 215 additions & 77 deletions

File tree

src/api.md

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,39 @@ The rules used by `.simplify()` when no explicit `rules` option is passed.
342342

343343
<MemberCard>
344344

345+
##### ExpressionComputeEngine.solveRules
346+
347+
```ts
348+
solveRules: Rule[];
349+
```
350+
351+
The rules used by `solve()` to find roots of univariate expressions.
352+
Each rule matches a normalized equation `f(_x) = 0` — the unknown is
353+
the wildcard `_x` — and `replace` produces a root expression.
354+
Conditions should reject matches where other wildcards capture `_x`.
355+
Candidate roots are validated against the original equation, so an
356+
over-eager template degrades to a no-op rather than a wrong answer.
357+
Initialized to the built-in root-finding rules; `push()` to extend,
358+
assign to replace.
359+
360+
</MemberCard>
361+
362+
<MemberCard>
363+
364+
##### ExpressionComputeEngine.harmonizationRules
365+
366+
```ts
367+
harmonizationRules: Rule[];
368+
```
369+
370+
The rules used by `solve()` to transform an equation into equivalent,
371+
easier-to-solve forms before root-finding (e.g. `ln f(x) → f(x) - 1`).
372+
Same conventions and extension pattern as `solveRules`.
373+
374+
</MemberCard>
375+
376+
<MemberCard>
377+
345378
##### ExpressionComputeEngine.strict
346379

347380
```ts
@@ -1985,6 +2018,8 @@ type ReplaceOptions = {
19852018
matchPermutations: boolean;
19862019
iterationLimit: number;
19872020
canonical: CanonicalOptions;
2021+
form: FormOption;
2022+
direction: "left-right" | "right-left";
19882023
};
19892024
```
19902025
@@ -7508,26 +7543,29 @@ Transform the expression by applying one or more replacement rules:
75087543
75097544
- If no rules apply, return `null`.
75107545
7511-
See also `expr.subs()` for a simple substitution of symbols.
7512-
7513-
Procedure for the determining the canonical-status of the input expression and replacements:
7546+
The `form` option controls the form of *replacements*. The deprecated
7547+
`canonical` option is also accepted for backward compatibility; only one
7548+
of the two may be specified.
75147549
7515-
- If `options.canonical` is set, the *entire expr.* is canonicalized to this degree: whether
7516-
the replacement occurs at the top-level, or within/recursively.
7550+
When neither `form` nor `canonical` is specified, the form of each
7551+
replacement is determined as follows:
7552+
1. the form of the replacement produced by the rule, if it has a
7553+
non-`'raw'` form;
7554+
2. otherwise, the form of the expression being replaced;
7555+
3. otherwise, the replacement is left in its raw form.
75177556
7518-
- If otherwise, the *direct replacement will be canonical* if either the 'replaced' expression
7519-
is canonical, or the given replacement (- is a Expression and -) is canonical.
7520-
Notably also, if this replacement takes place recursively (not at the top-level), then exprs.
7521-
containing the replaced expr. will still however have their (previous) canonical-status
7522-
*preserved*... unless this expr. was previously non-canonical, and *replacements have resulted
7523-
in canonical operands*. In this case, an expr. meeting this criteria will be updated to
7524-
canonical status. (Canonicalization is opportunistic here, in other words).
7557+
While the form applies directly to replaced sub-expressions only, a
7558+
non-`'raw'` form also propagates 'opportunistically' up the expression
7559+
tree: an expression whose operands all share a form after replacement
7560+
assumes that form as well. (Specifying `form: 'raw'` disables this
7561+
propagation.)
75257562
75267563
:::info[Note]
7527-
Applicable to canonical and non-canonical expressions.
7564+
Applicable to input expressions of any form.
75287565
75297566
To match a specific symbol (not a wildcard pattern), the `match` must be
75307567
a `Expression` (e.g., `{ match: ce.expr('x'), replace: ... }`).
7568+
75317569
For simple symbol substitution, consider using `subs()` instead.
75327570
:::
75337571

src/compute-engine/boxed-expression/match.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,11 @@ function matchOnce(
248248
//
249249
// For commutative operators, try permutations unless matchPermutations is false
250250
const matchPerms = options.matchPermutations ?? true;
251+
// Note: the pattern operator may have no definition, e.g. a user-defined
252+
// function in a rule pattern (rules are boxed in a scope that only
253+
// inherits from the system scope)
251254
result =
252-
pattern.operatorDefinition!.commutative && matchPerms
255+
pattern.operatorDefinition?.commutative && matchPerms
253256
? matchPermutation(expr, pattern, substitution, options)
254257
: matchArguments(expr, pattern.ops, substitution, options);
255258

src/compute-engine/boxed-expression/rules.ts

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -861,18 +861,15 @@ export function applyRule(
861861
// At least one operand (directly or recursively) matched: but continue onwards to match against
862862
// the top-level expr., test against any 'condition', et cetera.
863863
if (operandsMatched) {
864-
// (note: so not consult the input-expr 'form' because, assuming that replaced operands assume
865-
// the same form, this will be upcast in the subsequent branches.
866-
// ^Another reason to avoid this, is if the form of replacements differ from the input expr.,
867-
// then likely it is not the intention to preserve the form of the parent)
864+
// 'options.form' applies to *replacements only* (allowing finer control
865+
// of replacement operations), so the input expression's form is not
866+
// consulted here. However, if all child operands share a form after
867+
// replacement, 'eagerly' assume that form for this expression. (If this
868+
// expression also matches at the top level below, its form may still be
869+
// updated according to 'options.form'.)
870+
// Check 'canonical' first: numbers may be jointly marked as structural
871+
// and canonical.
868872
let form: FormOption = 'raw';
869-
// The current policy for applying a form according to 'options.form' is for this to apply to
870-
// *replacements only* (this ultimately allowing for finer control of replacement operations).
871-
// ...However, if all child operands bear the same form, 'eagerly' assume this form for the
872-
// present expression (if this present expression also later matches, form may be updated
873-
// according to 'options.form'.)
874-
//(@note: check 'canonical' first, because numbers may be jointly marked as structural and
875-
//canonical).
876873
if (newOps.every((x) => x.isCanonical)) form = 'canonical';
877874
else if (newOps.every((x) => x.isStructural)) form = 'structural';
878875

@@ -1082,9 +1079,9 @@ export function replace(
10821079
* For both cases, if neither `x` nor `x2` (nor compared sub-expressions if recursive) is
10831080
* structural or canonical, then return `false`.
10841081
*
1085-
* **Warning**: will throw an error if it is determined, in case of `recursive !== false`, that
1086-
* `x` and `x2` are not structurally equivalent/have an identical tree/branching structure.
1087-
* (It is therefore the responsibility of the caller to ensure this beforehand)
1082+
* If `x` and `x2` turn out not to share an identical tree/branching structure (possible since
1083+
* `isSame()` follows symbol value bindings), they are conservatively reported as differing
1084+
* (return `true`).
10881085
*/
10891086
function varyingForm(
10901087
x: Expression,
@@ -1098,14 +1095,11 @@ export function replace(
10981095
if (recursive === false) return false;
10991096

11001097
if (isFunction(x) && isFunction(x2)) {
1101-
if (x.ops.length !== x2.ops.length)
1102-
throw new Error(
1103-
`'x' and 'x2' detected to not be structurally equivalent`
1104-
);
1098+
if (x.ops.length !== x2.ops.length) return true;
11051099
if (x.nops === 0) return false;
11061100

11071101
return x.ops.some((op, index) =>
1108-
recursive === true || (!isFunction(op) && !isFunction(x2.ops[index]))
1102+
recursive !== true && !isFunction(op) && !isFunction(x2.ops[index])
11091103
? false
11101104
: varyingForm(op, x2.ops[index], { recursive })
11111105
);

src/compute-engine/types-expression.ts

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,32 +1252,31 @@ export interface Expression {
12521252
*
12531253
* - If no rules apply, return `null`.
12541254
*
1255-
*
1256-
* Option *'form'* controls the form of *replacements*. The deprecated option *'canonical'* is also
1257-
* accepted for backward compatibility (only one of these should be specified).
1258-
*
1259-
* In the absence of a specified 'form' (or 'canonical') value, then the present policy for
1260-
* determining replacement expression form is either [to]:
1261-
* - (a) first look to the form of the replacement, as generated by the replacement `Rule`...
1262-
* and failing this (i.e. if this has no non-'raw' form), then:
1263-
*
1264-
* - (b) consult the form of the *replaced* expression (provided that the form is *not
1265-
* explicitly specified as `'raw'`*)
1266-
* If still then no form is determined, the expression is also left in its original, raw form.
1267-
*
1268-
* Notably, whilst this option does only apply directly to replaced sub-expressions, the present
1269-
* is also to attempt to 'opportunistically' propagate any non-`raw` up the expression 'tree' (but
1270-
* with the explicit provision of form 'raw' resulting in the omission of this behaviour).
1255+
* The `form` option controls the form of *replacements*. The deprecated
1256+
* `canonical` option is also accepted for backward compatibility; only one
1257+
* of the two may be specified.
1258+
*
1259+
* When neither `form` nor `canonical` is specified, the form of each
1260+
* replacement is determined as follows:
1261+
* 1. the form of the replacement produced by the rule, if it has a
1262+
* non-`'raw'` form;
1263+
* 2. otherwise, the form of the expression being replaced;
1264+
* 3. otherwise, the replacement is left in its raw form.
1265+
*
1266+
* While the form applies directly to replaced sub-expressions only, a
1267+
* non-`'raw'` form also propagates 'opportunistically' up the expression
1268+
* tree: an expression whose operands all share a form after replacement
1269+
* assumes that form as well. (Specifying `form: 'raw'` disables this
1270+
* propagation.)
12711271
*
12721272
* :::info[Note]
1273-
* Applicable to input expressions of any 'form'.
1273+
* Applicable to input expressions of any form.
12741274
*
12751275
* To match a specific symbol (not a wildcard pattern), the `match` must be
12761276
* a `Expression` (e.g., `{ match: ce.expr('x'), replace: ... }`).
12771277
*
12781278
* For simple symbol substitution, consider using `subs()` instead.
12791279
* :::
1280-
*
12811280
*/
12821281
replace(
12831282
rules: BoxedRuleSet | Rule | Rule[],

src/compute-engine/types-kernel-serialization.ts

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -150,47 +150,51 @@ export type ReplaceOptions = {
150150
iterationLimit: number;
151151

152152
/**
153-
* Specify the canonical-status of _replaced_ sub-expressions.
153+
* Canonical-status of replaced sub-expressions.
154154
*
155-
* The specified canonical value/form may propagate upward to the input expression/root according
156-
* to 'eager' replacement polcicy. See `replace()` documentation for details.
155+
* Equivalent to `form`: `true` maps to `'canonical'`, `false` to `'raw'`,
156+
* and a `CanonicalForm` (or array of them) is used as-is. Specifying both
157+
* `canonical` and `form` is an error.
157158
*
158-
159-
/**
160-
* @deprecated This is a legacy property: see option `form` for a wider span of forms.
159+
* @deprecated Use `form` instead, which covers a wider range of forms.
161160
*/
162161
canonical?: CanonicalOptions;
163162

164163
/**
165-
* `form` policy for *replaced* expressions. \
164+
* The form (`'canonical'`, `'structural'`, `'raw'`, or specific canonical
165+
* transforms) applied to *replaced* sub-expressions.
166+
*
167+
* The form does not automatically apply to the entire input expression.
168+
* However, a non-`'raw'` form propagates upward through the expression tree:
169+
* an expression whose operands all share a form after replacement assumes
170+
* that form as well.
166171
*
167-
* (If there is a recursive replacement -) Does not automatically apply to the entire input expression... However, the present `replace()` policy is to 'eagerly' propagate any specified replaced-expression replacement form the entire way 'up' an expression-tree.
168-
* A value of
172+
* To guarantee a form for the *entire* result, either ensure the input is
173+
* already in the requested form before replacing, or request the form on the
174+
* result after replacement (e.g. with `.canonical`).
169175
*
170-
* If wishing to therefore ensure a the requested form for the *entire input* expression, either
171-
* ensure the input is already in the requested form before any replacement, or simply request the
172-
* form post-replacement.
176+
* If no `form` (or `canonical`) option is specified, the form of each
177+
* replacement is determined by the rule itself: see `replace()`.
173178
*
174-
* ::Additional notes
175-
* - form `'raw'` loses its applicability if the replaced expression - according to replacement mechanics - already assumes a form according to
176-
* replacement rule logic. (for example if the applying rule is of type `RuleFunction` and the
177-
* produced expression has a non-raw form).
179+
* Note: a `'raw'` form does not undo a form the replacement already has,
180+
* e.g. when a `RuleFunction` returns an expression that is already
181+
* canonical.
178182
*/
179183
form: FormOption;
180184

181-
/** *Traversal* direction (through the node 'tree') for both Rule matching & replacement.
182-
* Can be significant in the production of the final, overall replacement result (if operating
183-
* recursively) - if rule is a `RuleFunction` with arbitrary logic (e.g. replacements being
184-
* index-based).
185-
*
186-
* In 'tree' data-structure traversal terminology, possible values span:
185+
/**
186+
* Traversal direction through the expression tree, for both rule matching
187+
* and replacement:
187188
*
188-
* - `'left-right'` reflects *post-order* traversal, (left sub-tree first; depth-descending) (LRN).
189-
* - `'right-left'` reflects 'reverse' *post-order* (right sub-tree first; depth-descending) (RLN).
189+
* - `'left-right'` (default): post-order traversal — left sub-tree first,
190+
* depth-first (LRN).
191+
* - `'right-left'`: reverse post-order — right sub-tree first, depth-first
192+
* (RLN).
190193
*
191-
* For both cases traversal is always depth-first, and always visits the root/input expr. last .
194+
* In both cases the root (input) expression is visited last.
192195
*
193-
* **Default** is: `'left-right'` (standard post-order)
196+
* The direction is only observable for order-sensitive rules, e.g. a
197+
* `RuleFunction` whose replacements depend on visit order.
194198
*/
195199
direction: 'left-right' | 'right-left';
196200
};

test/compute-engine/rules.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,3 +240,84 @@ describe('PR #301 cherry-picked fixes', () => {
240240
expect(expr.replace(rule)?.toString()).toBe('8');
241241
});
242242
});
243+
244+
// Regression tests for PR #305: ReplaceOptions 'form' and 'direction'
245+
describe('ReplaceOptions form', () => {
246+
const rule = { match: 'x', replace: ['Add', 1, 1] };
247+
248+
it("form: 'canonical' canonicalizes replacements (and result)", () => {
249+
const expr = ce.parse('x + 1');
250+
const result = expr.replace(rule, { recursive: true, form: 'canonical' });
251+
expect(result?.isCanonical).toBe(true);
252+
// Replacement (1 + 1) is canonicalized to 2, then folded: 2 + 1 → 3
253+
expect(result?.toString()).toBe('3');
254+
});
255+
256+
it("form: 'raw' preserves the replacement structure", () => {
257+
const expr = ce.expr(['Add', 'x', 5], { form: 'raw' });
258+
const result = expr.replace(rule, { recursive: true, form: 'raw' });
259+
expect(result?.isCanonical).toBe(false);
260+
expect(result?.json).toEqual(['Add', ['Add', 1, 1], 5]);
261+
});
262+
263+
it('deprecated canonical: true behaves like form: canonical', () => {
264+
const expr = ce.parse('x + 1');
265+
const result = expr.replace(rule, { recursive: true, canonical: true });
266+
expect(result?.toString()).toBe('3');
267+
});
268+
269+
it('deprecated canonical: false behaves like form: raw', () => {
270+
const expr = ce.expr(['Add', 'x', 5], { form: 'raw' });
271+
const result = expr.replace(rule, { recursive: true, canonical: false });
272+
expect(result?.json).toEqual(['Add', ['Add', 1, 1], 5]);
273+
});
274+
275+
it('specifying both form and canonical throws', () => {
276+
const expr = ce.parse('x + 1');
277+
expect(() =>
278+
expr.replace(rule, { form: 'canonical', canonical: true })
279+
).toThrow(/mutually exclusive/);
280+
});
281+
282+
it('a replacement that only changes the form counts as a change', () => {
283+
// The rule returns the canonical variant of a raw expression: the value
284+
// is structurally the same, but the form differs. Before PR #305 this
285+
// was treated as "no change" and replace() returned null.
286+
const expr = ce.expr(['Multiply', 2, 'x'], { form: 'raw' });
287+
const rule = {
288+
match: ['Multiply', '_a', '_b'],
289+
replace: (e) => e.canonical,
290+
};
291+
const result = expr.replace(rule);
292+
expect(result).not.toBeNull();
293+
expect(result?.isCanonical).toBe(true);
294+
});
295+
});
296+
297+
describe('ReplaceOptions direction', () => {
298+
// An order-sensitive rule: each matched symbol is replaced with an
299+
// incrementing counter, so the traversal order is observable.
300+
const makeRule = () => {
301+
let counter = 0;
302+
return (e) => {
303+
if (!e.symbol) return undefined;
304+
counter += 1;
305+
return { value: ce.number(counter), because: 'counter' };
306+
};
307+
};
308+
309+
it('left-right (default) visits operands in order', () => {
310+
const expr = ce.expr(['List', 'a', 'b', 'c'], { form: 'raw' });
311+
const result = expr.replace(makeRule(), { recursive: true });
312+
expect(result?.json).toEqual(['List', 1, 2, 3]);
313+
});
314+
315+
it('right-left visits operands in reverse order', () => {
316+
const expr = ce.expr(['List', 'a', 'b', 'c'], { form: 'raw' });
317+
const result = expr.replace(makeRule(), {
318+
recursive: true,
319+
direction: 'right-left',
320+
});
321+
expect(result?.json).toEqual(['List', 3, 2, 1]);
322+
});
323+
});

test/compute-engine/simplify.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,3 +1337,22 @@ describe('AUTO PARTIAL FRACTION IN SIMPLIFY', () => {
13371337
}
13381338
});
13391339
});
1340+
1341+
// Regression test for PR #307: simplify() must dispatch its options
1342+
// (including custom rules) to the operand re-simplification that runs after a
1343+
// top-level rule fires (simplifyNonCommutativeFunction).
1344+
describe('CUSTOM RULES APPLY TO OPERANDS OF REWRITTEN EXPRESSIONS', () => {
1345+
test('operands introduced by a custom rule are simplified with the custom rules', () => {
1346+
// Rule 1 rewrites the top level: F(a) -> G(H(a))
1347+
// Rule 2 only applies inside the rewritten result: H(a) -> a
1348+
// Before the fix, the operand re-simplification used the default rules,
1349+
// so H survived: F(x) simplified to G(H(x)) instead of G(x).
1350+
const rules = [
1351+
{ match: ['F', '_a'], replace: ['G', ['H', '_a']] },
1352+
// (the replacement must be boxed: a string would be parsed as LaTeX)
1353+
{ match: ['H', '_a'], replace: ce.symbol('_a') },
1354+
];
1355+
const result = ce.box(['F', 'x']).simplify({ rules });
1356+
expect(result.json).toEqual(['G', 'x']);
1357+
});
1358+
});

0 commit comments

Comments
 (0)