Skip to main content

typesayer_types/
signature.rs

1// Copyright 2026 Thomas Santerre and Moderately AI Inc.
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Signature definitions for structured LLM prediction.
6//!
7//! A [`Signature`] defines the input/output contract for a prediction: what fields
8//! the caller provides, what fields the language model produces, and the task
9//! instructions that guide the model.
10
11use serde::{Deserialize, Serialize};
12
13use crate::{
14    error::{PredictError, Result},
15    field::{FieldDef, FieldKind, FieldType, OneOfDiscriminator, VariantArm},
16};
17
18/// A signature defines the input/output contract for a prediction.
19///
20/// Build with [`Signature::builder`]. Field ordering is preserved and deterministic.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Signature {
23    instructions: String,
24    fields: Vec<FieldDef>,
25}
26
27impl Signature {
28    /// Create a builder for constructing a signature.
29    pub fn builder(instructions: impl Into<String>) -> SignatureBuilder {
30        SignatureBuilder {
31            instructions: instructions.into(),
32            fields: Vec::new(),
33        }
34    }
35
36    /// The task instructions that guide the language model.
37    #[must_use]
38    pub fn instructions(&self) -> &str {
39        &self.instructions
40    }
41
42    /// Iterate over input fields in declaration order.
43    pub fn input_fields(&self) -> impl Iterator<Item = &FieldDef> {
44        self.fields.iter().filter(|f| f.kind == FieldKind::Input)
45    }
46
47    /// Iterate over output fields in declaration order.
48    pub fn output_fields(&self) -> impl Iterator<Item = &FieldDef> {
49        self.fields.iter().filter(|f| f.kind == FieldKind::Output)
50    }
51
52    /// All fields in declaration order.
53    #[must_use]
54    pub fn fields(&self) -> &[FieldDef] {
55        &self.fields
56    }
57
58    /// Serialize this signature to a JSON string.
59    ///
60    /// # Errors
61    ///
62    /// Returns a `PredictError` wrapping a `serde_json` error if serialization
63    /// fails (not expected for valid Signature values).
64    pub fn dump_state(&self) -> Result<String> {
65        serde_json::to_string(self).map_err(Into::into)
66    }
67
68    /// Update the task instructions.
69    pub fn set_instructions(&mut self, instructions: impl Into<String>) {
70        self.instructions = instructions.into();
71    }
72
73    /// Return a new signature with an output field prepended before existing outputs.
74    ///
75    /// Used by `Predict::chain_of_thought` to
76    /// inject a reasoning field ahead of the declared outputs.
77    #[must_use]
78    pub fn with_prepended_output(&self, field: FieldDef) -> Self {
79        let mut new_fields = Vec::with_capacity(self.fields.len() + 1);
80        let mut inserted = false;
81        for f in &self.fields {
82            if f.kind == FieldKind::Output && !inserted {
83                new_fields.push(FieldDef {
84                    kind: FieldKind::Output,
85                    ..field.clone()
86                });
87                inserted = true;
88            }
89            new_fields.push(f.clone());
90        }
91        if !inserted {
92            new_fields.push(FieldDef {
93                kind: FieldKind::Output,
94                ..field
95            });
96        }
97        Self {
98            instructions: self.instructions.clone(),
99            fields: new_fields,
100        }
101    }
102
103    /// Deserialize a signature from a JSON string.
104    ///
105    /// # Errors
106    ///
107    /// Returns a `PredictError` when the JSON is malformed or describes a
108    /// signature with no input or output fields.
109    pub fn load_state(json: &str) -> Result<Self> {
110        let sig: Self = serde_json::from_str(json)?;
111        // Validate the deserialized signature has inputs and outputs
112        if sig.input_fields().next().is_none() {
113            return Err(PredictError::invalid_signature(
114                "signature must have at least one input field",
115            ));
116        }
117        if sig.output_fields().next().is_none() {
118            return Err(PredictError::invalid_signature(
119                "signature must have at least one output field",
120            ));
121        }
122        Ok(sig)
123    }
124}
125
126/// Builder for constructing a [`Signature`].
127///
128/// Enforces that the signature has at least one input and one output field at
129/// build time.
130pub struct SignatureBuilder {
131    instructions: String,
132    fields: Vec<FieldDef>,
133}
134
135impl SignatureBuilder {
136    /// Add an input field. The field's `kind` is set to [`FieldKind::Input`]
137    /// regardless of what was passed.
138    #[must_use]
139    pub fn input(mut self, mut def: FieldDef) -> Self {
140        def.kind = FieldKind::Input;
141        self.fields.push(def);
142        self
143    }
144
145    /// Add an output field. The field's `kind` is set to [`FieldKind::Output`]
146    /// regardless of what was passed.
147    #[must_use]
148    pub fn output(mut self, mut def: FieldDef) -> Self {
149        def.kind = FieldKind::Output;
150        self.fields.push(def);
151        self
152    }
153
154    /// Validate and build the signature.
155    ///
156    /// Validation covers: at-least-one input + at-least-one output
157    /// (existing); structural validation of every transitively-reachable
158    /// [`FieldType::OneOf`] and
159    /// [`FieldType::AnyOf`] field (new) — non-empty
160    /// arms, discriminator parallelism with arms, unique tag values, and
161    /// arm-shape consistency with the named discriminator property.
162    ///
163    /// Schema-conversion-driven construction (via `field_type_from_schema` in
164    /// `typesayer`) is validated at conversion time and would not
165    /// produce malformed variants here. The validation in this builder is the
166    /// safety net for callers that construct `FieldType::OneOf` / `AnyOf`
167    /// programmatically.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`PredictError::InvalidSignature`] if there are no input or
172    /// output fields, or if any variant field violates the structural
173    /// constraints above.
174    pub fn build(self) -> Result<Signature> {
175        let has_input = self.fields.iter().any(|f| f.kind == FieldKind::Input);
176        let has_output = self.fields.iter().any(|f| f.kind == FieldKind::Output);
177
178        if !has_input {
179            return Err(PredictError::invalid_signature(
180                "signature must have at least one input field",
181            ));
182        }
183        if !has_output {
184            return Err(PredictError::invalid_signature(
185                "signature must have at least one output field",
186            ));
187        }
188
189        for field in &self.fields {
190            validate_variant_shapes(&field.name, &field.field_type)?;
191        }
192
193        Ok(Signature {
194            instructions: self.instructions,
195            fields: self.fields,
196        })
197    }
198}
199
200/// Recursively validate every variant FieldType reachable from `ft` rooted
201/// at `path`. Path is used for the error message so callers can locate the
202/// offending field within nested structures.
203fn validate_variant_shapes(path: &str, ft: &FieldType) -> Result<()> {
204    match ft {
205        FieldType::OneOf {
206            arms,
207            discriminator,
208        } => {
209            validate_variant_arms(path, arms, "OneOf")?;
210            if let Some(disc) = discriminator {
211                validate_one_of_discriminator(path, arms, disc)?;
212            }
213            for (i, arm) in arms.iter().enumerate() {
214                let arm_path = format!("{path}#arm{i}");
215                validate_variant_shapes(&arm_path, &arm.field_type)?;
216            }
217            Ok(())
218        }
219        FieldType::AnyOf { arms } => {
220            validate_variant_arms(path, arms, "AnyOf")?;
221            for (i, arm) in arms.iter().enumerate() {
222                let arm_path = format!("{path}#arm{i}");
223                validate_variant_shapes(&arm_path, &arm.field_type)?;
224            }
225            Ok(())
226        }
227        FieldType::List(inner) | FieldType::Nullable(inner) | FieldType::Map(inner) => {
228            validate_variant_shapes(path, inner)
229        }
230        FieldType::Object(fields) => {
231            for f in fields {
232                let nested = format!("{path}.{}", f.name);
233                validate_variant_shapes(&nested, &f.field_type)?;
234            }
235            Ok(())
236        }
237        FieldType::String
238        | FieldType::Int
239        | FieldType::Float
240        | FieldType::Bool
241        | FieldType::Enum(_)
242        | FieldType::Media { .. } => Ok(()),
243    }
244}
245
246fn validate_variant_arms(path: &str, arms: &[VariantArm], kind: &str) -> Result<()> {
247    if arms.is_empty() {
248        return Err(PredictError::invalid_signature(format!(
249            "{kind} field at `{path}` must declare at least one arm"
250        )));
251    }
252    Ok(())
253}
254
255/// For a discriminator-tagged OneOf, enforce: tags.len() == arms.len(),
256/// unique tag values, and every arm is an Object that includes the named
257/// property carrying the arm's tag as a const-restricted `Enum` (or
258/// `Enum(vec![tag])` produced by the schema converter's `const` fold).
259fn validate_one_of_discriminator(
260    path: &str,
261    arms: &[VariantArm],
262    discriminator: &OneOfDiscriminator,
263) -> Result<()> {
264    if discriminator.tags.len() != arms.len() {
265        return Err(PredictError::invalid_signature(format!(
266            "OneOf at `{path}` discriminator has {} tags but {} arms; the \
267             vectors must be parallel",
268            discriminator.tags.len(),
269            arms.len()
270        )));
271    }
272
273    let mut seen = std::collections::HashSet::with_capacity(discriminator.tags.len());
274    for tag in &discriminator.tags {
275        if !seen.insert(tag.as_str()) {
276            return Err(PredictError::invalid_signature(format!(
277                "OneOf at `{path}` discriminator tag `{tag}` appears more than \
278                 once; tags must be unique"
279            )));
280        }
281    }
282
283    for (i, arm) in arms.iter().enumerate() {
284        let arm_path = format!("{path}#{tag}", tag = discriminator.tags[i]);
285        validate_arm_carries_discriminator(
286            &arm_path,
287            &arm.field_type,
288            &discriminator.property,
289            &discriminator.tags[i],
290        )?;
291    }
292    Ok(())
293}
294
295/// Walk through a single Nullable layer if present (mirrors the schema
296/// converter's inference), then assert the arm is an `Object` declaring the
297/// discriminator property with an `Enum` whose only variant is the arm's tag.
298fn validate_arm_carries_discriminator(
299    arm_path: &str,
300    arm_type: &FieldType,
301    property: &str,
302    tag: &str,
303) -> Result<()> {
304    let inner = match arm_type {
305        FieldType::Nullable(inner) => inner.as_ref(),
306        other => other,
307    };
308    let FieldType::Object(fields) = inner else {
309        return Err(PredictError::invalid_signature(format!(
310            "tagged OneOf arm at `{arm_path}` must be an Object (or \
311             Nullable<Object>); got `{}`",
312            arm_type.type_label()
313        )));
314    };
315    let prop = fields.iter().find(|f| f.name == property).ok_or_else(|| {
316        PredictError::invalid_signature(format!(
317            "tagged OneOf arm at `{arm_path}` is missing the discriminator \
318             property `{property}`"
319        ))
320    })?;
321    match &prop.field_type {
322        FieldType::Enum(variants) if variants.iter().any(|v| v == tag) => Ok(()),
323        other => Err(PredictError::invalid_signature(format!(
324            "tagged OneOf arm at `{arm_path}` declares discriminator \
325             property `{property}` as `{}`, but the discriminator's tag \
326             `{tag}` requires an Enum variant containing it (typical: \
327             single-element Enum from a const-restricted schema)",
328            other.type_label()
329        ))),
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::field::FieldType;
337
338    fn simple_signature() -> Signature {
339        Signature::builder("Answer the question.")
340            .input(FieldDef::input(
341                "question",
342                FieldType::String,
343                "The user question",
344            ))
345            .output(FieldDef::output("answer", FieldType::String, "The answer"))
346            .build()
347            .unwrap()
348    }
349
350    #[test]
351    fn builder_produces_valid_signature() {
352        let sig = simple_signature();
353        assert_eq!(sig.instructions(), "Answer the question.");
354        assert_eq!(sig.input_fields().count(), 1);
355        assert_eq!(sig.output_fields().count(), 1);
356    }
357
358    #[test]
359    fn builder_no_inputs_fails() {
360        let result = Signature::builder("test")
361            .output(FieldDef::output("answer", FieldType::String, "answer"))
362            .build();
363        assert!(result.is_err());
364        let err = result.unwrap_err().to_string();
365        assert!(err.contains("input"));
366    }
367
368    #[test]
369    fn builder_no_outputs_fails() {
370        let result = Signature::builder("test")
371            .input(FieldDef::input("question", FieldType::String, "question"))
372            .build();
373        assert!(result.is_err());
374        let err = result.unwrap_err().to_string();
375        assert!(err.contains("output"));
376    }
377
378    #[test]
379    fn field_ordering_preserved() {
380        let sig = Signature::builder("test")
381            .input(FieldDef::input("a", FieldType::String, "first"))
382            .input(FieldDef::input("b", FieldType::Int, "second"))
383            .output(FieldDef::output("x", FieldType::String, "third"))
384            .output(FieldDef::output("y", FieldType::Bool, "fourth"))
385            .build()
386            .unwrap();
387
388        let input_names: Vec<_> = sig.input_fields().map(|f| f.name.as_str()).collect();
389        assert_eq!(input_names, vec!["a", "b"]);
390
391        let output_names: Vec<_> = sig.output_fields().map(|f| f.name.as_str()).collect();
392        assert_eq!(output_names, vec!["x", "y"]);
393    }
394
395    #[test]
396    fn dump_load_round_trip() {
397        let sig = Signature::builder("Classify sentiment.")
398            .input(FieldDef::input("text", FieldType::String, "Input text"))
399            .output(FieldDef::output(
400                "sentiment",
401                FieldType::Enum(vec!["positive".into(), "negative".into(), "neutral".into()]),
402                "The sentiment",
403            ))
404            .build()
405            .unwrap();
406
407        let json = sig.dump_state().unwrap();
408        let restored = Signature::load_state(&json).unwrap();
409
410        assert_eq!(sig.instructions(), restored.instructions());
411        assert_eq!(sig.fields().len(), restored.fields().len());
412        for (a, b) in sig.fields().iter().zip(restored.fields().iter()) {
413            assert_eq!(a, b);
414        }
415    }
416
417    #[test]
418    fn dump_load_with_nested_types() {
419        let sig = Signature::builder("Extract info.")
420            .input(FieldDef::input("doc", FieldType::String, "The document"))
421            .output(FieldDef::output(
422                "entities",
423                FieldType::List(Box::new(FieldType::Object(vec![
424                    crate::field::ObjectField {
425                        name: "name".into(),
426                        description: "Entity name".into(),
427                        field_type: FieldType::String,
428                    },
429                    crate::field::ObjectField {
430                        name: "type".into(),
431                        description: "Entity type".into(),
432                        field_type: FieldType::String,
433                    },
434                ]))),
435                "Extracted entities",
436            ))
437            .build()
438            .unwrap();
439
440        let json = sig.dump_state().unwrap();
441        let restored = Signature::load_state(&json).unwrap();
442        assert_eq!(sig.fields(), restored.fields());
443    }
444
445    // ============================================================
446    // OneOf / AnyOf structural validation at Signature::build.
447    //
448    // Schema-conversion-driven construction already rejects malformed
449    // input. These tests guard the programmatic-construction path:
450    // a caller who builds a FieldType::OneOf by hand with mismatched
451    // tag/arm parallelism, duplicate tags, or arms that lack the named
452    // discriminator property must get a build-time error rather than
453    // undefined behavior at parse time.
454    // ============================================================
455
456    use crate::field::{ObjectField, OneOfDiscriminator, VariantArm};
457
458    fn tagged_oneof_object_arm(tag: &str, extra_field: (&str, FieldType)) -> VariantArm {
459        VariantArm {
460            description: tag.into(),
461            field_type: FieldType::Object(vec![
462                ObjectField {
463                    name: "kind".into(),
464                    description: String::new(),
465                    field_type: FieldType::Enum(vec![tag.into()]),
466                },
467                ObjectField {
468                    name: extra_field.0.into(),
469                    description: String::new(),
470                    field_type: extra_field.1,
471                },
472            ]),
473        }
474    }
475
476    #[test]
477    fn build_rejects_oneof_with_empty_arms() {
478        let result = Signature::builder("inst")
479            .input(FieldDef::input("q", FieldType::String, ""))
480            .output(FieldDef::output(
481                "out",
482                FieldType::OneOf {
483                    arms: vec![],
484                    discriminator: None,
485                },
486                "",
487            ))
488            .build();
489        let err = result.unwrap_err().to_string();
490        assert!(err.contains("at least one arm"), "got: {err}");
491        assert!(err.contains("OneOf"), "got: {err}");
492    }
493
494    #[test]
495    fn build_rejects_anyof_with_empty_arms() {
496        let result = Signature::builder("inst")
497            .input(FieldDef::input("q", FieldType::String, ""))
498            .output(FieldDef::output(
499                "out",
500                FieldType::AnyOf { arms: vec![] },
501                "",
502            ))
503            .build();
504        let err = result.unwrap_err().to_string();
505        assert!(err.contains("at least one arm"), "got: {err}");
506        assert!(err.contains("AnyOf"), "got: {err}");
507    }
508
509    #[test]
510    fn build_rejects_discriminator_with_mismatched_tag_count() {
511        let result = Signature::builder("inst")
512            .input(FieldDef::input("q", FieldType::String, ""))
513            .output(FieldDef::output(
514                "out",
515                FieldType::OneOf {
516                    arms: vec![
517                        tagged_oneof_object_arm("a", ("x", FieldType::Int)),
518                        tagged_oneof_object_arm("b", ("y", FieldType::String)),
519                    ],
520                    discriminator: Some(OneOfDiscriminator {
521                        property: "kind".into(),
522                        tags: vec!["a".into()],
523                    }),
524                },
525                "",
526            ))
527            .build();
528        let err = result.unwrap_err().to_string();
529        assert!(err.contains("1 tags but 2 arms"), "got: {err}");
530        assert!(err.contains("parallel"), "got: {err}");
531    }
532
533    #[test]
534    fn build_rejects_duplicate_discriminator_tags() {
535        let result = Signature::builder("inst")
536            .input(FieldDef::input("q", FieldType::String, ""))
537            .output(FieldDef::output(
538                "out",
539                FieldType::OneOf {
540                    arms: vec![
541                        tagged_oneof_object_arm("dup", ("x", FieldType::Int)),
542                        tagged_oneof_object_arm("dup", ("y", FieldType::String)),
543                    ],
544                    discriminator: Some(OneOfDiscriminator {
545                        property: "kind".into(),
546                        tags: vec!["dup".into(), "dup".into()],
547                    }),
548                },
549                "",
550            ))
551            .build();
552        let err = result.unwrap_err().to_string();
553        assert!(err.contains("dup"), "got: {err}");
554        assert!(err.contains("unique"), "got: {err}");
555    }
556
557    #[test]
558    fn build_rejects_tagged_arm_that_is_not_an_object() {
559        let result = Signature::builder("inst")
560            .input(FieldDef::input("q", FieldType::String, ""))
561            .output(FieldDef::output(
562                "out",
563                FieldType::OneOf {
564                    arms: vec![VariantArm {
565                        description: "scalar arm".into(),
566                        field_type: FieldType::Int,
567                    }],
568                    discriminator: Some(OneOfDiscriminator {
569                        property: "kind".into(),
570                        tags: vec!["a".into()],
571                    }),
572                },
573                "",
574            ))
575            .build();
576        let err = result.unwrap_err().to_string();
577        assert!(err.contains("must be an Object"), "got: {err}");
578    }
579
580    #[test]
581    fn build_rejects_tagged_arm_missing_discriminator_property() {
582        let result = Signature::builder("inst")
583            .input(FieldDef::input("q", FieldType::String, ""))
584            .output(FieldDef::output(
585                "out",
586                FieldType::OneOf {
587                    arms: vec![VariantArm {
588                        description: "no discriminator field".into(),
589                        field_type: FieldType::Object(vec![ObjectField {
590                            name: "x".into(),
591                            description: String::new(),
592                            field_type: FieldType::Int,
593                        }]),
594                    }],
595                    discriminator: Some(OneOfDiscriminator {
596                        property: "kind".into(),
597                        tags: vec!["a".into()],
598                    }),
599                },
600                "",
601            ))
602            .build();
603        let err = result.unwrap_err().to_string();
604        assert!(
605            err.contains("missing the discriminator property"),
606            "got: {err}"
607        );
608        assert!(err.contains("kind"), "got: {err}");
609    }
610
611    #[test]
612    fn build_rejects_tagged_arm_with_wrong_enum_for_discriminator() {
613        let result = Signature::builder("inst")
614            .input(FieldDef::input("q", FieldType::String, ""))
615            .output(FieldDef::output(
616                "out",
617                FieldType::OneOf {
618                    arms: vec![VariantArm {
619                        description: "wrong tag enum".into(),
620                        field_type: FieldType::Object(vec![
621                            ObjectField {
622                                name: "kind".into(),
623                                description: String::new(),
624                                field_type: FieldType::Enum(vec!["other".into()]),
625                            },
626                            ObjectField {
627                                name: "x".into(),
628                                description: String::new(),
629                                field_type: FieldType::Int,
630                            },
631                        ]),
632                    }],
633                    discriminator: Some(OneOfDiscriminator {
634                        property: "kind".into(),
635                        tags: vec!["a".into()],
636                    }),
637                },
638                "",
639            ))
640            .build();
641        let err = result.unwrap_err().to_string();
642        assert!(err.contains("Enum variant containing"), "got: {err}");
643    }
644
645    #[test]
646    fn build_accepts_well_formed_tagged_oneof() {
647        Signature::builder("inst")
648            .input(FieldDef::input("q", FieldType::String, ""))
649            .output(FieldDef::output(
650                "out",
651                FieldType::OneOf {
652                    arms: vec![
653                        tagged_oneof_object_arm("a", ("x", FieldType::Int)),
654                        tagged_oneof_object_arm("b", ("y", FieldType::String)),
655                    ],
656                    discriminator: Some(OneOfDiscriminator {
657                        property: "kind".into(),
658                        tags: vec!["a".into(), "b".into()],
659                    }),
660                },
661                "",
662            ))
663            .build()
664            .expect("well-formed tagged OneOf must build");
665    }
666
667    #[test]
668    fn build_validates_variant_nested_inside_list_and_nullable() {
669        // Nested empty-arm OneOf reachable through List<Nullable<OneOf>>:
670        // the recursive walker should catch it.
671        let result = Signature::builder("inst")
672            .input(FieldDef::input("q", FieldType::String, ""))
673            .output(FieldDef::output(
674                "out",
675                FieldType::List(Box::new(FieldType::Nullable(Box::new(FieldType::OneOf {
676                    arms: vec![],
677                    discriminator: None,
678                })))),
679                "",
680            ))
681            .build();
682        let err = result.unwrap_err().to_string();
683        assert!(err.contains("at least one arm"), "got: {err}");
684    }
685
686    #[test]
687    fn load_state_validates_fields() {
688        // JSON with no input fields
689        let json = serde_json::json!({
690            "instructions": "test",
691            "fields": [{
692                "name": "answer",
693                "description": "answer",
694                "field_type": {"kind": "string"},
695                "kind": "output"
696            }]
697        });
698        let result = Signature::load_state(&json.to_string());
699        assert!(result.is_err());
700    }
701
702    #[test]
703    fn builder_forces_kind() {
704        // Even if you pass a FieldDef with the wrong kind, builder overrides it
705        let wrong_kind = FieldDef::output("question", FieldType::String, "A question");
706        let sig = Signature::builder("test")
707            .input(wrong_kind) // builder forces Input
708            .output(FieldDef::output("answer", FieldType::String, "answer"))
709            .build()
710            .unwrap();
711
712        let input = sig.input_fields().next().unwrap();
713        assert_eq!(input.kind, FieldKind::Input);
714    }
715}