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
33 changes: 32 additions & 1 deletion conformance/v2/valid/clinic.expected.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
{
"description": "The worked `book` example: a full booking flow with split opening hours, holiday exceptions, a buffer, and all four exits wired — the two failure exits both landing on voicemail, which is the pattern that keeps a calendar outage from becoming a dead line.",
"structurallyValid": true,
"semantic": { "ok": true, "errors": [] }
"semantic": { "ok": true, "errors": [] },
"requiredAssets": [
"bkday_fri",
"bkday_mon",
"bkday_sat",
"bkday_sun",
"bkday_thu",
"bkday_today",
"bkday_tomorrow",
"bkday_tue",
"bkday_wed",
"bkpress_1",
"bkpress_2",
"bkpress_3",
"bktaken",
"bktime_0900",
"bktime_0930",
"bktime_1000",
"bktime_1030",
"bktime_1100",
"bktime_1130",
"bktime_1200",
"bktime_1230",
"bktime_1300",
"bktime_1330",
"bktime_1400",
"bktime_1430",
"bktime_1500",
"bktime_1530",
"bktime_1600",
"bktime_1630"
]
}
38 changes: 24 additions & 14 deletions crates/wavekat-flow/src/book.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@
//!
//! So the times a flow can *ever* offer are enumerated at publish and
//! rendered then, exactly like its prompts. Two things make that a small
//! finite set rather than an impossible one: starts snap to a quarter
//! hour ([`BOOK_GRANULARITY_MINS`]), and the node already declares the
//! only hours it books in. A business open 9–5 on weekdays therefore
//! needs 32 time clips, not 1440 — and needs them regardless of which
//! week the caller rings in, because "nine thirty" is the same two words
//! on every one of those days.
//! finite set rather than an impossible one: starts snap to a half hour
//! ([`BOOK_GRANULARITY_MINS`]), and the node already declares the only
//! hours it books in. A business open 9–5 on weekdays therefore needs 16
//! time clips, not 1440 — and needs them regardless of which week the
//! caller rings in, because "nine thirty" is the same two words on every
//! one of those days.
//!
//! # Why it is split day + time
//!
Expand Down Expand Up @@ -78,13 +78,22 @@ pub const MAX_BOOK_HORIZON_DAYS: u64 = 31;
/// digits the vocabulary carries.
pub const MAX_BOOK_OFFERS: u64 = 5;

/// Every candidate appointment start lands on a quarter hour.
/// Every candidate appointment start lands on a half hour.
///
/// Load-bearing, not cosmetic: this is what bounds the vocabulary.
/// Twin: the platform's `SLOT_GRANULARITY_MINS`, which must agree — if
/// the server offers a time the vocabulary has no clip for, the caller
/// hears silence where the time should be.
pub const BOOK_GRANULARITY_MINS: u64 = 15;
///
/// **This constant is why the change is staged.** [`required_assets`]
/// is computed from the value compiled into *this* build, not from
/// anything a version carries — so a daemon still on the quarter hour,
/// handed a version published after this moved, asks for `bktime_0915`,
/// does not find it, and refuses to arm the flow at all. Platforms
/// narrow what they offer first; this follows once the fleet has it.
///
/// [`required_assets`]: crate::model_ext::required_assets
pub const BOOK_GRANULARITY_MINS: u64 = 30;

// ── Vocabulary refs ────────────────────────────────────────────────────

Expand Down Expand Up @@ -190,7 +199,7 @@ fn minutes_of(hhmm: &str) -> Option<u64> {
(hours <= 23 && mins <= 59).then_some(hours * 60 + mins)
}

/// The starts a single open range can produce: on the quarter-hour grid,
/// The starts a single open range can produce: on the half-hour grid,
/// from the first grid point at or after `open`, while the whole
/// appointment still finishes by `close`.
///
Expand Down Expand Up @@ -324,9 +333,10 @@ mod tests {
open: "09:10".into(),
close: "10:30".into(),
};
// First grid point at or after 09:10 is 09:15; the last start that
// still finishes by 10:30 with a 30-minute appointment is 10:00.
assert_eq!(starts_in_range(&range, 30), vec![555, 570, 585, 600]);
// First grid point at or after 09:10 is 09:30 — never 09:10, and
// no longer 09:15; the last start that still finishes by 10:30
// with a 30-minute appointment is 10:00.
assert_eq!(starts_in_range(&range, 30), vec![570, 600]);
// An appointment longer than the window produces nothing at all.
assert!(starts_in_range(&range, 120).is_empty());
}
Expand Down Expand Up @@ -419,13 +429,13 @@ mod tests {
#[test]
fn an_offer_ends_with_the_key_that_takes_it() {
let tz = crate::hours::resolve_tz("UTC").unwrap();
let start = datetime!(2026-07-07 09:15 UTC);
let start = datetime!(2026-07-07 09:30 UTC);
let now = datetime!(2026-07-07 08:00 UTC);
assert_eq!(
offer_refs(start, now, tz, 2),
vec![
"bkday_today".to_string(),
"bktime_0915".to_string(),
"bktime_0930".to_string(),
"bkpress_2".to_string()
],
);
Expand Down
2 changes: 1 addition & 1 deletion crates/wavekat-flow/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1119,7 +1119,7 @@ nodes:
}

/// Two Tuesday-morning slots, in the vocabulary the CLINIC schedule
/// renders (09:00–11:30 on the quarter hour).
/// renders (09:00–11:30 on the half hour).
fn two_slots() -> SlotOffer {
SlotOffer {
slots: vec![
Expand Down
46 changes: 46 additions & 0 deletions crates/wavekat-flow/tests/conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ struct Expectation {
#[serde(rename = "structurallyValid")]
structurally_valid: bool,
semantic: Semantic,
/// Optional: the exact asset set a daemon must have on disk before it
/// will arm this flow. See `corpus_required_assets_match_expectations`.
#[serde(rename = "requiredAssets", default)]
required_assets: Option<Vec<String>>,
}

#[derive(serde::Deserialize)]
Expand Down Expand Up @@ -169,3 +173,45 @@ fn corpus_semantic_matches_expectations() {
}
}
}

// The asset set, pinned across both languages.
//
// This is the one place a `book` node's vocabulary arithmetic is checked
// against something outside the language that computed it. Both sides derive
// the set from their own copy of `BOOK_GRANULARITY_MINS`, and a daemon
// refuses to arm a flow whose required assets are not all on disk — so if the
// two ever drift, the symptom is not a failing test but a customer's phone
// line going quiet, on whichever devices updated late.
//
// Exact-set equality, unlike the `errors` expectation above: this set *is* the
// contract, not a description of one, and a missing member is precisely the
// bug worth catching.
#[test]
fn corpus_required_assets_match_expectations() {
let mut pinned = 0usize;
for version in corpus_versions() {
for bucket in ["valid", "invalid"] {
for (stem, path) in yaml_cases(&version, bucket) {
let exp_path = corpus_dir(&version, bucket).join(format!("{stem}.expected.json"));
let exp: Expectation =
serde_json::from_str(&fs::read_to_string(exp_path).unwrap()).unwrap();
let Some(expected) = exp.required_assets else {
continue;
};

let yaml = fs::read_to_string(&path).unwrap();
let flow: Flow = serde_yaml_ng::from_str(&yaml)
.unwrap_or_else(|e| panic!("{version}/{bucket}/{stem} must parse: {e}"));
assert_eq!(
wavekat_flow::required_assets(&flow),
expected,
"required assets differ for {version}/{bucket}/{stem}"
);
pinned += 1;
}
}
}
// A corpus that stopped pinning any would make this test pass by
// doing nothing, which is the one way it could fail silently.
assert!(pinned > 0, "no corpus case pins a required-asset set");
}
19 changes: 14 additions & 5 deletions packages/flow-schema/src/book.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
// rendered then, exactly like its prompts. Two things make that a small
// finite set rather than an impossible one:
//
// * starts snap to a quarter hour ({@link BOOK_GRANULARITY_MINS}), and
// * starts snap to a half hour ({@link BOOK_GRANULARITY_MINS}), and
// * the node already declares the only hours it books in.
//
// A business open 9–5 on weekdays therefore needs 32 time clips, not
// A business open 9–5 on weekdays therefore needs 16 time clips, not
// 1440 — and it needs them regardless of which week the caller rings in,
// because "nine thirty" is the same two words on every one of those days.
//
Expand Down Expand Up @@ -83,15 +83,24 @@ export const MAX_BOOK_HORIZON_DAYS = 31;
export const MAX_BOOK_OFFERS = 5;

/**
* Every candidate appointment start lands on a quarter hour.
* Every candidate appointment start lands on a half hour.
*
* Load-bearing, not cosmetic: this is what bounds the vocabulary above.
* Widening it widens every published flow's render. Twin: the platform's
* `SLOT_GRANULARITY_MINS`, which must agree — if the server offers a
* time the vocabulary has no clip for, the caller hears silence where
* the time should be.
*
* **Narrowing this is not a free change**, and the direction matters.
* A daemon computes `requiredAssets` from *its own* copy of this
* constant, not from anything the version carries — so a device still on
* the quarter hour, handed a version published after this change, asks
* for `bktime_0915`, does not find it, and refuses to arm the flow at
* all. The safe order is: platforms narrow what they *offer* first
* (leaving the frozen set a superset), fleets update, and only then does
* this move. See the platform's docs/35 §2.
*/
export const BOOK_GRANULARITY_MINS = 15;
export const BOOK_GRANULARITY_MINS = 30;

// ── Vocabulary refs ──────────────────────────────────────────────────────

Expand Down Expand Up @@ -198,7 +207,7 @@ function minutesOf(hhmm: string): number | null {
}

/**
* The starts a single open range can produce: on the quarter-hour grid,
* The starts a single open range can produce: on the half-hour grid,
* from the first grid point at or after `open`, while the whole
* appointment still finishes by `close`.
*
Expand Down
18 changes: 7 additions & 11 deletions packages/flow-schema/test/book.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,25 +69,23 @@ describe('bookVocabularyRefs', () => {
expect(bookVocabularyRefs(bookNode())).toContain('bkpress_3');
});

it('puts starts on the quarter hour and leaves room for the whole appointment', () => {
it('puts starts on the half hour and leaves room for the whole appointment', () => {
// 09:00–11:00, 30-minute appointments: the last start that still
// finishes by close is 10:30.
expect(times(bookNode())).toEqual([
'bktime_0900',
'bktime_0915',
'bktime_0930',
'bktime_0945',
'bktime_1000',
'bktime_1015',
'bktime_1030',
]);
});

it('starts at the first quarter hour at or after opening', () => {
// 09:10 opens onto a 09:15 grid; 09:45 is dropped because a
// 30-minute appointment starting there runs past the 10:10 close.
const node = bookNode({ schedule: { tue: [{ open: '09:10', close: '10:10' }] } });
expect(times(node)).toEqual(['bktime_0915', 'bktime_0930']);
it('starts at the first half hour at or after opening', () => {
// 09:10 opens onto a 09:30 grid — never 09:10, and no longer 09:15.
// 10:30 is dropped because a 30-minute appointment starting there
// runs past the 10:40 close.
const node = bookNode({ schedule: { tue: [{ open: '09:10', close: '10:40' }] } });
expect(times(node)).toEqual(['bktime_0930', 'bktime_1000']);
});

it('is the union over every day and every window, without duplicates', () => {
Expand All @@ -103,10 +101,8 @@ describe('bookVocabularyRefs', () => {
});
expect(times(node)).toEqual([
'bktime_0900',
'bktime_0915',
'bktime_0930',
'bktime_1400',
'bktime_1415',
'bktime_1430',
]);
});
Expand Down
42 changes: 41 additions & 1 deletion packages/flow-schema/test/conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { parse } from 'yaml';

import { checkFlow } from '../src/index.js';
import { checkFlow, requiredAssets } from '../src/index.js';
import { validateStructure } from '../src/structure.js';

const here = dirname(fileURLToPath(import.meta.url));
Expand All @@ -38,6 +38,10 @@ type Expectation = {
* the deliberate cross-language divergence (unknown fields warn in TS,
* are silently ignored by Rust serde; both still accept the document). */
tsWarnings?: string[];
/** Optional: the exact asset set the daemon must have on disk before it
* will arm this flow. See the describe block below for why it is pinned
* here rather than in either language's own tests. */
requiredAssets?: string[];
};

function casesIn(version: string, bucket: 'valid' | 'invalid'): string[] {
Expand Down Expand Up @@ -104,3 +108,39 @@ describe.each(CORPUS_VERSIONS)('conformance corpus %s — semantic (checkFlow)',
}
}
});

// The asset set, pinned across both languages.
//
// This is the one place a `book` node's vocabulary arithmetic is checked
// against something outside the language that computed it. Both sides
// derive the set from their own copy of `BOOK_GRANULARITY_MINS`, and a
// daemon refuses to arm a flow whose required assets are not all on disk
// — so if the two constants ever drift, the symptom is not a failing
// test but a customer's phone line going quiet, on the devices that
// happened to update late.
//
// Exact-set equality, unlike the error expectations above: this set is
// the contract itself, not a description of one, and a missing member is
// exactly the bug worth catching.
// Only cases that pin a set: an `invalid` document has no assets to
// speak of, and a flow with no `book` node has nothing that could drift.
// Collected before `describe`, because a version whose corpus pins
// nothing must produce no suite rather than an empty one.
const ASSET_CASES = CORPUS_VERSIONS.map((version) => ({
version,
cases: (['valid', 'invalid'] as const).flatMap((bucket) =>
casesIn(version, bucket)
.map((name) => ({ bucket, name, expected: expectation(version, bucket, name) }))
.filter((entry) => entry.expected.requiredAssets !== undefined),
),
})).filter((entry) => entry.cases.length > 0);

describe.each(ASSET_CASES)('conformance corpus $version — required assets', ({ cases, version }) => {
for (const { bucket, name, expected } of cases) {
it(`${bucket}/${name}`, () => {
const result = checkFlow(source(version, bucket, name));
expect(result.flow, `${bucket}/${name} must parse to pin its assets`).toBeDefined();
expect(requiredAssets(result.flow!)).toEqual(expected.requiredAssets);
});
}
});
Loading