Skip to content

Commit 016ccd7

Browse files
committed
feat: Add support for braced \mathopen and \mathclose delimiters in LaTeX parser
1 parent 0b44870 commit 016ccd7

7 files changed

Lines changed: 194 additions & 176 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
### New Features
44

5+
- **`\mathopen` and `\mathclose` Support**: The LaTeX parser now supports
6+
`\mathopen` and `\mathclose` delimiter prefixes for matchfix operators. This
7+
allows parsing expressions like `\mathopen(a, b\mathclose)` and the braced
8+
form `\mathopen{(}a, b\mathclose{)}`. These commands are commonly used in
9+
LaTeX for explicit delimiter spacing control.
10+
511
- **Custom Operator Compilation**: The `compile()` method now supports overriding
612
operators to use function calls instead of native operators. This enables
713
compilation of vector/matrix operations and custom domain-specific languages.

requirements/DONE.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1799,3 +1799,51 @@ e3.solve(['a', 'b', 'c', 'd']);
17991799
for under-determined systems
18001800
- `test/compute-engine/solve.test.ts` - Added 5 new tests for parametric solutions
18011801
and updated existing test to expect parametric result
1802+
1803+
---
1804+
1805+
### 33. Polynomial Factoring ✅
1806+
1807+
**IMPLEMENTED:** Polynomial factoring capability for perfect square trinomials,
1808+
difference of squares, and quadratic factoring. Enables simplification of
1809+
expressions like `√(x²+2x+1)``|x+1|`. Fixes issue #180.
1810+
1811+
**Factoring functions implemented:**
1812+
1813+
- `factorPerfectSquare()` - Detects a² ± 2ab + b² → (a±b)²
1814+
- `factorDifferenceOfSquares()` - Detects a² - b² → (a-b)(a+b)
1815+
- `factorQuadratic()` - Factors ax² + bx + c when roots are rational
1816+
- `factorPolynomial()` - Combined entry point that tries all strategies
1817+
1818+
**Examples that now work:**
1819+
1820+
```typescript
1821+
// Perfect square trinomials in sqrt
1822+
ce.parse('\\sqrt{x^2+2x+1}').simplify().latex; // → "|x+1|"
1823+
ce.parse('\\sqrt{a^2+2ab+b^2}').simplify().latex; // → "|a+b|"
1824+
ce.parse('\\sqrt{a^2-2ab+b^2}').simplify().latex; // → "|a-b|"
1825+
ce.parse('\\sqrt{4x^2-12x+9}').simplify().latex; // → "|2x-3|"
1826+
1827+
// Direct factoring
1828+
factorPolynomial(ce.parse('x^2+5x+6'), 'x').latex; // → "(x+2)(x+3)"
1829+
factorPolynomial(ce.parse('x^2-4'), 'x').latex; // → "(x-2)(x+2)"
1830+
factorPolynomial(ce.parse('2x^2-8'), 'x').latex; // → "2(x-2)(x+2)"
1831+
1832+
// Rational expression simplification
1833+
ce.parse('\\frac{x^2-1}{x-1}').simplify().latex; // → "x+1"
1834+
```
1835+
1836+
**Integration with sqrt simplification:**
1837+
1838+
The `simplify-power.ts` file calls `factorPerfectSquare()` when simplifying
1839+
`Sqrt` of `Add` expressions, enabling automatic simplification of perfect
1840+
square trinomials inside square roots.
1841+
1842+
**Files modified:**
1843+
1844+
- `src/compute-engine/boxed-expression/factor.ts` - Added `factorPerfectSquare()`,
1845+
`factorDifferenceOfSquares()`, `factorQuadratic()`, `factorPolynomial()`, and
1846+
helper `extractSquareRoot()`
1847+
- `src/compute-engine/symbolic/simplify-power.ts` - Integrated perfect square
1848+
and difference of squares detection in Sqrt simplification
1849+
- `test/compute-engine/factor.test.ts` - Comprehensive test suite with 30+ tests

requirements/TODO.md

Lines changed: 2 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -644,172 +644,9 @@ See `requirements/DONE.md` for implementation details.
644644

645645
## Polynomial Operations
646646

647-
### 33. Polynomial Factoring
647+
### ~~33. Polynomial Factoring~~ ✅ COMPLETED
648648

649-
**Problem:** The system lacks polynomial factoring capability to convert expanded
650-
polynomials into factored forms. This prevents simplification of expressions like
651-
`√(x²+2x+1)` which should factor to `√((x+1)²)` and simplify to `|x+1|`.
652-
653-
**Related issue:** [#180](https://github.com/cortex-js/compute-engine/issues/180)
654-
- "Factoring before trying to simplify"
655-
656-
**Current behavior:**
657-
658-
```typescript
659-
// Factored form works
660-
ce.parse('\\sqrt{(x+1)^2}').simplify().latex;
661-
// → "\vert x+1\vert" ✓
662-
663-
// Expanded form doesn't factor first
664-
ce.parse('\\sqrt{x^2+2x+1}').simplify().latex;
665-
// → "\sqrt{x^2+2x+1}" ✗ (should recognize perfect square)
666-
667-
// Rational simplification works (partial fix in #180)
668-
ce.parse('\\frac{x}{x^2-x}').simplify().latex;
669-
// → "\frac{1}{x-1}" ✓ (factors denominator for cancellation)
670-
```
671-
672-
**Expected behavior:**
673-
674-
```typescript
675-
// Perfect square trinomials
676-
ce.parse('\\sqrt{x^2+2x+1}').simplify().latex;
677-
// → "\vert x+1\vert"
678-
679-
ce.parse('\\sqrt{a^2+2ab+b^2}').simplify().latex;
680-
// → "\vert a+b\vert"
681-
682-
ce.parse('\\sqrt{a^2-2ab+b^2}').simplify().latex;
683-
// → "\vert a-b\vert"
684-
685-
// General quadratic factoring
686-
ce.parse('x^2+5x+6').factor().latex;
687-
// → "(x+2)(x+3)"
688-
689-
ce.parse('2x^2-8').factor().latex;
690-
// → "2(x-2)(x+2)"
691-
```
692-
693-
**Implementation approach:**
694-
695-
1. **Add `factor()` method to BoxedExpression:**
696-
- Extend existing `factor()` in `src/compute-engine/boxed-expression/factor.ts`
697-
- Current implementation only factors out common coefficients/terms
698-
- Need to add polynomial factorization algorithms
699-
700-
2. **Factoring algorithms to implement:**
701-
- **Perfect square detection:** `a²+2ab+b²``(a+b)²`, `a²-2ab+b²``(a-b)²`
702-
- **Difference of squares:** `a²-b²``(a-b)(a+b)`
703-
- **Quadratic formula factoring:** For `ax²+bx+c`, use roots to construct factors
704-
- **Common factor extraction:** `6x²+9x``3x(2x+3)` (already implemented)
705-
- **Rational root theorem:** For higher-degree polynomials with integer coefficients
706-
- **Kronecker's method:** General factorization over integers (optional, for completeness)
707-
708-
3. **Perfect square trinomial detection (priority for issue #180):**
709-
```typescript
710-
function isPerfectSquare(expr: BoxedExpression): BoxedExpression | null {
711-
// For Add with 3 terms
712-
if (expr.operator !== 'Add' || expr.ops.length !== 3) return null;
713-
714-
// Check pattern: a² + 2ab + b² or a² - 2ab + b²
715-
// Extract terms, identify squares and cross term
716-
// Return (a+b)² or (a-b)² if match found
717-
}
718-
```
719-
720-
4. **Integration with simplification:**
721-
- Modify `simplifyPower()` in `src/compute-engine/symbolic/simplify-power.ts`
722-
- Before checking `sqrt(x^2)` patterns, try factoring the argument
723-
- Add check around line 126 (Sqrt operator handling):
724-
725-
```typescript
726-
if (op === 'Sqrt') {
727-
const arg = x.op1;
728-
if (!arg) return undefined;
729-
730-
// Try factoring first for perfect squares
731-
if (arg.operator === 'Add') {
732-
const factored = factorPerfectSquare(arg);
733-
if (factored?.operator === 'Power' && factored.op2?.is(2)) {
734-
// Found perfect square, apply sqrt(x^2) -> |x| rule
735-
return {
736-
value: ce._fn('Abs', [factored.op1]),
737-
because: 'sqrt(perfect square trinomial) -> |factor|'
738-
};
739-
}
740-
}
741-
742-
// ... existing sqrt simplification rules
743-
}
744-
```
745-
746-
5. **IMPORTANT - Recursion prevention:**
747-
- Factoring functions should NOT call `.simplify()` on results
748-
- Follow pattern from `polynomialDivide()` and other polynomial operations
749-
- Return canonical expressions, let caller decide if simplification needed
750-
- See `CLAUDE.md` "Simplification and Recursion Prevention" section
751-
752-
**Use cases:**
753-
754-
1. **Square root simplification** (issue #180):
755-
- `√(x²+2x+1)``|x+1|`
756-
- `√(4x²-12x+9)``|2x-3|`
757-
758-
2. **Rational expression simplification:**
759-
- `(x²-1)/(x+1)``x-1` (currently requires already factored form)
760-
- `(x²+5x+6)/(x+2)``x+3`
761-
762-
3. **Equation solving:**
763-
- `x²+5x+6 = 0` → factor → `(x+2)(x+3) = 0``x = -2` or `x = -3`
764-
765-
4. **Partial fraction decomposition:**
766-
- Requires factored denominators
767-
768-
**Files to modify:**
769-
770-
- `src/compute-engine/boxed-expression/factor.ts` - Add polynomial factoring
771-
- `src/compute-engine/symbolic/simplify-power.ts` - Use factoring in sqrt simplification
772-
- `src/compute-engine/boxed-expression/polynomials.ts` - Add helper functions if needed
773-
- `src/compute-engine/boxed-expression/abstract-boxed-expression.ts` - Ensure `.factor()` method exists
774-
775-
**Tests to add:**
776-
777-
```typescript
778-
// test/compute-engine/factor.test.ts
779-
describe('Polynomial factoring', () => {
780-
test('perfect square trinomials', () => {
781-
expect(parse('x^2+2x+1').factor()).toMatchInlineSnapshot(`["Square", ["Add", "x", 1]]`);
782-
expect(parse('a^2+2ab+b^2').factor()).toMatchInlineSnapshot(`["Square", ["Add", "a", "b"]]`);
783-
});
784-
785-
test('difference of squares', () => {
786-
expect(parse('x^2-4').factor()).toMatchInlineSnapshot(`["Multiply", ["Add", "x", -2], ["Add", "x", 2]]`);
787-
});
788-
789-
test('quadratic with roots', () => {
790-
expect(parse('x^2+5x+6').factor()).toMatchInlineSnapshot(`["Multiply", ["Add", "x", 2], ["Add", "x", 3]]`);
791-
});
792-
});
793-
794-
// test/compute-engine/simplify.test.ts - add to existing tests
795-
test('sqrt of perfect square trinomial', () => {
796-
expect(parse('\\sqrt{x^2+2x+1}').simplify()).toMatchInlineSnapshot(`["Abs", ["Add", "x", 1]]`);
797-
expect(parse('\\sqrt{a^2-2ab+b^2}').simplify()).toMatchInlineSnapshot(`["Abs", ["Add", "a", ["Negate", "b"]]]`);
798-
});
799-
```
800-
801-
**Implementation priority:**
802-
803-
1. **High priority:** Perfect square trinomial detection for sqrt simplification (fixes issue #180 cases)
804-
2. **Medium priority:** General quadratic factoring with rational roots
805-
3. **Low priority:** Higher-degree polynomial factoring (Kronecker's method, etc.)
806-
807-
**References:**
808-
809-
- Issue #180: https://github.com/cortex-js/compute-engine/issues/180
810-
- Current factor implementation: `src/compute-engine/boxed-expression/factor.ts`
811-
- Sqrt simplification: `src/compute-engine/symbolic/simplify-power.ts:124-254`
812-
- Polynomial utilities: `src/compute-engine/boxed-expression/polynomials.ts`
649+
See `requirements/DONE.md` for implementation details.
813650

814651
---
815652

src/compute-engine/latex-syntax/parse.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,11 @@ export class _Parser implements Parser {
841841
const closePrefix = OPEN_DELIMITER_PREFIX[this.peek];
842842
if (closePrefix) this.nextToken();
843843

844+
// Handle braced form: \mathopen{(} or \mathopen{\lbrack}
845+
// After consuming the prefix, check if there's a braced delimiter
846+
const hasBracedDelimiter = closePrefix && this.peek === '<{>';
847+
if (hasBracedDelimiter) this.nextToken(); // consume the opening brace
848+
844849
// If the delimiters are token arrays, look specifically for those
845850
if (Array.isArray(open)) {
846851
// If the open trigger is an array, the close trigger must be an array too
@@ -858,6 +863,12 @@ export class _Parser implements Parser {
858863
const matchedToken = this.nextToken();
859864
const useLatexCommand = matchedToken.startsWith('\\');
860865

866+
// Consume closing brace if we had a braced delimiter \mathopen{(}
867+
if (hasBracedDelimiter && !this.match('<}>')) {
868+
this.index = start;
869+
return false;
870+
}
871+
861872
// Find the corresponding close token variant
862873
const closeTokens = DELIMITER_SHORTHAND[close[0] as string] ?? [
863874
close[0],
@@ -866,8 +877,11 @@ export class _Parser implements Parser {
866877
useLatexCommand ? t.startsWith('\\') : !t.startsWith('\\')
867878
) ?? closeTokens[0]) as LatexToken;
868879

880+
// Build the close boundary: for braced form, expect \mathclose{)}
869881
const closeBoundary = closePrefix
870-
? [closePrefix, closeToken]
882+
? hasBracedDelimiter
883+
? [closePrefix, '<{>', closeToken, '<}>']
884+
: [closePrefix, closeToken]
871885
: [closeToken];
872886
this.addBoundary(closeBoundary);
873887
return true;
@@ -901,11 +915,23 @@ export class _Parser implements Parser {
901915

902916
open = this.nextToken() as Delimiter;
903917

918+
// Consume closing brace if we had a braced delimiter \mathopen{(}
919+
if (hasBracedDelimiter && !this.match('<}>')) {
920+
this.index = start;
921+
return false;
922+
}
923+
904924
// If we are using a shorthand delimiter, we need to add the
905925
// corresponding close delimiter.
906926
close = CLOSE_DELIMITER[open] ?? close;
907927

908-
this.addBoundary(closePrefix ? [closePrefix, close] : [close]);
928+
// Build the close boundary: for braced form, expect \mathclose{)}
929+
const closeBoundary = closePrefix
930+
? hasBracedDelimiter
931+
? [closePrefix, '<{>', close, '<}>']
932+
: [closePrefix, close]
933+
: [close];
934+
this.addBoundary(closeBoundary);
909935
return true;
910936
}
911937

@@ -1536,7 +1562,10 @@ export class _Parser implements Parser {
15361562
// Use the matchfix index for fast lookup of relevant definitions
15371563
// If there's a delimiter prefix like \left, \mathopen, peek ahead to get the actual delimiter
15381564
const hasPrefix = OPEN_DELIMITER_PREFIX[currentToken];
1539-
const lookupToken = hasPrefix ? this._tokens[this.index + 1] : currentToken;
1565+
let lookupToken = hasPrefix ? this._tokens[this.index + 1] : currentToken;
1566+
// Handle braced form: \mathopen{(} - skip past the brace to get the actual delimiter
1567+
if (hasPrefix && lookupToken === '<{>')
1568+
lookupToken = this._tokens[this.index + 2];
15401569

15411570
// Get only the matchfix defs that could match this opening token
15421571
// Note: some tokens (like |) may match multiple defs (|| and |)

test/compute-engine/latex-syntax/arithmetic.test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Expression } from '../../../src/math-json/types.ts';
2+
import { ComputeEngine } from '../../../src/compute-engine';
23
import { engine as ce, evaluate, latex } from '../../utils';
34

45
describe('SUM parsing', () => {
@@ -393,7 +394,7 @@ describe('EL-3: Condition/Filter Support in Element Expressions', () => {
393394
// filters the values from the set
394395

395396
test('parsing Element with Greater condition', () => {
396-
const ce2 = new (ce.constructor as typeof ce.constructor)();
397+
const ce2 = new ComputeEngine();
397398
const expr = ce2.parse('\\sum_{n \\in S, n > 0} n');
398399
// Should parse with condition attached to Element
399400
expect(expr.json).toMatchObject([
@@ -404,7 +405,7 @@ describe('EL-3: Condition/Filter Support in Element Expressions', () => {
404405
});
405406

406407
test('parsing Element with GreaterEqual condition', () => {
407-
const ce2 = new (ce.constructor as typeof ce.constructor)();
408+
const ce2 = new ComputeEngine();
408409
const expr = ce2.parse('\\sum_{n \\in S, n \\ge 2} n');
409410
expect(expr.json).toMatchObject([
410411
'Sum',
@@ -414,7 +415,7 @@ describe('EL-3: Condition/Filter Support in Element Expressions', () => {
414415
});
415416

416417
test('parsing Element with Less condition', () => {
417-
const ce2 = new (ce.constructor as typeof ce.constructor)();
418+
const ce2 = new ComputeEngine();
418419
const expr = ce2.parse('\\sum_{n \\in S, n < 0} n');
419420
expect(expr.json).toMatchObject([
420421
'Sum',
@@ -424,39 +425,39 @@ describe('EL-3: Condition/Filter Support in Element Expressions', () => {
424425
});
425426

426427
test('sum with condition n > 0 filters positive values', () => {
427-
const ce2 = new (ce.constructor as typeof ce.constructor)();
428+
const ce2 = new ComputeEngine();
428429
ce2.assign('S', ce2.box(['Set', 1, 2, 3, -1, -2]));
429430
const expr = ce2.parse('\\sum_{n \\in S, n > 0} n');
430431
// Should sum only 1+2+3 = 6
431432
expect(expr.evaluate().json).toBe(6);
432433
});
433434

434435
test('sum with condition n >= 2 filters values >= 2', () => {
435-
const ce2 = new (ce.constructor as typeof ce.constructor)();
436+
const ce2 = new ComputeEngine();
436437
ce2.assign('S', ce2.box(['Set', 1, 2, 3, 4, 5, -1, -2]));
437438
const expr = ce2.parse('\\sum_{n \\in S, n \\ge 2} n');
438439
// Should sum only 2+3+4+5 = 14
439440
expect(expr.evaluate().json).toBe(14);
440441
});
441442

442443
test('sum with condition n < 0 filters negative values', () => {
443-
const ce2 = new (ce.constructor as typeof ce.constructor)();
444+
const ce2 = new ComputeEngine();
444445
ce2.assign('S', ce2.box(['Set', 1, 2, 3, -1, -2, -3]));
445446
const expr = ce2.parse('\\sum_{n \\in S, n < 0} n');
446447
// Should sum only -1-2-3 = -6
447448
expect(expr.evaluate().json).toBe(-6);
448449
});
449450

450451
test('product with condition n > 0', () => {
451-
const ce2 = new (ce.constructor as typeof ce.constructor)();
452+
const ce2 = new ComputeEngine();
452453
ce2.assign('S', ce2.box(['Set', 1, 2, 3, 4, -1, -2]));
453454
const expr = ce2.parse('\\prod_{n \\in S, n > 0} n');
454455
// Should multiply only 1*2*3*4 = 24
455456
expect(expr.evaluate().json).toBe(24);
456457
});
457458

458459
test('condition with explicit inline set', () => {
459-
const ce2 = new (ce.constructor as typeof ce.constructor)();
460+
const ce2 = new ComputeEngine();
460461
const expr = ce2.box([
461462
'Sum',
462463
'n',

0 commit comments

Comments
 (0)