Skip to content

Commit 17d4fcd

Browse files
committed
fix #254
1 parent ce861c2 commit 17d4fcd

6 files changed

Lines changed: 523 additions & 88 deletions

File tree

CHANGELOG.md

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

33
### Improvements
44

5+
- **Interval Notation Parsing**: Added support for parsing mathematical interval
6+
notation from LaTeX, including half-open intervals. Addresses #254.
7+
8+
```javascript
9+
// Half-open intervals (American notation)
10+
ce.parse('[3, 4)').json; // → ["Interval", 3, ["Open", 4]]
11+
ce.parse('(3, 4]').json; // → ["Interval", ["Open", 3], 4]
12+
13+
// Open intervals (ISO/European notation)
14+
ce.parse(']3, 4[').json; // → ["Interval", ["Open", 3], ["Open", 4]]
15+
16+
// LaTeX bracket commands
17+
ce.parse('\\lbrack 3, 4\\rparen').json; // → ["Interval", 3, ["Open", 4]]
18+
```
19+
20+
**Contextual Parsing**: Lists and tuples are automatically converted to
21+
intervals when used in set contexts (Element, Union, Intersection, etc.):
22+
23+
```javascript
24+
ce.parse('x \\in [0, 1]').json;
25+
// → ["Element", "x", ["Interval", 0, 1]]
26+
27+
ce.parse('[0, 1] \\cup [2, 3]').json;
28+
// → ["Union", ["Interval", 0, 1], ["Interval", 2, 3]]
29+
30+
// Standalone notation remains backward compatible
31+
ce.parse('[0, 1]').json; // → ["List", 0, 1]
32+
ce.parse('(0, 1)').json; // → ["Tuple", 0, 1]
33+
```
34+
535
- **Absolute Value Power Simplification**: Fixed simplification of `|x^n|`
636
expressions with even and rational exponents. Previously, expressions like
737
`|x²|` and `|x^{2/3}|` were not simplified. Now they correctly simplify

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

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,57 @@ import {
1616
COMPARISON_PRECEDENCE,
1717
} from '../types';
1818

19+
/**
20+
* Parse the body of an interval expression and create an Interval MathJSON expression.
21+
*
22+
* @param body - The parsed body between the delimiters (typically a Delimiter with comma separator)
23+
* @param openLeft - If true, the left endpoint is open (excluded)
24+
* @param openRight - If true, the right endpoint is open (excluded)
25+
* @returns An Interval expression or null if the body doesn't have exactly 2 elements
26+
*/
27+
function parseIntervalBody(
28+
body: Expression,
29+
openLeft: boolean,
30+
openRight: boolean
31+
): Expression | null {
32+
// Handle empty body
33+
if (isEmptySequence(body)) return null;
34+
35+
// Extract the two endpoints from the body
36+
// The body is typically a Delimiter with a comma separator: ["Delimiter", ["Sequence", a, b], ","]
37+
// or just a Sequence: ["Sequence", a, b]
38+
let elements: Expression[];
39+
40+
const h = operator(body);
41+
if (h === 'Delimiter') {
42+
const delim = stringValue(operand(body, 2));
43+
// Must be comma-separated
44+
if (delim !== ',' && delim !== '(,)' && delim !== '[,]') return null;
45+
const inner = operand(body, 1);
46+
if (operator(inner) === 'Sequence') {
47+
elements = [...operands(inner)];
48+
} else {
49+
elements = inner ? [inner] : [];
50+
}
51+
} else if (h === 'Sequence') {
52+
elements = [...operands(body)];
53+
} else {
54+
// Single element - not valid for interval
55+
return null;
56+
}
57+
58+
// Intervals must have exactly two endpoints
59+
if (elements.length !== 2) return null;
60+
61+
const [lower, upper] = elements;
62+
63+
// Build the Interval expression with Open wrappers for open endpoints
64+
const lowerExpr: Expression = openLeft ? ['Open', lower] : lower;
65+
const upperExpr: Expression = openRight ? ['Open', upper] : upper;
66+
67+
return ['Interval', lowerExpr, upperExpr];
68+
}
69+
1970
export const DEFINITIONS_SETS: LatexDictionary = [
2071
//
2172
// Constants
@@ -184,9 +235,96 @@ export const DEFINITIONS_SETS: LatexDictionary = [
184235
},
185236
{
186237
name: 'Interval',
187-
// @todo: parse opening '[' or ']' or '('
188238
serialize: serializeSet,
189239
},
240+
241+
//
242+
// Interval Parsing - Half-open intervals with mismatched brackets
243+
//
244+
// These matchfix entries handle interval notations where the opening and closing
245+
// delimiters differ, indicating open vs closed endpoints.
246+
//
247+
248+
// [a, b) - Closed-open interval (American notation)
249+
{
250+
kind: 'matchfix',
251+
openTrigger: ['['],
252+
closeTrigger: [')'],
253+
parse: (_parser: Parser, body: Expression): Expression | null =>
254+
parseIntervalBody(body, false, true),
255+
},
256+
{
257+
kind: 'matchfix',
258+
openTrigger: ['\\lbrack'],
259+
closeTrigger: ['\\rparen'],
260+
parse: (_parser: Parser, body: Expression): Expression | null =>
261+
parseIntervalBody(body, false, true),
262+
},
263+
{
264+
kind: 'matchfix',
265+
openTrigger: ['\\lbrack'],
266+
closeTrigger: [')'],
267+
parse: (_parser: Parser, body: Expression): Expression | null =>
268+
parseIntervalBody(body, false, true),
269+
},
270+
{
271+
kind: 'matchfix',
272+
openTrigger: ['['],
273+
closeTrigger: ['\\rparen'],
274+
parse: (_parser: Parser, body: Expression): Expression | null =>
275+
parseIntervalBody(body, false, true),
276+
},
277+
278+
// (a, b] - Open-closed interval (American notation)
279+
{
280+
kind: 'matchfix',
281+
openTrigger: ['('],
282+
closeTrigger: [']'],
283+
parse: (_parser: Parser, body: Expression): Expression | null =>
284+
parseIntervalBody(body, true, false),
285+
},
286+
{
287+
kind: 'matchfix',
288+
openTrigger: ['\\lparen'],
289+
closeTrigger: ['\\rbrack'],
290+
parse: (_parser: Parser, body: Expression): Expression | null =>
291+
parseIntervalBody(body, true, false),
292+
},
293+
{
294+
kind: 'matchfix',
295+
openTrigger: ['\\lparen'],
296+
closeTrigger: [']'],
297+
parse: (_parser: Parser, body: Expression): Expression | null =>
298+
parseIntervalBody(body, true, false),
299+
},
300+
{
301+
kind: 'matchfix',
302+
openTrigger: ['('],
303+
closeTrigger: ['\\rbrack'],
304+
parse: (_parser: Parser, body: Expression): Expression | null =>
305+
parseIntervalBody(body, true, false),
306+
},
307+
308+
// ]a, b[ - Open interval (ISO/European reversed bracket notation)
309+
{
310+
kind: 'matchfix',
311+
openTrigger: [']'],
312+
closeTrigger: ['['],
313+
parse: (_parser: Parser, body: Expression): Expression | null =>
314+
parseIntervalBody(body, true, true),
315+
},
316+
{
317+
kind: 'matchfix',
318+
openTrigger: ['\\rbrack'],
319+
closeTrigger: ['\\lbrack'],
320+
parse: (_parser: Parser, body: Expression): Expression | null =>
321+
parseIntervalBody(body, true, true),
322+
},
323+
324+
// Note: ISO notation ]a, b] (open-closed) and [a, b[ (closed-open) are NOT
325+
// supported with plain brackets because they conflict with nested list parsing.
326+
// Use the American notation (a, b] and [a, b) instead, or use explicit
327+
// commands like \rbrack a, b \rbrack which are unambiguous.
190328
{
191329
name: 'Multiple',
192330
// @todo: parse
@@ -467,12 +605,17 @@ function serializeSet(
467605
op2 = operand(op2, 1);
468606
openRight = true;
469607
}
608+
// Use American notation for interval serialization:
609+
// [a, b] closed, (a, b) open, [a, b) closed-open, (a, b] open-closed
610+
// This enables round-trip parsing for half-open intervals.
611+
// Note: [a, b] and (a, b) will parse back as List/Tuple respectively
612+
// due to backward compatibility constraints.
470613
return joinLatex([
471-
`\\mathopen${openLeft ? '\\rbrack' : '\\lbrack'}`,
614+
openLeft ? '\\lparen' : '\\lbrack',
472615
serializer.serialize(op1),
473616
', ',
474617
serializer.serialize(op2),
475-
`\\mathclose${openRight ? '\\lbrack' : '\\rbrack'}`,
618+
openRight ? '\\rparen' : '\\rbrack',
476619
]);
477620
}
478621

src/compute-engine/library/sets.ts

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,37 @@ import {
2121
cantorEnumerateRationals,
2222
} from '../numerics/numeric';
2323

24+
/**
25+
* Transform a List or Tuple with exactly 2 elements to an Interval in set contexts.
26+
*
27+
* This enables contextual parsing where `[a, b]` and `(a, b)` are interpreted as
28+
* intervals when used as operands of set operations like Element, Union, etc.
29+
*
30+
* - `["List", a, b]` → `["Interval", a, b]` (closed interval [a, b])
31+
* - `["Tuple", a, b]` → `["Interval", ["Open", a], ["Open", b]]` (open interval (a, b))
32+
*
33+
* Returns the original expression unchanged if it's not a 2-element List/Tuple.
34+
*/
35+
function listToIntervalInSetContext(
36+
ce: ComputeEngine,
37+
expr: BoxedExpression
38+
): BoxedExpression {
39+
// Transform List with 2 elements to closed Interval
40+
if (expr.operator === 'List' && expr.nops === 2) {
41+
return ce.function('Interval', [expr.op1.canonical, expr.op2.canonical]);
42+
}
43+
44+
// Transform Tuple with 2 elements to open Interval
45+
if (expr.operator === 'Tuple' && expr.nops === 2) {
46+
return ce.function('Interval', [
47+
ce.function('Open', [expr.op1.canonical]),
48+
ce.function('Open', [expr.op2.canonical]),
49+
]);
50+
}
51+
52+
return expr.canonical;
53+
}
54+
2455
export const SETS_LIBRARY: SymbolDefinitions = {
2556
//
2657
// Constants
@@ -487,6 +518,21 @@ export const SETS_LIBRARY: SymbolDefinitions = {
487518
description:
488519
'Test whether a value is an element of a collection. ' +
489520
'Optional third argument is a boolean expression (condition) for filtered iteration in Sum/Product.',
521+
canonical: (args, { engine: ce }) => {
522+
if (args.length < 2) return ce._fn('Element', args);
523+
const [value, collection, condition] = args;
524+
// Transform List/Tuple with 2 elements to Interval in set context
525+
const canonicalCollection = listToIntervalInSetContext(ce, collection);
526+
// Only include condition if it's present and not Nothing
527+
if (condition && condition.symbol !== 'Nothing') {
528+
return ce._fn('Element', [
529+
value.canonical,
530+
canonicalCollection,
531+
condition.canonical,
532+
]);
533+
}
534+
return ce._fn('Element', [value.canonical, canonicalCollection]);
535+
},
490536
evaluate: ([value, collection, _condition], { engine: ce }) => {
491537
// Note: condition is only used during Sum/Product iteration,
492538
// not for standalone Element evaluation
@@ -514,6 +560,14 @@ export const SETS_LIBRARY: SymbolDefinitions = {
514560
signature: '(lhs:collection, rhs: collection) -> boolean',
515561
description:
516562
'Test whether the first collection is a strict subset of the second.',
563+
canonical: (args, { engine: ce }) => {
564+
if (args.length !== 2) return ce._fn('Subset', args);
565+
// Transform List/Tuple with 2 elements to Interval in set context
566+
return ce._fn('Subset', [
567+
listToIntervalInSetContext(ce, args[0]),
568+
listToIntervalInSetContext(ce, args[1]),
569+
]);
570+
},
517571
evaluate: ([lhs, rhs], { engine: ce }) => {
518572
const result = subset(lhs, rhs);
519573
if (result === true) return ce.True;
@@ -527,6 +581,14 @@ export const SETS_LIBRARY: SymbolDefinitions = {
527581
signature: '(lhs:collection, rhs: collection) -> boolean',
528582
description:
529583
'Test whether the first collection is a subset (possibly equal) of the second.',
584+
canonical: (args, { engine: ce }) => {
585+
if (args.length !== 2) return ce._fn('SubsetEqual', args);
586+
// Transform List/Tuple with 2 elements to Interval in set context
587+
return ce._fn('SubsetEqual', [
588+
listToIntervalInSetContext(ce, args[0]),
589+
listToIntervalInSetContext(ce, args[1]),
590+
]);
591+
},
530592
evaluate: ([lhs, rhs], { engine: ce }) => {
531593
const result = subset(lhs, rhs, false);
532594
if (result === true) return ce.True;
@@ -553,6 +615,14 @@ export const SETS_LIBRARY: SymbolDefinitions = {
553615
signature: '(lhs:collection, rhs: collection) -> boolean',
554616
description:
555617
'Test whether the first collection is a strict superset of the second.',
618+
canonical: (args, { engine: ce }) => {
619+
if (args.length !== 2) return ce._fn('Superset', args);
620+
// Transform List/Tuple with 2 elements to Interval in set context
621+
return ce._fn('Superset', [
622+
listToIntervalInSetContext(ce, args[0]),
623+
listToIntervalInSetContext(ce, args[1]),
624+
]);
625+
},
556626
evaluate: ([lhs, rhs], { engine: ce }) => {
557627
const result = subset(rhs, lhs); // reversed
558628
if (result === true) return ce.True;
@@ -566,6 +636,14 @@ export const SETS_LIBRARY: SymbolDefinitions = {
566636
signature: '(lhs:collection, rhs: collection) -> boolean',
567637
description:
568638
'Test whether the first collection is a superset (possibly equal) of the second.',
639+
canonical: (args, { engine: ce }) => {
640+
if (args.length !== 2) return ce._fn('SupersetEqual', args);
641+
// Transform List/Tuple with 2 elements to Interval in set context
642+
return ce._fn('SupersetEqual', [
643+
listToIntervalInSetContext(ce, args[0]),
644+
listToIntervalInSetContext(ce, args[1]),
645+
]);
646+
},
569647
evaluate: ([lhs, rhs], { engine: ce }) => {
570648
const result = subset(rhs, lhs, true); // reversed
571649
if (result === true) return ce.True;
@@ -643,13 +721,17 @@ export const SETS_LIBRARY: SymbolDefinitions = {
643721
canonical: (args, { engine: ce }) => {
644722
if (args.length === 0) return ce.symbol('EmptySet');
645723
if (args.length === 1) return ce.symbol('EmptySet');
646-
args =
724+
// Transform List/Tuple with 2 elements to Interval in set context
725+
const transformedArgs = args.map((arg) =>
726+
listToIntervalInSetContext(ce, arg)
727+
);
728+
const validatedArgs =
647729
validateArguments(
648730
ce,
649-
flatten(args, 'Intersection'),
731+
flatten(transformedArgs, 'Intersection'),
650732
parseType('(set+) -> set')
651-
) ?? args;
652-
return ce._fn('Intersection', args);
733+
) ?? transformedArgs;
734+
return ce._fn('Intersection', validatedArgs);
653735
},
654736
evaluate: intersection,
655737
collection: {
@@ -669,16 +751,20 @@ export const SETS_LIBRARY: SymbolDefinitions = {
669751
description: 'Return the union of two or more collections as a set.',
670752
canonical: (args, { engine: ce }) => {
671753
if (args.length === 0) return ce.symbol('EmptySet');
672-
args =
754+
// Transform List/Tuple with 2 elements to Interval in set context
755+
const transformedArgs = args.map((arg) =>
756+
listToIntervalInSetContext(ce, arg)
757+
);
758+
const validatedArgs =
673759
validateArguments(
674760
ce,
675-
flatten(args, 'Union'),
761+
flatten(transformedArgs, 'Union'),
676762
parseType('(collection+) -> set')
677-
) ?? args;
763+
) ?? transformedArgs;
678764
// Even if there is only one argument, we still need to call Union
679765
// to canonicalize the argument, since it may not be a set (it could
680766
// be a collection)
681-
return ce._fn('Union', args);
767+
return ce._fn('Union', validatedArgs);
682768
},
683769
evaluate: union,
684770

0 commit comments

Comments
 (0)