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.
Run the boundary
Section titled “Run the boundary”cd rust/rust-ai-engineeringcargo run -p brandforge -- audit \ --source fixtures/brandforge/site.md \ --profile fixtures/brandforge/business-profile.jsonOpen the profile fixture while you read. Treat it as a saved response from a model adapter.
Describe the output as Rust data
Section titled “Describe the output as Rust data”#[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.
Decode, then validate
Section titled “Decode, then validate”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.
Option is not a substitute for thinking
Section titled “Option is not a substitute for thinking”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.
Break it deliberately
Section titled “Break it deliberately”Copy the profile fixture and make one mutation at a time:
- change
#174D2Atogreen; - remove
services; - add an unknown
customer_countfield; - replace
serviceswith 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.
Test without a model
Section titled “Test without a model”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.
What you built
Section titled “What you built”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 →.