Round ISO8601 fractional seconds to the nearest millisecond#1997
Round ISO8601 fractional seconds to the nearest millisecond#1997adityasingh2400 wants to merge 3 commits into
Conversation
When formatting with includingFractionalSeconds, the milliseconds field
was computed from the nanosecond component by truncating toward zero:
let ms = Int((Double(ns) / 1_000_000.0).rounded(.towardZero))
A Date created from a fractional TimeInterval at a present-day magnitude
cannot represent the value exactly. For Date(timeIntervalSince1970:
1674036251.123) the nanosecond component the calendar extracts is
122999906 rather than 123000000, so truncation yields ".122" instead of
".123". The same off-by-one drops the last millisecond for many values,
for example ".001" becomes ".000" and ".011" becomes ".010".
Round to the nearest millisecond instead. The result is clamped to 999
so a value that rounds up to 1000 does not overflow the three-digit
field, matching the existing behavior already covered by the rounding()
test for ".9999".
Fixes swiftlang#963.
|
Does this work for the scenario that @parkera mentioned in his comment in the issue?
Can we add a unit test to see what this change would do to this input? |
Cover the scenario from issue swiftlang#963: a time at HH:MM:59 with a sub-millisecond remainder that rounds up to 1000 ms must clamp to .999 and keep the second at 59, never reading .000 or carrying into the seconds field.
|
Yes, it handles that case. The min(..., 999) clamp is exactly the guard for it: when the sub-millisecond remainder rounds up to 1000 ms, it is capped at .999 rather than wrapping the rounded value to .000 or carrying into the seconds field. So a time like 12:59:59.9999 formats as 12:59:59.999, never 12:59:59.000. I added a test at the :59 boundary in 7208ae6 (1674036299.0 + 0.9996 is 2023-01-18T10:04:59) asserting the output stays at .999. The existing per-millisecond loop and the round-up-to-1000 test cover the rest. |
| // (e.g. 122999906 instead of 123000000). Truncating that toward zero produces an | ||
| // off-by-one in the milliseconds field (".122" instead of ".123"). Clamp to 999 so | ||
| // a value that rounds up to 1000 does not overflow the three-digit field. | ||
| let ms = min(Int((Double(ns) / 1_000_000.0).rounded()), 999) |
There was a problem hiding this comment.
I might be missing something, but doesn't this patch basically preserve the "wrong" rounding behavior for 999xyz? In order to fix that last part we'd have to do the recalculation of checking the time zone as I mentioned in the bug, right?
|
You are right, and thanks for pushing on this. I traced the boundary case: a time of 10:04:59.9996 has second=59 and nanosecond about 999_600_000. The rounding gives 1000 ms, and min(..., 999) caps it to .999, so it formats as 10:04:59.999. The correct round-to-nearest result is 10:05:00.000, which is actually closer to the true value. So the clamp does preserve the wrong value at the boundary instead of carrying, exactly as you describe. My added boundary test asserted that wrong output as if it were correct, which is a mistake. The clamp also cannot fix this on its own, because this shared formatter only receives loose DateComponents and has no anchoring Date or time zone to carry across the second, minute, hour, and day fields, and across a possible DST boundary. The clean place to round is the Date entry point in Date+ISO8601FormatStyle.swift, where we still hold the actual Date. Rounding the time interval to the nearest millisecond before we extract the components lets the calendar perform every carry correctly, including the time-zone recalculation you mentioned in the bug. The DateComponents-only entry point has no Date to anchor that carry, so I think it needs separate handling or an explicit limitation. Does rounding the Date upfront in the Date path match what you had in mind for plumbing the rounding rule into the component calculation? I want to confirm the direction before I rework this, especially given the parse round-trip expectations in the issue. |
|
I think that makes sense. That is also the point where we know what options we are formatting with. For example, if we include fractional seconds at all in the output. Of course this brings up another question... if we do not include other fields, do we also round? Why are milliseconds special to round up to the next second, but presumably we do not round 2026-06-11 13:00:00 to 2026-06-12 if we do not include time? |
We definitely should not round 2026-06-11 13:00:00 to 2026-06-12 |
I agree -- I'd just like to make sure we're clear somewhere about expected behavior. I guess we don't support omitting minutes, so really this only applies to the milliseconds field (and how that propagates up to the other units as we discussed). Rounding at the very top before we calculate components does make sense, I think. |
The earlier fix rounded the milliseconds field inside the shared DateComponents formatter and clamped to 999. As parkera pointed out, the clamp preserves a wrong value at the second boundary: a time like 10:04:59.9996 formatted as 10:04:59.999 instead of carrying to 10:05:00.000. Round at the Date entry point instead. Before extracting components, when fractional seconds are included, round the date to the nearest millisecond. The calendar then carries any overflow correctly across seconds, minutes, hours, days, and the time zone boundary. The extracted nanosecond is snapped onto the nearest millisecond to undo the Double fraction error, so the shared formatter can go back to truncating. This scopes the rounding to the case it actually fixes. Rounding happens only when fractional seconds are in the output, at the finest included unit. Coarser fields keep truncating, so date-only and whole-second formatting are unchanged. The bare DateComponents path, which has no anchoring Date and cannot carry, keeps the original truncating behavior with no clamp. Tests cover the second-boundary carry, a DST spring-forward carry, a per-millisecond round-trip sweep, and a parse-then-format round-trip. The existing rounding test is updated to assert the correct carry.
|
Good question, and I think it points at the right rule. The principle is to round at the finest unit we actually include, applied to the remainder we are about to drop. With fractional seconds at millisecond precision, the sub-millisecond part is that remainder, so rounding to the nearest millisecond is rounding the included precision. By the same logic whole seconds would round the fractional-second remainder. The date-to-next-day case is the same rule taken to the coarse end: if we only format a date, the time of day is the dropped remainder, and rounding would push 13:00:00 to the next day. That is almost certainly not what anyone wants from ISO8601, which tells me rounding coarser fields is not desirable as a general behavior. So I scoped it that way: I moved the rounding to the Date entry point and it now rounds only when fractional seconds are included in the output, letting the calendar carry across seconds and the time zone correctly. Coarser fields keep truncating exactly as they do today, so date-only and whole-second formatting are unchanged. I also fixed my earlier boundary test, which had asserted the wrong .999 value. If you would rather have one consistent rounding rule across all precisions instead, I am happy to switch, this just keeps the change to the case it actually fixes. |
|
I'm not sure if the problem is rounding itself. The confusion stems from the fact that you start by initiating a time as .123 seconds, which you think it's .123000, but it's in fact represented as .122999. In this case truncated would indeed be wrong, and rounding gives the expected behavior ".123" But in the case of 59.9996... seconds, in my opinion truncating is right, which would mean it'd be "59.999" instead of carrying it over to the next minute |
Date.ISO8601FormatStylewithincludingFractionalSecondsreports the milliseconds field one millisecond too low for many values. For example:The same off-by-one drops the last millisecond for a large fraction of inputs at present-day magnitudes, e.g.
.001formats as.000and.011formats as.010.Root cause: the milliseconds field is derived from the nanosecond component by truncating toward zero.
A
Datebuilt from a fractionalTimeIntervalat a current-date magnitude cannot represent the value exactly, and the nanosecond the calendar extracts lands just below the intended value. For the example above the nanosecond component is122999906rather than123000000, so truncating gives122. This only shows up at large magnitudes: the existing665076946.011test case happens to land on a nanosecond at or above11000000, so truncation still produced.011there, which is why it was not caught.The fix rounds to the nearest millisecond instead of truncating, and clamps the result to
999so a value that would round up to1000does not overflow the three-digit field. That clamp keeps the behavior already asserted by the existingrounding()test, where15:35:45.9999formats as15:35:45.999.Parsing is unaffected: it already maps
.123back to123000000ns exactly, so the formatted output now matches what the parser expects for the displayable resolution.Verification: added a regression test that fails before this change and passes after. It checks the reported
.123case, asserts that every millisecond0..<1000from a present-day base round-trips through the formatted string, and checks the1000overflow clamp. The fullISO8601FormatStyle,Date.FormatStyle, and HTTP format style suites pass (the only failure in the widerFoundationEssentialsTestsrun isfileExtendedAttributes, which also fails on a clean checkout and is unrelated).Fixes #963.
Happy to retarget this at an active release branch if that fits the merge-forward strategy better.