Article

Rich Signatures, Lean Schemas

When signatures hit 5+ fields, JSON Schema overhead eats hundreds of tokens per call. BAML keeps them compact—no retraining needed.

V

Vicente Reig Rincon de Arellano

Fractional Engineering Lead •

I find writing Signatures instead of Prompts similar to modeling databases with ActiveRecord. You use objects to model the world as you want your prompt to see it. Start simple, add complexity as needed, and the framework handles the details.

Starting Simple

Here’s a basic signature - just input and output:

class SentimentAnalysis < DSPy::Signature
  description "Classify sentiment of a given sentence"

  class Sentiment < T::Enum
    enums do
      Positive = new('positive')
      Negative = new('negative')
      Neutral = new('neutral')
    end
  end

  input do
    const :text, String, description: "The text to analyze"
  end

  output do
    const :sentiment, Sentiment, description: "Sentiment classification"
    const :confidence, Float, description: "Confidence score between 0 and 1"
  end
end

# Configure DSPy with your LLM
DSPy.configure do |c|
  c.lm = DSPy::LM.new(
    'openai/gpt-4o-mini',
    api_key: ENV['OPENAI_API_KEY']
  )
end

# Use the signature
predictor = DSPy::Predict.new(SentimentAnalysis)
sentiment = predictor.call(text: "This book was super fun to read!")

puts sentiment.sentiment.serialize    # => "positive"
puts sentiment.confidence             # => 0.95

Clean. Minimal. The LM receives a compact schema that fits in a few lines.

Signatures Get Richer

But real applications need more structure. Task decomposition, for example:

class TaskDecomposition < DSPy::Signature
  description "Autonomously analyze a research topic and define optimal subtasks"

  input do
    const :topic, String, description: "The main research topic to investigate"
    const :context, String, description: "Any additional context or constraints"
  end

  output do
    const :subtasks, T::Array[String], description: "Research subtasks with clear objectives"
    const :task_types, T::Array[String], description: "Type classification for each task"
    const :priority_order, T::Array[Integer], description: "Priority rankings (1-5 scale)"
    const :estimated_effort, T::Array[Integer], description: "Effort estimates in hours"
    const :dependencies, T::Array[String], description: "Task dependency relationships"
    const :agent_requirements, T::Array[String], description: "Suggested agent types/skills"
  end
end

# Use the signature
predictor = DSPy::Predict.new(TaskDecomposition)
result = predictor.call(
  topic: "Build user authentication system",
  context: "Focus on security best practices and Rails integration"
)

# Access structured results
puts "Subtasks:"
result.subtasks.each_with_index do |task, i|
  puts "  #{i+1}. #{task} (#{result.estimated_effort[i]}h, priority: #{result.priority_order[i]})"
end

Six output fields with descriptions. Nested types. This is where schemas start creeping into your prompts.

The Schema Problem

With Enhanced Prompting (the default mode in DSPy.rb), schemas are embedded directly in prompts. Here’s what the LM actually receives for TaskDecomposition:

{
  "$schema": "http://json-schema.org/draft-06/schema#",
  "type": "object",
  "properties": {
    "subtasks": {
      "type": "array",
      "items": {"type": "string"},
      "description": "Research subtasks with clear objectives"
    },
    "task_types": {
      "type": "array",
      "items": {"type": "string"},
      "description": "Type classification for each task"
    },
    "priority_order": {
      "type": "array",
      "items": {"type": "integer"},
      "description": "Priority rankings (1-5 scale)"
    },
    "estimated_effort": {
      "type": "array",
      "items": {"type": "integer"},
      "description": "Effort estimates in hours"
    },
    "dependencies": {
      "type": "array",
      "items": {"type": "string"},
      "description": "Task dependency relationships"
    },
    "agent_requirements": {
      "type": "array",
      "items": {"type": "string"},
      "description": "Suggested agent types/skills"
    }
  },
  "required": ["subtasks", "task_types", "priority_order", "estimated_effort", "dependencies", "agent_requirements"]
}

1,378 characters. ~345 tokens. Every. Single. Call.

For rich signatures, JSON Schema verbosity becomes a real cost. Each API call carries hundreds of tokens just describing the output structure.

BAML: The Simple Fix

DSPy.rb v0.28.2 adds BAML schema format support via the sorbet-baml gem. BAML provides the same information compactly:

class TaskDecomposition {
  subtasks string[]
  task_types string[]
  priority_order int[]
  estimated_effort int[]
  dependencies string[]
  agent_requirements string[]
}

200 characters. ~50 tokens. 85.5% savings.

Same structure. Same type safety. Same validation. But 295 fewer tokens per call.

Verified Performance

From our integration tests across multiple signatures:

Token Comparison by Schema Format

Signature JSON Schema BAML Schema
TaskDecomposition (6 fields) 345 tokens 50 tokens
ResearchExecution (6 fields) 287 tokens 49 tokens
Aggregate (all tests) 632 tokens 99 tokens

Side-by-side comparison: Red bars show JSON Schema token consumption, green bars show BAML Schema savings. TaskDecomposition: 85.5% reduction (345→50 tokens). ResearchExecution: 83.0% reduction (287→49 tokens). Aggregate: 84.4% reduction (632→99 tokens) across all tests.

Detailed Performance Breakdown

TaskDecomposition (6 fields):

  • JSON Schema: 1,378 chars (~345 tokens)
  • BAML Schema: 200 chars (~50 tokens)
  • Savings: 85.5% (~295 tokens/call)

ResearchExecution (6 fields):

  • JSON Schema: 1,148 chars (~287 tokens)
  • BAML Schema: 195 chars (~49 tokens)
  • Savings: 83.0% (~238 tokens/call)

Aggregate across all tests:

  • JSON Schema: 2,526 chars (~632 tokens)
  • BAML Schema: 395 chars (~99 tokens)
  • Savings: 84.4% (~533 tokens/call)

Quality: 100% identical outputs across all tests.

No training needed. No optimization required. Your baseline signatures just got more efficient.

How to Use

One configuration change:

DSPy.configure do |c|
  c.lm = DSPy::LM.new(
    'openai/gpt-4o-mini',
    schema_format: :baml
  )
end

# Use any signature - BAML is automatic
predictor = DSPy::Predict.new(TaskDecomposition)
result = predictor.call(
  topic: "Build user authentication",
  context: "Focus on security best practices"
)

Works with all providers in Enhanced Prompting mode: OpenAI, Anthropic, Gemini, Ollama.

When It Matters

BAML shines with:

  • Complex signatures (5+ fields, nested types)
  • High-volume applications (the savings compound)
  • Cost-sensitive projects (every token counts)

For simple 1-3 field signatures, the difference is negligible.

If you’re using OpenAI’s Structured Outputs mode (structured_outputs: true), schemas are sent via API instead of in prompts - BAML has no effect there since schemas never appear in the prompt.

Requirements

The sorbet-baml gem is automatically included with DSPy.rb:

# Gemfile
gem 'dspy'

BAML generation is automatic from your Sorbet type signatures - no additional setup needed.

Resources