Skip to main content
A signature defines the contract for an LM task: what goes in, what comes out, and what instruction to follow. You write a struct with #[derive(Signature)], mark fields as #[input] or #[output], and the macro generates everything needed to work with predictors.

Basic syntax

use dspy_rs::Signature;

/// Answer questions accurately and concisely.
#[derive(Signature, Clone, Debug)]
struct QA {
    /// The question to answer
    #[input]
    question: String,

    /// A clear, direct answer
    #[output]
    answer: String,
}
That’s it. The macro generates QAInput which you’ll use to call the predictor.

Docstrings add meaning

Doc comments turn into prompt instructions. They add context that types and variable names can’t express:
  • Struct docstring → The main task instruction
  • Field docstrings → What each field means and what makes a good one
/// Extract action items from meeting notes.
/// Return only concrete, assignable tasks.
#[derive(Signature, Clone, Debug)]
struct ExtractActions {
    /// Raw meeting transcript or notes
    #[input]
    notes: String,

    /// Who requested this extraction (for context on relevance)
    #[input]
    requester_role: String,

    /// Concrete tasks that can be assigned to a person.
    /// Each should start with a verb and be completable within a week.
    #[output]
    action_items: Vec<String>,
}
For inputs, docstrings explain why this data matters and any formatting the LM should expect. For outputs, docstrings specify exactly what you want - not just the shape, but the qualities of a good response. Only add a docstring if it says something the variable name doesn’t. /// The question on a field called question adds nothing.

Using Rust’s type system

You get real types, not string parsing:
#[derive(Signature, Clone, Debug)]
/// Analyze text for spam detection.
struct SpamAnalysis {
    #[input]
    text: String,

    #[output]
    is_spam: bool,  // actual bool

    #[output]
    confidence: f64,  // actual float

    #[output]
    keywords: Vec<String>,  // actual vec

    #[output]
    category: Option<String>,  // nullable
}
Built-in types (no extra work needed):
  • String, bool
  • i8, i16, i32, i64, f32, f64
  • Option<T>, Vec<T>, HashMap<String, T>

Custom types

When you have a non-standard type in a field, add #[BamlType] on it:
use dspy_rs::{Signature, BamlType};

#[BamlType]
#[derive(Clone, Debug)]
enum Sentiment {
    Positive,
    Negative,
    Neutral,
}

#[BamlType]
#[derive(Clone, Debug)]
struct Citation {
    /// Document ID
    doc_id: String,
    /// Relevant quote from the document
    quote: String,
}

#[derive(Signature, Clone, Debug)]
/// Analyze sentiment and find supporting citations.
struct Analysis {
    #[input]
    text: String,

    #[output]
    sentiment: Sentiment,

    #[output]
    citations: Vec<Citation>,
}
See Custom Types for the full BamlType reference.

Demos (few-shot examples)

Attach examples for few-shot prompting via the predictor builder:
let predict = Predict::<QA>::builder()
    .demo(QA {
        question: "What is 2+2?".into(),
        answer: "4".into(),
    })
    .demo(QA {
        question: "What color is the sky?".into(),
        answer: "Blue".into(),
    })
    .build();
Demos are full signature structs with both input and output fields populated.

Field attributes

Beyond #[input] and #[output], you can use:

#[alias] - Rename for LLM

#[input]
#[alias = "user_question"]  // LLM sees "user_question", Rust uses "question"
question: String,

#[format] - Input serialization

#[input]
#[format("yaml")]  // serialize this input as YAML in the prompt
context: Vec<Document>,
Options: "json", "yaml", "toon"

#[render] - Custom input rendering (Jinja)

#[input]
#[render(jinja = "{{ this.title }}\n{{ this.body | truncate(300) }}")]
ticket: Ticket,
Use this when you need custom text rendering for an input field.
  • Only valid on #[input] fields
  • Template must be a string literal
  • Jinja syntax is validated at compile time
  • Cannot be combined with #[format] on the same field
  • In ChatAdapter, built-ins + BAML parity helpers (regex_match, sum) + truncate are available
Template context:
  • this - Current field value as JSON-like data
  • input - Full input object (with top-level alias overlays)
  • field - Metadata object with name, rust_name, and type
  • vars - Adapter/surface variables (currently empty in ChatAdapter)

#[check] - Soft constraint

#[output]
#[check("this >= 0.0 and this <= 1.0", label = "valid_range")]
confidence: f64,
Recorded in metadata, doesn’t fail parsing. Label is required. Use Jinja boolean operators (and, or) in expressions.

#[assert] - Hard constraint

#[output]
#[assert("this.len() > 0")]
answer: String,
Fails parsing if violated. Label is optional. See Constraints for the expression language.

Where it fits

  • A Signature doesn’t call the LM by itself
  • An Adapter turns the signature into a prompt and parses LM responses back into typed outputs
  • A Predict orchestrates this: signature + adapter + LM
  • Signatures are data - this separation supports reuse, testing, and optimizer integration
Signature + Input → Adapter (formats prompt) → LM → Adapter (parses response) → Typed Output