Skip to main content

wavekat_flow/
book.rs

1//! The `book` component (schema_version 2): its bounds, and the
2//! vocabulary that lets a caller be told a time.
3//!
4//! # Why a vocabulary exists at all
5//!
6//! Every sound a flow makes is a clip. The daemon runs no text-to-speech:
7//! prompts are rendered when the flow is published and frozen as version
8//! assets, and playback is a file read. That is what makes a flow work
9//! with no network, and it is why `book` cannot simply "say the time" —
10//! the time is not known until a caller is on the line, hours or days
11//! after the last thing was rendered.
12//!
13//! So the times a flow can *ever* offer are enumerated at publish and
14//! rendered then, exactly like its prompts. Two things make that a small
15//! finite set rather than an impossible one: starts snap to a half hour
16//! ([`BOOK_GRANULARITY_MINS`]), and the node already declares the only
17//! hours it books in. A business open 9–5 on weekdays therefore needs 16
18//! time clips, not 1440 — and needs them regardless of which week the
19//! caller rings in, because "nine thirty" is the same two words on every
20//! one of those days.
21//!
22//! # Why it is split day + time
23//!
24//! An utterance is two clips: a day phrase, then a time phrase
25//! ("Tuesday", then "ten thirty a.m."). One clip per (day, time) pair
26//! multiplies the render count by nine for no gain. Going finer the
27//! other way — composing "ten", "thirty" and "a.m." separately — is what
28//! a naive reading suggests and is wrong: word order and grammar differ across
29//! the languages this platform ships, and an engine concatenating
30//! fragments in English order produces something between odd and
31//! unintelligible elsewhere. Whole phrases keep each language's grammar
32//! inside the phrase, where a translator can see it; day-before-time is
33//! the one ordering assumption left, and it holds across all of them.
34//!
35//! # The contract
36//!
37//! These refs are the contract between the two sides: the platform
38//! renders exactly this set at publish and stores it with the version's
39//! frozen assets; the daemon plays them and never asks what they mean.
40//! Both sides compute the set from the same node config with
41//! [`vocabulary_refs`], so neither can render one thing and expect
42//! another. The refs join [`crate::model_ext`]'s `required_assets`,
43//! which puts them behind the daemon's existing "don't arm a flow whose
44//! audio hasn't synced" gate with no new machinery.
45//!
46//! Twin: `packages/flow-schema/src/book.ts`. Keep the two in lockstep —
47//! the conformance corpus pins the ref sets they produce.
48
49use std::collections::BTreeSet;
50
51use time::OffsetDateTime;
52use time_tz::{OffsetDateTimeExt, Tz};
53
54use crate::model::{Node, TimeRange};
55
56// ── Defaults (mirroring the schema's own, which typify reads) ───────────
57
58pub const DEFAULT_BOOK_BUFFER_MINS: u64 = 0;
59pub const DEFAULT_BOOK_LEAD_MINS: u64 = 120;
60pub const DEFAULT_BOOK_HORIZON_DAYS: u64 = 14;
61pub const DEFAULT_BOOK_MAX_OFFERS: u64 = 3;
62pub const DEFAULT_BOOK_RETRIES: u64 = 1;
63pub const DEFAULT_BOOK_TIMEOUT_SECS: u64 = 5;
64
65// ── Bounds (the validator's, per "schema owns shape, validators own
66//    semantics" — see CLAUDE.md) ───────────────────────────────────────
67
68/// Shortest and longest one appointment may run.
69pub const MIN_BOOK_DURATION_MINS: u64 = 5;
70pub const MAX_BOOK_DURATION_MINS: u64 = 480;
71/// Widest clear time that may be kept on each side of an appointment.
72pub const MAX_BOOK_BUFFER_MINS: u64 = 240;
73/// Furthest ahead a caller may be pushed before the first offer (30 days).
74pub const MAX_BOOK_LEAD_MINS: u64 = 60 * 24 * 30;
75/// Furthest ahead a `book` node may look.
76pub const MAX_BOOK_HORIZON_DAYS: u64 = 31;
77/// How many times one caller may be offered — bounded by the keypad
78/// digits the vocabulary carries.
79pub const MAX_BOOK_OFFERS: u64 = 5;
80
81/// Every candidate appointment start lands on a half hour.
82///
83/// Load-bearing, not cosmetic: this is what bounds the vocabulary.
84/// Twin: the platform's `SLOT_GRANULARITY_MINS`, which must agree — if
85/// the server offers a time the vocabulary has no clip for, the caller
86/// hears silence where the time should be.
87///
88/// **Narrowing this is not a free change.** [`required_assets`] is
89/// computed from the value compiled into *this* build, not from anything
90/// a version carries — so a daemon still on the quarter hour, handed a
91/// version published against a narrower grid, asks for `bktime_0915`,
92/// does not find it, and refuses to arm the flow at all.
93///
94/// That does not make the change wait on the fleet, which never fully
95/// updates. A renderer covers the devices it must serve by rendering the
96/// union of every grid still installed; [`vocabulary_refs_on`] is how it
97/// enumerates the members it no longer compiles in. See the platform's
98/// docs/36.
99///
100/// [`required_assets`]: crate::model_ext::required_assets
101pub const BOOK_GRANULARITY_MINS: u64 = 30;
102
103// ── Vocabulary refs ────────────────────────────────────────────────────
104
105/// Prefix every vocabulary ref carries, so nothing collides with a
106/// platform-generated clip ref (`vprompt_…`) or an author's own file.
107const PREFIX: &str = "bk";
108
109/// The day phrases, always required. `today`/`tomorrow` are how a person
110/// says a date this close to now, and a caller told "Monday" on a Monday
111/// has to work out which Monday.
112pub const BOOK_DAY_KEYS: &[&str] = &[
113    "today", "tomorrow", "mon", "tue", "wed", "thu", "fri", "sat", "sun",
114];
115
116/// `bkday_tue` — the day half of an utterance.
117pub fn day_ref(day: &str) -> String {
118    format!("{PREFIX}day_{day}")
119}
120
121/// The day key for a slot, given how many civil days away it falls and
122/// which weekday it lands on (0 = Monday, matching [`BOOK_DAY_KEYS`]'s
123/// weekday order). Today and tomorrow win over the weekday name.
124pub fn day_key(days_ahead: i64, weekday_from_monday: usize) -> &'static str {
125    match days_ahead {
126        0 => "today",
127        1 => "tomorrow",
128        _ => BOOK_DAY_KEYS[2 + (weekday_from_monday % 7)],
129    }
130}
131
132/// `bktime_0930` — the time half, in the flow's own timezone.
133pub fn time_ref(minutes_of_day: u64) -> String {
134    format!(
135        "{PREFIX}time_{:02}{:02}",
136        minutes_of_day / 60,
137        minutes_of_day % 60
138    )
139}
140
141/// `bkpress_2` — "press two", the digit that takes the offer.
142pub fn press_ref(digit: u64) -> String {
143    format!("{PREFIX}press_{digit}")
144}
145
146/// The one fixed line the component speaks on its own account: the slot
147/// the caller chose was taken between hearing it and pressing the key.
148/// Everything else a caller hears is either the author's prompt or a time.
149pub fn taken_ref() -> String {
150    format!("{PREFIX}taken")
151}
152
153/// What a vocabulary ref means — so the side that *renders* it can look
154/// up the words without re-deriving the ref format.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum VocabularyRef {
157    Day { day: String },
158    Time { hour: u64, minute: u64 },
159    Press { digit: u64 },
160    Taken,
161}
162
163/// Read a ref back into what it says. `None` for anything that is not a
164/// vocabulary ref (an author's clip, a filename), so a caller can filter
165/// a mixed asset list with it.
166pub fn parse_vocabulary_ref(reference: &str) -> Option<VocabularyRef> {
167    if reference == taken_ref() {
168        return Some(VocabularyRef::Taken);
169    }
170    if let Some(day) = reference.strip_prefix(&format!("{PREFIX}day_")) {
171        return BOOK_DAY_KEYS.contains(&day).then(|| VocabularyRef::Day {
172            day: day.to_string(),
173        });
174    }
175    if let Some(digits) = reference.strip_prefix(&format!("{PREFIX}time_")) {
176        if digits.len() != 4 || !digits.chars().all(|c| c.is_ascii_digit()) {
177            return None;
178        }
179        let hour: u64 = digits[..2].parse().ok()?;
180        let minute: u64 = digits[2..].parse().ok()?;
181        return (hour <= 23 && minute <= 59).then_some(VocabularyRef::Time { hour, minute });
182    }
183    if let Some(digits) = reference.strip_prefix(&format!("{PREFIX}press_")) {
184        let digit: u64 = digits.parse().ok()?;
185        return (1..=MAX_BOOK_OFFERS)
186            .contains(&digit)
187            .then_some(VocabularyRef::Press { digit });
188    }
189    None
190}
191
192// ── Enumerating a node's vocabulary ────────────────────────────────────
193
194/// `"HH:MM"` → minutes since midnight, or `None` if it isn't one.
195/// Lenient in the same direction as the runtime: a range this can't read
196/// is a range that offers nothing, not a document that fails to load.
197fn minutes_of(hhmm: &str) -> Option<u64> {
198    let (h, m) = hhmm.trim().split_once(':')?;
199    if m.len() != 2 || h.is_empty() || h.len() > 2 {
200        return None;
201    }
202    let hours: u64 = h.parse().ok()?;
203    let mins: u64 = m.parse().ok()?;
204    (hours <= 23 && mins <= 59).then_some(hours * 60 + mins)
205}
206
207/// The starts a single open range can produce: on the half-hour grid,
208/// from the first grid point at or after `open`, while the whole
209/// appointment still finishes by `close`.
210///
211/// Mirrors the platform's slot walk, including that the walk uses real
212/// instants and so may drop a candidate this keeps on a daylight-saving
213/// boundary. That direction is safe: the vocabulary may be a superset (a
214/// clip nobody plays), never a subset (a time nobody can say).
215/// The same walk on a grid this build does not necessarily use.
216///
217/// A device decides whether it can run a flow by computing the required
218/// set from the grid compiled into *it*, so a renderer that knows only
219/// its own can serve only devices that agree. Rendering the union of
220/// every grid still installed is what removes that coupling, and this is
221/// how the other members are enumerated. See the platform's docs/36.
222fn starts_in_range_on(range: &TimeRange, duration_mins: u64, granularity_mins: u64) -> Vec<u64> {
223    // Stepping by zero would never terminate — on a device, that is a
224    // hang rather than a wrong answer.
225    if granularity_mins == 0 {
226        return Vec::new();
227    }
228    let (Some(open), Some(close)) = (minutes_of(&range.open), minutes_of(&range.close)) else {
229        return Vec::new();
230    };
231    if close <= open {
232        return Vec::new();
233    }
234    let first = open.div_ceil(granularity_mins) * granularity_mins;
235    let mut starts = Vec::new();
236    let mut mins = first;
237    while mins < close {
238        if mins + duration_mins <= close {
239            starts.push(mins);
240        }
241        mins += granularity_mins;
242    }
243    starts
244}
245
246/// Every asset ref a `book` node needs in order to speak: the nine day
247/// phrases, one clip per bookable time of day, one "press N" per offer
248/// it may make, and the taken line. Sorted and unique, so two callers of
249/// this compare equal.
250///
251/// Reads only the node's own config, never a clock — the answer must be
252/// the same at publish (when it is rendered) and on a call months later
253/// (when it is played). Empty for every other kind of node, so a caller
254/// can map it over a whole flow.
255pub fn vocabulary_refs(node: &Node) -> Vec<String> {
256    vocabulary_refs_on(node, BOOK_GRANULARITY_MINS)
257}
258
259/// The same set on a grid this build does not necessarily use.
260///
261/// Twin of `bookVocabularyRefs(node, { granularityMins })`. Only a
262/// *renderer* has a reason to call this: it has to satisfy devices whose
263/// compiled-in grid differs from its own, and covering the union of the
264/// grids still installed is what stops a narrowing change from waiting on
265/// a fleet that never fully updates. A device answering "can I run this
266/// flow?" uses [`vocabulary_refs`] and its own constant, which is the
267/// question it is actually being asked. See the platform's docs/36.
268pub fn vocabulary_refs_on(node: &Node, granularity_mins: u64) -> Vec<String> {
269    let Node::Book {
270        schedule,
271        exceptions,
272        duration_mins,
273        max_offers,
274        ..
275    } = node
276    else {
277        return Vec::new();
278    };
279
280    let mut refs: BTreeSet<String> = BTreeSet::new();
281
282    for day in BOOK_DAY_KEYS {
283        refs.insert(day_ref(day));
284    }
285    refs.insert(taken_ref());
286
287    for digit in 1..=(*max_offers).min(MAX_BOOK_OFFERS) {
288        refs.insert(press_ref(digit));
289    }
290
291    // A duration outside the bounds is a document the validator rejects;
292    // clamping keeps a bad draft from asking for an unbounded render
293    // while the editor is still showing the error.
294    let duration = duration_mins.clamp(&MIN_BOOK_DURATION_MINS, &MAX_BOOK_DURATION_MINS);
295
296    let weekly = [
297        &schedule.mon,
298        &schedule.tue,
299        &schedule.wed,
300        &schedule.thu,
301        &schedule.fri,
302        &schedule.sat,
303        &schedule.sun,
304    ];
305    let mut ranges: Vec<&TimeRange> = weekly.iter().flat_map(|day| day.iter()).collect();
306    for exception in exceptions {
307        // A closed day offers nothing; its `ranges`, if any, are ignored
308        // by the runtime too.
309        if exception.closed {
310            continue;
311        }
312        ranges.extend(exception.ranges.iter());
313    }
314
315    for range in ranges {
316        for start in starts_in_range_on(range, *duration, granularity_mins) {
317            refs.insert(time_ref(start));
318        }
319    }
320
321    refs.into_iter().collect()
322}
323
324// ── Saying one time ────────────────────────────────────────────────────
325
326/// The clips that say one appointment time, in order: the day phrase
327/// then the time phrase ("Tuesday" → "ten thirty a.m.").
328///
329/// `start` is an absolute instant — the server answers in UTC — so the
330/// business's own zone is what turns it back into the words a caller
331/// expects; a 9 a.m. appointment must not be announced as 21:00 because
332/// the platform happens to run in UTC. `now` decides only whether the
333/// day is worth naming: "Tuesday" is a poor way to say tomorrow.
334pub fn time_refs(start: OffsetDateTime, now: OffsetDateTime, tz: &Tz) -> Vec<String> {
335    let local = start.to_timezone(tz);
336    let today = now.to_timezone(tz).date();
337    let days_ahead = (local.date() - today).whole_days();
338    let minutes = u64::from(local.hour()) * 60 + u64::from(local.minute());
339    vec![
340        day_ref(day_key(
341            days_ahead,
342            local.date().weekday().number_days_from_monday() as usize,
343        )),
344        time_ref(minutes),
345    ]
346}
347
348/// The clips that offer one time: what it is, then which key takes it.
349pub fn offer_refs(start: OffsetDateTime, now: OffsetDateTime, tz: &Tz, digit: u64) -> Vec<String> {
350    let mut refs = time_refs(start, now, tz);
351    refs.push(press_ref(digit));
352    refs
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use time::macros::datetime;
359
360    #[test]
361    fn grid_starts_leave_room_for_the_whole_appointment() {
362        let range = TimeRange {
363            open: "09:10".into(),
364            close: "10:30".into(),
365        };
366        // First grid point at or after 09:10 is 09:30 — never 09:10, and
367        // no longer 09:15; the last start that still finishes by 10:30
368        // with a 30-minute appointment is 10:00.
369        assert_eq!(
370            starts_in_range_on(&range, 30, BOOK_GRANULARITY_MINS),
371            vec![570, 600]
372        );
373        // An appointment longer than the window produces nothing at all.
374        assert!(starts_in_range_on(&range, 120, BOOK_GRANULARITY_MINS).is_empty());
375    }
376
377    // The twin of `bookVocabularyRefs(node, { granularityMins })`.
378    //
379    // A device answers "can I run this flow?" from the grid compiled into
380    // it, so a renderer that only knows its own can serve only devices
381    // that agree — which makes narrowing wait on the whole fleet
382    // updating, an event that does not occur. This is how a renderer
383    // enumerates the grids it no longer compiles in, so it can cover the
384    // union. See the platform's docs/36.
385    #[test]
386    fn an_explicit_grid_is_walked_instead_of_this_builds() {
387        let range = TimeRange {
388            open: "09:00".into(),
389            close: "11:00".into(),
390        };
391        // Quarter hours, from a build whose own constant says thirty.
392        assert_eq!(
393            starts_in_range_on(&range, 30, 15),
394            vec![540, 555, 570, 585, 600, 615, 630]
395        );
396        // This build's own grid, for contrast — the halves of the same
397        // window, and what every existing caller keeps getting.
398        assert_eq!(
399            starts_in_range_on(&range, 30, BOOK_GRANULARITY_MINS),
400            vec![540, 570, 600, 630]
401        );
402    }
403
404    #[test]
405    fn a_finer_grid_is_a_superset_of_a_coarser_one() {
406        // The property the union rests on: widening never drops a ref, so
407        // a device on the finer grid finds everything it computes inside
408        // what a renderer covering both froze.
409        let range = TimeRange {
410            open: "09:00".into(),
411            close: "17:00".into(),
412        };
413        let fine = starts_in_range_on(&range, 30, 15);
414        for start in starts_in_range_on(&range, 30, 30) {
415            assert!(fine.contains(&start), "{start} missing from the finer grid");
416        }
417    }
418
419    #[test]
420    fn a_zero_grid_yields_nothing_rather_than_spinning() {
421        // Unreachable through `vocabulary_refs`, which passes a constant.
422        // Asserted anyway because the loop steps by this value, and the
423        // failure would be a hung device rather than a wrong answer.
424        let range = TimeRange {
425            open: "09:00".into(),
426            close: "17:00".into(),
427        };
428        assert!(starts_in_range_on(&range, 30, 0).is_empty());
429    }
430
431    #[test]
432    fn unreadable_ranges_offer_nothing_rather_than_failing() {
433        let backwards = TimeRange {
434            open: "17:00".into(),
435            close: "09:00".into(),
436        };
437        assert!(starts_in_range_on(&backwards, 30, BOOK_GRANULARITY_MINS).is_empty());
438        let nonsense = TimeRange {
439            open: "nine".into(),
440            close: "five".into(),
441        };
442        assert!(starts_in_range_on(&nonsense, 30, BOOK_GRANULARITY_MINS).is_empty());
443    }
444
445    #[test]
446    fn refs_round_trip_through_their_meaning() {
447        assert_eq!(
448            parse_vocabulary_ref(&time_ref(9 * 60 + 30)),
449            Some(VocabularyRef::Time {
450                hour: 9,
451                minute: 30
452            })
453        );
454        assert_eq!(
455            parse_vocabulary_ref(&day_ref("tue")),
456            Some(VocabularyRef::Day { day: "tue".into() })
457        );
458        assert_eq!(
459            parse_vocabulary_ref(&press_ref(2)),
460            Some(VocabularyRef::Press { digit: 2 })
461        );
462        assert_eq!(
463            parse_vocabulary_ref(&taken_ref()),
464            Some(VocabularyRef::Taken)
465        );
466
467        // Not vocabulary: an author's clip, and refs that look like one
468        // but say nothing sayable.
469        assert_eq!(parse_vocabulary_ref("vprompt_ab12cd34"), None);
470        assert_eq!(parse_vocabulary_ref("bkday_funday"), None);
471        assert_eq!(parse_vocabulary_ref("bktime_2599"), None);
472        assert_eq!(parse_vocabulary_ref("bkpress_9"), None);
473    }
474
475    #[test]
476    fn day_key_prefers_today_and_tomorrow_over_the_weekday_name() {
477        assert_eq!(day_key(0, 3), "today");
478        assert_eq!(day_key(1, 4), "tomorrow");
479        assert_eq!(day_key(2, 0), "mon");
480        assert_eq!(day_key(6, 6), "sun");
481    }
482
483    #[test]
484    fn a_time_is_said_in_the_businesss_zone_not_the_servers() {
485        let tz = crate::hours::resolve_tz("America/New_York").unwrap();
486        // 14:30 UTC on Tuesday 7 July 2026 is 10:30 EDT the same day.
487        let start = datetime!(2026-07-07 14:30 UTC);
488        let now = datetime!(2026-07-06 12:00 UTC); // Monday: the slot is tomorrow
489        assert_eq!(
490            time_refs(start, now, tz),
491            vec!["bkday_tomorrow".to_string(), "bktime_1030".to_string()],
492        );
493
494        // Same instant, a week earlier in the caller's life: now it needs
495        // its weekday name.
496        let earlier = datetime!(2026-07-01 12:00 UTC);
497        assert_eq!(
498            time_refs(start, earlier, tz),
499            vec!["bkday_tue".to_string(), "bktime_1030".to_string()],
500        );
501    }
502
503    #[test]
504    fn a_late_utc_instant_can_be_the_previous_local_day() {
505        let tz = crate::hours::resolve_tz("America/New_York").unwrap();
506        // 01:00 UTC Wednesday is 21:00 EDT Tuesday — the local date, and
507        // therefore the day word, belongs to Tuesday.
508        let start = datetime!(2026-07-08 01:00 UTC);
509        let now = datetime!(2026-07-07 13:00 UTC); // Tuesday morning, local
510        assert_eq!(
511            time_refs(start, now, tz),
512            vec!["bkday_today".to_string(), "bktime_2100".to_string()],
513        );
514    }
515
516    #[test]
517    fn an_offer_ends_with_the_key_that_takes_it() {
518        let tz = crate::hours::resolve_tz("UTC").unwrap();
519        let start = datetime!(2026-07-07 09:30 UTC);
520        let now = datetime!(2026-07-07 08:00 UTC);
521        assert_eq!(
522            offer_refs(start, now, tz, 2),
523            vec![
524                "bkday_today".to_string(),
525                "bktime_0930".to_string(),
526                "bkpress_2".to_string()
527            ],
528        );
529    }
530}