#[predict] and #[cot] turn one function signature into one model call; #[module] compiles a function body that chains those calls into an IR Program. The macro reads each function once at expansion and emits both the runnable code and its program form, so the two cannot drift.
Steps
A step is one model call declared as a function with no body. The function is the contract; the framework writes the prompt and parses the answer.
So
fn answer(question: String) -> String gives the model an input called question and asks for an output called answer, which is why the result reads as out.answer. A step can take several inputs:
;; do not write async (the generated function is async automatically); no generics, no self, parameters must be plain identifiers; at least one input parameter and an explicit return type are required.
Chain of thought
#[cot] is the chain-of-thought variant of #[predict]: the model produces a reasoning field before the output. Same rules, same options. The result carries the extra field and auto-derefs to the output.
Model handles
Both attributes accept one option,model = "@name", which selects a declared model by reference; the leading @ is stripped, and anything else in the attribute is a compile error.
strong really is gets decided by whoever runs the program. Steps with no model = ... ride the default model from your configure(...) line automatically. Named handles have no such fallback; see Named model binding.
The name is the link
The function name follows the step everywhere:fx::Params::set_instruction("answer", ...) overrides its instruction, trace spans record the same name, and the generated signature lives in a module of the same name (answer::Sig). One exception: when a step is called inside a #[module] body, the trace and step name is the let binding name for that call, so let drafter = draft(...).await?; records as drafter.
Modules
#[module] compiles an ordinary async Rust function body into an IR Program. One parse, two projections: the executable function (typed boundary, runs through the interpreter, reads the ambient overlay) and name::program(), the same pipeline as a servable, optimizable, printable artifact.
async, cannot be generic, and must return Result<Out, Err> where Err: From<dspy_rs::ir::RunError>. Two options: caps("...", ...) declares the program’s capability ceiling, and deny_holes makes any non-step expression a compile error instead of a hole.
Accepted body shapes
The body is straight-line only, built from three shapes:let x = step(args).await?;wherestepis a#[predict],#[cot], or#[agent]function. Arguments must be ports: function parameters, priorbinding.fieldaccesses, or literals;.clone()and&wrappers are stripped.let y: SimpleType = <any Rust expr>;for plain Rust. The type ascription is required and must be simple:String,bool, an integer width fromi8throughi64oru8throughu32,f32/f64, orVec<...>/Option<...>of those. The expression becomes a typed extern hole, a named boundary around code the IR cannot describe as data; the hole page covers how holes bind and travel.- A tail expression
Ok(Struct { field: port, ... })giving the program’s output bindings.
The printed program
let binding name (sum, drafter), so the program speaks in your vocabulary. Every wire is a line of Rust written as a connection: sum.summarize flowing into draft’s summary input is the sum.summarize.clone() argument. The @strong handle survives intact, and the privacy scrub prints as hole clean_hole with an extern fingerprint in place of its code.
This is the build-time design point stated plainly: the macro reads the function once at expansion and emits both the runnable function and the Program. The two are projections of one source, so they cannot drift. The printed text is the canonical .dsrs form; its grammar and identity rules live in The .dsrs file.
Generated items
For a step#[predict] fn answer(question: String) -> String;:
#[cot] generates the same items; its function returns Result<Predicted<WithReasoning<answer::SigOutput>>, PredictError>, and WithReasoning auto-derefs to the output.
For #[module] async fn frontdesk(...), inside a module named after the function:
It also emits the executable
async fn itself, which loads the interpreter once, reads the ambient overlay via current_overlay(), and runs with a default budget.
Named model binding
Steps with nomodel = ... ride the default model from your configure(...) line automatically. A named handle has no such fallback, by design: bind it at load with frontdesk::env().bind_model("strong", strong) and run through Interpreter::load, or the load refuses, by name. The runnable example 22-frontdesk-module.rs does exactly this.
Common mistakes
Addingasync to a step. The generated function is already async. Writing async fn on a #[predict] or #[cot] step is a compile error with a clear message: remove async.
See also
- Signatures for the struct form of the same contract
- Holes for what plain Rust inside a module becomes
- Program and nodes for what
program()returns in memory - The .dsrs file for the printed text form and its grammar
- Runtime for
RuntimeEnv, binding, andInterpreter::load - Example: 22-frontdesk-module.rs
