Skip to content

Commit 911562f

Browse files
refactor(math): redesign math engine with pipeline architecture (#714)
* refactor(math): add pipeline type definitions * refactor(math): extract shared utilities to utils.ts * refactor(math): add analysis-normalize stage * refactor(math): add classify stage * refactor(math): add rewrite rules as declarative objects * refactor(math): add rewrite engine with parity tests * refactor(math): extract evaluator modules (aggregates, dateArithmetic) * refactor(math): replace eval loop with pipeline architecture - analysisNormalize → classify → early return → rewrite → evaluate → format - Speculative handlers for timezone, calendar, CSS, date arithmetic - Modifier early path (rounding, strip-unit, format) before speculative checks - useMathEngine.ts reduced from 1127 to ~480 lines * fix(math): remove duplicate point/points in knownUnitTokens * fix(math): guard modifier compatibility and weight pounds * refactor(math): extract pipeline evaluate and format * refactor(math): remove legacy preprocess pipeline
1 parent 0e8c895 commit 911562f

26 files changed

+2892
-1810
lines changed

src/renderer/composables/__tests__/useMathEngine.test.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/* eslint-disable test/prefer-lowercase-title */
22
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3-
import { preprocessMathExpression } from '../math-notebook/math-engine/preprocess'
43
import { useMathEngine } from '../math-notebook/useMathEngine'
54

65
const TEST_CURRENCY_RATES = {
@@ -334,6 +333,14 @@ describe('currency', () => {
334333
expect(result.error).toBe('Currency rates service unavailable')
335334
})
336335

336+
it('does not treat pound weight as currency while loading', () => {
337+
setCurrencyServiceState('loading')
338+
339+
const result = evalLine('1 pound to lb')
340+
expect(result.type).toBe('unit')
341+
expect(result.value).toContain('lb')
342+
})
343+
337344
it('$ symbol', () => {
338345
const result = evalLine('$30')
339346
expect(result.type).toBe('unit')
@@ -385,6 +392,24 @@ describe('currency', () => {
385392
})
386393
})
387394

395+
describe('modifier compatibility', () => {
396+
it('returns controlled error for timezone format modifier', () => {
397+
const result = evalLine('time in Paris in hex')
398+
expect(result.type).toBe('empty')
399+
expect(result.error).toBe(
400+
'Modifier is not supported for this expression type',
401+
)
402+
})
403+
404+
it('returns controlled error for css rounding modifier', () => {
405+
const result = evalLine('12 pt in px rounded')
406+
expect(result.type).toBe('empty')
407+
expect(result.error).toBe(
408+
'Modifier is not supported for this expression type',
409+
)
410+
})
411+
})
412+
388413
describe('unit conversion', () => {
389414
it('stacked units', () => {
390415
const result = evalLine('1 meter 20 cm')
@@ -963,10 +988,6 @@ describe('mixed currency and plain number', () => {
963988
expect(result.type).toBe('unit')
964989
})
965990

966-
it('does not infer currency for percentage operands', () => {
967-
expect(preprocessMathExpression('$100 + 10%')).toBe('100 USD + 10 / 100')
968-
})
969-
970991
it('keeps trailing conversion on the whole inferred expression', () => {
971992
const result = evalLine('10 USD + 1 in RUB')
972993
expect(result.type).toBe('unit')
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { analysisNormalize } from '../pipeline/analysisNormalize'
3+
4+
describe('analysisNormalize', () => {
5+
it('plain expression', () => {
6+
const view = analysisNormalize('10 + 5')
7+
expect(view.raw).toBe('10 + 5')
8+
expect(view.expression).toBe('10 + 5')
9+
expect(view.normalized).toBe('10 + 5')
10+
expect(view.label).toBeUndefined()
11+
})
12+
13+
it('strips label prefix', () => {
14+
const view = analysisNormalize('Price: $100 + $50')
15+
expect(view.expression).toBe('$100 + $50')
16+
expect(view.label).toBe('Price')
17+
})
18+
19+
it('strips multi-word label', () => {
20+
const view = analysisNormalize('Monthly cost: 1200 / 12')
21+
expect(view.expression).toBe('1200 / 12')
22+
expect(view.label).toBe('Monthly cost')
23+
})
24+
25+
it('strips quoted text', () => {
26+
const view = analysisNormalize('$275 for the "Model 227"')
27+
expect(view.expression).toBe('$275')
28+
})
29+
30+
it('strips label and quoted text', () => {
31+
const view = analysisNormalize('Item: $99 "discount"')
32+
expect(view.expression).toBe('$99')
33+
expect(view.label).toBe('Item')
34+
})
35+
36+
it('empty string', () => {
37+
const view = analysisNormalize('')
38+
expect(view.raw).toBe('')
39+
expect(view.expression).toBe('')
40+
expect(view.normalized).toBe('')
41+
})
42+
43+
it('comment', () => {
44+
const view = analysisNormalize('// comment')
45+
expect(view.expression).toBe('// comment')
46+
})
47+
48+
it('normalized is lowercase', () => {
49+
const view = analysisNormalize('5 USD to EUR')
50+
expect(view.normalized).toBe('5 usd to eur')
51+
})
52+
53+
it('trims whitespace', () => {
54+
const view = analysisNormalize(' 10 + 5 ')
55+
expect(view.raw).toBe('10 + 5')
56+
expect(view.expression).toBe('10 + 5')
57+
})
58+
})
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { analysisNormalize } from '../pipeline/analysisNormalize'
3+
import { classify } from '../pipeline/classify'
4+
5+
function classifyRaw(raw: string) {
6+
return classify(analysisNormalize(raw))
7+
}
8+
9+
describe('classify — primary intent', () => {
10+
it('empty', () => expect(classifyRaw('').primary).toBe('empty'))
11+
it('whitespace', () => expect(classifyRaw(' ').primary).toBe('empty'))
12+
13+
it('// comment', () =>
14+
expect(classifyRaw('// text').primary).toBe('comment'))
15+
it('# heading', () =>
16+
expect(classifyRaw('# heading').primary).toBe('comment'))
17+
18+
it('sum', () => expect(classifyRaw('sum').primary).toBe('aggregate-block'))
19+
it('total', () =>
20+
expect(classifyRaw('total').primary).toBe('aggregate-block'))
21+
it('average', () =>
22+
expect(classifyRaw('average').primary).toBe('aggregate-block'))
23+
it('avg', () => expect(classifyRaw('avg').primary).toBe('aggregate-block'))
24+
it('median', () =>
25+
expect(classifyRaw('median').primary).toBe('aggregate-block'))
26+
it('count', () =>
27+
expect(classifyRaw('count').primary).toBe('aggregate-block'))
28+
it('sUM case insensitive', () =>
29+
expect(classifyRaw('SUM').primary).toBe('aggregate-block'))
30+
31+
it('total of list', () =>
32+
expect(classifyRaw('total of 3, 4 and 9').primary).toBe(
33+
'aggregate-inline',
34+
))
35+
it('average of list', () =>
36+
expect(classifyRaw('average of 10, 20').primary).toBe('aggregate-inline'))
37+
38+
it('days since', () =>
39+
expect(classifyRaw('days since January 1').primary).toBe('calendar'))
40+
it('days till', () =>
41+
expect(classifyRaw('days till December 25').primary).toBe('calendar'))
42+
it('days between', () =>
43+
expect(classifyRaw('days between March 1 and March 31').primary).toBe(
44+
'calendar',
45+
))
46+
it('5 days from now', () =>
47+
expect(classifyRaw('5 days from now').primary).toBe('calendar'))
48+
it('3 days ago', () =>
49+
expect(classifyRaw('3 days ago').primary).toBe('calendar'))
50+
it('day of the week', () =>
51+
expect(classifyRaw('day of the week on Jan 1, 2024').primary).toBe(
52+
'calendar',
53+
))
54+
it('week of year', () =>
55+
expect(classifyRaw('week of year').primary).toBe('calendar'))
56+
it('days in February', () =>
57+
expect(classifyRaw('days in February 2020').primary).toBe('calendar'))
58+
it('days in Q3', () =>
59+
expect(classifyRaw('days in Q3').primary).toBe('calendar'))
60+
it('current timestamp', () =>
61+
expect(classifyRaw('current timestamp').primary).toBe('calendar'))
62+
63+
it('time in Paris', () => {
64+
const c = classifyRaw('time in Paris')
65+
expect(c.primary).toBe('timezone')
66+
expect(c.timezoneOperation).toBe('display')
67+
})
68+
it('now', () => {
69+
const c = classifyRaw('now')
70+
expect(c.primary).toBe('timezone')
71+
expect(c.timezoneOperation).toBe('display')
72+
})
73+
it('pST time', () => {
74+
const c = classifyRaw('PST time')
75+
expect(c.primary).toBe('timezone')
76+
expect(c.timezoneOperation).toBe('display')
77+
})
78+
79+
it('pST time - Berlin time', () => {
80+
const c = classifyRaw('PST time - Berlin time')
81+
expect(c.primary).toBe('timezone')
82+
expect(c.timezoneOperation).toBe('difference')
83+
})
84+
85+
it('2:30 pm HKT in Berlin', () => {
86+
const c = classifyRaw('2:30 pm HKT in Berlin')
87+
expect(c.primary).toBe('timezone')
88+
expect(c.timezoneOperation).toBe('display')
89+
})
90+
91+
it('ppi = 326', () => {
92+
const c = classifyRaw('ppi = 326')
93+
expect(c.primary).toBe('assignment')
94+
expect(c.assignmentTarget).toBe('css')
95+
})
96+
it('em = 20px', () => {
97+
const c = classifyRaw('em = 20px')
98+
expect(c.primary).toBe('assignment')
99+
expect(c.assignmentTarget).toBe('css')
100+
})
101+
102+
it('12 pt in px', () =>
103+
expect(classifyRaw('12 pt in px').primary).toBe('css'))
104+
it('12 pt into px', () =>
105+
expect(classifyRaw('12 pt into px').primary).toBe('css'))
106+
107+
it('x = 10', () => {
108+
const c = classifyRaw('x = 10')
109+
expect(c.primary).toBe('assignment')
110+
expect(c.assignmentTarget).toBe('math')
111+
})
112+
it('start = today', () => {
113+
const c = classifyRaw('start = today')
114+
expect(c.primary).toBe('assignment')
115+
expect(c.assignmentTarget).toBe('date')
116+
})
117+
118+
it('today + 3 days', () =>
119+
expect(classifyRaw('today + 3 days').primary).toBe('date-arithmetic'))
120+
121+
it('10 + 5', () => expect(classifyRaw('10 + 5').primary).toBe('math'))
122+
it('sqrt(16)', () => expect(classifyRaw('sqrt(16)').primary).toBe('math'))
123+
it('100 USD to EUR', () =>
124+
expect(classifyRaw('100 USD to EUR').primary).toBe('math'))
125+
})
126+
127+
describe('classify — modifiers', () => {
128+
it('rounding: to 2 dp', () => {
129+
const c = classifyRaw('1/3 to 2 dp')
130+
expect(c.modifiers.rounding).toEqual({ type: 'dp', param: 2 })
131+
})
132+
it('rounding: rounded', () => {
133+
const c = classifyRaw('5.5 rounded')
134+
expect(c.modifiers.rounding).toEqual({ type: 'round', param: 0 })
135+
})
136+
it('rounding: to nearest 10', () => {
137+
const c = classifyRaw('37 to nearest 10')
138+
expect(c.modifiers.rounding).toEqual({ type: 'nearest', param: 10 })
139+
})
140+
141+
it('format: in hex', () => {
142+
const c = classifyRaw('255 in hex')
143+
expect(c.modifiers.resultFormat).toBe('hex')
144+
})
145+
it('format: in sci', () => {
146+
const c = classifyRaw('5300 in sci')
147+
expect(c.modifiers.resultFormat).toBe('sci')
148+
})
149+
150+
it('strip: as number', () => {
151+
const c = classifyRaw('$100 as number')
152+
expect(c.modifiers.stripUnit).toBe('number')
153+
})
154+
it('strip: as dec', () => {
155+
const c = classifyRaw('20% as dec')
156+
expect(c.modifiers.stripUnit).toBe('dec')
157+
})
158+
it('strip: as fraction', () => {
159+
const c = classifyRaw('0.5 as fraction')
160+
expect(c.modifiers.stripUnit).toBe('fraction')
161+
})
162+
})
163+
164+
describe('classify — features', () => {
165+
it('hasCurrency for $ symbol', () => {
166+
expect(classifyRaw('$100 + $50').features.hasCurrency).toBe(true)
167+
})
168+
it('hasCurrency for USD code', () => {
169+
expect(classifyRaw('100 USD').features.hasCurrency).toBe(true)
170+
})
171+
it('does not treat pound weight as currency', () => {
172+
expect(classifyRaw('1 pound to lb').features.hasCurrency).toBe(false)
173+
})
174+
it('no currency for plain math', () => {
175+
expect(classifyRaw('10 + 5').features.hasCurrency).toBe(false)
176+
})
177+
it('hasAssignment', () => {
178+
expect(classifyRaw('x = 10').features.hasAssignment).toBe(true)
179+
})
180+
it('no assignment for ==', () => {
181+
expect(classifyRaw('5 == 5').features.hasAssignment).toBe(false)
182+
})
183+
it('hasConversion', () => {
184+
expect(classifyRaw('5 km to mile').features.hasConversion).toBe(true)
185+
})
186+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { analysisNormalize } from '../pipeline/analysisNormalize'
3+
import { classify } from '../pipeline/classify'
4+
import { rewrite } from '../pipeline/rewrite'
5+
6+
function rewriteRaw(raw: string): string {
7+
const view = analysisNormalize(raw)
8+
const classification = classify(view)
9+
return rewrite(view, classification)
10+
}
11+
12+
describe('rewrite canonical output', () => {
13+
const cases = [
14+
['10 + 5', '10 + 5'],
15+
['2 * 3', '2 * 3'],
16+
['5 300', '5300'],
17+
['45°', '45 deg'],
18+
['2k', '(2 * 1000)'],
19+
['3M', '(3 * 1000000)'],
20+
['1.5 billion', '(1.5 * 1000000000)'],
21+
['$100', '100 USD'],
22+
['€50', '50 EUR'],
23+
['10 dollars', '10 USD'],
24+
['8 times 9', '8 * 9'],
25+
['10 plus 5', '10 + 5'],
26+
['3 multiplied by 4', '3 * 4'],
27+
['100 divide by 4', '100 / 4'],
28+
['17 mod 5', '17 % 5'],
29+
['1 nautical mile', '1 nauticalmile'],
30+
['3 days', '3 mcday'],
31+
['2 hours', '2 mchour'],
32+
['1 meter 20 cm', '(1 meter + 20 cm)'],
33+
['20 sq cm', '20 cm^2'],
34+
['cbm', 'm^3'],
35+
['15% of 200', '15 / 100 * 200'],
36+
['200 + 10%', '200 * (1 + 10 / 100)'],
37+
['5% on 200', '200 * (1 + 5 / 100)'],
38+
['50 as a % of 100', '(50 / 100) * 100'],
39+
['5 km as mile', '5 km to mile'],
40+
['100 celsius into fahrenheit', '100 celsius to fahrenheit'],
41+
['sqrt 16', 'sqrt(16)'],
42+
['log 2 (8)', 'log(8, 2)'],
43+
['square root of 81', 'sqrt(81)'],
44+
['log 20 base 4', 'log(20, 4)'],
45+
['if 5 > 3 then 10 else 20', '(5 > 3) ? (10) : (20)'],
46+
['42 if 5 > 3', '(5 > 3) ? (42) : 0'],
47+
['Price: $100 + $50', '100 USD + 50 USD'],
48+
['$275 for the "Model 227"', '275 USD'],
49+
['meters in 10 km', '10 km to meters'],
50+
['km m', '1 km to m'],
51+
['$50 per week', '50 USD / mcweek'],
52+
['50 to 75 is what x', '75 / 50 to multiplier'],
53+
['6(3)', '6 * (3)'],
54+
['50 to 75 is what %', '((75 - 50) / 50) * 100'],
55+
['0.35 as %', '0.35 * 100'],
56+
['$100 + 10', '100 USD + 10 USD'],
57+
['$100 + 10%', '100 USD + 10 / 100'],
58+
['10 USD + 1 in RUB', '(10 USD + 1 USD) to RUB'],
59+
['3 to the power of 2', '3 ^ 2'],
60+
['remainder of 21 divided by 5', '21 % 5'],
61+
['2/3 of 600', '(2 / 3) * 600'],
62+
] as const
63+
64+
for (const [input, expected] of cases) {
65+
it(`"${input}"`, () => {
66+
expect(rewriteRaw(input)).toBe(expected)
67+
})
68+
}
69+
})

src/renderer/composables/math-notebook/math-engine/constants.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,6 @@ export const knownUnitTokens = new Set([
179179
'pixel',
180180
'pixels',
181181
'pt',
182-
'point',
183-
'points',
184182
'em',
185183
'mph',
186184
'kmh',

src/renderer/composables/math-notebook/math-engine/css.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { CssContext, SpecialLineResult } from './types'
22
import { lengthInchesByUnit } from './constants'
3-
import { splitByKeyword } from './preprocess'
3+
import { splitByKeyword } from './utils'
44

55
function normalizeCssUnit(unit: string) {
66
const normalized = unit.toLowerCase()

0 commit comments

Comments
 (0)