Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/compute-engine/boxed-expression/canonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export function canonicalForm(
// Partial canonicalization produces a structural expression, not a fully
// canonical one. This allows subsequent .canonical calls to perform full
// canonicalization.
if (isFunction(expr) && expr.isCanonical) {
if (isFunction(expr) && forms.length > 0) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was not a test to this... but changing this appeared to correct some behaviours whilst outlining the tests for transform. Throughout the partial-canonicalization process, appears that sometimes the expression is 'intermediarily' not marked as canonical.

expr = expr.engine.function(expr.operator, [...expr.ops!], {
form: 'structural',
});
Expand Down
110 changes: 64 additions & 46 deletions src/compute-engine/boxed-expression/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import type {
} from '../global-types';

import {
asLatexString,
isInequalityOperator,
isRelationalOperator,
} from '../latex-syntax/utils';
Expand Down Expand Up @@ -630,22 +629,16 @@ function boxRule(
// Normalize the condition to a function
let condFn: undefined | RuleConditionFunction;
if (typeof condition === 'string') {
const latex = asLatexString(condition);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking back on this did not appear to break anything. Concludes that it was one of either an outdated or mistaken check... the 'asLatexString' call appears to presently require that any string [input] be delimited by an '$' or '$$'

if (latex) {
// If the condition is a LaTeX string, it should be a predicate
// (an expression with a Boolean value).
const condPattern =
ce.parse(latex, {
form: options?.canonical ? 'canonical' : 'raw',
}) ?? ce.expr('Nothing');

// Substitute any unbound vars in the condition to a wildcard,
// then evaluate the condition
condFn = (x: BoxedSubstitution, _ce: ComputeEngine): boolean => {
const evaluated = condPattern.subs(x).evaluate();
return isSymbol(evaluated, 'True');
};
}
// If the condition is a LaTeX string, it should be a predicate
// (an expression with a Boolean value).
const condPattern = ce.parse(condition) ?? ce.expr('Nothing');

// Substitute any unbound vars in the condition to a wildcard,
// then evaluate the condition
condFn = (x: BoxedSubstitution, _ce: ComputeEngine): boolean => {
const evaluated = condPattern.subs(x).evaluate();
return isSymbol(evaluated, 'True');
};
} else {
if (condition !== undefined && typeof condition !== 'function')
throw new Error(
Expand All @@ -664,16 +657,9 @@ function boxRule(
);
}

// Push a clean scope that only inherits from the system scope (index 0),
// not from the global scope or user-defined scopes. This prevents user-defined
// symbols (like `x` used as a function name in `x(y+z)`) from interfering with
// rule parsing. The system scope contains all built-in definitions.
const systemScope = ce.contextStack[0]?.lexicalScope;
if (systemScope) {
ce.pushScope({ parent: systemScope, bindings: new Map() });
} else {
ce.pushScope();
}
// Ensure a clean scope (that only inherits from the system scope) before boxing or parsing:
// preventing wildcards & user-defined from inheriting definitions in rules.
pushSafeScope(ce);

let matchExpr: Expression | undefined;
let replaceExpr: Expression | RuleReplaceFunction | RuleFunction | undefined;
Expand Down Expand Up @@ -749,6 +735,25 @@ function boxRule(
};
}

/**
* Push a clean scope - safe for the boxing of rules - that only inherits from the system scope
* (index 0), not from the global scope or user-defined scopes. This prevents user-defined symbols
* (like `x` used as a function name in `x(y+z)`) from interfering with rule parsing. The system
* scope contains all built-in definitions.
*
* This also crucially prevents wildcards from being given definitions where captured & bound.
*
* @param ce
*/
function pushSafeScope(ce: ComputeEngine) {
const systemScope = ce.contextStack[0]?.lexicalScope;
if (systemScope) {
ce.pushScope({ parent: systemScope, bindings: new Map() });
} else {
ce.pushScope();
}
}

/**
* Create a boxed rule set from a collection of non-boxed rules
*/
Expand Down Expand Up @@ -796,19 +801,43 @@ export function applyRule(
options?: Readonly<Partial<ReplaceOptions>>
): RuleStep | null {
if (!rule) return null;
let canonical = options?.canonical ?? (expr.isCanonical || expr.isStructural);
//@todo?: consider 'structural' too (separately); maybe by up-typing 'options.canonical' as 'form'?
let canonical = options?.canonical ?? expr.isCanonical;

// eslint-disable-next-line prefer-const
let { match, replace, condition, id, onMatch, onBeforeMatch } = rule;
const because = id ?? '';

const ce = expr.engine;

if (canonical && match) {
const awc = getWildcards(match);
pushSafeScope(ce);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(See commit message - this change indeed may not be necessary, if the described present behaviour of the wildcard-symbol canonicalization (global-scope) is not as it should be).

const canonicalMatch = match.canonical;
ce.popScope();
const bwc = getWildcards(canonicalMatch);
// If the canonical form of the match loses wildcards, this rule cannot match
// canonical expressions (they would already be simplified). Skip this rule.
if (!awc.every((x) => bwc.includes(x))) return null;
}

let operandsMatched = false;

if (isFunction(expr) && options?.recursive) {
const direction = options?.direction ?? 'left-right';
let newOps =
direction === 'left-right' ? expr.ops : [...expr.ops].reverse();

// Apply the rule to the operands of the expression
const newOps = expr.ops.map((op) => {
newOps = newOps.map((op) => {
const subExpr = applyRule(rule, op, {}, options);
if (!subExpr) return op;
operandsMatched = true;
return subExpr.value;
});

if (direction === 'right-left') (newOps as Expression[]).reverse();

// 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) {
Expand All @@ -821,26 +850,12 @@ export function applyRule(
)
canonical = true;

expr = expr.engine.function(expr.operator, newOps, {
expr = ce.function(expr.operator, newOps, {
form: canonical ? 'canonical' : 'raw',
});
}
}

// eslint-disable-next-line prefer-const
let { match, replace, condition, id, onMatch, onBeforeMatch } = rule;
const because = id ?? '';

if (canonical && match) {
const awc = getWildcards(match);
const canonicalMatch = match.canonical;
const bwc = getWildcards(canonicalMatch);
// If the canonical form of the match loses wildcards, this rule cannot match
// canonical expressions (they would already be simplified). Skip this rule.
if (!awc.every((x) => bwc.includes(x)))
return operandsMatched ? { value: expr, because } : null;
}

const useVariations = rule.useVariations ?? options?.useVariations ?? false;
const matchPermutations = options?.matchPermutations ?? true;

Expand Down Expand Up @@ -877,7 +892,7 @@ export function applyRule(
};

try {
if (!condition(conditionSub, expr.engine))
if (!condition(conditionSub, ce))
return operandsMatched ? { value: expr, because } : null;
} catch (e) {
console.error(
Expand All @@ -902,7 +917,10 @@ export function applyRule(
? replace(expr, sub)
: replace.subs(sub, { canonical });

if (!result) return null;
if (!result)
return operandsMatched
? { value: canonical ? expr.canonical : expr, because }
: null;

// To aid in debugging, invoke onMatch when the rule matches
onMatch?.(rule, expr, result);
Expand Down
2 changes: 1 addition & 1 deletion src/compute-engine/boxed-expression/simplify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ function simplifyNonCommutativeFunction(
let last = result.at(-1)!.value;
if (last.isSame(expr)) return steps;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small oversight... but was inadvertently applying the entire default rule-set to operands! (indeed; no test failures result)

last = simplifyOperands(last);
last = simplifyOperands(last, options);

// If the simplified expression is not cheaper, we're done.
// Exception: power combination results (e.g., -4·2^x → -2^(x+2)) may be
Expand Down
6 changes: 6 additions & 0 deletions src/compute-engine/types-expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,12 @@ export interface Expression {
* a `Expression` (e.g., `{ match: ce.expr('x'), replace: ... }`).
* For simple symbol substitution, consider using `subs()` instead.
* :::
*
* <!--
* @todo?:
* - Consider more generally permitting specification of 'form' (that is, allow the request of
* 'structural' replacements, too.)
* -->
*/
replace(
rules: BoxedRuleSet | Rule | Rule[],
Expand Down
17 changes: 17 additions & 0 deletions src/compute-engine/types-kernel-serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,23 @@ export type ReplaceOptions = {
* Canonicalization policy after replacement.
*/
canonical: CanonicalOptions;

/** *Traversal* direction (through the node 'tree') for both Rule matching & replacement.
* Can be significant in the production of the final, overall replacement result (if operating
* recursively) - if rule is a `RuleFunction` with arbitrary logic (e.g. replacements being
* index-based).
*
* In 'tree' data-structure traversal terminology, possible values span:
*
* - `'left-right'` reflects *post-order* traversal, (left sub-tree first; depth-descending) (LRN).
* - `'right-left'` reflects 'reverse' *post-order* (right sub-tree first; depth-descending) (RLN).
*
* For both cases traversal is always depth-first, and always visits the root/input expr. last .
*
* **Default** is: `'left-right'` (standard post-order)
*
*/
direction: 'left-right' | 'right-left';
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At your discretion! (this is anyway achievable in a workaround-way with custom rule-functions and what-not).

(I did also intend to update the 'once' rule such that it would be typed as something like 'one-rule' | 'one-replacement' | false: where 'one-rule' would assume the old behaviour of 'true', but 'one-replacement' would command an even stricter policy (currently 'true' applies at the rule level: i.e. a rule may apply multiple times; 'one-replacement' would further restrict this to one replacement/within one rule). This would also somewhat add a 'raison d'être' for this property ('direction'), too.
However, again, both of these can, with a bit of trouble, be achieved through RuleFunctions.)

};

/**
Expand Down
2 changes: 1 addition & 1 deletion test/compute-engine/canonical-form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,7 @@ describe('CANONICAL FORMS', () => {
`);
expect(checkPower('{j * 4}^{-1}')).toMatchInlineSnapshot(`
box = ["Power", ["Multiply", "j", 4], -1]
canonForms = ["Power", ["Multiply", "j", 4], -1]
canonForms = ["Divide", 1, ["Multiply", "j", 4]]
canonical = ["Divide", 1, ["Multiply", 4, "j"]]
`);
});
Expand Down