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..8c70ff9 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"`. 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`) ```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/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/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..e2a6dac 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, @@ -102,7 +103,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 +325,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`, `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', '', '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 +440,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 +469,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..a4c5245 100644 --- a/packages/lingo/CHANGELOG.md +++ b/packages/lingo/CHANGELOG.md @@ -7,6 +7,42 @@ 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`. + +### 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. +- Cross-currency arithmetic in `calc()` reports `RATE_REQUIRED` instead of + treating every currency factor as 1, or masking the conversion throw as + `NONFINITE`. + ## [0.4.0] - 2026-08-02 ### Added diff --git a/packages/lingo/README.md b/packages/lingo/README.md index 32bc638..f11c3b9 100644 --- a/packages/lingo/README.md +++ b/packages/lingo/README.md @@ -389,6 +389,50 @@ 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; 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): + +```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 +937,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..8c70ff9 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"`. 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`) ```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..253277f 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,13 @@ 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 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 } export type RangeFieldOptions = LingoOptions & { @@ -72,6 +80,7 @@ export function quantityField( max?: number output: 'quantity' description?: string + calc?: QuantityFieldOptions['calc'] }, ): LingoField /** @@ -98,6 +107,7 @@ export function quantityField( max?: number output?: 'number' description?: string + calc?: QuantityFieldOptions['calc'] }, ): LingoField export function quantityField(opts: QuantityFieldOptions): LingoField { @@ -109,6 +119,42 @@ export function quantityField(opts: QuantityFieldOptions): LingoField): string { + if (!opts.calc) { + return '' + } + 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 new file mode 100644 index 0000000..d0ce587 --- /dev/null +++ b/packages/lingo/src/calc/calc.test.ts @@ -0,0 +1,336 @@ +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') + 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', () => { + 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('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) + expect(calc('10 usd * 5 eur').issues[0]?.code).toBe('RATE_REQUIRED') + }) + + 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: '=' }) + 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('keeps NONFINITE instead of masking it as NO_VALUE', () => { + 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') + }) + + 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', + '$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) + if (r.ok) { + expect(Number.isFinite(r.value), input).toBe(true) + } + } + }) + + 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') + 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) + 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' }) + 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) + }) + + 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) + }) +}) + +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..ec1a1e7 --- /dev/null +++ b/packages/lingo/src/calc/eval.ts @@ -0,0 +1,228 @@ +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 + } + const from = left.unit + const to = right.unit + 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 + } + 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 || (right.unit && p.reg.unit(kind, right.unit)?.offset)) && + 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 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..69c6fae --- /dev/null +++ b/packages/lingo/src/calc/format.ts @@ -0,0 +1,253 @@ +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' }) + } + return attachUnit(q, formatNumber(q.value, style === 'compact' ? 'scientific' : style)) + } + 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') { + return attachUnit(node.value, latexQuantityNumber(node.value.value), true) + } + 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 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 attachUnit(q: NonNullable, n: string, tex?: boolean): string { + const s = q.unitInfo().symbol + if (!s) { + return n + } + if (q.kind === 'currency') { + return `${tex ? s.replace('$', '\\$') : s}${n}` + } + if (s === '%' && tex) { + return `${n}\\%` + } + const u = tex ? `\\mathrm{${escapeLatex(s)}}` : s + return '°%‰′″'.includes(s[0]!) ? `${n}${u}` : tex ? `${n}\\,${u}` : `${n} ${u}` +} + +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..84c037e --- /dev/null +++ b/packages/lingo/src/calc/index.ts @@ -0,0 +1,213 @@ +/** + * 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 { 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, 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)) + } + const value = evaluate(p, node) + 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: EvalValue): 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..13e17a2 --- /dev/null +++ b/packages/lingo/src/calc/parse.ts @@ -0,0 +1,383 @@ +import { hasError } from '../core/errors' +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) { + if (!hasError(p.issues)) { + issue(p, 'NO_VALUE', { example: exampleFor(p) }, p.tokens[pos]?.start ?? 0, 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, 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) + } + if (aborted(saved)) { + return null + } + 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 | null { + 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) { + if (aborted(saved)) { + return null + } + restore(saved) + return left + } + return { + type: 'percent', + of, + percent: left, + mode, + span: join(left.span, of.span), + } + } + + function tryPrefix(): CalcNode | null { + const startPos = pos + for (const prefix of PREFIXES) { + const next = eatPhrase(p, pos, prefix.phrase) + if (next < 0) { + continue + } + pos = next + return { type: 'number', value: prefix.factor, span: tokenSpan(startPos, pos) } + } + 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 { 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 (!Number.isFinite(withHalf.base)) { + 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 } + } + 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 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 + 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..fb32707 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}: 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..9b8e56e 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' } @@ -167,16 +165,53 @@ 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` | | `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 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` / +`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 @@ -192,12 +227,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 +245,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 +254,13 @@ 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. 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 @@ -252,9 +286,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 +304,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,17 @@ 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'`. 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. Cross-currency + 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 diff --git a/wiki/decisions.md b/wiki/decisions.md index f7a627d..3cbe77b 100644 --- a/wiki/decisions.md +++ b/wiki/decisions.md @@ -679,3 +679,69 @@ 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. 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 +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. |