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.