Skip to main content

hf_chat_template/
clock.rs

1//! `strftime_now` support. Templates (Llama-3.1+, Command-R) call `strftime_now("%d %B %Y")`
2//! to stamp the current date into system prompts. We expose a [`Clock`] trait so tests can
3//! pin the date for byte-stable golden output, and ship a dependency-free default.
4//!
5//! The strftime implementation is intentionally minimal: it supports the conversion
6//! specifiers that real chat templates actually use. Unknown specifiers are passed through
7//! verbatim (matching neither libc nor Python perfectly — documented, and the corpus is the
8//! check on what's actually needed).
9
10/// Supplies "now", formatted per a strftime-style format string. Injectable for determinism.
11pub trait Clock: Send + Sync {
12    /// Format the current instant per the given strftime `format`.
13    fn strftime(&self, format: &str) -> String;
14}
15
16/// Civil date+time broken into fields. Internal building block shared by the clocks.
17#[derive(Clone, Copy, Debug)]
18pub(crate) struct Civil {
19    pub year: i64,
20    pub month: u32, // 1..=12
21    pub day: u32,   // 1..=31
22    pub hour: u32,
23    pub min: u32,
24    pub sec: u32,
25    pub weekday: u32, // 0=Sunday .. 6=Saturday
26    pub yday: u32,    // 1..=366
27}
28
29const MONTHS_FULL: [&str; 12] = [
30    "January",
31    "February",
32    "March",
33    "April",
34    "May",
35    "June",
36    "July",
37    "August",
38    "September",
39    "October",
40    "November",
41    "December",
42];
43const MONTHS_ABBR: [&str; 12] = [
44    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
45];
46const DAYS_FULL: [&str; 7] = [
47    "Sunday",
48    "Monday",
49    "Tuesday",
50    "Wednesday",
51    "Thursday",
52    "Friday",
53    "Saturday",
54];
55const DAYS_ABBR: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
56
57/// Convert days since the Unix epoch (1970-01-01) to a civil (year, month, day) using
58/// Howard Hinnant's well-known algorithm. Valid across the full practical range.
59pub(crate) fn civil_from_days(z: i64) -> (i64, u32, u32) {
60    let z = z + 719_468;
61    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
62    let doe = z - era * 146_097; // [0, 146096]
63    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
64    let y = yoe + era * 400;
65    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
66    let mp = (5 * doy + 2) / 153; // [0, 11]
67    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
68    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
69    let year = if m <= 2 { y + 1 } else { y };
70    (year, m, d)
71}
72
73/// Is `year` a leap year (proleptic Gregorian)?
74fn is_leap(year: i64) -> bool {
75    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
76}
77
78/// Day-of-year (1-based) for a civil date.
79fn day_of_year(year: i64, month: u32, day: u32) -> u32 {
80    const CUM: [u32; 12] = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
81    let mut d = CUM[(month - 1) as usize] + day;
82    if month > 2 && is_leap(year) {
83        d += 1;
84    }
85    d
86}
87
88impl Civil {
89    /// Build civil fields from seconds since the Unix epoch (UTC).
90    pub(crate) fn from_unix_secs(secs: i64) -> Civil {
91        let days = secs.div_euclid(86_400);
92        let tod = secs.rem_euclid(86_400);
93        let (year, month, day) = civil_from_days(days);
94        // 1970-01-01 was a Thursday (weekday index 4 with 0=Sunday).
95        let weekday = ((days.rem_euclid(7) + 4) % 7) as u32;
96        Civil {
97            year,
98            month,
99            day,
100            hour: (tod / 3600) as u32,
101            min: ((tod % 3600) / 60) as u32,
102            sec: (tod % 60) as u32,
103            weekday,
104            yday: day_of_year(year, month, day),
105        }
106    }
107
108    /// Render this instant with a minimal strftime supporting the specifiers chat templates use.
109    pub(crate) fn strftime(&self, format: &str) -> String {
110        let mut out = String::with_capacity(format.len() + 8);
111        let mut chars = format.chars().peekable();
112        while let Some(c) = chars.next() {
113            if c != '%' {
114                out.push(c);
115                continue;
116            }
117            match chars.next() {
118                Some('Y') => out.push_str(&self.year.to_string()),
119                Some('y') => out.push_str(&format!("{:02}", self.year.rem_euclid(100))),
120                Some('m') => out.push_str(&format!("{:02}", self.month)),
121                Some('d') => out.push_str(&format!("{:02}", self.day)),
122                Some('e') => out.push_str(&format!("{:2}", self.day)),
123                Some('B') => out.push_str(MONTHS_FULL[(self.month - 1) as usize]),
124                Some('b') | Some('h') => out.push_str(MONTHS_ABBR[(self.month - 1) as usize]),
125                Some('A') => out.push_str(DAYS_FULL[self.weekday as usize]),
126                Some('a') => out.push_str(DAYS_ABBR[self.weekday as usize]),
127                Some('j') => out.push_str(&format!("{:03}", self.yday)),
128                Some('H') => out.push_str(&format!("{:02}", self.hour)),
129                Some('I') => {
130                    let h12 = match self.hour % 12 {
131                        0 => 12,
132                        h => h,
133                    };
134                    out.push_str(&format!("{:02}", h12));
135                }
136                Some('M') => out.push_str(&format!("{:02}", self.min)),
137                Some('S') => out.push_str(&format!("{:02}", self.sec)),
138                Some('p') => out.push_str(if self.hour < 12 { "AM" } else { "PM" }),
139                Some('%') => out.push('%'),
140                // Unknown specifier: emit verbatim (`%X`), so we don't silently corrupt.
141                Some(other) => {
142                    out.push('%');
143                    out.push(other);
144                }
145                None => out.push('%'),
146            }
147        }
148        out
149    }
150}
151
152/// Real wall-clock (UTC). Dependency-free: reads `SystemTime` and does the calendar math here.
153///
154/// Note: this is UTC, not local time. transformers uses local time via Python's `datetime.now()`.
155/// For reproducible/server use UTC is usually preferable; use [`LocalClock`] (the `strftime`
156/// feature) to match Python's local-time behavior, or pin a [`FixedClock`] to match a specific
157/// reference exactly.
158#[derive(Clone, Copy, Debug, Default)]
159pub struct SystemClock;
160
161impl Clock for SystemClock {
162    fn strftime(&self, format: &str) -> String {
163        let secs = std::time::SystemTime::now()
164            .duration_since(std::time::UNIX_EPOCH)
165            .map(|d| d.as_secs() as i64)
166            .unwrap_or(0);
167        Civil::from_unix_secs(secs).strftime(format)
168    }
169}
170
171/// A clock pinned to a fixed Unix timestamp — for deterministic golden tests.
172#[derive(Clone, Copy, Debug)]
173pub struct FixedClock {
174    unix_secs: i64,
175}
176
177impl FixedClock {
178    /// Pin to a specific number of seconds since the Unix epoch (UTC).
179    pub fn from_unix_secs(unix_secs: i64) -> Self {
180        FixedClock { unix_secs }
181    }
182
183    /// Pin to a specific civil date at 00:00:00 UTC. Months and days are 1-based.
184    /// Returns `None` for an obviously invalid date.
185    pub fn from_ymd(year: i64, month: u32, day: u32) -> Option<Self> {
186        if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
187            return None;
188        }
189        // days from civil (inverse of civil_from_days), Hinnant.
190        let y = if month <= 2 { year - 1 } else { year };
191        let era = if y >= 0 { y } else { y - 399 } / 400;
192        let yoe = y - era * 400;
193        let mp = if month > 2 { month - 3 } else { month + 9 } as i64;
194        let doy = (153 * mp + 2) / 5 + day as i64 - 1;
195        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
196        let days = era * 146_097 + doe - 719_468;
197        Some(FixedClock {
198            unix_secs: days * 86_400,
199        })
200    }
201}
202
203impl Clock for FixedClock {
204    fn strftime(&self, format: &str) -> String {
205        Civil::from_unix_secs(self.unix_secs).strftime(format)
206    }
207}
208
209/// Real wall-clock in the **local** timezone, matching Python's `datetime.now()` (which is what
210/// `transformers` uses for `strftime_now`). Requires the `strftime` feature.
211///
212/// [`SystemClock`] is UTC and dependency-free; this reads the local timezone offset via `chrono`
213/// (the only thing we borrow from it — formatting still goes through our own strftime, so the
214/// supported specifiers and their output are identical to the other clocks). Construct it and pass
215/// it to [`ChatTemplateBuilder::clock`](crate::ChatTemplateBuilder::clock) when you need rendered
216/// dates to match what `transformers` would emit on the same machine:
217///
218/// ```
219/// # #[cfg(feature = "strftime")] {
220/// use hf_chat_template::{ChatTemplate, LocalClock};
221/// let tmpl = ChatTemplate::builder("Today: {{ strftime_now('%Y') }}")
222///     .clock(LocalClock)
223///     .build()
224///     .unwrap();
225/// # }
226/// ```
227#[cfg(feature = "strftime")]
228#[derive(Clone, Copy, Debug, Default)]
229pub struct LocalClock;
230
231#[cfg(feature = "strftime")]
232impl Clock for LocalClock {
233    fn strftime(&self, format: &str) -> String {
234        use chrono::{Datelike, Local, Timelike};
235        let now = Local::now();
236        let civil = Civil {
237            year: now.year() as i64,
238            month: now.month(),
239            day: now.day(),
240            hour: now.hour(),
241            min: now.minute(),
242            sec: now.second(),
243            // chrono: 0 = Sunday .. 6 = Saturday, matching our Civil convention.
244            weekday: now.weekday().num_days_from_sunday(),
245            yday: now.ordinal(), // chrono ordinal is 1-based, as is Civil::yday.
246        };
247        civil.strftime(format)
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn fixed_clock_formats_a_known_date() {
257        // 2024-07-04 00:00:00 UTC. Exercises the specifiers Granite-style templates use.
258        let clk = FixedClock::from_ymd(2024, 7, 4).unwrap();
259        assert_eq!(clk.strftime("%B %d, %Y"), "July 04, 2024");
260        assert_eq!(clk.strftime("%Y-%m-%d"), "2024-07-04");
261        assert_eq!(clk.strftime("%A"), "Thursday");
262    }
263
264    // LocalClock delegates to the local timezone; verify the wiring reads local wall time and maps
265    // chrono's fields onto Civil correctly (not, say, UTC or an off-by-one weekday/ordinal). We
266    // compare the date-portion against a fresh chrono::Local::now() taken in the test; the only
267    // race is the midnight boundary, which we tolerate by allowing either side of it.
268    #[cfg(feature = "strftime")]
269    #[test]
270    fn local_clock_reads_local_time() {
271        use chrono::{Datelike, Local};
272        let got = LocalClock.strftime("%Y-%m-%d");
273        let now = Local::now();
274        let same = format!("{:04}-{:02}-{:02}", now.year(), now.month(), now.day());
275        let prev = (now - chrono::Duration::days(1)).date_naive();
276        let prev = format!("{:04}-{:02}-{:02}", prev.year(), prev.month(), prev.day());
277        assert!(
278            got == same || got == prev,
279            "LocalClock date {got} matched neither {same} nor {prev}"
280        );
281    }
282}