Skip to content

Day 2 — Typed Model Output

Today the “AI” result stops being an unstructured paragraph. Brandforge expects a business profile with an exact shape. That makes the output usable by code, but it does not make the contents true.

Terminal window
cd rust/rust-ai-engineering
cargo run -p brandforge -- audit \
--source fixtures/brandforge/site.md \
--profile fixtures/brandforge/business-profile.json

Open the profile fixture while you read. Treat it as a saved response from a model adapter.

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BusinessProfile {
pub name: String,
pub website: String,
pub tagline: String,
pub service_area: String,
pub services: Vec<String>,
pub palette: Palette,
pub claims: Vec<Claim>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Claim {
pub text: String,
pub evidence_quote: String,
}

derive(Deserialize) generates the decoder. deny_unknown_fields catches output drift: if a model starts returning confidence_score, the program rejects it instead of silently discarding a field the prompt author may believe is being used.

let profile: BusinessProfile = serde_json::from_slice(&profile_bytes)?;
profile.validate()?;

These lines establish different facts:

  • deserialization proves JSON types and field names match;
  • validation proves application invariants such as nonempty services and six-digit colors;
  • tomorrow’s evidence audit asks whether the claims are supported;
  • nothing here proves that an advertisement will convert.

Name the layer whenever you say “validated.”

The palette check is ordinary Rust:

fn is_hex_color(value: &str) -> bool {
value.len() == 7
&& value.starts_with('#')
&& value.bytes().skip(1).all(|byte| byte.is_ascii_hexdigit())
}

There is no reason to spend a model call checking a deterministic syntax rule.

Use Option<T> only when absence is a legitimate product state. A service area might be optional for an online-only business; the company name is not. Making every field optional produces a struct that parses almost anything and pushes uncertainty into every consumer.

For structured-output APIs, remember that provider schemas may require every field. Represent a logically optional value as a required nullable field at the wire boundary, then convert it into an idiomatic Option<T> inside your domain.

Copy the profile fixture and make one mutation at a time:

  1. change #174D2A to green;
  2. remove services;
  3. add an unknown customer_count field;
  4. replace services with a string.

Run brandforge audit after each mutation. Classify the failure as JSON syntax, structural decoding, or domain validation. Do not label them all “bad AI output”—the responsible fix differs.

The reference crate proves validation using plain values:

#[test]
fn rejects_an_invalid_color() {
let mut value = profile();
value.palette.primary = "green".to_owned();
assert!(value.validate().is_err());
}

Fast deterministic tests should cover the rules your application owns. Provider integration gets a smaller contract test later.

You turned probabilistic text generation into a narrow, typed boundary. Serde, structs, vectors, validation functions, and honest required fields appeared because the campaign pipeline needs stable data—not because today was “the structs chapter.”

Next: Day 3 — Provider Boundary →.