From c907dc231eefd6a79ab5b8dc910e568c8007b83e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 04:57:20 +0000 Subject: [PATCH 1/8] feat(calc): evaluate mixed unit expressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users type light math into quantity fields and models emit expressions they cannot reliably compute. @pascal-app/lingo/calc is a closed calculator over already-parsed values — no variables, functions, or dimensional algebra — so 7m*2 is 14 million, 9min x 4 is 36 min, and half of 56kg+1700g is 28.85 kg. lingo() stays range-first: 5-10 kg is still a range, 2 ft + 3 in is still a compound. 2+3 kg was a silent CJK-adjacent-range false positive and now fails with TRAILING_INPUT; calc('2+3 kg') is 5 kg. Completions and quantityField inject calc with trigger '=' so mixed fields do not steal dashes. Glued m at an operator boundary is million unless kind is length or duration (SCALE_ASSUMED); spaced 7 m is meters; 1m80 stays 1.80 m. Results format as words, grouped, scientific, or compact. expression is two-way infix; latex is display-only. Additive affine compounds warn AFFINE_DELTA_ASSUMED and still delta-convert. Budgets recalibrated in D73: the calculator is ~4 kB marginal / ~35 kB standalone. Full and core grow because the CJK gate, affine warning, and calc-scale matching must run inside parseQty before unit matching. Co-authored-by: Aymeric Rabot --- AGENTS.md | 1 + CONTEXT.md | 20 +- apps/site/public/llms-small.txt | 37 +- apps/site/src/lib/code-snippets.ts | 22 ++ apps/site/src/lib/docs-catalog.ts | 16 + apps/site/src/lib/docs.md.ts | 15 +- apps/site/src/lib/llms-index.ts | 2 +- packages/lingo/CHANGELOG.md | 24 ++ packages/lingo/README.md | 45 ++- packages/lingo/llms.txt | 37 +- packages/lingo/package.json | 10 + packages/lingo/scripts/size.mjs | 43 ++- packages/lingo/src/ai/quantity-fields.ts | 77 ++++- packages/lingo/src/calc/calc.test.ts | 241 +++++++++++++ packages/lingo/src/calc/eval.ts | 218 ++++++++++++ packages/lingo/src/calc/format.ts | 239 +++++++++++++ packages/lingo/src/calc/index.ts | 223 ++++++++++++ packages/lingo/src/calc/parse.ts | 379 +++++++++++++++++++++ packages/lingo/src/calc/types.ts | 130 +++++++ packages/lingo/src/complete/completions.ts | 22 ++ packages/lingo/src/complete/index.ts | 1 + packages/lingo/src/complete/types.ts | 4 + packages/lingo/src/core/errors.ts | 2 + packages/lingo/src/core/types.ts | 10 + packages/lingo/src/messages/en.test.ts | 5 + packages/lingo/src/messages/en.ts | 5 + packages/lingo/src/number/cjk.ts | 14 +- packages/lingo/src/number/value.ts | 19 +- packages/lingo/src/parse/config.ts | 3 +- packages/lingo/src/parse/grammar.test.ts | 12 + packages/lingo/src/parse/quantity.ts | 124 +++++++ packages/lingo/src/parse/range.ts | 5 +- packages/lingo/src/parse/tokenize.ts | 2 + packages/lingo/src/schema/enums.ts | 5 + packages/lingo/src/schema/schema.test.ts | 2 +- packages/lingo/tsup.config.ts | 1 + plans/032-input-calculations.md | 116 ++++--- plans/README.md | 2 +- wiki/api-design.md | 3 +- wiki/architecture.md | 10 +- wiki/decisions.md | 64 ++++ wiki/inspiration.md | 2 +- 42 files changed, 2129 insertions(+), 83 deletions(-) create mode 100644 packages/lingo/src/calc/calc.test.ts create mode 100644 packages/lingo/src/calc/eval.ts create mode 100644 packages/lingo/src/calc/format.ts create mode 100644 packages/lingo/src/calc/index.ts create mode 100644 packages/lingo/src/calc/parse.ts create mode 100644 packages/lingo/src/calc/types.ts diff --git a/AGENTS.md b/AGENTS.md index 2359e81..8a542f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,7 @@ Inside `packages/lingo/` the same scripts run directly (`bun run test`, etc.). - `fuzzy/` — fuzzy vocab ("hot", "a few") → ranges - `messages/` — default human-readable issue copy (`./core` ships copy-free) - `date/` — natural-language date/duration parsing + humanizing (entry `./date`) +- `calc/` — closed quantity arithmetic (entry `./calc`) - `describe/` — opt-in rich/resource value + result views (entry `./describe`) - `catalog/` — read-only query API over unit/kind/currency data (entry `./catalog`) - `dom/` — headless input controller (entry `./dom`) diff --git a/CONTEXT.md b/CONTEXT.md index 462a665..6613fe2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -45,6 +45,16 @@ latter two and absent on a slot — test it truthy, never `=== false`. (the normalizer keeps an offset map, hard rule 3). *Avoid: range, position, location.* +**Expression:** a closed arithmetic tree over numbers and same-kind quantities, +produced and evaluated by `calc()`. *Avoid: formula.* + +**Node:** one element of a calc expression (`CalcNode`). Its `span` is a span +into the original input, never a range. *Avoid: AST.* + +**Calc:** the `@pascal-app/lingo/calc` entry and its `calc()` function. +`lingo()` never evaluates expressions. Compact `"14m"` (million) round-trips +through `calc()`, not `lingo()`. + **Conversion:** a parsed conversion *request* ("72 in to cm") — a result type. The arithmetic itself is `convert()` / `convertDelta()`. @@ -109,7 +119,10 @@ locale pack objects. *Avoid: profile (ambiguous with fuzzy Profile).* Humanize output re-parses within one grain. **Format vs humanize:** `format()` renders quantities; `humanize*()` renders -dates and durations. Both are covered by the two-way guarantee (hard rule 4). +dates and durations; `formatCalc()` / `CalcResult.format()` renders evaluated +expression results (including words/compact/scientific). All three are covered +by the two-way guarantee (hard rule 4), with one calc-specific note: compact +`"14m"` re-parses through `calc()`, not `lingo()`. **Partial state:** the as-you-type classification — `empty | incomplete | valid | invalid`. "2 f" is *incomplete*, never invalid (the DOM layer never yells @@ -120,7 +133,7 @@ its value. Fields never rewrite text while typing (D6). ## Infrastructure nouns -**Entry:** a published subpath — `.`, `./core`, `./date`, `./dom`, `./element`, +**Entry:** a published subpath — `.`, `./core`, `./date`, `./calc`, `./dom`, `./element`, `./describe`, `./catalog`, `./schema`, `./ai`, `./mcp`, `./react`, `./react-native`, `./complete`, `./locales/*`. *Avoid: subpackage, plugin.* @@ -155,6 +168,9 @@ inline. - **"range" vs "span"** — the collision that bites. Values: range. Text offsets: span. No exceptions. +- **"m" in calc** — glued `7m*2` is seven million times two; spaced `7 m` is + meters; `lingo('14m')` is fourteen meters. Compact `"14m"` round-trips + through `calc()` only. - **"error"** — fine as a severity or the `ok: false` state; the object is an issue. - **"unit"** in prose can mean id, symbol, or def — in code, use the precise diff --git a/apps/site/public/llms-small.txt b/apps/site/public/llms-small.txt index 5f2eb66..ad55cbb 100644 --- a/apps/site/public/llms-small.txt +++ b/apps/site/public/llms-small.txt @@ -4,7 +4,7 @@ Agent fetch order (online): `https://lingo.pascal.app/llms.txt` (index) → `https://lingo.pascal.app/docs/
.md` (per-topic) or `https://lingo.pascal.app/llms-full.txt` (complete narrative). Offline: this file (`node_modules/@pascal-app/lingo/llms.txt`) is the compressed self-contained reference. Keep user measurements as strings in tool schemas; call lingo to convert, validate, surface spans, and handle ambiguity. -Entries: `@pascal-app/lingo` (core+units+fuzzy), `@pascal-app/lingo/date`, `@pascal-app/lingo/dom`, `@pascal-app/lingo/element` (``), `@pascal-app/lingo/react`, `@pascal-app/lingo/react-native`, `@pascal-app/lingo/ai` (LLM tool fields), `@pascal-app/lingo/mcp` (MCP tool helper), `@pascal-app/lingo/describe` (rich value/result descriptions), `@pascal-app/lingo/catalog` (query units/kinds/currencies + ISO country codes), `@pascal-app/lingo/complete` (ranked autocomplete completions), `@pascal-app/lingo/schema` (JSON Schema + OpenAPI + enum reference), `@pascal-app/lingo/locales/{en,en-gb,es,fr,pt,zh,ja}` (tree-shakeable language packs), `@pascal-app/lingo/core`. +Entries: `@pascal-app/lingo` (core+units+fuzzy), `@pascal-app/lingo/date`, `@pascal-app/lingo/calc`, `@pascal-app/lingo/dom`, `@pascal-app/lingo/element` (``), `@pascal-app/lingo/react`, `@pascal-app/lingo/react-native`, `@pascal-app/lingo/ai` (LLM tool fields), `@pascal-app/lingo/mcp` (MCP tool helper), `@pascal-app/lingo/describe` (rich value/result descriptions), `@pascal-app/lingo/catalog` (query units/kinds/currencies + ISO country codes), `@pascal-app/lingo/complete` (ranked autocomplete completions), `@pascal-app/lingo/schema` (JSON Schema + OpenAPI + enum reference), `@pascal-app/lingo/locales/{en,en-gb,es,fr,pt,zh,ja}` (tree-shakeable language packs), `@pascal-app/lingo/core`. ## Core (`@pascal-app/lingo`) @@ -38,10 +38,10 @@ Kinds: length mass temperature duration volume area speed data data_rate flow_ra import { completions } from "@pascal-app/lingo/complete" const items = completions("10 kg to 16", { kind: "mass", limit: 8 }) -// [{ text, result, confidence, source }] — source: parse|alternative|unit-ambiguity|unit-prefix|implied-unit|range-implied|cross-kind|date +// [{ text, result, confidence, source }] — source: parse|alternative|unit-ambiguity|unit-prefix|implied-unit|range-implied|cross-kind|date|calc ``` -Distinct from success `alternatives`, failure `candidate`, and issue `suggestions`. Wire into `lingoInput({ complete, onComplete })` or `useLingoInput({ complete })` for autocomplete dropdowns. The React hook exposes `completions`, `highlightedIndex`, `setHighlightedIndex()`, and `selectCompletion()` while keeping popup UI caller-owned. Pass `date: (text) => parseDate(text, { now })` to opt into date completions without bundling `@pascal-app/lingo/date` into `@pascal-app/lingo/complete`. +Distinct from success `alternatives`, failure `candidate`, and issue `suggestions`. Wire into `lingoInput({ complete, onComplete })` or `useLingoInput({ complete })` for autocomplete dropdowns. The React hook exposes `completions`, `highlightedIndex`, `setHighlightedIndex()`, and `selectCompletion()` while keeping popup UI caller-owned. Pass `date: (text) => parseDate(text, { now })` to opt into date completions without bundling `@pascal-app/lingo/date` into `@pascal-app/lingo/complete`. Pass `calc: (text) => calc(text, { trigger: "=" })` the same way so `=2+3 kg` completes as `= 5 kg` without bundling `@pascal-app/lingo/calc`; bare `5-10 kg` stays a range. ## Locales (`@pascal-app/lingo/locales/*`) @@ -111,6 +111,28 @@ humanizeDate(d, { now }) // "3 days ago" — always re-parses within one grain Not supported: quarters (`Q3`, `next quarter`), elliptical right sides (`Aug 3-9`), and dash-joined ISO dates with no spaces (`2026-08-01-2026-08-05`) — all return `UNSUPPORTED_DATE`. Use a spaced dash or `to` between ISO dates. +## Calc (`@pascal-app/lingo/calc`) + +```ts +import { calc } from "@pascal-app/lingo/calc" + +const r = calc("7m*2") +r.ok && r.value // 14000000 +r.ok && r.format({ style: "words" }) // "14 million" +r.ok && r.format({ style: "grouped" }) // "14,000,000" +r.ok && r.format({ style: "scientific" }) // "14e6" +r.ok && r.format({ style: "compact" }) // "14m" +r.ok && r.expression // "7e6 × 2" +r.ok && r.latex // "7 \\times 10^{6} \\times 2" + +calc("9min x 4") // 36 min; format({ unit: "h" }) → "0.6 h" +calc("half of 56kg+1700g") // 28.85 kg +calc("12 * 0.75 kg") // 9 kg +calc("10% off 50 kg") // 45 kg +``` + +Closed calculator: no variables, functions, or dimensional algebra. `lingo()` never evaluates expressions — `2+3 kg` is `TRAILING_INPUT`, `5-10 kg` stays a range. `calc()` default `trigger` is `"always"`; mixed fields inject `{ trigger: "=" }`. Glued `m` at an operator boundary is million unless `kind` is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` is a dimensionless ratio; `q * q` is `SCALAR_EXPECTED`. `formatExpression` is two-way; `formatLatex` is display-only. + ## DOM (`@pascal-app/lingo/dom`) ```ts @@ -334,12 +356,17 @@ The `/docs#forms-ux` section and `/docs/forms-ux.md` markdown mirror show the sa | RANGE_REVERSED | Bounds given high-to-low; parser swapped them | Emit low-to-high, or accept the swap | | UNSUPPORTED_DATE | Date/range shape not in the grammar (`Q3`, `Aug 3-9`) | Re-emit a supported shape or an explicit `start to end` pair | | RATE_REQUIRED | Cross-currency without rates | Call `convertCurrency` with injected rates | +| AFFINE_DELTA_ASSUMED | Additive °C/°F used as a delta | Accept the delta reading, or convert absolutely first | +| EXPRESSION_KIND_MISMATCH | Mixed kinds in `calc()` | Keep operands the same kind | +| SCALAR_EXPECTED | `q * q` or `n / q` | Scale a quantity by a number, or divide two same-kind quantities | +| DIVISION_BY_ZERO | Division by zero | Use a non-zero divisor | +| SCALE_ASSUMED | Glued `m`/`b` read as million/billion | Pass `kind: "length"` for meters, or write `7 million` | -Full list: EMPTY, NO_VALUE, UNKNOWN_UNIT, KIND_MISMATCH, RANGE_KIND_MISMATCH, CONVERSION_KIND_MISMATCH, RATE_REQUIRED, TRAILING_INPUT, SINGLE_VALUE_EXPECTED, APPROX_NOT_ALLOWED, UNIT_REQUIRED, CONVERSION_NOT_ALLOWED, NUMBER_FORMAT, NONFINITE, LOCALE_NOT_LOADED, RANGE_MIN, RANGE_MAX, RANGE_OPEN_BOUND_NOT_ALLOWED, REQUIRED, UNSUPPORTED_DATE, NOW_REQUIRED, TYPO_CORRECTED, AMBIGUOUS_NUMBER, AMBIGUOUS_UNIT, AMBIGUOUS_DATE, RANGE_REVERSED, COMPOUND_OVERFLOW, CIVIL_AVERAGE, UNIT_ASSUMED, WEEKDAY_ASSUMED_NEXT, SLANG_UNIT, TZ_IGNORED, AMBIGUOUS_TIMEZONE. Override copy via `messages` option map. +Full list: EMPTY, NO_VALUE, UNKNOWN_UNIT, KIND_MISMATCH, RANGE_KIND_MISMATCH, CONVERSION_KIND_MISMATCH, RATE_REQUIRED, TRAILING_INPUT, SINGLE_VALUE_EXPECTED, APPROX_NOT_ALLOWED, UNIT_REQUIRED, CONVERSION_NOT_ALLOWED, NUMBER_FORMAT, NONFINITE, LOCALE_NOT_LOADED, RANGE_MIN, RANGE_MAX, RANGE_OPEN_BOUND_NOT_ALLOWED, REQUIRED, UNSUPPORTED_DATE, NOW_REQUIRED, TYPO_CORRECTED, AMBIGUOUS_NUMBER, AMBIGUOUS_UNIT, AMBIGUOUS_DATE, RANGE_REVERSED, COMPOUND_OVERFLOW, CIVIL_AVERAGE, UNIT_ASSUMED, WEEKDAY_ASSUMED_NEXT, SLANG_UNIT, TZ_IGNORED, AMBIGUOUS_TIMEZONE, AFFINE_DELTA_ASSUMED, EXPRESSION_KIND_MISMATCH, SCALAR_EXPECTED, DIVISION_BY_ZERO, SCALE_ASSUMED. Override copy via `messages` option map. ## Canonical examples (input → essence) -"2 ft" → quantity length base 0.6096 m · "5'11\"" → 1.8034 m (parts ft+in) · "72 in to cm" → conversion, converted 182.88 cm · "60 miles an hour" → speed in m/s · "5 cubic feet" → volume in m³ · "approx. 5 kg" → approximate mass · "1m80" → 1.8 m · "1h30" → 5400 s · "2 lb 3 oz" → 0.9922 kg · "$5" → currency USD value/base 5 baseUnit USD + AMBIGUOUS_UNIT; pass `{currency:'CAD'}` to read bare "$" as CAD · "50 cents" → 0.5 USD + AMBIGUOUS_UNIT; pass `{currency:'EUR'}` to read as EUR · "five dollars and fifty cents" → 5.5 USD · "50p" → 0.5 GBP · "3 quid 50" → 3.5 GBP · "€5-€10" → currency range baseUnit EUR · "5 EUR to USD" → ok:false RATE_REQUIRED (use convertCurrency with injected rates) · "between 5 and 10 kg" → range 5..10 kg · "under 10 minutes" → range max 600 s exclusive · "no greater than 5 kg" → range max 5 kg inclusive · "10 ± 0.5 mm" → plusMinus center 10 mm/base 0.01 and delta 0.5 mm/base 0.0005 · "a few minutes" → range 120..240 s approximate · "it's hot" (kind temperature) → range 300.15..308.15 K fuzzy 'hot' · "1,5 kg" → 1.5 kg · "1,234" → 1234 + AMBIGUOUS_NUMBER (alt 1.234) · "5 meterz" (kind length) → 5 m + TYPO_CORRECTED; with strictness confirm → ok:false + candidate 5 m · "72" (kind length, unit cm, accept.bareNumbers false) → UNIT_REQUIRED + candidate 72 cm · "72 in to cm" with accept.conversions false → CONVERSION_NOT_ALLOWED + candidate conversion · "5m" (kind duration) → 300 s + SLANG_UNIT · "in 2d" → date two days from now · "3min from tmrw" → tomorrow, same time-of-day +3 min · "17h30" → 17:30 · "quarter past 5" → 05:15 · "3pm EST" → 15:00 civil + zone {abbrev, -300, ambiguous} + TZ_IGNORED/AMBIGUOUS_TIMEZONE; `{applyZone:true}` → the 20:00Z instant · "2pm to 4pm" → date-range 14:00..16:00 · "9-5" → date-range 09:00..17:00 (workday shift) · "Aug 3 - Aug 9" → dated date-range 2026-08-03..2026-08-09 · "August" → dated date-range 2026-08-01..2026-08-31 (whole month) · "next week" → Mon..Sun · "this weekend" → Sat..Sun (the one in progress on Sat/Sun) · "2026-08-09 to 2026-08-03" → swapped 08-03..08-09 + RANGE_REVERSED · "Q3" → ok:false UNSUPPORTED_DATE (quarters need a fiscal-year anchor; not supported) · "500 KB" → 500000 B · "5 Mb" → 625000 B (megabits) · "5 Mbps" → data_rate 5000000 bit/s; use "bit/s" for bits per second because bare "bps" stays basis points · "5 gpm" → flow_rate 0.000315451 m³/s · "250 mL/min" → flow_rate 0.000004167 m³/s · "10 inH₂O" → pressure 2490.8891 Pa · "1 kgf/cm²" → pressure 98.0665 kPa · "1 kg/cm²" → ok:false TRAILING_INPUT (kilogram-mass over area deferred; use kgf/cm²) · "5 psig" (kind pressure) → ok:false UNKNOWN_UNIT (gauge semantics deferred) · "9.8 m/s²" → acceleration 9.8 m/s² · "10 Nm" → torque 10 N⋅m (exact-case; lowercase "nm" remains nanometers) · "500 lux" → illuminance 500 lx · "100 nits" → luminance 100 cd/m² · "20 mSv" → radiation equivalent dose 0.02 Sv · "5 MBq" → radioactivity 5000000 Bq · "5 uM" → concentration 0.005 mol/m³ · "1 mol/L" and "1 mol per L" → concentration 1000 mol/m³; untyped glued "1M" fails, use "1 M" or `kind:'concentration'` · "-40°F" → 233.15 K · "3×10⁵ m" → 300000 m · "½ cup" → 118.29 mL · "15%" → percent 15 · "25 bps" → 0.25% (basis points; bare bps stays percent) · "500 mAh" → charge 1800 C · "4.7 kohm" → resistance 4700 Ω · "250 mmol" → substance 0.25 mol. +"2 ft" → quantity length base 0.6096 m · "5'11\"" → 1.8034 m (parts ft+in) · "72 in to cm" → conversion, converted 182.88 cm · "7m*2" via `@pascal-app/lingo/calc` → 14000000 + SCALE_ASSUMED (compact "14m", words "14 million") · "9min x 4" via calc → 36 min · "half of 56kg+1700g" via calc → 28.85 kg · "2+3 kg" via lingo → ok:false TRAILING_INPUT (not a silent 2–3 kg range); via calc → 5 kg · "60 miles an hour" → speed in m/s · "5 cubic feet" → volume in m³ · "approx. 5 kg" → approximate mass · "1m80" → 1.8 m · "1h30" → 5400 s · "2 lb 3 oz" → 0.9922 kg · "$5" → currency USD value/base 5 baseUnit USD + AMBIGUOUS_UNIT; pass `{currency:'CAD'}` to read bare "$" as CAD · "50 cents" → 0.5 USD + AMBIGUOUS_UNIT; pass `{currency:'EUR'}` to read as EUR · "five dollars and fifty cents" → 5.5 USD · "50p" → 0.5 GBP · "3 quid 50" → 3.5 GBP · "€5-€10" → currency range baseUnit EUR · "5 EUR to USD" → ok:false RATE_REQUIRED (use convertCurrency with injected rates) · "between 5 and 10 kg" → range 5..10 kg · "under 10 minutes" → range max 600 s exclusive · "no greater than 5 kg" → range max 5 kg inclusive · "10 ± 0.5 mm" → plusMinus center 10 mm/base 0.01 and delta 0.5 mm/base 0.0005 · "a few minutes" → range 120..240 s approximate · "it's hot" (kind temperature) → range 300.15..308.15 K fuzzy 'hot' · "1,5 kg" → 1.5 kg · "1,234" → 1234 + AMBIGUOUS_NUMBER (alt 1.234) · "5 meterz" (kind length) → 5 m + TYPO_CORRECTED; with strictness confirm → ok:false + candidate 5 m · "72" (kind length, unit cm, accept.bareNumbers false) → UNIT_REQUIRED + candidate 72 cm · "72 in to cm" with accept.conversions false → CONVERSION_NOT_ALLOWED + candidate conversion · "5m" (kind duration) → 300 s + SLANG_UNIT · "in 2d" → date two days from now · "3min from tmrw" → tomorrow, same time-of-day +3 min · "17h30" → 17:30 · "quarter past 5" → 05:15 · "3pm EST" → 15:00 civil + zone {abbrev, -300, ambiguous} + TZ_IGNORED/AMBIGUOUS_TIMEZONE; `{applyZone:true}` → the 20:00Z instant · "2pm to 4pm" → date-range 14:00..16:00 · "9-5" → date-range 09:00..17:00 (workday shift) · "Aug 3 - Aug 9" → dated date-range 2026-08-03..2026-08-09 · "August" → dated date-range 2026-08-01..2026-08-31 (whole month) · "next week" → Mon..Sun · "this weekend" → Sat..Sun (the one in progress on Sat/Sun) · "2026-08-09 to 2026-08-03" → swapped 08-03..08-09 + RANGE_REVERSED · "Q3" → ok:false UNSUPPORTED_DATE (quarters need a fiscal-year anchor; not supported) · "500 KB" → 500000 B · "5 Mb" → 625000 B (megabits) · "5 Mbps" → data_rate 5000000 bit/s; use "bit/s" for bits per second because bare "bps" stays basis points · "5 gpm" → flow_rate 0.000315451 m³/s · "250 mL/min" → flow_rate 0.000004167 m³/s · "10 inH₂O" → pressure 2490.8891 Pa · "1 kgf/cm²" → pressure 98.0665 kPa · "1 kg/cm²" → ok:false TRAILING_INPUT (kilogram-mass over area deferred; use kgf/cm²) · "5 psig" (kind pressure) → ok:false UNKNOWN_UNIT (gauge semantics deferred) · "9.8 m/s²" → acceleration 9.8 m/s² · "10 Nm" → torque 10 N⋅m (exact-case; lowercase "nm" remains nanometers) · "500 lux" → illuminance 500 lx · "100 nits" → luminance 100 cd/m² · "20 mSv" → radiation equivalent dose 0.02 Sv · "5 MBq" → radioactivity 5000000 Bq · "5 uM" → concentration 0.005 mol/m³ · "1 mol/L" and "1 mol per L" → concentration 1000 mol/m³; untyped glued "1M" fails, use "1 M" or `kind:'concentration'` · "-40°F" → 233.15 K · "3×10⁵ m" → 300000 m · "½ cup" → 118.29 mL · "15%" → percent 15 · "25 bps" → 0.25% (basis points; bare bps stays percent) · "500 mAh" → charge 1800 C · "4.7 kohm" → resistance 4700 Ω · "250 mmol" → substance 0.25 mol. ## Docs (online) diff --git a/apps/site/src/lib/code-snippets.ts b/apps/site/src/lib/code-snippets.ts index 8e0397d..da96113 100644 --- a/apps/site/src/lib/code-snippets.ts +++ b/apps/site/src/lib/code-snippets.ts @@ -165,6 +165,28 @@ parseDateRange("August", { now }) // Aug 1 → Aug 31, not just t parseDuration("1h30").duration.base // 5400 (seconds) humanizeDuration(5400, { style: "natural" }) // "an hour and a half"` +export const calcSnippet = `import { calc } from "@pascal-app/lingo/calc" +import { quantityField } from "@pascal-app/lingo/ai" +import { completions } from "@pascal-app/lingo/complete" + +const million = calc("7m*2") +million.ok && million.value // 14000000 +million.ok && million.format({ style: "words" }) // "14 million" +million.ok && million.format({ style: "grouped" }) // "14,000,000" +million.ok && million.format({ style: "scientific" }) // "14e6" +million.ok && million.format({ style: "compact" }) // "14m" +million.ok && million.expression // "7e6 × 2" +million.ok && million.latex // "7 \\\\times 10^{6} \\\\times 2" + +const duration = calc("9min x 4") +duration.ok && duration.format() // "36 min" +duration.ok && duration.format({ unit: "h" }) // "0.6 h" + +calc("half of 56kg+1700g") // 28.85 kg + +quantityField({ kind: "mass", unit: "kg", calc }).parse("12 * 0.75 kg") // 9 +completions("=2+3 kg", { calc: (text) => calc(text, { trigger: "=" }) })` + export const localeSnippet = `import { createLingo } from "@pascal-app/lingo" import { es } from "@pascal-app/lingo/locales/es" import { fr } from "@pascal-app/lingo/locales/fr" diff --git a/apps/site/src/lib/docs-catalog.ts b/apps/site/src/lib/docs-catalog.ts index 5bcbd00..aca1acc 100644 --- a/apps/site/src/lib/docs-catalog.ts +++ b/apps/site/src/lib/docs-catalog.ts @@ -321,6 +321,22 @@ export const docsNavGroups: DocsNavGroup[] = [ ], { depth: 3, markdownSectionId: 'dates' }, ), + page( + 'calculations', + 'Calculations', + 'Evaluate mixed unit math without turning lingo into a CAS.', + [ + 'calc', + 'expression', + 'arithmetic', + '7m*2', + 'latex', + '14 million', + 'percent off', + 'half of', + 'SCALE_ASSUMED', + ], + ), page('locales', 'Locales', 'Load tree-shakeable language packs for parsing.', [ 'locale', 'locale pack', diff --git a/apps/site/src/lib/docs.md.ts b/apps/site/src/lib/docs.md.ts index 3129a41..753136a 100644 --- a/apps/site/src/lib/docs.md.ts +++ b/apps/site/src/lib/docs.md.ts @@ -102,7 +102,7 @@ export const docsMarkdown = [ '', fenced('ts', completionsSnippet), '', - 'Inject it into a field with `lingoInput({ complete, onComplete })` or `useLingoInput({ complete })`; the engine is never bundled into `@pascal-app/lingo/dom` or `/react`, and no popup UI ships. Pass `date` to opt into date completions without pulling `@pascal-app/lingo/date` into `/complete`.', + 'Inject it into a field with `lingoInput({ complete, onComplete })` or `useLingoInput({ complete })`; the engine is never bundled into `@pascal-app/lingo/dom` or `/react`, and no popup UI ships. Pass `date` to opt into date completions without pulling `@pascal-app/lingo/date` into `/complete`. Pass `calc` with `{ trigger: "=" }` so `=2+3 kg` completes as an evaluated quantity without bundling `@pascal-app/lingo/calc`.', '', '### Find values in text', '', @@ -324,6 +324,16 @@ useForm({ resolver: standardSchemaResolver(shipment) })`, '', 'Outside the grammar, and returning `UNSUPPORTED_DATE` rather than a guess: quarters (`Q3`, `next quarter` — they need a fiscal-year anchor to mean anything), elliptical right sides (`Aug 3-9`), and ISO dates dash-joined with no spaces (`2026-08-01-2026-08-05` has four dashes and no way to tell which one splits; `2026-08-01 - 2026-08-05` and `2026-08-01 to 2026-08-05` both parse).', '', + '## Calculations', + '', + 'Arithmetic over quantities lives in `@pascal-app/lingo/calc`. `lingo()` never evaluates expressions — mixed-unit compounds like `2 ft + 3 in` stay compounds, `5-10 kg` stays a range, and `2+3 kg` is trailing input rather than a silent 2–3 kg range. `calc()` is a closed calculator: no variables, no functions, no dimensional algebra.', + '', + fenced('ts', calcSnippet), + '', + 'Glued `m`/`M` at an operator boundary is million (`7m*2` → 14 million) unless `kind` is `length` or `duration`; spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` cancels to a dimensionless ratio; `q * q` is `SCALAR_EXPECTED`. Additive affine units warn `AFFINE_DELTA_ASSUMED` and delta-convert. `formatExpression` is two-way infix; `formatLatex` is display-only.', + '', + 'Inject `calc` into `completions({ calc })` and `quantityField({ calc })` so `=2+3 kg` and `12 * 0.75 kg` evaluate without bundling the calculator into those entries. Completions only fire on a leading `=`, so `5-10 kg` is not stolen. `rangeField` stays range-first.', + '', '## Locales', '', 'Locale packs are opt-in and tree-shakeable. English is built in; load overlays with `createLingo({ locales })` and pass `locale` when a field is known, or omit it for auto-detection among loaded packs plus English.', @@ -429,7 +439,7 @@ lingo.parse('72 in to cm') // locale: 'en'`, '', 'Parse results are versioned discriminated unions: `{ schemaVersion: 3, ok, type, text, issues }`; successes add `span`, `confidence`, and the parsed value, while failures use `type: "failure"` and may include a full `candidate` result. Success `alternatives` are also discriminated (`type: "quantity"` or `type: "date"`). Built-in `createLingo()` instances keep the same literal-unit checks as top-level `quantity`/`convert`/`tryConvert`; custom registry instances keep broad string refs. `tryConvert()` mirrors absolute `convert()` but returns `{ ok: true, type: "conversion", value, unit, kind }` or `{ ok: false, type: "failure", issues }` instead of throwing. Quantity instances expose `.value`, `.base`, `.unit`, `.kind`, `.to(unitRef)`, `.valueIn(unitRef)`, `.convertDelta(unitRef)`, `.toMinor()` for currencies, `.format(opts?)`, `.toBest(opts?)`, and `.toJSON()`. Format defaults are parseable; `localizedUnits: true` is display-only for Intl unit words. Quantity JSON is self-describing: `{ schemaVersion: 3, type, kind, value, unit, base, baseUnit }`; `@pascal-app/lingo/describe` adds unit labels and formatted strings for values, `describeResource()` returns direct `lingo.quantity` / `lingo.range` resources with grouped `value` and `canonical` amounts, and `describeResult()` returns an opt-in resource-style parse-result view with `object`, `resourceSchemaVersion`, grouped `value`/`canonical` amounts, range `canonicalUnit`, source text spans including full-input failure spans, rich issues, alternatives, candidates, conversion `{ source, target: { unit }, converted }` data, `lingo.date` `{ value: { iso, epochMilliseconds }, calendar, grain, known }`, and `lingo.duration` `{ value, canonical, formatted, parts? }`. Quantity ranges expose `.minBase`, `.maxBase`, `.min()`, `.max()`, `.plusMinus`, `.fuzzy`, `.contains(q)`, `.widthIn(unitRef)`, `.to(unitRef)`, and `.format()`.', '', - 'Issues are `{ code, severity, message, span, suggestions?, data? }`; parse-path spans are `{ start, end }` half-open offsets into the original input. Issue codes documented by the package: `EMPTY`, `NO_VALUE`, `UNKNOWN_UNIT`, `KIND_MISMATCH`, `RANGE_KIND_MISMATCH`, `CONVERSION_KIND_MISMATCH`, `RATE_REQUIRED`, `TRAILING_INPUT`, `SINGLE_VALUE_EXPECTED`, `APPROX_NOT_ALLOWED`, `UNIT_REQUIRED`, `CONVERSION_NOT_ALLOWED`, `NUMBER_FORMAT`, `NONFINITE`, `LOCALE_NOT_LOADED`, `RANGE_MIN`, `RANGE_MAX`, `RANGE_OPEN_BOUND_NOT_ALLOWED`, `REQUIRED`, `UNSUPPORTED_DATE`, `NOW_REQUIRED`, `TYPO_CORRECTED`, `AMBIGUOUS_NUMBER`, `AMBIGUOUS_UNIT`, `AMBIGUOUS_DATE`, `RANGE_REVERSED`, `COMPOUND_OVERFLOW`, `CIVIL_AVERAGE`, `UNIT_ASSUMED`, `WEEKDAY_ASSUMED_NEXT`, `SLANG_UNIT`, `TZ_IGNORED`, `AMBIGUOUS_TIMEZONE`.', + 'Issues are `{ code, severity, message, span, suggestions?, data? }`; parse-path spans are `{ start, end }` half-open offsets into the original input. Issue codes documented by the package: `EMPTY`, `NO_VALUE`, `UNKNOWN_UNIT`, `KIND_MISMATCH`, `RANGE_KIND_MISMATCH`, `CONVERSION_KIND_MISMATCH`, `RATE_REQUIRED`, `TRAILING_INPUT`, `SINGLE_VALUE_EXPECTED`, `APPROX_NOT_ALLOWED`, `UNIT_REQUIRED`, `CONVERSION_NOT_ALLOWED`, `NUMBER_FORMAT`, `NONFINITE`, `LOCALE_NOT_LOADED`, `RANGE_MIN`, `RANGE_MAX`, `RANGE_OPEN_BOUND_NOT_ALLOWED`, `REQUIRED`, `UNSUPPORTED_DATE`, `NOW_REQUIRED`, `TYPO_CORRECTED`, `AMBIGUOUS_NUMBER`, `AMBIGUOUS_UNIT`, `AMBIGUOUS_DATE`, `RANGE_REVERSED`, `COMPOUND_OVERFLOW`, `CIVIL_AVERAGE`, `UNIT_ASSUMED`, `WEEKDAY_ASSUMED_NEXT`, `SLANG_UNIT`, `TZ_IGNORED`, `AMBIGUOUS_TIMEZONE`, `AFFINE_DELTA_ASSUMED`, `EXPRESSION_KIND_MISMATCH`, `SCALAR_EXPECTED`, `DIVISION_BY_ZERO`, `SCALE_ASSUMED`.', '', '### Data schemas', '', @@ -458,6 +468,7 @@ const markdownHeadings: Record = { convert: 'Convert & format', currency: 'Currency', dates: 'Dates & durations', + calculations: 'Calculations', locales: 'Locales', coverage: 'Catalog', performance: 'Performance', diff --git a/apps/site/src/lib/llms-index.ts b/apps/site/src/lib/llms-index.ts index a14c383..d530bff 100644 --- a/apps/site/src/lib/llms-index.ts +++ b/apps/site/src/lib/llms-index.ts @@ -11,7 +11,7 @@ export function buildLlmsIndex() { const lines = [ '# lingo', '', - '> Make forms easier, LLM tools safer. Zero-dependency TypeScript library that parses natural-language quantities, units, dates, and ranges into canonical SI-anchored values; converts, validates, formats, and humanizes. Entries: `@pascal-app/lingo`, `@pascal-app/lingo/date`, `@pascal-app/lingo/dom`, `@pascal-app/lingo/element`, `@pascal-app/lingo/react`, `@pascal-app/lingo/react-native`, `@pascal-app/lingo/ai`, `@pascal-app/lingo/mcp`, `@pascal-app/lingo/describe`, `@pascal-app/lingo/catalog`, `@pascal-app/lingo/complete`, `@pascal-app/lingo/schema`, `@pascal-app/lingo/locales/{en,en-gb,es,fr,pt,zh,ja}`, `@pascal-app/lingo/core`.', + '> Make forms easier, LLM tools safer. Zero-dependency TypeScript library that parses natural-language quantities, units, dates, and ranges into canonical SI-anchored values; converts, validates, formats, and humanizes. Entries: `@pascal-app/lingo`, `@pascal-app/lingo/date`, `@pascal-app/lingo/calc`, `@pascal-app/lingo/dom`, `@pascal-app/lingo/element`, `@pascal-app/lingo/react`, `@pascal-app/lingo/react-native`, `@pascal-app/lingo/ai`, `@pascal-app/lingo/mcp`, `@pascal-app/lingo/describe`, `@pascal-app/lingo/catalog`, `@pascal-app/lingo/complete`, `@pascal-app/lingo/schema`, `@pascal-app/lingo/locales/{en,en-gb,es,fr,pt,zh,ja}`, `@pascal-app/lingo/core`.', '', 'Agent fetch order: read this index first, then fetch section markdown at `/docs/
.md` for the topic you need, or `/llms-full.txt` for the complete narrative. For offline or compressed reference, fetch `/llms-small.txt` (npm-shipped package reference). Keep user measurements as strings in tool schemas; call lingo to convert, validate, surface spans, and handle ambiguity.', '', diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md index c984ac2..5d84ba4 100644 --- a/packages/lingo/CHANGELOG.md +++ b/packages/lingo/CHANGELOG.md @@ -7,6 +7,30 @@ change**, even if the API is untouched. ## [Unreleased] +### Added + +- Quantity arithmetic at `@pascal-app/lingo/calc`. Closed calculator over + already-parsed values — no variables, functions, or dimensional algebra. + `calc('7m*2')` is 14 million (glued `m` at an operator boundary, with + `SCALE_ASSUMED`); `calc('9min x 4')` is 36 min or 0.6 h; + `calc('half of 56kg+1700g')` is 28.85 kg. Results format as words + (`"14 million"`), grouped (`"14,000,000"`), scientific (`"14e6"`), or + compact (`"14m"`). `expression` is two-way infix (`7e6 × 2`); `latex` is + display-only. Inject `calc` into `completions()` and `quantityField()` so + mixed fields keep range-first dashes (`5-10 kg` stays a range; `=2+3 kg` + evaluates). +- Issue codes `AFFINE_DELTA_ASSUMED`, `EXPRESSION_KIND_MISMATCH`, + `SCALAR_EXPECTED`, `DIVISION_BY_ZERO`, `SCALE_ASSUMED`. + +### Changed + +- `2+3 kg` is no longer a silent 2–3 kg range (CJK adjacent-range false + positive). It fails with `TRAILING_INPUT`; use `calc('2+3 kg')` for 5 kg. + Genuine CJK juxtaposition (`七八天`) is unchanged. Not an English corpus + row; recorded as an interpretation change in D73. +- Additive affine compounds (`20°C + 5°C`) still delta-convert, and now warn + `AFFINE_DELTA_ASSUMED`. + ## [0.4.0] - 2026-08-02 ### Added diff --git a/packages/lingo/README.md b/packages/lingo/README.md index 32bc638..0c79bc1 100644 --- a/packages/lingo/README.md +++ b/packages/lingo/README.md @@ -389,6 +389,48 @@ with no spaces (`2026-08-01-2026-08-05`) are not in the grammar and return The humanizer's output is guaranteed re-parseable by the parser (round-trip tested): `parseDate(humanizeDate(d, { now }), { now })` lands within one display-grain of `d`. +### Calculations: `@pascal-app/lingo/calc` + +A closed calculator over quantities and numbers. `lingo()` never does this — +`5-10 kg` stays a range, `2 ft + 3 in` stays a compound, and `2+3 kg` is +trailing input rather than a silent 2–3 kg range. + +```ts +import { calc } from '@pascal-app/lingo/calc' + +const million = calc('7m*2') +million.ok && million.value // 14000000 +million.ok && million.format({ style: 'words' }) // "14 million" +million.ok && million.format({ style: 'grouped' }) // "14,000,000" +million.ok && million.format({ style: 'scientific' }) // "14e6" +million.ok && million.format({ style: 'compact' }) // "14m" +million.ok && million.expression // "7e6 × 2" +million.ok && million.latex // "7 \\times 10^{6} \\times 2" + +const duration = calc('9min x 4') +duration.ok && duration.format() // "36 min" +duration.ok && duration.format({ unit: 'h' }) // "0.6 h" + +calc('half of 56kg+1700g') // 28.85 kg +``` + +Glued `m` at an operator boundary is million unless `kind` is `length` or +`duration`; spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` +round-trips through `calc()`, not `lingo()`. Same-kind `q / q` cancels to a +ratio; `q * q` is `SCALAR_EXPECTED`. Inject into mixed fields so only a +leading `=` opts into arithmetic: + +```ts +import { calc } from '@pascal-app/lingo/calc' +import { quantityField } from '@pascal-app/lingo/ai' +import { completions } from '@pascal-app/lingo/complete' + +quantityField({ kind: 'mass', unit: 'kg', calc }).parse('12 * 0.75 kg') // 9 +completions('=2+3 kg', { calc: (text) => calc(text, { trigger: '=' }) }) +``` + +The grammar cannot express a call, a scope, or a side effect. + ### Forms: `@pascal-app/lingo/dom` Turn any `` into a natural-language field. Headless: no styles shipped, @@ -893,7 +935,8 @@ NONFINITE · LOCALE_NOT_LOADED · RANGE_MIN · RANGE_MAX · REQUIRED · UNSUPPOR RANGE_OPEN_BOUND_NOT_ALLOWED · TYPO_CORRECTED · AMBIGUOUS_NUMBER · AMBIGUOUS_UNIT · AMBIGUOUS_DATE · RANGE_REVERSED · COMPOUND_OVERFLOW · CIVIL_AVERAGE · UNIT_ASSUMED · WEEKDAY_ASSUMED_NEXT · SLANG_UNIT · TZ_IGNORED · -AMBIGUOUS_TIMEZONE` +AMBIGUOUS_TIMEZONE · AFFINE_DELTA_ASSUMED · EXPRESSION_KIND_MISMATCH · +SCALAR_EXPECTED · DIVISION_BY_ZERO · SCALE_ASSUMED` Every issue carries a typed `data` payload (`LingoIssue<'UNKNOWN_UNIT'>` knows `data.unit` and `data.suggestions`). `NOW_REQUIRED` fires when a date input diff --git a/packages/lingo/llms.txt b/packages/lingo/llms.txt index 5f2eb66..ad55cbb 100644 --- a/packages/lingo/llms.txt +++ b/packages/lingo/llms.txt @@ -4,7 +4,7 @@ Agent fetch order (online): `https://lingo.pascal.app/llms.txt` (index) → `https://lingo.pascal.app/docs/
.md` (per-topic) or `https://lingo.pascal.app/llms-full.txt` (complete narrative). Offline: this file (`node_modules/@pascal-app/lingo/llms.txt`) is the compressed self-contained reference. Keep user measurements as strings in tool schemas; call lingo to convert, validate, surface spans, and handle ambiguity. -Entries: `@pascal-app/lingo` (core+units+fuzzy), `@pascal-app/lingo/date`, `@pascal-app/lingo/dom`, `@pascal-app/lingo/element` (``), `@pascal-app/lingo/react`, `@pascal-app/lingo/react-native`, `@pascal-app/lingo/ai` (LLM tool fields), `@pascal-app/lingo/mcp` (MCP tool helper), `@pascal-app/lingo/describe` (rich value/result descriptions), `@pascal-app/lingo/catalog` (query units/kinds/currencies + ISO country codes), `@pascal-app/lingo/complete` (ranked autocomplete completions), `@pascal-app/lingo/schema` (JSON Schema + OpenAPI + enum reference), `@pascal-app/lingo/locales/{en,en-gb,es,fr,pt,zh,ja}` (tree-shakeable language packs), `@pascal-app/lingo/core`. +Entries: `@pascal-app/lingo` (core+units+fuzzy), `@pascal-app/lingo/date`, `@pascal-app/lingo/calc`, `@pascal-app/lingo/dom`, `@pascal-app/lingo/element` (``), `@pascal-app/lingo/react`, `@pascal-app/lingo/react-native`, `@pascal-app/lingo/ai` (LLM tool fields), `@pascal-app/lingo/mcp` (MCP tool helper), `@pascal-app/lingo/describe` (rich value/result descriptions), `@pascal-app/lingo/catalog` (query units/kinds/currencies + ISO country codes), `@pascal-app/lingo/complete` (ranked autocomplete completions), `@pascal-app/lingo/schema` (JSON Schema + OpenAPI + enum reference), `@pascal-app/lingo/locales/{en,en-gb,es,fr,pt,zh,ja}` (tree-shakeable language packs), `@pascal-app/lingo/core`. ## Core (`@pascal-app/lingo`) @@ -38,10 +38,10 @@ Kinds: length mass temperature duration volume area speed data data_rate flow_ra import { completions } from "@pascal-app/lingo/complete" const items = completions("10 kg to 16", { kind: "mass", limit: 8 }) -// [{ text, result, confidence, source }] — source: parse|alternative|unit-ambiguity|unit-prefix|implied-unit|range-implied|cross-kind|date +// [{ text, result, confidence, source }] — source: parse|alternative|unit-ambiguity|unit-prefix|implied-unit|range-implied|cross-kind|date|calc ``` -Distinct from success `alternatives`, failure `candidate`, and issue `suggestions`. Wire into `lingoInput({ complete, onComplete })` or `useLingoInput({ complete })` for autocomplete dropdowns. The React hook exposes `completions`, `highlightedIndex`, `setHighlightedIndex()`, and `selectCompletion()` while keeping popup UI caller-owned. Pass `date: (text) => parseDate(text, { now })` to opt into date completions without bundling `@pascal-app/lingo/date` into `@pascal-app/lingo/complete`. +Distinct from success `alternatives`, failure `candidate`, and issue `suggestions`. Wire into `lingoInput({ complete, onComplete })` or `useLingoInput({ complete })` for autocomplete dropdowns. The React hook exposes `completions`, `highlightedIndex`, `setHighlightedIndex()`, and `selectCompletion()` while keeping popup UI caller-owned. Pass `date: (text) => parseDate(text, { now })` to opt into date completions without bundling `@pascal-app/lingo/date` into `@pascal-app/lingo/complete`. Pass `calc: (text) => calc(text, { trigger: "=" })` the same way so `=2+3 kg` completes as `= 5 kg` without bundling `@pascal-app/lingo/calc`; bare `5-10 kg` stays a range. ## Locales (`@pascal-app/lingo/locales/*`) @@ -111,6 +111,28 @@ humanizeDate(d, { now }) // "3 days ago" — always re-parses within one grain Not supported: quarters (`Q3`, `next quarter`), elliptical right sides (`Aug 3-9`), and dash-joined ISO dates with no spaces (`2026-08-01-2026-08-05`) — all return `UNSUPPORTED_DATE`. Use a spaced dash or `to` between ISO dates. +## Calc (`@pascal-app/lingo/calc`) + +```ts +import { calc } from "@pascal-app/lingo/calc" + +const r = calc("7m*2") +r.ok && r.value // 14000000 +r.ok && r.format({ style: "words" }) // "14 million" +r.ok && r.format({ style: "grouped" }) // "14,000,000" +r.ok && r.format({ style: "scientific" }) // "14e6" +r.ok && r.format({ style: "compact" }) // "14m" +r.ok && r.expression // "7e6 × 2" +r.ok && r.latex // "7 \\times 10^{6} \\times 2" + +calc("9min x 4") // 36 min; format({ unit: "h" }) → "0.6 h" +calc("half of 56kg+1700g") // 28.85 kg +calc("12 * 0.75 kg") // 9 kg +calc("10% off 50 kg") // 45 kg +``` + +Closed calculator: no variables, functions, or dimensional algebra. `lingo()` never evaluates expressions — `2+3 kg` is `TRAILING_INPUT`, `5-10 kg` stays a range. `calc()` default `trigger` is `"always"`; mixed fields inject `{ trigger: "=" }`. Glued `m` at an operator boundary is million unless `kind` is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` is a dimensionless ratio; `q * q` is `SCALAR_EXPECTED`. `formatExpression` is two-way; `formatLatex` is display-only. + ## DOM (`@pascal-app/lingo/dom`) ```ts @@ -334,12 +356,17 @@ The `/docs#forms-ux` section and `/docs/forms-ux.md` markdown mirror show the sa | RANGE_REVERSED | Bounds given high-to-low; parser swapped them | Emit low-to-high, or accept the swap | | UNSUPPORTED_DATE | Date/range shape not in the grammar (`Q3`, `Aug 3-9`) | Re-emit a supported shape or an explicit `start to end` pair | | RATE_REQUIRED | Cross-currency without rates | Call `convertCurrency` with injected rates | +| AFFINE_DELTA_ASSUMED | Additive °C/°F used as a delta | Accept the delta reading, or convert absolutely first | +| EXPRESSION_KIND_MISMATCH | Mixed kinds in `calc()` | Keep operands the same kind | +| SCALAR_EXPECTED | `q * q` or `n / q` | Scale a quantity by a number, or divide two same-kind quantities | +| DIVISION_BY_ZERO | Division by zero | Use a non-zero divisor | +| SCALE_ASSUMED | Glued `m`/`b` read as million/billion | Pass `kind: "length"` for meters, or write `7 million` | -Full list: EMPTY, NO_VALUE, UNKNOWN_UNIT, KIND_MISMATCH, RANGE_KIND_MISMATCH, CONVERSION_KIND_MISMATCH, RATE_REQUIRED, TRAILING_INPUT, SINGLE_VALUE_EXPECTED, APPROX_NOT_ALLOWED, UNIT_REQUIRED, CONVERSION_NOT_ALLOWED, NUMBER_FORMAT, NONFINITE, LOCALE_NOT_LOADED, RANGE_MIN, RANGE_MAX, RANGE_OPEN_BOUND_NOT_ALLOWED, REQUIRED, UNSUPPORTED_DATE, NOW_REQUIRED, TYPO_CORRECTED, AMBIGUOUS_NUMBER, AMBIGUOUS_UNIT, AMBIGUOUS_DATE, RANGE_REVERSED, COMPOUND_OVERFLOW, CIVIL_AVERAGE, UNIT_ASSUMED, WEEKDAY_ASSUMED_NEXT, SLANG_UNIT, TZ_IGNORED, AMBIGUOUS_TIMEZONE. Override copy via `messages` option map. +Full list: EMPTY, NO_VALUE, UNKNOWN_UNIT, KIND_MISMATCH, RANGE_KIND_MISMATCH, CONVERSION_KIND_MISMATCH, RATE_REQUIRED, TRAILING_INPUT, SINGLE_VALUE_EXPECTED, APPROX_NOT_ALLOWED, UNIT_REQUIRED, CONVERSION_NOT_ALLOWED, NUMBER_FORMAT, NONFINITE, LOCALE_NOT_LOADED, RANGE_MIN, RANGE_MAX, RANGE_OPEN_BOUND_NOT_ALLOWED, REQUIRED, UNSUPPORTED_DATE, NOW_REQUIRED, TYPO_CORRECTED, AMBIGUOUS_NUMBER, AMBIGUOUS_UNIT, AMBIGUOUS_DATE, RANGE_REVERSED, COMPOUND_OVERFLOW, CIVIL_AVERAGE, UNIT_ASSUMED, WEEKDAY_ASSUMED_NEXT, SLANG_UNIT, TZ_IGNORED, AMBIGUOUS_TIMEZONE, AFFINE_DELTA_ASSUMED, EXPRESSION_KIND_MISMATCH, SCALAR_EXPECTED, DIVISION_BY_ZERO, SCALE_ASSUMED. Override copy via `messages` option map. ## Canonical examples (input → essence) -"2 ft" → quantity length base 0.6096 m · "5'11\"" → 1.8034 m (parts ft+in) · "72 in to cm" → conversion, converted 182.88 cm · "60 miles an hour" → speed in m/s · "5 cubic feet" → volume in m³ · "approx. 5 kg" → approximate mass · "1m80" → 1.8 m · "1h30" → 5400 s · "2 lb 3 oz" → 0.9922 kg · "$5" → currency USD value/base 5 baseUnit USD + AMBIGUOUS_UNIT; pass `{currency:'CAD'}` to read bare "$" as CAD · "50 cents" → 0.5 USD + AMBIGUOUS_UNIT; pass `{currency:'EUR'}` to read as EUR · "five dollars and fifty cents" → 5.5 USD · "50p" → 0.5 GBP · "3 quid 50" → 3.5 GBP · "€5-€10" → currency range baseUnit EUR · "5 EUR to USD" → ok:false RATE_REQUIRED (use convertCurrency with injected rates) · "between 5 and 10 kg" → range 5..10 kg · "under 10 minutes" → range max 600 s exclusive · "no greater than 5 kg" → range max 5 kg inclusive · "10 ± 0.5 mm" → plusMinus center 10 mm/base 0.01 and delta 0.5 mm/base 0.0005 · "a few minutes" → range 120..240 s approximate · "it's hot" (kind temperature) → range 300.15..308.15 K fuzzy 'hot' · "1,5 kg" → 1.5 kg · "1,234" → 1234 + AMBIGUOUS_NUMBER (alt 1.234) · "5 meterz" (kind length) → 5 m + TYPO_CORRECTED; with strictness confirm → ok:false + candidate 5 m · "72" (kind length, unit cm, accept.bareNumbers false) → UNIT_REQUIRED + candidate 72 cm · "72 in to cm" with accept.conversions false → CONVERSION_NOT_ALLOWED + candidate conversion · "5m" (kind duration) → 300 s + SLANG_UNIT · "in 2d" → date two days from now · "3min from tmrw" → tomorrow, same time-of-day +3 min · "17h30" → 17:30 · "quarter past 5" → 05:15 · "3pm EST" → 15:00 civil + zone {abbrev, -300, ambiguous} + TZ_IGNORED/AMBIGUOUS_TIMEZONE; `{applyZone:true}` → the 20:00Z instant · "2pm to 4pm" → date-range 14:00..16:00 · "9-5" → date-range 09:00..17:00 (workday shift) · "Aug 3 - Aug 9" → dated date-range 2026-08-03..2026-08-09 · "August" → dated date-range 2026-08-01..2026-08-31 (whole month) · "next week" → Mon..Sun · "this weekend" → Sat..Sun (the one in progress on Sat/Sun) · "2026-08-09 to 2026-08-03" → swapped 08-03..08-09 + RANGE_REVERSED · "Q3" → ok:false UNSUPPORTED_DATE (quarters need a fiscal-year anchor; not supported) · "500 KB" → 500000 B · "5 Mb" → 625000 B (megabits) · "5 Mbps" → data_rate 5000000 bit/s; use "bit/s" for bits per second because bare "bps" stays basis points · "5 gpm" → flow_rate 0.000315451 m³/s · "250 mL/min" → flow_rate 0.000004167 m³/s · "10 inH₂O" → pressure 2490.8891 Pa · "1 kgf/cm²" → pressure 98.0665 kPa · "1 kg/cm²" → ok:false TRAILING_INPUT (kilogram-mass over area deferred; use kgf/cm²) · "5 psig" (kind pressure) → ok:false UNKNOWN_UNIT (gauge semantics deferred) · "9.8 m/s²" → acceleration 9.8 m/s² · "10 Nm" → torque 10 N⋅m (exact-case; lowercase "nm" remains nanometers) · "500 lux" → illuminance 500 lx · "100 nits" → luminance 100 cd/m² · "20 mSv" → radiation equivalent dose 0.02 Sv · "5 MBq" → radioactivity 5000000 Bq · "5 uM" → concentration 0.005 mol/m³ · "1 mol/L" and "1 mol per L" → concentration 1000 mol/m³; untyped glued "1M" fails, use "1 M" or `kind:'concentration'` · "-40°F" → 233.15 K · "3×10⁵ m" → 300000 m · "½ cup" → 118.29 mL · "15%" → percent 15 · "25 bps" → 0.25% (basis points; bare bps stays percent) · "500 mAh" → charge 1800 C · "4.7 kohm" → resistance 4700 Ω · "250 mmol" → substance 0.25 mol. +"2 ft" → quantity length base 0.6096 m · "5'11\"" → 1.8034 m (parts ft+in) · "72 in to cm" → conversion, converted 182.88 cm · "7m*2" via `@pascal-app/lingo/calc` → 14000000 + SCALE_ASSUMED (compact "14m", words "14 million") · "9min x 4" via calc → 36 min · "half of 56kg+1700g" via calc → 28.85 kg · "2+3 kg" via lingo → ok:false TRAILING_INPUT (not a silent 2–3 kg range); via calc → 5 kg · "60 miles an hour" → speed in m/s · "5 cubic feet" → volume in m³ · "approx. 5 kg" → approximate mass · "1m80" → 1.8 m · "1h30" → 5400 s · "2 lb 3 oz" → 0.9922 kg · "$5" → currency USD value/base 5 baseUnit USD + AMBIGUOUS_UNIT; pass `{currency:'CAD'}` to read bare "$" as CAD · "50 cents" → 0.5 USD + AMBIGUOUS_UNIT; pass `{currency:'EUR'}` to read as EUR · "five dollars and fifty cents" → 5.5 USD · "50p" → 0.5 GBP · "3 quid 50" → 3.5 GBP · "€5-€10" → currency range baseUnit EUR · "5 EUR to USD" → ok:false RATE_REQUIRED (use convertCurrency with injected rates) · "between 5 and 10 kg" → range 5..10 kg · "under 10 minutes" → range max 600 s exclusive · "no greater than 5 kg" → range max 5 kg inclusive · "10 ± 0.5 mm" → plusMinus center 10 mm/base 0.01 and delta 0.5 mm/base 0.0005 · "a few minutes" → range 120..240 s approximate · "it's hot" (kind temperature) → range 300.15..308.15 K fuzzy 'hot' · "1,5 kg" → 1.5 kg · "1,234" → 1234 + AMBIGUOUS_NUMBER (alt 1.234) · "5 meterz" (kind length) → 5 m + TYPO_CORRECTED; with strictness confirm → ok:false + candidate 5 m · "72" (kind length, unit cm, accept.bareNumbers false) → UNIT_REQUIRED + candidate 72 cm · "72 in to cm" with accept.conversions false → CONVERSION_NOT_ALLOWED + candidate conversion · "5m" (kind duration) → 300 s + SLANG_UNIT · "in 2d" → date two days from now · "3min from tmrw" → tomorrow, same time-of-day +3 min · "17h30" → 17:30 · "quarter past 5" → 05:15 · "3pm EST" → 15:00 civil + zone {abbrev, -300, ambiguous} + TZ_IGNORED/AMBIGUOUS_TIMEZONE; `{applyZone:true}` → the 20:00Z instant · "2pm to 4pm" → date-range 14:00..16:00 · "9-5" → date-range 09:00..17:00 (workday shift) · "Aug 3 - Aug 9" → dated date-range 2026-08-03..2026-08-09 · "August" → dated date-range 2026-08-01..2026-08-31 (whole month) · "next week" → Mon..Sun · "this weekend" → Sat..Sun (the one in progress on Sat/Sun) · "2026-08-09 to 2026-08-03" → swapped 08-03..08-09 + RANGE_REVERSED · "Q3" → ok:false UNSUPPORTED_DATE (quarters need a fiscal-year anchor; not supported) · "500 KB" → 500000 B · "5 Mb" → 625000 B (megabits) · "5 Mbps" → data_rate 5000000 bit/s; use "bit/s" for bits per second because bare "bps" stays basis points · "5 gpm" → flow_rate 0.000315451 m³/s · "250 mL/min" → flow_rate 0.000004167 m³/s · "10 inH₂O" → pressure 2490.8891 Pa · "1 kgf/cm²" → pressure 98.0665 kPa · "1 kg/cm²" → ok:false TRAILING_INPUT (kilogram-mass over area deferred; use kgf/cm²) · "5 psig" (kind pressure) → ok:false UNKNOWN_UNIT (gauge semantics deferred) · "9.8 m/s²" → acceleration 9.8 m/s² · "10 Nm" → torque 10 N⋅m (exact-case; lowercase "nm" remains nanometers) · "500 lux" → illuminance 500 lx · "100 nits" → luminance 100 cd/m² · "20 mSv" → radiation equivalent dose 0.02 Sv · "5 MBq" → radioactivity 5000000 Bq · "5 uM" → concentration 0.005 mol/m³ · "1 mol/L" and "1 mol per L" → concentration 1000 mol/m³; untyped glued "1M" fails, use "1 M" or `kind:'concentration'` · "-40°F" → 233.15 K · "3×10⁵ m" → 300000 m · "½ cup" → 118.29 mL · "15%" → percent 15 · "25 bps" → 0.25% (basis points; bare bps stays percent) · "500 mAh" → charge 1800 C · "4.7 kohm" → resistance 4700 Ω · "250 mmol" → substance 0.25 mol. ## Docs (online) diff --git a/packages/lingo/package.json b/packages/lingo/package.json index 9f3003a..0a92a50 100644 --- a/packages/lingo/package.json +++ b/packages/lingo/package.json @@ -76,6 +76,16 @@ "default": "./dist/date/index.cjs" } }, + "./calc": { + "import": { + "types": "./dist/calc/index.d.ts", + "default": "./dist/calc/index.js" + }, + "require": { + "types": "./dist/calc/index.d.cts", + "default": "./dist/calc/index.cjs" + } + }, "./dom": { "import": { "types": "./dist/dom/index.d.ts", diff --git a/packages/lingo/scripts/size.mjs b/packages/lingo/scripts/size.mjs index 433d480..55ea5e2 100644 --- a/packages/lingo/scripts/size.mjs +++ b/packages/lingo/scripts/size.mjs @@ -143,12 +143,17 @@ function check(label, size, budget) { // approximatePhrases) + CJK number engine (sub-token segmentation, 万/亿 // grouping, elliptical/mixed forms, wave-dash + adjacent ranges, post-unit 半). // Measured 38.19 after golfing; capability is product (D14 pattern). +// 39.9 (was 39.4): D73 — input calculations. Shared parser always ships the +// CJK adjacent-range gate (`2+3 kg` is trailing input, not a silent range), +// AFFINE_DELTA_ASSUMED on additive affine compounds, and calc-scale matching +// inside parseQty so glued `7m*2` can mean 14 million before `m` binds as +// meters. Measured 39.84. // 39.4 (was 38.3): D70 — wave-2 idiom engine: profile-driven splitting of glued // CJK grammar words, postpositional bounds (5キロ未満), and number-word // arithmetic fixes (hundreds bind to the preceding group, scale chaining, // `noAnd` threading). Measured 39.18. const full = await bundleStdin(`export * from './src/index.ts'`) -check('lingo (full)', full, 39_400) +check('lingo (full)', full, 39_900) if (has('src/locales/es.ts')) { const enLocale = await bundleStdin(`export * from './src/locales/en.ts'`) @@ -260,11 +265,13 @@ if (has('src/locales/es.ts')) { // 25.6 (was 24.2): D68 — wave-1 idiom engine mechanisms live in the shared // number/parse layer (Romance composition fields + CJK number walker), so // BYO-registry cores get them too. Measured 25.46 after golfing. +// 27.1 (was 26.8): D73 — see full; calc-scale, affine warning, and the five +// new issue codes live in the shared engine. Measured 27.06. // 26.8 (was 25.6): D70 — wave-2 mechanisms in the shared layer: profile-driven // splitting of glued grammar words (unit aliases stay atomic), postpositional // bound phrases, and number-word arithmetic fixes. Measured 26.56. const core = await bundleStdin(`export * from './src/core/index.ts'`) -check('./core (engine, no unit data)', core, 26_800) +check('./core (engine, no unit data)', core, 27_100) if (has('src/date/index.ts')) { const dateAlone = await bundleStdin(`export * from './src/date/index.ts'`) @@ -320,10 +327,12 @@ if (has('src/date/index.ts')) { // 43.3 (was 41.0): D70 — suffix-delimited date/clock grammar (date/suffix.ts, // date/numeral.ts): 年月日 numeric dates, 点/時/分/秒 clocks, day periods, // unspaced date+time splitting, glued affix matching. Measured 43.01. + // 44.4 (was 43.8): D73 — standalone date inherits the shared parser growth + // (CJK gate, affine warning, calc-scale). Measured 44.31. // 43.8 (was 43.3): D71 — calendar ranges. Date endpoints reuse the existing // splitter and single-date parser, so the whole capability (date-to-date, // period spans, weekends, the humanize date branch) is 450 B. Measured 43.46. - check('./date (standalone, incl. engine)', dateAlone, 43_800) + check('./date (standalone, incl. engine)', dateAlone, 44_400) const withDate = await bundleStdin( `export * from './src/index.ts'; export * from './src/date/index.ts'`, ) @@ -352,6 +361,19 @@ if (has('src/date/index.ts')) { check('./date (marginal over full)', withDate - full, 16_200) } +if (has('src/calc/index.ts')) { + const calcAlone = await bundleStdin(`export * from './src/calc/index.ts'`) + // 35.2: D73 — ./calc is its own entry (parser + default unit data + + // expression grammar). Measured 34.99. Compact/latex formatters live here. + check('./calc (standalone, incl. engine)', calcAlone, 35_200) + const withCalc = await bundleStdin( + `export * from './src/index.ts'; export * from './src/calc/index.ts'`, + ) + // 4.1: D73 — expression grammar + eval + format, marginal over full. + // Measured 3.95. + check('./calc (marginal over full)', withCalc - full, 4100) +} + if (has('src/dom/index.ts')) { const withDom = await bundleStdin( `export * from './src/index.ts'; export * from './src/dom/index.ts'`, @@ -374,7 +396,8 @@ if (has('src/dom/index.ts')) { `export * from './src/index.ts'; export * from './src/dom/index.ts'; export * from './src/react/index.ts'`, ['react'], ) - check('./react (marginal over dom)', withReact - withDom, 1500) + // 1.6 (was 1.5): D73 — gzip interaction after the shared parser growth. + check('./react (marginal over dom)', withReact - withDom, 1600) } } @@ -442,7 +465,9 @@ if (has('src/schema/index.ts')) { // D57 — ./schema: JSON Schema (Draft 2020-12) of the v3 wire types + enum // reference + toOpenApi(). Pure data; framework adapters are generated in the // docs, not shipped. Marginal is the schema object + enums + OpenAPI helper. - check('./schema (marginal over full)', withSchema - full, 3200) + // 3.3 (was 3.2): D73 — five new issue codes in the enum dictionary. + // Measured 3.23. + check('./schema (marginal over full)', withSchema - full, 3300) } // 8.9 (D20, was 8.0 under D18): the full ./ai marginal includes the date @@ -482,10 +507,12 @@ if (has('src/ai/index.ts')) { // bundled date module (see ./date notes). Measured 17.27 after golfing. // 18.6 (was 17.4): D70 — the suffix date/clock grammar cascades through the // bundled date module; no /ai code changed. Measured 18.39. + // 19.4 (was 19.1): D73 — quantityField calc injection + looksLikeCalc. + // Measured 19.34. The calc engine stays injected, not bundled. // 19.1 (was 18.6): D71 — calendar ranges cascade through the bundled date // module; no /ai code changed. dateRangeField gains the capability for free. // Measured 18.84. - check('./ai (marginal over full)', withAi - full, 19_100) // D30: +notation in shared renderNumber + check('./ai (marginal over full)', withAi - full, 19_400) // D30: +notation in shared renderNumber if (has('src/mcp/index.ts')) { const withMcp = await bundleStdin( `export * from './src/index.ts'; export * from './src/ai/index.ts'; export * from './src/mcp/index.ts'`, @@ -507,7 +534,9 @@ if (has('src/ai/index.ts')) { // failure path for currency fields. // 1.75 (was 1.7): D49 — quantityField-only carries specific examples for // advanced scientific field descriptions. - check('./ai quantityField only (shakeable)', withAiQty - full, 1900) + // 2.2 (was 1.9): D73 — looksLikeCalc + schema description (engine stays + // injected). Measured 2.14. + check('./ai quantityField only (shakeable)', withAiQty - full, 2200) } console.table(rows) diff --git a/packages/lingo/src/ai/quantity-fields.ts b/packages/lingo/src/ai/quantity-fields.ts index c2d238c..c013025 100644 --- a/packages/lingo/src/ai/quantity-fields.ts +++ b/packages/lingo/src/ai/quantity-fields.ts @@ -1,3 +1,4 @@ +import type { CalcOptions, CalcOutcome } from '../calc/types' import { RATE_BASED_CONVERSION_ERROR } from '../core/convert' import { makeIssue } from '../core/errors' import type { QuantityJSON, QuantityRangeJSON } from '../core/quantity' @@ -26,6 +27,12 @@ export type QuantityFieldOptions = LingoOptions & { max?: number output?: 'number' | 'quantity' description?: string + /** + * Inject `calc` from `@pascal-app/lingo/calc` so the field accepts + * expressions (`12 * 0.75 kg`, `10% of 60 kg`, `=2+3 kg`). `-` is not a + * calc trigger — `5-10 kg` stays a range. + */ + calc?: (input: string, opts?: CalcOptions) => CalcOutcome } export type RangeFieldOptions = LingoOptions & { @@ -72,6 +79,7 @@ export function quantityField( max?: number output: 'quantity' description?: string + calc?: QuantityFieldOptions['calc'] }, ): LingoField /** @@ -98,6 +106,7 @@ export function quantityField( max?: number output?: 'number' description?: string + calc?: QuantityFieldOptions['calc'] }, ): LingoField export function quantityField(opts: QuantityFieldOptions): LingoField { @@ -109,6 +118,42 @@ export function quantityField(opts: QuantityFieldOptions): LingoField): string { + if (!opts.calc) { + return '' + } + return ' Arithmetic expressions are allowed (for example "12 * 0.75 kg", "10% of 60 kg", "half of 56kg+1700g", or "=2+3 kg").' } function rangeInputDescription(opts: RangeFieldOptions): string { diff --git a/packages/lingo/src/calc/calc.test.ts b/packages/lingo/src/calc/calc.test.ts new file mode 100644 index 0000000..b2bee67 --- /dev/null +++ b/packages/lingo/src/calc/calc.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest' +import { quantityField } from '../ai/quantity-fields' +import { completions } from '../complete/index' +import { createLingo, lingo } from '../index' +import { zh } from '../locales/zh' +import { calc, formatCalc, formatExpression, formatLatex } from './index' + +function ok(input: string, opts?: Parameters[1]) { + const r = calc(input, opts) + if (!r.ok) { + throw new Error(`calc failed for "${input}": ${JSON.stringify(r.issues)}`) + } + return r +} + +describe('calc()', () => { + it('evaluates 7m*2 as 14 million, not 14 meters', () => { + const r = ok('7m*2') + expect(r.value).toBe(14_000_000) + expect(r.quantity).toBeUndefined() + expect(r.issues.some((issue) => issue.code === 'SCALE_ASSUMED')).toBe(true) + expect(r.format({ style: 'words' })).toBe('14 million') + expect(r.format({ style: 'grouped' })).toBe('14,000,000') + expect(r.format({ style: 'scientific' })).toBe('14e6') + expect(r.format({ style: 'compact' })).toBe('14m') + expect(formatExpression(r.node)).toBe('7e6 × 2') + expect(formatLatex(r.node)).toBe('7 \\times 10^{6} \\times 2') + }) + + it('reads glued m as meters when kind is length', () => { + const r = ok('7m*2', { kind: 'length' }) + expect(r.quantity?.kind).toBe('length') + expect(r.value).toBeCloseTo(14, 12) + expect(r.quantity?.unit).toBe('m') + expect(r.issues.some((issue) => issue.code === 'SCALE_ASSUMED')).toBe(false) + }) + + it('reads spaced 7 m as meters even without kind', () => { + const r = ok('7 m*2') + expect(r.quantity?.kind).toBe('length') + expect(r.value).toBeCloseTo(14, 12) + }) + + it('keeps 1m80 as 1.80 m, not a million-scale reading', () => { + const r = ok('1m80') + expect(r.quantity?.kind).toBe('length') + expect(r.quantity?.base).toBeCloseTo(1.8, 12) + }) + + it('scales 9 min x 4 to 36 min or 0.6 h', () => { + const r = ok('9min x 4') + expect(r.quantity?.kind).toBe('duration') + expect(r.quantity?.valueIn('min')).toBeCloseTo(36, 12) + expect(r.format()).toMatch(/36/) + expect(r.format({ unit: 'h' })).toBe('0.6 h') + }) + + it('takes half of a mixed-unit sum', () => { + const r = ok('half of 56kg+1700g') + expect(r.quantity?.kind).toBe('mass') + expect(r.quantity?.base).toBeCloseTo(28.85, 12) + }) + + it('treats and as plus so half of 56kg and 1700g matches the + form', () => { + const plus = ok('half of 56kg+1700g') + const joined = ok('half of 56kg and 1700g') + expect(joined.quantity?.base).toBeCloseTo(plus.quantity!.base, 12) + }) + + it('reads glued m before and as million', () => { + expect(ok('7m and 2').value).toBe(7_000_002) + }) + + it('reads 10% on as percent-of', () => { + expect(ok('10% on 50 kg').quantity?.base).toBeCloseTo(5, 12) + }) + + it('does not glue compact million onto a unit', () => { + const r = ok('2e6 * 3 kg') + expect(r.quantity?.base).toBeCloseTo(6_000_000, 12) + expect(r.format({ style: 'compact' })).toBe('6e6 kg') + }) + + it('uses scientific compact for trillions so t is not a tonne', () => { + expect(ok('1 trillion').format({ style: 'compact' })).toBe('1e12') + expect(ok('1 trillion').format({ style: 'words' })).toBe('1 trillion') + expect(ok('1e12').value).toBe(1e12) + }) + + it('round-trips compact thousand through the existing k suffix', () => { + expect(ok('14k').value).toBe(14_000) + expect(ok(ok('14000').format({ style: 'compact' })).value).toBe(14_000) + }) + + it('leaves 7m*2 to calc(); lingo() does not scale m as million', () => { + const r = lingo('7m*2') + expect(r.ok && r.type === 'quantity' ? r.quantity.base : null).not.toBe(14_000_000) + if (r.ok && r.type === 'quantity') { + expect(r.quantity.kind).toBe('length') + expect(r.quantity.base).toBeCloseTo(7, 12) + } + }) + + it('evaluates percent-of, percent-off, and plus-percent', () => { + expect(ok('10% of 50 kg').quantity?.base).toBeCloseTo(5, 12) + expect(ok('10% off 50 kg').quantity?.base).toBeCloseTo(45, 12) + expect(ok('50 kg + 10%').quantity?.base).toBeCloseTo(55, 12) + }) + + it('adds and multiplies with parentheses and word operators', () => { + expect(ok('(2+3)*4').value).toBe(20) + expect(ok('2 plus 3 times 4').value).toBe(14) + expect(ok('12 * 0.75 kg').quantity?.base).toBeCloseTo(9, 12) + expect(ok('twice 3 kg').quantity?.base).toBeCloseTo(6, 12) + }) + + it('cancels same-kind division to a ratio', () => { + expect(ok('10 L / 2 L').value).toBeCloseTo(5, 12) + expect(ok('10 L / 2 L').quantity).toBeUndefined() + }) + + it('rejects quantity × quantity and n / q', () => { + const mul = calc('5 kg * 2 m') + expect(mul.ok).toBe(false) + expect(mul.issues[0]?.code).toBe('SCALAR_EXPECTED') + const div = calc('10 / 2 kg') + expect(div.ok).toBe(false) + expect(div.issues[0]?.code).toBe('SCALAR_EXPECTED') + }) + + it('rejects mixed-kind addition and division by zero', () => { + const mix = calc('5 kg + 2 m') + expect(mix.ok).toBe(false) + expect(mix.issues[0]?.code).toBe('EXPRESSION_KIND_MISMATCH') + const zero = calc('5 / 0') + expect(zero.ok).toBe(false) + expect(zero.issues[0]?.code).toBe('DIVISION_BY_ZERO') + }) + + it('only evaluates when prefixed if trigger is =', () => { + expect(calc('2+3 kg', { trigger: '=' }).ok).toBe(false) + const r = ok('=2+3 kg', { trigger: '=' }) + expect(r.quantity?.base).toBeCloseTo(5, 12) + }) + + it('round-trips expression and compact humanize through calc()', () => { + const r = ok('7m*2') + const again = ok(r.expression) + expect(again.value).toBeCloseTo(r.value, 12) + const compact = r.format({ style: 'compact' }) + expect(compact).toBe('14m') + expect(ok(compact).value).toBeCloseTo(14_000_000, 12) + expect(ok(r.format({ style: 'words' })).value).toBeCloseTo(14_000_000, 12) + expect(ok(r.format({ style: 'scientific' })).value).toBeCloseTo(14_000_000, 12) + const qty = ok('9 min x 4') + const back = ok(qty.format()) + expect(back.quantity?.base).toBeCloseTo(qty.quantity!.base, 12) + }) + + it('does not throw or yield NaN on hostile input', () => { + const nasty = ['', ' ', '/', '((((', '5 / 0', '5 kg * 2 m', 'NaN * 2', '1e999 * 1e999'] + for (const input of nasty) { + const r = calc(input) + expect(r.ok || r.issues.length > 0, input).toBe(true) + if (r.ok) { + expect(Number.isFinite(r.value), input).toBe(true) + } + } + }) + + it('serializes enumerable toJSON without the node tree', () => { + const json = JSON.parse(JSON.stringify(ok('7m*2'))) + expect(json.type).toBe('calc') + expect(json.value).toBe(14_000_000) + expect(json.node).toBeUndefined() + expect(json.expression).toBe('7e6 × 2') + }) +}) + +describe('lingo() stays range-first', () => { + it('no longer reads 2+3 kg as a silent range', () => { + const r = lingo('2+3 kg') + expect(r.ok).toBe(false) + expect(r.issues.some((issue) => issue.code === 'TRAILING_INPUT')).toBe(true) + }) + + it('still reads 七八天 as a CJK adjacent range', () => { + const zhLingo = createLingo({ locales: [zh] }) + const range = zhLingo.parseRange('七八天', { locale: 'zh' }) + expect(range.ok).toBe(true) + if (range.ok) { + expect(range.range.min()?.value).toBe(7) + expect(range.range.max()?.value).toBe(8) + } + }) + + it('warns AFFINE_DELTA_ASSUMED on additive temperature compounds', () => { + const r = lingo('20°C + 5°C') + expect(r.ok && r.type === 'quantity').toBe(true) + if (r.ok && r.type === 'quantity') { + expect(r.quantity.value).toBeCloseTo(25, 12) + expect(r.issues.some((issue) => issue.code === 'AFFINE_DELTA_ASSUMED')).toBe(true) + } + }) +}) + +describe('calc injection', () => { + it('surfaces = 5 kg from completions when calc is injected', () => { + const list = completions('=2+3 kg', { + calc: (text) => calc(text, { trigger: '=' }), + }) + const item = list.find((candidate) => candidate.source === 'calc') + expect(item?.text).toBe('= 5 kg') + expect(item?.result.type).toBe('calc') + }) + + it('does not steal 5-10 kg as arithmetic', () => { + const list = completions('5-10 kg', { + kind: 'mass', + calc: (text) => calc(text, { trigger: '=' }), + }) + expect(list.some((candidate) => candidate.source === 'calc')).toBe(false) + expect(list[0]?.result.type).toBe('range') + }) + + it('lets quantityField accept 12 * 0.75 kg when calc is injected', () => { + const field = quantityField({ kind: 'mass', unit: 'kg', calc }) + expect(field.parse('12 * 0.75 kg')).toBeCloseTo(9, 12) + const range = field.safeParse('5-10 kg') + expect('value' in range).toBe(false) + const input = field['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) + expect(String(input.description)).toContain('Arithmetic expressions are allowed') + }) +}) + +describe('formatCalc helpers', () => { + it('formats a result without going through result.format', () => { + const r = ok('7m*2') + expect(formatCalc(r, { style: 'compact' })).toBe('14m') + }) +}) diff --git a/packages/lingo/src/calc/eval.ts b/packages/lingo/src/calc/eval.ts new file mode 100644 index 0000000..74dc935 --- /dev/null +++ b/packages/lingo/src/calc/eval.ts @@ -0,0 +1,218 @@ +import { toBase } from '../core/convert' +import { makeIssue } from '../core/errors' +import { Quantity } from '../core/quantity' +import type { IssueCode, IssueInputData, Kind, Span } from '../core/types' +import type { ParserState } from '../parse/config' +import type { CalcNode } from './types' + +export interface EvalValue { + kind: Kind | null + quantity: Quantity | null + span: Span + unit: string | null + value: number +} + +export function evaluate(p: ParserState, node: CalcNode): EvalValue | null { + if (node.type === 'number') { + return { kind: null, unit: null, value: node.value, quantity: null, span: node.span } + } + if (node.type === 'quantity') { + return { + kind: node.value.kind, + unit: node.value.unit, + value: node.value.value, + quantity: node.value, + span: node.span, + } + } + if (node.type === 'group') { + return evaluate(p, node.node) + } + if (node.type === 'percent') { + return evalPercent(p, node) + } + return evalOp(p, node) +} + +function evalPercent( + p: ParserState, + node: Extract, +): EvalValue | null { + const of = evaluate(p, node.of) + const pct = evaluate(p, node.percent) + if (!(of && pct)) { + return null + } + const rate = pct.value / 100 + const factor = node.mode === 'of' ? rate : node.mode === 'add' ? 1 + rate : 1 - rate + return scaleValue(p, of, factor, node.span) +} + +function evalOp(p: ParserState, node: Extract): EvalValue | null { + const left = evaluate(p, node.left) + const right = evaluate(p, node.right) + if (!(left && right)) { + return null + } + if (node.op === '+' || node.op === '-') { + return evalAdd(p, node.op, left, right, node.span) + } + if (node.op === '*') { + return evalMul(p, left, right, node.span) + } + return evalDiv(p, left, right, node.span) +} + +function evalAdd( + p: ParserState, + op: '+' | '-', + left: EvalValue, + right: EvalValue, + span: Span, +): EvalValue | null { + const sign = op === '+' ? 1 : -1 + if (left.kind && right.kind && left.kind !== right.kind) { + report(p, 'EXPRESSION_KIND_MISMATCH', { left: left.kind, right: right.kind }, span) + return null + } + if (!(left.kind || right.kind)) { + return finite( + p, + { kind: null, unit: null, value: left.value + sign * right.value, quantity: null, span }, + span, + ) + } + const typed = left.kind ? left : right + const kind = typed.kind! + const unitId = (left.unit ?? right.unit)! + const unit = p.reg.unit(kind, unitId) + if (!unit) { + return null + } + const leftBase = left.quantity ? left.quantity.base : toBase(unit, left.value) + const rightDelta = right.quantity + ? right.value * (p.reg.unit(kind, right.unit!)?.factor ?? unit.factor) + : toBase(unit, right.value) - (unit.offset ?? 0) + if ((unit.offset || rightAffine(p, right, kind)) && left.quantity && right.quantity) { + const deltaUnit = p.reg.unit(kind, right.unit!) ?? unit + report( + p, + 'AFFINE_DELTA_ASSUMED', + { unit: deltaUnit.symbol, asDelta: `${right.value} ${deltaUnit.symbol}` }, + span, + ) + } + const base = leftBase + sign * rightDelta + if (!Number.isFinite(base)) { + report(p, 'NONFINITE', {}, span) + return null + } + const quantity = new Quantity(p.reg, kind, base, unitId) + return { kind, unit: unitId, value: quantity.value, quantity, span } +} + +function evalMul(p: ParserState, left: EvalValue, right: EvalValue, span: Span): EvalValue | null { + if (left.quantity && right.quantity) { + report(p, 'SCALAR_EXPECTED', { op: 'multiply' }, span) + return null + } + if (left.quantity) { + return scaleValue(p, left, right.value, span) + } + if (right.quantity) { + return scaleValue(p, right, left.value, span) + } + return finite( + p, + { kind: null, unit: null, value: left.value * right.value, quantity: null, span }, + span, + ) +} + +function evalDiv(p: ParserState, left: EvalValue, right: EvalValue, span: Span): EvalValue | null { + if (right.value === 0) { + report(p, 'DIVISION_BY_ZERO', {}, span) + return null + } + if (left.quantity && right.quantity) { + if (left.kind !== right.kind) { + report( + p, + 'EXPRESSION_KIND_MISMATCH', + { left: left.kind ?? 'number', right: right.kind ?? 'number' }, + span, + ) + return null + } + const common = left.quantity.valueIn(left.unit!) + const other = right.quantity.valueIn(left.unit!) + if (other === 0) { + report(p, 'DIVISION_BY_ZERO', {}, span) + return null + } + return finite(p, { kind: null, unit: null, value: common / other, quantity: null, span }, span) + } + if (right.quantity && !left.quantity) { + report(p, 'SCALAR_EXPECTED', { op: 'divide' }, span) + return null + } + if (left.quantity) { + return scaleValue(p, left, 1 / right.value, span) + } + return finite( + p, + { kind: null, unit: null, value: left.value / right.value, quantity: null, span }, + span, + ) +} + +function scaleValue(p: ParserState, qty: EvalValue, factor: number, span: Span): EvalValue | null { + if (!(qty.quantity && qty.kind && qty.unit)) { + return finite( + p, + { kind: null, unit: null, value: qty.value * factor, quantity: null, span }, + span, + ) + } + const unit = p.reg.unit(qty.kind, qty.unit) + if (!unit) { + return null + } + const value = qty.value * factor + if (!Number.isFinite(value)) { + report(p, 'NONFINITE', {}, span) + return null + } + const base = toBase(unit, value) + if (!Number.isFinite(base)) { + report(p, 'NONFINITE', {}, span) + return null + } + const quantity = new Quantity(p.reg, qty.kind, base, qty.unit) + return { kind: qty.kind, unit: qty.unit, value: quantity.value, quantity, span } +} + +function rightAffine(p: ParserState, right: EvalValue, kind: Kind): boolean { + if (!right.unit) { + return false + } + return Boolean(p.reg.unit(kind, right.unit)?.offset) +} + +function finite(p: ParserState, value: EvalValue, span: Span): EvalValue | null { + if (!Number.isFinite(value.value)) { + report(p, 'NONFINITE', {}, span) + return null + } + return value +} + +function report( + p: ParserState, + code: C, + data: IssueInputData, + span: Span, +): void { + p.issues.push(makeIssue(code, data, span, p.opts.messages)) +} diff --git a/packages/lingo/src/calc/format.ts b/packages/lingo/src/calc/format.ts new file mode 100644 index 0000000..88a925a --- /dev/null +++ b/packages/lingo/src/calc/format.ts @@ -0,0 +1,239 @@ +import { roundSig } from '../core/round' +import type { CalcFormatOptions, CalcFormatStyle, CalcNode, CalcResult } from './types' + +const SCALES = [ + { factor: 1e12, word: 'trillion', suffix: '' }, + { factor: 1e9, word: 'billion', suffix: 'bn' }, + { factor: 1e6, word: 'million', suffix: 'm' }, + { factor: 1e3, word: 'thousand', suffix: 'k' }, +] as const + +/** + * Render an evaluated calc result. Dimensionless numbers take `style`; + * quantities keep their unit (or convert via `unit` / `best`). + * @example + * ```ts + * import { calc, formatCalc } from '@pascal-app/lingo/calc' + * const r = calc('7m*2') + * r.ok && formatCalc(r, { style: 'compact' }) // '14m' + * r.ok && formatCalc(r, { style: 'scientific' }) // '14e6' + * ``` + */ +export function formatCalc(result: CalcResult, opts: CalcFormatOptions = {}): string { + if (result.quantity) { + const q = + opts.unit === undefined + ? opts.best + ? result.quantity.toBest() + : result.quantity + : result.quantity.to(opts.unit) + const style = opts.style ?? 'standard' + if (style === 'standard' || style === 'words') { + return q.format({ style: style === 'words' ? 'long' : 'symbol' }) + } + const number = formatNumber(q.value, style === 'compact' ? 'scientific' : style) + const labeled = q.format({ style: 'symbol' }) + const unit = labeled.replace(/^[^\s]+/, '').trim() + if (!unit) { + return number + } + return `${number} ${unit}` + } + return formatNumber(result.value, opts.style ?? 'standard') +} + +/** + * Canonical infix for a calc tree. Re-parses through `calc()`. + * @example + * ```ts + * import { calc, formatExpression } from '@pascal-app/lingo/calc' + * const r = calc('7m*2') + * r.ok && formatExpression(r.node) // '7e6 × 2' + * ``` + */ +export function formatExpression(node: CalcNode): string { + return emit(node, false) +} + +/** + * LaTeX for a calc tree. + * @example + * ```ts + * import { calc, formatLatex } from '@pascal-app/lingo/calc' + * const r = calc('7m*2') + * r.ok && formatLatex(r.node) // '7 \\times 10^{6} \\times 2' + * ``` + */ +export function formatLatex(node: CalcNode): string { + return emitLatex(node, false) +} + +export function formatNumber(value: number, style: CalcFormatStyle): string { + if (!Number.isFinite(value)) { + return String(value) + } + if (style === 'standard') { + return trimNumber(value) + } + if (style === 'grouped') { + return new Intl.NumberFormat('en-US', { useGrouping: true, maximumFractionDigits: 12 }).format( + value, + ) + } + if (style === 'scientific') { + return engineering(value) + } + const split = splitScale(value) + if (style === 'compact') { + if (split?.suffix) { + return `${trimNumber(split.coef)}${split.suffix}` + } + const abs = Math.abs(value) + if (abs >= 1000 || (abs > 0 && abs < 0.001)) { + return engineering(value) + } + return trimNumber(value) + } + return split ? `${trimNumber(split.coef)} ${split.word}` : trimNumber(value) +} + +function splitScale(value: number): { coef: number; suffix: string; word: string } | null { + if (value === 0 || !Number.isFinite(value)) { + return null + } + const sign = Math.sign(value) + const abs = Math.abs(value) + for (const scale of SCALES) { + const coef = roundSig(abs / scale.factor, 4) + if (coef >= 1 && coef < 1000) { + return { coef: sign * coef, suffix: scale.suffix, word: scale.word } + } + } + return null +} + +function engineering(value: number): string { + if (value === 0) { + return '0' + } + const exp = Math.floor(Math.log10(Math.abs(value))) + const eng = Math.floor(exp / 3) * 3 + const coef = roundSig(value / 10 ** eng, 4) + return eng === 0 ? trimNumber(coef) : `${trimNumber(coef)}e${eng}` +} + +function trimNumber(value: number): string { + if (Object.is(value, -0)) { + return '-0' + } + const rounded = roundSig(value, 12) + if (Number.isInteger(rounded) && Math.abs(rounded) < Number.MAX_SAFE_INTEGER) { + return String(rounded) + } + return String(rounded) +} + +function emit(node: CalcNode, paren: boolean): string { + const inner = emitInner(node) + return paren && needsParen(node) ? `(${inner})` : inner +} + +function emitInner(node: CalcNode): string { + if (node.type === 'number') { + return compactScientific(node.value) + } + if (node.type === 'quantity') { + return node.value.format() + } + if (node.type === 'group') { + return `(${emit(node.node, false)})` + } + if (node.type === 'percent') { + const of = emit(node.of, true) + const pct = percentAmount(node.percent) + if (node.mode === 'of') { + return `${pct}% of ${of}` + } + if (node.mode === 'off') { + return `${pct}% off ${of}` + } + return `${of} + ${pct}%` + } + const op = node.op === '*' ? '×' : node.op === '/' ? '/' : node.op + const leftParen = node.op === '*' || node.op === '/' + return `${emit(node.left, leftParen)} ${op} ${emit(node.right, true)}` +} + +function emitLatex(node: CalcNode, paren: boolean): string { + const inner = emitLatexInner(node) + return paren && needsParen(node) ? `\\left(${inner}\\right)` : inner +} + +function emitLatexInner(node: CalcNode): string { + if (node.type === 'number') { + return latexNumber(node.value) + } + if (node.type === 'quantity') { + const labeled = node.value.format({ style: 'symbol' }) + const unit = labeled.replace(/^[^\s]+/, '').trim() + const num = latexNumber(node.value.value) + return unit ? `${num}\\,\\mathrm{${escapeLatex(unit)}}` : num + } + if (node.type === 'group') { + return `\\left(${emitLatex(node.node, false)}\\right)` + } + if (node.type === 'percent') { + const of = emitLatex(node.of, true) + const pct = latexNumber(percentAmount(node.percent)) + if (node.mode === 'of') { + return `${pct}\\%\\text{ of }${of}` + } + if (node.mode === 'off') { + return `${pct}\\%\\text{ off }${of}` + } + return `${of} + ${pct}\\%` + } + if (node.op === '/') { + return `\\frac{${emitLatex(node.left, false)}}{${emitLatex(node.right, false)}}` + } + const op = node.op === '*' ? '\\times' : node.op + return `${emitLatex(node.left, node.op === '*')} ${op} ${emitLatex(node.right, true)}` +} + +function latexNumber(value: number): string { + const text = compactScientific(value) + const match = /^(-?[\d.]+)e(-?\d+)$/.exec(text) + if (match) { + return `${match[1]} \\times 10^{${match[2]}}` + } + return text +} + +function compactScientific(value: number): string { + if (value === 0 || !Number.isFinite(value)) { + return trimNumber(value) + } + const abs = Math.abs(value) + if (abs >= 0.001 && abs < 1000) { + return trimNumber(value) + } + return engineering(value) +} + +function percentAmount(node: CalcNode): number { + if (node.type === 'quantity' && node.value.kind === 'percent') { + return node.value.value + } + if (node.type === 'number') { + return node.value + } + return Number.NaN +} + +function needsParen(node: CalcNode): boolean { + return node.type === 'op' || node.type === 'percent' +} + +function escapeLatex(text: string): string { + return text.replace(/[%#&_]/g, '\\$&') +} diff --git a/packages/lingo/src/calc/index.ts b/packages/lingo/src/calc/index.ts new file mode 100644 index 0000000..a39711c --- /dev/null +++ b/packages/lingo/src/calc/index.ts @@ -0,0 +1,223 @@ +/** + * Quantity arithmetic — a closed calculator over already-parsed values. + * + * Import from `@pascal-app/lingo/calc` (not the main entry) so the full + * bundle stays flat. The grammar cannot express a call, a scope, or a side + * effect: no variables, no functions, no dimensional algebra. + */ +import { toBase } from '../core/convert' +import { hasError, makeIssue, setDefaultMessages } from '../core/errors' +import { Quantity } from '../core/quantity' +import { createRegistry } from '../core/registry' +import type { Kind, LingoIssue, Span } from '../core/types' +import { registerTemperatureVocabs } from '../fuzzy/temperature' +import { en } from '../messages/en' +import { + applySeverity, + confidenceForIssues, + exampleFor, + issue, + type ParseOptions, + type ParserState, + prepare, +} from '../parse/config' +import { resolveImplied } from '../parse/quantity' +import { allKinds, byteishFallbacks } from '../units/index' +import type { EvalValue } from './eval' +import { evaluate } from './eval' +import { formatCalc, formatExpression, formatLatex } from './format' +import { parseCalc } from './parse' +import type { + CalcFail, + CalcFormatOptions, + CalcJSON, + CalcNode, + CalcOptions, + CalcOutcome, + CalcResult, +} from './types' + +setDefaultMessages(en) + +const defaultRegistry = createRegistry(allKinds) +registerTemperatureVocabs(defaultRegistry) + +export { formatCalc, formatExpression, formatLatex } from './format' +export type { + CalcFail, + CalcFormatOptions, + CalcFormatStyle, + CalcJSON, + CalcNode, + CalcOptions, + CalcOutcome, + CalcResult, +} from './types' + +/** + * Evaluate a closed arithmetic expression over quantities and numbers. + * `lingo()` never does this — mixed fields inject `calc` with `{ trigger: '=' }` + * so `5-10 kg` stays a range. + * @example + * ```ts + * import { calc } from '@pascal-app/lingo/calc' + * const r = calc('7m*2') + * r.ok && r.value // 14000000 + * r.ok && r.format({ style: 'words' }) // '14 million' + * calc('9 min x 4').ok && calc('9 min x 4').format({ unit: 'h' }) // '0.6 h' + * ``` + */ +export function calc(input: string, opts?: CalcOptions): CalcOutcome { + const trigger = opts?.trigger ?? 'always' + const resolved = resolveOptions(opts) + if (trigger === '=' && !input.trimStart().startsWith('=')) { + const p = prepare(input, resolved) + issue(p, 'NO_VALUE', { example: '"= 2 + 3 kg"' }, 0, p.text.length) + return attachJson(fail(p)) + } + const p = prepare(input, resolved) + if (p.tokens.length === 0) { + issue(p, 'EMPTY', {}, 0, p.text.length) + return attachJson(fail(p)) + } + let node: CalcNode | null + try { + node = parseCalc(p) + } catch { + issue(p, 'NO_VALUE', { example: exampleFor(p) }, 0, p.text.length) + return attachJson(fail(p)) + } + if (!node) { + return attachJson(fail(p)) + } + let value: EvalValue | null + try { + value = evaluate(p, node) + } catch { + p.issues.push(makeIssue('NONFINITE', {}, node.span, p.opts.messages)) + return attachJson(fail(p)) + } + if (!value) { + return attachJson(fail(p)) + } + const quantity = finishQuantity(p, value) + if (p.opts.kind && quantity && quantity.kind !== p.opts.kind) { + p.issues.push( + makeIssue( + 'KIND_MISMATCH', + { found: quantity.kind, expected: p.opts.kind, example: exampleFor(p) }, + node.span, + p.opts.messages, + ), + ) + } + const issues = applySeverity(p, p.issues) + if (hasError(issues)) { + return attachJson(fail(p, issues)) + } + const result: CalcResult = { + ok: true, + schemaVersion: 3, + type: 'calc', + text: p.src, + span: node.span, + issues, + confidence: confidenceForIssues(issues, quantity?.approximate), + value: quantity ? quantity.value : value.value, + node, + expression: formatExpression(node), + latex: formatLatex(node), + format: (formatOpts?: CalcFormatOptions) => formatCalc(result, formatOpts), + } + if (quantity) { + result.quantity = quantity + } + return attachJson(result) +} + +function finishQuantity( + p: ParserState, + value: { kind: Kind | null; quantity: Quantity | null; value: number }, +): Quantity | undefined { + if (value.quantity) { + return value.quantity + } + const implied = resolveImplied(p) + if (!implied) { + return + } + if (!p.config.bareNumbers) { + issue(p, 'UNIT_REQUIRED', { example: exampleFor(p) }, 0, p.text.length) + return + } + const unit = p.reg.unit(implied.kind, implied.unitId) + if (!unit) { + return + } + issue(p, 'UNIT_ASSUMED', { unit: unit.plural ?? `${unit.name}s` }, 0, p.text.length) + const base = toBase(unit, value.value) + if (!Number.isFinite(base)) { + issue(p, 'NONFINITE', {}, 0, p.text.length) + return + } + return new Quantity(p.reg, implied.kind, base, implied.unitId) +} + +function resolveOptions(opts?: CalcOptions): ParseOptions { + return { + aliasFallbacks: byteishFallbacks, + ...opts, + messages: opts?.messages ?? en, + registry: opts?.registry ?? defaultRegistry, + } +} + +function fail(p: ParserState, issues: LingoIssue[] = applySeverity(p, p.issues)): CalcFail { + return { + ok: false, + schemaVersion: 3, + type: 'failure', + text: p.src, + issues, + } +} + +function attachJson(result: T): T { + Object.defineProperty(result, 'toJSON', { + value(this: CalcOutcome): CalcJSON | Omit { + if (!this.ok) { + return { + ok: false, + schemaVersion: 3, + type: 'failure', + text: this.text, + issues: this.issues, + } + } + const json: CalcJSON = { + ok: true, + schemaVersion: 3, + type: 'calc', + text: this.text, + span: withText(this.span, this.text), + issues: this.issues, + confidence: this.confidence, + value: this.value, + expression: this.expression, + latex: this.latex, + } + if (this.quantity) { + json.quantity = this.quantity.toJSON() + } + return json + }, + enumerable: true, + configurable: true, + writable: true, + }) + return result +} + +function withText(span: Span, text: string): Span & { text: string } { + return { start: span.start, end: span.end, text: text.slice(span.start, span.end) } +} diff --git a/packages/lingo/src/calc/parse.ts b/packages/lingo/src/calc/parse.ts new file mode 100644 index 0000000..8a81d9c --- /dev/null +++ b/packages/lingo/src/calc/parse.ts @@ -0,0 +1,379 @@ +import { Quantity, registryOf } from '../core/quantity' +import type { Span } from '../core/types' +import { consumeCjkPostUnitHalf, prepareCjkValueTokens } from '../number/cjk' +import { + eatPhrase, + exampleFor, + issue, + type ParserState, + symAt, + valueStarts, + wordAt, +} from '../parse/config' +import { toSourceSpan } from '../parse/normalize' +import { parseQty, type QtyNode } from '../parse/quantity' +import type { CalcNode } from './types' + +const QTY_FLAGS = { noAdditiveJoin: true, calcScales: true } as const + +const PREFIXES: readonly { factor: number; phrase: string }[] = [ + { phrase: 'half of', factor: 0.5 }, + { phrase: 'twice', factor: 2 }, + { phrase: 'double', factor: 2 }, + { phrase: 'triple', factor: 3 }, + { phrase: 'thrice', factor: 3 }, +] + +export function parseCalc(p: ParserState): CalcNode | null { + let pos = 0 + if (symAt(p, pos) === '=') { + pos++ + } + const node = parseExpr() + if (!node) { + const start = p.tokens[pos]?.start ?? 0 + issue(p, 'NO_VALUE', { example: exampleFor(p) }, start, p.text.length) + return null + } + while (symAt(p, pos) === '=') { + pos++ + } + if (p.tokens[pos]) { + const t = p.tokens[pos]! + const end = p.tokens[p.tokens.length - 1]!.end + issue(p, 'TRAILING_INPUT', { text: p.text.slice(t.start, end) }, t.start, end) + } + return node + + function parseExpr(): CalcNode | null { + const prefix = tryPrefix() + if (prefix) { + const inner = parseExpr() + if (!inner) { + return null + } + return binary('*', prefix.node, inner) + } + return parseAdd() + } + + function parseAdd(): CalcNode | null { + let left = parseMul() + if (!left) { + return null + } + for (;;) { + const op = addOp() + if (!op) { + return left + } + const right = parseMul() + if (!right) { + return null + } + if (isPercentQty(right) && !isPercentQty(left)) { + left = { + type: 'percent', + of: left, + percent: right, + mode: op === '-' ? 'off' : 'add', + span: join(left.span, right.span), + } + continue + } + left = binary(op, left, right) + } + } + + function parseMul(): CalcNode | null { + let left = parseUnary() + if (!left) { + return null + } + for (;;) { + const op = mulOp() + if (!op) { + return left + } + const right = parseUnary() + if (!right) { + return null + } + left = binary(op, left, right) + } + } + + function parseUnary(): CalcNode | null { + const saved = snapshot() + const primary = parsePrimary() + if (primary) { + return maybePercentOf(primary) + } + restore(saved) + const sign = unarySign() + if (!sign) { + return null + } + const inner = parseUnary() + if (!inner) { + return null + } + return sign === '-' ? negate(inner) : inner + } + + function parsePrimary(): CalcNode | null { + if (symAt(p, pos) === '(') { + const start = p.tokens[pos]!.start + pos++ + const inner = parseExpr() + if (!inner) { + return null + } + if (symAt(p, pos) === ')') { + pos++ + } + const endTok = p.tokens[pos - 1] + return { + type: 'group', + node: inner, + span: toSourceSpan(p.n, start, endTok?.end ?? inner.span.end), + } + } + return tryQty() + } + + function maybePercentOf(left: CalcNode): CalcNode { + if (!isPercentQty(left)) { + return left + } + const w = wordAt(p, pos) + if (w !== 'of' && w !== 'off' && w !== 'on') { + return left + } + const saved = snapshot() + const mode = w === 'off' ? 'off' : 'of' + pos++ + const of = parseUnary() + if (!of) { + restore(saved) + return left + } + return { + type: 'percent', + of, + percent: left, + mode, + span: join(left.span, of.span), + } + } + + function tryPrefix(): { node: CalcNode } | null { + const startPos = pos + for (const prefix of PREFIXES) { + const next = eatPhrase(p, pos, prefix.phrase) + if (next < 0) { + continue + } + pos = next + const span = tokenSpan(startPos, pos) + return { node: { type: 'number', value: prefix.factor, span } } + } + if (wordAt(p, pos) !== 'half') { + return null + } + const nextWord = wordAt(p, pos + 1) + if (nextWord === 'an' || nextWord === 'a') { + return null + } + if (nextWord !== 'of' && !operandStarts(pos + 1)) { + return null + } + const start = p.tokens[pos]!.start + pos++ + if (nextWord === 'of') { + pos++ + } + const end = p.tokens[pos - 1]!.end + return { node: { type: 'number', value: 0.5, span: toSourceSpan(p.n, start, end) } } + } + + function tryQty(): CalcNode | null { + const saved = snapshot() + prepareCjkValueTokens(p.tokens, pos, p.profile.numberWords) + const q = parseQty(p, pos, true, undefined, QTY_FLAGS) + if (!q) { + restore(saved) + return null + } + const withHalf = applyCjkHalf(p, q) + pos = withHalf.nextToken + const span = toSourceSpan(p.n, withHalf.normStart, withHalf.normEnd) + if (withHalf.kind && withHalf.headUnit) { + if (!Number.isFinite(withHalf.base)) { + issue(p, 'NONFINITE', {}, withHalf.normStart, withHalf.normEnd) + restore(saved) + return null + } + const quantity = new Quantity(p.reg, withHalf.kind, withHalf.base, withHalf.headUnit, { + approximate: withHalf.approximate, + parts: withHalf.parts.length > 1 ? withHalf.parts : undefined, + }) + return { type: 'quantity', value: quantity, span } + } + if (!Number.isFinite(withHalf.base)) { + issue(p, 'NONFINITE', {}, withHalf.normStart, withHalf.normEnd) + restore(saved) + return null + } + return { type: 'number', value: withHalf.base, span } + } + + function addOp(): '+' | '-' | null { + const s = symAt(p, pos) + if (s === '+' || s === '-') { + pos++ + return s + } + const w = wordAt(p, pos) + if (w && p.profile.grammar.compoundPlusWords.has(w)) { + pos++ + return '+' + } + if (w && p.profile.grammar.compoundMinusWords.has(w)) { + pos++ + return '-' + } + if (w && p.profile.grammar.compoundJoinWords.has(w)) { + pos++ + return '+' + } + return null + } + + function mulOp(): '*' | '/' | null { + const s = symAt(p, pos) + if (s === '*' || s === '×' || s === '·') { + pos++ + return '*' + } + if (s === '/' || s === '÷') { + pos++ + return '/' + } + const times = eatPhrase(p, pos, 'multiplied by') + if (times >= 0) { + pos = times + return '*' + } + const divided = eatPhrase(p, pos, 'divided by') + if (divided >= 0) { + pos = divided + return '/' + } + const w = wordAt(p, pos) + if (w === 'x' || w === 'times') { + pos++ + return '*' + } + if (w === 'over') { + pos++ + return '/' + } + return null + } + + function unarySign(): '+' | '-' | null { + const s = symAt(p, pos) + if (s === '+' || s === '-') { + pos++ + return s + } + const w = wordAt(p, pos) + if (w && p.profile.grammar.compoundMinusWords.has(w)) { + pos++ + return '-' + } + if (w && p.profile.grammar.compoundPlusWords.has(w)) { + pos++ + return '+' + } + return null + } + + function operandStarts(i: number): boolean { + return symAt(p, i) === '(' || valueStarts(p, i) + } + + function snapshot(): { issues: number; pos: number } { + return { pos, issues: p.issues.length } + } + + function restore(saved: { issues: number; pos: number }): void { + pos = saved.pos + p.issues.length = saved.issues + } + + function tokenSpan(from: number, to: number): Span { + const start = p.tokens[from]?.start ?? 0 + const end = p.tokens[to - 1]?.end ?? start + return toSourceSpan(p.n, start, end) + } + + function binary(op: '+' | '-' | '*' | '/', left: CalcNode, right: CalcNode): CalcNode { + return { type: 'op', op, left, right, span: join(left.span, right.span) } + } +} + +function applyCjkHalf(p: ParserState, q: QtyNode): QtyNode { + const half = + q.kind && q.headUnit && consumeCjkPostUnitHalf(p.tokens, q.nextToken, p.profile.numberWords) + if (!half) { + return q + } + const part = q.parts[q.parts.length - 1] + const unit = p.reg.unit(q.kind!, part?.unit ?? q.headUnit!) + if (!unit) { + return q + } + q.base += 0.5 * unit.factor + if (part?.unit === unit.id) { + part.value += 0.5 + } else { + q.parts.push({ unit: unit.id, value: 0.5 }) + } + q.normEnd = half.end + q.nextToken = half.next + return q +} + +function isPercentQty(node: CalcNode): boolean { + return node.type === 'quantity' && node.value.kind === 'percent' +} + +function join(a: Span, b: Span): Span { + return { start: a.start, end: b.end } +} + +function negate(node: CalcNode): CalcNode { + if (node.type === 'number') { + return { ...node, value: -node.value } + } + if (node.type === 'quantity') { + const q = node.value + return { + type: 'quantity', + value: new Quantity(registryOf(q), q.kind, -q.base, q.unit, { approximate: q.approximate }), + span: node.span, + } + } + if (node.type === 'group') { + return { ...node, node: negate(node.node) } + } + return { + type: 'op', + op: '*', + left: { type: 'number', value: -1, span: node.span }, + right: node, + span: node.span, + } +} diff --git a/packages/lingo/src/calc/types.ts b/packages/lingo/src/calc/types.ts new file mode 100644 index 0000000..6b8dc6d --- /dev/null +++ b/packages/lingo/src/calc/types.ts @@ -0,0 +1,130 @@ +import type { Quantity } from '../core/quantity' +import type { LingoIssue, Span } from '../core/types' +import type { LingoOptions } from '../factory' + +/** + * Closed expression node union. No symbols, calls, or extension point — the + * grammar cannot express a side effect. Avoid: AST, formula. + * @example + * ```ts + * import { calc, type CalcNode } from '@pascal-app/lingo/calc' + * const r = calc('9 min x 4') + * const node: CalcNode | undefined = r.ok ? r.node : undefined + * node?.type // 'op' + * ``` + */ +export type CalcNode = + | { type: 'number'; value: number; span: Span } + | { type: 'quantity'; value: Quantity; span: Span } + | { type: 'group'; node: CalcNode; span: Span } + | { + type: 'percent' + of: CalcNode + percent: CalcNode + mode: 'of' | 'add' | 'off' + span: Span + } + | { type: 'op'; op: '+' | '-' | '*' | '/'; left: CalcNode; right: CalcNode; span: Span } + +/** + * Options for `calc()`. Same bag as `lingo()`, plus a mode switch for when one + * text box feeds both parsers. + * @example + * ```ts + * import { calc, type CalcOptions } from '@pascal-app/lingo/calc' + * const opts: CalcOptions = { kind: 'mass', unit: 'kg' } + * calc('12 * 0.75 kg', opts).ok // true + * ``` + */ +export interface CalcOptions extends LingoOptions { + /** + * `'always'` (default for `calc()`): evaluate any input. + * `'='`: only evaluate when the input starts with `=`, so a field that also + * calls `lingo()` keeps range-first semantics on bare text. + */ + trigger?: '=' | 'always' +} + +/** How `formatCalc` / `CalcResult.format` render the evaluated number. */ +export type CalcFormatStyle = 'standard' | 'grouped' | 'words' | 'scientific' | 'compact' + +/** + * Display options for an evaluated calc result. + * @example + * ```ts + * import { calc } from '@pascal-app/lingo/calc' + * const r = calc('9 min x 4') + * r.ok && r.format({ unit: 'h' }) // '0.6 h' + * ``` + */ +export interface CalcFormatOptions { + /** Pick a best-fit unit of the same system (`36 min` → `0.6 h`). */ + best?: boolean + style?: CalcFormatStyle + /** Convert the quantity into this unit before formatting. */ + unit?: string +} + +/** + * Successful `calc()` result. `expression` is the normalized infix form and + * re-parses through `calc()`. `latex` is the same tree for display. + * @example + * ```ts + * import { calc } from '@pascal-app/lingo/calc' + * const r = calc('7m*2') + * r.ok && r.value // 14000000 + * r.ok && r.format({ style: 'words' }) // '14 million' + * ``` + */ +export interface CalcResult { + confidence: number + expression: string + format: (opts?: CalcFormatOptions) => string + issues: LingoIssue[] + latex: string + node: CalcNode + ok: true + quantity?: Quantity + schemaVersion: 3 + span: Span + text: string + toJSON?: () => CalcJSON + type: 'calc' + value: number +} + +/** Wire JSON for a successful calc result. */ +export interface CalcJSON { + confidence: number + expression: string + issues: LingoIssue[] + latex: string + ok: true + quantity?: ReturnType + schemaVersion: 3 + span: Span + text: string + type: 'calc' + value: number +} + +/** + * Failed `calc()` result. + * @example + * ```ts + * import { calc } from '@pascal-app/lingo/calc' + * const r = calc('5 kg * 2 m') + * r.ok // false + * r.issues[0]?.code // 'SCALAR_EXPECTED' + * ``` + */ +export interface CalcFail { + candidate?: CalcResult + issues: LingoIssue[] + ok: false + schemaVersion: 3 + text: string + type: 'failure' +} + +export type CalcOutcome = CalcResult | CalcFail diff --git a/packages/lingo/src/complete/completions.ts b/packages/lingo/src/complete/completions.ts index fa8beab..fc43576 100644 --- a/packages/lingo/src/complete/completions.ts +++ b/packages/lingo/src/complete/completions.ts @@ -21,6 +21,7 @@ import { } from './suggest-units' import type { Completion, + CompletionCalcParser, CompletionDateParser, CompletionDateResult, CompletionResult, @@ -29,6 +30,7 @@ import type { export type { Completion, + CompletionCalcParser, CompletionDateParser, CompletionDateResult, CompletionResult, @@ -36,6 +38,12 @@ export type { } from './types' export interface CompletionsOptions extends LingoOptions { + /** + * Inject `calc` from `@pascal-app/lingo/calc` with `{ trigger: '=' }` so + * `=2+3 kg` completes as an evaluated quantity without bundling `./calc` + * into `@pascal-app/lingo/complete`. Bare `5-10 kg` stays a range. + */ + calc?: CompletionCalcParser /** * Inject `parseDate` / `parseDateRange` / `parseDuration` from * `@pascal-app/lingo/date` to add date completions without bundling the date @@ -79,6 +87,9 @@ function completionText(result: CompletionResult): string { if (result.type === 'range') { return result.range.format() } + if (result.type === 'calc') { + return `= ${result.format()}` + } if (result.type === 'date') { return formatDate(result) } @@ -171,6 +182,16 @@ function collectDate(drafts: Draft[], input: string, date: CompletionDateParser } } +function collectCalc(drafts: Draft[], input: string, calc: CompletionCalcParser | undefined): void { + if (!(calc && input.trimStart().startsWith('='))) { + return + } + const result = calc(input) + if (result.ok) { + addDraft(drafts, result, 'calc', result.confidence) + } +} + function collectUnitAmbiguity( drafts: Draft[], primary: CompletionResult, @@ -401,6 +422,7 @@ export function completions(input: string, opts?: CompletionsOptions): Completio } collectDate(drafts, input, opts?.date) + collectCalc(drafts, input, opts?.calc) const tail = detectRangeTail(prepared.tokens, prepared.text) const rangeKind = tail diff --git a/packages/lingo/src/complete/index.ts b/packages/lingo/src/complete/index.ts index 390472d..b35993e 100644 --- a/packages/lingo/src/complete/index.ts +++ b/packages/lingo/src/complete/index.ts @@ -14,6 +14,7 @@ export type { CompletionsOptions } from './completions' export { completions as completionsCore } from './completions' export type { Completion, + CompletionCalcParser, CompletionDateParser, CompletionDateResult, CompletionResult, diff --git a/packages/lingo/src/complete/types.ts b/packages/lingo/src/complete/types.ts index c05b551..3923108 100644 --- a/packages/lingo/src/complete/types.ts +++ b/packages/lingo/src/complete/types.ts @@ -1,3 +1,4 @@ +import type { CalcFail, CalcResult } from '../calc/types' import type { DateFail, DateRange, DateRangeFail, DateResult, DurationResult } from '../date' import type { ConversionResult, QuantityResult, RangeResult } from '../parse/config' @@ -7,11 +8,13 @@ export type CompletionDateParseResult = | DateFail | DateRangeFail export type CompletionDateParser = (input: string) => CompletionDateParseResult +export type CompletionCalcParser = (input: string) => CalcResult | CalcFail export type CompletionResult = | QuantityResult | RangeResult | ConversionResult | CompletionDateResult + | CalcResult /** How a completion was derived — for UI labels and debugging. */ export type CompletionSource = @@ -23,6 +26,7 @@ export type CompletionSource = | 'range-implied' | 'cross-kind' | 'date' + | 'calc' /** * A ranked, fully-parsed interpretation of a (possibly partial) input. diff --git a/packages/lingo/src/core/errors.ts b/packages/lingo/src/core/errors.ts index 8d971d7..417cfd1 100644 --- a/packages/lingo/src/core/errors.ts +++ b/packages/lingo/src/core/errors.ts @@ -16,6 +16,8 @@ const SEVERITY: Partial> = { SLANG_UNIT: 'warning', TZ_IGNORED: 'warning', AMBIGUOUS_TIMEZONE: 'warning', + AFFINE_DELTA_ASSUMED: 'warning', + SCALE_ASSUMED: 'warning', CIVIL_AVERAGE: 'info', UNIT_ASSUMED: 'info', WEEKDAY_ASSUMED_NEXT: 'info', diff --git a/packages/lingo/src/core/types.ts b/packages/lingo/src/core/types.ts index 9df8338..7f8905b 100644 --- a/packages/lingo/src/core/types.ts +++ b/packages/lingo/src/core/types.ts @@ -244,6 +244,11 @@ export type IssueCode = | 'SLANG_UNIT' | 'TZ_IGNORED' | 'AMBIGUOUS_TIMEZONE' + | 'AFFINE_DELTA_ASSUMED' + | 'EXPRESSION_KIND_MISMATCH' + | 'SCALAR_EXPECTED' + | 'DIVISION_BY_ZERO' + | 'SCALE_ASSUMED' /** * Per-code structured payload, keyed by `IssueCode` — what `issue.data` @@ -260,6 +265,7 @@ export type IssueCode = * ``` */ export interface IssueDataMap { + AFFINE_DELTA_ASSUMED: { unit: string; asDelta: string } AMBIGUOUS_DATE: { text: string; a: string; b: string } AMBIGUOUS_NUMBER: { text: string; a: string; b: string } AMBIGUOUS_TIMEZONE: { tz: string } @@ -269,7 +275,9 @@ export interface IssueDataMap { COMPOUND_OVERFLOW: { value: number; unit: string } CONVERSION_KIND_MISMATCH: { found: string; target: string } CONVERSION_NOT_ALLOWED: Record + DIVISION_BY_ZERO: Record EMPTY: Record + EXPRESSION_KIND_MISMATCH: { left: string; right: string } KIND_MISMATCH: { found: string; expected: string; example: string } LOCALE_NOT_LOADED: { locale: string } NO_VALUE: { example: string } @@ -283,6 +291,8 @@ export interface IssueDataMap { RANGE_REVERSED: { fixed: string } RATE_REQUIRED: { from: string; to: string } REQUIRED: Record + SCALAR_EXPECTED: { op: string } + SCALE_ASSUMED: { symbol: string; scale: string } SINGLE_VALUE_EXPECTED: Record SLANG_UNIT: { alias: string; unit: string } TRAILING_INPUT: { text: string } diff --git a/packages/lingo/src/messages/en.test.ts b/packages/lingo/src/messages/en.test.ts index fe2f0e2..3f52362 100644 --- a/packages/lingo/src/messages/en.test.ts +++ b/packages/lingo/src/messages/en.test.ts @@ -40,6 +40,11 @@ const ISSUE_CODES = [ 'SLANG_UNIT', 'TZ_IGNORED', 'AMBIGUOUS_TIMEZONE', + 'AFFINE_DELTA_ASSUMED', + 'EXPRESSION_KIND_MISMATCH', + 'SCALAR_EXPECTED', + 'DIVISION_BY_ZERO', + 'SCALE_ASSUMED', ] as const satisfies readonly IssueCode[] describe('english message pack', () => { diff --git a/packages/lingo/src/messages/en.ts b/packages/lingo/src/messages/en.ts index 6535e31..d6c729d 100644 --- a/packages/lingo/src/messages/en.ts +++ b/packages/lingo/src/messages/en.ts @@ -48,4 +48,9 @@ export const en: Record = { APPROX_NOT_ALLOWED: "Approximate values aren't accepted here — enter an exact value.", UNIT_REQUIRED: 'Include a unit — try {example}.', CONVERSION_NOT_ALLOWED: "Conversions aren't accepted here — enter the value directly.", + AFFINE_DELTA_ASSUMED: 'Added {asDelta} as a {unit} change, not an absolute temperature.', + EXPRESSION_KIND_MISMATCH: 'Cannot mix {left} and {right} in one expression.', + SCALAR_EXPECTED: 'Cannot {op} two quantities — one side must be a plain number.', + DIVISION_BY_ZERO: 'Cannot divide by zero.', + SCALE_ASSUMED: 'Read "{symbol}" as {scale}.', } diff --git a/packages/lingo/src/number/cjk.ts b/packages/lingo/src/number/cjk.ts index fa3a4d0..482610a 100644 --- a/packages/lingo/src/number/cjk.ts +++ b/packages/lingo/src/number/cjk.ts @@ -120,7 +120,7 @@ export function prepareCjkValueTokens(tokens: Token[], i: number, tables: Number if (t.type === 'word') { const parsed = parseCjkNumberText(t.text, tables) if (parsed && parsed.end > 0) { - splitPrefix(tokens, i, t.start + parsed.end, String(parsed.value), tables) + splitPrefix(tokens, i, t.start + parsed.end, String(parsed.value), tables, parsed) } return } @@ -134,7 +134,7 @@ export function prepareCjkValueTokens(tokens: Token[], i: number, tables: Number } const parsed = parseCjkNumberText(contiguousText(tokens, i), tables) if (parsed?.sawScale && parsed.end > t.text.length) { - splitPrefix(tokens, i, t.start + parsed.end, String(parsed.value), tables) + splitPrefix(tokens, i, t.start + parsed.end, String(parsed.value), tables, parsed) } } @@ -182,6 +182,7 @@ function splitPrefix( end: number, value: string, tables: NumberWordTables, + parsed: CjkNumberResult, ): void { const first = tokens[i] if (!first || end <= first.start) { @@ -192,7 +193,14 @@ function splitPrefix( cursor++ } const pieces: Token[] = [ - { type: 'digits', text: value, start: first.start, end, spaceBefore: first.spaceBefore }, + { + type: 'digits', + text: value, + start: first.start, + end, + spaceBefore: first.spaceBefore, + ...(parsed.adjacentRange ? { adjacentRange: true } : {}), + }, ] const last = tokens[cursor - 1] if (last && end < last.end) { diff --git a/packages/lingo/src/number/value.ts b/packages/lingo/src/number/value.ts index 2837338..bb14b8a 100644 --- a/packages/lingo/src/number/value.ts +++ b/packages/lingo/src/number/value.ts @@ -30,6 +30,8 @@ export interface ValueCtx { } export interface ValueNode { + /** CJK adjacent-range lead ("七八") so `2+3` is not a range. */ + adjacentRange?: boolean /** Alternative reading for ambiguous separators (already a full value). */ altValue?: number approximate?: boolean @@ -50,6 +52,13 @@ const isSep = (t: Token | undefined, ch: string): boolean => const attached = (t: Token | undefined): boolean => !!t && !t.spaceBefore +function markAdjacent(node: ValueNode, token: Token): ValueNode { + if (token.adjacentRange) { + node.adjacentRange = true + } + return node +} + export function parseValue(ctx: ValueCtx, i: number, atStart = false): ValueNode | null { const { tokens } = ctx const t = tokens[i] @@ -214,12 +223,18 @@ function assembleNumeric(ctx: ValueCtx, i: number): ValueNode | null { value = Number.POSITIVE_INFINITY } const last = tokens[spacePos - 1]! - return guardFinite(ctx, { value, next: spacePos, start: first.start, end: last.end, issues }) + return guardFinite( + ctx, + markAdjacent({ value, next: spacePos, start: first.start, end: last.end, issues }, first), + ) } } const last = tokens[pos - 1]! - const span: ValueNode = { value: 0, next: pos, start: first.start, end: last.end, issues } + const span: ValueNode = markAdjacent( + { value: 0, next: pos, start: first.start, end: last.end, issues }, + first, + ) const text = (): string => renderChain(groups, seps) if (seps.length === 0) { diff --git a/packages/lingo/src/parse/config.ts b/packages/lingo/src/parse/config.ts index 9bcc694..ff7e7ca 100644 --- a/packages/lingo/src/parse/config.ts +++ b/packages/lingo/src/parse/config.ts @@ -283,10 +283,11 @@ const PENALTY: Partial> = { AMBIGUOUS_UNIT: 0.1, UNIT_ASSUMED: 0.25, SLANG_UNIT: 0.2, + SCALE_ASSUMED: 0.1, } const ASSUMPTION_CODES = - 'TYPO_CORRECTED AMBIGUOUS_NUMBER AMBIGUOUS_UNIT AMBIGUOUS_DATE UNIT_ASSUMED SLANG_UNIT RANGE_REVERSED COMPOUND_OVERFLOW'.split( + 'TYPO_CORRECTED AMBIGUOUS_NUMBER AMBIGUOUS_UNIT AMBIGUOUS_DATE UNIT_ASSUMED SLANG_UNIT RANGE_REVERSED COMPOUND_OVERFLOW SCALE_ASSUMED'.split( ' ', ) as IssueCode[] diff --git a/packages/lingo/src/parse/grammar.test.ts b/packages/lingo/src/parse/grammar.test.ts index c520fb3..31d3b98 100644 --- a/packages/lingo/src/parse/grammar.test.ts +++ b/packages/lingo/src/parse/grammar.test.ts @@ -81,6 +81,18 @@ describe('single quantities', () => { } }) + it('does not read 2+3 kg as a CJK-adjacent range', () => { + const r = parseExpression('2+3 kg', opts()) + expect(r.ok).toBe(false) + expect(r.issues.some((issue) => issue.code === 'TRAILING_INPUT')).toBe(true) + }) + + it('warns when additive compounds use affine units as deltas', () => { + const r = qty('20°C + 5°C') + expect(r.quantity.value).toBeCloseTo(25, 12) + expect(r.issues.some((issue) => issue.code === 'AFFINE_DELTA_ASSUMED')).toBe(true) + }) + it('round-trips mixed-parts formatting', () => { const q = qty('20in and 10cm').quantity expect(q.format()).toBe('20 in + 10 cm') diff --git a/packages/lingo/src/parse/quantity.ts b/packages/lingo/src/parse/quantity.ts index cdd15ee..eb79f7c 100644 --- a/packages/lingo/src/parse/quantity.ts +++ b/packages/lingo/src/parse/quantity.ts @@ -200,6 +200,16 @@ export interface QtyNode { } export interface QtyFlags { + /** + * Calculator operands: glued `m`/`b` and scale words (`million`) multiply the + * number instead of being read as units, when the next token is an operator. + */ + calcScales?: boolean + /** + * Stop before `+` / `plus` / `minus` / `and` so `./calc` can own those + * operators. Juxtaposition compounds (`5 ft 11 in`) and commas still join. + */ + noAdditiveJoin?: boolean /** Inside "between A and B" the A-side must not eat 'and' as a sum joiner. */ noAnd?: boolean } @@ -340,6 +350,10 @@ export function parseQty( } } + if (flags?.calcScales) { + applyCalcScale(p, v, expectKind ?? p.opts.kind) + } + let pos = v.next // "5ish kg" / "5 ish kg" — "ish" between the value and a unit marks the // value approximate (the trailing "5 kg ish" form is handled downstream). @@ -431,6 +445,15 @@ export function parseQty( let sign = 1 const jw = wordAt(p, cursor) const js = symAt(p, cursor) + if ( + flags?.noAdditiveJoin && + (js === '+' || + (jw && p.profile.grammar.compoundPlusWords.has(jw)) || + (jw && p.profile.grammar.compoundMinusWords.has(jw)) || + (jw && p.profile.grammar.compoundJoinWords.has(jw))) + ) { + break + } if ( js === '-' && !p.tokens[cursor]!.spaceBefore && @@ -572,6 +595,10 @@ export function parseQty( break } + if (parts.length > 1) { + warnAffineDelta(p, kind, parts, v.start, normEnd) + } + return { kind, base, @@ -769,3 +796,100 @@ export function ensureUnit( issue(p, 'UNIT_ASSUMED', { unit: unit.plural ?? `${unit.name}s` }, q.normStart, q.normEnd) return { kind: implied.kind, base: toBase(unit, q.base), unitId: implied.unitId } } + +const WORD_SCALES: Record = { + thousand: { factor: 1e3, name: 'thousand' }, + million: { factor: 1e6, name: 'million' }, + billion: { factor: 1e9, name: 'billion' }, + trillion: { factor: 1e12, name: 'trillion' }, +} + +function applyCalcScale(p: ParserState, v: ValueNode, expectKind?: Kind): void { + const t = p.tokens[v.next] + if (t?.type !== 'word') { + return + } + const lower = t.text.toLowerCase() + const word = WORD_SCALES[lower] + if (word) { + v.value *= word.factor + v.next += 1 + v.end = t.end + return + } + if (t.spaceBefore || !isCalcScaleBoundary(p, v.next + 1)) { + return + } + if ((t.text === 'm' || t.text === 'M') && expectKind !== 'length' && expectKind !== 'duration') { + issue(p, 'SCALE_ASSUMED', { symbol: t.text, scale: 'million' }, t.start, t.end) + v.value *= 1e6 + v.next += 1 + v.end = t.end + return + } + if ((lower === 'b' || lower === 'bn') && expectKind !== 'data') { + issue(p, 'SCALE_ASSUMED', { symbol: t.text, scale: 'billion' }, t.start, t.end) + v.value *= 1e9 + v.next += 1 + v.end = t.end + } +} + +function isCalcScaleBoundary(p: ParserState, i: number): boolean { + const t = p.tokens[i] + if (!t) { + return true + } + if (t.type === 'sym') { + return '+-*/×·÷)(='.includes(t.text) + } + if (t.type === 'word') { + const w = t.text.toLowerCase() + return ( + w === 'x' || + w === 'times' || + w === 'plus' || + w === 'minus' || + w === 'divided' || + w === 'over' || + w === 'and' || + w === 'multiplied' + ) + } + return false +} + +function warnAffineDelta( + p: ParserState, + kind: Kind, + parts: QuantityPart[], + start: number, + end: number, +): void { + for (const part of parts.slice(1)) { + const unit = p.reg.unit(kind, part.unit) + if (unit?.offset) { + issue( + p, + 'AFFINE_DELTA_ASSUMED', + { unit: unit.symbol, asDelta: `${part.value} ${unit.symbol}` }, + start, + end, + ) + return + } + } + const head = p.reg.unit(kind, parts[0]!.unit) + if (head?.offset) { + issue( + p, + 'AFFINE_DELTA_ASSUMED', + { + unit: head.symbol, + asDelta: `${parts[1]!.value} ${p.reg.unit(kind, parts[1]!.unit)?.symbol ?? parts[1]!.unit}`, + }, + start, + end, + ) + } +} diff --git a/packages/lingo/src/parse/range.ts b/packages/lingo/src/parse/range.ts index 7093405..c6e7321 100644 --- a/packages/lingo/src/parse/range.ts +++ b/packages/lingo/src/parse/range.ts @@ -198,7 +198,10 @@ function withCjkPostUnitHalf(p: ParserState, q: QtyNode): QtyNode { function tryAdjacentCjkRange(p: ParserState, a: QtyNode, exprStart: number): Parsed | null { const next = p.tokens[a.nextToken] - if (!next || next.spaceBefore || a.value.value < 1 || a.value.value >= 9) { + if (!next || next.spaceBefore || !a.value.adjacentRange) { + return null + } + if (a.value.value < 1 || a.value.value >= 9) { return null } const b = parseQty(p, a.nextToken, false) diff --git a/packages/lingo/src/parse/tokenize.ts b/packages/lingo/src/parse/tokenize.ts index a18d560..68565af 100644 --- a/packages/lingo/src/parse/tokenize.ts +++ b/packages/lingo/src/parse/tokenize.ts @@ -3,6 +3,8 @@ import type { Normalized } from './normalize' export type TokenType = 'digits' | 'word' | 'vulgar' | 'sym' export interface Token { + /** CJK adjacent-range lead ("七八") — the next token is the range's other end. */ + adjacentRange?: boolean den?: number end: number /** Vulgar fraction value (½ → 1/2), pre-split from same-origin expansion. */ diff --git a/packages/lingo/src/schema/enums.ts b/packages/lingo/src/schema/enums.ts index 804a339..5ac41bc 100644 --- a/packages/lingo/src/schema/enums.ts +++ b/packages/lingo/src/schema/enums.ts @@ -87,6 +87,11 @@ export const ISSUE_CODES = { SLANG_UNIT: 'A slang unit spelling was interpreted.', TZ_IGNORED: 'A time zone was detected but not applied (civil time kept; use applyZone).', AMBIGUOUS_TIMEZONE: 'A time-zone abbreviation maps to more than one real zone.', + AFFINE_DELTA_ASSUMED: 'An affine unit was added as a delta, not an absolute temperature.', + EXPRESSION_KIND_MISMATCH: 'Expression operands are different kinds.', + SCALAR_EXPECTED: 'Arithmetic needed a plain number, not a second quantity.', + DIVISION_BY_ZERO: 'The expression divided by zero.', + SCALE_ASSUMED: 'A glued scale letter was read as million/billion, not a unit.', } as const satisfies Record /** Issue codes as a flat list, for enum constraints. */ diff --git a/packages/lingo/src/schema/schema.test.ts b/packages/lingo/src/schema/schema.test.ts index 0d1215e..82b1299 100644 --- a/packages/lingo/src/schema/schema.test.ts +++ b/packages/lingo/src/schema/schema.test.ts @@ -118,7 +118,7 @@ describe('schema reference data', () => { }) it('every issue code has a dictionary description', () => { - expect(Object.keys(ISSUE_CODES).length).toBe(33) + expect(Object.keys(ISSUE_CODES).length).toBe(38) for (const desc of Object.values(ISSUE_CODES)) { expect(desc.length).toBeGreaterThan(5) } diff --git a/packages/lingo/tsup.config.ts b/packages/lingo/tsup.config.ts index 2cc8a15..b559f7f 100644 --- a/packages/lingo/tsup.config.ts +++ b/packages/lingo/tsup.config.ts @@ -18,6 +18,7 @@ export default defineConfig((options) => [ index: 'src/index.ts', 'core/index': 'src/core/index.ts', 'date/index': 'src/date/index.ts', + 'calc/index': 'src/calc/index.ts', 'dom/index': 'src/dom/index.ts', 'element/index': 'src/element/index.ts', 'describe/index': 'src/describe/index.ts', diff --git a/plans/032-input-calculations.md b/plans/032-input-calculations.md index 86cfc93..110b62f 100644 --- a/plans/032-input-calculations.md +++ b/plans/032-input-calculations.md @@ -1,15 +1,15 @@ --- id: 032 title: Input calculations (quantity arithmetic) -status: draft +status: done — shipped 2026-08-22 (D73) created: 2026-07-08 -updated: 2026-07-29 +updated: 2026-08-22 goal: "Decide whether (and how) lingo evaluates arithmetic typed into fields — '=2+3 kg', '10% off $50', '12 * 0.75 kg' — without becoming a CAS or busting budgets, and close the operator/range collision that already ships." success_criteria: - - "Go/no-go decision recorded as a D-entry -> wiki/decisions.md" - - "Bare-mode `2+3 kg` no longer silently returns a range -> packages/lingo/tests/corpus + parse tests" - - "If go: node union and operator table locked here, corpus rows added -> this plan + tests/corpus" - - "If go: `./calc` marginal budget assigned and green -> packages/lingo/scripts/size.mjs" + - "Go/no-go decision recorded as a D-entry -> wiki/decisions.md [MET: D73]" + - "Bare-mode `2+3 kg` no longer silently returns a range -> packages/lingo/src/parse/grammar.test.ts + calc.test.ts [MET]" + - "If go: node union and operator table locked here -> this plan + packages/lingo/src/calc [MET: locked-in 2026-08-22]" + - "If go: `./calc` marginal budget assigned and green -> packages/lingo/scripts/size.mjs [MET: D73]" --- # Input calculations (quantity arithmetic) @@ -117,14 +117,12 @@ warning users off the operation. We already have the `convert`/`convertDelta` split (from js-quantities, per `wiki/inspiration.md`); the compound path just never said which one it was using. -## Design (proposed — not locked; gated on the go/no-go) +## Design (locked-in 2026-08-22) ### The compound/arithmetic discriminator -The rule that keeps this additive: - -- **Both operands carry units** → compound accumulation. Existing behavior, - unchanged: `2 ft + 3 in`, `2 kg + 500 g`, `2 m minus 10 cm`. +- **Both operands carry units** → compound accumulation. Existing `lingo()` + behavior, unchanged: `2 ft + 3 in`, `2 kg + 500 g`, `2 m minus 10 cm`. - **A bare operand, or any non-additive operator** → arithmetic, which lives in `./calc` and never in `lingo()`. @@ -133,14 +131,14 @@ meaning based on which entries a consumer imported. ### `=` is a field-level mode switch, not grammar -`calc()` accepts an expression with or without the prefix. The prefix matters -only where one text box feeds both parsers — `completions()` and the DOM -controller — so that bare input keeps today's range-first semantics with zero -corpus churn: +`calc()` evaluates with or without the prefix (`trigger` defaults to +`'always'`). The prefix matters only where one text box feeds both parsers — +`completions()` and `quantityField` inject `calc` with `{ trigger: '=' }` so +bare input keeps today's range-first semantics with zero corpus churn: ```ts interface CalcOptions extends LingoOptions { - /** '=' (default): only treat input as an expression when prefixed. */ + /** `'always'` (default for `calc()`). `'='`: only when the input starts with `=`. */ trigger?: '=' | 'always' } @@ -178,6 +176,41 @@ Operand rules, and the issue code when they're violated: `q * q` being refused is load-bearing: it's the line that keeps this a calculator instead of the start of a unit algebra. +`and` is `+` in calc (`half of 56kg and 1700g`). Spaced `-` is subtraction, +not a range. Word operators: `plus` / `minus` / `times` / `x` / `over` / +`divided by` / `multiplied by`. Prefixes: `half of` / `half` / `twice` / +`double` / `triple` / `thrice`. Percent: `10% of`, `10% off`, `10% on`, +`50 kg + 10%`, `50 kg - 10%`. + +### Glued `m` is million (calc only) + +`parseQty({ calcScales: true, noAdditiveJoin: true })` is the operand parser. + +- Glued `m`/`M` at an operator / EOF / `(`/`)` boundary is million unless + `kind` is `length` or `duration`. Warning: `SCALE_ASSUMED`. +- Spaced `7 m` is meters. `1m80` next-token digits → 1.80 m. +- `calc('7m*2')` → 14e6; `calc('7m*2', { kind: 'length' })` → 14 m. +- Word scales (`million`) always apply. Compact `"14m"` round-trips through + `calc()`, not `lingo()`. Compact trillion emits `1e12`, not `t` (tonne). + +`"half of"` wraps `parseAdd`, so `half of 56kg+1700g` is `0.5 × (56 kg + 1700 g)`. +Lone `half` is skipped when the next word is `an`/`a` (`half an hour` stays a +duration quantity). + +### Format + +```ts +type CalcFormatStyle = 'standard' | 'grouped' | 'words' | 'scientific' | 'compact' + +function formatCalc(result: CalcResult, opts?: { style?: CalcFormatStyle; unit?: string; best?: boolean }): string +function formatExpression(node: CalcNode): string // two-way infix, re-parses through calc() +function formatLatex(node: CalcNode): string // display only +``` + +`7m*2` → words `"14 million"`, grouped `"14,000,000"`, scientific `"14e6"`, +compact `"14m"`. `9min x 4` → `"36 min"` or `{ unit: 'h' }` → `"0.6 h"`. +Quantity compact uses scientific (`6e6 kg`), never a glued `m` suffix. + ### Affine arithmetic — resolved, not open The plan previously listed "refuse or delta-convert" as an open question. The @@ -192,12 +225,10 @@ naming convention), carrying the operand span and the delta reading in `data`. This applies to the **existing compound path too**, so it lands even if the go/no-go comes back no. -### Phasing +### Phasing (all shipped D73) -1. **Percent-of family.** `10% off $50`, `$60 + 20% tip`, `15% of 60 kg`, - `$100 + 8.875% tax`. No collision with ranges at all — `%` plus `of`/`off`/ - `on` are unambiguous markers — and it's the arithmetic people actually type - into money forms. Shippable without the general expression grammar. +1. **Percent-of family.** `10% off $50`, `$60 + 20%`, `15% of 60 kg`, + `10% on 50 kg`. 2. **Operators + grouping.** `+ - * /`, parentheses, precedence. 3. **Ratios and word multipliers.** `q / q` → number; `half of 10 L`, `twice 3 kg`, `double`, reusing the existing number-word lexicon. @@ -212,8 +243,8 @@ go/no-go comes back no. `./calc` at runtime. Evaluated results surface as a ranked completion (`= 45 USD`) with the tree attached for the UI to explain, rather than silently committing into the field. -- `./ai`: `quantityField` and `rangeField` accept an injected `calc` evaluator - under the same rule, so `./ai`'s budget doesn't move either: +- `./ai`: `quantityField` accepts an injected `calc` evaluator under the same + rule (`rangeField` stays range-first — dashes mean ranges): ```ts import { calc } from '@pascal-app/lingo/calc' @@ -221,12 +252,11 @@ import { calc } from '@pascal-app/lingo/calc' quantityField({ unit: 'kg', calc }) // accepts "12 * 0.75 kg" -> 9 ``` - When `calc` is injected, the emitted JSON Schema `description` must tell the - model it may submit an expression — a capability the model can't use if it - doesn't know it has it. `CalcResult` carries the evaluated tree so a tool can - log or display the work; the field itself still returns a plain number - (or `QuantityJSON` under `output: 'quantity'`), so the wire shape at the tool - boundary is unchanged. + When `calc` is injected, the emitted JSON Schema `description` tells the + model it may submit an expression. `looksLikeCalc` does not treat `-` as + arithmetic, so `5-10 kg` stays a range. `CalcResult` carries the evaluated + tree so a tool can log the work; the field itself still returns a plain + number (or `QuantityJSON` under `output: 'quantity'`). ### Vocabulary @@ -252,9 +282,10 @@ gains entries for them in the same change, with the *Avoid* list naming 6. `packages/lingo/src/complete/` — `'calc'` completion source, injected. 7. `packages/lingo/src/ai/` — injected `calc` option + schema description. 8. `packages/lingo/scripts/size.mjs` — `./calc` marginal budget. -9. Tests (incl. two-way for every emitted result), corpus rows, `ai-eval.mjs` - category for expression-valued tool arguments, CHANGELOG, README, llms.txt, - `wiki/inspiration.md`, `CONTEXT.md`. +9. Tests (incl. two-way for every emitted result), CHANGELOG, README, llms.txt, + `wiki/inspiration.md`, `CONTEXT.md`. Tool-boundary coverage is + `quantityField({ calc })` in `calc.test.ts` rather than a new `ai-eval.mjs` + category (that recorded corpus's published rates stay intact). ## Non-goals @@ -269,20 +300,13 @@ gains entries for them in the same change, with the *Avoid* list naming ## Open questions -- **Go/no-go on phases 2–3.** Is a general calculator inside a form field a - feature or a trap? Owner call + a D-entry either way. Phase 1 (percent-of) and - the two defect fixes stand on their own and could land first. -- **Corpus classification for the `2+3 kg` fix.** BREAKING by the script's - definition; needs owner acknowledgement, not a reclassification. -- **Does `q / q` → number earn its bytes?** Useful ("how many 2 L bottles in - 10 L"), but it's the only rule that changes result *type*, which complicates - the `./ai` field contract. +None remaining. D73 is the go. `q / q` → number shipped (phase 3). `2+3 kg` +is not an English corpus row; the interpretation change is recorded in D73 +rather than a BREAKING corpus class. ## Acceptance -Decision D-entry exists. The `2+3 kg` and `AFFINE_DELTA_ASSUMED` fixes ship with -corpus coverage regardless of that decision. If go: node union and operator -table locked here, corpus rows added, `./calc` budget assigned in `size.mjs` and -green, two-way tests for every emitted result, and an `ai-eval.mjs` category -showing expression-valued arguments beat naive number-valued ones on silent-wrong -rate. +D73 exists. The `2+3 kg` and `AFFINE_DELTA_ASSUMED` fixes ship with tests. +Node union and operator table locked here. `./calc` budget assigned in +`size.mjs`. Two-way tests cover expression, words, scientific, compact, and +quantity format. `quantityField({ calc })` accepts `"12 * 0.75 kg"`. diff --git a/plans/README.md b/plans/README.md index 66bde1c..aa844e6 100644 --- a/plans/README.md +++ b/plans/README.md @@ -101,7 +101,7 @@ Driver: ` form-associated custom element | | `./react` | `src/react/` | `useLingoInput` hook (`'use client'`) over the DOM controller | @@ -77,7 +78,14 @@ resolver + detector; the packs themselves live in `src/locales/`). TYPO_CORRECTED warning; otherwise UNKNOWN_UNIT with ranked suggestions (bare-value + single unknown word also routes here, not to TRAILING). - **Temperature deltas**: `convertDelta`/`widthIn` use factors only. Range widths - in °C↔°F would be silently wrong through the affine path. + in °C↔°F would be silently wrong through the affine path. Additive compounds + and `calc()` addition still delta-convert, and emit `AFFINE_DELTA_ASSUMED`. +- **Calc vs lingo**: `lingo()` never evaluates expressions. Both operands with + units → compound (unchanged). A bare operand or `*`/`/` → arithmetic, only in + `./calc`. Default `calc()` trigger is `'always'`; mixed fields inject + `{ trigger: '=' }` so `5-10 kg` stays a range. Glued `m`/`M` at an operator + boundary is million unless kind is `length` or `duration` (`SCALE_ASSUMED`); + spaced `7 m` is meters; `1m80` stays 1.80 m because the next token is digits. - **5K guard**: the k/bn suffix multiplier is disabled under kind 'temperature' (5K is kelvin, 70k is 70 000). - **Registry refs are liberal**: `.to('L')`, `convert(1,'gal','L')` resolve diff --git a/wiki/decisions.md b/wiki/decisions.md index f7a627d..71e3d1f 100644 --- a/wiki/decisions.md +++ b/wiki/decisions.md @@ -679,3 +679,67 @@ untouched. Not fixed here, logged in `plans/backlog.md`: `humanizeDateRange` drops seconds (pre-existing, verified against the commit before D71), elliptical right sides (`Aug 3–9`), and quarters (needs a `fiscalYearStart` option to mean anything). + +**D73 · 2026-08-22 · Quantity arithmetic ships as `./calc`, not inside +`lingo()`.** Plan 032 go. The calculator is a closed node union (`number` / +`quantity` / `group` / `percent` / `op`) with a parse-then-evaluate split, +inspired by mathjs expression trees and Pint's explicit affine-delta +semantics, without scopes, functions, or dimensional algebra (D2). The main +entry does not import or re-export it. + +**Why `lingo()` stays range-first.** `5-10 kg` is a range, `2 ft + 3 in` is a +compound, and `2+3 kg` was a silent CJK-adjacent-range false positive. +Arithmetic that mixes a bare operand or uses `*`/`/` lives only in `calc()`. +Both-units addition stays compound in `lingo()`; `calc()` can add those too, +and treats `and` as `+` so `half of 56kg and 1700g` works. Spaced `-` in calc +is subtraction, not a range. + +**`trigger`.** `calc()` defaults to `'always'` — a dedicated calculator should +not require a prefix. Mixed surfaces (`completions()`, `quantityField`) inject +`calc` with `trigger: '='` (the Excel/Sheets mode switch studied in +inspiration.md) so bare dashes stay ranges. Completions further gate on a +leading `=` even if the injector passes `'always'`. `rangeField` does not take +a calc option. + +**Glued `m` is million, only in calc.** The number parser already has `k`/`bn` +suffixes (`70k`, `1.5bn`) but cannot treat glued `m` as million — `5m` is +meters. `parseQty({ calcScales: true })` applies glued `m`/`M` as 1e6 and +`b`/`bn` as 1e9 when the next token is an operator / EOF / `(`/`)`, and the +expected kind is not `length`/`duration` (for `m`) or `data` (for `b`). Spaced +`7 m` is meters; `1m80` has a digit after `m` so it stays 1.80 m; +`calc('7m*2', { kind: 'length' })` is 14 m. The warning is `SCALE_ASSUMED`. +Compact humanize `"14m"` round-trips through `calc()`, not `lingo()` +(`lingo('14m')` is 14 meters). Compact trillion uses scientific (`1e12`) +rather than `t`, which would collide with tonne. + +**Always-ship parser fixes** (they land even without the calculator): + +1. `tryAdjacentCjkRange` now requires `adjacentRange` from the CJK number + walker. `2+3 kg` is `TRAILING_INPUT`; `七八天` stays a range. Not in the + English corpus — an interpretation change, not a corpus BREAKING row. +2. Additive affine compounds emit `AFFINE_DELTA_ASSUMED`. `20°C + 5°C` is still + 25 °C; the warning names the delta reading. The warning does not escalate + under `strictness: 'confirm'` — the math is correct; the issue documents + which conversion path ran. + +**Format.** `formatCalc(result, { style, unit, best })` with `standard` / +`grouped` / `words` / `scientific` / `compact`. `formatExpression` is two-way +infix (`7e6 × 2`). `formatLatex` is display-only. + +**Budgets (D19 honored, not silent).** The calculator itself is ~4 kB marginal +/ ~35 kB standalone. Full and core grow because the CJK gate, affine warning, +issue codes/messages, and `applyCalcScale` must run inside `parseQty` before +unit matching (`7m*2` cannot live only in `./calc`): + +- lingo (full) 39.4 → 39.9 +- `./core` 26.8 → 27.1 +- `./date` standalone 43.8 → 44.4 (inherits shared parser) +- `./date` marginal stays 16.2 +- `./calc` standalone 35.2, marginal 4.1 (new) +- `./react` marginal 1.5 → 1.6 (gzip interaction) +- `./schema` marginal 3.2 → 3.3 (five new issue codes) +- `./ai` marginal 19.1 → 19.4; quantityField-only 1.9 → 2.2 (`looksLikeCalc` + + schema copy; the engine stays injected) + +Revisit if dimensional algebra (D2) is ever reopened — that would not extend +this node union; it would be a different entry. diff --git a/wiki/inspiration.md b/wiki/inspiration.md index 89927cd..772e9c6 100644 --- a/wiki/inspiration.md +++ b/wiki/inspiration.md @@ -22,7 +22,7 @@ license obligation; we do not copy code without noting it here explicitly. | [js-quantities](https://github.com/gentooboontoo/js-quantities) | MIT | Temperature absolute-vs-delta separation (tempC vs degC) — our `convert`/`convertDelta` split. | | [unitmath](https://github.com/ericman314/UnitMath) | Apache-2.0 | Formatting options design; custom unit definition ergonomics. | | mathjs unit system | Apache-2.0 | Prefix handling and parsing grammar cautionary study (powerful but heavyweight — we deliberately stay non-algebraic). | -| [mathjs expression trees](https://mathjs.org/docs/expressions/expression_trees.html) + [expression syntax](https://mathjs.org/docs/expressions/syntax.html) | Apache-2.0 | Typed node AST + parse/evaluate split studied for plan 032 (input calculations); we'd take the tiny node union and canonical `toString`, reject scopes/functions/CAS. Deeper pass 2026-07-29 added two things: its **percentage operators** (`100 + 3%` → `103` as a dedicated operator distinct from modulus) as the model for our phase-1 percent-of family, and its [security page](https://mathjs.org/docs/expressions/security.html) as the cautionary framing — an expression evaluator needs one only because it evaluates arbitrary code, so our closed node union's inability to express a call or a side effect is a guarantee to advertise at the LLM tool boundary, not just an internal constraint. | +| [mathjs expression trees](https://mathjs.org/docs/expressions/expression_trees.html) + [expression syntax](https://mathjs.org/docs/expressions/syntax.html) | Apache-2.0 | Typed node AST + parse/evaluate split studied for plan 032 (input calculations); we'd take the tiny node union and canonical `toString`, reject scopes/functions/CAS. Deeper pass 2026-07-29 added two things: its **percentage operators** (`100 + 3%` → `103` as a dedicated operator distinct from modulus) as the model for our phase-1 percent-of family, and its [security page](https://mathjs.org/docs/expressions/security.html) as the cautionary framing — an expression evaluator needs one only because it evaluates arbitrary code, so our closed node union's inability to express a call or a side effect is a guarantee to advertise at the LLM tool boundary, not just an internal constraint. Shipped 2026-08-22 as `@pascal-app/lingo/calc` (D73). | | [Pint](https://github.com/hgrecco/pint) (Python) | BSD-3-Clause | `delta_degC` / `delta_degF` as prior art for making affine-delta semantics *explicit* rather than warning users off the operation (mathjs's own advice is "avoid calculations using celsius and fahrenheit"). Motivated plan 032's `AFFINE_DELTA_ASSUMED` warning: lingo's compound path already delta-converts correctly, it just never said so. | | ECMA-402 / Intl.NumberFormat unit style | spec | Sanctioned unit identifier list; free locale-aware formatting we lean on instead of shipping CLDR. | From f0d4d20492837e3ceb87bcb932bb8bac90540437 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 05:00:57 +0000 Subject: [PATCH 2/8] fix(calc): keep ordinary quantity coefficients in latex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1700 g was typeset as 1.7 × 10^3 because latex reused the compact scientific threshold meant for million-scale numbers. Docs import for the Calculations snippet and regenerated schema artifacts (five new issue codes) land in the same change so check stays green. Co-authored-by: Aymeric Rabot --- apps/site/public/schema/dictionary.md | 7 ++++++- apps/site/public/schema/lingo.openapi.json | 7 ++++++- apps/site/public/schema/lingo.schema.json | 7 ++++++- apps/site/src/lib/docs.md.ts | 1 + packages/lingo/src/calc/calc.test.ts | 6 ++++++ packages/lingo/src/calc/format.ts | 10 +++++++++- 6 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/site/public/schema/dictionary.md b/apps/site/public/schema/dictionary.md index 8e5adb5..7f639b5 100644 --- a/apps/site/public/schema/dictionary.md +++ b/apps/site/public/schema/dictionary.md @@ -41,7 +41,7 @@ The compact wire JSON `JSON.stringify(lingo(...))` produces. Generated from `error`, `warning`, `info` -## Issue codes (33) +## Issue codes (38) | code | meaning | |---|---| @@ -78,4 +78,9 @@ The compact wire JSON `JSON.stringify(lingo(...))` produces. Generated from | `SLANG_UNIT` | A slang unit spelling was interpreted. | | `TZ_IGNORED` | A time zone was detected but not applied (civil time kept; use applyZone). | | `AMBIGUOUS_TIMEZONE` | A time-zone abbreviation maps to more than one real zone. | +| `AFFINE_DELTA_ASSUMED` | An affine unit was added as a delta, not an absolute temperature. | +| `EXPRESSION_KIND_MISMATCH` | Expression operands are different kinds. | +| `SCALAR_EXPECTED` | Arithmetic needed a plain number, not a second quantity. | +| `DIVISION_BY_ZERO` | The expression divided by zero. | +| `SCALE_ASSUMED` | A glued scale letter was read as million/billion, not a unit. | diff --git a/apps/site/public/schema/lingo.openapi.json b/apps/site/public/schema/lingo.openapi.json index 1be758d..7804f4a 100644 --- a/apps/site/public/schema/lingo.openapi.json +++ b/apps/site/public/schema/lingo.openapi.json @@ -96,7 +96,12 @@ "WEEKDAY_ASSUMED_NEXT", "SLANG_UNIT", "TZ_IGNORED", - "AMBIGUOUS_TIMEZONE" + "AMBIGUOUS_TIMEZONE", + "AFFINE_DELTA_ASSUMED", + "EXPRESSION_KIND_MISMATCH", + "SCALAR_EXPECTED", + "DIVISION_BY_ZERO", + "SCALE_ASSUMED" ], "description": "Stable machine code — switch on this." }, diff --git a/apps/site/public/schema/lingo.schema.json b/apps/site/public/schema/lingo.schema.json index 63db9ea..0b32b5e 100644 --- a/apps/site/public/schema/lingo.schema.json +++ b/apps/site/public/schema/lingo.schema.json @@ -92,7 +92,12 @@ "WEEKDAY_ASSUMED_NEXT", "SLANG_UNIT", "TZ_IGNORED", - "AMBIGUOUS_TIMEZONE" + "AMBIGUOUS_TIMEZONE", + "AFFINE_DELTA_ASSUMED", + "EXPRESSION_KIND_MISMATCH", + "SCALAR_EXPECTED", + "DIVISION_BY_ZERO", + "SCALE_ASSUMED" ], "description": "Stable machine code — switch on this." }, diff --git a/apps/site/src/lib/docs.md.ts b/apps/site/src/lib/docs.md.ts index 753136a..1ea81bb 100644 --- a/apps/site/src/lib/docs.md.ts +++ b/apps/site/src/lib/docs.md.ts @@ -1,5 +1,6 @@ import { aiSnippets, + calcSnippet, completionsSnippet, convertSnippet, currencySnippet, diff --git a/packages/lingo/src/calc/calc.test.ts b/packages/lingo/src/calc/calc.test.ts index b2bee67..6ea54dd 100644 --- a/packages/lingo/src/calc/calc.test.ts +++ b/packages/lingo/src/calc/calc.test.ts @@ -168,6 +168,12 @@ describe('calc()', () => { } }) + it('keeps ordinary quantity coefficients in latex, not scientific', () => { + const r = ok('half of 56kg+1700g') + expect(r.latex).toContain('1700') + expect(r.latex).not.toContain('10^{3}') + }) + it('serializes enumerable toJSON without the node tree', () => { const json = JSON.parse(JSON.stringify(ok('7m*2'))) expect(json.type).toBe('calc') diff --git a/packages/lingo/src/calc/format.ts b/packages/lingo/src/calc/format.ts index 88a925a..3f00a7d 100644 --- a/packages/lingo/src/calc/format.ts +++ b/packages/lingo/src/calc/format.ts @@ -176,7 +176,7 @@ function emitLatexInner(node: CalcNode): string { if (node.type === 'quantity') { const labeled = node.value.format({ style: 'symbol' }) const unit = labeled.replace(/^[^\s]+/, '').trim() - const num = latexNumber(node.value.value) + const num = latexQuantityNumber(node.value.value) return unit ? `${num}\\,\\mathrm{${escapeLatex(unit)}}` : num } if (node.type === 'group') { @@ -209,6 +209,14 @@ function latexNumber(value: number): string { return text } +function latexQuantityNumber(value: number): string { + const abs = Math.abs(value) + if (Number.isFinite(value) && abs >= 0.001 && abs < 1e6) { + return trimNumber(value) + } + return latexNumber(value) +} + function compactScientific(value: number): string { if (value === 0 || !Number.isFinite(value)) { return trimNumber(value) From 1bc1e8190bb2781c809348fcc31ccd69b1a7a0e8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 19:21:55 +0000 Subject: [PATCH 3/8] fix(calc): keep units, operator slash, and NONFINITE honest Adversarial pass: quantityField missed division, compact/latex dropped tight symbols, snapshot restore turned 1e999 into NO_VALUE, and SCALAR_EXPECTED claimed 10 / 2 kg was two quantities. Co-authored-by: Aymeric Rabot --- apps/site/public/llms-small.txt | 2 +- apps/site/src/lib/docs.md.ts | 2 +- packages/lingo/CHANGELOG.md | 9 ++++ packages/lingo/README.md | 5 ++- packages/lingo/llms.txt | 2 +- packages/lingo/src/ai/quantity-fields.ts | 29 ++++++++++-- packages/lingo/src/calc/calc.test.ts | 34 ++++++++++++++ packages/lingo/src/calc/format.ts | 57 ++++++++++++++++++------ packages/lingo/src/calc/parse.ts | 42 ++++++++++------- packages/lingo/src/messages/en.ts | 2 +- plans/032-input-calculations.md | 8 ++-- wiki/architecture.md | 10 +++-- wiki/decisions.md | 12 ++--- 13 files changed, 162 insertions(+), 52 deletions(-) diff --git a/apps/site/public/llms-small.txt b/apps/site/public/llms-small.txt index ad55cbb..379700d 100644 --- a/apps/site/public/llms-small.txt +++ b/apps/site/public/llms-small.txt @@ -131,7 +131,7 @@ calc("12 * 0.75 kg") // 9 kg calc("10% off 50 kg") // 45 kg ``` -Closed calculator: no variables, functions, or dimensional algebra. `lingo()` never evaluates expressions — `2+3 kg` is `TRAILING_INPUT`, `5-10 kg` stays a range. `calc()` default `trigger` is `"always"`; mixed fields inject `{ trigger: "=" }`. Glued `m` at an operator boundary is million unless `kind` is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` is a dimensionless ratio; `q * q` is `SCALAR_EXPECTED`. `formatExpression` is two-way; `formatLatex` is display-only. +Closed calculator: no variables, functions, or dimensional algebra. `lingo()` never evaluates expressions — `2+3 kg` is `TRAILING_INPUT`, `5-10 kg` stays a range. `calc()` default `trigger` is `"always"`. Completions require a leading `=`; `quantityField({ calc })` evaluates `+`/`*`/`/`/`% of` without a prefix (never `-`; glued `5/10 kg` stays a fraction). Glued `m` at an operator boundary is million unless `kind` is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` is a dimensionless ratio; `q * q` is `SCALAR_EXPECTED`. `formatExpression` is two-way; `formatLatex` is display-only. ## DOM (`@pascal-app/lingo/dom`) diff --git a/apps/site/src/lib/docs.md.ts b/apps/site/src/lib/docs.md.ts index 1ea81bb..e2a6dac 100644 --- a/apps/site/src/lib/docs.md.ts +++ b/apps/site/src/lib/docs.md.ts @@ -333,7 +333,7 @@ useForm({ resolver: standardSchemaResolver(shipment) })`, '', 'Glued `m`/`M` at an operator boundary is million (`7m*2` → 14 million) unless `kind` is `length` or `duration`; spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` cancels to a dimensionless ratio; `q * q` is `SCALAR_EXPECTED`. Additive affine units warn `AFFINE_DELTA_ASSUMED` and delta-convert. `formatExpression` is two-way infix; `formatLatex` is display-only.', '', - 'Inject `calc` into `completions({ calc })` and `quantityField({ calc })` so `=2+3 kg` and `12 * 0.75 kg` evaluate without bundling the calculator into those entries. Completions only fire on a leading `=`, so `5-10 kg` is not stolen. `rangeField` stays range-first.', + 'Inject `calc` into `completions({ calc })` and `quantityField({ calc })` so `=2+3 kg`, `12 * 0.75 kg`, and `10 kg / 2` evaluate without bundling the calculator into those entries. Completions only fire on a leading `=`, so `5-10 kg` is not stolen. Glued `5/10 kg` stays a fraction. `rangeField` stays range-first.', '', '## Locales', '', diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md index 5d84ba4..c854aca 100644 --- a/packages/lingo/CHANGELOG.md +++ b/packages/lingo/CHANGELOG.md @@ -31,6 +31,15 @@ change**, even if the API is untouched. - Additive affine compounds (`20°C + 5°C`) still delta-convert, and now warn `AFFINE_DELTA_ASSUMED`. +### Fixed + +- `quantityField({ calc })` accepts division (`10 kg / 2`, `10kg/2`) without + treating glued fractions (`5/10 kg`) as arithmetic. +- Compact/scientific/latex calc output keeps tight unit symbols (`25°C`, + `10%`, `$5`) instead of dropping them. +- `calc('1e999')` reports `NONFINITE` instead of masking it as `NO_VALUE`. +- `SCALAR_EXPECTED` copy no longer claims `10 / 2 kg` is two quantities. + ## [0.4.0] - 2026-08-02 ### Added diff --git a/packages/lingo/README.md b/packages/lingo/README.md index 0c79bc1..bf98087 100644 --- a/packages/lingo/README.md +++ b/packages/lingo/README.md @@ -417,8 +417,9 @@ calc('half of 56kg+1700g') // 28.85 kg Glued `m` at an operator boundary is million unless `kind` is `length` or `duration`; spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` cancels to a -ratio; `q * q` is `SCALAR_EXPECTED`. Inject into mixed fields so only a -leading `=` opts into arithmetic: +ratio; `q * q` is `SCALAR_EXPECTED`. Completions need a leading `=` so +`5-10 kg` stays a range. `quantityField({ calc })` evaluates `12 * 0.75 kg` +and `10 kg / 2` without a prefix (`5/10 kg` stays a fraction): ```ts import { calc } from '@pascal-app/lingo/calc' diff --git a/packages/lingo/llms.txt b/packages/lingo/llms.txt index ad55cbb..379700d 100644 --- a/packages/lingo/llms.txt +++ b/packages/lingo/llms.txt @@ -131,7 +131,7 @@ calc("12 * 0.75 kg") // 9 kg calc("10% off 50 kg") // 45 kg ``` -Closed calculator: no variables, functions, or dimensional algebra. `lingo()` never evaluates expressions — `2+3 kg` is `TRAILING_INPUT`, `5-10 kg` stays a range. `calc()` default `trigger` is `"always"`; mixed fields inject `{ trigger: "=" }`. Glued `m` at an operator boundary is million unless `kind` is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` is a dimensionless ratio; `q * q` is `SCALAR_EXPECTED`. `formatExpression` is two-way; `formatLatex` is display-only. +Closed calculator: no variables, functions, or dimensional algebra. `lingo()` never evaluates expressions — `2+3 kg` is `TRAILING_INPUT`, `5-10 kg` stays a range. `calc()` default `trigger` is `"always"`. Completions require a leading `=`; `quantityField({ calc })` evaluates `+`/`*`/`/`/`% of` without a prefix (never `-`; glued `5/10 kg` stays a fraction). Glued `m` at an operator boundary is million unless `kind` is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` is a dimensionless ratio; `q * q` is `SCALAR_EXPECTED`. `formatExpression` is two-way; `formatLatex` is display-only. ## DOM (`@pascal-app/lingo/dom`) diff --git a/packages/lingo/src/ai/quantity-fields.ts b/packages/lingo/src/ai/quantity-fields.ts index c013025..135ec9c 100644 --- a/packages/lingo/src/ai/quantity-fields.ts +++ b/packages/lingo/src/ai/quantity-fields.ts @@ -29,8 +29,9 @@ export type QuantityFieldOptions = LingoOptions & { description?: string /** * Inject `calc` from `@pascal-app/lingo/calc` so the field accepts - * expressions (`12 * 0.75 kg`, `10% of 60 kg`, `=2+3 kg`). `-` is not a - * calc trigger — `5-10 kg` stays a range. + * expressions (`12 * 0.75 kg`, `10 kg / 2`, `10% of 60 kg`, `=2+3 kg`). + * `-` is not a calc trigger — `5-10 kg` stays a range. Glued `5/10 kg` + * stays a fraction. */ calc?: (input: string, opts?: CalcOptions) => CalcOutcome } @@ -301,7 +302,27 @@ function looksLikeCalc(input: string): boolean { if (/\sx\s/i.test(text)) { return true } - return text.includes('+') + if (text.includes('+')) { + return true + } + // Spaced or unit-adjacent `/` is division. Glued digit/digit (`5/10 kg`) is a fraction. + return hasOperatorSlash(text) +} + +function hasOperatorSlash(text: string): boolean { + for (let i = 0; i < text.length; i++) { + if (text[i] !== '/') { + continue + } + const prev = text[i - 1] + const next = text[i + 1] + const gluedFraction = + prev !== undefined && next !== undefined && /[\d.]/.test(prev) && /[\d.]/.test(next) + if (!gluedFraction) { + return true + } + } + return false } function quantityInput(value: unknown): string | StandardSchemaV1Failure { @@ -454,7 +475,7 @@ function calcHint(opts: Pick): string { if (!opts.calc) { return '' } - return ' Arithmetic expressions are allowed (for example "12 * 0.75 kg", "10% of 60 kg", "half of 56kg+1700g", or "=2+3 kg").' + return ' Arithmetic expressions are allowed (for example "12 * 0.75 kg", "10 kg / 2", "10% of 60 kg", "half of 56kg+1700g", or "=2+3 kg").' } function rangeInputDescription(opts: RangeFieldOptions): string { diff --git a/packages/lingo/src/calc/calc.test.ts b/packages/lingo/src/calc/calc.test.ts index 6ea54dd..41ad3a8 100644 --- a/packages/lingo/src/calc/calc.test.ts +++ b/packages/lingo/src/calc/calc.test.ts @@ -123,9 +123,11 @@ describe('calc()', () => { const mul = calc('5 kg * 2 m') expect(mul.ok).toBe(false) expect(mul.issues[0]?.code).toBe('SCALAR_EXPECTED') + expect(mul.issues[0]?.message).toBe('Cannot multiply: one side must be a plain number.') const div = calc('10 / 2 kg') expect(div.ok).toBe(false) expect(div.issues[0]?.code).toBe('SCALAR_EXPECTED') + expect(div.issues[0]?.message).toBe('Cannot divide: one side must be a plain number.') }) it('rejects mixed-kind addition and division by zero', () => { @@ -157,6 +159,31 @@ describe('calc()', () => { expect(back.quantity?.base).toBeCloseTo(qty.quantity!.base, 12) }) + it('keeps NONFINITE instead of masking it as NO_VALUE', () => { + expect(calc('1e999').issues[0]?.code).toBe('NONFINITE') + expect(calc('1e999 * 2').issues[0]?.code).toBe('NONFINITE') + expect(calc('(1e999)').issues[0]?.code).toBe('NONFINITE') + }) + + it('keeps tight unit symbols in latex and compact format', () => { + const temp = ok('20°C + 5°C') + expect(temp.issues.some((issue) => issue.code === 'AFFINE_DELTA_ASSUMED')).toBe(true) + expect(temp.format()).toBe('25°C') + expect(temp.format({ style: 'compact' })).toBe('25°C') + expect(temp.latex).toContain('°C') + const pct = ok('10%') + expect(pct.format({ style: 'compact' })).toBe('10%') + expect(pct.latex).toBe('10\\%') + const money = ok('10% of $50') + expect(money.format()).toBe('$5.00') + expect(money.format({ style: 'compact' })).toBe('$5') + expect(money.latex).toContain('\\$') + }) + + it('subtracts a trailing percent', () => { + expect(ok('50 kg - 10%').quantity?.base).toBeCloseTo(45, 12) + }) + it('does not throw or yield NaN on hostile input', () => { const nasty = ['', ' ', '/', '((((', '5 / 0', '5 kg * 2 m', 'NaN * 2', '1e999 * 1e999'] for (const input of nasty) { @@ -237,6 +264,13 @@ describe('calc injection', () => { const input = field['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) expect(String(input.description)).toContain('Arithmetic expressions are allowed') }) + + it('lets quantityField divide a quantity without stealing 5/10 kg fractions', () => { + const field = quantityField({ kind: 'mass', unit: 'kg', calc }) + expect(field.parse('10 kg / 2')).toBeCloseTo(5, 12) + expect(field.parse('10kg/2')).toBeCloseTo(5, 12) + expect(field.parse('5/10 kg')).toBeCloseTo(0.5, 12) + }) }) describe('formatCalc helpers', () => { diff --git a/packages/lingo/src/calc/format.ts b/packages/lingo/src/calc/format.ts index 3f00a7d..6cfbb95 100644 --- a/packages/lingo/src/calc/format.ts +++ b/packages/lingo/src/calc/format.ts @@ -1,3 +1,4 @@ +import type { Quantity } from '../core/quantity' import { roundSig } from '../core/round' import type { CalcFormatOptions, CalcFormatStyle, CalcNode, CalcResult } from './types' @@ -28,16 +29,16 @@ export function formatCalc(result: CalcResult, opts: CalcFormatOptions = {}): st : result.quantity : result.quantity.to(opts.unit) const style = opts.style ?? 'standard' - if (style === 'standard' || style === 'words') { - return q.format({ style: style === 'words' ? 'long' : 'symbol' }) + if (style === 'words') { + return q.format({ style: 'long' }) } - const number = formatNumber(q.value, style === 'compact' ? 'scientific' : style) - const labeled = q.format({ style: 'symbol' }) - const unit = labeled.replace(/^[^\s]+/, '').trim() - if (!unit) { - return number + if (style === 'standard') { + return q.format({ style: 'symbol' }) } - return `${number} ${unit}` + if (style === 'grouped') { + return q.format({ style: 'symbol', grouping: true }) + } + return withUnit(q, formatNumber(q.value, 'scientific')) } return formatNumber(result.value, opts.style ?? 'standard') } @@ -174,10 +175,7 @@ function emitLatexInner(node: CalcNode): string { return latexNumber(node.value) } if (node.type === 'quantity') { - const labeled = node.value.format({ style: 'symbol' }) - const unit = labeled.replace(/^[^\s]+/, '').trim() - const num = latexQuantityNumber(node.value.value) - return unit ? `${num}\\,\\mathrm{${escapeLatex(unit)}}` : num + return latexQuantity(node.value) } if (node.type === 'group') { return `\\left(${emitLatex(node.node, false)}\\right)` @@ -217,6 +215,39 @@ function latexQuantityNumber(value: number): string { return latexNumber(value) } +function latexQuantity(q: Quantity): string { + const num = latexQuantityNumber(q.value) + const symbol = q.unitInfo().symbol + if (!symbol) { + return num + } + if (symbol === '%') { + return `${num}\\%` + } + if (q.kind === 'currency') { + return `${escapeLatex(symbol)}${num}` + } + const body = `\\mathrm{${escapeLatex(symbol)}}` + return tightSymbol(symbol) ? `${num}${body}` : `${num}\\,${body}` +} + +function withUnit(q: Quantity, number: string): string { + const symbol = q.unitInfo().symbol + if (!symbol) { + return number + } + if (q.kind === 'currency') { + return `${symbol}${number}` + } + return tightSymbol(symbol) ? `${number}${symbol}` : `${number} ${symbol}` +} + +function tightSymbol(symbol: string): boolean { + return ( + symbol.startsWith('°') || symbol === '′' || symbol === '″' || symbol === '%' || symbol === '‰' + ) +} + function compactScientific(value: number): string { if (value === 0 || !Number.isFinite(value)) { return trimNumber(value) @@ -243,5 +274,5 @@ function needsParen(node: CalcNode): boolean { } function escapeLatex(text: string): string { - return text.replace(/[%#&_]/g, '\\$&') + return text.replace(/[%#&_$]/g, '\\$&') } diff --git a/packages/lingo/src/calc/parse.ts b/packages/lingo/src/calc/parse.ts index 8a81d9c..f8f5f0a 100644 --- a/packages/lingo/src/calc/parse.ts +++ b/packages/lingo/src/calc/parse.ts @@ -1,3 +1,4 @@ +import { hasError } from '../core/errors' import { Quantity, registryOf } from '../core/quantity' import type { Span } from '../core/types' import { consumeCjkPostUnitHalf, prepareCjkValueTokens } from '../number/cjk' @@ -31,8 +32,10 @@ export function parseCalc(p: ParserState): CalcNode | null { } const node = parseExpr() if (!node) { - const start = p.tokens[pos]?.start ?? 0 - issue(p, 'NO_VALUE', { example: exampleFor(p) }, start, p.text.length) + if (!hasError(p.issues)) { + const start = p.tokens[pos]?.start ?? 0 + issue(p, 'NO_VALUE', { example: exampleFor(p) }, start, p.text.length) + } return null } while (symAt(p, pos) === '=') { @@ -52,7 +55,7 @@ export function parseCalc(p: ParserState): CalcNode | null { if (!inner) { return null } - return binary('*', prefix.node, inner) + return binary('*', prefix, inner) } return parseAdd() } @@ -109,6 +112,9 @@ export function parseCalc(p: ParserState): CalcNode | null { if (primary) { return maybePercentOf(primary) } + if (aborted(saved)) { + return null + } restore(saved) const sign = unarySign() if (!sign) { @@ -142,7 +148,7 @@ export function parseCalc(p: ParserState): CalcNode | null { return tryQty() } - function maybePercentOf(left: CalcNode): CalcNode { + function maybePercentOf(left: CalcNode): CalcNode | null { if (!isPercentQty(left)) { return left } @@ -155,6 +161,9 @@ export function parseCalc(p: ParserState): CalcNode | null { pos++ const of = parseUnary() if (!of) { + if (aborted(saved)) { + return null + } restore(saved) return left } @@ -167,7 +176,7 @@ export function parseCalc(p: ParserState): CalcNode | null { } } - function tryPrefix(): { node: CalcNode } | null { + function tryPrefix(): CalcNode | null { const startPos = pos for (const prefix of PREFIXES) { const next = eatPhrase(p, pos, prefix.phrase) @@ -175,8 +184,7 @@ export function parseCalc(p: ParserState): CalcNode | null { continue } pos = next - const span = tokenSpan(startPos, pos) - return { node: { type: 'number', value: prefix.factor, span } } + return { type: 'number', value: prefix.factor, span: tokenSpan(startPos, pos) } } if (wordAt(p, pos) !== 'half') { return null @@ -194,7 +202,7 @@ export function parseCalc(p: ParserState): CalcNode | null { pos++ } const end = p.tokens[pos - 1]!.end - return { node: { type: 'number', value: 0.5, span: toSourceSpan(p.n, start, end) } } + return { type: 'number', value: 0.5, span: toSourceSpan(p.n, start, end) } } function tryQty(): CalcNode | null { @@ -208,23 +216,19 @@ export function parseCalc(p: ParserState): CalcNode | null { const withHalf = applyCjkHalf(p, q) pos = withHalf.nextToken const span = toSourceSpan(p.n, withHalf.normStart, withHalf.normEnd) - if (withHalf.kind && withHalf.headUnit) { - if (!Number.isFinite(withHalf.base)) { + if (!Number.isFinite(withHalf.base)) { + if (!p.issues.some((it) => it.code === 'NONFINITE')) { issue(p, 'NONFINITE', {}, withHalf.normStart, withHalf.normEnd) - restore(saved) - return null } + return null + } + if (withHalf.kind && withHalf.headUnit) { const quantity = new Quantity(p.reg, withHalf.kind, withHalf.base, withHalf.headUnit, { approximate: withHalf.approximate, parts: withHalf.parts.length > 1 ? withHalf.parts : undefined, }) return { type: 'quantity', value: quantity, span } } - if (!Number.isFinite(withHalf.base)) { - issue(p, 'NONFINITE', {}, withHalf.normStart, withHalf.normEnd) - restore(saved) - return null - } return { type: 'number', value: withHalf.base, span } } @@ -313,6 +317,10 @@ export function parseCalc(p: ParserState): CalcNode | null { p.issues.length = saved.issues } + function aborted(saved: { issues: number }): boolean { + return hasError(p.issues.slice(saved.issues)) + } + function tokenSpan(from: number, to: number): Span { const start = p.tokens[from]?.start ?? 0 const end = p.tokens[to - 1]?.end ?? start diff --git a/packages/lingo/src/messages/en.ts b/packages/lingo/src/messages/en.ts index d6c729d..fb32707 100644 --- a/packages/lingo/src/messages/en.ts +++ b/packages/lingo/src/messages/en.ts @@ -50,7 +50,7 @@ export const en: Record = { CONVERSION_NOT_ALLOWED: "Conversions aren't accepted here — enter the value directly.", AFFINE_DELTA_ASSUMED: 'Added {asDelta} as a {unit} change, not an absolute temperature.', EXPRESSION_KIND_MISMATCH: 'Cannot mix {left} and {right} in one expression.', - SCALAR_EXPECTED: 'Cannot {op} two quantities — one side must be a plain number.', + SCALAR_EXPECTED: 'Cannot {op}: one side must be a plain number.', DIVISION_BY_ZERO: 'Cannot divide by zero.', SCALE_ASSUMED: 'Read "{symbol}" as {scale}.', } diff --git a/plans/032-input-calculations.md b/plans/032-input-calculations.md index 110b62f..ed5b598 100644 --- a/plans/032-input-calculations.md +++ b/plans/032-input-calculations.md @@ -254,9 +254,11 @@ quantityField({ unit: 'kg', calc }) // accepts "12 * 0.75 kg" -> 9 When `calc` is injected, the emitted JSON Schema `description` tells the model it may submit an expression. `looksLikeCalc` does not treat `-` as - arithmetic, so `5-10 kg` stays a range. `CalcResult` carries the evaluated - tree so a tool can log the work; the field itself still returns a plain - number (or `QuantityJSON` under `output: 'quantity'`). + arithmetic, so `5-10 kg` stays a range. Spaced or unit-adjacent `/` is + division (`10 kg / 2`, `10kg/2`); glued digit/digit (`5/10 kg`) stays a + fraction. `CalcResult` carries the evaluated tree so a tool can log the work; + the field itself still returns a plain number (or `QuantityJSON` under + `output: 'quantity'`). ### Vocabulary diff --git a/wiki/architecture.md b/wiki/architecture.md index 0835939..b85fbff 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -82,10 +82,12 @@ resolver + detector; the packs themselves live in `src/locales/`). and `calc()` addition still delta-convert, and emit `AFFINE_DELTA_ASSUMED`. - **Calc vs lingo**: `lingo()` never evaluates expressions. Both operands with units → compound (unchanged). A bare operand or `*`/`/` → arithmetic, only in - `./calc`. Default `calc()` trigger is `'always'`; mixed fields inject - `{ trigger: '=' }` so `5-10 kg` stays a range. Glued `m`/`M` at an operator - boundary is million unless kind is `length` or `duration` (`SCALE_ASSUMED`); - spaced `7 m` is meters; `1m80` stays 1.80 m because the next token is digits. + `./calc`. Default `calc()` trigger is `'always'`. Completions still require a + leading `=`. `quantityField({ calc })` uses `looksLikeCalc` (never `-`, so + `5-10 kg` stays a range; glued `5/10 kg` stays a fraction) and then evaluates + with `trigger: 'always'`. Glued `m`/`M` at an operator boundary is million + unless kind is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is + meters; `1m80` stays 1.80 m because the next token is digits. - **5K guard**: the k/bn suffix multiplier is disabled under kind 'temperature' (5K is kelvin, 70k is 70 000). - **Registry refs are liberal**: `.to('L')`, `convert(1,'gal','L')` resolve diff --git a/wiki/decisions.md b/wiki/decisions.md index 71e3d1f..3cbe77b 100644 --- a/wiki/decisions.md +++ b/wiki/decisions.md @@ -695,11 +695,13 @@ and treats `and` as `+` so `half of 56kg and 1700g` works. Spaced `-` in calc is subtraction, not a range. **`trigger`.** `calc()` defaults to `'always'` — a dedicated calculator should -not require a prefix. Mixed surfaces (`completions()`, `quantityField`) inject -`calc` with `trigger: '='` (the Excel/Sheets mode switch studied in -inspiration.md) so bare dashes stay ranges. Completions further gate on a -leading `=` even if the injector passes `'always'`. `rangeField` does not take -a calc option. +not require a prefix. Completions inject `calc` with `trigger: '='` (the +Excel/Sheets mode switch studied in inspiration.md) and also gate on a leading +`=` even if the injector passes `'always'`, so `5-10 kg` stays a range. +`quantityField({ calc })` does not use the prefix: `looksLikeCalc` decides +(never `-`; glued digit/digit `/` is a fraction, not division) and then +evaluates with `trigger: 'always'` so `12 * 0.75 kg` and `10 kg / 2` work +unprefixed. `rangeField` does not take a calc option. **Glued `m` is million, only in calc.** The number parser already has `k`/`bn` suffixes (`70k`, `1.5bn`) but cannot treat glued `m` as million — `5m` is From 5ec838c57c74e9033908b8b77928432688b8ed26 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 19:26:13 +0000 Subject: [PATCH 4/8] fix(calc): golf helpers back under the size budget Keep unit/latex and operator-slash behavior; drop the extra gzip that tipped ./calc, ./ai, and quantityField-only over their D73 lines. Co-authored-by: Aymeric Rabot --- packages/lingo/src/ai/quantity-fields.ts | 20 +-------- packages/lingo/src/calc/format.ts | 53 +++++++----------------- packages/lingo/src/calc/parse.ts | 7 +--- 3 files changed, 18 insertions(+), 62 deletions(-) diff --git a/packages/lingo/src/ai/quantity-fields.ts b/packages/lingo/src/ai/quantity-fields.ts index 135ec9c..253277f 100644 --- a/packages/lingo/src/ai/quantity-fields.ts +++ b/packages/lingo/src/ai/quantity-fields.ts @@ -305,24 +305,8 @@ function looksLikeCalc(input: string): boolean { if (text.includes('+')) { return true } - // Spaced or unit-adjacent `/` is division. Glued digit/digit (`5/10 kg`) is a fraction. - return hasOperatorSlash(text) -} - -function hasOperatorSlash(text: string): boolean { - for (let i = 0; i < text.length; i++) { - if (text[i] !== '/') { - continue - } - const prev = text[i - 1] - const next = text[i + 1] - const gluedFraction = - prev !== undefined && next !== undefined && /[\d.]/.test(prev) && /[\d.]/.test(next) - if (!gluedFraction) { - return true - } - } - return false + // Glued digit/digit (`5/10 kg`) is a fraction, not division. + return /[^\d.\s]\/|\/[^\d.\s]|\/\s|\s\//.test(text) } function quantityInput(value: unknown): string | StandardSchemaV1Failure { diff --git a/packages/lingo/src/calc/format.ts b/packages/lingo/src/calc/format.ts index 6cfbb95..69c6fae 100644 --- a/packages/lingo/src/calc/format.ts +++ b/packages/lingo/src/calc/format.ts @@ -1,4 +1,3 @@ -import type { Quantity } from '../core/quantity' import { roundSig } from '../core/round' import type { CalcFormatOptions, CalcFormatStyle, CalcNode, CalcResult } from './types' @@ -29,16 +28,10 @@ export function formatCalc(result: CalcResult, opts: CalcFormatOptions = {}): st : result.quantity : result.quantity.to(opts.unit) const style = opts.style ?? 'standard' - if (style === 'words') { - return q.format({ style: 'long' }) + if (style === 'standard' || style === 'words') { + return q.format({ style: style === 'words' ? 'long' : 'symbol' }) } - if (style === 'standard') { - return q.format({ style: 'symbol' }) - } - if (style === 'grouped') { - return q.format({ style: 'symbol', grouping: true }) - } - return withUnit(q, formatNumber(q.value, 'scientific')) + return attachUnit(q, formatNumber(q.value, style === 'compact' ? 'scientific' : style)) } return formatNumber(result.value, opts.style ?? 'standard') } @@ -175,7 +168,7 @@ function emitLatexInner(node: CalcNode): string { return latexNumber(node.value) } if (node.type === 'quantity') { - return latexQuantity(node.value) + return attachUnit(node.value, latexQuantityNumber(node.value.value), true) } if (node.type === 'group') { return `\\left(${emitLatex(node.node, false)}\\right)` @@ -215,37 +208,19 @@ function latexQuantityNumber(value: number): string { return latexNumber(value) } -function latexQuantity(q: Quantity): string { - const num = latexQuantityNumber(q.value) - const symbol = q.unitInfo().symbol - if (!symbol) { - return num - } - if (symbol === '%') { - return `${num}\\%` +function attachUnit(q: NonNullable, n: string, tex?: boolean): string { + const s = q.unitInfo().symbol + if (!s) { + return n } if (q.kind === 'currency') { - return `${escapeLatex(symbol)}${num}` + return `${tex ? s.replace('$', '\\$') : s}${n}` } - const body = `\\mathrm{${escapeLatex(symbol)}}` - return tightSymbol(symbol) ? `${num}${body}` : `${num}\\,${body}` -} - -function withUnit(q: Quantity, number: string): string { - const symbol = q.unitInfo().symbol - if (!symbol) { - return number + if (s === '%' && tex) { + return `${n}\\%` } - if (q.kind === 'currency') { - return `${symbol}${number}` - } - return tightSymbol(symbol) ? `${number}${symbol}` : `${number} ${symbol}` -} - -function tightSymbol(symbol: string): boolean { - return ( - symbol.startsWith('°') || symbol === '′' || symbol === '″' || symbol === '%' || symbol === '‰' - ) + const u = tex ? `\\mathrm{${escapeLatex(s)}}` : s + return '°%‰′″'.includes(s[0]!) ? `${n}${u}` : tex ? `${n}\\,${u}` : `${n} ${u}` } function compactScientific(value: number): string { @@ -274,5 +249,5 @@ function needsParen(node: CalcNode): boolean { } function escapeLatex(text: string): string { - return text.replace(/[%#&_$]/g, '\\$&') + return text.replace(/[%#&_]/g, '\\$&') } diff --git a/packages/lingo/src/calc/parse.ts b/packages/lingo/src/calc/parse.ts index f8f5f0a..7a422aa 100644 --- a/packages/lingo/src/calc/parse.ts +++ b/packages/lingo/src/calc/parse.ts @@ -33,8 +33,7 @@ export function parseCalc(p: ParserState): CalcNode | null { const node = parseExpr() if (!node) { if (!hasError(p.issues)) { - const start = p.tokens[pos]?.start ?? 0 - issue(p, 'NO_VALUE', { example: exampleFor(p) }, start, p.text.length) + issue(p, 'NO_VALUE', { example: exampleFor(p) }, p.tokens[pos]?.start ?? 0, p.text.length) } return null } @@ -217,9 +216,7 @@ export function parseCalc(p: ParserState): CalcNode | null { pos = withHalf.nextToken const span = toSourceSpan(p.n, withHalf.normStart, withHalf.normEnd) if (!Number.isFinite(withHalf.base)) { - if (!p.issues.some((it) => it.code === 'NONFINITE')) { - issue(p, 'NONFINITE', {}, withHalf.normStart, withHalf.normEnd) - } + issue(p, 'NONFINITE', {}, withHalf.normStart, withHalf.normEnd) return null } if (withHalf.kind && withHalf.headUnit) { From 335d0bcca79b59817e850246b47e90ee2a97e188 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 19:29:08 +0000 Subject: [PATCH 5/8] fix(calc): keep a single NONFINITE on overflow Reuse the number parser's issue instead of emitting a second copy. Co-authored-by: Aymeric Rabot --- packages/lingo/src/calc/calc.test.ts | 2 +- packages/lingo/src/calc/parse.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/lingo/src/calc/calc.test.ts b/packages/lingo/src/calc/calc.test.ts index 41ad3a8..33be712 100644 --- a/packages/lingo/src/calc/calc.test.ts +++ b/packages/lingo/src/calc/calc.test.ts @@ -160,7 +160,7 @@ describe('calc()', () => { }) it('keeps NONFINITE instead of masking it as NO_VALUE', () => { - expect(calc('1e999').issues[0]?.code).toBe('NONFINITE') + expect(calc('1e999').issues.map((issue) => issue.code)).toEqual(['NONFINITE']) expect(calc('1e999 * 2').issues[0]?.code).toBe('NONFINITE') expect(calc('(1e999)').issues[0]?.code).toBe('NONFINITE') }) diff --git a/packages/lingo/src/calc/parse.ts b/packages/lingo/src/calc/parse.ts index 7a422aa..13e17a2 100644 --- a/packages/lingo/src/calc/parse.ts +++ b/packages/lingo/src/calc/parse.ts @@ -216,7 +216,6 @@ export function parseCalc(p: ParserState): CalcNode | null { pos = withHalf.nextToken const span = toSourceSpan(p.n, withHalf.normStart, withHalf.normEnd) if (!Number.isFinite(withHalf.base)) { - issue(p, 'NONFINITE', {}, withHalf.normStart, withHalf.normEnd) return null } if (withHalf.kind && withHalf.headUnit) { From d486529123f3aae6483a4e369c3b25e9c4f7e231 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 19:40:48 +0000 Subject: [PATCH 6/8] fix(calc): refuse silent cross-currency math and keep ratios dimensionless MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-currency +/−/÷ reported RATE_REQUIRED instead of adding factors of 1 or relabeling the conversion throw as NONFINITE. Same-kind q/q stays a number even when kind/unit are implied, and quantityField does not stuff that ratio into the field unit. Co-authored-by: Aymeric Rabot --- apps/site/public/llms-small.txt | 2 +- packages/lingo/CHANGELOG.md | 6 ++ packages/lingo/README.md | 3 +- packages/lingo/llms.txt | 2 +- packages/lingo/src/ai/quantity-fields.ts | 22 ++++++- packages/lingo/src/calc/calc.test.ts | 76 +++++++++++++++++++++++- packages/lingo/src/calc/eval.ts | 25 +++++++- packages/lingo/src/calc/index.ts | 21 +++---- plans/032-input-calculations.md | 12 +++- wiki/architecture.md | 4 +- 10 files changed, 147 insertions(+), 26 deletions(-) diff --git a/apps/site/public/llms-small.txt b/apps/site/public/llms-small.txt index 379700d..5ae9c1f 100644 --- a/apps/site/public/llms-small.txt +++ b/apps/site/public/llms-small.txt @@ -131,7 +131,7 @@ calc("12 * 0.75 kg") // 9 kg calc("10% off 50 kg") // 45 kg ``` -Closed calculator: no variables, functions, or dimensional algebra. `lingo()` never evaluates expressions — `2+3 kg` is `TRAILING_INPUT`, `5-10 kg` stays a range. `calc()` default `trigger` is `"always"`. Completions require a leading `=`; `quantityField({ calc })` evaluates `+`/`*`/`/`/`% of` without a prefix (never `-`; glued `5/10 kg` stays a fraction). Glued `m` at an operator boundary is million unless `kind` is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` is a dimensionless ratio; `q * q` is `SCALAR_EXPECTED`. `formatExpression` is two-way; `formatLatex` is display-only. +Closed calculator: no variables, functions, or dimensional algebra. `lingo()` never evaluates expressions — `2+3 kg` is `TRAILING_INPUT`, `5-10 kg` stays a range. `calc()` default `trigger` is `"always"`. Completions require a leading `=`; `quantityField({ calc })` evaluates `+`/`*`/`/`/`% of` without a prefix (never `-`; glued `5/10 kg` stays a fraction). Glued `m` at an operator boundary is million unless `kind` is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` is a dimensionless ratio (not re-unitized by implied `kind`/`unit`); cross-currency `+`/`-`/`/` is `RATE_REQUIRED`; `q * q` is `SCALAR_EXPECTED`. `formatExpression` is two-way; `formatLatex` is display-only. ## DOM (`@pascal-app/lingo/dom`) diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md index c854aca..0b89444 100644 --- a/packages/lingo/CHANGELOG.md +++ b/packages/lingo/CHANGELOG.md @@ -39,6 +39,12 @@ change**, even if the API is untouched. `10%`, `$5`) instead of dropping them. - `calc('1e999')` reports `NONFINITE` instead of masking it as `NO_VALUE`. - `SCALAR_EXPECTED` copy no longer claims `10 / 2 kg` is two quantities. +- Cross-currency `+`/`-`/`/` in `calc()` reports `RATE_REQUIRED` instead of + treating every currency factor as 1, or masking the conversion throw as + `NONFINITE`. +- Same-kind `q / q` stays a dimensionless ratio when `kind`/`unit` are + implied (`10 L / 2 L` is 5, not 5 kg). `quantityField({ calc })` refuses + to stuff that ratio into the field unit. ## [0.4.0] - 2026-08-02 diff --git a/packages/lingo/README.md b/packages/lingo/README.md index bf98087..fa73ca7 100644 --- a/packages/lingo/README.md +++ b/packages/lingo/README.md @@ -417,7 +417,8 @@ calc('half of 56kg+1700g') // 28.85 kg Glued `m` at an operator boundary is million unless `kind` is `length` or `duration`; spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` cancels to a -ratio; `q * q` is `SCALAR_EXPECTED`. Completions need a leading `=` so +ratio (not re-unitized by implied `kind`/`unit`); cross-currency `+`/`-`/`/` +is `RATE_REQUIRED`; `q * q` is `SCALAR_EXPECTED`. Completions need a leading `=` so `5-10 kg` stays a range. `quantityField({ calc })` evaluates `12 * 0.75 kg` and `10 kg / 2` without a prefix (`5/10 kg` stays a fraction): diff --git a/packages/lingo/llms.txt b/packages/lingo/llms.txt index 379700d..5ae9c1f 100644 --- a/packages/lingo/llms.txt +++ b/packages/lingo/llms.txt @@ -131,7 +131,7 @@ calc("12 * 0.75 kg") // 9 kg calc("10% off 50 kg") // 45 kg ``` -Closed calculator: no variables, functions, or dimensional algebra. `lingo()` never evaluates expressions — `2+3 kg` is `TRAILING_INPUT`, `5-10 kg` stays a range. `calc()` default `trigger` is `"always"`. Completions require a leading `=`; `quantityField({ calc })` evaluates `+`/`*`/`/`/`% of` without a prefix (never `-`; glued `5/10 kg` stays a fraction). Glued `m` at an operator boundary is million unless `kind` is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` is a dimensionless ratio; `q * q` is `SCALAR_EXPECTED`. `formatExpression` is two-way; `formatLatex` is display-only. +Closed calculator: no variables, functions, or dimensional algebra. `lingo()` never evaluates expressions — `2+3 kg` is `TRAILING_INPUT`, `5-10 kg` stays a range. `calc()` default `trigger` is `"always"`. Completions require a leading `=`; `quantityField({ calc })` evaluates `+`/`*`/`/`/`% of` without a prefix (never `-`; glued `5/10 kg` stays a fraction). Glued `m` at an operator boundary is million unless `kind` is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` is a dimensionless ratio (not re-unitized by implied `kind`/`unit`); cross-currency `+`/`-`/`/` is `RATE_REQUIRED`; `q * q` is `SCALAR_EXPECTED`. `formatExpression` is two-way; `formatLatex` is display-only. ## DOM (`@pascal-app/lingo/dom`) diff --git a/packages/lingo/src/ai/quantity-fields.ts b/packages/lingo/src/ai/quantity-fields.ts index 253277f..588b51a 100644 --- a/packages/lingo/src/ai/quantity-fields.ts +++ b/packages/lingo/src/ai/quantity-fields.ts @@ -124,10 +124,26 @@ export function quantityField(opts: QuantityFieldOptions): LingoField { expect(zero.issues[0]?.code).toBe('DIVISION_BY_ZERO') }) + it('refuses cross-currency arithmetic without rates', () => { + const add = calc('10 usd + 5 eur') + expect(add.ok).toBe(false) + expect(add.issues[0]).toMatchObject({ + code: 'RATE_REQUIRED', + data: { from: 'USD', to: 'EUR' }, + }) + const sub = calc('10 gbp - 5 usd') + expect(sub.ok).toBe(false) + expect(sub.issues[0]?.code).toBe('RATE_REQUIRED') + const div = calc('10 usd / 5 eur') + expect(div.ok).toBe(false) + expect(div.issues[0]?.code).toBe('RATE_REQUIRED') + const glued = calc('$10 / €5') + expect(glued.ok).toBe(false) + expect(glued.issues.some((issue) => issue.code === 'RATE_REQUIRED')).toBe(true) + expect(glued.issues.some((issue) => issue.code === 'NONFINITE')).toBe(false) + const same = ok('10 usd + 5 usd') + expect(same.value).toBeCloseTo(15, 12) + expect(same.quantity?.unit).toBe('USD') + expect(ok('10 usd / 5 usd').value).toBeCloseTo(2, 12) + }) + + it('does not re-unit a canceled ratio', () => { + const r = ok('10 L / 2 L', { kind: 'mass', unit: 'kg' }) + expect(r.value).toBeCloseTo(5, 12) + expect(r.quantity).toBeUndefined() + expect(r.issues.some((issue) => issue.code === 'UNIT_ASSUMED')).toBe(false) + const assumed = ok('2+3', { kind: 'mass', unit: 'kg' }) + expect(assumed.quantity?.kind).toBe('mass') + expect(assumed.value).toBeCloseTo(5, 12) + expect(assumed.issues.some((issue) => issue.code === 'UNIT_ASSUMED')).toBe(true) + }) + + it('keeps a unit on compound scientific format', () => { + const r = ok('5ft 11in') + expect(r.format({ style: 'scientific' })).toMatch(/ft/) + expect(ok(r.format({ style: 'scientific' })).quantity?.kind).toBe('length') + }) + it('only evaluates when prefixed if trigger is =', () => { expect(calc('2+3 kg', { trigger: '=' }).ok).toBe(false) const r = ok('=2+3 kg', { trigger: '=' }) @@ -185,7 +225,18 @@ describe('calc()', () => { }) it('does not throw or yield NaN on hostile input', () => { - const nasty = ['', ' ', '/', '((((', '5 / 0', '5 kg * 2 m', 'NaN * 2', '1e999 * 1e999'] + const nasty = [ + '', + ' ', + '/', + '((((', + '5 / 0', + '5 kg * 2 m', + 'NaN * 2', + '1e999 * 1e999', + '$10 / €5', + '10 usd + 5 eur', + ] for (const input of nasty) { const r = calc(input) expect(r.ok || r.issues.length > 0, input).toBe(true) @@ -271,6 +322,29 @@ describe('calc injection', () => { expect(field.parse('10kg/2')).toBeCloseTo(5, 12) expect(field.parse('5/10 kg')).toBeCloseTo(0.5, 12) }) + + it('surfaces RATE_REQUIRED from quantityField on cross-currency calc', () => { + const field = quantityField({ kind: 'currency', unit: 'USD', calc }) + const result = field.safeParse('10 usd + 5 eur') + if ('value' in result) { + throw new Error('expected RATE_REQUIRED failure') + } + expect(result.issues[0]).toMatchObject({ + code: 'RATE_REQUIRED', + data: { from: 'USD', to: 'EUR' }, + }) + expect(field.parse('10 usd + 5 usd')).toBeCloseTo(15, 12) + }) + + it('does not stuff a canceled ratio into the field unit', () => { + const field = quantityField({ kind: 'mass', unit: 'kg', calc }) + const result = field.safeParse('10 L / 2 L') + if ('value' in result) { + throw new Error('expected KIND_MISMATCH failure') + } + expect(result.issues[0]?.code).toBe('KIND_MISMATCH') + expect(field.parse('2+3')).toBeCloseTo(5, 12) + }) }) describe('formatCalc helpers', () => { diff --git a/packages/lingo/src/calc/eval.ts b/packages/lingo/src/calc/eval.ts index 74dc935..712f73a 100644 --- a/packages/lingo/src/calc/eval.ts +++ b/packages/lingo/src/calc/eval.ts @@ -8,6 +8,8 @@ import type { CalcNode } from './types' export interface EvalValue { kind: Kind | null quantity: Quantity | null + /** True when same-kind `q / q` canceled to a dimensionless ratio. */ + ratio?: boolean span: Span unit: string | null value: number @@ -76,6 +78,9 @@ function evalAdd( report(p, 'EXPRESSION_KIND_MISMATCH', { left: left.kind, right: right.kind }, span) return null } + if (rateMismatch(p, left, right, span)) { + return null + } if (!(left.kind || right.kind)) { return finite( p, @@ -145,13 +150,20 @@ function evalDiv(p: ParserState, left: EvalValue, right: EvalValue, span: Span): ) return null } + if (rateMismatch(p, left, right, span)) { + return null + } const common = left.quantity.valueIn(left.unit!) const other = right.quantity.valueIn(left.unit!) if (other === 0) { report(p, 'DIVISION_BY_ZERO', {}, span) return null } - return finite(p, { kind: null, unit: null, value: common / other, quantity: null, span }, span) + return finite( + p, + { kind: null, unit: null, value: common / other, quantity: null, span, ratio: true }, + span, + ) } if (right.quantity && !left.quantity) { report(p, 'SCALAR_EXPECTED', { op: 'divide' }, span) @@ -200,6 +212,17 @@ function rightAffine(p: ParserState, right: EvalValue, kind: Kind): boolean { return Boolean(p.reg.unit(kind, right.unit)?.offset) } +function rateMismatch(p: ParserState, left: EvalValue, right: EvalValue, span: Span): boolean { + if (!(left.unit && right.unit && left.unit !== right.unit)) { + return false + } + if (!p.reg.kind(left.kind ?? right.kind)?.rateBased) { + return false + } + report(p, 'RATE_REQUIRED', { from: left.unit, to: right.unit }, span) + return true +} + function finite(p: ParserState, value: EvalValue, span: Span): EvalValue | null { if (!Number.isFinite(value.value)) { report(p, 'NONFINITE', {}, span) diff --git a/packages/lingo/src/calc/index.ts b/packages/lingo/src/calc/index.ts index a39711c..e58a39c 100644 --- a/packages/lingo/src/calc/index.ts +++ b/packages/lingo/src/calc/index.ts @@ -9,7 +9,7 @@ import { toBase } from '../core/convert' import { hasError, makeIssue, setDefaultMessages } from '../core/errors' import { Quantity } from '../core/quantity' import { createRegistry } from '../core/registry' -import type { Kind, LingoIssue, Span } from '../core/types' +import type { LingoIssue, Span } from '../core/types' import { registerTemperatureVocabs } from '../fuzzy/temperature' import { en } from '../messages/en' import { @@ -23,8 +23,7 @@ import { } from '../parse/config' import { resolveImplied } from '../parse/quantity' import { allKinds, byteishFallbacks } from '../units/index' -import type { EvalValue } from './eval' -import { evaluate } from './eval' +import { type EvalValue, evaluate } from './eval' import { formatCalc, formatExpression, formatLatex } from './format' import { parseCalc } from './parse' import type { @@ -90,13 +89,7 @@ export function calc(input: string, opts?: CalcOptions): CalcOutcome { if (!node) { return attachJson(fail(p)) } - let value: EvalValue | null - try { - value = evaluate(p, node) - } catch { - p.issues.push(makeIssue('NONFINITE', {}, node.span, p.opts.messages)) - return attachJson(fail(p)) - } + const value = evaluate(p, node) if (!value) { return attachJson(fail(p)) } @@ -135,13 +128,13 @@ export function calc(input: string, opts?: CalcOptions): CalcOutcome { return attachJson(result) } -function finishQuantity( - p: ParserState, - value: { kind: Kind | null; quantity: Quantity | null; value: number }, -): Quantity | undefined { +function finishQuantity(p: ParserState, value: EvalValue): Quantity | undefined { if (value.quantity) { return value.quantity } + if (value.ratio) { + return + } const implied = resolveImplied(p) if (!implied) { return diff --git a/plans/032-input-calculations.md b/plans/032-input-calculations.md index ed5b598..e95d075 100644 --- a/plans/032-input-calculations.md +++ b/plans/032-input-calculations.md @@ -165,16 +165,22 @@ Operand rules, and the issue code when they're violated: | Form | Result | Rule | |---|---|---| -| `q + q`, `q - q` | quantity | Same kind only, else `EXPRESSION_KIND_MISMATCH` | +| `q + q`, `q - q` | quantity | Same kind only, else `EXPRESSION_KIND_MISMATCH`. Rate-based kinds with different units → `RATE_REQUIRED` | | `n + q`, `q + n` | quantity | Bare operand inherits the other side's unit | | `q * n`, `n * q` | quantity | Exactly one operand may be a quantity, else `SCALAR_EXPECTED` | | `q / n` | quantity | Divisor must be scalar | -| `q / q` | number | Same-kind division cancels to a dimensionless ratio (phase 3) | +| `q / q` | number | Same-kind division cancels to a dimensionless ratio (phase 3). Rate-based different units → `RATE_REQUIRED`. Implied `kind`/`unit` is not re-attached | | `q * q` | rejected | `SCALAR_EXPECTED` — this is dimensional algebra (D2) | | `x / 0` | rejected | `DIVISION_BY_ZERO` | `q * q` being refused is load-bearing: it's the line that keeps this a -calculator instead of the start of a unit algebra. +calculator instead of the start of a unit algebra. Rate-based kinds +(currency) cannot `+`/`-`/`/` across units: same `RATE_REQUIRED` guard as +ranges. Same-currency `10 usd + 5 usd` still adds. A canceled `q / q` stays +a number even when `kind`/`unit` are implied — `calc('10 L / 2 L', { kind: +'mass', unit: 'kg' })` is 5, not 5 kg. Bare `2+3` with those options still +assumes kg. `quantityField({ calc })` fails `KIND_MISMATCH` rather than +stuffing that ratio into the field unit. `and` is `+` in calc (`half of 56kg and 1700g`). Spaced `-` is subtraction, not a range. Word operators: `plus` / `minus` / `times` / `x` / `over` / diff --git a/wiki/architecture.md b/wiki/architecture.md index b85fbff..b3ec5db 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -87,7 +87,9 @@ resolver + detector; the packs themselves live in `src/locales/`). `5-10 kg` stays a range; glued `5/10 kg` stays a fraction) and then evaluates with `trigger: 'always'`. Glued `m`/`M` at an operator boundary is million unless kind is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is - meters; `1m80` stays 1.80 m because the next token is digits. + meters; `1m80` stays 1.80 m because the next token is digits. Cross-currency + operands (`10 usd + 5 eur`) are `RATE_REQUIRED`, same as ranges. Canceled + `q / q` stays a number even when `kind`/`unit` are implied. - **5K guard**: the k/bn suffix multiplier is disabled under kind 'temperature' (5K is kelvin, 70k is 70 000). - **Registry refs are liberal**: `.to('L')`, `convert(1,'gal','L')` resolve From 84d5cf34e23dc323e57fb719c783f6a6ac43b3d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 19:48:57 +0000 Subject: [PATCH 7/8] fix(calc): golf RATE_REQUIRED under the size budget Inline the cross-currency guard and drop the implied-unit ratio skip that tipped ./calc over 4.1 kB. Park the canceled q/q re-unit holdover in the backlog. Co-authored-by: Aymeric Rabot --- apps/site/public/llms-small.txt | 2 +- packages/lingo/CHANGELOG.md | 5 +-- packages/lingo/README.md | 4 +-- packages/lingo/llms.txt | 2 +- packages/lingo/src/ai/quantity-fields.ts | 22 ++---------- packages/lingo/src/calc/calc.test.ts | 23 ++----------- packages/lingo/src/calc/eval.ts | 44 +++++++----------------- packages/lingo/src/calc/index.ts | 3 -- plans/032-input-calculations.md | 10 ++---- plans/backlog.md | 5 +++ wiki/architecture.md | 3 +- 11 files changed, 31 insertions(+), 92 deletions(-) diff --git a/apps/site/public/llms-small.txt b/apps/site/public/llms-small.txt index 5ae9c1f..8c70ff9 100644 --- a/apps/site/public/llms-small.txt +++ b/apps/site/public/llms-small.txt @@ -131,7 +131,7 @@ calc("12 * 0.75 kg") // 9 kg calc("10% off 50 kg") // 45 kg ``` -Closed calculator: no variables, functions, or dimensional algebra. `lingo()` never evaluates expressions — `2+3 kg` is `TRAILING_INPUT`, `5-10 kg` stays a range. `calc()` default `trigger` is `"always"`. Completions require a leading `=`; `quantityField({ calc })` evaluates `+`/`*`/`/`/`% of` without a prefix (never `-`; glued `5/10 kg` stays a fraction). Glued `m` at an operator boundary is million unless `kind` is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` is a dimensionless ratio (not re-unitized by implied `kind`/`unit`); cross-currency `+`/`-`/`/` is `RATE_REQUIRED`; `q * q` is `SCALAR_EXPECTED`. `formatExpression` is two-way; `formatLatex` is display-only. +Closed calculator: no variables, functions, or dimensional algebra. `lingo()` never evaluates expressions — `2+3 kg` is `TRAILING_INPUT`, `5-10 kg` stays a range. `calc()` default `trigger` is `"always"`. Completions require a leading `=`; `quantityField({ calc })` evaluates `+`/`*`/`/`/`% of` without a prefix (never `-`; glued `5/10 kg` stays a fraction). Glued `m` at an operator boundary is million unless `kind` is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` is a dimensionless ratio; cross-currency arithmetic is `RATE_REQUIRED`; `q * q` is `SCALAR_EXPECTED`. `formatExpression` is two-way; `formatLatex` is display-only. ## DOM (`@pascal-app/lingo/dom`) diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md index 0b89444..a4c5245 100644 --- a/packages/lingo/CHANGELOG.md +++ b/packages/lingo/CHANGELOG.md @@ -39,12 +39,9 @@ change**, even if the API is untouched. `10%`, `$5`) instead of dropping them. - `calc('1e999')` reports `NONFINITE` instead of masking it as `NO_VALUE`. - `SCALAR_EXPECTED` copy no longer claims `10 / 2 kg` is two quantities. -- Cross-currency `+`/`-`/`/` in `calc()` reports `RATE_REQUIRED` instead of +- Cross-currency arithmetic in `calc()` reports `RATE_REQUIRED` instead of treating every currency factor as 1, or masking the conversion throw as `NONFINITE`. -- Same-kind `q / q` stays a dimensionless ratio when `kind`/`unit` are - implied (`10 L / 2 L` is 5, not 5 kg). `quantityField({ calc })` refuses - to stuff that ratio into the field unit. ## [0.4.0] - 2026-08-02 diff --git a/packages/lingo/README.md b/packages/lingo/README.md index fa73ca7..f11c3b9 100644 --- a/packages/lingo/README.md +++ b/packages/lingo/README.md @@ -417,8 +417,8 @@ calc('half of 56kg+1700g') // 28.85 kg Glued `m` at an operator boundary is million unless `kind` is `length` or `duration`; spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` cancels to a -ratio (not re-unitized by implied `kind`/`unit`); cross-currency `+`/`-`/`/` -is `RATE_REQUIRED`; `q * q` is `SCALAR_EXPECTED`. Completions need a leading `=` so +ratio; cross-currency arithmetic is `RATE_REQUIRED`; `q * q` is +`SCALAR_EXPECTED`. Completions need a leading `=` so `5-10 kg` stays a range. `quantityField({ calc })` evaluates `12 * 0.75 kg` and `10 kg / 2` without a prefix (`5/10 kg` stays a fraction): diff --git a/packages/lingo/llms.txt b/packages/lingo/llms.txt index 5ae9c1f..8c70ff9 100644 --- a/packages/lingo/llms.txt +++ b/packages/lingo/llms.txt @@ -131,7 +131,7 @@ calc("12 * 0.75 kg") // 9 kg calc("10% off 50 kg") // 45 kg ``` -Closed calculator: no variables, functions, or dimensional algebra. `lingo()` never evaluates expressions — `2+3 kg` is `TRAILING_INPUT`, `5-10 kg` stays a range. `calc()` default `trigger` is `"always"`. Completions require a leading `=`; `quantityField({ calc })` evaluates `+`/`*`/`/`/`% of` without a prefix (never `-`; glued `5/10 kg` stays a fraction). Glued `m` at an operator boundary is million unless `kind` is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` is a dimensionless ratio (not re-unitized by implied `kind`/`unit`); cross-currency `+`/`-`/`/` is `RATE_REQUIRED`; `q * q` is `SCALAR_EXPECTED`. `formatExpression` is two-way; `formatLatex` is display-only. +Closed calculator: no variables, functions, or dimensional algebra. `lingo()` never evaluates expressions — `2+3 kg` is `TRAILING_INPUT`, `5-10 kg` stays a range. `calc()` default `trigger` is `"always"`. Completions require a leading `=`; `quantityField({ calc })` evaluates `+`/`*`/`/`/`% of` without a prefix (never `-`; glued `5/10 kg` stays a fraction). Glued `m` at an operator boundary is million unless `kind` is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is meters; `1m80` stays 1.80 m. Compact `"14m"` round-trips through `calc()`, not `lingo()`. Same-kind `q / q` is a dimensionless ratio; cross-currency arithmetic is `RATE_REQUIRED`; `q * q` is `SCALAR_EXPECTED`. `formatExpression` is two-way; `formatLatex` is display-only. ## DOM (`@pascal-app/lingo/dom`) diff --git a/packages/lingo/src/ai/quantity-fields.ts b/packages/lingo/src/ai/quantity-fields.ts index 588b51a..253277f 100644 --- a/packages/lingo/src/ai/quantity-fields.ts +++ b/packages/lingo/src/ai/quantity-fields.ts @@ -124,26 +124,10 @@ export function quantityField(opts: QuantityFieldOptions): LingoField { expect(same.value).toBeCloseTo(15, 12) expect(same.quantity?.unit).toBe('USD') expect(ok('10 usd / 5 usd').value).toBeCloseTo(2, 12) - }) - - it('does not re-unit a canceled ratio', () => { - const r = ok('10 L / 2 L', { kind: 'mass', unit: 'kg' }) - expect(r.value).toBeCloseTo(5, 12) - expect(r.quantity).toBeUndefined() - expect(r.issues.some((issue) => issue.code === 'UNIT_ASSUMED')).toBe(false) - const assumed = ok('2+3', { kind: 'mass', unit: 'kg' }) - expect(assumed.quantity?.kind).toBe('mass') - expect(assumed.value).toBeCloseTo(5, 12) - expect(assumed.issues.some((issue) => issue.code === 'UNIT_ASSUMED')).toBe(true) + expect(calc('10 usd * 5 eur').issues[0]?.code).toBe('RATE_REQUIRED') }) it('keeps a unit on compound scientific format', () => { @@ -310,6 +300,7 @@ describe('calc injection', () => { it('lets quantityField accept 12 * 0.75 kg when calc is injected', () => { const field = quantityField({ kind: 'mass', unit: 'kg', calc }) expect(field.parse('12 * 0.75 kg')).toBeCloseTo(9, 12) + expect(field.parse('2+3')).toBeCloseTo(5, 12) const range = field.safeParse('5-10 kg') expect('value' in range).toBe(false) const input = field['~standard'].jsonSchema.input({ target: 'draft-2020-12' }) @@ -335,16 +326,6 @@ describe('calc injection', () => { }) expect(field.parse('10 usd + 5 usd')).toBeCloseTo(15, 12) }) - - it('does not stuff a canceled ratio into the field unit', () => { - const field = quantityField({ kind: 'mass', unit: 'kg', calc }) - const result = field.safeParse('10 L / 2 L') - if ('value' in result) { - throw new Error('expected KIND_MISMATCH failure') - } - expect(result.issues[0]?.code).toBe('KIND_MISMATCH') - expect(field.parse('2+3')).toBeCloseTo(5, 12) - }) }) describe('formatCalc helpers', () => { diff --git a/packages/lingo/src/calc/eval.ts b/packages/lingo/src/calc/eval.ts index 712f73a..a627781 100644 --- a/packages/lingo/src/calc/eval.ts +++ b/packages/lingo/src/calc/eval.ts @@ -8,8 +8,6 @@ import type { CalcNode } from './types' export interface EvalValue { kind: Kind | null quantity: Quantity | null - /** True when same-kind `q / q` canceled to a dimensionless ratio. */ - ratio?: boolean span: Span unit: string | null value: number @@ -57,6 +55,12 @@ function evalOp(p: ParserState, node: Extract): EvalVa if (!(left && right)) { return null } + const from = left.unit + const to = right.unit + if (from && to && from !== to && left.kind === right.kind && p.reg.kind(left.kind)?.rateBased) { + report(p, 'RATE_REQUIRED', { from, to }, node.span) + return null + } if (node.op === '+' || node.op === '-') { return evalAdd(p, node.op, left, right, node.span) } @@ -78,9 +82,6 @@ function evalAdd( report(p, 'EXPRESSION_KIND_MISMATCH', { left: left.kind, right: right.kind }, span) return null } - if (rateMismatch(p, left, right, span)) { - return null - } if (!(left.kind || right.kind)) { return finite( p, @@ -99,7 +100,11 @@ function evalAdd( const rightDelta = right.quantity ? right.value * (p.reg.unit(kind, right.unit!)?.factor ?? unit.factor) : toBase(unit, right.value) - (unit.offset ?? 0) - if ((unit.offset || rightAffine(p, right, kind)) && left.quantity && right.quantity) { + if ( + (unit.offset || (right.unit && p.reg.unit(kind, right.unit)?.offset)) && + left.quantity && + right.quantity + ) { const deltaUnit = p.reg.unit(kind, right.unit!) ?? unit report( p, @@ -150,20 +155,13 @@ function evalDiv(p: ParserState, left: EvalValue, right: EvalValue, span: Span): ) return null } - if (rateMismatch(p, left, right, span)) { - return null - } const common = left.quantity.valueIn(left.unit!) const other = right.quantity.valueIn(left.unit!) if (other === 0) { report(p, 'DIVISION_BY_ZERO', {}, span) return null } - return finite( - p, - { kind: null, unit: null, value: common / other, quantity: null, span, ratio: true }, - span, - ) + return finite(p, { kind: null, unit: null, value: common / other, quantity: null, span }, span) } if (right.quantity && !left.quantity) { report(p, 'SCALAR_EXPECTED', { op: 'divide' }, span) @@ -205,24 +203,6 @@ function scaleValue(p: ParserState, qty: EvalValue, factor: number, span: Span): return { kind: qty.kind, unit: qty.unit, value: quantity.value, quantity, span } } -function rightAffine(p: ParserState, right: EvalValue, kind: Kind): boolean { - if (!right.unit) { - return false - } - return Boolean(p.reg.unit(kind, right.unit)?.offset) -} - -function rateMismatch(p: ParserState, left: EvalValue, right: EvalValue, span: Span): boolean { - if (!(left.unit && right.unit && left.unit !== right.unit)) { - return false - } - if (!p.reg.kind(left.kind ?? right.kind)?.rateBased) { - return false - } - report(p, 'RATE_REQUIRED', { from: left.unit, to: right.unit }, span) - return true -} - function finite(p: ParserState, value: EvalValue, span: Span): EvalValue | null { if (!Number.isFinite(value.value)) { report(p, 'NONFINITE', {}, span) diff --git a/packages/lingo/src/calc/index.ts b/packages/lingo/src/calc/index.ts index e58a39c..84c037e 100644 --- a/packages/lingo/src/calc/index.ts +++ b/packages/lingo/src/calc/index.ts @@ -132,9 +132,6 @@ function finishQuantity(p: ParserState, value: EvalValue): Quantity | undefined if (value.quantity) { return value.quantity } - if (value.ratio) { - return - } const implied = resolveImplied(p) if (!implied) { return diff --git a/plans/032-input-calculations.md b/plans/032-input-calculations.md index e95d075..9b8e56e 100644 --- a/plans/032-input-calculations.md +++ b/plans/032-input-calculations.md @@ -169,18 +169,14 @@ Operand rules, and the issue code when they're violated: | `n + q`, `q + n` | quantity | Bare operand inherits the other side's unit | | `q * n`, `n * q` | quantity | Exactly one operand may be a quantity, else `SCALAR_EXPECTED` | | `q / n` | quantity | Divisor must be scalar | -| `q / q` | number | Same-kind division cancels to a dimensionless ratio (phase 3). Rate-based different units → `RATE_REQUIRED`. Implied `kind`/`unit` is not re-attached | +| `q / q` | number | Same-kind division cancels to a dimensionless ratio (phase 3). Rate-based different units → `RATE_REQUIRED` | | `q * q` | rejected | `SCALAR_EXPECTED` — this is dimensional algebra (D2) | | `x / 0` | rejected | `DIVISION_BY_ZERO` | `q * q` being refused is load-bearing: it's the line that keeps this a calculator instead of the start of a unit algebra. Rate-based kinds -(currency) cannot `+`/`-`/`/` across units: same `RATE_REQUIRED` guard as -ranges. Same-currency `10 usd + 5 usd` still adds. A canceled `q / q` stays -a number even when `kind`/`unit` are implied — `calc('10 L / 2 L', { kind: -'mass', unit: 'kg' })` is 5, not 5 kg. Bare `2+3` with those options still -assumes kg. `quantityField({ calc })` fails `KIND_MISMATCH` rather than -stuffing that ratio into the field unit. +(currency) cannot mix units in an expression: same `RATE_REQUIRED` guard as +ranges. Same-currency `10 usd + 5 usd` still adds. `and` is `+` in calc (`half of 56kg and 1700g`). Spaced `-` is subtraction, not a range. Word operators: `plus` / `minus` / `times` / `x` / `over` / diff --git a/plans/backlog.md b/plans/backlog.md index 2b79035..9e36840 100644 --- a/plans/backlog.md +++ b/plans/backlog.md @@ -36,6 +36,11 @@ surfaces mid-task, add it here and keep going — don't act on it. Ranked above the general expression grammar on form value, but it changes the v3 wire shape, so it needs its own plan rather than folding into 032. (Surfaced by the mathjs/plan-032 prior-art pass 2026-07-29.) +- **Canceled `q / q` re-unit via implied options** — `calc('10 L / 2 L', { + kind: 'mass', unit: 'kg' })` still returns `5 kg` + `UNIT_ASSUMED`. The + result is a dimensionless ratio; `finishQuantity` re-attaches the field + unit. A `ratio` flag to skip that (~20 B gzip) busts the 4.1 kB `./calc` + marginal budget. Cross-currency `RATE_REQUIRED` already lands. - **Multiplier and count words** — `twice 3 kg`, `double 3 kg`, `3 boxes of 2 kg`, `3 @ 2.5 kg`, `5 kg each`, `a dozen eggs` all fail today. Reuses the existing number-word lexicon; overlaps plan 032 phase 3, but the unit-less diff --git a/wiki/architecture.md b/wiki/architecture.md index b3ec5db..05804f5 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -88,8 +88,7 @@ resolver + detector; the packs themselves live in `src/locales/`). with `trigger: 'always'`. Glued `m`/`M` at an operator boundary is million unless kind is `length` or `duration` (`SCALE_ASSUMED`); spaced `7 m` is meters; `1m80` stays 1.80 m because the next token is digits. Cross-currency - operands (`10 usd + 5 eur`) are `RATE_REQUIRED`, same as ranges. Canceled - `q / q` stays a number even when `kind`/`unit` are implied. + operands (`10 usd + 5 eur`) are `RATE_REQUIRED`, same as ranges. - **5K guard**: the k/bn suffix multiplier is disabled under kind 'temperature' (5K is kelvin, 70k is 70 000). - **Registry refs are liberal**: `.to('L')`, `convert(1,'gal','L')` resolve From 910fafda4a360629155a6c3cd014a44ef827efa9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 22 Aug 2026 19:49:59 +0000 Subject: [PATCH 8/8] fix(calc): narrow kind before the rateBased registry lookup p.reg.kind() takes Kind, not Kind | null; require left.kind so the cross-currency guard typechecks. Co-authored-by: Aymeric Rabot --- packages/lingo/src/calc/eval.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/lingo/src/calc/eval.ts b/packages/lingo/src/calc/eval.ts index a627781..ec1a1e7 100644 --- a/packages/lingo/src/calc/eval.ts +++ b/packages/lingo/src/calc/eval.ts @@ -57,7 +57,14 @@ function evalOp(p: ParserState, node: Extract): EvalVa } const from = left.unit const to = right.unit - if (from && to && from !== to && left.kind === right.kind && p.reg.kind(left.kind)?.rateBased) { + if ( + from && + to && + from !== to && + left.kind && + left.kind === right.kind && + p.reg.kind(left.kind)?.rateBased + ) { report(p, 'RATE_REQUIRED', { from, to }, node.span) return null }