Skip to content

Commit

Permalink
Normative: Limit duration years, months, and weeks to <2³² each
Browse files Browse the repository at this point in the history
In order to prevent having to use bigint arithmetic, limit years, months,
and weeks to 32 bits each in durations.

There are more changes to the reference code than to the spec in this
commit because the upper limit now allows us to rewrite the reference
code's RoundDuration algorithm in a way that's more similar to how it was
already specified in the spec text.
  • Loading branch information
ptomato committed Jan 17, 2024
1 parent 93a2cf1 commit 854f5e1
Show file tree
Hide file tree
Showing 3 changed files with 62 additions and 35 deletions.
84 changes: 54 additions & 30 deletions polyfill/lib/ecmascript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3664,6 +3664,9 @@ export function RejectDuration(y, mon, w, d, h, min, s, ms, µs, ns) {
const propSign = MathSign(prop);
if (propSign !== 0 && propSign !== sign) throw new RangeError('mixed-sign values not allowed as duration fields');
}
if (MathAbs(y) >= 2 ** 32 || MathAbs(mon) >= 2 ** 32 || MathAbs(w) >= 2 ** 32) {
throw new RangeError('years, months, and weeks must be < 2³²');
}
const msResult = TruncatingDivModByPowerOf10(ms, 3);
const µsResult = TruncatingDivModByPowerOf10(µs, 6);
const nsResult = TruncatingDivModByPowerOf10(ns, 9);
Expand Down Expand Up @@ -5047,6 +5050,49 @@ export function RoundNumberToIncrement(quantity, increment, mode) {
return quotient.multiply(increment);
}

export function RoundJSNumberToIncrement(quantity, increment, mode) {
let quotient = MathTrunc(quantity / increment);
const remainder = quantity % increment;
if (remainder === 0) return quantity;
const sign = remainder < 0 ? -1 : 1;
const tiebreaker = MathAbs(remainder * 2);
const tie = tiebreaker === increment;
const expandIsNearer = tiebreaker > increment;
switch (mode) {
case 'ceil':
if (sign > 0) quotient += sign;
break;
case 'floor':
if (sign < 0) quotient += sign;
break;
case 'expand':
// always expand if there is a remainder
quotient += sign;
break;
case 'trunc':
// no change needed, because divmod is a truncation
break;
case 'halfCeil':
if (expandIsNearer || (tie && sign > 0)) quotient += sign;
break;
case 'halfFloor':
if (expandIsNearer || (tie && sign < 0)) quotient += sign;
break;
case 'halfExpand':
// "half up away from zero"
if (expandIsNearer || tie) quotient += sign;
break;
case 'halfTrunc':
if (expandIsNearer) quotient += sign;
break;
case 'halfEven': {
if (expandIsNearer || (tie && quotient % 2 === 1)) quotient += sign;
break;
}
}
return quotient * increment;
}

export function RoundInstant(epochNs, increment, unit, roundingMode) {
let { remainder } = NonNegativeBigIntDivmod(epochNs, DAY_NANOS);
const wholeDays = epochNs.minus(remainder);
Expand Down Expand Up @@ -5361,20 +5407,10 @@ export function RoundDuration(
const oneYear = new TemporalDuration(days < 0 ? -1 : 1);
let { days: oneYearDays } = MoveRelativeDate(calendarRec, plainRelativeTo, oneYear);

// Note that `nanoseconds` below (here and in similar code for months,
// weeks, and days further below) isn't actually nanoseconds for the
// full date range. Instead, it's a BigInt representation of total
// days multiplied by the number of nanoseconds in the last day of
// the duration. This lets us do days-or-larger rounding using BigInt
// math which reduces precision loss.
oneYearDays = MathAbs(oneYearDays);
if (oneYearDays === 0) throw new RangeError('custom calendar reported that a year is 0 days long');
const divisor = bigInt(oneYearDays).multiply(dayLengthNs);
const nanoseconds = divisor.multiply(years).plus(bigInt(days).multiply(dayLengthNs)).plus(norm.totalNs);
const rounded = RoundNumberToIncrement(nanoseconds, divisor.multiply(increment).toJSNumber(), roundingMode);
const { quotient, remainder } = nanoseconds.divmod(divisor);
total = quotient.toJSNumber() + remainder.toJSNumber() / divisor;
years = rounded.divide(divisor).toJSNumber();
total = years + (days + norm.fdiv(dayLengthNs)) / oneYearDays;
years = RoundJSNumberToIncrement(total, increment, roundingMode);
months = weeks = days = 0;
norm = TimeDuration.ZERO;
break;
Expand Down Expand Up @@ -5418,12 +5454,8 @@ export function RoundDuration(

oneMonthDays = MathAbs(oneMonthDays);
if (oneMonthDays === 0) throw new RangeError('custom calendar reported that a month is 0 days long');
const divisor = bigInt(oneMonthDays).multiply(dayLengthNs);
const nanoseconds = divisor.multiply(months).plus(bigInt(days).multiply(dayLengthNs)).plus(norm.totalNs);
const rounded = RoundNumberToIncrement(nanoseconds, divisor.multiply(increment), roundingMode);
const { quotient, remainder } = nanoseconds.divmod(divisor);
total = quotient.toJSNumber() + remainder.toJSNumber() / divisor;
months = rounded.divide(divisor).toJSNumber();
total = months + (days + norm.fdiv(dayLengthNs)) / oneMonthDays;
months = RoundJSNumberToIncrement(total, increment, roundingMode);
weeks = days = 0;
norm = TimeDuration.ZERO;
break;
Expand Down Expand Up @@ -5457,23 +5489,15 @@ export function RoundDuration(

oneWeekDays = MathAbs(oneWeekDays);
if (oneWeekDays === 0) throw new RangeError('custom calendar reported that a week is 0 days long');
const divisor = bigInt(oneWeekDays).multiply(dayLengthNs);
const nanoseconds = divisor.multiply(weeks).plus(bigInt(days).multiply(dayLengthNs)).plus(norm.totalNs);
const rounded = RoundNumberToIncrement(nanoseconds, divisor.multiply(increment), roundingMode);
const { quotient, remainder } = nanoseconds.divmod(divisor);
total = quotient.toJSNumber() + remainder.toJSNumber() / divisor;
weeks = rounded.divide(divisor).toJSNumber();
total = weeks + (days + norm.fdiv(dayLengthNs)) / oneWeekDays;
weeks = RoundJSNumberToIncrement(total, increment, roundingMode);
days = 0;
norm = TimeDuration.ZERO;
break;
}
case 'day': {
const divisor = bigInt(dayLengthNs);
const nanoseconds = divisor.multiply(days).plus(norm.totalNs);
const rounded = RoundNumberToIncrement(nanoseconds, divisor.multiply(increment), roundingMode);
const { quotient, remainder } = nanoseconds.divmod(divisor);
total = quotient.toJSNumber() + remainder.toJSNumber() / divisor;
days = rounded.divide(divisor).toJSNumber();
total = days + norm.fdiv(dayLengthNs);
days = RoundJSNumberToIncrement(total, increment, roundingMode);
norm = TimeDuration.ZERO;
break;
}
Expand Down
10 changes: 5 additions & 5 deletions polyfill/test/validStrings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -361,8 +361,8 @@ const durationHoursFraction = withCode(fraction, (data, result) => {
data.nanoseconds = Math.trunc(ns % 1e3) * data.factor;
});

const digitsNotInfinite = withSyntaxConstraints(oneOrMore(digit()), (result) => {
if (!Number.isFinite(+result)) throw new SyntaxError('try again on infinity');
const uint32Digits = withSyntaxConstraints(between(1, 10, digit()), (result) => {
if (+result >= 2 ** 32) throw new SyntaxError('try again for an uint32');
});
const timeDurationDigits = (factor) =>
withSyntaxConstraints(between(1, 16, digit()), (result) => {
Expand All @@ -387,17 +387,17 @@ const durationDays = seq(
daysDesignator
);
const durationWeeks = seq(
withCode(digitsNotInfinite, (data, result) => (data.weeks = +result * data.factor)),
withCode(uint32Digits, (data, result) => (data.weeks = +result * data.factor)),
weeksDesignator,
[durationDays]
);
const durationMonths = seq(
withCode(digitsNotInfinite, (data, result) => (data.months = +result * data.factor)),
withCode(uint32Digits, (data, result) => (data.months = +result * data.factor)),
monthsDesignator,
[choice(durationWeeks, durationDays)]
);
const durationYears = seq(
withCode(digitsNotInfinite, (data, result) => (data.years = +result * data.factor)),
withCode(uint32Digits, (data, result) => (data.years = +result * data.factor)),
yearsDesignator,
[choice(durationMonths, durationWeeks, durationDays)]
);
Expand Down
3 changes: 3 additions & 0 deletions spec/duration.html
Original file line number Diff line number Diff line change
Expand Up @@ -1203,6 +1203,9 @@ <h1>
1. If 𝔽(_v_) is not finite, return *false*.
1. If _v_ &lt; 0 and _sign_ &gt; 0, return *false*.
1. If _v_ &gt; 0 and _sign_ &lt; 0, return *false*.
1. If abs(_years_) &ge; 2<sup>32</sup>, return *false*.
1. If abs(_months_) &ge; 2<sup>32</sup>, return *false*.
1. If abs(_weeks_) &ge; 2<sup>32</sup>, return *false*.
1. Let _normalizedSeconds_ be _days_ &times; 86,400 + _hours_ &times; 3600 + _minutes_ &times; 60 + _seconds_ + _milliseconds_ &times; 10<sup>-3</sup> + _microseconds_ &times; 10<sup>-6</sup> + _nanoseconds_ &times; 10<sup>-9</sup>.
1. NOTE: The above step cannot be implemented directly using floating-point arithmetic. Multiplying by 10<sup>-3</sup>, 10<sup>-6</sup>, and 10<sup>-9</sup> respectively may be imprecise when _milliseconds_, _microseconds_, or _nanoseconds_ is an unsafe integer. This multiplication can be implemented in C++ with an implementation of `std::remquo()` with sufficient bits in the quotient. String manipulation will also give an exact result, since the multiplication is by a power of 10.
1. If abs(_normalizedSeconds_) &ge; 2<sup>53</sup>, return *false*.
Expand Down

0 comments on commit 854f5e1

Please sign in to comment.