Generate effects directly with GPT-5 as S-expressions under a Lark grammar
Absorbs ADR-0035.
Decision
Send the user's words and the current trip to a GPT-5 family model and have it write effects directly. The request exposes one custom tool whose output format is a Lark context-free grammar, and tool choice is required, so the model can only return text the grammar accepts. Creating a trip and editing one use the same call. For an existing trip the prompt carries its purpose and its destinations with their ids.
The grammar produces S-expressions, and serde-lexpr deserializes them straight into the Rust effect enum. The enum's serde derive is the only parser.
((create-destination (location . "Paris") (duration_days . 5))
(set-total-duration . 14))
Effects with several arguments are struct variants written as (field . value) pairs; effects with one argument use the dotted-pair form. The kebab-case rename applies to the variant tag only. The enum rejects unknown fields, because serde otherwise drops a misnamed field without an error. A test parses the grammar and checks that it names exactly the effects the enum can carry, and that every literal it accepts deserializes into the variant it names. Effects only the system produces, such as enrichment, stay out of the grammar on purpose. S-expressions exist only at the model boundary; the log stores effects as JSON.
Why
An edit only means something relative to the trip. "Make it two weeks" needs the current duration and "add Prague" needs the current destinations. Our first plan had Apple's on-device Foundation Models extract an intent for a rule interpreter, and it failed at extraction even on simple inputs while never seeing the trip. A cloud model that reads the trip handles the language, and constrained decoding makes an intermediate representation unnecessary, because the output is already a structurally valid list of effects.
The first grammar emitted a custom call syntax, CreateDestination("Paris", 5), read by a hand-written parser. Grammar and parser were kept in step by hand and disagreed only at runtime. S-expressions already have a serde implementation, so the derive replaces the parser at about the same token cost. Named fields over tuple variants stop the model swapping start and end, or from and to, and let an optional field be added without breaking anything. Chrono rejects a datetime without seconds with "premature end of input", which reads like a serde-lexpr fault; every datetime field the model can reach goes through a lenient parser that accepts minute precision.
Rejected alternatives
- Intent extraction on-device, then a rule interpreter. Foundation Models couldn't extract intents reliably, the intent step couldn't see the trip, and it meant maintaining an intent vocabulary and an interpreter beside the effects.
- JSON Schema structured output. Reliable, but roughly twice the tokens of S-expressions, and it constrains shape without a grammar we can test against the enum.
- A custom syntax with a hand-written parser. Grammar and parser agree only by hand and fail at runtime.
Consequences
The model sees the whole trip when it reads an edit, and a bad response fails at deserialization before anything is stored. Every natural-language edit is a call to OpenAI with its latency and a per-request bill, so this path needs a network connection (ADR-0073). One step from words to effects has no inspectable intent to debug. A new effect needs a variant and a grammar production, and the test fails until they agree.