Skip to content

Commit 80c7589

Browse files
committed
feat: add improvements for First-Order Logic parsing, including quantifier scope and automatic predicate inference
1 parent 4f939bf commit 80c7589

8 files changed

Lines changed: 208 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,24 @@
6868

6969
### Improvements
7070

71+
- **([#263](https://github.com/cortex-js/compute-engine/issues/263))
72+
First-Order Logic**: Added several improvements for working with First-Order
73+
Logic expressions:
74+
- **Configurable quantifier scope**: New `quantifierScope` parsing option
75+
controls how quantifier scope is determined. Use `"tight"` (default) for
76+
standard FOL conventions where quantifiers bind only the immediately
77+
following formula, or `"loose"` for scope extending to the end of the
78+
expression.
79+
```typescript
80+
ce.parse('\\forall x. P(x)', { quantifierScope: 'tight' }) // default
81+
ce.parse('\\forall x. P(x)', { quantifierScope: 'loose' })
82+
```
83+
- **Automatic predicate inference**: Single uppercase letters followed by
84+
parentheses (e.g., `P(x)`, `Q(a,b)`) are now automatically recognized as
85+
predicate/function applications without requiring explicit declaration.
86+
This enables natural FOL syntax like `\forall x. P(x) \rightarrow Q(x)`
87+
to work out of the box.
88+
7189
- **Polynomial Simplification**: The `simplify()` function now automatically
7290
cancels common polynomial factors in univariate rational expressions. For
7391
example, `(x² - 1)/(x - 1)` simplifies to `x + 1`, `(x³ - x)/(x² - 1)`

src/api.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4943,6 +4943,7 @@ type ParseLatexOptions = NumberFormat & {
49434943
getSymbolType: (symbol) => BoxedType;
49444944
parseUnexpectedToken: (lhs, parser) => Expression | null;
49454945
preserveLatex: boolean;
4946+
quantifierScope: "tight" | "loose";
49464947
};
49474948
```
49484949
@@ -5024,6 +5025,38 @@ commands replaced, for example `\egroup` and `\bgroup`.
50245025
50255026
**Default:** `false`
50265027
5028+
#### ParseLatexOptions.quantifierScope
5029+
5030+
```ts
5031+
quantifierScope: "tight" | "loose";
5032+
```
5033+
5034+
Controls how quantifier scope is determined when parsing expressions
5035+
like `\forall x. P(x) \rightarrow Q(x)`.
5036+
5037+
- `"tight"`: The quantifier binds only to the immediately following
5038+
well-formed formula, stopping at logical connectives (`\rightarrow`,
5039+
`\implies`, `\land`, `\lor`, etc.). This follows standard First-Order
5040+
Logic conventions. Use explicit parentheses for wider scope:
5041+
`\forall x. (P(x) \rightarrow Q(x))`.
5042+
5043+
- `"loose"`: The quantifier scope extends to the end of the expression
5044+
or until a lower-precedence operator is encountered.
5045+
5046+
**Default:** `"tight"`
5047+
5048+
##### Example
5049+
5050+
```ts
5051+
// With "tight" (default):
5052+
// \forall x. P(x) \rightarrow Q(x)
5053+
// parses as: (∀x. P(x)) → Q(x)
5054+
5055+
// With "loose":
5056+
// \forall x. P(x) \rightarrow Q(x)
5057+
// parses as: ∀x. (P(x) → Q(x))
5058+
```
5059+
50275060
</MemberCard>
50285061
50295062
### Parser

src/compute-engine/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1946,6 +1946,7 @@ export class ComputeEngine implements IComputeEngine {
19461946
},
19471947
parseUnexpectedToken: (_lhs, _parser) => null,
19481948
preserveLatex: false,
1949+
quantifierScope: 'tight',
19491950
};
19501951

19511952
const result = parse(

src/compute-engine/latex-syntax/dictionary/definitions-logic.ts

Lines changed: 32 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -356,11 +356,32 @@ function serializeQuantifier(
356356
};
357357
}
358358

359+
// Condition function for tight quantifier binding - stops at logical connectives
360+
function tightBindingCondition(
361+
p: Parser,
362+
terminator: Readonly<Terminator>
363+
): boolean {
364+
return (
365+
p.peek === '\\to' ||
366+
p.peek === '\\rightarrow' ||
367+
p.peek === '\\implies' ||
368+
p.peek === '\\Rightarrow' ||
369+
p.peek === '\\iff' ||
370+
p.peek === '\\Leftrightarrow' ||
371+
p.peek === '\\land' ||
372+
p.peek === '\\wedge' ||
373+
p.peek === '\\lor' ||
374+
p.peek === '\\vee' ||
375+
(terminator.condition?.(p) ?? false)
376+
);
377+
}
378+
359379
function parseQuantifier(
360380
kind: 'NotForAll' | 'NotExists' | 'ForAll' | 'Exists' | 'ExistsUnique'
361381
): (parser: Parser, terminator: Readonly<Terminator>) => Expression | null {
362382
return (parser, terminator) => {
363383
const index = parser.index;
384+
const useTightBinding = parser.options.quantifierScope !== 'loose';
364385

365386
// There are several acceptable forms:
366387
// - \forall x, x>0
@@ -392,28 +413,13 @@ function parseQuantifier(
392413
parser.match(':') ||
393414
parser.match('\\colon')
394415
) {
395-
// Use tight binding: parse body but stop at logical connectives
396-
// and arrows (→, ⇒, ∧, ∨, etc.)
397-
// This follows standard FOL convention where quantifier scope
416+
// Parse body with optional tight binding (stops at logical connectives)
417+
// Tight binding follows standard FOL convention where quantifier scope
398418
// extends only to the immediately following well-formed formula.
399-
// We use a condition function because arrows have higher precedence
400-
// than comparisons in this system, but we want to include comparisons
401-
// while excluding arrows/implications.
402-
const body = parser.parseExpression({
403-
...terminator,
404-
condition: (p) =>
405-
p.peek === '\\to' ||
406-
p.peek === '\\rightarrow' ||
407-
p.peek === '\\implies' ||
408-
p.peek === '\\Rightarrow' ||
409-
p.peek === '\\iff' ||
410-
p.peek === '\\Leftrightarrow' ||
411-
p.peek === '\\land' ||
412-
p.peek === '\\wedge' ||
413-
p.peek === '\\lor' ||
414-
p.peek === '\\vee' ||
415-
(terminator.condition?.(p) ?? false),
416-
});
419+
const bodyTerminator = useTightBinding
420+
? { ...terminator, condition: (p: Parser) => tightBindingCondition(p, terminator) }
421+
: terminator;
422+
const body = parser.parseExpression(bodyTerminator);
417423
return [kind, symbol, missingIfEmpty(body)] as Expression;
418424
}
419425
const body = parser.parseEnclosure();
@@ -430,22 +436,11 @@ function parseQuantifier(
430436
// Either a separator or a parenthesis
431437
parser.skipSpace();
432438
if (parser.matchAny([',', '\\mid', ':', '\\colon'])) {
433-
// Use tight binding for quantifier body - stop at logic operators
434-
const body = parser.parseExpression({
435-
...terminator,
436-
condition: (p) =>
437-
p.peek === '\\to' ||
438-
p.peek === '\\rightarrow' ||
439-
p.peek === '\\implies' ||
440-
p.peek === '\\Rightarrow' ||
441-
p.peek === '\\iff' ||
442-
p.peek === '\\Leftrightarrow' ||
443-
p.peek === '\\land' ||
444-
p.peek === '\\wedge' ||
445-
p.peek === '\\lor' ||
446-
p.peek === '\\vee' ||
447-
(terminator.condition?.(p) ?? false),
448-
});
439+
// Parse body with optional tight binding
440+
const bodyTerminator = useTightBinding
441+
? { ...terminator, condition: (p: Parser) => tightBindingCondition(p, terminator) }
442+
: terminator;
443+
const body = parser.parseExpression(bodyTerminator);
449444
return [kind, condition, missingIfEmpty(body)] as Expression;
450445
}
451446
if (parser.match('(')) {

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1496,8 +1496,13 @@ export class _Parser implements Parser {
14961496
this.index = start;
14971497
fn = parseSymbol(this);
14981498
if (!this.isFunctionOperator(fn)) {
1499-
this.index = start;
1500-
return null;
1499+
// Check if this looks like a predicate: single uppercase letter
1500+
// followed by parentheses (e.g., P(x), Q(a,b))
1501+
// This enables automatic inference of predicates in FOL contexts
1502+
if (!this.looksLikePredicate(fn)) {
1503+
this.index = start;
1504+
return null;
1505+
}
15011506
}
15021507
}
15031508

@@ -2096,6 +2101,25 @@ export class _Parser implements Parser {
20962101
return false;
20972102
}
20982103

2104+
/**
2105+
* Check if a symbol looks like a predicate in First-Order Logic.
2106+
* A predicate is typically a single uppercase letter (P, Q, R, etc.)
2107+
* followed by parentheses containing arguments.
2108+
*
2109+
* This enables automatic inference of predicates without explicit declaration,
2110+
* so `\forall x. P(x)` works without having to declare `P` as a function.
2111+
*/
2112+
private looksLikePredicate(id: MathJsonSymbol | null): boolean {
2113+
if (id === null || typeof id !== 'string') return false;
2114+
2115+
// Must be a single uppercase letter
2116+
if (!/^[A-Z]$/.test(id)) return false;
2117+
2118+
// Must be followed by an opening parenthesis or \left(
2119+
this.skipSpace();
2120+
return this.peek === '(' || this.peek === '\\left';
2121+
}
2122+
20992123
/** Return all defs of the specified kind.
21002124
* The defs at the end of the dictionary have priority, since they may
21012125
* override previous definitions. (For example, there is a core definition

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,32 @@ export type ParseLatexOptions = NumberFormat & {
770770
* **Default:** `false`
771771
*/
772772
preserveLatex: boolean;
773+
774+
/**
775+
* Controls how quantifier scope is determined when parsing expressions
776+
* like `\forall x. P(x) \rightarrow Q(x)`.
777+
*
778+
* - `"tight"`: The quantifier binds only to the immediately following
779+
* well-formed formula, stopping at logical connectives (`\rightarrow`,
780+
* `\implies`, `\land`, `\lor`, etc.). This follows standard First-Order
781+
* Logic conventions. Use explicit parentheses for wider scope:
782+
* `\forall x. (P(x) \rightarrow Q(x))`.
783+
*
784+
* - `"loose"`: The quantifier scope extends to the end of the expression
785+
* or until a lower-precedence operator is encountered.
786+
*
787+
* **Default:** `"tight"`
788+
*
789+
* @example
790+
* // With "tight" (default):
791+
* // \forall x. P(x) \rightarrow Q(x)
792+
* // parses as: (∀x. P(x)) → Q(x)
793+
*
794+
* // With "loose":
795+
* // \forall x. P(x) \rightarrow Q(x)
796+
* // parses as: ∀x. (P(x) → Q(x))
797+
*/
798+
quantifierScope: 'tight' | 'loose';
773799
};
774800

775801
/**

test/compute-engine/arithmetic.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,47 @@ describe('SUM', () => {
10091009
ce.parse('\\sum_{n=1}^{4}(3n + 5)').evaluate().toString()
10101010
).toMatchInlineSnapshot(`50`);
10111011
});
1012+
1013+
// Alternating weighted binomial: Sum((-1)^k * k * C(n,k)) = 0 for n >= 2
1014+
it('should simplify alternating weighted binomial sum to 0', () => {
1015+
expect(
1016+
ce.box(['Sum', ['Multiply', ['Power', -1, 'k'], 'k', ['Binomial', 'b', 'k']], ['Tuple', 'k', 0, 'b']]).simplify().toString()
1017+
).toMatchInlineSnapshot(`0`);
1018+
});
1019+
1020+
it('should evaluate alternating weighted binomial sum', () => {
1021+
expect(
1022+
ce.box(['Sum', ['Multiply', ['Power', -1, 'k'], 'k', ['Binomial', 4, 'k']], ['Tuple', 'k', 0, 4]]).evaluate()?.toString()
1023+
).toMatchInlineSnapshot(`0`);
1024+
});
1025+
1026+
// Sum of binomial squares: Sum(C(n,k)^2) = C(2n, n)
1027+
it('should simplify sum of binomial squares', () => {
1028+
expect(
1029+
ce.box(['Sum', ['Power', ['Binomial', 'b', 'k'], 2], ['Tuple', 'k', 0, 'b']]).simplify().toString()
1030+
).toMatchInlineSnapshot(`Binomial(2b, b)`);
1031+
});
1032+
1033+
it('should evaluate sum of binomial squares', () => {
1034+
// C(8,4) = 70
1035+
expect(
1036+
ce.box(['Sum', ['Power', ['Binomial', 4, 'k'], 2], ['Tuple', 'k', 0, 4]]).evaluate()?.toString()
1037+
).toMatchInlineSnapshot(`70`);
1038+
});
1039+
1040+
// Sum of k*(k+1): n(n+1)(n+2)/3
1041+
it('should simplify sum of k*(k+1)', () => {
1042+
expect(
1043+
ce.box(['Sum', ['Multiply', 'k', ['Add', 'k', 1]], ['Tuple', 'k', 1, 'b']]).simplify().toString()
1044+
).toMatchInlineSnapshot(`1/3 * b^3 + b^2 + 2/3 * b`);
1045+
});
1046+
1047+
it('should evaluate sum of k*(k+1)', () => {
1048+
// 4*5*6/3 = 40
1049+
expect(
1050+
ce.box(['Sum', ['Multiply', 'k', ['Add', 'k', 1]], ['Tuple', 'k', 1, 4]]).evaluate()?.toString()
1051+
).toMatchInlineSnapshot(`40`);
1052+
});
10121053
});
10131054

10141055
describe('PRODUCT', () => {
@@ -1155,6 +1196,34 @@ describe('PRODUCT', () => {
11551196
ce.parse('\\prod_{n=5}^{5}(2n)').simplify().toString()
11561197
).toMatchInlineSnapshot(`10`);
11571198
});
1199+
1200+
// Telescoping product: Product((k+1)/k) = b+1
1201+
it('should simplify telescoping product (k+1)/k', () => {
1202+
expect(
1203+
ce.parse('\\prod_{k=1}^{b}\\frac{k+1}{k}').simplify().toString()
1204+
).toMatchInlineSnapshot(`b + 1`);
1205+
});
1206+
1207+
it('should evaluate telescoping product (k+1)/k', () => {
1208+
// (2/1)*(3/2)*(4/3)*(5/4) = 5
1209+
expect(
1210+
ce.parse('\\prod_{k=1}^{4}\\frac{k+1}{k}').evaluate().toString()
1211+
).toMatchInlineSnapshot(`5`);
1212+
});
1213+
1214+
// Wallis-like product: Product(1 - 1/k^2) = (b+1)/(2b) = 1/(2b) + 1/2
1215+
it('should simplify Wallis-like product 1 - 1/k^2', () => {
1216+
expect(
1217+
ce.parse('\\prod_{k=2}^{b}(1 - \\frac{1}{k^2})').simplify().toString()
1218+
).toMatchInlineSnapshot(`1 / (2b) + 1/2`);
1219+
});
1220+
1221+
it('should evaluate Wallis-like product 1 - 1/k^2', () => {
1222+
// (1-1/4)*(1-1/9)*(1-1/16) = (3/4)*(8/9)*(15/16) = 5/8 = 0.625
1223+
expect(
1224+
ce.parse('\\prod_{k=2}^{4}(1 - \\frac{1}{k^2})').evaluate().toString()
1225+
).toMatchInlineSnapshot(`0.625`);
1226+
});
11581227
});
11591228

11601229
describe('GCD/LCM', () => {

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,8 @@ describe('Logic', () => {
601601
`);
602602
});
603603

604+
// Note: P(x) is automatically inferred as function application (predicate)
605+
// because P is a single uppercase letter followed by parentheses
604606
it('should parse ForAll with set membership', () => {
605607
expect(ce.parse('\\forall x \\in S, P(x)').json).toMatchInlineSnapshot(`
606608
[
@@ -611,12 +613,8 @@ describe('Logic', () => {
611613
S,
612614
],
613615
[
614-
InvisibleOperator,
615616
P,
616-
[
617-
Delimiter,
618-
x,
619-
],
617+
x,
620618
],
621619
]
622620
`);

0 commit comments

Comments
 (0)