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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Fully filled in up to 30c187f4b7
- Deprecate `Date::new_from_iso`/`Date::to_iso` (unicode-org#7287)
- Optimize Hebrew and Julian calendars (unicode-org#7213)
- Optimize day/week diffing to use RDs (unicode-org#7308)
- Optimize `until` month and day calculation performance
- `icu_casemap`
- General changes only
- `icu_collections`
Expand Down
4 changes: 2 additions & 2 deletions components/calendar/benches/until.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ fn bench_calendar<C: Copy + Calendar>(
}
}

fn convert_benches(c: &mut Criterion) {
fn until_benches(c: &mut Criterion) {
let mut group = c.benchmark_group("until/years");
bench_all_calendars!(group, bench_calendar, DateDurationUnit::Years);
group.finish();
Expand All @@ -55,5 +55,5 @@ fn convert_benches(c: &mut Criterion) {
group.finish();
}

criterion_group!(benches, convert_benches);
criterion_group!(benches, until_benches);
criterion_main!(benches);
5 changes: 5 additions & 0 deletions components/calendar/src/cal/coptic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ impl DateFieldsResolver for Coptic {
13
}

#[inline]
fn min_months_from_inner(_start: Self::YearInfo, years: i64) -> i64 {
13 * years
}

#[inline]
fn extended_year_from_era_year_unchecked(
&self,
Expand Down
7 changes: 7 additions & 0 deletions components/calendar/src/cal/east_asian_traditional.rs
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,13 @@ impl<R: Rules> DateFieldsResolver for EastAsianTraditional<R> {
12 + year.packed.leap_month().is_some() as u8
}

#[inline]
fn min_months_from_inner(_start: Self::YearInfo, years: i64) -> i64 {
// A lunisolar leap month is inserted at least every 3 years. Reingold denies
// that the Chinese calendar determines leap years using the 19-year Metonic cycle.
12 * years + (years / 3)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thought: This is much better, but we could in principle be even tighter. The 19-year bound is too small, but there should be something between that and what you have here. It would be nice to leave open a low-priority issue to investigate and implement the theoretical minimal bound.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was working on that math earlier but did not finish it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are a two issues with this:

  • EastAsianTraditional is generic in R: Rules, and the rules can do whatever they want. We either need to add a requirement that the rules insert a leap month at least every three years, or delegate this logic to the Rules implementation
  • Even for our concrete calendars, I'm not sure that this is correct. Both China and Korea use three different rules (1900-1912, 1912-2050/2100, simple approximation otherwise), so this needs to be shown to hold for all three (and for the transitions). For the hardcoded data we can write a test that this holds, but for the simple approximation I would like to see some kind of reasoning.

}

#[inline]
fn extended_year_from_era_year_unchecked(
&self,
Expand Down
5 changes: 5 additions & 0 deletions components/calendar/src/cal/ethiopian.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ impl DateFieldsResolver for Ethiopian {
Coptic::months_in_provided_year(year)
}

#[inline]
fn min_months_from_inner(start: Self::YearInfo, years: i64) -> i64 {
Coptic::min_months_from_inner(start, years)
}

#[inline]
fn extended_year_from_era_year_unchecked(
&self,
Expand Down
6 changes: 6 additions & 0 deletions components/calendar/src/cal/hebrew.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ impl DateFieldsResolver for Hebrew {
12 + year.keviyah.is_leap() as u8
}

#[inline]
fn min_months_from_inner(_start: HebrewYear, years: i64) -> i64 {
// There are 7 leap years in every 19-year Metonic cycle.
235 * years / 19
Comment on lines +139 to +140
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Explain why 235 * years / 19 is a lower bound. I understand that it is the average. and you are flooring the value. We end up with:

# of years min months
1 12
2 24
3 37 (+1 leap month)
4 49
5 61
6 74 (+1 leap month)
7 86
8 98
9 111 (+1 leap month)
10 123
11 136 (+1 leap month)
12 148
13 160
14 173 (+1 leap month)
15 185
16 197
17 210 (+1 leap month)
18 222
19 235 (+1 leap month)

Note: the Hebrew metonic cycle has leap years in 3, 6, 8, 11, 14, 17, and 19, i.e., leap year gaps of +3, +3, +2, +3, +3, +3, +2.

I think your formula is the theoretical minimum bound. I think, no matter where I start in the metonic cycle, the number of months I get from your formula is always less than or equal to the actual number of months.

For example, if I start in year 3 month M01 and add 5 years, I need to cross over 2 leap months, one in year 3 and one in year 6. Your formula agrees.

The "worst case" is I think month M06 in year 8 to year 18, a span that has only 3 leap months. That is a 10-year difference, and your formula correctly predicts that there are a minimum of 3 leap months during that span.

Please try to summarize why your bound is the theoretical minimum bound in a concise and compelling way.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do have the start of the hustification written in #7739 (comment)

Copy link
Contributor Author

@poulsbo poulsbo Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I know this PR is closed/merged, but this seems like the right place for this comment.)

My original plan was to use the start year parameter to tell exactly where we were in the cycle, using the $7y + 1 \mod{19} &lt; 7$ rule (Reingold 8.14). Then we could give an exact answer. But during implementation I kept breaking tests. I think I wasn't handling years < 0 correctly.

}

#[inline]
fn extended_year_from_era_year_unchecked(
&self,
Expand Down
31 changes: 22 additions & 9 deletions components/calendar/src/calendar_arithmetic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,14 @@ pub(crate) trait DateFieldsResolver: Calendar {
12
}

/// The minimum number of months over `years` years, starting from the given year.
///
/// The default impl is for non-lunisolar calendars with 12 months!
#[inline]
fn min_months_from_inner(_start: Self::YearInfo, years: i64) -> i64 {
12 * years
}

/// Calculates the ordinal month for the given year and month code.
///
/// The default impl is for non-lunisolar calendars!
Expand Down Expand Up @@ -902,7 +910,7 @@ impl<C: DateFieldsResolver> ArithmeticDate<C> {

/// Implements the Temporal abstract operation `NonISODateUntil`.
///
/// This takes a duration (`self`) and a date (`other`), then returns a duration that, when
/// This takes two dates (`self` and `other`), then returns a duration that, when
/// added to `self`, results in `other`, with largest unit according to `options`.
pub(crate) fn until(
&self,
Expand Down Expand Up @@ -992,14 +1000,12 @@ impl<C: DateFieldsResolver> ArithmeticDate<C> {
let mut candidate_months = sign;

if options.largest_unit == Some(DateDurationUnit::Months) && min_years != 0 {
// Optimization: No current calendar supports years with month length < 12.
// If something is at least N full years away, it is also at least 12*N full months away.
//
// In the future we can introduce per-calendar routines that are better at estimating a month count.
//
// We only need to apply this optimization for largest_unit = Months. If the largest_unit is years then
// our candidate date is already pretty close and won't need more than 12 iterations to get there.
let min_months = min_years * 12;
// If largest_unit = Months, then compute the calendar-specific minimum number of
// months corresponding to min_years. For solar calendars, this is 12 * min_years.
// For the Hebrew calendar, a leap month is added for 7 out of 19 years. East Asian
// Calendars do not provide a specialized implementation of `min_months_from()`
// because it would be too expensive to calculate; they default to 12 * min_years.
Comment on lines +1004 to +1007
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: don't really have to list out all the different implementations of min_months_from_inner, this will just become stale

let min_months = self.min_months_from(min_years);
debug_assert!(!self.surpasses(
other,
DateDuration::from_signed_ymwd(years, min_months, 0, 0),
Expand Down Expand Up @@ -1038,6 +1044,7 @@ impl<C: DateFieldsResolver> ArithmeticDate<C> {
candidate_weeks += sign;
}
}

// 1. Let _days_ be 0.
// 1. Let _candidateDays_ be _sign_.
// 1. Repeat, while NonISODateSurpasses(_calendar_, _sign_, _one_, _two_, _years_, _months_, _weeks_, _candidateDays_) is *false*,
Expand All @@ -1057,9 +1064,15 @@ impl<C: DateFieldsResolver> ArithmeticDate<C> {
days = candidate_days;
candidate_days += sign;
}

// 1. Return ! CreateDateDurationRecord(_years_, _months_, _weeks_, _days_).
DateDuration::from_signed_ymwd(years, months, weeks, days)
}

/// The minimum number of months over `years` years, starting from `self.year()`.
pub(crate) fn min_months_from(self, years: i64) -> i64 {
C::min_months_from_inner(self.year(), years)
}
}

#[cfg(test)]
Expand Down
2 changes: 1 addition & 1 deletion components/calendar/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ mod unstable {
/// let mut options_days = options_default;
/// options_days.largest_unit = Some(DateDurationUnit::Days);
/// assert_eq!(
/// d1.try_until_with_options(&d2, options_default).unwrap(),
/// d1.try_until_with_options(&d2, options_days).unwrap(),
/// DateDuration::for_days(410)
/// );
///
Expand Down