Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 8 additions & 68 deletions src/cast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -203,9 +208,6 @@ const UNITS = DURATION_UNIT_TABLE;

const ROW_OF_SUFFIX: ReadonlyMap<string, UnitRow> = 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.
*
Expand Down Expand Up @@ -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
Expand All @@ -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.
*
Expand Down
93 changes: 93 additions & 0 deletions src/duration-units.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();

Expand Down
93 changes: 20 additions & 73 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, number> = 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<string, UnitRow> = 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,
}));
}

/**
Expand Down
8 changes: 4 additions & 4 deletions test/duration-to-milliseconds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading
Loading