Predictors

Predictors are modules that execute signatures. DSPy.rb provides Predict for one typed call, ChainOfThought for a typed call with an added reasoning field, and ReAct for a bounded tool-selection loop.

DSPy::Predict

Executes a signature with one language-model request and converts the response to the declared output type.

Call Predict

class ClassifyText < DSPy::Signature
  description "Classify text sentiment and extract key topics"
  
  class Sentiment < T::Enum
    enums do
      Positive = new('positive')
      Negative = new('negative')
      Neutral = new('neutral')
    end
  end
  
  input do
    const :text, String
  end
  
  output do
    const :sentiment, Sentiment
    const :topics, T::Array[String]
    const :confidence, Float
  end
end

# Create and use the predictor
classifier = DSPy::Predict.new(ClassifyText)
result = classifier.call(text: "I absolutely love the new features in this app!")

puts result.sentiment    # => #<Sentiment::Positive>
puts result.topics       # => ["app", "features"]
puts result.confidence   # => 0.92

Use the Configured Language Model

# Basic usage - uses global language model
predictor = DSPy::Predict.new(ClassifyText)

DSPy::ChainOfThought

Adds a reasoning field to the signature output. Whether that improves task quality is an evaluation question, not a property of the module.

When to Use ChainOfThought

  • Tasks where an explicit reasoning field helps the application or evaluator
  • Comparisons that evaluate the same task with and without that field
  • Mathematical or logical tasks whose intermediate reasoning the application needs to inspect

Call ChainOfThought

class SolveMathProblem < DSPy::Signature
  description "Solve mathematical word problems step by step"
  
  input do
    const :problem, String
  end
  
  output do
    const :answer, String
    const :solution_steps, T::Array[String]
  end
end

solver = DSPy::ChainOfThought.new(SolveMathProblem)
result = solver.call(problem: "Sarah has 15 apples. She gives 7 to her friend and buys 12 more. How many apples does she have now?")

puts result.reasoning    # => "Let me work through this step by step:\n1. Sarah starts with 15 apples\n2. She gives away 7 apples: 15 - 7 = 8 apples\n3. She buys 12 more: 8 + 12 = 20 apples\nTherefore, Sarah has 20 apples."
puts result.answer       # => "20 apples"
puts result.solution_steps # => ["Start: 15 apples", "Give away 7: 15-7=8", "Buy 12 more: 8+12=20"]

Read the Added Reasoning Field

class ComplexAnalysis < DSPy::Signature
  description "Perform business analysis with reasoning"
  
  input do
    const :scenario, String
    const :constraints, T::Array[String]
  end
  
  output do
    const :recommendation, String
    const :risks, T::Array[String]
  end
end

analyzer = DSPy::ChainOfThought.new(ComplexAnalysis)

result = analyzer.call(
  scenario: "Launching a new product in a competitive market",
  constraints: ["Limited budget", "6-month timeline"]
)

# ChainOfThought automatically adds reasoning field
puts result.reasoning
# => "Let me analyze this step by step:
#     1. Market Analysis: [analysis]
#     2. Strategy Development: [approach]
#     ..."

puts result.recommendation
# => "Launch with a focused MVP approach..."

DSPy::ReAct

Runs a bounded loop in which the model chooses a typed tool call or submits the final result.

Define a Typed Tool

class WeatherTool < DSPy::Tools::Base
  extend T::Sig
  
  tool_name "weather"
  tool_description "Get weather information"
  
  sig { params(location: String).returns(String) }
  def call(location:)
    # Simulate weather API call
    {
      location: location,
      temperature: rand(60..85),
      condition: ['sunny', 'cloudy', 'rainy'].sample
    }.to_json
  end
end

class SearchTool < DSPy::Tools::Base
  extend T::Sig
  
  tool_name "search"
  tool_description "Search the web"
  
  sig { params(query: String).returns(String) }
  def call(query:)
    # Simulate web search
    [
      { name: "Result 1", snippet: "Information about #{query}" },
      { name: "Result 2", snippet: "More details on #{query}" }
    ].to_json
  end
end

Run a Bounded ReAct Agent

class TravelAssistant < DSPy::Signature
  description "Help users plan travel"
  
  input do
    const :destination, String
    const :interests, T::Array[String]
  end
  
  output do
    const :recommendations, String
  end
end

agent = DSPy::ReAct.new(
  TravelAssistant,
  tools: [WeatherTool.new, SearchTool.new],
  max_iterations: 5
)

result = agent.call(
  destination: "Tokyo, Japan",
  interests: ["food", "temples"]
)

# The agent will:
# 1. Think: "I need to check the weather for Tokyo"
# 2. Act: weather({"location": "Tokyo, Japan"})
# 3. Think: "Now I should search for food and temple recommendations"
# 4. Act: search({"query": "best food Tokyo"})
# 5. Think: "Based on research, I can make recommendations"
# 6. Provide final response

puts result.recommendations
# => "Visit Senso-ji Temple early morning. Try ramen at local shops in Shibuya..."

# Access the reasoning history
puts result.history
# => Array of reasoning steps, actions, and observations

puts result.iterations  # => 3
puts result.tools_used  # => ["weather", "search"]

Handling Max Iterations with Structured Context

begin
  result = agent.call(destination: "Tokyo, Japan", interests: ["food", "temples"])
rescue DSPy::ReAct::MaxIterationsError => error
  puts error.message
  puts error.iterations        # => 5
  puts error.max_iterations    # => 5
  puts error.tools_used        # => ["weather", "search"]
  puts error.last_observation  # => last observed tool output (or nil)

  # Use structured history for best-effort fallback synthesis
  summary = error.history.map { |entry| "#{entry[:step]}:#{entry[:action]}" }.join(", ")
  puts "Partial evidence: #{summary}"
end

Inject a Tool Dependency

class DatabaseTool < DSPy::Tools::Base
  extend T::Sig
  
  tool_name "database"
  tool_description "Query user database"
  
  sig { params(connection: T.untyped).void }
  def initialize(connection)
    super()
    @db = connection
  end
  
  sig { params(query: String).returns(String) }
  def call(query:)
    # Simple database query
    result = @db.execute(query)
    result.to_json
  end
end

class CustomerService < DSPy::Signature
  description "Provide customer service"
  
  input do
    const :customer_query, String
  end
  
  output do
    const :response, String
  end
end

service_agent = DSPy::ReAct.new(
  CustomerService,
  tools: [DatabaseTool.new(database_connection)],
  max_iterations: 3
)

DSPy::CodeAct

CodeAct now ships separately as the dspy-code_act gem. Install it alongside dspy to access Think-Code-Observe agents that synthesize and execute Ruby code.

  • 📦 Install: gem 'dspy-code_act', '~> 1.0'
  • 📚 Docs: lib/dspy/code_act/README.md
  • 🧪 Tests: run via the DSPy CodeAct GitHub Actions job

The predictor comparison still applies to CodeAct’s execution strategy; its package-specific API evolves independently from the core gem.

Predictor Comparison

Compare Model-Directed Steps

Predictor Model-directed steps Best fit
Predict One Classification, extraction, transformation
ChainOfThought One, with a reasoning field Tasks where explicit reasoning is useful
ReAct Up to max_iterations Tool use where the model must choose the next action
CodeAct Up to its configured limit Model-generated Ruby execution through the optional gem; the application supplies isolation

Choose by Model-Directed Control

# Simple, fast tasks
quick_classifier = DSPy::Predict.new(SimpleClassification)

# Complex reasoning needed
analyst = DSPy::ChainOfThought.new(ComplexAnalysis)

# Multi-step tasks with external data
agent = DSPy::ReAct.new(AgentTask, tools: [tool1, tool2])

# Dynamic programming and calculations
programmer = DSPy::CodeAct.new(ProgrammingTask, max_iterations: 10)

Error Handling

Basic Error Handling

class RobustPredictor
  def initialize(signature)
    @primary = DSPy::ChainOfThought.new(signature)
    @fallback = DSPy::Predict.new(signature)
  end
  
  def call(**input)
    @primary.call(**input)
  rescue StandardError => e
    puts "Primary predictor failed: #{e.message}"
    @fallback.call(**input)
  end
end

Input Validation

class ValidatedPredictor
  def initialize(signature)
    @predictor = DSPy::Predict.new(signature)
    @signature = signature
  end
  
  def call(input)
    # Validate input structure
    @signature.input_struct_class.new(**input)
    
    # Call predictor
    @predictor.call(**input)
  rescue ArgumentError => e
    raise DSPy::PredictionInvalidError.new({ input: e.message })
  end
end

Prompt Optimization

Working with Examples

# Create predictor with examples
classifier = DSPy::Predict.new(SentimentAnalysis)

# Add few-shot examples
examples = [
  DSPy::FewShotExample.new(
    input: { text: "I love this product!" },
    output: { sentiment: "positive", confidence: 0.9 }
  ),
  DSPy::FewShotExample.new(
    input: { text: "This is terrible." },
    output: { sentiment: "negative", confidence: 0.8 }
  )
]

optimized_classifier = classifier.with_examples(examples)

Custom Instructions

# Modify instruction
predictor = DSPy::Predict.new(TextClassifier)
optimized = predictor.with_instruction(
  "You are an expert classifier. Be precise and confident."
)

result = optimized.call(text: "Sample text")

Testing Predictors

Unit Tests

RSpec.describe DSPy::Predict do
  let(:signature) { SimpleClassification }
  let(:predictor) { described_class.new(signature) }
  
  describe "#call" do
    it "returns structured results" do
      result = predictor.call(text: "Sample text")
      
      expect(result).to respond_to(:classification)
      expect(result).to respond_to(:confidence)
    end
    
    it "validates input structure" do
      expect {
        predictor.call(invalid_field: "value")
      }.to raise_error(DSPy::PredictionInvalidError)
    end
  end
end

Testing ChainOfThought

RSpec.describe DSPy::ChainOfThought do
  let(:predictor) { described_class.new(ComplexAnalysis) }
  
  it "includes reasoning in output" do
    result = predictor.call(
      scenario: "Market expansion",
      constraints: ["Limited budget"]
    )
    
    expect(result).to respond_to(:reasoning)
    expect(result.reasoning).to be_a(String)
    expect(result.reasoning).not_to be_empty
  end
end

Run Independent Predictions Concurrently

Concurrency is application-owned rather than a predictor type. See Concurrent Predictions for the runnable Async::Barrier pattern, failure policy, and measurement boundaries.

Choose Execution, Failure, and Observability Boundaries

1. Choose the Right Predictor

# Simple extraction → Predict
email_extractor = DSPy::Predict.new(ExtractEmails)

# Complex analysis → ChainOfThought  
business_analyzer = DSPy::ChainOfThought.new(BusinessAnalysis)

# Multi-step with tools → ReAct
research_agent = DSPy::ReAct.new(ResearchTask, tools: [SearchTool.new])

2. Handle Errors Gracefully

class ProductionPredictor
  def call(**input)
    @predictor.call(**input)
  rescue DSPy::PredictionInvalidError => e
    handle_validation_error(e)
  rescue StandardError => e
    handle_unexpected_error(e)
  end
end

3. Inspect Predictor Events

token_events = []
subscription_id = DSPy.events.subscribe("lm.tokens") do |_name, attributes|
  token_events << attributes
end

begin
  result = @predictor.call(**input)
  puts token_events.sum { |event| event[:total_tokens] || 0 }
ensure
  DSPy.events.unsubscribe(subscription_id) if subscription_id
end

The core event subscription is in-process and reports usage only when the provider returns it. Trace export requires the optional observability packages, exporter configuration, credentials, and network access described in Observability. Predictors validate declared result types; evaluate task correctness separately with examples and a metric.