Skip to content

Signatures

A signature declares what a program takes in and what it produces. Instead of hand-writing a prompt, you describe the shape of the task and dspy4s builds the prompt from it.

A signature is an ordinary Scala type, so the compiler checks every field.

Every snippet here is verified

The Scala blocks on this page are extracted from Signatures.scala, which builds under -Werror -Wunused:all. They stay in sync with the library.

Inline signatures

The shortest form is Signature.fromType, applied to a function type whose parameters and result are named tuples:

object SentimentExample:
  val classify = Predict(Signature.fromType[(sentence: String) => (sentiment: Boolean)])

  def call(sentence: String)(using RuntimeContext): Either[DspyError, Boolean] =
    classify((sentence = sentence)).map(_.output.sentiment)

classify.apply((sentence = ...)) is checked at compile time, and _.output.sentiment is a Boolean.

The same signature can also be written as a string with Signature.fromString("sentence -> sentiment: bool"). Both forms are parsed at compile time into the same Signature, so pick whichever reads better.

Adding instructions

fromType takes an optional instructions string to steer the model:

object ToxicityExample:
  val signature = Signature.fromType[(comment: String) => (toxic: Boolean)](
    instructions = "Mark as 'toxic' if the comment includes insults, harassment, or sarcastic derogatory remarks."
  )

  val toxicity = Predict(signature)

  def call(comment: String)(using RuntimeContext): Either[DspyError, Boolean] =
    toxicity((comment = comment)).map(_.output.toxic)

Class-based signatures

When you want named fields or a constrained output type, declare a Spec trait with InputField / OutputField members. A fixed set of output values is a Scala enum:

enum Emotion derives Schema:
  case sadness, joy, love, anger, fear, surprise

trait EmotionSpec extends Spec:
  def sentence: InputField[String]
  def sentiment: OutputField[Emotion]

object EmotionExample:
  val classify = Predict(Signature.of[EmotionSpec](instructions = "Classify emotion."))

  def call(sentence: String)(using RuntimeContext): Either[DspyError, Emotion] =
    classify((sentence = sentence)).map(_.output.sentiment)

derives Schema gives the enum a flat-string wire form (the case name) at the output boundary, so the model's answer decodes straight into an Emotion.

Case-class signatures

When you already model inputs and outputs as case classes, Signature.derived builds a signature from an input type and an output type directly. The output is a Scala value, so _.output.sentiment has type Emotion with no cast:

case class EmotionInput(sentence: String) derives Schema

enum Emotion derives Schema:
  case sadness, joy, love, anger, fear, surprise

case class EmotionOutput(sentiment: Emotion) derives Schema
val signature: Signature[EmotionInput, EmotionOutput] = Signature.derived[EmotionInput, EmotionOutput](
  name = "Emotion",
  instructions = "Classify emotion in the given sentence."
)

Optional outputs

An output field of type Option[T] is optional. When the response of the model does not have the field, the value is None, and the call does not fail. In an inline signature, write the type as Optional[T] or T | None, for example "question -> answer, note: Optional[str]".

A required field that the response does not have is a parse error. A reply with no text is also a parse error, except a reply with tool calls for a signature with a tool_calls output. With the default ChatAdapter, and with XMLAdapter, a parse error gives one more call in the JSON format (useJsonAdapterFallback).

Sealed traits, tuples, and time values

A prompt shows the JSON schema of a structured field, and each typed value has one form:

Field type The form of the value
A sealed trait or an enum with only cases with no fields The case name: beta.
A sealed trait with fields The case name as a key: {"Circle": {"radius": 1.5}}, and {"Dot": {}} for a case with no fields.
A tuple A JSON array: [1, "a"].
An Option The value, or a null.
Instant, LocalDate, UUID, and the other time types A text: 2024-01-02T03:04:05Z.
BigDecimal A number. Write it as a text to keep each digit of a fraction.

Each form also works in a List, a Map value, a case, and a tuple. An input field, a demo, and a saved demo show these forms, and the variables of RLM have these forms.

The output also decodes from a bare case name (Dot), and from a record for a tuple ({"_1": 1, "_2": "a"}). The schema shows a case of a nested sealed trait with its own name, and a value in a prompt shows the path of the cases: {"Animal": {"Cat": {"name": "a"}}}. The output decodes from each form.

Signature.derived makes the record of each class from its structure. To use a Schema.transform, give the signature a shape with Signature.fromShapes and Shape.derived. The field then has the form of the value behind the transform.

Annotations

The annotations of zio-blocks change the names and the forms of a typed value. Only the field of a Scala value keeps its name in the code. The new name is the name in the prompt, the schema, a raw prediction, a labeled example, a demo, a history message, and a metric, so use it in a data set:

final case class Person(
    @Modifier.rename("full_name") name: String,
    @Modifier.alias("years") age: Int,
    @Modifier.transient() cached: Int = 0
)

@Modifier.discriminator("kind")
sealed trait Shape
final case class Circle(radius: Double) extends Shape
case object Dot extends Shape
Annotation The effect
@Modifier.rename("x") on a field or a case The name is x.
@Modifier.fieldNaming("snake_case") on a class Each field has a name in that form, except a field with a rename.
@Modifier.caseNaming("snake_case") on a sealed trait or an enum Each case has a name in that form, except a case with a rename. The cases of a nested sealed trait take the form of the outer one.
@Modifier.alias("x") on a field or a case The decode also reads x. The prompt does not show it.
@Modifier.discriminator("kind") on a sealed trait with fields The value is flat: {"kind": "Circle", "radius": 1.5}, and {"kind": "Dot"} for a case with no fields.
@Modifier.transient() on a field The field is not in the prompt, and the output has its default.
@Modifier.encodeTransient() on a field A value in your code does not reach the prompt. The model writes the field in an output, and an input shows its default.
@Modifier.noExtraFields() on a class The schema closes the object. The decode ignores an extra field.

An annotation on a simple case of a Scala enum has no effect. Use a sealed trait with case objects for such a case, or caseNaming on the enum.

The strategies are snake_case, camelCase, kebab-case, PascalCase, and identity. A field of a signature needs a name that is an identifier, so use kebab-case only on a nested class.

The name in the code of a nested field or case also decodes, and a Maybe field works as an Option field. A model must write an output field and an enum value with the name that the prompt shows: another name is a parse error. Keep the discriminator name different from each field name of the cases. If a case has a field with that name, the value keeps the case name as a key.

Building a signature programmatically

When a case class per signature is overkill (exploration, shapes assembled from config, tests), Signature.builder constructs one fluently. Each input/ output call summons the field's Schema:

val toxicity: SignatureLayout = Signature
  .builder("Toxicity")
  .input[String]("comment")
  .output[Boolean]("toxic")
  .output[Double]("confidence")
  .instructions(
    "Mark `toxic` as true when the comment includes insults, harassment, " +
      "or derogatory remarks. Report the confidence as a number in [0.0, 1.0]."
  )
  .build

Custom types

Inputs and outputs are not limited to primitives. Any case class (or nested container) that derives Schema can be a field. The schema drives both the wire shape and nested encode/decode:

case class QueryResult(text: String, score: Double) derives Schema

object MyContainer:
  case class Query(text: String) derives Schema
  case class Score(score: Double) derives Schema

object CustomTypesExample:
  val signature = Signature.fromType[(query: String) => (result: QueryResult)]

  val nestedSignature = Signature.fromType[
    (query: MyContainer.Query) => (score: MyContainer.Score)
  ]

Conversation history

A program does not keep the earlier turns of a conversation. To give them to the model, add an input field of type History to the signature:

trait QA extends Spec:
  def question: InputField[String]
  def history: InputField[History]
  def answer: OutputField[String]

A History holds messages. Each message is a record whose keys are field names of the same signature. The adapter does not show the field as a value. It replays each message before the current request: the input fields make an earlier user turn, and the output fields make the assistant turn that answered it.

The caller adds each turn to the history and gives the history to the next call:

private val predict = Predict(Signature.of[QA])

/** Asks each question in order. Each call gets the turns before it as its history. Returns the answers. */
def converse(questions: List[String])(using RuntimeContext): Either[DspyError, List[String]] =
  def loop(remaining: List[String], history: History, answers: List[String]): Either[DspyError, List[String]] =
    remaining match
      case Nil              => Right(answers)
      case question :: rest => predict((question = question, history = history)).flatMap { outputs =>
          val answer = outputs.output.answer
          loop(rest, history :+ DynamicValues.record("question" := question, "answer" := answer), answers :+ answer)
        }
  loop(questions, History.empty, Nil)

A message can also hold the tool calls of a turn and the results of those calls (History.withToolCallResults). With native function calling, the adapter sends them to the model as tool messages.

Summary

Form Declared with Use when
Inline Signature.fromType[(in: I) => (out: O)] Quick, primitive in/out.
With instructions Signature.fromType[...](instructions = ...) You need to steer the model.
Class-based Signature.of[T <: Spec] Named fields, enums, constrained outputs.
Case classes Signature.derived[In, Out] Inputs and outputs are already case classes.
Builder Signature.builder(...) Shapes built at runtime, or quick exploration.
Custom types any case class derives Schema Structured inputs/outputs.

A signature only declares the task. To run it, you wrap it in a module.

Next: Modules.