Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Welcome to Guiding Rust 🩀

This is a two-day course on Rust architecture and design patterns. It’s for engineers who read, review, and steer Rust rather than typing every line themselves. That situation is common now. Google said in October 2024 that more than a quarter of its new code was AI-generated, and the share has only grown since. This course is built for that setting.

The course assumes you’ve completed the first three days of Comprehensive Rust or know the equivalent material. It follows the same conventions: short pages and one idea per page.

We’ll practice three skills:

  • Specifying architecture in types and signatures before code is generated.
  • Recognizing design problems in generated code quickly.
  • Turning a review finding into a prompt that fixes the design.

Schedule

Including breaks, each half-day session takes about 4 hours:

SessionSegment
Day 1 MorningPatterns for reading and steering Rust
Day 1 AfternoonProject A: canscan, a CLI built with your agent
Day 2 MorningProject B: framecache, a library built skeleton-first
Day 2 AfternoonProject C: guardian, testing a stochastic system

Day 1 morning is the only lecture block. The rest of the course is project work in pairs, using your own coding agent.

Target audience

Engineers with a few years of experience in another language: C, C++, Java, Python, or Go. You can read Rust with some effort. You work in a setting where an LLM produces much of the Rust source and humans are responsible for its architecture, correctness, and maintainability.

Non-goals

  • Typing practice. You’ll write little Rust by hand in this course.
  • Prompt engineering. Prompts appear only as the way to deliver a design decision you’ve already made.
  • Covering all of Idiomatic Rust. We select the patterns that matter most for reviewing generated code and spend the remaining time on practice.

Welcome to Day 1

This morning is patterns: the things you’ll look for when you review and steer generated Rust. This afternoon you’ll use all of them on the first project.

Day 1 at a glance

BlockDuration
Morning session4 hours 30 minutes
Lunch1 hour
Afternoon session3 hours 30 minutes

Day 1 Morning schedule

Including a 10 minute break, this session should take about 4 hours and 30 minutes. It contains:

SegmentDuration
Roles: Architect, Author, Reviewer15 minutes
Rust Refresher for Readers30 minutes
Build Your Own Guardrails20 minutes
Signature-Driven Design70 minutes
Break10 minutes
Types That Enforce45 minutes
Errors as a Review Signal25 minutes
The Smell Catalog55 minutes

Day 1 Afternoon schedule

Including a 10 minute break, this session should take about 3 hours and 30 minutes. It contains:

SegmentDuration
Project A brief: canscan10 minutes
Project work (pairs), part one1 hour 15 minutes
Break10 minutes
Project work (pairs), part two1 hour 15 minutes
Debrief40 minutes

Roles: Architect, Author, Reviewer

An agent-assisted workflow has three roles, and the agent only fills one of them:

RoleWhoMain artifact
ArchitectYouTypes, signatures, module boundaries, the spec
AuthorThe agentFunction bodies, boilerplate, first-draft tests
ReviewerYouThe verdict, and the next prompt

Two things fall out of that:

  • Design happens before generation, in types. You specify the architecture in structs, enums, function signatures, and trait signatures, and the compiler enforces it while the agent fills in bodies. A review can catch problems in the bodies, but it can’t add a design that was never made.
  • A review finding is complete when it becomes a prompt. “This is wrong” isn’t actionable on its own. “This violates principle P, regenerate with instruction Q” can actually be acted on. The course teaches each pattern in that form: smell, then principle, then prompt.

Rust is a good fit for this workflow because the architect’s intent gets machine-checked. Put an ownership decision, a state machine, or an error contract into types, and it stops being a convention the agent can drift from. Generated code that ignores it doesn’t compile.

Rust Refresher for Readers

This refresher is about reading, not writing: what a construct tells you when you see it in a review.

Ownership

You seeIt means
fn f(x: Thing)f takes ownership. The caller can’t use x afterwards.
fn f(x: &Thing)f borrows x read-only. The caller keeps ownership.
fn f(x: &mut Thing)f mutates x in place, with exclusive access.
let y = x.clone()A copy. Sometimes deliberate, sometimes not (see the Smell Catalog).

Traits

  • A trait is a named capability: “this type can be displayed”, “this type can be iterated”, that kind of thing.
  • impl Trait for Type says the type has the capability. A bound T: Trait says the caller needs it.
  • dyn Trait selects the concrete type at runtime. Generics select it at compile time.

Two questions for any Rust file

  1. Who owns which data, and for how long? Look at the types: which ones hold data and which ones only reference it.
  2. What can fail, and where does the failure surface? Look at the Results. If the design is honest, fallibility is visible in the signatures.

Warm-up

Answer the two questions for this code:

use std::collections::HashMap;

pub struct Inventory {
    parts: HashMap<String, u32>,
}

pub struct OutOfStock;

impl Inventory {
    pub fn part_count(&self, part: &str) -> Option<u32> {
        self.parts.get(part).copied()
    }

    pub fn take(&mut self, part: &str, n: u32) -> Result<(), OutOfStock> {
        match self.parts.get_mut(part) {
            Some(count) if *count >= n => {
                *count -= n;
                Ok(())
            }
            _ => Err(OutOfStock),
        }
    }

    pub fn into_report(self) -> String {
        let mut lines: Vec<String> = self
            .parts
            .iter()
            .map(|(name, count)| format!("{name}: {count}"))
            .collect();
        lines.sort();
        lines.join("\n")
    }
}

Build Your Own Guardrails

You’re going to spend two days reviewing code you didn’t write. The reviewing is manual. Enforcing what you find doesn’t have to be. Rust’s tooling can turn a review finding into a check that runs on every build. And the agent that wrote the code is good at writing that configuration too.

The pattern:

  1. A review catches a problem: an unwrap() on input, a dependency nobody vetted, a test that asserts nothing.
  2. You fix the instance.
  3. You ask the agent to add a check that rejects the whole class from now on.

A prompt for step 3 looks like this:

Add a Clippy configuration that rejects this class of code. Show me the lint names, set the levels in the crate root, and add a clippy.toml with disallowed-methods for the calls we banned in review.

The agent knows the lint tables better than most of us do. You decide what must never happen. The tooling checks it on every change after that, including the agent’s own.

Tools we’ll use or mention

  • Clippy with a project configuration. Running default Clippy is a start. A per-project policy does more: lint levels in the crate root (#![warn(clippy::unwrap_used)] and similar), a clippy.toml with disallowed-methods and disallowed-types for calls your review banned, and --deny warnings in CI. Project A sets one up for the parser this afternoon.
  • Miri interprets your code and detects undefined behavior and memory errors that tests can miss. Run it whenever unsafe appears. It’s also cheap to run when none does. cargo +nightly miri test on Project B takes about two minutes.
  • mutest-rs mutates your code and reruns your tests. A mutant that survives marks a spot where the suite asserts nothing. This is how you check whether the agent’s green tests verify anything. Project C’s metrics tests are a good target.
  • Loom checks concurrent code by exploring thread interleavings. Nothing in this course is concurrent, so we only mention it: know that it exists, and reach for it when threads arrive.
  • Kani proves properties by bounded model checking. A passing test says “we found no failing input”. A Kani proof says “no input up to this size can fail”. Writing one proof for the Project C arbiter is a stretch goal.
  • Verus extends Rust with machine-checked specifications. We’ll not use it in this course. It’s worth knowing about for contracts that matter enough to verify fully.

Why this works in Rust

Every tool on that list leans on the same property: in Rust the contracts live in the types, and the toolchain can be programmed against them. A lint you configure today enforces a review decision on every line written after it, including the ones the agent writes next week. So when you ask the agent for code, ask for the guardrail in the same prompt.

Signature-Driven Design

This segment should take about 70 minutes. It contains:

Slide / ExerciseDuration
The Skeleton Is the Spec10 minutes
Getting to a Skeleton: The Design Interview10 minutes
Reading a Signature as a Contract3 minutes
The Promises in a Signature5 minutes
Ownership in Signatures10 minutes
Fallibility in Signatures7 minutes
Flexibility in Signatures10 minutes
Traits as Extension Points5 minutes
Exercise: One Task, Two Skeletons10 minutes

In an agent-assisted codebase, the most valuable artifact a human writes is the skeleton: the structs, enums, function signatures, and trait signatures, with bodies left as todo!(). In this segment we look at how each part of a signature constrains generated code, and how to review signatures before reviewing bodies.

The Skeleton Is the Spec

A prose spec constrains the agent weakly. A skeleton of types and signatures constrains it mechanically:

/// Decodes raw frames into engineering-unit signal values.
pub struct SignalDecoder { /* fields to be decided */ }

impl SignalDecoder {
    /// Parse a decoder from a signal database. Rejects malformed input.
    pub fn from_dbc(src: &str) -> Result<Self, DbcParseError> { todo!() }

    /// Decode one frame. A frame for an unknown message is not an error.
    pub fn decode(&self, frame: &Frame) -> Option<DecodedSignals> { todo!() }
}

These nine lines already rule out several kinds of generated code:

  • A decoder built from a partially parsed database. Result forces the question of what happens on bad input.
  • Treating an unknown frame as an error or a crash. Option declares it a normal outcome.
  • Mutating the decoder while decoding. &self forbids it, which also keeps decoding shareable across threads.
  • An improvised error strategy. DbcParseError is a type you defined.

The workflow is a loop, and the division of labor in it doesn’t change: bodies are generated, skeletons are written by hand. A skeleton is a page of code at most, so typing it’s the cheap part. The decisions it records are the work, and a generated skeleton is a set of decisions nobody made.

  1. You write the skeleton.
  2. The agent fills in the bodies.
  3. You review the bodies.
  4. When a review finding recurs, you change the skeleton so that the whole class of defect no longer compiles, and regenerate.

Step 4, concretely: a review that keeps finding unwrap on lookups changes the signature the bodies are generated against.

// before: bodies keep unwrapping the miss
pub fn scale_for(&self, id: CanId) -> f64 { todo!() }

// after: the miss is in the type; unwrapping it is now visible
pub fn scale_for(&self, id: CanId) -> Option<f64> { todo!() }

Getting to a Skeleton: The Design Interview

The previous page assumes you can already answer the design questions a skeleton encodes. Often you can’t yet, and staring at an empty lib.rs doesn’t produce the answers. The agent can help here too, in a different role: before the session where it writes code, run a session where it asks you questions.

The setup

Start a separate session with a prompt like this:

You’re helping me design a Rust module before any code is written. Do not write code in this session. Interview me, one question at a time, about the decisions my type and signature skeleton depends on. Cover at least: the environment (std or no_std, threads or async, target platform), performance (data sizes, throughput, latency budgets, and whether zero-copy is justified by a measurement), ownership (which data lives where, for how long, and who may mutate it), failure policy (what can fail, which failures callers react to, and what may panic), and change (which sets of variants are closed, and where new implementations must plug in). Challenge vague answers. When I don’t know an answer, record it as an open spec question instead of assuming one. Stop after roughly ten to twelve questions or twenty minutes, whichever comes first. At the end, produce two lists: the decisions we made and the open questions.

Two rules while it runs:

  • You answer; the agent asks. If it starts proposing code, remind it: no code in this session.
  • End after roughly ten to twelve questions or twenty minutes, then require the two lists. The cap keeps the interview useful without letting it replace engineering judgment or the skeleton work.
  • “I don’t know” is a legitimate answer. It goes on the open-questions list and becomes a question for whoever owns the spec, instead of a guess buried in a signature.

From answers to skeleton

Here’s the interview that produces the SignalDecoder skeleton from the previous page:

The agent asksYou answerThe decision
How large is a signal database, and how often is it loaded?Tens of kilobytes, once at startup.Parse to owned data. Startup cost is irrelevant, so no zero-copy in from_dbc.
Who calls decode, and from how many threads?Several reader threads, on the hot path.decode takes &self with no interior mutability, so a decoder is shareable.
What happens on a frame whose id isn’t in the database?Common; the bus carries messages we don’t model.Option. Absence is a normal outcome, not an error.
Do callers react differently to different database load errors?The config tooling shows them to a person.An error enum with a source() chain, so the report reads as a path.
Is the set of signal value types closed?Numeric only for now; strings are possible later.An enum for decoded values. Adding a variant later is a compile-checked change.

The skeleton on the previous page is the record of these answers. Write it, freeze it, and start a fresh session for generation: the new session receives the skeleton and its doc comments, not the interview transcript. If a decision exists only in the transcript, it’s not in the spec yet.

The division of labor is the same in both sessions: the agent asks questions or fills in bodies. The skeleton is yours to write.

Reading a Signature as a Contract

Every element of a Rust signature is a promise to the caller. Consider this method, without its body:

pub fn arm(&mut self) -> Result<ArmedToken, ArmError>

How many promises can you read out of it?

List as many as you can before looking at the next page.

The Promises in a Signature

pub fn arm(&mut self) -> Result<ArmedToken, ArmError>
ElementThe promise
pubThis is API. Callers you can’t see may depend on it.
&mut selfArming changes this object and needs exclusive access.
Result<..>Arming can fail, and the caller must handle that.
ArmedTokenSuccess produces a value that later operations will require.
ArmErrorThe failure modes are enumerated in a type.

Method receivers

ReceiverContract
&selfObservation. No visible state change. Shareable.
&mut selfIn-place modification with exclusive access.
selfConsumption. The value transitions or ends, and its old state can’t be used again.

Generated code rarely uses self by value, but it’s the most useful receiver for design: it makes a state transition irreversible at compile time. The Typestate page builds on this.

Ownership in Signatures

Parameter and return types state the ownership policy of a function. When you write the skeleton you set the policy. When you review, you check that the generated body matches it.

Parameters

The function takesIt’s saying
s: &str“I only need to read this.” The default for read-only use.
s: String“I need to keep this”: storing it, sending it, or consuming it.
s: impl Into<String>“I need to keep it, and I will do the conversion so callers don’t have to.”
s: &mut String“I will edit this string in place.” Rare, and should be deliberate.
s: Cow<'_, str>“I sometimes need to keep it. Don’t copy until necessary.”

A useful review check: does the parameter type match what the body does with the data? A function that takes String but only reads it forces every caller to give up or clone a value for no reason. A function that takes &str and calls .to_string() on the first line has moved the copy decision to the wrong place.

Returns

  • Returning String or Vec<T>: the function produces owned data.
  • Returning &str borrowed from a parameter: no copy, but the output now lives only as long as the input. This is a real design decision. The Lifetime Smells page covers when it’s the wrong one.
  • Returning impl Iterator<Item = ..>: the caller decides whether and when to collect.

Fallibility in Signatures

Three return shapes, three meanings:

ShapeMeaning
T“This doesn’t fail”, or “failure here’s a bug, and a documented one”. The reviewer should believe it or flag it.
Option<T>“Absence is a normal outcome.” Lookup misses, exhausted iterators.
Result<T, E>“This can fail, and E says how.”

Panics are a fourth shape. A documented panic on a violated precondition is a legitimate contract: slice indexing works this way, and so does RefCell::borrow. A function that returns T but panics on ordinary input has a dishonest signature: that failure path belongs in the type.

Absence or failure?

The same operation can be modeled either way:

fn signal(&self, name: &str) -> Option<&SignalDef>;               // a miss is normal
fn signal(&self, name: &str) -> Result<&SignalDef, UnknownSignal>; // a miss is a defect

Which is right depends on the callers: do they probe for signals that may not exist, or do they name signals that must exist? Decide this in the skeleton. If you don’t, the agent decides it for you, module by module, with no guarantee the modules agree.

Flexibility in Signatures

Rust has several ways to write code that works with more than one type. Each is a different design commitment:

MechanismDispatchSet of typesReads as
enummatchClosed: you list the variants“These, and only these.”
Generics <T: Trait>StaticOpen, fixed at compile time“Anything with this capability, known now.”
dyn TraitDynamicOpen, chosen at runtime“Anything with this capability, chosen later.”
CompositionNoneNot polymorphism“This is made of parts.”

A decision procedure, usable in review and in prompts:

  1. Do you control the full set of variants, and is it small and stable? Use an enum. As long as matches avoid _ arms, adding a variant produces a compile error everywhere it’s not handled.
  2. Do downstream users need to plug in their own types? Use a trait. Prefer generics; use dyn for heterogeneous collections or to keep type parameters out of a public signature.
  3. Is the relationship just “has-a”? Use composition.

In trait definitions

  • A generic parameter on the trait (trait Store<V>) allows one type to implement the trait several times, once per V.
  • An associated type (trait Store { type V; }) allows one implementation with one choice of V. This is usually what you want.

Traits as Extension Points

A trait in a skeleton is a hole that someone else will fill: the agent now, or another team later. It needs the same care as the types around it.

/// A sink for decoded signal values. Implementations must be cheap to
/// call. Batching and I/O belong behind the trait, not in front of it.
pub trait SignalSink {
    /// Called once per decoded frame, in arrival order.
    fn accept(&mut self, signals: &DecodedSignals) -> Result<(), SinkError>;
}

Properties of a well-designed extension point:

  • It has as few methods as possible. Each method is a burden on every implementor, and an agent will happily generate many methods if the trait invites it.
  • Its doc comments state the contract: ordering, threading, and performance expectations. Implementors, including agents, rely on them.
  • It’s sealed unless external implementations are intended. The sealed trait pattern lets outside code name the trait but not implement it, which keeps your options open.

A useful review question for any trait: does it need to exist? A trait with one implementation and no test double can often be replaced by the concrete type, or by a function.

Exercise: One Task, Two Skeletons

An experiment you can run with any agent. Same task, same prompt, two different skeletons.

The task for both runs: “Implement the bodies: record a vehicle test session, add distance samples, mark completion, and report total distance in km.”

Skeleton A

pub struct Session {
    pub id: String,
    pub state: String,          // "created" | "running" | "done"
    pub samples: Vec<f64>,      // unit not stated
    pub done: bool,
}

pub fn add_sample(s: &mut Session, v: f64) -> bool { todo!() }
pub fn finish(s: &mut Session) -> bool { todo!() }
pub fn total_km(s: &Session) -> f64 { todo!() }

Skeleton B

pub struct Meters(pub f64);
pub struct SessionId(u64);

pub struct Running { id: SessionId, samples: Vec<Meters> }
pub struct Finished { id: SessionId, total: Meters }

impl Running {
    pub fn add_sample(&mut self, d: Meters) { todo!() }
    pub fn finish(self) -> Finished { todo!() }   // consumes the session
}

impl Finished {
    pub fn total_km(&self) -> f64 { todo!() }
}

In pairs: list every defect that’s possible in the code generated for A and impossible in B. Work through the lifecycle of a session: creation, sampling, finishing, reporting.

Types That Enforce

This segment should take about 45 minutes. It contains:

SlideDuration
Newtypes: Parse, Don’t Validate12 minutes
Enums: Illegal States, Unrepresentable12 minutes
Typestate: State Machines That Compile12 minutes
RAII, Guards, and Drop Bombs9 minutes

Four patterns with one purpose: move a runtime check into the type system, so generated code doesn’t need to remember to perform it.

For a reviewer, these patterns are also a signal. Their presence suggests the codebase was designed. Their absence, with validation scattered through bodies and states tracked in booleans, suggests it accumulated.

Newtypes: Parse, Don’t Validate

A newtype wraps a primitive to give it identity and rules:

pub struct CanId(u16);   // 11-bit standard CAN identifier

impl CanId {
    pub fn new(raw: u16) -> Result<Self, InvalidCanId> {
        if raw <= 0x7FF { Ok(Self(raw)) } else { Err(InvalidCanId(raw)) }
    }
}

Outside its module, the constructor is the only way to create one, so any CanId that exists is valid by construction, and no function that receives one needs to check it again.

Two approaches to input checking:

  • Validate: inspect the data, return bool, and keep passing the raw type around. That the data was checked isn’t recorded anywhere the compiler can see.
  • Parse: inspect the data once, at the boundary, and produce a richer type as proof. The fact is now in the type system, visible to every function, every reviewer, and every generation run.

In review, follow any primitive (String, u16, f64) that crosses more than one function boundary. If its validity is checked more than once, or never, a newtype belongs there. The prompt follows directly: “introduce CanId with a fallible constructor, and don’t pass raw u16 beyond the parse boundary.”

Enums: Illegal States, Unrepresentable

A struct that accumulated one flag at a time:

pub struct Connection {
    pub connected: bool,
    pub connecting: bool,
    pub addr: Option<SocketAddr>,   // Some iff connected?
    pub retry_count: Option<u32>,   // Some iff connecting?
    pub error: Option<String>,
}

Five fields allow 32 combinations, of which perhaps 4 mean anything. Every function that touches this struct must handle, or mishandle unnoticed, the other 28.

The same states as an enum:

pub enum Connection {
    Disconnected,
    Connecting { retry_count: u32 },
    Connected { addr: SocketAddr },
    Failed { error: ConnectError },
}

The 28 meaningless states can no longer be written down. And as long as matches avoid _ arms, adding a Draining variant later produces a compile error everywhere it’s not handled.

In review, count the bools and Options in a generated struct and ask whether they are independent or one state machine spread across fields. If combinations of them are meaningless, the fix is an enum whose variants carry their own data.

Typestate: State Machines That Compile

How do we prevent a method from being called in the wrong state? Take the enum idea one step further: give each state its own type, and make transitions consume the old state.

pub struct Unarmed(SafetyMonitor);
pub struct Armed(SafetyMonitor);

impl Unarmed {
    pub fn arm(self, checks: PreflightReport) -> Result<Armed, ArmError> {
        todo!()  // consumes self; the Unarmed value is gone on success
    }
}

impl Armed {
    pub fn trigger(&mut self) -> Action { todo!() }
    pub fn disarm(self) -> Unarmed { todo!() }
}

There’s no way to call trigger on an unarmed monitor. The method doesn’t exist on that type, so the mistake is caught while the code is being generated rather than when it runs.

When to use it:

Use typestate whenUse a plain enum when
Wrong-state calls are costly (safety, money, data loss)States change often at runtime and are inspected together
The state graph is small and stableThe graph is large or data-driven
Compile-time proof is worth some API weightMixed states must live in one collection

Builders with required fields are a common everyday form of this pattern: build() only exists once the required fields are set.

RAII, Guards, and Drop Bombs

In Rust, cleanup is the type’s job rather than the caller’s. Drop runs when a value goes out of scope, on every path, including early returns and ?.

Three uses, in increasing strength:

  1. Resource cleanup. Files close, sockets shut down, and locks release when their owners are dropped. Generated code inherits this if resources are modeled as owned values instead of raw handles with paired open and close calls.

  2. Scope guards. Run something on exit, whatever exit looks like:

pub struct BusQuiet<'a>(&'a BusController);   // constructor pauses traffic

impl Drop for BusQuiet<'_> {
    fn drop(&mut self) { self.0.resume(); }   // resume cannot be forgotten
}
  1. Drop bombs. A guard that panics if dropped without being defused. It says: this transaction must be explicitly committed or rolled back, not forgotten. It’s an assertion about the caller’s control flow.

In review, search generated code for paired manual calls, such as begin and end, acquire and release, or pause and resume, where the caller must remember to call the second one. Any early return or ? between the paired calls skips the second one. The prompt: “return a guard from begin whose Drop performs end, and remove the manual end.”

Errors as a Review Signal

This segment should take about 25 minutes. It contains:

SlideDuration
Designing Errors13 minutes
Thirty-Second Error Review12 minutes

Error handling shows quickly whether a generated codebase is ready for production. It’s where happy-path generation is most visible, and a few skeleton decisions go a long way.

Designing Errors

Two questions decide an error design. Both should be answered in the skeleton, not left to the agent.

Will callers react to specific failures, or only report them?

If callers need to react to specific failures, they need an enum to match on:

#[derive(Debug)]
pub enum DecodeError {
    UnknownMessage(CanId),
    Truncated { need: usize, got: usize },
    Db(DbcParseError),                // the cause travels in the variant
}

impl std::fmt::Display for DecodeError { /* one line per variant */ }

impl std::error::Error for DecodeError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            DecodeError::Db(e) => Some(e),  // preserved in the chain
            _ => None,
        }
    }
}

If callers will only report the failure (log it, show it, abort the request), an opaque error such as Box<dyn Error + Send + Sync>, with good context attached, is a reasonable and cheap choice.

A common rule of thumb: libraries enumerate, applications aggregate. A library can’t know what its callers will match on. An application usually knows that nothing will.

Where does context attach?

An error should pick up a line of context at each boundary it crosses. The crossing wraps the cause in a variant that adds what the boundary knows (which profile, which file), and it exposes the cause through source(). The final report then reads as a chain, for example “failed to load profile: dbc parse error: truncated line 41”, rather than a leaf error with no path back to the operation that failed.

Thirty-Second Error Review

You can assess a module’s error handling from its types, before reading any bodies:

You seeLikely conclusion
Result<T, ModuleError> with a dedicated enumThe failure modes were designed.
An opaque error type in application codeA reasonable default. Check that context is attached at boundaries.
An opaque error type in a library APICallers can’t match on failures. Flag it.
Result<T, String>The error path wasn’t designed.
-> T with unwrap() or expect() insideRead the messages. A panic on a documented invariant is a contract; a panic on input is a failure path hidden as a crash.
Several of the above in one crateModules were generated separately with no shared convention.

The last row is the most characteristic of generated codebases. In that case the inconsistency is the main finding: one convention applied uniformly is easier to review than a mixture of individually good ones.

The prompt that fixes a poorly graded module is usually structural: “define one XError enum for this module, implementing std::error::Error; convert all String and boxed-error returns to it, and attach context at each ? that crosses a module boundary.”

The Smell Catalog

This segment should take about 55 minutes. It contains:

Slide / ExerciseDuration
Clone Confetti7 minutes
Arc<Mutex<Everything>>7 minutes
Stringly-Typed Code5 minutes
unwrap() on External Input5 minutes
Over-Abstraction7 minutes
Lifetime Smells12 minutes
Exercise: Smell Catalog v112 minutes

A smell isn’t a bug. It’s an easy-to-spot surface feature that often indicates a harder-to-spot design problem. Smells let a reviewer triage a large amount of generated code quickly.

Each page in this catalog has the same structure:

  • Smell: what you see, usually greppable.
  • Why generation produces it: the agent’s tendency.
  • Principle: what good design does instead.
  • Prompt: the instruction that regenerates it correctly.

One rule applies to the whole catalog: every smell here has legitimate uses. The question in review isn’t “is this construct present?” but “did someone decide to use it?”

Clone Confetti

Smell. .clone() scattered through the code: clones whose result is immediately passed by reference, clones inside loops, and clones of large collections added to make the code compile.

for frame in frames.clone() {                  // whole Vec copied
    let name = decoder.clone().lookup(&frame); // decoder cloned per frame
    results.push(name.clone());                // name is not used again
}

Why generation produces it. A borrow-check error has two fixes: restructure the ownership, which requires understanding the whole design, or add a clone, which usually compiles immediately. An agent iterating until the code compiles tends to take the second option. Each stray clone marks a borrow error that was worked around instead of resolved. The loop, reconstructed:

let frames = load_frames();
for frame in frames { handle(frame); }  // `frames` moved into the loop
let n = frames.len();                   // error[E0382]: borrow of moved value

for frame in frames.clone() { .. }      // the agent's fix: copy everything
for frame in &frames { .. }             // the structural fix: borrow

Principle. A clone is a claim that two owners need independent copies of the data. A stray clone is one that fails that test: the second owner doesn’t exist. The structural fixes are to pass &T down, end the borrow earlier, or move ownership instead of copying.

Prompt. “Remove clones that exist only to satisfy the borrow checker. Prefer borrowing. Where ownership transfer is the intent, move instead of cloning. For each clone that remains, add a one-line comment explaining why two independent copies are required.”

The comment requirement makes each remaining clone carry its own justification, for the agent and for the next reviewer. Clones without a good justification tend to disappear in the regeneration.

Arc<Mutex<Everything>>

Smell. Arc<Mutex<T>> (or Arc<RwLock<T>>) as the default answer whenever two places touch the same data, including places that aren’t concurrent. In advanced cases, nested: Arc<Mutex<HashMap<String, Arc<Mutex<Entry>>>>>.

Why generation produces it. It compiles without restructuring: shared and mutable at once. Like cloning, it turns a design question (who owns this, who may mutate it, and when) into a local edit. It’s also very common in training data.

Principle. Try the alternatives before reaching for shared mutable state:

  1. Can one owner mutate, with others reading through short borrows? Use plain &mut.
  2. Is this a pipeline? Move data through channels. Ownership transfers and nothing is shared.
  3. Is it read-mostly configuration? Use Arc<T> without a lock. Immutable sharing needs no synchronization.
  4. Only after these: shared mutable state, with the lock as a decision. Choose the granularity, keep hold times short, and document the acquisition order.

Prompt. “Restructure to avoid shared mutable state: single owner with borrowed access where possible, channel-passed ownership for the pipeline, Arc without a lock for immutable configuration. If a Mutex remains, document which invariant it protects and why the state must be both shared and mutable.”

Stringly-Typed Code

Smell. String doing the job of a type: states ("connected"), identifiers, values with units in suffixes ("250ms"), enums by convention (kind: String), and HashMap<String, String> as a data model. Related: functions taking several bools, and bare f64s whose unit lives in a comment.

Why generation produces it. Strings are the shortest path from a prose spec to code. If the spec says “the status can be connected or idle”, a String transcribes that sentence directly. Defining an enum requires interpreting it.

Principle. Closed sets are enums, validated values are newtypes, and quantities carry units. Every string comparison is a place where a typo compiles, and every stringly parameter is an invariant the compiler was never told about.

Prompt. “Replace stringly-typed values: model status as an enum, wrap device_id in a newtype with a fallible constructor, and use std::time::Duration for durations. Keep strings only for freeform text.”

unwrap() on External Input

Smell. unwrap(), expect(), or panicking indexing on paths where the input isn’t controlled: parsing external data, network reads, file I/O, or lock acquisition in long-running services.

Why generation produces it. Training examples unwrap freely, because documentation and snippets legitimately do. Unwrap also makes the happy path compile immediately, and the tests the agent writes for itself rarely feed it bad input.

Principle. A panic on input is the dishonest-signature case from the fallibility page. Panics are for broken invariants, that’s, for states the design declared impossible. Malformed frames, short reads, and missing keys aren’t broken invariants. They are ordinary values from the outside world, and the type for ordinary failure is Result.

Prompt. “This function handles external input and must not panic. Return Result with a typed error for each failure mode. expect() is acceptable only for invariants that no input can violate, and each use must state in its message why it’s unreachable.”

The expect-with-justification rule works like the clone rule: every remaining use carries its own argument, where the next reviewer can check it.

Over-Abstraction

Smell. Machinery with one user: a trait with a single implementation and no test double, generic parameters only ever instantiated with one type, a Box<dyn Strategy> choosing between two behaviors that an if could choose, a factory that builds one product, layers that only forward calls.

pub trait FrameSourceFactoryProvider {
    fn factory(&self) -> Box<dyn FrameSourceFactory>;  // builds one thing
}

Why generation produces it. Abstraction is associated with good engineering in the training data, and “make it extensible” in a prompt is taken literally. Unlike the other smells, this one comes from trying to do well.

Principle. Abstraction is a bet on future variation, paid for now in indirection. Every layer is another file between the reviewer and the behavior. The decision procedure from Flexibility in Signatures applies: closed set, use an enum; genuine extension point, use a trait; otherwise, write the function.

Prompt. “Remove speculative abstraction: inline traits with a single implementation unless they are a test seam or a documented extension point. Replace the strategy objects with an enum, since the set of variants is closed. Collapse layers that only forward.”

Lifetime Smells

This is the smell most specific to Rust, and the compiler won’t flag it: code with this smell compiles.

As a rule of thumb, a lifetime in a signature should reflect a design decision. If nobody made that decision, treat the lifetime as a smell.

Tier 1: a lifetime appears at all

Smell. Explicit <'a> in application-level code:

pub struct Summary<'a> {
    hottest_id: &'a str,       // borrowed from what, exactly?
    window: &'a [Frame<'a>],   // everything above must now outlive this
}

Why generation produces it. The agent started with a borrow-based design, hit lifetime errors, and added annotations until the code compiled, instead of restructuring. This is the same behavior that produces stray clones, taking the other available exit. Neither exit answers the design question: who should own this data?

Principle. In application code, owned data is the default, and borrows are short and local. A named lifetime constrains when one value may be dropped relative to another. Taking on that constraint makes sense when zero-copy has been measured to matter, and it’s a burden everywhere else.

Tier 2: a lifetime crosses a module or API boundary

Smell. A pub type or pub fn in one module exposing a lifetime parameter that other modules must carry.

Why it’s worse. A boundary-crossing lifetime propagates: every containing struct and trait implementation must now thread <'a>, and callers inherit the constraint. It also fixes the internal representation into the public contract, so moving to owned data later is a breaking change. Borrows should live and die within a function or module, and public types should own their data, unless the type is one of the deliberate exceptions below.

Three lines are enough to watch the propagation:

struct Dashboard<'a> {          // must now carry 'a...
    latest: Summary<'a>,
}

struct App<'a> {                // ...and so must everything above it
    dashboard: Dashboard<'a>,
    log: Vec<Frame>,            // and what does 'a even borrow from?
}

The legitimate exceptions

  • View types with an owned counterpart: &str and String, Path and PathBuf, BorrowedFd and OwnedFd. The borrow is the purpose of the type, and the standard library gives you the owned twin for each.
  • Iterators and RAII guards: created from a container or a lock, and short-lived by contract.
  • Zero-copy parsing, where a measured performance need exists, with an owned escape hatch (to_owned(), Cow).

The exceptions share a property: someone made the decision, the decision is visible (documentation, an owned twin, a benchmark), and the borrow’s scope is part of the contract.

Prompt. “Remove the lifetime parameters from this module’s public API. Exported types should own their data, with borrows kept internal and short-lived. If the hot path needs zero-copy, provide a borrowed view type alongside an owned type, following the Path and PathBuf pattern.”

Exercise: Smell Catalog v1

You receive the following generated Rust module. It compiles without warnings and its test passes. (The same module is in the canscan starter at exercises/smell-sample/, if you prefer reading it in an editor.)

//! telemetry_hub: parses device status lines and caches the latest reading.
//!
//! Generated from a prose spec, not yet reviewed.
//!
//! Input line format: `<device_id>,<status>,<value>,<unit>`
//! e.g. `pump-03,connected,42.5,C`

use std::collections::HashMap;
use std::sync::{Arc, Mutex};

pub mod parser {
    /// A parsed line. Borrowed straight from the input for efficiency.
    pub struct ParsedLine<'a> {
        pub device_id: &'a str,
        pub status: &'a str,
        pub value: f64,
        pub unit: &'a str,
    }

    /// Parse one line of telemetry input.
    pub fn parse_line(line: &str) -> ParsedLine<'_> {
        let parts: Vec<&str> = line.split(',').collect();
        ParsedLine {
            device_id: parts[0],
            status: parts[1],
            value: parts[2].parse::<f64>().unwrap(),
            unit: parts[3],
        }
    }
}

/// A reading held by the hub.
#[derive(Clone, Debug)]
pub struct Reading {
    pub device_id: String,
    pub status: String,
    pub connected: bool,
    pub value: f64,
    pub unit: String,
    pub stale: bool,
}

/// Storage abstraction so we can support other backends in the future.
pub trait StorageBackend {
    fn put(&mut self, key: String, reading: Reading);
    fn get(&self, key: &str) -> Option<Reading>;
    fn keys(&self) -> Vec<String>;
}

pub struct MemoryBackend {
    map: HashMap<String, Reading>,
}

impl StorageBackend for MemoryBackend {
    fn put(&mut self, key: String, reading: Reading) {
        self.map.insert(key, reading);
    }
    fn get(&self, key: &str) -> Option<Reading> {
        self.map.get(key).cloned()
    }
    fn keys(&self) -> Vec<String> {
        self.map.keys().cloned().collect()
    }
}

/// Factory for storage backends.
pub fn create_storage_backend(kind: &str) -> Box<dyn StorageBackend> {
    match kind {
        "memory" => Box::new(MemoryBackend { map: HashMap::new() }),
        _ => Box::new(MemoryBackend { map: HashMap::new() }),
    }
}

pub struct Hub {
    backend: Box<dyn StorageBackend>,
    config: Arc<Mutex<HashMap<String, String>>>,
    devices: Vec<String>,
}

impl Hub {
    pub fn new() -> Self {
        let mut config = HashMap::new();
        config.insert("stale_after_s".to_string(), "30".to_string());
        config.insert("mode".to_string(), "normal".to_string());
        Hub {
            backend: create_storage_backend("memory"),
            config: Arc::new(Mutex::new(config)),
            devices: Vec::new(),
        }
    }

    /// Ingest a batch of raw lines.
    pub fn ingest(&mut self, lines: &[String]) {
        for line in lines.to_vec().iter() {
            let parsed = parser::parse_line(line);
            let device_id = parsed.device_id.to_string();
            let status = parsed.status.to_string();
            let reading = Reading {
                device_id: device_id.clone(),
                status: status.clone(),
                connected: status.clone() == "connected",
                value: parsed.value,
                unit: parsed.unit.to_string(),
                stale: false,
            };
            if !self.devices.contains(&device_id.clone()) {
                self.devices.push(device_id.clone());
            }
            self.backend.put(device_id.clone(), reading.clone());
        }
    }

    /// Latest reading for a device, if any.
    pub fn latest(&self, device_id: &str) -> Option<Reading> {
        let mode = self
            .config
            .lock()
            // Invariant, not input: this lock is only ever taken in short
            // scopes in this module and no holder can panic, so poisoning
            // is unreachable.
            .expect("config mutex poisoned: no holder panics while locked")
            .get("mode")
            .cloned();
        if mode == Some("disabled".to_string()) {
            return None;
        }
        self.backend.get(device_id)
    }

    /// Iterate the known device ids, in first-seen order.
    pub fn device_ids(&self) -> impl Iterator<Item = &String> {
        self.devices.iter()
    }
}

impl Default for Hub {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn ingest_and_read_back() {
        let mut hub = Hub::new();
        hub.ingest(&[
            "pump-03,connected,42.5,C".to_string(),
            "valve-07,idle,0.0,bar".to_string(),
        ]);
        let r = hub.latest("pump-03").unwrap();
        assert!(r.connected);
        assert_eq!(r.value, 42.5);
        assert_eq!(hub.device_ids().count(), 2);
    }
}

Produce one row per finding:

Smell (line refs)Principle violatedThe prompt that fixes it

Rules:

  1. Work in triage order, not file order: error types, then parameter types, then flag and Option counts, then a grep pass for clone, unwrap, and <'a>. Bodies last, if at all.
  2. A finding without a prompt is incomplete. Write the sentence you would actually send to the agent.
  3. At least one construct in the sample looks like a smell and is a legitimate use. Find it and write down why it stays.

The merged table from all pairs is your team’s Smell Catalog v1. The appendix Review Checklist is its compact form.

Project A: canscan

Including a 10 minute break, this session should take about 3 hours and 30 minutes. It contains:

SegmentDuration
Brief10 minutes
Project work, part one1 hour 15 minutes
Break10 minutes
Project work, part two1 hour 15 minutes
Debrief40 minutes

Your first project with the agent. It’s a command-line tool that reads CAN bus log files and answers questions about them, small enough to finish in an afternoon and realistic enough that everything from this morning shows up in it.

This project practices:

  • Crate evaluation. The spec names no crates. Your agent will propose some, and deciding whether to accept them is your job.
  • Judging toolchain suggestions. clippy and rustc will make suggestions. Some are right for this tool and some aren’t. The agent tends to accept all of them.
  • Spec iteration. The spec is incomplete, like most specs. When reality disagrees with it, amend it in writing and regenerate.
  • The morning’s material: skeleton first, then smell triage on the output.

Working agreement: pairs. The less experienced Rust reader drives the agent, the more experienced one reviews out loud. Swap at the midpoint. Record findings in REVIEW-LOG.md as smell, principle, and prompt. The log is an input to the debrief.

The Specification

This is the spec as handed to students, including its gaps. Amendments made during the session are part of the deliverable.


canscan v0.1: CAN log inspection CLI

canscan reads CAN bus capture logs in candump text format and produces summaries for an integration engineer.

One line of candump format:

(1712345678.123456) can0 18FF0102#DEADBEEF11223344

That’s: (timestamp) interface can_id#hex_payload, where can_id is 3 hex digits (standard, 11-bit) or 8 hex digits (extended, 29-bit), and the payload is 0 to 8 bytes of hex.

Commands

canscan summary <FILE>
    Frame count, capture duration, distinct IDs, overall frames/sec.

canscan top <FILE> --by count|bytes [--limit N]
    The top N IDs ranked by frame count or by payload bytes, with
    per-ID rates. Default limit: 10.

canscan filter <FILE> --id <ID> [--after TS] [--before TS]
    Print matching frames, preserving input format.
    If the input is rejected, filter must not have produced partial output.

Requirements

  1. Every command supports --format text|json. JSON output must be stable enough to consume from a script.
  2. Exit code 0 on success, nonzero with a clear message on failure.
  3. summary on the well-formed logs/big.log (4,590,000,000 bytes) must complete in under 60 seconds on the classroom machine.
  4. The tool is the first of a family of internal utilities. Keep the parsing and analysis logic reusable.

Fixtures

The starter repo ships pre-generated logs/small.log (1,000 lines) and logs/medium.log (100,000 lines). Run make fixtures to build the well-formed logs/big.log: exactly 90,000,000 valid candump lines and 4,590,000,000 bytes. The source archive doesn’t include this file.


End of spec.

Milestones

The milestones are ordered so that stopping at any of them still gives a useful debrief. Each milestone ends with a review gate that feeds the next.

The project work is 2 hours 30 minutes, split around the afternoon break. As a rough budget:

MilestoneBudget
M0: skeleton, by hand30 minutes
M1: summary text/JSON on small.log25 minutes
M2: summary on medium.log40 minutes
M3: top, filter, JSON, big.log40 minutes
M4 (stretch): JSON schema policy15 minutes

The budget lands at M3, including requirement 1’s basic JSON output for every command. Only 15 of the 150 minutes are left for M4, and M2’s review gate is allowed to eat them, because that’s where the learning is. If you finish the two days without touching M4, you did the project as designed.

M0: Skeleton, before any generation

Write the skeleton by hand: the core types (which newtypes?), the parsing entry points, and the error types. Bodies are todo!(). If the design answers aren’t clear yet, run the design interview from this morning in a separate session first; the skeleton itself is still written by hand.

Run make check before the review; the gate isn’t passed until it’s green. Then swap skeletons with another pair and critique for two minutes each way, using the morning’s tables.

M1: summary works on small.log in text and JSON

Let the agent fill in bodies and wire up the CLI. Exercise both required formats; schema-versioning policy is deferred, not JSON implementation.

Review gate: run the thirty-second error review on the generated code and log the findings.

M2: summary handles medium.log

“Handles” means correct output, defined behavior for whatever the file contains, and a memory profile you can explain.

Review gate: grep for unwrap, clone, and <'a>. Each hit gets a row in the review log: either kept with a justification, or fixed with a prompt. For every policy row in your review log, point to the test that would fail if the policy broke. A row with no such test is a finding.

Guardrail step (part of M2)

Whatever your M2 review banned (an unwrap() on parse results, panicking on bad lines, a helper that reads the whole file into memory), ask the same agent for the matching check in the same session:

Write a clippy.toml with disallowed-methods covering the calls we just banned, set the lint levels in main.rs, and make cargo clippy -- --deny warnings pass.

Keep the configuration in the repository. The review made the decision once, and cargo clippy now applies it to every future change. See Build Your Own Guardrails.

M3: top and filter in both formats; big.log under 60 seconds

Build in release mode before timing, so compilation is excluded. Use the well-formed 90,000,000-line big.log, then record elapsed time and peak memory on the classroom machine in the review log.

Review gate: does the requirement 4 architecture exist? Could a teammate add a new subcommand against your core API without touching the parser? Before closing M3, run summary, top, and filter with both --format text and --format json; basic JSON support is required, not stretch work.

M4 (stretch): JSON schema policy

M1 and M3 already implemented the required JSON outputs. Requirement 1 also says that JSON must be “stable enough to consume from a script”. Decide what compatibility means beyond the working schema, write it down as a spec amendment, have the agent harden it, and test it.

Debrief

The debrief takes about 40 minutes. Have your review log open.

Each pair presents (20 minutes)

Each pair gets three minutes:

  1. One smell you caught: show the code, name the principle, read the prompt that fixed it. Did the fix work?
  2. One thing you kept: a construct that looked like a smell and survived review with a written justification.
  3. Your spec amendment: what did you add when reality disagreed with the spec?

Themes (15 minutes)

  • The dependency trees. One task, several trees. What selection criteria did the group converge on for accepting an agent’s crate proposal?
  • Clippy. Where was a suggestion right, and where was following it wrong? What should the team’s policy be: who may write #[allow], and what must accompany it?
  • Reality versus the starting spec. What is the difference between the spec you were handed and the spec you finished with, and who on your real team owns that difference?

Close (5 minutes)

One sentence per pair: what will you put in the skeleton next time that you put in a review comment today?

Welcome to Day 2

Yesterday we caught design problems after the code was generated. Today we stop them before generation. Then, in the afternoon, we tackle a harder question: what does testing even mean when the system’s correct behavior is a distribution?

Day 2 at a glance

BlockDuration
Morning session3 hours 45 minutes
Lunch1 hour
Afternoon session3 hours 45 minutes

Day 2 Morning schedule

Including a 10 minute break, this session should take about 3 hours and 45 minutes. It contains:

SegmentDuration
Warm-up: three review calls15 minutes
Project B brief: framecache15 minutes
Skeleton workshop + project work, part one1 hour 15 minutes
Break10 minutes
Skeleton workshop + project work, part two1 hour 10 minutes
Debrief40 minutes

Day 2 Afternoon schedule

Including a 10 minute break, this session should take about 3 hours and 45 minutes. It contains:

SegmentDuration
Project C brief: guardian15 minutes
Validating a stochastic system25 minutes
Project work, part one1 hour 5 minutes
Break10 minutes
Project work, part two1 hour 5 minutes
Debrief and course retrospective45 minutes

Warm-up: Three Review Calls

From here on we assume yesterday’s vocabulary. Three snippets. For each one: name the smell, the principle behind it, and the one-sentence prompt you’d send.

1

let cfg = config.clone();
apply(&cfg);

2

pub fn parse(s: &str) -> Config {
    let port = s.trim().parse().unwrap();
    Config { port }
}

3

// `status: String`, and this comparison appears at four call sites:
if status == "ready" {
    start_capture();
}

Project B: framecache

This one’s a library crate, not a CLI. framecache decodes raw CAN frames into named, scaled signal values and keeps a latest-value cache that other components query. In real life, several internal teams would depend on a crate like this. That’s the kind of pressure we’re simulating.

What we’re practicing:

  • Skeleton-first as the working method. Yesterday the skeleton was the first milestone. Today it’s a deliverable: written by hand, reviewed, and frozen before generation starts.
  • Spec review under pressure. This spec, like real specs, was assembled from several stakeholders with their own concerns. Part of today’s deliverable is whatever your review of it finds, found before generation starts, not after.
  • The material from yesterday’s smell catalog. Keep it at hand.

Working agreement: same pairs, roles swapped from yesterday’s end. The review log continues, with a new section for spec findings: every question, gap, or surprise raised against the spec, with its resolution in writing.

The Specification

As handed to students. The requirements are attributed to stakeholders on purpose.


framecache v0.1: signal decode and latest-value cache

A reusable crate for tooling and simulation targets (std environment). Two capabilities: decode and cache.

Decode

A SignalDb describes messages and signals (a simplified DBC: message id to signals; each signal has a name, start bit, length, scale, offset, and unit). It’s loaded from a TOML file at startup. Given a raw frame (id plus up to 8 payload bytes), decoding yields the signal values of that message in engineering units.

Cache

The cache ingests decoded frames from one ingest thread and serves the latest value per signal to multiple reader threads.

Requirements (from the stakeholder workshop)

  1. (Platform) Frame payloads must be processed zero-copy: decoding reads bytes where the receive buffer placed them, and a cache entry keeps using those bytes in place, including after the receive-buffer slot they occupy is reused for new frames.
  2. (Tooling) A cache read returns the signal’s latest value at any time, independent of what the ingest thread is doing, and the returned value remains valid for as long as the reader holds it.
  3. (Platform) The receive buffer is a fixed ring. Buffer slots are recycled as new frames arrive.
  4. (Diagnostics) Readers may subscribe to a signal and iterate values as they arrive.
  5. (All) The public API must be documented, and a downstream team must be able to mock the cache in their tests.

Deliverables

The framecache crate, and an examples/ binary wiring a synthetic frame source through decode into the cache with two reader threads.


End of spec. Log every spec finding. Resolutions must be written down.

Working the fill

Even with the public surface frozen, fill it in slices, one ask at a time. Data types and validation first. Then core behavior. Then subscriptions and concurrency. Then tests. Review between slices. One everything-at-once commission wipes out the review points this whole setup exists to create.

Skeleton First

The working method for this morning, as a fixed sequence.

The morning’s project block is 2 hours 25 minutes, split around the break. The phases:

PhaseBudget
Phase 1: skeleton workshop45 minutes
Phase 2: cross-review15 minutes
Phase 3: generation under contract1 hour 25 minutes

Phase 1: Skeleton workshop (45 minutes, no agent)

Forty-five minutes is enough because typing a skeleton is cheap. Deciding it’s the work, and the deciding is what this phase practices.

Write by hand, in src/lib.rs and modules:

  • Every public type, with fields, or a comment explaining why the fields are private and what they will be.
  • Every public function and method signature, with todo!() bodies.
  • The error enums, one per module, implementing std::error::Error.
  • Doc comments carrying the contracts: ordering, threading, blocking behavior, and failure semantics.

cargo check must pass. A skeleton that doesn’t compile isn’t finished.

Unlike Project A’s M0, the design interview stays closed here: run its questions on yourselves. By Day 2 the questions should be yours.

As you write, apply yesterday’s tables to your own work: receivers, parameter ownership, fallibility shapes, and any lifetime that appears in anything pub. For each, ask the question from the lifetime page: who decided this, and where is the decision written down?

Phase 2: Cross-review (15 minutes)

Swap skeletons with another pair. Each side files at least three findings in the other pair’s review log. Only signature-level findings are possible at this point. A finding made now saves someone from having to make it later against two thousand generated lines.

Phase 3: Generation under contract

Freeze the skeleton. The agent fills in bodies without changing public signatures; state that constraint in every prompt. Any signature change that turns out to be needed is a design change: it goes through the pair, gets a row in the log, and amends the skeleton before regeneration. Keep the diff between the frozen skeleton and the final one; it’s a debrief artifact.

Two guardrails belong to this phase (see Build Your Own Guardrails):

  • Freeze by lint, not by vigilance. Ask the agent for the Clippy configuration that enforces what your review decided (the banned calls in clippy.toml’s disallowed-methods, the lint levels at the crate root), and require cargo clippy -- --deny warnings green before any fill is accepted.
  • If any unsafe appears in a fill, it needs two things before acceptance: a cargo +nightly miri test run and a written justification in the log. The usual outcome is a safe rewrite. Miri tells you whether the unsafe block is sound instead of leaving you to guess. If time permits, run mutest-rs on the contract tests and look at what survives. A suite that no mutant can kill isn’t testing anything.

Debrief

The debrief takes about 40 minutes. Have your skeleton diff and your spec-findings log ready.

Spec findings (15 minutes)

  • Take your log’s most consequential finding. Who found it, and when: skeleton, cross-review, or generation? What did the discovery cost at each stage?
  • Read out the spec amendments and compare resolutions across pairs.

The skeleton diff (15 minutes)

  • Each pair: the frozen skeleton against the final one. Every changed line is a design decision that arrived late. Could it have been found in Phase 1?
  • Where did a lifetime try to enter a public signature, and what stopped it: your own review, the cross-review, or the compiler?
  • Show the doc comment that most visibly steered generation.

Close (10 minutes)

Yesterday’s closing question, extended: what belongs in your team’s standard skeleton? Which types, error conventions, and doc-contract habits should every new crate start from? Write the answers down.

Project C: guardian

The last project, and the hardest. guardian is a forward-collision warning arbiter. It takes noisy object tracks from a simulated perception stack, estimates time-to-collision, and every 50 ms cycle it picks one of three answers: NoAction, Warn, or Brake.

Two things make this the hardest project of the course:

  1. The input is stochastic. The provided sensor simulator is noisy by design. The same scenario never plays out twice, so a test that asserts an exact outcome passes or fails by chance.
  2. Correctness is a distribution. The question isn’t “did it warn?” but “does it warn often enough, and false-alarm rarely enough, across many runs?” That’s also how the real systems in this domain are judged.

The next page places the project in its ISO 26262 context: the assumed ASIL, the decomposition our architecture follows, and where generated code may sit inside that structure.

Before the agent joins, your skeleton has to answer some questions in types. Same interview discipline as Projects A and B, pointed at this domain. These questions stay out of the starter on purpose: they’re for you to answer, not the agent.

  • What state does the arbiter carry between cycles, if any, and why?
  • What is the signature of one cycle’s decision?
  • Which thresholds exist, what are their types and units, and who decided their values? Using f64 everywhere is also a choice, and rarely the right one.
  • What can fail, and what counts as absence rather than failure? No tracks this cycle and one channel reporting nothing are both normal.
  • The two channels report the same world. When they disagree, which one does your design believe, and what does it do while one channel reports nothing?

The afternoon’s project block is 2 hours 10 minutes, split around the break. As a rough budget:

StageBudget
Skeleton, by hand (both channels)30 minutes
Arbiter and metrics, first working pass45 minutes
Measure, revise, re-measure40 minutes
SAFETY-NOTES15 minutes

The statistical verdict layer is a stretch goal on top of this, not a row in the budget.

In this project we practice:

  • The whole course so far, under domain pressure: the state machine is an enum (or typestate), the thresholds are newtypes, and the arbiter’s signature is the spec.
  • Testing a stochastic system: property-based tests for invariants that must always hold, and statistical verdicts, via the provided feotest, for behavior that only exists in aggregate.
  • Recognizing when the agent’s tests are green but test nothing that matters.

Guardian in the ISO 26262 Frame

guardian is a toy, but we place it where its real counterpart would live: inside an item developed under ISO 26262. Before writing code, we walk the safety structure the code would sit in.

What ASIL is this?

An automotive safety integrity level (ASIL; ISO 26262-1:2018, 3.6) is assigned per hazardous event by a hazard analysis and risk assessment (HARA; ISO 26262-1:2018, 3.76, method in Part 3, Clause 6). The HARA classifies severity, exposure, and controllability (S, E, C; Part 3, Tables 1, 2, and 3) and reads the result out of the determination table (Part 3, Table 4). For a forward collision warning (FCW) function, two hazards dominate:

  • a missed or late intervention in a real conflict, and
  • an unwarranted brake at speed, which is itself a hazard for following traffic.

For this workshop, assume the HARA lands the braking path at ASIL D and a warning-only path at ASIL B. The point isn’t these letters; it’s that the false-alarm rows in our acceptance table aren’t comfort requirements. Both directions of failure are safety-relevant.

Decomposition

An ASIL D requirement doesn’t force every element to be developed at D. ISO 26262-9:2018, Clause 5 allows decomposing an initial safety requirement into redundant requirements on sufficiently independent elements (9, 5.4.3). The schemas are fixed in 5.4.9. For D, the options are C(D) + A(D), B(D) + B(D), or D(D) + QM(D), where QM means quality management: no ASIL assigned. The notation keeps the original ASIL in parentheses, because some obligations follow the original requirement, not the decomposed element. These terms and the rest of the safety vocabulary are collected in the glossary.

Our architecture uses the B(D) + B(D) schema, the classic one for forward collision systems, with the two decomposed requirements allocated to two sensing channels built on different physics:

Safety goal: no unwarranted brake; no missed intervention   [assumed D]

   decompose per ISO 26262-9:2018, 5.4.9 a)

├─ camera channel: detection, tracking, TTC estimate        [B(D)]
│    strong on classification and lateral position;
│    degrades in low light, fog, and glare
├─ radar channel: detection, tracking, TTC estimate         [B(D)]
│    strong on range and closing speed in all weather;
│    produces ghosts from multipath and clutter
└─ combiner: cross-channel agreement and envelope checks       [D]
     small, dull, hand-written; Brake requires corroboration,
     and a confident closing track inside the braking envelope
     implies at least Warn within N cycles

The whole decomposition claim rests on independence, and here’s the catch: independence has to be analyzed, not assumed. That’s Part 9, Clause 7. Different physics buys you different failure modes. Fog degrades the camera but not the radar. The multipath ghost that fools the radar is invisible to the camera. What different physics doesn’t buy you is freedom from shared causes: common power, a shared clock, one housing heating both sensors, one software update train. Or, and this is the one that matters for us, the same generated tracking code pasted into both channels. Do that and your B(D) + B(D) collapses back into a single channel wearing two names.

The element that merges the channels keeps the original D. Someone has to own the decision. Decomposition moves rigor around; it never makes the safety goal cheaper. In this exercise both channels are real code: every sensor-sim report carries a sensor field, radar and camera watching the same world with different noise and decorrelated dropouts and ghosts. Your arbiter consumes both streams, which makes it the decision element and the combiner at once, and the combiner keeps the D. So your SAFETY-NOTES need to say how the design weighs the channels when they disagree, and what it does while one of them reports nothing. That statement is your independence argument.

Where the LLM sits, and where the guarantees sit

The generated code lives inside a decomposed B(D) channel, and the letters spell out the cost. B still means the full safety lifecycle at B rigor. The (D) means confirmation obligations that follow the original requirement around. Decomposition doesn’t exempt the agent. It just bounds what one channel’s misbehavior can do. The guarantees live in what stays human-owned: the skeleton and its types, the monitor, the review (compare the independent confirmation measures of ISO 26262-2:2018, 6.4.7 and Table 1), and the verification evidence. Our three-layer suite is a miniature of the Part 6 verification tables. Example tests are requirements-based testing (26262-6:2018, Tables 7 and 8). The properties are the plausibility end of those methods. And the statistical layer, with its recorded threshold origin, is the shape of evidence the acceptance rows demand. A code-generating agent, viewed through Part 8, Clause 11 (tool confidence, TCL), is a software tool whose output you either trust because its failures are detected downstream or qualify separately (8, 11.4.5): our detection argument is the skeleton, the monitor, and the suite.

A rating, a regulation, and a standard

Three kinds of document govern a system like this, and they argue different things. Euro NCAP is a consumer rating: it scripts test scenarios (CCRs, CCRm, CCRb), grades each grid cell, and awards FCW points when the warning comes at TTC >= 1.70 s.1 FMVSS No. 127 is a regulation: FCW required from 10 to 145 km/h when a collision is imminent, lead-vehicle avoidance up to 62 mph, on US light vehicles from September 2029.2 ISO 26262 is a standard for how you develop: it governs the process and the evidence, not the test drive. A car is developed under the standard, certified against the regulation, and rated by the program. Passing any one of them is not the other two.

One detail from the protocol is worth reading aloud, because it’s the industrial twin of what our evaluator does. The protocol defines TAEB, the moment braking activates, by signal processing: find the last data point where filtered acceleration is below -1 m/s^2, then walk back to where it first crossed -0.3 m/s^2. “When did the system act” is a definition you write down. Our latency-at-truth-crossing rule is the same move at classroom scale.


  1. Euro NCAP, “AEB Car-to-Car Test Protocol” v4.3, https://cdn.euroncap.com/cars/assets/euro_ncap_aeb_c2c_test_protocol_v43_1e6ed06def.pdf. ↩

  2. NHTSA, FMVSS No. 127, final rule May 2024, amended November 2024, https://www.federalregister.gov/documents/2024/11/26/2024-27349/federal-motor-vehicle-safety-standards-automatic-emergency-braking-systems-for-light-vehicles. ↩

The Specification

As handed to students.


guardian v0.1: forward collision warning arbiter

In the decomposed architecture from the safety page, the track stream below is the radar channel’s input, and this arbiter is that channel’s B(D) decision element.

Input

The starter repo provides sensor-sim, which produces a stream of TrackReports at 20 Hz for scripted scenarios (constant-speed lead vehicle, hard-braking lead, cut-in, empty road). Two perception channels observe the same world: each report carries a sensor field, radar or camera. Reports carry an object id, range (m), range rate (m/s), and a confidence in [0, 1]. Reports are noisy, and the channels differ: radar’s range rate is precise, the camera’s is much noisier; each channel drops tracks for 1 to 3 cycles on its own schedule; and each channel produces its own spurious low-confidence ghosts. A cycle may carry reports from both channels, one, or neither. Your arbiter consumes the merged stream and owns the decision.

Behavior

Each cycle, guardian ingests the current reports and emits exactly one Decision:

  • Brake when collision is imminent (time-to-collision below a hard threshold).
  • Warn when a collision is plausible soon (time to collision, TTC, below a soft threshold).
  • NoAction otherwise.

TTC for a closing track is range / closing_speed. Tracks with confidence below 0.3 must not, on their own, trigger Brake.

Acceptance targets (per scenario suite, over many runs)

MetricTarget
Hard-braking-lead scenarios ending in Brake>= 99%
Empty-road runs with any Warn or Brake (false alarm)<= 2%
Median cycles from threshold crossing to correct decision<= 3

Classification semantics, shared by every evaluator of this table: “ending in Brake” means Brake appears within the final 20 cycles (one second) of the run. Latency medians are computed over responders only; a run that never reaches the required decision is a miss, which lowers the corresponding rate row and contributes no latency sample. Zero responders reports n/a. Latency output states its response coverage (responders over total runs) alongside the median.

Deliverables

The guardian crate; a runner binary that executes scenario suites and reports the metrics; and a test suite you would stake the release on.


End of spec.

Protocol alignment (stretch)

Real FCW systems are tested against published protocols. Two matter here, and they’re different kinds of document. Euro NCAP’s AEB Car-to-Car protocol is a consumer rating: it scripts rear-end test scenarios and awards FCW points when the warning comes at TTC >= 1.70 s.1 FMVSS No. 127 is a US regulation: it requires FCW between 10 and 145 km/h whenever a collision is imminent, and full avoidance of a lead vehicle at speeds up to 62 mph, on new light vehicles from September 2029.2

The protocol’s CCRb scenario is close to this project’s world: two vehicles at 50 km/h, then the lead brakes at -2 or -6 m/s^2, from a headway of 12 or 40 m. Four cells.

The sim gives you the mechanism and nothing more: Scenario::Scripted(ScriptedLead { .. }) takes an initial range, an initial closing speed, a start time, a closing acceleration, and a closing-speed cap. Building the four CCRb cells out of it is your agent’s work, under your review:

  1. Ask the agent to translate the protocol cells into ScriptedLead values. Review the unit conversions and the closing-speed cap (what does a stopped lead do to closing speed when both cars started at 50 km/h?).
  2. Ask it for a grid runner: N seeded trials per cell, median TTC at first Warn per cell, measured against noiseless ground truth.
  3. Add the acceptance row: median TTC at first warn >= 1.70 s on every CCRb cell. This row has a published origin; cite it.
  4. Then ask the room’s question: your arbiter can pass this row by warning constantly. Which other acceptance row stops that?

Simplifications to note in your SAFETY-NOTES: this world is one-dimensional, so the protocol’s lateral overlap grid has no meaning here, and the sim applies the lead’s deceleration as a constant rather than the protocol’s ramp-in tolerance.

Scope note

The statistical verdict layer (the feotest tests) is stretch work. The required finish line is the property-test layer plus a passing 1,000-trial metrics run with replayable failure seeds. If you get the statistical layer standing too, excellent; don’t start it before the metrics pass.


  1. Euro NCAP, “AEB Car-to-Car Test Protocol” v4.3 and “Assessment Protocol, Safety Assist, Collision Avoidance”, https://cdn.euroncap.com/cars/assets/euro_ncap_aeb_c2c_test_protocol_v43_1e6ed06def.pdf. FCW points are awarded at TTC >= 1.70 s. ↩

  2. NHTSA, FMVSS No. 127, final rule May 2024, amended November 2024, https://www.federalregister.gov/documents/2024/11/26/2024-27349/federal-motor-vehicle-safety-standards-automatic-emergency-braking-systems-for-light-vehicles. ↩

Validating a Stochastic System

Here’s the testing toolbox for a system with random input. Three layers, plus a fourth for when a property matters enough to prove.

Layer 1: Example tests

assert_eq!(decide(&reports), Decision::Brake) has a place: golden scenarios with noise disabled, pinning the deterministic core. But an agent asked for “tests” usually produces only this layer, often by seeding or disabling the randomness so the suite passes. Deterministic tests of a stochastic system only verify as much as the determinism covers.

Layer 2: Property tests

Some statements have to hold for every input, noise included. That’s what a property-testing library is for. The starter ships proptest. It generates inputs and, when a property fails, shrinks the failure down to a minimal counterexample:

proptest! {
    // A strictly closer and faster-closing situation never yields a
    // less severe decision.
    #[test]
    fn severity_is_monotone(base in track_report(), d in 1.0..50.0f64) {
        let closer = base.closer_by(d);
        prop_assert!(decide1(&closer) >= decide1(&base));
    }
}

Good properties for guardian: severity monotonicity, low-confidence tracks never causing Brake alone, a defined decision (no panic) for any report including NaN ranges, and an output present every cycle.

Layer 3: Statistical verdicts

The acceptance table says at least 99% of hard-brake scenarios end in Brake. That’s not a property of any single run; it’s a claim about a pass rate. Testing it by running 20 times and looking at the results gives you flaky CI and little confidence. The next page covers treating each run as a trial and testing the rate itself.

The table’s median-latency row needs a different analysis: retain every responder latency, report misses separately as response coverage, compute the median over responders as specified, and attach a bootstrap or documented distribution-free interval. A Bernoulli verdict may evaluate whether a run met a latency threshold, but that’s a different claim from the median row.

Layer 4: Mutation testing and proofs

Two stretch tasks for pairs with time left. Both come from Build Your Own Guardrails:

  • Mutation-test the metrics. Run mutest-rs over the arbiter and metrics code. Each surviving mutant marks a claim your tests never check. Ask the agent to explain each survivor, then add a test that kills it or write down why it’s equivalent. A green suite is evidence only after this step.
  • Build the protocol grid. The SPEC’s protocol alignment section has the four Euro NCAP CCRb cells and the sourced warn row. The sim gives you Scenario::Scripted; your agent builds the cells and the grid runner, and you review the unit conversions. One feotest verdict per cell, colour-graded, is the classroom version of the protocol’s per-point grading.
  • Prove one invariant with Kani. Pick a property that must hold for every input, not only the sampled ones. “An empty report slice never yields Warn or Brake” is a good first choice. Ask the agent to write the #[kani::proof] for it. The result is stronger than a passing test: not “we found no failing input” but “no input of this shape exists”. Verus goes further when a contract deserves a full specification. Knowing that it exists is enough for today.

feotest: Statistical Verdicts

feotest is the probabilistic testing framework that ships in the starter. The idea: treat each execution as a Bernoulli trial, pass or fail against a contract, and issue a verdict about the pass rate. It uses Wilson score intervals rather than a raw average, which is what you want at these sample sizes.

Use it for the two rate rows in the acceptance table: hard-braking success and empty-road false alarms. It also fits the protocol grid from the SPEC’s stretch section: one verdict per cell is how Euro NCAP grades its test points, at classroom scale. The latency row isn’t Bernoulli. Report latency response coverage plus the responder median, and quantify uncertainty with a bootstrap confidence interval or another documented distribution-free method. Don’t feed a median into a pass/fail-rate tool and call the result evidence for the median.

The entry point fits the runner directly:

use feotest::model::ContractViolation;
use feotest::probabilistic_test;

#[probabilistic_test(samples = 100, threshold = 0.99, threshold_origin = "slo")]
fn hard_braking_lead_ends_in_brake() -> Result<(), ContractViolation> {
    let outcome = run_scenario(Scenario::HardBrakingLead, Noise::default());
    if outcome.final_decision == Decision::Brake {
        Ok(())
    } else {
        Err(ContractViolation::new("no-brake", "run ended without Brake"))
    }
}

A single failing run counts as one trial rather than failing the test. The verdict is about the rate across all samples, and the threshold’s origin is recorded. Recognized origins are "sla", "slo", "policy", and "empirical"; other strings are stored as unspecified.

The workflow that comes with the tool

feotest’s intended workflow is measure, derive, test: baseline the behavior over many trials, derive a statistically grounded threshold from the baseline, then verify against it with an affordable sample count. This raises the question the acceptance table is really asking: 99% verified with how many samples, at what confidence? The framework offers three ways to answer (threshold-first, sample-size-first, confidence-first) instead of letting “run it 20 times” stand in for an answer.

Its statistical assumptions are stated in its documentation, and you should check them against guardian: trials roughly independent and stationary, binary outcomes, controlled conditions.

Note: feotest is early-stage and its API is marked unstable. Evaluate it the way you evaluated crates yesterday. For today it’s pinned in the starter’s lockfile.

Debrief

The debrief takes about 30 minutes. The course retrospective follows on the next page.

The chatter (10 minutes)

  • Show the worst decision trace of the afternoon. What did you change to eliminate it, where does that live in the types, and what did it cost on the latency target?
  • Which corner of the noise / false-alarm / latency triangle did you concede, and who at work should be making that call?

The test suite (15 minutes)

  • Each pair: count your suite by layer (example, property, statistical). What did the agent’s first suite look like by the same count?
  • Show the best property of the day. What bug does it catch that no example test could?
  • Your statistical verdicts: samples, threshold, confidence, and why those numbers. If you hit the flipping-verdict problem, what did it teach you about the difference between “we test it” and “we can make a claim about it”?

Closing question (5 minutes)

The acceptance table judged guardian in aggregate. The processes that judge your real systems work the same way, and increasingly so do the ones that judge LLM-produced code. What changes in your team’s definition of “tested” on Monday?

Course Close and Retrospective

The retrospective takes about 15 minutes. Before people scatter, collect everything the two days produced into one repository.

Artifacts produced

  • Smell Catalog v1: the merged table from Day 1, plus what the review logs added over the two days.
  • The skeleton standard: Project B’s closing list of types, error conventions, and doc-contract habits every new crate should start from. A cargo generate template is the natural next step.
  • The testing ladder: example, property, statistical, with Project C’s layer counts as the baseline and the sample-size question as an open item.
  • A working definition of review: smell, principle, prompt, with the rule that a recurring prompt gets promoted into the skeleton.

The three skills, restated

  1. Specify architecture in types and signatures, by hand. Bodies are generated; skeletons are written. The skeleton is the spec, and the compiler enforces it.
  2. Recognize design problems quickly. Triage by signatures, types, and smells before bodies. A mix of conventions is a finding on its own.
  3. Turn findings into prompts, and turn recurring prompts into skeleton changes.

Retrospective (one sentence per person, three rounds)

  • Something that clicked.
  • A sticking point you hit and will now recognize.
  • One practice you’ll introduce in the team’s real workflow this month, stated concretely enough that a colleague could check.

The Review Checklist

The course in one printable page. Work top to bottom. Each line is about a 30-second check.

Signatures first

  • Parameter ownership honest? &str to read, String to keep, &mut rare and deliberate.
  • Fallibility honest? Result where failure exists; a -> T that can panic does so only on documented invariant violations, never on input; Option only where absence is normal.
  • Receivers meaningful? self by value for transitions, &self for observation, interior mutability justified.
  • Any <'a> in a pub signature: who decided it, and where is the decision written down? A boundary-crossing lifetime with no named exception (view type with owned twin, iterator, guard, measured zero-copy) is a red flag.

Types second

  • Closed sets modeled as enums, not strings or flag sets. Count the bools and Options; check whether they are one state machine.
  • Validated values as newtypes with fallible constructors. Parse at the boundary; the type carries the proof.
  • Quantities carry units. IDs aren’t bare primitives.
  • Costly wrong-state calls prevented by construction (typestate) where the state graph is small and stable.
  • Paired manual calls (begin/end, acquire/release) replaced by guards with Drop.

Errors third

  • One convention per crate: libraries enumerate their errors in dedicated enums, applications aggregate into one opaque error with context. A mix of conventions is a finding on its own.
  • Context attached at boundaries; the chain reads causally.
  • unwrap/expect only on invariants, each with a message saying why it’s unreachable. Never where input arrives.

Smells last (grep pass)

  • clone: each survivor has a comment justifying two owners.
  • Arc<Mutex: each survivor names the invariant it protects and why the state is shared and mutable.
  • dyn , single-implementation traits: survivors are test seams or documented extension points.
  • Tests: count the ladder (example / property / statistical). A green suite that can’t detect the regressions you care about isn’t evidence.

Always

  • A finding without a prompt is incomplete.
  • A recurring prompt is a skeleton defect. Promote it.

Prompt Patterns for Rust

The prompts used in this course, collected. Each one delivers a design decision. Make the decision first; the prompt is the delivery.

Setting the contract

Fill in the todo!() bodies without changing any public signature or type definition. If an implementation seems to require a signature change, stop and explain why instead of changing it.

Treat the doc comments as requirements. Ordering, threading, and failure semantics stated there are contracts, not suggestions.

Ownership and lifetimes

Remove clones that exist only to satisfy the borrow checker. Prefer borrowing; move where transfer is the intent. Each remaining clone gets a one-line comment justifying two independent owners.

Remove lifetime parameters from this module’s public API. Exported types own their data; borrows stay internal and short-lived. If the hot path needs zero-copy, provide a borrowed view type alongside an owned type, following the Path and PathBuf pattern.

Restructure to avoid shared mutable state: single owner with borrowed access, channel-passed ownership for the pipeline, Arc without a lock for immutable configuration. Any remaining Mutex documents the invariant it protects.

Types

status is a closed set. Model it as an enum with data on the variants, and let match be exhaustive, with no _ arm.

Introduce CanId as a newtype with a fallible constructor. Raw u16 must not appear past the parse boundary.

These states have costly wrong-state calls. Use the typestate pattern: one type per state, transitions consume self.

begin_maintenance and end_maintenance are a paired-call hazard. Return a guard from begin whose Drop performs end, and remove the manual end.

Errors

Define one DecodeError enum for this module, implementing std::error::Error with a source() chain. Convert all String and Box<dyn Error> returns to it, and attach context at each ? crossing a module boundary.

This function handles external input and must not panic. expect() only for invariants no input can violate, and each use must state in its message why it’s unreachable.

Abstraction

Remove speculative abstraction: inline single-implementation traits unless they are a test seam or a documented extension point. The strategy objects become an enum, since the variant set is closed. Collapse layers that only forward.

Tests

Produce a three-layer suite: (1) golden example tests with noise disabled, pinning the deterministic core; (2) property tests for invariants, starting with severity monotonicity and no-panic-on-any- input; (3) probabilistic tests for each acceptance-table row, with samples, threshold, and threshold origin stated. Use the property and probabilistic testing libraries this project already depends on. Do not seed or disable randomness in layer 3.

Glossary

Safety vocabulary used in Project C, with pointers into ISO 26262:2018. Definitions here are paraphrased; the standard’s own wording is in the cited entries of ISO 26262-1:2018 (the vocabulary part).

ISO 26262 terms

  • ASIL, automotive safety integrity level (26262-1, 3.6): one of four levels, A through D, D the most demanding, that scales the rigor required of an element; assigned per hazardous event by the HARA.
  • HARA, hazard analysis and risk assessment (26262-1, 3.76; method in 26262-3, Clause 6): the analysis that classifies each hazardous event by severity, exposure, and controllability and derives an ASIL and a safety goal from them.
  • S, E, C: the three HARA classification axes: severity of harm, probability of exposure to the situation, and controllability by the people involved (26262-3, Tables 1, 2, and 3; ASIL determination in Table 4).
  • Safety goal (26262-1, 3.139): the top-level safety requirement for a hazardous event; everything below it refines this.
  • Safety mechanism (26262-1, 3.142): a technical measure that detects or controls faults to keep the item in a safe state; our monitor element is one.
  • ASIL decomposition (26262-1, 3.3; method in 26262-9, Clause 5): splitting a safety requirement into redundant requirements on sufficiently independent elements, using the schemas of 26262-9, 5.4.9.
  • X(Y) notation, as in QM(D) or B(D): the element is developed at level X, while the parenthesized Y records the ASIL of the original requirement before decomposition, because some obligations still follow the original.
  • QM, quality management: no ASIL assigned; the element is handled by normal quality processes rather than the safety lifecycle.
  • Confirmation review (26262-2, 6.4.7 and Table 1): a review of a key safety work product by someone with a required degree of independence from its authors.
  • Dependent failures (analysis per 26262-9, Clause 7): failures of supposedly independent elements that aren’t independent in fact, from shared causes (power, clock, housing, update train) or cascades; the analysis that an ASIL decomposition’s independence claim rests on.
  • TCL, tool confidence level (26262-8, Clause 11): the outcome of evaluating a software tool: whether its possible malfunctions matter and whether they would be detected, which determines if the tool needs qualification (26262-8, 11.4.5 and 11.4.6).

Domain terms

  • FCW, forward collision warning: the vehicle function guardian arbitrates for.
  • TTC, time to collision: range divided by closing speed, the quantity the arbiter thresholds.
  • EMA, exponential moving average: the smoothing filter in the reference decision channel.
  • Channel: one of the redundant sensing-and-decision paths in the decomposed architecture; this workshop’s exercise builds the radar channel, with the camera channel specified on paper.

Further Reading

The course this one builds on

  • Comprehensive Rust: the fundamentals this course assumes. Its Idiomatic Rust section covers the patterns from Day 1 morning in more depth, from the author’s side.

Patterns

  • Parse, don’t validate (Alexis King): makes the case for parsing input into richer types at the boundary. The newtype page is based on it.
  • Rust API Guidelines: recommendations on how to design APIs. Useful as a checklist for a skeleton’s public surface.
  • Rust Design Patterns: covers the newtype pattern, RAII guards, and builders, and includes an anti-pattern catalog.
  • The Typestate Pattern in Rust (Cliff Biffle): describes the typestate pattern in depth, with worked examples.

Errors

  • thiserror: derive macros for defining error enums.
  • anyhow: an opaque error type with context chaining, for applications.
  • Error Handling in Rust (BurntSushi): covers error handling in detail, from Option and Result up to library design.

Testing stochastic systems

  • proptest: property-based testing with input generation and shrinking.
  • feotest: probabilistic testing with statistical verdicts. The README includes a section on the statistical basis of the verdicts.
  • criterion: statistics-driven benchmarking. Useful for questions like Project B’s “did zero-copy matter here at all?”

Safety-critical Rust