diff --git a/src/cast.ts b/src/cast.ts index 4f4a114..5ff0380 100644 --- a/src/cast.ts +++ b/src/cast.ts @@ -28,7 +28,12 @@ */ import { civilOf, daysFromCivil } from "./civil.js"; -import { DURATION_UNIT_TABLE, type DurationKey, type UnitRow } from "./duration-units.js"; +import { + DURATION_UNIT_TABLE, + type DurationKey, + expandFraction, + type UnitRow, +} from "./duration-units.js"; import { floatMagnitude, floatText } from "./floats.js"; import type { CastType } from "./instructions.js"; import { formatDate, formatDateTime, readDate, readDateTime } from "./iso.js"; @@ -203,9 +208,6 @@ const UNITS = DURATION_UNIT_TABLE; const ROW_OF_SUFFIX: ReadonlyMap = new Map(UNITS.map((row) => [row.suffix, row])); -/** The units a fraction's remainder decomposes through, largest first. */ -const REMAINDER_LADDER: readonly UnitRow[] = UNITS.filter((row) => row.remainder); - /** * The suffixes as a pattern alternation, longest first. * @@ -260,9 +262,9 @@ export function readDuration(text: string): Duration | undefined { add(parts, row.key, Number(whole)); continue; } - const expanded = expand(Number(whole), digits, row); + const expanded = expandFraction(Number(whole), digits, row); if (expanded === undefined) return undefined; - for (const [amount, key] of expanded) add(parts, key, amount); + for (const { amount, row: unit } of expanded) add(parts, unit.key, amount); } // No amount added above is negative, so a sum is never smaller than an amount // in it, and checking each component as read covers a component written too @@ -278,68 +280,6 @@ function add(parts: { [key in DurationKey]?: number }, key: DurationKey, amount: parts[key] = (parts[key] ?? 0) + amount; } -/** - * Expands a component carrying a fraction into whole-unit amounts, or answers - * nothing when the fraction is not an exact number of milliseconds. - * - * A millisecond is the domain's floor, so a fraction below one is refused - * rather than rounded or truncated: `"0.5ms"` names no duration this domain - * holds. What the test asks is whether a remainder is zero, and a binary float - * answers that about a decimal fraction wrongly, so the scaling below is done - * over the written digits and the test reads the digits it shifted past. A - * literal is written by an author and its digit run has no bound, while the - * answer is smaller than the unit's own weight, so the shift is where the - * arithmetic has to stay exact and the answer is an ordinary number. - * - * A fraction of a month or of a year commits that unit's approximation at the - * moment the text is read, so half a month is a count of days and carries no - * month at all. - */ -function expand(whole: number, digits: string, row: UnitRow): [number, DurationKey][] | undefined { - const scaled = scale(digits, row.millis); - const shifted = scaled.length - digits.length; - if (NON_ZERO_DIGIT.test(scaled.slice(shifted))) return undefined; - const amounts: [number, DurationKey][] = []; - if (whole > 0) amounts.push([whole, row.key]); - let remaining = Number(scaled.slice(0, shifted) || "0"); - for (const step of REMAINDER_LADDER) { - const amount = Math.floor(remaining / step.millis); - if (amount > 0) amounts.push([amount, step.key]); - remaining %= step.millis; - } - if (amounts.length === 0) amounts.push([0, row.key]); - return amounts; -} - -const NON_ZERO_DIGIT = /[1-9]/; - -/** - * Multiplies a run of decimal digits by a whole number, answering the product - * as a run of decimal digits. - * - * It is long multiplication by a single factor, one written digit at a time. - * A carry is smaller than the factor, because it is a tenth of a step and a - * step is a digit times the factor plus a carry, so an intermediate stays - * below ten times the factor however long the run of digits is. The product - * keeps at least as many digits as it was given, which is what lets the caller - * read the fraction off its tail. - */ -function scale(digits: string, factor: number): string { - const product: number[] = []; - const zero = "0".charCodeAt(0); - let carry = 0; - for (let index = digits.length - 1; index >= 0; index -= 1) { - const step = (digits.charCodeAt(index) - zero) * factor + carry; - product.push(step % 10); - carry = Math.floor(step / 10); - } - while (carry > 0) { - product.push(carry % 10); - carry = Math.floor(carry / 10); - } - return product.reverse().join(""); -} - /** * Writes a duration in the literal grammar, largest unit first. * diff --git a/src/duration-units.ts b/src/duration-units.ts index 5b3acff..1f69dfc 100644 --- a/src/duration-units.ts +++ b/src/duration-units.ts @@ -6,6 +6,10 @@ * a duration literal, the `duration` opcode that turns a unit string into the * key it names, and the parse behind `::duration` and `parseDuration`. A unit * is added or removed here and nowhere else. + * + * The expansion of a fractional component lives here too, so the grammar and + * the parse turn a fraction into whole units by one arithmetic rather than + * two. */ import type { DurationParts } from "./values.js"; @@ -72,6 +76,95 @@ export const DURATION_UNIT_TABLE: readonly UnitRow[] = [ }, ]; +/** One whole-unit amount a fractional component expands into. */ +export interface ExpandedAmount { + readonly amount: number; + readonly row: UnitRow; +} + +/** The units a fraction's remainder is spent into, largest first. */ +const REMAINDER_LADDER: readonly UnitRow[] = DURATION_UNIT_TABLE.filter((row) => row.remainder); + +/** + * The most decimal places a fraction can carry and still be exact. + * + * A fraction is exact when the tens in its denominator all cancel against the + * twos and fives in its unit's millisecond value and in its own digits, and + * digits with no trailing zero cannot supply both. The richest unit here + * carries eleven twos, so past eleven places nothing cancels and the fraction + * is a sub-millisecond remainder whatever its digits say. Refusing there is + * also what keeps every product below inside the whole numbers this language + * holds exactly. + */ +const MOST_EXACT_PLACES = 11; + +/** + * Expands a component written with a fraction into whole-unit amounts, or + * answers nothing when the fraction is not an exact number of milliseconds. + * + * `whole` is the integer part and `digits` the run of digits after the decimal + * point, as written. A millisecond is the domain's floor, so a fraction below + * one is refused rather than rounded or truncated: `0.5ms` names no duration + * this domain holds. + * + * The arithmetic is whole numbers throughout - the digits are read as an + * integer and scaled by a power of ten, never as a binary fraction, which + * answers whether a decimal remainder is zero wrongly - so the component is + * exact or it is refused, and nothing is rounded on the way. The shared + * factors of the unit's millisecond value and the power of ten are cancelled + * before anything is multiplied, which is what keeps the products small enough + * to stay exact. + * + * The integer part keeps its own unit, and is left out when it is zero; only + * the remainder walks the ladder, which never spends back into a week, a month + * or a year. So a fraction of a month or of a year commits that unit's + * approximation at the moment the text is read: half a month is a count of + * days and carries no month at all. A component that resolves to nothing at + * all answers a zero amount of its own unit. The amounts come out largest + * unit first, each unit at most once; what a caller does with a unit that + * another component also names is the caller's rule, not this one's. + */ +export function expandFraction( + whole: number, + digits: string, + row: UnitRow, +): readonly ExpandedAmount[] | undefined { + // A trailing zero is a place that carries nothing: dropping it leaves the + // fraction's value alone and its denominator smaller. The zeros are counted + // back from the end rather than matched by a pattern anchored there, which + // retries from every zero in a long run and takes time quadratic in it. + let end = digits.length; + while (end > 0 && digits[end - 1] === "0") end -= 1; + const written = digits.slice(0, end); + if (written.length > MOST_EXACT_PLACES) return undefined; + + const numerator = written === "" ? 0 : Number(written); + const shared = greatestCommonDivisor(row.millis, 10 ** written.length); + const denominator = 10 ** written.length / shared; + if (numerator % denominator !== 0) return undefined; + + const amounts: ExpandedAmount[] = whole > 0 ? [{ amount: whole, row }] : []; + let remainder = (numerator / denominator) * (row.millis / shared); + for (const step of REMAINDER_LADDER) { + const amount = Math.floor(remainder / step.millis); + remainder -= amount * step.millis; + if (amount > 0) amounts.push({ amount, row: step }); + } + + return amounts.length === 0 ? [{ amount: 0, row }] : amounts; +} + +function greatestCommonDivisor(left: number, right: number): number { + let a = left; + let b = right; + while (b !== 0) { + const next = a % b; + a = b; + b = next; + } + return a; +} + /** The units smallest first: the order the reference adds a duration's terms in. */ const SMALLEST_FIRST: readonly UnitRow[] = [...DURATION_UNIT_TABLE].reverse(); diff --git a/src/parser.ts b/src/parser.ts index 89a2ee2..ef83880 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -65,7 +65,11 @@ import type { Statement, WhileStatement, } from "./ast.js"; -import { DURATION_UNIT_TABLE } from "./duration-units.js"; +import { + DURATION_UNIT_TABLE, + expandFraction as expandFractionalComponent, + type UnitRow, +} from "./duration-units.js"; import { ParseError, type ParseReason, type Position, type Span } from "./errors.js"; import { floatSpelling } from "./floats.js"; import { CAST_TYPE_NAMES, type CastType } from "./instructions.js"; @@ -1421,88 +1425,31 @@ function expandComponents( return { ok: true, value: pairs }; } -/** - * The exact whole milliseconds one of each unit is worth, read off the one - * unit table every duration reader shares. - */ -const UNIT_MILLISECONDS: ReadonlyMap = new Map( - DURATION_UNIT_TABLE.map((row) => [row.suffix, row.millis]), +/** The unit rows by the suffix a literal writes them in, from the one table. */ +const ROW_OF_SUFFIX: ReadonlyMap = new Map( + DURATION_UNIT_TABLE.map((row) => [row.suffix, row]), ); /** - * The units a remainder decomposes into, largest first, from the same table. - * - * A remainder never goes back into weeks, months or years: those three carry - * the language's own month and year approximations, and re-introducing one - * into a remainder that an approximation produced would be circular. So half a - * year is a hundred and eighty-two days and twelve hours, not twenty-six weeks. - */ -const REMAINDER_LADDER: readonly (readonly [string, number])[] = DURATION_UNIT_TABLE.filter( - (row) => row.remainder, -).map((row) => [row.suffix, row.millis] as const); - -/** - * The most decimal places a fraction can carry and still be exact. + * Expands one fractional component into whole-unit pairs, or says its + * fraction is not exact. * - * A fraction is exact when the tens in its denominator all cancel against the - * twos and fives in its unit's millisecond value and in its own digits, and - * digits with no trailing zero cannot supply both. The richest unit here - * carries eleven twos, so past eleven places nothing cancels and the fraction - * is a sub-millisecond remainder whatever its digits say. Refusing there is - * also what keeps every product below inside the whole numbers this language - * holds exactly. - */ -const MOST_EXACT_PLACES = 11; - -/** - * Expands one fractional component, or says its fraction is not exact. - * - * The arithmetic is whole numbers throughout - the digits are read as an - * integer and scaled by a power of ten, never as a binary fraction - so the - * component is exact or it is refused, and nothing is rounded on the way. The - * shared factors of the unit's millisecond value and the power of ten are - * cancelled before anything is multiplied, which is what keeps the products - * small enough to stay exact. The integer part keeps its own unit; only the - * remainder walks the ladder. + * The expansion is the one the parse behind `::duration` and `parseDuration` + * runs, so a literal and a parsed text expand a fraction alike; this only + * names each amount's unit by the suffix a literal writes. A unit the table + * does not know is refused as an inexact fraction would be. */ function expandFraction( whole: number, digits: string, unit: string, ): readonly DurationUnit[] | undefined { - const multiplier = UNIT_MILLISECONDS.get(unit); - if (multiplier === undefined) return undefined; - - // A trailing zero is a place that carries nothing: dropping it leaves the - // fraction's value alone and its denominator smaller. - const written = digits.replace(/0+$/, ""); - if (written.length > MOST_EXACT_PLACES) return undefined; - - const numerator = written === "" ? 0 : Number(written); - const shared = greatestCommonDivisor(multiplier, 10 ** written.length); - const denominator = 10 ** written.length / shared; - if (numerator % denominator !== 0) return undefined; - - const pairs: DurationUnit[] = whole > 0 ? [{ value: whole, unit }] : []; - let remainder = (numerator / denominator) * (multiplier / shared); - for (const [ladderUnit, ladderMilliseconds] of REMAINDER_LADDER) { - const value = Math.floor(remainder / ladderMilliseconds); - remainder -= value * ladderMilliseconds; - if (value > 0) pairs.push({ value, unit: ladderUnit }); - } - - return pairs.length === 0 ? [{ value: 0, unit }] : pairs; -} - -function greatestCommonDivisor(left: number, right: number): number { - let a = left; - let b = right; - while (b !== 0) { - const next = a % b; - a = b; - b = next; - } - return a; + const row = ROW_OF_SUFFIX.get(unit); + if (row === undefined) return undefined; + return expandFractionalComponent(whole, digits, row)?.map((expanded) => ({ + value: expanded.amount, + unit: expanded.row.suffix, + })); } /** diff --git a/test/duration-to-milliseconds.test.ts b/test/duration-to-milliseconds.test.ts index b27309f..0f8fad7 100644 --- a/test/duration-to-milliseconds.test.ts +++ b/test/duration-to-milliseconds.test.ts @@ -96,10 +96,10 @@ describe("durationToMilliseconds", () => { // The reference sums with integers of any size; a JavaScript number past // the largest safe integer is the nearest double. This pins what the // conversion answers there: the double the arithmetic gives, which is not - // the exact sum, and no refusal. That it keeps a plain number was decided - // under the night rule by the conductor, 2026-10-01; a component past the - // safe range is refused where a text is read instead, by the cast and by - // parseDuration. + // the exact sum, and no refusal. It keeps a plain number (2026-10-01) + // because the bound is enforced where a text is read: the cast and + // parseDuration refuse a component past the safe range, so a duration that + // reaches this conversion was built by a host and is weighed as it stands. // Sabotage: clamping the sum to the largest safe integer turns this red. it("answers the double arithmetic gives past the largest safe integer, never a throw", () => { const past = new Duration({ seconds: 1, milliseconds: Number.MAX_SAFE_INTEGER }); diff --git a/test/fraction-expansion.test.ts b/test/fraction-expansion.test.ts new file mode 100644 index 0000000..cb219cb --- /dev/null +++ b/test/fraction-expansion.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { compile } from "../src/compile.js"; +import { evaluateToValue } from "../src/evaluator.js"; +import { parseDuration } from "../src/index.js"; +import { Duration, type DurationParts, Undefined, type Value } from "../src/values.js"; +import { sameValue } from "./conformance/runner.js"; + +// A fraction is expanded into whole units by one function, which the grammar +// runs on a duration literal and the parse behind `::duration` and +// `parseDuration` runs on a text. Each row is a single component, so the two +// rules that differ between them - a repeated unit is refused by the grammar +// and accumulated by the parse - never come into it, and the two answer alike. +const EXACT: readonly (readonly [string, DurationParts])[] = [ + ["1.5s", { seconds: 1, milliseconds: 500 }], + ["2.25h", { hours: 2, minutes: 15 }], + ["0.5y", { days: 182, hours: 12 }], + ["0.001s", { milliseconds: 1 }], + // Eleven places, the most a unit can absorb exactly. + ["0.00000003125mo", { milliseconds: 81 }], + // Trailing zeros carry nothing, however many places they run to. + ["1.50000000000000000000s", { seconds: 1, milliseconds: 500 }], + ["0.0s", {}], +]; + +const INEXACT: readonly string[] = [ + "0.5ms", + "0.0001s", + "0.000000000005s", + "1.00000000000000000001s", +]; + +/** What a duration literal compiles and evaluates to, or undefined when refused. */ +function literalValue(text: string): Value { + const compiled = compile(text); + if (!compiled.ok) return Undefined; + const outcome = evaluateToValue(compiled.instructions); + return outcome.ok ? outcome.value : Undefined; +} + +/** What `parseDuration` reads a text as, or undefined when refused. */ +function parsedValue(text: string): Value { + const parsed = parseDuration(text); + return parsed.ok ? parsed.value : Undefined; +} + +describe("a fraction expands alike in a literal and in a parsed text", () => { + // Sabotage: reading the digits with their trailing zeros, in the shared + // expander, turns the trailing-zero row red. + it.each(EXACT)("expands %j to the same whole units both ways", (text, parts) => { + const expected = new Duration(parts); + expect(sameValue(literalValue(text), expected)).toBe(true); + expect(sameValue(parsedValue(text), expected)).toBe(true); + }); + + // Sabotage: answering the integer part alone when a fraction is inexact, in + // the shared expander, turns the two rows within eleven places red. + it.each(INEXACT)("refuses %j both ways", (text) => { + const compiled = compile(text); + expect(compiled.ok).toBe(false); + if (!compiled.ok) expect(compiled.error.reason).toBe("duration_fraction"); + expect(parseDuration(text)).toStrictEqual({ ok: false, reason: "invalid_duration_format" }); + }); + + // A run of zeros is read once from its end. Matching it with a pattern + // anchored at the end retries from every zero, which takes seconds on a run + // this long where reading it once takes a few milliseconds; the bound sits + // between the two, and the timeout is raised so the bound is what decides. + // Sabotage: stripping the zeros with a pattern anchored at the end, in the + // shared expander, turns this red on the elapsed-time assertion. + it("reads a long run of zeros in a fraction in time linear in its length", () => { + const text = `1.${"0".repeat(100_000)}1s`; + const started = performance.now(); + const compiled = compile(text); + const parsed = parseDuration(text); + const elapsed = performance.now() - started; + expect(compiled.ok).toBe(false); + expect(parsed.ok).toBe(false); + expect(elapsed).toBeLessThan(1_000); + }, 60_000); +});