Skip to content

Commit 06f12d6

Browse files
committed
feat(units): simplify units, unit predicates
1 parent 1ad721b commit 06f12d6

4 files changed

Lines changed: 239 additions & 19 deletions

File tree

src/compute-engine/library/arithmetic.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ import {
8181
convertUnit,
8282
convertCompoundUnit,
8383
getExpressionScale,
84+
getExpressionDimension,
85+
findNamedUnit,
8486
} from './unit-data';
8587
import { boxedToUnitExpression } from './units';
8688
import { range, rangeLast } from './collections';
@@ -1011,7 +1013,7 @@ export const ARITHMETIC_LIBRARY: SymbolDefinitions[] = [
10111013
},
10121014
evaluate: ([x], { engine }) => {
10131015
const evalX = x.evaluate();
1014-
if (evalX.operator === 'Quantity') {
1016+
if (isFunction(evalX) && evalX.operator === 'Quantity') {
10151017
const mag = evalX.op1.re;
10161018
if (mag !== undefined)
10171019
return engine._fn('Quantity', [
@@ -2236,7 +2238,7 @@ function quantityMultiply(
22362238
const combinedUnit =
22372239
unitParts.length === 1 ? unitParts[0] : ce._fn('Multiply', unitParts);
22382240

2239-
return ce._fn('Quantity', [ce.number(totalMag), combinedUnit]);
2241+
return simplifyQuantityUnit(ce, totalMag, combinedUnit);
22402242
}
22412243

22422244
/**
@@ -2251,13 +2253,31 @@ function quantityDivide(
22512253
const denQ = isQuantity(den) ? den : null;
22522254

22532255
if (numQ && denQ) {
2254-
// Quantity / Quantity => Quantity with divided units
2256+
// Quantity / Quantity
22552257
const numMag = numQ.op1.re;
22562258
const denMag = denQ.op1.re;
22572259
if (numMag === undefined || denMag === undefined || denMag === 0)
22582260
return undefined;
2261+
const resultMag = numMag / denMag;
2262+
2263+
// Check if units cancel (same dimension → dimensionless scalar)
2264+
const numUE = boxedToUnitExpression(unitExpr(numQ));
2265+
const denUE = boxedToUnitExpression(unitExpr(denQ));
2266+
if (numUE && denUE) {
2267+
const numDim = getExpressionDimension(numUE);
2268+
const denDim = getExpressionDimension(denUE);
2269+
if (numDim && denDim && numDim.every((v, i) => v === denDim[i])) {
2270+
// Same dimension — convert to common scale and return scalar
2271+
const numScale = getExpressionScale(numUE);
2272+
const denScale = getExpressionScale(denUE);
2273+
if (numScale !== null && denScale !== null)
2274+
return ce.number((numMag * numScale) / (denMag * denScale));
2275+
}
2276+
}
2277+
2278+
// Different dimensions — produce compound unit, then try to simplify
22592279
const resultUnit = ce._fn('Divide', [unitExpr(numQ), unitExpr(denQ)]);
2260-
return ce._fn('Quantity', [ce.number(numMag / denMag), resultUnit]);
2280+
return simplifyQuantityUnit(ce, resultMag, resultUnit);
22612281
}
22622282

22632283
if (numQ && !denQ) {
@@ -2282,6 +2302,41 @@ function quantityDivide(
22822302
return undefined;
22832303
}
22842304

2305+
/**
2306+
* Try to simplify a compound unit to a named derived unit.
2307+
* E.g. Multiply(N, m) → J, Divide(kg, Multiply(m, Power(s, 2))) → Pa.
2308+
* If no simplification is found, returns the Quantity as-is.
2309+
*/
2310+
function simplifyQuantityUnit(
2311+
ce: ComputeEngine,
2312+
mag: number,
2313+
unitExpr: Expression
2314+
): Expression {
2315+
const ue = boxedToUnitExpression(unitExpr);
2316+
if (ue) {
2317+
const dim = getExpressionDimension(ue);
2318+
if (dim) {
2319+
// Check if the result is dimensionless (all zeros)
2320+
if (dim.every((v) => v === 0)) {
2321+
const scale = getExpressionScale(ue);
2322+
if (scale !== null) return ce.number(mag * scale);
2323+
}
2324+
const match = findNamedUnit(dim);
2325+
if (match) {
2326+
// Adjust magnitude for scale difference
2327+
const scale = getExpressionScale(ue);
2328+
const matchScale = 1; // findNamedUnit only returns scale=1 units
2329+
if (scale !== null)
2330+
return ce._fn('Quantity', [
2331+
ce.number((mag * scale) / matchScale),
2332+
ce.symbol(match),
2333+
]);
2334+
}
2335+
}
2336+
}
2337+
return ce._fn('Quantity', [ce.number(mag), unitExpr]);
2338+
}
2339+
22852340
/**
22862341
* Raise a Quantity to a power.
22872342
*/
@@ -2298,7 +2353,7 @@ function quantityPower(
22982353
// Simplify unit exponents: Power(Power(u, a), b) → Power(u, a*b)
22992354
const unit = unitExpr(base);
23002355
let resultUnit: Expression;
2301-
if (unit.operator === 'Power') {
2356+
if (isFunction(unit) && unit.operator === 'Power') {
23022357
const innerExp = unit.op2?.re;
23032358
if (innerExp !== undefined) {
23042359
const combined = innerExp * n;

src/compute-engine/library/relational-operator.ts

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,42 @@ import type {
88
import { isRelationalOperator } from '../latex-syntax/utils';
99
import { flatten } from '../boxed-expression/flatten';
1010
import { eq } from '../boxed-expression/compare';
11-
import { isNumber, isFunction } from '../boxed-expression/type-guards';
11+
import { isNumber, isFunction, isSymbol } from '../boxed-expression/type-guards';
12+
import { boxedToUnitExpression } from './units';
13+
import {
14+
getExpressionDimension,
15+
getExpressionScale,
16+
} from './unit-data';
17+
18+
/**
19+
* Compare two Quantity expressions.
20+
* Returns negative if a < b, 0 if equal, positive if a > b,
21+
* or null if incompatible or not both quantities.
22+
*/
23+
function quantityCompare(a: Expression, b: Expression): number | null {
24+
if (!isFunction(a) || !isFunction(b)) return null;
25+
if (a.operator !== 'Quantity' || b.operator !== 'Quantity') return null;
26+
27+
const aMag = a.op1.re;
28+
const bMag = b.op1.re;
29+
if (aMag === undefined || bMag === undefined) return null;
30+
31+
const aUE = boxedToUnitExpression(a.op2);
32+
const bUE = boxedToUnitExpression(b.op2);
33+
if (!aUE || !bUE) return null;
34+
35+
// Check compatible dimensions
36+
const aDim = getExpressionDimension(aUE);
37+
const bDim = getExpressionDimension(bUE);
38+
if (!aDim || !bDim || !aDim.every((v, i) => v === bDim[i])) return null;
39+
40+
// Convert both to SI
41+
const aScale = getExpressionScale(aUE);
42+
const bScale = getExpressionScale(bUE);
43+
if (aScale === null || bScale === null) return null;
44+
45+
return aMag * aScale - bMag * bScale;
46+
}
1247

1348
// // eq, lt, leq, gt, geq, neq, approx
1449
// // shortLogicalImplies: 52, // ➔
@@ -149,6 +184,14 @@ export const RELOP_LIBRARY: SymbolDefinitions = {
149184
for (const arg of ops) {
150185
if (!lhs) lhs = arg;
151186
else {
187+
// Try quantity comparison first
188+
const qcmp = quantityCompare(lhs, arg);
189+
if (qcmp !== null) {
190+
if (Math.abs(qcmp) > ce.tolerance) return ce.False;
191+
lhs = arg;
192+
continue;
193+
}
194+
152195
const test = eq(lhs, arg);
153196
if (test === false) return ce.False;
154197

@@ -228,6 +271,9 @@ export const RELOP_LIBRARY: SymbolDefinitions = {
228271
evaluate: (ops, { engine: ce }) => {
229272
if (ops.length === 2) {
230273
const [lhs, rhs] = ops;
274+
// Try quantity comparison first
275+
const qcmp = quantityCompare(lhs, rhs);
276+
if (qcmp !== null) return qcmp < 0 ? ce.True : ce.False;
231277
const cmp = lhs.isLess(rhs);
232278
if (cmp === undefined) return undefined;
233279
return cmp ? ce.True : ce.False;
@@ -238,9 +284,14 @@ export const RELOP_LIBRARY: SymbolDefinitions = {
238284
for (const arg of ops!) {
239285
if (!lhs) lhs = arg;
240286
else {
241-
const cmp = arg.isLess(lhs);
242-
if (cmp === undefined) return undefined;
243-
if (cmp === false) return ce.False;
287+
const qcmp = quantityCompare(lhs, arg);
288+
if (qcmp !== null) {
289+
if (qcmp >= 0) return ce.False;
290+
} else {
291+
const cmp = lhs.isLess(arg);
292+
if (cmp === undefined) return undefined;
293+
if (cmp === false) return ce.False;
294+
}
244295
lhs = arg;
245296
}
246297
}
@@ -281,6 +332,8 @@ export const RELOP_LIBRARY: SymbolDefinitions = {
281332
evaluate: (ops, { engine: ce }) => {
282333
if (ops.length === 2) {
283334
const [lhs, rhs] = ops;
335+
const qcmp = quantityCompare(lhs, rhs);
336+
if (qcmp !== null) return qcmp <= 0 ? ce.True : ce.False;
284337
const cmp = lhs.isLessEqual(rhs);
285338
if (cmp === undefined) return undefined;
286339
return cmp ? ce.True : ce.False;
@@ -291,9 +344,14 @@ export const RELOP_LIBRARY: SymbolDefinitions = {
291344
for (const arg of ops!) {
292345
if (!lhs) lhs = arg;
293346
else {
294-
const cmp = arg.isLessEqual(lhs);
295-
if (cmp === undefined) return undefined;
296-
if (cmp === false) return ce.False;
347+
const qcmp = quantityCompare(lhs, arg);
348+
if (qcmp !== null) {
349+
if (qcmp > 0) return ce.False;
350+
} else {
351+
const cmp = lhs.isLessEqual(arg);
352+
if (cmp === undefined) return undefined;
353+
if (cmp === false) return ce.False;
354+
}
297355
lhs = arg;
298356
}
299357
}

src/compute-engine/library/units.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { SymbolDefinitions, Expression } from '../global-types';
2-
import { isSymbol, isFunction } from '../boxed-expression/type-guards';
2+
import { isSymbol, isString, isFunction } from '../boxed-expression/type-guards';
33
import {
44
convertUnit,
55
convertCompoundUnit,
@@ -84,7 +84,7 @@ export const UNITS_LIBRARY: SymbolDefinitions = {
8484
// If the second argument is a string containing DSL operators,
8585
// parse it into a structured MathJSON unit expression.
8686
const unitArg = args[1];
87-
if (unitArg.string && /[/*^()]/.test(unitArg.string)) {
87+
if (isString(unitArg) && /[/*^()]/.test(unitArg.string)) {
8888
const parsed = parseUnitDSL(unitArg.string);
8989
if (typeof parsed !== 'string') {
9090
const boxed = ce.box(parsed as any);
@@ -135,14 +135,16 @@ export const UNITS_LIBRARY: SymbolDefinitions = {
135135
if (!ce) return undefined;
136136
const quantity = ops[0]?.evaluate();
137137
const targetUnitExpr = ops[1];
138-
if (!quantity || quantity.operator !== 'Quantity') return undefined;
138+
if (!quantity || !isFunction(quantity) || quantity.operator !== 'Quantity')
139+
return undefined;
139140

140141
const mag = quantity.op1.re;
141142
if (mag === undefined) return undefined;
142143

143144
// Try simple symbol-based conversion first
144-
const fromSymbol = quantity.op2?.symbol;
145-
const toSymbol = targetUnitExpr?.symbol;
145+
const fromUnit = quantity.op2;
146+
const fromSymbol = isSymbol(fromUnit) ? fromUnit.symbol : null;
147+
const toSymbol = isSymbol(targetUnitExpr) ? targetUnitExpr.symbol : null;
146148

147149
if (fromSymbol && toSymbol) {
148150
const converted = convertUnit(mag, fromSymbol, toSymbol);
@@ -156,7 +158,7 @@ export const UNITS_LIBRARY: SymbolDefinitions = {
156158
}
157159

158160
// Fall back to compound unit conversion
159-
const fromUE = boxedToUnitExpression(quantity.op2);
161+
const fromUE = boxedToUnitExpression(fromUnit);
160162
const toUE = boxedToUnitExpression(targetUnitExpr);
161163
if (!fromUE || !toUE) return undefined;
162164

@@ -173,7 +175,7 @@ export const UNITS_LIBRARY: SymbolDefinitions = {
173175
signature: '(value) -> value',
174176
evaluate: (ops, { engine: ce }) => {
175177
const arg = ops[0]?.evaluate();
176-
if (!arg || arg.operator !== 'Quantity') return arg;
178+
if (!arg || !isFunction(arg) || arg.operator !== 'Quantity') return arg;
177179

178180
const mag = arg.op1.re;
179181
const unitExpr = arg.op2;

test/compute-engine/units.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,3 +946,108 @@ describe('LATEX ROUND-TRIP COMPOUND UNITS', () => {
946946
expect(parsed.latex).toBe(original);
947947
});
948948
});
949+
950+
describe('UNIT CANCELLATION', () => {
951+
test('Same unit division gives scalar', () => {
952+
const expr = engine
953+
.box(['Divide', ['Quantity', 10, 'm'], ['Quantity', 2, 'm']])
954+
.evaluate();
955+
expect(expr.re).toBe(5);
956+
expect(expr.operator).not.toBe('Quantity');
957+
});
958+
959+
test('Compatible unit division gives scalar with scale', () => {
960+
const expr = engine
961+
.box(['Divide', ['Quantity', 1, 'km'], ['Quantity', 500, 'm']])
962+
.evaluate();
963+
expect(expr.re).toBe(2);
964+
});
965+
966+
test('Different dimension division gives compound unit', () => {
967+
const expr = engine
968+
.box(['Divide', ['Quantity', 100, 'm'], ['Quantity', 10, 's']])
969+
.evaluate();
970+
expect(expr.operator).toBe('Quantity');
971+
expect(expr.op1.re).toBe(10);
972+
});
973+
});
974+
975+
describe('AUTO-SIMPLIFY COMPOUND UNITS', () => {
976+
test('N * m simplifies to J', () => {
977+
const expr = engine
978+
.box(['Multiply', ['Quantity', 5, 'N'], ['Quantity', 2, 'm']])
979+
.evaluate();
980+
expect(expr.operator).toBe('Quantity');
981+
expect(expr.op1.re).toBe(10);
982+
expect(expr.op2.symbol).toBe('J');
983+
});
984+
985+
test('J / m simplifies to N', () => {
986+
const expr = engine
987+
.box(['Divide', ['Quantity', 100, 'J'], ['Quantity', 10, 'm']])
988+
.evaluate();
989+
expect(expr.operator).toBe('Quantity');
990+
expect(expr.op1.re).toBe(10);
991+
expect(expr.op2.symbol).toBe('N');
992+
});
993+
994+
test('J / s simplifies to W', () => {
995+
const expr = engine
996+
.box(['Divide', ['Quantity', 60, 'J'], ['Quantity', 2, 's']])
997+
.evaluate();
998+
expect(expr.operator).toBe('Quantity');
999+
expect(expr.op1.re).toBe(30);
1000+
expect(expr.op2.symbol).toBe('W');
1001+
});
1002+
});
1003+
1004+
describe('QUANTITY COMPARISON', () => {
1005+
test('Less: 500m < 1km', () => {
1006+
const expr = engine
1007+
.box(['Less', ['Quantity', 500, 'm'], ['Quantity', 1, 'km']])
1008+
.evaluate();
1009+
expect(expr.symbol).toBe('True');
1010+
});
1011+
1012+
test('Less: 1km < 500m is False', () => {
1013+
const expr = engine
1014+
.box(['Less', ['Quantity', 1, 'km'], ['Quantity', 500, 'm']])
1015+
.evaluate();
1016+
expect(expr.symbol).toBe('False');
1017+
});
1018+
1019+
test('Greater: 1km > 500m', () => {
1020+
const expr = engine
1021+
.box(['Greater', ['Quantity', 1, 'km'], ['Quantity', 500, 'm']])
1022+
.evaluate();
1023+
expect(expr.symbol).toBe('True');
1024+
});
1025+
1026+
test('Equal: 100cm == 1m', () => {
1027+
const expr = engine
1028+
.box(['Equal', ['Quantity', 100, 'cm'], ['Quantity', 1, 'm']])
1029+
.evaluate();
1030+
expect(expr.symbol).toBe('True');
1031+
});
1032+
1033+
test('Equal: 1km == 1000m', () => {
1034+
const expr = engine
1035+
.box(['Equal', ['Quantity', 1, 'km'], ['Quantity', 1000, 'm']])
1036+
.evaluate();
1037+
expect(expr.symbol).toBe('True');
1038+
});
1039+
1040+
test('LessEqual: 1km <= 1000m', () => {
1041+
const expr = engine
1042+
.box(['LessEqual', ['Quantity', 1, 'km'], ['Quantity', 1000, 'm']])
1043+
.evaluate();
1044+
expect(expr.symbol).toBe('True');
1045+
});
1046+
1047+
test('Incompatible units stay unevaluated', () => {
1048+
const expr = engine
1049+
.box(['Less', ['Quantity', 5, 'm'], ['Quantity', 3, 's']])
1050+
.evaluate();
1051+
expect(expr.operator).toBe('Less');
1052+
});
1053+
});

0 commit comments

Comments
 (0)